diff --git a/_bmad-output/implementation-artifacts/2-5-per-aircraft-drawing.md b/_bmad-output/implementation-artifacts/2-5-per-aircraft-drawing.md new file mode 100644 index 0000000..e223a83 --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-5-per-aircraft-drawing.md @@ -0,0 +1,123 @@ +# Story 2.5: Per-Aircraft Drawing (Arrow, Label, Trail, MLAT) + +Status: ready-for-dev + +## Story + +As a user looking at the display, +I want each aircraft drawn with a heading arrow, callsign/altitude label, a 5-dot position trail with the oldest dot smallest, and MLAT aircraft visually distinct, +So that I can read direction, identity, altitude, recent path, and data confidence at a glance. + +## Acceptance Criteria + +AC1: **Given** an `Aircraft` with `heading=90.0` (due east) **When** the heading arrow is drawn **Then** the arrow points east on the display, correctly rotated from north-up reference + +AC2: **Given** an `Aircraft` with `callsign="BAW1"` and `altitude_ft=28000` **When** the label is drawn **Then** callsign and altitude are rendered near the aircraft position **And** the label colour matches the aircraft's altitude colour band + +AC3: **Given** a trail `deque` with 3 entries **When** the trail is drawn **Then** 3 dots are rendered with decreasing size from most-recent to oldest (interpolated between `TRAIL_DOT_SIZE_MAX` and `TRAIL_DOT_SIZE_MIN`) **And** dot colour is `COLOUR_TRAIL` + +AC4: **Given** an `Aircraft` with `is_mlat=True` **When** the aircraft is drawn **Then** it is rendered in a visually distinct style (dashed/dotted outline instead of filled triangle) + +AC5: **Given** an `Aircraft` with `callsign=""` **When** the label is drawn **Then** altitude only is rendered with no blank callsign prefix, and no exception is raised + +## Tasks / Subtasks + +- [ ] Task 1: Implement drawing functions in `src/planemapper/renderer/aircraft.py` (AC: #1–#5) + - [ ] 1.1 Replace `# stub` with full implementation + - [ ] 1.2 Implement `_rotate_point(x, y, angle_deg) -> tuple[float, float]`: + ```python + import math + def _rotate_point(x, y, angle_deg): + r = math.radians(angle_deg) + return (x * math.cos(r) - y * math.sin(r), + x * math.sin(r) + y * math.cos(r)) + ``` + - [ ] 1.3 Implement `_draw_arrow(draw, cx, cy, heading, colour, is_mlat)`: + - Triangle with local coords: tip `(0, -12)`, base-left `(-6, 8)`, base-right `(6, 8)` + - Rotate all 3 points by `heading` degrees using `_rotate_point`, then translate by `(cx, cy)` + - Regular aircraft: `ImageDraw.polygon(points, fill=colour)` + - MLAT aircraft: `ImageDraw.polygon(points, fill=None, outline=colour)` + - [ ] 1.4 Implement `_draw_label(draw, cx, cy, aircraft, colour)`: + - If `aircraft.callsign` is non-empty: text = `f"{aircraft.callsign}\n{aircraft.altitude_ft}ft"` + - If `aircraft.callsign` is empty: text = `f"{aircraft.altitude_ft}ft"` + - Position: `(cx + 12, cy - 8)` + - Font: `ImageFont.load_default()` + - Colour: `altitude_to_colour(aircraft.altitude_ft)` + - [ ] 1.5 Implement `_draw_trail(draw, trail)`: + - Iterate the trail deque; `i=0` is most recent + - Size formula: `size = TRAIL_DOT_SIZE_MAX - i * (TRAIL_DOT_SIZE_MAX - TRAIL_DOT_SIZE_MIN) / max(len(trail) - 1, 1)` + - Draw `ImageDraw.ellipse` centred at trail point with half-width/height `size // 2` + - Colour: `COLOUR_TRAIL` + - [ ] 1.6 Implement `draw_aircraft(image, aircraft, pos, trail) -> None`: + - Create `ImageDraw.Draw(image)` + - Resolve colour via `altitude_to_colour(aircraft.altitude_ft)` + - Unpack `pos` as `(cx, cy)` + - Call `_draw_trail(draw, trail)` + - Call `_draw_arrow(draw, cx, cy, aircraft.heading or 0.0, colour, aircraft.is_mlat)` + - Call `_draw_label(draw, cx, cy, aircraft, colour)` + +- [ ] Task 2: Write tests in `tests/test_aircraft_draw.py` (AC: #1–#5) + - [ ] 2.1 Test AC1: create white 800×480 RGB image, call `draw_aircraft` with `heading=90.0`; assert pixel at `(cx+12, cy)` is not white (arrow painted over white background) + - [ ] 2.2 Test AC2: call `draw_aircraft` with `callsign="BAW1"`, `altitude_ft=28000`; assert no exception, return value is `None` + - [ ] 2.3 Test AC3: call `draw_aircraft` with a `deque` of 3 `(x, y)` trail points; assert no exception raised + - [ ] 2.4 Test AC4: call `draw_aircraft` with `is_mlat=True`; assert no exception raised + - [ ] 2.5 Test AC5: call `draw_aircraft` with `callsign=""`; assert no exception raised + +- [ ] Task 3: Run quality gates + - [ ] 3.1 `python -m pytest tests/` — all tests pass + - [ ] 3.2 `python -m ruff check .` — zero violations + - [ ] 3.3 `python -m ruff format --check .` — no formatting issues + +## Implementation Notes + +### Required imports for `aircraft.py` + +```python +import collections +import math + +from PIL import Image, ImageDraw, ImageFont + +from planemapper.models import Aircraft +from planemapper.constants import COLOUR_TRAIL, TRAIL_DOT_SIZE_MAX, TRAIL_DOT_SIZE_MIN +from planemapper.renderer.colours import altitude_to_colour +``` + +### Arrow geometry detail + +The arrow is drawn in "north-up" local coordinates before rotation. The tip points north (negative y) and the base is south (positive y): + +- Tip: `(0, -12)` +- Base left: `(-6, 8)` +- Base right: `(6, 8)` + +The rotation matrix for clockwise-from-north bearing (matching compass/ADS-B heading convention) is the standard 2D rotation — no sign inversion required. A `heading=0` leaves the triangle pointing up (north). A `heading=90` rotates the tip to point right (east). + +### Trail dot size interpolation + +With `i=0` (most recent): +- `size = TRAIL_DOT_SIZE_MAX` (largest) + +With `i = len(trail) - 1` (oldest): +- `size = TRAIL_DOT_SIZE_MIN` (smallest) + +The formula `size = TRAIL_DOT_SIZE_MAX - i * (TRAIL_DOT_SIZE_MAX - TRAIL_DOT_SIZE_MIN) / max(len(trail) - 1, 1)` handles the edge case of a single-point trail via `max(..., 1)`. + +### MLAT visual distinction + +MLAT aircraft use `fill=None, outline=colour` in `polygon()`, producing a hollow/outlined triangle rather than a filled one. This signals lower positional accuracy to the viewer without requiring a separate icon asset. + +### Label callsign guard + +Guard with `if aircraft.callsign:` (falsy check covers both `None` and `""`). Do not use `is not None` alone as an empty string should also suppress the callsign line. + +### Constants required in `src/planemapper/constants.py` + +Ensure the following are present (add if missing): + +```python +TRAIL_MAX_DOTS = 5 +TRAIL_DOT_SIZE_MAX = 6 +TRAIL_DOT_SIZE_MIN = 2 +COLOUR_TRAIL = COLOUR_BLACK +``` diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 1959d14..1d45d3c 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-22 -last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, epic-2 in-progress +last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 ready-for-dev, epic-2 in-progress project: planeMapper project_key: NOKEY tracking_system: file-system @@ -57,7 +57,7 @@ development_status: 2-2-coordinate-projection-and-base-map-loading: done 2-3-home-marker-and-airspace-outlines: done 2-4-altitude-colour-bands-and-aircraft-type-icons: done - 2-5-per-aircraft-drawing: backlog + 2-5-per-aircraft-drawing: ready-for-dev 2-6-stateful-renderer-and-display-interface: backlog 2-7-operational-radar-loop-startup-screen-and-systemd-wiring: backlog epic-2-retrospective: optional