From 12453f78e9950ff294337a2e3ced0c96cb4df922 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:29:45 -0400 Subject: [PATCH] BigUI WIP: Curve Speed Border + SLC --- scripts/host_tool_runner.sh | 10 +- .../settings/starpilot/longitudinal.py | 17 +- .../ui/layouts/settings/starpilot/metro.py | 35 +- .../ui/layouts/settings/starpilot/panel.py | 4 +- .../ui/onroad/starpilot/curve_speed_border.py | 208 +++++++++ .../ui/onroad/starpilot/slc_speed_limit.py | 407 ++++++++++++++++++ .../onroad/starpilot/starpilot_onroad_view.py | 42 +- 7 files changed, 697 insertions(+), 26 deletions(-) create mode 100644 selfdrive/ui/onroad/starpilot/curve_speed_border.py create mode 100644 selfdrive/ui/onroad/starpilot/slc_speed_limit.py diff --git a/scripts/host_tool_runner.sh b/scripts/host_tool_runner.sh index 7d7208c0..6ae003a3 100755 --- a/scripts/host_tool_runner.sh +++ b/scripts/host_tool_runner.sh @@ -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 diff --git a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py index e1e95ce9..137faf37 100644 --- a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py +++ b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py @@ -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"}, diff --git a/selfdrive/ui/layouts/settings/starpilot/metro.py b/selfdrive/ui/layouts/settings/starpilot/metro.py index 3302805c..ae168150 100644 --- a/selfdrive/ui/layouts/settings/starpilot/metro.py +++ b/selfdrive/ui/layouts/settings/starpilot/metro.py @@ -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 diff --git a/selfdrive/ui/layouts/settings/starpilot/panel.py b/selfdrive/ui/layouts/settings/starpilot/panel.py index d683262e..fe67c246 100644 --- a/selfdrive/ui/layouts/settings/starpilot/panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/panel.py @@ -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 diff --git a/selfdrive/ui/onroad/starpilot/curve_speed_border.py b/selfdrive/ui/onroad/starpilot/curve_speed_border.py new file mode 100644 index 00000000..69083dc8 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/curve_speed_border.py @@ -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), + ) diff --git a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py new file mode 100644 index 00000000..1634f20f --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py @@ -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) diff --git a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py index 12e4ea95..e798a68b 100644 --- a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py +++ b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py @@ -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)