Skip to content

Paint as strokes, and a language you can read back - #14

Merged
Lincoln504 merged 5 commits into
pr/07-erosion-regions-and-shellfrom
pr/08-paint-and-the-printer
Jul 29, 2026
Merged

Paint as strokes, and a language you can read back#14
Lincoln504 merged 5 commits into
pr/07-erosion-regions-and-shellfrom
pr/08-paint-and-the-printer

Conversation

@Lincoln504

Copy link
Copy Markdown
Collaborator
  • Paint where the rules cannot reach: strokes, not pixels
  • Two controls that were harder to use than they were to build
  • A language you can read back: the printer the parser never had
  • The mesh was hiding the erosion: the grid now follows the map
  • One grammar, one printer: echo now goes through the IR

44 files changed, 5139 insertions(+), 446 deletions(-) · gate green at f684b55 (fmt, clippy, workspace tests against a real adapter, wasm build, headless-Chromium smoke) · payload 490822 gz (226127 from the previous PR, against the 6 MB NFR-2 budget)

Part of a 12-PR stack over pr/00-ci. Based on pr/07-erosion-regions-and-shell; retargets to main once that lands.

⚠️ Please merge with a merge commit — squash or rebase rewrites the SHAs and breaks every PR above this one in the stack.

Everything else in the document is a rule — a generator, a bedding operator,
three altitude bands. Rules are what make a landscape look like a landscape,
and they are also what makes it impossible to say "not here". So: a brush.

Five channels. Two move the ground (raise/carve, and soften) before the beds
are laid, so benches follow what you sculpted and gullies cut down into it.
Three are the erosion bands' own masks, and painting one does not deposit
anything — it says *this band's settings apply here*, so the band's parameters
still decide what the gullies look like and a painted region reads as part of
the same landscape rather than as something dropped onto it.

The painted mask and the band's altitude window resolve to one number, because
nobody thinks of "where the altitude puts this band" and "where I painted it"
as two things. Positive paint is an override interpolated toward a normalised
target, so the three weights still sum to exactly one — a sum that sagged would
leave a terrace along the edge of a stroke, which is the worst possible place
for one. Negative paint is a removal: it takes weight without giving it to
anyone, which the filter already reads as less erosion here. Painting all three
away is an off switch you can draw.

The document stores paths and brushes, never pixels. A stroke is the falloff
applied to the path's own distance field rather than a row of stamped dabs,
because with a monotone radial falloff and max compositing those are the same
thing — and evaluating the distance directly removes the last way the result
could depend on how fast the pointer was sampled. A stroke drawn at 60 Hz and
the same stroke at 144 Hz rasterise identically, which they must: the document
is replayed. Drawing slowly cannot dig deeper than drawing quickly either.

The band masks ride in the tail of the erosion filter's parameter buffer.
Compatibility mode allows four storage buffers per compute stage and that
kernel already binds four; a runtime-sized array as the last member costs no
binding, and the header and the mask region are written separately, so dragging
a slider still moves eighty bytes rather than three megabytes.

This replaced a shift-click brush that wrote the heights and then snapshotted
the whole terrain into an embedded PNG — one click destroyed the generator, the
bedding, the erosion filter, the simulated passes and the plants. Every SetKind
site that rebuilt a terrain payload had the same bug latent in it; all of them
now carry the strokes through. Nothing is baked: reseeding keeps the painting,
and so does a preset.

A drag is one history entry, not one per pointer move — the inverse of a paint
is the whole stroke list, and thirty of those is real memory for a state nobody
can reach. Measured in the browser: a stroke changes 31.7% of the frame and one
undo returns it to a 0.00% difference.

Also fixed three things the recorder had been dropping silently, since the
printer is what makes a transcript a replay rather than a description: the
simulated pass counts, the height scale, and an `add cube` emitted for a
*change* to a cube, which grew an object per edit on replay.

Attribution: technique and vocabulary from published descriptions of how
landscape tools combine painted and procedural masks (Unreal's removal layers,
Gaea's Combine, Unity's filter stacks) — no code from any of them.
The coverage slider was linear over a forty-fold range, and what it sets is a
slope the kernel divides by — so the first quarter of the travel did nearly all
the work and the last half did almost none. That was in the audit as a measured
defect and went unfixed because the number was right; the mapping was not.
Geometric now, so equal travel is equal ratio. The stored value is untouched,
which means no document, preset or parity case changes: this is the slider, not
the setting.

And `b` arms the brush. It is the one control in the paint section worth a key,
because it is the only one you reach for *between* strokes rather than while
setting one up — everything else you set once and then draw.
`Action` has had three ways in — the typed parser, the keyword interpreter,
serde from a model tier — and no way out. Nothing could state, as a property,
that the language the parser accepts is the language the docs advertise, and
the banded filter had already grown four vocabulary keywords by hand.

