Integrate engine into application prototype #15

Merged
glen merged 24 commits from engine-integration into main 2024-11-12 00:46:16 +00:00
14 changed files with 947 additions and 93 deletions

View File

@ -10,6 +10,7 @@ default = ["console_error_panic_hook"]
[dependencies] [dependencies]
itertools = "0.13.0" itertools = "0.13.0"
js-sys = "0.3.70" js-sys = "0.3.70"
lazy_static = "1.5.0"
nalgebra = "0.33.0" nalgebra = "0.33.0"
rustc-hash = "2.0.0" rustc-hash = "2.0.0"
slab = "0.4.9" slab = "0.4.9"
@ -25,6 +26,7 @@ console_error_panic_hook = { version = "0.1.7", optional = true }
version = "0.3.69" version = "0.3.69"
features = [ features = [
'HtmlCanvasElement', 'HtmlCanvasElement',
'HtmlInputElement',
'Performance', 'Performance',
'WebGl2RenderingContext', 'WebGl2RenderingContext',
'WebGlBuffer', 'WebGlBuffer',

View File

@ -93,7 +93,7 @@ details[open]:has(li) .elt-switch::after {
display: flex; display: flex;
} }
.elt-rep > div, .cst-rep { .elt-rep > div {
padding: 2px 0px 0px 0px; padding: 2px 0px 0px 0px;
font-size: 10pt; font-size: 10pt;
text-align: center; text-align: center;
@ -104,10 +104,17 @@ details[open]:has(li) .elt-switch::after {
font-style: italic; font-style: italic;
} }
.cst > input { .cst > input[type=checkbox] {
margin: 0px 8px 0px 0px; margin: 0px 8px 0px 0px;
} }
.cst > input[type=text] {
color: #fcfcfc;
background-color: inherit;
border: 1px solid #555;
border-radius: 2px;
}
/* display */ /* display */
canvas { canvas {

8
app-proto/run-examples Executable file
View File

@ -0,0 +1,8 @@
# based on "Enabling print statements in Cargo tests", by Jon Almeida
#
# https://jonalmeida.com/posts/2015/01/23/print-cargo/
#
cargo test -- --nocapture engine::tests::irisawa_hexlet_test
cargo test -- --nocapture engine::tests::three_spheres_example
cargo test -- --nocapture engine::tests::point_on_sphere_example

View File

@ -11,8 +11,9 @@ fn load_gen_assemb(assembly: &Assembly) {
id: String::from("gemini_a"), id: String::from("gemini_a"),
label: String::from("Castor"), label: String::from("Castor"),
color: [1.00_f32, 0.25_f32, 0.00_f32], color: [1.00_f32, 0.25_f32, 0.00_f32],
rep: engine::sphere(0.5, 0.5, 0.0, 1.0), representation: engine::sphere(0.5, 0.5, 0.0, 1.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -20,8 +21,9 @@ fn load_gen_assemb(assembly: &Assembly) {
id: String::from("gemini_b"), id: String::from("gemini_b"),
label: String::from("Pollux"), label: String::from("Pollux"),
color: [0.00_f32, 0.25_f32, 1.00_f32], color: [0.00_f32, 0.25_f32, 1.00_f32],
rep: engine::sphere(-0.5, -0.5, 0.0, 1.0), representation: engine::sphere(-0.5, -0.5, 0.0, 1.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -29,8 +31,9 @@ fn load_gen_assemb(assembly: &Assembly) {
id: String::from("ursa_major"), id: String::from("ursa_major"),
label: String::from("Ursa major"), label: String::from("Ursa major"),
color: [0.25_f32, 0.00_f32, 1.00_f32], color: [0.25_f32, 0.00_f32, 1.00_f32],
rep: engine::sphere(-0.5, 0.5, 0.0, 0.75), representation: engine::sphere(-0.5, 0.5, 0.0, 0.75),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -38,8 +41,9 @@ fn load_gen_assemb(assembly: &Assembly) {
id: String::from("ursa_minor"), id: String::from("ursa_minor"),
label: String::from("Ursa minor"), label: String::from("Ursa minor"),
color: [0.25_f32, 1.00_f32, 0.00_f32], color: [0.25_f32, 1.00_f32, 0.00_f32],
rep: engine::sphere(0.5, -0.5, 0.0, 0.5), representation: engine::sphere(0.5, -0.5, 0.0, 0.5),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -47,8 +51,9 @@ fn load_gen_assemb(assembly: &Assembly) {
id: String::from("moon_deimos"), id: String::from("moon_deimos"),
label: String::from("Deimos"), label: String::from("Deimos"),
color: [0.75_f32, 0.75_f32, 0.00_f32], color: [0.75_f32, 0.75_f32, 0.00_f32],
rep: engine::sphere(0.0, 0.15, 1.0, 0.25), representation: engine::sphere(0.0, 0.15, 1.0, 0.25),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -56,18 +61,9 @@ fn load_gen_assemb(assembly: &Assembly) {
id: String::from("moon_phobos"), id: String::from("moon_phobos"),
label: String::from("Phobos"), label: String::from("Phobos"),
color: [0.00_f32, 0.75_f32, 0.50_f32], color: [0.00_f32, 0.75_f32, 0.50_f32],
rep: engine::sphere(0.0, -0.15, -1.0, 0.25), representation: engine::sphere(0.0, -0.15, -1.0, 0.25),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
} index: 0
);
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)
} }
); );
} }
@ -80,8 +76,9 @@ fn load_low_curv_assemb(assembly: &Assembly) {
id: "central".to_string(), id: "central".to_string(),
label: "Central".to_string(), label: "Central".to_string(),
color: [0.75_f32, 0.75_f32, 0.75_f32], color: [0.75_f32, 0.75_f32, 0.75_f32],
rep: engine::sphere(0.0, 0.0, 0.0, 1.0), representation: engine::sphere(0.0, 0.0, 0.0, 1.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -89,8 +86,9 @@ fn load_low_curv_assemb(assembly: &Assembly) {
id: "assemb_plane".to_string(), id: "assemb_plane".to_string(),
label: "Assembly plane".to_string(), label: "Assembly plane".to_string(),
color: [0.75_f32, 0.75_f32, 0.75_f32], 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), representation: engine::sphere_with_offset(0.0, 0.0, 1.0, 0.0, 0.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -98,8 +96,9 @@ fn load_low_curv_assemb(assembly: &Assembly) {
id: "side1".to_string(), id: "side1".to_string(),
label: "Side 1".to_string(), label: "Side 1".to_string(),
color: [1.00_f32, 0.00_f32, 0.25_f32], 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), representation: engine::sphere_with_offset(1.0, 0.0, 0.0, 1.0, 0.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -107,8 +106,9 @@ fn load_low_curv_assemb(assembly: &Assembly) {
id: "side2".to_string(), id: "side2".to_string(),
label: "Side 2".to_string(), label: "Side 2".to_string(),
color: [0.25_f32, 1.00_f32, 0.00_f32], color: [0.25_f32, 1.00_f32, 0.00_f32],
rep: engine::sphere_with_offset(-0.5, a, 0.0, 1.0, 0.0), representation: engine::sphere_with_offset(-0.5, a, 0.0, 1.0, 0.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -116,8 +116,9 @@ fn load_low_curv_assemb(assembly: &Assembly) {
id: "side3".to_string(), id: "side3".to_string(),
label: "Side 3".to_string(), label: "Side 3".to_string(),
color: [0.00_f32, 0.25_f32, 1.00_f32], color: [0.00_f32, 0.25_f32, 1.00_f32],
rep: engine::sphere_with_offset(-0.5, -a, 0.0, 1.0, 0.0), representation: engine::sphere_with_offset(-0.5, -a, 0.0, 1.0, 0.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -125,8 +126,9 @@ fn load_low_curv_assemb(assembly: &Assembly) {
id: "corner1".to_string(), id: "corner1".to_string(),
label: "Corner 1".to_string(), label: "Corner 1".to_string(),
color: [0.75_f32, 0.75_f32, 0.75_f32], 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), representation: engine::sphere(-4.0/3.0, 0.0, 0.0, 1.0/3.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -134,8 +136,9 @@ fn load_low_curv_assemb(assembly: &Assembly) {
id: "corner2".to_string(), id: "corner2".to_string(),
label: "Corner 2".to_string(), label: "Corner 2".to_string(),
color: [0.75_f32, 0.75_f32, 0.75_f32], 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), representation: engine::sphere(2.0/3.0, -4.0/3.0 * a, 0.0, 1.0/3.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -143,8 +146,9 @@ fn load_low_curv_assemb(assembly: &Assembly) {
id: String::from("corner3"), id: String::from("corner3"),
label: String::from("Corner 3"), label: String::from("Corner 3"),
color: [0.75_f32, 0.75_f32, 0.75_f32], 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), representation: engine::sphere(2.0/3.0, 4.0/3.0 * a, 0.0, 1.0/3.0),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
} }
@ -204,33 +208,51 @@ pub fn AddRemove() -> View {
}, },
on:click=|_| { on:click=|_| {
let state = use_context::<AppState>(); let state = use_context::<AppState>();
let args = state.selection.with( let subjects = state.selection.with(
|sel| { |sel| {
let arg_vec: Vec<_> = sel.into_iter().collect(); let subject_vec: Vec<_> = sel.into_iter().collect();
(arg_vec[0].clone(), arg_vec[1].clone()) (subject_vec[0].clone(), subject_vec[1].clone())
} }
); );
let lorentz_prod = create_signal(0.0);
let active = create_signal(true);
state.assembly.insert_constraint(Constraint { state.assembly.insert_constraint(Constraint {
args: args, subjects: subjects,
rep: 0.0, lorentz_prod: lorentz_prod,
active: create_signal(true) lorentz_prod_text: create_signal(String::new()),
lorentz_prod_valid: create_signal(false),
active: active,
}); });
state.assembly.realize();
state.selection.update(|sel| sel.clear()); state.selection.update(|sel| sel.clear());
/* DEBUG */ /* DEBUG */
// print updated constraint list // print updated constraint list
console::log_1(&JsValue::from("constraints:")); console::log_1(&JsValue::from("Constraints:"));
state.assembly.constraints.with(|csts| { state.assembly.constraints.with(|csts| {
for (_, cst) in csts.into_iter() { for (_, cst) in csts.into_iter() {
console::log_5( console::log_5(
&JsValue::from(" "), &JsValue::from(" "),
&JsValue::from(cst.args.0), &JsValue::from(cst.subjects.0),
&JsValue::from(cst.args.1), &JsValue::from(cst.subjects.1),
&JsValue::from(":"), &JsValue::from(":"),
&JsValue::from(cst.rep) &JsValue::from(cst.lorentz_prod.get_untracked())
); );
} }
}); });
// update the realization when the constraint activated, or
// edited while active
create_effect(move || {
lorentz_prod.track();
console::log_2(
&JsValue::from("Lorentz product updated to"),
&JsValue::from(lorentz_prod.get_untracked())
);
if active.get() {
state.assembly.realize();
}
});
} }
) { "🔗" } ) { "🔗" }
select(bind:value=assembly_name) { /* DEBUG */ select(bind:value=assembly_name) { /* DEBUG */

View File

@ -1,22 +1,40 @@
use nalgebra::DVector; use nalgebra::{DMatrix, DVector};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use slab::Slab; use slab::Slab;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use sycamore::prelude::*; use sycamore::prelude::*;
use web_sys::{console, wasm_bindgen::JsValue}; /* DEBUG */
use crate::engine::{realize_gram, PartialMatrix};
// the types of the keys we use to access an assembly's elements and constraints
pub type ElementKey = usize;
pub type ConstraintKey = usize;
pub type ElementColor = [f32; 3];
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub struct Element { pub struct Element {
pub id: String, pub id: String,
pub label: String, pub label: String,
pub color: [f32; 3], pub color: ElementColor,
pub rep: DVector<f64>, pub representation: DVector<f64>,
pub constraints: BTreeSet<usize> pub constraints: BTreeSet<ConstraintKey>,
// the configuration matrix column index that was assigned to this element
// last time the assembly was realized
/* TO DO */
// this is public, as a kludge, because `Element` doesn't have a constructor
// yet. it should be made private as soon as the constructor is written
pub index: usize
} }
#[derive(Clone)] #[derive(Clone)]
pub struct Constraint { pub struct Constraint {
pub args: (usize, usize), pub subjects: (ElementKey, ElementKey),
pub rep: f64, pub lorentz_prod: Signal<f64>,
pub lorentz_prod_text: Signal<String>,
glen marked this conversation as resolved
Review

But now I am confused; is this the string like 0.5 that represents the specified lorentz_prod of the constraint in the outline view? Why is that in the assembly? Or am I misunderstanding the import of this property?

If not, it seems like a violation of separation of concerns. The platonic reality of a constraint is just the number that the lorentz product has to be. The string is just an artifact of the outline view, so it should live there. To drive that point home more, we will surely at some point want to represent Constraints in the Display view. There, we might represent them as little translucent angle symbols, perhaps labeled with the angle in radians or radians/tau. It's unlikely that the raw lorentz product would appear in the graphical view. So an altogether different string would be involved.

But now I am confused; is this the string like 0.5 that represents the specified lorentz_prod of the constraint in the outline view? Why is that in the assembly? Or am I misunderstanding the import of this property? If not, it seems like a violation of separation of concerns. The platonic reality of a constraint is just the number that the lorentz product has to be. The string is just an artifact of the outline view, so it should live there. To drive that point home more, we will surely at some point want to represent Constraints in the Display view. There, we might represent them as little translucent angle symbols, perhaps labeled with the angle in radians or radians/tau. It's unlikely that the raw lorentz product would appear in the graphical view. So an altogether different string would be involved.
Review

is this the string like 0.5 that represents the specified lorentz_prod of the constraint in the outline view?

Yes. More generally, it's the reactive string that represents the Lorentz product in every view where it's edited or displayed as a string. Right now, this just means the two inputs that appear in the outline view, but in the future it might also include inputs in a Gram matrix view.

I agree that the model and view aspects of the Lorentz product should be separated better. One way might be to write a wrapper around Constraint to hold reactive values which need to stay synchronized across several views, but aren't part of the Platonic ideal assembly. However, I'd expect this way—or any other way—to lead to an extra layer of organization over the whole assembly. For that reason, I think that any attempt to do this should be its own dedicated pull request. Right now, I don't see the less-than-ideal organization as a major obstacle to developing a minimum viable product.

> is this the string like 0.5 that represents the specified lorentz_prod of the constraint in the outline view? Yes. More generally, it's the reactive string that represents the Lorentz product in every view where it's edited or displayed as a string. Right now, this just means the two inputs that appear in the outline view, but in the future it might also include inputs in a Gram matrix view. I agree that the model and view aspects of the Lorentz product should be separated better. One way might be to write a wrapper around `Constraint` to hold reactive values which need to stay synchronized across several views, but aren't part of the Platonic ideal assembly. However, I'd expect this way—or any other way—to lead to an extra layer of organization over the whole assembly. For that reason, I think that any attempt to do this should be its own dedicated pull request. Right now, I don't see the less-than-ideal organization as a major obstacle to developing a minimum viable product.
Review

Especially right now where the only occurrences of the string are in the outline view, it seems absolutely clear that this string (and its validity) should be housed in the view, not in the assembly. It should then be "wired up" so that when a valid string has been entered, that updates the actual constraint in the assembly; and vice-versa, if the actual constraint in the assembly becomes changed (by some other control, say), then the representing view-string should be updated to the string of the new value, and the representing validity-flag should be updated to true.

Just like in Numberscope when you enter invalid input for a parameter, it should not disable the associated entity, it should simply not (yet at that point) update it (so the last valid value will continue to be used, until there's a new valid input in the box). There should of course be some visible indication that the input is in a bad state; if you think it is important enough, that indication could in addition display that previous value that's still being used.

With this design, suppose there is a Gram view in the future. It has its input boxes with their reactive strings. When one of them is updated, that action will update the actual constraint in the Assembly. And that event in turn should update the reactive string in the Outline view. So everything remains harmonized. This is no different from dragging a point onto an entity in the Display to mean that it should have a new constraint of lying on that entity vs selecting the point and entity in the Outline view and hitting the "coincident" tool, or whatever. It's the type of thing that will come up over and over again as we create multiple ways of doing things that amount to the same action in the Assembly -- there will not be any synchronized state kept across views. They should synchronize by virtue of two-way communication with the Assembly.

I think it's important to do it this way, because if you think about actual use cases, I think it will be quite rare that there would really ever be a "reactive string that needs to stay synchronized across views". For example, as dyna3 matures, the outline view is going to express this sort of a constraint as an angle, and it will likely have a degrees or radians or radians/tau selector which will affect what string is used, while the Gram matrix will likely express it as the cosine-value of the angle, which will entail a different string.

For all these reasons, please do change the location/architecture of the reactive values that are not properly part of the model (Assembly) for this PR. Let's keep the models and the view-controllers well separated from the start. Thanks so much for your understanding!

Note that if there is a concept in the Assembly of a "turned-off constraint", then the on/off state of a constraint should be represented as such in the model, and views that display or allow control of that bit should have their needed apparatus to do so.

Especially right now where the only occurrences of the string are in the outline view, it seems absolutely clear that this string (and its validity) should be housed in the view, not in the assembly. It should then be "wired up" so that when a valid string has been entered, that updates the actual constraint in the assembly; and vice-versa, if the actual constraint in the assembly becomes changed (by some other control, say), then the representing view-string should be updated to the string of the new value, and the representing validity-flag should be updated to true. Just like in Numberscope when you enter invalid input for a parameter, it should not disable the associated entity, it should simply not (yet at that point) update it (so the last valid value will continue to be used, until there's a new valid input in the box). There should of course be some visible indication that the input is in a bad state; if you think it is important enough, that indication could in addition display that previous value that's still being used. With this design, suppose there is a Gram view in the future. It has its input boxes with their reactive strings. When one of them is updated, that action will update the actual constraint in the Assembly. And that event in turn should update the reactive string in the Outline view. So everything remains harmonized. This is no different from dragging a point onto an entity in the Display to mean that it should have a new constraint of lying on that entity vs selecting the point and entity in the Outline view and hitting the "coincident" tool, or whatever. It's the type of thing that will come up over and over again as we create multiple ways of doing things that amount to the same action in the Assembly -- there will not be any synchronized state kept across views. They should synchronize by virtue of two-way communication with the Assembly. I think it's important to do it this way, because if you think about actual use cases, I think it will be quite rare that there would really ever be a "reactive string that needs to stay synchronized across views". For example, as dyna3 matures, the outline view is going to express this sort of a constraint as an angle, and it will likely have a degrees or radians or radians/tau selector which will affect what string is used, while the Gram matrix will likely express it as the cosine-value of the angle, which will entail a _different_ string. For all these reasons, please do change the location/architecture of the reactive values that are not properly part of the model (Assembly) for this PR. Let's keep the models and the view-controllers well separated from the start. Thanks so much for your understanding! Note that if there is a concept in the Assembly of a "turned-off constraint", then the on/off state of a constraint should be represented as such in the model, and views that display or allow control of that bit should have their needed apparatus to do so.
Review

Just like in Numberscope when you enter invalid input for a parameter, it should not disable the associated entity, it should simply not (yet at that point) update it (so the last valid value will continue to be used, until there's a new valid input in the box). There should of course be some visible indication that the input is in a bad state; if you think it is important enough, that indication could in addition display that previous value that's still being used.

With this design, suppose there is a Gram view in the future. It has its input boxes with their reactive strings. When one of them is updated, that action will update the actual constraint in the Assembly. And that event in turn should update the reactive string in the Outline view. So everything remains harmonized.

That seems workable. I could probably implement it by moving the Lorentz product string into the LorentzProductInput component introduced in pull request #16.

For all these reasons, please do change the location/architecture of the reactive values that are not properly part of the model (Assembly) for this PR.

The likely costs of this will be:

  • The work I do to get this feature into this PR will be partly or entirely scrapped and redone for PR #16.
  • Merging #16 will take longer because of the additional merge conflicts.

Let me know whether you'd still like me to go ahead.

> Just like in Numberscope when you enter invalid input for a parameter, it should not disable the associated entity, it should simply not (yet at that point) update it (so the last valid value will continue to be used, until there's a new valid input in the box). There should of course be some visible indication that the input is in a bad state; if you think it is important enough, that indication could in addition display that previous value that's still being used. > With this design, suppose there is a Gram view in the future. It has its input boxes with their reactive strings. When one of them is updated, that action will update the actual constraint in the Assembly. And that event in turn should update the reactive string in the Outline view. So everything remains harmonized. That seems workable. I could probably implement it by moving the Lorentz product string into the `LorentzProductInput` component introduced in pull request #16. > For all these reasons, please do change the location/architecture of the reactive values that are not properly part of the model (Assembly) for this PR. The likely costs of this will be: - The work I do to get this feature into this PR will be partly or entirely scrapped and redone for PR #16. - Merging #16 will take longer because of the additional merge conflicts. Let me know whether you'd still like me to go ahead.
Review

Please file an issue to do this immediately after #16.

Please file an issue to do this immediately after #16.
pub lorentz_prod_valid: Signal<bool>,
glen marked this conversation as resolved
Review

It's unclear what it means for a lorentz_product to be "valid"

This signal keeps track of whether lorentz_prod_text can be parsed to a floating point number. Maybe the name lorentz_prod_specified would be more descriptive?

The realization routine only enforces a constraint when lorentz_prod_valid is true. This helps ensure that the engine is always enforcing the constraints that the user is looking at. In pull request #16, the interface flags constraints that aren't being enforced because the Lorentz product can't be parsed.

Like lorentz_prod_text, this signal is closer to the views than to the model, so the discussion about separation of concerns from this comment applies to it too.

> It's unclear what it means for a lorentz_product to be "valid" This signal keeps track of whether `lorentz_prod_text` can be parsed to a floating point number. Maybe the name `lorentz_prod_specified` would be more descriptive? The realization routine only enforces a constraint when `lorentz_prod_valid` is true. This helps ensure that the engine is always enforcing the constraints that the user is looking at. In pull request #16, the interface flags constraints that aren't being enforced because the Lorentz product can't be parsed. Like `lorentz_prod_text`, this signal is closer to the views than to the model, so the discussion about separation of concerns from [this comment](https://code.studioinfinity.org/glen/dyna3/pulls/15#issuecomment-1851) applies to it too.
Review

Yes, I think this will naturally go somewhere else and be called something clearer when the reactive strings that are part of the view go into the view.

Yes, I think this will naturally go somewhere else and be called something clearer when the reactive strings that are part of the view go into the view.
pub active: Signal<bool> pub active: Signal<bool>
} }
@ -28,7 +46,7 @@ pub struct Assembly {
pub constraints: Signal<Slab<Constraint>>, pub constraints: Signal<Slab<Constraint>>,
// indexing // indexing
pub elements_by_id: Signal<FxHashMap<String, usize>> pub elements_by_id: Signal<FxHashMap<String, ElementKey>>
} }
impl Assembly { impl Assembly {
@ -40,6 +58,8 @@ impl Assembly {
} }
} }
// --- inserting elements and constraints ---
// insert an element into the assembly without checking whether we already // insert an element into the assembly without checking whether we already
// have an element with the same identifier. any element that does have the // 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 // same identifier will get kicked out of the `elements_by_id` index
@ -76,18 +96,100 @@ impl Assembly {
id: id, id: id,
label: format!("Sphere {}", id_num), label: format!("Sphere {}", id_num),
color: [0.75_f32, 0.75_f32, 0.75_f32], color: [0.75_f32, 0.75_f32, 0.75_f32],
rep: DVector::<f64>::from_column_slice(&[0.0, 0.0, 0.0, 0.5, -0.5]), representation: DVector::<f64>::from_column_slice(&[0.0, 0.0, 0.0, 0.5, -0.5]),
constraints: BTreeSet::default() constraints: BTreeSet::default(),
index: 0
} }
); );
} }
pub fn insert_constraint(&self, constraint: Constraint) { pub fn insert_constraint(&self, constraint: Constraint) {
let args = constraint.args; let subjects = constraint.subjects;
let key = self.constraints.update(|csts| csts.insert(constraint)); let key = self.constraints.update(|csts| csts.insert(constraint));
self.elements.update(|elts| { self.elements.update(|elts| {
elts[args.0].constraints.insert(key); elts[subjects.0].constraints.insert(key);
elts[args.1].constraints.insert(key); elts[subjects.1].constraints.insert(key);
}) });
}
// --- realization ---
pub fn realize(&self) {
// index the elements
self.elements.update_silent(|elts| {
for (index, (_, elt)) in elts.into_iter().enumerate() {
elt.index = 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.constraints.with_untracked(|csts| {
for (_, cst) in csts {
if cst.active.get_untracked() && cst.lorentz_prod_valid.get_untracked() {
let subjects = cst.subjects;
let row = elts[subjects.0].index;
let col = elts[subjects.1].index;
gram_to_be.push_sym(row, col, cst.lorentz_prod.get_untracked());
}
}
});
// 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.index;
gram_to_be.push_sym(index, index, 1.0);
guess_to_be.set_column(index, &elt.representation);
}
(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, success, history) = realize_gram(
&gram, guess, &[],
1.0e-12, 0.5, 0.9, 1.1, 200, 110
);
/* DEBUG */
glen marked this conversation as resolved
Review

Are the _1 and _2 suffixes the number of arguments? I take it rust doesn't have functions with a varying number of arguments? Or do they mean something else?

Are the _1 and _2 suffixes the number of arguments? I take it rust doesn't have functions with a varying number of arguments? Or do they mean something else?
Review

Are the _1 and _2 suffixes the number of arguments?

Yes—log_0 through log_7 are convenience wrappers for log, which takes an array of things to log.

> Are the _1 and _2 suffixes the number of arguments? Yes—[`log_0`](https://docs.rs/web-sys/latest/web_sys/console/fn.log_0.html) through [`log_7`](https://docs.rs/web-sys/latest/web_sys/console/fn.log_7.html) are convenience wrappers for [`log`](https://docs.rs/web-sys/latest/web_sys/console/fn.log.html), which takes an array of things to log.
Review

Is that a Rustism? Or a local convenience? It's pretty awful; I hope we can avoid it in the long run, one way or another.

Is that a Rustism? Or a local convenience? It's pretty awful; I hope we can avoid it in the long run, one way or another.
// 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()));
if success {
// read out the solution
self.elements.update(|elts| {
for (_, elt) in elts.iter_mut() {
elt.representation.set_column(0, &config.column(elt.index));
}
});
}
} }
} }

View File

@ -297,7 +297,9 @@ pub fn Display() -> View {
// get the assembly // get the assembly
let elements = state.assembly.elements.get_clone(); let elements = state.assembly.elements.get_clone();
let element_iter = (&elements).into_iter(); let element_iter = (&elements).into_iter();
let reps_world: Vec<_> = element_iter.clone().map(|(_, elt)| &assembly_to_world * &elt.rep).collect(); let reps_world: Vec<_> = element_iter.clone().map(
|(_, elt)| &assembly_to_world * &elt.representation
).collect();
let colors: Vec<_> = element_iter.clone().map(|(key, elt)| let colors: Vec<_> = element_iter.clone().map(|(key, elt)|
if state.selection.with(|sel| sel.contains(&key)) { if state.selection.with(|sel| sel.contains(&key)) {
elt.color.map(|ch| 0.2 + 0.8*ch) elt.color.map(|ch| 0.2 + 0.8*ch)

View File

@ -1,4 +1,13 @@
use nalgebra::DVector; use lazy_static::lazy_static;
use nalgebra::{Const, DMatrix, DVector, Dyn};
use web_sys::{console, wasm_bindgen::JsValue}; /* DEBUG */
// --- elements ---
#[cfg(test)]
pub fn point(x: f64, y: f64, z: f64) -> DVector<f64> {
DVector::from_column_slice(&[x, y, z, 0.5, 0.5*(x*x + y*y + z*z)])
}
// the sphere with the given center and radius, with inward-pointing normals // 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<f64> { pub fn sphere(center_x: f64, center_y: f64, center_z: f64, radius: f64) -> DVector<f64> {
@ -24,4 +33,508 @@ pub fn sphere_with_offset(dir_x: f64, dir_y: f64, dir_z: f64, off: f64, curv: f6
0.5 * curv, 0.5 * curv,
off * (1.0 + 0.5 * off * curv) off * (1.0 + 0.5 * off * curv)
]) ])
}
// --- partial matrices ---
struct MatrixEntry {
index: (usize, usize),
value: f64
}
pub struct PartialMatrix(Vec<MatrixEntry>);
impl PartialMatrix {
pub fn new() -> PartialMatrix {
PartialMatrix(Vec::<MatrixEntry>::new())
}
pub fn push_sym(&mut self, row: usize, col: usize, value: f64) {
let PartialMatrix(entries) = self;
entries.push(MatrixEntry { index: (row, col), value: value });
if row != col {
entries.push(MatrixEntry { index: (col, row), value: value });
}
}
/* DEBUG */
pub fn log_to_console(&self) {
let PartialMatrix(entries) = self;
for ent in entries {
let ent_str = format!(" {} {} {}", ent.index.0, ent.index.1, ent.value);
console::log_1(&JsValue::from(ent_str.as_str()));
}
}
fn proj(&self, a: &DMatrix<f64>) -> DMatrix<f64> {
let mut result = DMatrix::<f64>::zeros(a.nrows(), a.ncols());
let PartialMatrix(entries) = self;
for ent in entries {
result[ent.index] = a[ent.index];
}
result
}
fn sub_proj(&self, rhs: &DMatrix<f64>) -> DMatrix<f64> {
let mut result = DMatrix::<f64>::zeros(rhs.nrows(), rhs.ncols());
let PartialMatrix(entries) = self;
for ent in entries {
result[ent.index] = ent.value - rhs[ent.index];
}
result
}
}
// --- descent history ---
pub struct DescentHistory {
pub config: Vec<DMatrix<f64>>,
pub scaled_loss: Vec<f64>,
pub neg_grad: Vec<DMatrix<f64>>,
pub min_eigval: Vec<f64>,
pub base_step: Vec<DMatrix<f64>>,
pub backoff_steps: Vec<i32>
}
impl DescentHistory {
fn new() -> DescentHistory {
DescentHistory {
config: Vec::<DMatrix<f64>>::new(),
scaled_loss: Vec::<f64>::new(),
neg_grad: Vec::<DMatrix<f64>>::new(),
min_eigval: Vec::<f64>::new(),
base_step: Vec::<DMatrix<f64>>::new(),
backoff_steps: Vec::<i32>::new(),
}
}
}
// --- gram matrix realization ---
// the Lorentz form
lazy_static! {
static ref Q: DMatrix<f64> = DMatrix::from_row_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, 0.0,
0.0, 0.0, 0.0, 0.0, -2.0,
0.0, 0.0, 0.0, -2.0, 0.0
]);
}
glen marked this conversation as resolved
Review

The coordinates are reordered from the notes, I believe, with the "position" coordinates first, and then the bend & coradius. And the sign is flipped from that Q, right? But the relative scaling of the position and radius terms seems strange to me; in the notes it's (cr/2 + rc/2) - xx - yy - zz; here it seems to be xx + yy + zz - 2cr -2rc. I know we discussed at one point what effect that relative factor of 4 between the radii and position terms has on all of the claims in the inversive notes; my apologies for having forgotten/still being confused on the details. Please put some comments either here or in the notes file on this topic to clarify how the actual coordinates we are using compare to the ones in the inversive notes. Thanks!

The coordinates are reordered from the notes, I believe, with the "position" coordinates first, and then the bend & coradius. And the sign is flipped from that Q, right? But the relative scaling of the position and radius terms seems strange to me; in the notes it's (cr/2 + rc/2) - xx - yy - zz; here it seems to be xx + yy + zz - 2cr -2rc. I know we discussed at one point what effect that relative factor of 4 between the radii and position terms has on all of the claims in the inversive notes; my apologies for having forgotten/still being confused on the details. Please put some comments either here or in the notes file on this topic to clarify how the _actual_ coordinates we are using compare to the ones in the inversive notes. Thanks!
Review

I've added a section about this to the notes!

I've added a section about this to the notes!
Review

Ah, that's very helpful. I had forgotten that the coordinates changed, so that the Lorentz product only flipped sign, so that all of the claims about the values of the Lorentz product in the notes are still true for Q' just with the signs flipped.

Just remember that at whatever point we add a "radius" constraint to a sphere, that the constraint itself is represented by the radius, which is the salient platonic characteristic of that constraint; the fact that the constraint is achieved by setting some coordinate matrix entry to 1/2r is secondary.

In that vein, we should both be clear about the fact that currently a Constraint is represented by "set the Gram matrix entry at this position to blah" is temporary, for this PR and the run-up to definitely settling on a first-effort engine. That's not a proper part of the platonic reality being modeled; it wouldn't be preserved across the switch to a totally different representation/optimization method, which is a good test for what could ultimately be in the Assembly. So as soon as we have decided that this is the engine we're going to go with for "ver 0.1", so to speak, we need an issue to replace the sort of Constraint that's there now with platonically viable ones: "angle between elements", "elements are tangent", "elements are parallel", "point lies on element", etc. etc.; these then get translated in the engine to the forms in which they can be practically enforced in that particular engine.

Ah, that's very helpful. I had forgotten that the coordinates changed, so that the Lorentz product only flipped sign, so that all of the claims about the values of the Lorentz product in the notes are still true for $Q'$ just with the signs flipped. Just remember that at whatever point we add a "radius" constraint to a sphere, that the _constraint_ itself is represented by the radius, which is the salient platonic characteristic of that constraint; the fact that the constraint is _achieved_ by setting some coordinate matrix entry to 1/2r is secondary. In that vein, we should both be clear about the fact that currently a Constraint is represented by "set the Gram matrix entry at this position to blah" is temporary, for this PR and the run-up to definitely settling on a first-effort engine. That's not a proper part of the platonic reality being modeled; it wouldn't be preserved across the switch to a totally different representation/optimization method, which is a good test for what could ultimately be in the Assembly. So as soon as we have decided that this is the engine we're going to go with for "ver 0.1", so to speak, we need an issue to replace the sort of Constraint that's there now with platonically viable ones: "angle between elements", "elements are tangent", "elements are parallel", "point lies on element", etc. etc.; these then get translated in the engine to the forms in which they can be practically enforced in that particular engine.
struct SearchState {
config: DMatrix<f64>,
err_proj: DMatrix<f64>,
loss: f64
}
impl SearchState {
fn from_config(gram: &PartialMatrix, config: DMatrix<f64>) -> SearchState {
let err_proj = gram.sub_proj(&(config.tr_mul(&*Q) * &config));
let loss = err_proj.norm_squared();
SearchState {
config: config,
err_proj: err_proj,
loss: loss
}
}
}
fn basis_matrix(index: (usize, usize), nrows: usize, ncols: usize) -> DMatrix<f64> {
let mut result = DMatrix::<f64>::zeros(nrows, ncols);
result[index] = 1.0;
result
}
// use backtracking line search to find a better configuration
fn seek_better_config(
gram: &PartialMatrix,
state: &SearchState,
base_step: &DMatrix<f64>,
base_target_improvement: f64,
min_efficiency: f64,
backoff: f64,
max_backoff_steps: i32
) -> Option<(SearchState, i32)> {
let mut rate = 1.0;
for backoff_steps in 0..max_backoff_steps {
let trial_config = &state.config + rate * base_step;
let trial_state = SearchState::from_config(gram, trial_config);
let improvement = state.loss - trial_state.loss;
if improvement >= min_efficiency * rate * base_target_improvement {
return Some((trial_state, backoff_steps));
}
rate *= backoff;
}
None
}
// seek a matrix `config` for which `config' * Q * config` matches the partial
// matrix `gram`. use gradient descent starting from `guess`
pub fn realize_gram(
gram: &PartialMatrix,
guess: DMatrix<f64>,
frozen: &[(usize, usize)],
scaled_tol: f64,
min_efficiency: f64,
backoff: f64,
reg_scale: f64,
max_descent_steps: i32,
max_backoff_steps: i32
) -> (DMatrix<f64>, bool, DescentHistory) {
// start the descent history
let mut history = DescentHistory::new();
// find the dimension of the search space
let element_dim = guess.nrows();
let assembly_dim = guess.ncols();
let total_dim = element_dim * assembly_dim;
// scale the tolerance
let scale_adjustment = (gram.0.len() as f64).sqrt();
let tol = scale_adjustment * scaled_tol;
// convert the frozen indices to stacked format
let frozen_stacked: Vec<usize> = frozen.into_iter().map(
|index| index.1*element_dim + index.0
).collect();
// use Newton's method with backtracking and gradient descent backup
let mut state = SearchState::from_config(gram, guess);
for _ in 0..max_descent_steps {
// stop if the loss is tolerably low
history.config.push(state.config.clone());
history.scaled_loss.push(state.loss / scale_adjustment);
if state.loss < tol { break; }
// find the negative gradient of the loss function
let neg_grad = 4.0 * &*Q * &state.config * &state.err_proj;
let mut neg_grad_stacked = neg_grad.clone().reshape_generic(Dyn(total_dim), Const::<1>);
history.neg_grad.push(neg_grad.clone());
// find the negative Hessian of the loss function
let mut hess_cols = Vec::<DVector<f64>>::with_capacity(total_dim);
for col in 0..assembly_dim {
for row in 0..element_dim {
let index = (row, col);
let basis_mat = basis_matrix(index, element_dim, assembly_dim);
let neg_d_err =
basis_mat.tr_mul(&*Q) * &state.config
+ state.config.tr_mul(&*Q) * &basis_mat;
let neg_d_err_proj = gram.proj(&neg_d_err);
let deriv_grad = 4.0 * &*Q * (
-&basis_mat * &state.err_proj
+ &state.config * &neg_d_err_proj
);
hess_cols.push(deriv_grad.reshape_generic(Dyn(total_dim), Const::<1>));
}
}
let mut hess = DMatrix::from_columns(hess_cols.as_slice());
// regularize the Hessian
let min_eigval = hess.symmetric_eigenvalues().min();
if min_eigval <= 0.0 {
hess -= reg_scale * min_eigval * DMatrix::identity(total_dim, total_dim);
}
history.min_eigval.push(min_eigval);
// project the negative gradient and negative Hessian onto the
// orthogonal complement of the frozen subspace
let zero_col = DVector::zeros(total_dim);
let zero_row = zero_col.transpose();
for &k in &frozen_stacked {
neg_grad_stacked[k] = 0.0;
hess.set_row(k, &zero_row);
hess.set_column(k, &zero_col);
hess[(k, k)] = 1.0;
}
// compute the Newton step
/*
we need to either handle or eliminate the case where the minimum
eigenvalue of the Hessian is zero, so the regularized Hessian is
singular. right now, this causes the Cholesky decomposition to return
`None`, leading to a panic when we unrap
*/
let base_step_stacked = hess.cholesky().unwrap().solve(&neg_grad_stacked);
let base_step = base_step_stacked.reshape_generic(Dyn(element_dim), Dyn(assembly_dim));
history.base_step.push(base_step.clone());
// use backtracking line search to find a better configuration
match seek_better_config(
gram, &state, &base_step, neg_grad.dot(&base_step),
min_efficiency, backoff, max_backoff_steps
) {
Some((better_state, backoff_steps)) => {
state = better_state;
history.backoff_steps.push(backoff_steps);
},
None => return (state.config, false, history)
};
}
(state.config, state.loss < tol, history)
}
// --- tests ---
#[cfg(test)]
glen marked this conversation as resolved
Review

love that there are tests right in here, we definitely want to keep that up and have them be as pervasive as possible.

love that there are tests right in here, we definitely want to keep that up and have them be as pervasive as possible.
Review

Yes, this is one of the recommended ways to organize tests in Rust!

Yes, this is one of the [recommended ways](https://doc.rust-lang.org/book/ch11-03-test-organization.html) to organize tests in Rust!
mod tests {
use std::{array, f64::consts::PI};
use super::*;
#[test]
fn sub_proj_test() {
let target = PartialMatrix(vec![
MatrixEntry { index: (0, 0), value: 19.0 },
MatrixEntry { index: (0, 2), value: 39.0 },
MatrixEntry { index: (1, 1), value: 59.0 },
MatrixEntry { index: (1, 2), value: 69.0 }
]);
let attempt = DMatrix::<f64>::from_row_slice(2, 3, &[
1.0, 2.0, 3.0,
4.0, 5.0, 6.0
]);
let expected_result = DMatrix::<f64>::from_row_slice(2, 3, &[
18.0, 0.0, 36.0,
0.0, 54.0, 63.0
]);
assert_eq!(target.sub_proj(&attempt), expected_result);
}
#[test]
fn zero_loss_test() {
let gram = PartialMatrix({
let mut entries = Vec::<MatrixEntry>::new();
for j in 0..3 {
for k in 0..3 {
entries.push(MatrixEntry {
index: (j, k),
value: if j == k { 1.0 } else { -1.0 }
});
}
}
entries
});
let config = {
let a: f64 = 0.75_f64.sqrt();
DMatrix::from_columns(&[
sphere(1.0, 0.0, 0.0, a),
sphere(-0.5, a, 0.0, a),
sphere(-0.5, -a, 0.0, a)
])
};
let state = SearchState::from_config(&gram, config);
assert!(state.loss.abs() < f64::EPSILON);
}
// this problem is from a sangaku by Irisawa Shintarō Hiroatsu. the article
// below includes a nice translation of the problem statement, which was
// recorded in Uchida Itsumi's book _Kokon sankan_ (_Mathematics, Past and
// Present_)
//
// "Japan's 'Wasan' Mathematical Tradition", by Abe Haruki
// https://www.nippon.com/en/japan-topics/c12801/
//
#[test]
fn irisawa_hexlet_test() {
let gram = PartialMatrix({
let mut entries = Vec::<MatrixEntry>::new();
for s in 0..9 {
// each sphere is represented by a spacelike vector
entries.push(MatrixEntry { index: (s, s), value: 1.0 });
// the circumscribing sphere is tangent to all of the other
// spheres, with matching orientation
if s > 0 {
entries.push(MatrixEntry { index: (0, s), value: 1.0 });
entries.push(MatrixEntry { index: (s, 0), value: 1.0 });
}
if s > 2 {
// each chain sphere is tangent to the "sun" and "moon"
// spheres, with opposing orientation
for n in 1..3 {
entries.push(MatrixEntry { index: (s, n), value: -1.0 });
entries.push(MatrixEntry { index: (n, s), value: -1.0 });
}
// each chain sphere is tangent to the next chain sphere,
// with opposing orientation
let s_next = 3 + (s-2) % 6;
entries.push(MatrixEntry { index: (s, s_next), value: -1.0 });
entries.push(MatrixEntry { index: (s_next, s), value: -1.0 });
}
}
entries
});
let guess = DMatrix::from_columns(
[
sphere(0.0, 0.0, 0.0, 15.0),
sphere(0.0, 0.0, -9.0, 5.0),
sphere(0.0, 0.0, 11.0, 3.0)
].into_iter().chain(
(1..=6).map(
|k| {
let ang = (k as f64) * PI/3.0;
sphere(9.0 * ang.cos(), 9.0 * ang.sin(), 0.0, 2.5)
}
)
).collect::<Vec<_>>().as_slice()
);
let frozen: [(usize, usize); 4] = array::from_fn(|k| (3, k));
const SCALED_TOL: f64 = 1.0e-12;
let (config, success, history) = realize_gram(
&gram, guess, &frozen,
SCALED_TOL, 0.5, 0.9, 1.1, 200, 110
);
let entry_tol = SCALED_TOL.sqrt();
let solution_diams = [30.0, 10.0, 6.0, 5.0, 15.0, 10.0, 3.75, 2.5, 2.0 + 8.0/11.0];
for (k, diam) in solution_diams.into_iter().enumerate() {
assert!((config[(3, k)] - 1.0 / diam).abs() < entry_tol);
}
print!("\nCompleted Gram matrix:{}", config.tr_mul(&*Q) * &config);
if success {
println!("Target accuracy achieved!");
} else {
println!("Failed to reach target accuracy");
}
println!("Steps: {}", history.scaled_loss.len() - 1);
println!("Loss: {}", history.scaled_loss.last().unwrap());
if success {
println!("\nChain diameters:");
println!(" {} sun (given)", 1.0 / config[(3, 3)]);
for k in 4..9 {
println!(" {} sun", 1.0 / config[(3, k)]);
}
}
println!("\nStep │ Loss\n─────┼────────────────────────────────");
for (step, scaled_loss) in history.scaled_loss.into_iter().enumerate() {
println!("{:<4}{}", step, scaled_loss);
}
}
// --- process inspection examples ---
// these tests are meant for human inspection, not automated use. run them
// one at a time in `--nocapture` mode and read through the results and
// optimization histories that they print out. the `run-examples` script
// will run all of them
#[test]
fn three_spheres_example() {
let gram = PartialMatrix({
let mut entries = Vec::<MatrixEntry>::new();
for j in 0..3 {
for k in 0..3 {
entries.push(MatrixEntry {
index: (j, k),
value: if j == k { 1.0 } else { -1.0 }
});
}
}
entries
});
let guess = {
let a: f64 = 0.75_f64.sqrt();
DMatrix::from_columns(&[
sphere(1.0, 0.0, 0.0, 1.0),
sphere(-0.5, a, 0.0, 1.0),
sphere(-0.5, -a, 0.0, 1.0)
])
};
println!();
let (config, success, history) = realize_gram(
&gram, guess, &[],
1.0e-12, 0.5, 0.9, 1.1, 200, 110
);
print!("\nCompleted Gram matrix:{}", config.tr_mul(&*Q) * &config);
if success {
println!("Target accuracy achieved!");
} else {
println!("Failed to reach target accuracy");
}
println!("Steps: {}", history.scaled_loss.len() - 1);
println!("Loss: {}", history.scaled_loss.last().unwrap());
println!("\nStep │ Loss\n─────┼────────────────────────────────");
for (step, scaled_loss) in history.scaled_loss.into_iter().enumerate() {
println!("{:<4}{}", step, scaled_loss);
}
}
#[test]
fn point_on_sphere_example() {
let gram = PartialMatrix({
let mut entries = Vec::<MatrixEntry>::new();
for j in 0..2 {
for k in 0..2 {
entries.push(MatrixEntry {
index: (j, k),
value: if (j, k) == (1, 1) { 1.0 } else { 0.0 }
});
}
}
entries
});
let guess = DMatrix::from_columns(&[
point(0.0, 0.0, 2.0),
sphere(0.0, 0.0, 0.0, 1.0)
]);
let frozen = [(3, 0)];
println!();
let (config, success, history) = realize_gram(
&gram, guess, &frozen,
1.0e-12, 0.5, 0.9, 1.1, 200, 110
);
print!("\nCompleted Gram matrix:{}", config.tr_mul(&*Q) * &config);
print!("Configuration:{}", config);
if success {
println!("Target accuracy achieved!");
} else {
println!("Failed to reach target accuracy");
}
println!("Steps: {}", history.scaled_loss.len() - 1);
println!("Loss: {}", history.scaled_loss.last().unwrap());
println!("\nStep │ Loss\n─────┼────────────────────────────────");
for (step, scaled_loss) in history.scaled_loss.into_iter().enumerate() {
println!("{:<4}{}", step, scaled_loss);
}
}
/* TO DO */
// --- new test placed here to avoid merge conflict ---
// at the frozen indices, the optimization steps should have exact zeros,
// and the realized configuration should match the initial guess
#[test]
fn frozen_entry_test() {
let gram = {
let mut gram_to_be = PartialMatrix::new();
for j in 0..2 {
for k in j..2 {
gram_to_be.push_sym(j, k, if (j, k) == (1, 1) { 1.0 } else { 0.0 });
}
}
gram_to_be
};
let guess = DMatrix::from_columns(&[
point(0.0, 0.0, 2.0),
sphere(0.0, 0.0, 0.0, 1.0)
]);
let frozen = [(3, 0), (3, 1)];
println!();
let (config, success, history) = realize_gram(
&gram, guess.clone(), &frozen,
1.0e-12, 0.5, 0.9, 1.1, 200, 110
);
assert_eq!(success, true);
for base_step in history.base_step.into_iter() {
for index in frozen {
assert_eq!(base_step[index], 0.0);
}
}
for index in frozen {
assert_eq!(config[index], guess[index]);
}
}
} }

View File

@ -8,14 +8,14 @@ use rustc_hash::FxHashSet;
use sycamore::prelude::*; use sycamore::prelude::*;
use add_remove::AddRemove; use add_remove::AddRemove;
use assembly::Assembly; use assembly::{Assembly, ElementKey};
use display::Display; use display::Display;
use outline::Outline; use outline::Outline;
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
assembly: Assembly, assembly: Assembly,
selection: Signal<FxHashSet<usize>> selection: Signal<FxHashSet<ElementKey>>
} }
impl AppState { impl AppState {

View File

@ -1,12 +1,40 @@
use itertools::Itertools; use itertools::Itertools;
glen marked this conversation as resolved
Review

Definitely at some point we are going to need to separate the src directory into parts for the Assembly/Engine and for each of the views, but fine if you don't think that's warranted yet. On the other hand, if you just want to set up a reasonable hierarchy now and see how it works, that's fine too.

Definitely at some point we are going to need to separate the src directory into parts for the Assembly/Engine and for each of the views, but fine if you don't think that's warranted yet. On the other hand, if you just want to set up a reasonable hierarchy now and see how it works, that's fine too.
Review

Definitely at some point we are going to need to separate the src directory into parts for the Assembly/Engine [...]

Yeah, I think we'll probably want separate modules for "front of house" and "back of house" code at some point. Right now it does seem like the engine and assembly modules should go together in the "back of house" module, but I could imagine that changing, since the assembly structure is pretty tailored to the needs of the user interface.

[...] and for each of the views

Note that the Display and Outline views each have their own module already: that's why the expressions mod display; and mod outline; appear at the top of main.rs.

> Definitely at some point we are going to need to separate the src directory into parts for the Assembly/Engine [...] Yeah, I think we'll probably want separate modules for "front of house" and "back of house" code at some point. Right now it does seem like the `engine` and `assembly` modules should go together in the "back of house" module, but I could imagine that changing, since the assembly structure is pretty tailored to the needs of the user interface. > [...] and for each of the views Note that the `Display` and `Outline` views each have their own module already: that's why the expressions `mod display;` and `mod outline;` appear at the top of `main.rs`.
Review

I was commenting on the fact that outline is in the same directory as assembly and engine. I guess the module structure may not be immediately clear to the untrained eye; but directory structure is.

Also be cautious about the notion that "assembly is tailored to needs of UI". The driver for what goes in Assembly is the facts of the platonic universe we are observing. So color and label are OK because the sphere labeled George is red in the platonic universe. But unless "hidden" means that an entity must be hidden in all possible views -- and I doubt it should -- that should not be part of Assembly. (That's just an example, I know there is not a "hidden" property yet.)

I was commenting on the fact that outline is in the same directory as assembly and engine. I guess the module structure may not be immediately clear to the untrained eye; but directory structure is. Also be cautious about the notion that "assembly is tailored to needs of UI". The driver for what goes in Assembly is the facts of the platonic universe we are observing. So color and label are OK because the sphere labeled George is red in the platonic universe. But unless "hidden" means that an entity must be hidden in all possible views -- and I doubt it should -- that should not be part of Assembly. (That's just an example, I know there is not a "hidden" property yet.)
use sycamore::{prelude::*, web::tags::div}; use sycamore::{prelude::*, web::tags::div};
use web_sys::{Element, KeyboardEvent, MouseEvent, wasm_bindgen::JsCast}; use web_sys::{
Element,
Event,
HtmlInputElement,
KeyboardEvent,
MouseEvent,
wasm_bindgen::JsCast
};
use crate::AppState; use crate::{AppState, assembly::Constraint};
// this component lists the elements of the assembly, showing the constraints // an editable view of the Lorentz product representing a constraint
// on each element as a collapsible sub-list. its implementation is based on #[component(inline_props)]
// Kate Morley's HTML + CSS tree views: fn LorentzProductInput(constraint: Constraint) -> View {
view! {
input(
r#type="text",
bind:value=constraint.lorentz_prod_text,
on:change=move |event: Event| {
let target: HtmlInputElement = event.target().unwrap().unchecked_into();
match target.value().parse::<f64>() {
Ok(lorentz_prod) => batch(|| {
constraint.lorentz_prod.set(lorentz_prod);
constraint.lorentz_prod_valid.set(true);
}),
Err(_) => constraint.lorentz_prod_valid.set(false)
};
}
)
}
}
// a component that lists the elements of the current 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/ // https://iamkate.com/code/tree-views/
// //
@ -44,15 +72,13 @@ pub fn Outline() -> View {
} }
}); });
let label = elt.label.clone(); let label = elt.label.clone();
let rep_components = elt.rep.iter().map(|u| { let rep_components = elt.representation.iter().map(|u| {
let u_coord = u.to_string().replace("-", "\u{2212}"); let u_coord = u.to_string().replace("-", "\u{2212}");
View::from(div().children(u_coord)) View::from(div().children(u_coord))
}).collect::<Vec<_>>(); }).collect::<Vec<_>>();
let constrained = elt.constraints.len() > 0; let constrained = elt.constraints.len() > 0;
let details_node = create_node_ref(); let details_node = create_node_ref();
view! { view! {
/* [TO DO] switch to integer-valued parameters whenever
that becomes possible again */
li { li {
details(ref=details_node) { details(ref=details_node) {
summary( summary(
@ -124,21 +150,21 @@ pub fn Outline() -> View {
ul(class="constraints") { ul(class="constraints") {
Keyed( Keyed(
list=elt.constraints.into_iter().collect::<Vec<_>>(), list=elt.constraints.into_iter().collect::<Vec<_>>(),
view=move |c_key: usize| { view=move |c_key| {
let c_state = use_context::<AppState>(); let c_state = use_context::<AppState>();
let assembly = &c_state.assembly; let assembly = &c_state.assembly;
let cst = assembly.constraints.with(|csts| csts[c_key].clone()); let cst = assembly.constraints.with(|csts| csts[c_key].clone());
let other_arg = if cst.args.0 == key { let other_arg = if cst.subjects.0 == key {
cst.args.1 cst.subjects.1
} else { } else {
cst.args.0 cst.subjects.0
}; };
let other_arg_label = assembly.elements.with(|elts| elts[other_arg].label.clone()); let other_arg_label = assembly.elements.with(|elts| elts[other_arg].label.clone());
view! { view! {
li(class="cst") { li(class="cst") {
input(r#type="checkbox", bind:checked=cst.active) input(r#type="checkbox", bind:checked=cst.active)
div(class="cst-label") { (other_arg_label) } div(class="cst-label") { (other_arg_label) }
div(class="cst-rep") { (cst.rep) } LorentzProductInput(constraint=cst)
} }
} }
}, },

View File

@ -8,7 +8,8 @@ using Optim
export export
rand_on_shell, Q, DescentHistory, rand_on_shell, Q, DescentHistory,
realize_gram_gradient, realize_gram_newton, realize_gram_optim, realize_gram realize_gram_gradient, realize_gram_newton, realize_gram_optim,
realize_gram_alt_proj, realize_gram
# === guessing === # === guessing ===
@ -143,7 +144,7 @@ function realize_gram_gradient(
break break
end end
# find negative gradient of loss function # find the negative gradient of the loss function
neg_grad = 4*Q*L*Δ_proj neg_grad = 4*Q*L*Δ_proj
slope = norm(neg_grad) slope = norm(neg_grad)
dir = neg_grad / slope dir = neg_grad / slope
@ -232,7 +233,7 @@ function realize_gram_newton(
break break
end end
# find the negative gradient of loss function # find the negative gradient of the loss function
neg_grad = 4*Q*L*Δ_proj neg_grad = 4*Q*L*Δ_proj
# find the negative Hessian of the loss function # find the negative Hessian of the loss function
@ -313,6 +314,129 @@ function realize_gram_optim(
) )
end end
# seek a matrix `L` for which `L'QL` matches the sparse matrix `gram` at every
# explicit entry of `gram`. use gradient descent starting from `guess`, with an
# alternate technique for finding the projected base step from the unprojected
# Hessian
function realize_gram_alt_proj(
gram::SparseMatrixCSC{T, <:Any},
guess::Matrix{T},
frozen = CartesianIndex[];
scaled_tol = 1e-30,
min_efficiency = 0.5,
backoff = 0.9,
reg_scale = 1.1,
max_descent_steps = 200,
max_backoff_steps = 110
) where T <: Number
# start history
history = DescentHistory{T}()
# find the dimension of the search space
dims = size(guess)
element_dim, construction_dim = dims
total_dim = element_dim * construction_dim
# list the constrained entries of the gram matrix
J, K, _ = findnz(gram)
constrained = zip(J, K)
# scale the tolerance
scale_adjustment = sqrt(T(length(constrained)))
tol = scale_adjustment * scaled_tol
# convert the frozen indices to stacked format
frozen_stacked = [(index[2]-1)*element_dim + index[1] for index in frozen]
# initialize search state
L = copy(guess)
Δ_proj = proj_diff(gram, L'*Q*L)
loss = dot(Δ_proj, Δ_proj)
# use Newton's method with backtracking and gradient descent backup
for step in 1:max_descent_steps
# stop if the loss is tolerably low
if loss < tol
break
end
# find the negative gradient of the loss function
neg_grad = 4*Q*L*Δ_proj
# find the negative Hessian of the loss function
hess = Matrix{T}(undef, total_dim, total_dim)
indices = [(j, k) for k in 1:construction_dim for j in 1:element_dim]
for (j, k) in indices
basis_mat = basis_matrix(T, j, k, dims)
neg_dΔ = basis_mat'*Q*L + L'*Q*basis_mat
neg_dΔ_proj = proj_to_entries(neg_dΔ, constrained)
deriv_grad = 4*Q*(-basis_mat*Δ_proj + L*neg_dΔ_proj)
hess[:, (k-1)*element_dim + j] = reshape(deriv_grad, total_dim)
end
hess_sym = Hermitian(hess)
push!(history.hess, hess_sym)
# regularize the Hessian
min_eigval = minimum(eigvals(hess_sym))
push!(history.positive, min_eigval > 0)
if min_eigval <= 0
hess -= reg_scale * min_eigval * I
end
# compute the Newton step
neg_grad_stacked = reshape(neg_grad, total_dim)
for k in frozen_stacked
neg_grad_stacked[k] = 0
hess[k, :] .= 0
hess[:, k] .= 0
hess[k, k] = 1
end
base_step_stacked = Hermitian(hess) \ neg_grad_stacked
base_step = reshape(base_step_stacked, dims)
push!(history.base_step, base_step)
# store the current position, loss, and slope
L_last = L
loss_last = loss
push!(history.scaled_loss, loss / scale_adjustment)
push!(history.neg_grad, neg_grad)
push!(history.slope, norm(neg_grad))
# find a good step size using backtracking line search
push!(history.stepsize, 0)
push!(history.backoff_steps, max_backoff_steps)
empty!(history.last_line_L)
empty!(history.last_line_loss)
rate = one(T)
step_success = false
base_target_improvement = dot(neg_grad, base_step)
for backoff_steps in 0:max_backoff_steps
history.stepsize[end] = rate
L = L_last + rate * base_step
Δ_proj = proj_diff(gram, L'*Q*L)
loss = dot(Δ_proj, Δ_proj)
improvement = loss_last - loss
push!(history.last_line_L, L)
push!(history.last_line_loss, loss / scale_adjustment)
if improvement >= min_efficiency * rate * base_target_improvement
history.backoff_steps[end] = backoff_steps
step_success = true
break
end
rate *= backoff
end
# if we've hit a wall, quit
if !step_success
return L_last, false, history
end
end
# return the factorization and its history
push!(history.scaled_loss, loss / scale_adjustment)
L, loss < tol, history
end
# seek a matrix `L` for which `L'QL` matches the sparse matrix `gram` at every # seek a matrix `L` for which `L'QL` matches the sparse matrix `gram` at every
# explicit entry of `gram`. use gradient descent starting from `guess` # explicit entry of `gram`. use gradient descent starting from `guess`
function realize_gram( function realize_gram(
@ -321,7 +445,6 @@ function realize_gram(
frozen = nothing; frozen = nothing;
scaled_tol = 1e-30, scaled_tol = 1e-30,
min_efficiency = 0.5, min_efficiency = 0.5,
init_rate = 1.0,
backoff = 0.9, backoff = 0.9,
reg_scale = 1.1, reg_scale = 1.1,
max_descent_steps = 200, max_descent_steps = 200,
@ -352,20 +475,19 @@ function realize_gram(
unfrozen_stacked = reshape(is_unfrozen, total_dim) unfrozen_stacked = reshape(is_unfrozen, total_dim)
end end
# initialize variables # initialize search state
grad_rate = init_rate
L = copy(guess) L = copy(guess)
# use Newton's method with backtracking and gradient descent backup
Δ_proj = proj_diff(gram, L'*Q*L) Δ_proj = proj_diff(gram, L'*Q*L)
loss = dot(Δ_proj, Δ_proj) loss = dot(Δ_proj, Δ_proj)
# use Newton's method with backtracking and gradient descent backup
for step in 1:max_descent_steps for step in 1:max_descent_steps
# stop if the loss is tolerably low # stop if the loss is tolerably low
if loss < tol if loss < tol
break break
end end
# find the negative gradient of loss function # find the negative gradient of the loss function
neg_grad = 4*Q*L*Δ_proj neg_grad = 4*Q*L*Δ_proj
# find the negative Hessian of the loss function # find the negative Hessian of the loss function
@ -420,6 +542,7 @@ function realize_gram(
empty!(history.last_line_loss) empty!(history.last_line_loss)
rate = one(T) rate = one(T)
step_success = false step_success = false
base_target_improvement = dot(neg_grad, base_step)
for backoff_steps in 0:max_backoff_steps for backoff_steps in 0:max_backoff_steps
history.stepsize[end] = rate history.stepsize[end] = rate
L = L_last + rate * base_step L = L_last + rate * base_step
@ -428,7 +551,7 @@ function realize_gram(
improvement = loss_last - loss improvement = loss_last - loss
push!(history.last_line_L, L) push!(history.last_line_L, L)
push!(history.last_line_loss, loss / scale_adjustment) push!(history.last_line_loss, loss / scale_adjustment)
if improvement >= min_efficiency * rate * dot(neg_grad, base_step) if improvement >= min_efficiency * rate * base_target_improvement
history.backoff_steps[end] = backoff_steps history.backoff_steps[end] = backoff_steps
step_success = true step_success = true
break break

View File

@ -74,4 +74,13 @@ if success
for k in 5:9 for k in 5:9
println(" ", 1 / L[4,k], " sun") println(" ", 1 / L[4,k], " sun")
end end
end end
# test an alternate technique for finding the projected base step from the
# unprojected Hessian
L_alt, success_alt, history_alt = Engine.realize_gram_alt_proj(gram, guess, frozen)
completed_gram_alt = L_alt'*Engine.Q*L_alt
println("\nDifference in result using alternate projection:\n")
display(completed_gram_alt - completed_gram)
println("\nDifference in steps: ", size(history_alt.scaled_loss, 1) - size(history.scaled_loss, 1))
println("Difference in loss: ", history_alt.scaled_loss[end] - history.scaled_loss[end], "\n")

View File

@ -64,4 +64,13 @@ else
println("\nFailed to reach target accuracy") println("\nFailed to reach target accuracy")
end end
println("Steps: ", size(history.scaled_loss, 1)) println("Steps: ", size(history.scaled_loss, 1))
println("Loss: ", history.scaled_loss[end], "\n") println("Loss: ", history.scaled_loss[end], "\n")
# test an alternate technique for finding the projected base step from the
# unprojected Hessian
L_alt, success_alt, history_alt = Engine.realize_gram_alt_proj(gram, guess, frozen)
completed_gram_alt = L_alt'*Engine.Q*L_alt
println("\nDifference in result using alternate projection:\n")
display(completed_gram_alt - completed_gram)
println("\nDifference in steps: ", size(history_alt.scaled_loss, 1) - size(history.scaled_loss, 1))
println("Difference in loss: ", history_alt.scaled_loss[end] - history.scaled_loss[end], "\n")

View File

@ -93,4 +93,13 @@ if success
infty = BigFloat[0, 0, 0, 0, 1] infty = BigFloat[0, 0, 0, 0, 1]
radius_ratio = dot(infty, Engine.Q * L[:,5]) / dot(infty, Engine.Q * L[:,6]) radius_ratio = dot(infty, Engine.Q * L[:,5]) / dot(infty, Engine.Q * L[:,6])
println("\nCircumradius / inradius: ", radius_ratio) println("\nCircumradius / inradius: ", radius_ratio)
end end
# test an alternate technique for finding the projected base step from the
# unprojected Hessian
L_alt, success_alt, history_alt = Engine.realize_gram_alt_proj(gram, guess, frozen)
completed_gram_alt = L_alt'*Engine.Q*L_alt
println("\nDifference in result using alternate projection:\n")
display(completed_gram_alt - completed_gram)
println("\nDifference in steps: ", size(history_alt.scaled_loss, 1) - size(history.scaled_loss, 1))
println("Difference in loss: ", history_alt.scaled_loss[end] - history.scaled_loss[end], "\n")

View File

@ -41,3 +41,25 @@ I will have to work out formulas for the Euclidean distance between two entities
In this vein, it seems as though if J1 and J2 are the reps of two points, then Q(J1,J2) = d^2/2. So then the sphere centered at J1 through J2 is (J1-(2Q(J1,J2),0,0,0,0))/sqrt(2Q(J1,J2)). Ugh has a sqrt in it. Similarly for sphere centered at J3 through J2, (J3-(2Q(J3,J2),0000))/sqrt(2Q(J3,J2)). J1,J2,J3 are collinear if these spheres are tangent, i.e. if those vectors have Q-inner-product 1, which is to say Q(J1,J3) - Q(J1,J2) - Q(J3,J2) = 2sqrt(Q(J1,J2)Q(J2,J3)). But maybe that's not the simplest way of putting it. After all, we can just say that the cross-product of the two differences is 0; that has no square roots in it. In this vein, it seems as though if J1 and J2 are the reps of two points, then Q(J1,J2) = d^2/2. So then the sphere centered at J1 through J2 is (J1-(2Q(J1,J2),0,0,0,0))/sqrt(2Q(J1,J2)). Ugh has a sqrt in it. Similarly for sphere centered at J3 through J2, (J3-(2Q(J3,J2),0000))/sqrt(2Q(J3,J2)). J1,J2,J3 are collinear if these spheres are tangent, i.e. if those vectors have Q-inner-product 1, which is to say Q(J1,J3) - Q(J1,J2) - Q(J3,J2) = 2sqrt(Q(J1,J2)Q(J2,J3)). But maybe that's not the simplest way of putting it. After all, we can just say that the cross-product of the two differences is 0; that has no square roots in it.
One conceivable way to canonicalize lines is to use the *perpendicular* plane that goes through the origin, that's uniquely defined, and anyway just amounts to I = (0,0,d) where d is the ordinary direction vector of the line; and a point J in that plane that the line goes through, which just amounts to J=(r^2,1,E) with Q(I,J) = 0, i.e. E\dot d = 0. It's also the point on the line closest to the origin. The reason that we don't usually use that point as the companion to the direction vector is that the resulting set of six coordinates is not homogeneous. But here that's not an issue, since we have our standard point coordinates and plane coordinates; and for a plane through the origin, only two of the direction coordinates are really free, and then we have the one dot-product relation, so only two of the point coordinates are really free, giving us the correct dimensionality of 4 for the set of lines. So in some sense this says that we could take naively as coordinates for a line the projection of the unit direction vector to the xy plane and the projection of the line's closest point to the origin to the xy plane. That doesn't seem to have any weird gimbal locks or discontinuities or anything. And with these coordinates, you can test if the point E=x,y,z is on the line (dx,dy,cx,cy) by extending (dx,dy) to d via dz = sqrt(1-dx^2 - dy^2), extending (cx,cy) to c by determining cz via d\dot c = 0, and then checking if d\cross(E-c) = 0. And you can see if two lines are parallel just by checking if they have the same direction vector, and if not, you can see if they are coplanar by projecting both of their closest points perpendicularly onto the line in the direction of the cross product of their directions, and if the projections match they are coplanar. One conceivable way to canonicalize lines is to use the *perpendicular* plane that goes through the origin, that's uniquely defined, and anyway just amounts to I = (0,0,d) where d is the ordinary direction vector of the line; and a point J in that plane that the line goes through, which just amounts to J=(r^2,1,E) with Q(I,J) = 0, i.e. E\dot d = 0. It's also the point on the line closest to the origin. The reason that we don't usually use that point as the companion to the direction vector is that the resulting set of six coordinates is not homogeneous. But here that's not an issue, since we have our standard point coordinates and plane coordinates; and for a plane through the origin, only two of the direction coordinates are really free, and then we have the one dot-product relation, so only two of the point coordinates are really free, giving us the correct dimensionality of 4 for the set of lines. So in some sense this says that we could take naively as coordinates for a line the projection of the unit direction vector to the xy plane and the projection of the line's closest point to the origin to the xy plane. That doesn't seem to have any weird gimbal locks or discontinuities or anything. And with these coordinates, you can test if the point E=x,y,z is on the line (dx,dy,cx,cy) by extending (dx,dy) to d via dz = sqrt(1-dx^2 - dy^2), extending (cx,cy) to c by determining cz via d\dot c = 0, and then checking if d\cross(E-c) = 0. And you can see if two lines are parallel just by checking if they have the same direction vector, and if not, you can see if they are coplanar by projecting both of their closest points perpendicularly onto the line in the direction of the cross product of their directions, and if the projections match they are coplanar.
#### Engine Conventions
The coordinate conventions used in the engine are different from the ones used in these notes. Marking the engine vectors and coordinates with $'$, we have
$$I' = (x', y', z', b', c'),$$
where
$$
\begin{align*}
x' & = x & b' & = b/2 \\
y' & = y & c' & = c/2. \\
z' & = z
\end{align*}
$$
The engine uses the quadratic form $Q' = -Q$, which is expressed in engine coordinates as
$$Q'(I'_1, I'_2) = x'_1 x'_2 + y'_1 y'_2 + z'_1 z'_2 - 2(b'_1c'_2 + c'_1 b'_2).$$
In the `engine` module, the matrix of $Q'$ is encoded in the lazy static variable `Q`.
In the engine's coordinate conventions, a sphere with radius $r > 0$ centered on $P = (P_x, P_y, P_z)$ is represented by the vector
$$I'_s = \left(\frac{P_x}{r}, \frac{P_y}{r}, \frac{P_z}{r}, \frac1{2r}, \frac{\|P\|^2 - r^2}{2r}\right),$$
which has the normalization $Q'(I'_s, I'_s) = 1$. The point $P$ is represented by the vector
$$I'_P = \left(P_x, P_y, P_z, \frac{1}{2}, \frac{\|P\|^2}{2}\right).$$
In the `engine` module, these formulas are encoded in the `sphere` and `point` functions.