From 01599193df0f9be39e5ff32abcd145c994b857d3 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:59:13 -0400 Subject: [PATCH] Aethergauge Pretty --- selfdrive/ui/onroad/starpilot/aethergauge.py | 353 ++++++++++++++---- .../onroad/starpilot/widgets/aethergauge.py | 6 +- selfdrive/ui/tests/test_aethergauge.py | 140 ++++++- 3 files changed, 431 insertions(+), 68 deletions(-) diff --git a/selfdrive/ui/onroad/starpilot/aethergauge.py b/selfdrive/ui/onroad/starpilot/aethergauge.py index 98507293f..2ed45071a 100644 --- a/selfdrive/ui/onroad/starpilot/aethergauge.py +++ b/selfdrive/ui/onroad/starpilot/aethergauge.py @@ -11,6 +11,11 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.selfdrive.ui.onroad.starpilot.starpilot_border import _csc_state, _intensity, _glow_color from openpilot.selfdrive.ui.lib.starpilot_status import get_border_color +from openpilot.selfdrive.ui.onroad.starpilot.widget_style import ( + CONTROL_BORDER, + CONTROL_BORDER_WIDTH, + draw_control_card, +) # --- Scale factor (single knob for all pixel-space dimensions) --- @@ -35,6 +40,31 @@ ROAD_THICKNESS = 4.0 * SCALE ROAD_HALF_SIZE = 40.0 * SCALE ROAD_EDGE_INSET = 2.0 * SCALE FILL_ALPHA = 90 +ROAD_CONTOUR_WIDTH = 2.0 * SCALE + +# The gauge overlays an unconstrained camera image. It shares the visible +# control-card frame used by Set Speed and MAP, then owns two contained +# viewports: animated road graphics above, glanceable status metrics below. +# Nothing may draw outside the card. +AETHER_CONTENT_INSET_X = max(5.0 * SCALE, CONTROL_BORDER_WIDTH / 2.0 + 3.0 * SCALE) +AETHER_ROAD_TOP_INSET = max(5.0 * SCALE, CONTROL_BORDER_WIDTH / 2.0 + 3.0 * SCALE) +AETHER_ROAD_VIEWPORT_HEIGHT = 75.0 * SCALE +AETHER_ROAD_TO_CRADLE_GAP = 2.0 * SCALE +AETHER_CRADLE_TOP = 83.0 * SCALE +AETHER_CRADLE_BOTTOM_INSET = max(2.0 * SCALE, CONTROL_BORDER_WIDTH / 2.0 + 2.0 * SCALE) + +AETHER_LABEL_SIZE = int(16.0 * SCALE) +AETHER_LABEL_SLOT_HEIGHT = AETHER_LABEL_SIZE +AETHER_VALUE_GAP = 2.0 * SCALE +AETHER_VALUE_MAX_SIZE = int(40.0 * SCALE) +AETHER_VALUE_MIN_SIZE = int(24.0 * SCALE) +AETHER_LEAD_MAX_SIZE = int(32.0 * SCALE) +AETHER_LEAD_MIN_SIZE = int(18.0 * SCALE) +AETHER_REDUCTION_SIZE = int(16.0 * SCALE) +AETHER_REDUCTION_GAP = 4.0 * SCALE +AETHER_ACCENT_GAP = 2.0 * SCALE +AETHER_UNIT_SIZE = int(18.0 * SCALE) +AETHER_UNIT_GAP = 2.0 * SCALE STOP_SNAP_THRESHOLD = 0.5 STOP_LERP_RATE = 0.25 @@ -51,7 +81,10 @@ LEAD_STOPPED_SPEED_THRESHOLD = 1.0 COLOR_FORCE_STOP = rl.Color(255, 30, 60, 255) COLOR_LEAD_STOPPED = rl.Color(255, 60, 60, 255) COLOR_LEAD_SLOWER = rl.Color(255, 191, 0, 255) -COLOR_SHADOW = rl.Color(0, 0, 0, 100) +COLOR_CONTOUR = rl.Color(2, 6, 9, 235) +COLOR_AETHER_CARD = rl.Color(9, 14, 18, 226) +COLOR_PRIMARY_TEXT = rl.Color(245, 250, 252, 255) +COLOR_SECONDARY_TEXT = rl.Color(225, 235, 240, 235) COLOR_STOP_SIGN_OUTLINE = rl.Color(255, 255, 255, 255) COLOR_STOP_LINE_GLOW = rl.Color(255, 30, 60, 255) COLOR_STOP_LINE_CORE = rl.Color(255, 200, 200, 255) @@ -150,12 +183,119 @@ def _get_perspective_offset(t: float, data: 'AetherGaugeData | None') -> float: max_offset_top = math.tanh(path_y_far * PERSPECTIVE_GAIN) * PERSPECTIVE_MAX_OFFSET return max_offset_top * (t ** PERSPECTIVE_EXPONENT) + +@dataclass(frozen=True) +class AetherGaugeLayout: + card: rl.Rectangle + road: rl.Rectangle + cradle: rl.Rectangle + + +def _snapped_rect(x: float, y: float, width: float, height: float) -> rl.Rectangle: + return rl.Rectangle( + int(round(x)), int(round(y)), max(1, int(round(width))), max(1, int(round(height))), + ) + + +def _aether_layout(rect: rl.Rectangle) -> AetherGaugeLayout: + """Return the one geometry contract used by every AetherGauge primitive.""" + # Match the Set Speed and MAP frame exactly; internal viewports provide the + # clearance needed by the denser AetherGauge road and metric visuals. + card = _snapped_rect(rect.x, rect.y, rect.width, rect.height) + + cradle_top = int(round(card.y + AETHER_CRADLE_TOP)) + cradle_bottom = int(round(card.y + card.height - AETHER_CRADLE_BOTTOM_INSET)) + road_y = int(round(card.y + AETHER_ROAD_TOP_INSET)) + road_height = min( + int(round(AETHER_ROAD_VIEWPORT_HEIGHT)), + max(1, cradle_top - road_y - int(round(AETHER_ROAD_TO_CRADLE_GAP))), + ) + content_x = int(round(card.x + AETHER_CONTENT_INSET_X)) + content_width = max(1, int(round(card.width - 2.0 * AETHER_CONTENT_INSET_X))) + + return AetherGaugeLayout( + card=card, + road=_snapped_rect(content_x, road_y, content_width, road_height), + cradle=_snapped_rect(content_x, cradle_top, content_width, max(1, cradle_bottom - cradle_top)), + ) + + +def _draw_aether_card(layout: AetherGaugeLayout, alpha: float = 1.0) -> None: + """Draw the shared control frame with AetherGauge's deeper contrast fill.""" + draw_control_card( + layout.card, + fill=_fade(COLOR_AETHER_CARD, alpha), + border=_fade(CONTROL_BORDER, alpha), + ) + + +def _text_outline_px(size: int) -> int: + return max(1, int(round(size / (28.0 * SCALE)))) + + def _draw_text_with_shadow(font: rl.Font, text: str, pos: rl.Vector2, size: int, color: rl.Color, alpha: float = 1.0): - for dx, dy in ((-1, -1), (1, -1), (-1, 1), (1, 1)): - rl.draw_text_ex(font, text, rl.Vector2(pos.x + dx, pos.y + dy), size, 0, _fade(rl.BLACK, alpha)) + outline_px = _text_outline_px(size) + for dx, dy in ( + (-outline_px, 0), (outline_px, 0), (0, -outline_px), (0, outline_px), + (-outline_px, -outline_px), (outline_px, -outline_px), + (-outline_px, outline_px), (outline_px, outline_px), + ): + rl.draw_text_ex(font, text, rl.Vector2(pos.x + dx, pos.y + dy), size, 0, _fade(COLOR_CONTOUR, alpha)) rl.draw_text_ex(font, text, pos, size, 0, _fade(color, alpha)) +def _fit_text_size(font: rl.Font, text: str, max_size: int, min_size: int, + max_width: float, max_height: float) -> tuple[int, rl.Vector2]: + """Measure down to a guaranteed-safe text size for the supplied content box.""" + for size in range(max_size, min_size - 1, -1): + text_size = measure_text_cached(font, text, size) + outline = _text_outline_px(size) + if text_size.x + 2 * outline <= max_width and text_size.y + 2 * outline <= max_height: + return size, text_size + + # Keep pathological future values contained even if they exceed the intended + # type scale. Normal gauge data never reaches this fallback. + for size in range(min_size - 1, 0, -1): + text_size = measure_text_cached(font, text, size) + outline = _text_outline_px(size) + if text_size.x + 2 * outline <= max_width and text_size.y + 2 * outline <= max_height: + return size, text_size + + return 1, measure_text_cached(font, text, 1) + + +def _fit_numeric_line(font_bold: rl.Font, font_medium: rl.Font, value: str, reduction: str, + max_width: float, max_height: float) -> 'tuple[int, rl.Vector2, rl.Vector2 | None, bool]': + """Fit the primary value and optional reduction inline, or reflow the reduction.""" + reduction_size = measure_text_cached(font_medium, reduction, AETHER_REDUCTION_SIZE) if reduction else None + + for value_font_size in range(AETHER_VALUE_MAX_SIZE, 0, -1): + value_size = measure_text_cached(font_bold, value, value_font_size) + value_outline = _text_outline_px(value_font_size) + if value_size.y + 2 * value_outline > max_height: + continue + + if reduction_size is None: + if value_size.x + 2 * value_outline <= max_width: + return value_font_size, value_size, None, True + continue + + reduction_outline = _text_outline_px(AETHER_REDUCTION_SIZE) + inline_width = value_size.x + AETHER_REDUCTION_GAP + reduction_size.x + 2 * max(value_outline, reduction_outline) + if inline_width <= max_width: + return value_font_size, value_size, reduction_size, True + + if value_font_size == AETHER_VALUE_MIN_SIZE: + break + + # The normal source values fit inline. This fallback keeps a future long + # value contained by using the reserved label row for the reduction. + value_font_size, value_size = _fit_text_size( + font_bold, value, AETHER_VALUE_MIN_SIZE, 1, max_width, max_height, + ) + return value_font_size, value_size, reduction_size, False + + # --- Data model --- class IndicatorType(Enum): @@ -178,6 +318,14 @@ class AetherGaugeData: is_numeric: bool = False +def _state_label(data: AetherGaugeData) -> str: + return { + IndicatorType.FORCE_STOP: "STOP", + IndicatorType.STOP_LIGHT: "RED LIGHT", + IndicatorType.LEAD: "LEAD", + }.get(data.indicator_type, "") + + # --- Source functions (replaces class-based sources) --- def _build_curve_gauge_data(curvature: float, target_speed: float, v_cruise: float) -> AetherGaugeData: @@ -460,16 +608,13 @@ class AetherGauge: if not data: return - if cx is None or bottom is None: + if cx is None: base_cx = rect.x + rect.width / 2 - cy_speed = rect.y + 180 * SCALE speed_text = str(round(current_speed)) speed_text_size = measure_text_cached(font_bold, speed_text, int(176 * SCALE)) icx = base_cx - speed_text_size.x / 2 - 70.0 * SCALE - icy = cy_speed - 39.5 * SCALE else: icx = cx - icy = bottom - ROAD_HALF_SIZE if data.indicator_type != self._last_indicator_type: self._dist_filter.x = data.indicator_value @@ -497,60 +642,71 @@ class AetherGauge: data.unit = unit if data.indicator_type in (IndicatorType.ROAD_CURVE, IndicatorType.FORCE_STOP, IndicatorType.LEAD, IndicatorType.STOP_LIGHT): - self._render_unified_road(rect, icx, icy, data, font_bold, font_medium, alpha) + self._render_unified_road(rect, icx, data, font_bold, font_medium, alpha) - def _render_unified_road(self, rect, icx, icy, data, font_bold, font_medium, alpha=1.0): - bottom = icy + ROAD_HALF_SIZE + def _render_unified_road(self, rect, icx, data, font_bold, font_medium, alpha=1.0): + layout = _aether_layout(rect) + _draw_aether_card(layout, alpha) + bottom = layout.road.y + layout.road.height + road_icy = bottom - ROAD_HALF_SIZE if data.indicator_type in (IndicatorType.FORCE_STOP, IndicatorType.STOP_LIGHT): distance = 15.0 else: distance = data.indicator_value - points_left = [] - points_right = [] - self._current_road_h = self._road_h_filter.update(_get_road_height(data)) - road_h = self._current_road_h + # Custom widgets render after AugmentedRoadView releases its content + # scissor. Keep every animated primitive inside the road viewport here. + rl.begin_scissor_mode( + int(layout.road.x), int(layout.road.y), int(layout.road.width), int(layout.road.height), + ) + try: + points_left = [] + points_right = [] + self._current_road_h = min(self._road_h_filter.update(_get_road_height(data)), layout.road.height) + road_h = self._current_road_h - for i in range(ROAD_SEGMENTS + 1): - t = i / ROAD_SEGMENTS - offset = _get_perspective_offset(t, data) - cx_t = icx + offset - y_t = bottom - t * road_h - w_t = ROAD_W_BOTTOM - t * (ROAD_W_BOTTOM - ROAD_W_TOP) + for i in range(ROAD_SEGMENTS + 1): + t = i / ROAD_SEGMENTS + offset = _get_perspective_offset(t, data) + cx_t = icx + offset + y_t = bottom - t * road_h + w_t = ROAD_W_BOTTOM - t * (ROAD_W_BOTTOM - ROAD_W_TOP) - points_left.append(rl.Vector2(cx_t - w_t, y_t)) - points_right.append(rl.Vector2(cx_t + w_t, y_t)) + points_left.append(rl.Vector2(cx_t - w_t, y_t)) + points_right.append(rl.Vector2(cx_t + w_t, y_t)) - fill_color = _fade(_with_alpha(data.color, FILL_ALPHA), alpha) - for i in range(ROAD_SEGMENTS): - t = i / ROAD_SEGMENTS - stroke = ROAD_THICKNESS * (1.0 - 0.6 * t) - rl.draw_triangle(points_left[i], points_right[i], points_left[i+1], fill_color) - rl.draw_triangle(points_right[i], points_right[i+1], points_left[i+1], fill_color) - rl.draw_line_ex(points_left[i], points_left[i+1], stroke, _fade(COLOR_SHADOW, alpha)) - rl.draw_line_ex(points_right[i], points_right[i+1], stroke, _fade(COLOR_SHADOW, alpha)) - rl.draw_line_ex(points_left[i], points_left[i+1], stroke, _fade(data.color, alpha)) - rl.draw_line_ex(points_right[i], points_right[i+1], stroke, _fade(data.color, alpha)) + fill_color = _fade(_with_alpha(data.color, FILL_ALPHA), alpha) + for i in range(ROAD_SEGMENTS): + t = i / ROAD_SEGMENTS + stroke = ROAD_THICKNESS * (1.0 - 0.6 * t) + rl.draw_triangle(points_left[i], points_right[i], points_left[i+1], fill_color) + rl.draw_triangle(points_right[i], points_right[i+1], points_left[i+1], fill_color) + rl.draw_line_ex(points_left[i], points_left[i+1], stroke + ROAD_CONTOUR_WIDTH, _fade(COLOR_CONTOUR, alpha)) + rl.draw_line_ex(points_right[i], points_right[i+1], stroke + ROAD_CONTOUR_WIDTH, _fade(COLOR_CONTOUR, alpha)) + rl.draw_line_ex(points_left[i], points_left[i+1], stroke, _fade(data.color, alpha)) + rl.draw_line_ex(points_right[i], points_right[i+1], stroke, _fade(data.color, alpha)) - it = data.indicator_type + it = data.indicator_type - if it in (IndicatorType.STOP_LIGHT, IndicatorType.FORCE_STOP): - self._draw_stop_line(icx, bottom, distance, data, alpha) + if it in (IndicatorType.STOP_LIGHT, IndicatorType.FORCE_STOP): + self._draw_stop_line(icx, bottom, distance, data, alpha) - if it == IndicatorType.LEAD: - self._draw_lead_car(icx, bottom, data, alpha) + if it == IndicatorType.LEAD: + self._draw_lead_car(icx, bottom, data, alpha) - if it in (IndicatorType.ROAD_CURVE, IndicatorType.FORCE_STOP, IndicatorType.STOP_LIGHT): - self._draw_standard_chevrons(icx, bottom, distance, data, alpha) + if it in (IndicatorType.ROAD_CURVE, IndicatorType.FORCE_STOP, IndicatorType.STOP_LIGHT): + self._draw_standard_chevrons(icx, bottom, distance, data, alpha) - if it == IndicatorType.STOP_LIGHT: - self._draw_traffic_light(icx, icy, distance, data, alpha) + if it == IndicatorType.STOP_LIGHT: + self._draw_traffic_light(icx, road_icy, distance, data, alpha) - if it == IndicatorType.FORCE_STOP: - self._draw_approaching_stop_sign(icx, icy, bottom, distance, data, font_bold, alpha) + if it == IndicatorType.FORCE_STOP: + self._draw_approaching_stop_sign(icx, road_icy, bottom, distance, data, font_bold, alpha) + finally: + rl.end_scissor_mode() - self._draw_mini_cradle(icx, bottom, data, font_bold, font_medium, alpha) + self._draw_mini_cradle(layout.cradle, data, font_bold, font_medium, alpha) def _draw_stop_line(self, icx, bottom, distance, data, alpha=1.0): t, cx_line, cy_line = _road_xy(distance, icx, bottom, data, self._current_road_h) @@ -562,6 +718,10 @@ class AetherGauge: fade = 1.0 - t * 0.5 glow_a = int(150 * fade) + rl.draw_line_ex( + p_left, p_right, max(3.0 * SCALE, 7.0 * SCALE * fade) + ROAD_CONTOUR_WIDTH, + _fade(COLOR_CONTOUR, alpha), + ) rl.draw_line_ex(p_left, p_right, max(3.0 * SCALE, 7.0 * SCALE * fade), _fade(_with_alpha(COLOR_STOP_LINE_GLOW, glow_a), alpha)) rl.draw_line_ex(p_left, p_right, max(1.5 * SCALE, 3.5 * SCALE * fade), _fade(COLOR_STOP_LINE_CORE, alpha)) @@ -642,7 +802,10 @@ class AetherGauge: chev_a = max(0, min(255, int(data.color.a * (1.0 - t / t_lead) * math.sin(t / t_lead * math.pi)))) c_color = _fade(_with_alpha(data.color, chev_a), alpha) + c_contour = _fade(_with_alpha(COLOR_CONTOUR, int(chev_a * 0.9)), alpha) + rl.draw_line_ex(rl.Vector2(lx, cy_t - chevron_w * 0.5), rl.Vector2(cx_t, cy_t), chevron_thick + ROAD_CONTOUR_WIDTH, c_contour) + rl.draw_line_ex(rl.Vector2(rx, cy_t - chevron_w * 0.5), rl.Vector2(cx_t, cy_t), chevron_thick + ROAD_CONTOUR_WIDTH, c_contour) rl.draw_line_ex(rl.Vector2(lx, cy_t - chevron_w * 0.5), rl.Vector2(cx_t, cy_t), chevron_thick, c_color) rl.draw_line_ex(rl.Vector2(rx, cy_t - chevron_w * 0.5), rl.Vector2(cx_t, cy_t), chevron_thick, c_color) @@ -690,7 +853,10 @@ class AetherGauge: chev_a = max(0, min(255, int(data.color.a * alpha_factor))) chev_color = _fade(_with_alpha(data.color, chev_a), alpha) chev_shadow = _fade(rl.Color(0, 0, 0, int(chev_a * 0.5)), alpha) + chev_contour = _fade(_with_alpha(COLOR_CONTOUR, int(chev_a * 0.9)), alpha) + rl.draw_line_ex(rl.Vector2(lx, ly), rl.Vector2(cx_t, cy_t), chevron_thick + ROAD_CONTOUR_WIDTH, chev_contour) + rl.draw_line_ex(rl.Vector2(rx, ry), rl.Vector2(cx_t, cy_t), chevron_thick + ROAD_CONTOUR_WIDTH, chev_contour) rl.draw_line_ex(rl.Vector2(lx, ly + 1.5), rl.Vector2(cx_t, cy_t + 1.5), chevron_thick, chev_shadow) rl.draw_line_ex(rl.Vector2(rx, ry + 1.5), rl.Vector2(cx_t, cy_t + 1.5), chevron_thick, chev_shadow) rl.draw_line_ex(rl.Vector2(lx, ly), rl.Vector2(cx_t, cy_t), chevron_thick, chev_color) @@ -761,18 +927,53 @@ class AetherGauge: stop_txt_size = measure_text_cached(font_bold, "STOP", stop_font_size) rl.draw_text_ex(font_bold, "STOP", rl.Vector2(cx_stop - stop_txt_size.x / 2, y_sign - stop_txt_size.y / 2), stop_font_size, 0, _fade(rl.WHITE, alpha)) - def _draw_mini_cradle(self, cx, bottom, data, font_bold, font_medium, alpha=1.0): + def _draw_mini_cradle(self, cradle, data, font_bold, font_medium, alpha=1.0): if not data.text: return - if data.is_numeric: - val_size = measure_text_cached(font_bold, data.text, int(50 * SCALE)) - val_pos = rl.Vector2(int(cx - val_size.x / 2), int(bottom + 6 * SCALE)) - _draw_text_with_shadow(font_bold, data.text, val_pos, int(50 * SCALE), data.color, alpha) + label = _state_label(data) + metric_top = int(round(cradle.y + AETHER_LABEL_SLOT_HEIGHT + AETHER_VALUE_GAP)) + cradle_right = cradle.x + cradle.width - accent_y = int(val_pos.y + val_size.y + 2 * SCALE) - accent_w = int(val_size.x + 16 * SCALE) - accent_x = int(cx - accent_w / 2) + if data.is_numeric: + unit_size = measure_text_cached(font_medium, data.unit, AETHER_UNIT_SIZE) if data.unit else None + footer_height = AETHER_ACCENT_GAP + if unit_size is not None: + footer_height += AETHER_UNIT_GAP + unit_size.y + max_metric_height = max(1.0, cradle.y + cradle.height - metric_top - footer_height) + max_metric_width = max(1.0, cradle.width - 2 * _text_outline_px(AETHER_VALUE_MAX_SIZE)) + value_font_size, value_size, reduction_size, reduction_inline = _fit_numeric_line( + font_bold, font_medium, data.text, data.reduction_text, max_metric_width, max_metric_height, + ) + value_outline = _text_outline_px(value_font_size) + reduction_outline = _text_outline_px(AETHER_REDUCTION_SIZE) if reduction_size is not None else 0 + + group_width = value_size.x + if reduction_size is not None and reduction_inline: + group_width += AETHER_REDUCTION_GAP + reduction_size.x + value_x = int(round(cradle.x + (cradle.width - group_width) / 2)) + value_pos = rl.Vector2(value_x, metric_top) + + label_max_width = cradle.width - 2 * _text_outline_px(AETHER_LABEL_SIZE) + if label and reduction_size is not None and not reduction_inline: + label_max_width -= reduction_size.x + AETHER_REDUCTION_GAP + reduction_outline + if label: + label_font_size, label_size = _fit_text_size( + font_medium, label, AETHER_LABEL_SIZE, 1, max(1.0, label_max_width), AETHER_LABEL_SLOT_HEIGHT, + ) + label_x = cradle.x + value_outline if reduction_size is not None and not reduction_inline else cradle.x + (cradle.width - label_size.x) / 2 + label_y = cradle.y + (AETHER_LABEL_SLOT_HEIGHT - label_size.y) / 2 + _draw_text_with_shadow( + font_medium, label, rl.Vector2(int(round(label_x)), int(round(label_y))), + label_font_size, COLOR_PRIMARY_TEXT, alpha, + ) + + _draw_text_with_shadow(font_bold, data.text, value_pos, value_font_size, COLOR_PRIMARY_TEXT, alpha) + + accent_y = int(round(value_pos.y + value_size.y + AETHER_ACCENT_GAP)) + accent_w = min(max_metric_width, value_size.x + 16 * SCALE) + accent_x = int(round(value_pos.x + value_size.x / 2 - accent_w / 2)) + accent_x = max(int(round(cradle.x + value_outline)), min(accent_x, int(round(cradle_right - value_outline - accent_w)))) rl.draw_line_ex( rl.Vector2(accent_x, accent_y), rl.Vector2(accent_x + accent_w, accent_y), @@ -780,16 +981,42 @@ class AetherGauge: _fade(_with_alpha(data.color, 160), alpha), ) - if data.reduction_text: - red_size = measure_text_cached(font_medium, data.reduction_text, int(22 * SCALE)) - red_pos = rl.Vector2(int(cx + val_size.x / 2 + 6 * SCALE), int(val_pos.y + val_size.y / 2 - red_size.y / 2)) - _draw_text_with_shadow(font_medium, data.reduction_text, red_pos, int(22 * SCALE), COLOR_REDUCTION, alpha) + if reduction_size is not None: + if reduction_inline: + reduction_x = int(round(value_pos.x + value_size.x + AETHER_REDUCTION_GAP)) + reduction_y = int(round(value_pos.y + (value_size.y - reduction_size.y) / 2)) + else: + reduction_x = int(round(cradle_right - reduction_size.x - reduction_outline)) + reduction_y = int(round(cradle.y + (AETHER_LABEL_SLOT_HEIGHT - reduction_size.y) / 2)) + _draw_text_with_shadow( + font_medium, data.reduction_text, rl.Vector2(reduction_x, reduction_y), + AETHER_REDUCTION_SIZE, COLOR_REDUCTION, alpha, + ) - if data.unit: - unit_size = measure_text_cached(font_medium, data.unit, int(20 * SCALE)) - unit_pos = rl.Vector2(int(cx - unit_size.x / 2), int(accent_y + 3 * SCALE)) - _draw_text_with_shadow(font_medium, data.unit, unit_pos, int(20 * SCALE), rl.Color(255, 255, 255, 180), alpha) + if unit_size is not None: + unit_pos = rl.Vector2( + int(round(cradle.x + (cradle.width - unit_size.x) / 2)), + int(round(accent_y + AETHER_UNIT_GAP)), + ) + _draw_text_with_shadow(font_medium, data.unit, unit_pos, AETHER_UNIT_SIZE, COLOR_SECONDARY_TEXT, alpha) else: - val_size = measure_text_cached(font_bold, data.text, int(32 * SCALE)) - val_pos = rl.Vector2(int(cx - val_size.x / 2), int(bottom + 10 * SCALE)) - _draw_text_with_shadow(font_bold, data.text, val_pos, int(32 * SCALE), data.color, alpha) + if label: + label_font_size, label_size = _fit_text_size( + font_medium, label, AETHER_LABEL_SIZE, 1, + cradle.width - 2 * _text_outline_px(AETHER_LABEL_SIZE), AETHER_LABEL_SLOT_HEIGHT, + ) + label_pos = rl.Vector2( + int(round(cradle.x + (cradle.width - label_size.x) / 2)), + int(round(cradle.y + (AETHER_LABEL_SLOT_HEIGHT - label_size.y) / 2)), + ) + _draw_text_with_shadow(font_medium, label, label_pos, label_font_size, COLOR_PRIMARY_TEXT, alpha) + + lead_font_size, lead_size = _fit_text_size( + font_bold, data.text, AETHER_LEAD_MAX_SIZE, AETHER_LEAD_MIN_SIZE, + cradle.width - 2 * _text_outline_px(AETHER_LEAD_MAX_SIZE), + cradle.y + cradle.height - metric_top, + ) + lead_pos = rl.Vector2( + int(round(cradle.x + (cradle.width - lead_size.x) / 2)), metric_top, + ) + _draw_text_with_shadow(font_bold, data.text, lead_pos, lead_font_size, COLOR_PRIMARY_TEXT, alpha) diff --git a/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py b/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py index 90f338986..278d66ae8 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py +++ b/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py @@ -2,7 +2,7 @@ import pyray as rl from openpilot.common.filter_simple import FirstOrderFilter from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget -from openpilot.selfdrive.ui.onroad.starpilot.aethergauge import AetherGauge, _fade +from openpilot.selfdrive.ui.onroad.starpilot.aethergauge import AetherGauge from openpilot.selfdrive.ui.onroad.starpilot.widget_style import CONTROL_WIDTH class AetherGaugeWidget(LayoutWidget): @@ -29,12 +29,10 @@ class AetherGaugeWidget(LayoutWidget): return cx = rect.x + rect.width / 2 - # Set the road bottom to rect.y + 145, leaving 115px for the text cradle underneath - bottom = rect.y + 145.0 + # AetherGauge derives its bounded road and metric viewports from rect. self._aethergauge.render( rect, self._font_bold, self._font_medium, current_speed=self.hud_renderer.speed, cx=cx, - bottom=bottom, alpha=alpha, ) diff --git a/selfdrive/ui/tests/test_aethergauge.py b/selfdrive/ui/tests/test_aethergauge.py index 5b0389b0a..60cd0ebb2 100644 --- a/selfdrive/ui/tests/test_aethergauge.py +++ b/selfdrive/ui/tests/test_aethergauge.py @@ -31,16 +31,20 @@ mock_ui_state = types.SimpleNamespace( }, ) from openpilot.selfdrive.ui.onroad.starpilot import aethergauge +from openpilot.selfdrive.ui.onroad.starpilot import widget_style from openpilot.selfdrive.ui.onroad.starpilot.aethergauge import ( AetherGauge, AetherGaugeData, IndicatorType, + _aether_layout, _cem_curvature_data, + _draw_aether_card, _is_cem_curvature, _is_curve_speed, _is_lead, _is_stop_light, _lead_data, + _state_label, ) from openpilot.starpilot.common.experimental_state import CEStatus @@ -295,7 +299,7 @@ def test_monotonic_ratchet_clamp_prevents_upward_bounce(monkeypatch): rendered_data = [] gauge = AetherGauge() - monkeypatch.setattr(gauge, "_render_unified_road", lambda rect, cx, cy, data, fb, fm, alpha: rendered_data.append(data)) + monkeypatch.setattr(gauge, "_render_unified_road", lambda rect, cx, data, fb, fm, alpha: rendered_data.append(data)) # Initial frame at 30m _set_plan(redLight=True, forcingStopLength=30.0) @@ -307,3 +311,137 @@ def test_monotonic_ratchet_clamp_prevents_upward_bounce(monkeypatch): gauge.render(None, None, None, current_speed=10.0, cx=100.0, bottom=200.0) # Ratchet clamp must prevent the display number from increasing above 30m! assert int(rendered_data[-1].text) <= 30 + + +def test_aether_card_reuses_the_shared_control_frame(monkeypatch): + triangles = [] + fills = [] + keylines = [] + + monkeypatch.setattr(aethergauge.rl, "draw_triangle", lambda *args: triangles.append(args)) + monkeypatch.setattr(aethergauge.rl, "draw_rectangle_rounded", lambda *args: fills.append(args)) + monkeypatch.setattr(aethergauge.rl, "draw_rectangle_rounded_lines_ex", lambda *args: keylines.append(args)) + + rect = aethergauge.rl.Rectangle(10, 20, 176, 260) + layout = _aether_layout(rect) + _draw_aether_card(layout, 0.5) + + assert not triangles + assert len(fills) == 1 + assert len(keylines) == 1 + + card, roundness, segments, fill = fills[0] + keyline_card, keyline_roundness, keyline_segments, border_width, border = keylines[0] + assert card.x == rect.x + assert card.y == rect.y + assert card.width == rect.width + assert card.height == rect.height + assert keyline_card.x == card.x + assert keyline_card.y == card.y + assert keyline_card.width == card.width + assert keyline_card.height == card.height + assert roundness == keyline_roundness == widget_style.CONTROL_ROUNDNESS + assert segments == keyline_segments == widget_style.CONTROL_SEGMENTS + assert border_width == widget_style.CONTROL_BORDER_WIDTH + assert fill.a == int(aethergauge.COLOR_AETHER_CARD.a * 0.5) + assert (border.r, border.g, border.b, border.a) == ( + widget_style.CONTROL_BORDER.r, + widget_style.CONTROL_BORDER.g, + widget_style.CONTROL_BORDER.b, + int(widget_style.CONTROL_BORDER.a * 0.5), + ) + + +def test_aether_layout_keeps_road_and_cradle_inside_one_card(): + layout = _aether_layout(aethergauge.rl.Rectangle(10, 20, 176, 260)) + card_right = layout.card.x + layout.card.width + card_bottom = layout.card.y + layout.card.height + inner_border = widget_style.CONTROL_BORDER_WIDTH / 2.0 + + for viewport in (layout.road, layout.cradle): + assert layout.card.x <= viewport.x + assert viewport.x + viewport.width <= card_right + assert layout.card.y <= viewport.y + assert viewport.y + viewport.height <= card_bottom + assert layout.road.x >= layout.card.x + inner_border + assert layout.road.y >= layout.card.y + inner_border + assert layout.cradle.y + layout.cradle.height <= card_bottom - inner_border + assert layout.road.y + layout.road.height < layout.cradle.y + + +def _fake_text_size(_, text, size): + return aethergauge.rl.Vector2(max(1, len(text) * size * 0.5), size) + + +def _assert_text_calls_fit_card(text_calls, card): + card_right = card.x + card.width + card_bottom = card.y + card.height + for text, pos, size in text_calls: + measured = _fake_text_size(None, text, size) + assert card.x <= pos.x + assert pos.x + measured.x <= card_right + assert card.y <= pos.y + assert pos.y + measured.y <= card_bottom + + +@pytest.mark.parametrize("data", [ + AetherGaugeData( + text="120", unit="km/h", color=aethergauge.COLOR_CEM_SPEED, + indicator_type=IndicatorType.ROAD_CURVE, reduction_text="-20", is_numeric=True, + ), + AetherGaugeData( + text="999", unit="ft", color=aethergauge.COLOR_FORCE_STOP, + indicator_type=IndicatorType.STOP_LIGHT, is_numeric=True, + ), + AetherGaugeData( + text="STOPPED", color=aethergauge.COLOR_LEAD_STOPPED, + indicator_type=IndicatorType.LEAD, + ), +]) +def test_aether_cradle_fits_text_within_card(monkeypatch, data): + text_calls = [] + monkeypatch.setattr(aethergauge, "measure_text_cached", _fake_text_size) + monkeypatch.setattr( + aethergauge.rl, "draw_text_ex", + lambda _, text, pos, size, __, ___: text_calls.append((text, pos, size)), + ) + monkeypatch.setattr(aethergauge.rl, "draw_line_ex", lambda *args: None) + + layout = _aether_layout(aethergauge.rl.Rectangle(10, 20, 176, 260)) + AetherGauge()._draw_mini_cradle(layout.cradle, data, object(), object()) + + _assert_text_calls_fit_card(text_calls, layout.card) + + +def test_aether_road_render_scissors_the_animated_viewport(monkeypatch): + scissor_calls = [] + events = [] + monkeypatch.setattr(aethergauge, "measure_text_cached", _fake_text_size) + monkeypatch.setattr(aethergauge.rl, "begin_scissor_mode", lambda *args: scissor_calls.append(args)) + monkeypatch.setattr(aethergauge.rl, "end_scissor_mode", lambda: events.append("end_scissor")) + monkeypatch.setattr(aethergauge.rl, "draw_rectangle_rounded", lambda *args: None) + monkeypatch.setattr(aethergauge.rl, "draw_rectangle_rounded_lines_ex", lambda *args: None) + monkeypatch.setattr(aethergauge.rl, "draw_triangle", lambda *args: None) + monkeypatch.setattr(aethergauge.rl, "draw_line_ex", lambda *args: None) + monkeypatch.setattr(aethergauge.rl, "draw_text_ex", lambda *args: events.append("text")) + monkeypatch.setattr(aethergauge.rl, "get_frame_time", lambda: 0.0) + + rect = aethergauge.rl.Rectangle(10, 20, 176, 260) + layout = _aether_layout(rect) + data = AetherGaugeData( + text="23", unit="mph", color=aethergauge.COLOR_CEM_SPEED, + indicator_type=IndicatorType.ROAD_CURVE, indicator_value=0.005, is_numeric=True, + ) + AetherGauge()._render_unified_road(rect, 98.0, data, object(), object()) + + assert scissor_calls == [( + int(layout.road.x), int(layout.road.y), int(layout.road.width), int(layout.road.height), + )] + assert events.index("end_scissor") < events.index("text") + + +def test_state_labels_make_urgent_and_lead_states_explicit(): + assert _state_label(AetherGaugeData(text="", indicator_type=IndicatorType.FORCE_STOP)) == "STOP" + assert _state_label(AetherGaugeData(text="", indicator_type=IndicatorType.STOP_LIGHT)) == "RED LIGHT" + assert _state_label(AetherGaugeData(text="", indicator_type=IndicatorType.LEAD)) == "LEAD" + assert _state_label(AetherGaugeData(text="", indicator_type=IndicatorType.ROAD_CURVE)) == ""