Unifies the interface elements for measuring and constraining real-valued observables, as proposed in issue #47. The resulting combination is called a "Regulator," at least in the code. They are presented as text inputs in the table view. When a Regulatore is in measurement mode (has no "set point"), the text field displays its value. Entering a desired value into the text field creates a set point, and then the Regulator acts to (attempt to) constrain the value. Setting the desired value to the empty string switches the observable back to measurement mode. If you enter a desired value that can't be parsed as a floating point number, the regulator input is flagged as invalid and it has no effect on the state of the regulator. The set point can in this case be restored to its previous value (or to no set point if that was its prior state) by pressing the "Esc" key. Co-authored-by: Aaron Fenyes <aaron.fenyes@fareycircles.ooo> Co-authored-by: glen <glen@studioinfinity.org> Reviewed-on: glen/dyna3#48 Co-authored-by: Vectornaut <vectornaut@nobody@nowhere.net> Co-committed-by: Vectornaut <vectornaut@nobody@nowhere.net>
461 lines
No EOL
18 KiB
Rust
461 lines
No EOL
18 KiB
Rust
use nalgebra::{DMatrix, DVector, DVectorView, Vector3};
|
|
use rustc_hash::FxHashMap;
|
|
use slab::Slab;
|
|
use std::{collections::BTreeSet, sync::atomic::{AtomicU64, Ordering}};
|
|
use sycamore::prelude::*;
|
|
use web_sys::{console, wasm_bindgen::JsValue}; /* DEBUG */
|
|
|
|
use crate::{
|
|
engine::{Q, local_unif_to_std, realize_gram, ConfigSubspace, PartialMatrix},
|
|
specified::SpecifiedValue
|
|
};
|
|
|
|
// the types of the keys we use to access an assembly's elements and regulators
|
|
pub type ElementKey = usize;
|
|
pub type RegulatorKey = usize;
|
|
|
|
pub type ElementColor = [f32; 3];
|
|
|
|
/* KLUDGE */
|
|
// we should reconsider this design when we build a system for switching between
|
|
// assemblies. at that point, we might want to switch to hierarchical keys,
|
|
// where each each element has a key that identifies it within its assembly and
|
|
// each assembly has a key that identifies it within the sesssion
|
|
static NEXT_ELEMENT_SERIAL: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub struct Element {
|
|
pub id: String,
|
|
pub label: String,
|
|
pub color: ElementColor,
|
|
pub representation: Signal<DVector<f64>>,
|
|
|
|
// All regulators with this element as a subject. The assembly owning
|
|
// this element is responsible for keeping this set up to date.
|
|
pub regulators: Signal<BTreeSet<RegulatorKey>>,
|
|
|
|
// a serial number, assigned by `Element::new`, that uniquely identifies
|
|
// each element
|
|
pub serial: u64,
|
|
|
|
// the configuration matrix column index that was assigned to this element
|
|
// last time the assembly was realized, or `None` if the element has never
|
|
// been through a realization
|
|
column_index: Option<usize>
|
|
}
|
|
|
|
impl Element {
|
|
pub fn new(
|
|
id: String,
|
|
label: String,
|
|
color: ElementColor,
|
|
representation: DVector<f64>
|
|
) -> Element {
|
|
// take the next serial number, panicking if that was the last number we
|
|
// had left. the technique we use to panic on overflow is taken from
|
|
// _Rust Atomics and Locks_, by Mara Bos
|
|
//
|
|
// https://marabos.nl/atomics/atomics.html#example-handle-overflow
|
|
//
|
|
let serial = NEXT_ELEMENT_SERIAL.fetch_update(
|
|
Ordering::SeqCst, Ordering::SeqCst,
|
|
|serial| serial.checked_add(1)
|
|
).expect("Out of serial numbers for elements");
|
|
|
|
Element {
|
|
id: id,
|
|
label: label,
|
|
color: color,
|
|
representation: create_signal(representation),
|
|
regulators: create_signal(BTreeSet::default()),
|
|
serial: serial,
|
|
column_index: None
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct Regulator {
|
|
pub subjects: (ElementKey, ElementKey),
|
|
pub measurement: ReadSignal<f64>,
|
|
pub set_point: Signal<SpecifiedValue>
|
|
}
|
|
|
|
// the velocity is expressed in uniform coordinates
|
|
pub struct ElementMotion<'a> {
|
|
pub key: ElementKey,
|
|
pub velocity: DVectorView<'a, f64>
|
|
}
|
|
|
|
type AssemblyMotion<'a> = Vec<ElementMotion<'a>>;
|
|
|
|
// a complete, view-independent description of an assembly
|
|
#[derive(Clone)]
|
|
pub struct Assembly {
|
|
// elements and regulators
|
|
pub elements: Signal<Slab<Element>>,
|
|
pub regulators: Signal<Slab<Regulator>>,
|
|
|
|
// solution variety tangent space. the basis vectors are stored in
|
|
// configuration matrix format, ordered according to the elements' column
|
|
// indices. when you realize the assembly, every element that's present
|
|
// during realization gets a column index and is reflected in the tangent
|
|
// space. since the methods in this module never assign column indices
|
|
// without later realizing the assembly, we get the following invariant:
|
|
//
|
|
// (1) if an element has a column index, its tangent motions can be found
|
|
// in that column of the tangent space basis matrices
|
|
//
|
|
pub tangent: Signal<ConfigSubspace>,
|
|
|
|
// indexing
|
|
pub elements_by_id: Signal<FxHashMap<String, ElementKey>>
|
|
}
|
|
|
|
impl Assembly {
|
|
pub fn new() -> Assembly {
|
|
Assembly {
|
|
elements: create_signal(Slab::new()),
|
|
regulators: create_signal(Slab::new()),
|
|
tangent: create_signal(ConfigSubspace::zero(0)),
|
|
elements_by_id: create_signal(FxHashMap::default())
|
|
}
|
|
}
|
|
|
|
// --- inserting elements and regulators ---
|
|
|
|
// 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::new(
|
|
id,
|
|
format!("Sphere {}", id_num),
|
|
[0.75_f32, 0.75_f32, 0.75_f32],
|
|
DVector::<f64>::from_column_slice(&[0.0, 0.0, 0.0, 0.5, -0.5])
|
|
)
|
|
);
|
|
}
|
|
|
|
fn insert_regulator(&self, regulator: Regulator) {
|
|
let subjects = regulator.subjects;
|
|
let key = self.regulators.update(|regs| regs.insert(regulator));
|
|
let subject_regulators = self.elements.with(
|
|
|elts| (elts[subjects.0].regulators, elts[subjects.1].regulators)
|
|
);
|
|
subject_regulators.0.update(|regs| regs.insert(key));
|
|
subject_regulators.1.update(|regs| regs.insert(key));
|
|
}
|
|
|
|
pub fn insert_new_regulator(self, subjects: (ElementKey, ElementKey)) {
|
|
// create and insert a new regulator
|
|
let measurement = self.elements.map(
|
|
move |elts| {
|
|
let reps = (
|
|
elts[subjects.0].representation.get_clone(),
|
|
elts[subjects.1].representation.get_clone()
|
|
);
|
|
reps.0.dot(&(&*Q * reps.1))
|
|
}
|
|
);
|
|
let set_point = create_signal(SpecifiedValue::from_empty_spec());
|
|
self.insert_regulator(Regulator {
|
|
subjects: subjects,
|
|
measurement: measurement,
|
|
set_point: set_point
|
|
});
|
|
|
|
/* DEBUG */
|
|
// print an updated list of regulators
|
|
console::log_1(&JsValue::from("Regulators:"));
|
|
self.regulators.with(|regs| {
|
|
for (_, reg) in regs.into_iter() {
|
|
console::log_5(
|
|
&JsValue::from(" "),
|
|
&JsValue::from(reg.subjects.0),
|
|
&JsValue::from(reg.subjects.1),
|
|
&JsValue::from(":"),
|
|
®.set_point.with_untracked(
|
|
|set_pt| JsValue::from(set_pt.spec.as_str())
|
|
)
|
|
);
|
|
}
|
|
});
|
|
|
|
// update the realization when the regulator becomes a constraint, or is
|
|
// edited while acting as a constraint
|
|
create_effect(move || {
|
|
console::log_1(&JsValue::from(
|
|
format!("Updated constraint with subjects ({}, {})", subjects.0, subjects.1)
|
|
));
|
|
if set_point.with(|set_pt| set_pt.is_present()) {
|
|
self.realize();
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- realization ---
|
|
|
|
pub fn realize(&self) {
|
|
// index the elements
|
|
self.elements.update_silent(|elts| {
|
|
for (index, (_, elt)) in elts.into_iter().enumerate() {
|
|
elt.column_index = Some(index);
|
|
}
|
|
});
|
|
|
|
// set up the Gram matrix and the initial configuration matrix
|
|
let (gram, guess) = self.elements.with_untracked(|elts| {
|
|
// set up the off-diagonal part of the Gram matrix
|
|
let mut gram_to_be = PartialMatrix::new();
|
|
self.regulators.with_untracked(|regs| {
|
|
for (_, reg) in regs {
|
|
reg.set_point.with_untracked(|set_pt| {
|
|
if let Some(val) = set_pt.value {
|
|
let subjects = reg.subjects;
|
|
let row = elts[subjects.0].column_index.unwrap();
|
|
let col = elts[subjects.1].column_index.unwrap();
|
|
gram_to_be.push_sym(row, col, val);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// set up the initial configuration matrix and the diagonal of the
|
|
// Gram matrix
|
|
let mut guess_to_be = DMatrix::<f64>::zeros(5, elts.len());
|
|
for (_, elt) in elts {
|
|
let index = elt.column_index.unwrap();
|
|
gram_to_be.push_sym(index, index, 1.0);
|
|
guess_to_be.set_column(index, &elt.representation.get_clone_untracked());
|
|
}
|
|
|
|
(gram_to_be, guess_to_be)
|
|
});
|
|
|
|
/* DEBUG */
|
|
// log the Gram matrix
|
|
console::log_1(&JsValue::from("Gram matrix:"));
|
|
gram.log_to_console();
|
|
|
|
/* DEBUG */
|
|
// log the initial configuration matrix
|
|
console::log_1(&JsValue::from("Old configuration:"));
|
|
for j in 0..guess.nrows() {
|
|
let mut row_str = String::new();
|
|
for k in 0..guess.ncols() {
|
|
row_str.push_str(format!(" {:>8.3}", guess[(j, k)]).as_str());
|
|
}
|
|
console::log_1(&JsValue::from(row_str));
|
|
}
|
|
|
|
// look for a configuration with the given Gram matrix
|
|
let (config, tangent, success, history) = realize_gram(
|
|
&gram, guess, &[],
|
|
1.0e-12, 0.5, 0.9, 1.1, 200, 110
|
|
);
|
|
|
|
/* DEBUG */
|
|
// report the outcome of the search
|
|
console::log_1(&JsValue::from(
|
|
if success {
|
|
"Target accuracy achieved!"
|
|
} else {
|
|
"Failed to reach target accuracy"
|
|
}
|
|
));
|
|
console::log_2(&JsValue::from("Steps:"), &JsValue::from(history.scaled_loss.len() - 1));
|
|
console::log_2(&JsValue::from("Loss:"), &JsValue::from(*history.scaled_loss.last().unwrap()));
|
|
console::log_2(&JsValue::from("Tangent dimension:"), &JsValue::from(tangent.dim()));
|
|
|
|
if success {
|
|
// read out the solution
|
|
for (_, elt) in self.elements.get_clone_untracked() {
|
|
elt.representation.update(
|
|
|rep| rep.set_column(0, &config.column(elt.column_index.unwrap()))
|
|
);
|
|
}
|
|
|
|
// save the tangent space
|
|
self.tangent.set_silent(tangent);
|
|
}
|
|
}
|
|
|
|
// --- deformation ---
|
|
|
|
// project the given motion to the tangent space of the solution variety and
|
|
// move the assembly along it. the implementation is based on invariant (1)
|
|
// from above and the following additional invariant:
|
|
//
|
|
// (2) if an element is affected by a constraint, it has a column index
|
|
//
|
|
// we have this invariant because the assembly gets realized each time you
|
|
// add a constraint
|
|
pub fn deform(&self, motion: AssemblyMotion) {
|
|
/* KLUDGE */
|
|
// when the tangent space is zero, deformation won't do anything, but
|
|
// the attempt to deform should be registered in the UI. this console
|
|
// message will do for now
|
|
if self.tangent.with(|tan| tan.dim() <= 0 && tan.assembly_dim() > 0) {
|
|
console::log_1(&JsValue::from("The assembly is rigid"));
|
|
}
|
|
|
|
// give a column index to each moving element that doesn't have one yet.
|
|
// this temporarily breaks invariant (1), but the invariant will be
|
|
// restored when we realize the assembly at the end of the deformation.
|
|
// in the process, we find out how many matrix columns we'll need to
|
|
// hold the deformation
|
|
let realized_dim = self.tangent.with(|tan| tan.assembly_dim());
|
|
let motion_dim = self.elements.update_silent(|elts| {
|
|
let mut next_column_index = realized_dim;
|
|
for elt_motion in motion.iter() {
|
|
let moving_elt = &mut elts[elt_motion.key];
|
|
if moving_elt.column_index.is_none() {
|
|
moving_elt.column_index = Some(next_column_index);
|
|
next_column_index += 1;
|
|
}
|
|
}
|
|
next_column_index
|
|
});
|
|
|
|
// project the element motions onto the tangent space of the solution
|
|
// variety and sum them to get a deformation of the whole assembly. the
|
|
// matrix `motion_proj` that holds the deformation has extra columns for
|
|
// any moving elements that aren't reflected in the saved tangent space
|
|
const ELEMENT_DIM: usize = 5;
|
|
let mut motion_proj = DMatrix::zeros(ELEMENT_DIM, motion_dim);
|
|
for elt_motion in motion {
|
|
// we can unwrap the column index because we know that every moving
|
|
// element has one at this point
|
|
let column_index = self.elements.with_untracked(
|
|
|elts| elts[elt_motion.key].column_index.unwrap()
|
|
);
|
|
|
|
if column_index < realized_dim {
|
|
// this element had a column index when we started, so by
|
|
// invariant (1), it's reflected in the tangent space
|
|
let mut target_columns = motion_proj.columns_mut(0, realized_dim);
|
|
target_columns += self.tangent.with(
|
|
|tan| tan.proj(&elt_motion.velocity, column_index)
|
|
);
|
|
} else {
|
|
// this element didn't have a column index when we started, so
|
|
// by invariant (2), it's unconstrained
|
|
let mut target_column = motion_proj.column_mut(column_index);
|
|
let unif_to_std = self.elements.with_untracked(
|
|
|elts| {
|
|
elts[elt_motion.key].representation.with_untracked(
|
|
|rep| local_unif_to_std(rep.as_view())
|
|
)
|
|
}
|
|
);
|
|
target_column += unif_to_std * elt_motion.velocity;
|
|
}
|
|
}
|
|
|
|
// step the assembly along the deformation. this changes the elements'
|
|
// normalizations, so we restore those afterward
|
|
/* KLUDGE */
|
|
// since our test assemblies only include spheres, we assume that every
|
|
// element is on the 1 mass shell
|
|
for (_, elt) in self.elements.get_clone_untracked() {
|
|
elt.representation.update_silent(|rep| {
|
|
match elt.column_index {
|
|
Some(column_index) => {
|
|
// step the assembly along the deformation
|
|
*rep += motion_proj.column(column_index);
|
|
|
|
// restore normalization by contracting toward the last
|
|
// coordinate axis
|
|
let q_sp = rep.fixed_rows::<3>(0).norm_squared();
|
|
let half_q_lt = -2.0 * rep[3] * rep[4];
|
|
let half_q_lt_sq = half_q_lt * half_q_lt;
|
|
let scaling = half_q_lt + (q_sp + half_q_lt_sq).sqrt();
|
|
rep.fixed_rows_mut::<4>(0).scale_mut(1.0 / scaling);
|
|
},
|
|
None => {
|
|
console::log_1(&JsValue::from(
|
|
format!("No velocity to unpack for fresh element \"{}\"", elt.id)
|
|
))
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// bring the configuration back onto the solution variety. this also
|
|
// gets the elements' column indices and the saved tangent space back in
|
|
// sync
|
|
self.realize();
|
|
}
|
|
} |