Compare commits

..

3 Commits

Author SHA1 Message Date
e917272c60 Give each element a serial number (#22)
Give each `Element` a serial number, which identifies it uniquely. The serial number is assigned by the `Element::new` constructor.

Because disallows potentially unsafe global state (at least without explicit `unsafe` blocks), the next serial number is stored in a thread-safe static atomic variable (`assembly::NEXT_ELEMENT_SERIAL`), as suggested in [this StackOverflow answer](https://stackoverflow.com/a/32936288). Since the overhead for keeping track of memory ordering should be minimal, we're using the strongest available ordering: [sequentially consistent](https://marabos.nl/atomics/memory-ordering.html#seqcst).

Resolves #20.

Co-authored-by: Aaron Fenyes <aaron.fenyes@fareycircles.ooo>
Reviewed-on: #22
Co-authored-by: Vectornaut <vectornaut@nobody@nowhere.net>
Co-committed-by: Vectornaut <vectornaut@nobody@nowhere.net>
2024-11-22 02:25:10 +00:00
65cee1ecc2 Clean up the outline view (#19)
Clean up the source code and interface of the outline view. In addition, [fix a bug](commit/6e42681b719d7ec97c4225ca321225979bf87b56) that could cause `Assembly::realize` to react to itself under certain circumstances. Those circumstances arose, making the bug noticeable, while this branch was being written.

#### Source code

- Modularize the `Outline` component into smaller components.
- Switch from static iteration to dynamic Sycamore lists. This reduces the amount of re-rendering that happens when an element or constraint changes. It also allows constraint details to stay open or closed during constraint updates, rather than resetting to closed.
- Make `Element::index` private, as discussed [here](pulls/15#issuecomment-1816).

#### Interface

- Make constraints editable, updating the assembly realization on input. Flag constraints where the Lorentz product value doesn't parse.
- Round element vector coordinates to prevent the displayed strings from overlapping.

Note that issue #20 was created by this PR, but it will be addressed shortly.

Co-authored-by: Aaron Fenyes <aaron.fenyes@fareycircles.ooo>
Reviewed-on: #19
Co-authored-by: Vectornaut <vectornaut@nobody@nowhere.net>
Co-committed-by: Vectornaut <vectornaut@nobody@nowhere.net>
2024-11-15 03:32:47 +00:00
707618cdd3 Integrate engine into application prototype (#15)
Port the engine prototype to Rust, integrate it into the application prototype, and use it to enforce the constraints.

### Features

To see the engine in action:

1. Add a constraint by shift-clicking to select two spheres in the outline view and then hitting the 🔗 button
2. Click a summary arrow to see the outline item for the new constraint
2. Set the constraint's Lorentz product by entering a value in the text field at the right end of the outline item
   * *The display should update as soon as you press* Enter *or focus away from the text field*

The checkbox at the left end of a constraint outline item controls whether the constraint is active. Activating a constraint triggers a solution update. (Deactivating a constraint doesn't, since the remaining active constraints are still satisfied.)

### Precision

The Julia prototype of the engine uses a generic scalar type, so you can pass in any type the linear algebra functions are implemented for. The examples use the [adjustable-precision](https://docs.julialang.org/en/v1/base/numbers/#Base.MPFR.setprecision) `BigFloat` type.

In the Rust port of the engine, the scalar type is currently fixed at `f64`. Switching to generic scalars shouldn't be too hard, but I haven't looked into [which other types](https://www.nalgebra.org/docs/user_guide/generic_programming) the linear algebra functions are implemented for.

### Testing

To confirm quantitatively that the Rust port of the engine is working, you can go to the `app-proto` folder and:

* Run some automated tests by calling `cargo test`.
* Inspect the optimization process in a few examples calling the `run-examples` script. The first example that prints is the same as the Irisawa hexlet example from the engine prototype. If you go into `engine-proto/gram-test`, launch Julia, and then

  ```
  include("irisawa-hexlet.jl")
  for (step, scaled_loss) in enumerate(history_alt.scaled_loss)
    println(rpad(step-1, 4), " | ", scaled_loss)
  end
  ```

  you should see that it prints basically the same loss history until the last few steps, when the lower default precision of the Rust engine really starts to show.

### A small engine revision

The Rust port of the engine improves on the Julia prototype in one part of the constraint-solving routine: projecting the Hessian onto the subspace where the frozen entries stay constant. The Julia prototype does this by removing the rows and columns of the Hessian that correspond to the frozen entries, finding the Newton step from the resulting "compressed" Hessian, and then adding zero entries to the Newton step in the appropriate places. The Rust port instead replaces each frozen row and column with its corresponding standard unit vector, avoiding the finicky compressing and decompressing steps.

To confirm that this version of the constraint-solving routine works the same as the original, I implemented it in Julia as `realize_gram_alt_proj`. The solutions we get from this routine match the ones we get from the original `realize_gram` to very high precision, and in the simplest examples (`sphere-in-tetrahedron.jl` and `tetrahedron-radius-ratio.jl`), the descent paths also match to very high precision. In a more complicated example (`irisawa-hexlet.jl`), the descent paths diverge about a quarter of the way into the search, even though they end up in the same place.

Co-authored-by: Aaron Fenyes <aaron.fenyes@fareycircles.ooo>
Reviewed-on: #15
Co-authored-by: Vectornaut <vectornaut@nobody@nowhere.net>
Co-committed-by: Vectornaut <vectornaut@nobody@nowhere.net>
2024-11-12 00:46:16 +00:00
14 changed files with 380 additions and 288 deletions

View File

@ -6,7 +6,6 @@ edition = "2021"
[features] [features]
default = ["console_error_panic_hook"] default = ["console_error_panic_hook"]
irisawa = []
[dependencies] [dependencies]
itertools = "0.13.0" itertools = "0.13.0"
@ -37,12 +36,7 @@ features = [
'WebGlVertexArrayObject' 'WebGlVertexArrayObject'
] ]
# the self-dependency specifies features to use for tests and examples
#
# https://github.com/rust-lang/cargo/issues/2911#issuecomment-1483256987
#
[dev-dependencies] [dev-dependencies]
dyna3 = { path = ".", default-features = false, features = ["irisawa"] }
wasm-bindgen-test = "0.3.34" wasm-bindgen-test = "0.3.34"
[profile.release] [profile.release]

View File

@ -1,25 +0,0 @@
use dyna3::engine::{Q, irisawa::realize_irisawa_hexlet};
fn main() {
const SCALED_TOL: f64 = 1.0e-12;
let (config, success, history) = realize_irisawa_hexlet(SCALED_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);
}
}

View File

@ -1,38 +0,0 @@
use nalgebra::DMatrix;
use dyna3::engine::{Q, point, realize_gram, sphere, PartialMatrix};
fn main() {
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)];
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);
}
}

View File

@ -1,40 +0,0 @@
use nalgebra::DMatrix;
use dyna3::engine::{Q, realize_gram, sphere, PartialMatrix};
fn main() {
let gram = {
let mut gram_to_be = PartialMatrix::new();
for j in 0..3 {
for k in j..3 {
gram_to_be.push_sym(j, k, if j == k { 1.0 } else { -1.0 });
}
}
gram_to_be
};
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);
}
}

