How a scroll-driven 3D engine and its in-page editor were built — the three ideas everything rests on, the shape of the code, and the bugs that taught us the most. Written to be studied, not skimmed: the failures are here in as much detail as the design, because they are where the design came from.
Building a scroll-driven 3D page today means hand-authoring numbers: a
table of waypoints, a lerp() per camera framing, and a
browser refresh between every guess. The numbers are invisible — you
cannot see a waypoint, you can only see where the object ended up — so
tuning is a loop of edit, reload, squint.
3drossa is that work extracted. The whole experience — the path, the camera, the moments the page stops — is one plain-data document, and a visual editor runs inside your own page and writes it back to disk. You drag the path over your real headline, at its real size, at the depth it really sits.
Everything below assumes these. They are not jargon for its own sake — each one names a decision that could have gone another way.
p.p.The visible width of a perspective scene shrinks as you approach the lens. So a world-x that comfortably clears your text column at one depth is off-screen at another — and “stay out of the copy” is the entire job of a waypoint.
With the demo's lens (z 5.9, fov 38°,
aspect 1.6), the visible half-width is
3.80 world units at z = -1 and only
2.42 at z = +1.5. The same
sx = 0.62 therefore means two different world positions —
which is exactly right, because it means the same thing on screen.
sx 0.62 clears it by the same
proportion on both rows, which is the only thing a waypoint is ever
asked to do.
This is also the one thing a generic spline editor cannot do. Three.js's own editor, zz85's, the Cientos one — they all edit raw XYZ, which throws away the property that makes the table work. So the editor drags in world space and stores screen space, converting on every pointer move.
// src/editor/JourneyEditor.js — world in, screen space out
const screenPatch = (position) => ({
sx: Number(journey.toScreenX(position.x, position.z).toFixed(3)),
y: Number(position.y.toFixed(3)),
z: Number(position.z.toFixed(3)),
});
Depth is typed, never dragged. A 2D gesture has no third axis to give,
and z is the one that decides whether the subject passes in
front of the copy or behind it — too important to guess at.
Each row declares the page progress it belongs to. The subject is at that waypoint when the reader is at that point in the page — guaranteed, not approximately.
The obvious alternative is to space waypoints evenly along the spline and hope they line up with the sections. They don't. Sections are wildly different heights, so an evenly spaced spline drifts off the content it was written for, and a beat written for one section plays over another.
// src/core/journey.js — shared by the subject's path and the camera's
function tableToU(table, p) {
const last = table.length - 1;
if (p <= table[0].p) return 0;
if (p >= table[last].p) return 1;
let i = 0;
while (i < last - 1 && p > table[i + 1].p) i++;
const a = table[i], b = table[i + 1];
return (i + (p - a.p) / (b.p - a.p)) / last;
}
The subject moves faster between distant waypoints, which is correct: it is covering more ground in the same amount of scroll.
A station pins the viewport for a written run of scroll: the page stops, the camera takes up a framing, and copy arrives on beats inside the held frame. Between stations the pin releases and the subject travels. That alternation is what makes a page read as a place rather than a stack of sections.
At a station boundary the spline's only job is to hand off. The obvious implementation states the position twice — once in the station, once in the path — and the two drift apart every single time a station is retuned. The drift reads as the subject teleporting, and it is miserable to diagnose because both numbers look plausible.
// A path row either states a position, or asks a station for one.
{ "p": 0.121, "station": "launch", "at": 0 } // derived — cannot drift
{ "p": 0.450, "sx": -0.72, "y": -0.15, "z": -1.8 } // authored
The editor shows derived rows in teal, read-only, with their resolved values visible. You can see the spline and the station agreeing at the boundary, which is exactly the thing that used to silently stop being true.
These are layers, not steps — each depends only on the ones above it. The rule that shapes everything: core is pure.
The runtime. No React, no DOM, no WebGL — three only,
for vectors and splines. Parses and validates the document, samples
the journey, converts screen space to world space.
createStage, <Stage>,
<Station>, <Subject>,
useSubject, and the camera director. Owns the scroll
loop, the pin measurement and all damping.
The overlay. Handles, drag, navigation, the occlusion volumes, the
camera gizmo and preview, the inspector panel, the draft in
localStorage. Dev-only; the one layer that pulls drei.
The save logic as plain functions, plus a connect middleware and a Vite plugin. Knows nothing about any framework.
createSaveRoute() — about forty lines of adapter over
the layer above.
Not a layering nicety. It is what makes an experience checkable: a whole journey can be swept in node and asserted against, with no browser, no GPU and no screenshots.
import { Journey, makeSample } from "3drossa";
const journey = new Journey(doc);
const out = makeSample();
for (let p = 0; p <= 1; p += 0.001) {
journey.sample(p, out); // subject, camera, beats, station, stationT
}
Every motion bug this engine was extracted from was originally found by instrumenting a running page, because there was no other way to look at it. Now a continuity violation — the path jumping, a station handoff not meeting exactly — fails in CI in 150ms.
Damping and blending are deliberately not in core.
sample() reports where the path is and where the station
wants the subject; easing between them across time is runtime state, and
belongs to the renderer. Keeping it out is what keeps sample()
a pure function of p.
The finding that made this an engine rather than a spline library: every station framing in the original hand-built site was already two endpoints and an easing, wearing a function costume.
// what it was
out.position.set(lerp(1.1, -0.3, ease), lerp(-0.35, 0.9, ease), lerp(2.9, 6.6, ease));
// what it is
"camera": [
{ "t": 0, "position": [ 1.1, -0.35, 2.9], "target": [0.9, -0.5, 0], "fov": 34, "ease": "smoothstep" },
{ "t": 1, "position": [-0.3, 0.9, 6.6], "target": [0.1, 0.35, 0], "fov": 40 }
]
No logic a keyframe pair could not hold. So travel waypoints and station
framings collapse into one format, and one tool edits both —
where previously stations could only be changed by hand-writing
lerp() calls, which is the worse of the two authoring
problems.
A keyframe may carry channels the engine has never heard of — a fog
density, a light colour — and they interpolate and round-trip untouched.
ease sets a segment's easing; <channel>Ease
overrides one channel.
// src/core/track.js
const RESERVED = new Set(["t", "at", "ease", "id", "p", "station"]);
export function channelsOf(key) {
return Object.keys(key).filter(
(k) => !RESERVED.has(k) && !k.endsWith("Ease") && (isNum(key[k]) || isVec(key[k]))
);
}
Per-channel easing is not gold-plating. The original site eased position
on smoothstep but scale on raw t; without it the
conversion would not have been lossless, and lossless was the whole proof.
Stable key order, fixed precision. parse → serialize → parse
is identical, so a save that changes nothing produces no diff. A tool that
dirties a file just by opening it is a tool people stop using.
This reaches further than it looks. The starter document the scaffold writes goes through the same serializer, so a new project's very first Save also produces no diff — and the packaging check asserts exactly that, because hand-stringifying the starter was the obvious shortcut and it silently broke the guarantee.
One mutable object is the bridge. GSAP, Lenis or the built-in loop writes
to it; useFrame reads it. The scroll pipeline costs
zero reconciliations, which is why sixty-times-a-second
editing is free.
sample() into a reused object; nothing allocatesuseSubject writes position, scale and heading to the ref
All negative, on purpose. In React Three Fiber a positive
priority takes over the render loop and makes you call
gl.render() yourself; negative priorities only order the
callbacks, so automatic rendering still happens after all of them. The
blue steps are the editor's, and they are negative for a second reason —
see the preview, below.
A station has two positions. The pin is measured from the live
DOM — offsetTop, offsetHeight — and is
authoritative at runtime. The handoffs are the two path rows that
say where the spline meets it, and those are authored.
The document still owns the space around a station —
lead and trail, in viewport heights, applied as
margin. Because offsetTop already accounts for margin, moving
a station needs no arithmetic anywhere else: the pin, the editor readout
and every handoff follow for free. And the gap between two stations
is the travel section, so its length is a choreography decision,
not a layout one.
stage keeps its identity across a rebuild. That is deliberate
— it is what makes editing free — but it means anything reading the
document during render has no way to notice a change. Two
components were quietly broken by exactly this before it was named and
given one hook.
// src/react/useDocument.js
export default function useDocument(stage) {
const subscribe = useCallback(
(listener) => (stage?.subscribe ? stage.subscribe(listener) : () => {}), [stage]
);
const snapshot = useCallback(() => (stage?.getVersion ? stage.getVersion() : 0), [stage]);
useSyncExternalStore(subscribe, snapshot, snapshot);
return stage?.journey.doc ?? null;
}
The overlay has to sit above the host's content without hiding it — and that turned out to be the single most expensive problem in the project, because two of its failure modes look identical to bugs elsewhere.
First: canvas.parentElement is R3F's own wrapper,
not your positioning container. Raising it achieves nothing, because it
still sits inside the host's container — which is typically
fixed inset-0 -z-10, deliberately behind the page. So the
whole ancestor chain is lifted to <body>, and any
static ancestor gets position: relative first, since
z-index does nothing without one.
Second: the page content is then lifted above the canvas with
pointer-events: none, so it stays legible while clicks fall
through to the handles. And its backgrounds are knocked out for the
duration — because lifting opaque sections over the 3D just gives you a
normal-looking website with an editor panel beside it and no path visible
anywhere, which is worse than either choice alone.
pointer-events: none is not evidence of stacking order.
elementFromPoint happily reports the canvas underneath while
a section is still painting on top of it. Hit-testing and paint order are
different questions, and confusing them cost about an hour.
drei's TransformControls was tried first and never received a
single pointer event, while R3F's own click events on the same meshes
worked fine. Selection worked; dragging silently did not — which reads as
“it moves when I click but I cannot hold it”.
Rather than keep guessing why a third-party control was deaf, dragging
became a plane raycast on the event path that was already demonstrably
working: a plane through the point, facing the camera, with
pointermove on the window — because a fast drag
routinely leaves the canvas, and a listener that stops at its edge drops
the gesture halfway.
stage.rebuild() runs in an effect, which is a frame too late.
Add a station and the working document immediately contains a row handing
off to it while stage.journey has never heard of it —
resolving that row throws, and the editor unmounts mid-edit.
const lastGood = useRef(null);
const working = useMemo(() => {
try { lastGood.current = new Journey(doc); } catch {}
return lastGood.current ?? stage.journey; // you may type "2" on the way to "0.25"
}, [doc, stage]);
This was the worst problem the editor had, and it is not a bug in any single piece of code. The moment you take the camera to inspect the path, the thing you are authoring — the shot — becomes invisible, because the shot is defined by a lens you are no longer looking through.
So you fly out to see the path, cannot judge the framing, fly back to judge the framing, cannot see the path. Every adjustment cost two trips, and the trips are where the session goes.
Both pieces need to know where the page camera is, and the first version of both worked it out themselves. That is the mistake this codebase keeps naming: the gizmo drifted from the real camera the moment anything about the director changed, and it was wrong in exactly the cases that matter — at a station, and in the camera-path mode the gizmo exists to help author.
So the branch table moved out of <Stage> into one
function. <Stage> damps the answer onto the real
camera; the editor reads the same answer undamped, because a gizmo should
show where the shot is composed, not where a lerp has got to.
// src/react/director.js — pure in everything but `out`
export function solveCamera(stage, sample, held, out, overrides = {}) {
// locked · orbit · path · follow · beats, then the station blend
out.position.lerpVectors(followPos, sample.camera.position, k);
out.target.lerpVectors(followTarget, sample.camera.target, k);
out.fov = MathUtils.lerp(baseFov, sample.camera.fov, sample.station ? k : 0);
return out;
}
The obvious implementation of a picture-in-picture takes over the render
loop: a positive useFrame priority, two gl.render
calls, a scissored viewport for the corner. It works, and it makes the
editor responsible for the host's main pass as well — so any host with
postprocessing silently loses its effects the moment the editor opens.
Drawing into a texture touches nothing. At a negative priority the automatic pass still happens afterwards, exactly as it did before.
const previous = gl.getRenderTarget(); gl.setRenderTarget(target); gl.render(scene, pageCamera); // the host's scene, its lights, its fog gl.setRenderTarget(previous); // and the normal pass follows
The quad showing that texture is then written in front of the editor's camera each frame rather than parented to it — the host owns the camera object and may not even have it in the scene graph, so a child of it is sometimes rendered and sometimes not.
Rendered naively that view is useless: it contains the handles, the spline, the keep-out volumes and the camera gizmo — a picture of the editor rather than a picture of the page. Culling by layer is the only way to separate them without a second scene, and a second scene is worse, because the host's lights, fog and background all live in its one.
// src/editor/layers.js
export const EDITOR_LAYER = 19; // not 1 — hosts use low layers
// assigned, not enabled: an editor object has no business being
// visible to the page camera at all
if (object.layers.mask !== EDITOR_MASK) object.layers.mask = EDITOR_MASK;
Claimed every frame, because the editor's subtree changes constantly — a waypoint added, a station's keyframes appearing when it is selected — and there is no single mount hook that covers all of it. It costs one integer compare per object on the frames where nothing changed, which is all of them.
That layer also made every handle unpickable, for a reason worth its own case file below.
The first version of the scene view was accurate and unusable, in four ways that are each obvious once named and none of which are about correctness.
| Was | Reads as | Now |
|---|---|---|
| A fixed-radius sphere per waypoint | A dot at the far end of the path and a beachball at the near end — people assumed the far rows had no handle | Unit geometry scaled per frame to a constant pixel size, at the handle's own depth |
| A bare spline in a void | No depth at all: two waypoints at the same screen position and very different depths look identical | A ground grid, a vertical stem from each waypoint, and a mark where it lands |
| No direction | Which way the subject travels is a guess, and it is half of what you are authoring | Arrowheads spaced by arc length, so the long travel sections get them too |
| Unlabelled handles | Moving “#4” means clicking handles until the right panel row highlights | A sprite label naming each row the way the panel names it |
Arrowheads are spaced by arc length, not by parameter.
getPoints is evenly spaced in the curve's parameter, which on
a centripetal Catmull-Rom is emphatically not evenly spaced in distance.
Arrows bunched up wherever waypoints were close together and vanished
across the long travel sections — exactly backwards, because the long
sections are the ones where you cannot otherwise tell which way the
subject is going.
Labels are sprites, not <Html>. The
editor goes to considerable trouble to lift the canvas's whole ancestor
chain above the host's page and then lift the host's copy back above
that; an HTML label lands in R3F's own wrapper somewhere inside that
stack, so it is either buried under the copy it annotates or painted over
the panel. A sprite is in the scene, which is where a label on a point in
the scene belongs.
X-ray is on by default — handles draw through geometry — because a waypoint tucked behind the subject's own model is a waypoint you cannot select, and “the handle is missing” is indistinguishable from “this row has no handle” until you have flown around to check.
Two handlers, deliberately separate: one takes JSON and validates it with the engine's own parser, the other takes opaque bytes it cannot inspect. Those deserve different rules, and folding them together is how the strict one ends up with the loose one's checks.
Nothing from a request is ever spliced into a file. The document is parsed, validated, and re-serialized from the validated result, so the worst a malformed request can do is fail. A route that takes strings from a client and writes them into a source file is a remote code execution hole, whoever it was meant for.
For dropped models the rules are: an allowlist of extensions, the filename reduced to a safe basename, the joined path re-checked against the asset directory, an 80 MB cap, and a flat refusal in production.
.js”
rule is a list of the attacks someone already thought of, and the
interesting ones are always the other kind.
All of that used to live inside the Next route handler, which made “can I use 3drossa?” mean “are you on Next?” for anyone who wanted the editor to write to disk — and writing to disk is the entire product. It is now plain functions taking and returning plain data, with thin adapters on top.
// vite.config.js
import { rossaVite } from "3drossa/server";
export default { plugins: [react(), rossaVite({ file: "src/journey.json" })] };
// or anything that speaks connect middleware
app.use(rossaMiddleware({ file: "src/journey.json" }));
// or Next's App Router — about forty lines of adapter over the same functions
export const POST = createSaveRoute({ file: "app/journey.json" }).POST;
FormData upload, because a hand-written multipart parser fed
only its own fixtures is a parser that has not been tested.
The page most likely to have someone trying a model on it is a static one — a Pages deploy, a preview build, the playground this project ships. The editor used to refuse the drop there with “no save route”, which is honest, and also means the most interesting thing the tool does was unavailable in the only place most people will ever meet it.
An object URL is the obvious fallback and was rejected for a good reason:
it dies with the tab, so the document you saved refers to a model that no
longer exists. The reason holds; it is an argument against object URLs,
not against local models. The bytes go into IndexedDB and the document
records a rossa-local: URL.
| Where you drop it | Where it goes | What the document records |
|---|---|---|
| A page with a save route | public/models/, in your repository |
/models/ship.glb — clone the repo and both halves are there |
| A page with none | That browser's IndexedDB | rossa-local:ship.glb — survives a reload, lives in one browser |
There is a file picker beside the drop zone too, because drag-and-drop is the nicer gesture and not a reliable one: it does not exist on a touch device, it is awkward from inside an archive manager, and when a drop silently fails there is nothing to try again.
There is a gap between npm install 3drossa and a page you can
actually edit, and it is not a conceptual gap. The library is four imports
and a JSON document, and every one of them has to be in a particular place
relative to your framework.
None of that is hard; all of it is fiddly; and getting any one of them wrong produces a symptom that does not name the cause — a 404 on save, a model that vanishes on reload, a development tool in a production bundle. So the answer should be a command, not a checklist.
npm install 3drossa three @react-three/fiber @react-three/drei npx 3drossa init + app/journey.json the experience. commit it. + app/api/rossa/route.js lets the editor write that file back + app/api/rossa/model/route.js lets a dropped model land in your repo + app/page.jsx a starter page, editor already gated + public/models/ where a dropped .glb goes
It reads the directory rather than package.json to decide
what you are on — a monorepo root lists every framework anyone uses, and
the question is not “is Next installed somewhere” but
“where do routes go in this directory”. It never
overwrites: existing files are reported and left alone, so running it
again when you add a second page is safe.
The editor is a development tool and should not reach your visitors. Three things, all of which the scaffold sets up:
NODE_ENV, which keeps it out of the bundle rather
than merely out of the render.route.dev.js, listing
.dev.js in pageExtensions only in development —
so they are not routes at all in a production build, which is harder to
get wrong than a script that moves source around.journey.json and public/models/ are
committed. They are the experience. Everything the page does at
runtime is in those two places, and that is what makes it reproducible:
clone the repository and you have the whole thing.three, R3F and your
document. No editor, no drei, no save route.
The packaging check runs all of this from outside: it packs the tarball,
installs it into a throwaway project, imports every entry point,
runs the CLI, and asserts the starter it wrote is a document the
engine accepts and round-trips byte-for-byte. A tarball where
bin/ was left out of files is a tarball whose
README is wrong on line one.
Grouped by what kind of mistake they were, because the class is the transferable part. Each of these took real time, and most of them produced a symptom that pointed somewhere else entirely.
Pointer parallax was added after the damping lerp. That feeds
back: the next frame damps from a position that already contains the
offset, so it accumulates until damping bleeds it off as fast as it
arrives. Solving x = x(1−e) + Te + p gives
x = T + p/e — about 12× the intended
offset at 60fps with a damping of 4.5.
The amplification alone would only be too much parallax. What made it
shake is that e depends on frame time, so the
resting position moved on every slightly-longer frame — worst when the
camera was otherwise still, which is exactly what a station does.
Fixed by putting parallax into the target, never onto the damped result. Proved arithmetically in a node test, not by watching.
The stage learns how many pixels of scroll make one unit of
progress, by watching the ratio — because computing it from
scrollHeight is wrong by nearly a factor of two on a pinned
page (measured: document 17444px, actual progress range 9100).
But the exponential average started at zero. That is not merely imprecise for a few frames: everything derived from it is out by however far it has converged, and the error is invisible because the number looks like a number.
Seed an estimator with a bad-but-right-magnitude value, then learn. Zero is never a neutral starting point — it is a wrong answer that happens to be quiet.
A subject pinned in place parked correctly, then vanished to
[0,0,0] the first time any station took hold. The core was
right: it refused to move it. But useSubject independently
recomputed the station position from sample.subject — a
field a fixed sample never writes — and read the zeroes it was allocated
with.
Two places deciding where the subject goes is two copies of the waypoint table, one level down. The same disease as duplicated handoffs, in a different layer.
The draft loader validated by constructing a Journey — then
returned the raw parsed JSON, throwing the normalization away.
A draft written before a field existed still validates, because
Journey fills its own defaults internally. So the editor
received a document missing whatever had been added since, and died on
the first read of it.
The severity is the point: a stale draft is restored on every refresh, so this is a page nobody can get back from without knowing to clear site data.
Storage outlives the code that wrote it. Normalize on the way in, so a stored value is indistinguishable from a freshly parsed one.
R3F dispatches all its pointer events from one listener on the
canvas. OrbitControls has its own listener on that same canvas. So
stopPropagation() inside a handle's
onPointerDown is invisible to it — it only stops other R3F
objects — and both gestures ran on one drag.
stopPropagation stops propagation within one dispatch
system. A sibling listener on the same element is a different
system and never hears it.
The preview inset needs the editor's objects culled from the page camera's view, so they all moved to layer 19. Immediately, pressing a waypoint did nothing but orbit the view.
Raycaster tests object.layers against its own,
and R3F's raycaster is on layer 0. Moving the handles onto a layer of
their own made every one of them invisible to hit-testing — a filter
with no error, no warning and no trace in any stack.
What made it expensive is that the symptom points somewhere else entirely. “Pressing a handle rotates the world” is word-for-word the older OrbitControls bug, so the obvious reading is that the controls are stealing the gesture again — and you go and re-debug event dispatch, which is fine, and layers, which are not.
When you add a filter, enumerate everything that reads through it. The camera was the obvious reader; the raycaster is a camera-shaped thing that is not the camera.
After the layer fix a handle drag moved the waypoint correctly and still turned the view, by the same amount every time. A bit-identical number across three different fixes is not a gesture bug.
The frame-the-selection callback is rebuilt whenever the document changes, because what “everything” means changes with it. As an effect dependency, that disposed and reconstructed the controls on every edit — and a fresh instance adopts the camera with a new pivot at the world origin. Mid-drag it tore down the very object holding the gesture.
// the handlers go through a ref; the construction does not depend on them
const shortcuts = useRef({ onFrameSelection, onFrameAll });
shortcuts.current = { onFrameSelection, onFrameAll };
useEffect(() => { …build the controls… }, [camera, gl, set, get]);
An effect that constructs something long-lived must depend only on that thing's identity, never on callbacks whose identity tracks your data. The cheap test: stamp the instance, do an edit, see if the stamp survived.
The demo page's drop handler called preventDefault() and
then, three lines later, checked event.defaultPrevented to
decide whether the editor had already claimed the drop.
event.preventDefault();
setOver(false);
if (event.defaultPrevented) return; // always true. the rest is dead.
So the entire fallback was unreachable, and the first thing anybody
tries on the live demo — drag a .glb onto it — silently did
nothing. The check cannot be done by inspecting the event in any case:
both listeners are on the window, this one is registered first, and it
has no way to find out whether a listener that has not run yet is going
to handle it. It now asks the only thing that knows — whether the editor
is mounted.
A guard that reads state you just wrote is not a guard. And a drop
handler that returns without preventDefault hands the file
to the browser, which navigates away and takes every unsaved edit with
it — so the wrong-file case needs swallowing too.
useLoader throws, and a throw inside
<Suspense> with no error boundary above it unmounts
the tree. So a document naming a file that is not there is not “the
subject is missing”, it is a blank page.
That is not hypothetical. The path outlives the file in every ordinary
way: a teammate clones without the asset, a branch is switched, a draft
in localStorage survives the file being deleted. The draft
case makes it reproducible on every reload, which is the worst
version.
Anything that loads a URL recorded in data needs a boundary, and the fallback should be whatever the host already rendered. A missing preview asset is not a reason to take the page down.
The demo depends on the engine as file:../.., so
node_modules/3drossa is a symlink back into the repository.
Webpack puts everything under node_modules in
snapshot.managedPaths — the set of directories it assumes
change only when a package is installed — and served a cached chunk
after every rebuild.
The page kept working, so there was no error to notice. It simply ran the code from before the last build, and each “fix” produced a bit-identical wrong number that looked like the fix having no effect. Three went by before the served file itself was checked.
When a result does not change at all across a real change, stop debugging the change and verify what is executing. This is the same family as the npm lesson below: a package you did not reinstall is a package you are not running.
All the CDP tools called
Runtime.addScriptToEvaluateOnNewDocument. The method lives
on Page. The wrong domain returns an error nobody was
reading, so the injection simply never happened — and the harnesses had
been quietly testing less than they claimed for weeks.
A sibling of the same class, found later: the new harness proved a model
had loaded by counting blob: entries in
performance.getEntriesByType("resource"). A blob URL is
served from memory and never appears in the resource timeline, so the
check reported zero while the model was loading perfectly.
A test that cannot fail is worse than no test, because it is also reassuring. Prove a new assertion red before trusting it green.
The playground autosaves its draft to localStorage and the
harness's browser profile persists between runs, so each run inherited
whatever the last one left half-edited — including a model path whose
file the cleanup step had since deleted.
A harness may fail; it may not be unrepeatable. Clear the origin's
storage before navigating, and take back out whatever the run wrote into
the repository — an untracked file in git status after a
test run looks like a bug in the product.
Three times now. A “waypoints stay out of the text column” check that failed on good authoring — row 10 is legitimately high and far back. A snap test asserting “snapping changed something” on a document whose handoffs already matched the measured pin to four decimals. And three navigation assertions that encoded the wrong convention — a left drag carries the eye left, a right drag carries the pivot right — where the code was correct and the expectation was not.
A test that fails on good data gets deleted or, worse, gets the data “fixed” to suit it. Repair it by detuning a value first, then asserting it comes back.
Running npm install ../3drossa/3drossa-x.tgz with the
working directory inside the project adds
"3drossa": "file:3drossa-x.tgz" to its own
package.json — a fatal ENOENT on the next
install. It happened three times before it became a hard error in the
build script.
Nearby: a same-version tarball is not reinstalled — npm serves its cached copy and the lockfile integrity matches — so a consuming project can run stale code for a long time while you debug a fix that is already on disk. Bump the version; that is what versions are for.
When a mistake is easy to repeat, make it impossible rather than memorable.
The suite is split along the purity line, and the split is the whole strategy: anything that is a property of the data is asserted in node, and only what is a property of the interaction needs a browser.
| Pass | Runs in | Asserts |
|---|---|---|
npm test |
node, no browser | 65 tests. The path never jumps. Station handoffs meet exactly. An orbit holds its radius. A fixed subject does not move at any p. The director agrees with itself in every camera mode. The navigation's gestures, and that the horizon never rolls. The save rules, over a real socket. The scaffold's output. |
tools/exercise.mjs |
headless Chrome, CDP | The editor's own controls, pressed in order. Ordering failures only exist here. |
tools/navigate.mjs |
headless Chrome, CDP | Every key and every mouse button, as real input events. That the wheel zooms and does not scroll the page. That keys typed into a panel field are text. That a handle drag moves the waypoint and not the view. |
tools/static-model.mjs |
the production export | A dropped model on a page with no server — and that it is still there after a reload, which is the half an object URL could never pass. |
npm run verify |
a throwaway project | The packed tarball installs, every entry point imports, and npx 3drossa init produces a document the engine accepts. |
Nothing in it reaches into React state. Every step is a click or a keystroke a developer would make — which is the only way ordering bugs appear at all: a document referencing a station the live journey has not heard of, a rename leaving path rows pointing at the old id.
W (forward) ok Q is world up ok Q does not drift ok W typed in a field is text ok left drag orbits (radius held) ok right drag looks (eye held) ok wheel does not scroll the page ok the horizon never rolled ok the controls survive an edit ok pressing a handle selects it ok dragging a handle moves it ok dragging a handle does not orbit ok the page camera has a frustum ok editor gizmos are off the page layer ok a dropped model reaches the document ok fixture untouched all checks passed
That fixture line is not decoration. The editor can write
source files, so the harness diffs the demo's journey.json
before and after every run — after a selector once hit Save
and rewrote the fixture, leaving the next run starting from a document
nobody had authored.
Assertions are made on the scene, not the document, wherever both
are possible. Writing subject.rotation and reading it back
would pass just as happily if useSubject ignored the field —
and ignoring it is precisely the bug worth catching. Where the production
build hides the development handles, the harness measures something the
page cannot fake instead: a wrapped fetch, the DOM, the
stored draft.
The shape of almost every feature here is the same, and following it is the fastest way to add another:
src/core/document.js. Validate it there too — a document the engine would reject must never reach the disk.p. Write into the reused sample object; allocate nothing.path generates a route reproducing the current beats track,
so the switch is nearly a no-op on screen and you edit from where you
were. A mode switch that visibly throws your framing away is one people
undo rather than explore.
Read back, nearly every decision here is the same decision. When two
things must agree, do not maintain both — make one ask the other. Path
handoffs ask stations. The editor's scrub bar asks the page. The camera
gizmo and the real lens ask one solver. useSubject asks the
sample. The measured pin beats the written one.
And nearly every bug was the same bug: a second copy of something, quietly drifting. A duplicated position. A state that outlived its schema. An estimator converging from a wrong start. A chunk served from a cache after the file behind it had changed.
The newer ones add a second family, which is worth naming on its own: a check that cannot see what it is checking. A raycaster filtered onto the wrong layer. A guard reading a flag it had just set. A harness counting resource entries that a blob URL never produces. Each one was quiet, and each one was reassuring while it was wrong.
The engine is small. What took the time was finding out, one failure at a time, which things were allowed to be true in two places — and the answer was none of them.