diff --git a/README.md b/README.md index c845403..400fc0b 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ pixel explosions and a spinning radar. Runs with GitHub Actions. **Lincoln — default carrier** - - A pixel jet launches from a Lincoln-inspired carrier through rolling ocean water, with an angled runway, detailed island and spinning radar. + + A pixel jet launches from a Lincoln-inspired carrier through rolling ocean water, with an angled runway, detailed island and spinning radar. **Classic — original carrier** - - The original ivory-island carrier with a spinning radar and flowing ocean wake launches a pixel jet to clear the commit calendar. + + The original ivory-island carrier with a spinning radar and flowing ocean wake launches a pixel jet to clear the commit calendar. Choose `carrier: 'lincoln'` (default) or `carrier: 'classic'` in your workflow. @@ -26,7 +26,7 @@ Choose `water: 'ocean'` (default) for the flowing ocean surface and wake, or `water: 'original'` for the original small wave patches. This works with either carrier. For the CLI, use `--water ocean` or `--water original`. -Both demos use **56 synthetic commits across 14 active days**. Live mode uses your +Both demos show **three jets** and **56 synthetic commits across 14 active days**. Live mode uses your actual data. **One attack clears one day cell**, regardless of that day's count. The jet returns to land, the calendar rebuilds, and the animation loops. @@ -55,8 +55,11 @@ smooth curves at a steady cruise speed and fires while moving. The default is Set `jets: '2'` or `jets: '3'` to launch multiple jets and clear busy calendars faster. The default is `jets: '1'`. Jets share the day cells, so each active day -is still attacked exactly once. Launches are staggered and the calendar resets -after every jet lands. Small calendars may take a similar time because launch +is still attacked exactly once. Jets line up visibly on the runway, launch one +at a time and land in order, returning to their original runway spots. Waiting +jets remain visible before takeoff and after landing. Launch timing keeps the +runway clear for each arrival. The calendar resets after every jet lands. +Small calendars may take a similar time because launch and return flights still need to finish. For the CLI, use `--jets 3`. GIFs over **25 MB** are automatically retried at a lower resolution, then lower diff --git a/examples/demo-classic/git-jet.gif b/examples/demo-classic/git-jet.gif index 379784b..da18bd6 100644 Binary files a/examples/demo-classic/git-jet.gif and b/examples/demo-classic/git-jet.gif differ diff --git a/examples/demo-classic/git-jet.png b/examples/demo-classic/git-jet.png index 7d8e3e8..45df11a 100644 Binary files a/examples/demo-classic/git-jet.png and b/examples/demo-classic/git-jet.png differ diff --git a/examples/demo-classic/report.json b/examples/demo-classic/report.json index 0bf45b9..f0360b3 100644 --- a/examples/demo-classic/report.json +++ b/examples/demo-classic/report.json @@ -9,7 +9,7 @@ "active_days": 14, "attacks": 14, "attack_mode": "day-cell", - "jets": 1, + "jets": 3, "carrier": "classic", "water": "ocean", "seed": "git-jet-loop", @@ -17,17 +17,39 @@ { "jet": 1, "seed": "git-jet-loop", - "attacks": 14, + "attacks": 5, "launch_frame": 8, - "landed_frame": 480 + "landed_frame": 464 + }, + { + "jet": 2, + "seed": "c00aa13156cdeac3fb98d0a11caf2c029fe45633b0fc5c66151bd6697d2bd51b", + "attacks": 5, + "launch_frame": 72, + "landed_frame": 533 + }, + { + "jet": 3, + "seed": "cfeb2707ba3fdf21e2c2ba11b9914dc444d464190efe3c955fe1f6578640abfd", + "attacks": 4, + "launch_frame": 149, + "landed_frame": 562 } ], "maneuvers": [ { "kind": "loop", "direction": -1, - "start_frame": 244, - "end_frame": 289 + "start_frame": 209, + "end_frame": 253, + "jet": 1 + }, + { + "kind": "loop", + "direction": -1, + "start_frame": 285, + "end_frame": 331, + "jet": 2 } ], "width": 960, @@ -36,12 +58,12 @@ "requested_scale": 2, "requested_fps": 20, "auto_compressed": false, - "frames": 560, + "frames": 640, "fps": 20, - "duration_seconds": 28.0, + "duration_seconds": 32.0, "transparent": true, "decoded_frames_match": true, - "gif_bytes": 14334512, + "gif_bytes": 16526574, "targets": [ { "date": "2026-10-18", @@ -49,125 +71,125 @@ "commits": 6, "column": 50, "row": 0, - "fire_frame": 189, - "hit_frame": 196 + "fire_frame": 154, + "hit_frame": 161 }, { - "date": "2026-10-30", + "date": "2026-08-03", "jet": 1, - "commits": 7, - "column": 51, - "row": 5, - "fire_frame": 197, - "hit_frame": 203 + "commits": 3, + "column": 39, + "row": 1, + "fire_frame": 181, + "hit_frame": 187 }, { - "date": "2026-09-30", + "date": "2026-05-10", "jet": 1, - "commits": 5, - "column": 47, - "row": 3, - "fire_frame": 201, - "hit_frame": 207 + "commits": 7, + "column": 27, + "row": 0, + "fire_frame": 236, + "hit_frame": 242 + }, + { + "date": "2026-10-30", + "jet": 2, + "commits": 7, + "column": 51, + "row": 5, + "fire_frame": 240, + "hit_frame": 246 }, { "date": "2026-09-01", - "jet": 1, + "jet": 2, "commits": 4, "column": 43, "row": 2, - "fire_frame": 208, - "hit_frame": 214 + "fire_frame": 253, + "hit_frame": 259 }, { - "date": "2026-08-03", - "jet": 1, - "commits": 3, - "column": 39, - "row": 1, - "fire_frame": 216, - "hit_frame": 222 - }, - { - "date": "2026-07-02", + "date": "2026-02-04", "jet": 1, - "commits": 2, - "column": 34, - "row": 4, - "fire_frame": 222, - "hit_frame": 227 + "commits": 4, + "column": 13, + "row": 3, + "fire_frame": 266, + "hit_frame": 272 }, { "date": "2026-04-12", - "jet": 1, + "jet": 3, "commits": 6, "column": 23, "row": 0, - "fire_frame": 241, - "hit_frame": 248 + "fire_frame": 271, + "hit_frame": 278 }, { - "date": "2026-03-16", + "date": "2025-11-16", "jet": 1, + "commits": 1, + "column": 2, + "row": 0, + "fire_frame": 287, + "hit_frame": 294 + }, + { + "date": "2026-03-16", + "jet": 2, "commits": 5, "column": 19, "row": 1, - "fire_frame": 247, - "hit_frame": 253 - }, - { - "date": "2026-05-10", - "jet": 1, - "commits": 7, - "column": 27, - "row": 0, - "fire_frame": 271, - "hit_frame": 277 + "fire_frame": 291, + "hit_frame": 297 }, { "date": "2026-06-03", - "jet": 1, + "jet": 2, "commits": 1, "column": 30, "row": 3, - "fire_frame": 277, - "hit_frame": 283 + "fire_frame": 319, + "hit_frame": 325 }, { - "date": "2026-02-04", - "jet": 1, - "commits": 4, - "column": 13, + "date": "2026-09-30", + "jet": 3, + "commits": 5, + "column": 47, "row": 3, - "fire_frame": 301, - "hit_frame": 307 + "fire_frame": 337, + "hit_frame": 343 }, { - "date": "2026-01-13", - "jet": 1, - "commits": 3, - "column": 10, - "row": 2, - "fire_frame": 307, - "hit_frame": 313 + "date": "2026-07-02", + "jet": 3, + "commits": 2, + "column": 34, + "row": 4, + "fire_frame": 358, + "hit_frame": 364 }, { "date": "2025-12-16", - "jet": 1, + "jet": 2, "commits": 2, "column": 6, "row": 2, - "fire_frame": 313, - "hit_frame": 319 + "fire_frame": 360, + "hit_frame": 366 }, { - "date": "2025-11-16", - "jet": 1, - "commits": 1, - "column": 2, - "row": 0, - "fire_frame": 322, - "hit_frame": 329 + "date": "2026-01-13", + "jet": 3, + "commits": 3, + "column": 10, + "row": 2, + "fire_frame": 398, + "hit_frame": 404 } ] } diff --git a/examples/demo/git-jet.gif b/examples/demo/git-jet.gif index 98534dd..49346b3 100644 Binary files a/examples/demo/git-jet.gif and b/examples/demo/git-jet.gif differ diff --git a/examples/demo/git-jet.png b/examples/demo/git-jet.png index 84fbeac..9124a39 100644 Binary files a/examples/demo/git-jet.png and b/examples/demo/git-jet.png differ diff --git a/examples/demo/report.json b/examples/demo/report.json index de400ac..6a6387e 100644 --- a/examples/demo/report.json +++ b/examples/demo/report.json @@ -9,7 +9,7 @@ "active_days": 14, "attacks": 14, "attack_mode": "day-cell", - "jets": 1, + "jets": 3, "carrier": "lincoln", "water": "ocean", "seed": "git-jet-loop", @@ -17,17 +17,39 @@ { "jet": 1, "seed": "git-jet-loop", - "attacks": 14, + "attacks": 5, "launch_frame": 8, "landed_frame": 405 + }, + { + "jet": 2, + "seed": "c00aa13156cdeac3fb98d0a11caf2c029fe45633b0fc5c66151bd6697d2bd51b", + "attacks": 5, + "launch_frame": 35, + "landed_frame": 435 + }, + { + "jet": 3, + "seed": "cfeb2707ba3fdf21e2c2ba11b9914dc444d464190efe3c955fe1f6578640abfd", + "attacks": 4, + "launch_frame": 101, + "landed_frame": 452 } ], "maneuvers": [ { "kind": "loop", "direction": -1, - "start_frame": 160, - "end_frame": 205 + "start_frame": 148, + "end_frame": 193, + "jet": 1 + }, + { + "kind": "loop", + "direction": -1, + "start_frame": 185, + "end_frame": 232, + "jet": 2 } ], "width": 960, @@ -41,7 +63,7 @@ "duration_seconds": 24.0, "transparent": true, "decoded_frames_match": true, - "gif_bytes": 13041689, + "gif_bytes": 13681523, "targets": [ { "date": "2026-10-18", @@ -49,89 +71,62 @@ "commits": 6, "column": 50, "row": 0, - "fire_frame": 106, - "hit_frame": 113 + "fire_frame": 94, + "hit_frame": 101 }, { - "date": "2026-10-30", + "date": "2026-08-03", "jet": 1, + "commits": 3, + "column": 39, + "row": 1, + "fire_frame": 120, + "hit_frame": 126 + }, + { + "date": "2026-10-30", + "jet": 2, "commits": 7, "column": 51, "row": 5, - "fire_frame": 113, - "hit_frame": 119 - }, - { - "date": "2026-09-30", - "jet": 1, - "commits": 5, - "column": 47, - "row": 3, - "fire_frame": 117, - "hit_frame": 123 + "fire_frame": 140, + "hit_frame": 146 }, { "date": "2026-09-01", - "jet": 1, + "jet": 2, "commits": 4, "column": 43, "row": 2, - "fire_frame": 124, - "hit_frame": 130 - }, - { - "date": "2026-08-03", - "jet": 1, - "commits": 3, - "column": 39, - "row": 1, - "fire_frame": 132, - "hit_frame": 138 - }, - { - "date": "2026-07-02", - "jet": 1, - "commits": 2, - "column": 34, - "row": 4, - "fire_frame": 138, - "hit_frame": 144 + "fire_frame": 153, + "hit_frame": 159 }, { "date": "2026-06-03", - "jet": 1, + "jet": 2, "commits": 1, "column": 30, "row": 3, - "fire_frame": 145, - "hit_frame": 151 + "fire_frame": 173, + "hit_frame": 179 }, { - "date": "2026-04-12", + "date": "2026-05-10", "jet": 1, - "commits": 6, - "column": 23, + "commits": 7, + "column": 27, "row": 0, - "fire_frame": 157, - "hit_frame": 164 + "fire_frame": 176, + "hit_frame": 182 }, { "date": "2026-03-16", - "jet": 1, + "jet": 2, "commits": 5, "column": 19, "row": 1, - "fire_frame": 164, - "hit_frame": 170 - }, - { - "date": "2026-05-10", - "jet": 1, - "commits": 7, - "column": 27, - "row": 0, - "fire_frame": 188, - "hit_frame": 194 + "fire_frame": 192, + "hit_frame": 198 }, { "date": "2026-02-04", @@ -139,35 +134,62 @@ "commits": 4, "column": 13, "row": 3, - "fire_frame": 217, - "hit_frame": 223 + "fire_frame": 205, + "hit_frame": 211 }, { - "date": "2026-01-13", + "date": "2025-11-16", "jet": 1, - "commits": 3, - "column": 10, - "row": 2, - "fire_frame": 223, - "hit_frame": 229 + "commits": 1, + "column": 2, + "row": 0, + "fire_frame": 226, + "hit_frame": 233 + }, + { + "date": "2026-09-30", + "jet": 3, + "commits": 5, + "column": 47, + "row": 3, + "fire_frame": 227, + "hit_frame": 233 + }, + { + "date": "2026-07-02", + "jet": 3, + "commits": 2, + "column": 34, + "row": 4, + "fire_frame": 247, + "hit_frame": 253 }, { "date": "2025-12-16", - "jet": 1, + "jet": 2, "commits": 2, "column": 6, "row": 2, - "fire_frame": 229, - "hit_frame": 235 + "fire_frame": 260, + "hit_frame": 266 }, { - "date": "2025-11-16", - "jet": 1, - "commits": 1, - "column": 2, + "date": "2026-01-13", + "jet": 3, + "commits": 3, + "column": 10, + "row": 2, + "fire_frame": 288, + "hit_frame": 294 + }, + { + "date": "2026-04-12", + "jet": 3, + "commits": 6, + "column": 23, "row": 0, - "fire_frame": 238, - "hit_frame": 245 + "fire_frame": 349, + "hit_frame": 356 } ] } diff --git a/scripts/preview_runway.py b/scripts/preview_runway.py new file mode 100644 index 0000000..0138be4 --- /dev/null +++ b/scripts/preview_runway.py @@ -0,0 +1,51 @@ +"""Render complete three-jet launch/landing loops and a scene contact sheet.""" + +from pathlib import Path + +from PIL import Image, ImageDraw + +from git_jet.calendar import Calendar +from git_jet.export import Options, render_assets +from git_jet.render import Renderer + + +def main(): + root = Path(__file__).resolve().parents[1] + calendar = Calendar.read(root / "examples/demo/calendar.json") + for carrier in ("lincoln", "classic"): + output = root / "out/runway-preview" / carrier + report = render_assets( + calendar, + output, + Options(), + jets=3, + carrier=carrier, + seed="git-jet-loop", + source="demo", + ) + renderer = Renderer( + calendar, fps=report["fps"], jets=3, carrier=carrier, seed=report["seed"] + ) + first, last = renderer.flights[0], renderer.flights[-1] + scenes = ( + ("Queued", 0), + ("First takeoff", first.launch_end), + ("All launched", last.launch_end), + ("First touchdown", first.touchdown), + ("First parked", first.landed), + ("All parked", renderer.flight.landed), + ) + sheet = Image.new("RGB", (1440, 640), "#0d1117") + for index, (label, frame) in enumerate(scenes): + panel = Image.new("RGBA", (480, 320), "#0d1117") + panel.alpha_composite(renderer.frame(frame)) + ImageDraw.Draw(panel).text( + (8, 8), f"{label} — {frame / report['fps']:.1f}s", fill="white" + ) + sheet.paste(panel.convert("RGB"), ((index % 3) * 480, (index // 3) * 320)) + sheet.save(output / "runway-contact-sheet.png") + print(f"{carrier}: {report['duration_seconds']}s, {report['gif_bytes']} bytes, {output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/render_demos.py b/scripts/render_demos.py index 6d1bf41..994404f 100644 --- a/scripts/render_demos.py +++ b/scripts/render_demos.py @@ -14,6 +14,7 @@ def main(): calendar, root / "examples" / folder, Options(), + jets=3, carrier=carrier, seed="git-jet-loop", source="demo", diff --git a/src/git_jet/art.py b/src/git_jet/art.py index e5f849a..99f4e96 100644 --- a/src/git_jet/art.py +++ b/src/git_jet/art.py @@ -29,7 +29,9 @@ def validate_carrier(carrier): def carrier_launch_points(carrier=DEFAULT_CARRIER): validate_carrier(carrier) if carrier == "classic": - return (224, 33, 28), (-8, 32, 28) + # Keep the 55-pixel landing roll on the deck, with room for a queue + # on either side of the central launch/parking position. + return (168, 33, 28), (-8, 32, 28) from .lincoln import flight_anchors return flight_anchors() diff --git a/src/git_jet/flight.py b/src/git_jet/flight.py index 10c3c1b..2f20ade 100644 --- a/src/git_jet/flight.py +++ b/src/git_jet/flight.py @@ -17,6 +17,7 @@ GRID_X, GRID_Y, GRID_STEP = 28, 237, 8 HEADING_STEPS = 64 CRUISE_SPEED = 100.0 # Logical pixels per second, independent of export FPS. +RUNWAY_SPACING = 32.0 def unit(vector): @@ -250,6 +251,7 @@ def __init__( delay=0, jet_id=0, carrier=DEFAULT_CARRIER, + deck_offset=0, ): self.fps = fps deck_point, bow_point = carrier_launch_points(carrier) @@ -261,8 +263,10 @@ def __init__( height = rng.uniform(68, 76) sx, sy = project(*deck_point) bx, by = project(*bow_point) - self.deck, self.bow = (65 + sx, 12 + sy), (65 + bx, 12 + by) + base = (65 + sx, 12 + sy) + self.bow = (65 + bx, 12 + by) self.runway = unit((bx - sx, by - sy)) + self.deck = tuple(p + v * deck_offset for p, v in zip(base, self.runway, strict=True)) self.takeoff_heading = heading(self.runway) departure_turn = turn_points(self.runway, (1, 0)) factor = (top - self.bow[1]) / departure_turn[-1][1] @@ -297,18 +301,21 @@ def __init__( for lap in range(laps) for m in self.course.maneuvers ] - approach = tuple(p - v * 55 for p, v in zip(self.deck, self.runway, strict=True)) - corner = turn_points((1, 0), (0, -1)) - factor = -(top - 137) / corner[-1][1] - corner_x = 442 - (top - 137) - corner = [(corner_x + x * factor, top + y * factor) for x, y in corner] - first_return = ArcPath([entry, (corner_x, top), *corner[1:]]) + approach = tuple(p - v * 55 for p, v in zip(base, self.runway, strict=True)) landing_turn = turn_points((0, -1), self.runway) factor = (approach[0] - 442) / landing_turn[-1][0] turn_start = (442, approach[1] - landing_turn[-1][1] * factor) landing_turn = [ (turn_start[0] + x * factor, turn_start[1] + y * factor) for x, y in landing_turn ] + # Keep travelling north between the two return turns when the Classic + # touchdown point moves onto the deck, rather than reversing direction. + corner_y = max(137, turn_start[1] + 5) + corner = turn_points((1, 0), (0, -1)) + factor = -(top - corner_y) / corner[-1][1] + corner_x = 442 - (top - corner_y) + corner = [(corner_x + x * factor, top + y * factor) for x, y in corner] + first_return = ArcPath([entry, (corner_x, top), *corner[1:]]) second_return = ArcPath([corner[-1], turn_start, *landing_turn[1:]]) self.returns = (first_return, second_return) self.return_frames = tuple(math.ceil(path.length / self.step) for path in self.returns) @@ -456,17 +463,30 @@ def __init__( if index == 0 else hashlib.sha256(f"{self.seed}:jet:{index}".encode()).hexdigest() ) - flight = Flight( - calendar, - fps, - child_seed, - targets=targets[index::jets], - delay=delay, - jet_id=index, - carrier=carrier, - ) + + def plan(start_delay, index=index, child_seed=child_seed): + return Flight( + calendar, + fps, + child_seed, + targets=targets[index::jets], + delay=start_delay, + jet_id=index, + carrier=carrier, + deck_offset=((jets - 1) / 2 - index) * RUNWAY_SPACING, + ) + + flight = plan(delay) + if self.flights: + # Reserve the runway in launch order. Waiting on deck keeps + # airborne routes continuous and lets each arrival stop behind + # the previous jet, in the same slot it occupied at the start. + earliest = self.flights[-1].landed + max(1, round(0.35 * fps)) + if flight.touchdown < earliest: + delay += earliest - flight.touchdown + flight = plan(delay) self.flights.append(flight) - # The next jet enters the catapult after the previous aircraft clears it. + # The next queued jet starts its roll after the runway is clear. delay = flight.launch_end + round(0.35 * fps) - round(0.4 * fps) self.strikes = sorted( (s for flight in self.flights for s in flight.strikes), key=lambda s: s.hit @@ -491,10 +511,4 @@ def restored(self, cell, frame): return frame >= self.reset_start + (cell.column / 53) * self.reset_frames def visible_poses(self, frame): - if frame >= self.landed: - return (self.flights[0].pose(0),) - return tuple( - flight.pose(frame) - for index, flight in enumerate(self.flights) - if (index == 0 or frame >= self.flights[index - 1].launch_end) and frame < flight.landed - ) + return tuple(flight.pose(frame) for flight in self.flights) diff --git a/tests/test_carriers.py b/tests/test_carriers.py index 63f6651..6395cb3 100644 --- a/tests/test_carriers.py +++ b/tests/test_carriers.py @@ -39,10 +39,13 @@ def test_lincoln_is_the_default_and_preserves_the_approved_body(assert_carrier_b def test_each_carrier_aligns_launch_and_landing_with_its_deck(carrier): deck, bow = art.carrier_launch_points(carrier) renderer = Renderer(demo_calendar(), jets=3, seed="carrier-deck", carrier=carrier) + sx, sy = art.project(*deck) + assert sum(f.deck[0] for f in renderer.flights) / 3 == pytest.approx(65 + sx) + assert sum(f.deck[1] for f in renderer.flights) / 3 == pytest.approx(12 + sy) for flight in renderer.flights: - sx, sy = art.project(*deck) bx, by = art.project(*bow) - assert flight.deck == (65 + sx, 12 + sy) + dx, dy = flight.deck[0] - (65 + sx), flight.deck[1] - (12 + sy) + assert dx * flight.runway[1] - dy * flight.runway[0] == pytest.approx(0) assert flight.bow == (65 + bx, 12 + by) assert (flight.pose(0).x, flight.pose(0).y) == flight.deck assert (flight.pose(flight.landed).x, flight.pose(flight.landed).y) == flight.deck diff --git a/tests/test_runway_queue.py b/tests/test_runway_queue.py new file mode 100644 index 0000000..e22c38e --- /dev/null +++ b/tests/test_runway_queue.py @@ -0,0 +1,86 @@ +import math + +import pytest +from PIL import Image, ImageChops, ImageDraw + +from git_jet.art import project +from git_jet.demo import demo_calendar +from git_jet.flight import HEIGHT, WIDTH +from git_jet.render import Renderer + + +@pytest.mark.parametrize("carrier", ("lincoln", "classic")) +@pytest.mark.parametrize("jets", (1, 2, 3)) +@pytest.mark.parametrize("fps", (5, 10, 20, 25)) +def test_jets_stay_visible_and_move_continuously_to_their_runway_slots(carrier, jets, fps): + renderer = Renderer(demo_calendar(), jets=jets, fps=fps, carrier=carrier, seed="runway-queue") + first = renderer.flight.visible_poses(0) + assert len(first) == jets + assert len({(p.x, p.y) for p in first}) == jets + previous = first + for frame in range(1, renderer.frame_count): + poses = renderer.flight.visible_poses(frame) + assert len(poses) == jets + for before, pose, flight in zip(previous, poses, renderer.flights, strict=True): + assert math.dist((before.x, before.y), (pose.x, pose.y)) * fps < 103 + if frame < flight.launch_start or frame >= flight.touchdown: + assert pose.heading == pytest.approx(flight.takeoff_heading) + assert pose.bank == 0 and pose.thrust == 0 + previous = poses + assert previous == first + + +@pytest.mark.parametrize("carrier", ("lincoln", "classic")) +@pytest.mark.parametrize("jets", (2, 3)) +def test_runway_is_clear_for_each_arrival_and_grounded_jets_do_not_overlap(carrier, jets): + renderer = Renderer(demo_calendar(), jets=jets, carrier=carrier, seed="runway-reservations") + for first, second in zip(renderer.flights, renderer.flights[1:], strict=False): + assert first.landed < second.touchdown + assert math.dist(first.deck, second.deck) >= 31 + for frame in range(renderer.frame_count): + occupied = Image.new("1", (WIDTH, HEIGHT)) + for flight in renderer.flights: + if flight.launch_end < frame < flight.touchdown: + continue + pose = flight.pose(frame) + sprite = renderer.jet_image(pose, frame) + mask = Image.new("1", (WIDTH, HEIGHT)) + half = sprite.width // 2 + mask.paste( + sprite.getchannel("A").convert("1"), + (round(pose.x) - half, round(pose.y) - half), + ) + assert ImageChops.logical_and(occupied, mask).getbbox() is None, frame + occupied = ImageChops.logical_or(occupied, mask) + + +@pytest.mark.parametrize("jets", (1, 2, 3)) +def test_classic_touchdown_and_parking_keep_the_whole_jet_on_deck(jets): + renderer = Renderer(demo_calendar(), jets=jets, carrier="classic", seed="classic-touchdown") + deck = Image.new("L", (WIDTH, HEIGHT)) + ImageDraw.Draw(deck).polygon( + [ + (65 + x, 12 + y) + for x, y in ( + project(u, v, 25) + for u, v in ( + (-18, 20), + (0, 0), + (248, 0), + (256, 12), + (256, 52), + (8, 52), + (-18, 36), + ) + ) + ], + fill=255, + ) + for flight in renderer.flights: + for frame in [0, *range(flight.touchdown, flight.landed + 1)]: + pose = flight.pose(frame) + sprite = renderer.jet_image(pose, frame) + mask = Image.new("L", (WIDTH, HEIGHT)) + half = sprite.width // 2 + mask.paste(sprite.getchannel("A"), (round(pose.x) - half, round(pose.y) - half)) + assert ImageChops.subtract(mask, deck).getbbox() is None