FEnigma/src/fenigma/grid_widget.py
Dominik Roth 1ddb532325 Fix ally/target bugs, OCR fire-support parsing, add debug capture
- Board.clear() now also drops allies; the "clear board?" guard checks
  allies too. New Board.clear_units() + Clear button right-click menu
  ("clear enemies, units & flights", keeps Nest/spotters/RPs).
- An Ally with the ad-hoc TargetType.ENEMY showed "Enemy" on the map
  popover/toast instead of "Ally" (icons.target_type_label already had
  the fix for the picker, now reused everywhere else via app.py's
  _display_name).
- Firing panel drag-reorder no longer triggers a full app refresh
  (solver + dedupe + map redraw) on every drop, just a local rebuild.
- "Always show geo" didn't draw for Allies (missing from the overlay
  candidate list); blast radius only respected selection, not the
  show_geo_desc pin.
- ocr.py: added a second fire-support-request grammar ("Infantry#N
  taking fire ... Requesting X Shell on our position at <coord> before
  <time>", plus a bearing/distance-from-position variant), distinct
  from the existing Marine Garrison one.
- New debug_capture.py: saves screenshots (+ metadata) the app handled
  badly, for later tuning of map_vision/ocr against real failures:
  map-read errors, user grid corrections (paired with the auto-detected
  grid), screenshots that read as text but may have been a map, and
  marker-detection ground truth (every proposal's accept/reject verdict
  plus units added with no matching proposal) captured whenever a
  screenshot stops being the active one.
- README: Known issues section (map screenshot reading, grid + unit
  detection, is unreliable and fails often).
- 14 new tests (tests/test_models.py, tests/test_debug_capture.py, +
  additions to tests/test_ocr.py), 38/38 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:35:37 +02:00

1214 lines
55 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
from collections import namedtuple
import cairo
import gi
import numpy as np
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gtk # noqa: E402
from . import ballistics, icons, 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
# An imported screenshot is a backdrop, not the subject: slightly transparent so
# the grid lines and markers drawn over it stay legible.
SCREENSHOT_ALPHA = 0.88
OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin
MIN_ZOOM = 1.0 # the whole 20x10 map fits, the default
MAX_ZOOM = 10.0
ZOOM_STEP = 1.15 # per scroll-wheel notch
# Below this cell width, a marker's game icon wouldn't read as anything
# but a smudge, a plain dot is more honest about the zoom level than a
# barely-legible picture.
ICON_MIN_CELL_PX = 42
# Everything needed to convert between km-space (the 20x10 grid) and
# widget pixels for one frame, bundled so every draw/hit-test method
# takes one argument instead of threading cell_w/cell_h/grid_h/pan
# separately through a dozen call sites. cell_w/cell_h differ only when
# square_cells is off (the default): the map then stretches to fill the
# widget exactly, cell_w == cell_h only when the widget's own aspect
# ratio happens to match the grid's. pad_x/pad_y are the letterboxing
# margin added on whichever axis has leftover space when square_cells
# is on. ox/oy are the visible viewport's origin in km-space (0,0
# unless zoomed in and panned).
_View = namedtuple("_View", "cell_w cell_h grid_w grid_h pad_x pad_y ox oy vis_cols vis_rows")
# Everything below (CATEGORY_COLOR through PLACEMENT_PREVIEW) is a
# module-level name deliberately kept mutable: _apply_palette() below
# reassigns all of them via `global`, in place, whenever the app's
# light/dark scheme changes (see GridCanvas.__init__, which hooks
# Adw.StyleManager's own dark/light detection, including live updates
# if the system theme changes while running). Every draw method
# references these bare names directly (`cr.set_source_rgb(*BG)` etc.)
# rather than threading a palette object through every call, reassigning
# the names in place is what makes that keep working without touching
# every call site. The values set here at import time are the dark
# palette, _apply_palette(is_dark=True) (called from __init__) reapplies
# the same values, it's the light branch that actually changes anything
# the first time it runs.
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),
"ally": (0.30, 0.85, 0.85), # cyan, distinct from every other category's color
}
SCOUT_FLIGHT = (0.70, 0.45, 0.92)
BG = (0.13, 0.12, 0.10)
GRID_LINE = (1.0, 1.0, 1.0, 0.20)
SUBGRID_LINE = (0.72, 0.70, 0.65, 0.15) # verified by actually computing the blended-over-BG
# pixel values, not eyeballing it: alpha 0.35 (a previous version) blended this same RGB out to
# (86, 83, 75), BRIGHTER than GRID_LINE's own blended (77, 76, 72), backwards from the intent.
# 0.15 blends to (56, 53, 47): sits between BG (33, 31, 26) and GRID_LINE (77, 76, 72), the RGB
# tint stays visible but the line itself reads as genuinely fainter, not louder.
HOVER_LEGEND = (0.45, 0.65, 0.95) # blue, not yellow, for the highlighted X/Y legend label
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) # not literally "white" any more in the light palette, see _LIGHT_PALETTE:
# its role is "a neutral that maximally contrasts with BG", the name stuck around from when this
# only ever ran on a dark background.
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)
_DARK_PALETTE = dict(
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), "ally": (0.30, 0.85, 0.85),
},
SCOUT_FLIGHT=(0.70, 0.45, 0.92),
BG=(0.13, 0.12, 0.10),
GRID_LINE=(1.0, 1.0, 1.0, 0.20),
SUBGRID_LINE=(0.72, 0.70, 0.65, 0.15),
HOVER_LEGEND=(0.45, 0.65, 0.95),
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),
)
# Same relative brightness relationships as the dark palette (main grid
# line vs. the fainter subgrid one, category colors distinct from each
# other), just inverted for a light background: every color that needs
# to contrast against BG got darkened instead of brightened. First-pass
# guesses, flagged the same way the Shell descriptions were, correct
# whichever look off once actually seen on a real light-themed desktop.
_LIGHT_PALETTE = dict(
CATEGORY_COLOR={
"nest": (0.15, 0.35, 0.75), "spotter": (0.10, 0.50, 0.15),
"rp": (0.65, 0.50, 0.05), "target": (0.75, 0.12, 0.10), "ally": (0.05, 0.45, 0.45),
},
SCOUT_FLIGHT=(0.45, 0.20, 0.65),
BG=(0.96, 0.95, 0.93),
GRID_LINE=(0.08, 0.08, 0.08, 0.20),
SUBGRID_LINE=(0.08, 0.08, 0.08, 0.15),
HOVER_LEGEND=(0.10, 0.35, 0.75),
LABEL=(0.15, 0.14, 0.12),
COORD_LABEL=(0.42, 0.40, 0.37),
YELLOW=(0.65, 0.48, 0.02),
WHITE=(0.10, 0.10, 0.10),
FIRING_ARROW=(0.80, 0.10, 0.10),
SELECTION_RING=(0.05, 0.05, 0.05),
BLAST_RADIUS=(0.80, 0.35, 0.05),
PLACEMENT_PREVIEW=(0.65, 0.48, 0.02),
)
def _apply_palette(is_dark: bool) -> None:
global CATEGORY_COLOR, SCOUT_FLIGHT, BG, GRID_LINE, SUBGRID_LINE, HOVER_LEGEND, LABEL, \
COORD_LABEL, YELLOW, WHITE, FIRING_ARROW, SELECTION_RING, BLAST_RADIUS, PLACEMENT_PREVIEW
p = _DARK_PALETTE if is_dark else _LIGHT_PALETTE
CATEGORY_COLOR = p["CATEGORY_COLOR"]
SCOUT_FLIGHT = p["SCOUT_FLIGHT"]
BG = p["BG"]
GRID_LINE = p["GRID_LINE"]
SUBGRID_LINE = p["SUBGRID_LINE"]
HOVER_LEGEND = p["HOVER_LEGEND"]
LABEL = p["LABEL"]
COORD_LABEL = p["COORD_LABEL"]
YELLOW = p["YELLOW"]
WHITE = p["WHITE"]
FIRING_ARROW = p["FIRING_ARROW"]
SELECTION_RING = p["SELECTION_RING"]
BLAST_RADIUS = p["BLAST_RADIUS"]
PLACEMENT_PREVIEW = p["PLACEMENT_PREVIEW"]
# path -> loaded cairo.ImageSurface (or None for a path that failed to
# load, so a missing/bad icon file only ever gets one failed attempt,
# not one per frame). Module-level, not per-canvas: the icon set is
# fixed at import time, no reason to reload it per GridCanvas instance.
_ICON_SURFACE_CACHE: dict = {}
def _icon_surface(path) -> cairo.ImageSurface | None:
if path is None:
return None
if path not in _ICON_SURFACE_CACHE:
try:
_ICON_SURFACE_CACHE[path] = cairo.ImageSurface.create_from_png(str(path))
except Exception:
_ICON_SURFACE_CACHE[path] = None
return _ICON_SURFACE_CACHE[path]
def _icon_for(category: str, obj) -> cairo.ImageSurface | None:
"""Whichever game icon fits `obj`'s type, or None to fall back to
the plain dot (see ICON_MIN_CELL_PX for when that fallback actually
kicks in)."""
if category == "nest":
return _icon_surface(icons.NEST_ICON_PATH)
if category == "target":
return _icon_surface(icons.target_icon_path(obj.type, is_ally=False))
if category == "ally":
return _icon_surface(icons.target_icon_path(obj.type, is_ally=True))
return None
class GridCanvas(Gtk.DrawingArea):
def __init__(self, board: Board) -> None:
super().__init__()
self.board = board
# Follow the app's light/dark scheme (system setting, or an
# in-app override if one's ever added later) for every color
# this canvas draws with, live: if the scheme changes while
# running, redraw with the other palette rather than staying
# stuck on whichever was active at startup.
style_manager = Adw.StyleManager.get_default()
_apply_palette(style_manager.get_dark())
style_manager.connect("notify::dark", self._on_style_changed)
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
# callback(proposal, x, y): fired when an imported screenshot's pending
# proposal is clicked with either button. A proposal exists only to be
# accepted or rejected, so plain clicking it offers that rather than
# selecting something the board doesn't contain yet.
self.on_proposal_click = None
# callback(Coord, x, y, obj, point): fired on right-click unless placing.
# obj/point are the entity under the cursor when there is one (same
# hit test as left-click selection), so the handler can offer actions
# on that entity instead of the place-something-here menu.
self.on_right_click = None
self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar
# An imported map screenshot, rectified into board space, drawn under
# everything else, plus the units detected in it as [(proposal, Coord)].
# Proposals are kept separate from board entities on purpose: they are
# not on the board until accepted, so nothing that walks the board can
# see them, and they get their own hit test.
self._screenshot = None # (cairo surface, backing array, px_per_km)
self.proposals = []
# Which large cell the cursor is currently over, (col, row) both
# floored, or None off the map/off the widget entirely. Redrawn
# only when this actually changes cell (not on every pixel of
# motion within the same one), see _draw_hover_subgrid().
self._hover_cell: tuple[int, int] | None = None
# 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.
# placement_kind == "scout_flight" is a different shape entirely
# (see start_scout_flight_placement): the callback there gets
# (center_km, bearing_deg) instead of a Coord, and the preview is
# the scout flight's oriented rectangle instead of a circle.
self.placement_callback = None
self.placement_preview_radius_km = None
self.placement_kind = "point"
self._placement_cursor_km = None
# square_cells: off by default (the map stretches to fill the
# widget, cell_w != cell_h unless the widget's own aspect ratio
# happens to match 20:10), toggled from the header (see app.py).
# zoom/pan_km: scroll-wheel zoom state, pan_km is the km-space
# point the current view is centered on; _view() clamps it so
# the visible viewport never hangs off the grid's edge.
# _last_pointer_px: tracked from motion events so a scroll event
# (which doesn't carry its own pointer position) has somewhere
# to zoom towards.
self.square_cells = False
self.zoom = MIN_ZOOM
self.pan_km = (COLS / 2.0, ROWS / 2.0)
self._last_pointer_px: tuple[float, float] | None = None
# Drag-to-pan state, see _on_drag_begin/_on_drag_update.
# _drag_did_pan tracks whether the in-progress/just-finished
# drag actually moved the view (past a small pixel threshold,
# not just an ordinary click's own tiny jitter), so _on_click
# can skip treating that same gesture as a select/click too.
self._drag_start_pan_km: tuple[float, float] | None = None
self._drag_start_view: _View | None = None
self._drag_did_pan = False
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)
scroll = Gtk.EventControllerScroll(flags=Gtk.EventControllerScrollFlags.VERTICAL)
scroll.connect("scroll", self._on_scroll)
self.add_controller(scroll)
# Only actually pans once zoomed in (see _on_drag_update), at
# the default zoom the whole map's already on screen, nothing
# to drag to.
drag = Gtk.GestureDrag()
drag.connect("drag-begin", self._on_drag_begin)
drag.connect("drag-update", self._on_drag_update)
drag.connect("drag-end", self._on_drag_end)
self.add_controller(drag)
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()
def _on_style_changed(self, style_manager, _pspec) -> None:
_apply_palette(style_manager.get_dark())
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.placement_kind = "point"
self.set_cursor_from_name("crosshair")
self.queue_draw()
def start_scout_flight_placement(self, callback) -> None:
"""Like start_placement(), but the next click calls
callback(center_km, bearing_deg) instead of callback(Coord): the
anchor is the large grid square the cursor is in (not wherever
exactly it's pointing), and the bearing is derived from where in
that square the cursor sits, see _scout_flight_anchor()."""
self.placement_callback = callback
self.placement_preview_radius_km = None
self.placement_kind = "scout_flight"
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.placement_kind = "point"
self.set_cursor_from_name(None)
self.queue_draw()
def _scout_flight_anchor(self, cursor_km) -> tuple[tuple[float, float], float]:
"""(center_km, bearing_deg) for scout-flight placement: the center
of the large grid square the cursor is in, and the bearing from
that center out toward the actual cursor position, this is what
lets the anchor snap to a clean cell center while direction stays
under fine mouse control. Clamped so a cursor right at the map's
edge still resolves to that edge cell rather than one off the
grid."""
col, row = cursor_km
cell_col = min(max(math.floor(col), 0), COLS - 1)
cell_row = min(max(math.floor(row), 0), ROWS - 1)
center_km = (cell_col + 0.5, cell_row + 0.5)
bearing = ballistics.bearing_deg_point(center_km, cursor_km)
return center_km, bearing
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 set_square_cells(self, square: bool) -> None:
self.square_cells = square
self.queue_draw()
def _visible_extent(self, avail_w: float, avail_h: float) -> tuple[float, float]:
"""(vis_cols, vis_rows): how much of the 20x10 grid the current
zoom level actually shows. Locked to the grid's own 20:10 shape
unless square_cells is on AND actually zoomed in, in which case
there's no reason a cropped view has to keep the whole map's
fixed shape (only the *full* map has a reason to look like
that), so it follows the canvas's own aspect instead: cells
come out square with zero letterboxing, rather than the same
fixed-size padding band persisting at every zoom level and
eating a bigger and bigger share of an already-zoomed-in view.
Clamped to ROWS/COLS so an extreme canvas aspect can't ask for a
viewport bigger than the whole map itself. Shared by _view() and
_on_scroll(), which both need the exact same numbers, a
mismatch between them would throw off the scroll-to-zoom anchor
math."""
vis_cols = COLS / self.zoom
if self.square_cells and self.zoom > MIN_ZOOM:
vis_rows = min(vis_cols * avail_h / avail_w, ROWS)
vis_cols = min(vis_rows * avail_w / avail_h, COLS)
else:
vis_rows = ROWS / self.zoom
return vis_cols, vis_rows
def _view(self, width: int, height: int) -> _View:
"""Everything needed to convert km-space <-> pixels for one
frame, see _View's own docstring. zoom==MIN_ZOOM (the default)
recovers the exact pre-zoom/pre-square_cells behavior: the whole
grid stretched edge to edge, no letterboxing."""
avail_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1)
avail_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1)
vis_cols, vis_rows = self._visible_extent(avail_w, avail_h)
cx, cy = self.pan_km
ox = min(max(cx - vis_cols / 2, 0.0), COLS - vis_cols)
oy = min(max(cy - vis_rows / 2, 0.0), ROWS - vis_rows)
cell_w = avail_w / vis_cols
cell_h = avail_h / vis_rows
pad_x = pad_y = 0.0
if self.square_cells:
cell = min(cell_w, cell_h)
grid_w, grid_h = cell * vis_cols, cell * vis_rows
pad_x = (avail_w - grid_w) / 2
pad_y = (avail_h - grid_h) / 2
cell_w = cell_h = cell
else:
grid_w, grid_h = avail_w, avail_h
return _View(cell_w, cell_h, grid_w, grid_h, pad_x, pad_y, ox, oy, vis_cols, vis_rows)
def _km_to_px(self, view: _View, point_km) -> tuple[float, float]:
col, row = point_km
x = MARGIN_LEFT + view.pad_x + (col - view.ox) * view.cell_w
y = MARGIN_TOP + view.pad_y + view.grid_h - (row - view.oy) * view.cell_h
return x, y
def _px_to_km(self, view: _View, x, y) -> tuple[float, float]:
col = (x - MARGIN_LEFT - view.pad_x) / view.cell_w + view.ox
row = (view.grid_h - (y - MARGIN_TOP - view.pad_y)) / view.cell_h + view.oy
return col, row
def _on_scroll(self, _controller, _dx, dy) -> bool:
"""Zoom in/out anchored at the last known cursor position (a
scroll event carries no position of its own), so the km point
under the cursor stays under it after the zoom level changes
instead of the view just re-centering on the grid's middle."""
width, height = self.get_width(), self.get_height()
view_before = self._view(width, height)
px, py = self._last_pointer_px or (
MARGIN_LEFT + view_before.pad_x + view_before.grid_w / 2,
MARGIN_TOP + view_before.pad_y + view_before.grid_h / 2,
)
anchor_km = self._px_to_km(view_before, px, py)
self.zoom = min(max(self.zoom * (ZOOM_STEP ** -dy), MIN_ZOOM), MAX_ZOOM)
avail_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1)
avail_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1)
vis_cols, vis_rows = self._visible_extent(avail_w, avail_h)
frac_x = (px - MARGIN_LEFT - view_before.pad_x) / view_before.grid_w if view_before.grid_w else 0.5
frac_y = 1 - (py - MARGIN_TOP - view_before.pad_y) / view_before.grid_h if view_before.grid_h else 0.5
# _view() clamps this back onto the grid itself if it would
# otherwise hang the viewport off an edge, no separate bounds
# check needed here.
self.pan_km = (
anchor_km[0] + (0.5 - frac_x) * vis_cols,
anchor_km[1] + (0.5 - frac_y) * vis_rows,
)
self.queue_draw()
return True
def _on_drag_begin(self, _gesture, _x, _y) -> None:
if self.zoom <= MIN_ZOOM or self.placement_callback is not None:
return # the whole map's already on screen, nothing to pan to
self._drag_start_pan_km = self.pan_km
self._drag_start_view = self._view(self.get_width(), self.get_height())
self._drag_did_pan = False
def _on_drag_update(self, _gesture, offset_x: float, offset_y: float) -> None:
if self._drag_start_view is None:
return
if math.hypot(offset_x, offset_y) > 3:
self._drag_did_pan = True
view = self._drag_start_view
# Dragging pans opposite to how zooming re-centers: the content
# follows the cursor (drag right -> content moves right, drag
# down -> content moves down), like dragging a piece of paper,
# not like moving a camera. Derived directly from _km_to_px()'s
# own formula: solving for how much ox/oy (and so pan_km, which
# is just their re-centered form) has to change for a given
# point_km to land `offset` pixels away from where it started.
cx, cy = self._drag_start_pan_km
self.pan_km = (cx - offset_x / view.cell_w, cy + offset_y / view.cell_h)
self.queue_draw()
def _on_drag_end(self, _gesture, _offset_x, _offset_y) -> None:
self._drag_start_pan_km = None
self._drag_start_view = None
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
# -- imported screenshot ---------------------------------------------------
def set_screenshot(self, bgra, px_per_km: int) -> None:
"""Show a rectified map screenshot as the board's backdrop.
`bgra` covers the whole board (COLS x ROWS km at px_per_km), transparent
wherever the screenshot didn't reach, so a partial view of the table
doesn't blank out the rest of the map. Pre-warping into board space is
what makes this drawable at all: cairo has no projective transform, but
once the image is rectified a plain scale and translate places it.
"""
if bgra is None:
self._screenshot = None
self.queue_draw()
return
buf = np.ascontiguousarray(bgra)
h, w = buf.shape[:2]
surface = cairo.ImageSurface.create_for_data(
memoryview(buf), cairo.FORMAT_ARGB32, w, h, w * 4)
# The array must outlive the surface: create_for_data does not copy.
self._screenshot = (surface, buf, px_per_km)
self.queue_draw()
def has_screenshot(self) -> bool:
return self._screenshot is not None
def set_proposals(self, proposals) -> None:
"""proposals is [(proposal, Coord)]; the widget only reads the Coord and
the proposal's accepted/rejected flags, so it stays ignorant of
map_import's own coordinate format."""
self.proposals = list(proposals)
self.queue_draw()
def _pending_proposals(self):
return [(p, c) for p, c in self.proposals if p.pending]
def hit_test_proposal(self, view: _View, x: float, y: float):
"""The pending proposal nearest the cursor within range, or None."""
best, best_dist = None, HOVER_RADIUS_PX
for p, coord in self._pending_proposals():
px, py = self._km_to_px(view, coord.as_fraction())
dist = math.hypot(px - x, py - y)
if dist < best_dist:
best_dist, best = dist, p
return best
def _draw_screenshot(self, cr, view) -> None:
surface, buf, px_per_km = self._screenshot
# Board space runs col 0..COLS rightward and row 0..ROWS upward, so the
# image's top-left pixel is (col 0, row ROWS) -- the top-left corner.
x0, y0 = self._km_to_px(view, (0, ROWS))
x1, y1 = self._km_to_px(view, (COLS, 0))
ih, iw = buf.shape[:2]
if iw <= 0 or ih <= 0:
return
cr.save()
cr.translate(x0, y0)
cr.scale((x1 - x0) / iw, (y1 - y0) / ih)
cr.set_source_surface(surface, 0, 0)
cr.get_source().set_filter(cairo.FILTER_GOOD)
cr.paint_with_alpha(SCREENSHOT_ALPHA)
cr.restore()
def _draw_proposals(self, cr, view, width, height) -> None:
"""Detected-but-unconfirmed units. Drawn hollow, the same shape the map
already uses for "this might be where it is", because that is exactly
what a proposal is until the user accepts it."""
for p, coord in self._pending_proposals():
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
self._draw_marker(cr, view, coord.as_fraction(), color,
f"? {coord.label()}", width, height,
hollow=True, coord=coord)
def _hit_test(self, view: _View, 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."""
best_obj, best_coord, best_dist = None, None, HOVER_RADIUS_PX
for obj, coord in self._all_positions():
px, py = self._km_to_px(view, coord.as_fraction())
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:
self._last_pointer_px = (x, y)
view = self._view(self.get_width(), self.get_height())
cursor_km = self._px_to_km(view, x, y)
if self.on_cursor_move is not None:
self.on_cursor_move(cursor_km)
col, row = cursor_km
new_cell = (int(col), int(row)) if (0 <= col < COLS and 0 <= row < ROWS) else None
if new_cell != self._hover_cell:
self._hover_cell = new_cell
self.queue_draw()
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(view, 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:
self._last_pointer_px = None
if self.on_cursor_move is not None:
self.on_cursor_move(None)
if self._hover_cell is not None:
self._hover_cell = None
self.queue_draw()
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._drag_did_pan:
# The GestureDrag that just finished actually panned the
# view (past the jitter threshold), don't also treat its
# release as a click-to-select, that would either select
# whatever ended up under the cursor after the pan or
# deselect the current selection, neither of which is what
# a drag gesture was for.
self._drag_did_pan = False
return
view = self._view(self.get_width(), self.get_height())
if self.placement_callback is not None:
cursor_km = self._px_to_km(view, x, y)
callback, kind = self.placement_callback, self.placement_kind
self.cancel_placement()
if kind == "scout_flight":
center_km, bearing = self._scout_flight_anchor(cursor_km)
callback(center_km, bearing)
else:
coord = solver.point_to_coord(cursor_km)
if coord is not None:
callback(coord)
return
proposal = self.hit_test_proposal(view, x, y)
if proposal is not None and self.on_proposal_click is not None:
self.on_proposal_click(proposal, x, y)
return
hit, coord = self._hit_test(view, 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
view = self._view(self.get_width(), self.get_height())
# A pending proposal wins over a board entity underneath it: it is the
# thing the user is being asked to decide about, and it disappears as
# soon as they do, so whatever it overlaps becomes reachable again.
hit = self.hit_test_proposal(view, x, y)
point = None
if hit is None:
hit, point = self._hit_test(view, x, y)
coord = solver.point_to_coord(self._px_to_km(view, x, y))
if coord is None and hit is None:
return
self.on_right_click(coord, x, y, hit, point)
# -- drawing ----------------------------------------------------------------
def _draw(self, _area, cr, width, height) -> None:
cr.set_source_rgb(*BG)
cr.paint()
view = self._view(width, height)
# Grid lines only for integer boundaries actually within the
# visible viewport, not always 0..COLS/0..ROWS, once zoomed in
# most of the grid isn't on screen at all. A previous version
# over-generated a couple of lines past the true edge (meant to
# cover a trailing partial cell, which doesn't need its own line
# in the first place, just the whole-integer lines bounding it,
# already included here) and those, drawn before anything gets
# clipped, showed up as stray lines bleeding into the label
# margin above/left of the actual grid. ceil/floor here means
# every c/r produced is guaranteed to already land inside the
# grid rectangle, nothing to clip.
first_col, last_col = math.ceil(view.ox), math.floor(view.ox + view.vis_cols)
first_row, last_row = math.ceil(view.oy), math.floor(view.oy + view.vis_rows)
cr.set_source_rgba(*GRID_LINE)
cr.set_line_width(1)
for c in range(max(first_col, 0), min(last_col, COLS) + 1):
x, _ = self._km_to_px(view, (c, 0))
cr.move_to(x, MARGIN_TOP + view.pad_y)
cr.line_to(x, MARGIN_TOP + view.pad_y + view.grid_h)
for r in range(max(first_row, 0), min(last_row, ROWS) + 1):
_, y = self._km_to_px(view, (0, r))
cr.move_to(MARGIN_LEFT + view.pad_x, y)
cr.line_to(MARGIN_LEFT + view.pad_x + view.grid_w, y)
cr.stroke()
# Column/row labels: one per whole large-cell that's at least
# partly visible (its own span overlaps the viewport), not
# pegged to the gridline boundaries above, a partially-visible
# edge cell still gets its letter/number shown. Whichever
# column/row the cursor is actually over gets called out
# (accent color + underline), an easy way to read the current
# cell off the legend at a glance instead of counting gridlines.
hover_col, hover_row = self._hover_cell if self._hover_cell is not None else (None, None)
cr.set_font_size(11)
for i in range(max(math.floor(view.ox), 0), min(math.ceil(view.ox + view.vis_cols), COLS)):
x, _ = self._km_to_px(view, (i + 0.5, 0))
label_x, label_y = x - 4, MARGIN_TOP + view.pad_y - 10
is_hover = i == hover_col
cr.set_source_rgb(*(HOVER_LEGEND if is_hover else LABEL))
cr.move_to(label_x, label_y)
cr.show_text(LARGE_X[i])
if is_hover:
text_w = cr.text_extents(LARGE_X[i]).width
cr.new_path()
cr.set_line_width(1.5)
cr.move_to(label_x, label_y + 3)
cr.line_to(label_x + max(text_w, 6), label_y + 3)
cr.stroke()
for r in range(max(math.floor(view.oy), 0), min(math.ceil(view.oy + view.vis_rows), ROWS)):
_, y = self._km_to_px(view, (0, r + 0.5))
label_x, label_y = 4, y + 4
is_hover = r == hover_row
cr.set_source_rgb(*(HOVER_LEGEND if is_hover else LABEL))
cr.move_to(label_x, label_y)
cr.show_text(str(r + 1))
if is_hover:
text_w = cr.text_extents(str(r + 1)).width
cr.new_path()
cr.set_line_width(1.5)
cr.move_to(label_x, label_y + 3)
cr.line_to(label_x + max(text_w, 6), label_y + 3)
cr.stroke()
# Everything below projects a km position to a pixel one with no
# inherent bound, an entity that's genuinely elsewhere on the
# map (outside the current zoomed viewport) would otherwise
# still get drawn whenever its projected pixel position happens
# to land inside the canvas's own bounds (including the
# letterbox padding bands), showing up as markers/lines that
# look like they're floating off the visible grid. Clipping to
# the grid's own drawable rectangle is a single fix for all of
# it (markers, overlays, arrows, scout flights) rather than
# teaching every draw call its own visibility check.
cr.save()
cr.rectangle(MARGIN_LEFT + view.pad_x, MARGIN_TOP + view.pad_y, view.grid_w, view.grid_h)
cr.clip()
# Under everything: the imported screenshot is the backdrop the rest of
# the map is drawn on top of.
if self._screenshot is not None:
self._draw_screenshot(cr, view)
self._draw_hover_subgrid(cr, view)
self._draw_geo_overlays(cr, view)
self._draw_firing_arrows(cr, view)
self._draw_blast_radius(cr, view)
self._draw_placement_preview(cr, view)
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, view, obj.coord.as_fraction(), CATEGORY_COLOR[category],
obj.name, width, height,
dim=(category == "target" and not obj.alive) or obj.hidden,
selected=(obj is self.selected), coord=obj.coord,
extra_line=getattr(obj, "requested_time", None),
icon_surface=_icon_for(category, obj))
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, view, candidate.as_fraction(), color,
f"{obj.name}? ({i + 1})", width, height,
hollow=True, dim=obj.hidden or (category == "target" and not obj.alive),
selected=is_selected, coord=candidate,
extra_line=getattr(obj, "requested_time", None))
self._draw_proposals(cr, view, width, height)
for sf in self.board.scout_flights:
if sf.hidden:
continue # hidden means gone from the map, not just darkened, no selection to reinstate it
self._draw_scout_flight_rect(cr, view, sf.center, sf.bearing_deg)
cx, cy = self._km_to_px(view, sf.center)
start_coord = solver.point_to_coord(sf.center)
grid_name = f"{start_coord.X}{start_coord.Y}" if start_coord is not None else "?"
cr.set_font_size(11)
cr.set_source_rgba(*LABEL, 1.0)
cr.move_to(cx + LABEL_PAD, cy - 7)
cr.show_text(sf.name)
cr.set_font_size(9)
cr.set_source_rgba(*COORD_LABEL, 1.0)
cr.move_to(cx + LABEL_PAD, cy + 5)
cr.show_text(f"{grid_name} {sf.bearing_deg:05.1f}°")
cr.set_font_size(11)
cr.restore()
def _draw_hover_subgrid(self, cr, view) -> None:
"""The fine 10x10 x:y subdivision lines for whichever large
cell the cursor is currently over, for precise sub-cell
targeting, most useful once zoomed in enough that a single
large cell actually has room to show them meaningfully."""
if self._hover_cell is None:
return
cell_col, cell_row = self._hover_cell
cr.set_source_rgba(*SUBGRID_LINE)
cr.set_line_width(1)
x0, y0 = self._km_to_px(view, (cell_col, cell_row))
x1, y1 = self._km_to_px(view, (cell_col + 1, cell_row + 1))
for i in range(1, 10):
x, _ = self._km_to_px(view, (cell_col + i / 10, cell_row))
cr.move_to(x, y0)
cr.line_to(x, y1)
for i in range(1, 10):
_, y = self._km_to_px(view, (cell_col, cell_row + i / 10))
cr.move_to(x0, y)
cr.line_to(x1, y)
cr.stroke()
def _draw_marker(self, cr, view, point_km, color, label,
canvas_width, canvas_height, *, hollow=False, dim=False,
selected=False, coord=None, extra_line=None, icon_surface=None) -> None:
x, y = self._km_to_px(view, point_km)
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()
# An ambiguous candidate (hollow) always stays a dashed ring,
# never the icon, that shape is deliberately how "this might be
# where it is" reads, an icon there would look too confident
# about a position that isn't actually confirmed. Below
# ICON_MIN_CELL_PX a game icon would be an illegible smudge, a
# plain filled dot is more honest about the current zoom level.
if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX:
self._draw_icon_marker(cr, x, y, icon_surface, alpha)
elif 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)
if extra_line:
cr.set_font_size(9)
cr.set_source_rgba(*COORD_LABEL, alpha)
cr.move_to(label_x, label_y + (24 if coord_text else 12))
cr.show_text(extra_line)
cr.set_font_size(11)
def _draw_icon_marker(self, cr, x, y, surface, alpha) -> None:
"""The game's own icon for this entity's type, centered on
(x, y), scaled to a fixed box regardless of the source image's
own resolution (uniform scale from whichever of width/height is
larger, so the icon never comes out stretched)."""
box = 32.0
sw, sh = surface.get_width(), surface.get_height()
scale = box / max(sw, sh)
cr.save()
cr.translate(x - sw * scale / 2, y - sh * scale / 2)
cr.scale(scale, scale)
cr.set_source_surface(surface, 0, 0)
cr.paint_with_alpha(alpha)
cr.restore()
def _draw_firing_arrows(self, cr, view) -> 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(view, candidate.as_fraction())
nx, ny = self._km_to_px(view, nest_km)
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, view) -> None:
"""Every Target's effective shell's blast radius, for whichever
ones are selected or pinned via the same "always show geo"
show_geo_desc toggle the bearing/distance overlay uses (not on
plain hover, unlike that overlay -- a blast radius circle
flickering in on every hover was judged too noisy, selection/
pinning is a deliberate choice). Uses the specific selected
candidate point if an ambiguous target is the selected one;
skipped per-target if there's no known point yet, or the shell's
blast radius isn't known."""
targets = [
t for t in self.board.targets
if not self._excluded_from_map(t) and (t is self.selected or t.show_geo_desc)
]
for target in targets:
point = target.coord if target.coord is not None else (
self.selected_point if target is self.selected else None
)
if point is None:
continue
radius_km = target.effective_shell.blast_radius_km
if radius_km is None:
continue
x, y = self._km_to_px(view, point.as_fraction())
rx, ry = view.cell_w * radius_km, view.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_scout_flight_rect(self, cr, view, center_km, bearing_deg, *,
dashed=False, alpha_mult=1.0) -> None:
corners = solver.scout_flight_corners(center_km, bearing_deg)
px_corners = [self._km_to_px(view, p) for p in corners]
r, g, b = SCOUT_FLIGHT
cr.new_path()
cr.move_to(*px_corners[0])
for p in px_corners[1:]:
cr.line_to(*p)
cr.close_path()
cr.set_source_rgba(r, g, b, 0.15 * alpha_mult)
cr.fill_preserve()
cr.set_source_rgba(r, g, b, 0.85 * alpha_mult)
cr.set_line_width(1.5 if dashed else 2)
if dashed:
cr.set_dash([3, 2])
cr.stroke()
if dashed:
cr.set_dash([])
def _draw_placement_preview(self, cr, view) -> None:
"""While armed to place/reposition something, a small crosshair dot
follows the cursor, plus a preview of whatever shape is being
placed: a blast-radius circle (e.g. a Strike, see its shell before
you commit) or a scout flight's oriented rectangle."""
if self.placement_callback is None or self._placement_cursor_km is None:
return
if self.placement_kind == "scout_flight":
center_km, bearing = self._scout_flight_anchor(self._placement_cursor_km)
self._draw_scout_flight_rect(cr, view, center_km, bearing, dashed=True)
x, y = self._km_to_px(view, self._placement_cursor_km)
if self.placement_preview_radius_km is not None:
rx, ry = view.cell_w * self.placement_preview_radius_km, view.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, view) -> None:
"""Bearing/distance overlay lines for whatever's hovered, pinned
via show_geo_desc, or currently selected. Selection matters even
for a target that never resolved at all (no coord, no
potential_coords, e.g. two clues that don't quite geometrically
agree), it's not in placed_entities()/ambiguous_entities() either
way, so this looks at every RP/Target directly rather than those,
the only way to let the user eyeball a bad-but-close reading
against what it should have crossed."""
# Nest/Spotter never carry clues (always given as a direct grid
# coord, no relative-bearing mechanic for them), so leaving them
# out here wouldn't visibly change anything -- but Allies DO get
# clues from OCR ("FriendlyTank#1 Spotted. 088, 12.10km from
# Spotter#1") and also have a show_geo_desc pin in the UI (see
# app.py's per-card "always show geo" toggle), so omitting them
# here meant pinning one silently did nothing.
candidates = list(self.board.reference_points) + list(self.board.targets) + list(self.board.allies)
to_show = [
obj for obj in candidates
if obj.location.clues and not self._excluded_from_map(obj)
and (obj is self.hovered or obj.show_geo_desc or obj is self.selected)
]
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(view, ref_km)
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(view, target_km)
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 and clue.bearing_tolerance_deg is not None:
# A compass word ('West') names a whole sector, not a
# single ray, solve_location() never tries to
# triangulate this into an exact point (see its own
# docstring), draw the actual sector instead of
# pretending it's more precise than it is.
lo_km = solver.point_from_bearing_distance(
ref_km, clue.bearing_deg - clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM)
hi_km = solver.point_from_bearing_distance(
ref_km, clue.bearing_deg + clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM)
lx, ly = self._km_to_px(view, lo_km)
hx, hy = self._km_to_px(view, hi_km)
cr.new_path()
cr.move_to(rx, ry)
cr.line_to(lx, ly)
cr.line_to(hx, hy)
cr.close_path()
cr.set_source_rgba(*YELLOW, 0.15)
cr.fill_preserve()
cr.set_source_rgba(*YELLOW, 0.85)
cr.set_line_width(1.5)
cr.set_dash([3, 2])
cr.stroke()
cr.set_dash([])
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(view, far_km)
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 = view.cell_w * clue.distance_km, view.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(view, radius_target_km)
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))