Skip to content

feat(raster): Shaded Relief casts soft shadows (#1782, part 1) - #1796

Draft
elpidioassinaturas wants to merge 11 commits into
opengeos:mainfrom
elpidioassinaturas:feat/1782-soft-shadows-sky
Draft

elpidioassinaturas wants to merge 11 commits into
opengeos:mainfrom
elpidioassinaturas:feat/1782-soft-shadows-sky

Conversation

@elpidioassinaturas

@elpidioassinaturas elpidioassinaturas commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1782: the cast shadows. Sky visibility will follow in a second pull request, as the issue suggested, so each stays reviewable.

What it does

Shaded Relief gets one more setting, Cast shadows (raster.symbology "shadow", 0 to 1): how dark the slopes the terrain hides from the sun are drawn. A valley behind a ridge is now in shadow, with a soft edge, instead of looking as lit as a slope in the open.

  • 0, the default, draws exactly today's pixels (tested), so existing projects don't change. The setting is saved only when it isn't 0.
  • Left out of raster.symbology, a Shaded Relief keeps its own value (as Stretch keeps its inversion); a new one casts none.
  • The Style pane shows it under Azimuth, Altitude and Z factor.

How

  • rustgis_map::shadow::light traces each cell toward the sun's azimuth, one column or row at a time. It reads heights between cells (bilinear) and keeps the steepest angle it meets. The sun is a disc 5° either side of its altitude, and the light left is the share of that disc above the angle. Reading the nearest cell instead over-shadows diagonal suns (ray_shade marks lit slopes as shadowed on diagonal azimuths (cell read at the wrong distance) franciscoparrao/surtgis#153).
  • The trace stops once nothing farther can change the cell: the terrain left can't rise above the angle kept (or the disc's lower edge), or the whole disc is hidden.
  • The result is one byte per cell: the light left, 0 to 255.
  • Drawing multiplies the hillshade by 1 − shadow × (1 − light), so the ambient light stays (ambient + direct × shadow, as the issue proposed). The grid doesn't depend on the strength, so changing the strength is immediate.
  • Off the frame (AGENTS.md, Never hold the window). RasterLayer::cast_shadows works the grid out once per pixels, band, sun, Z factor and mask, on a thread of its own, and keeps it.
    • The map draws without the shadows meanwhile, as a frame with tiles still to come, and draws again when they land (rustgis_tiles::cache::notify_at).
    • A layer and its copies (another map, an undo) keep up to 3 grids, so two maps lit differently don't keep stopping each other's. Each grid remembers the layer drawing it: a new sun from that layer stops the unfinished work for its last one, unless an export waits for it. Eviction past 3 skips the grids an export waits for.
    • The rows are spread over rustgis_data::threads::max() threads, with the same grid on one thread, bit for bit (tested). A thread the system refuses runs its part on the caller.
  • Exports wait for the grids of the visible layers before they draw (map, layout and PMTiles exports). The wait gives up when the run is cancelled (rustgis_data::progress).
  • The Layout view doesn't wait. It draws its page on the interface's thread, so its frames draw without the shadows. layout::shadows_to_come says so, and the page is drawn again as they land, natively too.
  • Never silent. The relief is drawn without cast shadows, and a notice names the layer and the reason, when:
    • the raster is over 500,000 cells in the browser, which has one thread;
    • it is a cloud-optimised GeoTIFF read in place with only its overview held (the parts in view are read as the map draws, so there is no whole grid to trace);
    • the grid can't be held in memory.
      The notice shows in the map view, in the Layout view and with a map or layout export. The PMTiles export refuses in those cases, as it refuses a part drawn from less than it needs.

Measured

Ouro Preto and Serra do Caraça, Minas Gerais: Copernicus DEM GLO-30 reprojected to UTM 23S at 30 m, 1966 × 1827 cells (3.59 million). The whole DEM was exported at 1600 × 1500 with rustgis-cli (release), sun 315° / 30°, three runs each, on a 16-thread Windows PC with other builds running, so the times are indicative.

shadow 0 (today) shadow 0.8 Cost of the shadows
--threads 1 1.90–2.10 s 7.67–7.83 s (one run 12.6 s) about 5.8 s
--threads 16 1.48–2.32 s 2.12–2.83 s about 0.5 s
Peak working set 72.4–73.4 MB 76.8–77.1 MB +3.6 MB, the grid (one byte a cell)
  • With 1 and with 16 threads, the export is the same, pixel for pixel.

  • A lower sun traces farther. In a 1:75,000 view of the Caraça with the sun at 20°, one run each took 2.9 → 10.7 s on one thread and 2.5 s with shadows on 16.

  • With the sun at 5° or lower, the disc's lower edge is at or below the horizon, so over gentle ground a cell traces to the raster's edge. The whole DEM, one run each:

    Sun altitude --threads 16, 0 → 0.8 --threads 1, 0 → 0.8
    5° 1.35 → 4.95 s 1.53 → 20.99 s
    3° 1.45 → 5.56 s 1.90 → 22.38 s
    2° 1.72 → 3.94 s 1.55 → 21.95 s
  • How much it changes depends on the sun:

    • With the sun at 30°, 0.8% of the exported pixels change, since few slopes here are steeper than the disc's lower edge (25°).
    • With the sun at 20°, 15.9% of the 1:75,000 view changes.

