initial version

This commit is contained in:
Florian Stecker 2024-08-22 22:40:34 -04:00
commit 1bb2302654
9 changed files with 608 additions and 0 deletions

39
Cargo.toml Normal file
View File

@ -0,0 +1,39 @@
[package]
name = "billiards"
version = "0.1.0"
edition = "2021"
[package.metadata.docs.rs]
all-features = true
targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"]
[dependencies]
egui = "0.28"
eframe = { version = "0.28", default-features = false, features = ["default_fonts", "glow"] }
log = "0.4"
serde = "1.0.208"
serde_derive = "1.0.208"
serde_json = "1.0.125"
# You only need serde if you want app persistence:
# serde = { version = "1", features = ["derive"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.10"
rusqlite = "0.32.1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys]
version = "0.3.4"
[profile.release]
opt-level = 2
[profile.dev.package."*"]
opt-level = 2
[build-dependencies]
rusqlite = "0.32.1"
serde_json = "1.0.125"

1
Trunk.toml Normal file
View File

@ -0,0 +1 @@
[build]

48
build.rs Normal file
View File

@ -0,0 +1,48 @@
use std::env;
use std::fs;
use std::path::Path;
fn main() {
let out_dir = env::var_os("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("builtin_tables.rs");
let configs = rusqlite::Connection::open("tables.db")
.unwrap()
.prepare("SELECT * FROM tables")
.unwrap()
.query_map([], |row| Ok((
row.get::<_, String>("name")?,
row.get::<_, String>("outline")?,
row.get::<_, f64>("pos_x")?,
row.get::<_, f64>("pos_y")?,
row.get::<_, f64>("vec_x")?,
row.get::<_, f64>("vec_y")?,
)))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
let mut lines: Vec<String> = Vec::new();
for (name, outline, pos_x, pos_y, vec_x, vec_y) in &configs {
let outline_parsed: Vec<(f64, f64)> =
serde_json::from_str(outline)
.expect("outline field should be a valid [(f64, f64)]");
let outline_str: String = outline_parsed
.into_iter()
.map(|x|format!("({:.15},{:.15}),",x.0,x.1))
.collect();
lines.push(format!(
"BilliardConfiguration {{ name: {:?}.into(), outline: vec![{}], initial_pos: ({:.15}, {:.15}), initial_vec: ({:.15}, {:.15}) }},\n",
name, &outline_str, *pos_x, *pos_y, *vec_x, *vec_y
));
}
fs::write(
&dest_path,
"vec![".to_string() + &lines.iter().map(|x|x.as_str()).collect::<String>() + "]"
).unwrap();
println!("cargo::rerun-if-changed=build.rs");
}

202
src/app.rs Normal file
View File

@ -0,0 +1,202 @@
use egui::{ComboBox, Slider, Context, CentralPanel, Layout, Align};
use crate::table::Table;
#[cfg(not(target_arch = "wasm32"))]
use rusqlite::Connection;
#[cfg(not(target_arch = "wasm32"))]
use std::time::Instant;
use std::sync::LazyLock;
pub struct BilliardConfiguration {
pub name: String,
pub outline: Vec<(f64, f64)>,
pub initial_pos: (f64, f64),
pub initial_vec: (f64, f64),
}
pub static BUILTIN_TABLES: LazyLock<Vec<BilliardConfiguration>> =
LazyLock::new(|| include!(concat!(env!("OUT_DIR"), "/builtin_tables.rs")));
pub struct BilliardsApp {
table: Table,
speed: f64,
stopped: bool,
last_time: f64,
table_index: usize,
requested_table_index: usize,
table_names: Vec<String>,
#[cfg(not(target_arch = "wasm32"))]
db: Connection,
}
impl BilliardsApp {
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let mut result = Self {
table: Table::new(
&BUILTIN_TABLES[0].outline,
BUILTIN_TABLES[0].initial_pos.0,
BUILTIN_TABLES[0].initial_pos.1,
BUILTIN_TABLES[0].initial_vec.0,
BUILTIN_TABLES[0].initial_vec.1,
0.0
),
speed: 1.0,
stopped: false,
last_time: get_elapsed_time(),
table_index: 0,
requested_table_index: 0,
#[cfg(not(target_arch = "wasm32"))]
db: Connection::open("tables.db").unwrap(),
table_names: BUILTIN_TABLES.iter().map(|x|x.name.clone()).collect(),
};
let _ = result.update_table_names();
result.load_table_by_name(&result.table_names[0].clone());
result
}
#[cfg(not(target_arch = "wasm32"))]
fn update_table_names(&mut self) -> rusqlite::Result<()> {
self.table_names = self.db
.prepare("SELECT name FROM tables")?
.query_map([], |row| row.get(0))?
.collect::<Result<_, _>>()?;
Ok(())
}
#[cfg(target_arch = "wasm32")]
fn update_table_names(&mut self) -> Result<(), std::convert::Infallible> {
// do nothing
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn load_table_by_name(&mut self, name: &str) {
let get_from_db = || -> rusqlite::Result<_> { Ok(
self.db
.prepare("SELECT * FROM tables WHERE name = :name")?
.query_row(&[(":name", name)], |row| {
Ok((
row.get::<_, String>("outline")?,
row.get::<_, f64>("pos_x")?,
row.get::<_, f64>("pos_y")?,
row.get::<_, f64>("vec_x")?,
row.get::<_, f64>("vec_y")?
))
})?
)};
let Ok((outline, pos_x, pos_y, vec_x, vec_y)) = get_from_db()
else {
return;
};
let Ok(outline_decoded) = serde_json::from_str::<Vec<(f64, f64)>>(&outline)
else {
println!("Invalid outline!");
return;
};
self.table.set_outline_and_initial(
&outline_decoded, pos_x, pos_y, vec_x, vec_y, 0.0
);
}
#[cfg(target_arch = "wasm32")]
fn load_table_by_name(&mut self, name: &str) {
for conf in &*BUILTIN_TABLES {
if conf.name == name {
self.table.set_outline_and_initial(
&conf.outline,
conf.initial_pos.0,
conf.initial_pos.1,
conf.initial_vec.0,
conf.initial_vec.1,
0.0
);
break;
}
}
}
}
#[allow(dead_code)]
static WEB: bool = cfg!(target_arch = "wasm32");
impl eframe::App for BilliardsApp {
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
CentralPanel::default().show(ctx, |ui| {
ui.heading("Billiards simulator");
ui.horizontal(|ui| {
ComboBox::from_id_source("outline")
.selected_text(&self.table_names[self.table_index])
.show_ui(ui, |ui| {
for (i, n) in self.table_names.iter().enumerate() {
ui.selectable_value(&mut self.requested_table_index, i, n);
}
});
let startstop = if self.stopped { "Start" } else { "Stop" };
if ui.button(startstop).clicked() {
self.stopped = !self.stopped;
}
if ui.button("Reset").clicked() {
self.table.set_time(0.0);
}
ui.add(Slider::new(&mut self.speed, 0.0 ..= 10.0));
});
ui.separator();
// Combobox selection changed!
if self.requested_table_index != self.table_index {
self.table_index = self.requested_table_index;
let table_name = self.table_names[self.table_index].to_string();
self.load_table_by_name(&table_name);
// while we're accessing the database anyway, also update the list of names
let _ = self.update_table_names();
}
let elapsed = get_elapsed_time();
let frametime = elapsed - self.last_time;
self.last_time = elapsed;
if !self.stopped {
self.table.add_time(frametime * self.speed);
}
ui.add_sized(ui.available_size(), &mut self.table);
if !self.stopped {
ui.ctx().request_repaint();
}
ui.with_layout(Layout::bottom_up(Align::LEFT), |ui| {
egui::warn_if_debug_build(ui);
});
});
}
}
/// return the time elapsed since an arbitrary moment, in seconds
#[cfg(not(target_arch = "wasm32"))]
pub fn get_elapsed_time() -> f64 {
static TIME_ORIGIN: LazyLock<Instant> = LazyLock::new(Instant::now);
Instant::now().duration_since(*TIME_ORIGIN).as_nanos() as f64 * 1e-9
}
#[cfg(target_arch = "wasm32")]
pub fn get_elapsed_time() -> f64 {
web_sys::window().unwrap().performance().unwrap().now() * 1e-3
}

