From 10473fc211f90a0453400200f4104dcd2f4e7d5a Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sat, 26 Sep 2026 15:14:48 +0200 Subject: [PATCH] j138 draw-only: yellow triangle (enemy-heading other point, enemy, us) in the geometry overlay --- ModularBot_garage/src/geo_overlay.nim | 74 +++++++++- ModularBot_garage/tests/test_geo_overlay.nim | 140 +++++++++++++++++-- 2 files changed, 196 insertions(+), 18 deletions(-) diff --git a/ModularBot_garage/src/geo_overlay.nim b/ModularBot_garage/src/geo_overlay.nim index 86cfd0b..4a682c6 100644 --- a/ModularBot_garage/src/geo_overlay.nim +++ b/ModularBot_garage/src/geo_overlay.nim @@ -12,7 +12,15 @@ ## 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. +## what the maths gives; +## * the TRIANGLE whose corners are the ENEMY heading line's OTHER +## intersection, the enemy, and our bot — three vertices the rest of the +## overlay already computes. Drawn YELLOW (#FFFF00), stroke width 1.5, +## with a small yellow hollow ring (r=6) on each distinct corner, so it is +## not confused with the grey circle / cyan / orange heading lines. A +## zero-length edge (a tangent enemy heading puts corner 1 on top of +## corner 2; or corner 1 on top of corner 3 when the enemy heading points +## straight at us) is SKIPPED rather than emitted as a degenerate line. ## ## Env knobs (read once at boot by ModularBot, registered in env_report.nim): ## TR_GEO_DEBUG presence/1 = on. default OFF. @@ -98,11 +106,13 @@ proc lineCircleIntersections*(px, py, headingDeg: float, 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) + 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) + gpTriangleEdge ## one edge of the enemy-heading triangle + gpTriangleCorner ## a DISTINCT corner of that triangle (hollow ring) GeoPrim* = object kind*: GeoPrimKind @@ -117,11 +127,17 @@ const GeoEnemyLineColor* = "#FF8000" # orange— ENEMY heading line GeoSelfMarkColor* = "#FFFFFF" # white — the bot itself (hollow ring) GeoOtherMarkColor* = "#FF00FF" # magenta — the OTHER intersection (filled) + GeoTriangleColor* = "#FFFF00" # yellow — the requested triangle (edges+corner rings) GeoCircleWidth* = 1.0 GeoLineWidth* = 1.0 GeoMarkWidth* = 1.5 + GeoTriangleWidth* = 1.5 GeoSelfMarkRadius* = 4.0 GeoOtherMarkRadius* = 4.0 + GeoTriangleCornerRadius* = 6.0 + GeoZeroLen* = 1e-6 + ## Below this two triangle corners are the SAME point: the edge between + ## them is degenerate (skipped) and they share a single corner ring. GeoLineMargin* = 10.0 ## Extra length (px) the heading segment extends beyond the far ## intersection; a segment of half-length 2r + margin always contains both @@ -157,6 +173,36 @@ proc headingSegment(px, py, headingDeg, halfLen: float): tuple[x1, y1, x2, y2: f let dy = sin(h) * halfLen (px - dx, py - dy, px + dx, py + dy) +proc samePoint(a, b: GeomPoint): bool {.inline.} = + hypot(b.x - a.x, b.y - a.y) < GeoZeroLen + +proc trianglePrims*(enemyOther, enemyPos, selfPos: GeomPoint): seq[GeoPrim] = + ## DRAW-ONLY: the owner-requested triangle whose three corners are, in order, + ## the ENEMY heading line's OTHER intersection with the diameter circle, the + ## enemy itself, and our bot. Returns its three edges plus one hollow corner + ## ring per DISTINCT corner, all in `GeoTriangleColor` (yellow, width 1.5). + ## + ## Degenerate handling, no new geometry is derived: + ## * a zero-length edge is SKIPPED instead of drawn as a degenerate line. + ## This happens when the enemy heading is TANGENT to the circle (corner 1 + ## coincides with corner 2) or points straight at us (corner 1 coincides + ## with corner 3); + ## * coincident corners share a single ring, so no zero-radius garbage. + let vs = [enemyOther, enemyPos, selfPos] + for i in 0 ..< 3: + let a = vs[i] + let b = vs[(i + 1) mod 3] + if samePoint(a, b): continue + result.add GeoPrim(kind: gpTriangleEdge, x1: a.x, y1: a.y, x2: b.x, y2: b.y, + color: GeoTriangleColor) + for i in 0 ..< 3: + var dup = false + for j in 0 ..< i: + if samePoint(vs[i], vs[j]): dup = true; break + if dup: continue + result.add GeoPrim(kind: gpTriangleCorner, cx: vs[i].x, cy: vs[i].y, + r: GeoTriangleCornerRadius, color: GeoTriangleColor) + proc collectGeoPrims*(selfX, selfY, selfHeading, enemyX, enemyY, enemyHeading: float): seq[GeoPrim] = ## Pure: the primitives the overlay draws. Empty in the degenerate case where @@ -194,6 +240,14 @@ proc collectGeoPrims*(selfX, selfY, selfHeading, cy: soEnemy.other.y, r: GeoOtherMarkRadius, color: GeoOtherMarkColor) + # The owner-requested TRIANGLE: the enemy heading's OTHER intersection, + # the enemy, and our bot. All three corners were already computed just + # above — nothing is re-derived here. + for p in trianglePrims(soEnemy.other, + GeomPoint(x: enemyX, y: enemyY), + GeomPoint(x: selfX, y: selfY)): + result.add p + # ── emit to the GUI debug-graphics buffer ──────────────────────────────────── proc emitGeoPrims*(prims: seq[GeoPrim]): int = @@ -215,6 +269,14 @@ proc emitGeoPrims*(prims: seq[GeoPrim]): int = of gpMarkOther: setFillColor(p.color) fillCircle(p.cx, p.cy, p.r) + of gpTriangleEdge: + setStrokeColor(p.color) + setStrokeWidth(GeoTriangleWidth) + drawLine(p.x1, p.y1, p.x2, p.y2) + of gpTriangleCorner: + setStrokeColor(p.color) + setStrokeWidth(GeoTriangleWidth) + drawCircle(p.cx, p.cy, p.r) result = prims.len proc drawGeoOverlay*(selfX, selfY, selfHeading, diff --git a/ModularBot_garage/tests/test_geo_overlay.nim b/ModularBot_garage/tests/test_geo_overlay.nim index d8b5e75..78eb581 100644 --- a/ModularBot_garage/tests/test_geo_overlay.nim +++ b/ModularBot_garage/tests/test_geo_overlay.nim @@ -2,9 +2,9 @@ ## ## 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). +## the hand-computed "other" points, and the owner-requested triangle whose +## corners are the enemy-heading OTHER point, the enemy and our bot. Also pins +## the two env switches and the SVG emission. ## ## Run: nim c -r ModularBot_garage/tests/test_geo_overlay.nim @@ -135,31 +135,144 @@ 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 +# ── the owner-requested triangle ───────────────────────────────────────────── + +proc triEdges(prims: seq[GeoPrim]): seq[GeoPrim] = + for p in prims: + if p.kind == gpTriangleEdge: result.add p + +proc triRings(prims: seq[GeoPrim]): seq[GeoPrim] = + for p in prims: + if p.kind == gpTriangleCorner: result.add p + +proc edgeLen(p: GeoPrim): float = hypot(p.x2 - p.x1, p.y2 - p.y1) + +proc hasVertex(prims: seq[GeoPrim], x, y: float): bool = + ## The closed edge loop covers every corner, so scan the edge endpoints. + for p in triEdges(prims): + if (approx(p.x1, x) and approx(p.y1, y)) or + (approx(p.x2, x) and approx(p.y2, y)): return true + false + +proc testTriangleCaseB() = + ## The EXISTING case: self (0,0) heading 45, enemy (100,0) heading 0. + ## The enemy heading (east) lies ALONG the diameter, so the enemy heading's + ## OTHER intersection is hand-computed to be our own bot (0,0). The triangle + ## is therefore FLAT: corner 1 == corner 3. + let selfX = 0.0; let selfY = 0.0 + let enX = 100.0; let enY = 0.0; let enH = 0.0 + let c = makeDiameterCircle(selfX, selfY, enX, enY) + let soEnemy = pickSelfOther(lineCircleIntersections(enX, enY, enH, c), enX, enY) + check "T/B: enemy-heading other point is our bot (0,0)", + soEnemy.hasOther and approx(soEnemy.other.x, 0.0) and + approx(soEnemy.other.y, 0.0) + + let prims = trianglePrims(soEnemy.other, GeomPoint(x: enX, y: enY), + GeomPoint(x: selfX, y: selfY)) + check "T/B: triangle corners are (0,0), enemy (100,0), us (0,0)", + hasVertex(prims, 0.0, 0.0) and hasVertex(prims, 100.0, 0.0) + check "T/B: the collapsed triangle has 2 edges (v3->v1 skipped)", + triEdges(prims).len == 2 + check "T/B: coincident corners share one ring (2 not 3)", + triRings(prims).len == 2 + check "T/B: no zero-length edge is emitted", + (block: + var ok = true + for p in triEdges(prims): + if edgeLen(p) < GeoZeroLen: ok = false + ok) + check "T/B: all three corners lie ON the circle", + onCircle(soEnemy.other, c) and + onCircle(GeomPoint(x: enX, y: enY), c) and + onCircle(GeomPoint(x: selfX, y: selfY), c) + +proc testTriangleCaseD() = + ## Non-degenerate: self (0,0), enemy (100,0) heading 135. The enemy heading + ## line meets the circle again at the hand-computed (50,50). + let selfX = 0.0; let selfY = 0.0 + let enX = 100.0; let enY = 0.0; let enH = 135.0 + let c = makeDiameterCircle(selfX, selfY, enX, enY) + let soEnemy = pickSelfOther(lineCircleIntersections(enX, enY, enH, c), enX, enY) + check "T/D: enemy-heading other point is hand-computed (50,50)", + soEnemy.hasOther and approx(soEnemy.other.x, 50.0, 1e-5) and + approx(soEnemy.other.y, 50.0, 1e-5) + + let prims = trianglePrims(soEnemy.other, GeomPoint(x: enX, y: enY), + GeomPoint(x: selfX, y: selfY)) + check "T/D: triangle corners are (50,50), enemy (100,0), us (0,0)", + hasVertex(prims, 50.0, 50.0) and hasVertex(prims, 100.0, 0.0) and + hasVertex(prims, 0.0, 0.0) + check "T/D: 3 edges and 3 distinct corner rings", + triEdges(prims).len == 3 and triRings(prims).len == 3 + check "T/D: every edge has non-zero length", + (block: + var ok = true + for p in triEdges(prims): + if edgeLen(p) < GeoZeroLen: ok = false + ok) + check "T/D: all three corners lie ON the circle", + onCircle(soEnemy.other, c) and + onCircle(GeomPoint(x: enX, y: enY), c) and + onCircle(GeomPoint(x: selfX, y: selfY), c) + check "T/D: triangle colour differs from circle / heading lines / marks", + GeoTriangleColor != GeoCircleColor and + GeoTriangleColor != GeoSelfLineColor and + GeoTriangleColor != GeoEnemyLineColor and + GeoTriangleColor != GeoOtherMarkColor + +proc testTriangleTangent() = + ## TANGENT: enemy (100,0) heading 90 (north) — the vertical line x=100 is + ## tangent to the circle at the enemy, so corner 1 coincides with corner 2. + ## The zero-length edge must be skipped, not emitted. + let selfX = 0.0; let selfY = 0.0 + let enX = 100.0; let enY = 0.0; let enH = 90.0 + let c = makeDiameterCircle(selfX, selfY, enX, enY) + let soEnemy = pickSelfOther(lineCircleIntersections(enX, enY, enH, c), enX, enY) + check "T/tangent: other point coincides with the enemy (100,0)", + soEnemy.hasOther and approx(soEnemy.other.x, 100.0) and + approx(soEnemy.other.y, 0.0) + let prims = trianglePrims(soEnemy.other, GeomPoint(x: enX, y: enY), + GeomPoint(x: selfX, y: selfY)) + check "T/tangent: zero-length edge SKIPPED (2 edges, not 3)", + triEdges(prims).len == 2 + check "T/tangent: no emitted edge has zero length", + (block: + var ok = true + for p in triEdges(prims): + if edgeLen(p) < GeoZeroLen: ok = false + ok) + check "T/tangent: coincident corners share one ring (2 not 3)", + triRings(prims).len == 2 + # ── 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 + check "non-degenerate case draws 1 circle + 2 lines + 4 marks + 4 triangle = 11", + prims.len == 11 + var circles, lines, selfMarks, otherMarks, triEdge, triCorner = 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 + of gpTriangleEdge: inc triEdge + of gpTriangleCorner: inc triCorner + check "primitive mix is (1 circle, 2 lines, 2 self, 2 other, 2 tri edge, 2 tri ring)", + circles == 1 and lines == 2 and selfMarks == 2 and otherMarks == 2 and + triEdge == 2 and triCorner == 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("") @@ -168,6 +281,9 @@ testCaseA() testCaseB() testCaseC() testDegenerate() +testTriangleCaseB() +testTriangleCaseD() +testTriangleTangent() testPrimsAndEmit() if failures > 0: