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 0000000000..3ecb5de2fa Binary files /dev/null and b/selfdrive/assets/bp_themes/april_fools/steering_wheel/wheel.png differ 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 0000000000..9da97039ab Binary files /dev/null and b/selfdrive/assets/bp_themes/christmas_week/steering_wheel/wheel.png differ diff --git a/selfdrive/assets/bp_themes/cinco_de_mayo/colors/colors.json b/selfdrive/assets/bp_themes/cinco_de_mayo/colors/colors.json new file mode 100644 index 0000000000..4804f18ec1 --- /dev/null +++ b/selfdrive/assets/bp_themes/cinco_de_mayo/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 20, + "green": 25, + "blue": 15, + "alpha": 255 + }, + "Path": { + "red": 0, + "green": 150, + "blue": 80, + "alpha": 255 + }, + "PathEdge": { + "red": 215, + "green": 40, + "blue": 55, + "alpha": 255 + }, + "LaneLines": { + "red": 255, + "green": 255, + "blue": 255, + "alpha": 230 + }, + "RoadEdges": { + "red": 215, + "green": 40, + "blue": 55, + "alpha": 255 + }, + "LeadMarker": { + "red": 215, + "green": 40, + "blue": 55, + "alpha": 255 + }, + "Accent": { + "red": 0, + "green": 170, + "blue": 90, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/cinco_de_mayo/scene.json b/selfdrive/assets/bp_themes/cinco_de_mayo/scene.json new file mode 100644 index 0000000000..e748d9b818 --- /dev/null +++ b/selfdrive/assets/bp_themes/cinco_de_mayo/scene.json @@ -0,0 +1,90 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "mesas", + "tint": "#A0522D", + "horizon_frac": 0.46, + "height_frac": 0.26, + "alpha_minimal": 235, + "alpha_camera": 70, + "celestial": "sun", + "celestial_pos": [ + 0.22, + 0.16 + ], + "celestial_tint": "#FFCF6E", + "celestial_size_frac": 0.16 + }, + { + "type": "particles", + "sprite": "pennant", + "mode": "fall", + "count": 38, + "camera_count_scale": 0.4, + "size_px": [ + 17, + 34 + ], + "fall_speed": [ + 0.07, + 0.13 + ], + "sway_amp": [ + 0.004, + 0.015 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + -120, + 120 + ], + "tints": [ + "#009650", + "#FFFFFF", + "#D72837" + ], + "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": [ + "#009650", + "#FFFFFF", + "#D72837" + ], + "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": "sombrero", + "size_frac": 0.15 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": false + } +} diff --git a/selfdrive/assets/bp_themes/cinco_de_mayo/season.json b/selfdrive/assets/bp_themes/cinco_de_mayo/season.json new file mode 100644 index 0000000000..6046faba88 --- /dev/null +++ b/selfdrive/assets/bp_themes/cinco_de_mayo/season.json @@ -0,0 +1,4 @@ +{ + "start": "04-29", + "end": "05-06" +} diff --git a/selfdrive/assets/bp_themes/cinco_de_mayo/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/cinco_de_mayo/steering_wheel/wheel.png new file mode 100644 index 0000000000..bc44fb9f9d Binary files /dev/null and b/selfdrive/assets/bp_themes/cinco_de_mayo/steering_wheel/wheel.png differ 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 0000000000..f3b198fb41 Binary files /dev/null and b/selfdrive/assets/bp_themes/easter_week/steering_wheel/wheel.png differ 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 0000000000..1fd99b4c08 Binary files /dev/null and b/selfdrive/assets/bp_themes/fourth_of_july/steering_wheel/wheel.png differ 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 0000000000..dc3bce4db8 Binary files /dev/null and b/selfdrive/assets/bp_themes/halloween_week/steering_wheel/wheel.png differ diff --git a/selfdrive/assets/bp_themes/new_years/colors/colors.json b/selfdrive/assets/bp_themes/new_years/colors/colors.json new file mode 100644 index 0000000000..9f5cb60b49 --- /dev/null +++ b/selfdrive/assets/bp_themes/new_years/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 15, + "green": 15, + "blue": 35, + "alpha": 255 + }, + "Path": { + "red": 218, + "green": 175, + "blue": 50, + "alpha": 255 + }, + "PathEdge": { + "red": 255, + "green": 220, + "blue": 90, + "alpha": 255 + }, + "LaneLines": { + "red": 240, + "green": 240, + "blue": 255, + "alpha": 220 + }, + "RoadEdges": { + "red": 150, + "green": 150, + "blue": 200, + "alpha": 255 + }, + "LeadMarker": { + "red": 255, + "green": 215, + "blue": 0, + "alpha": 255 + }, + "Accent": { + "red": 255, + "green": 215, + "blue": 0, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/new_years/scene.json b/selfdrive/assets/bp_themes/new_years/scene.json new file mode 100644 index 0000000000..78c0311c1f --- /dev/null +++ b/selfdrive/assets/bp_themes/new_years/scene.json @@ -0,0 +1,125 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "skyline", + "tint": "#37406E", + "horizon_frac": 0.46, + "height_frac": 0.26, + "alpha_minimal": 235, + "alpha_camera": 70 + }, + { + "type": "balldrop", + "x_frac": 0.5, + "top_frac": 0.06, + "bottom_frac": 0.3, + "period_s": 24, + "ball_frac": 0.085, + "tint": "#FFE066", + "pole_tint": "#9AA4B8", + "alpha_minimal": 235, + "alpha_camera": 90 + }, + { + "type": "burst", + "period_s": [ + 1.0, + 2.0 + ], + "particles_per_burst": 30, + "radial_speed": [ + 0.12, + 0.26 + ], + "gravity": 0.12, + "fade_s": [ + 1.2, + 2.0 + ], + "region": [ + 0.06, + 0.03, + 0.88, + 0.3 + ], + "tints": [ + "#FFD700", + "#FFFFFF", + "colors:Accent" + ], + "blend": "additive" + }, + { + "type": "particles", + "sprite": "spark", + "mode": "fall", + "count": 14, + "camera_count_scale": 0.4, + "size_px": [ + 10, + 20 + ], + "fall_speed": [ + 0.03, + 0.06 + ], + "sway_amp": [ + 0.004, + 0.015 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + 0, + 0 + ], + "tints": [ + "#FFD700", + "#F0F0FF" + ], + "minimal_alpha": 200, + "camera_alpha": 90, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "additive" + }, + { + "type": "decor", + "sprite": "bulb", + "anchor": "top_edge", + "size_frac": 0.028, + "spacing_frac": 0.05, + "droop_frac": 0.028, + "tints": [ + "#FFD700", + "#FFFFFF", + "#F0E68C" + ], + "alpha_minimal": 225, + "alpha_camera": 90, + "idle": "twinkle" + }, + { + "type": "hero", + "x_frac": 0.5, + "y_frac": 0.8, + "alpha_minimal": 235, + "alpha_camera": 120, + "sprite": "orb", + "size_frac": 0.12, + "tint": "#FFD966" + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": true, + "corner_alpha": 60 + } +} diff --git a/selfdrive/assets/bp_themes/new_years/season.json b/selfdrive/assets/bp_themes/new_years/season.json new file mode 100644 index 0000000000..a938e3052f --- /dev/null +++ b/selfdrive/assets/bp_themes/new_years/season.json @@ -0,0 +1,4 @@ +{ + "start": "12-29", + "end": "01-05" +} diff --git a/selfdrive/assets/bp_themes/new_years/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/new_years/steering_wheel/wheel.png new file mode 100644 index 0000000000..9ebde998e5 Binary files /dev/null and b/selfdrive/assets/bp_themes/new_years/steering_wheel/wheel.png differ 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 0000000000..f663bfe207 Binary files /dev/null and b/selfdrive/assets/bp_themes/st_patricks_day/steering_wheel/wheel.png differ diff --git a/selfdrive/assets/bp_themes/thanksgiving_week/colors/colors.json b/selfdrive/assets/bp_themes/thanksgiving_week/colors/colors.json new file mode 100644 index 0000000000..58752dccaf --- /dev/null +++ b/selfdrive/assets/bp_themes/thanksgiving_week/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 30, + "green": 18, + "blue": 8, + "alpha": 255 + }, + "Path": { + "red": 185, + "green": 95, + "blue": 35, + "alpha": 255 + }, + "PathEdge": { + "red": 230, + "green": 150, + "blue": 50, + "alpha": 255 + }, + "LaneLines": { + "red": 245, + "green": 210, + "blue": 140, + "alpha": 220 + }, + "RoadEdges": { + "red": 210, + "green": 90, + "blue": 35, + "alpha": 255 + }, + "LeadMarker": { + "red": 210, + "green": 90, + "blue": 35, + "alpha": 255 + }, + "Accent": { + "red": 230, + "green": 150, + "blue": 50, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/thanksgiving_week/scene.json b/selfdrive/assets/bp_themes/thanksgiving_week/scene.json new file mode 100644 index 0000000000..7fdbd1c4a7 --- /dev/null +++ b/selfdrive/assets/bp_themes/thanksgiving_week/scene.json @@ -0,0 +1,105 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "farmland", + "tint": "#3A2410", + "horizon_frac": 0.46, + "height_frac": 0.24, + "alpha_minimal": 235, + "alpha_camera": 70, + "celestial": "sun", + "celestial_pos": [ + 0.2, + 0.18 + ], + "celestial_tint": "#FFC873", + "celestial_size_frac": 0.17 + }, + { + "type": "particles", + "sprite": "leaf", + "mode": "fall", + "count": 34, + "camera_count_scale": 0.4, + "size_px": [ + 29, + 61 + ], + "fall_speed": [ + 0.05, + 0.1 + ], + "sway_amp": [ + 0.008, + 0.02 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + -90, + 90 + ], + "tints": [ + "#B95F23", + "#E69632", + "#D25A23", + "#F5D28C" + ], + "minimal_alpha": 225, + "camera_alpha": 110, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "decor", + "sprite": "leaf", + "anchor": "top_left", + "size_frac": 0.055, + "spacing_frac": 0.06, + "droop_frac": 0.025, + "tints": [ + "#E69632", + "#B95F23" + ], + "alpha_minimal": 225, + "alpha_camera": 90, + "idle": "none" + }, + { + "type": "decor", + "sprite": "leaf", + "anchor": "top_right", + "size_frac": 0.055, + "spacing_frac": 0.06, + "droop_frac": 0.025, + "tints": [ + "#D25A23", + "#F5D28C" + ], + "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": "turkey", + "size_frac": 0.15 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": false + } +} diff --git a/selfdrive/assets/bp_themes/thanksgiving_week/season.json b/selfdrive/assets/bp_themes/thanksgiving_week/season.json new file mode 100644 index 0000000000..ee6abf478f --- /dev/null +++ b/selfdrive/assets/bp_themes/thanksgiving_week/season.json @@ -0,0 +1,5 @@ +{ + "anchor": "us_thanksgiving", + "offset_start": -5, + "offset_end": 2 +} diff --git a/selfdrive/assets/bp_themes/thanksgiving_week/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/thanksgiving_week/steering_wheel/wheel.png new file mode 100644 index 0000000000..aa126e36e1 Binary files /dev/null and b/selfdrive/assets/bp_themes/thanksgiving_week/steering_wheel/wheel.png differ diff --git a/selfdrive/assets/bp_themes/valentines_day/colors/colors.json b/selfdrive/assets/bp_themes/valentines_day/colors/colors.json new file mode 100644 index 0000000000..d85631a29b --- /dev/null +++ b/selfdrive/assets/bp_themes/valentines_day/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 40, + "green": 10, + "blue": 25, + "alpha": 255 + }, + "Path": { + "red": 230, + "green": 70, + "blue": 120, + "alpha": 255 + }, + "PathEdge": { + "red": 255, + "green": 120, + "blue": 180, + "alpha": 255 + }, + "LaneLines": { + "red": 255, + "green": 200, + "blue": 215, + "alpha": 220 + }, + "RoadEdges": { + "red": 255, + "green": 80, + "blue": 140, + "alpha": 255 + }, + "LeadMarker": { + "red": 255, + "green": 64, + "blue": 129, + "alpha": 255 + }, + "Accent": { + "red": 255, + "green": 105, + "blue": 180, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/valentines_day/scene.json b/selfdrive/assets/bp_themes/valentines_day/scene.json new file mode 100644 index 0000000000..1e8d2a761a --- /dev/null +++ b/selfdrive/assets/bp_themes/valentines_day/scene.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 40 + }, + "layers": [ + { + "type": "backdrop", + "style": "hearts", + "tint": "#3A1024", + "horizon_frac": 0.46, + "height_frac": 0.24, + "alpha_minimal": 235, + "alpha_camera": 70, + "celestial": "moon", + "celestial_pos": [ + 0.8, + 0.14 + ], + "celestial_tint": "#FFD9E8", + "celestial_size_frac": 0.15 + }, + { + "type": "particles", + "sprite": "heart", + "mode": "rise", + "count": 32, + "camera_count_scale": 0.4, + "size_px": [ + 27, + 58 + ], + "fall_speed": [ + 0.03, + 0.07 + ], + "sway_amp": [ + 0.006, + 0.018 + ], + "sway_freq_hz": [ + 0.15, + 0.5 + ], + "spin_dps": [ + 0, + 0 + ], + "tints": [ + "#FF69B4", + "#FF5088", + "#FFC8D7" + ], + "minimal_alpha": 225, + "camera_alpha": 110, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "decor", + "sprite": "heart", + "anchor": "top_edge", + "size_frac": 0.032, + "spacing_frac": 0.055, + "droop_frac": 0.028, + "tints": [ + "#FF69B4", + "#FF5088", + "#FFC8D7" + ], + "alpha_minimal": 225, + "alpha_camera": 90, + "idle": "sway" + }, + { + "type": "hero", + "x_frac": 0.5, + "y_frac": 0.8, + "alpha_minimal": 235, + "alpha_camera": 120, + "sprite": "winged_heart", + "size_frac": 0.15 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": true, + "corner_alpha": 55 + } +} diff --git a/selfdrive/assets/bp_themes/valentines_day/season.json b/selfdrive/assets/bp_themes/valentines_day/season.json new file mode 100644 index 0000000000..6d17a92d15 --- /dev/null +++ b/selfdrive/assets/bp_themes/valentines_day/season.json @@ -0,0 +1,4 @@ +{ + "start": "02-07", + "end": "02-15" +} diff --git a/selfdrive/assets/bp_themes/valentines_day/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/valentines_day/steering_wheel/wheel.png new file mode 100644 index 0000000000..866fffaceb Binary files /dev/null and b/selfdrive/assets/bp_themes/valentines_day/steering_wheel/wheel.png differ diff --git a/selfdrive/assets/bp_themes/veterans_day/colors/colors.json b/selfdrive/assets/bp_themes/veterans_day/colors/colors.json new file mode 100644 index 0000000000..8256d6bd5f --- /dev/null +++ b/selfdrive/assets/bp_themes/veterans_day/colors/colors.json @@ -0,0 +1,44 @@ +{ + "Background": { + "red": 10, + "green": 20, + "blue": 40, + "alpha": 255 + }, + "Path": { + "red": 40, + "green": 70, + "blue": 120, + "alpha": 255 + }, + "PathEdge": { + "red": 224, + "green": 60, + "blue": 49, + "alpha": 255 + }, + "LaneLines": { + "red": 240, + "green": 240, + "blue": 245, + "alpha": 235 + }, + "RoadEdges": { + "red": 224, + "green": 60, + "blue": 49, + "alpha": 255 + }, + "LeadMarker": { + "red": 255, + "green": 200, + "blue": 80, + "alpha": 255 + }, + "Accent": { + "red": 224, + "green": 60, + "blue": 49, + "alpha": 255 + } +} diff --git a/selfdrive/assets/bp_themes/veterans_day/scene.json b/selfdrive/assets/bp_themes/veterans_day/scene.json new file mode 100644 index 0000000000..fe76600ea6 --- /dev/null +++ b/selfdrive/assets/bp_themes/veterans_day/scene.json @@ -0,0 +1,133 @@ +{ + "version": 1, + "sky": { + "camera_tint_alpha": 35 + }, + "layers": [ + { + "type": "backdrop", + "style": "flags", + "tint": "#FFFFFF", + "horizon_frac": 0.46, + "height_frac": 0.26, + "alpha_minimal": 235, + "alpha_camera": 70, + "celestial": "sun", + "celestial_pos": [ + 0.82, + 0.14 + ], + "celestial_tint": "#FFD9A0", + "celestial_size_frac": 0.15 + }, + { + "type": "particles", + "sprite": "poppy", + "mode": "fall", + "count": 18, + "camera_count_scale": 0.4, + "size_px": [ + 20, + 40 + ], + "fall_speed": [ + 0.04, + 0.09 + ], + "sway_amp": [ + 0.004, + 0.012 + ], + "sway_freq_hz": [ + 0.12, + 0.4 + ], + "spin_dps": [ + -40, + 40 + ], + "tints": [ + "#FFFFFF" + ], + "minimal_alpha": 225, + "camera_alpha": 105, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "particles", + "sprite": "star5", + "mode": "fall", + "count": 10, + "camera_count_scale": 0.4, + "size_px": [ + 14, + 26 + ], + "fall_speed": [ + 0.03, + 0.06 + ], + "sway_amp": [ + 0.003, + 0.008 + ], + "sway_freq_hz": [ + 0.1, + 0.3 + ], + "spin_dps": [ + -25, + 25 + ], + "tints": [ + "#FFD966", + "#F0F0F5", + "#E03C31" + ], + "minimal_alpha": 210, + "camera_alpha": 95, + "speed_scale": 0.5, + "depth_split": 0.6, + "blend": "alpha" + }, + { + "type": "decor", + "sprite": "star5", + "anchor": "top_left", + "size_frac": 0.05, + "tints": [ + "#FFD966" + ], + "alpha_minimal": 225, + "alpha_camera": 100, + "idle": "twinkle" + }, + { + "type": "decor", + "sprite": "star5", + "anchor": "top_right", + "size_frac": 0.05, + "tints": [ + "#FFD966" + ], + "alpha_minimal": 225, + "alpha_camera": 100, + "idle": "twinkle" + }, + { + "type": "hero", + "x_frac": 0.5, + "y_frac": 0.8, + "alpha_minimal": 235, + "alpha_camera": 120, + "sprite": "usflag", + "size_frac": 0.15 + } + ], + "foreground": { + "particles_over_hud": true, + "corner_accents": false + } +} diff --git a/selfdrive/assets/bp_themes/veterans_day/season.json b/selfdrive/assets/bp_themes/veterans_day/season.json new file mode 100644 index 0000000000..a36291af52 --- /dev/null +++ b/selfdrive/assets/bp_themes/veterans_day/season.json @@ -0,0 +1,4 @@ +{ + "start": "11-08", + "end": "11-12" +} diff --git a/selfdrive/assets/bp_themes/veterans_day/steering_wheel/wheel.png b/selfdrive/assets/bp_themes/veterans_day/steering_wheel/wheel.png new file mode 100644 index 0000000000..9250a040db Binary files /dev/null and b/selfdrive/assets/bp_themes/veterans_day/steering_wheel/wheel.png differ 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",