diff --git a/src/fenigma/app.py b/src/fenigma/app.py index d096d5b..245e2b2 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -57,9 +57,10 @@ def _row( on_toggle_show_geo=None, alive=None, on_toggle_alive=None, + on_convert=None, ) -> Gtk.Widget: - """One list entry: a label plus input/screenshot/geo/hide[/alive][/remove] - action buttons.""" + """One list entry: a label plus input/screenshot/geo/hide[/alive] + [/convert][/remove] action buttons.""" box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) box.set_margin_top(4) box.set_margin_bottom(4) @@ -112,6 +113,15 @@ def _row( hide_btn.connect("clicked", lambda _b: on_toggle_hidden()) box.append(hide_btn) + if on_convert is not None: + convert_btn = Gtk.Button( + icon_name="object-flip-horizontal-symbolic", + tooltip_text="Wrong bucket? Convert to a Target/Reference Point", + ) + convert_btn.add_css_class("flat") + convert_btn.connect("clicked", lambda _b: on_convert()) + box.append(convert_btn) + if on_remove is not None: rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove") rm_btn.add_css_class("flat") @@ -795,6 +805,7 @@ class MainWindow(Adw.ApplicationWindow): on_toggle_hidden=lambda rp=rp: self._toggle_hidden(rp, rebuild), show_geo=rp.show_geo_desc, on_toggle_show_geo=lambda rp=rp: self._toggle_show_geo(rp, rebuild), + on_convert=lambda rp=rp: self._convert_rp_to_target(rp, rebuild), )) box.append(Gtk.Separator()) @@ -817,6 +828,21 @@ class MainWindow(Adw.ApplicationWindow): self._refresh() rebuild() + def _convert_rp_to_target(self, rp, rebuild) -> None: + """OCR guesses which bucket a named thing belongs in (a fixed + landmark vs. an actual contact), sometimes it guesses wrong, + this fixes it without losing the position/clues already worked + out, rather than deleting and re-typing it from scratch.""" + if self.canvas.selected is rp: + self._set_selection(None) + self.board.remove_reference_point(rp) + new_target = self.board.add_target(TargetType.UNKNOWN, rp.location, id_=rp.rp_name) + new_target.hidden = rp.hidden + new_target.show_geo_desc = rp.show_geo_desc + self._refresh() + rebuild() + self.toast(f"{rp.name} converted to {new_target.name}.") + # -- Targets ----------------------------------------------------------------- def _build_targets_popover(self, rebuild) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) @@ -849,6 +875,7 @@ class MainWindow(Adw.ApplicationWindow): on_toggle_show_geo=lambda t=t: self._toggle_show_geo(t, rebuild), alive=t.alive, on_toggle_alive=lambda t=t: self._toggle_alive(t, rebuild), + on_convert=lambda t=t: self._convert_target_to_rp(t, rebuild), )) box.append(Gtk.Separator()) @@ -868,6 +895,20 @@ class MainWindow(Adw.ApplicationWindow): self.board.add_target(type_ or TargetType.UNKNOWN, location, id_) self._refresh() + def _convert_target_to_rp(self, target, rebuild) -> None: + """The other direction of _convert_rp_to_target: an actual + contact that was actually a fixed landmark. Reuses the target's + own name as the new RP's name, so it stays recognizable.""" + if self.canvas.selected is target: + self._set_selection(None) + self.board.remove_target(target) + new_rp = self.board.add_reference_point(target.location, name=target.name) + new_rp.hidden = target.hidden + new_rp.show_geo_desc = target.show_geo_desc + self._refresh() + rebuild() + self.toast(f"{target.name} converted to {new_rp.name}.") + # -- Scout Flights ------------------------------------------------------------ def _build_scout_flights_popover(self, rebuild) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index 0bf262f..4a0012f 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -30,7 +30,8 @@ import numpy as np import pytesseract from PIL import Image, ImageFilter -from .models import Clue, Coord, TargetType +from . import solver +from .models import Board, Clue, Coord, Location, TargetType from .shells import Shell # --- preprocessing ----------------------------------------------------------- @@ -249,6 +250,13 @@ _RP_HEADER_RE = re.compile(r"Reference\s+Point\s+([A-Za-z][\w-]*)\s*:?", re.IGNO # and also stops a bare "Word 094" clue line (space only) from matching. _NAMED_HEADER_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*:?\s*(.*)$") +# Last-resort header: a name with no keyword and no id at all, just +# ':' after squashing ('HMS Rockingham:' -> 'HMSRockingham:'). The +# colon is REQUIRED here (unlike every other header regex's optional +# one), nothing else anchors this match, so an optional colon would +# false-positive on an ordinary clue-continuation line's leading word. +_BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$") + # Ad-hoc enemy installations are named in plain English rather than given a # Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal # Station"), which breaks two assumptions everywhere else in this module: @@ -327,6 +335,88 @@ def parse_named_at_coord(text: str) -> list[dict]: return entries +# Forward-observer reports: several standalone one-liners, each an +# independent FO position (a literal one-off coordinate right there in +# the line, not a name referencing some other known entity) giving one +# bearing/distance reading on a named target: +# FO#5 Audio report on HMS Rockingham: 2.24km From I8 6:9 . . . +# FO#4 Eyes on HMS Rockingham: 087° From G7 6:7 . . . +# FO Eyes on HMS Rockingham: 099° From C9 1:2 . . . +# ('FO' alone, no '#N', for exactly one report is fine, still its own +# distinct position.) Distance tried before bearing, same reasoning as +# _CLUE_PATTERNS elsewhere: an ambiguous run of digits could otherwise +# let the bearing pattern's optional '°' swallow part of a distance +# reading if bearing ran first. +_FO_TARGET_NAME = r"([A-Za-z][A-Za-z0-9'\s]*?)" +_FO_COORD = rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" +_FO_DISTANCE_RE = re.compile( + rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*" + rf"([\d.]+)\s*k?m\s+From\s+{_FO_COORD}", + re.IGNORECASE, +) +_FO_BEARING_RE = re.compile( + rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*" + rf"({_DIGIT_CLASS}{{1,3}})\s*°?\s+From\s+{_FO_COORD}", + re.IGNORECASE, +) + + +def parse_fo_reports(text: str) -> dict[str, tuple[str, Coord | None]]: + """An FO position isn't tracked as a real board entity, nothing to + re-reference later, no ongoing observation post, just a one-off + fix, so this triangulates immediately: for each named target, build + a throwaway scratch Board with just that target's FO positions as + (never-persisted) reference points, run the exact same + solve_location() geometry/priority the real board uses, keep only + the resulting Coord (or None, if the readings under- or + over-determine it in a way solve_location() can't resolve), then + discard the scratch board and every ephemeral FO entity in it. + Returns target_name -> (raw report text, resolved coord or None).""" + consumed: list[tuple[int, int]] = [] + + def overlaps(span: tuple[int, int]) -> bool: + return any(span[0] < e and span[1] > s for s, e in consumed) + + # target_key -> list of (fo_name, fo_coord, bearing_deg, distance_km, raw_line) + groups: dict[str, list[tuple[str, Coord, float | None, float | None, str]]] = {} + + def collect(m, is_distance: bool) -> None: + span = m.span() + if overlaps(span): + return + consumed.append(span) + fo_num, target_name, value, letter, y, x, yy = m.groups() + fo_name = f"FO#{_fix_id_digits(fo_num)}" if fo_num else "FO" + try: + coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) + except ValueError: + return + target_key = "".join(re.sub(r"[^A-Za-z0-9]", "", w) for w in target_name.split()) + bearing = None if is_distance else float(_fix_digits(value)) + distance = float(_fix_digits(value)) if is_distance else None + groups.setdefault(target_key, []).append((fo_name, coord, bearing, distance, m.group(0).strip())) + + for m in _FO_DISTANCE_RE.finditer(text): + collect(m, is_distance=True) + for m in _FO_BEARING_RE.finditer(text): + collect(m, is_distance=False) + + results: dict[str, tuple[str, Coord | None]] = {} + for target_key, readings in groups.items(): + scratch = Board() + clues = [] + placed: set[str] = set() + for fo_name, coord, bearing, distance, _raw in readings: + if fo_name not in placed: + placed.add(fo_name) + scratch.add_reference_point(coord, name=fo_name) + clues.append(Clue(reference=fo_name, bearing_deg=bearing, distance_km=distance)) + raw_text = "\n".join(r[4] for r in readings) + result = solver.solve_location(Location.from_desc(raw_text, clues), scratch) + results[target_key] = (raw_text, result.coord) + return results + + _BLOCK_END_RE = re.compile(r"^[.\s]{1,6}$") _LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])") @@ -549,6 +639,25 @@ def parse_intel_blocks(text: str) -> list[dict]: "id": num, "raw": [line], "clues": []} continue + # Last resort: a bare name with none of the above (no 'Reference + # Point'/'Enemy' keyword, no digit id), just ':' - + # ('HMS Rockingham:'). Requiring the colon (not optional, unlike + # the other header regexes) matters here specifically: without + # any keyword or id anchoring the match, an optional colon would + # false-positive on an ordinary clue-continuation line's first + # word ('Distance 6.14km...' -> 'Distance' read as a header). + # Treated as a Target (TargetType.UNKNOWN, whose short form IS + # 'Target'), not a Reference Point, a named thing giving its own + # clues is being spotted, not a fixed landmark spotters aim off + # of, same reasoning as the "Target is at-" calibration line. + bare_m = next((m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))), None) + if bare_m: + flush() + name = bare_m.group(1) + current = {"kind": "named", "name": f"Target#{name}", "type_word": "Target", + "id": name, "raw": [line], "clues": []} + continue + if current is not None: current["raw"].append(line) @@ -700,6 +809,19 @@ def _squash_span_content(content: str) -> str: multiword_id = _MULTIWORD_ID_SPAN_RE.match(content) if multiword_id: return "".join(multiword_id.group(1).split()) + "#" + multiword_id.group(2) + words = content.split() + if len(words) > 1 and not any(re.search(r"\d", w) for w in words): + # Neither shape above, but still a multi-word span with no digits + # in it, a plain multi-word name ('The Mole', "Dockmaster's + # House"): still one logical name, collapse it into one + # alphanumeric token the same way every reference elsewhere is + # matched (_RP_HEADER_RE, the (\S+) clue-reference capture), + # stripping punctuation too (apostrophes etc.), those aren't in + # _RP_HEADER_RE's word-character class and would otherwise + # truncate the match. The no-digits guard matters: a multi-word + # bold span can just as easily be a coordinate ('C9 7:9'), and + # squashing THAT the same way corrupts it instead of a name. + return "".join(re.sub(r"[^A-Za-z0-9]", "", w) for w in words) return content @@ -802,6 +924,17 @@ def parse_text(text: str) -> ParsedInfo: for entry in parse_named_at_coord(text): info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) + for target_key, (raw, coord) in parse_fo_reports(text).items(): + key = (TargetType.UNKNOWN, target_key) + if key in info.targets: + old_raw, old_clues, old_coord, shell, requested_time = info.targets[key] + info.targets[key] = ( + f"{old_raw}\n{raw}", old_clues, old_coord if old_coord is not None else coord, + shell, requested_time, + ) + else: + info.targets[key] = (raw, [], coord, None, None) + info.destroyed = parse_destroyed(text) return info