mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
503 lines
27 KiB
Python
503 lines
27 KiB
Python
"""Parked onroad layout preview using the same native view as the driving UI."""
|
|
|
|
from typing import cast
|
|
|
|
from pathlib import Path
|
|
import json
|
|
import math
|
|
import struct
|
|
import time
|
|
from openpilot.common.filter_simple import FirstOrderFilter
|
|
import zlib
|
|
|
|
import numpy as np
|
|
from dataclasses import replace
|
|
|
|
import pyray as rl
|
|
|
|
from openpilot.starpilot.ui.layout_preview_transport import SCENES, validate_preview_document
|
|
from openpilot.starpilot.ui.developer_preview import OnroadVisualPreview
|
|
from openpilot.starpilot.ui.onroad_customization import (CAMERA_WIDGETS, PROFILES, ROAD_BASE_WIDTH, RUNTIME_PREVIEW_WIDGETS, fit_to_road, placement,
|
|
validate_document, widget_size)
|
|
from openpilot.starpilot.ui.onroad_widget_style import draw_widget_frame
|
|
from openpilot.starpilot.curve_speed.status import CurveObservation
|
|
from openpilot.starpilot.ui.onroad_state import ConditionalLead
|
|
from openpilot.starpilot.ui.onroad_state import AlertSize, ObservationKind, OnroadAlert, OnroadState, SourceReading, SpeedLimitObservation
|
|
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice, OnroadAppearance
|
|
from openpilot.starpilot.ui.onroad_dm import draw_monitor_art, monitor_bounds, monitor_visible
|
|
from openpilot.starpilot.ui.onroad_stopped_timer import PREVIEW_DURATION_S
|
|
from openpilot.starpilot.ui.layout_preview_sidebar import SIDEBAR_WIDTH, render_sidebar
|
|
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory
|
|
from openpilot.starpilot.ui.rainbow_path import RainbowPath
|
|
from openpilot.starpilot.ui.road_colors import edge_gradient, lane_color, path_mode, solid_gradient
|
|
from openpilot.starpilot.ui.onroad_customization import ROAD_COLORS
|
|
from openpilot.system.ui.lib.shader_polygon import Gradient, draw_polygon
|
|
|
|
|
|
CEM_SCENE_REASONS = {"cem_stop_light": "STOP LIGHT", "cem_lead": "LEAD", "cem_curve": "CURVE"}
|
|
IDLE_SECONDS = 30.0
|
|
ASSET_DIRECTORY = Path(__file__).parents[2] / "selfdrive/assets"
|
|
|
|
|
|
def render_sample_road(rect: rl.Rectangle, state: OnroadState, profile: Profile) -> None:
|
|
style = state.customization["roadColors"][str(profile)]
|
|
|
|
def points(values):
|
|
return np.array([(rect.x + x * rect.width, rect.y + y * rect.height) for x, y in values], dtype=np.float32)
|
|
|
|
draw_polygon(rect, points([(0, 1), (.4, .45), (.55, .45), (.83, 1)]), rl.Color(48, 57, 67, 255))
|
|
if profile == Profile.LARGE and "pathEdge" in style:
|
|
for strip in ([(.18, 1), (.44, .45), (.447, .45), (.231, 1)],
|
|
[(.639, 1), (.503, .45), (.51, .45), (.69, 1)]):
|
|
draw_polygon(rect, points(strip), gradient=edge_gradient(style["pathEdge"]))
|
|
path = [(.231, 1), (.447, .45), (.503, .45), (.639, 1)]
|
|
else:
|
|
path = [(.18, 1), (.44, .45), (.51, .45), (.69, 1)]
|
|
mode = path_mode(style)
|
|
gradient = (RainbowPath().gradient() if mode == "rainbow" else
|
|
solid_gradient(style.get("path", ROAD_COLORS["path"])) if mode == "color" else
|
|
Gradient((0, 1), (0, 0), [rl.Color(13, 248, 122, 102), rl.Color(114, 255, 92, 89),
|
|
rl.Color(114, 255, 92, 0)], [0, .5, 1]))
|
|
draw_polygon(rect, points(path), gradient=gradient)
|
|
for index, (bottom, top) in enumerate(((.16, .43), (.71, .52), (.02, .38), (.85, .57))):
|
|
adjacent = profile == Profile.COMPACT and index < 2
|
|
default = rl.Color(0, 255, 64, 178) if adjacent else rl.Color(255, 255, 255, 178)
|
|
line = [(bottom - .002, 1), (top - .0005, .45), (top + .0005, .45), (bottom + .002, 1)]
|
|
draw_polygon(rect, points(line), lane_color(style, adjacent, default, .7))
|
|
|
|
|
|
def sample_state(scene: str, document: dict) -> OnroadState:
|
|
if scene not in SCENES:
|
|
raise ValueError("Unknown layout preview scene")
|
|
# SLC presentation changes do not disengage either driving axis. Only the explicit
|
|
# cruise-off/lateral-only/longitudinal-only scenes change engagement.
|
|
lateral = scene not in ('long_only', 'slc_disengaged')
|
|
longitudinal = scene not in ('aol', 'slc_disengaged')
|
|
state = OnroadState(engaged=lateral or longitudinal, camera_available=False,
|
|
speed_mps=0.0 if scene in ("cem_lead", "stopped") else 22.0, cruise_kph=88.0,
|
|
stopped_duration_s=PREVIEW_DURATION_S if scene == "stopped" else None,
|
|
speed_limit=SpeedLimitObservation(kind=ObservationKind.VALID, source="dashboard",
|
|
speed_limit_mps=22.0 if scene == 'slc_pending' else 20.0,
|
|
accepted_speed_limit_mps=20.0, accepted_source='dashboard',
|
|
accepted_adjusted_limit_mps=22.0, effective_cluster_target_mps=22.0,
|
|
offset_mps=2.0, limiting_max_set=True,
|
|
pending_adjusted_limit_mps=24.0 if scene == 'slc_pending' else None,
|
|
presentation_adjusted_limit_mps=24.0 if scene == 'slc_pending' else 22.0,
|
|
pending_speed_limit_mps=22.0 if scene == "slc_pending" else None,
|
|
session_id="preview",
|
|
source_readings=(SourceReading('dashboard', True, 'valid', 22 if scene == 'slc_pending' else 20),
|
|
SourceReading('map', True, 'valid', 20),
|
|
SourceReading('vision', True, 'unknown')),
|
|
decision_id=1, presentation_id=1, action_enabled=longitudinal),
|
|
alert=OnroadAlert(), appearance=OnroadAppearance(wheel_pedal_feedback=True,
|
|
show_speed_limit_sign=True),
|
|
wheel_acceleration=-2.0 if scene == "braking" else 0.0,
|
|
metric=False, experimental_available=True,
|
|
experimental_enabled=scene == "experimental" or scene in CEM_SCENE_REASONS, lateral_active=lateral,
|
|
longitudinal_active=longitudinal, stock_cruise_active=False,
|
|
slc_system_long_available=longitudinal, torque_utilization=0.78 if lateral else 0.0,
|
|
customization=document,
|
|
slc_prompt_open=scene == 'slc_pending',
|
|
visual_preview=OnroadVisualPreview(cem_reason=CEM_SCENE_REASONS[scene], layout_sample=True) if scene in CEM_SCENE_REASONS else None)
|
|
|
|
if scene == 'cem_lead':
|
|
state = replace(state, conditional_lead=ConditionalLead(12.0, 0.0))
|
|
elif scene == 'cem_curve':
|
|
state = replace(state, curve=CurveObservation(False, None, None, False, False, False, 0.0, 2.0, 0.0,
|
|
'preview', 'idle', 'none', False, False, 0.005))
|
|
observation = state.speed_limit
|
|
if scene == 'slc_max':
|
|
state = replace(state, cruise_kph=65)
|
|
observation = replace(observation, limiting_max_set=False)
|
|
elif scene == 'slc_equal':
|
|
state = replace(state, cruise_kph=79.2)
|
|
elif scene == 'slc_pedal':
|
|
state = replace(state, longitudinal_overridden=True)
|
|
elif scene == 'slc_retained':
|
|
observation = replace(observation, driver_override_active=True,
|
|
override_basis='persistent', retained_override=True)
|
|
elif scene == 'slc_unavailable':
|
|
observation = SpeedLimitObservation(kind=ObservationKind.STALE, status='display_hold')
|
|
elif scene == 'slc_disengaged':
|
|
observation = replace(observation, action_enabled=True, effective_cluster_target_mps=None)
|
|
elif scene == 'slc_display':
|
|
observation = replace(observation, action_enabled=False, effective_cluster_target_mps=None)
|
|
return replace(state, speed_limit=observation)
|
|
|
|
|
|
def _chunk(kind: bytes, data: bytes) -> bytes:
|
|
payload = kind + data
|
|
return struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload) & 0xffffffff)
|
|
|
|
|
|
def _straight_rgba(pixels: bytes) -> bytes:
|
|
"""PNG uses straight alpha; the transparent GL scratch target stores premultiplied RGB."""
|
|
values = np.frombuffer(pixels, dtype=np.uint8).reshape(-1, 4).astype(np.uint16)
|
|
alpha = values[:, 3:4]
|
|
values[:, :3] = np.where(alpha > 0, np.minimum(255, (values[:, :3] * 255 + alpha // 2) // np.maximum(alpha, 1)), 0)
|
|
return values.astype(np.uint8).tobytes()
|
|
|
|
|
|
def _png_rgba(width: int, height: int, pixels: bytes, *, widget_art: dict | None = None) -> bytes:
|
|
if len(pixels) != width * height * 4:
|
|
raise ValueError("Invalid layout preview pixel buffer")
|
|
stride = width * 4
|
|
rows = b"".join(b"\0" + pixels[y * stride:(y + 1) * stride] for y in range(height))
|
|
return (b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) +
|
|
(_chunk(b"tEXt", b"starpilot-widget-art\0" + json.dumps(widget_art, separators=(",", ":"), allow_nan=False).encode())
|
|
if widget_art is not None else b"") +
|
|
_chunk(b"IDAT", zlib.compress(rows, 3)) + _chunk(b"IEND", b""))
|
|
|
|
|
|
class _DriverMonitorArt:
|
|
def __init__(self, profile: Profile):
|
|
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
|
|
|
|
self.profile = profile
|
|
self.textures: dict[str, rl.Texture] = {}
|
|
self.renderer = DriverStateRenderer.__new__(DriverStateRenderer)
|
|
self.renderer._lines = False
|
|
self.renderer._is_active = True
|
|
self.renderer._force_active = False
|
|
self.renderer._awareness_unfull = False
|
|
self.renderer._fade_filter = FirstOrderFilter(1.0, 0.05, 1 / 60)
|
|
self.renderer._rotation_filter = FirstOrderFilter(90.0, 0.05, 1 / 60)
|
|
self.renderer._color_fade_filter = FirstOrderFilter(1.0, 0.05, 1 / 60)
|
|
directory = ASSET_DIRECTORY / "icons_mici/onroad/driver_monitoring"
|
|
try:
|
|
for name in ("dm_background", "dm_person", "dm_cone"):
|
|
image = rl.load_image(str(directory / f"{name}.png"))
|
|
try:
|
|
size = 128 if profile == Profile.LARGE else 60
|
|
if name != "dm_background":
|
|
size = round(52 / 60 * size)
|
|
rl.image_resize(image, size, size)
|
|
texture = rl.load_texture_from_image(image)
|
|
finally:
|
|
rl.unload_image(image)
|
|
if not texture.id:
|
|
raise RuntimeError(f"Unable to load driver monitoring artwork: {name}")
|
|
self.textures[name] = texture
|
|
self.renderer._dm_background = self.textures["dm_background"]
|
|
self.renderer._dm_person = self.textures["dm_person"]
|
|
self.renderer._dm_cone = self.textures["dm_cone"]
|
|
except Exception:
|
|
self.close()
|
|
raise
|
|
|
|
def render(self, _content: rl.Rectangle, state: OnroadState, *, top_icons: bool = False) -> None:
|
|
if not monitor_visible(self.profile, state, fresh=True, onroad=True, top_icons=top_icons):
|
|
return
|
|
bounds = monitor_bounds(state.customization, self.profile, False)
|
|
draw_widget_frame(bounds, state.customization, self.profile, "driver_monitor")
|
|
draw_monitor_art(self.renderer, bounds, self.profile)
|
|
|
|
def close(self) -> None:
|
|
if rl.is_window_ready():
|
|
for texture in self.textures.values():
|
|
rl.unload_texture(texture)
|
|
self.textures.clear()
|
|
|
|
|
|
class _Canvas:
|
|
def __init__(self, profile: Profile, viewport=None):
|
|
from openpilot.starpilot.ui.onroad import OnroadView
|
|
|
|
self.profile = profile
|
|
self.viewport = viewport
|
|
self.fonts: BitmapFonts | None = None
|
|
self.view: OnroadView | None = None
|
|
self.monitor: _DriverMonitorArt | None = None
|
|
self.target: rl.RenderTexture | None = None
|
|
self.map = None
|
|
try:
|
|
self.fonts = BitmapFonts(profile, default_font_directory())
|
|
layers = {'background_layer': self._render_background}
|
|
if viewport is None:
|
|
self.view = OnroadView(self.fonts, ASSET_DIRECTORY, background_layer=layers['background_layer'])
|
|
else:
|
|
from openpilot.starpilot.system.android_auto.projection_onroad import ProjectionOnroad
|
|
self.view = ProjectionOnroad.create_view(OnroadView, self.fonts, viewport=viewport, **layers)
|
|
from openpilot.starpilot.system.android_auto.projection_exit import ProjectionExit
|
|
self.view.projection_exit_layer = ProjectionExit(rl).render
|
|
self.view.now_playing.track = {'source': 'spotify', 'status': 'playing', 'title': 'Midnight City', 'artist': 'M83', 'album': '',
|
|
'durationMs': 243000, 'position': {'ms': 92000, 'at': 0, 'speed': 0.0}}
|
|
if profile == Profile.LARGE:
|
|
from openpilot.starpilot.ui.onroad_map import MapOverlay, SampleTileReader
|
|
self.map = MapOverlay(reader=SampleTileReader(), fonts=self.fonts)
|
|
self.view.map_layer = self._render_map
|
|
from openpilot.starpilot.models.jetlink_status import JetlinkStatus
|
|
from openpilot.starpilot.ui.onroad_jetlink import JetlinkStatusWidget
|
|
cast(JetlinkStatusWidget, self.view.jetlink_status).status = JetlinkStatus('iPhone', 'active', 24.6, 'connected')
|
|
self.monitor = _DriverMonitorArt(profile)
|
|
self.view.driver_monitor_layer = self._render_driver_monitor
|
|
self.view.preview_pip_layer = self._render_preview_pip
|
|
self.target = rl.load_render_texture(*(viewport or profile.size))
|
|
if not self.target.id:
|
|
raise RuntimeError("Unable to create layout preview target")
|
|
except Exception:
|
|
self.close()
|
|
raise
|
|
|
|
def _render_background(self, rect: rl.Rectangle, state: OnroadState) -> None:
|
|
render_sample_road(rect, state, self.profile)
|
|
if not state.customization.get("widgetOrder", {}).get(str(self.profile)) and self.viewport is None:
|
|
self._render_side_cameras(state)
|
|
|
|
@staticmethod
|
|
def _map_placement(state: OnroadState):
|
|
placed = state.customization["layouts"]["large"].get("nav_map")
|
|
return placed if placed is not None and placed["enabled"] else None
|
|
|
|
def _render_map(self, _rect: rl.Rectangle, state: OnroadState) -> None:
|
|
placed = self._map_placement(state)
|
|
if placed is not None and self.map is not None:
|
|
shift = (self.viewport[0] if self.viewport else state.viewport_width) - 1860
|
|
self.map.draw(rl.Rectangle(placed["x"] + shift, placed["y"], placed["width"], placed["height"]), placed["opacity"] / 100.0)
|
|
|
|
def _render_preview_pip(self, rect, state, *, submit=None):
|
|
if submit is not None:
|
|
for key in CAMERA_WIDGETS:
|
|
submit(key, lambda key=key: self._render_side_cameras(state, only=key))
|
|
|
|
def _render_side_cameras(self, state: OnroadState, only=None) -> None:
|
|
for key in CAMERA_WIDGETS:
|
|
if only is not None and key != only:
|
|
continue
|
|
position = placement(state.customization, self.profile, key)
|
|
if not position["enabled"]:
|
|
continue
|
|
widget = PROFILES[str(self.profile)]["widgets"][key]
|
|
radius = widget["width"] / 2
|
|
center = rl.Vector2(position["x"] + radius, position["y"] + radius)
|
|
rl.draw_circle_v(center, radius, rl.Color(21, 44, 57, 230))
|
|
rl.draw_ring(center, radius - 3, radius, 0, 360, 64, rl.Color(94, 229, 238, 255))
|
|
|
|
def _render_driver_monitor(self, rect: rl.Rectangle, state: OnroadState, *, general: bool = False) -> None:
|
|
top_icons = (not general and self.profile == Profile.COMPACT and state.alert.size == AlertSize.NONE and
|
|
state.appearance.camera_view != CameraViewChoice.DRIVER and not state.reverse_driver_camera and
|
|
self.view.compact_hud._set_speed_alpha.x > 0.01 and
|
|
placement(state.customization, self.profile, "max_speed")["enabled"])
|
|
if self.viewport is None:
|
|
self.monitor.render(rect, state, top_icons=top_icons)
|
|
else:
|
|
rl.rl_push_matrix()
|
|
rl.rl_translatef(0, self.viewport[1] - 1080, 0)
|
|
try:
|
|
self.monitor.render(rect, state, top_icons=top_icons)
|
|
finally:
|
|
rl.rl_pop_matrix()
|
|
|
|
def render(self, state: OnroadState, scene: str) -> bytes:
|
|
if self.view is None or self.fonts is None or self.target is None:
|
|
raise RuntimeError("Layout preview resources are closed")
|
|
self._settle(state)
|
|
width, height = self.viewport or self.profile.size
|
|
placed = self._map_placement(state) if self.map is not None else None
|
|
if placed is not None:
|
|
from openpilot.starpilot.ui.onroad_map import sample_input
|
|
self.map.prepare(sample_input(), placed["width"], placed["height"]) # offscreen, before the preview target
|
|
rl.begin_texture_mode(self.target)
|
|
try:
|
|
rl.clear_background(rl.BLACK)
|
|
self.view.render(state)
|
|
if self.viewport is not None:
|
|
# ProjectionOnroad adds this permanent brand after the shared onroad view.
|
|
self.fonts.draw('StarPilot', FontRole.BRAND, 30, width - 210, height - 90)
|
|
if self.profile == Profile.LARGE and self.viewport is None and state.appearance.show_developer_sidebar:
|
|
render_sidebar(self.fonts, rl.Rectangle(width - SIDEBAR_WIDTH, 0, SIDEBAR_WIDTH, height))
|
|
finally:
|
|
rl.end_texture_mode()
|
|
image = rl.load_image_from_texture(self.target.texture)
|
|
try:
|
|
rl.image_format(image, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8)
|
|
rl.image_flip_vertical(image)
|
|
scale = min(1.0, 2560 / width, 1440 / height)
|
|
if scale < 1.0:
|
|
width, height = round(width * scale), round(height * scale)
|
|
rl.image_resize(image, width, height)
|
|
return _png_rgba(width, height, bytes(rl.ffi.buffer(image.data, width * height * 4)))
|
|
finally:
|
|
rl.unload_image(image)
|
|
|
|
def render_widget_art(self, state: OnroadState, positions: dict, *, general: bool = False) -> bytes:
|
|
"""Transparent arrangement art from the same widgets/fonts as the live view."""
|
|
if self.view is None or self.fonts is None or self.target is None:
|
|
raise RuntimeError("Layout preview resources are closed")
|
|
# Sprite crops use the saved 1860px road layout; the editor applies runtime anchor offsets.
|
|
if self.profile == Profile.LARGE and self.viewport is None:
|
|
state = replace(state, viewport_width=1860)
|
|
self._settle(state)
|
|
canvas_width, canvas_height = self.viewport or self.profile.size
|
|
road_width = self.viewport[0] if self.viewport else state.viewport_width
|
|
content = (rl.Rectangle(30, 30, road_width - 60, canvas_height - 60) if self.profile == Profile.LARGE
|
|
else rl.Rectangle(0, 0, 476, 240))
|
|
sprites = {}
|
|
# A bounded subset covers the edited widgets without adding an onroad render loop.
|
|
x = y = row_height = 0
|
|
captures = []
|
|
for key in RUNTIME_PREVIEW_WIDGETS[str(self.profile)]:
|
|
logical_width, logical_height = widget_size(state.customization, self.profile, key)
|
|
position = positions[key]
|
|
widget = PROFILES[str(self.profile)]['widgets'][key]
|
|
crop_x = max(0, math.floor(position['x'] - widget.get('visualInsetLeft', 0)))
|
|
crop_y = max(0, math.floor(position['y'] - widget.get('visualInsetTop', 0)))
|
|
offset_x, offset_y = crop_x - position['x'], crop_y - position['y']
|
|
width, height = math.ceil(logical_width - offset_x), math.ceil(logical_height - offset_y)
|
|
if self.profile == Profile.COMPACT and key == 'speed_limit' and 0 <= crop_y < canvas_height:
|
|
height = min(height, canvas_height - crop_y)
|
|
if x + width > 1024:
|
|
x, y, row_height = 0, y + row_height, 0
|
|
sprites[key] = {'x': x, 'y': y, 'width': width, 'height': height, 'offsetX': offset_x, 'offsetY': offset_y}
|
|
x += width
|
|
row_height = max(row_height, height)
|
|
if not (0 <= crop_x <= canvas_width - width and 0 <= crop_y <= canvas_height - height):
|
|
raise ValueError("Widget art crop lies outside the canvas")
|
|
rl.begin_texture_mode(self.target)
|
|
try:
|
|
rl.clear_background(rl.BLANK)
|
|
# Match the runtime's transparent cache coverage before exporting straight-alpha PNG.
|
|
rl.rl.rlSetBlendFactorsSeparate(0x0302, 0x0303, 1, 0x0303, 0x8006, 0x8006)
|
|
rl.begin_blend_mode(rl.BlendMode.BLEND_CUSTOM_SEPARATE)
|
|
try:
|
|
if general and key == 'driver_monitor':
|
|
self._render_driver_monitor(content, state, general=True)
|
|
else:
|
|
# General edit shows a representative CEM sample even though its backdrop
|
|
# remains engaged. Driving scenes always use their unmodified state.
|
|
sample = {'cem_gauge': 'cem_curve', 'speed_limit_actions': 'slc_pending'}.get(key) if general else None
|
|
widget_state = sample_state(sample, state.customization) if sample is not None else state
|
|
self._settle(widget_state)
|
|
self.view._ordered_widgets(content, widget_state, only_widget=key)
|
|
finally:
|
|
rl.end_blend_mode()
|
|
finally:
|
|
rl.end_texture_mode()
|
|
image = rl.load_image_from_texture(self.target.texture)
|
|
try:
|
|
rl.image_format(image, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8)
|
|
rl.image_flip_vertical(image)
|
|
rl.image_crop(image, rl.Rectangle(crop_x, crop_y, width, height))
|
|
captures.append((key, _straight_rgba(bytes(rl.ffi.buffer(image.data, width * height * 4)))))
|
|
finally:
|
|
rl.unload_image(image)
|
|
sheet_width = max(slot['x'] + slot['width'] for slot in sprites.values())
|
|
sheet_height = y + row_height
|
|
if not (0 < sheet_width <= 1024 and 0 < sheet_height <= 1440):
|
|
raise ValueError("Widget art sheet exceeds preview bounds")
|
|
pixels = bytearray(sheet_width * sheet_height * 4)
|
|
for key, data in captures:
|
|
slot = sprites[key]
|
|
for row in range(slot['height']):
|
|
start = ((slot['y'] + row) * sheet_width + slot['x']) * 4
|
|
pixels[start:start + slot['width'] * 4] = data[row * slot['width'] * 4:(row + 1) * slot['width'] * 4]
|
|
return _png_rgba(sheet_width, sheet_height, bytes(pixels),
|
|
widget_art={'version': 1, 'width': sheet_width, 'height': sheet_height, 'sprites': sprites})
|
|
|
|
def _settle(self, state: OnroadState) -> None:
|
|
if self.view is None:
|
|
return
|
|
if self.profile == Profile.COMPACT:
|
|
hud = self.view.compact_hud
|
|
# Freeze the synthetic sample inside the normal post-change set-speed blink.
|
|
# Otherwise the first frame/reset or an old cached scene can hide its glyphs.
|
|
now_ns = time.monotonic_ns()
|
|
hud._cruise_drive_frame = state.drive_frame
|
|
hud._last_cruise_display = (round(state.cruise_kph * (1 if state.metric else .621371))
|
|
if state.cruise_active and state.cruise_kph is not None else None)
|
|
hud._last_cruise_ns = now_ns if state.cruise_active else None
|
|
hud._set_speed_changed_ns = now_ns - 1_000_000_000 if state.cruise_active else None
|
|
hud._was_cruise_active = state.cruise_active
|
|
hud._set_speed_alpha.x = 1.0 if state.cruise_active else 0.0
|
|
hud._wheel_alpha.x = 255 * 0.9 if state.lateral_active else 0.0
|
|
hud._wheel_y.x = 0.0 if state.lateral_active else 25.0
|
|
self.view.torque_bar._torque_filter.x = state.torque_utilization if state.lateral_active else 0.0
|
|
self.view.torque_bar._alpha_filter.x = 1.0 if state.lateral_active else 0.0
|
|
if gauge := getattr(self.view, 'cem_gauge', None):
|
|
from openpilot.starpilot.ui.onroad_cem_gauge import gauge_data, _get_road_height
|
|
data = gauge_data(state)
|
|
gauge._drive_frame = state.drive_frame
|
|
gauge._lead_stopped = data is not None and data.indicator_extra == 'stopped'
|
|
gauge._road_h_filter.x = gauge._current_road_h = _get_road_height(data)
|
|
|
|
def close(self) -> None:
|
|
if self.map is not None:
|
|
self.map.close()
|
|
self.map = None
|
|
if self.target is not None and rl.is_window_ready():
|
|
rl.unload_render_texture(self.target)
|
|
self.target = None
|
|
if self.monitor is not None:
|
|
self.monitor.close()
|
|
self.monitor = None
|
|
if self.view is not None:
|
|
self.view.close()
|
|
self.view = None
|
|
if self.fonts is not None:
|
|
self.fonts.close()
|
|
self.fonts = None
|
|
|
|
|
|
class LayoutPreviewRenderer:
|
|
def __init__(self):
|
|
self._canvas: _Canvas | None = None
|
|
self._last_use = 0.0
|
|
|
|
@property
|
|
def has_resources(self) -> bool:
|
|
return self._canvas is not None
|
|
|
|
def __call__(self, payload: dict) -> bytes:
|
|
if type(payload) is not dict or set(payload) not in ({"document", "profile", "scene"}, {"document", "profile", "scene", "art"}):
|
|
raise ValueError("Invalid layout preview request")
|
|
if "art" in payload and not (payload["art"] is True or payload["art"] == "backdrop"):
|
|
raise ValueError("Invalid widget art request")
|
|
viewport = None
|
|
if payload['profile'] == 'projection':
|
|
from openpilot.starpilot.system.android_auto.projection_layout import projection_customization
|
|
layout = payload['document']['layout']
|
|
viewport = (layout['canvas']['width'], layout['canvas']['height'])
|
|
document = projection_customization(layout, validate_document(payload['document']['base']))
|
|
else:
|
|
document = validate_preview_document(payload['document'])
|
|
if type(payload["profile"]) is not str or payload["profile"] not in ("large", "compact", "projection"):
|
|
raise ValueError("Invalid layout preview profile")
|
|
profile = Profile.LARGE if viewport else Profile(payload["profile"])
|
|
scene = payload["scene"]
|
|
sidebar = document.pop('developerSidebar', False)
|
|
if profile == Profile.LARGE and viewport is None:
|
|
width = profile.size[0] - (SIDEBAR_WIDTH if sidebar else 0)
|
|
# Widget art is cropped from the saved 1860px road, as the editor places each sprite itself.
|
|
document = fit_to_road(document, ROAD_BASE_WIDTH if payload.get('art') is True else width)
|
|
state = sample_state(scene, document)
|
|
if profile == Profile.LARGE and viewport is None:
|
|
state = replace(state, viewport_width=width, appearance=replace(state.appearance, show_developer_sidebar=sidebar))
|
|
if profile == Profile.LARGE:
|
|
from openpilot.starpilot.ui.navigation_state import NavigationDisplay
|
|
state = replace(state, navigation=NavigationDisplay(("preview", 1), "Turn left onto Desert Inn Road", "turn", "left",
|
|
152.0, 6800.0, 540.0))
|
|
if not rl.is_window_ready():
|
|
raise RuntimeError("Layout preview requires the UI graphics context")
|
|
if self._canvas is None or self._canvas.profile != profile or self._canvas.viewport != viewport:
|
|
self.close()
|
|
self._canvas = _Canvas(profile, viewport)
|
|
self._last_use = time.monotonic()
|
|
if payload.get('art') is True:
|
|
positions = payload['document']['layout']['widgets'] if viewport else document['layouts'][str(profile)]
|
|
return self._canvas.render_widget_art(state, positions, general=scene == 'general')
|
|
if payload.get('art') == 'backdrop':
|
|
# Everything the car draws except the movable widgets, which the editor places over it.
|
|
# A non-empty order that names no widget keeps the ordered path and draws none of them.
|
|
order = {**state.customization.get('widgetOrder', {}), str(profile): ['backdrop']}
|
|
return self._canvas.render(replace(state, customization={**state.customization, 'widgetOrder': order}), scene)
|
|
return self._canvas.render(state, scene)
|
|
|
|
def expire(self) -> None:
|
|
if self._canvas is not None and time.monotonic() - self._last_use >= IDLE_SECONDS:
|
|
self.close()
|
|
|
|
def close(self) -> None:
|
|
if self._canvas is not None:
|
|
self._canvas.close()
|
|
self._canvas = None
|