Aethergauge Pretty

This commit is contained in:
firestarsdog
2026-08-26 02:59:13 -04:00
parent 9437efe93b
commit 01599193df
3 changed files with 431 additions and 68 deletions
+290 -63
View File
@@ -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)
@@ -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,
)
+139 -1
View File
@@ -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)) == ""