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 queue import Queue, Empty
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
# Imports from GUI components # Use absolute imports for robustness and clarity
from .ppi_display import PPIDisplay from target_simulator.gui.ppi_display import PPIDisplay
from .connection_settings_window import ConnectionSettingsWindow from target_simulator.gui.connection_settings_window import ConnectionSettingsWindow
from .radar_config_window import RadarConfigWindow from target_simulator.gui.radar_config_window import RadarConfigWindow
from .scenario_controls_frame import ScenarioControlsFrame from target_simulator.gui.scenario_controls_frame import ScenarioControlsFrame
from .target_list_frame import TargetListFrame from target_simulator.gui.target_list_frame import TargetListFrame
# Imports from Core components from target_simulator.core.communicator_interface import CommunicatorInterface
from core.communicator_interface import CommunicatorInterface from target_simulator.core.serial_communicator import SerialCommunicator
from core.serial_communicator import SerialCommunicator from target_simulator.core.tftp_communicator import TFTPCommunicator
from core.tftp_communicator import TFTPCommunicator from target_simulator.core.simulation_engine import SimulationEngine
from core.simulation_engine import SimulationEngine from target_simulator.core.models import Scenario, Target
from core.models import Scenario, Target
# Imports from Utils from target_simulator.utils.logger import get_logger, shutdown_logging_system
from utils.logger import get_logger, shutdown_logging_system from target_simulator.utils.config_manager import ConfigManager
from utils.config_manager import ConfigManager
GUI_QUEUE_POLL_INTERVAL_MS = 100 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.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.h_pane.add(self.ppi_widget, weight=2)
controls_frame = self.ppi_widget.children.get('!frame') # Add Connect button to the PPI's own control frame for better layout
if controls_frame: if hasattr(self.ppi_widget, 'controls_frame'):
connect_btn = ttk.Button(controls_frame, text="Connect", command=self._on_connect_button) connect_btn = ttk.Button(self.ppi_widget.controls_frame, text="Connect", command=self._on_connect_button)
connect_btn.pack(side=tk.RIGHT, padx=10) connect_btn.pack(side=tk.RIGHT, padx=10)
# --- Left Pane --- # --- Left Pane ---
@ -416,13 +414,31 @@ class MainView(tk.Tk):
def _open_radar_config(self): def _open_radar_config(self):
self.logger.info("Opening radar config window.") self.logger.info("Opening radar config window.")
dialog = RadarConfigWindow(self, current_scan_limit=self.scan_limit, current_max_range=self.max_range) 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: 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.scan_limit = dialog.scan_limit
self.max_range = dialog.max_range 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) # --- LOGICA MODIFICATA ---
self.h_pane.add(self.ppi_widget, weight=2) # Non distruggere il widget, ma riconfiguralo.
self._update_all_views() 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): def _on_closing(self):
self.logger.info("Application shutting down.") self.logger.info("Application shutting down.")

View File

