add waypoint editor

This commit is contained in:
VALLONGOL 2025-10-08 14:18:38 +02:00
parent 2ce621f505
commit 7a9c530f25
4 changed files with 455 additions and 397 deletions

View File

@ -8,23 +8,21 @@ from tkinter import ttk, scrolledtext, messagebox
from queue import Queue, Empty
from typing import Optional, Dict, Any, List
# Imports from GUI components
from .ppi_display import PPIDisplay
from .connection_settings_window import ConnectionSettingsWindow
from .radar_config_window import RadarConfigWindow
from .scenario_controls_frame import ScenarioControlsFrame
from .target_list_frame import TargetListFrame
# Use absolute imports for robustness and clarity
from target_simulator.gui.ppi_display import PPIDisplay
from target_simulator.gui.connection_settings_window import ConnectionSettingsWindow
from target_simulator.gui.radar_config_window import RadarConfigWindow
from target_simulator.gui.scenario_controls_frame import ScenarioControlsFrame
from target_simulator.gui.target_list_frame import TargetListFrame
# Imports from Core components
from core.communicator_interface import CommunicatorInterface
from core.serial_communicator import SerialCommunicator
from core.tftp_communicator import TFTPCommunicator
from core.simulation_engine import SimulationEngine
from core.models import Scenario, Target
from target_simulator.core.communicator_interface import CommunicatorInterface
from target_simulator.core.serial_communicator import SerialCommunicator
from target_simulator.core.tftp_communicator import TFTPCommunicator
from target_simulator.core.simulation_engine import SimulationEngine
from target_simulator.core.models import Scenario, Target
# Imports from Utils
from utils.logger import get_logger, shutdown_logging_system
from utils.config_manager import ConfigManager
from target_simulator.utils.logger import get_logger, shutdown_logging_system
from target_simulator.utils.config_manager import ConfigManager
GUI_QUEUE_POLL_INTERVAL_MS = 100
@ -85,9 +83,9 @@ class MainView(tk.Tk):
self.ppi_widget = PPIDisplay(self.h_pane, max_range_nm=self.max_range, scan_limit_deg=self.scan_limit)
self.h_pane.add(self.ppi_widget, weight=2)
controls_frame = self.ppi_widget.children.get('!frame')
if controls_frame:
connect_btn = ttk.Button(controls_frame, text="Connect", command=self._on_connect_button)
# Add Connect button to the PPI's own control frame for better layout
if hasattr(self.ppi_widget, 'controls_frame'):
connect_btn = ttk.Button(self.ppi_widget.controls_frame, text="Connect", command=self._on_connect_button)
connect_btn.pack(side=tk.RIGHT, padx=10)
# --- Left Pane ---
@ -416,13 +414,31 @@ class MainView(tk.Tk):
def _open_radar_config(self):
self.logger.info("Opening radar config window.")
dialog = RadarConfigWindow(self, current_scan_limit=self.scan_limit, current_max_range=self.max_range)
# wait_window è già gestito all'interno di RadarConfigWindow,
# quindi il codice prosegue solo dopo la sua chiusura.
if dialog.scan_limit is not None and dialog.max_range is not None:
# Check if values have actually changed to avoid unnecessary redraws
if self.scan_limit != dialog.scan_limit or self.max_range != dialog.max_range:
self.logger.info("Radar configuration changed. Applying new settings.")
self.scan_limit = dialog.scan_limit
self.max_range = dialog.max_range
self.ppi_widget.destroy()
self.ppi_widget = PPIDisplay(self.h_pane, max_range_nm=self.max_range, scan_limit_deg=self.scan_limit)
self.h_pane.add(self.ppi_widget, weight=2)
self._update_all_views()
# --- LOGICA MODIFICATA ---
# Non distruggere il widget, ma riconfiguralo.
self.ppi_widget.reconfigure_radar(
max_range_nm=self.max_range,
scan_limit_deg=self.scan_limit
)
self.logger.info(f"Scan limit set to: ±{self.scan_limit} degrees")
self.logger.info(f"Max range set to: {self.max_range} NM")
# Non è necessario chiamare _update_all_views() perché
# reconfigure_radar forza già un ridisegno completo.
else:
self.logger.info("Radar configuration confirmed, but no changes were made.")
def _on_closing(self):
self.logger.info("Application shutting down.")

View File

