Engine notes · v0.4.0

Inside 3drossa

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.

Core dependenciesthree, and nothing else Node tests65 Browser harnesses3 Entry points5, plus a CLI
Premisewhy it exists

The thing it replaces

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.

That last part is the product. A standalone 3D studio can only draw an abstraction of your page — a single “the text column is 79% of the frame” wedge — and you author against that guess. Here the keep-out volumes are measured from the live DOM, every frame.
Vocabularyunits & terms

The words, first

Everything below assumes these. They are not jargon for its own sake — each one names a decision that could have gone another way.

p
Page progress, 0 at the top and 1 at the bottom. Every table in the document is bound to it.
sx
Sideways position as a fraction of the visible half-width at that depth. Not a world coordinate. 0 is centre, 1 is the edge of frame.
t
A station's own clock, 0 on arrival and 1 on leaving. Independent of p.
station
A section that pins the viewport for a written run of scroll while copy arrives on beats.
travel
Everything between stations. The pin releases and the subject moves along the spline.
handoff
A path row that derives its position from a station rather than stating one.
beat
A moment inside a station when a line of copy arrives or leaves, in that station's clock.
shot
Where the page camera is, what it aims at and its field of view, for one value of p.
svh
Small viewport height. Station lengths are written in these, not pixels.

Ideaone of three

Waypoints live in screen space

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.

the visible frame at two depths — drawn to scale z −1 half-width 3.80 x 2.36 the copy z +1.5 half-width 2.42 x 1.50 the copy one number: sx 0.62 sx 0 · centre sx 1 sx 1
Two depths, the frames drawn to scale against each other. Approaching the lens costs you a third of the visible width — so the same world x would walk onto the copy. 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.

Ideatwo of three

Waypoints are bound to measured progress

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.

Ideathree of three

Stations and travel, and derived handoffs

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
Deriving makes the drift impossible rather than merely unlikely. This is the pattern the whole codebase keeps returning to: when two things must agree, do not maintain both — make one ask the other.

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.


Architectureby dependency layer

Five layers, one rule

These are layers, not steps — each depends only on the ones above it. The rule that shapes everything: core is pure.

3drossasrc/core

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.

3drossa/reactsrc/react

createStage, <Stage>, <Station>, <Subject>, useSubject, and the camera director. Owns the scroll loop, the pin measurement and all damping.

3drossa/editorsrc/editor

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.

3drossa/serversrc/server

The save logic as plain functions, plus a connect middleware and a Vite plugin. Knows nothing about any framework.

3drossa/nextsrc/next

createSaveRoute() — about forty lines of adapter over the layer above.

Why core is pure

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.

Formatone artifact

Everything is keyframes

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.

Channels are discovered, not declared

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.

Serialization is byte-stable

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.


Runtimethe frame

Scroll never touches React state

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.

−20
Sample the journeyone sample() into a reused object; nothing allocates
−19
The editor claims its layerso the preview can render the page without the editor in it
−18
Navigation integrateskeyboard velocity, before anything reads the camera
−17
The page camera is solved and drawnthe frustum gizmo, and the preview rendered to a texture
−10
Subjects place themselvesuseSubject writes position, scale and heading to the ref
−1
The camera reactsto where the subject actually ended up

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.

The measured pin beats the document

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 says where a station was meant to be. Only the layout knows where it ended up. When the two disagree, the subject reaches the framing before or after the page stops — which reads as a teleport with no obvious cause, so the editor shows both and offers to snap one onto the other.

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.

The rule that bites

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;
}
Overlayinside the host

Getting a canvas on top of a page

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.

Dragging without a gizmo

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.

A working journey, built in the same render

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]);

Camerathe hardest usability bug

Seeing the camera you are editing

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.

Waypoint handles The subject’s path. Drag one to move it. The subject Where it is at this scroll position. Page camera The lens the reader looks through. Its frustum Everything that is inside the shot. Preview inset The shot itself, rendered live. Floor and stems What makes a waypoint’s depth readable.
The viewport in free camera. You are nowhere near the lens the reader looks through, and you can still see both halves of what you are authoring: the cone says what is in shot, the inset says what the shot looks like.

One solver, two readers

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.

