From b430c82f547d6d4798eada52d4f5422d0752f24a Mon Sep 17 00:00:00 2001 From: ghbarker <117389362+ghbarker@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:46:16 -0400 Subject: [PATCH] ui: unified theme selector with seasonal scene packs + animated scenes (C3X + C4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One theme system for both devices: a single selector param (BPThemePack) drives per-pack colors, steering-wheel art, and layered onroad scenes (static + animated), with an optional auto-seasonal mode where each pack declares its own date window. - selfdrive/ui/bp/lib/theme_pack.py — pack resolution + param plumbing. - selfdrive/ui/bp/lib/theme_scene.py + theme_scene_assets.py — the scene architecture: modular static/animated layers, one active_scene() the renderers draw through. - Renderers (mici + c3x augmented road view / cameraview / model / hud / torque / chevron / exp button) draw the active scene via that shared accessor — no per-file theme logic. - selfdrive/assets/bp_themes/** — eleven packs (holidays + seasonal), stored directly in git (see .gitattributes); soundd.py — per-pack sounds. - Params BPThemePack + BPThemeAutoSeasonal; on-device selector + sunnylink. - Tests: test_theme_scene.py, test_theme_seasonal.py. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Selector lists packs in calendar order of their season windows (8-Bit Racer stays the default entry); fixes the C3X theme dialog saving "Off" regardless of selection (callback read the DialogResult enum as the label). --- .gitattributes | 3 + bluepilot/selfdrive/car/bp_card_publisher.py | 2 +- common/params_keys.h | 2 + selfdrive/assets/bp_themes/README.md | 80 ++ .../bp_themes/april_fools/colors/colors.json | 44 + .../assets/bp_themes/april_fools/scene.json | 85 ++ .../assets/bp_themes/april_fools/season.json | 4 + .../april_fools/steering_wheel/wheel.png | Bin 0 -> 15368 bytes .../christmas_week/colors/colors.json | 44 + .../bp_themes/christmas_week/scene.json | 119 ++ .../bp_themes/christmas_week/season.json | 4 + .../christmas_week/steering_wheel/wheel.png | Bin 0 -> 14134 bytes .../cinco_de_mayo/colors/colors.json | 44 + .../assets/bp_themes/cinco_de_mayo/scene.json | 90 ++ .../bp_themes/cinco_de_mayo/season.json | 4 + .../cinco_de_mayo/steering_wheel/wheel.png | Bin 0 -> 9441 bytes .../bp_themes/easter_week/colors/colors.json | 44 + .../assets/bp_themes/easter_week/scene.json | 104 ++ .../assets/bp_themes/easter_week/season.json | 5 + .../easter_week/steering_wheel/wheel.png | Bin 0 -> 17564 bytes .../fourth_of_july/colors/colors.json | 44 + .../bp_themes/fourth_of_july/scene.json | 111 ++ .../bp_themes/fourth_of_july/season.json | 4 + .../fourth_of_july/steering_wheel/wheel.png | Bin 0 -> 12115 bytes .../halloween_week/colors/colors.json | 44 + .../bp_themes/halloween_week/scene.json | 110 ++ .../bp_themes/halloween_week/season.json | 4 + .../halloween_week/steering_wheel/wheel.png | Bin 0 -> 56331 bytes .../bp_themes/new_years/colors/colors.json | 44 + .../assets/bp_themes/new_years/scene.json | 125 ++ .../assets/bp_themes/new_years/season.json | 4 + .../new_years/steering_wheel/wheel.png | Bin 0 -> 8388 bytes .../st_patricks_day/colors/colors.json | 44 + .../bp_themes/st_patricks_day/scene.json | 98 ++ .../bp_themes/st_patricks_day/season.json | 4 + .../st_patricks_day/steering_wheel/wheel.png | Bin 0 -> 9607 bytes .../thanksgiving_week/colors/colors.json | 44 + .../bp_themes/thanksgiving_week/scene.json | 105 ++ .../bp_themes/thanksgiving_week/season.json | 5 + .../steering_wheel/wheel.png | Bin 0 -> 11850 bytes .../valentines_day/colors/colors.json | 44 + .../bp_themes/valentines_day/scene.json | 91 ++ .../bp_themes/valentines_day/season.json | 4 + .../valentines_day/steering_wheel/wheel.png | Bin 0 -> 6210 bytes .../bp_themes/veterans_day/colors/colors.json | 44 + .../assets/bp_themes/veterans_day/scene.json | 133 ++ .../assets/bp_themes/veterans_day/season.json | 4 + .../veterans_day/steering_wheel/wheel.png | Bin 0 -> 6771 bytes selfdrive/ui/bp/layouts/settings/bluepilot.py | 63 +- selfdrive/ui/bp/lib/theme_pack.py | 278 +++++ selfdrive/ui/bp/lib/theme_scene.py | 697 +++++++++++ selfdrive/ui/bp/lib/theme_scene_assets.py | 1080 +++++++++++++++++ .../bp/mici/layouts/settings/visuals_mici.py | 20 +- .../bp/mici/onroad/augmented_road_view_bp.py | 24 +- selfdrive/ui/bp/mici/onroad/cameraview_bp.py | 6 +- .../ui/bp/mici/onroad/hud_renderer_bp.py | 44 +- .../ui/bp/mici/onroad/model_renderer_bp.py | 47 +- selfdrive/ui/bp/mici/widgets/button_bp.py | 27 + .../ui/bp/onroad/augmented_road_view_bp.py | 20 +- selfdrive/ui/bp/onroad/cameraview_bp.py | 6 +- selfdrive/ui/bp/onroad/chevron_metrics_bp.py | 14 +- selfdrive/ui/bp/onroad/exp_button_bp.py | 10 + selfdrive/ui/bp/onroad/model_renderer_bp.py | 83 +- .../ui/bp/onroad/torque_bar_renderer_bp.py | 21 +- selfdrive/ui/bp/tests/test_theme_scene.py | 237 ++++ selfdrive/ui/bp/tests/test_theme_seasonal.py | 75 ++ selfdrive/ui/soundd.py | 37 +- sunnypilot/sunnylink/settings_ui.json | 72 +- 68 files changed, 4574 insertions(+), 75 deletions(-) create mode 100644 selfdrive/assets/bp_themes/README.md create mode 100644 selfdrive/assets/bp_themes/april_fools/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/april_fools/scene.json create mode 100644 selfdrive/assets/bp_themes/april_fools/season.json create mode 100644 selfdrive/assets/bp_themes/april_fools/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/christmas_week/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/christmas_week/scene.json create mode 100644 selfdrive/assets/bp_themes/christmas_week/season.json create mode 100644 selfdrive/assets/bp_themes/christmas_week/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/cinco_de_mayo/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/cinco_de_mayo/scene.json create mode 100644 selfdrive/assets/bp_themes/cinco_de_mayo/season.json create mode 100644 selfdrive/assets/bp_themes/cinco_de_mayo/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/easter_week/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/easter_week/scene.json create mode 100644 selfdrive/assets/bp_themes/easter_week/season.json create mode 100644 selfdrive/assets/bp_themes/easter_week/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/fourth_of_july/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/fourth_of_july/scene.json create mode 100644 selfdrive/assets/bp_themes/fourth_of_july/season.json create mode 100644 selfdrive/assets/bp_themes/fourth_of_july/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/halloween_week/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/halloween_week/scene.json create mode 100644 selfdrive/assets/bp_themes/halloween_week/season.json create mode 100644 selfdrive/assets/bp_themes/halloween_week/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/new_years/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/new_years/scene.json create mode 100644 selfdrive/assets/bp_themes/new_years/season.json create mode 100644 selfdrive/assets/bp_themes/new_years/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/st_patricks_day/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/st_patricks_day/scene.json create mode 100644 selfdrive/assets/bp_themes/st_patricks_day/season.json create mode 100644 selfdrive/assets/bp_themes/st_patricks_day/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/thanksgiving_week/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/thanksgiving_week/scene.json create mode 100644 selfdrive/assets/bp_themes/thanksgiving_week/season.json create mode 100644 selfdrive/assets/bp_themes/thanksgiving_week/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/valentines_day/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/valentines_day/scene.json create mode 100644 selfdrive/assets/bp_themes/valentines_day/season.json create mode 100644 selfdrive/assets/bp_themes/valentines_day/steering_wheel/wheel.png create mode 100644 selfdrive/assets/bp_themes/veterans_day/colors/colors.json create mode 100644 selfdrive/assets/bp_themes/veterans_day/scene.json create mode 100644 selfdrive/assets/bp_themes/veterans_day/season.json create mode 100644 selfdrive/assets/bp_themes/veterans_day/steering_wheel/wheel.png create mode 100644 selfdrive/ui/bp/lib/theme_pack.py create mode 100644 selfdrive/ui/bp/lib/theme_scene.py create mode 100644 selfdrive/ui/bp/lib/theme_scene_assets.py create mode 100644 selfdrive/ui/bp/tests/test_theme_scene.py create mode 100644 selfdrive/ui/bp/tests/test_theme_seasonal.py diff --git a/.gitattributes b/.gitattributes index 3c36eea1d1..45e6e695c8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -53,3 +53,6 @@ third_party/bootstrap/bootstrap-icons.ttf -filter -diff -merge # BluePilot custom sounds: store directly in Git, not upstream GitLab LFS. selfdrive/assets/sounds/bluepilot/*.wav -filter -diff -merge + +# Theme pack assets: store directly in Git, not upstream GitLab LFS. +selfdrive/assets/bp_themes/** -filter -diff -merge diff --git a/bluepilot/selfdrive/car/bp_card_publisher.py b/bluepilot/selfdrive/car/bp_card_publisher.py index 7dfa8c5853..d6e18950fd 100644 --- a/bluepilot/selfdrive/car/bp_card_publisher.py +++ b/bluepilot/selfdrive/car/bp_card_publisher.py @@ -76,7 +76,7 @@ def _refresh_settings_cache() -> dict: "bmsHideOnroadBorder": _get_bool(p, "BPHideOnroadBorder"), "bmsDisableLaneLineStatusColor": _get_bool(p, "BPDisableLaneLineStatusColor"), "bmsMinimalDrivingView": _get_bool(p, "BPHideCameraView"), - "bmsEightBitRacerTheme": _get_bool(p, "BPRadRacerTheme"), + "bmsEightBitRacerTheme": _get_str(p, "BPThemePack").strip().lower() == "rad_racer", "bmsRainbowLaneLines": _get_bool(p, "BPRainbowLines"), "bmsShowBlindspotOverlay": _get_bool(p, "ShowBlindspotOverlay", True), "bmsShowBrakeStatus": _get_bool(p, "ShowBrakeStatus"), diff --git a/common/params_keys.h b/common/params_keys.h index 3248d6e8d3..5c257491fb 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -335,6 +335,8 @@ inline static std::unordered_map keys = { {"BPHideOnroadBorder", {PERSISTENT | BACKUP, BOOL, "0"}}, {"BPHideCameraView", {PERSISTENT | BACKUP, BOOL, "0"}}, {"BPRadRacerTheme", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"BPThemePack", {PERSISTENT | BACKUP, STRING, ""}}, + {"BPThemeAutoSeasonal", {PERSISTENT | BACKUP, BOOL, "0"}}, {"BPRainbowLines", {PERSISTENT | BACKUP, BOOL, "0"}}, {"BPShowConfidenceBall", {PERSISTENT | BACKUP, BOOL, "1"}}, {"BPAnimateSteeringWheel", {PERSISTENT | BACKUP, BOOL, "1"}}, diff --git a/selfdrive/assets/bp_themes/README.md b/selfdrive/assets/bp_themes/README.md new file mode 100644 index 0000000000..52900672b4 --- /dev/null +++ b/selfdrive/assets/bp_themes/README.md @@ -0,0 +1,80 @@ +# BluePilot seasonal theme packs + +Asset-only theme packs loaded by `selfdrive/ui/bp/lib/theme_pack.py` and selected with +the `BPThemePack` param (Settings → Visuals → Theme, on both the comma 3X and comma +four UIs). The same selector also carries the built-in 8-Bit Racer code theme, so one +param controls all theming. + +``` +/ + colors/colors.json # RGBA: Path, PathEdge, LaneLines, LeadMarker, RoadEdges, Accent, Background + scene.json # optional: animated + static scene layers (see below) + sounds/.wav # optional: overrides stock alert sounds by filename (mono 16-bit 48kHz) + steering_wheel/wheel.png # optional: steering wheel icon override +``` + +`Path`/`PathEdge` color the path ribbon, `LaneLines` the lane lines, `RoadEdges` the +road boundary lines, `LeadMarker` the vision-lead chevron and its info box, `Accent` +the torque-bar fill and set-speed value, and `Background` paints a sky gradient behind +the road when Minimal Driving View hides the camera. Every key is optional; missing +keys keep the stock color. + +The bundled packs cover the seasonal holidays: New Year's, Valentine's, St. Patrick's, +April Fools, Easter, Cinco de Mayo, Fourth of July, Halloween, Thanksgiving, and +Christmas. Palettes are original; steering-wheel icons are from +[OpenMoji](https://openmoji.org) (CC BY-SA 4.0). + +User packs can be dropped into `/data/bp_themes/` on the device — same layout, no code +or reinstall needed; same-name user packs shadow bundled ones. Malformed or missing +pieces degrade gracefully (bad wavs fall back to stock sounds, missing entries leave +stock visuals in place). + +## scene.json — animated and static scene layers + +A pack may ship a `scene.json` describing what the theme draws around the road: falling +snow, rising hearts, firework bursts, string lights, garlands. One generic engine +(`selfdrive/ui/bp/lib/theme_scene.py`) renders every pack; sprites are procedurally +generated (no images needed). Scenes render at full strength in Minimal Driving View +and subtly over the live camera; alerts always render on top. A pack without +`scene.json` is colors-only, exactly as before — and a user pack in `/data/bp_themes` +shadowing a bundled name without its scene.json disables that pack's animation. + +```jsonc +{ + "version": 1, + "sky": {"camera_tint_alpha": 40}, // 0-120: Background-color tint over live camera + "layers": [ + { "type": "particles", + "sprite": "snowflake", // snowflake | heart | leaf | petal | clover | + // bat | firefly | spark | bulb | pennant | + // pumpkin | garland + "mode": "fall", // fall | rise | wander | flutter + "count": 44, // minimal-view count (aggregate cap 60) + "camera_count_scale": 0.4, // over-camera count multiplier (cap 24) + "size_px": [10, 24], // at 1080p; scaled by screen height + "fall_speed": [0.05, 0.11], // screen fractions per second + "sway_amp": [0.004, 0.018], "sway_freq_hz": [0.15, 0.5], + "spin_dps": [-30, 30], + "tints": ["#FFFFFF", [221, 232, 255], "colors:Accent"], + "minimal_alpha": 235, "camera_alpha": 110, + "speed_scale": 0.5, // extra fall/drift with vehicle speed + "depth_split": 0.6, // fraction drawn BEHIND the road (smaller/dimmer) + "blend": "alpha" }, // or "additive" (glows, sparks) + { "type": "burst", // fireworks: periodic radial explosions + "period_s": [1.5, 3.0], "particles_per_burst": 24, + "radial_speed": [0.10, 0.22], "gravity": 0.12, "fade_s": [1.0, 1.6], + "region": [0.1, 0.05, 0.8, 0.35], "tints": ["#FFD700", "#FFFFFF"] }, + { "type": "decor", // static decoration, optional gentle idle + "sprite": "bulb", "anchor": "top_edge",// top_edge | top_left | top_right | + // bottom_left | bottom_right + "size_frac": 0.030, "spacing_frac": 0.045, "droop_frac": 0.030, + "tints": ["#FF4040", "#FFD24A", "#39C25C", "#3E7BFF"], + "idle": "twinkle", // none | twinkle | pulse | sway + "alpha_minimal": 220, "alpha_camera": 90 } + ], + "foreground": {"particles_over_hud": true, "corner_accents": true, "corner_alpha": 55} +} +``` + +All values are validated and clamped; invalid layers are dropped and a malformed file +falls back to colors-only, so experimentation on-device is safe. diff --git a/selfdrive/assets/bp_themes/april_fools/colors/colors.json b/selfdrive/assets/bp_themes/april_fools/colors/colors.json new file mode 100644 index 0000000000..f07f37979f --- /dev/null +++ b/selfdrive/assets/bp_themes/april_fools/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 30, + "green": 15, + "blue": 40, + "alpha": 255 + }, + "Path": { + "red": 180, + "green": 70, + "blue": 210, + "alpha": 255 + }, + "PathEdge": { + "red": 0, + "green": 200, + "blue": 200, + "alpha": 255 + }, + "LaneLines": { + "red": 255, + "green": 240, + "blue": 120, + "alpha": 220 + }, + "RoadEdges": { + "red": 255, + "green": 140, + "blue": 0, + "alpha": 255 + }, + "LeadMarker": { + "red": 0, + "green": 190, + "blue": 190, + "alpha": 255 + }, + "Accent": { + "red": 0, + "green": 190, + "blue": 190, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/april_fools/scene.json b/selfdrive/assets/bp_themes/april_fools/scene.json new file mode 100644 index 0000000000..65fd9a4ab6 --- /dev/null +++ b/selfdrive/assets/bp_themes/april_fools/scene.json @@ -0,0 +1,85 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "zigzag", + "tint": "#3C1450", + "horizon_frac": 0.46, + "height_frac": 0.24, + "alpha_minimal": 235, + "alpha_camera": 70 + }, + { + "type": "particles", + "sprite": "pennant", + "mode": "fall", + "count": 44, + "camera_count_scale": 0.4, + "size_px": [ + 17, + 34 + ], + "fall_speed": [ + 0.08, + 0.16 + ], + "sway_amp": [ + 0.008, + 0.025 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + -180, + 180 + ], + "tints": [ + "#B446D2", + "#00C8C8", + "#FFF078", + "#FF8C00" + ], + "minimal_alpha": 225, + "camera_alpha": 110, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "decor", + "sprite": "pennant", + "anchor": "top_edge", + "size_frac": 0.03, + "spacing_frac": 0.05, + "droop_frac": 0.028, + "tints": [ + "#B446D2", + "#00C8C8", + "#FFF078", + "#FF8C00" + ], + "alpha_minimal": 225, + "alpha_camera": 90, + "idle": "none" + }, + { + "type": "hero", + "x_frac": 0.5, + "y_frac": 0.8, + "alpha_minimal": 235, + "alpha_camera": 120, + "sprite": "jester_hat", + "size_frac": 0.15 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": false + } +} diff --git a/selfdrive/assets/bp_themes/april_fools/season.json b/selfdrive/assets/bp_themes/april_fools/season.json new file mode 100644 index 0000000000..8c72529a98 --- /dev/null +++ b/selfdrive/assets/bp_themes/april_fools/season.json @@ -0,0 +1,4 @@ +{ + "start": "03-30", + "end": "04-02" +} diff --git a/selfdrive/assets/bp_themes/april_fools/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/april_fools/steering_wheel/wheel.png new file mode 100644 index 0000000000000000000000000000000000000000..3ecb5de2fa42685a8cb856a6d301407e585b90c4 GIT binary patch literal 15368 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 b1>Zpan5`!7@?fO?k0vdyAXY1482o<#R8IC2 literal 0 HcmV?d00001 diff --git a/selfdrive/assets/bp_themes/christmas_week/colors/colors.json b/selfdrive/assets/bp_themes/christmas_week/colors/colors.json new file mode 100644 index 0000000000..d8f33ff3af --- /dev/null +++ b/selfdrive/assets/bp_themes/christmas_week/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 6, + "green": 26, + "blue": 16, + "alpha": 255 + }, + "Path": { + "red": 30, + "green": 150, + "blue": 70, + "alpha": 255 + }, + "PathEdge": { + "red": 220, + "green": 50, + "blue": 60, + "alpha": 255 + }, + "LaneLines": { + "red": 255, + "green": 245, + "blue": 230, + "alpha": 230 + }, + "RoadEdges": { + "red": 200, + "green": 35, + "blue": 50, + "alpha": 255 + }, + "LeadMarker": { + "red": 220, + "green": 50, + "blue": 60, + "alpha": 255 + }, + "Accent": { + "red": 255, + "green": 200, + "blue": 90, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/christmas_week/scene.json b/selfdrive/assets/bp_themes/christmas_week/scene.json new file mode 100644 index 0000000000..425402d8db --- /dev/null +++ b/selfdrive/assets/bp_themes/christmas_week/scene.json @@ -0,0 +1,119 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "pines", + "tint": "#1E4632", + "horizon_frac": 0.46, + "height_frac": 0.3, + "alpha_minimal": 235, + "alpha_camera": 70, + "celestial": "moon", + "celestial_pos": [ + 0.82, + 0.14 + ], + "celestial_tint": "#E8F2FF", + "celestial_size_frac": 0.15 + }, + { + "type": "particles", + "sprite": "snowflake", + "mode": "fall", + "count": 48, + "camera_count_scale": 0.4, + "size_px": [ + 22, + 51 + ], + "fall_speed": [ + 0.05, + 0.11 + ], + "sway_amp": [ + 0.004, + 0.015 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + -30, + 30 + ], + "tints": [ + "#FFFFFF", + "#DDE8FF", + "#BFD4E8" + ], + "minimal_alpha": 225, + "camera_alpha": 110, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "decor", + "sprite": "bulb", + "anchor": "top_edge", + "size_frac": 0.03, + "spacing_frac": 0.045, + "droop_frac": 0.03, + "tints": [ + "#FF4040", + "#FFD24A", + "#39C25C", + "#3E7BFF" + ], + "alpha_minimal": 220, + "alpha_camera": 90, + "idle": "twinkle" + }, + { + "type": "decor", + "sprite": "garland", + "anchor": "top_left", + "size_frac": 0.055, + "spacing_frac": 0.06, + "droop_frac": 0.025, + "tints": [ + "#FFFFFF" + ], + "alpha_minimal": 220, + "alpha_camera": 90, + "idle": "none" + }, + { + "type": "decor", + "sprite": "garland", + "anchor": "top_right", + "size_frac": 0.055, + "spacing_frac": 0.06, + "droop_frac": 0.025, + "tints": [ + "#FFFFFF" + ], + "alpha_minimal": 220, + "alpha_camera": 90, + "idle": "none" + }, + { + "type": "hero", + "x_frac": 0.5, + "y_frac": 0.8, + "alpha_minimal": 235, + "alpha_camera": 120, + "sprite": "sleigh", + "size_frac": 0.16 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": false + } +} diff --git a/selfdrive/assets/bp_themes/christmas_week/season.json b/selfdrive/assets/bp_themes/christmas_week/season.json new file mode 100644 index 0000000000..150bb7bbac --- /dev/null +++ b/selfdrive/assets/bp_themes/christmas_week/season.json @@ -0,0 +1,4 @@ +{ + "start": "12-18", + "end": "12-27" +} diff --git a/selfdrive/assets/bp_themes/christmas_week/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/christmas_week/steering_wheel/wheel.png new file mode 100644 index 0000000000000000000000000000000000000000..9da97039abf41cb09fc4a12ec333b4c1322e919d GIT binary patch literal 14134 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

KB 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 literal 0 HcmV?d00001 diff --git a/selfdrive/assets/bp_themes/easter_week/colors/colors.json b/selfdrive/assets/bp_themes/easter_week/colors/colors.json new file mode 100644 index 0000000000..8ea185de58 --- /dev/null +++ b/selfdrive/assets/bp_themes/easter_week/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 35, + "green": 25, + "blue": 50, + "alpha": 255 + }, + "Path": { + "red": 175, + "green": 135, + "blue": 225, + "alpha": 255 + }, + "PathEdge": { + "red": 255, + "green": 205, + "blue": 140, + "alpha": 255 + }, + "LaneLines": { + "red": 255, + "green": 250, + "blue": 215, + "alpha": 220 + }, + "RoadEdges": { + "red": 245, + "green": 160, + "blue": 195, + "alpha": 255 + }, + "LeadMarker": { + "red": 240, + "green": 150, + "blue": 190, + "alpha": 255 + }, + "Accent": { + "red": 250, + "green": 200, + "blue": 130, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/easter_week/scene.json b/selfdrive/assets/bp_themes/easter_week/scene.json new file mode 100644 index 0000000000..375a507560 --- /dev/null +++ b/selfdrive/assets/bp_themes/easter_week/scene.json @@ -0,0 +1,104 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "eggs", + "tint": "#2C2044", + "horizon_frac": 0.46, + "height_frac": 0.24, + "alpha_minimal": 235, + "alpha_camera": 70, + "celestial": "sun", + "celestial_pos": [ + 0.8, + 0.15 + ], + "celestial_tint": "#FFF2C8", + "celestial_size_frac": 0.14 + }, + { + "type": "particles", + "sprite": "petal", + "mode": "fall", + "count": 36, + "camera_count_scale": 0.4, + "size_px": [ + 20, + 41 + ], + "fall_speed": [ + 0.03, + 0.06 + ], + "sway_amp": [ + 0.008, + 0.02 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + -25, + 25 + ], + "tints": [ + "#F5A0C3", + "#AF87E1", + "#FFCD8C", + "#FFFAD7" + ], + "minimal_alpha": 225, + "camera_alpha": 110, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "decor", + "sprite": "petal", + "anchor": "top_left", + "size_frac": 0.05, + "spacing_frac": 0.06, + "droop_frac": 0.025, + "tints": [ + "#F5A0C3" + ], + "alpha_minimal": 225, + "alpha_camera": 90, + "idle": "none" + }, + { + "type": "decor", + "sprite": "petal", + "anchor": "top_right", + "size_frac": 0.05, + "spacing_frac": 0.06, + "droop_frac": 0.025, + "tints": [ + "#AF87E1" + ], + "alpha_minimal": 225, + "alpha_camera": 90, + "idle": "none" + }, + { + "type": "hero", + "x_frac": 0.5, + "y_frac": 0.8, + "alpha_minimal": 235, + "alpha_camera": 120, + "sprite": "bunny", + "size_frac": 0.15 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": true, + "corner_alpha": 50 + } +} diff --git a/selfdrive/assets/bp_themes/easter_week/season.json b/selfdrive/assets/bp_themes/easter_week/season.json new file mode 100644 index 0000000000..b0c816b18e --- /dev/null +++ b/selfdrive/assets/bp_themes/easter_week/season.json @@ -0,0 +1,5 @@ +{ + "anchor": "easter", + "offset_start": -6, + "offset_end": 1 +} diff --git a/selfdrive/assets/bp_themes/easter_week/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/easter_week/steering_wheel/wheel.png new file mode 100644 index 0000000000000000000000000000000000000000..f3b198fb41c2a1744654255d9d7e383d9be302f1 GIT binary patch literal 17564 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 literal 0 HcmV?d00001 diff --git a/selfdrive/assets/bp_themes/fourth_of_july/colors/colors.json b/selfdrive/assets/bp_themes/fourth_of_july/colors/colors.json new file mode 100644 index 0000000000..683773102e --- /dev/null +++ b/selfdrive/assets/bp_themes/fourth_of_july/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 10, + "green": 15, + "blue": 40, + "alpha": 255 + }, + "Path": { + "red": 45, + "green": 80, + "blue": 180, + "alpha": 255 + }, + "PathEdge": { + "red": 205, + "green": 45, + "blue": 60, + "alpha": 255 + }, + "LaneLines": { + "red": 255, + "green": 255, + "blue": 255, + "alpha": 235 + }, + "RoadEdges": { + "red": 205, + "green": 45, + "blue": 60, + "alpha": 255 + }, + "LeadMarker": { + "red": 205, + "green": 45, + "blue": 60, + "alpha": 255 + }, + "Accent": { + "red": 100, + "green": 140, + "blue": 235, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/fourth_of_july/scene.json b/selfdrive/assets/bp_themes/fourth_of_july/scene.json new file mode 100644 index 0000000000..2e9d0bb2a7 --- /dev/null +++ b/selfdrive/assets/bp_themes/fourth_of_july/scene.json @@ -0,0 +1,111 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "capitol", + "tint": "#28325A", + "horizon_frac": 0.46, + "height_frac": 0.26, + "alpha_minimal": 235, + "alpha_camera": 70 + }, + { + "type": "burst", + "period_s": [ + 1.1, + 2.2 + ], + "particles_per_burst": 28, + "radial_speed": [ + 0.12, + 0.26 + ], + "gravity": 0.12, + "fade_s": [ + 1.1, + 1.9 + ], + "region": [ + 0.06, + 0.03, + 0.88, + 0.28 + ], + "tints": [ + "#CD2D3C", + "#FFFFFF", + "#648CEB" + ], + "blend": "additive" + }, + { + "type": "particles", + "sprite": "spark", + "mode": "wander", + "count": 12, + "camera_count_scale": 0.4, + "size_px": [ + 8, + 17 + ], + "fall_speed": [ + 0.02, + 0.05 + ], + "sway_amp": [ + 0.004, + 0.015 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + 0, + 0 + ], + "tints": [ + "#FFFFFF", + "#648CEB" + ], + "minimal_alpha": 190, + "camera_alpha": 85, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "additive" + }, + { + "type": "decor", + "sprite": "pennant", + "anchor": "top_edge", + "size_frac": 0.03, + "spacing_frac": 0.05, + "droop_frac": 0.028, + "tints": [ + "#CD2D3C", + "#FFFFFF", + "#648CEB" + ], + "alpha_minimal": 220, + "alpha_camera": 90, + "idle": "none" + }, + { + "type": "hero", + "x_frac": 0.5, + "y_frac": 0.8, + "alpha_minimal": 235, + "alpha_camera": 120, + "sprite": "rocket", + "size_frac": 0.15 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": false + } +} diff --git a/selfdrive/assets/bp_themes/fourth_of_july/season.json b/selfdrive/assets/bp_themes/fourth_of_july/season.json new file mode 100644 index 0000000000..bdb0d6ed23 --- /dev/null +++ b/selfdrive/assets/bp_themes/fourth_of_july/season.json @@ -0,0 +1,4 @@ +{ + "start": "06-27", + "end": "07-05" +} diff --git a/selfdrive/assets/bp_themes/fourth_of_july/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/fourth_of_july/steering_wheel/wheel.png new file mode 100644 index 0000000000000000000000000000000000000000..1fd99b4c082c8775587f90e5c09241fcd94b8148 GIT binary patch literal 12115 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 literal 0 HcmV?d00001 diff --git a/selfdrive/assets/bp_themes/halloween_week/colors/colors.json b/selfdrive/assets/bp_themes/halloween_week/colors/colors.json new file mode 100644 index 0000000000..988718693d --- /dev/null +++ b/selfdrive/assets/bp_themes/halloween_week/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 28, + "green": 12, + "blue": 46, + "alpha": 255 + }, + "Path": { + "red": 255, + "green": 130, + "blue": 20, + "alpha": 255 + }, + "PathEdge": { + "red": 255, + "green": 170, + "blue": 60, + "alpha": 255 + }, + "LaneLines": { + "red": 190, + "green": 140, + "blue": 255, + "alpha": 220 + }, + "RoadEdges": { + "red": 140, + "green": 60, + "blue": 220, + "alpha": 255 + }, + "LeadMarker": { + "red": 255, + "green": 120, + "blue": 0, + "alpha": 255 + }, + "Accent": { + "red": 255, + "green": 140, + "blue": 30, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/halloween_week/scene.json b/selfdrive/assets/bp_themes/halloween_week/scene.json new file mode 100644 index 0000000000..65e84ddc5f --- /dev/null +++ b/selfdrive/assets/bp_themes/halloween_week/scene.json @@ -0,0 +1,110 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "spooky", + "tint": "#50287D", + "horizon_frac": 0.46, + "height_frac": 0.28, + "alpha_minimal": 235, + "alpha_camera": 70, + "celestial": "moon", + "celestial_pos": [ + 0.78, + 0.13 + ], + "celestial_tint": "#FFD9A0", + "celestial_size_frac": 0.19 + }, + { + "type": "particles", + "sprite": "leaf", + "mode": "fall", + "count": 28, + "camera_count_scale": 0.4, + "size_px": [ + 22, + 44 + ], + "fall_speed": [ + 0.06, + 0.12 + ], + "sway_amp": [ + 0.004, + 0.015 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + -60, + 60 + ], + "tints": [ + "#FF8214", + "#FFAA3C", + "#C85A0A" + ], + "minimal_alpha": 225, + "camera_alpha": 110, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "particles", + "sprite": "bat", + "mode": "flutter", + "count": 6, + "camera_count_scale": 0.4, + "size_px": [ + 34, + 55 + ], + "fall_speed": [ + 0.06, + 0.1 + ], + "sway_amp": [ + 0.01, + 0.03 + ], + "sway_freq_hz": [ + 0.5, + 1.2 + ], + "spin_dps": [ + 0, + 0 + ], + "tints": [ + "#3C1E64", + "#8C3CDC" + ], + "minimal_alpha": 235, + "camera_alpha": 130, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "hero", + "x_frac": 0.5, + "y_frac": 0.8, + "alpha_minimal": 235, + "alpha_camera": 120, + "sprite": "ghost", + "size_frac": 0.15 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": false + } +} diff --git a/selfdrive/assets/bp_themes/halloween_week/season.json b/selfdrive/assets/bp_themes/halloween_week/season.json new file mode 100644 index 0000000000..d349ae5d50 --- /dev/null +++ b/selfdrive/assets/bp_themes/halloween_week/season.json @@ -0,0 +1,4 @@ +{ + "start": "10-24", + "end": "11-01" +} diff --git a/selfdrive/assets/bp_themes/halloween_week/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/halloween_week/steering_wheel/wheel.png new file mode 100644 index 0000000000000000000000000000000000000000..dc3bce4db8934ebc54875c579ff44f6f6d1d5999 GIT binary patch literal 56331 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 literal 0 HcmV?d00001 diff --git a/selfdrive/assets/bp_themes/st_patricks_day/colors/colors.json b/selfdrive/assets/bp_themes/st_patricks_day/colors/colors.json new file mode 100644 index 0000000000..a74eaf94ae --- /dev/null +++ b/selfdrive/assets/bp_themes/st_patricks_day/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 8, + "green": 28, + "blue": 14, + "alpha": 255 + }, + "Path": { + "red": 30, + "green": 160, + "blue": 70, + "alpha": 255 + }, + "PathEdge": { + "red": 90, + "green": 220, + "blue": 120, + "alpha": 255 + }, + "LaneLines": { + "red": 245, + "green": 255, + "blue": 245, + "alpha": 220 + }, + "RoadEdges": { + "red": 255, + "green": 190, + "blue": 60, + "alpha": 255 + }, + "LeadMarker": { + "red": 60, + "green": 200, + "blue": 100, + "alpha": 255 + }, + "Accent": { + "red": 60, + "green": 210, + "blue": 110, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/st_patricks_day/scene.json b/selfdrive/assets/bp_themes/st_patricks_day/scene.json new file mode 100644 index 0000000000..8506bb53ad --- /dev/null +++ b/selfdrive/assets/bp_themes/st_patricks_day/scene.json @@ -0,0 +1,98 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "clover_hills", + "tint": "#0C3018", + "horizon_frac": 0.46, + "height_frac": 0.24, + "alpha_minimal": 235, + "alpha_camera": 70, + "rainbow": true + }, + { + "type": "particles", + "sprite": "clover", + "mode": "fall", + "count": 34, + "camera_count_scale": 0.4, + "size_px": [ + 26, + 54 + ], + "fall_speed": [ + 0.04, + 0.08 + ], + "sway_amp": [ + 0.004, + 0.015 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + -45, + 45 + ], + "tints": [ + "#3CD26E", + "#5ADC78", + "#2AA050", + "#FFBE3C" + ], + "minimal_alpha": 225, + "camera_alpha": 110, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "decor", + "sprite": "clover", + "anchor": "top_left", + "size_frac": 0.055, + "spacing_frac": 0.06, + "droop_frac": 0.025, + "tints": [ + "#3CD26E" + ], + "alpha_minimal": 225, + "alpha_camera": 90, + "idle": "none" + }, + { + "type": "decor", + "sprite": "clover", + "anchor": "top_right", + "size_frac": 0.055, + "spacing_frac": 0.06, + "droop_frac": 0.025, + "tints": [ + "#5ADC78" + ], + "alpha_minimal": 225, + "alpha_camera": 90, + "idle": "none" + }, + { + "type": "hero", + "x_frac": 0.5, + "y_frac": 0.8, + "alpha_minimal": 235, + "alpha_camera": 120, + "sprite": "pot_gold", + "size_frac": 0.14 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": true, + "corner_alpha": 50 + } +} diff --git a/selfdrive/assets/bp_themes/st_patricks_day/season.json b/selfdrive/assets/bp_themes/st_patricks_day/season.json new file mode 100644 index 0000000000..48e3d5e8c2 --- /dev/null +++ b/selfdrive/assets/bp_themes/st_patricks_day/season.json @@ -0,0 +1,4 @@ +{ + "start": "03-10", + "end": "03-18" +} diff --git a/selfdrive/assets/bp_themes/st_patricks_day/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/st_patricks_day/steering_wheel/wheel.png new file mode 100644 index 0000000000000000000000000000000000000000..f663bfe20730c9cf8e201b370cb0b3b4d982ddea GIT binary patch literal 9607 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 literal 0 HcmV?d00001 diff --git a/selfdrive/ui/bp/layouts/settings/bluepilot.py b/selfdrive/ui/bp/layouts/settings/bluepilot.py index 03f1e2ecfc..15ab5d125f 100644 --- a/selfdrive/ui/bp/layouts/settings/bluepilot.py +++ b/selfdrive/ui/bp/layouts/settings/bluepilot.py @@ -19,6 +19,8 @@ get_steering_wheel_icon_style, SteeringWheelIconStyle, ) +# BluePilot: seasonal theme packs +from openpilot.selfdrive.ui.bp.lib import theme_pack from openpilot.selfdrive.ui.bp.lib.dm_icon_style import ( DMIconStyle, ensure_dm_icon_style_initialized, @@ -80,7 +82,6 @@ def __init__(self): ("FordPrefSteerAngleCurvature", self._steer_angle_curvature), ("BPDisableLaneLineStatusColor", self._disable_lane_line_status_color), ("BPHideCameraView", self._hide_camera_view), - ("BPRadRacerTheme", self._rad_racer_theme), ("BPRainbowLines", self._rainbow_lane_lines), ("ShowBlindspotOverlay", self._show_blindspot), ("ShowBrakeStatus", self._show_brake_status), @@ -148,15 +149,6 @@ def _initialize_items(self): icon="chffr_wheel.png" ) - # Rad Racer 8-bit theme toggle - self._rad_racer_theme = toggle_item( - lambda: tr("8-Bit Racer Theme"), - lambda: tr("Retro racing game onroad view: hides the camera, draws the road as green game lines, and shows a bottom gauge cluster."), - initial_state=self._safe_get_bool(self._params, "BPRadRacerTheme"), - callback=lambda state: self._toggle_callback(state, "BPRadRacerTheme"), - icon="chffr_wheel.png" - ) - # Rainbow lane lines toggle self._rainbow_lane_lines = toggle_item( lambda: tr("Rainbow Lane Lines"), @@ -211,6 +203,30 @@ def _initialize_items(self): icon="chffr_wheel.png" ) + # BluePilot: one theme selector for everything (8-Bit Racer + seasonal packs), + # same entries and param as the MICI page — see theme_pack.selector_entries(). + # A dialog (not a button row) so any number of packs stays inside the item box. + self._theme_entries = theme_pack.selector_entries() + self._theme_dialog: MultiOptionDialog | None = None + self._theme_action = ButtonAction(lambda: tr("SELECT")) + self._theme_action.set_value(lambda: self._get_theme_display()) + self._theme_pack_btn = ListItem( + lambda: tr("Theme"), + description=lambda: tr("8-Bit Racer game view, or a seasonal theme pack (recolors the road and the wheel icon)."), + action_item=self._theme_action, + callback=self._select_theme, + ) + + # Auto seasonal: date-driven pack during holiday weeks; the manual Theme + # selection above still applies outside those windows. + self._theme_auto_seasonal = toggle_item( + lambda: tr("Auto Seasonal Theme"), + lambda: tr("During a holiday week, switch to that seasonal theme pack automatically. Outside holiday weeks the Theme selection above applies."), + initial_state=self._safe_get_bool(self._params, "BPThemeAutoSeasonal"), + callback=lambda state: self._toggle_callback(state, "BPThemeAutoSeasonal"), + icon="warning.png" + ) + wheel_style_idx = int(ensure_steering_wheel_icon_style_initialized(self._params, SteeringWheelIconStyle.COMMA_3X)) self._wheel_icon_style_btn = multiple_button_item( lambda: tr("Wheel Icon Style"), @@ -684,7 +700,8 @@ def _section(title: str, items: list) -> list: self._hide_onroad_border, self._disable_lane_line_status_color, self._hide_camera_view, - self._rad_racer_theme, + self._theme_pack_btn, + self._theme_auto_seasonal, self._rainbow_lane_lines, self._show_blindspot, self._show_brake_status, @@ -1015,6 +1032,30 @@ def _set_overlay_size(self, button_index: int): """Handle overlay size button selection.""" self._params.put("FordPrefRadarOverlaySize", button_index) + def _get_theme_display(self) -> str: + """Label of the currently selected theme entry (falls back to Off).""" + stored = self._safe_get(self._params, theme_pack.PARAM_KEY) or "" + if isinstance(stored, bytes): + stored = stored.decode("utf-8", errors="replace") + return next((label for label, v in self._theme_entries if v and v.lower() == stored.lower()), tr("Off")) + + def _select_theme(self): + """Open a scrollable option dialog; stores the selected entry's param value.""" + def handle_selection(result: DialogResult): + if result == DialogResult.CONFIRM and self._theme_dialog is not None: + selection = self._theme_dialog.selection + value = next((v for label, v in self._theme_entries if label == selection), "") + self._params.put(theme_pack.PARAM_KEY, value) + self._theme_dialog = None + + self._theme_dialog = MultiOptionDialog( + tr("Select Theme"), + [label for label, _ in self._theme_entries], + self._get_theme_display(), + callback=handle_selection, + ) + gui_app.push_widget(self._theme_dialog) + def _set_wheel_icon_style(self, button_index: int): """Handle wheel icon style: 0 = comma 4, 1 = comma 3X.""" self._params.put("BPSteeringWheelIconStyle", button_index) diff --git a/selfdrive/ui/bp/lib/theme_pack.py b/selfdrive/ui/bp/lib/theme_pack.py new file mode 100644 index 0000000000..57f2b2ba06 --- /dev/null +++ b/selfdrive/ui/bp/lib/theme_pack.py @@ -0,0 +1,278 @@ +"""BluePilot: seasonal theme pack loader. + +A theme pack is a directory of plain assets — no code: + + /colors/colors.json RGBA entries: Path, PathEdge, LaneLines, LeadMarker + /sounds/.wav overrides for matching alert sounds (engage.wav, disengage.wav, ...) + /steering_wheel/wheel.png steering wheel icon override + +Packs are discovered in BUNDLED_DIR (shipped with the repo) and USER_DIR (device-local, +drop packs in over SSH). Selection is the BPThemePack string param holding the pack's +directory name; empty/missing/unknown means no theme pack, and the special value +RAD_RACER selects the built-in 8-Bit Racer code theme instead of a pack. + +This module is imported by soundd as well as the UI, so pyray is only imported inside +the texture/color helpers, never at module level. +""" +import datetime +import json +import os +import time + +from openpilot.common.basedir import BASEDIR +from openpilot.common.params import Params + +PARAM_KEY = "BPThemePack" +AUTO_PARAM_KEY = "BPThemeAutoSeasonal" +RAD_RACER = "rad_racer" # param value: the built-in 8-Bit Racer code theme (not a pack on disk) +BUNDLED_DIR = os.path.join(BASEDIR, "selfdrive", "assets", "bp_themes") +USER_DIR = "/data/bp_themes" +_PARAM_POLL_S = 2.0 + +# colors.json keys the renderers consume (unknown keys are loaded but ignored): +# Path/PathEdge (path ribbon gradient), LaneLines, LeadMarker (vision lead chevron + info box), +# RoadEdges (road boundary lines), Accent (torque bar fill, set-speed value), +# Background (sky treatment — rendered by theme_scene.PackScene) +COLOR_KEYS = ("Path", "PathEdge", "LaneLines", "LeadMarker", "RoadEdges", "Accent", "Background") + + +class ThemePack: + def __init__(self, name: str, root: str): + self.name = name + self.root = root + # Raw RGBA tuples keyed by colors.json name; rl.Color built lazily in rl_colors() + self.colors: dict[str, tuple[int, int, int, int]] = {} + self._rl_colors: dict | None = None + self._wheel_textures: dict[int, object] = {} + + colors_path = os.path.join(root, "colors", "colors.json") + if os.path.isfile(colors_path): + try: + with open(colors_path) as f: + data = json.load(f) + for key, v in data.items(): + self.colors[key] = (int(v["red"]), int(v["green"]), int(v["blue"]), int(v["alpha"])) + except (OSError, ValueError, KeyError, TypeError): + self.colors = {} + + def sound_path(self, filename: str) -> str | None: + p = os.path.join(self.root, "sounds", filename) + return p if os.path.isfile(p) else None + + @property + def wheel_png(self) -> str | None: + p = os.path.join(self.root, "steering_wheel", "wheel.png") + return p if os.path.isfile(p) else None + + def rl_colors(self) -> dict: + """colors.json entries as rl.Color, built once. UI processes only.""" + if self._rl_colors is None: + import pyray as rl + self._rl_colors = {k: rl.Color(*v) for k, v in self.colors.items()} + return self._rl_colors + + def wheel_texture(self, size: int): + """Steering wheel icon as a texture resized to size x size, or None. UI processes only.""" + size = int(size) + if size not in self._wheel_textures: + self._wheel_textures[size] = None + if self.wheel_png is not None: + import pyray as rl + img = rl.load_image(self.wheel_png) + if img.width > 0: + rl.image_resize(img, size, size) + tex = rl.load_texture_from_image(img) + rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + self._wheel_textures[size] = tex + rl.unload_image(img) + return self._wheel_textures[size] + + +def list_packs() -> list[str]: + """Names of all discoverable packs, bundled first, then user packs, each sorted.""" + names: list[str] = [] + for base in (BUNDLED_DIR, USER_DIR): + if os.path.isdir(base): + for entry in sorted(os.listdir(base)): + if entry not in names and os.path.isdir(os.path.join(base, entry)): + names.append(entry) + return names + + +def _resolve(name: str) -> ThemePack | None: + for base in (USER_DIR, BUNDLED_DIR): # user packs shadow bundled ones of the same name + root = os.path.join(base, name) + if os.path.isdir(root): + return ThemePack(name, root) + return None + + +def _param_value(params: Params | None = None) -> str: + raw = (params or Params()).get(PARAM_KEY) or "" + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + return raw.strip() + + +def _easter(year: int) -> datetime.date: + """Gregorian Easter Sunday (Anonymous Gregorian algorithm).""" + a = year % 19 + b, c = divmod(year, 100) + d, e = divmod(b, 4) + g = (8 * b + 13) // 25 + h = (19 * a + b - d - g + 15) % 30 + i, k = divmod(c, 4) + ell = (32 + 2 * e + 2 * i - h - k) % 7 + m = (a + 11 * h + 22 * ell) // 451 + month, day = divmod(h + ell - 7 * m + 114, 31) + return datetime.date(year, month, day + 1) + + +def _thanksgiving(year: int) -> datetime.date: + """US Thanksgiving: fourth Thursday of November.""" + first = datetime.date(year, 11, 1) + return first + datetime.timedelta(days=(3 - first.weekday()) % 7 + 21) + + +_ANCHORS = {"easter": _easter, "us_thanksgiving": _thanksgiving} + + +def _season_window(root: str, year: int) -> tuple[datetime.date, datetime.date] | None: + """The pack's holiday window for `year`, from /season.json, else None. + + Every discoverable pack — bundled or user-dropped — is treated the same: a pack + participates in auto-seasonal switching iff it ships a season.json. Two forms: + + {"start": "12-18", "end": "12-27"} fixed MM-DD, inclusive; + start > end wraps the year + {"anchor": "easter", "offset_start": -6, "offset_end": 1} relative to a movable + feast (easter, us_thanksgiving) + """ + path = os.path.join(root, "season.json") + try: + with open(path) as f: + data = json.load(f) + anchor = data.get("anchor") + if anchor is not None: + day = _ANCHORS[anchor](year) + return (day + datetime.timedelta(days=int(data.get("offset_start", 0))), + day + datetime.timedelta(days=int(data.get("offset_end", 0)))) + sm, sd = (int(v) for v in data["start"].split("-")) + em, ed = (int(v) for v in data["end"].split("-")) + return datetime.date(year, sm, sd), datetime.date(year, em, ed) + except (OSError, ValueError, KeyError, TypeError): + return None + + +def seasonal_pack(today: datetime.date | None = None) -> str: + """Name of the pack whose season.json window contains `today`, else ''. + + All packs are scanned equally (user packs shadow bundled ones via _resolve). + Overlaps go to the shortest window — the more specific holiday (April Fools) + beats the broader one it falls inside (Easter week) — then alphabetical. + """ + today = today or datetime.date.today() + best: tuple[int, str] | None = None + for name in list_packs(): + pack = _resolve(name) + if pack is None: + continue + for year in (today.year - 1, today.year): # windows that wrap the year boundary + window = _season_window(pack.root, year) + if window is None: + continue + start, end = window + if end < start: # fixed window wrapping into the next year (e.g. 12-29 .. 01-05) + end = end.replace(year=end.year + 1) + if start <= today <= end: + length = (end - start).days + if best is None or (length, name) < best: + best = (length, name) + return best[1] if best else "" + + +def auto_seasonal_enabled(params: Params | None = None) -> bool: + return (params or Params()).get_bool(AUTO_PARAM_KEY) + + +def _effective_name(params: Params | None = None) -> str: + """Selector value, overridden by the date-matched pack while Auto Seasonal is on. + + Outside holiday windows (or if the seasonal pack is missing on disk) the manual + selection — including Off and Rad Racer — applies unchanged. + """ + p = params or Params() + name = _param_value(p) + if p.get_bool(AUTO_PARAM_KEY): + season = seasonal_pack() + if season and _resolve(season) is not None: + return season + return name + + +def rad_racer_active(params: Params | None = None) -> bool: + """True when the theme selector is set to the built-in 8-Bit Racer theme. + + Rad Racer is a code theme, not a pack on disk, so it lives as a special selector value; + while it is active get_active_pack() returns None (no pack color/sound/wheel overrides). + """ + return _effective_name(params).lower() == RAD_RACER + + +def selector_entries() -> list[tuple[str, str]]: + """(label, param value) pairs for the theme selector — the single source of truth for + both the C3X and MICI settings pages, so the toggle behaves identically on each. + Packs are listed in calendar order of their season windows (Jan→Dec); packs without + a season.json follow, alphabetical.""" + year = datetime.date.today().year + + def _calendar_key(name: str): + pack = _resolve(name) + win = _season_window(pack.root, year) if pack is not None else None + if win is None: + return (1, 0, 0, name) + return (0, win[0].month, win[0].day, name) + + return [("Off", ""), ("8-Bit Racer", RAD_RACER)] + [(name, name) for name in sorted(list_packs(), key=_calendar_key)] + + +_cache: dict = {"checked_at": 0.0, "name": None, "pack": None} + + +def get_active_pack(force: bool = False) -> ThemePack | None: + """The currently selected pack, or None. Re-reads the param at most every 2s.""" + now = time.monotonic() + if not force and now - _cache["checked_at"] < _PARAM_POLL_S: + return _cache["pack"] + _cache["checked_at"] = now + + name = _effective_name() + if name != _cache["name"]: + _cache["name"] = name + _cache["pack"] = _resolve(name) if name else None + return _cache["pack"] + + +def active_pack_name() -> str: + pack = get_active_pack() + return pack.name if pack else "" + + +if __name__ == "__main__": + # CLI for scripts/launchers: python3 -m openpilot.selfdrive.ui.bp.lib.theme_pack [pack|off|rad_racer] [minimal|camera] + # The optional second arg toggles BPHideCameraView, for previewing over rlog-only replays + # where the camera feed is black. + import sys + _name = sys.argv[1] if len(sys.argv) > 1 else "" + if _name.lower() == "off": + _name = "" + if _name and _name.lower() != RAD_RACER and _resolve(_name) is None: + print(f"unknown pack '{_name}' (available: {', '.join(list_packs()) or 'none'}, or '{RAD_RACER}')") + sys.exit(1) + params = Params() + # block: launchers start the UI right after this process — the value must be on disk + params.put(PARAM_KEY, _name, block=True) + print(f"BPThemePack = '{_name}'") + if len(sys.argv) > 2 and sys.argv[2] in ("minimal", "camera"): + params.put_bool("BPHideCameraView", sys.argv[2] == "minimal", block=True) + print(f"BPHideCameraView = {sys.argv[2] == 'minimal'}") diff --git a/selfdrive/ui/bp/lib/theme_scene.py b/selfdrive/ui/bp/lib/theme_scene.py new file mode 100644 index 0000000000..e3565b8b43 --- /dev/null +++ b/selfdrive/ui/bp/lib/theme_scene.py @@ -0,0 +1,697 @@ +"""BluePilot: the theme scene system — one modular architecture for every theme kind. + +A *scene* is what a theme draws around the road view: a background pass (before the +model/road render) and a foreground pass (after the HUD, always under alerts). Three +kinds exist behind one interface: + + - None: plain UI (no theme selected) + - RadRacerScene: the built-in 8-Bit Racer code theme. The scene object carries the + capability flags (replaces_road / replaces_hud); the actual pixel + work stays in rad_racer_theme.py, invoked by the host views exactly + as before — this adapter unifies SELECTION and dispatch, not drawing. + - PackScene: data-driven scenes for seasonal packs. Each pack may ship a + scene.json describing particle layers (falling snow, rising hearts, + fluttering bats, firework bursts), static decor (string lights, + garlands, bunting) and a sky treatment. Packs remain pure assets. + +Host views call active_scene() every frame (internally cached, param-polled by +theme_pack) and use the scene's capabilities + draw passes. The model renderers keep +their own rad-racer road-swap flag (same param, same cadence) — replaces_road() here +mirrors it for the view layer. + +scene.json (version 1) — all coordinates are FRACTIONS of the view rect, sizes in px +at 1080p (scaled by rect height). Unknown/invalid layers are dropped; a missing or +malformed scene.json means a colors-only pack (previous behavior). Two intensity +contexts: Minimal Driving View (camera hidden) gets full counts/alpha; over live +camera counts are scaled by camera_count_scale and camera_alpha applies. + +This module is UI-only but must import without pyray (unit tests parse and simulate +scenes on any machine); pyray is imported inside draw methods only. +""" +import json +import math +import os +import random +import zlib + +from openpilot.selfdrive.ui.bp.lib import theme_pack + +# Hard budgets — a malformed (or malicious user-) scene.json cannot exceed these. +MAX_PARTICLES_MINIMAL = 60 +MAX_PARTICLES_CAMERA = 24 +MAX_BURST_POOL = 64 +MAX_DECOR = 12 +_SPEED_REF_MS = 30.0 # vEgo giving full speed_scale effect + +_MODES = ("fall", "rise", "wander", "flutter") +_ANCHORS = ("top_edge", "top_left", "top_right", "bottom_left", "bottom_right") +_IDLES = ("none", "twinkle", "pulse", "sway") + + +def _clampf(v, lo, hi, default): + try: + return max(lo, min(hi, float(v))) + except (TypeError, ValueError): + return default + + +def _rng_range(spec, lo, hi, default): + """[a, b] pair clamped; scalars accepted as [v, v].""" + if isinstance(spec, (int, float)): + spec = [spec, spec] + if not (isinstance(spec, (list, tuple)) and len(spec) == 2): + spec = default + a = _clampf(spec[0], lo, hi, default[0]) + b = _clampf(spec[1], lo, hi, default[1]) + return (min(a, b), max(a, b)) + + +def _resolve_tint(t, colors): + """'#RRGGBB' | [r,g,b] | 'colors:Key' -> (r, g, b); white on anything invalid.""" + try: + if isinstance(t, str) and t.startswith("colors:"): + c = colors.get(t[7:]) + return (c[0], c[1], c[2]) if c else (255, 255, 255) + if isinstance(t, str) and t.startswith("#") and len(t) == 7: + return (int(t[1:3], 16), int(t[3:5], 16), int(t[5:7], 16)) + if isinstance(t, (list, tuple)) and len(t) >= 3: + return (int(t[0]) & 255, int(t[1]) & 255, int(t[2]) & 255) + except (TypeError, ValueError): + pass + return (255, 255, 255) + + +class _ParticleLayer: + def __init__(self, d, colors): + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import SPRITE_NAMES + self.sprite = str(d.get("sprite", "")) + if self.sprite not in SPRITE_NAMES: + raise ValueError(f"unknown sprite {self.sprite!r}") + self.mode = str(d.get("mode", "fall")) + if self.mode not in _MODES: + raise ValueError(f"unknown mode {self.mode!r}") + self.count = int(_clampf(d.get("count", 30), 1, MAX_PARTICLES_MINIMAL, 30)) + self.camera_count_scale = _clampf(d.get("camera_count_scale", 0.4), 0.0, 1.0, 0.4) + self.size_px = _rng_range(d.get("size_px"), 4, 64, (10, 22)) + self.fall_speed = _rng_range(d.get("fall_speed"), 0.005, 0.5, (0.05, 0.10)) + self.sway_amp = _rng_range(d.get("sway_amp"), 0.0, 0.08, (0.004, 0.015)) + self.sway_freq_hz = _rng_range(d.get("sway_freq_hz"), 0.02, 2.0, (0.15, 0.5)) + self.spin_dps = _rng_range(d.get("spin_dps"), -360, 360, (0.0, 0.0)) + self.tints = [_resolve_tint(t, colors) for t in (d.get("tints") or ["#FFFFFF"])][:8] or [(255, 255, 255)] + self.minimal_alpha = int(_clampf(d.get("minimal_alpha", 225), 0, 255, 225)) + self.camera_alpha = int(_clampf(d.get("camera_alpha", 110), 0, 255, 110)) + self.speed_scale = _clampf(d.get("speed_scale", 0.5), 0.0, 2.0, 0.5) + self.depth_split = _clampf(d.get("depth_split", 0.6), 0.0, 1.0, 0.6) + self.blend_additive = d.get("blend") == "additive" + # per-particle state: [x, y, size, speed, phase, spin_dps, angle, tint_idx, seed] + self.particles: list[list[float]] = [] + + def seed(self, rng): + self.particles = [] + for _ in range(self.count): + self.particles.append([ + rng.random(), rng.random(), + rng.uniform(*self.size_px), + rng.uniform(*self.fall_speed), + rng.uniform(0.0, 2.0 * math.pi), + rng.uniform(*self.spin_dps), + rng.uniform(0.0, 360.0), + rng.randrange(len(self.tints)), + rng.random(), + ]) + + def step(self, dt, speed_mult): + two_pi = 2.0 * math.pi + for p in self.particles: + freq = self.sway_freq_hz[0] + (self.sway_freq_hz[1] - self.sway_freq_hz[0]) * p[8] + p[4] = (p[4] + two_pi * freq * dt) % two_pi + p[6] = (p[6] + p[5] * dt) % 360.0 + v = p[3] * speed_mult + if self.mode == "fall": + p[1] += v * dt + if p[1] > 1.06: + p[1] -= 1.12 + elif self.mode == "rise": + p[1] -= v * dt + if p[1] < -0.06: + p[1] += 1.12 + elif self.mode == "flutter": + p[0] += v * dt * (1.0 if p[8] > 0.5 else -1.0) + if p[0] > 1.08: + p[0] -= 1.16 + elif p[0] < -0.08: + p[0] += 1.16 + else: # wander + p[0] += math.cos(p[4] * 0.7) * v * dt * 0.6 + p[1] += math.sin(p[4]) * v * dt * 0.45 + p[0] = p[0] % 1.0 + p[1] = p[1] % 1.0 + + +class _BurstLayer: + """Firework bursts: periodic radial explosions from random points in a region.""" + + def __init__(self, d, colors): + self.period_s = _rng_range(d.get("period_s"), 0.4, 10.0, (1.5, 3.0)) + self.per_burst = int(_clampf(d.get("particles_per_burst"), 6, 32, 24)) + self.radial_speed = _rng_range(d.get("radial_speed"), 0.02, 0.6, (0.10, 0.22)) + self.gravity = _clampf(d.get("gravity", 0.12), 0.0, 1.0, 0.12) + self.fade_s = _rng_range(d.get("fade_s"), 0.3, 4.0, (1.0, 1.6)) + r = d.get("region") or [0.1, 0.05, 0.8, 0.35] + self.region = tuple(_clampf(r[i] if isinstance(r, (list, tuple)) and len(r) == 4 else None, + 0.0, 1.0, (0.1, 0.05, 0.8, 0.35)[i]) for i in range(4)) + self.tints = [_resolve_tint(t, colors) for t in (d.get("tints") or ["#FFD700", "#FFFFFF"])][:8] + self.minimal_alpha = int(_clampf(d.get("minimal_alpha", 235), 0, 255, 235)) + self.camera_alpha = int(_clampf(d.get("camera_alpha", 100), 0, 255, 100)) + self.blend_additive = d.get("blend", "additive") == "additive" + self.camera_count_scale = _clampf(d.get("camera_count_scale", 0.5), 0.0, 1.0, 0.5) + # pool entries: [x, y, vx, vy, age, ttl, tint_idx, size] + self.pool: list[list[float]] = [] + self._next_burst = 0.0 + + def seed(self, rng): + self.pool = [] + self._next_burst = rng.uniform(*self.period_s) * 0.5 + + def step(self, dt, rng, minimal): + self._next_burst -= dt + if self._next_burst <= 0.0: + self._next_burst = rng.uniform(*self.period_s) + n = self.per_burst if minimal else max(4, int(self.per_burst * self.camera_count_scale)) + if len(self.pool) + n <= MAX_BURST_POOL: + cx = self.region[0] + rng.random() * self.region[2] + cy = self.region[1] + rng.random() * self.region[3] + tint = rng.randrange(len(self.tints)) + for _ in range(n): + ang = rng.uniform(0.0, 2.0 * math.pi) + spd = rng.uniform(*self.radial_speed) + self.pool.append([cx, cy, math.cos(ang) * spd, math.sin(ang) * spd, + 0.0, rng.uniform(*self.fade_s), tint, + rng.uniform(14.0, 26.0)]) + alive = [] + for p in self.pool: + p[4] += dt + if p[4] < p[5]: + p[0] += p[2] * dt + p[3] += self.gravity * dt + p[1] += p[3] * dt + alive.append(p) + self.pool = alive + + +class _BackdropLayer: + """Horizon scenery: a tinted silhouette band + optional moon/sun + optional rainbow.""" + + def __init__(self, d, colors): + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import BACKDROP_STYLES + self.style = str(d.get("style", "hills")) + if self.style not in BACKDROP_STYLES: + raise ValueError(f"unknown backdrop style {self.style!r}") + self.horizon_frac = _clampf(d.get("horizon_frac", 0.46), 0.20, 0.75, 0.46) + self.height_frac = _clampf(d.get("height_frac", 0.16), 0.05, 0.40, 0.16) + self.tint = _resolve_tint(d.get("tint", "#404040"), colors) + self.alpha_minimal = int(_clampf(d.get("alpha_minimal", 235), 0, 255, 235)) + self.alpha_camera = int(_clampf(d.get("alpha_camera", 70), 0, 255, 70)) + self.celestial = d.get("celestial") if d.get("celestial") in ("moon", "sun") else None + cp = d.get("celestial_pos") or [0.78, 0.16] + self.celestial_pos = (_clampf(cp[0] if len(cp) == 2 else None, 0.0, 1.0, 0.78), + _clampf(cp[1] if len(cp) == 2 else None, 0.0, 1.0, 0.16)) + self.celestial_size_frac = _clampf(d.get("celestial_size_frac", 0.15), 0.04, 0.4, 0.15) + self.celestial_tint = _resolve_tint(d.get("celestial_tint", "#FFFFFF"), colors) + self.rainbow = bool(d.get("rainbow", False)) + + +class _BallDropLayer: + """Times-Square-style drop: a glowing faceted ball slides down its pole on a loop, + dwelling at the bottom with a celebratory pulse before resetting.""" + + def __init__(self, d, colors): + self.x_frac = _clampf(d.get("x_frac", 0.5), 0.05, 0.95, 0.5) + self.top_frac = _clampf(d.get("top_frac", 0.08), 0.0, 0.9, 0.08) + self.bottom_frac = _clampf(d.get("bottom_frac", 0.34), 0.05, 0.95, 0.34) + self.period_s = _clampf(d.get("period_s", 24.0), 6.0, 120.0, 24.0) + self.ball_frac = _clampf(d.get("ball_frac", 0.085), 0.02, 0.2, 0.085) + self.tint = _resolve_tint(d.get("tint", "#FFE066"), colors) + self.pole_tint = _resolve_tint(d.get("pole_tint", "#9AA4B8"), colors) + self.alpha_minimal = int(_clampf(d.get("alpha_minimal", 235), 0, 255, 235)) + self.alpha_camera = int(_clampf(d.get("alpha_camera", 90), 0, 255, 90)) + self.hold = 0.22 # tail fraction of the loop spent glowing at the bottom + self.phase = 0.0 + + def step(self, dt): + self.phase = (self.phase + dt / self.period_s) % 1.0 + + +class _HeroLayer: + """The pack's mascot 'driving along' — rad-racer-car equivalent for scene packs. + Sits above the torque bar (y_frac default keeps it clear on both layouts), bobs + gently, and leans/bobs faster with speed.""" + + def __init__(self, d, colors): + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import SPRITE_NAMES + self.sprite = str(d.get("sprite", "")) + if self.sprite not in SPRITE_NAMES: + raise ValueError(f"unknown sprite {self.sprite!r}") + self.x_frac = _clampf(d.get("x_frac", 0.50), 0.05, 0.95, 0.50) + self.y_frac = _clampf(d.get("y_frac", 0.72), 0.10, 0.88, 0.72) + self.size_frac = _clampf(d.get("size_frac", 0.15), 0.05, 0.30, 0.15) + self.tint = _resolve_tint(d.get("tint", "#FFFFFF"), colors) + self.alpha_minimal = int(_clampf(d.get("alpha_minimal", 235), 0, 255, 235)) + self.alpha_camera = int(_clampf(d.get("alpha_camera", 120), 0, 255, 120)) + self.phase = 0.0 + + def step(self, dt, speed_norm): + self.phase += dt * (1.1 + 1.3 * speed_norm) * 2.0 * math.pi * 0.35 + + +class _DecorLayer: + """Static (or gently idling) decoration: tiled edge sprites or corner pieces.""" + + def __init__(self, d, colors): + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import SPRITE_NAMES + self.sprite = str(d.get("sprite", "")) + if self.sprite not in SPRITE_NAMES: + raise ValueError(f"unknown sprite {self.sprite!r}") + self.anchor = str(d.get("anchor", "top_edge")) + if self.anchor not in _ANCHORS: + raise ValueError(f"unknown anchor {self.anchor!r}") + self.size_frac = _clampf(d.get("size_frac", 0.04), 0.01, 0.20, 0.04) + self.spacing_frac = _clampf(d.get("spacing_frac", 0.06), 0.02, 0.5, 0.06) + self.droop_frac = _clampf(d.get("droop_frac", 0.025), 0.0, 0.12, 0.025) + self.count = int(_clampf(d.get("count", 1), 1, MAX_DECOR, 1)) + self.tints = [_resolve_tint(t, colors) for t in (d.get("tints") or ["#FFFFFF"])][:8] + self.alpha_minimal = int(_clampf(d.get("alpha_minimal", 220), 0, 255, 220)) + self.alpha_camera = int(_clampf(d.get("alpha_camera", 90), 0, 255, 90)) + self.idle = str(d.get("idle", "none")) + if self.idle not in _IDLES: + self.idle = "none" + self.phase = 0.0 + + def step(self, dt): + self.phase = (self.phase + dt) % 1000.0 + + +class SceneSpec: + def __init__(self, sky_tint_alpha, particles, bursts, decor, fg, backdrops=None, balldrops=None, heroes=None): + self.sky_camera_tint_alpha = sky_tint_alpha + self.particles = particles + self.bursts = bursts + self.decor = decor + self.backdrops = backdrops or [] + self.balldrops = balldrops or [] + self.heroes = heroes or [] + self.particles_over_hud = bool(fg.get("particles_over_hud", True)) + self.corner_accents = bool(fg.get("corner_accents", False)) + self.corner_alpha = int(_clampf(fg.get("corner_alpha", 55), 0, 160, 55)) + + +def load_scene_spec(path, colors=None) -> SceneSpec | None: + """Parse + validate a scene.json. Returns None (colors-only pack) on any file-level + problem; individual invalid layers are dropped.""" + colors = colors or {} + try: + with open(path) as f: + d = json.load(f) + if int(d.get("version", 0)) != 1: + return None + except (OSError, ValueError, TypeError): + return None + + sky = d.get("sky") or {} + sky_tint = int(_clampf(sky.get("camera_tint_alpha", 40), 0, 120, 40)) + particles, bursts, decor, backdrops, balldrops, heroes = [], [], [], [], [], [] + total = 0 + for layer in (d.get("layers") or []): + if not isinstance(layer, dict): + continue + try: + t = layer.get("type", "particles") + if t == "particles": + pl = _ParticleLayer(layer, colors) + if total + pl.count > MAX_PARTICLES_MINIMAL: + pl.count = max(0, MAX_PARTICLES_MINIMAL - total) + total += pl.count + if pl.count > 0: + particles.append(pl) + elif t == "burst": + if len(bursts) < 2: + bursts.append(_BurstLayer(layer, colors)) + elif t == "decor": + if len(decor) < 4: + decor.append(_DecorLayer(layer, colors)) + elif t == "backdrop": + if len(backdrops) < 2: + backdrops.append(_BackdropLayer(layer, colors)) + elif t == "balldrop": + if len(balldrops) < 1: + balldrops.append(_BallDropLayer(layer, colors)) + elif t == "hero": + if len(heroes) < 1: + heroes.append(_HeroLayer(layer, colors)) + except (ValueError, TypeError, KeyError): + continue # drop invalid layer, keep the rest + return SceneSpec(sky_tint, particles, bursts, decor, d.get("foreground") or {}, backdrops, balldrops, heroes) + + +# ---------------------------------------------------------------------------- scene kinds +class SceneBase: + def replaces_road(self) -> bool: + return False + + def replaces_hud(self) -> bool: + return False + + def draw_background(self, rect, minimal: bool) -> None: + pass + + def draw_foreground(self, rect, minimal: bool) -> None: + pass + + +class RadRacerScene(SceneBase): + """Capability adapter for the built-in 8-Bit Racer code theme. Drawing stays in + rad_racer_theme.py / the host views; this object routes the dispatch decisions + that used to be ad-hoc `rad_racer_active()` checks.""" + + def replaces_road(self) -> bool: + return True + + def replaces_hud(self) -> bool: + return True # TICI renders its full scene; MICI hosts draw inline as before + + +class PackScene(SceneBase): + def __init__(self, pack, spec: SceneSpec): + self.pack = pack + self.spec = spec + self._rng = random.Random(zlib.crc32(pack.name.encode())) + for layer in spec.particles: + layer.seed(self._rng) + for b in spec.bursts: + b.seed(self._rng) + self._stepped_this_frame = False + + # ---------------- sim + def _v_ego(self) -> float: + try: + from openpilot.selfdrive.ui.ui_state import ui_state + if ui_state.sm is not None and ui_state.sm.valid.get('carState', False): + return max(0.0, float(ui_state.sm['carState'].vEgo)) + except Exception: + pass + return 0.0 + + def _step(self, minimal: bool): + from openpilot.system.ui.lib.application import gui_app + dt = 1.0 / max(1, gui_app.target_fps) + speed_mult = 1.0 + v = self._v_ego() + for layer in self.spec.particles: + m = 1.0 + layer.speed_scale * min(1.0, v / _SPEED_REF_MS) + layer.step(dt, m * speed_mult) + for b in self.spec.bursts: + b.step(dt, self._rng, minimal) + for dl in self.spec.decor: + dl.step(dt) + for bd in self.spec.balldrops: + bd.step(dt) + for h in self.spec.heroes: + h.step(dt, min(1.0, v / _SPEED_REF_MS)) + + # ---------------- draw helpers + def _draw_particles(self, rect, minimal: bool, front_pass: bool): + import pyray as rl + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import build_sprite + scale = rect.height / 1080.0 + for layer in self.spec.particles: + n_total = layer.count if minimal else max(1, int(layer.count * layer.camera_count_scale)) + split = int(round(n_total * layer.depth_split)) + lo, hi = (split, n_total) if front_pass else (0, split) + if lo >= hi: + continue + alpha = layer.minimal_alpha if minimal else layer.camera_alpha + tex = build_sprite(layer.sprite) + tex2 = build_sprite("bat2") if layer.sprite == "bat" else None + if layer.blend_additive: + rl.begin_blend_mode(rl.BlendMode.BLEND_ADDITIVE) + src = rl.Rectangle(0, 0, tex.width, tex.height) + for p in layer.particles[lo:hi]: + sway = math.sin(p[4]) * (layer.sway_amp[0] + (layer.sway_amp[1] - layer.sway_amp[0]) * p[8]) + x = rect.x + ((p[0] + sway) % 1.0) * rect.width + y = rect.y + p[1] * rect.height + # back-pass particles render smaller + dimmer for depth + size = p[2] * scale * (1.0 if front_pass else 0.72) + a = int(alpha * (1.0 if front_pass else 0.7)) + tint = layer.tints[int(p[7])] + t = tex2 if (tex2 is not None and p[4] > math.pi) else tex + dst = rl.Rectangle(x, y, size, size) + rl.draw_texture_pro(t, src, dst, rl.Vector2(size / 2, size / 2), p[6], + rl.Color(tint[0], tint[1], tint[2], a)) + if layer.blend_additive: + rl.end_blend_mode() + + def _draw_bursts(self, rect, minimal: bool): + import pyray as rl + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import build_sprite + scale = rect.height / 1080.0 + for b in self.spec.bursts: + if not b.pool: + continue + alpha = b.minimal_alpha if minimal else b.camera_alpha + tex = build_sprite("spark") + src = rl.Rectangle(0, 0, tex.width, tex.height) + if b.blend_additive: + rl.begin_blend_mode(rl.BlendMode.BLEND_ADDITIVE) + for p in b.pool: + fade = max(0.0, 1.0 - p[4] / p[5]) + a = int(alpha * fade * fade) + if a <= 2: + continue + size = p[7] * scale * (0.6 + 0.4 * fade) + tint = b.tints[int(p[6]) % len(b.tints)] + dst = rl.Rectangle(rect.x + p[0] * rect.width, rect.y + p[1] * rect.height, size, size) + rl.draw_texture_pro(tex, src, dst, rl.Vector2(size / 2, size / 2), 0.0, + rl.Color(tint[0], tint[1], tint[2], a)) + if b.blend_additive: + rl.end_blend_mode() + + def _draw_decor(self, rect, minimal: bool): + import pyray as rl + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import build_sprite + for dl in self.spec.decor: + alpha = dl.alpha_minimal if minimal else dl.alpha_camera + tex = build_sprite(dl.sprite) + src = rl.Rectangle(0, 0, tex.width, tex.height) + size = dl.size_frac * rect.height + idle_a = 1.0 + if dl.idle == "pulse": + idle_a = 0.75 + 0.25 * math.sin(dl.phase * 2.0) + if dl.anchor == "top_edge": + # tiled along the top with a gentle catenary droop; twinkle alternates brightness + n = max(2, int(rect.width / (dl.spacing_frac * rect.width))) + for i in range(n + 1): + fx = i / n + droop = math.sin(fx * math.pi) * dl.droop_frac + x = rect.x + fx * rect.width + y = rect.y + droop * rect.height + size * 0.4 + a = alpha * idle_a + if dl.idle == "twinkle": + a *= 0.55 + 0.45 * math.sin(dl.phase * 3.0 + i * 1.7) ** 2 + tint = dl.tints[i % len(dl.tints)] + rot = math.degrees(math.atan2(math.cos(fx * math.pi) * dl.droop_frac * math.pi, 1.0 / max(n, 1))) if dl.sprite == "garland" else 0.0 + dst = rl.Rectangle(x, y, size, size) + rl.draw_texture_pro(tex, src, dst, rl.Vector2(size / 2, size / 2), rot, + rl.Color(tint[0], tint[1], tint[2], int(a))) + else: + corner_size = size * 2.2 + margin = corner_size * 0.42 + pos = { + "top_left": (rect.x + margin, rect.y + margin, -18.0), + "top_right": (rect.x + rect.width - margin, rect.y + margin, 18.0), + "bottom_left": (rect.x + margin, rect.y + rect.height - margin, 195.0), + "bottom_right": (rect.x + rect.width - margin, rect.y + rect.height - margin, 165.0), + }[dl.anchor] + tint = dl.tints[0] + dst = rl.Rectangle(pos[0], pos[1], corner_size, corner_size) + rl.draw_texture_pro(tex, src, dst, rl.Vector2(corner_size / 2, corner_size / 2), pos[2], + rl.Color(tint[0], tint[1], tint[2], int(alpha * idle_a))) + + def _draw_backdrops(self, rect, minimal: bool): + import pyray as rl + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import ( + build_backdrop, build_celestial, build_rainbow) + for b in self.spec.backdrops: + alpha = b.alpha_minimal if minimal else b.alpha_camera + if alpha <= 0: + continue + if b.rainbow: + tex = build_rainbow() + w = rect.width * 0.62 + h = w * tex.height / tex.width + dst = rl.Rectangle(rect.x + rect.width * 0.5 - w / 2, + rect.y + rect.height * b.horizon_frac - h * 0.92, w, h) + rl.draw_texture_pro(tex, rl.Rectangle(0, 0, tex.width, tex.height), dst, + rl.Vector2(0, 0), 0.0, rl.Color(255, 255, 255, int(alpha * 0.8))) + if b.celestial is not None: + ctex = build_celestial(b.celestial) + cs = rect.height * b.celestial_size_frac + cx = rect.x + rect.width * b.celestial_pos[0] - cs / 2 + cy = rect.y + rect.height * b.celestial_pos[1] - cs / 2 + ct = b.celestial_tint + rl.draw_texture_pro(ctex, rl.Rectangle(0, 0, ctex.width, ctex.height), + rl.Rectangle(cx, cy, cs, cs), rl.Vector2(0, 0), 0.0, + rl.Color(ct[0], ct[1], ct[2], alpha)) + tex = build_backdrop(b.style) + band_h = rect.height * b.height_frac + band_y = rect.y + rect.height * b.horizon_frac - band_h + tile_w = band_h * tex.width / tex.height + t = b.tint + col = rl.Color(t[0], t[1], t[2], alpha) + x = rect.x + while x < rect.x + rect.width: + w = min(tile_w, rect.x + rect.width - x) + src_w = tex.width * (w / tile_w) + rl.draw_texture_pro(tex, rl.Rectangle(0, 0, src_w, tex.height), + rl.Rectangle(x, band_y, w, band_h), rl.Vector2(0, 0), 0.0, col) + x += tile_w + + def _draw_balldrops(self, rect, minimal: bool): + import pyray as rl + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import build_sprite + for bd in self.spec.balldrops: + alpha = bd.alpha_minimal if minimal else bd.alpha_camera + if alpha <= 0: + continue + drop_end = 1.0 - bd.hold + t = min(bd.phase / drop_end, 1.0) # constant slow descent, then dwell + holding = bd.phase >= drop_end + y_frac = bd.top_frac + (bd.bottom_frac - bd.top_frac) * t + px = rect.x + rect.width * bd.x_frac + y_top = rect.y + rect.height * bd.top_frac + y_bot = rect.y + rect.height * bd.bottom_frac + size = rect.height * bd.ball_frac + pt = bd.pole_tint + pole_w = max(2.0, size * 0.07) + rl.draw_rectangle_rounded(rl.Rectangle(px - pole_w / 2, y_top - size * 0.55, + pole_w, y_bot - y_top + size * 0.9), + 1.0, 4, rl.Color(pt[0], pt[1], pt[2], int(alpha * 0.8))) + rl.draw_rectangle(int(px - size * 0.5), int(y_top - size * 0.62), int(size), max(2, int(pole_w * 0.7)), + rl.Color(pt[0], pt[1], pt[2], int(alpha * 0.8))) # finial crossbar + cy = rect.y + rect.height * y_frac + tex = build_sprite("orb") + src = rl.Rectangle(0, 0, tex.width, tex.height) + tint = bd.tint + pulse = 1.0 + if holding: + pulse = 1.0 + 0.35 * math.sin((bd.phase - drop_end) / bd.hold * math.pi) \ + * (0.6 + 0.4 * math.sin(bd.phase * 80.0)) + glow = size * 2.1 * pulse + rl.begin_blend_mode(rl.BlendMode.BLEND_ADDITIVE) + rl.draw_texture_pro(tex, src, rl.Rectangle(px, cy, glow, glow), + rl.Vector2(glow / 2, glow / 2), 0.0, + rl.Color(tint[0], tint[1], tint[2], int(alpha * 0.30))) + rl.end_blend_mode() + rl.draw_texture_pro(tex, src, rl.Rectangle(px, cy, size, size), + rl.Vector2(size / 2, size / 2), 0.0, + rl.Color(tint[0], tint[1], tint[2], alpha)) + + def _draw_corner_accents(self, rect, minimal: bool): + import pyray as rl + colors = self.pack.rl_colors() + acc = colors.get("Accent") or colors.get("Background") + if acc is None: + return + a = self.spec.corner_alpha if minimal else int(self.spec.corner_alpha * 0.55) + w = rect.width * 0.13 + h = rect.height * 0.10 + c = rl.Color(acc.r, acc.g, acc.b, a) + fade = rl.Color(acc.r, acc.g, acc.b, 0) + x0, y0, x1 = rect.x, rect.y, rect.x + rect.width + rl.draw_triangle(rl.Vector2(x0, y0), rl.Vector2(x0, y0 + h), rl.Vector2(x0 + w, y0), c) + rl.draw_triangle(rl.Vector2(x0 + w, y0), rl.Vector2(x0, y0 + h), rl.Vector2(x0 + w * 1.6, y0 + h * 0.35), fade) + rl.draw_triangle(rl.Vector2(x1, y0), rl.Vector2(x1 - w, y0), rl.Vector2(x1, y0 + h), c) + rl.draw_triangle(rl.Vector2(x1 - w, y0), rl.Vector2(x1 - w * 1.6, y0 + h * 0.35), rl.Vector2(x1, y0 + h), fade) + + # ---------------- passes + def draw_background(self, rect, minimal: bool) -> None: + import pyray as rl + bg = self.pack.rl_colors().get("Background") + if bg is not None: + if minimal: + bottom = rl.Color(int(bg.r * 0.12), int(bg.g * 0.12), int(bg.b * 0.12), 255) + rl.draw_rectangle_gradient_v(int(rect.x), int(rect.y), int(rect.width), int(rect.height), bg, bottom) + elif self.spec.sky_camera_tint_alpha > 0: + rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), + rl.Color(bg.r, bg.g, bg.b, self.spec.sky_camera_tint_alpha)) + self._draw_backdrops(rect, minimal) + self._draw_balldrops(rect, minimal) + self._step(minimal) + self._draw_particles(rect, minimal, front_pass=False) + + def _draw_heroes(self, rect, minimal: bool): + import pyray as rl + from openpilot.selfdrive.ui.bp.lib.theme_scene_assets import build_sprite + for h in self.spec.heroes: + alpha = h.alpha_minimal if minimal else h.alpha_camera + if alpha <= 0: + continue + tex = build_sprite(h.sprite) + size = rect.height * h.size_frac + y_scale = getattr(self, "hero_y_scale", 1.0) # hosts lower this to clear their chrome + x = rect.x + rect.width * h.x_frac # stays centered — no lateral wander + y = rect.y + rect.height * h.y_frac * y_scale + size * 0.04 * math.sin(h.phase) + rot = 2.5 * math.sin(h.phase * 0.71) + t = h.tint + rl.draw_texture_pro(tex, rl.Rectangle(0, 0, tex.width, tex.height), + rl.Rectangle(x, y, size, size), rl.Vector2(size / 2, size / 2), rot, + rl.Color(t[0], t[1], t[2], alpha)) + + def draw_foreground(self, rect, minimal: bool) -> None: + if self.spec.corner_accents: + self._draw_corner_accents(rect, minimal) + self._draw_decor(rect, minimal) + self._draw_heroes(rect, minimal) + if self.spec.particles_over_hud: + self._draw_particles(rect, minimal, front_pass=True) + self._draw_bursts(rect, minimal) + + +# ---------------------------------------------------------------------------- dispatcher +_scene_cache: dict = {"key": None, "scene": None, "checked_at": 0.0} +_POLL_S = 2.0 + + +def _default_spec() -> SceneSpec: + """Colors-only packs (no scene.json): behave exactly like the pre-scene loader — + gradient sky in Minimal Driving View, nothing over camera, no layers.""" + return SceneSpec(0, [], [], [], {"particles_over_hud": False, "corner_accents": False}) + + +def active_scene() -> SceneBase | None: + """The scene for the current theme selection, or None. Cheap per-frame call: the + selection is re-checked at most every _POLL_S; scenes are rebuilt (and reseeded) + only when the selection changes.""" + import time + now = time.monotonic() + if now - _scene_cache["checked_at"] < _POLL_S: + return _scene_cache["scene"] + _scene_cache["checked_at"] = now + + if theme_pack.rad_racer_active(): + key = theme_pack.RAD_RACER + else: + pack = theme_pack.get_active_pack() + key = pack.name if pack else None + + if key != _scene_cache["key"]: + _scene_cache["key"] = key + scene = None + if key == theme_pack.RAD_RACER: + scene = RadRacerScene() + elif key is not None: + pack = theme_pack.get_active_pack() + if pack is not None: + spec = load_scene_spec(os.path.join(pack.root, "scene.json"), pack.colors) + scene = PackScene(pack, spec if spec is not None else _default_spec()) + _scene_cache["scene"] = scene + return _scene_cache["scene"] diff --git a/selfdrive/ui/bp/lib/theme_scene_assets.py b/selfdrive/ui/bp/lib/theme_scene_assets.py new file mode 100644 index 0000000000..b2e8e052ce --- /dev/null +++ b/selfdrive/ui/bp/lib/theme_scene_assets.py @@ -0,0 +1,1080 @@ +"""BluePilot: procedurally rendered sprites for theme-pack scenes. + +Every sprite is generated from 2D signed-distance fields in numpy at load time — +no bundled images, smooth antialiased edges, proper shading. Most sprites render +GRAYSCALE + alpha so a single texture serves every pack (per-particle color via +draw_texture_pro tint); a few sprites (garland, poppy, flags, the hero mascots) +bake their own colors because they are multi-color by nature. + +The field math is GL-free and unit-testable (render_sprite returns numpy arrays); +only build_sprite touches pyray, uploading via an in-memory PNG. +""" +import math +import struct +import zlib +from functools import lru_cache + +import numpy as np + +SPRITE_SIZE = 96 # default raster size (px); crisp down to ~14 px on screen +_EDGE = 1.5 # antialias edge width in pixels + + +# ---------------------------------------------------------------------------- field helpers +def _grid(size: int): + """Coordinate grid in [-1, 1] x [-1, 1], y increasing downward.""" + ax = np.linspace(-1.0, 1.0, size, dtype=np.float32) + x, y = np.meshgrid(ax, ax) + return x, y + + +def _aa(sdf: np.ndarray, size: int) -> np.ndarray: + """Signed distance -> coverage alpha [0..1] with a smooth edge.""" + px = 2.0 / size + return np.clip(0.5 - sdf / (_EDGE * px * 2.0), 0.0, 1.0).astype(np.float32) + + +def _circle(x, y, cx, cy, r): + return np.hypot(x - cx, y - cy) - r + + +def _ellipse(x, y, cx, cy, rx, ry): + # scaled-space approximation; adequate for shading/AA at sprite scale + return (np.hypot((x - cx) / rx, (y - cy) / ry) - 1.0) * min(rx, ry) + + +def _segment(x, y, ax_, ay, bx, by, w): + px, py = x - ax_, y - ay + bx_, by_ = bx - ax_, by - ay + h = np.clip((px * bx_ + py * by_) / (bx_ * bx_ + by_ * by_ + 1e-9), 0.0, 1.0) + return np.hypot(px - bx_ * h, py - by_ * h) - w + + +def _rot(x, y, ang): + c, s = math.cos(ang), math.sin(ang) + return c * x - s * y, s * x + c * y + + +def _smin(a, b, k=0.06): + """Smooth union — organic blends between lobes.""" + h = np.clip(0.5 + 0.5 * (b - a) / k, 0.0, 1.0) + return b + (a - b) * h - k * h * (1.0 - h) + + +# ---------------------------------------------------------------------------- sprites +def _snowflake(size: int, seed: int = 0): + """Six-fold dendrite: folded polar space -> one stem + angled side branches.""" + x, y = _grid(size) + r = np.hypot(x, y) + ang = np.arctan2(y, x) + # fold into one 30-degree sector (6-fold + mirror symmetry) + sector = math.pi / 3.0 + a = np.abs(((ang + sector / 2.0) % sector) - sector / 2.0) + fx, fy = r * np.cos(a), r * np.sin(a) + + rng = np.random.default_rng(11 + seed) + # strokes sized to survive 20-40px on-screen draws — thin dendrites vanish there + stem_w = 0.060 + 0.010 * rng.random() + d = _segment(fx, fy, 0.02, 0.0, 0.92, 0.0, stem_w) + # side branches: at each radius, a V pair angled forward, shrinking outward + for br in (0.30, 0.52, 0.72): + ln = (0.30 - 0.22 * br) * (0.9 + 0.2 * rng.random()) + bx = br + ln * math.cos(1.05) + by = ln * math.sin(1.05) + d = np.minimum(d, _segment(fx, fy, br, 0.0, bx, by, stem_w * 0.85)) + d = np.minimum(d, _circle(fx, fy, 0.0, 0.0, 0.13)) # hub + d = np.minimum(d, _circle(fx, fy, 0.92, 0.0, 0.075)) # tip crystal + alpha = _aa(d, size) + # crystalline shading: brighter core, faint sparkle at hub/tips + v = 0.82 + 0.18 * np.clip(1.0 - r, 0.0, 1.0) + return v, alpha + + +def _heart(size: int): + """Classic implicit heart, glossy: rim shade + upper-left light.""" + x, y = _grid(size) + hx, hy = x * 1.25, -y * 1.25 + 0.30 + f = (hx * hx + hy * hy - 0.75) ** 3 - hx * hx * hy * hy * hy + # convert implicit value to a pseudo-distance for AA + d = f / (np.abs(np.gradient(f)[0]) + np.abs(np.gradient(f)[1]) + 1e-6) * (2.0 / size) + alpha = _aa(d.astype(np.float32), size) + light = np.clip(0.85 - 0.35 * np.hypot(x + 0.35, y + 0.35), 0.35, 1.0) + rim = np.clip(-d * size * 0.5, 0.0, 1.0) # darker just inside the edge + v = np.clip(light * (0.75 + 0.25 * rim), 0.0, 1.0) + # specular dot + v = np.maximum(v, np.clip(1.0 - np.hypot((x + 0.30) * 4.0, (y + 0.42) * 4.0), 0.0, 1.0)) + return v.astype(np.float32), alpha + + +def _vesica(x, y, width, length): + """Leaf/petal body: intersection of two offset circles, stretched.""" + r = (width * width + length * length) / (2.0 * width) + return np.maximum(_circle(x, y, r - width, 0.0, r), _circle(x, y, -(r - width), 0.0, r)) + + +def _leaf(size: int): + """Pointed leaf: vesica body tip-up, midrib to the tip, veins clipped inside, + stem attached at the base and flowing into the midrib.""" + x, y = _grid(size) + lx, ly = _rot(x, y, -0.30) + body = _vesica((ly + 0.05) * 1.05, lx * 1.05, 0.36, 0.80) * 0.9 + stem = _segment(lx, ly, 0.0, 0.72, -0.06, 1.00, 0.044) + stem = np.minimum(stem, _segment(lx, ly, 0.0, 0.72, 0.0, 0.55, 0.044)) + alpha = np.maximum(_aa(body, size), _aa(stem, size)) + inside = body < 0 + v = 0.58 + 0.42 * np.clip(0.5 - ly * 0.65, 0.0, 1.0) + rib = _segment(lx, ly, 0.0, -0.78, 0.0, 0.72, 0.020) + v = np.where((rib < 0) & inside, v * 0.52, v) + for t, ln in ((-0.50, 0.20), (-0.18, 0.28), (0.16, 0.30), (0.45, 0.24)): + for sgn in (-1.0, 1.0): + vein = _segment(lx, ly, 0.0, t, sgn * ln, t - ln * 0.9, 0.014) + v = np.where((vein < 0) & inside, v * 0.70, v) + v = np.where(stem < 0, 0.42, v) + return v.astype(np.float32), alpha + + +def _petal(size: int): + x, y = _grid(size) + bend = 0.18 * (y * y - 0.3) # soft banana curve + body = _vesica((y) * 1.05, (x - bend) * 1.05, 0.30, 0.82) * 0.9 + alpha = _aa(body, size) + v = 0.66 + 0.34 * np.clip(0.55 - y * 0.75, 0.0, 1.0) + edge_hi = np.clip(-body * size * 0.25, 0.0, 1.0) + v = np.clip(v * (0.85 + 0.15 * edge_hi), 0.0, 1.0) + crease = _segment(x - bend, y, 0.0, -0.75, 0.0, 0.65, 0.016) + v = np.where(crease < 0, v * 0.82, v) + return v.astype(np.float32), alpha + + +def _clover(size: int): + """Three heart-shaped leaflets 120 degrees apart, notched, stem attached.""" + x, y = _grid(size) + cy = y + 0.10 + d = None + for k in range(3): + lx, ly = _rot(x, cy, k * 2.0 * math.pi / 3.0) + lobe = _smin(_circle(lx, ly, -0.155, -0.40, 0.235), _circle(lx, ly, 0.155, -0.40, 0.235), 0.07) + notch = _segment(lx, ly, 0.0, -0.62, 0.0, -0.40, 0.045) # heart cleft + lobe = np.maximum(lobe, -notch) + d = lobe if d is None else np.minimum(d, lobe) + # stem starts INSIDE the lobe junction and arcs out + stem = np.minimum(_segment(x, cy, 0.0, -0.05, 0.06, 0.45, 0.048), + _segment(x, cy, 0.06, 0.45, 0.22, 0.80, 0.048)) + alpha = np.maximum(_aa(d, size), _aa(stem, size)) + v = np.full_like(x, 0.85) + for k in range(3): + lx, ly = _rot(x, cy, k * 2.0 * math.pi / 3.0) + crease = _segment(lx, ly, 0.0, -0.14, 0.0, -0.58, 0.011) + v = np.where((crease < 0) & (d < 0), v * 0.60, v) + v = np.where((stem < 0) & (d > 0), 0.55, v) + return v.astype(np.float32), alpha + + +def _bat(size: int, flap_up: bool): + """Iconic bat: two wing humps on top, scalloped trailing edge below, ears + head. + Frames differ by wing tilt about the shoulders.""" + x, y = _grid(size) + ax = np.abs(x) + tilt = -0.30 if flap_up else 0.18 + wx, wy = _rot(ax - 0.08, y, tilt) + # top humps (wing leading edge) + humps = np.minimum(_circle(wx, wy, 0.30, 0.02, 0.30), _circle(wx, wy, 0.64, 0.10, 0.22)) + # membrane fills down to a trailing edge... + membrane = np.maximum(humps - 0.10, wy - 0.30) + # ...scalloped by bites from below + for i, bx in enumerate((0.16, 0.46, 0.74)): + membrane = np.maximum(membrane, -_circle(wx, wy, bx, 0.46, 0.155 - 0.015 * i)) + membrane = np.maximum(membrane, wx - 0.90) + body = _ellipse(x, y, 0.0, 0.06, 0.115, 0.21) + head = _circle(x, y, 0.0, -0.16, 0.105) + # ears: short tapered strokes from the head top to points + ear = np.minimum(_segment(ax, y, 0.055, -0.22, 0.105, -0.385, 0.052), + _segment(ax, y, 0.075, -0.30, 0.105, -0.385, 0.030)) + d = np.minimum(np.minimum(membrane, body), np.minimum(head, ear)) + alpha = _aa(d.astype(np.float32), size) + v = np.full_like(x, 0.88) + for bx in (0.20, 0.50): + ridge = _segment(wx, wy, 0.04, 0.0, bx + 0.18, 0.34, 0.011) + v = np.where((ridge < 0) & (d < 0), v * 0.72, v) + return v.astype(np.float32), alpha + + +def _glow(size: int, core: float, falloff: float): + """Radial glow (firefly / spark core) — built for additive blending.""" + x, y = _grid(size) + r = np.hypot(x, y) + core_term = np.clip(1.0 - r / core, 0.0, 1.0) if core > 1e-6 else 0.0 + alpha = np.clip(np.exp(-(r / falloff) ** 2) + core_term, 0.0, 1.0) + return np.ones_like(x), alpha.astype(np.float32) + + +def _spark(size: int): + """Four-point star + glow.""" + x, y = _grid(size) + d = np.minimum(_segment(x, y, -0.7, 0.0, 0.7, 0.0, 0.045), + _segment(x, y, 0.0, -0.7, 0.0, 0.7, 0.045)) + dx, dy = _rot(x, y, math.pi / 4) + d = np.minimum(d, np.minimum(_segment(dx, dy, -0.4, 0.0, 0.4, 0.0, 0.03), + _segment(dx, dy, 0.0, -0.4, 0.0, 0.4, 0.03))) + star = _aa(d, size) + _, glow = _glow(size, 0.10, 0.45) + alpha = np.clip(star + glow * 0.6, 0.0, 1.0) + return np.ones_like(x), alpha.astype(np.float32) + + +def _bulb(size: int): + """String-light bulb hanging point-down: cap fused to the glass, specular + highlight, restrained halo.""" + x, y = _grid(size) + glass = _smin(_circle(x, y, 0.0, 0.20, 0.30), _segment(x, y, 0.0, -0.22, 0.0, 0.16, 0.14), 0.10) + cap = np.maximum(np.abs(x) - 0.15, np.abs(y + 0.30) - 0.13).astype(np.float32) + alpha = np.maximum(_aa(glass, size), _aa(cap, size)) + r_glow = np.hypot(x, (y - 0.14) * 0.9) + alpha = np.clip(alpha + np.exp(-(r_glow / 0.55) ** 2) * 0.30, 0.0, 1.0) + v = np.where(cap < 0, 0.32, 0.90) + ribs = np.abs(((y + 0.30) * 12.0) % 2.0 - 1.0) < 0.35 + v = np.where((cap < 0) & ribs, 0.45, v) + spec = np.clip(1.0 - np.hypot((x + 0.11) * 5.5, (y - 0.08) * 4.0), 0.0, 1.0) + v = np.clip(v + spec * 0.9, 0.0, 1.3) + return v.astype(np.float32), alpha + + +def _pennant(size: int): + """Crisp bunting triangle, bordered, subtle sheen.""" + x, y = _grid(size) + # triangle: top edge y=-0.8 between x±0.55, apex (0, 0.85) + d = np.maximum(y * 0 + (-0.8 - y), np.abs(x) * 1.55 + (y + 0.8) * 0.52 - 0.86) + d = np.maximum(d, -(0.85 - y)) + alpha = _aa(d.astype(np.float32), size) + v = 0.78 + 0.22 * np.clip(-x + 0.3, 0.0, 1.0) * 0.5 + border = np.clip(-d * size * 0.35, 0.0, 1.0) + v = np.clip(v * (0.7 + 0.3 * border), 0.0, 1.0) + return v.astype(np.float32), alpha + + +def _garland_tile(size: int): + """Colored: a lush horizontal pine sprig — dense two-row needle fan on a branch, + berry cluster with highlights — stamped along curves by the engine.""" + rng = np.random.default_rng(9) + x, y = _grid(size) + branch = _segment(x, y, -0.9, 0.02, 0.9, -0.02, 0.030) + d = branch.copy() + for i in range(34): + t = -0.85 + 1.7 * (i / 33.0) + side = 1.0 if i % 2 == 0 else -1.0 + ang = side * (1.05 + rng.uniform(-0.15, 0.15)) - 0.42 + ln = 0.30 + rng.uniform(-0.05, 0.07) + seg = _segment(x, y, t, 0.0, t + ln * 0.45, side * ln * 0.85, 0.030) + d = np.minimum(d, seg) + seg2 = _segment(x, y, t + 0.04, 0.0, t + 0.04 + ln * 0.55, side * ln * 0.6, 0.024) + d = np.minimum(d, seg2) + alpha = _aa(d, size) + rgb = np.zeros((size, size, 3), dtype=np.float32) + tone = (0.70 + 0.30 * rng.random((size, size))).astype(np.float32) + rgb[..., 0] = 0.14 * tone + rgb[..., 1] = 0.46 * tone + rgb[..., 2] = 0.20 * tone + rgb[branch < 0] = (0.24, 0.16, 0.08) + for bx, by in ((0.30, 0.06), (0.42, -0.08), (0.36, 0.20)): + berry = _circle(x, y, bx, by, 0.080) + rgb[berry < 0] = (0.80, 0.15, 0.15) + rgb[_circle(x, y, bx - 0.026, by - 0.026, 0.028) < 0] = (1.0, 0.58, 0.58) + alpha = np.maximum(alpha, _aa(berry, size)) + return rgb, alpha + + +def _tri_sdf(x, y, p0, p1, p2): + """Signed distance-ish field of a triangle (inside < 0). CCW vertex order.""" + d = None + pts = (p0, p1, p2) + for i in range(3): + a, b = pts[i], pts[(i + 1) % 3] + nx, ny = b[1] - a[1], -(b[0] - a[0]) + ln = math.hypot(nx, ny) or 1.0 + sd = (x - a[0]) * (nx / ln) + (y - a[1]) * (ny / ln) + d = sd if d is None else np.maximum(d, sd) + return d + + +def _star5(size: int): + """Five-point star, point up: union of 5 point-triangles over an inner pentagon.""" + x, y = _grid(size) + outer, inner = 0.88, 0.36 + op = [(outer * math.sin(a), -outer * math.cos(a)) for a in (i * 2 * math.pi / 5 for i in range(5))] + ip = [(inner * math.sin(a + math.pi / 5), -inner * math.cos(a + math.pi / 5)) for a in (i * 2 * math.pi / 5 for i in range(5))] + d = None + for i in range(5): + t = _tri_sdf(x, y, op[i], ip[i], ip[(i - 1) % 5]) + d = t if d is None else np.minimum(d, t) + pent = None # inner pentagon: intersection of its 5 edge half-planes + for i in range(5): + a, b = ip[i], ip[(i + 1) % 5] + nx, ny = b[1] - a[1], -(b[0] - a[0]) + ln = math.hypot(nx, ny) or 1.0 + sd = (x - a[0]) * (nx / ln) + (y - a[1]) * (ny / ln) + pent = sd if pent is None else np.maximum(pent, sd) + d = np.minimum(d, pent) + alpha = _aa(d, size) + # beveled shading: ridges from center to the points + r = np.hypot(x, y) + v = 0.78 + 0.22 * np.clip(1.0 - r * 0.9, 0.0, 1.0) + v = np.maximum(v, np.clip(1.0 - np.hypot((x + 0.2) * 3.0, (y + 0.28) * 3.0), 0.0, 1.0)) + return v.astype(np.float32), alpha + + +def _orb(size: int): + """Faceted glowing sphere — the New Year's drop ball. Tinted at draw time.""" + x, y = _grid(size) + r = np.hypot(x, y) + disc = _aa(r - 0.60, size) + glow = np.exp(-((r - 0.60).clip(0) / 0.30) ** 2) * 0.55 + alpha = np.clip(disc + glow, 0.0, 1.0) + lattice = 0.68 + 0.32 * (np.sin(x * 21.0) * np.sin(y * 21.0)) ** 2 + v = np.where(disc > 0.5, lattice * (1.0 - 0.25 * np.clip(r / 0.60, 0.0, 1.0) ** 2), 1.0) + v = np.maximum(v, np.clip(1.0 - np.hypot((x + 0.22) * 3.4, (y + 0.22) * 3.4), 0.0, 1.0)) + return v.astype(np.float32), alpha + + +def _poppy(size: int): + """Colored: remembrance poppy — four round overlapping scarlet petals, dark center.""" + x, y = _grid(size) + petals = None + edge = None + for k in range(4): + a = k * math.pi / 2 + math.pi / 4 + c = _circle(x, y, 0.36 * math.cos(a), 0.36 * math.sin(a), 0.44) + petals = c if petals is None else np.minimum(petals, c) + edge = np.abs(c) if edge is None else np.minimum(edge, np.abs(c)) + alpha = _aa(petals, size) + r = np.hypot(x, y) + shade = np.clip(1.0 - edge * 1.8, 0.55, 1.0) * (0.80 + 0.20 * np.clip(1.0 - r, 0.0, 1.0)) + rgb = np.zeros((size, size, 3), dtype=np.float32) + rgb[..., 0] = 0.90 * shade + rgb[..., 1] = 0.13 * shade + rgb[..., 2] = 0.10 * shade + center = _circle(x, y, 0.0, 0.0, 0.17) + rgb[center < 0] = (0.10, 0.07, 0.06) + ring = (np.abs(r - 0.20) < 0.035) + rgb[ring & (petals < 0)] = (0.30, 0.22, 0.10) # stamen ring + return rgb, np.clip(alpha, 0.0, 1.0) + + +def _sleigh(size: int): + """Colored: Santa's sleigh in side profile — red body, curled prow, gold runner.""" + x, y = _grid(size) + red = (0.72, 0.10, 0.12) + dark_red = (0.50, 0.06, 0.08) + gold = (0.85, 0.68, 0.25) + rgb = np.zeros((size, size, 3), dtype=np.float32) + alpha = np.zeros((size, size), dtype=np.float32) + body = np.maximum(_ellipse(x, y, 0.05, 0.05, 0.62, 0.34), y - 0.28) # tub, flat floor + back = _ellipse(x, y, 0.52, -0.12, 0.16, 0.30) # high back rest + body = np.minimum(body, np.maximum(back, y - 0.28)) + prow = _segment(x, y, -0.52, 0.10, -0.72, -0.30, 0.075) # curling front + prow = np.minimum(prow, _circle(x, y, -0.72, -0.34, 0.095)) + shape = np.minimum(body, prow) + alpha = np.maximum(alpha, _aa(shape, size)) + rgb[shape < 1] = red + rgb[(shape < 0) & (y < -0.05)] = dark_red + rim = (np.abs(shape) < 0.035) & (y < 0.1) + rgb[rim] = gold + runner = _segment(x, y, -0.62, 0.42, 0.58, 0.42, 0.035) # gold runner + runner = np.minimum(runner, _circle(x, y, -0.66, 0.36, 0.06)) + strut1 = _segment(x, y, -0.30, 0.28, -0.30, 0.42, 0.03) + strut2 = _segment(x, y, 0.34, 0.28, 0.34, 0.42, 0.03) + for part in (runner, strut1, strut2): + alpha = np.maximum(alpha, _aa(part, size)) + rgb[part < 0] = gold + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +def _ghost(size: int): + """Colored: friendly sheet ghost — wavy hem, side-glance eyes.""" + x, y = _grid(size) + rgb = np.zeros((size, size, 3), dtype=np.float32) + head = _circle(x, y, 0.0, -0.30, 0.42) + taper = 0.42 - 0.10 * np.clip(y + 0.30, 0.0, 1.0) # sheet narrows toward the hem + sides = np.maximum(np.abs(x) - taper, y + 0.30) + hem = y - (0.52 + 0.10 * np.cos(x * 11.0)) # wavy bottom edge + body = np.maximum(np.minimum(head, sides), hem) + alpha = _aa(body, size) + shade = 0.86 + 0.14 * np.clip(-(x + y) * 0.6, 0.0, 1.0) + for ch in range(3): + rgb[..., ch] = np.where(body < 1, shade, 0.0) + rgb[..., 2] = np.where(body < 1, np.minimum(shade + 0.04, 1.0), 0.0) # cool cast + for ex in (-0.10, 0.16): + eye = _ellipse(x, y, ex, -0.30, 0.065, 0.10) + rgb[eye < 0] = (0.12, 0.12, 0.18) + mouth = _ellipse(x, y, 0.04, -0.08, 0.05, 0.07) + rgb[mouth < 0] = (0.12, 0.12, 0.18) + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +def _turkey(size: int): + """Colored: cartoon turkey — banded fan tail, round body, wattle.""" + x, y = _grid(size) + rgb = np.zeros((size, size, 3), dtype=np.float32) + alpha = np.zeros((size, size), dtype=np.float32) + fan_bands = ((0.62, (0.72, 0.18, 0.10)), (0.50, (0.85, 0.48, 0.12)), (0.38, (0.92, 0.72, 0.22))) + ang = np.arctan2(-(y + 0.10), x) + in_fan_ang = (ang > 0.15) & (ang < math.pi - 0.15) + r = np.hypot(x, y + 0.10) + for radius, col in fan_bands: + band = in_fan_ang & (r < radius) + rgb[band] = col + alpha = np.maximum(alpha, band.astype(np.float32)) + body = _ellipse(x, y, 0.0, 0.22, 0.34, 0.30) + alpha = np.maximum(alpha, _aa(body, size)) + rgb[body < 0] = (0.42, 0.26, 0.14) + head = _circle(x, y, 0.24, -0.10, 0.13) + neck = _segment(x, y, 0.16, 0.14, 0.24, -0.08, 0.09) + for part in (neck, head): + alpha = np.maximum(alpha, _aa(part, size)) + rgb[part < 0] = (0.48, 0.30, 0.16) + beak = _tri_sdf(x, y, (0.35, -0.12), (0.46, -0.07), (0.35, -0.03)) + alpha = np.maximum(alpha, _aa(beak.astype(np.float32), size)) + rgb[beak < 0] = (0.95, 0.75, 0.25) + wattle = _ellipse(x, y, 0.33, 0.02, 0.045, 0.09) + alpha = np.maximum(alpha, _aa(wattle, size)) + rgb[wattle < 0] = (0.80, 0.15, 0.15) + eye = _circle(x, y, 0.27, -0.13, 0.028) + rgb[eye < 0] = (0.08, 0.08, 0.08) + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +def _bunny(size: int): + """Colored: hopping bunny silhouette — long ears back, cotton tail.""" + x, y = _grid(size) + rgb = np.zeros((size, size, 3), dtype=np.float32) + alpha = np.zeros((size, size), dtype=np.float32) + fur = (0.88, 0.86, 0.90) + body = _ellipse(x, y, -0.05, 0.18, 0.40, 0.28) + head = _circle(x, y, 0.34, -0.06, 0.19) + ear1 = _ellipse(*_rot(x - 0.28, y + 0.34, -0.55), 0.0, 0.0, 0.07, 0.30) + ear2 = _ellipse(*_rot(x - 0.16, y + 0.30, -0.85), 0.0, 0.0, 0.07, 0.28) + leg = _ellipse(x, y, -0.28, 0.36, 0.20, 0.12) + for part in (body, head, ear1, ear2, leg): + alpha = np.maximum(alpha, _aa(part, size)) + rgb[part < 0] = fur + inner = _ellipse(*_rot(x - 0.28, y + 0.34, -0.55), 0.0, 0.02, 0.035, 0.20) + rgb[inner < 0] = (0.95, 0.72, 0.78) + tail = _circle(x, y, -0.46, 0.10, 0.09) + alpha = np.maximum(alpha, _aa(tail, size)) + rgb[tail < 0] = (0.98, 0.98, 1.0) + eye = _circle(x, y, 0.40, -0.10, 0.028) + rgb[eye < 0] = (0.10, 0.10, 0.12) + nose = _circle(x, y, 0.52, -0.02, 0.025) + rgb[nose < 0] = (0.90, 0.55, 0.60) + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +def _winged_heart(size: int): + """Colored: red heart with white wings — valentine courier.""" + x, y = _grid(size) + rgb = np.zeros((size, size, 3), dtype=np.float32) + alpha = np.zeros((size, size), dtype=np.float32) + for sgn in (-1.0, 1.0): # wings: three stacked feather arcs each side + for k, (wy, wl) in enumerate(((-0.18, 0.44), (-0.06, 0.36), (0.06, 0.26))): + wing = _ellipse(*_rot(x * sgn - 0.42, y + 0.10 - wy * 0.3, 0.45 + k * 0.12), 0.0, wy, wl, 0.09) + alpha = np.maximum(alpha, _aa(wing, size)) + rgb[wing < 0] = (0.94, 0.94, 0.97) + hx, hy = x * 1.9, -y * 1.9 + 0.25 + f = (hx * hx + hy * hy - 0.75) ** 3 - hx * hx * hy * hy * hy + heart = np.where(f < 0, -1.0, 1.0).astype(np.float32) + am = _aa(heart, size) + alpha = np.maximum(alpha, am) + hr = np.clip(0.80 - 0.25 * np.hypot(x + 0.10, y + 0.12), 0.35, 1.0) + mask = am > 0.5 + rgb[mask] = 0.0 + rgb[..., 0] = np.where(mask, 0.85 * hr + 0.15, rgb[..., 0]) + rgb[..., 1] = np.where(mask, 0.10 * hr, rgb[..., 1]) + rgb[..., 2] = np.where(mask, 0.14 * hr, rgb[..., 2]) + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +def _pot_gold(size: int): + """Colored: leprechaun's pot with a mound of coins.""" + x, y = _grid(size) + rgb = np.zeros((size, size, 3), dtype=np.float32) + alpha = np.zeros((size, size), dtype=np.float32) + pot = np.maximum(_circle(x, y, 0.0, 0.10, 0.46), -(y - -0.08)) # bowl below rim line + rim = _ellipse(x, y, 0.0, -0.08, 0.50, 0.09) + mound = _ellipse(x, y, 0.0, -0.16, 0.40, 0.16) + alpha = np.maximum.reduce([_aa(pot, size), _aa(rim, size), _aa(mound, size)]) + shade = 0.30 + 0.14 * np.clip(-(x + y), 0.0, 1.0) + for ch in range(3): + rgb[..., ch] = np.where(pot < 1, shade, rgb[..., ch]) + rgb[..., 2] = np.where(pot < 1, shade + 0.05, rgb[..., 2]) # cool iron cast + rgb[rim < 0] = (0.24, 0.24, 0.30) + gold_shade = (0.88 + 0.12 * np.clip(-(x * 0.5 + y), 0.0, 1.0)) + gm = mound < 0 + rgb[..., 0] = np.where(gm, 0.92 * gold_shade, rgb[..., 0]) + rgb[..., 1] = np.where(gm, 0.74 * gold_shade, rgb[..., 1]) + rgb[..., 2] = np.where(gm, 0.22 * gold_shade, rgb[..., 2]) + rng = np.random.default_rng(3) + for _ in range(7): # coin glints + cx, cy = rng.uniform(-0.30, 0.30), rng.uniform(-0.28, -0.10) + coin = _circle(x, y, cx, cy, 0.045) + rgb[coin < 0] = (1.0, 0.90, 0.45) + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +def _rocket(size: int): + """Colored: firework rocket climbing — red nose, white body, flame.""" + x, y = _grid(size) + rx, ry = _rot(x, y, 0.5) # tilted like it's climbing forward + rgb = np.zeros((size, size, 3), dtype=np.float32) + alpha = np.zeros((size, size), dtype=np.float32) + body = _ellipse(rx, ry, 0.0, 0.05, 0.16, 0.46) + alpha = np.maximum(alpha, _aa(body, size)) + rgb[body < 0] = (0.93, 0.93, 0.96) + nose = ry + 0.28 + nm = (body < 0) & (nose < 0) + rgb[nm] = (0.78, 0.14, 0.16) + for sgn in (-1.0, 1.0): + fin = _tri_sdf(rx * sgn, ry, (0.10, 0.28), (0.30, 0.55), (0.10, 0.50)) + alpha = np.maximum(alpha, _aa(fin.astype(np.float32), size)) + rgb[fin < 0] = (0.78, 0.14, 0.16) + window = _circle(rx, ry, 0.0, -0.05, 0.07) + rgb[window < 0] = (0.35, 0.55, 0.85) + flame = _ellipse(rx, ry, 0.0, 0.62, 0.09, 0.14) + fg = np.exp(-((np.hypot(rx, ry - 0.62) / 0.22) ** 2)) * 0.9 + alpha = np.maximum(alpha, np.maximum(_aa(flame, size), fg)) + fm = flame < 0 + rgb[..., 0] = np.where(fm, 1.0, np.where(fg > 0.15, np.maximum(rgb[..., 0], fg), rgb[..., 0])) + rgb[..., 1] = np.where(fm, 0.78, np.where(fg > 0.15, np.maximum(rgb[..., 1], fg * 0.6), rgb[..., 1])) + rgb[..., 2] = np.where(fm, 0.25, rgb[..., 2]) + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +def _sombrero(size: int): + """Colored: sombrero — wide swooping brim, tall crown, festive band.""" + x, y = _grid(size) + rgb = np.zeros((size, size, 3), dtype=np.float32) + alpha = np.zeros((size, size), dtype=np.float32) + straw = (0.88, 0.72, 0.38) + brim = _ellipse(x, y + 0.06 * np.cos(x * 4.0), 0.0, 0.22, 0.72, 0.14) + crown = np.maximum(_ellipse(x, y, 0.0, 0.02, 0.30, 0.38), y - 0.22) + tip = _ellipse(x, y, 0.0, -0.34, 0.16, 0.09) + for part in (brim, crown, tip): + alpha = np.maximum(alpha, _aa(part, size)) + rgb[part < 0] = straw + shade = (crown < 0) & (x > 0.10) + rgb[shade] = (0.78, 0.60, 0.28) + band = (crown < 0) & (np.abs(y - 0.12) < 0.06) + bx = ((x * 6.0).astype(int) % 2 == 0) + rgb[band & bx] = (0.75, 0.15, 0.18) + rgb[band & ~bx] = (0.15, 0.50, 0.30) + edge = (np.abs(brim) < 0.03) + rgb[edge] = (0.75, 0.15, 0.18) + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +def _jester_hat(size: int): + """Colored: three-pronged jester hat with belled tips.""" + x, y = _grid(size) + rgb = np.zeros((size, size, 3), dtype=np.float32) + alpha = np.zeros((size, size), dtype=np.float32) + prongs = (((-0.62, -0.44), (0.55, 0.10, 0.65), (-0.02, 0.10)), + ((0.0, -0.62), (0.90, 0.70, 0.20), (0.0, 0.05)), + ((0.62, -0.40), (0.16, 0.62, 0.60), (0.02, 0.10))) + base_y = 0.30 + for (tipx, tipy), col, (bx_off, _) in prongs: + prong = _tri_sdf(x, y, (tipx, tipy), (bx_off + 0.30, base_y), (bx_off - 0.30, base_y)) + alpha = np.maximum(alpha, _aa(prong.astype(np.float32), size)) + rgb[prong < 0] = col + bell = _circle(x, y, tipx, tipy, 0.075) + alpha = np.maximum(alpha, _aa(bell, size)) + rgb[bell < 0] = (0.95, 0.85, 0.40) + brim = _ellipse(x, y, 0.0, base_y + 0.04, 0.58, 0.10) + alpha = np.maximum(alpha, _aa(brim, size)) + rgb[brim < 0] = (0.55, 0.20, 0.60) + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +def _usflag(size: int): + """Colored: American flag on a short pole, gently waving. 13 stripes, starred canton.""" + x, y = _grid(size) + red = (0.76, 0.12, 0.16) + white = (0.93, 0.93, 0.96) + navy = (0.13, 0.18, 0.38) + rgb = np.zeros((size, size, 3), dtype=np.float32) + alpha = np.zeros((size, size), dtype=np.float32) + # pole down the left edge + pole = _segment(x, y, -0.78, -0.72, -0.78, 0.85, 0.035) + alpha = np.maximum(alpha, _aa(pole, size)) + rgb[pole < 0] = (0.62, 0.65, 0.72) + rgb[_circle(x, y, -0.78, -0.78, 0.06) < 0] = (0.85, 0.72, 0.35) # gold finial + alpha = np.maximum(alpha, _aa(_circle(x, y, -0.78, -0.78, 0.06), size)) + # flag body: rows waved horizontally by a sine in y + wave = 0.06 * np.sin(y * 7.0) + fx = x - wave # flag-local x + in_flag = (fx > -0.74) & (fx < 0.72) & (y > -0.72) & (y < 0.22) + fy = (y + 0.72) / 0.94 # 0 at flag top .. 1 at bottom + stripe = (fy * 13).astype(int) % 2 + for ch, (rv, wv) in enumerate(zip(red, white, strict=True)): + rgb[..., ch] = np.where(in_flag, np.where(stripe == 0, rv, wv), rgb[..., ch]) + canton = in_flag & (fx < -0.15) & (fy < 7.0 / 13.0) + for ch, cv in enumerate(navy): + rgb[..., ch] = np.where(canton, cv, rgb[..., ch]) + # star dots on a staggered grid inside the canton + gx = ((fx + 0.74) * 14.0).astype(int) + gy = (fy * 14.0).astype(int) + dots = canton & ((gx + gy) % 2 == 0) & (np.abs((fx + 0.74) * 14.0 - gx - 0.5) < 0.30) \ + & (np.abs(fy * 14.0 - gy - 0.5) < 0.30) + for ch, wv in enumerate(white): + rgb[..., ch] = np.where(dots, wv, rgb[..., ch]) + alpha = np.maximum(alpha, in_flag.astype(np.float32)) + # gentle shading down the waves for cloth depth + shade = np.where(in_flag, 0.88 + 0.12 * np.cos(y * 7.0), 1.0) + rgb *= shade[..., None] + return np.clip(rgb, 0.0, 1.0), np.clip(alpha, 0.0, 1.0) + + +# ---------------------------------------------------------------------------- registry +def render_sprite(name: str, size: int = SPRITE_SIZE): + """(rgb float32 [h,w,3], alpha float32 [h,w]) in 0..1 — GL-free, unit-testable.""" + if name.startswith("snowflake"): + variant = int(name[-1]) if name[-1].isdigit() else 0 + v, a = _snowflake(size, variant) + elif name == "heart": + v, a = _heart(size) + elif name == "leaf": + v, a = _leaf(size) + elif name == "petal": + v, a = _petal(size) + elif name == "clover": + v, a = _clover(size) + elif name == "bat": + v, a = _bat(size, True) + elif name == "bat2": + v, a = _bat(size, False) + elif name == "spark": + v, a = _spark(size) + elif name == "bulb": + v, a = _bulb(size) + elif name == "pennant": + v, a = _pennant(size) + elif name == "star5": + v, a = _star5(size) + elif name == "orb": + v, a = _orb(size) + elif name == "garland": + return _garland_tile(size) + elif name == "poppy": + return _poppy(size) + elif name == "usflag": + return _usflag(size) + elif name == "sleigh": + return _sleigh(size) + elif name == "ghost": + return _ghost(size) + elif name == "turkey": + return _turkey(size) + elif name == "bunny": + return _bunny(size) + elif name == "winged_heart": + return _winged_heart(size) + elif name == "pot_gold": + return _pot_gold(size) + elif name == "rocket": + return _rocket(size) + elif name == "sombrero": + return _sombrero(size) + elif name == "jester_hat": + return _jester_hat(size) + else: + raise KeyError(name) + rgb = np.repeat(np.clip(v, 0.0, 1.0)[..., None], 3, axis=2) + return rgb, a + + + + +# ---------------------------------------------------------------------------- backdrops +BACKDROP_STYLES = ("pines", "skyline", "hills", "mesas", "spooky", "zigzag", + "hearts", "eggs", "clover_hills", "farmland", "capitol", "flags") + + +def render_backdrop(style: str, width: int = 768, height: int = 160, seed: int = 3): + """Horizon silhouette band (rgb, alpha), tileable horizontally. White/grey shades — + tinted at draw time from the pack palette. GL-free.""" + rng = np.random.default_rng(seed + hash(style) % 1000) + xs = np.arange(width) + prof = np.zeros(width, dtype=np.float32) + flag_poles: list[tuple[int, float]] = [] + + if style == "pines": + x = 0 + while x < width: + w = int(rng.uniform(18, 42)) + h = rng.uniform(0.45, 0.95) + for i in range(w): + if x + i < width: + prof[x + i] = max(prof[x + i], h * (1.0 - abs(i - w / 2) / (w / 2))) + x += int(w * rng.uniform(0.55, 0.8)) + elif style == "skyline": + x = 0 + while x < width: + w = int(rng.uniform(24, 70)) + h = rng.uniform(0.25, 0.95) + prof[x:x + w] = h + x += w + int(rng.uniform(2, 10)) + elif style == "hills": + for f, a in ((1.0, 0.45), (2.3, 0.25), (4.7, 0.12)): + ph = rng.uniform(0, 2 * math.pi) + prof += a * (0.5 + 0.5 * np.sin(2 * math.pi * f * xs / width + ph)) + prof = np.clip(prof, 0.05, 1.0) + elif style == "mesas": + prof[:] = 0.12 + x = 0 + while x < width: + w = int(rng.uniform(60, 140)) + h = rng.uniform(0.4, 0.85) + ramp = int(w * 0.18) + for i in range(w): + if x + i >= width: + break + t = 1.0 + if i < ramp: + t = i / ramp + elif i > w - ramp: + t = (w - i) / ramp + prof[x + i] = max(prof[x + i], h * t) + x += w + int(rng.uniform(40, 110)) + placed = 0 # saguaros only in the flats between mesas: flat-topped trunk + two arms + for _ in range(24): + if placed >= 5: + break + cx = int(rng.uniform(12, width - 12)) + if float(np.max(prof[cx - 10:cx + 11])) > 0.20: + continue + placed += 1 + base = 0.12 + h = rng.uniform(0.34, 0.46) + for off, frac, hw in ((0, 1.0, 2), (-6, 0.58, 1), (6, 0.66, 1)): + for i in range(-hw, hw + 1): + xx = cx + off + i + if 0 <= xx < width: + round_top = 1.0 - 0.12 * (abs(i) / max(hw, 1)) ** 2 + prof[xx] = max(prof[xx], base + h * frac * round_top) + elif style == "spooky": + for f, a in ((0.8, 0.35), (2.1, 0.18)): + ph = rng.uniform(0, 2 * math.pi) + prof += a * (0.5 + 0.5 * np.sin(2 * math.pi * f * xs / width + ph)) + for _ in range(6): # bare gnarled trees: tapered trunk + stepped branch tiers + x = int(rng.uniform(10, width - 24)) + h = rng.uniform(0.55, 0.95) + base = float(prof[x]) + for i in range(-3, 4): # trunk tapers upward + if 0 <= x + i < width: + prof[x + i] = max(prof[x + i], base + h * 0.55 * (1.0 - abs(i) / 4.5)) + for tier, frac in ((1, 0.42), (2, 0.30)): # branch tiers reach sideways + span = int(6 + tier * 4) + for sgn in (-1, 1): + bx = x + sgn * span + for i in range(-2, 3): + if 0 <= bx + i < width: + prof[bx + i] = max(prof[bx + i], base + h * frac * (1.0 - abs(i) / 3.5)) + elif style == "hearts": + # heart summits: two round lobes with a soft cleft, clear gaps between hearts + x = 0 + while x < width: + w = int(rng.uniform(110, 150)) + h = rng.uniform(0.5, 0.85) + c = w / 2.0 + for i in range(w): + if x + i >= width: + break + t = (i - c) / c + m = 0.0 + for lc in (-0.42, 0.42): # big round lobes, wide overlap at the middle + d2 = 1.0 - ((t - lc) / 0.56) ** 2 + if d2 > 0: + m = max(m, math.sqrt(d2)) + cleft = 1.0 - 0.30 * max(0.0, 1.0 - (abs(t) / 0.20) ** 2) # dip only near center + prof[x + i] = max(prof[x + i], h * m * cleft) + x += int(w * rng.uniform(1.15, 1.45)) + elif style == "eggs": + # a clutch of giant eggs standing on the horizon, overlapping + x = 0 + while x < width: + w = int(rng.uniform(70, 130)) + h = rng.uniform(0.45, 0.9) + c = w / 2.0 + for i in range(w): + if x + i >= width: + break + t = (i - c) / c + d2 = 1.0 - t * t + if d2 > 0: + prof[x + i] = max(prof[x + i], h * d2 ** 0.72) + x += int(w * rng.uniform(0.55, 0.75)) + elif style == "clover_hills": + # triple-lobed shamrock mounds: tall center lobe, two lower shoulders + x = 0 + while x < width: + w = int(rng.uniform(110, 160)) + h = rng.uniform(0.42, 0.78) + c = w / 2.0 + for i in range(w): + if x + i >= width: + break + t = (i - c) / c + m = 0.0 + for lc, lh, lr in ((-0.58, 0.70, 0.42), (0.0, 1.0, 0.46), (0.58, 0.70, 0.42)): + d2 = 1.0 - ((t - lc) / lr) ** 2 + if d2 > 0: + m = max(m, lh * math.sqrt(d2)) + prof[x + i] = max(prof[x + i], h * m) + x += int(w * rng.uniform(0.82, 1.05)) + elif style == "farmland": + # gentle field swells, haystack domes, and two barns with silos + for f, a in ((0.7, 0.18), (1.9, 0.10)): + ph = rng.uniform(0, 2 * math.pi) + prof += a * (0.5 + 0.5 * np.sin(2 * math.pi * f * xs / width + ph)) + prof += 0.08 + for _ in range(5): + x0 = int(rng.uniform(0, width - 44)) + w = int(rng.uniform(24, 40)) + h = rng.uniform(0.14, 0.24) + c = w / 2.0 + base = float(np.max(prof[x0:x0 + w])) + for i in range(w): + t = (i - c) / c + d2 = 1.0 - t * t + if d2 > 0: + prof[x0 + i] = max(prof[x0 + i], base + h * math.sqrt(d2)) + for bx in (int(width * 0.20), int(width * 0.64)): + bw = int(rng.uniform(54, 68)) + body = rng.uniform(0.26, 0.32) + base = float(np.max(prof[bx:bx + bw])) * 0.5 + c = bw / 2.0 + for i in range(bw): + if bx + i >= width: + break + t = abs(i - c) / c + prof[bx + i] = max(prof[bx + i], base + body + 0.24 * (1.0 - t)) # straight gable roof + sx = bx + bw + 5 # silo: slim flat-capped tower + for i in range(11): + if sx + i >= width: + break + t = abs(i - 5.0) / 5.0 + prof[sx + i] = max(prof[sx + i], base + body + 0.22 + 0.05 * (1.0 - t * t)) + elif style == "capitol": + # low government skyline, central rotunda dome, obelisk monument + x = 0 + while x < width: + w = int(rng.uniform(30, 66)) + h = rng.uniform(0.20, 0.42) + prof[x:x + w] = h + x += w + int(rng.uniform(4, 12)) + cx, dw, dh, base = width // 2, 116, 0.30, 0.34 + for i in range(dw): + xx = cx - dw // 2 + i + t = (i - dw / 2.0) / (dw / 2.0) + d2 = 1.0 - t * t + if 0 <= xx < width and d2 > 0: + prof[xx] = max(prof[xx], base + dh * math.sqrt(d2)) + for i in range(-4, 5): # lantern atop the dome + if 0 <= cx + i < width: + prof[cx + i] = max(prof[cx + i], base + dh + 0.11 * (1.0 - abs(i) / 5.0)) + ox = width // 4 # obelisk: tapered shaft, pyramidion tip + for i in range(-7, 8): + if 0 <= ox + i < width: + t = abs(i) / 7.0 + prof[ox + i] = max(prof[ox + i], 0.86 * (1.0 - t * t * 0.55)) + elif style == "flags": + # memorial hillside: soft lawn swells and a rank of flagpoles (flags stamped below) + for f, a in ((0.9, 0.14), (2.2, 0.07)): + ph = rng.uniform(0, 2 * math.pi) + prof += a * (0.5 + 0.5 * np.sin(2 * math.pi * f * xs / width + ph)) + prof += 0.06 + for k in range(4): + px = int(width * (0.10 + 0.26 * k) + rng.uniform(-8, 8)) + h = rng.uniform(0.74, 0.88) + flag_poles.append((px, h)) + for i in range(-1, 2): + if 0 <= px + i < width: + prof[px + i] = max(prof[px + i], h) + else: # zigzag + period = 48 + prof = 0.15 + 0.7 * np.abs(((xs / period) % 2.0) - 1.0).astype(np.float32) + + # solid ground base so the band fills its rect instead of stretching empty sky + prof = 0.26 + 0.74 * np.clip(prof, 0.0, 1.0) * 0.92 + yy = np.linspace(1.0, 0.0, height, dtype=np.float32)[:, None] # row 0 = texture top + # opaque below the profile line, soft 2px edge at the top of the silhouette + alpha = np.clip((prof[None, :] - yy) * height * 0.5, 0.0, 1.0) + # mild depth shading, kept bright enough to survive dark-tint packs + v = (0.72 + 0.28 * yy).astype(np.float32) * np.ones((height, width), dtype=np.float32) + + if style in ("skyline", "capitol"): + # sparse lit windows: full-white texels read as the tint at full brightness + win_rng = np.random.default_rng(7) + fill = alpha > 0.6 + for wy in range(6, height - 3, 9): + for wx in range(4, width - 3, 7): + if fill[wy, wx] and fill[wy + 2, wx + 1] and win_rng.random() < 0.28: + v[wy:wy + 2, wx:wx + 2] = 1.0 + elif style == "flags": + # Self-colored (pack tint should be #FFFFFF): slate hillside, US flags on the poles. + hill = np.array([0.30, 0.38, 0.55], dtype=np.float32) + red = np.array([0.76, 0.12, 0.16], dtype=np.float32) + white = np.array([0.93, 0.93, 0.96], dtype=np.float32) + navy = np.array([0.13, 0.18, 0.38], dtype=np.float32) + rgb_f = v[..., None] * hill[None, None, :] + for px, raw_h in flag_poles: + top = 0.26 + 0.74 * min(raw_h, 1.0) * 0.92 # same remap as the profile + fl = int(width * 0.058) # flag length + fh = 0.13 # flag height in profile units + for row in range(height): + ry = float(yy[row, 0]) + if top - fh <= ry <= top - 0.015: + rel = (top - 0.015 - ry) / (fh - 0.015) # 0 at flag top .. 1 at bottom + wave = int(2.5 * math.sin(row * 0.9 + px)) + x0, x1 = px + 2, min(width - 1, px + 2 + fl + wave) + if x1 <= x0: + continue + stripe = red if int(rel * 7) % 2 == 0 else white # 7 visible stripes + rgb_f[row, x0:x1] = stripe + alpha[row, x0:x1] = 1.0 + if rel < 0.54: # canton with star dots + c1 = min(x1, x0 + int(fl * 0.42)) + rgb_f[row, x0:c1] = navy + if row % 2 == 0: + for sx in range(x0 + 1, c1 - 1, 3): + rgb_f[row, sx] = white + # pole picked out in light grey, only above the hill surface beside it + hill_ref = float(prof[min(px + 5, width - 1)]) + pole_rows = (alpha[:, px] > 0.5) & (yy[:, 0] > hill_ref + 0.01) + for i in range(-1, 2): + if 0 <= px + i < width: + rgb_f[pole_rows, px + i] = np.array([0.62, 0.65, 0.72], dtype=np.float32) + rgb = np.clip(rgb_f, 0.0, 1.0) + return rgb, alpha.astype(np.float32) + + rgb = np.repeat(v[..., None], 3, axis=2) + return rgb, alpha.astype(np.float32) + + +def render_celestial(kind: str, size: int = 192): + """(rgb, alpha): big soft moon (with craters) or sun disc with glow — tinted at draw.""" + x, y = _grid(size) + r = np.hypot(x, y) + if kind == "moon": + disc = _aa(r - 0.55, size) + v = np.full((size, size), 0.95, dtype=np.float32) + rng = np.random.default_rng(5) + for _ in range(6): + cx, cy = rng.uniform(-0.35, 0.35), rng.uniform(-0.35, 0.35) + cr = rng.uniform(0.05, 0.14) + crater = np.hypot(x - cx, y - cy) < cr + v = np.where(crater, v * 0.82, v) + glow = np.exp(-((r - 0.55).clip(0) / 0.35) ** 2) * 0.45 + alpha = np.clip(disc + glow, 0.0, 1.0) + v = np.where(disc > 0.5, v, 1.0) + else: # sun + core = _aa(r - 0.42, size) + glow = np.exp(-((r - 0.42).clip(0) / 0.5) ** 2) * 0.6 + alpha = np.clip(core + glow, 0.0, 1.0) + v = np.ones((size, size), dtype=np.float32) + rgb = np.repeat(v[..., None] if v.ndim == 2 else v, 3, axis=2) + return rgb, alpha.astype(np.float32) + + +def render_rainbow(width: int = 640, height: int = 320): + """(rgb, alpha): a soft arc rainbow, self-colored.""" + ax = np.linspace(-1.0, 1.0, width, dtype=np.float32) + ay = np.linspace(0.0, 1.0, height, dtype=np.float32) + x, y = np.meshgrid(ax, ay) + # arc centered below the strip's bottom edge so the visible part is a proper rainbow + r = np.hypot(x * 1.05, (1.0 - y) * 0.9 + 0.12) + bands = [(0.98, (230, 60, 60)), (0.925, (240, 150, 50)), (0.87, (245, 220, 80)), + (0.815, (90, 200, 90)), (0.76, (80, 140, 230)), (0.705, (150, 90, 200))] + rgb = np.zeros((height, width, 3), dtype=np.float32) + alpha = np.zeros((height, width), dtype=np.float32) + for r0, c in bands: + band = np.clip(1.0 - np.abs(r - r0) / 0.028, 0.0, 1.0) + for i in range(3): + rgb[..., i] = np.where(band > alpha, c[i] / 255.0, rgb[..., i]) + alpha = np.maximum(alpha, band * 0.85) + # legs dissolve into horizon mist instead of hard-clipping at the strip's bottom edge + fade = np.clip((1.0 - y) / 0.26, 0.0, 1.0) + alpha *= fade * fade * (3.0 - 2.0 * fade) + return rgb, alpha + + +SPRITE_NAMES = frozenset({ + "snowflake", "heart", "leaf", "petal", "clover", "bat", "bat2", "spark", + "bulb", "pennant", "garland", "star5", "orb", "poppy", "usflag", + "sleigh", "ghost", "turkey", "bunny", "winged_heart", "pot_gold", "rocket", + "sombrero", "jester_hat", +}) + + +def _png_bytes(rgb: np.ndarray, alpha: np.ndarray) -> bytes: + """Minimal in-memory PNG encoder (RGBA8) — avoids per-pixel GL image writes.""" + h, w = alpha.shape + rgba = np.empty((h, w, 4), dtype=np.uint8) + rgba[..., :3] = np.clip(rgb * 255.0, 0, 255).astype(np.uint8) + rgba[..., 3] = np.clip(alpha * 255.0, 0, 255).astype(np.uint8) + raw = b"".join(b"\x00" + rgba[i].tobytes() for i in range(h)) + + def chunk(tag: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data)) + + ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b"")) + + +@lru_cache(maxsize=None) +def build_backdrop(style: str): + """Backdrop band as a texture (bilinear). Requires GL.""" + import pyray as rl + rgb, a = render_backdrop(style) + png = _png_bytes(rgb, a) + img = rl.load_image_from_memory(".png", png, len(png)) + tex = rl.load_texture_from_image(img) + rl.unload_image(img) + rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + return tex + + +@lru_cache(maxsize=None) +def build_celestial(kind: str): + import pyray as rl + rgb, a = render_celestial(kind) + png = _png_bytes(rgb, a) + img = rl.load_image_from_memory(".png", png, len(png)) + tex = rl.load_texture_from_image(img) + rl.unload_image(img) + rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + return tex + + +@lru_cache(maxsize=None) +def build_rainbow(): + import pyray as rl + rgb, a = render_rainbow() + png = _png_bytes(rgb, a) + img = rl.load_image_from_memory(".png", png, len(png)) + tex = rl.load_texture_from_image(img) + rl.unload_image(img) + rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + return tex + + +@lru_cache(maxsize=None) +def build_sprite(name: str, size: int = SPRITE_SIZE): + """Sprite as an rl.Texture (bilinear — these are smooth art, not pixel art). + Requires an active GL context. Cached per (name, size).""" + import pyray as rl + rgb, a = render_sprite(name, size) + png = _png_bytes(rgb, a) + img = rl.load_image_from_memory(".png", png, len(png)) + tex = rl.load_texture_from_image(img) + rl.unload_image(img) + rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + return tex diff --git a/selfdrive/ui/bp/mici/layouts/settings/visuals_mici.py b/selfdrive/ui/bp/mici/layouts/settings/visuals_mici.py index 8248928dc8..66cdaae969 100644 --- a/selfdrive/ui/bp/mici/layouts/settings/visuals_mici.py +++ b/selfdrive/ui/bp/mici/layouts/settings/visuals_mici.py @@ -17,7 +17,10 @@ BigParamControlBP, BigMultiParamToggleBP, BigMultiParamToggleBoolBP, + BigMultiParamToggleStrBP, ) +# BluePilot: seasonal theme packs (discovered on disk at panel build time) +from openpilot.selfdrive.ui.bp.lib import theme_pack from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.widgets.scroller import NavScroller @@ -33,7 +36,17 @@ def __init__(self, back_callback: Callable[[], None] | None = None): ["off", "lead car speed", "speed", "lead car distance", "time to lead car"], ) self.rainbow_mode = BigParamControlBP("Rainbow Mode", "RainbowMode") - self.rad_racer_theme = BigParamControlBP("8-Bit Racer Theme", "BPRadRacerTheme") + # BluePilot: one theme selector for everything (8-Bit Racer + seasonal packs), + # same entries and param as the C3X page — see theme_pack.selector_entries(). + _theme_entries = theme_pack.selector_entries() + self.theme_pack_sel = BigMultiParamToggleStrBP( + "Theme", theme_pack.PARAM_KEY, + [label for label, _ in _theme_entries], values=[value for _, value in _theme_entries], + value_size=24, + ) + # Auto seasonal: date-driven pack during holiday weeks; the manual Theme + # selection above still applies outside those windows. + self.theme_auto_seasonal = BigParamControlBP("Auto Seasonal Theme", theme_pack.AUTO_PARAM_KEY) self.hide_fade = BigParamControlBP("Hide Onroad Fade", "mici_hide_onroad_fade") self.hide_border = BigParamControlBP("Hide Onroad Border", "BPHideOnroadBorder") self.hide_camera_view = BigParamControlBP("Minimal Driving View", "BPHideCameraView") @@ -57,7 +70,8 @@ def __init__(self, back_callback: Callable[[], None] | None = None): self._scroller.add_widgets([ self.show_lead_vehicle, self.rainbow_mode, - self.rad_racer_theme, + self.theme_pack_sel, + self.theme_auto_seasonal, self.hide_fade, self.hide_border, self.hide_camera_view, @@ -73,10 +87,10 @@ def __init__(self, back_callback: Callable[[], None] | None = None): self._refresh_toggles = ( ("RainbowMode", self.rainbow_mode), - ("BPRadRacerTheme", self.rad_racer_theme), ("mici_hide_onroad_fade", self.hide_fade), ("BPHideOnroadBorder", self.hide_border), ("BPHideCameraView", self.hide_camera_view), + ("BPThemeAutoSeasonal", self.theme_auto_seasonal), ("BPRainbowLines", self.rainbow_lane_lines), ("ShowBlindspotOverlay", self.show_blindspot_ui), ("ShowBrakeStatus", self.show_brake_status), diff --git a/selfdrive/ui/bp/mici/onroad/augmented_road_view_bp.py b/selfdrive/ui/bp/mici/onroad/augmented_road_view_bp.py index 36b958de63..b155375038 100644 --- a/selfdrive/ui/bp/mici/onroad/augmented_road_view_bp.py +++ b/selfdrive/ui/bp/mici/onroad/augmented_road_view_bp.py @@ -20,6 +20,8 @@ from openpilot.selfdrive.ui.bp.mici.onroad.lateral_debug_mici import LateralDebugMici from openpilot.selfdrive.ui.bp.mici.onroad.rad_racer_mici import RadRacerThemeMici from openpilot.system.ui.widgets import Widget +# BluePilot: unified theme selector (BPThemePack param) +from openpilot.selfdrive.ui.bp.lib import theme_pack, theme_scene # BluePilot: Margin to keep confidence ball inside the MICI rounded border MICI_BALL_BORDER_MARGIN = 25 # half of 50px MICI border thickness @@ -92,8 +94,6 @@ def __init__(self, *args, **kwargs): # BluePilot: Rad Racer 8-bit theme (MICI-scaled; no gauge cluster on the small screen) self._rad_racer_theme = RadRacerThemeMici() - self._rad_racer_active = self._bp_params.get_bool("BPRadRacerTheme") - self._rad_racer_param_counter = 0 def _on_swipe_down(self): if not ui_state.is_onroad(): @@ -144,21 +144,22 @@ def _render(self, _): # Render the base camera view. Minimal Driving View suppression lives in MiciCameraViewBP. MiciCameraViewBP._render(self, self._content_rect) - # BluePilot: Rad Racer theme — refresh cached param (~1s), then sky/skyline/signs behind the road - self._rad_racer_param_counter += 1 - if self._rad_racer_param_counter >= 60: - self._rad_racer_param_counter = 0 - self._rad_racer_active = self._bp_params.get_bool("BPRadRacerTheme") - if self._rad_racer_active: + # BluePilot: unified theme scene dispatch — Rad Racer draws inline on MICI (no full + # takeover: the normal HUD stays); pack scenes get background + foreground passes. + _scene = theme_scene.active_scene() + _rad_racer = _scene is not None and _scene.replaces_hud() + if _rad_racer: self._model_renderer.prepare_projection(self._content_rect) self._rad_racer_theme.render_background(self._content_rect, self._model_renderer) + elif _scene is not None: + _scene.draw_background(self._content_rect, getattr(self, "_bp_hide_camera_view", False)) # Model overlays self._model_renderer.render(self._content_rect) # BluePilot: Rad Racer sprites (ego + leads) over the road; no gauge cluster on MICI, # so the ego car anchors to the bottom edge of the content rect instead. - if self._rad_racer_active: + if _rad_racer: self._rad_racer_theme.render_foreground( self._content_rect, self._model_renderer, self._content_rect.y + self._content_rect.height - 4) @@ -182,6 +183,11 @@ def _render(self, _): self._hud_renderer.set_can_draw_top_icons(alert_to_render is None) self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and alert_to_render.visual_alert == car.CarControl.HUDControl.VisualAlert.steerRequired) + # BluePilot: theme scene foreground — decor + near particles, always under alerts. + # hero_y_scale lifts the mascot above the MICI fade band + torque bar. + if _scene is not None and not _rad_racer: + _scene.hero_y_scale = 0.90 + _scene.draw_foreground(self._content_rect, getattr(self, "_bp_hide_camera_view", False)) if ui_state.started: self._alert_renderer.render(self._content_rect) self._hud_renderer.render(self._content_rect) diff --git a/selfdrive/ui/bp/mici/onroad/cameraview_bp.py b/selfdrive/ui/bp/mici/onroad/cameraview_bp.py index 5f9aab43f7..f562a5b62b 100644 --- a/selfdrive/ui/bp/mici/onroad/cameraview_bp.py +++ b/selfdrive/ui/bp/mici/onroad/cameraview_bp.py @@ -4,6 +4,8 @@ from openpilot.common.params import Params from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView as MiciCameraView from openpilot.selfdrive.ui.ui_state import ui_state +# BluePilot: unified theme selector (BPThemePack param) +from openpilot.selfdrive.ui.bp.lib import theme_pack class MiciCameraViewBP(MiciCameraView): @@ -14,7 +16,7 @@ def __init__(self, *args, **kwargs): self._bp_camera_params = Params() self._bp_hide_camera_view = self._bp_camera_params.get_bool("BPHideCameraView") # BluePilot: Rad Racer theme draws its own scene over a black sky (mirrors TICI) - self._bp_rad_racer_theme = self._bp_camera_params.get_bool("BPRadRacerTheme") + self._bp_rad_racer_theme = theme_pack.rad_racer_active(self._bp_camera_params) self._bp_camera_param_counter = 0 def _update_state(self): @@ -23,7 +25,7 @@ def _update_state(self): if self._bp_camera_param_counter >= 60: self._bp_camera_param_counter = 0 self._bp_hide_camera_view = self._bp_camera_params.get_bool("BPHideCameraView") - self._bp_rad_racer_theme = self._bp_camera_params.get_bool("BPRadRacerTheme") + self._bp_rad_racer_theme = theme_pack.rad_racer_active(self._bp_camera_params) def _should_hide_camera_view(self) -> bool: return ( diff --git a/selfdrive/ui/bp/mici/onroad/hud_renderer_bp.py b/selfdrive/ui/bp/mici/onroad/hud_renderer_bp.py index a71e329fcb..770e27dbf7 100644 --- a/selfdrive/ui/bp/mici/onroad/hud_renderer_bp.py +++ b/selfdrive/ui/bp/mici/onroad/hud_renderer_bp.py @@ -2,7 +2,10 @@ from openpilot.common.params import Params from opendbc.sunnypilot.car.ford.lateral_curv_ext import PrimaryLateralControl from opendbc.car.structs import ControllerStateBP -from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer +from openpilot.selfdrive.ui.mici.onroad.hud_renderer import ( + HudRenderer, FONT_SIZES, KM_TO_MILE, CRUISE_DISABLED_CHAR, SET_SPEED_PERSISTENCE, +) +from openpilot.system.ui.lib.multilang import tr from openpilot.selfdrive.ui.bp.mici.onroad.powerflow_gauge import MiciPowerflowGauge from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.bp.lib.steering_wheel_style import ( @@ -11,6 +14,8 @@ SteeringWheelIconStyle, ) from openpilot.selfdrive.ui.bp.lib.ui_debug_logger import bp_ui_log +# BluePilot: seasonal theme packs (steering wheel icon override) +from openpilot.selfdrive.ui.bp.lib import theme_pack from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.application import gui_app from openpilot.bluepilot.ui.lib.bp_shaders import draw_shader_circle_gradient @@ -32,6 +37,7 @@ def __init__(self): self._txt_wheel_comma_3x = gui_app.texture("icons/chffr_wheel.png", self._txt_wheel.width, self._txt_wheel.height) self._animate_steering_wheel = self._bp_params.get_bool("BPAnimateSteeringWheel") self._wheel_icon_style = ensure_steering_wheel_icon_style_initialized(self._bp_params, SteeringWheelIconStyle.COMMA_4) + self._theme_pack = theme_pack.get_active_pack(force=True) self._animate_wheel_param_counter = 0 self.show_lateral_control = False # BluePilot: actual mode from controllerStateBP (None = not published, e.g. non-Ford) @@ -50,6 +56,7 @@ def _update_state(self) -> None: self._animate_wheel_param_counter = 0 self._animate_steering_wheel = self._bp_params.get_bool("BPAnimateSteeringWheel") self._wheel_icon_style = get_steering_wheel_icon_style(self._bp_params, SteeringWheelIconStyle.COMMA_4) + self._theme_pack = theme_pack.get_active_pack() if self._bp_params.get_bool("ShowBrakeStatus"): sm = ui_state.sm @@ -81,6 +88,11 @@ def _render(self, rect: rl.Rectangle) -> None: def _draw_steering_wheel(self, rect: rl.Rectangle) -> None: """Override to add brake status coloring to wheel icon, powerflow gauge, and lateral control overlay.""" normal_wheel_txt = self._txt_wheel_comma_3x if self._wheel_icon_style == SteeringWheelIconStyle.COMMA_3X else self._txt_wheel + # BluePilot: theme pack steering wheel icon wins over the built-in styles + if self._theme_pack is not None: + pack_wheel = self._theme_pack.wheel_texture(self._txt_wheel.width) + if pack_wheel is not None: + normal_wheel_txt = pack_wheel # BluePilot: Preserve the upstream critical-alert wheel regardless of the user's normal wheel style. wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else normal_wheel_txt @@ -142,6 +154,36 @@ def _draw_steering_wheel(self, rect: rl.Rectangle) -> None: self._power_flow.set_wheel_rect(power_rect) self._power_flow.render(rect) + def _draw_set_speed(self, rect: rl.Rectangle) -> None: + """Upstream set-speed drawing, with the theme pack Accent tinting the value text.""" + accent = self._theme_pack.rl_colors().get("Accent") if self._theme_pack is not None else None + if accent is None: + super()._draw_set_speed(rect) + return + + alpha = self._set_speed_alpha_filter.update(0 < rl.get_time() - self._set_speed_changed_time < SET_SPEED_PERSISTENCE and + self._can_draw_top_icons and self._engaged) + if alpha < 1e-2: + return + + x, y = rect.x, rect.y + circle_radius = 162 // 2 + rl.draw_circle_gradient(rl.Vector2(x + circle_radius, y + circle_radius), circle_radius, + rl.Color(0, 0, 0, int(255 / 2 * alpha)), rl.BLANK) + + set_speed_color = rl.Color(accent.r, accent.g, accent.b, int(255 * 0.9 * alpha)) + max_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha)) + + set_speed = self.set_speed + if self.is_cruise_set and not ui_state.is_metric: + set_speed *= KM_TO_MILE + + set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(set_speed)) + rl.draw_text_ex(self._font_display, set_speed_text, rl.Vector2(x + 13 + 4, y + 3 - 8 - 3 + 4), + FONT_SIZES.set_speed, 0, set_speed_color) + rl.draw_text_ex(self._font_semi_bold, tr("MAX"), rl.Vector2(x + 25, y + FONT_SIZES.set_speed - 7 + 4), + FONT_SIZES.max_speed, 0, max_color) + def _draw_lateral_control_overlay(self, center_x: int, center_y: int, wheel_size: int) -> None: """Draw a letter overlay indicating current lateral control mode (only when wheel is visible).""" if not self.show_lateral_control or self._wheel_alpha_filter.x <= 0 or self.lateral_mode is None: diff --git a/selfdrive/ui/bp/mici/onroad/model_renderer_bp.py b/selfdrive/ui/bp/mici/onroad/model_renderer_bp.py index d8c4d5f37f..64f0fdaedf 100644 --- a/selfdrive/ui/bp/mici/onroad/model_renderer_bp.py +++ b/selfdrive/ui/bp/mici/onroad/model_renderer_bp.py @@ -9,6 +9,8 @@ from openpilot.bluepilot.ui.lib.bp_shaders import draw_rainbow_polygon # BluePilot: Rad Racer 8-bit road, shared with the TICI renderer from openpilot.selfdrive.ui.bp.onroad.rad_racer_road import RadRacerRoadMixin, RAD_RACER_DASH_LEN_M, RAD_RACER_GAP_LEN_M +# BluePilot: seasonal theme packs (colors.json overrides for road colors) +from openpilot.selfdrive.ui.bp.lib import theme_pack class ModelRendererBP(RadRacerRoadMixin, ModelRenderer): def __init__(self): @@ -18,8 +20,10 @@ def __init__(self): self._disable_lane_line_status_color = self._bp_params.get_bool("BPDisableLaneLineStatusColor") self._rainbow_lane_lines = self._bp_params.get_bool("BPRainbowLines") # BluePilot: Rad Racer 8-bit theme (green game road; dash scroll animation state) - self._rad_racer = self._bp_params.get_bool("BPRadRacerTheme") + self._rad_racer = theme_pack.rad_racer_active(self._bp_params) self._dash_phase = 0.0 + # BluePilot: seasonal theme pack (None when disabled) + self._theme_pack = theme_pack.get_active_pack(force=True) def prepare_projection(self, rect: rl.Rectangle) -> None: """Set clip region so _map_to_screen works before render(). @@ -38,7 +42,8 @@ def _update_state(self): if self._counter % 60 == 0: self._disable_lane_line_status_color = self._bp_params.get_bool("BPDisableLaneLineStatusColor") self._rainbow_lane_lines = self._bp_params.get_bool("BPRainbowLines") - self._rad_racer = self._bp_params.get_bool("BPRadRacerTheme") + self._rad_racer = theme_pack.rad_racer_active(self._bp_params) + self._theme_pack = theme_pack.get_active_pack() if ui_state.rainbow_path or self._rainbow_lane_lines: v = sm['carState'].vEgo @@ -55,9 +60,47 @@ def _draw_path(self, sm): return if ui_state.rainbow_path: draw_rainbow_polygon(self._rect, self._path.projected_points, rainbow_v=self._rainbow_v) + elif (themed_gradient := self._themed_path_gradient()) is not None: + # BluePilot: theme pack path colors replace the throttle/no-throttle gradient + if not self._path.projected_points.size: + return + path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32) + if ui_state.status == UIStatus.DISENGAGED: + draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90)) + else: + draw_polygon(self._rect, path_pts, gradient=themed_gradient) else: super()._draw_path(sm) + def _themed_path_gradient(self) -> Gradient | None: + """Bottom-to-top path gradient from the active theme pack, or None. + + PathEdge tints the near end, Path carries the ribbon and fades out at the horizon — + same alpha profile as the stock throttle gradient so depth perception is unchanged. + """ + if self._theme_pack is None: + return None + colors = self._theme_pack.rl_colors() + path = colors.get("Path") + if path is None: + return None + edge = colors.get("PathEdge", path) + return Gradient( + start=(0.0, 1.0), + end=(0.0, 0.0), + colors=[rl.Color(edge.r, edge.g, edge.b, 190), rl.Color(path.r, path.g, path.b, 150), rl.Color(path.r, path.g, path.b, 30)], + stops=[0.0, 0.5, 1.0], + ) + + def _get_ll_color(self, prob: float, adjacent: bool, left: bool): + """BluePilot: theme pack lane color with upstream's confidence-based alpha (disengaged stays black).""" + if self._theme_pack is not None and ui_state.status != UIStatus.DISENGAGED: + pack_color = self._theme_pack.rl_colors().get("LaneLines") + if pack_color is not None: + alpha = float(np.clip(prob, 0.0, 0.7)) * (pack_color.a / 255.0) + return rl.Color(pack_color.r, pack_color.g, pack_color.b, int(alpha * 255)) + return super()._get_ll_color(prob, adjacent, left) + def _draw_lane_lines(self): """Draw lane lines and road edges, with optional rainbow inner lane lines.""" # BluePilot: Rad Racer theme replaces all road rendering with the 8-bit game road diff --git a/selfdrive/ui/bp/mici/widgets/button_bp.py b/selfdrive/ui/bp/mici/widgets/button_bp.py index a35c7bbb41..8d2984b3d1 100644 --- a/selfdrive/ui/bp/mici/widgets/button_bp.py +++ b/selfdrive/ui/bp/mici/widgets/button_bp.py @@ -161,6 +161,33 @@ def _handle_mouse_release(self, mouse_pos): self._params.put_bool(self._param, bool(new_idx), block=False) +class BigMultiParamToggleStrBP(BigMultiParamToggleBP): + """Like BigMultiParamToggleBP but for a STRING param, with display labels decoupled from + stored values via `values` (parallel to `options`; defaults to the labels themselves, + with option index 0 stored as ""). Dynamic option lists (e.g. theme packs discovered on + disk) stay stable when entries come and go. + """ + + def __init__(self, text: str, param: str, options: list[str], values: list[str] | None = None, **kwargs): + # _values must exist before super().__init__ — parent construction calls _load_value() + self._values = values if values is not None else [""] + options[1:] + assert len(self._values) == len(options) + super().__init__(text, param, options, **kwargs) + + def _load_value(self): + stored = self._params.get(self._param) or "" + if isinstance(stored, bytes): + stored = stored.decode("utf-8", errors="replace") + # Case-insensitive value match so entries written by scripts still light up their option + idx = next((i for i, v in enumerate(self._values) if v and v.lower() == stored.lower()), 0) + self.set_value(self._options[idx]) + + def _handle_mouse_release(self, mouse_pos): + # Advance option and update display (BigMultiToggle), but store the option's VALUE, not its label. + BigMultiToggle._handle_mouse_release(self, mouse_pos) + self._params.put(self._param, self._values[self._options.index(self.value)], block=False) + + class BigParamControlBP(BigToggleBP, BigParamControl): def __init__(self, text: str, param: str, is_active_param: str = None, toggle_callback: Callable = None, diff --git a/selfdrive/ui/bp/onroad/augmented_road_view_bp.py b/selfdrive/ui/bp/onroad/augmented_road_view_bp.py index e454a09c4d..7df82fc8b2 100644 --- a/selfdrive/ui/bp/onroad/augmented_road_view_bp.py +++ b/selfdrive/ui/bp/onroad/augmented_road_view_bp.py @@ -23,6 +23,8 @@ from openpilot.selfdrive.ui.sunnypilot.onroad.developer_ui import DeveloperUiState, get_bottom_dev_ui_offset from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.bp.lib.ui_debug_logger import bp_ui_log +# BluePilot: unified theme selector (BPThemePack param) +from openpilot.selfdrive.ui.bp.lib import theme_pack, theme_scene class GaugeStyle(IntEnum): @@ -84,9 +86,8 @@ def __init__(self, *args, **kwargs): except (TypeError, ValueError): self._cached_gauge_size = 2 - # BluePilot: Rad Racer 8-bit theme + # BluePilot: Rad Racer 8-bit theme (drawing host; selection lives in theme_scene) self._rad_racer_theme = RadRacerTheme() - self._rad_racer_active = self._bp_params.get_bool("BPRadRacerTheme") def update_fade_out_bottom_overlay(self, _content_rect): """BluePilot: Skip MICI fade overlay on TICI — causes unwanted black gradient at bottom.""" @@ -109,8 +110,6 @@ def _render(self, rect): except (TypeError, ValueError): self._cached_gauge_size = 2 self._hybrid_gauge_style = GaugeStyle(self._bp_params.get("FordPrefGaugeStyle", return_default=True) or 0) - # BluePilot: Rad Racer theme toggle - self._rad_racer_active = self._bp_params.get_bool("BPRadRacerTheme") self._switch_stream_if_needed(ui_state.sm) self._update_calibration() @@ -145,10 +144,15 @@ def _render(self, rect): # Render the base camera view. Minimal Driving View suppression lives in CameraViewBP. CameraViewBP._render(self, rect) - # BluePilot: Rad Racer 8-bit theme takes over the whole scene (skips HUD/gauges/ball) - if self._rad_racer_active: + # BluePilot: unified theme scene dispatch. A scene that replaces the HUD (Rad Racer) + # takes over the whole render; pack scenes draw a background pass here and a + # foreground pass just before alerts. + _scene = theme_scene.active_scene() + if _scene is not None and _scene.replaces_hud(): self._render_rad_racer_scene(rect) return + if _scene is not None: + _scene.draw_background(self._content_rect, getattr(self, "_bp_hide_camera_view", False)) # Render model (uses full content rect for camera-space overlays) self.model_renderer.render(self._content_rect) @@ -212,6 +216,10 @@ def _render(self, rect): torque_rect = rl.Rectangle(ui_rect.x, ui_rect.y, ui_rect.width, ui_rect.height - get_bottom_dev_ui_offset()) self._torque_bar.render(torque_rect, gauge_height_offset=gauge_height_offset) + # BluePilot: theme scene foreground — decor + near particles over the HUD, under alerts. + if _scene is not None and not _scene.replaces_hud(): + _scene.draw_foreground(self._content_rect, getattr(self, "_bp_hide_camera_view", False)) + # Alerts last so they are never covered by gauges or other overlays. # BluePilot: Full-screen alerts (e.g. reverse gear) must use content_rect so they cover # the confidence ball strip; otherwise the camera shows through where the ball was. diff --git a/selfdrive/ui/bp/onroad/cameraview_bp.py b/selfdrive/ui/bp/onroad/cameraview_bp.py index 117edd8c6a..bdb8fa0f3e 100644 --- a/selfdrive/ui/bp/onroad/cameraview_bp.py +++ b/selfdrive/ui/bp/onroad/cameraview_bp.py @@ -4,6 +4,8 @@ from openpilot.common.params import Params from openpilot.selfdrive.ui.onroad.cameraview import CameraView as TiciCameraView from openpilot.selfdrive.ui.ui_state import ui_state +# BluePilot: unified theme selector (BPThemePack param) +from openpilot.selfdrive.ui.bp.lib import theme_pack class CameraViewBP(TiciCameraView): @@ -13,7 +15,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._bp_camera_params = Params() self._bp_hide_camera_view = self._bp_camera_params.get_bool("BPHideCameraView") - self._bp_rad_racer_theme = self._bp_camera_params.get_bool("BPRadRacerTheme") + self._bp_rad_racer_theme = theme_pack.rad_racer_active(self._bp_camera_params) self._bp_camera_param_counter = 0 def _update_state(self): @@ -22,7 +24,7 @@ def _update_state(self): if self._bp_camera_param_counter >= 60: self._bp_camera_param_counter = 0 self._bp_hide_camera_view = self._bp_camera_params.get_bool("BPHideCameraView") - self._bp_rad_racer_theme = self._bp_camera_params.get_bool("BPRadRacerTheme") + self._bp_rad_racer_theme = theme_pack.rad_racer_active(self._bp_camera_params) def _should_hide_camera_view(self) -> bool: return ( diff --git a/selfdrive/ui/bp/onroad/chevron_metrics_bp.py b/selfdrive/ui/bp/onroad/chevron_metrics_bp.py index 2f1a7d7e5a..15ded0c9d2 100644 --- a/selfdrive/ui/bp/onroad/chevron_metrics_bp.py +++ b/selfdrive/ui/bp/onroad/chevron_metrics_bp.py @@ -6,6 +6,8 @@ from openpilot.selfdrive.ui.sunnypilot.onroad.chevron_metrics import ChevronMetrics, ChevronOptions from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.selfdrive.ui.bp.lib.ui_debug_logger import bp_ui_log +# BluePilot: seasonal theme packs (LeadMarker recolors the vision-lead box border) +from openpilot.selfdrive.ui.bp.lib import theme_pack # BluePilot: Inversion thresholds for radar overlay (close-proximity mode) # When lead is closer than INVERT_UNDER_M, overlay moves to top with chevron flipped @@ -145,15 +147,19 @@ def _render_text_lines_bp(self, text_lines: list[str], lead_vehicle, else: y = chevron_y + CHEVRON_H - # Border color: blue for radar, red for vision + # Border color: blue for radar, red (or theme pack LeadMarker) for vision if is_radar: glow_color = LEAD_RADAR_GLOW border_color = rl.Color(RADAR_BORDER_COLOR_BASE.r, RADAR_BORDER_COLOR_BASE.g, RADAR_BORDER_COLOR_BASE.b, alpha) else: - glow_color = LEAD_VISION_GLOW - border_color = rl.Color(VISION_BORDER_COLOR_BASE.r, VISION_BORDER_COLOR_BASE.g, - VISION_BORDER_COLOR_BASE.b, alpha) + vision_base, glow_color = VISION_BORDER_COLOR_BASE, LEAD_VISION_GLOW + pack = theme_pack.get_active_pack() + if pack is not None: + pack_marker = pack.rl_colors().get("LeadMarker") + if pack_marker is not None: + vision_base = glow_color = pack_marker + border_color = rl.Color(vision_base.r, vision_base.g, vision_base.b, alpha) border_thickness = max(2, int(6 * scale)) # Gap between triangle outline and box edge so the outline doesn't bleed into the box diff --git a/selfdrive/ui/bp/onroad/exp_button_bp.py b/selfdrive/ui/bp/onroad/exp_button_bp.py index 4ab138ddde..e254e0faa5 100644 --- a/selfdrive/ui/bp/onroad/exp_button_bp.py +++ b/selfdrive/ui/bp/onroad/exp_button_bp.py @@ -8,6 +8,8 @@ ) from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app +# BluePilot: seasonal theme packs (steering wheel icon override) +from openpilot.selfdrive.ui.bp.lib import theme_pack class ExpButtonBP(ExpButton): @@ -16,8 +18,10 @@ class ExpButtonBP(ExpButton): def __init__(self, button_size: int, icon_size: int): super().__init__(button_size, icon_size) self._txt_wheel_comma_4 = gui_app.texture("icons_mici/wheel.png", icon_size, icon_size) + self._icon_size = icon_size self._animate_steering_wheel = self._params.get_bool("BPAnimateSteeringWheel") self._wheel_icon_style = ensure_steering_wheel_icon_style_initialized(self._params, SteeringWheelIconStyle.COMMA_3X) + self._theme_pack = theme_pack.get_active_pack(force=True) self._param_counter = 0 def _update_state(self) -> None: @@ -29,6 +33,7 @@ def _update_state(self) -> None: self._param_counter = 0 self._animate_steering_wheel = self._params.get_bool("BPAnimateSteeringWheel") self._wheel_icon_style = get_steering_wheel_icon_style(self._params, SteeringWheelIconStyle.COMMA_3X) + self._theme_pack = theme_pack.get_active_pack() def _render(self, rect: rl.Rectangle) -> None: center_x = int(self._rect.x + self._rect.width // 2) @@ -38,6 +43,11 @@ def _render(self, rect: rl.Rectangle) -> None: experimental_mode = self._held_or_actual_mode() wheel_texture = self._txt_wheel_comma_4 if self._wheel_icon_style == SteeringWheelIconStyle.COMMA_4 else self._txt_wheel + # BluePilot: theme pack steering wheel icon wins over the built-in styles + if self._theme_pack is not None: + pack_wheel = self._theme_pack.wheel_texture(self._icon_size) + if pack_wheel is not None: + wheel_texture = pack_wheel texture = self._txt_exp if experimental_mode else wheel_texture rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg) diff --git a/selfdrive/ui/bp/onroad/model_renderer_bp.py b/selfdrive/ui/bp/onroad/model_renderer_bp.py index eba81f492a..d787e5503e 100644 --- a/selfdrive/ui/bp/onroad/model_renderer_bp.py +++ b/selfdrive/ui/bp/onroad/model_renderer_bp.py @@ -6,10 +6,12 @@ from openpilot.selfdrive.ui.bp.onroad.chevron_metrics_bp import ChevronMetricsBP from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.lib.shader_polygon import draw_polygon +from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient # BluePilot: Rainbow shader moved to BP module after upstream removal from openpilot.bluepilot.ui.lib.bp_shaders import draw_rainbow_polygon from openpilot.selfdrive.ui.bp.lib.ui_debug_logger import bp_ui_log +# BluePilot: seasonal theme packs (colors.json overrides for road colors) +from openpilot.selfdrive.ui.bp.lib import theme_pack # BluePilot: Lane line colors by status (upstream removed LANE_LINE_COLORS dict) LANE_LINE_COLORS_BP = { @@ -68,9 +70,11 @@ def __init__(self): self._hide_camera_view = self._bp_params.get_bool("BPHideCameraView") self._rainbow_lane_lines = self._bp_params.get_bool("BPRainbowLines") # BluePilot: Rad Racer 8-bit theme (green game road, dash scroll animation state) - self._rad_racer = self._bp_params.get_bool("BPRadRacerTheme") + self._rad_racer = theme_pack.rad_racer_active(self._bp_params) self._dash_phase = 0.0 self._transform_dirty = True + # BluePilot: seasonal theme pack (None when disabled) + self._theme_pack = theme_pack.get_active_pack(force=True) # BluePilot: Lead position smoothing filters to reduce radar jitter dt = 1 / gui_app.target_fps @@ -99,7 +103,7 @@ def _refresh_bp_params(self) -> None: self._transform_dirty = True self._hide_camera_view = hide_camera_view - rad_racer = self._bp_params.get_bool("BPRadRacerTheme") + rad_racer = theme_pack.rad_racer_active(self._bp_params) if rad_racer != self._rad_racer: self._transform_dirty = True self._rad_racer = rad_racer @@ -107,6 +111,10 @@ def _refresh_bp_params(self) -> None: self.ford_overlay_enabled = self._bp_params.get_bool("FordPrefShowRadarLeadOverlay") self._disable_lane_line_status_color = self._bp_params.get_bool("BPDisableLaneLineStatusColor") self._rainbow_lane_lines = self._bp_params.get_bool("BPRainbowLines") + new_pack = theme_pack.get_active_pack() + if (new_pack is None) != (self._theme_pack is None): + self._transform_dirty = True # themed lane widths differ; re-project on toggle + self._theme_pack = new_pack def _render(self, rect: rl.Rectangle): sm = ui_state.sm @@ -261,7 +269,7 @@ def _update_model(self, lead, path_x_array): for i, lane_line in enumerate(self._lane_lines): is_current_lane = (i == 1 or i == 2) lane_line_width = LANE_LINE_WIDTH - if self._hide_camera_view or self._rad_racer: + if self._hide_camera_view or self._rad_racer or self._theme_pack is not None: lane_line_width = MINIMAL_VIEW_LANE_LINE_WIDTH if is_current_lane else MINIMAL_VIEW_OUTER_LANE_LINE_WIDTH lane_line.projected_points = self._map_line_to_polygon( lane_line.raw_points, lane_line_width * self._lane_line_probs[i], 0.0, max_idx, max_distance @@ -270,8 +278,10 @@ def _update_model(self, lead, path_x_array): # BluePilot: Rad Racer solid outer lines are thicker than minimal view if self._rad_racer: road_edge_width = RAD_RACER_ROAD_EDGE_WIDTH + elif self._hide_camera_view or self._theme_pack is not None: + road_edge_width = MINIMAL_VIEW_ROAD_EDGE_WIDTH else: - road_edge_width = MINIMAL_VIEW_ROAD_EDGE_WIDTH if self._hide_camera_view else ROAD_EDGE_WIDTH + road_edge_width = ROAD_EDGE_WIDTH for road_edge in self._road_edges: road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, road_edge_width, 0.0, max_idx, max_distance) @@ -338,6 +348,12 @@ def _get_ll_color(self, prob: float, is_current_lane: bool) -> rl.Color: if ui_state.status == UIStatus.DISENGAGED: return rl.Color(0, 0, 0, 255) + # BluePilot: theme pack lane color replaces status/white coloring (disengaged stays black) + if self._theme_pack is not None: + pack_color = self._theme_pack.rl_colors().get("LaneLines") + if pack_color is not None: + return pack_color + if not is_current_lane or self._disable_lane_line_status_color: return rl.Color(255, 255, 255, 255) @@ -371,7 +387,8 @@ def _draw_enhanced_lane_lines(self): if road_edge.projected_points.size == 0: continue edge_alpha = np.clip(1.0 - self._road_edge_stds[i], 0.0, 1.0) * 0.6 - color = rl.Color(255, 0, 0, int(edge_alpha * 255)) + edge_base = self._road_edge_base_color() + color = rl.Color(edge_base.r, edge_base.g, edge_base.b, int(edge_alpha * 255)) draw_polygon(self._rect, road_edge.projected_points, color) self._draw_road_edge_glow_effects() @@ -394,10 +411,20 @@ def _draw_lane_glow_effects(self): base_color = self._get_ll_color(float(self._lane_line_probs[i]), is_current_lane) expanded_points = self._expand_polygon(lane_line.projected_points, 12.0) if expanded_points.size > 0: - alpha = int(base_alpha * 0.12 * 255) + # Themed lanes get a stronger halo so the pack color reads as glow, not just a line + glow_mult = 0.22 if self._theme_pack is not None else 0.12 + alpha = int(base_alpha * glow_mult * 255) color = rl.Color(base_color.r, base_color.g, base_color.b, alpha) draw_polygon(self._rect, expanded_points, color) + def _road_edge_base_color(self) -> rl.Color: + """Road edge color: theme pack RoadEdges when set, stock red otherwise.""" + if self._theme_pack is not None: + pack_color = self._theme_pack.rl_colors().get("RoadEdges") + if pack_color is not None: + return pack_color + return rl.Color(255, 0, 0, 255) + def _rainbow_lane_lines_active(self, sm) -> bool: """Return true when inner lane lines should use the rainbow shader.""" if not self._rainbow_lane_lines or self._disable_lane_line_status_color: @@ -421,8 +448,10 @@ def _draw_road_edge_glow_effects(self): continue expanded_points = self._expand_polygon(road_edge.projected_points, 18.0) if expanded_points.size > 0: - alpha = int(edge_alpha * 0.08 * 255) - color = rl.Color(255, 0, 0, alpha) + glow_mult = 0.16 if self._theme_pack is not None else 0.08 + alpha = int(edge_alpha * glow_mult * 255) + edge_base = self._road_edge_base_color() + color = rl.Color(edge_base.r, edge_base.g, edge_base.b, alpha) draw_polygon(self._rect, expanded_points, color) def _expand_polygon(self, points: np.ndarray, width: float) -> np.ndarray: @@ -488,11 +517,34 @@ def _draw_path(self, sm): if ui_state.rainbow_path: draw_rainbow_polygon(self._rect, self._path.projected_points, rainbow_v=self._rainbow_v) + elif (themed_gradient := self._themed_path_gradient()) is not None: + # BluePilot: theme pack path colors replace the throttle/no-throttle gradient + draw_polygon(self._rect, self._path.projected_points, gradient=themed_gradient) else: super()._draw_path(sm) self._draw_path_edges() + def _themed_path_gradient(self) -> Gradient | None: + """Bottom-to-top path gradient from the active theme pack, or None. + + PathEdge tints the near end, Path carries the ribbon and fades out at the horizon — + same alpha profile as the stock throttle gradient so depth perception is unchanged. + """ + if self._theme_pack is None: + return None + colors = self._theme_pack.rl_colors() + path = colors.get("Path") + if path is None: + return None + edge = colors.get("PathEdge", path) + return Gradient( + start=(0.0, 1.0), + end=(0.0, 0.0), + colors=[rl.Color(edge.r, edge.g, edge.b, 190), rl.Color(path.r, path.g, path.b, 150), rl.Color(path.r, path.g, path.b, 30)], + stops=[0.0, 0.5, 1.0], + ) + def _draw_path_edges(self): """Draw path edges (left, right, and front) with status-based colors. @@ -510,7 +562,11 @@ def _draw_path_edges(self): left_edge = points[:mid_point] right_edge = points[mid_point:][::-1] - if self._disable_lane_line_status_color and ui_state.status != UIStatus.DISENGAGED: + pack_edge = self._theme_pack.rl_colors().get("PathEdge") if self._theme_pack is not None else None + if pack_edge is not None and ui_state.status != UIStatus.DISENGAGED: + # BluePilot: theme pack path edge color (disengaged keeps the status blackout) + edge_color = pack_edge + elif self._disable_lane_line_status_color and ui_state.status != UIStatus.DISENGAGED: edge_color = rl.Color(255, 255, 255, 255) else: edge_color = LANE_LINE_COLORS_BP.get(ui_state.status, LANE_LINE_COLORS_BP[UIStatus.DISENGAGED]) @@ -581,8 +637,11 @@ def _draw_lead_indicator(self): LEAD_RADAR_CHEVRON_BASE.b, lead.fill_alpha) else: glow_color = LEAD_VISION_GLOW - chevron_color = rl.Color(LEAD_VISION_CHEVRON_BASE.r, LEAD_VISION_CHEVRON_BASE.g, - LEAD_VISION_CHEVRON_BASE.b, lead.fill_alpha) + # BluePilot: theme pack lead marker color for vision leads (radar leads stay blue) + vision_base = LEAD_VISION_CHEVRON_BASE + if self._theme_pack is not None: + vision_base = self._theme_pack.rl_colors().get("LeadMarker", vision_base) + chevron_color = rl.Color(vision_base.r, vision_base.g, vision_base.b, lead.fill_alpha) rl.draw_triangle_fan(lead.glow, len(lead.glow), glow_color) rl.draw_triangle_fan(lead.chevron, len(lead.chevron), chevron_color) diff --git a/selfdrive/ui/bp/onroad/torque_bar_renderer_bp.py b/selfdrive/ui/bp/onroad/torque_bar_renderer_bp.py index 95823bb1fb..a05185a27b 100644 --- a/selfdrive/ui/bp/onroad/torque_bar_renderer_bp.py +++ b/selfdrive/ui/bp/onroad/torque_bar_renderer_bp.py @@ -23,6 +23,18 @@ from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient from openpilot.selfdrive.ui.bp.lib.ui_debug_logger import bp_ui_log from openpilot.selfdrive.ui.bp.onroad.torque_bar_state_bp import TorqueBarStateBP +# BluePilot: seasonal theme packs (Accent recolors the active fill) +from openpilot.selfdrive.ui.bp.lib import theme_pack + + +def _accent_or(default: rl.Color, alpha: int) -> rl.Color: + """Theme pack Accent color at the given alpha, or the default color.""" + pack = theme_pack.get_active_pack() + if pack is not None: + accent = pack.rl_colors().get("Accent") + if accent is not None: + return rl.Color(accent.r, accent.g, accent.b, alpha) + return default # Arc geometry (legacy arc — kept for MICI / fallback) TORQUE_ANGLE_SPAN = 12.7 @@ -232,20 +244,21 @@ def render(self, rect: rl.Rectangle, gauge_height_offset: float = 0.0): end_grad_pt = (cx * (1 - 0.65) + (max(bg_pts[:, 0]) * 0.65)) / effective_rect.width if is_active: - # Smooth color transition: white → yellow/orange at high torque + # Smooth color transition: base (white or theme Accent) → yellow/orange at high torque high_blend = max(0.0, abs_torque - 0.75) * 4 + base = _accent_or(rl.Color(255, 255, 255, int(255 * 0.9 * alpha)), int(255 * 0.9 * alpha)) start_color = blend_colors( - rl.Color(255, 255, 255, int(255 * 0.9 * alpha)), + base, rl.Color(255, 200, 0, int(255 * alpha)), high_blend, ) end_color = blend_colors( - rl.Color(255, 255, 255, int(255 * 0.9 * alpha)), + base, rl.Color(255, 115, 0, int(255 * alpha)), high_blend, ) else: - start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * alpha)) + start_color = end_color = _accent_or(rl.Color(255, 255, 255, int(255 * 0.35 * alpha)), int(255 * 0.35 * alpha)) gradient = Gradient( start=(start_grad_pt, 0), diff --git a/selfdrive/ui/bp/tests/test_theme_scene.py b/selfdrive/ui/bp/tests/test_theme_scene.py new file mode 100644 index 0000000000..550755691b --- /dev/null +++ b/selfdrive/ui/bp/tests/test_theme_scene.py @@ -0,0 +1,237 @@ +"""Tests for the theme scene system (GL-free: parsing, simulation, dispatch).""" +import json +import math +import os +import unittest +from unittest import mock + +from openpilot.common.basedir import BASEDIR +from openpilot.selfdrive.ui.bp.lib import theme_pack, theme_scene +from openpilot.selfdrive.ui.bp.lib.theme_scene import ( + MAX_BURST_POOL, MAX_PARTICLES_MINIMAL, PackScene, RadRacerScene, SceneSpec, + _resolve_tint, load_scene_spec, +) + +PACKS_DIR = os.path.join(BASEDIR, "selfdrive", "assets", "bp_themes") + +GOOD = { + "version": 1, + "sky": {"camera_tint_alpha": 40}, + "layers": [ + {"type": "particles", "sprite": "snowflake", "mode": "fall", "count": 20, + "size_px": [10, 24], "fall_speed": [0.05, 0.11], "tints": ["#FFFFFF", "colors:Accent"]}, + {"type": "burst", "tints": ["#FFD700"]}, + {"type": "decor", "sprite": "bulb", "anchor": "top_edge", "idle": "twinkle"}, + ], + "foreground": {"particles_over_hud": True, "corner_accents": True, "corner_alpha": 55}, +} + + +def _write(tmpdir, data) -> str: + p = os.path.join(tmpdir, "scene.json") + with open(p, "w") as f: + if isinstance(data, str): + f.write(data) + else: + json.dump(data, f) + return p + + +class _FakePack: + name = "test_pack" + root = "/nonexistent" + colors = {"Accent": (10, 20, 30, 255)} + + def rl_colors(self): + return {} + + +class TestSceneSpecParsing(unittest.TestCase): + def setUp(self): + import tempfile + self.tmp = tempfile.mkdtemp() + + def test_happy_path(self): + spec = load_scene_spec(_write(self.tmp, GOOD), {"Accent": (10, 20, 30, 255)}) + self.assertIsNotNone(spec) + self.assertEqual(len(spec.particles), 1) + self.assertEqual(len(spec.bursts), 1) + self.assertEqual(len(spec.decor), 1) + self.assertEqual(spec.particles[0].count, 20) + self.assertEqual(spec.particles[0].tints[1], (10, 20, 30)) # colors:Accent resolved + self.assertTrue(spec.corner_accents) + + def test_missing_file(self): + self.assertIsNone(load_scene_spec(os.path.join(self.tmp, "nope.json"))) + + def test_invalid_json(self): + self.assertIsNone(load_scene_spec(_write(self.tmp, "{not json"))) + + def test_wrong_version(self): + self.assertIsNone(load_scene_spec(_write(self.tmp, {"version": 2, "layers": []}))) + + def test_unknown_sprite_layer_dropped_others_kept(self): + d = dict(GOOD) + d["layers"] = [{"type": "particles", "sprite": "unicorn"}] + GOOD["layers"] + spec = load_scene_spec(_write(self.tmp, d), {}) + self.assertEqual(len(spec.particles), 1) # the snowflake layer survived + + def test_count_clamped_to_budget(self): + d = {"version": 1, "layers": [ + {"type": "particles", "sprite": "snowflake", "count": 500}, + {"type": "particles", "sprite": "heart", "count": 500}, + ]} + spec = load_scene_spec(_write(self.tmp, d), {}) + total = sum(pl.count for pl in spec.particles) + self.assertLessEqual(total, MAX_PARTICLES_MINIMAL) + + def test_tint_resolution(self): + colors = {"Accent": (1, 2, 3, 255)} + self.assertEqual(_resolve_tint("#FF8000", colors), (255, 128, 0)) + self.assertEqual(_resolve_tint([4, 5, 6], colors), (4, 5, 6)) + self.assertEqual(_resolve_tint("colors:Accent", colors), (1, 2, 3)) + self.assertEqual(_resolve_tint("colors:Nope", colors), (255, 255, 255)) + self.assertEqual(_resolve_tint("garbage", colors), (255, 255, 255)) + + +class TestSimulation(unittest.TestCase): + def _scene(self, name="p1"): + spec = load_scene_spec.__wrapped__ if hasattr(load_scene_spec, "__wrapped__") else None + import tempfile + tmp = tempfile.mkdtemp() + p = _write(tmp, GOOD) + s = load_scene_spec(p, {}) + pack = _FakePack() + pack.name = name + return PackScene(pack, s) + + def test_seed_determinism(self): + a = self._scene("same") + b = self._scene("same") + c = self._scene("different") + self.assertEqual(a.spec.particles[0].particles, b.spec.particles[0].particles) + self.assertNotEqual(a.spec.particles[0].particles, c.spec.particles[0].particles) + + def test_long_sim_stays_bounded_no_alloc(self): + scene = self._scene() + layer = scene.spec.particles[0] + burst = scene.spec.bursts[0] + n0 = len(layer.particles) + dt = 1.0 / 60.0 + for _ in range(10_000): + layer.step(dt, 1.5) + burst.step(dt, scene._rng, True) + self.assertLessEqual(len(burst.pool), MAX_BURST_POOL) + self.assertEqual(len(layer.particles), n0) + for p in layer.particles: + self.assertGreaterEqual(p[1], -0.2) + self.assertLessEqual(p[1], 1.2) + self.assertTrue(math.isfinite(p[0]) and math.isfinite(p[1])) + + def test_burst_pool_expires(self): + scene = self._scene() + burst = scene.spec.bursts[0] + dt = 1.0 / 60.0 + for _ in range(2000): + burst.step(dt, scene._rng, True) + # after enough time with expiry, pool stays comfortably under the cap + self.assertLess(len(burst.pool), MAX_BURST_POOL) + + +class TestBundledScenes(unittest.TestCase): + def test_all_bundled_scene_files_parse(self): + found = 0 + for pack in sorted(os.listdir(PACKS_DIR)): + p = os.path.join(PACKS_DIR, pack, "scene.json") + if not os.path.isfile(p): + continue + found += 1 + colors_p = os.path.join(PACKS_DIR, pack, "colors", "colors.json") + colors = {} + if os.path.isfile(colors_p): + with open(colors_p) as f: + colors = {k: (v["red"], v["green"], v["blue"], v["alpha"]) for k, v in json.load(f).items()} + spec = load_scene_spec(p, colors) + self.assertIsNotNone(spec, f"{pack}/scene.json failed to parse") + self.assertGreater(len(spec.particles) + len(spec.bursts) + len(spec.decor), 0, pack) + pack_dirs = [d for d in os.listdir(PACKS_DIR) if os.path.isdir(os.path.join(PACKS_DIR, d))] + self.assertEqual(found, len(pack_dirs), "every bundled pack must ship a scene.json") + + def test_every_bundled_pack_has_a_hero(self): + """The mascot layer is part of the pack contract now — parse must keep it.""" + for pack in sorted(os.listdir(PACKS_DIR)): + p = os.path.join(PACKS_DIR, pack, "scene.json") + if not os.path.isfile(p): + continue + spec = load_scene_spec(p, {}) + self.assertEqual(len(spec.heroes), 1, f"{pack} lost its hero layer in parsing") + self.assertLessEqual(spec.heroes[0].y_frac, 0.80, f"{pack} hero would sit under the torque bar") + + def test_invalid_hero_sprite_dropped(self): + import json as _json + import tempfile + d = {"version": 1, "layers": [{"type": "hero", "sprite": "no_such_sprite"}, + {"type": "particles", "sprite": "heart", "count": 4}]} + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + _json.dump(d, f) + path = f.name + try: + spec = load_scene_spec(path, {}) + self.assertEqual(len(spec.heroes), 0) + self.assertEqual(len(spec.particles), 1) # bad layer dropped, rest kept + finally: + os.unlink(path) + + def test_bundled_sprites_render(self): + """Every sprite referenced by a bundled scene must render non-empty (GL-free).""" + from openpilot.selfdrive.ui.bp.lib import theme_scene_assets as tsa + names = set() + for pack in os.listdir(PACKS_DIR): + p = os.path.join(PACKS_DIR, pack, "scene.json") + if os.path.isfile(p): + with open(p) as f: + for layer in json.load(f).get("layers", []): + if "sprite" in layer: + names.add(layer["sprite"]) + names |= {"spark", "bat2"} # engine-internal uses + for n in sorted(names): + rgb, a = tsa.render_sprite(n, 64) + self.assertEqual(a.shape, (64, 64), n) + self.assertGreater(float(a.sum()), 10.0, f"sprite {n} rendered empty") + + +class TestDispatcher(unittest.TestCase): + def _reset(self): + theme_scene._scene_cache.update({"key": None, "scene": None, "checked_at": 0.0}) + + def test_rad_racer_capabilities(self): + self._reset() + with mock.patch.object(theme_pack, "rad_racer_active", return_value=True): + s = theme_scene.active_scene() + self.assertIsInstance(s, RadRacerScene) + self.assertTrue(s.replaces_hud()) + self.assertTrue(s.replaces_road()) + self._reset() + + def test_no_selection_returns_none(self): + self._reset() + with mock.patch.object(theme_pack, "rad_racer_active", return_value=False), \ + mock.patch.object(theme_pack, "get_active_pack", return_value=None): + self.assertIsNone(theme_scene.active_scene()) + self._reset() + + def test_colors_only_pack_gets_default_scene(self): + self._reset() + pack = _FakePack() + with mock.patch.object(theme_pack, "rad_racer_active", return_value=False), \ + mock.patch.object(theme_pack, "get_active_pack", return_value=pack): + s = theme_scene.active_scene() + self.assertIsInstance(s, PackScene) + self.assertFalse(s.replaces_hud()) + self.assertEqual(len(s.spec.particles), 0) # sky-only default + self.assertEqual(s.spec.sky_camera_tint_alpha, 0) + self._reset() + + +if __name__ == "__main__": + unittest.main() diff --git a/selfdrive/ui/bp/tests/test_theme_seasonal.py b/selfdrive/ui/bp/tests/test_theme_seasonal.py new file mode 100644 index 0000000000..56b8075187 --- /dev/null +++ b/selfdrive/ui/bp/tests/test_theme_seasonal.py @@ -0,0 +1,75 @@ +"""Auto seasonal theme: pack-declared season.json windows, movable feasts, user-pack parity.""" +import datetime +import json + +from openpilot.selfdrive.ui.bp.lib import theme_pack +from openpilot.selfdrive.ui.bp.lib.theme_pack import _easter, _thanksgiving, seasonal_pack + +d = datetime.date + + +class TestMovableFeasts: + def test_easter_dates(self): + # Known Easter Sundays (Gregorian) + assert _easter(2024) == d(2024, 3, 31) + assert _easter(2025) == d(2025, 4, 20) + assert _easter(2026) == d(2026, 4, 5) + assert _easter(2027) == d(2027, 3, 28) + + def test_thanksgiving_dates(self): + # Fourth Thursday of November + assert _thanksgiving(2024) == d(2024, 11, 28) + assert _thanksgiving(2025) == d(2025, 11, 27) + assert _thanksgiving(2026) == d(2026, 11, 26) + + +class TestSeasonalWindows: + def test_bundled_windows(self): + assert seasonal_pack(d(2026, 1, 3)) == "new_years" # wraps the year boundary + assert seasonal_pack(d(2026, 2, 14)) == "valentines_day" + assert seasonal_pack(d(2026, 3, 17)) == "st_patricks_day" + assert seasonal_pack(d(2026, 5, 5)) == "cinco_de_mayo" + assert seasonal_pack(d(2026, 7, 4)) == "fourth_of_july" + assert seasonal_pack(d(2026, 10, 31)) == "halloween_week" + assert seasonal_pack(d(2026, 12, 25)) == "christmas_week" + assert seasonal_pack(d(2026, 12, 30)) == "new_years" + + def test_movable_windows_and_overlap(self): + # Easter 2026 is Apr 5, so its week (Mar 30 - Apr 6) contains April Fools; + # the shorter April Fools window wins its own days. + assert seasonal_pack(d(2026, 4, 1)) == "april_fools" + assert seasonal_pack(d(2026, 4, 4)) == "easter_week" + assert seasonal_pack(d(2026, 4, 6)) == "easter_week" + # Thanksgiving 2026 is Nov 26: window Nov 21-28 + assert seasonal_pack(d(2026, 11, 21)) == "thanksgiving_week" + assert seasonal_pack(d(2026, 11, 28)) == "thanksgiving_week" + + def test_off_season_is_empty(self): + for day in (d(2026, 1, 20), d(2026, 6, 10), d(2026, 7, 22), d(2026, 9, 15), d(2026, 12, 5)): + assert seasonal_pack(day) == "" + + def test_every_day_resolves_to_known_pack(self): + bundled = set(theme_pack.list_packs()) | {""} + day = d(2026, 1, 1) + while day.year == 2026: + assert seasonal_pack(day) in bundled + day += datetime.timedelta(days=1) + + +class TestUserPackParity: + def test_user_pack_participates_equally(self, tmp_path, monkeypatch): + # A user-dropped pack with a season.json joins auto rotation like any bundled pack + root = tmp_path / "team_week" + root.mkdir() + (root / "season.json").write_text(json.dumps({"start": "09-01", "end": "09-07"})) + monkeypatch.setattr(theme_pack, "USER_DIR", str(tmp_path)) + assert seasonal_pack(d(2026, 9, 3)) == "team_week" + assert seasonal_pack(d(2026, 9, 20)) == "" + + def test_user_pack_shadows_bundled_window(self, tmp_path, monkeypatch): + # Same-name user pack overrides the bundled one entirely, window included + root = tmp_path / "christmas_week" + root.mkdir() + (root / "season.json").write_text(json.dumps({"start": "12-01", "end": "12-31"})) + monkeypatch.setattr(theme_pack, "USER_DIR", str(tmp_path)) + assert seasonal_pack(d(2026, 12, 5)) == "christmas_week" diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index 6b64289766..ad8c2f2ec7 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -16,6 +16,9 @@ from openpilot.sunnypilot.selfdrive.ui.quiet_mode import QuietMode +# BluePilot: seasonal theme packs (sound overrides by matching filename) +from openpilot.selfdrive.ui.bp.lib import theme_pack + SAMPLE_RATE = 48000 SAMPLE_BUFFER = 4096 # (approx 100ms) MAX_VOLUME = 1.0 @@ -93,17 +96,31 @@ def __init__(self): def load_sounds(self): self.loaded_sounds: dict[int, np.ndarray] = {} + # BluePilot: theme pack sound overrides — pack wavs replace stock files of the same name + pack = theme_pack.get_active_pack(force=True) + self._theme_pack_name = pack.name if pack else "" + # Load all sounds for sound in sound_list: filename, play_count, volume = sound_list[sound] - with wave.open(BASEDIR + "/selfdrive/assets/sounds/" + filename, 'r') as wavefile: - assert wavefile.getnchannels() == 1 - assert wavefile.getsampwidth() == 2 - assert wavefile.getframerate() == SAMPLE_RATE - - length = wavefile.getnframes() - self.loaded_sounds[sound] = np.frombuffer(wavefile.readframes(length), dtype=np.int16).astype(np.float32) / (2**16/2) + stock_path = BASEDIR + "/selfdrive/assets/sounds/" + filename + pack_path = pack.sound_path(filename) if pack else None + for path in filter(None, (pack_path, stock_path)): + try: + with wave.open(path, 'r') as wavefile: + assert wavefile.getnchannels() == 1 + assert wavefile.getsampwidth() == 2 + assert wavefile.getframerate() == SAMPLE_RATE + + length = wavefile.getnframes() + self.loaded_sounds[sound] = np.frombuffer(wavefile.readframes(length), dtype=np.int16).astype(np.float32) / (2**16/2) + break + except (OSError, AssertionError, wave.Error) as e: + # A malformed pack wav (stereo/44.1kHz/mp3-in-disguise) falls back to the stock sound + if path == stock_path: + raise + cloudlog.warning(f"soundd: theme pack sound {path} unusable ({e}), using stock {filename}") def get_sound_data(self, frames): # get "frames" worth of data from the current alert sound, looping when required @@ -177,6 +194,12 @@ def soundd_thread(self): self.load_param() + # BluePilot: hot-reload sounds when the theme pack changes (only while no alert plays) + if self.current_alert == AudibleAlert.none: + pack = theme_pack.get_active_pack() + if (pack.name if pack else "") != self._theme_pack_name: + self.load_sounds() + # Always update volume, even when alert is playing if sm.updated['soundPressure']: self.spl_filter_weighted.update(sm["soundPressure"].soundPressureWeightedDb) diff --git a/sunnypilot/sunnylink/settings_ui.json b/sunnypilot/sunnylink/settings_ui.json index d3a25d7887..229b12f431 100644 --- a/sunnypilot/sunnylink/settings_ui.json +++ b/sunnypilot/sunnylink/settings_ui.json @@ -2212,21 +2212,71 @@ "description": "Disable camera feed & only show lane lines and model path." }, { - "key": "BPRadRacerTheme", - "widget": "toggle", - "title": "[Visuals] 8-Bit Racer Theme (C3X)", - "description": "Retro 8-bit racing game onroad view: hides the camera feed, draws the road as green game lines, and moves all metrics into a gauge cluster at the bottom of the screen.", - "visibility": [ + "key": "BPThemePack", + "widget": "multiple_button", + "title": "[Visuals] Theme", + "description": "8-Bit Racer game view, or a seasonal theme pack (recolors the road and the wheel icon, adds the pack's scene).", + "options": [ { - "type": "not", - "condition": { - "type": "capability", - "field": "device_type", - "equals": "mici" - } + "value": "", + "label": "Off" + }, + { + "value": "rad_racer", + "label": "8-Bit Racer" + }, + { + "value": "april_fools", + "label": "April Fools" + }, + { + "value": "christmas_week", + "label": "Christmas" + }, + { + "value": "cinco_de_mayo", + "label": "Cinco de Mayo" + }, + { + "value": "easter_week", + "label": "Easter" + }, + { + "value": "fourth_of_july", + "label": "Fourth of July" + }, + { + "value": "halloween_week", + "label": "Halloween" + }, + { + "value": "new_years", + "label": "New Year's" + }, + { + "value": "st_patricks_day", + "label": "St. Patrick's" + }, + { + "value": "thanksgiving_week", + "label": "Thanksgiving" + }, + { + "value": "valentines_day", + "label": "Valentine's" + }, + { + "value": "veterans_day", + "label": "Veterans Day" } ] }, + { + "key": "BPThemeAutoSeasonal", + "widget": "toggle", + "title": "[Visuals] Auto Seasonal Theme", + "description": "During a holiday week, switch to that seasonal theme pack automatically. Outside holiday weeks the Theme selection applies." + }, { "key": "BPRainbowLines", "widget": "toggle",