Files
StarPilot/openpilot/starpilot/ui/layout_preview_renderer.py

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