2
src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod app;
pub mod table;

68
src/main.rs Normal file
View File

@ -0,0 +1,68 @@
#![warn(clippy::all, rust_2018_idioms)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
use billiards::app::BilliardsApp;
// native
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 300.0])
.with_min_inner_size([300.0, 220.0])
.with_icon(
// NOTE: Adding an icon is optional
eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon-256.png")[..])
.expect("Failed to load icon"),
),
..Default::default()
};
eframe::run_native(
"Billiards simulator",
native_options,
Box::new(|cc| Ok(Box::new(BilliardsApp::new(cc)))),
)
}
// wasm
#[cfg(target_arch = "wasm32")]
fn main() {
// Redirect `log` message to `console.log` and friends:
eframe::WebLogger::init(log::LevelFilter::Debug).ok();
let web_options = eframe::WebOptions {
follow_system_theme: false,
default_theme: eframe::Theme::Dark,
.. eframe::WebOptions::default()
};
wasm_bindgen_futures::spawn_local(async {
let start_result = eframe::WebRunner::new()
.start(
"the_canvas_id",
web_options,
Box::new(|cc| Ok(Box::new(BilliardsApp::new(cc)))),
)
.await;
// Remove the loading text and spinner:
let loading_text = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("loading_text"));
if let Some(loading_text) = loading_text {
match start_result {
Ok(_) => {
loading_text.remove();
}
Err(e) => {
loading_text.set_inner_html(
"<p> The app has crashed. See the developer console for details. </p>",
);
panic!("Failed to start eframe: {e:?}");
}
}
}
});
}

