diff --git a/app-proto/Cargo.toml b/app-proto/Cargo.toml index 38205a7..c11fef4 100644 --- a/app-proto/Cargo.toml +++ b/app-proto/Cargo.toml @@ -26,6 +26,7 @@ console_error_panic_hook = { version = "0.1.7", optional = true } [dependencies.web-sys] version = "0.3.69" features = [ + 'DomRect', 'HtmlCanvasElement', 'HtmlInputElement', 'Performance', diff --git a/app-proto/src/assembly.rs b/app-proto/src/assembly.rs index 59cba41..fb5bbf7 100644 --- a/app-proto/src/assembly.rs +++ b/app-proto/src/assembly.rs @@ -1,4 +1,4 @@ -use nalgebra::{DMatrix, DVector}; +use nalgebra::{DMatrix, DVector, Vector3}; use rustc_hash::FxHashMap; use slab::Slab; use std::{collections::BTreeSet, sync::atomic::{AtomicU64, Ordering}}; @@ -65,6 +65,49 @@ impl Element { column_index: 0 } } + + // the smallest positive depth, represented as a multiple of `dir`, where + // the line generated by `dir` hits the element (which is assumed to be a + // sphere). returns `None` if the line misses the sphere. this function + // should be kept synchronized with `sphere_cast` in `inversive.frag`, which + // does essentially the same thing on the GPU side + pub fn cast(&self, dir: Vector3, assembly_to_world: &DMatrix) -> Option { + // if `a/b` is less than this threshold, we approximate + // `a*u^2 + b*u + c` by the linear function `b*u + c` + const DEG_THRESHOLD: f64 = 1e-9; + + let rep = self.representation.with_untracked(|rep| assembly_to_world * rep); + let a = -rep[3] * dir.norm_squared(); + let b = rep.rows_range(..3).dot(&dir); + let c = -rep[4]; + + let adjust = 4.0*a*c/(b*b); + if adjust < 1.0 { + // 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` + let square_rect_ratio = 1.0 + (1.0 - adjust).sqrt(); + let lin_root = -(2.0*c)/b / square_rect_ratio; + if a.abs() > DEG_THRESHOLD * b.abs() { + if lin_root > 0.0 { + Some(lin_root) + } else { + let other_root = -b/(2.*a) * square_rect_ratio; + (other_root > 0.0).then_some(other_root) + } + } else { + (lin_root > 0.0).then_some(lin_root) + } + } else { + // the line through `dir` misses the sphere completely + None + } + } } diff --git a/app-proto/src/display.rs b/app-proto/src/display.rs index ee0af47..494dd3c 100644 --- a/app-proto/src/display.rs +++ b/app-proto/src/display.rs @@ -4,7 +4,9 @@ use sycamore::{prelude::*, motion::create_raf}; use web_sys::{ console, window, + Element, KeyboardEvent, + MouseEvent, WebGl2RenderingContext, WebGlProgram, WebGlShader, @@ -12,7 +14,7 @@ use web_sys::{ wasm_bindgen::{JsCast, JsValue} }; -use crate::AppState; +use crate::{AppState, assembly::ElementKey}; fn compile_shader( context: &WebGl2RenderingContext, @@ -82,6 +84,24 @@ fn bind_vertex_attrib( ); } +// the direction in camera space that a mouse event is pointing along +fn event_dir(event: &MouseEvent) -> Vector3 { + let target: Element = event.target().unwrap().unchecked_into(); + let rect = target.get_bounding_client_rect(); + let width = rect.width(); + let height = rect.height(); + let shortdim = width.min(height); + + // this constant should be kept synchronized with `inversive.frag` + const FOCAL_SLOPE: f64 = 0.3; + + Vector3::new( + FOCAL_SLOPE * (2.0*(f64::from(event.client_x()) - rect.left()) - width) / shortdim, + FOCAL_SLOPE * (2.0*(rect.bottom() - f64::from(event.client_y())) - height) / shortdim, + -1.0 + ) +} + #[component] pub fn Display() -> View { let state = use_context::(); @@ -89,6 +109,9 @@ pub fn Display() -> View { // canvas let display = create_node_ref(); + // viewpoint + let assembly_to_world = create_signal(DMatrix::::identity(5, 5)); + // navigation let pitch_up = create_signal(0.0); let pitch_down = create_signal(0.0); @@ -296,7 +319,7 @@ pub fn Display() -> View { 0.0, 0.0, 0.0, 0.0, 1.0 ]) }; - let assembly_to_world = &location * &orientation; + let asm_to_world = &location * &orientation; // get the assembly let ( @@ -311,7 +334,7 @@ pub fn Display() -> View { // representation vectors in world coordinates elts.iter().map( - |(_, elt)| elt.representation.with(|rep| &assembly_to_world * rep) + |(_, elt)| elt.representation.with(|rep| &asm_to_world * rep) ).collect::>(), // colors @@ -370,6 +393,9 @@ pub fn Display() -> View { // draw the scene ctx.draw_arrays(WebGl2RenderingContext::TRIANGLES, 0, VERTEX_CNT as i32); + // update the viewpoint + assembly_to_world.set(asm_to_world); + // clear the scene change flag scene_changed.set( pitch_up_val != 0.0 @@ -458,6 +484,44 @@ pub fn Display() -> View { yaw_left.set(0.0); roll_ccw.set(0.0); roll_cw.set(0.0); + }, + on:click=move |event: MouseEvent| { + // find the nearest element along the pointer direction + let dir = event_dir(&event); + console::log_1(&JsValue::from(dir.to_string())); + let mut clicked: Option<(ElementKey, f64)> = None; + for (key, elt) in state.assembly.elements.get_clone_untracked() { + match assembly_to_world.with(|asm_to_world| elt.cast(dir, asm_to_world)) { + Some(depth) => match clicked { + Some((_, best_depth)) => { + if depth < best_depth { + clicked = Some((key, depth)) + } + }, + None => clicked = Some((key, depth)) + } + None => () + }; + } + + // if we clicked something, select it + match clicked { + Some((key, _)) => { + 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); + }) + } + }, + None => state.selection.update(|sel| sel.clear()) + }; } ) }