ui: texture masks, the later-era texture dials, and the pools a canvas map needs
The mask pair is the one that matters and nothing can stand in for it. 1.12 has no texture masking at all — its single masked thing is the minimap disc, cut engine-side — and no idiom reaches the same end, because the point is that a rectangular texture draws as a non-rectangular shape. A seamless world map is that operation and nothing else: a zone's art is a rectangle of parchment and the map wants it cut to the zone's coastline. It was affordable because the render pass already had the slot. `UiQuad::mask` is a screen-anchored alpha mask built for the minimap's fixed circle with tiles panning under it, which is exactly `AddMaskTexture`'s semantics — the mask is another region with its own anchors, and the subject keeps the fragments its alpha covers at that place. So this is plumbing, not a new material. One mask per quad, said rather than hidden: widening it is a second texture binding and a second uniform on every UI quad in the client. The list is kept whole, because the order decides the winner — first added, which for a stitched map is the land mask. Two traps are pinned by tests: a mask region is skipped at extract rather than marked hidden, since its rect is what it covers; and a mask whose art is missing drops the mask, not the quad, because zero coverage would blank the art it was meant to shape. `Texture:SetRotation` was nearly free — the renderer has carried the angle since the minimap arrow, so this is the Lua setter for it. `SetDesaturation` is the fractional form of 1.12's own flag, rounded onto the one flag this renderer has, with the getter answering the caller's own number. The tile pair is applied: the host computes the repeat count once the art has loaded. `SetPropagateKeyboardInput` is stored, not applied: 1.12 already always propagates, so there is no swallow mode for false to select. `EditBox:HasFocus` publishes a fact this client already tracks and never exposed. Corpus: 85 ok / 1 fail, roster and digest unchanged. The 12 data-half reds are byte-identical to HEAD's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -1319,6 +1319,8 @@ fn convert_entry(
|
||||
portrait_unit,
|
||||
rotation,
|
||||
desaturated,
|
||||
mask,
|
||||
tile,
|
||||
} => {
|
||||
// A live unit portrait (`SetPortraitTexture(region, unit)`): the model bake, or the
|
||||
// reference's 2D TemporaryPortrait while the model streams; no entry yet draws nothing.
|
||||
@@ -1382,8 +1384,15 @@ fn convert_entry(
|
||||
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
|
||||
None => UvRect::FULL,
|
||||
};
|
||||
// A slice past the texture tiles (`BonusActionBarFrame.lua:173`), on that axis alone.
|
||||
let wrap = tiling_axes(&uv);
|
||||
// A slice past the texture tiles (`BonusActionBarFrame.lua:173`), on that axis alone;
|
||||
// `SetHorizTile`/`SetVertTile` ask for the same wrap without naming a count, so they
|
||||
// join it here and the count is computed below, once the art's texel size is known.
|
||||
let explicit_uv = tex_coords.is_some();
|
||||
let mut uv = uv;
|
||||
let wrap = {
|
||||
let (h, v) = tiling_axes(&uv);
|
||||
(h || (tile.0 && !explicit_uv), v || (tile.1 && !explicit_uv))
|
||||
};
|
||||
let handle = match (path.as_deref(), assets.as_mut()) {
|
||||
(Some(p), Some(a)) => {
|
||||
let resolved = if p == benilla_ui::script::nameplate::BORDER_TEXTURE {
|
||||
@@ -1426,6 +1435,36 @@ fn convert_entry(
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// `SetHorizTile`/`SetVertTile`'s repeat count: the region's own span over the art's,
|
||||
// which is what "tile at its native size" means. Taken here because the texel size is
|
||||
// only known once the art has loaded. A missing image, a zero-texel axis or an
|
||||
// explicit `SetTexCoord` leaves the mapping alone.
|
||||
if (tile.0 || tile.1) && !explicit_uv {
|
||||
if let Some(sz) = handle
|
||||
.as_ref()
|
||||
.and_then(|h| images.get(h))
|
||||
.map(|img| img.texture_descriptor.size)
|
||||
{
|
||||
let repeats = |span: f32, texels: u32| {
|
||||
if texels > 0 {
|
||||
span / texels as f32
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
};
|
||||
let u = if tile.0 {
|
||||
repeats(rect.width(), sz.width)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let v = if tile.1 {
|
||||
repeats(rect.height(), sz.height)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
uv = UvRect::from_tex_coords([0.0, u, 0.0, v]);
|
||||
}
|
||||
}
|
||||
// A crop's magnified edge would filter in the neighbouring atlas cell.
|
||||
let uv_clamp = handle
|
||||
.as_ref()
|
||||
@@ -1447,6 +1486,18 @@ fn convert_entry(
|
||||
// `SetRotation` is counter-clockwise-positive, `UiQuad::rotation` clockwise.
|
||||
rotation: -rotation,
|
||||
desaturated,
|
||||
// `AddMaskTexture`: the mask's own art and rect, converted to screen space the
|
||||
// same way as `rect`. A mask whose art the archives lack drops the mask, not the
|
||||
// quad — a missing cut-out shows the whole rectangle, which is the same thing the
|
||||
// caller sees when it never had a mask to add.
|
||||
mask: mask.and_then(|m| {
|
||||
let art = assets.as_mut()?.sprite_texture(&m.path, images)?;
|
||||
let r = m.rect;
|
||||
Some(crate::ui_pass::UiQuadMask {
|
||||
texture: art,
|
||||
rect: Rect::new(r.left * s, h - r.top * s, r.right * s, h - r.bottom * s),
|
||||
})
|
||||
}),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
@@ -1831,6 +1882,83 @@ mod clip_plumb_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A mask whose art the archives lack drops the **mask**, not the quad.
|
||||
///
|
||||
/// Worth pinning because the other reading is tempting and wrong: a mask that cannot load is
|
||||
/// zero coverage, and honouring that would erase the art it was meant to shape — a stitched
|
||||
/// map would go blank wherever a generated mask was missing, which reads as the map's own art
|
||||
/// failing. Showing the whole rectangle is what the caller sees when it never had a mask.
|
||||
///
|
||||
/// Runs with no `WorldAssets` at all, which is the same path a missing file takes: the quad is
|
||||
/// a pathless solid so it still draws, and the mask asks for art and gets none.
|
||||
#[test]
|
||||
fn a_mask_whose_art_is_missing_drops_the_mask_and_keeps_the_quad() {
|
||||
let script = UiScript::new().unwrap();
|
||||
script
|
||||
.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "MaskPlumb")
|
||||
f:SetPoint("TOPLEFT", 0, 0)
|
||||
f:SetWidth(50); f:SetHeight(50)
|
||||
local m = f:CreateTexture(nil, "ARTWORK")
|
||||
m:SetTexture(0, 0, 1)
|
||||
m:SetAllPoints()
|
||||
local cut = f:CreateMaskTexture(nil, "ARTWORK")
|
||||
cut:SetTexture("Interface\\AddOns\\NoSuch\\mask")
|
||||
cut:SetAllPoints()
|
||||
m:AddMaskTexture(cut)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut app = App::new();
|
||||
app.insert_non_send_resource(script);
|
||||
app.init_resource::<UiQuads>();
|
||||
app.init_resource::<crate::ui_script::UiPassState>();
|
||||
app.init_resource::<Assets<Image>>();
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::portrait::PaneBakeSizes>();
|
||||
app.init_resource::<crate::portrait::PanePositions>();
|
||||
app.init_resource::<crate::portrait::UnitPaneBooths>();
|
||||
app.init_resource::<crate::ui_models::UiModelTiles>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<crate::ui_script::UiFrameCost>();
|
||||
app.init_resource::<crate::ui_script::UiCostWanted>();
|
||||
app.init_resource::<Time>();
|
||||
app.init_resource::<Time<Real>>();
|
||||
app.init_resource::<crate::ui_script::UiClock>();
|
||||
app.init_resource::<crate::ui_script::UiScaleCvar>();
|
||||
app.world_mut().spawn((
|
||||
Window {
|
||||
resolution: UVec2::new(1024, 768).into(),
|
||||
..default()
|
||||
},
|
||||
PrimaryWindow,
|
||||
));
|
||||
app.add_systems(Update, (tick_script, paint_script).chain());
|
||||
app.update();
|
||||
|
||||
let quads = &app.world().resource::<UiQuads>().quads;
|
||||
let marker: &UiQuad = quads
|
||||
.iter()
|
||||
.find(|q| q.color[2] == 1.0 && q.color[0] == 0.0)
|
||||
.expect("the solid still draws");
|
||||
assert!(
|
||||
marker.mask.is_none(),
|
||||
"a mask with no art is no mask, not an empty one"
|
||||
);
|
||||
// And the mask region itself emitted nothing: one quad for the frame slot, one for the
|
||||
// solid, and none for the cut-out.
|
||||
assert_eq!(
|
||||
quads
|
||||
.iter()
|
||||
.filter(|q| q.color[2] == 1.0 && q.color[0] == 0.0)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclipped_texture_quad_carries_no_clip() {
|
||||
let script = UiScript::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user