Paint as strokes, and a language you can read back - #14
Merged
Lincoln504 merged 5 commits intoJul 29, 2026
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
44 files changed, 5139 insertions(+), 446 deletions(-)· gate green atf684b55(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 onpr/07-erosion-regions-and-shell; retargets tomainonce that lands.