mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-14 00:13:42 +08:00
Clarify SLC targets and confirmation prompts
This commit is contained in:
@@ -47,6 +47,25 @@ try {
|
||||
assert.equal(starport.previewHidden("car_exit"), false)
|
||||
assert.deepEqual([...saved], before)
|
||||
|
||||
for (const projection of [false, true]) {
|
||||
const vm = editor(projection)
|
||||
vm.state.scene = 'slc_pending'
|
||||
vm.state.profile = 'large'
|
||||
vm.layout = {clock:{enabled:true}}
|
||||
vm.state.art = {status:'ready', scene:'slc_pending', profile:projection ? 'projection' : 'large',
|
||||
sprites:{sprites:{clock:{visible:false}}}}
|
||||
assert.equal(vm.sceneHidden('clock'), true, 'Native prompt occlusion hides the preview and its eye control')
|
||||
vm.layout.clock.enabled = false
|
||||
assert.equal(vm.sceneHidden('clock'), false, 'A disabled clock can still be added from the tray')
|
||||
vm.layout.clock.enabled = true
|
||||
for (const change of [{status:'updating'}, {scene:'engaged'}, {profile:'compact'}]) {
|
||||
const native = vm.state.art
|
||||
vm.state.art = {...native, ...change}
|
||||
assert.equal(vm.sceneHidden('clock'), false, 'Retained artwork cannot apply stale visibility')
|
||||
vm.state.art = native
|
||||
}
|
||||
}
|
||||
|
||||
saved.delete('gx-layout-preview-visibility-v2:comma')
|
||||
saved.set('gx-layout-preview-visibility-v1:comma', JSON.stringify({ large: { current_speed: true }, compact: { stopped_timer: true } }))
|
||||
const migrated = editor(false)
|
||||
|
||||
@@ -10,11 +10,11 @@ print(json.dumps(dict(document=default_document(), defaults=default_document(),
|
||||
revision="native", editable=True, valid=True, activeProfile="large", supportedProfiles=["large"])))
|
||||
`], { encoding: "utf8" }))
|
||||
assert.equal(validSnapshot(data), true)
|
||||
for (const version of [11, 12]) {
|
||||
for (const version of [11, 12, 13]) {
|
||||
assert.equal(validSnapshot({ ...data, document: { ...data.document, version } }), true)
|
||||
assert.equal(validSnapshot({ ...data, defaults: { ...data.defaults, version } }), true)
|
||||
}
|
||||
for (const version of [9, 10, 13]) {
|
||||
for (const version of [9, 10, 14]) {
|
||||
assert.equal(validSnapshot({ ...data, document: { ...data.document, version } }), false)
|
||||
assert.equal(validSnapshot({ ...data, defaults: { ...data.defaults, version } }), false)
|
||||
}
|
||||
@@ -92,25 +92,26 @@ assert.equal(vm.layout.nav_map.opacity, 35)
|
||||
assert.equal(validSnapshot({ ...data, document: vm.state.draft }), true)
|
||||
|
||||
// Use actual native geometry for source attachment, bounds, and the transient timer.
|
||||
assert.deepEqual(data.metadata.profiles.large.widgets.cruise_limits.sourcesExtent, {inline:108, card:116, actionGap:8})
|
||||
assert.deepEqual(data.metadata.profiles.large.widgets.cruise_limits.sourcesExtent, {inline:104, card:112, actionGap:8})
|
||||
vm.state.draft = structuredClone(data.document)
|
||||
vm.state.selected = "cruise_limits"
|
||||
vm.setSpeedSources("speedSources", true)
|
||||
assert.equal(vm.layout.speed_limit_actions.y, 359)
|
||||
assert.equal(vm.layout.speed_limit_actions.y, 381)
|
||||
vm.setSpeedSources("speedSourcesCard", true)
|
||||
assert.equal(vm.layout.speed_limit_actions.y, 367)
|
||||
assert.equal(vm.layout.speed_limit_actions.y, 389)
|
||||
vm.setSpeedSources("speedSources", false)
|
||||
assert.equal(vm.layout.speed_limit_actions.y, 267)
|
||||
const overlapping = structuredClone(data.document)
|
||||
overlapping.layouts.large.cruise_limits.y = 550
|
||||
assert.equal(validDocument(overlapping, data.metadata), false)
|
||||
// Keep the expanded confirmation clear of the torque arc while testing the bottom edge.
|
||||
vm.layout.cruise_limits.x = 1520
|
||||
vm.layout.speed_limit_actions.x = 1544
|
||||
// Disable torque while testing source attachment at the lower screen edge.
|
||||
vm.layout.torque_bar.enabled = false
|
||||
vm.layout.cruise_limits.x = 1430
|
||||
vm.layout.speed_limit_actions.x = 1454
|
||||
vm.layout.cruise_limits.y = 690
|
||||
vm.layout.speed_limit_actions.y = 882
|
||||
vm.setSpeedSources("speedSources", true)
|
||||
assert.equal(vm.layout.cruise_limits.y, 590)
|
||||
assert.equal(vm.layout.cruise_limits.y, 568)
|
||||
assert.equal(vm.layout.speed_limit_actions.y, 882)
|
||||
assert.equal(validDocument(vm.state.draft, data.metadata), true)
|
||||
Object.assign(vm.layout.speed_limit_actions, {x:600, y:500})
|
||||
|
||||
@@ -531,13 +531,12 @@ assert.match(OnroadLayoutPage.template, /state\.profile === 'compact'" class="gx
|
||||
// One scene dropdown, grouped, with every scene exactly once.
|
||||
assert.deepEqual(vm.sceneGroups.map(group => group.label), ["Editing", "Driving", "Conditional experimental", "Speed limit"])
|
||||
assert.deepEqual(vm.sceneGroups.flatMap(group => group.scenes.map(scene => scene.id)).sort(), vm.scenes.map(scene => scene.id).sort())
|
||||
// The Small screen only offers scenes that look different there; a hidden pick falls back to General edit.
|
||||
// Compact exposes its pedal and display-only states just like the large screen.
|
||||
vm.state.scene = "slc_display"
|
||||
vm.selectProfile("compact")
|
||||
assert.equal(vm.state.scene, "general")
|
||||
assert.equal(vm.state.scene, "slc_display")
|
||||
const smallScenes = vm.sceneGroups.flatMap(group => group.scenes.map(scene => scene.id))
|
||||
for (const id of ["slc_pedal", "slc_display"]) assert.ok(!smallScenes.includes(id), id)
|
||||
assert.ok(smallScenes.includes('slc_retained')) // The override X is visible on the Small screen too.
|
||||
for (const id of ["slc_pedal", "slc_display", "slc_retained"]) assert.ok(smallScenes.includes(id), id)
|
||||
assert.ok(smallScenes.includes("slc_unavailable") && smallScenes.includes("slc_disengaged"))
|
||||
vm.selectProfile("large")
|
||||
assert.equal(vm.sceneGroups.flatMap(group => group.scenes).length, vm.scenes.length)
|
||||
@@ -1211,12 +1210,26 @@ console.log('Display options, target switching and reload during polling passed'
|
||||
// A closed developer sidebar makes the torque arc taller than its narrow-road box.
|
||||
{
|
||||
const sample = snapshot(), profile = sample.metadata.profiles.large
|
||||
profile.widgets.cruise_limits.height = 168
|
||||
profile.widgets.cruise_limits.confirmation = { x: 172, y: -12, width: 419, height: 204 }
|
||||
profile.widgets.cruise_limits.height = 194
|
||||
profile.widgets.cruise_limits.confirmation = { x: 172, y: -12, width: 388 * Math.hypot(2160,1080) / 1800, height: 168 * Math.hypot(2160,1080) / 1800 }
|
||||
profile.widgets.torque_bar.confirmationObstacle = { x: 0, y: -48, width: 1250, height: 289 }
|
||||
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 550, sample.document.layouts.large), true)
|
||||
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 535, sample.document.layouts.large), true)
|
||||
delete profile.widgets.torque_bar.confirmationObstacle
|
||||
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 550, sample.document.layouts.large), false)
|
||||
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 535, sample.document.layouts.large), false)
|
||||
profile.widgets.cruise_limits.height = 276
|
||||
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 500, sample.document.layouts.large), true)
|
||||
}
|
||||
|
||||
// Portrait sizing and its attached chooser use the same compact bounds as native input.
|
||||
{
|
||||
const sample = snapshot(), profile = sample.metadata.profiles.compact
|
||||
profile.widgets.max_speed.width = 146
|
||||
profile.widgets.speed_limit = { ...profile.widgets.speed_limit, width: 128, height: 144,
|
||||
confirmation: { x: -172, y: -8, width: 328, height: 152 } }
|
||||
sample.document.version = 13
|
||||
sample.document.layouts.compact.speed_limit = { x: 320, y: 16, enabled: true }
|
||||
assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 320, 16, sample.document.layouts.compact), false)
|
||||
assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 320, 24, sample.document.layouts.compact), true)
|
||||
assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 317, 16, sample.document.layouts.compact), true)
|
||||
assert.equal(clampPlacement(profile, 'speed_limit', 330, 16, sample.document.layouts.compact).x, 320)
|
||||
}
|
||||
|
||||
@@ -25,15 +25,15 @@ metadata.widgets.jetlink_status = { label: "Jetlink status", kind: "jetlink_stat
|
||||
metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "jetlink_status", "car_exit"]
|
||||
const widgets = Object.fromEntries(Object.entries(metadata.widgets).map(([id, widget]) => [id,
|
||||
{ ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }]))
|
||||
const doc = { version: 5, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "center", canvas: { width: 2880, height: 1080 }, widgets }
|
||||
const doc = { version: 6, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "center", canvas: { width: 2880, height: 1080 }, widgets }
|
||||
const raw = { version: 1, document: doc, defaults: copy(doc), metadata,
|
||||
screen: { width: 1280, height: 720, margin_width: 0, margin_height: 240 },
|
||||
revision: "aa-screen-and-layout", editable: true, valid: true,
|
||||
colors: { palette: native.document.palette, widgetColors: native.document.widgetColors.large, roadColors: {} } }
|
||||
const data = editorSnapshot(raw)
|
||||
assert.deepEqual(editorSnapshot({ ...raw, document: { ...doc, version: 4 }, defaults: { ...doc, version: 4 } }).document,
|
||||
data.document, "version 4 layouts remain readable alongside the backend's version 5")
|
||||
for (const bad of [{ version: 6 }, { version: "5" }, { canvas: { width: 1920, height: 1080 } }])
|
||||
data.document, "version 4 layouts remain readable alongside the backend's version 6")
|
||||
for (const bad of [{ version: 7 }, { version: "5" }, { canvas: { width: 1920, height: 1080 } }])
|
||||
assert.throws(() => editorSnapshot({ ...raw, document: { ...doc, ...bad } }), /does not match/)
|
||||
|
||||
assert.equal(validSnapshot(data), true)
|
||||
@@ -260,7 +260,7 @@ const draft = copy(feed.data.document)
|
||||
draft.layouts.large.current_speed.x += 20
|
||||
assert.equal(await feed.save(draft), true)
|
||||
assert.equal(requests[0].url, "./api/starport/layout")
|
||||
assert.equal(requests[1].body.document.version, 5)
|
||||
assert.equal(requests[1].body.document.version, 6)
|
||||
assert.deepEqual(Object.keys(requests[1].body.document).sort(), ["blindSpotBorder", "blindSpotCamera", "canvas", "clock24Hour", "hidePersonalityAlerts", "largeUiGammaTrial", "leadIndicator", "leadInfo", "signalBorder", "speedAlign", "speedSources", "speedSourcesCard", "version", "wheelPedalFeedback", "widgets"])
|
||||
assert.equal(requests[1].body.document.widgets.current_speed.x, 1170)
|
||||
assert.equal(requests[1].body.document.speedAlign, "center")
|
||||
|
||||
@@ -27,10 +27,22 @@ const withHeader={...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1
|
||||
assert.deepEqual(await widgetArtMetadata(image(withHeader)),withHeader,'Native action sprites include the heading above the buttons')
|
||||
for(const bad of [{...meta,width:3},{...meta,sprites:{conditional_mode:{x:1,y:0,width:2,height:1}}},
|
||||
{...meta,sprites:{conditional_mode:{x:0,y:0,width:NaN,height:1}}},
|
||||
{...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1,offsetY:-33}}},
|
||||
{...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1,offsetY:-34}}},
|
||||
{...meta,sprites:{conditional_mode:{x:0,y:0,width:2,height:1,offsetY:-32}}},
|
||||
{...meta,sprites:{conditional_mode:{x:0,y:0,width:2,height:1,offsetX:1}}}])await assert.rejects(widgetArtMetadata(image(bad)))
|
||||
await assert.rejects(widgetArtMetadata(new Blob(['not PNG'])))
|
||||
for (const [id, offsetX, offsetY] of [['speed_limit', -182.5, -12.5], ['speed_limit', -2, 0], ['cruise_limits', 0, -12.5]]) {
|
||||
const native = {...meta, sprites: {[id]: {x:0,y:0,width:2,height:1,offsetX,offsetY,visible:false}}}
|
||||
assert.deepEqual(await widgetArtMetadata(image(native)), native)
|
||||
}
|
||||
for (const rect of [{offsetX:-193}, {offsetY:-14}, {visible:'false'}]) {
|
||||
await assert.rejects(widgetArtMetadata(image({...meta,sprites:{speed_limit:{x:0,y:0,width:2,height:1,...rect}}})))
|
||||
}
|
||||
const compactIds=['weather','max_speed','speed_limit','steering_wheel','conditional_mode','following_distance','model_confidence',
|
||||
'driver_monitor','speed_limit_actions','driving_mode_descriptions','clock']
|
||||
const complete={...meta,sprites:Object.fromEntries(compactIds.map(id=>[id,{x:0,y:0,width:2,height:1,visible:true}]))}
|
||||
assert.deepEqual(await widgetArtMetadata(image(complete)),complete)
|
||||
await assert.rejects(widgetArtMetadata(image({...complete,sprites:{...complete.sprites,extra:{x:0,y:0,width:2,height:1}}})))
|
||||
const flush=async()=>{for(let i=0;i<20;i++)await Promise.resolve()}
|
||||
function fixture() {
|
||||
const updates=[],requests=[],timers=new Map(),revoked=[];let next=0,url=0,unauthorized=0
|
||||
@@ -59,6 +71,7 @@ tick();await answer(1);assert.equal(requests.length,3);await answer(2);assert.eq
|
||||
feed.update(document,'compact','engaged')
|
||||
assert.equal(feed.currentUrl,'blob:1','Returning to a profile restores its artwork immediately')
|
||||
assert.equal(updates.at(-1).status,'ready');assert.equal(timers.size,0,'Unchanged cached artwork needs no request')
|
||||
assert.equal(updates.findLast(update=>'profile' in update).profile,'compact','Cached artwork retains its screen identity')
|
||||
assert.deepEqual(revoked,[],'Switching profiles keeps both mounted sheets alive')
|
||||
feed.update(document,'large','braking');tick();await answer(3,503)
|
||||
assert.equal(feed.currentUrl,'blob:2','A transient failure retains visible artwork')
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { widgetArtMetadata } from "./widget-art.js"
|
||||
import { widgetSize } from "./onroad-layout-document.js"
|
||||
// hiddenOn: screens where the scene looks exactly like another one. The Small screen has no text for these
|
||||
// speed-limit states, so they match Engaged there.
|
||||
export const PREVIEW_SCENES = [
|
||||
{ id: "general", label: "General edit" },
|
||||
{ id: "engaged", label: "Engaged" },
|
||||
@@ -17,11 +15,11 @@ export const PREVIEW_SCENES = [
|
||||
{ id: "slc_pending", label: "Confirmation choices" },
|
||||
{ id: "slc_max", label: "Using set speed" },
|
||||
{ id: "slc_equal", label: "Equal speed limits" },
|
||||
{ id: "slc_pedal", label: "Using accelerator", hiddenOn: ["compact"] },
|
||||
{ id: "slc_pedal", label: "Using accelerator" },
|
||||
{ id: "slc_retained", label: "Set-speed override" },
|
||||
{ id: "slc_disengaged", label: "Cruise off" },
|
||||
{ id: "slc_unavailable", label: "Unavailable speed limit" },
|
||||
{ id: "slc_display", label: "Display-only speed limit", hiddenOn: ["compact"] },
|
||||
{ id: "slc_display", label: "Display-only speed limit" },
|
||||
]
|
||||
const SCENES = PREVIEW_SCENES.map(({ id }) => id)
|
||||
|
||||
@@ -36,8 +34,9 @@ export function previewScene(scene) {
|
||||
mode: off ? "Disengaged" : scene === "slc_pedal" ? "ACC - Override" : !longitudinal ? "Always On Lateral" : experimental ? "ACC - Experimental" : "ACC - Chill" }
|
||||
}
|
||||
|
||||
export function sceneHidesWidget(scene, profile, id, layout = {}, widgets = {}) {
|
||||
export function sceneHidesWidget(scene, profile, id, layout = {}, widgets = {}, sprites = {}) {
|
||||
if (scene === "general") return false
|
||||
if (layout?.[id]?.enabled && typeof sprites[id]?.visible === "boolean") return !sprites[id].visible
|
||||
const { lateral, longitudinal } = previewScene(scene)
|
||||
if (id === "stopped_timer") return scene !== "stopped"
|
||||
if (id === "cem_gauge") return !scene.startsWith("cem_")
|
||||
@@ -159,7 +158,7 @@ export class LayoutPreviewFeed {
|
||||
if (cached) this.latestArt.set(profile, cacheKey)
|
||||
const shown = cached || this.artCache.get(this.latestArt.get(profile))
|
||||
this.currentUrl = shown?.url || null
|
||||
this.publish({ url: this.currentUrl, sprites: shown?.sprites || null, scene: shown?.scene || null })
|
||||
this.publish({ url: this.currentUrl, sprites: shown?.sprites || null, scene: shown?.scene || null, profile })
|
||||
if (cached?.key === renderKey) {
|
||||
this.publish({ status: "ready", error: "" })
|
||||
return
|
||||
|
||||
@@ -140,7 +140,7 @@ export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "
|
||||
export function validDocument(document, metadata) {
|
||||
if (!object(document)) return false
|
||||
const profiles = metadata?.projection === true ? ["large"] : PROFILES
|
||||
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "reduceMotion") ? ["reduceMotion"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || (metadata?.projection === true ? document.version !== 8 : ![11, 12].includes(document.version)) ||
|
||||
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "reduceMotion") ? ["reduceMotion"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || (metadata?.projection === true ? document.version !== 8 : ![11, 12, 13].includes(document.version)) ||
|
||||
(Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") ||
|
||||
(Object.hasOwn(document, "speedSourcesCard") && typeof document.speedSourcesCard !== "boolean") ||
|
||||
(Object.hasOwn(document, "speedAlign") && !SPEED_ALIGNMENTS.includes(document.speedAlign)) ||
|
||||
|
||||
@@ -606,7 +606,12 @@ export const OnroadLayoutPage = {
|
||||
this.state.showEditingAids = !this.state.showEditingAids
|
||||
try { localStorage.setItem(aidsPreferenceKey, this.state.showEditingAids ? "1" : "0") } catch { }
|
||||
},
|
||||
sceneHidden(id) { return sceneHidesWidget(this.state.scene, this.state.profile, id, this.layout, this.profile?.widgets) },
|
||||
sceneHidden(id) {
|
||||
const { scene, profile, art } = this.state
|
||||
const sprites = art?.status === "ready" && art.scene === scene && art.profile === (this.projection ? "projection" : profile) ?
|
||||
art.sprites?.sprites : undefined
|
||||
return sceneHidesWidget(scene, profile, id, this.layout, this.profile?.widgets, sprites)
|
||||
},
|
||||
previewHidden(id) {
|
||||
if (this.projection && id === "car_exit") return false
|
||||
if (this.sceneHidden(id)) return true
|
||||
|
||||
@@ -25,7 +25,7 @@ export function editorSnapshot(raw) {
|
||||
throw new Error(raw?.reason || "Connect Android Auto once to obtain the actual screen.")
|
||||
const colors = raw.colors
|
||||
const document = (value) => {
|
||||
if (![4, 5].includes(value.version) || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
|
||||
if (![4, 5, 6].includes(value.version) || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
|
||||
throw new Error("The saved StarPort layout does not match its screen. Reload before editing.")
|
||||
return { version: 8, clock24Hour: value.clock24Hour === true, largeUiGammaTrial: value.largeUiGammaTrial === true, blindSpotBorder: value.blindSpotBorder === true, signalBorder: value.signalBorder === true, blindSpotCamera: value.blindSpotCamera === true, hidePersonalityAlerts: value.hidePersonalityAlerts === true, wheelPedalFeedback: value.wheelPedalFeedback === true, leadIndicator: value.leadIndicator === true, leadInfo: LEAD_INFO.includes(value.leadInfo) ? value.leadInfo : "Off", speedSources: value.speedSources === true, speedSourcesCard: value.speedSourcesCard === true, speedAlign: ["center", "right"].includes(value.speedAlign) ? value.speedAlign : "left", ...(value.widgetOrder ? { widgetOrder: { large: clone(value.widgetOrder) } } : {}), palette: clone(colors.palette), layouts: { large: clone(value.widgets) },
|
||||
widgetColors: { large: clone(colors.widgetColors) }, roadColors: { large: clone(colors.roadColors) } }
|
||||
@@ -38,7 +38,7 @@ export function editorSnapshot(raw) {
|
||||
}
|
||||
|
||||
export function projectionPayload(body, metadata) {
|
||||
return { revision: body.revision, document: { version: 5,
|
||||
return { revision: body.revision, document: { version: 6,
|
||||
canvas: { width: metadata.profiles.large.width, height: metadata.profiles.large.height },
|
||||
clock24Hour: body.document.clock24Hour === true,
|
||||
largeUiGammaTrial: body.document.largeUiGammaTrial === true,
|
||||
|
||||
@@ -32,14 +32,16 @@ export async function widgetArtMetadata(blob, header = null) {
|
||||
metadata.width < 1 || metadata.width > 1024 || metadata.height < 1 || metadata.height > 1440 ||
|
||||
metadata.width !== view.getUint32(16) || metadata.height !== view.getUint32(20) ||
|
||||
!metadata.sprites || typeof metadata.sprites !== "object" || Array.isArray(metadata.sprites) ||
|
||||
Object.keys(metadata.sprites).length > 10) throw new Error("Invalid widget art metadata")
|
||||
Object.keys(metadata.sprites).length > 11) throw new Error("Invalid widget art metadata")
|
||||
for (const [id, rect] of Object.entries(metadata.sprites)) {
|
||||
const minX = id === "speed_limit" ? -192 : -2
|
||||
const minY = id === "speed_limit_actions" ? -33 : ["cruise_limits", "speed_limit"].includes(id) ? -13 : -1
|
||||
if (!/^[a-z_]+$/.test(id) || !rect || ![rect.x,rect.y,rect.width,rect.height].every(Number.isInteger) ||
|
||||
rect.x < 0 || rect.y < 0 || rect.width < 1 || rect.height < 1 ||
|
||||
rect.x + rect.width > metadata.width || rect.y + rect.height > metadata.height ||
|
||||
!Number.isFinite(rect.offsetX ?? 0) || (rect.offsetX ?? 0) <= -1 || (rect.offsetX ?? 0) > 0 ||
|
||||
!Number.isFinite(rect.offsetY ?? 0) || (rect.offsetY ?? 0) <= (id === 'speed_limit_actions' ? -33 : -1) ||
|
||||
(rect.offsetY ?? 0) > 0) throw new Error("Invalid widget art rectangle")
|
||||
!Number.isFinite(rect.offsetX ?? 0) || (rect.offsetX ?? 0) < minX || (rect.offsetX ?? 0) > 0 ||
|
||||
!Number.isFinite(rect.offsetY ?? 0) || (rect.offsetY ?? 0) < minY || (rect.offsetY ?? 0) > 0 ||
|
||||
(rect.visible !== undefined && typeof rect.visible !== "boolean")) throw new Error("Invalid widget art rectangle")
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
@@ -43,14 +43,21 @@ With **Confirm limits** set to **Off**, qualified new limits are adopted immedia
|
||||
accepted posted limit. An unselected direction is automatic.
|
||||
Without a previous limit, any manual policy asks for the first one unless it
|
||||
already matches SET.
|
||||
The stationary confirmation card gives two gentle purple pulses
|
||||
The stationary New Limit caption gives two gentle pulses
|
||||
(700 ms each, separated by 200 ms), then rests until the 3.2-second cycle repeats.
|
||||
Only the lower part of the card pulses; the speed and source remain steady.
|
||||
Only the caption changes color; the speed and source remain steady.
|
||||
The arrow remains relative to vehicle speed.
|
||||
Outside manual confirmation, large UI uses the same US or Vienna sign at 1.25×
|
||||
scale beside SET speed.
|
||||
US signs share a cached antialiased white outline with a transparent interior;
|
||||
the offset fits inside the fixed sign height on both screens.
|
||||
Signs and confirmation choices have transparent interiors. Cached sign outlines and
|
||||
tight dark glyph edges keep the camera visible without sampling its brightness.
|
||||
SET keeps its label and value in both profiles. When the displayed road limit
|
||||
matches the fresh accepted cap actually limiting SET, it gets an IN USE label;
|
||||
the rectangular sign also turns green.
|
||||
An applied offset target uses a large LIMIT value with the source reading below.
|
||||
A held override uses the same rectangular readout labeled OVERRIDE, including in
|
||||
Vienna mode; the road limit remains readable below it. While the accelerator is
|
||||
pressed, PEDAL identifies the temporary driver input without claiming a held speed.
|
||||
Both sign styles identify the displayed source: DASH for Dashboard, OSM for offline
|
||||
Maps, and VISION for the vision model. Explicit Mapbox readings in historical
|
||||
replay use NAV; unidentified live sources show SPEED. A retained road reading
|
||||
@@ -58,10 +65,17 @@ shows LAST alongside its source when known. For the same source and speed, the
|
||||
label clears after 150 ms of live updates. Vision keeps a confirmed sign current
|
||||
after it leaves view while the camera pipeline stays healthy. DASH follows the
|
||||
current OEM reading, and Maps keeps the current road limit separate from its
|
||||
upcoming preview. The binding SLC speed is shown separately beside SET, including
|
||||
during a new-limit confirmation.
|
||||
Both styles place only this source above the number, beside any pending arrow.
|
||||
The source and arrow share a centered row; all sign contents use the same geometry
|
||||
upcoming preview. Retained and recovering readings never claim IN USE.
|
||||
During a new-limit confirmation the applied LIMIT or OVERRIDE stays below SET;
|
||||
if SET is hidden, that status appears in the confirmation header with a `Now:`
|
||||
label. The expanded Accept button contains the proposed limit including its offset
|
||||
and unit; Reject says `new limit`. Source readings and offset arithmetic stay in
|
||||
the collapsed notice, outside the expanded decision. Accept and Reject retain their original touch
|
||||
areas and action validation. Green Accept and red Reject outlines contain each
|
||||
button without filling its interior; pressing strengthens the outline and colors
|
||||
the label. Disabled choices use muted ink.
|
||||
Ordinary signs keep the source above the number, beside any pending arrow.
|
||||
The source and arrow share a centered row; sign contents use the same geometry
|
||||
on both screens. Small arrows are baked at double resolution and text is aligned
|
||||
to final pixels for clear native rendering.
|
||||
Steering-wheel + accepts and − declines. Tapping the sign opens the same
|
||||
|
||||
@@ -182,7 +182,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}}
|
||||
result = {'version': 5, 'clock24Hour': False, 'largeUiGammaTrial': False, **display, 'canvas': {key: metadata[key] for key in ('width', 'height')},
|
||||
result = {'version': 6, '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()}}
|
||||
place_attached_slc_actions(result['widgets'], metadata['bounds'], sources_extent(result))
|
||||
@@ -220,7 +220,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, 5) or
|
||||
type(value['version']) is not int or value['version'] not in (1, 2, 3, 4, 5, 6) 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}):
|
||||
@@ -305,6 +305,8 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
|
||||
dimensions = SPEED_LAYOUT_V7['large'][key][:2]
|
||||
elif value['version'] < 5 and key == 'cruise_limits':
|
||||
dimensions = (widget['width'], 132)
|
||||
elif value['version'] < 6 and key == 'cruise_limits':
|
||||
dimensions = (widget['width'], 168)
|
||||
# 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:
|
||||
@@ -333,7 +335,12 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
|
||||
actions = result['widgets']['speed_limit_actions']
|
||||
if (actions['x'], actions['y']) == (100, 296):
|
||||
actions.update(x=metadata['widgets']['speed_limit_actions']['default']['x'], y=metadata['widgets']['speed_limit_actions']['default']['y'])
|
||||
result['version'] = 5
|
||||
result['version'] = 6
|
||||
if value['version'] == 5 and result.get('speedSources') is True:
|
||||
card, actions = result['widgets']['cruise_limits'], result['widgets']['speed_limit_actions']
|
||||
old_gap = 284 + (8 if result.get('speedSourcesCard') is True else 0)
|
||||
if actions['x'] == card['x'] + 24 and actions['y'] == card['y'] + old_gap:
|
||||
actions['y'] = card['y'] + 192
|
||||
migrate_confirmation_layout(result['widgets'], 'large', metadata['widgets'], bounds, extra_height=sources_extent(result))
|
||||
place_attached_slc_actions(result['widgets'], bounds, sources_extent(result))
|
||||
if len(json.dumps(result, separators=(',', ':'), allow_nan=False).encode()) > MAX_BYTES:
|
||||
|
||||
@@ -288,14 +288,9 @@ class ProjectionOnroad:
|
||||
else:
|
||||
self._monitor_pair = None
|
||||
self._monitor_pair_ns = None
|
||||
self.native.rl.rl_push_matrix()
|
||||
self.native.rl.rl_translatef(0, self.height - 1080, 0)
|
||||
try:
|
||||
self.monitor.render(state, monitor=monitor, driver=driver,
|
||||
fresh=monitor is not None and driver is not None,
|
||||
onroad=ui.is_onroad())
|
||||
finally:
|
||||
self.native.rl.rl_pop_matrix()
|
||||
self.monitor.render(state, monitor=monitor, driver=driver,
|
||||
fresh=monitor is not None and driver is not None,
|
||||
onroad=ui.is_onroad(), y_offset=self.height - 1080)
|
||||
|
||||
def _ensure_map(self):
|
||||
"""Build the map overlay the first time a layout turns it on; it stays for the connection."""
|
||||
|
||||
@@ -50,7 +50,7 @@ class TestProjectionLayout(unittest.TestCase):
|
||||
with self.subTest(viewport=viewport, separate=separate):
|
||||
document = default_layout_for_viewport(viewport, {'speedSources': True, 'speedSourcesCard': separate})
|
||||
actions = document['widgets']['speed_limit_actions']
|
||||
self.assertEqual(actions['y'], 367 if separate else 359)
|
||||
self.assertEqual(actions['y'], 389 if separate else 381)
|
||||
self.assertEqual(validate_layout_for_viewport(document, viewport), document)
|
||||
native = projection_customization(document, default_document())
|
||||
self.assertEqual(placement(native, 'large', 'speed_limit_actions'), actions)
|
||||
@@ -58,7 +58,7 @@ class TestProjectionLayout(unittest.TestCase):
|
||||
actions['y'] = 882
|
||||
saved = validate_layout_for_viewport(document, viewport)
|
||||
card, actions = (saved['widgets'][key] for key in ('cruise_limits', 'speed_limit_actions'))
|
||||
self.assertEqual(actions['y'], card['y'] + 284 + (8 if separate else 0))
|
||||
self.assertEqual(actions['y'], card['y'] + 306 + (8 if separate else 0))
|
||||
self.assertLessEqual(actions['y'] + 168, viewport[1] - 30)
|
||||
self.assertEqual(validate_layout_for_viewport(saved, viewport), saved)
|
||||
|
||||
@@ -72,8 +72,8 @@ class TestProjectionLayout(unittest.TestCase):
|
||||
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 - 192)
|
||||
self.assertEqual(migrated['version'], 6)
|
||||
self.assertEqual(migrated['widgets']['cruise_limits']['y'], bottom - 214)
|
||||
self.assertEqual(migrated['widgets']['speed_limit_actions'], document['widgets']['speed_limit_actions'])
|
||||
self.assertEqual(validate_layout(migrated, SCREEN), migrated)
|
||||
|
||||
@@ -85,6 +85,33 @@ class TestProjectionLayout(unittest.TestCase):
|
||||
migrated = validate_layout(document, SCREEN)
|
||||
self.assertEqual(migrated['widgets']['speed_limit_actions']['y'], card['y']+192)
|
||||
|
||||
def test_v5_portrait_upgrade_keeps_disabled_actions_and_clamps_only_the_resized_card(self):
|
||||
document = default_layout(SCREEN)
|
||||
document['version'] = 5
|
||||
bottom = sum(layout_metadata(SCREEN)['bounds'][key] for key in ('y', 'height'))
|
||||
document['widgets']['cruise_limits']['y'] = bottom - 168
|
||||
document['widgets']['speed_limit_actions'].update(x=250, y=400, enabled=False)
|
||||
before = copy.deepcopy(document)
|
||||
migrated = validate_layout(document, SCREEN)
|
||||
expected = copy.deepcopy(document)
|
||||
expected['version'] = 6
|
||||
expected['widgets']['cruise_limits']['y'] = bottom - 194
|
||||
self.assertEqual(document, before)
|
||||
self.assertEqual(migrated, expected)
|
||||
self.assertEqual(validate_layout(migrated, SCREEN), migrated)
|
||||
|
||||
def test_v5_source_attachment_follows_the_new_row_without_moving_custom_actions(self):
|
||||
for separate in (False, True):
|
||||
document = default_layout(SCREEN, {'speedSources': True, 'speedSourcesCard': separate})
|
||||
document['version'] = 5
|
||||
actions = document['widgets']['speed_limit_actions']
|
||||
actions['y'] = 367 if separate else 359
|
||||
updated = validate_layout(document, SCREEN)
|
||||
self.assertEqual(updated['widgets']['speed_limit_actions']['y'], 389 if separate else 381)
|
||||
self.assertEqual(validate_layout(updated, SCREEN), updated)
|
||||
actions.update(x=600, y=400)
|
||||
self.assertEqual(validate_layout(document, SCREEN)['widgets']['speed_limit_actions'], actions)
|
||||
|
||||
def test_jetlink_widget_migrates_and_preserves_placement_and_order(self):
|
||||
document = default_layout(SCREEN)
|
||||
expected = document['widgets'].pop('jetlink_status')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Retained presentation assets
|
||||
|
||||
The speed group uses the existing font atlas with a thin contour and short shadow
|
||||
in `speed_limit_widget.py`; it owns no background texture. The large camera header
|
||||
gradient and compact `icons_mici/onroad/onroad_fade.png` remain unchanged. The
|
||||
compact fade stays pinned in `onroad-assets.json` with the retained wheel artwork.
|
||||
The speed group uses the existing font atlas with a tight dark contour
|
||||
in `speed_limit_widget.py`; it owns no background texture. The large header gradient
|
||||
and compact camera fade are no longer drawn. The bundled compact fade remains
|
||||
pinned in `onroad-assets.json` for the retained upstream artwork.
|
||||
|
||||
`fonts` contains the unchanged four Inter bitmap pairs and one Unifont bitmap
|
||||
pair used by the presentation reference, plus the supplied Sora wordmark font
|
||||
@@ -46,12 +46,26 @@ 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, joined arrows and confirmation surfaces are generated by
|
||||
The SLC ring, rectangular outline, buttons, arrows and steering-wheel cues 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.
|
||||
Confirmation surfaces stretch only their flat centers and share that atlas;
|
||||
their translucent purple pulse stays below the source and speed.
|
||||
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.
|
||||
The sign interiors and confirmation choices stay transparent. Tight dark edges
|
||||
support the light glyphs and outlines; only the New Limit caption pulses.
|
||||
Expanded Accept / Reject buttons have separate large and compact sprites with
|
||||
double-density antialiased strokes and a tight dark contour baked in. Each button is one tinted
|
||||
quad, including its pressed variant; interiors remain transparent.
|
||||
Button text uses measured ink bounds: action headings share a row, the speed and
|
||||
unit are centered together, and each complete text group has equal vertical margins.
|
||||
Both onroad profiles use a 128 × 144 portrait footprint. Large UI scales it by
|
||||
1.3416 (the 402.5-to-300 PPI ratio) and normalizes font sizes to preserve physical
|
||||
lettering size. The compact footprint stays above the torque arc. State headings
|
||||
and targets are centered; long source/offset details use two supporting rows.
|
||||
Held overrides use the rectangular readout in either sign style so a driver-selected
|
||||
target cannot look like a posted Vienna limit. Pedal input replaces the number with
|
||||
`Pedal` until the controller reports a held target.
|
||||
|
||||
Open confirmations use 180 × 128 logical touch areas on large UI and 150 × 112
|
||||
on compact UI. The `Now:` header carries the current controller target once;
|
||||
the proposed target and unit are inside Accept. The same dimensions drive
|
||||
rendering, touch handling, layout validation, and native editor artwork.
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from openpilot.starpilot.ui.large_speed_geometry import (SIGN_WIDTH, SIGN_HEIGHT, CONFIRMATION_BUTTON, CONFIRMATION_HEIGHT,
|
||||
CONFIRMATION_HEADER, CONFIRMATION_PADDING)
|
||||
from openpilot.starpilot.ui.speed_limit_shapes import ATLAS_PATH, ATLAS_SIZE, SPRITES, SPRITE_DENSITIES
|
||||
|
||||
|
||||
@@ -21,27 +23,31 @@ def generate():
|
||||
py = ((np.arange(height * SAMPLES) + .5) / SAMPLES - height / 2) / density
|
||||
xx, yy = np.meshgrid(px, py)
|
||||
rgba = np.full((height, width, 4), 255, dtype=np.uint8)
|
||||
if name in ('disc', 'ring'):
|
||||
if name == '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)
|
||||
mask = (radius <= 59) & (radius >= 51)
|
||||
rgba[..., 3] = np.rint(coverage(mask, width, height) * 255).astype(np.uint8)
|
||||
elif name in ('outline', 'override_outline'):
|
||||
elif name == 'outline':
|
||||
# Signed distance to a rounded rectangle; supersampling is offline only.
|
||||
half_width, half_height = (57, 64) if name == 'outline' else (66, 47)
|
||||
half_width, half_height = SIGN_WIDTH / 2 - 2, SIGN_HEIGHT / 2 - 2
|
||||
qx, qy = np.abs(xx) - (half_width - 8), np.abs(yy) - (half_height - 8)
|
||||
distance = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - 8
|
||||
rgba[..., 3] = np.rint(coverage((distance >= -2) & (distance <= 0), width, height) * 255).astype(np.uint8)
|
||||
elif name == 'override_circle':
|
||||
distance = np.hypot(xx, yy) - 60
|
||||
alpha = coverage(distance <= 0, width, height)
|
||||
interior = coverage(distance <= -6, width, height)
|
||||
weight = np.divide(interior, alpha, out=np.zeros_like(alpha), where=alpha > 0)[..., None]
|
||||
rgba[..., :3] = np.rint(np.array((201, 34, 49)) * (1 - weight) + 255 * weight).astype(np.uint8)
|
||||
elif name.startswith('button'):
|
||||
profile = name.split('_')[1]
|
||||
half_width = (CONFIRMATION_BUTTON[profile] - 8) / 2
|
||||
half_height = (CONFIRMATION_HEIGHT[profile] - CONFIRMATION_HEADER - 2 * CONFIRMATION_PADDING) / 2
|
||||
qx, qy = np.abs(xx) - (half_width - 16), np.abs(yy) - (half_height - 16)
|
||||
distance = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - 16
|
||||
half_stroke = 1.5 if name.endswith('_pressed') else 1.
|
||||
fill = coverage(np.abs(distance) <= half_stroke, width, height)
|
||||
border = coverage((np.abs(distance) > half_stroke) & (np.abs(distance) <= half_stroke + .8), width, height) * .95
|
||||
alpha = fill + border
|
||||
# Bake the tight contour into the same quad so curved edges never overlap or facet.
|
||||
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)
|
||||
elif name == 'override_slash':
|
||||
mask = np.hypot(np.maximum(np.abs(xx) - 20.5, 0), yy) <= 1.2
|
||||
rgba[..., 3] = np.rint(coverage(mask, width, height) * 255).astype(np.uint8)
|
||||
elif name in ('wheel_plus', 'wheel_minus'):
|
||||
qx, qy = np.abs(xx) - 4, np.abs(yy) - 4
|
||||
distance = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - 5
|
||||
@@ -57,30 +63,6 @@ def generate():
|
||||
rgba[..., channel] = np.rint(np.divide(border * edge + mark * ink, alpha,
|
||||
out=np.where(center, ink, edge).astype(float), where=alpha > 0)).astype(np.uint8)
|
||||
rgba[..., 3] = np.rint(alpha * 255).astype(np.uint8)
|
||||
elif name.startswith('confirmation'):
|
||||
pulse = name == 'confirmation_pulse'
|
||||
radius = 16 if name == 'confirmation_open' else 24
|
||||
qx = np.abs(xx) - ((width / density - 2) / 2 - radius)
|
||||
qy = np.abs(yy + (43 if pulse else 0)) - (76 - radius)
|
||||
distance = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - radius
|
||||
fill = coverage(distance <= 0, width, height)
|
||||
row = ((np.arange(height) + .5) / density - 1 + (86 if pulse else 0)) / 152
|
||||
if pulse:
|
||||
rgba[..., :3] = (191, 115, 253)
|
||||
alpha = fill * np.clip((row * 152 - 86) / 66, 0, 1)[:, None] * .20
|
||||
else:
|
||||
stops = (0, .08, .55, .64, 1)
|
||||
colors = np.array(((73, 69, 94), (57, 55, 77), (55, 52, 78), (65, 53, 90), (86, 55, 118)))
|
||||
base = np.stack([np.interp(row, stops, colors[:, c]) for c in range(3)], axis=-1)[:, None, :]
|
||||
colors = np.array(((173, 153, 205), (116, 103, 141), (94, 72, 124)))
|
||||
edge = np.stack([np.interp(row, (0, .5, 1), colors[:, c]) for c in range(3)], axis=-1)[:, None, :]
|
||||
rim = coverage((distance >= -1) & (distance <= 0), width, height)
|
||||
rim = np.divide(rim, fill, out=np.zeros_like(fill), where=fill > 0) * np.interp(row, (0, .5, 1), (.68, .46, .40))[:, None]
|
||||
alpha = rim + 230 / 255 * (1 - rim)
|
||||
rgba[..., :3] = np.rint((rim[..., None] * edge + 230 / 255 * (1 - rim[..., None]) * base) / alpha[..., None]).astype(np.uint8)
|
||||
alpha *= fill
|
||||
rgba[..., 3] = np.rint(alpha * 255).astype(np.uint8)
|
||||
rgba[rgba[..., 3] == 0] = 0
|
||||
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
|
||||
@@ -94,11 +76,11 @@ def generate():
|
||||
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 = np.zeros_like(fill) if name == 'arrow_confirmation' else coverage((distance_sq <= outline**2) & ~inside, width, height) * .87
|
||||
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, 255 if name == 'arrow_confirmation' else dark), where=alpha > 0)).astype(np.uint8)
|
||||
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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 24 KiB |
@@ -4,8 +4,10 @@ import math
|
||||
|
||||
WIDTH = 488
|
||||
MAX_ROW_HEIGHT = 132
|
||||
LARGE_SIGN_SCALE = 1.25
|
||||
LIMIT_ROW_HEIGHT = 168
|
||||
# Preserve physical lettering size across the 402.5 PPI and 300 PPI displays.
|
||||
LARGE_SIGN_SCALE = math.hypot(2160, 1080) / 6 / 300
|
||||
SIGN_WIDTH, SIGN_HEIGHT = 128, 144
|
||||
LIMIT_ROW_HEIGHT = math.ceil(SIGN_HEIGHT * LARGE_SIGN_SCALE)
|
||||
CARD_HEIGHT = LIMIT_ROW_HEIGHT
|
||||
ACTION_WIDTH = 308
|
||||
ACTION_HEIGHT = 168
|
||||
@@ -15,35 +17,37 @@ GAP = 8
|
||||
SOURCE_ROW_HEIGHT = 76
|
||||
PADDING = 24
|
||||
|
||||
LIMIT_RECTS = {'large': (318, 75, 148, 165), 'compact': (330, 20, 118, 132)}
|
||||
LIMIT_RECTS = {'large': (318, 75, math.ceil(SIGN_WIDTH * LARGE_SIGN_SCALE), LIMIT_ROW_HEIGHT),
|
||||
'compact': (320, 16, SIGN_WIDTH, SIGN_HEIGHT)}
|
||||
ACTION_BOUNDS = {
|
||||
'large': ((112, ACTION_Y, 420, ACTION_Y + 80), (112, ACTION_Y + 88, 420, ACTION_Y + ACTION_HEIGHT)),
|
||||
'compact': ((146, 44, 330, 102), (146, 106, 330, 164)),
|
||||
}
|
||||
ACTION_TEXT_SIZE = {'large': 64, 'compact': 48}
|
||||
|
||||
# Match physical touch size: the large display is ~402.5 PPI; compact is 300 PPI.
|
||||
CONFIRMATION_SCALE = {'large': math.hypot(2160, 1080) / 6 / 300, 'compact': 1.0}
|
||||
CONFIRMATION_HEIGHT = 152
|
||||
CONFIRMATION_SCALE = {'large': LARGE_SIGN_SCALE, 'compact': 1.0}
|
||||
CONFIRMATION_HEIGHT = {'large': 168, 'compact': 152}
|
||||
CONFIRMATION_HEADER = 40
|
||||
CONFIRMATION_BUTTON = 144
|
||||
CONFIRMATION_GAP = 8
|
||||
CONFIRMATION_BUTTON = {'large': 180, 'compact': 150}
|
||||
CONFIRMATION_GAP = 12
|
||||
CONFIRMATION_PADDING = 8
|
||||
|
||||
|
||||
def confirmation_bounds(profile, x, y, *, opened=False):
|
||||
scale = CONFIRMATION_SCALE[profile]
|
||||
width = 2 * CONFIRMATION_PADDING + 2 * CONFIRMATION_BUTTON + CONFIRMATION_GAP if opened else 240
|
||||
left = x + 172 if profile == 'large' else x + 130 - width * scale
|
||||
return left, y - 12, left + width * scale, y - 12 + CONFIRMATION_HEIGHT * scale
|
||||
width = 2 * CONFIRMATION_PADDING + 2 * CONFIRMATION_BUTTON[profile] + CONFIRMATION_GAP if opened else 240
|
||||
left = x + 172 if profile == 'large' else x - 172 if opened else x - 100
|
||||
top = y - (12 if profile == 'large' else 8)
|
||||
height = CONFIRMATION_HEIGHT[profile] if opened else 152
|
||||
return left, top, left + width * scale, top + height * scale
|
||||
|
||||
|
||||
def confirmation_buttons(profile, bounds):
|
||||
left, top, _, bottom = bounds
|
||||
scale = CONFIRMATION_SCALE[profile]
|
||||
return tuple((left + (CONFIRMATION_PADDING + x) * scale, top + CONFIRMATION_HEADER * scale,
|
||||
left + (CONFIRMATION_PADDING + x + CONFIRMATION_BUTTON) * scale, bottom)
|
||||
for x in (0, CONFIRMATION_BUTTON + CONFIRMATION_GAP))
|
||||
left + (CONFIRMATION_PADDING + x + CONFIRMATION_BUTTON[profile]) * scale, bottom)
|
||||
for x in (0, CONFIRMATION_BUTTON[profile] + CONFIRMATION_GAP))
|
||||
|
||||
|
||||
def confirmation_footprint(profile):
|
||||
@@ -51,7 +55,7 @@ def confirmation_footprint(profile):
|
||||
return {'x': left, 'y': top, 'width': right - left, 'height': bottom - top}
|
||||
|
||||
|
||||
SOURCE_TOP = math.ceil(confirmation_bounds('large', 0, 0)[3]) + GAP
|
||||
SOURCE_TOP = math.ceil(confirmation_bounds('large', 0, 0, opened=True)[3]) + GAP
|
||||
|
||||
|
||||
def card_height(mode):
|
||||
|
||||
@@ -30,7 +30,6 @@ from openpilot.starpilot.ui.layout_preview_sidebar import SIDEBAR_WIDTH, render_
|
||||
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory
|
||||
from openpilot.starpilot.ui.rainbow_path import RainbowPath
|
||||
from openpilot.starpilot.ui.road_colors import edge_gradient, lane_color, path_mode, solid_gradient
|
||||
from openpilot.starpilot.ui.speed_limit_shapes import SPRITES
|
||||
from openpilot.starpilot.ui.onroad_customization import ROAD_COLORS
|
||||
from openpilot.system.ui.lib.shader_polygon import Gradient, draw_polygon
|
||||
|
||||
@@ -190,10 +189,11 @@ class _DriverMonitorArt:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def render(self, _content: rl.Rectangle, state: OnroadState, *, top_icons: bool = False) -> None:
|
||||
if not monitor_visible(self.profile, state, fresh=True, onroad=True, top_icons=top_icons):
|
||||
def render(self, _content: rl.Rectangle, state: OnroadState, *, top_icons: bool = False, y_offset=0) -> None:
|
||||
bounds = monitor_bounds(state.customization, self.profile, state.right_hand_drive)
|
||||
bounds.y += y_offset
|
||||
if not monitor_visible(self.profile, state, fresh=True, onroad=True, top_icons=top_icons, bounds=bounds):
|
||||
return
|
||||
bounds = monitor_bounds(state.customization, self.profile, False)
|
||||
draw_widget_frame(bounds, state.customization, self.profile, "driver_monitor")
|
||||
draw_monitor_art(self.renderer, bounds, self.profile)
|
||||
|
||||
@@ -283,15 +283,7 @@ class _Canvas:
|
||||
state.appearance.camera_view != CameraViewChoice.DRIVER and not state.reverse_driver_camera and
|
||||
self.view.compact_hud._set_speed_alpha.x > 0.01 and
|
||||
placement(state.customization, self.profile, "max_speed")["enabled"])
|
||||
if self.viewport is None:
|
||||
self.monitor.render(rect, state, top_icons=top_icons)
|
||||
else:
|
||||
rl.rl_push_matrix()
|
||||
rl.rl_translatef(0, self.viewport[1] - 1080, 0)
|
||||
try:
|
||||
self.monitor.render(rect, state, top_icons=top_icons)
|
||||
finally:
|
||||
rl.rl_pop_matrix()
|
||||
self.monitor.render(rect, state, top_icons=top_icons, y_offset=self.viewport[1] - 1080 if self.viewport else 0)
|
||||
|
||||
def render(self, state: OnroadState, scene: str) -> bytes:
|
||||
if self.view is None or self.fonts is None or self.target is None:
|
||||
@@ -349,13 +341,10 @@ class _Canvas:
|
||||
crop_y = max(0, math.floor(position['y'] - widget.get('visualInsetTop', 0)))
|
||||
offset_x, offset_y = crop_x - position['x'], crop_y - position['y']
|
||||
width, height = math.ceil(logical_width - offset_x), math.ceil(logical_height - offset_y)
|
||||
if self.profile == Profile.COMPACT and key == 'speed_limit' and state.speed_limit.driver_override_active:
|
||||
sprite = 'override_circle' if state.appearance.use_vienna_sign else 'override_outline'
|
||||
# The override sign is wider than the saved sign footprint.
|
||||
outset = (SPRITES[sprite][2] - logical_width) / 2
|
||||
crop_x = max(0, math.floor(position['x'] - outset))
|
||||
if self.profile == Profile.COMPACT and key == 'speed_limit':
|
||||
crop_x = max(0, math.floor(position['x'] - 2))
|
||||
offset_x = crop_x - position['x']
|
||||
width = min(canvas_width - crop_x, math.ceil(logical_width - offset_x + outset))
|
||||
width = math.ceil(logical_width + 2 - offset_x)
|
||||
if state.speed_limit.pending_speed_limit_mps is not None and (area := widget.get('confirmation')) is not None:
|
||||
crop_x = math.floor(min(crop_x, position['x'] + area['x']))
|
||||
crop_y = math.floor(min(crop_y, position['y'] + area['y']))
|
||||
@@ -396,7 +385,9 @@ class _Canvas:
|
||||
rl.image_format(image, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8)
|
||||
rl.image_flip_vertical(image)
|
||||
rl.image_crop(image, rl.Rectangle(crop_x, crop_y, width, height))
|
||||
captures.append((key, _straight_rgba(bytes(image.data[:width * height * 4]))))
|
||||
data = _straight_rgba(bytes(image.data[:width * height * 4]))
|
||||
sprites[key]['visible'] = any(data[3::4])
|
||||
captures.append((key, data))
|
||||
finally:
|
||||
rl.unload_image(image)
|
||||
sheet_width = max(slot['x'] + slot['width'] for slot in sprites.values())
|
||||
|
||||
@@ -10,8 +10,6 @@ from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
|
||||
@@ -47,19 +45,6 @@ OverlayLayer = Callable[[rl.Rectangle, OnroadState], None]
|
||||
PipLayer = Callable[[rl.Rectangle, OnroadState], None]
|
||||
ProjectionControlLayer = Callable[[OnroadState], None]
|
||||
|
||||
def _load_fade(path: Path) -> rl.Texture:
|
||||
entry = next(item for item in json.loads(Path(__file__).with_name('onroad-assets.json').read_text())['files']
|
||||
if Path(item['file']).name == path.name)
|
||||
with path.open('rb') as stream:
|
||||
data = stream.read(entry['bytes'] + 1)
|
||||
if len(data) != entry['bytes'] or hashlib.sha256(data).hexdigest() != entry['sha256']:
|
||||
raise ValueError(f'Unreviewed onroad fade art: {path.name}')
|
||||
texture = rl.load_texture(str(path))
|
||||
if not texture.id:
|
||||
raise RuntimeError(f'Unable to load onroad fade art: {path.name}')
|
||||
return texture
|
||||
|
||||
|
||||
def driving_mode_description(state: OnroadState) -> str:
|
||||
if state.longitudinal_overridden:
|
||||
return "ACC - Override"
|
||||
@@ -159,12 +144,6 @@ class OnroadView:
|
||||
self.compact_hud = CompactHudRenderer(fonts, asset_directory)
|
||||
self.compact_sidebar = MiciSidebarWidgets(fonts)
|
||||
self.personality = self.compact_sidebar.personality
|
||||
self._fade_path = asset_directory / "icons_mici/onroad/onroad_fade.png"
|
||||
self._fade: rl.Texture | None = None
|
||||
|
||||
def _prepare_compact_fade(self) -> None:
|
||||
if self._fade is None:
|
||||
self._fade = _load_fade(self._fade_path)
|
||||
|
||||
def render(self, state: OnroadState) -> None:
|
||||
if state.camera_available and self.camera_layer is None:
|
||||
@@ -223,6 +202,9 @@ class OnroadView:
|
||||
state.appearance.camera_view == CameraViewChoice.DRIVER or state.reverse_driver_camera):
|
||||
return
|
||||
width, height = widget_size(state.customization, profile, MODE_WIDGET)
|
||||
x, y = position["x"] + center_shift, position["y"]
|
||||
if slc_action_occludes(profile, state, (x, y, x + width, y + height)):
|
||||
return
|
||||
size = 40 if profile == Profile.LARGE else 18
|
||||
role = FontRole.SEMI_BOLD
|
||||
lines = [""]
|
||||
@@ -260,6 +242,10 @@ class OnroadView:
|
||||
measured = self.fonts.measure(text, role, size)
|
||||
x = position["x"] + center_shift + (width - measured.width) / 2
|
||||
y = position["y"] + (height - measured.height) / 2
|
||||
left, right = self.fonts.horizontal_ink(text, role, size)
|
||||
top, bottom = self.fonts.vertical_ink(text, role, size)
|
||||
if slc_action_occludes(profile, state, (x + left, y + top, x + right, y + bottom)):
|
||||
return
|
||||
color = rl.Color(*rgba(state.customization, "text", profile, CLOCK_WIDGET))
|
||||
offset_px = 2 if profile == Profile.LARGE else 1
|
||||
self.fonts.draw(text, role, size, x + offset_px, y + offset_px, rl.Color(0, 0, 0, min(color.a, 170)))
|
||||
@@ -269,6 +255,11 @@ class OnroadView:
|
||||
if (not placement(state.customization, self.fonts.profile, 'weather')['enabled'] or
|
||||
state.alert.size != AlertSize.NONE or state.appearance.camera_view == CameraViewChoice.DRIVER or state.reverse_driver_camera):
|
||||
return
|
||||
position = placement(state.customization, self.fonts.profile, 'weather')
|
||||
width, height = widget_size(state.customization, self.fonts.profile, 'weather')
|
||||
x, y = position['x'] + center_shift, position['y']
|
||||
if slc_action_occludes(self.fonts.profile, state, (x, y, x + width, y + height)):
|
||||
return
|
||||
from openpilot.starpilot.ui.weather import WeatherClient
|
||||
from openpilot.starpilot.ui.onroad_weather import render_weather
|
||||
if not hasattr(self, '_weather_client'):
|
||||
@@ -370,7 +361,6 @@ class OnroadView:
|
||||
self.background_layer(content, state)
|
||||
ordered = bool(state.customization.get("widgetOrder", {}).get("large"))
|
||||
if ordered:
|
||||
rl.draw_rectangle_gradient_v(30, 30, int(content.width), 300, rl.Color(0, 0, 0, 114), rl.BLANK)
|
||||
self._ordered_widgets(content, state)
|
||||
if self.extra_overlays is not None:
|
||||
self.extra_overlays(content, state)
|
||||
@@ -381,7 +371,6 @@ class OnroadView:
|
||||
map_layer = getattr(self, "map_layer", None)
|
||||
if map_layer is not None and state.alert.size != AlertSize.FULL:
|
||||
map_layer(content, state)
|
||||
rl.draw_rectangle_gradient_v(30, 30, int(content.width), 300, rl.Color(0, 0, 0, 114), rl.BLANK)
|
||||
if state.alert.size != AlertSize.FULL:
|
||||
if placement(state.customization, "large", "cruise_limits")["enabled"]:
|
||||
self.unified_speed.render(content, state)
|
||||
@@ -486,8 +475,6 @@ class OnroadView:
|
||||
self.background_layer(frame, state)
|
||||
ordered = bool(state.customization.get("widgetOrder", {}).get("compact"))
|
||||
if ordered:
|
||||
self._prepare_compact_fade()
|
||||
rl.draw_texture_ex(self._fade, rl.Vector2(0, 0), 0, 1, rl.WHITE)
|
||||
clip.end_scissor_mode()
|
||||
clip.begin_scissor_mode(0, 0, 536, 240)
|
||||
self._ordered_widgets(frame, state, driver_camera=driver_camera, stock_layer=stock_layer if use_stock else None)
|
||||
@@ -500,8 +487,6 @@ class OnroadView:
|
||||
if pip_layer is not None and state.camera_available and not driver_camera and \
|
||||
state.appearance.camera_view != CameraViewChoice.NONE:
|
||||
pip_layer(frame, state)
|
||||
self._prepare_compact_fade()
|
||||
rl.draw_texture_ex(self._fade, rl.Vector2(0, 0), 0, 1, rl.WHITE)
|
||||
if state.alert.size == AlertSize.NONE and not driver_camera:
|
||||
self.compact_hud.render(state)
|
||||
if placement(state.customization, "compact", "torque_bar")["enabled"]:
|
||||
@@ -578,6 +563,3 @@ class OnroadView:
|
||||
self.steering_wheel.close()
|
||||
else:
|
||||
self.compact_hud.close()
|
||||
if self._fade is not None and rl.is_window_ready():
|
||||
rl.unload_texture(self._fade)
|
||||
self._fade = None
|
||||
|
||||
@@ -18,7 +18,7 @@ from openpilot.starpilot.ui.onroad_conditional import reason as conditional_reas
|
||||
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
|
||||
from openpilot.starpilot.ui.onroad_widget_style import draw_widget_frame
|
||||
from openpilot.starpilot.ui.wheel_feedback import WheelFeedbackFade
|
||||
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
|
||||
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget, draw_caption, draw_ink, set_speed_status, speed_status
|
||||
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
|
||||
from openpilot.starpilot.ui.moon import draw_moon
|
||||
from openpilot.starpilot.ui.onroad_personality import PersonalityWidget
|
||||
@@ -135,27 +135,28 @@ class CompactHudRenderer:
|
||||
self.render_steering_wheel(state)
|
||||
|
||||
def render_max_speed(self, state: OnroadState) -> None:
|
||||
slc_target = resolve_unified_speed(state).slc_target_text
|
||||
set_speed_alpha = self._set_speed_opacity(state, (state.observed_ns or time.monotonic_ns()), slc_active=slc_target is not None)
|
||||
cruise = "–" if state.cruise_kph is None else str(round(state.cruise_kph if state.metric else state.cruise_kph * 0.621371))
|
||||
shown = resolve_unified_speed(state)
|
||||
status = set_speed_status(state, Profile.COMPACT, shown)
|
||||
persistent = shown.slc_target_text is not None or shown.override_text is not None or speed_status(state, Profile.COMPACT, shown) is not None
|
||||
set_speed_alpha = self._set_speed_opacity(state, (state.observed_ns or time.monotonic_ns()), slc_active=persistent)
|
||||
cruise = shown.max_text
|
||||
mx, my = widget_offset(state.customization, "compact", "max_speed")
|
||||
center = widget_size(state.customization, 'compact', 'max_speed')[0] / 2 + mx
|
||||
if set_speed_alpha > 0.01 and placement(state.customization, "compact", "max_speed")["enabled"]:
|
||||
shadow = rl.Color(0, 0, 0, int(255 / 2 * set_speed_alpha))
|
||||
try:
|
||||
rl.draw_circle_gradient(rl.Vector2(81 + mx, 81 + my), 81, shadow, rl.BLANK)
|
||||
except (RuntimeError, TypeError):
|
||||
rl.draw_circle_gradient(int(81 + mx), int(81 + my), 81, shadow, rl.BLANK)
|
||||
red, green, blue, alpha = rgba(state.customization, "text", "compact", "max_speed")
|
||||
color = rl.Color(red, green, blue, int(alpha * 0.9 * set_speed_alpha))
|
||||
speed_size = 112
|
||||
while speed_size > 72 and self.fonts.measure(cruise, FontRole.SET_SPEED, speed_size).width > 128:
|
||||
speed_size -= 1
|
||||
self.fonts.draw(cruise, FontRole.SET_SPEED, speed_size, 17 + mx, -4 + my, color)
|
||||
self.fonts.draw("SET" if slc_target is not None else "MAX", FontRole.SEMI_BOLD, 36, 25 + mx, 109 + my, color)
|
||||
if slc_target is not None:
|
||||
label = f'SLC {slc_target}'
|
||||
top, _ = self.fonts.vertical_ink(label, FontRole.SEMI_BOLD, 22)
|
||||
self.fonts.draw(label, FontRole.SEMI_BOLD, 22, 25 + mx, 145 + my - top, color)
|
||||
color = rl.Color(red, green, blue, int(alpha * set_speed_alpha))
|
||||
draw_caption(self.fonts, 'SET', center, 20 + my, color, unit=shown.unit, size=24, centered=True)
|
||||
size = 96
|
||||
left, right = self.fonts.horizontal_ink(cruise, FontRole.SET_SPEED, size)
|
||||
size *= min(1, 128 / max(1, right - left))
|
||||
left, right = self.fonts.horizontal_ink(cruise, FontRole.SET_SPEED, size)
|
||||
draw_ink(self.fonts, cruise, FontRole.SET_SPEED, size, center - (left + right) / 2, 46 + my, color)
|
||||
if status and placement(state.customization, 'compact', 'max_speed')['enabled']:
|
||||
color = rl.Color(241, 246, 243, 255)
|
||||
left, right = self.fonts.horizontal_ink(status, FontRole.SEMI_BOLD, 24)
|
||||
size = 24 * min(1, 128 / max(1, right - left))
|
||||
left, right = self.fonts.horizontal_ink(status, FontRole.SEMI_BOLD, size)
|
||||
draw_ink(self.fonts, status, FontRole.SEMI_BOLD, size, center - (left + right) / 2, 128 + my, color)
|
||||
|
||||
def render_steering_wheel(self, state: OnroadState) -> None:
|
||||
color = self._feedback.update(state.wheel_acceleration, state.appearance.wheel_pedal_feedback, state.observed_ns, state.drive_frame)
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
|
||||
from openpilot.starpilot.ui.large_speed_geometry import confirmation_bounds, confirmation_footprint
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LIMIT_RECTS, confirmation_bounds, confirmation_footprint
|
||||
from openpilot.starpilot.ui.onroad_stopped_timer import timer_size
|
||||
from openpilot.starpilot.ui.onroad_favorites_geometry import shelf_metadata
|
||||
from openpilot.starpilot.ui.large_speed_geometry import (WIDTH, CARD_HEIGHT, ACTION_WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y,
|
||||
@@ -104,7 +104,7 @@ PROFILES: dict[str, LayoutProfile] = {
|
||||
"reservedZones": [],
|
||||
"widgets": {
|
||||
"max_speed": _widget("Set speed", "max_speed", 146, 162, 0, 0),
|
||||
"speed_limit": _widget("Speed limit", "speed_limit", 118, 132, 330, 20),
|
||||
"speed_limit": _widget("Speed limit", "speed_limit", *LIMIT_RECTS['compact'][2:], *LIMIT_RECTS['compact'][:2]),
|
||||
"speed_limit_actions": _widget("Speed limit actions", "speed_limit_actions", 184, 120, 146, 44),
|
||||
"steering_wheel": _widget("Steering wheel", "steering_wheel", 50, 50, 21, 176),
|
||||
"driver_monitor": _widget("Driver monitoring", "driver_monitor", 60, 60, 16, 10)}}}
|
||||
@@ -247,9 +247,9 @@ _torque['confirmationObstacle'] = {
|
||||
# These editor sprites use the runtime components and their canonical assets/fonts.
|
||||
RUNTIME_PREVIEW_WIDGETS = {
|
||||
"large": (WEATHER_WIDGET, "current_speed", "cruise_limits", CEM_GAUGE_WIDGET, "steering_wheel", "following_distance",
|
||||
"driver_monitor", "speed_limit_actions"),
|
||||
"driver_monitor", "speed_limit_actions", MODE_WIDGET, CLOCK_WIDGET),
|
||||
"compact": (WEATHER_WIDGET, "max_speed", "speed_limit", "steering_wheel", "conditional_mode", "following_distance",
|
||||
"model_confidence", "driver_monitor", "speed_limit_actions"),
|
||||
"model_confidence", "driver_monitor", "speed_limit_actions", MODE_WIDGET, CLOCK_WIDGET),
|
||||
}
|
||||
|
||||
_FRAME_COLORS = {"cardFill": "#00000000", "cardBorder": "#00000000"}
|
||||
@@ -342,7 +342,7 @@ def customization_metadata():
|
||||
|
||||
|
||||
def default_document():
|
||||
return {"version": 12, "clock24Hour": False, "reduceMotion": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES},
|
||||
return {"version": 13, "clock24Hour": False, "reduceMotion": 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()}
|
||||
@@ -450,14 +450,15 @@ def migrate_confirmation_layout(layout, profile, widgets, bounds, *, extra_heigh
|
||||
owner = 'cruise_limits' if profile == 'large' else 'speed_limit'
|
||||
bounds = widgets[owner].get('bounds', bounds)
|
||||
area, at = confirmation_footprint(profile), layout[owner]
|
||||
if not at['enabled'] or not layout['speed_limit_actions']['enabled']:
|
||||
return
|
||||
confirming = at['enabled'] and layout['speed_limit_actions']['enabled']
|
||||
actions = layout['speed_limit_actions']
|
||||
attached = profile == 'large' and actions['x'] == at['x'] + ACTION_X - 88 and actions['y'] == at['y'] + ACTION_Y - 75
|
||||
for axis, extent in (('x', 'width'), ('y', 'height')):
|
||||
low = max(bounds[axis], bounds[axis] - area[axis])
|
||||
high = min(bounds[axis] + bounds[extent] - widgets[owner][extent],
|
||||
bounds[axis] + bounds[extent] - area[axis] - area[extent])
|
||||
low = bounds[axis]
|
||||
high = bounds[axis] + bounds[extent] - widgets[owner][extent]
|
||||
if confirming:
|
||||
low = max(low, bounds[axis] - area[axis])
|
||||
high = min(high, bounds[axis] + bounds[extent] - area[axis] - area[extent])
|
||||
if axis == 'y':
|
||||
high = min(high, bounds['y'] + bounds['height'] - widgets[owner]['height'] - extra_height)
|
||||
at[axis] = max(math.ceil(low), min(at[axis], math.floor(high)))
|
||||
@@ -472,7 +473,7 @@ def migrate_confirmation_layout(layout, profile, widgets, bounds, *, extra_heigh
|
||||
|
||||
|
||||
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, 13):
|
||||
if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 14):
|
||||
raise ValueError("Invalid customization version")
|
||||
fields = {"version", "palette", "layouts"} | ({"widgetColors"} if value["version"] >= 2 else set())
|
||||
if value["version"] >= 3:
|
||||
@@ -661,6 +662,11 @@ def validate_document(value):
|
||||
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)
|
||||
if dimensions is None and value['version'] < 13:
|
||||
if profile == 'large' and key == 'cruise_limits':
|
||||
dimensions = (WIDTH, 168)
|
||||
elif profile == 'compact' and key == 'speed_limit':
|
||||
dimensions = (118, 132)
|
||||
if dimensions is None and value['version'] < 11 and key == 'speed_limit_actions':
|
||||
default = widget['default']
|
||||
current_default = (placement['x'], placement['y']) == (default['x'], default['y'])
|
||||
@@ -728,11 +734,16 @@ def validate_document(value):
|
||||
extra_height=sources_extent(result) if profile == 'large' else 0)
|
||||
return validate_document(result)
|
||||
for profile, data in PROFILES.items():
|
||||
if value['version'] < 12:
|
||||
if value['version'] < 13:
|
||||
if profile == 'compact':
|
||||
sign = result['layouts'][profile]['speed_limit']
|
||||
if (sign['x'], sign['y']) == (330, 20):
|
||||
sign.update(x=LIMIT_RECTS['compact'][0], y=LIMIT_RECTS['compact'][1])
|
||||
if profile == 'large' and result.get('speedSources') is True:
|
||||
card, actions = result['layouts'][profile]['cruise_limits'], result['layouts'][profile]['speed_limit_actions']
|
||||
old_extra = SOURCE_ROW_HEIGHT + (GAP if result.get('speedSourcesCard') is True else 0)
|
||||
if actions['x'] == card['x'] + ACTION_X - 88 and actions['y'] == card['y'] + ACTION_Y - 75 + old_extra:
|
||||
old_gap = 284 if value['version'] == 12 else ACTION_Y - 75 + SOURCE_ROW_HEIGHT
|
||||
old_gap += GAP if result.get('speedSourcesCard') is True else 0
|
||||
if actions['x'] == card['x'] + ACTION_X - 88 and actions['y'] == card['y'] + old_gap:
|
||||
actions['y'] = card['y'] + ACTION_Y - 75
|
||||
migrate_confirmation_layout(result['layouts'][profile], profile, data['widgets'], data['bounds'],
|
||||
extra_height=sources_extent(result) if profile == 'large' else 0)
|
||||
|
||||
@@ -48,10 +48,14 @@ def draw_monitor_art(renderer, bounds, profile):
|
||||
rl.rl_pop_matrix()
|
||||
|
||||
|
||||
def monitor_visible(profile, state, *, fresh, onroad, top_icons):
|
||||
def monitor_visible(profile, state, *, fresh, onroad, top_icons, bounds=None):
|
||||
from openpilot.starpilot.ui.onroad_state import slc_action_occludes
|
||||
|
||||
monitor = bounds if bounds is not None else monitor_bounds(state.customization, profile, state.right_hand_drive)
|
||||
if slc_action_occludes(profile, state, (monitor.x, monitor.y, monitor.x + monitor.width, monitor.y + monitor.height)):
|
||||
return False
|
||||
occluded = False
|
||||
if profile == Profile.COMPACT and top_icons:
|
||||
monitor = monitor_bounds(state.customization, profile, False)
|
||||
speed = placement(state.customization, profile, 'max_speed')
|
||||
widget = PROFILES['compact']['widgets']['max_speed']
|
||||
occluded = rl.check_collision_recs(monitor, rl.Rectangle(speed['x'], speed['y'], widget['width'], widget['height']))
|
||||
@@ -86,12 +90,13 @@ class DriverMonitorLayer:
|
||||
self.renderer.set_rect(rl.Rectangle(0, 0, 128, 128))
|
||||
self.renderer.load_icons()
|
||||
|
||||
def render(self, state, *, monitor, driver, fresh, onroad, top_icons=False):
|
||||
def render(self, state, *, monitor, driver, fresh, onroad, top_icons=False, y_offset=0):
|
||||
ready = fresh and valid_observation(monitor, driver)
|
||||
visible = monitor_visible(self.profile, state, fresh=ready, onroad=onroad, top_icons=top_icons)
|
||||
self.renderer.set_should_draw(visible)
|
||||
is_rhd = monitor.isRHD if ready else getattr(self.renderer, '_is_rhd', False)
|
||||
bounds = monitor_bounds(state.customization, self.profile, is_rhd)
|
||||
bounds.y += y_offset
|
||||
visible = monitor_visible(self.profile, state, fresh=ready, onroad=onroad, top_icons=top_icons, bounds=bounds)
|
||||
self.renderer.set_should_draw(visible)
|
||||
self.renderer.set_position(bounds.x, bounds.y)
|
||||
if ready:
|
||||
self.renderer._update_state()
|
||||
|
||||
@@ -11,12 +11,12 @@ import json
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
|
||||
from openpilot.starpilot.ui.onroad_customization import offset, rgba, widget_size
|
||||
from openpilot.starpilot.ui.onroad_state import OnroadState, manual_slc_confirmation, speed_limit_bounds
|
||||
from openpilot.starpilot.ui.onroad_state import OnroadState, manual_slc_confirmation, speed_limit_bounds, slc_action_occludes
|
||||
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
|
||||
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
|
||||
from openpilot.starpilot.ui.speed_limit_widget import MUTED, SpeedLimitWidget, draw_caption, draw_ink, draw_line, draw_text
|
||||
from openpilot.starpilot.ui.speed_limit_widget import MUTED, SpeedLimitWidget, draw_caption, draw_ink, draw_line, draw_text, set_speed_status
|
||||
|
||||
|
||||
ENGAGED = rl.Color(128, 216, 166, 255)
|
||||
@@ -26,7 +26,7 @@ DISENGAGED = rl.Color(145, 155, 149, 255)
|
||||
def _center(fonts: BitmapFonts, text: str, role: FontRole, size: int, rect: rl.Rectangle, y: float,
|
||||
color: rl.Color = rl.WHITE) -> None:
|
||||
measured = fonts.measure(text, role, size)
|
||||
fonts.draw(text, role, size, rect.x + (rect.width - measured.width) / 2, y, color)
|
||||
draw_text(fonts, text, role, size, rect.x + (rect.width - measured.width) / 2, y, color)
|
||||
|
||||
|
||||
class UnifiedSpeedWidget:
|
||||
@@ -80,33 +80,29 @@ class UnifiedSpeedWidget:
|
||||
text = rl.Color(*rgba(state.customization, 'text', 'large', 'cruise_limits'))
|
||||
if shown.mode != 'limit_only':
|
||||
confirming = manual_slc_confirmation(Profile.LARGE, state)
|
||||
center = rect.x + 120
|
||||
x = rect.x + PADDING if confirming else center
|
||||
draw_caption(self.fonts, 'SET', x, rect.y + 12, unit=shown.unit, centered=not confirming)
|
||||
if shown.max_text == '–':
|
||||
center = rect.x + 120
|
||||
draw_caption(self.fonts, 'SET', center, rect.y + 14, unit=shown.unit, centered=True)
|
||||
draw_line(Profile.LARGE, rl.Vector2(center - 28, rect.y + 101), rl.Vector2(center + 28, rect.y + 101), 8, MUTED)
|
||||
elif confirming:
|
||||
x = rect.x + PADDING
|
||||
draw_caption(self.fonts, 'SET', x, rect.y + 19, unit=shown.unit, size=32)
|
||||
size = 120
|
||||
while size > 72 and self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width > 148:
|
||||
size -= 1
|
||||
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, x, rect.y + 51, text)
|
||||
draw_line(Profile.LARGE, rl.Vector2(center - 28, rect.y + 90), rl.Vector2(center + 28, rect.y + 90), 8, MUTED)
|
||||
else:
|
||||
center = rect.x + 120
|
||||
draw_caption(self.fonts, 'SET', center, rect.y + 14, unit=shown.unit, centered=True)
|
||||
size = 108
|
||||
size *= min(1, 200 / max(1, self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width))
|
||||
size = 100
|
||||
size *= min(1, (148 if confirming else 200) / max(1, self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width))
|
||||
left, right = self.fonts.horizontal_ink(shown.max_text, FontRole.SET_SPEED, size)
|
||||
top, bottom = self.fonts.vertical_ink(shown.max_text, FontRole.SET_SPEED, size)
|
||||
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, center - (left + right) / 2,
|
||||
rect.y + 101 - (bottom - top) / 2, text)
|
||||
if shown.slc_target_text is not None:
|
||||
label = f'SLC {shown.slc_target_text}'
|
||||
width = self.fonts.measure(label, FontRole.SEMI_BOLD, 22).width
|
||||
x = rect.x + (PADDING if confirming else 120 - width / 2)
|
||||
draw_ink(self.fonts, label, FontRole.SEMI_BOLD, 22, x, rect.y + 148, text)
|
||||
number_x = x + self.fonts.horizontal_ink('SET', FontRole.MEDIUM, 34)[0] - left if confirming else center - (left + right) / 2
|
||||
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, number_x, rect.y + 90 - (bottom - top) / 2, text)
|
||||
if shown.mode != 'limit_only' and (label := set_speed_status(state, Profile.LARGE, shown)):
|
||||
confirming = manual_slc_confirmation(Profile.LARGE, state)
|
||||
size = 24
|
||||
left, right = self.fonts.horizontal_ink(label, FontRole.SEMI_BOLD, size)
|
||||
size *= min(1, (148 if confirming else 200) / max(1, right - left))
|
||||
left, right = self.fonts.horizontal_ink(label, FontRole.SEMI_BOLD, size)
|
||||
x = rect.x + (PADDING + self.fonts.horizontal_ink('SET', FontRole.MEDIUM, 34)[0] - left if confirming else 120 - (left + right) / 2)
|
||||
draw_ink(self.fonts, label, FontRole.SEMI_BOLD, size, x, rect.y + 136, text)
|
||||
self.sign.render(state, defer_confirmation=True)
|
||||
if shown.mode == 'limit_only' and (bounds := speed_limit_bounds(Profile.LARGE, state)) is not None:
|
||||
if (shown.mode == 'limit_only' and not manual_slc_confirmation(Profile.LARGE, state) 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 + max(height, SOURCE_TOP) + (GAP if separate else 0), WIDTH, SOURCE_ROW_HEIGHT)
|
||||
@@ -129,13 +125,22 @@ class CurrentSpeedHud:
|
||||
rect = rl.Rectangle(content.x + 610 + dx, content.y + dy, 580, 300)
|
||||
number_top, _ = self.fonts.vertical_ink(speed, FontRole.SPEED, 176)
|
||||
old_top, old_bottom = self.fonts.vertical_ink(speed, FontRole.BOLD, 176)
|
||||
_center(self.fonts, speed, FontRole.SPEED, 176, rect, content.y + 42 + dy + old_top - number_top,
|
||||
rl.Color(*rgba(state.customization, 'text', 'large', 'current_speed')))
|
||||
unit = 'km/h' if state.metric else 'mph'
|
||||
unit_top, _ = self.fonts.vertical_ink(unit, FontRole.NORMAL, 28)
|
||||
red, green, blue, alpha = rgba(state.customization, 'text', 'large', 'current_speed')
|
||||
_center(self.fonts, unit, FontRole.NORMAL, 28, rect, content.y + 42 + dy + old_bottom + 20 - unit_top,
|
||||
rl.Color(red, green, blue, int(alpha * 200 / 255)))
|
||||
rows = ((speed, FontRole.SPEED, 176, content.y + 42 + dy + old_top - number_top, rl.Color(red, green, blue, alpha)),
|
||||
(unit, FontRole.NORMAL, 28, content.y + 42 + dy + old_bottom + 20 - unit_top,
|
||||
rl.Color(red, green, blue, int(alpha * 200 / 255))))
|
||||
if manual_slc_confirmation(Profile.LARGE, state):
|
||||
for text, role, size, y, _ in rows:
|
||||
x = rect.x + (rect.width - self.fonts.measure(text, role, size).width) / 2
|
||||
left, right = self.fonts.horizontal_ink(text, role, size)
|
||||
top, bottom = self.fonts.vertical_ink(text, role, size)
|
||||
if slc_action_occludes(Profile.LARGE, state, (x + left, y + top, x + right, y + bottom)):
|
||||
return
|
||||
for text, role, size, y, color in rows:
|
||||
_center(self.fonts, text, role, size, rect, y, color)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""One cached atlas for speed-limit signs and confirmation surfaces."""
|
||||
"""One cached atlas for transparent speed signs, buttons, arrows and wheel cues."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -6,21 +6,16 @@ from openpilot.starpilot.ui.raylib import rl
|
||||
|
||||
|
||||
ATLAS_PATH = Path(__file__).with_name('assets') / 'speed_limit_shapes.png'
|
||||
ATLAS_SIZE = (384, 612)
|
||||
ATLAS_SIZE = (736, 688)
|
||||
SPRITES = {
|
||||
'disc': (0, 0, 128, 128), 'ring': (128, 0, 128, 128),
|
||||
'ring': (128, 0, 128, 128),
|
||||
'arrow_large': (0, 128, 32, 40), 'arrow_compact': (32, 128, 32, 48),
|
||||
'outline': (256, 0, 128, 144),
|
||||
'confirmation': (0, 176, 104, 308), 'confirmation_open': (104, 176, 72, 308),
|
||||
'confirmation_pulse': (176, 176, 104, 136),
|
||||
'outline': (256, 0, 136, 152),
|
||||
'wheel_plus': (280, 176, 48, 48), 'wheel_minus': (328, 176, 48, 48),
|
||||
'arrow_confirmation': (280, 224, 32, 48),
|
||||
'override_outline': (0, 484, 136, 98),
|
||||
'override_circle': (136, 484, 124, 124),
|
||||
'override_slash': (260, 484, 48, 8),
|
||||
'button_large': (0, 224, 368, 248), 'button_large_pressed': (368, 224, 368, 248),
|
||||
'button_compact': (0, 472, 308, 216), 'button_compact_pressed': (308, 472, 308, 216),
|
||||
}
|
||||
SPRITE_DENSITIES = {'arrow_compact': 2, 'confirmation': 2, 'confirmation_open': 2, 'confirmation_pulse': 2,
|
||||
'wheel_plus': 2, 'wheel_minus': 2, 'arrow_confirmation': 2}
|
||||
SPRITE_DENSITIES = {name: 2 for name in SPRITES if name.startswith('button') or name in ('arrow_compact', 'wheel_plus', 'wheel_minus')}
|
||||
|
||||
|
||||
class SpeedLimitShapes:
|
||||
@@ -28,7 +23,6 @@ class SpeedLimitShapes:
|
||||
self.scale = scale
|
||||
self._headless_context = headless_context
|
||||
self.texture = None
|
||||
self.short_outline = None
|
||||
self.sources = {name: rl.Rectangle(*rect) for name, rect in SPRITES.items()}
|
||||
self.origins = {name: rl.Vector2(rect[2] * scale / SPRITE_DENSITIES.get(name, 1) / 2,
|
||||
rect[3] * scale / SPRITE_DENSITIES.get(name, 1) / 2) for name, rect in SPRITES.items()}
|
||||
@@ -65,60 +59,26 @@ class SpeedLimitShapes:
|
||||
origin, rotation, color)
|
||||
|
||||
def circle(self, x, y, color):
|
||||
self._draw('disc', x, y, rl.WHITE)
|
||||
self._draw('ring', x, y, color)
|
||||
self._outlined('ring', x, y, color)
|
||||
|
||||
def override_sign(self, x, y, *, vienna):
|
||||
self._draw('override_circle' if vienna else 'override_outline', x, y, rl.WHITE)
|
||||
def _outlined(self, name, x, y, color, *, scale=None):
|
||||
radius = self.scale if scale is None else scale
|
||||
for dx, dy in ((-radius, 0), (radius, 0), (0, -radius), (0, radius)):
|
||||
self._draw(name, x + dx, y + dy, rl.Color(6, 14, 10, 230), scale=scale)
|
||||
self._draw(name, x, y, color, scale=scale)
|
||||
|
||||
def override_slash(self, x, y, width, color):
|
||||
self._load()
|
||||
source = self.sources['override_slash']
|
||||
height = source.height * self.scale
|
||||
rl.draw_texture_pro(self.texture, source, rl.Rectangle(x, y, width, height),
|
||||
rl.Vector2(width / 2, height / 2), -11, color)
|
||||
def outline(self, x, y, color):
|
||||
self._outlined('outline', x, y, color)
|
||||
|
||||
def outline(self, x, y, color, *, short=False):
|
||||
if not short:
|
||||
self._draw('outline', x, y, color)
|
||||
return
|
||||
self._load()
|
||||
if self.short_outline is None:
|
||||
source = self.sources['outline']
|
||||
# Remove only the straight middle: corners and border thickness stay intact.
|
||||
self.short_outline = ((rl.Rectangle(source.x, source.y, source.width, 64), -64),
|
||||
(rl.Rectangle(source.x, source.y + 80, source.width, 64), 0))
|
||||
origin = rl.Vector2(64 * self.scale, 0)
|
||||
for source, dy in self.short_outline:
|
||||
rl.draw_texture_pro(self.texture, source,
|
||||
rl.Rectangle(x, y + dy * self.scale, source.width * self.scale, source.height * self.scale),
|
||||
origin, 0, color)
|
||||
def button(self, x, y, color, *, profile, scale, pressed=False):
|
||||
self._draw(f'button_{profile}' + ('_pressed' if pressed else ''), x, y, color, scale=scale)
|
||||
|
||||
def arrow(self, size, x, y, direction, color, *, scale=None):
|
||||
name = 'arrow_large' if size == 'large' else 'arrow_confirmation' if size == 'confirmation' else 'arrow_compact'
|
||||
name = 'arrow_large' if size == 'large' else 'arrow_compact'
|
||||
self._draw(name, x, y, color, 180 if direction < 0 else 0, scale=scale)
|
||||
|
||||
def wheel_key(self, plus, x, y, *, scale):
|
||||
self._draw('wheel_plus' if plus else 'wheel_minus', x, y, rl.WHITE, scale=scale)
|
||||
|
||||
def confirmation(self, bounds, *, opened=False, strength=0):
|
||||
self._load()
|
||||
x, y, right, bottom = bounds
|
||||
scale = (bottom - y) / 152
|
||||
layers = [('confirmation_open' if opened else 'confirmation', y, 255)]
|
||||
if not opened and strength > 0:
|
||||
layers.append(('confirmation_pulse', y + 86 * scale, round(255 * strength)))
|
||||
for name, top, alpha in layers:
|
||||
source = self.sources[name]
|
||||
texel_scale = scale / SPRITE_DENSITIES[name]
|
||||
cap = (source.width - 8) / 2
|
||||
# Stretch only the flat middle; keep the two-pixel atlas padding out of the quad.
|
||||
for sx, sw, dx, dw in ((source.x + 2, cap, x, cap * texel_scale),
|
||||
(source.x + 2 + cap, 4, x + cap * texel_scale, right - x - 2 * cap * texel_scale),
|
||||
(source.x + source.width - 2 - cap, cap, right - cap * texel_scale, cap * texel_scale)):
|
||||
rl.draw_texture_pro(self.texture, rl.Rectangle(sx, source.y + 2, sw, source.height - 4),
|
||||
rl.Rectangle(dx, top, dw, (source.height - 4) * texel_scale),
|
||||
rl.Vector2(0, 0), 0, rl.Color(255, 255, 255, alpha))
|
||||
self._outlined('wheel_plus' if plus else 'wheel_minus', x, y, rl.WHITE, scale=scale)
|
||||
|
||||
def close(self):
|
||||
if self.texture is not None and (rl.is_window_ready() or self._headless_context):
|
||||
|
||||
@@ -6,18 +6,20 @@ from typing import cast
|
||||
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, CONFIRMATION_SCALE, CONFIRMATION_PADDING
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_WIDTH, SIGN_HEIGHT, CONFIRMATION_SCALE
|
||||
from openpilot.starpilot.ui.onroad_customization import placement
|
||||
from openpilot.starpilot.ui.onroad_state import OnroadState, manual_slc_confirmation, speed_limit_bounds, slc_controls, slc_accept_available
|
||||
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.stop_sign_widget import render_stop_sign
|
||||
from openpilot.starpilot.ui.unified_speed_presentation import displayed_adjusted_limit_mps, positive, resolve_unified_speed
|
||||
|
||||
|
||||
INK = rl.Color(241, 246, 243, 255)
|
||||
MUTED = rl.Color(190, 208, 202, 255)
|
||||
IN_USE = rl.Color(128, 216, 166, 255)
|
||||
OVERRIDE = rl.Color(214, 188, 240, 255)
|
||||
REJECT = rl.Color(242, 139, 139, 255)
|
||||
SOURCE_LABELS = {'dashboard': 'DASH', 'map': 'OSM', 'vision': 'VISION', 'mapbox': 'NAV'}
|
||||
|
||||
|
||||
@@ -25,11 +27,9 @@ def draw_text(fonts, text, role, size, x, y, color=INK):
|
||||
if isinstance(color, (tuple, list)):
|
||||
color = rl.Color(*color)
|
||||
radius = 1.15 if fonts.profile == Profile.LARGE else 1.
|
||||
contour = rl.Color(6, 14, 10, round(color.a * .90))
|
||||
contour = rl.Color(6, 14, 10, round(color.a * .95))
|
||||
for dx, dy in ((-radius, 0), (radius, 0), (0, -radius), (0, radius)):
|
||||
fonts.draw(text, role, size, x + dx, y + dy, contour)
|
||||
fonts.draw(text, role, size, x, y + (1.5 if fonts.profile == Profile.LARGE else 1.2),
|
||||
rl.Color(7, 16, 12, round(color.a * .32)))
|
||||
fonts.draw(text, role, size, x, y, color)
|
||||
|
||||
|
||||
@@ -65,6 +65,34 @@ def draw_caption(fonts, label, x, top, color=MUTED, *, unit=None, size=None, cen
|
||||
return width, height
|
||||
|
||||
|
||||
def speed_status(state, profile, shown):
|
||||
"""The current controller state, independent of where its label is drawn."""
|
||||
obs = state.speed_limit
|
||||
if obs.action_enabled and (state.longitudinal_overridden or
|
||||
state.longitudinal_active and obs.driver_override_active and obs.override_basis == 'pedal'):
|
||||
return 'PEDAL'
|
||||
if shown.pending or speed_limit_bounds(profile, state) is None:
|
||||
if shown.override_text is not None:
|
||||
return f'OVERRIDE {shown.override_text}'
|
||||
if shown.slc_target_text is not None:
|
||||
return f'LIMIT {shown.slc_target_text}'
|
||||
return None
|
||||
|
||||
|
||||
def set_speed_status(state, profile, shown):
|
||||
status = speed_status(state, profile, shown)
|
||||
if speed_limit_bounds(profile, state) is not None:
|
||||
confirming = manual_slc_confirmation(profile, state)
|
||||
if confirming and state.slc_prompt_open or not confirming and status == 'PEDAL':
|
||||
return None
|
||||
return status
|
||||
|
||||
|
||||
def set_speed_visible(state, profile):
|
||||
return (state.customization.get('showMaxSpeed', True) if profile == Profile.LARGE else
|
||||
placement(state.customization, profile, 'max_speed')['enabled'])
|
||||
|
||||
|
||||
class SpeedLimitWidget:
|
||||
def __init__(self, fonts: BitmapFonts, profile: Profile):
|
||||
self.fonts = fonts
|
||||
@@ -78,6 +106,8 @@ class SpeedLimitWidget:
|
||||
self.shapes.close()
|
||||
|
||||
def render(self, state: OnroadState, *, defer_confirmation=False) -> None:
|
||||
from openpilot.starpilot.ui.stop_sign_widget import render_stop_sign
|
||||
|
||||
render_stop_sign(self.fonts, state)
|
||||
bounds = speed_limit_bounds(self.profile, state)
|
||||
now = state.observed_ns / 1e9 if state.observed_ns else time.monotonic()
|
||||
@@ -101,6 +131,8 @@ class SpeedLimitWidget:
|
||||
width = (right - x) / scale
|
||||
shown = resolve_unified_speed(state, confirmation=True)
|
||||
source = SOURCE_LABELS.get(shown.source, 'SPEED')
|
||||
current_status = (speed_status(state, self.profile, resolve_unified_speed(state))
|
||||
if state.slc_prompt_open or not set_speed_visible(state, self.profile) else None)
|
||||
white = rl.Color(242, 246, 244, 255)
|
||||
secondary = rl.Color(209, 222, 218, 255)
|
||||
|
||||
@@ -111,57 +143,66 @@ class SpeedLimitWidget:
|
||||
left, right = self.fonts.horizontal_ink(text, role, text_size(size))
|
||||
return (right - left) / scale
|
||||
|
||||
def ink_height(label, size, role=FontRole.MEDIUM):
|
||||
top, bottom = self.fonts.vertical_ink(label, role, text_size(size))
|
||||
return (bottom - top) / scale
|
||||
|
||||
def text(label, size, cx, cy, color=white, role=FontRole.MEDIUM):
|
||||
size = text_size(size)
|
||||
left, right = self.fonts.horizontal_ink(label, role, size)
|
||||
top, bottom = self.fonts.vertical_ink(label, role, size)
|
||||
self.fonts.draw(label, role, size, x + cx * scale - (left + right) / 2,
|
||||
y + cy * scale - (top + bottom) / 2, color)
|
||||
|
||||
def rounded(left, top, w, h, color, radius=8):
|
||||
rect = rl.Rectangle(x + left * scale, y + top * scale, w * scale, h * scale)
|
||||
rl.draw_rectangle_rounded(rect, 2 * radius / min(w, h), 12, color)
|
||||
draw_text(self.fonts, label, role, size, x + cx * scale - (left + right) / 2,
|
||||
y + cy * scale - (top + bottom) / 2, color)
|
||||
|
||||
if state.slc_prompt_open:
|
||||
self.shapes.confirmation(bounds, opened=True)
|
||||
target_size = 40
|
||||
nw, uw = ink_width(shown.proposal_text, target_size, FontRole.BOLD), ink_width(shown.unit, 20)
|
||||
context = source + (f' {shown.posted_text} {shown.offset_text}' if shown.offset_text else '')
|
||||
context_size = 20 * min(1, (width - 24 - nw - 6 - uw - 16) / max(1, ink_width(context, 20)))
|
||||
context_width = ink_width(context, context_size)
|
||||
start = (width - nw - 6 - uw - 16 - context_width) / 2
|
||||
text(shown.proposal_text, target_size, start + nw / 2, 24, white, FontRole.BOLD)
|
||||
nt, nb = self.fonts.vertical_ink(shown.proposal_text, FontRole.BOLD, text_size(target_size))
|
||||
ut, ub = self.fonts.vertical_ink(shown.unit, FontRole.MEDIUM, text_size(20))
|
||||
text(shown.unit, 20, start + nw + 6 + uw / 2, 24 + (nb - nt - ub + ut) / scale / 2, secondary)
|
||||
text(context, context_size, start + nw + 6 + uw + 16 + context_width / 2, 24, rl.Color(203, 197, 215, 255))
|
||||
for control, (fill, border, ink) in zip(slc_controls(self.profile, state), (
|
||||
((35, 74, 57), (106, 153, 127), (224, 243, 232)),
|
||||
((77, 46, 57), (173, 122, 134), (248, 229, 234))), strict=True):
|
||||
if not control.enabled:
|
||||
fill, border, ink = (43, 49, 48), (94, 105, 101), (161, 174, 168)
|
||||
context = (f'Now: {current_status}' + (f' {shown.unit}' if current_status != 'PEDAL' else '')
|
||||
if current_status else 'New limit')
|
||||
context_size = 26 if self.profile == Profile.LARGE else 24
|
||||
context_size *= min(1, (width - 24) / max(1, ink_width(context, context_size)))
|
||||
text(context, context_size, width / 2, 24, secondary)
|
||||
for control in slc_controls(self.profile, state):
|
||||
accent = (IN_USE if control.label == 'Accept' else REJECT) if control.enabled else MUTED
|
||||
selected = control.enabled and pressed == control.request
|
||||
ink = accent if selected or not control.enabled else white
|
||||
left = (control.bounds[0] - x) / scale
|
||||
top = (control.bounds[1] - y) / scale
|
||||
w, h = (control.bounds[2] - control.bounds[0]) / scale, (control.bounds[3] - control.bounds[1]) / scale
|
||||
# Keep the full touch area around the shorter, visually balanced face.
|
||||
face_top, face_height = top + CONFIRMATION_PADDING, h - 2 * CONFIRMATION_PADDING
|
||||
rounded(left, face_top, w, face_height, rl.Color(*border, 255), 16)
|
||||
color = tuple(min(255, channel + 14) for channel in fill) if pressed == control.request else fill
|
||||
rounded(left + 1.5, face_top + 1.5, w - 3, face_height - 3, rl.Color(*color, 255), 14.5)
|
||||
text(control.label, 37, left + w / 2, top + h / 2, rl.Color(*ink, 255))
|
||||
self.shapes.button(x + (left + w / 2) * scale, y + (top + h / 2) * scale, accent,
|
||||
profile=self.profile, scale=scale, pressed=selected)
|
||||
uw = ink_width(shown.unit, 20)
|
||||
size = 60 if self.profile == Profile.LARGE else 52
|
||||
padding, gap = (28, 6) if self.profile == Profile.LARGE else (24, 4)
|
||||
size *= min(1, (w - padding - gap - uw) / max(1, ink_width(shown.proposal_text, size, FontRole.BOLD)))
|
||||
nh, uh = ink_height(shown.proposal_text, size, FontRole.BOLD), ink_height(shown.unit, 20)
|
||||
caption_height = ink_height('new limit', 22)
|
||||
row_height = max(nh, uh, caption_height)
|
||||
# Center each visible text group while keeping both action headings on one row.
|
||||
label_y = top + (h - 8 - row_height) / 2
|
||||
detail_bottom = label_y + ink_height(control.label, 37) / 2 + 8 + row_height
|
||||
text(control.label, 37, left + w / 2, label_y, ink)
|
||||
if control.label == 'Accept':
|
||||
# Bind the adjusted proposal to its action, not to the current override or source reading.
|
||||
nw = ink_width(shown.proposal_text, size, FontRole.BOLD)
|
||||
start = left + (w - nw - gap - uw) / 2
|
||||
text(shown.proposal_text, size, start + nw / 2, detail_bottom - nh / 2, ink, FontRole.BOLD)
|
||||
text(shown.unit, 20, start + nw + gap + uw / 2, detail_bottom - uh / 2, ink)
|
||||
else:
|
||||
text('new limit', 22, left + w / 2, detail_bottom - caption_height / 2, ink)
|
||||
return
|
||||
|
||||
self.shapes.confirmation(bounds, strength=self.pulse.confirmation_strength(now))
|
||||
source_color = rl.Color(197, 212, 208, 255)
|
||||
label_width = ink_width(source, 22, FontRole.SEMI_BOLD)
|
||||
if current_status:
|
||||
source += f' / {current_status}'
|
||||
label_size = 22 * min(1, (width - 40) / max(1, ink_width(source, 22, FontRole.SEMI_BOLD)))
|
||||
label_width = ink_width(source, label_size, FontRole.SEMI_BOLD)
|
||||
target = state.speed_limit.pending_adjusted_limit_mps
|
||||
if not positive(target):
|
||||
target = displayed_adjusted_limit_mps(state.speed_limit)
|
||||
direction = (-1 if target > state.speed_mps else 1) if target is not None and state.speed_mps is not None and target != state.speed_mps else 0
|
||||
start = (width - label_width - (19.2 if direction else 0)) / 2
|
||||
text(source, 22, start + label_width / 2, 22, source_color, FontRole.SEMI_BOLD)
|
||||
text(source, label_size, start + label_width / 2, 22, source_color, FontRole.SEMI_BOLD)
|
||||
if direction:
|
||||
self.shapes.arrow('confirmation', x + (start + label_width + 12.6) * scale, y + 22.5 * scale,
|
||||
self.shapes.arrow('compact', x + (start + label_width + 12.6) * scale, y + 22.5 * scale,
|
||||
direction, source_color, scale=1.1 * scale)
|
||||
nw = ink_width(shown.posted_text, 64, FontRole.BOLD)
|
||||
ow = ink_width(shown.offset_text, 32) if shown.offset_text else 0
|
||||
@@ -176,11 +217,12 @@ class SpeedLimitWidget:
|
||||
label = 'New Limit' if slc_accept_available(state) else 'Unavailable'
|
||||
caption = ink_width(label, 30, FontRole.SEMI_BOLD)
|
||||
cx = (width - caption - 10) / 2
|
||||
accent = rl.Color(214, 188, 240, 255)
|
||||
strength = self.pulse.confirmation_strength(now)
|
||||
accent = rl.Color(214, round(188 + 35 * strength), 240, 255)
|
||||
text(label, 30, cx + caption / 2, 104, accent, FontRole.SEMI_BOLD)
|
||||
ax, ay = x + (cx + caption + 8) * scale, y + 104 * scale
|
||||
rl.draw_line_ex(rl.Vector2(ax - 2 * scale, ay - 4 * scale), rl.Vector2(ax + 2 * scale, ay), 1.5 * scale, accent)
|
||||
rl.draw_line_ex(rl.Vector2(ax + 2 * scale, ay), rl.Vector2(ax - 2 * scale, ay + 4 * scale), 1.5 * scale, accent)
|
||||
draw_line(self.profile, rl.Vector2(ax - 2 * scale, ay - 4 * scale), rl.Vector2(ax + 2 * scale, ay), 1.5 * scale, accent)
|
||||
draw_line(self.profile, rl.Vector2(ax + 2 * scale, ay), rl.Vector2(ax - 2 * scale, ay + 4 * scale), 1.5 * scale, accent)
|
||||
if state.slc_wheel_confirmation_available:
|
||||
accept, reject = ink_width('Accept', 26), ink_width('Reject', 26)
|
||||
start = (width - 18 - 4 - accept - 10 - 18 - 4 - reject) / 2
|
||||
@@ -189,6 +231,8 @@ class SpeedLimitWidget:
|
||||
hint_ink = rl.Color(210, 216, 216, 255)
|
||||
text('Accept', 26, start + 22 + accept / 2, 135.5, hint_ink if slc_accept_available(state) else MUTED)
|
||||
text('Reject', 26, start + 18 + 4 + accept + 10 + 22 + reject / 2, 135.5, hint_ink)
|
||||
else:
|
||||
text('Tap to review', 22, width / 2, 135.5, secondary)
|
||||
|
||||
def _sign(self, state: OnroadState, bounds, now: float) -> None:
|
||||
shown, obs = resolve_unified_speed(state), state.speed_limit
|
||||
@@ -198,6 +242,7 @@ class SpeedLimitWidget:
|
||||
heading = f'LAST {heading}' if heading != 'SPEED' else 'LAST'
|
||||
x, top = bounds[0] + 12, bounds[1] + 12
|
||||
scale = self.scale
|
||||
center_x = SIGN_WIDTH / 2
|
||||
pending = shown.pending and not obs.automatic_adoption_pending
|
||||
target = displayed_adjusted_limit_mps(obs) if pending and not shown.posted_retained else None
|
||||
arrow_direction = 0
|
||||
@@ -210,16 +255,16 @@ class SpeedLimitWidget:
|
||||
return tuple(value / scale for value in (*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=59):
|
||||
def centered(text, role, size, ink_top, color, cx=None):
|
||||
left, right, a, b = ink(text, role, size)
|
||||
# Snap only the final draw, after fitting in the shared 118 x 132 geometry.
|
||||
origin = round(x + (center_x - (left + right) / 2) * scale)
|
||||
# Snap only the final draw, after fitting the visible glyphs inside the sign.
|
||||
origin = round(x + ((center_x if cx is None else cx) - (left + right) / 2) * scale)
|
||||
y = round(top + (ink_top - a) * scale)
|
||||
self.fonts.draw(text, role, size * self.text_scale, origin, y, color)
|
||||
draw_text(self.fonts, text, role, size * self.text_scale, origin, y, color)
|
||||
return origin + left * scale, y + a * scale, origin + right * scale, y + b * scale
|
||||
|
||||
vienna_center_y = 67
|
||||
mutcd_center_y = 66
|
||||
mutcd_center_y = SIGN_HEIGHT / 2
|
||||
|
||||
def circle_fits(left, right, a, b):
|
||||
return all((px - 59) ** 2 + (py - vienna_center_y) ** 2 <= 46 ** 2
|
||||
@@ -227,80 +272,49 @@ class SpeedLimitWidget:
|
||||
|
||||
vienna = state.appearance.use_vienna_sign
|
||||
sign_center_y = vienna_center_y if vienna else mutcd_center_y
|
||||
set_visible = (shown.mode != 'limit_only' if self.profile == Profile.LARGE else
|
||||
shown.max_text != '–' and placement(state.customization, self.profile, 'max_speed')['enabled'])
|
||||
road_target = shown.limit_text if shown.offset_text else shown.posted_text
|
||||
active_text = shown.override_text
|
||||
if not set_visible and shown.slc_target_text != road_target:
|
||||
if shown.slc_target_text is not None and (not shown.limit_in_use or shown.slc_target_text != shown.posted_text):
|
||||
active_text = active_text or shown.slc_target_text
|
||||
if active_text is not None and shown.posted_text != '–' and not pending:
|
||||
override = shown.override_text is not None
|
||||
road_role = FontRole.SEMI_BOLD if vienna else FontRole.BOLD
|
||||
override_role = FontRole.BOLD
|
||||
|
||||
def fit(text, role, size, width):
|
||||
left, right, _, _ = ink(text, role, size)
|
||||
size *= min(1, width / (right - left))
|
||||
left, right, a, b = ink(text, role, size)
|
||||
return size, right - left, b - a
|
||||
|
||||
road_text = shown.posted_text + (shown.offset_text or '') if not override else shown.posted_text
|
||||
road_size, road_width, road_height = fit(road_text, road_role, 33, 32 if vienna else 38)
|
||||
override_size, override_width, override_height = fit(active_text, override_role, 56, 46 if vienna else 55)
|
||||
label = 'OVERRIDE' if override else f'{heading} / SLC'
|
||||
if override and last:
|
||||
label = 'LAST / OVERRIDE'
|
||||
heading_role = FontRole.SEMI_BOLD
|
||||
heading_size = 17 if vienna else 20
|
||||
if self.profile == Profile.COMPACT:
|
||||
heading_size = 15 if vienna else 14
|
||||
heading_size, _, heading_height = fit(label, heading_role, heading_size, 72 if vienna else 94)
|
||||
row_left = 59 - (road_width + override_width + 11) / 2
|
||||
road_x = row_left + 1.5 + road_width / 2
|
||||
override_x = row_left + road_width + 11 + override_width / 2
|
||||
heading_gap = 10 if vienna else 13
|
||||
row_height = max(road_height, override_height)
|
||||
half_height = (heading_height + heading_gap + row_height) / 2
|
||||
content_center = sign_center_y - (3 if vienna else 0)
|
||||
baseline = content_center + half_height
|
||||
# Vienna centers the two numbers vertically. MUTCD aligns their baselines.
|
||||
road_top = baseline - ((row_height + road_height) / 2 if vienna else road_height)
|
||||
override_top = baseline - ((row_height + override_height) / 2 if vienna else override_height)
|
||||
muted = rl.Color(86, 92, 99, 255) if vienna else rl.Color(164, 174, 182, 255)
|
||||
active = rl.BLACK if vienna else rl.Color(243, 233, 255, 255)
|
||||
slash = rl.Color(201, 34, 49, 255) if vienna else rl.Color(245, 100, 102, 255)
|
||||
cx, cy = x + 59 * scale, top + sign_center_y * scale
|
||||
if override:
|
||||
self.shapes.override_sign(cx, cy, vienna=vienna)
|
||||
elif vienna:
|
||||
self.shapes.circle(cx, cy, rl.Color(201, 34, 49, 255))
|
||||
else:
|
||||
self.shapes.outline(cx, cy, white)
|
||||
centered(label, heading_role, heading_size, content_center - half_height, muted)
|
||||
centered(road_text, road_role, road_size, road_top, muted, road_x)
|
||||
centered(active_text, override_role, override_size, override_top, active if override or vienna else white, override_x)
|
||||
if override:
|
||||
self.shapes.override_slash(x + road_x * scale, top + (road_top + road_height * .51) * scale,
|
||||
(road_width + 5) * scale, slash)
|
||||
pedal = speed_status(state, self.profile, shown) == 'PEDAL'
|
||||
if (active_text is not None or pedal or shown.limit_in_use and not vienna) and not shown.pending:
|
||||
# Driver targets are rectangular readouts, never regulatory red-ring signs.
|
||||
self.shapes.outline(x + center_x * scale, top + mutcd_center_y * scale, IN_USE if shown.limit_in_use else white)
|
||||
label = 'Override' if shown.override_text is not None else 'Limit'
|
||||
active_text = active_text or shown.slc_target_text or shown.posted_text
|
||||
target_rows = (('Pedal', FontRole.SEMI_BOLD, 44, 68, OVERRIDE),) if pedal else (
|
||||
(label, FontRole.SEMI_BOLD, 34, 25, OVERRIDE if label == 'Override' else white),
|
||||
(active_text, FontRole.SET_SPEED if self.profile == Profile.LARGE else FontRole.BOLD, 64, 68, white))
|
||||
posted = shown.posted_text + (f' {shown.offset_text}' if shown.offset_text else '')
|
||||
detail = f'{heading} {posted}' if pedal or shown.override_text is not None or active_text != shown.posted_text else heading
|
||||
left, right, _, _ = ink(detail, FontRole.MEDIUM, 22)
|
||||
source_rows = ((detail, FontRole.MEDIUM, 22, 120, secondary),) if detail == heading or right - left <= 108 else (
|
||||
(heading, FontRole.SEMI_BOLD, 18, 104, secondary), (posted, FontRole.MEDIUM, 20, 125, secondary))
|
||||
for value, role, size, cy, color in (*target_rows, *source_rows):
|
||||
left, right, _, _ = ink(value, role, size)
|
||||
size *= min(1, 108 / max(1, right - left))
|
||||
_, _, a, b = ink(value, role, size)
|
||||
centered(value, role, size, cy - (b - a) / 2, color)
|
||||
return
|
||||
|
||||
large_steady = self.profile == Profile.LARGE and not vienna and not shown.pending
|
||||
color = rl.Color(0, 0, 0, 255) if vienna else white
|
||||
color = white
|
||||
if vienna:
|
||||
x, top = x + 5 * scale, top + 5 * scale
|
||||
center_x = 59
|
||||
self.shapes.circle(x + 59 * scale, top + sign_center_y * scale, self.pulse.color(rl.Color(201, 34, 49, 255), now))
|
||||
else:
|
||||
self.shapes.outline(x + 59 * scale, top + sign_center_y * scale, white)
|
||||
self.shapes.outline(x + center_x * scale, top + sign_center_y * scale, IN_USE if shown.limit_in_use else white)
|
||||
|
||||
heading_size = 28 / self.text_scale if large_steady else 18
|
||||
heading_size = 28 if not vienna else 18
|
||||
if vienna and self.profile == Profile.LARGE and not shown.pending:
|
||||
heading_size = 21
|
||||
left, right, a, b = ink(heading, FontRole.SEMI_BOLD, heading_size)
|
||||
if not vienna and (large_steady or last) and right - left > 96:
|
||||
heading_size *= 96 / (right - left)
|
||||
if not vienna and right - left > 108:
|
||||
heading_size *= 108 / (right - left)
|
||||
left, right, a, b = ink(heading, FontRole.SEMI_BOLD, heading_size)
|
||||
# Lift the Vienna stack to keep the offset clear of the bottom rim.
|
||||
lift = (7 if shown.offset_text else 2) if vienna else 0
|
||||
header_y = 47 - lift if vienna else 21 if large_steady else 26
|
||||
lift = (12 if shown.limit_in_use else 7 if shown.offset_text else 2) if vienna else 0
|
||||
header_y = 47 - lift if vienna else 26
|
||||
arrow_scale = 1.
|
||||
if vienna:
|
||||
# Fit the heading and arrow inside the circle at the top of their ink.
|
||||
@@ -315,16 +329,16 @@ class SpeedLimitWidget:
|
||||
heading_width = right - left
|
||||
# The small arrow's visible ink is 12 x 16, with a six-pixel label gap.
|
||||
arrow_width = 12 * arrow_scale
|
||||
header_left = 59 - (heading_width + (6 + arrow_width if arrow_direction else 0)) / 2
|
||||
header_left = center_x - (heading_width + (6 + arrow_width if arrow_direction else 0)) / 2
|
||||
centered(heading, FontRole.SEMI_BOLD, heading_size, header_y - (b - a) / 2, color, header_left + heading_width / 2)
|
||||
if arrow_direction:
|
||||
self.shapes.arrow('compact', round(x + (header_left + heading_width + 6 + arrow_width / 2) * scale),
|
||||
round(top + header_y * scale), arrow_direction, color, scale=scale * arrow_scale)
|
||||
|
||||
role = FontRole.SET_SPEED if large_steady else FontRole.BOLD
|
||||
size = 77.92 / self.text_scale if large_steady else 58 if vienna else 64
|
||||
size = 58 if vienna else 64
|
||||
offset_size = 30 if vienna else 27
|
||||
number_center = (73 if shown.offset_text else 80) - lift if vienna else (67 if shown.offset_text else 81)
|
||||
number_center = (73 if shown.offset_text else 80) - lift if vienna else (68 if shown.offset_text else 80)
|
||||
while True:
|
||||
left, right, a, b = ink(shown.posted_text, role, size)
|
||||
width, height = right - left, b - a
|
||||
@@ -336,7 +350,7 @@ class SpeedLimitWidget:
|
||||
offset_top = number_top + height + 4
|
||||
fits = fits and circle_fits(59 - (right - left) / 2, 59 + (right - left) / 2, offset_top, offset_top + b - a)
|
||||
else:
|
||||
fits = width <= 96 and height <= 54
|
||||
fits = width <= 108 and height <= 54
|
||||
if fits:
|
||||
break
|
||||
size *= .95
|
||||
@@ -346,6 +360,8 @@ class SpeedLimitWidget:
|
||||
if shown.offset_text:
|
||||
if not vienna:
|
||||
left, right, a, b = ink(shown.offset_text, FontRole.MEDIUM, offset_size)
|
||||
offset_size *= min(1, 96 / (right - left), 24 / (b - a))
|
||||
offset_top = 97
|
||||
offset_size *= min(1, 108 / (right - left), 24 / (b - a))
|
||||
offset_top = 105
|
||||
centered(shown.offset_text, FontRole.MEDIUM, offset_size, offset_top, color if vienna else secondary)
|
||||
if shown.limit_in_use:
|
||||
centered('IN USE', FontRole.SEMI_BOLD, 15 if vienna else 22, 98 if vienna else 117, IN_USE)
|
||||
|
||||
@@ -3,12 +3,13 @@ import math
|
||||
import time
|
||||
|
||||
from openpilot.starpilot.speed_limits.vision.stop_observation import STOP_MAX_AGE_NS
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, LIMIT_RECTS
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_HEIGHT, LIMIT_RECTS
|
||||
from openpilot.starpilot.ui.onroad_customization import offset, placement
|
||||
from openpilot.starpilot.ui.onroad_state import AlertSize, slc_action_occludes
|
||||
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice
|
||||
from openpilot.starpilot.ui.presentation import FontRole, Profile
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
from openpilot.starpilot.ui.speed_limit_widget import draw_line, draw_text
|
||||
|
||||
|
||||
def stop_sign_bounds(profile: Profile, state):
|
||||
@@ -19,7 +20,7 @@ def stop_sign_bounds(profile: Profile, state):
|
||||
dx, dy = offset(state.customization, profile, widget)
|
||||
scale = LARGE_SIGN_SCALE if profile == Profile.LARGE else .7
|
||||
left = x + dx + width + 24 if profile == Profile.LARGE else x + dx - 94
|
||||
top = y + dy + (66 * LARGE_SIGN_SCALE - 50 * scale if profile == Profile.LARGE else 8)
|
||||
top = y + dy + (SIGN_HEIGHT / 2 * LARGE_SIGN_SCALE - 50 * scale if profile == Profile.LARGE else 8)
|
||||
if slc_action_occludes(profile, state, (left, top, left + 100 * scale, top + 100 * scale)):
|
||||
return None
|
||||
return left, top, 100 * scale, scale
|
||||
@@ -42,13 +43,11 @@ def draw_stop_sign(fonts, x: float, y: float, *, scale: float = 1) -> None:
|
||||
white = rl.Color(244, 247, 245, 255)
|
||||
center = rl.Vector2(x + 50 * scale, y + 50 * scale)
|
||||
radius = 50 * scale / math.cos(math.pi / 8)
|
||||
if fonts.profile == Profile.LARGE:
|
||||
rl.draw_poly(center, 8, radius, 22.5, rl.Color(10, 17, 19, 230))
|
||||
vertices = [rl.Vector2(center.x + radius * math.cos(math.radians(22.5 + i * 45)),
|
||||
center.y + radius * math.sin(math.radians(22.5 + i * 45))) for i in range(8)]
|
||||
for i, point in enumerate(vertices):
|
||||
rl.draw_line_ex(point, vertices[(i + 1) % 8], 2 * scale, white)
|
||||
draw_line(fonts.profile, point, vertices[(i + 1) % 8], 2 * scale, white)
|
||||
size = 28 * scale * Profile.COMPACT.font_scale / fonts.profile.font_scale
|
||||
left, right = fonts.horizontal_ink('STOP', FontRole.BOLD, size)
|
||||
top, bottom = fonts.vertical_ink('STOP', FontRole.BOLD, size)
|
||||
fonts.draw('STOP', FontRole.BOLD, size, center.x - (left + right) / 2, center.y - (top + bottom) / 2, white)
|
||||
draw_text(fonts, 'STOP', FontRole.BOLD, size, center.x - (left + right) / 2, center.y - (top + bottom) / 2, white)
|
||||
|
||||
@@ -345,7 +345,6 @@ class AppearanceSettingsTests(unittest.TestCase):
|
||||
view.torque_bar = Mock()
|
||||
view.stock_confidence_layer = Mock()
|
||||
view.stock_confidence_reset = Mock()
|
||||
view._fade = None
|
||||
base = OnroadState(engaged=True, camera_available=False, speed_mps=12, cruise_kph=60,
|
||||
speed_limit=SpeedLimitObservation(),
|
||||
appearance=OnroadAppearance(show_stock_confidence_ball=True),
|
||||
@@ -356,7 +355,7 @@ class AppearanceSettingsTests(unittest.TestCase):
|
||||
patch.object(onroad.rl, "draw_rectangle_rec"), patch.object(onroad.rl, "draw_rectangle_gradient_v"), \
|
||||
patch.object(onroad.rl, "draw_rectangle"), patch.object(onroad.rl, "draw_texture_ex"), \
|
||||
patch.object(onroad.rl, "draw_rectangle_rounded_lines_ex"), patch.object(onroad, "render_compact_half_borders"), \
|
||||
patch.object(view, "_prepare_compact_fade"), patch.object(view, "_slc_actions"):
|
||||
patch.object(view, "_slc_actions"):
|
||||
view.render(base)
|
||||
view.stock_confidence_layer.assert_called_once()
|
||||
view.stock_confidence_reset.assert_called_once()
|
||||
@@ -624,7 +623,7 @@ class AppearanceSettingsTests(unittest.TestCase):
|
||||
UnifiedSpeedWidget(fonts).render(rl.Rectangle(30, 30, 1800, 1020), base)
|
||||
card.assert_not_called()
|
||||
from openpilot.starpilot.ui.tests.test_unified_speed_native import foreground_calls
|
||||
self.assertEqual(foreground_calls(fonts.draw)[0].args[3:5], (192, 89))
|
||||
self.assertEqual(foreground_calls(fonts.draw)[0].args[3:5], (192, 87))
|
||||
for profile in (Profile.LARGE, Profile.COMPACT):
|
||||
view = onroad.OnroadView.__new__(onroad.OnroadView)
|
||||
view.personality = Mock()
|
||||
@@ -645,12 +644,11 @@ class AppearanceSettingsTests(unittest.TestCase):
|
||||
view.steering_wheel = Mock()
|
||||
view.compact_hud = Mock()
|
||||
view.compact_sidebar = Mock()
|
||||
view._fade = None
|
||||
with patch.object(onroad.clip, "begin_scissor_mode"), patch.object(onroad.clip, "end_scissor_mode"), \
|
||||
patch.object(onroad.rl, "draw_rectangle_rec"), patch.object(onroad.rl, "draw_rectangle_gradient_v"), \
|
||||
patch.object(onroad.rl, "draw_rectangle_lines_ex"), patch.object(onroad.rl, "draw_rectangle"), \
|
||||
patch.object(onroad.rl, "draw_texture_ex"), patch.object(onroad.rl, "draw_rectangle_rounded_lines_ex"), \
|
||||
patch.object(onroad, "render_corner_hint"), patch.object(view, "_prepare_compact_fade"), \
|
||||
patch.object(onroad, "render_corner_hint"), \
|
||||
patch.object(view, "_slc_actions") as slc:
|
||||
view.render(base)
|
||||
view.torque_bar.render.assert_called_once()
|
||||
|
||||
@@ -4,11 +4,11 @@ from unittest.mock import Mock, patch
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_WIDTH, SIGN_HEIGHT
|
||||
from openpilot.starpilot.ui.onroad_state import speed_limit_bounds, slc_controls, SpeedLimitObservation, ObservationKind
|
||||
from openpilot.starpilot.ui.presentation import Profile
|
||||
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
|
||||
from openpilot.starpilot.ui.tests.test_unified_speed_native import fonts, state as pending_state
|
||||
from openpilot.starpilot.ui.tests.test_unified_speed_native import foreground_calls, fonts, state as pending_state
|
||||
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
|
||||
|
||||
|
||||
@@ -46,9 +46,9 @@ def test_both_sign_styles_keep_their_height_with_or_without_offset(profile, offs
|
||||
else:
|
||||
historical_outline.assert_not_called()
|
||||
widget.shapes.outline.assert_called_once()
|
||||
assert widget.shapes.outline.call_args.args[:2] == (bounds[0]+12+59*scale, bounds[1]+12+66*scale)
|
||||
assert widget.shapes.outline.call_args.args[:2] == (bounds[0]+12+SIGN_WIDTH/2*scale, bounds[1]+12+SIGN_HEIGHT/2*scale)
|
||||
assert widget.shapes.outline.call_args.kwargs == {}
|
||||
assert ('+5' in [call.args[0] for call in widget.fonts.draw.call_args_list]) == offset
|
||||
assert ('+5' in [call.args[0] for call in foreground_calls(widget.fonts.draw)]) == offset
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', (65, 120))
|
||||
@@ -69,8 +69,8 @@ def test_small_sign_keeps_its_footprint_for_every_offset(value, metric, show, of
|
||||
assert bounds is not None
|
||||
historical_outline.assert_not_called()
|
||||
widget.shapes.outline.assert_called_once()
|
||||
assert bounds[3] - bounds[1] == 152
|
||||
texts = [call.args[0] for call in widget.fonts.draw.call_args_list]
|
||||
assert bounds[3] - bounds[1] == SIGN_HEIGHT + 20
|
||||
texts = [call.args[0] for call in foreground_calls(widget.fonts.draw)]
|
||||
assert texts == ['VISION', str(value)] + ([shown.offset_text] if shown.offset_text else [])
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ def test_small_offset_layout_transitions_reuse_the_same_widget(historical_outlin
|
||||
historical_outline.assert_not_called()
|
||||
bounds = speed_limit_bounds(Profile.COMPACT, current)
|
||||
assert bounds is not None
|
||||
assert bounds[3] - bounds[1] == 152
|
||||
assert bounds[3] - bounds[1] == SIGN_HEIGHT + 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize('y', [20, 50, 108])
|
||||
@@ -100,6 +100,6 @@ def test_small_rectangular_no_offset_regression(historical_outline):
|
||||
widget.shapes = Mock()
|
||||
widget.render(current)
|
||||
bounds = speed_limit_bounds(Profile.COMPACT, current)
|
||||
assert bounds is not None and bounds[3] - bounds[1] == 152
|
||||
assert bounds is not None and bounds[3] - bounds[1] == SIGN_HEIGHT + 20
|
||||
historical_outline.assert_not_called()
|
||||
widget.shapes.outline.assert_called_once()
|
||||
|
||||
@@ -92,7 +92,6 @@ class DeveloperPreviewRenderTests(unittest.TestCase):
|
||||
view.compact_hud = Mock()
|
||||
view.compact_sidebar = Mock()
|
||||
view.torque_bar = Mock()
|
||||
view._fade = Mock()
|
||||
view._fade_path = Path('/unused')
|
||||
with patch('openpilot.starpilot.ui.onroad.clip.begin_scissor_mode'), \
|
||||
patch('openpilot.starpilot.ui.onroad.clip.end_scissor_mode'), \
|
||||
@@ -104,7 +103,7 @@ class DeveloperPreviewRenderTests(unittest.TestCase):
|
||||
patch('openpilot.starpilot.ui.onroad.rl.draw_rectangle_rounded'), \
|
||||
patch('openpilot.starpilot.ui.onroad.rl.draw_rectangle_rounded_lines_ex'), \
|
||||
patch('openpilot.starpilot.ui.onroad.render_corner_hint'), \
|
||||
patch.object(view, '_prepare_compact_fade'), patch.object(view, '_slc_actions'):
|
||||
patch.object(view, '_slc_actions'):
|
||||
view.render(state(full_alert=True))
|
||||
drawn = [call.args[0] for call in fonts.draw.call_args_list]
|
||||
self.assertTrue(any('SYNTHETIC REPLAY PREVIEW' in text for text in drawn))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from dataclasses import replace
|
||||
from itertools import product
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
@@ -13,6 +14,7 @@ import pytest
|
||||
|
||||
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory, font_filename, font_path, validate_bitmap_font
|
||||
from openpilot.starpilot.ui.onroad_customization import default_document, validate_document
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_WIDTH, SIGN_HEIGHT, LIMIT_RECTS, confirmation_bounds
|
||||
from openpilot.starpilot.ui.onroad_state import ObservationKind, slc_controls, speed_limit_bounds
|
||||
from openpilot.starpilot.ui.tests.test_speed_stack import road
|
||||
from openpilot.starpilot.system.starport.projection_layout import default_layout_for_viewport, validate_layout_for_viewport
|
||||
@@ -92,13 +94,14 @@ def test_large_sign_is_secondary_to_set_speed_at_the_saved_position():
|
||||
with patch.object(fonts, 'draw', lambda *args, calls=calls: calls.append(args)), patch.object(rl, 'draw_line_ex') as lines:
|
||||
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.SET_SPEED)
|
||||
posted = next(call for call in calls if call[0] == '130' and call[1] == FontRole.SET_SPEED and call[3] > 388)
|
||||
left, right = fonts.horizontal_ink(posted[0], posted[1], posted[2])
|
||||
assert right-left <= 96*1.25
|
||||
assert abs(posted[3] + (left + right) / 2 - 491.75) <= .5
|
||||
a, b = fonts.vertical_ink('120', FontRole.SET_SPEED, posted[2])
|
||||
assert b - a == pytest.approx(60, abs=.1)
|
||||
assert abs(posted[4]+(a+b)/2-(95+67*1.25)) <= .5
|
||||
assert right-left <= 108*LARGE_SIGN_SCALE
|
||||
assert abs(posted[3] + (left + right) / 2 - (418 + SIGN_WIDTH/2*LARGE_SIGN_SCALE)) <= .5
|
||||
a, b = fonts.vertical_ink('130', FontRole.SET_SPEED, posted[2])
|
||||
assert 30 <= b - a <= 64
|
||||
assert abs(posted[4]+(a+b)/2-(95+68*LARGE_SIGN_SCALE)) <= .5
|
||||
assert {'Limit', '120 +10'} <= {call[0] for call in calls}
|
||||
set_calls = [call for call in calls if call[1] == FontRole.SET_SPEED and call[3] < 388]
|
||||
assert bool(set_calls) == (show_set and value is not None)
|
||||
if set_calls:
|
||||
@@ -108,15 +111,15 @@ def test_large_sign_is_secondary_to_set_speed_at_the_saved_position():
|
||||
assert x + (left + right) / 2 == pytest.approx(308)
|
||||
assert x + right + 20 <= 418
|
||||
top, bottom = fonts.vertical_ink(text, role, size)
|
||||
assert bottom - top == pytest.approx(83, abs=1)
|
||||
assert y + (top + bottom) / 2 == pytest.approx(196)
|
||||
assert bottom - top == pytest.approx(77, abs=1)
|
||||
assert y + (top + bottom) / 2 == pytest.approx(185)
|
||||
assert next(call for call in calls if call[0] == 'SET')[2] == 34
|
||||
unit = next(call for call in calls if call[0] == ('km/h' if metric else 'mph'))
|
||||
assert unit[2] == 32
|
||||
assert unit[4] + fonts.vertical_ink(unit[0], unit[1], unit[2])[1] + 12 <= y + top
|
||||
assert unit[4] + fonts.vertical_ink(unit[0], unit[1], unit[2])[1] + 6 <= y + top
|
||||
if show_set and value is None:
|
||||
start, end, stroke, color = lines.call_args.args
|
||||
assert (start.x, start.y, end.x, end.y, stroke) == (280, 196, 336, 196, 8)
|
||||
assert (start.x, start.y, end.x, end.y, stroke) == (280, 185, 336, 185, 8)
|
||||
assert (color.r, color.g, color.b, color.a) == (190, 208, 202, 255)
|
||||
assert all(call[0] not in ('–', '?') for call in calls)
|
||||
else:
|
||||
@@ -150,6 +153,115 @@ def test_pending_confirmation_keeps_three_digit_set_speed_clear():
|
||||
rl.close_window()
|
||||
|
||||
|
||||
def test_status_below_set_has_real_ink_clearance_and_fits_its_widget():
|
||||
from unittest.mock import patch
|
||||
from openpilot.starpilot.ui.onroad_customization import placement, widget_size
|
||||
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
|
||||
from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget
|
||||
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, 'SET status clearance')
|
||||
try:
|
||||
for profile, status, set_speed in product(Profile, ('PEDAL', 'LIMIT 45', 'OVERRIDE 100'), (70, 100, 199)):
|
||||
with BitmapFonts(profile, default_font_directory()) as fonts:
|
||||
current = state(cruise_kph=set_speed, longitudinal_overridden=status == 'PEDAL', slc_prompt_open=True)
|
||||
current = replace(current, speed_limit=replace(current.speed_limit,
|
||||
pending_speed_limit_mps=None if status == 'PEDAL' else 50/3.6,
|
||||
limiting_max_set=status == 'LIMIT 45', driver_override_active=status == 'OVERRIDE 100',
|
||||
override_basis='persistent', effective_cluster_target_mps=(100 if status == 'OVERRIDE 100' else 45)/3.6))
|
||||
widget = UnifiedSpeedWidget(fonts) if profile == Profile.LARGE else CompactHudRenderer(fonts, Path('/unused'))
|
||||
if isinstance(widget, CompactHudRenderer):
|
||||
widget._set_speed_alpha.x = 1
|
||||
calls = []
|
||||
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)), \
|
||||
patch('openpilot.starpilot.ui.speed_limit_widget.speed_limit_bounds', return_value=None):
|
||||
if isinstance(widget, UnifiedSpeedWidget):
|
||||
widget.render(rl.Rectangle(30,30,1800,1020), current)
|
||||
else:
|
||||
widget.render_max_speed(current)
|
||||
rows = [row for row in calls if row[-1].a == 255]
|
||||
number = next(row for row in rows if row[0] == str(set_speed) and row[1] == FontRole.SET_SPEED)
|
||||
caption = next(row for row in rows if row[0] == status)
|
||||
bottom = number[4] + fonts.vertical_ink(*number[:3])[1]
|
||||
top = caption[4] + fonts.vertical_ink(*caption[:3])[0]
|
||||
assert top - bottom >= 6, (profile, status, set_speed)
|
||||
key = 'cruise_limits' if profile == Profile.LARGE else 'max_speed'
|
||||
saved = placement(current.customization, profile, key)
|
||||
width, height = widget_size(current.customization, profile, key)
|
||||
assert caption[4] + fonts.vertical_ink(*caption[:3])[1] <= saved['y'] + height - 12
|
||||
assert 18 <= caption[2] <= 24
|
||||
left, right = fonts.horizontal_ink(*caption[:3])
|
||||
number_left, number_right = fonts.horizontal_ink(*number[:3])
|
||||
if profile == Profile.COMPACT:
|
||||
assert saved['x'] + 8 <= caption[3] + left < caption[3] + right <= saved['x'] + width - 8
|
||||
assert caption[3] + (left + right) / 2 == pytest.approx(saved['x'] + width / 2)
|
||||
assert number[3] + (number_left + number_right) / 2 == pytest.approx(saved['x'] + width / 2)
|
||||
elif status != 'PEDAL':
|
||||
label = next(row for row in rows if row[0] == 'SET')
|
||||
label_left = label[3] + fonts.horizontal_ink(*label[:3])[0]
|
||||
assert caption[3] + left == pytest.approx(label_left)
|
||||
assert number[3] + number_left == pytest.approx(label_left)
|
||||
widget.close()
|
||||
finally:
|
||||
if owns_context:
|
||||
rl.close_window()
|
||||
|
||||
|
||||
def test_sign_status_rows_keep_balanced_insets_with_real_fonts():
|
||||
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
|
||||
owns_context = not rl.is_window_ready()
|
||||
if owns_context:
|
||||
rl.init_window(128, 128, 'Sign status margins')
|
||||
try:
|
||||
for profile in Profile:
|
||||
with BitmapFonts(profile, default_font_directory()) as fonts:
|
||||
widget = SpeedLimitWidget(fonts, profile)
|
||||
for variant in ('in_use', 'offset', 'override', 'pedal', 'retained'):
|
||||
current = state(cruise_kph=140, longitudinal_overridden=variant == 'pedal')
|
||||
target = 128 if variant == 'override' else 110 if variant == 'offset' else 100
|
||||
current = replace(current, speed_limit=replace(current.speed_limit,
|
||||
source='vision', speed_limit_mps=100/3.6, pending_speed_limit_mps=None,
|
||||
accepted_source='vision', accepted_speed_limit_mps=100/3.6, accepted_adjusted_limit_mps=target/3.6,
|
||||
presentation_adjusted_limit_mps=target/3.6, effective_cluster_target_mps=target/3.6,
|
||||
limiting_max_set=True, driver_override_active=variant == 'override', override_basis='persistent'))
|
||||
if variant == 'pedal':
|
||||
current.customization['showMaxSpeed'] = False
|
||||
current.customization['layouts']['compact']['max_speed']['enabled'] = False
|
||||
if variant == 'retained':
|
||||
current = replace(current, speed_limit=replace(current.speed_limit, kind=ObservationKind.STALE,
|
||||
speed_limit_mps=None, last_received_speed_limit_mps=100/3.6, last_received_source='vision'))
|
||||
calls = []
|
||||
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)):
|
||||
widget.render(current)
|
||||
if variant == 'retained':
|
||||
assert [call[0] for call in calls if call[-1].a == 255] == ['Limit', '100', 'LAST VISION']
|
||||
bounds = speed_limit_bounds(profile, current)
|
||||
assert bounds is not None
|
||||
x, y = bounds[0] + 12, bounds[1] + 12
|
||||
scale = widget.scale
|
||||
boxes = []
|
||||
for text, role, size, left, top, color in calls:
|
||||
if color.a != 255:
|
||||
continue
|
||||
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
|
||||
# Keep real glyph ink clear of the portrait outline, including both footer rows.
|
||||
assert x+9.5*scale <= box[0] < box[2] <= x+118.5*scale, (profile, variant, text)
|
||||
assert y+10.5*scale <= box[1] < box[3] <= y+133.5*scale, (profile, variant, text)
|
||||
assert (box[0]+box[2])/2 == pytest.approx(x+SIGN_WIDTH/2*scale, abs=.5)
|
||||
boxes.append(box)
|
||||
assert len(boxes) >= 2
|
||||
assert all(second[1] - first[3] >= 4*scale for first, second in zip(boxes, boxes[1:], strict=False))
|
||||
widget.close()
|
||||
finally:
|
||||
if owns_context:
|
||||
rl.close_window()
|
||||
|
||||
|
||||
def test_galaxy_card_pixels_align_without_reading_font_textures():
|
||||
from unittest.mock import patch
|
||||
from openpilot.starpilot.ui.layout_preview_renderer import _Canvas, sample_state
|
||||
@@ -276,7 +388,7 @@ def test_compact_preview_sprite_keeps_the_full_confirmation_and_override_sign():
|
||||
rl.init_window(128, 128, 'SLC preview crop fixture')
|
||||
canvas = _Canvas(Profile.COMPACT)
|
||||
try:
|
||||
for y, scene, vienna in product((20, 24), ('slc_pending', 'slc_retained'), (False, True)):
|
||||
for y, scene, vienna in product((16, 20), ('slc_pending', 'slc_retained', 'slc_display'), (False, True)):
|
||||
document = default_document()
|
||||
document['layouts']['compact']['speed_limit']['y'] = y
|
||||
assert validate_document(document)['layouts']['compact']['speed_limit']['y'] == y
|
||||
@@ -288,7 +400,7 @@ def test_compact_preview_sprite_keeps_the_full_confirmation_and_override_sign():
|
||||
slot = json.loads(sheet.info['starpilot-widget-art'])['sprites']['speed_limit']
|
||||
sprite = sheet.crop((slot['x'], slot['y'], slot['x']+slot['width'], slot['y']+slot['height']))
|
||||
rebuilt = Image.new('RGBA', Profile.COMPACT.size)
|
||||
rebuilt.paste(sprite, (round(330+slot['offsetX']), round(y+slot['offsetY'])))
|
||||
rebuilt.paste(sprite, (round(LIMIT_RECTS['compact'][0]+slot['offsetX']), round(y+slot['offsetY'])))
|
||||
image = rl.load_image_from_texture(canvas.target.texture)
|
||||
try:
|
||||
rl.image_flip_vertical(image)
|
||||
@@ -297,10 +409,9 @@ def test_compact_preview_sprite_keeps_the_full_confirmation_and_override_sign():
|
||||
rl.unload_image(image)
|
||||
assert np.array_equal(direct[..., 3], np.asarray(rebuilt)[..., 3])
|
||||
if scene == 'slc_pending':
|
||||
assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (-182, -12, 312, 152)
|
||||
assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (-172, -8, 328, 152)
|
||||
else:
|
||||
assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (
|
||||
(-3, 0, 124, 132) if vienna else (-9, 0, 136, 132))
|
||||
assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (-2, 0, SIGN_WIDTH+4, SIGN_HEIGHT)
|
||||
assert np.any(direct[..., 3]), (y, scene)
|
||||
finally:
|
||||
canvas.close()
|
||||
@@ -378,9 +489,14 @@ def test_source_card_action_sprite_contains_both_actual_button_rows():
|
||||
slot = json.loads(image.info['starpilot-widget-art'])['sprites']['cruise_limits']
|
||||
assert slot['width'] >= 591 and slot['offsetY'] == -12
|
||||
alpha = np.asarray(image.convert('RGBA'))[..., 3]
|
||||
assert alpha[100, 220] > 200, 'Accept must remain in its sprite'
|
||||
assert alpha[100, 450] > 200, 'Reject must remain in its sprite'
|
||||
assert alpha[100, 382] > 200, 'The shared surface must remain in its sprite'
|
||||
origin = document['layouts']['large']['cruise_limits']
|
||||
for control in slc_controls(Profile.LARGE, state):
|
||||
left, top, right, bottom = control.bounds
|
||||
x1, x2 = [round(value - origin['x'] - slot['offsetX'] + slot['x']) for value in (left, right)]
|
||||
y1, y2 = [round(value - origin['y'] - slot['offsetY'] + slot['y']) for value in (top, bottom)]
|
||||
assert np.count_nonzero(alpha[y1:y2, x1:x2] > 200) > 50, control.label
|
||||
assert np.mean(alpha[y1:y2, x1:x2] == 0) > .65, 'Button border and label leave the camera visible'
|
||||
assert alpha[100, 382] == 0, 'The space between choices keeps the camera visible'
|
||||
finally:
|
||||
rl.unload_render_texture(target)
|
||||
if owns_context:
|
||||
@@ -411,7 +527,7 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
|
||||
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))
|
||||
positions = ((88, 75), (188, 95), (1642, 856)) if profile == Profile.LARGE else ((320, 16), (100, 0), (348, 96))
|
||||
for vienna, (source, heading), (posted, offset), elapsed, (saved_x, y), direction, retained in product(
|
||||
(False, True), (('map', 'OSM'), ('vision', 'VISION')),
|
||||
((65, 5), (120, 10), (130, -10), (145, -137), (199, -191), (200, -192), (252, -244), (406, -261), (55, 0)),
|
||||
@@ -433,6 +549,7 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
|
||||
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)), \
|
||||
patch.object(widget.shapes, 'arrow') as arrow_draw:
|
||||
widget.render(current)
|
||||
calls = [call for call in calls if call[-1].a == 255]
|
||||
assert sum(call[0] == (f'LAST {heading}' if retained else heading) for call in calls) == 1, case
|
||||
assert all(call[0] != 'LIMIT' for call in calls), case
|
||||
ink = []
|
||||
@@ -441,14 +558,15 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
|
||||
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
|
||||
cx, cy = x+64*scale, y+72*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]))), case
|
||||
assert (color.r, color.g, color.b, color.a) == (0, 0, 0, 255), case
|
||||
expected = widget.pulse.color(rl.Color(244, 247, 245, 255), current.observed_ns / 1e9)
|
||||
assert (color.r, color.g, color.b, color.a) == (expected.r, expected.g, expected.b, expected.a), case
|
||||
else:
|
||||
# The inner stroke ends at x+4/x+114; keep one clear pixel beside the glyphs.
|
||||
assert x+5*scale <= box[0] < box[2] <= x+113*scale, case
|
||||
assert y+7*scale <= box[1] < box[3] <= y+125*scale, case
|
||||
# The inner stroke ends at x+4/x+124; keep one clear pixel beside the glyphs.
|
||||
assert x+5*scale <= box[0] < box[2] <= x+123*scale, case
|
||||
assert y+7*scale <= box[1] < box[3] <= y+137*scale, case
|
||||
ink.append((text, box))
|
||||
nominal_size = size/widget.text_scale
|
||||
if role == FontRole.BOLD:
|
||||
@@ -468,7 +586,7 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
|
||||
arrow = ax+left*arrow_scale, ay+top*arrow_scale, ax+right*arrow_scale, ay+bottom*arrow_scale
|
||||
label = next(box for text, box in ink if text == heading)
|
||||
assert abs(arrow[0] - label[2] - 6*scale) <= 1, case
|
||||
assert abs((label[0] + arrow[2]) / 2 - (x+59*scale)) <= .5, case
|
||||
assert abs((label[0] + arrow[2]) / 2 - (x+64*scale)) <= .5, case
|
||||
assert abs((label[1] + label[3]) / 2 - (arrow[1] + arrow[3]) / 2) <= 1, case
|
||||
if vienna:
|
||||
# The arrow's transparent bounding-box corners are not painted ink.
|
||||
@@ -476,8 +594,8 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
|
||||
assert np.all(np.linalg.norm(pixels-(cx, cy), axis=1) <= 47*scale), case
|
||||
assert .95*(label[3]-label[1]) <= arrow[3]-arrow[1] <= 1.25*(label[3]-label[1]), case
|
||||
else:
|
||||
assert x+7*scale <= arrow[0] < arrow[2] <= x+111*scale, case
|
||||
assert y+7*scale <= arrow[1] < arrow[3] <= y+125*scale, case
|
||||
assert x+7*scale <= arrow[0] < arrow[2] <= x+121*scale, case
|
||||
assert y+7*scale <= arrow[1] < arrow[3] <= y+137*scale, case
|
||||
assert all(box[2]+4*scale <= arrow[0] for text, box in ink if text == heading), (case, ink, arrow)
|
||||
assert arrow[3]+6*scale <= next(box[1] for text, box in ink if text==str(posted))
|
||||
ink.append(('arrow', arrow))
|
||||
@@ -512,9 +630,10 @@ def test_sign_profiles_share_layout_and_snap_only_final_pixels():
|
||||
calls = []
|
||||
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)):
|
||||
widget.render(current)
|
||||
calls = [call for call in calls if call[-1].a == 255]
|
||||
assert all(float(call[3]).is_integer() and float(call[4]).is_integer() for call in calls)
|
||||
if fonts.profile == Profile.LARGE and not vienna and not pending:
|
||||
assert calls[0][2] <= 28
|
||||
if not vienna:
|
||||
assert calls[0][2]/widget.text_scale <= 28
|
||||
elif fonts.profile == Profile.LARGE and not pending:
|
||||
assert 18 <= calls[0][2] / widget.text_scale <= 21
|
||||
elif vienna and pending and source == 'vision':
|
||||
@@ -532,8 +651,8 @@ def test_sign_profiles_share_layout_and_snap_only_final_pixels():
|
||||
start = int(vienna and not pending)
|
||||
for a, b in zip(small_calls[start:], large_calls[start:], strict=True):
|
||||
assert abs(a[2]/small_widget.text_scale-b[2]/large_widget.text_scale) < 1e-9
|
||||
assert abs((a[3]-330)-(b[3]-318)/1.25) <= 1
|
||||
assert abs((a[4]-20)-(b[4]-75)/1.25) <= 1
|
||||
assert abs((a[3]-320)-(b[3]-318)/LARGE_SIGN_SCALE) <= 1
|
||||
assert abs((a[4]-16)-(b[4]-75)/LARGE_SIGN_SCALE) <= 1
|
||||
assert (a[-1].r, a[-1].g, a[-1].b, a[-1].a) == (b[-1].r, b[-1].g, b[-1].b, b[-1].a)
|
||||
if pending:
|
||||
for widget in (small_widget, large_widget):
|
||||
@@ -554,7 +673,7 @@ 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'] == 12
|
||||
assert migrated['version'] == 13
|
||||
assert migrated['layouts']['large']['cruise_limits'] == default_document()['layouts']['large']['cruise_limits']
|
||||
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1550, 'y': 882, 'enabled': True}
|
||||
assert migrated['speedSources'] is True
|
||||
@@ -562,7 +681,7 @@ def test_v5_geometry_migrates_and_keeps_custom_actions_independent():
|
||||
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'] == 359
|
||||
assert validate_document(doc)['layouts']['large']['speed_limit_actions']['y'] == 381
|
||||
|
||||
|
||||
def test_pending_choices_use_the_sign_with_separated_horizontal_targets():
|
||||
@@ -581,7 +700,7 @@ 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'] == 5
|
||||
assert migrated['version'] == 6
|
||||
assert migrated['widgets']['speed_limit_actions']['y'] == 267
|
||||
assert migrated['widgets']['current_speed'] == doc['widgets']['current_speed']
|
||||
assert validate_layout_for_viewport(migrated, viewport) == migrated
|
||||
@@ -594,12 +713,14 @@ def test_committed_projection_v1_right_edge_is_preserved_and_clamped():
|
||||
doc['widgets']['cruise_limits'].update(x=1654, y=75)
|
||||
doc['widgets']['speed_limit_actions'].update(x=1654, y=500)
|
||||
migrated = validate_layout_for_viewport(doc, viewport)
|
||||
assert migrated['widgets']['cruise_limits']['x'] == 1239
|
||||
card = migrated['widgets']['cruise_limits']
|
||||
assert confirmation_bounds('large', card['x'], card['y'], opened=True)[2] <= 1830
|
||||
assert confirmation_bounds('large', card['x']+1, card['y'], opened=True)[2] > 1830
|
||||
assert migrated['widgets']['speed_limit_actions'] == {'x': 1510, 'y': 500, 'enabled': True}
|
||||
|
||||
|
||||
|
||||
def test_chooser_target_header_and_padded_labels_fit_the_native_ink():
|
||||
def test_chooser_binds_the_target_to_accept_and_keeps_all_native_ink_inside_its_control():
|
||||
from unittest.mock import patch
|
||||
from openpilot.starpilot.ui.large_speed_geometry import CONFIRMATION_SCALE
|
||||
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
|
||||
@@ -615,7 +736,9 @@ def test_chooser_target_header_and_padded_labels_fit_the_native_ink():
|
||||
try:
|
||||
for metric, posted, offset, show_offset in ((False, 65, 5, True), (True, 120, 10, True),
|
||||
(True, 50, -5, True), (False, 65, 5, False),
|
||||
(True, 120, 10, False), (True, 50, -5, False)):
|
||||
(True, 120, 10, False), (True, 50, -5, False),
|
||||
(True, 110, 10, True), (True, 110, 10, False),
|
||||
(False, 5, 0, False), (True, 5, 0, False)):
|
||||
current = state()
|
||||
factor = 3.6 if metric else 2.2369362921
|
||||
obs = replace(current.speed_limit, source='vision', speed_limit_mps=posted / factor,
|
||||
@@ -627,6 +750,7 @@ def test_chooser_target_header_and_padded_labels_fit_the_native_ink():
|
||||
notice_calls = []
|
||||
with patch.object(fonts, 'draw', lambda *args, sink=notice_calls: sink.append(args)):
|
||||
widget.render_confirmation(current)
|
||||
notice_calls = [call for call in notice_calls if call[-1].a == 255]
|
||||
notice_bounds = speed_limit_bounds(profile, current)
|
||||
assert notice_bounds is not None
|
||||
boxes = {}
|
||||
@@ -647,29 +771,52 @@ def test_chooser_target_header_and_padded_labels_fit_the_native_ink():
|
||||
else:
|
||||
assert f'{offset:+d}' not in boxes
|
||||
current = replace(current, slc_prompt_open=True)
|
||||
if posted == 110:
|
||||
document = default_document()
|
||||
document['showMaxSpeed'] = False
|
||||
document['layouts']['compact']['max_speed']['enabled'] = False
|
||||
current = replace(current, customization=document, speed_limit=replace(current.speed_limit,
|
||||
driver_override_active=True, override_basis='persistent', effective_cluster_target_mps=128 / 3.6))
|
||||
calls = []
|
||||
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)), patch.object(rl, 'draw_rectangle_rounded'):
|
||||
widget.render_confirmation(current)
|
||||
assert calls[0][0] == str(posted + offset)
|
||||
assert calls[1][0] == ('km/h' if metric else 'mph')
|
||||
assert calls[2][0] == (f'VISION {posted} {offset:+d}' if show_offset else 'VISION')
|
||||
calls = [call for call in calls if call[-1].a == 255]
|
||||
context = 'Now: OVERRIDE 128 km/h' if posted == 110 else 'New limit'
|
||||
assert [call[0] for call in calls] == [context, 'Accept', str(posted + offset),
|
||||
'km/h' if metric else 'mph', 'Reject', 'new limit']
|
||||
bounds = speed_limit_bounds(profile, current)
|
||||
assert bounds is not None
|
||||
previous_right = bounds[0]
|
||||
for text, role, size, x, y, _ in calls[:3]:
|
||||
for text, role, size, x, y, _ in calls[:1]:
|
||||
left, right = fonts.horizontal_ink(text, role, size)
|
||||
top, bottom = fonts.vertical_ink(text, role, size)
|
||||
assert x + left >= previous_right
|
||||
assert x + left >= bounds[0] + 10 * scale
|
||||
assert x + right <= bounds[2] - 10 * scale
|
||||
assert bounds[1] + 8 * scale <= y + top < y + bottom <= bounds[1] + 40 * scale
|
||||
previous_right = x + right
|
||||
for control, (text, role, size, x, y, _) in zip(slc_controls(profile, current), calls[-2:], strict=True):
|
||||
left, right = fonts.horizontal_ink(text, role, size)
|
||||
top, bottom = fonts.vertical_ink(text, role, size)
|
||||
assert x + left - control.bounds[0] >= 12 * scale
|
||||
assert control.bounds[2] - x - right >= 12 * scale
|
||||
assert control.bounds[1] + 10 * scale <= y + top < y + bottom <= control.bounds[3] - 10 * scale
|
||||
assert (bottom - top) / scale * 25.4 / 300 >= 2.8
|
||||
heading_centers = []
|
||||
for control, content in zip(slc_controls(profile, current), (calls[1:4], calls[4:]), strict=True):
|
||||
label_bottom = 0
|
||||
boxes = []
|
||||
for text, role, size, x, y, _ in content:
|
||||
left, right = fonts.horizontal_ink(text, role, size)
|
||||
top, bottom = fonts.vertical_ink(text, role, size)
|
||||
boxes.append((x + left, y + top, x + right, y + bottom))
|
||||
assert x + left - control.bounds[0] >= 12 * scale - 1e-6
|
||||
assert control.bounds[2] - x - right >= 12 * scale - 1e-6
|
||||
assert control.bounds[1] + 10 * scale <= y + top < y + bottom <= control.bounds[3] - 10 * scale
|
||||
if text == control.label:
|
||||
label_bottom = y + bottom
|
||||
assert (bottom - top) / scale * 25.4 / 300 >= 2.8
|
||||
else:
|
||||
assert y + top >= label_bottom + 6 * scale
|
||||
cx, cy = (control.bounds[0] + control.bounds[2]) / 2, (control.bounds[1] + control.bounds[3]) / 2
|
||||
assert (boxes[0][0] + boxes[0][2]) / 2 == pytest.approx(cx)
|
||||
assert (min(box[0] for box in boxes[1:]) + max(box[2] for box in boxes[1:])) / 2 == pytest.approx(cx)
|
||||
assert (min(box[1] for box in boxes) + max(box[3] for box in boxes)) / 2 == pytest.approx(cy)
|
||||
heading_centers.append((boxes[0][1] + boxes[0][3]) / 2)
|
||||
if control.label == 'Accept':
|
||||
assert boxes[1][3] == pytest.approx(boxes[2][3]), 'Speed and unit share a lower edge'
|
||||
assert (boxes[1][3] - boxes[1][1]) / scale * 25.4 / 300 >= (3.3 if profile == Profile.LARGE else 2.7)
|
||||
assert heading_centers[0] == pytest.approx(heading_centers[1])
|
||||
finally:
|
||||
widget.close()
|
||||
finally:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -70,7 +71,9 @@ def test_every_scene_matches_native_border_mode_and_widget_visibility(target):
|
||||
expected['steering_wheel'] = not state.lateral_active
|
||||
expected['max_speed'] = not state.cruise_active
|
||||
expected['speed_limit'] = str(state.speed_limit.kind) != 'valid'
|
||||
expected['driver_monitor'] = not monitor_visible(profile, state, fresh=True, onroad=True,
|
||||
# Before native art arrives, scene rules cover eligibility; native pixels supply prompt occlusion.
|
||||
eligibility = replace(state, speed_limit=replace(state.speed_limit, pending_speed_limit_mps=None))
|
||||
expected['driver_monitor'] = not monitor_visible(profile, eligibility, fresh=True, onroad=True,
|
||||
top_icons=state.cruise_active and layout['max_speed']['enabled'])
|
||||
assert scene['hidden'] == expected, (target, scene['id'], move)
|
||||
|
||||
|
||||
@@ -47,6 +47,55 @@ def test_widget_art_registry_is_shared_with_native_and_projection_metadata():
|
||||
assert layout_metadata_for_viewport((2880, 1080))['runtimePreviewWidgets'] == list(RUNTIME_PREVIEW_WIDGETS['large'])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile', ['large', 'compact'])
|
||||
def test_native_prompt_art_parses_in_galaxy_and_reports_obscured_widgets(profile):
|
||||
import io
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from PIL import Image
|
||||
from openpilot.starpilot.ui.layout_preview_renderer import _Canvas, sample_state
|
||||
from openpilot.starpilot.ui.onroad_customization import validate_document
|
||||
from openpilot.starpilot.ui.presentation import Profile
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
|
||||
document = default_document()
|
||||
layout = document['layouts'][profile]
|
||||
if profile == 'compact':
|
||||
layout['max_speed']['enabled'] = False
|
||||
layout['speed_limit'].update(x=200, y=20)
|
||||
x, y, obscured = 200, 0, 'driver_monitor'
|
||||
else:
|
||||
layout['cruise_limits']['x'] = 600
|
||||
x, y, obscured = 900, 80, 'current_speed'
|
||||
for key in ('clock', 'driving_mode_descriptions'):
|
||||
layout[key].update(enabled=True, x=x, y=y)
|
||||
document = validate_document(document)
|
||||
own_window = not rl.is_window_ready()
|
||||
if own_window:
|
||||
rl.init_window(128, 128, 'Native preview contract')
|
||||
canvas = _Canvas(Profile(profile))
|
||||
try:
|
||||
for scene, visible in [('slc_pending', False), ('engaged', True)]:
|
||||
png = canvas.render_widget_art(sample_state(scene, document), document['layouts'][profile])
|
||||
with Image.open(io.BytesIO(png)) as image:
|
||||
native = json.loads(image.info['starpilot-widget-art'])
|
||||
assert len(native['sprites']) == len(RUNTIME_PREVIEW_WIDGETS[profile])
|
||||
for key in (obscured, 'clock', 'driving_mode_descriptions'):
|
||||
assert native['sprites'][key]['visible'] is visible, (scene, key)
|
||||
parser = Path(__file__).parents[2] / 'galaxy/web/js/widget-art.js'
|
||||
script = f"""
|
||||
import {{widgetArtMetadata}} from {json.dumps(parser.as_uri())};
|
||||
const data = []; for await (const part of process.stdin) data.push(part);
|
||||
console.log(JSON.stringify(await widgetArtMetadata(new Blob(data))));
|
||||
"""
|
||||
result = subprocess.run(['node', '--input-type=module', '-e', script], input=png, capture_output=True, check=True)
|
||||
assert json.loads(result.stdout) == native
|
||||
finally:
|
||||
canvas.close()
|
||||
if own_window:
|
||||
rl.close_window()
|
||||
|
||||
|
||||
def test_transparent_render_coverage_exports_straight_alpha_without_darkening():
|
||||
straight = _straight_rgba(bytes((128, 64, 0, 128, 9, 8, 7, 0, 255, 255, 255, 255)))
|
||||
assert straight == bytes((255, 128, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255))
|
||||
|
||||
@@ -32,7 +32,7 @@ class TestOnroadAxes(unittest.TestCase):
|
||||
def setUp(self):
|
||||
for name in ('draw_rectangle_rounded', 'draw_rectangle_rounded_lines_ex', 'draw_circle', 'draw_ring'):
|
||||
self.enterContext(patch('openpilot.starpilot.ui.speed_limit_widget.rl.' + name, new_callable=Mock))
|
||||
for name in ('circle', 'arrow', 'outline', 'confirmation'):
|
||||
for name in ('circle', 'arrow', 'outline', 'button'):
|
||||
self.enterContext(patch('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.' + name, new_callable=Mock))
|
||||
self.ui = ui_fake()
|
||||
self.ui.CP = NS(carFingerprint='', openpilotLongitudinalControl=True, pcmCruise=False)
|
||||
@@ -316,12 +316,11 @@ class TestOnroadAxes(unittest.TestCase):
|
||||
view.steering_wheel = Mock()
|
||||
view.compact_hud = Mock()
|
||||
view.compact_sidebar = Mock()
|
||||
view._fade = None
|
||||
with patch.object(onroad.clip, "begin_scissor_mode"), patch.object(onroad.clip, "end_scissor_mode"), \
|
||||
patch.object(onroad.rl, "draw_rectangle_rec"), patch.object(onroad.rl, "draw_rectangle_gradient_v"), \
|
||||
patch.object(onroad.rl, "draw_rectangle_lines_ex"), patch.object(onroad.rl, "draw_rectangle"), \
|
||||
patch.object(onroad.rl, "draw_texture_ex"), patch.object(onroad.rl, "draw_rectangle_rounded_lines_ex") as border, \
|
||||
patch.object(onroad, "render_corner_hint"), patch.object(view, "_prepare_compact_fade"), \
|
||||
patch.object(onroad, "render_corner_hint"), \
|
||||
patch.object(view, "_slc_actions"):
|
||||
view.render(state)
|
||||
expected = compact_color if profile == Profile.COMPACT else rgba(axis_status_color(state))
|
||||
|
||||
@@ -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'] == 5
|
||||
assert aa['version'] == 6
|
||||
aa['version'] = 2
|
||||
aa['widgets'][CEM_GAUGE_WIDGET].update(x=900, y=200)
|
||||
moved = validate_layout_for_viewport(aa, viewport)
|
||||
assert moved['version'] == 5 and (moved['widgets'][CEM_GAUGE_WIDGET]['x'], moved['widgets'][CEM_GAUGE_WIDGET]['y']) == (944, 224)
|
||||
assert moved['version'] == 6 and (moved['widgets'][CEM_GAUGE_WIDGET]['x'], moved['widgets'][CEM_GAUGE_WIDGET]['y']) == (944, 224)
|
||||
assert validate_layout_for_viewport(moved, viewport) == moved
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ def drawing_guard(monkeypatch):
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.arrow', Mock())
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.outline', Mock())
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.circle', Mock())
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.confirmation', Mock())
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.button', Mock())
|
||||
|
||||
|
||||
def road(document=None, **changes):
|
||||
@@ -43,6 +43,57 @@ def test_torque_bar_keeps_its_familiar_customization_label():
|
||||
assert profile['widgets']['torque_bar']['label'] == 'Torque bar'
|
||||
|
||||
|
||||
def test_v12_portrait_upgrade_preserves_preferences_and_is_idempotent():
|
||||
import copy
|
||||
old = default_document()
|
||||
old['version'] = 12
|
||||
old['layouts']['compact']['speed_limit'].update(x=330, y=20)
|
||||
old['layouts']['large']['current_speed'].update(x=700, enabled=False)
|
||||
old['widgetColors']['large']['current_speed'] = {'text': '#ABCDEF80'}
|
||||
old['reduceMotion'] = True
|
||||
before = copy.deepcopy(old)
|
||||
updated = validate_document(old)
|
||||
expected = copy.deepcopy(old)
|
||||
expected['version'] = 13
|
||||
expected['layouts']['compact']['speed_limit'].update(x=320, y=16)
|
||||
assert old == before and updated == expected
|
||||
assert validate_document(updated) == updated
|
||||
|
||||
|
||||
def test_v12_edge_placements_remain_bounded_even_with_confirmation_disabled():
|
||||
old = default_document()
|
||||
old['version'] = 12
|
||||
for layout in old['layouts'].values():
|
||||
layout['speed_limit_actions']['enabled'] = False
|
||||
old['layouts']['large']['cruise_limits']['y'] = 882
|
||||
old['layouts']['compact']['speed_limit'].update(x=358, y=108)
|
||||
updated = validate_document(old)
|
||||
assert updated['layouts']['large']['cruise_limits']['y'] == 856
|
||||
assert updated['layouts']['compact']['speed_limit'] == {'x': 348, 'y': 96, 'enabled': True}
|
||||
assert validate_document(updated) == updated
|
||||
assert all(not layout['speed_limit_actions']['enabled'] for layout in updated['layouts'].values())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('separate', [False, True])
|
||||
def test_v12_source_attachment_follows_the_new_row_without_moving_custom_actions(separate):
|
||||
old = default_document()
|
||||
old.update(version=12, speedSources=True, speedSourcesCard=separate)
|
||||
actions = old['layouts']['large']['speed_limit_actions']
|
||||
actions['y'] = 367 if separate else 359
|
||||
updated = validate_document(old)
|
||||
assert updated['layouts']['large']['speed_limit_actions']['y'] == (389 if separate else 381)
|
||||
assert validate_document(updated) == updated
|
||||
actions.update(x=600, y=400)
|
||||
assert validate_document(old)['layouts']['large']['speed_limit_actions'] == actions
|
||||
|
||||
|
||||
def test_current_portrait_layout_rejects_a_chooser_outside_the_compact_screen():
|
||||
document = default_document()
|
||||
document['layouts']['compact']['speed_limit']['x'] = 330
|
||||
with pytest.raises(ValueError, match='confirmation'):
|
||||
validate_document(document)
|
||||
|
||||
|
||||
def test_defaults_and_metadata_are_independent_and_valid():
|
||||
document = default_document()
|
||||
assert validate_document(document) == document
|
||||
@@ -92,7 +143,7 @@ def test_source_attachment_saves_real_bounded_positions_without_changing_input(v
|
||||
assert placement(saved, 'large', 'speed_limit_actions') == actions
|
||||
assert actions['y'] == card['y'] + widget_size(saved, 'large', 'cruise_limits')[1] + 8
|
||||
assert actions['y'] + widget_size(saved, 'large', 'speed_limit_actions')[1] <= 1050
|
||||
assert actions['y'] == (367 if separate else 359)
|
||||
assert actions['y'] == (389 if separate else 381)
|
||||
|
||||
|
||||
def test_v7_speed_layout_migration_is_bounded_non_mutating_and_idempotent():
|
||||
@@ -114,12 +165,12 @@ 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'] == 12 and validate_document(migrated) == migrated
|
||||
assert migrated['layouts']['large']['cruise_limits']['x'] == 1510
|
||||
assert migrated['version'] == 13 and validate_document(migrated) == migrated
|
||||
assert migrated['layouts']['large']['cruise_limits']['x'] == 1437
|
||||
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1510, 'y': 882, 'enabled': True}
|
||||
assert migrated['layouts']['large']['current_speed'] == original['layouts']['large']['current_speed']
|
||||
assert migrated['layouts']['large']['cem_gauge'] == {'x': 56, 'y': 536, 'enabled': True}
|
||||
assert migrated['layouts']['compact']['speed_limit']['x'] == 330
|
||||
assert migrated['layouts']['compact']['speed_limit']['x'] == 320
|
||||
assert migrated['layouts']['compact']['max_speed']['y'] == 60
|
||||
assert migrated['widgetColors'] == original['widgetColors'] and migrated['speedSources']
|
||||
document['layouts']['large']['cruise_limits']['x'] = 2000
|
||||
@@ -144,11 +195,11 @@ 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'] == 12 and validate_document(migrated) == migrated
|
||||
assert migrated['version'] == 13 and validate_document(migrated) == migrated
|
||||
assert migrated['layouts']['large'] == document['layouts']['large']
|
||||
assert migrated['widgetColors'] == document['widgetColors']
|
||||
assert migrated['widgetOrder'] == document['widgetOrder']
|
||||
expected = document['layouts']['compact']['speed_limit'] if custom else {'x': 330, 'y': 20, 'enabled': True}
|
||||
expected = document['layouts']['compact']['speed_limit'] if custom else {'x': 320, 'y': 16, 'enabled': True}
|
||||
assert migrated['layouts']['compact']['speed_limit'] == expected
|
||||
assert migrated['layouts']['compact']['max_speed']['x'] == 0
|
||||
assert migrated['layouts']['compact']['speed_limit_actions'] == {'x': 146, 'y': 44, 'enabled': True}
|
||||
@@ -164,7 +215,7 @@ def test_v8_compact_speed_widgets_at_old_edges_stay_in_bounds_after_upgrade():
|
||||
document['layouts']['compact']['speed_limit'].update(x=212, y=158)
|
||||
migrated = validate_document(document)
|
||||
assert migrated['layouts']['compact']['max_speed'] == {'x': 0, 'y': 0, 'enabled': True}
|
||||
assert migrated['layouts']['compact']['speed_limit'] == {'x': 330, 'y': 20, 'enabled': True}
|
||||
assert migrated['layouts']['compact']['speed_limit'] == {'x': 320, 'y': 16, 'enabled': True}
|
||||
assert validate_document(migrated) == migrated
|
||||
|
||||
|
||||
@@ -185,7 +236,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'] == 12 and validate_document(migrated) == migrated
|
||||
assert migrated['version'] == 13 and validate_document(migrated) == migrated
|
||||
assert migrated['layouts']['large'] == {**original['layouts']['large'], 'speed_limit_actions': large_actions}
|
||||
assert migrated['layouts']['compact']['speed_limit'] == default_document()['layouts']['compact']['speed_limit']
|
||||
assert migrated['layouts']['compact']['speed_limit_actions'] == {'x': 146, 'y': 44, 'enabled': True}
|
||||
@@ -230,7 +281,7 @@ def test_protected_compact_actions_reject_actual_overlap(widget, x, y):
|
||||
validate_document(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("widget,x,y", [("speed_limit", 330, 24), ("max_speed", 0, 0), ("steering_wheel", 78, 174)])
|
||||
@pytest.mark.parametrize("widget,x,y", [("speed_limit", 320, 23), ("max_speed", 0, 0), ("steering_wheel", 78, 174)])
|
||||
def test_protected_zone_edge_touch_is_valid(widget, x, y):
|
||||
document = default_document()
|
||||
document["layouts"]["compact"][widget].update(x=x, y=y)
|
||||
@@ -304,7 +355,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"] == 12
|
||||
assert migrated["version"] == 13
|
||||
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}
|
||||
@@ -356,7 +407,7 @@ 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": 12', '"version": 11, "version": 11').encode()
|
||||
raw = json.dumps(document).replace('"version": 13', '"version": 11, "version": 11').encode()
|
||||
with patch("openpilot.starpilot.ui.onroad_customization.read_saved", return_value=(raw, True)):
|
||||
assert read_customization(Mock()) == default_document()
|
||||
|
||||
@@ -372,7 +423,7 @@ def test_v9_large_sign_migration_preserves_customizations_and_clamps_bottom_edge
|
||||
before = deepcopy(old)
|
||||
updated = validate_document(old)
|
||||
assert old == before
|
||||
assert updated['version'] == 12
|
||||
assert updated['version'] == 13
|
||||
assert updated['layouts']['large']['cruise_limits']['y'] == default_document()['layouts']['large']['cruise_limits']['y']
|
||||
assert updated['layouts']['large']['speed_limit_actions'] == before['layouts']['large']['speed_limit_actions']
|
||||
assert updated['layouts']['compact'] == before['layouts']['compact']
|
||||
@@ -482,9 +533,9 @@ def test_large_draw_origins_and_wheel_input_move_together():
|
||||
with patch("openpilot.starpilot.ui.onroad_large_widgets.draw_control_card") as card, patch.object(rl, "draw_line_ex"):
|
||||
UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
|
||||
card.assert_not_called()
|
||||
assert foreground_calls(fonts.draw)[0].args[3:5] == (292, 109)
|
||||
assert foreground_calls(fonts.draw)[0].args[3:5] == (292, 107)
|
||||
CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
|
||||
assert fonts.draw.call_args_list[-2].args[3:5] == (965, 112)
|
||||
assert foreground_calls(fonts.draw)[-2].args[3:5] == (965, 112)
|
||||
wheel = SteeringWheelWidget(Path('/unused'))
|
||||
wheel._texture = Mock()
|
||||
with patch.object(rl, "draw_circle") as circle, patch.object(rl, "draw_texture_pro"):
|
||||
@@ -515,10 +566,10 @@ def test_frozen_default_draw_geometry_and_neutral_colors():
|
||||
UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
|
||||
card.assert_not_called()
|
||||
assert fonts.draw.call_args_list[0].args[0] == 'SET'
|
||||
assert foreground_calls(fonts.draw)[0].args[3:5] == (192, 89)
|
||||
assert foreground_calls(fonts.draw)[0].args[3:5] == (192, 87)
|
||||
CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
|
||||
assert fonts.draw.call_args_list[-2].args[3:5] == (925, 72)
|
||||
assert fonts.draw.call_args_list[-1].args[3:5] == pytest.approx((925, 215.2))
|
||||
assert foreground_calls(fonts.draw)[-2].args[3:5] == (925, 72)
|
||||
assert foreground_calls(fonts.draw)[-1].args[3:5] == pytest.approx((925, 215.2))
|
||||
wheel = SteeringWheelWidget(Path('/unused'))
|
||||
wheel._texture = Mock()
|
||||
with patch.object(rl, "draw_circle") as circle, patch.object(rl, "draw_texture_pro"):
|
||||
@@ -541,16 +592,17 @@ def test_compact_us_sign_keeps_camera_visible_at_saved_position(speed):
|
||||
hud._speed_limit_sign(state)
|
||||
fill.assert_not_called()
|
||||
outline.assert_called_once()
|
||||
assert outline.call_args.args[:2] == (259, 96)
|
||||
assert outline.call_args.args[:2] == (264, 102)
|
||||
assert [call.args[0] for call in foreground_calls(fonts.draw)] == ['SPEED', str(speed)]
|
||||
assert foreground_calls(fonts.draw)[0].args[3:5] == (254, 50)
|
||||
assert foreground_calls(fonts.draw)[0].args[3:5] == (259, 46)
|
||||
|
||||
|
||||
def test_compact_drag_keeps_animation_state_and_separate_origins():
|
||||
document = default_document()
|
||||
document["layouts"]["compact"]["max_speed"].update(x=100, y=10)
|
||||
document["layouts"]["compact"]["steering_wheel"].update(x=90, y=150)
|
||||
fonts = NS(profile=Profile.COMPACT, draw=Mock(), vertical_ink=lambda text, role, size: (0, size * .7), measure=lambda text, _role, size: NS(
|
||||
fonts = NS(profile=Profile.COMPACT, draw=Mock(), vertical_ink=lambda text, role, size: (0, size * .7),
|
||||
horizontal_ink=lambda text, role, size: (0, len(text) * size * .5), measure=lambda text, _role, size: NS(
|
||||
width=len(text) * size * .5, height=size * Profile.COMPACT.font_scale))
|
||||
hud = CompactHudRenderer(Mock(spec=BitmapFonts, **vars(fonts)), Mock())
|
||||
hud._wheel = Mock()
|
||||
@@ -558,7 +610,7 @@ def test_compact_drag_keeps_animation_state_and_separate_origins():
|
||||
with patch.object(hud, "_speed_limit_sign"), patch.object(rl, "draw_circle_gradient"), patch.object(rl, "draw_texture_pro") as wheel:
|
||||
hud.render(state)
|
||||
hud.render(state)
|
||||
assert foreground_calls(fonts.draw)[-1].args[3:5] == (125, 119)
|
||||
assert foreground_calls(fonts.draw)[-1].args[3:5] == (125, 56)
|
||||
rect = wheel.call_args.args[2]
|
||||
assert (rect.x, rect.y) == (115, 175)
|
||||
alpha = hud._set_speed_alpha.x
|
||||
@@ -627,7 +679,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'] == 12
|
||||
assert result['version'] == 13
|
||||
assert result['palette'] == old['palette']
|
||||
for profile, layout in old['layouts'].items():
|
||||
for key, position in layout.items():
|
||||
@@ -664,10 +716,10 @@ def test_widget_color_isolation_and_native_text_resolution():
|
||||
assert rgba(validated, 'text', 'compact', 'max_speed') == (16, 32, 48, 64)
|
||||
assert rgba(validated, 'text', 'large', 'cruise_limits') == (16, 32, 48, 64)
|
||||
assert widget_palette(validated, 'compact', 'conditional_mode')['cardFill'] == '#00000000'
|
||||
fonts = NS(measure=lambda *a, **k: NS(width=10, height=20), vertical_ink=lambda text, role, size: (0., size * .7),
|
||||
fonts = NS(profile=Profile.LARGE, measure=lambda *a, **k: NS(width=10, height=20), vertical_ink=lambda text, role, size: (0., size * .7),
|
||||
horizontal_ink=lambda text, role, size: (0., 10.), draw=Mock())
|
||||
CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(rl.Rectangle(30, 30, 1800, 1020), road(validated))
|
||||
actual = fonts.draw.call_args_list[0].args[-1]
|
||||
actual = foreground_calls(fonts.draw)[0].args[-1]
|
||||
assert (actual.r, actual.g, actual.b, actual.a) == (170, 187, 204, 221)
|
||||
|
||||
|
||||
@@ -746,7 +798,7 @@ def test_driver_frame_matches_native_preview_and_keeps_face_renderer_separate(mo
|
||||
def test_small_sign_action_overlap_roundtrips_without_hiding_sign(enabled):
|
||||
document = default_document()
|
||||
large = json.loads(json.dumps(document["layouts"]["large"]))
|
||||
document["layouts"]["compact"]["speed_limit"].update(x=330, y=20, enabled=enabled)
|
||||
document["layouts"]["compact"]["speed_limit"].update(x=320, y=16, enabled=enabled)
|
||||
document["layouts"]["compact"]["speed_limit_actions"].update(x=150, y=44)
|
||||
assert decode_document(json.dumps(validate_document(document))) == document
|
||||
assert document["layouts"]["large"] == large
|
||||
|
||||
@@ -189,7 +189,7 @@ def test_valid_vision_limit_has_persistent_sign_without_action_receipt():
|
||||
renderer._speed_limit_sign(shown)
|
||||
fill.assert_not_called()
|
||||
outline.assert_called_once()
|
||||
assert outline.call_args.args[:2] == (389, 86)
|
||||
assert outline.call_args.args[:2] == (384, 88)
|
||||
assert [call.args[0] for call in foreground_calls(fonts.draw)] == ['VISION', '55']
|
||||
outline.reset_mock()
|
||||
renderer._speed_limit_sign(replace(shown, speed_limit=SpeedLimitObservation(kind=ObservationKind.STALE)))
|
||||
@@ -200,7 +200,7 @@ def test_valid_vision_limit_has_persistent_sign_without_action_receipt():
|
||||
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()
|
||||
assert [call.args[0] for call in fonts.draw.call_args_list] == ['VISION', '55']
|
||||
assert [call.args[0] for call in foreground_calls(fonts.draw)] == ['VISION', '55']
|
||||
|
||||
|
||||
def test_pending_confirmation_omits_redundant_speed_heading():
|
||||
@@ -216,9 +216,10 @@ def test_pending_confirmation_omits_redundant_speed_heading():
|
||||
view.fonts.horizontal_ink.return_value = (0, 50)
|
||||
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
|
||||
view.compact_hud = CompactHudRenderer(view.fonts, Mock())
|
||||
with patch.object(rl, 'draw_line_ex'), patch.object(rl, 'draw_rectangle_rounded'), patch.object(SpeedLimitShapes, 'confirmation'):
|
||||
with patch.object(rl, 'draw_line_ex'), patch.object(rl, 'draw_rectangle_rounded'), \
|
||||
patch.object(SpeedLimitShapes, 'button'):
|
||||
view._slc_actions(state)
|
||||
assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['35', 'mph', 'VISION', 'Accept', 'Reject']
|
||||
assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['New limit', 'Accept', '35', 'mph', 'Reject', 'new limit']
|
||||
|
||||
|
||||
def test_compact_pending_label_and_button_follow_saved_action_placement():
|
||||
@@ -238,11 +239,12 @@ def test_compact_pending_label_and_button_follow_saved_action_placement():
|
||||
view.fonts.horizontal_ink.return_value = (0, 50)
|
||||
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
|
||||
view.compact_hud = CompactHudRenderer(view.fonts, Mock())
|
||||
with patch.object(rl, 'draw_rectangle_rounded') as button, patch.object(rl, 'draw_line_ex'), patch.object(SpeedLimitShapes, 'confirmation'):
|
||||
with patch.object(rl, 'draw_rectangle_rounded') as button, patch.object(rl, 'draw_line_ex'), \
|
||||
patch.object(SpeedLimitShapes, 'button'):
|
||||
view._slc_actions(state)
|
||||
assert [call.args[0] for call in foreground_calls(view.fonts.draw)][-2:] == ['Accept', 'Reject']
|
||||
assert button.call_count == 4
|
||||
assert slc_controls(Profile.COMPACT, state)[0].bounds == (156., 48., 300., 160.)
|
||||
assert [call.args[0] for call in foreground_calls(view.fonts.draw) if call.args[0] in ('Accept', 'Reject')] == ['Accept', 'Reject']
|
||||
button.assert_not_called()
|
||||
assert slc_controls(Profile.COMPACT, state)[0].bounds == (156., 48., 306., 160.)
|
||||
|
||||
|
||||
def test_fresh_physical_blinker_uses_upstream_art_without_optional_border():
|
||||
|
||||
@@ -824,6 +824,8 @@ class TestRuntimeSnapshot(unittest.TestCase):
|
||||
def test_zero_speed_remains_visible_across_signed_filtered_estimates(self):
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
from openpilot.starpilot.ui.onroad_large_widgets import CurrentSpeedHud
|
||||
from openpilot.starpilot.ui.presentation import Profile
|
||||
from openpilot.starpilot.ui.tests.test_unified_speed_native import foreground_calls
|
||||
|
||||
for metric in (False, True):
|
||||
ui = ui_fake()
|
||||
@@ -836,12 +838,13 @@ class TestRuntimeSnapshot(unittest.TestCase):
|
||||
state = adapter.build(ShellMode.ONROAD, now_ns=NOW).onroad
|
||||
self.assertEqual(state.speed_mps, max(0.0, speed))
|
||||
fonts = Mock(spec=BitmapFonts)
|
||||
fonts.profile = Profile.LARGE
|
||||
fonts.measure.return_value = NS(width=10, height=20)
|
||||
fonts.vertical_ink.return_value = (0., 20.)
|
||||
CurrentSpeedHud(fonts).render(rl.Rectangle(0, 0, 1800, 1020), state)
|
||||
self.assertEqual(fonts.draw.call_args_list[0].args[0], "1" if speed == 0.44704 and not metric else
|
||||
self.assertEqual(foreground_calls(fonts.draw)[0].args[0], "1" if speed == 0.44704 and not metric else
|
||||
str(round(max(0., speed) * (3.6 if metric else 2.2369362921))))
|
||||
self.assertEqual(fonts.draw.call_args_list[1].args[0], "km/h" if metric else "mph")
|
||||
self.assertEqual(foreground_calls(fonts.draw)[1].args[0], "km/h" if metric else "mph")
|
||||
ui.sm.logMonoTime["carState"] = NOW - 2_000_000_000
|
||||
self.assertIsNone(adapter.build(ShellMode.ONROAD, now_ns=NOW).onroad.speed_mps)
|
||||
|
||||
|
||||
@@ -50,19 +50,19 @@ def test_closed_and_open_card_share_anchor_and_physically_scaled_targets(profile
|
||||
opened = touch.present(current)
|
||||
after = speed_limit_bounds(profile, opened)
|
||||
assert before is not None and after is not None
|
||||
assert before[1::2] == after[1::2]
|
||||
assert before[0 if profile == Profile.LARGE else 2] == after[0 if profile == Profile.LARGE else 2]
|
||||
assert before[1] == after[1] and before[3] <= after[3]
|
||||
assert after[0] <= before[0] < before[2] <= after[2]
|
||||
first, last = slc_controls(profile, opened)
|
||||
scale = CONFIRMATION_SCALE[profile]
|
||||
assert (first.bounds[2] - first.bounds[0]) / scale == pytest.approx(144)
|
||||
assert (first.bounds[3] - first.bounds[1]) / scale == pytest.approx(112)
|
||||
assert (last.bounds[0] - first.bounds[2]) / scale == pytest.approx(8)
|
||||
assert (first.bounds[2] - first.bounds[0]) / scale == pytest.approx(180 if profile == Profile.LARGE else 150)
|
||||
assert (first.bounds[3] - first.bounds[1]) / scale == pytest.approx(128 if profile == Profile.LARGE else 112)
|
||||
assert (last.bounds[0] - first.bounds[2]) / scale == pytest.approx(12)
|
||||
assert (first.bounds[0] - after[0]) / scale == pytest.approx(8)
|
||||
assert (after[2] - last.bounds[2]) / scale == pytest.approx(8)
|
||||
torque = current.customization['layouts'][str(profile)]['torque_bar']
|
||||
assert after[3] < torque['y']
|
||||
if profile == Profile.COMPACT:
|
||||
assert after == (148., 8, 460., 160.)
|
||||
assert after == (148., 8, 476., 160.)
|
||||
assert after[0] > 146
|
||||
|
||||
|
||||
@@ -136,19 +136,19 @@ def test_new_layouts_reject_torque_overlap_and_legacy_migration_is_targeted(prof
|
||||
original = deepcopy(doc)
|
||||
migrated = validate_document(doc)
|
||||
assert doc == original
|
||||
assert migrated['version'] == 12
|
||||
assert migrated['version'] == 13
|
||||
assert migrated['palette'] == doc['palette']
|
||||
assert migrated['layouts']['large']['current_speed'] == doc['layouts']['large']['current_speed']
|
||||
assert migrated['layouts'][str(profile)][owner]['y'] == default_document()['layouts'][str(profile)][owner]['y']
|
||||
assert validate_document(migrated) == migrated
|
||||
|
||||
|
||||
def test_legacy_sources_keep_guidance_below_the_row_and_above_the_gauge():
|
||||
def test_legacy_sources_keep_the_saved_action_area_below_the_row():
|
||||
doc = default_document()
|
||||
doc.update(version=11, speedSources=True, speedSourcesCard=True)
|
||||
doc['layouts']['large']['speed_limit_actions']['y'] = 351
|
||||
migrated = validate_document(doc)
|
||||
assert migrated['layouts']['large']['speed_limit_actions']['y'] == 367
|
||||
assert migrated['layouts']['large']['speed_limit_actions']['y'] == 389
|
||||
assert validate_document(migrated) == migrated
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Controller output and native layouts agree across driver actions and saved layouts."""
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.speed_limits import runtime_settings as settings
|
||||
from openpilot.starpilot.speed_limits.acceptance import Policy
|
||||
from openpilot.starpilot.speed_limits.runtime import Action
|
||||
from openpilot.starpilot.speed_limits.tests import test_runtime_replay as replay
|
||||
from openpilot.starpilot.speed_limits.tests.test_core_flow import controller, tick
|
||||
from openpilot.starpilot.ui.onroad_customization import default_document, validate_document
|
||||
from openpilot.starpilot.ui.onroad import OnroadView
|
||||
from openpilot.starpilot.ui.onroad_dm import monitor_visible
|
||||
from openpilot.starpilot.ui.onroad_large_widgets import CurrentSpeedHud, UnifiedSpeedWidget
|
||||
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
|
||||
from openpilot.starpilot.ui.onroad_state import OnroadState, speed_limit_from_message
|
||||
from openpilot.starpilot.ui.presentation import Profile
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget, set_speed_status, speed_status
|
||||
from openpilot.starpilot.ui.tests.test_unified_speed_native import drawing as drawing, fonts, foreground_calls, state
|
||||
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [settings.PedalOverride.SPEED_REACHED, settings.PedalOverride.SET_SPEED])
|
||||
def test_controller_messages_keep_set_stable_through_pedal_release_stop_and_resume(mode):
|
||||
fixture = replay.RuntimeReplayTests()
|
||||
fixture.setUp()
|
||||
sm, cp = fixture.sm, fixture.cp
|
||||
runtime = controller()
|
||||
runtime.settings = replace(runtime.settings, pedal_override=mode)
|
||||
|
||||
def display(step):
|
||||
output = tick(runtime, sm, cp, step)
|
||||
view = OnroadState(True, False, max(0., sm['carState'].vEgoCluster), 100., speed_limit_from_message(output.message.slcState),
|
||||
metric=True, longitudinal_active=True)
|
||||
return view, resolve_unified_speed(view)
|
||||
|
||||
_, first = display(0)
|
||||
assert first.limit_in_use and first.max_text == '100'
|
||||
sm['carState'].vEgo = sm['carState'].vEgoCluster = 95. / 3.6
|
||||
sm['carState'].gasPressed = True
|
||||
pressed, shown = display(1)
|
||||
assert shown.override_text is None and not shown.limit_in_use
|
||||
assert speed_status(pressed, Profile.COMPACT, shown) == 'PEDAL'
|
||||
assert set_speed_status(pressed, Profile.COMPACT, shown) is None
|
||||
sm['carState'].gasPressed = False
|
||||
_, released = display(2)
|
||||
assert released.override_text == ('95' if mode is settings.PedalOverride.SPEED_REACHED else '100')
|
||||
sm['carState'].standstill = True
|
||||
sm['carState'].vEgo = sm['carState'].vEgoCluster = -.08
|
||||
_, stopped = display(3)
|
||||
assert stopped.override_text == released.override_text and stopped.max_text == '100'
|
||||
sm['carState'].standstill = False
|
||||
sm['carState'].vEgo = sm['carState'].vEgoCluster = 1.
|
||||
assert display(4)[1].override_text == released.override_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile', list(Profile))
|
||||
@pytest.mark.parametrize('mode', ('limit', 'override', 'pedal'))
|
||||
def test_hidden_set_keeps_control_context_inside_moved_confirmation(profile, mode, drawing):
|
||||
document = default_document()
|
||||
document['showMaxSpeed'] = False
|
||||
document['layouts']['compact']['max_speed']['enabled'] = False
|
||||
document['layouts']['compact']['speed_limit'].update(x=200, y=20)
|
||||
document = validate_document(document)
|
||||
current = state(customization=document, slc_prompt_open=True)
|
||||
current = replace(current, longitudinal_overridden=mode == 'pedal', speed_limit=replace(current.speed_limit,
|
||||
source='vision', pending_source='vision', speed_limit_mps=110 / 3.6, pending_speed_limit_mps=110 / 3.6,
|
||||
pending_adjusted_limit_mps=120 / 3.6, presentation_adjusted_limit_mps=120 / 3.6,
|
||||
limiting_max_set=mode == 'limit', driver_override_active=mode != 'limit',
|
||||
override_basis='pedal' if mode == 'pedal' else 'persistent', effective_cluster_target_mps=128 / 3.6))
|
||||
font = fonts(profile)
|
||||
if profile == Profile.COMPACT:
|
||||
CompactHudRenderer(font, Mock()).render_max_speed(current)
|
||||
else:
|
||||
UnifiedSpeedWidget(font).render(rl.Rectangle(30, 30, 1800, 1020), current)
|
||||
assert not foreground_calls(font.draw)
|
||||
SpeedLimitWidget(font, profile).render_confirmation(current)
|
||||
rows = [call.args for call in foreground_calls(font.draw)]
|
||||
expected = {'limit': 'Now: LIMIT 128 km/h', 'override': 'Now: OVERRIDE 128 km/h', 'pedal': 'Now: PEDAL'}[mode]
|
||||
assert [row[0] for row in rows] == [expected, 'Accept', '120', 'km/h', 'Reject', 'new limit']
|
||||
status, accept = rows[:2]
|
||||
assert status[4] + font.vertical_ink(*status[:3])[1] < accept[4]
|
||||
assert not monitor_visible(Profile.COMPACT, current, fresh=True, onroad=True, top_icons=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile', list(Profile))
|
||||
@pytest.mark.parametrize('choice', ('accept', 'reject'))
|
||||
def test_displayed_proposal_matches_the_decision_during_a_held_override(profile, choice, drawing):
|
||||
fixture = replay.RuntimeReplayTests()
|
||||
fixture.setUp()
|
||||
sm, cp = fixture.sm, fixture.cp
|
||||
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 130.
|
||||
sm['carState'].vEgo = sm['carState'].vEgoCluster = 100 / 3.6
|
||||
sm['slcDashboardObservation'].speedMps = 100 / 3.6
|
||||
runtime = controller(offset=10 / 3.6)
|
||||
runtime.settings = replace(runtime.settings, pedal_override=settings.PedalOverride.SPEED_REACHED)
|
||||
tick(runtime, sm, cp, 0)
|
||||
sm['carState'].gasPressed = True
|
||||
sm['carState'].vEgo = sm['carState'].vEgoCluster = 128 / 3.6
|
||||
tick(runtime, sm, cp, 1)
|
||||
sm['carState'].gasPressed = False
|
||||
tick(runtime, sm, cp, 2)
|
||||
runtime.settings = replace(runtime.settings, acceptance=Policy())
|
||||
sm['slcDashboardObservation'].speedMps = 110 / 3.6
|
||||
pending = tick(runtime, sm, cp, 3).message.slcState
|
||||
current = OnroadState(True, False, 128 / 3.6, 130., speed_limit_from_message(pending),
|
||||
metric=True, longitudinal_active=True, slc_prompt_open=True)
|
||||
font = fonts(profile)
|
||||
SpeedLimitWidget(font, profile).render_confirmation(current)
|
||||
assert [call.args[0] for call in foreground_calls(font.draw)] == ['Now: OVERRIDE 128 km/h', 'Accept', '120', 'km/h', 'Reject', 'new limit']
|
||||
assert resolve_unified_speed(current).override_text == '128'
|
||||
action = Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, choice)
|
||||
result = tick(runtime, sm, cp, 4, request=action).message.slcState
|
||||
assert not result.hasPending
|
||||
assert result.acceptedSpeedLimit == pytest.approx((110 if choice == 'accept' else 100) / 3.6)
|
||||
assert result.effectiveClusterTarget == pytest.approx((120 if choice == 'accept' else 128) / 3.6)
|
||||
assert result.driverOverrideActive == (choice == 'reject')
|
||||
assert sm['carState'].vCruise == 130.
|
||||
|
||||
|
||||
def test_moved_transparent_prompt_suppresses_intersecting_current_speed_only(drawing):
|
||||
document = default_document()
|
||||
document['layouts']['large']['cruise_limits']['x'] = 600
|
||||
current = state(customization=validate_document(document), slc_prompt_open=True, viewport_width=2160)
|
||||
font = fonts(Profile.LARGE)
|
||||
hud = CurrentSpeedHud(font)
|
||||
hud.render(rl.Rectangle(180, 30, 2100, 1020), current)
|
||||
font.draw.assert_not_called()
|
||||
clear = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
|
||||
hud.render(rl.Rectangle(180, 30, 2100, 1020), clear)
|
||||
assert foreground_calls(font.draw)
|
||||
font.draw.reset_mock()
|
||||
hud.render(rl.Rectangle(30, 30, 1800, 1020), state(slc_prompt_open=True))
|
||||
assert foreground_calls(font.draw), 'The default prompt only intersects layout padding, not current-speed ink.'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('widget,method', [('clock', '_clock'), ('driving_mode_descriptions', '_driving_mode'), ('weather', '_weather')])
|
||||
def test_hidden_set_prompt_suppresses_only_overlapping_optional_text(widget, method, drawing, monkeypatch):
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.onroad_weather.draw_weather_icon', Mock())
|
||||
document = default_document()
|
||||
document['layouts']['compact']['max_speed']['enabled'] = False
|
||||
document['layouts']['compact']['speed_limit'].update(x=200, y=20)
|
||||
document['layouts']['compact'][widget].update(enabled=True, x=330 if widget == 'weather' else 200, y=0)
|
||||
current = state(customization=validate_document(document), slc_prompt_open=True)
|
||||
view = OnroadView.__new__(OnroadView)
|
||||
view.fonts = fonts(Profile.COMPACT)
|
||||
view.wall_clock = lambda: datetime(2026, 11, 5, 12)
|
||||
getattr(view, method)(current)
|
||||
view.fonts.draw.assert_not_called()
|
||||
clear = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
|
||||
getattr(view, method)(clear)
|
||||
assert view.fonts.draw.call_count > 0
|
||||
|
||||
|
||||
def test_projection_monitor_checks_the_same_shifted_bounds_it_draws(drawing):
|
||||
from openpilot.starpilot.system.starport.projection_layout import default_layout_for_viewport, projection_customization
|
||||
from openpilot.starpilot.ui.onroad_dm import DriverMonitorLayer
|
||||
viewport = (2880, 1440)
|
||||
layout = default_layout_for_viewport(viewport)
|
||||
layout['widgets']['cruise_limits'].update(x=88, y=1200)
|
||||
layout['widgets']['driver_monitor'].update(x=270, y=1200)
|
||||
layout['widgets']['torque_bar']['enabled'] = False
|
||||
document = projection_customization(layout, default_document())
|
||||
current = state(customization=document, slc_prompt_open=True)
|
||||
layer = DriverMonitorLayer.__new__(DriverMonitorLayer)
|
||||
layer.profile = Profile.LARGE
|
||||
layer.renderer = Mock(_is_rhd=False)
|
||||
layer.render(current, monitor=None, driver=None, fresh=False, onroad=True, y_offset=360)
|
||||
layer.renderer.set_position.assert_called_once_with(270, 1200)
|
||||
layer.renderer.set_should_draw.assert_called_once_with(False)
|
||||
layer.renderer._render.assert_not_called()
|
||||
@@ -13,7 +13,7 @@ from openpilot.starpilot.ui.preview_settings import reference_settings_state
|
||||
from openpilot.starpilot.ui.shell import ShellInput, ShellMode, ShellSnapshot
|
||||
from openpilot.starpilot.ui.slc_action_dispatch import SlcActionDispatcher
|
||||
|
||||
from openpilot.starpilot.ui.large_speed_geometry import sources_extent
|
||||
from openpilot.starpilot.ui.large_speed_geometry import CARD_HEIGHT, GAP, ACTION_Y, sources_extent
|
||||
from openpilot.starpilot.ui.onroad_customization import default_document, placement, validate_document
|
||||
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
|
||||
from openpilot.starpilot.ui.tests.test_onroad_customization import road
|
||||
@@ -275,7 +275,7 @@ def test_large_attached_actions_follow_source_row_and_custom_actions_stay_put():
|
||||
state.customization['speedSources'] = True
|
||||
state = replace(state, customization=validate_document(state.customization))
|
||||
after = slc_action_bounds(Profile.LARGE, state)
|
||||
assert after[0][1] - before[0][1] == sources_extent(state.customization) - 16
|
||||
assert after[0][1] - before[0][1] == CARD_HEIGHT + sources_extent(state.customization) + GAP - (ACTION_Y - 75)
|
||||
assert after[0][:2] == tuple(placement(state.customization, Profile.LARGE, 'speed_limit_actions')[key] for key in ('x', 'y'))
|
||||
state.customization['layouts']['large']['speed_limit_actions'].update(x=600, y=500)
|
||||
assert slc_action_bounds(Profile.LARGE, state)[0][:2] == (600, 500)
|
||||
@@ -300,11 +300,12 @@ def test_v10_default_migration_preserves_unrelated_customization_and_optional_wi
|
||||
|
||||
|
||||
def test_large_controls_with_source_card_clear_the_default_cem_gauge():
|
||||
state = road()
|
||||
state.customization.update(speedSources=True, speedSourcesCard=True)
|
||||
state = replace(state, customization=validate_document(state.customization))
|
||||
bounds = slc_action_bounds(Profile.LARGE, state)
|
||||
assert bounds[-1][3] < state.customization['layouts']['large']['cem_gauge']['y']
|
||||
current = replace(state(), slc_prompt_open=True)
|
||||
current.customization.update(speedSources=True, speedSourcesCard=True)
|
||||
current = replace(current, customization=validate_document(current.customization))
|
||||
controls = slc_controls(Profile.LARGE, current)
|
||||
assert len(controls) == 2
|
||||
assert controls[-1].bounds[3] < current.customization['layouts']['large']['cem_gauge']['y']
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_atlas_matches_generated_geometry_and_has_symmetric_antialiased_edges():
|
||||
center = alpha[:, width//2]
|
||||
painted = np.flatnonzero(center > 40)
|
||||
assert painted[-1] - painted[0] + 1 == len(painted)
|
||||
if name in ('outline', 'override_outline'):
|
||||
if name == 'outline':
|
||||
assert not alpha[18:-18, 18:-18].any(), 'the camera must remain visible inside the sign'
|
||||
assert np.array_equal(alpha, alpha[::-1]), 'rounded corners must be symmetric'
|
||||
|
||||
@@ -48,13 +48,14 @@ def gpu(monkeypatch):
|
||||
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
|
||||
assert gpu['draw_texture_pro'].call_count == 5
|
||||
assert all(call.args[1].x == shapes.SPRITES['ring'][0] for call in gpu['draw_texture_pro'].call_args_list)
|
||||
gpu['draw_texture_pro'].reset_mock()
|
||||
owner.outline(59, 66, shapes.rl.WHITE)
|
||||
owner.outline(59, 66, shapes.rl.WHITE)
|
||||
assert gpu['draw_texture_pro'].call_count == 2
|
||||
assert gpu['draw_texture_pro'].call_count == 10
|
||||
_, source, destination, _, _, _ = gpu['draw_texture_pro'].call_args.args
|
||||
assert (source.width, source.height) == (128, 144)
|
||||
assert (source.width, source.height) == (136, 152)
|
||||
assert (destination.x, destination.y) == (59, 66)
|
||||
for direction, rotation in ((1, 0), (-1, 180)):
|
||||
gpu['draw_texture_pro'].reset_mock()
|
||||
@@ -97,30 +98,8 @@ def test_small_arrow_has_larger_ink_at_double_density():
|
||||
assert ((bounds[2]-bounds[0])/density, (bounds[3]-bounds[1])/density) == (12, 16)
|
||||
|
||||
|
||||
def test_confirmation_arrow_has_the_same_fill_without_the_dark_outline():
|
||||
with Image.open(shapes.ATLAS_PATH) as atlas:
|
||||
sprites = {}
|
||||
for name in ('arrow_compact', 'arrow_confirmation'):
|
||||
x, y, width, height = shapes.SPRITES[name]
|
||||
sprites[name] = np.asarray(atlas.crop((x, y, x + width, y + height)))
|
||||
outlined = sprites['arrow_compact']
|
||||
plain = sprites['arrow_confirmation']
|
||||
assert shapes.SPRITE_DENSITIES['arrow_confirmation'] == 2
|
||||
assert np.all(plain[..., :3] == 255)
|
||||
assert np.all(plain[..., 3] <= outlined[..., 3])
|
||||
assert np.any(plain[..., 3] < outlined[..., 3])
|
||||
assert np.array_equal(plain[..., 3] == 255, outlined[..., 3] == 255)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('direction,rotation', [(1, 0), (-1, 180)])
|
||||
def test_confirmation_arrow_uses_the_shared_atlas_in_both_directions(gpu, direction, rotation):
|
||||
owner = shapes.SpeedLimitShapes()
|
||||
owner.arrow('confirmation', 100, 50, direction, shapes.rl.WHITE, scale=1.1)
|
||||
_, source, destination, _, drawn_rotation, _ = gpu['draw_texture_pro'].call_args.args
|
||||
assert (source.x, source.y, source.width, source.height) == (280, 224, 32, 48)
|
||||
assert (destination.width, destination.height) == pytest.approx((17.6, 26.4))
|
||||
assert drawn_rotation == rotation
|
||||
owner.close()
|
||||
|
||||
|
||||
def test_wheel_cues_have_smooth_rounded_edges_and_distinct_symbols():
|
||||
@@ -141,12 +120,11 @@ def test_wheel_cues_have_smooth_rounded_edges_and_distinct_symbols():
|
||||
|
||||
def test_wheel_cues_share_the_confirmation_atlas(gpu):
|
||||
owner = shapes.SpeedLimitShapes()
|
||||
owner.confirmation((0, 0, 240, 152))
|
||||
owner.outline(120, 76, shapes.rl.WHITE)
|
||||
gpu['draw_texture_pro'].reset_mock()
|
||||
owner.wheel_key(True, 50, 135.5, scale=1)
|
||||
owner.wheel_key(False, 160, 135.5, scale=1)
|
||||
assert gpu['draw_texture_pro'].call_count == 2
|
||||
assert [call.args[1].x for call in gpu['draw_texture_pro'].call_args_list] == [280, 328]
|
||||
assert {call.args[1].x for call in gpu['draw_texture_pro'].call_args_list} == {280, 328}
|
||||
assert all(call.args[2].width == 24 for call in gpu['draw_texture_pro'].call_args_list)
|
||||
gpu['load_image'].assert_called_once()
|
||||
gpu['load_texture_from_image'].assert_called_once()
|
||||
@@ -171,108 +149,36 @@ def test_headless_context_loads_atlas_without_a_window(gpu, monkeypatch):
|
||||
gpu['load_texture_from_image'].assert_called_once()
|
||||
|
||||
|
||||
def test_short_outline_reuses_the_same_atlas_and_preserves_corner_scale(gpu):
|
||||
owner = shapes.SpeedLimitShapes()
|
||||
owner.outline(59, 58, shapes.rl.WHITE, short=True)
|
||||
assert gpu['draw_texture_pro'].call_count == 2
|
||||
rows = [call.args for call in gpu['draw_texture_pro'].call_args_list]
|
||||
assert [(r[1].x, r[1].y, r[1].width, r[1].height) for r in rows] == [(256, 0, 128, 64), (256, 80, 128, 64)]
|
||||
assert [(r[2].x, r[2].y, r[2].width, r[2].height) for r in rows] == [(59, -6, 128, 64), (59, 58, 128, 64)]
|
||||
assert all((r[3].x, r[3].y) == (64, 0) for r in rows)
|
||||
owner.outline(59, 66, shapes.rl.WHITE)
|
||||
owner.outline(59, 58, shapes.rl.WHITE, short=True)
|
||||
gpu['load_image'].assert_called_once()
|
||||
gpu['load_texture_from_image'].assert_called_once()
|
||||
owner.close()
|
||||
gpu['unload_texture'].assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('scale', [1, 1.3416407865])
|
||||
@pytest.mark.parametrize('opened', [False, True])
|
||||
def test_confirmation_reuses_atlas_and_preserves_corners_without_overlapping_slices(gpu, scale, opened):
|
||||
owner = shapes.SpeedLimitShapes()
|
||||
width = (312 if opened else 240) * scale
|
||||
bounds = (12., 8., 12 + width, 8 + 152 * scale)
|
||||
owner.confirmation(bounds, opened=opened, strength=1)
|
||||
rows = [call.args for call in gpu['draw_texture_pro'].call_args_list]
|
||||
assert len(rows) == (3 if opened else 6)
|
||||
cap = (16 if opened else 24) * scale
|
||||
for group in (rows[:3], rows[3:]):
|
||||
if not group:
|
||||
continue
|
||||
left, middle, right = [row[2] for row in group]
|
||||
assert left.x == pytest.approx(bounds[0])
|
||||
assert left.width == pytest.approx(cap)
|
||||
assert middle.x == pytest.approx(left.x + left.width)
|
||||
assert right.x == pytest.approx(middle.x + middle.width)
|
||||
assert right.x + right.width == pytest.approx(bounds[2])
|
||||
assert right.width == pytest.approx(cap)
|
||||
assert all(row[2].y + row[2].height == pytest.approx(bounds[3]) for row in group)
|
||||
assert rows[0][2].height == pytest.approx(152 * scale)
|
||||
if not opened:
|
||||
assert rows[3][2].y == pytest.approx(bounds[1] + 86 * scale)
|
||||
owner.confirmation(bounds, opened=opened)
|
||||
gpu['load_image'].assert_called_once()
|
||||
gpu['load_texture_from_image'].assert_called_once()
|
||||
owner.close()
|
||||
gpu['unload_texture'].assert_called_once()
|
||||
|
||||
|
||||
def test_confirmation_has_translucent_fill_a_defined_rim_and_a_local_pulse():
|
||||
@pytest.mark.parametrize('profile', ('large', 'compact'))
|
||||
def test_buttons_have_smooth_symmetric_contours_and_transparent_interiors(profile):
|
||||
colored_areas = []
|
||||
with Image.open(shapes.ATLAS_PATH) as atlas:
|
||||
x, y, w, h = shapes.SPRITES['confirmation']
|
||||
face = np.asarray(atlas.crop((x, y, x + w, y + h)))
|
||||
assert face[h // 2, w // 2, 3] == 230
|
||||
assert face[2, w // 2, 3] > 230
|
||||
x, y, w, h = shapes.SPRITES['confirmation_pulse']
|
||||
pulse = np.asarray(atlas.crop((x, y, x + w, y + h)))
|
||||
assert pulse[2, w // 2, 3] == 0
|
||||
assert 0 < pulse[-3, w // 2, 3] <= 51
|
||||
for name in (f'button_{profile}', f'button_{profile}_pressed'):
|
||||
x, y, width, height = shapes.SPRITES[name]
|
||||
pixels = np.asarray(atlas.crop((x, y, x + width, y + height)))
|
||||
alpha = pixels[..., 3]
|
||||
assert shapes.SPRITE_DENSITIES[name] == 2
|
||||
assert np.array_equal(pixels, pixels[::-1]) and np.array_equal(pixels, pixels[:, ::-1])
|
||||
assert not alpha[40:-40, 40:-40].any()
|
||||
assert np.mean(alpha == 0) > .85
|
||||
assert ((alpha > 0) & (alpha < 255)).sum() > 500
|
||||
colored_areas.append(np.count_nonzero((pixels[..., 0] > 128) & (alpha > 0)))
|
||||
assert colored_areas[1] > colored_areas[0], 'Press feedback strengthens the same contour'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('scale', [1, 1.3416407865])
|
||||
def test_native_confirmation_blends_once_and_pulses_only_below_the_speed(scale):
|
||||
rl = shapes.rl
|
||||
owns_context = not rl.is_window_ready()
|
||||
if owns_context:
|
||||
rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN)
|
||||
rl.init_window(128, 128, 'SLC surface fixture')
|
||||
target = rl.load_render_texture(460, 240)
|
||||
def test_buttons_share_one_upload_and_each_uses_one_quad(gpu):
|
||||
owner = shapes.SpeedLimitShapes()
|
||||
|
||||
def capture(bounds, opened, strength, background=(90, 120, 160)):
|
||||
rl.begin_texture_mode(target)
|
||||
rl.clear_background(rl.Color(*background, 255))
|
||||
owner.confirmation(bounds, opened=opened, strength=strength)
|
||||
rl.end_texture_mode()
|
||||
image = rl.load_image_from_texture(target.texture)
|
||||
try:
|
||||
rl.image_flip_vertical(image)
|
||||
return np.frombuffer(image.data[:image.width * image.height * 4], dtype=np.uint8).reshape(image.height, image.width, 4).copy()
|
||||
finally:
|
||||
rl.unload_image(image)
|
||||
|
||||
try:
|
||||
for opened in (False, True):
|
||||
width = (312 if opened else 240) * scale
|
||||
bounds = (16, 16, 16 + width, 16 + 152 * scale)
|
||||
rest, peak = (capture(bounds, opened, strength) for strength in (0, 1))
|
||||
assert np.array_equal(rest[:int(16 + 86 * scale)], peak[:int(16 + 86 * scale)])
|
||||
if opened:
|
||||
assert np.array_equal(rest, peak)
|
||||
else:
|
||||
assert not np.array_equal(rest, peak)
|
||||
changed = np.any(rest != peak, axis=-1)
|
||||
assert not changed[:, :16].any() and not changed[:, int(np.ceil(bounds[2])):].any()
|
||||
assert not changed[int(np.ceil(bounds[3])):].any()
|
||||
row = int(16 + 76 * scale)
|
||||
columns = slice(18, int(bounds[2]) - 2)
|
||||
assert np.ptp(rest[row, columns, :3], axis=0).max() <= 1, 'slices must join without a seam'
|
||||
lighter = capture(bounds, opened, 0, (190, 220, 255))
|
||||
difference = lighter[row, int(16 + width / 2), :3].astype(int) - rest[row, int(16 + width / 2), :3]
|
||||
assert np.all((difference >= 8) & (difference <= 12)), 'the core should transmit about 10% of the camera'
|
||||
finally:
|
||||
owner.close()
|
||||
rl.unload_render_texture(target)
|
||||
if owns_context:
|
||||
rl.close_window()
|
||||
for profile, scale in (('compact', 1.), ('large', 1.342)):
|
||||
for pressed in (False, True):
|
||||
gpu['draw_texture_pro'].reset_mock()
|
||||
owner.button(200, 96, shapes.rl.GREEN, profile=profile, scale=scale, pressed=pressed)
|
||||
gpu['draw_texture_pro'].assert_called_once()
|
||||
_, source, destination, origin, _, color = gpu['draw_texture_pro'].call_args.args
|
||||
assert (source.x, source.y, source.width, source.height) == shapes.SPRITES[f'button_{profile}' + ('_pressed' if pressed else '')]
|
||||
assert (destination.x, destination.y) == (200, 96)
|
||||
assert (destination.width, destination.height) == pytest.approx((source.width / 2 * scale, source.height / 2 * scale))
|
||||
assert (origin.x, origin.y) == pytest.approx((source.width / 4 * scale, source.height / 4 * scale))
|
||||
assert color == shapes.rl.GREEN
|
||||
gpu['load_texture_from_image'].assert_called_once()
|
||||
owner.close()
|
||||
gpu['unload_texture'].assert_called_once()
|
||||
|
||||
@@ -168,6 +168,5 @@ class DrawerNativeTest(unittest.TestCase):
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_sign_graphics(monkeypatch):
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.outline', Mock())
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.confirmation', Mock())
|
||||
for name in ('draw_rectangle_rounded_lines_ex', 'draw_circle', 'draw_ring'):
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_widget.rl.' + name, Mock())
|
||||
|
||||
@@ -52,8 +52,8 @@ 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'] == 12
|
||||
assert migrated['layouts']['large']['cruise_limits']['x'] == 1539
|
||||
assert migrated['version'] == 13
|
||||
assert migrated['layouts']['large']['cruise_limits']['x'] == 1437
|
||||
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1678, 'y': 267, 'enabled': True}
|
||||
assert migrated['layouts']['large']['current_speed'] == old_speed
|
||||
assert migrated['speedSources'] is True
|
||||
|
||||
@@ -5,6 +5,7 @@ import unittest
|
||||
|
||||
from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation, ObservationKind
|
||||
from openpilot.starpilot.ui.presentation import Profile
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_HEIGHT, LIMIT_RECTS
|
||||
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
|
||||
from openpilot.starpilot.ui.stop_sign_widget import render_stop_sign, stop_sign_bounds
|
||||
|
||||
@@ -16,8 +17,12 @@ class TestStopSignWidget(unittest.TestCase):
|
||||
|
||||
def test_approved_full_screen_positions(self):
|
||||
state = self.state()
|
||||
self.assertEqual(stop_sign_bounds(Profile.COMPACT, state)[:3], (236, 28, 70.))
|
||||
self.assertEqual(stop_sign_bounds(Profile.LARGE, state)[:3], (490, 95., 125.))
|
||||
self.assertEqual(stop_sign_bounds(Profile.COMPACT, state)[:3], (226, 24, 70.))
|
||||
left, top, size, _ = stop_sign_bounds(Profile.LARGE, state)
|
||||
x, y, width, _ = LIMIT_RECTS['large']
|
||||
self.assertEqual(left, x + width + 24)
|
||||
self.assertAlmostEqual(top + size/2, y + SIGN_HEIGHT/2*LARGE_SIGN_SCALE)
|
||||
self.assertAlmostEqual(size, 100*LARGE_SIGN_SCALE)
|
||||
|
||||
def test_camera_age_expiry_and_missing_observation_hide(self):
|
||||
state = self.state()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""One set speed and a shared, latest-limit sign in the large and compact UIs."""
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -14,7 +13,7 @@ from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget
|
||||
from openpilot.starpilot.ui.onroad import OnroadView
|
||||
from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState, SpeedLimitObservation, slc_controls, speed_limit_bounds
|
||||
from openpilot.starpilot.ui.presentation import FontRole, Profile
|
||||
from openpilot.starpilot.ui.speed_limit_widget import INK, MUTED, SpeedLimitWidget, draw_text
|
||||
from openpilot.starpilot.ui.speed_limit_widget import INK, IN_USE, MUTED, REJECT, 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
|
||||
|
||||
@@ -26,7 +25,7 @@ def drawing(monkeypatch):
|
||||
'draw_ring', 'draw_circle_gradient', 'draw_rectangle_rec', 'draw_circle_v', 'draw_rectangle_gradient_ex'):
|
||||
calls[name] = Mock()
|
||||
monkeypatch.setattr(rl, name, calls[name])
|
||||
for name in ('circle', 'arrow', 'outline', 'confirmation', 'wheel_key', 'override_sign', 'override_slash'):
|
||||
for name in ('circle', 'arrow', 'outline', 'wheel_key', 'button'):
|
||||
calls[name] = Mock()
|
||||
monkeypatch.setattr(SpeedLimitShapes, name, calls[name])
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.onroad_large_widgets.draw_control_card', Mock())
|
||||
@@ -90,9 +89,9 @@ def test_sign_keeps_distinct_slc_target_when_set_widget_is_hidden(profile, vienn
|
||||
effective_cluster_target_mps=45 / 3.6))
|
||||
font = fonts(profile)
|
||||
render_sign(SpeedLimitWidget(font, profile), current)
|
||||
assert [call.args[0] for call in font.draw.call_args_list] == ['OSM / SLC', '50+5', '45']
|
||||
drawing['circle' if vienna else 'outline'].assert_called_once()
|
||||
drawing['override_slash'].assert_not_called()
|
||||
assert [call.args[0] for call in foreground_calls(font.draw)] == ['Limit', '45', 'OSM 50 +5']
|
||||
drawing['outline'].assert_called_once()
|
||||
drawing['circle'].assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile', list(Profile))
|
||||
@@ -107,7 +106,8 @@ def test_set_and_binding_slc_stay_visible_beside_sign_or_prompt(profile, stage):
|
||||
presentation_adjusted_limit_mps=speed, last_received_speed_limit_mps=speed, last_received_source='dashboard',
|
||||
pending_speed_limit_mps=25 * CV.MPH_TO_MS if pending else None,
|
||||
pending_adjusted_limit_mps=25 * CV.MPH_TO_MS if pending else None, pending_source='map',
|
||||
effective_cluster_target_mps=speed, effective_cap_mps=speed, limiting_max_set=True)
|
||||
effective_cluster_target_mps=speed, effective_cap_mps=speed, limiting_max_set=True,
|
||||
accepted_speed_limit_mps=speed, accepted_adjusted_limit_mps=speed, accepted_source='dashboard')
|
||||
current = replace(current, speed_limit=observation)
|
||||
font = fonts(profile)
|
||||
view = slc_view(font)
|
||||
@@ -121,9 +121,13 @@ def test_set_and_binding_slc_stay_visible_beside_sign_or_prompt(profile, stage):
|
||||
view.compact_hud._speed_limit_sign(current)
|
||||
view._slc_actions(current)
|
||||
texts = [call.args[0] for call in foreground_calls(font.draw)]
|
||||
assert 'SET' in texts and '35' in texts and texts.count('SLC 30') == 1
|
||||
assert 'SET' in texts and '35' in texts and not any(text.startswith('SLC ') for text in texts)
|
||||
assert ('LIMIT 30' in texts) == (stage == 'prompt')
|
||||
assert ('Now: LIMIT 30 mph' in texts) == (stage == 'expanded')
|
||||
assert ('Limit' in texts) == (stage in ('live', 'retained'))
|
||||
if pending:
|
||||
assert '25' in texts and 'OSM' in texts and not any(text.startswith('LAST') for text in texts)
|
||||
assert '25' in texts and not any(text.startswith('LAST') for text in texts)
|
||||
assert ('OSM' in texts) == (stage != 'expanded')
|
||||
else:
|
||||
assert ('LAST DASH' if gap else 'DASH') in texts and '30' in texts
|
||||
|
||||
@@ -136,7 +140,7 @@ def test_confirmation_arrow_uses_upcoming_target_while_current_reading_stays_cur
|
||||
font = fonts(profile)
|
||||
SpeedLimitWidget(font, profile).render_confirmation(current)
|
||||
assert drawing['arrow'].call_args.args[3] == 1
|
||||
assert '30' in [call.args[0] for call in font.draw.call_args_list]
|
||||
assert '30' in [call.args[0] for call in foreground_calls(font.draw)]
|
||||
assert current.speed_limit.speed_limit_mps == 50 / 3.6
|
||||
|
||||
|
||||
@@ -177,12 +181,12 @@ def test_sign_heading_identifies_the_displayed_source(profile, pending, vienna,
|
||||
font = fonts(profile)
|
||||
render_sign(SpeedLimitWidget(font, profile), current)
|
||||
expected = [heading, '50']
|
||||
assert [call.args[0] for call in font.draw.call_args_list] == expected + (['+5'] if offset else []) + (['New Limit'] if pending else [])
|
||||
assert [call.args[0] for call in foreground_calls(font.draw)] == expected + (['+5'] if offset else []) + (['New Limit', 'Tap to review'] if pending else [])
|
||||
if not pending:
|
||||
label, speed = font.draw.call_args_list[:2]
|
||||
label, speed = foreground_calls(font.draw)[:2]
|
||||
assert rgba_tuple(label.args[-1]) == rgba_tuple(speed.args[-1])
|
||||
if vienna and not pending:
|
||||
assert rgba_tuple(font.draw.call_args_list[0].args[-1]) == (0, 0, 0, 255)
|
||||
assert rgba_tuple(foreground_calls(font.draw)[0].args[-1]) == (244, 247, 245, 255)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('metric', [False, True])
|
||||
@@ -197,7 +201,7 @@ def test_compact_sign_omits_missing_zero_and_rounded_zero_offsets(metric, vienna
|
||||
assert resolve_unified_speed(current).offset_text is None
|
||||
font = fonts(Profile.COMPACT)
|
||||
CompactHudRenderer(font, Mock())._speed_limit_sign(current)
|
||||
assert [call.args[0] for call in font.draw.call_args_list] == ['OSM', '65']
|
||||
assert [call.args[0] for call in foreground_calls(font.draw)] == ['OSM', '65']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile', list(Profile))
|
||||
@@ -209,14 +213,14 @@ def test_pending_notice_pulses_without_changing_ink_and_arrow_follows_speed(prof
|
||||
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna))
|
||||
render_sign(sign, current)
|
||||
before = {call.args[0]: call.args for call in foreground_calls(font.draw)}
|
||||
strength = drawing['confirmation'].call_args.kwargs['strength']
|
||||
assert drawing['arrow'].call_args.args[0] == 'confirmation'
|
||||
assert drawing['arrow'].call_args.args[0] == 'compact'
|
||||
assert drawing['arrow'].call_args.args[3] == -1
|
||||
render_sign(sign, 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 set(before) == {'OSM', '50', '+5', 'New Limit'}
|
||||
assert all(after[text][3:5] == args[3:5] and rgba_tuple(after[text][-1]) == rgba_tuple(args[-1]) for text, args in before.items())
|
||||
assert drawing['confirmation'].call_args.kwargs['strength'] != strength
|
||||
assert set(before) == {'OSM', '50', '+5', 'New Limit', 'Tap to review'}
|
||||
assert all(after[text][3:5] == args[3:5] for text, args in before.items())
|
||||
assert all(rgba_tuple(after[text][-1]) == rgba_tuple(args[-1]) for text, args in before.items() if text != 'New Limit')
|
||||
assert rgba_tuple(after['New Limit'][-1]) != rgba_tuple(before['New Limit'][-1])
|
||||
drawing['circle'].assert_not_called()
|
||||
drawing['outline'].assert_not_called()
|
||||
assert drawing['arrow'].call_args.args[3] == 1
|
||||
@@ -231,7 +235,7 @@ def test_regular_sign_keeps_the_outlined_arrow(profile, vienna, recovering, draw
|
||||
speed_limit=replace(current.speed_limit, decision_id=0, sign_recovering=recovering))
|
||||
font = fonts(profile)
|
||||
SpeedLimitWidget(font, profile).render(current)
|
||||
assert font.draw.call_args_list[0].args[0] == ('LAST OSM' if recovering else 'OSM')
|
||||
assert foreground_calls(font.draw)[0].args[0] == ('LAST OSM' if recovering else 'OSM')
|
||||
assert drawing['arrow'].call_args.args[0] == 'compact'
|
||||
|
||||
|
||||
@@ -242,6 +246,8 @@ def test_wheel_cues_follow_confirmation_availability(profile, available, drawing
|
||||
current = state(slc_wheel_confirmation_available=available)
|
||||
sign = SpeedLimitWidget(fonts(profile), profile)
|
||||
sign.render_confirmation(current)
|
||||
drawing['draw_rectangle_rounded_lines_ex'].assert_not_called()
|
||||
drawing['button'].assert_not_called()
|
||||
calls = drawing['wheel_key'].call_args_list
|
||||
assert [call.args[0] for call in calls] == ([True, False] if available else [])
|
||||
if available:
|
||||
@@ -266,13 +272,13 @@ def test_sign_text_stays_centered_without_an_arrow(profile, vienna, reason, draw
|
||||
font = fonts(profile)
|
||||
render_sign(SpeedLimitWidget(font, profile), current)
|
||||
drawing['arrow'].assert_not_called()
|
||||
for call in font.draw.call_args_list:
|
||||
for call in foreground_calls(font.draw):
|
||||
text, role, size, x, _, _ = call.args
|
||||
if reason in ('equal_speed', 'unknown_speed'):
|
||||
continue
|
||||
left, right = font.horizontal_ink(text, role, size)
|
||||
from openpilot.starpilot.ui.large_speed_geometry import LIMIT_RECTS, LARGE_SIGN_SCALE
|
||||
center = LIMIT_RECTS[profile][0] + 59 * (1 if profile == Profile.COMPACT else LARGE_SIGN_SCALE)
|
||||
center = LIMIT_RECTS[profile][0] + 64 * (1 if profile == Profile.COMPACT else LARGE_SIGN_SCALE)
|
||||
assert abs(x + (left + right) / 2 - center) <= .5
|
||||
|
||||
|
||||
@@ -282,40 +288,30 @@ def test_sign_text_stays_centered_without_an_arrow(profile, vienna, reason, draw
|
||||
def test_override_sign_uses_cluster_target_and_restores_normal_sign(profile, vienna, retained, drawing):
|
||||
font = fonts(profile)
|
||||
sign = SpeedLimitWidget(font, profile)
|
||||
current = state()
|
||||
current = state(cruise_kph=140)
|
||||
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna),
|
||||
speed_limit=replace(current.speed_limit, speed_limit_mps=100 / 3.6,
|
||||
pending_speed_limit_mps=None, effective_cluster_target_mps=128 / 3.6))
|
||||
presentation_adjusted_limit_mps=100 / 3.6, pending_speed_limit_mps=None,
|
||||
effective_cluster_target_mps=128 / 3.6))
|
||||
if retained:
|
||||
current = replace(current, speed_limit=replace(current.speed_limit, kind=ObservationKind.ABSENT, source='none',
|
||||
speed_limit_mps=None, last_received_speed_limit_mps=100 / 3.6, last_received_source='map'))
|
||||
sign.render(replace(current, longitudinal_overridden=True))
|
||||
drawing['override_sign'].assert_not_called()
|
||||
drawing['override_slash'].assert_not_called()
|
||||
overridden = replace(current, speed_limit=replace(current.speed_limit, driver_override_active=True))
|
||||
sign.render(replace(overridden, longitudinal_overridden=True))
|
||||
assert 'Override' not in [call.args[0] for call in foreground_calls(font.draw)]
|
||||
assert 'Pedal' in [call.args[0] for call in foreground_calls(font.draw)]
|
||||
font.draw.reset_mock()
|
||||
sign.render(replace(current, speed_limit=replace(current.speed_limit, driver_override_active=True)))
|
||||
assert drawing['override_sign'].call_args.kwargs == {'vienna': vienna}
|
||||
drawing['override_slash'].assert_called_once()
|
||||
heading, posted, override = [call.args for call in font.draw.call_args_list]
|
||||
assert (heading[0], posted[0], override[0]) == ('LAST / OVERRIDE' if retained else 'OVERRIDE', '100', '128')
|
||||
assert heading[1] == FontRole.SEMI_BOLD
|
||||
assert posted[1] == (FontRole.SEMI_BOLD if vienna else FontRole.BOLD)
|
||||
assert override[1] == FontRole.BOLD
|
||||
assert override[2] > posted[2]
|
||||
assert override[3] > posted[3]
|
||||
heading_bottom = heading[4] + font.vertical_ink(*heading[:3])[1]
|
||||
number_top = min(row[4] + font.vertical_ink(*row[:3])[0] for row in (posted, override))
|
||||
assert number_top - heading_bottom >= (10 if vienna else 13) * sign.scale - 1
|
||||
if vienna:
|
||||
centers = [row[4] + sum(font.vertical_ink(*row[:3])) / 2 for row in (posted, override)]
|
||||
assert abs(centers[0] - centers[1]) <= 1
|
||||
sign.render(overridden)
|
||||
rows = [call.args for call in foreground_calls(font.draw)]
|
||||
assert [row[0] for row in rows] == (['Override', '128', 'LAST OSM', '100'] if retained else ['Override', '128', 'OSM 100'])
|
||||
assert rows[1][2] > rows[-1][2]
|
||||
for first, second in zip(rows, rows[1:], strict=False):
|
||||
assert first[4] + font.vertical_ink(*first[:3])[1] <= second[4] + font.vertical_ink(*second[:3])[0] + 1
|
||||
drawing['outline'].assert_called()
|
||||
font.draw.reset_mock()
|
||||
sign.render(current)
|
||||
assert font.draw.call_args_list[0].args[0] == ('LAST OSM' if retained else 'OSM')
|
||||
drawing['override_sign'].assert_called_once()
|
||||
drawing['override_slash'].assert_called_once()
|
||||
normal_shape = drawing['circle' if vienna else 'outline']
|
||||
assert normal_shape.call_args.args[:2] == drawing['override_sign'].call_args.args[:2]
|
||||
assert foreground_calls(font.draw)[0].args[0] == ('LAST OSM' if retained else 'OSM')
|
||||
drawing['circle' if vienna else 'outline'].assert_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile', list(Profile))
|
||||
@@ -333,7 +329,7 @@ def test_retained_limits_keep_original_source_and_have_no_actions(profile, vienn
|
||||
current = replace(current, speed_limit=observation, appearance=replace(current.appearance, use_vienna_sign=vienna))
|
||||
font = fonts(profile)
|
||||
render_sign(SpeedLimitWidget(font, profile), current)
|
||||
texts = [call.args[0] for call in font.draw.call_args_list]
|
||||
texts = [call.args[0] for call in foreground_calls(font.draw)]
|
||||
assert texts.count(heading) == 1
|
||||
assert ('50' if latest_received else '40') in texts
|
||||
drawing['arrow'].assert_not_called()
|
||||
@@ -348,7 +344,7 @@ def test_retained_sign_has_no_proposal_arrow_when_actions_are_hidden(profile, dr
|
||||
last_received_speed_limit_mps=50 / 3.6, last_received_source='map')
|
||||
font = fonts(profile)
|
||||
render_sign(SpeedLimitWidget(font, profile), replace(current, speed_limit=observation))
|
||||
assert font.draw.call_args_list[0].args[0] == 'LAST OSM'
|
||||
assert foreground_calls(font.draw)[0].args[0] == 'LAST OSM'
|
||||
drawing['arrow'].assert_not_called()
|
||||
|
||||
|
||||
@@ -377,20 +373,35 @@ def test_compact_set_speed_stays_visible_during_confirmation_then_fades():
|
||||
assert alpha < .01
|
||||
|
||||
|
||||
def test_compact_max_restores_dinish_number_label_and_shadow(drawing):
|
||||
from openpilot.starpilot.ui.presentation import FontRole, font_filename
|
||||
def test_compact_set_stays_visible_when_pedal_status_moves_into_the_sign():
|
||||
font = fonts(Profile.COMPACT)
|
||||
renderer = CompactHudRenderer(font, Mock())
|
||||
current = state(drive_frame=1, longitudinal_overridden=True)
|
||||
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None,
|
||||
driver_override_active=True, override_basis='pedal', limiting_max_set=False))
|
||||
for now in range(10_000_000_000, 20_000_000_000, 100_000_000):
|
||||
renderer.render_max_speed(replace(current, observed_ns=now))
|
||||
assert renderer._set_speed_alpha.x > .99
|
||||
assert 'SET' in [call.args[0] for call in foreground_calls(font.draw)]
|
||||
assert 'PEDAL' not in [call.args[0] for call in foreground_calls(font.draw)]
|
||||
font.draw.reset_mock()
|
||||
renderer._speed_limit_sign(current)
|
||||
assert 'Pedal' in [call.args[0] for call in foreground_calls(font.draw)]
|
||||
|
||||
|
||||
def test_compact_set_keeps_dinish_number_and_has_no_backplate(drawing):
|
||||
from openpilot.starpilot.ui.presentation import font_filename
|
||||
font = fonts(Profile.COMPACT)
|
||||
hud = CompactHudRenderer(font, Mock())
|
||||
current = state(drive_frame=1, cruise_kph=80)
|
||||
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
|
||||
hud.render_max_speed(current)
|
||||
hud.render_max_speed(replace(current, observed_ns=current.observed_ns + 20_000_000))
|
||||
assert [(call.args[0], call.args[1]) for call in font.draw.call_args_list] == [
|
||||
('80', FontRole.SET_SPEED), ('MAX', FontRole.SEMI_BOLD)]
|
||||
assert [(call.args[0], call.args[1]) for call in foreground_calls(font.draw)] == [
|
||||
('SET', FontRole.MEDIUM), ('km/h', FontRole.NORMAL), ('80', FontRole.SET_SPEED)]
|
||||
assert font_filename(Profile.COMPACT, FontRole.SET_SPEED) == 'DINish-Speed.fnt'
|
||||
assert font.draw.call_args_list[0].args[3:5] == (17, -4)
|
||||
assert font.draw.call_args_list[1].args[2:5] == (36, 25, 109)
|
||||
drawing['draw_circle_gradient'].assert_called_once()
|
||||
assert foreground_calls(font.draw)[2].args[4] == 46
|
||||
drawing['draw_circle_gradient'].assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('metric', [False, True])
|
||||
@@ -418,38 +429,51 @@ def test_large_limit_keeps_a_unit_when_set_speed_is_hidden():
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile', list(Profile))
|
||||
def test_chooser_has_separate_tonal_actions_and_immediate_pressed_feedback(profile, drawing):
|
||||
view = OnroadView.__new__(OnroadView)
|
||||
view.personality = Mock()
|
||||
view.fonts = fonts(profile)
|
||||
if profile == Profile.LARGE:
|
||||
view.unified_speed = UnifiedSpeedWidget(view.fonts)
|
||||
else:
|
||||
view.compact_hud = CompactHudRenderer(view.fonts, Mock())
|
||||
@pytest.mark.parametrize('pressed_index', (0, 1))
|
||||
def test_transparent_chooser_keeps_touch_targets_and_pressed_feedback(profile, pressed_index, drawing, monkeypatch):
|
||||
view = slc_view(fonts(profile))
|
||||
current = state(slc_prompt_open=True)
|
||||
controls = slc_controls(profile, current)
|
||||
view._slc_actions(current)
|
||||
assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['55', 'km/h', 'OSM 50 +5', 'Accept', 'Reject']
|
||||
backgrounds = [rgba_tuple(call.args[-1]) for call in drawing['draw_rectangle_rounded'].call_args_list]
|
||||
assert backgrounds[1] == (35, 74, 57, 255)
|
||||
assert backgrounds[3] == (77, 46, 57, 255)
|
||||
bounds = speed_limit_bounds(profile, current)
|
||||
drawing['confirmation'].assert_called_once_with(bounds, opened=True)
|
||||
assert bounds is not None
|
||||
x, y, right, bottom = bounds
|
||||
panel = rl.Rectangle(x, y, right - x, bottom - y)
|
||||
accept, _, reject, _ = [call.args[0] for call in drawing['draw_rectangle_rounded'].call_args_list]
|
||||
margin = accept.x - panel.x
|
||||
assert margin > 0
|
||||
assert reject.x - accept.x - accept.width == pytest.approx(margin, abs=.001)
|
||||
assert panel.x + panel.width - reject.x - reject.width == pytest.approx(margin, abs=.001)
|
||||
assert panel.y + panel.height - accept.y - accept.height == pytest.approx(margin, abs=.001)
|
||||
assert accept.y == reject.y and accept.height == reject.height
|
||||
rows = [call.args for call in foreground_calls(view.fonts.draw)]
|
||||
assert [row[0] for row in rows] == ['New limit', 'Accept', '55', 'km/h', 'Reject', 'new limit']
|
||||
drawing['draw_rectangle_rounded'].assert_not_called()
|
||||
drawing['draw_line_ex'].assert_not_called()
|
||||
view.pressed_action = lambda: slc_controls(profile, current)[0].request
|
||||
drawing['draw_rectangle_rounded'].reset_mock()
|
||||
drawing['draw_rectangle_rounded_lines_ex'].assert_not_called()
|
||||
outlines = drawing['button'].call_args_list
|
||||
assert [rgba_tuple(call.args[-1]) for call in outlines] == [rgba_tuple(IN_USE), rgba_tuple(REJECT)]
|
||||
for control, content in zip(controls, (rows[1:4], rows[4:]), strict=True):
|
||||
for row in content:
|
||||
left, top, right, bottom = control.bounds
|
||||
a, b = view.fonts.horizontal_ink(*row[:3])
|
||||
c, d = view.fonts.vertical_ink(*row[:3])
|
||||
assert left < row[3] + a < row[3] + b < right
|
||||
assert top < row[4] + c < row[4] + d < bottom
|
||||
original = [rgba_tuple(row[-1]) for row in rows if row[0] in ('Accept', 'Reject')]
|
||||
view.pressed_action = lambda: controls[pressed_index].request
|
||||
view.fonts.draw.reset_mock()
|
||||
drawing['button'].reset_mock()
|
||||
view._slc_actions(current)
|
||||
pressed = [rgba_tuple(call.args[-1]) for call in drawing['draw_rectangle_rounded'].call_args_list]
|
||||
assert pressed[1] != backgrounds[1] and pressed[3] == backgrounds[3]
|
||||
assert [call.args[:5] for call in foreground_calls(view.fonts.draw)] == [row[:5] for row in rows]
|
||||
pressed = [rgba_tuple(call.args[-1]) for call in foreground_calls(view.fonts.draw) if call.args[0] in ('Accept', 'Reject')]
|
||||
assert pressed[pressed_index] != original[pressed_index] and pressed[1-pressed_index] == original[1-pressed_index]
|
||||
pressed_outlines = drawing['button'].call_args_list
|
||||
assert [call.kwargs['pressed'] for call in pressed_outlines] == [pressed_index == 0, pressed_index == 1]
|
||||
assert not any(call.kwargs['pressed'] for call in outlines)
|
||||
for before, after in zip(outlines, pressed_outlines, strict=True):
|
||||
assert before.args[:2] == after.args[:2]
|
||||
assert before.kwargs['scale'] == after.kwargs['scale']
|
||||
assert rgba_tuple(before.args[-1]) == rgba_tuple(after.args[-1])
|
||||
assert slc_controls(profile, current) == controls
|
||||
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_widget.slc_controls',
|
||||
lambda *_: tuple(replace(control, enabled=False) for control in controls))
|
||||
view.fonts.draw.reset_mock()
|
||||
drawing['button'].reset_mock()
|
||||
view._slc_actions(current)
|
||||
assert all(rgba_tuple(call.args[-1]) == rgba_tuple(MUTED) for call in foreground_calls(view.fonts.draw)[1:])
|
||||
assert all(rgba_tuple(call.args[-1]) == rgba_tuple(MUTED)
|
||||
for call in drawing['button'].call_args_list)
|
||||
assert not any(call.kwargs['pressed'] for call in drawing['button'].call_args_list)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile', list(Profile))
|
||||
@@ -460,15 +484,13 @@ def test_text_contour_is_bounded_and_preserves_foreground_opacity(profile, alpha
|
||||
color = rl.Color(188, 132, 255, alpha)
|
||||
draw_text(font, '65 +5', FontRole.MEDIUM, 36, 100, 50, color)
|
||||
calls = [call.args for call in font.draw.call_args_list]
|
||||
assert len(calls) == 6
|
||||
assert len(calls) == 5
|
||||
assert all(call[:3] == ('65 +5', FontRole.MEDIUM, 36) for call in calls)
|
||||
assert calls[-1][3:5] == (100, 50) and calls[-1][-1] is color
|
||||
radius = 1.15 if profile == Profile.LARGE else 1.
|
||||
assert {(round(call[3]-100, 2), round(call[4]-50, 2)) for call in calls[:4]} == {
|
||||
(-radius, 0), (radius, 0), (0, -radius), (0, radius)}
|
||||
assert all(call[-1].a == round(alpha*.90) for call in calls[:4])
|
||||
assert calls[-2][3:5] == (100, 50+(1.5 if profile == Profile.LARGE else 1.2))
|
||||
assert calls[-2][-1].a == round(alpha*.32)
|
||||
assert all(call[-1].a == round(alpha*.95) for call in calls[:4])
|
||||
|
||||
|
||||
def test_full_coverage_contour_protects_base_muted_and_peak_purple_on_white():
|
||||
@@ -476,7 +498,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), rl.Color(170, 115, 235, 255)):
|
||||
for color in (INK, MUTED, IN_USE, REJECT, 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]
|
||||
@@ -490,18 +512,3 @@ def test_contour_accepts_raylib_standard_color_tuples():
|
||||
draw_text(font, 'Dash', FontRole.NORMAL, 22, 100, 50, rl.WHITE)
|
||||
color = font.draw.call_args.args[-1]
|
||||
assert (color.r, color.g, color.b, color.a) == (255, 255, 255, 255)
|
||||
|
||||
|
||||
def test_retained_compact_fade_still_validates_before_decoding(tmp_path, monkeypatch):
|
||||
from openpilot.starpilot.ui.onroad import _load_fade
|
||||
directory = Path(__file__).parents[3] / 'selfdrive/assets'
|
||||
path = directory / 'icons_mici/onroad/onroad_fade.png'
|
||||
decoder = Mock(return_value=SimpleNamespace(id=1))
|
||||
monkeypatch.setattr(rl, 'load_texture', decoder)
|
||||
assert _load_fade(path) is decoder.return_value
|
||||
decoder.reset_mock()
|
||||
invalid = tmp_path / path.name
|
||||
invalid.write_bytes(b'invalid PNG')
|
||||
with pytest.raises(ValueError, match='Unreviewed'):
|
||||
_load_fade(invalid)
|
||||
decoder.assert_not_called()
|
||||
|
||||
@@ -116,3 +116,32 @@ def test_confirmation_proposal_uses_the_pending_target_and_its_offset(pending, a
|
||||
observation = replace(current.speed_limit, pending_speed_limit_mps=pending, pending_adjusted_limit_mps=adjusted,
|
||||
presentation_adjusted_limit_mps=presentation, offset_mps=offset)
|
||||
assert resolve_unified_speed(replace(current, speed_limit=observation)).proposal_text == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['none', 'retained', 'hold', 'recovering', 'pending', 'source', 'rounded-posted',
|
||||
'rounded-target', 'pedal', 'override', 'display-only', 'not-limiting', 'disengaged'])
|
||||
def test_in_use_requires_fresh_accepted_source_and_actual_control(change):
|
||||
current = state()
|
||||
obs = replace(current.speed_limit, accepted_speed_limit_mps=30., accepted_adjusted_limit_mps=32.,
|
||||
accepted_source='vision', effective_cluster_target_mps=32., limiting_max_set=True)
|
||||
changes = {
|
||||
'retained': {'kind': ObservationKind.STALE, 'speed_limit_mps': None, 'last_received_speed_limit_mps': 30.},
|
||||
'hold': {'status': 'display_hold'}, 'recovering': {'sign_recovering': True},
|
||||
'pending': {'pending_speed_limit_mps': 30.}, 'source': {'accepted_source': 'dashboard'},
|
||||
'rounded-posted': {'accepted_speed_limit_mps': 30.01},
|
||||
'rounded-target': {'effective_cluster_target_mps': 32.01},
|
||||
'override': {'driver_override_active': True}, 'display-only': {'action_enabled': False},
|
||||
'not-limiting': {'limiting_max_set': False},
|
||||
}
|
||||
current = replace(current, speed_limit=replace(obs, **changes.get(change, {})),
|
||||
longitudinal_overridden=change == 'pedal', longitudinal_active=change != 'disengaged')
|
||||
assert resolve_unified_speed(current).limit_in_use == (change == 'none')
|
||||
|
||||
|
||||
def test_pedal_basis_cannot_be_presented_as_a_held_override_between_message_updates():
|
||||
current = state()
|
||||
current = replace(current, speed_limit=replace(current.speed_limit, driver_override_active=True,
|
||||
effective_cluster_target_mps=26., override_basis='pedal'))
|
||||
assert resolve_unified_speed(current).override_text is None
|
||||
released = replace(current, speed_limit=replace(current.speed_limit, override_basis='persistent'))
|
||||
assert resolve_unified_speed(released).override_text == '94'
|
||||
|
||||
@@ -83,4 +83,4 @@ def test_vision_sign_and_slc_cap_use_configured_units_without_rewriting_set(prof
|
||||
texts = render(output.message.slcState, car.vCruise)
|
||||
assert str(number) in texts
|
||||
assert str(round(90. if metric else 90. * CV.KPH_TO_MPH)) in texts
|
||||
assert ('SET' if profile == Profile.LARGE else 'MAX') in texts
|
||||
assert 'SET' in texts
|
||||
|
||||
@@ -20,6 +20,7 @@ class UnifiedSpeedPresentation:
|
||||
override_text: str | None
|
||||
slc_target_text: str | None = None
|
||||
posted_retained: bool = False
|
||||
limit_in_use: bool = False
|
||||
|
||||
@property
|
||||
def max_status(self) -> str:
|
||||
@@ -105,12 +106,12 @@ def resolve_unified_speed(state, *, confirmation=False) -> UnifiedSpeedPresentat
|
||||
status = 'Unavailable'
|
||||
elif not obs.action_enabled:
|
||||
status = 'Display only'
|
||||
elif state.longitudinal_overridden:
|
||||
status = 'Using accelerator'
|
||||
elif obs.driver_override_active:
|
||||
status = 'Set-speed override'
|
||||
elif pending:
|
||||
status = 'Adopting limit' if getattr(obs, 'automatic_adoption_pending', False) else 'Confirm speed limit'
|
||||
elif state.longitudinal_overridden:
|
||||
status = 'Using accelerator'
|
||||
elif state.cruise_active and slc_limiting:
|
||||
status = 'Using speed limit'
|
||||
elif state.cruise_active:
|
||||
@@ -124,12 +125,20 @@ def resolve_unified_speed(state, *, confirmation=False) -> UnifiedSpeedPresentat
|
||||
proposal = obs.pending_adjusted_limit_mps or (
|
||||
target if obs.pending_speed_limit_mps == posted else obs.pending_speed_limit_mps + (obs.offset_mps or 0))
|
||||
active_target = obs.effective_cluster_target_mps
|
||||
# A matching displayed number alone does not establish that this source is controlling.
|
||||
limit_in_use = bool(slc_limiting and not pending and not posted_retained and not obs.sign_recovering and
|
||||
positive(posted) and positive(target) and positive(active_target) and
|
||||
positive(obs.accepted_speed_limit_mps) and source == obs.accepted_source and
|
||||
math.isclose(posted, obs.accepted_speed_limit_mps, abs_tol=.001) and
|
||||
math.isclose(target, active_target, abs_tol=.001))
|
||||
return UnifiedSpeedPresentation(mode, max_text, speed(posted), offset, 'km/h' if state.metric else 'mph',
|
||||
source, pending,
|
||||
('limit' if slc_limiting else 'max') if state.cruise_active else 'none',
|
||||
speed(target), status,
|
||||
speed(proposal),
|
||||
speed(obs.effective_cluster_target_mps) if obs.driver_override_active and
|
||||
obs.action_enabled and state.longitudinal_active and not state.longitudinal_overridden and
|
||||
obs.override_basis != 'pedal' and
|
||||
positive(obs.effective_cluster_target_mps) else None,
|
||||
speed(active_target) if slc_limiting and positive(active_target) else None,
|
||||
posted_retained)
|
||||
posted_retained, limit_in_use)
|
||||
|
||||
Reference in New Issue
Block a user