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()
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
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