Files
benilla-octo/crates/benilla-app/examples/bake_zone_land.rs
T
gromandClaude Opus 5 65520a4a58 The map's darkening was a grid of grey boxes, and a 12 px string was emptying the glyph sheet
grom, after a night's play: "make the map look a lot nicer, it would be really nice if the
darkened areas were not just massive squares on the map, and should fit into the actual
edges of the terrian if that makes sense, also I shouldn't see this dark squares on the
fully zoomed out world map, also make it so the world map can't move out of the frame."

**The darkening.** OpenMapClassic washed a never-visited zone through its land mask, and
those masks are Voronoi-dilated by construction — "so that the forty of them tile each
continent with no gap" — so for any zone that is not on a coast the mask *is* the
rectangle. Measured at zoom 2.5 over the Eastern Kingdoms: a grid of flat grey boxes.
Nothing could have been softened into a fix; it was the wrong mask. It was also drawn at
full strength from every zoom, and at the whole-world view the zones' own art is not drawn
at all, so the only thing on the world map was the fog's rectangles.

A land mask for every map now comes from the client's own data. `bake_zone_land` unions a
map's `WorldMapOverlay` alpha — the pieces the game reveals as you explore, cut to the
shape of the ground — into one 256×256 mask; `overlay-land-index.py` writes the list the
addon reads, because Lua cannot test for a file and a *missing* mask is dropped by the
engine so the whole rectangle draws, which is the defect itself. 53 maps have overlays
(the 40 stock zones, Hyjal, Alterac Valley and 11 of the realm's own); 76 have none and
keep a wide feather. `clip-zone-masks-to-coast.py` then takes the open sea off the dilated
sheet masks — the same dilation was painting parchment out on the ocean — by closing the
continent's own land by 500 yards (above the 466-yard inradius of the widest enclosed gap,
so seams fill and the sea does not) and keeping a 300-yard coastal margin. Twelve
landlocked zones lose 0.0%; Darkshore loses 53%, all of it water.

`tga.rs` gains the writer that needed, `script::uimap` and `ui_world_map::build_catalog`
become public so an example can reach the uiMapID graph, and `WorldMapUiData` grows the
accessor so the VM push and an outside caller cannot build different lists.

**The glyph sheet.** His four-minute session reset it 27 times, each dropping every cached
glyph. The producer is Atlas-CFM's cursor coordinate readout: a 12 px FontString on
`WorldMapDetailFrame`, rewritten every frame, and the canvas zooms by scaling that frame —
140 device px a digit at a canvas scale of 6.9. Nothing about the addon is wrong, so the
fix is ours: `raster_ppem` rasterizes exactly up to 64 device px and snaps to a
third-octave ladder above it, with `cell()` scaling the cached bitmap and metrics left
keyed on the true ppem, so advances and `GetStringWidth` are bit-for-bit unchanged. Same
1,200-frame reproduction: 3 resets and 6,623 rasters before, **0 and 1,424** after, with
occupancy climbing once to 30% and stopping. A reset no longer clears the metric cache
either, and `note_loud_cell` names the string that asked for an outsized cell.

**Four more from the same log.** `SetOwner`'s unknown-anchor line named a forwarding
wrapper rather than the addon that chose the anchor (two `debugstack` frames now); the nine
reference anchor names are written down with the binary evidence, because `ANCHOR_TOP` is
not one of them. The `CreateFrame(kind, …, inherits=<other kind>)` warning fired in both
directions and only one drops content — `kind_subsumes` makes it directional, and
`type_chain` moves out of `install` so `IsObjectType` and the loader read one table. A
string that is not UTF-8 now names the Lua line that cut it, and both glyph warnings quote
the string the character came out of. The three world-map CVars stay unregistered, with the
argument written down beside `REGISTERED` so it is not re-litigated from the warning.

