portrait: a unit pane honours Model:SetPosition

`SetPosition` is the widget's model-root translate and the extract spent it only on the file-pane
path. The booth path dropped the value and posed `R(yaw) · S(s)` alone, so Tmog's wheel zoom, its
right-drag pan and the per-slot framing of every browse card moved nothing at all.

It shows on a `<PlayerModel>` and not on a plain `<Model>` for one reason: this widget's camera is
frozen (`0x7acf10`) where `<Model>`'s perspective leg republishes its authored camera through the
very root being moved (`0x718960`), which cancels. `capture::probe_model_camera`'s `position` leg
pins that half; this is the other.

The extract publishes the translate in bevy's axes into a new `PanePositions`, keyed by slot so the
four stock window panes and the stock dressing room are served by the same channel rather than a
second path; a slot that is not drawn keeps the translation it has rather than snapping back to the
origin. The booths apply it as one `RootPose { yaw, scale, pos }`, latched together — keyed on
`(yaw, scale)` alone a position-only change compares equal and writes nothing, so a wheel would
zoom once and then stop. `WOW_BOOTH_LOG` gains `pane pose slot=… yaw=… scale=… pos=[…]`, one line
per change.

One model unit per `SetPosition` unit, and deliberately not the reference's `pos · layoutScale`:
that factor belongs to the group the reference's root scales with it, and this booth's root carries
only the renormalizer (`framing::pane_root_scale_at` drops `modelScale` and `layoutScale` both), so
taking the translate's share alone would move a `scale="4"` pane's model four times its own size.
The magnitude is the one part nothing in the tree pins — the only two calibrated `SetPosition`
sites are the ortho leg's pixel ladder and the `<Model>` leg where it cancels — and the extract's
arm says what to revisit if a wheel notch turns out to zoom too hard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-16 18:45:25 -06:00
co-authored by Claude Opus 5
parent c1ef619295
commit 7fef604615
5 changed files with 247 additions and 34 deletions
+115 -2
View File
@@ -439,14 +439,14 @@ pub(super) fn tick_script(
// `FrameHandle` the next VM will reuse for something else.
booths.panes.0.clear();
booths.bake_sizes.0.clear();
booths.bake_sizes.0.clear();
booths.positions.0.clear();
booths.unit_panes.release_all();
return;
};
let Ok(window) = window.single() else {
booths.panes.0.clear();
booths.bake_sizes.0.clear();
booths.bake_sizes.0.clear();
booths.positions.0.clear();
booths.unit_panes.release_all();
return;
};
@@ -983,6 +983,7 @@ pub(super) fn paint_script(
// entry simply did not change ([`crate::portrait::UnitPaneBooths::sweep_begin`]).
booths.panes.0.clear();
booths.bake_sizes.0.clear();
booths.positions.0.clear();
booths.unit_panes.sweep_begin();
let mut spans: Vec<u32> = Vec::with_capacity(prev.extracted.len());
for eq in extracted {
@@ -1238,6 +1239,32 @@ fn convert_entry(
let Some(slot) = booths.model_pane_slot(name.as_deref(), handle, unit, facing) else {
return;
};
// `SetPosition`, published before the readiness check as the aspect is below: the
// booth root's translate, in bevy's axes. The order is the widget's — `(depth,
// screen-right, up)` in model space, the camera standing off on the model's `+x` —
// and it is converted here so no booth has to know it, exactly as the file-pane leg
// above hands `root_pos` to `wow_to_bevy`.
//
// Unlike the file pane's, this one *moves* the model: a `<PlayerModel>`'s camera is
// frozen (`0x7acf10`) rather than republished through the root, so the pane zooms and
// pans — Tmog's wheel and right-drag.
//
// **One model unit per `SetPosition` unit, and deliberately not the reference's
// `pos · layoutScale`.** That factor belongs to the group the reference's root scales
// with it (`T(pos·layout) · R(facing) · S(G48·(5/3)·modelScale·layout)`), and this
// booth's root carries only the renormalizer — `framing::pane_root_scale_at` drops
// both `modelScale` and `layoutScale` — so taking the translate's share alone would
// move a `scale="4"` pane's model four times its own size. Tmog's panes are
// `scale="4"` cards and a `scale="2"` preview (`Tmog.xml:148,579`), so that is not a
// hypothetical. The magnitude is the one part of this nothing in the tree pins: the
// only two calibrated `SetPosition` sites are the ortho leg's pixel ladder and the
// `<Model>` perspective leg where it provably cancels. If a wheel notch in Tmog turns
// out to zoom far too hard, or a browse card frames past its model, the factor is
// what to revisit — together with the root's missing `S` terms, not separately.
booths.positions.0.insert(
slot.to_string(),
benilla_assets::coords::wow_to_bevy([position.0, position.1, position.2]),
);
// The aspect is published before the readiness check: the bake waits on the publish.
if rect.height() > 0.0 {
booths
@@ -1733,6 +1760,7 @@ mod clip_plumb_tests {
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>();
@@ -1821,6 +1849,7 @@ mod clip_plumb_tests {
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>();
@@ -2017,6 +2046,7 @@ mod extract_gate_tests {
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>();
@@ -2215,6 +2245,89 @@ mod extract_gate_tests {
);
}
/// `Model:SetPosition` is the pane's model-root translate (`T(pos · layoutScale)`,
/// `0x76d1a0`), and on a `<PlayerModel>` it *moves the model*: the widget's camera is frozen
/// (`0x7acf10`) rather than republished through the root, so unlike a plain `<Model>`'s
/// perspective leg — where it provably cancels, which
/// [`crate::capture::probe_model_camera`]'s `position` leg pins — the pane zooms and pans.
/// The booth path used to drop the call on the floor, which cost Tmog its wheel zoom, its
/// right-drag pan and the per-slot framing of every browse card.
///
/// The axes are the widget's — `SetPosition(depth, right, up)` in model units, the camera
/// standing off on the model's `+x` — so bevy's translate is `(-right, up, -depth)` and a
/// wheel-up (`+depth`) walks the model toward the camera.
#[test]
fn a_unit_pane_publishes_its_set_position_as_its_booth_roots_translate() {
use crate::portrait::{PanePositions, UNIT_PANE_SLOTS};
let mut app = app_from_script(
r#"
local doll = CreateFrame("PlayerModel", "CharacterModelFrame")
doll:SetPoint("TOPLEFT", 0, 0)
doll:SetWidth(233); doll:SetHeight(224)
doll:SetUnit("player")
doll:SetPosition(1, 0, 0)
local p = CreateFrame("PlayerModel", "TmogFramePlayerModel")
p:SetPoint("TOPLEFT", 300, -40)
p:SetWidth(300); p:SetHeight(400)
p:SetUnit("player")
p:SetPosition(2, 0.5, -0.25)
"#,
);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.set_unit(
"player",
Some(benilla_ui::script::UnitState {
guid: 0xdead_beef,
exists: true,
..default()
}),
);
app.update();
let at =
|app: &App, slot: &str| app.world().resource::<PanePositions>().0.get(slot).copied();
assert_eq!(
at(&app, UNIT_PANE_SLOTS[0]),
Some(Vec3::new(-0.5, -0.25, -2.0)),
"an addon's own pane publishes under the pooled slot it claimed"
);
// A stock name takes its window's booth, and its pane's position with it: the channel is
// keyed by slot precisely so the four window panes are not a second path.
assert_eq!(
at(&app, "paperdoll"),
Some(Vec3::new(0.0, 0.0, -1.0)),
"a named pane publishes under its window's booth"
);
// One model unit per `SetPosition` unit: the pane's own layout scale does **not**
// multiply it, because this booth's root does not scale by it either
// (`framing::pane_root_scale_at`). Taking the translate's share of the reference's
// `T(pos·layout) · R · S(…·layout)` group while dropping the scale's would move a
// `scale="4"` Tmog card's model four times its own size.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("TmogFramePlayerModel:SetScale(0.5)")
.unwrap();
app.update();
assert_eq!(
at(&app, UNIT_PANE_SLOTS[0]),
Some(Vec3::new(-0.5, -0.25, -2.0)),
"a scaled pane moves its model by the same model units"
);
// A pane that stops drawing publishes nothing at all, so the booth keeps where it was
// put ([`crate::portrait::pane_position`]) instead of reading an absent entry as 0.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("TmogFramePlayerModel:Hide()")
.unwrap();
app.update();
assert_eq!(
at(&app, UNIT_PANE_SLOTS[0]),
None,
"an undrawn pane publishes nothing"
);
}
/// The cap is only acceptable if a booth comes back: without a release the first eight panes
/// to draw would own the pool for the session, and pfUI's ninth unit frame would be
/// permanently empty. A pane hiding frees its slot, and the pane that was refused takes it —
@@ -463,6 +463,7 @@ mod caret_tests {
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>();
@@ -481,6 +481,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
app.init_resource::<crate::portrait::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::<UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();