A gizmo that lies about where the camera is is worse than no gizmo, because you author against it.

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;
}

Rendering the inset without taking the loop

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.

Splitting the editor out of the picture

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.

Drawingwhat you can see

Making a path legible

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.

WasReads asNow
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
None of this changed a single number in the document. All of it is about being able to see what the numbers mean, which is the entire premise of the tool — and the absence of it was being read as the coordinates being wrong.

Two details that took a second attempt

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.


Savingwriting to disk

Writing source files from a browser

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.

An allowlist, not a denylist. A “everything except .js” rule is a list of the attacks someone already thought of, and the interesting ones are always the other kind.

The rules live in one place, not three

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;
Security checks that exist in three copies are security checks that exist in one copy and two near-misses. Fifteen node tests assert the rules directly — path traversal, the extension allowlist, the size cap, the production refusal — over a real socket, with a real FormData upload, because a hand-written multipart parser fed only its own fixtures is a parser that has not been tested.

And when there is nowhere to write

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 itWhere it goesWhat 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.

Adoptingreproducibility

Putting it on your own site

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.

What actually ships

The editor is a development tool and should not reach your visitors. Three things, all of which the scaffold sets up:

  1. The editor is dynamically imported and gated on NODE_ENV, which keeps it out of the bundle rather than merely out of the render.
  2. The save routes refuse in production by construction. The demo goes further and names them 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.
  3. 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.
What reaches a visitor is the engine, 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.


Case filesby failure class

The bugs worth studying

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.

Feedback loop

The camera shook, worst when still

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.

Convergence

A station two-thirds down the page measured at progress 2.5

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.

Two sources of truth

A fixed subject snapped to the world origin

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.

State outliving code

A draft from an older version crashed the editor on open

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.

Event plumbing

The world rotated while you held a handle

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.

Invisible filter

Giving the editor its own layer made every handle unpickable

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.

Dependency churn

The navigation was rebuilt on every keystroke

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.

Unreachable code

Dropping a model on the playground did nothing, ever

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.

Missing boundary

A model that 404s took the whole page down

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.

Silent staleness

Three fixes in a row were verified against code that was not running

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.

Silent tooling failure

Every browser harness was injecting nothing

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.

Non-determinism

Two consecutive runs of the same harness disagreed

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.

Bad assertion

A test that failed on correct data

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.

Packaging

The package depended on itself, three times

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.


Verificationfour passes

What is checked, and where

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.

PassRuns inAsserts
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.

The browser half presses real buttons

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.

Extendingwhere to add

Adding something new

The shape of almost every feature here is the same, and following it is the fastest way to add another:

  1. Put it in the document with a default that changes nothing, in src/core/document.js. Validate it there too — a document the engine would reject must never reach the disk.
  2. Sample it in core, as a pure function of p. Write into the reused sample object; allocate nothing.
  3. Consume it in the renderer, where damping and blending live. Props are overrides; the document is the source.
  4. Expose it in the panel, in the section it belongs to, with a one-line explanation of what it does in the reader's terms.
  5. Assert it — in node if it is a property of the data, in the browser if it is a property of the interaction.
Seed new modes from what the page already does. Switching the camera to 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.

Known gaps

  • Lights, ground and environment are still the host's JSX, not document data — so a dropped model is lit by whatever the page happened to set up. This is the largest remaining gap.
  • Route variants (one path shaped differently per user choice) are a code hook, not data.
  • Station camera framings are editable as numbers but have no drag handles; the subject keyframes and the camera path do.
  • While a station is held, heading still follows the travel spline rather than the station's own motion.
  • The preview inset renders the scene a second time each frame. It is small, and it is a development tool, but it is not free.
  • Mobile is untested, and the navigation assumes a keyboard and three mouse buttons.
  • A locally stored model lives in one browser, so a teammate who clones the repository sees the fallback subject until it is saved properly.

Closing

The through-line

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.


Authorget in touch

Abdullah Bilal

Software Engineer & Founder

web
abdullahbilal.nxera.io
email
aabdullahbillall@gmail.com
phone
+92 317 5081727
github
github.com/abdullahbilal-y
demo
abdullahbilal-y.github.io/3drossa