From 980be8690811fc6cb6d5e52a4314f4d42b54bc06 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sat, 8 Aug 2026 23:56:24 +0200 Subject: [PATCH] Fall back to a compromise point when two distance circles don't cross Previously two distance-only clues whose circles didn't actually intersect (real typewriter data isn't perfectly consistent, a misplaced spotter or an off-by-a-bit distance reading is enough) just gave up entirely, even when they were nearly touching. solver.closest_compromise_point() picks the midpoint between each circle's point facing the other, the standard notion of the closest approach between two circles. It stays well-behaved even when the centers are nearly coincident, unlike projecting along the center line the way a real intersection's formula does, which diverges as the centers get close while the radii stay far apart, exactly the near-coincident case this is for. The result is flagged rather than treated as a clean fix: Location gained a field, set whenever solve_location() had to use this fallback, cleared by any subsequent coord.setter call (manual or solver), shown in the entity's status label and toasted after a manual clue edit. Persisted through save/load. --- src/fenigma/app.py | 11 +++++--- src/fenigma/models.py | 41 +++++++++++++++++++++++++---- src/fenigma/solver.py | 61 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/fenigma/app.py b/src/fenigma/app.py index 44dced7..8336c7c 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -162,7 +162,8 @@ def _scout_flight_row(sf, *, on_replot, on_remove, on_toggle_hidden) -> Gtk.Widg def _location_status(obj) -> str: if obj.coord is not None: - return obj.coord.label() + suffix = " approximate" if obj.location.note else "" + return f"{obj.coord.label()}{suffix}" if obj.location.potential_coords: return f"ambiguous ({len(obj.location.potential_coords)} candidates)" if obj.location.desc_raw: @@ -991,8 +992,12 @@ class MainWindow(Adw.ApplicationWindow): _apply_location(obj, location) self._refresh() # A manual clue edit (typically the Description tab) can go in and - # come out unresolved with no other feedback, tell the user why - # instead of leaving it looking like nothing happened. + # come out unresolved, or resolved only approximately, with no + # other feedback, tell the user which instead of leaving it + # looking like nothing happened / a clean fix. + if obj.location.note: + self.toast(f"{obj.name}: {obj.location.note}.") + return reason = solver.explain_unresolved(obj.location, self.board) if reason is not None: self.toast(f"{obj.name} not resolved: {reason}.") diff --git a/src/fenigma/models.py b/src/fenigma/models.py index d036f34..392966b 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -140,12 +140,17 @@ class Location: that resolution. `potential_coords` holds a solver result that was genuinely ambiguous (e.g. a bearing ray crossing a distance circle twice), shown on the map as candidates, never treated as resolved - and never used to resolve anything else.""" + and never used to resolve anything else. `note` is set instead when + the solver had to fall back to an approximate compromise point + because the clues didn't quite geometrically agree (see + solver.closest_compromise_point()), unlike potential_coords this + *is* a single resolved coord, just flagged as not fully trustworthy.""" coord: Coord | None = None desc_raw: str | None = None clues: list[Clue] = field(default_factory=list) potential_coords: list[Coord] = field(default_factory=list) + note: str | None = None @property def is_resolved(self) -> bool: @@ -170,6 +175,7 @@ class Location: "desc_raw": self.desc_raw, "clues": [c.to_dict() for c in self.clues], "potential_coords": [c.to_dict() for c in self.potential_coords], + "note": self.note, } @classmethod @@ -181,6 +187,7 @@ class Location: desc_raw=d.get("desc_raw"), clues=[Clue.from_dict(c) for c in d.get("clues", [])], potential_coords=[Coord.from_dict(c) for c in d.get("potential_coords", [])], + note=d.get("note"), ) @@ -206,8 +213,14 @@ class Nest: # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), - # so it just goes inert rather than being deleted. + # so it just goes inert rather than being deleted. `note` DOES get + # cleared: it's the solver's "this was an approximate compromise, + # not a real fix" flag, and any new coord here, solver-derived or + # a manual override, invalidates whatever note was there before + # (resolve_board() re-attaches a fresh one right after, if this + # new coord is itself another approximate fix). self.location.coord = value + self.location.note = None @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members @@ -232,8 +245,14 @@ class Spotter: # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), - # so it just goes inert rather than being deleted. + # so it just goes inert rather than being deleted. `note` DOES get + # cleared: it's the solver's "this was an approximate compromise, + # not a real fix" flag, and any new coord here, solver-derived or + # a manual override, invalidates whatever note was there before + # (resolve_board() re-attaches a fresh one right after, if this + # new coord is itself another approximate fix). self.location.coord = value + self.location.note = None @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members @@ -258,8 +277,14 @@ class ReferencePoint: # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), - # so it just goes inert rather than being deleted. + # so it just goes inert rather than being deleted. `note` DOES get + # cleared: it's the solver's "this was an approximate compromise, + # not a real fix" flag, and any new coord here, solver-derived or + # a manual override, invalidates whatever note was there before + # (resolve_board() re-attaches a fresh one right after, if this + # new coord is itself another approximate fix). self.location.coord = value + self.location.note = None @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members @@ -306,8 +331,14 @@ class Target: # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), - # so it just goes inert rather than being deleted. + # so it just goes inert rather than being deleted. `note` DOES get + # cleared: it's the solver's "this was an approximate compromise, + # not a real fix" flag, and any new coord here, solver-derived or + # a manual override, invalidates whatever note was there before + # (resolve_board() re-attaches a fresh one right after, if this + # new coord is itself another approximate fix). self.location.coord = value + self.location.note = None @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members diff --git a/src/fenigma/solver.py b/src/fenigma/solver.py index 6421c0a..8ea3814 100644 --- a/src/fenigma/solver.py +++ b/src/fenigma/solver.py @@ -38,6 +38,11 @@ Point = tuple[float, float] # (col, row) in km class SolveResult: coord: Coord | None = None potential: list[Coord] = field(default_factory=list) + # Set when `coord` came from closest_compromise_point() rather than a + # real intersection, i.e. the underlying readings don't quite agree + # with each other. Explains itself, meant to be shown to the user + # (see resolve_board()/Location.note), never checked by code. + note: str | None = None def bearing_distance_to_delta(bearing_deg: float, distance_km: float) -> Point: @@ -123,6 +128,34 @@ def circle_circle_intersections( return [(px + h * perp_x, py + h * perp_y), (px - h * perp_x, py - h * perp_y)] +def closest_compromise_point( + center_a: Point, radius_a: float, center_b: Point, radius_b: float +) -> Point | None: + """When two distance-only clues' circles don't actually cross (real + game data isn't perfectly consistent, a spotter's position or a + reported distance can be off by enough that the two circles end up + nested or just short of touching), the point that best reconciles + both readings anyway: the midpoint between circle A's point facing + circle B and circle B's point facing circle A, the standard notion of + "closest points between two circles" when they're genuinely apart, + and it degrades gracefully rather than blowing up when they're + nested or nearly concentric too (unlike projecting along the + center line the way a real intersection's `a` term does, which + diverges as the centers get close together while the radii stay far + apart, exactly the case this function exists for). None only for + coincident centers, where "the line through them" isn't defined.""" + ax, ay = center_a + bx, by = center_b + dx, dy = bx - ax, by - ay + d = math.hypot(dx, dy) + if d < 1e-9: + return None + ux, uy = dx / d, dy / d + edge_a = (ax + ux * radius_a, ay + uy * radius_a) # on circle A, facing B + edge_b = (bx - ux * radius_b, by - uy * radius_b) # on circle B, facing A + return (edge_a[0] + edge_b[0]) / 2, (edge_a[1] + edge_b[1]) / 2 + + def ray_ray_intersection( origin_a: Point, bearing_a: float, origin_b: Point, bearing_b: float ) -> Point | None: @@ -215,6 +248,19 @@ def solve_location(location: Location, board: Board) -> SolveResult: return SolveResult(coord=coords[0]) if len(coords) >= 2: return SolveResult(potential=coords) # genuinely ambiguous + # No real intersection, the circles are nested or just short of + # touching. Rather than give up, use the point that best splits + # the difference, flagged as approximate rather than treated as + # a clean fix. + point = closest_compromise_point(p1, c1.distance_km, p2, c2.distance_km) + if point is not None: + coord = point_to_coord(point) + if coord is not None: + return SolveResult(coord=coord, note=( + f"approximate: {c1.reference}'s {c1.distance_km}km and {c2.reference}'s " + f"{c2.distance_km}km circles don't actually cross, used the closest point " + "between them instead" + )) return SolveResult() @@ -248,11 +294,15 @@ def explain_unresolved(location: Location, board: Board) -> str | None: f"circle around {cd.reference}, check those two readings against each other") if len(distances) >= 2: + # circle_circle_intersections() not crossing isn't fatal by itself + # any more, solve_location() falls back to closest_compromise_point() + # for that, only reaching here if even that gave up. (c1, p1), (c2, p2) = distances[0], distances[1] - if not circle_circle_intersections(p1, c1.distance_km, p2, c2.distance_km): - return (f"the {c1.distance_km}km circle around {c1.reference} and the {c2.distance_km}km " - f"circle around {c2.reference} don't cross, too far apart or one nested inside " - "the other given those references' actual positions, check the readings/positions") + point = closest_compromise_point(p1, c1.distance_km, p2, c2.distance_km) + if point is None: + return f"{c1.reference} and {c2.reference} are reported at the exact same position, can't triangulate from two coincident circles" + if point_to_coord(point) is None: + return f"the best-fit point for {c1.reference}'s and {c2.reference}'s distances falls off the map" return None # e.g. two bearings that are (near-)parallel, or genuinely just needs more info @@ -274,7 +324,8 @@ def resolve_board(board: Board) -> list[str]: continue # already resolved, or stuck ambiguous, don't reprocess result = solve_location(obj.location, board) if result.coord is not None: - obj.coord = result.coord + obj.coord = result.coord # clears any stale note (see the coord setters) + obj.location.note = result.note newly_resolved.append(obj.name) changed = True elif result.potential: