add waypoint editor
This commit is contained in:
parent
2ce621f505
commit
7a9c530f25
@ -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:
|
||||||
self.scan_limit = dialog.scan_limit
|
# Check if values have actually changed to avoid unnecessary redraws
|
||||||
self.max_range = dialog.max_range
|
if self.scan_limit != dialog.scan_limit or self.max_range != dialog.max_range:
|
||||||
self.ppi_widget.destroy()
|
self.logger.info("Radar configuration changed. Applying new settings.")
|
||||||
self.ppi_widget = PPIDisplay(self.h_pane, max_range_nm=self.max_range, scan_limit_deg=self.scan_limit)
|
self.scan_limit = dialog.scan_limit
|
||||||
self.h_pane.add(self.ppi_widget, weight=2)
|
self.max_range = dialog.max_range
|
||||||
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):
|
def _on_closing(self):
|
||||||
self.logger.info("Application shutting down.")
|
self.logger.info("Application shutting down.")
|
||||||
|
|||||||
@ -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("0°")
|
|
||||||
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)
|
||||||
|
|
||||||
|
# --- NEW: Initial draw of scan lines ---
|
||||||
|
self._update_scan_lines()
|
||||||
|
|
||||||
def _on_range_selected(self, event):
|
def _update_scan_lines(self):
|
||||||
"""Handles the selection of a new range from the combobox."""
|
"""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.
|
self.active_targets = [t for t in targets if t.active]
|
||||||
"""
|
for artist in self.target_artists: artist.remove()
|
||||||
if targets:
|
|
||||||
self.active_targets = [t for t in targets if t.active]
|
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
# Gestione tooltip
|
|
||||||
def on_motion(event):
|
|
||||||
# Mostra hint se il mouse è vicino a un punto target
|
|
||||||
if event.inaxes != self.ax:
|
|
||||||
if hasattr(self, '_tooltip_label') and 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.canvas.draw()
|
||||||
|
|
||||||
|
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 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:
|
||||||
|
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):
|
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()
|
||||||
@ -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)
|
|
||||||
|
|
||||||
self.winfo_toplevel().wait_window(add_window)
|
|
||||||
|
|
||||||
|
|
||||||
|
main_ppi = self.winfo_toplevel().ppi_widget
|
||||||
if add_window.new_target:
|
editor = TrajectoryEditorWindow(self, existing_ids=existing_ids, max_range_nm=main_ppi.max_range)
|
||||||
|
|
||||||
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
|
messagebox.showwarning("No Selection", "Please select a target to edit.", parent=self)
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
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)
|
||||||
228
target_simulator/gui/trajectory_editor_window.py
Normal file
228
target_simulator/gui/trajectory_editor_window.py
Normal 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)
|
||||||
Loading…
Reference in New Issue
Block a user