feat: cut the terrain structurally, and judge authored terrain with the game's own rule #284

Merged
panda merged 6 commits from feat/terrain-cutaway into main 2026-08-08 15:49:29 +02:00
Collaborator

Summary

The client removes the layers above you only when the world's own structure says you are inside it
never because something happens to be drawn in front of you.

That distinction is the whole PR, and it was arrived at by getting it wrong first, in the open. Cutting
on occlusion — "something is between the camera and the body" — is true on any slope with a hill in
front of it, and it blacked out half the screen in an open forest. Cutting on "there is higher ground
nearby" is the same mistake spelled differently; the forest has that too. What separates a crater from a
forest is measured: of the eight compass directions, how many still offer a way out (0-1 against 4-5,
at every radius tried).

So there are exactly two ways in:

  • UNDER — your column has another interval above the one you stand on: a cave, a tunnel, an overhang;
  • SHUT IN — at most one of the eight directions offers a way out within EscapeRadiusCells.

A canyon has two ways out along its length and is deliberately left alone; so is a plain with a hill on
it. Nothing in it is absolute, so a canyon at z −80 behaves exactly like the same canyon at z +80 and
terrain authored below zero needs no special case — the owner's requirement, in his words: "van scritte
regole strutturali precise, un domani creerò canyon e valli sotto z0, non voglio ripetere sta
discussione"
. And nothing in it is a camera, so the answer is identical from every viewing angle.

What is then removed is everything above a structural height — the bottom of the ceiling you are
under, or head height over the floor you are shut in on — and never a shape derived from where the player
stands. A boundary anchored to the walker slides across the landscape at every step ("il problema non è
la zona nera, è che si muove"
); one anchored to a height is a contour of the terrain and holds still.

The full model lives in the header of TerrainCutaway (Client.Core) — it is the reference, and
CLAUDE.md carries the invariant so it cannot quietly drift back to occlusion.

Screenshots / recording

Not attached, and I am not going to pretend otherwise. This behaviour was developed and judged
interactively with the owner over several rounds on his own world, against his screenshots — that is how
the occlusion version was caught and rejected. What I can attach now would be a re-staging after the fact,
not the evidence that drove the decisions.

What replaces them here, and is stronger for a reviewer: TerrainCutaway is pure and unit-tested, and
the landform contract is pinned as a table — cave → cut, crater (0-1 ways out) → cut, canyon (2 ways out)
→ no cut, valley and plain → no cut. CutawayState also exposes Under, ShutIn, CutMode and a
one-word Shape to the admin overlay, so the decision is legible in-game rather than being a black screen
with no explanation.

Three known gaps are filed rather than hidden: #279 (a v3 that preserved a reachable region and cut by
screen-space occlusion — built, judged worse by the owner, reverted; the revert pair is not in this branch
because the two cancel exactly), #280 (the character reads as detached from the terrain when the cut is
active) and #281 (silhouette in the open).

How it was tested

  • Unit tests in IsoMmo.Client.Core.Tests over TerrainCutaway: the two entries (UNDER / SHUT IN), the
    eight-direction escape count, and the landform table above.
  • The Z fed to it is the authoritative surface, never the smoothed DisplayZ — asserted, because that
    is the one substitution that would make the answer depend on presentation.
  • Gates on this exact commit: CSharpier clean (580 files), dotnet test green (10 test projects), whole
    solution builds with zero warnings.

