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
{
private const float FRONT_Z = 1.0f;
private const float BACK_Z = -1.0f;
private const float RIGHT_Z = 0.5f;
private const float LEFT_Z = -0.5f;
private const float Z_TOLERANCE = 0.1f;
// ══════════════════════════════════════════════════════════════════
// 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().
// ══════════════════════════════════════════════════════════════════
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;
// cursor calibration
public double MarginX = 0.0;
public double MarginY = 0.0;
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<string, Tune> _tunes = new Dictionary<string, Tune>();
// 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;
[UI] private Label MapCaseLabel = null;
[UI] private Button MapBackButton = null;
[UI] private RadioButton BodySkeletonRadio = null;
[UI] private RadioButton BodyJacketRadio = null;
[UI] private ComboBoxText JacketSizeCombo = null;
[UI] private RadioButton ViewFrontRadio = null;
[UI] private RadioButton ViewBackRadio = null;
[UI] private RadioButton ViewRightRadio = null;
[UI] private RadioButton ViewLeftRadio = null;
[UI] private CheckButton HideCircleCheck = null;
private readonly Language _lang;
private readonly SoundLooper _sound = new SoundLooper();
// ── state ──
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 readonly ArduinoConnection _arduino;
// ── live Arduino position ──
private float _currentX, _currentY, _currentZ;
private bool _hasPosition;
private static readonly Regex FullRx = new Regex(
@"(\d+(?:\.\d+)?)\(([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?)\)",
RegexOptions.Compiled);
private static readonly Regex AvgRx = new Regex(
@"Avg(\d+(?:\.\d+)?)\(([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?)\)",
RegexOptions.Compiled);
private string _bodyPath = "(none)";
private string _mapPath = "(none)";
public event EventHandler BackRequested;
private JacketData _jacket;
private List<float> _highlighted = new List<float>();
private static bool _listed;
// ── tuner state ──
private Window _tuner;
private TextView _tunerOut;
private Label _tunerInfo, _tunerKey;
private readonly Dictionary<string, SpinButton> _spins = new Dictionary<string, SpinButton>();
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, ArduinoConnection arduino, Language language)
{
builder.Autoconnect(this);
_arduino = arduino;
SeedDefaults();
LoadCalibFile(false); // calib.txt overrides seeds if present
_jacket = JacketData.Load();
Logger.Write("jacket", "folder: " + _jacket.Folder + " loaded=" + _jacket.Loaded);
if (MapArea == null || MapTitleLabel == null || MapBackButton == null)
throw new InvalidOperationException(
"Glade id mismatch — MapArea=" + (MapArea != null) +
" MapTitleLabel=" + (MapTitleLabel != null) +
" MapBackButton=" + (MapBackButton != null));
_lang = language;
MapArea.Drawn += OnMapDrawn;
MapBackButton.Clicked += OnBackClicked;
BodySkeletonRadio.Toggled += (s, e) => { if (BodySkeletonRadio.Active) ReloadBody(); };
BodyJacketRadio.Toggled += (s, e) => { if (BodyJacketRadio.Active) ReloadBody(); };
JacketSizeCombo.Changed += (s, e) => { ReloadBody(); SyncSpins(); MapArea.QueueDraw(); };
HookView(ViewFrontRadio, "front");
HookView(ViewBackRadio, "back");
HookView(ViewRightRadio, "right");
HookView(ViewLeftRadio, "left");
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);
// NEW — live position feed
_arduino.LineReceived += OnArduinoLine;
_arduino.ConnectionChanged += OnArduinoConnectionChanged;
}
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;
rb.Toggled += (s, e) => { if (rb.Active) SetView(view); };
}
// ══════════════════════════════════════════════════════════════════
// ARDUINO POSITION
// ══════════════════════════════════════════════════════════════════
private void OnArduinoLine(string line)
{
var fullMatches = FullRx.Matches(line);
var avgMatch = AvgRx.Match(line);
float avgChipNumber = -1f;
float x = 0, y = 0, z = 0;
bool havePosition = false;
_highlighted.Clear();
Logger.Write("chip", "raw='" + line + "' fullMatches=" + fullMatches.Count + // NEW
" avgMatch=" + avgMatch.Success);
if (avgMatch.Success)
{
avgChipNumber = ParseF(avgMatch.Groups[1].Value);
x = ParseF(avgMatch.Groups[2].Value);
y = ParseF(avgMatch.Groups[3].Value);
z = ParseF(avgMatch.Groups[4].Value);
havePosition = true;
}
if (fullMatches.Count > 0)
{
foreach (Match m in fullMatches)
{
float chipNumber = ParseF(m.Groups[1].Value);
if (Math.Abs(chipNumber - avgChipNumber) < 0.001f) continue; // that's the Avg entry itself
_highlighted.Add(chipNumber); // feeds the existing yellow-fill in DrawOneCircle
}
if (!havePosition)
{
var first = fullMatches[0];
x = ParseF(first.Groups[2].Value);
y = ParseF(first.Groups[3].Value);
z = ParseF(first.Groups[4].Value);
havePosition = true;
}
}
if (!havePosition) return; // status/mode line, not a coordinate line
_currentX = x;
_currentY = y;
_currentZ = z;
_hasPosition = _highlighted.Count > 0; // NEW — marker only shows when a chip is actually on the target
string newView = DetermineViewFromZ(z);
if (newView != _view)
SwitchViewFromArduino(newView);
// Logger.Write("chip", "highlighted=[" + string.Join(",", _highlighted) + "] " + // NEW
// "view=" + _view + " size=" + JacketSize + // NEW
// " circlesInView=" + (_jacket != null ? _jacket.Get(JacketSize, _view).Count : -1)); // NEW
MapArea.QueueDraw();
}
private static float ParseF(string s)
{
return float.Parse(s, CultureInfo.InvariantCulture);
}
private void OnArduinoConnectionChanged(bool connected)
{
if (!connected)
{
_hasPosition = false;
MapArea.QueueDraw();
}
}
/// Maps the live Arduino (x,y) into BODY PIXELS using the same
/// bounding-box scale/offset DrawCircles uses, so the cursor tracks
/// the same coordinate frame as the numbered circles.
private bool TryGetCursorBodyPoint(out double bx, out double by)
{
bx = by = 0;
if (!_hasPosition || _jacket == null || _body == null) return false;
var circles = _jacket.Get(JacketSize, _view);
if (circles.Count == 0) return false;
var t = T;
int panelW = _body.Width;
int panelH = _body.Height;
float minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
foreach (var c in circles)
{
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;
}
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 false;
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;
// NEW — same centroid DrawCircles computes, needed so clustering below
// pulls the cursor toward the exact same point the circles cluster around.
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;
// CHANGED — subtract t.OffsetX/OffsetY, exactly like DrawOneCircle does.
// This was the main misalignment: circles were shifted by this offset,
// the cursor never was.
bx = (_currentX * 50 + t.MarginX) * scale + offsetX - t.OffsetX;
by = (_currentY * 50 + t.MarginY) * scale + offsetY - t.OffsetY;
if (JacketSize == "XL" && (_view == "front" || _view == "right" || _view == "left"))
{
double centerX = panelW / 2.0;
double offsetFromCenter = bx - centerX;
bx = centerX - offsetFromCenter;
}
return true;
}
private void DrawCursor(Cairo.Context cr, double x, double y)
{
const double size = 14;
cr.NewPath();
// cr.SetSourceRGBA(1.0, 0.0, 0.0, 0.9); //red not that visible
cr.SetSourceRGBA(1.0, 0.85, 0.0, 0.95); //yellow
cr.LineWidth = 2.5;
cr.MoveTo(x - size, y);
cr.LineTo(x + size, y);
cr.MoveTo(x, y - size);
cr.LineTo(x, y + size);
cr.Stroke();
cr.NewPath();
cr.Arc(x, y, 5, 0, 2 * Math.PI);
cr.Stroke();
}
private void DrawCoordHud(Cairo.Context cr, int w, int h)
{
string text = string.Format(CultureInfo.InvariantCulture,
"X={0:F2} Y={1:F2} Z={2:F2} view={3}", _currentX, _currentY, _currentZ, _view);
cr.SelectFontFace("Monospace", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold);
cr.SetFontSize(13);
var ext = cr.TextExtents(text);
double pad = 6, bx = 8, by = 8;
double bw = ext.Width + pad * 2, bh = ext.Height + pad * 2;
cr.NewPath();
cr.Rectangle(bx, by, bw, bh);
cr.SetSourceRGBA(0, 0, 0, 0.55);
cr.Fill();
cr.MoveTo(bx + pad - ext.XBearing, by + pad + ext.Height);
cr.SetSourceRGBA(1, 1, 1, 1);
cr.ShowText(text);
}
// ── public API ─────────────────────────────────────────────────────
public void ShowCase(CaseDefinition def, string title)
{
_case = def;
_view = "front";
if (ViewFrontRadio != null) ViewFrontRadio.Active = true;
MapTitleLabel.Text = title ?? "";
MapCaseLabel.Text = title ?? "-";
DisposePixbufs();
_body = LoadBody();
_map = LoadMap(MapPathForView());
MapArea.QueueDraw();
UpdateTunerInfo();
_sound.Start(SoundWindow.AppFile(def.SoundPath));
Logger.Write("case", "open: " + title + " | map=" + MapPathForView());
}
public void StopSession()
{
_sound.Stop();
Logger.Write("case", "closed");
}
/// 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
// overlay registered. Zoom-to-fit, same as PictureBoxSizeMode.Zoom.
double s = Math.Min((double)w / _body.Width, (double)h / _body.Height);
double ox = (w - _body.Width * s) / 2.0;
double oy = (h - _body.Height * s) / 2.0;
cr.Save();
cr.Translate(ox, oy);
cr.Scale(s, s); // everything below is in BODY PIXELS
if (ShowBody)
{
Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0);
cr.Paint();
}
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
double cx, cy;
if (TryGetCursorBodyPoint(out cx, out cy))
DrawCursor(cr, cx, cy);
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();
// NEW — screen-space readout, unaffected by the body scale/translate above
if (_hasPosition)
DrawCoordHud(cr, w, h); //show the coordinate on top of the image
args.RetVal = true;
}
private void DrawCircles(Cairo.Context cr)
{
if (_jacket == null || _body == null) return;
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 minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
foreach (var c in circles)
{
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;
}
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, t, scale, offsetX, offsetY, avgX, avgY);
}
private void DrawOneCircle(Cairo.Context cr, Circle c, Tune t,
double scale, double ox, double oy,
double avgX, double avgY)
{
double x = c.X * scale + ox - t.OffsetX;
double y = c.Y * scale + oy - t.OffsetY;
if (Math.Abs(t.Cluster - 1.0) > 0.001)
{
x = avgX + (x - avgX) * t.Cluster;
y = avgY + (y - avgY) * t.Cluster;
}
double d = CircleDiameter * scale * t.SizeScale;
double r = d / 2;
bool hot = false;
foreach (float n in _highlighted)
if (Math.Abs(c.Number - n) < 0.001f) { hot = true; break; }
cr.NewPath();
cr.Arc(x, y, r, 0, 2 * Math.PI);
if (hot)
{
cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0); // Color.FromArgb(255,235,59)
cr.FillPreserve();
}
cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0); // BorderColor = Gray
cr.LineWidth = Math.Max(1.0, scale);
cr.Stroke();
string text = (c.Number % 1 == 0)
? ((int)c.Number).ToString()
: c.Number.ToString("F1", CultureInfo.InvariantCulture);
cr.SetFontSize(Math.Max(6.0, d / (c.Inner ? 3 : 4)));
var ext = cr.TextExtents(text);
cr.MoveTo(x - ext.Width / 2 - ext.XBearing, y + ext.Height / 2);
cr.SetSourceRGBA(0.2, 0.2, 0.2, 1.0); // TextColor
cr.ShowText(text);
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("Cursor (Arduino)"), false, false, 0);
AddSpin(col, "MarginX", "Margin X (raw units)", -2000, 2000, 1, 0);
AddSpin(col, "MarginY", "Margin Y (raw units)", -2000, 2000, 1, 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<string, Tune>(_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 = "<b>" + text + "</b>";
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;
case "MarginX": t.MarginX = v; break;
case "MarginY": t.MarginY = 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;
case "MarginX": return t.MarginX;
case "MarginY": return t.MarginY;
}
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 = "<b>Editing: " + TuneKey + "</b>";
_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>XL_)?(?<view>FRONT|BACK|RIGHT|LEFT)_" +
@"(?<field>OFFSET_X|OFFSET_Y|SIZE|CLUSTER|FIT_MARGIN|MAP_ZOOM|MAP_X|MAP_Y|MAP_ALPHA)" +
@"\s*=\s*(?<val>-?[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<string>();
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)
{
if (_view == view) return;
_view = view;
ReloadBody();
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);
}
private void ReloadBody()
{
if (_body != null) { _body.Dispose(); _body = null; }
_body = LoadBody();
UpdateTunerInfo();
MapArea.QueueDraw();
}
private Gdk.Pixbuf LoadBody()
{
string stem = BodyStem();
string full = SoundWindow.AppFile(Path.Combine("map", stem + ".png"));
_bodyPath = "map/" + stem + ".png";
if (!File.Exists(full))
{
Logger.Write("body", "not found: map/" + stem + ".png");
ListFolderOnce("map");
return null;
}
return Load(full);
}
private static void ListFolderOnce(string rel)
{
if (_listed) return;
_listed = true;
string dir = SoundWindow.AppFile(rel);
if (!Directory.Exists(dir)) { Logger.Write("body", "folder missing: " + dir); return; }
foreach (string f in Directory.GetFiles(dir))
Logger.Write("body", "found: " + Path.GetFileName(f));
}
private string BodyStem()
{
bool skeleton = (BodySkeletonRadio != null && BodySkeletonRadio.Active);
if (skeleton)
{
switch (_view)
{
case "back": return "jacketBackBody";
case "right": return "jacketRightBody";
case "left": return "jacketLeftBody";
default: return "jacketFrontBody";
}
}
switch (_view)
{
case "back": return "jacketBack";
case "right": return "jacketRight";
case "left": return "jacketLeft";
default: return "jacketFront";
}
}
private string MapPathForView()
{
if (_case == null) return null;
string rel;
switch (_view)
{
case "back": rel = _case.MapBack; break;
case "right": rel = _case.MapRight; break;
case "left": rel = _case.MapLeft; break;
default: rel = _case.MapFront; break;
}
// Form2 falls back to a mirrored front map when there's no dedicated back.
if (string.IsNullOrEmpty(rel)) rel = _case.MapFront;
return string.IsNullOrEmpty(rel) ? null : SoundWindow.AppFile(rel);
}
private static Gdk.Pixbuf Load(string path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
Logger.Write("map", "image not found: " + path);
return null;
}
try { return new Gdk.Pixbuf(path); }
catch (Exception ex)
{
Logger.Write("map", "load failed " + path + ": " + ex.Message);
return null;
}
}
private Gdk.Pixbuf LoadMap(string path)
{
_mapPath = path ?? "(none)";
var raw = Load(path);
if (raw == null) return null;
// Key out black FIRST, then scale. Scaling first leaves near-black
// edge pixels (1,0,2) that AddAlpha won't match — that's the dark halo.
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;
}
// ── plumbing ───────────────────────────────────────────────────────
private void OnBackClicked(object sender, EventArgs e)
{
StopSession();
var h = BackRequested;
if (h != null) h(this, EventArgs.Empty);
}
private void DisposePixbufs()
{
if (_body != null) { _body.Dispose(); _body = null; }
if (_map != null) { _map.Dispose(); _map = null; }
if (_mapRaw != null) { _mapRaw.Dispose(); _mapRaw = null; }
}
public void Dispose()
{
_arduino.LineReceived -= OnArduinoLine;
_arduino.ConnectionChanged -= OnArduinoConnectionChanged;
_sound.Dispose();
DisposePixbufs();
if (_tuner != null) { _tuner.Destroy(); _tuner = null; }
}
private string JacketSize
{
get { return (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L"; }
}
private string DetermineViewFromZ(float z)
{
float frontDiff = Math.Abs(z - FRONT_Z);
float backDiff = Math.Abs(z - BACK_Z);
float rightDiff = Math.Abs(z - RIGHT_Z);
float leftDiff = Math.Abs(z - LEFT_Z);
float minDiff = Math.Min(Math.Min(frontDiff, backDiff), Math.Min(rightDiff, leftDiff));
if (minDiff > Z_TOLERANCE) return _view; // no clean match — stay put
if (minDiff == frontDiff) return "front";
if (minDiff == backDiff) return "back";
if (minDiff == rightDiff) return "right";
return "left";
}
/// Drives the same radio buttons a manual click would, so the UI and
/// _view stay in sync (SetView no-ops if it's already the active view).
private void SwitchViewFromArduino(string view)
{
RadioButton rb = view == "back" ? ViewBackRadio
: view == "right" ? ViewRightRadio
: view == "left" ? ViewLeftRadio
: ViewFrontRadio;
if (rb != null) rb.Active = true;
else SetView(view);
}
}