Compare commits

...

2 Commits

Author SHA1 Message Date
Aaron Fenyes
f4c9d3d7f4 App: factor out the selection routine 2024-11-25 18:58:56 -08:00
Aaron Fenyes
17d4ed86e4 Display: click to select spheres 2024-11-25 18:58:56 -08:00
5 changed files with 118 additions and 16 deletions

View File

@ -26,6 +26,7 @@ console_error_panic_hook = { version = "0.1.7", optional = true }
[dependencies.web-sys] [dependencies.web-sys]
version = "0.3.69" version = "0.3.69"
features = [ features = [
'DomRect',
'HtmlCanvasElement', 'HtmlCanvasElement',
'HtmlInputElement', 'HtmlInputElement',
'Performance', 'Performance',

View File

@ -1,4 +1,4 @@
use nalgebra::{DMatrix, DVector}; use nalgebra::{DMatrix, DVector, Vector3};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use slab::Slab; use slab::Slab;
use std::{collections::BTreeSet, sync::atomic::{AtomicU64, Ordering}}; use std::{collections::BTreeSet, sync::atomic::{AtomicU64, Ordering}};
@ -65,6 +65,49 @@ impl Element {
column_index: 0 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<f64>, assembly_to_world: &DMatrix<f64>) -> Option<f64> {
// 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
}
}
} }

View File

@ -4,7 +4,9 @@ use sycamore::{prelude::*, motion::create_raf};
use web_sys::{ use web_sys::{
console, console,
window, window,
Element,
KeyboardEvent, KeyboardEvent,
MouseEvent,
WebGl2RenderingContext, WebGl2RenderingContext,
WebGlProgram, WebGlProgram,
WebGlShader, WebGlShader,
@ -12,7 +14,7 @@ use web_sys::{
wasm_bindgen::{JsCast, JsValue} wasm_bindgen::{JsCast, JsValue}
}; };
use crate::AppState; use crate::{AppState, assembly::ElementKey};
fn compile_shader( fn compile_shader(
context: &WebGl2RenderingContext, 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<f64> {
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] #[component]
pub fn Display() -> View { pub fn Display() -> View {
let state = use_context::<AppState>(); let state = use_context::<AppState>();
@ -89,6 +109,9 @@ pub fn Display() -> View {
// canvas // canvas
let display = create_node_ref(); let display = create_node_ref();
// viewpoint
let assembly_to_world = create_signal(DMatrix::<f64>::identity(5, 5));
// navigation // navigation
let pitch_up = create_signal(0.0); let pitch_up = create_signal(0.0);
let pitch_down = 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 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 // get the assembly
let ( let (
@ -311,7 +334,7 @@ pub fn Display() -> View {
// representation vectors in world coordinates // representation vectors in world coordinates
elts.iter().map( elts.iter().map(
|(_, elt)| elt.representation.with(|rep| &assembly_to_world * rep) |(_, elt)| elt.representation.with(|rep| &asm_to_world * rep)
).collect::<Vec<_>>(), ).collect::<Vec<_>>(),
// colors // colors
@ -370,6 +393,9 @@ pub fn Display() -> View {
// draw the scene // draw the scene
ctx.draw_arrays(WebGl2RenderingContext::TRIANGLES, 0, VERTEX_CNT as i32); 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 // clear the scene change flag
scene_changed.set( scene_changed.set(
pitch_up_val != 0.0 pitch_up_val != 0.0
@ -458,6 +484,31 @@ pub fn Display() -> View {
yaw_left.set(0.0); yaw_left.set(0.0);
roll_ccw.set(0.0); roll_ccw.set(0.0);
roll_cw.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())
};
} }
) )
} }

View File

@ -25,6 +25,24 @@ impl AppState {
selection: create_signal(FxHashSet::default()) 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() { fn main() {

View File

@ -83,18 +83,7 @@ fn ElementOutlineItem(key: ElementKey, element: assembly::Element) -> View {
move |event: KeyboardEvent| { move |event: KeyboardEvent| {
match event.key().as_str() { match event.key().as_str() {
"Enter" => { "Enter" => {
if event.shift_key() { state.select(key, 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(); event.prevent_default();
}, },
"ArrowRight" if constrained.get() => { "ArrowRight" if constrained.get() => {