diff --git a/openpilot/starpilot/speed_limits/README.md b/openpilot/starpilot/speed_limits/README.md index c9e47618d4..ddeec6e3e7 100644 --- a/openpilot/starpilot/speed_limits/README.md +++ b/openpilot/starpilot/speed_limits/README.md @@ -16,8 +16,12 @@ a source gap or same-value provider change; a later different limit starts a new window, including when an earlier numeric limit returns. With Automatic adoption on, the widget slowly flashes for three seconds before -adoption. With it off, the widget gently hops and shows an arrow relative to vehicle -speed. Steering-wheel + accepts and − declines. Tapping the sign opens the same +adoption. With it off, the stationary widget pulses purple twice slowly (700 ms +each), rests for one second, pulses twice quickly (300 ms each), and rests for one +second before repeating. Each pair has a 200 ms gap. The arrow remains relative +to vehicle speed; Vienna numerals stay black while their ring pulses. +Large UI uses the same US or Vienna sign at 1.25× scale beside SET speed. +Steering-wheel + accepts and − declines. Tapping the sign opens the same Accept / Decline choices on either native screen. A pending manual choice expires after ten monotonic seconds, including time without active longitudinal control. Decline and timeout leave set speed unchanged. An action at the exact deadline diff --git a/openpilot/starpilot/system/android_auto/projection_layout.py b/openpilot/starpilot/system/android_auto/projection_layout.py index e8aa630008..746f120dae 100644 --- a/openpilot/starpilot/system/android_auto/projection_layout.py +++ b/openpilot/starpilot/system/android_auto/projection_layout.py @@ -11,7 +11,7 @@ from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK SPEED_ALIGNMENTS, customization_metadata, validate_map_box, validate_widget_order) from openpilot.starpilot.ui.onroad_stopped_timer import timer_size from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint -from openpilot.starpilot.ui.onroad_customization import SPEED_LAYOUT_V7, migrate_speed_layout +from openpilot.starpilot.ui.onroad_customization import SPEED_LAYOUT_V7, migrate_speed_layout, migrate_large_sign_layout MAX_BYTES = 16384 DOCUMENT_KEY = 'projection' @@ -178,7 +178,7 @@ def default_layout(screen, display=None): def default_layout_for_viewport(viewport, display=None): metadata = layout_metadata_for_viewport(viewport) display = {**DISPLAY_DEFAULTS, **{key: value for key, value in (display or {}).items() if key in DISPLAY_DEFAULTS}} - return {'version': 4, 'clock24Hour': False, 'largeUiGammaTrial': False, **display, 'canvas': {key: metadata[key] for key in ('width', 'height')}, + return {'version': 5, 'clock24Hour': False, 'largeUiGammaTrial': False, **display, 'canvas': {key: metadata[key] for key in ('width', 'height')}, 'widgets': {key: {**widget['default'], **({'size': 192} if key == 'steering_wheel' else {})} for key, widget in metadata['widgets'].items()}} @@ -211,7 +211,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): if type(value) is dict: fields |= {field for field in ('widgetOrder', 'clock24Hour', 'largeUiGammaTrial', *DISPLAY_DEFAULTS) if field in value} if (type(value) is not dict or set(value) != fields or - type(value['version']) is not int or value['version'] not in (1, 2, 3, 4) or + type(value['version']) is not int or value['version'] not in (1, 2, 3, 4, 5) or value['canvas'] != {key: metadata[key] for key in ('width', 'height')} or type(value['widgets']) is not dict or not set(value['widgets']) <= set(metadata['widgets']) or not set(metadata['widgets']) - set(value['widgets']) <= {CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, *PROJECTION_WIDGETS}): @@ -291,6 +291,8 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): dimensions = placement_size(key, widget, placement) if value['version'] < 4 and key in SPEED_LAYOUT_V7['large']: dimensions = SPEED_LAYOUT_V7['large'][key][:2] + elif value['version'] < 5 and key == 'cruise_limits': + dimensions = (widget['width'], 132) # Version 2 and older saved the CEM gauge's full drawing frame; check that, then move to the inset box. old_gauge = key == CEM_GAUGE_WIDGET and value['version'] < 3 and key in value['widgets'] if old_gauge: @@ -314,7 +316,9 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): card, actions = result['widgets']['cruise_limits'], result['widgets']['speed_limit_actions'] gap = actions['y'] - card['y'] migrate_speed_layout(result['widgets'], 'large', bounds, gap if gap in (232, 492) else 232, widgets=metadata['widgets']) - result['version'] = 4 + if value['version'] < 5: + migrate_large_sign_layout(result['widgets'], bounds, widgets=metadata['widgets']) + result['version'] = 5 if len(json.dumps(result, separators=(',', ':'), allow_nan=False).encode()) > MAX_BYTES: raise ValueError('Projection layout too large') return result diff --git a/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py b/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py index 11af632d57..b876d31446 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py +++ b/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py @@ -18,6 +18,29 @@ SCREEN = {'version': 1, 'width': 1280, 'height': 720, 'margin_width': 0, class TestProjectionLayout(unittest.TestCase): + def test_v4_large_sign_migration_clamps_old_bottom_placement_without_losing_custom_actions(self): + document = default_layout(SCREEN) + document['version'] = 4 + bounds = layout_metadata(SCREEN)['bounds'] + bottom = bounds['y'] + bounds['height'] + document['widgets']['cruise_limits']['y'] = bottom - 132 + document['widgets']['speed_limit_actions'].update(x=250, y=400) + before = copy.deepcopy(document) + migrated = validate_layout(document, SCREEN) + self.assertEqual(document, before) + self.assertEqual(migrated['version'], 5) + self.assertEqual(migrated['widgets']['cruise_limits']['y'], bottom - 168) + self.assertEqual(migrated['widgets']['speed_limit_actions'], document['widgets']['speed_limit_actions']) + self.assertEqual(validate_layout(migrated, SCREEN), migrated) + + def test_v4_moves_actions_only_when_they_follow_the_old_default_sign_position(self): + document = default_layout(SCREEN) + document['version'] = 4 + card = document['widgets']['cruise_limits'] + document['widgets']['speed_limit_actions'].update(x=card['x']+12, y=card['y']+173) + migrated = validate_layout(document, SCREEN) + self.assertEqual(migrated['widgets']['speed_limit_actions']['y'], card['y']+221) + def test_jetlink_widget_migrates_and_preserves_placement_and_order(self): document = default_layout(SCREEN) expected = document['widgets'].pop('jetlink_status') diff --git a/openpilot/starpilot/ui/assets/README.md b/openpilot/starpilot/ui/assets/README.md index c3b77cb3e7..8cf17df569 100644 --- a/openpilot/starpilot/ui/assets/README.md +++ b/openpilot/starpilot/ui/assets/README.md @@ -45,3 +45,11 @@ pinned in `sora-brand-font.json` and retains Sora's embedded copyright and SIL Open Font License notice. Home can now use the pinned source-owned Sora, Inter, and Unifont fonts without an external directory. An explicit font-directory override remains available and must pass the same byte validation. + +The original SLC circle/ring masks and joined arrows are generated by +`python -m openpilot.starpilot.ui.assets.generate_speed_limit_shapes`. +The 8× coverage sampling happens offline; runtime shares one small atlas and +draws tinted quads without per-frame shape tessellation or new shaders. +Both onroad profiles use these US and Vienna signs. Large UI scales their geometry +by 1.25 and normalizes font sizes to preserve the compact sign's proportions, +including its fitted offsets, joined arrows and confirmation pulses. diff --git a/openpilot/starpilot/ui/assets/generate_speed_limit_shapes.py b/openpilot/starpilot/ui/assets/generate_speed_limit_shapes.py new file mode 100644 index 0000000000..1931431037 --- /dev/null +++ b/openpilot/starpilot/ui/assets/generate_speed_limit_shapes.py @@ -0,0 +1,52 @@ +"""Rasterize fixed SLC geometry once; runtime only tints and draws atlas quads.""" + +import numpy as np +from PIL import Image + +from openpilot.starpilot.ui.speed_limit_shapes import ATLAS_PATH, ATLAS_SIZE, SPRITES + + +SAMPLES = 8 + + +def coverage(mask, width, height): + return mask.reshape(height, SAMPLES, width, SAMPLES).mean(axis=(1, 3)) + + +def generate(): + atlas = Image.new('RGBA', ATLAS_SIZE) + for name, (x, y, width, height) in SPRITES.items(): + px = (np.arange(width * SAMPLES) + .5) / SAMPLES - width / 2 + py = (np.arange(height * SAMPLES) + .5) / SAMPLES - height / 2 + xx, yy = np.meshgrid(px, py) + rgba = np.full((height, width, 4), 255, dtype=np.uint8) + if name in ('disc', 'ring'): + radius = np.hypot(xx, yy) + # End the white backing inside the opaque rim, avoiding an outer white fringe. + mask = radius <= 55 if name == 'disc' else (radius <= 59) & (radius >= 51) + rgba[..., 3] = np.rint(coverage(mask, width, height) * 255).astype(np.uint8) + else: + half_height, wing, stem, head, outline = (14, 9, 3, 9, 1.4) if name == 'arrow_large' else (7, 5, 2.5, 5, 1) + base = half_height - head + points = ((-stem/2, -half_height), (stem/2, -half_height), (stem/2, base), (wing, base), + (0, half_height), (-wing, base), (-stem/2, base)) + inside = ((np.abs(xx) <= stem/2) & (yy >= -half_height) & (yy <= base)) | ( + (yy >= base) & (yy <= half_height) & (np.abs(xx) <= wing * (half_height - yy) / head)) + distance_sq = np.full_like(xx, np.inf) + for (ax, ay), (bx, by) in zip(points, (*points[1:], points[0]), strict=True): + dx, dy = bx - ax, by - ay + t = np.clip(((xx - ax) * dx + (yy - ay) * dy) / (dx*dx + dy*dy), 0, 1) + distance_sq = np.minimum(distance_sq, (xx - ax - t*dx)**2 + (yy - ay - t*dy)**2) + fill = coverage(inside, width, height) + border = coverage((distance_sq <= outline**2) & ~inside, width, height) * .87 + alpha = fill + border + for channel, dark in enumerate((6, 14, 10)): + rgba[..., channel] = np.rint(np.divide(fill * 255 + border * dark, alpha, + out=np.full_like(alpha, dark), where=alpha > 0)).astype(np.uint8) + rgba[..., 3] = np.rint(alpha * 255).astype(np.uint8) + atlas.paste(Image.fromarray(rgba), (x, y)) + return atlas + + +if __name__ == '__main__': + generate().save(ATLAS_PATH, optimize=True) diff --git a/openpilot/starpilot/ui/assets/speed_limit_shapes.png b/openpilot/starpilot/ui/assets/speed_limit_shapes.png new file mode 100644 index 0000000000..7a455ed958 Binary files /dev/null and b/openpilot/starpilot/ui/assets/speed_limit_shapes.png differ diff --git a/openpilot/starpilot/ui/large_speed_geometry.py b/openpilot/starpilot/ui/large_speed_geometry.py index b0a9a43256..872cd777c1 100644 --- a/openpilot/starpilot/ui/large_speed_geometry.py +++ b/openpilot/starpilot/ui/large_speed_geometry.py @@ -2,22 +2,23 @@ WIDTH = 488 MAX_ROW_HEIGHT = 132 -LIMIT_ROW_HEIGHT = MAX_ROW_HEIGHT -CARD_HEIGHT = MAX_ROW_HEIGHT +LARGE_SIGN_SCALE = 1.25 +LIMIT_ROW_HEIGHT = 168 +CARD_HEIGHT = LIMIT_ROW_HEIGHT ACTION_WIDTH = 576 -ACTION_HEIGHT = 160 +ACTION_HEIGHT = 148 ACTION_X = 100 -ACTION_Y = 248 +ACTION_Y = 296 GAP = 8 SOURCE_ROW_HEIGHT = 76 PADDING = 24 -LIMIT_RECTS = {'large': (272, 94, 292, 124), 'compact': (330, 20, 118, 132)} +LIMIT_RECTS = {'large': (272, 75, 148, 165), 'compact': (330, 20, 118, 132)} ACTION_BOUNDS = { - 'large': ((100, 248, 372, 408), (404, 248, 676, 408)), + 'large': ((100, ACTION_Y, 372, ACTION_Y + ACTION_HEIGHT), (404, ACTION_Y, 676, ACTION_Y + ACTION_HEIGHT)), 'compact': ((128, 182, 296, 240), (308, 182, 476, 240)), } -ACTION_TEXT = {'large': (64, 320), 'compact': (48, 213)} +ACTION_TEXT = {'large': (64, ACTION_Y + 72), 'compact': (48, 213)} def card_height(mode): diff --git a/openpilot/starpilot/ui/onroad.py b/openpilot/starpilot/ui/onroad.py index 36215d33eb..adad0842d4 100644 --- a/openpilot/starpilot/ui/onroad.py +++ b/openpilot/starpilot/ui/onroad.py @@ -546,6 +546,7 @@ class OnroadView: self._corner_cache.close() self.navigation.close() if self.fonts.profile == Profile.LARGE: + self.unified_speed.close() self.steering_wheel.close() else: self.compact_hud.close() diff --git a/openpilot/starpilot/ui/onroad_compact_widgets.py b/openpilot/starpilot/ui/onroad_compact_widgets.py index 3e4f54b473..cd5c8ee92a 100644 --- a/openpilot/starpilot/ui/onroad_compact_widgets.py +++ b/openpilot/starpilot/ui/onroad_compact_widgets.py @@ -170,6 +170,7 @@ class CompactHudRenderer: rl.Color(*color, int(wheel_alpha))) def close(self) -> None: + self._speed_limit_widget.close() if self._wheel is not None and rl.is_window_ready(): rl.unload_texture(self._wheel) self._wheel = None diff --git a/openpilot/starpilot/ui/onroad_customization.py b/openpilot/starpilot/ui/onroad_customization.py index 48cce67538..70761e511d 100644 --- a/openpilot/starpilot/ui/onroad_customization.py +++ b/openpilot/starpilot/ui/onroad_customization.py @@ -303,7 +303,7 @@ def customization_metadata(): def default_document(): - return {"version": 9, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES}, + return {"version": 10, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES}, "widgetColors": {profile: {} for profile in PROFILES}, "layouts": { profile: {key: {**widget["default"], **({"size": WIDGET_SIZES[key][profile][1]} if key in WIDGET_SIZES else {})} for key, widget in data["widgets"].items()} @@ -364,8 +364,19 @@ def _compact_action_collisions(layout): yield key, height +def migrate_large_sign_layout(layout, bounds, *, widgets=None, extra_height=0): + widgets = PROFILES['large']['widgets'] if widgets is None else widgets + card, actions = layout['cruise_limits'], layout['speed_limit_actions'] + # Only the old attached arrangement follows the sign; independently placed actions stay put. + paired = actions['x'] == card['x'] + 12 and actions['y'] == card['y'] + 173 + card['y'] = min(card['y'], bounds['y'] + bounds['height'] - widgets['cruise_limits']['height'] - extra_height) + if paired: + actions['y'] = min(card['y'] + ACTION_Y - 75, + bounds['y'] + bounds['height'] - widgets['speed_limit_actions']['height']) + + def validate_document(value): - if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 10): + if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 11): raise ValueError("Invalid customization version") fields = {"version", "palette", "layouts"} | ({"widgetColors"} if value["version"] >= 2 else set()) if value["version"] >= 3: @@ -538,6 +549,8 @@ def validate_document(value): dimensions = SPEED_LAYOUT_V7[profile].get(key) if value['version'] == 8 and profile == 'compact': dimensions = COMPACT_SPEED_LAYOUT_V8.get(key) + if dimensions is None and value['version'] < 10 and profile == 'large' and key == 'cruise_limits': + dimensions = (WIDTH, 132) dimension = (dimensions[0 if axis == 'x' else 1] if dimensions else size if key in WIDGET_SIZES else placement[extent] if widget.get("box") else widget[extent]) if old_gauge: @@ -568,6 +581,8 @@ def validate_document(value): result['clock24Hour'] = value['clock24Hour'] if 'showMaxSpeed' in value: result['showMaxSpeed'] = value['showMaxSpeed'] + if value['version'] < 10: + migrate_large_sign_layout(result['layouts']['large'], PROFILES['large']['bounds'], extra_height=sources_extent(result)) if value['version'] < 8: if value['version'] < 5 and value['palette'] != PALETTE: colors = result['widgetColors']['large'].setdefault('cruise_limits', {}) diff --git a/openpilot/starpilot/ui/onroad_large_widgets.py b/openpilot/starpilot/ui/onroad_large_widgets.py index 157af6d7a4..5d7617a157 100644 --- a/openpilot/starpilot/ui/onroad_large_widgets.py +++ b/openpilot/starpilot/ui/onroad_large_widgets.py @@ -11,7 +11,7 @@ import json import pyray as rl from openpilot.starpilot.ui.onroad_customization import offset, rgba, widget_size -from openpilot.starpilot.ui.onroad_state import OnroadState +from openpilot.starpilot.ui.onroad_state import OnroadState, speed_limit_bounds from openpilot.starpilot.ui.onroad_widget_style import draw_control_card from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile from openpilot.starpilot.ui.wheel_feedback import WheelFeedbackFade @@ -36,6 +36,9 @@ class UnifiedSpeedWidget: self.fonts = fonts self.sign = SpeedLimitWidget(fonts, Profile.LARGE) + def close(self): + self.sign.close() + def _draw_sources(self, rect, state): """Each enabled source as a small label over its reading; the selected source is highlighted.""" from openpilot.starpilot.ui.large_speed_geometry import PADDING @@ -82,6 +85,8 @@ class UnifiedSpeedWidget: size -= 1 draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, x, rect.y + 51, text) self.sign.render(state) + if shown.mode == 'limit_only' and (bounds := speed_limit_bounds(Profile.LARGE, state)) is not None: + draw_ink(self.fonts, shown.unit, FontRole.NORMAL, 24, bounds[2], bounds[3] - 32) if sources: row = rl.Rectangle(rect.x, rect.y + height + (GAP if separate else 0), WIDTH, SOURCE_ROW_HEIGHT) if separate: diff --git a/openpilot/starpilot/ui/speed_limit_pulse.py b/openpilot/starpilot/ui/speed_limit_pulse.py index a6dcd8120f..fe4ac6a8c6 100644 --- a/openpilot/starpilot/ui/speed_limit_pulse.py +++ b/openpilot/starpilot/ui/speed_limit_pulse.py @@ -4,14 +4,20 @@ import math import pyray as rl -from openpilot.starpilot.ui.onroad_state import OnroadState +from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState from openpilot.starpilot.ui.unified_speed_presentation import displayed_limit_mps +CONFIRMATION_PURPLE = (170, 115, 235) +CONFIRMATION_PULSES = ((0.0, .7), (.9, .7), (2.6, .3), (3.1, .3)) +CONFIRMATION_CYCLE_SECONDS = 4.4 + + class SpeedLimitPulse: def __init__(self): self.last_limit = 0.0 self.start_time = -math.inf + self.manual_pending = False self._started_frame: int | None = None def update(self, state: OnroadState, now: float, *, visible: bool = True) -> None: @@ -20,26 +26,39 @@ class SpeedLimitPulse: self.start_time = -math.inf self._started_frame = state.drive_frame observation = state.speed_limit - if not visible: - self.start_time = -math.inf - return limit = displayed_limit_mps(observation) - if limit is None: - return - if observation.automatic_adoption_pending and observation.pending_since_ns > 0: - self.start_time = observation.pending_since_ns / 1e9 - elif observation.automatic_adoption_pending and limit != self.last_limit: - self.start_time = now - elif not observation.automatic_adoption_pending: + pending = (observation.kind == ObservationKind.VALID and observation.pending_speed_limit_mps is not None and + observation.pending_speed_limit_mps > 0) + if not visible or limit is None or not pending: self.start_time = -math.inf + self.manual_pending = False + return + manual = not observation.automatic_adoption_pending + if observation.pending_since_ns > 0: + self.start_time = observation.pending_since_ns / 1e9 + elif limit != self.last_limit or manual != self.manual_pending or not math.isfinite(self.start_time): + self.start_time = now + self.manual_pending = manual self.last_limit = limit def color(self, base: rl.Color, now: float) -> rl.Color: if isinstance(base, (tuple, list)): base = rl.Color(*base) elapsed = now - self.start_time - if not 0.0 <= elapsed < 3.0: + if not math.isfinite(elapsed) or elapsed < 0: return base - fraction = (1 - math.cos(2 * math.pi * elapsed)) / 2 + if self.manual_pending: + phase = elapsed % CONFIRMATION_CYCLE_SECONDS + fraction = 0.0 + for start, duration in CONFIRMATION_PULSES: + if start <= phase < start + duration: + fraction = math.sin(math.pi * (phase - start) / duration) ** 2 + break + target = CONFIRMATION_PURPLE + else: + if elapsed >= 3.0: + return base + fraction = (1 - math.cos(2 * math.pi * elapsed)) / 2 + target = (188, 132, 255) return rl.Color(*(round(value + (target - value) * fraction) - for value, target in zip((base.r, base.g, base.b), (188, 132, 255), strict=True)), base.a) + for value, target in zip((base.r, base.g, base.b), target, strict=True)), base.a) diff --git a/openpilot/starpilot/ui/speed_limit_shapes.py b/openpilot/starpilot/ui/speed_limit_shapes.py new file mode 100644 index 0000000000..e8e1fbbaa4 --- /dev/null +++ b/openpilot/starpilot/ui/speed_limit_shapes.py @@ -0,0 +1,61 @@ +"""One cached atlas for joined arrows and antialiased speed-limit sign edges.""" + +from pathlib import Path + +import pyray as rl + + +ATLAS_PATH = Path(__file__).with_name('assets') / 'speed_limit_shapes.png' +ATLAS_SIZE = (256, 176) +SPRITES = { + 'disc': (0, 0, 128, 128), 'ring': (128, 0, 128, 128), + 'arrow_large': (0, 128, 32, 40), 'arrow_compact': (32, 128, 16, 24), +} + + +class SpeedLimitShapes: + def __init__(self, scale=1): + self.scale = scale + self.texture = None + self.sources = {name: rl.Rectangle(*rect) for name, rect in SPRITES.items()} + self.origins = {name: rl.Vector2(rect[2] * scale / 2, rect[3] * scale / 2) for name, rect in SPRITES.items()} + + def _load(self): + if self.texture is not None: + return + if not rl.is_window_ready(): + raise RuntimeError('Speed-limit shapes require a graphics context') + image = rl.load_image(str(ATLAS_PATH)) + try: + if image.data == rl.ffi.NULL or (image.width, image.height) != ATLAS_SIZE: + raise ValueError('Invalid speed-limit shape atlas') + self.texture = rl.load_texture_from_image(image) + if not self.texture.id: + self.texture = None + raise RuntimeError('Unable to upload speed-limit shapes') + rl.set_texture_filter(self.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + rl.set_texture_wrap(self.texture, rl.TextureWrap.TEXTURE_WRAP_CLAMP) + except BaseException: + self.close() + raise + finally: + if image.data != rl.ffi.NULL: + rl.unload_image(image) + + def _draw(self, name, x, y, color, rotation=0): + self._load() + source = self.sources[name] + rl.draw_texture_pro(self.texture, source, rl.Rectangle(x, y, source.width * self.scale, source.height * self.scale), + self.origins[name], rotation, color) + + def circle(self, x, y, color): + self._draw('disc', x, y, rl.WHITE) + self._draw('ring', x, y, color) + + def arrow(self, size, x, y, direction, color): + self._draw('arrow_large' if size == 'large' else 'arrow_compact', x, y, color, 180 if direction < 0 else 0) + + def close(self): + if self.texture is not None and rl.is_window_ready(): + rl.unload_texture(self.texture) + self.texture = None diff --git a/openpilot/starpilot/ui/speed_limit_widget.py b/openpilot/starpilot/ui/speed_limit_widget.py index 5202fb90e5..e4baadaf98 100644 --- a/openpilot/starpilot/ui/speed_limit_widget.py +++ b/openpilot/starpilot/ui/speed_limit_widget.py @@ -1,14 +1,14 @@ """The latest limit, its attention animation and override mark on both screens.""" -import math import time import pyray as rl -from openpilot.starpilot.ui.onroad_customization import placement +from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE from openpilot.starpilot.ui.onroad_state import OnroadState, speed_limit_bounds from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile from openpilot.starpilot.ui.speed_limit_pulse import SpeedLimitPulse +from openpilot.starpilot.ui.speed_limit_shapes import SpeedLimitShapes from openpilot.starpilot.ui.unified_speed_presentation import displayed_adjusted_limit_mps, resolve_unified_speed @@ -59,7 +59,13 @@ class SpeedLimitWidget: def __init__(self, fonts: BitmapFonts, profile: Profile): self.fonts = fonts self.profile = profile + self.scale = LARGE_SIGN_SCALE if profile == Profile.LARGE else 1 + self.text_scale = self.scale * Profile.COMPACT.font_scale / profile.font_scale self.pulse = SpeedLimitPulse() + self.shapes = SpeedLimitShapes(self.scale) + + def close(self): + self.shapes.close() def render(self, state: OnroadState) -> None: bounds = speed_limit_bounds(self.profile, state) @@ -67,92 +73,67 @@ class SpeedLimitWidget: self.pulse.update(state, now, visible=bounds is not None) if bounds is None: return - if self.profile == Profile.COMPACT: - self._compact_sign(state, bounds, now) - return - shown, obs = resolve_unified_speed(state), state.speed_limit - pending = shown.pending and not obs.automatic_adoption_pending - elapsed = max(0., now - obs.pending_since_ns / 1e9) - hop = -6 * math.sin(math.pi * (elapsed % 1.5) / 1.5) ** 2 if pending else 0 - large = self.profile == Profile.LARGE - x, top, _, _ = bounds - x, top = x + 12, max(0, top + 12) - number_top = top + (40 if large else 32) + hop - color = self.pulse.color(INK, now) - set_visible = shown.mode != 'limit_only' if large else (state.cruise_active and state.cruise_kph is not None and - placement(state.customization, self.profile, 'max_speed')['enabled']) - heading_width, heading_height = draw_caption(self.fonts, 'LIMIT', x, top, unit=None if set_visible else shown.unit) - width, height = draw_ink(self.fonts, shown.posted_text, FontRole.BOLD, 70 if large else 58, x, number_top, color) - end = x + width - if shown.offset_text: - size = 46 if large else 36 - a, b = self.fonts.vertical_ink(shown.offset_text, FontRole.MEDIUM, size) - gap = 12 if large else 10 - draw_ink(self.fonts, shown.offset_text, FontRole.MEDIUM, size, end + gap, number_top + height - (b - a), color) - if pending: - target = displayed_adjusted_limit_mps(obs) - if target is not None and state.speed_mps is not None and target != state.speed_mps: - ax, ay = x + heading_width + (20 if large else 14), top + heading_height / 2 - direction = -1 if target > state.speed_mps else 1 - half_height, wing, stroke = (14, 9, 3) if large else (10, 6, 2) - tip = rl.Vector2(ax, ay + direction * half_height) - draw_line(self.profile, rl.Vector2(ax, ay - direction * half_height), tip, stroke, color) - for dx in (-wing, wing): - draw_line(self.profile, tip, rl.Vector2(ax + dx, tip.y - direction * wing), stroke, color) - if obs.driver_override_active: - color = rl.Color(197, 163, 255, 240) - left, top, right, bottom = x - 4, number_top - 3, x + width + 4, number_top + height + 3 - draw_line(self.profile, rl.Vector2(left, top), rl.Vector2(right, bottom), 3 if large else 2.5, color) - draw_line(self.profile, rl.Vector2(right, top), rl.Vector2(left, bottom), 3 if large else 2.5, color) + self._sign(state, bounds, now) - def _compact_sign(self, state: OnroadState, bounds, now: float) -> None: + def _sign(self, state: OnroadState, bounds, now: float) -> None: shown, obs = resolve_unified_speed(state), state.speed_limit x, top = bounds[0] + 12, bounds[1] + 12 + scale = self.scale pending = shown.pending and not obs.automatic_adoption_pending - elapsed = max(0., now - obs.pending_since_ns / 1e9) - hop = -6 * math.sin(math.pi * (elapsed % 1.5) / 1.5) ** 2 if pending else 0 + target = displayed_adjusted_limit_mps(obs) if pending else None + arrow_direction = 0 + if target is not None and state.speed_mps is not None and target != state.speed_mps: + arrow_direction = -1 if target > state.speed_mps else 1 white = self.pulse.color(rl.Color(244, 247, 245, 255), now) secondary = self.pulse.color(rl.Color(223, 232, 227, 255), now) - def centered(text, role, size, ink_top, color): - left, right = self.fonts.horizontal_ink(text, role, size) - a, b = self.fonts.vertical_ink(text, role, size) - origin = x + 59 - (left + right) / 2 - self.fonts.draw(text, role, size, origin, ink_top - a, color) + def ink(text, role, size): + return (*self.fonts.horizontal_ink(text, role, size * self.text_scale), + *self.fonts.vertical_ink(text, role, size * self.text_scale)) + + def centered(text, role, size, ink_top, color, center_x=x + 59 * scale): + left, right, a, b = ink(text, role, size) + origin = center_x - (left + right) / 2 + self.fonts.draw(text, role, size * self.text_scale, origin, ink_top - a, color) return origin + left, ink_top, origin + right, ink_top + b - a if state.appearance.use_vienna_sign: - cx, cy = x + 59, top + 67 + hop - rl.draw_circle(int(cx), int(cy), 59, rl.WHITE) - rl.draw_ring(rl.Vector2(cx, cy), 51, 59, 0, 360, 64, self.pulse.color(rl.Color(201, 34, 49, 255), now)) - color = self.pulse.color(rl.Color(0, 0, 0, 255), now) - size = 58 if len(shown.posted_text) <= 2 else 48 - a, b = self.fonts.vertical_ink(shown.posted_text, FontRole.BOLD, size) - number_top = top + 36 + hop if shown.offset_text else cy - (b - a) / 2 + cx, cy = x + 59 * scale, top + 67 * scale + self.shapes.circle(cx, cy, self.pulse.color(rl.Color(201, 34, 49, 255), now)) + # Keep black-on-white numerals stable; the ring carries manual confirmation pulses. + color = rl.Color(0, 0, 0, 255) if pending else self.pulse.color(rl.Color(0, 0, 0, 255), now) + size = (54 if pending else 58) if len(shown.posted_text) <= 2 else 48 + number_center = cy - 7 * scale if shown.offset_text else cy + (7 * scale if pending else 0) + while True: + left, right, a, b = ink(shown.posted_text, FontRole.BOLD, size) + if ((right - left) / 2) ** 2 + (abs(number_center - cy) + (b - a) / 2) ** 2 <= (47 * scale) ** 2 or size <= 32: + break + size -= 2 + number_top = number_center - (b - a) / 2 number = centered(shown.posted_text, FontRole.BOLD, size, number_top, color) if shown.offset_text: - centered(shown.offset_text, FontRole.MEDIUM, 36, top + 81 + hop, color) + offset_top, size = number[3] + 4 * scale, 36 + while True: + left, right, a, b = ink(shown.offset_text, FontRole.MEDIUM, size) + if ((right - left) / 2) ** 2 + max((offset_top - cy) ** 2, (offset_top + b - a - cy) ** 2) <= (47 * scale) ** 2 or size <= 24: + break + size -= 2 + centered(shown.offset_text, FontRole.MEDIUM, size, offset_top, color) else: - rect = rl.Rectangle(x + 2, top + 2, 114, 128) + rect = rl.Rectangle(x + 2 * scale, top + 2 * scale, 114 * scale, 128 * scale) rl.draw_rectangle_rounded(rect, .1, 16, rl.Color(10, 17, 19, 230)) - rl.draw_rectangle_rounded_lines_ex(rect, .1, 16, 2, white) - for label, y in (('SPEED', top + 11), ('LIMIT', top + 27)): - centered(label, FontRole.SEMI_BOLD, 20, y, white) - number = centered(shown.posted_text, FontRole.BOLD, 58, top + (50 if shown.offset_text else 61) + hop, white) + rl.draw_rectangle_rounded_lines_ex(rect, .1, 16, 2 * scale, white) + for label, y in (('SPEED', top + 11 * scale), ('LIMIT', top + 27 * scale)): + centered(label, FontRole.SEMI_BOLD, 20, y, white, x + (53 if arrow_direction else 59) * scale) + number = centered(shown.posted_text, FontRole.BOLD, 58, top + (50 if shown.offset_text else 61) * scale, white) if shown.offset_text: - centered(shown.offset_text, FontRole.MEDIUM, 36, top + 98 + hop, secondary) - if pending: - target = displayed_adjusted_limit_mps(obs) - if target is not None and state.speed_mps is not None and target != state.speed_mps: - ax, ay = ((x + 105, top + 49 + hop) if state.appearance.use_vienna_sign else (x + 108, top + 23)) - arrow_color = color if state.appearance.use_vienna_sign else white - direction = -1 if target > state.speed_mps else 1 - tip = rl.Vector2(ax, ay + direction * 6) - draw_line(self.profile, rl.Vector2(ax, ay - direction * 6), tip, 2, arrow_color) - for dx in (-3, 3): - draw_line(self.profile, tip, rl.Vector2(ax + dx, tip.y - direction * 4), 2, arrow_color) + centered(shown.offset_text, FontRole.MEDIUM, 36, top + 98 * scale, secondary) + if arrow_direction: + ax, ay = ((x + 59 * scale, top + 29 * scale) if state.appearance.use_vienna_sign else (x + 99 * scale, top + 26 * scale)) + arrow_color = color if state.appearance.use_vienna_sign else white + self.shapes.arrow('compact' if state.appearance.use_vienna_sign else 'large', ax, ay, arrow_direction, arrow_color) if obs.driver_override_active: color = rl.Color(197, 163, 255, 240) left, top, right, bottom = number - draw_line(self.profile, rl.Vector2(left - 3, top - 2), rl.Vector2(right + 3, bottom + 2), 2.5, color) - draw_line(self.profile, rl.Vector2(right + 3, top - 2), rl.Vector2(left - 3, bottom + 2), 2.5, color) + draw_line(self.profile, rl.Vector2(left - 3 * scale, top - 2 * scale), rl.Vector2(right + 3 * scale, bottom + 2 * scale), 2.5 * scale, color) + draw_line(self.profile, rl.Vector2(right + 3 * scale, top - 2 * scale), rl.Vector2(left - 3 * scale, bottom + 2 * scale), 2.5 * scale, color) diff --git a/openpilot/starpilot/ui/tests/test_dinish_speed.py b/openpilot/starpilot/ui/tests/test_dinish_speed.py index 7278d91027..4c60d7eb4c 100644 --- a/openpilot/starpilot/ui/tests/test_dinish_speed.py +++ b/openpilot/starpilot/ui/tests/test_dinish_speed.py @@ -35,7 +35,7 @@ def test_numeric_subset_is_pinned_and_has_real_tabular_glyphs(): def test_approved_limit_offset_and_action_sizes_fit_actual_fonts(): - from openpilot.starpilot.ui.large_speed_geometry import ACTION_TEXT, LIMIT_RECTS + from openpilot.starpilot.ui.large_speed_geometry import ACTION_TEXT, LARGE_SIGN_SCALE, LIMIT_RECTS owns_context = not rl.is_window_ready() if owns_context: rl.init_window(128, 128, "Native speed-group font fixture") @@ -43,13 +43,15 @@ def test_approved_limit_offset_and_action_sizes_fit_actual_fonts(): for profile in Profile: with BitmapFonts(profile, default_font_directory()) as fonts: large = profile == Profile.LARGE - limit_size, offset_size = (70, 46) if large else (58, 36) + scale = LARGE_SIGN_SCALE if large else 1 + text_scale = scale * Profile.COMPACT.font_scale / profile.font_scale + limit_size, offset_size = 58 * text_scale, 36 * text_scale for value, offset in (('65', '+5'), ('120', '+10'), ('130', '-10'), ('145', '-137')): a, b = fonts.horizontal_ink(value, FontRole.BOLD, limit_size) value_width = b - a a, b = fonts.horizontal_ink(offset, FontRole.MEDIUM, offset_size) offset_width = b - a - width = value_width + offset_width + 40 if large else max(value_width, offset_width) + 20 + width = max(value_width, offset_width) + 20 * scale assert width <= LIMIT_RECTS[profile][2] action_width = 272 if large else 168 assert all(fonts.measure(label, FontRole.MEDIUM, ACTION_TEXT[profile][0]).width + 4 <= action_width @@ -65,7 +67,7 @@ def test_approved_limit_offset_and_action_sizes_fit_actual_fonts(): rl.close_window() -def test_set_speed_and_limit_keep_the_approved_origins_and_fixed_limit_size(): +def test_large_sign_scales_beside_the_unchanged_set_speed(): from unittest.mock import patch from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget from openpilot.starpilot.ui.layout_preview_renderer import sample_state @@ -89,8 +91,10 @@ def test_set_speed_and_limit_keep_the_approved_origins_and_fixed_limit_size(): UnifiedSpeedWidget(fonts).render(rl.Rectangle(30, 30, 1800, 1020), current) calls = [call for call in calls if call[-1].a == 255] posted = next(call for call in calls if call[0] == '120' and call[1] == FontRole.BOLD) - assert posted[2:4] == (70, 372) - assert posted[4] + fonts.vertical_ink('120', FontRole.BOLD, 70)[0] == 154 + assert posted[2] == 58 * 1.25 * Profile.COMPACT.font_scale / Profile.LARGE.font_scale + left, right = fonts.horizontal_ink('120', FontRole.BOLD, posted[2]) + assert abs(posted[3] + (left + right) / 2 - 445.75) < 1e-6 + assert posted[4] + fonts.vertical_ink('120', FontRole.BOLD, posted[2])[0] == 157.5 set_calls = [call for call in calls if call[1] == FontRole.SET_SPEED] assert bool(set_calls) == show_set if set_calls: @@ -165,7 +169,7 @@ def test_galaxy_card_pixels_align_without_reading_font_textures(): rl.close_window() -def test_native_captions_offsets_and_bounce_fit_their_touch_targets(): +def test_native_signs_offsets_and_captions_fit_their_touch_targets(): from unittest.mock import Mock, patch from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget @@ -202,12 +206,10 @@ def test_native_captions_offsets_and_bounce_fit_their_touch_targets(): call[4]+fonts.vertical_ink(call[0], call[1], call[2])[1]) for call in calls if call[-1].a == 255} if profile == Profile.LARGE: - assert ink['SET'][1] == ink['LIMIT'][1] - assert ink['120'][3] == ink['+10'][3] - assert ink['120'][2] < ink['+10'][0] + assert ink['SET'][2] < ink['SPEED'][0] else: assert 'SET' not in ink and 'SPEED' in ink - assert ink['120'][3] < ink['+10'][1] + assert ink['120'][3] < ink['+10'][1] bounds = speed_limit_bounds(profile, current) assert bounds is not None left, top, right, bottom = bounds @@ -255,47 +257,118 @@ def test_compact_preview_sprite_keeps_the_sign_offset_arrow_and_override_cross() rl.close_window() -def test_compact_balanced_stack_uses_mock_sizes_and_contains_all_ink(): +def test_large_sources_and_confirmation_text_have_clearance_below_both_signs(): from unittest.mock import patch - from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget + from openpilot.starpilot.ui.large_speed_geometry import CARD_HEIGHT, GAP, SOURCE_ROW_HEIGHT + from openpilot.starpilot.ui.onroad import OnroadView + from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget + from openpilot.starpilot.ui.onroad_state import SourceReading from openpilot.starpilot.ui.tests.test_unified_speed_native import state owns_context = not rl.is_window_ready() if owns_context: - rl.init_window(128, 128, 'C4 contained sign fixture') + rl.init_window(128, 128, 'Large sign sources and confirmation spacing') try: - with BitmapFonts(Profile.COMPACT, default_font_directory()) as fonts: - widget = SpeedLimitWidget(fonts, Profile.COMPACT) - for vienna, (posted, offset), elapsed, (x, y) in product( - (False, True), ((65, 5), (120, 10), (130, -10), (145, -137), (55, 0)), - (0, 750_000_000, 1_500_000_000), ((330, 20), (100, 0), (358, 108))): - current = state(observed_ns=10_000_000_000+elapsed) - current.customization['layouts']['compact']['speed_limit'].update(x=x, y=y) + with BitmapFonts(Profile.LARGE, default_font_directory()) as fonts: + widget = UnifiedSpeedWidget(fonts) + view = OnroadView.__new__(OnroadView) + view.fonts = fonts + for vienna, separate in product((False, True), repeat=2): + current = state(slc_prompt_open=True) + current.customization.update(speedSources=True, speedSourcesCard=separate) current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna), - speed_limit=replace(current.speed_limit, speed_limit_mps=posted/3.6, - pending_speed_limit_mps=posted/3.6, presentation_adjusted_limit_mps=(posted+offset)/3.6)) + speed_limit=replace(current.speed_limit, source_readings=tuple( + SourceReading(source, True, 'valid', 50/3.6) for source in ('dashboard', 'map', 'vision')))) calls = [] with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)): - widget.render(current) - ink = [] - for text, role, size, left, top, _ in calls: - a, b = fonts.horizontal_ink(text, role, size) - c, d = fonts.vertical_ink(text, role, size) - box = left+a, top+c, left+b, top+d + widget.render(rl.Rectangle(30, 30, 1800, 1020), current) + view._slc_actions(current) + foreground = [call for call in calls if call[-1].a == 255] + action_top = min(y+fonts.vertical_ink(text, role, size)[0] + for text, role, size, _, y, _ in foreground if text in ('Accept', 'Decline')) + source_bottom = 75+CARD_HEIGHT+(GAP if separate else 0)+SOURCE_ROW_HEIGHT + assert source_bottom+8 <= action_top + widget.close() + finally: + if owns_context: + rl.close_window() + + +def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale(): + from unittest.mock import patch + from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget + from openpilot.starpilot.ui.tests.test_unified_speed_native import state + from openpilot.starpilot.ui.speed_limit_shapes import ATLAS_PATH, SPRITES + glyphs = {} + with Image.open(ATLAS_PATH) as atlas: + for size in ('large', 'compact'): + sx, sy, sw, sh = SPRITES[f'arrow_{size}'] + bbox = atlas.crop((sx, sy, sx+sw, sy+sh)).getchannel('A').point(lambda alpha: 255 if alpha > 40 else 0).getbbox() + glyphs[size] = (bbox[0]-sw/2, bbox[1]-sh/2, bbox[2]-sw/2, bbox[3]-sh/2) + owns_context = not rl.is_window_ready() + if owns_context: + rl.init_window(128, 128, 'Contained signs on both device profiles') + try: + for profile in Profile: + with BitmapFonts(profile, default_font_directory()) as fonts: + widget = SpeedLimitWidget(fonts, profile) + scale = widget.scale + positions = ((88, 75), (188, 95), (1642, 882)) if profile == Profile.LARGE else ((330, 20), (100, 0), (358, 108)) + for vienna, (posted, offset), elapsed, (saved_x, y), direction in product( + (False, True), ((65, 5), (120, 10), (130, -10), (145, -137), (199, -191), (200, -192), (55, 0)), + (0, 350_000_000, 2_100_000_000), positions, (-1, 1)): + if not vienna and posted > 145: + continue + current = state(observed_ns=10_000_000_000+elapsed) + key = 'cruise_limits' if profile == Profile.LARGE else 'speed_limit' + current.customization['layouts'][profile][key].update(x=saved_x, y=y) + x = saved_x+184 if profile == Profile.LARGE else saved_x + current = replace(current, speed_mps=(posted+offset+direction*2)/3.6, + appearance=replace(current.appearance, use_vienna_sign=vienna), + speed_limit=replace(current.speed_limit, speed_limit_mps=posted/3.6, + pending_speed_limit_mps=posted/3.6, presentation_adjusted_limit_mps=(posted+offset)/3.6)) + calls = [] + with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)), \ + patch.object(widget.shapes, 'arrow') as arrow_draw: + widget.render(current) + ink = [] + for text, role, size, left, top, color in calls: + a, b = fonts.horizontal_ink(text, role, size) + c, d = fonts.vertical_ink(text, role, size) + box = left+a, top+c, left+b, top+d + if vienna: + cx, cy = x+59*scale, y+67*scale + assert all((px-cx)**2+(py-cy)**2 <= (47*scale)**2 + for px, py in product((box[0], box[2]), (box[1], box[3]))) + assert (color.r, color.g, color.b, color.a) == (0, 0, 0, 255) + else: + assert x+7*scale <= box[0] < box[2] <= x+111*scale + assert y+7*scale <= box[1] < box[3] <= y+125*scale + ink.append((text, box)) + nominal_size = size/widget.text_scale + if role == FontRole.BOLD: + assert 31.99 <= nominal_size <= (48.01 if len(text)>2 else 54.01) if vienna else nominal_size == 58 + elif role == FontRole.MEDIUM: + assert 23.99 <= nominal_size <= 36.01 if vienna else nominal_size == 36 + arrow_draw.assert_called_once() + size, ax, ay, arrow_direction, _ = arrow_draw.call_args.args + assert size == ('compact' if vienna else 'large') + assert arrow_direction == direction + glyph = glyphs[size] + left, top, right, bottom = glyph if direction>0 else (-glyph[2], -glyph[3], -glyph[0], -glyph[1]) + arrow = ax+left*scale, ay+top*scale, ax+right*scale, ay+bottom*scale if vienna: - hop = -6 if elapsed == 750_000_000 else 0 - cx, cy = x+59, y+67+hop - assert all((px-cx)**2+(py-cy)**2 < 59**2 for px, py in product((box[0], box[2]), (box[1], box[3]))) + assert all((px-cx)**2+(py-cy)**2 <= (47*scale)**2 + for px, py in product((arrow[0], arrow[2]), (arrow[1], arrow[3]))) else: - assert x+7 <= box[0] < box[2] <= x+111 - assert y+7 <= box[1] < box[3] <= y+125 - ink.append((text, box)) - if role == FontRole.BOLD: - assert size == (48 if vienna and len(text) > 2 else 58) - elif role == FontRole.MEDIUM: - assert size == 36 - for index, (_, a) in enumerate(ink): - for _, b in ink[index+1:]: - assert not (a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]) + assert x+7*scale <= arrow[0] < arrow[2] <= x+111*scale + assert y+7*scale <= arrow[1] < arrow[3] <= y+125*scale + assert all(box[2]+4*scale <= arrow[0] for text, box in ink if text in ('SPEED', 'LIMIT')) + assert arrow[3]+6*scale <= next(box[1] for text, box in ink if text==str(posted)) + ink.append(('arrow', arrow)) + for index, (_, a) in enumerate(ink): + for _, b in ink[index+1:]: + assert not (a[0]b[0] and a[1]b[1]) + widget.close() finally: if owns_context: rl.close_window() @@ -308,23 +381,23 @@ def test_v5_geometry_migrates_and_keeps_custom_actions_independent(): doc['layouts']['large']['cruise_limits'].update(x=1550, y=638) doc['layouts']['large']['speed_limit_actions'].update(x=1550, y=954) migrated = validate_document(doc) - assert migrated['version'] == 9 + assert migrated['version'] == 10 assert migrated['layouts']['large']['cruise_limits'] == {'x': 1550, 'y': 638, 'enabled': True} - assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1550, 'y': 890, 'enabled': True} + assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1550, 'y': 902, 'enabled': True} assert migrated['speedSources'] is True assert doc['layouts']['large']['cruise_limits']['x'] == 1550 assert validate_document(migrated) == migrated doc['layouts']['large']['cruise_limits'].update(x=88, y=75) doc['layouts']['large']['speed_limit_actions'].update(x=88, y=447) - assert validate_document(doc)['layouts']['large']['speed_limit_actions']['y'] == 248 + assert validate_document(doc)['layouts']['large']['speed_limit_actions']['y'] == 296 def test_pending_choices_are_plain_accept_and_decline_with_padded_targets(): state = road() state = replace(state, slc_prompt_open=True, speed_limit=replace(state.speed_limit, pending_speed_limit_mps=25)) accept, decline = slc_controls(Profile.LARGE, state) - assert accept.bounds == (100, 248, 372, 408) - assert decline.bounds == (404, 248, 676, 408) + assert accept.bounds == (100, 296, 372, 444) + assert decline.bounds == (404, 296, 676, 444) assert (accept.label, decline.label) == ('Accept', 'Decline') @@ -335,8 +408,8 @@ def test_projection_v1_layout_migrates_without_losing_other_widgets(): doc['widgets']['speed_limit_actions'].update(x=88, y=447) doc['widgets']['current_speed'].update(x=1400, y=100) migrated = validate_layout_for_viewport(doc, viewport) - assert migrated['version'] == 4 - assert migrated['widgets']['speed_limit_actions']['y'] == 248 + assert migrated['version'] == 5 + assert migrated['widgets']['speed_limit_actions']['y'] == 296 assert migrated['widgets']['current_speed'] == doc['widgets']['current_speed'] assert validate_layout_for_viewport(migrated, viewport) == migrated @@ -349,5 +422,5 @@ def test_committed_projection_v1_right_edge_is_preserved_and_clamped(): doc['widgets']['speed_limit_actions'].update(x=1654, y=500) migrated = validate_layout_for_viewport(doc, viewport) assert migrated['widgets']['cruise_limits']['x'] == 1342 - assert migrated['widgets']['speed_limit_actions'] == {'x': 1254, 'y': 248, 'enabled': True} + assert migrated['widgets']['speed_limit_actions'] == {'x': 1254, 'y': 296, 'enabled': True} diff --git a/openpilot/starpilot/ui/tests/test_onroad_axes.py b/openpilot/starpilot/ui/tests/test_onroad_axes.py index e5dc11efbe..bb15ad49cc 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_axes.py +++ b/openpilot/starpilot/ui/tests/test_onroad_axes.py @@ -337,6 +337,7 @@ class TestOnroadAxes(unittest.TestCase): fonts.measure.return_value = NS(width=20, height=10) fonts.vertical_ink.return_value = (0, 10) compact = CompactHudRenderer(fonts, Path("/unused")) + fonts.horizontal_ink.return_value = (0, 20) with patch.object(compact, "prepare"), patch("openpilot.starpilot.ui.onroad_compact_widgets.rl.draw_texture_pro"), \ patch("openpilot.starpilot.ui.onroad_compact_widgets.rl.draw_circle_gradient"): compact.render(active) @@ -395,5 +396,7 @@ if __name__ == "__main__": @pytest.fixture(autouse=True) def mock_sign_graphics(monkeypatch): - for name in ('draw_rectangle_rounded_lines_ex', 'draw_circle', 'draw_ring'): + for name in ('draw_rectangle_rounded', 'draw_rectangle_rounded_lines_ex', 'draw_circle', 'draw_ring'): monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_widget.rl.' + name, Mock()) + for name in ('circle', 'arrow'): + monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.' + name, Mock()) diff --git a/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py b/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py index 98566327f9..c5d37d3393 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py +++ b/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py @@ -301,11 +301,11 @@ def test_gauge_box_shrinks_to_its_content_and_saved_positions_keep_the_drawing_i # Android Auto version 2 layouts move the same way. viewport = (2880, 1080) aa = default_layout_for_viewport(viewport) - assert aa['version'] == 4 + assert aa['version'] == 5 aa['version'] = 2 aa['widgets'][CEM_GAUGE_WIDGET].update(x=900, y=200) moved = validate_layout_for_viewport(aa, viewport) - assert moved['version'] == 4 and (moved['widgets'][CEM_GAUGE_WIDGET]['x'], moved['widgets'][CEM_GAUGE_WIDGET]['y']) == (944, 224) + assert moved['version'] == 5 and (moved['widgets'][CEM_GAUGE_WIDGET]['x'], moved['widgets'][CEM_GAUGE_WIDGET]['y']) == (944, 224) assert validate_layout_for_viewport(moved, viewport) == moved diff --git a/openpilot/starpilot/ui/tests/test_onroad_customization.py b/openpilot/starpilot/ui/tests/test_onroad_customization.py index fff21407b6..abd91649e0 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_customization.py +++ b/openpilot/starpilot/ui/tests/test_onroad_customization.py @@ -25,6 +25,8 @@ from openpilot.starpilot.ui.tests.test_unified_speed_native import foreground_ca @pytest.fixture(autouse=True) def drawing_guard(monkeypatch): monkeypatch.setattr(rl, 'draw_line_ex', Mock()) + monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.arrow', Mock()) + monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.circle', Mock()) def road(document=None, **changes): @@ -65,9 +67,9 @@ def test_v7_speed_layout_migration_is_bounded_non_mutating_and_idempotent(): original = copy.deepcopy(document) migrated = validate_document(document) assert document == original - assert migrated['version'] == 9 and validate_document(migrated) == migrated + assert migrated['version'] == 10 and validate_document(migrated) == migrated assert migrated['layouts']['large']['cruise_limits']['x'] == 1510 - assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1510, 'y': 890, 'enabled': True} + assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1510, 'y': 900, 'enabled': True} assert migrated['layouts']['large']['current_speed'] == original['layouts']['large']['current_speed'] assert migrated['layouts']['large']['cem_gauge'] == {'x': 56, 'y': 452, 'enabled': True} assert migrated['layouts']['compact']['speed_limit']['x'] == 330 @@ -95,7 +97,7 @@ def test_v8_restores_only_compact_speed_defaults_and_preserves_customizations(cu original = copy.deepcopy(document) migrated = validate_document(document) assert document == original - assert migrated['version'] == 9 and validate_document(migrated) == migrated + assert migrated['version'] == 10 and validate_document(migrated) == migrated assert migrated['layouts']['large'] == document['layouts']['large'] assert migrated['widgetColors'] == document['widgetColors'] assert migrated['widgetOrder'] == document['widgetOrder'] @@ -136,7 +138,7 @@ def test_v8_actionless_layout_preserves_customizations_when_adding_compact_actio original = copy.deepcopy(document) migrated = validate_document(document) assert document == original - assert migrated['version'] == 9 and validate_document(migrated) == migrated + assert migrated['version'] == 10 and validate_document(migrated) == migrated assert migrated['layouts']['large'] == {**original['layouts']['large'], 'speed_limit_actions': large_actions} assert migrated['layouts']['compact']['speed_limit'] == original['layouts']['compact']['speed_limit'] assert migrated['layouts']['compact']['speed_limit_actions'] == {'x': 128, 'y': 186, 'enabled': True} @@ -255,7 +257,7 @@ def test_v3_layout_migrates_wheel_size_without_changing_saved_placement_or_color for profile in ("large", "compact"): del document["layouts"][profile]["steering_wheel"]["size"] migrated = validate_document(document) - assert migrated["version"] == 9 + assert migrated["version"] == 10 assert migrated["palette"] == document["palette"] assert migrated["layouts"]["large"]["steering_wheel"] == {"x": 1300, "y": 100, "enabled": False, "size": 192} assert migrated["layouts"]["compact"]["steering_wheel"] == {"x": 90, "y": 130, "enabled": True, "size": 50} @@ -286,7 +288,7 @@ def test_pre_action_saved_layout_migrates_without_losing_positions_or_palette(re assert migrated["layouts"][profile][key] == { **position, **({"size": customization_metadata()["profiles"][profile]["widgets"][key]["width"]} if key == "steering_wheel" else {})} - assert migrated["layouts"]["large"]["speed_limit_actions"] == {"x": 300, "y": 348, "enabled": False} + assert migrated["layouts"]["large"]["speed_limit_actions"] == {"x": 300, "y": 396, "enabled": False} assert migrated["layouts"]["compact"]["speed_limit_actions"] == default_document()["layouts"]["compact"]["speed_limit_actions"] @@ -304,11 +306,40 @@ def test_malformed_pre_action_cruise_placement_is_rejected(): def test_duplicate_key_cannot_apply_otherwise_valid_placement(): document = default_document() document["layouts"]["large"]["current_speed"]["x"] = 100 - raw = json.dumps(document).replace('"version": 9', '"version": 9, "version": 9').encode() + raw = json.dumps(document).replace('"version": 10', '"version": 10, "version": 10').encode() with patch("openpilot.starpilot.ui.onroad_customization.read_saved", return_value=(raw, True)): assert read_customization(Mock()) == default_document() +@pytest.mark.parametrize('sources', [False, True]) +def test_v9_large_sign_migration_preserves_customizations_and_clamps_bottom_edge(sources): + from copy import deepcopy + from openpilot.starpilot.ui.large_speed_geometry import CARD_HEIGHT, SOURCE_ROW_HEIGHT + old = default_document() + old['version'] = 9 + old['speedSources'] = sources + old['layouts']['large']['cruise_limits'].update(y=918) + old['layouts']['large']['speed_limit_actions'].update(x=250, y=400) + before = deepcopy(old) + updated = validate_document(old) + assert old == before + assert updated['version'] == 10 + assert updated['layouts']['large']['cruise_limits']['y'] == 1050-CARD_HEIGHT-(SOURCE_ROW_HEIGHT if sources else 0) + assert updated['layouts']['large']['speed_limit_actions'] == before['layouts']['large']['speed_limit_actions'] + assert updated['layouts']['compact'] == before['layouts']['compact'] + assert validate_document(updated) == updated + + +def test_v9_moves_only_actions_paired_with_the_old_large_speed_group(): + old = default_document() + old['version'] = 9 + old['layouts']['large']['cruise_limits'].update(x=188, y=95) + old['layouts']['large']['speed_limit_actions'].update(x=200, y=268) + updated = validate_document(old) + assert updated['layouts']['large']['speed_limit_actions'] == {'x': 200, 'y': 316, 'enabled': True} + assert validate_document(updated) == updated + + def test_wheel_hit_edges_match_default_draw_bounds(): emit = Mock() inputs = OnroadInput(emit) @@ -435,7 +466,7 @@ def test_speed_limit_actions_move_independently_of_cruise_card_and_keep_touch_pr presentation_id=2, action_enabled=True) state = road(document, speed_limit=observation, longitudinal_active=True, slc_system_long_available=True) controls = slc_controls(Profile.LARGE, state) - assert controls[0].bounds == (388, 600, 660, 760) + assert controls[0].bounds == (388, 600, 660, 748) assert slc_controls(Profile.COMPACT, state)[0].bounds == (128, 182, 296, 240) emit = Mock() inputs = OnroadInput(emit) @@ -605,7 +636,7 @@ def test_v1_palette_positions_and_visibility_migrate_without_mutating_source(): original = copy.deepcopy(old) result = decode_document(json.dumps(old).encode()) assert old == original - assert result['version'] == 9 + assert result['version'] == 10 assert result['palette'] == old['palette'] for profile, layout in old['layouts'].items(): for key, position in layout.items(): diff --git a/openpilot/starpilot/ui/tests/test_onroad_parity.py b/openpilot/starpilot/ui/tests/test_onroad_parity.py index a2b6635f96..ed71d69d23 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_parity.py +++ b/openpilot/starpilot/ui/tests/test_onroad_parity.py @@ -194,7 +194,7 @@ def test_valid_vision_limit_has_persistent_sign_without_action_receipt(): outline.assert_not_called() renderer._speed_limit_sign(replace(road(speed_limit=valid), appearance=replace(shown.appearance, show_speed_limit_sign=False))) outline.assert_not_called() - with patch.object(rl, 'draw_circle') as circle, patch.object(rl, 'draw_ring'): + with patch.object(renderer._speed_limit_widget.shapes, 'circle') as circle: renderer._speed_limit_sign(replace(shown, appearance=replace(shown.appearance, use_vienna_sign=True))) circle.assert_called_once() diff --git a/openpilot/starpilot/ui/tests/test_speed_limit_pulse.py b/openpilot/starpilot/ui/tests/test_speed_limit_pulse.py index 406800a48e..286071c0a4 100644 --- a/openpilot/starpilot/ui/tests/test_speed_limit_pulse.py +++ b/openpilot/starpilot/ui/tests/test_speed_limit_pulse.py @@ -1,7 +1,8 @@ -"""The sign flashes only during the controller's three-second automatic window.""" +"""Controller-timed automatic pulses and the stationary manual confirmation rhythm.""" from dataclasses import replace import pyray as rl +import pytest from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState, SpeedLimitObservation from openpilot.starpilot.ui.speed_limit_pulse import SpeedLimitPulse @@ -27,13 +28,64 @@ def test_three_slow_flashes_stop_at_the_controller_deadline(): assert rgba(pulse.color(rl.WHITE, now)) == (188, 132, 255, 255) -def test_a_manual_proposal_never_flashes_and_leaving_automatic_stops_the_flash(): +def test_manual_confirmation_repeats_two_slow_pulses_rest_and_two_fast_pulses(): pulse = SpeedLimitPulse() pulse.update(state(False), 10.) - assert rgba(pulse.color(rl.WHITE, 10.5)) == rgba(rl.WHITE) - pulse.update(state(), 11.) - pulse.update(state(False), 11.1) - assert rgba(pulse.color(rl.WHITE, 11.5)) == rgba(rl.WHITE) + base = rl.Color(244, 247, 245, 96) + for cycle in (0., 4.4): + for elapsed in (.35, 1.25, 2.75, 3.25): + now = 10. + cycle + elapsed + pulse.update(state(False), now) + assert rgba(pulse.color(base, now)) == (170, 115, 235, 96) + for elapsed in (0., .8, 1.6, 2.1, 2.59, 3., 3.4, 3.9, 4.39): + assert rgba(pulse.color(base, 10. + cycle + elapsed)) == rgba(base) + assert rgba(pulse.color(base, 9.9)) == rgba(base) + + +@pytest.mark.parametrize('kind', list(ObservationKind)) +def test_finished_or_unavailable_manual_decision_stops_pulsing(kind): + pulse = SpeedLimitPulse() + current = state(False) + pulse.update(current, 10.) + observation = replace(current.speed_limit, kind=kind, speed_limit_mps=20. if kind == ObservationKind.VALID else None, + pending_speed_limit_mps=None, last_received_speed_limit_mps=20.) + pulse.update(replace(current, speed_limit=observation), 10.1) + assert rgba(pulse.color(rl.WHITE, 10.35)) == rgba(rl.WHITE) + assert pulse.start_time == -float('inf') + + +def test_manual_widget_reappearance_and_new_decision_use_controller_time(): + pulse = SpeedLimitPulse() + pulse.update(state(False), 10., visible=False) + pulse.update(state(False), 12.6) + assert rgba(pulse.color(rl.WHITE, 12.75)) == (170, 115, 235, 255) + current = state(False) + current = replace(current, speed_limit=replace(current.speed_limit, pending_since_ns=13_000_000_000, + speed_limit_mps=25., pending_speed_limit_mps=25.)) + pulse.update(current, 13.) + assert rgba(pulse.color(rl.WHITE, 13.)) == rgba(rl.WHITE) + assert rgba(pulse.color(rl.WHITE, 13.35)) == (170, 115, 235, 255) + + +def test_missing_pending_timestamp_starts_once_and_resets_for_a_new_drive(): + pulse = SpeedLimitPulse() + current = state(False) + current = replace(current, speed_limit=replace(current.speed_limit, pending_since_ns=0)) + pulse.update(current, 10.) + pulse.update(current, 10.35) + assert rgba(pulse.color(rl.WHITE, 10.35)) == (170, 115, 235, 255) + pulse.update(replace(current, drive_frame=2), 20.) + assert pulse.start_time == 20. + + +def test_switching_between_manual_and_automatic_uses_the_current_rhythm(): + pulse = SpeedLimitPulse() + pulse.update(state(), 10.) + pulse.update(state(False), 10.) + assert rgba(pulse.color(rl.WHITE, 10.35)) == (170, 115, 235, 255) + pulse.update(state(), 10.) + assert rgba(pulse.color(rl.WHITE, 10.5)) == (188, 132, 255, 255) + assert rgba(pulse.color(rl.WHITE, 13.)) == rgba(rl.WHITE) def test_reappearing_widget_uses_remaining_controller_time(): diff --git a/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py b/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py new file mode 100644 index 0000000000..c524d5cac5 --- /dev/null +++ b/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py @@ -0,0 +1,85 @@ +"""The atlas has smooth coverage, solid arrow joins, and bounded GPU ownership.""" + +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import numpy as np +from PIL import Image +import pytest + +from openpilot.starpilot.ui import speed_limit_shapes as shapes +from openpilot.starpilot.ui.assets.generate_speed_limit_shapes import generate + + +def test_atlas_matches_generated_geometry_and_has_symmetric_antialiased_edges(): + with Image.open(shapes.ATLAS_PATH) as atlas: + assert atlas.size == shapes.ATLAS_SIZE + assert atlas.tobytes() == generate().tobytes() + for name, (x, y, width, height) in shapes.SPRITES.items(): + alpha = np.asarray(atlas.crop((x, y, x+width, y+height)))[..., 3] + assert np.array_equal(alpha, alpha[:, ::-1]) + assert ((alpha > 0) & (alpha < 255)).sum() > 10 + assert not alpha[0].any() and not alpha[-1].any() + assert not alpha[:, 0].any() and not alpha[:, -1].any() + if name.startswith('arrow'): + for row in alpha: + ink = np.flatnonzero(row > 40) + assert not len(ink) or ink[-1] - ink[0] + 1 == len(ink) + center = alpha[:, width//2] + painted = np.flatnonzero(center > 40) + assert painted[-1] - painted[0] + 1 == len(painted) + + +@pytest.fixture +def gpu(monkeypatch): + monkeypatch.setattr(shapes.rl, 'is_window_ready', lambda: True) + calls = {} + for name, value in (('load_image', NS(data=1, width=256, height=176)), + ('load_texture_from_image', NS(id=7)), ('unload_image', None), ('unload_texture', None), + ('set_texture_filter', None), ('set_texture_wrap', None), ('draw_texture_pro', None)): + calls[name] = Mock(return_value=value) + monkeypatch.setattr(shapes.rl, name, calls[name]) + return calls + + +def test_shapes_share_one_upload_and_arrow_is_one_draw_in_either_direction(gpu): + owner = shapes.SpeedLimitShapes() + owner.circle(389, 87, shapes.rl.RED) + assert gpu['draw_texture_pro'].call_count == 2 + for direction, rotation in ((1, 0), (-1, 180)): + gpu['draw_texture_pro'].reset_mock() + owner.arrow('compact', 389, 49, direction, shapes.rl.BLACK) + gpu['draw_texture_pro'].assert_called_once() + assert gpu['draw_texture_pro'].call_args.args[-2] == rotation + gpu['load_image'].assert_called_once() + gpu['load_texture_from_image'].assert_called_once() + gpu['unload_image'].assert_called_once() + owner.close() + owner.close() + gpu['unload_texture'].assert_called_once() + + +def test_bad_atlas_dimensions_release_image_without_uploading(gpu): + gpu['load_image'].return_value.width = 4096 + with pytest.raises(ValueError, match='Invalid speed-limit'): + shapes.SpeedLimitShapes().circle(0, 0, shapes.rl.RED) + gpu['unload_image'].assert_called_once() + gpu['load_texture_from_image'].assert_not_called() + + +def test_large_sign_scale_changes_destination_and_rotation_origin_only(gpu): + owner = shapes.SpeedLimitShapes(1.25) + owner.arrow('compact', 345.75, 111.25, -1, shapes.rl.BLACK) + _, source, destination, origin, rotation, _ = gpu['draw_texture_pro'].call_args.args + assert (source.width, source.height) == (16, 24) + assert (destination.x, destination.y, destination.width, destination.height) == (345.75, 111.25, 20, 30) + assert (origin.x, origin.y, rotation) == (10, 15, 180) + owner.close() + + +def test_texture_setup_failure_releases_both_resources(gpu): + gpu['set_texture_filter'].side_effect = RuntimeError('filter unavailable') + with pytest.raises(RuntimeError, match='filter unavailable'): + shapes.SpeedLimitShapes().circle(0, 0, shapes.rl.RED) + gpu['unload_texture'].assert_called_once() + gpu['unload_image'].assert_called_once() diff --git a/openpilot/starpilot/ui/tests/test_speed_stack.py b/openpilot/starpilot/ui/tests/test_speed_stack.py index 94ff2ca0ff..69b8e374f4 100644 --- a/openpilot/starpilot/ui/tests/test_speed_stack.py +++ b/openpilot/starpilot/ui/tests/test_speed_stack.py @@ -52,9 +52,9 @@ def test_v4_migration_preserves_settings_and_clamps_only_enlarged_widgets(): doc['layouts']['large']['speed_limit_actions'].update(x=1654, y=500) old_speed = dict(doc['layouts']['large']['current_speed']) migrated = validate_document(doc) - assert migrated['version'] == 9 + assert migrated['version'] == 10 assert migrated['layouts']['large']['cruise_limits']['x'] == 1642 - assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1554, 'y': 248, 'enabled': True} + assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1554, 'y': 296, 'enabled': True} assert migrated['layouts']['large']['current_speed'] == old_speed assert migrated['speedSources'] is True assert rgba(migrated, 'cardFill', 'large', 'cruise_limits') == (18, 52, 86, 120) diff --git a/openpilot/starpilot/ui/tests/test_unified_speed_native.py b/openpilot/starpilot/ui/tests/test_unified_speed_native.py index 967401b723..1662a736c7 100644 --- a/openpilot/starpilot/ui/tests/test_unified_speed_native.py +++ b/openpilot/starpilot/ui/tests/test_unified_speed_native.py @@ -13,6 +13,7 @@ from openpilot.starpilot.ui.onroad import OnroadView from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState, SpeedLimitObservation, slc_controls from openpilot.starpilot.ui.presentation import Profile from openpilot.starpilot.ui.speed_limit_widget import INK, MUTED, SpeedLimitWidget, draw_text +from openpilot.starpilot.ui.speed_limit_shapes import SpeedLimitShapes from openpilot.starpilot.ui.unified_speed_presentation import displayed_limit_mps, resolve_unified_speed @@ -22,6 +23,9 @@ def drawing(monkeypatch): for name in ('draw_rectangle_rounded_lines_ex', 'draw_rectangle_rounded', 'draw_line_ex', 'draw_circle', 'draw_ring', 'draw_circle_gradient'): calls[name] = Mock() monkeypatch.setattr(rl, name, calls[name]) + for name in ('circle', 'arrow'): + calls[name] = Mock() + monkeypatch.setattr(SpeedLimitShapes, name, calls[name]) monkeypatch.setattr('openpilot.starpilot.ui.onroad_large_widgets.draw_control_card', Mock()) return calls @@ -46,6 +50,10 @@ def foreground_calls(draw): return [call for call in draw.call_args_list if (call.args[-1].r, call.args[-1].g, call.args[-1].b) not in ((6, 14, 10), (7, 16, 12))] +def rgba_tuple(color): + return (color.r, color.g, color.b, color.a) + + def test_latest_limit_and_offset_replace_accepted_sign_while_waiting(): shown = resolve_unified_speed(state()) assert (shown.max_text, shown.posted_text, shown.offset_text, shown.source) == ('100', '50', '+5', 'map') @@ -84,21 +92,45 @@ def test_compact_sign_omits_missing_zero_and_rounded_zero_offsets(metric, vienna @pytest.mark.parametrize('profile', list(Profile)) -def test_pending_sign_hops_and_arrow_follows_vehicle_speed(profile, drawing): +@pytest.mark.parametrize('vienna', [False, True]) +def test_pending_sign_pulses_in_place_and_arrow_follows_vehicle_speed(profile, vienna, drawing): font = fonts(profile) sign = SpeedLimitWidget(font, profile) current = state() + current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna)) sign.render(current) before = {call.args[0]: call.args for call in foreground_calls(font.draw)} - arrow = foreground_calls(drawing['draw_line_ex'])[0].args - assert arrow[1].y < arrow[0].y - sign.render(replace(current, observed_ns=10_750_000_000, speed_mps=60 / 3.6)) + assert drawing['arrow'].call_args.args[3] == -1 + sign.render(replace(current, observed_ns=10_350_000_000, speed_mps=60 / 3.6)) after = {call.args[0]: call.args for call in foreground_calls(font.draw)} - assert after['LIMIT'][4] == before['LIMIT'][4] - assert after['50'][4] == before['50'][4] - 6 - assert after['+5'][4] == before['+5'][4] - 6 - arrow = foreground_calls(drawing['draw_line_ex'])[-3].args - assert arrow[1].y > arrow[0].y + assert all(after[text][3:5] == args[3:5] for text, args in before.items()) + if vienna: + assert rgba_tuple(after['50'][-1]) == (0, 0, 0, 255) + assert drawing['circle'].call_args.args[:2] == ((389, 87) if profile == Profile.COMPACT else (345.75, 158.75)) + assert rgba_tuple(drawing['circle'].call_args.args[-1]) == (170, 115, 235, 255) + else: + assert rgba_tuple(after['50'][-1]) == (170, 115, 235, 255) + assert rgba_tuple(before['LIMIT'][-1]) == (244, 247, 245, 255) + assert rgba_tuple(after['LIMIT'][-1]) == (170, 115, 235, 255) + assert drawing['arrow'].call_args.args[3] == 1 + + +@pytest.mark.parametrize('reason', ['steady', 'automatic', 'equal_speed', 'unknown_speed']) +def test_compact_us_heading_stays_centered_without_an_arrow(reason, drawing): + current = state() + if reason == 'steady': + current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None)) + elif reason == 'automatic': + current = replace(current, speed_limit=replace(current.speed_limit, automatic_adoption_pending=True)) + else: + current = replace(current, speed_mps=55 / 3.6 if reason == 'equal_speed' else None) + font = fonts(Profile.COMPACT) + SpeedLimitWidget(font, Profile.COMPACT).render(current) + drawing['arrow'].assert_not_called() + for call in font.draw.call_args_list: + text, role, size, x, _, _ = call.args + left, right = font.horizontal_ink(text, role, size) + assert x + (left + right) / 2 == 389 @pytest.mark.parametrize('profile', list(Profile)) @@ -145,7 +177,7 @@ def test_large_limit_keeps_a_unit_when_set_speed_is_hidden(): current.customization['showMaxSpeed'] = False current.customization['layouts']['compact']['max_speed']['enabled'] = False font = fonts(profile) - SpeedLimitWidget(font, profile).render(current) + UnifiedSpeedWidget(font).render(rl.Rectangle(30, 30, 1800, 1020), current) assert [call.args[0] for call in foreground_calls(font.draw)].count('km/h') == 1 @@ -191,7 +223,7 @@ def test_full_coverage_contour_protects_base_muted_and_peak_purple_on_white(): def luminance(channels): linear = [((x/255+.055)/1.055)**2.4 if x/255 > .04045 else x/255/12.92 for x in channels] return sum(x*weight for x, weight in zip(linear, (.2126, .7152, .0722), strict=True)) - for color in (INK, MUTED, rl.Color(188, 132, 255, 255)): + for color in (INK, MUTED, rl.Color(188, 132, 255, 255), rl.Color(170, 115, 235, 255)): font = fonts() draw_text(font, '65', FontRole.BOLD, 70, 100, 50, color) contour = font.draw.call_args_list[0].args[-1]