diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 3e09cf1a..adb75b60 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -1249,21 +1249,36 @@ fn convert_entry( // 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. + // **`G48` model units per `SetPosition` unit**, `G48 = 1/√(a²+1)` for the screen + // aspect — the client's own `[0x832a48]`, the same constant `Model::layout_unit` + // inverts for the orthographic leg. 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, so spending the translate's share alone would + // move a `scale="4"` pane's model four times its own size. + // + // The constant is **fitted, not traced**, and the fit is Tmog's own shipped table: + // `Positions[slot][race][sex]` frames a browse card on the slot being browsed, so + // each row is a displacement that must land the model's head in frame against that + // race's authored camera. Across gnome (3.8, standoff ≈2.2), dwarf (6.3), human (8.8, + // standoff 4.02) and night elf (11.8, the tallest) the ratio sits at 0.45–0.55, and + // `G48` is 0.50 at a 16:9-ish panel. Shipping 1.0 put every card's model behind its + // own camera and the grid drew empty, which is what pinned the order of magnitude. + // A capture of the reference's root would settle it properly; until then this is a + // measurement of an addon, not of the client. + let diag = if h > 0.0 { + ((w / h) * (w / h) + 1.0).sqrt() + } else { + 5.0 / 3.0 + }; + let g48 = 1.0 / diag; booths.positions.0.insert( slot.to_string(), - benilla_assets::coords::wow_to_bevy([position.0, position.1, position.2]), + benilla_assets::coords::wow_to_bevy([ + position.0 * g48, + position.1 * g48, + position.2 * g48, + ]), ); // The aspect is published before the readiness check: the bake waits on the publish. if rect.height() > 0.0 { @@ -2287,19 +2302,28 @@ mod extract_gate_tests { app.update(); let at = |app: &App, slot: &str| app.world().resource::().0.get(slot).copied(); - assert_eq!( + // `G48` is irrational at most aspects, so compare to a tolerance, not a bit pattern. + let near = |got: Option, want: Vec3, what: &str| { + let got = got.unwrap_or_else(|| panic!("{what}: nothing published")); + assert!( + got.abs_diff_eq(want, 1e-5), + "{what}: got {got:?}, want {want:?}" + ); + }; + // The window is 1024x768 in this harness, so `G48 = 1/√((4/3)²+1) = 0.6`. + near( 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" + Vec3::new(-0.3, -0.15, -1.2), + "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!( + near( at(&app, "paperdoll"), - Some(Vec3::new(0.0, 0.0, -1.0)), - "a named pane publishes under its window's booth" + Vec3::new(0.0, 0.0, -0.6), + "a named pane publishes under its window's booth", ); - // One model unit per `SetPosition` unit: the pane's own layout scale does **not** + // `G48` model units 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 @@ -2309,10 +2333,10 @@ mod extract_gate_tests { .run("TmogFramePlayerModel:SetScale(0.5)") .unwrap(); app.update(); - assert_eq!( + near( 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" + Vec3::new(-0.3, -0.15, -1.2), + "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.