From fd34e4ae4aadd9434d15c8acd70a3476bce83e8f Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sat, 26 Sep 2026 14:29:46 +0200 Subject: [PATCH] j135 draw-only geometry overlay (TR_GEO_DEBUG) + global debug-draw switch (TR_DEBUG_DRAW) --- ModularBot_garage/src/ModularBot.nim | 37 ++- ModularBot_garage/src/env_report.nim | 7 + ModularBot_garage/src/geo_overlay.nim | 225 +++++++++++++++++++ ModularBot_garage/tests/test_geo_overlay.nim | 176 +++++++++++++++ 4 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 ModularBot_garage/src/geo_overlay.nim create mode 100644 ModularBot_garage/tests/test_geo_overlay.nim diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index d8b8fa3..707ecd9 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -44,6 +44,7 @@ import targeting/enemy_tracker import targeting/target_selector import env_report import vbullet_draw +import geo_overlay const botJsonPath = currentSourcePath().parentDir / "ModularBot.json" const DebugVBullets = false @@ -176,6 +177,19 @@ let VBulletDebugOn = vBulletDebugEnabled() let VBulletDebugMax = vBulletDebugMaxBullets() let VBulletDebugGun = resolveGunFilter(getEnv(VBulletDebugGunEnv, ""), GunNames) +## ── global debug-draw switch (TR_DEBUG_DRAW, default ON) ─────────────────── +## When 0/false/no/off, every mover is built with `debugGraphics = false` and +## the virtual-bullet overlay is skipped, so nothing but the (independent, +## default-off) TR_GEO_DEBUG geometry overlay is left on screen. When on, every +## per-element knob behaves exactly as before. +let DebugDrawOn = debugDrawEnabled() + +## ── geometry overlay (TR_GEO_DEBUG, default OFF) ─────────────────────────── +## Draw-only visual aid (see `geo_overlay.nim`): the two bots as the endpoints +## of a circle diameter, each heading line, and each line's intersection points +## with the circle. No decision, movement or gun change. +let GeoDebugOn = geoDebugEnabled() + const CLR_GUN = "\e[33m" # yellow CLR_MOVE = "\e[36m" # cyan @@ -1357,7 +1371,7 @@ method run*(bot: ModularBot) = of BitBrainId: bot.bitbrain.onResult(fe) else: discard if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss - if VBulletDebugOn: + if DebugDrawOn and VBulletDebugOn: resolvedMarks.add VbResolved( gunId: gunId, aimX: fe.prediction.x, aimY: fe.prediction.y, actualX: fe.actualX, actualY: fe.actualY, hit: fe.hit) @@ -1440,12 +1454,19 @@ method run*(bot: ModularBot) = setGunTurnRate(normDelta) - # Virtual-bullet overlay (default off). Drawn AFTER selection so the - # default "selected gun only" filter uses this tick's choice. - if VBulletDebugOn: + # Virtual-bullet overlay (default off; suppressed by TR_DEBUG_DRAW=0). + # Drawn AFTER selection so the default "selected gun only" filter uses + # this tick's choice. + if DebugDrawOn and VBulletDebugOn: discard drawVirtualBullets(bot.tracker, resolvedMarks, selectedGun, VBulletDebugGun, VBulletDebugMax, GunNames) + # Geometry overlay (TR_GEO_DEBUG, default off). Independent of + # TR_DEBUG_DRAW: this is the one thing left when debug drawing is off. + if GeoDebugOn: + discard drawGeoOverlay(ws.selfX, ws.selfY, ws.selfHeading, + ws.enemyX, ws.enemyY, ws.enemyHeading) + # Emit exactly one config line per tick, at the END of the tick, so the gun # field reflects the selection made above rather than the previous tick's. if bot.cfgDirty: @@ -1490,9 +1511,9 @@ when isMainModule: bitbrain: initBitBrainGun(), radar: RadarLockModule(), meleeRadar: initAdaptiveMeleeRadar(), - mover: TFILModule(debugGraphics: true), - ringMover: TFILRingModule(debugGraphics: true), - strafeMover: StrafeModule(debugGraphics: true), + mover: TFILModule(debugGraphics: DebugDrawOn), + ringMover: TFILRingModule(debugGraphics: DebugDrawOn), + strafeMover: StrafeModule(debugGraphics: DebugDrawOn), surfMover: initWaveSurfer(), learnedMover: initLearnedSurfer(), rammer: initRammer(), @@ -1512,6 +1533,8 @@ when isMainModule: vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""), vBulletDebugMax: VBulletDebugMax, recordWorldState: RecordWorldState, + geoDebug: GeoDebugOn, + debugDraw: DebugDrawOn, radarForceSpin: RadarForceSpin, radarScanLog: RadarScanLog, radarScanLogPath: RadarScanLogPath, diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index a6c75e5..c12a08b 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -33,6 +33,7 @@ import guns/tm_horizon import guns/bitbrain_gun import guns/pattern_matcher import vbullet_draw +import geo_overlay const EnvReportEnableEnvVar* = "TR_ENV_REPORT" @@ -49,6 +50,8 @@ type vBulletDebugGun*: string vBulletDebugMax*: int recordWorldState*: bool + geoDebug*: bool + debugDraw*: bool radarForceSpin*: bool radarScanLog*: bool radarScanLogPath*: string @@ -209,6 +212,9 @@ proc printEffectiveValues(ctx: EnvReportContext) = sourceOf(VBulletDebugGunEnv)) emit("TR_VBULLET_DEBUG_MAX", $ctx.vBulletDebugMax, sourceOf(VBulletDebugMaxEnv)) + # global debug-draw switch (default on) + the draw-only geometry overlay + emit(DebugDrawEnv, onOff(ctx.debugDraw), sourceOfPresence(DebugDrawEnv)) + emit(GeoDebugEnv, onOff(ctx.geoDebug), sourceOfPresence(GeoDebugEnv)) emit("TR_POWER_LOG", onOff(ctx.powerLog), sourceOfPresence("TR_POWER_LOG")) emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG")) emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState), @@ -488,6 +494,7 @@ proc knownEnvNames*(): seq[string] = "TR_RADAR_FORCE_SPIN", "TR_RADAR_SCANLOG", "TR_RADAR_SCAN_LOG_PATH", "TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY", VBulletDebugEnv, VBulletDebugGunEnv, VBulletDebugMaxEnv, + DebugDrawEnv, GeoDebugEnv, "TR_POWER_LOG", "TR_RESULT_LOG", # the .env loader's own environment hook (TR_ENV_FILE names the file) diff --git a/ModularBot_garage/src/geo_overlay.nim b/ModularBot_garage/src/geo_overlay.nim new file mode 100644 index 0000000..86cfd0b --- /dev/null +++ b/ModularBot_garage/src/geo_overlay.nim @@ -0,0 +1,225 @@ +## Geometry overlay (TR_GEO_DEBUG) — a DRAW-ONLY visual aid. +## +## No decision, no movement, no gun change: this module only emits SVG for the +## GUI. DEFAULT OFF, so the shipped bot is byte-for-byte unchanged. It exists so +## the owner can reason about a specific piece of geometry: +## +## * the two bots are the endpoints of a DIAMETER, so both lie exactly on the +## circle (centre = midpoint, radius = distance / 2); +## * a line along OUR heading through our position; +## * a line along the ENEMY heading through the enemy position; +## * the intersection points of each line with the circle, marked. A line +## through a point that is ON the circle meets it twice — the bot itself and +## ONE OTHER point — and the OTHER point is drawn visually distinct (that is +## the one worth looking at). A tangent line's two points coincide; we draw +## what the maths gives. +## +## Env knobs (read once at boot by ModularBot, registered in env_report.nim): +## TR_GEO_DEBUG presence/1 = on. default OFF. +## TR_DEBUG_DRAW presence/1 = on (default); 0/false/no/off disables every +## mover's `debugGraphics` and the virtual-bullet overlay, so +## the geometry overlay is the ONLY thing left on screen. +## +## The geometry is pure and unit-tested by `tests/test_geo_overlay.nim`; only +## `emitGeoPrims` touches the GUI buffer. + +import std/[math, os, strutils] +import robocode_tankroyale_botapi + +const + GeoDebugEnv* = "TR_GEO_DEBUG" + DebugDrawEnv* = "TR_DEBUG_DRAW" + +const DegToRad = PI / 180.0 + +# ── env resolution (pure, so a test can pin it) ────────────────────────────── + +proc envFlagOn(value: string, default: bool): bool = + ## "" -> `default`; explicit 0/false/no/off -> false; anything else -> true. + let s = value.strip().toLowerAscii() + if s.len == 0: return default + s notin ["0", "false", "no", "off"] + +proc geoDebugEnabled*(): bool = + ## `TR_GEO_DEBUG`: default OFF (the shipped bot draws nothing new). + envFlagOn(getEnv(GeoDebugEnv, ""), false) + +proc debugDrawEnabled*(): bool = + ## `TR_DEBUG_DRAW`: default ON. When off, every mover is built with + ## `debugGraphics = false` and the virtual-bullet overlay is skipped. + envFlagOn(getEnv(DebugDrawEnv, ""), true) + +# ── the geometry (pure) ────────────────────────────────────────────────────── + +type + GeomCircle* = object + cx*, cy*, r*: float + + GeomPoint* = object + x*, y*: float + +proc makeDiameterCircle*(ax, ay, bx, by: float): GeomCircle = + ## The circle whose DIAMETER is the segment a-b: centre at the midpoint, + ## radius half the distance. Both a and b therefore lie on the circumference. + ## A degenerate (coincident) pair yields r = 0; callers skip the draw. + result.cx = (ax + bx) * 0.5 + result.cy = (ay + by) * 0.5 + result.r = 0.5 * hypot(bx - ax, by - ay) + +proc distTo*(p: GeomPoint, c: GeomCircle): float {.inline.} = + hypot(p.x - c.cx, p.y - c.cy) + +proc onCircle*(p: GeomPoint, c: GeomCircle): bool {.inline.} = + abs(p.distTo(c) - c.r) < 1e-6 + +proc lineCircleIntersections*(px, py, headingDeg: float, + c: GeomCircle): seq[GeomPoint] = + ## The two intersection points of the INFINITE line through (px, py) along + ## `headingDeg` with circle `c`. The line is parametrised P(t) = p + t*d with + ## d a unit vector, so the quadratic has a = 1 and the roots are signed + ## distances along d. Returns the two roots when the line meets the circle + ## (coincident when tangent), and nothing when it misses. + ## + ## Tank Royale bearings: 0° = east, positive counter-clockwise. + let h = headingDeg * DegToRad + let dx = cos(h) + let dy = sin(h) + let ox = px - c.cx + let oy = py - c.cy + let b = 2.0 * (ox * dx + oy * dy) + let cc = ox * ox + oy * oy - c.r * c.r + let disc = b * b - 4.0 * cc + if disc < 0.0: return @[] + let s = sqrt(disc) + let t1 = (-b - s) * 0.5 + let t2 = (-b + s) * 0.5 + result.add GeomPoint(x: px + t1 * dx, y: py + t1 * dy) + result.add GeomPoint(x: px + t2 * dx, y: py + t2 * dy) + +type + GeoPrimKind* = enum + gpCircle ## the diameter circle outline + gpLineSelf ## line along OUR heading + gpLineEnemy ## line along the ENEMY heading + gpMarkSelf ## intersection that coincides with the bot (hollow ring) + gpMarkOther ## the OTHER intersection (filled, distinct) + + GeoPrim* = object + kind*: GeoPrimKind + x1*, y1*: float ## lines: first endpoint + x2*, y2*: float ## lines: second endpoint + cx*, cy*, r*: float ## circles: centre + radius + color*: string + +const + GeoCircleColor* = "#888888" # grey — the diameter circle + GeoSelfLineColor* = "#00FFFF" # cyan — OUR heading line + GeoEnemyLineColor* = "#FF8000" # orange— ENEMY heading line + GeoSelfMarkColor* = "#FFFFFF" # white — the bot itself (hollow ring) + GeoOtherMarkColor* = "#FF00FF" # magenta — the OTHER intersection (filled) + GeoCircleWidth* = 1.0 + GeoLineWidth* = 1.0 + GeoMarkWidth* = 1.5 + GeoSelfMarkRadius* = 4.0 + GeoOtherMarkRadius* = 4.0 + GeoLineMargin* = 10.0 + ## Extra length (px) the heading segment extends beyond the far + ## intersection; a segment of half-length 2r + margin always contains both + ## intersections because a chord of a circle is at most 2r long. + +proc pickSelfOther*(pts: seq[GeomPoint], ax, ay: float): + tuple[self, other: GeomPoint, hasOther: bool] = + ## The intersection nearest the anchor `(ax, ay)` is the bot itself (the + ## anchor lies ON the circle, so the distance is ~0); the farther one is the + ## OTHER point. `hasOther` distinguishes a real second point from a tangent + ## coincidence. + if pts.len == 0: return + var iSelf = 0 + var dSelf = hypot(pts[0].x - ax, pts[0].y - ay) + for i in 1 ..< pts.len: + let d = hypot(pts[i].x - ax, pts[i].y - ay) + if d < dSelf: dSelf = d; iSelf = i + result.self = pts[iSelf] + var iOther = -1 + var dOther = 0.0 + for i in 0 ..< pts.len: + if i == iSelf: continue + let d = hypot(pts[i].x - ax, pts[i].y - ay) + if iOther < 0 or d > dOther: iOther = i; dOther = d + if iOther >= 0: + result.other = pts[iOther] + result.hasOther = true + +proc headingSegment(px, py, headingDeg, halfLen: float): tuple[x1, y1, x2, y2: float] = + ## Segment centred on (px, py), `halfLen` long each way along `headingDeg`. + let h = headingDeg * DegToRad + let dx = cos(h) * halfLen + let dy = sin(h) * halfLen + (px - dx, py - dy, px + dx, py + dy) + +proc collectGeoPrims*(selfX, selfY, selfHeading, + enemyX, enemyY, enemyHeading: float): seq[GeoPrim] = + ## Pure: the primitives the overlay draws. Empty in the degenerate case where + ## the two bots coincide (distance ~0) — skip rather than divide by zero. + let c = makeDiameterCircle(selfX, selfY, enemyX, enemyY) + if c.r < 1e-6: return + + result.add GeoPrim(kind: gpCircle, cx: c.cx, cy: c.cy, r: c.r, + color: GeoCircleColor) + + let halfLen = 2.0 * c.r + GeoLineMargin + + # OUR heading line + its two intersection points. + let segSelf = headingSegment(selfX, selfY, selfHeading, halfLen) + result.add GeoPrim(kind: gpLineSelf, x1: segSelf.x1, y1: segSelf.y1, + x2: segSelf.x2, y2: segSelf.y2, color: GeoSelfLineColor) + let ipSelf = lineCircleIntersections(selfX, selfY, selfHeading, c) + let soSelf = pickSelfOther(ipSelf, selfX, selfY) + if soSelf.hasOther: + result.add GeoPrim(kind: gpMarkSelf, cx: soSelf.self.x, cy: soSelf.self.y, + r: GeoSelfMarkRadius, color: GeoSelfMarkColor) + result.add GeoPrim(kind: gpMarkOther, cx: soSelf.other.x, cy: soSelf.other.y, + r: GeoOtherMarkRadius, color: GeoOtherMarkColor) + + # ENEMY heading line + its two intersection points. + let segEnemy = headingSegment(enemyX, enemyY, enemyHeading, halfLen) + result.add GeoPrim(kind: gpLineEnemy, x1: segEnemy.x1, y1: segEnemy.y1, + x2: segEnemy.x2, y2: segEnemy.y2, color: GeoEnemyLineColor) + let ipEnemy = lineCircleIntersections(enemyX, enemyY, enemyHeading, c) + let soEnemy = pickSelfOther(ipEnemy, enemyX, enemyY) + if soEnemy.hasOther: + result.add GeoPrim(kind: gpMarkSelf, cx: soEnemy.self.x, cy: soEnemy.self.y, + r: GeoSelfMarkRadius, color: GeoSelfMarkColor) + result.add GeoPrim(kind: gpMarkOther, cx: soEnemy.other.x, + cy: soEnemy.other.y, r: GeoOtherMarkRadius, + color: GeoOtherMarkColor) + +# ── emit to the GUI debug-graphics buffer ──────────────────────────────────── + +proc emitGeoPrims*(prims: seq[GeoPrim]): int = + ## Emit the primitives into the current tick's SVG buffer. Thin lines. + for p in prims: + case p.kind + of gpCircle: + setStrokeColor(p.color) + setStrokeWidth(GeoCircleWidth) + drawCircle(p.cx, p.cy, p.r) + of gpLineSelf, gpLineEnemy: + setStrokeColor(p.color) + setStrokeWidth(GeoLineWidth) + drawLine(p.x1, p.y1, p.x2, p.y2) + of gpMarkSelf: + setStrokeColor(p.color) + setStrokeWidth(GeoMarkWidth) + drawCircle(p.cx, p.cy, p.r) + of gpMarkOther: + setFillColor(p.color) + fillCircle(p.cx, p.cy, p.r) + result = prims.len + +proc drawGeoOverlay*(selfX, selfY, selfHeading, + enemyX, enemyY, enemyHeading: float): int = + ## Entry point called once per tick by ModularBot when `TR_GEO_DEBUG` is on. + ## Returns the number of primitives drawn. + emitGeoPrims(collectGeoPrims(selfX, selfY, selfHeading, + enemyX, enemyY, enemyHeading)) diff --git a/ModularBot_garage/tests/test_geo_overlay.nim b/ModularBot_garage/tests/test_geo_overlay.nim new file mode 100644 index 0000000..d8b5e75 --- /dev/null +++ b/ModularBot_garage/tests/test_geo_overlay.nim @@ -0,0 +1,176 @@ +## Deterministic, offline check of the geometry overlay (`geo_overlay`). +## +## NO battle, NO Java, NO server: fixed positions/headings, then assert the +## circle/diameter property, the two line-circle intersections per heading line, +## and the hand-computed "other" points. Also pins the two env switches and the +## SVG emission (2 line segments + 5 circles: 1 outline + 2 hollow self marks + +## 2 filled other marks). +## +## Run: nim c -r ModularBot_garage/tests/test_geo_overlay.nim + +import std/[math, os, strutils] +import robocode_tankroyale_botapi +import geo_overlay + +var failures = 0 +proc check(name: string, ok: bool) = + if ok: echo "PASS: ", name + else: echo "FAIL: ", name; inc failures + +proc approx(a, b: float, eps = 1e-6): bool = abs(a - b) < eps + +proc crossOnLine(px, py, headingDeg, qx, qy: float): float = + ## |(q - p) x direction| / |q - p| == perpendicular distance of q from the + ## line through p along `headingDeg`; 0 when q is on the line. + let h = headingDeg * PI / 180.0 + let dx = cos(h) + let dy = sin(h) + let vx = qx - px + let vy = qy - py + let len = hypot(vx, vy) + if len < 1e-9: return 0.0 + abs(vx * dy - vy * dx) + +# ── the env switches ───────────────────────────────────────────────────────── + +proc testEnvSwitches() = + delEnv(GeoDebugEnv) + check "TR_GEO_DEBUG defaults OFF", not geoDebugEnabled() + putEnv(GeoDebugEnv, "1") + check "TR_GEO_DEBUG=1 is ON", geoDebugEnabled() + putEnv(GeoDebugEnv, "0") + check "TR_GEO_DEBUG=0 is OFF", not geoDebugEnabled() + delEnv(GeoDebugEnv) + + delEnv(DebugDrawEnv) + check "TR_DEBUG_DRAW defaults ON", debugDrawEnabled() + putEnv(DebugDrawEnv, "0") + check "TR_DEBUG_DRAW=0 is OFF", not debugDrawEnabled() + putEnv(DebugDrawEnv, "false") + check "TR_DEBUG_DRAW=false is OFF", not debugDrawEnabled() + putEnv(DebugDrawEnv, "1") + check "TR_DEBUG_DRAW=1 is ON", debugDrawEnabled() + delEnv(DebugDrawEnv) + +# ── hand-computed case A: heading along the diameter ───────────────────────── + +proc testCaseA() = + let selfX = 100.0; let selfY = 100.0; let selfH = 0.0 + let enX = 300.0; let enY = 100.0; let enH = 180.0 + let c = makeDiameterCircle(selfX, selfY, enX, enY) + check "A: centre is the midpoint (200,100)", + approx(c.cx, 200.0) and approx(c.cy, 100.0) + check "A: radius is distance/2 (=100)", approx(c.r, 100.0) + check "A: our bot lies on the circle", + onCircle(GeomPoint(x: selfX, y: selfY), c) + check "A: enemy lies on the circle", + onCircle(GeomPoint(x: enX, y: enY), c) + + let ipSelf = lineCircleIntersections(selfX, selfY, selfH, c) + check "A: our line yields exactly two intersections", ipSelf.len == 2 + let soSelf = pickSelfOther(ipSelf, selfX, selfY) + check "A: our own point is an intersection (100,100)", + approx(soSelf.self.x, 100.0) and approx(soSelf.self.y, 100.0) + check "A: our OTHER point is hand-computed (300,100)", + soSelf.hasOther and approx(soSelf.other.x, 300.0) and + approx(soSelf.other.y, 100.0) + + let ipEnemy = lineCircleIntersections(enX, enY, enH, c) + check "A: enemy line yields exactly two intersections", ipEnemy.len == 2 + let soEnemy = pickSelfOther(ipEnemy, enX, enY) + check "A: enemy's own point is an intersection (300,100)", + approx(soEnemy.self.x, 300.0) and approx(soEnemy.self.y, 100.0) + check "A: enemy's OTHER point is hand-computed (100,100)", + soEnemy.hasOther and approx(soEnemy.other.x, 100.0) and + approx(soEnemy.other.y, 100.0) + +# ── hand-computed case B: headings not along the diameter ──────────────────── + +proc testCaseB() = + let selfX = 0.0; let selfY = 0.0; let selfH = 45.0 + let enX = 100.0; let enY = 0.0; let enH = 0.0 + let c = makeDiameterCircle(selfX, selfY, enX, enY) + check "B: centre (50,0) r=50", approx(c.cx, 50.0) and approx(c.r, 50.0) + check "B: both bots on the circle", + onCircle(GeomPoint(x: selfX, y: selfY), c) and + onCircle(GeomPoint(x: enX, y: enY), c) + + let ipSelf = lineCircleIntersections(selfX, selfY, selfH, c) + check "B: our line yields exactly two intersections", ipSelf.len == 2 + let soSelf = pickSelfOther(ipSelf, selfX, selfY) + check "B: our own point is (0,0)", + approx(soSelf.self.x, 0.0) and approx(soSelf.self.y, 0.0) + check "B: our OTHER point is hand-computed (50,50)", + soSelf.hasOther and approx(soSelf.other.x, 50.0) and + approx(soSelf.other.y, 50.0) + check "B: the other point lies on BOTH the line and the circle", + crossOnLine(selfX, selfY, selfH, soSelf.other.x, soSelf.other.y) < 1e-6 and + onCircle(soSelf.other, c) + + let ipEnemy = lineCircleIntersections(enX, enY, enH, c) + check "B: enemy line yields exactly two intersections", ipEnemy.len == 2 + let soEnemy = pickSelfOther(ipEnemy, enX, enY) + check "B: enemy's other point is (0,0)", + soEnemy.hasOther and approx(soEnemy.other.x, 0.0) and + approx(soEnemy.other.y, 0.0) + check "B: enemy's other point lies on BOTH line and circle", + crossOnLine(enX, enY, enH, soEnemy.other.x, soEnemy.other.y) < 1e-6 and + onCircle(soEnemy.other, c) + +# ── case C: tangent line (two points coincide) ─────────────────────────────── + +proc testCaseC() = + let selfX = 0.0; let selfY = 0.0; let selfH = 90.0 + let enX = 100.0; let enY = 0.0; let enH = 0.0 + let c = makeDiameterCircle(selfX, selfY, enX, enY) + let ipSelf = lineCircleIntersections(selfX, selfY, selfH, c) + check "C: tangent line still gives two (coincident) points", ipSelf.len == 2 + check "C: both tangent points are the bot itself", + approx(ipSelf[0].x, 0.0) and approx(ipSelf[0].y, 0.0) and + approx(ipSelf[1].x, 0.0) and approx(ipSelf[1].y, 0.0) + +# ── degenerate case: bots coincide ─────────────────────────────────────────── + +proc testDegenerate() = + let prims = collectGeoPrims(50.0, 50.0, 0.0, 50.0, 50.0, 180.0) + check "coincident bots: nothing drawn (no divide by zero)", prims.len == 0 + +# ── primitive collection + emission ────────────────────────────────────────── + +proc testPrimsAndEmit() = + let prims = collectGeoPrims(100.0, 100.0, 0.0, 300.0, 100.0, 180.0) + check "non-degenerate case draws 1 circle + 2 lines + 4 marks = 7", + prims.len == 7 + var circles, lines, selfMarks, otherMarks = 0 + for p in prims: + case p.kind + of gpCircle: inc circles + of gpLineSelf, gpLineEnemy: inc lines + of gpMarkSelf: inc selfMarks + of gpMarkOther: inc otherMarks + check "primitive mix is (1 circle, 2 lines, 2 self marks, 2 other marks)", + circles == 1 and lines == 2 and selfMarks == 2 and otherMarks == 2 + check "self and other marks use distinct colours", + GeoSelfMarkColor != GeoOtherMarkColor + + clearGraphics() + let n = drawGeoOverlay(100.0, 100.0, 0.0, 300.0, 100.0, 180.0) + let svg = svgOutput() + check "drawGeoOverlay returns the primitive count (7)", n == 7 + check "SVG carries 2 line segments", svg.count("") + +testEnvSwitches() +testCaseA() +testCaseB() +testCaseC() +testDegenerate() +testPrimsAndEmit() + +if failures > 0: + echo "\n", failures, " check(s) FAILED" + quit(1) +echo "\nAll geo-overlay checks passed."