View File

@ -1,7 +1,19 @@
:root {
--text: #fcfcfc; /* almost white */
--text-bright: white;
--text-invalid: #f58fc2; /* bright pink */
--border: #555; /* light gray */
--border-focus: #aaa; /* bright gray */
--border-invalid: #70495c; /* dusky pink */
--selection-highlight: #444; /* medium gray */
--page-background: #222; /* dark gray */
--display-background: #020202; /* almost black */
}
body { body {
margin: 0px; margin: 0px;
color: #fcfcfc; color: var(--text);
background-color: #222; background-color: var(--page-background);
font-family: 'Fira Sans', sans-serif; font-family: 'Fira Sans', sans-serif;
} }
@ -17,7 +29,7 @@ body {
padding: 0px; padding: 0px;
border-width: 0px 1px 0px 0px; border-width: 0px 1px 0px 0px;
border-style: solid; border-style: solid;
border-color: #555; border-color: var(--border);
} }
/* add-remove */ /* add-remove */
@ -35,6 +47,10 @@ body {
} }
/* KLUDGE */ /* KLUDGE */
/*
for convenience, we're using emoji as temporary icons for some buttons. these
buttons need to be displayed in an emoji font
*/
#add-remove > button.emoji { #add-remove > button.emoji {
font-family: 'Noto Emoji', sans-serif; font-family: 'Noto Emoji', sans-serif;
} }
@ -57,49 +73,49 @@ summary {
} }
summary.selected { summary.selected {
color: #fff; color: var(--text-bright);
background-color: #444; background-color: var(--selection-highlight);
} }
summary > div, .cst { summary > div, .constraint {
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
} }
.elt, .cst { .element, .constraint {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
padding-left: 8px; padding-left: 8px;
padding-right: 8px; padding-right: 8px;
} }
.elt-switch { .element-switch {
width: 18px; width: 18px;
padding-left: 2px; padding-left: 2px;
text-align: center; text-align: center;
} }
details:has(li) .elt-switch::after { details:has(li) .element-switch::after {
content: '▸'; content: '▸';
} }
details[open]:has(li) .elt-switch::after { details[open]:has(li) .element-switch::after {
content: '▾'; content: '▾';
} }
.elt-label { .element-label {
flex-grow: 1; flex-grow: 1;
} }
.cst-label { .constraint-label {
flex-grow: 1; flex-grow: 1;
} }
.elt-rep { .element-representation {
display: flex; display: flex;
} }
.elt-rep > div { .element-representation > div {
padding: 2px 0px 0px 0px; padding: 2px 0px 0px 0px;
font-size: 10pt; font-size: 10pt;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@ -107,27 +123,27 @@ details[open]:has(li) .elt-switch::after {
width: 56px; width: 56px;
} }
.cst { .constraint {
font-style: italic; font-style: italic;
} }
.cst.invalid { .constraint.invalid {
color: #f58fc2; color: var(--text-invalid);
} }
.cst > input[type=checkbox] { .constraint > input[type=checkbox] {
margin: 0px 8px 0px 0px; margin: 0px 8px 0px 0px;
} }
.cst > input[type=text] { .constraint > input[type=text] {
color: inherit; color: inherit;
background-color: inherit; background-color: inherit;
border: 1px solid #555; border: 1px solid var(--border);
border-radius: 2px; border-radius: 2px;
} }
.cst.invalid > input[type=text] { .constraint.invalid > input[type=text] {
border-color: #70495c; border-color: var(--border-invalid);
} }
.status { .status {
@ -140,7 +156,7 @@ details[open]:has(li) .elt-switch::after {
.invalid > .status::after, details:has(.invalid):not([open]) .status::after { .invalid > .status::after, details:has(.invalid):not([open]) .status::after {
content: '⚠'; content: '⚠';
color: #f58fc2; color: var(--text-invalid);
} }
/* display */ /* display */
@ -149,11 +165,11 @@ canvas {
float: left; float: left;
margin-left: 20px; margin-left: 20px;
margin-top: 20px; margin-top: 20px;
background-color: #020202; background-color: var(--display-background);
border: 1px solid #555; border: 1px solid var(--border);
border-radius: 16px; border-radius: 16px;
} }
canvas:focus { canvas:focus {
border-color: #aaa; border-color: var(--border-focus);
} }

View File

@ -1,9 +1,8 @@
# run all Cargo examples, as described here: # based on "Enabling print statements in Cargo tests", by Jon Almeida
# #
# Karol Kuczmarski. "Add examples to your Rust libraries" # https://jonalmeida.com/posts/2015/01/23/print-cargo/
# http://xion.io/post/code/rust-examples.html
# #
cargo run --example irisawa-hexlet cargo test -- --nocapture engine::tests::irisawa_hexlet_test
cargo run --example three-spheres cargo test -- --nocapture engine::tests::three_spheres_example
cargo run --example point-on-sphere cargo test -- --nocapture engine::tests::point_on_sphere_example

View File

@ -4,6 +4,8 @@ use web_sys::{console, wasm_bindgen::JsValue};
use crate::{engine, AppState, assembly::{Assembly, Constraint, Element}}; use crate::{engine, AppState, assembly::{Assembly, Constraint, Element}};
/* DEBUG */ /* DEBUG */
// load an example assembly for testing. this code will be removed once we've
// built a more formal test assembly system
fn load_gen_assemb(assembly: &Assembly) { fn load_gen_assemb(assembly: &Assembly) {
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
Element::new( Element::new(
@ -56,6 +58,8 @@ fn load_gen_assemb(assembly: &Assembly) {
} }
/* DEBUG */ /* DEBUG */
// load an example assembly for testing. this code will be removed once we've
// built a more formal test assembly system
fn load_low_curv_assemb(assembly: &Assembly) { fn load_low_curv_assemb(assembly: &Assembly) {
let a = 0.75_f64.sqrt(); let a = 0.75_f64.sqrt();
let _ = assembly.try_insert_element( let _ = assembly.try_insert_element(
@ -173,27 +177,27 @@ pub fn AddRemove() -> View {
} }
) { "+" } ) { "+" }
button( button(
class="emoji", /* KLUDGE */ class="emoji", /* KLUDGE */ // for convenience, we're using an emoji as a temporary icon for this button
disabled={ disabled={
let state = use_context::<AppState>(); let state = use_context::<AppState>();
state.selection.with(|sel| sel.len() != 2) state.selection.with(|sel| sel.len() != 2)
}, },
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 rep = create_signal(0.0); let lorentz_prod = create_signal(0.0);
let rep_valid = create_signal(false); let lorentz_prod_valid = create_signal(false);
let active = create_signal(true); let active = create_signal(true);
state.assembly.insert_constraint(Constraint { state.assembly.insert_constraint(Constraint {
args: args, subjects: subjects,
rep: rep, lorentz_prod: lorentz_prod,
rep_text: create_signal(String::new()), lorentz_prod_text: create_signal(String::new()),
rep_valid: rep_valid, lorentz_prod_valid: lorentz_prod_valid,
active: active, active: active,
}); });
state.selection.update(|sel| sel.clear()); state.selection.update(|sel| sel.clear());
@ -205,10 +209,10 @@ pub fn AddRemove() -> View {
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.get_untracked()) &JsValue::from(cst.lorentz_prod.get_untracked())
); );
} }
}); });
@ -217,18 +221,19 @@ pub fn AddRemove() -> View {
// and valid, or is edited while active and valid // and valid, or is edited while active and valid
create_effect(move || { create_effect(move || {
console::log_1(&JsValue::from( console::log_1(&JsValue::from(
format!("Constraint ({}, {}) updated", args.0, args.1) format!("Constraint ({}, {}) updated", subjects.0, subjects.1)
)); ));
rep.track(); lorentz_prod.track();
if active.get() && rep_valid.get() { if active.get() && lorentz_prod_valid.get() {
state.assembly.realize(); state.assembly.realize();
} }
}); });
} }
) { "🔗" } ) { "🔗" }
select(bind:value=assembly_name) { /* DEBUG */ select(bind:value=assembly_name) { /* DEBUG */ // example assembly chooser
option(value="general") { "General" } option(value="general") { "General" }
option(value="low-curv") { "Low-curvature" } option(value="low-curv") { "Low-curvature" }
option(value="empty") { "Empty" }
} }
} }
} }