**`World.zmp`.** The realm's `WorldMapArea` carries a third `areaID == 0` row, `694 World`,
with an all-zero rect; the reference ships no bitmap for it and neither the graph nor the
realm's own `Overrides.lua` lets it reach a player. The miss is a `debug!` line now, and
the `warn!` is kept for a continent that *has* a rectangle and is missing its bitmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-04 21:16:20 -07:00

676 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `bake_zone_land`: one land mask per map, cut from the client's own exploration art.
//!
//! ```text
//! cargo run -q -p benilla-app --example bake_zone_land -- [--out <dir>] [--dry-run]
//! [--sheet <dir>] [--dump-rects <file>]
//! ```
//!
//! The map addon (`OpenMapClassic`) stitches every zone's 1.12 sheet onto one canvas, and a sheet
//! is a **rectangle** while a zone is not: Westfall's sheet paints sea, parchment and a slice of
//! Duskwood around the land. So each sheet is masked down to its own ground by a per-zone alpha
//! mask, `Media/Textures/MapZoneDilate/<uiMapID>.tga`. There are **forty** of those, hand-traced
//! upstream for stock Classic's forty outdoor zones. This realm's `WorldMapArea.dbc` has 175 rows
//! — 124 maps of its own (Alah'Thalas, Gilneas, Tel'Abim, Hyjal, the Timbermaw tunnels…) — and a
//! map with no mask falls back to a plain rectangular feather. That fallback is what grom sees:
//! "it would be really nice if the darkened areas were not just massive squares on the map, and
//! should fit into the actual edges of the terrian".
//!
//! The shape of the ground is already in the install, in a form that covers every map the realm
//! has, because the realm had to author it to ship the zone at all: `WorldMapOverlay.dbc`'s art.
//! An overlay is one piece the game reveals as you explore, and it is cut to the ground — that is
//! why a half-explored zone shows a ragged coastline rather than a half-rectangle. The **union of
//! a map's overlay alpha** is therefore the land, measured rather than traced, and it needs no
//! per-zone handwork: a realm update is a re-run of this tool.
//!
//! What it does, per map with at least one overlay:
//!
//! 1. Composites every overlay's alpha into a 1002×668 buffer — the detail sheet's own pixel
//! extent (`compat/classic_api/uimap.rs`'s `LAYER_WIDTH`/`LAYER_HEIGHT`) — at the overlay's
//! `offsetX`/`offsetY`, tile by tile, exactly as the window stacks them (see
//! [`rasterize_overlays`]).
//! 2. Raises any bare patch the overlay art completely surrounds, which is a seam between two
//! abutting pieces and not sea ([`fill_enclosed_gaps`]).
//! 3. Area-downscales that to 256×256, the size the shipped masks are.
//! 4. Softens it the way `scripts/soften-zone-masks.py` softens the hand-made ones, and for the
//! same reason: a binary mask ends every sheet on a crisp line against its neighbour, so
//! nothing blends. Same constants (`DILATE`, `BLUR`), and **without** that script's
//! `RECT_MARGIN` — the rect inset exists to hide a 1.12 sheet's painted parchment border, and
//! an overlay-derived mask has no border to hide.
//!
//! The report names the maps with **no** overlay at all, which are the ones that still need the
//! rectangular fallback, and flags any mask whose coverage is so high the mask is a square again.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use benilla_ui::script::uimap::{self, UiMapNode};
use benilla_ui::script::{WorldMapContinentView, WorldMapZoneView};
/// The detail sheet's pixel extent, which every overlay's offset is relative to: the same pair
/// `compat/classic_api/uimap.rs` publishes as `layerWidth`/`layerHeight`, and the reciprocals
/// `script/worldmap.rs`'s `OVERLAY_RECIP_X`/`_Y` scale a hit rect by.
const LAYER_WIDTH: usize = 1002;
const LAYER_HEIGHT: usize = 668;
/// An overlay's art is cut into 256×256 pieces (`WorldMapFrame.lua:115-158`).
const TILE: u32 = 256;
/// The shipped masks' size, so a baked one drops in beside a hand-made one.
const MASK: usize = 256;
/// Outward dilate, then ramp, both in mask pixels — `scripts/soften-zone-masks.py`'s own
/// constants, unchanged. That script's docstring is the argument for the pair: the dilate pushes
/// coverage out so the ramp starts at the old boundary instead of eating into the zone, and a
/// symmetric blur would take both neighbours to 50% at a shared border and show the sea-grey
/// underlay through as a bright seam.
const DILATE: usize = 2;
const BLUR: f32 = 3.0;
/// Coverage above this and the mask keeps essentially the whole sheet, i.e. it is the rectangle
/// again and masking with it buys nothing. Reported rather than suppressed: a map whose overlays
/// genuinely tile their sheet is a fact about the data, and silently shipping a square is how the
/// complaint happened in the first place.
const SQUARE_COVERAGE: f32 = 0.95;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let dry_run = args.iter().any(|a| a == "--dry-run");
// `--sheet <dir>`: the 1002×668 union before the downscale, one greyscale PNG per map. The
// diagnostic for the one failure that looks like success — an edge tile composited with its
// padding smears every overlay out to the 256 grid, which at mask size is a plausible-looking
// blob and at sheet size is unmistakably a lattice.
let sheet_dir: Option<PathBuf> = args
.iter()
.position(|a| a == "--sheet")
.and_then(|i| args.get(i + 1))
.map(PathBuf::from);
// `--dump-rects <file>`: where every map's art sits on the stitched world sheet, as
// `uiMapID<TAB>left<TAB>right<TAB>top<TAB>bottom` normalized on `WORLD_UI_MAP_ID`. The masks
// this example bakes are each in their own map's rect; anything that reasons about two maps at
// once — `scripts/clip-zone-masks-to-coast.py` composites all of them into one world-space land
// buffer — needs the placement too, and this is the same graph the map window is driven by, so
// the file cannot drift from what the client draws.
let dump_rects: Option<PathBuf> = args
.iter()
.position(|a| a == "--dump-rects")
.and_then(|i| args.get(i + 1))
.map(PathBuf::from);
let out_dir: PathBuf = args
.iter()
.position(|a| a == "--out")
.and_then(|i| args.get(i + 1))
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from("benilla-config/AddOns/OpenMapClassic/Media/Textures/MapZoneLand")
});
let Some(data) = benilla_formats::wow_data() else {
eprintln!("no install");
std::process::exit(1);
};
println!("data: {}", data.display());
let mut chain = benilla_formats::open_chain(&data).expect("chain opens");
let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable.dbc");
let maps =
benilla_assets::MapCatalogRes(benilla_formats::load_map_catalog(&mut chain).expect("Map"));
// The same catalog the map window itself is fed, so the masks are keyed to the maps this
// client has rather than to a list transcribed from somewhere else.
let (continents, ui_data) =
benilla_app::ui_world_map::build_catalog(&mut chain, &areas, &maps).expect("the catalog");
let direct_areas = ui_data.direct_area_views();
let graph = uimap::build_graph(&continents, &direct_areas);
println!(
"catalog: {} continents, {} zones, {} instance maps, {} graph nodes",
continents.len(),
continents.iter().map(|c| c.zones.len()).sum::<usize>(),
direct_areas.len(),
graph.len(),
);
if let Some(path) = &dump_rects {
dump_world_rects(&graph, path);
}
// The maps worth a mask: a node with a `WorldMapArea` row of its own, which is every zone and
// every orphan. The world sheet and the continents have no row in the catalog's zone lists —
// their art is a different stitch and the addon does not mask it — so they are counted, not
// listed as gaps.
let mut rows: Vec<(&UiMapNode, &WorldMapZoneView)> = Vec::new();
let mut no_row = 0usize;
for node in &graph {
match zone_view(&continents, &direct_areas, node) {
Some(view) => rows.push((node, view)),
None => no_row += 1,
}
}
println!(
"{} map rows to bake from; {} node(s) carry no row of their own (the world sheet and the \
continents)",
rows.len(),
no_row
);
// A mask is named by uiMapID, so two rows minting the same id would overwrite each other.
let mut seen: BTreeMap<u32, &str> = BTreeMap::new();
let mut collisions: Vec<String> = Vec::new();
for (node, _) in &rows {
if let Some(first) = seen.insert(node.ui_map_id, node.name.as_str()) {
collisions.push(format!("{} ({first} and {})", node.ui_map_id, node.name));
}
}
if !collisions.is_empty() {
println!(
"\nWARNING: {} uiMapID collision(s) — the later row's mask overwrites the earlier: {}",
collisions.len(),
collisions.join(", ")
);
}
let mut baked: Vec<Baked> = Vec::new();
let mut empty: Vec<(u32, String)> = Vec::new();
let mut missing_tiles: BTreeSet<String> = BTreeSet::new();
for (node, view) in &rows {
if view.overlays.is_empty() {
empty.push((node.ui_map_id, node.name.clone()));
continue;
}
let (mut sheet, tiles, missed) = rasterize_overlays(&mut chain, view, &mut missing_tiles);
let filled = fill_enclosed_gaps(&mut sheet);
if let Some(dir) = &sheet_dir {
std::fs::create_dir_all(dir).expect("the sheet directory");
let bytes: Vec<u8> = sheet
.iter()
.map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8)
.collect();
image::GrayImage::from_raw(LAYER_WIDTH as u32, LAYER_HEIGHT as u32, bytes)
.expect("the sheet is 1002x668")
.save(dir.join(format!("{}-sheet.png", node.ui_map_id)))
.expect("write the sheet");
}
if tiles == missed {
// Overlay rows with no art on this install: the map is as maskless as one with no
// rows at all, and the difference matters to whoever fixes it, so say which it is.
empty.push((
node.ui_map_id,
format!(
"{} [{} overlay row(s), no art]",
node.name,
view.overlays.len()
),
));
continue;
}
let mask = soften(&downscale(&sheet, LAYER_WIDTH, LAYER_HEIGHT, MASK, MASK));
let coverage = mask.iter().sum::<f32>() / (MASK * MASK) as f32;
baked.push(Baked {
ui_map_id: node.ui_map_id,
name: node.name.clone(),
map_file: view.map_file.clone(),
overlays: view.overlays.len(),
tiles,
missed,
filled,
coverage,
mask,
});
}
baked.sort_by_key(|b| b.ui_map_id);
empty.sort();
if !dry_run {
std::fs::create_dir_all(&out_dir).expect("the output directory");
for b in &baked {
let rgba: Vec<u8> = b
.mask
.iter()
.flat_map(|&a| [0, 0, 0, (a * 255.0).round().clamp(0.0, 255.0) as u8])
.collect();
let bytes = benilla_formats::rgba_to_tga(MASK as u32, MASK as u32, &rgba)
.expect("a 256x256 mask encodes");
std::fs::write(out_dir.join(format!("{}.tga", b.ui_map_id)), bytes).expect("write");
}
}
println!("\n-- baked --");
println!(
"{:>8} {:<28} {:<24} {:>8} {:>7} {:>7} {:>8} note",
"uiMapID", "name", "art folder", "overlays", "tiles", "seam px", "coverage"
);
for b in &baked {
let note = if b.coverage > SQUARE_COVERAGE {
"SQUARE: the overlays tile the whole sheet"
} else if b.missed > 0 {
"partial: some tiles are not in the chain"
} else {
""
};
println!(
"{:>8} {:<28} {:<24} {:>8} {:>7} {:>7} {:>8.3} {note}",
b.ui_map_id,
trim(&b.name, 28),
trim(&b.map_file, 24),
b.overlays,
if b.missed > 0 {
format!("{}-{}", b.tiles, b.missed)
} else {
b.tiles.to_string()
},
b.filled,
b.coverage,
);
}
println!("\n-- no overlay art: these still need the rectangular fallback --");
for (id, name) in &empty {
println!("{id:>8} {name}");
}
let squares: Vec<&Baked> = baked
.iter()
.filter(|b| b.coverage > SQUARE_COVERAGE)
.collect();
println!(
"\n{} mask(s) baked, {} map(s) with no overlay art, {} above {:.2} coverage{}",
baked.len(),
empty.len(),
squares.len(),
SQUARE_COVERAGE,
if squares.is_empty() {
String::new()
} else {
format!(
" ({})",
squares
.iter()
.map(|b| format!("{} {}", b.ui_map_id, b.name))
.collect::<Vec<_>>()
.join(", ")
)
}
);
if !missing_tiles.is_empty() {
println!(
"{} overlay tile(s) named by the DBC are not in the chain, e.g. {}",
missing_tiles.len(),
missing_tiles
.iter()
.take(4)
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
if dry_run {
println!(
"--dry-run: nothing written (would have written to {})",
out_dir.display()
);
} else {
println!("wrote {} mask(s) to {}", baked.len(), out_dir.display());
}
}
/// Write every placed map's rect on the world sheet, and report the sheet's scale in yards.
///
/// One row per node that `UiMapNode::placed_on_world` accepts, tab-separated:
/// `uiMapID left right top bottom`, normalized on `WORLD_UI_MAP_ID` with left/top the small end.
/// A continent is included as well as its zones — it is a real rect on the sheet, and a consumer
/// that wants only zones has the zone ids already.
///
/// The yards line exists because a world unit is otherwise dimensionless. Each row carries both
/// its yard rect (`loc_rect`, from `WorldMapArea.dbc`) and its world rect, so their ratio is
/// yards per world unit, measured once per map rather than assumed; it differs between the two
/// continents because each is scaled onto the sheet by its own art rect.
fn dump_world_rects(graph: &[UiMapNode], path: &std::path::Path) {
let mut lines = String::new();
let mut rows = 0usize;
// Yards per world unit, per continent, from every map whose placement and yard rect are both
// real. `loc_rect` runs left > right and top > bottom (WoW's axes decrease), so the spans are
// taken that way round.
let mut scales: BTreeMap<(u32, char), Vec<f32>> = BTreeMap::new();
for node in graph {
if !node.placed_on_world() {
continue;
}
let (l, r, t, b) = node.world_rect;
lines.push_str(&format!(
"{}\t{l:.8}\t{r:.8}\t{t:.8}\t{b:.8}\n",
node.ui_map_id
));
rows += 1;
let (ll, lr, lt, lb) = node.loc_rect;
if let Some(cont) = node.continent_map_id {
if ll - lr > 0.0 && lt - lb > 0.0 {
scales
.entry((cont, 'u'))
.or_default()
.push((ll - lr) / (r - l));
scales
.entry((cont, 'v'))
.or_default()
.push((lt - lb) / (b - t));
}
}
}
if let Some(dir) = path.parent() {
if !dir.as_os_str().is_empty() {
std::fs::create_dir_all(dir).expect("the dump directory");
}
}
std::fs::write(path, lines).expect("write the rects");
println!("\n-- world rects --");
println!("{rows} placed map(s) written to {}", path.display());
for ((cont, axis), mut v) in scales {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
println!(
"Map.dbc {cont} {axis}: {:.0} yards per world unit (median of {} map(s), \
{:.0}..{:.0})",
v[v.len() / 2],
v.len(),
v[0],
v[v.len() - 1],
);
}
}
/// One finished mask and the measurements behind it.
struct Baked {
ui_map_id: u32,
name: String,
map_file: String,
overlays: usize,
tiles: usize,
missed: usize,
/// Sheet pixels [`fill_enclosed_gaps`] raised: seams between abutting overlay pieces.
filled: usize,
/// Mean coverage over the 256×256, 0..1.
coverage: f32,
/// Row-major 256×256 coverage, 0..1.
mask: Vec<f32>,
}
fn trim(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
s.chars().take(n - 1).chain(std::iter::once('…')).collect()
}
/// The catalog row behind a graph node, joined the way `compat/classic_api/uimap.rs`'s `views`
/// joins it: a zone by its 1-based `(continent, zone)` dropdown pair, an orphan by its
/// `WorldMapArea` row id. `None` for the world sheet and the continents, which have neither.
fn zone_view<'a>(
continents: &'a [WorldMapContinentView],
direct_areas: &'a [(u32, WorldMapZoneView)],
node: &UiMapNode,
) -> Option<&'a WorldMapZoneView> {
if let Some(id) = node.direct_area {
return direct_areas
.iter()
.find(|(row_id, _)| *row_id == id)
.map(|(_, v)| v);
}
let (ci, zi) = node.selection?;
let cont = continents.get(ci.checked_sub(1)? as usize)?;
cont.zones.get(zi.checked_sub(1)? as usize)
}
/// The union of a map's overlay alpha over the 1002×668 detail sheet, as `(sheet, tiles, missed)`
/// with coverage in 0..1.
///
/// **Alpha-over, `c ← c + a(1 − c)`, not `max`.** Both are unions and both keep overlapping land
/// at full coverage rather than brightening past it, but this one is the arithmetic the window
/// itself performs: `WorldMapFrame.lua` creates one `WorldMapOverlay<i>` texture per piece on
/// `WorldMapDetailFrame` in the ARTWORK layer at the default `BLEND` mode, so a fully explored
/// map's visible reveal is `1 − Π(1 − aᵢ)` per pixel and nothing else.
///
/// Measured, the two barely differ on this data — Elwynn's sheet is 43.7% fully opaque under
/// `max` and 44.2% under this, mean coverage 0.544 against 0.546 — because 1.12's pieces are laid
/// edge to edge and hardly overlap at all. It is written this way anyway: where they *do* overlap
/// the window adds their feathers and `max` does not, so `max` would under-read exactly the seams
/// the art overlaps to hide, and a realm that authors its own overlays with real overlap would
/// get a striped mask out of this tool with no sign that anything was wrong.
fn rasterize_overlays(
chain: &mut benilla_formats::Chain,
view: &WorldMapZoneView,
missing: &mut BTreeSet<String>,
) -> (Vec<f32>, usize, usize) {
let mut sheet = vec![0f32; LAYER_WIDTH * LAYER_HEIGHT];
let mut tiles = 0usize;
let mut missed = 0usize;
for o in &view.overlays {
// The reference's own tile count and numbering: `ceil(w/256) * ceil(h/256)` files named
// `<texture><n>`, row-major from 1 (`WorldMapFrame.lua:115-158`, and
// `compat/classic_api/uimap.rs`'s `fileDataIDs` walk).
let (cols, rows) = (o.width.div_ceil(TILE), o.height.div_ceil(TILE));
for n in 1..=(cols * rows) {
tiles += 1;
let (col, row) = ((n - 1) % cols, (n - 1) / cols);
// The art is BLP on this install; the engine's own lookup tries `.tga` first
// (`0x835248`) and no shipped overlay is one.
let path = format!("{}{n}.blp", o.texture);
let Ok((tw, th, rgba)) = benilla_formats::read_texture_rgba(chain, &path) else {
missed += 1;
missing.insert(path);
continue;
};
// **The crop is the whole point.** An edge tile is padded out to 256 with whatever the
// authoring tool left there, and on this install that padding is opaque: composited
// whole, every overlay smears out to its tile grid and the mask becomes a lattice of
// squares — the defect this tool exists to remove, re-created one step later.
let keep_w = (o.width - col * TILE).min(TILE).min(tw) as usize;
let keep_h = (o.height - row * TILE).min(TILE).min(th) as usize;
for y in 0..keep_h {
let dy = o.offset_y as usize + (row * TILE) as usize + y;
if dy >= LAYER_HEIGHT {
break;
}
for x in 0..keep_w {
let dx = o.offset_x as usize + (col * TILE) as usize + x;
if dx >= LAYER_WIDTH {
break;
}
let a = rgba[(y * tw as usize + x) * 4 + 3] as f32 / 255.0;
let d = &mut sheet[dy * LAYER_WIDTH + dx];
*d += a * (1.0 - *d);
}
}
}
}
(sheet, tiles, missed)
}
/// Coverage below which a sheet pixel counts as bare for [`fill_enclosed_gaps`].
const BARE: f32 = 0.5;
/// Raise every bare region that does **not** reach the sheet's border to full coverage, and answer
/// how many pixels that was.
///
/// 1.12's overlay pieces are laid edge to edge, each feathered into its own rect, so where two
/// abut their ramps cross well below opaque and the union has a hairline gap along the join. Those
/// gaps are not sea — they are a seam between two paintings of the same ground — and left in they
/// come out of the downscale as dark specks scattered through the middle of a zone, which is a new
/// defect of the same family as the one this tool removes.
///
/// A flood from the border rather than a morphological close, because a close is a guess about how
/// wide a seam is and this is not: anything the overlay art completely surrounds is ground the
/// overlay art covers. Measured on the three stock zones below, it is a small correction — 76 px of
/// Elwynn's 669,336, 1,424 of Tanaris', 10,165 of Westfall's — and all of it is speckle.
fn fill_enclosed_gaps(sheet: &mut [f32]) -> usize {
let mut outside = vec![false; sheet.len()];
let mut stack: Vec<usize> = Vec::new();
let push = |i: usize, outside: &mut Vec<bool>, stack: &mut Vec<usize>| {
if sheet[i] < BARE && !outside[i] {
outside[i] = true;
stack.push(i);
}
};
for x in 0..LAYER_WIDTH {
push(x, &mut outside, &mut stack);
push(
(LAYER_HEIGHT - 1) * LAYER_WIDTH + x,
&mut outside,
&mut stack,
);
}
for y in 0..LAYER_HEIGHT {
push(y * LAYER_WIDTH, &mut outside, &mut stack);
push(y * LAYER_WIDTH + LAYER_WIDTH - 1, &mut outside, &mut stack);
}
while let Some(i) = stack.pop() {
let (x, y) = (i % LAYER_WIDTH, i / LAYER_WIDTH);
// Four-connected, so a one-pixel diagonal chink does not count as a way out to sea.
if x > 0 {
push(i - 1, &mut outside, &mut stack);
}
if x + 1 < LAYER_WIDTH {
push(i + 1, &mut outside, &mut stack);
}
if y > 0 {
push(i - LAYER_WIDTH, &mut outside, &mut stack);
}
if y + 1 < LAYER_HEIGHT {
push(i + LAYER_WIDTH, &mut outside, &mut stack);
}
}
let mut filled = 0usize;
for (i, v) in sheet.iter_mut().enumerate() {
if *v < BARE && !outside[i] {
*v = 1.0;
filled += 1;
}
}
filled
}
/// For each destination index, the source indices it covers and how much of each — one axis of a
/// box/area resample, so a 3.91-source-pixel-wide destination pixel weighs its two partial ends
/// by the fraction they contribute instead of rounding one of them away.
fn axis_weights(src: usize, dst: usize) -> Vec<Vec<(usize, f32)>> {
let scale = src as f64 / dst as f64;
(0..dst)
.map(|d| {
let (a, b) = (d as f64 * scale, (d + 1) as f64 * scale);
let first = a.floor() as usize;
let last = (b.ceil() as usize).clamp(first + 1, src);
(first..last)
.map(|s| {
let lo = (s as f64).max(a);
let hi = ((s + 1) as f64).min(b);
(s, (hi - lo).max(0.0) as f32)
})
.collect()
})
.collect()
}
/// Area-average `src` down to `dw`×`dh`.
fn downscale(src: &[f32], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec<f32> {
let xw = axis_weights(sw, dw);
let yw = axis_weights(sh, dh);
let mut out = vec![0f32; dw * dh];
for (dy, ys) in yw.iter().enumerate() {
for (dx, xs) in xw.iter().enumerate() {
let mut acc = 0f32;
let mut sum = 0f32;
for &(sy, wy) in ys {
for &(sx, wx) in xs {
let w = wy * wx;
acc += src[sy * sw + sx] * w;
sum += w;
}
}
out[dy * dw + dx] = if sum > 0.0 { acc / sum } else { 0.0 };
}
}
out
}
/// Square max filter of radius `r`, the dilate: `ImageFilter.MaxFilter(2r+1)`'s shape.
fn dilate(src: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
if r == 0 {
return src.to_vec();
}
// Separable, because max is: the max over a square is the max of the per-row maxima.
let mut mid = vec![0f32; w * h];
for y in 0..h {
for x in 0..w {
let lo = x.saturating_sub(r);
let hi = (x + r + 1).min(w);
mid[y * w + x] = src[y * w + lo..y * w + hi]
.iter()
.copied()
.fold(0.0, f32::max);
}
}
let mut out = vec![0f32; w * h];
for y in 0..h {
let lo = y.saturating_sub(r);
let hi = (y + r + 1).min(h);
for x in 0..w {
out[y * w + x] = (lo..hi).map(|k| mid[k * w + x]).fold(0.0, f32::max);
}
}
out
}
/// Separable gaussian of standard deviation `sigma`, edges clamped.
///
/// Clamped rather than zero-padded on purpose: an overlay that runs to the sheet's own border is
/// land that continues past it, and zero padding would fade that border out — putting a soft line
/// along a coast the art draws hard.
fn blur(src: &[f32], w: usize, h: usize, sigma: f32) -> Vec<f32> {
if sigma <= 0.0 {
return src.to_vec();
}
let r = (sigma * 3.0).ceil() as usize;
let k: Vec<f32> = (0..=2 * r)
.map(|i| {
let d = i as f32 - r as f32;
(-(d * d) / (2.0 * sigma * sigma)).exp()
})
.collect();
let norm: f32 = k.iter().sum();
let k: Vec<f32> = k.iter().map(|v| v / norm).collect();
let mut mid = vec![0f32; w * h];
for y in 0..h {
for x in 0..w {
let mut acc = 0f32;
for (i, &kw) in k.iter().enumerate() {
let sx = (x + i).saturating_sub(r).min(w - 1);
acc += src[y * w + sx] * kw;
}
mid[y * w + x] = acc;
}
}
let mut out = vec![0f32; w * h];
for y in 0..h {
for x in 0..w {
let mut acc = 0f32;
for (i, &kw) in k.iter().enumerate() {
let sy = (y + i).saturating_sub(r).min(h - 1);
acc += mid[sy * w + x] * kw;
}
out[y * w + x] = acc;
}
}
out
}
/// Dilate, then ramp — `scripts/soften-zone-masks.py`'s `soften` minus its `RECT_MARGIN` inset.
///
/// The inset is dropped because it is not a softening at all: it holds coverage at zero a fixed
/// fraction in from the sheet's border to hide the torn parchment frame a 1.12 zone sheet paints
/// around its own rect. An overlay has no frame — it is a cut-out of ground — so an inset here
/// would only throw away real coast, and on a map whose overlays run to the sheet edge it would
/// throw away the edge of the zone.
fn soften(coverage: &[f32]) -> Vec<f32> {
let d = dilate(coverage, MASK, MASK, DILATE);
blur(&d, MASK, MASK, BLUR)
.iter()
.map(|v| v.clamp(0.0, 1.0))
.collect()
}