print.rs is the inverse: the canonical line for an action, with round-trip,
idempotence, totality, and one property the design note didn't ask for —
coverage taken from serde's own unknown-variant error, so the corpus and
VOCABULARY cannot fall behind the enum. Every golden utterance now round-trips
too, which is what makes the typed and spoken surfaces one language.

Floats go out at shortest-round-trip precision, so no quantisation was needed
anywhere: the transcript keeps its three decimals for the person reading it,
and that is now an argued decision with a test pinning the bound and, more
usefully, that it is a fixed point. Save/load/save returns the same file.

Two defects the printer found: `rename 5 north ridge` named the object
`north`, and NaN/inf parsed as legal numbers — they survived every clamp and
reached a kernel.

And three the browser found while checking the work:

- The brush ring never appeared. `canvasEl` was a plain `let`, so the child
  that draws it mounted with undefined and never heard about the canvas.
- The sun could only be dragged, never set — and the dome clamps at 2°, so
  once dragged it could not go back below the horizon where `night` lives.
  The read-only azimuth/elevation line is now two sliders.
- The gizmo and the dome claimed ARIA roles they could not honour. Both are
  pointer conveniences over controls that exist elsewhere, so they say so.

Verified in headless Chromium: ring 0 before arming and 242px after; a phrase
answers `terrain snowy scale=large / water deep`, a typed line answers in
prose. Gate green.
FSD §6.1 has always said the terrain mesh is "indexed per resolution". It was
not — 256 vertices a side, fixed, while the map goes to 2048. Above 256² that
made the mesh the limit rather than the data, on a product whose headline
filter carves gullies a few texels across, and under a Terrain Lab caption
promising that 512² carries finer branches.

Measured as high-frequency energy in a 640×480 frame of the default eroded
landform:

  map \ grid |  256    512   1024
  256²       |  9.84   9.78  10.76
  512²       | 10.71  12.97  13.71
  1024²      | 12.07  14.57  16.26

Two readings decide it. A 256² map is already fully resolved by a 256 grid, so
the default scene is unchanged to the pixel and costs nothing. A 512² map gains
21% by matching, of which 1024 adds only another 6% — for 42 MB of index
buffers and 3.4 ms a frame against 1.8. So the grid follows the map and stops
at 512; 1024 waits for the device-tier switch, which is a Pi release gate and
cannot be signed off from here.

MACRO_SPAN is now a fraction of the map rather than a count of vertices. Left
as a count it would have narrowed fourfold with the grid and scattered the
material into patches exactly where the new detail is — which is the artefact
it was introduced to fix. The wireframe index list is built on first use: it
is the same size as the fill list and most sessions never draw one.

The regression guard is the measurement, not a golden image, and it
discriminates: pinning the grid back to 256 fails it at 10.71 against the 11.8
it needs.
The design note said it: `Action` is the IR, and `echo` should go *through*
`print` wherever a Command has an Action preimage, with the holes visible in
the type instead of hidden in a `_ =>`. This does that.

`lift(&Command) -> Vec<Say>` is the preimage. `Say::Do(Action)` is something
the language can say; `Say::Note` is something it cannot — a ground plane, a
light, a camera, an imported heightmap, a primitive's payload — and it is a
variant, so a new command or object kind is a compile error here rather than a
plausible-looking wrong line.

The transcript keeps its rounding by asking the same printer for
Precision::Transcript. Grammar, key names, ordering and canonical-form rules
are shared; only digits differ. That is the whole point: a hand-written
Command→String is a second, silent statement of the language, and it drifts
downward because nothing round-trips it.

Which it had. Three more defects, all the same shape:

- A terrain with no palette chosen — the serde default, and the boot scene —
  echoed `material ` with nothing after it. That does not parse; it fell
  through to the keyword engine, which found the word `material` and did
  nothing. The transcript now records what is drawn, which for an unset name
  is verdant.
- Taking the bedding away was recorded by writing nothing at all, so a script
  with no beds replayed with whatever beds the scene already had. The filter
  and the plants had both been fixed; this one was invisible because the lines
  it should have written were simply absent.
- Adding water echoed the verb that *moves* water. On a dry scene that is a
  no-op, so a script that built a lake rebuilt a desert.

The property that replaces "best-effort" is that every line a transcript
contains is a comment or a line of the language, and means the same thing
after a trip through the parser — over every command shape and every object
kind. It could not be stated before: the only check available was "does it
parse into something", which an unparseable line passed anyway by falling
through to the keyword engine and matching a stray word.

Also: `Action::Filter::sediment` said it was shared across bands. The compiler
never agreed — it is per band, and VOCABULARY always said so.
@Lincoln504
Lincoln504 merged commit ab37660 into pr/07-erosion-regions-and-shell Jul 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant