mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-29 01:23:58 +08:00
Torque Bar Optimization + Gradient
This commit is contained in:
@@ -143,6 +143,7 @@ class OnroadView:
|
||||
from openpilot.starpilot.ui.onroad_now_playing import NowPlayingWidget
|
||||
self.now_playing = NowPlayingWidget(fonts)
|
||||
self.torque_bar = TorqueBarWidget()
|
||||
gui_app.add_render_prepare(self.torque_bar.prepare, self.torque_bar.close)
|
||||
if fonts.profile == Profile.LARGE:
|
||||
self.unified_speed = UnifiedSpeedWidget(fonts)
|
||||
self.current_speed = CurrentSpeedHud(fonts)
|
||||
@@ -584,6 +585,8 @@ class OnroadView:
|
||||
def close(self) -> None:
|
||||
gui_app.remove_render_prepare(self._corner_cache.prepare)
|
||||
self._corner_cache.close()
|
||||
gui_app.remove_render_prepare(self.torque_bar.prepare)
|
||||
self.torque_bar.close()
|
||||
self.navigation.close()
|
||||
if self.fonts.profile == Profile.LARGE:
|
||||
self.unified_speed.close()
|
||||
|
||||
@@ -1,162 +1,23 @@
|
||||
"""Adapted StarPilot torque-bar polygon with supplied utilization.
|
||||
"""Shared StarPilot torque bar with caller-qualified utilization."""
|
||||
|
||||
The arc polygon generator is retained from the frozen renderer under LICENSE.
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from functools import lru_cache, wraps
|
||||
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.system.ui.lib.shader_polygon import draw_polygon
|
||||
from openpilot.starpilot.ui.onroad_state import OnroadState
|
||||
from openpilot.starpilot.ui.onroad_customization import offset as widget_offset, placement
|
||||
from openpilot.starpilot.ui.onroad_torque_geometry import (
|
||||
CAP_RADIUS, LARGE_CAP_RADIUS, MAX_HEIGHT, MAX_OFFSET, MIN_HEIGHT, MIN_OFFSET, RADIUS, TORQUE_ANGLE_SPAN,
|
||||
)
|
||||
from openpilot.starpilot.ui.onroad_torque_draw import TorqueBarPainter
|
||||
from openpilot.starpilot.ui.onroad_torque_geometry import CAP_RADIUS, LARGE_CAP_RADIUS
|
||||
|
||||
DEBUG = False
|
||||
|
||||
def quantized_lru_cache(maxsize=128):
|
||||
def decorator(func):
|
||||
cache = OrderedDict()
|
||||
@wraps(func)
|
||||
def wrapper(r_mid, thickness, a0_deg, a1_deg, **kwargs):
|
||||
# Quantize inputs: balanced for smoothness vs cache effectiveness
|
||||
key = (round(r_mid),
|
||||
round(thickness), # 1px precision for smoother height transitions
|
||||
round(a0_deg * 10) / 10, # 0.1° precision for smoother angle transitions
|
||||
round(a1_deg * 10) / 10,
|
||||
tuple(sorted(kwargs.items())))
|
||||
|
||||
if key in cache:
|
||||
cache.move_to_end(key)
|
||||
else:
|
||||
if len(cache) >= maxsize:
|
||||
cache.popitem(last=False)
|
||||
|
||||
result = func(r_mid, thickness, a0_deg, a1_deg, **kwargs)
|
||||
cache[key] = result
|
||||
return cache[key]
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _cap_vectors(left: bool, segments: int):
|
||||
# These quarter-circle samples depend only on tessellation, not live torque,
|
||||
# radius or widget placement. Reuse them when the arc geometry cache misses.
|
||||
start, end = (180, 90) if left else (90, 0)
|
||||
alpha = np.deg2rad(np.linspace(start, end, segments + 2))[1:-1]
|
||||
start, end = (-90, -180) if left else (0, -90)
|
||||
alpha2 = np.deg2rad(np.linspace(start, end, segments + 1))[:-1]
|
||||
vectors = (np.cos(alpha), np.sin(alpha), np.cos(alpha2), np.sin(alpha2))
|
||||
for vector in vectors:
|
||||
vector.flags.writeable = False
|
||||
return vectors
|
||||
|
||||
|
||||
@quantized_lru_cache(maxsize=256)
|
||||
def arc_bar_pts(r_mid: float, thickness: float,
|
||||
a0_deg: float, a1_deg: float,
|
||||
*, max_points: int = 100, cap_segs: int = 10,
|
||||
cap_radius: float = CAP_RADIUS, px_per_seg: float = 2.0) -> np.ndarray:
|
||||
"""Return Nx2 np.float32 points for a single closed polygon (rounded thick arc), centered at origin."""
|
||||
|
||||
def get_cap(left: bool, a_deg: float):
|
||||
# end cap at a1: center (a1), sweep a1→a1+180 (skip endpoints to avoid dupes)
|
||||
# quarter arc (outer corner) at a1 with fixed pixel radius cap_radius
|
||||
|
||||
nx, ny = math.cos(math.radians(a_deg)), math.sin(math.radians(a_deg)) # outward normal
|
||||
tx, ty = -ny, nx # tangent (CCW)
|
||||
|
||||
mx, my = nx * r_mid, ny * r_mid # mid-point at a1
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(mx), int(my), 4, rl.PURPLE)
|
||||
|
||||
ex = mx + nx * (half - cap_radius)
|
||||
ey = my + ny * (half - cap_radius)
|
||||
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(ex), int(ey), 2, rl.WHITE)
|
||||
|
||||
# sweep 90° in the local (t,n) frame: from outer edge toward inside
|
||||
cos_a, sin_a, cos_b, sin_b = _cap_vectors(left, cap_segs)
|
||||
cap_end = np.column_stack((ex + cos_a * cap_radius * tx + sin_a * cap_radius * nx,
|
||||
ey + cos_a * cap_radius * ty + sin_a * cap_radius * ny))
|
||||
|
||||
# bottom quarter (inner corner) at a1
|
||||
ex2 = mx + nx * (-half + cap_radius)
|
||||
ey2 = my + ny * (-half + cap_radius)
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(ex2), int(ey2), 2, rl.WHITE)
|
||||
|
||||
cap_end_bot = np.column_stack((ex2 + cos_b * cap_radius * tx + sin_b * cap_radius * nx,
|
||||
ey2 + cos_b * cap_radius * ty + sin_b * cap_radius * ny))
|
||||
|
||||
# append to the top quarter
|
||||
if not left:
|
||||
cap_end = np.vstack((cap_end, cap_end_bot))
|
||||
else:
|
||||
cap_end = np.vstack((cap_end_bot, cap_end))
|
||||
|
||||
return cap_end
|
||||
|
||||
if a1_deg < a0_deg:
|
||||
a0_deg, a1_deg = a1_deg, a0_deg
|
||||
half = thickness * 0.5
|
||||
|
||||
cap_radius = min(cap_radius, half)
|
||||
|
||||
span = max(1e-3, a1_deg - a0_deg)
|
||||
|
||||
# pick arc segment count from arc length, clamp to shader points[] budget
|
||||
arc_len = r_mid * math.radians(span)
|
||||
arc_segs = max(6, int(arc_len / px_per_seg))
|
||||
max_arc = (max_points - (4 * cap_segs + 3)) // 2
|
||||
arc_segs = max(6, min(arc_segs, max_arc))
|
||||
|
||||
# outer arc a0→a1
|
||||
ang_o = np.deg2rad(np.linspace(a0_deg, a1_deg, arc_segs + 1))
|
||||
outer = np.column_stack((np.cos(ang_o) * (r_mid + half),
|
||||
np.sin(ang_o) * (r_mid + half)))
|
||||
|
||||
# end cap at a1
|
||||
cap_end = get_cap(False, a1_deg)
|
||||
|
||||
# inner arc a1→a0
|
||||
ang_i = np.deg2rad(np.linspace(a1_deg, a0_deg, arc_segs + 1))
|
||||
inner = np.column_stack((np.cos(ang_i) * (r_mid - half),
|
||||
np.sin(ang_i) * (r_mid - half)))
|
||||
|
||||
# start cap at a0
|
||||
cap_start = get_cap(True, a0_deg)
|
||||
|
||||
pts = np.vstack((outer, cap_end, inner, cap_start, outer[:1])).astype(np.float32)
|
||||
|
||||
# Rotate to start from middle of cap for proper triangulation
|
||||
pts = np.roll(pts, cap_segs, axis=0)
|
||||
|
||||
if DEBUG:
|
||||
n = len(pts)
|
||||
idx = int(time.monotonic() * 12) % max(1, n) # speed: 12 pts/sec
|
||||
for i, (x, y) in enumerate(pts):
|
||||
j = (i - idx) % n # rotate the gradient
|
||||
t = j / n
|
||||
color = rl.Color(255, int(255 * (1 - t)), int(255 * t), 255)
|
||||
rl.draw_circle(int(x), int(y), 2, color)
|
||||
|
||||
return pts
|
||||
|
||||
class TorqueBarWidget:
|
||||
def __init__(self):
|
||||
self._torque_filter = FirstOrderFilter(0.0, 0.1, 1 / 60)
|
||||
self._alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / 60)
|
||||
self._drive_frame = None
|
||||
self._painter = TorqueBarPainter()
|
||||
|
||||
def prepare(self) -> None:
|
||||
self._painter.prepare()
|
||||
|
||||
def render(self, rect: rl.Rectangle, state: OnroadState, screen_width: float) -> None:
|
||||
drive_frame = getattr(state, 'torque_drive_frame', None)
|
||||
@@ -172,28 +33,10 @@ class TorqueBarWidget:
|
||||
utilization = max(-1.0, min(1.0, state.torque_utilization))
|
||||
torque = self._torque_filter.update(utilization)
|
||||
alpha = self._alpha_filter.update(1.0)
|
||||
scale = rect.height / 240.0 * (rect.width / screen_width)
|
||||
torque_offset = float(np.interp(abs(torque), [0.5, 1.0], [MIN_OFFSET * scale, MAX_OFFSET * scale]))
|
||||
torque_height = float(np.interp(abs(torque), [0.5, 1.0], [MIN_HEIGHT * scale, MAX_HEIGHT * scale]))
|
||||
radius = RADIUS * scale
|
||||
span = alpha * TORQUE_ANGLE_SPAN
|
||||
middle_radius = radius + torque_height / 2
|
||||
center_x = rect.x + rect.width / 2 + 8
|
||||
center_y = rect.y + rect.height + radius - torque_offset
|
||||
offset = np.array([center_x, center_y], dtype=np.float32)
|
||||
dx, dy = widget_offset(state.customization, profile, 'torque_bar')
|
||||
translation = np.array([dx, dy], dtype=np.float32)
|
||||
draw_rect = rl.Rectangle(rect.x + dx, rect.y + dy, rect.width, rect.height)
|
||||
cap_radius = LARGE_CAP_RADIUS if profile == 'large' else CAP_RADIUS
|
||||
points = arc_bar_pts(middle_radius, torque_height, -90 - span / 2, -90 + span / 2, cap_radius=cap_radius) + offset
|
||||
if not enabled:
|
||||
return
|
||||
bg_alpha = float(np.interp(abs(torque), [0.5, 1.0], [0.25, 0.5]))
|
||||
draw_polygon(draw_rect, points + translation, color=rl.Color(255, 255, 255, int(255 * bg_alpha * alpha)))
|
||||
indicator_end = -90 + span / 2 * torque
|
||||
indicator = arc_bar_pts(middle_radius, torque_height, -90, indicator_end, cap_radius=cap_radius) + offset
|
||||
indicator_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
|
||||
draw_polygon(draw_rect, indicator + translation, color=indicator_color)
|
||||
if abs(torque) < 0.5:
|
||||
dot_y = rect.y + rect.height - torque_offset - torque_height / 2
|
||||
rl.draw_circle(int(center_x + dx), int(dot_y + dy), 5 * scale, rl.Color(182, 182, 182, int(255 * 0.9 * alpha)))
|
||||
cap_radius = LARGE_CAP_RADIUS if profile == 'large' else CAP_RADIUS
|
||||
self._painter.render(rect, torque, alpha, screen_width, cap_radius, widget_offset(state.customization, profile, 'torque_bar'))
|
||||
|
||||
def close(self) -> None:
|
||||
self._painter.close()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Antialiased torque arc, painted in one bounded pass without a polygon cache."""
|
||||
|
||||
import math
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.ui.lib.application import GL_VERSION
|
||||
from openpilot.starpilot.ui.onroad_torque_geometry import MAX_HEIGHT, MAX_OFFSET, MIN_HEIGHT, MIN_OFFSET, RADIUS, TORQUE_ANGLE_SPAN
|
||||
|
||||
|
||||
VERTEX_SHADER = GL_VERSION + """
|
||||
in vec3 vertexPosition;
|
||||
in vec2 vertexTexCoord;
|
||||
uniform mat4 mvp;
|
||||
out vec2 position;
|
||||
void main() {
|
||||
position = vertexTexCoord;
|
||||
gl_Position = mvp * vec4(vertexPosition, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
FRAGMENT_SHADER = GL_VERSION + """
|
||||
in vec2 position;
|
||||
out vec4 finalColor;
|
||||
uniform vec4 geometry; // center, middle radius, half thickness
|
||||
uniform vec4 backgroundNormals;
|
||||
uniform vec4 indicatorNormals;
|
||||
uniform vec4 style; // cap radius, background alpha, indicator alpha, dot alpha
|
||||
uniform vec4 dotGeometry; // center, radius, reserved
|
||||
uniform vec4 gradient; // signed inverse reach, center green, green change, blue
|
||||
|
||||
float capDistance(vec2 p, vec2 normal) {
|
||||
vec2 tangent = vec2(-normal.y, normal.x);
|
||||
float radial = abs(dot(p, normal) - geometry.z);
|
||||
return length(vec2(dot(p, tangent), max(radial - (geometry.w - style.x), 0.0))) - style.x;
|
||||
}
|
||||
float barDistance(vec2 p, vec4 normals, float ringDistance) {
|
||||
vec2 tangent0 = vec2(-normals.y, normals.x);
|
||||
vec2 tangent1 = vec2(-normals.w, normals.z);
|
||||
float sector = max(ringDistance, max(-dot(p, tangent0), dot(p, tangent1)));
|
||||
return min(sector, min(capDistance(p, normals.xy), capDistance(p, normals.zw)));
|
||||
}
|
||||
float coverage(float distance) {
|
||||
return clamp(0.5 - distance / max(fwidth(distance), 0.0001), 0.0, 1.0);
|
||||
}
|
||||
void main() {
|
||||
vec2 p = position - geometry.xy;
|
||||
float ringDistance = abs(length(p) - geometry.z) - geometry.w;
|
||||
float b = coverage(barDistance(p, backgroundNormals, ringDistance)) * style.y;
|
||||
float i = coverage(barDistance(p, indicatorNormals, ringDistance)) * style.z;
|
||||
float d = coverage(length(position - dotGeometry.xy) - dotGeometry.z) * style.w;
|
||||
float bi = b + i * (1.0 - b);
|
||||
float alpha = d + bi * (1.0 - d);
|
||||
float t = clamp(p.x * gradient.x, 0.0, 1.0);
|
||||
vec3 indicatorColor = vec3(1.0, gradient.y + gradient.z * t, gradient.w);
|
||||
vec3 premultiplied = (vec3(b * (1.0 - i)) + indicatorColor * i) * (1.0 - d) + vec3((182.0 / 255.0) * d);
|
||||
finalColor = vec4(premultiplied / max(alpha, 0.00001), alpha);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class TorqueBarPainter:
|
||||
def __init__(self):
|
||||
self._shader = None
|
||||
self._uniforms = {}
|
||||
self._previous = {}
|
||||
|
||||
def prepare(self) -> None:
|
||||
if self._shader is not None:
|
||||
return
|
||||
shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAGMENT_SHADER)
|
||||
names = ('geometry', 'backgroundNormals', 'indicatorNormals', 'style', 'dotGeometry', 'gradient')
|
||||
locations = {name: rl.get_shader_location(shader, name) for name in names}
|
||||
if not shader.id or any(location < 0 for location in locations.values()):
|
||||
if shader.id and shader.id != rl.rl_get_shader_id_default():
|
||||
rl.unload_shader(shader)
|
||||
raise RuntimeError('Torque bar shader unavailable')
|
||||
self._uniforms = {name: (location, rl.ffi.new('float[4]')) for name, location in locations.items()}
|
||||
self._previous.clear()
|
||||
self._shader = shader
|
||||
|
||||
def render(self, rect: rl.Rectangle, torque: float, alpha: float, screen_width: float, cap_radius: float,
|
||||
offset: tuple[float, float] = (0, 0)) -> None:
|
||||
if alpha <= 0:
|
||||
return
|
||||
self.prepare()
|
||||
scale = rect.height / 240.0 * (rect.width / screen_width)
|
||||
strength = max(0.0, min(1.0, (abs(torque) - 0.5) * 2))
|
||||
torque_offset = MIN_OFFSET * scale + (MAX_OFFSET * scale - MIN_OFFSET * scale) * strength
|
||||
thickness = MIN_HEIGHT * scale + (MAX_HEIGHT * scale - MIN_HEIGHT * scale) * strength
|
||||
radius = RADIUS * scale
|
||||
middle_radius = radius + thickness / 2
|
||||
cap = min(cap_radius, thickness / 2)
|
||||
cx = rect.x + rect.width / 2 + 8 + offset[0]
|
||||
cy = rect.y + rect.height + radius - torque_offset + offset[1]
|
||||
half_span = math.radians(alpha * TORQUE_ANGLE_SPAN / 2)
|
||||
sine, cosine = math.sin(half_span), math.cos(half_span)
|
||||
end_sine, end_cosine = math.sin(half_span * torque), math.cos(half_span * torque)
|
||||
start, end = ((end_sine, -end_cosine), (0, -1)) if torque < 0 else ((0, -1), (end_sine, -end_cosine))
|
||||
dot_y = rect.y + rect.height - torque_offset - thickness / 2 + offset[1]
|
||||
indicator_alpha = int(255 * 0.9 * alpha) / 255
|
||||
warmth = max(0.0, min(1.0, (abs(torque) - 0.75) * 4))
|
||||
extent = (middle_radius + thickness / 2 - cap) * sine + cap
|
||||
reach = max(0.65 * extent, 0.0001)
|
||||
values = {
|
||||
'geometry': (cx, cy, middle_radius, thickness / 2),
|
||||
'backgroundNormals': (-sine, -cosine, sine, -cosine),
|
||||
'indicatorNormals': (*start, *end),
|
||||
'style': (cap, int(255 * (0.25 + 0.25 * strength) * alpha) / 255,
|
||||
indicator_alpha, indicator_alpha if abs(torque) < 0.5 else 0),
|
||||
'dotGeometry': (int(cx), int(dot_y), 5 * scale, 0),
|
||||
'gradient': (math.copysign(1 / reach, torque), 1 - warmth + warmth * (115 / 255),
|
||||
warmth * (85 / 255), 1 - warmth),
|
||||
}
|
||||
# Cover subpixel edge samples without allocating an offscreen surface, including desktop downscaling.
|
||||
extent += 4
|
||||
top = rect.y + rect.height - torque_offset - thickness + offset[1] - 4
|
||||
bottom = cy - (radius + cap) * cosine + cap + 4
|
||||
rl.begin_shader_mode(self._shader)
|
||||
try:
|
||||
for name, value in values.items():
|
||||
if self._previous.get(name) != value:
|
||||
location, pointer = self._uniforms[name]
|
||||
pointer[0:4] = value
|
||||
rl.set_shader_value(self._shader, location, pointer, rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4)
|
||||
self._previous[name] = value
|
||||
# rlgl may transform positions on the CPU; UVs keep distance calculations in logical pixels.
|
||||
left, right = cx - extent, cx + extent
|
||||
rl.rl_set_texture(0)
|
||||
rl.rl_begin(rl.RL_QUADS)
|
||||
for x, y in ((left, top), (left, bottom), (right, bottom), (right, top)):
|
||||
rl.rl_tex_coord2f(x, y)
|
||||
rl.rl_vertex2f(x, y)
|
||||
rl.rl_end()
|
||||
finally:
|
||||
rl.end_shader_mode()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._shader is not None and rl.is_window_ready():
|
||||
rl.unload_shader(self._shader)
|
||||
self._shader = None
|
||||
self._uniforms.clear()
|
||||
self._previous.clear()
|
||||
@@ -21,6 +21,7 @@ from openpilot.starpilot.ui.preview_shell import reference_onroad
|
||||
from openpilot.starpilot.ui.runtime_snapshot import RuntimeSnapshotAdapter
|
||||
from openpilot.starpilot.ui.shell import ShellMode
|
||||
from openpilot.starpilot.ui.tests.test_runtime_snapshot import NOW, ui_fake
|
||||
from openpilot.starpilot.ui.tests.test_onroad_torque_draw import record_gpu
|
||||
|
||||
|
||||
def rgba(color: rl.Color) -> tuple[int, int, int, int]:
|
||||
@@ -354,13 +355,12 @@ class TestOnroadAxes(unittest.TestCase):
|
||||
self.assertEqual(compact._set_speed_opacity(stale, compact._last_cruise_ns + 150_000_001), 0)
|
||||
self.assertEqual(compact._set_speed_alpha.x, 0)
|
||||
torque = TorqueBarWidget()
|
||||
with patch("openpilot.starpilot.ui.onroad_torque.draw_polygon") as draw, \
|
||||
patch("openpilot.starpilot.ui.onroad_torque.rl.draw_circle"):
|
||||
with record_gpu() as gpu:
|
||||
torque.render(rl.Rectangle(0, 0, 476, 240), active, 536)
|
||||
self.assertGreater(torque._alpha_filter.x, 0)
|
||||
draw.reset_mock()
|
||||
gpu.frames.clear()
|
||||
torque.render(rl.Rectangle(0, 0, 476, 240), stale, 536)
|
||||
draw.assert_not_called()
|
||||
self.assertFalse(gpu.frames)
|
||||
self.assertEqual(torque._alpha_filter.x, 0)
|
||||
|
||||
def test_missing_actual_torque_output_stays_hidden_with_active_axes(self):
|
||||
@@ -369,9 +369,9 @@ class TestOnroadAxes(unittest.TestCase):
|
||||
self.assertTrue(state.lateral_active)
|
||||
self.assertFalse(state.torque_source_available)
|
||||
torque = TorqueBarWidget()
|
||||
with patch("openpilot.starpilot.ui.onroad_torque.draw_polygon") as draw:
|
||||
with record_gpu() as gpu:
|
||||
torque.render(rl.Rectangle(0, 0, 476, 240), state, 536)
|
||||
draw.assert_not_called()
|
||||
self.assertFalse(gpu.frames or gpu.loads)
|
||||
self.assertEqual(torque._alpha_filter.x, 0)
|
||||
|
||||
def test_pressed_control_cancels_on_axis_transition_even_with_fresh_transport(self):
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import patch
|
||||
|
||||
import pyray as rl
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.ui.onroad_torque_draw import TorqueBarPainter
|
||||
|
||||
|
||||
@contextmanager
|
||||
def record_gpu():
|
||||
data = NS(uniforms={}, uploads=[], frames=[], loads=[], unloads=[])
|
||||
names, vertices, coordinates = {}, [], []
|
||||
|
||||
def upload(shader, location, values, kind):
|
||||
name = next(name for name, value in names.items() if value == location)
|
||||
data.uniforms[name] = tuple(values[0:4])
|
||||
data.uploads.append((name, int(rl.ffi.cast('uintptr_t', values))))
|
||||
|
||||
def finish():
|
||||
data.frames.append(NS(uniforms=dict(data.uniforms), vertices=tuple(vertices), coordinates=tuple(coordinates)))
|
||||
vertices.clear()
|
||||
coordinates.clear()
|
||||
|
||||
with patch.object(rl, 'load_shader_from_memory', side_effect=lambda *_: data.loads.append(1) or NS(id=len(data.loads))), \
|
||||
patch.object(rl, 'get_shader_location', side_effect=lambda _, name: names.setdefault(name, len(names))), \
|
||||
patch.object(rl, 'set_shader_value', side_effect=upload), \
|
||||
patch.object(rl, 'begin_shader_mode'), patch.object(rl, 'end_shader_mode'), \
|
||||
patch.object(rl, 'rl_set_texture'), patch.object(rl, 'rl_begin'), \
|
||||
patch.object(rl, 'rl_tex_coord2f', side_effect=lambda x, y: coordinates.append((x, y))), \
|
||||
patch.object(rl, 'rl_vertex2f', side_effect=lambda x, y: vertices.append((x, y))), \
|
||||
patch.object(rl, 'rl_end', side_effect=finish), \
|
||||
patch.object(rl, 'unload_shader', side_effect=lambda shader: data.unloads.append(shader.id)), \
|
||||
patch.object(rl, 'is_window_ready', return_value=True), patch.object(rl, 'rl_get_shader_id_default', return_value=0):
|
||||
yield data
|
||||
|
||||
|
||||
def test_small_torque_changes_reach_the_screen_without_angle_steps():
|
||||
painter = TorqueBarPainter()
|
||||
with record_gpu() as gpu:
|
||||
rect = rl.Rectangle(0, 0, 476, 240)
|
||||
painter.render(rect, .3, 1, 536, 7)
|
||||
painter.render(rect, .3001, 1, 536, 7)
|
||||
endpoints = [frame.uniforms['indicatorNormals'][2] for frame in gpu.frames]
|
||||
assert 0 < endpoints[1] - endpoints[0] < .00002
|
||||
assert all(frame.vertices == frame.coordinates for frame in gpu.frames)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('rect,width,cap,torque,expected', [
|
||||
((0, 0, 476, 240), 536, 7, .3, (246, 1286.134328, 1071.888060, 6.216418)),
|
||||
((0, 0, 476, 240), 536, 7, 1, (246, 1282.582090, 1090.537313, 24.865672)),
|
||||
((30, 30, 2100, 1020), 2160, 21, .3, (1088, 5917.430556, 4987.256944, 28.923611)),
|
||||
((30, 30, 2100, 1020), 2160, 21, 1, (1088, 5900.902778, 5074.027778, 115.694444)),
|
||||
((30, 30, 1800, 1020), 2160, 21, .3, (938, 5222.083333, 4274.791667, 24.791667)),
|
||||
((30, 30, 1800, 1020), 2160, 21, 1, (938, 5207.916667, 4349.166667, 99.166667)),
|
||||
])
|
||||
def test_native_arc_dimensions_and_opacities_match_the_original_renderer(rect, width, cap, torque, expected):
|
||||
with record_gpu() as gpu:
|
||||
TorqueBarPainter().render(rl.Rectangle(*rect), torque, 1, width, cap)
|
||||
frame, = gpu.frames
|
||||
assert frame.uniforms['geometry'] == pytest.approx(expected, abs=.001)
|
||||
assert tuple(round(value * 255) for value in frame.uniforms['style'][1:]) == ((63, 229, 229) if torque == .3 else (127, 229, 0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('rect,width,cap', [((0, 0, 476, 240), 536, 7), ((30, 30, 2100, 1020), 2160, 21)])
|
||||
@pytest.mark.parametrize('torque,warmth', [(.5, 0), (.75, 0), (.875, .5), (1, 1)])
|
||||
def test_gradient_colors_and_direction_for_both_ui_sizes(rect, width, cap, torque, warmth):
|
||||
painter = TorqueBarPainter()
|
||||
with record_gpu() as gpu:
|
||||
painter.render(rl.Rectangle(*rect), torque, 1, width, cap)
|
||||
painter.render(rl.Rectangle(*rect), -torque, 1, width, cap)
|
||||
right, left = [frame.uniforms['gradient'] for frame in gpu.frames]
|
||||
assert right[0] > 0
|
||||
assert left[0] == pytest.approx(-right[0])
|
||||
assert left[1:] == pytest.approx(right[1:])
|
||||
assert (1, right[1], right[3]) == pytest.approx((1, 1 - warmth * 140 / 255, 1 - warmth))
|
||||
assert (1, right[1] + right[2], right[3]) == pytest.approx((1, 1 - warmth * 55 / 255, 1 - warmth))
|
||||
|
||||
|
||||
def test_gradient_anchor_follows_the_bar_when_repositioned():
|
||||
painter = TorqueBarPainter()
|
||||
with record_gpu() as gpu:
|
||||
painter.render(rl.Rectangle(30, 30, 2100, 1020), 1, .6, 2160, 21)
|
||||
painter.render(rl.Rectangle(130, 80, 2100, 1020), 1, .6, 2160, 21, (7, -4))
|
||||
first, second = gpu.frames
|
||||
assert second.uniforms['gradient'] == pytest.approx(first.uniforms['gradient'])
|
||||
assert second.uniforms['geometry'][0] - first.uniforms['geometry'][0] == pytest.approx(107)
|
||||
assert second.uniforms['geometry'][1] - first.uniforms['geometry'][1] == pytest.approx(46)
|
||||
assert second.uniforms['style'] == pytest.approx(first.uniforms['style'])
|
||||
|
||||
|
||||
def test_shader_and_uniform_buffers_are_reused_and_released_before_reload():
|
||||
painter = TorqueBarPainter()
|
||||
rect = rl.Rectangle(0, 0, 476, 240)
|
||||
with record_gpu() as gpu:
|
||||
painter.close()
|
||||
painter.render(rect, .3, 1, 536, 7)
|
||||
first_uploads = list(gpu.uploads)
|
||||
painter.render(rect, .3, 1, 536, 7)
|
||||
assert gpu.uploads == first_uploads
|
||||
painter.render(rect, .3001, 1, 536, 7)
|
||||
pointers = dict(first_uploads)
|
||||
assert all(pointer == pointers[name] for name, pointer in gpu.uploads)
|
||||
assert len(gpu.loads) == 1
|
||||
painter.close()
|
||||
painter.close()
|
||||
assert gpu.unloads == [1]
|
||||
painter.render(rect, .3, 1, 536, 7)
|
||||
assert len(gpu.loads) == 2
|
||||
assert len(gpu.uploads) == len(first_uploads) * 2 + 1
|
||||
painter.close()
|
||||
assert gpu.unloads == [1, 2]
|
||||
|
||||
|
||||
def test_invisible_bar_does_not_allocate_graphics_resources():
|
||||
with record_gpu() as gpu:
|
||||
TorqueBarPainter().render(rl.Rectangle(0, 0, 476, 240), .3, 0, 536, 7)
|
||||
assert not gpu.loads and not gpu.frames
|
||||
|
||||
|
||||
@pytest.mark.parametrize('default_shader_id', [0, 1])
|
||||
def test_shader_failure_never_paints_the_bounding_rectangle(default_shader_id):
|
||||
with record_gpu() as gpu, patch.object(rl, 'get_shader_location', return_value=-1), \
|
||||
patch.object(rl, 'rl_get_shader_id_default', return_value=default_shader_id):
|
||||
with pytest.raises(RuntimeError, match='Torque bar shader unavailable'):
|
||||
TorqueBarPainter().render(rl.Rectangle(0, 0, 476, 240), .3, 1, 536, 7)
|
||||
assert not gpu.frames
|
||||
assert gpu.unloads == ([1] if default_shader_id == 0 else [])
|
||||
@@ -9,6 +9,7 @@ import pyray as rl
|
||||
from openpilot.starpilot.ui.onroad_customization import PROFILES, default_document, validate_document, widget_size
|
||||
from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation
|
||||
from openpilot.starpilot.ui.onroad_torque import TorqueBarWidget
|
||||
from openpilot.starpilot.ui.tests.test_onroad_torque_draw import record_gpu
|
||||
|
||||
|
||||
def scene(document=None):
|
||||
@@ -21,17 +22,15 @@ def geometry(profile, state, torque=.3, alpha=1.):
|
||||
bounds = data['bounds']
|
||||
rect = rl.Rectangle(*(bounds[key] for key in ('x', 'y', 'width', 'height')))
|
||||
widget = TorqueBarWidget()
|
||||
calls, dots = [], []
|
||||
with patch.object(widget._torque_filter, 'update', return_value=torque), \
|
||||
patch.object(widget._alpha_filter, 'update', return_value=alpha), \
|
||||
patch('openpilot.starpilot.ui.onroad_torque.draw_polygon', side_effect=lambda rect, points, **kwargs: calls.append((rect, points.copy(), kwargs))), \
|
||||
patch('openpilot.starpilot.ui.onroad_torque.rl.draw_circle', side_effect=lambda x, y, radius, color: dots.append((x, y, radius))):
|
||||
record_gpu() as gpu:
|
||||
widget.render(rect, state, data['width'])
|
||||
return calls, dots
|
||||
return gpu.frames
|
||||
|
||||
|
||||
class TestTorqueLayout(unittest.TestCase):
|
||||
def test_moved_polygon_dot_and_gradient_origin_translate_without_resizing(self):
|
||||
def test_moved_arc_dot_and_sampling_quad_translate_without_resizing(self):
|
||||
for profile in PROFILES:
|
||||
with self.subTest(profile=profile):
|
||||
document = default_document()
|
||||
@@ -39,37 +38,39 @@ class TestTorqueLayout(unittest.TestCase):
|
||||
base['x'] += 10
|
||||
base['y'] += 10
|
||||
document = validate_document(document)
|
||||
original, original_dots = geometry(profile, scene())
|
||||
moved, moved_dots = geometry(profile, scene(document))
|
||||
for (old_rect, old_points, old_style), (new_rect, new_points, new_style) in zip(original, moved, strict=True):
|
||||
np.testing.assert_array_equal(new_points, old_points + np.array([10, 10], dtype=np.float32))
|
||||
self.assertEqual((new_rect.x, new_rect.y, new_rect.width, new_rect.height),
|
||||
(old_rect.x + 10, old_rect.y + 10, old_rect.width, old_rect.height))
|
||||
if 'gradient' in old_style:
|
||||
for field in ('start', 'end', 'stops'):
|
||||
self.assertEqual(getattr(new_style['gradient'], field), getattr(old_style['gradient'], field))
|
||||
self.assertEqual([tuple(getattr(color, channel) for channel in 'rgba') for color in new_style['gradient'].colors],
|
||||
[tuple(getattr(color, channel) for channel in 'rgba') for color in old_style['gradient'].colors])
|
||||
self.assertEqual(moved_dots, [(x + 10, y + 10, radius) for x, y, radius in original_dots])
|
||||
original, = geometry(profile, scene())
|
||||
moved, = geometry(profile, scene(document))
|
||||
np.testing.assert_allclose(moved.vertices, np.array(original.vertices) + [10, 10], atol=1e-10)
|
||||
for field in ('geometry', 'dotGeometry'):
|
||||
x, y, *rest = original.uniforms[field]
|
||||
self.assertEqual(moved.uniforms[field], (x + 10, y + 10, *rest))
|
||||
for field in ('backgroundNormals', 'indicatorNormals', 'style'):
|
||||
self.assertEqual(moved.uniforms[field], original.uniforms[field])
|
||||
|
||||
def test_metadata_bounds_enclose_all_legal_polygon_and_dot_states(self):
|
||||
def test_metadata_bounds_enclose_all_legal_arc_and_dot_states(self):
|
||||
for profile, data in PROFILES.items():
|
||||
widget = data['widgets']['torque_bar']
|
||||
x, y = widget['default']['x'], widget['default']['y']
|
||||
right, bottom = x + widget['width'], y + widget['height']
|
||||
for torque in np.linspace(-1, 1, 41):
|
||||
for alpha in (0, .01, .2, .5, 1):
|
||||
polygons, dots = geometry(profile, scene(), float(torque), alpha)
|
||||
for _, points, _ in polygons:
|
||||
self.assertGreaterEqual(float(points[:, 0].min()), x)
|
||||
self.assertGreaterEqual(float(points[:, 1].min()), y)
|
||||
self.assertLessEqual(float(points[:, 0].max()), right)
|
||||
self.assertLessEqual(float(points[:, 1].max()), bottom)
|
||||
for cx, cy, radius in dots:
|
||||
self.assertGreaterEqual(cx - radius, x)
|
||||
self.assertGreaterEqual(cy - radius, y)
|
||||
self.assertLessEqual(cx + radius, right)
|
||||
self.assertLessEqual(cy + radius, bottom)
|
||||
for frame in geometry(profile, scene(), float(torque), alpha):
|
||||
cx, cy, radius, half = frame.uniforms['geometry']
|
||||
cap = frame.uniforms['style'][0]
|
||||
normals = np.array(frame.uniforms['backgroundNormals']).reshape(2, 2)
|
||||
# The arc and its rounded ends must fit the saved layout, independent of the padded sampling quad.
|
||||
ends = np.array([cx, cy]) + radius * normals
|
||||
extents = abs(normals) * (half - cap) + cap
|
||||
self.assertGreaterEqual(float((ends - extents)[:, 0].min()), x)
|
||||
self.assertGreaterEqual(cy - radius - half, y)
|
||||
self.assertLessEqual(float((ends + extents)[:, 0].max()), right)
|
||||
self.assertLessEqual(float((ends + extents)[:, 1].max()), bottom)
|
||||
if frame.uniforms['style'][3] > 0:
|
||||
dx, dy, dot_radius, _ = frame.uniforms['dotGeometry']
|
||||
self.assertGreaterEqual(dx - dot_radius, x)
|
||||
self.assertGreaterEqual(dy - dot_radius, y)
|
||||
self.assertLessEqual(dx + dot_radius, right)
|
||||
self.assertLessEqual(dy + dot_radius, bottom)
|
||||
|
||||
def test_only_torque_underlay_can_overlap_reserved_actions(self):
|
||||
document = default_document()
|
||||
@@ -124,9 +125,9 @@ class TestTorqueLayout(unittest.TestCase):
|
||||
rect = rl.Rectangle(*(data[key] for key in ('x', 'y', 'width', 'height')))
|
||||
document = default_document()
|
||||
document['layouts']['compact']['torque_bar']['enabled'] = False
|
||||
with patch('openpilot.starpilot.ui.onroad_torque.draw_polygon') as draw:
|
||||
with record_gpu() as gpu:
|
||||
widget.render(rect, replace(scene(document), torque_utilization=.8), 536)
|
||||
draw.assert_not_called()
|
||||
self.assertFalse(gpu.frames or gpu.loads)
|
||||
self.assertGreater(widget._torque_filter.x, 0)
|
||||
self.assertGreater(widget._alpha_filter.x, 0)
|
||||
|
||||
@@ -135,16 +136,16 @@ class TestTorqueLayout(unittest.TestCase):
|
||||
for lateral, source in ((True, False), (False, True)):
|
||||
widget._torque_filter.x = widget._alpha_filter.x = .8
|
||||
state = replace(scene(), lateral_active=lateral, torque_source_available=source)
|
||||
with patch('openpilot.starpilot.ui.onroad_torque.draw_polygon') as draw:
|
||||
with record_gpu() as gpu:
|
||||
widget.render(rl.Rectangle(0, 0, 476, 240), state, 536)
|
||||
draw.assert_not_called()
|
||||
self.assertFalse(gpu.frames or gpu.loads)
|
||||
self.assertEqual((widget._torque_filter.x, widget._alpha_filter.x), (0, 0))
|
||||
|
||||
def test_new_drive_resets_existing_filters_before_first_fresh_render(self):
|
||||
widget = TorqueBarWidget()
|
||||
state = replace(scene(), lateral_active=True, torque_source_available=True, torque_drive_frame=10, torque_utilization=.8)
|
||||
rect = rl.Rectangle(0, 0, 476, 240)
|
||||
with patch('openpilot.starpilot.ui.onroad_torque.draw_polygon'), patch('openpilot.starpilot.ui.onroad_torque.rl.draw_circle'):
|
||||
with record_gpu():
|
||||
for _ in range(30):
|
||||
widget.render(rect, state, 536)
|
||||
self.assertGreater(widget._torque_filter.x, .7)
|
||||
|
||||
Reference in New Issue
Block a user