diff --git a/app-proto/.gitignore b/app-proto/.gitignore
new file mode 100644
index 0000000..19aa86b
--- /dev/null
+++ b/app-proto/.gitignore
@@ -0,0 +1,4 @@
+target
+dist
+profiling
+Cargo.lock
\ No newline at end of file
diff --git a/app-proto/Cargo.toml b/app-proto/Cargo.toml
new file mode 100644
index 0000000..920469a
--- /dev/null
+++ b/app-proto/Cargo.toml
@@ -0,0 +1,42 @@
+[package]
+name = "sketch-outline"
+version = "0.1.0"
+authors = ["Aaron"]
+edition = "2021"
+
+[features]
+default = ["console_error_panic_hook"]
+
+[dependencies]
+itertools = "0.13.0"
+js-sys = "0.3.70"
+nalgebra = "0.33.0"
+rustc-hash = "2.0.0"
+slab = "0.4.9"
+sycamore = "0.9.0-beta.3"
+
+# The `console_error_panic_hook` crate provides better debugging of panics by
+# logging them with `console.error`. This is great for development, but requires
+# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
+# code size when deploying.
+console_error_panic_hook = { version = "0.1.7", optional = true }
+
+[dependencies.web-sys]
+version = "0.3.69"
+features = [
+ 'HtmlCanvasElement',
+ 'Performance',
+ 'WebGl2RenderingContext',
+ 'WebGlBuffer',
+ 'WebGlProgram',
+ 'WebGlShader',
+ 'WebGlUniformLocation',
+ 'WebGlVertexArrayObject'
+]
+
+[dev-dependencies]
+wasm-bindgen-test = "0.3.34"
+
+[profile.release]
+opt-level = "s" # optimize for small code size
+debug = true # include debug symbols
diff --git a/app-proto/index.html b/app-proto/index.html
new file mode 100644
index 0000000..5474fe9
--- /dev/null
+++ b/app-proto/index.html
@@ -0,0 +1,9 @@
+
+
+
+
+ Sketch outline
+
+
+
+
diff --git a/app-proto/main.css b/app-proto/main.css
new file mode 100644
index 0000000..bdbacfb
--- /dev/null
+++ b/app-proto/main.css
@@ -0,0 +1,124 @@
+body {
+ margin: 0px;
+ color: #fcfcfc;
+ background-color: #222;
+}
+
+/* sidebar */
+
+#sidebar {
+ display: flex;
+ flex-direction: column;
+ float: left;
+ width: 450px;
+ height: 100vh;
+ margin: 0px;
+ padding: 0px;
+ border-width: 0px 1px 0px 0px;
+ border-style: solid;
+ border-color: #555;
+}
+
+/* add-remove */
+
+#add-remove {
+ display: flex;
+ gap: 8px;
+ margin: 8px;
+}
+
+#add-remove > button {
+ width: 32px;
+ height: 32px;
+ font-size: large;
+}
+
+/* outline */
+
+#outline {
+ flex-grow: 1;
+ margin: 0px;
+ padding: 0px;
+ overflow-y: scroll;
+}
+
+li {
+ user-select: none;
+}
+
+summary {
+ display: flex;
+}
+
+summary.selected {
+ color: #fff;
+ background-color: #444;
+}
+
+summary > div, .cst {
+ padding-top: 4px;
+ padding-bottom: 4px;
+}
+
+.elt, .cst {
+ display: flex;
+ flex-grow: 1;
+ padding-left: 8px;
+ padding-right: 8px;
+}
+
+.elt-switch {
+ width: 18px;
+ padding-left: 2px;
+ text-align: center;
+}
+
+details:has(li) .elt-switch::after {
+ content: '▸';
+}
+
+details[open]:has(li) .elt-switch::after {
+ content: '▾';
+}
+
+.elt-label {
+ flex-grow: 1;
+}
+
+.cst-label {
+ flex-grow: 1;
+}
+
+.elt-rep {
+ display: flex;
+}
+
+.elt-rep > div, .cst-rep {
+ padding: 2px 0px 0px 0px;
+ font-size: 10pt;
+ text-align: center;
+ width: 56px;
+}
+
+.cst {
+ font-style: italic;
+}
+
+.cst > input {
+ margin: 0px 8px 0px 0px;
+}
+
+/* display */
+
+canvas {
+ float: left;
+ margin-left: 20px;
+ margin-top: 20px;
+ background-color: #020202;
+ border: 1px solid #555;
+ border-radius: 16px;
+}
+
+canvas:focus {
+ border-color: #aaa;
+}
\ No newline at end of file
diff --git a/app-proto/src/add_remove.rs b/app-proto/src/add_remove.rs
new file mode 100644
index 0000000..ab5db70
--- /dev/null
+++ b/app-proto/src/add_remove.rs
@@ -0,0 +1,242 @@
+use std::collections::BTreeSet; /* DEBUG */
+use sycamore::prelude::*;
+use web_sys::{console, wasm_bindgen::JsValue};
+
+use crate::{engine, AppState, assembly::{Assembly, Constraint, Element}};
+
+/* DEBUG */
+fn load_gen_assemb(assembly: &Assembly) {
+ let _ = assembly.try_insert_element(
+ Element {
+ id: String::from("gemini_a"),
+ label: String::from("Castor"),
+ color: [1.00_f32, 0.25_f32, 0.00_f32],
+ rep: engine::sphere(0.5, 0.5, 0.0, 1.0),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: String::from("gemini_b"),
+ label: String::from("Pollux"),
+ color: [0.00_f32, 0.25_f32, 1.00_f32],
+ rep: engine::sphere(-0.5, -0.5, 0.0, 1.0),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: String::from("ursa_major"),
+ label: String::from("Ursa major"),
+ color: [0.25_f32, 0.00_f32, 1.00_f32],
+ rep: engine::sphere(-0.5, 0.5, 0.0, 0.75),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: String::from("ursa_minor"),
+ label: String::from("Ursa minor"),
+ color: [0.25_f32, 1.00_f32, 0.00_f32],
+ rep: engine::sphere(0.5, -0.5, 0.0, 0.5),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: String::from("moon_deimos"),
+ label: String::from("Deimos"),
+ color: [0.75_f32, 0.75_f32, 0.00_f32],
+ rep: engine::sphere(0.0, 0.15, 1.0, 0.25),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: String::from("moon_phobos"),
+ label: String::from("Phobos"),
+ color: [0.00_f32, 0.75_f32, 0.50_f32],
+ rep: engine::sphere(0.0, -0.15, -1.0, 0.25),
+ constraints: BTreeSet::default()
+ }
+ );
+ assembly.insert_constraint(
+ Constraint {
+ args: (
+ assembly.elements_by_id.with_untracked(|elts_by_id| elts_by_id["gemini_a"]),
+ assembly.elements_by_id.with_untracked(|elts_by_id| elts_by_id["gemini_b"])
+ ),
+ rep: 0.5,
+ active: create_signal(true)
+ }
+ );
+}
+
+/* DEBUG */
+fn load_low_curv_assemb(assembly: &Assembly) {
+ let a = 0.75_f64.sqrt();
+ let _ = assembly.try_insert_element(
+ Element {
+ id: "central".to_string(),
+ label: "Central".to_string(),
+ color: [0.75_f32, 0.75_f32, 0.75_f32],
+ rep: engine::sphere(0.0, 0.0, 0.0, 1.0),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: "assemb_plane".to_string(),
+ label: "Assembly plane".to_string(),
+ color: [0.75_f32, 0.75_f32, 0.75_f32],
+ rep: engine::sphere_with_offset(0.0, 0.0, 1.0, 0.0, 0.0),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: "side1".to_string(),
+ label: "Side 1".to_string(),
+ color: [1.00_f32, 0.00_f32, 0.25_f32],
+ rep: engine::sphere_with_offset(1.0, 0.0, 0.0, 1.0, 0.0),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: "side2".to_string(),
+ label: "Side 2".to_string(),
+ color: [0.25_f32, 1.00_f32, 0.00_f32],
+ rep: engine::sphere_with_offset(-0.5, a, 0.0, 1.0, 0.0),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: "side3".to_string(),
+ label: "Side 3".to_string(),
+ color: [0.00_f32, 0.25_f32, 1.00_f32],
+ rep: engine::sphere_with_offset(-0.5, -a, 0.0, 1.0, 0.0),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: "corner1".to_string(),
+ label: "Corner 1".to_string(),
+ color: [0.75_f32, 0.75_f32, 0.75_f32],
+ rep: engine::sphere(-4.0/3.0, 0.0, 0.0, 1.0/3.0),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: "corner2".to_string(),
+ label: "Corner 2".to_string(),
+ color: [0.75_f32, 0.75_f32, 0.75_f32],
+ rep: engine::sphere(2.0/3.0, -4.0/3.0 * a, 0.0, 1.0/3.0),
+ constraints: BTreeSet::default()
+ }
+ );
+ let _ = assembly.try_insert_element(
+ Element {
+ id: String::from("corner3"),
+ label: String::from("Corner 3"),
+ color: [0.75_f32, 0.75_f32, 0.75_f32],
+ rep: engine::sphere(2.0/3.0, 4.0/3.0 * a, 0.0, 1.0/3.0),
+ constraints: BTreeSet::default()
+ }
+ );
+}
+
+#[component]
+pub fn AddRemove() -> View {
+ /* DEBUG */
+ let assembly_name = create_signal("general".to_string());
+ create_effect(move || {
+ // get name of chosen assembly
+ let name = assembly_name.get_clone();
+ console::log_1(
+ &JsValue::from(format!("Showing assembly \"{}\"", name.clone()))
+ );
+
+ batch(|| {
+ let state = use_context::();
+ let assembly = &state.assembly;
+
+ // clear state
+ assembly.elements.update(|elts| elts.clear());
+ assembly.elements_by_id.update(|elts_by_id| elts_by_id.clear());
+ state.selection.update(|sel| sel.clear());
+
+ // load assembly
+ match name.as_str() {
+ "general" => load_gen_assemb(assembly),
+ "low-curv" => load_low_curv_assemb(assembly),
+ _ => ()
+ };
+ });
+ });
+
+ view! {
+ div(id="add-remove") {
+ button(
+ on:click=|_| {
+ let state = use_context::();
+ state.assembly.insert_new_element();
+
+ /* DEBUG */
+ // print updated list of elements by identifier
+ console::log_1(&JsValue::from("elements by identifier:"));
+ for (id, key) in state.assembly.elements_by_id.get_clone().iter() {
+ console::log_3(
+ &JsValue::from(" "),
+ &JsValue::from(id),
+ &JsValue::from(*key)
+ );
+ }
+ }
+ ) { "+" }
+ button(
+ disabled={
+ let state = use_context::();
+ state.selection.with(|sel| sel.len() != 2)
+ },
+ on:click=|_| {
+ let state = use_context::();
+ let args = state.selection.with(
+ |sel| {
+ let arg_vec: Vec<_> = sel.into_iter().collect();
+ (arg_vec[0].clone(), arg_vec[1].clone())
+ }
+ );
+ state.assembly.insert_constraint(Constraint {
+ args: args,
+ rep: 0.0,
+ active: create_signal(true)
+ });
+ state.selection.update(|sel| sel.clear());
+
+ /* DEBUG */
+ // print updated constraint list
+ console::log_1(&JsValue::from("constraints:"));
+ state.assembly.constraints.with(|csts| {
+ for (_, cst) in csts.into_iter() {
+ console::log_5(
+ &JsValue::from(" "),
+ &JsValue::from(cst.args.0),
+ &JsValue::from(cst.args.1),
+ &JsValue::from(":"),
+ &JsValue::from(cst.rep)
+ );
+ }
+ });
+ }
+ ) { "🔗" }
+ select(bind:value=assembly_name) { /* DEBUG */
+ option(value="general") { "General" }
+ option(value="low-curv") { "Low-curvature" }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-proto/src/assembly.rs b/app-proto/src/assembly.rs
new file mode 100644
index 0000000..e8dab79
--- /dev/null
+++ b/app-proto/src/assembly.rs
@@ -0,0 +1,93 @@
+use nalgebra::DVector;
+use rustc_hash::FxHashMap;
+use slab::Slab;
+use std::collections::BTreeSet;
+use sycamore::prelude::*;
+
+#[derive(Clone, PartialEq)]
+pub struct Element {
+ pub id: String,
+ pub label: String,
+ pub color: [f32; 3],
+ pub rep: DVector,
+ pub constraints: BTreeSet
+}
+
+#[derive(Clone)]
+pub struct Constraint {
+ pub args: (usize, usize),
+ pub rep: f64,
+ pub active: Signal
+}
+
+// a complete, view-independent description of an assembly
+#[derive(Clone)]
+pub struct Assembly {
+ // elements and constraints
+ pub elements: Signal>,
+ pub constraints: Signal>,
+
+ // indexing
+ pub elements_by_id: Signal>
+}
+
+impl Assembly {
+ pub fn new() -> Assembly {
+ Assembly {
+ elements: create_signal(Slab::new()),
+ constraints: create_signal(Slab::new()),
+ elements_by_id: create_signal(FxHashMap::default())
+ }
+ }
+
+ // insert an element into the assembly without checking whether we already
+ // have an element with the same identifier. any element that does have the
+ // same identifier will get kicked out of the `elements_by_id` index
+ fn insert_element_unchecked(&self, elt: Element) {
+ let id = elt.id.clone();
+ let key = self.elements.update(|elts| elts.insert(elt));
+ self.elements_by_id.update(|elts_by_id| elts_by_id.insert(id, key));
+ }
+
+ pub fn try_insert_element(&self, elt: Element) -> bool {
+ let can_insert = self.elements_by_id.with_untracked(
+ |elts_by_id| !elts_by_id.contains_key(&elt.id)
+ );
+ if can_insert {
+ self.insert_element_unchecked(elt);
+ }
+ can_insert
+ }
+
+ pub fn insert_new_element(&self) {
+ // find the next unused identifier in the default sequence
+ let mut id_num = 1;
+ let mut id = format!("sphere{}", id_num);
+ while self.elements_by_id.with_untracked(
+ |elts_by_id| elts_by_id.contains_key(&id)
+ ) {
+ id_num += 1;
+ id = format!("sphere{}", id_num);
+ }
+
+ // create and insert a new element
+ self.insert_element_unchecked(
+ Element {
+ id: id,
+ label: format!("Sphere {}", id_num),
+ color: [0.75_f32, 0.75_f32, 0.75_f32],
+ rep: DVector::::from_column_slice(&[0.0, 0.0, 0.0, 0.5, -0.5]),
+ constraints: BTreeSet::default()
+ }
+ );
+ }
+
+ pub fn insert_constraint(&self, constraint: Constraint) {
+ let args = constraint.args;
+ let key = self.constraints.update(|csts| csts.insert(constraint));
+ self.elements.update(|elts| {
+ elts[args.0].constraints.insert(key);
+ elts[args.1].constraints.insert(key);
+ })
+ }
+}
\ No newline at end of file
diff --git a/app-proto/src/display.rs b/app-proto/src/display.rs
new file mode 100644
index 0000000..c32b470
--- /dev/null
+++ b/app-proto/src/display.rs
@@ -0,0 +1,443 @@
+use core::array;
+use nalgebra::{DMatrix, Rotation3, Vector3};
+use sycamore::{prelude::*, motion::create_raf};
+use web_sys::{
+ console,
+ window,
+ KeyboardEvent,
+ WebGl2RenderingContext,
+ WebGlProgram,
+ WebGlShader,
+ WebGlUniformLocation,
+ wasm_bindgen::{JsCast, JsValue}
+};
+
+use crate::AppState;
+
+fn compile_shader(
+ context: &WebGl2RenderingContext,
+ shader_type: u32,
+ source: &str,
+) -> WebGlShader {
+ let shader = context.create_shader(shader_type).unwrap();
+ context.shader_source(&shader, source);
+ context.compile_shader(&shader);
+ shader
+}
+
+fn get_uniform_array_locations(
+ context: &WebGl2RenderingContext,
+ program: &WebGlProgram,
+ var_name: &str,
+ member_name_opt: Option<&str>
+) -> [Option; N] {
+ array::from_fn(|n| {
+ let name = match member_name_opt {
+ Some(member_name) => format!("{var_name}[{n}].{member_name}"),
+ None => format!("{var_name}[{n}]")
+ };
+ context.get_uniform_location(&program, name.as_str())
+ })
+}
+
+// load the given data into the vertex input of the given name
+fn bind_vertex_attrib(
+ context: &WebGl2RenderingContext,
+ index: u32,
+ size: i32,
+ data: &[f32]
+) {
+ // create a data buffer and bind it to ARRAY_BUFFER
+ let buffer = context.create_buffer().unwrap();
+ context.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&buffer));
+
+ // load the given data into the buffer. the function `Float32Array::view`
+ // creates a raw view into our module's `WebAssembly.Memory` buffer.
+ // allocating more memory will change the buffer, invalidating the view.
+ // that means we have to make sure we don't allocate any memory until the
+ // view is dropped
+ unsafe {
+ context.buffer_data_with_array_buffer_view(
+ WebGl2RenderingContext::ARRAY_BUFFER,
+ &js_sys::Float32Array::view(&data),
+ WebGl2RenderingContext::STATIC_DRAW,
+ );
+ }
+
+ // allow the target attribute to be used
+ context.enable_vertex_attrib_array(index);
+
+ // take whatever's bound to ARRAY_BUFFER---here, the data buffer created
+ // above---and bind it to the target attribute
+ //
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/vertexAttribPointer
+ //
+ context.vertex_attrib_pointer_with_i32(
+ index,
+ size,
+ WebGl2RenderingContext::FLOAT,
+ false, // don't normalize
+ 0, // zero stride
+ 0, // zero offset
+ );
+}
+
+#[component]
+pub fn Display() -> View {
+ let state = use_context::();
+
+ // canvas
+ let display = create_node_ref();
+
+ // navigation
+ let pitch_up = create_signal(0.0);
+ let pitch_down = create_signal(0.0);
+ let yaw_right = create_signal(0.0);
+ let yaw_left = create_signal(0.0);
+ let roll_ccw = create_signal(0.0);
+ let roll_cw = create_signal(0.0);
+ let zoom_in = create_signal(0.0);
+ let zoom_out = create_signal(0.0);
+ let turntable = create_signal(false); /* BENCHMARKING */
+
+ // change listener
+ let scene_changed = create_signal(true);
+ create_effect(move || {
+ state.assembly.elements.track();
+ state.selection.track();
+ scene_changed.set(true);
+ });
+
+ /* INSTRUMENTS */
+ const SAMPLE_PERIOD: i32 = 60;
+ let mut last_sample_time = 0.0;
+ let mut frames_since_last_sample = 0;
+ let mean_frame_interval = create_signal(0.0);
+
+ on_mount(move || {
+ // timing
+ let mut last_time = 0.0;
+
+ // viewpoint
+ const ROT_SPEED: f64 = 0.4; // in radians per second
+ const ZOOM_SPEED: f64 = 0.15; // multiplicative rate per second
+ const TURNTABLE_SPEED: f64 = 0.1; /* BENCHMARKING */
+ let mut orientation = DMatrix::::identity(5, 5);
+ let mut rotation = DMatrix::::identity(5, 5);
+ let mut location_z: f64 = 5.0;
+
+ // display parameters
+ const OPACITY: f32 = 0.5; /* SCAFFOLDING */
+ const HIGHLIGHT: f32 = 0.2; /* SCAFFOLDING */
+ const LAYER_THRESHOLD: i32 = 0; /* DEBUG */
+ const DEBUG_MODE: i32 = 0; /* DEBUG */
+
+ /* INSTRUMENTS */
+ let performance = window().unwrap().performance().unwrap();
+
+ // get the display canvas
+ let canvas = display.get().unchecked_into::();
+ let ctx = canvas
+ .get_context("webgl2")
+ .unwrap()
+ .unwrap()
+ .dyn_into::()
+ .unwrap();
+
+ // compile and attach the vertex and fragment shaders
+ let vertex_shader = compile_shader(
+ &ctx,
+ WebGl2RenderingContext::VERTEX_SHADER,
+ include_str!("identity.vert"),
+ );
+ let fragment_shader = compile_shader(
+ &ctx,
+ WebGl2RenderingContext::FRAGMENT_SHADER,
+ include_str!("inversive.frag"),
+ );
+ let program = ctx.create_program().unwrap();
+ ctx.attach_shader(&program, &vertex_shader);
+ ctx.attach_shader(&program, &fragment_shader);
+ ctx.link_program(&program);
+ let link_status = ctx
+ .get_program_parameter(&program, WebGl2RenderingContext::LINK_STATUS)
+ .as_bool()
+ .unwrap();
+ let link_msg = if link_status {
+ "Linked successfully"
+ } else {
+ "Linking failed"
+ };
+ console::log_1(&JsValue::from(link_msg));
+ ctx.use_program(Some(&program));
+
+ /* DEBUG */
+ // print the maximum number of vectors that can be passed as
+ // uniforms to a fragment shader. the OpenGL ES 3.0 standard
+ // requires this maximum to be at least 224, as discussed in the
+ // documentation of the GL_MAX_FRAGMENT_UNIFORM_VECTORS parameter
+ // here:
+ //
+ // https://registry.khronos.org/OpenGL-Refpages/es3.0/html/glGet.xhtml
+ //
+ // there are also other size limits. for example, on Aaron's
+ // machine, the the length of a float or genType array seems to be
+ // capped at 1024 elements
+ console::log_2(
+ &ctx.get_parameter(WebGl2RenderingContext::MAX_FRAGMENT_UNIFORM_VECTORS).unwrap(),
+ &JsValue::from("uniform vectors available")
+ );
+
+ // find indices of vertex attributes and uniforms
+ const SPHERE_MAX: usize = 200;
+ let position_index = ctx.get_attrib_location(&program, "position") as u32;
+ let sphere_cnt_loc = ctx.get_uniform_location(&program, "sphere_cnt");
+ let sphere_sp_locs = get_uniform_array_locations::(
+ &ctx, &program, "sphere_list", Some("sp")
+ );
+ let sphere_lt_locs = get_uniform_array_locations::(
+ &ctx, &program, "sphere_list", Some("lt")
+ );
+ let color_locs = get_uniform_array_locations::(
+ &ctx, &program, "color_list", None
+ );
+ let highlight_locs = get_uniform_array_locations::(
+ &ctx, &program, "highlight_list", None
+ );
+ let resolution_loc = ctx.get_uniform_location(&program, "resolution");
+ let shortdim_loc = ctx.get_uniform_location(&program, "shortdim");
+ let opacity_loc = ctx.get_uniform_location(&program, "opacity");
+ let layer_threshold_loc = ctx.get_uniform_location(&program, "layer_threshold");
+ let debug_mode_loc = ctx.get_uniform_location(&program, "debug_mode");
+
+ // create a vertex array and bind it to the graphics context
+ let vertex_array = ctx.create_vertex_array().unwrap();
+ ctx.bind_vertex_array(Some(&vertex_array));
+
+ // set the vertex positions
+ const VERTEX_CNT: usize = 6;
+ let positions: [f32; 3*VERTEX_CNT] = [
+ // northwest triangle
+ -1.0, -1.0, 0.0,
+ -1.0, 1.0, 0.0,
+ 1.0, 1.0, 0.0,
+ // southeast triangle
+ -1.0, -1.0, 0.0,
+ 1.0, 1.0, 0.0,
+ 1.0, -1.0, 0.0
+ ];
+ bind_vertex_attrib(&ctx, position_index, 3, &positions);
+
+ // set up a repainting routine
+ let (_, start_animation_loop, _) = create_raf(move || {
+ // get the time step
+ let time = performance.now();
+ let time_step = 0.001*(time - last_time);
+ last_time = time;
+
+ // get the navigation state
+ let pitch_up_val = pitch_up.get();
+ let pitch_down_val = pitch_down.get();
+ let yaw_right_val = yaw_right.get();
+ let yaw_left_val = yaw_left.get();
+ let roll_ccw_val = roll_ccw.get();
+ let roll_cw_val = roll_cw.get();
+ let zoom_in_val = zoom_in.get();
+ let zoom_out_val = zoom_out.get();
+ let turntable_val = turntable.get(); /* BENCHMARKING */
+
+ // update the assembly's orientation
+ let ang_vel = {
+ let pitch = pitch_up_val - pitch_down_val;
+ let yaw = yaw_right_val - yaw_left_val;
+ let roll = roll_ccw_val - roll_cw_val;
+ if pitch != 0.0 || yaw != 0.0 || roll != 0.0 {
+ ROT_SPEED * Vector3::new(-pitch, yaw, roll).normalize()
+ } else {
+ Vector3::zeros()
+ }
+ } /* BENCHMARKING */ + if turntable_val {
+ Vector3::new(0.0, TURNTABLE_SPEED, 0.0)
+ } else {
+ Vector3::zeros()
+ };
+ let mut rotation_sp = rotation.fixed_view_mut::<3, 3>(0, 0);
+ rotation_sp.copy_from(
+ Rotation3::from_scaled_axis(time_step * ang_vel).matrix()
+ );
+ orientation = &rotation * &orientation;
+
+ // update the assembly's location
+ let zoom = zoom_out_val - zoom_in_val;
+ location_z *= (time_step * ZOOM_SPEED * zoom).exp();
+
+ if scene_changed.get() {
+ /* INSTRUMENTS */
+ // measure mean frame interval
+ frames_since_last_sample += 1;
+ if frames_since_last_sample >= SAMPLE_PERIOD {
+ mean_frame_interval.set((time - last_sample_time) / (SAMPLE_PERIOD as f64));
+ last_sample_time = time;
+ frames_since_last_sample = 0;
+ }
+
+ // find the map from assembly space to world space
+ let location = {
+ let u = -location_z;
+ DMatrix::from_column_slice(5, 5, &[
+ 1.0, 0.0, 0.0, 0.0, 0.0,
+ 0.0, 1.0, 0.0, 0.0, 0.0,
+ 0.0, 0.0, 1.0, 0.0, u,
+ 0.0, 0.0, 2.0*u, 1.0, u*u,
+ 0.0, 0.0, 0.0, 0.0, 1.0
+ ])
+ };
+ let assembly_to_world = &location * &orientation;
+
+ // get the assembly
+ let elements = state.assembly.elements.get_clone();
+ let element_iter = (&elements).into_iter();
+ let reps_world: Vec<_> = element_iter.clone().map(|(_, elt)| &assembly_to_world * &elt.rep).collect();
+ let colors: Vec<_> = element_iter.clone().map(|(key, elt)|
+ if state.selection.with(|sel| sel.contains(&key)) {
+ elt.color.map(|ch| 0.2 + 0.8*ch)
+ } else {
+ elt.color
+ }
+ ).collect();
+ let highlights: Vec<_> = element_iter.map(|(key, _)|
+ if state.selection.with(|sel| sel.contains(&key)) {
+ 1.0_f32
+ } else {
+ HIGHLIGHT
+ }
+ ).collect();
+
+ // set the resolution
+ let width = canvas.width() as f32;
+ let height = canvas.height() as f32;
+ ctx.uniform2f(resolution_loc.as_ref(), width, height);
+ ctx.uniform1f(shortdim_loc.as_ref(), width.min(height));
+
+ // pass the assembly
+ ctx.uniform1i(sphere_cnt_loc.as_ref(), elements.len() as i32);
+ for n in 0..reps_world.len() {
+ let v = &reps_world[n];
+ ctx.uniform3f(
+ sphere_sp_locs[n].as_ref(),
+ v[0] as f32, v[1] as f32, v[2] as f32
+ );
+ ctx.uniform2f(
+ sphere_lt_locs[n].as_ref(),
+ v[3] as f32, v[4] as f32
+ );
+ ctx.uniform3fv_with_f32_array(
+ color_locs[n].as_ref(),
+ &colors[n]
+ );
+ ctx.uniform1f(
+ highlight_locs[n].as_ref(),
+ highlights[n]
+ );
+ }
+
+ // pass the display parameters
+ ctx.uniform1f(opacity_loc.as_ref(), OPACITY);
+ ctx.uniform1i(layer_threshold_loc.as_ref(), LAYER_THRESHOLD);
+ ctx.uniform1i(debug_mode_loc.as_ref(), DEBUG_MODE);
+
+ // draw the scene
+ ctx.draw_arrays(WebGl2RenderingContext::TRIANGLES, 0, VERTEX_CNT as i32);
+
+ // clear the scene change flag
+ scene_changed.set(
+ pitch_up_val != 0.0
+ || pitch_down_val != 0.0
+ || yaw_left_val != 0.0
+ || yaw_right_val != 0.0
+ || roll_cw_val != 0.0
+ || roll_ccw_val != 0.0
+ || zoom_in_val != 0.0
+ || zoom_out_val != 0.0
+ || turntable_val /* BENCHMARKING */
+ );
+ } else {
+ frames_since_last_sample = 0;
+ mean_frame_interval.set(-1.0);
+ }
+ });
+ start_animation_loop();
+ });
+
+ let set_nav_signal = move |event: KeyboardEvent, value: f64| {
+ let mut navigating = true;
+ let shift = event.shift_key();
+ match event.key().as_str() {
+ "ArrowUp" if shift => zoom_in.set(value),
+ "ArrowDown" if shift => zoom_out.set(value),
+ "ArrowUp" => pitch_up.set(value),
+ "ArrowDown" => pitch_down.set(value),
+ "ArrowRight" if shift => roll_cw.set(value),
+ "ArrowLeft" if shift => roll_ccw.set(value),
+ "ArrowRight" => yaw_right.set(value),
+ "ArrowLeft" => yaw_left.set(value),
+ _ => navigating = false
+ };
+ if navigating {
+ scene_changed.set(true);
+ event.prevent_default();
+ }
+ };
+
+ view! {
+ /* TO DO */
+ // switch back to integer-valued parameters when that becomes possible
+ // again
+ canvas(
+ ref=display,
+ width="600",
+ height="600",
+ tabindex="0",
+ on:keydown=move |event: KeyboardEvent| {
+ if event.key() == "Shift" {
+ roll_cw.set(yaw_right.get());
+ roll_ccw.set(yaw_left.get());
+ zoom_in.set(pitch_up.get());
+ zoom_out.set(pitch_down.get());
+ yaw_right.set(0.0);
+ yaw_left.set(0.0);
+ pitch_up.set(0.0);
+ pitch_down.set(0.0);
+ } else {
+ if event.key() == "Enter" { /* BENCHMARKING */
+ turntable.set_fn(|turn| !turn);
+ scene_changed.set(true);
+ }
+ set_nav_signal(event, 1.0);
+ }
+ },
+ on:keyup=move |event: KeyboardEvent| {
+ if event.key() == "Shift" {
+ yaw_right.set(roll_cw.get());
+ yaw_left.set(roll_ccw.get());
+ pitch_up.set(zoom_in.get());
+ pitch_down.set(zoom_out.get());
+ roll_cw.set(0.0);
+ roll_ccw.set(0.0);
+ zoom_in.set(0.0);
+ zoom_out.set(0.0);
+ } else {
+ set_nav_signal(event, 0.0);
+ }
+ },
+ on:blur=move |_| {
+ pitch_up.set(0.0);
+ pitch_down.set(0.0);
+ yaw_right.set(0.0);
+ yaw_left.set(0.0);
+ roll_ccw.set(0.0);
+ roll_cw.set(0.0);
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app-proto/src/engine.rs b/app-proto/src/engine.rs
new file mode 100644
index 0000000..79668bb
--- /dev/null
+++ b/app-proto/src/engine.rs
@@ -0,0 +1,27 @@
+use nalgebra::DVector;
+
+// the sphere with the given center and radius, with inward-pointing normals
+pub fn sphere(center_x: f64, center_y: f64, center_z: f64, radius: f64) -> DVector {
+ let center_norm_sq = center_x * center_x + center_y * center_y + center_z * center_z;
+ DVector::from_column_slice(&[
+ center_x / radius,
+ center_y / radius,
+ center_z / radius,
+ 0.5 / radius,
+ 0.5 * (center_norm_sq / radius - radius)
+ ])
+}
+
+// the sphere of curvature `curv` whose closest point to the origin has position
+// `off * dir` and normal `dir`, where `dir` is a unit vector. setting the
+// curvature to zero gives a plane
+pub fn sphere_with_offset(dir_x: f64, dir_y: f64, dir_z: f64, off: f64, curv: f64) -> DVector {
+ let norm_sp = 1.0 + off * curv;
+ DVector::from_column_slice(&[
+ norm_sp * dir_x,
+ norm_sp * dir_y,
+ norm_sp * dir_z,
+ 0.5 * curv,
+ off * (1.0 + 0.5 * off * curv)
+ ])
+}
\ No newline at end of file
diff --git a/app-proto/src/identity.vert b/app-proto/src/identity.vert
new file mode 100644
index 0000000..183a65f
--- /dev/null
+++ b/app-proto/src/identity.vert
@@ -0,0 +1,7 @@
+#version 300 es
+
+in vec4 position;
+
+void main() {
+ gl_Position = position;
+}
\ No newline at end of file
diff --git a/app-proto/src/inversive.frag b/app-proto/src/inversive.frag
new file mode 100644
index 0000000..d50cb1e
--- /dev/null
+++ b/app-proto/src/inversive.frag
@@ -0,0 +1,234 @@
+#version 300 es
+
+precision highp float;
+
+out vec4 outColor;
+
+// --- inversive geometry ---
+
+struct vecInv {
+ vec3 sp;
+ vec2 lt;
+};
+
+// --- uniforms ---
+
+// assembly
+const int SPHERE_MAX = 200;
+uniform int sphere_cnt;
+uniform vecInv sphere_list[SPHERE_MAX];
+uniform vec3 color_list[SPHERE_MAX];
+uniform float highlight_list[SPHERE_MAX];
+
+// view
+uniform vec2 resolution;
+uniform float shortdim;
+
+// controls
+uniform float opacity;
+uniform int layer_threshold;
+uniform bool debug_mode;
+
+// light and camera
+const float focal_slope = 0.3;
+const vec3 light_dir = normalize(vec3(2., 2., 1.));
+const float ixn_threshold = 0.005;
+const float INTERIOR_DIMMING = 0.7;
+
+// --- sRGB ---
+
+// map colors from RGB space to sRGB space, as specified in the sRGB standard
+// (IEC 61966-2-1:1999)
+//
+// https://www.color.org/sRGB.pdf
+// https://www.color.org/chardata/rgb/srgb.xalter
+//
+// in RGB space, color value is proportional to light intensity, so linear
+// color-vector interpolation corresponds to physical light mixing. in sRGB
+// space, the color encoding used by many monitors, we use more of the value
+// interval to represent low intensities, and less of the interval to represent
+// high intensities. this improves color quantization
+
+float sRGB(float t) {
+ if (t <= 0.0031308) {
+ return 12.92*t;
+ } else {
+ return 1.055*pow(t, 5./12.) - 0.055;
+ }
+}
+
+vec3 sRGB(vec3 color) {
+ return vec3(sRGB(color.r), sRGB(color.g), sRGB(color.b));
+}
+
+// --- shading ---
+
+struct Fragment {
+ vec3 pt;
+ vec3 normal;
+ vec4 color;
+};
+
+Fragment sphere_shading(vecInv v, vec3 pt, vec3 base_color) {
+ // the expression for normal needs to be checked. it's supposed to give the
+ // negative gradient of the lorentz product between the impact point vector
+ // and the sphere vector with respect to the coordinates of the impact
+ // point. i calculated it in my head and decided that the result looked good
+ // enough for now
+ vec3 normal = normalize(-v.sp + 2.*v.lt.s*pt);
+
+ float incidence = dot(normal, light_dir);
+ float illum = mix(0.4, 1.0, max(incidence, 0.0));
+ return Fragment(pt, normal, vec4(illum * base_color, opacity));
+}
+
+float intersection_dist(Fragment a, Fragment b) {
+ float intersection_sin = length(cross(a.normal, b.normal));
+ vec3 disp = a.pt - b.pt;
+ return max(
+ abs(dot(a.normal, disp)),
+ abs(dot(b.normal, disp))
+ ) / intersection_sin;
+}
+
+// --- ray-casting ---
+
+struct TaggedDepth {
+ float depth;
+ float dimming;
+ int id;
+};
+
+// if `a/b` is less than this threshold, we approximate `a*u^2 + b*u + c` by
+// the linear function `b*u + c`
+const float DEG_THRESHOLD = 1e-9;
+
+// the depths, represented as multiples of `dir`, where the line generated by
+// `dir` hits the sphere represented by `v`. if both depths are positive, the
+// smaller one is returned in the first component. if only one depth is
+// positive, it could be returned in either component
+vec2 sphere_cast(vecInv v, vec3 dir) {
+ float a = -v.lt.s * dot(dir, dir);
+ float b = dot(v.sp, dir);
+ float c = -v.lt.t;
+
+ float adjust = 4.*a*c/(b*b);
+ if (adjust < 1.) {
+ // as long as `b` is non-zero, the linear approximation of
+ //
+ // a*u^2 + b*u + c
+ //
+ // at `u = 0` will reach zero at a finite depth `u_lin`. the root of the
+ // quadratic adjacent to `u_lin` is stored in `lin_root`. if both roots
+ // have the same sign, `lin_root` will be the one closer to `u = 0`
+ float square_rect_ratio = 1. + sqrt(1. - adjust);
+ float lin_root = -(2.*c)/b / square_rect_ratio;
+ if (abs(a) > DEG_THRESHOLD * abs(b)) {
+ return vec2(lin_root, -b/(2.*a) * square_rect_ratio);
+ } else {
+ return vec2(lin_root, -1.);
+ }
+ } else {
+ // the line through `dir` misses the sphere completely
+ return vec2(-1., -1.);
+ }
+}
+
+void main() {
+ vec2 scr = (2.*gl_FragCoord.xy - resolution) / shortdim;
+ vec3 dir = vec3(focal_slope * scr, -1.);
+
+ // cast rays through the spheres
+ const int LAYER_MAX = 12;
+ TaggedDepth top_hits [LAYER_MAX];
+ int layer_cnt = 0;
+ for (int id = 0; id < sphere_cnt; ++id) {
+ // find out where the ray hits the sphere
+ vec2 hit_depths = sphere_cast(sphere_list[id], dir);
+
+ // insertion-sort the points we hit into the hit list
+ float dimming = 1.;
+ for (int side = 0; side < 2; ++side) {
+ float depth = hit_depths[side];
+ if (depth > 0.) {
+ for (int layer = layer_cnt; layer >= 0; --layer) {
+ if (layer < 1 || top_hits[layer-1].depth <= depth) {
+ // we're not as close to the screen as the hit before
+ // the empty slot, so insert here
+ if (layer < LAYER_MAX) {
+ top_hits[layer] = TaggedDepth(depth, dimming, id);
+ }
+ break;
+ } else {
+ // we're closer to the screen than the hit before the
+ // empty slot, so move that hit into the empty slot
+ top_hits[layer] = top_hits[layer-1];
+ }
+ }
+ layer_cnt = min(layer_cnt + 1, LAYER_MAX);
+ dimming = INTERIOR_DIMMING;
+ }
+ }
+ }
+
+ /* DEBUG */
+ // in debug mode, show the layer count instead of the shaded image
+ if (debug_mode) {
+ // at the bottom of the screen, show the color scale instead of the
+ // layer count
+ if (gl_FragCoord.y < 10.) layer_cnt = int(16. * gl_FragCoord.x / resolution.x);
+
+ // convert number to color
+ ivec3 bits = layer_cnt / ivec3(1, 2, 4);
+ vec3 color = mod(vec3(bits), 2.);
+ if (layer_cnt % 16 >= 8) {
+ color = mix(color, vec3(0.5), 0.5);
+ }
+ outColor = vec4(color, 1.);
+ return;
+ }
+
+ // composite the sphere fragments
+ vec3 color = vec3(0.);
+ int layer = layer_cnt - 1;
+ TaggedDepth hit = top_hits[layer];
+ Fragment frag_next = sphere_shading(
+ sphere_list[hit.id],
+ hit.depth * dir,
+ hit.dimming * color_list[hit.id]
+ );
+ float highlight_next = highlight_list[hit.id];
+ --layer;
+ for (; layer >= layer_threshold; --layer) {
+ // load the current fragment
+ Fragment frag = frag_next;
+ float highlight = highlight_next;
+
+ // shade the next fragment
+ hit = top_hits[layer];
+ frag_next = sphere_shading(
+ sphere_list[hit.id],
+ hit.depth * dir,
+ hit.dimming * color_list[hit.id]
+ );
+ highlight_next = highlight_list[hit.id];
+
+ // highlight intersections
+ float ixn_dist = intersection_dist(frag, frag_next);
+ float max_highlight = max(highlight, highlight_next);
+ float ixn_highlight = 0.5 * max_highlight * (1. - smoothstep(2./3.*ixn_threshold, 1.5*ixn_threshold, ixn_dist));
+ frag.color = mix(frag.color, vec4(1.), ixn_highlight);
+ frag_next.color = mix(frag_next.color, vec4(1.), ixn_highlight);
+
+ // highlight cusps
+ float cusp_cos = abs(dot(dir, frag.normal));
+ float cusp_threshold = 2.*sqrt(ixn_threshold * sphere_list[hit.id].lt.s);
+ float cusp_highlight = highlight * (1. - smoothstep(2./3.*cusp_threshold, 1.5*cusp_threshold, cusp_cos));
+ frag.color = mix(frag.color, vec4(1.), cusp_highlight);
+
+ // composite the current fragment
+ color = mix(color, frag.color.rgb, frag.color.a);
+ }
+ color = mix(color, frag_next.color.rgb, frag_next.color.a);
+ outColor = vec4(sRGB(color), 1.);
+}
\ No newline at end of file
diff --git a/app-proto/src/main.rs b/app-proto/src/main.rs
new file mode 100644
index 0000000..2c71a83
--- /dev/null
+++ b/app-proto/src/main.rs
@@ -0,0 +1,42 @@
+mod add_remove;
+mod assembly;
+mod display;
+mod engine;
+mod outline;
+
+use rustc_hash::FxHashSet;
+use sycamore::prelude::*;
+
+use add_remove::AddRemove;
+use assembly::Assembly;
+use display::Display;
+use outline::Outline;
+
+#[derive(Clone)]
+struct AppState {
+ assembly: Assembly,
+ selection: Signal>
+}
+
+impl AppState {
+ fn new() -> AppState {
+ AppState {
+ assembly: Assembly::new(),
+ selection: create_signal(FxHashSet::default())
+ }
+ }
+}
+
+fn main() {
+ sycamore::render(|| {
+ provide_context(AppState::new());
+
+ view! {
+ div(id="sidebar") {
+ AddRemove {}
+ Outline {}
+ }
+ Display {}
+ }
+ });
+}
\ No newline at end of file
diff --git a/app-proto/src/outline.rs b/app-proto/src/outline.rs
new file mode 100644
index 0000000..4e4de9c
--- /dev/null
+++ b/app-proto/src/outline.rs
@@ -0,0 +1,161 @@
+use itertools::Itertools;
+use sycamore::{prelude::*, web::tags::div};
+use web_sys::{Element, KeyboardEvent, MouseEvent, wasm_bindgen::JsCast};
+
+use crate::AppState;
+
+// this component lists the elements of the assembly, showing the constraints
+// on each element as a collapsible sub-list. its implementation is based on
+// Kate Morley's HTML + CSS tree views:
+//
+// https://iamkate.com/code/tree-views/
+//
+#[component]
+pub fn Outline() -> View {
+ // sort the elements alphabetically by ID
+ let elements_sorted = create_memo(|| {
+ let state = use_context::();
+ state.assembly.elements
+ .get_clone()
+ .into_iter()
+ .sorted_by_key(|(_, elt)| elt.id.clone())
+ .collect()
+ });
+
+ view! {
+ ul(
+ id="outline",
+ on:click={
+ let state = use_context::();
+ move |_| state.selection.update(|sel| sel.clear())
+ }
+ ) {
+ Keyed(
+ list=elements_sorted,
+ view=|(key, elt)| {
+ let state = use_context::();
+ let class = create_memo({
+ move || {
+ if state.selection.with(|sel| sel.contains(&key)) {
+ "selected"
+ } else {
+ ""
+ }
+ }
+ });
+ let label = elt.label.clone();
+ let rep_components = elt.rep.iter().map(|u| {
+ let u_coord = u.to_string().replace("-", "\u{2212}");
+ View::from(div().children(u_coord))
+ }).collect::>();
+ let constrained = elt.constraints.len() > 0;
+ let details_node = create_node_ref();
+ view! {
+ /* [TO DO] switch to integer-valued parameters whenever
+ that becomes possible again */
+ li {
+ details(ref=details_node) {
+ summary(
+ class=class.get(),
+ on:keydown={
+ move |event: KeyboardEvent| {
+ match event.key().as_str() {
+ "Enter" => {
+ if event.shift_key() {
+ state.selection.update(|sel| {
+ if !sel.remove(&key) {
+ sel.insert(key);
+ }
+ });
+ } else {
+ state.selection.update(|sel| {
+ sel.clear();
+ sel.insert(key);
+ });
+ }
+ event.prevent_default();
+ },
+ "ArrowRight" if constrained => {
+ let _ = details_node
+ .get()
+ .unchecked_into::()
+ .set_attribute("open", "");
+ },
+ "ArrowLeft" => {
+ let _ = details_node
+ .get()
+ .unchecked_into::()
+ .remove_attribute("open");
+ },
+ _ => ()
+ }
+ }
+ }
+ ) {
+ div(
+ class="elt-switch",
+ on:click=|event: MouseEvent| event.stop_propagation()
+ )
+ div(
+ class="elt",
+ on:click={
+ move |event: MouseEvent| {
+ if event.shift_key() {
+ state.selection.update(|sel| {
+ if !sel.remove(&key) {
+ sel.insert(key);
+ }
+ });
+ } else {
+ state.selection.update(|sel| {
+ sel.clear();
+ sel.insert(key);
+ });
+ }
+ event.stop_propagation();
+ event.prevent_default();
+ }
+ }
+ ) {
+ div(class="elt-label") { (label) }
+ div(class="elt-rep") { (rep_components) }
+ }
+ }
+ ul(class="constraints") {
+ Keyed(
+ list=elt.constraints.into_iter().collect::>(),
+ view=move |c_key: usize| {
+ let c_state = use_context::();
+ let assembly = &c_state.assembly;
+ let cst = assembly.constraints.with(|csts| csts[c_key].clone());
+ let other_arg = if cst.args.0 == key {
+ cst.args.1
+ } else {
+ cst.args.0
+ };
+ let other_arg_label = assembly.elements.with(|elts| elts[other_arg].label.clone());
+ view! {
+ li(class="cst") {
+ input(r#type="checkbox", bind:checked=cst.active)
+ div(class="cst-label") { (other_arg_label) }
+ div(class="cst-rep") { (cst.rep) }
+ }
+ }
+ },
+ key=|c_key| c_key.clone()
+ )
+ }
+ }
+ }
+ }
+ },
+ key=|(key, elt)| (
+ key.clone(),
+ elt.id.clone(),
+ elt.label.clone(),
+ elt.constraints.clone()
+ )
+ )
+ }
+ }
+}
\ No newline at end of file