diff --git a/crates/benilla-app/src/portrait/dressup.rs b/crates/benilla-app/src/portrait/dressup.rs index 7a9bfbac..ea244c7c 100644 --- a/crates/benilla-app/src/portrait/dressup.rs +++ b/crates/benilla-app/src/portrait/dressup.rs @@ -175,6 +175,7 @@ pub(super) fn sync_dressup_booth( bake: Res, mut booths: ResMut, panes: Res, + positions: Res, mut booth_light: ResMut, mut materials: ResMut>, creatures: Option>, @@ -182,7 +183,7 @@ pub(super) fn sync_dressup_booth( mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>, mut palettes: ResMut, mut env_cache: Local>, - mut last: Local>, + mut last: Local>, // Whether a bake stands on the stage; `Booth::baked` keys the mirrored booths, not this one. mut staged_flag: Local, ) { @@ -194,9 +195,17 @@ pub(super) fn sync_dressup_booth( }; // The pane's aspect, latched while on screen: the dressing room's is 316×351, not square. let aspect = panes.0.get(DRESSUP_SLOT).copied().unwrap_or(booth.aspect); - let (last_rev, last_yaw) = last.unwrap_or((u64::MAX, f32::NAN)); + // `Model:SetPosition` on the stock room's own pane, latched with the yaw because they are + // one root transform; absent, the last applied one, as [`super::pane_position`] keeps it. + let pos = positions + .0 + .get(DRESSUP_SLOT) + .copied() + .or(last.map(|l| l.2)) + .unwrap_or(Vec3::ZERO); + let (last_rev, last_yaw, last_pos) = last.unwrap_or((u64::MAX, f32::NAN, Vec3::NAN)); let rebake = last_rev != bake.revision || booth.aspect != aspect; - if !rebake && last_yaw == preview.yaw { + if !rebake && last_yaw == preview.yaw && last_pos == pos { return; } booth.aspect = aspect; @@ -217,7 +226,7 @@ pub(super) fn sync_dressup_booth( booth.parked = false; *staged_flag = false; } - *last = Some((bake.revision, preview.yaw)); + *last = Some((bake.revision, preview.yaw, pos)); return; } // Rig and framing come from the display cache the assembly gated on; if not ready, @@ -266,9 +275,9 @@ pub(super) fn sync_dressup_booth( } commands .entity(booth.root) - .insert(Transform::from_rotation(Quat::from_rotation_y(preview.yaw))); + .insert(Transform::from_translation(pos).with_rotation(Quat::from_rotation_y(preview.yaw))); booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); - *last = Some((bake.revision, preview.yaw)); + *last = Some((bake.revision, preview.yaw, pos)); } /// What [`stage_assembled_bake`] put on the stage, for a caller's log line. pub(super) struct Staged { diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 792d7e55..8ba1f154 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -761,6 +761,26 @@ pub(crate) fn pane_bake_size(longest_px: u32) -> u32 { #[derive(Resource, Default)] pub(crate) struct PaneBakeSizes(pub(crate) HashMap); +/// Each drawn pane's `Model:SetPosition`, as the translation its booth's model root carries: one +/// model unit per `SetPosition` unit, in bevy's axes, converted at the publish so no booth has to +/// know the widget's coordinate order. The extract's `ModelPane` arm says why the reference's +/// `· layoutScale` is deliberately left off here. +/// +/// A channel of its own, keyed by slot, for the reason [`PaneBakeSizes`] gives and one more: it +/// serves the four stock window panes as well as the pool, and only the extract can see a pane's +/// effective scale. Written where the aspect is and cleared with it; an absent entry means "not +/// drawn this frame", which keeps the translation already applied rather than snapping the model +/// back to the origin ([`pane_position`]). +/// +/// It shows at all because ``'s camera is frozen (`0x7acf10`). A plain ``'s +/// perspective leg publishes its authored camera through this very root (`0x718960`), so there +/// the translate cancels and moves nothing — which is what +/// [`crate::capture::probe_model_camera`]'s `position` leg pins. On a `` it is how +/// Tmog zooms its preview with the wheel, pans it with a right-drag, and frames each browse card +/// on the slot being browsed (`Tmog.lua`'s `Positions[slot][race][sex]`). +#[derive(Resource, Default)] +pub(crate) struct PanePositions(pub(crate) HashMap); + /// The dynamic unit-pane booths' slot names, one per pool slot. Fixed strings, not minted per /// pane, so the whole String-keyed booth machinery ([`Booths`], [`BoothCam`], [`PortraitImages`], /// [`BoothPanes`]) carries them with no second rendering path: a slot is a booth like any other, @@ -1065,6 +1085,8 @@ pub(crate) struct BoothBridge<'w> { pub(crate) unit_panes: ResMut<'w, UnitPaneBooths>, /// How big a bake each drawn pooled pane wants ([`PaneBakeSizes`]). pub(crate) bake_sizes: ResMut<'w, PaneBakeSizes>, + /// Where each drawn pane puts its model ([`PanePositions`]). + pub(crate) positions: ResMut<'w, PanePositions>, } impl BoothBridge<'_> { @@ -1242,6 +1264,8 @@ fn pool_pace_phases(drawn: [bool; UNIT_PANE_SLOTS.len()]) -> ([f64; UNIT_PANE_SL #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct BoothFraming<'w> { pub(crate) panes: Res<'w, BoothPanes>, + /// `Model:SetPosition`, the model root's translate ([`PanePositions`]). + pub(crate) positions: Res<'w, PanePositions>, pub(crate) gx: Res<'w, GxAspect>, } @@ -1285,6 +1309,7 @@ impl Plugin for PortraitPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -2224,9 +2249,9 @@ fn sync_body_booths( mut palettes: ResMut, mut wow_mats: ResMut>, mut env_cache: Local>, - // The applied `(yaw, root scale)` per slot, which a resize re-latches without a re-bake; - // `None` is a booth with nothing posed in it. - mut last_pose: Local>>, + // The applied root pose per slot ([`RootPose`]), which a resize or a `SetPosition` + // re-latches without a re-bake; `None` is a booth with nothing posed in it. + mut last_pose: Local>>, mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>, framing_in: BoothFraming, anim_data: Option>, @@ -2273,6 +2298,7 @@ fn sync_body_booths( &mut cams, anim_data.as_deref(), &framing_in.panes, + &framing_in.positions, ); } // The dynamic pool: an addon's own pane, through the camera it asked for, animating at the @@ -2322,6 +2348,7 @@ fn sync_body_booths( &mut cams, anim_data.as_deref(), &framing_in.panes, + &framing_in.positions, last_pose.entry(slot).or_default(), ) { log_pane_source(slot, claim.map(|c| c.pane), why, &mut pane_source); @@ -2352,6 +2379,7 @@ fn sync_body_booths( &mut cams, anim_data.as_deref(), &framing_in.panes, + &framing_in.positions, ); } } @@ -2385,6 +2413,69 @@ fn needs_restage( staged != Some((pane, revision)) || had_aspect != aspect } +/// The pose a pane's model root carries: the widget's own `T(pos) · R(facing) · S(s)` +/// (`0x76d1a0`), latched per slot so a frame that moves none of the three writes no transform and +/// wakes no camera. +/// +/// One type for the three because they are one transform, and because the latch has to carry all +/// of it: keyed on `(yaw, scale)` alone, a frame that only moved the model would compare equal +/// and write nothing, so Tmog's wheel would zoom once and then stop. +#[derive(Clone, Copy, PartialEq, Debug)] +struct RootPose { + /// `SetFacing`/`SetRotation`'s radians, about the model's up axis. + yaw: f32, + /// [`framing::pane_root_scale_at`], the renormalizer — not `SetModelScale`. + scale: f32, + /// [`PanePositions`]: `SetPosition · layoutScale`, bevy's axes. + pos: Vec3, +} + +/// The translation `slot`'s root should carry: this frame's publish, else the one it already has. +/// +/// A pane that is not drawn publishes nothing, and that must not read as "back to the origin" — +/// it would re-pose and wake the camera on the frame a window closes, and again on the frame it +/// opens. The aspect latches into [`Booth::aspect`] for the same reason; the applied pose is this +/// one's latch, so it needs no field. +fn pane_position(positions: &PanePositions, slot: &str, last_pose: Option) -> Vec3 { + positions + .0 + .get(slot) + .copied() + .or(last_pose.map(|p| p.pos)) + .unwrap_or(Vec3::ZERO) +} + +/// Put `pose` on the booth's model root, unless it already holds it; `force` writes regardless, +/// for a bake whose root was just rebuilt from nothing. +/// +/// bevy composes a `Transform` as `T·R·S`, which is the reference's product order: the translate +/// is in model units and is **not** multiplied by the scale. +fn pose_booth_root( + commands: &mut Commands, + booth: &mut Booth, + slot: &str, + pose: RootPose, + last_pose: &mut Option, + force: bool, +) { + if !force && *last_pose == Some(pose) { + return; + } + commands.entity(booth.root).insert( + Transform::from_translation(pose.pos) + .with_rotation(Quat::from_rotation_y(pose.yaw)) + .with_scale(Vec3::splat(pose.scale)), + ); + *last_pose = Some(pose); + booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); + if booth_log() { + eprintln!( + "[booth] pane pose slot={slot} yaw={:.3} scale={:.3} pos=[{:.3}, {:.3}, {:.3}]", + pose.yaw, pose.scale, pose.pos.x, pose.pos.y, pose.pos.z + ); + } +} + /// Stage a pooled pane's own assembled look into its booth, framed and turned like the mirrored /// path frames and turns one. `true` when the booth now holds this bake — including when it /// already did and there was nothing to do. @@ -2408,7 +2499,8 @@ fn stage_pane_look( cams: &mut Query<(&BoothCam, &mut Transform, &mut Projection)>, anim_data: Option<&crate::creature_anim::AnimData>, panes: &BoothPanes, - last_pose: &mut Option<(f32, f32)>, + positions: &PanePositions, + last_pose: &mut Option, ) -> bool { let Some(creatures) = creatures else { return false; @@ -2465,17 +2557,14 @@ fn stage_pane_look( slot, &framing::pane_frame_at(&anchors, aspect, camera), ); - // The root's yaw and scale, as [`sync_body_booth`] applies them: latched together, because - // both move on a resize. - let model_scale = framing::pane_root_scale_at(&anchors, display_aspect, camera); - if *last_pose != Some((yaw, model_scale)) { - commands.entity(booth.root).insert( - Transform::from_rotation(Quat::from_rotation_y(yaw)) - .with_scale(Vec3::splat(model_scale)), - ); - *last_pose = Some((yaw, model_scale)); - booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); - } + // The root, as [`sync_body_booth`] applies it: turn, scale and position latched together, + // because they are one transform and the scale moves on a resize. + let pose = RootPose { + yaw, + scale: framing::pane_root_scale_at(&anchors, display_aspect, camera), + pos: pane_position(positions, slot, *last_pose), + }; + pose_booth_root(commands, booth, slot, pose, last_pose, false); true } @@ -2550,7 +2639,7 @@ fn sync_body_booth( yaw: f32, camera: i32, life: PaneLife, - last_pose: &mut Option<(f32, f32)>, + last_pose: &mut Option, display_aspect: f32, commands: &mut Commands, booths: &mut Booths, @@ -2563,6 +2652,7 @@ fn sync_body_booth( cams: &mut Query<(&BoothCam, &mut Transform, &mut Projection)>, anim_data: Option<&crate::creature_anim::AnimData>, panes: &BoothPanes, + positions: &PanePositions, ) { let Some(booth) = booths.0.get_mut(slot) else { return; @@ -2753,8 +2843,9 @@ fn sync_body_booth( ); booth.snap = Some(key); } - // The model root: the widget's `T(pos)·R(facing)·S(s)` with `pos` at the origin. `s` - // (`framing::pane_root_scale`) is latched with the yaw: both move on a resize. + // The model root: the widget's `T(pos)·R(facing)·S(s)`, all three latched together + // ([`RootPose`]) — `s` (`framing::pane_root_scale`) moves on a resize, `pos` whenever the + // pane calls `SetPosition`. // // The turn keys on the yaw alone, as `0x505bb0` picks the shuffle before storing the angle, // and compares the Lua-facing scalar the reference passes to `SetRotation`. @@ -2764,14 +2855,12 @@ fn sync_body_booth( } booth.turn.faced = Some(yaw); } - if parts_changed || *last_pose != Some((yaw, model_scale)) { - commands.entity(booth.root).insert( - Transform::from_rotation(Quat::from_rotation_y(yaw)) - .with_scale(Vec3::splat(model_scale)), - ); - *last_pose = Some((yaw, model_scale)); - booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); - } + let pose = RootPose { + yaw, + scale: model_scale, + pos: pane_position(positions, slot, *last_pose), + }; + pose_booth_root(commands, booth, slot, pose, last_pose, parts_changed); } /// What one frame owes a booth's pipeline settle ([`Booth::pipes_settling`]). diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index cf47d570..3e09cf1a 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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 = 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 ``'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 + // `` 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::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); @@ -1821,6 +1849,7 @@ mod clip_plumb_tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); @@ -2017,6 +2046,7 @@ mod extract_gate_tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); @@ -2215,6 +2245,89 @@ mod extract_gate_tests { ); } + /// `Model:SetPosition` is the pane's model-root translate (`T(pos · layoutScale)`, + /// `0x76d1a0`), and on a `` it *moves the model*: the widget's camera is frozen + /// (`0x7acf10`) rather than republished through the root, so unlike a plain ``'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::() + .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::().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::() + .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::() + .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 — diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index 0fdd7d68..5689ba22 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -463,6 +463,7 @@ mod caret_tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index edf29fdc..fd97e77f 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -481,6 +481,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::();