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