GTK4/libadwaita desktop helper for IRON NEST: Heavy Turret Simulator. Reads clipboard screenshots of the game's typewriter orders via Tesseract OCR, parses absolute/relative entity positions, geometrically resolves relative bearing/distance clues into map coordinates, and provides a firing-commands sidebar with real ballistics (elevation/azimuth/powder charge). Screen-reading only — no game files touched, no input injected. - models.py: Board/Nest/Spotter/ReferencePoint/Target data model - ocr.py: Tesseract preprocessing + typewriter-text parsing - solver.py: bearing/distance geometric resolution (4 solvable shapes) plus position-based dedup for generic contacts later identified more specifically at the same resolved coord - ballistics.py / shells.py: elevation/azimuth/charge math, shell types - grid_widget.py: interactive map canvas (hidden entities and, optionally, dead targets are excluded from the map view entirely unless selected) - firing_panel.py: drag-reorderable firing-command sidebar - app.py: main window wiring it all together Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
534 lines
22 KiB
Python
534 lines
22 KiB
Python
"""GridCanvas: draws the 20x10 map, every placed entity, ambiguous
|
|
solver candidates, and geo-description overlays (bearing/distance clues)
|
|
for whatever's hovered or pinned via show_geo_desc. Also handles
|
|
click-to-select and hover notification so the firing-commands panel can
|
|
stay in sync with the map (see app.py)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "4.0")
|
|
gi.require_version("Gdk", "4.0")
|
|
from gi.repository import Gdk, Gtk # noqa: E402
|
|
|
|
from . import solver
|
|
from .models import LARGE_X, Board, Target
|
|
|
|
COLS, ROWS = 20, 10
|
|
|
|
MARGIN_LEFT = 34
|
|
MARGIN_TOP = 30
|
|
MARGIN_RIGHT = 50
|
|
MARGIN_BOTTOM = 30
|
|
|
|
LABEL_PAD = 8 # gap between a marker and its name label
|
|
|
|
HOVER_RADIUS_PX = 12
|
|
OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin
|
|
|
|
CATEGORY_COLOR = {
|
|
"nest": (0.35, 0.60, 0.95),
|
|
"spotter": (0.35, 0.78, 0.40),
|
|
"rp": (0.95, 0.78, 0.20),
|
|
"target": (0.92, 0.30, 0.28),
|
|
}
|
|
|
|
BG = (0.13, 0.12, 0.10)
|
|
GRID_LINE = (1.0, 1.0, 1.0, 0.20)
|
|
LABEL = (0.88, 0.86, 0.80)
|
|
COORD_LABEL = (0.60, 0.58, 0.54)
|
|
YELLOW = (0.95, 0.85, 0.20)
|
|
WHITE = (1.0, 1.0, 1.0)
|
|
FIRING_ARROW = (0.95, 0.15, 0.15)
|
|
SELECTION_RING = (1.0, 1.0, 1.0)
|
|
BLAST_RADIUS = (0.95, 0.40, 0.10)
|
|
PLACEMENT_PREVIEW = (0.95, 0.85, 0.20)
|
|
|
|
|
|
class GridCanvas(Gtk.DrawingArea):
|
|
def __init__(self, board: Board) -> None:
|
|
super().__init__()
|
|
self.board = board
|
|
self.hovered = None
|
|
self.hovered_point = None # which candidate, when obj has more than one point
|
|
self.selected = None
|
|
self.selected_point = None # which candidate, when obj has more than one point
|
|
self.on_select = None # callback(obj | None, point | None), fired on click
|
|
self.on_hover_change = None # callback(obj | None, point | None), fired on hover change
|
|
self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave
|
|
self.on_right_click = None # callback(Coord, x, y), fired on right-click (unless placing)
|
|
self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar
|
|
|
|
# Placement mode: while armed, the next left-click calls
|
|
# placement_callback(Coord) instead of doing the normal
|
|
# select/hit-test, and (if placement_preview_radius_km is set) a
|
|
# circle of that radius follows the cursor as a preview.
|
|
self.placement_callback = None
|
|
self.placement_preview_radius_km = None
|
|
self._placement_cursor_km = None
|
|
|
|
self.set_hexpand(True)
|
|
self.set_vexpand(True)
|
|
self.set_draw_func(self._draw)
|
|
|
|
motion = Gtk.EventControllerMotion()
|
|
motion.connect("motion", self._on_motion)
|
|
motion.connect("leave", self._on_leave)
|
|
self.add_controller(motion)
|
|
|
|
click = Gtk.GestureClick()
|
|
click.connect("released", self._on_click)
|
|
self.add_controller(click)
|
|
|
|
right_click = Gtk.GestureClick()
|
|
right_click.set_button(Gdk.BUTTON_SECONDARY)
|
|
right_click.connect("released", self._on_right_click)
|
|
self.add_controller(right_click)
|
|
|
|
keys = Gtk.EventControllerKey()
|
|
keys.connect("key-pressed", self._on_key_pressed)
|
|
self.set_focusable(True)
|
|
self.add_controller(keys)
|
|
|
|
def refresh(self) -> None:
|
|
self.queue_draw()
|
|
|
|
# -- placement mode -----------------------------------------------------------
|
|
def start_placement(self, callback, preview_radius_km=None) -> None:
|
|
self.placement_callback = callback
|
|
self.placement_preview_radius_km = preview_radius_km
|
|
self.set_cursor_from_name("crosshair")
|
|
self.queue_draw()
|
|
|
|
def cancel_placement(self) -> None:
|
|
if self.placement_callback is None:
|
|
return
|
|
self.placement_callback = None
|
|
self.placement_preview_radius_km = None
|
|
self.set_cursor_from_name(None)
|
|
self.queue_draw()
|
|
|
|
def _on_key_pressed(self, _controller, keyval, _keycode, _state) -> bool:
|
|
if keyval == Gdk.KEY_Escape and self.placement_callback is not None:
|
|
self.cancel_placement()
|
|
return True
|
|
return False
|
|
|
|
def set_selected(self, obj, point=None) -> None:
|
|
if obj is not self.selected or point != self.selected_point:
|
|
self.selected = obj
|
|
self.selected_point = point
|
|
self.queue_draw()
|
|
|
|
# -- geometry -------------------------------------------------------------
|
|
def _cell_size(self, width: int, height: int) -> tuple[float, float]:
|
|
grid_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1)
|
|
grid_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1)
|
|
return grid_w / COLS, grid_h / ROWS
|
|
|
|
def _km_to_px(self, point_km, cell_w, cell_h, grid_h) -> tuple[float, float]:
|
|
col, row = point_km
|
|
return MARGIN_LEFT + col * cell_w, MARGIN_TOP + grid_h - row * cell_h
|
|
|
|
def _px_to_km(self, x, y, cell_w, cell_h, grid_h) -> tuple[float, float]:
|
|
return (x - MARGIN_LEFT) / cell_w, (grid_h - (y - MARGIN_TOP)) / cell_h
|
|
|
|
def _excluded_from_map(self, obj) -> bool:
|
|
"""True if `obj` should be dropped from the map view entirely —
|
|
it's hidden, or it's a dead target with the map's dead-hiding
|
|
toggle on — unless it's the current selection, in which case it's
|
|
still drawn (darkened) so it stays reachable/un-hideable."""
|
|
if obj is self.selected:
|
|
return False
|
|
if obj.hidden:
|
|
return True
|
|
if self.hide_dead_from_map and isinstance(obj, Target) and not obj.alive:
|
|
return True
|
|
return False
|
|
|
|
def _all_positions(self):
|
|
"""Yield (obj, coord) for every point drawn on the map, including
|
|
each ambiguous candidate separately (hover/click targets each of
|
|
them individually, but they all resolve to the same obj). Hidden
|
|
entities, and (if toggled) dead targets, are excluded from the map
|
|
entirely unless they're the current selection (so they can still
|
|
be un-hidden/interacted with once selected some other way, e.g.
|
|
from the firing panel)."""
|
|
for _category, obj in self.board.placed_entities_all():
|
|
if self._excluded_from_map(obj):
|
|
continue
|
|
yield obj, obj.coord
|
|
for _category, obj in self.board.ambiguous_entities_all():
|
|
if self._excluded_from_map(obj):
|
|
continue
|
|
for candidate in obj.location.potential_coords:
|
|
yield obj, candidate
|
|
|
|
def _hit_test(self, x: float, y: float):
|
|
"""Returns (obj, coord) of the nearest marker within range, or
|
|
(None, None) — coord disambiguates which candidate of an
|
|
ambiguous obj was actually hit, since it can have several points."""
|
|
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
|
grid_h = cell_h * ROWS
|
|
best_obj, best_coord, best_dist = None, None, HOVER_RADIUS_PX
|
|
for obj, coord in self._all_positions():
|
|
px, py = self._km_to_px(coord.as_fraction(), cell_w, cell_h, grid_h)
|
|
dist = math.hypot(px - x, py - y)
|
|
if dist < best_dist:
|
|
best_dist, best_obj, best_coord = dist, obj, coord
|
|
return best_obj, best_coord
|
|
|
|
# -- hover / click ------------------------------------------------------------
|
|
def _on_motion(self, _controller, x: float, y: float) -> None:
|
|
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
|
grid_h = cell_h * ROWS
|
|
cursor_km = self._px_to_km(x, y, cell_w, cell_h, grid_h)
|
|
|
|
if self.on_cursor_move is not None:
|
|
self.on_cursor_move(cursor_km)
|
|
|
|
if self.placement_callback is not None:
|
|
self._placement_cursor_km = cursor_km
|
|
self.queue_draw()
|
|
return # no hover/select while placing — the map's just a target picker right now
|
|
|
|
hit, coord = self._hit_test(x, y)
|
|
if hit is not self.hovered or coord != self.hovered_point:
|
|
self.hovered = hit
|
|
self.hovered_point = coord
|
|
self.queue_draw()
|
|
if self.on_hover_change is not None:
|
|
self.on_hover_change(hit, coord)
|
|
|
|
def _on_leave(self, _controller) -> None:
|
|
if self.on_cursor_move is not None:
|
|
self.on_cursor_move(None)
|
|
if self.placement_callback is not None:
|
|
self._placement_cursor_km = None
|
|
self.queue_draw()
|
|
if self.hovered is not None:
|
|
self.hovered = None
|
|
self.hovered_point = None
|
|
self.queue_draw()
|
|
if self.on_hover_change is not None:
|
|
self.on_hover_change(None, None)
|
|
|
|
def _on_click(self, _gesture, _n_press, x: float, y: float) -> None:
|
|
if self.placement_callback is not None:
|
|
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
|
grid_h = cell_h * ROWS
|
|
coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h))
|
|
callback = self.placement_callback
|
|
self.cancel_placement()
|
|
if coord is not None:
|
|
callback(coord)
|
|
return
|
|
|
|
hit, coord = self._hit_test(x, y)
|
|
self.set_selected(hit, coord)
|
|
if self.on_select is not None:
|
|
self.on_select(hit, coord)
|
|
|
|
def _on_right_click(self, _gesture, _n_press, x: float, y: float) -> None:
|
|
if self.placement_callback is not None:
|
|
self.cancel_placement()
|
|
return
|
|
if self.on_right_click is None:
|
|
return
|
|
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
|
grid_h = cell_h * ROWS
|
|
coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h))
|
|
if coord is not None:
|
|
self.on_right_click(coord, x, y)
|
|
|
|
# -- drawing ----------------------------------------------------------------
|
|
def _draw(self, _area, cr, width, height) -> None:
|
|
cr.set_source_rgb(*BG)
|
|
cr.paint()
|
|
|
|
cell_w, cell_h = self._cell_size(width, height)
|
|
grid_w, grid_h = cell_w * COLS, cell_h * ROWS
|
|
|
|
cr.set_source_rgba(*GRID_LINE)
|
|
cr.set_line_width(1)
|
|
for c in range(COLS + 1):
|
|
x = MARGIN_LEFT + c * cell_w
|
|
cr.move_to(x, MARGIN_TOP)
|
|
cr.line_to(x, MARGIN_TOP + grid_h)
|
|
for r in range(ROWS + 1):
|
|
y = MARGIN_TOP + grid_h - r * cell_h
|
|
cr.move_to(MARGIN_LEFT, y)
|
|
cr.line_to(MARGIN_LEFT + grid_w, y)
|
|
cr.stroke()
|
|
|
|
cr.set_source_rgb(*LABEL)
|
|
cr.set_font_size(11)
|
|
for i, letter in enumerate(LARGE_X):
|
|
x = MARGIN_LEFT + i * cell_w + cell_w / 2 - 4
|
|
cr.move_to(x, MARGIN_TOP - 10)
|
|
cr.show_text(letter)
|
|
for r in range(ROWS):
|
|
y = MARGIN_TOP + grid_h - r * cell_h - cell_h / 2 + 4
|
|
cr.move_to(4, y)
|
|
cr.show_text(str(r + 1))
|
|
|
|
self._draw_geo_overlays(cr, cell_w, cell_h, grid_h)
|
|
self._draw_firing_arrows(cr, cell_w, cell_h, grid_h)
|
|
self._draw_blast_radius(cr, cell_w, cell_h, grid_h)
|
|
self._draw_placement_preview(cr, cell_w, cell_h, grid_h)
|
|
|
|
for category, obj in self.board.placed_entities_all():
|
|
if self._excluded_from_map(obj):
|
|
continue # hidden/dead-and-toggled-off entities are removed, not just darkened
|
|
self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category],
|
|
obj.name, cell_w, cell_h, grid_h, width, height,
|
|
dim=(category == "target" and not obj.alive) or obj.hidden,
|
|
selected=(obj is self.selected), coord=obj.coord)
|
|
|
|
for category, obj in self.board.ambiguous_entities_all():
|
|
if self._excluded_from_map(obj):
|
|
continue
|
|
color = CATEGORY_COLOR[category]
|
|
for i, candidate in enumerate(obj.location.potential_coords):
|
|
is_selected = obj is self.selected and candidate == self.selected_point
|
|
self._draw_marker(cr, candidate.as_fraction(), color,
|
|
f"{obj.name}? ({i + 1})", cell_w, cell_h, grid_h, width, height,
|
|
hollow=True, dim=obj.hidden or (category == "target" and not obj.alive),
|
|
selected=is_selected, coord=candidate)
|
|
|
|
def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h,
|
|
canvas_width, canvas_height, *, hollow=False, dim=False,
|
|
selected=False, coord=None) -> None:
|
|
x, y = self._km_to_px(point_km, cell_w, cell_h, grid_h)
|
|
r, g, b = color
|
|
alpha = 0.45 if dim else 1.0
|
|
|
|
if selected:
|
|
cr.new_path()
|
|
cr.set_source_rgba(*SELECTION_RING, 0.9)
|
|
cr.set_line_width(2)
|
|
cr.arc(x, y, 9, 0, 2 * math.pi)
|
|
cr.stroke()
|
|
|
|
if hollow:
|
|
cr.new_path() # cairo's arc() draws a line from any stale current
|
|
cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's
|
|
cr.set_line_width(1.5) # show_text position) to the arc's start —
|
|
cr.set_dash([3, 2]) # this is what stops that connector line.
|
|
cr.arc(x, y, 5.5, 0, 2 * math.pi)
|
|
cr.stroke()
|
|
cr.set_dash([])
|
|
else:
|
|
cr.new_path()
|
|
cr.set_source_rgba(r, g, b, alpha)
|
|
cr.arc(x, y, 5.5, 0, 2 * math.pi)
|
|
cr.fill()
|
|
cr.new_path()
|
|
cr.set_source_rgba(0, 0, 0, 0.6 * alpha)
|
|
cr.arc(x, y, 5.5, 0, 2 * math.pi)
|
|
cr.set_line_width(1)
|
|
cr.stroke()
|
|
|
|
cr.set_font_size(11)
|
|
text_width = cr.text_extents(label).width
|
|
coord_text = coord.label() if coord is not None else None
|
|
coord_width = cr.text_extents(coord_text).width if coord_text else 0
|
|
|
|
# Flip to the left of the marker if the label would run past the
|
|
# right edge; clamp vertically so it doesn't clip top/bottom either.
|
|
label_x = x + LABEL_PAD
|
|
if label_x + max(text_width, coord_width) > canvas_width - 4:
|
|
label_x = x - LABEL_PAD - max(text_width, coord_width)
|
|
label_y = max(10, min(y - 7, canvas_height - 20))
|
|
|
|
cr.set_source_rgba(*LABEL, alpha)
|
|
cr.move_to(label_x, label_y)
|
|
cr.show_text(label)
|
|
|
|
if coord_text:
|
|
cr.set_font_size(9)
|
|
cr.set_source_rgba(*COORD_LABEL, alpha)
|
|
cr.move_to(label_x, label_y + 12)
|
|
cr.show_text(coord_text)
|
|
cr.set_font_size(11)
|
|
|
|
def _draw_firing_arrows(self, cr, cell_w, cell_h, grid_h) -> None:
|
|
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
|
Points at exactly the hovered/selected candidate when one is known
|
|
(mouse over/click on a specific ambiguous marker) rather than every
|
|
candidate of that target — same reasoning as the selection ring:
|
|
drawing to all of them makes it impossible to tell which is which."""
|
|
nest = self.board.nest
|
|
if nest.coord is None:
|
|
return
|
|
nest_km = nest.coord.as_fraction()
|
|
|
|
points: list = []
|
|
for target, point in ((self.hovered, self.hovered_point), (self.selected, self.selected_point)):
|
|
if not isinstance(target, Target):
|
|
continue
|
|
if target.coord is not None:
|
|
points.append(target.coord)
|
|
elif point is not None:
|
|
points.append(point)
|
|
else:
|
|
points.extend(target.location.potential_coords)
|
|
|
|
seen = set()
|
|
for candidate in points:
|
|
if candidate in seen:
|
|
continue
|
|
seen.add(candidate)
|
|
tx, ty = self._km_to_px(candidate.as_fraction(), cell_w, cell_h, grid_h)
|
|
nx, ny = self._km_to_px(nest_km, cell_w, cell_h, grid_h)
|
|
cr.set_source_rgb(*FIRING_ARROW)
|
|
cr.set_line_width(2)
|
|
self._draw_arrow(cr, nx, ny, tx, ty)
|
|
|
|
def _draw_blast_radius(self, cr, cell_w, cell_h, grid_h) -> None:
|
|
"""When a Target is selected, its effective shell's blast radius —
|
|
selection only, not hover (unlike the geo overlays/firing arrow),
|
|
per spec. Uses the specific selected candidate point if the target
|
|
is ambiguous; skipped entirely if there's no known point yet, or
|
|
the shell's blast radius isn't known."""
|
|
if not isinstance(self.selected, Target):
|
|
return
|
|
target = self.selected
|
|
point = target.coord if target.coord is not None else self.selected_point
|
|
if point is None:
|
|
return
|
|
radius_km = target.effective_shell.blast_radius_km
|
|
if radius_km is None:
|
|
return
|
|
|
|
x, y = self._km_to_px(point.as_fraction(), cell_w, cell_h, grid_h)
|
|
rx, ry = cell_w * radius_km, cell_h * radius_km
|
|
self._draw_ellipse(cr, x, y, rx, ry)
|
|
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
|
|
cr.fill_preserve()
|
|
cr.set_source_rgba(*BLAST_RADIUS, 0.85)
|
|
cr.set_line_width(2)
|
|
cr.stroke()
|
|
|
|
def _draw_placement_preview(self, cr, cell_w, cell_h, grid_h) -> None:
|
|
"""While armed to place/reposition something, a small crosshair dot
|
|
follows the cursor, plus a blast-radius preview circle if one was
|
|
given (e.g. placing a Strike — see its shell before you commit)."""
|
|
if self.placement_callback is None or self._placement_cursor_km is None:
|
|
return
|
|
x, y = self._km_to_px(self._placement_cursor_km, cell_w, cell_h, grid_h)
|
|
|
|
if self.placement_preview_radius_km is not None:
|
|
rx, ry = cell_w * self.placement_preview_radius_km, cell_h * self.placement_preview_radius_km
|
|
self._draw_ellipse(cr, x, y, rx, ry)
|
|
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.15)
|
|
cr.fill_preserve()
|
|
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.8)
|
|
cr.set_line_width(1.5)
|
|
cr.set_dash([3, 2])
|
|
cr.stroke()
|
|
cr.set_dash([])
|
|
|
|
cr.new_path()
|
|
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.9)
|
|
cr.set_line_width(1.5)
|
|
cr.move_to(x - 7, y)
|
|
cr.line_to(x + 7, y)
|
|
cr.move_to(x, y - 7)
|
|
cr.line_to(x, y + 7)
|
|
cr.stroke()
|
|
|
|
def _draw_geo_overlays(self, cr, cell_w, cell_h, grid_h) -> None:
|
|
to_show = []
|
|
for category, obj in self.board.placed_entities():
|
|
if obj is self.hovered or obj.show_geo_desc:
|
|
to_show.append(obj)
|
|
for category, obj in self.board.ambiguous_entities():
|
|
if obj is self.hovered or obj.show_geo_desc:
|
|
to_show.append(obj)
|
|
|
|
for obj in to_show:
|
|
for clue in obj.location.clues:
|
|
ref = self.board.find_by_name(clue.reference)
|
|
if ref is None or ref.coord is None:
|
|
continue
|
|
ref_km = ref.coord.as_fraction()
|
|
rx, ry = self._km_to_px(ref_km, cell_w, cell_h, grid_h)
|
|
|
|
if clue.bearing_deg is not None and clue.distance_km is not None:
|
|
target_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, clue.distance_km)
|
|
tx, ty = self._km_to_px(target_km, cell_w, cell_h, grid_h)
|
|
cr.set_source_rgb(*YELLOW)
|
|
cr.set_line_width(2)
|
|
self._draw_arrow(cr, rx, ry, tx, ty)
|
|
elif clue.bearing_deg is not None:
|
|
far_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, OVERLAY_RAY_LENGTH_KM)
|
|
fx, fy = self._km_to_px(far_km, cell_w, cell_h, grid_h)
|
|
cr.set_source_rgb(*YELLOW)
|
|
cr.set_line_width(1.5)
|
|
cr.move_to(rx, ry)
|
|
cr.line_to(fx, fy)
|
|
cr.stroke()
|
|
elif clue.distance_km is not None:
|
|
radius_x, radius_y = cell_w * clue.distance_km, cell_h * clue.distance_km
|
|
self._draw_ellipse(cr, rx, ry, radius_x, radius_y)
|
|
cr.set_source_rgba(*WHITE, 0.85)
|
|
cr.set_line_width(1.5)
|
|
cr.stroke()
|
|
|
|
# Radius indicator: a line from center to an actual point
|
|
# on the circle — the intersection with another of this
|
|
# entity's clues if one pairs with it (same geometry the
|
|
# solver would use; picks the nearer of two candidates),
|
|
# else straight up as a last-resort fallback with nothing
|
|
# to intersect against yet.
|
|
radius_target_km = None
|
|
for other in obj.location.clues:
|
|
if other is clue or other.bearing_deg is None or other.distance_km is not None:
|
|
continue
|
|
other_ref = self.board.find_by_name(other.reference)
|
|
if other_ref is None or other_ref.coord is None:
|
|
continue
|
|
points = solver.ray_circle_intersections(
|
|
other_ref.coord.as_fraction(), other.bearing_deg, ref_km, clue.distance_km
|
|
)
|
|
if points:
|
|
radius_target_km = points[0]
|
|
break
|
|
if radius_target_km is None:
|
|
radius_target_km = (ref_km[0], ref_km[1] + clue.distance_km)
|
|
|
|
tx, ty = self._km_to_px(radius_target_km, cell_w, cell_h, grid_h)
|
|
cr.new_path()
|
|
cr.set_source_rgba(*WHITE, 0.85)
|
|
cr.set_line_width(1)
|
|
cr.set_dash([1.5, 2.5])
|
|
cr.move_to(rx, ry)
|
|
cr.line_to(tx, ty)
|
|
cr.stroke()
|
|
cr.set_dash([])
|
|
|
|
def _draw_arrow(self, cr, x0, y0, x1, y1, head_size=8) -> None:
|
|
cr.new_path()
|
|
cr.move_to(x0, y0)
|
|
cr.line_to(x1, y1)
|
|
cr.stroke()
|
|
angle = math.atan2(y1 - y0, x1 - x0)
|
|
cr.new_path()
|
|
for delta in (math.pi * 5 / 6, -math.pi * 5 / 6):
|
|
cr.move_to(x1, y1)
|
|
cr.line_to(x1 + head_size * math.cos(angle + delta), y1 + head_size * math.sin(angle + delta))
|
|
cr.stroke()
|
|
|
|
def _draw_ellipse(self, cr, cx, cy, rx, ry, steps=72) -> None:
|
|
# Points computed explicitly (not via cr.scale) so the stroke width
|
|
# stays uniform regardless of rx/ry — a scaled CTM would stretch it.
|
|
cr.new_path()
|
|
cr.move_to(cx + rx, cy)
|
|
for i in range(1, steps + 1):
|
|
theta = 2 * math.pi * i / steps
|
|
cr.line_to(cx + rx * math.cos(theta), cy + ry * math.sin(theta))
|