diff --git a/program/MainWindow.cs b/program/MainWindow.cs index 9a2b13a..767e890 100644 --- a/program/MainWindow.cs +++ b/program/MainWindow.cs @@ -69,11 +69,11 @@ StyleContext.AddProviderForScreen(Gdk.Screen.Default, _scaleCssProvider, 600); // StyleContext.AddProviderForScreen(Gdk.Screen.Default, _Language._languageCssProvider, 600); - // make it full screen - this.Fullscreen(); - // //use the linux computer resolution - // this.SetDefaultSize(1280, 800); - // this.SetPosition(WindowPosition.Center); + // // make it full screen + // this.Fullscreen(); + //use the linux computer resolution + this.SetDefaultSize(1280, 800); + this.SetPosition(WindowPosition.Center); // RootStack.TransitionType = StackTransitionType.None; // RootStack.TransitionType = StackTransitionType.SlideLeftRight; diff --git a/program/MapWindow.cs b/program/MapWindow.cs index b3915ed..805a888 100644 --- a/program/MapWindow.cs +++ b/program/MapWindow.cs @@ -1,26 +1,87 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Text; +using System.Text.RegularExpressions; using Gtk; using UI = Gtk.Builder.ObjectAttribute; public class MapWindow : IDisposable { - // Form2 view offsets — L size - private const float FRONT_OFFSET_X = 25.0f, FRONT_OFFSET_Y = 50.0f; - private const float BACK_OFFSET_X = 20.0f, BACK_OFFSET_Y = 20.0f; - private const float RIGHT_OFFSET_X = 10.0f, RIGHT_OFFSET_Y = -150.0f; - private const float LEFT_OFFSET_X = -50.0f, LEFT_OFFSET_Y = -150.0f; + // ══════════════════════════════════════════════════════════════════ + // TUNABLES — per (jacket size, view). Absolute values, subtracted. + // Paste a CalibrationWindow dump into the tuner to overwrite these, + // then "Dump seed" to bake the result back into SeedDefaults(). + // ══════════════════════════════════════════════════════════════════ - private const float FRONT_SIZE = 1.0f, BACK_SIZE = 0.5f, - RIGHT_SIZE = 1.0f, LEFT_SIZE = 1.0f; + public class Tune + { + public double OffsetX = 25.0; // circle cluster shift (SUBTRACTED) + public double OffsetY = 50.0; + public double SizeScale = 1.0; // circle diameter multiplier + public double Cluster = 1.0; // 1.0 = no clustering + public double MapZoom = 1.0; // see MapFit mode + public double MapX = 0.0; // map nudge, body pixels + public double MapY = 0.0; + public double MapAlpha = 0.7; + public double FitMargin = 40.0; // Form2: panel − 40 (front: −100) + // private MapFit _mapFit = MapFit.FitBody; - // XL deltas - private const float XL_FRONT_OFFSET_X = -28.0f, XL_FRONT_OFFSET_Y = 24.0f; - private const float XL_BACK_OFFSET_X = 8.0f, XL_BACK_OFFSET_Y = -15.0f; - private const float XL_RIGHT_OFFSET_X = -41.0f, XL_RIGHT_OFFSET_Y = 8.0f; - private const float XL_LEFT_OFFSET_X = 54.0f, XL_LEFT_OFFSET_Y = 0.0f; + public Tune Clone() { return (Tune)MemberwiseClone(); } + } + + /// How the MAP is sized over the body. + /// Native546 — Form2 behaviour: 546x546 drawn at native size, centred. + /// MapZoom multiplies that. MapZoom 1.0 == today's output. + /// FitBody — CalibrationWindow behaviour: raw map scaled to fit the + /// body, then x MapZoom. Use this to verify a dump. + public enum MapFit { Native546, FitBody } + + private readonly Dictionary _tunes = new Dictionary(); + // private MapFit _mapFit = MapFit.Native546; + private MapFit _mapFit = MapFit.FitBody; + + private string TuneKey { get { return JacketSize + ":" + _view; } } + + private Tune T + { + get + { + string k = TuneKey; + if (!_tunes.ContainsKey(k)) _tunes[k] = new Tune(); + return _tunes[k]; + } + } + + private void SeedDefaults() + { + // ── L ──────────────────────────────────────────────────────── + Seed("L:front", 9.0, 34.0, 2.75, 1.10, 95, 1.060, -4.0, -14.0, 0.70); + Seed("L:back", -2.0, 10.0, 1.95, 0.90, 40, 1.090, 2.0, -7.0, 0.70); + Seed("L:right", 16.0, -106.0, 1.00, 0.50, 40, 1.010, -32.0, 71.0, 0.70); + Seed("L:left", -14.0, -110.0, 0.80, 0.40, 15, 1.050, 0.0, 5.0, 0.70); + + // ── XL ── (converted from the old XL_* deltas: L_offset − delta) + Seed("XL:front", 7.0, 17.0, 2.30, 1.05, 50, 1.060, -4.0, -14.0, 0.70); + Seed("XL:back", 5.0, 10.0, 1.95, 0.90, 40, 1.090, 2.0, -7.0, 0.70); + Seed("XL:right", 10.0, -108.0, 0.80, 0.40, 40, 1.010, -32.0, 71.0, 0.70); + Seed("XL:left", -4.0, -101.0, 0.80, 0.40, 40, 1.050, 0.0, 5.0, 0.70); + } + + private void Seed(string key, double ox, double oy, double size, double cluster, + double fitMargin, double zoom, double mx, double my, double alpha) + { + _tunes[key] = new Tune + { + OffsetX = ox, OffsetY = oy, SizeScale = size, Cluster = cluster, + FitMargin = fitMargin, MapZoom = zoom, MapX = mx, MapY = my, MapAlpha = alpha + }; + } private const int CircleDiameter = 50; + private const int MapSize = 546; // Form2: new Bitmap(mapImage, 546, 546) + // ── glade widgets ── [UI] private DrawingArea MapArea = null; [UI] private Label MapTitleLabel = null; @@ -39,24 +100,46 @@ private readonly SoundLooper _sound = new SoundLooper(); // ── state ── - private Gdk.Pixbuf _body; // background, native size - private Gdk.Pixbuf _map; // 546x546, black keyed out — also the audio reference + private Gdk.Pixbuf _body; // background, native size + private Gdk.Pixbuf _map; // 546x546, black keyed — audio reference + private Gdk.Pixbuf _mapRaw; // keyed, ORIGINAL size — used by FitBody mode private CaseDefinition _case; private string _view = "front"; - private const int MapSize = 546; // Form2: new Bitmap(mapImage, 546, 546) - private const double MapAlpha = 0.7; // Form2: CreateOverlayImage(..., 0.7f, ...) + private string _bodyPath = "(none)"; + private string _mapPath = "(none)"; public event EventHandler BackRequested; private JacketData _jacket; - private System.Collections.Generic.List _highlighted = new System.Collections.Generic.List(); + private List _highlighted = new List(); private static bool _listed; + // ── tuner state ── + private Window _tuner; + private TextView _tunerOut; + private Label _tunerInfo, _tunerKey; + private readonly Dictionary _spins = new Dictionary(); + private CheckButton _tShowBody, _tShowMap, _tShowCircles, _tGuides; + private ComboBoxText _tFitCombo; + private bool _building; + private bool _keysHooked; + + private bool ShowBody { get { return _tShowBody == null || _tShowBody.Active; } } + private bool ShowMap { get { return _tShowMap == null || _tShowMap.Active; } } + private bool ShowCircles { get { return (_tShowCircles == null || _tShowCircles.Active) + && !HideCircleCheck.Active; } } + private bool ShowGuides { get { return _tGuides != null && _tGuides.Active; } } + + // ══════════════════════════════════════════════════════════════════ + public MapWindow(Builder builder, Language language) { builder.Autoconnect(this); + SeedDefaults(); + LoadCalibFile(false); // calib.txt overrides seeds if present + _jacket = JacketData.Load(); Logger.Write("jacket", "folder: " + _jacket.Folder + " loaded=" + _jacket.Loaded); @@ -73,7 +156,7 @@ BodySkeletonRadio.Toggled += (s, e) => { if (BodySkeletonRadio.Active) ReloadBody(); }; BodyJacketRadio.Toggled += (s, e) => { if (BodyJacketRadio.Active) ReloadBody(); }; - JacketSizeCombo.Changed += (s, e) => { ReloadBody(); MapArea.QueueDraw(); }; + JacketSizeCombo.Changed += (s, e) => { ReloadBody(); SyncSpins(); MapArea.QueueDraw(); }; HookView(ViewFrontRadio, "front"); HookView(ViewBackRadio, "back"); @@ -82,9 +165,34 @@ HideCircleCheck.Toggled += (s, e) => MapArea.QueueDraw(); + // Ctrl+T anywhere, or Ctrl+double-click on the map, opens the tuner. + MapArea.Realized += (s, e) => HookKeys(); + MapArea.AddEvents((int)Gdk.EventMask.ButtonPressMask); + MapArea.ButtonPressEvent += (o, a) => + { + if (a.Event.Type == Gdk.EventType.TwoButtonPress && + (a.Event.State & Gdk.ModifierType.ControlMask) != 0) + ToggleTuner(); + }; + _sound.Log += msg => Logger.Write("sound", msg); } + private void HookKeys() + { + if (_keysHooked) return; + var top = MapArea.Toplevel as Window; + if (top == null) return; + _keysHooked = true; + + top.KeyPressEvent += (o, a) => + { + if ((a.Event.State & Gdk.ModifierType.ControlMask) == 0) return; + if (a.Event.Key == Gdk.Key.t || a.Event.Key == Gdk.Key.T) + ToggleTuner(); + }; + } + private void HookView(RadioButton rb, string view) { if (rb == null) return; @@ -107,6 +215,7 @@ _map = LoadMap(MapPathForView()); MapArea.QueueDraw(); + UpdateTunerInfo(); _sound.Start(SoundWindow.AppFile(def.SoundPath)); Logger.Write("case", "open: " + title + " | map=" + MapPathForView()); @@ -121,14 +230,67 @@ /// Raw MAP pixbuf — Form2's originalMapForAudio. Never composited. public Gdk.Pixbuf AudioReference { get { return _map; } } + /// Body-pixel point -> pixel index inside the MAP that is drawn there. + /// Stays correct in both fit modes, so audio sampling tracks the display. + public bool BodyToMapPixel(double bx, double by, out int px, out int py) + { + px = py = 0; + double mx, my, mw, mh; + if (!MapRect(out mx, out my, out mw, out mh)) return false; + if (mw <= 0 || mh <= 0) return false; + + double u = (bx - mx) / mw, v = (by - my) / mh; + if (u < 0 || u >= 1 || v < 0 || v >= 1) return false; + + Gdk.Pixbuf src = (_mapFit == MapFit.FitBody && _mapRaw != null) ? _mapRaw : _map; + if (src == null) return false; + + px = (int)(u * src.Width); + py = (int)(v * src.Height); + return true; + } + // ── the paint ────────────────────────────────────────────────────── + /// Where the MAP lands, in BODY PIXELS. Single source of truth for both + /// the draw and BodyToMapPixel. + private bool MapRect(out double mx, out double my, out double mw, out double mh) + { + mx = my = mw = mh = 0; + if (_body == null) return false; + + var t = T; + + if (_mapFit == MapFit.FitBody) + { + if (_mapRaw == null) return false; + double ms = Math.Min((double)_body.Width / _mapRaw.Width, + (double)_body.Height / _mapRaw.Height) * t.MapZoom; + mw = _mapRaw.Width * ms; + mh = _mapRaw.Height * ms; + } + else + { + if (_map == null) return false; + mw = _map.Width * t.MapZoom; + mh = _map.Height * t.MapZoom; + } + + mx = (_body.Width - mw) / 2.0 + t.MapX; + my = (_body.Height - mh) / 2.0 + t.MapY; + return true; + } + private void OnMapDrawn(object o, DrawnArgs args) { var cr = args.Cr; int w = MapArea.AllocatedWidth; int h = MapArea.AllocatedHeight; + cr.SetSourceRGB(1, 1, 1); + cr.Rectangle(0, 0, w, h); + cr.Fill(); + if (_body == null) { args.RetVal = true; return; } // ONE scale factor, derived from the body — this is what keeps the @@ -141,22 +303,52 @@ cr.Translate(ox, oy); cr.Scale(s, s); // everything below is in BODY PIXELS - Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0); - cr.Paint(); - - if (_map != null) + if (ShowBody) { - // CreateOverlayImage(position: null) == centre the MAP on the body - double mx = (_body.Width - _map.Width) / 2.0; - double my = (_body.Height - _map.Height) / 2.0; - - Gdk.CairoHelper.SetSourcePixbuf(cr, _map, mx, my); - cr.PaintWithAlpha(MapAlpha); + Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0); + cr.Paint(); } - if (!HideCircleCheck.Active) + double mx, my, mw, mh; + bool haveMap = MapRect(out mx, out my, out mw, out mh); + + if (haveMap && ShowMap) + { + Gdk.Pixbuf src = (_mapFit == MapFit.FitBody && _mapRaw != null) ? _mapRaw : _map; + double sx = mw / src.Width, sy = mh / src.Height; + + cr.Save(); + cr.Translate(mx, my); + cr.Scale(sx, sy); + Gdk.CairoHelper.SetSourcePixbuf(cr, src, 0, 0); + cr.PaintWithAlpha(T.MapAlpha); + cr.Restore(); + } + + if (ShowCircles) DrawCircles(cr); // body-pixel coords; transform does the rest + if (ShowGuides) + { + if (haveMap) + { + cr.NewPath(); + cr.SetSourceRGBA(1, 0, 0, 0.6); + cr.LineWidth = 1.5; + cr.Rectangle(mx, my, mw, mh); + cr.Stroke(); + } + + cr.NewPath(); + cr.SetSourceRGBA(0, 0, 1, 0.35); + cr.LineWidth = 1.0; + cr.MoveTo(_body.Width / 2.0, 0); + cr.LineTo(_body.Width / 2.0, _body.Height); + cr.MoveTo(0, _body.Height / 2.0); + cr.LineTo(_body.Width, _body.Height / 2.0); + cr.Stroke(); + } + cr.Restore(); args.RetVal = true; } @@ -168,12 +360,12 @@ var circles = _jacket.Get(JacketSize, _view); if (circles.Count == 0) return; + var t = T; + // "panel" is the body pixbuf — Form2's gridPictureBox was 546x546 int panelW = _body.Width; int panelH = _body.Height; - float scale = ScalingFactor(circles, panelW, panelH); - float minX = float.MaxValue, maxX = float.MinValue; float minY = float.MaxValue, maxY = float.MinValue; foreach (var c in circles) @@ -184,81 +376,47 @@ if (c.Y > maxY) maxY = c.Y; } - float offsetX = (panelW - (maxX - minX) * scale) / 2 - minX * scale; - float offsetY = (panelH - (maxY - minY) * scale) / 2 - minY * scale; + double availW = panelW - t.FitMargin, availH = panelH - t.FitMargin; + const double circleRadius = 2.1 / 2; + double dataW = (maxX - minX) + circleRadius * 2; + double dataH = (maxY - minY) + circleRadius * 2; + if (dataW <= 0 || dataH <= 0) return; + + double scale = Math.Min(availW / dataW, availH / dataH) * 0.9; + + double offsetX = (panelW - (maxX - minX) * scale) / 2 - minX * scale; + double offsetY = (panelH - (maxY - minY) * scale) / 2 - minY * scale; + + // cluster centroid, in already-offset space + double avgX = 0, avgY = 0; + foreach (var c in circles) + { + avgX += c.X * scale + offsetX - t.OffsetX; + avgY += c.Y * scale + offsetY - t.OffsetY; + } + avgX /= circles.Count; + avgY /= circles.Count; cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold); foreach (var c in circles) - DrawOneCircle(cr, c, scale, offsetX, offsetY); + DrawOneCircle(cr, c, t, scale, offsetX, offsetY, avgX, avgY); } - private float ScalingFactor(System.Collections.Generic.List circles, int panelW, int panelH) + private void DrawOneCircle(Cairo.Context cr, Circle c, Tune t, + double scale, double ox, double oy, + double avgX, double avgY) { - if (circles.Count == 0) return 1.0f; + double x = c.X * scale + ox - t.OffsetX; + double y = c.Y * scale + oy - t.OffsetY; - int availW = panelW - 40, availH = panelH - 40; - if (_view == "front") { availW = panelW - 100; availH = panelH - 100; } - - float minX = float.MaxValue, maxX = float.MinValue; - float minY = float.MaxValue, maxY = float.MinValue; - foreach (var c in circles) + if (Math.Abs(t.Cluster - 1.0) > 0.001) { - if (c.X < minX) minX = c.X; - if (c.X > maxX) maxX = c.X; - if (c.Y < minY) minY = c.Y; - if (c.Y > maxY) maxY = c.Y; + x = avgX + (x - avgX) * t.Cluster; + y = avgY + (y - avgY) * t.Cluster; } - const float circleRadius = 2.1f / 2; - float dataW = (maxX - minX) + circleRadius * 2; - float dataH = (maxY - minY) + circleRadius * 2; - - if (dataW <= 0 || dataH <= 0) return 1.0f; - - return Math.Min(availW / dataW, availH / dataH) * 0.9f; - } - - private void DrawOneCircle(Cairo.Context cr, Circle c, float scale, float ox, float oy) - { - float x, y, sizeScale; - - switch (_view) - { - case "back": - x = c.X * scale + ox - BACK_OFFSET_X; - y = c.Y * scale + oy - BACK_OFFSET_Y; - sizeScale = BACK_SIZE; - break; - case "right": - x = c.X * scale + ox - RIGHT_OFFSET_X; - y = c.Y * scale + oy - RIGHT_OFFSET_Y; - sizeScale = RIGHT_SIZE; - break; - case "left": - x = c.X * scale + ox - LEFT_OFFSET_X; - y = c.Y * scale + oy - LEFT_OFFSET_Y; - sizeScale = LEFT_SIZE; - break; - default: - x = c.X * scale + ox - FRONT_OFFSET_X; - y = c.Y * scale + oy - FRONT_OFFSET_Y; - sizeScale = FRONT_SIZE; - break; - } - - if (JacketSize == "XL") - { - switch (_view) - { - case "back": x += XL_BACK_OFFSET_X; y += XL_BACK_OFFSET_Y; break; - case "right": x += XL_RIGHT_OFFSET_X; y += XL_RIGHT_OFFSET_Y; break; - case "left": x += XL_LEFT_OFFSET_X; y += XL_LEFT_OFFSET_Y; break; - default: x += XL_FRONT_OFFSET_X; y += XL_FRONT_OFFSET_Y; break; - } - } - - double d = CircleDiameter * scale * sizeScale; + double d = CircleDiameter * scale * t.SizeScale; double r = d / 2; bool hot = false; @@ -270,16 +428,9 @@ if (hot) { - // cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0); // Color.FromArgb(255,235,59) - // cr.Arc(x, y, r, 0, 2 * Math.PI); - // cr.FillPreserve(); - cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0); + cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0); // Color.FromArgb(255,235,59) cr.FillPreserve(); } - // else - // { - // cr.Arc(x, y, r, 0, 2 * Math.PI); - // } cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0); // BorderColor = Gray cr.LineWidth = Math.Max(1.0, scale); @@ -287,7 +438,7 @@ string text = (c.Number % 1 == 0) ? ((int)c.Number).ToString() - : c.Number.ToString("F1"); + : c.Number.ToString("F1", CultureInfo.InvariantCulture); cr.SetFontSize(Math.Max(6.0, d / (c.Inner ? 3 : 4))); var ext = cr.TextExtents(text); @@ -297,6 +448,452 @@ cr.NewPath(); } + // ══════════════════════════════════════════════════════════════════ + // TUNER + // ══════════════════════════════════════════════════════════════════ + + public void ToggleTuner() + { + if (_tuner != null) { _tuner.Destroy(); _tuner = null; return; } + BuildTuner(); + } + + private void BuildTuner() + { + _tuner = new Window("MapWindow tuner — Ctrl+T to close"); + _tuner.SetDefaultSize(430, 760); + _tuner.DeleteEvent += (o, a) => { _tuner = null; }; + + var scroll = new ScrolledWindow(); + var col = new Box(Orientation.Vertical, 4); + col.MarginLeft = col.MarginRight = col.MarginTop = col.MarginBottom = 8; + + _tunerKey = new Label(); _tunerKey.Xalign = 0; + col.PackStart(_tunerKey, false, false, 0); + + _tunerInfo = new Label(); _tunerInfo.Xalign = 0; + _tunerInfo.Selectable = true; + col.PackStart(_tunerInfo, false, false, 0); + + col.PackStart(Header("Sound map"), false, false, 0); + + _tFitCombo = new ComboBoxText(); + _tFitCombo.AppendText("Native546 (Form2)"); + _tFitCombo.AppendText("FitBody (calibrator)"); + _tFitCombo.Active = (_mapFit == MapFit.FitBody) ? 1 : 0; + _tFitCombo.Changed += (s, e) => + { + if (_building) return; + _mapFit = _tFitCombo.Active == 1 ? MapFit.FitBody : MapFit.Native546; + UpdateTunerInfo(); + MapArea.QueueDraw(); + }; + col.PackStart(Row("Map fit mode", _tFitCombo), false, false, 0); + + AddSpin(col, "MapZoom", "Zoom", 0.10, 3.00, 0.01, 2); + AddSpin(col, "MapX", "Offset X (px)", -600, 600, 1, 0); + AddSpin(col, "MapY", "Offset Y (px)", -600, 600, 1, 0); + AddSpin(col, "MapAlpha", "Opacity", 0.00, 1.00, 0.05, 2); + + col.PackStart(Header("Circle cluster"), false, false, 0); + AddSpin(col, "OffsetX", "Offset X (px)", -600, 600, 1, 0); + AddSpin(col, "OffsetY", "Offset Y (px)", -600, 600, 1, 0); + AddSpin(col, "SizeScale", "Circle size", 0.05, 4.00, 0.05, 2); + AddSpin(col, "Cluster", "Cluster factor", 0.05, 2.00, 0.05, 2); + AddSpin(col, "FitMargin", "Fit margin (px)", 0, 400, 5, 0); + + col.PackStart(Header("Display"), false, false, 0); + _tShowBody = Chk(col, "Show body", true); + _tShowMap = Chk(col, "Show map", true); + _tShowCircles = Chk(col, "Show circles", true); + _tGuides = Chk(col, "Show guides", false); + + col.PackStart(Header("Images"), false, false, 0); + var imgRow = new Box(Orientation.Horizontal, 4); + imgRow.PackStart(Btn("Reload images", () => + { + ReloadBody(); + if (_map != null) { _map.Dispose(); _map = null; } + if (_mapRaw != null) { _mapRaw.Dispose(); _mapRaw = null; } + _map = LoadMap(MapPathForView()); + UpdateTunerInfo(); + MapArea.QueueDraw(); + }), true, true, 0); + imgRow.PackStart(Btn("Cycle view", () => + { + string[] v = { "front", "back", "right", "left" }; + int i = Array.IndexOf(v, _view); + string next = v[(i + 1) % v.Length]; + RadioButton rb = next == "back" ? ViewBackRadio + : next == "right" ? ViewRightRadio + : next == "left" ? ViewLeftRadio : ViewFrontRadio; + if (rb != null) rb.Active = true; else SetView(next); + }), true, true, 0); + col.PackStart(imgRow, false, false, 0); + + col.PackStart(Header("Constants"), false, false, 0); + + var r1 = new Box(Orientation.Horizontal, 4); + r1.PackStart(Btn("Apply pasted C#", ApplyPastedCSharp), true, true, 0); + r1.PackStart(Btn("Dump C# (calib)", () => Emit(DumpCalibStyle())), true, true, 0); + col.PackStart(r1, false, false, 0); + + var r2 = new Box(Orientation.Horizontal, 4); + r2.PackStart(Btn("Dump seed", () => Emit(DumpSeed())), true, true, 0); + r2.PackStart(Btn("Reset view", () => + { + _tunes.Remove(TuneKey); + var fresh = new Dictionary(_tunes); + SeedDefaults(); + foreach (var kv in fresh) if (kv.Key != TuneKey) _tunes[kv.Key] = kv.Value; + SyncSpins(); MapArea.QueueDraw(); + }), true, true, 0); + col.PackStart(r2, false, false, 0); + + var r3 = new Box(Orientation.Horizontal, 4); + r3.PackStart(Btn("Load calib.txt", () => { LoadCalibFile(true); SyncSpins(); MapArea.QueueDraw(); }), true, true, 0); + r3.PackStart(Btn("Save calib.txt", SaveCalibFile), true, true, 0); + col.PackStart(r3, false, false, 0); + + _tunerOut = new TextView(); + _tunerOut.OverrideFont(Pango.FontDescription.FromString("Monospace 9")); + _tunerOut.WrapMode = WrapMode.None; + var outScroll = new ScrolledWindow(); + outScroll.HeightRequest = 220; + outScroll.Add(_tunerOut); + col.PackStart(outScroll, true, true, 0); + + col.PackStart(new Label( + "Paste a CalibrationWindow dump above, then Apply pasted C#.") { Xalign = 0 }, + false, false, 0); + + scroll.Add(col); + _tuner.Add(scroll); + _tuner.ShowAll(); + + SyncSpins(); + UpdateTunerInfo(); + } + + // ── tuner widget helpers ── + + private Label Header(string text) + { + var l = new Label(); + l.Markup = "" + text + ""; + l.Xalign = 0; + l.MarginTop = 8; + return l; + } + + private Box Row(string label, Widget w) + { + var box = new Box(Orientation.Horizontal, 6); + var l = new Label(label); + l.Xalign = 0; + l.WidthRequest = 130; + box.PackStart(l, false, false, 0); + box.PackStart(w, true, true, 0); + return box; + } + + private Button Btn(string label, System.Action onClick) + { + var b = new Button(label); + b.Clicked += (s, e) => onClick(); + return b; + } + + private CheckButton Chk(Box parent, string label, bool active) + { + var c = new CheckButton(label); + c.Active = active; + c.Toggled += (s, e) => MapArea.QueueDraw(); + parent.PackStart(c, false, false, 0); + return c; + } + + private void AddSpin(Box parent, string field, string label, + double min, double max, double step, uint digits) + { + var sb = new SpinButton(min, max, step); + sb.Digits = digits; + sb.Numeric = true; + sb.ValueChanged += (s, e) => + { + if (_building) return; + SetField(field, sb.Value); + UpdateTunerInfo(); + MapArea.QueueDraw(); + }; + _spins[field] = sb; + parent.PackStart(Row(label, sb), false, false, 0); + } + + private void SetField(string field, double v) + { + var t = T; + switch (field) + { + case "OffsetX": t.OffsetX = v; break; + case "OffsetY": t.OffsetY = v; break; + case "SizeScale": t.SizeScale = v; break; + case "Cluster": t.Cluster = v; break; + case "MapZoom": t.MapZoom = v; break; + case "MapX": t.MapX = v; break; + case "MapY": t.MapY = v; break; + case "MapAlpha": t.MapAlpha = v; break; + case "FitMargin": t.FitMargin = v; break; + } + } + + private double GetField(string field) + { + var t = T; + switch (field) + { + case "OffsetX": return t.OffsetX; + case "OffsetY": return t.OffsetY; + case "SizeScale": return t.SizeScale; + case "Cluster": return t.Cluster; + case "MapZoom": return t.MapZoom; + case "MapX": return t.MapX; + case "MapY": return t.MapY; + case "MapAlpha": return t.MapAlpha; + case "FitMargin": return t.FitMargin; + } + return 0; + } + + private void SyncSpins() + { + if (_tuner == null) return; + _building = true; + foreach (var kv in _spins) kv.Value.Value = GetField(kv.Key); + if (_tunerKey != null) _tunerKey.Markup = "Editing: " + TuneKey + ""; + _building = false; + } + + private void UpdateTunerInfo() + { + if (_tunerInfo == null) return; + + var sb = new StringBuilder(); + sb.Append("body: ").Append(_bodyPath); + if (_body != null) sb.Append(" [").Append(_body.Width).Append("x").Append(_body.Height).Append("]"); + else sb.Append(" [MISSING]"); + sb.AppendLine(); + + sb.Append("map: ").Append(_mapPath); + if (_mapRaw != null) sb.Append(" raw[").Append(_mapRaw.Width).Append("x").Append(_mapRaw.Height).Append("]"); + if (_map != null) sb.Append(" used[").Append(_map.Width).Append("x").Append(_map.Height).Append("]"); + else sb.Append(" [MISSING]"); + sb.AppendLine(); + + double mx, my, mw, mh; + if (MapRect(out mx, out my, out mw, out mh)) + sb.AppendFormat(CultureInfo.InvariantCulture, + "rect: x={0:F1} y={1:F1} w={2:F1} h={3:F1}\n", mx, my, mw, mh); + + if (_mapFit == MapFit.FitBody) + sb.AppendLine("NOTE: FitBody changes display geometry; AudioReference\n" + + " is still the 546 pixbuf — sample via BodyToMapPixel."); + + _tunerInfo.Text = sb.ToString(); + } + + private void Emit(string msg) + { + if (_tunerOut == null) { Logger.Write("tune", msg); return; } + _tunerOut.Buffer.Text = msg + "\n\n" + _tunerOut.Buffer.Text; + } + + // ── C# constant round-trip ── + + private static readonly Regex ConstRx = new Regex( + @"(?XL_)?(?FRONT|BACK|RIGHT|LEFT)_" + + @"(?OFFSET_X|OFFSET_Y|SIZE|CLUSTER|FIT_MARGIN|MAP_ZOOM|MAP_X|MAP_Y|MAP_ALPHA)" + + @"\s*=\s*(?-?[0-9]*\.?[0-9]+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// Parses a CalibrationWindow "Dump" block pasted into the text view. + private void ApplyPastedCSharp() + { + if (_tunerOut == null) return; + string text = _tunerOut.Buffer.Text; + + var inv = CultureInfo.InvariantCulture; + int applied = 0; + var touched = new List(); + + foreach (Match m in ConstRx.Matches(text)) + { + string size = m.Groups["xl"].Success ? "XL" : "L"; + string view = m.Groups["view"].Value.ToLower(); + string key = size + ":" + view; + + double v; + if (!double.TryParse(m.Groups["val"].Value, NumberStyles.Float, inv, out v)) continue; + + if (!_tunes.ContainsKey(key)) _tunes[key] = new Tune(); + var t = _tunes[key]; + + switch (m.Groups["field"].Value.ToUpper()) + { + case "OFFSET_X": t.OffsetX = v; break; + case "OFFSET_Y": t.OffsetY = v; break; + case "SIZE": t.SizeScale = v; break; + case "CLUSTER": t.Cluster = v; break; + case "FIT_MARGIN": t.FitMargin = v; break; + case "MAP_ZOOM": t.MapZoom = v; break; + case "MAP_X": t.MapX = v; break; + case "MAP_Y": t.MapY = v; break; + case "MAP_ALPHA": t.MapAlpha = v; break; + default: continue; + } + applied++; + if (!touched.Contains(key)) touched.Add(key); + } + + SyncSpins(); + UpdateTunerInfo(); + MapArea.QueueDraw(); + + if (applied == 0) + Emit("no constants recognised — expected lines like\n" + + " private const float FRONT_OFFSET_X = 25.0f;"); + else + Emit("applied " + applied + " values to: " + string.Join(", ", touched.ToArray()) + + "\nIf MAP_ZOOM came from the calibrator, switch fit mode to FitBody."); + } + + /// Same format CalibrationWindow emits — round-trips through Apply. + private string DumpCalibStyle() + { + var inv = CultureInfo.InvariantCulture; + var sb = new StringBuilder(); + sb.AppendLine("// MapWindow tuner " + DateTime.Now.ToString("yyyy-MM-dd HH:mm") + + " fit=" + _mapFit); + + foreach (string size in new[] { "L", "XL" }) + { + sb.AppendLine("// ---- " + size + " ----"); + foreach (string view in new[] { "front", "back", "right", "left" }) + { + string k = size + ":" + view; + if (!_tunes.ContainsKey(k)) continue; + var t = _tunes[k]; + string p = (size == "XL" ? "XL_" : "") + view.ToUpper(); + + sb.AppendLine("private const float " + p + "_OFFSET_X = " + t.OffsetX.ToString("F1", inv) + "f;"); + sb.AppendLine("private const float " + p + "_OFFSET_Y = " + t.OffsetY.ToString("F1", inv) + "f;"); + sb.AppendLine("private const float " + p + "_SIZE = " + t.SizeScale.ToString("F2", inv) + "f;"); + sb.AppendLine("private const float " + p + "_CLUSTER = " + t.Cluster.ToString("F2", inv) + "f;"); + sb.AppendLine("private const float " + p + "_FIT_MARGIN = " + t.FitMargin.ToString("F0", inv) + "f;"); + sb.AppendLine("private const double " + p + "_MAP_ZOOM = " + t.MapZoom.ToString("F3", inv) + ";"); + sb.AppendLine("private const double " + p + "_MAP_X = " + t.MapX.ToString("F1", inv) + ";"); + sb.AppendLine("private const double " + p + "_MAP_Y = " + t.MapY.ToString("F1", inv) + ";"); + sb.AppendLine("private const double " + p + "_MAP_ALPHA = " + t.MapAlpha.ToString("F2", inv) + ";"); + sb.AppendLine(); + } + } + return sb.ToString(); + } + + /// Paste this straight over SeedDefaults() to bake values into the build. + private string DumpSeed() + { + var inv = CultureInfo.InvariantCulture; + var sb = new StringBuilder(); + sb.AppendLine("private void SeedDefaults()"); + sb.AppendLine("{"); + foreach (string size in new[] { "L", "XL" }) + foreach (string view in new[] { "front", "back", "right", "left" }) + { + string k = size + ":" + view; + if (!_tunes.ContainsKey(k)) continue; + var t = _tunes[k]; + sb.AppendFormat(inv, + " Seed(\"{0}\", {1:F1}, {2:F1}, {3:F2}, {4:F2}, {5:F0}, {6:F3}, {7:F1}, {8:F1}, {9:F2});\n", + k, t.OffsetX, t.OffsetY, t.SizeScale, t.Cluster, + t.FitMargin, t.MapZoom, t.MapX, t.MapY, t.MapAlpha); + } + sb.AppendLine("}"); + sb.AppendLine("// _mapFit = MapFit." + _mapFit + ";"); + return sb.ToString(); + } + + // ── calib.txt (same tab format CalibrationWindow writes) ── + + private static string CalibPath { get { return SoundWindow.AppFile("calib.txt"); } } + + private void LoadCalibFile(bool verbose) + { + string p = CalibPath; + if (!File.Exists(p)) + { + if (verbose) Emit("calib.txt not found at " + p); + return; + } + + var inv = CultureInfo.InvariantCulture; + int n = 0; + foreach (string line in File.ReadAllLines(p)) + { + if (line.StartsWith("#") || line.Trim().Length == 0) continue; + string[] f = line.Split('\t'); + if (f.Length < 10) continue; + + try + { + var t = new Tune(); + t.OffsetX = double.Parse(f[1], inv); + t.OffsetY = double.Parse(f[2], inv); + t.SizeScale = double.Parse(f[3], inv); + t.Cluster = double.Parse(f[4], inv); + t.MapZoom = double.Parse(f[5], inv); + t.MapX = double.Parse(f[6], inv); + t.MapY = double.Parse(f[7], inv); + t.MapAlpha = double.Parse(f[8], inv); + t.FitMargin = double.Parse(f[9], inv); + _tunes[f[0]] = t; + n++; + } + catch { } + } + + Logger.Write("tune", "loaded " + n + " entries from " + p); + if (verbose) Emit("loaded " + n + " entries from " + p + + "\ncalib.txt zoom values assume FitBody mode."); + } + + private void SaveCalibFile() + { + var inv = CultureInfo.InvariantCulture; + var sb = new StringBuilder(); + sb.AppendLine("# size:view offsetX offsetY sizeScale cluster mapZoom mapX mapY mapAlpha fitMargin"); + foreach (var kv in _tunes) + { + var t = kv.Value; + sb.AppendLine(string.Join("\t", new[] + { + kv.Key, + t.OffsetX.ToString(inv), t.OffsetY.ToString(inv), + t.SizeScale.ToString(inv), t.Cluster.ToString(inv), + t.MapZoom.ToString(inv), t.MapX.ToString(inv), + t.MapY.ToString(inv), t.MapAlpha.ToString(inv), + t.FitMargin.ToString(inv) + })); + } + + try + { + File.WriteAllText(CalibPath, sb.ToString()); + Emit("saved " + CalibPath); + } + catch (Exception ex) { Emit("save failed: " + ex.Message); } + } + // ── loading ──────────────────────────────────────────────────────── private void SetView(string view) @@ -306,9 +903,12 @@ ReloadBody(); - if (_map != null) { _map.Dispose(); _map = null; } + if (_map != null) { _map.Dispose(); _map = null; } + if (_mapRaw != null) { _mapRaw.Dispose(); _mapRaw = null; } _map = LoadMap(MapPathForView()); + SyncSpins(); + UpdateTunerInfo(); MapArea.QueueDraw(); Logger.Write("view", "-> " + view); } @@ -317,13 +917,15 @@ { if (_body != null) { _body.Dispose(); _body = null; } _body = LoadBody(); + UpdateTunerInfo(); MapArea.QueueDraw(); } private Gdk.Pixbuf LoadBody() { - string stem = BodyStem(); + string stem = BodyStem(); string full = SoundWindow.AppFile(Path.Combine("map", stem + ".png")); + _bodyPath = "map/" + stem + ".png"; if (!File.Exists(full)) { @@ -333,13 +935,8 @@ } return Load(full); - // string type = (BodyJacketRadio != null && BodyJacketRadio.Active) ? "jacket" : "skel"; - // string size = (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L"; - - // // Adjust to your actual body/ filenames. - // string rel = Path.Combine("body", type + "_" + _view + "_" + size + ".png"); - // return Load(SoundWindow.AppFile(rel)); } + private static void ListFolderOnce(string rel) { if (_listed) return; @@ -411,8 +1008,10 @@ } } - private static Gdk.Pixbuf LoadMap(string path) + private Gdk.Pixbuf LoadMap(string path) { + _mapPath = path ?? "(none)"; + var raw = Load(path); if (raw == null) return null; @@ -421,6 +1020,10 @@ var keyed = raw.AddAlpha(true, 0, 0, 0); raw.Dispose(); + // Keep the keyed original for FitBody mode — the calibrator never + // resized to 546, so its zoom numbers refer to THIS pixbuf. + _mapRaw = keyed.Copy(); + var sized = keyed.ScaleSimple(MapSize, MapSize, Gdk.InterpType.Bilinear); keyed.Dispose(); return sized; @@ -437,14 +1040,16 @@ private void DisposePixbufs() { - if (_body != null) { _body.Dispose(); _body = null; } - if (_map != null) { _map.Dispose(); _map = null; } + if (_body != null) { _body.Dispose(); _body = null; } + if (_map != null) { _map.Dispose(); _map = null; } + if (_mapRaw != null) { _mapRaw.Dispose(); _mapRaw = null; } } public void Dispose() { _sound.Dispose(); DisposePixbufs(); + if (_tuner != null) { _tuner.Destroy(); _tuner = null; } } private string JacketSize