@ -1,179 +1,220 @@
# target_simulator/gui/ppi_display.py
"""
A Tkinter widget that displays a Plan Position Indicator (PPI) using Matplotlib.
A reusable Tkinter widget that displays a Plan Position Indicator (PPI)
using Matplotlib, capable of showing both live targets and trajectory previews.
"""
import tkinter as tk
from tkinter import ttk
import math
import numpy as np
import copy
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import matplotlib.transforms as transforms
import matplotlib.markers as markers
from core.models import Target
from typing import List
# Use absolute imports
from target_simulator.core.models import Target, Waypoint
from typing import List, Optional
class PPIDisplay(ttk.Frame):
"""A custom widget for the PPI radar display."""
"""A custom, reusable widget for the PPI radar display."""
def __init__(self, master, max_range_nm: int = 100, scan_limit_deg: int = 60):
super().__init__(master)
self.max_range = max_range_nm
self.scan_limit_deg = scan_limit_deg
self.target_artists = []
self.active_targets: List[Target] = []
self._target_dots = []
self.preview_artists = []
self._create_controls()
self._create_plot()
def _create_controls(self):
"""Creates the control widgets for the PPI display."""
controls_frame = ttk.Frame(self)
controls_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
self.controls_frame = ttk.Frame(self)
self.controls_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
ttk.Label(controls_frame, text="Range (NM):").pack(side=tk.LEFT)
ttk.Label(self.controls_frame, text="Range (NM):").pack(side=tk.LEFT)
# Generate range steps
steps = list(range(self.max_range, 19, -20))
if 10 not in steps:
steps.append(10)
# Create a list of valid range steps up to the theoretical max_range
all_steps = [10, 20, 40, 80, 100, 160]
valid_steps = sorted([s for s in all_steps if s <= self.max_range])
if not valid_steps:
valid_steps = [self.max_range]
# Ensure the initial max range is in the list if not a standard step
if self.max_range not in valid_steps:
valid_steps.append(self.max_range)
valid_steps.sort()
# The initial value for the combobox is the max_range passed to the constructor
self.range_var = tk.IntVar(value=self.max_range)
self.range_selector = ttk.Combobox(
controls_frame,
textvariable=self.range_var,
values=steps,
state="readonly",
width=5
self.controls_frame, textvariable=self.range_var,
values=valid_steps, state="readonly", width=5
)
self.range_selector.pack(side=tk.LEFT)
self.range_selector.pack(side=tk.LEFT, padx=5)
self.range_selector.bind("<<ComboboxSelected>>", self._on_range_selected)
def _create_plot(self):
"""Initializes the Matplotlib polar plot."""
# Dark theme for the plot
fig = Figure(figsize=(5, 5), dpi=100, facecolor='#3E3E3E')
fig.subplots_adjust(left=0.05, right=0.95, top=0.85, bottom=0.05)
self.ax = fig.add_subplot(111, projection='polar', facecolor='#2E2E2E')
# Configure polar axes
self.ax.set_theta_zero_location('N') # 0 degrees at the top
self.ax.set_theta_direction(-1) # Clockwise rotation
self.ax.set_theta_zero_location('N')
self.ax.set_theta_direction(-1)
self.ax.set_rlabel_position(90)
self.ax.set_ylim(0, self.max_range)
self.ax.set_ylim(0, self.range_var.get())
# Set custom theta labels for sector scan view
angles = np.arange(0, 360, 30)
labels = []
for angle in angles:
if angle == 0:
labels.append("")
elif angle < 180:
labels.append(f"+{angle}°")
elif angle == 180:
labels.append("±180°")
else: # angle > 180
labels.append(f"-{360 - angle}°")
labels = [f"{angle}°" if angle == 0 else f"+{angle}°" if angle < 180 else "±180°" if angle == 180 else f"-{360 - angle}°" for angle in angles]
self.ax.set_thetagrids(angles, labels)
# Style the plot
self.ax.tick_params(axis='x', colors='white')
self.ax.tick_params(axis='y', colors='white')
self.ax.tick_params(axis='x', colors='white', labelsize=8)
self.ax.tick_params(axis='y', colors='white', labelsize=8)
self.ax.grid(color='white', linestyle='--', linewidth=0.5, alpha=0.5)
self.ax.spines['polar'].set_color('white')
self.ax.set_title("PPI Display", color='white', y=1.08)
# Draw scan sector limits
limit_rad = np.deg2rad(self.scan_limit_deg)
self.ax.plot([limit_rad, limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1)
self.ax.plot([-limit_rad, -limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1)
# --- Artists for drawing ---
self._start_plot, = self.ax.plot([], [], 'go', markersize=8)
self._waypoints_plot, = self.ax.plot([], [], 'y+', markersize=10, mew=2, linestyle='None')
self._path_plot, = self.ax.plot([], [], 'r--', linewidth=1.5)
self.preview_artists = [self._start_plot, self._waypoints_plot, self._path_plot]
# --- NEW: Create artists for scan lines ---
limit_rad = np.deg2rad(self.scan_limit_deg)
self._scan_line_1, = self.ax.plot([limit_rad, limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1)
self._scan_line_2, = self.ax.plot([-limit_rad, -limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1)
self._tooltip_label = None
# Embed the plot in a Tkinter canvas
self.canvas = FigureCanvasTkAgg(fig, master=self)
self.canvas.draw()
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self.canvas.mpl_connect('motion_notify_event', self._on_motion)
def _on_range_selected(self, event):
"""Handles the selection of a new range from the combobox."""
# --- NEW: Initial draw of scan lines ---
self._update_scan_lines()
def _update_scan_lines(self):
"""Updates the length of the scan sector lines to match the current range."""
current_range_max = self.ax.get_ylim()[1]
self._scan_line_1.set_ydata([0, current_range_max])
self._scan_line_2.set_ydata([0, current_range_max])
def _on_range_selected(self, event=None):
"""Handles the selection of a new range."""
new_range = self.range_var.get()
self.ax.set_ylim(0, new_range)
# Update heading vector lengths on zoom
self.update_targets([]) # Pass empty list to redraw existing ones with new scale
# --- NEW: Update scan lines on zoom change ---
self._update_scan_lines()
self.update_targets(self.active_targets)
self.canvas.draw()
# ... il resto dei metodi rimane invariato ...
def update_targets(self, targets: List[Target]):
"""
Aggiorna la posizione dei target sulla PPI e mostra l'ID accanto al punto. Tooltip con ID al passaggio del mouse.
"""
if targets:
# (This method is unchanged)
self.active_targets = [t for t in targets if t.active]
# Rimuovi artisti precedenti
for artist in self.target_artists:
artist.remove()
for artist in self.target_artists: artist.remove()
self.target_artists.clear()
# Rimuovi eventuali tooltips precedenti
if hasattr(self, 'tooltips'):
for tip in self.tooltips:
tip.remove()
self.tooltips = []
self._target_dots.clear()
vector_len = self.range_var.get() / 25
self._target_dots = []
for target in getattr(self, 'active_targets', []):
r1 = target.current_range_nm
th1 = np.deg2rad(target.current_azimuth_deg)
# Punto del target
dot, = self.ax.plot(th1, r1, 'o', markersize=5, color='red', picker=5)
for target in self.active_targets:
r = target.current_range_nm
theta = np.deg2rad(target.current_azimuth_deg)
dot, = self.ax.plot(theta, r, 'o', markersize=5, color='red')
self.target_artists.append(dot)
self._target_dots.append((dot, target))
# Vettore heading
x1 = r1 * np.sin(th1)
y1 = r1 * np.cos(th1)
x1, y1 = r * np.sin(theta), r * np.cos(theta)
h_rad = np.deg2rad(target.current_heading_deg)
dx = vector_len * np.sin(h_rad)
dy = vector_len * np.cos(h_rad)
dx, dy = vector_len * np.sin(h_rad), vector_len * np.cos(h_rad)
x2, y2 = x1 + dx, y1 + dy
r2 = np.sqrt(x2**2 + y2**2)
th2 = np.arctan2(x2, y2)
line, = self.ax.plot([th1, th2], [r1, r2], color='red', linewidth=1.2)
r2, th2 = np.sqrt(x2**2 + y2**2), np.arctan2(x2, y2)
line, = self.ax.plot([theta, th2], [r, r2], color='red', linewidth=1.2)
self.target_artists.append(line)
self.canvas.draw()
# Gestione tooltip
def on_motion(event):
# Mostra hint se il mouse è vicino a un punto target
def draw_trajectory_preview(self, initial_state: dict, waypoints: List[Waypoint]):
# (This method is unchanged)
self.clear_previews()
if not waypoints: self.canvas.draw(); return
temp_target = Target(target_id=0, **initial_state, trajectory=copy.deepcopy(waypoints))
path_thetas, path_rs = [], []; wp_thetas, wp_rs = [], []
total_duration = sum(wp.duration_s for wp in waypoints)
sim_time, time_step = 0.0, 0.5
path_thetas.append(math.radians(temp_target.current_azimuth_deg)); path_rs.append(temp_target.current_range_nm)
while sim_time < total_duration:
wp_index_before = temp_target._current_waypoint_index
temp_target.update_state(time_step)
path_thetas.append(math.radians(temp_target.current_azimuth_deg)); path_rs.append(temp_target.current_range_nm)
if temp_target._current_waypoint_index > wp_index_before:
wp_thetas.append(path_thetas[-2]); wp_rs.append(path_rs[-2])
sim_time += time_step
start_r, start_theta = initial_state['initial_range_nm'], math.radians(initial_state['initial_azimuth_deg'])
self._start_plot.set_data([start_theta], [start_r])
self._waypoints_plot.set_data(wp_thetas, wp_rs)
self._path_plot.set_data(path_thetas, path_rs)
max_r = max(path_rs) if path_rs else start_r
self.ax.set_ylim(0, max_r * 1.1)
self._update_scan_lines() # Also update scan lines to fit preview if it zooms out
self.canvas.draw()
def clear_previews(self):
# (This method is unchanged)
for artist in self.preview_artists: artist.set_data([], [])
self.canvas.draw()
def _on_motion(self, event):
# (This method is unchanged)
if event.inaxes != self.ax:
if hasattr(self, '_tooltip_label') and self._tooltip_label:
self._tooltip_label.place_forget()
self._tooltip_label = None
if self._tooltip_label: self._tooltip_label.place_forget(); self._tooltip_label = None
return
found = False
for dot, target in self._target_dots:
cont, _ = dot.contains(event)
if cont:
# Usa la posizione del mouse relativa al widget Tkinter
self._show_tooltip(event.x + 10, event.y + 10, f"ID: {target.target_id}")
found = True
break
if not found and hasattr(self, '_tooltip_label') and self._tooltip_label:
self._tooltip_label.place_forget()
self._tooltip_label = None
self.canvas.mpl_connect('motion_notify_event', on_motion)
self._tooltip_label = None
self.canvas.draw()
self._show_tooltip(event.x + 10, event.y + 10, f"ID: {target.target_id}"); found = True; break
if not found and self._tooltip_label: self._tooltip_label.place_forget(); self._tooltip_label = None
def _show_tooltip(self, x, y, text):
# Mostra un tooltip Tkinter sopra la canvas, centrato sul punto target
if self._tooltip_label:
self._tooltip_label.place_forget()
# (This method is unchanged)
if self._tooltip_label: self._tooltip_label.place_forget()
self._tooltip_label = tk.Label(self.canvas.get_tk_widget(), text=text, bg='yellow', fg='black', font=('Consolas', 9), relief='solid', borderwidth=1)
self._tooltip_label.place(x=x, y=y)
def reconfigure_radar(self, max_range_nm: int, scan_limit_deg: int):
"""
Updates the radar parameters (range, scan limit) of an existing PPI display.
"""
self.max_range = max_range_nm
self.scan_limit_deg = scan_limit_deg
# Update the range combobox values
steps = [10, 20, 40, 80, 100, 160]
valid_steps = sorted([s for s in steps if s <= self.max_range])
if not valid_steps: valid_steps = [self.max_range]
if self.max_range not in valid_steps:
valid_steps.append(self.max_range)
valid_steps.sort()
self.range_selector['values'] = valid_steps
self.range_var.set(self.max_range) # Set to the new max range
# Update the scan limit lines
limit_rad = np.deg2rad(self.scan_limit_deg)
self._scan_line_1.set_xdata([limit_rad, limit_rad])
self._scan_line_2.set_xdata([-limit_rad, -limit_rad])
# Apply the new range and redraw everything
self._on_range_selected()

View File

@ -1,387 +1,160 @@
# target_simulator/gui/target_list_frame.py
"""
A frame containing the Treeview for listing targets and control buttons.
"""
import tkinter as tk
from tkinter import ttk, messagebox
from typing import List, Callable, Optional
from typing import List, Callable
from core.models import Target
from core.models import Target, FPS_TO_KNOTS, NM_TO_FT
# Importa il nuovo editor di traiettoria
from .trajectory_editor_window import TrajectoryEditorWindow
class TargetListFrame(ttk.LabelFrame):
"""Frame for displaying and managing the list of targets."""
def __init__(self, master, targets_changed_callback: Callable[[List[Target]], None] | None = None):
def __init__(self, master, targets_changed_callback: Optional[Callable[[List[Target]], None]] = None):
super().__init__(master, text="Target List")
self.targets_cache: List[Target] = []
self.targets_changed_callback = targets_changed_callback
# --- Treeview for Targets ---
columns = ("id", "range", "az", "vel", "hdg", "alt")
self.tree = ttk.Treeview(self, columns=columns, show="headings")
# Define headings
self.tree.heading("id", text="ID")
self.tree.heading("range", text="Range (NM, m)")
self.tree.heading("range", text="Range (NM)")
self.tree.heading("az", text="Azimuth (°)")
self.tree.heading("vel", text="Velocity (kn, m/s)")
self.tree.heading("vel", text="Velocity (kn)")
self.tree.heading("hdg", text="Heading (°)")
self.tree.heading("alt", text="Altitude (ft)")
# Configure column widths
self.tree.column("id", width=40, anchor=tk.CENTER)
self.tree.column("range", width=120, anchor=tk.E)
self.tree.column("range", width=100, anchor=tk.E)
self.tree.column("az", width=80, anchor=tk.E)
self.tree.column("vel", width=120, anchor=tk.E)
self.tree.column("vel", width=100, anchor=tk.E)
self.tree.column("hdg", width=80, anchor=tk.E)
self.tree.column("alt", width=100, anchor=tk.E)
# --- Scrollbar ---
# --- Scrollbar & Layout ---
scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscroll=scrollbar.set)
# --- Layout ---
self.tree.grid(row=0, column=0, sticky=tk.NSEW)
scrollbar.grid(row=0, column=1, sticky=tk.NS)
self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
# --- Buttons ---
button_frame = ttk.Frame(self)
button_frame.grid(row=1, column=0, columnspan=2, pady=5)
button_frame.grid(row=1, column=0, columnspan=2, pady=5, sticky=tk.W)
self.add_button = ttk.Button(button_frame, text="Add", command=self._on_add_click)
self.add_button.pack(side=tk.LEFT, padx=5)
self.remove_button = ttk.Button(button_frame, text="Remove", command=self._on_remove_click)
self.remove_button.pack(side=tk.LEFT, padx=5)
self.edit_button = ttk.Button(button_frame, text="Edit", command=self._on_edit_click)
self.edit_button = ttk.Button(button_frame, text="Edit Trajectory...", command=self._on_edit_click)
self.edit_button.pack(side=tk.LEFT, padx=5)
# Bind double click
self.tree.bind('<Double-1>', self._on_double_click)
def get_targets(self) -> List[Target]:
"""Returns the current list of targets from the cache."""
return self.targets_cache
def _on_add_click(self):
from .add_target_window import AddTargetWindow
"""Opens the trajectory editor to create a new target."""
existing_ids = [t.target_id for t in self.targets_cache]
add_window = AddTargetWindow(self, existing_ids=existing_ids)
self.winfo_toplevel().wait_window(add_window)
if add_window.new_target:
self.targets_cache.append(add_window.new_target)
main_ppi = self.winfo_toplevel().ppi_widget
editor = TrajectoryEditorWindow(self, existing_ids=existing_ids, max_range_nm=main_ppi.max_range)
if editor.result_target:
self.targets_cache.append(editor.result_target)
self.update_target_list(self.targets_cache)
if self.targets_changed_callback:
self.targets_changed_callback(self.targets_cache)
def _on_remove_click(self):
selected_item = self.tree.focus()
if not selected_item:
messagebox.showwarning("No Selection", "Please select a target to remove.", parent=self)
return
try:
target_id = int(self.tree.item(selected_item, "values")[0])
self.targets_cache = [t for t in self.targets_cache if t.target_id != target_id]
self.update_target_list(self.targets_cache)
if self.targets_changed_callback:
self.targets_changed_callback(self.targets_cache)
except (ValueError, IndexError):
messagebox.showerror("Error", "Could not identify the selected target.", parent=self)
def _on_edit_click(self, event=None):
"""Opens the trajectory editor to modify an existing target."""
selected_item = self.tree.focus()
if not selected_item:
if event: # Don't show warning on double-click if nothing is selected
return
if not event: # Show warning only on button click, not on failed double-click
messagebox.showwarning("No Selection", "Please select a target to edit.", parent=self)
return
try:
target_id = int(self.tree.item(selected_item, "values")[0])
except (ValueError, IndexError):
messagebox.showerror("Error", "Could not identify the selected target.", parent=self)
return
return # Fail silently on invalid row data
target_to_edit = next((t for t in self.targets_cache if t.target_id == target_id), None)
if not target_to_edit:
messagebox.showerror("Error", f"Target with ID {target_id} not found.", parent=self)
messagebox.showerror("Error", f"Internal error: Target with ID {target_id} not found in cache.", parent=self)
return
from .add_target_window import AddTargetWindow
other_ids = [t.target_id for t in self.targets_cache if t.target_id != target_id]
main_ppi = self.winfo_toplevel().ppi_widget
editor = TrajectoryEditorWindow(self, existing_ids=other_ids, target_to_edit=target_to_edit, max_range_nm=main_ppi.max_range)
edit_window = AddTargetWindow(self, existing_ids=other_ids)
edit_window.title("Edit Target")
# Pre-fill data
edit_window.id_var.set(target_to_edit.target_id)
edit_window.id_spinbox.config(state='disabled')
edit_window.range_var.set(target_to_edit.initial_range_nm)
edit_window.az_var.set(target_to_edit.initial_azimuth_deg)
edit_window.alt_var.set(target_to_edit.initial_altitude_ft)
# Pre-fill from trajectory (assuming simple model for now)
if target_to_edit.trajectory:
first_waypoint = target_to_edit.trajectory[0]
fps_to_knots = 0.592484
velocity_knots = first_waypoint.target_velocity_fps * fps_to_knots
edit_window.vel_knots_var.set(velocity_knots)
edit_window.hdg_var.set(first_waypoint.target_heading_deg)
self.winfo_toplevel().wait_window(edit_window)
if edit_window.new_target:
# Find and replace the old target with the edited one
if editor.result_target:
# Replace the old target object with the new, edited one
for i, t in enumerate(self.targets_cache):
if t.target_id == target_id:
self.targets_cache[i] = edit_window.new_target
self.targets_cache[i] = editor.result_target
break
self.update_target_list(self.targets_cache)
if self.targets_changed_callback:
self.targets_changed_callback(self.targets_cache)
def _on_double_click(self, event):
# Ensure a row was actually double-clicked
if self.tree.identify_row(event.y):
self._on_edit_click(event)
def update_target_list(self, targets: List[Target]):
"""Clears and repopulates the treeview with the current list of targets."""
self.targets_cache = list(targets)
# Remember selection
selected_item = self.tree.focus()
selected_id_str = self.tree.focus()
selected_id = None
if selected_item:
if selected_id_str:
try:
selected_id = int(self.tree.item(selected_item, "values")[0])
selected_id = int(self.tree.item(selected_id_str, "values")[0])
except (ValueError, IndexError):
selected_id = None
# Clear and repopulate
self.tree.delete(*self.tree.get_children())
new_selection_item = None
for target in sorted(self.targets_cache, key=lambda t: t.target_id):
# Conversions
nm_to_m = 1852.0
fps_to_knots = 0.592484
knots_to_ms = 0.514444
range_m = target.current_range_nm * nm_to_m
vel_knots = target.current_velocity_fps * fps_to_knots
vel_ms = vel_knots * knots_to_ms
values = (
target.target_id,
f"{target.current_range_nm:.2f} / {range_m:.0f}",
f"{target.current_range_nm:.2f}",
f"{target.current_azimuth_deg:.2f}",
f"{vel_knots:.2f} / {vel_ms:.2f}",
f"{target.current_velocity_fps * FPS_TO_KNOTS:.2f}",
f"{target.current_heading_deg:.2f}",
f"{target.current_altitude_ft:.2f}",
f"{target.current_altitude_ft:.0f}",
)
item = self.tree.insert("", tk.END, values=values)
item_id = self.tree.insert("", tk.END, values=values)
if target.target_id == selected_id:
new_selection_item = item
# Restore selection
new_selection_item = item_id
if new_selection_item:
self.tree.focus(new_selection_item)
self.tree.selection_set(new_selection_item)

View File

@ -0,0 +1,228 @@
# target_simulator/gui/trajectory_editor_window.py
"""
A Toplevel window for visually editing a target's trajectory using waypoints,
including a live preview of the path.
"""
import tkinter as tk
from tkinter import ttk, messagebox
from typing import List, Optional
import copy
# Use absolute imports for robustness
from target_simulator.core.models import Target, Waypoint, MIN_TARGET_ID, MAX_TARGET_ID, KNOTS_TO_FPS, FPS_TO_KNOTS
from target_simulator.gui.ppi_display import PPIDisplay
class TrajectoryEditorWindow(tk.Toplevel):
"""A dialog for creating and editing a target's trajectory with a visual preview."""
def __init__(self, master, existing_ids: List[int], max_range_nm: int, target_to_edit: Optional[Target] = None):
super().__init__(master)
self.transient(master)
self.grab_set()
self.title("Trajectory Editor")
self.resizable(False, False)
self.existing_ids = existing_ids
self.result_target: Optional[Target] = None
self.initial_max_range = max_range_nm
# --- Internal State ---
if target_to_edit:
self.target_id = target_to_edit.target_id
self.initial_range = target_to_edit.initial_range_nm
self.initial_az = target_to_edit.initial_azimuth_deg
self.initial_alt = target_to_edit.initial_altitude_ft
self.waypoints = copy.deepcopy(target_to_edit.trajectory)
else:
self.target_id = next((i for i in range(MIN_TARGET_ID, MAX_TARGET_ID + 1) if i not in existing_ids), -1)
self.initial_range = 20.0
self.initial_az = 0.0
self.initial_alt = 10000.0
self.waypoints: List[Waypoint] = []
self._create_widgets()
self._populate_waypoint_list()
self._update_preview()
# --- NUOVA LOGICA DI CENTRATURA SULLA FINESTRA PADRE ---
self.update_idletasks()
self.title("Target editor")
self.geometry('1200x1024')
self.minsize(1024, 768)
# --- FINE LOGICA DI CENTRATURA ---
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.wait_window(self)
def _center_window(self):
"""Centers the window on its parent."""
self.update_idletasks()
parent = self.master
x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
self.geometry(f"+{x}+{y}")
def _create_widgets(self):
main_frame = ttk.Frame(self, padding=10)
main_frame.pack(fill=tk.BOTH, expand=True)
main_frame.columnconfigure(2, weight=1)
main_frame.rowconfigure(1, weight=1)
initial_state_frame = ttk.LabelFrame(main_frame, text="Initial State")
initial_state_frame.grid(row=0, column=0, columnspan=3, sticky=tk.EW, pady=(0, 10))
self._create_initial_state_widgets(initial_state_frame)
list_frame = ttk.LabelFrame(main_frame, text="Waypoints")
list_frame.grid(row=1, column=0, sticky=tk.NS, padx=(0, 10))
self._create_waypoint_list_widgets(list_frame)
self.details_frame = ttk.LabelFrame(main_frame, text="Waypoint Details")
self.details_frame.grid(row=1, column=1, sticky=tk.NS)
self._create_waypoint_details_widgets(self.details_frame)
preview_frame = ttk.LabelFrame(main_frame, text="Trajectory Preview")
preview_frame.grid(row=1, column=2, sticky=tk.NSEW, padx=(10, 0))
# --- Instantiate the reusable PPIDisplay widget ---
self.ppi_preview = PPIDisplay(preview_frame, max_range_nm=self.initial_max_range)
self.ppi_preview.pack(fill=tk.BOTH, expand=True)
# We can hide the controls if they are not needed in this context
#self.ppi_preview.controls_frame.pack_forget()
button_frame = ttk.Frame(main_frame)
button_frame.grid(row=2, column=0, columnspan=3, sticky=tk.E, pady=(10, 0))
ttk.Button(button_frame, text="OK", command=self._on_ok).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="Cancel", command=self._on_cancel).pack(side=tk.LEFT)
def _create_initial_state_widgets(self, parent):
parent.columnconfigure(1, weight=1); parent.columnconfigure(3, weight=1)
self.id_var = tk.IntVar(value=self.target_id)
ttk.Label(parent, text="ID:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2)
id_spinbox = ttk.Spinbox(parent, from_=MIN_TARGET_ID, to=MAX_TARGET_ID, textvariable=self.id_var, width=8, command=self._update_preview)
id_spinbox.grid(row=0, column=1, sticky=tk.W, padx=5, pady=2)
if self.target_id in self.existing_ids: id_spinbox.config(state='disabled')
self.range_var = tk.DoubleVar(value=self.initial_range)
self.az_var = tk.DoubleVar(value=self.initial_az)
self.alt_var = tk.DoubleVar(value=self.initial_alt)
ttk.Label(parent, text="Range (NM):").grid(row=0, column=2, sticky=tk.W, padx=5, pady=2)
ttk.Spinbox(parent, from_=0, to=1000, textvariable=self.range_var, width=8, command=self._update_preview).grid(row=0, column=3, sticky=tk.W, padx=5, pady=2)
ttk.Label(parent, text="Azimuth (°):").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2)
ttk.Spinbox(parent, from_=-180, to=180, textvariable=self.az_var, width=8, command=self._update_preview).grid(row=1, column=1, sticky=tk.W, padx=5, pady=2)
ttk.Label(parent, text="Altitude (ft):").grid(row=1, column=2, sticky=tk.W, padx=5, pady=2)
ttk.Spinbox(parent, from_=-1000, to=80000, textvariable=self.alt_var, width=8).grid(row=1, column=3, sticky=tk.W, padx=5, pady=2)
def _create_waypoint_list_widgets(self, parent):
self.wp_tree = ttk.Treeview(parent, columns=("duration", "vel", "hdg"), show="headings", height=8)
self.wp_tree.heading("duration", text="Duration (s)"); self.wp_tree.heading("vel", text="Vel (kn)"); self.wp_tree.heading("hdg", text="Hdg (°)")
self.wp_tree.column("duration", width=80, anchor=tk.E); self.wp_tree.column("vel", width=80, anchor=tk.E); self.wp_tree.column("hdg", width=80, anchor=tk.E)
self.wp_tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self.wp_tree.bind("<<TreeviewSelect>>", self._on_waypoint_select)
btn_frame = ttk.Frame(parent)
btn_frame.pack(fill=tk.X, pady=5)
ttk.Button(btn_frame, text="Add", command=self._on_add_waypoint).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="Remove", command=self._on_remove_waypoint).pack(side=tk.LEFT, padx=2)
def _create_waypoint_details_widgets(self, parent):
parent.columnconfigure(1, weight=1)
self.wp_duration_var = tk.DoubleVar(); self.wp_vel_var = tk.DoubleVar(); self.wp_hdg_var = tk.DoubleVar()
ttk.Label(parent, text="Duration (s):").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5)
self.wp_duration_spinbox = ttk.Spinbox(parent, from_=1, to=3600, textvariable=self.wp_duration_var)
self.wp_duration_spinbox.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5)
ttk.Label(parent, text="Target Vel (kn):").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5)
self.wp_vel_spinbox = ttk.Spinbox(parent, from_=0, to=2000, textvariable=self.wp_vel_var)
self.wp_vel_spinbox.grid(row=1, column=1, sticky=tk.EW, padx=5, pady=5)
ttk.Label(parent, text="Target Hdg (°):").grid(row=2, column=0, sticky=tk.W, padx=5, pady=5)
self.wp_hdg_spinbox = ttk.Spinbox(parent, from_=0, to=359.9, textvariable=self.wp_hdg_var)
self.wp_hdg_spinbox.grid(row=2, column=1, sticky=tk.EW, padx=5, pady=5)
ttk.Button(parent, text="Apply Changes", command=self._on_apply_changes).grid(row=3, column=0, columnspan=2, pady=10)
for child in parent.winfo_children(): child.configure(state=tk.DISABLED)
def _update_preview(self):
"""Collects current editor data and tells the PPI widget to draw the preview."""
initial_state = {
"initial_range_nm": self.range_var.get(),
"initial_azimuth_deg": self.az_var.get(),
"initial_altitude_ft": self.alt_var.get()
}
self.ppi_preview.draw_trajectory_preview(initial_state, self.waypoints)
def _on_apply_changes(self):
selected_item = self.wp_tree.focus()
if not selected_item: return
wp_index = int(selected_item)
try:
wp = self.waypoints[wp_index]
wp.duration_s = self.wp_duration_var.get()
wp.target_velocity_fps = self.wp_vel_var.get() * KNOTS_TO_FPS
wp.target_heading_deg = self.wp_hdg_var.get()
self._populate_waypoint_list()
self._update_preview()
self.wp_tree.focus(selected_item); self.wp_tree.selection_set(selected_item)
except (ValueError, tk.TclError):
messagebox.showerror("Invalid Input", "Please enter valid numbers.", parent=self)
def _on_add_waypoint(self):
last_wp = self.waypoints[-1] if self.waypoints else None
new_wp = Waypoint(
duration_s=10.0,
target_velocity_fps=last_wp.target_velocity_fps if last_wp else 500.0,
target_heading_deg=last_wp.target_heading_deg if last_wp else 90.0
)
self.waypoints.append(new_wp)
self._populate_waypoint_list()
self._update_preview()
new_item_id = str(len(self.waypoints) - 1)
self.wp_tree.focus(new_item_id); self.wp_tree.selection_set(new_item_id)
def _on_remove_waypoint(self):
selected_item = self.wp_tree.focus()
if not selected_item:
messagebox.showwarning("No Selection", "Please select a waypoint to remove.", parent=self)
return
wp_index = int(selected_item)
del self.waypoints[wp_index]
self._populate_waypoint_list()
self._update_preview()
for child in self.details_frame.winfo_children(): child.configure(state=tk.DISABLED)
def _on_ok(self):
target_id = self.id_var.get()
if target_id != self.target_id and target_id in self.existing_ids:
messagebox.showerror("Invalid ID", f"Target ID {target_id} is already in use.", parent=self)
return
if not self.waypoints:
messagebox.showerror("No Trajectory", "A target must have at least one waypoint.", parent=self)
return
try:
self.result_target = Target(target_id=target_id, initial_range_nm=self.range_var.get(), initial_azimuth_deg=self.az_var.get(), initial_altitude_ft=self.alt_var.get(), trajectory=self.waypoints)
self.destroy()
except (ValueError, tk.TclError) as e:
messagebox.showerror("Validation Error", str(e), parent=self)
def _on_cancel(self):
self.result_target = None
self.destroy()
def _populate_waypoint_list(self):
self.wp_tree.delete(*self.wp_tree.get_children())
for i, wp in enumerate(self.waypoints):
self.wp_tree.insert("", tk.END, iid=str(i), values=(f"{wp.duration_s:.1f}", f"{wp.target_velocity_fps * FPS_TO_KNOTS:.2f}", f"{wp.target_heading_deg:.2f}"))
def _on_waypoint_select(self, event=None):
selected_item = self.wp_tree.focus()
if not selected_item: return
for child in self.details_frame.winfo_children(): child.configure(state=tk.NORMAL)
wp = self.waypoints[int(selected_item)]
self.wp_duration_var.set(wp.duration_s)
self.wp_vel_var.set(wp.target_velocity_fps * FPS_TO_KNOTS)
self.wp_hdg_var.set(wp.target_heading_deg)