FEnigma/src/ironnest_assist/grid_widget.py
Dominik Roth 8bec273b3f Initial commit: IRON NEST Assist
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>
2026-08-08 20:01:50 +02:00

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))