host.controller_config.spline_editor

Spline editor dialog for analog action response curves.

Visual cubic hermite spline editor that maps joystick input (X: -1 to 1) to output (Y: -1 to 1). Users can add, remove, and drag control points to shape the response curve.

The evaluation uses standard cubic hermite interpolation, mathematically identical to wpimath.spline.CubicHermiteSpline. For robot-side evaluation, construct each segment between adjacent control points as::

from wpimath.spline import CubicHermiteSpline

dx = x1 - x0
spline = CubicHermiteSpline(
    (x0, dx), (x1, dx),           # x control vectors (linear in x)
    (y0, m0 * dx), (y1, m1 * dx)  # y control vectors (shaped output)
)
pose, curvature = spline.getPoint(t)  # t = (x - x0) / dx
output = pose.y                        # the mapped output value

Control point data format (stored in ActionDefinition.extra)::

action.extra["spline_points"] = [
    {"x": -1.0, "y": -1.0, "tangent": 1.0},
    {"x":  0.0, "y":  0.0, "tangent": 1.0},
    {"x":  1.0, "y":  1.0, "tangent": 1.0},
]

Endpoints (x=-1 and x=1) are always present; intermediate points can be added or removed freely.

  1"""Spline editor dialog for analog action response curves.
  2
  3Visual cubic hermite spline editor that maps joystick input (X: -1 to 1)
  4to output (Y: -1 to 1). Users can add, remove, and drag control points
  5to shape the response curve.
  6
  7The evaluation uses standard cubic hermite interpolation, mathematically
  8identical to wpimath.spline.CubicHermiteSpline. For robot-side evaluation,
  9construct each segment between adjacent control points as::
 10
 11    from wpimath.spline import CubicHermiteSpline
 12
 13    dx = x1 - x0
 14    spline = CubicHermiteSpline(
 15        (x0, dx), (x1, dx),           # x control vectors (linear in x)
 16        (y0, m0 * dx), (y1, m1 * dx)  # y control vectors (shaped output)
 17    )
 18    pose, curvature = spline.getPoint(t)  # t = (x - x0) / dx
 19    output = pose.y                        # the mapped output value
 20
 21Control point data format (stored in ActionDefinition.extra)::
 22
 23    action.extra["spline_points"] = [
 24        {"x": -1.0, "y": -1.0, "tangent": 1.0},
 25        {"x":  0.0, "y":  0.0, "tangent": 1.0},
 26        {"x":  1.0, "y":  1.0, "tangent": 1.0},
 27    ]
 28
 29Endpoints (x=-1 and x=1) are always present; intermediate points
 30can be added or removed freely.
 31"""
 32
 33import math
 34import tkinter as tk
 35from copy import deepcopy
 36from tkinter import ttk, filedialog, messagebox
 37
 38import yaml
 39
 40from host.controller_config.colors import (
 41    BG_WHITE,
 42    CURVE_LINE,
 43    ENDPOINT_FILL,
 44    HANDLE_FILL,
 45    HANDLE_LINE,
 46    POINT_FILL,
 47    POINT_OUTLINE,
 48)
 49from host.controller_config.editor_utils import (
 50    DIALOG_H,
 51    DIALOG_MARGIN,
 52    DIALOG_PLOT_H,
 53    DIALOG_PLOT_W,
 54    DIALOG_W,
 55    UndoStack,
 56    draw_editor_grid,
 57)
 58
 59# Visual sizes (pixels)
 60_POINT_RADIUS = 7
 61_HANDLE_RADIUS = 5
 62_HANDLE_LENGTH = 50
 63_CURVE_SAMPLES_PER_SEG = 80
 64
 65# Minimum gap between adjacent control point X positions
 66_MIN_X_GAP = 0.04
 67
 68_DEFAULT_STATUS = ("Click to add point | Right-click to remove | "
 69                   "Drag to adjust")
 70
 71
 72# ---------------------------------------------------------------------------
 73#   Spline math (canonical implementations in utils/math/curves.py)
 74# ---------------------------------------------------------------------------
 75
 76from utils.math.curves import (
 77    evaluate_spline,
 78    default_spline_points as default_points,
 79    numerical_slope as _numerical_slope,
 80)
 81
 82
 83# ---------------------------------------------------------------------------
 84#   SplineEditorDialog
 85# ---------------------------------------------------------------------------
 86
 87class SplineEditorDialog(tk.Toplevel):
 88    """Modal dialog for visually editing a cubic hermite spline curve.
 89
 90    Interactions:
 91      - Left-click on empty space: add a new control point
 92      - Right-click on a point: remove it (not endpoints)
 93      - Drag a red control point: move Y (endpoints) or X+Y (intermediate)
 94      - Drag a green tangent handle: adjust slope at that point
 95    """
 96
 97    def __init__(self, parent, points: list[dict],
 98                 other_curves: dict[str, list[dict]] | None = None,
 99                 scale: float = 1.0, inversion: bool = False):
