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
This commit is contained in:
Executable
+515
@@ -0,0 +1,515 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clip OpenMapClassic's per-zone sheet masks to the continent's own coastline.
|
||||
|
||||
grom, at mid zoom out over the Great Sea west of the Eastern Kingdoms: pale parchment
|
||||
**rectangles float on the open ocean**. Measured from a capture at world point (0.68, 0.42), zoom
|
||||
6: the pale blocks sit at world points (0.725, 0.483) and (0.748, 0.407), and
|
||||
`OpenMapClassic.MapStitch.Rect(id)` puts both inside the rects of uiMapIDs 1426 (Dun Morogh) and
|
||||
1437 (Wetlands) -- two stock zones, each with a hand-made mask.
|
||||
|
||||
So the cause is the **dilation**. `Media/Textures/MapZoneDilate/<id>.tga` is Voronoi-dilated, "so
|
||||
that the forty of them tile each continent with no gap", and a Voronoi cell does not stop at the
|
||||
water: a coastal zone's cell runs out to sea until it meets the next cell, which out there is
|
||||
never. The mask is opaque over open ocean, so the zone's sheet paints its blank parchment there.
|
||||
`MapZoneSoft` is that same set softened and inherits it exactly.
|
||||
|
||||
On land the dilation is *right* and must be kept -- it is the only thing closing the seam between
|
||||
two neighbouring sheets. It is wrong only past the coast, where there is no neighbour to meet. So
|
||||
this intersects each soft mask with the continent's own land, and the three constants below are
|
||||
the three ways that intersection can be got wrong.
|
||||
|
||||
What the land is: `MapOverlayLand/<id>.tga`, the union of each map's `WorldMapOverlay` alpha --
|
||||
the pieces the game reveals as you explore, cut to the ground (`examples/bake_zone_land.rs`).
|
||||
Composited into one world-space buffer at each map's rect on the stitched sheet, it is the shape
|
||||
of Azeroth, measured rather than traced.
|
||||
|
||||
Three constants, each settling one trade-off:
|
||||
|
||||
* `CLOSE_YARDS`, the morphological close. The overlay union has gaps along every zone border --
|
||||
two sheets' art abutting, not sea -- and intersecting with it raw would re-cut every seam the
|
||||
dilation exists to hide. A close (dilate R, erode R) removes any background a disk of radius R
|
||||
cannot sit in, so it fills the seams and leaves the ocean. R is set from the data: the widest
|
||||
fully-enclosed gap in the union, per continent, which `--dry-run` measures and then re-measures
|
||||
after the close to show it is gone.
|
||||
* `MARGIN_YARDS`, the coastal keep. A 1.12 zone sheet deliberately paints the water off its own
|
||||
coast -- Blizzard's hatching, the islets, the ships -- and clipping exactly at the overlay's
|
||||
edge would shave all of it off. So the closed land is grown by this much before the intersect.
|
||||
Coastal water yes, open ocean no.
|
||||
* `FEATHER_YARDS`, the ramp at the outer edge of that margin. A hard cut would put a straight line
|
||||
across open water wherever the margin ends inside a sheet's rect -- the defect's own shape, one
|
||||
step further out. This is the same argument `soften-zone-masks.py` makes for its `BLUR`.
|
||||
|
||||
Reads `MapZoneSoft` and `MapOverlayLand`, writes `MapZoneCoast`; both sources are left alone so
|
||||
this is re-runnable and the three sets can be compared. 256x256 uncompressed 32-bit TGA out, RGB
|
||||
black, alpha the coverage, exactly as `soften-zone-masks.py` writes and the shipped masks are.
|
||||
|
||||
The world rects come from the client's own uiMap graph, dumped by
|
||||
|
||||
cargo run -q -p benilla-app --example bake_zone_land -- --dry-run --dump-rects <file>
|
||||
|
||||
which is the same graph the map window is driven by, so the placement here cannot drift from what
|
||||
the addon draws. `--rects` takes that file.
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Yards per world unit on the stitched sheet, measured by `--dump-rects` over every placed map:
|
||||
# 44533 across u and 29689 down v on both continents, to the yard, on all 76 of them. The sheet is
|
||||
# therefore *anisotropic in world units* and isotropic in yards -- 1002:668 is exactly 1.5, and so
|
||||
# is 44533:29689 -- which is why the land buffer below is sized in yards and not in world units: a
|
||||
# circular dilate in world units would be an ellipse on the map.
|
||||
YARDS_PER_U = 44533.0
|
||||
YARDS_PER_V = 29689.0
|
||||
|
||||
# The land buffer's resolution, as the width in pixels; the height follows from the yard extent so
|
||||
# a pixel is square in yards. 4096 across the 60100-yard span is 15.1 yards a pixel, finer than
|
||||
# the 256x256 source masks are over any zone (~19 yards for Dun Morogh), so the composite does not
|
||||
# throw away mask detail.
|
||||
BUFFER_WIDTH = 4096
|
||||
|
||||
# Pad the world extent by this much on every side, in world units, so a dilate near the outermost
|
||||
# map's edge is not clipped by the buffer border.
|
||||
EXTENT_PAD = 0.02
|
||||
|
||||
# Below this the composited coverage is background. The sources are softened (dilate 2, blur 3),
|
||||
# and a gaussian's half level sits on the step it blurred, so 0.5 recovers the dilated boundary.
|
||||
LAND_LEVEL = 0.5
|
||||
|
||||
# The close radius. A gap is closed when R exceeds its *inradius* -- the largest disk that fits
|
||||
# inside it -- because closing leaves exactly the background a disk of radius R can sit in. The
|
||||
# report measures that over every fully-enclosed gap in the union: 467 yards on Kalimdor (a 933
|
||||
# yard gap) and 267 on the Eastern Kingdoms (534 yards). 500 is just above the larger, and the
|
||||
# report then re-measures the gaps after the close to show there are none left.
|
||||
#
|
||||
# The guard that this is not *too* large is the other end of the report: a zone with no coastline
|
||||
# -- Elwynn, Badlands, Loch Modan, Duskwood -- must lose ~nothing, because a radius big enough to
|
||||
# bridge open water is a radius that has already swallowed the gaps around a landlocked zone. Too
|
||||
# small and the seams between neighbouring sheets re-open; too large and the coastline grows back
|
||||
# out to sea and nothing has been fixed.
|
||||
#
|
||||
# What it buys, measured with the coastal margin set to zero so the close is the only thing acting:
|
||||
# with no close the landlocked zones lose 2.7--35.8% of their coverage to the seams (Redridge
|
||||
# 35.8, Alterac 18.5, Loch Modan 17.8, Elwynn 14.2); at 500 that is 0.0--8.0%, and the 300-yard
|
||||
# margin takes the rest of it to 0.0 for every one of them.
|
||||
CLOSE_YARDS = 500.0
|
||||
|
||||
# The coastal keep, and the ramp past it. 300 yards is about 45 pixels of a 1.12 zone sheet (one
|
||||
# is 1002 px over 4900--7000 yards on these rects): the width of the hatched shallows and the
|
||||
# islets Blizzard paints off a coast, and far short of the 1000--1500 yards of blank parchment the
|
||||
# Voronoi cells were reaching into -- measured, 10.4% of everything the forty sheets paint sits
|
||||
# more than 600 yards past any land, and 2.9% sits more than 1200. Looked at, 300 is where the
|
||||
# kept water stops being a rectangle and starts being a coast; at 600 a straight rect edge is
|
||||
# still visible off the Wetlands and Dun Morogh, which is the defect itself. The feather is half
|
||||
# again, so the sheet's own water fades out instead of ending on a line of its own.
|
||||
MARGIN_YARDS = 300.0
|
||||
FEATHER_YARDS = 150.0
|
||||
|
||||
# Names for the report only, read from the addon's own data file, which carries the zone name in a
|
||||
# comment beside each uiMapID.
|
||||
NAMES_LUA = "Data/Levels.lua"
|
||||
|
||||
# Coverage loss above which a zone with no coastline is a failure rather than a result: the close
|
||||
# has eaten the inter-zone gaps instead of the ocean. See `CLOSE_YARDS`.
|
||||
INLAND_LOSS_LIMIT = 0.03
|
||||
|
||||
# The zones used as that guard: stock zones with no coastline, so every pixel of their dilated
|
||||
# mask is over their own ground or a neighbour's and nothing here may touch them. Arathi,
|
||||
# Stonetalon, Hillsbrad, Dun Morogh and Thousand Needles read as inland and are deliberately not
|
||||
# on the list -- each reaches water somewhere, and each does lose a slice. **Moonglade** is off it
|
||||
# for the same reason and it is the one that is not obvious: its rect's north edge is past
|
||||
# Kalimdor's own north coast (looked at: `--dump-png`'s land buffer, Moonglade's rect sits on the
|
||||
# shoreline north of Felwood), so it loses 9% and that loss is the fix working, not failing.
|
||||
INLAND_GUARD = (1416, 1418, 1422, 1427, 1428, 1429, 1430, 1431, 1432, 1433, 1449, 1452)
|
||||
|
||||
# grom's own two measurements of the defect, from a capture at world point (0.68, 0.42), zoom 6:
|
||||
# the middle of each pale parchment block floating on the Great Sea. The report answers what the
|
||||
# clip does at exactly those two points, so a run that does not close the reported symptom says so
|
||||
# rather than being judged on an average.
|
||||
DEFECT_PROBES = ((0.725, 0.483), (0.748, 0.407))
|
||||
|
||||
|
||||
def read_rects(path: Path) -> dict[int, tuple[float, float, float, float]]:
|
||||
"""`uiMapID<TAB>left<TAB>right<TAB>top<TAB>bottom`, normalized on uiMapID 947."""
|
||||
out = {}
|
||||
for line in path.read_text().splitlines():
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
out[int(parts[0])] = tuple(float(v) for v in parts[1:5])
|
||||
return out
|
||||
|
||||
|
||||
def read_names(path: Path) -> dict[int, str]:
|
||||
out = {}
|
||||
if not path.is_file():
|
||||
return out
|
||||
for m in re.finditer(r"\[(\d+)\]\s*=\s*\{[^}]*\}\s*,\s*--\s*(.+)", path.read_text()):
|
||||
out[int(m.group(1))] = m.group(2).strip()
|
||||
return out
|
||||
|
||||
|
||||
def read_alpha(path: Path) -> np.ndarray:
|
||||
"""A mask's alpha as 0..1 floats, row 0 at the top."""
|
||||
im = Image.open(path).convert("RGBA")
|
||||
return np.asarray(im.split()[-1], dtype=np.float32) / 255.0
|
||||
|
||||
|
||||
def sample(mask: np.ndarray, u: np.ndarray, v: np.ndarray) -> np.ndarray:
|
||||
"""Bilinear sample `mask` at normalized coordinates `u` (across) and `v` (down), clamped."""
|
||||
h, w = mask.shape
|
||||
x = np.clip(u * w - 0.5, 0.0, w - 1.0)
|
||||
y = np.clip(v * h - 0.5, 0.0, h - 1.0)
|
||||
x0 = np.floor(x).astype(np.int32)
|
||||
y0 = np.floor(y).astype(np.int32)
|
||||
x1 = np.minimum(x0 + 1, w - 1)
|
||||
y1 = np.minimum(y0 + 1, h - 1)
|
||||
fx = (x - x0)[None, :]
|
||||
fy = (y - y0)[:, None]
|
||||
top = mask[np.ix_(y0, x0)] * (1 - fx) + mask[np.ix_(y0, x1)] * fx
|
||||
bot = mask[np.ix_(y1, x0)] * (1 - fx) + mask[np.ix_(y1, x1)] * fx
|
||||
return top * (1 - fy) + bot * fy
|
||||
|
||||
|
||||
class World:
|
||||
"""The world-space land buffer: a pixel grid over the stitched sheet, square in yards."""
|
||||
|
||||
def __init__(self, rects: dict[int, tuple[float, float, float, float]], width: int):
|
||||
# The extent is every map's rect, not the unit square: the continents hang off both ends
|
||||
# of the world sheet on this realm's data (Kalimdor's left edge is u -0.187, the Eastern
|
||||
# Kingdoms' right edge is 1.163), so a buffer cut to 0..1 would clip real land.
|
||||
us = [r[0] for r in rects.values()] + [r[1] for r in rects.values()]
|
||||
vs = [r[2] for r in rects.values()] + [r[3] for r in rects.values()]
|
||||
self.u0, self.u1 = min(us) - EXTENT_PAD, max(us) + EXTENT_PAD
|
||||
self.v0, self.v1 = min(vs) - EXTENT_PAD, max(vs) + EXTENT_PAD
|
||||
self.w = width
|
||||
self.ypp = (self.u1 - self.u0) * YARDS_PER_U / width # yards per pixel, both axes
|
||||
self.h = max(1, int(round((self.v1 - self.v0) * YARDS_PER_V / self.ypp)))
|
||||
self.buf = np.zeros((self.h, self.w), dtype=np.float32)
|
||||
|
||||
def cols(self, u0: float, u1: float) -> tuple[int, int]:
|
||||
a = int(np.floor((u0 - self.u0) / (self.u1 - self.u0) * self.w))
|
||||
b = int(np.ceil((u1 - self.u0) / (self.u1 - self.u0) * self.w))
|
||||
return max(0, a), min(self.w, b)
|
||||
|
||||
def rows(self, v0: float, v1: float) -> tuple[int, int]:
|
||||
a = int(np.floor((v0 - self.v0) / (self.v1 - self.v0) * self.h))
|
||||
b = int(np.ceil((v1 - self.v0) / (self.v1 - self.v0) * self.h))
|
||||
return max(0, a), min(self.h, b)
|
||||
|
||||
def u_of(self, cols: np.ndarray) -> np.ndarray:
|
||||
return self.u0 + (cols + 0.5) / self.w * (self.u1 - self.u0)
|
||||
|
||||
def v_of(self, rows: np.ndarray) -> np.ndarray:
|
||||
return self.v0 + (rows + 0.5) / self.h * (self.v1 - self.v0)
|
||||
|
||||
def paste_max(self, rect: tuple[float, float, float, float], mask: np.ndarray) -> None:
|
||||
left, right, top, bottom = rect
|
||||
c0, c1 = self.cols(left, right)
|
||||
r0, r1 = self.rows(top, bottom)
|
||||
if c1 <= c0 or r1 <= r0:
|
||||
return
|
||||
u = (self.u_of(np.arange(c0, c1)) - left) / (right - left)
|
||||
v = (self.v_of(np.arange(r0, r1)) - top) / (bottom - top)
|
||||
# `cols`/`rows` round outwards, so the first and last pixel of the span can sit just past
|
||||
# the rect. `sample` clamps there rather than wrapping, which would smear the mask's edge
|
||||
# row one pixel out all the way round; these zero it instead.
|
||||
inside = (u >= 0) & (u <= 1)
|
||||
inside_v = (v >= 0) & (v <= 1)
|
||||
patch = sample(mask, u, v) * inside[None, :] * inside_v[:, None]
|
||||
np.maximum(self.buf[r0:r1, c0:c1], patch, out=self.buf[r0:r1, c0:c1])
|
||||
|
||||
def read(self, rect: tuple[float, float, float, float], field: np.ndarray, n: int) -> np.ndarray:
|
||||
"""Sample `field` over `rect` into an n x n grid -- the inverse of `paste_max`."""
|
||||
left, right, top, bottom = rect
|
||||
u = left + (np.arange(n) + 0.5) / n * (right - left)
|
||||
v = top + (np.arange(n) + 0.5) / n * (bottom - top)
|
||||
return sample(field, (u - self.u0) / (self.u1 - self.u0), (v - self.v0) / (self.v1 - self.v0))
|
||||
|
||||
|
||||
def edt(fg: np.ndarray, cap: float) -> np.ndarray:
|
||||
"""Euclidean distance in pixels from every pixel to the nearest `True`, exact up to `cap`.
|
||||
|
||||
Felzenszwalb--Huttenlocher's decomposition: an exact per-column distance first, then the exact
|
||||
combination along each row. The row pass is restricted to offsets within `cap`, which costs
|
||||
nothing in correctness for the answers we use -- if the true distance is at most `cap` then the
|
||||
nearest pixel is within `cap` columns -- and turns an O(w) inner loop into a fixed one.
|
||||
"""
|
||||
h, w = fg.shape
|
||||
inf = np.float32(cap + 1.0)
|
||||
g = np.where(fg, np.float32(0.0), inf)
|
||||
for y in range(1, h):
|
||||
np.minimum(g[y], g[y - 1] + 1.0, out=g[y])
|
||||
for y in range(h - 2, -1, -1):
|
||||
np.minimum(g[y], g[y + 1] + 1.0, out=g[y])
|
||||
np.minimum(g, inf, out=g)
|
||||
best = g * g
|
||||
tmp = np.empty_like(best)
|
||||
for dx in range(1, int(np.ceil(cap)) + 1):
|
||||
d2 = np.float32(dx * dx)
|
||||
for src, dst, pad in (
|
||||
(np.s_[:, dx:], np.s_[:, : w - dx], np.s_[:, w - dx :]),
|
||||
(np.s_[:, : w - dx], np.s_[:, dx:], np.s_[:, :dx]),
|
||||
):
|
||||
tmp[dst] = g[src]
|
||||
tmp[pad] = inf
|
||||
np.multiply(tmp, tmp, out=tmp)
|
||||
np.add(tmp, d2, out=tmp)
|
||||
np.minimum(best, tmp, out=best)
|
||||
return np.sqrt(best)
|
||||
|
||||
|
||||
def border_reach(mask: np.ndarray) -> np.ndarray:
|
||||
"""Which `True` pixels of `mask` are 4-connected to the buffer border -- i.e. are open sea.
|
||||
|
||||
Run-wise rather than pixel-wise: within one row, every pixel of a maximal run of `mask` is
|
||||
reachable from every other, so a whole run is settled at once. Alternating a row pass with a
|
||||
column pass converges in a handful of iterations where a pixel-at-a-time flood would take
|
||||
thousands.
|
||||
"""
|
||||
seed = np.zeros_like(mask)
|
||||
seed[0] |= mask[0]
|
||||
seed[-1] |= mask[-1]
|
||||
seed[:, 0] |= mask[:, 0]
|
||||
seed[:, -1] |= mask[:, -1]
|
||||
|
||||
def rows_pass(m: np.ndarray, s: np.ndarray) -> np.ndarray:
|
||||
starts = m.copy()
|
||||
starts[:, 1:] &= ~m[:, :-1]
|
||||
ids = np.cumsum(starts.ravel())
|
||||
flat_m = m.ravel()
|
||||
hit = np.bincount(ids[flat_m], weights=s.ravel()[flat_m], minlength=ids[-1] + 1) > 0
|
||||
return (hit[ids].reshape(m.shape)) & m
|
||||
|
||||
for _ in range(64):
|
||||
before = int(seed.sum())
|
||||
seed = rows_pass(mask, seed)
|
||||
seed = np.ascontiguousarray(
|
||||
rows_pass(np.ascontiguousarray(mask.T), np.ascontiguousarray(seed.T)).T
|
||||
)
|
||||
if int(seed.sum()) == before:
|
||||
break
|
||||
return seed
|
||||
|
||||
|
||||
def report_gaps(
|
||||
world: "World",
|
||||
rects: dict[int, tuple[float, float, float, float]],
|
||||
land: np.ndarray,
|
||||
dist_to_land: np.ndarray,
|
||||
cap: float,
|
||||
) -> np.ndarray:
|
||||
"""Print the widest fully-enclosed gap per continent, and answer which pixels those are."""
|
||||
bg = ~land
|
||||
holes = bg & ~border_reach(bg)
|
||||
for label, lo, hi in continent_bands(rects):
|
||||
band = np.zeros_like(holes)
|
||||
c0, c1 = world.cols(lo, hi)
|
||||
band[:, c0:c1] = True
|
||||
sel = holes & band
|
||||
if not sel.any():
|
||||
print(f" {label}: no enclosed gap at all")
|
||||
continue
|
||||
widest = float(dist_to_land[sel].max())
|
||||
at_cap = " (AT THE MEASUREMENT CAP -- raise --gap-cap-yards)" if widest >= cap else ""
|
||||
print(
|
||||
f" {label}: {int(sel.sum())} enclosed gap px, widest "
|
||||
f"{2 * widest * world.ypp:.0f} yards across (inradius {widest * world.ypp:.0f} "
|
||||
f"yards / {widest:.1f} px){at_cap}"
|
||||
)
|
||||
return holes
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("textures", type=Path, help="OpenMapClassic/Media/Textures")
|
||||
ap.add_argument("--rects", type=Path, required=True, help="--dump-rects output")
|
||||
ap.add_argument("--out", type=Path, default=None, help="default: <textures>/MapZoneCoast")
|
||||
ap.add_argument("--soft-dir", default="MapZoneSoft")
|
||||
ap.add_argument("--land-dir", default="MapOverlayLand")
|
||||
ap.add_argument("--close-yards", type=float, default=CLOSE_YARDS)
|
||||
ap.add_argument("--margin-yards", type=float, default=MARGIN_YARDS)
|
||||
ap.add_argument("--feather-yards", type=float, default=FEATHER_YARDS)
|
||||
ap.add_argument("--width", type=int, default=BUFFER_WIDTH)
|
||||
# How far out the gap measurement can see. Only the measurement: an enclosed gap wider than
|
||||
# this reads as exactly this wide, and the report says so rather than quietly under-reporting.
|
||||
ap.add_argument("--gap-cap-yards", type=float, default=4000.0)
|
||||
ap.add_argument("--dry-run", action="store_true", help="measure and report, write nothing")
|
||||
ap.add_argument("--dump-png", type=Path, default=None, help="land buffer and before/after PNGs")
|
||||
args = ap.parse_args()
|
||||
|
||||
soft_dir = args.textures / args.soft_dir
|
||||
land_dir = args.textures / args.land_dir
|
||||
out_dir = args.out or (args.textures / "MapZoneCoast")
|
||||
for d in (soft_dir, land_dir):
|
||||
if not d.is_dir():
|
||||
print(f"not a directory: {d}", file=sys.stderr)
|
||||
return 2
|
||||
rects = read_rects(args.rects)
|
||||
names = read_names(args.textures.parent.parent / NAMES_LUA)
|
||||
|
||||
# -- the land buffer -------------------------------------------------------------------
|
||||
world = World(rects, args.width)
|
||||
print(
|
||||
f"world buffer {world.w}x{world.h} over u {world.u0:.4f}..{world.u1:.4f}, "
|
||||
f"v {world.v0:.4f}..{world.v1:.4f} -- {world.ypp:.2f} yards a pixel"
|
||||
)
|
||||
land_ids, unplaced = [], []
|
||||
for path in sorted(land_dir.glob("*.tga"), key=lambda p: int(p.stem)):
|
||||
mid = int(path.stem)
|
||||
if mid not in rects:
|
||||
unplaced.append(mid)
|
||||
continue
|
||||
world.paste_max(rects[mid], read_alpha(path))
|
||||
land_ids.append(mid)
|
||||
print(
|
||||
f"composited {len(land_ids)} land mask(s); {len(unplaced)} have no rect on the sheet "
|
||||
f"({', '.join(str(m) for m in unplaced) or 'none'})"
|
||||
)
|
||||
|
||||
binary = world.buf >= LAND_LEVEL
|
||||
raw_cover = float(binary.mean())
|
||||
|
||||
# -- what the close has to fill --------------------------------------------------------
|
||||
# A gap between two zones is background the overlay art completely surrounds; open sea is
|
||||
# background that reaches the buffer's border. Closing by radius R removes exactly the
|
||||
# background a disk of radius R cannot sit inside, so the number that sets R is the largest
|
||||
# radius of a disk that fits in an enclosed gap.
|
||||
gap_cap = max(args.gap_cap_yards, args.close_yards * 1.5) / world.ypp
|
||||
dist_to_land = edt(binary, gap_cap)
|
||||
print(f"\nland {raw_cover * 100:.2f}% of the buffer")
|
||||
holes = report_gaps(world, rects, binary, dist_to_land, gap_cap)
|
||||
print(f"close radius R = {args.close_yards:.0f} yards ({args.close_yards / world.ypp:.1f} px)")
|
||||
|
||||
# -- close, then the coastal margin ----------------------------------------------------
|
||||
r_px = args.close_yards / world.ypp
|
||||
dilated = dist_to_land <= r_px
|
||||
closed = edt(~dilated, r_px * 1.5) > r_px
|
||||
print(
|
||||
f"close raised coverage {raw_cover * 100:.2f}% -> {float(closed.mean()) * 100:.2f}% "
|
||||
f"of the buffer; after it:"
|
||||
)
|
||||
# The same measurement again, which is the only proof the radius was big enough: a seam that
|
||||
# survives the close is a seam that will be cut out of two neighbouring sheets.
|
||||
report_gaps(world, rects, closed, edt(closed, gap_cap), gap_cap)
|
||||
# The margin and its ramp come out of one distance field: full coverage out to `margin`, then a
|
||||
# smoothstep to nothing by `margin + feather`.
|
||||
out_dist = edt(closed, (args.margin_yards + args.feather_yards) / world.ypp * 1.2)
|
||||
m_px = args.margin_yards / world.ypp
|
||||
f_px = max(args.feather_yards / world.ypp, 1e-6)
|
||||
t = np.clip(1.0 - (out_dist - m_px) / f_px, 0.0, 1.0)
|
||||
keep = (t * t * (3.0 - 2.0 * t)).astype(np.float32)
|
||||
print(
|
||||
f"coastal margin {args.margin_yards:.0f} yards + {args.feather_yards:.0f} yard feather "
|
||||
f"({args.margin_yards / YARDS_PER_U:.4f} of the sheet's width, "
|
||||
f"{args.margin_yards / YARDS_PER_V:.4f} of its height) "
|
||||
f"-> keep {float((keep > 0.5).mean()) * 100:.2f}% of the buffer"
|
||||
)
|
||||
|
||||
# -- the reported symptom, at the reported spots ----------------------------------------
|
||||
masked = {int(p.stem) for p in soft_dir.glob("*.tga")}
|
||||
print("\nat grom's two measured points:")
|
||||
for u, v in DEFECT_PROBES:
|
||||
col = int((u - world.u0) / (world.u1 - world.u0) * world.w)
|
||||
row = int((v - world.v0) / (world.v1 - world.v0) * world.h)
|
||||
here = [
|
||||
f"{m} {names.get(m, '?')}"
|
||||
for m, (left, right, top, bottom) in sorted(rects.items())
|
||||
if m in masked and left <= u <= right and top <= v <= bottom
|
||||
]
|
||||
print(
|
||||
f" ({u}, {v}): {out_dist[row, col] * world.ypp:>5.0f} yards past the closed land, "
|
||||
f"keep {keep[row, col]:.3f} -- in the rect of {', '.join(here) or 'no masked map'}"
|
||||
)
|
||||
|
||||
# -- the masks -------------------------------------------------------------------------
|
||||
rows = []
|
||||
for path in sorted(soft_dir.glob("*.tga"), key=lambda p: int(p.stem)):
|
||||
mid = int(path.stem)
|
||||
if mid not in rects:
|
||||
print(f" {mid}: no rect on the sheet, skipped", file=sys.stderr)
|
||||
continue
|
||||
before = read_alpha(path)
|
||||
after = (before * world.read(rects[mid], keep, before.shape[0])).astype(np.float32)
|
||||
rows.append((mid, before, after))
|
||||
|
||||
print(f"\n{'uiMapID':>8} {'zone':<24} {'before':>8} {'after':>8} {'removed':>8}")
|
||||
worst = []
|
||||
for mid, before, after in rows:
|
||||
b, a = float(before.mean()), float(after.mean())
|
||||
frac = 0.0 if b <= 0 else 1.0 - a / b
|
||||
worst.append((frac, mid))
|
||||
print(f"{mid:>8} {names.get(mid, '?'):<24} {b:>8.4f} {a:>8.4f} {frac * 100:>7.1f}%")
|
||||
|
||||
bad = [(f, m) for f, m in worst if m in INLAND_GUARD and f > INLAND_LOSS_LIMIT]
|
||||
worst.sort(reverse=True)
|
||||
print(
|
||||
"\nlost most: "
|
||||
+ ", ".join(f"{names.get(m, m)} {f * 100:.0f}%" for f, m in worst[:6])
|
||||
)
|
||||
print(
|
||||
"lost least: "
|
||||
+ ", ".join(f"{names.get(m, m)} {f * 100:.1f}%" for f, m in worst[-4:][::-1])
|
||||
)
|
||||
|
||||
if args.dump_png:
|
||||
args.dump_png.mkdir(parents=True, exist_ok=True)
|
||||
png(args.dump_png / "land-raw.png", world.buf)
|
||||
png(args.dump_png / "land-closed.png", closed.astype(np.float32))
|
||||
png(args.dump_png / "land-keep.png", keep)
|
||||
png(args.dump_png / "land-gaps.png", holes.astype(np.float32))
|
||||
for mid, before, after in rows:
|
||||
png(args.dump_png / f"{mid}-before.png", before)
|
||||
png(args.dump_png / f"{mid}-after.png", after)
|
||||
print(f"PNGs in {args.dump_png}")
|
||||
|
||||
# The guard is checked before anything is written, not after: a run that fails it has eaten
|
||||
# the inter-zone gaps and its masks would cut seams back into the sheets, so it ships nothing.
|
||||
if bad:
|
||||
print(
|
||||
"\nFAILED: inland zone(s) lost more than "
|
||||
f"{INLAND_LOSS_LIMIT * 100:.0f}% -- "
|
||||
+ ", ".join(f"{names.get(m, m)} {f * 100:.1f}%" for f, m in bad)
|
||||
+ ". The close radius is too small and is eating the inter-zone gaps, not the ocean."
|
||||
" Nothing written.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if args.dry_run:
|
||||
print(f"\n--dry-run: nothing written (would have written to {out_dir})")
|
||||
return 0
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for mid, _, after in rows:
|
||||
rgba = np.zeros(after.shape + (4,), dtype=np.uint8)
|
||||
rgba[:, :, 3] = np.round(np.clip(after, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||
Image.fromarray(rgba, "RGBA").save(out_dir / f"{mid}.tga")
|
||||
print(f"\nwrote {len(rows)} mask(s) to {out_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
def continent_bands(rects: dict[int, tuple[float, float, float, float]]) -> list:
|
||||
"""The u span each continent owns alone, for splitting a measurement between the two.
|
||||
|
||||
The two continent rects overlap in the middle of the sheet -- Kalimdor's reaches to u 0.640,
|
||||
the Eastern Kingdoms' starts at 0.372 -- and the overlap is all open water, so the band each
|
||||
owns outright is the honest place to measure its gaps.
|
||||
"""
|
||||
out = []
|
||||
kal, ek = rects.get(1414), rects.get(1415)
|
||||
if kal and ek:
|
||||
out.append(("Kalimdor", kal[0], ek[0]))
|
||||
out.append(("Eastern Kingdoms", kal[1], ek[1]))
|
||||
return out
|
||||
|
||||
|
||||
def png(path: Path, field: np.ndarray) -> None:
|
||||
data = np.round(np.clip(field, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||
Image.fromarray(data, "L").save(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write OpenMapClassic's `Data/OverlayLand.lua` from the baked `MapOverlayLand` masks.
|
||||
|
||||
`cargo run -p benilla-app --example bake_zone_land` writes one 256x256 alpha mask per map, the
|
||||
union of that map's own `WorldMapOverlay` art -- the shape of the ground it paints. Lua cannot ask
|
||||
whether a texture file exists (a missing one draws as a white quad, or, as a *mask*, is silently
|
||||
dropped and the whole rectangle shows), so the addon needs a table of which ids were baked. That
|
||||
is all this writes.
|
||||
|
||||
Run it after the baker, any time the mask set changes:
|
||||
|
||||
python3 scripts/overlay-land-index.py
|
||||
|
||||
`--dir` points at another mask folder, `--out` at another destination.
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ADDON = Path(__file__).resolve().parent.parent / "benilla-config/AddOns/OpenMapClassic"
|
||||
|
||||
HEAD = """--------------------------------------------------------------------------------
|
||||
-- Which maps have a generated land mask (Media/Textures/MapOverlayLand/<uiMapID>.tga).
|
||||
--
|
||||
-- GENERATED -- do not edit. `cargo run -p benilla-app --example bake_zone_land` writes the masks,
|
||||
-- `scripts/overlay-land-index.py` writes this list.
|
||||
--
|
||||
-- Each mask is the union of that map's own `WorldMapOverlay` alpha: the pieces the game reveals
|
||||
-- as you explore, cut to the shape of the ground, so the union is the map's land and nothing
|
||||
-- else. `Map/Stitch.lua` cuts the unexplored darkening to it, and falls back to it for the art of
|
||||
-- any map the hand-made `MapZoneSoft` set has no file for -- this realm has 175 `WorldMapArea`
|
||||
-- rows against that set's 40.
|
||||
--
|
||||
-- A file list, not a file test, because Lua has no way to ask whether a texture exists: a mask
|
||||
-- whose art is missing is dropped by the engine and the whole rectangle draws, which is the
|
||||
-- "massive squares" this set exists to remove.
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
local _, ns = ...
|
||||
|
||||
ns.MapOverlayLand = {
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dir", default=str(ADDON / "Media/Textures/MapOverlayLand"))
|
||||
ap.add_argument("--out", default=str(ADDON / "Data/OverlayLand.lua"))
|
||||
args = ap.parse_args()
|
||||
|
||||
folder = Path(args.dir)
|
||||
if not folder.is_dir():
|
||||
print(f"overlay-land-index: no mask folder at {folder}", file=sys.stderr)
|
||||
return 1
|
||||
ids = sorted(
|
||||
int(m.group(1))
|
||||
for p in folder.iterdir()
|
||||
if (m := re.fullmatch(r"(\d+)\.tga", p.name, re.IGNORECASE))
|
||||
)
|
||||
if not ids:
|
||||
print(f"overlay-land-index: {folder} holds no <id>.tga masks", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
body = []
|
||||
for i in range(0, len(ids), 8):
|
||||
body.append("\t" + " ".join(f"[{n}] = true," for n in ids[i : i + 8]))
|
||||
text = HEAD + "\n".join(body) + "\n}\n"
|
||||
Path(args.out).write_text(text, encoding="utf-8")
|
||||
print(f"overlay-land-index: {len(ids)} masks -> {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user