BigUI WIP: Curve Speed Border + SLC

This commit is contained in:
firestarsdog
2026-04-01 03:29:45 -04:00
parent 73f5fc84aa
commit 12453f78e9
7 changed files with 697 additions and 26 deletions
+9 -1
View File
@@ -222,7 +222,8 @@ purge_host_desktop_ui_artifacts() {
"${WORK_DIR}/selfdrive/ui/main.o" \
"${WORK_DIR}/selfdrive/ui/moc_ui.o" \
"${WORK_DIR}/selfdrive/ui/ui.o" \
"${WORK_DIR}/selfdrive/ui/ui"
"${WORK_DIR}/selfdrive/ui/ui" \
"${WORK_DIR}/cereal/gen/cpp/"*.capnp.o
}
ensure_host_python_tools() {
@@ -324,7 +325,14 @@ sync_worktree() {
rsync_args+=(--exclude "${pattern}")
done
local _capnp_before
_capnp_before="$(stat -c '%Y' "${WORK_DIR}/cereal/custom.capnp" 2>/dev/null || echo 0)"
rsync "${rsync_args[@]}" "${ROOT_DIR}/" "${WORK_DIR}/"
local _capnp_after
_capnp_after="$(stat -c '%Y' "${WORK_DIR}/cereal/custom.capnp" 2>/dev/null || echo 0)"
if [[ "${_capnp_before}" != "${_capnp_after}" ]]; then
rm -rf "${SP_SCONS_CACHE_DIR:-${HOST_ROOT}/scons_cache}"
fi
purge_host_desktop_ui_artifacts
rm -f "${WORK_DIR}/third_party/libjson11.a" "${WORK_DIR}/third_party/libkaitai.a"
sync_host_generated_headers
@@ -260,6 +260,7 @@ class StarPilotCurveSpeedLayout(StarPilotPanel):
self.CATEGORIES = [
{
"title": tr_noop("Curve Speed Controller"),
"desc": tr_noop("Automatically slow down for upcoming curves using data learned from your driving style."),
"type": "toggle",
"get_state": lambda: self._params.get_bool("CurveSpeedController"),
"set_state": lambda s: self._params.put_bool("CurveSpeedController", s),
@@ -268,6 +269,7 @@ class StarPilotCurveSpeedLayout(StarPilotPanel):
},
{
"title": tr_noop("Status Widget"),
"desc": tr_noop("Show the Curve Speed Controller ambient effect on the driving screen."),
"type": "toggle",
"get_state": lambda: self._params.get_bool("ShowCSCStatus"),
"set_state": lambda s: self._params.put_bool("ShowCSCStatus", s),
@@ -275,6 +277,7 @@ class StarPilotCurveSpeedLayout(StarPilotPanel):
},
{
"title": tr_noop("Calibrated Lateral Accel"),
"desc": tr_noop("The learned lateral acceleration from collected driving data. Higher values allow faster cornering."),
"type": "value",
"get_value": lambda: f"{self._params_memory.get_float('CalibratedLateralAcceleration'):.2f} m/s²",
"on_click": lambda: None,
@@ -282,6 +285,7 @@ class StarPilotCurveSpeedLayout(StarPilotPanel):
},
{
"title": tr_noop("Calibration Progress"),
"desc": tr_noop("How much curve data has been collected. Normal for the value to stay low."),
"type": "value",
"get_value": lambda: f"{self._params_memory.get_float('CalibrationProgress'):.2f}%",
"on_click": lambda: None,
@@ -289,6 +293,7 @@ class StarPilotCurveSpeedLayout(StarPilotPanel):
},
{
"title": tr_noop("Reset Curve Data"),
"desc": tr_noop("Reset collected user data for Curve Speed Controller."),
"type": "hub",
"on_click": lambda: self._reset_curve_data(),
"color": "#1BA1E2",
@@ -299,8 +304,9 @@ class StarPilotCurveSpeedLayout(StarPilotPanel):
def _reset_curve_data(self):
def on_close(res):
if res == DialogResult.CONFIRM:
self._params.remove("CalibratedLateralAcceleration")
self._params.put_float("CalibratedLateralAcceleration", 2.00)
self._params.remove("CalibrationProgress")
self._params.remove("CurvatureData")
self._rebuild_grid()
gui_app.set_modal_overlay(ConfirmDialog(tr("Reset Curve Data?"), tr("Confirm"), on_close=on_close))
@@ -576,6 +582,15 @@ class StarPilotSpeedLimitControllerLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Speed Limit Controller"),
"desc": tr_noop("Limit the car's maximum speed to the current speed limit."),
"type": "toggle",
"get_state": lambda: self._params.get_bool("SpeedLimitController"),
"set_state": lambda s: self._params.put_bool("SpeedLimitController", s),
"icon": "toggle_icons/icon_speed_limit.png",
"color": "#1BA1E2",
},
{"title": tr_noop("SLC Offsets"), "panel": "slc_offsets", "icon": "toggle_icons/icon_speed_limit.png", "color": "#1BA1E2"},
{"title": tr_noop("SLC Quality of Life"), "panel": "slc_qol", "icon": "toggle_icons/icon_speed_limit.png", "color": "#1BA1E2"},
{"title": tr_noop("SLC Visuals"), "panel": "slc_visuals", "icon": "toggle_icons/icon_speed_limit.png", "color": "#1BA1E2"},
@@ -90,14 +90,17 @@ class HubTile(MetroTile):
class ToggleTile(MetroTile):
def __init__(self, title: str, get_state: Callable[[], bool], set_state: Callable[[bool], None], icon_path: str | None = None, bg_color: rl.Color | str | None = None):
def __init__(self, title: str, get_state: Callable[[], bool], set_state: Callable[[bool], None], icon_path: str | None = None,
bg_color: rl.Color | str | None = None, desc: str = ""):
if bg_color: super().__init__(bg_color=bg_color)
else: super().__init__(bg_color=rl.Color(0, 163, 0, 255))
self.title = title
self.desc = desc
self.get_state = get_state
self.set_state = set_state
self._icon = gui_app.starpilot_texture(icon_path, 80, 80) if icon_path else None
self._font = gui_app.font(FontWeight.BOLD)
self._font_desc = gui_app.font(FontWeight.NORMAL)
self._active_color = self.bg_color
self._inactive_color = rl.Color(120, 120, 120, 255)
@@ -122,6 +125,8 @@ class ToggleTile(MetroTile):
title_x = rect.x + padding + (55 if self._icon else 0)
max_title_width = rect.width - (title_x - rect.x) - padding
self._draw_text_fit(self._font, self.title, rl.Vector2(title_x, rect.y + padding + 2), max_title_width, 35)
if self.desc:
self._draw_text_fit(self._font_desc, self.desc, rl.Vector2(title_x, rect.y + padding + 40), max_title_width, 22)
state_text = tr("ON") if active else tr("OFF")
ts = measure_text_cached(self._font, state_text, 30)
rl.draw_text_ex(self._font, state_text, rl.Vector2(rect.x + rect.width - ts.x - padding, rect.y + rect.height - 50), 30, 0, rl.WHITE)
@@ -129,34 +134,22 @@ class ToggleTile(MetroTile):
class ValueTile(MetroTile):
def __init__(self, title: str, get_value: Callable[[], str], on_click: Callable, icon_path: str | None = None,
bg_color: rl.Color | str | None = None, is_enabled: Callable[[], bool] | None = None):
bg_color: rl.Color | str | None = None, is_enabled: Callable[[], bool] | None = None, desc: str = ""):
super().__init__(bg_color=bg_color, on_click=on_click)
self.title = title
self.desc = desc
self.get_value = get_value
self.is_enabled = is_enabled or (lambda: True)
# Wire is_enabled into the parent Widget.enabled property
self._enabled = is_enabled or (lambda: True)
self._icon = gui_app.starpilot_texture(icon_path, 80, 80) if icon_path else None
self._font = gui_app.font(FontWeight.BOLD)
self._font_desc = gui_app.font(FontWeight.NORMAL)
self._active_color = self.bg_color
self._disabled_color = rl.Color(120, 120, 120, 255)
def _enabled(self) -> bool:
return self.is_enabled() if callable(self.is_enabled) else bool(self.is_enabled)
def _handle_mouse_press(self, mouse_pos: MousePos):
if not self._enabled():
self._is_pressed = False
return
super()._handle_mouse_press(mouse_pos)
def _handle_mouse_release(self, mouse_pos: MousePos):
if not self._enabled():
self._is_pressed = False
return
super()._handle_mouse_release(mouse_pos)
def _render(self, rect: rl.Rectangle):
self.set_rect(rect)
enabled = self._enabled()
enabled = self.enabled
base_color = self._active_color if enabled else self._disabled_color
r, g, b = max(0, base_color.r - 20), max(0, base_color.g - 20), max(0, base_color.b - 20)
color = rl.Color(r, g, b, 255) if self._is_pressed and enabled else base_color
@@ -169,7 +162,9 @@ class ValueTile(MetroTile):
title_x = rect.x + padding + (55 if self._icon else 0)
max_title_width = rect.width - (title_x - rect.x) - padding
self._draw_text_fit(self._font, self.title, rl.Vector2(title_x, rect.y + padding + 2), max_title_width, 35)
if self.desc:
self._draw_text_fit(self._font_desc, self.desc, rl.Vector2(title_x, rect.y + padding + 40), max_title_width, 22)
val_text = self.get_value()
# Bottom value: scale to fit if it's too long (common for Car Models)
max_val_width = rect.width - 2 * padding
@@ -84,9 +84,9 @@ class StarPilotPanel(Widget):
bg_color=cat.get("color"),
)
elif tile_type == "toggle":
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color"), desc=tr(cat.get("desc", "")))
elif tile_type == "value":
tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color"), desc=tr(cat.get("desc", "")))
else:
continue
@@ -0,0 +1,208 @@
import math
import pyray as rl
from openpilot.selfdrive.ui import UI_BORDER_SIZE
from openpilot.selfdrive.ui.ui_state import ui_state
# Amber palette
AMBER = rl.Color(251, 191, 36, 255)
# Filament
_FILAMENT_WIDTH = 3.0
_FILAMENT_PASSES = 3
_FILAMENT_SPEED_BASE = 2.0
_FILAMENT_SEGMENTS = 120
# Glow
_GLOW_LAYERS = 8
_GLOW_MAX_ALPHA = 60
_BREATHING_PERIOD = 4.5 # seconds — resting heart rate ~0.22 Hz
# Curvature mapping
_CURVATURE_MIN = 0.001
_CURVATURE_MAX = 0.1
# ── State ──────────────────────────────────────────────────────────────
def _csc_state():
"""Read CSC state from starpilotPlan. Returns dict or None if stale/hidden."""
sm = ui_state.sm
if sm.recv_frame["starpilotPlan"] < ui_state.started_frame:
return None
if sm.recv_frame["carState"] < ui_state.started_frame:
return None
if sm.recv_frame["controlsState"] < ui_state.started_frame:
return None
plan = sm["starpilotPlan"]
if plan.speedLimitChanged or not ui_state.params.get_bool("ShowCSCStatus"):
return None
car_state = sm["carState"]
v_cruise = car_state.vCruiseCluster
if v_cruise == 0.0:
v_cruise = sm["controlsState"].vCruiseDEPRECATED
is_cruise_set = 0 < v_cruise < 255
return {
'training': plan.cscTraining,
'active': is_cruise_set and plan.cscControllingSpeed,
'curvature': plan.roadCurvature,
}
# ── Math ───────────────────────────────────────────────────────────────
def _intensity(curvature: float) -> float:
"""Map abs(curvature) from [CURVATURE_MIN, CURVATURE_MAX] → [0, 1]."""
return max(0.0, min(1.0, (abs(curvature) - _CURVATURE_MIN) / (_CURVATURE_MAX - _CURVATURE_MIN)))
def _perimeter(r: rl.Rectangle, roundness: float) -> list[tuple[float, float]]:
"""Generate evenly-spaced points around a rounded rectangle perimeter."""
rx = roundness * min(r.width, r.height) / 2.0
rx = min(rx, r.width / 2.0, r.height / 2.0)
x, y, w, h = r.x, r.y, r.width, r.height
# Corner centers
corners = [
(x + w - rx, y + rx), # top-right
(x + w - rx, y + h - rx), # bottom-right
(x + rx, y + h - rx), # bottom-left
(x + rx, y + rx), # top-left
]
# Straight edge lengths (connecting corner tangent points)
edges = [
w - 2 * rx, # top
h - 2 * rx, # right
w - 2 * rx, # bottom
h - 2 * rx, # left
]
arc_len = math.pi * rx / 2.0
total = 4 * arc_len + sum(edges)
if total <= 0:
return [(x, y)] * _FILAMENT_SEGMENTS
points = []
for i in range(_FILAMENT_SEGMENTS):
d = (i / _FILAMENT_SEGMENTS) * total
# Walk corners and edges in order
for ci in range(4):
if d < arc_len:
frac = d / arc_len
# Sweep 90° clockwise: 270→0, 0→90, 90→180, 180→270
angle = math.radians(270 + ci * 90 + frac * 90)
cx, cy = corners[ci]
points.append((cx + rx * math.cos(angle), cy + rx * math.sin(angle)))
break
d -= arc_len
edge = edges[ci]
if d < edge:
frac = d / edge
cx, cy = corners[ci]
# Next corner in CW order
nx, ny = corners[(ci + 1) % 4]
# Tangent exit point from current corner
dx = (1.0, 0.0, -1.0, 0.0)[ci]
dy = (0.0, 1.0, 0.0, -1.0)[ci]
ex, ey = cx + rx * dx, cy + rx * dy
# Tangent entry point to next corner
nnx, nny = nx - rx * dx, ny - rx * dy
points.append((ex + (nnx - ex) * frac, ey + (nny - ey) * frac))
break
d -= edge
else:
points.append((x, y))
return points
# ── Public API ─────────────────────────────────────────────────────────
def render_glow(border_rect: rl.Rectangle):
"""Layer 3: Amber glow behind the standard border. Call BEFORE drawing standard border."""
state = _csc_state()
if state is None or not state['active']:
return
intensity = _intensity(state['curvature'])
if intensity <= 0.0:
return
phase = (rl.get_time() % _BREATHING_PERIOD) / _BREATHING_PERIOD
breath = 0.5 + 0.5 * math.sin(phase * 2 * math.pi)
alpha = _GLOW_MAX_ALPHA * intensity * (0.5 + 0.5 * breath)
for i in range(_GLOW_LAYERS):
inset = (UI_BORDER_SIZE / _GLOW_LAYERS) * i
falloff = 1.0 - (i / _GLOW_LAYERS)
a = int(alpha * falloff * falloff)
if a < 2:
continue
glow_rect = rl.Rectangle(
border_rect.x + inset,
border_rect.y + inset,
border_rect.width - 2 * inset,
border_rect.height - 2 * inset,
)
rl.draw_rectangle_rounded(glow_rect, 0.12, 10, rl.Color(251, 191, 36, a))
def render_filament(border_rect: rl.Rectangle):
"""Layer 5: Amber filament on top of the standard border. Call AFTER drawing standard border."""
state = _csc_state()
if state is None:
return
if not state['active'] and not state['training']:
return
curvature = state['curvature']
inner = rl.Rectangle(
border_rect.x + UI_BORDER_SIZE,
border_rect.y + UI_BORDER_SIZE,
border_rect.width - 2 * UI_BORDER_SIZE,
border_rect.height - 2 * UI_BORDER_SIZE,
)
points = _perimeter(inner, 0.12)
n = len(points)
direction = -1.0 if curvature < 0 else 1.0
speed = _FILAMENT_SPEED_BASE + _intensity(curvature) * 3.0
time_offset = rl.get_time() * speed * direction
base_alpha = 120 if state['training'] else 200
for pass_idx in range(_FILAMENT_PASSES):
width = _FILAMENT_WIDTH + pass_idx * 1.5
alpha_scale = 1.0 - (pass_idx / _FILAMENT_PASSES) * 0.5
for i in range(n):
j = (i + 1) % n
t = ((i + time_offset) / n) % 1.0
# Single pulse: ramp up 0→25%, hold 25→50%, ramp down 50→75%, off 75→100%
if t < 0.25:
a = t / 0.25
elif t < 0.5:
a = 1.0
elif t < 0.75:
a = (0.75 - t) / 0.25
else:
continue
alpha = int(base_alpha * a * alpha_scale)
if alpha < 2:
continue
rl.draw_line_ex(
rl.Vector2(points[i][0], points[i][1]),
rl.Vector2(points[j][0], points[j][1]),
width,
rl.Color(251, 191, 36, alpha),
)
@@ -0,0 +1,407 @@
import pyray as rl
from openpilot.common.constants import CV
from openpilot.selfdrive.ui import UI_BORDER_SIZE
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
# ── Constants (matching Qt exactly) ───────────────────────────────────
# Set speed rect layout (from hud_renderer.py UI_CONFIG)
SET_SPEED_X_OFFSET = 60
SET_SPEED_Y_OFFSET = 45
SET_SPEED_HEIGHT = 204
SET_SPEED_WIDTH_IMP = 172
SET_SPEED_WIDTH_MET = 200
# US MUTCD sign
US_SIGN_HEIGHT = 186
US_SIGN_RADIUS = 24
US_INNER_RADIUS = 16
US_BORDER_WIDTH = 6
US_INSET = 9
# EU Vienna sign
EU_SIGN_SIZE = 176
EU_SIGN_WIDTH = 176
RED_RING_WIDTH = 20
# Pending sign
PENDING_BLINK_MS = 500
PENDING_US_WIDE = 200
# Sources panel
SOURCE_ROW_W = 450
SOURCE_ROW_H = 60
SOURCE_ROW_GAP = UI_BORDER_SIZE // 2
# Fonts
FONT_LABEL = 28
FONT_SPEED = 70
FONT_OFFSET = 50
FONT_EU_LARGE = 70
FONT_EU_SMALL = 60
FONT_EU_OFFSET = 40
# Layout
SIGN_MARGIN = 12
# ── State ─────────────────────────────────────────────────────────────
def _get_slc_state():
"""Extract SLC state from SubMaster. Returns dict or None if stale."""
sm = ui_state.sm
if sm.recv_frame["starpilotPlan"] < ui_state.started_frame:
return None
plan = sm["starpilotPlan"]
speed_limit_changed = plan.speedLimitChanged
show_slc = ui_state.params.get_bool("ShowSpeedLimits") or ui_state.params.get_bool("SpeedLimitController")
hide_sl = ui_state.params.get_bool("HideSpeedLimit")
hide = not speed_limit_changed and hide_sl
if not show_slc and not speed_limit_changed:
return None
speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
show_offset = ui_state.params.get_bool("ShowSLCOffset")
dashboard_sl = sm["starpilotCarState"].dashboardSpeedLimit if sm.valid.get("starpilotCarState", False) else 0.0
vision_sl = ui_state.params_memory.get_float("VisionSpeedLimit") if ui_state.params.get_bool("VisionSpeedLimitDetection") else 0.0
slc_overridden_speed = plan.slcOverriddenSpeed
speed_limit = plan.slcSpeedLimit
# Offset calculation (matches Qt lines 199-204)
if slc_overridden_speed == 0 and not show_offset:
speed_limit += plan.slcSpeedLimitOffset
speed_limit *= speed_conversion
speed_limit_offset = plan.slcSpeedLimitOffset * speed_conversion
offset_str = f"{'+' if speed_limit_offset > 0 else '-'}{abs(int(round(speed_limit_offset)))}" if speed_limit_offset != 0 else "\u2013"
return {
'speed_limit': speed_limit,
'speed_limit_str': "\u2013" if speed_limit <= 1 else str(int(round(speed_limit))),
'slc_overridden_speed': slc_overridden_speed,
'speed_limit_source': plan.slcSpeedLimitSource,
'unconfirmed_speed_limit': max(0.0, plan.unconfirmedSlcSpeedLimit * speed_conversion),
'speed_limit_changed': speed_limit_changed,
'hide': hide,
'show_offset': show_offset,
'use_vienna': ui_state.params.get_bool("UseVienna"),
'offset_str': offset_str,
'speed_conversion': speed_conversion,
'show_sources': ui_state.params.get_bool("SpeedLimitSources"),
# Per-source raw values
'dashboard_sl': max(0.0, dashboard_sl * speed_conversion),
'map_sl': max(0.0, plan.slcMapSpeedLimit * speed_conversion),
'vision_sl': max(0.0, vision_sl * speed_conversion),
'mapbox_sl': max(0.0, plan.slcMapboxSpeedLimit * speed_conversion),
'next_sl': max(0.0, plan.slcNextSpeedLimit * speed_conversion),
}
# ── Fonts ─────────────────────────────────────────────────────────────
_font_bold = None
_font_semi_bold = None
def _get_bold():
global _font_bold
if _font_bold is None:
_font_bold = gui_app.font(FontWeight.BOLD)
return _font_bold
def _get_semi_bold():
global _font_semi_bold
if _font_semi_bold is None:
_font_semi_bold = gui_app.font(FontWeight.SEMI_BOLD)
return _font_semi_bold
# ── US MUTCD Sign ─────────────────────────────────────────────────────
def _draw_us_sign(x: float, y: float, sign_width: float, speed_text: str, offset_str: str,
alpha: int, show_offset: bool):
"""Draw US-style speed limit sign at (x, y) with given width."""
sign_rect = rl.Rectangle(x, y, sign_width, US_SIGN_HEIGHT)
border_rect = rl.Rectangle(x + US_INSET, y + US_INSET,
sign_width - 2 * US_INSET,
US_SIGN_HEIGHT - 2 * US_INSET)
# White background
rl.draw_rectangle_rounded(sign_rect, US_SIGN_RADIUS / US_SIGN_HEIGHT, 16,
rl.Color(255, 255, 255, alpha))
# Black border
rl.draw_rectangle_rounded_lines_ex(border_rect, US_INNER_RADIUS / (US_SIGN_HEIGHT - 18), 16,
US_BORDER_WIDTH, rl.Color(0, 0, 0, alpha))
font_bold = _get_bold()
font_semi = _get_semi_bold()
text_color = rl.Color(0, 0, 0, alpha)
cx = x + sign_width / 2
if show_offset:
# Offset ON: "LIMIT" at y=22, speed at y=51, offset at y=120
limit_size = measure_text_cached(font_semi, tr("LIMIT"), FONT_LABEL)
rl.draw_text_ex(font_semi, tr("LIMIT"), rl.Vector2(cx - limit_size.x / 2, y + 22), FONT_LABEL, 0, text_color)
speed_size = measure_text_cached(font_bold, speed_text, FONT_SPEED)
rl.draw_text_ex(font_bold, speed_text, rl.Vector2(cx - speed_size.x / 2, y + 51), FONT_SPEED, 0, text_color)
offset_size = measure_text_cached(font_semi, offset_str, FONT_OFFSET)
rl.draw_text_ex(font_semi, offset_str, rl.Vector2(cx - offset_size.x / 2, y + 120), FONT_OFFSET, 0, text_color)
else:
# Offset OFF: "SPEED" at y=22, "LIMIT" at y=51, speed at y=85
speed_label_size = measure_text_cached(font_semi, tr("SPEED"), FONT_LABEL)
rl.draw_text_ex(font_semi, tr("SPEED"), rl.Vector2(cx - speed_label_size.x / 2, y + 22), FONT_LABEL, 0, text_color)
limit_size = measure_text_cached(font_semi, tr("LIMIT"), FONT_LABEL)
rl.draw_text_ex(font_semi, tr("LIMIT"), rl.Vector2(cx - limit_size.x / 2, y + 51), FONT_LABEL, 0, text_color)
speed_size = measure_text_cached(font_bold, speed_text, FONT_SPEED)
rl.draw_text_ex(font_bold, speed_text, rl.Vector2(cx - speed_size.x / 2, y + 85), FONT_SPEED, 0, text_color)
# ── EU Vienna Sign ────────────────────────────────────────────────────
def _draw_eu_sign(x: float, y: float, speed_text: str, offset_str: str,
text_alpha: int, show_offset: bool):
"""Draw EU-style speed limit sign at (x, y)."""
center_x = x + EU_SIGN_SIZE / 2
center_y = y + EU_SIGN_SIZE / 2
radius = EU_SIGN_SIZE / 2
# White circle (full opacity)
rl.draw_circle(int(center_x), int(center_y), radius, rl.Color(255, 255, 255, 255))
# Red ring (full opacity, static)
rl.draw_ring(rl.Vector2(center_x, center_y), radius - RED_RING_WIDTH, radius,
0, 360, 64, rl.Color(201, 34, 49, 255))
font_bold = _get_bold()
font_semi = _get_semi_bold()
text_color = rl.Color(0, 0, 0, text_alpha)
eu_font = FONT_EU_LARGE if len(speed_text) <= 2 else FONT_EU_SMALL
if show_offset:
# Offset ON: speed shifted up, offset below
speed_size = measure_text_cached(font_bold, speed_text, eu_font)
speed_pos = rl.Vector2(center_x - speed_size.x / 2, center_y - speed_size.y / 2 - 25)
rl.draw_text_ex(font_bold, speed_text, speed_pos, eu_font, 0, text_color)
offset_size = measure_text_cached(font_semi, offset_str, FONT_EU_OFFSET)
offset_pos = rl.Vector2(center_x - offset_size.x / 2, y + 100)
rl.draw_text_ex(font_semi, offset_str, offset_pos, FONT_EU_OFFSET, 0, text_color)
else:
# Centered speed value
speed_size = measure_text_cached(font_bold, speed_text, eu_font)
speed_pos = rl.Vector2(center_x - speed_size.x / 2, center_y - speed_size.y / 2)
rl.draw_text_ex(font_bold, speed_text, speed_pos, eu_font, 0, text_color)
# ── Main Speed Limit Sign ────────────────────────────────────────────
def _draw_speed_limit_sign(state: dict, sign_x: float, sign_y: float, sign_width: float):
"""Draw the main speed limit sign (US or EU based on setting)."""
speed_text = state['speed_limit_str']
offset_str = state['offset_str']
alpha = 72 if state['slc_overridden_speed'] != 0 else 255
if state['use_vienna']:
_draw_eu_sign(sign_x, sign_y, speed_text, offset_str, alpha, state['show_offset'])
else:
_draw_us_sign(sign_x, sign_y, sign_width, speed_text, offset_str, alpha, state['show_offset'])
# ── Pending Speed Limit Sign ─────────────────────────────────────────
def _draw_pending_sign(state: dict, pending_x: float, pending_y: float, sign_width: float):
"""Draw the blinking pending speed limit sign."""
pending_speed = state['unconfirmed_speed_limit']
if pending_speed <= 0:
return
speed_text = "\u2013" if pending_speed <= 1 else str(int(round(pending_speed)))
use_vienna = state['use_vienna']
blink_on = int(rl.get_time() * 1000) % (PENDING_BLINK_MS * 2) < PENDING_BLINK_MS
if use_vienna:
# EU pending: white circle, STATIC red ring, TEXT blinks black/red
size = EU_SIGN_SIZE
center_x = pending_x + size / 2
center_y = pending_y + size / 2
radius = size / 2
rl.draw_circle(int(center_x), int(center_y), radius, rl.Color(255, 255, 255, 255))
rl.draw_ring(rl.Vector2(center_x, center_y), radius - RED_RING_WIDTH, radius,
0, 360, 64, rl.Color(201, 34, 49, 255))
eu_font = FONT_EU_LARGE if len(speed_text) <= 2 else FONT_EU_SMALL
text_color = rl.Color(0, 0, 0) if blink_on else rl.Color(201, 34, 49)
font_bold = _get_bold()
speed_size = measure_text_cached(font_bold, speed_text, eu_font)
speed_pos = rl.Vector2(center_x - speed_size.x / 2, center_y - speed_size.y / 2)
rl.draw_text_ex(font_bold, speed_text, speed_pos, eu_font, 0, text_color)
else:
# US pending: white rounded rect, BORDER blinks black/red
w = PENDING_US_WIDE if len(speed_text) >= 3 else sign_width
h = US_SIGN_HEIGHT
sign_rect = rl.Rectangle(pending_x, pending_y, w, h)
border_rect = rl.Rectangle(pending_x + US_INSET, pending_y + US_INSET,
w - 2 * US_INSET, h - 2 * US_INSET)
rl.draw_rectangle_rounded(sign_rect, US_SIGN_RADIUS / h, 16, rl.Color(255, 255, 255, 255))
border_color = rl.Color(0, 0, 0) if blink_on else rl.Color(201, 34, 49)
rl.draw_rectangle_rounded_lines_ex(border_rect, US_INNER_RADIUS / (h - 18), 16,
US_BORDER_WIDTH, border_color)
font_bold = _get_bold()
font_semi = _get_semi_bold()
black = rl.Color(0, 0, 0, 255)
cx = pending_x + w / 2
pending_label = measure_text_cached(font_semi, tr("PENDING"), FONT_LABEL)
rl.draw_text_ex(font_semi, tr("PENDING"),
rl.Vector2(cx - pending_label.x / 2, pending_y + 22),
FONT_LABEL, 0, black)
limit_label = measure_text_cached(font_semi, tr("LIMIT"), FONT_LABEL)
rl.draw_text_ex(font_semi, tr("LIMIT"),
rl.Vector2(cx - limit_label.x / 2, pending_y + 51),
FONT_LABEL, 0, black)
speed_size = measure_text_cached(font_bold, speed_text, FONT_SPEED)
rl.draw_text_ex(font_bold, speed_text,
rl.Vector2(cx - speed_size.x / 2, pending_y + 85),
FONT_SPEED, 0, black)
# ── Sources Panel ─────────────────────────────────────────────────────
def _draw_sources_panel(state: dict, panel_x: float, panel_y: float):
"""Draw the 5 source indicator rows below the speed limit sign."""
font_bold = _get_bold()
font_semi = _get_semi_bold()
active_source = state['speed_limit_source']
sources = [
("Dashboard", state['dashboard_sl']),
("Map Data", state['map_sl']),
("Vision", state['vision_sl']),
("Mapbox", state['mapbox_sl']),
("Upcoming", state['next_sl']),
]
y = panel_y
for title, value in sources:
is_active = active_source == title and value != 0
rect = rl.Rectangle(panel_x, y, SOURCE_ROW_W, SOURCE_ROW_H)
if is_active:
bg = rl.Color(201, 34, 49, 166)
border = rl.Color(201, 34, 49, 255)
text_font = font_bold
else:
bg = rl.Color(0, 0, 0, 166)
border = rl.Color(0, 0, 0, 255)
text_font = font_semi
rl.draw_rectangle_rounded(rect, 24 / SOURCE_ROW_H, 16, bg)
rl.draw_rectangle_rounded_lines_ex(rect, 24 / SOURCE_ROW_H, 16, 10, border)
speed_text = f"{int(round(value))}" if value != 0 else "N/A"
full_text = f"{tr(title)} - {speed_text}"
if is_active:
# Draw with black stroke outline
text_pos = rl.Vector2(rect.x + 20, rect.y + (SOURCE_ROW_H - 35) / 2)
rl.draw_text_ex(text_font, full_text, text_pos, 35, 0, rl.Color(255, 255, 255, 255))
else:
text_pos = rl.Vector2(rect.x + 20, rect.y + (SOURCE_ROW_H - 35) / 2)
rl.draw_text_ex(text_font, full_text, text_pos, 35, 0, rl.Color(255, 255, 255, 255))
y += SOURCE_ROW_H + SOURCE_ROW_GAP
# ── Sign Rect Calculations ───────────────────────────────────────────
def _calc_sign_rect(sign_x: float, sign_y: float, sign_width: float, use_vienna: bool) -> rl.Rectangle:
if use_vienna:
return rl.Rectangle(sign_x, sign_y, EU_SIGN_SIZE, EU_SIGN_SIZE)
return rl.Rectangle(sign_x, sign_y, sign_width, US_SIGN_HEIGHT)
def _calc_pending_rect(sign_x: float, sign_y: float, sign_width: float,
unconfirmed_speed: float, use_vienna: bool) -> rl.Rectangle:
# Pending sign is adjacent to main sign (Qt: translated by sign.width + UI_BORDER_SIZE)
pending_x = sign_x + sign_width + UI_BORDER_SIZE
if use_vienna:
return rl.Rectangle(pending_x, sign_y, EU_SIGN_SIZE, EU_SIGN_SIZE)
speed_text = str(int(round(unconfirmed_speed)))
w = PENDING_US_WIDE if len(speed_text) >= 3 else sign_width
return rl.Rectangle(pending_x, sign_y, w, US_SIGN_HEIGHT)
# ── Click Handling ────────────────────────────────────────────────────
def handle_slc_click(mouse_pos, sign_x: float, sign_y: float, sign_width: float):
"""Handle mouse click for accepting pending speed limit. Call from _handle_mouse_press."""
state = _get_slc_state()
if state is None or not state['speed_limit_changed']:
return
pending_rect = _calc_pending_rect(sign_x, sign_y, sign_width,
state['unconfirmed_speed_limit'], state['use_vienna'])
if (pending_rect.x <= mouse_pos.x <= pending_rect.x + pending_rect.width and
pending_rect.y <= mouse_pos.y <= pending_rect.y + pending_rect.height):
from openpilot.common.params import Params
Params(memory=True).put_bool("SpeedLimitAccepted", True)
# ── Public API ────────────────────────────────────────────────────────
def render_speed_limit(content_rect: rl.Rectangle):
"""Render SLC speed limit signs. Call from onroad view render loop."""
state = _get_slc_state()
if state is None:
return
# Compute sign rect matching Qt:
# signRect = QRect(setSpeedRect.x() + signMargin, setSpeedRect.bottom() + signMargin,
# setSpeedRect.width() - 2*signMargin, usSignHeight)
ss_width = SET_SPEED_WIDTH_MET if ui_state.is_metric else SET_SPEED_WIDTH_IMP
ss_x = content_rect.x + SET_SPEED_X_OFFSET + (SET_SPEED_WIDTH_IMP - ss_width) // 2
ss_y = content_rect.y + SET_SPEED_Y_OFFSET
sign_width = ss_width - 2 * SIGN_MARGIN
sign_x = ss_x + SIGN_MARGIN
sign_y = ss_y + SET_SPEED_HEIGHT + SIGN_MARGIN
use_vienna = state['use_vienna']
# 1. Pending sign (always drawn when speedLimitChanged, even if main sign hidden)
if state['speed_limit_changed']:
pending_rect = _calc_pending_rect(sign_x, sign_y, sign_width,
state['unconfirmed_speed_limit'], use_vienna)
_draw_pending_sign(state, pending_rect.x, pending_rect.y, sign_width)
# 2. Main speed limit sign (unless hidden)
if not state['hide']:
_draw_speed_limit_sign(state, sign_x, sign_y, sign_width)
# 3. Sources panel
if state['show_sources']:
sign_rect = _calc_sign_rect(sign_x, sign_y, sign_width, use_vienna)
sources_x = sign_rect.x - SIGN_MARGIN
sources_y = sign_rect.y + sign_rect.height + UI_BORDER_SIZE
_draw_sources_panel(state, sources_x, sources_y)
@@ -3,8 +3,14 @@ from msgq.visionipc import VisionStreamType
from openpilot.common.params import Params
from openpilot.selfdrive.ui import UI_BORDER_SIZE
from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView, BORDER_COLORS
from openpilot.selfdrive.ui.onroad.starpilot.curve_speed_border import render_glow, render_filament
from openpilot.selfdrive.ui.onroad.starpilot.personality_button import PersonalityButton, BTN_SIZE
from openpilot.selfdrive.ui.onroad.starpilot.slc_speed_limit import (
render_speed_limit, handle_slc_click, SET_SPEED_X_OFFSET, SET_SPEED_Y_OFFSET,
SET_SPEED_WIDTH_IMP, SET_SPEED_WIDTH_MET, SET_SPEED_HEIGHT, SIGN_MARGIN,
)
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.system.ui.lib.application import MousePos, gui_app
AOL_COLOR = rl.Color(10, 186, 181, 255)
@@ -22,8 +28,21 @@ class StarPilotOnroadView(AugmentedRoadView):
if not ui_state.started:
return
self._render_slc(rect)
self._render_overlays()
def _render_slc(self, rect: rl.Rectangle):
content_rect = rl.Rectangle(
rect.x + UI_BORDER_SIZE, rect.y + UI_BORDER_SIZE,
rect.width - 2 * UI_BORDER_SIZE, rect.height - 2 * UI_BORDER_SIZE,
)
rl.begin_scissor_mode(
int(content_rect.x), int(content_rect.y),
int(content_rect.width), int(content_rect.height),
)
render_speed_limit(content_rect)
rl.end_scissor_mode()
def _render_overlays(self):
self._position_personality_button()
self._personality_button.render()
@@ -52,10 +71,29 @@ class StarPilotOnroadView(AugmentedRoadView):
rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, rl.BLACK)
border_rect = rl.Rectangle(rect.x + UI_BORDER_SIZE, rect.y + UI_BORDER_SIZE,
rect.width - 2 * UI_BORDER_SIZE, rect.height - 2 * UI_BORDER_SIZE)
# Layer 3: Amber glow (behind standard border)
render_glow(border_rect)
# Layer 4: Standard border
border_color = AOL_COLOR if ui_state.always_on_lateral_active else BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED])
rl.draw_rectangle_rounded_lines_ex(border_rect, 0.12, 10, UI_BORDER_SIZE, border_color)
def _handle_mouse_press(self, _):
# Layer 5: Amber filament (on top of standard border)
render_filament(border_rect)
def _handle_mouse_press(self, mouse_pos: MousePos):
content_rect = rl.Rectangle(
UI_BORDER_SIZE, UI_BORDER_SIZE,
gui_app.width - 2 * UI_BORDER_SIZE, gui_app.height - 2 * UI_BORDER_SIZE,
)
ss_width = SET_SPEED_WIDTH_MET if ui_state.is_metric else SET_SPEED_WIDTH_IMP
ss_x = content_rect.x + SET_SPEED_X_OFFSET + (SET_SPEED_WIDTH_IMP - ss_width) // 2
sign_x = ss_x + SIGN_MARGIN
sign_y = content_rect.y + SET_SPEED_Y_OFFSET + SET_SPEED_HEIGHT + SIGN_MARGIN
sign_width = ss_width - 2 * SIGN_MARGIN
handle_slc_click(mouse_pos, sign_x, sign_y, sign_width)
if self._personality_button.is_interacting:
return
super()._handle_mouse_press(_)
super()._handle_mouse_press(mouse_pos)