View File

@ -1,38 +1,68 @@
use nalgebra::{DMatrix, 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, sync::atomic::{AtomicU64, Ordering}};
use sycamore::prelude::*; use sycamore::prelude::*;
use web_sys::{console, wasm_bindgen::JsValue}; /* DEBUG */ use web_sys::{console, wasm_bindgen::JsValue}; /* DEBUG */
use crate::engine::{realize_gram, PartialMatrix}; 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];
/* 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)] #[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: Signal<DVector<f64>>, pub representation: Signal<DVector<f64>>,
pub constraints: Signal<BTreeSet<usize>>, pub constraints: Signal<BTreeSet<ConstraintKey>>,
// internal properties, not reflected in any view // a serial number, assigned by `Element::new`, that uniquely identifies
pub index: usize // each element
pub serial: u64,
// the configuration matrix column index that was assigned to this element
// last time the assembly was realized
column_index: usize
} }
impl Element { impl Element {
pub fn new( pub fn new(
id: String, id: String,
label: String, label: String,
color: [f32; 3], color: ElementColor,
rep: DVector<f64> representation: DVector<f64>
) -> Element { ) -> 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 { Element {
id: id, id: id,
label: label, label: label,
color: color, color: color,
rep: create_signal(rep), representation: create_signal(representation),
constraints: create_signal(BTreeSet::default()), constraints: create_signal(BTreeSet::default()),
index: 0 serial: serial,
column_index: 0
} }
} }
} }
@ -40,10 +70,10 @@ impl Element {
#[derive(Clone)] #[derive(Clone)]
pub struct Constraint { pub struct Constraint {
pub args: (usize, usize), pub subjects: (ElementKey, ElementKey),
pub rep: Signal<f64>, pub lorentz_prod: Signal<f64>,
pub rep_text: Signal<String>, pub lorentz_prod_text: Signal<String>,
pub rep_valid: Signal<bool>, pub lorentz_prod_valid: Signal<bool>,
pub active: Signal<bool> pub active: Signal<bool>
} }
@ -55,7 +85,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 {
@ -111,13 +141,13 @@ impl Assembly {
} }
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));
let arg_constraints = self.elements.with( let subject_constraints = self.elements.with(
|elts| (elts[args.0].constraints, elts[args.1].constraints) |elts| (elts[subjects.0].constraints, elts[subjects.1].constraints)
); );
arg_constraints.0.update(|csts| csts.insert(key)); subject_constraints.0.update(|csts| csts.insert(key));
arg_constraints.1.update(|csts| csts.insert(key)); subject_constraints.1.update(|csts| csts.insert(key));
} }
// --- realization --- // --- realization ---
@ -126,7 +156,7 @@ impl Assembly {
// index the elements // index the elements
self.elements.update_silent(|elts| { self.elements.update_silent(|elts| {
for (index, (_, elt)) in elts.into_iter().enumerate() { for (index, (_, elt)) in elts.into_iter().enumerate() {
elt.index = index; elt.column_index = index;
} }
}); });
@ -136,11 +166,11 @@ impl Assembly {
let mut gram_to_be = PartialMatrix::new(); let mut gram_to_be = PartialMatrix::new();
self.constraints.with_untracked(|csts| { self.constraints.with_untracked(|csts| {
for (_, cst) in csts { for (_, cst) in csts {
if cst.active.get_untracked() && cst.rep_valid.get_untracked() { if cst.active.get_untracked() && cst.lorentz_prod_valid.get_untracked() {
let args = cst.args; let subjects = cst.subjects;
let row = elts[args.0].index; let row = elts[subjects.0].column_index;
let col = elts[args.1].index; let col = elts[subjects.1].column_index;
gram_to_be.push_sym(row, col, cst.rep.get_untracked()); gram_to_be.push_sym(row, col, cst.lorentz_prod.get_untracked());
} }
} }
}); });
@ -149,9 +179,9 @@ impl Assembly {
// Gram matrix // Gram matrix
let mut guess_to_be = DMatrix::<f64>::zeros(5, elts.len()); let mut guess_to_be = DMatrix::<f64>::zeros(5, elts.len());
for (_, elt) in elts { for (_, elt) in elts {
let index = elt.index; let index = elt.column_index;
gram_to_be.push_sym(index, index, 1.0); gram_to_be.push_sym(index, index, 1.0);
guess_to_be.set_column(index, &elt.rep.get_clone_untracked()); guess_to_be.set_column(index, &elt.representation.get_clone_untracked());
} }
(gram_to_be, guess_to_be) (gram_to_be, guess_to_be)
@ -194,8 +224,8 @@ impl Assembly {
if success { if success {
// read out the solution // read out the solution
for (_, elt) in self.elements.get_clone_untracked() { for (_, elt) in self.elements.get_clone_untracked() {
elt.rep.update( elt.representation.update(
|rep| rep.set_column(0, &config.column(elt.index)) |rep| rep.set_column(0, &config.column(elt.column_index))
); );
} }
} }

View File

@ -105,7 +105,7 @@ pub fn Display() -> View {
create_effect(move || { create_effect(move || {
state.assembly.elements.with(|elts| { state.assembly.elements.with(|elts| {
for (_, elt) in elts { for (_, elt) in elts {
elt.rep.track(); elt.representation.track();
} }
}); });
state.selection.track(); state.selection.track();
@ -311,7 +311,7 @@ pub fn Display() -> View {
// representation vectors in world coordinates // representation vectors in world coordinates
elts.iter().map( elts.iter().map(
|(_, elt)| elt.rep.with(|rep| &assembly_to_world * rep) |(_, elt)| elt.representation.with(|rep| &assembly_to_world * rep)
).collect::<Vec<_>>(), ).collect::<Vec<_>>(),
// colors // colors

View File

@ -4,6 +4,7 @@ use web_sys::{console, wasm_bindgen::JsValue}; /* DEBUG */
// --- elements --- // --- elements ---
#[cfg(test)]
pub fn point(x: f64, y: f64, z: f64) -> DVector<f64> { 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)]) DVector::from_column_slice(&[x, y, z, 0.5, 0.5*(x*x + y*y + z*z)])
} }
@ -112,7 +113,7 @@ impl DescentHistory {
// the Lorentz form // the Lorentz form
lazy_static! { lazy_static! {
pub static ref Q: DMatrix<f64> = DMatrix::from_row_slice(5, 5, &[ static ref Q: DMatrix<f64> = DMatrix::from_row_slice(5, 5, &[
1.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, 1.0, 0.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0,
@ -276,79 +277,12 @@ pub fn realize_gram(
// --- tests --- // --- tests ---
// this problem is from a sangaku by Irisawa Shintarō Hiroatsu. the article #[cfg(test)]
// below includes a nice translation of the problem statement, which was mod tests {
// 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/
//
#[cfg(feature = "irisawa")]
pub mod irisawa {
use std::{array, f64::consts::PI}; use std::{array, f64::consts::PI};
use super::*; use super::*;
pub fn realize_irisawa_hexlet(scaled_tol: f64) -> (DMatrix<f64>, bool, DescentHistory) {
let gram = {
let mut gram_to_be = PartialMatrix::new();
for s in 0..9 {
// each sphere is represented by a spacelike vector
gram_to_be.push_sym(s, s, 1.0);
// the circumscribing sphere is tangent to all of the other
// spheres, with matching orientation
if s > 0 {
gram_to_be.push_sym(0, s, 1.0);
}
if s > 2 {
// each chain sphere is tangent to the "sun" and "moon"
// spheres, with opposing orientation
for n in 1..3 {
gram_to_be.push_sym(s, n, -1.0);
}
// each chain sphere is tangent to the next chain sphere,
// with opposing orientation
let s_next = 3 + (s-2) % 6;
gram_to_be.push_sym(s, s_next, -1.0);
}
}
gram_to_be
};
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()
);
// the frozen entries fix the radii of the circumscribing sphere, the
// "sun" and "moon" spheres, and one of the chain spheres
let frozen: [(usize, usize); 4] = array::from_fn(|k| (3, k));
realize_gram(
&gram, guess, &frozen,
scaled_tol, 0.5, 0.9, 1.1, 200, 110
)
}
}
#[cfg(test)]
mod tests {
use super::{*, irisawa::realize_irisawa_hexlet};
#[test] #[test]
fn sub_proj_test() { fn sub_proj_test() {
let target = PartialMatrix(vec![ let target = PartialMatrix(vec![
@ -394,17 +328,213 @@ mod tests {
assert!(state.loss.abs() < f64::EPSILON); 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] #[test]
fn irisawa_hexlet_test() { fn irisawa_hexlet_test() {
// solve Irisawa's problem let gram = PartialMatrix({
const SCALED_TOL: f64 = 1.0e-12; let mut entries = Vec::<MatrixEntry>::new();
let (config, _, _) = realize_irisawa_hexlet(SCALED_TOL); for s in 0..9 {
// each sphere is represented by a spacelike vector
entries.push(MatrixEntry { index: (s, s), value: 1.0 });
// check against Irisawa's solution // 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 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]; 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() { for (k, diam) in solution_diams.into_iter().enumerate() {
assert!((config[(3, k)] - 1.0 / diam).abs() < entry_tol); 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

@ -1 +0,0 @@
pub mod engine;

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

@ -8,7 +8,7 @@ use web_sys::{
wasm_bindgen::JsCast wasm_bindgen::JsCast
}; };
use crate::{AppState, assembly, assembly::Constraint}; use crate::{AppState, assembly, assembly::{Constraint, ConstraintKey, ElementKey}};
// an editable view of the Lorentz product representing a constraint // an editable view of the Lorentz product representing a constraint
#[component(inline_props)] #[component(inline_props)]
@ -16,15 +16,15 @@ fn LorentzProductInput(constraint: Constraint) -> View {
view! { view! {
input( input(
r#type="text", r#type="text",
bind:value=constraint.rep_text, bind:value=constraint.lorentz_prod_text,
on:change=move |event: Event| { on:change=move |event: Event| {
let target: HtmlInputElement = event.target().unwrap().unchecked_into(); let target: HtmlInputElement = event.target().unwrap().unchecked_into();
match target.value().parse::<f64>() { match target.value().parse::<f64>() {
Ok(rep) => batch(|| { Ok(lorentz_prod) => batch(|| {
constraint.rep.set(rep); constraint.lorentz_prod.set(lorentz_prod);
constraint.rep_valid.set(true); constraint.lorentz_prod_valid.set(true);
}), }),
Err(_) => constraint.rep_valid.set(false) Err(_) => constraint.lorentz_prod_valid.set(false)
}; };
} }
) )
@ -33,23 +33,23 @@ fn LorentzProductInput(constraint: Constraint) -> View {
// a list item that shows a constraint in an outline view of an element // a list item that shows a constraint in an outline view of an element
#[component(inline_props)] #[component(inline_props)]
fn ConstraintOutlineItem(constraint_key: usize, element_key: usize) -> View { fn ConstraintOutlineItem(constraint_key: ConstraintKey, element_key: ElementKey) -> View {
let state = use_context::<AppState>(); let state = use_context::<AppState>();
let assembly = &state.assembly; let assembly = &state.assembly;
let constraint = assembly.constraints.with(|csts| csts[constraint_key].clone()); let constraint = assembly.constraints.with(|csts| csts[constraint_key].clone());
let other_arg = if constraint.args.0 == element_key { let other_subject = if constraint.subjects.0 == element_key {
constraint.args.1 constraint.subjects.1
} else { } else {
constraint.args.0 constraint.subjects.0
}; };
let other_arg_label = assembly.elements.with(|elts| elts[other_arg].label.clone()); let other_subject_label = assembly.elements.with(|elts| elts[other_subject].label.clone());
let class = constraint.rep_valid.map( let class = constraint.lorentz_prod_valid.map(
|&rep_valid| if rep_valid { "cst" } else { "cst invalid" } |&lorentz_prod_valid| if lorentz_prod_valid { "constraint" } else { "constraint invalid" }
); );
view! { view! {
li(class=class.get()) { li(class=class.get()) {
input(r#type="checkbox", bind:checked=constraint.active) input(r#type="checkbox", bind:checked=constraint.active)
div(class="cst-label") { (other_arg_label) } div(class="constraint-label") { (other_subject_label) }
LorentzProductInput(constraint=constraint) LorentzProductInput(constraint=constraint)
div(class="status") div(class="status")
} }
@ -58,13 +58,13 @@ fn ConstraintOutlineItem(constraint_key: usize, element_key: usize) -> View {
// a list item that shows an element in an outline view of an assembly // a list item that shows an element in an outline view of an assembly
#[component(inline_props)] #[component(inline_props)]
fn ElementOutlineItem(key: usize, element: assembly::Element) -> View { fn ElementOutlineItem(key: ElementKey, element: assembly::Element) -> View {
let state = use_context::<AppState>(); let state = use_context::<AppState>();
let class = state.selection.map( let class = state.selection.map(
move |sel| if sel.contains(&key) { "selected" } else { "" } move |sel| if sel.contains(&key) { "selected" } else { "" }
); );
let label = element.label.clone(); let label = element.label.clone();
let rep_components = element.rep.map( let rep_components = element.representation.map(
|rep| rep.iter().map( |rep| rep.iter().map(
|u| format!("{:.3}", u).replace("-", "\u{2212}") |u| format!("{:.3}", u).replace("-", "\u{2212}")
).collect() ).collect()
@ -115,11 +115,11 @@ fn ElementOutlineItem(key: usize, element: assembly::Element) -> View {
} }
) { ) {
div( div(
class="elt-switch", class="element-switch",
on:click=|event: MouseEvent| event.stop_propagation() on:click=|event: MouseEvent| event.stop_propagation()
) )
div( div(
class="elt", class="element",
on:click={ on:click={
move |event: MouseEvent| { move |event: MouseEvent| {
if event.shift_key() { if event.shift_key() {
@ -139,8 +139,8 @@ fn ElementOutlineItem(key: usize, element: assembly::Element) -> View {
} }
} }
) { ) {
div(class="elt-label") { (label) } div(class="element-label") { (label) }
div(class="elt-rep") { div(class="element-representation") {
Indexed( Indexed(
list=rep_components, list=rep_components,
view=|coord_str| view! { view=|coord_str| view! {
@ -200,7 +200,7 @@ pub fn Outline() -> View {
view=|(key, elt)| view! { view=|(key, elt)| view! {
ElementOutlineItem(key=key, element=elt) ElementOutlineItem(key=key, element=elt)
}, },
key=|(key, _)| key.clone() key=|(_, elt)| elt.serial
) )
} }
} }

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.