The map arrow pointed the wrong way wherever the player was not
`UpdateWorldMapArrowFrames` (`0x4a8d10`, `0x4a7c20`) copies the camera-tracked object's facing to both arrows and leaves them alone when there is no such object. Ours tested "is there a player" with `WorldMapState::player_uv.is_some()` — a different question, because that field is `GetPlayerMapPosition` on the **displayed** map. A view sitting on a continent, on another zone, or on an addon's stitched world answers `None` on every frame, so the arrow kept the heading it had when the window last sat on the player's own zone: the right place, pointing the wrong way. `player_facing_seen` is the real test, set the first time the feed pushes a facing — the feed runs only where there is a player, and the facing it pushes is unconditional. The existing test asserted the old behaviour on its second leg; that leg now asserts the heading following off the displayed map, and a new test holds the other half (before any feed, the login and loading screens, the arrow is left alone). `scripts/soften-zone-masks.py` is for the OpenMapClassic port rather than the client: its per-zone land masks are binary, so every map sheet ends on a crisp line against a differently-lit neighbour. It writes a softened copy — dilate, blur, times a rect ramp that reaches zero inside the sheet's own torn parchment border, which is the half that is not obvious. `docs/ADDON_COMPAT.md` writes up this round's five: the flashing arrow (two writers on one frame, measured `h h S h h S`), why correcting after 1.12 rather than suppressing it cost a full UI conversion every frame, pfQuest's pins being both behind the map and only ever for one zone, the softened masks, and the cities. Gates: fmt 0, clippy 0, benilla-ui 2121 pass / 0 fail, benilla-app 4233 pass / 12 fail (the recorded data-golden baseline). Corpus 86/87 load clean, 86/87 survive a session start and a UI probe, 0 raised on hover, click or drag — unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -206,6 +206,17 @@ pub struct WorldMapState {
|
||||
pub raid_uv: Vec<Option<(f32, f32)>>,
|
||||
/// Radians, 0 north, counterclockwise-positive; `UpdateWorldMapArrowFrames` turns the arrows.
|
||||
pub player_facing: f32,
|
||||
/// Whether [`Self::player_facing`] has ever been pushed, i.e. whether there is a player at
|
||||
/// all. `UpdateWorldMapArrowFrames` (`0x4a7c20`) copies the camera-tracked object's facing and
|
||||
/// leaves the arrow's alone when there is no such object — this is that test.
|
||||
///
|
||||
/// It used to be [`Self::player_uv`]`.is_some()`, which is a different question: that is
|
||||
/// `GetPlayerMapPosition` on the **displayed** map, so a view sitting on a continent, another
|
||||
/// zone, or an addon's stitched world — anywhere the player is not — froze the arrow's
|
||||
/// heading at whatever it was when the map last showed the player's own zone. The arrow was
|
||||
/// in the right place and pointing the wrong way, which is half of "where it shows the arrow
|
||||
/// is still incorrect".
|
||||
pub player_facing_seen: bool,
|
||||
/// `GetCorpseMapPosition()` on the displayed map; `None` is no corpse or off the map.
|
||||
pub corpse_uv: Option<(f32, f32)>,
|
||||
/// `PLAYER_EXPLORED_ZONES_1`, 64 words, bit n being `AreaTable.dbc` exploreFlag n.
|
||||
@@ -541,6 +552,7 @@ impl super::UiScript {
|
||||
model.worldmap.player_zone = player_zone;
|
||||
model.worldmap.player_uv = player_uv;
|
||||
model.worldmap.player_facing = player_facing;
|
||||
model.worldmap.player_facing_seen = true;
|
||||
model.worldmap.corpse_uv = corpse_uv;
|
||||
model.worldmap.party_uv = party_uv;
|
||||
model.worldmap.raid_uv = raid_uv;
|
||||
|
||||
@@ -164,6 +164,9 @@ fn arrow_wrapper(lua: &Lua, which: Arrow) -> mlua::Result<Option<Table>> {
|
||||
|
||||
/// `UpdateWorldMapArrowFrames()` (`0x4a8d10`, `0x4a7c20`): re-centre each arrow and copy the
|
||||
/// camera-tracked object's facing, the player's; with no player the facing stays.
|
||||
///
|
||||
/// "No player" is `WorldMapState::player_facing_seen`, not "the player is on the map the window
|
||||
/// is showing" — see that field for the arrow this used to freeze.
|
||||
fn update(lua: &Lua) -> mlua::Result<()> {
|
||||
for which in [Arrow::World, Arrow::Mini] {
|
||||
let Some(wrapper) = arrow_wrapper(lua, which)? else {
|
||||
@@ -173,7 +176,7 @@ fn update(lua: &Lua) -> mlua::Result<()> {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
(
|
||||
model.worldmap.player_facing,
|
||||
model.worldmap.player_uv.is_some(),
|
||||
model.worldmap.player_facing_seen,
|
||||
model.model_facts_for(ARROW_MODEL),
|
||||
)
|
||||
};
|
||||
@@ -490,11 +493,39 @@ mod tests {
|
||||
(facing - 1.25).abs() < 1e-6,
|
||||
"the tracked object's facing: {facing}"
|
||||
);
|
||||
// Off the displayed map, the heading still follows. `0x4a7c20` reads the camera-tracked
|
||||
// object, which is the player wherever the window happens to be looking; this leg used to
|
||||
// assert the opposite, because the "is there a player" test was `player_uv.is_some()` —
|
||||
// `GetPlayerMapPosition` on the *displayed* map. A stitched continent view answers `None`
|
||||
// there on every frame, so the arrow kept the heading it had when the map last sat on the
|
||||
// player's own zone.
|
||||
s.set_world_map_feed(None, None, 2.0, None, Vec::new(), Vec::new());
|
||||
s.run("UpdateWorldMapArrowFrames()").unwrap();
|
||||
assert!(
|
||||
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 1.25).abs() < 1e-6,
|
||||
"no tracked object: the facing does not move"
|
||||
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 2.0).abs() < 1e-6,
|
||||
"the player is still the tracked object off the displayed map"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of that rule: with no player at all — nothing has fed a facing yet, which
|
||||
/// is the login screen and the loading screen — the arrow keeps whatever facing it has.
|
||||
#[test]
|
||||
fn a_world_map_arrow_keeps_its_facing_until_a_player_feeds_one() {
|
||||
let mut s = vm();
|
||||
arrow_facts(&mut s);
|
||||
s.run("CreateWorldMapArrowFrame(WM)").unwrap();
|
||||
s.run("arrow = ({WM:GetChildren()})[2]").unwrap();
|
||||
s.run("arrow:SetFacing(0.75)").unwrap();
|
||||
s.run("UpdateWorldMapArrowFrames()").unwrap();
|
||||
assert!(
|
||||
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 0.75).abs() < 1e-6,
|
||||
"no feed has run: the facing is left alone"
|
||||
);
|
||||
s.set_world_map_feed(None, None, 2.0, None, Vec::new(), Vec::new());
|
||||
s.run("UpdateWorldMapArrowFrames()").unwrap();
|
||||
assert!(
|
||||
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 2.0).abs() < 1e-6,
|
||||
"once a player has fed one, it is the arrow's"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3589,3 +3589,188 @@ trade is the one that constant's own comment has always named: an addon that gat
|
||||
rather than on a function takes the newer branch and may reach for a name this layer has not
|
||||
built, where it gets a clean `nil`-call error naming the function. The floor is unchanged and
|
||||
still load-bearing — below `11304`, pfUI does not load at all.
|
||||
|
||||
## The player arrow was flashing because two things were placing it
|
||||
|
||||
> "my player arrow is still all messed up, its like flashing between a position and where I
|
||||
> actually am" — grom
|
||||
|
||||
1.12 places the world map's arrow from `WorldMapButton_OnUpdate`, **every frame the map is
|
||||
shown** (`WorldMapFrame.lua`, the `-- Position player` block): `PositionWorldMapArrowFrame`
|
||||
against `WorldMapDetailFrame` when `GetPlayerMapPosition("player")` answers, and
|
||||
`ShowWorldMapArrowFrame(nil)` — a hide — when it answers `(0, 0)`, which is every frame the
|
||||
window is not on the player's own zone.
|
||||
|
||||
On a stitched map that placement is wrong in both arms: the fraction is measured against
|
||||
Blizzard's own continent art, which is a different projection from OpenMap's grid, and the
|
||||
`(0, 0)` arm fires constantly because the map underneath is usually some other zone. The port's
|
||||
`Map/StockArrow.lua` already placed the arrow properly — on a `0.05 s` throttle. At 60 fps that
|
||||
does not *replace* 1.12's placement, it **alternates** with it. Counted over 199 frames of a
|
||||
capture, sampling the arrow model every frame:
|
||||
|
||||
```
|
||||
before shown 66, hidden 133 run: h h S h h S h h S h h S h h (stock hid it on 199 frames)
|
||||
after shown 199, hidden 0 run: S S S S S S S S S S S S S S
|
||||
```
|
||||
|
||||
That is the flashing, measured. It only started when `C_Map.GetPlayerMapPosition` began answering
|
||||
at all — before the unit feed stopped publishing Bevy's frame as world yards, `WorldMap.lua`'s
|
||||
`S.SetPlayer` path drew OpenMap's own arrow and the stock one stayed hidden, so there was nothing
|
||||
to fight.
|
||||
|
||||
The fix is not to write after 1.12 but to stop it writing. `StockArrow.lua` wraps
|
||||
`WorldMapButton_OnUpdate`'s global — the XML calls it by name, so replacing the global is enough —
|
||||
computes the placement first, and for the frames where it has an answer makes
|
||||
`PositionWorldMapArrowFrame` and `ShowWorldMapArrowFrame` no-ops for the length of the stock
|
||||
call. Re-asserted on the map's `OnShow`, because three addons in this realm's own folder
|
||||
(`FlightMap`, `LevelRange-Turtle`) replace that global outright rather than hooking it.
|
||||
|
||||
### Correcting *after* 1.12 would have cost a full UI pass every frame
|
||||
|
||||
The first version of this did run after the stock handler and re-show the arrow. It looked right
|
||||
and was expensive, which is worth writing down because the same shape will come up again: a frame
|
||||
going hidden → shown takes a **new link-stamp** (`WidgetArena::resequence_to_tail`, bits 21..38
|
||||
of the packed `ZKey`), the stamp is in the extracted entry, and the extract gate compares
|
||||
extracted entries. So an arrow hidden and shown once a frame moved one entry's sort key once a
|
||||
frame, and `ui_script`'s gate — which otherwise skips the whole conversion when nothing changed —
|
||||
missed on every single frame.
|
||||
|
||||
```
|
||||
WOW_UI_GATE=1, stitched map, settled
|
||||
correcting after [ui-gate] miss: entry 1292/1372 … ModelPane … z 6922020567064772608 → …066869760 every frame
|
||||
suppressing [ui-gate] miss: capture mode (the gate never skips under a capture) i.e. nothing differed
|
||||
```
|
||||
|
||||
With his own saved variables and the map open that conversion is **1.19–1.54 ms a frame** on top
|
||||
of a 0.88 ms extract (`WOW_UI_PHASES`, 5,148 quads at 1100x800). The map's whole UI pass is
|
||||
2.1–3.0 ms/f against 0.6 ms with the map closed, and the frame itself 8.67 ms open against 3.73
|
||||
closed — so the stitched map is the most expensive window in the interface and a gate miss on it
|
||||
is not a rounding error.
|
||||
|
||||
The *point* is still written every frame, and the distinction is worth keeping straight: a
|
||||
`SetPoint` is read by the extract once, after every handler has run, so the last writer wins and
|
||||
the rect it sees is constant. A `Show` is not — it moves the frame in the draw order and the new
|
||||
stamp is what the gate reads. Memoizing the point to skip the write looked like the same saving
|
||||
and was a bug: 1.12 re-anchors `WorldMapPlayer` from its own handler and only the arrow frames'
|
||||
two verbs are suppressed, so the frames where the value had not changed kept 1.12's answer on the
|
||||
button that carries the arrow's tooltip.
|
||||
|
||||
## The arrow's heading froze wherever the player was not
|
||||
|
||||
`UpdateWorldMapArrowFrames` (`0x4a8d10`, `0x4a7c20`) copies the camera-tracked object's facing to
|
||||
both arrows and leaves them alone when there is no such object. Ours tested "is there a player"
|
||||
with `WorldMapState::player_uv.is_some()` — and that field is `GetPlayerMapPosition` on the
|
||||
**displayed** map, which is a different question. A view sitting on a continent, on another zone,
|
||||
or on an addon's stitched world answers `None` on every frame, so the arrow kept whatever heading
|
||||
it had when the window last sat on the player's own zone: the right place, pointing the wrong way.
|
||||
|
||||
`player_facing_seen` is the real test, set the first time the feed pushes a facing — the feed runs
|
||||
only where there is a player, and the facing it pushes is `player.facing()` unconditionally. Two
|
||||
tests hold both halves: the heading follows off the displayed map, and before any feed (login and
|
||||
loading screens) the arrow is left alone.
|
||||
|
||||
## pfQuest's pins were behind the map, and only ever for one zone
|
||||
|
||||
> "pfui pins are still messed up, can't see them at most zooms and the ones I can see them at are
|
||||
> pretty useless" — grom
|
||||
|
||||
Two separate defects with one symptom, and the zoom-announcement fix of the previous round was
|
||||
neither of them. With four nodes seeded into Dun Morogh and the rest from pfQuest's own database:
|
||||
|
||||
```
|
||||
zoom 4 (host = continent) pfQuest pins built 131, shown 0 ← GetCurrentMapZone() is 0
|
||||
zoom 8 / 16 / 30 (zone) built 131, shown 131, 13.6 / 15.2 / 17.9 screen px
|
||||
```
|
||||
|
||||
The sizes were right all along. What was wrong was the **set** and the **level**.
|
||||
|
||||
*The set.* `pfMap:UpdateNodes` opens `local map = pfMap:GetMapID(GetCurrentMapContinent(),
|
||||
GetCurrentMapZone())` and returns at once when that is nil. On Blizzard's map that is the whole
|
||||
story, because the window only ever shows one map. On a stitched map the view is usually looking
|
||||
at several zones and only the map underneath has any pins at all — and zoomed out far enough that
|
||||
OpenMap switches the map underneath to a continent, `GetCurrentMapZone()` is 0 and **every pin
|
||||
disappears**.
|
||||
|
||||
*The level.* pfQuest fixes no frame level: every pin is a child of `WorldMapButton` and takes its
|
||||
level, 3. `S.Build` lays 80 levels of stitched art over the canvas, `artTop` 188. So at zoom 8,
|
||||
131 pins answered `IsShown()` and **not one pixel of any of them was on the screenshot**. Questie's
|
||||
and Leatrix's are lifted for exactly this reason (`Map/Extras.lua`); pfQuest reached nobody's list
|
||||
because it does not place its pins through HereBeDragons.
|
||||
|
||||
`Map/PfQuest.lua` (new, port-authored) does both: it lifts pfQuest's own pins above `S.artTop`,
|
||||
and for every zone in view that is *not* the map underneath it places the **quest log's** nodes
|
||||
(`pfMap.nodes["PFQUEST"]`, what `quest.lua` writes) through pfQuest's own `BuildNode` /
|
||||
`UpdateNode` / `ResizeNode`, so they are its pins in every way a player can tell — its textures,
|
||||
its `WorldMapTooltip` tooltips, its zoom sizing. Quest-log nodes only, not the database search's:
|
||||
a search can put thousands of spawns on one zone and twenty zones of that is a different
|
||||
complaint. Bounded at 300 pins, refreshed on `OpenMap_ViewChanged` (throttled to a few a second)
|
||||
and on pfQuest's own `UpdateNodes`. A switch in the map's own checklist, shown only when pfQuest
|
||||
is loaded, as Questie's row is.
|
||||
|
||||
Measured after, same seeds: continent zoom 12 bridge pins shown where 0 pins showed before; zone
|
||||
zoom 131 of pfQuest's own now visible plus 5 neighbours'.
|
||||
|
||||
## Zone sheets butted against each other, so the continent looked like a grid of boxes
|
||||
|
||||
> "it looks super boxy and weird, edges of maps are the same, is there a way that we can blend
|
||||
> together maps that overlap?" — grom
|
||||
|
||||
`Media/Textures/MapZoneDilate/<uiMapID>.tga` is one 256x256 alpha mask per Classic outdoor zone,
|
||||
Voronoi-dilated so the forty of them tile each continent with no gap. They are **binary**:
|
||||
measured over four of them, 19k–48k pixels at alpha 0, 12k–51k at 255, and under 250 pixels
|
||||
anywhere in between. Every sheet, and every shade over an unexplored one, therefore ends on a
|
||||
crisp line against a neighbour painted at a different brightness, and the two sheets are different
|
||||
paintings of the same ground. Nothing blends because nothing was asked to.
|
||||
|
||||
`scripts/soften-zone-masks.py` writes `MapZoneSoft` beside it: the same coverage dilated 2 px,
|
||||
blurred 3 px, and multiplied by a rect ramp that reaches zero 5% in from the sheet's own border.
|
||||
The inset is the half that is not obvious — a 1.12 zone sheet paints its own torn parchment frame
|
||||
and its own sea right to the edge of its rect, and the shipped masks are cut so that none of that
|
||||
shows. Softening with no inset pulls it back in: measured, a hard straight line of Wetlands'
|
||||
parchment border across a view where the binary mask had a soft coast. With the inset, the land
|
||||
the mask gives up is covered by the neighbour's own dilated mask, which is what the dilation was
|
||||
for.
|
||||
|
||||
Captured at the same view before and after, the vertical and horizontal seams across Khaz Modan
|
||||
and the Wetlands are gone and the continent reads as one painting; at zone zoom (22x over
|
||||
Ironforge) the hard diagonal where the next sheet cut in is gone too. `MapZoneDilate` stays beside
|
||||
it as the script's source, so the transform is re-runnable and the two can be compared.
|
||||
|
||||
### Where "darken unexplored areas" did not reach
|
||||
|
||||
The shade over a zone the player has not found takes `ZoneMask(...)`, which answers only for the
|
||||
forty zones in `S.ZoneMaskIDs`; the zone's **art** falls back to the plain edge feather when there
|
||||
is no mask (`PlaceBase`). So a zone with no generated mask was drawn and never shaded — the art
|
||||
had a fallback and the shade did not. Both shade paths now take the same fallback the art takes.
|
||||
On this realm that reaches Hyjal (2482) and anything added later; the realm's other new land
|
||||
(Alah'Thalas, the Grim Reaches, Dun Kithas, Ambershire) is painted *inside* the vanilla sheets
|
||||
rather than given maps of its own — measured off the client, 47 zone maps in all, 1411–1458 plus
|
||||
2482, and no others.
|
||||
|
||||
## Cities: a half-faded plan over the zone's own painting, and the minimap you cannot have
|
||||
|
||||
> "on certain cities I can't get it to go into the minimap view, or it does but there is still an
|
||||
> overlap of the map art above it" — grom
|
||||
|
||||
Two different things, one of them not a defect.
|
||||
|
||||
*The overlap.* A city's own sheet crossfades in over the zone's painting of the same ground across
|
||||
`CITY_FADE`, a share of the view. Halfway through that band the city is a half-opaque sheet over
|
||||
the zone's art, and a 1.12 sheet is parchment with no transparent surround, so it reads as a dark
|
||||
rectangle sitting on the map. Measured over Ironforge: a grey box at zoom 30, the finished plan at
|
||||
70. The band is now `{ 0.26, 0.36 }` rather than `{ 0.2, 0.4 }` and the three cities with no
|
||||
traced outline (Stormwind, Ironforge, Darnassus) are drawn through `FEATHER_WIDE` rather than
|
||||
`FEATHER` — 16% of the sheet rather than 9%. Neither removes the blend; they shorten the zoom
|
||||
range that shows it and soften the box, which is the half a player can see.
|
||||
|
||||
*The minimap.* Over a surface city `Close.lua` capped the minimap at `CITY_MINI` (0.5, and 0.15
|
||||
over Orgrimmar and Thunder Bluff), on the reasoning that a city's own plan should lead. That is a
|
||||
taste, so it is now a setting — "Minimap detail in cities", default 100%, dialling down to
|
||||
upstream's own caps.
|
||||
|
||||
Ironforge and Undercity are a different case and the answer there is no. `Close.lua` keeps the
|
||||
minimap off them because the minimap at those coordinates is the *surface* — the mountain, the
|
||||
ruins — not the city. That is true of this install and not only of retail: `textures\Minimap` on
|
||||
the player's own chain holds `Azeroth` and `Kalimdor` and a folder per dungeon, and no folder for
|
||||
any city (measured across 112,411 files). There is no interior minimap to show, so the two
|
||||
underground cities keep their plan whatever the new setting says.
|
||||
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Soften OpenMapClassic's per-zone land masks, so neighbouring sheets blend instead of butting.
|
||||
|
||||
`Media/Textures/MapZoneDilate/<uiMapID>.tga` is one 256x256 alpha mask per Classic outdoor zone,
|
||||
Voronoi-dilated so that the forty of them tile each continent with no gap. They are **binary**:
|
||||
measured over four of them, 19k-48k pixels at alpha 0, 12k-51k at alpha 255, and under 250 pixels
|
||||
anywhere in between -- the anti-aliasing of a traced outline and nothing more.
|
||||
|
||||
A binary mask is why grom's map "looks super boxy and weird, edges of maps are the same". Every
|
||||
zone's sheet, and the shade over an unexplored one, ends on a crisp line against its neighbour,
|
||||
and the two sheets are different paintings of the same land at different brightnesses. Nothing
|
||||
blends because nothing was ever asked to.
|
||||
|
||||
So: keep the mask fully opaque everywhere it already was, and let it fall off *outwards* over a
|
||||
few pixels. `DILATE` pushes the coverage out so the ramp starts at the old boundary rather than
|
||||
eating into the zone, and `BLUR` is the ramp. Where two zones meet, the one drawn second fades in
|
||||
over the one drawn first across roughly `BLUR` mask pixels -- about a dozen screen pixels at the
|
||||
zoom where a continent fills the window, which is the zoom the complaint came from.
|
||||
|
||||
Outward, not symmetric, on purpose: a symmetric blur would take both neighbours to 50% at their
|
||||
shared border and let the sea-grey underlay show through as a bright seam -- trading a hard edge
|
||||
for a worse one.
|
||||
|
||||
Reads `MapZoneDilate`, writes `MapZoneSoft`; the source folder is left alone so this is
|
||||
re-runnable and the two can be compared. 256x256 uncompressed 32-bit TGA either way: RGB black,
|
||||
alpha the coverage, as the shipped masks are.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
# Mask pixels. A zone mask is 256 px across its own rect however far the map is zoomed, so these
|
||||
# are a fraction of a zone, not a count of screen pixels: 3 + 4 is a ramp a little under 3% of a
|
||||
# zone's width.
|
||||
DILATE = 2
|
||||
BLUR = 3.0
|
||||
# How far in from the sheet's own border the softened coverage is held at zero, as a fraction of
|
||||
# the sheet. A 1.12 zone sheet paints its own torn parchment frame and its own sea right to the
|
||||
# edge of its rect, and the shipped masks are cut so that none of that shows. A blur alone pulls
|
||||
# it back in -- measured: softening with no inset put a hard straight line of Wetlands' parchment
|
||||
# border across the view where the binary mask had a soft coast. So the ramp is multiplied by a
|
||||
# rect ramp that reaches zero at the border, and the neighbour's own (dilated) mask covers the
|
||||
# land the inset gives up.
|
||||
RECT_MARGIN = 0.05
|
||||
|
||||
|
||||
def rect_ramp(n: int, margin: float) -> np.ndarray:
|
||||
"""1 in the middle, 0 at both ends, smoothstep across `margin` of the span."""
|
||||
if margin <= 0:
|
||||
return np.ones(n)
|
||||
x = (np.arange(n) + 0.5) / n
|
||||
t = np.clip(np.minimum(x, 1.0 - x) / margin, 0.0, 1.0)
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
|
||||
|
||||
def soften(src: Path, dilate: int, blur: float, margin: float) -> Image.Image:
|
||||
im = Image.open(src).convert("RGBA")
|
||||
a = im.split()[-1]
|
||||
# MaxFilter is a square dilation; its size must be odd and is the full width, not the radius.
|
||||
if dilate > 0:
|
||||
a = a.filter(ImageFilter.MaxFilter(2 * dilate + 1))
|
||||
if blur > 0:
|
||||
a = a.filter(ImageFilter.GaussianBlur(blur))
|
||||
w, h = im.size
|
||||
cover = np.asarray(a, dtype=np.float32) / 255.0
|
||||
cover = cover * np.outer(rect_ramp(h, margin), rect_ramp(w, margin))
|
||||
out = np.zeros((h, w, 4), dtype=np.uint8)
|
||||
out[:, :, 3] = np.round(cover * 255.0).astype(np.uint8)
|
||||
return Image.fromarray(out, "RGBA")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("textures", type=Path, help="OpenMapClassic/Media/Textures")
|
||||
ap.add_argument("--from-dir", default="MapZoneDilate")
|
||||
ap.add_argument("--to-dir", default="MapZoneSoft")
|
||||
ap.add_argument("--dilate", type=int, default=DILATE)
|
||||
ap.add_argument("--blur", type=float, default=BLUR)
|
||||
ap.add_argument("--rect-margin", type=float, default=RECT_MARGIN)
|
||||
args = ap.parse_args()
|
||||
|
||||
src = args.textures / args.from_dir
|
||||
dst = args.textures / args.to_dir
|
||||
if not src.is_dir():
|
||||
print(f"not a directory: {src}", file=sys.stderr)
|
||||
return 2
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
n = 0
|
||||
for path in sorted(src.glob("*.tga")):
|
||||
soften(path, args.dilate, args.blur, args.rect_margin).save(dst / path.name)
|
||||
n += 1
|
||||
print(f"wrote {n} mask(s) to {dst} (dilate {args.dilate}, blur {args.blur}, rect margin {args.rect_margin})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user