Checklist

  • just lint passes (CSharpier + analyzers, zero warnings)
  • just test is green
  • The whole solution builds (client and tools included)
  • Multi-platform preserved (server on Win/macOS/Linux, client on Win/macOS)
  • Tests added/updated for this change
  • Linked the related issues (#279, #280, #281 stay open as follow-ups)

Stack: 2 of 3, based on feat/volume-world-f1 (#283). Merge that one first; this PR's base can then be
retargeted to main.


Added by the merge of #285: the map editor redesign (closes #282)

While this PR was being rebased onto the squashed main, #285 was merged into this branch, so it now
carries the editor work as well. Nothing was lost or changed by that: the tree here is byte-for-byte
identical to the original 43-commit branch, verified by diff. The two halves stay independent — the
cutaway touches the client renderer, the editor touches tools/IsoMmo.MapEditor* — so they can still be
read separately.

Summary of the editor half

The height tools were producing terrain the game could not use, and the editor could not show it. They are
rebuilt around one idea: nothing reaches the map until it has been judged, and it is judged with the
game's own rule
(StepRule) rather than with arithmetic of the editor's own — an editor that scores
terrain its own way ends up promising ground the server refuses.

  • Preview: every height tool proposes. Enter commits it as one undoable step, Escape and the map is
    byte-for-byte as it was. The 3D view carries the proposal too — a road is a shape, and a top-down canvas
    cannot show a shape.
  • Overlays score slope / walkability / reachability, keeping a route (SustainableRisePerStep) and a
    single ledge (MaxWalkStepZ) apart: a mountain built of legal single steps is a wall you can stroll up.
  • Ridges and calderas blend into the ground they land on, so a shape has no seam by construction. The
    caldera gained an explicit CliffWall because a smooth cone turned out to be walkable — measured, not
    deduced.
  • Roads, driven to three revisions by the owner: one centreline and one baker for freehand and routed
    alike; routing that prices each step's rise by its square and earthwork at 25 a Z, so it takes the
    gentlest line and accepts being longer ("come una vera strada di montagna"); and the routed line
    rounded off, because a grid path at an angle is a staircase (107 direction changes in 272 cells).
    How much to round is the ground's call — RoadPlanner keeps the smoothest line that costs the road
    nothing.
  • The baker stopped blaming a road for terrain it was laid across: it measured the absolute step at the
    edge of its earthwork, so a flat road on flat ground was refused for a 131 Z escarpment seventy cells
    away. It now measures the delta.

Measured on the authored massif, 116 Z over 260 cells: 272 cells instead of 262, steepest steps 6
instead of 55, no earthwork at all
; turning 369° → 206° after rounding, shoulder 140 cells → 26. The
baked road is walkable by StepRule from foot to summit, 984/984 paved cells reachable, 0 seams.

Screenshots (editor half)

Driven through the editor's own automation channel on a copy of the authored world:

ui off · view 2d · goto 7449 4230 · zoom 2.6 · overlay slope
route road_dirt 3 96 7449 4356 7449 4096
screenshot <path>

Instradata contro linea dritta — verde = pendenza sostenibile, giallo = gradino, rosso = muro:

routed vs straight

Prima e dopo l'arrotondamento, stesso tratto ingrandito 3x:

before and after

A 7 px/cella, mentre infila la sella fra due masse invalicabili:

close up

Gates, on this exact head

CSharpier clean (608 files), dotnet test green — 1,200 tests across 10 projects — whole solution
builds with zero warnings. 192 of those tests are the editor's, all pure, including the road regressions
pinned from real failures: a wide road climbing a hill is not a conflict with itself; a road beside a
natural cliff is not blamed for the cliff; a gentle detour beats a steep short line; a road over walkable
ground moves no earth at all.

## Summary The client removes the layers above you **only when the world's own structure says you are inside it** — never because something happens to be drawn in front of you. That distinction is the whole PR, and it was arrived at by getting it wrong first, in the open. Cutting on *occlusion* — "something is between the camera and the body" — is true on any slope with a hill in front of it, and it blacked out half the screen in an open forest. Cutting on "there is higher ground nearby" is the same mistake spelled differently; the forest has that too. What separates a crater from a forest is **measured**: of the eight compass directions, how many still offer a way out (0-1 against 4-5, at every radius tried). So there are exactly two ways in: - **UNDER** — your column has another interval above the one you stand on: a cave, a tunnel, an overhang; - **SHUT IN** — at most one of the eight directions offers a way out within `EscapeRadiusCells`. A canyon has two ways out along its length and is deliberately left alone; so is a plain with a hill on it. **Nothing in it is absolute**, so a canyon at z −80 behaves exactly like the same canyon at z +80 and terrain authored below zero needs no special case — the owner's requirement, in his words: *"van scritte regole strutturali precise, un domani creerò canyon e valli sotto z0, non voglio ripetere sta discussione"*. And nothing in it is a camera, so the answer is identical from every viewing angle. What is then removed is everything above a **structural height** — the bottom of the ceiling you are under, or head height over the floor you are shut in on — and never a shape derived from where the player stands. A boundary anchored to the walker slides across the landscape at every step (*"il problema non è la zona nera, è che si muove"*); one anchored to a height is a contour of the terrain and holds still. The full model lives in the header of `TerrainCutaway` (Client.Core) — it is the reference, and `CLAUDE.md` carries the invariant so it cannot quietly drift back to occlusion. ## Screenshots / recording **Not attached, and I am not going to pretend otherwise.** This behaviour was developed and judged interactively with the owner over several rounds on his own world, against his screenshots — that is how the occlusion version was caught and rejected. What I can attach now would be a re-staging after the fact, not the evidence that drove the decisions. What replaces them here, and is stronger for a reviewer: `TerrainCutaway` is **pure and unit-tested**, and the landform contract is pinned as a table — cave → cut, crater (0-1 ways out) → cut, canyon (2 ways out) → no cut, valley and plain → no cut. `CutawayState` also exposes `Under`, `ShutIn`, `CutMode` and a one-word `Shape` to the admin overlay, so the decision is legible in-game rather than being a black screen with no explanation. Three known gaps are filed rather than hidden: **#279** (a v3 that preserved a reachable region and cut by screen-space occlusion — built, judged worse by the owner, reverted; the revert pair is not in this branch because the two cancel exactly), **#280** (the character reads as detached from the terrain when the cut is active) and **#281** (silhouette in the open). ## How it was tested - Unit tests in `IsoMmo.Client.Core.Tests` over `TerrainCutaway`: the two entries (UNDER / SHUT IN), the eight-direction escape count, and the landform table above. - The Z fed to it is the **authoritative** surface, never the smoothed `DisplayZ` — asserted, because that is the one substitution that would make the answer depend on presentation. - Gates on this exact commit: CSharpier clean (580 files), `dotnet test` green (10 test projects), whole solution builds with zero warnings. ## Checklist - [x] `just lint` passes (CSharpier + analyzers, zero warnings) - [x] `just test` is green - [x] The whole solution builds (client and tools included) - [x] Multi-platform preserved (server on Win/macOS/Linux, client on Win/macOS) - [x] Tests added/updated for this change - [x] Linked the related issues (#279, #280, #281 stay open as follow-ups) --- **Stack:** 2 of 3, based on `feat/volume-world-f1` (#283). Merge that one first; this PR's base can then be retargeted to `main`. --- # Added by the merge of #285: the map editor redesign (closes #282) While this PR was being rebased onto the squashed `main`, **#285 was merged into this branch**, so it now carries the editor work as well. Nothing was lost or changed by that: the tree here is byte-for-byte identical to the original 43-commit branch, verified by diff. The two halves stay independent — the cutaway touches the client renderer, the editor touches `tools/IsoMmo.MapEditor*` — so they can still be read separately. ## Summary of the editor half The height tools were producing terrain the game could not use, and the editor could not show it. They are rebuilt around one idea: **nothing reaches the map until it has been judged, and it is judged with the game's own rule** (`StepRule`) rather than with arithmetic of the editor's own — an editor that scores terrain its own way ends up promising ground the server refuses. - **Preview**: every height tool proposes. Enter commits it as one undoable step, Escape and the map is byte-for-byte as it was. The 3D view carries the proposal too — a road is a shape, and a top-down canvas cannot show a shape. - **Overlays** score slope / walkability / reachability, keeping a *route* (`SustainableRisePerStep`) and a single *ledge* (`MaxWalkStepZ`) apart: a mountain built of legal single steps is a wall you can stroll up. - **Ridges and calderas** blend into the ground they land on, so a shape has no seam by construction. The caldera gained an explicit `CliffWall` because a smooth cone turned out to be *walkable* — measured, not deduced. - **Roads**, driven to three revisions by the owner: one centreline and one baker for freehand and routed alike; routing that prices each step's rise by its **square** and earthwork at 25 a Z, so it takes the gentlest line and accepts being longer (*"come una vera strada di montagna"*); and the routed line rounded off, because a grid path at an angle **is** a staircase (107 direction changes in 272 cells). How much to round is the ground's call — `RoadPlanner` keeps the smoothest line that costs the road nothing. - The baker stopped blaming a road for terrain it was laid across: it measured the *absolute* step at the edge of its earthwork, so a flat road on flat ground was refused for a 131 Z escarpment seventy cells away. It now measures the delta. Measured on the authored massif, 116 Z over 260 cells: **272 cells instead of 262, steepest steps 6 instead of 55, no earthwork at all**; turning 369° → 206° after rounding, shoulder 140 cells → 26. The baked road is walkable by `StepRule` from foot to summit, **984/984 paved cells reachable**, 0 seams. ## Screenshots (editor half) Driven through the editor's own automation channel on a **copy** of the authored world: ``` ui off · view 2d · goto 7449 4230 · zoom 2.6 · overlay slope route road_dirt 3 96 7449 4356 7449 4096 screenshot <path> ``` Instradata contro linea dritta — verde = pendenza sostenibile, giallo = gradino, rosso = muro: ![routed vs straight](https://git.homelab.devncode.it/attachments/ec8e1ffe-b975-40d3-9ed0-1c93136414e3) Prima e dopo l'arrotondamento, stesso tratto ingrandito 3x: ![before and after](https://git.homelab.devncode.it/attachments/1b977346-7092-4144-920b-eefc153f02c7) A 7 px/cella, mentre infila la sella fra due masse invalicabili: ![close up](https://git.homelab.devncode.it/attachments/3c0c0c89-6416-4d09-a738-a42885a79041) ## Gates, on this exact head CSharpier clean (608 files), `dotnet test` green — **1,200 tests across 10 projects** — whole solution builds with zero warnings. 192 of those tests are the editor's, all pure, including the road regressions pinned from real failures: a wide road climbing a hill is not a conflict with itself; a road beside a natural cliff is not blamed for the cliff; a gentle detour beats a steep short line; a road over walkable ground moves no earth at all.
Two screenshots from the owner, on opposite sides of the same slope, both with
half the screen black and nothing covering the character. Height alone was
taking the whole landscape.

Two conditions join it, both per-fragment. NEARER: terrain farther from the
camera than the player cannot be between them and it. CLOSE: within reach of
the body on screen — because height is worth 2.75 times ground distance, so a
massif that merely stands beside you is "nearer" too, and that was the culprit
in both shots.

Generous on purpose (320 px cut outright, fading over another 260): the opening
has to be one you can fight in, not peer through. Both numbers are single
constants, which is the point — the rule is now right in kind, and what remains
is taste.
fix(client): anchor the cutaway to a structural height, not to the walker
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 10m9s
5732a5903a
panda changed target branch from feat/volume-world-f1 to main 2026-08-08 15:35:58 +02:00
panda force-pushed feat/terrain-cutaway from 5732a5903a
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 10m9s
to e77f53712b
Some checks failed
ci / Lua content lint (pull_request) Has been cancelled
ci / Lint & Test (pull_request) Has been cancelled
2026-08-08 15:41:51 +02:00
Compare
feat(editor): judge terrain with the game's own rule, and propose before committing (#285)
All checks were successful
ci / Lua content lint (pull_request) Successful in 16s
ci / Lint & Test (pull_request) Successful in 5m37s
90772b9c5c
Closes #282.

## Summary

The height tools were producing terrain the game could not use, and the editor could not show it. This
rebuilds them around one idea: **nothing reaches the map until it has been judged, and it is judged with
the game's own rule** (`StepRule`, the same gate `TileMapRules.CanStep` applies) rather than with
arithmetic of the editor's own — an editor that scores terrain its own way ends up promising ground the
server refuses.

**Preview.** Every height tool now *proposes*. The proposal is drawn over the canvas tinted by the
validator's verdicts, with magenta for a seam, and the readout carries the numbers the decision needs
before it is made: cells changed, max step, walls, seams, undo cost. **Enter** commits it as one undoable
step; **Escape** and the map is byte-for-byte as it was. The 3D view carries the proposal too — a road is
a shape, and the top-down canvas cannot show a shape.

**Overlays** score the terrain in the mover's terms: slope (sustainable route / single ledge / wall),
walkability, and reachability by flood. Two rules deliberately never blurred into one "walkable" number:
a route must hold `SustainableRisePerStep`, a single ledge may clear `MaxWalkStepZ` once. A mountain built
of legal single steps is a wall you can stroll up, which is why they are counted apart.

**Ridges and calderas** are rebuilt to blend into the ground they land on, so a shape has no seam by
construction; the caldera is one continuous radial section with an explicit `CliffWall`, because a smooth
cone turned out to be *walkable* — spiral up it and you are on the rim. Measured, not deduced.

**Roads** are the largest piece, and the owner drove it to three revisions:

- One centreline, one baker. Freehand and Route both hand a line to `RoadBaker`; the tools these replace
  stamped different geometry from the same path.
- *"la strada dovrebbe seguire naturalmente il terreno con meno pendenza, anche a costo di essere più
  lunga… come una vera strada di montagna"* — so routing prices each step's rise by its **square** (pricing
  it linearly is a constant between fixed endpoints and changes nothing) and prices earthwork at 25 a Z
  instead of 2. On the authored massif, 116 Z over 260 cells: **272 cells instead of 262, steepest steps 6
  instead of 55, and no earthwork at all**.
- *"è da renderla meno a scale… più naturale"* — a grid path travelling at an angle **is** a staircase:
  measured, 107 direction changes in 272 cells, 75 of its 108 straight runs one single cell long. The line
  is simplified to the corners that carry information and those corners rounded off, held inside a corridor
  as wide as the simplification tolerance so it can never wander onto ground the router never looked at.
  How much to smooth is the **ground's** call, not a constant: `RoadPlanner` bakes each rung and keeps the
  smoothest line that costs the road nothing. Turning 369° → 206°, sharpest bend 5.7° → 2.9°, and the
  shoulder falls from 140 cells to 26.

The baker also stopped blaming a road for terrain it was laid across. Its postcondition measured the
*absolute* step at the edge of its earthwork, so a flat road on flat ground was refused because of a 131 Z
escarpment the author had drawn seventy cells away. It now measures the delta: a **cut face** is reported
(that is what a bench cut into a hillside *is*), a carriageway left **standing on nothing** refuses the bake.

`content/worlds/caldera-fixture` is a 512×512 crop of the authored world frozen as a regression fixture —
the terrain that showed what the tools were doing wrong, kept so a redrawn landform is compared against
something real instead of against a memory of it. Its numbers are pinned by tests, so editing it fails the
build.

## Screenshots / recording

Captured by driving the editor through its own automation channel (`docs/map-editor.md` — a DEBUG-only
loopback on 127.0.0.1:5200 that drives the REAL editing paths), on a **copy** of the authored world so
nothing of the owner's was touched:

```
ui off · view 2d · goto 7449 4230 · zoom 2.6 · overlay slope
route road_dirt 3 96 7449 4356 7449 4096
screenshot <path>
```

The routed road before and after the rounding, side by side at the same framing and camera, and a
close-up at 7 px/cell of the road threading the saddle between two impassable masses.
**Instradata contro linea dritta** — stesso massiccio, stessi due estremi. Verde = pendenza sostenibile,
giallo = gradino, rosso = muro.

![routed vs straight](https://git.homelab.devncode.it/attachments/fe08b153-8e52-4d7b-b4ea-aff391450981)

**Prima e dopo l'arrotondamento**, stesso tratto ingrandito 3x:

![before and after](https://git.homelab.devncode.it/attachments/00fd61d4-bd8d-49a3-b5d7-5c6b35a6b7b6)

**A 7 px/cella**, mentre infila la sella fra due masse invalicabili:

![close up](https://git.homelab.devncode.it/attachments/995ca474-8c33-46c3-a131-d6c64cd55cb5)

Verified numerically on the same road: worst step 2 Z, **984/984 paved cells reachable from the foot**,
summit reached, 0 seams.

## How it was tested

- 192 tests in `IsoMmo.MapEditor.Core.Tests`, all pure: the validator against `StepRule`, the edit
  window/plan/history (including the undo budget refusing rather than committing without undo), the crop
  and survey, ridge and caldera geometry, and the road baker and router.
- Road regressions pinned from real failures the owner hit: a wide road climbing a hill is not a conflict
  with itself; a road beside a natural cliff is not blamed for the cliff; an earthwork that cannot reach
  the ground leaves a reported cut face rather than a refusal; a carriageway that cannot be supported is
  refused; a gentle detour beats a steep short line; a road over walkable ground moves no earth at all.
- Measured end-to-end on the owner's own massif in `asterra` (read-only): the baked road is walkable by
  `StepRule` from foot to summit with zero seams.
- Gates: CSharpier clean (608 files), `dotnet test` green — 1,200 tests across 10 projects — whole
  solution builds with zero warnings.

## Checklist

- [x] `just lint` passes (CSharpier + analyzers, zero warnings)
- [x] `just test` is green
- [x] The whole solution builds (client and tools included)
- [x] Multi-platform preserved (server on Win/macOS/Linux, client on Win/macOS)
- [x] Tests added/updated for this change
- [x] Linked the related issue (#282) and its Definition of Done is met

---

**Stack:** 3 of 3, based on `feat/terrain-cutaway` (#284), itself based on `feat/volume-world-f1` (#283).
Merge in order.

Reviewed-on: #285
panda changed title from feat(client): cut the terrain when the world encloses you, not when it occludes you to feat: cut the terrain structurally, and judge authored terrain with the game's own rule 2026-08-08 15:44:21 +02:00
panda merged commit 527761287a into main 2026-08-08 15:49:29 +02:00
panda deleted branch feat/terrain-cutaway 2026-08-08 15:49:30 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo!284
No description provided.