227
src/table.rs Normal file
View File

@ -0,0 +1,227 @@
use egui::{Color32, Response, Ui, Widget, Stroke, Rect, Pos2};
const EPSILON: f64 = 1e-10;
// a time, point and a vector, the fundamental state of the simulation
#[derive(Debug,Clone,Copy)]
struct State {
t: f64,
x: f64,
y: f64,
dx: f64,
dy: f64,
corner: bool,
}
pub struct Table {
bbox: Rect,
outline: Vec<(f64, f64)>,
path: Vec<State>,
time: f64,
}
impl Table {
pub fn new(outline: &[(f64, f64)], x: f64, y: f64, dx: f64, dy: f64, time: f64) -> Table {
let mut result = Table {
bbox: Rect { min: Pos2 {x: 0.0, y: 0.0}, max: Pos2 {x: 0.0, y: 0.0}},
outline: outline.to_vec(),
path: vec![State { t: 0.0, x, y, dx, dy, corner: false }],
time: 0.0,
};
result.update_bbox();
result.set_initial(x, y, dx, dy, time);
result
}
fn update_bbox(&mut self) {
let mut bbox = Rect {
min: Pos2 {x: f32::MAX, y: f32::MAX },
max: Pos2 {x: f32::MIN, y: f32::MIN },
};
for (x,y) in &self.outline {
bbox.max.x = f32::max(bbox.max.x, *x as f32);
bbox.max.y = f32::max(bbox.max.y, *y as f32);
bbox.min.x = f32::min(bbox.min.x, *x as f32);
bbox.min.y = f32::min(bbox.min.y, *y as f32);
}
self.bbox = bbox;
}
/// find the intersection of the ray originating from `state` and the line segment
/// between `a` and `b`. Returns `None` if there is no intersection.
/// Intersections in "negative direction" are not counted.
fn intersect(state: State, a: (f64, f64), b: (f64, f64)) -> Option<State> {
let vnprod = state.dx * (a.1 - b.1) - state.dy * (a.0 - b.0);
let nnprod = (a.0 - b.0) * (a.0 - b.0) + (a.1 - b.1) * (a.1 - b.1);
let s = (state.dx * (state.y - b.1) - state.dy * (state.x - b.0)) / vnprod;
let dt = ((a.0 - b.0) * (state.y - b.1) - (a.1 - b.1) * (state.x - b.0)) / vnprod;
if s < -EPSILON || s > 1.0 + EPSILON || dt < EPSILON {
None
} else {
Some(State {
t: state.t + dt,
x: state.x + dt * state.dx,
y: state.y + dt * state.dy,
dx: state.dx - 2.0 * vnprod / nnprod * (a.1 - b.1),
dy: state.dy - 2.0 * vnprod / nnprod * (b.0 - a.0),
corner: s.abs() < EPSILON || (s-1.0).abs() < EPSILON,
})
}
}
pub fn precompute_path(&mut self, time_limit: f64) {
let mut last_state = *self.path.last().unwrap();
while last_state.t < time_limit {
if last_state.corner {
break;
}
// find the first intersection
let mut next_state = None;
let mut next_time = f64::MAX;
let outline_len = self.outline.len();
for i in 0..outline_len {
let int = Self::intersect(last_state, self.outline[i], self.outline[(i+1)%outline_len]);
if let Some(st) = int {
if st.t > last_state.t && st.t < next_time {
next_time = st.t;
next_state = Some(st);
}
}
}
if let Some(next_state) = next_state {
self.path.push(next_state);
last_state = next_state;
} else {
break;
}
// let Some(next_state) = next_state else { panic!("Table has to be a closed polygon!"); };
}
}
pub fn set_time(&mut self, time: f64) {
self.time = time;
let endpoint = self.path.last().unwrap();
// extend 10 seconds in the future just for good measure
if !endpoint.corner && time + 10.0 > endpoint.t {
self.precompute_path(time + 10.0);
}
}
pub fn get_time(&self) -> f64 {
self.time
}
pub fn add_time(&mut self, time: f64) {
self.set_time(self.time + time);
}
pub fn set_initial(&mut self, x: f64, y: f64, dx: f64, dy: f64, time: f64) {
self.path.truncate(0);
self.path.push(State { t: 0.0, x, y, dx, dy, corner: false });
self.set_time(time);
}
// resets time to 0, but keeps initial position and vector
pub fn set_outline(&mut self, outline: &[(f64, f64)]) {
self.outline = outline.to_vec();
self.path.truncate(1);
self.update_bbox();
self.set_time(0.0);
}
pub fn set_outline_and_initial(&mut self, outline: &[(f64, f64)], x: f64, y: f64, dx: f64, dy: f64, time: f64) {
self.outline = outline.to_vec();
self.update_bbox();
self.path.truncate(0);
self.path.push(State { t: 0.0, x, y, dx, dy, corner: false });
self.set_time(time);
}
}
impl Widget for &mut Table {
fn ui(self, ui: &mut Ui) -> Response {
let (response, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::click());
let stroke_outline = Stroke::new(2.0, Color32::from_gray(255));
let stroke_path = Stroke::new(1.0, Color32::from_rgb(255, 0, 0));
let object_rect = self.bbox;
let screen_rect = response.rect;
let scale = 0.9 * f32::min(
(screen_rect.max.x - screen_rect.min.x) / (object_rect.max.x - object_rect.min.x),
(screen_rect.max.y - screen_rect.min.y) / (object_rect.max.y - object_rect.min.y));
let (offsetx, offsety) = (
0.5*(screen_rect.min.x + screen_rect.max.x
- scale*object_rect.min.x - scale*object_rect.max.x),
0.5*(screen_rect.min.y + screen_rect.max.y
- scale*object_rect.min.y - scale*object_rect.max.y));
let mut ball_pos: (f64, f64) = (0.0, 0.0);
// draw billiard path
let len = self.path.len();
for i in 1 .. len {
if self.path[i-1].t > self.time {
break;
}
let pointa = Pos2 {
x: self.path[i-1].x as f32 * scale + offsetx,
y: self.path[i-1].y as f32 * scale + offsety
};
ball_pos = if self.path[i].t < self.time {
(self.path[i].x, self.path[i].y)
} else {
let s = (self.time - self.path[i-1].t) /
(self.path[i].t - self.path[i-1].t);
(self.path[i-1].x + s * (self.path[i].x - self.path[i-1].x),
self.path[i-1].y + s * (self.path[i].y - self.path[i-1].y))
};
let pointb = Pos2 {
x: ball_pos.0 as f32 * scale + offsetx,
y: ball_pos.1 as f32 * scale + offsety
};
painter.line_segment([pointa, pointb], stroke_path);
}
// draw table
let len = self.outline.len();
for i in 0 .. len {
painter.line_segment([
Pos2 {
x: self.outline[i].0 as f32 * scale + offsetx,
y: self.outline[i].1 as f32 * scale + offsety
},
Pos2 {
x: self.outline[(i+1)%len].0 as f32 * scale + offsetx,
y: self.outline[(i+1)%len].1 as f32 * scale + offsety
}], stroke_outline);
}
// draw ball
let center = Pos2 {
x: ball_pos.0 as f32 * scale + offsetx,
y: ball_pos.1 as f32 * scale + offsety};
painter.circle_filled(center, 5.0, Color32::from_gray(255));
// println!("{:?} {:?}", &screen_rect, elapsed);
response
}
}

