mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-11-07 03:23:43 +08:00
carview: test page for the car's own browser, fed live from the comma
carviewd serves a three.js road scene on port 8099 and streams the car's speed, gear and engagement to it as server-sent events. Every 2 s the page posts back what it measured (frame rate, WebGL, how steadily data arrives, pixel ratio), logged with the car's state to /data/media/0/carview/stats.jsonl, so one drive shows whether the car's browser keeps drawing and receiving in Drive. - runs only while /data/media/0/carview/enabled exists, at nice 15; it only reads messages, and selfdrived ignores it if it dies - cars.js: clay cars built in code (crossover, sedan, SUV, pickup, dark ego), lofted from side profiles with wheel arches, glass, mirrors and tail lights that can show braking; supersampled on top of MSAA, stepping down only on real slowness; /garage shows them up close - ?flat draws a plain 2D canvas instead, to compare Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -150,7 +150,7 @@ class SelfdriveD:
|
||||
self.state_machine = StateMachine()
|
||||
self.rk = Ratekeeper(100, print_delay_threshold=None)
|
||||
|
||||
self.ignored_processes = {'mapd', }
|
||||
self.ignored_processes = {'mapd', 'carviewd'} # carviewd only serves a page, it never takes control away
|
||||
|
||||
# Determine startup event
|
||||
is_remote = build_metadata.openpilot.comma_remote or build_metadata.openpilot.sunnypilot_remote
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
carview test server: a page for the car's own browser, fed live from the comma.
|
||||
|
||||
It serves static/index.html on the local network and streams a small snapshot of the car's state to it as server-sent
|
||||
events. Every few seconds the page posts back what it measured (frame rate, WebGL, how steadily the data arrives), and
|
||||
each report is logged with the car's state at that moment, so one drive shows whether the car's browser keeps drawing
|
||||
and receiving while in Drive. It only reads messages and serves HTTP: it can't affect driving, and selfdrived ignores it
|
||||
if it dies. It runs only while the enabled file in carview_root() exists.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
PORT = 8099
|
||||
SEND_HZ = 20
|
||||
MAX_STREAMS = 4 # page reloads leave old streams open until their socket errors out
|
||||
MAX_REPORT_BYTES = 16_384
|
||||
STATS_MAX_BYTES = 5_000_000 # the log rolls over to stats.jsonl.1 past this
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
# the only files served, by URL path
|
||||
STATIC_FILES = {
|
||||
"/": ("index.html", "text/html; charset=utf-8"),
|
||||
"/index.html": ("index.html", "text/html; charset=utf-8"),
|
||||
"/garage": ("garage.html", "text/html; charset=utf-8"),
|
||||
"/cars.js": ("cars.js", "text/javascript; charset=utf-8"),
|
||||
}
|
||||
|
||||
|
||||
def carview_root() -> Path:
|
||||
return Path(Paths.log_root()).parent / "carview"
|
||||
|
||||
|
||||
def enabled_path() -> Path:
|
||||
return carview_root() / "enabled"
|
||||
|
||||
|
||||
def stats_path() -> Path:
|
||||
return carview_root() / "stats.jsonl"
|
||||
|
||||
|
||||
class CarSnapshot:
|
||||
"""The latest car state as a plain dict, refreshed from messaging on a background thread."""
|
||||
|
||||
def __init__(self):
|
||||
from openpilot.cereal import messaging
|
||||
self.sm = messaging.SubMaster(['carState', 'selfdriveState', 'selfdriveStateSP'])
|
||||
self.lock = threading.Lock()
|
||||
self.state: dict = {}
|
||||
threading.Thread(target=self._run, daemon=True).start()
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
self.sm.update(0)
|
||||
cs, ss, sp = self.sm['carState'], self.sm['selfdriveState'], self.sm['selfdriveStateSP']
|
||||
state = {
|
||||
'vEgo': round(cs.vEgo, 2), 'gear': str(cs.gearShifter), 'standstill': cs.standstill,
|
||||
'vCruise': round(cs.vCruiseCluster, 1), 'leftBlinker': cs.leftBlinker, 'rightBlinker': cs.rightBlinker,
|
||||
'engaged': ss.enabled, 'mads': sp.mads.enabled, 'state': str(ss.state), 'alert': ss.alertText1,
|
||||
'carStateAlive': self.sm.alive['carState'],
|
||||
}
|
||||
with self.lock:
|
||||
self.state = state
|
||||
time.sleep(1. / SEND_HZ)
|
||||
|
||||
def __call__(self) -> dict:
|
||||
with self.lock:
|
||||
return dict(self.state)
|
||||
|
||||
|
||||
def wall_ms() -> int:
|
||||
# wall clock on purpose: reports are lined up afterwards with the drive's own logs
|
||||
return int(time.time() * 1000) # noqa: TID251
|
||||
|
||||
|
||||
def append_report(path: Path, record: dict, max_bytes: int = STATS_MAX_BYTES) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists() and path.stat().st_size > max_bytes:
|
||||
path.replace(path.with_name(path.name + ".1"))
|
||||
with open(path, "a") as f:
|
||||
f.write(json.dumps(record, separators=(",", ":")) + "\n")
|
||||
|
||||
|
||||
def make_handler(snapshot: Callable[[], dict], log_path: Path, send_hz: float = SEND_HZ):
|
||||
streams = threading.BoundedSemaphore(MAX_STREAMS)
|
||||
seq_lock, seq = threading.Lock(), [0]
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "carview"
|
||||
|
||||
def log_message(self, *args) -> None: # no stderr line per request
|
||||
pass
|
||||
|
||||
def _send(self, code: int, body: bytes, content_type: str) -> None:
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path in STATIC_FILES:
|
||||
name, content_type = STATIC_FILES[path]
|
||||
self._send(200, (STATIC_DIR / name).read_bytes(), content_type)
|
||||
elif path == "/events":
|
||||
self._stream()
|
||||
elif path == "/state.json":
|
||||
self._send(200, json.dumps(snapshot()).encode(), "application/json")
|
||||
else:
|
||||
self._send(404, b"not found", "text/plain")
|
||||
|
||||
def _stream(self) -> None:
|
||||
if not streams.acquire(blocking=False):
|
||||
self._send(503, b"too many streams", "text/plain")
|
||||
return
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"retry: 1000\n\n")
|
||||
while True:
|
||||
with seq_lock:
|
||||
seq[0] += 1
|
||||
n = seq[0]
|
||||
msg = {'n': n, 'ts': wall_ms(), **snapshot()}
|
||||
self.wfile.write(b"data: " + json.dumps(msg, separators=(",", ":")).encode() + b"\n\n")
|
||||
self.wfile.flush()
|
||||
time.sleep(1. / send_hz)
|
||||
except OSError: # the page went away: broken pipe, reset or timeout
|
||||
pass
|
||||
finally:
|
||||
streams.release()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path.split("?", 1)[0] != "/report":
|
||||
self._send(404, b"not found", "text/plain")
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if length <= 0 or length > MAX_REPORT_BYTES:
|
||||
self._send(413, b"bad length", "text/plain")
|
||||
return
|
||||
try:
|
||||
page = json.loads(self.rfile.read(length))
|
||||
if not isinstance(page, dict):
|
||||
raise ValueError("not an object")
|
||||
except ValueError:
|
||||
self._send(400, b"bad json", "text/plain")
|
||||
return
|
||||
server_ms = wall_ms()
|
||||
append_report(log_path, {'serverTime': server_ms, 'client': self.client_address[0], 'car': snapshot(), 'page': page})
|
||||
self._send(200, json.dumps({'serverTime': server_ms}).encode(), "application/json")
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
class CarviewServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def handle_error(self, request, client_address) -> None:
|
||||
# a browser dropping an idle keep-alive connection is normal, anything else goes to the log instead of stderr
|
||||
if not isinstance(sys.exc_info()[1], (ConnectionError, TimeoutError)):
|
||||
cloudlog.exception(f"carviewd: request from {client_address[0]} failed")
|
||||
|
||||
|
||||
def make_server(snapshot: Callable[[], dict], log_path: Path, host: str = "0.0.0.0", port: int = PORT,
|
||||
send_hz: float = SEND_HZ) -> ThreadingHTTPServer:
|
||||
return CarviewServer((host, port), make_handler(snapshot, log_path, send_hz))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.nice(15) # well below the driving processes
|
||||
server = make_server(CarSnapshot(), stats_path())
|
||||
cloudlog.warning(f"carviewd serving on port {PORT}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,320 @@
|
||||
// Clay car models for carview, built in code so the page needs no downloaded model files.
|
||||
//
|
||||
// Each car is one smooth lofted skin: a side profile gives the body's top and bottom lines (wheel arches included) and
|
||||
// the cabin's roof line; at ~100 stations along the car a rounded cross-section is swept from them, full width below the
|
||||
// beltline and leaning in toward the roof above it. Triangles on the cabin's sides, windscreen and rear window form the
|
||||
// glass. Units are metres, the car's front points down -z and it sits on y = 0.
|
||||
|
||||
export const CAR_TYPES = ['crossover', 'sedan', 'suv', 'pickup'];
|
||||
|
||||
// Side profiles, front at +x. top: the body's outline, front bumper over the hood to the tail and back underneath
|
||||
// (the wheel arches are added). cabin: the greenhouse outline, only its top edge is used. axles: wheel centres along x.
|
||||
const PROFILES = {
|
||||
crossover: {
|
||||
width: 1.92, cabinRatio: 0.86, lean: 0.17, wheelR: 0.36, axles: [1.42, -1.47], sill: 0.25, archR: 0.43,
|
||||
top: [['M', 2.24, 0.27], ['Q', 2.42, 0.31, 2.41, 0.50], ['Q', 2.39, 0.70, 2.20, 0.78], ['Q', 1.55, 0.97, 0.86, 1.02],
|
||||
['L', -1.92, 1.07], ['Q', -2.30, 1.08, -2.37, 0.90], ['Q', -2.43, 0.52, -2.31, 0.31], ['Q', -2.25, 0.27, -2.10, 0.27]],
|
||||
cabin: [['M', 0.98, 0.96], ['Q', 0.50, 1.31, 0.04, 1.54], ['Q', -0.64, 1.67, -1.30, 1.58], ['Q', -1.86, 1.47, -2.12, 1.10],
|
||||
['L', -2.02, 0.98]],
|
||||
},
|
||||
sedan: {
|
||||
width: 1.86, cabinRatio: 0.84, lean: 0.17, wheelR: 0.34, axles: [1.44, -1.44], sill: 0.21, archR: 0.41,
|
||||
top: [['M', 2.28, 0.23], ['Q', 2.44, 0.28, 2.42, 0.47], ['Q', 2.39, 0.67, 2.18, 0.74], ['Q', 1.50, 0.88, 0.76, 0.92],
|
||||
['L', -1.66, 0.98], ['Q', -2.12, 1.00, -2.34, 0.96], ['Q', -2.44, 0.62, -2.34, 0.29], ['Q', -2.28, 0.23, -2.12, 0.23]],
|
||||
cabin: [['M', 0.88, 0.88], ['Q', 0.38, 1.21, -0.08, 1.41], ['Q', -0.62, 1.50, -1.12, 1.43], ['Q', -1.54, 1.31, -1.80, 1.00],
|
||||
['L', -1.70, 0.92]],
|
||||
},
|
||||
suv: {
|
||||
width: 1.98, cabinRatio: 0.90, lean: 0.12, wheelR: 0.39, axles: [1.50, -1.50], sill: 0.31, archR: 0.47,
|
||||
top: [['M', 2.36, 0.33], ['Q', 2.51, 0.39, 2.49, 0.63], ['Q', 2.47, 0.91, 2.29, 0.99], ['Q', 1.70, 1.10, 1.02, 1.13],
|
||||
['L', -2.28, 1.17], ['Q', -2.47, 1.17, -2.48, 0.99], ['Q', -2.51, 0.59, -2.40, 0.37], ['Q', -2.34, 0.33, -2.20, 0.33]],
|
||||
cabin: [['M', 1.10, 1.08], ['Q', 0.78, 1.43, 0.40, 1.75], ['Q', -0.80, 1.83, -2.12, 1.79], ['Q', -2.38, 1.74, -2.44, 1.46],
|
||||
['L', -2.40, 1.10]],
|
||||
},
|
||||
pickup: {
|
||||
width: 2.02, cabinRatio: 0.88, lean: 0.12, wheelR: 0.40, axles: [1.92, -1.78], sill: 0.37, archR: 0.49,
|
||||
top: [['M', 2.82, 0.41], ['Q', 2.97, 0.47, 2.95, 0.73], ['Q', 2.93, 1.03, 2.75, 1.11], ['Q', 2.10, 1.21, 1.30, 1.23],
|
||||
['L', -2.80, 1.27], ['Q', -2.91, 1.27, -2.91, 1.10], ['Q', -2.93, 0.70, -2.84, 0.45], ['Q', -2.78, 0.41, -2.64, 0.41]],
|
||||
cabin: [['M', 1.40, 1.16], ['Q', 1.08, 1.53, 0.70, 1.91], ['Q', -0.06, 1.97, -0.80, 1.93], ['Q', -0.93, 1.89, -0.95, 1.70],
|
||||
['L', -0.97, 1.18]],
|
||||
bed: [-1.05, -2.74],
|
||||
},
|
||||
};
|
||||
|
||||
const PAINTS = {
|
||||
clay: { body: 0xd8dbdf, glass: 0x7f8792, roughness: 0.62, metalness: 0.0, glassRoughness: 0.22 },
|
||||
ego: { body: 0x2f3338, glass: 0x0f1115, roughness: 0.3, metalness: 0.4, glassRoughness: 0.1 },
|
||||
};
|
||||
|
||||
const STATIONS = 104;
|
||||
const CORNER = 5; // points per rounded corner of a cross-section
|
||||
|
||||
function outline(THREE, cmds, p, withArches) {
|
||||
const s = new THREE.Shape();
|
||||
for (const [op, ...a] of cmds) {
|
||||
if (op === 'M') s.moveTo(a[0], a[1]);
|
||||
else if (op === 'L') s.lineTo(a[0], a[1]);
|
||||
else if (op === 'Q') s.quadraticCurveTo(a[0], a[1], a[2], a[3]);
|
||||
}
|
||||
if (withArches) {
|
||||
for (const ax of [...p.axles].sort((a, b) => a - b)) { // underside, rear to front, over each wheel
|
||||
s.lineTo(ax - p.archR, p.sill);
|
||||
s.absarc(ax, p.wheelR, p.archR, Math.PI + Math.asin(Math.min(1, (p.wheelR - p.sill) / p.archR)), -Math.asin(Math.min(1, (p.wheelR - p.sill) / p.archR)), true);
|
||||
s.lineTo(ax + p.archR, p.sill);
|
||||
}
|
||||
}
|
||||
s.closePath();
|
||||
return s.getPoints(48);
|
||||
}
|
||||
|
||||
// the outline split at its front- and rearmost points into an upper and a lower line, each sorted by x
|
||||
function chains(points) {
|
||||
let iMax = 0, iMin = 0;
|
||||
points.forEach((q, i) => { if (q.x > points[iMax].x) iMax = i; if (q.x < points[iMin].x) iMin = i; });
|
||||
const walk = (from, to) => { const out = []; for (let i = from; ; i = (i + 1) % points.length) { out.push(points[i]); if (i === to) break; } return out; };
|
||||
const a = walk(iMax, iMin), b = walk(iMin, iMax);
|
||||
const meanY = (c) => c.reduce((s, q) => s + q.y, 0) / c.length;
|
||||
const sort = (c) => c.map(q => [q.x, q.y]).sort((u, v) => u[0] - v[0]);
|
||||
return meanY(a) > meanY(b) ? { upper: sort(a), lower: sort(b), minX: points[iMin].x, maxX: points[iMax].x }
|
||||
: { upper: sort(b), lower: sort(a), minX: points[iMin].x, maxX: points[iMax].x };
|
||||
}
|
||||
|
||||
function yAt(chain, x, pick = Math.max) {
|
||||
// linear between the points around x; where the line is vertical (an arch's side), pick decides
|
||||
let lo = 0, hi = chain.length - 1;
|
||||
if (x <= chain[0][0]) return chain[0][1];
|
||||
if (x >= chain[hi][0]) return chain[hi][1];
|
||||
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (chain[mid][0] <= x) lo = mid; else hi = mid; }
|
||||
const [x0, y0] = chain[lo], [x1, y1] = chain[hi];
|
||||
return x1 - x0 < 1e-6 ? pick(y0, y1) : y0 + (y1 - y0) * (x - x0) / (x1 - x0);
|
||||
}
|
||||
|
||||
const arc = (cx, cy, r, a0, a1, out) => { for (let k = 0; k <= CORNER; k++) { const a = a0 + (a1 - a0) * k / CORNER; out.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); } };
|
||||
|
||||
// right half of a cross-section, bottom centre to roof centre, as [z, y]; glassFrom/glassTo index the cabin's side
|
||||
function section(b, t, r, hw, cwBase, cwTop) {
|
||||
const pts = [[0, b]];
|
||||
const h = Math.max(t - b, 1e-4);
|
||||
const rb = Math.min(0.10, h / 3, hw / 3), rs = Math.min(0.13, h / 3, hw / 3);
|
||||
arc(hw - rb, b + rb, rb, -Math.PI / 2, 0, pts);
|
||||
arc(hw - rs, t - rs, rs, 0, Math.PI / 2, pts);
|
||||
const glassFrom = pts.length;
|
||||
const cabinH = Math.max(r - t, 0);
|
||||
const rr = Math.min(0.17, cabinH * 0.45, cwTop / 3);
|
||||
pts.push([Math.min(cwBase, hw - rs), t]);
|
||||
for (const f of [0.08, 0.5]) pts.push([cwBase + (cwTop - cwBase) * f, t + (cabinH - rr) * f]);
|
||||
arc(cwTop - rr, r - rr, rr, 0, Math.PI / 2, pts);
|
||||
const glassTo = pts.length - CORNER + 1; // the roof corner's lower part is still glass
|
||||
pts.push([0, r]);
|
||||
return { pts, glassFrom, glassTo };
|
||||
}
|
||||
|
||||
const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
|
||||
|
||||
function skin(THREE, p) {
|
||||
const body = chains(outline(THREE, p.top, p, true));
|
||||
const cabin = chains(outline(THREE, p.cabin, p, false));
|
||||
const half = (body.maxX - body.minX) / 2, xc = (body.maxX + body.minX) / 2;
|
||||
const planWidth = (x) => { const u = Math.min(1, Math.abs(x - xc) / (half * 1.015)); return Math.pow(1 - Math.pow(u, 5), 1 / 5); };
|
||||
const roofAt = (x) => (x > cabin.minX && x < cabin.maxX) ? yAt(cabin.upper, x) : -1;
|
||||
|
||||
const positions = [], glass = [], paintTris = [], glassTris = [];
|
||||
let ring = 0;
|
||||
for (let i = 0; i < STATIONS; i++) {
|
||||
// denser toward the ends, where the shape turns fastest
|
||||
const s = i / (STATIONS - 1), x = xc - half * Math.cos(Math.PI * s) * 0.999;
|
||||
const b = yAt(body.lower, x, Math.max), t = yAt(body.upper, x);
|
||||
const r = Math.max(t, roofAt(x));
|
||||
const hw = (p.width / 2) * planWidth(x);
|
||||
const cwBase = hw * p.cabinRatio, cwTop = cwBase * (1 - p.lean);
|
||||
const { pts, glassFrom, glassTo } = section(b, t, r, hw, cwBase, cwTop);
|
||||
if (i === 0) ring = pts.length * 2 - 2;
|
||||
const dr = (Math.max(t, roofAt(x + 0.05)) - Math.max(t, roofAt(x - 0.05))) / 0.1; // roof slope: windscreen or rear window
|
||||
const cabinH = r - t;
|
||||
const flags = [];
|
||||
const loop = [...pts, ...pts.slice(1, -1).reverse().map(([z, y]) => [-z, y])];
|
||||
const idx = (j) => (j < pts.length ? j : pts.length - 1 - (j - pts.length + 1)); // loop index to half index
|
||||
loop.forEach(([z, y], j) => {
|
||||
positions.push(x, y, z);
|
||||
const k = idx(j);
|
||||
const side = k >= glassFrom + 1 && k < glassTo;
|
||||
const top = k >= glassTo && Math.abs(dr) > 0.42;
|
||||
flags.push(cabinH > 0.12 && (side || top) && Math.abs(x - xc) < half - 0.12);
|
||||
});
|
||||
glass.push(flags);
|
||||
}
|
||||
// quads between stations, split into paint and glass triangles
|
||||
for (let i = 0; i < STATIONS - 1; i++) {
|
||||
for (let j = 0; j < ring; j++) {
|
||||
const j1 = (j + 1) % ring;
|
||||
const a = i * ring + j, bq = i * ring + j1, c = (i + 1) * ring + j, d = (i + 1) * ring + j1;
|
||||
const isGlass = glass[i][j] && glass[i][j1] && glass[i + 1][j] && glass[i + 1][j1];
|
||||
(isGlass ? glassTris : paintTris).push(a, c, bq, bq, c, d);
|
||||
}
|
||||
}
|
||||
// close both ends with a fan around each end ring's centre, facing out: -x at the tail, +x at the nose
|
||||
for (const [i, nose] of [[0, false], [STATIONS - 1, true]]) {
|
||||
let cx = 0, cy = 0, cz = 0;
|
||||
for (let j = 0; j < ring; j++) { cx += positions[(i * ring + j) * 3]; cy += positions[(i * ring + j) * 3 + 1]; cz += positions[(i * ring + j) * 3 + 2]; }
|
||||
const centre = positions.length / 3;
|
||||
positions.push(cx / ring, cy / ring, cz / ring);
|
||||
for (let j = 0; j < ring; j++) {
|
||||
const a = i * ring + j, bq = i * ring + (j + 1) % ring;
|
||||
if (nose) paintTris.push(centre, bq, a); else paintTris.push(centre, a, bq);
|
||||
}
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
g.setIndex([...paintTris, ...glassTris]);
|
||||
g.addGroup(0, paintTris.length, 0);
|
||||
g.addGroup(paintTris.length, glassTris.length, 1);
|
||||
g.computeVertexNormals();
|
||||
return { g, beltlineAt: (x) => yAt(body.upper, x), halfWidthAt: (x) => (p.width / 2) * planWidth(x), cabinFront: cabin.maxX,
|
||||
rearX: body.minX };
|
||||
}
|
||||
|
||||
let shadowTexture = null;
|
||||
function contactShadow(THREE, length, width) {
|
||||
if (!shadowTexture) {
|
||||
const c = document.createElement('canvas'); c.width = c.height = 128;
|
||||
const ctx = c.getContext('2d');
|
||||
const grd = ctx.createRadialGradient(64, 64, 6, 64, 64, 64);
|
||||
grd.addColorStop(0, 'rgba(0,0,0,0.5)'); grd.addColorStop(0.5, 'rgba(0,0,0,0.26)'); grd.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = grd; ctx.fillRect(0, 0, 128, 128);
|
||||
shadowTexture = new THREE.CanvasTexture(c);
|
||||
}
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(width * 1.5, length * 1.18),
|
||||
new THREE.MeshBasicMaterial({ map: shadowTexture, transparent: true, depthWrite: false }));
|
||||
m.rotation.x = -Math.PI / 2; m.position.y = 0.012; m.renderOrder = -1;
|
||||
return m;
|
||||
}
|
||||
|
||||
const materialCache = new Map();
|
||||
function materials(THREE, paintName) {
|
||||
if (!materialCache.has(paintName)) {
|
||||
const p = PAINTS[paintName] || PAINTS.clay;
|
||||
materialCache.set(paintName, {
|
||||
body: new THREE.MeshStandardMaterial({ color: p.body, roughness: p.roughness, metalness: p.metalness }),
|
||||
glass: new THREE.MeshStandardMaterial({ color: p.glass, roughness: p.glassRoughness, metalness: 0.1 }),
|
||||
tire: new THREE.MeshStandardMaterial({ color: 0x26282c, roughness: 0.85 }),
|
||||
hub: new THREE.MeshStandardMaterial({ color: paintName === 'ego' ? 0x9097a0 : 0xcdd0d5, roughness: 0.45, metalness: 0.3 }),
|
||||
bed: new THREE.MeshStandardMaterial({ color: 0x55595f, roughness: 0.9 }),
|
||||
});
|
||||
}
|
||||
return materialCache.get(paintName);
|
||||
}
|
||||
|
||||
const partsCache = new Map();
|
||||
function parts(THREE, type) {
|
||||
if (!partsCache.has(type)) {
|
||||
const p = PROFILES[type];
|
||||
const s = skin(THREE, p);
|
||||
const tire = new THREE.CylinderGeometry(p.wheelR, p.wheelR, 0.25, 40, 1);
|
||||
tire.rotateX(Math.PI / 2);
|
||||
const hub = new THREE.CylinderGeometry(p.wheelR * 0.64, p.wheelR * 0.64, 0.02, 40, 1);
|
||||
hub.rotateX(Math.PI / 2);
|
||||
const mirror = new THREE.SphereGeometry(0.1, 20, 14);
|
||||
mirror.scale(1.15, 0.85, 1.25);
|
||||
partsCache.set(type, { ...s, tire, hub, mirror });
|
||||
}
|
||||
return partsCache.get(type);
|
||||
}
|
||||
|
||||
// A car as a THREE.Group, front toward -z. paint: 'clay' for other traffic, 'ego' for our own car.
|
||||
export function buildCar(THREE, type = 'crossover', paint = 'clay') {
|
||||
type = PROFILES[type] ? type : 'crossover';
|
||||
const p = PROFILES[type], g = parts(THREE, type), mat = materials(THREE, paint);
|
||||
const car = new THREE.Group(); // what the caller moves around
|
||||
const model = new THREE.Group(); // the profile's +x turned to -z
|
||||
model.rotation.y = Math.PI / 2;
|
||||
car.add(model);
|
||||
|
||||
const skinMesh = new THREE.Mesh(g.g, [mat.body, mat.glass]);
|
||||
model.add(skinMesh);
|
||||
if (p.bed) {
|
||||
const [x0, x1] = p.bed, y = g.beltlineAt((x0 + x1) / 2);
|
||||
const bed = new THREE.Mesh(new THREE.BoxGeometry(x0 - x1, 0.02, 2 * g.halfWidthAt((x0 + x1) / 2) - 0.3), mat.bed);
|
||||
bed.position.set((x0 + x1) / 2, y + 0.006, 0);
|
||||
model.add(bed);
|
||||
}
|
||||
// mirrors sit on the door tops just behind the windscreen's base, dug into the shoulder so they never float
|
||||
const mx = g.cabinFront - 0.22, my = g.beltlineAt(mx) + 0.03, mz = g.halfWidthAt(mx) - 0.01;
|
||||
for (const side of [-1, 1]) {
|
||||
const m = new THREE.Mesh(g.mirror, mat.body);
|
||||
m.position.set(mx, my, side * mz);
|
||||
model.add(m);
|
||||
}
|
||||
const zWheel = p.width / 2 - 0.17;
|
||||
for (const ax of p.axles) {
|
||||
for (const side of [-1, 1]) {
|
||||
const tire = new THREE.Mesh(g.tire, mat.tire);
|
||||
tire.position.set(ax, p.wheelR, side * zWheel);
|
||||
model.add(tire);
|
||||
const hub = new THREE.Mesh(g.hub, mat.hub);
|
||||
hub.position.set(ax, p.wheelR, side * (zWheel + 0.13));
|
||||
model.add(hub);
|
||||
}
|
||||
}
|
||||
// a tail light bar across the back, dark until car.userData.setBraking(true)
|
||||
const lx = g.rearX + 0.07, ly = g.beltlineAt(lx + 0.15) - 0.12;
|
||||
const lights = new THREE.MeshStandardMaterial({ color: 0x4a2124, roughness: 0.4, emissive: 0xff2b2b, emissiveIntensity: 0 });
|
||||
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.07, 2 * g.halfWidthAt(lx) * 0.92), lights);
|
||||
bar.position.set(lx, ly, 0);
|
||||
model.add(bar);
|
||||
|
||||
const length = Math.max(...p.top.map(c => c[c.length - 2])) - Math.min(...p.top.map(c => c[c.length - 2]));
|
||||
car.add(contactShadow(THREE, length, p.width));
|
||||
car.userData = {
|
||||
type, length, width: p.width,
|
||||
setBraking: (on) => { lights.emissiveIntensity = on ? 2.4 : 0; lights.color.setHex(on ? 0xc92a2a : 0x4a2124); },
|
||||
};
|
||||
return car;
|
||||
}
|
||||
|
||||
// Supersampling on top of the renderer's own multisampling: draw at 1.5-3x the screen's pixels and let the browser
|
||||
// scale it down, so edges stay smooth even on a 1x screen. Steps down a quarter at a time while the frame rate is under
|
||||
// minFps for two seconds running, never below 1x. Call tick(now) once per frame; it returns the pixel ratio in use.
|
||||
export function supersampler(renderer, { minFps = 45, start = null } = {}) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
let ratio = start ?? Math.min(3, Math.max(1.5, dpr * 1.5));
|
||||
renderer.setPixelRatio(ratio);
|
||||
let frames = 0, since = performance.now(), slowWindows = 0;
|
||||
return {
|
||||
get ratio() { return ratio; },
|
||||
tick(now) {
|
||||
frames++;
|
||||
if (now - since < 1000) return ratio;
|
||||
const fps = frames * 1000 / (now - since);
|
||||
frames = 0; since = now;
|
||||
// under ~8 fps the browser is throttling a hidden page, not struggling to draw: leave the resolution alone
|
||||
slowWindows = fps >= 8 && fps < minFps ? slowWindows + 1 : 0;
|
||||
if (slowWindows >= 2 && ratio > 1) {
|
||||
ratio = Math.max(1, ratio - 0.25); slowWindows = 0;
|
||||
renderer.setPixelRatio(ratio);
|
||||
renderer.setSize(renderer.domElement.clientWidth, renderer.domElement.clientHeight, false);
|
||||
}
|
||||
return ratio;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Soft studio light for the clay look: a pre-filtered environment from a few glowing panels around a grey room.
|
||||
export function studioEnvironment(THREE, renderer) {
|
||||
const room = new THREE.Scene();
|
||||
room.background = new THREE.Color(0x8e9197);
|
||||
const panel = (w, h, x, y, z, intensity) => {
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(intensity, intensity, intensity), side: THREE.DoubleSide }));
|
||||
m.position.set(x, y, z); m.lookAt(0, 0, 0); room.add(m);
|
||||
};
|
||||
panel(16, 16, 0, 12, 0, 3.0); // big overhead softbox
|
||||
panel(12, 5, 0, 4, -13, 1.6); // front strip
|
||||
panel(12, 5, 13, 3, 0, 1.1); // side fills
|
||||
panel(12, 5, -13, 3, 0, 0.9);
|
||||
panel(12, 5, 0, 3, 13, 1.2); // behind
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.MeshBasicMaterial({ color: 0x5e6166 }));
|
||||
floor.rotation.x = -Math.PI / 2; floor.position.y = -2; room.add(floor);
|
||||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||
const env = pmrem.fromScene(room, 0.035).texture;
|
||||
pmrem.dispose();
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>carview garage</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #dcdde0;
|
||||
font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
|
||||
canvas { display: block; width: 100vw; height: 100vh; }
|
||||
#bar { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; gap: 8px; }
|
||||
#bar button { font-size: 15px; padding: 10px 16px; border-radius: 999px; border: 0; background: rgba(255,255,255,0.9);
|
||||
color: #1d1f22; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,0.1); }
|
||||
#bar button.on { background: #1d1f22; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div id="bar"></div>
|
||||
<script type="module">
|
||||
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
|
||||
import { buildCar, studioEnvironment, supersampler, CAR_TYPES } from '/cars.js';
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
const ss = supersampler(renderer);
|
||||
renderer.toneMapping = THREE.NeutralToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xdcdde0);
|
||||
scene.environment = studioEnvironment(THREE, renderer);
|
||||
scene.environmentIntensity = 0.75;
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.6); sun.position.set(-5, 9, 6); scene.add(sun);
|
||||
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.MeshStandardMaterial({ color: 0xeeeff1, roughness: 1 }));
|
||||
floor.rotation.x = -Math.PI / 2; scene.add(floor);
|
||||
|
||||
const cars = [];
|
||||
const params = new URLSearchParams(location.search);
|
||||
// ?car=sedan shows one car up close, otherwise the whole row
|
||||
const row = params.get('car') ? [[params.get('car'), params.get('paint') || 'clay']] : [...CAR_TYPES.map(t => [t, 'clay']), ['crossover', 'ego']];
|
||||
row.forEach(([type, paint], i) => {
|
||||
const car = buildCar(THREE, type, paint);
|
||||
car.position.x = (i - (row.length - 1) / 2) * 6.4;
|
||||
scene.add(car); cars.push(car);
|
||||
});
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 200);
|
||||
const VIEWS = { front: [0.55, 0.30], side: [Math.PI / 2, 0.12], rear: [Math.PI + 0.6, 0.32], chase: [Math.PI, 0.45] };
|
||||
let view = new URLSearchParams(location.search).get('view') || 'front';
|
||||
const bar = document.getElementById('bar');
|
||||
for (const v of Object.keys(VIEWS)) {
|
||||
const b = document.createElement('button'); b.textContent = v;
|
||||
b.onclick = () => { view = v; for (const x of bar.children) x.className = x === b ? 'on' : ''; };
|
||||
if (v === view) b.className = 'on';
|
||||
bar.appendChild(b);
|
||||
}
|
||||
const resize = () => {
|
||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||||
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
|
||||
};
|
||||
window.addEventListener('resize', resize); resize();
|
||||
|
||||
const spin = new URLSearchParams(location.search).has('spin');
|
||||
function frame(t) {
|
||||
const [yaw, pitch] = VIEWS[view];
|
||||
// the camera stays put in front of the row and every car turns to the chosen angle, so they're all seen alike
|
||||
cars.forEach(c => { c.rotation.y = Math.PI + yaw + (spin ? t / 4000 : 0); });
|
||||
const d = row.length > 1 ? 40 : 12;
|
||||
camera.position.set(0, d * Math.sin(pitch) + 0.6, d * Math.cos(pitch));
|
||||
camera.lookAt(0, 0.7, 0);
|
||||
ss.tick(t);
|
||||
renderer.render(scene, camera);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>carview test</title>
|
||||
<style>
|
||||
:root {
|
||||
--sky: #dcdde0; --ink: #1d1f22; --muted: #6b7076; --card: rgba(255, 255, 255, 0.92); --line: #e3e4e7;
|
||||
--ok: #1f9d55; --bad: #d64545; --wait: #b7791f; --blue: #3e6ae1;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--sky); color: var(--ink);
|
||||
font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; -webkit-user-select: none; user-select: none; }
|
||||
#scene { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; }
|
||||
|
||||
#hud { position: fixed; top: 24px; left: 32px; }
|
||||
#gears { letter-spacing: 0.35em; font-size: 18px; color: #a3a7ad; font-weight: 600; }
|
||||
#gears b { color: var(--ink); }
|
||||
#speed { font-size: 88px; font-weight: 300; line-height: 1; margin-top: 6px; font-variant-numeric: tabular-nums; }
|
||||
#unit { font-size: 16px; color: var(--muted); letter-spacing: 0.1em; }
|
||||
#setRow { display: flex; gap: 12px; align-items: center; margin-top: 12px; }
|
||||
#max { font-size: 26px; color: var(--blue); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
#max small { font-size: 13px; display: block; letter-spacing: 0.1em; }
|
||||
.pill { font-size: 14px; font-weight: 600; padding: 4px 10px; border-radius: 999px; background: #c9ccd1; color: #fff; }
|
||||
.pill.on { background: var(--ok); } .pill.lat { background: var(--blue); }
|
||||
#clock { position: fixed; top: 26px; right: 32px; font-size: 22px; font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
|
||||
#panel { position: fixed; left: 24px; bottom: 24px; width: min(440px, calc(100vw - 48px)); background: var(--card);
|
||||
border-radius: 16px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); padding: 16px 18px; }
|
||||
#panel h1 { font-size: 17px; margin: 0 0 4px; display: flex; justify-content: space-between; align-items: center; }
|
||||
#beat { width: 56px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
|
||||
#beatDot { width: 12px; height: 6px; border-radius: 3px; background: var(--blue); }
|
||||
.row { display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; align-items: baseline; padding: 7px 0;
|
||||
border-top: 1px solid var(--line); font-size: 16px; }
|
||||
.row:first-of-type { border-top: 0; }
|
||||
.mark { font-weight: 700; } .ok .mark { color: var(--ok); } .bad .mark { color: var(--bad); } .wait .mark { color: var(--wait); }
|
||||
.val { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
#renderer { font-size: 12px; color: var(--muted); margin: 2px 0 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#panel button { margin-top: 10px; width: 100%; font-size: 16px; padding: 12px; border-radius: 10px; border: 0;
|
||||
background: var(--ink); color: #fff; font-weight: 600; }
|
||||
#panel p { margin: 8px 0 0; font-size: 13px; color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="scene"></canvas>
|
||||
|
||||
<div id="hud">
|
||||
<div id="gears">P R N D</div>
|
||||
<div id="speed">--</div>
|
||||
<div id="unit">MPH</div>
|
||||
<div id="setRow"><div id="max">--<small>MAX</small></div><span id="engaged" class="pill">off</span></div>
|
||||
</div>
|
||||
<div id="clock"></div>
|
||||
|
||||
<div id="panel">
|
||||
<h1>carview test <span id="beat"><div id="beatDot"></div></span></h1>
|
||||
<div id="renderer">checking WebGL...</div>
|
||||
<div class="row wait" id="r-data"><span class="mark">…</span><span>Live data from the comma</span><span class="val"></span></div>
|
||||
<div class="row wait" id="r-draw"><span class="mark">…</span><span>Drawing</span><span class="val"></span></div>
|
||||
<div class="row wait" id="r-webgl"><span class="mark">…</span><span>WebGL 2</span><span class="val"></span></div>
|
||||
<div class="row wait" id="r-three"><span class="mark">…</span><span>three.js</span><span class="val"></span></div>
|
||||
<div class="row wait" id="r-report"><span class="mark">…</span><span>Reports to the comma</span><span class="val"></span></div>
|
||||
<button id="fullscreen">Try fullscreen</button>
|
||||
<p>Leave this open and drive. Every 2 s the page tells the comma what it measured.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---- what the page measures, reported to the comma every REPORT_S
|
||||
const REPORT_S = 2;
|
||||
const started = performance.now();
|
||||
const m = { frames: 0, frameGapMax: 0, events: 0, eventGapMax: 0, lastEvent: 0, rtt: null, reportOk: null,
|
||||
webgl1: false, webgl2: false, renderer: '', three: 'loading', threeError: '', mode: 'none' };
|
||||
let car = { vEgo: 0, gear: 'unknown', vCruise: 0, engaged: false, mads: false };
|
||||
let fps = 0, fpsMin = 0;
|
||||
|
||||
function setRow(id, state, text) {
|
||||
const el = document.getElementById(id);
|
||||
el.className = 'row ' + state;
|
||||
el.querySelector('.mark').textContent = state === 'ok' ? '✓' : state === 'bad' ? '✗' : '…';
|
||||
el.querySelector('.val').textContent = text;
|
||||
}
|
||||
|
||||
(function detectWebGL() {
|
||||
const c = document.createElement('canvas');
|
||||
const g2 = c.getContext('webgl2');
|
||||
m.webgl2 = !!g2;
|
||||
const g = g2 || c.getContext('webgl');
|
||||
m.webgl1 = !!(g2 || document.createElement('canvas').getContext('webgl'));
|
||||
if (g) {
|
||||
const info = g.getExtension('WEBGL_debug_renderer_info');
|
||||
m.renderer = info ? g.getParameter(info.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER);
|
||||
}
|
||||
document.getElementById('renderer').textContent = m.renderer || 'no WebGL renderer';
|
||||
setRow('r-webgl', m.webgl2 ? 'ok' : 'bad', m.webgl2 ? 'yes' : (m.webgl1 ? 'only WebGL 1' : 'no'));
|
||||
})();
|
||||
|
||||
// ---- live data: server-sent events from the comma
|
||||
const es = new EventSource('/events');
|
||||
let eventsThisWindow = 0;
|
||||
es.onmessage = (e) => {
|
||||
const now = performance.now();
|
||||
if (m.lastEvent) m.eventGapMax = Math.max(m.eventGapMax, now - m.lastEvent);
|
||||
m.lastEvent = now;
|
||||
m.events++; eventsThisWindow++;
|
||||
try { car = JSON.parse(e.data); } catch (err) { /* keep the last good state */ }
|
||||
};
|
||||
|
||||
// ---- the scene: three.js when it loads, a flat canvas otherwise
|
||||
const canvas = document.getElementById('scene');
|
||||
let drawScene = () => {};
|
||||
let distance = 0, vSmooth = 0;
|
||||
let ss = null, leadCar = null; // the supersampler and the car ahead, once three.js has loaded
|
||||
|
||||
async function startThree() {
|
||||
const THREE = await import('https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js');
|
||||
const { buildCar, studioEnvironment, supersampler } = await import('/cars.js');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.toneMapping = THREE.NeutralToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
ss = supersampler(renderer);
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xdcdde0);
|
||||
scene.fog = new THREE.Fog(0xdcdde0, 45, 170);
|
||||
scene.environment = studioEnvironment(THREE, renderer);
|
||||
scene.environmentIntensity = 0.75;
|
||||
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 400);
|
||||
camera.position.set(0, 6.5, 15);
|
||||
camera.lookAt(0, 0, -26);
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0xa9abaf, 0.6));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
|
||||
sun.position.set(-5, 9, 6);
|
||||
scene.add(sun);
|
||||
|
||||
const flat = (w, l, color, x, z, y) => {
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, l), new THREE.MeshStandardMaterial({ color, roughness: 1 }));
|
||||
mesh.rotation.x = -Math.PI / 2; mesh.position.set(x, y, z); scene.add(mesh); return mesh;
|
||||
};
|
||||
flat(400, 400, 0xbfc2c6, 0, -150, 0);
|
||||
flat(11.2, 400, 0xf5f5f6, 0, -180, 0.01);
|
||||
flat(0.15, 400, 0xd9b44a, -5.4, -180, 0.02); // yellow edge, left
|
||||
flat(0.15, 400, 0xb8bbc0, 5.4, -180, 0.02); // white edge, right
|
||||
const path = flat(1.1, 58, 0x3e6ae1, 0, -31, 0.03);
|
||||
path.material.transparent = true; path.material.opacity = 0.9;
|
||||
|
||||
// lane dashes: one instanced mesh, moved by the distance driven
|
||||
const DASH_EVERY = 12, DASHES = 34, LANES = [-1.8, 1.8];
|
||||
const dashes = new THREE.InstancedMesh(new THREE.BoxGeometry(0.15, 0.01, 3), new THREE.MeshStandardMaterial({ color: 0xa9adb3, roughness: 1 }),
|
||||
DASHES * LANES.length);
|
||||
scene.add(dashes);
|
||||
const tmp = new THREE.Object3D();
|
||||
|
||||
const place = (type, paint, x, z) => { const c = buildCar(THREE, type, paint); c.position.set(x, 0, z); scene.add(c); return c; };
|
||||
place('crossover', 'ego', 0, 0);
|
||||
const lead = place('crossover', 'clay', 0, -34);
|
||||
place('suv', 'clay', -3.6, -58);
|
||||
place('sedan', 'clay', 3.6, -22);
|
||||
place('pickup', 'clay', -3.6, -96);
|
||||
leadCar = lead;
|
||||
|
||||
const resize = () => {
|
||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||||
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
|
||||
};
|
||||
window.addEventListener('resize', resize); resize();
|
||||
|
||||
drawScene = (now) => {
|
||||
ss.tick(now);
|
||||
let i = 0;
|
||||
for (const x of LANES) {
|
||||
for (let k = 0; k < DASHES; k++) {
|
||||
tmp.position.set(x, 0.02, 20 - k * DASH_EVERY + (distance % DASH_EVERY));
|
||||
tmp.updateMatrix(); dashes.setMatrixAt(i++, tmp.matrix);
|
||||
}
|
||||
}
|
||||
dashes.instanceMatrix.needsUpdate = true;
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
m.three = 'loaded'; m.mode = 'three';
|
||||
}
|
||||
|
||||
function startFlat() {
|
||||
const ctx = canvas.getContext('2d');
|
||||
m.mode = ctx ? 'canvas2d' : 'none';
|
||||
drawScene = () => {
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
||||
ctx.fillStyle = '#dcdde0'; ctx.fillRect(0, 0, w, h);
|
||||
const hz = h * 0.42, cx = w / 2;
|
||||
ctx.fillStyle = '#f5f5f6';
|
||||
ctx.beginPath(); ctx.moveTo(cx - 20, hz); ctx.lineTo(cx + 20, hz); ctx.lineTo(cx + w * 0.45, h); ctx.lineTo(cx - w * 0.45, h); ctx.fill();
|
||||
ctx.fillStyle = '#a9adb3';
|
||||
for (let k = 0; k < 14; k++) {
|
||||
const z = ((k * 12 - (distance % 12)) + 168) % 168 + 4; // metres ahead
|
||||
const y = hz + (h - hz) * (6 / z);
|
||||
for (const s of [-1, 1]) {
|
||||
const x = cx + s * (w * 0.15) * (6 / z);
|
||||
ctx.fillRect(x - 2, y, 4, Math.max(2, 40 * (6 / z)));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (new URLSearchParams(location.search).has('flat')) { // ?flat: skip three.js, to compare a plain canvas
|
||||
m.three = 'skipped'; startFlat();
|
||||
} else {
|
||||
startThree().catch((err) => { m.three = 'failed'; m.threeError = String(err).slice(0, 200); startFlat(); });
|
||||
}
|
||||
|
||||
// ---- the frame loop
|
||||
const beatDot = document.getElementById('beatDot');
|
||||
let lastFrame = 0, minThisWindow = Infinity;
|
||||
function frame(now) {
|
||||
if (lastFrame) {
|
||||
const dt = Math.min((now - lastFrame) / 1000, 0.25);
|
||||
m.frameGapMax = Math.max(m.frameGapMax, now - lastFrame);
|
||||
vSmooth += (car.vEgo - vSmooth) * Math.min(1, dt * 4); // the stream is 20 Hz, the screen faster
|
||||
distance += vSmooth * dt;
|
||||
}
|
||||
lastFrame = now; m.frames++;
|
||||
beatDot.style.transform = `translateX(${(now / 25) % 44}px)`;
|
||||
if (leadCar) leadCar.userData.setBraking(Math.floor(now / 3000) % 3 === 0); // the tail lights blink for the test
|
||||
drawScene(now);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
// frames counted by the clock, not by the frame loop, so a browser that stops drawing reads 0 instead of its last rate
|
||||
let hudFrames = 0, hudTime = performance.now();
|
||||
function measureFps() {
|
||||
const now = performance.now();
|
||||
fps = Math.round((m.frames - hudFrames) * 1000 / (now - hudTime));
|
||||
minThisWindow = Math.min(minThisWindow, fps);
|
||||
hudFrames = m.frames; hudTime = now;
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// ---- HUD and checklist, once a second is plenty
|
||||
const GEARS = { park: 'P', reverse: 'R', neutral: 'N', drive: 'D' };
|
||||
function updateHud() {
|
||||
measureFps();
|
||||
const gear = GEARS[car.gear] || '';
|
||||
document.getElementById('gears').innerHTML = ['P', 'R', 'N', 'D'].map(g => g === gear ? `<b>${g}</b>` : g).join(' ');
|
||||
document.getElementById('speed').textContent = Math.round(car.vEgo * 2.23694);
|
||||
const max = car.vCruise > 0 && car.vCruise < 250 ? Math.round(car.vCruise * 0.621371) : '--';
|
||||
document.getElementById('max').innerHTML = `${max}<small>MAX</small>`;
|
||||
const pill = document.getElementById('engaged');
|
||||
pill.className = 'pill' + (car.engaged ? ' on' : car.mads ? ' lat' : '');
|
||||
pill.textContent = car.engaged ? 'openpilot' : car.mads ? 'steering' : 'off';
|
||||
document.getElementById('clock').textContent = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
|
||||
const since = m.lastEvent ? performance.now() - m.lastEvent : Infinity;
|
||||
setRow('r-data', since < 1000 && eventsThisWindow >= 10 ? 'ok' : since < 3000 ? 'wait' : 'bad',
|
||||
m.lastEvent ? `${eventsThisWindow}/s, gap ${Math.round(m.eventGapMax)} ms` : 'waiting');
|
||||
eventsThisWindow = 0;
|
||||
setRow('r-draw', fps >= 30 ? 'ok' : fps > 0 ? 'wait' : 'bad', `${fps} fps (${m.mode}${ss ? `, ${ss.ratio}x` : ''})`);
|
||||
setRow('r-three', m.three === 'loaded' ? 'ok' : m.three === 'failed' ? 'bad' : 'wait', m.three);
|
||||
if (m.reportOk !== null) setRow('r-report', m.reportOk ? 'ok' : 'bad', m.reportOk ? `${m.rtt} ms round trip` : 'failed');
|
||||
}
|
||||
setInterval(updateHud, 1000);
|
||||
|
||||
// ---- report to the comma
|
||||
async function report() {
|
||||
const body = {
|
||||
uptimeS: Math.round((performance.now() - started) / 1000), fps, fpsMin: Number.isFinite(minThisWindow) ? Math.round(minThisWindow) : null,
|
||||
frames: m.frames, frameGapMaxMs: Math.round(m.frameGapMax), events: m.events, eventGapMaxMs: Math.round(m.eventGapMax),
|
||||
eventSource: es.readyState, carSeen: { vEgo: car.vEgo, gear: car.gear, n: car.n },
|
||||
webgl1: m.webgl1, webgl2: m.webgl2, renderer: m.renderer, three: m.three, threeError: m.threeError, mode: m.mode,
|
||||
visibility: document.visibilityState, w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio,
|
||||
rttMs: m.rtt, pixelRatio: ss ? ss.ratio : null, ua: navigator.userAgent, url: location.href,
|
||||
};
|
||||
m.frameGapMax = 0; m.eventGapMax = 0; minThisWindow = Infinity;
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await fetch('/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
m.reportOk = r.ok; m.rtt = Math.round(performance.now() - t0);
|
||||
} catch (err) {
|
||||
m.reportOk = false;
|
||||
}
|
||||
}
|
||||
setInterval(report, REPORT_S * 1000);
|
||||
document.addEventListener('visibilitychange', report);
|
||||
|
||||
document.getElementById('fullscreen').onclick = () => {
|
||||
// the car opens YouTube's redirect page chromeless, and the page it forwards to stays that way
|
||||
location.href = 'https://www.youtube.com/redirect?q=' + encodeURIComponent(location.href);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.system.carview import carviewd
|
||||
|
||||
CAR = {'vEgo': 12.5, 'gear': 'drive', 'engaged': True, 'mads': True, 'vCruise': 104.6}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(tmp_path):
|
||||
srv = carviewd.make_server(lambda: dict(CAR), tmp_path / "stats.jsonl", host="127.0.0.1", port=0, send_hz=200)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
yield srv, tmp_path / "stats.jsonl"
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
|
||||
def request(srv, method, path, body=None, headers=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", srv.server_address[1], timeout=5)
|
||||
conn.request(method, path, body=body, headers=headers or {})
|
||||
return conn, conn.getresponse()
|
||||
|
||||
|
||||
def test_serves_the_page(server):
|
||||
srv, _ = server
|
||||
_, r = request(srv, "GET", "/")
|
||||
assert r.status == 200
|
||||
assert r.getheader("Content-Type").startswith("text/html")
|
||||
assert b"new EventSource('/events')" in r.read()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,content_type,marker", [
|
||||
("/cars.js", "text/javascript", b"export function buildCar"),
|
||||
("/garage", "text/html", b"import { buildCar"),
|
||||
])
|
||||
def test_serves_the_car_models_and_the_garage(server, path, content_type, marker):
|
||||
srv, _ = server
|
||||
_, r = request(srv, "GET", path)
|
||||
assert r.status == 200
|
||||
assert r.getheader("Content-Type").startswith(content_type)
|
||||
assert marker in r.read()
|
||||
|
||||
|
||||
def test_every_served_file_exists():
|
||||
for name, _ in carviewd.STATIC_FILES.values():
|
||||
assert (carviewd.STATIC_DIR / name).is_file()
|
||||
|
||||
|
||||
def test_unknown_paths_are_not_found(server):
|
||||
srv, _ = server
|
||||
for method, path in (("GET", "/../carviewd.py"), ("GET", "/static/index.html"), ("POST", "/")):
|
||||
_, r = request(srv, method, path, body=b"{}", headers={"Content-Length": "2"})
|
||||
assert r.status == 404
|
||||
|
||||
|
||||
def test_streams_the_car_state_with_a_sequence(server):
|
||||
srv, _ = server
|
||||
conn, r = request(srv, "GET", "/events")
|
||||
assert r.getheader("Content-Type") == "text/event-stream"
|
||||
events = []
|
||||
while len(events) < 3:
|
||||
line = r.fp.readline().decode().strip()
|
||||
if line.startswith("data: "):
|
||||
events.append(json.loads(line[len("data: "):]))
|
||||
conn.close()
|
||||
assert all(e['vEgo'] == CAR['vEgo'] and e['gear'] == 'drive' and 'ts' in e for e in events)
|
||||
assert [e['n'] for e in events] == sorted(e['n'] for e in events) and len({e['n'] for e in events}) == 3
|
||||
|
||||
|
||||
def test_a_report_is_logged_with_the_car_state(server):
|
||||
srv, log = server
|
||||
page = {'fps': 60, 'webgl2': True, 'visibility': 'visible'}
|
||||
_, r = request(srv, "POST", "/report", body=json.dumps(page), headers={"Content-Type": "application/json"})
|
||||
assert r.status == 200 and 'serverTime' in json.loads(r.read())
|
||||
record = json.loads(log.read_text().splitlines()[-1])
|
||||
assert record['page'] == page
|
||||
assert record['car']['gear'] == 'drive' and record['car']['vEgo'] == CAR['vEgo']
|
||||
assert record['client'] == "127.0.0.1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [b"not json", b"[1, 2]", b""])
|
||||
def test_a_bad_report_is_refused_and_not_logged(server, body):
|
||||
srv, log = server
|
||||
_, r = request(srv, "POST", "/report", body=body, headers={"Content-Length": str(len(body))})
|
||||
assert r.status in (400, 413)
|
||||
assert not log.exists()
|
||||
|
||||
|
||||
def test_an_oversized_report_is_refused(server):
|
||||
srv, log = server
|
||||
body = json.dumps({'pad': "x" * carviewd.MAX_REPORT_BYTES})
|
||||
_, r = request(srv, "POST", "/report", body=body)
|
||||
assert r.status == 413
|
||||
assert not log.exists()
|
||||
|
||||
|
||||
def test_too_many_streams_are_turned_away(server):
|
||||
srv, _ = server
|
||||
open_streams = [request(srv, "GET", "/events") for _ in range(carviewd.MAX_STREAMS)]
|
||||
assert all(r.status == 200 for _, r in open_streams)
|
||||
_, r = request(srv, "GET", "/events")
|
||||
assert r.status == 503
|
||||
for conn, _ in open_streams:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_the_log_rolls_over_past_its_size(tmp_path):
|
||||
log = tmp_path / "stats.jsonl"
|
||||
for i in range(5):
|
||||
carviewd.append_report(log, {'i': i, 'pad': "x" * 40}, max_bytes=100)
|
||||
rolled = tmp_path / "stats.jsonl.1"
|
||||
assert rolled.exists()
|
||||
assert log.stat().st_size <= 100 + 60
|
||||
last = [json.loads(line)['i'] for line in log.read_text().splitlines()]
|
||||
assert last[-1] == 4
|
||||
|
||||
|
||||
def test_it_runs_only_while_the_enabled_file_exists(tmp_path, monkeypatch):
|
||||
from openpilot.system.manager import process_config
|
||||
flag = tmp_path / "enabled"
|
||||
monkeypatch.setattr(process_config, "carview_enabled_path", lambda: flag)
|
||||
assert not process_config.carview(True, None, None)
|
||||
flag.touch()
|
||||
assert process_config.carview(True, None, None) and process_config.carview(False, None, None)
|
||||
assert any(p.name == "carviewd" for p in process_config.procs)
|
||||
@@ -10,6 +10,7 @@ from openpilot.system.manager.process import PythonProcess, NativeProcess, Daemo
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
|
||||
from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH
|
||||
from openpilot.sunnypilot.system.carview.carviewd import enabled_path as carview_enabled_path
|
||||
|
||||
from openpilot.sunnypilot.models.helpers import get_active_model_runner
|
||||
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, use_sunnylink_uploader
|
||||
@@ -71,6 +72,9 @@ def only_offroad(started: bool, params: Params, CP: car.CarParams) -> bool:
|
||||
def livestream(started: bool, params: Params, CP: car.CarParams) -> bool:
|
||||
return params.get_bool("IsLiveStreaming")
|
||||
|
||||
def carview(started: bool, params: Params, CP: car.CarParams) -> bool:
|
||||
return os.path.exists(carview_enabled_path())
|
||||
|
||||
def use_copyparty(started, params, CP: car.CarParams) -> bool:
|
||||
return bool(params.get_bool("EnableCopyparty"))
|
||||
|
||||
@@ -186,6 +190,9 @@ procs += [
|
||||
|
||||
# locationd
|
||||
NativeProcess("locationd_llk", "openpilot/sunnypilot/selfdrive/locationd", ["./locationd"], only_onroad),
|
||||
|
||||
# a page for the car's own browser, only while carview's enabled file exists
|
||||
PythonProcess("carviewd", "openpilot.sunnypilot.system.carview.carviewd", carview),
|
||||
]
|
||||
|
||||
if os.path.exists("../../sunnypilot/sunnylink/uploader.py"):
|
||||
|
||||
Reference in New Issue
Block a user