Tests

  • Against geometry with a known answer, not another tool (crates/map/src/shadow.rs):
    • A plane rising toward the sun is lit below 25° of slope and dark above 35° (sun at 30°, soft edge ±5°). This holds at 10 azimuths, the diagonals included (the surtgis#153 case).
    • A wall's shadow is fully dark up to h / tan(alt + 5°) and fully lit past h / tan(alt − 5°).
    • In the soft edge, the light is the share of the disc hidden.
  • A stopped trace gives no grid. Flat ground, no data, a masked tower and a missing band cast nothing. One thread gives the same grid as seven.
  • RasterLayer::cast_shadows:
    • A shadow of 0 starts no work. The grid is worked out once, kept, and kept when only the strength changes. Another sun gets another grid, and a copy lit otherwise keeps its own beside it.
    • Read in place with only the overview held, it says so; with the whole image held, it has the grid.
  • Drawing: a shadow of 0 draws today's pixels with a grid or without; 1 and 0.5 darken the direct light by that much; a grid of another size isn't read.
  • raster.symbology keeps the setting when it is left out, clamps it to 0–1, saves it only when it isn't 0, and a map export with shadows draws.
  • hostile_params_never_panic passes.

Saved files

shadow is a new field in a saved project, written only when it isn't 0.

  • An older RustGIS reads such a project without it, draws the relief without shadows, and saving again drops the field.
  • crates/engine/src/project.rs says FORMAT advances when an older reader would lose meaningful saved information on resaving.
  • I haven't advanced it. Doing so would make every project saved by the next release unreadable to 0.6.0, shadows or not. feat(layout): a legend shows a layer under a label instead of its name (#1654) #1739 also added a saved field (legend labels) without advancing it.

@giswqs, should this PR advance FORMAT, or leave it as #1739 did?

Limits

  • Terrain outside the raster casts nothing on it, so a small clip can miss a shadow from a mountain past its edge. A grid spanning 0 to 360° casts nothing across its seam.
  • A narrow occluder between the eight main directions can be missed in part: a 5 × 5 tower at azimuth 20° loses some shadow cells. A plane is right at every azimuth.
  • A geographic raster uses its cell size in metres at its centre's latitude, as the hillshade does.
  • The soft edge is fixed at 5°, far wider than the real sun, for a soft edge at map scale. It could become a setting.
  • A sun under about 5° costs more: the trace grows with the raster's width as well as its cells (measured above). The map draws meanwhile; an export waits, and gives up on Cancel. The trace isn't cut at a distance: that would change the output exactly where low suns cast their long shadows.
  • Not measured in WebAssembly; cargo check --target wasm32-unknown-unknown can't run on this PC (zstd-sys needs clang).

Screenshots

Antes / Depois = before / after:

  • Area: the east side of the Serra do Caraça, Minas Gerais, at 1:40,000, exported with rustgis-cli from this branch.
  • Settings: Terrain ramp, sun 315° / 20°, shadow 0 (left) and 0.8 (right).
  • Before is shadow 0, which draws main's pixels (tested).
  • What changes: the foot of the massif south-east of the scarp falls in its shadow, with the soft edge fading out over the lowland.
  • DEM: Copernicus DEM GLO-30, © DLR e.V. and Airbus, provided under COPERNICUS by the European Union and ESA.

The Style pane (new UI): Cast shadows under Z factor, with the same DEM at 1:50,000. A headless ui_shot of the window; the map shows its shadows once they had landed (the script waited 4 s after setting the style).

Where the code comes from

Written here from the geometry. The bilinear read and the early stop follow what I found testing surtgis-relief (MIT OR Apache-2.0) for the issue, but no code is copied from it. rayshader (GPL-3) was looked at only for what it does, through its documentation.

🤖 Generated with Claude Code

elpidiofilho and others added 2 commits November 11, 2026 14:06
Shaded Relief gets "shadow" (0 to 1, Cast shadows in the Style pane):
how dark the slopes the terrain hides from the sun are drawn. 0, the
default, draws today's pixels and isn't saved.

rustgis_map::shadow::light traces each cell toward the sun, reading
heights between cells (bilinear, so diagonal suns don't over-shadow), and
keeps the share of a sun disc 5 degrees either side of its altitude left
above the steepest angle seen; the trace stops once nothing farther can
change it. RasterLayer::cast_shadows works the grid out once per pixels,
band, sun, Z factor and mask on a thread of its own and keeps it (up to 3
per layer and its copies): the map draws without it meanwhile and again
when it lands; exports, the Layout page and the PMTiles export wait for it
(the wait stops when the run is cancelled). The drawn hillshade is
multiplied by 1 - shadow * (1 - light), so the ambient light stays.

The relief is drawn without cast shadows, with a notice, over 500,000
cells in the browser, for a COG read in place with only its overview held,
and when the grid can't be held.

Tests: a plane, a wall and the soft edge against their known geometry at
10 azimuths; stop, flat, no data, mask; threads give the same grid; the
grid kept per sun and for copies; read in place said; drawing at 0, 0.5
and 1; raster.symbology keeps, clamps and saves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
Pre-merge checks: Inconclusive 2

Failed Failed checks: 2 inconclusive

Check name Status Explanation Resolution
Title check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
Description check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Comment thread crates/render/src/lib.rs Outdated
// Its cast shadows: an export waits for them; the map draws without them meanwhile,
// and again once they land.
let landed = || rustgis_tiles::cache::notify_at(web_time::Instant::now());
let light = match rl.cast_shadows(full_res_rasters, landed) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (AGENTS: Never hold the window), medium-high confidence. The Layout view renders its page on the UI thread (ui-egui/src/layoutview.rs:658, render_pending → complete → render_cpu(map, view, true, …)), so full_res_rasters is true here. With full_res_rasters true, cast_shadows(true, …) blocks in work.result(true) until the whole grid is worked out. That is seconds on one thread for a few million cells at a low sun (the PR's own numbers: 5.8 s extra at 30° on one thread, about 10 s at 20°). The wait only stops on progress::check(), and the UI thread has no probe installed, so Cancel can't reach it. Before this change the layout wait was for network tiles. It now also waits on CPU work, with no way out.

Suggested direction: the layout page should draw without the grid and set page_wait/tile_out.pending the way the interactive map does, or the grid should be started from a background path before the page draws.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0306284.

  • The map's frames never wait for the grid any more.
  • An export waits for the visible layers' grids in complete before it draws. That covers map exports, layout::render and layout::render_drawing, and the PMTiles export.
  • layout::render_pending, which the Layout view calls on the interface's thread, draws its frames without the grids still to come (without_shadow_wait). layout::shadows_to_come() reports them, and layoutview.rs sets page_wait for them natively too, so the page is drawn again as they land (notify_at moves the generation landings() reads).
  • Test: an_export_waits_for_cast_shadows.

Comment thread crates/map/src/raster.rs Outdated
None => {
// A few kept, for copies of the layer lit otherwise (another map, an undo):
// past them, the oldest goes and its work stops.
if jobs.len() >= KEPT_LIGHTS {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (doc/behaviour mismatch, medium confidence). The doc comment and PR body say "a change of sun stops the old work", but nothing stops a job when the sun changes. The old job is kept, and its LightJob::drop only runs on eviction here, after three newer keys exist. So a slider drag through many suns leaves up to three full grid computations running per layer (each on threads::max() threads). The newest sun then competes with stale work.

There is a second effect. Copies share this list (CastLight is an Arc), so another map's request for a fourth sun evicts the job an export is waiting on. The export's result(true) then returns Unavailable("…were stopped"), and the export refuses with a misleading reason.

Either stop the superseded job when a new key for the same pixels arrives, or correct the doc and PR body.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0306284.

  • cast_shadows now takes the drawing layer's id as its owner. A new key from the same owner drops (and so stops) that owner's unfinished job, unless an export is waiting for it.
  • Finished grids are kept, so going back to a sun is free.
  • Copies drawn by other owners are left alone, so two maps lit differently don't stop each other.
  • Eviction past the 3 kept skips grids an export waits for: another map can no longer make an export fail with "were stopped".
  • The doc and the PR body now say this.
  • Test: a_new_sun_supersedes_the_owners_last_work.

if let Some((grid, strength)) = self.shadow
&& let Some(lit) = grid.get(ri as usize * r.width as usize + ci as usize)
{
let left = 1.0 - strength * (1.0 - f64::from(*lit) / 255.0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (spec mismatch), medium confidence. The PR body says drawing keeps "the ambient light (ambient + direct × shadow)". The code does not do that. hillshade (crates/map/src/raster.rs:1776) has no ambient term, and this line multiplies the whole shade by 1 − strength × (1 − lit). At shadow 1 and light 0 the shade goes to 0, which the test cast_shadows_darken_the_direct_light_by_their_strength confirms (want(0)). So a fully shadowed slope is pure black, not ambient-lit.

Either add an ambient floor to the formula, or correct the PR body and the user guide (docs/user-guide/data.md) so they say shadows darken the whole relief.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No code change for the formula: the ambient term is there, in the Colorizer rather than in hillshade.

  • RasterRenderer::ShadedRelief colours with base × (0.25 + 0.75 × h) (crates/map/src/raster.rs, paint). This PR scales only h, so the colour is base × (0.25 + 0.75 × h × (1 − shadow × (1 − light))): ambient + direct × shadow.
  • In the test, want(0) is Colorizer::color(.., Some(0)), which is a quarter of the colour, not black.
  • 0306284 makes that explicit: the test now asserts that a fully shadowed grey pixel is [64, 64, 64, 255], and the user guide says full shadow keeps the ambient light.

Comment thread crates/map/src/raster.rs
});
if spawned.is_err()
&& let Some(f) = run.lock().ok().and_then(|mut f| f.take())
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENTS (Never hold the window), medium-low confidence. If the OS refuses the thread, f() runs on the caller. The caller is render_frame, which runs on the UI thread for the interactive map and for the Layout view. The whole trace then runs on the UI thread, and it also runs while self.cast.0 is locked, so any other copy of the layer blocks behind it.

AGENTS says a refused thread "costs speed, not the run". A refused thread should not cost the window. Consider leaving the job unstarted (Pending) and retrying on a later frame, or spawning from a background path. Holding the jobs lock during the trace also needs a look.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as it is, with one part fixed in 0306284.

  • A thread the system refuses is the AGENTS.md case of "a thread the OS refuses costs speed, not the run". It needs the OS to refuse a thread, which hardly happens.
  • Retrying on later frames would leave the shadows out for as long as threads are refused, and an export would wait for nothing.
  • The other part is fixed: the work now starts after the jobs lock is let go, so other copies of the layer don't block behind an inline trace.

Comment thread crates/map/src/raster.rs
#[serde(default, skip_serializing_if = "is_no_shadow")]
shadow: f64,
},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENTS (saved files), medium confidence. shadow is a new serialized field, and it is saved whenever it is above 0. crates/engine/src/project.rs:56-58 says FORMAT must advance "when an older reader would lose meaningful saved information on resaving". An older RustGIS parses this variant without shadow (serde ignores unknown fields here), so it silently resets the setting to 0 and saves the file again without it. docs/maintaining.md (Saved files) asks for a format bump in that case, and a coordinate-first decision.

Please either advance FORMAT (and state the release-ordering plan), or show that an older reader keeps the field unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed here: this is a maintainer decision, and I've asked it in the PR body (Saved files).

  • An older RustGIS reads shadow as absent, draws without shadows, and drops it on saving again: what you describe.
  • Advancing FORMAT would make every project saved by the next release unreadable to 0.6.0, with or without shadows. docs/maintaining.md prefers shipping the reader a release before anything writes it.
  • feat(layout): a legend shows a layer under a label instead of its name (#1654) #1739 added a saved field (legend labels) the same way without advancing it.
  • I'll follow whichever giswqs prefers.

Comment thread crates/map/src/shadow.rs Outdated
}
}
let mut results: Vec<Result<(), Unlit>> = refused.into_iter().filter_map(take).map(run).collect();
results.extend(handles.into_iter().map(|h| h.join().unwrap_or(Err(Unlit::Stopped))));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality / Never silent, low confidence. h.join().unwrap_or(Err(Unlit::Stopped)) turns a panicked worker into Unlit::Stopped. The caller then reports "its cast shadows were stopped", though nothing was stopped and a panic happened. Map the join error to a distinct reason (for example, a panic notice like the one in start_light). The worker's catch_unwind is not reached from here, since the panic is in a scoped thread.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0306284.

  • A panicked scoped worker is now Unlit::Failed, and the notice says "working them out failed", as the catch_unwind around the whole trace does.
  • Stopped is only for work superseded on purpose.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed the full diff (12 files, +778/−52) against pr-context/AGENTS.md. No prior review threads existed, so none were skipped as already raised. I did not run the build or tests; the checks below are from reading the code. Six inline comments are posted.

Bugs

  • Medium-high: The Layout view renders on the UI thread with full_res_rasters true, so cast_shadows(true, …) blocks the window until the whole grid is done. The wait only stops on progress cancel, and the UI thread has no probe. Before this change the wait was for network tiles only. (crates/render/src/lib.rs:4005)
  • Medium: The PR says "a change of sun stops the old work", but stale jobs are only stopped on eviction. Drags leave up to three grids running per layer. Copies share the job list, so another map can evict the job an export is waiting on, and the export then fails with "were stopped". (crates/map/src/raster.rs:768)
  • Medium: The PR body says drawing keeps the ambient light (ambient + direct × shadow). The code multiplies the whole hillshade, and hillshade has no ambient term. At shadow 1 a shadowed cell goes to 0. The test cast_shadows_darken_the_direct_light_by_their_strength confirms this. (crates/render/src/raster.rs:710)

Security

  • None found. The new shadow parameter is clamped and bounds-checked, and the trace reads with get().

Performance

  • Low: cast_shadows clones the mask on every frame to build LightKey. It is small in practice, so this is a nit.
  • The per-row trace is O(w) for low suns on flat ground, because the early stop never fires for an altitude near 0. The PR documents that low suns trace farther. It is not a bug.

Quality

  • Low: A worker panic is reported as "stopped", not as a panic. (crates/map/src/shadow.rs:97)
  • Low: start_light's spawn-refused fallback runs the trace on the caller while holding the jobs lock. This is the same concern as the UI-thread item above. (crates/map/src/raster.rs:895)
  • Low: The raster export maps Pending to None silently. That only happens on cancel, and the export's own steps should stop it, so this is minor.
  • Low: The background worker never looks at Cancel. A cancelled export or layout leaves the grid to finish, since the worker has no probe.
  • Low: Wrap-around (0–360°) grids: the shadow trace doesn't wrap at the seam, unlike the hillshade. The docs don't mention this.

AGENTS.md

  • Medium: The new shadow field is saved whenever it is above 0. FORMAT in crates/engine/src/project.rs:58 must advance when an older reader would lose saved information on resave. An older RustGIS will reset the setting and save the file without it. docs/maintaining.md asks for a format bump and coordination here. (crates/map/src/raster.rs:1182)
  • Compliant: Never-crash rules hold (no unwrap/panic in non-test code). Browser caps and notices are present. Screenshots are attached. Attribution is noted for surtgis, and rayshader was only read for its docs. Cargo.toml allows too_many_arguments.
  • Gap: The notice paths (the browser cap, the render error push, and the export refusal) have no tests. The Unavailable tests cover only the in-place case.

Change size:

Change size

Compared with the base branch: the whole PR against main (merge base 780e071 to PR head 50f4abd).

Measure Value
Files changed 12 (1 added, 11 modified, 0 removed, 0 renamed)
Size before 3.90 MB
Size after 3.94 MB
Total size change +33.6 KB
Path Before After Change
crates/map/src/shadow.rs 0 B 14.1 KB +14.1 KB
crates/map/src/raster.rs 88.3 KB 99.8 KB +11.4 KB
crates/render/src/raster.rs 51.4 KB 55.2 KB +3.8 KB
crates/engine/src/tests.rs 2.32 MB 2.33 MB +1.5 KB
crates/render/src/lib.rs 562.0 KB 563.0 KB +1.0 KB
docs/user-guide/data.md 102.2 KB 103.0 KB +0.9 KB
crates/render/src/raster_export.rs 11.5 KB 12.1 KB +0.6 KB
crates/engine/src/cmd/raster.rs 56.4 KB 56.9 KB +0.5 KB
crates/ui-egui/src/panes.rs 419.1 KB 419.6 KB +0.5 KB
crates/map/src/qgis/raster.rs 16.0 KB 16.0 KB +29 B
crates/map/src/lib.rs 177.0 KB 177.0 KB +16 B
crates/map/src/qgis/tests.rs 133.8 KB 133.8 KB +13 B

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

📖 Docs preview

Item Value
Preview https://preview.rustgis.app/pr-1796/
Web app https://preview.rustgis.app/pr-1796/app/
Commit ae2c369

GitHub Pages takes a minute or two to publish a new commit.

…upersedes the old (opengeos#1782)

Review round 1 of opengeos#1796:

- The map's frames never wait for cast shadows: an export waits for the
  visible layers' grids before it draws (`complete`), while the Layout
  view's page (`render_pending`, on the interface's thread) is drawn
  without them, says so (`layout::shadows_to_come`) and is drawn again as
  they land, natively too. The layout export (`layout::render`) still waits.
- `cast_shadows` takes the drawing layer's id as its owner: a new sun from
  the same owner stops the work for its last one unless it is done or an
  export waits for it, and eviction past the 3 kept skips grids an export
  waits for, so another map can't make an export fail with "were stopped".
- The work is started after the jobs lock is let go.
- A trace thread that panics is said as a failure, not as stopped.
- Docs: only the direct light is darkened (full shadow keeps the ambient
  light, now tested: a quarter of the colour, not black); the Layout view
  draws without them until they land; a 0-360 grid casts nothing across
  its seam.

Tests: a new sun supersedes the owner's last work while another owner's
goes on; an export waits for cast shadows and draws them; the PMTiles
export refuses a layer read in place with cast shadows and says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@elpidioassinaturas

Copy link
Copy Markdown
Contributor Author

The review summary's items without a thread, after 0306284:

  • The mask cloned for the key on every frame: left as is. A mask holds at most 256 values (MAX_MASK_VALUES), and the clone is next to a lock taken once per layer and frame.
  • Low suns over flat ground trace far: as the summary says, documented and not a bug. The early stop still fires once the ray is above the raster's highest cell.
  • The PMTiles export maps Pending to None: it calls cast_shadows(true, …), which returns Pending only when the run is cancelled; the export then stops at its own next check.
  • The worker doesn't look at Cancel: an export's wait gives up on Cancel (progress::check), but the trace runs on. It stops when its owner asks for another sun or the pixels go. Otherwise it finishes and is kept, so the next draw or export of that sun is immediate.
  • 0–360° grids: the user guide now says the shadows don't cross the seam (0306284).
  • Tests of the notice paths:
    • The PMTiles export's refusal is now tested (an_in_place_raster_unread_is_not_exported_from_its_overview).
    • The browser cap is cfg(target_arch = "wasm32"), so native tests can't reach it.
    • The render error push needs a COG read in place with its overview held. The map-side message is covered by cast_shadows_of_a_raster_read_in_place_need_the_whole_image.

🤖 Generated with Claude Code

if shadows {
PAGE_SHADOWS.with(|c| c.set(true));
}
*pending |= img.tiles_pending || img.data_pending || shadows;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (Never silent), medium-high confidence. frame_canvas only reads img.tiles_pending and img.data_pending from the frame's Rendered. Its tile_errors are dropped, and nothing else in layout.rs reads them. render_pending returns (Pixmap, bool) and render returns a Pixmap, so the new CastShadows::Unavailable reasons never reach the user on a Layout page.

Failure case: a Shaded Relief layer with shadow > 0 that is read in place with only its overview held, or that is over 500,000 cells in the browser, or whose grid can't be allocated. On the Layout view, or in a layout export, the page is drawn with no shadows and no notice. The PR's "a notice names the layer and the reason" holds for the map view (mapview.rs shows tile_errors) but not for pages.

Fix direction: collect each frame's img.tile_errors into the page result and show them as a notice in the Layout view, and refuse the layout export the way the map export does. Add a test that a layout page with an unavailable shadow reports it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e4a3d0.

  • Each frame's reasons are kept for the page (take_shadow_notices, then layout::shadow_notices()).
  • The Layout view says them once each through the same set the map view uses.
  • A layout export (PNG, PDF, series) says them as notices.
  • The export doesn't refuse: the map export doesn't refuse either. It writes, and says what's missing (its gaps).
  • Test: a_page_says_why_a_frame_drew_no_cast_shadows. It checks that a frame read in place gives one notice naming the layer, and that a page with shadows, or with none asked for, gives none.
  • Only the cast-shadow reasons are carried to the page; other tile_errors stay as they were on main.

Comment thread crates/map/src/shadow.rs
}
// Nothing farther rises above what is kept: the angle seen, or the disc's lower
// edge (below it the cell has the whole sun); or the whole disc is hidden.
if (self.top - z0) / d <= steepest.max(self.sun.low) || steepest >= self.sun.high {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance, medium confidence (from reading, not measured). The trace only stops when the terrain left can't rise above the angle kept, or the disc is hidden. The bound is (self.top - z0) / d <= max(steepest, low), where top is the highest cell in the whole raster. For a sun at 5° or lower, low is 0 or negative, so over gentle or flat ground the bound is never met and every cell traces to the raster's edge. Each cell costs up to max(w, h) bilinear samples, so the total is O(cells × raster size), roughly 10^11 to 10^12 steps for a 10,000 × 10,000 DEM. A raster like that is a plausible input.

Two related points:

  • Nothing in the loop checks the run's Cancel. Only the superseded stop flag is read, and a cancelled export waits in result and then gives up, while this grid runs to completion on its own thread. The grid is kept for the layer, so it isn't lost, but the AGENTS rule about looking at Cancel in long loops applies.
  • The PR's limits section and user guide don't mention the cost at low suns.

Options: a maximum trace distance (changes output for distant terrain, so it needs a decision and a line in the limits), or per-block maxima in place of the global top so the bound becomes local. Either way, a test for a low sun on a larger grid would pin the cost.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured on the Ouro Preto DEM (1966 × 1827, 3.59 M cells), whole-DEM export, one run each:

Sun altitude --threads 16, 0 → 0.8 --threads 1, 0 → 0.8
5° 1.35 → 4.95 s 1.53 → 20.99 s
3° 1.45 → 5.56 s 1.90 → 22.38 s
2° 1.72 → 3.94 s 1.55 → 21.95 s

At 30° it was about +0.5 s on 16 threads and +5.8 s on one, so a low sun costs several seconds more.

What changed in 4e4a3d0:

  • The trace looks at the run's Cancel between rows on a thread that has one. That is the browser, which works the grid out where it is asked.
  • Work stopped part-way is dropped and started again on the next draw, not kept as a failure.
  • On the desktop the trace runs on its own thread, and the map draws without it meanwhile. Its stop is the superseding, which the review rounds made exact. An export's wait gives up on Cancel.
  • The cost is now in the user guide and in the PR's limits.

What I left out:

  • A maximum distance would change the output exactly where low suns cast their long shadows.
  • Per-block maxima don't help with a disc whose lower edge is at or below the horizon: any higher ground ahead still counts.
  • If you'd rather cap it, a cap at the distance where Earth's curvature hides the raster's relief would be the honest one. I'd do that in part 2 with a test.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Two new findings were posted inline. Three smaller findings and the prior threads are summarised below.

Bugs

  • Layout pages drop the new shadow "unavailable" reasons, so the PR's "a notice names the layer and the reason" does not hold for pages. frame_canvas discards Rendered.tile_errors (crates/render/src/layout.rs:513). Medium-high confidence. Posted inline.
  • Owner keying for the grid cache uses the layer id, which is unique only within one map (Map::next_id, crates/map/src/lib.rs:1556). Two maps' layers that share a raster, with different suns, can supersede each other's unfinished grids (crates/map/src/raster.rs:772). Requires cloned layers with different sun settings, so low confidence.
  • A poisoned mutex returns CastShadows::None with no notice (crates/map/src/raster.rs, cast_shadows and LightWork::result). Low confidence; a panic while holding these locks looks unlikely.
  • Race: an export's job can be evicted between releasing the lock and incrementing waiting (cast_shadows, then result(true)). It returns Unavailable("…were stopped"). Narrow window, low confidence.

Performance

  • Unbounded trace length at low suns, plus no Cancel check in the grid loop (crates/map/src/shadow.rs:207). Medium confidence, from reading. Posted inline.
  • Every frame, cast_shadows clones the layer's mask to build LightKey, and runs jobs.retain. Cheap, but it runs per layer per frame.

Quality (nits, grouped)

  • Two functions named shadows_to_come with different semantics: render/src/lib.rs (SHADOWS_TO_COME, full-res frames) and render/src/layout.rs (PAGE_SHADOWS, layout page). Rename one.
  • cast_shadows silently returns None on a poisoned lock (see above).

AGENTS.md

  • Never hold the window: a refused thread in start_light runs the whole trace on the caller, which can be the UI thread (crates/map/src/raster.rs, near the spawned.is_err() branch). Already raised in a prior thread, still present; left alone.
  • Never silent: the Layout-page gap above.
  • Test both ways: no test covers the Layout-page unavailable notice.
  • Saved files: the FORMAT question is already raised in a prior thread and the author has asked the maintainer. Left alone.

Prior review threads

  • Ambient light / "pure black" (raster.rs ~710): the claim doesn't hold. The ambient floor is in Colorizer::color (crates/map/src/raster.rs:1811, 0.25 + 0.75 × h), so full shadow is 25% of the colour, not black. Left alone; the thread may still need a reply.
  • Layout waiting on CPU work (lib.rs ~4005): resolved. render_pending now runs under without_shadow_wait, and complete() does not wait there.
  • Superseding jobs (raster.rs ~768): resolved for the same owner. A new sun stops the owner's unfinished, unwaited job.
  • Join panic mapped to Stopped (shadow.rs ~97): resolved. It now maps to Unlit::Failed.

Left alone: 3 concerns already raised (ambient, refused-thread, FORMAT). 3 more are resolved in the current code.

Change size

Compared with main (merge base 780e071 to PR head 0306284), the whole PR.

Measure Value
Files changed 14 (1 added, 13 modified, 0 removed, 0 renamed)
Size before 4.29 MB
Size after 4.34 MB
Total size change +44.7 KB
Path Before After Change
crates/map/src/raster.rs 88.3 KB 103.5 KB +15.2 KB
crates/map/src/shadow.rs 0 B 14.2 KB +14.2 KB
crates/render/src/lib.rs 562.0 KB 566.2 KB +4.2 KB
crates/render/src/raster.rs 51.4 KB 55.4 KB +4.0 KB
crates/engine/src/tests.rs 2.33 MB 2.33 MB +1.5 KB
crates/render/src/layout.rs 225.9 KB 227.1 KB +1.3 KB
docs/user-guide/data.md 102.2 KB 103.2 KB +1.1 KB
crates/render/src/raster_export.rs 11.5 KB 12.6 KB +1.1 KB
crates/engine/src/cmd/raster.rs 56.4 KB 56.9 KB +516 B
crates/ui-egui/src/panes.rs 419.1 KB 419.6 KB +482 B
crates/ui-egui/src/layoutview.rs 173.2 KB 173.4 KB +152 B
crates/map/src/qgis/raster.rs 16.0 KB 16.0 KB +29 B
crates/map/src/lib.rs 177.0 KB 177.0 KB +16 B
crates/map/src/qgis/tests.rs 133.7 KB 133.7 KB +13 B

elpidiofilho and others added 2 commits November 11, 2026 15:19
… count (opengeos#1782)

`landed` is called on the worker just after the grid is kept, so a count
read as soon as the export's wait returned could still be 0 (CI on Linux).
The test now receives each landing on a channel, waiting for the first, and
checks that none comes after the kept grid is used again. Under load (16
busy loops, --test-threads=32) the old test failed 3 times in 40, this one
none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PAGE_SHADOWS.with(|c| c.set(true));
}
*pending |= img.tiles_pending || img.data_pending || shadows;
Some(finish_frame(img, ppp))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-raising the earlier note on this function, since it changed in this PR and the problem still holds. img.tile_errors is never read in frame_canvas, and nothing else in layout.rs reads it (grep confirms). The new CastShadows::Unavailable reasons go into tile_out.errors in render_frame, the same channel, so on a Layout page they never reach the user. The PR body's "a notice names the layer and the reason" holds for the map view only. Suggested direction: collect each frame's tile_errors into the page result, show them in the Layout view, and refuse the layout export as the map export does.

Confidence: high.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same as the thread above: fixed in 4e4a3d0.

Comment thread crates/map/src/raster.rs Outdated
Comment on lines +759 to +772
jobs.retain(|j| j.pixels.strong_count() > 0);
match jobs.iter().position(|j| j.key == key && Weak::ptr_eq(&j.pixels, &Arc::downgrade(r))) {
Some(i) => {
// The latest used last, so the oldest goes first.
let mut job = jobs.remove(i);
job.owner = owner;
let work = job.work.clone();
jobs.push(job);
work
}
None => {
// The same owner's work for another sun, not done and not waited for, is
// superseded: it stops (as it is dropped). Grids done are kept, for going back.
jobs.retain(|j| j.owner != owner || j.work.is_done() || j.work.waited());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The earlier thread on superseding was fixed for a new sun, but not for returning to a sun already held. Here the Some(i) branch keeps the owner's other unfinished job. Example: owner 1 is on sun A (done), moves to sun B (unfinished), then back to A. A is found, so B is never stopped, and it runs to completion for nothing. The doc comment says a new sun from the same owner stops its last work. Suggested change (untested, I could not build it here):

Suggested change
jobs.retain(|j| j.pixels.strong_count() > 0);
match jobs.iter().position(|j| j.key == key && Weak::ptr_eq(&j.pixels, &Arc::downgrade(r))) {
Some(i) => {
// The latest used last, so the oldest goes first.
let mut job = jobs.remove(i);
job.owner = owner;
let work = job.work.clone();
jobs.push(job);
work
}
None => {
// The same owner's work for another sun, not done and not waited for, is
// superseded: it stops (as it is dropped). Grids done are kept, for going back.
jobs.retain(|j| j.owner != owner || j.work.is_done() || j.work.waited());
jobs.retain(|j| j.pixels.strong_count() > 0);
let same = |j: &LightJob| j.key == key && Weak::ptr_eq(&j.pixels, &Arc::downgrade(r));
// The owner's work for another sun, not done and not waited for, is superseded
// whichever sun it now draws: it stops (as it is dropped). Grids done are kept.
jobs.retain(|j| j.owner != owner || same(j) || j.work.is_done() || j.work.waited());
match jobs.iter().position(same) {
Some(i) => {
// The latest used last, so the oldest goes first.
let mut job = jobs.remove(i);
job.owner = owner;
let work = job.work.clone();
jobs.push(job);
work
}
None => {

The None branch's own retain line then goes, since the one above covers it. Add a test for the back-and-forth case next to a_new_sun_supersedes_the_owners_last_work.

Confidence: medium.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e4a3d0, as suggested: the owner's retain now runs before the lookup, so going back to a sun already kept stops the unfinished one just left. The test is extended for the back-and-forth case.

Comment thread crates/map/src/raster.rs
/// What it has: with `wait`, once it is done, unless the run waiting is cancelled (see
/// [`rustgis_data::progress`]).
fn result(&self, wait: bool) -> CastShadows {
let _waiting = wait.then(|| Waiting::on(&self.waiting));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible race: cast_shadows releases the jobs lock before result(wait) registers the waiter (Waiting::on, here). In the gap, a new sun from the same owner (the map view on the UI thread, while an export runs on a background thread with the same layer.id) can run retain and stop the export's job. The export then gets Unavailable("…were stopped"), which is a misleading refusal. A fix is to register the waiter inside the lock when wait is set, and pass the guard into result.

Confidence: low. I have not reproduced it, and the window is small.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as it is. The window is real but narrow, and its result isn't silent.

  • For it to bite, the map view must ask for a new sun for the same layer in the microseconds between the lock being let go and the export's waiter being counted.
  • The export then refuses with "its cast shadows were stopped". That is a refusal that says why, not a wrong image, and exporting again gives the right one.
  • Counting the waiter under the lock means passing a guard tied to the lock's lifetime into result. I'd rather not add that for this case. If a maintainer wants it closed, it's a small follow-up.

Comment thread docs/user-guide/data.md Outdated
Comment on lines +1181 to +1182
view draw without them for the moment that takes (under a second for a few million cells on a
multi-core computer, several seconds on one thread), then with them, and an export waits for them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timing claim is only measured at a 30° sun. The PR's table gives about 0.5 s extra for 3.6 million cells on 16 threads, and 5.8 s on one thread. At a 20° sun the trace goes farther, and the PR reports 2.5 s with shadows on 16 threads for its 1:75,000 view, and 10.7 s on one thread. Suggested wording (untested):

Suggested change
view draw without them for the moment that takes (under a second for a few million cells on a
multi-core computer, several seconds on one thread), then with them, and an export waits for them.
view draw without them for the moment that takes (about half a second extra for 3.6 million cells at a
30° sun on 16 threads; several seconds on one thread, and longer at a lower sun), then with them,
and an export waits for them.

Confidence: medium.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e4a3d0 with the measured numbers, a little differently from the suggestion. The guide now says:

  • under a second for a few million cells on a multi-core computer, and several seconds on one thread;
  • a sun under about 5° traces much farther: some seconds on a multi-core computer, and twenty or so on one thread.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • Race between a new sun and an export waiting on the same owner: the waiter is registered after the jobs lock is released, so the export's job can be stopped and it refuses with a misleading "were stopped" reason. Posted inline at crates/map/src/raster.rs:847. Confidence: low.

Security

  • Nothing found. Shadow parameters are clamped, the trace is bounded by the raster, and the new code has no unwrap, expect or panic in non-test paths.

Performance

  • Returning to a sun already held does not stop the owner's unfinished grid for the sun it left, contrary to the doc comment. Posted inline at crates/map/src/raster.rs:759-772 with a suggestion. Confidence: medium.
  • Not new, left alone: at low suns the trace runs to the raster's edge, because the stop bound uses the global maximum. The cancel check is also missing from the trace loop.

Quality

  • The user guide's timing claim ("under a second for a few million cells") is measured only at a 30° sun. Posted inline at docs/user-guide/data.md:1181-1182 with suggested wording. Confidence: medium.
  • Two functions named shadows_to_come with different thread-locals (crates/render/src/layout.rs:81 and crates/render/src/lib.rs:6334), and layout.rs:507 calls one only to clear it. Confusing; a nit for the quality pass.
  • shadow::light returns an all-lit grid without a notice when the band is missing or the data is short (return Ok(out)). Probably unreachable through symbology, which clamps the band. Confidence: low.
  • cast_shadows clones the RasterMask into LightKey on every frame. Minor.

AGENTS.md

  • Never silent: on a Layout page, the new "cast shadows couldn't be drawn" reasons never reach the user, because tile_errors is never read there. Posted inline at crates/render/src/layout.rs:514. Confidence: high.
  • Never hold the window: a refused thread runs the trace on the UI thread. Already raised in an earlier thread and answered by the author. Left alone.
  • Saved files: shadow is a new saved field and FORMAT is not advanced. Already raised; the author has asked a maintainer. Left alone.

Earlier review threads (8 on this PR)

  • Left alone, 4: the ambient-light formula (answered by the author); the refused-thread case; the FORMAT question; the trace performance and cancel issue at shadow.rs:207.
  • Re-raised, 1: the Layout page dropping tile_errors (layout.rs, inline above), since that function changed and the problem remains.
  • Verified fixed, 3: the Layout wait on the UI thread, the supersede on a new sun from another owner (the same-owner return path is the new finding above), and the panicked-worker reason.

Change size

Compared: the whole PR against main (merge base 780e071 to PR head ca6eda2).

Measure Value
Files changed 14 (added 1, modified 13, removed 0, renamed 0)
Size before 4.29 MB
Size after 4.34 MB
Total size change +43.9 KB
Path Before After Change
crates/map/src/raster.rs 88.3 KB 103.8 KB +15.4 KB
crates/map/src/shadow.rs 0 B 14.2 KB +14.2 KB
crates/render/src/lib.rs 562.0 KB 566.2 KB +4.2 KB
crates/render/src/raster.rs 51.4 KB 55.4 KB +4.0 KB
crates/engine/src/tests.rs 2.32 MB 2.33 MB +1.5 KB
crates/render/src/layout.rs 225.9 KB 227.1 KB +1.3 KB
docs/user-guide/data.md 102.2 KB 103.2 KB +1.1 KB
crates/render/src/raster_export.rs 11.5 KB 12.6 KB +1.1 KB
crates/engine/src/cmd/raster.rs 56.4 KB 56.9 KB +0.5 KB
crates/ui-egui/src/panes.rs 419.1 KB 419.6 KB +0.5 KB
crates/ui-egui/src/layoutview.rs 173.2 KB 173.4 KB +152 B
crates/map/src/qgis/raster.rs 16.0 KB 16.0 KB +29 B
crates/map/src/lib.rs 177.0 KB 177.0 KB +16 B
crates/map/src/qgis/tests.rs 133.7 KB 133.8 KB +13 B

I did not build or run the tests. Everything above comes from reading the diff and the surrounding code.

…nd low suns (opengeos#1782)

Review rounds 2 and 3 of opengeos#1796, and the merge of main:

- Two `draw_with` calls that came with main take `light` (None).
- Why a layer was drawn without its cast shadows reaches a Layout page:
  frames keep the reasons (`layout::shadow_notices`), the Layout view
  says them once each as the map view does, and a layout export says
  them as notices.
- The trace looks at the run's Cancel between rows where the thread has
  one (the browser). Work stopped part-way is dropped and started again,
  not kept as a failure.
- Going back to a sun already kept stops the unfinished work for the one
  just left, as a new sun does.
- `shadows_to_come` in the render crate is `frame_shadows_to_come`, apart
  from `layout::shadows_to_come`.
- The user guide gives the cost of a sun under about 5 degrees, measured on
  the Ouro Preto DEM (3.59 M cells): 2.5-4 s more on 16 threads, about 20 s
  on one.

Tests: a page says why a frame drew no cast shadows (read in place), and
nothing when it has them or none was asked for; going back to a sun kept
stops the one left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@elpidioassinaturas

Copy link
Copy Markdown
Contributor Author

The summary items of review rounds 2 and 3 without a thread, after 4e4a3d0:

  • A layer's id is unique within one map only: two copies with the same id, on two maps and lit differently, would stop each other's unfinished work. Neither breaks: each draws its grid once its own work lands. That takes copies sharing pixels and an id across maps with different suns, which a paste doesn't make. If it matters, the owner could be (map, layer).
  • A poisoned mutex: cast_shadows then draws without shadows (None), as masked_stats does with its lock. Nothing panics while holding it in this code.
  • The eviction race: past 3 kept grids, eviction skips the ones waited for. The narrow window before a waiter is counted is answered on its thread.
  • The mask cloned per frame: at most 256 values, next to one lock a layer and frame. Left as it is.
  • shadow::light lights every cell when the band is missing: the symbology clamps the band to the raster's (RasterStyle::repair), so this is only reachable by a direct call. All lit is the harmless answer there.

🤖 Generated with Claude Code

Comment thread crates/map/src/shadow.rs
let chunks: Vec<(usize, &mut [u8])> = out.chunks_mut(rows * w).enumerate().collect();
let run = |(i, chunk): (usize, &mut [u8])| -> Result<(), Unlit> {
for (dy, row) in chunk.chunks_mut(w).enumerate() {
if stop.load(Ordering::Relaxed) || rustgis_data::progress::check().is_err() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (AGENTS: Never hold the window), high confidence. The Cancel check here can never fire on the threads that run this loop. progress::check() reads a thread-local probe (crates/data/src/progress.rs:25). start_light spawns a fresh thread with no probe installed, and the spawn_scoped row threads don't inherit one either. So natively this always returns Ok, and a cancelled export leaves a background thread tracing the whole grid to completion. Only the export thread gives up, in CastShadows::result. The earlier thread on this line said nothing checks Cancel. The check is now present, but it is inert.

Suggested direction: pass the caller's probe into start_light and install it on the worker and on each scoped row thread (rustgis_data::progress::install(Some(probe))), or give light a cancel closure from the caller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6a30910. You're right that the per-row progress::check() can't fire on the desktop: the worker and its row threads have no probe.

  • An export cancelled while it alone waits for a grid now sets that work's stop in LightWork::result. The row threads already look at stop between rows.
  • The map, drawing again, starts the work over: stopped work is dropped (round 2) and now calls landed.
  • The progress::check() stays for the browser, which traces on the run's own thread.
  • With others also waiting, the work goes on for them.
  • Test: a_cancelled_export_stops_the_work_it_waited_for. A 2000 × 2000 grid under a 2° sun, waited for under a cancelled probe, ends stopped with no grid, and the next draw starts it again.

Comment thread crates/render/src/raster_export.rs Outdated
let light = match layer.cast_shadows(true, u32::MAX, || {}) {
rustgis_map::raster::CastShadows::Ready(grid) => Some(grid),
rustgis_map::raster::CastShadows::Unavailable(why) => return Err(format!("{}: {why}, so the archive isn't written", r.name)),
rustgis_map::raster::CastShadows::Pending | rustgis_map::raster::CastShadows::None => None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (Never silent), medium confidence. Pending is returned by cast_shadows(true, …) when the wait gives up on Cancel (crates/map/src/raster.rs, CastShadows::result). Mapping it to None here means the archive is drawn and written without its shadows, with no notice, if the caller doesn't discard a cancelled result. raster_export::export has no progress::step of its own, so nothing here stops it. The map path has the same shape: wait_for_cast_shadows ignores the outcome, and complete then draws without shadows.

Suggested fix: treat Pending as an error here, for example Err(format!("{}: its cast shadows weren't worked out (the run was cancelled), so the archive isn't written", r.name)), and check progress::check() after the wait in complete.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6a30910 for the PMTiles export: Pending from a wait that gave up is now an error ("its cast shadows weren't worked out (the export was cancelled), so the archive isn't written"). Test: a_cancelled_wait_for_cast_shadows_writes_no_archive.

complete is left as it is. It already returns early on Cancel for its tile wait ("An export cancelled meanwhile stops waiting: it writes nothing"). Its callers look at Cancel before writing: the map's PNG and GeoTIFF exports run progress::check() right after drawing (share.rs), and the layout export calls progress::step per page. So a cancelled map or layout export writes nothing.

A run that isn't cancelled always gets the grid, or Unavailable, which render_frame then says as a notice.

Comment thread crates/map/src/raster.rs Outdated
}));
match traced {
// Stopped, it was replaced: nothing is drawn again for it.
Ok(Err(crate::shadow::Unlit::Stopped)) => work.finish(Err(STOPPED.into())),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug, medium-low confidence. The Stopped branch calls work.finish but not landed(). The Ok(out) branch does call it. A frame or page that was drawn Pending on a job that is then superseded, or evicted by another owner's grids, is not told that the state changed. The Layout page waits on shadows_to_come() and is redrawn only when notify_at fires, so it can keep drawing without shadows until the next interaction. This is most likely when a Layout page's sun is replaced by another owner's eviction.

Suggested fix: call landed() after finish in every branch, including Stopped. Stopped work has no grid, so a redraw just asks again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6a30910: the Stopped branch calls landed() after finish, so a frame or page drawn while that work was to come is drawn again and asks for what it draws now.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Three new findings, all posted inline. Earlier concerns were left alone where still open: 9 threads raised again or still open (the Waiting race in raster.rs, the refused-thread-on-UI case, the FORMAT question, the O(cells × width) trace at low suns, the data.md timing wording, and the wait on a refused thread). Two earlier threads are resolved by this push: the Layout-page notices now reach the user through frame_canvas, and the Layout view no longer waits on the grid. The map view was checked: it calls render, not complete, so it does not block on the grid.

Bugs

  • Cancel never reaches the cast-shadow worker natively. progress::check() in shadow.rs:77 reads a thread-local probe, and the worker and its scoped row threads have none, so the check is inert. A cancelled export leaves the grid running to completion. Confidence: high.
  • A cancelled export can write an archive without shadows and no notice. raster_export.rs:67 maps Pending to None, and complete ignores the outcome of wait_for_cast_shadows. Confidence: medium.
  • A superseded or evicted job never calls landed(), so a Layout page drawn pending on it may not redraw until the next interaction. raster.rs:925. Confidence: medium-low.

Security

  • None found.

Performance

  • Already raised (trace bound uses the raster-wide maximum; a sun under about 5° traces to the edge). Not re-posted.

Quality

  • Nothing new worth a comment. Notices are per layer rather than one per kind with a count; I left this out as a judgement call on AGENTS wording.

AGENTS.md

  • Never hold the window: the Cancel finding above is the main gap.
  • Never silent: the cancelled-export path above.
  • No unwrap, expect or panic in new non-test code. Checked; none found.

Change size

Compared: the whole PR against main (merge base 2c75bda to PR head 4e4a3d0).

Measure Value
Files changed 16 (1 added, 15 modified, 0 removed, 0 renamed)
Size before 4.81 MB
Size after 4.86 MB
Total size change +49.0 KB
Path Before After Change
crates/map/src/raster.rs 88.3 KB 104.7 KB +16.3 KB
crates/map/src/shadow.rs 0 B 14.5 KB +14.5 KB
crates/render/src/lib.rs 562.0 KB 566.8 KB +5.0 KB
crates/render/src/raster.rs 56.5 KB 60.5 KB +4.0 KB
crates/render/src/layout.rs 225.9 KB 229.5 KB +3.6 KB
crates/engine/src/tests.rs 2.33 MB 2.33 MB +1.5 KB
docs/user-guide/data.md 102.2 KB 103.4 KB +1.2 KB
crates/render/src/raster_export.rs 11.5 KB 12.6 KB +1.1 KB
crates/engine/src/cmd/raster.rs 56.4 KB 56.9 KB +516 B
crates/ui-egui/src/layoutview.rs 173.2 KB 173.7 KB +488 B
crates/ui-egui/src/panes.rs 419.1 KB 419.6 KB +482 B
crates/engine/src/cmd/layout.rs 248.4 KB 248.7 KB +291 B
crates/ui-egui/src/mapview.rs 276.2 KB 276.3 KB +106 B
crates/map/src/qgis/raster.rs 16.0 KB 16.0 KB +29 B
crates/map/src/lib.rs 177.0 KB 177.0 KB +16 B
crates/map/src/qgis/tests.rs 133.7 KB 133.7 KB +13 B

…pengeos#1782)

Review round 4 of opengeos#1796:

- The worker and its row threads have no Cancel probe, so the per-row
  check never fired on the desktop. An export cancelled while it alone
  waits for a grid now sets that work's stop; the map, drawing again,
  starts it over.
- Stopped work calls `landed` too, so a frame or Layout page drawn while
  it was to come is drawn again and asks for what it draws now.
- The PMTiles export refuses when its wait for cast shadows was cancelled,
  instead of writing an archive without them.

Tests: a cancelled export stops the work it waited for, says so, and the
next draw starts it over; a cancelled PMTiles export writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread crates/render/src/lib.rs Outdated
// Its cast shadows, never waited for here (an export waits before it draws, see
// `complete`): drawn without them meanwhile, and again once they land. A map's frame
// says so as tiles to come; a page's, as shadows to come.
let light = match rl.cast_shadows(false, layer.id, shadows_landed) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug, medium-high confidence (from reading, not run). The cast-shadow owner is layer.id, which is map-local: Map::next_id starts each map at 1, so the first raster layer of almost every map has id 1. The Layout view draws every map frame on the UI thread with owner = layer.id, so two maps on one page that each have a Shaded Relief layer with different suns share an owner. In RasterLayer::cast_shadows, retain(... j.owner != owner ...) then stops the other frame's unfinished grid each time it asks. Frame A starts grid A, frame B stops it and starts grid B, and the next redraw (triggered by the stopped worker's landed()) does the same to B. Neither grid completes, the shadows never appear in the Layout view, and the page keeps redrawing.

This is not covered by the earlier threads, which were about the owner concept itself, not id reuse across maps.

Suggested fix: use the process-local identity the map crate already has, Layer::instance_id(). It is unique per live layer, renewed on copies (renew_instance_id), and kept by undo snapshots, which is what an owner needs. That means changing the owner parameter to u64, passing layer.instance_id() here and in wait_for_cast_shadows (lib.rs:6359), and passing u64::MAX in raster_export.rs:64. Please add a test with two RasterLayers that both have owner id 1 and different suns, and check that both grids complete.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5a081a2. The owner is now (map.instance_id(), layer.instance_id()), not the map-local id.

  • I used both identities rather than the layer's alone: a layer copied into another map can keep its instance id, and the pair covers that case too.
  • The export's pre-wait uses the same owner.
  • Test: two_maps_lit_otherwise_on_a_page_keep_their_own_shadows. Two maps, each with a copy of one relief as its first layer (id 1) and lit from opposite suns, sit on one page. The page is drawn again until it has no shadows to come.
  • With the old owner put back, the test fails: the page never got its shadows in 60 s. With the fix it settles at once.

Comment thread crates/render/src/lib.rs
rustgis_map::raster::CastShadows::Unavailable(why) => {
let e = format!("{}: {why}", layer.name);
// A page's frames say it on the page too (see `layout::shadow_notices`).
SHADOW_NOTICES.with(|n| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (Never silent), medium confidence. SHADOW_NOTICES is pushed here on every render_frame, including the interactive map view on the UI thread. Its only drain is take_shadow_notices() in the Layout page's frame_canvas (layout.rs:524). So a notice raised by the map view stays in the buffer until the next Layout render, and then the page reports it, even if the layer named is not on that page.

Failure case: map A has a Shaded Relief layer read in place with only its overview held. The user views map A, so its notice is pushed and not drained. The user then renders a layout of an unrelated map B. The Layout view says "A's layer: its cast shadows aren't drawn", which is false for that page.

Suggested fix: keep the page's notices separate from the map view's. For example, have render_frame push to SHADOW_NOTICES only when a page is being drawn (the existing NO_SHADOW_WAIT flag is set for exactly that case), or drain the map view's buffer on its own path so it never accumulates. Add a test that a map frame's notice doesn't appear in a later page's shadow_notices().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5a081a2. The shadow notices are cleared as each frame of a page starts, so a frame says only its own.

  • Test: a_page_says_why_a_frame_drew_no_cast_shadows now also draws a map-view frame that says a notice, then a clean page, which says nothing.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • Owner collision across maps (Layout view livelock), medium-high. The cast-shadow owner is the map-local layer.id, and each map's first layer gets id 1. Two maps on one page with different suns therefore supersede each other's unfinished grids, so the shadows never land and the page keeps redrawing. Posted inline at crates/render/src/lib.rs:4005, with Layer::instance_id() suggested as the owner. Not run; from reading the code.
  • Stale notices leak into Layout pages, medium. SHADOW_NOTICES is filled by every frame, including the map view on the UI thread, and drained only by the Layout view. A map-view notice can therefore show up on an unrelated layout page. Posted inline at crates/render/src/lib.rs:4018.

Performance

  • Cancelled export can start a new grid, low. If the wait gives up on Cancel and the worker has already finished as stopped, render_frame finds no job and starts a fresh full trace that nobody waits for. This compounds the existing point that the worker never sees Cancel.

Security

  • None found. Non-finite or out-of-range azimuth, altitude, z_factor and shadow are guarded, and project-file values are clamped before use.

Quality

  • Notices are per layer, not per kind with a count, low. AGENTS.md asks for one notice per kind with a count. The shadow notices are one per layer and reason, which follows the existing tile_errors pattern. Not raised inline.

AGENTS.md

  • Saved files, already raised. Adding shadow to a saved field without advancing FORMAT is still open. The author answered that it is a maintainer decision. Left alone, as already raised.
  • The new code follows the rules I checked: no unwrap or expect in non-test code, cancellation and background work go through the existing paths, and the layer-level notices are tested both ways.

Already raised, left alone (9 concerns): the UI-thread wait for layout pages, the refused-thread fallback, the race in the waiter count, the performance of low suns, the progress::check() probe not reaching worker threads, the stop-without-landed() case, raster_export treating Pending as success, the panic mapped to Stopped, and the saved-file FORMAT question. Several of these were answered and fixed in later commits and are not repeated here.

Change size

Compared with main (merge base 2c75bda to PR head 6a30910), the whole PR.

Measure Value
Files changed 16 (1 added, 15 modified, 0 removed, 0 renamed)
Size before 4.81 MB
Size after 4.86 MB
Total size change +54.6 KB
Path Before After Change
crates/map/src/raster.rs 88.3 KB 107.3 KB +18.9 KB
crates/map/src/shadow.rs 0 B 14.6 KB +14.6 KB
crates/render/src/lib.rs 562.0 KB 567.0 KB +5.0 KB
crates/render/src/raster.rs 56.5 KB 60.5 KB +4.0 KB
crates/render/src/layout.rs 225.9 KB 229.5 KB +3.6 KB
crates/render/src/raster_export.rs 11.5 KB 14.2 KB +2.7 KB
crates/engine/src/tests.rs 2.33 MB 2.33 MB +1.5 KB
docs/user-guide/data.md 102.2 KB 103.4 KB +1.2 KB
crates/engine/src/cmd/raster.rs 56.4 KB 56.9 KB +516 B
crates/ui-egui/src/layoutview.rs 173.2 KB 173.7 KB +488 B
crates/ui-egui/src/panes.rs 419.1 KB 419.6 KB +482 B
crates/engine/src/cmd/layout.rs 248.4 KB 248.7 KB +291 B
crates/ui-egui/src/mapview.rs 276.2 KB 276.3 KB +106 B
crates/map/src/qgis/raster.rs 16.0 KB 16.0 KB +29 B
crates/map/src/lib.rs 177.0 KB 177.0 KB +16 B
crates/map/src/qgis/tests.rs 133.7 KB 133.7 KB +13 B

…opengeos#1782)

Review round 5 of opengeos#1796:

- A layer's id repeats from map to map (each map's first layer is 1), so a
  page with a main map and a locator showing the same relief under other
  suns had each stop the other's unfinished grid: the shadows never
  landed and the page kept drawing. The owner is now the map's and the
  layer's live identities (`Map::instance_id`, `Layer::instance_id`).
- A page's frame no longer says what an earlier frame left (the map
  view's, another page's): the shadow notices are cleared as each frame
  of a page starts.

Tests: two maps on a page, lit otherwise, end up with both their shadows
(it never settles with the old owner); a clean page drawn after a map
view frame that said a notice says nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@elpidioassinaturas

Copy link
Copy Markdown
Contributor Author

Round 5's items without a thread, after 5a081a2:

  • A cancelled export can start a new grid: only on the next draw that asks for that sun, which is the map view showing the layer, and that draw wants the grid. Stopped work is dropped (round 2) and says it landed (round 4), so it is started again only by a frame that draws it. An export cancelled with nothing else drawing the layer starts nothing more.
  • Notices per layer, not per kind with a count: they follow tile_errors, as the summary says. Each names the layer and why, and the map and Layout views say each once (deduplicated in MapView::tile_errors). With several layers it's one line each, as with tile layers.

🤖 Generated with Claude Code

Comment thread crates/map/src/raster.rs Outdated
None => {
// Past the few kept, the oldest nothing waits for goes.
if jobs.len() >= KEPT_LIGHTS
&& let Some(i) = jobs.iter().position(|j| !j.work.waited())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug, medium-high confidence (from reading, not run). With KEPT_LIGHTS (3) grids already kept, this evicts the oldest one nothing waits for, even when it is still being worked out. Dropping it sets its stop; the worker returns Stopped and calls landed() (the branch just above). That redraws the map or page, which asks for the evicted grid again and evicts another unfinished one. With four Shaded Relief layers in one map, or four relief frames on a page, each pass stops one of the four grids, so none finishes. The view keeps redrawing and the shadows never appear.

Suggested fix (untested): evict only finished grids that nothing waits for. Unfinished grids are bounded by the layers that want them, and retain already drops the dead and superseded ones.

Suggested change
&& let Some(i) = jobs.iter().position(|j| !j.work.waited())
&& let Some(i) = jobs.iter().position(|j| j.work.is_done() && !j.work.waited())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced, and fixed in dff3689.

  • Reproduction: a page of 5 maps, each showing a copy of one relief as its first layer under its own sun, drawn again until no shadows were to come. It never settled in 60 s (many_maps_lit_otherwise_on_a_page_all_get_their_shadows).
  • Fix: each owner's latest grid is never let go, since it is what that owner draws. The cap of 3 now applies only to grids of suns their owners have left (done, nothing waiting), kept for going back. The test now settles at once.
  • Memory: live grids are bounded by the owners drawing the layer (one byte a cell each), plus those 3.
  • Different layers: four separate relief layers in one map never shared a list, since each layer has its own grids, so they weren't affected. Only copies of one layer lit otherwise were.

Comment thread crates/render/src/lib.rs
// says so as tiles to come; a page's, as shadows to come.
let light = match rl.cast_shadows(false, (map_instance, layer.instance_id()), shadows_landed) {
rustgis_map::raster::CastShadows::Ready(grid) => Some(grid),
rustgis_map::raster::CastShadows::Pending => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never silent, low-medium confidence. On a full-resolution draw (a map or PDF export, or the Layout page), Pending only sets SHADOWS_TO_COME. render_png and layout::render never read that flag, so the file is written with the layer drawn without its shadows and no notice.

complete waits for the grid before it draws, but the wait does not pin it. Once the wait returns, the waiter count drops to zero, so the cap can evict the finished grid (another job from the UI thread) before this lookup. That is the case the earlier answer ("a run that isn't cancelled always gets the grid") does not cover.

Suggested direction: when full_res_rasters gets Pending, record a notice or tile error as the Unavailable arm does, so the export refuses or names the gap. Or keep the grid pinned from the wait until the draw.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as it is, since after dff3689 the window is down to a change of sun during the export.

  • The grid an export waited for is its owner's latest, which is no longer let go. A finished grid of an owner's current sun stays until the pixels go.
  • The only way left between the wait and the draw is the same map, in the interface, moving to a new sun in that instant, and three other left-behind grids then evicting this one. The export would then draw its snapshot of the old sun without shadows.
  • If you'd like it closed fully, pinning the grids from the wait to the draw is a small follow-up. For this PR I'd rather not add it for a race that needs a sun change mid-export.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • Livelock when more than three grids are wanted at once (crates/map/src/raster.rs:781, inline). The cap evicts unfinished grids. Each eviction triggers a redraw that recreates the evicted grid and evicts another. With four relief layers in a map, or four relief frames on a page, shadows never appear and the view keeps redrawing. Confidence: medium-high, from reading, not run.

Security

  • None found.

Performance

  • The same livelock also churns threads: each redraw spawns new grid workers that are stopped almost at once. Confidence: medium-high, same cause as above.

Quality

  • Notices are one per layer, not one per kind with a count (crates/render/src/lib.rs and layoutview.rs). Low confidence, a nit. Left for the release quality pass.

AGENTS.md

  • Silent output on a full-resolution draw (crates/render/src/lib.rs:4010, inline). Pending in a full-resolution draw only sets a flag that render_png and layout::render never read, so the file is written without shadows and with no notice. The wait in complete does not pin the grid, so eviction can remove it first. Confidence: low-medium; the window is narrow.

Checked and found correct

  • The interactive map calls render(), which does not go through complete(), so it never blocks on a grid.
  • The Layout view sets NO_SHADOW_WAIT, so it does not block either.
  • Only exports wait.
  • The grid index uses the same wrapped (ci, ri) as the cell, so the shadow multiply lines up.
  • The shadow parameter is clamped, and a grid is only drawn when its length matches.
  • deeplearning.rs renders only classification layers, so it cannot reach shadows.

Prior reviews

  • I left 17 earlier threads alone. Twelve are fixed in the code I read. Four were answered with no new evidence: the ambient-light formula, the refused-thread fallback, the FORMAT question, and the lock-gap race. The low-sun cost in shadow.rs is documented and measured.

Change size

Compared: the whole PR against main (merge base 2c75bda to PR head 5a081a2).

Measure Value
Files changed 16 (added 1, modified 15, removed 0, renamed 0)
Size before 4.81 MB
Size after 4.87 MB
Total size change +57.2 KB
File Before After Change
crates/map/src/raster.rs 88.3 KB 107.5 KB +19.2 KB
crates/map/src/shadow.rs 0 B 14.6 KB +14.6 KB
crates/render/src/layout.rs 225.9 KB 232.8 KB +6.9 KB
crates/render/src/lib.rs 562.0 KB 567.3 KB +5.3 KB
crates/render/src/raster.rs 56.5 KB 60.5 KB +4.0 KB
crates/render/src/raster_export.rs 11.5 KB 14.2 KB +2.7 KB
crates/engine/src/tests.rs 2.32 MB 2.33 MB +1.5 KB
docs/user-guide/data.md 102.2 KB 103.3 KB +1.2 KB
crates/engine/src/cmd/raster.rs 56.4 KB 56.9 KB +0.5 KB
crates/ui-egui/src/layoutview.rs 173.2 KB 173.7 KB +0.5 KB
crates/ui-egui/src/panes.rs 419.1 KB 419.6 KB +0.5 KB
crates/engine/src/cmd/layout.rs 248.4 KB 248.6 KB +0.3 KB
crates/ui-egui/src/mapview.rs 276.2 KB 276.3 KB +0.1 KB
crates/map/src/qgis/raster.rs 16.0 KB 16.0 KB +29 B
crates/map/src/lib.rs 177.0 KB 177.0 KB +16 B
crates/map/src/qgis/tests.rs 133.7 KB 133.8 KB +13 B

…on a page get theirs (opengeos#1782)

Review round 6 of opengeos#1796: past 3 grids kept, the oldest unfinished one was
let go; a page with more maps showing copies of one relief under other
suns (an atlas of several frames) then had each landing redraw a frame
that started its grid again and let another go, so the shadows never
came. Reproduced: 5 maps on a page never settled within 60 s.

Each owner's latest grid is now kept, as it is what that owner draws; the
cap of 3 applies to grids of suns their owners have left (kept, done,
for going back).

Test: the two-map page test is shared with a five-map one, which settles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread crates/render/src/lib.rs
n.push(e.clone());
}
});
if !tile_out.errors.contains(&e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never silent, medium-high confidence. The PNG map export (share.exportMap, map_png, map_png_fitted) drops the shadows without any notice when this arm returns Unavailable. The path is png_of → render_png → render_exported (lib.rs ~6507), which refuses only pending_in_place and partial. Nothing in the engine reads tile_errors for a PNG. So a browser export of a raster over 500,000 cells, or one where the grid can't be allocated or the worker panics, is written as a plain relief with no message. The GeoTIFF export does say something, through gaps, but that's a different function.

This also answers the earlier thread on the layout notices. The author's reply said "the map export ... says what's missing (its gaps)". That holds for render_placed only, not for render_png.

Suggested fix (small): mark the layer as partial, so render_exported refuses with the reason already in tile_errors ("name: its cast shadows aren't drawn: …, so the image isn't written"). Needs a regression test that a browser-sized or failed grid refuses the PNG, and that a clean relief still writes.

Suggested change
if !tile_out.errors.contains(&e) {
if !tile_out.errors.contains(&e) {
tile_out.errors.push(e);
}
// Refused by the exports that draw whole, as a part drawn from less than it needs is.
if !tile_out.partial.contains(&layer.id) {
tile_out.partial.push(layer.id);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ae2c369, without marking the layer partial, which would make the export refuse.

  • PNG and PDF: these map exports now say the reason as a warning (": : its cast shadows aren't drawn: "), as the layout export does. rustgis_render::take_shadow_notices() gives what the frames drawn on the thread said, and render_exported clears it first.
  • GeoTIFF: render_placed gave every tile_errors entry as "a tile layer couldn't be drawn, so its part is empty", so a layer drawn without its shadows read as a failed tile layer. Its gaps now give the cast-shadow reasons as themselves and count only the real tile-layer errors under that sentence.
  • Test: a_map_export_says_a_layer_drawn_without_its_cast_shadows. A PNG and a GeoTIFF of a layer read in place with only its overview held each give one warning naming the layer and why, not as a tile layer. A clean export gives none.
  • Browser limit: the case asked for in the review can't be reached by native tests (cfg(target_arch = "wasm32")). It goes through the same Unavailable path as the read-in-place one tested here.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

I read the full diff, the 19 existing review threads, and the current code at the points the diff touches. I posted one inline comment. A second finding is in unchanged lines outside the diff, so it appears below instead.

Bugs

  • PNG map export drops cast shadows with no notice. When a grid is Unavailable, render_frame only records the reason in tile_errors. render_png → render_exported refuses only partial and pending_in_place, and nothing in the engine reads tile_errors for a PNG. A browser export of a raster over 500,000 cells, or a grid that can't be allocated, or a worker panic, writes a plain relief with no message. Confidence: medium-high. Inline comment posted at crates/render/src/lib.rs:4027, with a suggested fix that marks the layer partial.
  • Wrong gap wording in the GeoTIFF export (outside the diff, so not posted inline). render_placed (crates/render/src/lib.rs:6490) turns every tile_errors entry into "a tile layer couldn't be drawn, so its part is empty". The new cast-shadow reasons flow into that, so a layer drawn without shadows is described as a failed tile layer with an empty part. Confidence: medium. Fix: give shadow reasons their own gap wording.

Security

  • None found. shadow goes through f(), which rejects non-finite values, and the saved field can't hold NaN.

Performance

  • Nothing new beyond the low-sun trace cost the PR already documents. The per-frame LightKey mask clone is cheap in practice.

Quality

  • Nothing worth raising outside the release quality pass.

AGENTS.md

  • The earlier reply on the layout notices says the map export reports its gaps. That holds for the GeoTIFF export only, which is the same evidence as the first bug above.
  • Owner ids are renewed only on copy paths, so the grid cache stays bounded.
  • Exports wait via complete, the interactive map never waits, and the Layout view goes through without_shadow_wait. I found no silent path for shadows other than the PNG gap.

Prior reviews

  • 19 threads already exist. I left 17 alone: answered by the author, or already covered by a fix in the current head.
  • I re-raised one thread on the layout notices, because the author's answer does not hold for the PNG export. That is the first bug above.
  • I left the FORMAT question alone. The author has put it to the maintainer, and it is a maintainer decision.

Change size

Compared with main (merge base 2c75bda, head dff3689).

Measure Value
Files changed 16 (1 added, 15 modified, 0 removed, 0 renamed)
Size before 4.81 MB
Size after 4.87 MB
Total size change +56.9 KB
File Before After Change
crates/map/src/raster.rs 88.3 KB 108.3 KB +19.9 KB
crates/map/src/shadow.rs 0 B 14.6 KB +14.6 KB
crates/render/src/layout.rs 225.9 KB 233.0 KB +7.2 KB
crates/render/src/lib.rs 561.9 KB 567.2 KB +5.3 KB
crates/render/src/raster.rs 56.5 KB 60.5 KB +4.0 KB
crates/render/src/raster_export.rs 11.5 KB 14.2 KB +2.7 KB
crates/engine/src/tests.rs 2.32 MB 2.33 MB +1.5 KB
docs/user-guide/data.md 102.2 KB 102.8 KB +1.2 KB
crates/engine/src/cmd/raster.rs 56.4 KB 56.9 KB +516 B
crates/ui-egui/src/layoutview.rs 173.4 KB 173.8 KB +488 B
crates/ui-egui/src/panes.rs 419.1 KB 419.6 KB +482 B
crates/engine/src/cmd/layout.rs 248.4 KB 248.7 KB +291 B
crates/ui-egui/src/mapview.rs 276.2 KB 276.3 KB +106 B
crates/map/src/qgis/raster.rs 16.0 KB 16.0 KB +29 B
crates/map/src/lib.rs 177.0 KB 177.0 KB +16 B
crates/map/src/qgis/tests.rs 133.7 KB 133.8 KB +13 B

…as such (opengeos#1782)

Review round 7 of opengeos#1796:

- A PNG or PDF export of the map drew a layer whose cast shadows were
  unavailable (read in place, past the browser's cells, no memory) as a
  plain relief and said nothing: the reason only reached `tile_errors`,
  which those exports don't read. They now say it as a warning, as the
  layout export does; `rustgis_render::take_shadow_notices` gives what
  the frames drawn on this thread said.
- A GeoTIFF export said it as "a tile layer couldn't be drawn, so its
  part is empty": its gaps now give it as itself ("<layer>: its cast
  shadows aren't drawn: <why>"), apart from tile layers that failed.

Test: a PNG and a GeoTIFF of a layer read in place with only its overview
held each say why its shadows aren't drawn, not as a tile layer; a clean
export says nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread crates/map/src/raster.rs
// going back.
jobs.retain(|j| same(j) || j.owner != owner || j.work.is_done() || j.work.waited());
match jobs.iter().position(same) {
Some(i) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug, medium-low confidence. When a caller joins an existing unfinished job (this Some(i) branch), its landed callback is never stored. Only the caller that started the job gets start_light(…, landed) (line 805). The one that starts a job with a no-op is raster_export::export (landed = || {}, owner (u64::MAX, u64::MAX)), and same ignores the owner. So if a PMTiles export starts the grid first and the map view or a Layout frame joins it afterwards, the joiner's redraw request is dropped. The joiner then never redraws when the grid lands (see the separate comment on mapview.rs about redraw keys).

Suggested fix: keep a list of landing callbacks in LightWork (push on join, call all in finish), or call the one passed by the caller that started the job plus each joiner's.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5541646.

  • LightWork keeps every landing call (at most 32), and the worker makes them all once it is done, stopped or failed.
  • A joiner's call is added under the calls' lock after checking the work isn't done. The worker drains them after finish, so a call is either kept and made, or not needed: the joiner then gets the grid at once.
  • Test: who_joins_work_under_way_is_told_when_it_lands. A starter and a joiner on another owner are both told.

Comment thread crates/ui-egui/src/layoutview.rs Outdated
// view says them.
for e in rustgis_engine::render::layout::shadow_notices() {
if app.map.tile_errors.insert(e.clone()) {
app.session.notify(e, true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality / consistency, low-medium confidence. These cast-shadow notices are raised with notify(e, true), which is Severity::Error. The map export (share.rs map_work, map_pdf_inner) raises the same reason as s.warn (Warning). The layout export (engine/src/cmd/layout.rs:4093) also uses notify(e, true). A shadow the export still wrote shouldn't be an error. Suggested: use the same severity everywhere, most likely warn/Warning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5541646: the Layout view and the layout export now say these as warnings (Session::warn), as the map exports do. What was drawn is written, so it isn't an error.

let written = (|| {
let png = png_of(&mut map, view_size, width, height, dpi)?;
// Layers drawn without their cast shadows (see `rustgis_render::take_shadow_notices`).
shadows = rustgis_render::take_shadow_notices();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never silent, medium-high confidence; also a UI-thread hold. The shadow notices are taken here for share.exportMap and in map_pdf_inner, but map_png (share.rs:204-220) calls render_png and never takes them. map_png_fitted backs the app's Export Map download and Capture To Clipboard (ui-egui/src/lib.rs:3754, :4087), and sharing.rs:509 calls render_png directly. A layer drawn without its shadows there is silent. Suggested fix: in map_png, drain take_shadow_notices() and s.warn(...) each one.

Also, ui.captureMap (lib.rs:3751) and ui.exportMap run map_png_fitted synchronously on the UI thread. Through complete → wait_for_cast_shadows, they now block on the grid with no Cancel. The earlier fix (0306284) covered only the Layout view. These need a non-waiting draw or a background path (exports::runs_in_background).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5541646.

  • Notices: map_png now says why layers were drawn without their cast shadows, and, when it was drawn without waiting, that they were still being worked out. That covers map_png_fitted, the MCP snapshot, the time series and thumbnails. The web map page (sharing.rs) says the reasons too.
  • UI-thread hold: ui.captureMap and the ui.exportMap download draw inside the new public rustgis_render::without_shadow_wait, as the Layout view does: drawn without the grids still to come, and said.
  • Usually no wait at all: when the map already shows its shadows, the grid is the same owner's and key, so it is ready at once.
  • Left waiting: the thumbnail and the time series still wait as other exports do. They run off the frame or after the map has drawn.
  • Test: a_map_export_says_a_layer_drawn_without_its_cast_shadows now also checks map_png.

attribution: Vec<String>,
/// Tile layer errors already reported.
tile_errors: std::collections::HashSet<String>,
/// Tile layer errors, and why layers were drawn without their cast shadows (in the map or

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug, medium confidence (from reading, not run). key.tiles only follows cache::generation() when fetches_tiles is true (line ~890). That is false for a map with no tile layers, no tile terrain and no layer read in place. A cast-shadow grid landing bumps the generation through notify_at, but the key stays equal and no new frame is drawn. The map view never reads tiles_pending, so nothing else triggers a redraw.

Failure case: a DEM-only project. A Style change draws without shadows, the landing repaints, and the key is unchanged, so the shadows only appear after a pan, zoom or other revision. Suggested fix: count a visible relief layer with shadow > 0 in fetches_tiles, or fold the generation into the key while a frame reports shadows to come. Check the live_rev branch, which pins key.tiles = 0. The PR's headless screenshot may have come from an unrelated redraw, so it's worth checking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 5541646. The first window check didn't show it because opening the Style pane resized the view, which redrew the map.

  • The bug: fetches_tiles was false for a map of a DEM alone, so key.tiles stayed 0, and the landing's notify_at moved a generation the key never read.
  • The fix: fetches_tiles now also counts a map with a visible layer casting shadows (casts_shadows), as reads_in_place does, and the live-tiles shortcut that zeroes key.tiles leaves such a map alone.
  • Checked in the window with ui_shot, on a map with only the Ouro Preto DEM:
    • set the style with shadow 1;
    • give no input, just let 6 s of real time pass;
    • the later frame differs from the one right after the change in 13.8 % of its pixels: the shadows came in by themselves.
    • A trace of the key showed fetches=false, and so tiles stuck at 0, before the fix.
  • Test: a_relief_casting_shadows_is_drawn_again_as_they_land. Not casting, or hidden, it doesn't count.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • Relief-only maps may not redraw when shadows land (medium, from reading). mapview.rs keys redraws on cache::generation() only when fetches_tiles is true. A DEM-only map is not, so the landing never triggers a new frame and the shadows appear only after a pan or zoom. The PR's headless screenshot may have come from an unrelated redraw, so please verify. Inline on mapview.rs:340.
  • A joining caller's redraw callback is dropped (medium-low). In cast_shadows, the Some(i) join branch never stores landed. If raster_export (no-op callback) starts a grid that the map view or a Layout frame joins, that joiner never redraws. Inline on raster.rs:770.
  • Shadow notices are Error severity in the Layout view and the layout export, but Warning in the map exports (low-medium). A successful export shouldn't show an error. Inline on layoutview.rs:674; the same applies to engine/src/cmd/layout.rs:4093.

Security

  • None found.

Performance

  • No new findings. The trace-cost concern from the earlier thread stands and is covered there.

Quality

  • None raised beyond the points above. Nits are left for the release quality pass.

AGENTS.md

  • Never silent: map_png drops the notices (medium-high). map_png and map_png_fitted never drain the cast-shadow notices. Those paths back Export Map download and Capture To Clipboard, and sharing.rs:509 calls render_png directly. Inline on share.rs:423, which also covers the fix.
  • Never hold the window: ui.captureMap and ui.exportMap block the UI thread (medium-high). They run map_png_fitted synchronously and now wait for the grid with no Cancel. The earlier fix covered only the Layout view. Inline on share.rs:423.
  • One notice per kind with a count (low). The PNG and PDF export paths emit one notice per layer and reason. The engine convention aggregates per kind, as report.rs does. Not raised inline.

Earlier threads: 19 existing review threads. I did not re-raise any of them. The PNG and PDF notice thread (lib.rs:4027) is marked fixed in ae2c369; the map_png gap above is a different entry point, not a re-raise. Three of my inline anchors failed because they fell outside the diff, so I re-anchored them to changed lines in the same files.

Change size

Compared with main (merge base 2c75bda to PR head ae2c369), the whole PR.

Measure Value
Files changed 17 (1 added, 16 modified, 0 removed, 0 renamed)
Size before 4.85 MB
Size after 4.91 MB
Total size change +61.3 KB
File Before After Change
crates/map/src/raster.rs 88.3 KB 108.3 KB +19.9 KB
crates/map/src/shadow.rs 0 B 14.6 KB +14.6 KB
crates/render/src/layout.rs 225.9 KB 233.1 KB +7.2 KB
crates/render/src/lib.rs 562.0 KB 567.8 KB +5.9 KB
crates/render/src/raster.rs 56.5 KB 60.5 KB +4.0 KB
crates/engine/src/tests.rs 2.32 MB 2.33 MB +3.3 KB
crates/render/src/raster_export.rs 11.5 KB 14.2 KB +2.7 KB
docs/user-guide/data.md 102.2 KB 103.3 KB +1.2 KB
crates/engine/src/cmd/share.rs 37.0 KB 37.7 KB +0.7 KB
crates/engine/src/cmd/raster.rs 56.4 KB 56.9 KB +0.5 KB
crates/ui-egui/src/layoutview.rs 173.2 KB 173.7 KB +0.5 KB
crates/ui-egui/src/panes.rs 419.1 KB 419.6 KB +0.5 KB
crates/engine/src/cmd/layout.rs 248.4 KB 248.7 KB +0.3 KB
crates/ui-egui/src/mapview.rs 276.2 KB 276.3 KB +0.1 KB
crates/map/src/qgis/raster.rs 16.0 KB 16.0 KB +29 B
crates/map/src/lib.rs 177.0 KB 177.0 KB +16 B
crates/map/src/qgis/tests.rs 133.7 KB 133.8 KB +13 B

…ing moved (opengeos#1782)

Review round 8 of opengeos#1796:

- The map view draws again on the tile cache's generation only for a map
  that fetches tiles, so on a map of a DEM alone the shadows' landing
  (which moves that generation) drew nothing until the map was moved.
  A map with a visible layer casting shadows now counts, as one reading
  in place does. Checked in the window (ui_shot): with no input after the
  style is set, the frame 6 s later shows the shadows (13.8% of it
  changed); before, it didn't.
- A frame or export joining work under way kept no landing call, so the
  map wasn't drawn again when it landed: the work now keeps every
  landing call (32 at most) and makes them once done.
- The app's Capture To Clipboard and Export Map download draw on the
  interface's thread without waiting for cast shadows still being worked
  out (`rustgis_render::without_shadow_wait`), and `map_png` says both
  why a layer was drawn without them and when they were still to come.
  The web map page says the reasons too.
- Cast-shadow notices on Layout pages and layout exports are warnings,
  as they are in map exports: what was drawn is written.

Tests: a relief casting shadows makes the map draw again as they land
(and not without, or hidden); who joins work under way is told when it
lands; `map_png` says a layer drawn without its shadows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants