Terrain Selection Notes
This note documents the current heightfield-terrain selection model, the reasoning behind it, the bugs and design mistakes encountered while building it, and the cleanup still remaining.
It is intentionally more operational than Terrain Architecture. The goal here is to preserve the implementation context that would otherwise be lost in commit history.
Current Model
heightfield-terrain selection is now built around these rules:
heightfield-terrainowns the persistent selection state and its surface overlay- terrain tools reuse that shared selection instead of each tool owning a separate overlay
- selection is sample-based, not cell-based
- the overlay shows the affected cell footprint of the selected samples
- rect picking uses an explicit
:terrain-rect-pickapp state - paint uses a parallel explicit
:terrain-paintstate
The clean split is:
- terrain runtime:
- owns committed selection
- owns transient preview selection
- renders the selection overlay
- scene:
- answers terrain hit queries
- answers terrain drag target queries
- tool views / interaction state:
- initiate picking
- update preview
- commit resolved targets back into forms and terrain selection
Why Selection Is Terrain-Owned
We considered two designs:
- tool-owned overlays
- terrain-owned shared selection
The terrain-owned approach won because it provides:
- one source of truth for selection on a terrain
- one reusable visual for multiple tools
- persistent selection even if a tool closes
- support for typed form edits and live picking feeding the same terrain state
This does not mean terrain owns the interaction lifecycle. It owns only reusable selection state and rendering.
The interaction lifecycle still belongs to explicit app states:
terrain-rect-pick-stateterrain-paint-state
Why Selection Stayed Sample-Based
We considered changing the model to cell-based selection because it is visually more intuitive.
We kept sample-based selection because it matches the real data model:
- the canonical heightfield stores sample heights
- terrain tools mutate samples
- direct sample access is the more powerful and lower-complexity model
Cell-based selection would mainly improve UX. It would not improve the canonical data model. It would also introduce extra interpretation rules around how selected cells map to corner samples.
So the current design is:
- canonical model: samples
- visual overlay: affected cells around those samples
Current Selection Query Semantics
The current intended rect selection semantics are:
- point hits are real terrain surface hits
- a rectangle target is derived from the terrain-local hit positions
- the selected samples are the samples crossed by the dragged terrain-local interval
- samples are not chosen by nearest-sample endpoint snapping anymore
The relevant query seam is:
HeightfieldTerrainQuery.target-between-hits(record, start-hit, end-hit)
This currently computes the selected sample range from the terrain-local coordinate interval between the two accepted hits.
That means:
- if the dragged interval crosses a sample position, that sample is selected
- if the dragged interval stays between samples, no sample is selected on that axis
This matches the “cross the sample to include it” model more closely than midpoint snapping.
Overlay Semantics
We tried two overlay semantics:
- direct selected sample region
- affected cell footprint
The direct sample region gave a smaller visual region, but it stopped aligning with the visible grid and created the false impression that samples were cells.
We reverted to the affected-cell footprint because it is more truthful for a sample-based terrain:
- the selected thing is still samples
- the surface that visibly responds is the surrounding cell footprint
So the current overlay intentionally shows more than a “single little patch” for a single sample.
Persistent Selection vs Preview
Another important design correction:
- committed selection and transient preview are now separate state
This matters because:
- invalid typed drafts should not destroy committed selection
- cancelling live pick should not destroy committed selection
- preview should be able to come and go without changing what the terrain currently considers selected
Current runtime responsibilities:
- committed selection:
- persistent until explicitly replaced or cleared
- preview selection:
- transient, tool-driven, may be cleared on invalid draft or cancel
The overlay renders preview when present, otherwise committed selection.
Theme Reactivity
The terrain selection overlay is theme-owned, not tool-owned.
That means the runtime must react when the active theme changes while a selection is visible.
This was originally missed. The overlay now refreshes theme colors through the runtime update seam rather than only when the selection target changes.
Major Problems Encountered
1. Hidden Override Input Routing
The first implementations tried to keep terrain interaction local to tool views via hidden selector/session overrides.
This repeatedly caused:
- normal selection winning instead of terrain picking
- button clicks arming interactions that then immediately died
- weak or misleading input ownership
The clean fix was to move terrain interactions onto explicit app states:
terrain-rect-pickterrain-paint
That made interaction ownership visible both in code and in runtime behavior.
2. Render/Query Transform Mismatch
At one point:
- the terrain render runtime ignored persisted quaternion rotation
- the query path respected it
That meant visible terrain and queried terrain did not match.
This was a real bug and produced selection misses and displaced results.
The fix was to make runtime terrain rendering and terrain query use the same transform interpretation.
3. Scene Query vs Runtime Transform Mismatch
Another real bug:
- scene point hits used runtime-transformed terrain query records
- drag-region selection was temporarily using the raw persisted terrain record
That caused the selected region to be offset from the visible terrain.
This has been corrected so drag queries also use the runtime-transformed query record.
4. Slow and Wrong Screen-Space Sample Selection
One attempted implementation selected samples by:
- projecting every sample to screen space
- testing whether the projected sample lay inside the drag rectangle
This was a design mistake.
Problems:
- too slow for live drag
- selected by screen projection, not terrain-space drag geometry
- easy to disagree with the actually visible surface
This path has been removed. Rect selection is back on the terrain-space hit seam.
5. Session Handoff / Drag Lifecycle Bugs
Several live bugs were caused by interaction lifecycle mistakes:
- arming a picker on button click, then consuming the same mouse-up as the first picker event
- dropping coalesced motion on mouse-up before flushing it
- requiring exact terrain hit on mouse-down and refusing to recover later in the drag
These were fixed incrementally. The important lesson is:
- drag capture semantics must be explicit and tested at the real interaction seam
6. Tests That Covered the Wrong Seam
Repeatedly, tests passed while live behavior was broken.
The main reasons were:
- tests hit helper seams instead of the real hosted-tool path
- tests compared fast vs exact in a narrower context than the live interaction actually used
- some tests eventually became self-referential because the capture path delegated back into the same scene seam the test used as an oracle
This is now partially improved, but still not fully solved.
Current Trade-Offs
Sample-Based Selection vs Cell-Based Selection
Sample-based selection remains the cleaner canonical model because it matches stored data and preserves precision.
Cell-based selection would be easier to explain visually, but mostly improves UX rather than architecture.
The current compromise is:
- keep sample-based selection
- render the affected cell footprint
Explicit App States vs Hidden Tool Sessions
Explicit app states are heavier than local widget logic, but they are much cleaner for global pointer interactions.
The current design chooses:
- explicit app states for terrain interactions
- terrain-owned reusable selection state
This split is correct.
Exact Hit Path vs Fast Hit Path
The terrain point-hit path has had several fast/exact issues. The current working path is the one that is covered and known to behave correctly with the current selection semantics.
The lesson here is:
- never switch live picking to a faster path unless the public seam has parity coverage strong enough to catch the exact regression we care about
Current Review Findings
As of this note, the current selection path is functional but not yet perfect.
The main remaining design issues are:
HeightfieldTargetCapturestill re-queriesscene:screen-drag-terrain-target(...)from stored screen positions instead of deriving the target directly from the already accepted hits.
This is redundant and slightly weakens the seam. A cleaner design would use:
- point queries only to obtain accepted hits
TerrainQuery.target-between-hits(query-record, start-hit, end-hit)to derive preview and final target
scene:screen-drag-terrain-target(...)still has weaker semantics than the live picker.
The live picker currently compensates for:
- pending-start drag promotion
- release with no final hit but a prior valid hit
That behavior is partly in HeightfieldTargetCapture, not wholly in the scene seam.
This should be unified.
- Some interaction tests are still too coupled to shared implementation.
Tests that compare capture output to scene:screen-drag-terrain-target(...) are useful as plumbing checks, but they are not strong enough as behavior oracles if capture itself relies on that same scene seam.
More fixed-fixture tests should sit at the public interaction seam.
Recommended Cleanup Path
If work continues on terrain selection, the next clean steps should be:
- Move rect-target derivation in
HeightfieldTargetCaptureto the already accepted hits instead of re-querying from screen positions. - Decide whether robust drag semantics belong in:
scene:screen-drag-terrain-target(...), or- the capture object and make that ownership explicit instead of split.
- Add one or two non-self-referential interaction regressions using fixed fixtures for:
- promoted-start drag
- release after the last valid hit
- Only after that, revisit precision/performance tuning if manual testing still feels rough.
Modules Involved
Core runtime/query:
assets/lua/heightfield-terrain.fnlassets/lua/heightfield-terrain-selection-overlay.fnlassets/lua/heightfield-terrain-query.fnlassets/lua/heightfield-terrain-grid.fnlassets/lua/scene.fnlassets/lua/terrain-query.fnl
Interaction:
assets/lua/terrain-rect-pick-state.fnlassets/lua/terrain-paint-state.fnlassets/lua/graph/view/terrain-rect-pick-manager.fnlassets/lua/graph/view/terrain-paint-manager.fnlassets/lua/graph/view/heightfield-target-capture.fnlassets/lua/graph/view/heightfield-paint-capture.fnl
Tool integration:
assets/lua/graph/view/heightfield-target-tool-view.fnlassets/lua/graph/terrain-editor-form-view.fnlassets/lua/graph/world-data.fnl
Primary tests:
assets/lua/tests/test-terrain-query.fnlassets/lua/tests/test-demo-browser.fnlassets/lua/tests/test-graph-view.fnlassets/lua/tests/test-world-nodes.fnl