@ -1,179 +1,220 @@
# target_simulator/gui/ppi_display.py # 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 import tkinter as tk
from tkinter import ttk from tkinter import ttk
import math import math
import numpy as np import numpy as np
import copy
from matplotlib.figure import Figure from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg 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): 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): def __init__(self, master, max_range_nm: int = 100, scan_limit_deg: int = 60):
super().__init__(master) super().__init__(master)
self.max_range = max_range_nm self.max_range = max_range_nm
self.scan_limit_deg = scan_limit_deg self.scan_limit_deg = scan_limit_deg
self.target_artists = [] self.target_artists = []
self.active_targets: List[Target] = []
self._target_dots = []
self.preview_artists = []
self._create_controls() self._create_controls()
self._create_plot() self._create_plot()
def _create_controls(self): def _create_controls(self):
"""Creates the control widgets for the PPI display.""" """Creates the control widgets for the PPI display."""
controls_frame = ttk.Frame(self) self.controls_frame = ttk.Frame(self)
controls_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) 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 # Create a list of valid range steps up to the theoretical max_range
steps = list(range(self.max_range, 19, -20)) all_steps = [10, 20, 40, 80, 100, 160]
if 10 not in steps: valid_steps = sorted([s for s in all_steps if s <= self.max_range])
steps.append(10) 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_var = tk.IntVar(value=self.max_range)
self.range_selector = ttk.Combobox( self.range_selector = ttk.Combobox(
controls_frame, self.controls_frame, textvariable=self.range_var,
textvariable=self.range_var, values=valid_steps, state="readonly", width=5
values=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) self.range_selector.bind("<<ComboboxSelected>>", self._on_range_selected)
def _create_plot(self): def _create_plot(self):
"""Initializes the Matplotlib polar plot.""" """Initializes the Matplotlib polar plot."""
# Dark theme for the plot
fig = Figure(figsize=(5, 5), dpi=100, facecolor='#3E3E3E') fig = Figure(figsize=(5, 5), dpi=100, facecolor='#3E3E3E')
fig.subplots_adjust(left=0.05, right=0.95, top=0.85, bottom=0.05) 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') self.ax = fig.add_subplot(111, projection='polar', facecolor='#2E2E2E')
# Configure polar axes self.ax.set_theta_zero_location('N')
self.ax.set_theta_zero_location('N') # 0 degrees at the top self.ax.set_theta_direction(-1)
self.ax.set_theta_direction(-1) # Clockwise rotation
self.ax.set_rlabel_position(90) 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) angles = np.arange(0, 360, 30)
labels = [] 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]
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}°")
self.ax.set_thetagrids(angles, labels) self.ax.set_thetagrids(angles, labels)
# Style the plot self.ax.tick_params(axis='x', colors='white', labelsize=8)
self.ax.tick_params(axis='x', colors='white') self.ax.tick_params(axis='y', colors='white', labelsize=8)
self.ax.tick_params(axis='y', colors='white')
self.ax.grid(color='white', linestyle='--', linewidth=0.5, alpha=0.5) self.ax.grid(color='white', linestyle='--', linewidth=0.5, alpha=0.5)
self.ax.spines['polar'].set_color('white') self.ax.spines['polar'].set_color('white')
self.ax.set_title("PPI Display", color='white', y=1.08) self.ax.set_title("PPI Display", color='white', y=1.08)
# Draw scan sector limits # --- Artists for drawing ---
limit_rad = np.deg2rad(self.scan_limit_deg) self._start_plot, = self.ax.plot([], [], 'go', markersize=8)
self.ax.plot([limit_rad, limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) self._waypoints_plot, = self.ax.plot([], [], 'y+', markersize=10, mew=2, linestyle='None')
self.ax.plot([-limit_rad, -limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) 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 = FigureCanvasTkAgg(fig, master=self)
self.canvas.draw() self.canvas.draw()
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True) 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): # --- NEW: Initial draw of scan lines ---
"""Handles the selection of a new range from the combobox.""" 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() new_range = self.range_var.get()
self.ax.set_ylim(0, new_range) 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() self.canvas.draw()
# ... il resto dei metodi rimane invariato ...
def update_targets(self, targets: List[Target]): def update_targets(self, targets: List[Target]):
""" # (This method is unchanged)
Aggiorna la posizione dei target sulla PPI e mostra l'ID accanto al punto. Tooltip con ID al passaggio del mouse.
"""
if targets:
self.active_targets = [t for t in targets if t.active] self.active_targets = [t for t in targets if t.active]
for artist in self.target_artists: artist.remove()
# Rimuovi artisti precedenti
for artist in self.target_artists:
artist.remove()
self.target_artists.clear() self.target_artists.clear()
self._target_dots.clear()
# Rimuovi eventuali tooltips precedenti
if hasattr(self, 'tooltips'):
for tip in self.tooltips:
tip.remove()
self.tooltips = []
vector_len = self.range_var.get() / 25 vector_len = self.range_var.get() / 25
self._target_dots = [] for target in self.active_targets:
r = target.current_range_nm
for target in getattr(self, 'active_targets', []): theta = np.deg2rad(target.current_azimuth_deg)
r1 = target.current_range_nm dot, = self.ax.plot(theta, r, 'o', markersize=5, color='red')
th1 = np.deg2rad(target.current_azimuth_deg)
# Punto del target
dot, = self.ax.plot(th1, r1, 'o', markersize=5, color='red', picker=5)
self.target_artists.append(dot) self.target_artists.append(dot)
self._target_dots.append((dot, target)) self._target_dots.append((dot, target))
x1, y1 = r * np.sin(theta), r * np.cos(theta)
# Vettore heading
x1 = r1 * np.sin(th1)
y1 = r1 * np.cos(th1)
h_rad = np.deg2rad(target.current_heading_deg) h_rad = np.deg2rad(target.current_heading_deg)
dx = vector_len * np.sin(h_rad) dx, dy = vector_len * np.sin(h_rad), vector_len * np.cos(h_rad)
dy = vector_len * np.cos(h_rad)
x2, y2 = x1 + dx, y1 + dy x2, y2 = x1 + dx, y1 + dy
r2 = np.sqrt(x2**2 + y2**2) r2, th2 = np.sqrt(x2**2 + y2**2), np.arctan2(x2, y2)
th2 = np.arctan2(x2, y2) line, = self.ax.plot([theta, th2], [r, r2], color='red', linewidth=1.2)
line, = self.ax.plot([th1, th2], [r1, r2], color='red', linewidth=1.2)
self.target_artists.append(line) self.target_artists.append(line)
self.canvas.draw()
# Gestione tooltip def draw_trajectory_preview(self, initial_state: dict, waypoints: List[Waypoint]):
def on_motion(event): # (This method is unchanged)
# Mostra hint se il mouse è vicino a un punto target 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 event.inaxes != self.ax:
if hasattr(self, '_tooltip_label') and self._tooltip_label: if self._tooltip_label: self._tooltip_label.place_forget(); self._tooltip_label = None
self._tooltip_label.place_forget()
self._tooltip_label = None
return return
found = False found = False
for dot, target in self._target_dots: for dot, target in self._target_dots:
cont, _ = dot.contains(event) cont, _ = dot.contains(event)
if cont: 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
self._show_tooltip(event.x + 10, event.y + 10, f"ID: {target.target_id}") if not found and self._tooltip_label: self._tooltip_label.place_forget(); self._tooltip_label = None
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()
def _show_tooltip(self, x, y, text): def _show_tooltip(self, x, y, text):
# Mostra un tooltip Tkinter sopra la canvas, centrato sul punto target # (This method is unchanged)
if self._tooltip_label: if self._tooltip_label: self._tooltip_label.place_forget()
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 = 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) 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 # target_simulator/gui/target_list_frame.py
""" """
A frame containing the Treeview for listing targets and control buttons. A frame containing the Treeview for listing targets and control buttons.
""" """
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox from tkinter import ttk, messagebox
from typing import List, Callable, Optional
from typing import List, Callable from core.models import Target, FPS_TO_KNOTS, NM_TO_FT
# Importa il nuovo editor di traiettoria
from core.models import Target from .trajectory_editor_window import TrajectoryEditorWindow
class TargetListFrame(ttk.LabelFrame): class TargetListFrame(ttk.LabelFrame):
"""Frame for displaying and managing the list of targets.""" """Frame for displaying and managing the list of targets."""
def __init__(self, master, targets_changed_callback: Optional[Callable[[List[Target]], None]] = None):
def __init__(self, master, targets_changed_callback: Callable[[List[Target]], None] | None = None):
super().__init__(master, text="Target List") super().__init__(master, text="Target List")
self.targets_cache: List[Target] = [] self.targets_cache: List[Target] = []
self.targets_changed_callback = targets_changed_callback self.targets_changed_callback = targets_changed_callback
# --- Treeview for Targets --- # --- Treeview for Targets ---
columns = ("id", "range", "az", "vel", "hdg", "alt") columns = ("id", "range", "az", "vel", "hdg", "alt")
self.tree = ttk.Treeview(self, columns=columns, show="headings") self.tree = ttk.Treeview(self, columns=columns, show="headings")
# Define headings
self.tree.heading("id", text="ID") self.tree.heading("id", text="ID")
self.tree.heading("range", text="Range (NM)")
self.tree.heading("range", text="Range (NM, m)")
self.tree.heading("az", text="Azimuth (°)") self.tree.heading("az", text="Azimuth (°)")
self.tree.heading("vel", text="Velocity (kn)")
self.tree.heading("vel", text="Velocity (kn, m/s)")
self.tree.heading("hdg", text="Heading (°)") self.tree.heading("hdg", text="Heading (°)")
self.tree.heading("alt", text="Altitude (ft)") self.tree.heading("alt", text="Altitude (ft)")
# Configure column widths
self.tree.column("id", width=40, anchor=tk.CENTER) self.tree.column("id", width=40, anchor=tk.CENTER)
self.tree.column("range", width=100, anchor=tk.E)
self.tree.column("range", width=120, anchor=tk.E)
self.tree.column("az", width=80, anchor=tk.E) self.tree.column("az", width=80, anchor=tk.E)
self.tree.column("vel", width=100, anchor=tk.E)
self.tree.column("vel", width=120, anchor=tk.E)
self.tree.column("hdg", width=80, anchor=tk.E) self.tree.column("hdg", width=80, anchor=tk.E)
self.tree.column("alt", width=100, anchor=tk.E) self.tree.column("alt", width=100, anchor=tk.E)
# --- Scrollbar & Layout ---
# --- Scrollbar ---
scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview) scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscroll=scrollbar.set) self.tree.configure(yscroll=scrollbar.set)
# --- Layout ---
self.tree.grid(row=0, column=0, sticky=tk.NSEW) self.tree.grid(row=0, column=0, sticky=tk.NSEW)
scrollbar.grid(row=0, column=1, sticky=tk.NS) scrollbar.grid(row=0, column=1, sticky=tk.NS)
self.rowconfigure(0, weight=1) self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
# --- Buttons --- # --- Buttons ---
button_frame = ttk.Frame(self) button_frame = ttk.Frame(self)
button_frame.grid(row=1, column=0, columnspan=2, pady=5, sticky=tk.W)
button_frame.grid(row=1, column=0, columnspan=2, pady=5)
self.add_button = ttk.Button(button_frame, text="Add", command=self._on_add_click) self.add_button = ttk.Button(button_frame, text="Add", command=self._on_add_click)
self.add_button.pack(side=tk.LEFT, padx=5) 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 = ttk.Button(button_frame, text="Remove", command=self._on_remove_click)
self.remove_button.pack(side=tk.LEFT, padx=5) self.remove_button.pack(side=tk.LEFT, padx=5)
self.edit_button = ttk.Button(button_frame, text="Edit Trajectory...", command=self._on_edit_click)
self.edit_button = ttk.Button(button_frame, text="Edit", command=self._on_edit_click)
self.edit_button.pack(side=tk.LEFT, padx=5) self.edit_button.pack(side=tk.LEFT, padx=5)
# Bind double click
self.tree.bind('<Double-1>', self._on_double_click) self.tree.bind('<Double-1>', self._on_double_click)
def get_targets(self) -> List[Target]: def get_targets(self) -> List[Target]:
"""Returns the current list of targets from the cache.""" """Returns the current list of targets from the cache."""
return self.targets_cache return self.targets_cache
def _on_add_click(self): def _on_add_click(self):
"""Opens the trajectory editor to create a new target."""
from .add_target_window import AddTargetWindow
existing_ids = [t.target_id for t in self.targets_cache] existing_ids = [t.target_id for t in self.targets_cache]
add_window = AddTargetWindow(self, existing_ids=existing_ids) main_ppi = self.winfo_toplevel().ppi_widget
editor = TrajectoryEditorWindow(self, existing_ids=existing_ids, max_range_nm=main_ppi.max_range)
self.winfo_toplevel().wait_window(add_window)
if add_window.new_target:
self.targets_cache.append(add_window.new_target)
if editor.result_target:
self.targets_cache.append(editor.result_target)
self.update_target_list(self.targets_cache) self.update_target_list(self.targets_cache)
if self.targets_changed_callback: if self.targets_changed_callback:
self.targets_changed_callback(self.targets_cache) self.targets_changed_callback(self.targets_cache)
def _on_remove_click(self): def _on_remove_click(self):
selected_item = self.tree.focus() selected_item = self.tree.focus()
if not selected_item: if not selected_item:
messagebox.showwarning("No Selection", "Please select a target to remove.", parent=self) messagebox.showwarning("No Selection", "Please select a target to remove.", parent=self)
return return
try: try:
target_id = int(self.tree.item(selected_item, "values")[0]) 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.targets_cache = [t for t in self.targets_cache if t.target_id != target_id]
self.update_target_list(self.targets_cache) self.update_target_list(self.targets_cache)
if self.targets_changed_callback: if self.targets_changed_callback:
self.targets_changed_callback(self.targets_cache) self.targets_changed_callback(self.targets_cache)
except (ValueError, IndexError): except (ValueError, IndexError):
messagebox.showerror("Error", "Could not identify the selected target.", parent=self) messagebox.showerror("Error", "Could not identify the selected target.", parent=self)
def _on_edit_click(self, event=None): def _on_edit_click(self, event=None):
"""Opens the trajectory editor to modify an existing target."""
selected_item = self.tree.focus() selected_item = self.tree.focus()
if not selected_item: if not selected_item:
if not event: # Show warning only on button click, not on failed double-click
if event: # Don't show warning on double-click if nothing is selected
return
messagebox.showwarning("No Selection", "Please select a target to edit.", parent=self) messagebox.showwarning("No Selection", "Please select a target to edit.", parent=self)
return return
try: try:
target_id = int(self.tree.item(selected_item, "values")[0]) target_id = int(self.tree.item(selected_item, "values")[0])
except (ValueError, IndexError): except (ValueError, IndexError):
return # Fail silently on invalid row data
messagebox.showerror("Error", "Could not identify the selected target.", parent=self)
return
target_to_edit = next((t for t in self.targets_cache if t.target_id == target_id), None) target_to_edit = next((t for t in self.targets_cache if t.target_id == target_id), None)
if not target_to_edit: if not target_to_edit:
messagebox.showerror("Error", f"Internal error: Target with ID {target_id} not found in cache.", parent=self)
messagebox.showerror("Error", f"Target with ID {target_id} not found.", parent=self)
return return
from .add_target_window import AddTargetWindow
other_ids = [t.target_id for t in self.targets_cache if t.target_id != target_id] 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) if editor.result_target:
# Replace the old target object with the new, edited one
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
for i, t in enumerate(self.targets_cache): for i, t in enumerate(self.targets_cache):
if t.target_id == target_id: if t.target_id == target_id:
self.targets_cache[i] = editor.result_target
self.targets_cache[i] = edit_window.new_target
break break
self.update_target_list(self.targets_cache) self.update_target_list(self.targets_cache)
if self.targets_changed_callback: if self.targets_changed_callback:
self.targets_changed_callback(self.targets_cache) self.targets_changed_callback(self.targets_cache)
def _on_double_click(self, event): def _on_double_click(self, event):
# Ensure a row was actually double-clicked
if self.tree.identify_row(event.y): if self.tree.identify_row(event.y):
self._on_edit_click(event) self._on_edit_click(event)
def update_target_list(self, targets: List[Target]): def update_target_list(self, targets: List[Target]):
"""Clears and repopulates the treeview with the current list of targets.""" """Clears and repopulates the treeview with the current list of targets."""
self.targets_cache = list(targets) self.targets_cache = list(targets)
selected_id_str = self.tree.focus()
# Remember selection
selected_item = self.tree.focus()
selected_id = None selected_id = None
if selected_id_str:
if selected_item:
try: try:
selected_id = int(self.tree.item(selected_id_str, "values")[0])
selected_id = int(self.tree.item(selected_item, "values")[0])
except (ValueError, IndexError): except (ValueError, IndexError):
selected_id = None selected_id = None
# Clear and repopulate
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
new_selection_item = None new_selection_item = None
for target in sorted(self.targets_cache, key=lambda t: t.target_id): 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 = ( values = (
target.target_id, target.target_id,
f"{target.current_range_nm:.2f}",
f"{target.current_range_nm:.2f} / {range_m:.0f}",
f"{target.current_azimuth_deg:.2f}", f"{target.current_azimuth_deg:.2f}",
f"{target.current_velocity_fps * FPS_TO_KNOTS:.2f}",
f"{vel_knots:.2f} / {vel_ms:.2f}",
f"{target.current_heading_deg:.2f}", f"{target.current_heading_deg:.2f}",
f"{target.current_altitude_ft:.0f}",
f"{target.current_altitude_ft:.2f}",
) )
item_id = self.tree.insert("", tk.END, values=values)
item = self.tree.insert("", tk.END, values=values)
if target.target_id == selected_id: if target.target_id == selected_id:
new_selection_item = item_id
new_selection_item = item
# Restore selection
if new_selection_item: if new_selection_item:
self.tree.focus(new_selection_item) self.tree.focus(new_selection_item)
self.tree.selection_set(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)