100        """
101        Args:
102            parent: parent window
103            points: initial control points
104            other_curves: optional {action_name: points} for "Copy from..."
105            scale: action scale factor for processed display
106            inversion: action inversion flag for processed display
107        """
108        super().__init__(parent)
109        self.title("Spline Response Curve Editor")
110        self.transient(parent)
111        self.resizable(False, False)
112
113        self._points = [dict(p) for p in points]
114        self._points.sort(key=lambda p: p["x"])
115        self._result = None
116        self._symmetric = False
117        self._other_curves = other_curves or {}
118        self._scale = scale
119        self._inversion = inversion
120
121        # Drag state
122        self._drag_type = None   # "point" or "handle"
123        self._drag_idx = None
124        self._drag_side = None   # "in" or "out"
125
126        # Undo stack
127        self._undo = UndoStack()
128        self._drag_undo_pushed = False
129
130        self._show_processed = False
131
132        self._build_ui()
133        self._draw()
134
135        # Center on the parent window (follows it across monitors)
136        self.update_idletasks()
137        pw = parent.winfo_width()
138        ph = parent.winfo_height()
139        px = parent.winfo_rootx()
140        py = parent.winfo_rooty()
141        dw = self.winfo_reqwidth()
142        dh = self.winfo_reqheight()
143        x = px + (pw - dw) // 2
144        y = py + (ph - dh) // 2
145        self.geometry(f"+{x}+{y}")
146
147        self.grab_set()
148        self.protocol("WM_DELETE_WINDOW", self._on_cancel)
149        self.focus_set()
150
151    def get_result(self) -> list[dict] | None:
152        """Block until dialog closes. Returns points list or None."""
153        self.wait_window()
154        return self._result
155
156    # ------------------------------------------------------------------
157    # Display scale
158    # ------------------------------------------------------------------
159
160    @property
161    def _display_scale(self) -> float:
162        """Scale factor for displayed Y values when processed view is on."""
163        if self._show_processed:
164            s = self._scale
165            if self._inversion:
166                s = -s
167            return s
168        return 1.0
169
170    # ------------------------------------------------------------------
171    # Undo
172    # ------------------------------------------------------------------
173
174    def _push_undo(self):
175        """Save current points to undo stack."""
176        self._undo.push(self._points)
177
178    def _pop_undo(self):
179        """Restore previous points from undo stack."""
180        state = self._undo.pop()
181        if state is None:
182            self._status_var.set("Nothing to undo")
183            return
184        self._points = state
185        self._draw()
186        self._status_var.set(f"Undo ({len(self._undo)} remaining)")
187
188    # ------------------------------------------------------------------
189    # Import / Export / Copy
190    # ------------------------------------------------------------------
191
192    def _on_export(self):
193        """Export current curve points to a YAML file."""
194        path = filedialog.asksaveasfilename(
195            parent=self, title="Export Spline Curve",
196            defaultextension=".yaml",
197            filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")])
198        if not path:
199            return
200        data = {"type": "spline", "points": deepcopy(self._points)}
201        with open(path, "w") as f:
202            yaml.dump(data, f, default_flow_style=False, sort_keys=False)
203        self._status_var.set(f"Exported to {path}")
204
205    def _on_import(self):
206        """Import curve points from a YAML file."""
207        path = filedialog.askopenfilename(
208            parent=self, title="Import Spline Curve",
209            filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")])
210        if not path:
211            return
212        try:
213            with open(path) as f:
214                data = yaml.safe_load(f)
215        except Exception as exc:
216            messagebox.showerror("Import Failed",
217                                 f"Could not read YAML file:\n{exc}",
218                                 parent=self)
219            return
220
221        if isinstance(data, dict):
222            points = data.get("points", [])
223        elif isinstance(data, list):
224            points = data
225        else:
226            messagebox.showerror(
227                "Import Failed",
228                "File does not contain curve data.\n"
229                "Expected a 'points' list of {{x, y, tangent}} entries.",
230                parent=self)
231            return
232
233        if not points:
234            messagebox.showerror(
235                "Import Failed",
236                "No points found in file.\n"
237                "Expected a 'points' key containing a list.",
238                parent=self)
239            return
240
241        if not isinstance(points, list) or not all(
242                isinstance(p, dict) and "x" in p and "y" in p
243                for p in points):
244            messagebox.showerror(
245                "Import Failed",
246                "Invalid point data. Each point must have "
247                "'x' and 'y' fields.",
248                parent=self)
249            return
250
251        # Ensure tangent field exists for spline points
252        for p in points:
253            if "tangent" not in p:
254                p["tangent"] = 1.0
255        self._push_undo()
256        self._points = [dict(p) for p in points]
257        self._points.sort(key=lambda p: p["x"])
258        self._draw()
259        self._status_var.set(f"Imported from {path}")
260
261    def _on_copy_from(self):
262        """Copy curve data from another action."""
263        if not self._other_curves:
264            return
265        win = tk.Toplevel(self)
266        win.title("Copy Curve From...")
267        win.transient(self)
268        win.grab_set()
269        win.resizable(False, False)
270
271        ttk.Label(win, text="Select an action to copy its curve:",
272                  padding=5).pack(anchor=tk.W)
273        listbox = tk.Listbox(win, height=min(10, len(self._other_curves)),
274                             width=40)
275        listbox.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)
276        names = sorted(self._other_curves.keys())
277        for name in names:
278            listbox.insert(tk.END, name)
279
280        def on_ok():
281            sel = listbox.curselection()
282            if not sel:
283                return
284            chosen = names[sel[0]]
285            pts = self._other_curves[chosen]
286            for p in pts:
287                if "tangent" not in p:
288                    p["tangent"] = 1.0
289            self._push_undo()
290            self._points = [dict(p) for p in pts]
291            self._points.sort(key=lambda p: p["x"])
292            self._draw()
293            self._status_var.set(f"Copied curve from {chosen}")
294            win.destroy()
295
296        listbox.bind("<Double-1>", lambda e: on_ok())
297        bf = ttk.Frame(win)
298        bf.pack(fill=tk.X, padx=10, pady=(0, 10))
299        ttk.Button(bf, text="OK", command=on_ok).pack(side=tk.RIGHT, padx=5)
300        ttk.Button(bf, text="Cancel",
301                   command=win.destroy).pack(side=tk.RIGHT)
302
303        # Center on parent editor dialog
304        win.update_idletasks()
305        px, py = self.winfo_rootx(), self.winfo_rooty()
306        pw, ph = self.winfo_width(), self.winfo_height()
307        ww, wh = win.winfo_width(), win.winfo_height()
308        win.geometry(f"+{px + (pw - ww) // 2}+{py + (ph - wh) // 2}")
309
310    # ------------------------------------------------------------------
311    # UI
312    # ------------------------------------------------------------------
313
314    def _build_ui(self):
315        self._canvas = tk.Canvas(
316            self, width=DIALOG_W, height=DIALOG_H,
317            bg=BG_WHITE, cursor="crosshair")
318        self._canvas.pack(padx=10, pady=(10, 5))
319
320        self._canvas.bind("<ButtonPress-1>", self._on_press)
321        self._canvas.bind("<B1-Motion>", self._on_drag)
322        self._canvas.bind("<ButtonRelease-1>", self._on_release)
323        self._canvas.bind("<Button-3>", self._on_right_click)
324
325        self._status_var = tk.StringVar(value=_DEFAULT_STATUS)
326        ttk.Label(self, textvariable=self._status_var,
327                  relief=tk.SUNKEN, anchor=tk.W).pack(fill=tk.X, padx=10)
328
329        # Top button row: file and copy operations
330        top_btn = ttk.Frame(self)
331        top_btn.pack(fill=tk.X, padx=10, pady=(5, 0))
332        ttk.Button(top_btn, text="Export YAML",
333                   command=self._on_export).pack(side=tk.LEFT, padx=5)
334        ttk.Button(top_btn, text="Import YAML",
335                   command=self._on_import).pack(side=tk.LEFT, padx=5)
336        if self._other_curves:
337            ttk.Button(top_btn, text="Copy from...",
338                       command=self._on_copy_from).pack(side=tk.LEFT, padx=5)
339
340        # Bottom button row: edit operations
341        btn = ttk.Frame(self)
342        btn.pack(fill=tk.X, padx=10, pady=(5, 10))
343        ttk.Button(btn, text="Reset to Linear",
344                   command=self._on_reset).pack(side=tk.LEFT, padx=5)
345        ttk.Button(btn, text="Undo",
346                   command=self._pop_undo).pack(side=tk.LEFT, padx=5)
347        self._sym_var = tk.BooleanVar()
348        ttk.Checkbutton(btn, text="Symmetry", variable=self._sym_var,
349                        command=self._on_symmetry_toggle
350                        ).pack(side=tk.LEFT, padx=5)
351        self._proc_var = tk.BooleanVar(value=False)
352        self._proc_cb = ttk.Checkbutton(
353            btn, text="Show Processed",
354            variable=self._proc_var,
355            command=self._on_processed_toggle)
356        self._proc_cb.pack(side=tk.LEFT, padx=5)
357        ttk.Button(btn, text="Cancel",
358                   command=self._on_cancel).pack(side=tk.RIGHT, padx=5)
359        ttk.Button(btn, text="OK",
360                   command=self._on_ok).pack(side=tk.RIGHT, padx=5)
361
362        self.bind("<Control-z>", lambda e: self._pop_undo())
363
364    # ------------------------------------------------------------------
365    # Coordinate conversion
366    # ------------------------------------------------------------------
367
368    @property
369    def _y_extent(self) -> float:
370        """Half-range for Y axis, expanded to fit scaled values."""
371        return max(1.0, abs(self._display_scale))
372
373    def _d2c(self, x: float, y: float) -> tuple[float, float]:
374        """Data to canvas pixels."""
375        ext = self._y_extent
376        cx = DIALOG_MARGIN + (x + 1) / 2 * DIALOG_PLOT_W
377        cy = DIALOG_MARGIN + (ext - y) / (2 * ext) * DIALOG_PLOT_H
378        return cx, cy
379
380    def _c2d(self, cx: float, cy: float) -> tuple[float, float]:
381        """Canvas pixels to data."""
382        ext = self._y_extent
383        x = (cx - DIALOG_MARGIN) / DIALOG_PLOT_W * 2 - 1
384        y = ext - (cy - DIALOG_MARGIN) / DIALOG_PLOT_H * (2 * ext)
385        return x, y
386
387    def _tangent_offset(self, tangent: float) -> tuple[float, float]:
388        """Tangent slope to canvas-pixel offset for handle drawing."""
389        ext = self._y_extent
390        ppx = DIALOG_PLOT_W / 2       # pixels per data unit, X
391        ppy = DIALOG_PLOT_H / (2 * ext)  # pixels per data unit, Y
392        vis_tangent = tangent * self._display_scale
393        dx = 1.0 * ppx
394        dy = -vis_tangent * ppy  # canvas Y inverted
395        length = math.hypot(dx, dy)
396        if length < 1e-6:
397            return float(_HANDLE_LENGTH), 0.0
398        s = _HANDLE_LENGTH / length
399        return dx * s, dy * s
400
401    def _offset_to_tangent(self, dx: float, dy: float) -> float:
402        """Canvas-pixel offset back to raw tangent slope (un-scaled)."""
403        ext = self._y_extent
404        ppx = DIALOG_PLOT_W / 2
405        ppy = DIALOG_PLOT_H / (2 * ext)
406        data_dx = dx / ppx
407        data_dy = -dy / ppy
408        if abs(data_dx) < 1e-6:
409            return 10.0 if data_dy > 0 else -10.0
410        vis_tangent = data_dy / data_dx
411        s = self._display_scale
412        if abs(s) < 1e-6:
413            return vis_tangent
414        return vis_tangent / s
415
416    # ------------------------------------------------------------------
417    # Drawing
418    # ------------------------------------------------------------------
419
420    def _draw(self):
421        c = self._canvas
422        c.delete("all")
423        self._draw_grid()
424        self._draw_curve()
425        self._draw_handles()
426        self._draw_points()
427
428    def _draw_grid(self):
429        ext = self._y_extent
430        draw_editor_grid(self._canvas, self._d2c,
431                         DIALOG_MARGIN, DIALOG_PLOT_W, DIALOG_PLOT_H,
432                         DIALOG_W, DIALOG_H,
433                         y_min=-ext, y_max=ext)
434
435    def _draw_curve(self):
436        pts = self._points
437        if len(pts) < 2:
438            return
439        s = self._display_scale
440        n = _CURVE_SAMPLES_PER_SEG * (len(pts) - 1)
441        x_min, x_max = pts[0]["x"], pts[-1]["x"]
442        coords = []
443        for i in range(n + 1):
444            x = x_min + (x_max - x_min) * i / n
445            y = max(-1.5, min(1.5, evaluate_spline(pts, x) * s))
446            cx, cy = self._d2c(x, y)
447            coords.extend([cx, cy])
448        if len(coords) >= 4:
449            self._canvas.create_line(
450                *coords, fill=CURVE_LINE, width=2, smooth=False)
451
452    def _draw_handles(self):
453        c = self._canvas
454        s = self._display_scale
455        for i, pt in enumerate(self._points):
456            cx, cy = self._d2c(pt["x"], pt["y"] * s)
457            hdx, hdy = self._tangent_offset(pt["tangent"])
458            c.create_line(cx - hdx, cy - hdy, cx + hdx, cy + hdy,
459                          fill=HANDLE_LINE, width=1, dash=(4, 4))
460            if i > 0:
461                hx, hy = cx - hdx, cy - hdy
462                c.create_oval(hx - _HANDLE_RADIUS, hy - _HANDLE_RADIUS,
463                              hx + _HANDLE_RADIUS, hy + _HANDLE_RADIUS,
464                              fill=HANDLE_FILL, outline="#308030")
465            if i < len(self._points) - 1:
466                hx, hy = cx + hdx, cy + hdy
467                c.create_oval(hx - _HANDLE_RADIUS, hy - _HANDLE_RADIUS,
468                              hx + _HANDLE_RADIUS, hy + _HANDLE_RADIUS,
469                              fill=HANDLE_FILL, outline="#308030")
470
471    def _draw_points(self):
472        c = self._canvas
473        s = self._display_scale
474        for i, pt in enumerate(self._points):
475            cx, cy = self._d2c(pt["x"], pt["y"] * s)
476            is_endpoint = (i == 0 or i == len(self._points) - 1)
477            is_mirror = (self._symmetric
478                         and pt["x"] < -_MIN_X_GAP / 2)
479            if is_mirror:
480                fill = "#c0a0a0"   # muted — auto-mirrored, not draggable
481            elif is_endpoint:
482                fill = ENDPOINT_FILL
483            else:
484                fill = POINT_FILL
485            c.create_oval(cx - _POINT_RADIUS, cy - _POINT_RADIUS,
486                          cx + _POINT_RADIUS, cy + _POINT_RADIUS,
487                          fill=fill, outline=POINT_OUTLINE, width=2)
488
489    # ------------------------------------------------------------------
490    # Hit testing & interaction
491    # ------------------------------------------------------------------
492
493    def _hit_test(self, cx, cy):
494        """Find what element is at canvas position (cx, cy).
495
496        Returns (type, index, side) or None.
497        When symmetry is on, negative-side mirrors are not interactive.
498        """
499        s = self._display_scale
500        # Check handles first (smaller targets, higher priority)
501        for i, pt in enumerate(self._points):
502            if self._symmetric and pt["x"] < -_MIN_X_GAP / 2:
503                continue
504            px, py = self._d2c(pt["x"], pt["y"] * s)
505            hdx, hdy = self._tangent_offset(pt["tangent"])
506            if i < len(self._points) - 1:
507                if math.hypot(cx - (px + hdx),
508                              cy - (py + hdy)) <= _HANDLE_RADIUS + 3:
509                    return ("handle", i, "out")
510            if i > 0:
511                if math.hypot(cx - (px - hdx),
512                              cy - (py - hdy)) <= _HANDLE_RADIUS + 3:
513                    return ("handle", i, "in")
514        # Then check points
515        for i, pt in enumerate(self._points):
516            if self._symmetric and pt["x"] < -_MIN_X_GAP / 2:
517                continue
518            px, py = self._d2c(pt["x"], pt["y"] * s)
519            if math.hypot(cx - px, cy - py) <= _POINT_RADIUS + 3:
520                return ("point", i, None)
521        return None
522
523    def _is_endpoint(self, idx: int) -> bool:
524        """True if point at *idx* is the first or last point."""
525        return idx == 0 or idx == len(self._points) - 1
526
527    def _add_point_at(self, cx, cy):
528        """Add a new control point at canvas position."""
529        x, vis_y = self._c2d(cx, cy)
530
531        # When symmetric, only allow adding on the positive side
532        if self._symmetric and x < -_MIN_X_GAP / 2:
533            self._status_var.set(
534                "Add points on the positive side (symmetry)")
535            return
536
537        x_min = self._points[0]["x"]
538        x_max = self._points[-1]["x"]
539        if x <= x_min + _MIN_X_GAP or x >= x_max - _MIN_X_GAP:
540            return
541        # Un-scale the visual Y to get the raw point value
542        s = self._display_scale
543        y = vis_y / s if abs(s) > 1e-6 else vis_y
544        y = max(-1.0, min(1.0, y))
545
546        # Don't add if too close to an existing point
547        for pt in self._points:
548            if abs(pt["x"] - x) < _MIN_X_GAP:
549                return
550
551        self._push_undo()
552        tangent = _numerical_slope(self._points, x)
553        self._points.append({
554            "x": round(x, 3),
555            "y": round(y, 3),
556            "tangent": round(tangent, 3),
557        })
558        self._points.sort(key=lambda p: p["x"])
559
560        if self._symmetric:
561            self._enforce_symmetry()
562
563        self._draw()
564        self._status_var.set(
565            f"Added point at x={x:.2f} ({len(self._points)} points)")
566
567    def _remove_point(self, idx):
568        """Remove the control point at *idx* (not endpoints)."""
569        if self._is_endpoint(idx) or len(self._points) <= 2:
570            return
571        self._push_undo()
572        self._points.pop(idx)
573        if self._symmetric:
574            self._enforce_symmetry()
575        self._draw()
576        self._status_var.set(
577            f"Removed point ({len(self._points)} points)")
578
579    def _on_press(self, event):
580        hit = self._hit_test(event.x, event.y)
581        if hit:
582            self._drag_type, self._drag_idx, self._drag_side = hit
583            self._drag_undo_pushed = False
584        else:
585            self._drag_type = None
586            self._add_point_at(event.x, event.y)
587
588    def _on_drag(self, event):
589        if self._drag_type is None:
590            return
591        if not self._drag_undo_pushed:
592            self._push_undo()
593            self._drag_undo_pushed = True
594        i = self._drag_idx
595        pt = self._points[i]
596
597        if self._drag_type == "point":
598            _, vis_y = self._c2d(event.x, event.y)
599            s = self._display_scale
600            raw_y = vis_y / s if abs(s) > 1e-6 else vis_y
601            # Center point with symmetry: y locked to 0
602            if self._symmetric and abs(pt["x"]) < _MIN_X_GAP / 2:
603                pt["y"] = 0.0
604            else:
605                pt["y"] = round(max(-1.0, min(1.0, raw_y)), 3)
606            # Intermediate points: also move X
607            if not self._is_endpoint(i):
608                x, _ = self._c2d(event.x, event.y)
609                x_lo = self._points[i - 1]["x"] + _MIN_X_GAP
610                x_hi = self._points[i + 1]["x"] - _MIN_X_GAP
611                # With symmetry, keep positive points on positive side
612                if self._symmetric and pt["x"] > 0:
613                    x_lo = max(x_lo, _MIN_X_GAP)
614                pt["x"] = round(max(x_lo, min(x_hi, x)), 3)
615        elif self._drag_type == "handle":
616            cx, cy = self._d2c(pt["x"], pt["y"])
617            dx, dy = event.x - cx, event.y - cy
618            if self._drag_side == "in":
619                dx, dy = -dx, -dy
620            if math.hypot(dx, dy) > 5:
621                t = self._offset_to_tangent(dx, dy)
622                pt["tangent"] = round(max(-10.0, min(10.0, t)), 3)
623
624        if self._symmetric:
625            self._enforce_symmetry()
626            # Re-find drag index (positive points keep identity)
627            for j, p in enumerate(self._points):
628                if p is pt:
629                    self._drag_idx = j
630                    break
631
632        self._status_var.set(
633            f"Point {self._drag_idx}: x={pt['x']:.2f}  y={pt['y']:.3f}  "
634            f"tangent={pt['tangent']:.3f}")
635        self._draw()
636
637    def _on_release(self, event):
638        self._drag_type = None
639        self._status_var.set(_DEFAULT_STATUS)
640
641    def _on_right_click(self, event):
642        """Remove a control point on right-click."""
643        hit = self._hit_test(event.x, event.y)
644        if not hit or hit[0] != "point":
645            return
646        i = hit[1]
647        if self._is_endpoint(i):
648            self._status_var.set("Cannot remove endpoints")
649            return
650        if len(self._points) <= 2:
651            self._status_var.set("Need at least 2 points")
652            return
653        # Clear any in-progress drag to avoid stale index
654        self._drag_type = None
655        self._remove_point(i)
656
657    # ------------------------------------------------------------------
658    # Symmetry
659    # ------------------------------------------------------------------
660
661    def _on_symmetry_toggle(self):
662        """Handle the Symmetry checkbox toggle."""
663        self._push_undo()
664        self._symmetric = self._sym_var.get()
665        if self._symmetric:
666            self._enforce_symmetry()
667            self._draw()
668            self._status_var.set("Symmetry on — edit positive side")
669
670    def _enforce_symmetry(self):
671        """Rebuild negative-side points as odd mirrors of positive side.
672
673        Positive-side points (x > 0) are the source of truth.
674        Center point (x ~ 0) gets y locked to 0.
675        Negative-side points are recreated as mirrors.
676        Positive and center dict objects keep their identity so
677        in-progress drag indices can be recovered.
678        """
679        positive = [pt for pt in self._points
680                    if pt["x"] > _MIN_X_GAP / 2]
681        center = None
682        for pt in self._points:
683            if abs(pt["x"]) < _MIN_X_GAP / 2:
684                center = pt
685                break
686
687        if center is None:
688            center = {"x": 0.0, "y": 0.0, "tangent": 1.0}
689        else:
690            center["x"] = 0.0
691            center["y"] = 0.0
692
693        new_points = []
694        for pt in reversed(positive):
695            new_points.append({
696                "x": round(-pt["x"], 3),
697                "y": round(-pt["y"], 3),
698                "tangent": pt["tangent"],
699            })
700        new_points.append(center)
701        new_points.extend(positive)
702        self._points = new_points
703
704    # ------------------------------------------------------------------
705    # Processed view toggle
706    # ------------------------------------------------------------------
707
708    def _on_processed_toggle(self):
709        """Toggle display of scale and inversion on the curve."""
710        self._show_processed = self._proc_var.get()
711        self._draw()
712        if self._show_processed:
713            parts = []
714            if self._inversion:
715                parts.append("inverted")
716            if self._scale != 1.0:
717                parts.append(f"scale={self._scale}")
718            detail = ", ".join(parts) if parts else "no change"
719            self._status_var.set(f"Processed view ({detail})")
720        else:
721            self._status_var.set("Raw view")
722
723    # ------------------------------------------------------------------
724    # Buttons
725    # ------------------------------------------------------------------
726
727    def _on_reset(self):
728        self._push_undo()
729        self._points = default_points()
730        if self._symmetric:
731            self._enforce_symmetry()
732        self._draw()
733        self._status_var.set("Reset to linear (3 points)")
734
735    def _on_ok(self):
736        self._result = self._points
737        self.grab_release()
738        self.destroy()
739
740    def _on_cancel(self):
741        self._result = None
742        self.grab_release()
743        self.destroy()
class SplineEditorDialog(tkinter.Toplevel):
 88class SplineEditorDialog(tk.Toplevel):
 89    """Modal dialog for visually editing a cubic hermite spline curve.
 90
 91    Interactions:
 92      - Left-click on empty space: add a new control point
 93      - Right-click on a point: remove it (not endpoints)
 94      - Drag a red control point: move Y (endpoints) or X+Y (intermediate)
 95      - Drag a green tangent handle: adjust slope at that point
 96    """
 97
 98    def __init__(self, parent, points: list[dict],
 99                 other_curves: dict[str, list[dict]] | None = None,
100                 scale: float = 1.0, inversion: bool = False):
101        """
102        Args:
103            parent: parent window
104            points: initial control points
105            other_curves: optional {action_name: points} for "Copy from..."
106            scale: action scale factor for processed display
107            inversion: action inversion flag for processed display
108        """
109        super().__init__(parent)
110        self.title("Spline Response Curve Editor")
111        self.transient(parent)
112        self.resizable(False, False)
113
114        self._points = [dict(p) for p in points]
115        self._points.sort(key=lambda p: p["x"])
116        self._result = None
117        self._symmetric = False
118        self._other_curves = other_curves or {}
119        self._scale = scale
120        self._inversion = inversion
121
122        # Drag state
123        self._drag_type = None   # "point" or "handle"
124        self._drag_idx = None
125        self._drag_side = None   # "in" or "out"
126
127        # Undo stack
128        self._undo = UndoStack()
129        self._drag_undo_pushed = False
130
131        self._show_processed = False
132
133        self._build_ui()
134        self._draw()
135
136        # Center on the parent window (follows it across monitors)
137        self.update_idletasks()
138        pw = parent.winfo_width()
139        ph = parent.winfo_height()
140        px = parent.winfo_rootx()
141        py = parent.winfo_rooty()
142        dw = self.winfo_reqwidth()
143        dh = self.winfo_reqheight()
144        x = px + (pw - dw) // 2
145        y = py + (ph - dh) // 2
146        self.geometry(f"+{x}+{y}")
147
148        self.grab_set()
149        self.protocol("WM_DELETE_WINDOW", self._on_cancel)
150        self.focus_set()
151
152    def get_result(self) -> list[dict] | None:
153        """Block until dialog closes. Returns points list or None."""
154        self.wait_window()
155        return self._result
156
157    # ------------------------------------------------------------------
158    # Display scale
159    # ------------------------------------------------------------------
160
161    @property
162    def _display_scale(self) -> float:
163        """Scale factor for displayed Y values when processed view is on."""
164        if self._show_processed:
165            s = self._scale
166            if self._inversion:
167                s = -s
168            return s
169        return 1.0
170
171    # ------------------------------------------------------------------
172    # Undo
173    # ------------------------------------------------------------------
174
175    def _push_undo(self):
176        """Save current points to undo stack."""
177        self._undo.push(self._points)
178
179    def _pop_undo(self):
180        """Restore previous points from undo stack."""
181        state = self._undo.pop()
182        if state is None:
183            self._status_var.set("Nothing to undo")
184            return
185        self._points = state
186        self._draw()
187        self._status_var.set(f"Undo ({len(self._undo)} remaining)")
188
189    # ------------------------------------------------------------------
190    # Import / Export / Copy
191    # ------------------------------------------------------------------
192
193    def _on_export(self):
194        """Export current curve points to a YAML file."""
195        path = filedialog.asksaveasfilename(
196            parent=self, title="Export Spline Curve",
197            defaultextension=".yaml",
198            filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")])
199        if not path:
200            return
201        data = {"type": "spline", "points": deepcopy(self._points)}
202        with open(path, "w") as f:
203            yaml.dump(data, f, default_flow_style=False, sort_keys=False)
204        self._status_var.set(f"Exported to {path}")
205
206    def _on_import(self):
207        """Import curve points from a YAML file."""
208        path = filedialog.askopenfilename(
209            parent=self, title="Import Spline Curve",
210            filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")])
211        if not path:
212            return
213        try:
214            with open(path) as f:
215                data = yaml.safe_load(f)
216        except Exception as exc:
217            messagebox.showerror("Import Failed",
218                                 f"Could not read YAML file:\n{exc}",
219                                 parent=self)
220            return
221
222        if isinstance(data, dict):
223            points = data.get("points", [])
224        elif isinstance(data, list):
225            points = data
226        else:
227            messagebox.showerror(
228                "Import Failed",
229                "File does not contain curve data.\n"
230                "Expected a 'points' list of {{x, y, tangent}} entries.",
231                parent=self)
232            return
233
234        if not points:
235            messagebox.showerror(
236                "Import Failed",
237                "No points found in file.\n"
238                "Expected a 'points' key containing a list.",
239                parent=self)
240            return
241
242        if not isinstance(points, list) or not all(
243                isinstance(p, dict) and "x" in p and "y" in p
244                for p in points):
245            messagebox.showerror(
246                "Import Failed",
247                "Invalid point data. Each point must have "
248                "'x' and 'y' fields.",
249                parent=self)
250            return
251
252        # Ensure tangent field exists for spline points
253        for p in points:
254            if "tangent" not in p:
255                p["tangent"] = 1.0
256        self._push_undo()
257        self._points = [dict(p) for p in points]
258        self._points.sort(key=lambda p: p["x"])
259        self._draw()
260        self._status_var.set(f"Imported from {path}")
261
262    def _on_copy_from(self):
263        """Copy curve data from another action."""
264        if not self._other_curves:
265            return
266        win = tk.Toplevel(self)
267        win.title("Copy Curve From...")
268        win.transient(self)
269        win.grab_set()
270        win.resizable(False, False)
271
272        ttk.Label(win, text="Select an action to copy its curve:",
273                  padding=5).pack(anchor=tk.W)
274        listbox = tk.Listbox(win, height=min(10, len(self._other_curves)),
275                             width=40)
276        listbox.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)
277        names = sorted(self._other_curves.keys())
278        for name in names:
279            listbox.insert(tk.END, name)
280
281        def on_ok():
282            sel = listbox.curselection()
283            if not sel:
284                return
285            chosen = names[sel[0]]
286            pts = self._other_curves[chosen]
287            for p in pts:
288                if "tangent" not in p:
289                    p["tangent"] = 1.0
290            self._push_undo()
291            self._points = [dict(p) for p in pts]
292            self._points.sort(key=lambda p: p["x"])
293            self._draw()
294            self._status_var.set(f"Copied curve from {chosen}")
295            win.destroy()
296
297        listbox.bind("<Double-1>", lambda e: on_ok())
298        bf = ttk.Frame(win)
299        bf.pack(fill=tk.X, padx=10, pady=(0, 10))
300        ttk.Button(bf, text="OK", command=on_ok).pack(side=tk.RIGHT, padx=5)
301        ttk.Button(bf, text="Cancel",
302                   command=win.destroy).pack(side=tk.RIGHT)
303
304        # Center on parent editor dialog
305        win.update_idletasks()
306        px, py = self.winfo_rootx(), self.winfo_rooty()
307        pw, ph = self.winfo_width(), self.winfo_height()
308        ww, wh = win.winfo_width(), win.winfo_height()
309        win.geometry(f"+{px + (pw - ww) // 2}+{py + (ph - wh) // 2}")
310
311    # ------------------------------------------------------------------
312    # UI
313    # ------------------------------------------------------------------
314
315    def _build_ui(self):
316        self._canvas = tk.Canvas(
317            self, width=DIALOG_W, height=DIALOG_H,
318            bg=BG_WHITE, cursor="crosshair")
319        self._canvas.pack(padx=10, pady=(10, 5))
320
321        self._canvas.bind("<ButtonPress-1>", self._on_press)
322        self._canvas.bind("<B1-Motion>", self._on_drag)
323        self._canvas.bind("<ButtonRelease-1>", self._on_release)
324        self._canvas.bind("<Button-3>", self._on_right_click)
325
326        self._status_var = tk.StringVar(value=_DEFAULT_STATUS)
327        ttk.Label(self, textvariable=self._status_var,
328                  relief=tk.SUNKEN, anchor=tk.W).pack(fill=tk.X, padx=10)
329
330        # Top button row: file and copy operations
331        top_btn = ttk.Frame(self)
332        top_btn.pack(fill=tk.X, padx=10, pady=(5, 0))
333        ttk.Button(top_btn, text="Export YAML",
334                   command=self._on_export).pack(side=tk.LEFT, padx=5)
335        ttk.Button(top_btn, text="Import YAML",
336                   command=self._on_import).pack(side=tk.LEFT, padx=5)
337        if self._other_curves:
338            ttk.Button(top_btn, text="Copy from...",
339                       command=self._on_copy_from).pack(side=tk.LEFT, padx=5)
340
341        # Bottom button row: edit operations
342        btn = ttk.Frame(self)
343        btn.pack(fill=tk.X, padx=10, pady=(5, 10))
344        ttk.Button(btn, text="Reset to Linear",
345                   command=self._on_reset).pack(side=tk.LEFT, padx=5)
346        ttk.Button(btn, text="Undo",
347                   command=self._pop_undo).pack(side=tk.LEFT, padx=5)
348        self._sym_var = tk.BooleanVar()
349        ttk.Checkbutton(btn, text="Symmetry", variable=self._sym_var,
350                        command=self._on_symmetry_toggle
351                        ).pack(side=tk.LEFT, padx=5)
352        self._proc_var = tk.BooleanVar(value=False)
353        self._proc_cb = ttk.Checkbutton(
354            btn, text="Show Processed",
355            variable=self._proc_var,
356            command=self._on_processed_toggle)
357        self._proc_cb.pack(side=tk.LEFT, padx=5)
358        ttk.Button(btn, text="Cancel",
359                   command=self._on_cancel).pack(side=tk.RIGHT, padx=5)
360        ttk.Button(btn, text="OK",
361                   command=self._on_ok).pack(side=tk.RIGHT, padx=5)
362
363        self.bind("<Control-z>", lambda e: self._pop_undo())
364
365    # ------------------------------------------------------------------
366    # Coordinate conversion
367    # ------------------------------------------------------------------
368
369    @property
370    def _y_extent(self) -> float:
371        """Half-range for Y axis, expanded to fit scaled values."""
372        return max(1.0, abs(self._display_scale))
373
374    def _d2c(self, x: float, y: float) -> tuple[float, float]:
375        """Data to canvas pixels."""
376        ext = self._y_extent
377        cx = DIALOG_MARGIN + (x + 1) / 2 * DIALOG_PLOT_W
378        cy = DIALOG_MARGIN + (ext - y) / (2 * ext) * DIALOG_PLOT_H
379        return cx, cy
380
381    def _c2d(self, cx: float, cy: float) -> tuple[float, float]:
382        """Canvas pixels to data."""
383        ext = self._y_extent
384        x = (cx - DIALOG_MARGIN) / DIALOG_PLOT_W * 2 - 1
385        y = ext - (cy - DIALOG_MARGIN) / DIALOG_PLOT_H * (2 * ext)
386        return x, y
387
388    def _tangent_offset(self, tangent: float) -> tuple[float, float]:
389        """Tangent slope to canvas-pixel offset for handle drawing."""
390        ext = self._y_extent
391        ppx = DIALOG_PLOT_W / 2       # pixels per data unit, X
392        ppy = DIALOG_PLOT_H / (2 * ext)  # pixels per data unit, Y
393        vis_tangent = tangent * self._display_scale
394        dx = 1.0 * ppx
395        dy = -vis_tangent * ppy  # canvas Y inverted
396        length = math.hypot(dx, dy)
397        if length < 1e-6:
398            return float(_HANDLE_LENGTH), 0.0
399        s = _HANDLE_LENGTH / length
400        return dx * s, dy * s
401
402    def _offset_to_tangent(self, dx: float, dy: float) -> float:
403        """Canvas-pixel offset back to raw tangent slope (un-scaled)."""
404        ext = self._y_extent
405        ppx = DIALOG_PLOT_W / 2
406        ppy = DIALOG_PLOT_H / (2 * ext)
407        data_dx = dx / ppx
408        data_dy = -dy / ppy
409        if abs(data_dx) < 1e-6:
410            return 10.0 if data_dy > 0 else -10.0
411        vis_tangent = data_dy / data_dx
412        s = self._display_scale
413        if abs(s) < 1e-6:
414            return vis_tangent
415        return vis_tangent / s
416
417    # ------------------------------------------------------------------
418    # Drawing
419    # ------------------------------------------------------------------
420
421    def _draw(self):
422        c = self._canvas
423        c.delete("all")
424        self._draw_grid()
425        self._draw_curve()
426        self._draw_handles()
427        self._draw_points()
428
429    def _draw_grid(self):
430        ext = self._y_extent
431        draw_editor_grid(self._canvas, self._d2c,
432                         DIALOG_MARGIN, DIALOG_PLOT_W, DIALOG_PLOT_H,
433                         DIALOG_W, DIALOG_H,
434                         y_min=-ext, y_max=ext)
435
436    def _draw_curve(self):
437        pts = self._points
438        if len(pts) < 2:
439            return
440        s = self._display_scale
441        n = _CURVE_SAMPLES_PER_SEG * (len(pts) - 1)
442        x_min, x_max = pts[0]["x"], pts[-1]["x"]
443        coords = []
444        for i in range(n + 1):
445            x = x_min + (x_max - x_min) * i / n
446            y = max(-1.5, min(1.5, evaluate_spline(pts, x) * s))
447            cx, cy = self._d2c(x, y)
448            coords.extend([cx, cy])
449        if len(coords) >= 4:
450            self._canvas.create_line(
451                *coords, fill=CURVE_LINE, width=2, smooth=False)
452
453    def _draw_handles(self):
454        c = self._canvas
455        s = self._display_scale
456        for i, pt in enumerate(self._points):
457            cx, cy = self._d2c(pt["x"], pt["y"] * s)
458            hdx, hdy = self._tangent_offset(pt["tangent"])
459            c.create_line(cx - hdx, cy - hdy, cx + hdx, cy + hdy,
460                          fill=HANDLE_LINE, width=1, dash=(4, 4))
461            if i > 0:
462                hx, hy = cx - hdx, cy - hdy
463                c.create_oval(hx - _HANDLE_RADIUS, hy - _HANDLE_RADIUS,
464                              hx + _HANDLE_RADIUS, hy + _HANDLE_RADIUS,
465                              fill=HANDLE_FILL, outline="#308030")
466            if i < len(self._points) - 1:
467                hx, hy = cx + hdx, cy + hdy
468                c.create_oval(hx - _HANDLE_RADIUS, hy - _HANDLE_RADIUS,
469                              hx + _HANDLE_RADIUS, hy + _HANDLE_RADIUS,
470                              fill=HANDLE_FILL, outline="#308030")
471
472    def _draw_points(self):
473        c = self._canvas
474        s = self._display_scale
475        for i, pt in enumerate(self._points):
476            cx, cy = self._d2c(pt["x"], pt["y"] * s)
477            is_endpoint = (i == 0 or i == len(self._points) - 1)
478            is_mirror = (self._symmetric
479                         and pt["x"] < -_MIN_X_GAP / 2)
480            if is_mirror:
481                fill = "#c0a0a0"   # muted — auto-mirrored, not draggable
482            elif is_endpoint:
483                fill = ENDPOINT_FILL
484            else:
485                fill = POINT_FILL
486            c.create_oval(cx - _POINT_RADIUS, cy - _POINT_RADIUS,
487                          cx + _POINT_RADIUS, cy + _POINT_RADIUS,
488                          fill=fill, outline=POINT_OUTLINE, width=2)
489
490    # ------------------------------------------------------------------
491    # Hit testing & interaction
492    # ------------------------------------------------------------------
493
494    def _hit_test(self, cx, cy):
495        """Find what element is at canvas position (cx, cy).
496
497        Returns (type, index, side) or None.
498        When symmetry is on, negative-side mirrors are not interactive.
499        """
500        s = self._display_scale
501        # Check handles first (smaller targets, higher priority)
502        for i, pt in enumerate(self._points):
503            if self._symmetric and pt["x"] < -_MIN_X_GAP / 2:
504                continue
505            px, py = self._d2c(pt["x"], pt["y"] * s)
506            hdx, hdy = self._tangent_offset(pt["tangent"])
507            if i < len(self._points) - 1:
508                if math.hypot(cx - (px + hdx),
509                              cy - (py + hdy)) <= _HANDLE_RADIUS + 3:
510                    return ("handle", i, "out")
511            if i > 0:
512                if math.hypot(cx - (px - hdx),
513                              cy - (py - hdy)) <= _HANDLE_RADIUS + 3:
514                    return ("handle", i, "in")
515        # Then check points
516        for i, pt in enumerate(self._points):
517            if self._symmetric and pt["x"] < -_MIN_X_GAP / 2:
518                continue
519            px, py = self._d2c(pt["x"], pt["y"] * s)
520            if math.hypot(cx - px, cy - py) <= _POINT_RADIUS + 3:
521                return ("point", i, None)
522        return None
523
524    def _is_endpoint(self, idx: int) -> bool:
525        """True if point at *idx* is the first or last point."""
526        return idx == 0 or idx == len(self._points) - 1
527
528    def _add_point_at(self, cx, cy):
529        """Add a new control point at canvas position."""
530        x, vis_y = self._c2d(cx, cy)
531
532        # When symmetric, only allow adding on the positive side
533        if self._symmetric and x < -_MIN_X_GAP / 2:
534            self._status_var.set(
535                "Add points on the positive side (symmetry)")
536            return
537
538        x_min = self._points[0]["x"]
539        x_max = self._points[-1]["x"]
540        if x <= x_min + _MIN_X_GAP or x >= x_max - _MIN_X_GAP:
541            return
542        # Un-scale the visual Y to get the raw point value
543        s = self._display_scale
544        y = vis_y / s if abs(s) > 1e-6 else vis_y
545        y = max(-1.0, min(1.0, y))
546
547        # Don't add if too close to an existing point
548        for pt in self._points:
549            if abs(pt["x"] - x) < _MIN_X_GAP:
550                return
551
552        self._push_undo()
553        tangent = _numerical_slope(self._points, x)
554        self._points.append({
555            "x": round(x, 3),
556            "y": round(y, 3),
557            "tangent": round(tangent, 3),
558        })
559        self._points.sort(key=lambda p: p["x"])
560
561        if self._symmetric:
562            self._enforce_symmetry()
563
564        self._draw()
565        self._status_var.set(
566            f"Added point at x={x:.2f} ({len(self._points)} points)")
567
568    def _remove_point(self, idx):
569        """Remove the control point at *idx* (not endpoints)."""
570        if self._is_endpoint(idx) or len(self._points) <= 2:
571            return
572        self._push_undo()
573        self._points.pop(idx)
574        if self._symmetric:
575            self._enforce_symmetry()
576        self._draw()
577        self._status_var.set(
578            f"Removed point ({len(self._points)} points)")
579
580    def _on_press(self, event):
581        hit = self._hit_test(event.x, event.y)
582        if hit:
583            self._drag_type, self._drag_idx, self._drag_side = hit
584            self._drag_undo_pushed = False
585        else:
586            self._drag_type = None
587            self._add_point_at(event.x, event.y)
588
589    def _on_drag(self, event):
590        if self._drag_type is None:
591            return
592        if not self._drag_undo_pushed:
593            self._push_undo()
594            self._drag_undo_pushed = True
595        i = self._drag_idx
596        pt = self._points[i]
597
598        if self._drag_type == "point":
599            _, vis_y = self._c2d(event.x, event.y)
600            s = self._display_scale
601            raw_y = vis_y / s if abs(s) > 1e-6 else vis_y
602            # Center point with symmetry: y locked to 0
603            if self._symmetric and abs(pt["x"]) < _MIN_X_GAP / 2:
604                pt["y"] = 0.0
605            else:
606                pt["y"] = round(max(-1.0, min(1.0, raw_y)), 3)
607            # Intermediate points: also move X
608            if not self._is_endpoint(i):
609                x, _ = self._c2d(event.x, event.y)
610                x_lo = self._points[i - 1]["x"] + _MIN_X_GAP
611                x_hi = self._points[i + 1]["x"] - _MIN_X_GAP
612                # With symmetry, keep positive points on positive side
613                if self._symmetric and pt["x"] > 0:
614                    x_lo = max(x_lo, _MIN_X_GAP)
615                pt["x"] = round(max(x_lo, min(x_hi, x)), 3)
616        elif self._drag_type == "handle":
617            cx, cy = self._d2c(pt["x"], pt["y"])
618            dx, dy = event.x - cx, event.y - cy
619            if self._drag_side == "in":
620                dx, dy = -dx, -dy
621            if math.hypot(dx, dy) > 5:
622                t = self._offset_to_tangent(dx, dy)
623                pt["tangent"] = round(max(-10.0, min(10.0, t)), 3)
624
625        if self._symmetric:
626            self._enforce_symmetry()
627            # Re-find drag index (positive points keep identity)
628            for j, p in enumerate(self._points):
629                if p is pt:
630                    self._drag_idx = j
631                    break
632
633        self._status_var.set(
634            f"Point {self._drag_idx}: x={pt['x']:.2f}  y={pt['y']:.3f}  "
635            f"tangent={pt['tangent']:.3f}")
636        self._draw()
637
638    def _on_release(self, event):
639        self._drag_type = None
640        self._status_var.set(_DEFAULT_STATUS)
641
642    def _on_right_click(self, event):
643        """Remove a control point on right-click."""
644        hit = self._hit_test(event.x, event.y)
645        if not hit or hit[0] != "point":
646            return
647        i = hit[1]
648        if self._is_endpoint(i):
649            self._status_var.set("Cannot remove endpoints")
650            return
651        if len(self._points) <= 2:
652            self._status_var.set("Need at least 2 points")
653            return
654        # Clear any in-progress drag to avoid stale index
655        self._drag_type = None
656        self._remove_point(i)
657
658    # ------------------------------------------------------------------
659    # Symmetry
660    # ------------------------------------------------------------------
661
662    def _on_symmetry_toggle(self):
663        """Handle the Symmetry checkbox toggle."""
664        self._push_undo()
665        self._symmetric = self._sym_var.get()
666        if self._symmetric:
667            self._enforce_symmetry()
668            self._draw()
669            self._status_var.set("Symmetry on — edit positive side")
670
671    def _enforce_symmetry(self):
672        """Rebuild negative-side points as odd mirrors of positive side.
673
674        Positive-side points (x > 0) are the source of truth.
675        Center point (x ~ 0) gets y locked to 0.
676        Negative-side points are recreated as mirrors.
677        Positive and center dict objects keep their identity so
678        in-progress drag indices can be recovered.
679        """
680        positive = [pt for pt in self._points
681                    if pt["x"] > _MIN_X_GAP / 2]
682        center = None
683        for pt in self._points:
684            if abs(pt["x"]) < _MIN_X_GAP / 2:
685                center = pt
686                break
687
688        if center is None:
689            center = {"x": 0.0, "y": 0.0, "tangent": 1.0}
690        else:
691            center["x"] = 0.0
692            center["y"] = 0.0
693
694        new_points = []
695        for pt in reversed(positive):
696            new_points.append({
697                "x": round(-pt["x"], 3),
698                "y": round(-pt["y"], 3),
699                "tangent": pt["tangent"],
700            })
701        new_points.append(center)
702        new_points.extend(positive)
703        self._points = new_points
704
705    # ------------------------------------------------------------------
706    # Processed view toggle
707    # ------------------------------------------------------------------
708
709    def _on_processed_toggle(self):
710        """Toggle display of scale and inversion on the curve."""
711        self._show_processed = self._proc_var.get()
712        self._draw()
713        if self._show_processed:
714            parts = []
715            if self._inversion:
716                parts.append("inverted")
717            if self._scale != 1.0:
718                parts.append(f"scale={self._scale}")
719            detail = ", ".join(parts) if parts else "no change"
720            self._status_var.set(f"Processed view ({detail})")
721        else:
722            self._status_var.set("Raw view")
723
724    # ------------------------------------------------------------------
725    # Buttons
726    # ------------------------------------------------------------------
727
728    def _on_reset(self):
729        self._push_undo()
730        self._points = default_points()
731        if self._symmetric:
732            self._enforce_symmetry()
733        self._draw()
734        self._status_var.set("Reset to linear (3 points)")
735
736    def _on_ok(self):
737        self._result = self._points
738        self.grab_release()
739        self.destroy()
740
741    def _on_cancel(self):
742        self._result = None
743        self.grab_release()
744        self.destroy()

Modal dialog for visually editing a cubic hermite spline curve.

Interactions:

  • Left-click on empty space: add a new control point
  • Right-click on a point: remove it (not endpoints)
  • Drag a red control point: move Y (endpoints) or X+Y (intermediate)
  • Drag a green tangent handle: adjust slope at that point
SplineEditorDialog( parent, points: list[dict], other_curves: dict[str, list[dict]] | None = None, scale: float = 1.0, inversion: bool = False)
 98    def __init__(self, parent, points: list[dict],
 99                 other_curves: dict[str, list[dict]] | None = None,
100                 scale: float = 1.0, inversion: bool = False):
101        """
102        Args:
103            parent: parent window
104            points: initial control points
105            other_curves: optional {action_name: points} for "Copy from..."
106            scale: action scale factor for processed display
107            inversion: action inversion flag for processed display
108        """
109        super().__init__(parent)
110        self.title("Spline Response Curve Editor")
111        self.transient(parent)
112        self.resizable(False, False)
113
114        self._points = [dict(p) for p in points]
115        self._points.sort(key=lambda p: p["x"])
116        self._result = None
117        self._symmetric = False
118        self._other_curves = other_curves or {}
119        self._scale = scale
120        self._inversion = inversion
121
122        # Drag state
123        self._drag_type = None   # "point" or "handle"
124        self._drag_idx = None
125        self._drag_side = None   # "in" or "out"
126
127        # Undo stack
128        self._undo = UndoStack()
129        self._drag_undo_pushed = False
130
131        self._show_processed = False
132
133        self._build_ui()
134        self._draw()
135
136        # Center on the parent window (follows it across monitors)
137        self.update_idletasks()
138        pw = parent.winfo_width()
139        ph = parent.winfo_height()
140        px = parent.winfo_rootx()
141        py = parent.winfo_rooty()
142        dw = self.winfo_reqwidth()
143        dh = self.winfo_reqheight()
144        x = px + (pw - dw) // 2
145        y = py + (ph - dh) // 2
146        self.geometry(f"+{x}+{y}")
147
148        self.grab_set()
149        self.protocol("WM_DELETE_WINDOW", self._on_cancel)
150        self.focus_set()

Args: parent: parent window points: initial control points other_curves: optional {action_name: points} for "Copy from..." scale: action scale factor for processed display inversion: action inversion flag for processed display

def get_result(self) -> list[dict] | None:
152    def get_result(self) -> list[dict] | None:
153        """Block until dialog closes. Returns points list or None."""
154        self.wait_window()
155        return self._result

Block until dialog closes. Returns points list or None.