Files
planeMapper/_bmad-output/implementation-artifacts/2-5-per-aircraft-drawing.md
T
Matt Edholm e2e4e885d1 bmad: create story 2-5 (per-aircraft drawing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:25:21 -04:00

124 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```