BIN
tables.db Normal file

Binary file not shown.

21
tables.sql Normal file
View File

@ -0,0 +1,21 @@
BEGIN TRANSACTION;
CREATE TABLE tables(name TEXT NOT NULL, pos_x REAL NOT NULL, pos_y REAL NOT NULL, vec_x REAL NOT NULL, vec_y REAL NOT NULL, outline TEXT);
INSERT INTO tables VALUES('Rectangle (periodic short)',-1,0,1.6,2.4,'[[-2.0, -1.0], [2.0, -1.0], [2.0, 1.0], [-2.0, 1.0]]');
INSERT INTO tables VALUES('Rectangle (periodic long)',-1,0,7.04,8,'[[-2.0, -1.0], [2.0, -1.0], [2.0, 1.0], [-2.0, 1.0]]');
INSERT INTO tables VALUES('Rectangle (dense)',-1,0,10,7.34819206,'[[-2.0, -1.0], [2.0, -1.0], [2.0, 1.0], [-2.0, 1.0]]');
INSERT INTO tables VALUES('Rectangle (horizontal)',-1,0,2,0,'[[-2.0, -1.0], [2.0, -1.0], [2.0, 1.0], [-2.0, 1.0]]');
INSERT INTO tables VALUES('Triangle 6 (dense)',0,-0.5,5,5,'[[-2.0, -1.0], [2.0, -1.0], [2.0, 1.309401076758]]');
INSERT INTO tables VALUES('Triangle 6 (periodic)',0,-0.5,1,4.33012701892,'[[-2.0, -1.0], [2.0, -1.0], [2.0, 1.309401076758]]');
INSERT INTO tables VALUES('Triangle 6 (orthogonal)',0.3,-0.3,0,-1.000000,'[[-2.0, -1.0], [2.0, -1.0], [2.0, 1.309401076758]]');
INSERT INTO tables VALUES('Triangle 8 (periodic)',0.4,-0.7,-0.5,2.41421356237,'[[-2.0, -1.0], [2.0, -1.0], [2.0, 0.656854249492]]');
INSERT INTO tables VALUES('Dumbbell',-1.5,-0.1,1,2.12345567899,'[[-2.0, -1.0], [-1.0, -1.0], [-1.0, -0.05], [1.0, -0.05], [1.0, -1.0], [2.0, -1.0], [2.0, 1.0], [1.0, 1.0], [1.0, 0.05], [-1.0, 0.05], [-1.0, 1.0], [-2.0, 1.0]]');
INSERT INTO tables VALUES('L (dense)',0.2,0.5,0.5,-0.5,'[[1.0, 1.0], [1.0, 0.0], [2.276394569, 0.0], [2.276394569, -1.0], [-1.0, -1.0], [-1.0, 1.0]]');
INSERT INTO tables VALUES('L (periodic)',0.7,0.5,0.5,-0.5,'[[1.0, 1.0], [1.0, 0.0], [2.276394569, 0.0], [2.276394569, -1.0], [-1.0, -1.0], [-1.0, 1.0]]');
INSERT INTO tables VALUES('Nonconvex',0.4,0,0,-1.5,'[[0.0, -1.0], [-0.5, 1.8356409098088555], [0.5, 0.6438873172146455], [1.0, 1.0634371328032852], [1.5, 0.7133333636984291], [0.5502838083459437, -1.0]]');
INSERT INTO tables VALUES('Nonconvex 2',0.3,0,0,-1.5,'[[0.0, -1.0], [-0.5, 1.8356409098088555], [0.5, 0.6438873172146455], [1.0, 1.0634371328032852], [1.5, 0.7133333636984291], [0.5502838083459437, -1.0]]');
INSERT INTO tables VALUES('Orthogonal',1,0,0,-1,'[[-2.0, -1.0], [2.0, -1.0], [0.33512798812026023, 1.8836429126751244]]');
INSERT INTO tables VALUES('Hourglass',-0.2,-0.1,2,1,'[[-1.2, 1.0], [1.2, 1.0], [0.2, 0.0], [1.2, -1.0], [-1.2, -1.0], [-0.2, 0.0]]');
INSERT INTO tables VALUES('Star',0.2,0.5,0.5,-0.5,'[[0.0, 1.5], [0.5, 0.5], [1.5, 0.0], [0.5, -0.5], [0.0, -1.5], [-0.5, -0.5], [-1.5, 0.0], [-0.5, 0.5]]');
INSERT INTO tables VALUES('Star (short)',0.5,0,0.5,-0.8660254037844386,'[[0.0, 1.3660254037844386], [0.5, 0.5], [1.3660254037844386, 0.0], [0.5, -0.5], [0.0, -1.3660254037844386], [-0.5, -0.5], [-1.3660254037844386, 0.0], [-0.5, 0.5]]');
INSERT INTO tables VALUES('Tooth',-0.7,0.3,-1,-1,'[[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [0.0, 0.0], [-1.0, 1.0]]');
COMMIT;