From b490c8707fd506c5d819692976bcbccd17127a9c Mon Sep 17 00:00:00 2001 From: Vectornaut Date: Wed, 27 Nov 2024 05:02:06 +0000 Subject: [PATCH] Click the display to select spheres (#25) On the incoming branch, you can select a sphere by clicking it in the display. Holding *shift* while clicking enables multiple selection. These controls match the ones already implemented in the outline view. Since the selection routine is now used in multiple places, the incoming branch factors it out into the `AppState::select` method. Co-authored-by: Aaron Fenyes Reviewed-on: https://code.studioinfinity.org/glen/dyna3/pulls/25 Co-authored-by: Vectornaut Co-committed-by: Vectornaut --- app-proto/Cargo.toml | 1 + app-proto/src/assembly.rs | 45 ++++++++++++++++++++++++++++++- app-proto/src/display.rs | 57 ++++++++++++++++++++++++++++++++++++--- app-proto/src/main.rs | 18 +++++++++++++ app-proto/src/outline.rs | 13 +-------- 5 files changed, 118 insertions(+), 16 deletions(-) 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..c39e575 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,31 @@ 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, _)) => state.select(key, event.shift_key()), + None => state.selection.update(|sel| sel.clear()) + }; } ) } diff --git a/app-proto/src/main.rs b/app-proto/src/main.rs index 897f9d4..8a012d3 100644 --- a/app-proto/src/main.rs +++ b/app-proto/src/main.rs @@ -25,6 +25,24 @@ impl AppState { selection: create_signal(FxHashSet::default()) } } + + // in single-selection mode, select the element with the given key. in + // multiple-selection mode, toggle whether the element with the given key + // is selected + fn select(&self, key: ElementKey, multi: bool) { + if multi { + self.selection.update(|sel| { + if !sel.remove(&key) { + sel.insert(key); + } + }); + } else { + self.selection.update(|sel| { + sel.clear(); + sel.insert(key); + }); + } + } } fn main() { diff --git a/app-proto/src/outline.rs b/app-proto/src/outline.rs index e2cf49c..148f870 100644 --- a/app-proto/src/outline.rs +++ b/app-proto/src/outline.rs @@ -83,18 +83,7 @@ fn ElementOutlineItem(key: ElementKey, element: assembly::Element) -> View { 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); - }); - } + state.select(key, event.shift_key()); event.prevent_default(); }, "ArrowRight" if constrained.get() => {