From e8ce0602a4eacaa3e7d7e987c1c0f2bea9253f80 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 22 Apr 2026 23:16:03 -0400 Subject: [PATCH] feat(story-2.3): implement home marker and airspace outline rendering Adds draw_home_marker() in overlay.py (red cross at projected home position) and draw_airspace() in airspace.py (GeoJSON Polygon boundaries in blue, graceful FileNotFoundError handling). Includes airspace fixture and three tests covering all acceptance criteria. All 70 tests pass; ruff check and format clean. Co-Authored-By: Claude Sonnet 4.6 --- .../2-3-home-marker-and-airspace-outlines.md | 52 +++++++++--------- .../sprint-status.yaml | 2 +- src/planemapper/renderer/airspace.py | 31 ++++++++++- src/planemapper/renderer/overlay.py | 13 +++++ tests/fixtures/airspace_sample.geojson | 22 +++++++- tests/test_airspace.py | 53 +++++++++++++++++++ 6 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 src/planemapper/renderer/overlay.py create mode 100644 tests/test_airspace.py diff --git a/_bmad-output/implementation-artifacts/2-3-home-marker-and-airspace-outlines.md b/_bmad-output/implementation-artifacts/2-3-home-marker-and-airspace-outlines.md index e2e6f6f..146a80c 100644 --- a/_bmad-output/implementation-artifacts/2-3-home-marker-and-airspace-outlines.md +++ b/_bmad-output/implementation-artifacts/2-3-home-marker-and-airspace-outlines.md @@ -1,6 +1,6 @@ # Story 2.3: Home Marker & Airspace Outlines -Status: ready-for-dev +Status: review ## Story @@ -18,35 +18,35 @@ AC3: **Given** `airspace.geojson` does not exist at `AIRSPACE_PATH` **When** air ## Tasks / Subtasks -- [ ] Task 1: Implement `draw_home_marker(image, bounds)` in `src/planemapper/renderer/overlay.py` (AC: #1) - - [ ] 1.1 New file; imports: `from PIL import Image, ImageDraw`; `from planemapper.constants import COLOUR_HOME_MARKER`; `from planemapper.renderer.projection import MapBounds, project` - - [ ] 1.2 Signature: `def draw_home_marker(image: Image.Image, bounds: MapBounds) -> None:` - - [ ] 1.3 Project `(bounds.home_lat, bounds.home_lon)` to `(cx, cy)` - - [ ] 1.4 Draw cross: horizontal line `(cx-10, cy)` to `(cx+10, cy)`, vertical line `(cx, cy-10)` to `(cx, cy+10)`, fill `COLOUR_HOME_MARKER`, width 3 - - [ ] 1.5 Use `ImageDraw.Draw(image).line(...)` +- [x] Task 1: Implement `draw_home_marker(image, bounds)` in `src/planemapper/renderer/overlay.py` (AC: #1) + - [x] 1.1 New file; imports: `from PIL import Image, ImageDraw`; `from planemapper.constants import COLOUR_HOME_MARKER`; `from planemapper.renderer.projection import MapBounds, project` + - [x] 1.2 Signature: `def draw_home_marker(image: Image.Image, bounds: MapBounds) -> None:` + - [x] 1.3 Project `(bounds.home_lat, bounds.home_lon)` to `(cx, cy)` + - [x] 1.4 Draw cross: horizontal line `(cx-10, cy)` to `(cx+10, cy)`, vertical line `(cx, cy-10)` to `(cx, cy+10)`, fill `COLOUR_HOME_MARKER`, width 3 + - [x] 1.5 Use `ImageDraw.Draw(image).line(...)` -- [ ] Task 2: Implement `draw_airspace(image, bounds)` in `src/planemapper/renderer/airspace.py` (AC: #2, #3) - - [ ] 2.1 Replace `# stub` with full implementation - - [ ] 2.2 Imports: `import json`, `import logging`; `from PIL import Image, ImageDraw`; `from planemapper.constants import AIRSPACE_PATH, COLOUR_AIRSPACE`; `from planemapper.renderer.projection import MapBounds, project` - - [ ] 2.3 `log = logging.getLogger(__name__)` - - [ ] 2.4 Try to open `AIRSPACE_PATH`; on `FileNotFoundError`: `log.warning("airspace.geojson not found")` and return - - [ ] 2.5 Parse JSON, iterate `data["features"]` - - [ ] 2.6 For each feature with `geometry["type"] == "Polygon"`: get `coords = feature["geometry"]["coordinates"][0]` - - [ ] 2.7 Reverse GeoJSON `[lon, lat]` → `(lat, lon)` at parse boundary: `points = [project(lat, lon, bounds) for lon, lat in coords]` - - [ ] 2.8 Draw with `ImageDraw.Draw(image).line(points, fill=COLOUR_AIRSPACE, width=2)`; skip features with fewer than 2 points +- [x] Task 2: Implement `draw_airspace(image, bounds)` in `src/planemapper/renderer/airspace.py` (AC: #2, #3) + - [x] 2.1 Replace `# stub` with full implementation + - [x] 2.2 Imports: `import json`, `import logging`; `from PIL import Image, ImageDraw`; `from planemapper.constants import AIRSPACE_PATH, COLOUR_AIRSPACE`; `from planemapper.renderer.projection import MapBounds, project` + - [x] 2.3 `log = logging.getLogger(__name__)` + - [x] 2.4 Try to open `AIRSPACE_PATH`; on `FileNotFoundError`: `log.warning("airspace.geojson not found")` and return + - [x] 2.5 Parse JSON, iterate `data["features"]` + - [x] 2.6 For each feature with `geometry["type"] == "Polygon"`: get `coords = feature["geometry"]["coordinates"][0]` + - [x] 2.7 Reverse GeoJSON `[lon, lat]` → `(lat, lon)` at parse boundary: `points = [project(lat, lon, bounds) for lon, lat in coords]` + - [x] 2.8 Draw with `ImageDraw.Draw(image).line(points, fill=COLOUR_AIRSPACE, width=2)`; skip features with fewer than 2 points -- [ ] Task 3: Update `tests/fixtures/airspace_sample.geojson` with a sample polygon (AC: #2) - - [ ] 3.1 Replace empty features list with one `Polygon` feature having 5 `[lon, lat]` coordinate pairs forming a closed ring near lat=53, lon=-6 +- [x] Task 3: Update `tests/fixtures/airspace_sample.geojson` with a sample polygon (AC: #2) + - [x] 3.1 Replace empty features list with one `Polygon` feature having 5 `[lon, lat]` coordinate pairs forming a closed ring near lat=53, lon=-6 -- [ ] Task 4: Write tests in `tests/test_airspace.py` (AC: #1, #2, #3) - - [ ] 4.1 Test AC1 (home marker): Create 800×480 white RGB image, create `MapBounds(53.0, -6.0, 100.0)`, call `draw_home_marker(image, bounds)`, assert pixel at (400, 240) is red - - [ ] 4.2 Test AC2 (airspace drawn): Monkeypatch `AIRSPACE_PATH` to `tests/fixtures/airspace_sample.geojson` path, create image, call `draw_airspace(image, bounds)`, assert no exception raised and function returns normally (drawing occurred without crash) - - [ ] 4.3 Test AC3 (missing geojson): Monkeypatch `AIRSPACE_PATH` to a nonexistent path, call `draw_airspace(image, bounds)`, assert no exception raised +- [x] Task 4: Write tests in `tests/test_airspace.py` (AC: #1, #2, #3) + - [x] 4.1 Test AC1 (home marker): Create 800×480 white RGB image, create `MapBounds(53.0, -6.0, 100.0)`, call `draw_home_marker(image, bounds)`, assert pixel at (400, 240) is red + - [x] 4.2 Test AC2 (airspace drawn): Monkeypatch `AIRSPACE_PATH` to `tests/fixtures/airspace_sample.geojson` path, create image, call `draw_airspace(image, bounds)`, assert no exception raised and function returns normally (drawing occurred without crash) + - [x] 4.3 Test AC3 (missing geojson): Monkeypatch `AIRSPACE_PATH` to a nonexistent path, call `draw_airspace(image, bounds)`, assert no exception raised -- [ ] Task 5: Run quality gates - - [ ] 5.1 `python -m pytest tests/` — all tests pass - - [ ] 5.2 `python -m ruff check .` — zero violations - - [ ] 5.3 `python -m ruff format --check .` — no formatting issues +- [x] Task 5: Run quality gates + - [x] 5.1 `python -m pytest tests/` — all tests pass + - [x] 5.2 `python -m ruff check .` — zero violations + - [x] 5.3 `python -m ruff format --check .` — no formatting issues ## Dev Notes diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 7f51cf3..754703a 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -55,7 +55,7 @@ development_status: epic-2: in-progress 2-1-aircraft-data-model-and-fetcher: done 2-2-coordinate-projection-and-base-map-loading: done - 2-3-home-marker-and-airspace-outlines: ready-for-dev + 2-3-home-marker-and-airspace-outlines: review 2-4-altitude-colour-bands-and-aircraft-type-icons: backlog 2-5-per-aircraft-drawing: backlog 2-6-stateful-renderer-and-display-interface: backlog diff --git a/src/planemapper/renderer/airspace.py b/src/planemapper/renderer/airspace.py index d352c7e..d5ac027 100644 --- a/src/planemapper/renderer/airspace.py +++ b/src/planemapper/renderer/airspace.py @@ -1 +1,30 @@ -# stub +from __future__ import annotations + +import json +import logging + +from PIL import Image, ImageDraw + +from planemapper.constants import AIRSPACE_PATH, COLOUR_AIRSPACE +from planemapper.renderer.projection import MapBounds, project + +log = logging.getLogger(__name__) + + +def draw_airspace(image: Image.Image, bounds: MapBounds) -> None: + try: + data = json.loads(AIRSPACE_PATH.read_text(encoding="utf-8")) + except FileNotFoundError: + log.warning("airspace.geojson not found at %s — skipping airspace overlay", AIRSPACE_PATH) + return + draw = ImageDraw.Draw(image) + for feature in data.get("features", []): + geom = feature.get("geometry", {}) + if geom.get("type") != "Polygon": + continue + coords = geom.get("coordinates", [[]])[0] + if len(coords) < 2: + continue + # GeoJSON is [lon, lat] — reverse at parse boundary + points = [project(lat, lon, bounds) for lon, lat in coords] + draw.line(points, fill=COLOUR_AIRSPACE, width=2) diff --git a/src/planemapper/renderer/overlay.py b/src/planemapper/renderer/overlay.py new file mode 100644 index 0000000..5a1fb37 --- /dev/null +++ b/src/planemapper/renderer/overlay.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from PIL import Image, ImageDraw + +from planemapper.constants import COLOUR_HOME_MARKER +from planemapper.renderer.projection import MapBounds, project + + +def draw_home_marker(image: Image.Image, bounds: MapBounds) -> None: + cx, cy = project(bounds.home_lat, bounds.home_lon, bounds) + draw = ImageDraw.Draw(image) + draw.line([(cx - 10, cy), (cx + 10, cy)], fill=COLOUR_HOME_MARKER, width=3) + draw.line([(cx, cy - 10), (cx, cy + 10)], fill=COLOUR_HOME_MARKER, width=3) diff --git a/tests/fixtures/airspace_sample.geojson b/tests/fixtures/airspace_sample.geojson index e2c5f1b..9043337 100644 --- a/tests/fixtures/airspace_sample.geojson +++ b/tests/fixtures/airspace_sample.geojson @@ -1 +1,21 @@ -{"type": "FeatureCollection", "features": []} +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-6.5, 53.3], + [-5.5, 53.3], + [-5.5, 53.7], + [-6.5, 53.7], + [-6.5, 53.3] + ] + ] + }, + "properties": {"name": "Test Airspace"} + } + ] +} diff --git a/tests/test_airspace.py b/tests/test_airspace.py new file mode 100644 index 0000000..3f25564 --- /dev/null +++ b/tests/test_airspace.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pathlib + +import pytest +from PIL import Image + +from planemapper.renderer.airspace import draw_airspace +from planemapper.renderer.overlay import draw_home_marker +from planemapper.renderer.projection import MapBounds + +FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures" + + +@pytest.fixture +def white_image() -> Image.Image: + return Image.new("RGB", (800, 480), color=(255, 255, 255)) + + +@pytest.fixture +def bounds() -> MapBounds: + return MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0) + + +def test_home_marker_draws_red_cross(white_image: Image.Image, bounds: MapBounds) -> None: + draw_home_marker(white_image, bounds) + # Centre pixel should be red (COLOUR_HOME_MARKER) + assert white_image.getpixel((400, 240)) == (255, 0, 0) + + +def test_airspace_drawn_without_exception( + white_image: Image.Image, + bounds: MapBounds, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "planemapper.renderer.airspace.AIRSPACE_PATH", + FIXTURE_DIR / "airspace_sample.geojson", + ) + draw_airspace(white_image, bounds) # should not raise + + +def test_airspace_missing_file_no_exception( + white_image: Image.Image, + bounds: MapBounds, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "planemapper.renderer.airspace.AIRSPACE_PATH", + tmp_path / "nonexistent.geojson", + ) + draw_airspace(white_image, bounds) # should not raise — logs WARNING instead