isometric-motion
Build animations and interactive elements (explainers, figures, hero visuals, step-through walkthroughs) in the crafted isometric "Anatomy" style, with many small precise solid parts, four-tone shading, correct painter's order, a framed card with title, hint and live readout, and
Install
npx skills add https://github.com/dzhng/skills/tree/main/skills/graphics/isometric-motion
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install dzhng-skills@llmmart
git clone https://github.com/dzhng/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole dzhng/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Isometric Motion
Copied from wheresryan22/anatomy (MIT, see
LICENSE) and repurposed for animations and interactive elements in the same style.
This skill makes figures like a small instrument on a bench: a heavy base on feet, rails with tick rulers, a carriage on bushings, knurled knobs, coil springs, a dial gauge whose needle moves, screws as tiny dots. The reader understands the idea because they recognise the machine. Every number the figure shows comes from the same model that moves its parts.
Nine complete examples ship with the skill. Open the closest one before you start. Each framework-free example is built with node examples/<name>/build.mjs, which writes a standalone HTML page next to the script; add --light for the light theme.
examples/dial-indicator/is the smallest: a dial gauge on a stand, about 300 lines in all.examples/desk-computer/is framework-free SVG: a desk computer with a keyboard you can type on. The typed line appears on a CRT screen drawn in the plane of the case front, and the picture collapses to a line and a dot when you switch it off. It has about 85 solids and 850 paths.examples/arcade-cabinet/is an upright arcade cabinet with its side panel off, a Hero of about 240 solids and 15 cables. A quarter flies into the slot, rolls down the coin chute at the speed a rolling disc reaches on a 30° slope, trips the coin-switch wire and drops into the cash box; the switch's pulse runs chunk by chunk along its cable to the board, and the CRT warms up, self-tests and runs an attract mode drawn in the plane of the tilted monitor. The stick tilts and closes a microswitch under the panel. It shows how to place a camera so a cutaway's mechanism stays in view, a concave board drawn as a prism along any axis, moving parts rebuilt in the page from the same module the build uses, and live coins and a wire drawn in reserved slots of the settled order.node build.mjs --auditproves the geometry.examples/raptor-engine/is a rocket engine hanging in a test stand. Its round parts come fromkit/lathe.mjsand its pipes and harness fromkit/tube.mjs. It comes apart into 11 assemblies with staggered springs and per-frame depth re-sorting. Every part records its true shape, andnode build.mjs --audit(kit/audit.mjs) proves, at rest, apart and in every replayed frame of the explode, that nothing passes through anything, that every end sits on a real mount, that no bend pinches, folds or lands on another line, that no curved profile's slope wobbles, and that every overlap is drawn in depth order. A WebGL plume, shock, splash and steam render between the back and front SVG. It is the reference for dense pipework, smooth shading and fades.examples/ripple-tank/is a school ripple tank on tall legs over a paper screen, seen from 44° up. Two WebGL layers (lamp light, shadows and caustics on the paper, and the water's reflections and glass) are registered to the isometric planes and driven by a pure dispersion and optics model (model.mjs); a frequency change travels out from the dippers through a history texture.examples/next-token/is the abstract-concept example: how an LLM picks its next token, drawn as a sampler on a bench. The prompt is written on a terminal screen in the plane of its front, the five candidate words are on keycaps, chip stacks on a ruled board are as tall as each word's probability, a knurled temperature knob reshapes them, and a carriage stops at a seeded random u on a strip where the probabilities lie end to end. The model (model.mjs: softmax, inverse-CDF pick, a seedable generator) drives every number.examples/test-rig/is a production figure from a real site (React/Next): a glass button on a spring-mounted carriage, a finger probe on a rail that presses and pulls, and a dial gauge reading the lean. It has 53 solids and about 470 paths.examples/spot-plate/is a second production figure from the same site: six pairs of glass drops on a spot plate, each pair drawn from one distance field, so a pair closer thanmergeruns together into one piece of glass. A fan of feeler blades setsmerge, one blade per gap. It is the reference for shapes that morph into each other and for one control driving many parts.examples/turning-dial/is the optional 3D mode: a combination dial that turns in true 3D with a small robot hand on it. It has about 90 live parts in metal materials, a second turn group, a hinge, a three-hinge finger that lifts and a pod that breaks off as a free body. A WebGL LED, its light pool and sparks follow the moving parts.stress.mjsbeside it is a dense hand of about 380 parts in a cluttered workshop, used to measure performance.
The test rig is the quality bar for the drawing itself. The Raptor is the bar for shader work: a WebGL effect has to reach its standard of world-space, no-pop, seamless rendering.
The one idea that matters
The object is the explanation. Don't draw boxes, arrows and labels. Invent a real, well-made physical thing whose working is the concept. Here is how earlier figures mapped:
| Concept | Object |
|---|---|
| One shared GPU context serving many canvases | A gantry plotter: one print head visiting 16 wells |
| A cache key that skips work when nothing changed | A pin-tumbler lock: when every pin matches, the plug turns and nothing is redrawn |
| Seven render targets with different bytes per texel | A chest of seven drawers, each as deep as its bytes |
| A separable blur (49 × 49 taps vs 49 + 49) | A bed-of-nails stamp beside two combs on rails |
| A spring that presses and leans | A test rig with springs, a probe and a dial gauge |
| A height profile | A profile (contour) gauge pressed against the edge |
| Light that depends on facing | A small photo studio: key and fill lamps around a turntable |
Abstract concepts may bend this rule. When the subject is not a machine at all (a model choosing its next token, a ranking, a data flow), you may draw the concept itself as crafted isometric parts instead of inventing a physical metaphor. Keep everything else: solid volumes, many small precise parts, four-tone shading, painter's order, the framed card and live readout, and calm eased motion driven by one model. Never fall back to flat boxes, arrows and labels. examples/next-token/ shows how:
- Words are parts. When the concept is made of words (tokens, labels, queries), put them on faces in the plane of a part: keycap tops, a screen set into a case front, the top of a stack. Size them to read at 1× (about 7 world units on a keycap) and fit them to the face; that is the exception to keeping words out of the drawing.
- Continuous parameters get a real control. A temperature, a rate or a threshold is a knurled knob with a ticked dial ring (or a slider carriage on a ruled rail), turned by dragging on it, by the wheel over it and by the arrow keys. No HTML range inputs or buttons inside the card.
- Randomness is seeded. Draw from a small seedable generator in the model, never
Math.random, and expose a hook (window.__nextToken.roll(u)) that sets the draw, so a screenshot or a test can replay any outcome. The motion of a random event stays calm: an eased glide to the result, then the lit part, not a tumbling die. - Checks for a small figure. With no pipes, cables or round parts to prove, skip the audit; the line check in both themes and in every resting pose (after a draw, at the extremes of the control), desktop and 390 px captures, a contact sheet of the event and 4× close-ups are what apply.
Two ways this fails, both seen in practice:
- Too abstract. Stacked plates, floating slabs or labelled boxes don't read as anything.
- Too flat and bland. One big slab with a few lines on it has no volume, no parts and nothing to discover.
The fix for both is the same: a recognisable machine (for an abstract concept, a recognisable instrument made of the concept's own parts), built from many small, precise, purposeful parts.
Every creation is highly detailed. This holds for every figure, however small the idea or the request: a quick explainer is still a dense, real machine, never a few slabs. What scales with the size (workflow step 1) is how far you push it: a Figure gets the 4× sweep over its subject and mechanism, a Hero or an Epic over the whole drawing.
- Read the real thing. Build it the way the real object is built: every screw, seam, bevel, bushing, label plate, cable with its connector, scale with its ticks, and the parts that hold the parts.
- Two looks. At 1× it reads as one clear machine. At 4× every region still has something true to find; a bare face or an empty corner is unfinished work, not calm.
- Purposeful, never noise. Detail comes from parts that belong to the mechanism. Each one sits on another part, ends on an edge, and fades rather than stops.
- Check it last. Before you finish, zoom to 4× (over the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic) and add the real parts that are missing wherever a region is plain.
Workflow
Work in this order. Write the concept and the parts list before any geometry. Most of the quality is decided there.
- Size it before you start. Name the size before any other work, and tell the user roughly how long it will take when the tier below calls for an estimate.
- Figure: one mechanism, 40–80 parts, SVG with at most one WebGL layer. Name the size and build it without asking. Do not quote a time to the user, since a Figure often finishes in well under an hour. For the progress checks below, budget up to an hour.
- Hero: a dense object, several WebGL layers or one 3D subject, 80–150 parts. About 2–4 hours. Say so, then build it unless the user asked for something quick.
- Epic: a whole vehicle, building or plant with many moving systems, or the 3D mode on more than one subject. 6 hours or more. Do not start it. Give the estimate, propose a Hero version that keeps the core of the idea, and list what the full version would add. Build the Epic only when the user chooses it.
- Whatever the size, have a working page that opens in the browser within the first hour, and deepen it from there. Run
dateat the start and at each step, and when a step runs past twice its share of the estimate, tell the user where it stands and what is left.
- Truth first. Collect the real numbers and behaviour of the thing being explained: sizes, counts, thresholds, curves. The readout, motion and caption must all be true. If you can, write the model as a small pure module, like
rig.tsin the test-rig example, and drive everything from it. - Invent the object. Pick a machine, tool, instrument or piece of furniture whose mechanism maps one-to-one onto the concept. Say in one sentence what moves and what that motion means. Never copy a reference image: take its finish, not its object.
- Parts list. Write at least 40 parts (40–80 for a Figure, 80–150 for a Hero, more for an Epic), grouped: base (plate, feet, groove, corner screws, label plate, ruler strip), structure (posts, rails, frame bars, end stops), mechanism (carriage, bushings, springs, plunger, needle), controls (knurled knob, thumb screw, crown) and the subject itself, which is the one lit part. Each part sits on another part.
references/craft.mdhas a vocabulary of parts with recipes. - Plan the world. Use world units of about 1 px of the real thing: x and y on the floor, z up. Lay out plans as rounded rectangles
{x, y, w, d, r}and circles. Choose the camera: azimuth 45° by default, 35–60° to show the long side, elevation 30° (higher, like the ripple tank's 44°, to look into something open). Then fit withfitProjection(points, width, height, { pad, azimuth }). - Build geometry once. Precompute every static path in a geometry module with the kit:
slabOf,cylinder,extrude,planOutline,topTicks,knurl,coil,radialTicksand so on. Round parts on any axis (bells, flanges, valves, actuators) come fromkit/lathe.mjs, and pipes and cables fromkit/tube.mjs. Moving parts are built once at a rest position and moved withtranslateAlong. Only parts whose shape changes are rebuilt per frame. - Paint back to front, bottom to top. Sort repeated parts with
depthOf. Split anything that wraps another part into a back half and a front half: the springs around a rod, the bars of a frame around a carriage. A moving part is drawn in the slot where it physically sits. See "Painter's order" inreferences/craft.md. Once a figure has pipes, cables and round parts, stop tuning keys by eye: record each item's shape as you put it intoA.recorder(P, { order, spring }), letA.settle(R, P)measure true depth wherever two items overlap and set the keys, and end the build withA.auditOrExit(R, P). Route pipes before you draw them: real ports at both ends, legs along the structure, crossings on straights, collars gapping the pipe (references/craft.md, section 8). Record the true shape of what you draw: a stand-in shape that silences the audit is a bug, and the audit fails one. A rod that slides out of a gland is one rod fromG.rodOut, recorded withA.slide. - Frame it. Put the figure in a card:
Fig nat top left, a short title at top right, the interaction hint at bottom left and the live readout at bottom right. Legend keys and a true caption go under the card, and words stay out of the drawing apart from at most one tiny engraved label. - Make it live. Use calm time-based motion: exponential follow or real springs. An idle tour loops when nobody touches it. The figure responds to pointer and keyboard, stops when offscreen, and goes still under reduced motion. Write attributes on refs and don't re-render per frame. See
references/motion.md. - Verify like a critic. The owner finds faults by zooming in, so you zoom in first. Read every PNG you make.
- Capture desktop, phone (390 wide), hover and several moments of motion with
scripts/capture.mjs; usescripts/drive.mjsfor sequences, the apart pose and the--no-webglfallback. - Run the audit to zero. With pipes, cables or round parts,
node build.mjs --auditmust endaudit passed, every failing line at 0: clearance at rest, apart and in mid-flight, terminals, loose ends, folds, self-overlap, crowding (against slender solids too), stand-in shapes, support, wobble, saddles and depth order. Fix an interlock in the geometry, never with a key bias. - Run the line check to zero. The audit never sees line markup.
node scripts/lines.mjs page.html --out shots/lines.png(and on the-lightpage, and in every resting pose withdrive.mjs's["lines"]action) must printline ends: 0: every seam, rule, rib, tick and pointer ends on a stroke, a dot or an outline. Read the ringed PNG. - 4× close-ups of every junction in both themes (build
--lighttoo), assembled and apart: every flange, tee, elbow, clamp, valve, bellows, band, saddle, ball joint and crossing, every curved body (no tone staircase), every place a texture ends (on an edge or fading), flat box faces with their seams, every textured hose at a chunk joint, every sliding rod at rest and at full stroke, and every window with text or shader content in it. - Shaders: a contact sheet slowed with
["slow", 0.05]across every transition; nothing pops, no colour switch, edges read 0. In light, also log ink per frame through each transition (it must follow the light monotonically) and crop where the ink meets strokes at 8–16× (references/verify.md, "Light-theme shaders"). - Put your PNG next to the examples and ask: is it instantly a real object, rich in small parts with volume, with every line ending on something? Is it highly detailed at 1× and still rewarding at 4×, with no bare face or empty corner? Iterate until yes. Checklist:
references/verify.md.
Setup
The kit is in kit/:
| File | Use it for |
|---|---|
kit/iso-kit.ts |
The whole core in one file: camera, solids, a parts library, an SVG string renderer, card and page builders, and the CSS (ISO_CSS). Copy it into a TypeScript project. |
kit/iso-kit.mjs |
The same core compiled to plain ESM, for Node scripts. Import it directly. |
kit/iso.css |
ISO_CSS written out. Import it once and put class="iso" (plus data-theme="light" for light) on a wrapper or <body>. |
kit/react/draw.tsx |
Server-safe React components: Solid, Line, Wire, Dots, FaceText, IsoFigure, Plate. |
kit/react/live.tsx |
"use client" hooks: useReadout, useLoop, useEased, useInView, useStillness, plus spring and follow. |
kit/lathe.mjs |
Round parts along any axis: frameAlong, lathe (smooth shading switches on by itself for any profile with a smooth knot), solidOf, disc, ringBand, sphereOf, saddleOf, bandsOf (one curved body cut into depth-sortable bands that share one surface, with one outline from sidesOf), visible arcs, ribs and bolt circles on round surfaces (ribsOf and dotsOf take fade, ribsOf takes seams), rodOut for a rod that slides out of a gland, and fillet for pipe routes. |
kit/tube.mjs |
tubePieces splits a 3D pipe or cable into depth-sortable chunks (with breaks, gaps under collars and clamps, elliptical end caps, and rings for a braid, corrugation or wrap that follows the cross-section through every joint), and tubeSvg draws one; crossingsOf finds where a route passes through a clamp, runsOf the drawn runs. Its .tb-* fills are in ISO_CSS. |
kit/audit.mjs |
Proves the geometry. recorder(P, { order, spring }) collects items, shapes (turned bodies, slabs, prisms, boxes, balls, tubes, pads cut by their host) and routes as the helpers draw them; settle(R, P) ray-casts every overlapping pair for true depth, at rest, apart and in the replayed explode, and sets the painter's keys (overlapsOf, orderKeys); auditOrExit(R, P) is the --audit hook that runs every check (clearances, solidClearances, terminals, looseEnds, supports, folds, selfOverlaps, crowding with slender solids as lines, coverage for stand-in shapes, wobble, seats, depthOrder) and exits non-zero on any problem. A.slide records a rod that slides out of a gland. references/kit.md, section 12. |
kit/gl.mjs |
The WebGL layer for shaders inside the drawing: GLSL_ISO (the camera in GLSL, including rayOf and pixelOf), glCamera(P), glLayer(...), and the JS envelopes smooth, burst and settle. For 3D figures, GLSL_TURN and glslPose(name) bring world points into a moving group and mask a front canvas with coverOf. |
kit/turn.mjs, kit/turn-build.mjs, kit/turn-audit.mjs |
The optional 3D mode. turn-build.mjs has turning(P, { recorder }): groups (turn, slide, hinge, free), live layers, convex parts with materials, and T.build. turn.mjs is the runtime: TURN.mount, the controller, TURN.cover, TURN.poseUniforms and TURN_CSS. turn-audit.mjs has orbitOrExit, the orbit audit over every turn angle and motion clip. references/3d.md. |
scripts/capture.mjs |
Headless Chrome screenshots, close-ups and console checks, with WebGL on. Run with node. |
scripts/drive.mjs |
Scripted headless Chrome with WebGL: move, click, key, slow motion, wait-until, eval, shots and contact sheets that catch pops; --no-webgl and --preload. |
scripts/lines.mjs |
The line-end check on a built page, in headless Chrome: every texture line must end on a stroke, a dot or an outline. Rings the failures with --out; exits 1 on any. |
scripts/inline-kit.mjs |
kitScript(), glScript() and turnScript() return the kit, the WebGL layer and the 3D runtime as source to inline into a standalone page. |
scripts/turn-check.mjs, scripts/turn-bench.mjs |
3D figures: fidelity against the kit builders, a z-buffer order check, pixel pops, line ends at 24 angles and frame timing in the browser; Node CPU time per part kind. |
Pick the path that fits the project:
- Standalone HTML, a static SVG, a blog post or any non-React site. Write a Node build script like
examples/dial-indicator/build.mjs. It importskit/iso-kit.mjs, assembles strings withsolidSvg,lineSvg,dotsSvg,wireSvgandfaceTextSvg, wraps them withfigureSvgandplateHtml, and writes the page withpageHtml({ script: kitScript() + live }), orkitScript() + glScript() + livewhen it has a shader. For a lone.svgfile usefigureSvg({ standalone: "dark" | "light" }), which embeds the styles. - React or Next.js. Copy
kit/into the project (for examplecomponents/iso/). Keep geometry in plain.tsmodules, build the static art in a server component with thedraw.tsxcomponents, and pass it as element props to one"use client"live component that moves things.references/react.mdexplains the split and a trap with large pages.
Craft rules (short form)
These are the rules that separate a crafted figure from a diagram. references/craft.md gives the full reasoning and recipes.
- Solids, not wireframes. Every part is filled in paper colour and painted in order, so things in front hide things behind. No see-through edges and no dashed hidden lines.
- Let the kit shade.
extrudereturns four side tones by facing plus a lighter top. Keep it for anything with height, and useflatonly for floors and inlays thinner than 1 unit. - Tone hierarchy. Base and feet are
lo, structure ismidand the subject ishi. Exactly one accent islit(white in dark, black in light): the part the reader should watch. Grooves arefaint, minor tickslo, major ticksmidand the zero markhi. Use colour only when colour is the subject, such as red, green and blue channels. - Small precise detail, never noise. Use bevels of 0.4–1.6, grooves inset 3–5 from an edge, screws as 0.4–0.55 dots with a ring at the corners of plates, knurls on knobs, ruler ticks every 4–5 units with a major every 5th, and a label plate with three rule lines. Each detail belongs to a part.
- Lines resolve. Every line ends on something: a face, another line or a terminal dot. Connectors are
Wires whose terminal dots land on objects. There are no leader lines, balloons, dimension arrows or labels floating in the air. - No pipe or cable passes through anything. Not through another pipe, cable or solid, at rest, apart or in any frame of the explode. Keep about 1.5 radii of clearance to everything but a line's own connection, run lines beside the structure rather than across the subject's face, and gap a pipe wherever a flange, band, bellows, clamp or valve wraps it. Parts meet flush, tangent or rim-on-surface, never sunk in: a flange's back face lands on its port (on a saddle pad,
G.saddleOf, when the port is curved), a branch leaves from a ball tee, and every end, apart too, finishes on a flange, coupling, connector shell or terminal dot. - Every crossing is drawn in true depth order. Where two items overlap on screen, the nearer is drawn on top, proved by
A.settleand the audit, not tuned by eye. Lines cross on straights, well apart in depth: a bend, end or clamp that lands on another line's outline reads as a joint (a rod, tie rod, post or rail counts as a line: a pipe's last leg never runs along one on screen), a leg that points at the viewer and then turns pinches to a V (leave such a port through an elbow ball), and a route never doubles back on screen. A pair that can't be ordered interlocks, and the fix is a cut or a tangent contact, never a bias. - Curved surfaces shade smoothly, never in a staircase. Mark curved knots smooth (
[s, r, 1]) andlatheshades them from one monotone curve that also draws the outline; give any curved body larger than a fitting an analytic profile withsmooth: { slope }, cut long bodies withG.bandsOf, and useG.sphereOffor balls. Tone bands taper to nothing instead of ending on a straight cut, and seams go under the outline (inner). - A texture ends on a real edge or fades. Ribs, bolt circles, rule lines, seams and grooves never stop in the middle of a surface, flat or curved:
ribsOfanddotsOftakefade(draw faded ribs withfadedSvg), a rib row ends on its seams (ribsOf(…, { seams: true })), a seam or weld on a box wraps its corners (k.sideSeam, as the slab'sinner), a line on one face runs edge to edge, vents are closed slots, ticks hang from a ruler strip's edge, a single line that must stop ramps out withfadeLineSvg, and a row whose features face up (holes on top of a ring) is seen all round (all: true). A texture on a pipe (braid, corrugation, wrap) follows its cross-section and runs through every chunk joint:tubePieces(…, { rings }), never a dashed stroke down the centreline. A pipe's shine and shade run onto its end caps and through every chunk joint. Only rule lines that stand for printed text, a needle's tail and a pointer's tip may stop on a bare face, markedfree. - Shaders never pop or cut off. Every uniform rides an envelope with an attack and a decay, phase changes are crossfades driven by the physical quantity, every term reaches zero before any bound or canvas edge, and the effect lives in world space in the figure's own camera (see below).
- Things on faces fit them. Text a machine displays fits its window at its widest string and is clipped to it; content seen through an opening is clipped by the opening as well as by its own plane, with the recess walls drawn.
- Volume. Give things real thickness, make them stand on other things, and put a soft halo under the base and under anything lifted.
- Composition. Centre the figure and let it fill about 55–65% of the card. Keep a consistent scale across a set of figures, and make sure it still reads at 390 px wide.
WebGL inside the drawing
Use a shader only for what lines can't draw: fire and exhaust, water and caustics, real lamp light, glow, steam. The parts stay SVG. The core rules come from the first shader figure, whose colour change between phases looked cut rather than crossfaded:
- Three layers. A back SVG holds what the effect covers, the canvas holds the effect (
pointer-events: none), and a front SVG holds what covers it. Split a part, or the effect, when it is on both sides. - World space, same camera. Build every shader point from
onFloor,onWallXandonWallY, or march the pixel's rayrayOf(vb) + t·towardViewer(). Size everything in world units timesuK. Anything lying on a plane registers exactly with that plane's SVG lines. - Nothing pops. Every uniform follows an envelope with an attack and a decay (the kit's
burstfor events,settlefor levels). Phase changes are crossfades where colour shifts continuously with the physical quantity, and a fade-out only gets darker. - No hard cut-offs. Every term reaches zero before any
if, bound or canvas edge, and before any cell edge of a cell noise. - Seen through an opening, masked twice. Content behind a window or a recess is masked in its own plane and by the opening in the face plane, multiplied (
references/webgl.md, section 5). - Blending. Use
tonemap()and dither. In dark, a canvas that only adds light uses premultiplied output withmix-blend-mode: screen, and one that must also darken (the ripple tank's water) stays normal-blended. - Light theme ships, and its shaders are designed for paper. Use normal premultiplied alpha, never screen. Write the colour the paper should show and solve for the ink. A glow is a saturated body with a pale core, a coloured rim and an alpha floor, never a darkened dark-theme colour: that gave the Raptor's "red ink smoke".
- What the light falls on takes a tint, and the cavity that holds it gets a darker physical ground (soot, refractory, water depth).
- Hot bodies follow one blackbody ink table shared with the fallback.
- Ink only grows with light: fade the finished ink once, never one term at a time.
- Ink ends at each stroke's inner edge.
- Measure ink per frame.
references/webgl.md, section 8, has the recipes and the faults reviewers found.
- Restraint. Cap the device ratio at 1.5 and render only while the effect is active and onscreen. Reduced motion gets one still frame, and the SVG must still read without WebGL.
Read references/webgl.md before writing any shader.
3D: figures that turn (beta)
3D is an option like WebGL. Use it when a part moving in depth is the idea: a dial or turntable that turns, fingers that flex, a piece that breaks off and falls. The rest of the figure stays static kit art.
It is in beta: the engine is proved on its example and one test figure, not yet on a production figure. Turn one subject at a time and keep the clutter around it static. A small figure runs at 60 fps; a dense 380-part flexing hand measured about 20 fps at 1440 and 13–15 fps on a phone profile (390 px, 4× CPU throttle) on a heavily loaded machine.
- Live parts are the kit's own picture. Each frame, convex kit primitives (prisms, rounds, domes, balls with flats, lathes, tubes) are re-projected and re-shaded by the kit's rules. A turn by θ is the kit drawing at azimuth
a − θ. - Order comes from separating planes. Same-group pairs get a plane fixed in the group, pairs on different links get one fixed in their common frame, and the rest are solved each frame. The audit proves there are no cycles at any angle or in any clip frame.
- Contacts are flush, tangent or rim-on-flat. Joints are coaxial knuckle discs on the child group that touch the parent's link, or the audit calls the whole child group loose. No live line spans two moving groups: a cable crosses a joint through a clip on each side, or reaches the turntable through a slip ring at the hub.
- Live tubes bend in near-horizontal planes or inside fittings, and never point along the view. A bend needs R ≥ 2r / sin²(e − θmax), where θmax includes the pitch range of every hinge it rides: 8r is the bound when flat, and on a ±12° hinge it is 21r. Each route runs from mount surface to mount surface, with its ports in
gaps. That holds at every angle and in every pose. - Materials remap the ramp. Gold, chrome, steel, gunmetal, rubber, brass and copper each have a light and a dark set; tones still come from facing.
- Free groups break off without a jump. Coaxial parts are fixed pictures with turning textures.
- Shaders follow the motion. Anchor them to a group with
controller.anchorortoGroup, occlude a front canvas withcoverOf, and keep world effects (smoke, falling sparks, oil on the bench) out of group space. - Verify with
--audit(turn audit passed) andscripts/turn-check.mjs(--fidelity,--order,--pops,--lines,--perf). Iterate with--quick --only=…. Only a full run proves the figure, and it takes 15–20 minutes for 30 parts, so run it in the background.
Read references/3d.md first.
References
Read these as you need them:
references/craft.md: the look in depth, including the parts vocabulary with code, tone tables, painter's order patterns, round parts, pipes and harnesses, exploded views, screens on faces, and the anti-patterns that got figures rejected. Read it before designing.references/kit.md: the API, with signatures and examples.references/motion.md: live behaviour (loops, springs, tours, pointer, keyboard, accessibility, reduced motion), plus envelopes for one-shot events, exploded views, depth re-sorting and arming, in vanilla JS and React.references/react.md: the React/Next.js server/client split, element props and the large-payload key-warning trap.references/verify.md: thecapture.mjsanddrive.mjscommands, the review checklist (including shaders) and common faults.references/webgl.md: shaders inside the drawing: when one earns its place, the layer stack, the camera contract, ray-marching in the iso camera, the full API with a minimal page, the craft rules with the bugs that taught them, the light theme (inks on paper, per-phenomenon recipes, the reviewers' faults), and how to verify them.references/walkthrough.md: how the test rig was designed, step by step, from concept to code.references/3d.md: the optional 3D mode: groups and poses, live layers, convex parts and their rules, materials, free groups, hinge chains, painter's order, static clutter around a turntable, shaders that follow the turn, the orbit audit, browser checks, performance and limits.
Files (skills)
-
examples
-
arcade-cabinet
-
arcade-cabinet-light.html 984.3 KB · in bundle
-
arcade-cabinet.css 3.1 KB · in bundle
-
arcade-cabinet.html 984.3 KB · in bundle
-
build.mjs 50.3 KB · in bundle
-
live.js 20.8 KB
const D = __DATA__; const P = D.P; const K = { iso, pathOf, solidSvg, lineSvg, dotsSvg }; const PARTS = makeParts(K, G, P, { stick: D.stick }); const stage = document.querySelector(".iso-stage"); const svg = stage.querySelector("svg"); const $ = (sel) => svg.querySelector(sel); const $$ = (sel) => [...svg.querySelectorAll(sel)]; const at = (p) => iso(p, P); const readout = document.querySelector("[data-readout]"); const status = document.querySelector("[data-status]"); const still = matchMedia("(prefers-reduced-motion: reduce)"); const el = { upper: $('[data-part="stick-upper"]'), lower: $('[data-part="stick-lower"]'), wire: $('[data-part="wire"]'), fall: $('[data-part="coin-fall"]'), chute: $('[data-part="coin-chute"]'), fly: $('[data-part="coin-fly"]'), pile: $('[data-part="pile"]'), screen: $('[data-part="screen"]'), raster: $('[data-part="raster"]'), dot: $('[data-part="dot"]'), test: $('[data-mode="test"]'), attract: $('[data-mode="attract"]'), steps: $$("[data-step]"), formation: $('[data-part="formation"]'), aliens: $$("[data-alien]"), ship: $('[data-part="ship"]'), shot: $('[data-part="shot"]'), boom: $('[data-part="boom"]'), score: $('[data-part="score"]'), insert: $('[data-part="insert"]'), start: $('[data-part="start"]'), credit: $('[data-part="credit"]'), meter: $('[data-part="meter"]'), glass: $('[data-part="glass"]'), stack: $$("[data-stack]"), fire: $('[data-button="fire"]'), p1: $('[data-button="p1"]'), lamps: $$(".ac-lamp"), leds: $$(".ac-led"), marquee: $$('[data-name="marquee.tube"], [data-name="marquee.plexi"], .ac-marquee'), }; const routes = {}; for (const g of $$("[data-route]")) (routes[g.dataset.route] ??= []).push(g); for (const list of Object.values(routes)) list.sort((a, b) => a.dataset.chunk - b.dataset.chunk); const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); }; const lerp3 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; const norm = (v) => { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; }; const GRAV = 1962; const FLY = 0.9; const INSERT = 0.34; const MECH = 0.42; const DROP_FROM = D.mech.z0 + PARTS.COIN_R; const FLOOR = D.box.z0 + 0.7 + PARTS.COIN_T / 2; const HEATER = 2.2; const TEST = [1.0, 1.7, 2.3, 2.8]; const ATTRACT_AT = 3.6; const HKHZ = 15.734; const state = { power: 0, powerAt: -99, offAt: -99, credits: 0, meter: 4127, coins: [], pile: 0, stack: D.tray.count, stick: { x: 0, target: 0 }, fire: 0, fireHeld: 0, pulses: [], wire: { x: 0, v: 0, push: 0 }, game: null, clock: 0, }; function newGame() { return { step: 0, dir: 1, offset: 0, frame: 0, alive: D_FORMATION.map(() => true), shipX: 112, shot: null, boom: null, score: 0, cool: 1.2, userAt: -99, respawn: 0 }; } const D_FORMATION = SCREEN.FORMATION; const STACK_AT = (n) => [D.tray.x, D.tray.y, 1.6 + (n - 1) * D.tray.step + 0.2]; const SLOT_OUT = [D.slot.x, D.slot.y + 9, D.slot.z]; const SLOT_IN = [D.slot.x, D.slot.y - 12, D.slot.z]; const CH = D.chute; const SN = 0.5; const CS = Math.sqrt(3) / 2; const chuteZ = (y) => CH.z0 - CH.slope * (CH.y0 - y); const ROLL_FROM = D.mech.y0; const ROLL_A = (2 / 3) * GRAV * SN; const ROLL_LEN = (ROLL_FROM - CH.y1) / CS; const T_ROLL = Math.sqrt((2 * ROLL_LEN) / ROLL_A); const V_END = ROLL_A * T_ROLL; const END = [D.slot.x, CH.y1 - PARTS.COIN_R * SN, chuteZ(CH.y1) + PARTS.COIN_R * CS]; const EDGE_Z = FLOOR + PARTS.COIN_R; const T_DROP = (-V_END * SN + Math.sqrt((V_END * SN) ** 2 + 2 * GRAV * (END[2] - EDGE_Z))) / GRAV; const LAND_Y = END[1] - V_END * CS * T_DROP; function insertCoin() { if (state.stack <= 0) state.stack = D.tray.count; const from = STACK_AT(state.stack); state.stack--; showStack(); state.coins.push({ t: 0, from, landAt: pileSpot(state.pile + state.coins.length), switched: false, landed: false }); run(); } function pileSpot(i) { const a = i * 2.39996; const r = Math.min(5, 1.1 * Math.sqrt(i)); const lo = D.box.y0 + 0.7 + PARTS.COIN_R + 0.3; const hi = D.box.y1 - 0.7 - PARTS.COIN_R - 0.3; return [D.slot.x + Math.cos(a) * r * 1.2, Math.max(lo, Math.min(hi, LAND_Y + Math.sin(a) * r)), FLOOR + Math.floor(i / 12) * PARTS.COIN_T]; } function coinPose(c) { let t = c.t; if (t < FLY) { const s = smooth(0, 1, t / FLY); const mid = [(c.from[0] + SLOT_OUT[0]) / 2, (c.from[1] + SLOT_OUT[1]) / 2, Math.max(c.from[2], SLOT_OUT[2]) + 26]; const a = lerp3(c.from, mid, s); const b = lerp3(mid, SLOT_OUT, s); const p = lerp3(a, b, s); const turn = smooth(0.15, 0.85, t / FLY); return { layer: "fly", p, axis: norm(lerp3([0, 0, 1], [1, 0, 0], turn)), spin: s * 7 }; } t -= FLY; if (t < INSERT) return { layer: "fly", clip: "door", p: lerp3(SLOT_OUT, SLOT_IN, smooth(0, 1, t / INSERT)), axis: [1, 0, 0], spin: 7 }; t -= INSERT; if (t < MECH) return { layer: "none", stage: "mech" }; t -= MECH; if (t < T_ROLL) { const s = 0.5 * ROLL_A * t * t; const y = ROLL_FROM - s * CS; const p = [D.slot.x, y - PARTS.COIN_R * SN, chuteZ(y) + PARTS.COIN_R * CS]; return { layer: "chute", clip: p[1] + PARTS.COIN_R > D.mech.y0 ? "mech" : null, p, axis: [1, 0, 0], spin: 7 + s / PARTS.COIN_R, stage: "roll" }; } t -= T_ROLL; const land = c.landAt; if (t < T_DROP) { const share = t / T_DROP; const p = [END[0] + (land[0] - END[0]) * share, END[1] - V_END * CS * t, END[2] - V_END * SN * t - 0.5 * GRAV * t * t]; p[1] = END[1] + (land[1] - END[1]) * share; return { layer: "fall", p, axis: [1, 0, 0], spin: 7 + ROLL_LEN / PARTS.COIN_R + t * 20, stage: "drop" }; } const tl = t - T_DROP; const tip = smooth(0, 0.16, tl); const lie = [land[0], land[1], land[2] + PARTS.COIN_R * (1 - tip)]; return { layer: "fall", p: lie, axis: norm(lerp3([1, 0, 0], [0, 0, 1], tip)), spin: 7, settled: tl > 0.22, stage: "drop" }; } const CLIP_DOOR = (() => { const a = at([D.slot.x, D.slot.y, -100]); const b = at([D.slot.x, D.slot.y, 300]); return `M${a[0]} ${a[1]}L${b[0]} ${b[1]}L${b[0] + 400} ${b[1]}L${a[0] + 400} ${a[1]}Z`; })(); const CLIP_MECH = (() => { const a = at([D.slot.x, D.mech.y0, -100]); const b = at([D.slot.x, D.mech.y0, 300]); return `M${a[0]} ${a[1]}L${b[0]} ${b[1]}L${b[0] - 400} ${b[1]}L${a[0] - 400} ${a[1]}Z`; })(); svg.querySelector("defs") || svg.insertAdjacentHTML("afterbegin", "<defs></defs>"); svg.querySelector("defs").insertAdjacentHTML("beforeend", `<clipPath id="ac-clip-door"><path d="${CLIP_DOOR}"/></clipPath><clipPath id="ac-clip-mech"><path d="${CLIP_MECH}"/></clipPath>`); function drawCoins() { const out = { fly: "", chute: "", fall: "" }; for (const c of state.coins) { const pose = coinPose(c); if (pose.layer === "none") continue; const coin = PARTS.coin(pose.p, pose.axis, pose.spin); out[pose.layer] += pose.clip ? `<g clip-path="url(#ac-clip-${pose.clip})">${coin}</g>` : coin; } for (const key of ["fly", "chute", "fall"]) if (el[key].innerHTML !== out[key]) el[key].innerHTML = out[key]; } function showStack() { el.stack.forEach((g, i) => (g.style.display = i < state.stack ? "" : "none")); } function drawPile() { let out = ""; for (let i = 0; i < state.pile; i++) out += PARTS.coin(pileSpot(i), [0, 0, 1], i * 1.7, { lit: false }); el.pile.innerHTML = out; } function wireStep(dt, push) { const w = state.wire; if (push > w.x) { w.x = push; w.v = 0; } else { const k = 900; const c = 9; for (let i = 0; i < 4; i++) { const h = dt / 4; w.v += (-k * w.x - c * w.v) * h; w.x += w.v * h; } if (Math.abs(w.x) < 1e-3 && Math.abs(w.v) < 1e-2) (w.x = 0), (w.v = 0); } const from = D.wire.from; const angle = Math.atan2(w.x, D.wire.length); const tip = [from[0] + D.wire.length * Math.cos(angle), from[1], from[2] - D.wire.length * Math.sin(angle)]; el.wire.innerHTML = lineSvg(pathOf([at(from), at(tip)]), { tone: w.x > 0.3 ? "lit" : "hi", free: "end" }); return w.x !== 0; } function wirePush() { let push = 0; const wz = D.wire.from[2]; const wy = D.wire.from[1]; for (const c of state.coins) { const pose = coinPose(c); if (pose.stage !== "roll") continue; const dy = wy - pose.p[1]; if (Math.abs(dy) >= PARTS.COIN_R) continue; const low = pose.p[2] - Math.sqrt(PARTS.COIN_R ** 2 - dy * dy); push = Math.max(push, Math.min(1.2, wz - low)); } return Math.max(0, push) * (D.wire.length / (D.slot.x - D.wire.from[0])); } function pulse(route, delay = 0, seconds = 0.5) { if (!routes[route]) return; state.pulses.push({ route, t: -delay, seconds }); } function drawPulses(dt) { const hot = new Set(); state.pulses = state.pulses.filter((p) => { p.t += dt; if (p.t < 0) return true; const list = routes[p.route]; const at = (p.t / p.seconds) * (list.length + 1); list.forEach((g, i) => Math.abs(i - at + 0.5) < 1.1 && hot.add(g)); return p.t < p.seconds * 1.25; }); for (const list of Object.values(routes)) for (const g of list) g.toggleAttribute("data-hot", hot.has(g)); return state.pulses.length > 0; } function setPower(on) { if (!!on === !!state.power) return; state.power = on ? 1 : 0; if (on) { state.powerAt = state.clock; state.game = newGame(); } else { state.offAt = state.clock; state.credits = 0; } el.leds.forEach((g) => g.toggleAttribute("data-on", !!on)); el.lamps.forEach((g) => g.toggleAttribute("data-on", !!on)); status.textContent = on ? "Coin accepted. The cabinet wakes and the CRT warms up." : "Switched off."; run(); } function marqueeLevel(t) { if (!state.power) return 0; if (t < 0.06) return 0; if (t < 0.12) return 1; if (t < 0.24) return 0; if (t < 0.3) return 1; if (t < 0.42) return 0.2; return 1; } function picture() { const t = state.clock - state.powerAt; const off = state.clock - state.offAt; let bright = 0; let sx = 1; let sy = 1; let dot = 0; let mode = "none"; if (state.power) { bright = smooth(0.9, HEATER + 0.4, t); mode = t < ATTRACT_AT ? (t > 0.9 ? "test" : "none") : t < ATTRACT_AT + 0.08 ? "none" : "attract"; } else if (off < 1.2 && state.offAt > 0) { bright = 1; mode = state.lastMode; if (off < 0.09) sx = 1 - 0.99 * smooth(0, 0.09, off); else if (off < 0.26) (sx = 0.01), (sy = 1 - smooth(0.09, 0.26, off)); else (sx = 0), (sy = 0); dot = off < 0.2 ? 0 : 1 - smooth(0.26, 1.1, off); } if (state.power) state.lastMode = mode; el.raster.setAttribute("transform", `scale(${Math.max(sx, 0.001).toFixed(3)} ${Math.max(sy, 0.004).toFixed(3)})`); el.raster.style.opacity = (sx === 0 ? 0 : bright).toFixed(3); el.dot.style.opacity = dot.toFixed(3); el.test.style.display = mode === "test" ? "" : "none"; el.attract.style.display = mode === "attract" ? "" : "none"; el.steps.forEach((s, i) => (s.style.display = t > TEST[i] ? "" : "none")); el.glass.toggleAttribute("data-on", bright > 0.05 && sx > 0); const m = marqueeLevel(t); el.marquee.forEach((g) => g.toggleAttribute("data-on", m > 0.5)); return { mode, t, busy: state.power ? t < ATTRACT_AT + 0.2 : off < 1.2 }; } const SHOT_SPEED = 220; function gameStep(dt, mode) { const g = state.game; if (!g || mode !== "attract") return; g.step += dt; if (g.step > 0.32) { g.step = 0; g.offset += g.dir * 2; if (Math.abs(g.offset) >= 16) g.dir = -g.dir; g.frame = 1 - g.frame; el.formation.setAttribute("transform", `translate(${g.offset} 0)`); for (const a of el.aliens) { a.children[0].style.display = g.frame ? "none" : ""; a.children[1].style.display = g.frame ? "" : "none"; } } const user = state.clock - g.userAt < 3; if (user) g.shipX += state.stick.x * 420 * dt; else { const live = D_FORMATION.map((a, i) => (g.alive[i] ? a.x + 5 + g.offset : null)).filter((x) => x !== null); const target = live.length ? live[Math.floor((state.clock * 0.37) % live.length)] : 112; g.shipX += Math.max(-50 * dt, Math.min(50 * dt, target - g.shipX)); } g.shipX = Math.max(14, Math.min(210, g.shipX)); el.ship.setAttribute("transform", `translate(${g.shipX.toFixed(1)} ${SCREEN.SHIP_Y})`); g.cool -= dt; if (!g.shot && ((state.fire > 0 && user) || (!user && g.cool < 0))) { g.shot = { x: Math.round(g.shipX), y: SCREEN.SHIP_Y - 6 }; g.cool = 0.9 + Math.random() * 0.8; } if (g.shot) { g.shot.y -= SHOT_SPEED * dt; let hit = -1; D_FORMATION.forEach((a, i) => { if (!g.alive[i] || hit >= 0) return; const x = a.x + g.offset; if (g.shot.x >= x && g.shot.x <= x + 10 && g.shot.y <= a.y + 8 && g.shot.y + 5 >= a.y) hit = i; }); if (hit >= 0) { g.alive[hit] = false; el.aliens[hit].style.display = "none"; const a = D_FORMATION[hit]; g.boom = { x: a.x + g.offset, y: a.y, t: 0.22 }; g.score += a.kind === "eye" ? 30 : 10; el.score.setAttribute("d", SCREEN.text(String(g.score).padStart(5, "0"), 16, 20)); g.shot = null; } else if (g.shot.y < 30) g.shot = null; } el.shot.style.display = g.shot ? "" : "none"; if (g.shot) el.shot.setAttribute("transform", `translate(${g.shot.x} ${g.shot.y.toFixed(1)})`); if (g.boom) { g.boom.t -= dt; el.boom.style.display = g.boom.t > 0 ? "" : "none"; el.boom.setAttribute("transform", `translate(${g.boom.x} ${g.boom.y})`); if (g.boom.t <= 0) g.boom = null; } if (g.alive.every((a) => !a)) { g.respawn += dt; if (g.respawn > 1.2) { g.respawn = 0; g.alive = g.alive.map(() => true); el.aliens.forEach((a) => (a.style.display = "")); } } const blink = Math.floor(state.clock / 0.5) % 2 === 0; el.insert.style.display = !state.credits && blink ? "" : "none"; el.start.style.display = state.credits && blink ? "" : "none"; el.p1?.toggleAttribute("data-blink", state.credits > 0 && blink); } let creditShown = -1; function showCredit() { if (creditShown === state.credits) return; creditShown = state.credits; el.credit.setAttribute("d", SCREEN.text(`CREDIT ${Math.min(9, state.credits)}`, 120, 274)); } const TILT = 0.21; let stickShown = null; function stickStep(dt, calm) { const s = state.stick; const before = s.x; s.x = calm ? s.target : s.x + (s.target - s.x) * (1 - Math.exp(-dt / 0.05)); if (Math.abs(s.x - s.target) < 1e-3) s.x = s.target; const key = s.x.toFixed(3); if (key !== stickShown) { stickShown = key; el.upper.innerHTML = PARTS.stickUpper(s.x * TILT, 0); el.lower.innerHTML = PARTS.stickLower(s.x * TILT, 0); } return s.x !== s.target || before !== s.x; } const firePush = axisVector(P, D.stick.nc.map((v) => -v * 1.1)); function fireStep(dt) { state.fireHeld = Math.max(0, state.fireHeld - dt); const want = state.fireHeld > 0 ? 1 : 0; state.fire += (want - state.fire) * (1 - Math.exp(-dt / 0.03)); if (Math.abs(want - state.fire) < 0.01) state.fire = want; el.fire.setAttribute("transform", state.fire ? `translate(${(firePush[0] * state.fire).toFixed(2)} ${(firePush[1] * state.fire).toFixed(2)})` : ""); el.fire.toggleAttribute("data-down", state.fire > 0.5); return state.fire !== want || state.fireHeld > 0; } let phase = ""; function say(mode, t) { let text; const coin = state.coins.find((c) => !c.landed); if (coin) { const tt = coin.t; text = tt < FLY ? "coin · 24.26 mm quarter" : tt < FLY + INSERT ? "coin · into the slot" : tt < FLY + INSERT + MECH ? "mech · checking the coin" : coin.switched ? `coin switch · meter ${String(state.meter).padStart(6, "0")}` : "coin · rolling down the chute"; } else if (!state.power) text = state.clock - state.offAt < 1.2 && state.offAt > 0 ? "off · picture folds to a line" : `off · ${state.pile ? "insert another coin" : "insert coin"}`; else if (mode === "test") text = t < TEST[1] ? "self-test" : t < TEST[2] ? "self-test · RAM OK" : "self-test · RAM OK · ROM OK"; else if (mode !== "attract") text = `warming · heater ${Math.min(HEATER, t).toFixed(1)} s`; else { const dir = state.stick.target < 0 ? " · stick ←" : state.stick.target > 0 ? " · stick →" : state.fireHeld > 0 ? " · fire" : ""; text = `credit ${state.credits} · ${HKHZ} kHz${dir}`; } if (readout.textContent !== text) readout.textContent = text; const now = !state.power ? "off" : mode; if (now !== phase) { phase = now; if (now === "attract") status.textContent = `Attract mode, ${state.credits} credit${state.credits === 1 ? "" : "s"}.`; if (now === "test") status.textContent = "Self-test."; } } let frame = 0; let last = 0; let visible = false; let touring = !still.matches; let tourClock = 0; let idle = 0; const TOUR = [ [1.4, () => insertCoin()], [9.5, () => press("right", 0.5)], [10.4, () => press("left", 0.6)], [11.4, () => fireButton()], [12.6, () => press("right", 0.35)], [13.3, () => fireButton()], [15.2, () => insertCoin()], [24, () => setPower(0)], [27.5, null], ]; let tourIndex = 0; function tourStep(dt) { tourClock += dt; while (tourIndex < TOUR.length && tourClock >= TOUR[tourIndex][0]) { const action = TOUR[tourIndex][1]; tourIndex++; if (action) action(); else { tourClock = 0; tourIndex = 0; state.pile = 0; state.stack = D.tray.count; showStack(); drawPile(); } } } let releaseTimer = 0; function press(dir, seconds) { state.stick.target = dir === "left" ? -1 : 1; if (state.game) state.game.userAt = state.clock; clearTimeout(releaseTimer); releaseTimer = setTimeout(() => ((state.stick.target = 0), run()), seconds * 1000); run(); } function fireButton() { state.fireHeld = 0.16; if (state.game) state.game.userAt = state.clock; run(); } function tick(now) { const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60; last = now; const calm = still.matches; state.clock += dt; if (touring) tourStep(dt); for (const c of state.coins) { c.t += calm ? 10 : dt; const pose = coinPose(c); if (!c.switched && (calm || (pose.stage === "roll" && pose.p[1] < D.wire.from[1]))) { c.switched = true; state.meter++; el.meter.textContent = String(state.meter).padStart(6, "0"); pulse("wire.coin", 0, 0.45); pulse("wire.meter", 0, 0.25); if (!state.power) { setTimeout(() => { setPower(1); pulse("cable.power", 0, 0.5); pulse("cable.video", 0.5, 0.5); }, calm ? 0 : 420); } setTimeout(() => { state.credits++; showCredit(); run(); }, calm ? 0 : 450); } if (!c.landed && (pose.settled || calm)) { c.landed = true; state.pile++; drawPile(); } } state.coins = state.coins.filter((c) => !c.landed); drawCoins(); const wireBusy = wireStep(dt, wirePush()); const pulsing = drawPulses(dt); const { mode, t, busy } = picture(); gameStep(dt, mode); showCredit(); const sticking = stickStep(dt, calm); const firing = fireStep(dt); say(mode, t); const alive = touring || state.coins.length || wireBusy || pulsing || busy || sticking || firing || (state.power && !calm); frame = visible && alive ? requestAnimationFrame(tick) : 0; } function run() { if (!frame && visible) { last = 0; frame = requestAnimationFrame(tick); } } function takeOver() { if (touring) { touring = false; } clearTimeout(idle); idle = setTimeout(() => { if (still.matches) return; touring = true; tourClock = state.power ? 4 : 0; tourIndex = TOUR.findIndex(([t]) => t > tourClock); if (state.power) tourIndex = TOUR.findIndex(([t]) => t > 9); tourClock = state.power ? 9 : 0; run(); }, 12000); } new IntersectionObserver((entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); }, { rootMargin: "120px 0px" }).observe(stage); stage.addEventListener("keydown", (event) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const key = event.key; if (key === "c" || key === "C" || key === "5") { event.preventDefault(); takeOver(); insertCoin(); } else if (key === "ArrowLeft" || key === "ArrowRight") { event.preventDefault(); takeOver(); state.stick.target = key === "ArrowLeft" ? -1 : 1; if (state.game) state.game.userAt = state.clock; clearTimeout(releaseTimer); run(); } else if (key === " " || key === "Enter") { event.preventDefault(); takeOver(); state.fireHeld = 0.5; if (state.game) state.game.userAt = state.clock; run(); } else if (key === "Escape") { event.preventDefault(); takeOver(); setPower(0); } }); stage.addEventListener("keyup", (event) => { if (event.key === "ArrowLeft" || event.key === "ArrowRight") { state.stick.target = 0; run(); } if (event.key === " " || event.key === "Enter") state.fireHeld = Math.min(state.fireHeld, 0.06); }); svg.addEventListener("click", (event) => { const hit = event.target.closest('[data-part="tray"], [data-name^="door"]'); if (hit) { takeOver(); insertCoin(); stage.focus({ preventScroll: true }); } }); showStack(); drawPile(); showCredit(); wireStep(0, 0); picture(); say("none", 0); if (still.matches) touring = false; -
parts.mjs 4.2 KB · in bundle
-
screen.mjs 6.5 KB · in bundle
-
-
desk-computer
-
build.mjs 22.5 KB · in bundle
-
desk-computer-light.html 231.8 KB · in bundle
-
desk-computer.html 231.8 KB · in bundle
-
-
dial-indicator
-
build.mjs 14.6 KB · in bundle
-
dial-indicator-light.html 133.3 KB · in bundle
-
dial-indicator.html 133.3 KB · in bundle
-
-
next-token
-
build.mjs 35.5 KB · in bundle
-
model.mjs 1.7 KB · in bundle
-
next-token-light.html 226.3 KB · in bundle
-
next-token.html 226.3 KB · in bundle
-
-
raptor-engine
-
build.mjs 69 KB · in bundle
-
live.js 30 KB
const D = __DATA__; const root = document.querySelector(".rp"); const stage = root.querySelector(".rp-stage"); const front = stage.querySelector(".rp-front"); const engine = front.querySelector(".rp-engine"); const canvas = stage.querySelector(".rp-plume"); const veilCanvas = stage.querySelector(".rp-veil"); const glowGroup = stage.querySelector(".rp-glow-g"); const flashVeil = stage.querySelector(".rp-flash"); const live = root.querySelector("[data-live]"); const readout = root.querySelector("[data-readout]"); const fireButton = root.querySelector("[data-fire]"); const fireLabel = root.querySelector("[data-fire-label]"); const partButtons = [...root.querySelectorAll(".rp-part")]; const still = matchMedia("(prefers-reduced-motion: reduce)"); const nodes = [...engine.querySelectorAll(".it")].map((el) => ({ el, piece: el.dataset.piece, ride: el.dataset.ride || el.dataset.piece, key: Number(el.dataset.k), m: el.dataset.m.split(",").map(Number), index: Number(el.dataset.i), shown: "", })); for (const node of nodes) node.lift = node.m[0] * D.V[0] + node.m[1] * D.V[1] + node.m[2] * D.V[2]; let order = nodes.map((_, index) => index); const IDS = Object.keys(D.pieces); const STAGGER = D.stagger; const SPRING = D.spring; const piece = Object.fromEntries(IDS.map((id) => [id, { x: 0, v: 0, target: 0, pending: null }])); const clamp = (value, low, high) => Math.min(high, Math.max(low, value)); const fmt = (value) => Math.round(value).toLocaleString("en-US"); const pct = (value) => `${(value * 100).toFixed(2)}%`; stage.style.setProperty("--gx", pct(D.exit[0] / D.W)); stage.style.setProperty("--gy", pct((D.exit[1] + 6) / D.H)); let clock = 0; let apart = false; let selected = null; let inside = false; let leaveTimer = 0; let touring = !still.matches; let tourClock = -1; let idleTimer = 0; let visible = false; let frame = 0; let last = 0; let jitter = [0, 0]; const VENT = 0.7; const MAIN = 1.25; const STEADY = 9; const PREBURNER_PC = 0.13; const LONGEST = D.plumeLength + 60; const glide = () => ({ x: 0, v: 0 }); const burn = { on: false, armed: false, tour: false, t: -1, stopAt: Infinity, stopping: -1, pc: 0, pcRate: 0, hue: glide(), glowStop: null, lastGlow: 0, popped: false, phase: "", pre: glide(), vent: glide(), smoke: glide(), reach: glide(), splash: glide(), mainAt: -99, popAt: -99, }; function say(text) { if (readout.textContent !== text) readout.textContent = text; } function describe() { const text = selected ? D.pieces[selected].say : burn.on ? "firing" : apart ? "apart · 11 assemblies" : "Assembled, cold"; if (stage.getAttribute("aria-valuetext") !== text) stage.setAttribute("aria-valuetext", text); } function setApart(on) { if (apart === on) return; apart = on; const goal = on ? 1 : 0; let index = 0; for (const id of on ? STAGGER : [...STAGGER].reverse()) { if (!piece[id]) continue; if (piece[id].target === goal) piece[id].pending = null; else piece[id].pending = { value: goal, at: clock + (still.matches ? 0 : index++ * SPRING.stagger) }; } describe(); run(); } function select(id) { if (id === "stand") id = null; if (selected === id) return; selected = id; for (const node of nodes) { if (id !== null && node.piece === id) node.el.setAttribute("data-on", ""); else node.el.removeAttribute("data-on"); } if (id) stage.setAttribute("data-focus", ""); else stage.removeAttribute("data-focus"); partButtons.forEach((button) => (button.dataset.part === id ? button.setAttribute("data-on", "") : button.removeAttribute("data-on"))); stage.setAttribute("aria-valuenow", String(id ? D.order.indexOf(id) + 1 : 0)); describe(); } function steerSpring(state, dt, calm) { if (state.pending && clock >= state.pending.at) { state.target = state.pending.value; state.pending = null; } if (calm) { state.x = state.target; state.v = 0; return false; } for (let index = 0; index < SPRING.substeps; index++) { const h = dt / SPRING.substeps; state.v += ((state.target - state.x) * SPRING.stiffness - state.v * SPRING.damping) * h; state.x += state.v * h; } if (Math.abs(state.target - state.x) < 2e-4 && Math.abs(state.v) < 2e-4) { state.x = state.target; state.v = 0; return state.pending !== null; } return true; } function place() { for (const node of nodes) { const share = piece[node.ride].x; const tx = (D.EX[0] * node.m[0] + D.EY[0] * node.m[1] + D.EZ[0] * node.m[2]) * share; const ty = (D.EX[1] * node.m[0] + D.EY[1] * node.m[1] + D.EZ[1] * node.m[2]) * share; const text = share === 0 ? "" : `translate(${tx.toFixed(2)} ${ty.toFixed(2)})`; if (text !== node.shown) { node.shown = text; if (text) node.el.setAttribute("transform", text); else node.el.removeAttribute("transform"); } } const next = nodes.map((_, index) => index).sort((a, b) => nodes[a].key + nodes[a].lift * piece[nodes[a].ride].x - (nodes[b].key + nodes[b].lift * piece[nodes[b].ride].x)); if (next.some((index, at) => index !== order[at])) { const fragment = document.createDocumentFragment(); for (const index of next) fragment.appendChild(nodes[index].el); engine.appendChild(fragment); order = next; } const moved = Math.abs(jitter[0]) > 0.005 || Math.abs(jitter[1]) > 0.005; const shift = moved ? `translate(${jitter[0].toFixed(2)} ${jitter[1].toFixed(2)})` : ""; engine.setAttribute("transform", shift); glowGroup.setAttribute("transform", shift); } const COMMON = ` uniform vec2 uJit; uniform float uPc; uniform float uHue; uniform float uSmoke; uniform float uVent; uniform float uShockR; uniform float uShockA; uniform float uLight; const float ZE=${D.zExit.toFixed(1)}; const float R0=${D.exitR.toFixed(1)}; const float PL=${D.plumeLength.toFixed(1)}; const float RB=132.; const vec2 COL0=vec2(${D.columns[0][0].toFixed(1)},${D.columns[0][1].toFixed(1)}); const vec2 COL1=vec2(${D.columns[1][0].toFixed(1)},${D.columns[1][1].toFixed(1)}); const float COLH=${D.columnHalf.toFixed(1)}; const float COLTOP=${D.columnTop.toFixed(1)}; float ground(float r){ if(r>56.)return 0.; if(r>48.)return 4.; if(r>43.)return mix(4.,8.,(48.-r)/5.); if(r>35.)return mix(8.,13.,(43.-r)/8.); if(r>25.)return mix(13.,19.,(35.-r)/10.); if(r>14.5)return mix(19.,25.,(25.-r)/10.5); if(r>6.)return mix(25.,29.5,(14.5-r)/8.5); if(r>2.)return mix(29.5,32.,(6.-r)/4.); return 32.; } bool solid(vec3 p){return p.z<ground(length(p.xy));} float ringField(vec3 p){return length(vec2(length(p.xy)-68.,p.z-6.4))-2.1;} vec2 groundHit(vec3 o,vec3 V){ float tf=-o.z/V.z; float best=tf; if(length(o.xy)<56.5){ float ta=(36.-o.z)/V.z; float prev=ta; for(int i=1;i<=28;i++){ float t=mix(ta,tf,float(i)/28.); if(solid(o+t*V)){ float lo=t;float hi=prev; for(int j=0;j<7;j++){float m=(lo+hi)*.5;if(solid(o+m*V))lo=m;else hi=m;} best=hi; break; } prev=t; } } float t6=(6.4-o.z)/V.z; if(abs(length((o+t6*V).xy)-68.)<12.){ float t=(9.-o.z)/V.z; for(int i=0;i<14;i++){ float d=ringField(o+t*V); if(d<.02)break; t-=d; } if(ringField(o+t*V)<.06&&t>best)return vec2(t,1.); } return vec2(best,0.); } float columnNear(vec3 o,vec3 V,vec2 c){ vec3 lo=vec3(c-COLH,0.); vec3 hi=vec3(c+COLH,COLTOP); vec3 a=(lo-o)/V; vec3 b=(hi-o)/V; vec3 mn=min(a,b); vec3 mx=max(a,b); float tIn=max(mn.x,max(mn.y,mn.z)); float tOut=min(mx.x,min(mx.y,mx.z)); return tOut>tIn?tOut:-1e9; } float columnsNear(vec3 o,vec3 V){return max(columnNear(o,V,COL0),columnNear(o,V,COL1));} float engineR(float z){ float s=z-ZE; float b=36.+29.*(1.-pow(clamp(s,0.,120.)/120.,1.6))+3.; return mix(b,48.,smoothstep(108.,124.,s))*smoothstep(-6.,0.,s)*(1.-smoothstep(318.,330.,s)); } float inFront(vec3 p,vec3 V){ vec2 h=V.xy; float hl=length(h); float tau=-dot(p.xy,h)/(hl*hl); float d=length(p.xy+tau*h); float re=engineR(p.z+tau*V.z); float inside=1.-smoothstep(re-3.,re+3.,d); float surface=tau*hl+sqrt(max(re*re-d*d,0.)); return inside*smoothstep(0.,5.,-surface); } float side(float front,float veil){return mix(1.-front,front,veil);} float beyond(float t0,float tc,float sigma){return 1./(1.+exp(clamp(2.41*(t0-tc)/sigma,-30.,30.)));} vec3 steamOf(vec3 g0,vec3 V,float tA,float u,float z0,float pf,float T,float veil){ if(uSmoke<.0004&&uVent<.0004)return vec3(0.); float sE=uCam.z; float Hs=70.+60.*uSmoke; vec3 start=g0+tA*V; float steam=0.; float vapour=0.; for(int i=0;i<6;i++){ float w=(float(i)+.5)/6.; vec3 p=start+(-Hs*log(w)/sE)*V; float r=length(p.xy); float lay=side(inFront(p,V),veil); float box=1.-smoothstep(120.,178.,max(abs(p.x),abs(p.y))); steam+=box*(.4+.6*exp(-pow((r-90.)/80.,2.)))*(1.-smoothstep(170.,330.,p.z))*lay; vapour+=exp(-pow(r/96.,2.))*(1.-smoothstep(40.,140.,p.z))*lay; } float layer=exp(-max(start.z,0.)/Hs)/6.; float b1=fbm2(vec2(u*.018+T*.03,z0*.016-T*.26+7.)); float b2=fbm2(vec2(u*.045-T*.05,z0*.04-T*.5+2.)); vec3 lit=mix(vec3(.3,.3,.33),vec3(.72,.4,.2),pf*exp(-max(z0,0.)/150.)); return lit*(b1*.75+b2*.4)*smoothstep(.05,.6,b1)*steam*layer*uSmoke*1.05+vec3(.42,.47,.54)*(b2*.6+.4)*vapour*layer*uVent*.5; } float shellOf(float dist,float tc,float tAir,float rs,float w,vec3 e0,vec3 V,float veil){ if(rs<.5)return 0.; float m=sqrt(max(rs*rs-dist*dist,0.)); float edge=exp(-pow(max(dist-rs,0.)/w,2.)); float limb=rs*w/sqrt(m*m+2.*rs*w)/sqrt(rs*w*.5); limb*=limb; float soft=w*2.+2.; float far=tc-m; float near=tc+m; float aFar=smoothstep(tAir-soft*.5,tAir+soft,far)*side(inFront(e0+far*V,V),veil); float aNear=smoothstep(tAir-soft*.5,tAir+soft,near)*side(inFront(e0+near*V,V),veil); return edge*limb*(aFar+aNear)*.5; } vec3 shockOf(float dist,float tc,float tAir,vec3 e0,vec3 V,float veil){ if(uShockA<.0004)return vec3(0.); float rs=uShockR; float sw=1.8+max(rs-26.,0.)*.01; float lead=shellOf(dist,tc,tAir,rs,sw,e0,V,veil); float trail=shellOf(dist,tc,tAir,max(rs-sw*2.6,0.),sw*2.2,e0,V,veil)*.3; return vec3(.84,.88,1.)*(lead+trail)*.42*uShockA*(1.-smoothstep(170.,270.,rs)); } vec4 inkOf(vec3 col){ col=tonemap(col,1.15); float m=max(col.r,max(col.g,col.b)); float n=(hash12(gl_FragCoord.xy+fract(uTime*.61)*91.)+hash12(gl_FragCoord.yx*1.37+fract(uTime*.37)*57.)-1.)/255.; col=max(col+n*smoothstep(0.,1.5/255.,m),0.); m=max(col.r,max(col.g,col.b)); vec3 ink=col/max(m,1e-5)*vec3(.62,.34,.3); vec4 light=vec4(ink*m*.92,m*.92); return mix(vec4(col,m),light,uLight); } struct Scene{vec3 V;vec3 g0;vec3 e0;vec3 hit;float tLow;float ring;float tCol;float tAir;float tAirE;float tLowE;float u;float z0;float s0;float dist;float tc;}; Scene sceneOf(vec2 vb){ Scene S; S.V=towardViewer(); float sE=uCam.z; S.g0=rayOf(vb); vec2 gh=groundHit(S.g0,S.V); S.tLow=gh.x; S.ring=gh.y; S.tCol=columnsNear(S.g0,S.V); S.tAir=max(S.tLow,S.tCol); S.hit=S.g0+S.tLow*S.V; S.e0=rayOf(vb-uJit); S.u=(vb.x-uJit.x-uOrigin.x)/uK; S.z0=S.e0.z; S.s0=ZE-S.z0; S.tLowE=S.tLow+(S.g0.z-S.e0.z)/sE; S.tAirE=S.tAir+(S.g0.z-S.e0.z)/sE; vec3 C=vec3(0.,0.,ZE-6.); S.tc=dot(C-S.e0,S.V); S.dist=length(S.e0+S.tc*S.V-C); return S; } `; const PLUME = `${COMMON} uniform float uGlow; uniform float uPre; uniform float uReach; uniform float uSplash; uniform float uFlash; uniform float uRingR; uniform float uRingA; uniform float uPop; uniform float uPopR; float onDeck(vec2 p){return 1.-smoothstep(134.,150.,max(abs(p.x),abs(p.y)));} float radiusAt(float s,float pf,float warm){ float spread=.045+.12*(1.-pf); return R0*(1.-.07*pf*smoothstep(0.,40.,s)-.42*warm*smoothstep(0.,80.,s))+spread*max(s,0.); } void main(){ vec2 vb=viewOf(gl_FragCoord.xy); Scene S=sceneOf(vb); vec3 V=S.V; float sE=uCam.z; float cE=uCam.w; float tanE=sE/cE; float T=uTime; float pc=clamp(uPc,0.,1.); float pf=pc; float warm=1.-clamp(uHue,0.,1.); float glow=uGlow; float dith=hash12(gl_FragCoord.xy); vec3 col=vec3(0.); vec3 g0=S.g0; vec3 e0=S.e0; float u=S.u; float z0=S.z0; float s0=S.s0; vec3 hit=S.hit; float rho=length(hit.xy); vec2 dirH=hit.xy/max(rho,1e-3); float tongue=fbm2(vec2(u*.07,s0*.06-T*8.)); float reach=uReach*(1.+(tongue-.5)*.55*warm); float rag=1.+1.6*warm; float lam=29.+7.*pf; float v0=19.+6.*pf; float cellOn=smoothstep(.4,.95,pf); if((glow>.0004||uVent>.0004)&&abs(u)<RB){ float e=fbm2(vec2(u*.06+7.,s0*.03-T*4.6)); float e2=fbm2(vec2(u*.16-3.,s0*.08-T*7.3)); float streak=.86+.3*fbm2(vec2(u*.22,s0*.012-T*9.)); float flick=(.94+.06*sin(T*97.+s0*.23))*(1.+uPre*warm*(tongue-.45)*.9); float mist=fbm2(vec2(u*.035+.4*T,s0*.022-T*.7)); float halfChord=sqrt(max(RB*RB-u*u,0.))/cE; float ta=max(S.tAirE,-halfChord); float tb=min((ZE+2.-z0)/sE,halfChord); float dt=max(tb-ta,0.)/28.; vec3 sum=vec3(0.); vec3 cold=vec3(0.); for(int i=0;i<28;i++){ float t=ta+(float(i)+dith)*dt; vec3 p=e0+t*V; float s=ZE-p.z; float r=length(p.xy); float keep=(1.-smoothstep(RB*.8,RB,r))*smoothstep(-.5,4.,s); float Rv=radiusAt(s,pf,warm); float Re=Rv*(1.+(e-.5)*.42*rag*smoothstep(4.,120.,s)+(e2-.5)*.1*rag*smoothstep(0.,40.,s)); float rn=r/Re; float fade=(1.-smoothstep(reach-46.,reach,s))*keep; vec3 violet=mix(vec3(.86,.44,1.),vec3(1.,.5,.62),smoothstep(0.,150.,s)*.7); vec3 ember=mix(vec3(1.,.36,.07),vec3(1.,.7,.32),tongue); vec3 bodyCol=mix(violet,ember,warm); float fill=smoothstep(1.06,.72,rn); float wBody=dt*cE/(2.*Re); sum+=bodyCol*fill*wBody*(.26+.3*exp(-max(s,0.)/110.))*streak*flick*fade*2.; sum+=bodyCol*exp(-rn*rn*2.)*(dt*cE/(Re*1.25))*.22*fade; float rc=Rv*.34; sum+=mix(vec3(1.,.74,.9),vec3(1.,.8,.45),warm)*exp(-(r*r)/(rc*rc))*(dt*cE/(rc*1.772))*.34*(1.-.5*smoothstep(PL-30.,PL+10.,s))*fade; float rb=Rv*.62; sum+=mix(vec3(1.,.9,.76),vec3(1.,.78,.46),warm)*exp(-(r*r)/(rb*rb))*exp(-max(s,0.)/24.)*(dt*cE/(rb*1.772))*1.6*keep; float sw=Re*.24; float shell=exp(-pow((r-Re*.9)/sw,2.))*smoothstep(8.,150.,s)*(dt*cE/(sw*3.545)); sum+=vec3(1.,.42,.12)*shell*.8*(.35+1.1*e)*fade; sum+=vec3(1.,.8,.52)*smoothstep(PL-30.,PL+4.,s)*fill*wBody*.8*fade; for(int c=0;c<4;c++){ float fc=float(c); float vc=v0+fc*lam+sin(T*47.+fc*2.3)*.6; float Rc=R0*(1.-.07*pf)+(.045+.12*(1.-pf))*vc; float amp=exp(-fc*.28)*cellOn*(1.-smoothstep(reach-70.,reach-10.,vc))*(1.-smoothstep(PL-24.,PL-6.,vc))*keep; float rm=Rc*.33; float ph=(s-vc)/(lam*.62); float taper=rm*(1.06-clamp(ph,0.,1.)*.8)+.6; float bead=exp(-pow(r/taper,2.)*2.)*exp(-max(ph,0.)*2.2)*smoothstep(-.16,.02,ph)*(1.-smoothstep(.7,1.,ph)); sum+=vec3(1.,.82,.46)*bead*(dt*cE/(taper*1.25))*2.1*amp; float d=r/(Rc*.9)+abs(s-vc+lam*.08)/(lam*.5); sum+=vec3(1.,.5,.64)*pow(clamp(1.-d,0.,1.),2.2)*(dt*cE/(Rc*.9*.62))*1.05*amp; } float sigma=R0*(.85+max(s,0.)*.012); cold+=vec3(.5,.56,.64)*exp(-(r*r)/(sigma*sigma))*(dt*cE/(sigma*1.772))*smoothstep(0.,16.,s)*(1.-smoothstep(50.,170.,s))*keep; } col+=sum*glow; col+=cold*mist*uVent*.62; } if(glow>.0004){ float sh=radiusAt(clamp(s0,0.,PL),pf,warm)*2.2; float span=2.6*sh/cE; float h0=max(S.tAirE,-span); float dh=max(span-h0,0.)/8.; float halo=0.; for(int i=0;i<8;i++){ float t=h0+(float(i)+dith)*dh; float z=z0+t*sE; float rr=u*u+t*t*cE*cE; halo+=exp(-rr/(sh*sh))*exp(-max(ZE-z,0.)/240.)*smoothstep(0.,30.,z)*smoothstep(ZE+25.,ZE-15.,z); } col+=mix(vec3(.9,.42,.62),vec3(1.,.45,.16),warm)*halo*(dh*cE/(sh*1.772))*.16*glow; } for(int c=0;c<4;c++){ float fc=float(c); float vc=v0+fc*lam+sin(T*47.+fc*2.3)*.6; float Rc=R0*(1.-.07*pf)+(.045+.12*(1.-pf))*vc; float amp=exp(-fc*.28)*cellOn*(1.-smoothstep(reach-70.,reach-10.,vc))*(1.-smoothstep(PL-24.,PL-6.,vc)); float rm=Rc*.33; float q=length(vec2(u/rm,(s0-vc)/(rm*tanE+1.2))); col+=vec3(1.,.98,.9)*exp(-q*q*2.4)*2.6*amp*glow; } float dist=S.dist; col+=vec3(1.,.84,.64)*uFlash*(exp(-pow(dist/120.,2.))*.36*beyond(S.tAirE,S.tc,120.)+exp(-dist/30.)*1.5*beyond(S.tAirE,S.tc,30.)); col+=shockOf(dist,S.tc,S.tAirE,e0,V,0.); float floorSeen=S.tCol>S.tLow?0.:1.; float deck=onDeck(hit.xy)*floorSeen; vec3 tube=hit-vec3(dirH*68.,6.4); float tubeLit=max(dot(normalize(tube),normalize(vec3(-dirH*68.,26.))),0.); float facing=mix(1.,.15+1.3*tubeLit,S.ring); float splash=uSplash*pf; if(splash>.0004||uRingA>.0004){ float flow=fbm3(vec3(dirH*2.4,rho*.035-T*3.8)); float flow2=noise3(vec3(dirH*5.1+9.,rho*.07-T*6.1)); float reachS=56.+80.*splash; float sheet=exp(-pow(rho/reachS,1.5))*deck*facing; vec3 hot=mix(vec3(1.,.38,.1),vec3(1.,.82,.55),exp(-rho/26.)); col+=hot*sheet*(.26+.56*flow+.22*flow2)*splash*.62; col+=vec3(1.,.88,.7)*exp(-pow(rho/40.,2.))*splash*.9*floorSeen; float dust=noise3(vec3(dirH*7.,rho*.05)); float gw=4.+uRingR*.034; col+=vec3(1.,.8,.6)*exp(-pow((rho-uRingR)/gw,2.))*(.45+dust)*deck*facing*uRingA*.5; if(splash>.0004){ float tS=(70.-g0.z)/sE; float dS=max(tS-S.tAir,0.)/10.; float curtain=0.; for(int i=0;i<10;i++){ vec3 p=g0+(S.tAir+(float(i)+dith)*dS)*V; float r=length(p.xy); float h=max(p.z-ground(r),0.); curtain+=exp(-pow((r-reachS*.8)/(reachS*.34),2.))*exp(-h/26.)*onDeck(p.xy)*dS*sE/26.; } float lick=fbm2(vec2(u*.05+T*.3,z0*.06-T*1.4)); col+=vec3(1.,.46,.16)*curtain*lick*.55*splash; } } col+=steamOf(g0,V,S.tAir,u,z0,pf,T,0.); if(uPop>.0004){ vec3 Cp=vec3(0.,0.,ZE-uPopR*.55); float tp=dot(Cp-e0,V); float pd=length(e0+tp*V-Cp); float pn=fbm2(vec2(u*.045,s0*.045-T*2.4)); col+=vec3(1.,.46,.15)*exp(-pow(pd/uPopR,2.))*(.55+.7*pn)*smoothstep(ZE+24.,ZE-12.,z0)*beyond(S.tAirE,tp,uPopR)*uPop*.95; } gl_FragColor=inkOf(col); }`; const VEIL = `${COMMON} void main(){ vec2 vb=viewOf(gl_FragCoord.xy); Scene S=sceneOf(vb); float pf=clamp(uPc,0.,1.); vec3 col=steamOf(S.g0,S.V,S.tAir,S.u,S.z0,pf,uTime,1.); col+=shockOf(S.dist,S.tc,S.tAirE,S.e0,S.V,1.); gl_FragColor=inkOf(col); }`; const SHARED = ["uJit", "uPc", "uHue", "uSmoke", "uVent", "uShockR", "uShockA", "uLight"]; const layer = glLayer({ canvas, fragment: PLUME, view: [D.W, D.H], camera: D.camera, uniforms: [...SHARED, "uGlow", "uPre", "uReach", "uSplash", "uFlash", "uRingR", "uRingA", "uPop", "uPopR"], }); const veil = layer ? glLayer({ canvas: veilCanvas, fragment: VEIL, view: [D.W, D.H], camera: D.camera, uniforms: SHARED, }) : null; if (layer) stage.setAttribute("data-gl", ""); const LIGHT = document.body.dataset.theme === "light" || root.closest('[data-theme="light"]') ? 1 : 0; let lastKey = ""; function paintPlume(values, active, veiled) { if (!layer) return; if (!active) { layer.clear(); if (veil) veil.clear(); lastKey = ""; return; } const time = still.matches ? 0.8 : clock; const key = still.matches ? JSON.stringify(values) + veiled : ""; if (key && key === lastKey) return; lastKey = key; layer.draw(values, time); if (!veil) return; if (veiled) veil.draw(values, time); else veil.clear(); } function announce(phase) { if (burn.phase === phase) return; burn.phase = phase; if (live) live.textContent = phase; } function fireState(state) { if (state) fireButton.dataset.state = state; else delete fireButton.dataset.state; } function ignite(fromTour = false) { if (burn.on) return; setApart(false); select(null); burn.on = true; describe(); burn.armed = true; burn.tour = fromTour; burn.t = 0; burn.stopping = -1; burn.glowStop = null; burn.popped = false; burn.stopAt = fromTour ? MAIN + 4.2 : MAIN + STEADY; fireState("armed"); fireLabel.textContent = "Shut down"; run(); } function shutdown() { if (!burn.on || burn.stopping >= 0) return; burn.stopping = burn.t; } function pcTarget() { if (!burn.on || burn.armed || burn.stopping >= 0) return 0; if (burn.t >= MAIN) return 1; if (burn.t >= VENT) return PREBURNER_PC; return 0; } function stepBurn(dt, calm) { if (burn.on && burn.armed && IDS.every((id) => piece[id].x < 0.002 && !piece[id].pending)) { burn.armed = false; fireState("on"); if (calm) burn.t = MAIN + 0.5; } const before = burn.t; if (burn.on && !burn.armed) burn.t += dt; const t = burn.t; if (burn.on && burn.stopping < 0 && t >= burn.stopAt) burn.stopping = t; const lit = burn.on && !burn.armed && burn.stopping < 0; if (lit && t >= MAIN && before < MAIN && !calm) burn.mainAt = clock; const target = pcTarget(); if (calm) { burn.pc = target; burn.pcRate = 0; } else { const falling = burn.stopping >= 0; const stiff = falling ? 16 : target > PREBURNER_PC ? 20 : 64; const drag = falling ? 8.2 : 2 * Math.sqrt(stiff); for (let index = 0; index < 4; index++) { const h = dt / 4; burn.pcRate += ((target - burn.pc) * stiff - burn.pcRate * drag) * h; burn.pc = clamp(burn.pc + burn.pcRate * h, 0, 1); } } settle(burn.hue, lit && burn.pc > 0.2 ? 1 : 0, dt, lit ? 0.32 : 0.5, calm); const onset = smooth(0, 0.1, burn.pc); let glow = onset * (1.15 - 0.15 * burn.hue.x); if (burn.stopping >= 0 && !calm) { if (!burn.glowStop) burn.glowStop = { glow: burn.lastGlow ?? glow, onset: Math.max(onset, 1e-3), pc: Math.max(burn.pc, 1e-3) }; const stop = burn.glowStop; glow = Math.min(stop.glow, stop.glow * Math.min(1, onset / stop.onset) * Math.sqrt(Math.min(1, burn.pc / stop.pc))); } if (!burn.on) glow = 0; burn.lastGlow = glow; settle(burn.pre, lit && t > VENT && t < MAIN + 0.1 ? 1 : 0, dt, lit && t < MAIN ? 0.06 : 0.12, calm); settle(burn.vent, lit && t < MAIN ? 1 : 0, dt, lit && t < MAIN ? 0.16 : 0.24, calm); settle(burn.reach, 72 * smooth(0, 0.12, burn.pc) + (LONGEST - 72) * smooth(0.16, 0.75, burn.pc), dt, burn.stopping >= 0 ? 0.12 : 0.07, calm); settle(burn.splash, smooth(0.35, 0.9, burn.pc) * smooth(D.plumeLength - 10, D.plumeLength + 40, burn.reach.x), dt, 0.16, calm); settle(burn.smoke, lit && t > MAIN + 0.4 ? 1 : 0, dt, lit ? 0.9 : 1.8, calm); if (burn.on && burn.stopping >= 0 && burn.pc < 0.2 && !burn.popped && !calm) { burn.popAt = clock; burn.popped = true; } if (burn.on && burn.stopping >= 0 && burn.pc < 0.003 && Math.abs(burn.pcRate) < 0.01 && t - burn.stopping > 0.9) { burn.on = false; burn.t = -1; burn.pc = 0; burn.pcRate = 0; burn.glowStop = null; fireState(""); fireLabel.textContent = "Ignite"; announce("cold"); describe(); if (burn.tour) tourClock = 0; } const sinceMain = clock - burn.mainAt; const sincePop = burn.popAt >= 0 ? clock - burn.popAt : -1; const flash = calm ? 0 : burst(sinceMain, 0.08, 0.13); const shockA = calm ? 0 : burst(sinceMain, 0.05, 0.12); const ringA = calm ? 0 : burst(sinceMain - 0.08, 0.08, 0.16); const pop = calm || sincePop < 0 ? 0 : burst(sincePop, 0.09, 0.2); const shake = calm ? 0 : 3.2 * burst(sinceMain, 0.05, 0.12); const hum = calm ? 0 : 0.32 * burn.pc * smooth(0.02, 0.12, burn.pc); jitter = [Math.sin(clock * 91) * hum + Math.sin(clock * 53) * shake, Math.cos(clock * 77) * hum * 0.7 + Math.cos(clock * 61) * shake]; const values = { uJit: jitter, uPc: burn.pc, uHue: burn.hue.x, uGlow: glow, uLight: LIGHT, uPre: burn.pre.x, uVent: burn.vent.x, uSmoke: burn.smoke.x, uReach: burn.reach.x, uSplash: burn.splash.x, uFlash: flash, uShockR: 26 + 900 * Math.max(0, sinceMain), uShockA: shockA, uRingR: 600 * Math.max(0, sinceMain - 0.08), uRingA: ringA, uPop: pop, uPopR: 24 + 70 * (1 - Math.exp(-Math.max(0, sincePop) / 0.25)), }; const active = burn.pc > 1e-3 || burn.pre.x > 1e-3 || burn.vent.x > 1e-3 || burn.smoke.x > 1e-3 || flash > 1e-3 || shockA > 1e-3 || ringA > 1e-3 || pop > 1e-3 || (sinceMain > 0 && sinceMain < 0.6); paintPlume(values, active, burn.smoke.x > 1e-3 || burn.vent.x > 1e-3 || shockA > 1e-3); const rim = clamp(burn.pc * 0.85 + burn.pre.x * 0.45 * smooth(0, 0.05, burn.pc) + flash * 0.6 + pop * 0.7, 0, 1); stage.style.setProperty("--rim", rim.toFixed(3)); stage.style.setProperty("--deck-glow", clamp(burn.pc * 0.9 * burn.splash.x + flash * 0.5 + burn.pre.x * 0.12 * smooth(0, 0.05, burn.pc) + pop * 0.25, 0, 1).toFixed(3)); flashVeil.style.opacity = (flash * 0.32).toFixed(3); stage.style.transform = shake > 0.01 ? `translate(${(Math.sin(clock * 47) * shake * 0.5).toFixed(2)}px, ${(Math.cos(clock * 59) * shake * 0.5).toFixed(2)}px)` : ""; if (burn.on) { const bar = fmt(burn.pc * D.chamberBar); const thrust = fmt(Math.max(0, D.vacuumTf * burn.pc - D.backTf)); if (burn.armed) { say("closing up · arming"); announce("arming"); } else if (burn.stopping >= 0) { say(burn.pc > 0.01 ? `shutdown · ${bar} bar` : "shutdown · purging"); announce("shutdown"); } else if (t < VENT) { say("chill-down · venting"); announce("chill-down, venting"); } else if (t < MAIN) { say(`spin-up · preburners lit · ${bar} bar`); announce("preburners lit"); } else { say(`Pc ${bar} bar · ${thrust} tf`); announce("main stage"); } } return burn.on || active; } const TOUR_STEP = 1.5; function tourAt(time) { const apartFrom = 0.4; const listFrom = apartFrom + 0.9; const listTo = listFrom + D.order.length * TOUR_STEP; const fire = listTo + 1.4; if (time < apartFrom) return { apart: false, part: null, fire: false }; if (time < listFrom) return { apart: true, part: null, fire: false }; if (time < listTo) return { apart: true, part: D.order[Math.floor((time - listFrom) / TOUR_STEP)], fire: false }; if (time < fire) return { apart: false, part: null, fire: false }; return { apart: false, part: null, fire: true }; } function quietTour() { touring = false; clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (inside || burn.on) return quietTour(); touring = !still.matches; tourClock = -0.6; run(); }, 3600); } function idleText() { if (selected) return D.pieces[selected].say; const moving = IDS.some((id) => Math.abs(piece[id].x - piece[id].target) > 0.002 || piece[id].pending); if (moving) return apart ? "taking apart" : "closing up"; return apart ? "apart · 11 assemblies" : "1,630 kg · cold"; } function tick(now) { const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60; last = now; clock += dt; const calm = still.matches; if (touring && !burn.on) { tourClock += dt; const step = tourAt(tourClock); setApart(step.apart); select(step.part); if (step.fire) ignite(true); } let moving = false; for (const id of IDS) if (steerSpring(piece[id], dt, calm)) moving = true; const burning = stepBurn(dt, calm); place(); if (!burn.on) say(idleText()); frame = visible && (moving || burning || touring) ? requestAnimationFrame(tick) : 0; } function run() { if (!frame && visible) { last = 0; frame = requestAnimationFrame(tick); } } new IntersectionObserver( (entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); }, { rootMargin: "120px 0px" }, ).observe(stage); function pieceAt(event) { const hit = document.elementFromPoint(event.clientX, event.clientY); const item = hit && hit.closest ? hit.closest(".it") : null; return item ? item.dataset.piece : null; } stage.addEventListener("pointermove", (event) => { if (event.pointerType === "touch") return; inside = true; clearTimeout(leaveTimer); quietTour(); if (burn.on) return; setApart(true); select(pieceAt(event)); run(); }); stage.addEventListener("pointerleave", () => { inside = false; clearTimeout(leaveTimer); leaveTimer = setTimeout(() => { if (inside) return; select(null); setApart(false); }, 260); }); stage.addEventListener("pointerdown", (event) => { if (event.pointerType !== "touch" || burn.on) return; quietTour(); const id = pieceAt(event); if (!id || id === "stand") { select(null); setApart(!apart); } else { setApart(true); select(id); } run(); }); fireButton.addEventListener("click", () => { quietTour(); if (burn.on) shutdown(); else ignite(false); run(); }); partButtons.forEach((button) => { const show = () => { if (burn.on) return; quietTour(); clearTimeout(leaveTimer); setApart(true); select(button.dataset.part); run(); }; const hide = () => { clearTimeout(leaveTimer); leaveTimer = setTimeout(() => { if (inside || partButtons.some((other) => other.matches(":hover, :focus-visible"))) return; select(null); setApart(false); }, 260); }; button.addEventListener("pointerenter", show); button.addEventListener("focus", show); button.addEventListener("pointerleave", hide); button.addEventListener("blur", hide); button.addEventListener("click", show); }); stage.addEventListener("keydown", (event) => { const index = selected ? D.order.indexOf(selected) : -1; let next = null; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = Math.min(D.order.length - 1, index + 1); else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = Math.max(0, index - 1); else if (event.key === "Home") next = 0; else if (event.key === "End") next = D.order.length - 1; else if (event.key === "Escape") { event.preventDefault(); quietTour(); if (burn.on) shutdown(); select(null); setApart(false); run(); return; } else if (event.key === "i" || event.key === "I" || event.key === "Enter") { event.preventDefault(); quietTour(); if (burn.on) shutdown(); else ignite(false); run(); return; } else if (event.key === " ") { event.preventDefault(); quietTour(); if (!burn.on) setApart(!apart); if (!apart) select(null); run(); return; } if (next === null || burn.on) return; event.preventDefault(); quietTour(); setApart(true); select(D.order[next]); run(); }); if (still.matches) touring = false; const narrow = matchMedia("(hover: none), (max-width: 480px)"); const hint = root.querySelector(".rp-hint"); const wording = () => hint && (hint.textContent = narrow.matches ? "Tap to take apart" : "Hover the engine to take it apart"); wording(); narrow.addEventListener("change", wording); window.addEventListener("resize", () => { lastKey = ""; run(); }); place(); say(idleText()); -
raptor-engine-light.html 1.5 MB · in bundle
-
raptor-engine.html 1.5 MB · in bundle
-
raptor.css 6.6 KB · in bundle
-
-
ripple-tank
-
build.mjs 31.3 KB · in bundle
-
live.js 21.2 KB
const D = __DATA__; const root = document.querySelector(".rt"); const stage = root.querySelector(".rt-stage"); const readout = root.querySelector("[data-readout]"); const paperCanvas = stage.querySelector(".rt-paper-gl"); const waterCanvas = stage.querySelector(".rt-water-gl"); const front = stage.querySelector(".rt-front"); const mid = stage.querySelector(".rt-mid"); const farDipper = front.querySelector('[data-part="dipper-far"]'); const nearDipper = front.querySelector('[data-part="dipper-near"]'); const eccentric = front.querySelector('[data-part="eccentric"]'); const pointer = mid.querySelector('[data-part="pointer"]'); const needle = mid.querySelector('[data-part="needle"]'); const bar = front.querySelector('[data-part="bar"]'); const motor = front.querySelector('[data-part="motor"]'); const said = stage.querySelector(".rt-said"); const crestPath = stage.querySelector('[data-part="crests"]'); const nodalPath = stage.querySelector('[data-part="nodal"]'); const still = matchMedia("(prefers-reduced-motion: reduce)"); const pale = !!root.closest('[data-theme="light"]'); const TAU = Math.PI * 2; const HIST = 32; const clampTo = (value, low, high) => Math.min(high, Math.max(low, value)); const WAVE_GLSL = ` uniform vec2 uS1;uniform vec2 uS2;uniform float uPhase;uniform float uReach; uniform sampler2D uHT;uniform vec4 uLo;uniform vec4 uSpan;uniform vec2 uHist;uniform float uIG;uniform float uKNow;uniform float uStep; uniform float uSlope;uniform float uCaustic;uniform float uMag; uniform vec4 uIn;uniform float uBeach;uniform vec4 uZ;uniform float uLight;uniform float uPale;uniform float uBed;uniform vec3 uLamp;uniform float uSpread; const float HN=${HIST}.; float gPx;vec2 gGrad;vec3 gHess;float gCurv;float gAmp;float gKA;float gMove; float coneOf(vec3 p){float drop=uLamp.z-p.z;float spread=13.4+drop*uSpread;return 1.-smoothstep(spread*.72,spread,length(p.xy-uLamp.xy));} float sdRect(vec2 p,vec4 r,float rad){vec2 c=.5*(r.xy+r.zw);vec2 h=.5*(r.zw-r.xy)-rad;vec2 q=abs(p-c)-h;return length(max(q,0.))+min(max(q.x,q.y),0.)-rad;} float inRect(vec2 p,vec4 r,float rad,float soft){return 1.-smoothstep(-soft,soft,sdRect(p,r,rad));} vec2 unpack(vec4 t){t=floor(t*255.+.5);return vec2(t.x*256.+t.y,t.z*256.+t.w)/65535.;} float slotOf(float age){float s=age/uHist.y;return clamp(mix(s/max(uHist.x,1e-3),1.+s-uHist.x,step(uHist.x,s)),0.,HN-1.);} vec2 rowAt(float u,float row){ float n=floor(u); vec2 a=unpack(texture2D(uHT,vec2((n+.5)/HN,row))); vec2 b=unpack(texture2D(uHT,vec2((min(n+1.,HN-1.)+.5)/HN,row))); return mix(a,b,u-n); } void source(vec2 p,vec2 s,float scale){ vec2 q=p-s;float r2=dot(q,q)+4.;float r=sqrt(r2); float ig=uLo.w+uSpan.w*rowAt(slotOf(r*uIG),.75).y; float u=slotOf(r*ig); vec2 kp=uLo.xy+uSpan.xy*rowAt(u,.25); float kE=kp.x; float dec=uLo.z+uSpan.z*rowAt(u,.75).x; float fine=smoothstep(2.5,5.,6.2832/kE*scale/gPx); float inv=1./(r+uReach); float A=uSlope/kE*fine*sqrt((uReach+6.2832/kE)*inv)*exp(-r*dec); float a1=-.5*inv-dec; float a2=a1*a1+.5*inv*inv; float th=kE*r-uPhase+kp.y;float cs=cos(th);float sn=sin(th); float g1=A*(a1*cs-kE*sn); float g2=A*(a2*cs-2.*a1*kE*sn-kE*kE*cs); vec2 n=q/r;float t=g1/r; gGrad+=g1*n; gHess+=vec3((g2-t)*n.x*n.x+t,(g2-t)*n.x*n.y,(g2-t)*n.y*n.y+t); gCurv+=A*kE*kE; gAmp+=A; gKA+=A*kE; gMove=max(gMove,abs(uKNow-kE)/max(ig,1e-6)*uStep/kE*fine); } void field(vec2 p,float scale){ gGrad=vec2(0.);gHess=vec3(0.);gCurv=0.;gAmp=0.;gKA=0.;gMove=0.; source(p,uS1,scale);source(p,uS2,scale); float w=smoothstep(0.,uBeach,-sdRect(p,uIn,0.)); gGrad*=w;gHess*=w;gCurv*=w;gAmp*=w;gKA*=w;gMove*=w; } vec3 dither(vec3 c){ float m=max(c.r,max(c.g,c.b)); float n=(hash12(gl_FragCoord.xy)+hash12(gl_FragCoord.yx*1.37+17.)-1.)/255.; return max(c+n*smoothstep(0.,1.5/255.,m),0.); } `; const PACK = [ [0.15, 0.85], [-256, 256], [0, 0.004], [0.0025, 0.005], ]; function textureOf(layer) { const gl = layer.gl; const texture = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, HIST, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); return texture; } const PAPER_GLSL = `${WAVE_GLSL} uniform vec4 uOut;uniform float uLampR;uniform vec4 uPaper;uniform vec4 uBase;uniform vec2 uRound;uniform vec4 uLegs;uniform vec4 uPost; uniform vec4 uBar;uniform vec4 uBeam;uniform vec4 uMotor;uniform vec3 uHigh;uniform vec4 uBox;uniform float uBoxTop;uniform float uBlur;uniform vec3 uExpo;uniform vec3 uFoam; float gPen; vec2 hitAt(vec3 q,float z){return uLamp.xy+(q.xy-uLamp.xy)*(uLamp.z-z)/(uLamp.z-q.z);} float penAt(vec3 q,float z){return max(uLampR*(z-q.z)/(uLamp.z-q.z),gPen);} float rodLight(vec3 q,vec2 c,float rad,float top){ vec2 ab=uLamp.xy-q.xy;float h=(top-q.z)/(uLamp.z-q.z); float t=clamp(dot(c-q.xy,ab)/max(dot(ab,ab),1e-4),0.,h); float pen=max(uLampR*t,gPen); return smoothstep(rad-pen,rad+pen,length(q.xy+ab*t-c)); } float legClear(vec3 q,vec2 c){ vec3 v=towardViewer(); float top=(uLegs.w-q.z)/v.z; float t=clamp(dot(c-q.xy,v.xy)/max(dot(v.xy,v.xy),1e-4),0.,top); return smoothstep(uLegs.z+3.,uLegs.z+7.,length(q.xy+v.xy*t-c)); } float causticAt(vec2 u){ vec2 u0=u; field(u0,uMag);u0=u-uCaustic*gGrad; field(u0,uMag);u0=u-uCaustic*gGrad; field(u0,uMag); float c=uCaustic; float J=(1.+c*gHess.x)*(1.+c*gHess.z)-c*c*gHess.y*gHess.y; float b=sqrt(uBlur*uBlur+pow(.55*gPx/uMag,2.)+pow(.5*gMove,2.)); float e=.6*pow(max(gKA/max(gAmp,1e-6)*b,1e-4),.6667); return sqrt(1.+e*e)/sqrt(J*J+e*e); } float lampAt(vec3 q){ vec3 d=uLamp-q;float fall=pow(d.z/length(d),3.)*coneOf(q); float through=inRect(hitAt(q,uZ.y),uIn,0.,penAt(q,uZ.y)); through*=mix(uFoam.z,1.,inRect(hitAt(q,uFoam.x),uIn+vec4(uFoam.y,uFoam.y,-uFoam.y,-uFoam.y),0.,max(penAt(q,uFoam.x),uFoam.y*.5))); float open=1.-inRect(hitAt(q,uZ.w),uOut,1.5,penAt(q,uZ.w)); float caustic=1.; if(through>.001)caustic=causticAt(hitAt(q,uZ.z)); float shade=rodLight(q,vec2(uLegs.x,uLegs.y),uLegs.z,uLegs.w)*rodLight(q,vec2(-uLegs.x,uLegs.y),uLegs.z,uLegs.w)*rodLight(q,vec2(uLegs.x,-uLegs.y),uLegs.z,uLegs.w)*rodLight(q,vec2(-uLegs.x,-uLegs.y),uLegs.z,uLegs.w); shade*=rodLight(q,uPost.xy,uPost.z,uPost.w); shade*=1.-.96*inRect(hitAt(q,uHigh.x),uBar,1.,penAt(q,uHigh.x)); shade*=1.-.96*inRect(hitAt(q,uHigh.y),uBeam,1.,penAt(q,uHigh.y)); shade*=1.-.96*inRect(hitAt(q,uHigh.z),uMotor,3.,penAt(q,uHigh.z)); shade*=1.-.96*inRect(hitAt(q,uBoxTop),uBox,4.,penAt(q,uBoxTop)); return fall*shade*(through*caustic*mix(.6,1.1,clamp(caustic-1.,0.,1.))*.9+open); } void main(){ vec2 vb=viewOf(gl_FragCoord.xy); gPx=pixelOf()/uCam.z; float soft=.7*gPx; vec3 q=onFloor(vb,uZ.x); vec2 rest=q.xy; vec3 s=onFloor(vb,uZ.z); float wet=inRect(s.xy,uIn,0.,soft); vec2 shift=vec2(0.); if(wet>.001){ field(s.xy,1.); vec3 n=normalize(vec3(-gGrad,1.)); vec3 d=-towardViewer(); vec3 inWater=refract(d,n,1./1.333); vec3 flatWater=refract(d,vec3(0.,0.,1.),1./1.333); vec3 below=refract(inWater,vec3(0.,0.,1.),1.333); shift=(inWater.xy/-inWater.z-flatWater.xy/-flatWater.z)*(uZ.z-uBed)+(below.xy/-below.z-d.xy/-d.z)*(uBed-uZ.x); shift*=wet*legClear(q,vec2(uLegs.x,uLegs.y))*legClear(q,vec2(-uLegs.x,uLegs.y))*legClear(q,vec2(uLegs.x,-uLegs.y))*legClear(q,vec2(-uLegs.x,-uLegs.y)); } q.xy+=shift; #ifdef GL_OES_standard_derivatives float stretch=length(fwidth(q.xy))/max(length(fwidth(rest)),1e-6); #else float stretch=1.+8.*length(shift)/max(uZ.z-uZ.x,1.); #endif gPen=1.5*soft*max(stretch,1.); float base=inRect(q.xy,uBase,uRound.y,soft); if(base<.001){gl_FragColor=vec4(0.);return;} float paper=inRect(q.xy,uPaper,uRound.x,soft); float e=lampAt(q)*mix(1.,.84,wet); if(uPale>.5){ float lit=mix(1.-.05*exp(-e),1.-uExpo.x/(1.+pow(e/uExpo.y,uExpo.z)),paper); float off=(1.-mix(.95,1.-uExpo.x,paper))/.89*base; float ink=mix(off,(1.-lit)/.89*base,uLight); gl_FragColor=vec4(dither(vec3(.11,.11,.12)*ink),ink); }else{ float glow=(1.-exp(-uExpo.x*e))*mix(.34,.92,paper)*base*uLight; vec3 c=dither(vec3(glow)); gl_FragColor=vec4(c,c.r); } }`; const WATER_GLSL = `${WAVE_GLSL} uniform vec2 uNear;uniform float uContact;uniform float uFloor;uniform float uRim;uniform float uTop;uniform vec4 uEnv;uniform vec3 uGlint;uniform float uTint;uniform vec4 uWall;uniform vec2 uGlass; float beamAt(vec2 vb){ float sum=0.; float jitter=hash12(gl_FragCoord.xy); for(int i=0;i<28;i++){ float z=mix(uTop+1.,uLamp.z,(float(i)+jitter)/28.); vec3 p=onFloor(vb,z); float drop=uLamp.z-z; sum+=coneOf(p)*smoothstep(0.,16.,drop)/(1.+drop*drop/6400.); } return sum/28.; } float envAt(vec3 r,float blur){ float el=asin(clamp(r.z,-1.,1.)); float w=sqrt(uEnv.w*uEnv.w+blur*blur); float g=sqrt(uGlint.y*uGlint.y+blur*blur); float glint=(uGlint.y/g)*exp(-pow((el-asin(uCam.z)-uGlint.x)/g,2.)); return mix(uEnv.x,uEnv.y,smoothstep(-w,w,el-asin(uCam.z)))+uEnv.z*smoothstep(-.3,1.2,el)+uGlint.z*glint; } float lineAt(float x,float at,float width){return exp(-pow((x-at)/width,2.))/(width*1.772);} void main(){ vec2 vb=viewOf(gl_FragCoord.xy); float px=pixelOf(); gPx=px/uCam.z; float soft=.7*gPx; vec3 s=onFloor(vb,uZ.z); float wet=inRect(s.xy,uIn,0.,soft); vec3 v=towardViewer(); float add=0.; float tint=0.; if(wet>.001){ field(s.xy,1.); float lc=max(2.2,1.6*gPx); vec4 dw=vec4(s.x-uIn.x,uIn.z-s.x,s.y-uIn.y,uIn.w-s.y); vec4 tilt=1.1*exp(-max(dw,0.)/lc); vec2 g=-gGrad+vec2(tilt.x-tilt.y,tilt.z-tilt.w); vec3 n=normalize(vec3(g,1.)); float cosI=max(dot(n,v),0.); float fres=.02+.98*pow(1.-cosI,5.); vec3 r=reflect(-v,n); float spread=2.*gPx*(gCurv+dot(tilt,vec4(1.))/lc); float refl=fres*envAt(r,spread); float flat0=(.02+.98*pow(1.-uCam.z,5.))*envAt(vec3(-v.xy,v.z),0.); float rw=max(.45,1.2*soft); float rc=uContact+rw; float ring=(exp(-pow((length(s.xy-uS1)-rc)/rw,2.))+exp(-pow((length(s.xy-uS2)-rc)/rw,2.)))*(.45/rw); float lift=uPale>.5?max(refl-flat0,0.)/.15:refl; float sink=uPale>.5?max(flat0-refl,0.)/.74:0.; add+=(lift+ring*.12)*wet; tint+=(uTint+sink+ring*.05)*wet; } float pz=px/uCam.w; float sz=.7*pz; vec3 bx=onWallX(vb,uNear.x); vec3 by=onWallY(vb,uNear.y); float alongX=window(bx.y,uIn.y,uIn.w,.7*px/uCam.y); float alongY=window(by.x,uIn.x,uIn.z,.7*px/uCam.x); float bandX=window(bx.z,uFloor,uZ.z,sz)*alongX; float bandY=window(by.z,uFloor,uZ.z,sz)*alongY; float band=max(bandX,bandY); vec3 fx=onWallX(vb,uIn.x); vec3 fy=onWallY(vb,uIn.y); float glass=max(window(bx.z,uZ.z,uRim,sz)*alongX,window(by.z,uZ.z,uRim,sz)*alongY); glass=max(glass,max(window(fx.z,uZ.z,uRim,sz)*window(fx.y,uIn.y,uIn.w,.7*px/uCam.y),window(fy.z,uZ.z,uRim,sz)*window(fy.x,uIn.x,uIn.z,.7*px/uCam.x))); add+=glass*uGlass.x; tint+=glass*uGlass.y; float lw=max(.45,.9*pz); float lip=max(lineAt(bx.z,uZ.z+.5,lw)*alongX,lineAt(by.z,uZ.z+.5,lw)*alongY)*1.2; float sole=max(lineAt(bx.z,uFloor+.3,lw)*alongX,lineAt(by.z,uFloor+.3,lw)*alongY)*1.2; add+=band*uWall.x+(lip+sole*.35)*uWall.z; tint+=band*uWall.y+(lip+sole*.35)*uWall.w; if(uPale<.5)add+=tonemap(vec3(beamAt(vb)*.16),1.).r*.9; add=max(add,0.);tint=clamp(tint,0.,.9); float a=add+tint; float over=max(a,1.); vec3 ink=uPale>.5?vec3(.11,.11,.12):vec3(.02,.025,.03); vec3 c=dither(vec3(add)+ink*tint)/over; gl_FragColor=vec4(c,a/over)*uLight; }`; const FIXED = { uReach: D.reach, uSlope: D.slope, uCaustic: D.optics.caustic, uMag: D.optics.magnification, uIn: D.inside, uBeach: D.beach, uZ: D.z, uPale: pale ? 1 : 0, uBed: D.bed, uFloor: D.floor, uOut: D.outside, uLamp: D.lamp, uLampR: D.lampR, uBlur: D.blur, uPaper: D.paper, uBase: D.base, uRound: D.round, uLegs: D.legs, uPost: D.post, uBar: D.bar, uBeam: D.beam, uMotor: D.motor, uHigh: D.high, uBox: D.box, uBoxTop: D.boxTop, uNear: D.near, uContact: D.contact, uSpread: D.spread, uFoam: D.foam, uLo: PACK.map(([low]) => low), uSpan: PACK.map(([low, high]) => high - low), uExpo: pale ? [0.3, 0.7, 2.4] : [0.55, 0, 0], uEnv: pale ? [0.3, 1, 0, 0.02] : [0.04, 6, 0.3, 0.02], uGlass: pale ? [0, 0.05] : [0.02, 0.03], uGlint: pale ? [0.035, 0.006, 3] : [0.035, 0.006, 26], uRim: D.rim, uTop: D.top, uTint: pale ? 0.06 : 0.08, uWall: pale ? [0, 0.2, 0, 0.45] : [0.05, 0.16, 0.5, 0], }; const NAMES = ["uS1", "uS2", "uPhase", "uHist", "uIG", "uKNow", "uStep", "uLight"]; const paperLayer = glLayer({ canvas: paperCanvas, fragment: PAPER_GLSL, view: [D.W, D.H], camera: D.camera, uniforms: NAMES, fixed: FIXED, extensions: ["GL_OES_standard_derivatives"] }); const waterLayer = paperLayer ? glLayer({ canvas: waterCanvas, fragment: WATER_GLSL, view: [D.W, D.H], camera: D.camera, uniforms: NAMES, fixed: FIXED }) : null; const lit = !!(paperLayer && waterLayer); const bytes = new Uint8Array(HIST * 2 * 4); const textures = lit ? [paperLayer, waterLayer].map(textureOf) : []; if (lit) stage.setAttribute("data-gl", ""); const FR = D.range.frequency; const SR = D.range.spacing; const freq = { x: D.start.frequency, v: 0 }; const gap = { x: D.start.spacing, v: 0 }; const state = { tf: D.start.frequency, td: D.start.spacing, phase: D.phase, lamp: 0, lampClock: 0, clock: 0, pushed: 0, step: 0 }; let model = stateOf(freq.x, gap.x, D.optics); const history = { k: new Float64Array(HIST), phi: new Float64Array(HIST), omega: new Float64Array(HIST), decay: new Float64Array(HIST), slow: new Float64Array(HIST), at: new Float64Array(HIST) }; let phi = 0; let touring = !still.matches; let tourClock = -1.4; let tourFrom = { f: freq.x, d: gap.x }; let idleTimer = 0; let sayTimer = 0; let visible = false; let frame = 0; let last = 0; let dirty = true; let drawn = ""; const angleOf = (f) => ((-135 + (270 * (f - FR[0])) / (FR[1] - FR[0])) * Math.PI) / 180; function tourAt(time) { const f = 17 + 8.5 * Math.sin((time * TAU) / 28 - 0.3); const d = 50 + 25 * Math.sin((time * TAU) / 16 + 0.2); const blend = smooth(0, 1, time / 2.4); return { f: tourFrom.f + (f - tourFrom.f) * blend, d: tourFrom.d + (d - tourFrom.d) * blend }; } function announce() { clearTimeout(sayTimer); sayTimer = setTimeout(() => { const words = wordsOf(stateOf(state.tf, state.td, D.optics)); if (said.textContent !== words) said.textContent = words; }, 700); } function record(slot) { history.k[slot] = model.kMm; history.phi[slot] = phi; history.omega[slot] = TAU * model.frequency; history.decay[slot] = model.decay; history.slow[slot] = 1 / (model.group * 1000); history.at[slot] = state.clock; } function remember(dt, calm) { state.clock += dt; phi += TAU * model.frequency * dt; if (calm) { for (let slot = 0; slot < HIST; slot++) record(slot); state.pushed = state.clock; return; } while (state.clock - state.pushed >= D.histDt) { state.pushed += D.histDt; for (const list of Object.values(history)) list.copyWithin(1, 0, HIST - 1); } record(0); } function put(index, value, [low, high]) { const code = Math.round(clampTo((value - low) / (high - low), 0, 1) * 65535); bytes[index] = code >> 8; bytes[index + 1] = code & 255; } function packHistory() { for (let slot = 0; slot < HIST; slot++) { const lag = phi - history.phi[slot] - history.omega[slot] * (state.clock - history.at[slot]); put(slot * 4, history.k[slot], PACK[0]); put(slot * 4 + 2, slot ? lag : 0, PACK[1]); put((HIST + slot) * 4, history.decay[slot], PACK[2]); put((HIST + slot) * 4 + 2, history.slow[slot], PACK[3]); } for (const [index, layer] of [paperLayer, waterLayer].entries()) { const gl = layer.gl; gl.bindTexture(gl.TEXTURE_2D, textures[index]); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, HIST, 2, gl.RGBA, gl.UNSIGNED_BYTE, bytes); } } function drawFallback() { if (lit) return; const key = `${freq.x.toFixed(2)}|${gap.x.toFixed(1)}`; if (key === drawn) return; drawn = key; const pattern = patternOf(model, D.pattern, (point) => iso(point, D.P), D.phase); crestPath.setAttribute("d", pattern.crests); nodalPath.setAttribute("d", pattern.nodal); } function drawSvg() { const half = gap.x / 2; const bob = model.amplitude * Math.sin(state.phase); farDipper.setAttribute("transform", translateAlong(D.P, [0, -half, bob])); nearDipper.setAttribute("transform", translateAlong(D.P, [0, half, bob])); bar.setAttribute("transform", translateAlong(D.P, [0, 0, bob])); motor.setAttribute("transform", translateAlong(D.P, [0, 0, bob])); const a = angleOf(freq.x); const [kx, ky] = D.knob; pointer.setAttribute("d", lineOnTop([kx + Math.sin(a) * 1.4, ky - Math.cos(a) * 1.4], [kx + Math.sin(a) * 5.6, ky - Math.cos(a) * 5.6], D.boxTop + 8, D.P)); const [mx, my] = D.meter; needle.setAttribute("d", lineOnTop([mx - Math.sin(a) * 2.2, my + Math.cos(a) * 2.2], [mx + Math.sin(a) * 9.6, my - Math.cos(a) * 9.6], D.boxTop + 3.4, D.P)); const [ex, ez] = D.eccentric; eccentric.setAttribute("d", sideRing([ex + 0.05, 2.9 * Math.cos(state.phase), ez + bob + 2.9 * Math.sin(state.phase)], 1.05, "x", D.P, 14)); stage.style.setProperty("--rt-lamp", state.lamp.toFixed(3)); drawFallback(); } function drawGl() { if (!lit || !paperCanvas.clientWidth || !paperCanvas.clientHeight) return; if (state.lamp <= 0 && !pale) { paperLayer.clear(); waterLayer.clear(); return; } packHistory(); const values = { uS1: [D.xs, -gap.x / 2], uS2: [D.xs, gap.x / 2], uPhase: state.phase, uHist: [(state.clock - state.pushed) / D.histDt, D.histDt], uIG: 1 / (model.group * 1000), uKNow: model.kMm, uStep: state.step, uLight: state.lamp, }; paperLayer.draw(values, 0); if (state.lamp > 0) waterLayer.draw(values, 0); else waterLayer.clear(); } function tick(now) { const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60; last = now; const calm = still.matches; if (touring && !calm) { tourClock += dt; if (tourClock >= 0) { const aim = tourAt(tourClock); state.tf = aim.f; state.td = aim.d; } } const before = [freq.x, gap.x]; settle(freq, state.tf, dt, 0.625, calm); settle(gap, state.td, dt, 0.4, calm); if (lit) { state.lampClock += dt; state.lamp = calm ? 1 : smooth(0, 1, (state.lampClock - 0.15) / 0.9); } if (!calm) state.phase = (state.phase + dt * TAU * D.beat) % TAU; state.step = calm ? 0 : dt; const changed = dirty || before[0] !== freq.x || before[1] !== gap.x; if (changed) model = stateOf(freq.x, gap.x, D.optics); remember(dt, calm); if (changed || !calm) { drawSvg(); drawGl(); if (readout.textContent !== readoutOf(model)) readout.textContent = readoutOf(model); } dirty = false; const settled = calm && freq.x === state.tf && gap.x === state.td && (!lit || state.lamp === 1); frame = visible && !settled ? requestAnimationFrame(tick) : 0; } function run() { if (!frame && visible) { last = 0; frame = requestAnimationFrame(tick); } } function hold() { touring = false; clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (still.matches) return; touring = true; tourClock = -0.001; tourFrom = { f: freq.x, d: gap.x }; run(); }, 3600); } function steer(f, d) { if (touring) { freq.v = 0; gap.v = 0; } state.tf = clampTo(f, FR[0], FR[1]); state.td = clampTo(d, SR[0], SR[1]); dirty = true; hold(); announce(); run(); } new IntersectionObserver( (entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); }, { rootMargin: "120px 0px" }, ).observe(stage); function aimOf(event) { const box = stage.getBoundingClientRect(); const fx = clampTo(((event.clientX - box.left) / box.width - 0.12) / 0.76, 0, 1); const fy = 1 - clampTo(((event.clientY - box.top) / box.height - 0.16) / 0.66, 0, 1); return [FR[0] + fx * (FR[1] - FR[0]), SR[0] + fy * (SR[1] - SR[0])]; } stage.addEventListener("pointermove", (event) => { if (event.pointerType === "touch") { if (!event.buttons) return; steer(aimOf(event)[0], touring ? gap.x : state.td); return; } const [f, d] = aimOf(event); steer(f, d); }); stage.addEventListener("pointerdown", (event) => { const [f, d] = aimOf(event); steer(f, d); }); stage.addEventListener("keydown", (event) => { const big = event.shiftKey; let f = touring ? freq.x : state.tf; let d = touring ? gap.x : state.td; if (event.key === "ArrowRight") f += big ? 2 : 0.5; else if (event.key === "ArrowLeft") f -= big ? 2 : 0.5; else if (event.key === "ArrowUp") d += big ? 10 : 2; else if (event.key === "ArrowDown") d -= big ? 10 : 2; else if (event.key === "Home") f = FR[0]; else if (event.key === "End") f = FR[1]; else return; event.preventDefault(); steer(f, d); }); still.addEventListener("change", () => { touring = !still.matches; dirty = true; run(); }); if (matchMedia("(hover: none)").matches) { const hint = root.querySelector('[data-corner="hint"]'); if (hint) hint.textContent = "Tap to set f and spacing · drag across for f"; } window.addEventListener("resize", () => { dirty = true; run(); }); stage.addEventListener("focus", () => { said.textContent = wordsOf(model); }); for (let slot = 0; slot < HIST; slot++) record(slot); drawSvg(); drawGl(); readout.textContent = readoutOf(model); -
model.mjs 4.9 KB · in bundle
-
ripple-tank-light.html 493.6 KB · in bundle
-
ripple-tank.html 493.6 KB · in bundle
-
ripple.css 1.6 KB · in bundle
-
-
spot-plate
-
art.tsx 4.4 KB · in bundle
-
drops.ts 18.7 KB
import { type Flat, iso, pathOf, type Plane } from "../../kit/iso-kit"; import { BIG_Y, CLIMB, FLOOR, GAPS, LIGHT, P, RA, RB, THICK, VIEW, fieldOf, rowX, rowY, spanOf, type Field } from "./rig"; const STEP = 2; const FINE = 0.5; const FINE_HALF = 6; const NUDGE = -0.05; const KISS = 0.75; const WINDOW = 4; const BEND = 0.3; const BEVEL = 2.2; const TAPER = 10; const SHADES = 4; const STRIP = 8; const SHINE_IN = 3.4; const SHINE_FACING = 0.82; const FADE_SHARE = 0.3; const FADE_MOST = 9; const FADE_STEPS = 6; interface Grid { values: Float32Array; us: Float64Array; vs: Float64Array; } function ticksOf(centre: number, before: number, after: number) { const ticks: number[] = []; for (let k = -Math.ceil(before / STEP); k <= Math.ceil(after / STEP); k++) { if (Math.abs(k * STEP) > FINE_HALF) ticks.push(centre + k * STEP); } const fine = Math.round(FINE_HALF / FINE); for (let k = -fine; k <= fine; k++) ticks.push(centre + k * FINE); return Float64Array.from(ticks.sort((a, b) => a - b)); } function sample(field: Field, gap: number): Grid { const middle = RA + gap / 2; const us = ticksOf(middle, middle + RA + 6, spanOf(gap) + RB + 6 - middle); const vs = ticksOf(0, RA + 6, RA + 6); const nx = us.length; const ny = vs.length; const values = new Float32Array(nx * ny); for (let j = 0; j < ny; j++) { const mirror = ny - 1 - j; if (mirror < j) { values.copyWithin(j * nx, mirror * nx, mirror * nx + nx); continue; } const v = vs[j]!; for (let i = 0; i < nx; i++) values[j * nx + i] = field(us[i]!, v); } return { values, us, vs }; } const SEGMENTS: Record<number, [number, number][]> = { 1: [[3, 2]], 2: [[2, 1]], 3: [[3, 1]], 4: [[0, 1]], 6: [[0, 2]], 7: [[3, 0]], 8: [[3, 0]], 9: [[0, 2]], 11: [[0, 1]], 12: [[3, 1]], 13: [[2, 1]], 14: [[3, 2]], }; function rings({ values, us, vs }: Grid, level: number): Plane[][] { const nx = us.length; const ny = vs.length; const at = (i: number, j: number) => values[j * nx + i]!; const links = new Map<number, number[]>(); const points = new Map<number, Plane>(); const pointOf = (id: number) => { if (points.has(id)) return; const cell = id >> 1; const i = cell % nx; const j = (cell - i) / nx; const down = id & 1; const a = at(i, j); const b = down ? at(i, j + 1) : at(i + 1, j); const t = Math.min(Math.max((level - a) / (b - a), 0), 1); points.set(id, down ? [us[i]!, vs[j]! + (vs[j + 1]! - vs[j]!) * t] : [us[i]! + (us[i + 1]! - us[i]!) * t, vs[j]!]); }; const link = (a: number, b: number) => { pointOf(a); pointOf(b); (links.get(a) ?? links.set(a, []).get(a)!).push(b); (links.get(b) ?? links.set(b, []).get(b)!).push(a); }; for (let j = 0; j < ny - 1; j++) { for (let i = 0; i < nx - 1; i++) { const tl = at(i, j); const tr = at(i + 1, j); const br = at(i + 1, j + 1); const bl = at(i, j + 1); const code = (tl < level ? 8 : 0) | (tr < level ? 4 : 0) | (br < level ? 2 : 0) | (bl < level ? 1 : 0); if (code === 0 || code === 15) continue; const edges = [(j * nx + i) * 2, ((j * nx + i + 1) * 2) | 1, ((j + 1) * nx + i) * 2, ((j * nx + i) * 2) | 1]; let pairs = SEGMENTS[code]; if (!pairs) { const centre = (tl + tr + br + bl) / 4 < level; if (code === 5) pairs = centre ? [[3, 0], [2, 1]] : [[0, 1], [3, 2]]; else pairs = centre ? [[0, 1], [3, 2]] : [[3, 0], [2, 1]]; } for (const [a, b] of pairs) link(edges[a]!, edges[b]!); } } const seen = new Set<number>(); const found: Plane[][] = []; for (const start of links.keys()) { if (seen.has(start)) continue; const ring: Plane[] = []; let previous = -1; let current = start; while (!seen.has(current)) { seen.add(current); ring.push(points.get(current)!); const next = links.get(current)!.find((candidate) => candidate !== previous && !seen.has(candidate)); if (next === undefined) break; previous = current; current = next; } if (ring.length > 3) found.push(ring); } return found; } function simplify(points: Plane[], tolerance: number): Plane[] { if (points.length < 3) return points; const first = points[0]!; const last = points[points.length - 1]!; const dx = last[0] - first[0]; const dy = last[1] - first[1]; const length = Math.hypot(dx, dy) || 1; let worst = 0; let index = 0; for (let step = 1; step < points.length - 1; step++) { const point = points[step]!; const off = Math.abs((point[0] - first[0]) * dy - (point[1] - first[1]) * dx) / length; if (off > worst) { worst = off; index = step; } } if (worst <= tolerance) return [first, last]; return [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)]; } function simplifyRing(ring: Plane[], tolerance: number): Plane[] { let far = 0; let best = 0; for (let index = 1; index < ring.length; index++) { const distance = Math.hypot(ring[index]![0] - ring[0]![0], ring[index]![1] - ring[0]![1]); if (distance > best) { best = distance; far = index; } } const one = simplify(ring.slice(0, far + 1), tolerance); const two = simplify([...ring.slice(far), ring[0]!], tolerance); return [...one.slice(0, -1), ...two.slice(0, -1)]; } function signedArea(points: Plane[] | Flat[]) { let area = 0; for (let index = 0; index < points.length; index++) { const [ax, ay] = points[index]!; const [bx, by] = points[(index + 1) % points.length]!; area += ax * by - bx * ay; } return area / 2; } const oriented = (points: Flat[]) => (signedArea(points) < 0 ? [...points].reverse() : points); function runsOf(flags: boolean[], want: boolean) { const count = flags.length; const runs: number[][] = []; const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]); if (start < 0) return flags[0] === want ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : []; let run: number[] | null = null; for (let step = 0; step < count; step++) { const index = (start + step) % count; if (flags[index] === want) { if (!run) run = [index]; run.push((index + 1) % count); } else if (run) { runs.push(run); run = null; } } if (run) runs.push(run); return runs; } export interface GlassShape { fill: string; shades: { d: string; shade: number }[]; top: string; crease: string; bevel: string; outline: string; shine: string; depth: number; } const ease = (t: number) => t * t * (3 - 2 * t); type Hidden = (x: number, y: number, z: number, edge: number) => boolean; function hiddenBy(ring: Plane[], top: number): Hidden { const count = ring.length; const xs = new Float64Array(count); const ys = new Float64Array(count); for (let index = 0; index < count; index++) { xs[index] = ring[index]![0]; ys[index] = ring[index]![1]; } const inside = (x: number, y: number) => { let hit = false; for (let index = 0, previous = count - 1; index < count; previous = index++) { const ay = ys[index]!; const by = ys[previous]!; if (ay > y !== by > y && x < ((xs[previous]! - xs[index]!) * (y - ay)) / (by - ay) + xs[index]!) hit = !hit; } return hit; }; return (x, y, z, edge) => { const reach = (top - z) / CLIMB; if (reach <= 0) return false; if (inside(x + VIEW[0] * 0.05, y + VIEW[1] * 0.05)) return true; const fx = x + VIEW[0] * reach; const fy = y + VIEW[1] * reach; const left = Math.min(x, fx); const right = Math.max(x, fx); const low = Math.min(y, fy); const high = Math.max(y, fy); for (let index = 0; index < count; index++) { if (index === edge || (edge < 0 && (index === -edge - 1 || (index + 1) % count === -edge - 1))) continue; const next = index + 1 === count ? 0 : index + 1; const ax = xs[index]!; const ay = ys[index]!; const bx = xs[next]!; const by = ys[next]!; if ((ax < left && bx < left) || (ax > right && bx > right) || (ay < low && by < low) || (ay > high && by > high)) continue; const ex = bx - ax; const ey = by - ay; const cross = VIEW[0] * ey - VIEW[1] * ex; if (Math.abs(cross) < 1e-12) continue; const ox = ax - x; const oy = ay - y; const t = (ox * ey - oy * ex) / cross; const u = (ox * VIEW[1] - oy * VIEW[0]) / cross; if (t > 0.05 && t < reach && u >= 0 && u <= 1) return true; } return false; }; } function visibleRuns(ring: Plane[], run: number[], z: number, hidden: Hidden): Flat[][] { const runs: Flat[][] = []; let line: Flat[] = []; const shown = run.map((index) => !hidden(ring[index]![0], ring[index]![1], z, -index - 1)); const lift = ([x, y]: Plane) => iso([x, y, z], P); const cut = (edge: number, fromShown: boolean): Plane => { const a = ring[edge]!; const b = ring[(edge + 1) % ring.length]!; let low = 0; let high = 1; for (let round = 0; round < 14; round++) { const mid = (low + high) / 2; const seen = !hidden(a[0] + (b[0] - a[0]) * mid, a[1] + (b[1] - a[1]) * mid, z, edge); if (seen === fromShown) low = mid; else high = mid; } const t = (low + high) / 2; return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; }; for (let index = 0; index < run.length; index++) { const point = ring[run[index]!]!; if (index > 0 && shown[index] !== shown[index - 1]) { const edge = cut(run[index - 1]!, shown[index - 1]!); if (shown[index - 1]) { line.push(lift(edge)); if (line.length > 1) runs.push(line); line = []; } else { line = [lift(edge)]; } } if (shown[index]) line.push(lift(point)); } if (line.length > 1) runs.push(line); return runs; } function solidOf(ring: Plane[], z: number, height: number, occlude: boolean): GlassShape { const count = ring.length; const counter = signedArea(ring) > 0; const normals = ring.map(([ax, ay], index) => { const [bx, by] = ring[(index + 1) % count]!; const dx = bx - ax; const dy = by - ay; const length = Math.hypot(dx, dy) || 1; return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]) as Plane; }); const facing = normals.map(([nx, ny]) => nx * VIEW[0] + ny * VIEW[1] > 1e-9); const top = ring.map(([x, y]) => iso([x, y, z + height], P)); const bottom = ring.map(([x, y]) => iso([x, y, z], P)); const hidden = occlude ? hiddenBy(ring, z + height) : null; const strips: { depth: number; shade: number; d: string }[] = []; let open: { from: number; to: number; shade: number } | null = null; const close = () => { if (!open) return; const upper: Flat[] = []; const lower: Flat[] = []; let depth = 0; for (let index = open.from; index <= open.to + 1; index++) { const point = index % count; upper.push(top[point]!); lower.push(bottom[point]!); depth += ring[point]![0] * VIEW[0] + ring[point]![1] * VIEW[1]; } strips.push({ depth: depth / upper.length, shade: open.shade, d: pathOf(oriented([...upper, ...lower.reverse()]), true) }); open = null; }; for (let index = 0; index < count; index++) { if (!facing[index]) { close(); continue; } const [nx, ny] = normals[index]!; const lit = (nx * LIGHT[0] + ny * LIGHT[1] + 1) / 2; const shade = Math.min(SHADES - 1, Math.max(0, Math.floor(lit * SHADES))); if (open && open.shade === shade && index - open.from < STRIP) open.to = index; else { close(); open = { from: index, to: index, shade }; } } close(); strips.sort((a, b) => a.depth - b.depth); const shades: { d: string; shade: number }[] = []; for (const strip of strips) { const last = shades[shades.length - 1]; if (last && last.shade === strip.shade) last.d += strip.d; else shades.push({ d: strip.d, shade: strip.shade }); } const inward = (index: number, by: number): Plane => { const [ax, ay] = normals[(index + count - 1) % count]!; const [bx, by2] = normals[index]!; const mx = ax + bx; const my = ay + by2; const length = Math.hypot(mx, my) || 1; const [x, y] = ring[index]!; return [x - (mx / length) * by, y - (my / length) * by]; }; const crease: string[] = []; const outline: string[] = []; const bevel: string[] = []; for (const run of runsOf(facing, false)) outline.push(pathOf(run.map((index) => top[index]!))); for (const run of runsOf(facing, true)) { crease.push(pathOf(run.map((index) => top[index]!))); if (hidden) for (const seen of visibleRuns(ring, run, z, hidden)) outline.push(pathOf(seen)); else outline.push(pathOf(run.map((index) => bottom[index]!))); const along = [0]; for (let step = 1; step < run.length; step++) { const [ax, ay] = ring[run[step - 1]!]!; const [bx, by] = ring[run[step]!]!; along.push(along[step - 1]! + Math.hypot(bx - ax, by - ay)); } const total = along[along.length - 1]!; bevel.push( pathOf( run.map((index, step) => { const near = Math.min(along[step]!, total - along[step]!); const [x, y] = inward(index, BEVEL * ease(Math.min(1, near / TAPER))); return iso([x, y, z + height], P); }), ), ); } for (let index = 0; index < count; index++) { if (facing[(index + count - 1) % count] === facing[index]) continue; const [x, y] = ring[index]!; let from = z; if (hidden && hidden(x, y, z, -index - 1)) { let low = z; let high = z + height; for (let round = 0; round < 12; round++) { const mid = (low + high) / 2; if (hidden(x, y, mid, -index - 1)) low = mid; else high = mid; } from = high; } if (z + height - from > 0.05) outline.push(pathOf([top[index]!, iso([x, y, from], P)])); } const toward = normals.map(([nx, ny], index) => { const [px, py] = normals[(index + count - 1) % count]!; return ((nx + px) * LIGHT[0] + (ny + py) * LIGHT[1]) / 2 > SHINE_FACING; }); const shine = runsOf(toward, true) .filter((run) => run.length > 3) .map((run) => pathOf(run.slice(1, -1).map((index) => iso([...inward(index, SHINE_IN), z + height] as [number, number, number], P)))) .join(""); const cx = ring.reduce((sum, [x]) => sum + x, 0) / count; const cy = ring.reduce((sum, [, y]) => sum + y, 0) / count; return { fill: [pathOf(oriented(top), true), pathOf(oriented(bottom), true)].join(""), shades, top: pathOf(oriented(top), true), crease: crease.join(""), bevel: bevel.join(""), outline: outline.join(""), shine, depth: cx * VIEW[0] + cy * VIEW[1], }; } export interface Glint { d: string; alpha: number; } export interface RowShape { row: number; gap: number; joined: boolean; glass: GlassShape[]; neck: Glint[]; } const atTop = (points: Plane[]) => points.map(([x, y]) => iso([x, y, FLOOR + THICK], P)); function concaveRuns(ring: Plane[]): number[][] { const count = ring.length; const along = [0]; for (let index = 0; index < count; index++) { const [ax, ay] = ring[index]!; const [bx, by] = ring[(index + 1) % count]!; along.push(along[index]! + Math.hypot(bx - ax, by - ay)); } const total = along[count]!; const pointAt = (distance: number): Plane => { const s = ((distance % total) + total) % total; let low = 0; let high = count; while (high - low > 1) { const mid = (low + high) >> 1; if (along[mid]! <= s) low = mid; else high = mid; } const [ax, ay] = ring[low]!; const [bx, by] = ring[(low + 1) % count]!; const t = (s - along[low]!) / Math.max(along[low + 1]! - along[low]!, 1e-9); return [ax + (bx - ax) * t, ay + (by - ay) * t]; }; const turn = signedArea(ring) > 0 ? 1 : -1; const flags = ring.map(([x, y], index) => { const [ax, ay] = pointAt(along[index]! - WINDOW); const [bx, by] = pointAt(along[index]! + WINDOW); return turn * ((x - ax) * (by - y) - (y - ay) * (bx - x)) < -BEND; }); return runsOf(flags, true).filter((run) => run.length > 2); } function glintsOf(points: Flat[]): Glint[] { const along = [0]; for (let index = 1; index < points.length; index++) { along.push(along[index - 1]! + Math.hypot(points[index]![0] - points[index - 1]![0], points[index]![1] - points[index - 1]![1])); } const total = along[along.length - 1]!; if (total < 0.5) return []; const cut = (from: number, to: number): Flat[] => { const out: Flat[] = []; const place = (distance: number) => { let index = 1; while (index < along.length - 1 && along[index]! < distance) index++; const t = (distance - along[index - 1]!) / Math.max(along[index]! - along[index - 1]!, 1e-9); const [ax, ay] = points[index - 1]!; const [bx, by] = points[index]!; return [ax + (bx - ax) * t, ay + (by - ay) * t] as Flat; }; out.push(place(from)); for (let index = 0; index < points.length; index++) if (along[index]! > from && along[index]! < to) out.push(points[index]!); out.push(place(to)); return out; }; const fade = Math.min(total * FADE_SHARE, FADE_MOST); const glints: Glint[] = []; let before = 0; for (let layer = 0; layer < FADE_STEPS; layer++) { const now = layer === FADE_STEPS - 1 ? 1 : ease((layer + 1) / FADE_STEPS); const alpha = Math.round((1 - (1 - now) / (1 - before)) * 100) / 100; before = now; const inset = (fade * layer) / (FADE_STEPS - 1); glints.push({ d: pathOf(cut(inset, total - inset)), alpha }); } return glints; } const cache = new Map<number, RowShape>(); const CACHE_MOST = 420; export const keyOf = (merge: number) => Math.round(merge * 4); export function rowOf(row: number, merge: number): RowShape { const gap = GAPS[row]!; const plain = gap >= 2 * merge; const key = row * 1000 + (plain ? 999 : keyOf(merge)); const known = cache.get(key); if (known) return known; const exact = plain ? 0 : keyOf(merge) / 4; const x0 = rowX(row); const y0 = rowY(row) + BIG_Y; const toWorld = ([u, v]: Plane): Plane => [x0 + v, y0 + u]; const field = fieldOf(gap, exact); const kiss = !plain && keyOf(merge) === gap * 4; const level = kiss ? field(RA + gap / 2, KISS) : NUDGE; const found = rings(sample(field, gap), level).map((ring) => simplifyRing(ring, 0.06)); const joined = gap < exact && found.length === 1; const whole = found.length === 1; const glass = found.map((local) => solidOf(local.map(toWorld), FLOOR, THICK, whole)).sort((a, b) => a.depth - b.depth); const neck = joined ? concaveRuns(found[0]!.map(toWorld)).flatMap((run) => glintsOf(atTop(run.map((index) => toWorld(found[0]![index]!))))) : []; const shape: RowShape = { row, gap, joined, glass, neck }; if (cache.size >= CACHE_MOST) cache.delete(cache.keys().next().value!); cache.set(key, shape); return shape; } export const rowsOf = (merge: number) => GAPS.map((_, row) => rowOf(row, merge)); -
fan.ts 2.7 KB
import { extrude, iso, pathOf, type Plane } from "../../kit/iso-kit"; import { BLADE_LONG, BLADE_Z, DECK_H, GAPS, LEAD_H, LEAD_LONG, P, PIVOT, ROWS, angleOf, thicknessOf } from "./rig"; const RADIANS = Math.PI / 180; const BUTT = 7.2; const TIP = 4.2; const LEAD_TIP = 3.2; const ARC = 10; export interface BladePose { fill: string; shades: string[]; top: string; crease: string; outline: string; line: string; } export interface FanPose { blades: BladePose[]; halo: string; out: boolean[]; } function outlineOf(length: number, tip: number): Plane[] { const out: Plane[] = []; for (let step = 0; step <= ARC; step++) { const angle = -90 + (180 * step) / ARC; out.push([length + tip * Math.cos(angle * RADIANS), tip * Math.sin(angle * RADIANS)]); } for (let step = 0; step <= ARC; step++) { const angle = 90 + (180 * step) / ARC; out.push([BUTT * Math.cos(angle * RADIANS), BUTT * Math.sin(angle * RADIANS)]); } return out; } const BLADE = outlineOf(BLADE_LONG, TIP); const LEAD = outlineOf(LEAD_LONG, LEAD_TIP); function place(shape: Plane[], degrees: number): Plane[] { const a = degrees * RADIANS; const dx = Math.sin(a); const dy = -Math.cos(a); const nx = Math.cos(a); const ny = Math.sin(a); return shape.map(([u, v]) => [PIVOT[0] + u * dx + v * nx, PIVOT[1] + u * dy + v * ny]); } function along(degrees: number, from: number, to: number, z: number) { const a = degrees * RADIANS; const dx = Math.sin(a); const dy = -Math.cos(a); return pathOf([iso([PIVOT[0] + from * dx, PIVOT[1] + from * dy, z], P), iso([PIVOT[0] + to * dx, PIVOT[1] + to * dy, z], P)]); } function bladeAt(shape: Plane[], degrees: number, z: number, height: number, length: number): BladePose { const ring = place(shape, degrees); const solid = extrude(ring, z, height, P, { convex: true }); return { fill: solid.fill, shades: solid.shades ?? [], top: solid.top, crease: solid.crease, outline: solid.outline, line: along(degrees, 13, length - 6, z + height), }; } export function fanOf(merge: number): FanPose { const lead = angleOf(merge); const blades: BladePose[] = []; const halo: string[] = []; const out: boolean[] = []; GAPS.forEach((gap, index) => { const angle = Math.min(lead, angleOf(gap)); out.push(gap < merge); blades.push(bladeAt(BLADE, angle, BLADE_Z[index]!, thicknessOf(gap), BLADE_LONG)); halo.push(pathOf(place(BLADE, angle).map(([x, y]) => iso([x, y, DECK_H], P)), true)); }); blades.push(bladeAt(LEAD, lead, BLADE_Z[ROWS]!, LEAD_H, LEAD_LONG)); halo.push(pathOf(place(LEAD, lead).map(([x, y]) => iso([x, y, DECK_H], P)), true)); return { blades, halo: halo.join(""), out }; } -
figure.tsx 1.1 KB · in bundle
-
geometry.ts 10.7 KB
import { circleRing, extrude, insetPlan, onTop, pathOf, type Plan, type Plane, planOutline, type Point3, ringOf, type SolidPaths, topMatrix } from "../../kit/iso-kit"; import { BASE, BASE_H, BOTTLE, DATUM_Y, DECK, DECK_H, FOOT_H, GAPS, MERGE_MOST, P, PIVOT, SCALE_R, STACK_TOP, STOP_R, SWEEP, TRAY, TRAY_H, FLOOR, VIEW, WASHER_H, WELL_D, WELL_HALF, WELLS, BORDER, angleOf, at, rowX, rowY, ROW_RIM, } from "./rig"; const RADIANS = Math.PI / 180; const VIEW_ANGLE = Math.atan2(VIEW[1], VIEW[0]); const line = (points: Point3[]) => pathOf(points.map(at)); const lines = (runs: Point3[][]) => runs.map(line).join(""); function clean(ring: Plane[]): Plane[] { return ring.filter(([x, y], index) => { const [nx, ny] = ring[(index + 1) % ring.length]!; return Math.hypot(nx - x, ny - y) > 1e-6; }); } const solid = (plan: Plan, z: number, height: number, steps = 6, bevel = 0): SolidPaths => extrude(clean(ringOf(plan, steps)), z, height, P, { convex: true, bevel }); const cyl = (x: number, y: number, r: number, z: number, h: number, steps = 28): SolidPaths => extrude(circleRing(x, y, r, steps), z, h, P); const ring = (x: number, y: number, r: number, z: number, steps = 24) => onTop(circleRing(x, y, r, steps), z, P, true); function knurl(x: number, y: number, r: number, z0: number, z1: number, count = 9) { const runs: Point3[][] = []; for (let index = 0; index < count; index++) { const angle = VIEW_ANGLE - Math.PI * 0.42 + (Math.PI * 0.84 * index) / (count - 1); const px = x + r * Math.cos(angle); const py = y + r * Math.sin(angle); runs.push([ [px, py, z0 + 0.4], [px, py, z1 - 0.4], ]); } return lines(runs); } function normalsOf(points: Plane[]) { const count = points.length; let area = 0; for (let index = 0; index < count; index++) { const [ax, ay] = points[index]!; const [bx, by] = points[(index + 1) % count]!; area += ax * by - bx * ay; } const counter = area > 0; return points.map(([ax, ay], index) => { const [bx, by] = points[(index + 1) % count]!; const dx = bx - ax; const dy = by - ay; const length = Math.hypot(dx, dy) || 1; return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]) as Plane; }); } function runOf(points: Plane[], want: boolean) { const count = points.length; const facing = normalsOf(points).map(([nx, ny]) => nx * VIEW[0] + ny * VIEW[1] > 1e-9); const start = facing.findIndex((flag, index) => flag === want && facing[(index + count - 1) % count] !== want); if (start < 0) return [] as Plane[]; const run: Plane[] = [points[start]!]; for (let step = 0; step < count && facing[(start + step) % count] === want; step++) run.push(points[(start + step + 1) % count]!); return run; } const visibleRun = (plan: Plan, z: number, steps = 8) => onTop(runOf(clean(ringOf(plan, steps)), true), z, P); const corners = ({ x, y, w, d }: Plan, inset: number): Plane[] => [ [x + inset, y + inset], [x + w - inset, y + inset], [x + w - inset, y + d - inset], [x + inset, y + d - inset], ]; const FEET = corners(BASE, 18); const BASE_SCREWS = corners(BASE, 6); export const BASE_ART = { halo: solid(insetPlan(BASE, 6), -BASE_H - FOOT_H, 0.4, 8).fill, feet: FEET.map(([x, y]) => [cyl(x, y, 3.6, -BASE_H - FOOT_H, FOOT_H - 1.2, 24), cyl(x, y, 4.8, -BASE_H - 1.2, 1.2, 28)]), slab: solid(BASE, -BASE_H, BASE_H, 10, 2.2), groove: visibleRun(BASE, -BASE_H * 0.45, 10), inlay: planOutline(insetPlan(BASE, 4), 0, P, 8), screws: BASE_SCREWS.map(([x, y]) => at([x, y, 0])), screwRings: BASE_SCREWS.map(([x, y]) => ring(x, y, 1.6, 0, 18)).join(""), }; function wellOf(plan: Plan) { const points = clean(ringOf(plan, 12)); const back = runOf(points, false); const wall = back.length > 1 ? pathOf([...back.map(([x, y]) => at([x, y, TRAY_H])), ...[...back].reverse().map(([x, y]) => at([x, y, FLOOR]))], true) : ""; return { opening: onTop(points, TRAY_H, P, true), wall, foot: onTop(back, FLOOR, P), }; } const RULE_X = WELL_HALF + 2.2; const RULE_MINOR = 2.2; const RULE_MAJOR = 4.6; const RULE_REACH = MERGE_MOST; function rulerOf(row: number) { const x0 = rowX(row) + RULE_X; const minor: Point3[][] = []; const major: Point3[][] = []; for (let step = 0; step <= RULE_REACH; step += 2) { const y = rowY(row) + DATUM_Y + step; (step % 8 === 0 ? major : minor).push([ [x0, y, TRAY_H], [x0 + (step % 8 === 0 ? RULE_MAJOR : RULE_MINOR), y, TRAY_H], ]); } return { minor, major }; } const RULERS = GAPS.map((_, row) => rulerOf(row)); const countY = (row: number) => rowY(row) + WELL_D + (row < 3 ? ROW_RIM : BORDER) / 2; const TRAY_SCREWS = corners(TRAY, 7); export const TRAY_ART = { screws: TRAY_SCREWS.map(([x, y]) => at([x, y, TRAY_H])), screwRings: TRAY_SCREWS.map(([x, y]) => ring(x, y, 1.7, TRAY_H, 18)).join(""), slab: solid(TRAY, 0, TRAY_H, 10, 2.4), groove: visibleRun(TRAY, TRAY_H * 0.42, 10), inlay: planOutline(insetPlan(TRAY, 4.2), TRAY_H, P, 10), wells: WELLS.map(wellOf), minor: lines(RULERS.flatMap((ruler) => ruler.minor)), major: lines(RULERS.flatMap((ruler) => ruler.major)), datum: lines( GAPS.map((_, row) => [ [rowX(row) - WELL_HALF - 3.4, rowY(row) + DATUM_Y, TRAY_H], [rowX(row) - WELL_HALF - 0.8, rowY(row) + DATUM_Y, TRAY_H], ]), ), gapMarks: GAPS.map((gap, row) => at([rowX(row) + RULE_X + RULE_MAJOR + 2.4, rowY(row) + DATUM_Y + gap, TRAY_H])), counts: GAPS.flatMap((_, row) => Array.from({ length: row + 1 }, (__, dot) => at([rowX(row) + (dot - row / 2) * 3.2, countY(row), TRAY_H])), ), }; function sector(r0: number, r1: number, from: number, to: number, steps = 40): Plane[] { const out: Plane[] = []; const point = (r: number, degrees: number): Plane => [PIVOT[0] + r * Math.sin(degrees * RADIANS), PIVOT[1] - r * Math.cos(degrees * RADIANS)]; for (let step = 0; step <= steps; step++) out.push(point(r1, from + ((to - from) * step) / steps)); for (let step = steps; step >= 0; step--) out.push(point(r0, from + ((to - from) * step) / steps)); return out; } const polar = (r: number, degrees: number, z: number): Point3 => [PIVOT[0] + r * Math.sin(degrees * RADIANS), PIVOT[1] - r * Math.cos(degrees * RADIANS), z]; const BAND_IN = SCALE_R - 2; const BAND_OUT = SCALE_R + 10; const BAND_FROM = -7; const BAND_TO = SWEEP + 7; const BAND_SPLIT = (Math.atan2(VIEW[1], VIEW[0]) * 180) / Math.PI; const BAND_TOP = STACK_TOP; function scaleTicks() { const minor: Point3[][] = []; const major: Point3[][] = []; for (let merge = 0; merge <= MERGE_MOST; merge += 2) { const angle = angleOf(merge); const long = merge % 8 === 0; (long ? major : minor).push([polar(BAND_IN + 1.2, angle, BAND_TOP), polar(BAND_IN + (long ? 6 : 3.6), angle, BAND_TOP)]); } return { minor: lines(minor), major: lines(major) }; } const TICKS = scaleTicks(); const DECK_SCREWS = corners(DECK, 6); const STOPS = GAPS.map((gap) => polar(STOP_R, angleOf(gap), DECK_H)); export const DECK_ART = { slab: solid(DECK, 0, DECK_H, 8, 1.6), inlay: planOutline(insetPlan(DECK, 3.4), DECK_H, P, 8), screws: DECK_SCREWS.map(([x, y]) => at([x, y, DECK_H])), screwRings: DECK_SCREWS.map(([x, y]) => ring(x, y, 1.5, DECK_H, 18)).join(""), guide: onTop( Array.from({ length: 61 }, (_, step) => { const [x, y] = polar(STOP_R + 3, (SWEEP * step) / 60, DECK_H); return [x, y] as Plane; }), DECK_H, P, ), stops: STOPS.map(([x, y]) => cyl(x, y, 1.3, DECK_H, 1.8, 16)), stopTops: STOPS.map(([x, y]) => at([x, y, DECK_H + 1.8])), bandBack: extrude(sector(BAND_IN, BAND_OUT, BAND_FROM, BAND_SPLIT, 24), DECK_H, BAND_TOP - DECK_H, P, { convex: false }), bandFront: extrude(sector(BAND_IN, BAND_OUT, BAND_SPLIT, BAND_TO, 64), DECK_H, BAND_TOP - DECK_H, P, { convex: false }), seam: at(polar((BAND_IN + BAND_OUT) / 2, BAND_SPLIT, BAND_TOP)), bandLine: onTop(sector(BAND_OUT - 1.6, BAND_OUT - 1.6, BAND_FROM + 1.2, BAND_TO - 1.2, 96).slice(0, 97), BAND_TOP, P), minor: TICKS.minor, major: TICKS.major, gapDots: GAPS.map((gap) => at(polar(BAND_OUT - 3.6, angleOf(gap), BAND_TOP))), ends: [0, SWEEP].map((angle) => at(polar(BAND_OUT - 3.6, angle, BAND_TOP))), label: topMatrix([PIVOT[0] - 13, PIVOT[1] + 150], DECK_H, P, "x"), washer: cyl(PIVOT[0], PIVOT[1], 9.4, DECK_H, WASHER_H, 32), cap: cyl(PIVOT[0], PIVOT[1], 8.2, STACK_TOP, 0.8, 32), nut: cyl(PIVOT[0], PIVOT[1], 5.8, STACK_TOP + 0.8, 4.2, 28), nutKnurl: knurl(PIVOT[0], PIVOT[1], 5.8, STACK_TOP + 0.8, STACK_TOP + 5, 13), nutRing: ring(PIVOT[0], PIVOT[1], 3.2, STACK_TOP + 5, 20), pin: at([PIVOT[0], PIVOT[1], STACK_TOP + 5]), }; const [BX, BY] = BOTTLE; const COASTER_TOP = DECK_H + 2; const BODY_R = 19; const BODY_TOP = COASTER_TOP + 32; const SHOULDER_TOP = BODY_TOP + 2.2; const NECK_TOP = SHOULDER_TOP + 2.2; const COLLAR_TOP = NECK_TOP + 2.6; const CAP_TOP = COLLAR_TOP + 11; const BULB_TOP = CAP_TOP + 10; const SHINE_ANGLE = VIEW_ANGLE + 0.95; const shineAt = (r: number, z0: number, z1: number) => line([ [BX + r * Math.cos(SHINE_ANGLE), BY + r * Math.sin(SHINE_ANGLE), z0], [BX + r * Math.cos(SHINE_ANGLE), BY + r * Math.sin(SHINE_ANGLE), z1], ]); export const BOTTLE_ART = { coaster: cyl(BX, BY, 25, DECK_H, COASTER_TOP - DECK_H, 44), coasterRing: ring(BX, BY, 22.6, COASTER_TOP, 40), coasterDots: [0, 1, 2, 3].map((index) => { const angle = Math.PI / 4 + (Math.PI / 2) * index; return at([BX + 23.8 * Math.cos(angle), BY + 23.8 * Math.sin(angle), COASTER_TOP]); }), body: cyl(BX, BY, BODY_R, COASTER_TOP, BODY_TOP - COASTER_TOP, 44), level: onTop(runOf(clean(circleRing(BX, BY, BODY_R, 44)), true), COASTER_TOP + 21, P), foot: onTop(runOf(clean(circleRing(BX, BY, BODY_R, 44)), true), COASTER_TOP + 2.4, P), shine: shineAt(BODY_R, COASTER_TOP + 5, BODY_TOP - 4), shoulder: cyl(BX, BY, 15.5, BODY_TOP, SHOULDER_TOP - BODY_TOP, 40), neck: cyl(BX, BY, 11, SHOULDER_TOP, NECK_TOP - SHOULDER_TOP, 36), collar: cyl(BX, BY, 9.4, NECK_TOP, COLLAR_TOP - NECK_TOP, 36), cap: cyl(BX, BY, 10, COLLAR_TOP, CAP_TOP - COLLAR_TOP, 36), capKnurl: knurl(BX, BY, 10, COLLAR_TOP + 1, CAP_TOP - 1, 15), capRing: ring(BX, BY, 7.2, CAP_TOP, 32), bulb: cyl(BX, BY, 6, CAP_TOP, BULB_TOP - CAP_TOP, 32), bulbShine: shineAt(6, CAP_TOP + 2, BULB_TOP - 2), bulbRing: ring(BX, BY, 3.4, BULB_TOP, 24), tip: at([BX, BY, BULB_TOP]), }; export const LABEL = `A lab spot plate on a bench. The plate has six channels, each holding a pair of glass drops, R30 and R22, at gaps of ${GAPS.join(", ")} px, drawn to scale against a ruler on the rim. On the right, beside a dropper bottle, a fan of six feeler blades, one for each gap, turns on a pivot over a protractor marked 0 to ${MERGE_MOST} px; the top blade is merge. As it opens, each blade stops at its own gap, and the pair with that gap runs together into one piece of glass.`; -
live.tsx 11.4 KB · in bundle
-
rig.ts 8 KB
import { cameraOf, type Flat, iso, type Plan, type Point3, type Projection } from "../../kit/iso-kit"; export const RADII = [30, 22] as const; export const GAPS = [4, 10, 16, 24, 32, 44] as const; export const MERGE = 20; export const MERGE_LEAST = 0; export const MERGE_MOST = 48; export const MERGE_STEP = 1; const UNIFORM = 2; const SIDE = 0.35; const FACING = 0.65; const DEPTH = 0.25; export const [RA, RB] = RADII; export const ROWS = GAPS.length; const FAR = GAPS[ROWS - 1]; export const spanOf = (gap: number) => RA + gap + RB; export const reachOf = (merge: number) => merge * UNIFORM; export const sideOf = (merge: number) => Math.round(reachOf(merge) * SIDE * 10) / 10; export const seenGapOf = (gap: number, merge: number) => (gap >= 2 * merge ? gap : gap <= merge ? 0 : 2 * Math.sqrt(merge * (gap - merge))); export const joinedAt = (merge: number) => GAPS.filter((gap) => gap < merge); export type Field = (u: number, v: number) => number; export function fieldOf(gap: number, merge: number): Field { const span = spanOf(gap); const most = reachOf(merge); return (u, v) => { const qu = u - span; const la = Math.sqrt(u * u + v * v); const lb = Math.sqrt(qu * qu + v * v); const a = la - RA; const b = lb - RB; const nearest = a < b ? a : b; if (most <= 0) return nearest; const spread = a < b ? b - a : a - b; if (spread >= most) return nearest; const raw = 0.5 - (0.5 * (u * qu + v * v)) / Math.max(la * lb, 0.0001); const facing = raw < 0 ? 0 : raw > 1 ? 1 : raw; const reach = most * (SIDE + FACING * facing); const h = Math.max(reach - spread, 0) / reach; return nearest - h * h * reach * DEPTH; }; } function halfWidth(field: Field, u: number) { if (field(u, 0) >= 0) return 0; let low = 0; let high = RA + 12; for (let round = 0; round < 30; round++) { const mid = (low + high) / 2; if (field(u, mid) < 0) low = mid; else high = mid; } return low; } export function neckOf(gap: number, merge: number) { if (gap >= merge) return 0; const field = fieldOf(gap, merge); const from = RA - 8; const to = RA + gap + 8; const coarse = 48; let best = Infinity; let at = (from + to) / 2; for (let step = 0; step <= coarse; step++) { const u = from + ((to - from) * step) / coarse; const width = halfWidth(field, u); if (width > 0 && width < best) { best = width; at = u; } } const golden = (Math.sqrt(5) - 1) / 2; let low = Math.max(from, at - (to - from) / coarse); let high = Math.min(to, at + (to - from) / coarse); for (let round = 0; round < 24; round++) { const c = high - golden * (high - low); const d = low + golden * (high - low); if (halfWidth(field, c) < halfWidth(field, d)) high = d; else low = c; } return 2 * halfWidth(field, (low + high) / 2); } export const WIDTH = 600; export const HEIGHT = 340; const PAD_X = 80; const PAD_Y = 34; export const AZIMUTH = 50; export const MARGIN = 6; export const PITCH = 84; export const WELL_HALF = RA + MARGIN; export const BIG_Y = MARGIN + RA; export const DATUM_Y = BIG_Y + RA; export const WELL_D = DATUM_Y + FAR + 2 * RB + MARGIN; export const BORDER = 14; export const COLS = 3; export const ROW_RIM = 16; export const rowX = (row: number) => (row % COLS) * PITCH; export const rowY = (row: number) => Math.floor(row / COLS) * (WELL_D + ROW_RIM); export const WELLS: Plan[] = GAPS.map((_, row) => ({ x: rowX(row) - WELL_HALF, y: rowY(row), w: WELL_HALF * 2, d: WELL_D, r: WELL_HALF })); export const TRAY: Plan = { x: -WELL_HALF - BORDER, y: -BORDER, w: rowX(COLS - 1) + (WELL_HALF + BORDER) * 2, d: rowY(ROWS - 1) + WELL_D + BORDER * 2, r: 16, }; export const TRAY_H = 13; export const WELL_SINK = 3; export const FLOOR = TRAY_H - WELL_SINK; export const THICK = 8; export const GLASS_TOP = FLOOR + THICK; export const DECK: Plan = { x: TRAY.x + TRAY.w + 10, y: TRAY.y, w: 172, d: TRAY.d, r: 12 }; export const DECK_H = 6; export const PIVOT: [number, number] = [DECK.x + 22, DECK.y + DECK.d / 2 + 6]; export const BLADE_LONG = 104; export const LEAD_LONG = 113; export const STOP_R = 111; export const SCALE_R = 120; export const SWEEP = 180; export const WASHER_H = 0.8; export const STACK_Z = DECK_H + WASHER_H; export const thicknessOf = (gap: number) => 0.45 + gap / 20; export const LEAD_H = 1.1; export const BLADE_Z: number[] = (() => { const out: number[] = []; let z = STACK_Z; for (const gap of GAPS) { out.push(z); z += thicknessOf(gap) + 0.12; } out.push(z); return out; })(); export const STACK_TOP = BLADE_Z[ROWS]! + LEAD_H; export const angleOf = (merge: number) => (Math.min(Math.max(merge, MERGE_LEAST), MERGE_MOST) / MERGE_MOST) * SWEEP; export const BOTTLE: [number, number] = [DECK.x + DECK.w - 33, DECK.y + 35]; export const BOTTLE_TOP = DECK_H + 64; export const BASE: Plan = { x: TRAY.x - 14, y: TRAY.y - 14, w: DECK.x + DECK.w + 14 - (TRAY.x - 14), d: TRAY.d + 28, r: 10 }; export const BASE_H = 10; export const FOOT_H = 5; const RADIANS = Math.PI / 180; function fit(points: Point3[]): Projection { const unitView: Projection = { origin: [0, 0], scale: 1, azimuth: AZIMUTH }; const flat = points.map((point) => iso(point, unitView)); const xs = flat.map(([x]) => x); const ys = flat.map(([, y]) => y); const left = Math.min(...xs); const right = Math.max(...xs); const up = Math.min(...ys); const down = Math.max(...ys); const scale = Math.min((WIDTH - PAD_X * 2) / (right - left), (HEIGHT - PAD_Y * 2) / (down - up)); return { origin: [WIDTH / 2 - ((left + right) / 2) * scale, HEIGHT / 2 - ((up + down) / 2) * scale], scale, azimuth: AZIMUTH }; } const box = ({ x, y, w, d }: Plan, z0: number, z1: number): Point3[] => { const out: Point3[] = []; for (const px of [x, x + w]) for (const py of [y, y + d]) for (const pz of [z0, z1]) out.push([px, py, pz]); return out; }; export const P = fit([...box(BASE, -BASE_H - FOOT_H, 0), ...box(DECK, 0, STACK_TOP + 6), [BOTTLE[0], BOTTLE[1], BOTTLE_TOP]]); export const at = (point: Point3): Flat => iso(point, P); const CAMERA = cameraOf(P); export const VIEW: [number, number] = [CAMERA.cosA, CAMERA.sinA]; export const LIGHT: [number, number] = [-CAMERA.sinA, CAMERA.cosA]; export const CLIMB = CAMERA.sinE / CAMERA.cosE; export const K = CAMERA.k; export const SIN_A = Math.sin(AZIMUTH * RADIANS); export const COS_A = Math.cos(AZIMUTH * RADIANS); const SLIDE_FROM = 0.16; const SLIDE_TO = 0.84; export function mergeAtX(x: number) { const share = (x / WIDTH - SLIDE_FROM) / (SLIDE_TO - SLIDE_FROM); return MERGE_LEAST + Math.min(1, Math.max(0, share)) * (MERGE_MOST - MERGE_LEAST); } export const roundMerge = (merge: number) => Math.min(MERGE_MOST, Math.max(MERGE_LEAST, Math.round(merge))); const listOf = (values: readonly (number | string)[]) => values.length < 2 ? values.join("") : `${values.slice(0, -1).join(", ")} and ${values[values.length - 1]}`; export function readoutOf(merge: number) { const shown = roundMerge(merge); return `merge ${shown} px · ${joinedAt(shown).length} of ${ROWS} joined`; } export function joinedListOf(merge: number) { const joined = joinedAt(roundMerge(merge)); if (!joined.length) return "none of the six"; if (joined.length === ROWS) return "all six"; return `${listOf(joined)} px`; } export function bladesOutOf(merge: number) { const out = joinedAt(roundMerge(merge)).length; if (!out) return "no blade is out"; if (out === ROWS) return "all six blades are out"; return out === 1 ? "one blade is out" : `${["", "", "two", "three", "four", "five"][out]} blades are out`; } export function spokenOf(merge: number) { const shown = roundMerge(merge); const joined = joinedAt(shown); const apart = GAPS.filter((gap) => gap >= shown); const head = `merge ${shown} px: ${joined.length} of ${ROWS} pairs joined`; if (!joined.length) return `${head}, all six apart`; if (!apart.length) return `${head}, every gap from ${GAPS[0]} to ${FAR} px`; return `${head}, gaps ${listOf(joined)} px; ${listOf(apart)} px ${apart.length > 1 ? "stay" : "stays"} apart`; } export const listText = listOf; -
spot-plate.css 1.5 KB · in bundle
-
-
test-rig
-
art.tsx 7.2 KB · in bundle
-
figure.tsx 850 B · in bundle
-
geometry.ts 12.8 KB
import { circleRing, extrude, insetPlan, onTop, pathOf, planOutline, slabOf, topMatrix, type Flat, type Plan, type Plane, type Point3 } from "../../kit/iso-kit"; import { BAR, BAR_H, BASE, BASE_H, BUSH_D, BUSH_END, BUSH_W, CARRIAGE, CARRIAGE_H, CARRIAGE_TOP, CARRIAGE_Z, DIAL, DIAL_H, DIAL_R, DIAL_SPAN, DIAL_TOP, DIAL_Z, FOOT_H, FRAME_X, FRAME_Y, GLASS_TOP, HOUSING_H, HOUSING_R, HOUSING_Z, P, PAD_H, PAD_R, PLUNGER_Z, POST, POST_L, POST_R, PROBE_HOME, RAIL_D, RAIL_H, RAIL_Y, RAIL_Z, ROD_HALF, ROD_Y, ROD_Z, STEM, at, } from "./view"; const solid = (plan: Plan, z: number, h: number, steps = 6, bevel = 0) => slabOf(plan, z, h, P, steps, bevel); const cylinder = (cx: number, cy: number, r: number, z: number, h: number, steps = 32) => extrude(circleRing(cx, cy, r, steps), z, h, P, { convex: true }); const ring = (cx: number, cy: number, r: number, z: number, steps = 40) => onTop(circleRing(cx, cy, r, steps), z, P, true); const segment = (a: Point3, b: Point3) => pathOf([at(a), at(b)]); const join = (paths: string[]) => paths.join(""); const RADIANS = Math.PI / 180; function knurl(cx: number, cy: number, r: number, z0: number, z1: number, count: number) { const lines: string[] = []; for (let index = 0; index < count; index++) { const angle = (index / count) * Math.PI * 2; const nx = Math.cos(angle); const ny = Math.sin(angle); if (nx * Math.cos(57 * RADIANS) + ny * Math.sin(57 * RADIANS) < 0.15) continue; lines.push(segment([cx + r * nx, cy + r * ny, z0 + 0.4], [cx + r * nx, cy + r * ny, z1 - 0.4])); } return join(lines); } function sideArc(cx: number, cy: number, r: number, z: number, steps = 24) { const points: Flat[] = []; for (let step = 0; step <= steps; step++) { const angle = (57 - 90 + (step / steps) * 180) * RADIANS; points.push(at([cx + r * Math.cos(angle), cy + r * Math.sin(angle), z])); } return pathOf(points); } function faceRing(x: number, cy: number, cz: number, r: number, steps = 20) { const points: Flat[] = []; for (let step = 0; step < steps; step++) { const angle = (step / steps) * Math.PI * 2; points.push(at([x, cy + r * Math.cos(angle), cz + r * Math.sin(angle)])); } return pathOf(points, true); } const FEET: Plane[] = [ [BASE.x + 18, BASE.y + 16], [BASE.x + BASE.w - 18, BASE.y + 16], [BASE.x + 18, BASE.y + BASE.d - 16], [BASE.x + BASE.w - 18, BASE.y + BASE.d - 16], ]; const BASE_SCREWS: Plane[] = [ [BASE.x + 8, BASE.y + 8], [BASE.x + BASE.w - 8, BASE.y + 8], [BASE.x + 8, BASE.y + BASE.d - 8], [BASE.x + BASE.w - 8, BASE.y + BASE.d - 8], ]; const PLATE: Plan = { x: BASE.x + 20, y: FRAME_Y + BAR + 6, w: 56, d: 11, r: 2 }; export const BASE_ART = { halo: solid(BASE, -BASE_H - FOOT_H, BASE_H).fill, feet: FEET.map(([x, y]) => [cylinder(x, y, 7, -BASE_H - FOOT_H, 1.6), cylinder(x, y, 5.5, -BASE_H - FOOT_H + 1.6, FOOT_H - 1.6)]), slab: solid(BASE, -BASE_H, BASE_H, 8, 1.6), groove: planOutline(insetPlan(BASE, 4.5), 0, P), screws: BASE_SCREWS.map(([x, y]) => at([x, y, 0])), screwRings: join(BASE_SCREWS.map(([x, y]) => ring(x, y, 2.2, 0, 16))), plate: solid(PLATE, 0, 0.8, 4), plateLine: planOutline(insetPlan(PLATE, 1.6), 0.8, P), plateScrews: [at([PLATE.x + 3.5, PLATE.y + PLATE.d / 2, 0.8]), at([PLATE.x + PLATE.w - 3.5, PLATE.y + PLATE.d / 2, 0.8])], plateRule: join([0, 1, 2].map((row) => onTop([[PLATE.x + 8, PLATE.y + 3.4 + row * 2.1], [PLATE.x + (row === 1 ? 34 : 44), PLATE.y + 3.4 + row * 2.1]], 0.8, P))), }; const POST_PLANS: Plan[] = [POST_L, POST_R].map((x) => ({ x, y: RAIL_Y - POST / 2, w: POST, d: POST, r: 2 })); const RAIL: Plan = { x: POST_L - 5, y: RAIL_Y - RAIL_D / 2, w: POST_R + POST - POST_L + 10, d: RAIL_D, r: 2 }; const RAIL_FACE = RAIL_Y + RAIL_D / 2; const RAIL_TOP = RAIL_Z + RAIL_H; const RULE_FROM = POST_L + POST + 4; const RULE_TO = POST_R - 4; function ruler() { const minor: string[] = []; const major: string[] = []; for (let x = Math.ceil(RULE_FROM / 5) * 5; x <= RULE_TO; x += 5) { const big = x % 25 === 0; (big ? major : minor).push(segment([x, RAIL_FACE, RAIL_TOP - 1.2], [x, RAIL_FACE, RAIL_TOP - (big ? 5 : 3)])); } return { minor: join(minor), major: join(major) }; } const RULE = ruler(); export const RAIL_ART = { feet: POST_PLANS.map((plan) => solid(insetPlan(plan, -3), 0, 2, 4)), footScrews: POST_PLANS.flatMap((plan) => [at([plan.x - 1, plan.y + POST / 2, 2]), at([plan.x + POST + 1, plan.y + POST / 2, 2])]), posts: POST_PLANS.map((plan) => solid(plan, 2, RAIL_Z - 2, 4)), postLines: join(POST_PLANS.map((plan) => segment([plan.x + POST, plan.y + POST, 6], [plan.x + POST, plan.y + POST, RAIL_Z - 4]))), rail: solid(RAIL, RAIL_Z, RAIL_H, 4, 0.8), railScrews: POST_PLANS.map((plan) => at([plan.x + POST / 2, RAIL_Y, RAIL_TOP])), ruleMinor: RULE.minor, ruleMajor: RULE.major, ruleBase: segment([RULE_FROM, RAIL_FACE, RAIL_TOP - 1.2], [RULE_TO, RAIL_FACE, RAIL_TOP - 1.2]), stops: [RULE_FROM - 2, RULE_TO + 2].map((x) => solid({ x: x - 1.5, y: RAIL_Y - RAIL_D / 2 - 0.5, w: 3, d: RAIL_D + 1, r: 0.8 }, RAIL_TOP, 2.2, 3)), }; export const RIDER = { body: solid({ x: -1.6, y: RAIL_Y - RAIL_D / 2 - 0.6, w: 3.2, d: RAIL_D + 1.2, r: 0.8 }, RAIL_TOP, 1.4, 3), tick: segment([0, RAIL_FACE + 0.6, RAIL_TOP + 1.4], [0, RAIL_FACE + 0.6, RAIL_TOP - 3]), }; const H = PROBE_HOME; const BLOCK: Plan = { x: H - 11, y: RAIL_Y - 7, w: 22, d: 14, r: 2.5 }; const BLOCK_Z = RAIL_TOP; const BLOCK_H = 9; const ARM_Z = BLOCK_Z + 3; const ARM_H = 5; const ARM: Plan = { x: H - 3.5, y: BLOCK.y + BLOCK.d - 1, w: 7, d: -(BLOCK.y + BLOCK.d - 1) + 2, r: 1.5 }; export const PROBE_ART = { block: solid(BLOCK, BLOCK_Z, BLOCK_H, 5, 0.8), blockLine: planOutline(insetPlan(BLOCK, 2), BLOCK_Z + BLOCK_H, P), blockScrews: [at([BLOCK.x + 3, BLOCK.y + 3, BLOCK_Z + BLOCK_H]), at([BLOCK.x + BLOCK.w - 3, BLOCK.y + 3, BLOCK_Z + BLOCK_H])], thumb: cylinder(H - 5, RAIL_Y - 1, 2.6, BLOCK_Z + BLOCK_H, 3.2, 20), thumbKnurl: knurl(H - 5, RAIL_Y - 1, 2.6, BLOCK_Z + BLOCK_H, BLOCK_Z + BLOCK_H + 3.2, 16), pointer: solid({ x: H - 2, y: BLOCK.y + BLOCK.d, w: 4, d: 1, r: 0.4 }, RAIL_TOP - 4, 4 + 2, 2), index: segment([H, BLOCK.y + BLOCK.d + 1, RAIL_TOP + 1.5], [H, BLOCK.y + BLOCK.d + 1, RAIL_TOP - 3.6]), arm: solid(ARM, ARM_Z, ARM_H, 4, 0.6), armLine: onTop([[H, ARM.y + 3], [H, -9]], ARM_Z + ARM_H, P), armScrews: [at([H, ARM.y + 2, ARM_Z + ARM_H]), at([H, -2, ARM_Z + ARM_H])], housing: cylinder(H, 0, HOUSING_R, HOUSING_Z, HOUSING_H - 2, 32), cap: cylinder(H, 0, HOUSING_R + 0.8, HOUSING_Z + HOUSING_H - 2, 2, 32), collar: cylinder(H, 0, HOUSING_R + 0.8, HOUSING_Z, 2.4, 32), housingSeam: join([HOUSING_Z + 6, HOUSING_Z + 11].map((z) => sideArc(H, 0, HOUSING_R, z))), lock: solid({ x: H - 1.6, y: HOUSING_R - 0.5, w: 3.2, d: 3.5, r: 0.8 }, HOUSING_Z + 8, 3.2, 2), lockHead: cylinder(H, HOUSING_R + 4.4, 2.2, HOUSING_Z + 7.4, 4.4, 16), }; const PAD_Z = GLASS_TOP; export const FINGER_ART = { stem: cylinder(H, 0, 1.5, PAD_Z + PAD_H + 2, STEM, 16), stemMarks: join([8, 11, 14].map((rise) => sideArc(H, 0, 1.5, PAD_Z + PAD_H + 2 + rise, 12))), collar: cylinder(H, 0, 2.6, PAD_Z + PAD_H, 2, 20), pad: cylinder(H, 0, PAD_R, PAD_Z, PAD_H, 32), padRing: ring(H, 0, PAD_R - 1.2, PAD_Z + PAD_H, 28), }; const BARS: Plan[] = [ { x: -FRAME_X - BAR, y: -FRAME_Y - BAR, w: (FRAME_X + BAR) * 2, d: BAR, r: 1.5 }, { x: -FRAME_X - BAR, y: -FRAME_Y, w: BAR, d: FRAME_Y * 2, r: 0.6 }, { x: FRAME_X, y: -FRAME_Y, w: BAR, d: FRAME_Y * 2, r: 0.6 }, { x: -FRAME_X - BAR, y: FRAME_Y, w: (FRAME_X + BAR) * 2, d: BAR, r: 1.5 }, ]; const barScrews = (plan: Plan): Flat[] => plan.w > plan.d ? [-1, 1].map((side) => at([side * (FRAME_X + BAR / 2), plan.y + BAR / 2, BAR_H])) : [-1, 1].map((side) => at([plan.x + BAR / 2, side * (FRAME_Y - 6), BAR_H])); const FLOOR: Plan = { x: -FRAME_X, y: -FRAME_Y, w: FRAME_X * 2, d: FRAME_Y * 2, r: 0 }; function floorGrid() { const lines: string[] = []; for (let x = -FRAME_X + 10; x < FRAME_X; x += 10) lines.push(onTop([[x, -FRAME_Y], [x, FRAME_Y]], 0.8, P)); for (let y = -FRAME_Y + 7.4; y < FRAME_Y; y += 7.4) lines.push(onTop([[-FRAME_X, y], [FRAME_X, y]], 0.8, P)); return join(lines); } function barTicks() { const minor: string[] = []; const major: string[] = []; const y = FRAME_Y + 1; for (let x = -40; x <= 40; x += 2) { const big = x % 10 === 0; (big ? major : minor).push(onTop([[x, y], [x, y + (big ? 3.2 : 1.8)]], BAR_H, P)); } return { minor: join(minor), major: join(major) }; } const TICKS = barTicks(); export const FRAME_ART = { back: solid(BARS[0]!, 0, BAR_H, 4, 0.8), left: solid(BARS[1]!, 0, BAR_H, 3, 0.8), right: solid(BARS[2]!, 0, BAR_H, 3, 0.8), front: solid(BARS[3]!, 0, BAR_H, 4, 0.8), backScrews: barScrews(BARS[0]!), leftScrews: barScrews(BARS[1]!), rightScrews: barScrews(BARS[2]!), frontScrews: barScrews(BARS[3]!), floor: solid(FLOOR, 0, 0.8, 2), grid: floorGrid(), ticksMinor: TICKS.minor, ticksMajor: TICKS.major, zero: onTop([[0, FRAME_Y + 0.6], [0, FRAME_Y + BAR - 0.6]], BAR_H, P), rodEnds: join([-ROD_Y, ROD_Y].map((y) => faceRing(FRAME_X + BAR, y, ROD_Z, 3, 16))), rodNuts: [-ROD_Y, ROD_Y].map((y) => at([FRAME_X + BAR, y, ROD_Z])), bore: faceRing(FRAME_X + BAR, 0, PLUNGER_Z, 2.2, 14), leftBores: join([-ROD_Y, ROD_Y].map((y) => faceRing(-FRAME_X, y, ROD_Z, 3, 16))), }; export const RODS = [-ROD_Y, ROD_Y].map((y) => solid({ x: -FRAME_X, y: y - ROD_HALF, w: FRAME_X * 2, d: ROD_HALF * 2, r: ROD_HALF - 0.2 }, ROD_Z - ROD_HALF, ROD_HALF * 2, 3)); const SEAT: Plan = { x: -63, y: -18, w: 126, d: 36, r: 18 }; const BUSHES: Plan[] = [-1, 1].flatMap((side) => [-ROD_Y, ROD_Y].map((y) => ({ x: side < 0 ? -BUSH_END : BUSH_END - BUSH_W, y: y - BUSH_D / 2, w: BUSH_W, d: BUSH_D, r: 2 })), ); export const CARRIAGE_ART = { halo: solid(insetPlan(CARRIAGE, 3), 0.8, 0.1).fill, plate: solid(CARRIAGE, CARRIAGE_Z, CARRIAGE_H, 6, 0.8), seat: planOutline(SEAT, CARRIAGE_TOP, P, 10), seatInner: planOutline(insetPlan(SEAT, 2.4), CARRIAGE_TOP, P, 10), bushes: BUSHES.map((plan) => solid(plan, CARRIAGE_Z - 1, CARRIAGE_H + 4, 3, 0.6)), bushScrews: BUSHES.flatMap((plan) => [at([plan.x + 3, plan.y + plan.d / 2, CARRIAGE_TOP + 3]), at([plan.x + plan.w - 3, plan.y + plan.d / 2, CARRIAGE_TOP + 3])]), bushBores: join([-ROD_Y, ROD_Y].map((y) => faceRing(BUSH_END, y, ROD_Z, 2.8, 14))), contact: solid({ x: BUSH_END - 8, y: -4, w: 8, d: 8, r: 2 }, CARRIAGE_Z, CARRIAGE_H, 3, 0.4), rivets: [-56, -28, 0, 28, 56].map((x) => at([x, CARRIAGE.y + CARRIAGE.d - 2.4, CARRIAGE_TOP])), }; export const PLUNGER_ART = { rod: solid({ x: BUSH_END, y: -1.1, w: 44, d: 2.2, r: 1 }, PLUNGER_Z - 1.1, 2.2, 3), collar: solid({ x: BUSH_END + 0.4, y: -2.2, w: 2.4, d: 4.4, r: 1 }, PLUNGER_Z - 2.2, 4.4, 3, 0.3), }; const [DX, DY] = DIAL; const STAND: Plan = { x: DX - 15, y: DY - 14, w: 30, d: 28, r: 3 }; function dialTicks() { const minor: string[] = []; const major: string[] = []; for (let step = 0; step < DIAL_SPAN; step++) { const angle = (step / DIAL_SPAN) * Math.PI * 2; const big = step % 5 === 0; const sx = Math.sin(angle); const sy = -Math.cos(angle); const outer = DIAL_R - 2.4; const inner = outer - (big ? 2.6 : 1.4); (big ? major : minor).push(onTop([[DX + sx * inner, DY + sy * inner], [DX + sx * outer, DY + sy * outer]], DIAL_TOP, P)); } return { minor: join(minor), major: join(major) }; } const DIAL_TICKS = dialTicks(); const GIVE_ANGLE = (14 / DIAL_SPAN) * Math.PI * 2; const R_GIVE = DIAL_R - 1.4; export const DIAL_ART = { stand: solid(STAND, 0, DIAL_Z, 4, 0.6), standScrews: [at([STAND.x + 3.4, STAND.y + 3.4, DIAL_Z]), at([STAND.x + STAND.w - 3.4, STAND.y + STAND.d - 3.4, DIAL_Z]), at([STAND.x + 3.4, STAND.y + STAND.d - 3.4, DIAL_Z]), at([STAND.x + STAND.w - 3.4, STAND.y + 3.4, DIAL_Z])], sleeve: solid({ x: FRAME_X + BAR, y: -2.6, w: DX - DIAL_R - FRAME_X - BAR + 0.6, d: 5.2, r: 1 }, PLUNGER_Z - 2.6, 5.2, 3), clamp: cylinder(FRAME_X + BAR + 3.2, 0, 1.6, PLUNGER_Z + 2.6, 2, 12), puck: cylinder(DX, DY, DIAL_R, DIAL_Z, DIAL_H, 56), bezel: ring(DX, DY, DIAL_R - 1, DIAL_TOP, 56), face: ring(DX, DY, DIAL_R - 2.4, DIAL_TOP, 56), knurl: knurl(DX, DY, DIAL_R, DIAL_Z + DIAL_H - 3, DIAL_Z + DIAL_H, 56), band: sideArc(DX, DY, DIAL_R, DIAL_Z + DIAL_H - 3, 40), minor: DIAL_TICKS.minor, major: DIAL_TICKS.major, give: [-1, 1].map((side) => at([DX + Math.sin(side * GIVE_ANGLE) * R_GIVE, DY - Math.cos(side * GIVE_ANGLE) * R_GIVE, DIAL_TOP])), crown: solid({ x: DX + DIAL_R - 0.6, y: -2.4, w: 4.4, d: 4.8, r: 1.2 }, DIAL_Z + 2, 4, 3), crownCap: solid({ x: DX + DIAL_R + 3.6, y: -3.2, w: 2.6, d: 6.4, r: 1.2 }, DIAL_Z + 1.2, 5.6, 3), lug: solid({ x: DX - 3, y: DY - DIAL_R - 3.4, w: 6, d: 4, r: 1.2 }, DIAL_Z + 1.5, 5, 3), label: topMatrix([DX, DY + 5.2], DIAL_TOP, P, "x"), }; const REST_GLASS = { x: -60, y: -16, w: 120, d: 32, r: 16 }; export const REST_OUTLINE = planOutline(REST_GLASS, GLASS_TOP, P, 12); -
live.tsx 17.1 KB · in bundle
-
pose.ts 3.8 KB
import { insetPlan, onTop, pathOf, planOutline, slabOf, type Flat, type SolidPaths } from "../../kit/iso-kit"; import { carried, type Pose } from "./rig"; import { AZIMUTH, BUSH_END, COIL_R, COIL_TURNS, DIAL, DIAL_SPAN, DIAL_TOP, DIAL_R, FRAME_X, GLASS_H, GLASS_TOP, GLASS_Z, P, ROD_Y, ROD_Z, at, shift } from "./view"; export interface GlassPaths extends SolidPaths { rim: string; shine: string; } export interface Frame { glass: GlassPaths; coilsBack: string; coilsLeft: string; coilsRightBack: string; coilsRightFront: string; carriage: string; needle: string; grab: Flat; finger: Flat; pull: string; rider: string; probe: string; tip: string; } const RADIANS = Math.PI / 180; const SIN_A = Math.sin(AZIMUTH * RADIANS); const COS_E = Math.cos(30 * RADIANS); const SIN_E = Math.sin(30 * RADIANS); const PER_TURN = 16; const tenth = (value: number) => Math.round(value * 10) / 10; export function glassOf(pose: Pose): GlassPaths { const [hx, hy] = pose.half; const plan = { x: pose.mid - hx, y: -hy, w: hx * 2, d: hy * 2, r: hy }; const paths = slabOf(plan, GLASS_Z, GLASS_H, P, 10, 1.2); const inner = insetPlan(plan, 3.2); const shine = [ onTop([[pose.mid - hx * 0.62, -hy + 5.4], [pose.mid + hx * 0.18, -hy + 5.4]], GLASS_TOP, P), onTop([[pose.mid + hx * 0.3, -hy + 5.4], [pose.mid + hx * 0.42, -hy + 5.4]], GLASS_TOP, P), ].join(""); return { ...paths, rim: planOutline(inner, GLASS_TOP, P, 10), shine }; } function coil(from: number, to: number, y: number) { const count = COIL_TURNS * PER_TURN; const back: Flat[][] = []; const front: Flat[][] = []; let run: Flat[] = []; let facing: boolean | null = null; for (let index = 0; index <= count; index++) { const share = index / count; const angle = share * COIL_TURNS * Math.PI * 2; const cy = Math.cos(angle); const cz = Math.sin(angle); const point = at([from + (to - from) * share, y + COIL_R * cy, ROD_Z + COIL_R * cz]); const near = cy * SIN_A * COS_E + cz * SIN_E > 0; if (facing === null) facing = near; if (near !== facing) { run.push(point); (facing ? front : back).push(run); run = [point]; facing = near; continue; } run.push(point); } if (run.length > 1) (facing ? front : back).push(run); return { back: back.map((points) => pathOf(points)).join(""), front: front.map((points) => pathOf(points)).join("") }; } function needleOf(lean: number) { const angle = (-lean / DIAL_SPAN) * Math.PI * 2; const ux = Math.sin(angle); const uy = -Math.cos(angle); const [cx, cy] = DIAL; const point = (along: number, across: number) => at([cx + ux * along - uy * across, cy + uy * along + ux * across, DIAL_TOP + 0.1]); return pathOf([point(DIAL_R - 3.4, 0), point(0, 0.75), point(-3, 0.35), point(-3, -0.35), point(0, -0.75)], true); } export function frameOf(pose: Pose, lean: number, grab: number, finger: number, lift: number, home: number, held = false): Frame { const left = ROD_Y; const coils = [ coil(-FRAME_X, -BUSH_END + lean, -left), coil(-FRAME_X, -BUSH_END + lean, left), coil(BUSH_END + lean, FRAME_X, -left), coil(BUSH_END + lean, FRAME_X, left), ]; const grabX = carried(pose, grab); const grabAt = at([grabX, 0, GLASS_TOP]); const fingerAt = at([finger, 0, GLASS_TOP + lift]); return { glass: glassOf(pose), coilsBack: coils.map((one) => one.back).join(""), coilsLeft: coils[0]!.front + coils[1]!.front, coilsRightBack: coils[2]!.front, coilsRightFront: coils[3]!.front, carriage: shift(lean), needle: needleOf(lean), grab: [tenth(grabAt[0]), tenth(grabAt[1])], finger: [tenth(fingerAt[0]), tenth(fingerAt[1])], pull: !held || Math.abs(grabX - finger) < 0.6 ? "" : pathOf([grabAt, fingerAt]), rider: shift(grab), probe: shift(finger - home), tip: shift(finger - home, lift), }; } -
rig.ts 5.5 KB
export type Pair = [number, number]; export type Tuning = readonly [number, number]; export interface Spring { at: number; rate: number; } export interface Member { swell: Spring; lean: Spring; } export interface Pose { mid: number; half: Pair; lean: number; press: number; } export const HALF: Pair = [60, 16]; export const SWELL = 0.08; export const SWELL_MOST = 6; export const SWELL_FLOOR = -0.25; export const RUBBER = 0.55; const STRETCH = 0.35; export const STRETCH_MOST = 0.08; const THIN = 0.5; const SOFT = 1; export const GIVE = 14; export const REACH = 0.15; export const GIVE_MOST = 0.25; const QUIET = 0.0005; const SUB_RATE = 240; export const FRAME = 1 / 60; export const PRESS_SPRING: Tuning = [700, 32]; export const HOLD_SPRING: Tuning = [540, 40]; export const RELEASE_SPRING: Tuning = [320, 16]; const LONGEST = Math.max(HALF[0], HALF[1]); export const SHARE = Math.min(SWELL, SWELL_MOST / LONGEST); export const GIVEN = Math.min(GIVE, REACH * 2 * LONGEST); export const ACROSS = (GIVEN * HALF[1]) / LONGEST; export const zeta = ([stiff, drag]: Tuning) => drag / (2 * Math.sqrt(stiff)); function spring(held: Spring, want: number, dt: number, [stiff, drag]: Tuning, calm: boolean) { const damping = calm ? 2 * Math.sqrt(stiff) : drag; const steps = Math.max(Math.ceil(dt * SUB_RATE), 1); const slice = dt / steps; for (let step = 0; step < steps; step++) { held.rate += (want - held.at) * stiff * slice - held.rate * damping * slice; held.at += held.rate * slice; } if (Math.abs(want - held.at) < QUIET && Math.abs(held.rate) < QUIET) { held.at = want; held.rate = 0; } } export function rubberBand(distance: number, limit: number) { return limit <= 0 ? 0 : limit * (1 - 1 / (1 + (RUBBER * distance) / limit)); } const softened = (value: number) => Math.hypot(value, SOFT) - SOFT; function stretched(lean: number, half: number, grow: number) { const most = STRETCH_MOST * grow * half; return most * Math.tanh((STRETCH * softened(lean)) / most); } export const grownOf = (member: Member) => 1 + SHARE * Math.max(member.swell.at, SWELL_FLOOR); export const memberAt = (swell = 0, lean = 0): Member => ({ swell: { at: swell, rate: 0 }, lean: { at: lean, rate: 0 } }); export function step(member: Member, held: boolean, pull: number, dt: number, calm = false) { spring(member.swell, held ? 1 : 0, dt, held ? PRESS_SPRING : RELEASE_SPRING, calm); const want = held && !calm ? Math.sign(pull) * rubberBand(Math.abs(pull), GIVEN) : 0; spring(member.lean, want, dt, held ? HOLD_SPRING : RELEASE_SPRING, calm); } export function poseOf(member: Member): Pose { const grow = grownOf(member); const lean = member.lean.at; const stretch = stretched(lean, HALF[0], grow); const ratio = 1 + stretch / (grow * HALF[0]); return { mid: lean - Math.sign(lean) * stretch, half: [grow * HALF[0] * ratio, grow * HALF[1] * Math.pow(ratio, -THIN)], lean, press: Math.min(Math.max(member.swell.at, 0), 1.2), }; } export const carried = (pose: Pose, x: number) => pose.mid + (pose.half[0] / HALF[0]) * x; export const settled = (member: Member, held: boolean, pull: number) => member.swell.rate === 0 && member.lean.rate === 0 && member.swell.at === (held ? 1 : 0) && (held || member.lean.at === 0) && (held || pull === 0); export const one = (value: number) => { const text = (Math.round(value * 10) / 10).toFixed(1); return text === "-0.0" ? "0.0" : text; }; export const two = (value: number) => (Math.round(value * 100) / 100).toFixed(2); export const sizeText = (pose: Pose) => `${one(pose.half[0] * 2)} × ${one(pose.half[1] * 2)}`; export function steady(pull: number) { const member = memberAt(); for (let frame = 0; frame < 360; frame++) step(member, true, pull, FRAME); return poseOf(member); } function pressRun() { const member = memberAt(); let peak = 0; let peakAt = 0; for (let frame = 1; frame <= 120; frame++) { step(member, true, 0, FRAME); const tall = poseOf(member).half[1] * 2; if (tall > peak) { peak = tall; peakAt = frame * FRAME; } } return { peak, peakAt }; } export const STILL_ENOUGH = 0.1; function releaseRun(pull: number) { const member = memberAt(); for (let frame = 0; frame < 360; frame++) step(member, true, pull, FRAME); let swing = 0; let swingAt = 0; let dip = 0; let still = 0; for (let frame = 1; frame <= 300; frame++) { step(member, false, 0, FRAME); const lean = member.lean.at; if (-Math.sign(pull) * lean > swing) { swing = -Math.sign(pull) * lean; swingAt = frame * FRAME; } dip = Math.max(dip, (1 - grownOf(member)) * 100); const pose = poseOf(member); const edges = [pose.mid - pose.half[0], pose.mid + pose.half[0], -pose.half[1], pose.half[1]]; const rest = [-HALF[0], HALF[0], -HALF[1], HALF[1]]; const off = Math.max(...edges.map((edge, index) => Math.abs(edge - rest[index]!))); if (off > STILL_ENOUGH) still = (frame + 1) * FRAME; } return { swing, swingAt, dip, still }; } export const DRAG = 80; export const PULL_MOST = 110; export const REST: Pose = { mid: 0, half: [HALF[0], HALF[1]], lean: 0, press: 0 }; export const PRESSED = steady(0); export const PULLED = steady(-DRAG); export const PULLED_MOST = steady(-PULL_MOST); export const PRESS_RUN = pressRun(); export const RELEASE_RUN = releaseRun(-DRAG); export const DIP_MOST = -SWELL_FLOOR * SHARE * 100; export const SIDE_GROWTH = SHARE * HALF[0]; export function readoutOf(pose: Pose) { return `${sizeText(pose)} · lean ${one(Math.abs(pose.lean))} px`; } -
stretch.css 1.2 KB · in bundle
-
view.ts 3 KB
import { iso, type Flat, type Plan, type Point3, type Projection } from "../../kit/iso-kit"; export const WIDTH = 600; export const HEIGHT = 340; const PAD = 34; export const AZIMUTH = 57; export const BASE: Plan = { x: -134, y: -82, w: 298, d: 150, r: 9 }; export const BASE_H = 9; export const FOOT_H = 5; export const FRAME_X = 101; export const FRAME_Y = 37; export const BAR = 9; export const BAR_H = 12; export const ROD_Y = 18; export const ROD_Z = 6; export const ROD_HALF = 2; export const COIL_R = 4.2; export const COIL_TURNS = 6; export const CARRIAGE: Plan = { x: -72, y: -26, w: 144, d: 52, r: 4 }; export const CARRIAGE_Z = 3; export const CARRIAGE_H = 5; export const CARRIAGE_TOP = CARRIAGE_Z + CARRIAGE_H; export const BUSH_END = 80; export const BUSH_W = 14; export const BUSH_D = 12; export const GLASS_Z = CARRIAGE_TOP; export const GLASS_H = 10; export const GLASS_TOP = GLASS_Z + GLASS_H; export const PLUNGER_Z = 6; export const DIAL: Flat = [131, 0]; export const DIAL_R = 15; export const DIAL_Z = 3; export const DIAL_H = 8; export const DIAL_TOP = DIAL_Z + DIAL_H; export const DIAL_SPAN = 20; export const RAIL_Y = -66; export const RAIL_D = 8; export const RAIL_Z = 50; export const RAIL_H = 8; export const POST_L = -124; export const POST_R = 66; export const POST = 12; export const PROBE_HOME = 45; export const PROBE_MIN = -98; export const PROBE_MAX = 48; export const HOUSING_R = 5.5; export const HOUSING_Z = 44; export const HOUSING_H = 20; export const PAD_R = 4.4; export const PAD_H = 3; export const HOVER = 12; export const STEM = 28; export const GRAB_MOST = 52; export const FINGER_MOST = 62; function fit(points: Point3[]): Projection { const unit: Projection = { origin: [0, 0], scale: 1, azimuth: AZIMUTH }; const flat = points.map((point) => iso(point, unit)); const xs = flat.map(([x]) => x); const ys = flat.map(([, y]) => y); const left = Math.min(...xs); const right = Math.max(...xs); const up = Math.min(...ys); const down = Math.max(...ys); const scale = Math.min((WIDTH - PAD * 2) / (right - left), (HEIGHT - PAD * 2) / (down - up)); return { origin: [WIDTH / 2 - ((left + right) / 2) * scale, HEIGHT / 2 - ((up + down) / 2) * scale], scale, azimuth: AZIMUTH }; } const corners = ({ x, y, w, d }: Plan, z0: number, z1: number): Point3[] => { const out: Point3[] = []; for (const px of [x, x + w]) for (const py of [y, y + d]) for (const z of [z0, z1]) out.push([px, py, z]); return out; }; export const P = fit([ ...corners(BASE, -BASE_H - FOOT_H, 0), ...corners({ x: POST_L, y: RAIL_Y - RAIL_D / 2, w: POST_R + POST - POST_L, d: RAIL_D, r: 0 }, RAIL_Z, RAIL_Z + RAIL_H + 6), ]); export const at = (point: Point3) => iso(point, P); const ORIGIN = at([0, 0, 0]); const minus = (a: Flat, b: Flat): Flat => [a[0] - b[0], a[1] - b[1]]; export const EX = minus(at([1, 0, 0]), ORIGIN); export const EZ = minus(at([0, 0, 1]), ORIGIN); const fine = (value: number) => Math.round(value * 100) / 100; export const shift = (x: number, z = 0) => `translate(${fine(EX[0] * x + EZ[0] * z)} ${fine(EX[1] * x + EZ[1] * z)})`;
-
-
turning-dial
-
build.mjs 31.3 KB · in bundle
-
dial.css 509 B · in bundle
-
live.js 12.2 KB
const DATA = __DATA__; const follow = null; const stage = document.querySelector(".td-stage"); const readout = document.querySelector("[data-readout]"); const still = matchMedia("(prefers-reduced-motion: reduce)"); const INDEX = (DATA.index * 180) / Math.PI; let angle = 0; let target = 0; let frame = 0; let last = 0; let visible = false; let touring = !still.matches; let idle = 0; let drawn = null; let clock = 0; let lift = 0; let broken = 0; let breaking = null; let podHook; const SPEED = 18; const readingOf = (turn) => ((((INDEX + 90 - turn) / 3.6) % 100) + 100) % 100; const describe = () => { const reading = Math.round(readingOf(target)) % 100; stage.setAttribute("aria-valuenow", String(reading)); stage.setAttribute("aria-valuetext", `dial at ${reading}${broken ? ", the pod has broken off" : ""}`); }; const hold = () => { touring = false; clearTimeout(idle); idle = setTimeout(() => { touring = !still.matches; run(); }, 3600); }; let own = false; const show = (value) => { const text = `dial ${Math.round(readingOf(value)) % 100} · ${(((value % 360) + 360) % 360).toFixed(1)}°${broken ? " · pod off" : ""}`; if (readout.textContent !== text) readout.textContent = text; }; const podAt = () => { if (!breaking && podHook !== undefined) return podHook; if (!breaking) return broken ? breakAt(DATA.breakEnd) : null; const t = Math.min(DATA.breakEnd, breaking.time); return breakAt(breaking.reverse ? DATA.breakEnd - t : t); }; let paint = () => {}; const controller = TURN.mount(stage, DATA.turn, { follow, onHold: () => { touring = false; clearTimeout(idle); }, onFrame: (turned) => { const value = turned.values.dial; if (!own) { angle = target = drawn = value; lift = turned.values.lift ?? lift; if ("pod" in turned.values) podHook = turned.values.pod; describe(); } show(value); paint(); }, }); const GLD = DATA.gl; const LIGHT = (stage.closest("[data-theme]")?.getAttribute("data-theme") ?? "dark") !== "dark" ? 1 : 0; const INK = ` uniform float uLight; vec4 over(vec4 top,vec4 under){return top+under*(1.-top.a);} vec4 inkFor(vec3 seen,float cover){vec3 absorbed=1.-seen;float a=max(max(absorbed.r,max(absorbed.g,absorbed.b)),cover);return vec4(vec3(a)-absorbed,a);} vec4 finish(vec4 ink,vec2 frag){ink.rgb=min(ink.rgb,vec3(ink.a));float n=(hash12(frag)-.5)/255.*smoothstep(0.,1.5/255.,ink.a);return clamp(ink+vec4(n),0.,1.);} `; const BACK = `${glslPose("Dial")}${INK} uniform vec3 uLed; uniform vec3 uLedDir; uniform float uOn; const float FACE_Z=${GLD.faceZ.toFixed(3)}; const float FACE_R=${GLD.faceR.toFixed(3)}; const float HUB_R=${GLD.hubR.toFixed(3)}; void main(){ vec2 vb=viewOf(gl_FragCoord.xy); vec3 p=onFloor(vb,FACE_Z); vec3 q=toDial(p); float r=length(q.xy); float face=(1.-smoothstep(FACE_R-3.,FACE_R-1.,r))*smoothstep(HUB_R+.2,HUB_R+1.6,r); vec3 v=p-uLed; float d2=dot(v,v); float d=sqrt(d2); float lens=.3+.7*pow(max(0.,dot(v/d,uLedDir)),1.5); float lambert=max(0.,(uLed.z-FACE_Z+.6)/d); float grain=.84+.16*noise2(vec2(atan(q.y,q.x)*180.,r*.6)); float e=420.*lens*lambert/(d2+10.)*grain*face; float lit=1.-exp(-e); vec3 tint=vec3(.40,.84,.60); vec4 light=inkFor(mix(vec3(1.),tint,.7*lit),0.); vec3 glow=vec3(.25,1.,.58)*lit*.55; vec4 dark=vec4(glow,max(glow.r,max(glow.g,glow.b))); gl_FragColor=finish(mix(dark,light,uLight)*uOn,gl_FragCoord.xy); }`; const FRONT = `${GLSL_TURN}${INK} uniform vec2 uLedVb; uniform float uLedR; uniform float uLedFace; uniform float uOn; uniform vec4 uSpark[24]; uniform float uSparkHeat[24]; vec4 sparkInk(float heat,float along,float young){ vec3 tail=mix(vec3(.78,.2,.04),vec3(1.,.55,.08),smoothstep(.35,.8,heat)); vec3 head=mix(tail,vec3(1.,.74,.2),smoothstep(.55,.9,heat)*smoothstep(.5,1.,along)); vec3 seen=mix(head,vec3(1.,.95,.82),young*smoothstep(.8,1.,along)); float a=.88*smoothstep(0.,.3,along)*smoothstep(.12,.45,heat); vec4 light=vec4(seen*a,a); vec3 hot=mix(vec3(1.,.35,.05),vec3(1.,.9,.6),young*along)*(.4+.6*heat); vec4 dark=vec4(hot*a,a); return mix(dark,light,uLight); } void main(){ vec2 vb=viewOf(gl_FragCoord.xy); float px=uView.x/uCanvas.x; float soft=.7*px; float d=length(vb-uLedVb); float body=1.-smoothstep(uLedR-.6*px,uLedR+.6*px,d); float core=1.-smoothstep(uLedR*.32,uLedR*.55+.5*px,d); float rim=smoothstep(uLedR*.55,uLedR,d); vec3 seen=mix(mix(vec3(.18,.70,.43),vec3(.11,.52,.31),rim),vec3(.90,1.,.94),core); vec4 lightLed=inkFor(seen,.92)*body; vec3 hot=mix(vec3(.2,1.,.55),vec3(.92,1.,.95),core)*body; vec4 darkLed=vec4(hot,body); vec4 led=mix(darkLed,lightLed,uLight)*smoothstep(.02,.3,uLedFace)*uOn; vec4 sparks=vec4(0.); float w=max(.35+.6*px,1.25*px); for(int i=0;i<24;i++){ float heat=uSparkHeat[i]; if(heat<=0.)continue; vec4 s=uSpark[i]; vec2 a=s.zw; vec2 b=s.xy; vec2 ab=b-a; float l2=max(dot(ab,ab),1e-6); float t=clamp(dot(vb-a,ab)/l2,0.,1.); float dist=length(vb-a-ab*t); float cover=1.-smoothstep(w*.5-.5*px,w*.5+.5*px,dist); if(cover<=0.)continue; sparks=over(sparkInk(heat,t,step(.65,heat))*cover,sparks); } vec4 ink=over(sparks,led)*(1.-coverOf(vb,soft)); gl_FragColor=finish(ink,gl_FragCoord.xy); }`; const canvasOf = (name) => stage.querySelector(`[data-canvas="${name}"]`); const view = [GLD.W, GLD.H]; const backLayer = glLayer({ canvas: canvasOf("back"), fragment: BACK, view, camera: GLD.camera, uniforms: ["uLed", "uLedDir", "uOn", "uDialX", "uDialY", "uDialZ", "uDialT"], fixed: { uLight: LIGHT } }); const frontLayer = backLayer ? glLayer({ canvas: canvasOf("front"), fragment: FRONT, view, camera: GLD.camera, uniforms: ["uLedVb", "uLedR", "uLedFace", "uOn", "uSpark", "uSparkHeat", "uCoverEdge", "uCoverCount"], fixed: { uLight: LIGHT } }) : null; const roomy = frontLayer ? frontLayer.gl.getParameter(frontLayer.gl.MAX_FRAGMENT_UNIFORM_VECTORS) >= 128 : false; if (backLayer && frontLayer && roomy) stage.setAttribute("data-gl", ""); const SPARKS = 24; const sparkData = new Float32Array(SPARKS * 4); const sparkHeat = new Float32Array(SPARKS); const sparks = []; let first = { x: 0, v: 0 }; let glClock = 0; let seed = 7; const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647; const ignite = () => { const at = controller.anchor("dial", GLD.socket).world; const spin = ((angle - (drawn ?? angle)) * Math.PI) / 180; void spin; for (let i = 0; i < SPARKS; i++) { const a = random() * Math.PI * 2; const tilt = 0.35 + random() * 0.9; const speed = 16 + random() * 22; sparks[i] = { p: at.slice(), v: [Math.cos(a) * Math.sin(tilt) * speed, Math.sin(a) * Math.sin(tilt) * speed, Math.cos(tilt) * speed], age: -random() * 0.08, life: 0.3 + random() * 0.45 }; } }; const stepSparks = (dt) => { let live = false; for (let i = 0; i < SPARKS; i++) { const s = sparks[i]; sparkHeat[i] = 0; if (!s) continue; s.age += dt; if (s.age < 0) { live = true; continue; } if (s.age > s.life) { sparks[i] = null; continue; } s.v[2] -= 98 * dt; for (let c = 0; c < 3; c++) s.p[c] += s.v[c] * dt; if (s.p[2] < GLD.faceZ) { sparks[i] = null; continue; } live = true; } return live; }; const writeSparks = () => { const w = controller.cams().world; const project = (p) => [w.ox + w.m[0] * p[0] + w.m[1] * p[1] + w.m[2] * p[2], w.oy + w.m[3] * p[0] + w.m[4] * p[1] + w.m[5] * p[2]]; for (let i = 0; i < SPARKS; i++) { const s = sparks[i]; if (!s || s.age < 0) { sparkHeat[i] = 0; continue; } const head = project(s.p); const tail = project([s.p[0] - s.v[0] * 0.05, s.p[1] - s.v[1] * 0.05, s.p[2] - s.v[2] * 0.05]); sparkData.set([head[0], head[1], tail[0], tail[1]], 4 * i); sparkHeat[i] = 1 - s.age / s.life; } }; paint = () => { if (!stage.hasAttribute("data-gl")) return; const led = controller.anchor(GLD.led.group, GLD.led.at, GLD.led.normal); const world = controller.cams().world; const facing = led.direction[0] * world.V[0] + led.direction[1] * world.V[1] + led.direction[2] * world.V[2]; const on = first.x; backLayer.draw({ uLed: led.world, uLedDir: led.direction, uOn: on, ...TURN.poseUniforms(controller, "dial", "uDial") }, glClock); writeSparks(); const live = sparks.some(Boolean); const cover = TURN.cover(controller, live ? [[GLD.led.part], ["hand.pod.socket"]] : [GLD.led.part]); frontLayer.draw({ uLedVb: led.screen, uLedR: 0.52 * world.k, uLedFace: facing, uOn: on, uSpark: sparkData, uSparkHeat: sparkHeat, uCoverEdge: cover.uCoverEdge, uCoverCount: cover.uCoverCount }, glClock); const u = on; const alpha = 0.92; stage.style.setProperty("--td-fallback", String(Math.max(0, (1 - u) / (1 - u * alpha + 1e-6)))); }; const draw = () => { own = true; controller.set({ dial: angle, lift, pod: podAt() }); own = false; drawn = angle; }; function tick(now) { const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60; last = now; const calm = still.matches; if (calm) { touring = false; clearTimeout(idle); } if (touring) { target += SPEED * dt; clock += dt; } angle = calm ? target : angle + (target - angle) * (1 - Math.exp(-dt / 0.12)); if (Math.abs(target - angle) < 1e-3) angle = target; const nextLift = calm ? 0 : touring ? lift + (tapAt(clock) - lift) * (1 - Math.exp(-dt / 0.15)) : lift; let moved = Math.abs(nextLift - lift) > 1e-5; lift = nextLift; glClock += dt; const before = first.x; const lighting = stage.hasAttribute("data-gl") && settle(first, 1, dt, 0.35, calm); if (first.x !== before) moved = true; const sparking = stage.hasAttribute("data-gl") && stepSparks(calm ? 1 : dt); if (lighting || sparking) moved = true; if (breaking) { if (!breaking.reverse && breaking.time === 0 && !calm) ignite(); breaking.time += calm ? DATA.breakEnd : dt; moved = true; if (breaking.time >= DATA.breakEnd) { broken = breaking.reverse ? 0 : 1; breaking = null; describe(); } } if (moved || drawn === null || Math.abs(angle - drawn) > 1e-4) draw(); const settled = !touring && angle === target && !breaking && !lighting && !sparking; frame = visible && !settled ? requestAnimationFrame(tick) : 0; } const run = () => { if (!frame && visible) { last = 0; frame = requestAnimationFrame(tick); } }; new IntersectionObserver( (entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); else if (frame) { cancelAnimationFrame(frame); frame = 0; } }, { rootMargin: "120px 0px" }, ).observe(stage); let drag = null; stage.addEventListener("pointerdown", (event) => { hold(); drag = { from: controller.angleAt(event.clientX, event.clientY, 17.2, "dial"), base: target, id: event.pointerId, captured: false }; }); stage.addEventListener("pointermove", (event) => { if (!drag || event.pointerId !== drag.id) return; hold(); const now = controller.angleAt(event.clientX, event.clientY, 17.2, "dial"); const delta = now - drag.from; if (!drag.captured && Math.abs(delta) > 1.5) { drag.captured = true; stage.setPointerCapture(event.pointerId); stage.setAttribute("data-drag", ""); } if (!drag.captured) return; target = drag.base + delta; describe(); run(); }); const release = (event) => { if (!drag || event.pointerId !== drag.id) return; drag = null; stage.removeAttribute("data-drag"); try { stage.releasePointerCapture(event.pointerId); } catch {} }; stage.addEventListener("pointerup", release); stage.addEventListener("pointercancel", release); stage.addEventListener("keydown", (event) => { if (event.key === "b" || event.key === "B") { event.preventDefault(); podHook = undefined; if (breaking) breaking = { reverse: !breaking.reverse, time: DATA.breakEnd - breaking.time }; else breaking = { reverse: Boolean(broken), time: 0 }; run(); return; } const step = event.shiftKey ? 36 : 3.6; let next = null; if (event.key === "ArrowRight" || event.key === "ArrowUp") next = Math.round(target / 3.6) * 3.6 - step; if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = Math.round(target / 3.6) * 3.6 + step; if (event.key === "Home") next = INDEX + 90 - 360 * Math.round((INDEX + 90 - target) / 360); if (next === null) return; event.preventDefault(); hold(); target = next; describe(); run(); }); if (still.matches) touring = false; draw(); describe(); -
stress.mjs 19 KB · in bundle
-
turning-dial-light.html 658.1 KB · in bundle
-
turning-dial-verify-light.html 685.3 KB · in bundle
-
turning-dial.html 658.1 KB · in bundle
-
turning-stress-light.html 1.2 MB · in bundle
-
-
-
kit
-
react
-
draw.tsx 6.4 KB · in bundle
-
live.tsx 4.3 KB · in bundle
-
-
audit.mjs 90.5 KB · in bundle
-
canvas-painter.mjs 26.9 KB · in bundle
-
gl-shared.mjs 8.7 KB · in bundle
-
gl.mjs 8.6 KB · in bundle
-
iso-kit.mjs 34.4 KB · in bundle
-
iso-kit.ts 37.7 KB
export type Flat = [number, number]; export type Point3 = [number, number, number]; export type Plane = [number, number]; export interface Projection { origin: Flat; scale: number; azimuth?: number; elevation?: number; } export interface Plan { x: number; y: number; w: number; d: number; r: number; } export interface SolidPaths { fill: string; outline: string; crease: string; top: string; shades: string[]; bevel?: string; seamless?: boolean; } export type Tone = "hi" | "mid" | "lo" | "faint"; export type LineTone = Tone | "lit" | "red" | "green" | "blue"; export const AZIMUTH = 45; export const ELEVATION = 30; export const SHADES = 4; const RADIANS = Math.PI / 180; const UNIT = Math.sqrt(1.6); interface Camera { sinA: number; cosA: number; sinE: number; cosE: number; k: number; } export function cameraOf({ scale, azimuth = AZIMUTH, elevation = ELEVATION }: Projection): Camera { const a = azimuth * RADIANS; const e = elevation * RADIANS; return { sinA: Math.sin(a), cosA: Math.cos(a), sinE: Math.sin(e), cosE: Math.cos(e), k: scale * UNIT }; } export function iso([x, y, z]: Point3, projection: Projection): Flat { const { origin } = projection; const { sinA, cosA, sinE, cosE, k } = cameraOf(projection); return [origin[0] + (x * sinA - y * cosA) * k, origin[1] + ((x * cosA + y * sinA) * sinE - z * cosE) * k]; } export function towardViewer(projection: Projection): Plane { const { sinA, cosA } = cameraOf(projection); return [cosA, sinA]; } export function depthOf([x, y, z]: Point3, projection: Projection) { const { sinA, cosA, sinE, cosE } = cameraOf(projection); return (x * cosA + y * sinA) * cosE + z * sinE; } const tenth = (value: number) => { const text = (Math.round(value * 10) / 10).toFixed(1).replace(/\.0$/, ""); return text === "-0" ? "0" : text; }; export function pathOf(points: Flat[], closed = false) { if (!points.length) return ""; return points.map(([x, y], at) => `${at ? "L" : "M"}${tenth(x)} ${tenth(y)}`).join("") + (closed ? "Z" : ""); } export const joinPaths = (paths: string[]) => paths.filter(Boolean).join(""); export function roundedPlan({ x, y, w, d, r }: Plan, steps = 8): Plane[] { const radius = Math.max(0, Math.min(r, w / 2, d / 2)); const corners: [number, number, number][] = [ [x + w - radius, y + radius, -90], [x + w - radius, y + d - radius, 0], [x + radius, y + d - radius, 90], [x + radius, y + radius, 180], ]; const out: Plane[] = []; for (const [cx, cy, start] of corners) { const count = radius > 0 ? steps : 0; for (let step = 0; step <= count; step++) { const angle = (start + (count ? (90 * step) / count : 45)) * RADIANS; out.push([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]); } } return out; } function signedArea(points: Plane[] | Flat[]) { let area = 0; for (let index = 0; index < points.length; index++) { const [ax, ay] = points[index]!; const [bx, by] = points[(index + 1) % points.length]!; area += ax * by - bx * ay; } return area / 2; } const oriented = (points: Flat[]) => (signedArea(points) < 0 ? [...points].reverse() : points); function convexHull(points: Flat[]): Flat[] { const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]); if (sorted.length < 3) return sorted; const cross = (o: Flat, a: Flat, b: Flat) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); const lower: Flat[] = []; for (const point of sorted) { while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0) lower.pop(); lower.push(point); } const upper: Flat[] = []; for (let index = sorted.length - 1; index >= 0; index--) { const point = sorted[index]!; while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0) upper.pop(); upper.push(point); } return [...lower.slice(0, -1), ...upper.slice(0, -1)]; } function runsOf(flags: boolean[], want: boolean) { const count = flags.length; const runs: number[][] = []; const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]); if (start < 0) return flags[0] === want ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : []; let run: number[] | null = null; for (let step = 0; step < count; step++) { const index = (start + step) % count; if (flags[index] === want) { if (!run) run = [index]; run.push((index + 1) % count); } else if (run) { runs.push(run); run = null; } } if (run) runs.push(run); return runs; } export interface ExtrudeOptions { convex?: boolean; bevel?: number; } export function extrude(ring: Plane[], z: number, height: number, projection: Projection, options: ExtrudeOptions = {}): SolidPaths { const convex = options.convex ?? true; const count = ring.length; const counter = signedArea(ring) > 0; const [vx, vy] = towardViewer(projection); const { sinA, cosA } = cameraOf(projection); const normals = ring.map(([ax, ay], index) => { const [bx, by] = ring[(index + 1) % count]!; const dx = bx - ax; const dy = by - ay; const length = Math.hypot(dx, dy) || 1; return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]) as Plane; }); const facing = normals.map(([nx, ny]) => nx * vx + ny * vy > 1e-9); const top = ring.map(([x, y]) => iso([x, y, z + height], projection)); const bottom = ring.map(([x, y]) => iso([x, y, z], projection)); const outline: string[] = []; const crease: string[] = []; for (const run of runsOf(facing, false)) outline.push(pathOf(run.map((index) => top[index]!))); for (const run of runsOf(facing, true)) { crease.push(pathOf(run.map((index) => top[index]!))); if (height > 0) outline.push(pathOf(run.map((index) => bottom[index]!))); } if (height > 0) { for (let index = 0; index < count; index++) { if (facing[(index + count - 1) % count] !== facing[index]) outline.push(pathOf([top[index]!, bottom[index]!])); } } let fill: string; if (convex) { fill = pathOf(convexHull([...top, ...bottom]), true); } else { const parts = [pathOf(oriented(top), true), pathOf(oriented(bottom), true)]; for (let index = 0; index < count; index++) { if (!facing[index]) continue; const next = (index + 1) % count; parts.push(pathOf(oriented([top[index]!, top[next]!, bottom[next]!, bottom[index]!]), true)); } fill = parts.join(""); } const buckets: string[][] = Array.from({ length: SHADES }, () => []); if (height > 0) { const [lx, ly] = [-sinA, cosA]; for (let index = 0; index < count; index++) { if (!facing[index]) continue; const [nx, ny] = normals[index]!; const lit = (nx * lx + ny * ly + 1) / 2; const shade = Math.min(SHADES - 1, Math.max(0, Math.floor(lit * SHADES))); const next = (index + 1) % count; buckets[shade]!.push(pathOf(oriented([top[index]!, top[next]!, bottom[next]!, bottom[index]!]), true)); } } let bevel: string | undefined; if (options.bevel && options.bevel > 0) { const by = options.bevel; const inset = ring.map(([x, y], index) => { const [ax, ay] = normals[(index + count - 1) % count]!; const [bx, by2] = normals[index]!; const mx = ax + bx; const my = ay + by2; const length = Math.hypot(mx, my) || 1; return [x - (mx / length) * by, y - (my / length) * by] as Plane; }); const insetTop = inset.map(([x, y]) => iso([x, y, z + height], projection)); bevel = runsOf(facing, true) .map((run) => pathOf([top[run[0]!]!, ...run.slice(1, -1).map((index) => insetTop[index]!), top[run[run.length - 1]!]!])) .join(""); } return { fill, outline: outline.join(""), crease: crease.join(""), top: pathOf(oriented(top), true), shades: buckets.map((paths) => paths.join("")), bevel, }; } export const ringOf = (plan: Plan, steps = 8): Plane[] => roundedPlan(plan, steps); export function slabOf(plan: Plan, z: number, height: number, projection: Projection, steps = 8, bevel = 0) { return extrude(ringOf(plan, steps), z, height, projection, { convex: true, bevel }); } export function circleRing(cx: number, cy: number, radius: number, steps = 40): Plane[] { return Array.from({ length: steps }, (_, step) => { const angle = (step / steps) * Math.PI * 2; return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)] as Plane; }); } export function cylinder(cx: number, cy: number, radius: number, z: number, height: number, projection: Projection, steps = 32, bevel = 0) { return extrude(circleRing(cx, cy, radius, steps), z, height, projection, { convex: true, bevel }); } export const insetPlan = ({ x, y, w, d, r }: Plan, by: number): Plan => ({ x: x + by, y: y + by, w: w - by * 2, d: d - by * 2, r: Math.max(0, r - by) }); export function onTop(points: Plane[], z: number, projection: Projection, closed = false) { return pathOf( points.map(([x, y]) => iso([x, y, z], projection)), closed, ); } export const planOutline = (plan: Plan, z: number, projection: Projection, steps = 8) => onTop(ringOf(plan, steps), z, projection, true); export const lineOnTop = (from: Plane, to: Plane, z: number, projection: Projection) => onTop([from, to], z, projection); export const segment = (from: Point3, to: Point3, projection: Projection) => pathOf([iso(from, projection), iso(to, projection)]); export const rise = (at: Plane, from: number, to: number, projection: Projection) => segment([at[0], at[1], from], [at[0], at[1], to], projection); export const ring = (cx: number, cy: number, radius: number, z: number, projection: Projection, steps = 40) => onTop(circleRing(cx, cy, radius, steps), z, projection, true); export const pointsAt = (points: Point3[], projection: Projection): Flat[] => points.map((point) => iso(point, projection)); export function dotGrid(x: number, y: number, columns: number, rows: number, gap: number, z: number, projection: Projection): Flat[] { const dots: Flat[] = []; for (let row = 0; row < rows; row++) { for (let column = 0; column < columns; column++) dots.push(iso([x + column * gap, y + row * gap, z], projection)); } return dots; } export function corners({ x, y, w, d }: Plan, inset = 0): Plane[] { return [ [x + inset, y + inset], [x + w - inset, y + inset], [x + inset, y + d - inset], [x + w - inset, y + d - inset], ]; } export function boxCorners({ x, y, w, d }: Plan, z0: number, z1: number): Point3[] { const out: Point3[] = []; for (const px of [x, x + w]) for (const py of [y, y + d]) for (const z of [z0, z1]) out.push([px, py, z]); return out; } export function knurl(cx: number, cy: number, radius: number, z0: number, z1: number, count: number, projection: Projection, inset = 0.4) { const [vx, vy] = towardViewer(projection); const lines: string[] = []; for (let index = 0; index < count; index++) { const angle = (index / count) * Math.PI * 2; const nx = Math.cos(angle); const ny = Math.sin(angle); if (nx * vx + ny * vy < 0.15) continue; lines.push(segment([cx + radius * nx, cy + radius * ny, z0 + inset], [cx + radius * nx, cy + radius * ny, z1 - inset], projection)); } return lines.join(""); } export function sideArc(cx: number, cy: number, radius: number, z: number, projection: Projection, steps = 24) { const [vx, vy] = towardViewer(projection); const facing = Math.atan2(vy, vx); const points: Flat[] = []; for (let step = 0; step <= steps; step++) { const angle = facing - Math.PI / 2 + (step / steps) * Math.PI; points.push(iso([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle), z], projection)); } return pathOf(points); } export function ringSeam(ring: Plane[], z: number, projection: Projection) { const count = ring.length; const counter = signedArea(ring) > 0; const [vx, vy] = towardViewer(projection); const facing = ring.map(([ax, ay], index) => { const [bx, by] = ring[(index + 1) % count]; const dx = bx - ax; const dy = by - ay; const [nx, ny] = counter ? [dy, -dx] : [-dy, dx]; return nx * vx + ny * vy > 1e-9; }); return runsOf(facing, true).map((run) => pathOf(run.map((index) => iso([ring[index][0], ring[index][1], z], projection)))).join(""); } export const sideSeam = (plan: Plan, z: number, projection: Projection, steps = 8) => ringSeam(ringOf(plan, steps), z, projection); export function sideRing(center: Point3, radius: number, axis: "x" | "y", projection: Projection, steps = 20) { const [px, py, pz] = center; const points: Flat[] = []; for (let step = 0; step < steps; step++) { const angle = (step / steps) * Math.PI * 2; const u = radius * Math.cos(angle); const v = radius * Math.sin(angle); points.push(iso(axis === "x" ? [px, py + u, pz + v] : [px + u, py, pz + v], projection)); } return pathOf(points, true); } export function coil(from: number, to: number, center: Plane, radius: number, turns: number, axis: "x" | "y", projection: Projection, perTurn = 16) { const count = Math.max(1, Math.round(turns * perTurn)); const back: string[] = []; const front: string[] = []; let run: Flat[] = []; let near: boolean | null = null; for (let index = 0; index <= count; index++) { const share = index / count; const angle = share * turns * Math.PI * 2; const u = radius * Math.cos(angle); const v = radius * Math.sin(angle); const along = from + (to - from) * share; const point: Point3 = axis === "x" ? [along, center[0] + u, center[1] + v] : [center[0] + u, along, center[1] + v]; const normal: Point3 = axis === "x" ? [0, Math.cos(angle), Math.sin(angle)] : [Math.cos(angle), 0, Math.sin(angle)]; const facing = depthOf(normal, projection) > 0; const flat = iso(point, projection); if (near === null) near = facing; if (facing !== near) { run.push(flat); (near ? front : back).push(pathOf(run)); run = [flat]; near = facing; continue; } run.push(flat); } if (run.length > 1) (near ? front : back).push(pathOf(run)); return { back: back.join(""), front: front.join("") }; } export interface Ticks { minor: string; major: string; } export function topTicks(from: number, to: number, step: number, every: number, edge: number, z: number, lengths: [number, number], projection: Projection, axis: "x" | "y" = "x"): Ticks { const minor: string[] = []; const major: string[] = []; const first = Math.ceil(from / step) * step; for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) { const big = index % every === 0; const length = big ? lengths[1] : lengths[0]; const a: Plane = axis === "x" ? [at, edge] : [edge, at]; const b: Plane = axis === "x" ? [at, edge + length] : [edge + length, at]; (big ? major : minor).push(lineOnTop(a, b, z, projection)); } return { minor: minor.join(""), major: major.join("") }; } export function sideTicks(from: number, to: number, step: number, every: number, face: number, top: number, lengths: [number, number], projection: Projection, axis: "x" | "y" = "x"): Ticks { const minor: string[] = []; const major: string[] = []; const first = Math.ceil(from / step) * step; for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) { const big = index % every === 0; const length = big ? lengths[1] : lengths[0]; const a: Point3 = axis === "x" ? [at, face, top] : [face, at, top]; const b: Point3 = axis === "x" ? [at, face, top - length] : [face, at, top - length]; (big ? major : minor).push(segment(a, b, projection)); } return { minor: minor.join(""), major: major.join("") }; } export function radialTicks(cx: number, cy: number, radius: number, count: number, every: number, z: number, lengths: [number, number], projection: Projection, from = 0, span = Math.PI * 2): Ticks { const minor: string[] = []; const major: string[] = []; const closed = Math.abs(span - Math.PI * 2) < 1e-9; const steps = closed ? count : count + 1; for (let index = 0; index < steps; index++) { const angle = from + (index / count) * span; const big = index % every === 0; const inner = radius - (big ? lengths[1] : lengths[0]); const sx = Math.sin(angle); const sy = -Math.cos(angle); (big ? major : minor).push(lineOnTop([cx + sx * inner, cy + sy * inner], [cx + sx * radius, cy + sy * radius], z, projection)); } return { minor: minor.join(""), major: major.join("") }; } export const haloOf = (plan: Plan, z: number, height: number, projection: Projection) => slabOf(plan, z, height, projection).fill; export function axisVector(projection: Projection, [dx, dy, dz]: Point3): Flat { const origin = iso([0, 0, 0], projection); const moved = iso([dx, dy, dz], projection); return [moved[0] - origin[0], moved[1] - origin[1]]; } const fine = (value: number) => Math.round(value * 100) / 100; export function translateAlong(projection: Projection, by: Point3) { const [x, y] = axisVector(projection, by); return `translate(${fine(x)} ${fine(y)})`; } const thousandth = (value: number) => Math.round(value * 1000) / 1000; function axesAt(at: Plane, z: number, projection: Projection) { const origin = iso([at[0], at[1], z], projection); const along = (point: Point3): Flat => { const [px, py] = iso(point, projection); return [px - origin[0], py - origin[1]]; }; return { origin, x: along([at[0] + 1, at[1], z]), y: along([at[0], at[1] + 1, z]), z: along([at[0], at[1], z + 1]) }; } const matrixOf = (u: Flat, v: Flat, origin: Flat) => `matrix(${thousandth(u[0])} ${thousandth(u[1])} ${thousandth(v[0])} ${thousandth(v[1])} ${thousandth(origin[0])} ${thousandth(origin[1])})`; export function holdStill(element: Element, [dx, dy]: Flat) { const text = dx || dy ? `translate(${-dx} ${-dy})` : ""; element.querySelectorAll("[data-hold]").forEach((node) => (text ? node.setAttribute("transform", text) : node.removeAttribute("transform"))); } export function topMatrix(at: Plane, z: number, projection: Projection, along: "x" | "y" = "x") { const { origin, x, y } = axesAt(at, z, projection); return along === "x" ? matrixOf(x, y, origin) : matrixOf([-y[0], -y[1]], x, origin); } export function sideMatrix(at: Plane, z: number, projection: Projection, face: "left" | "right") { const { origin, x, y, z: up } = axesAt(at, z, projection); const down: Flat = [-up[0], -up[1]]; return face === "left" ? matrixOf(x, down, origin) : matrixOf([-y[0], -y[1]], down, origin); } export function screenRise(height: number, projection: Projection) { const { cosE, k } = cameraOf(projection); return height * cosE * k; } export interface FitOptions { pad?: number; headroom?: number; azimuth?: number; elevation?: number; } export function fitProjection(points: Point3[], width: number, height: number, { pad = 24, headroom = 0, azimuth = AZIMUTH, elevation = ELEVATION }: FitOptions = {}): Projection { const unit: Projection = { origin: [0, 0], scale: 1, azimuth, elevation }; const flat = points.map((point) => iso(point, unit)); const xs = flat.map(([x]) => x); const ys = flat.map(([, y]) => y); const left = Math.min(...xs); const right = Math.max(...xs); const up = Math.min(...ys); const down = Math.max(...ys); const scale = Math.min((width - pad * 2) / (right - left), (height - pad * 2 - headroom) / (down - up)); return { origin: [width / 2 - ((left + right) / 2) * scale, headroom + (height - headroom) / 2 - ((up + down) / 2) * scale], scale, azimuth, elevation, }; } const escape = (text: string) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); const attrs = (pairs: Record<string, string | number | undefined | false>) => Object.entries(pairs) .filter(([, value]) => value !== undefined && value !== false) .map(([key, value]) => ` ${key}="${escape(String(value))}"`) .join(""); export interface SolidStyle { tone?: Tone; crease?: Tone | "none"; halo?: boolean; lit?: boolean; flat?: boolean; className?: string; inner?: string; } export function solidSvg(paths: SolidPaths, { tone = "hi", crease = "faint", halo = false, lit = false, flat = false, className, inner = "" }: SolidStyle = {}) { const parts: string[] = []; if (halo) parts.push(`<path class="iso-halo" d="${paths.fill}"/>`); parts.push(`<path class="iso-fill" d="${paths.fill}"/>`); if (!flat) { paths.shades.forEach((d, shade) => { if (d) parts.push(`<path class="iso-shade" data-shade="${shade}" d="${d}"/>`); }); parts.push(`<path class="iso-top" d="${paths.top}"/>`); } if (crease !== "none" && paths.crease) parts.push(`<path class="iso-line iso-crease" data-tone="${crease}" d="${paths.crease}"/>`); if (crease !== "none" && paths.bevel) parts.push(`<path class="iso-line iso-bevel" data-tone="${crease}" d="${paths.bevel}"/>`); if (inner) parts.push(inner); parts.push(`<path class="iso-line iso-edge" data-tone="${tone}" d="${paths.outline}"/>`); return `<g${attrs({ class: className ? `iso-solid ${className}` : "iso-solid", "data-lit": lit ? "" : undefined, "data-seamless": paths.seamless ? "" : undefined })}>${parts.join("")}</g>`; } export interface LineStyle { tone?: LineTone; dotted?: boolean; dashed?: boolean; flow?: boolean; className?: string; free?: boolean | "start" | "end"; } export function lineSvg(d: string, { tone = "lo", dotted = false, dashed = false, flow = false, className, free }: LineStyle = {}) { if (!d) return ""; return `<path${attrs({ class: className ? `iso-line ${className}` : "iso-line", "data-tone": tone, "data-free": free === true ? "" : free || undefined, "data-dotted": dotted ? "" : undefined, "data-dashed": dashed ? "" : undefined, "data-flow": flow ? "" : undefined, d, })}/>`; } export function dotsSvg(points: Array<Flat | [number, number, number]>, { size = 0.5, tone = "mid", pulse = false }: { size?: number; tone?: Tone; pulse?: boolean } = {}) { const dots = points .map(([x, y, alpha]: number[], index) => `<circle cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"${alpha !== undefined && alpha < 1 ? ` fill-opacity="${Math.max(0, alpha).toFixed(2)}"` : ""}${pulse ? ` data-pulse="" style="--pulse-at:${(index * 137) % 1600}ms"` : ""}/>`) .join(""); return `<g class="iso-dots" data-tone="${tone}">${dots}</g>`; } export type Faded = { d: string; alpha: number } | [string, number]; export function fadedSvg(list: Faded[], style: LineStyle = {}) { const groups = new Map<number, string>(); for (const entry of list) { const [d, alpha] = Array.isArray(entry) ? entry : [entry.d, entry.alpha]; if (!d || !(alpha > 0.02)) continue; const level = Math.min(1, Math.round(alpha * 20) / 20); groups.set(level, (groups.get(level) ?? "") + d); } return [...groups].map(([alpha, d]) => (alpha >= 1 ? lineSvg(d, style) : `<g opacity="${alpha.toFixed(2)}">${lineSvg(d, style)}</g>`)).join(""); } export interface FadeLineStyle { fade?: number | [number, number]; tone?: LineTone; className?: string; stops?: number; } export function fadeLineSvg(points: Flat[], { fade = [0, 0], tone = "lo", className, stops = 6 }: FadeLineStyle = {}) { if (points.length < 2) return ""; const ease = (t: number) => t * t * (3 - 2 * t); const hashOf = (text: string) => { let hash = 2166136261; for (let index = 0; index < text.length; index++) hash = Math.imul(hash ^ text.charCodeAt(index), 16777619); return (hash >>> 0).toString(36); }; const lengths = [0]; for (let index = 1; index < points.length; index++) lengths.push(lengths[index - 1] + Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1])); const at = (length: number): Flat => { let index = 1; while (index < points.length - 1 && lengths[index] < length) index++; const span = lengths[index] - lengths[index - 1] || 1; const t = Math.max(0, Math.min(1, (length - lengths[index - 1]) / span)); return [points[index - 1][0] + (points[index][0] - points[index - 1][0]) * t, points[index - 1][1] + (points[index][1] - points[index - 1][1]) * t]; }; const pieceOf = (from: number, to: number): Flat[] => [at(from), ...points.filter((_, index) => lengths[index] > from + 1e-6 && lengths[index] < to - 1e-6), at(to)]; const total = lengths[lengths.length - 1]; if (total < 1e-6) return ""; let [start, end] = Array.isArray(fade) ? fade : [fade, fade]; if (start + end > total) { const share = total / (start + end); start *= share; end *= share; } const paint = `var(--anatomy-${tone})`; const ramp = (piece: Flat[], rising: boolean) => { const d = pathOf(piece); const [x1, y1] = piece[0]; const [x2, y2] = piece[piece.length - 1]; const id = `iso-fade-${hashOf(`${d}|${tone}|${rising}`)}`; const marks = Array.from({ length: stops + 1 }, (_, index) => { const t = index / stops; const alpha = ease(rising ? t : 1 - t); return `<stop offset="${t.toFixed(3)}" stop-opacity="${alpha.toFixed(3)}" style="stop-color:${paint}"/>`; }).join(""); const gradient = `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${tenth(x1)}" y1="${tenth(y1)}" x2="${tenth(x2)}" y2="${tenth(y2)}">${marks}</linearGradient>`; return `<defs>${gradient}</defs><path${attrs({ class: className ? `iso-line ${className}` : "iso-line", "data-tone": tone, "data-free": rising ? "start" : "end", d, style: `stroke:url(#${id})` })}/>`; }; const parts: string[] = []; if (start > 0.05) parts.push(ramp(pieceOf(0, start), true)); if (total - end - start > 0.05) parts.push(lineSvg(pathOf(pieceOf(start, total - end)), { tone, className })); if (end > 0.05) parts.push(ramp(pieceOf(total - end, total), false)); return `<g class="iso-fade">${parts.join("")}</g>`; } export interface WireStyle { via?: Flat[]; tone?: LineTone; dotted?: boolean; dashed?: boolean; flow?: boolean; ends?: "both" | "from" | "to" | "none"; size?: number; } export function wireSvg(from: Flat, to: Flat, { via = [], tone = "lo", dotted = false, dashed = false, flow = false, ends = "both", size = 1.2 }: WireStyle = {}) { const d = pathOf([from, ...via, to]); const end = ([x, y]: Flat) => `<circle class="iso-wire-end" cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"/>`; return `<g class="iso-wire" data-tone="${tone}">${lineSvg(d, { tone, dotted, dashed, flow })}${ends === "both" || ends === "from" ? end(from) : ""}${ends === "both" || ends === "to" ? end(to) : ""}</g>`; } export function faceTextSvg(transform: string, text: string, { size = 3, tone = "mid", anchor = "start" }: { size?: number; tone?: Tone; anchor?: "start" | "middle" | "end" } = {}) { return `<text class="iso-face-text" data-tone="${tone}" transform="${transform}" font-size="${size}" text-anchor="${anchor}">${escape(text)}</text>`; } export function groupSvg(children: string | string[], extra: Record<string, string | number | undefined | false> = {}) { return `<g${attrs(extra)}>${Array.isArray(children) ? children.join("") : children}</g>`; } export type Theme = "dark" | "light"; export interface FigureOptions { width: number; height: number; label: string; body: string | string[]; standalone?: Theme | false; className?: string; } export function figureSvg({ width, height, label, body, standalone = false, className }: FigureOptions) { const style = standalone ? `<style>${ISO_CSS}</style>` : ""; return `<svg${attrs({ xmlns: "http://www.w3.org/2000/svg", class: `iso-svg${standalone ? " iso" : ""}${className ? ` ${className}` : ""}`, "data-theme": standalone || undefined, viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": label, })}>${style}${Array.isArray(body) ? body.join("") : body}</svg>`; } export type KeyMark = "raised" | "flat" | "lit" | "edge" | "dim" | "dotted" | "dashed" | "red" | "green" | "blue"; export interface LegendKey { mark: KeyMark; label: string; } function swatchSvg(mark: KeyMark) { if (mark === "raised" || mark === "flat" || mark === "lit") { const body = mark === "flat" ? "M2 6.5 8 3.5 14 6.5 8 9.5Z" : "M2 4.5 8 1.5 14 4.5 14 6 8 9 2 6Z"; const crease = mark === "flat" ? "" : `<path class="iso-swatch-crease" d="M2 4.5 8 7.5 14 4.5"/>`; return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="${body}"/>${crease}</svg>`; } return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="M1.5 5h13"/></svg>`; } export interface PlateOptions { fig: string; title?: string; hint?: string; readout?: string; keys?: LegendKey[]; caption?: string; body: string; } export function plateHtml({ fig, title, hint, readout = "", keys = [], caption, body }: PlateOptions) { const corner = (name: string, text: string, extra = "") => `<span class="iso-plate-corner" data-corner="${name}"${extra}>${escape(text)}</span>`; const legend = keys.length || caption ? `<figcaption class="iso-legend">${keys.length ? `<span class="iso-keys">${keys.map((key) => `<span class="iso-key">${swatchSvg(key.mark)}${escape(key.label)}</span>`).join("")}</span>` : ""}${caption ? `<span class="iso-caption">${escape(caption)}</span>` : ""}</figcaption>` : ""; return `<figure class="iso-figure"><div class="iso-plate"><div class="iso-plate-corners" aria-hidden="true">${corner("fig", fig)}${title ? corner("title", title) : ""}${hint ? corner("hint", hint) : ""}${corner("readout", readout, " data-readout")}</div>${body}</div>${legend}</figure>`; } export interface PageOptions { title: string; theme?: Theme; body: string; script?: string; width?: number; } export function pageHtml({ title, theme = "dark", body, script = "", width = 650 }: PageOptions) { return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${ISO_CSS}${PAGE_CSS.replace("__WIDTH__", String(width))}</style></head><body class="iso" data-theme="${theme}"><main class="iso-page">${body}</main>${script ? `<script type="module">${script.replace(/<\/(script)/gi, "<\\/$1")}</script>` : ""}</body></html>`; } const PAGE_CSS = ` *{box-sizing:border-box} html{color-scheme:dark} body[data-theme="light"]{color-scheme:light} body{margin:0;background:var(--anatomy-page);color:var(--anatomy-text);font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased} .iso-page{width:min(__WIDTH__px,100% - 32px);margin:64px auto 96px} `; export const ISO_CSS = ` .iso{ --anatomy-page:#0b0b0b;--anatomy-card:#131313;--anatomy-text:#a3a3a3;--anatomy-strong:#e6e6e6;--anatomy-muted:#6e6e6e; --anatomy-paper:#131313;--anatomy-face:#1b1b1b;--anatomy-top:#1c1c1c; --anatomy-shade-0:#0f0f0f;--anatomy-shade-1:#121212;--anatomy-shade-2:#151515;--anatomy-shade-3:#181818; --anatomy-lit-top:#262626;--anatomy-lit-shade:#1d1d1d; --anatomy-hi:#d4d4d4;--anatomy-mid:#6a6a6a;--anatomy-lo:#3e3e3e;--anatomy-faint:#2a2a2a;--anatomy-lit:#ffffff; --anatomy-halo:#050505;--anatomy-halo-opacity:.9;--anatomy-dot:#e6e6e6; --anatomy-red:#ff6b6b;--anatomy-green:#6be0a0;--anatomy-blue:#6b9bff; --anatomy-weight:.6px;--anatomy-ease:cubic-bezier(.32,.72,0,1); --anatomy-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --anatomy-lift:inset 0 1px 0 0 #ffffff0a,inset 0 0 0 1px #ffffff06,0 0 0 1px #00000026,0 1px 1px -.5px #00000014,0 3px 3px -1.5px #00000014,0 6px 6px -3px #00000014,0 12px 12px -6px #00000014; } .iso[data-theme="light"]{ --anatomy-page:#f2f2ef;--anatomy-card:#fbfbfa;--anatomy-text:#57575a;--anatomy-strong:#18181a;--anatomy-muted:#8c8c90; --anatomy-paper:#fbfbfa;--anatomy-face:#ffffff;--anatomy-top:#ffffff; --anatomy-shade-0:#dcdcd9;--anatomy-shade-1:#e5e5e2;--anatomy-shade-2:#ededea;--anatomy-shade-3:#f4f4f1; --anatomy-lit-top:#ffffff;--anatomy-lit-shade:#eeeeeb; --anatomy-hi:#26262a;--anatomy-mid:#8a8a8f;--anatomy-lo:#b4b4b8;--anatomy-faint:#d4d4d6;--anatomy-lit:#000000; --anatomy-halo:#8a8a80;--anatomy-halo-opacity:.32;--anatomy-dot:#3a3a3e; --anatomy-red:#e5484d;--anatomy-green:#30a46c;--anatomy-blue:#3e63dd; --anatomy-lift:inset 0 1px 0 0 #ffffff,0 0 0 1px #0000000f,0 1px 1px -.5px #0000000a,0 3px 3px -1.5px #0000000a,0 6px 6px -3px #0000000a,0 12px 12px -6px #0000000a; } .iso-svg{display:block;width:100%;height:auto;overflow:visible} .iso-svg path,.iso-svg circle{vector-effect:non-scaling-stroke} .iso-fill{fill:var(--anatomy-paper);stroke:none} .iso-solid[data-lit]>.iso-fill{fill:var(--anatomy-face)} .iso-shade,.iso-top{stroke:none} .iso-shade[data-shade="0"]{fill:var(--anatomy-shade-0)} .iso-shade[data-shade="1"]{fill:var(--anatomy-shade-1)} .iso-shade[data-shade="2"]{fill:var(--anatomy-shade-2)} .iso-shade[data-shade="3"]{fill:var(--anatomy-shade-3)} .iso-top{fill:var(--anatomy-top)} .iso-solid[data-lit]>.iso-top{fill:var(--anatomy-lit-top)} .iso-solid[data-lit]>.iso-shade{fill:var(--anatomy-lit-shade)} .iso-solid[data-seamless]>.iso-shade,.iso-solid[data-seamless]>.iso-top{stroke-width:.4;stroke-linejoin:round} .iso-solid[data-seamless]>.iso-shade[data-shade="0"]{stroke:var(--anatomy-shade-0)} .iso-solid[data-seamless]>.iso-shade[data-shade="1"]{stroke:var(--anatomy-shade-1)} .iso-solid[data-seamless]>.iso-shade[data-shade="2"]{stroke:var(--anatomy-shade-2)} .iso-solid[data-seamless]>.iso-shade[data-shade="3"]{stroke:var(--anatomy-shade-3)} .iso-solid[data-seamless]>.iso-top{stroke:var(--anatomy-top)} .iso-solid[data-seamless][data-lit]>.iso-top{stroke:var(--anatomy-lit-top)} .iso-solid[data-seamless][data-lit]>.iso-shade{stroke:var(--anatomy-lit-shade)} .iso-solid[data-lit]>.iso-edge{stroke:var(--anatomy-lit)} .iso-halo{fill:var(--anatomy-halo);stroke:none;filter:blur(9px);opacity:var(--anatomy-halo-opacity);transform:translateY(3px)} .iso-line{fill:none;stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight);stroke-linecap:round;stroke-linejoin:round} .iso-line[data-tone="hi"]{stroke:var(--anatomy-hi)} .iso-line[data-tone="mid"]{stroke:var(--anatomy-mid)} .iso-line[data-tone="lo"]{stroke:var(--anatomy-lo)} .iso-line[data-tone="faint"]{stroke:var(--anatomy-faint)} .iso-line[data-tone="lit"]{stroke:var(--anatomy-lit)} .iso-line[data-tone="red"]{stroke:var(--anatomy-red)} .iso-line[data-tone="green"]{stroke:var(--anatomy-green)} .iso-line[data-tone="blue"]{stroke:var(--anatomy-blue)} .iso-line[data-dotted]{stroke-dasharray:.01 3.5;stroke-width:1} .iso-line[data-dashed]{stroke-dasharray:3 3} .iso-line[data-flow]{stroke-dasharray:2 6} .iso-wire-end{fill:var(--anatomy-paper);stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight)} .iso-wire[data-tone="hi"] .iso-wire-end{stroke:var(--anatomy-hi)} .iso-wire[data-tone="mid"] .iso-wire-end{stroke:var(--anatomy-mid)} .iso-wire[data-tone="faint"] .iso-wire-end{stroke:var(--anatomy-faint)} .iso-wire[data-tone="lit"] .iso-wire-end{stroke:var(--anatomy-lit)} .iso-dots circle{fill:var(--anatomy-dot);stroke:none;opacity:.32} .iso-dots[data-tone="hi"] circle{opacity:.85} .iso-dots[data-tone="mid"] circle{opacity:.45} .iso-dots[data-tone="lo"] circle{opacity:.22} .iso-dots[data-tone="faint"] circle{opacity:.12} .iso-face-text{fill:var(--anatomy-mid);font-family:var(--anatomy-mono);letter-spacing:.04em} .iso-face-text[data-tone="hi"]{fill:var(--anatomy-hi)} .iso-face-text[data-tone="lo"]{fill:var(--anatomy-lo)} .iso-face-text[data-tone="faint"]{fill:var(--anatomy-faint)} .tb-body{fill:var(--anatomy-shade-2);stroke:none} .tb-shine{fill:var(--anatomy-top);stroke:none} .tb-shade{fill:var(--anatomy-shade-0);stroke:none} .tb-ring{fill:none;stroke:var(--anatomy-lo);stroke-width:.45;stroke-linecap:butt;stroke-linejoin:round} .tb-ring[data-tone="mid"]{stroke:var(--anatomy-mid)} .tb-ring[data-tone="faint"]{stroke:var(--anatomy-faint)} .tb-ring[data-tone="hi"]{stroke:var(--anatomy-hi)} @keyframes iso-flow{to{stroke-dashoffset:-32}} @keyframes iso-pulse{0%,100%{opacity:.18}12%{opacity:1}40%{opacity:.18}} .iso-line[data-flow]{animation:iso-flow 1600ms linear infinite} .iso-dots circle[data-pulse]{animation:iso-pulse 1600ms steps(1,end) infinite;animation-delay:var(--pulse-at,0ms)} .iso-figure{margin:32px 0 36px} .iso-plate{position:relative;padding:52px 24px;border-radius:0;background:var(--anatomy-card);box-shadow:0 0 0 1px var(--anatomy-mid);--anatomy-paper:var(--anatomy-card)} .iso-plate-corners{position:absolute;inset:18px 20px;pointer-events:none} .iso-plate-corner{position:absolute;color:var(--anatomy-muted);font-family:var(--anatomy-mono);font-size:10.5px;line-height:1;letter-spacing:.04em;white-space:nowrap} .iso-plate-corner[data-corner="fig"]{top:0;left:0;color:var(--anatomy-text)} .iso-plate-corner[data-corner="title"]{top:0;right:0;text-transform:uppercase;letter-spacing:.1em} .iso-plate-corner[data-corner="hint"]{bottom:0;left:0;text-transform:uppercase;letter-spacing:.1em} .iso-plate-corner[data-corner="readout"]{right:0;bottom:0;color:var(--anatomy-text);font-variant-numeric:tabular-nums} .iso-plate .iso-svg{margin:0 auto} .iso-legend{display:grid;gap:8px;margin-top:12px;color:var(--anatomy-muted);font-size:13px;line-height:1.55} .iso-keys{display:flex;flex-wrap:wrap;gap:6px 18px} .iso-key{display:inline-flex;align-items:center;gap:7px} .iso-swatch{width:16px;height:10px;flex:none;overflow:visible} .iso-swatch path{fill:none;stroke:var(--anatomy-hi);stroke-width:1;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round} .iso-swatch[data-mark="raised"] path,.iso-swatch[data-mark="flat"] path{stroke:var(--anatomy-mid)} .iso-swatch[data-mark="lit"] path{stroke:var(--anatomy-lit)} .iso-swatch[data-mark="dim"] path{stroke:var(--anatomy-lo)} .iso-swatch[data-mark="dotted"] path{stroke-dasharray:.01 3;stroke-width:1.4} .iso-swatch[data-mark="dashed"] path{stroke-dasharray:3 2.5} .iso-swatch[data-mark="red"] path{stroke:var(--anatomy-red)} .iso-swatch[data-mark="green"] path{stroke:var(--anatomy-green)} .iso-swatch[data-mark="blue"] path{stroke:var(--anatomy-blue)} .iso-swatch-crease{stroke:var(--anatomy-lo)!important} @media (max-width:560px){.iso-plate{padding:48px 12px 70px}.iso-plate-corner[data-corner="hint"]{bottom:18px}} @media (prefers-reduced-motion:reduce){.iso-line[data-flow],.iso-dots circle[data-pulse]{animation:none}} `; -
iso.css 7.5 KB · in bundle
-
lathe.mjs 46.9 KB · in bundle
-
tube.mjs 13.9 KB · in bundle
-
turn-audit.mjs 55.8 KB · in bundle
-
turn-build.mjs 63.7 KB · in bundle
-
turn-fixed.mjs 143.6 KB · in bundle
-
turn.mjs 136.5 KB · in bundle
-
-
references
-
3d.md 43.6 KB
# 3D: parts that turn in the drawing The optional 3D mode ("turning groups") lets some parts of a figure move as rigid bodies: a dial or turntable that spins, fingers that flex, a piece that breaks off and tumbles. Those parts are re-projected and re-shaded every frame by the kit's own rules and painted in an order solved from separating planes. Everything else stays ordinary static kit art. A figure that doesn't opt in is untouched. **Status: beta.** The engine is proved on `examples/turning-dial` and one test figure (a desk fan built from this document alone), not yet on a production figure. Turn one subject at a time, keep clutter static, and measure your figure against section 15 before you add parts. ## Contents 1. When a figure earns 3D 2. The model 3. A minimal page 4. The authoring API 5. Part kinds and the rules that keep them true 6. Materials 7. Free groups: parts that break off 8. Hinge chains: hands and fingers 9. Painter's order and why it is correct 10. Shading and fades 11. Static layers and a messy workshop around a turntable 12. Shaders that follow the turn and the groups 13. The orbit audit 14. Browser checks 15. Performance 16. Known limits ## 1. When a figure earns 3D Only when the motion is the explanation: a dial whose numbers are read as it turns, a turntable that shows an object from every side, a wheel pack, a hand that grips or breaks. Never as decoration. A figure that only needs a part to slide or a needle to swing stays 2D: `translateAlong` and a re-drawn needle are cheaper and exact. ## 2. The model - **Groups.** A group is a rigid frame with a pose. Kinds: `turn` (about a vertical axis through `pivot`, any angle), `slide` (along a direction, `0…travel`), `hinge` (about any axis through a point, within `range`, in degrees) and `free` (any rotation plus a translation, set by the figure every frame). Groups nest: a pose is relative to the parent. - **Rest.** Every constructor takes **world coordinates at rest** (every group value 0, every free group `null`). The engine subtracts each group's origin, so the recorder (`A.recorder`), the kit and the engine share one world. - **Live layers.** A live layer is one `<svg class="iso-svg iso-live" data-live="name">` in the stage. Live and static layers stack in the order of `T.stack([...])`: for example `back svg → [dial live] → back canvas → [hand live] → front canvas → front svg`. - **The identity.** Turning a group by θ about a vertical axis is exactly the kit drawing at azimuth `a − θ`, because the kit's light is camera-locked. So every frame of a turn group is the kit's own picture at P′(θ), shifted; the kit builders at P′(θ) are the fidelity reference, and every kit screen-space audit runs unchanged at P′(θ). Hinges and free groups compose matrices instead (correct, slightly slower). ## 3. A minimal page `examples/turning-dial/` is the reference scene: a dial with a knurl, ticks and numerals, a second turn group (an index wheel under a window), a hinge (a pawl riding the knurl), a hand of about 80 parts with materials, a three-hinge finger that lifts, a hex pod that breaks off as a free group, and a WebGL LED, light pool and sparks. `examples/turning-dial/stress.mjs` is the performance scene (a dense hand on a turntable in a cluttered workshop); it is not audit-clean, so take patterns from `build.mjs`. `build.mjs`, reduced to its skeleton: ```js import * as k from "../../kit/iso-kit.mjs"; import * as A from "../../kit/audit.mjs"; import { turning } from "../../kit/turn-build.mjs"; import * as TA from "../../kit/turn-audit.mjs"; import { turnScript, glScript } from "../../scripts/inline-kit.mjs"; export const P = k.fitProjection(points, W, H, { pad: 22, azimuth: 40, elevation: 31 }); export const R = A.recorder(P); export const T = turning(P, { recorder: R }); const dial = T.group("dial", { turn: { pivot: [0, 0] } }); const lift = T.group("f2.mcp", { parent: dial, hinge: { point: C1, axis: w, range: [-22, 0] } }); const hand = T.layer("hand"); T.stack(["back", "hand", "front"]); export const follow = (values) => ({ ...values, "f2.mcp": -22 * (values.lift ?? 0) }); export const poses = () => Array.from({ length: 720 }, (_, i) => follow({ dial: i * 0.5, lift: (i % 90) / 90 })); T.round({ name: "dial.body", group: dial, layer: hand, F: VERT(0, 0), s0: 10, s1: 14.4, r: 50, material: "steel", details: { ribs: { s0: 10.9, s1: 13.7, count: 150, seams: true, tone: "lo" } } }); T.prism({ name: "hand.palm", group: dial, layer: hand, plan: PALM, z: 19.4, h: 8, bevel: 0.9, material: "gold" }); T.tube({ name: "cable.red", group: dial, layer: hand, route, r: 0.7, hue: "red", material: "rubber", gaps }); // … static kit art: R.put({ name, layer: "back", svg, shapes }) as usual … const out = T.build({ statics: R.items, poses, verify: argv.includes("--verify") }); A.settle(R, P); if (argv.includes("--audit")) { A.auditOrExit(R, P); TA.orbitOrExit(T, R, P, { sweep: { at: (x) => follow({ dial: x }), from: 0, to: 360, frame: 0.3 }, clips: [{ name: "tap", at: (t) => follow({ dial: 40 + 12 * t, lift: tapAt(t) }), from: 0, to: 4, frame: 1 / 60 }] }); } const stage = `<div class="iso-stage iso-turn" tabindex="0" role="slider" …>${backSvg}${out.layerSvg("hand", { width: W, height: H })}${frontSvg}</div>`; const page = k.pageHtml({ title, theme, body: k.plateHtml({ …, body: `<style>${out.css}</style>${stage}` }), script: turnScript() + LIVE }); ``` `live.js`: ```js const controller = TURN.mount(stage, DATA.turn, { follow, onHold: () => (touring = false), onFrame: () => paint() }); function tick(now) { … controller.set({ dial: angle, lift }); frame = visible && moving ? requestAnimationFrame(tick) : 0; } ``` The figure owns the loop (`motion.md`); the controller never runs its own `requestAnimationFrame`. `out.css` is `TURN_CSS` (the face band rules, materials, hue tubes and the `.iso-turn` stacking): include it once. **In React or Next.js** (`references/react.md`): copy `turn.mjs`, `turn-build.mjs` and `turn-audit.mjs` beside the kit with the imports rewritten (`./iso-kit.mjs` → `./kit`, and so on), and `TURN_CSS` as a `turn.css` imported once. The geometry module (server) runs `turning(…)` and `T.build(…)` and returns the stage markup (`out.layerSvg(name, { width, height })` for each live layer, in stack order between the static SVGs and canvases) plus `out.data` in the figure's data. The client mount calls `TURN.mount(stageElement, data.turn, { follow, onHold, onFrame })` and drives `controller.set(...)` from its own loop; return `controller.destroy` from the effect's cleanup. A verify build can put `out.verify` in the data and have the mount assign `window.__isoVerify` for `turn-check --order`. ## 4. The authoring API ### Scene | Call | What it does | |---|---| | `turning(P, { recorder, scale = 1 })` | The builder `T`. Pass the kit recorder so every live shape is recorded for the kit audit too. | | `T.group(name, { parent?, turn: { pivot: [x, y] } })` | Rotation about the vertical through `pivot`, value in degrees. | | `T.group(name, { parent?, slide: { direction, travel } })` | Translation, value `0…travel` world units. | | `T.group(name, { parent?, hinge: { point, axis, range: [lo, hi] } })` | Rotation about `axis` through `point`, value in degrees within `range`. | | `T.group(name, { parent?, free: { origin } })` | A free rigid body (section 7). Value `{ R: [9] \| q: [x, y, z, w] \| axis, angle, t: [3], world? }` or `null` for rest. | | `T.layer(name)`, `T.stack([…])` | Live layers and the full stack of live and static layer names, back to front. | | `T.build({ statics, poses, verify = false, strict = true })` | Solves every pair, proves every static item's layer, packs the page data. `poses` is the figure's reachable states (a list or a function returning one): every motion clip, sampled. Throws a readable error on any pair with no separating plane, any static that can't be proved, any non-convex part. `strict: false` keeps interlocked pairs (so a planted fault reaches the audit). | `T.build` returns `out = { svg, data, css, report, layerSvg(name, { width, height }), verify }`. `out.data` goes to the page (`DATA.turn`). `out.report` has `parts`, `layers` (per layer: `planar`, `framed`, `dynamic`, `skip` counts), `tight`, `margins`, `proofs`, `seconds`. ### Constructors Common options on every constructor: `{ name, group, layer, tone = "hi", crease = "faint", lit = false, material, owner, details = {} }`. | Constructor | Part | |---|---| | `T.prism({ plan, z, h, steps, bevel })` | An upright kit slab (the kit `extrude` rule). | | `T.prism({ F, polygon, s0, s1, round = 0, steps = 4, bevel = 0 })` | A convex polygon in frame `F = { o, a, u, v }` extruded along `F.a` from `s0` to `s1`, any axis (the `prismOf` rule). | | `T.round({ F, s0, s1, r \| [r0, r1], ends = ["flat", "flat"], fixed })` | Cylinder or cone frustum; each end `"flat"`, `"dome"` (radius must equal the side's: a capsule) or `"open"`. One dome and one flat end is a half-capsule, and any length works, even one shorter than the radius (a nose cone on a motor can). A vertical round in an upright group becomes a `fixed` picture automatically (`fixed: false` stops it). | | `T.lathe({ F, profile, slope, radius })` | A general convex smooth body (r(s) concave). Costly; prefer rounds. | | `T.ball({ c, r, flats: [{ n, d }] })` | A sphere; each flat keeps n·(p − c) ≤ d and draws its rim as a cap, so `n` is the flat's outward normal. Without flats it is a `fixed` picture, and it takes no `details`. It is the costliest kind to draw (section 15). | | `T.tube({ route, r, chunk = 6, turn = 20, gaps, breaks, caps, rings, hue, material, bundle, owner, touch })` | The kit `tubePieces` semantics, chunked; `hue` is `"red" \| "green" \| "blue"`; records `R.route`. The route runs from mount surface to mount surface. The port, gland or clamp at each end is a separate part with `owner` set to the tube's name, and its span along the route goes in `gaps`. A route that starts at a port's outer face fails `terminals` in the kit audit (below). | | `T.fixed({ svg, anchor, shapes, hull, F })` | Any kit markup whose picture never changes under the group's motion, moved by `translate`. | | `T.plane({ part, o, u, v, svg, fade = [0.04, 0.3], normal })` | Line or text markup in a plane of the part (ticks, numerals, label plates), placed by one matrix, faded by facing. | | `T.billboard({ part, at, normal, svg, fade })` | Screen-aligned markup at a projected point (an SVG fallback for a shader glow), faded by facing. `svg` is in viewBox px centred on the projected point, not in world units: multiply world sizes by `G.scaleOf(P)` (`<circle r="${(1.1 * G.scaleOf(P)).toFixed(2)}"/>` fills a lens of radius 1.1). | | `T.split(part, { n, d })` | Two parts cut by the plane (prism: any plane through it along or across the axis; round: across). The cut edges are not stroked. Use it to break a genuine cycle. | ### Details (in the part's frame: `s` along the axis, `x` and `y` in `u` and `v`) Each constructor has its own frame and takes its own detail kinds. Anything else is ignored without a warning. | Constructor | Frame `[s, x, y]` | Takes | |---|---|---| | `T.round`, `T.lathe` | `F`: `s` along `F.a`, `x` along `F.u`, `y` along `F.v` | `seams`, `ribs`, `bolts`, `dots`, `rings`, `rules` | | `T.prism({ F, polygon })` | `F` | `dots`, `seams`, `bevel` | | `T.prism({ plan, z, h })` | The world: `s` is z, `x` and `y` are world x and y | `dots`, `seams` (z values), `bevel` | | `T.ball` with flats | Origin `c`, `a` = the first flat's `n`, `u` and `v` from `G.frameAlong(c, n)`. The flat is at `s = d` and the far pole at `s = −r` | `bolts`, `dots`, `rings`, `rules` | | `T.ball` without flats | None: a fixed picture | Nothing | | `T.fixed({ F })` | `F` (no `F`: no details) | `bolts`, `dots`, `rings`, `rules` | A centre dot on a domed spinner `T.ball({ c, r: 4.5, flats: [{ n: [-1, 0, 0], d: 0 }] })` (the flat faces −x, the dome +x) sits at `at: [-4.5, 0, 0], normal: [1, 0, 0]`: `s = −r` is the pole. `[4.5, 0, 0]` would land 4.5 behind the flat, inside whatever the spinner is mounted on. For a ring or a rule on a prism face, use `T.plane`. ```js details: { seams: [s…], // rings on rounds, ring seams on prisms ribs: { s0, s1, count, phase, twist, fade: [0.1, 0.55], seams: true, tone }, // knurl rows on rounds bolts: [{ s, r, count, phase, size: 0.5, tone: "mid", fade: [0.04, 0.42] }], // bolt circles dots: [{ at: [s, x, y], normal, size, tone }], rings: [{ at: [s, x, y], normal, r, tone }], // become planes rules: [{ points: [[s, x, y]…], tone, free }], // become planes bevel: 0.4…1.6, // prism cap bevel (faded slots) } ``` Every detail fades with facing: dots and bolts by `smoothstep(0.04, 0.42, n·V)`, ribs per rib, bevel lines per edge, planes on the group's opacity. No texture stops at full strength. ### Runtime (`kit/turn.mjs`, inlined with `turnScript()`) | Call | What it does | |---|---| | `TURN.mount(stage, data, { onHold, onFrame, follow })` | Binds every live layer in `stage`, renders the rest pose, installs `window.__isoTurn`. `follow(values)` maps figure values to group values on every `set` (one input drives coupled groups). `onFrame(controller)` runs after each render (draw shaders here). `onHold()` runs when a tool calls the hook. | | `controller.set(values \| θ, { detail })` | Sets group values (a number sets the first turn group), renders synchronously, returns stats. `detail` (0…1) scales faded lines, ribs and dots (section 15). | | `controller.angleAt(clientX, clientY, z, group)` | The pointer as an unwrapped angle about the group's pivot on the plane at height `z` (drag a dial). | | `controller.pose(name)` | The group's world pose `{ R, t }`. | | `controller.detach(name)` | The `{ world: true, R, t }` value that continues a free group's current pose in the world frame (no jump). | | `controller.anchor(group, restPoint, restDir?)` | Where a rest-world point (and direction) of a group is now: `{ world, screen, direction }`. Spark sources, flame roots, LED positions. | | `controller.cover(names, options)`, `order(layer)`, `orderIndex(layer)`, `events()`, `stats()`, `frames()`, `cams()`, `scene()`, `values`, `destroy()` | Cover masks (section 12), paint order, order-change events, `{ ms, writes, moved, forced, refined, unseparated, emitted }`. | | `TURN.cover(controller, names \| [names, names…], { polygons = 6, edges = 10, pad = 0.3, layers })` | `{ uCoverEdge, uCoverCount, dropped, parts }` for `coverOf`. | | `TURN.poseUniforms(controller, group, prefix = "uGroup")` | `{ prefixX, prefixY, prefixZ, prefixT }` such that `toGroup(p)` gives the group's **rest-world** coordinates. | | `TURN.materials`, `TURN.materialCss()` | The material names; the material CSS alone (it is already in `TURN_CSS`). | | `TURN.exact(on)` | Write path coordinates unrounded (6 decimals) instead of to 0.01 px. The orbit audit turns it on for the continuity line; a page never needs it. | | `TURN.rotation(axis, degrees)`, `TURN.quat(q)`, `TURN.compose(A, B)` | 3×3 rotations as arrays, for free poses. | | `TURN.floorOf(scene, group, R)` | The lowest z of a group's parts under rotation `R` (relative to its origin): to set a piece down flush. | | `window.__isoTurn` | `{ controller, set, get, order, events, stats, layers }`; every call holds the figure (`onHold`). `set` takes a number or a values object. | ## 5. Part kinds and the rules that keep them true These are binding; the build and the audit fail without them. - **Convex parts only.** A stepped dial is tiers; a notched wheel is a hub disc plus rim sectors; a U-bracket is three prisms; a hand's shell is plates. `T.prism` checks its polygon, `T.lathe` its profile. - **Contacts are flush, tangent or rim-on-flat.** A rim on a sphere is not separable: give the ball a flat (`T.ball({ flats })`) and land the rim on it. A pad sunk into its host is not allowed in a live layer (`separation` fails with `interlock A × B −0.6`). - **Joints.** The two links stay at least 0.2 apart, and the joint is bridged by a knuckle disc on the child group: coaxial with the hinge axis, and touching the parent's link, flush or tangent. A disc coaxial with its hinge (or any flat face turning in its own plane) is invariant under the hinge, so it never collides, and it is the contact the kit audit needs. With nothing in the child group touching the parent, `rigid` reports every part of the child group as `support loose`. Links meeting at a joint share one radius; dome centres sit the same distance from the joint so the gap holds at any angle (two domes of radius r at distance c from the joint stay apart while cos(β/2) ≥ r/c). A trunnion on a yoke: ```js T.round({ name: "yaw.arm+", group: yaw, layer, F: VERT(0, 11.7), s0: 40, s1: 61, r: 1 }); T.round({ name: "head.can", group: tilt, layer, F: frameOf([0, 0, 56], [1, 0, 0], [0, 0, 1]), s0: -12, s1: 8, r: 9 }); T.round({ name: "head.trunnion+", group: tilt, layer, F: frameOf([0, 9, 56], [0, 1, 0], [0, 0, 1]), s0: 0, s1: 1.7, r: 4.5 }); ``` `tilt` hinges about y through `[0, 0, 56]`. The can reaches y = 9 and the arm starts at y = 10.7, so the links are 1.7 apart. The trunnion runs from 9 to 10.7: it sits on the can and its end face is tangent to the arm. Ending it at 10.5, with a 0.2 gap to the arm, fails `rigid` for the whole head. - **No live line spans two moving groups.** A cable or hose crossing a flexing joint is two routes ending in a clip or port on each side of the joint, or crosses at the hinge axis through a rotary union (banjo). A cable that must reach a turning part from static ground goes up the axis through a slip ring at the hub (section 11). - **Tubes bend nearly flat or inside fittings.** A bend of radius R in a plane tilted θ from horizontal, toward the viewer, is an ellipse on screen whose tightest radius is R·sin²(e − θ) (in world units; × `G.scaleOf(P)` for px). `folds` fails a bend that turns more than 90° on screen round less than 2r, and `self` one under r. So a bend needs **R ≥ 2r / sin²(e − θmax)**, where θmax is the plane's own tilt plus the pitch range of every hinge it rides on (a turn group sweeps the tilt through every azimuth, so the worst case always comes round). At e = 30°: | θmax | 0° | 5° | 8° | 10° | 12° | 15° | 20° | |---|---|---|---|---|---|---|---| | R ≥ | 8r | 11r | 14r | 17r | 21r | 30r | 66r | 8r is the bound itself on a horizontal plane, not a margin: take 10r. Past about 15° no practical radius holds, so turn inside a fitting (clip block, banjo, elbow ball, a junction box) or run the line straight. Draw bends with `G.fillet(points, R)`, never the view-dependent fillet. The audit's `folds` and `self` run at every angle and pose. - **Tubes never point along the view.** A tube's tangent elevation stays out of 15°–45° and −45°–−15° at e = 30° (`endon`), in every pose: on a hinge chain, check the link's pitch range too (a cable on a link that flexes 30° will fail). - **Lathes are the one kind with small residual steps** (≤ 2.5 tone·px² where a band changes topology). Prefer rounds, domes and balls with flats for live parts. ## 6. Materials A part's material is one option on its constructor (`material: "gold"`). Tones still come from facing exactly as the kit's do; a material remaps the ramp (the four shades, the top, paper, outline and line tones, dots and the tube body, shine and shade), with a light and a dark set each. | Material | Look | |---|---| | `gold` | Champagne-gold anodised aluminium: warm pale top, bronze shadows, dark bronze outline. | | `chrome` | Bright polished steel: a light far band, a dark reflection band (n·L 0.2–0.42) next to a bright band and a white highlight. Non-monotonic on purpose: the bands move with facing, never jump. | | `steel` | Satin steel, monotonic and cool. | | `gunmetal` | Dark blue-grey metal with light dots. | | `rubber` | Black rubber and cable sheath with a soft sheen. With `hue`, a tube becomes a coloured sheath (the hue mixed into the rubber ramp). | | `brass`, `copper` | Warm metals for fittings, ports, valves. | Static parts use the same families as plain classes: wrap kit markup in `<g data-mat="brass">…</g>` (the example's index wedge). The rules live in `TURN_CSS`; a page with static art only can include `TURN.materialCss()`. A dark plate inside a light page picks the dark set (`.iso[data-theme="dark"] [data-mat]` is ordered last). On a light page a material figure is far stronger than kit greys: keep the ground (bench, base, plates) in kit tones and put materials on the subject, so it reads as the lit, finished object. ## 7. Free groups: parts that break off `T.group("pod", { parent: dial, free: { origin } })` makes a rigid body whose pose the figure sets every frame. At rest (`null`) it sits exactly where it was built, attached: the parent carries it. To break it off, start setting a pose relative to the parent: there is no single-frame change because the first value is the identity. - **Falling onto the turntable**: keep the parent as the turntable group, so the piece lands and then turns with it. Compute the flight as a pure function of time (`breakAt(t)` in the example: a somersault about the centroid, a ballistic arc, a flush landing found with `TURN.floorOf`, a decaying slide and yaw). A pure function can be sampled by the build and the audit, and played backwards. - **Falling off onto the bench**: switch to world space with `controller.detach(name)`, which returns `{ world: true, R, t }` continuing the current pose, then integrate from there. - **Order**: a free group's parts are paired at runtime with every part of their layer (by screen box, then a GJK plane cached per relative pose); intra-group pairs are planar. Cycles among free parts are possible in principle; the audit samples the clips and reports them. - **Proof**: give the build the clips in `poses()` (every frame of the break at several turn angles), and the audit the same clips (`clips`). The audit poses every shape in every clip frame and checks clearances against live and static parts (`posed`), order, cycles and continuity. ## 8. Hinge chains: hands and fingers Each link is a hinge group whose parent is the previous link (`T.group("f0.j1", { parent: f0j0, hinge: { point: J1, axis: w, range } })`). A wrist is a hinge carrying the palm; a thumb base is a hinge (vertical axis) carrying the thumb chain. Drive the chain from one figure value through `follow` (`lift` → three angles). - Pairs on different links are solved at build where possible: a pair whose swept volumes over the build's `poses()` have a plane in their common ancestor's frame with margin ≥ 0.1 becomes a **framed** plane (no runtime GJK). Only near-joint pairs stay dynamic (GJK, warm-started, cached while the two groups keep their relative pose). So `poses()` must cover the motion: if a gesture leaves the sampled range, the audit's `unseparated` line names the pair ("framed plane: … add it to poses()"). - Keep ranges physical and small: a three-link finger at ±5° per joint already reads as a hand moving; 20° per joint needs every route and contact re-proved. - **A route on a hinged link carries the link's pitch range.** Section 5's `endon` rule and its bend bound both use the tilt the line can reach, not the tilt at rest: a horizontal 90° bend at R = 8.6r (inside the 8r rule for a flat plane) on a ±12° hinge fails `folds` and `self`, because at 12° of pitch it needs 21r. Make the line straight into a junction box, or move the bend to the parent. ## 9. Painter's order and why it is correct Every pair of parts that can overlap on screen gets one relation: a plane fixed in their shared group (planar), a plane fixed in their common ancestor's frame (framed), a GJK plane each frame (dynamic, different groups that move relative to each other) or a skip (neighbouring chunks of one route). B is in front of A when n·V > 0 in that frame. Order is a stable Kahn sort keyed by the previous frame's order; on a stall, edges between parts whose exact screen hulls are apart (or touch within 0.1 px) are dropped; a remaining stall is a forced pick, counted and made fatal by the audit. Correct because two disjoint convex solids whose projections overlap are both hit by some view ray, and a separating plane crosses that ray once. Flicker-free because a plane's relation flips only when the plane is edge-on, and then the two footprints lie on opposite sides of a line: swapping them changes no pixel. Edge-on relations (|n·V| < 1e-7) add no edge. ## 10. Shading and fades - Flat faces (prism sides, caps, ball flats) use the kit's thresholds (`EB` for upright prisms, `LB` otherwise) through a smoothed staircase: a face holds the kit tone except within ±0.035 of a threshold, where it crossfades in 64 steps per band (`data-q`, 0…256). That fine step keeps chrome's high-contrast bands smooth. - **The continuity line measures the unrounded geometry.** The page writes path coordinates rounded to 0.01 px, which moves an edge by at most 0.005 px, far below a device pixel. Measured on those rounded paths, a long edge's rounding steps never shrink, so the halving test once read large flat faces (the example's palm, a desk fan's 21-tall prism arms) as jumps that were not there. `orbit` therefore switches the runtime to exact coordinates (`TURN.exact(true)`) for the continuity line and back afterwards; large flat faces are fine on turning groups. - Curved surfaces always use analytic iso-light bands (`labelledArcs`), never facets. Capsule domes are nested hulls; balls with flats are clipped caps. Bands are born at zero width. - Faded-line slots hold ribs and bevels: alphas quantised to 1/32, as many slots as the build's orbit sweep shows a set needs. - `lit: true` uses the lit tokens; with a material, the material's lit set. ## 11. Static layers and a messy workshop around a turntable Every static item carries `layer` (`R.put({ …, layer: "back" })`) and the build proves a constant relation with every live layer over all reachable states, by the first rule that holds: a **horizontal plane** (the item is wholly under or over the layer's z range), the **cylinder rule** (the item lies wholly behind or in front of the turn group's swept cylinder along the view's floor direction), **never overlap** on screen, or a **swept-envelope plane**. Otherwise: `static S interleaves live layer Λ: split S at z = …, or move it`. Authoring rules for a dense workshop (`examples/turning-dial/stress.mjs` proves 450 relations: bench, turntable base, power unit, tool rack, cable reel, tray, wrench, three bench cables): - **Keep clutter outside the swept cylinder or under the turntable plane.** Tall things (power units, racks, lamps) go behind or in front of the cylinder of radius R (the live layer's reach from the axis) along the view's floor direction (cos a, sin a). Things beside it, neither behind nor in front, must be low. - **Anything under the turntable's lowest live z is behind the live layer, even when it sits in front on the bench.** It belongs in a layer under the live one (`back`), not `front`: the build says so. - **A cable that must reach the turning part** runs on the bench to the stationary base, climbs inside it and reaches the turning part through a slip ring or rotary joint at the hub. Draw the static run to a port on the base, and start the live cables from a terminal block on the turning platter. No route ever spans static ground and a turning group. - Static cables lie flat on the bench (bends in the bench plane, R ≥ 8r) so they never fold, and end on a port, a reel or a free end marked `free`. A cable resting on the bench (centre at bench top + r) touches it, which the kit's `clearance` check fails (`close stand.feed × stand.bench clearance 0.00 < 0.40`) unless the route says so: `R.route(name, route, r, { owner: name, limp: true, touch: ["stand.bench"] })`. - `stress.mjs` is a performance and layer-proof scene, not an audit-clean one: its bench cables have no `touch` and its hand has loose links, so the kit audit fails it. Copy authoring patterns from `build.mjs`. ## 12. Shaders that follow the turn and the groups `kit/gl.mjs` gains (both opt-in; old fragments see nothing new): - `GLSL_TURN`: `uGroupX/Y/Z/T`, `toGroup(p)`, `toWorld(g)`, `dirToGroup(d)`, `dirToWorld(d)`, and the cover masks `uCoverEdge[60]`, `uCoverCount[6]`, `coverOf(vb, soft)`. - `glslPose(Name, prefix = "uName")`: the same four functions for any group (`toDial`, `fromDial`, `dirToDial`, `dirFromDial`), so one shader can follow several groups. - `glLayer` sends arrays longer than 4 to `vec2`/`vec3`/`vec4` uniform arrays by their declared type (float arrays unchanged). How a shader follows the motion: 1. **Content on a moving part** (an LED on a fingertip, a flame from a ruptured line's end, oil jetting from a pipe end): take the anchor every frame with `controller.anchor(group, restPoint, restDir)` and pass `world`, `screen` and `direction` as uniforms. Draw it on the **front canvas** (over the live layer) and occlude it with the parts painted after it: `const c = TURN.cover(controller, [partName]); layer.draw({ ...c, … })` and `ink *= 1. - coverOf(vb, .7 * px)`. Every live part is convex, so its hull is its exact silhouette; the cover is pushed out 0.3 vb so ink stops at the occluder's stroke, never in it. Several targets share the six polygons: `TURN.cover(controller, [["hand.f2.p3"], ["hand.pod.socket"]])`. 2. **Content fixed to a turning surface** (oil pooling on the turntable, a texture revealed by light on a dial): pass `TURN.poseUniforms(controller, "dial", "uDial")` and evaluate in rest-world coordinates: `vec3 q = toDial(onFloor(vb, FACE_Z));` The pool turns with the dial. 3. **Content in world space** (smoke rising, sparks after they leave the source, oil pooling on the bench outside the turntable): never touch a group transform. Emit particles from an anchor's world position at birth and integrate them in world space (the example's sparks): the turn doesn't drag them along. 4. **Back canvas** (light falling on a surface under the parts that cover it): put the canvas between two live layers. The example splits its dial into its own live layer `dial` under `hand`, so the LED's pool lands on the dial face and the hand covers it with no masks at all. Every rule of `webgl.md` still holds: envelopes, windows to 0 before any bound, light-theme inks (saturated bodies, pale cores, tinted ground, ink only grows with light), dither, canvas edges at 0, an SVG fallback (`T.billboard` inside the owning part, hidden under `[data-gl]`, handed over with `(1 − u)/(1 − u·α)`). The cover chunk uses 70 uniform vectors: read `MAX_FRAGMENT_UNIFORM_VECTORS` and keep the SVG fallback below 128. `GLSL_TURN` defines `toWorld`: don't prepend it to a fragment that declares its own. ## 13. The orbit audit `node build.mjs --audit` runs the kit audit, then `TA.orbitOrExit(T, R, P, options)`, which prints one line per check and ends `turn audit passed` or `turn audit failed: n problems` (exit 1). Only a full run ends `turn audit passed`: a run with `only` ends `turn audit partial: no problems in what ran (n lines skipped by only)`, and a quick one `turn audit quick: no problems in what ran (quick run, undersampled: the full run is the proof)`. Options: `sweep: { at(x), from, to, frame }` (the turn), `clips: [{ name, at(t), from, to, frame }]` (gestures, the break-apart: every frame at 60 fps), `poses` (extra states), `crowd: [[route, part]…]` (pairs exempt from `crowding`, after you have looked), `cover`, `fittings: [[route, from, to]]` (stretches inside a fitting, exempt from `endon`), `quick` (≈ 10× fewer states, for iteration), `only: ["order", "rigid", "swept", "posed", "routes", "coverage", "continuity", "depth"]` (an unknown name throws), `log`. The example takes `--quick`, `--only=a,b` and `--verbose`. `only` runs these lines: `order` → `unseparated`, `cycles`, `forced`, `swaps`; `routes` → `folds`, `self`, `crowding`, `endon`; `depth` → `depth`, `layers`; the others their own line. `separation` always runs. A line that did not run prints `skipped`. **Time and how to iterate.** The orbit audit is slow, and its time grows with parts × states. Measured on a 28-part desk fan (one turn, one hinge, one clip): `--quick` 85 s, full 17 minutes. The example scene takes several minutes; budget about 30–40 s per part for a full run. That is past a 10-minute shell timeout, so: 1. Iterate with `--quick --only=…` on the lines you are fixing. 2. `--quick` alone before the final run. It undersamples: it checked 100 halving candidates against 800 in the full run and missed 20 `continuity` failures the full run found. 3. The final full run in the background (`node build.mjs --audit > audit.txt 2>&1 &`), read when it ends. Only that run's `turn audit passed` counts. | Line | Fails on | Fix | |---|---|---| | `separation` | A same-group pair with no plane (margin < −0.08) | Split, a flat, or a flush contact | | `unseparated` | A cross-group pair interpenetrating while their hulls overlap; a framed plane that a state breaks | Fix the contact or the motion; add the motion to `poses()` | | `cycles` | A cycle among overlapping parts in some state; prints the parts, the state range and the split plane (the cycle edge with the largest margin) | `T.split` that part on that plane | | `forced` | The runtime had to force a pick | As cycles | | `swaps` | Two overlapping parts swap order while overlapping more than 0.25 px² (0.5 px² for a pair built flush, margin under 0.05: at the instant their contact plane is edge-on, the true overlap is a line, and the drawn curves leave a sliver about 0.1 px wide) | Look first: `drive.mjs page.html '[["orbit", "swap.png", θ−0.3, θ+0.2, 0.1, null, 6, [x, y, w, h]]]'` (θ from the line; the region round the pair in CSS px of the plate, at scale 6). Something that appears or vanishes in one step is a drawing bug, not an order bug. Engines before 2026-10-06 drew a round with one `"dome"` end shorter than its radius with a wedge behind its flat end, which vanished when that end went edge-on: copy the current `turn.mjs`. A clean swap with sane order on both sides (`__isoTurn.order`) is a wrong plane: report it with the part pair and θ | | `layers` | A static item on the wrong side of a live part (ray-cast) | Move it or split it (section 11) | | `rigid` | Kit clearances, terminals, supports, solid clearances at rest | As in the kit audit. `support loose` on every part of a hinged group: nothing in it touches the parent (the knuckle disc, section 5). `terminal … from X`: the route starts at a port's face, not the mount (the `T.tube` row, section 4). `clearance … × stand.bench`: a bench cable without `touch` (section 11) | | `swept` | A static shape inside a turn group's swept envelope | Move it out of the cylinder | | `posed` | Shapes of different groups (and moving live × static) passing through each other in any sampled state | The motion or the geometry | | `folds`, `self`, `endon` | Live routes at every angle and posed state | Section 5 (bend radius with the pitch range, section 8) | | `crowding` | A live route's end or bend lands on the outline of a slender solid (a rod, post, stem) at some angle | A line parallel to a turning rod crosses the rod's outline at some angle whatever its offset, so you can't design it out. Look at the angles it names (`["turn", θ]` and a 4× shot), and if it reads as two separate parts, list the pair in `crowd: [["cable.a", "yaw.stem"]]`, or route the line inside the rod. A line that is not parallel: approach across the rod | | `coverage` | A part's recorded shape covers < 60% of its drawing | Record the true shape | | `depth` | The ray-cast oracle disagrees with the engine order | Report it | | `continuity` | Per 0.05°: tone step > 1/16 band, a birth > 2 px², a dot α step > 0.01, a faded α step > 1/32 + 0.005, outline motion over 1.5·ω·Δθ + 0.05 px; and the halving test | A detail that stops: fade it. A part whose outline or face boundary jumps in one step: find the angle with an orbit sheet at 0.1°; on a plain kit primitive it is an engine bug, report it with the part and θ. `--quick` undersamples: only the full run counts | The halving test re-emits each candidate step at ½, ¼ and ⅛ of itself with exact coordinates, rasterises the frames at 4× (jittered samples on 8 sub-scanlines) and measures the pixels that change owner, weighted by tone. A smooth change halves with the step; a jump doesn't. A step is a discontinuity when it stays above 60% at each halving and is still ≥ 2 tone·px² at ⅛, measured at 4× and again at 16×: a step that fails at 4× fails only if it also fails at 16×. Measured on the page's rounded paths instead, every long edge keeps a floor of about its length × 0.01 px that never shrinks; that floor is what flagged large flat faces before the switch to exact coordinates. Every line has a planted fault that fails it (`3d/engine/plants.mjs` in the build session: a sunk pad, a pinwheel of three tilted sticks, a vertical-plane fibre bend, a tube at 30°, a stopped rib row). ## 14. Browser checks ```sh node examples/turning-dial/build.mjs --light --verify # writes turning-dial-verify-light.html node scripts/turn-check.mjs examples/turning-dial/build.mjs --fidelity node scripts/turn-check.mjs page-verify.html --order --step 1 node scripts/turn-check.mjs page-verify.html --order --states clip.json node scripts/turn-check.mjs page.html --pops --no-webgl --step 1 --out shots/pops node scripts/turn-check.mjs page.html --lines # and the dark page node scripts/turn-check.mjs page.html --perf --width 1440 node scripts/turn-check.mjs page.html --perf --width 390 --throttle 4 node scripts/turn-bench.mjs examples/turning-dial/build.mjs node scripts/drive.mjs page.html '[["turn", 37], ["shot", "a.png"], ["orbit", "orbit.png", 0, 345, 15]]' ``` - `--fidelity` (Node): prisms against `k.extrude`/`G.prismOf` (outline, top and faces each ≤ 0.06 px), rounds and lathes against `G.lathe(…, smooth)` (outline ≤ 0.05 px against the kit drawn at 10×, bands ≤ 0.5 px; the line also prints the outline against the kit's own paths, which round to 0.1 px, so 0.08–0.13 px there is expected and not judged), tubes against `tubePieces` (0.05 px), at P′(θ). Control: one prism drawn 2° off must fail. - `--order`: the recorded shapes in a WebGL z-buffer against the DOM painter's fills, 2×; a live pair with 4+ interior pixels fails. Control: each live layer reversed must give ≥ 1000 px. Static × live mismatches are listed (static recorded shapes are often stand-ins, such as a slab for a plate with a window). `--part name` prints a part's footprint in both images. `--step` sweeps the **first turn group only**, with every other group at rest: a hinge, a slide or a clip needs `--states`. - `--pops`: first difference (θ → θ + 0.02°, two erosions) and second difference (0.1°, |2b − a − c| > 56, or > 6 on flat pixels, one erosion; a core of 6 px fails). Control: the largest part hidden for one frame. `--out dir` saves the first core's frames. With `--states`, consecutive entries are treated as neighbouring frames and only the first difference runs, so the list must be **one continuous path in steps of about 0.02°**: 529 unrelated yaw × tilt poses gave 404 false failures; a tilt sweep of 1201 states at 0.02° gave 0. - `--states file.json`: a list of value objects instead of angles. Write them from the build module, which exports `follow` and the clip functions: ```js import { writeFileSync } from "node:fs"; import { follow, nodAt } from "./build.mjs"; const at = (t) => follow({ yaw: 20 + 30 * t, tilt: nodAt(t) }); writeFileSync("clip.json", JSON.stringify(Array.from({ length: 241 }, (_, i) => at(i / 60)))); writeFileSync("tilt-path.json", JSON.stringify(Array.from({ length: 1201 }, (_, i) => follow({ yaw: 30, tilt: -12 + i * 0.02 })))); ``` `clip.json` (every 60 fps frame of the clip) is for `--order`; `tilt-path.json` (one hinge swept at 0.02°) is for `--pops`. Run `--pops` on one path per hinge, at a turn angle where the hinge's parts overlap the most. ## 15. Performance Measured by `turn-bench` (Node CPU) and `turn-check --perf` (browser, 240 frames at 1.1°). **What a part costs.** `turn-bench` prints the Node time per part by kind. In prism units (one live prism, about 0.03–0.04 ms per frame on the build machine): | Kind | Cost | Kind | Cost | |---|---|---|---| | `fixed` (vertical rounds, plain balls) | 0.5 | `round` | 2.5–4 | | `prism` | 1 | `ball` with flats | 8–10 | | tube chunk | 2–3 | `lathe` | about 25 | A figure's weight is the sum. A **small figure** weighs up to about 50 (a few dozen parts, mostly prisms and fixed pictures); a **dense** one up to about 230. The desk fan (28 parts: 6 prisms, 7 fixed, 5 tube chunks, 8 rounds, 2 balls with flats) weighs about 65, and measured 1.6–1.9 ms of Node CPU per frame and a 6.0 ms browser task at 1440, so it is over the small-figure line, mostly because of its two balls. The turning dial (88 parts, 42 rounds, one lathe) weighs about 225 and measures 6.4–7.2 ms. A ball with flats is the right part for a domed end with a rim landing on it (section 5), but it costs as much as nine prisms; a plain ball or a vertical round is a fixed picture and nearly free. Times vary by machine and load: compare kinds within one run, and measure your own figure. | Measure | Budget for a small figure (weight ≤ 50) | Budget for a dense figure (weight ≤ 230) | |---|---|---| | Node CPU per frame | ≤ 2 ms | ≤ 7 ms | | Browser main-thread task at 1440, DPR 2 | ≤ 6 ms | ≤ 13 ms (60 fps) | | Main thread at 390, DPR 3, 4× CPU | ≤ 16 ms | ≤ 33 ms (30 fps) | | `data.turn` JSON | | ≤ 250 KB | Levers, in order: 1. `fixed` for every vertical-axis turned part and every plain sphere (automatic). 2. Planes for every set of lines or text on a face; upright prism tops are one matrix. 3. Small parts sample by device pixels: rings, caps and domes of a part under 30 px round take 12–16 samples, never the large-part minimums. 4. **Framed planes and the relative-pose cache** (automatic): cross-group pairs cost nothing while their groups keep their relative pose (a pure turntable turn), and pairs whose motion the build sampled are framed planes. 5. **Adaptive half rate** (the figure's loop): keep an exponential moving average of `controller.set(...)` time; above 14 ms render the live layers on alternate frames (time-based motion unchanged), below 9 ms every frame again (`stress.mjs`'s loop). 6. **Detail by speed** (opt-in): `controller.set(values, { detail })` with `detail = settle(1 − smoothstep(120, 240, |ω|°/s), 0.25 s)`: faded lines, ribs and dots scale and are skipped below 0.01. Outlines and tones are never reduced. Authoring rules that keep a figure inside budget: about 150–250 live parts per moving group; small details as `details` (dots, rings, bolts) rather than parts; static clutter as static art; one live layer per independently moving region; tubes chunked at 6 with as few chunks as the bends allow. The measured numbers for the example and the stress scene are in the skill's build notes; a 380-part flexing hand on this machine is a 30 fps figure at 1440 with the half-rate lever, not a 60 fps one. ## 16. Known limits - Vertical turn axes take the azimuth shortcut; other rotations compose matrices. - Only convex parts. Concave bodies are convex pieces; ball flats replace rim-on-sphere joints. - Live tubes obey the fold and end-on rules: there are no swept-tube silhouettes, so turns happen flat or in fittings, and no route spans two moving groups. - No automatic splitting: a cycle is an audit failure with a suggested plane. - Flat faces crossfade over ±0.035 of score (about 8° of turn). - Lathes keep small residual steps where a band changes topology. - Free groups pair at runtime with their whole layer; dozens of debris pieces cost GJK while they move. - Phones: the budget aims at 30 fps, not 60, and only a figure inside it gets there. The 380-part stress scene (`examples/turning-dial/stress.mjs`) measured about 20 fps at 1440 and 13–15 fps at 390 with 4× CPU throttle, with the half-rate lever, on a heavily loaded machine. There is no canvas painter. -
craft.md 49.1 KB
# Craft How a figure comes to read as a crafted object rather than a diagram. Every rule here came from a figure that was rejected or approved by a demanding reviewer. ## Contents 1. What "crafted" means 2. Anti-patterns 3. Parts vocabulary, with recipes 4. Tones 5. Painter's order 6. Camera and composition 7. Themes 8. Round parts and pipes 9. Exploded views 10. Screens and things on faces 11. Shaders ## 1. What "crafted" means The approved figures share five qualities. 1. **A real object you could build.** The reader names it at a glance: a test rig, a dial indicator, a chest of drawers, a gantry plotter, a lock. It is invented for the subject, never traced from a reference. 2. **Many small, precise parts.** A good inline figure has 30–60 solids and 300–500 paths. A hero subject that really has more parts can carry far more: the desk computer has about 85 solids, and the Raptor about 270 solids in 478 depth-sorted items. Every part rests on another part, aligns to its neighbours and has a job. Density comes from structure (feet, rails, stops, bushings, knobs), not from texture. 3. **Volume.** Parts have real thickness, sides shaded in four tones, lighter tops, bevels that catch light, and a soft halo underneath. 4. **Calm.** Thin 0.6 px lines and a restrained palette. One lit accent tells the eye where to look. Motion is eased and slow enough to follow. 5. **Truth.** The readout shows live, correct numbers, and the motion follows the real model: springs with real stiffness, true sizes and thresholds. ## 2. Anti-patterns Reviewers rejected each of these, so don't do them: - **Abstract stacks.** Floating plates, slabs or tiles standing for "layers" are too abstract to read as anything. If you need layers, make them drawers, trays in a rack, plates on guide pillars, or cards in a holder. - **One big plain slab.** A large surface with a few lines is too flat and bland. Break it into a base, a carriage and parts; add a groove, screws, a ruler and a label plate. - **Blueprint furniture.** Leader lines, balloons with numbers, dimension arrows, hatching, dashed hidden edges and floating labels belong in the legend and caption, not the drawing. - **Lines that stop in mid-air.** Guide fans, connectors that end short of a part, or a crease that stops before the silhouette corner. Every line must land on something. - **Wireframes.** See-through boxes where the back edges show. - **Colour everywhere.** Keep it monochrome unless colour is the subject: one lit accent plus true RGB only for colour channels. A fluid is not colour by default: oil in a sight glass, water in a tank and coolant in a pipe are tints of the figure's own inks, and the `--no-webgl` fallback draws the same tint the shader does. A blind-test bench painted its oil saturated orange in an otherwise grey figure, and its fallback oil was grey. - **Text in the drawing.** Allow at most one tiny engraved label (`FaceText` on a face). Everything else goes in the card corners, legend or caption. - **Copying a reference.** Take mood and finish from a reference, then invent the object. - **Raw pointer jumps.** Parts that snap to the cursor. Ease everything. - **Pipes through pipes.** On the first Raptor the owner zoomed in and found cables and lines passing through the main ducts and through each other, "which makes no sense". At 1× it looked fine. Every pipe and cable keeps clear of everything but its own connection, every crossing is drawn in true depth order, and `kit/audit.mjs` proves both (section 8, "Clearance and crossings"). - **Textures that stop.** On the same close-up, rows of bolt dots on the chamber stopped dead in the middle of the surface, and the bell and throat were shaded as a staircase of flat tone blocks. Both read as broken. The owner's words: if a texture has to end, "we'd rather fade them out at the end than an instant stop". Shade curved surfaces smoothly, and make a texture end on a real edge or fade out (section 8, "Shading and textures on curved surfaces"). - **Shaders that pop or are cut.** The first Raptor plume changed colour instantly between its phases, "as if cut out", and a glow ended in a straight line at the canvas edge. Every shader term rides an envelope and reaches zero before any bound (`webgl.md`, section 7). - **Textures that stop, on flat faces too.** The same fault came back on a later figure's boxes, where no curved-surface rule reached it: a tank's weld seam inset 6 units from each rounded corner, a manifold face line inset 4, a row of faint ticks floating on a bare bedplate with no ruler under them, cooling ribs that stopped 2 units short of the seam arcs at both ends, and a hose braid drawn as a dashed stroke down the centreline that ended in two straight hard edges along the hose and broke at every chunk joint. A seam on a box runs edge to edge round the visible corners (`k.sideSeam`), ribs end on seams at the same `s` (`ribsOf(…, { seams: true })`), ticks hang from a ruler strip's edge, and a texture on a pipe comes from `tubePieces(…, { rings })`. `node scripts/lines.mjs page.html` fails every line end that touches nothing (`verify.md`, "Line ends"). - **Stand-in shapes.** To quiet the audit a builder recorded a 0.45-thick disc at the tip of each of five overlapping rod copies, and a sliver for a second rod that was drawn sunk into its cylinder head. The audit passed and said nothing about either rod, and the lit rod's outline was notched every 12 units while it moved. Record the true shape of what you draw; the audit's stand-in check fails an item whose recorded shapes cover less than 60% of its drawing. A rod that slides out of a gland is one rod (section 3, "Rod out of a gland"). - **Dark-theme glows pasted on white.** The owner made light the shipping theme for every site figure, and seven shader figures built on a dark plate had to be converted. Taking a dark glow and darkening it gave "red ink smoke" (the Raptor's light plume). It also gave flames drawn as outlines, sparks that looked like dirt, salmon-coloured cooling glass, grey fades and cavities that got heavier as the fire went out. On paper, light reads through saturation and a darker physical ground (section 7, and `webgl.md`, section 8). - **Fixing one close-up by eye.** Tuning a painter's key or nudging a waypoint until one screenshot looks right breaks another frame or another theme. The Raptor's hand-set keys looked right at 1× and had 323 depth-order problems. Measure (section 8, "Clearance and crossings"), then look. ## 3. Parts vocabulary, with recipes `P` is the projection and `k` is the kit, as in `import * as k from "./iso-kit"`. Heights are in world units, with z up. Draw each recipe's solids in the order listed. ### Base plate on feet ```js const PLATE = { x: -128, y: -64, w: 262, d: 128, r: 8 }; halo: `<path class="iso-halo" d="${k.haloOf(PLATE, -17, 12, P)}"/>` feet: for (const [x, y] of byDepth(k.corners(PLATE, 22))) { k.cylinder(x, y, 7, -17, 1.6, P, 28) // flange, tone lo k.cylinder(x, y, 5.4, -15.4, 3.4, P, 28) // foot, tone mid } slab: k.slabOf(PLATE, -12, 12, P, 8, 1.6) // tone mid, bevel 1.6 groove: k.planOutline(k.insetPlan(PLATE, 5), 0, P) // line lo screws: k.corners(PLATE, 10) → k.ring(x, y, 2.2, 0, P, 16) (line lo) + dots size .5 ``` Sort feet with `byDepth = (pts) => [...pts].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P))`. ### Ruler strip and label plate ```js const strip = { x: -110, y: PLATE.y + PLATE.d - 15, w: 230, d: 7, r: 1 }; k.slabOf(strip, 0, 0.6, P, 3) // tone lo, crease none const t = k.topTicks(strip.x + 4, strip.x + strip.w - 4, 4, 5, strip.y + 0.8, 0.6, [2, 3.6], P); t.minor → line lo, t.major → line mid label plate: k.slabOf(plan, 0, 0.8, P, 4) + k.planOutline(k.insetPlan(plan, 1.6), 0.8, P) (faint) + three k.lineOnTop rule lines of different lengths (lo, { free: true }) + two screw dots ``` Ticks only live on a ruler strip or a dial face, rooted on its edge: `topTicks` grows each tick from `edge`, so put `edge` on (or within 1 unit of) the strip's edge, and `sideTicks` hangs them from `top`, so pass the face's top edge, not 2 units below it. A row of marks on a bare plate is a texture that floats at both ends. The label plate's rule lines stand for printed text, so they are the one place a line may stop on a bare face: pass `free: true` so the line check knows it was meant. ### Seams, welds and trim lines on a box ```js const seam = k.sideSeam(TANK, 14, P, 6); // visible near half of the plan at z = 14, round the corners k.solidSvg(k.slabOf(TANK, 6, 98, P, 6, 1.2), { tone: "mid", inner: k.lineSvg(seam, { tone: "faint" }) }) ``` A seam, weld bead, band or lid line on a box wraps the visible corners and ends exactly on the vertical silhouette edges, like `sideArc` on a cylinder. Pass the slab's own `steps` so the seam follows the same facets, and pass it as `inner` so its ends sit under the outline. A line that belongs to one face only (a stiffener, a panel joint) runs the full face, edge to edge: `segment([x, y, z0], [x, y, z1])` with `z0` and `z1` the face's bottom and top, never `z0 + 3`. Slots and vents are closed outlines (a `planOutline` of a thin rounded plan, or the same in a face plane), so they have no ends at all. Never inset a line by a few units from its corners: at 4× it reads as a texture that stops. ### Rod out of a gland A piston rod, a plunger or a slide whose visible length changes as it moves is one rod, built once at full length (visible plus stroke), with the part inside the gland clipped away: ```js const ROD = G.rodOut(F, gland, tip, r, P, { stroke }); // F.a points out of the gland svg = `<clipPath id="rod"><path data-hold d="${ROD.clip}"/></clipPath><g clip-path="url(#rod)">${k.solidSvg(ROD.paths, { lit: true })}</g>` R.put({ ..., move: G.mul3(F.a, stroke), svg, shapes: [R.solid(A.slide(F, gland, tip, r, { name: "rod" }))] }) live: el.setAttribute("transform", `translate(${dx} ${dy})`); holdStill(el, [dx, dy]) // dx, dy: the item's screen offset this frame ``` `rodOut` returns the rod's paths from `gland − stroke` to `tip` and a clip that covers it from the gland face to `tip + stroke`. The clip path carries `data-hold`; whatever moves the item calls `holdStill(el, [dx, dy])` with the same screen offset, which moves the clip the other way, so the gland face stays put while the rod slides through it. The same helper retracts a rod into a gland (a load cylinder) when its item moves the other way. Draw a faint seal ring (`circleOf` at the gland face, radius `r + 0.4`) on the gland. Record it with `A.slide`, which the audit poses by stretching the rod from the gland face instead of translating it, so clearances, depth and solid checks see the true rod in every frame. Never fake the extension with overlapping copies: each copy's lit edge stops where the next one's fill starts, and the outline is notched every few units while it moves. ### Rails, end stops and a carriage ```js rails: for y of [-20, 15]: k.slabOf({ x: 2, y, w: 122, d: 5, r: 1 }, 0, 3, P, 3, 0.4) // mid stops: k.slabOf({ x, y: -23, w: 5, d: 46, r: 1.2 }, 0, 7, P, 3, 0.5) + 2 screw dots on top carriage (built at x = 0, moved with translateAlong): k.slabOf({ x: -28, y: -24, w: 56, d: 48, r: 3 }, 3, 5, P, 5, 0.6) bushings: small slabs at the four corners, taller than the plate, with screw dots rivets: a row of 0.4 dots along the front edge handle: a small neck slab plus a knurled cylinder on the front face ``` ### Post, collar, clamp and thumb screw ```js k.cylinder(px, py, 5, z0, 4, P, 28) // collar k.cylinder(px, py, 3.2, z0 + 4, h, P, 28) // post, crease none k.sideArc(px, py, 3.2, z, P, 16) // faint seam rings on the post k.slabOf({ x: px - 7, y: py - 7, w: 14, d: 14, r: 2.5 }, zc, 12, P, 4, 0.6) // clamp block thumb screw: a 2.8 × 3 neck slab from the clamp face + k.cylinder(r 2.6, h 6) + k.knurl(..., 18) ``` ### Knurled knob with a pointer ```js k.cylinder(cx, cy, 6, z, 4, P, 32) // mid k.knurl(cx, cy, 6, z, z + 4, 36, P) // line lo k.lineOnTop([cx - 3, cy], [cx + 4.6, cy], z + 4, P) // pointer, line hi ``` ### Coil spring around a rod (split for painter's order) ```js const c = k.coil(xFrom, xTo, [rodY, rodZ], 4.2, 6, "x", P); c.back → line lo, drawn BEFORE the rod rod → k.slabOf({ x, y: rodY - 2, w, d: 4, r: 1.8 }, rodZ - 2, 4, P, 3) c.front → line mid, drawn AFTER the rod ``` Rebuild the coil each frame when it compresses, by changing `xFrom`/`xTo`. It is cheap. ### Dial gauge (face up) ```js k.cylinder(x, y, R, z, 10, P, 64, 0.8) // puck, tone hi, crease lo k.knurl(x, y, R, top - 3.4, top, 84, P, 0.3) // knurled bezel band, lo k.sideArc(x, y, R, top - 3.4, P, 40) // band seam, faint k.ring(x, y, R - 2.2, top, P, 64) // bezel ring, lo const t = k.radialTicks(x, y, R - 3, 50, 5, top, [1.3, 2.8], P) // minor lo, major mid crown: two small slabs on the +x side; lug: a slab joining the arm needle: k.lineOnTop(tail, tip, top + 0.2, P), tone lit, stroke-width 1.1, rebuilt per frame hub: k.ring(x, y, 1.6, top, P, 16) ``` ### Drawer with a pull and label holder ```js case: k.slabOf(casePlan, z, h, P, 4, 0.8) front: a thin slab on the near face; pull: two posts plus a bar (small slabs) holder: an inset outline on the front with a card slab inside; rivets as 0.45 dots open: translate the drawer along its axis with translateAlong; its box (floor plus 3 walls) is drawn before the front, and its contents are engraved on the floor ``` ### Turntable with degree ticks ```js k.cylinder(cx, cy, R, z, 4, P, 72, 0.8) k.radialTicks(cx, cy, R - 1, 72, 6, z + 4, [1.2, 2.6], P) rotate: rebuild only what turns (or the object on top) at the new angle each frame ``` ### Perforations, vents and a pulsing dot grid ```js k.dotGrid(x, y, cols, rows, gap, zTop, P) → dotsSvg(points, { size: .45, tone: "lo" }) // add { pulse: true } for a slow pulsing grid on an "active" part ``` ### Lamp, probe and finger These are stacks of cylinders: housing, cap ring, collar, stem, pad. Seam rings come from `sideArc`, and the lock screw is a small slab plus a cylinder head on the side. ## 4. Tones | Element | Tone | |---|---| | Base, feet, floors | `lo` | | Structure: posts, rails, frame, carriage, case | `mid` | | The subject: the thing being explained | `hi` | | The part the reader should watch now | `lit` (one only) | | Grooves, inset outlines, floor grid | `faint` | | Minor ticks | `lo` | | Major ticks | `mid` | | Zero mark, pointer line | `hi` | | Screws and rivets | dots `mid` at 0.4–0.55 | | Crease (top/side join) | `faint` (default), `lo` on hi parts | A `Solid` with `lit` gets a white edge and a lighter top. Use it for the moving subject, the plunger or a picked drawer. ## 5. Painter's order - Draw the base first, then everything resting on it. - Within a level, sort by `depthOf(centre, P)`, ascending: far before near. - **Split wrapping parts.** For a spring or a frame around a carriage, draw the back half, then the inner part, then the front half. In the test rig: back frame bars → rods with the back coil halves → carriage → glass → front coil halves → front bars → dial. - **Moving parts** are drawn at their slot in the order, inside a `<g transform>` that moves them. If a moving part can pass in front of and behind the same neighbour, split the neighbour or bound the motion so the order never changes. - **Things standing on a stepped surface.** Order the steps so the taller ones are further back. A probe resting on a lower step then can't be hidden by a taller one in front of it. - Vertical parts that pass through a horizontal one (a post through a clamp) are drawn in two segments, below and above, around the horizontal part. - **A clip cut is an edge.** Where a clip ends one part against another (a cable-chain run entering its loop, a rod at a gland), make the clip's section flush with the joint plane, and draw a stroked edge along the cut that moves with the clip (`data-clip`). On the plasma cutter, a run's clip section reached past the joint. The run's top face and slot bars were drawn 26 units into the loop's hollow and ended on a vertical cut with no stroke, which is a line stopping in mid-air. Re-ordering the painter's keys wouldn't have fixed it: the loop link's end face really is behind the run. - **Dense figures settle their keys from geometry.** Past a few dozen overlapping parts, hand-set biases fight each other. Give every item its true shape and let `kit/audit.mjs` measure which surface is nearer wherever two items overlap, then `orderKeys` raises each key just enough to satisfy every pair (`kit.md`, section 12). What it can't satisfy is a pair that interlocks, which needs a split. The first Raptor's hand-set keys left 190 pairs drawn the wrong way round assembled and 133 apart; measured keys took both to zero. - **Crossings sort per chunk.** A pipe or cable is drawn as `tubePieces` chunks, each its own item at its own depth, so one line can pass in front of one part and behind the next. Where two lines cross on screen, the chunk nearer the camera is drawn later; a crossing can only be ordered if neither chunk also runs behind the other somewhere else, so break long chunks at a crossing (`breaks`). ## 6. Camera and composition - Default camera: azimuth 45°, elevation 30°. Rotate the azimuth (35–60°) so the face that carries the mechanism points at the viewer. The test rig uses 57° to show the long side. Raise the elevation to look into something open: the ripple tank uses 44° so the water surface and the paper under the tank both read. - Fit with `fitProjection(boxCorners(...) ∪ tallest points, width, height, { pad: 24–34, azimuth })`. Typical viewBoxes are 600 × 340–380. - The object fills about 55–65% of the card. Leave air around it, don't let it touch the corner labels, and keep sizes consistent across a set of figures. - On phones (390 px), check it still reads. If parts get too small, scale the SVG up to about 110–118% inside a card with `overflow: hidden` and a negative margin, as `stretch.css` does in the test-rig example. ## 7. Themes `ISO_CSS` defines tokens on `.iso`, with dark as the default and `.iso[data-theme="light"]` for light. Every fill and stroke reads a token, so a theme is just a token set. To tune one: - **Paper** (`--anatomy-paper`) equals the card colour, so fills hide what is behind without showing as patches. - **Shades.** Four steps from darkest (0, facing away from the light) to lightest (3). Keep them close: about 3% apart in luminance. - **Lines.** In dark mode, `hi` is near-white and `faint` barely above the paper. Light mode inverts this. - **Halo.** A blurred dark fill under the base: strong in dark mode, about 30% in light. **Light ships.** Every figure on the isometrics site is light, and that includes its shaders. Design light first and keep dark working where that is cheap. What the seven shader conversions taught about the drawing: - **Every colour is a token.** A colour typed into a figure's CSS or its shader is a theme bug waiting to happen. Give it a light value and a dark override (`.x { --x: … }`, then `.iso[data-theme="dark"] .x { --x: … }`). Where the shader needs the same colour, read it from the token (`webgl.md`, section 8.3). - **Cavities get their own token set.** The inside of a furnace mouth, a ladle, a vessel seen through a cutaway, a water column or a duct window is darker for a reason: soot, refractory, depth, shadow. Give its group a class that redefines `--anatomy-paper`, `--anatomy-face`, `--anatomy-top`, the four shades and the line tones to darker, warmer or bluer greys. Keep all the tones so it still has form, mix it toward the light's hue by a variable the live code writes, and set the tokens to `inherit` in dark. Glows then have a ground, without a black box. - **Mind nested themes.** A dark plate can sit on a light page. A rule written `.iso:not([data-theme="dark"]) .x` also matches through the page around it. Order the dark rule after the light one, or key on the plate's own theme. - **Opacity fades a pale colour to grey.** Anything that fades by opacity in light (a fallback glow, a lit accent) gets a saturated token, and its tints are multiply-blended so the lines under them stay visible. ## 8. Round parts and pipes Engines, pumps, valves, lamps and instruments are mostly turned parts and pipes. `kit/lathe.mjs` and `kit/tube.mjs` build them along any axis (`references/kit.md`, sections 10 and 11). `examples/raptor-engine/build.mjs` has every recipe below in working form. ### Turned parts: think in meridians Write a part as its half-section: a list of `[s, r]` from one end to the other, and turn it with `G.solidOf(profile, F, P)`. - **Steps and shoulders** are where the radius jumps at one `s`: `[6, 12], [6, 9]`. They make the crease lines that read as machining. - **Curves** are many points marked smooth (`[s, r, 1]`), so no crease is drawn across them: a bell, a dome, a fillet into a flange. The kit shades any profile with a smooth vertex smoothly on its own (`smooth: "auto"`), so the shading follows the curve instead of stepping facet by facet; pass `smooth: false` only to opt out. - **A waisted body is never hulled.** A profile that narrows in the middle (a throat, a spool) has a concave outline. `lathe` notices when its true silhouette lies more than half a pixel inside the hull of its rings and then fills and outlines it with the silhouette and the visible end arcs instead (`fill: "auto"`, or force `"surface"`). Drawn as a hull, the Raptor throat was a straight cone with a pale unshaded strip and a seam that stopped 8 px short of the outline. - **Seams and weld lines** on a curved surface are `G.arcOf(F, s, r, P, { slope })`, with the profile's slope at that ring, drawn `faint` and passed as the solid's `inner` (`solidSvg(paths, { inner: lineSvg(arcOf(…), { tone: "faint" }) })`; the Raptor's `S` and `Ln` are one-line wrappers over these), so they sit under the outline. They end exactly at the limb, a little short of it. Concatenated after the solid, each seam's round cap lands on the edge stroke and the dark-theme outline of the Raptor's actuator barrels looked dashed at 4×. - **A long body** (a nozzle, a barrel) is cut into bands with `G.bandsOf(profile, cuts, F, P, { radius, slope })`, so each band depth-sorts against the pipes that wrap it. The bands share one `steps`, one normal field and one surface, their own outlines come cleared, and `sidesOf` draws one continuous outline for the whole body (`kit.md`, section 10), so the tone curves run through every joint without a step and the sides don't kink. Without an analytic `radius` and `slope` it fits one curve through the whole profile, never one per band. - **A ring around a band** (a stiffening ring on a chamber, a collar) splits the band: band, ring, band. Drawn as one long band with the ring on top, the ring's back half paints over the band (or the band hides the ring's front); split, each piece orders itself. The Raptor chamber jacket is three bands between its two rings. - **An open end** is a `ringBand` lip, with a filled void (`circleOf`) for the dark inside and the inside arc from `arcOf(..., { inward: true, least: -1 })`. Bolt dots go around the lip. ### Parts on a round body - **Flange:** its back face sits on the route end, so the route ends exactly on the mount's surface and the disc runs from there back along the pipe (`centre = end − tangent · t/2`). `disc(-t/2, t/2, r, frameAlong(centre, axis), P)`, with a bolt circle `dotsOf(F, faceS, 0.74r, 8, P, { all: true })` on the face that points at the viewer. Skip the bolts when the face is edge-on (`|dot(a, V)| < 0.12`). - **Boss or port** on a curved wall: a short `disc` along the outward normal, with a `faint` face ring (`circleOf`) when it faces you. This is where a sense line, igniter or plug lands. Make it at least as wide as the flange that lands on it; a flange that overhangs its boss floats at the rim. - **Saddle pad.** A flat flange on a curved body leaves a gap at its rim (the sagitta, `R − √(R² − a²)`): an 11.6 flange on an 18.4 pump body stood 4 units off at the sides. Wherever that gap would be more than about 0.15, seat the flange on a pad: a short cylinder whose base follows the host surface (`G.saddleOf(F, a, start, h, P)` with `start(angle)` found from the host's shape, the Raptor's `padOn`/`boss(..., { host })`). Record it as a disc `cut` by its host and marked `seated`, so the audit sees its true shape. Keep the pad inside one band of the host (an overhang under a step is drawn in front of the band that hides it; a Raptor pad lowered until its rim touched the step below became an interlock in every frame) and off the host's silhouette: a pad that straddles the silhouette is half in front of and half behind its host, and no painter's order draws it. The Raptor's LOX duct moved from 130° to 150° for that reason. **A saddle belongs to its host's assembly.** Its base is cut to the host's curve, so if it flies out with the pipe the cut base is on show apart: the Raptor's hot-gas pad, cut to the methane pump's turbine and ring, became a jagged shard with a pointed tail. Give the pad to the host's piece (`boss("ftp", …, { host: FTP_TURBINE })`) and let the moving pipe's flange leave its flat top; the audit's `seats` lists every seated shape whose host moves with another assembly. - **Igniter plug, one terminal.** A plug carries either the igniter line (leaving its outer face along its axis) or the exciter cable, never both. The cable lands on its own small boss beside it. - **Valve on a pipe:** a bulged `lathe` profile on `frameAlong(point, tangent)` turned with `smooth: true`, with bolt dots on both end faces, a round neck and an actuator box with a lid outline and two screws. It sits on a straight leg (body length plus both fillet cuts), the pipe is gapped under its body, and the actuator stands perpendicular to the pipe: up on a level pipe, sideways (a round can) on a vertical one, never along the pipe. - **Volute:** `spiral(F, s, r0, r1, a0, a1)` fed to `tubePieces` with a radius that grows along it. Keep its centreline at housing radius plus tube radius plus 0.3, so it wraps the housing tangentially; a volute sunk into the housing is a pipe through a solid. - **Ball joints and tees** are spheres (`sphereOf`): a rod-end bearing on an actuator, a cast tee where a branch meets a manifold. - **Knurls, ribs and fins on a tilted axis:** `ribsOf`. Vanes on a face: `spokesOf` with a `sweep`. ### Pipes - Lay out a route as a few straight legs between real ports, then `fillet` it with a bend of about 2.6 × the pipe radius. Pipes bend; they don't kink. - End every pipe on a port with a flange, or on a block with a terminal dot. A pipe that stops in the air is a line that stops in the air. - Add the parts a real line has: bands where it is clamped or welded (`bands: [0.5]`), a bellows where it must flex (a run of thin `disc`s, `bellows: [from, to, count]`), and a valve where flow is controlled. These are the Raptor `pipe()` helper's options (`kit.md`, section 11). - Use few sizes, all big enough to tell apart: main ducts (r 5–8), feed lines (r 2–4), sense lines (r 0.9–1.5), cables (r 0.5–0.65). ### Choosing waypoints Route the big lines first (ducts, downcomers, manifolds), then feed lines, then sense lines, then cables, so each smaller line routes round what is already there. For each route: - **Start on the port's normal.** The first leg leaves the port straight out along its surface normal, long enough for the flange, then the bend: `fillet` takes at most 0.48 of the shorter leg, so a leg shorter than about twice the bend radius gives a tight bend, and a turn right at a port facing the viewer pinches ("Clearance and crossings", step 5). - **Follow structure.** Legs run parallel to the body they follow (along a chamber, up a downcomer) or round it on a ring at a fixed radius (`arcPoints`), offset from its skin by the body's own clearance: skin radius plus about 1.5 × the line's radius. The owner's close-up showed sense lines fanning across the chamber's face and over the main duct to reach their block; on the new build the long line from the nozzle manifold runs up the left side, the fuel-valve cable runs up beside the downcomer, and the harness comes down from the controller in parallel drops into clamped lanes. - **Stay on your side of the subject.** A line that serves a port on the far side goes round the back or round the side at a ring, never diagonally across the front of the thing being explained. - **Prefer legs across the screen.** A leg that runs straight toward or away from the viewer projects short and makes every turn at its ends a cusp or a V ("Clearance and crossings", steps 4 and 5). - **Leave room for fittings.** A valve, bellows or clamp needs a straight leg at least its body plus both bend allowances (`r_bend · tan(turn / 2)` each). On a bend the straight body cuts into the pipe; the Raptor's downcomer was rerouted so its valve sits on a straight run, and the oxygen duct gained a vertical straight for its valve. - **Don't land on a rod.** A pipe's last leg must not run along a rod, tie rod, post or rail on screen, and its end fitting must not sit on one's outline: the blind-test bench's hose ran its last leg parallel to a tie rod, 2 px above it, so the rod read as plugged into the hose fitting. Approach the port across the rods, or turn the elbow so the leg lies across the screen. The audit's `crowding` treats every slender solid (length over 4× its radius or half-width) as a line, so it fails these. Check every pose too: a ferrule that sits on a moving plate's edge at the end of the stroke reads as touching it. - **Cross deliberately.** If two lines must cross on screen, they cross once, on straights, at a clear angle, and far enough apart in depth that the clearance check passes with room to spare. A crossing near a bend or an end reads as a joint ("Clearance and crossings", step 6). - **Split, don't overlap.** Where something wraps a pipe (flange, band, bellows disc, clamp, valve body), the pipe is gapped there and a piece ends on each face (`tubePieces` `gaps`, `crossingsOf` for clamps). Run through it as one chunk, the pipe is both behind and in front of the collar and no key can order it. The Raptor chamber jacket was split at its two rings for the same reason. ### Contacts and junctions Every place two parts meet is either a flat face on a flat face, a tangent contact, or a rim landing on a round body. Nothing is sunk into anything: a sunk part is both in front of and behind its host, and no painter's order can draw it. - **Flush.** A rod ends on the face of its clevis; a boss starts on the surface it stands on; a lug is a short pin from the housing to the ball. - **Rim on a round body.** A cylinder of radius `a` meeting a sphere or tube of radius `R` ends where its rim lies on the surface, `sqrt(R² − a²)` from the centre. Its end face is then hidden inside the host and nothing shows through. This is how the Raptor's actuator rods meet their ball joints and how the manifold tube and the downcomer meet their tee. - **Tees.** A branch into a pipe is a sphere fitting a little larger than both: the run is gapped where its rim meets the sphere and the branch ends on the sphere. Size the sphere so neither pipe's rim pokes into the other: for a branch of radius `b` into a run of radius `a`, `R ≥ sqrt(a² + b²)`. - **Stand-offs.** A ring that would have to sink into a curved body to touch it (a manifold round a bell) stands clear by at least half its tube radius on small radial pads. - **Valves and bellows on straights.** A straight lathe on a curved pipe cuts into it at both ends. Put them where the route is straight for their whole length. - **Two flanges, one joint.** Where a duct bolts to a pump outlet flange, the duct's own flange meets the outlet flange face to face; two flanges in the same place interlock. Each part then keeps a flange when the figure comes apart. - **Branches have fittings.** A branch leaves its run from a tee (a ball the run is gapped under, the branch starting where its rim meets the ball) or from a saddle pad on the run, never from a bare spot on the run's skin. - **Struts end in ball joints.** A strut's eye disc laid along an oblique axis sinks into whatever it meets. Give each end a short stem along the host's normal, a ball on the stem, and the rod between the balls (rim on sphere at all four joints). - **Balls with flats for joints that move.** In a 3D figure a rim landing on a sphere can't be ordered at every angle. Give the ball a flat (`T.ball({ flats: [{ n, d }] })`, recorded as `A.ball(c, r, { flats })`) and land the rim on the flat, or use coaxial knuckle discs. `references/3d.md`, section 5. - **Everything is held.** Every solid is connected through contacts to the stand: brackets carry boxes, studs carry harness clamps, a gearbox plate joins a motor to its actuator. A clamp that touches only the cable it holds floats. ### Clearance and crossings A pipe keeps about 1.5 radii between its centreline and anything but its own connection, so a 0.6 cable passes 0.3 clear of a duct's skin and a duct keeps half its radius off its neighbours. Where two lines must cross on screen, they cross in depth, one well in front of the other, and the chunk keys order them; where a pipe passes through a collar, clamp or valve it is gapped there (`kit.md`, section 11). Prove it rather than eyeballing it: 1. Record a shape for every item and every tube as you build, into one `A.recorder(P, { order, spring })` (`kit.md`, section 12): `R.put` for drawn items with their `shapes`, `R.solid` for each shape, `R.route` for each pipe and cable. Make the helpers do it, so nothing escapes. There is a recorded shape for everything the kit draws: `A.slab`, `A.extruded` and `A.cylinder` for floor prisms, `A.solid`, `A.lathe` and `A.disc` for turned parts (traced along the same curve the drawing uses), `A.prism` for `prismOf`, `A.ball`, and tubes. 2. Settle the painter's keys with `A.settle(R, P)` on every build: it measures the overlaps in every state the figure rests in (assembled and apart) and in every frame of its real explode (`explodeStates` replays the live springs and stagger) and runs `orderKeys`. 3. End the build with `A.auditOrExit(R, P)` and run `node build.mjs --audit` until every failing line prints zero. An interlocked pair is a geometry bug (a split, a gap or a tangent contact fixes it); don't bias it away. 4. Lines don't double back on screen. A horizontal leg that runs away from the viewer reads as going up, so a pipe that runs away and then drops reads as a cusp, a pipe ending in mid-air. Route the leg sideways on screen first (the audit's `folds` finds every case: a screen turn over 140° within 4 radii while the real turn is under 110°). 5. No outline folds back on itself, and no bend pinches to a point on screen. A leg that points at the viewer and then turns (a sense line leaving a jacket boss radially and climbing to a block, a pigtail leaving a transducer tip and hooking back) projects its fillet edge-on, and the two legs meet in a V with no visible radius. `folds` measures the screen radius of every visible bend over 90° and fails it when it is under twice the tube's screen radius. Fixes, in order of preference: let the turn happen inside a fitting (a ball tee on the boss, the line leaving it toward its next point: the Raptor's `elbow`, used by sense0, sense2, sense5 and the fuel-rich igniter line); bend in a plane that faces the viewer (the Raptor's pigtails loop in the transducer's own radial plane, which faces the camera; its local helper `sideOn(d) = unit3(cross3(unit3(V − d·(V·d)), d))`, with `V = G.viewOf(P)`, gives the direction across the screen from a leg along `d`, so a bend in the plane of `d` and `sideOn(d)` faces the viewer); move the port to where its normal lies across the screen. A bend in a plane whose normal makes `f = |n·V|` with the view projects to a screen radius of about `R·f²`; once that is under the tube's screen half-width, the inner edge of a single chunk runs backwards and the outline crosses itself in a little knot (the audit's `selfOverlaps`). `fillet(…, { P, tube })` raises each corner's radius to clear that, but only as far as the legs allow, so give a turn next to a port at least `R + 3r` of straight leg. A port that faces the viewer squarely (the Raptor's speed pickup on the methane pump) makes every approach a hairpin: move it. 6. Crossings happen on straights, clearly. Where one line's bend or end lands on another's outline on screen (touching, or overlapping by any amount), the eye reads a joint or a line threaded through another, whatever the true depth: a pigtail hook that peeks out from behind a sense-line elbow, a flange that lands on another line's band in the apart pose, a cable that runs along a short duct on screen and over its end flange. A fitting counts too: a harness clamp whose outline lands on another line reads as that line growing out of the clamp, as the Raptor's clamp did on the oxygen-to-preburner line apart. The audit's `crowding` lists every visible case, assembled and apart; reroute one line so the meeting is a clear crossing of two straights or a visible gap of at least a pixel (two for a clamp). Excuse a pair only after you have looked at it at 8× (`CROWD_OK`). The first Raptor had 54 clearance problems (39 pass-throughs) and 323 depth-order problems; the routes in this section took both to zero. ### Shading and textures on curved surfaces - **Smooth shading** (automatic on any profile with a smooth knot; `smooth: { slope }` or `{ slope, radius }` for an analytic curve; `bandsOf` for a body in bands; `sphereOf` for balls) on every curved surface: bells, throats, domes, cones, valve bulges, balls. A flat facet tone per row is only right on a cylinder. Each tone is one polygon over the whole body for as long as its region count holds, so no row seams show. - **A texture ends on an edge or fades, along the axis too.** Each rib's `s0` and `s1` land exactly on a drawn step, crease or seam arc at the same `s`: pass `seams: true` and `ribsOf` returns the two seam arcs with the ribs, so they can't drift apart (the blind-test motor ran its ribs from 26 to 88 with seams at 24 and 90, and every rib stopped 2 units short at both ends). Where a rib row has no seam, let it run onto the face that ends the band (`s0`, `s1` equal to the band's ends) or fade it. - **A texture on a pipe follows its cross-section.** Braid, corrugation, tape wrap and armour come from `tubePieces(route, r, P, { rings: { pitch, twist, cross, fade } })`: rings (or with `twist`, helices, and with `cross: true`, a braid of both hands) placed by 3D arc length along the whole route, each drawn as the visible front arc of its cross-section, fading toward both limbs and ending on the ferrule and gap faces. Never a `stroke-dasharray` along a chunk's centreline: it ends in straight hard edges along the hose, doesn't wrap the round section, piles up on the inside of bends, and restarts at every chunk joint. - **Textures end on an edge or fade.** Ribs and bolt circles that are limited to the side facing you stop wherever the cut-off falls, in the middle of the surface: the bolt circles on the Raptor's chamber rings did, in the owner's close-up. Use `ribsOf(..., { fade })` with `fadedSvg`, and `dotsOf(..., { fade })`, so they fade out toward the turn (the Raptor uses `fade: [0.04, 0.42]` for bolt dots and `[0.1, 0.55]` for ribs). Seam arcs and band lines already end on the silhouette. A single texture line that has to stop in the middle of a face (a groove that runs out, a weld bead short of a corner) is drawn with `fadeLineSvg(points, { fade: [start, end] })`, which ramps it out over a few px with a gradient along the line; never let it end on a bare cut. Choose the cut-off from the way the feature faces, not from the ring it sits on: spray holes on top of a deluge ring face up and are seen all the way round, so they take `all: true`. Culled by the ring's outward normal (`least: -0.2`), the Raptor's row stopped at full strength just past the ring's sides while the ring ran on round the back, bare. Never cut a row on an up-facing feature with a radial `least`; if part of it really is hidden, `fade` toward the turn. - **Smooth tones taper.** Where a tone band appears or disappears along a curved profile, it is born at zero width and grows (`smooth` refines rows by screen error until each boundary is within 0.35 px and every change of band count happens in a sliver). A band that ends on a straight cut across the surface, or a pale wedge of bare fill, is the bug the reviewer finds first. - **Shape and shading describe one curve.** Smooth runs of a profile are interpolated with a monotone cubic Hermite curve (Fritsch–Carlson), and the kit uses that same curve for the normals, the tone sheets, the silhouette and the hull. A natural spline overshoots near a peak: on the Raptor's valves it put three sign changes of slope into a body that rises and falls once, and the tone bands zig-zagged across the bulge like wood grain while the outline stayed a polyline with a nub at every profile point. A monotone curve can only turn where the profile turns. - **The monotone Hermite is only C1.** Its `dr/ds` is a piecewise quadratic: with hand-typed knots it plateaus at each knot and overshoots between them, and tone bands, which follow the slope, stall and then lurch, a staircase in scallops. The Raptor throat had extrema of slope at five places on a profile that bends one way; its deck cone wobbled more mildly. Any curved body larger than a fitting needs an analytic profile `r(s)` and its derivative passed as `smooth: { slope }`, sampled at 20–30 knots: the throat is `√(24.2² + (c·(s − 146))²)`, the cone `48·(1 − x)^0.8` closed to `r = 0` at its tip with a vertical tangent, so the outline rounds over the tip instead of peaking above a flat tip disc. The audit's `wobble` fails any profile smoothed without a `slope` whose `dr/ds` turns between its ends. - **Outlines are ellipses, not facet polygons.** Facet counts are for tone fills only (about 3 px per facet on screen, `stepsFor(r, 32, 160, scale)`); every silhouette, rim and crease is sampled from the analytic circle at about 1.5 px, and creases and bevels end exactly where the faces turn away. A 20-sided flange outline next to a 96-sample torus reads as a notch at 4×. - **Tube stripes.** Shine and shade bands run continuously through chunk joints and on to the very end: at every capped end they follow the outward half of the end ellipse, and at an end face turned toward you they stop on its inner rim, which is drawn. At a joint the stripes overhang the body by a further 0.5 (bodies overlap by 0.5, stripes by 1), and every frame is computed from the whole route, so the later chunk's stripe covers its own body's anti-aliased cut and ends on an identical stripe. With body and stripe cut on one line, a hairline crossed the volute's shade band at every joint, light in light theme and dark in dark. Check them at 4× at every bend, tee, bellows and flange, and on a strongly curved tapered tube. Anything you draw on a chunk yourself (a texture, flow marks) follows the same rule: compute it from the whole route, phase it by 3D arc length from the route's start, and extend it over the chunk's stripe overhang (1 unit past each joint), or the joint shows as a half-dash, a doubled mark or a notch. The `rings` option does this for you. ### Harness routing Cables are the easiest way to ruin a figure: a cable drawn across the subject's face reads as a scribble. Route them like a real harness: - **Lanes.** Run cables in parallel lanes on a ring around the body (`arcPoints` at a fixed `s`, each lane offset 1.4 units out and 0.2 along for r 0.62 cables). They leave the lane only to branch to their plug. - **Enter in order.** Cables come down from the connectors in parallel drops and join the ring where their drop meets their lane (at `acos(x / laneR)`), so the innermost lane is fed by the connector nearest the front and no drop crosses another. - **Leave in order.** The outermost lane leaves the ring first; an inner cable that turns outward while outer lanes still run beside it has to cross them. - **Follow structure.** A cable that has to climb runs up beside a pipe (the Raptor's valve cable runs up the downcomer) instead of crossing open space in front of the engine. - **Short branches.** Go from the lane straight to the port. Don't run diagonally across the subject's face. - **Clamps** (`disc(-0.9, 0.9, r, frameAlong(point, tangent))`, tone `mid`) hold the lanes to the body at regular angles. Gap each cable where it passes through a clamp (`crossingsOf`), or the clamp and the cable interlock. - **Ends land.** Each cable starts at a connector on the controller or a junction box and ends on a plug, sensor tip, valve lid or bulkhead connector, never on a clamp or in the air beside the bundle. An end that stays with its own assembly gets a terminal dot; an end on another assembly gets a connector shell (`cable(..., { shells: [false, true] })`) that moves with the cable. - **Count what you draw.** Push every route into a list (`CABLES`) and build the readout from its length. The first Raptor readout claimed 8 cables while 11 were drawn. ## 9. Exploded views An exploded view earns its place when the parts are the explanation (what feeds what, in what order): - Choose the assemblies the real thing comes apart into (the Raptor has 11), not single solids. - Move each assembly along its real disassembly direction, far enough that its silhouette clears its neighbours: down along the axis for stacked stages, out radially for side units. - Every part keeps a home. Parts that bridge two assemblies (actuators, cables) go with the one they stay attached to, and when apart they still end on something of their own: a flange, a coupling nut at a quick-disconnect, a connector shell on a cable. Strut ends are ball studs on their hosts with the rod leaving with its assembly, and saddle pads stay on their hosts. Mark each end that comes off its mount when apart (`free: [true, false]`); the audit's `looseEnds` fails every other end that touches nothing of another owner in the apart state, and lists the free ones so each is a choice. - Highlight and motion are separate. A sensor bolted to the chamber highlights with the plumbing but rides the chamber's spring (`ride`, emitted as `data-ride`). Moved with another assembly's vector on its own spring, it slides along its mount and sinks into it mid-flight. - Choose explode vectors and the stagger so nothing passes through anything, in any frame: `references/motion.md` ("Order the stagger so nothing has to pass through") has the rules, and the audit replays every frame of the real springs, opening, closing and reversing halfway, and fails on any pass-through. - List the assemblies in the legend as numbered buttons (`01 Gimbal and thrust puck`…) that select, the same as hovering does. - Springs, staggering and per-frame depth re-sorting are in `references/motion.md`. ## 10. Screens and things on faces A screen, a dial face or a display is drawn in the plane of the face it sits on (`examples/desk-computer/`): - **The face plane.** `sideMatrix([0, y], 0, P, "left")` maps a group's local `(x, −z)` coordinates onto the vertical face at `y`. Draw everything on the screen (the glow, scanlines, a mark, `<text>` and a cursor) in those flat coordinates inside that `<g transform>`. - **A recess.** Clip the screen to its rounded outline with a `clipPath` in the same plane, and draw the recess's inner sides as quads between the face and the face moved `depth` back. Shade the sides by facing (tops `iso-top`, sides by shade). Draw the outline `lo` on the face and `faint` at the back. - **Motion on the face** is a transform in face coordinates. The CRT power-off scales the picture about the screen centre to a line, then to a dot, with a beam line and a fading dot. - **Text the machine displays** (a typed line, a counter's digits) is part of the object, not a label, so the one-label rule doesn't apply. Keep it in the screen's own type and size, and fit it: the cap height is about 0.55 of the window's height and the widest string you will ever show (`8.88`, the longest unit) must fit its width with a margin of about a tenth of the height. Clip it to the window like any screen. A blind-test flow meter set 5.6 px digits in a 12.8 × 8 window, so the leading digit crossed the window's left border and the digit tops ran into the unit label. - **Content seen through an opening** (a sight glass, a gauge window, a recess) sits behind the face, so draw the opening's inner walls between the face and the back plane, shaded by facing, and clip the content to the opening in the face plane as well as its own. A strip of bare face inside the opening reads as a hole drawn on the surface. For a shader behind an opening, `webgl.md`, section 5. ## 11. Shaders Some subjects can't be drawn with lines: fire, exhaust, water, caustics, real lamp light, steam. These go in a WebGL layer *inside* the drawing, in world space and in the same camera, between a back SVG and a front SVG. `references/webgl.md` has the layer stack, the camera contract, the craft rules (nothing pops, no hard cut-offs, tonemap and dither, blending per theme) and how to verify them. Everything in this page still applies to the SVG around the effect. Its section 8 covers the light theme, which ships: inks solved from the colour the paper should show, ink that grows with the light, a dark physical ground for every glow, recipes for hot metal, glass, sparks, flame, steam, plasma and Cherenkov light, and a no-WebGL fallback that matches the shader. -
kit.md 66.3 KB
# Kit API Everything is in `kit/iso-kit.ts`, with the same API in `kit/iso-kit.mjs`. World coordinates are `[x, y, z]`, with x and y on the floor and z up, in units of about 1 px. Screen coordinates are `[sx, sy]` in the SVG viewBox. Paths come back as SVG `d` strings, rounded to 0.1. ## Contents 1. Types 2. Camera 3. Solids 4. Lines on faces 5. Parts library 6. Moving things 7. SVG strings (framework-free) 8. Card and page 9. React components 10. Round parts on any axis (`kit/lathe.mjs`) 11. Pipes, tubes and cables (`kit/tube.mjs`) 12. The audit (`kit/audit.mjs`) 13. The WebGL layer (`kit/gl.mjs`) 14. Tools 15. Turning groups (`kit/turn.mjs`, `turn-build.mjs`, `turn-audit.mjs`) ## 1. Types ```ts type Flat = [number, number]; // screen point type Point3 = [number, number, number]; // world point type Plane = [number, number]; // floor point (x, y) interface Plan { x: number; y: number; w: number; d: number; r: number } // rounded rectangle on the floor interface Projection { origin: Flat; scale: number; azimuth?: number; elevation?: number } interface SolidPaths { fill; outline; crease; top; shades: string[4]; bevel?; seamless? } type Tone = "hi" | "mid" | "lo" | "faint"; type LineTone = Tone | "lit" | "red" | "green" | "blue"; ``` ## 2. Camera | Function | What it does | |---|---| | `fitProjection(points: Point3[], width, height, { pad = 24, headroom = 0, azimuth = 45, elevation = 30 })` | A projection that centres and fits these points in the viewBox. Pass every extreme point, such as `boxCorners(plan, z0, z1)` and the tallest tip. | | `iso(point, P)` | World to screen. | | `depthOf(point, P)` | Larger means nearer the viewer. Sort ascending to paint back to front. | | `towardViewer(P)` | The floor direction pointing at the viewer, `[cos a, sin a]`. Faces whose normal points this way are visible. | | `screenRise(h, P)` | How many screen px a height of `h` covers. | | `cameraOf(P)` | The raw sines and cosines. | ## 3. Solids All solids are prisms: a floor outline extruded straight up. | Function | What it does | |---|---| | `slabOf(plan, z, h, P, steps = 8, bevel = 0)` | A rounded box. `steps` is the points per corner (2–4 for small parts, 8–10 for large rounded ones). `bevel` (0.3–1.6) adds a highlight line inset on the top along the visible edges, running into the silhouette corners. | | `cylinder(cx, cy, r, z, h, P, steps = 32, bevel = 0)` | A round post, puck, foot, knob or pad. Use 16–20 steps for small ones and 56–72 for big discs. | | `extrude(ring: Plane[], z, h, P, { convex = true, bevel })` | Any floor outline. For concave outlines pass `convex: false` (the fill becomes faces rather than a hull). | | `ringOf(plan, steps)` | A rounded-rectangle outline as points. | | `roundedPlan(plan, steps)` | The same thing. | | `circleRing(cx, cy, r, steps)` | A circle outline as points. | | `insetPlan(plan, by)` | Shrinks a plan (use a negative `by` to grow it). Keeps the radius sensible. | | `corners(plan, inset)` | Four floor points inset from the corners, for feet and screws. | | `boxCorners(plan, z0, z1)` | The eight corners, for `fitProjection`. | | `haloOf(plan, z, h, P)` | A fill path for a soft shadow: `<path class="iso-halo" d=…>`. | What a `SolidPaths` contains: - `fill`: paper-coloured, it hides whatever is behind. - `shades[0..3]`: visible sides, bucketed by how they face the light. - `top`: the top face, lighter. - `crease`: where top meets the visible sides. - `bevel`: optional. - `outline`: the silhouette. `solidSvg` and `<Solid>` paint them in that order. ## 4. Lines on faces | Function | What it draws | |---|---| | `onTop(points: Plane[], z, P, closed?)` | A polyline on a horizontal face. | | `lineOnTop(a, b, z, P)` | One segment on a horizontal face. | | `planOutline(plan, z, P, steps)` | A rounded-rectangle outline on a face, used for grooves and inlays (`insetPlan`). | | `ring(cx, cy, r, z, P, steps)` | A circle on a face, used for screw rings, bezels and seats. | | `segment(a, b, P)` | Any 3D segment. | | `rise(at, z0, z1, P)` | A vertical segment. | | `pointsAt(points, P)` | Projects a list of points, for screw and rivet dots. | | `dotGrid(x, y, cols, rows, gap, z, P)` | Projected perforations. | | `topMatrix(at, z, P, along = "x")` | A transform that lays text flat on a top face, for `faceTextSvg`/`FaceText`. | | `sideMatrix(at, z, P, "left" \| "right")` | The same, on a vertical face. | ## 5. Parts library | Function | Returns | |---|---| | `knurl(cx, cy, r, z0, z1, count, P, inset = 0.4)` | Vertical lines on the visible half of a cylinder. | | `sideArc(cx, cy, r, z, P, steps)` | The visible front half of a horizontal circle on a cylinder side: seams and bands. | | `sideSeam(plan, z, P, steps = 8)` | The same for a box: the visible near half of the rounded plan at height `z`, wrapping its corners and ending exactly on the slab's vertical silhouette edges. Pass the slab's own `steps`, and draw it as the slab's `inner`. It is the way to draw a weld bead, a band, a lid seam or a trim line on a box: never a `segment` inset from the corners. | | `ringSeam(ring, z, P)` | `sideSeam` for any floor outline (the ring you gave `extrude`). | | `sideRing(center: Point3, r, "x" \| "y", P)` | A circle on a vertical face: bores, rod ends, lenses. | | `coil(from, to, [cy, cz], r, turns, "x" \| "y", P)` | `{ back, front }` spring halves, so you can paint back → rod → front. | | `topTicks(from, to, step, every, edge, z, [minor, major], P, axis)` | `{ minor, major }` ruler ticks on a top face, growing from the line `edge` in +y (or +x). | | `sideTicks(from, to, step, every, face, top, [minor, major], P, axis)` | Ticks hanging down a vertical face. | | `radialTicks(cx, cy, r, count, every, z, [minor, major], P, from = 0, span = 2π)` | Dial and turntable ticks. Angle 0 points to −y, increasing clockwise as seen from above. | ## 6. Moving things | Function | What it does | |---|---| | `translateAlong(P, [dx, dy, dz])` | A `"translate(x y)"` string that moves a pre-built group by a world offset. Use it for carriages, drawers, plungers and lifts. | | `holdStill(element, [dx, dy])` | Gives every `[data-hold]` element inside `element` the opposite translate, so a clip stays where it is while the group it sits in moves by `[dx, dy]` screen units. Call it with the same offset whenever you move an item that holds a `G.rodOut` clip. | | `axisVector(P, [1, 0, 0])` | The screen vector of one world unit. Use it to turn pointer movement into world movement. | Rotations about z can't be done with a screen transform: rebuild the rotating paths for each frame or step. That is cheap for a needle, a pointer or a small part. ## 7. SVG strings (framework-free) | Function | Notes | |---|---| | `solidSvg(paths, { tone = "hi", crease = "faint", halo, lit, flat, className, inner })` | When `paths.seamless` is set (smooth lathe shading, `sphereOf`), the group gets `data-seamless` and `ISO_CSS` strokes each tone region in its own colour, 0.4 wide, so the many abutting tone sheets show no hairline seams. `inner` is extra line markup placed just before the outline (`iso-edge`), for seams, weld lines and band lines that must sit under the silhouette stroke; the React `Solid` takes it as a prop. | | `lineSvg(d, { tone = "lo", dotted, dashed, flow, className, free })` | `flow` animates dashes; use it for things moving along a path. `free: true` (or `"start"`, `"end"`) writes `data-free`, telling `scripts/lines.mjs` that an end is meant to stop on a bare face: rule lines that stand for printed text, a needle's tail, a pointer's tip. Every other line end must land on something. | | `dotsSvg(points, { size = 0.5, tone = "mid", pulse })` | A point may carry a third value, its opacity (`[x, y, 0.4]`), which is how `dotsOf(..., { fade })` fades a bolt circle out instead of stopping it. | | `fadedSvg(list, style)` | Draws a set of lines that each carry an opacity, `[{ d, alpha }]` or `[d, alpha]`, as returned by `ribsOf(..., { fade })`. Lines are grouped by opacity in steps of 0.05, each group in one `<g opacity>`, and lines under 0.02 are dropped. Use it for any texture whose members fade one by one toward a limb: ribs, fins, knurls, rule lines. | | `fadeLineSvg(points, { fade = [start, end], tone = "lo", className, stops = 6 })` | One line (screen points) that fades out over its first `start` and last `end` px instead of stopping (each ramp carries `data-free` on its faded end, so the line check passes it). Each ramp is stroked with a `userSpaceOnUse` linear gradient along its own chord (a smoothstep in `stops` steps, the colour taken from `var(--anatomy-<tone>)`, so both themes work), and the middle is a plain `lineSvg`, so there are no overlapping caps, beads or seams. Use it for a texture line that ends in the middle of a face: a groove that runs out, a weld line that stops short of a corner, a rule on a curved plate. Keep a ramp shorter than a quarter turn of the line, since a linear gradient follows the chord. Gradient ids are a hash of the path, so the same line drawn twice shares one. | | `wireSvg(from, to, { via = [], tone = "lo", dotted, dashed, flow, ends: "both" \| "from" \| "to" \| "none", size = 1.2 })` | A connector with terminal dots. | | `faceTextSvg(transform, text, { size = 3, tone = "mid", anchor })` | | | `groupSvg(children, { class, transform, "data-part": … })` | | | `figureSvg({ width, height, label, body, standalone: false \| "dark" \| "light", className })` | The `<svg>`. `label` is the accessible description: say what the object is and what it shows. `className` is added to its class, which helps when you stack it under a canvas (`rt-back`, `rp-back`). | ## 8. Card and page | Function | Notes | |---|---| | `plateHtml({ fig, title, hint, readout, keys: [{ mark, label }], caption, body })` | The card. `mark` is one of `raised`, `flat`, `lit`, `edge`, `dim`, `dotted`, `dashed`, `red`, `green`, `blue`. The readout element carries `data-readout`, so live code writes to it. | | `pageHtml({ title, theme = "dark", body, script, width = 650 })` | A standalone document with `ISO_CSS` inlined. | | `kitScript()` | From `scripts/inline-kit.mjs`. Prepend it to `script` so the page can call kit functions such as `lineOnTop` and `translateAlong` while animating. | | `glScript()` | From `scripts/inline-kit.mjs`. The source of `kit/gl.mjs` (`GLSL_ISO`, `glCamera`, `glLayer`, `smooth`, `burst`, `settle`), to inline after `kitScript()` on a page with a shader: `script: kitScript() + glScript() + live`. It doesn't depend on the kit, so a page whose live code calls no kit function can inline it alone (the Raptor uses `glScript() + LIVE`). Everything is one module script, so live code must not redeclare `smooth`, `burst` or `settle`: a second `const smooth` is a SyntaxError that kills the page. | | `turnScript()` | From `scripts/inline-kit.mjs`. The 3D runtime (`TURN`, `TURN_CSS`) with `export` stripped, for a page with live parts: `script: turnScript() + glScript() + live`. Its internals are prefixed `t3`/`T3_`, so it inlines beside the kit and the WebGL layer. | | `ISO_CSS` | All the styles. `kit/iso.css` is the same text. | ## 9. React components From `kit/react/draw.tsx`, which is server-safe: - `<Solid paths tone crease halo lit flat className inner />` (`inner` is line markup placed under the outline, as for `solidSvg`) - `<Line d tone dotted dashed flow free />` - `<Wire from to via tone dotted dashed flow ends size />` - `<Dots points size tone pulse />` (a point may carry opacity as `[x, y, alpha]`, as for `dotsSvg`) - `<FaceText transform size tone anchor>text</FaceText>` - `<IsoFigure width height label>` …svg children… `</IsoFigure>` - `<Plate fig title hint readout keys caption>` …figure… `</Plate>` From `kit/react/live.tsx`, which is `"use client"`: - `useReadout()` returns `{ anchor, say }`. Put `anchor` on your root element; `say(text)` writes the card's readout. - `useLoop(tick: (dt, calm) => keepGoing, running)` runs a frame loop while `running` and while `tick` returns true. It returns `kick()` to restart it. - `follow(current, target, dt, seconds, calm)` is exponential easing. - `spring(state, target, dt, stiffness, damping, calm)` is a sub-stepped spring. It returns true when settled. - `useEased(target, seconds)` returns an eased number as React state. Fine for small figures. - `useInView(ref)`, `useStillness()`, `asksForStillness()`. - `Stage` is the wrapper `IsoFigure` uses. It adds `data-inview` for CSS animations. ## 10. Round parts on any axis (`kit/lathe.mjs`) `kit/iso-kit.mjs` builds prisms that stand on the floor. `kit/lathe.mjs` turns a profile around **any** axis, for bells, chambers, flanges, valve bodies, pump volutes, pipe fittings, actuators lying at an angle, and connectors on a vertical face. It returns the same `SolidPaths` as `slabOf`, so `solidSvg` paints them. Import it as `import * as G from "../../kit/lathe.mjs"`. **Frames.** A frame is `{ o, a, u, v }`: an origin, the unit axis, and two unit radial directions. A point on the part is `(s, r, angle)`: `s` along the axis from `o`, `r` from the axis, and `angle` from `u` toward `v`. | Function | What it does | |---|---| | `frameAlong(o, a)` | A frame on axis `a` through `o`, with `u` and `v` chosen for you. Use it for pipes, bosses and flanges at any angle. | | `frameOf(o, a, u, v)` | A frame with explicit radial directions, so angles mean something: `G.frameOf([0, 0, z], [0, 0, 1], [1, 0, 0], [0, 1, 0])` makes angle 0 point along +x. | | `pointOf(F, s, r, angle)` | The world point. | | `radialOf(F, angle)`, `tangentOf(F, angle)`, `angleToward(F, direction)` | Directions in the frame, and the angle that faces a direction. | **Solids.** A meridian is a list of `[s, r]` points along the axis. Add a third element (`[s, r, 1]`) to mark a smooth ring, so no crease line is drawn there. Use that for curved profiles such as a bell or a dome. A meridian with any smooth vertex is shaded smoothly on its own (`smooth` defaults to `"auto"`, which is `softOf(meridian)`); pass `smooth: false` to opt out, `true` or `{ rows }` to force it on a profile without smooth vertices, and `{ slope }` or `{ slope, radius }` for an analytic curve. **Smooth shading.** Without it every facet row gets one flat tone, and a curved profile shows a staircase of tone blocks (the first Raptor bell and throat did, and the reviewer saw it at once). With `smooth` the side of each meridian segment is shaded from an interpolated normal: the tone boundaries become the true iso-light curves, sampled at `rows` levels and emitted as one polygon per tone region, so they run as clean curves across the whole part. - `smooth: true` interpolates each run of smooth vertices (`[s, r, 1]`) with a monotone cubic Hermite curve (Fritsch–Carlson slopes: zero where the profile turns, a weighted harmonic mean elsewhere), and uses that one curve for the normals, the tone sheets, the silhouette and the hull, resampled until it is within 0.2 px of the chord. Shading and outline then describe the same shape, and the normal can only change sign where the profile does. Use it for any profile with smooth vertices: domes, throats, cones, valve bulges, a flame cone. (A natural spline overshoots near a peak; on a valve bulge it zig-zagged the tone bands across the body.) - `smooth: { slope }` takes the normal from an analytic `dr/ds`, so a body cut into bands shares one normal field and the tone curves cross the band joints without a step. With a `slope` alone the profile points are joined straight, so sample the analytic `r(s)` densely (20–30 knots on a throat), and the silhouette of a waisted body uses the same `slope`, so the outline follows the true curve too. - `smooth: { slope, radius }` also takes the surface from an analytic `r(s)`: the tone sheets, the hull and the silhouette all follow `radius` between the knots, so two knots are enough for a band. This is what `bandsOf` passes to every band. - **Hand-typed knots are not a curve.** The monotone Hermite is only C1: its `dr/ds` is a piecewise quadratic that plateaus at every knot and overshoots between them, so tone bands stall and then lurch, a staircase in scallops. The Raptor throat (`[128, 31, 1], [134, 27.6, 1], …`) showed it at 5×. Any curved body larger than a fitting gets an analytic profile and `smooth: { slope }`: the throat is `r = √(24.2² + (c·(s − 146))²)`, the flame cone `r = 48·(1 − x)^0.8` closed to `r = 0` at the tip, the valve bulge an ellipse, the gimbal puck `18.4 − a·|s − 297.5|⁴`. The audit's `wobble` lists every profile smoothed without a `slope` whose `dr/ds` turns between its ends. - `rows` (default 6) is the starting sampling along each segment. Rows are then split until every tone boundary is within `error` (0.35 px) of a straight chord, and wherever the number of bands changes down to `depth` (8) levels or to `fineness` (0.1 px) on screen, whichever comes first, so a band that appears or disappears is born at zero width in a sliver instead of ending on a straight cut. Each tone is then emitted as one polygon for as long as its own number of regions holds, across every meridian segment and smooth knot of the body, and only that tone is bridged where its count changes; a band that comes and goes no longer cuts the other tones into rows. That made a small turned ball about half the size and removed the row seams. Visibility uses the drawn facet's normal, so the tones always reach the outline; every tone polygon is wound the same way, so overlapping pieces of one tone never cancel into a hole. - The result is marked `seamless` (see `solidSvg`). Flat caps (rings where `s` doesn't change) keep their facet shading. | Function | What it does | |---|---| | `lathe(meridian, F, P, { steps = stepsFor(rMax, 32, 160, scaleOf(P)), fill = "auto", hole = null, bevel = 0, rims = "all", smooth = "auto", waist = 0.5 })` | The general solid of revolution. Facets are shaded in four tones by the kit's light and bucketed like `extrude`. `fill: "auto"` uses the hull of the rings unless the true silhouette lies more than `waist` px inside it (a throat, a spool), and then fills and outlines the body with `silhouettePoints` on both sides joined by the visible end arcs (only when the silhouette is unbroken along the whole side: a body whose surface faces you head-on somewhere, like a valve bulge that leaves its neck at a right angle, falls back to the hull, because joining the broken pieces drew stray triangles across it); `"surface"` forces that, `"hull"` forces the hull; `fill: "facets"` fills only the visible facets and draws no outline, which you need for a concave or open shape seen from inside. `hole: [s, r]` cuts a round hole into the fill and outline, for the end of a tube. `bevel` adds a highlight inset from a closed cap (a meridian end at `r = 0`). `rims` keeps the outline on the end rings: `"all"`, `"none"`, `"first"`, `"last"` or `"ends"`. Only hull edges along the first or last ring are dropped; edges along interior rings always stay, so the side silhouette stays whole. | | `solidOf(profile, F, P, options)` | Closes a profile to the axis at both ends and picks `steps` from the largest radius. It is the usual way to make a turned part: `G.solidOf([[0, 12], [6, 12], [8, 9, 1], [20, 9]], F, P)`. | | `bandOfProfile(profile, F, P, options)` | An open band with no caps, for one stretch of a long body that is split into depth-sorted pieces. Prefer `bandsOf`, which cuts the whole body at once. | | `bandsOf(profile, cuts, F, P, { slope, radius, steps, rows = 6, rims = "none", fill = "auto", samples = 72 })` | One curved body (`s` rising along `profile`) cut at the `s` values in `cuts` into depth-sortable bands that share one normal field, one surface and one facet count. Pass the analytic `slope` and `radius`, or neither: then `profileCurve(profile)` makes them from the profile (the monotone Hermite through each run of smooth knots, straight elsewhere), so no band ever has its own end slopes. Returns `{ bands, sidesOf, radius, slope, steps, s0, s1 }`. Each band is `{ s0, s1, profile, paths, outline, seam }`: `paths` is ready for `solidSvg` with its outline cleared, `outline` is the band's own chord outline if you need it (the last band of the Raptor skirt keeps it), `seam` is the visible arc where it meets the next band (put it in `inner`), and `profile` is what to record for the audit (`A.solid(F, band.profile, { radius })`). `sidesOf(from = s0, to = s1, { ends = false \| true \| "first" \| "last", count = samples })` returns `{ d, points }`: the body's two analytic side edges over that span as one path, optionally with the visible rim arcs at its ends, plus their 3D points to record (`points.map((list) => A.tube(list, 0.4 / scale))`). Draw `d` as one item with a small depth bias over the bands, so the outline is one continuous curve. | | `profileCurve(profile, { slope, radius })` | `{ s0, s1, radius(s), slope(s) }` for a profile whose `s` rises: the curve `smooth` draws through it. Throws if `s` doesn't rise, since a step in a profile is a crease and belongs to a separate solid. | | `outlineOf(meridian, F, P, { steps, fill, hole, rims, smooth, waist })` | Only the `fill` and `outline` of `lathe`, without the shading, for a body whose outline you draw separately. | | `softOf(meridian)` | True when any vertex is marked smooth. | | `disc(s0, s1, r, F, P, options)` | A short cylinder on the axis: pucks, flanges, bosses, pins, plugs. | | `ringBand(s0, s1, rIn, rOut, F, P, options)` | An annulus with thickness: a flange or collar around something. | | `stepsFor(radius, least = 16, most = 112, scale = 0)` | A facet count. With `scale` (screen px per unit, `scaleOf(P)`) it keeps every facet about 3 px long on screen; `disc`, `ringBand`, `solidOf`, `bandOfProfile` and `lathe` use that (32–160) when you pass no `steps`. Facets only set the tone fills: outlines, rims, creases and bevels are sampled from the true circle at about 1.5 px and end exactly where the faces turn away. | | `saddleOf(F, a, start, s1, P, { steps })` | A saddle pad: a cylinder of radius `a` along `F.a` from a curved host surface to a flat face at `s1`. `start(angle)` is where each generator leaves the host (find it by bisecting the host's `sdf`), or a number `R` for a host cylinder perpendicular to `F.a` (then `saddleStart(R, a, angle)` gives each generator's start). The fill is the union of its visible facets and face, the outline its two side generators, the visible part of the saddle curve and the face rim, so it meets the host along the true intersection. Seat flanges on it wherever a flat flange on a curved body would leave a rim gap. | | `prismOf(F, polygon, s0, s1, P)` and `stadium(r0, r1, offset)` | A prism of any convex `[x, y]` polygon in the frame's `u`, `v` plane, extruded along `F.a`, with facet tones, the hull as fill and outline, and creases only at sharp corners. `stadium` is the hull of two circles, for a gearbox plate joining a motor to an actuator. | | `sphereOf(centre, r, P, { steps = 48 })` | A ball with exact smooth tones: each tone region is the projected hull of a spherical cap, so it costs four small polygons instead of a turned profile. Use it for rod-end bearings, tee fittings and knobs. It is `seamless`. | | `silhouettePoints(profile, F, P, { samples = 72, slope, radius })` | The two side edges of `silhouetteOf` as 3D point lists, to record the outline's shape for the audit. | | `silhouetteOf(profile, F, P, { samples = 72, slope, radius })` | The two analytic side edges of the surface of revolution through `profile` (`[s, r]` points, linearly interpolated), as one path of two polylines: the points where the surface turns away from the viewer (`cos(θ − φ) = r′·aV/|V⊥|`). Use it to draw one continuous outline over a body built in bands. Where the surface faces the viewer head-on (`|c| > 1`) a sample is skipped. Pass the analytic `slope` (the same one the bands are shaded with) so the edge doesn't kink at every profile point. | **Lines and dots on round surfaces.** These draw only what faces the viewer. `slope` is `dr/ds` of the surface at that ring, so a line on a cone or bell hides where the real surface turns away. | Function | What it does | |---|---| | `arcOf(F, s, r, P, { slope = 0, steps, least = 0.02, inward = false, from = 0, to = 2π, trim = 0.3 })` | The visible arc of a ring on the surface: seams, bands, weld lines, a lip. The limb angles are solved exactly (`t = φ ± acos(c)`), and each end that stops at the limb is shortened by `trim` px so its round cap stays inside the outline stroke. `inward: true` draws the arc you see on an inner surface, such as the inside of a nozzle. `least: -1` gives the whole ring. Draw seams *under* the outline: `solidSvg(paths, { inner: lineSvg(arcOf(…), { tone: "faint" }) })`. Appended after the solid, a faint seam cap paints over the edge stroke and notches the silhouette at every seam. | | `circleOf(F, s, r, P, steps)` | A full closed circle, such as a face ring, a bore or a void you fill. | | `ribsOf(F, s0, s1, r, count, P, { phase, twist, least = 0.12, r1 = r, fade, seams })` | Visible axial lines: a knurl, ribs or fins along any axis. `twist` makes them helical and `r1` lets them run along a cone. With `fade: [lo, hi]` it returns `[{ d, alpha }]`, each rib's opacity a smoothstep of how much it faces you, so the set fades out toward the turn instead of stopping at `least`. Draw them with `fadedSvg`. `seams: true` (or `"first"`, `"last"`) adds the seam arcs at `s0` and `s1` to the result (at full opacity), so a rib row and the seams it ends on always share one `s`; leave it off only where the ribs end on a step or a band end that is already drawn. | | `rodOut(F, from, to, r, P, { stroke, ...latheOptions })` | A rod that slides out of a gland at `s = from` (`F.a` points out of it), its tip at `to` at rest, travelling up to `stroke` further out, or the same rod retracting into it. Returns `{ paths, clip, from, to, stroke, reach }`: `paths` is one rod from `from − stroke` to `to`, and `clip` a path covering the rod from the gland face to `to + stroke`. Draw `<clipPath id><path data-hold d=clip/></clipPath><g clip-path=url(#id)>solidSvg(paths)</g>`, move the item, call `holdStill`, and record `A.slide(F, from, to, r)` (`craft.md`, section 3). | | `spokesOf(F, s, r0, r1, count, P, { phase, sweep, from, to })` | Radial lines on a face, for vanes and turbine blades. | | `dotsOf(F, s, r, count, P, { phase, slope, least = 0.08, all = false, fade })` | Screen points of a bolt circle on the visible side. Pass them to `dotsSvg`. Use `all: true` for a face you see head-on. A `least` well above 0 stops the circle in the middle of the surface; pass `fade: [0.04, 0.42]` instead and the dots carry a falling opacity. | | `fadeOf(value, [lo, hi])` | The smoothstep both fades use. | | `facingOf(F, angle, P, slope)` | How much a surface at that angle faces the viewer. Positive means visible. | **Paths in 3D**, for pipes and cables. | Function | What it does | |---|---| | `fillet(points, radius, perArc = 7, { P, tube, margin = 1.3 })` | Rounds every corner of a polyline with a bend of `radius`, clamped to the room available (0.48 of the shorter leg). Lay out a pipe as a few straight legs, then fillet it. With `P` and `tube` (the pipe radius) each corner's bend grows to `margin · tube / (0.7 · f²)`, where `f = |n · V|` is how squarely the bend's plane faces the viewer: a bend seen edge-on projects to a radius `R·f²`, and below the tube's own radius the outline folds back on itself. The legs still cap it, so a turn next to a port facing you needs long legs or an elbow fitting. | | `resample(points, spacing)`, `pathLength(points)`, `cut(points, from, to)` | Even spacing, total length, and a sub-path by arc length. | | `pointAlong(points, at)`, `tangentAlong(points, at)` | Position and direction at an arc length. Use them to place flanges, bands, valves and clamps on a route. | | `spiral(F, s, r0, r1, a0, a1, steps = 28)` | 3D points of a widening spiral, such as a pump volute. Feed them to `tubePieces` with a radius function. | | `arcPoints(F, s, r, a0, a1, steps = 24)` | 3D points around a ring, such as a manifold, a deluge ring or a harness lane. | **Camera and vectors.** - `viewOf(P)` is the unit direction to the viewer, so `dot3(p, viewOf(P))` equals `depthOf(p, P)`. - `lightOf(P)` is the kit's light. - `scaleOf(P)` is the screen units per world unit. - `toneOf(normal, P)` and `bandOf(score)` give the four-tone bucket. - `hullOf(points)` and `runsOf(flags)` are the hull and run helpers the module uses itself. - The vector helpers are `dot3`, `add3`, `sub3`, `mul3`, `len3`, `unit3`, `cross3` and `lerp3`. **A long curved body in bands.** Cut it with `bandsOf` so each band depth-sorts against the pipes that wrap it. Every band shares the body's `steps`, its normal field and its surface, so tone curves cross every joint without a step, and the outline comes from `sidesOf` as one curve instead of a chord per band that kinks at every joint. The Raptor bell: ```js const BELL = G.bandsOf([[3.2, bell(3.2)], [116, bell(116)]], [14, 28, 44, 60, 76, 92, 106], ENGINE, P, { radius: bell, slope: bellSlope, steps: 96, rows: 8 }); BELL.bands.forEach((band, index) => put("nozzle", ex(ENGINE, (band.s0 + band.s1) / 2), S(band.paths, { tone: "hi", inner: index === 2 ? Ln(band.seam, { tone: "faint" }) : "" }), { shapes: [record(A.solid(ENGINE, band.profile, { radius: bell }))] })); const SIDES = BELL.sidesOf(3.2, 106, { ends: "first" }); put("nozzle", ex(ENGINE, 105), Ln(SIDES.d, { tone: "hi" }), { bias: 0.2, shapes: SIDES.points.map((points) => A.tube(points, 0.4 / KS)) }); ``` Don't build bands by hand from separate `bandOfProfile` calls on two-knot chords with their own Hermite fits: each band then has its own end slopes, its tones step at every joint, and its chord outline kinks. **Known traps.** - **Four-tone quantisation follows the slope of each facet row** when a body isn't smoothed. Mark every curved knot smooth (`[s, r, 1]`) and the shading follows on its own; a profile of hard knots that still describes a curve needs `smooth: true`. Use `bandsOf` on a body cut into bands. - **Smooth shading is heavier.** A small turned ball with `smooth` costs about 9 KB; `sphereOf` costs under 3 KB. Use `sphereOf` for balls and keep `rows` low on small parts. ## 11. Pipes, tubes and cables (`kit/tube.mjs`) ```js import { tubePieces, tubeSvg, runsOf, crossingsOf } from "../../kit/tube.mjs"; ``` | Function | What it does | |---|---| | `tubePieces(points3, radius, P, { maxLength = 30, spacing = 2.2, closed = false, breaks = [], gaps = [], caps = true, stripes = "auto" })` | Splits a 3D route into chunks of at most `maxLength` world units. Each one is drawn as a 2D ribbon with a paper body, a shine band toward the light, a shade band away from it, and two edge lines. Neighbouring pieces overlap by 0.5 units, and the edge lines are cut from the same overlapped span as the body, so neither a seam nor a notch in the outline shows at a joint. The shine and shade run 0.5 further than the body at every open end, so a stripe covers the anti-aliased cut of the body under it and its own cut lies on the neighbour's identical stripe; every frame normal is a central difference along the whole route, so the stripes of two chunks coincide exactly where they overlap (with both cut on one line, a hairline crossed the volute's shade band at every joint). `radius` is a number or a function of `t` (0 to 1 along the route), for tapers and volutes. `breaks` are arc lengths where a chunk must end (at a crossing, or where the pipe turns back past a part). `gaps` are `[from, to]` arc-length spans that are not drawn because a collar, clamp, valve or fitting covers them; the pipe ends flat on each side of a gap. Every end next to a gap gets an elliptical cap (the projected cross-section, with its outer half-arc as an edge), and so do the route's own two ends unless you pass `caps: false` (or `[start, end]`, such as `[false, true]` for a branch whose start hides inside a tee collar), so a pipe meets a flange face along the right curve instead of a straight cut. The shine and shade stripes follow the cap: onto the outward half of the ellipse when the end face is turned away, and up to its inner rim (drawn as an edge) when the face is turned toward you, so no stripe stops short of the end. A closed route with no gaps wraps its first and last pieces round each other. Returns `[{ mid, body, shine, shade, edges, wide, points, radii, ends, from, to }]`: `mid` is the chunk's centre in world space, to depth-sort it; `wide` is true when the route is wide enough on screen for its shine and shade bands (decided once for the whole route from its widest point, so a taper never drops its stripes at one chunk joint; `stripes: true` or `false` overrides it); `points` and `radii` are the chunk's 3D centreline and radii (record them for the audit); `ends` says which ends are real ends rather than joints with a neighbour. | | `tubeSvg(piece, { tone = "hi" })` | One piece as `<g class="tb">`. Shine and shade appear only on `wide` pieces, then the piece's rings (`.tb-ring`), then the edges. | | `tubePieces(…, { rings: { pitch = 3, twist = 0, cross = false, fade = [0.1, 0.55], phase = 0, steps = 16, tone = "lo" } })` | A texture that follows the pipe's cross-section: a braid, corrugation, tape wrap or armour. Stations sit every `pitch` units of 3D arc length from the route's start (`phase` shifts them), so the pattern is continuous through every chunk joint; each is drawn as the visible front arc of the cross-section there, split into short segments whose opacity is `fadeOf(facing, fade)`, so the texture fades toward both limbs instead of ending on a hard line. `twist` (in radii) turns each ring into a helix that advances that far from limb to limb, and `cross: true` adds the other hand, for a braid. Rings run over each chunk's stripe overhang, so the later chunk at a joint draws an identical piece over its own body's cut, and stop on real ends and gap faces (rings keep a third of a pitch off an end face, helices end on it). Each piece carries `rings: [{ d, alpha }]`; `ringsSvg(rings, tone)` draws them alone. | | `runsOf(total, gaps)` | The drawn `[from, to]` spans of a route of length `total` once `gaps` are taken out. Record each run as its own tube for the audit. | | `crossingsOf(points, centre, axis, radius)` | Arc lengths where a route crosses the disc of a clamp or collar (the plane through `centre` normal to `axis`, within `radius`). Turn each into a gap of the clamp's thickness. | The fills for `.tb-body`, `.tb-shine` and `.tb-shade` are part of `ISO_CSS` (and `kit/iso.css`), so `tubeSvg` works on any kit page with no extra CSS. Override them in page CSS only to restyle tubes, as the Raptor does for selected parts (`.it[data-on] .tb-body`). **Depth-sorting tubes.** A pipe runs between parts at many depths, so draw each piece at its own depth: put it in your item list with key `G.dot3(piece.mid, G.viewOf(P))`, next to the solids it passes. Use short pieces (`maxLength` 6–18) where a pipe weaves between neighbours, and long ones (400) for a ring that lies flat on the floor. To split a pipe between SVG layers, test `piece.mid`. The ripple tank's motor cable goes three ways: pieces over the bridge (`piece.mid[2] > Z.top - 1 && piece.mid[1] < IN.y + IN.d - 10`) to the front SVG before the bar, the rest above the rim to the front SVG after the near caps, and the run down the leg to the control box to the middle SVG. Its lamp cable sends the pieces that hang over the base edge (`piece.mid[1] < BASE.y - 0.2`) to the back SVG. In a figure as dense as the Raptor, don't tune these keys by eye: record the pieces and let `orderKeys` (section 12) settle them. **Anything that wraps a pipe splits it.** A collar, band, bellows disc, clamp or valve body is wider than the pipe it sits on. If the pipe runs through it as one chunk, the chunk is both behind and in front of the collar and no key can order them: the collar either hides the pipe's near side or the pipe paints over the collar's face. Gap the pipe at the collar (`gaps: [[at - t/2, at + t/2]]`): the far piece ends at the collar's back face, the near piece starts at its front face, and both order cleanly. The Raptor's `pipe()` does this for its own end flanges, bands, bellows and valve, and `cable()` for the harness clamps via `crossingsOf`. The Raptor's `pipe()` helper is a good pattern: 1. `fillet` the control points with a bend of 2.6 × r (at least 3). A valve or bellows needs a straight leg at least as long as its body plus both fillet cuts (`r_bend · tan(turn / 2)` each); put it on a bend and its ends cut into the pipe. 2. Work out the collars first (end flanges, bands, bellows discs, the valve body) and pass their spans as `gaps`. 3. `tubePieces` the route, recording each chunk with its `points` and `radii`, and each drawn run as a tube for the audit. 4. Draw the collars, owned by the pipe (`owner`), so the audit doesn't count a pipe's own flange as a collision. `cable()` adds a terminal dot at an end that stays with its own assembly and a connector shell (`shells: [start, end]`) at an end that lands on another one, so every cable still ends on something when the figure comes apart. Both helpers take `free: [start, end]` for an end that comes off its mount when the figure is apart, and fillet with `{ P, tube: r }` (see `fillet`). A line that must leave a port facing the viewer starts from an `elbow` (a ball on the boss, rim on sphere at both joints, the line leaving it toward its next point), so the turn happens inside a fitting. `pipe()` puts each end flange's back face on the route end and can end a line in a `coupling` (a union nut at a quick-disconnect) instead of a flange. ## 12. The audit (`kit/audit.mjs`) ```js import * as A from "../../kit/audit.mjs"; ``` **The one-line hook.** Make a recorder, let every drawing helper put its items and record its shapes into it, settle the keys, and end the build with `auditOrExit`: ```js const R = A.recorder(P, { order: STAGGER, spring: SPRING }); R.put({ piece: "base", at: centre, bias: 0.1, svg, shapes: [R.solid(A.slab(plan, 0, 6, { name: "base.plate" }))] }); R.route("base.cable", route, 0.6, { piece: "base", gaps, limp: true }); A.settle(R, P); R.items.sort((a, b) => a.key - b.key); A.auditOrExit(R, P, { crowd: [] }); ``` `node build.mjs --audit` then prints the report and exits non-zero on any failing line; without the flag `auditOrExit` returns at once. A figure that doesn't come apart passes no `order` and every explode check reduces to the rest pose. | Function | What it does | |---|---| | `recorder(P, { order = [], spring = {}, ground = grounded })` | `{ items, solids, tubes, routes, put, solid, route, nameFor }`. `put({ at, bias, key, name, piece, move, ride, shapes, ...rest })` adds a drawn item (its key is `dot3(at, V) + bias` unless you pass `key`; the rest, such as `svg`, `route` and `chunk`, is kept on the item) and stamps its shapes with the item's `move`, `ride` and `piece`. `solid(shape)` records a shape and returns it, for the item's `shapes` or for static parts drawn outside the sorted layer. `route(name, points, radius, { owner, touch, bundle, limp, gaps, closed, ends, rims, free, piece, move, ride })` records a pipe or cable: one tube per drawn run between `gaps`, with its `tips`, `tipAxes`, `tipRims` and `free` ends, plus the route for `folds` and `crowding`. `nameFor(base)` numbers repeated names (`base`, `base#2`, …). `order` and `spring` are the live page's stagger and spring. | | `explodeStates(order, spring, { uniform = 10, stride = 4 })` | `{ depth, frames, apart }`: the depth-solver states (uniform shares, every `stride`th replayed frame with reversals at every stagger boundary, prefix states) and the clearance frames (every replayed frame plus the prefix states). `{ depth: [0], frames: [] }` when nothing comes apart. | | `settle(R, P, { states, skip = sameRun, ...overlapOptions })` | Runs `overlapsOf` and `orderKeys` and writes the keys onto the items. It keeps the order the items had, so you may sort `R.items` afterwards. Run it on every build. | | `audit(R, P, { allow, crowd, cover, ground, frames, apart })` | Every check below on the recorded shapes, as one result for `report` and `failures`: clearance at rest, apart and in every frame, solid clearance, crowding at rest and apart (against slender solids too), stand-in shapes, terminals, loose ends, folds, self-overlaps, wobble, seats, supports and depth order. `allow` excuses depth pairs, `crowd` crowding pairs, `cover` stand-in items. It never sees line markup: run `scripts/lines.mjs` on the page for that. | | `auditOrExit(R, P, { flag = "--audit", argv, ...auditOptions })` | With the flag: `audit`, print the report and a summary line, and set `process.exitCode = 1` on any failure. | | `sameRun(a, b)`, `grounded(shape)` | Neighbouring chunks of one route (skipped by the overlap pass), and the default ground (a shape with `ground: true` in its meta or a name starting `stand.`). | | `keyAt(item, state, V)`, `labelOf(state)`, `cameraBasis(P)` | An item's sort key in an explode state (`key + lift · share`), a state's printable label, and the camera's screen-right, screen-down and toward-viewer unit vectors `{ R, D, V }`, for probes of your own. | A detailed figure fails in ways the eye misses until a reviewer zooms in: a pipe or cable that passes through something, at rest or in any frame of the explode, a flange that floats off its mount, a part that hangs in the air or is sunk into its neighbour, a route that doubles back or pinches to a point on screen, a bend or end that lands on another line's outline, a cable end that dangles when the figure comes apart, and two things drawn in the wrong order where they overlap. The audit measures all of them from the real geometry. On the Raptor (478 items, 1,119 depth states, about 4,400 replayed and prefix frames for clearance) the keys take about a minute on every build and a full `--audit` about three minutes in all, most of it the per-frame clearance. **Shapes.** Record a shape for everything you draw, in world units, as you build it: | Constructor | Shape | |---|---| | `solid(F, profile, { radius, ...meta })` | A turned part: the profile closed to the axis at both ends, the same `[s, r]` list you give `solidOf`. Runs of smooth knots are traced along the same Hermite curve the drawing uses (or along `radius(s)`, as for a `bandsOf` band), so the recorded surface is the drawn one, not its chords. | | `lathe(F, meridian, { radius, ...meta })` | The shape of `G.lathe(meridian, F, P)` with the same tracing: any closed meridian (rings, valve bodies, cans). | | `body(F, polygon, meta)` | Any closed `[s, r]` polygon turned round `F`, taken as it is. Edges that lie on the axis are not surface, so a point deep inside a disc measures its true depth. | | `disc(F, s0, s1, r, meta)`, `ring(F, s0, s1, rIn, rOut, meta)` | Flanges, bosses, pins, collars. | | `box(min, max, meta)`, `orientedBox(centre, axes, half, meta)` | Square blocks; a beam at 45°. | | `slab(plan, z, height, meta, steps = 8)`, `extruded(ring, z, height, meta)`, `cylinder(cx, cy, r, z, height, meta)` | The shapes of `slabOf`, `extrude` and `cylinder`: the same rounded plan or any polygon ring (convex or not) stood on the floor, so a rounded deck corner or an L-shaped bracket is checked as drawn. | | `prism(F, polygon, s0, s1, meta)` | The shape of `G.prismOf`: any polygon in the frame's `u`, `v` plane extruded along `F.a`. | | `traced(meridian, { radius, error = 0.02, depth = 6 })` | The polygon `solid` and `lathe` record. | | `ball(at, r, meta)` | Spheres and terminal dots (`size / scale` world units). `meta.flats: [[nx, ny, nz, d]]` cuts flats (the half-space n·(p − c) ≤ d is kept): `sdf` is `max(\|p − c\| − r, n·(p − c) − d …)`, surface samples include the flats' rims, `boundsOf` is unchanged (conservative). The 3D mode's knuckle balls record this. | | `tube(points, radius, meta)` | A pipe or cable centreline with a radius (number, function of 0..1, or a list per point). | | `slide(F, from, to, r, meta)` | A rod sliding out of a gland at `from` (a `disc` from `from` to `to`). Posed in an explode or stroke state, it stretches from the gland face to `to + offset · F.a` instead of translating, so every check sees the rod that is drawn. Put it on the moving item (the one with the stroke `move`). | | `moved(shape, offset)`, `sdf(shape, p)`, `boundsOf(shape)` | Helpers: translate, signed distance (negative inside), world bounds. | `meta` carries `name` (used in reports, and matched by prefix: `"nozzle.bell*"`), `owner` (a pipe's own flanges, bands and valve), `touch` (names a tube may touch on purpose, such as the clamps round a harness), `bundle` (cables in one harness may lie side by side, and a clamp carries its harness's bundle), `joined` (runs of one gapped pipe), `limp` (a cable: it holds nothing up), `anchor: false` (a clamp: an end may not count it as its mount), `fitting` (a clamp or collar whose centre `crowding` checks against other lines), `seated` (a pad or rim-on-sphere joint sunk into its host by design), `cut` (a shape or list of shapes subtracted from this one: a saddle pad is a disc cut by its host) and `curve` (the smoothed profile of a turned part with no analytic slope, for `wobble`). Tube runs also carry `tips` (the route's true end points, or `null` for an end that merges into a body, like a volute's start), `tipAxes`, `tipRims` (the flange or connector radius at each end), `free` (ends that are meant to come off their mount apart), `offset` and `total` (where the run sits on its route). Every shape carries the `move`, `ride` and `piece` of the item that draws it, so it can be posed in any explode state. **States.** An explode state is a number (every assembly at that share) or `{ label, shares }`, one share per assembly. `uniformStates(10)` gives 0, 0.1 … 1; they are not frames anyone sees, so they only feed the key solver. `springStates(order, { stagger, stiffness, damping, substeps, fps = 60, settle = 1e-3, stride = 1, reversals = [] })` replays the live page's own springs with their stagger, opening from rest and closing from apart, one state per frame at `fps` until every spring is within `settle` of its target (about 1.2 s each way on the Raptor, so the slow tail of every collapse is checked too). `stride: 4` keeps every fourth frame, for the depth raster. `reversals` adds the paths a quick hover makes: open, then close again after that many seconds (and the reverse), with the page's own scheduling: on a reversal only the assemblies whose target changes are rescheduled, staggered among themselves, so one that has just left comes straight back and one still waiting simply stays home. **`reversalsOf(order, stagger)`** gives a reversal 8 ms either side of every stagger boundary: that is where the bad windows are (a reversal 15 ms before the next start left the hot-gas duct flying out into a methane pump still parked at home), and three hand-picked times missed every one. **`prefixStates(order, { shares = [0.25, 0.5, 0.75, 1] })`** adds the static states behind the prefix rule: the first `k − 1` assemblies fully out, the `k`-th at each share, the rest home. Shares along the stagger are always non-increasing (earlier assemblies lead, opening, closing and reversing), so every reachable frame lies between these. A 40 ms sample missed whole pass-throughs: between two samples the nozzle travelled 15 units, more than twice the widest pipe. `shareOf(item, state)` reads an item's share from its `ride` (or `piece`), and `offsetOf(item, state)` is its move times that share. **`overlapsOf(items, P, { step = 0.25, tolerance = 0.3, least = 0.25, states = [0], skip })`** finds every pair of drawn items whose screen footprints overlap in each state and, on a grid of `step` viewBox units over the overlap, ray-casts both shapes along the camera ray. A cell counts when one surface is more than `tolerance` nearer. Each side's flagged cells are closed (one dilate, one erode, so an aliased sliver becomes solid), eroded once (so the hairline along a real contact drops out), and the largest connected piece is measured in px²; a side is "in front" when that area is at least `least`. At the old 0.5 step with a 4-neighbour core count, a whole flange sunk into a pump housing and every cable crossing (a 0.5 cable is under three cells wide) read as noise. Each item is ray-cast once, at home; in any state its raster is that one shifted on screen by its offset (rounded to the grid) and in depth by `offset · V`, which is exact for a translation. Pair results are cached by the pair's relative shift, so pairs that move together are measured once. On the Raptor, 1,119 states (uniform, every fourth frame of every replayed path, prefix states) cost less than the old 61. `skip(a, b)` excludes pairs, such as neighbouring chunks of one pipe. **`orderKeys(items, overlaps, { gap = 0.02 })`** turns every one-sided pair into a rule in that pair's state: `keyFront − keyBack ≥ gap + shareBack · liftBack − shareFront · liftFront`. It is linear in the keys, so one longest-path relaxation settles all states at once and only ever moves an item later. A loop that can't be met drops its weakest rule (`dropped`). Pairs in front of each other in different places are `cycles`. **`depthOrder(items, P, { overlaps, solved, rest, allow })`** checks the final order in every state. At rest (`rest`, default the states 0 and 1) and in every replayed spring frame, every wrong pair is a problem: a clean misorder, an interlock and a pair no key of the form `k + lift · share` can hold all show on screen. In the uniform states only a clean misorder counts; interlocks there are listed as passing. `allow` takes `[patternA, patternB]` pairs to excuse explicitly. **`clearances(tubes, solids, { ratio = 0.5, least = 0.2, state, only, mounts })`** samples every tube and lists every place it comes closer than `max(least, ratio · r)` to a solid or another tube. Near a run's flat end it measures the end ring, not a sphere that would overhang the cut. Only an end's own mount (the shape its rim sits on, see `mountsOf`), that mount's pipe, and the fittings of a line that shares the mount or lands on this tube are excused, and only within `r + gap + 1` of the end; near an end anything else may still not be entered by more than 0.1. Pass `state` to pose everything (the excuse holds only while tube and mount are within 0.05 of where they sit together) and `only: "through"` to list penetrations alone. A tube's distance to another tube is the true capped-cylinder distance, so a run that ends flat under a flange doesn't grow a phantom round cap that the next run seems to enter. **`mountsOf(tubes, solids)`**, **`terminals(tubes, solids)`** find, for every tube end, the nearest anchored shape to its end rim (12 points at the flange or connector radius) and list every end whose rim isn't within 0.15 of it: a flange standing off its port, a flat flange on a curved body (the rim gap), a cable that stops beside the bundle or inside a clamp. **`looseEnds(tubes, solids, { state = 1 })`** lists every tube end that, in that explode state, touches no solid of another owner that moves with it. Its own flange, coupling or connector shell does not count (with it, the check could never fail: 25 Raptor ends rested only on their own fitting). Ends marked `free` are reported separately as a deliberate list; everything else fails. **`supports(solids, tubes, { ground, touch = 0.15, sunk = 0.5 })`** builds a contact graph of solids and rigid tubes and lists every solid not connected to the `ground` (default: names starting `stand.`), plus every pair of solids sunk more than `sunk` into each other unless they share an owner or one is `seated` on the other (named in its `cut`). A seated pad is excused against its host only: the Raptor's igniter flange sunk 0.5 into the hot-gas pad of another assembly hid behind a blanket `seated` flag. `hanging` lists rigid tubes that connect to nothing held, and fails too. **`solidClearances(solids, { state, sunk = 0.5 })`** poses the solids and, for every pair that sits at a different offset in that state, tests each one's surface points against the other's `sdf`; anything more than `sunk` inside fails (a seated pad and its host are excused). Run it at state 1 and in every replayed and prefix state: a housing, pad or flange of one assembly sweeping through another's body is invisible to the tube checks. **`seats(solids)`** lists every `seated` shape cut to a host that moves with another assembly. Apart, its cut base is on show: the hot-gas duct's saddle, cut to the methane pump's turbine and ring, flew out as a jagged shard with a pointed tail. A saddle belongs to its host's assembly; the moving line meets it on its flat face. **`selfOverlaps(items, P, { fold = 0.25, least = 0.5 })`** checks each drawn tube chunk against itself: where the screen centreline turns on a radius under the tube's screen half-width, the inner edge of the ribbon runs backwards and the single-chunk outline crosses itself (a knot with the hidden leg's edges showing through). It fails when that fold is at least `least` px and the spot isn't covered by another item. A leg that points at the viewer and turns within a radius or two of a port is the usual cause. **`wobble(solids, { samples = 20 })`** samples `dr/ds` of every recorded `curve` at 20 points per segment and lists the `s` values where it turns between the run's ends. **`folds(routes, P, { fold = 140, turn = 110, pinch = 2, items })`** lists every route (`{ name, points, r }`) whose screen direction turns more than `fold` degrees within four radii while its real turn is under `turn` (the cusp of a pipe that runs away from the viewer and then drops), and every bend that turns more than 90° on screen round a radius under `pinch` × the tube's screen radius, measured over spans of 1.5, 3 and 6 radii (a fillet seen edge-on: the V of a leg that points at the viewer and then climbs). A pinch within three tube widths of an end is a stub seen end-on and doesn't count. With `items`, only bends you can see count: each is ray-cast against every drawn shape. **`crowding(routes, P, { gap = 1, bend = 30, links, items, allow, state, clear = 2 })`** lists every place where a route's bend (a 3D turn over `bend` degrees within ±2 radii) or end lands on another route's outline on screen: the outlines closer than `gap` px or overlapping, with both lines visible there (ray-cast against `items`; a route's own flanges, shells and clamps count as the route). Those read as a joint or a line threaded through another, whatever the true depth. A bend is never excused by overlapping deeply; an end only when the other line is straight there and the end's tip itself is clear of it. Every `fitting` (a harness clamp) is checked too: a clamp whose outline lands within `clear` px of another line reads as that line growing out of the clamp, as the Raptor's harness clamp did on the oxygen-to-preburner line in the apart pose. `links` (from **`linksOf(tubes, mounts)`**) excuses lines that really meet: one mounted on the other, both on one mount. `state` poses routes and items in an explode state, so the apart pose is checked too (routes carry `move`, `ride`, `piece`). `allow` takes `[patternA, patternB]` pairs you have looked at and judged clear. **`slenderOf(solids, { ratio = 4, least = 0.3 })`** turns every slender recorded solid (a turned part, prism or box longer than `ratio` × its radius or half-width: tie rods, piston rods, posts, rails, handles) into a pseudo-route, which `audit` passes to `crowding` as `rods`. A tube's bend or end that lands on one fails like one landing on another tube (`crowding: hose.B end × cyl.tie-rod (rod)`), unless the tube is mounted on it or on something linked to it. Rods are only ever the line landed on: their own ends aren't checked. **`coverage(items, P, { least = 0.6, pad = 1, allow })`** compares, for every item with an `svg` string, the screen box of what it draws (`markupBox(svg)`: every path, circle, line, rect and polyline, through `transform`s and `clip-path`s, without halos, text and defs) with the screen box of its recorded shapes (`footprintOf(shape, cameraBasis(P))`), and lists every item whose shapes cover less than `least` of its drawing: `stand-in shapes: rod.rod its shapes cover 4% of its drawing`. A sliver recorded for a long rod, or one shape recorded for an item that draws three parts, makes every other check blind to the rest. `audit` takes `cover: [names]` to excuse items whose drawing really reaches past their body; look at each first. **`report(result)`** formats everything; **`failures(result)`** counts what should fail the build. The Raptor wires it like this (`examples/raptor-engine/build.mjs`): its `put`, `tubeItems` and `record` are thin wrappers over `R.put`, `R.route` and `R.solid`, and the end of the build is ```js A.settle(R, P); items.sort((a, b) => a.key - b.key); A.auditOrExit(R, P, { crowd: CROWD_OK }); ``` The live page reads the same `STAGGER` and `SPRING` from its data, so the audit and the page can't drift apart. Every helper (`turned`, `band`, `flangeAt`, `boss`, `padOn`, `boxAt`, `valveOn`, `pipe`, `cable`, `clampAt`, `strut`, the actuators) records its shape as it puts its item, and the static stand drawn in the back layer (deck, flame cone, deluge ring and feed, columns, splices) is recorded as fixed shapes owned by `stand.frame`, so an explode vector that drove a part into the stand would fail too. Nothing escapes the check. ## 13. The WebGL layer (`kit/gl.mjs`) | Export | What it is | |---|---| | `GLSL_ISO` | The GLSL prelude that `glLayer` prepends. It holds the camera uniforms (`uCanvas`, `uView`, `uOrigin`, `uK`, `uCam`, `uTime`) and the functions `viewOf`, `project`, `towardViewer`, `rayOf`, `pixelOf`, `lightDir`, `onFloor`, `onWallX`, `onWallY`, `hash12`, `hash13`, `noise2`, `noise3`, `fbm2`, `fbm3`, `window` and `tonemap`. Don't redefine any of them in your fragment: GLSL rejects a second `rayOf`. | | `rayOf(vb)` (GLSL) | The world point under viewBox point `vb` on the vertical plane through the origin that faces the camera. `rayOf(vb) + t·towardViewer()` is that pixel's whole ray; larger `t` is nearer the viewer. | | `pixelOf()` (GLSL) | World units per canvas pixel, `(uView.x / uCanvas.x) / uK`. Divide by `uCam.z` for the floor's depth direction, or by `uCam.w` up a wall. | | `glCamera(P)` | `{ origin, k, cam }` for a projection. Works in Node, so put it in the page `DATA`. | | `glLayer({ canvas, fragment, view, camera, uniforms = [], fixed = {}, extensions = [], maxRatio = 1.5, onError })` | Returns `{ gl, extensions, set(values), draw(values, time), clear() }`, or `null` without WebGL or on a compile error. `uniforms` names the uniforms you will send per frame. `fixed` is `{ name: value }`, set once at creation, for everything that never changes (plans, heights, theme constants). `extensions` lists GLSL extensions such as `"GL_OES_standard_derivatives"`; each one the context supports is enabled and prepended as `#extension … : enable`, and the returned `extensions` lists those that were. | | `set(values)` | Sets uniforms without drawing, for a value that changes rarely. | | `draw(values, time)` | Fits the canvas, sets the camera uniforms and `uTime`, then `values`, and draws. | | `clear()` | Clears to transparent, only if something was drawn. | | `smooth(a, b, x)` | Smoothstep in JS. | | `burst(age, rise, fall)` | An event envelope: a smoothstep attack over `rise` seconds, then exponential decay with time constant `fall`. 0 for `age ≤ 0`. | | `settle(state, target, dt, seconds, calm = false)` | A critically damped spring on `stat -
motion.md 22.1 KB
# Motion and interaction A figure is alive when its mechanism moves the way the real thing would: a carriage glides and settles, a spring overshoots once, a needle swings to the new reading. Motion should be calm enough to follow and true to the model. ## Principles - **Time-based.** Take `dt` from `requestAnimationFrame` timestamps and clamp it to 1/30 s so a background tab doesn't jump. Never step a fixed amount per frame. - **Ease toward targets.** Pointer and keyboard set a *target*, and the part follows with exponential easing (`x += (target − x)·(1 − e^(−dt/τ))`, τ = 0.1–0.3 s) or a spring. Parts never snap to the cursor. - **Real springs where the subject is springy.** Use stiffness and damping from the real model, sub-stepped about 240 times a second for stability. Snap to the target once both error and velocity fall below 5e-4. - **One loop per figure.** A single `tick(dt)` updates the model, then writes the DOM through refs: `setAttribute("transform" | "d")`, `textContent`, `dataset`. Don't re-render React per frame. - **Stop when settled or offscreen.** Use an `IntersectionObserver` with rootMargin `120px 0px`, and read the newest entry (`entries[entries.length - 1]`): when the stage leaves and comes back between two callbacks, one callback delivers both entries oldest first, and reading the first one leaves the figure frozen on screen. Stop the loop once everything has reached its target. - **Reduced motion.** When `prefers-reduced-motion: reduce` is set, jump to targets with no tour, flow or pulse. The figure must still respond to input. - **Idle tour.** When nobody is interacting, play a scripted loop after about 1.4 s that shows the point of the figure. Pause it on hover, focus or press, and resume about 3.6 s after the last interaction. The tour drives the same targets the user would. - **Readout updates live** from the same model, in true units and short: `block 2 · reads 14.0 px`, `120.0 × 32.0 · lean 10.6 px`. Read it from where the parts are, not from the target: the Raptor says `taking apart` or `closing up` while any spring is moving or pending, and only then `apart · 11 assemblies` or `1,630 kg · cold`. Take a phase word and the number beside it from the same instant of the same source: a blind-test bench printed `end of stroke · relief 85 bar` (the word from the model, the number from a gauge needle lagging 0.25 s behind it) although its relief valve cracks at 92, and `idle · 44 bar` just after retracting. ## Pointer - Map the pointer to world space with the screen vector of an axis. Use `axisVector(P, [1, 0, 0])` for motion along x, or map the figure's width fraction onto the part's travel range. The range mapping is simpler and feels better when the travel is short. - Hover previews and press commits. On touch there is no hover, so make a tap act and keep `touch-action: pan-y` so the page still scrolls. - Use pointer capture while dragging, and release it on `pointerup` or `pointercancel`. ## Keyboard and accessibility - The figure's root is focusable (`tabIndex=0`) with `role="slider"` (or `"group"` plus buttons when there are discrete choices), an `aria-label` that explains the controls, and `aria-valuenow`/`aria-valuetext` in words, such as "Pressed: 129.6 by 34.6 pixels, leaning 10.6 px". Write the value text when the reader's choice changes (a part selected, apart or assembled, firing or cold), not from the loop: screen readers announce every change of a focused slider's value text, so a readout ticking each frame ("Pc 290 bar · 222 tf") became a stream of numbers. Keep the visual readout and the accessible value separate (the Raptor's `say()` writes only the readout, `describe()` the value text); the live region carries the phases. - Keys: arrows step by a sensible unit (Shift for bigger steps), Home and End go to the extremes, Space or Enter presses or toggles, and Escape releases. - Show a focus ring: `outline: 1px solid var(--anatomy-muted); outline-offset: 6px`. - The `<svg>` carries `role="img"` and an `aria-label` describing the object and what it shows. ## Vanilla loop (framework-free) ```js let frame = 0, last = 0, visible = false, touring = !still.matches, clock = 0; function tick(now) { const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60; last = now; const calm = still.matches; if (touring) { clock += dt; target = tourAt(clock); } x = calm ? target : x + (target - x) * (1 - Math.exp(-dt / 0.28)); springTo(tip, heightUnder(x), dt, 420, 26, calm); draw(); // setAttribute on refs, readout.textContent = … const settled = !touring && x === target && tip.rate === 0; frame = visible && !settled ? requestAnimationFrame(tick) : 0; } const run = () => { if (!frame && visible) { last = 0; frame = requestAnimationFrame(tick); } }; new IntersectionObserver((entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); }, { rootMargin: "120px 0px" }).observe(stage); ``` `examples/dial-indicator/build.mjs` contains a complete version, with the tour, pointer mapping and keyboard stops. ## React loop Keep static art in props built on the server and move only `<g>` wrappers and the paths that change shape: ```tsx "use client"; export function RigLive(props: { base: React.ReactNode; carriage: React.ReactNode; dial: React.ReactNode }) { const { anchor, say } = useReadout<HTMLDivElement>(); const car = React.useRef<SVGGElement>(null); const needle = React.useRef<SVGPathElement>(null); const state = React.useRef({ x: REST, target: REST, tip: { at: 0, rate: 0 } }); const visible = useInView(anchor); const kick = useLoop((dt, calm) => { const s = state.current; s.x = follow(s.x, s.target, dt, 0.28, calm); const settled = spring(s.tip, heightUnder(s.x), dt, 420, 26, calm); car.current?.setAttribute("transform", translateAlong(P, [s.x, 0, 0])); needle.current?.setAttribute("d", needleAt(s.tip.at)); say(readoutOf(s)); return !(settled && s.x === s.target); }, visible); return ( <div ref={anchor} tabIndex={0} role="slider" onPointerMove={(e) => { state.current.target = toWorld(e); kick(); }}> <IsoFigure width={W} height={H} label={LABEL}> {props.base} <g ref={car}>{props.carriage}</g> {props.dial} <path ref={needle} className="iso-line" data-tone="lit" d={NEEDLE_REST} /> </IsoFigure> </div> ); } ``` `examples/test-rig/live.tsx` is the full production version, with springs, a tour, press and pull, keyboard handling and aria. ## Shapes that change When a part changes shape rather than position (a swelling capsule, a compressing spring, a needle turning), rebuild only its paths each frame with the kit, using `slabOf`, `coil` or `lineOnTop`, and write them to its paths: `fill`, `shades[0..3]`, `top`, `crease`, `bevel`, `outline`. One solid costs well under 0.1 ms. Measure it: a frame should stay under about 1 ms of script. - **Precomputed frames step.** A lever drawn from 41 prebuilt frames over ±15° moved its knob about 0.8 px per frame, visible in slow motion. Rebuild a rotating part each frame, or precompute enough frames that one step moves nothing more than 0.25 px. - **A rod that slides out of a gland** doesn't change shape: it is one full-length rod that moves, clipped at the gland face by a clip that stays put (`G.rodOut`, `holdStill`, `craft.md`, section 3). Never draw it as several overlapping copies. ## Envelopes for one-shot events A flash, a click, a pop, a shake or a shock is an event with an age, not a value you set. Drive it with an envelope that has an attack and a decay. `kit/gl.mjs` exports `smooth(a, b, x)` (smoothstep) and `burst(age, rise, fall)`, which is `smooth(0, rise, age)` times `exp(−(age − rise)/fall)` after the peak and 0 before the event: ```js import { smooth, burst, settle } from "../../kit/gl.mjs"; const flash = calm ? 0 : burst(clock - mainAt, 0.08, 0.13); ``` On a standalone page `glScript()` already inlines all three, so use them directly and don't declare your own: a second `const smooth` in the same module script is a SyntaxError. - The attack is 40–120 ms, and the smoothstep gives it zero slope at onset. Setting a value to 1 in one frame is a pop. - Store the event's start time (`mainAt = clock`) and compute every envelope from its age each frame. Two events that overlap then simply add. - Give each event its own flag (`popped`), so a quick restart can't swallow the next one and can't cut off one that is still fading. - Under reduced motion every event envelope is 0. ## Critically damped settle For a value that should arrive without overshoot (a hue, a brightness, a lamp, a reach, a frequency), use the kit's `settle(state, target, dt, seconds, calm)`: a critically damped spring on `state = { x, v }` with time constant `seconds`, `v += (w²·(target − x) − 2w·v)·dt` with `w = 1/seconds`, in four sub-steps. It is smoother than exponential follow, because velocity is continuous too. It snaps to the target once error and velocity are both below 1e-4, jumps there under `calm`, and returns true while still moving, so it can keep the loop alive: ```js const moving = settle(freq, state.tf, dt, 0.625, calm); ``` The ripple tank runs its frequency (0.625 s) and dipper spacing (0.4 s) through `settle`, so a key press or a pointer move never jumps the wavelength. - **Separate what a phase changes.** When one physical quantity (say chamber pressure) drives colour, shape and brightness, a fast spring on that quantity makes all three jump at once. Give colour its own slower `settle` (0.3–1 s), and compute brightness so it moves one way through the change. - **A fade-out only gets darker.** When a stop begins, freeze the brightness at its current level and scale it only by quantities that fall. - **Changing a spring changes its neighbours.** Damping one spring critically slows every phase that relied on its old overshoot, so recheck all of them. ## Exploded views Taking a machine apart on hover is a strong way to show its parts (`examples/raptor-engine/`): - **Build every piece at its assembled position**, tagged with an assembly name, and give each assembly a world move vector (`PIECES.nozzle.move = [0, 0, -120]`). Moves follow the real assembly: the nozzle drops furthest, side units slide out radially (`out(deg, r, z)`), and the stand doesn't move. - **One spring per assembly**, with value 0 for assembled and 1 for apart. The Raptor uses stiffness 64 and drag 14.2, just under critical, so the parts settle with a hint of weight, and sub-steps 4 times per frame. - **Stagger the springs.** Opening, each assembly's target changes 35 ms after the previous one, in an order that lets nothing pass through anything (the Raptor: `nozzle, downcomer, controller, plumbing, lox, ftp, hotgas, chamber, orpb, otp, gimbal, stand`, see "Order the stagger" below). Closing uses the reverse order, so the machine folds back together from the inside out. Keep pending targets as `{ value, at }` and apply them when `clock >= at`. - **On a reversal, reschedule only what changes.** When the reader reverses mid-way (a quick hover, Space, a tap), cancel the pending target of every assembly that hasn't left yet and stagger only the ones whose target changes, by their index in that shorter list. The last one out is then the first one back, at once. Rescheduling the whole list left the early assemblies flying out toward apart for 0.3 s while later ones sat at home, and the explode only worked because the next assembly moved out of the way 35 ms later: a reversal in that window drove the hot-gas duct into a methane pump that hadn't left. With this rule, shares along the stagger stay in order in every frame (earlier assemblies always lead), which is what the prefix rule below relies on. - **Move with transforms.** Each item's screen offset is its move vector, projected and multiplied by the spring value: `translate(tx ty)`. Remove the attribute at 0, so an assembled figure is exactly the static art. - **Keep the lines landing.** Parts that join two assemblies (an actuator between the frame and a pump lug, or a cable between a box and a plug) belong to the assembly they physically stay with, and they still end on something when apart. Cables get terminal dots at both ends. - **Selection.** Hover, the keyboard arrows and a parts list all select one assembly. The selected parts become the one `lit` accent: CSS on `.it[data-on]` moves `hi` to `lit` and `mid` to `hi`, and switches tops and sides to the lit fills. Every other part steps down one tone (`hi` to `mid`, `mid` to `lo`) through CSS on `[data-focus]`, and the readout says what the selected part does. ## Depth re-sorting of moving parts Parts that move toward or away from the viewer change painter's order as they move. When they do, sort every frame: - Give every item a static key `k = dot3(ref, viewOf(P)) + bias`, where `ref` is its centre and `bias` settles ties between touching parts. Also give it a lift: `lift = dot3(move, viewOf(P))`. - Each frame, its key is `k + lift × spring`. Sort the indices by that key, and **re-append the DOM nodes only when the order actually changed** (one `DocumentFragment`). Most frames change nothing. - Store the key and the move vector on the element (`data-k`, `data-m`), so the live script needs no geometry. - Tubes are sorted per piece (`tubePieces` → `piece.mid`), so a pipe can pass in front of one part and behind another. - **Settle the static keys from the motion, not just the rest poses.** Each item's key is `k + lift × spring`, but with staggered springs every assembly has its own share in any frame, so two items that are ordered right assembled and apart can still be drawn the wrong way round in between: in the first Raptor a pressurisation line hid behind an actuator column it was in front of for 40% of every explode. The Raptor measures true depth for every overlapping pair at 0, 0.1 … 1, at every fourth frame of its own springs replayed with the live stagger until they settle, opening, closing and reversed either side of every stagger boundary, and in the prefix states (`A.explodeStates(STAGGER, SPRING).depth`, which is `[...A.uniformStates(10), ...A.springStates(STAGGER, { ...SPRING, stride: 4, reversals: A.reversalsOf(STAGGER, SPRING.stagger) }), ...A.prefixStates(STAGGER)]`, 1,119 states, and the default of `A.settle(R, P)` for a recorder made with `{ order: STAGGER, spring: SPRING }`), and `orderKeys` solves all of them at once (each rule uses that frame's shares). Opening and closing alone left wrong pairs in the frames a quick hover makes: a purge flange that should sit in front of the chamber band vanished behind it on a reversal at 0.20 s. The audit fails on any wrong pair at rest and in any replayed frame; only the uniform states, which nobody sees, may hold an interlock. - **One source for the springs.** Keep the stagger order and the spring constants (`SPRING = { stagger, stiffness, damping, substeps }`) in the build and pass them to the page in its data, so the audit replays exactly what the viewer sees. - **Move a part with what it is mounted on (`ride`).** A sensor bolted to the chamber highlights with the plumbing but rides the chamber's spring. Each item records `ride` (the assembly whose spring moves it, found from its move vector) next to `piece` (the assembly it highlights with), the page reads `data-ride`, and `place()` uses `piece[node.ride].x` for both the translate and the sort. An item that borrows another assembly's vector but keeps its own spring slides along its mount and sinks into it mid-flight. - **The prefix rule.** Every assembly's path must be clear of every later-leaving assembly parked at home, with the earlier ones out: that is the frame a reversal at a stagger boundary freezes. `A.prefixStates(STAGGER)` poses exactly those (the first `k − 1` out, the `k`-th at 0.25 … 1, the rest home) and the audit checks them with the replayed frames. On the Raptor it moved the hot-gas manifold after the methane pump it bolts to (its vector points into the pump), and the controller before the plumbing (the oxygen-to-preburner line is enclosed by the controller's sense lines, so it can only leave after them). - **Order the stagger so nothing has to pass through.** The springs overlap: with 35 ms between starts and about a second to settle, every assembly is moving at once, and relative motion is a blend of every vector. The first Raptor explode passed 66 pairs through each other in mid-flight. The rules that took it to zero, checked every frame and on reversals by the audit: - **Whoever is outside leaves first.** On each side, the part that everything else is tucked behind or under moves first: the Raptor's nozzle drops before anything (everything sits on it), and the downcomer, which runs outside every sense line and harness that reaches the methane pump, goes second. Closing reverses it, so the outer part is also the last one home. - **When a part's explode vector points into a face of another assembly, that assembly comes earlier in the order.** The downcomer drops 84 onto the nozzle's port tee; the nozzle drops 120 and leaves 35 ms earlier, so the port is always below the flange, opening and closing. - **Point every vector away from every face the part touches while the other side is still in place**, not only the two it bolts to: a line threaded between the chamber and the pump must move in the sector both mount normals allow (the Raptor's jacket lines go to −120° with no drop, sliding off both pads), a cable wrapped round a core part may only move in the sector that is outward for its whole wrap (a 90° wrap allows 90°; a wrap over 180° allows nothing, so shorten it or end it on the near side), and a line that would sweep down over a part bolted below it must not drop. - **Things threaded through each other share a spring.** Sense lines that rise through the harness ring into a block, and the harness under that block, cannot have separate springs: with 35 ms between them they shear past each other. The Raptor's sense lines and block ride the controller (`BLOCK_MOVE = PIECES.controller.move`, so `ride` resolves to `controller`) while still highlighting as plumbing. - **Give each line that is mounted on one part only the move of that part.** - **Check the paths a viewer can make**, not only the two from rest: a quick hover reverses the springs halfway (`reversals`), and an assembly that is late on the way back can sweep through a cable that is already home. Reverse at every stagger boundary (`A.reversalsOf`), not at a few round times: the bad windows are about 15 ms wide, and 0.15, 0.3 and 0.5 s missed all of them. Feed those frames to the depth states as well as to the clearance check, and test solids against solids too (`A.solidClearances`): a flange tucked under an overhang on its host met the host's spring tail on the way home, 0.67 deep, with every tube check at zero. ## Turning by hand For a 3D figure (`references/3d.md`): - **Drag.** `controller.angleAt(clientX, clientY, zFace, "dial")` is the pointer's angle on the face's plane, unwrapped across turns. On pointer down, store it and the current target. On move, set target = start + Δ, and capture the pointer once the move passes about 1.5°. The group follows with exponential follow (τ = 0.12 s). The stage keeps `touch-action: pan-y`. - **Keys.** Arrow keys step by one division, Shift steps by ten, Home returns to the index. - **Coupled groups.** Drive them from one value through `follow(values)` (a wheel at a quarter of the dial, a finger chain from one `lift`), so a tool, a drag and the tour all agree. - **Gestures and clips are pure functions of time** (`tapAt(t)`, `breakAt(t)`): the build samples them, the audit walks them frame by frame, and the page can play them backwards to reset with no jump. - **Hook takeover.** Any `window.__isoTurn` call runs `onHold`: stop the tour, and keep values a tool set until the reader acts again. - **Half rate.** A dense figure keeps an average of `controller.set` time and renders on alternate frames above 14 ms, every frame below 9 ms, with time-based motion unchanged. - Reduced motion gets one still frame. Offscreen, the loop stops (IntersectionObserver). ## Arming If an action needs the machine in a certain state (assembled before firing, door closed before spinning), arm first: 1. Set the targets that bring it into that state (`setApart(false)`, `select(null)`). 2. Mark it armed: the button shows `data-state="armed"` and the readout says `closing up · arming`. 3. Start the action's clock only when every spring has arrived and nothing is pending. Never run the action on a half-exploded machine, and never snap the parts home to save time. ## Taking over from the tour When the user acts during the idle tour, stop the tour and leave the state where it is. If your take-over helper resets something (for example, it forces the power on), and the user's action is itself a toggle of that thing, the two cancel out in the same frame and replay an animation. Pass the helper a flag (`takeOver(switching)`), and test every toggle in every phase of the tour, not only the default one. A step key pressed during the tour must step from the value the reader sees, not from the tour's target, which may be far ahead. The ripple tank's keys start from `freq.x` and `gap.x` while touring (`state.tf` and `state.td` otherwise), and `steer()` zeroes both springs' velocity when it takes over, so the value only moves in the key's direction. ## Announcing phases For a machine with phases, add a visually hidden `aria-live="polite"` element and write only phase changes to it: `arming`, `chill-down, venting`, `preburners lit`, `main stage`, `shutdown`, `cold`. Never write per-frame numbers to it. For a continuous control, wait until the input has been quiet for about 700 ms before you announce (the ripple tank's `announce`). Leave the live region empty at load and fill it with the current state on `focus`, so a screen reader hears the state when it arrives rather than a stale line. A button whose label changes ("Ignite" to "Shut down") doesn't also need `aria-pressed`. ## CSS motion - `.iso-line[data-flow]` animates dashes, for things travelling along a path. - `.iso-dots circle[data-pulse]` blinks a dot grid slowly, for an "active" surface. - For hover lifts, wrap a group with `transition: transform 700ms var(--anatomy-ease)` and translate it up on hover. All of these switch off under reduced motion in `ISO_CSS`. -
react.md 3.5 KB
# React and Next.js ## File layout for one figure ``` components/iso/ the kit: iso-kit.ts, iso.css, react/draw.tsx, react/live.tsx components/figures/rig/ model.ts pure model: true numbers, springs, readout text (no DOM) view.ts world layout constants, the projection P (fitProjection), helpers like at(point) geometry.ts every static path, computed once at module load frame.ts paths for the parts that change shape: frameOf(state) -> { glass, coils, needle, ... } art.tsx server component: static parts as JSX constants, passed to the live component live.tsx "use client": refs, loop, pointer and keyboard, aria, writes the readout rig.css scoped tokens and styles (e.g. .rig { … }) figure.tsx <Plate fig title hint readout keys caption><RigArt /></Plate> ``` `examples/test-rig/` follows this layout. It uses `rig.ts`, `view.ts`, `geometry.ts`, `pose.ts`, `art.tsx`, `live.tsx` and `figure.tsx`. ## The server/client split - Geometry is pure TypeScript, so it runs on the server and the client receives finished path strings. No geometry code is shipped unless a moving part needs to rebuild per frame. Then `frame.ts` is imported by `live.tsx` and ships to the client. - `art.tsx` builds the static parts as JSX (`<Solid paths={BASE_ART.slab} tone="mid" />` and so on) and passes them as element props, such as `base`, `rail`, `carriage` and `dial`, to the `"use client"` live component. The live component places them in painter's order and wraps the moving ones in `<g ref>`. - Only the live component and `kit/react/live.tsx` are client code. `draw.tsx` is server-safe. ## The large-page key-warning trap On a big page the server payload is large, and React streams some element props as deferred (lazy) chunks. The client renders `{props.dial}` among siblings. It can't mark a lazy as key-checked, so once the lazy resolves to an element without a key, React logs "Each child in a list should have a unique key", pointing at your live component or at `IsoFigure`. The same figure on a small test page logs nothing, so checking one figure at a time misses it. There are two fixes, and you should do both from the start: 1. **Give the root of every element prop a stable key:** `const DIAL = (<g key="dial">…</g>)`, and `<React.Fragment key="sled">` for fragments. Keys on a single element cost nothing. 2. **Keep `"use client"` components out of server-built art.** Every client component element inside a server tree becomes a lazy reference, which makes all of its ancestors lazy too. If parts of the art need interactivity (hover-to-highlight, for example), make them plain `<g data-part="…">` and handle events with delegation in the live component. Any array you build with `.map` needs keys as usual. ## Styles - Import `iso.css` once, globally or in the figure. Put `className="iso"` on a wrapper (or `<body>`) for the tokens, plus `data-theme="light"` for the light theme. - Scope per-figure tokens to the figure's class (`.rig { --rig-glass-top: … }`) and override kit tokens inside `.iso-plate` only when the card needs it. - Don't put figure tokens on `:root`. ## Readout `useReadout()` finds the nearest `.iso-plate [data-readout]` from its `anchor`, and `say(text)` writes it without re-rendering. Render the resting text on the server so it is right before hydration. ## Hydration Render the rest pose on the server and on the client's first render; start motion only in effects. Don't read `window` while rendering. `asksForStillness()` is safe because it is only called inside effects and loops. -
verify.md 42.6 KB
# Verify Look at the figure the way a demanding reviewer would, before anyone else does. Rendering it is cheap, and guessing is expensive. ## Capture `scripts/capture.mjs` starts its own headless Chrome (set `CHROME_PATH` if it can't find one), loads a file or URL, and clips to `.iso-plate` by default. It also prints the readout, any console errors or exceptions, and whether the page scrolls sideways. ```sh node scripts/capture.mjs page.html --out shots/desk.png # desktop 1440, dpr 2 node scripts/capture.mjs page.html --out shots/phone.png --width 390 # phone node scripts/capture.mjs page.html --out shots/hover.png --hover 0.7,0.5 # pointer at 70% across node scripts/capture.mjs page.html --out shots/tour.png --times 1200,2400,3600 # motion: extra shots over time node scripts/capture.mjs page.html --out shots/keys.png --key ArrowRight,ArrowRight node scripts/capture.mjs page.html --out shots/joint.png --zoom 200,80,220,170 # close-up at 4x, in card px node scripts/capture.mjs page.html --out shots/still.png --reduced # reduced motion node scripts/capture.mjs http://localhost:3000/docs --select "figure:nth-of-type(3) .iso-plate" # one figure on a dev-server page ``` `--select` takes any CSS selector; by default it clips to the first `.iso-plate`. Each `--times` delay counts from the first shot, so `1200,2400,3600` shoots at about 1.2, 2.4 and 3.6 s after it (a capture takes 60–200 ms, so delays closer together than that run late), saved as `name-<ms>.png`. `--key` sends the same key codes as `drive.mjs` (`7` as `Digit7`, `.` as `Period`; write a comma as `Comma`, since the list is comma separated). The other options are `--height` (1000), `--scale` (2), `--wait` (2500 ms before the first shot), `--zoom-scale` (4) and `--theme dark|light`. Read every PNG you make: open it and look. Both scripts start Chrome with WebGL on (`--enable-unsafe-swiftshader --ignore-gpu-blocklist`), so shaders show in `capture.mjs` stills too. Use `drive.mjs` for anything that has to be timed or slowed down. ## Drive `scripts/drive.mjs` runs a scripted session in headless Chrome with WebGL on. Use it for sequences: hover, then click, then type, then wait for a phase. Use it also for slowed-down contact sheets that catch pops, and for the no-WebGL fallback. It prints the readout after each shot, then any console errors and exceptions. ```sh node scripts/drive.mjs page.html '[["wait",1500],["move",".rt-stage",0.7,0.4],["wait",600],["shot","shots/hover.png"]]' node scripts/drive.mjs page.html --actions steps.json --height 1300 node scripts/drive.mjs page.html --actions steps.json --width 390 node scripts/drive.mjs page-light.html --actions steps.json --reduced node scripts/drive.mjs page.html --actions steps.json --no-webgl node scripts/drive.mjs page-light.html --actions steps.json --preload slow.js ``` Options are `--width` (1440), `--height` (1100), `--scale` (1), `--reduced`, `--theme dark|light`, `--no-webgl` (every `getContext("webgl")` or `"webgl2"` returns `null`, to check the SVG fallback), `--preload file.js` (a script that runs before the page's own scripts: counters, stubs, hooks, or `window.__driveSpeed=0.04` to run slowly from the first frame) and `--actions file.json`. Selectors default to the first `.iso-plate`, and fractions run 0..1 across the element. `--theme` (in both scripts) only emulates `prefers-color-scheme`. Kit pages choose their theme when they are built (`data-theme` on `<body>`) and ignore that media query, so to check the light theme, build with `--light` and drive the `-light.html` file. | Action | What it does | |---|---| | `["scroll", sel]` | Scrolls the element into the middle of the viewport. | | `["wait", ms]` | Waits. | | `["move", sel, fx, fy]` | Glides the mouse there in 10 steps, firing `pointermove`. | | `["click", sel, fx?, fy?]` | Clicks, at the centre by default. | | `["drag", sel, fx0, fy0, fx1, fy1, steps?]` | Presses at one point, glides to the other in `steps` (20) moves a frame apart, releases. These are real input events (pointer id 1), so a handler that calls `setPointerCapture` works. Test drag-to-turn with it: `PointerEvent`s dispatched from `eval` have no active pointer, and `setPointerCapture` throws `NotFoundError` and aborts the handler. | | `["key", key, sel?, code?]` | Focuses `sel` (default `[tabindex="0"]`) and presses a key: `"i"`, `"Escape"`, `"ArrowRight"`, `" "`, `"7"`. Digits send `DigitN`, letters `KeyX` and punctuation its proper code (`Space`, `Minus`, `Comma`…). Pass `code` for keys that share a name: `["key", "Shift", null, "ShiftRight"]`, `["key", "Enter", null, "NumpadEnter"]`. | | `["slow", factor]` | Runs the page's `requestAnimationFrame` timestamps and `performance.now` at `factor` × real time: `["slow", 0.05]` is 20× slow motion, so a sheet catches a 100 ms event in many frames. `["slow", 1]` restores real time. | | `["until", js, timeoutMs?]` | Polls a JS expression every 25 ms until it is truthy (default 10 s), and prints a line if it times out. | | `["eval", js]` | Runs JS in the page and prints the result. Promises are awaited. | | `["shot", out, sel?, scale?, [x,y,w,h]?]` | Screenshots an element, or a region of it in CSS px. | | `["sheet", out, every, count, sel?, scale?, [x,y,w,h]?, columns?]` | Takes `count` frames `every` ms apart and tiles them into one numbered PNG. | | `["turn", value, group?]` | 3D figures: sets `window.__isoTurn` (a number sets the first turn group, `group` names another, an object sets several) and waits two frames. | | `["orbit", out, from, to, step, sel?, scale?, [x,y,w,h]?, columns?]` | 3D figures: a contact sheet across angles of the first turn group, each frame labelled. `["orbit", "swap.png", 129.7, 130.2, 0.1, null, 6, [x, y, w, h]]` is a 6× close-up of one order change. | | `["lines", out?, sel?, { tolerance }?]` | The line-end check below, in whatever pose the actions before it left the figure (apart, mid-stroke). `out` writes the card with every floating end ringed. A failure makes the run exit 1. | Traps: - **Shots are correct after `scroll`**, so you can scroll to a figure lower on a page. A viewport tall enough for the whole card (`--height 1300`) is still the simplest: the figure is visible at scroll 0, and its `IntersectionObserver` starts it. - **Frame spacing in a sheet is `every` plus the capture time** (60–200 ms, more on heavy WebGL pages, where capture also stalls animation frames). For transitions faster than that, put `["slow", 0.05]` before the sheet and `["slow", 1]` after it. `wait`, `every` and the page's `setTimeout` timers stay in real time, so at 0.05 a sheet every 100 ms is 5 ms of page time apart, and an idle tour's resume timer still fires on the wall clock: a key press holds it off for only about 3.6 s of real time, so press a harmless key again before each slowed sheet. An event that starts on load needs the page slow from its first frame: `--preload` a file with `window.__driveSpeed=0.04`. - **Slow mode doesn't slow timers.** `setTimeout` runs on the wall clock, so during a long slowed capture an idle tour's resume timer (the Raptor's 3.6 s `quietTour`) can restart the tour and close the engine mid-sheet. Before every slowed sheet stop the tour and wait for rest: preload a stub for that timer (`const st = window.setTimeout; window.setTimeout = (f, d, ...a) => (d === 3600 ? 0 : st(f, d, ...a));`), press `Escape`, `["until", "document.querySelector('[data-readout]').textContent.includes('cold')"]`, and only then the key that starts the transition. A sheet labelled "opening" that starts from a tour that has already opened films a close. - **Wait for a phase, don't guess.** `["until", "document.querySelector('[data-readout]').textContent.includes('main stage')", 8000]` returns as soon as the readout reaches the phase. - **A `null` selector is the default.** In every action a missing or `null` selector means the first `.iso-plate` (for `key`, the focusable `[tabindex="0"]`), so `["sheet", "s.png", 200, 16, null, 0.5]` films the card. - **Long eval strings break inline JSON quoting.** Put anything with `eval` or `until` in `--actions steps.json`. - **Pages inline their live code as `<script type="module">`,** so top-level variables aren't visible to `eval`. Read state through the DOM instead: readout text, `aria-valuetext`, `data-*` attributes. To count frames or read a value per frame, install a hook with `--preload` (it runs before the page, so it can wrap `requestAnimationFrame` or `HTMLCanvasElement.prototype.getContext`). ## Line ends The audit sees recorded shapes, never line markup, so it can't see a seam, rule, rib or tick that stops in the middle of a face. `scripts/lines.mjs` checks the drawing itself, in headless Chrome: ```sh node scripts/lines.mjs page.html --out shots/lines.png node scripts/lines.mjs page-light.html --out shots/lines-light.png --reduced node scripts/drive.mjs page.html '[["key"," "],["wait",2000],["lines","shots/lines-apart.png"]]' ``` ``` line ends: 3 of 1148 ends on 295 lines float iso-line faint in it k -0.080 end 7.25 px from anything at card 507.4,407.2 length 75.7 d M392.1 381.7L507.4 407.2… ``` Every open subpath of every `.iso-line` in the card (seams, grooves, ribs from `fadedSvg`, rule lines, ticks, needles, pointers, wires) must end within 1 viewBox px of another stroke, a dot or an outline, or under a fill painted after it. A short straight mark (up to 7 px: a tick) needs one end rooted within 2 px; both ends floating fails. The kit's own outlines, creases, bevels and tube edges aren't checked, and neither are ends that a `fadeLineSvg` ramp fades out. `lineSvg(d, { free: true })` (or `"start"`, `"end"`) marks an end that is meant to stop: rule lines that stand for printed text, a needle's tail, a pointer's tip. Use it for nothing else. It prints each floating end in card px, ready for `capture.mjs --zoom`, with the start of its `d` so you can find it in the build; `--out` rings them on a 2× shot of the card. Run it on both themes and in every pose the figure rests in, and fix every line until it prints 0: | You see | Fix | |---|---| | A seam or weld line on a box inset from the corners | `k.sideSeam(plan, z, P, steps)` as the slab's `inner`: it wraps the corners and ends on the silhouette (`craft.md`, section 3). | | A stiffener or panel line on one face, inset at both ends | Run it edge to edge: from the face's bottom to its top, or from end to end. | | Ribs that stop short of their seams | `ribsOf(…, { seams: true })`, or `s0`, `s1` on the band's ends. | | Ticks floating on a bare plate or hanging below an edge | Put them on a ruler strip, rooted on its edge (`topTicks` `edge`, `sideTicks` `top` on the face's top edge). | | Vent or grille lines | Closed slots: a thin rounded outline, not a line. | | A line that runs under a part and stops just past it | Draw it before the part, full length, and let the part hide it. | | Rule lines on a label plate | `free: true`. They stand for text. | ## Audit Screenshots catch what you think to look at. A figure with pipes, cables and round parts also needs the geometry checked, because a cable through a duct or a part drawn over the thing in front of it is invisible at 1× and obvious at 4×. Wire `kit/audit.mjs` into the build (`kit.md`, section 12): helpers put into one `A.recorder(P, { order, spring })`, the build calls `A.settle(R, P)` and ends with `A.auditOrExit(R, P)`, then run: ```sh node examples/raptor-engine/build.mjs --audit ``` ``` clearance: 0 problems clearance apart: 0 problems terminals: 0 ends not on a mount loose ends: 0 screen folds: 0 support: 0 unsupported solids · 0 hanging tubes · 0 sunk pairs depth order: 0 problems through in mid-flight: 0 pairs self-overlap: 0 chunks whose outline crosses itself on screen wobble: 0 smoothed profiles whose slope turns between its ends saddles off their host apart: 0 stand-in shapes: 0 items whose recorded shape covers too little of what it draws crowding: 0 pairs where a bend or end touches another line on screen free ends apart (marked free, not failing): 35 audit · 80 tubes · 267 solids · 478 items · 1119 depth states · 1707702 overlaps · 4292 order rules · 151.9 s audit passed ``` It prints `audit passed` or `audit failed: n problems` and exits non-zero on any failure. Every line counts, including what happens in mid-flight and `items with no shape`, except two lists that are there to be read: `in passing` and `free ends apart`. Depth order and pass-through (tube against everything, and solid against solid) are both checked on the same replayed frames: every frame of the springs opening and closing until they settle, every frame of a reversal 8 ms either side of each stagger boundary (`reversalsOf`: 0.027, 0.043, 0.062, 0.078 … 0.393 s on the Raptor's 35 ms stagger), and the prefix states (`prefixStates`). Depth uses every fourth of those frames. How to read and fix each line: | Line | Meaning | Fix | |---|---|---| | `through A × B clearance -2.4` | Tube A intersects B over that arc-length span. | Reroute A round B with about 1.5 radii to spare, or move B. Only the shape A's end rim sits on is excused, and only within a radius or so of that end. | | `close A × B clearance 0.2 < 0.6` | A passes B with less than half its radius to spare. | Nudge the route; widen harness lane spacing. | | `clearance apart: through share 1 …` | In the apart state a tube passes through something. | Change that assembly's explode vector. | | `terminals: A end 1.40 from B (rim -1.1…1.3)` | A's end, or the rim of its end flange, doesn't sit on its mount: it floats, it is tilted, or the flange is wider than the boss under it. | End the route exactly on the surface, along the surface normal; make the boss at least as wide as the flange; on a curved body seat the flange on a saddle pad. A cable never ends on a clamp. | | `loose ends: share 1 A end touches nothing that moves with it` | Apart, the end hangs in the air: nothing of another owner moves with it (its own flange or shell doesn't count). | Give it a mount that moves with it, or, if it is meant to come off (a flanged duct, a connector on another assembly), mark it `free` so it is listed under "free ends" as a choice. | | `self-overlap: A:3 its outline folds back on screen` | One chunk's outline crosses itself: the line turns on a screen radius under its own half-width (a leg pointing at the viewer that turns near a port). | Leave the port through an elbow ball (the Raptor's `elbow`), give the turn more straight leg, or bend in a plane that faces the viewer (the Raptor's `sideOn`, `craft.md`, section 8). | | `wobble: A dr/ds turns at s = …` | A profile smoothed from typed knots has a slope that plateaus and reverses, so its tone bands staircase. | Give it an analytic `r(s)` and pass `smooth: { slope }`. | | `saddles off their host apart: A (piece) is cut to B (host piece)` | A saddle pad flies off with another assembly, showing its cut base. | Give the pad to the host's assembly; the moving pipe leaves its flat face. | | `solid state A × B sunk 1.81 deep` | Two solids of different assemblies pass through each other in that frame. | As for a tube: fix the order or the vectors, or open the gap under an overhang. | | `screen folds: A turns 150° on screen for 78° in 3D` | The route doubles back on screen and reads as a cusp. | Route the leg before the turn sideways on screen. | | `screen folds: A turns 110° on screen round a 0.4 px bend` | A visible bend pinches to a point: its fillet is seen edge-on, so the two legs meet in a V. | Turn inside a fitting (a ball tee, the line leaving at 45°), bend in a plane that faces the viewer, or move the port so its normal lies across the screen. | | `through in mid-flight: apart 0.233 s A × B clearance -1.6` | In that replayed frame A passes through B. | Fix the order or the vectors (`motion.md`, "Order the stagger"): the part outside leaves first, a vector that points into another assembly's face needs that assembly to leave earlier, threaded parts share a spring. | | `crowding: A bend × B outlines overlap 0.05 px` | A's bend or end lands on B's outline on screen and reads as a joint or a line threaded through B. | Reroute so the meeting is a clear crossing of two straights, or leave a visible gap. Excuse a pair (`allow`) only after looking at it at 8×. | | `crowding: A end × B (rod)` | A's end or bend lands on the outline of a slender solid: a tie rod, piston rod, post, rail or handle (any solid longer than 4× its radius or half-width). It reads as the rod plugged into A's fitting. | Approach the port across the rod, or turn the elbow so the last leg lies across the screen. | | `stand-in shapes: A its shapes cover 4% of its drawing` | The shapes recorded for A cover much less of the screen than A draws, so the audit checks a sliver and says nothing about the rest. | Record the true shape of what you draw (`A.slide` for a rod that slides out of a gland). Excuse an item (`cover: [name]`) only when its drawing really reaches past its body, and only after looking at it. | | `crowding: C fitting × B` | A clamp lands within 2 px of another line and reads as that line growing out of it. | Change the explode vector or the clamp's angle. | | `support: loose A` / `hanging A` / `sunk A × B 1.2 deep` | A solid, or a rigid tube, touches nothing that leads to the stand, or is buried in B. | Add the bracket, stud or plate that holds it; for a sunk part fix the contact (rim on surface, pad cut by its host marked `seated`; `seated` only excuses the host in its `cut`). | | `order A is in front of B but drawn first` | A's surface is nearer wherever they overlap, but B paints over it, at rest or in a mid-flight frame. | `orderKeys` normally fixes these; one left over means a rule was dropped from a loop, so look for the interlocked pair in that loop. | | `cycle A … (n the other way)` | A and B are each in front of the other somewhere: they interpenetrate on screen. | Change the geometry: gap the pipe at its collar, split the band at the ring, end the rod on the ball, make the volute tangent, put the valve on a straight, keep a pad off its host's silhouette. | | `in passing` | Interlocks, or can't be ordered by any `k + lift · share`, in a uniform state only (all assemblies at one share, which the page never shows). | Not a failure. The same thing in a replayed frame is a `depth order` problem. | | `items with no shape` | Something is drawn without a recorded shape, so nothing checks it. | Record it in the helper that draws it. | A zero audit is necessary, not sufficient: it can't see a seam, a staircase or a texture that stops, and it only checks what you recorded. A stand-in shape that silences it is a bug, which is why it fails one. Run the line check, then look. ## Orbit (3D figures) A figure with turning groups (`references/3d.md`) adds the orbit audit and the browser checks. Run them on the light page and the dark page. ```sh node build.mjs --light --audit > audit.txt 2>&1 & # the full run, in the background: ends "turn audit passed" node build.mjs --light --audit --quick # ≈ 10× fewer states while iterating: ends "turn audit quick" node build.mjs --light --audit --quick --only=order,continuity # a subset: the rest print "skipped", ends "turn audit partial" node build.mjs --light --verify # a page with window.__isoVerify (recorded shapes) for --order node ../../scripts/turn-check.mjs build.mjs --fidelity node ../../scripts/turn-check.mjs page-verify-light.html --order --step 1 node ../../scripts/turn-check.mjs page-verify-light.html --order --states clip.json # hinges and clips: --step turns the first turn group only node ../../scripts/turn-check.mjs page-light.html --pops --no-webgl --states tilt-path.json # one hinge as a continuous path at 0.02° node ../../scripts/turn-check.mjs page-light.html --pops --no-webgl --step 1 --out shots/pops node ../../scripts/turn-check.mjs page-light.html --lines node ../../scripts/turn-check.mjs page-light.html --perf --width 1440 node ../../scripts/turn-check.mjs page-light.html --perf --width 390 --throttle 4 node ../../scripts/drive.mjs page-light.html '[["orbit", "shots/orbit.png", 0, 345, 15]]' ``` **Time.** A 28-part figure with one turn, one hinge and one clip took 85 s quick and 17 minutes full, past a 10-minute shell timeout: run the full audit in the background. Quick runs undersample (`continuity` checks 100 halving candidates instead of 800, and the order scan a tenth of the states), so they guide, and only the full run's `turn audit passed` counts. A run with `--only` ends `turn audit partial`, never `passed`. **States.** The audit sweeps each turn group at 0.3° (order) and 0.05° (continuity), plus 2000 random states and every order-change event ± 0.001°. Motion clips (`clips: [{ name, at(t), from, to, frame }]`) are walked frame by frame: a gesture, a grasp, a break-apart. Free and hinge groups are proved only on the states you give, so give every clip, at several turn angles. **Lines.** `separation`, `unseparated`, `cycles`, `forced`, `swaps`, `layers`, `rigid`, `swept`, `posed`, `folds`, `self`, `crowding`, `endon`, `coverage`, `depth`, `continuity`. `3d.md`, section 13, has each line's failure and fix. A planted fault fails each line: a sunk pad (`separation`), a pinwheel of three tilted sticks (`cycles`), a fibre bent in a vertical plane (`folds`), a tube at 30° (`endon`), a rib row that stops (`continuity`). **Browser checks** (each runs its control first; a control that doesn't fail means the check is broken): - `--order`: the recorded shapes in a WebGL z-buffer against the DOM's fills: 0 live pairs with 4+ interior pixels at every angle and clip state. - `--pops`: first difference at every degree (θ → θ + 0.02°) and second difference at 0.1° over a full turn: 0 surviving regions, 0 cores of 6+ px. Read the 1–5 px cores it lists. - `--lines`: 0 floating ends at 24 angles. - `--perf`: the budgets in `3d.md`, section 15, at 1440 and at 390 with 4× CPU. **Orbit sheets.** 0–345° at 15°, then 0.1° steps across the three largest order changes (the audit lists them under "order changes"), then every clip slowed (`["slow", 0.05]`). Read them for spider angles (a hand that reads as legs from some side), parts that vanish behind others, and lines that end on another part's outline. ### Checklist for 3D figures - [ ] `turn audit passed` with the full state set (not `--quick`, not `--only`), clips included. - [ ] `--order` on `--states` for every hinge and clip, `--pops` on a 0.02° path for every hinge (`3d.md`, section 14). - [ ] `turn-check` order, pops and lines at 0, fidelity passed, every control failing as designed. - [ ] Orbit sheets read at 15°; 8× crops of every knuckle, crossing and texture end at 8 angles, light and dark. - [ ] Materials read as metal on the light page: highlight bands move with the turn, nothing pops at a tone crossfade. - [ ] Every shader anchored to its group follows it at 0.1° steps; world effects don't turn; ink stops at occluding strokes (8× crop where a cover mask meets a part). - [ ] Budgets measured at 1440 and 390 × 4, with the half-rate lever if a dense figure needs it. ## Close-ups The owner found the Raptor's faults on a close-up of the light theme: cables over the main duct, a stepped bell, bolt rows that stopped dead. Shoot that close-up yourself, everywhere, before anyone else does. 1. **Build both themes** (`node build.mjs` and `node build.mjs --light`) and list the junctions: every flange, tee, elbow, ball joint, clamp, valve, bellows, band, saddle pad and crossing, every curved body (bells, throats, domes, cones, valve bulges, balls), every band joint of a body cut with `bandsOf`, and every place a texture ends. On flat parts too: every seam, weld, stiffener and trim line on a box face (does it reach both corners?), every row of ticks or marks on a plate (is it on a ruler strip?), every rib row's two ends along its axis, every textured hose at a chunk joint and at its ferrules, every rod that slides (at rest, mid-stroke and at full stroke), and every window or screen with text or shader content in it. 2. **Shoot each area at 4× in both themes, assembled.** `--zoom x,y,w,h` takes a region in card px and scales it by `--zoom-scale` (4): ```sh node scripts/capture.mjs page.html --out shots/joint-dark.png --zoom 200,80,220,170 node scripts/capture.mjs page-light.html --out shots/joint-light.png --zoom 200,80,220,170 ``` 3. **And apart**, if the figure comes apart. Drive it there, wait for rest, then shoot a region at scale 4. The Raptor toggles apart on Space and its readout starts `apart ·` only once every spring has arrived, so `apart.json` is ```json [["key", " "], ["until", "document.querySelector('[data-readout]').textContent.startsWith('apart')"], ["wait", 400], ["shot", "shots/apart-joint.png", null, 4, [200, 80, 220, 170]]] ``` run with `node scripts/drive.mjs page.html --actions apart.json`, then again on `page-light.html`. 4. **Look at each PNG for these faults, in this order:** a line that runs through or into something; a crossing whose nearer line is drawn behind; a bend, end or clamp sitting on another line's outline; a flange standing off its port or sunk into it; tone blocks stepping across a curved surface, scallops or a pale wedge; a band ending on a straight cut; a row of ribs, dots or rule lines stopping in the middle of a surface; a hairline across a tube's shine or shade at a chunk joint; a seam notching the outline; a seam, stiffener or rib that stops short of the edge it should reach; a short mark on a top that touches nothing; a hose texture with straight hard sides, or one that restarts or is cut at a chunk joint; a rod whose outline breaks while it moves; text that crosses its window; shader content that spills past an opening's rim. Compare with the previous round's shot of the same region. 5. **Shaders:** the same regions during the effect, plus contact sheets slowed with `["slow", 0.05]` (`webgl.md`, section 9). **Detail pass.** Last, sweep at 4× in a grid of crops (the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic; `SKILL.md`, workflow step 1) and look for any face, corner or stretch of base with nothing true on it. Every figure is highly detailed (`SKILL.md`, "Every creation is highly detailed"): add the real parts that belong there (screws, seams, bevels, label plates, scales, the brackets that hold things), never decoration that belongs to no part. ## Light-theme shaders Light is the shipping theme, and on paper a glow is ink. Every light conversion so far failed review first on something a picture of one frame doesn't show: ink that rose while the light fell, a pale flash before the glow, a salmon stage, a fringe beside a stroke. So measure the ink as well as looking at it (`webgl.md`, section 8). **Ink per frame.** Save this as `ink.js` and pass it with `--preload`. For every frame the page draws, it logs the mean ink one canvas region lays on white, `255 − lum(rgb + 255 − a)`, read back in the same task as the draw: ```js const get = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function (kind, ...rest) { const gl = get.call(this, kind, ...rest); if (gl && String(kind).startsWith("webgl") && !this.__gl) { this.__gl = gl; for (const name of ["drawArrays", "clear"]) { const call = gl[name].bind(gl); gl[name] = (...args) => { gl.__drew = true; return call(...args); }; } } return gl; }; const raf = window.requestAnimationFrame.bind(window); window.__ink = []; window.requestAnimationFrame = (callback) => raf((now) => { callback(now); const canvas = document.querySelector(window.__inkCanvas || "canvas"); const gl = canvas && canvas.__gl; if (!gl || !gl.__drew) return; gl.__drew = false; const [x, y, w, h] = window.__inkBox || [0, 0, canvas.width, canvas.height]; const px = new Uint8Array(w * h * 4); gl.readPixels(x, y, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); let ink = 0; for (let i = 0; i < px.length; i += 4) { const bare = 255 - px[i + 3]; ink += 255 - (0.2126 * (px[i] + bare) + 0.7152 * (px[i + 1] + bare) + 0.0722 * (px[i + 2] + bare)); } window.__ink.push(Math.round((ink / (w * h)) * 100) / 100); }); ``` - **Choose what to measure.** Before the transition, set `window.__inkCanvas` (a selector) and `window.__inkBox` (`[x, y, w, h]` in device px of the canvas, measured from its bottom-left; scale CSS px by `canvas.width / canvas.clientWidth`) with an `eval`. Read the rows afterwards with `["eval", "window.__ink.join(' ')"]`. - **Do it once per region that matters:** a cavity, the core, an area only the fallback covers. Run it slowed, across first light (`--preload` a second file with `window.__driveSpeed=0.04`), every phase change and the fade-out. - **What to look for.** Ink follows the physical quantity monotonically: up as the light comes in, down as it goes. There is no hump at a hand-off and no dip at first light. - **What it caught.** The afterburner's cavity peaked at 3600 on the cut before its fix; afterwards it fell monotonically from 1894 to 155. Those rows are in that figure's own units, a sum over its window region rather than this hook's mean. On the ripple tank's light page the hook reads 14.2 at rest and falls as the lamp comes in, because that page's light brightens the paper. **Colour along the ramp.** For anything that heats or cools, `eval` a pixel read at the centre and the rim at several temperatures (or several temperatures' worth of a slowed run) and write them down. The order must be dull red → orange → yellow → near-white, with the rim cooler than the centre and no salmon or grey stage on the way. **Cross-sections.** Read one row of ink across a flame, a jet or a stream. It peaks on the axis and falls to translucent edges. An edge-heavy profile is a hollow, outlined flame. **Strokes at 8–16×.** Crop every place where ink meets an SVG stroke (cut faces, rims, mouths, creases, a canvas edge with a body entering) at 8–16× in light. Look for a pale gap between the ink and the line, ink over the line, or a stroke the ink cleared. **Both paths, and nested.** The dark build should match its baseline, because every light change is gated on `uLight`. Render a dark plate on a light page and a light plate on its own, each with and without WebGL: a `:not([data-theme="dark"])` rule can reach through the page into a dark plate. ## The review checklist **It is an object** - [ ] At a glance it reads as one real, well-made thing, and you can name it. - [ ] Its mechanism maps to the concept: what moves and what that means can be said in one sentence. - [ ] It isn't a stack of abstract plates and isn't one plain slab. **Parts and volume** - [ ] 30–60 solids for an inline figure; a hero subject that really has more parts can go far past that (the Raptor has about 270). Every part rests on another and has a job. - [ ] Sides shaded and tops lighter; bevels on the main slabs; a halo under the base and under lifted parts. - [ ] Small details belong to parts: screws at the corners of plates, grooves, rulers, knurls, label plates. **Lines** - [ ] At 4×, every line ends on a face, another line or a terminal dot. Nothing stops in the air. - [ ] No leader lines, balloons, dimension arrows or floating labels; at most one engraved label. - [ ] Painter's order is right everywhere: nothing behind shows through, springs and frames are split. - [ ] **Audit zero.** With pipes, cables or round parts in the figure, `node build.mjs --audit` ends `audit passed`: zero on every failing line, assembled, apart and in every frame of the replayed explode (opening, closing and reversing). No interlock was hidden with a key bias, and every excused pair (`allow`, `crowd`) has been looked at at 8×. - [ ] No pipe or cable passes through another pipe, cable or solid, and every crossing is drawn in depth order: the nearer line on top, crossing on straights, well apart in depth. - [ ] No line runs across the subject's face where it could run beside the structure; no bend pinches to a V on screen; no bend, end or clamp lands on another line's outline, assembled or apart. - [ ] Every flange sits on its port (on a saddle pad if the port is curved); every collar, clamp, band, bellows and valve gaps the pipe it wraps; every valve and bellows sits on a straight; no route doubles back on screen; every end, apart too, ends on a flange, coupling, connector or terminal dot. - [ ] **Junction close-ups.** Every junction area shot at 4× in both themes, assembled and apart (see "Close-ups"), and each PNG read. - [ ] **No staircase.** Every curved surface is smooth-shaded at 4×: no stepped tone blocks, no scalloped terraces, no pale wedge, no band that ends on a straight cut, no kink at a band joint; waisted bodies follow their true outline; seams meet the outline without a nick. - [ ] **Line ends zero.** `node scripts/lines.mjs` prints `line ends: 0` on both themes and in every resting pose; every `free` end is a rule line standing for text, a needle tail or a pointer tip. - [ ] **Shapes are true.** No stand-in shapes: what is recorded is what is drawn, including rods that slide. - [ ] **No abrupt texture ends.** Every row of ribs, bolt dots, rule lines or grooves ends on a real edge or fades out; none stops at full strength in the middle of a surface, flat faces included. Seams on boxes wrap their corners, rib rows end on their seams, ticks sit on a ruler strip, and a texture on a hose follows its cross-section and runs through every chunk joint. Pipe stripes run onto their end caps and show no hairline at any chunk joint, also on a strongly curved tapered tube (a volute). **Tone and colour** - [ ] Base `lo`, structure `mid`, subject `hi`, exactly one `lit` accent. - [ ] Monochrome unless colour is the subject. **Card and words** - [ ] `Fig n` / title / hint / live readout are in the corners, with legend keys and a caption under the card. - [ ] Every number is true and comes from the model. The readout changes as things move, and its words and numbers come from the same instant: a phase word from the model next to a number from a lagging gauge reads `relief 85 bar` before the valve has cracked. - [ ] Text the machine displays fits its window at its widest string and is clipped to it. **Motion and access** - [ ] Eased, calm, no snapping; an idle tour; pauses on interaction. - [ ] Stops offscreen; still under `--reduced`; no console errors (including React key warnings). - [ ] Keyboard works, the focus ring shows, and `aria-valuetext` reads the state in words. **Shaders (if the figure has one; `references/webgl.md`)** - [ ] A contact sheet across every transition (start, each phase change, steady, stop), slowed with `["slow", 0.05]` (or `--preload` with `window.__driveSpeed=0.04` for an effect that starts on load) so every frame of each crossfade shows: no sudden colour switch, no one-frame flash, nothing cut out, and brightness never rises during a fade-out. - [ ] 2× and 4× crops where the effect meets the SVG: no seams, hard edges, banding, dark collars or double lines; things on a plane register with that plane's lines. - [ ] The canvas's outer rows and columns read alpha 0 in every phase. - [ ] Without WebGL (`--no-webgl`) the SVG alone still reads; under reduced motion you get one still, representative frame; the loop stops when idle and offscreen. - [ ] **Light theme** (`webgl.md`, section 8; "Light-theme shaders" above): - Glows read through saturation, with a saturated body, a pale core and a coloured rim, never as dark or brown ink. - What the light falls on is tinted, and the cavity that holds the light is a darker physical ground. - Hot bodies run in blackbody order with no salmon stage. - Smoke and steam are light grey. - There are no halos in empty air. - Ink per frame follows the light monotonically through first light, every hand-off and the fade-out. - Ink stops at each stroke's inner edge at 8–16×. - The fallback shows the same inks and ground, and hands over to the shader without a dip. - Dark is unchanged, and a dark plate on a light page still works. **Composition** - [ ] Centred, filling about 55–65% of the card; it doesn't touch the corner labels. - [ ] Still readable at 390 px with no sideways scroll, with a part selected and during a burn, not only at rest: the longest readout must not run under the controls. ## Common faults and fixes | You see | Fix | |---|---| | A part shows through something in front of it | It is painted too late. Move it earlier, or split the front part. | | An ellipse shows across a post where it meets a face | A full `ring` drawn over a cylinder that continues shows its back half. Use `sideArc` (front half only), or let the cylinder's own outline make the edge. | | A post looks pasted onto the block it passes through | Draw it in two segments, below and above, with the block painted between them. | | A crease or bevel stops short of a corner | Use `bevel` on `slabOf`/`cylinder`. It already ends on the silhouette. Don't hand-draw edge lines. | | Flat, dead sides | You passed `flat`, or the solid has height 0. Give it thickness. | | Too sparse | Add structure, not texture: feet, rails with rulers and end stops, a label plate, screws, a knob, a second stage. | | Too busy | Drop texture-like repeats and tone grooves down to `faint`; keep one lit accent. | | The figure is tiny in the card | Fit with a smaller `pad`, or tighten the points passed to `fitProjection`. | | It jumps when a tab regains focus | Clamp `dt` to 1/30 s. | | Key warnings only on the big page | See `react.md`: key the roots of element props. | | A shader colour jumps at a phase change | Give colour its own slow envelope, and drive it from the physical quantity, not from a phase flag. See `webgl.md`, section 7. | | A straight edge in the glow, often at the canvas border | A term isn't zero at a boundary. Window it to zero before every `if`, bound and canvas edge. | | A glow runs through the floor or over a column | It is in screen space. Rebuild it in world space with the ray and the ground hit, and give simple parts to the shader as occluders. | | A dark collar under a glowing rim | The rim is normal-blended over the shader. Move it to an overlay SVG with `mix-blend-mode: screen`. | | Flat bands in soft glows | Add gated triangular dither after `tonemap`. | | In light, a glow reads as dark red or brown smoke | It is the dark colour darkened, with alpha set to brightness. Solve the ink from a paper ramp with a saturated body, a pale core and an alpha floor (`webgl.md`, section 8.1). | | In light, first light goes pale and then dark, or a fade-out greys or gets heavier | Per-term fades, an alpha-only fade, or a hue that reddens at fixed alpha. Fade the finished ink once, dim energy and alpha together, and take the hue at steady brightness (`webgl.md`, section 8.2). | | In light, a pale fringe or white seam along an edge | The ink stops short of the stroke, or clears a crease nobody strokes. End it at the stroke's inner edge (0.3 vb) and draw the crease in ink (`webgl.md`, section 8.6). | | A dark plate's fallback vanishes on a light page | A `.iso:not([data-theme="dark"])` rule matched through the page. Put the dark rule last, or key on the plate's own theme (`webgl.md`, section 8.5). | | A pipe or cable looks pasted on or cuts through a part | Its pieces are in one layer. Depth-sort each `tubePieces` chunk by `mid`, and use shorter pieces where it weaves. If it really passes through the part, reroute it; `--audit` lists every case. | | Cables or sense lines fan across the subject's face and over a main duct | They were routed point to point. Re-route along the structure: up beside a pipe, round the body in clamped lanes, round the side to a far port (`craft.md`, "Choosing waypoints"). | | Fixing one crossing's order breaks another, or the apart pose | Keys tuned by hand. Record shapes, let `A.settle` set the keys, and fix whatever it reports as interlocked in the geometry. | | A collar, band or clamp hides the pipe's near side, or the pipe paints over the collar's face | The pipe runs through it as one chunk. Gap the pipe at the collar (`gaps`), so a piece ends on each face. | | A ring on a body shows its back half over the body | The body is one long band under the ring. Split it at the ring. | | A valve or bellows cuts into its pipe at the ends | It sits on a bend. Move it to a straight at least its own length plus both fillet cuts. | | Stepped tone blocks on a bell, dome or cone | Facet shading. Mark the curved knots smooth (`[s, r, 1]`, shaded smoothly by default) or pass `smooth: true`; cut a body into bands with `G.bandsOf`. | | Tone bands on a throat, cone or puck stall and lurch in scallops | A smoothed profile of hand-typed knots: its slope plateaus at each knot. Write the profile as an analytic `r(s)` and pass its derivative as `smooth: { slope }` (`wobble` lists them). | | Hairline seams inside a smooth-shaded part | The tone sheets abut. Use the kit's `solidSvg` with `seamless` paths (smooth lathes and `sphereOf` set it) and `ISO_CSS`. | | A row of ribs or bolt dots stops in the middle of a surface | A hard `least` cut-off. Use `fade: [lo, hi]` on `ribsOf` or `dotsOf`. | | A pale wedge with straight edges on a smooth-shaded cone or bell | Bare fill between tone pieces: pieces of one tone wound in opposite directions cancel under the nonzero rule, or a band ended without tapering. The kit orients every tone polygon and tapers bands; if you build tone paths yourself, orient them. | | A narrowing body looks like a straight cone with a pale strip | It was hulled. Let `lathe` pick `fill: "auto"` (or pass `"surface"`). | | A hairline crosses a tube's shine or shade band | Body and stripe cut on the same line at a chunk joint. `tubePieces` runs the stripes past the body; keep that if you change it. | | The outline is dashed where seams meet it | Seams drawn after the solid. Pass them as `inner`. | | A pipe's stripes stop short of its end | An end without a cap. `tubePieces` caps every end by default; don't pass `caps: false` for an end that shows. | | Parts slide along their mount while the figure comes apart | They move with one assembly's vector on another's spring. Give the item that assembly's `ride`. | | A long turned body has a kinked outline at band joints | Build it with `G.bandsOf` and draw the sides once with its `sidesOf` (`kit.md`, section 10). | | A texture line stops in the middle of a face | End it on a real edge, or draw it with `fadeLineSvg(points, { fade })` so it ramps out. `scripts/lines.mjs` lists every one. | | A seam on a box stops short of its corners | `k.sideSeam(plan, z, P, steps)` as `inner`. | | A hose braid or corrugation with straight hard sides, or broken at chunk joints | `tubePieces(…, { rings: { pitch, twist, cross: true } })`. | | A rod's outline notched while it slides | One rod from `G.rodOut`, clipped at the gland, recorded with `A.slide`; never overlapping copies. | | LCD or counter digits cross the window | Fit the type to the window's height and widest string, and clip it. | | Stray triangles of outline across a valve or bulge | An old kit joined a broken silhouette; the kit now falls back to the hull when a side's silhouette has gaps. Rebuild with the current `lathe.mjs`. | -
walkthrough.md 5.2 KB
# Walkthrough: the test rig (Fig 6) How `examples/test-rig/` was designed. It is a figure for a "Stretch" section explaining how a glass button responds to touch: a press swells it, pulling makes it lean toward the finger (with less give the further you pull), and letting go springs it back past rest once. ## 1. Truth The behaviour was copied into a pure model, `rig.ts`, from the library source: - The rest size is 120 × 32 (`HALF = [60, 16]`). - A press swells it by 8%, at most 6 px a side. - The lean is a rubber band: `give · (1 − 1/(1 + 0.55·d/give))`, with `give` = 14 px. - There are three springs: press 700/32, hold 540/40 and release 320/16, as stiffness/drag. - The capsule stretches along the lean by at most 8% and thins across. `step(member, held, pull, dt)` advances it and `poseOf(member)` returns the shape. The readout, the aria text, the caption numbers and every moving part read from this one model. ## 2. Object "A test rig." A real lab would hold a button in a sprung fixture and measure it with a probe and a dial gauge. Each part carries meaning: - **The glass capsule** is the subject. It is lit while held and its shape is rebuilt every frame from the pose. - **A carriage on two guide rods with four coil springs** shows that the glass is springy and returns. The springs compress and extend as it leans. - **A finger probe on a rail behind it** is the pointer. It comes down to press, then slides along the rail to pull. A ruler and a rider on the rail show how far it has pulled. - **A dial gauge with a plunger touching the carriage** reads the lean. Its needle turns 20 px per revolution, with a mark at `give`. - **A base plate on feet** carries a frame of four bars with a ruler and a zero mark, screws, a label plate, a floor grid and bores for the rods. ## 3. Parts list (53 solids) | Group | Parts | |---|---| | Base | plate, 4 feet (flange and foot), groove, 4 corner screws with rings, label plate with screws and rule | | Rail | 2 posts on feet, the beam with a ruler (minor every 5, major every 25), 2 end stops, screws, a rider | | Probe | block, thumb knob with knurl, pointer, index line, arm, housing with cap, collar, seams, lock screw, stem, collar, pad | | Frame | back, left, right and front bars with screws, floor with grid, rod bores and ends, ticks with a zero mark | | Mechanism | 2 rods, 4 coils (split back and front), the carriage plate with a seat outline, 4 bushings, rivets, a contact block, a plunger rod with a collar | | Dial | stand with screws, sleeve, clamp, lug, puck, knurled band, bezel and face rings, ticks, give marks, crown, a "px" engraving, the needle | | Subject | the glass capsule (fill, shades, top, crease, bevel, inner rim, shine, edge) and the dotted rest outline | ## 4. Camera and layout (`view.ts`) - **Camera.** Azimuth 57°, so the long front of the frame faces the viewer. The viewBox is 600 × 340, fitted to the base and the top of the rail with a pad of 34. - **Layout.** The rig is laid out around the origin. The glass is centred at (0, 0) on the carriage top, the rods run along x at y = ±18, the dial stands at x = 131 and the rail sits behind at y = −66, z = 50. - **Helpers.** `at(point)` projects a point and `shift(x, z)` moves things along x and z. ## 5. Geometry (`geometry.ts`, `pose.ts`) - `geometry.ts` computes every static part once: a `solid(plan, z, h, steps, bevel)` wrapper over `slabOf`, a `cylinder`, `knurl`, `sideArc`, `faceRing` and ruler helpers. - `pose.ts` has `frameOf(pose, …)`, which rebuilds only what changes: the capsule's paths, the 4 coils, the carriage and probe transforms, the needle and the pull wire. ## 6. Painter's order (`live.tsx`) 1. Base. 2. Rail, with the rider and the probe block. 3. Back frame bars. 4. Back coil halves. 5. Rods. 6. Left coils. 7. Carriage. 8. Glass. 9. Rest outline. 10. Pull wire. 11. Right-back coils. 12. Plunger. 13. Right-front coils. 14. Front frame bars. 15. Dial with needle. 16. Finger. 17. Arm. The frame and the springs are split so the carriage sits *inside* them. ## 7. Live behaviour (`live.tsx`) - **One loop.** It reads the scripted tour or the user's hand, eases the finger (τ 0.09 s) and the lift, steps the spring model, then writes every changed attribute through refs and says the readout. - **Idle tour.** It starts after 1.4 s in view and loops every 7.6 s: rest, press, pull 80, release, settle. It pauses on hover, focus or press and resumes 3.6 s after the last input. - **Pointer.** Hover moves the finger along the rail; press-and-drag presses and pulls. - **Keyboard.** Space presses or lets go, arrows move and pull (Shift for bigger steps), Home and End go to the ends, and Escape releases. - **Accessibility.** `role="slider"`, and `aria-valuetext` such as "Pressed: 129.6 by 34.6 pixels, pulled 80.0 px, leaning 10.6 px". - **Reduced motion.** The springs become critically damped: the glass still swells, but it never leans or bounces. ## 8. Card - **Corners.** "Fig 6" · "Press and pull" · hint "Press, then pull ← →" · readout "120.0 × 32.0 · lean 0.0 px". - **Legend keys.** The glass while it is held (raised) · where it rests (dotted) · the pull, from where I pressed to the finger (edge). - **Caption.** States the rig and the true numbers. -
webgl.md 59.7 KB
# WebGL inside the drawing How to put a shader effect (fire, water, light, glow) inside an isometric figure so it reads as part of the same object, seen through the same camera. Two shipped examples do this: `examples/raptor-engine/` (a plume, shock, splash and steam under a rocket engine) and `examples/ripple-tank/` (caustics, shadows and a rippled water surface). Read their `live.js` next to this page. The user loved the first shader figure, but noticed that the colour changed instantly between its phases, as if the effect had been cut. So the bar is: **nothing pops, nothing is cut, and everything sits in the drawing's perspective.** Every rule below came from a bug that broke that bar. ## Contents 1. When a shader earns its place 2. The layer stack 3. The camera contract 4. Ray-marching in the iso camera 5. Registering shader content to the SVG 6. API and a minimal page 7. Craft rules, each with the bug that taught it 8. Light theme (8b. Shaders on a turning group) 9. Verify 10. Performance ## 1. When a shader earns its place Use a shader for things lines can't draw: emitted light and glow, fire and exhaust, water surfaces, caustics, soft shadows from a real lamp, smoke and steam. These are continuous fields, and they're the subject or what the subject does. Don't use one for: - **Solids.** Parts, edges, ticks and screws stay SVG from the kit. The shader never draws a part that the SVG could draw. - **Decoration.** Shimmer, background gradients, bloom on every edge, or noise added to make the figure look "rich". If you could delete the effect and the explanation still holds, delete it. - **Something the SVG already shows well.** A glowing LED is a `lit` dot, not a shader. The SVG must still read on its own. The Raptor without WebGL still shows the engine, with a rim light and a CSS deck glow. The ripple tank draws its crests and nodal lines as SVG lines (`patternOf` in `model.mjs`), clipped to the drawn outline of the tank's shadow on the paper, and hides them with `.rt-stage[data-gl] .rt-fallback { display: none }` once WebGL is running. ## 2. The layer stack A shader can't be depth-tested against SVG, so you order layers instead. The basic stack is three layers in one `position: relative` stage, all with the same viewBox: ``` back SVG what the effect covers (k.figureSvg: role="img" and the label) canvas the effect (position:absolute; inset:0; pointer-events:none) front SVG what covers the effect (aria-hidden) ``` For every part, ask one question: **is it ever covered by the effect, or does it cover the effect?** Parts that are covered go in the back SVG, and parts that cover go in the front SVG. If a part does both, you have three choices: - **Split the art.** Cut the part into pieces and send each piece to the layer, and the slot in that layer, where it belongs. The ripple tank's motor cable is three groups of `tubePieces`: the run over the bridge goes in the front SVG before the bar, the run along the near rim goes in the front SVG after the near caps, and the run down the left leg to the control box goes in the middle SVG. - **Model it in the shader.** If the part is a simple shape (box, cylinder, torus) and stays in the back SVG, give the shader that shape as an occluder. The Raptor's columns are opaque boxes (`columnNear`), so every air integral starts at `max(tGround, tColumn)`. The deluge ring is a torus inside `groundHit`, so the splash lights its inner side and leaves its outer side dark. - **Split the effect.** When the effect passes in front of front-SVG parts, add a second canvas above the front SVG. An analytic proxy of the occluding part decides, for each sample, whether it goes to the back canvas (weight `1 − f`) or the front canvas (weight `f`). Each sample is drawn exactly once, so there is no seam. Make the proxy generous: outside the real silhouette both layers look the same. ### Worked case: the Raptor ``` back SVG deck, feet, flame cone, deluge ring, columns, ladder, cable tray canvas .rp-plume: plume volume, shock (back half), flash, splash, ground ring, steam behind the engine front SVG every engine item (depth-sorted .it groups), pipes, harness, crosshead glow SVG .rp-glow: the two nozzle-lip rims, screen-blended so they can only add light canvas .rp-veil: steam and shock samples that lie in front of the engine div .rp-flash: a CSS radial gradient (closest-side) for the ignition flash ``` The engine proxy is `engineR(z)`, a radius function of the bell, chamber and pumps. `inFront(p, V)` says whether sample `p` lies between the engine and the viewer. `side(front, veil)` sends the sample to one canvas or the other. ### Worked case: the ripple tank ``` back SVG base plate, feet, ruler, label plate, paper sheet with tape, lamp cable over the base edge, SVG fallback pattern canvas .rt-paper-gl: lamp light on the paper and base, caustics, shadows of the legs, post, bridge, motor, control box and foam beach, refraction middle SVG post, arm, lamp and its cables, control box, motor cable down the left leg in clips, legs with levelling screws, far rails, three corner posts, far glass, foam beach, plain-water fill canvas .rt-water-gl: window reflection and room-light glints, meniscus tilt at the walls, dipper contact rings, the water band behind the near glass, glass tint, the lamp's beam (dark only) front SVG far caps, motor cable over the bridge, far pillar and spring, bar, dippers, motor, near spring, near rails, glass bottom, water lines, near glass, near caps, motor cable on the rim, near corner post, near pillar, beam ``` Two canvases sit at two depths because there are two effects at two heights: light on the paper (under the legs), and the water surface (inside the tank). The control box stands on the base beside the tank, below the water canvas, so it lives in the middle SVG; the near corner post is painted after the caps it stands on. CSS for the stack (adapted from `ripple.css`): ```css .fx-stage { position: relative; touch-action: pan-y; } .fx-stage > svg, .fx-stage > canvas { display: block; width: 100%; height: auto; } .fx-stage > .fx-layer { position: absolute; inset: 0; height: 100% !important; pointer-events: none; } .iso:not([data-theme="light"]) .fx-gl { mix-blend-mode: screen; } ``` Screen blending can only brighten. A canvas that must also darken what it covers (the ripple tank's water, which tints the paper seen through it) stays on normal premultiplied blending in dark too: only `.rt-paper-gl` is screen-blended. If the front SVG must take hover (the Raptor's parts do), give it `pointer-events: none` and its items `pointer-events: visiblePainted`, so the canvas and empty areas never catch the pointer. ## 3. The camera contract `kit/gl.mjs` prepends `GLSL_ISO` to your fragment. It declares the uniforms and functions that make the shader see the same camera as `iso()`: | Name | What it is | |---|---| | `uCanvas` | Canvas size in device pixels. | | `uView` | The viewBox size `[W, H]`. | | `uOrigin`, `uK` | The projection origin and the screen units per world unit (`glCamera(P).k`, equal to `scaleOf(P)`). | | `uCam` | `(sinA, cosA, sinE, cosE)` of the azimuth and elevation. | | `uTime` | The time you pass to `draw`. | | `viewOf(gl_FragCoord.xy)` | Device pixel to viewBox point (it flips y). | | `project(vec3 p)` | World to viewBox, identical to the kit's `iso()`. | | `towardViewer()` | The unit 3D view direction, `(cosA·cosE, sinA·cosE, sinE)`. Every `p + t·towardViewer()` lands on the same pixel. Not the same as the kit's JS `towardViewer(P)`, which returns the 2D floor direction. | | `rayOf(vb)` | The world point under `vb` on the vertical plane through the origin that faces the camera, so `rayOf(vb) + t·towardViewer()` is the pixel's ray. | | `pixelOf()` | World units per canvas pixel across the screen, `(uView.x / uCanvas.x) / uK`. | | `lightDir()` | The kit's light, equal to `lightOf(P)`, so shader shading agrees with the SVG tones. | | `onFloor(vb, z)` | The world point on the horizontal plane at height `z` under this pixel. | | `onWallX(vb, x)`, `onWallY(vb, y)` | The world point on the vertical plane `x = const` or `y = const` under this pixel. | | `hash12`, `hash13`, `noise2`, `noise3`, `fbm2`, `fbm3` | Hashes and value noise, plus 4-octave fbm. | | `window(x, a, b, soft)` | 1 inside `[a + soft, b − soft]` and 0 outside `[a − soft, b + soft]`: a smoothstep `2·soft` wide centred on each bound, so it is 0.5 at `a` and `b`. | | `tonemap(c, exposure)` | `1 − exp(−c·exposure)`. Use it instead of `clamp`. | `onFloor` and the wall functions round-trip exactly with `iso()`: `project(onFloor(vb, z)) == vb`. That is what lets shader content register with SVG lines. **Why world space.** The figure's perspective is the projection. If you draw an effect in screen space (an ellipse sized by eye, a glow column in pixels), it can't agree with the drawing. It floats above the floor, runs through the deck, or keeps the same shape when the camera changes. Build everything from world points and world sizes. Screen sizes come only from world units times `uK`. Antialiasing in world units: `pixelOf()` is world units per device pixel across the screen. On the floor in the depth direction, divide it by `uCam.z` (sinE), and up a wall by `uCam.w` (cosE). Soften registered edges by about `0.7 * px`, so they come out one pixel soft: crisp like the SVG, but not aliased. Where refraction stretches the image, a fixed softness stair-steps; the ripple tank enables `GL_OES_standard_derivatives` and widens its shadow penumbra by `length(fwidth(q.xy)) / length(fwidth(rest))`, the shifted point's footprint over the unshifted one, which is exactly 1 at rest, with an `#ifdef GL_OES_standard_derivatives` fallback. ## 4. Ray-marching in the iso camera The camera is orthographic with uniform scale: both rows of the projection are orthonormal. Three things follow. - **Every pixel is a straight ray** `p(t) = p0 + t·V`, where `V = towardViewer()` and `p0` is any world point under the pixel. Take `p0 = onFloor(vb, h)`, or the kit's `rayOf(vb)`, the point on the vertical plane through the origin that faces the camera (the Raptor's ray state starts there: `S.g0 = rayOf(vb)`). Larger `t` is nearer the viewer. - **Distances to points are screen distances.** Screen distance divided by `uK` is the exact perpendicular distance from the pixel's ray to a world point. A spherical flash, shock shell or glow ball can be computed from that distance and still be world-correct. The closest-approach parameter is `tc = dot(C − p0, V)`. - **Visible fraction behind an occluder.** If the first opaque surface along the ray is at `tLow`, a Gaussian centred at `tc` with width `sigma` is visible by `1/(1 + exp(2.41·(tLow − tc)/sigma))` (the Raptor's `beyond`). That gives a soft cut, but a geometric one. To march a volume: 1. **Find the first opaque hit** along the ray: the floor, plus any heightfield the SVG draws. The Raptor steps 28 times from above the flame cone's tip down to the deck and then bisects 7 times on a `ground(r)` heightfield built from the same numbers as the SVG lathe profile. Only the part of the ray in front of the hit (`t > tLow`) is air. 2. **Bound the march** with a simple shape (the Raptor uses a radius-132 cylinder from the ground hit up to the exit plane). Window every term to exactly zero before that bound, so the bound is only an optimisation. 3. **Sample with dither.** Use `t = ta + (i + hash12(gl_FragCoord.xy))·dt`, so 28 samples don't band. 4. **Normalise each term by its chord**, so the centre pixel gets the intended brightness whatever the step count. For a vertical-axis volume `r² = u² + (t·cosE)²`, so a Gaussian of radius `σ` integrates to `σ√π / cosE` along the ray: weight each sample by `dt·cosE/(σ·1.772)`. 5. **Expect a uniform cylinder to look softer.** Its projected profile is sqrt-shaped, so it loses the defined edge a flat drawing had. In dark, add a limb shell (the Raptor's orange shear layer at `0.9·Re`) to bring the edge back. In light the same shell inks an outline round a hollow flame, so there you weight the body toward the axis instead (section 8.4). Some terms don't need marching: - A thin horizontal disc seen along the ray is an exact ellipse. The Raptor's Mach disks stay analytic: `u² + ((s0 − sc)/tanE)² < rm²`. - A thin spherical shell integrated along a ray is limb-brightened: brightness is proportional to `rs·w / sqrt(rs² − d² + 2·rs·w)`. Square the normalised profile, or the inside reads as a milky bubble. ## 5. Registering shader content to the SVG Anything that lies on a plane must line up exactly with that plane's SVG lines. - **Same numbers.** Pass the SVG's plans and heights to the shader through `DATA` (the ripple tank passes `inside`, `outside`, `paper`, `base`, `round`, `z` and the plans of everything that casts a shadow), and hand them to `glLayer` once as `fixed` uniforms (its `FIXED`). Test against the same rounded rectangles with a signed-distance `sdRect`, so a lit paper corner ends on the drawn corner radius. - **Same plane.** The ripple tank uses `onFloor(vb, uZ.x)` (the paper), `onFloor(vb, uZ.z)` (the water) and `onWallX(vb, uNear.x)` and `onWallY(vb, uNear.y)` (the two near glass walls) for the water band behind the glass. Never use screen offsets. - **Seen through an opening, masked twice.** Content behind an opening in a nearer plane (a sight glass, a window, a recess, a gauge face under its bezel) is masked by its own plane (`onWallY(vb, back)`) and by the opening tested in the opening's plane (`onWallY(vb, face)`), the two soft masks multiplied together. Masked only in the back plane, a blind-test sight glass spilled its oil and glass about 0.4 px past the top rim onto the body face, and at the bottom the offset showed a strip of bare body inside the opening. Draw the recess's inner walls in the front SVG, shaded by facing (`craft.md`, section 10), so the offset between the two planes reads as wall, not as bare face. - **Only the deviation moves.** If an effect displaces what you see (refraction through ripples), apply only the part that differs from the rest state. The ripple tank computes the sight line bent by the rippled surface and by flat water, and applies only the difference. At rest, the paper under the water lines up with the SVG paper. The shift is also faded to zero wherever the sight line to the paper passes within a few units of a leg (`legClear`), so refracted paper never slides out from behind a leg that the middle SVG draws over it. - **Heightfields match the lathe.** The Raptor's `ground(r)` is the deck disc plus the flame-cone profile, with the same radii and heights as the `G.solidOf` profile in `build.mjs`. Flow behind the cone is hidden geometrically, and hard edges appear only where the SVG draws a real silhouette. Check registration at 4× (section 9). ## 6. API and a minimal page ```js import { GLSL_ISO, glCamera, glLayer, smooth, burst, settle } from "../../kit/gl.mjs"; import { kitScript, glScript } from "../../scripts/inline-kit.mjs"; ``` | Function | What it does | |---|---| | `glCamera(P)` | `{ origin, k, cam }` for a kit projection. Works in Node and the browser. Put it in your page `DATA`. | | `glLayer({ canvas, fragment, view, camera, uniforms = [], fixed = {}, extensions = [], maxRatio = 1.5, onError })` | Compiles `GLSL_ISO + fragment` on `canvas` with a premultiplied-alpha WebGL1 context. `view` is `[W, H]` of the viewBox, `camera` is `glCamera(P)` and `uniforms` lists the uniform names you send per frame. `fixed` is `{ name: value }` for uniforms set once at creation (geometry, theme constants). `extensions` lists GLSL extensions (`["GL_OES_standard_derivatives"]`); each one the context has is enabled and prepended as an `#extension` directive. Returns `{ gl, extensions, set(values), draw(values, time), clear() }`, where `extensions` lists the ones actually enabled, or `null` when WebGL is missing or the shader fails to compile (`onError`, which defaults to `console.error`, gets the log). | | `set(values)` | Sets uniforms without drawing. | | `draw(values, time)` | Resizes the canvas to its CSS box × `min(maxRatio, devicePixelRatio)`, sets the camera uniforms, sets `uTime = time`, sets each value in `values` and draws one full-screen triangle. | | `clear()` | Clears to transparent, only if something was drawn. | | `smooth(a, b, x)`, `burst(age, rise, fall)`, `settle(state, target, dt, seconds, calm)` | The JS envelopes every shader uniform should ride on (section 7 and `motion.md`). `settle` returns true while still moving. | | `glScript()` | The source of `kit/gl.mjs` with `export` stripped, for inlining into a standalone page after `kitScript()`. Live code on that page uses `glLayer`, `smooth`, `burst` and `settle` directly and must not declare its own. | A uniform value (in `fixed`, `set` or `draw`) can be a number (float), an array of length 2–4 (`vec2`–`vec4`), or an array or `Float32Array` longer than 4, sent with `uniform1fv` to a `uniform float name[N]`: a 24-sample history is one `uH[24]`. There are no ints, matrices or samplers through these calls. For more data than a uniform array holds, use a texture: a `sampler2D` reads unit 0 by default, so create and update it on `layer.gl`. The ripple tank packs a 32 × 2 history into an RGBA8 texture, 16 bits per value (`PACK`, `put`, `unpack`), one texture per layer because each layer has its own context. Unknown names and uniforms the compiler optimised away are skipped. Your fragment declares its own extra uniforms and a `main()` that writes **premultiplied** `gl_FragColor`. The canvas must cover exactly the same box as the SVGs, with the same aspect ratio, because `viewOf` assumes one uniform scale. A minimal page: a hot plate glowing on top of a square deck. The plate registers with an SVG ring of radius 40 (`k.ring(0, 0, 40, 0, P, 72)`), and the glow stops at the deck's rounded edge, since there is nothing beyond it to light. `build.mjs`: ```js const P = k.fitProjection(points, W, H, { pad: 28, azimuth: 40 }); const DECK = { x: -90, y: -90, w: 180, d: 180, r: 8 }; const DATA = { W, H, camera: glCamera(P), plateR: 40, deck: [DECK.w / 2, DECK.r] }; const backSvg = k.figureSvg({ width: W, height: H, label: LABEL, body: back }); const frontSvg = `<svg xmlns="http://www.w3.org/2000/svg" class="iso-svg fx-layer" viewBox="0 0 ${W} ${H}" aria-hidden="true">${front.join("")}</svg>`; const stage = `<div class="fx-stage" tabindex="0">${backSvg}<canvas class="fx-layer fx-gl" aria-hidden="true"></canvas>${frontSvg}</div>`; const page = k.pageHtml({ title, theme, body: `<style>${CSS}</style>${k.plateHtml({ ...card, body: stage })}`, script: kitScript() + glScript() + LIVE.replace("__DATA__", JSON.stringify(DATA)) }); ``` `live.js`: ```js const D = __DATA__; const stage = document.querySelector(".fx-stage"); const still = matchMedia("(prefers-reduced-motion: reduce)"); const LIGHT = document.body.dataset.theme === "light" ? 1 : 0; const FRAGMENT = ` uniform float uHeat; uniform float uLight; const float R=${D.plateR.toFixed(1)}; const vec2 DECK=vec2(${D.deck[0].toFixed(1)},${D.deck[1].toFixed(1)}); float deckDist(vec2 p){vec2 q=abs(p)-DECK.x+DECK.y;return length(max(q,0.))+min(max(q.x,q.y),0.)-DECK.y;} void main(){ vec2 vb=viewOf(gl_FragCoord.xy); float px=pixelOf()/uCam.z; vec3 p=onFloor(vb,0.); float r=length(p.xy); float disc=1.-smoothstep(R-.7*px,R+.7*px,r); float onDeck=1.-smoothstep(-.7*px,.7*px,deckDist(p.xy)); float halo=exp(-pow(r/(R*1.6),2.))*onDeck; vec3 col=tonemap(vec3(1.,.42,.12)*(disc*.9+halo*.35)*uHeat,1.2); float a=max(col.r,max(col.g,col.b)); vec4 dark=vec4(col,a); vec4 light=vec4(vec3(.62,.24,.08)*a*.8,a*.8); gl_FragColor=mix(dark,light,uLight); }`; const layer = glLayer({ canvas: stage.querySelector(".fx-gl"), fragment: FRAGMENT, view: [D.W, D.H], camera: D.camera, uniforms: ["uHeat"], fixed: { uLight: LIGHT } }); if (layer) stage.setAttribute("data-gl", ""); const heat = { x: 0, v: 0 }; let target = 1, clock = 0, last = 0, frame = 0, visible = false; function tick(now) { const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60; last = now; clock += dt; const moving = settle(heat, target, dt, 0.35, still.matches); if (layer) heat.x > 1e-3 ? layer.draw({ uHeat: heat.x }, still.matches ? 0.8 : clock) : layer.clear(); frame = visible && moving ? requestAnimationFrame(tick) : 0; } const run = () => { if (!frame && visible) { last = 0; frame = requestAnimationFrame(tick); } }; new IntersectionObserver((entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); }, { rootMargin: "120px 0px" }).observe(stage); ``` Without `uTime` animation this loop stops once the heat has settled. With an animated field (flicker, flow), keep running while the effect is above its activity threshold and visible, and stop otherwise. ## 7. Craft rules, each with the bug that taught it ### Nothing pops Every uniform moves along a smooth envelope in time, with an attack and a decay. Never set a value to 1 in a single frame. - **One-shot events use `burst`.** The first ignition flash went from 0 to full in one frame and whited out the card. Now every event (flash, shock, ground ring, shutdown pop, card shake) uses the kit's `burst(age, rise, fall)` from `kit/gl.mjs`, a smoothstep attack then an exponential decay: ```js const flash = calm ? 0 : burst(sinceMain, 0.08, 0.13); ``` with a 50–90 ms attack (zero slope at onset). An alpha function `t·e^(−t)` ties the decay to the rise and stays white too long. Keep whole-card flashes low too: a flash plus a CSS veil at 0.9 opacity whited out the whole card for six frames, so the veil is now `flash × 0.32`. - **Phase changes are crossfades driven by the physical quantity.** A separate preburner term that died while the main plume grew read as an instant colour switch. Now colour, brightness, raggedness, tongue length and splash are all functions of chamber pressure, and pressure moves on a spring toward each phase's target. - **Colour gets its own slow envelope.** Even when driven by pressure, the orange-to-violet change crossed its smoothstep window in about 0.23 s on a fast spring, and brightness dipped 33% and came back inside the 130 ms flash: a cut hidden by a flash. The fix: - Hue has its own critically damped envelope, the kit's `settle`: `settle(burn.hue, lit && burn.pc > 0.2 ? 1 : 0, dt, lit ? 0.32 : 0.5, calm)`, sent as `uHue`. - Brightness is a separate `uGlow`, computed in JS so it falls monotonically from 1.15 to 1.0 as hue rises. - The pressure spring is critically damped (stiffness 20, drag 2√20), so it doesn't overshoot to 318 bar. - **Brightness during a fade-out only falls.** On shutdown the plume flared back up as an orange flame, because `mix(a, b, hue)` with `b < a` rose again as hue decayed. Now the glow level freezes when shutdown starts and is then scaled only by quantities that fall. - **Recheck neighbours when you change a spring.** Once the pressure spring was made critically damped, the preburner tongue nearly vanished. That leg needed its own stiffer critical spring (stiffness 64). - **One-shot flags belong to the event.** Re-igniting within 2 s of a shutdown lost the next shutdown pop, because the pop's clock was only reset after a quiet period. Now a per-burn `popped` flag decides it. Resetting the clock in `ignite()` would have cut off a pop that was still fading. - **Changes travel through the medium.** When the ripple tank's frequency changes, the new wavelength doesn't re-lay the whole field at once. Each frame the page records the source's true phase, ω, k, damping per mm and 1/group speed, keeps 32 samples 50 ms apart, and uploads them as a 32 × 2 texture to both shaders. Each point finds its age `r / groupSpeed` with one fixed-point step on its own history's group speed, and reads k and damping from that age, so the new wavelength spreads outward from the dippers the way it really would. - **Keep the phase continuous through a change.** The first version used `θ = k(age)·r − φ(t)`. During a sweep the term `r·dk/dt` made far crests race and alias, and damping the amplitude while the frequency moved (`calm`) blanked the pattern: mean paper brightness dipped 17%. Now `θ = kE·r − uPhase + Ψ(age)`, where `Ψ` is the phase the source gained since that sample beyond what its ω predicts (`lag` in `packHistory`). At a fixed point the crests then move only as fast as ω changes, so no amplitude damping is needed, and a small motion-blur term (`gMove`, how far crests move per frame) softens the caustics during fast changes. - **First light fades in, and in light it only brightens.** The ripple lamp fades in over 0.9 s on first view (`smooth(0, 1, (lampClock − 0.15) / 0.9)`). In dark the SVG plain-water fill fades out on the same value through a CSS variable (`--rt-lamp`). In light the first version started from bare paper, so the troughs' ink made the tank go darker as the lamp came on, and the plain-water fill darkened it further. Now the water fill is hidden whenever WebGL runs, and the paper is drawn from the first frame (lamp 0) at its unlit grey (`off`) and mixed toward lit ink by `uLight`, so first light only brightens. For an emitter on paper the same rule reads the other way round: its ink only grows as the light comes in, and the scene never goes pale first or dips dark (section 8.2). ### No hard spatial cut-offs Every term reaches zero before any boundary: an `if`, a bounding shape, a plane, or the canvas edge. Use `if` only as an optimisation where the term is provably about zero. - A bead term was cut off sharply at both ends of its range by an `if`. It now fades in and out with smoothstep. - The plume had an `if` box, a `s < .01 continue` and a `step(ax, R0)` halo mask. All three showed as seams. - A screen-space glow column ran from the nozzle to the bottom of the canvas. The last rows of the canvas still had alpha 11–19/255, so it ended in a straight horizontal line under the deck. It is now a world-space halo bounded by the ground, and the last 10 rows and columns read exactly 0. - A wide `exp()` flash and the floor-coordinate steam were still visible at the canvas edges. Both now fall to zero inside the canvas. - Cell noise looks only inside its own cell, so a feature whose offset plus radius plus soft tail reaches past the cell's half-width is clipped flat at the cell edge. Keep `offset + wobble + radius + tail` under half a cell, or test the 3 × 3 neighbouring cells. A blind-test bubble field let a centre sit 0.31 of a cell off-centre with a reach of 0.86 against a 0.85 half-cell. - Radial noise that used `atan` had a seam along one angle. Use `noise3(vec3(dir, r·k − t))` of the direction vector instead. - A CSS `radial-gradient` with percentage stops ended outside its element. Use `circle closest-side` so the gradient ends inside it. ### World space, in the camera - The ignition shock was a 2D circle around the projected exit. Its lower arc ran over and under the deck and painted over both columns. It is now a real sphere: each pixel ray hits it at two roots, roots below the ground are hidden softly, each root is limb-weighted, and each one goes to the back or front canvas by the engine proxy. - The floor splash was laid flat over the deluge ring, so the tube lost its volume. The ring is now a torus occluder, lit with `max(dot(n, toPlume), 0.)`. - Haze behind the near column painted over its front face. Columns are now opaque boxes in the shader. ### Layering and blending - In the dark theme, canvases that only emit light use `mix-blend-mode: screen` and write premultiplied colour with alpha = the largest channel. A canvas that also darkens what it covers stays normal-blended: the ripple tank's water tints the paper seen through it, which screen blending would silently drop. In the light theme every canvas is normal-blended, and its ink is solved from the colour the paper should show (section 8), not darkened from the dark-theme colour. - A blurred orange rim drawn in normal blend over the white-hot core read as a dim brown collar under the nozzle lip, and drawn slightly off the lip it made a double arc. Glow outlines over a shader now go in their own overlay SVG (`.rp-glow`), screen-blended in dark, at the exact lip. In light nothing can add light, so a rim is part of the ink itself: a saturated band at the body's edge around a pale core (section 8.1). - Steam that should pass in front of the bell was always behind it. It is now split across two canvases with the engine proxy (section 2). ### Tonemap and dither - Use `tonemap()`, never `clamp`, so bright cores roll off instead of flattening. - Without output dither, the large soft terms became 8-bit staircases (runs of 18–34 px per level), and screen blending made them worse. Dither after tonemap with about ±1 LSB of animated triangular noise, gated so empty pixels stay exactly 0 (the Raptor's `inkOf`): ```glsl col=tonemap(col,1.15); float m=max(col.r,max(col.g,col.b)); float n=(hash12(gl_FragCoord.xy+fract(uTime*.61)*91.)+hash12(gl_FragCoord.yx*1.37+fract(uTime*.37)*57.)-1.)/255.; col=max(col+n*smoothstep(0.,1.5/255.,m),0.); ``` - Band-limit fine detail. The ripple tank fades out each wave whose projected wavelength drops below 2.5–5 device pixels (its `fine` term). Otherwise a 28 Hz pattern turns into moiré. ### Render only while it matters - Cap the device ratio at 1.5 (the `maxRatio` default). Draw only while the effect is active: the Raptor clears both canvases and lets the loop stop once every envelope is below 1e-3. Stop offscreen with the same `IntersectionObserver` as the rest of the figure, and redraw on `resize`. - An effect that never rests (bubbles in a returning flow, a pilot flame) keeps the shader drawing at idle, but not the rest of the figure: split the loop so the SVG work (depth sort, gauges, readout) runs only while something in it changes, and run the effect alone, at a reduced rate if it is slow. A blind-test bench kept its whole tick at 60 fps forever because one envelope settled at 0.54 instead of 0. - Gate costly terms with uniform checks (`if(uSmoke<.0004&&uVent<.0004)return vec3(0.);`). These are the same for the whole draw, so they cost nothing when off. ### Reduced motion Jump straight to a representative steady state (the Raptor jumps to main stage, and the ripple tank to its current f and d), freeze `uTime` (the Raptor uses 0.8), zero every event envelope (no flash, shock or shake), and redraw only when the JSON of the uniforms changes. The figure must still respond to input. ### Fallback `glLayer` returns `null`, so guard every call. Mark the stage (`data-gl`) when WebGL runs so CSS can swap between the SVG fallback and the shader. Test by stubbing WebGL (section 9). ## 8. Light theme Light is the shipping theme. A page that is light gets shaders designed for paper, not dark-plate glows converted at the end. Seven figures on the isometrics site were built on a dark plate and then converted: a pool reactor, a foundry pour, an afterburner, a glory hole, a tokamak, a plasma cutter and a forge. Each conversion took three review rounds, and every rule in this section is one of their findings. The Raptor's light plume (`inkOf` in `examples/raptor-engine/live.js`) shows what not to do. It multiplies every hue by a fixed dark red, `vec3(.62,.34,.3)`, and sets alpha to brightness, so the hottest gas comes out darkest and most opaque. Reviewers called it "red ink smoke". Don't copy it. The ripple tank's grey ink (`uPale`) is still a good model for light that isn't a glow: a tone curve `1 − uExpo.x/(1 + (e/uExpo.y)^uExpo.z)` takes the crests to near paper-white and the troughs to the kit's greys, and its water writes only the change from flat water. ### 8.1 Ink on paper Over white, the canvas is normal-blended, premultiplied, and never `screen`, because screen over white is white. No pixel can be brighter than the paper, so light has to read through **saturation and local contrast**: - **A glow is a saturated body, a pale near-white core and a coloured rim.** The pale core reads as hot only because the body around it is saturated and the ground around that is darker. - **What the light falls on takes a tint** (multiply-like, with capped alpha). It doesn't get brighter. - **The volume that holds the light is darker, for a physical reason:** soot, refractory, deep water or shadow (section 8.3). It is never a black box. **Design the colour the paper should show, then solve for the ink.** Write a ramp from light energy to the colour the reader should see over white. Then invert normal compositing over white, which shows `rgb + (1 − a)`: ```glsl vec4 inkFor(vec3 seen,float cover){ vec3 absorbed=1.-seen; float a=max(max(absorbed.r,max(absorbed.g,absorbed.b)),cover); return vec4(vec3(a)-absorbed,a); } ``` Over white this composites to exactly `seen`. - **Reactor.** `inkOf` does this with its `paperOf(e)` ramp, where `e = 1 − exp(−2.3·max(lin))`. The ramp runs white → `TINT (.85,.93,1)` → `BODY (.2,.46,.94)` (by e .12–.5) → `RIM (.2,.68,1)` (.4–.65) → `ICE (.9,.98,1)` (.6–.9). - **Afterburner.** Its `paperOf` mixes white → body → a pale core, and then inverts the same way. - **`cover` is an alpha floor at the hot end:** `WASH·smoothstep(.6,.9,e)` with WASH .52 in the reactor, and `COVER·smoothstep(.45,.92,m)` with COVER .94 in the afterburner. Without it, a near-white core has alpha near 0, so the SVG lines behind it show straight through and it reads as a hole, not as light. The other figures use the form `vec4(colour·a, a)`: `colour` comes from a ramp and `a` from a saturating curve of energy. The tokamak's `glowInk` is an example. Both forms work; `inkFor` also guarantees the colour the reader sees. **One ink per kind of light, composited in the shader.** Keep separate accumulators, give each its own ink, and stack them back to front with `vec4 over(vec4 top,vec4 under){return top+under*(1.-top.a);}`: | Term | Ink | Seen in | |---|---|---| | Emission, the hot thing itself | ramp body plus a pale core, alpha up to .96–.97 | plasma cutter `bodyInk`, forge `barInk`, tokamak `glowInk` | | Light falling on a surface | hue pulled warm or cool, alpha capped at .5–.8 | foundry `castInk` and `tintInk`, tokamak `tintInk` (.5), plasma cutter `tintInk` (.75–.8) | | Haze: steam, smoke, fume, breakdown gas | grey or lightly tinted, alpha `1 − exp(−density·k)` | plasma cutter `hazeInk`, tokamak `gasInk`, forge steam | | Sparks and streaks | on top of everything | foundry and forge `sparks` | The plasma cutter's `lightInkOf` is the clearest example: `over(arcCore, over(bodyInk(body), over(arc, over(fumeInk, over(steamInk, tintInk(tint))))))`. If you sum the energies first and ink the sum, everything gets one hue and the pale core is lost. **Finish.** - Clamp `rgb ≤ a` after every sum: the afterburner showed a 1-LSB premultiply overflow. - Dither the final premultiplied value, rgb and alpha both, gated by `smoothstep(0.,1.5/255.,a)` (the glass furnace's `paperOut`, the foundry's `dithered`). - Keep the canvas edges at exactly 0. - Tonemap energy before it reaches a ramp (`tonemap`, or `1 − exp(−k·e)`), never `clamp`. ### 8.2 Ink grows with light In dark, more light only adds. On paper, more light means more ink, and a careless formula can add ink while the light falls or take ink away while it rises. These were the faults reviewers found most often: - **Fade the finished ink once.** Scaling a premultiplied ink by `u` is an exact linear crossfade from bare paper to the full picture, monotonic in every pixel: the reactor's `ink*=uFirst`, the glass furnace's `paperOut(overInk(gas, ink)*uLit)`. The glory hole first faded each term before inking it (`*uLit` in `holeOf`, the port bore and the ball). The pale core arrived before its saturated body, then the ground darkened, so first light went pale and then dark. Build every light ink from unfaded tones (its `litOf()` returns 1 in light) and fade only the result. - **Fade a glow by energy and alpha together.** If you fade only alpha, a pale core stays pale and the thinning glow turns milky grey: the tokamak's contact band did. If you fade only energy, the glow runs back down its ramp. The tokamak now scales energy down to a floor and alpha the rest of the way: `hot+=glowInk(c*level*(.3+.7*hold))*hold*(1.-hot.a)`. The band stays pink-mauve as it goes. - **Take the hue at steady brightness.** As energy falls, the tonemap reddens and darkens the hue, and with alpha near its cap the ink rises: the afterburner's cavity got 55% heavier as the flame went out. The fix: - Divide the burn by the local lit strength before taking the hue (`burn/lit`, `lit = la.x·glowOf(la.y)/LIT_GLOW`). - Blend the hues by each part's share of the energy. - Tie alpha to energy: `cap·(1 − exp(−1.6·e/cap))`. - At low cover, warm the hue toward `EMBER` and scale alpha by `inkOf(hue)/inkOf(ember)`, so the ink stays the same. With that, the window's ink fell monotonically from 1894 to 155 on the cut. - **Hand-offs are plain premultiplied mixes.** The afterburner's jet has two copies, dry blue and lit warm, crossfaded with `mix(blue, warm, lift)`. A `max(alpha)` cover there doubled the dry jet's ink. Also gate each copy so it can't pick up the other's terms: the dry shock disc leaked into the warm copy as a cream patch. - **Envelopes that multiply can rise during a fade.** If a first-light envelope is still rising while the effect's own edge falls, the fade-out brightens: the tokamak did this when Home was pressed in its first half second. Freeze first-light growth while the effect is falling. - **The handover from the SVG fallback is exact,** or the scene dips while the shader comes in (section 8.5). ### 8.3 A ground for the light A glow on white with nothing around it reads as a stain. Give it the ground it really has: - **Cavities in the SVG take a darker token set.** A group class redefines the kit tokens for everything inside it: - the afterburner's `.ab-inside`: paper `#4a453f`, shades `#3c3833` to `#544e47`, lines `#5e5952` to `#9b958b`; - the tokamak's `.tk-cavity`: `#66646e` to `#8b8992`; - the foundry's sooted bore tones (`--fd-bore-*-sooted`, a `color-mix` of `--anatomy-shade-0` with `--fd-soot-ink: #2f2a25`); - the glory hole's `--gf-void` and `--gf-bore`; - the reactor's water-column gradient (`--rx-water-top`, `-mid`, `-deep`, from `#edf2f4` to `#c9d7e0`). In dark the same tokens are set to `inherit`. All four shade tones stay, so the inside still has form and reads as refractory, soot or deep water rather than a hole. - **The ground takes the light's tint, live.** The same tokens `color-mix` toward the light's hue by a variable the loop writes: `--ab-flame` at 30% on faces and 55% on lines and back plates, `--tk-cold-mix` for the violet of breakdown. The SVG ground then warms with the flame even without WebGL. - **The shader's ground equals the SVG's.** Where the shader paints an opaque cavity (the glory hole's `holeOf = overInk(emberInk(t,1.), vec4(SOOT,1.))`), read the token rather than retyping it. The foundry adds a probe path, sets its fill to each `var(--fd-…)`, reads `getComputedStyle(probe).fill` and passes the result as a `fixed` uniform (`paintOf` in its `mount.ts`). - **Light around the source is a tint with a long tail.** The tokamak's vessel read grey at flat-top until a long-range wash (`skin*(.14*exp(-gap/(.22*U))+.17*exp(-gap/(.9*U)))`) tinted its walls magenta. Measured on the column: `#6e6c76` with the plasma off, `#786778` with the wash at .17, which reads as lit. At .1 it still read grey. - **No bloom in empty air.** In light, a halo around a stream or sheet hanging in the air is a smudge on the paper (the foundry's stream and notch glows were). Drop halos in air in light. Keep the limb rim inside the body, and the tint on the surfaces the light reaches. - **Light only lands where the source can see.** The reactor's surface light leaked onto a shield's cut face and coping until its seal test used the true top of the tank lip (`RIMZ`) rather than the deck. The plasma cutter lit the slat bay under the jet's lower end while the jet was visibly in the next bay; its `bayLight` now lights each bay by its share of the jet column, so the light crossfades from bay to bay as the jet moves. ### 8.4 Recipes by phenomenon Hot bodies run dull red → orange → yellow → near-white, in that order and no other. Keep one stop table in the model, compile it to GLSL and use the same table for the fallback: the foundry's `INK_STOPS` (`inkRampGlsl`, and `glowInk` for the SVG), the forge's `STEEL_INK` (`steelInk`, `steelInkOf`). - **Molten metal, forged steel, billets, kerfs.** - Index the table by brightness temperature, so a dim far wall and a bright pour read from the same table. The foundry's `brightOf` inverts Wien from the green channel. - The forge's stops: 560 °C `(.45,.09,.05)`, 680 `(.56,.07,.04)`, 780 `(.78,.10,.03)`, 950 `(.95,.38,.06)`, 1150 `(1,.78,.12)`, 1260 `(1,.93,.78)`. - Alpha opens at visible glow: `steelGlows` over 560–820 °C, or the foundry's `INK_FROM`/`INK_FULL` over 470–590 °C, crossfading from the metal's own ground tone. - The rim is cooler and darker (`steelInk(t − 140)`, the foundry's 150 K limb drop). A cream core appears only in the hottest centre (`steelPale` over 1050–1280 °C, at .8). - A dark skin sits under the glow and grows as the metal cools: the forge's `barInk` runs from oxide `(.28,.29,.32)` to skin `(.24,.22,.23)` with cover up to .86. - Reviewers' words for the foundry stream: "a cream core with an orange skin". - **Glass gathers.** - Alpha is a luminance gate: `GLOW_SEEN` .008–.028, about 600–710 °C. - The colour is `pow(hue, mix(1.6,.8,warm))*mix(.62,1.,warm)`, which runs deep cherry → red → orange. - The pale core (`CORE_PALE (1,.88,.62)`) sits where the pixel's own emission is brightest (`cored`, luminance .14–.24), not at a fixed radius. - The cool skin (`SKIN_COOL (.3,.11,.07)`) only recedes. - The first version had a salmon stage. A red mixed toward white is salmon, and no temperature looks like that: go through orange and yellow before anything turns pale. - **Sparks.** - A saturated head and a darker, cooler tail. The plasma cutter's `tailCooling` sets the tail at 0.62 of the head's temperature; the forge's tail ink is `(.42,.07,.02)`, alpha .75 capped at .8. Only the youngest third of a spark gets a pale core (`u < .35`). - Keep the streak at least about 1.25 device px wide (`max(.35+.6px, 1.25px)`), or diagonal streaks bead at 10–24×. - On white a spark is ink, so over a hotter emitter it must vanish. The foundry multiplies spark ink by `1 − cover·smoothstep(sparkT−60, sparkT+60, tb)`, against both the metal and the glowing bore. - A spark that passes behind a jet fades by depth: `cover*smoothstep(-.4,.4,tJet-tS)`. - **Flame and exhaust.** - A saturated orange-yellow body with translucent edges, weighted toward the axis in light: `mix(1.,1.3-.6*smoothstep(0.,rf,r),uLight)`. - Cut the limb mantle (`mix(.8,.3,uLight)`). The limb shell that gives a dark plume its edge (section 4) draws an outline on paper and leaves the flame hollow. - Keep raggedness to the outer part, widen the hot axis, and put the pale core at the nozzle, fading out over .4–.9 of the length. - Check a cross-section. The afterburner's final flame reads `0 2 13 33 31 49 78 72 78 52 40 32 8` in ink from edge to edge. - The dry jet is a faint blue column. Shock diamonds are pale cream discs (`CREAM (1,.95,.86)`) with a warm rim and a soft edge (about .65 of the disc radius). Pushed stronger, a second diamond read as a knob or a washer, so it stays a faint lens. - **Smoke and steam.** - Light grey, never with a dark glow ink laid over it. Edge `(.84,.87,.90)`, crown `(.97,.975,.98)`, and the underside `(.72,.76,.81)` by the cloud's own self-shadow; alpha `1 − exp(−density·k)`. - Near a hot source, warm the cloud toward pale amber (`mix(steelInk(T), CREAM, .5)`). The forge first laid glow ink over the cloud, and reviewers saw a salmon blob. - A quench puff is a `burst` that leaves the tub: it climbs from 4 to about 40 units above the water, slowing as it goes (`PUFF` in the forge's `shader.ts`), clears the rim within about 0.15 s, widens from 7 to 13, thins as it grows, and shares the column's height window, so it never cuts off. - **Plasma (tokamak).** - Saturated magenta-violet: `inkBody(chroma)=pow(chroma,vec3(1.8))*vec3(.93,.86,.97)`. - Above m .55 the core goes pale, toward white with 8% of the chroma kept. - Alpha: `.97*smoothstep(.02,.42,m)*(1.-exp(-2.6*(m+2.*m*m)))`. - Breakdown gas is a violet haze, `(.52,.44,1)` to `(.74,.68,1)`, alpha at most .64. Seen through the cutaway, the vessel's interior uses the cavity token set. - **Cherenkov (reactor).** - Vivid cyan-blue in a blue-tinted water column: the paper ramp in 8.1, over the SVG water gradient, with the hot end floored by `WASH` so the core covers the fuel lines behind it. - Compress the pulse above critical (`1.+.75*log(level)`), so a 1.5 GW pulse brightens the pool without flooding the card. - The glow on the hall wall uses a separate, weaker ink (`HALL_INK` .35). - **Plasma arc (plasma cutter).** - A violet arc `vec3(.6,.34,1.)` with a pale core `vec3(.96,.95,1.)`. - Warm tint ink on the plate and water: `mix(vec3(1.,.5,.08),vec3(1.,.68,.3),…)`, alpha `.75*(1.-exp(-3.2*sqrt(m)))`. ### 8.5 The SVG and the fallback in light - **Read the theme once.** Choose the theme at build time (`pageHtml({ theme })`, or `data-theme` on the React wrapper; on the isometrics site the figure's build returns `theme` and `LivePlate` wraps a dark plate in its own `.iso[data-theme="dark"]`). The live code reads it from the nearest `[data-theme]` (`root.closest("[data-theme]")?.getAttribute("data-theme") !== "dark"`) and passes `uLight` as `fixed`. Gate every light-only change on `uLight` (`mix(dark, light, uLight)`, or a branch on the uniform), so dark output stays as it was. Keep dark working if that is cheap. - **No colour literals.** If a figure hard-codes dark colours in its CSS, convert them to tokens with dark overrides: `.x { --x: light }` then `.iso[data-theme="dark"] .x { --x: dark }`. Shader constants that must match a token come from a probe (8.3). - **Nested themes.** `.iso:not([data-theme="dark"]) .x` also matches through the light page around a dark plate. That is how the reactor's dark fallback got multiplied into black. Put the dark rule after the light one, at equal specificity, or key on the plate's own theme: the forge uses `.iso-plate[data-plate="light"]`. - **How the fallback blends.** Multiply its saturated tints, which darken and leave the lines under them visible. Normal-blend its pale cores, because multiply can't lighten: the reactor's no-WebGL core is a radial gradient from `#fff` at .85 to `#dcefff` at .3, blended normally. - Fallback colours come from the same ramp as the shader: the foundry's `glowLightCss`, and the glass furnace's `gatherInk`, which mirrors the shader's gather ink exactly. - Opacity on a pale token fades to grey, so give the fallback the saturated hue: the tokamak's fallback rings set `--anatomy-lit: #e0409c` and `--anatomy-hi: #f27ec0`. - **Fallback shape changes crossfade.** The tokamak's no-WebGL plasma ring is 11 precomputed rings (`LIMITED_RINGS`). Neighbouring rings are crossfaded with linear weights that sum to 1, on the same eased radius the shader draws. With 6 rings a doubled outline showed mid-fade. - **Nothing with gaps goes under a translucent ink.** Whatever the SVG draws shows through an ink that isn't fully opaque. The forge's heat band was a set of strokes with paper between them, and it printed grey streaks through the bar's 14% transparency. It is now a face filled with the heat gradient. - **The handover from fallback to shader is exact.** At first light the canvas ink rises by `u` while the fallback under it goes. A linear `1 − u` on the fallback makes the middle of the fade paler than either end. For an exact linear crossfade, set the fallback's opacity to `(1 − u)/(1 − u·α)`, where α is the canvas ink's alpha where it matters: the glass furnace's `--gf-gather-fade`, with α the gather's centre stop. Its other fallbacks stay at full strength until the shader is nearly in: `clamp(0, calc(var(--gf-fallback, 1) * 12), 1)`. - **Every body the shader draws has a fallback.** The foundry's tap stream had none, so it was missing without WebGL. It is now an SVG outline rebuilt each frame from the same history the shader reads. ### 8.6 Where the ink meets a line The canvas lies over the back SVG's 0.6 px strokes. In light, a misregistered edge shows as a pale gap or a coloured line over the stroke, rather than as a dark seam. - **Stop at the stroke's inner edge.** Ramp a face's ink to full under the stroke and end it at the stroke's inner edge, half the line weight in: 0.3 vb for the kit's 0.6 px lines. Measure the computed stroke width; the forge's reviewer guessed 1.1. Examples: the reactor's `cutFade=smoothstep(-SEAM-.5*px,-SEAM+.5*px,gap)` with `SEAM=.3`, and the forge's `strokeMeet`. Pad a tint by the same half stroke (the foundry's `ringLight`), so it never paints over the line and leaves no pale band inside it. - **Don't clear the ink where nothing is stroked.** The forge's veil cleared at the crease two faces share, which the SVG doesn't stroke, and white seams showed along the bar. Draw such a crease in the ink instead: ×0.72, at least 0.8 canvas px wide (sharper came out dotted). - **Supersample where neighbouring rays disagree.** The reactor traces four sub-pixel rays and takes 16 samples when their hit, depth, kind, normal or cut share differ (`apart`), so cut-face edges don't stair-step. At dpr 1 a canvas pixel is about as wide as the stroke, so one pixel of softness remains. - **A body that enters from out of frame** may fade in from the canvas edge in screen space. The foundry's tap stream fades in over 11 vb, starting 10.5 device px below the canvas top, so the edge still reads 0 and the stream reads as coming from above. ### 8.7 What the reviewers saw, and the fix | You see in light | Cause | Fix | |---|---|---| | A red-brown "ink smoke" plume | Hue × a dark multiplier, alpha set to brightness | A paper ramp: saturated body, pale core, alpha floor (8.1) | | A flame drawn as an outline, hollow inside | The dark limb mantle | Weight the body toward the axis, cut the mantle (8.4) | | First light goes pale, then dark | Terms faded one by one before inking | Fade the finished premultiplied ink once (8.2) | | A glow fades out through milky grey | Alpha-only fade of a pale ink | Dim energy and alpha together (8.2) | | A cavity gets darker as the fire goes out | Tonemap reddens the hue while alpha holds | Hue at steady brightness, alpha from energy, ember match (8.2) | | A salmon stage as glass or steel cools | Red mixed toward white | A blackbody-ordered ramp, pale only past yellow, core where emission peaks (8.4) | | Sparks look like dirt on the metal | Dark ink over a hotter emitter | Drown sparks over hotter bodies; saturated head, darker tail (8.4) | | Spark streaks beaded at zoom | Tails too thin | At least about 1.25 px wide; thin the tail less (8.4) | | A spark shows through the jet | No depth test against the jet | Fade it by depth behind the jet (8.4) | | A halo stains the paper around a stream | Dark-theme bloom in empty air | No halos in air in light (8.3) | | A salmon blob in the steam | Glow ink laid over the cloud | Tint the cloud toward pale amber (8.4) | | The vessel reads grey with the plasma on | Tint falls off too fast | A long-range wash term (8.3) | | A pale fringe or white seam along an edge | Ink stops short of the stroke, or clears an unstroked crease | End the ink at the stroke's inner edge; draw the crease in ink (8.6) | | A cream patch in the jet | One copy picks up another copy's term | Gate each copy (8.2) | | The dark plate's fallback is invisible | A `:not([data-theme="dark"])` rule matched through the light page | Order the dark rule last, or key on the plate (8.5) | | A grey, washed-out fallback glow | Opacity on a pale token | Saturated tokens; multiply tints, normal pale cores (8.5) | | Grey streaks through a hot face | SVG strokes with gaps under a translucent ink | Fill the face (8.5) | | Light on a face the source can't see | A surface light without the true occluder | Use the same geometry the SVG draws (8.3) | ## 8b. Shaders on a turning group In a 3D figure (`references/3d.md`) parts move as rigid groups, so a shader has to follow them. - **Anchor to a part**: `controller.anchor(group, restPoint, restDir)` gives `{ world, screen, direction }` every frame. Pass them as uniforms. That is how an LED rides a flexing fingertip, a flame stays on a ruptured line's end and a spark source sits on a knuckle. - **Anchor to a surface**: prepend `glslPose("Dial")` (or `GLSL_TURN` for one group) and pass `TURN.poseUniforms(controller, "dial", "uDial")`. Then `vec3 q = toDial(onFloor(vb, z))` is the surface point in the group's rest-world coordinates. Oil pooling on a turntable, a texture revealed by light and a scorch mark all turn with it. - **World space stays world space.** Smoke, sparks after they leave the source and oil on the bench never touch a group transform. Emit at the anchor's world position, then integrate in world. - **Occlusion by live parts.** A front canvas sits over a live layer. Mask its ink with the parts painted after the effect's own part: `TURN.cover(controller, [partName])` → `uCoverEdge`, `uCoverCount`, then `ink *= 1. - coverOf(vb, .7 * px)`. The masks are the exact convex silhouettes pushed out 0.3 vb, so ink stops at the occluder's stroke. Six polygons of ten edges each. Read `MAX_FRAGMENT_UNIFORM_VECTORS` and keep the SVG fallback below 128. - **A back canvas between live layers** needs no masks: put the surface that receives light in its own live layer under the parts that cover it (the turning dial's face under the hand). - Vector uniform arrays (`uniform vec3 uSpark[24]`) take a `Float32Array` through `draw`. - **The SVG fallback on a moving part** is a `T.billboard({ part, at, normal, svg })`. Its markup is in viewBox px centred on the projected point, not in world units, so scale world sizes by `G.scaleOf(P)`: a glow sized for a lens of radius 1.1 is `<circle r="${(1.1 * G.scaleOf(P)).toFixed(2)}"/>`. Written as `r="1.1"`, it draws a pin-dot about a third of the lens's size. Check it with `--no-webgl` and a 4× zoom. ## 9. Verify Use `scripts/drive.mjs` for anything that moves; `capture.mjs` also has WebGL now, so it is fine for still shots of a shader page. `references/verify.md` has the usage. On top of the normal checklist: - **A contact sheet across every transition:** start, each phase change, steady state and stop. Real-time sheets on a heavy WebGL page are too sparse, because a headless capture takes 60–200 ms and blocks animation frames. Slow the page's clock first with `["slow", 0.05]` (20× slow motion for `requestAnimationFrame` and `performance.now`), take the sheet, then `["slow", 1]`. To catch something that starts on the first frame, such as a first-light fade-in, pass `--preload` a file containing `window.__driveSpeed=0.04` so the page runs slowly from its first frame. Wait for a phase with `["until", "document.querySelector('[data-readout]').textContent.includes('main stage')", 8000]` rather than a guessed `wait`. Hold any idle tour off before each sheet: its resume timer runs on the wall clock, not the slowed one, so it can take the targets back mid-sheet. A key press holds it for a few real seconds; for a long slowed sheet, stub the timer with `--preload` and wait for rest first (`verify.md`, "Slow mode doesn't slow timers"). - **Numbers, not just pictures.** `canvas.getContext("webgl")` returns the page's own context. In a `requestAnimationFrame` callback, read uniforms with `gl.getUniform(gl.getParameter(gl.CURRENT_PROGRAM), location)` and read the mean canvas RGB with `gl.readPixels`. Readback only works in the same task as the draw, because `preserveDrawingBuffer` is off: all-zero frames from a separate task are a test artefact. Log one row per frame across a whole run. Hue and mean luminance should each move monotonically through a phase change, apart from deliberate bursts. - **Edges read 0.** Read back the last 10 rows and columns of the canvas during every phase. Anything above 0 is a cut that will show under screen blending. - **In light, log ink, not brightness.** Per frame, log the ink a region lays on white, `1 − lum(rgb + 1 − a)`, with the hook in `verify.md` ("Light-theme shaders"). Do it for each region that matters (a cavity, the core, a fallback-only area) across first light, every phase change and the fade-out. Ink must follow the physical quantity: up as the light comes in, down as it goes, with no hump and no dip. Stated as numbers, the afterburner's cut window fell 1894 → 155, and its light-off rose 154 → 1959 and settled at 1914. - **In light, probe colours along the physical ramp.** For something that heats or cools, read the centre and rim pixel at several temperatures. They must run dull red → orange → yellow → near-white, with the rim cooler than the centre and no salmon or grey stage. The glory hole's table runs from 255/179/99 centre and 252/114/25 rim at 930 °C, through 211/63/6 and 165/28/1 at about 620 °C, to clear glass by 465 °C. For a volume, read a cross-section of ink from edge to edge: it should peak on the axis. - **In light, check the dark path too.** Every light change sits behind `uLight`, so the dark build's frames should match its baseline. Also render a dark plate inside a light page, with and without WebGL (the nested-theme trap, section 8.5). - **2× and 4× crops** of every place the effect meets the SVG: rims, lips, deck edges, wall bands, the paper corners, occluders, and every opening the effect is seen through (does it stop at the rim on all four sides, with the recess wall showing below?). Look for seams, hard edges, banding, a dark collar, a double line or a sudden colour switch. In light, also shoot 8–16× crops wherever ink meets a stroke (cut faces, rims, mouths, creases), and look for a pale gap between ink and line, ink painted over a line, or a stroke the ink cleared. Compare the same crop with the front SVG hidden and with the canvas hidden (set `style.display="none"` with an `eval`). - **Fallback.** Run `drive.mjs` with `--no-webgl` (every `getContext("webgl")` returns `null`) and check that `data-gl` is absent and the SVG alone still reads, in both themes. In light, the fallback should show the same inks as the shader and the same dark ground, and every body the shader draws should be there. Film first light on a sheet with `--preload` slowing the page from its first frame: the handover from fallback to shader must not dip (section 8.5). - **Reduced motion** (`--reduced`): one still, representative frame. - **Console clean.** A shader compile error arrives as `console.error` from `onError`. ## 10. Performance - Cost is roughly pixels × samples. The Raptor runs 28 volume samples, each with four shock-cell terms, plus a 28-step ground search with 7 bisections, an 8-sample halo, a 10-sample splash curtain and 6 steam samples per canvas, at up to 1.5× device ratio. It runs in SwiftShader, but its frame time hasn't been measured on a real GPU. Measure in a real browser's Performance panel before shipping something heavier, and if it's too slow, cut samples first, then `maxRatio`.
-
-
scripts
-
audit-affine-bounds.test.mjs 4.1 KB · in bundle
-
audit-cache.test.mjs 1.6 KB · in bundle
-
audit-clearance-envelope.test.mjs 2.4 KB · in bundle
-
audit-empty-clipping.test.mjs 3.8 KB · in bundle
-
audit-order-convergence.test.mjs 2.8 KB · in bundle
-
audit-path-origin.test.mjs 1.6 KB · in bundle
-
audit-profile.test.mjs 2.1 KB · in bundle
-
audit-projection.test.mjs 1.9 KB · in bundle
-
audit-slide-clearance.test.mjs 2.8 KB · in bundle
-
audit-svg-attributes.test.mjs 3.4 KB · in bundle
-
audit-taper.test.mjs 1.2 KB · in bundle
-
capture-current-geometry.test.mjs 5 KB · in bundle
-
capture-selector.test.mjs 7.5 KB · in bundle
-
capture.mjs 13 KB · in bundle
-
capture.test.mjs 2 KB · in bundle
-
chrome-port.mjs 1.1 KB · in bundle
-
chrome-startup.test.mjs 1.8 KB · in bundle
-
drive-actions.test.mjs 1.8 KB · in bundle
-
drive-async-until.test.mjs 2.7 KB · in bundle
-
drive-turn-controller.test.mjs 2.6 KB · in bundle
-
drive.mjs 21.6 KB · in bundle
-
drive.test.mjs 3.6 KB · in bundle
-
enter-key.test.mjs 2.5 KB · in bundle
-
fidelity-cut.test.mjs 3.6 KB · in bundle
-
fidelity-tube-coverage.test.mjs 3.1 KB · in bundle
-
fingerprint.mjs 1.2 KB · in bundle
-
inline-kit.mjs 648 B · in bundle
-
line-ends.mjs 14.8 KB · in bundle
-
lines-arcs.test.mjs 2 KB · in bundle
-
lines-bezier.test.mjs 1.7 KB · in bundle
-
lines-round.test.mjs 2.4 KB · in bundle
-
lines-rounded-rect.test.mjs 2.5 KB · in bundle
-
lines.mjs 1.7 KB · in bundle
-
lines.test.mjs 1.5 KB · in bundle
-
navigation.test.mjs 2.2 KB · in bundle
-
orbit-sampling.test.mjs 2.7 KB · in bundle
-
react-dots-opacity.test.mjs 2.8 KB · in bundle
-
react-readout-first-write.test.mjs 3.8 KB · in bundle
-
react-readout-target.test.mjs 4.5 KB · in bundle
-
react-solid-seamless.test.mjs 2.7 KB · in bundle
-
react-visibility-target.test.mjs 3.8 KB · in bundle
-
turn-bench.mjs 3.8 KB · in bundle
-
turn-check.mjs 36.6 KB · in bundle
-
turn-fidelity.mjs 19.7 KB · in bundle
-
turn-group-reference.test.mjs 2.1 KB · in bundle
-
turn-split-owner.test.mjs 3.3 KB · in bundle
-
-
LICENSE 1 KB · in bundle
-
SKILL.md 32.8 KB
--- name: isometric-motion description: Build animations and interactive elements (explainers, figures, hero visuals, step-through walkthroughs) in the crafted isometric "Anatomy" style, with many small precise solid parts, four-tone shading, correct painter's order, a framed card with title, hint and live readout, and calm eased motion. Works for physical machines and for abstract concepts (e.g. how an LLM picks its next token), as SVG/HTML or React/Next.js, with optional in-perspective WebGL shaders. Use this whenever someone wants an isometric animation, interactive element, explanatory diagram or figure for docs, a blog post, a landing page or an article, a "Linear-style" or "premium" technical drawing, or an SVG that shows how a system, algorithm or component works, even if they never say "isometric". --- # Isometric Motion > Copied from [wheresryan22/anatomy](https://github.com/wheresryan22/anatomy) (MIT, see `LICENSE`) and repurposed for animations and interactive elements in the same style. This skill makes figures like a small instrument on a bench: a heavy base on feet, rails with tick rulers, a carriage on bushings, knurled knobs, coil springs, a dial gauge whose needle moves, screws as tiny dots. The reader understands the idea because they recognise the machine. Every number the figure shows comes from the same model that moves its parts. Nine complete examples ship with the skill. Open the closest one before you start. Each framework-free example is built with `node examples/<name>/build.mjs`, which writes a standalone HTML page next to the script; add `--light` for the light theme. - `examples/dial-indicator/` is the smallest: a dial gauge on a stand, about 300 lines in all. - `examples/desk-computer/` is framework-free SVG: a desk computer with a keyboard you can type on. The typed line appears on a CRT screen drawn in the plane of the case front, and the picture collapses to a line and a dot when you switch it off. It has about 85 solids and 850 paths. - `examples/arcade-cabinet/` is an upright arcade cabinet with its side panel off, a Hero of about 240 solids and 15 cables. A quarter flies into the slot, rolls down the coin chute at the speed a rolling disc reaches on a 30° slope, trips the coin-switch wire and drops into the cash box; the switch's pulse runs chunk by chunk along its cable to the board, and the CRT warms up, self-tests and runs an attract mode drawn in the plane of the tilted monitor. The stick tilts and closes a microswitch under the panel. It shows how to place a camera so a cutaway's mechanism stays in view, a concave board drawn as a prism along any axis, moving parts rebuilt in the page from the same module the build uses, and live coins and a wire drawn in reserved slots of the settled order. `node build.mjs --audit` proves the geometry. - `examples/raptor-engine/` is a rocket engine hanging in a test stand. Its round parts come from `kit/lathe.mjs` and its pipes and harness from `kit/tube.mjs`. It comes apart into 11 assemblies with staggered springs and per-frame depth re-sorting. Every part records its true shape, and `node build.mjs --audit` (`kit/audit.mjs`) proves, at rest, apart and in every replayed frame of the explode, that nothing passes through anything, that every end sits on a real mount, that no bend pinches, folds or lands on another line, that no curved profile's slope wobbles, and that every overlap is drawn in depth order. A WebGL plume, shock, splash and steam render between the back and front SVG. It is the reference for dense pipework, smooth shading and fades. - `examples/ripple-tank/` is a school ripple tank on tall legs over a paper screen, seen from 44° up. Two WebGL layers (lamp light, shadows and caustics on the paper, and the water's reflections and glass) are registered to the isometric planes and driven by a pure dispersion and optics model (`model.mjs`); a frequency change travels out from the dippers through a history texture. - `examples/next-token/` is the abstract-concept example: how an LLM picks its next token, drawn as a sampler on a bench. The prompt is written on a terminal screen in the plane of its front, the five candidate words are on keycaps, chip stacks on a ruled board are as tall as each word's probability, a knurled temperature knob reshapes them, and a carriage stops at a seeded random u on a strip where the probabilities lie end to end. The model (`model.mjs`: softmax, inverse-CDF pick, a seedable generator) drives every number. - `examples/test-rig/` is a production figure from a real site (React/Next): a glass button on a spring-mounted carriage, a finger probe on a rail that presses and pulls, and a dial gauge reading the lean. It has 53 solids and about 470 paths. - `examples/spot-plate/` is a second production figure from the same site: six pairs of glass drops on a spot plate, each pair drawn from one distance field, so a pair closer than `merge` runs together into one piece of glass. A fan of feeler blades sets `merge`, one blade per gap. It is the reference for shapes that morph into each other and for one control driving many parts. - `examples/turning-dial/` is the optional 3D mode: a combination dial that turns in true 3D with a small robot hand on it. It has about 90 live parts in metal materials, a second turn group, a hinge, a three-hinge finger that lifts and a pod that breaks off as a free body. A WebGL LED, its light pool and sparks follow the moving parts. `stress.mjs` beside it is a dense hand of about 380 parts in a cluttered workshop, used to measure performance. The test rig is the quality bar for the drawing itself. The Raptor is the bar for shader work: a WebGL effect has to reach its standard of world-space, no-pop, seamless rendering. ## The one idea that matters **The object is the explanation.** Don't draw boxes, arrows and labels. Invent a real, well-made physical thing whose working *is* the concept. Here is how earlier figures mapped: | Concept | Object | |---|---| | One shared GPU context serving many canvases | A gantry plotter: one print head visiting 16 wells | | A cache key that skips work when nothing changed | A pin-tumbler lock: when every pin matches, the plug turns and nothing is redrawn | | Seven render targets with different bytes per texel | A chest of seven drawers, each as deep as its bytes | | A separable blur (49 × 49 taps vs 49 + 49) | A bed-of-nails stamp beside two combs on rails | | A spring that presses and leans | A test rig with springs, a probe and a dial gauge | | A height profile | A profile (contour) gauge pressed against the edge | | Light that depends on facing | A small photo studio: key and fill lamps around a turntable | **Abstract concepts may bend this rule.** When the subject is not a machine at all (a model choosing its next token, a ranking, a data flow), you may draw the concept itself as crafted isometric parts instead of inventing a physical metaphor. Keep everything else: solid volumes, many small precise parts, four-tone shading, painter's order, the framed card and live readout, and calm eased motion driven by one model. Never fall back to flat boxes, arrows and labels. `examples/next-token/` shows how: - **Words are parts.** When the concept is made of words (tokens, labels, queries), put them on faces in the plane of a part: keycap tops, a screen set into a case front, the top of a stack. Size them to read at 1× (about 7 world units on a keycap) and fit them to the face; that is the exception to keeping words out of the drawing. - **Continuous parameters get a real control.** A temperature, a rate or a threshold is a knurled knob with a ticked dial ring (or a slider carriage on a ruled rail), turned by dragging on it, by the wheel over it and by the arrow keys. No HTML range inputs or buttons inside the card. - **Randomness is seeded.** Draw from a small seedable generator in the model, never `Math.random`, and expose a hook (`window.__nextToken.roll(u)`) that sets the draw, so a screenshot or a test can replay any outcome. The motion of a random event stays calm: an eased glide to the result, then the lit part, not a tumbling die. - **Checks for a small figure.** With no pipes, cables or round parts to prove, skip the audit; the line check in both themes and in every resting pose (after a draw, at the extremes of the control), desktop and 390 px captures, a contact sheet of the event and 4× close-ups are what apply. Two ways this fails, both seen in practice: - **Too abstract.** Stacked plates, floating slabs or labelled boxes don't read as anything. - **Too flat and bland.** One big slab with a few lines on it has no volume, no parts and nothing to discover. The fix for both is the same: a recognisable machine (for an abstract concept, a recognisable instrument made of the concept's own parts), built from many small, precise, purposeful parts. **Every creation is highly detailed.** This holds for every figure, however small the idea or the request: a quick explainer is still a dense, real machine, never a few slabs. What scales with the size (workflow step 1) is how far you push it: a Figure gets the 4× sweep over its subject and mechanism, a Hero or an Epic over the whole drawing. - **Read the real thing.** Build it the way the real object is built: every screw, seam, bevel, bushing, label plate, cable with its connector, scale with its ticks, and the parts that hold the parts. - **Two looks.** At 1× it reads as one clear machine. At 4× every region still has something true to find; a bare face or an empty corner is unfinished work, not calm. - **Purposeful, never noise.** Detail comes from parts that belong to the mechanism. Each one sits on another part, ends on an edge, and fades rather than stops. - **Check it last.** Before you finish, zoom to 4× (over the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic) and add the real parts that are missing wherever a region is plain. ## Workflow Work in this order. Write the concept and the parts list before any geometry. Most of the quality is decided there. 1. **Size it before you start.** Name the size before any other work, and tell the user roughly how long it will take when the tier below calls for an estimate. - **Figure**: one mechanism, 40–80 parts, SVG with at most one WebGL layer. Name the size and build it without asking. Do not quote a time to the user, since a Figure often finishes in well under an hour. For the progress checks below, budget up to an hour. - **Hero**: a dense object, several WebGL layers or one 3D subject, 80–150 parts. About 2–4 hours. Say so, then build it unless the user asked for something quick. - **Epic**: a whole vehicle, building or plant with many moving systems, or the 3D mode on more than one subject. 6 hours or more. Do not start it. Give the estimate, propose a Hero version that keeps the core of the idea, and list what the full version would add. Build the Epic only when the user chooses it. - Whatever the size, have a working page that opens in the browser within the first hour, and deepen it from there. Run `date` at the start and at each step, and when a step runs past twice its share of the estimate, tell the user where it stands and what is left. 2. **Truth first.** Collect the real numbers and behaviour of the thing being explained: sizes, counts, thresholds, curves. The readout, motion and caption must all be true. If you can, write the model as a small pure module, like `rig.ts` in the test-rig example, and drive everything from it. 3. **Invent the object.** Pick a machine, tool, instrument or piece of furniture whose mechanism maps one-to-one onto the concept. Say in one sentence what moves and what that motion means. Never copy a reference image: take its finish, not its object. 4. **Parts list.** Write at least 40 parts (40–80 for a Figure, 80–150 for a Hero, more for an Epic), grouped: base (plate, feet, groove, corner screws, label plate, ruler strip), structure (posts, rails, frame bars, end stops), mechanism (carriage, bushings, springs, plunger, needle), controls (knurled knob, thumb screw, crown) and the subject itself, which is the one lit part. Each part sits on another part. `references/craft.md` has a vocabulary of parts with recipes. 5. **Plan the world.** Use world units of about 1 px of the real thing: x and y on the floor, z up. Lay out plans as rounded rectangles `{x, y, w, d, r}` and circles. Choose the camera: azimuth 45° by default, 35–60° to show the long side, elevation 30° (higher, like the ripple tank's 44°, to look into something open). Then fit with `fitProjection(points, width, height, { pad, azimuth })`. 6. **Build geometry once.** Precompute every static path in a geometry module with the kit: `slabOf`, `cylinder`, `extrude`, `planOutline`, `topTicks`, `knurl`, `coil`, `radialTicks` and so on. Round parts on any axis (bells, flanges, valves, actuators) come from `kit/lathe.mjs`, and pipes and cables from `kit/tube.mjs`. Moving parts are built once at a rest position and moved with `translateAlong`. Only parts whose shape changes are rebuilt per frame. 7. **Paint back to front, bottom to top.** Sort repeated parts with `depthOf`. Split anything that wraps another part into a back half and a front half: the springs around a rod, the bars of a frame around a carriage. A moving part is drawn in the slot where it physically sits. See "Painter's order" in `references/craft.md`. Once a figure has pipes, cables and round parts, stop tuning keys by eye: record each item's shape as you put it into `A.recorder(P, { order, spring })`, let `A.settle(R, P)` measure true depth wherever two items overlap and set the keys, and end the build with `A.auditOrExit(R, P)`. Route pipes before you draw them: real ports at both ends, legs along the structure, crossings on straights, collars gapping the pipe (`references/craft.md`, section 8). Record the true shape of what you draw: a stand-in shape that silences the audit is a bug, and the audit fails one. A rod that slides out of a gland is one rod from `G.rodOut`, recorded with `A.slide`. 8. **Frame it.** Put the figure in a card: `Fig n` at top left, a short title at top right, the interaction hint at bottom left and the live readout at bottom right. Legend keys and a true caption go under the card, and words stay out of the drawing apart from at most one tiny engraved label. 9. **Make it live.** Use calm time-based motion: exponential follow or real springs. An idle tour loops when nobody touches it. The figure responds to pointer and keyboard, stops when offscreen, and goes still under reduced motion. Write attributes on refs and don't re-render per frame. See `references/motion.md`. 10. **Verify like a critic.** The owner finds faults by zooming in, so you zoom in first. Read every PNG you make. - Capture desktop, phone (390 wide), hover and several moments of motion with `scripts/capture.mjs`; use `scripts/drive.mjs` for sequences, the apart pose and the `--no-webgl` fallback. - **Run the audit to zero.** With pipes, cables or round parts, `node build.mjs --audit` must end `audit passed`, every failing line at 0: clearance at rest, apart and in mid-flight, terminals, loose ends, folds, self-overlap, crowding (against slender solids too), stand-in shapes, support, wobble, saddles and depth order. Fix an interlock in the geometry, never with a key bias. - **Run the line check to zero.** The audit never sees line markup. `node scripts/lines.mjs page.html --out shots/lines.png` (and on the `-light` page, and in every resting pose with `drive.mjs`'s `["lines"]` action) must print `line ends: 0`: every seam, rule, rib, tick and pointer ends on a stroke, a dot or an outline. Read the ringed PNG. - **4× close-ups of every junction in both themes** (build `--light` too), assembled and apart: every flange, tee, elbow, clamp, valve, bellows, band, saddle, ball joint and crossing, every curved body (no tone staircase), every place a texture ends (on an edge or fading), flat box faces with their seams, every textured hose at a chunk joint, every sliding rod at rest and at full stroke, and every window with text or shader content in it. - **Shaders:** a contact sheet slowed with `["slow", 0.05]` across every transition; nothing pops, no colour switch, edges read 0. In light, also log ink per frame through each transition (it must follow the light monotonically) and crop where the ink meets strokes at 8–16× (`references/verify.md`, "Light-theme shaders"). - Put your PNG next to the examples and ask: is it instantly a real object, rich in small parts with volume, with every line ending on something? Is it highly detailed at 1× and still rewarding at 4×, with no bare face or empty corner? Iterate until yes. Checklist: `references/verify.md`. ## Setup The kit is in `kit/`: | File | Use it for | |---|---| | `kit/iso-kit.ts` | The whole core in one file: camera, solids, a parts library, an SVG string renderer, card and page builders, and the CSS (`ISO_CSS`). Copy it into a TypeScript project. | | `kit/iso-kit.mjs` | The same core compiled to plain ESM, for Node scripts. Import it directly. | | `kit/iso.css` | `ISO_CSS` written out. Import it once and put `class="iso"` (plus `data-theme="light"` for light) on a wrapper or `<body>`. | | `kit/react/draw.tsx` | Server-safe React components: `Solid`, `Line`, `Wire`, `Dots`, `FaceText`, `IsoFigure`, `Plate`. | | `kit/react/live.tsx` | `"use client"` hooks: `useReadout`, `useLoop`, `useEased`, `useInView`, `useStillness`, plus `spring` and `follow`. | | `kit/lathe.mjs` | Round parts along any axis: `frameAlong`, `lathe` (smooth shading switches on by itself for any profile with a smooth knot), `solidOf`, `disc`, `ringBand`, `sphereOf`, `saddleOf`, `bandsOf` (one curved body cut into depth-sortable bands that share one surface, with one outline from `sidesOf`), visible arcs, ribs and bolt circles on round surfaces (`ribsOf` and `dotsOf` take `fade`, `ribsOf` takes `seams`), `rodOut` for a rod that slides out of a gland, and `fillet` for pipe routes. | | `kit/tube.mjs` | `tubePieces` splits a 3D pipe or cable into depth-sortable chunks (with `breaks`, `gaps` under collars and clamps, elliptical end caps, and `rings` for a braid, corrugation or wrap that follows the cross-section through every joint), and `tubeSvg` draws one; `crossingsOf` finds where a route passes through a clamp, `runsOf` the drawn runs. Its `.tb-*` fills are in `ISO_CSS`. | | `kit/audit.mjs` | Proves the geometry. `recorder(P, { order, spring })` collects items, shapes (turned bodies, slabs, prisms, boxes, balls, tubes, pads cut by their host) and routes as the helpers draw them; `settle(R, P)` ray-casts every overlapping pair for true depth, at rest, apart and in the replayed explode, and sets the painter's keys (`overlapsOf`, `orderKeys`); `auditOrExit(R, P)` is the `--audit` hook that runs every check (`clearances`, `solidClearances`, `terminals`, `looseEnds`, `supports`, `folds`, `selfOverlaps`, `crowding` with slender solids as lines, `coverage` for stand-in shapes, `wobble`, `seats`, `depthOrder`) and exits non-zero on any problem. `A.slide` records a rod that slides out of a gland. `references/kit.md`, section 12. | | `kit/gl.mjs` | The WebGL layer for shaders inside the drawing: `GLSL_ISO` (the camera in GLSL, including `rayOf` and `pixelOf`), `glCamera(P)`, `glLayer(...)`, and the JS envelopes `smooth`, `burst` and `settle`. For 3D figures, `GLSL_TURN` and `glslPose(name)` bring world points into a moving group and mask a front canvas with `coverOf`. | | `kit/turn.mjs`, `kit/turn-build.mjs`, `kit/turn-audit.mjs` | The optional 3D mode. `turn-build.mjs` has `turning(P, { recorder })`: groups (turn, slide, hinge, free), live layers, convex parts with materials, and `T.build`. `turn.mjs` is the runtime: `TURN.mount`, the controller, `TURN.cover`, `TURN.poseUniforms` and `TURN_CSS`. `turn-audit.mjs` has `orbitOrExit`, the orbit audit over every turn angle and motion clip. `references/3d.md`. | | `scripts/capture.mjs` | Headless Chrome screenshots, close-ups and console checks, with WebGL on. Run with `node`. | | `scripts/drive.mjs` | Scripted headless Chrome with WebGL: move, click, key, slow motion, wait-until, eval, shots and contact sheets that catch pops; `--no-webgl` and `--preload`. | | `scripts/lines.mjs` | The line-end check on a built page, in headless Chrome: every texture line must end on a stroke, a dot or an outline. Rings the failures with `--out`; exits 1 on any. | | `scripts/inline-kit.mjs` | `kitScript()`, `glScript()` and `turnScript()` return the kit, the WebGL layer and the 3D runtime as source to inline into a standalone page. | | `scripts/turn-check.mjs`, `scripts/turn-bench.mjs` | 3D figures: fidelity against the kit builders, a z-buffer order check, pixel pops, line ends at 24 angles and frame timing in the browser; Node CPU time per part kind. | Pick the path that fits the project: - **Standalone HTML, a static SVG, a blog post or any non-React site.** Write a Node build script like `examples/dial-indicator/build.mjs`. It imports `kit/iso-kit.mjs`, assembles strings with `solidSvg`, `lineSvg`, `dotsSvg`, `wireSvg` and `faceTextSvg`, wraps them with `figureSvg` and `plateHtml`, and writes the page with `pageHtml({ script: kitScript() + live })`, or `kitScript() + glScript() + live` when it has a shader. For a lone `.svg` file use `figureSvg({ standalone: "dark" | "light" })`, which embeds the styles. - **React or Next.js.** Copy `kit/` into the project (for example `components/iso/`). Keep geometry in plain `.ts` modules, build the static art in a server component with the `draw.tsx` components, and pass it as element props to one `"use client"` live component that moves things. `references/react.md` explains the split and a trap with large pages. ## Craft rules (short form) These are the rules that separate a crafted figure from a diagram. `references/craft.md` gives the full reasoning and recipes. - **Solids, not wireframes.** Every part is filled in paper colour and painted in order, so things in front hide things behind. No see-through edges and no dashed hidden lines. - **Let the kit shade.** `extrude` returns four side tones by facing plus a lighter top. Keep it for anything with height, and use `flat` only for floors and inlays thinner than 1 unit. - **Tone hierarchy.** Base and feet are `lo`, structure is `mid` and the subject is `hi`. Exactly one accent is `lit` (white in dark, black in light): the part the reader should watch. Grooves are `faint`, minor ticks `lo`, major ticks `mid` and the zero mark `hi`. Use colour only when colour is the subject, such as red, green and blue channels. - **Small precise detail, never noise.** Use bevels of 0.4–1.6, grooves inset 3–5 from an edge, screws as 0.4–0.55 dots with a ring at the corners of plates, knurls on knobs, ruler ticks every 4–5 units with a major every 5th, and a label plate with three rule lines. Each detail belongs to a part. - **Lines resolve.** Every line ends on something: a face, another line or a terminal dot. Connectors are `Wire`s whose terminal dots land on objects. There are no leader lines, balloons, dimension arrows or labels floating in the air. - **No pipe or cable passes through anything.** Not through another pipe, cable or solid, at rest, apart or in any frame of the explode. Keep about 1.5 radii of clearance to everything but a line's own connection, run lines beside the structure rather than across the subject's face, and gap a pipe wherever a flange, band, bellows, clamp or valve wraps it. Parts meet flush, tangent or rim-on-surface, never sunk in: a flange's back face lands on its port (on a saddle pad, `G.saddleOf`, when the port is curved), a branch leaves from a ball tee, and every end, apart too, finishes on a flange, coupling, connector shell or terminal dot. - **Every crossing is drawn in true depth order.** Where two items overlap on screen, the nearer is drawn on top, proved by `A.settle` and the audit, not tuned by eye. Lines cross on straights, well apart in depth: a bend, end or clamp that lands on another line's outline reads as a joint (a rod, tie rod, post or rail counts as a line: a pipe's last leg never runs along one on screen), a leg that points at the viewer and then turns pinches to a V (leave such a port through an elbow ball), and a route never doubles back on screen. A pair that can't be ordered interlocks, and the fix is a cut or a tangent contact, never a bias. - **Curved surfaces shade smoothly, never in a staircase.** Mark curved knots smooth (`[s, r, 1]`) and `lathe` shades them from one monotone curve that also draws the outline; give any curved body larger than a fitting an analytic profile with `smooth: { slope }`, cut long bodies with `G.bandsOf`, and use `G.sphereOf` for balls. Tone bands taper to nothing instead of ending on a straight cut, and seams go under the outline (`inner`). - **A texture ends on a real edge or fades.** Ribs, bolt circles, rule lines, seams and grooves never stop in the middle of a surface, flat or curved: `ribsOf` and `dotsOf` take `fade` (draw faded ribs with `fadedSvg`), a rib row ends on its seams (`ribsOf(…, { seams: true })`), a seam or weld on a box wraps its corners (`k.sideSeam`, as the slab's `inner`), a line on one face runs edge to edge, vents are closed slots, ticks hang from a ruler strip's edge, a single line that must stop ramps out with `fadeLineSvg`, and a row whose features face up (holes on top of a ring) is seen all round (`all: true`). A texture on a pipe (braid, corrugation, wrap) follows its cross-section and runs through every chunk joint: `tubePieces(…, { rings })`, never a dashed stroke down the centreline. A pipe's shine and shade run onto its end caps and through every chunk joint. Only rule lines that stand for printed text, a needle's tail and a pointer's tip may stop on a bare face, marked `free`. - **Shaders never pop or cut off.** Every uniform rides an envelope with an attack and a decay, phase changes are crossfades driven by the physical quantity, every term reaches zero before any bound or canvas edge, and the effect lives in world space in the figure's own camera (see below). - **Things on faces fit them.** Text a machine displays fits its window at its widest string and is clipped to it; content seen through an opening is clipped by the opening as well as by its own plane, with the recess walls drawn. - **Volume.** Give things real thickness, make them stand on other things, and put a soft halo under the base and under anything lifted. - **Composition.** Centre the figure and let it fill about 55–65% of the card. Keep a consistent scale across a set of figures, and make sure it still reads at 390 px wide. ## WebGL inside the drawing Use a shader only for what lines can't draw: fire and exhaust, water and caustics, real lamp light, glow, steam. The parts stay SVG. The core rules come from the first shader figure, whose colour change between phases looked cut rather than crossfaded: - **Three layers.** A back SVG holds what the effect covers, the canvas holds the effect (`pointer-events: none`), and a front SVG holds what covers it. Split a part, or the effect, when it is on both sides. - **World space, same camera.** Build every shader point from `onFloor`, `onWallX` and `onWallY`, or march the pixel's ray `rayOf(vb) + t·towardViewer()`. Size everything in world units times `uK`. Anything lying on a plane registers exactly with that plane's SVG lines. - **Nothing pops.** Every uniform follows an envelope with an attack and a decay (the kit's `burst` for events, `settle` for levels). Phase changes are crossfades where colour shifts continuously with the physical quantity, and a fade-out only gets darker. - **No hard cut-offs.** Every term reaches zero before any `if`, bound or canvas edge, and before any cell edge of a cell noise. - **Seen through an opening, masked twice.** Content behind a window or a recess is masked in its own plane and by the opening in the face plane, multiplied (`references/webgl.md`, section 5). - **Blending.** Use `tonemap()` and dither. In dark, a canvas that only adds light uses premultiplied output with `mix-blend-mode: screen`, and one that must also darken (the ripple tank's water) stays normal-blended. - **Light theme ships, and its shaders are designed for paper.** Use normal premultiplied alpha, never screen. Write the colour the paper should show and solve for the ink. A glow is a saturated body with a pale core, a coloured rim and an alpha floor, never a darkened dark-theme colour: that gave the Raptor's "red ink smoke". - What the light falls on takes a tint, and the cavity that holds it gets a darker physical ground (soot, refractory, water depth). - Hot bodies follow one blackbody ink table shared with the fallback. - Ink only grows with light: fade the finished ink once, never one term at a time. - Ink ends at each stroke's inner edge. - Measure ink per frame. `references/webgl.md`, section 8, has the recipes and the faults reviewers found. - **Restraint.** Cap the device ratio at 1.5 and render only while the effect is active and onscreen. Reduced motion gets one still frame, and the SVG must still read without WebGL. Read `references/webgl.md` before writing any shader. ## 3D: figures that turn (beta) 3D is an option like WebGL. Use it when a part moving in depth is the idea: a dial or turntable that turns, fingers that flex, a piece that breaks off and falls. The rest of the figure stays static kit art. It is in beta: the engine is proved on its example and one test figure, not yet on a production figure. Turn one subject at a time and keep the clutter around it static. A small figure runs at 60 fps; a dense 380-part flexing hand measured about 20 fps at 1440 and 13–15 fps on a phone profile (390 px, 4× CPU throttle) on a heavily loaded machine. - **Live parts are the kit's own picture.** Each frame, convex kit primitives (prisms, rounds, domes, balls with flats, lathes, tubes) are re-projected and re-shaded by the kit's rules. A turn by θ is the kit drawing at azimuth `a − θ`. - **Order comes from separating planes.** Same-group pairs get a plane fixed in the group, pairs on different links get one fixed in their common frame, and the rest are solved each frame. The audit proves there are no cycles at any angle or in any clip frame. - **Contacts are flush, tangent or rim-on-flat.** Joints are coaxial knuckle discs on the child group that touch the parent's link, or the audit calls the whole child group loose. No live line spans two moving groups: a cable crosses a joint through a clip on each side, or reaches the turntable through a slip ring at the hub. - **Live tubes bend in near-horizontal planes or inside fittings, and never point along the view.** A bend needs R ≥ 2r / sin²(e − θmax), where θmax includes the pitch range of every hinge it rides: 8r is the bound when flat, and on a ±12° hinge it is 21r. Each route runs from mount surface to mount surface, with its ports in `gaps`. That holds at every angle and in every pose. - **Materials remap the ramp.** Gold, chrome, steel, gunmetal, rubber, brass and copper each have a light and a dark set; tones still come from facing. - **Free groups break off without a jump.** Coaxial parts are fixed pictures with turning textures. - **Shaders follow the motion.** Anchor them to a group with `controller.anchor` or `toGroup`, occlude a front canvas with `coverOf`, and keep world effects (smoke, falling sparks, oil on the bench) out of group space. - **Verify with `--audit`** (`turn audit passed`) and `scripts/turn-check.mjs` (`--fidelity`, `--order`, `--pops`, `--lines`, `--perf`). Iterate with `--quick --only=…`. Only a full run proves the figure, and it takes 15–20 minutes for 30 parts, so run it in the background. Read `references/3d.md` first. ## References Read these as you need them: - `references/craft.md`: the look in depth, including the parts vocabulary with code, tone tables, painter's order patterns, round parts, pipes and harnesses, exploded views, screens on faces, and the anti-patterns that got figures rejected. Read it before designing. - `references/kit.md`: the API, with signatures and examples. - `references/motion.md`: live behaviour (loops, springs, tours, pointer, keyboard, accessibility, reduced motion), plus envelopes for one-shot events, exploded views, depth re-sorting and arming, in vanilla JS and React. - `references/react.md`: the React/Next.js server/client split, element props and the large-payload key-warning trap. - `references/verify.md`: the `capture.mjs` and `drive.mjs` commands, the review checklist (including shaders) and common faults. - `references/webgl.md`: shaders inside the drawing: when one earns its place, the layer stack, the camera contract, ray-marching in the iso camera, the full API with a minimal page, the craft rules with the bugs that taught them, the light theme (inks on paper, per-phenomenon recipes, the reviewers' faults), and how to verify them. - `references/walkthrough.md`: how the test rig was designed, step by step, from concept to code. - `references/3d.md`: the optional 3D mode: groups and poses, live layers, convex parts and their rules, materials, free groups, hinge chains, painter's order, static clutter around a turntable, shaders that follow the turn, the orbit audit, browser checks, performance and limits.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.