diff --git a/programdeltafixer/CalibrationWindow.cs b/programdeltafixer/CalibrationWindow.cs new file mode 100644 index 0000000..e2bf735 --- /dev/null +++ b/programdeltafixer/CalibrationWindow.cs @@ -0,0 +1,600 @@ +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 _tunes = new Dictionary(); + private readonly Dictionary _spins = new Dictionary(); + + private ComboBoxText _viewCombo, _sizeCombo, _bodyCombo, _caseCombo; + private CheckButton _showCircles, _showMap, _showBody, _showGuides; + + private JacketData _jacket; + private List _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 = "" + 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 ComboBoxText Combo(string[] items, int active, Action 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; + } +} \ No newline at end of file diff --git a/programdeltafixer/CaseDefinition.cs b/programdeltafixer/CaseDefinition.cs new file mode 100644 index 0000000..39b9c85 --- /dev/null +++ b/programdeltafixer/CaseDefinition.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +/// One row of cases.csv. +public class CaseDefinition +{ + public int Number { get; set; } // Number + public string Type { get; set; } // Type 心音 / 呼吸音 + public string CategoryJp { get; set; } // Category + public string SubcategoryJp { get; set; } // Subcategory + public string LocationJp { get; set; } // Location + public string TreeLevel1 { get; set; } // Tree_Level1 + public string TreeLevel2 { get; set; } // Tree_Level2 + public string TreeLevel3 { get; set; } // Tree_Level3 + public string MapFront { get; set; } // Image_File + public string SoundPath { get; set; } // Sound_File + public string MapRight { get; set; } // Image_Right + public string MapLeft { get; set; } // Image_Left + public string MapBack { get; set; } // Image_Back + + public bool IsHeart + { + get { return !string.IsNullOrEmpty(Type) && Type.Contains("心"); } + } + + /// Non-empty levels only, e.g. 呼吸音 → 正常呼吸音 → 気管音. + /// Depth varies per row, which is what drives the drill-down. + public string[] TreePath + { + get + { + var parts = new List(); + foreach (string s in new[] { Type, TreeLevel1, TreeLevel2, TreeLevel3 }) + if (!string.IsNullOrWhiteSpace(s)) parts.Add(s.Trim()); + return parts.ToArray(); + } + } + + public override string ToString() + { + return Number + ": " + string.Join(" : ", TreePath); + } + + // ── loader ───────────────────────────────────────────────────────── + + public static List LoadCasesFromCsv(string path) + { + var list = new List(); + + if (!File.Exists(path)) + { + Console.WriteLine("CSV not found: " + path); + return list; + } + + // Encoding.UTF8 with BOM detection. Reading Japanese as the default + // ANSI codepage gives mojibake that only shows up on the buttons. + string[] lines; + try + { + lines = File.ReadAllLines(path, Encoding.UTF8); + } + catch (Exception ex) + { + Console.WriteLine("Failed to read " + path + ": " + ex.Message); + return list; + } + + if (lines.Length < 2) + { + Console.WriteLine("CSV has no data rows: " + path); + return list; + } + + // Your file is TAB separated; fall back to comma if it gets re-exported. + char sep = lines[0].Contains("\t") ? '\t' : ','; + + // Header name -> column index, so adding or reordering columns is safe. + // string[] header = lines[0].TrimStart('\uFEFF').Split(sep); + string[] header = lines[0].TrimStart(new[] { '\uFEFF' }).Split(sep); + var idx = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < header.Length; i++) + { + string key = header[i].Trim(); + if (key.Length > 0 && !idx.ContainsKey(key)) idx[key] = i; + } + + if (!idx.ContainsKey("Number")) + { + Console.WriteLine("CSV header has no 'Number' column — wrong file or wrong delimiter?"); + return list; + } + + Func get = (fields, name) => + { + int i; + if (!idx.TryGetValue(name, out i) || i >= fields.Length) return ""; + return fields[i].Trim(); + }; + + int skipped = 0; + + for (int r = 1; r < lines.Length; r++) + { + if (string.IsNullOrWhiteSpace(lines[r])) continue; + + string[] f = lines[r].Split(sep); + + int number; + if (!int.TryParse(get(f, "Number"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out number)) + { + skipped++; + Console.WriteLine("Line " + (r + 1) + ": bad Number, skipped"); + continue; + } + + list.Add(new CaseDefinition + { + Number = number, + Type = get(f, "Type"), + CategoryJp = get(f, "Category"), + SubcategoryJp = get(f, "Subcategory"), + LocationJp = get(f, "Location"), + TreeLevel1 = get(f, "Tree_Level1"), + TreeLevel2 = get(f, "Tree_Level2"), + TreeLevel3 = get(f, "Tree_Level3"), + MapFront = get(f, "Image_File"), + SoundPath = get(f, "Sound_File"), + MapRight = get(f, "Image_Right"), + MapLeft = get(f, "Image_Left"), + MapBack = get(f, "Image_Back") + }); + } + + Console.WriteLine("Loaded " + list.Count + " cases from " + path + + (skipped > 0 ? " (" + skipped + " skipped)" : "")); + return list; + } +} \ No newline at end of file diff --git a/programdeltafixer/JacketData.cs b/programdeltafixer/JacketData.cs new file mode 100644 index 0000000..d09ae52 --- /dev/null +++ b/programdeltafixer/JacketData.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +/// One chip position. Mirrors Form2.Circle. +public class Circle +{ + public float Number { get; set; } + public int X { get; set; } // screen-space: OriginalX * 50 + MarginX + public int Y { get; set; } + public float OriginalX { get; set; } + public float OriginalY { get; set; } + public float OriginalZ { get; set; } + public bool Inner { get; set; } // non-integer Number +} + +/// Loads the 8 CSVs from JacketData/ — front|back|right|left x L|XL. +public class JacketData +{ + public static string AppFile(string relative) { return relative; } + public const int CoordScale = 50; // Form2: originalX * 50 + public const int MarginX = 30; + public const int MarginY = 30; + + private const string ConfigFile = "config.txt"; + private const string DefaultDir = "JacketData"; + + private static readonly string[] Views = { "front", "back", "right", "left" }; + private static readonly string[] Sizes = { "L", "XL" }; + + private readonly Dictionary> _l = new Dictionary>(); + private readonly Dictionary> _xl = new Dictionary>(); + + public string Folder { get; private set; } + public bool Loaded { get; private set; } + + /// Form2.GetCurrentCSVData() + public List Get(string size, string view) + { + var dict = (size == "XL") ? _xl : _l; + List list; + return dict.TryGetValue(view, out list) ? list : new List(); + } + + public bool Has(string size, string view) + { + var dict = (size == "XL") ? _xl : _l; + return dict.ContainsKey(view) && dict[view].Count > 0; + } + + // ── folder resolution: config.txt, else JacketData/ next to the exe ── + + public static string ResolveFolder() + { + string cfg = ConfigFile; + + if (File.Exists(cfg)) + { + try + { + string saved = File.ReadAllText(cfg).Trim(); + if (Directory.Exists(saved) && Validate(saved)) + { + Logger.Write("jacket", "folder from config.txt: " + saved); + return saved; + } + + Logger.Write("jacket", "config.txt path invalid, ignoring: " + saved); + } + catch (Exception ex) + { + Logger.Write("jacket", "config.txt unreadable: " + ex.Message); + } + } + + // return SoundWindow.AppFile(DefaultDir); + return DefaultDir; + } + + public static void SaveFolder(string folder) + { + try { File.WriteAllText(SoundWindow.AppFile(ConfigFile), folder); } + catch (Exception ex) { Logger.Write("jacket", "could not save config.txt: " + ex.Message); } + } + + /// Form1.ValidateCSVFiles — all 8 must be present. + public static bool Validate(string folder) + { + foreach (string v in Views) + foreach (string s in Sizes) + if (!File.Exists(Path.Combine(folder, v + "_" + s + ".csv"))) + return false; + return true; + } + + public static string[] MissingFiles(string folder) + { + var missing = new List(); + foreach (string v in Views) + foreach (string s in Sizes) + { + string name = v + "_" + s + ".csv"; + if (!File.Exists(Path.Combine(folder, name))) missing.Add(name); + } + return missing.ToArray(); + } + + // ── loading ───────────────────────────────────────────────────────── + + public static JacketData Load() + { + return Load(ResolveFolder()); + } + + public static JacketData Load(string folder) + { + var data = new JacketData(); + data.Folder = folder; + + if (!Directory.Exists(folder)) + { + Logger.Write("jacket", "folder not found: " + folder); + return data; + } + + string[] missing = MissingFiles(folder); + if (missing.Length > 0) + Logger.Write("jacket", "missing " + missing.Length + " file(s): " + string.Join(", ", missing)); + + foreach (string view in Views) + { + foreach (string size in Sizes) + { + string path = Path.Combine(folder, view + "_" + size + ".csv"); + var circles = ReadCsv(path); + if (circles.Count == 0) continue; + + if (size == "XL") data._xl[view] = circles; + else data._l[view] = circles; + + Logger.Write("jacket", "loaded " + circles.Count + " chips from " + view + "_" + size + ".csv"); + } + } + + data.Loaded = (data._l.Count > 0 || data._xl.Count > 0); + if (!data.Loaded) Logger.Write("jacket", "NO chip data loaded from " + folder); + + return data; + } + + /// Columns: Number, OriginalX, OriginalY, OriginalZ. Header optional. + private static List ReadCsv(string path) + { + var circles = new List(); + + if (!File.Exists(path)) + { + Logger.Write("jacket", "file not found: " + path); + return circles; + } + + string[] lines; + try { lines = File.ReadAllLines(path); } + catch (Exception ex) + { + Logger.Write("jacket", "read failed " + path + ": " + ex.Message); + return circles; + } + + int start = 0; + if (lines.Length > 0) + { + string h = lines[0].ToLower(); + if (h.Contains("number") || h.Contains("originalx")) start = 1; + } + + for (int i = start; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.Length == 0) continue; + + string[] p = line.Split(','); + if (p.Length < 4) + { + Logger.Write("jacket", path + " line " + (i + 1) + ": only " + p.Length + " columns"); + continue; + } + + float number, ox, oy, oz; + var inv = CultureInfo.InvariantCulture; + + // InvariantCulture matters — under ja_JP/de_DE, "1.5" can fail to parse. + if (!float.TryParse(p[0].Trim(), NumberStyles.Float, inv, out number) || + !float.TryParse(p[1].Trim(), NumberStyles.Float, inv, out ox) || + !float.TryParse(p[2].Trim(), NumberStyles.Float, inv, out oy) || + !float.TryParse(p[3].Trim(), NumberStyles.Float, inv, out oz)) + { + Logger.Write("jacket", path + " line " + (i + 1) + ": bad number format"); + continue; + } + + circles.Add(new Circle + { + Number = number, + X = (int)(ox * CoordScale) + MarginX, + Y = (int)(oy * CoordScale) + MarginY, + OriginalX = ox, + OriginalY = oy, + OriginalZ = oz, + Inner = (number % 1 != 0) + }); + } + + return circles; + } +} \ No newline at end of file diff --git a/programdeltafixer/Logger.cs b/programdeltafixer/Logger.cs new file mode 100644 index 0000000..85b0efe --- /dev/null +++ b/programdeltafixer/Logger.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Text; + +/// Append-only text log next to the .exe: logs/ears-YYYYMMDD.log +public static class Logger +{ + private static readonly object _gate = new object(); + private static string _path; + private static bool _failed; + + public static void Write(string message) + { + Write(null, message); + } + + public static void Write(string tag, string message) + { + if (_failed) return; + + string line = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") + + (string.IsNullOrEmpty(tag) ? " " : " [" + tag + "] ") + + message; + + lock (_gate) + { + try + { + if (_path == null) _path = Init(); + File.AppendAllText(_path, line + Environment.NewLine, Encoding.UTF8); + } + catch (Exception ex) + { + _failed = true; // never let logging crash the app + Console.WriteLine("[Logger] disabled: " + ex.Message); + } + } + } + + public static void Write(string tag, string format, params object[] args) + { + Write(tag, string.Format(format, args)); + } + + public static void Exception(string tag, Exception ex) + { + Write(tag, ex.GetType().Name + ": " + ex.Message); + Write(tag, ex.StackTrace ?? "(no stack)"); + } + + private static string Init() + { + string dir = Path.Combine( + Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), + "logs"); + + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, "ears-" + DateTime.Now.ToString("yyyyMMdd") + ".log"); + + File.AppendAllText(path, + Environment.NewLine + + "=== session start " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + + " (" + Environment.OSVersion.Platform + ") ===" + Environment.NewLine, + Encoding.UTF8); + + return path; + } +} \ No newline at end of file diff --git a/programdeltafixer/SoundLooper.cs b/programdeltafixer/SoundLooper.cs new file mode 100644 index 0000000..311d4b6 --- /dev/null +++ b/programdeltafixer/SoundLooper.cs @@ -0,0 +1,165 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Media; +using System.Threading; + +public class SoundLooper : IDisposable +{ + // ── unix backend ── + private Process _proc; + private Thread _thread; + private volatile bool _running; + private string _path; + + // ── windows backend ── + private SoundPlayer _player; + + public event Action Log; + + public bool IsPlaying { get { return _running; } } + + private static bool IsWindows + { + get + { + var p = Environment.OSVersion.Platform; + return p != PlatformID.Unix && p != PlatformID.MacOSX; + } + } + + public void Start(string wavPath) + { + Stop(); + + if (string.IsNullOrEmpty(wavPath) || !File.Exists(wavPath)) + { + Emit("Sound file not found: " + wavPath); + return; + } + + _path = wavPath; + + if (IsWindows) StartWindows(); + else StartUnix(); + } + + public void Stop() + { + if (!_running) return; + _running = false; + + if (_player != null) + { + try { _player.Stop(); } catch { } + try { _player.Dispose(); } catch { } + _player = null; + } + + try + { + var p = _proc; + if (p != null && !p.HasExited) p.Kill(); + } + catch { } + _proc = null; + + if (_thread != null && _thread.IsAlive) _thread.Join(500); + _thread = null; + + Emit("Looping stopped"); + } + + // ── windows: SoundPlayer loops natively, no thread needed ────────── + + private void StartWindows() + { + try + { + _player = new SoundPlayer(_path); + _player.Load(); // throws here if the WAV isn't plain PCM + _player.PlayLooping(); // gapless, runs until Stop() + _running = true; + Emit("Looping started (SoundPlayer): " + _path); + } + catch (Exception ex) + { + Emit("SoundPlayer failed for " + _path + ": " + ex.Message); + _player = null; + _running = false; + } + } + + // ── unix: respawn a CLI player each pass ─────────────────────────── + + private void StartUnix() + { + _running = true; + _thread = new Thread(LoopWorker); + _thread.IsBackground = true; + _thread.Start(); + Emit("Looping started (" + PlayerCommand() + "): " + _path); + } + + private void LoopWorker() + { + while (_running) + { + try + { + var psi = new ProcessStartInfo + { + FileName = PlayerCommand(), + Arguments = "\"" + _path + "\"", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + + _proc = Process.Start(psi); + _proc.WaitForExit(); + } + catch (Exception ex) + { + Emit("Playback error: " + ex.Message); + _running = false; + return; + } + } + } + + private static string _player_cmd; + private static string PlayerCommand() + { + if (_player_cmd != null) return _player_cmd; + _player_cmd = Exists("paplay") ? "paplay" : "aplay"; + return _player_cmd; + } + + private static bool Exists(string cmd) + { + try + { + var p = Process.Start(new ProcessStartInfo + { + FileName = "which", + Arguments = cmd, + UseShellExecute = false, + RedirectStandardOutput = true + }); + p.WaitForExit(); + return p.ExitCode == 0; + } + catch { return false; } + } + + private void Emit(string msg) + { + var h = Log; + if (h != null) h(msg); + else Console.WriteLine("[SoundLooper] " + msg); + } + + public void Dispose() { Stop(); } +} \ No newline at end of file diff --git a/programdeltafixer/SoundWindow.cs b/programdeltafixer/SoundWindow.cs new file mode 100644 index 0000000..e8b4488 --- /dev/null +++ b/programdeltafixer/SoundWindow.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Gtk; +using UI = Gtk.Builder.ObjectAttribute; + +/// Controller for notebook page 2 (SoundBox). Not a Gtk.Window — same +/// arrangement as SettingsWindow: it drives widgets inside the shared root. +public class SoundWindow +{ + [UI] private Label ConditionNameLabel = null; + [UI] private Grid SoundButtonGrid = null; + [UI] private Button SoundBackButton = null; + + private const int Columns = 2; + + private readonly List _allCases; + private readonly List _path = new List(); + + /// Raised when the drill-down reaches a leaf case. + public event EventHandler CaseSelected; + + /// Raised when Back is pressed at the top level. + public event EventHandler BackRequested; + + public SoundWindow(Builder builder) + { + builder.Autoconnect(this); + if (ConditionNameLabel == null || SoundButtonGrid == null || SoundBackButton == null) + throw new InvalidOperationException( + "Glade id mismatch — ConditionNameLabel=" + (ConditionNameLabel != null) + + " SoundButtonGrid=" + (SoundButtonGrid != null) + + " SoundBackButton=" + (SoundBackButton != null)); + + _allCases = CaseDefinition.LoadCasesFromCsv(AppFile("cases.csv")); + if (_allCases.Count == 0) + Console.WriteLine("WARNING: no cases loaded — check cases.csv"); + + SoundBackButton.Clicked += OnBackClicked; + } + + /// Call every time the page becomes visible. + public void Reset() + { + _path.Clear(); + Render(); + } + + /// Resolve next to the .exe, NOT the working directory. + public static string AppFile(string relative) + { + string dir = Path.GetDirectoryName( + System.Reflection.Assembly.GetExecutingAssembly().Location); + return Path.Combine(dir, relative); + } + + // ── one method covers all three levels ───────────────────────────── + + private void Render() + { + var matches = _allCases.Where(MatchesPath).ToList(); + + // Distinct values one level deeper than where we currently are. + var options = matches + .Where(c => c.TreePath.Length > _path.Count) + .Select(c => c.TreePath[_path.Count]) + .Distinct() + .ToList(); + + ConditionNameLabel.Text = _path.Count == 0 + ? "種別を選択" + : string.Join(" : ", _path); + + BuildButtons(options, picked => + { + _path.Add(picked); + + // Landed on a leaf? Play it and stay put. + var leaf = _allCases.FirstOrDefault( + c => c.TreePath.Length == _path.Count && MatchesPath(c)); + + if (leaf != null) + { + Play(leaf); + _path.RemoveAt(_path.Count - 1); + return; + } + + Render(); + }); + } + + private bool MatchesPath(CaseDefinition c) + { + string[] p = c.TreePath; + if (p.Length < _path.Count) return false; + for (int i = 0; i < _path.Count; i++) + if (!string.Equals(p[i], _path[i], StringComparison.Ordinal)) return false; + return true; + } + + private void BuildButtons(IEnumerable labels, Action onPick) + { + foreach (var child in SoundButtonGrid.Children) + { + SoundButtonGrid.Remove(child); + child.Destroy(); + } + + int i = 0; + foreach (string text in labels) + { + string captured = text; // don't close over the loop variable + var btn = new Button(captured); + btn.Hexpand = true; + btn.Clicked += (s, e) => onPick(captured); + SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1); + i++; + } + + SoundButtonGrid.ShowAll(); // widgets made in code start hidden + } + + private void Play(CaseDefinition c) + { + if (string.IsNullOrWhiteSpace(c.SoundPath)) + { + Console.WriteLine("No Sound_File for case " + c.Number); + return; + } + + WavePlayer.Stop(); // the map page owns audio from here on + + var h = CaseSelected; + if (h != null) h(this, c); + + // string full = AppFile(c.SoundPath); // "sound/SND200.wav" -> absolute + // if (!File.Exists(full)) + // { + // Console.WriteLine("Sound file missing: " + full); + // return; + // } + + // ConditionNameLabel.Text = string.Join(" : ", _path); + // WavePlayer.Play(full); + } + + private void OnBackClicked(object sender, EventArgs e) + { + if (_path.Count > 0) + { + _path.RemoveAt(_path.Count - 1); + Render(); + } + else + { + WavePlayer.Stop(); + if (BackRequested != null) BackRequested(this, EventArgs.Empty); + } + } +} \ No newline at end of file diff --git a/programdeltafixer/WavePlayer.cs b/programdeltafixer/WavePlayer.cs new file mode 100644 index 0000000..00cd07a --- /dev/null +++ b/programdeltafixer/WavePlayer.cs @@ -0,0 +1,60 @@ +using System; +using System.Diagnostics; +using System.IO; + +public static class WavePlayer +{ + private static Process _current; + + private static bool IsUnix + { + get + { + int p = (int)Environment.OSVersion.Platform; + return p == 4 || p == 6 || p == 128; + } + } + + public static void Play(string absolutePath) + { + Stop(); + + if (!IsUnix) + { + try + { + var sp = new System.Media.SoundPlayer(absolutePath); + sp.Play(); + } + catch (Exception ex) { Console.WriteLine("Playback failed: " + ex.Message); } + return; + } + + foreach (string player in new[] { "paplay", "aplay" }) + { + try + { + var psi = new ProcessStartInfo(player, "\"" + absolutePath + "\"") + { + UseShellExecute = false, + RedirectStandardError = true + }; + _current = Process.Start(psi); + return; + } + catch { /* not installed, try the next one */ } + } + + Console.WriteLine("No audio player found — install pulseaudio-utils or alsa-utils"); + } + + public static void Stop() + { + try + { + if (_current != null && !_current.HasExited) _current.Kill(); + } + catch { } + _current = null; + } +} \ No newline at end of file diff --git a/programdeltafixer/calibrate.glade b/programdeltafixer/calibrate.glade new file mode 100644 index 0000000..66eccc6 --- /dev/null +++ b/programdeltafixer/calibrate.glade @@ -0,0 +1,144 @@ + + + + + False + EARS — Calibration + 1280 + 800 + + + True + horizontal + 10 + + + True + True + 380 + never + + + True + + + True + vertical + 6 + 10 + 10 + 10 + 10 + + + + + + + False + True + 0 + + + + + True + vertical + 6 + True + 10 + 10 + 10 + + + True + True + True + + + True + True + 0 + + + + + True + 6 + + + Dump C# constants + True + True + True + + + True + True + 0 + + + + + Save calib.txt + True + True + True + + + True + True + 1 + + + + + Reset view + True + True + True + + + False + True + 2 + + + + + False + True + 1 + + + + + True + True + 170 + in + + + True + False + True + + + + + False + True + 2 + + + + + True + True + 1 + + + + + + \ No newline at end of file diff --git a/programdeltafixer/program.cs b/programdeltafixer/program.cs new file mode 100644 index 0000000..46b4c1a --- /dev/null +++ b/programdeltafixer/program.cs @@ -0,0 +1,23 @@ +using System; +using System.IO; +using System.Reflection; +using Gtk; + +class Program +{ + [STAThread] + static void Main(string[] args) + { + // Resolve every relative path (calibrate.glade, map/, cases.csv, + // JacketData/) against the exe's folder, not the launch directory. + Directory.SetCurrentDirectory( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); + + Application.Init(); + + var app = new CalibrationWindow(); + app.ShowAll(); + + Application.Run(); + } +} \ No newline at end of file