using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using Gtk;
using UI = Gtk.Builder.ObjectAttribute;
using IOPath = System.IO.Path;
/// Standalone tuning tool: circle cluster scaling, sound-map zoom,
/// and sound-map position over the jacket. Writes values you paste
/// back into MapWindow.cs.
public class CalibrationWindow : Window
{
[UI] private Box ControlBox = null;
[UI] private DrawingArea CalibArea = null;
[UI] private TextView OutputView = null;
[UI] private Button DumpButton = null;
[UI] private Button SaveButton = null;
[UI] private Button ResetButton = null;
// ── per (size, view) tunables ──
private 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; // fraction of body the map fills
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)
public Tune Clone() { return (Tune)MemberwiseClone(); }
}
private readonly Dictionary<string, Tune> _tunes = new Dictionary<string, Tune>();
private readonly Dictionary<string, SpinButton> _spins = new Dictionary<string, SpinButton>();
private ComboBoxText _viewCombo, _sizeCombo, _bodyCombo, _caseCombo;
private CheckButton _showCircles, _showMap, _showBody, _showGuides;
private JacketData _jacket;
private List<CaseDefinition> _cases;
private Gdk.Pixbuf _body, _map;
private string _view = "front";
private string _size = "L";
private bool _skeleton = true;
private bool _building; // suppress handlers while repopulating spins
private const int CircleDiameter = 50;
private const string CalibFile = "calib.txt";
private string Key { get { return _size + ":" + _view; } }
private bool _listed;
private Tune T
{
get
{
if (!_tunes.ContainsKey(Key)) _tunes[Key] = DefaultFor(_view);
return _tunes[Key];
}
}
private static Tune DefaultFor(string view)
{
var t = new Tune();
switch (view)
{
case "back": t.OffsetX = 20; t.OffsetY = 20; t.SizeScale = 0.5; break;
case "right": t.OffsetX = 10; t.OffsetY = -150; t.Cluster = 0.5; break;
case "left": t.OffsetX = -50; t.OffsetY = -150; t.Cluster = 0.4; break;
default: t.OffsetX = 25; t.OffsetY = 50; t.FitMargin = 100; break;
}
return t;
}
// ── construction ───────────────────────────────────────────────────
public CalibrationWindow() : this(CreateBuilder()) { }
private static Builder CreateBuilder()
{
var b = new Builder();
b.AddFromFile("calibrate.glade");
return b;
}
private CalibrationWindow(Builder builder) : base(builder.GetObject("CalibRoot").Handle)
{
builder.Autoconnect(this);
_jacket = JacketData.Load();
_cases = CaseDefinition.LoadCasesFromCsv("cases.csv");
BuildControls();
LoadCalib();
CalibArea.Drawn += OnDraw;
DumpButton.Clicked += (s, e) => Emit(DumpConstants());
SaveButton.Clicked += (s, e) => SaveCalib();
ResetButton.Clicked += (s, e) => { _tunes[Key] = DefaultFor(_view); SyncSpins(); Redraw(); };
DeleteEvent += (o, a) => Application.Quit();
ReloadImages();
SyncSpins();
}
private void BuildControls()
{
ControlBox.PackStart(Header("Source"), false, false, 0);
_viewCombo = Combo(new[] { "front", "back", "right", "left" }, 0, v =>
{
_view = v; ReloadImages(); SyncSpins(); Redraw();
});
ControlBox.PackStart(Row("View", _viewCombo), false, false, 0);
_sizeCombo = Combo(new[] { "L", "XL" }, 0, v =>
{
_size = v; SyncSpins(); Redraw();
});
ControlBox.PackStart(Row("Jacket size", _sizeCombo), false, false, 0);
_bodyCombo = Combo(new[] { "Skeleton", "Jacket" }, 0, v =>
{
_skeleton = (v == "Skeleton"); ReloadImages(); Redraw();
});
ControlBox.PackStart(Row("Body", _bodyCombo), false, false, 0);
_caseCombo = new ComboBoxText();
foreach (var c in _cases)
_caseCombo.AppendText(c.Number + " " + string.Join(":", c.TreePath));
if (_cases.Count > 0) _caseCombo.Active = 0;
_caseCombo.Changed += (s, e) => { ReloadImages(); Redraw(); };
ControlBox.PackStart(Row("Case", _caseCombo), false, false, 0);
ControlBox.PackStart(Header("Sound map"), false, false, 0);
AddSpin("MapZoom", "Zoom (x body)", 0.10, 3.00, 0.01, 2);
AddSpin("MapX", "Offset X (px)", -600, 600, 1, 0);
AddSpin("MapY", "Offset Y (px)", -600, 600, 1, 0);
AddSpin("MapAlpha", "Opacity", 0.00, 1.00, 0.05, 2);
ControlBox.PackStart(Header("Circle cluster"), false, false, 0);
AddSpin("OffsetX", "Offset X (px)", -600, 600, 1, 0);
AddSpin("OffsetY", "Offset Y (px)", -600, 600, 1, 0);
AddSpin("SizeScale", "Circle size", 0.05, 4.00, 0.05, 2);
AddSpin("Cluster", "Cluster factor", 0.05, 2.00, 0.05, 2);
AddSpin("FitMargin", "Fit margin (px)", 0, 400, 5, 0);
ControlBox.PackStart(Header("Display"), false, false, 0);
_showBody = Check("Show body", true);
_showMap = Check("Show map", true);
_showCircles = Check("Show circles", true);
_showGuides = Check("Show centre guides", true);
}
// ── 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 ComboBoxText Combo(string[] items, int active, Action<string> onChange)
{
var c = new ComboBoxText();
foreach (string s in items) c.AppendText(s);
c.Active = active;
c.Changed += (s, e) => { if (!_building && c.ActiveText != null) onChange(c.ActiveText); };
return c;
}
private CheckButton Check(string label, bool active)
{
var c = new CheckButton(label);
c.Active = active;
c.Toggled += (s, e) => Redraw();
ControlBox.PackStart(c, false, false, 0);
return c;
}
private void AddSpin(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;
Set(field, sb.Value);
Redraw();
};
_spins[field] = sb;
ControlBox.PackStart(Row(label, sb), false, false, 0);
}
private void Set(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 Get(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()
{
_building = true;
foreach (var kv in _spins) kv.Value.Value = Get(kv.Key);
_building = false;
}
private void Redraw() { CalibArea.QueueDraw(); }
// ── images ─────────────────────────────────────────────────────────
private void ReloadImages()
{
if (_body != null) { _body.Dispose(); _body = null; }
if (_map != null) { _map.Dispose(); _map = null; }
_body = LoadBody();
_map = LoadMap();
}
private Gdk.Pixbuf LoadBody()
{
string stem;
if (_skeleton)
{
switch (_view)
{
case "back": stem = "jacketBackBody"; break;
case "right": stem = "jacketRightBody"; break;
case "left": stem = "jacketLeftBody"; break;
default: stem = "jacketFrontBody"; break;
}
}
else
{
switch (_view)
{
case "back": stem = "jacketBack"; break;
case "right": stem = "jacketRight"; break;
case "left": stem = "jacketLeft"; break;
default: stem = "jacketFront"; break;
}
}
foreach (string ext in new[] { ".png", ".jpg", ".bmp" })
{
string p = IOPath.Combine("map", stem + ext);
if (File.Exists(p)) return Safe(p);
}
Emit("body not found: map/" + stem + ".*");
ListMapFolder();
return null;
}
private void ListMapFolder()
{
if (_listed) return;
_listed = true;
Emit("cwd: " + Directory.GetCurrentDirectory());
if (!Directory.Exists("map")) { Emit("map/ does not exist"); return; }
var sb = new StringBuilder();
sb.AppendLine("map/ contains:");
foreach (string f in Directory.GetFiles("map"))
sb.AppendLine(" " + IOPath.GetFileName(f));
Emit(sb.ToString());
}
private Gdk.Pixbuf LoadMap()
{
if (_caseCombo == null || _caseCombo.Active < 0 || _caseCombo.Active >= _cases.Count)
return null;
var c = _cases[_caseCombo.Active];
string rel;
switch (_view)
{
case "back": rel = c.MapBack; break;
case "right": rel = c.MapRight; break;
case "left": rel = c.MapLeft; break;
default: rel = c.MapFront; break;
}
if (string.IsNullOrEmpty(rel)) rel = c.MapFront;
if (string.IsNullOrEmpty(rel)) return null;
string full = rel;
if (!File.Exists(full)) { Emit("map not found: " + rel); return null; }
var raw = Safe(full);
if (raw == null) return null;
var keyed = raw.AddAlpha(true, 0, 0, 0);
raw.Dispose();
return keyed;
}
private Gdk.Pixbuf Safe(string path)
{
try { return new Gdk.Pixbuf(path); }
catch (Exception ex) { Emit("load failed " + path + ": " + ex.Message); return null; }
}
// ── the draw ───────────────────────────────────────────────────────
private void OnDraw(object o, DrawnArgs args)
{
var cr = args.Cr;
int w = CalibArea.AllocatedWidth, h = CalibArea.AllocatedHeight;
cr.SetSourceRGB(1, 1, 1);
cr.Rectangle(0, 0, w, h);
cr.Fill();
Gdk.Pixbuf refPb = _body ?? _map;
if (refPb == null) { args.RetVal = true; return; }
double s = Math.Min((double)w / refPb.Width, (double)h / refPb.Height);
double ox = (w - refPb.Width * s) / 2.0;
double oy = (h - refPb.Height * s) / 2.0;
cr.Save();
cr.Translate(ox, oy);
cr.Scale(s, s); // body-pixel space
if (_body != null && _showBody.Active)
{
Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0);
cr.Paint();
}
if (_map != null && _showMap.Active)
{
double ms = Math.Min((double)refPb.Width / _map.Width,
(double)refPb.Height / _map.Height) * T.MapZoom;
double mw = _map.Width * ms, mh = _map.Height * ms;
double mx = (refPb.Width - mw) / 2.0 + T.MapX;
double my = (refPb.Height - mh) / 2.0 + T.MapY;
cr.Save();
cr.Translate(mx, my);
cr.Scale(ms, ms);
Gdk.CairoHelper.SetSourcePixbuf(cr, _map, 0, 0);
cr.PaintWithAlpha(T.MapAlpha);
cr.Restore();
if (_showGuides.Active)
{
cr.NewPath();
cr.SetSourceRGBA(1, 0, 0, 0.6);
cr.LineWidth = 1.5;
cr.Rectangle(mx, my, mw, mh);
cr.Stroke();
}
}
if (_showCircles.Active) DrawCircles(cr, refPb);
if (_showGuides.Active)
{
cr.NewPath();
cr.SetSourceRGBA(0, 0, 1, 0.35);
cr.LineWidth = 1.0;
cr.MoveTo(refPb.Width / 2.0, 0);
cr.LineTo(refPb.Width / 2.0, refPb.Height);
cr.MoveTo(0, refPb.Height / 2.0);
cr.LineTo(refPb.Width, refPb.Height / 2.0);
cr.Stroke();
}
cr.Restore();
args.RetVal = true;
}
private void DrawCircles(Cairo.Context cr, Gdk.Pixbuf refPb)
{
var circles = _jacket.Get(_size, _view);
if (circles.Count == 0) return;
int panelW = refPb.Width, panelH = refPb.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 offX = (panelW - (maxX - minX) * scale) / 2 - minX * scale;
double offY = (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 + offX - T.OffsetX;
avgY += c.Y * scale + offY - T.OffsetY;
}
avgX /= circles.Count;
avgY /= circles.Count;
cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold);
foreach (var c in circles)
{
double x = c.X * scale + offX - T.OffsetX;
double y = c.Y * scale + offY - 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;
cr.NewPath();
cr.Arc(x, y, r, 0, 2 * Math.PI);
cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0);
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);
cr.ShowText(text);
cr.NewPath();
}
}
// ── output ─────────────────────────────────────────────────────────
private string DumpConstants()
{
var inv = CultureInfo.InvariantCulture;
var sb = new StringBuilder();
sb.AppendLine("// generated by CalibrationWindow " + DateTime.Now.ToString("yyyy-MM-dd HH:mm"));
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();
}
private void SaveCalib()
{
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(CalibFile, sb.ToString());
Emit("saved " + CalibFile);
}
catch (Exception ex) { Emit("save failed: " + ex.Message); }
}
private void LoadCalib()
{
string p = CalibFile;
if (!File.Exists(p)) return;
var inv = CultureInfo.InvariantCulture;
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;
var t = new Tune();
try
{
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;
}
catch { }
}
Emit("loaded " + CalibFile);
}
private void Emit(string msg)
{
OutputView.Buffer.Text = msg + "\n\n" + OutputView.Buffer.Text;
}
}