mirror of
https://github.com/a-b-street/abstreet.git
synced 2024-12-28 08:53:26 +03:00
Render A/B Street's lanes and traffic simulation on top of Mapbox GL (#788)
[rebuild] [release]
This commit is contained in:
parent
04b54b08cd
commit
00df96f173
18
Cargo.lock
generated
18
Cargo.lock
generated
@ -2889,6 +2889,24 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8bcd96cb740d03149cbad5518db9fd87126a10ab519c011893b1754134c468"
|
||||
|
||||
[[package]]
|
||||
name = "piggyback"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"abstio",
|
||||
"abstutil",
|
||||
"geom",
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"log",
|
||||
"map_gui",
|
||||
"map_model",
|
||||
"sim",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"widgetry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.6"
|
||||
|
@ -17,6 +17,7 @@ members = [
|
||||
"map_model",
|
||||
"osm_viewer",
|
||||
"parking_mapper",
|
||||
"piggyback",
|
||||
"popdat",
|
||||
"santa",
|
||||
"sim",
|
||||
|
@ -163,7 +163,13 @@ impl App {
|
||||
}
|
||||
|
||||
let mut cache = self.primary.agents.borrow_mut();
|
||||
cache.draw_unzoomed_agents(g, self);
|
||||
cache.draw_unzoomed_agents(
|
||||
g,
|
||||
&self.primary.map,
|
||||
&self.primary.sim,
|
||||
&self.cs,
|
||||
&self.opts,
|
||||
);
|
||||
|
||||
if let Some(a) = self
|
||||
.primary
|
||||
@ -419,10 +425,8 @@ impl App {
|
||||
borrows.extend(bus_stops);
|
||||
|
||||
// Expand all of the Traversables into agents, populating the cache if needed.
|
||||
{
|
||||
for on in &agents_on {
|
||||
agents.populate_if_needed(*on, map, &self.primary.sim, &self.cs, prerender);
|
||||
}
|
||||
for on in &agents_on {
|
||||
agents.populate_if_needed(*on, map, &self.primary.sim, &self.cs, prerender);
|
||||
}
|
||||
|
||||
for on in agents_on {
|
||||
@ -666,7 +670,7 @@ impl PerMap {
|
||||
map,
|
||||
draw_map,
|
||||
sim,
|
||||
agents: RefCell::new(AgentCache::new_state()),
|
||||
agents: RefCell::new(AgentCache::new()),
|
||||
current_selection: None,
|
||||
current_flags: flags,
|
||||
last_warped_from: None,
|
||||
|
@ -26,7 +26,7 @@ impl MinimapControls<App> for MinimapController {
|
||||
}
|
||||
|
||||
let mut cache = app.primary.agents.borrow_mut();
|
||||
cache.draw_unzoomed_agents(g, app);
|
||||
cache.draw_unzoomed_agents(g, &app.primary.map, &app.primary.sim, &app.cs, &app.opts);
|
||||
}
|
||||
|
||||
fn make_unzoomed_panel(&self, ctx: &mut EventCtx, app: &App) -> Panel {
|
||||
|
@ -647,7 +647,7 @@ fn mouseover_unzoomed_agent_circle(ctx: &mut EventCtx, app: &mut App) {
|
||||
.primary
|
||||
.agents
|
||||
.borrow_mut()
|
||||
.calculate_unzoomed_agents(ctx, app)
|
||||
.calculate_unzoomed_agents(ctx, &app.primary.map, &app.primary.sim, &app.cs)
|
||||
.query(
|
||||
Circle::new(cursor, Distance::meters(3.0))
|
||||
.get_bounds()
|
||||
|
@ -52,6 +52,11 @@ impl Duration {
|
||||
Duration::seconds(mins * 60.0)
|
||||
}
|
||||
|
||||
/// Creates a duration in milliseconds.
|
||||
pub fn milliseconds(value: f64) -> Duration {
|
||||
Duration::seconds(value / 1000.0)
|
||||
}
|
||||
|
||||
pub const fn const_seconds(value: f64) -> Duration {
|
||||
Duration(value)
|
||||
}
|
||||
|
@ -12,10 +12,10 @@ use sim::{AgentID, Sim, UnzoomedAgent, VehicleType};
|
||||
use widgetry::{Color, Drawable, GeomBatch, GfxCtx, Panel, Prerender};
|
||||
|
||||
use crate::colors::ColorScheme;
|
||||
use crate::options::Options;
|
||||
use crate::render::{
|
||||
draw_vehicle, unzoomed_agent_radius, DrawPedCrowd, DrawPedestrian, Renderable,
|
||||
};
|
||||
use crate::AppLike;
|
||||
|
||||
pub struct AgentCache {
|
||||
/// This is controlled almost entirely by the minimap panel. It has no meaning in edit mode.
|
||||
@ -30,7 +30,7 @@ pub struct AgentCache {
|
||||
}
|
||||
|
||||
impl AgentCache {
|
||||
pub fn new_state() -> AgentCache {
|
||||
pub fn new() -> AgentCache {
|
||||
AgentCache {
|
||||
unzoomed_agents: UnzoomedAgents::new(),
|
||||
time: None,
|
||||
@ -87,9 +87,11 @@ impl AgentCache {
|
||||
pub fn calculate_unzoomed_agents<P: AsRef<Prerender>>(
|
||||
&mut self,
|
||||
prerender: &mut P,
|
||||
app: &dyn AppLike,
|
||||
map: &Map,
|
||||
sim: &Sim,
|
||||
cs: &ColorScheme,
|
||||
) -> &QuadTree<AgentID> {
|
||||
let now = app.sim().time();
|
||||
let now = sim.time();
|
||||
let mut recalc = true;
|
||||
if let Some((time, ref orig_agents, _, _)) = self.unzoomed {
|
||||
if now == time && self.unzoomed_agents == orig_agents.clone() {
|
||||
@ -98,10 +100,10 @@ impl AgentCache {
|
||||
}
|
||||
|
||||
if recalc {
|
||||
let highlighted = app.sim().get_highlighted_people();
|
||||
let highlighted = sim.get_highlighted_people();
|
||||
|
||||
let mut batch = GeomBatch::new();
|
||||
let mut quadtree = QuadTree::default(app.map().get_bounds().as_bbox());
|
||||
let mut quadtree = QuadTree::default(map.get_bounds().as_bbox());
|
||||
// It's quite silly to produce triangles for the same circle over and over again. ;)
|
||||
let car_circle = Circle::new(
|
||||
Pt2D::new(0.0, 0.0),
|
||||
@ -111,8 +113,8 @@ impl AgentCache {
|
||||
let ped_circle =
|
||||
Circle::new(Pt2D::new(0.0, 0.0), unzoomed_agent_radius(None)).to_polygon();
|
||||
|
||||
for agent in app.sim().get_unzoomed_agents(app.map()) {
|
||||
if let Some(mut color) = self.unzoomed_agents.color(&agent, app.cs()) {
|
||||
for agent in sim.get_unzoomed_agents(map) {
|
||||
if let Some(mut color) = self.unzoomed_agents.color(&agent, cs) {
|
||||
// If the sim has highlighted people, then fade all others out.
|
||||
if highlighted
|
||||
.as_ref()
|
||||
@ -141,19 +143,26 @@ impl AgentCache {
|
||||
&self.unzoomed.as_ref().unwrap().2
|
||||
}
|
||||
|
||||
pub fn draw_unzoomed_agents(&mut self, g: &mut GfxCtx, app: &dyn AppLike) {
|
||||
self.calculate_unzoomed_agents(g, app);
|
||||
pub fn draw_unzoomed_agents(
|
||||
&mut self,
|
||||
g: &mut GfxCtx,
|
||||
map: &Map,
|
||||
sim: &Sim,
|
||||
cs: &ColorScheme,
|
||||
opts: &Options,
|
||||
) {
|
||||
self.calculate_unzoomed_agents(g, map, sim, cs);
|
||||
g.redraw(&self.unzoomed.as_ref().unwrap().3);
|
||||
|
||||
if app.opts().debug_all_agents {
|
||||
if opts.debug_all_agents {
|
||||
let mut cnt = 0;
|
||||
for input in app.sim().get_all_draw_cars(app.map()) {
|
||||
for input in sim.get_all_draw_cars(map) {
|
||||
cnt += 1;
|
||||
draw_vehicle(input, app.map(), app.sim(), g.prerender, app.cs());
|
||||
draw_vehicle(input, map, sim, g.prerender, cs);
|
||||
}
|
||||
println!(
|
||||
"At {}, debugged {} cars",
|
||||
app.sim().time(),
|
||||
sim.time(),
|
||||
abstutil::prettyprint_usize(cnt)
|
||||
);
|
||||
// Pedestrians aren't the ones crashing
|
||||
|
@ -252,7 +252,7 @@ impl State<App> for Viewer {
|
||||
// get_obj must succeed, because we can only click static map elements.
|
||||
let outline = app
|
||||
.draw_map
|
||||
.get_obj(ctx, id, app, &mut map_gui::render::AgentCache::new_state())
|
||||
.get_obj(ctx, id, app, &mut map_gui::render::AgentCache::new())
|
||||
.unwrap()
|
||||
.get_outline(&app.map);
|
||||
let mut batch = GeomBatch::from(vec![(app.cs.perma_selected_object, outline)]);
|
||||
|
25
piggyback/Cargo.toml
Normal file
25
piggyback/Cargo.toml
Normal file
@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "piggyback"
|
||||
version = "0.1.0"
|
||||
authors = ["Dustin Carlino <dabreegster@gmail.com>"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "lib"]
|
||||
|
||||
[features]
|
||||
wasm = ["getrandom/js", "js-sys", "map_gui/wasm", "wasm-bindgen", "web-sys", "widgetry/wasm-backend"]
|
||||
|
||||
[dependencies]
|
||||
abstio = { path = "../abstio" }
|
||||
abstutil = { path = "../abstutil" }
|
||||
geom = { path = "../geom" }
|
||||
getrandom = { version = "0.2.3", optional = true }
|
||||
js-sys = { version = "0.3.51", optional = true }
|
||||
log = "0.4.14"
|
||||
map_gui= { path = "../map_gui" }
|
||||
map_model = { path = "../map_model" }
|
||||
sim = { path = "../sim" }
|
||||
wasm-bindgen = { version = "0.2.70", optional = true }
|
||||
web-sys = { version = "0.3.47", optional = true }
|
||||
widgetry = { path = "../widgetry" }
|
43
piggyback/README.md
Normal file
43
piggyback/README.md
Normal file
@ -0,0 +1,43 @@
|
||||
# A/B Street + Mapbox demo
|
||||
|
||||
This is an example of integrating parts of A/B Street with Mapbox GL. It's a
|
||||
normal web app using Mapbox, but it includes a layer rendering streets and
|
||||
moving agents from A/B Street.
|
||||
|
||||
The goal is to increase interoperability and meet developers where they're at.
|
||||
Parts of the A/B Street code-base are intended as
|
||||
[a platform](https://a-b-street.github.io/docs/tech/map/platform.html) to build
|
||||
other transportation-related things, using unique features like the detailed
|
||||
street rendering. But Rust and our unusual UI library are a huge barrier.
|
||||
Treating A/B Street as a layer that can be added to Mapbox and as a library with
|
||||
a simple API for controlling a traffic simulation should be an easier start.
|
||||
|
||||
Another goal is to take advantage of all the great stuff that exists in the web
|
||||
ecosystem. Instead of implementing satellite layers, multi-line text entry
|
||||
(seriously!), and story mapping ourselves, we can just use stuff that's built
|
||||
aready.
|
||||
|
||||
## How to run
|
||||
|
||||
You'll need `wasm-pack` and `python3` setup. You'll also need the `data/system/`
|
||||
directory to contain some maps.
|
||||
|
||||
Quick development:
|
||||
`wasm-pack build --dev --target web -- --features wasm && ./serve_locally.py`
|
||||
|
||||
To build the WASM in release mode:
|
||||
`wasm-pack build --release --target web -- --features wasm && ./serve_locally.py`
|
||||
|
||||
Maps can be specified by URL:
|
||||
|
||||
- http://localhost:8000/?map=/data/system/us/seattle/maps/arboretum.bin
|
||||
|
||||
No deployment instructions yet.
|
||||
|
||||
## How it works
|
||||
|
||||
The `PiggybackDemo` struct is a thin layer written in Rust to hook up to the
|
||||
rest of the A/B Street codebase. After beng initialized with a WebGL context and
|
||||
a map file, it can render streets and agents and control a traffic simulation.
|
||||
It serves as a public API, exposed via WASM. Then a regular Mapbox GL app treats
|
||||
it as a library and adds a custom WebGL rendering layer that calls this API.
|
22
piggyback/bundle_static_files.sh
Executable file
22
piggyback/bundle_static_files.sh
Executable file
@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# This creates a .zip with all of the files needed to serve a copy of the Mapbox demo.
|
||||
|
||||
set -x
|
||||
set -e
|
||||
|
||||
wasm-pack build --release --target web -- --features wasm
|
||||
|
||||
mkdir mapbox_demo
|
||||
cp -Rv index.html serve_locally.py pkg mapbox_demo
|
||||
|
||||
mkdir -p mapbox_demo/data/system/us/seattle/maps
|
||||
mkdir -p mapbox_demo/data/system/de/berlin/maps
|
||||
# Just include a few maps
|
||||
cp ../data/system/us/seattle/maps/montlake.bin mapbox_demo/data/system/us/seattle/maps
|
||||
cp ../data/system/de/berlin/maps/neukolln.bin mapbox_demo/data/system/de/berlin/maps
|
||||
|
||||
# Uncomment with caution!
|
||||
# Note this embeds a tiny slice of the data/ directory underneath mapbox_demo.
|
||||
# The S3 bucket has gzipped map files, but the JS / Rust layers don't handle
|
||||
# reading both yet.
|
||||
#aws s3 sync mapbox_demo s3://abstreet/dev/mapbox_demo
|
1
piggyback/data
Symbolic link
1
piggyback/data
Symbolic link
@ -0,0 +1 @@
|
||||
../data/
|
180
piggyback/index.html
Normal file
180
piggyback/index.html
Normal file
@ -0,0 +1,180 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>MVP</title>
|
||||
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v2.5.1/mapbox-gl.css" rel="stylesheet">
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v2.5.1/mapbox-gl.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0px;
|
||||
border: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
#map {
|
||||
/* TODO Fill remaining space. Can't get flexbox working... */
|
||||
height: 800px;
|
||||
width: 90%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<input type="checkbox" id="show_roads" checked />
|
||||
<label for="show_roads">Show A/B Street roads</label>
|
||||
<span id="traffic_controls"></span>
|
||||
<div id="map"></div>
|
||||
<script type="module">
|
||||
import init, { PiggybackDemo } from './pkg/piggyback.js';
|
||||
|
||||
async function setup() {
|
||||
// Initialize the WASM library.
|
||||
await init();
|
||||
|
||||
// What map should we load?
|
||||
var loadPath = new URL(window.location).searchParams.get('map');
|
||||
if (loadPath == null) {
|
||||
loadPath = 'data/system/us/seattle/maps/montlake.bin';
|
||||
}
|
||||
|
||||
// TODO Ideally we'd load this in the background and let Mapbox run first, but I'm not sure how to make the map.on('load') callback async.
|
||||
console.log(`Fetching ${loadPath}`);
|
||||
const resp = await fetch(loadPath);
|
||||
const mapBytes = await resp.arrayBuffer();
|
||||
|
||||
// TODO I definitely copied this from example code somewhere. Generate my own and restrict its use once we decide how to deploy this demo.
|
||||
mapboxgl.accessToken = 'pk.eyJ1IjoibWFwYm94IiwiYSI6ImNpejY4NXVycTA2emYycXBndHRqcmZ3N3gifQ.rJcFIG214AriISLbB6B5aw';
|
||||
|
||||
// Create the Mapbox map
|
||||
const map = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: 'mapbox://styles/mapbox/satellite-streets-v11',
|
||||
center: [-122.3037, 47.6427],
|
||||
zoom: 16,
|
||||
antialias: true,
|
||||
// TODO This appears to duplicate the parameter as soon as we move...
|
||||
//hash: `map=${loadPath}`
|
||||
hash: true
|
||||
});
|
||||
|
||||
var piggyback = null;
|
||||
|
||||
const abstLayer = {
|
||||
id: 'abst',
|
||||
type: 'custom',
|
||||
onAdd: function (map, gl) {
|
||||
piggyback = PiggybackDemo.create_with_map_bytes(gl, mapBytes);
|
||||
// TODO If the URL didn't specify an initial location, warp to the center of this map?
|
||||
sync_canvas();
|
||||
},
|
||||
render: function (gl, matrix) {
|
||||
if (piggyback == null) {
|
||||
return;
|
||||
}
|
||||
if (map.getZoom() >= 16) {
|
||||
piggyback.draw_zoomed(document.getElementById('show_roads').checked);
|
||||
} else {
|
||||
piggyback.draw_unzoomed();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
map.on('load', () => {
|
||||
map.addLayer(abstLayer);
|
||||
});
|
||||
map.on('move', sync_canvas);
|
||||
map.on('click', (e) => {
|
||||
const debug = piggyback.debug_object_at(e.lngLat.lng, e.lngLat.lat);
|
||||
if (debug != null) {
|
||||
new mapboxgl.Popup()
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(`<pre style="overflow-x: scroll; overflow-y: scroll; max-width: 240px; max-height: 300px;">${debug}</pre>`)
|
||||
.addTo(map);
|
||||
}
|
||||
});
|
||||
// We don't have map until here, so set up the handler now
|
||||
document.getElementById('show_roads').onclick = function () {
|
||||
map.triggerRepaint();
|
||||
}
|
||||
|
||||
function sync_canvas() {
|
||||
// If things broke during initialization, don't spam the console trying to call methods on a null object
|
||||
if (piggyback != null) {
|
||||
const bounds = map.getBounds();
|
||||
const ne = bounds.getNorthEast();
|
||||
const sw = bounds.getSouthWest();
|
||||
piggyback.move_canvas(ne.lng, ne.lat, sw.lng, sw.lat);
|
||||
}
|
||||
}
|
||||
|
||||
function traffic_controls_inactive() {
|
||||
const span = document.getElementById('traffic_controls');
|
||||
span.innerHTML = `
|
||||
<button type="button" onclick="start_traffic_sim()">Start traffic simulation</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function traffic_controls_active() {
|
||||
const span = document.getElementById('traffic_controls');
|
||||
span.innerHTML = `
|
||||
<button type="button" onclick="pause_or_resume()">Pause / resume</button>
|
||||
<button type="button" onclick="clear_traffic_sim()">Clear simulation</button>
|
||||
`;
|
||||
}
|
||||
|
||||
var lastTime = performance.now();
|
||||
var animationRequest = null;
|
||||
|
||||
function start_traffic_sim() {
|
||||
traffic_controls_active();
|
||||
lastTime = performance.now();
|
||||
animationRequest = requestAnimationFrame(runSimulation);
|
||||
piggyback.spawn_traffic();
|
||||
}
|
||||
|
||||
function pause_or_resume() {
|
||||
if (animationRequest == null) {
|
||||
lastTime = performance.now();
|
||||
animationRequest = requestAnimationFrame(runSimulation);
|
||||
} else {
|
||||
cancelAnimationFrame(animationRequest);
|
||||
animationRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clear_traffic_sim() {
|
||||
traffic_controls_inactive();
|
||||
cancelAnimationFrame(animationRequest);
|
||||
animationRequest = null;
|
||||
piggyback.clear_traffic();
|
||||
map.triggerRepaint();
|
||||
}
|
||||
|
||||
function runSimulation(timestamp) {
|
||||
const now = performance.now();
|
||||
const dt = now - lastTime;
|
||||
// This is called over 60 times per second or so! Throttle to about 10fps
|
||||
if (dt >= 100) {
|
||||
lastTime = now;
|
||||
piggyback.advance_sim_time(dt);
|
||||
map.triggerRepaint();
|
||||
}
|
||||
animationRequest = requestAnimationFrame(runSimulation);
|
||||
}
|
||||
|
||||
traffic_controls_inactive();
|
||||
|
||||
// Let the onclick handlers reach into this scope. Alternatively, grab the buttons here and set onclick handlers.
|
||||
window.start_traffic_sim = start_traffic_sim;
|
||||
window.pause_or_resume = pause_or_resume;
|
||||
window.clear_traffic_sim = clear_traffic_sim;
|
||||
}
|
||||
|
||||
setup();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
14
piggyback/serve_locally.py
Executable file
14
piggyback/serve_locally.py
Executable file
@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
# This serves the current directory over HTTP. We need something more than
|
||||
# `python3 -m http.server 8000` to inject the CORS header.
|
||||
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler, test
|
||||
import sys
|
||||
|
||||
class CORSRequestHandler (SimpleHTTPRequestHandler):
|
||||
def end_headers (self):
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
SimpleHTTPRequestHandler.end_headers(self)
|
||||
|
||||
if __name__ == '__main__':
|
||||
test(CORSRequestHandler, HTTPServer, port=int(sys.argv[1]) if len(sys.argv) > 1 else 8000)
|
13
piggyback/src/lib.rs
Normal file
13
piggyback/src/lib.rs
Normal file
@ -0,0 +1,13 @@
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod piggyback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use piggyback::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn dummy() {
|
||||
info!("Just avoiding an unused warning");
|
||||
}
|
236
piggyback/src/piggyback.rs
Normal file
236
piggyback/src/piggyback.rs
Normal file
@ -0,0 +1,236 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use abstutil::{prettyprint_usize, Timer};
|
||||
use geom::{Circle, Distance, Duration, LonLat, Pt2D, Time};
|
||||
use map_gui::colors::ColorScheme;
|
||||
use map_gui::options::Options;
|
||||
use map_gui::render::{AgentCache, DrawMap, DrawOptions};
|
||||
use map_gui::{AppLike, ID};
|
||||
use map_model::{Map, Traversable};
|
||||
use sim::Sim;
|
||||
use widgetry::{EventCtx, GfxCtx, RenderOnly, Settings, State};
|
||||
|
||||
/// This allows part of A/B Street to "piggyback" onto a WebGL canvas managed by something else,
|
||||
/// such as Mapbox GL.
|
||||
#[wasm_bindgen]
|
||||
pub struct PiggybackDemo {
|
||||
render_only: RenderOnly,
|
||||
map: Map,
|
||||
sim: Sim,
|
||||
draw_map: DrawMap,
|
||||
agents: AgentCache,
|
||||
cs: ColorScheme,
|
||||
options: Options,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
impl PiggybackDemo {
|
||||
/// Initializes the piggyback mode with a WebGL context and raw bytes representing a map file
|
||||
/// to manage. (The map file shouldn't be gzipped.)
|
||||
pub fn create_with_map_bytes(
|
||||
gl: web_sys::WebGlRenderingContext,
|
||||
map_bytes: js_sys::ArrayBuffer,
|
||||
) -> PiggybackDemo {
|
||||
abstutil::logger::setup();
|
||||
|
||||
let mut render_only = RenderOnly::new(
|
||||
gl,
|
||||
Settings::new("Piggyback demo").read_svg(Box::new(abstio::slurp_bytes)),
|
||||
);
|
||||
|
||||
let mut timer = Timer::new("loading map");
|
||||
let array = js_sys::Uint8Array::new(&map_bytes);
|
||||
info!(
|
||||
"Parsing {} map bytes",
|
||||
prettyprint_usize(map_bytes.byte_length() as usize)
|
||||
);
|
||||
let mut map: Map = abstutil::from_binary(&array.to_vec()).unwrap();
|
||||
map.map_loaded_directly(&mut timer);
|
||||
info!("Loaded {:?}", map.get_name());
|
||||
|
||||
let sim = Sim::new(&map, sim::SimOptions::default());
|
||||
|
||||
let mut ctx = render_only.event_ctx();
|
||||
let cs = ColorScheme::new(&mut ctx, map_gui::colors::ColorSchemeChoice::DayMode);
|
||||
let options = map_gui::options::Options::load_or_default();
|
||||
info!("Creating draw map");
|
||||
let draw_map = DrawMap::new(&mut ctx, &map, &options, &cs, &mut timer);
|
||||
|
||||
PiggybackDemo {
|
||||
render_only,
|
||||
map,
|
||||
sim,
|
||||
draw_map,
|
||||
agents: AgentCache::new(),
|
||||
cs,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the camera to match a northeast and southwest corner, given by lon/lat.
|
||||
pub fn move_canvas(&mut self, ne_lon: f64, ne_lat: f64, sw_lon: f64, sw_lat: f64) {
|
||||
let gps_bounds = self.map.get_gps_bounds();
|
||||
let top_left = LonLat::new(ne_lon, ne_lat).to_pt(gps_bounds);
|
||||
let bottom_right = LonLat::new(sw_lon, sw_lat).to_pt(gps_bounds);
|
||||
let center =
|
||||
LonLat::new((ne_lon + sw_lon) / 2.0, (ne_lat + sw_lat) / 2.0).to_pt(gps_bounds);
|
||||
|
||||
let mut ctx = self.render_only.event_ctx();
|
||||
// This is quite a strange way of calculating zoom, but it works
|
||||
let want_diagonal_dist = top_left.dist_to(bottom_right);
|
||||
let b = ctx.canvas.get_screen_bounds();
|
||||
let current_diagonal_dist =
|
||||
Pt2D::new(b.min_x, b.min_y).dist_to(Pt2D::new(b.max_x, b.max_y));
|
||||
// We can do this calculation before changing the center, because we're working in mercator
|
||||
// already; distances shouldn't change based on where we are.
|
||||
|
||||
ctx.canvas.cam_zoom *= current_diagonal_dist / want_diagonal_dist;
|
||||
ctx.canvas.center_on_map_pt(center);
|
||||
}
|
||||
|
||||
/// Advances the traffic simulation.
|
||||
pub fn advance_sim_time(&mut self, delta_milliseconds: f64) {
|
||||
let dt = Duration::milliseconds(delta_milliseconds);
|
||||
// Use the real time passed as the deadline
|
||||
self.sim.time_limited_step(&self.map, dt, dt, &mut None);
|
||||
}
|
||||
|
||||
/// Spawn random, unrealistic traffic.
|
||||
pub fn spawn_traffic(&mut self) {
|
||||
let mut rng = sim::SimFlags::for_test("spawn_traffic").make_rng();
|
||||
let mut timer = Timer::new("spawn traffic");
|
||||
sim::ScenarioGenerator::small_run(&self.map)
|
||||
.generate(&self.map, &mut rng, &mut timer)
|
||||
.instantiate(&mut self.sim, &self.map, &mut rng, &mut timer);
|
||||
}
|
||||
|
||||
/// Reset the traffic simulation.
|
||||
pub fn clear_traffic(&mut self) {
|
||||
self.sim = Sim::new(&self.map, sim::SimOptions::default());
|
||||
}
|
||||
|
||||
/// Draw the zoomed-in view. If `show_roads` is true, render roads and intersections in detail.
|
||||
/// Always draw agents in their zoomed-in view. Don't draw anything else -- it's assumed that
|
||||
/// the web app otherwise renders areas, buildings, and such already.
|
||||
// Note this is &mut to conveniently work with AgentCache. Other code uses RefCell.
|
||||
pub fn draw_zoomed(&mut self, show_roads: bool) {
|
||||
// Short-circuit if there's nothing to do
|
||||
if !show_roads && self.sim.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let g = &mut self.render_only.gfx_ctx();
|
||||
|
||||
let objects = self
|
||||
.draw_map
|
||||
.get_renderables_back_to_front(g.get_screen_bounds(), &self.map);
|
||||
|
||||
let opts = DrawOptions::new();
|
||||
// As we draw the static map elements, track where we need to draw live agents.
|
||||
let mut agents_on = Vec::new();
|
||||
for obj in objects {
|
||||
if show_roads {
|
||||
if let ID::Lane(_) | ID::Intersection(_) | ID::Road(_) = obj.get_id() {
|
||||
obj.draw(g, self, &opts);
|
||||
}
|
||||
}
|
||||
|
||||
if let ID::Lane(l) = obj.get_id() {
|
||||
agents_on.push(Traversable::Lane(l));
|
||||
}
|
||||
if let ID::Intersection(i) = obj.get_id() {
|
||||
for t in &self.map.get_i(i).turns {
|
||||
agents_on.push(Traversable::Turn(t.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for on in agents_on {
|
||||
self.agents
|
||||
.populate_if_needed(on, &self.map, &self.sim, &self.cs, g.prerender);
|
||||
for obj in self.agents.get(on) {
|
||||
obj.draw(g, self, &opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw unzoomed agents.
|
||||
pub fn draw_unzoomed(&mut self) {
|
||||
if self.sim.is_empty() {
|
||||
return;
|
||||
}
|
||||
let g = &mut self.render_only.gfx_ctx();
|
||||
self.agents
|
||||
.draw_unzoomed_agents(g, &self.map, &self.sim, &self.cs, &self.options);
|
||||
}
|
||||
|
||||
/// If there's a road, intersection, or area at the specififed coordinates, return a JSON
|
||||
/// string with debug info.
|
||||
pub fn debug_object_at(&self, lon: f64, lat: f64) -> Option<String> {
|
||||
let pt = LonLat::new(lon, lat).to_pt(self.map.get_gps_bounds());
|
||||
let mut objects = self.draw_map.get_renderables_back_to_front(
|
||||
Circle::new(pt, Distance::meters(3.0)).get_bounds(),
|
||||
&self.map,
|
||||
);
|
||||
objects.reverse();
|
||||
for obj in objects {
|
||||
if obj.contains_pt(pt, &self.map) {
|
||||
let json = match obj.get_id() {
|
||||
ID::Road(r) => abstutil::to_json(self.map.get_r(r)),
|
||||
ID::Intersection(i) => abstutil::to_json(self.map.get_i(i)),
|
||||
ID::Area(a) => abstutil::to_json(self.map.get_a(a)),
|
||||
_ => continue,
|
||||
};
|
||||
return Some(json);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Drawing some of the objects requires this interface
|
||||
impl AppLike for PiggybackDemo {
|
||||
fn map(&self) -> &Map {
|
||||
&self.map
|
||||
}
|
||||
fn sim(&self) -> &Sim {
|
||||
&self.sim
|
||||
}
|
||||
fn cs(&self) -> &ColorScheme {
|
||||
&self.cs
|
||||
}
|
||||
fn mut_cs(&mut self) -> &mut ColorScheme {
|
||||
&mut self.cs
|
||||
}
|
||||
fn draw_map(&self) -> &DrawMap {
|
||||
&self.draw_map
|
||||
}
|
||||
fn mut_draw_map(&mut self) -> &mut DrawMap {
|
||||
&mut self.draw_map
|
||||
}
|
||||
fn opts(&self) -> &Options {
|
||||
&self.options
|
||||
}
|
||||
fn mut_opts(&mut self) -> &mut Options {
|
||||
&mut self.options
|
||||
}
|
||||
fn map_switched(&mut self, _: &mut EventCtx, _: map_model::Map, _: &mut abstutil::Timer) {
|
||||
unreachable!()
|
||||
}
|
||||
fn draw_with_opts(&self, _: &mut GfxCtx, _: map_gui::render::DrawOptions) {
|
||||
unreachable!()
|
||||
}
|
||||
fn make_warper(
|
||||
&mut self,
|
||||
_: &EventCtx,
|
||||
_: Pt2D,
|
||||
_: Option<f64>,
|
||||
_: Option<map_gui::ID>,
|
||||
) -> Box<dyn State<PiggybackDemo>> {
|
||||
unreachable!()
|
||||
}
|
||||
fn sim_time(&self) -> Time {
|
||||
self.sim.time()
|
||||
}
|
||||
}
|
@ -260,7 +260,7 @@ type WindowAdapter = crate::backend_glow_native::WindowAdapter;
|
||||
|
||||
pub struct PrerenderInnards {
|
||||
gl: Rc<glow::Context>,
|
||||
window_adapter: WindowAdapter,
|
||||
window_adapter: Option<WindowAdapter>,
|
||||
program: <glow::Context as glow::HasContext>::Program,
|
||||
|
||||
// TODO Prerender doesn't know what things are temporary and permanent. Could make the API more
|
||||
@ -272,7 +272,7 @@ impl PrerenderInnards {
|
||||
pub fn new(
|
||||
gl: glow::Context,
|
||||
program: <glow::Context as glow::HasContext>::Program,
|
||||
window_adapter: WindowAdapter,
|
||||
window_adapter: Option<WindowAdapter>,
|
||||
) -> PrerenderInnards {
|
||||
PrerenderInnards {
|
||||
gl: Rc::new(gl),
|
||||
@ -378,7 +378,7 @@ impl PrerenderInnards {
|
||||
}
|
||||
|
||||
pub(crate) fn window(&self) -> &winit::window::Window {
|
||||
self.window_adapter.window()
|
||||
self.window_adapter.as_ref().expect("no window").window()
|
||||
}
|
||||
|
||||
pub fn request_redraw(&self) {
|
||||
@ -399,7 +399,10 @@ impl PrerenderInnards {
|
||||
|
||||
pub fn window_resized(&self, new_size: ScreenDims, scale_factor: f64) {
|
||||
let physical_size = winit::dpi::LogicalSize::from(new_size).to_physical(scale_factor);
|
||||
self.window_adapter.window_resized(new_size, scale_factor);
|
||||
self.window_adapter
|
||||
.as_ref()
|
||||
.expect("no window")
|
||||
.window_resized(new_size, scale_factor);
|
||||
unsafe {
|
||||
self.gl
|
||||
.viewport(0, 0, physical_size.width, physical_size.height);
|
||||
@ -421,8 +424,11 @@ impl PrerenderInnards {
|
||||
self.window().scale_factor()
|
||||
}
|
||||
|
||||
pub fn draw_finished(&self, gfc_ctx_innards: GfxCtxInnards) {
|
||||
self.window_adapter.draw_finished(gfc_ctx_innards)
|
||||
pub fn draw_finished(&self, gfx_ctx_innards: GfxCtxInnards) {
|
||||
self.window_adapter
|
||||
.as_ref()
|
||||
.expect("no window")
|
||||
.draw_finished(gfx_ctx_innards)
|
||||
}
|
||||
|
||||
pub(crate) fn screencap(&self, dims: ScreenDims, filename: String) -> anyhow::Result<()> {
|
||||
@ -455,6 +461,13 @@ impl PrerenderInnards {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn use_program_for_renderonly(&self) {
|
||||
unsafe {
|
||||
self.gl.use_program(Some(self.program));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads a sprite sheet of textures to the GPU so they can be used by Fill::Texture and
|
||||
|
@ -73,7 +73,7 @@ pub fn setup(
|
||||
timer.stop("load textures");
|
||||
|
||||
(
|
||||
PrerenderInnards::new(gl, program, WindowAdapter(windowed_context)),
|
||||
PrerenderInnards::new(gl, program, Some(WindowAdapter(windowed_context))),
|
||||
event_loop,
|
||||
)
|
||||
}
|
||||
|
@ -1,11 +1,15 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Result;
|
||||
use wasm_bindgen::JsCast;
|
||||
use winit::platform::web::WindowExtWebSys;
|
||||
|
||||
use abstutil::Timer;
|
||||
|
||||
use crate::assets::Assets;
|
||||
use crate::backend_glow::{build_program, GfxCtxInnards, PrerenderInnards, SpriteTexture};
|
||||
use crate::{ScreenDims, Settings};
|
||||
use crate::{Canvas, Event, EventCtx, GfxCtx, Prerender, ScreenDims, Settings, Style, UserInput};
|
||||
|
||||
pub fn setup(
|
||||
settings: &Settings,
|
||||
@ -63,22 +67,18 @@ pub fn setup(
|
||||
// First try WebGL 2.0 context.
|
||||
// WebGL 2.0 isn't supported by default on macOS Safari, or any iOS browser (which are all just
|
||||
// Safari wrappers).
|
||||
let (program, gl) = webgl2_program_context(&canvas, timer)
|
||||
let (gl, program) = webgl2_glow_context(&canvas)
|
||||
.and_then(|gl| webgl2_program(gl, timer))
|
||||
.or_else(|err| {
|
||||
warn!(
|
||||
"failed to build WebGL 2.0 context with error: \"{}\". Trying WebGL 1.0 instead...",
|
||||
err
|
||||
);
|
||||
webgl1_program_context(&canvas, timer)
|
||||
webgl1_glow_context(&canvas).and_then(|gl| webgl1_program(gl, timer))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
debug!("built WebGL context");
|
||||
|
||||
fn webgl2_program_context(
|
||||
canvas: &web_sys::HtmlCanvasElement,
|
||||
timer: &mut Timer,
|
||||
) -> anyhow::Result<(glow::Program, glow::Context)> {
|
||||
fn webgl2_glow_context(canvas: &web_sys::HtmlCanvasElement) -> Result<glow::Context> {
|
||||
let maybe_context: Option<_> = canvas
|
||||
.get_context("webgl2")
|
||||
.map_err(|err| anyhow!("error getting context for WebGL 2.0: {:?}", err))?;
|
||||
@ -87,34 +87,10 @@ pub fn setup(
|
||||
let webgl2_context = js_webgl2_context
|
||||
.dyn_into::<web_sys::WebGl2RenderingContext>()
|
||||
.map_err(|err| anyhow!("unable to cast to WebGl2RenderingContext. error: {:?}", err))?;
|
||||
let gl = glow::Context::from_webgl2_context(webgl2_context);
|
||||
let program = unsafe {
|
||||
build_program(
|
||||
&gl,
|
||||
include_str!("../shaders/vertex_300.glsl"),
|
||||
include_str!("../shaders/fragment_300.glsl"),
|
||||
)?
|
||||
};
|
||||
|
||||
timer.start("load textures");
|
||||
let sprite_texture = SpriteTexture::new(
|
||||
include_bytes!("../textures/spritesheet.png").to_vec(),
|
||||
64,
|
||||
64,
|
||||
)
|
||||
.expect("failed to format texture sprite sheet");
|
||||
sprite_texture
|
||||
.upload_gl2(&gl)
|
||||
.expect("failed to upload textures");
|
||||
timer.stop("load textures");
|
||||
|
||||
Ok((program, gl))
|
||||
Ok(glow::Context::from_webgl2_context(webgl2_context))
|
||||
}
|
||||
|
||||
fn webgl1_program_context(
|
||||
canvas: &web_sys::HtmlCanvasElement,
|
||||
timer: &mut Timer,
|
||||
) -> anyhow::Result<(glow::Program, glow::Context)> {
|
||||
fn webgl1_glow_context(canvas: &web_sys::HtmlCanvasElement) -> Result<glow::Context> {
|
||||
let maybe_context: Option<_> = canvas
|
||||
.get_context("webgl")
|
||||
.map_err(|err| anyhow!("error getting context for WebGL 1.0: {:?}", err))?;
|
||||
@ -123,36 +99,63 @@ pub fn setup(
|
||||
let webgl1_context = js_webgl1_context
|
||||
.dyn_into::<web_sys::WebGlRenderingContext>()
|
||||
.map_err(|err| anyhow!("unable to cast to WebGlRenderingContext. error: {:?}", err))?;
|
||||
let gl = glow::Context::from_webgl1_context(webgl1_context);
|
||||
let program = unsafe {
|
||||
build_program(
|
||||
&gl,
|
||||
include_str!("../shaders/vertex_webgl1.glsl"),
|
||||
include_str!("../shaders/fragment_webgl1.glsl"),
|
||||
)?
|
||||
};
|
||||
|
||||
timer.start("load textures");
|
||||
let sprite_texture = SpriteTexture::new(
|
||||
include_bytes!("../textures/spritesheet.png").to_vec(),
|
||||
64,
|
||||
64,
|
||||
)
|
||||
.expect("failed to format texture sprite sheet");
|
||||
sprite_texture
|
||||
.upload_webgl1(&gl)
|
||||
.expect("failed to upload textures");
|
||||
timer.stop("load textures");
|
||||
|
||||
Ok((program, gl))
|
||||
Ok(glow::Context::from_webgl1_context(webgl1_context))
|
||||
}
|
||||
|
||||
(
|
||||
PrerenderInnards::new(gl, program, WindowAdapter(winit_window)),
|
||||
PrerenderInnards::new(gl, program, Some(WindowAdapter(winit_window))),
|
||||
event_loop,
|
||||
)
|
||||
}
|
||||
|
||||
fn webgl2_program(gl: glow::Context, timer: &mut Timer) -> Result<(glow::Context, glow::Program)> {
|
||||
let program = unsafe {
|
||||
build_program(
|
||||
&gl,
|
||||
include_str!("../shaders/vertex_300.glsl"),
|
||||
include_str!("../shaders/fragment_300.glsl"),
|
||||
)?
|
||||
};
|
||||
|
||||
timer.start("load textures");
|
||||
let sprite_texture = SpriteTexture::new(
|
||||
include_bytes!("../textures/spritesheet.png").to_vec(),
|
||||
64,
|
||||
64,
|
||||
)
|
||||
.expect("failed to format texture sprite sheet");
|
||||
sprite_texture
|
||||
.upload_gl2(&gl)
|
||||
.expect("failed to upload textures");
|
||||
timer.stop("load textures");
|
||||
|
||||
Ok((gl, program))
|
||||
}
|
||||
|
||||
fn webgl1_program(gl: glow::Context, timer: &mut Timer) -> Result<(glow::Context, glow::Program)> {
|
||||
let program = unsafe {
|
||||
build_program(
|
||||
&gl,
|
||||
include_str!("../shaders/vertex_webgl1.glsl"),
|
||||
include_str!("../shaders/fragment_webgl1.glsl"),
|
||||
)?
|
||||
};
|
||||
|
||||
timer.start("load textures");
|
||||
let sprite_texture = SpriteTexture::new(
|
||||
include_bytes!("../textures/spritesheet.png").to_vec(),
|
||||
64,
|
||||
64,
|
||||
)
|
||||
.expect("failed to format texture sprite sheet");
|
||||
sprite_texture
|
||||
.upload_webgl1(&gl)
|
||||
.expect("failed to upload textures");
|
||||
timer.stop("load textures");
|
||||
|
||||
Ok((gl, program))
|
||||
}
|
||||
|
||||
pub struct WindowAdapter(Rc<winit::window::Window>);
|
||||
|
||||
impl WindowAdapter {
|
||||
@ -169,3 +172,73 @@ impl WindowAdapter {
|
||||
|
||||
pub fn draw_finished(&self, _gfc_ctx_innards: GfxCtxInnards) {}
|
||||
}
|
||||
|
||||
/// Sets up widgetry in a mode where it just draws to a WebGL context and doesn't handle events or
|
||||
/// interactions at all.
|
||||
pub struct RenderOnly {
|
||||
prerender: Prerender,
|
||||
style: Style,
|
||||
canvas: Canvas,
|
||||
}
|
||||
|
||||
impl RenderOnly {
|
||||
pub fn new(raw_gl: web_sys::WebGlRenderingContext, settings: Settings) -> RenderOnly {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
error!("Panicked: {}", info);
|
||||
}));
|
||||
|
||||
info!("Setting up widgetry in render-only mode");
|
||||
let mut timer = Timer::new("setup render-only");
|
||||
let initial_size = ScreenDims::new(
|
||||
raw_gl.drawing_buffer_width().into(),
|
||||
raw_gl.drawing_buffer_height().into(),
|
||||
);
|
||||
// Mapbox always seems to hand us WebGL1
|
||||
let (gl, program) =
|
||||
webgl1_program(glow::Context::from_webgl1_context(raw_gl), &mut timer).unwrap();
|
||||
let prerender_innards = PrerenderInnards::new(gl, program, None);
|
||||
|
||||
let style = Style::light_bg();
|
||||
let prerender = Prerender {
|
||||
assets: Assets::new(
|
||||
style.clone(),
|
||||
settings.assets_base_url,
|
||||
settings.assets_are_gzipped,
|
||||
settings.read_svg,
|
||||
),
|
||||
num_uploads: Cell::new(0),
|
||||
inner: prerender_innards,
|
||||
scale_factor: settings.scale_factor.unwrap_or(1.0),
|
||||
};
|
||||
let canvas = Canvas::new(initial_size, settings.canvas_settings);
|
||||
|
||||
RenderOnly {
|
||||
prerender,
|
||||
style,
|
||||
canvas,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a no-op `EventCtx`, just for client code that needs this interface to upload
|
||||
/// geometry. There's no actual event.
|
||||
pub fn event_ctx(&mut self) -> EventCtx {
|
||||
EventCtx {
|
||||
fake_mouseover: true,
|
||||
input: UserInput::new(Event::NoOp, &self.canvas),
|
||||
canvas: &mut self.canvas,
|
||||
prerender: &self.prerender,
|
||||
style: &mut self.style,
|
||||
updates_requested: vec![],
|
||||
canvas_movement_called: false,
|
||||
focus_owned_by: None,
|
||||
next_focus_owned_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `GfxCtx`, allowing things to be drawn.
|
||||
pub fn gfx_ctx(&self) -> GfxCtx {
|
||||
self.prerender.inner.use_program_for_renderonly();
|
||||
let screenshot = false;
|
||||
GfxCtx::new(&self.prerender, &self.canvas, &self.style, screenshot)
|
||||
}
|
||||
}
|
||||
|
@ -107,6 +107,9 @@ mod backend {
|
||||
pub use crate::backend_glow::*;
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm-backend")]
|
||||
pub use crate::backend_glow_wasm::RenderOnly;
|
||||
|
||||
/// Like [`std::include_bytes!`], but also returns its argument, the relative path to the bytes
|
||||
///
|
||||
/// returns a `(path, bytes): (&str, &[u8])` tuple
|
||||
|
@ -196,12 +196,12 @@ pub struct Settings {
|
||||
pub(crate) assets_base_url: Option<String>,
|
||||
pub(crate) assets_are_gzipped: bool,
|
||||
dump_raw_events: bool,
|
||||
scale_factor: Option<f64>,
|
||||
pub(crate) scale_factor: Option<f64>,
|
||||
require_minimum_width: Option<f64>,
|
||||
window_icon: Option<String>,
|
||||
loading_tips: Option<Text>,
|
||||
read_svg: Box<dyn Fn(&str) -> Vec<u8>>,
|
||||
canvas_settings: CanvasSettings,
|
||||
pub(crate) read_svg: Box<dyn Fn(&str) -> Vec<u8>>,
|
||||
pub(crate) canvas_settings: CanvasSettings,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
|
Loading…
Reference in New Issue
Block a user