diff --git a/.forgejo/setup-trunk/action.yaml b/.forgejo/setup-trunk/action.yaml new file mode 100644 index 0000000..6007527 --- /dev/null +++ b/.forgejo/setup-trunk/action.yaml @@ -0,0 +1,22 @@ +# set up the Trunk web build system +# +# https://trunkrs.dev +# +# the `curl` call is based on David Tolnay's `rust-toolchain` action +# +# https://github.com/dtolnay/rust-toolchain +# +runs: + using: "composite" + steps: + - run: rustup target add wasm32-unknown-unknown + + # install the Trunk binary to `ci-bin` within the workspace directory, which + # is determined by the `github.workspace` label and reflected in the + # `GITHUB_WORKSPACE` environment variable. then, make the `trunk` command + # available by placing the fully qualified path to `ci-bin` on the + # workflow's search path + - run: mkdir -p ci-bin + - run: curl --output - --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused --location --silent --show-error --fail 'https://github.com/trunk-rs/trunk/releases/download/v0.21.12/trunk-x86_64-unknown-linux-gnu.tar.gz' | tar --gunzip --extract --file - + working-directory: ci-bin + - run: echo "${{ github.workspace }}/ci-bin" >> $GITHUB_PATH diff --git a/.forgejo/workflows/continuous-integration.yaml b/.forgejo/workflows/continuous-integration.yaml new file mode 100644 index 0000000..f3b0130 --- /dev/null +++ b/.forgejo/workflows/continuous-integration.yaml @@ -0,0 +1,29 @@ +on: + pull_request: + push: + branches: [main] +jobs: + # run the automated tests, reporting success if the tests pass and were built + # without warnings. the examples are run as tests, because we've configured + # each example target with `test = true` and `harness = false` in Cargo.toml. + # Trunk build failures caused by problems outside the Rust source code, like + # missing assets, should be caught by `trunk_build_test` + test: + runs-on: docker + container: + image: cimg/rust:1.86-node + defaults: + run: + # set the default working directory for each `run` step, relative to the + # workspace directory. this default only affects `run` steps (and if we + # tried to set the `working-directory` label for any other kind of step, + # it wouldn't be recognized anyway) + working-directory: app-proto + steps: + # Check out the repository so that its top-level directory is the + # workspace directory (action variable `github.workspace`, environment + # variable `$GITHUB_WORKSPACE`): + - uses: https://code.forgejo.org/actions/checkout@v4 + + - uses: ./.forgejo/setup-trunk + - run: RUSTFLAGS='-D warnings' cargo test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3e95fba..ba2944f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,2 @@ -node_modules -site -docbuild -__tests__ -coverage -dyna3.zip -tmpproj +ci-bin *~ diff --git a/README.md b/README.md index 9ea9cbf..3a29eb0 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,51 @@ Note that currently this is just the barest beginnings of the project, more of a * Able to run in browser (so implemented in WASM-compatible language) * Produce scalable graphics of 3D diagrams, and maybe STL files (or other fabricatable file format) as well. + +## Prototype + +The latest prototype is in the folder `app-proto`. It includes both a user interface and a numerical constraint-solving engine. + +### Install the prerequisites + +1. Install [`rustup`](https://rust-lang.github.io/rustup/): the officially recommended Rust toolchain manager + * It's available on Ubuntu as a [Snap](https://snapcraft.io/rustup) +2. Call `rustup default stable` to "download the latest stable release of Rust and set it as your default toolchain" + * If you forget, the `rustup` [help system](https://github.com/rust-lang/rustup/blob/d9b3601c3feb2e88cf3f8ca4f7ab4fdad71441fd/src/errors.rs#L109-L112) will remind you +3. Call `rustup target add wasm32-unknown-unknown` to add the [most generic 32-bit WebAssembly target](https://doc.rust-lang.org/nightly/rustc/platform-support/wasm32-unknown-unknown.html) +4. Call `cargo install wasm-pack` to install the [WebAssembly toolchain](https://rustwasm.github.io/docs/wasm-pack/) +5. Call `cargo install trunk` to install the [Trunk](https://trunkrs.dev/) web-build tool +6. Add the `.cargo/bin` folder in your home directory to your executable search path + * This lets you call Trunk, and other tools installed by Cargo, without specifying their paths + * On POSIX systems, the search path is stored in the `PATH` environment variable + +### Play with the prototype + +1. From the `app-proto` folder, call `trunk serve --release` to build and serve the prototype + * *The crates the prototype depends on will be downloaded and served automatically* + * *For a faster build, at the expense of a much slower prototype, you can call `trunk serve` without the `--release` flag* + * *If you want to stay in the top-level folder, you can call `trunk serve --config app-proto [--release]`* from there instead. +3. In a web browser, visit one of the URLs listed under the message `INFO 📡 server listening at:` + * *Touching any file in the `app-proto` folder will make Trunk rebuild and live-reload the prototype* +4. Press *ctrl+C* in the shell where Trunk is running to stop serving the prototype + +### Run the engine on some example problems + +1. Go into the `app-proto` folder +2. Call `./run-examples` + * *For each example problem, the engine will print the value of the loss function at each optimization step* + * *The first example that prints is the same as the Irisawa hexlet example from the Julia version of the engine prototype. If you go into `engine-proto/gram-test`, launch Julia, and then* + + ```julia + 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* + +### Run the automated tests + +1. Go into the `app-proto` folder +2. Call `cargo test` diff --git a/app-proto/.gitignore b/app-proto/.gitignore new file mode 100644 index 0000000..19aa86b --- /dev/null +++ b/app-proto/.gitignore @@ -0,0 +1,4 @@ +target +dist +profiling +Cargo.lock \ No newline at end of file diff --git a/app-proto/Cargo.lock b/app-proto/Cargo.lock new file mode 100644 index 0000000..4f75c45 --- /dev/null +++ b/app-proto/Cargo.lock @@ -0,0 +1,1325 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytemuck" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "charming" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ffae2e616ae7d66b2e9ea369f1c7650042bdcdc1dc08b04b027107007b4f09" +dependencies = [ + "handlebars", + "js-sys", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_with", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dyna3" +version = "0.1.0" +dependencies = [ + "charming", + "console_error_panic_hook", + "dyna3", + "itertools", + "js-sys", + "lazy_static", + "nalgebra", + "readonly", + "sycamore", + "wasm-bindgen-test", + "web-sys", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "handlebars" +version = "6.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +dependencies = [ + "equivalent", + "hashbrown 0.14.5", + "serde", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "matrixmultiply" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9380b911e3e96d10c1f415da0876389aaf1b56759054eeb0de7df940c456ba1a" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minicov" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c71e683cd655513b99affab7d317deb690528255a0d5f717f1024093c12b169" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nalgebra" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c4b5f057b303842cf3262c27e465f4c303572e7f6b0648f60e16248ac3397f4" +dependencies = [ + "approx", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pest" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "readonly" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2a62d85ed81ca5305dc544bd42c8804c5060b78ffa5ad3c64b0fb6a8c13d062" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "safe_arch" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3460605018fdc9612bce72735cba0d27efbcd9904780d44c7e3a9948f96148a" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.5.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simba" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a386a501cd104797982c15ae17aafe8b9261315b5d07e3ec803f2ea26be0fa" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "slotmap" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "sycamore" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f38201dcb10aa609e81ca6f7547758a7eb602240a5ff682e668909fd0f7b2cc" +dependencies = [ + "hashbrown 0.14.5", + "indexmap 2.5.0", + "paste", + "sycamore-core", + "sycamore-macro", + "sycamore-reactive", + "sycamore-web", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "sycamore-core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dc04bf0de321c6486356b2be751fac82fabb06c992d25b6748587561bba187" +dependencies = [ + "hashbrown 0.14.5", + "paste", + "sycamore-reactive", +] + +[[package]] +name = "sycamore-macro" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c1d2eddc94db6d03e67eb832df5512b967e81053a573cd01bf3e1c3db00137" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "rand", + "sycamore-view-parser", + "syn", +] + +[[package]] +name = "sycamore-reactive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bacf810535efc2701187a716a5652197ad241d620d5b00fb12caa6dfa23add" +dependencies = [ + "paste", + "slotmap", + "smallvec", + "wasm-bindgen", +] + +[[package]] +name = "sycamore-view-parser" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c22875843db83cd4d49c0123a195e433bdc74e13ed0fff4ace0e77bb0a67033" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sycamore-web" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b17aa5875f59f541cdf6fb58751ec702a6ed9801f30dd2b4d5f2279025b98bd" +dependencies = [ + "html-escape", + "js-sys", + "once_cell", + "paste", + "smallvec", + "sycamore-core", + "sycamore-macro", + "sycamore-reactive", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "syn" +version = "2.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68497a05fb21143a08a7d24fc81763384a3072ee43c44e86aad1744d6adef9d9" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "minicov", + "scoped-tls", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b8220be1fa9e4c889b30fd207d4906657e7e90b12e0e6b0c8b8d8709f5de021" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "web-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wide" +version = "0.7.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b828f995bf1e9622031f8009f8481a85406ce1f4d4588ff746d872043e855690" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/app-proto/Cargo.toml b/app-proto/Cargo.toml new file mode 100644 index 0000000..1230b47 --- /dev/null +++ b/app-proto/Cargo.toml @@ -0,0 +1,81 @@ +[package] +name = "dyna3" +version = "0.1.0" +authors = ["Aaron Fenyes", "Glen Whitney"] +edition = "2021" +rust-version = "1.86" + +[features] +default = ["console_error_panic_hook"] +dev = [] + +[dependencies] +itertools = "0.13.0" +js-sys = "0.3.70" +lazy_static = "1.5.0" +nalgebra = "0.33.0" +readonly = "0.2.12" +sycamore = "0.9.1" + +# We use Charming to help display engine diagnostics +charming = { version = "0.5.1", features = ["wasm"] } + +# The `console_error_panic_hook` crate provides better debugging of panics by +# logging them with `console.error`. This is great for development, but requires +# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for +# code size when deploying. +console_error_panic_hook = { version = "0.1.7", optional = true } + +[dependencies.web-sys] +version = "0.3.69" +features = [ + 'DomRect', + 'HtmlCanvasElement', + 'HtmlInputElement', + 'Performance', + 'WebGl2RenderingContext', + 'WebGlBuffer', + 'WebGlProgram', + 'WebGlShader', + 'WebGlUniformLocation', + 'WebGlVertexArrayObject' +] + +# the self-dependency specifies features to use for tests and examples +# +# https://github.com/rust-lang/cargo/issues/2911#issuecomment-1483256987 +# +[dev-dependencies] +dyna3 = { path = ".", default-features = false, features = ["dev"] } +wasm-bindgen-test = "0.3.34" + +# turn off spurious warnings about the custom config that Sycamore uses +# +# https://sycamore.dev/book/troubleshooting#unexpected-cfg-condition-name--sycamore-force-ssr +# +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(sycamore_force_ssr)"] } + +[profile.release] +opt-level = "s" # optimize for small code size +debug = true # include debug symbols + +[[example]] +name = "irisawa-hexlet" +test = true +harness = false + +[[example]] +name = "kaleidocycle" +test = true +harness = false + +[[example]] +name = "point-on-sphere" +test = true +harness = false + +[[example]] +name = "three-spheres" +test = true +harness = false diff --git a/app-proto/examples/common/print.rs b/app-proto/examples/common/print.rs new file mode 100644 index 0000000..2aa6a39 --- /dev/null +++ b/app-proto/examples/common/print.rs @@ -0,0 +1,36 @@ +#![allow(dead_code)] + +use nalgebra::DMatrix; + +use dyna3::engine::{Q, DescentHistory, Realization}; + +pub fn title(title: &str) { + println!("─── {title} ───"); +} + +pub fn realization_diagnostics(realization: &Realization) { + let Realization { result, history } = realization; + println!(); + if let Err(ref message) = result { + println!("❌️ {message}"); + } else { + println!("✅️ Target accuracy achieved!"); + } + println!("Steps: {}", history.scaled_loss.len() - 1); + println!("Loss: {}", history.scaled_loss.last().unwrap()); +} + +pub fn gram_matrix(config: &DMatrix) { + println!("\nCompleted Gram matrix:{}", (config.tr_mul(&*Q) * config).to_string().trim_end()); +} + +pub fn config(config: &DMatrix) { + println!("\nConfiguration:{}", config.to_string().trim_end()); +} + +pub fn loss_history(history: &DescentHistory) { + println!("\nStep │ Loss\n─────┼────────────────────────────────"); + for (step, scaled_loss) in history.scaled_loss.iter().enumerate() { + println!("{:<4} │ {}", step, scaled_loss); + } +} \ No newline at end of file diff --git a/app-proto/examples/irisawa-hexlet.rs b/app-proto/examples/irisawa-hexlet.rs new file mode 100644 index 0000000..0d710ff --- /dev/null +++ b/app-proto/examples/irisawa-hexlet.rs @@ -0,0 +1,23 @@ +#[path = "common/print.rs"] +mod print; + +use dyna3::engine::{ConfigNeighborhood, examples::realize_irisawa_hexlet}; + +fn main() { + const SCALED_TOL: f64 = 1.0e-12; + let realization = realize_irisawa_hexlet(SCALED_TOL); + print::title("Irisawa hexlet"); + print::realization_diagnostics(&realization); + if let Ok(ConfigNeighborhood { config, .. }) = realization.result { + // print the diameters of the chain spheres + println!("\nChain diameters:"); + println!(" {} sun (given)", 1.0 / config[(3, 3)]); + for k in 4..9 { + println!(" {} sun", 1.0 / config[(3, k)]); + } + + // print the completed Gram matrix + print::gram_matrix(&config); + } + print::loss_history(&realization.history); +} \ No newline at end of file diff --git a/app-proto/examples/kaleidocycle.rs b/app-proto/examples/kaleidocycle.rs new file mode 100644 index 0000000..7ca1f97 --- /dev/null +++ b/app-proto/examples/kaleidocycle.rs @@ -0,0 +1,32 @@ +#[path = "common/print.rs"] +mod print; + +use nalgebra::{DMatrix, DVector}; + +use dyna3::engine::{ConfigNeighborhood, examples::realize_kaleidocycle}; + +fn main() { + const SCALED_TOL: f64 = 1.0e-12; + let realization = realize_kaleidocycle(SCALED_TOL); + print::title("Kaleidocycle"); + print::realization_diagnostics(&realization); + if let Ok(ConfigNeighborhood { config, nbhd: tangent }) = realization.result { + // print the completed Gram matrix and the realized configuration + print::gram_matrix(&config); + print::config(&config); + + // find the kaleidocycle's twist motion by projecting onto the tangent + // space + const N_POINTS: usize = 12; + let up = DVector::from_column_slice(&[0.0, 0.0, 1.0, 0.0]); + let down = -&up; + let twist_motion: DMatrix<_> = (0..N_POINTS).step_by(4).flat_map( + |n| [ + tangent.proj(&up.as_view(), n), + tangent.proj(&down.as_view(), n+1) + ] + ).sum(); + let normalization = 5.0 / twist_motion[(2, 0)]; + println!("\nTwist motion:{}", (normalization * twist_motion).to_string().trim_end()); + } +} \ No newline at end of file diff --git a/app-proto/examples/point-on-sphere.rs b/app-proto/examples/point-on-sphere.rs new file mode 100644 index 0000000..89dee76 --- /dev/null +++ b/app-proto/examples/point-on-sphere.rs @@ -0,0 +1,33 @@ +#[path = "common/print.rs"] +mod print; + +use dyna3::engine::{ + point, + realize_gram, + sphere, + ConfigNeighborhood, + ConstraintProblem +}; + +fn main() { + let mut problem = ConstraintProblem::from_guess(&[ + point(0.0, 0.0, 2.0), + sphere(0.0, 0.0, 0.0, 1.0) + ]); + for j in 0..2 { + for k in j..2 { + problem.gram.push_sym(j, k, if (j, k) == (1, 1) { 1.0 } else { 0.0 }); + } + } + problem.frozen.push(3, 0, problem.guess[(3, 0)]); + let realization = realize_gram( + &problem, 1.0e-12, 0.5, 0.9, 1.1, 200, 110 + ); + print::title("Point on a sphere"); + print::realization_diagnostics(&realization); + if let Ok(ConfigNeighborhood{ config, .. }) = realization.result { + print::gram_matrix(&config); + print::config(&config); + } + print::loss_history(&realization.history); +} \ No newline at end of file diff --git a/app-proto/examples/three-spheres.rs b/app-proto/examples/three-spheres.rs new file mode 100644 index 0000000..aa5a105 --- /dev/null +++ b/app-proto/examples/three-spheres.rs @@ -0,0 +1,34 @@ +#[path = "common/print.rs"] +mod print; + +use dyna3::engine::{ + realize_gram, + sphere, + ConfigNeighborhood, + ConstraintProblem +}; + +fn main() { + let mut problem = ConstraintProblem::from_guess({ + let a: f64 = 0.75_f64.sqrt(); + &[ + 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) + ] + }); + for j in 0..3 { + for k in j..3 { + problem.gram.push_sym(j, k, if j == k { 1.0 } else { -1.0 }); + } + } + let realization = realize_gram( + &problem, 1.0e-12, 0.5, 0.9, 1.1, 200, 110 + ); + print::title("Three spheres"); + print::realization_diagnostics(&realization); + if let Ok(ConfigNeighborhood{ config, .. }) = realization.result { + print::gram_matrix(&config); + } + print::loss_history(&realization.history); +} \ No newline at end of file diff --git a/app-proto/index.html b/app-proto/index.html new file mode 100644 index 0000000..4fbe52f --- /dev/null +++ b/app-proto/index.html @@ -0,0 +1,17 @@ + + + + + dyna3 + + + + + + + + + diff --git a/app-proto/main.css b/app-proto/main.css new file mode 100644 index 0000000..7981285 --- /dev/null +++ b/app-proto/main.css @@ -0,0 +1,240 @@ +:root { + --text: #fcfcfc; /* almost white */ + --text-bright: white; + --text-invalid: #f58fc2; /* bright pink */ + --border: #555; /* light gray */ + --border-focus-dark: #aaa; /* bright gray */ + --border-focus-light: white; + --border-invalid: #70495c; /* dusky pink */ + --selection-highlight: #444; /* medium gray */ + --page-background: #222; /* dark gray */ + --display-background: #020202; /* almost black */ +} + +body { + margin: 0px; + color: var(--text); + background-color: var(--page-background); + font-family: 'Fira Sans', sans-serif; +} + +.invalid { + color: var(--text-invalid); +} + +.status { + width: 20px; + text-align: center; + font-family: 'Noto Emoji'; + font-style: normal; +} + +/* sidebar */ + +#sidebar { + display: flex; + flex-direction: column; + float: left; + width: 500px; + height: 100vh; + margin: 0px; + padding: 0px; + border-width: 0px 1px 0px 0px; + border-style: solid; + border-color: var(--border); +} + +/* add-remove */ + +#add-remove { + display: flex; + gap: 8px; + margin: 8px; +} + +#add-remove > button { + height: 32px; +} + +/* 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 { + width: 32px; + font-family: 'Noto Emoji', sans-serif; + font-size: large; +} + +/* outline */ + +#outline { + flex-grow: 1; + margin: 0px; + padding: 0px; + overflow-y: scroll; +} + +li { + user-select: none; +} + +summary { + display: flex; +} + +summary.selected { + color: var(--text-bright); + background-color: var(--selection-highlight); +} + +summary > div, .regulator { + padding-top: 4px; + padding-bottom: 4px; +} + +.element, .regulator { + display: flex; + flex-grow: 1; + padding-left: 8px; + padding-right: 8px; +} + +.element > input { + margin-left: 8px; +} + +.element-switch { + width: 18px; + padding-left: 2px; + text-align: center; +} + +details:has(li) .element-switch::after { + content: '▸'; +} + +details[open]:has(li) .element-switch::after { + content: '▾'; +} + +.element-label { + flex-grow: 1; +} + +.regulator-label { + flex-grow: 1; +} + +.element-representation { + display: flex; +} + +.element-representation > div { + padding: 2px 0px 0px 0px; + font-size: 10pt; + font-variant-numeric: tabular-nums; + text-align: right; + width: 56px; +} + +.regulator { + font-style: italic; +} + +.regulator-type { + padding: 2px 8px 0px 8px; + font-size: 10pt; +} + +.regulator-input { + margin-right: 4px; + color: inherit; + background-color: inherit; + border: 1px solid var(--border); + border-radius: 2px; +} + +.regulator-input::placeholder { + color: inherit; + opacity: 54%; + font-style: italic; +} + +.regulator-input.constraint { + background-color: var(--display-background); +} + +.regulator-input.invalid { + color: var(--text-invalid); + border-color: var(--border-invalid); +} + +.regulator-input.invalid + .status::after, details:has(.invalid):not([open]) .status::after { + content: '⚠'; + color: var(--text-invalid); +} + +/* diagnostics */ + +#diagnostics { + margin: 10px; +} + +#diagnostics-bar { + display: flex; +} + +#realization-status { + display: flex; + flex-grow: 1; +} + +#realization-status .status { + margin-right: 4px; +} + +#realization-status :not(.status) { + flex-grow: 1; +} + +#realization-status .status::after { + content: '✓'; +} + +#realization-status.invalid .status::after { + content: '⚠'; +} + +.diagnostics-panel { + margin-top: 10px; + min-height: 180px; +} + +.diagnostics-chart { + background-color: var(--display-background); + border: 1px solid var(--border); + border-radius: 8px; +} + +/* display */ + +#display { + float: left; + margin-left: 20px; + margin-top: 20px; + background-color: var(--display-background); + border: 1px solid var(--border); + border-radius: 16px; +} + +#display:focus { + border-color: var(--border-focus-dark); + outline: none; +} + +input:focus { + border-color: var(--border-focus-light); + outline: none; +} \ No newline at end of file diff --git a/app-proto/run-examples.sh b/app-proto/run-examples.sh new file mode 100644 index 0000000..861addf --- /dev/null +++ b/app-proto/run-examples.sh @@ -0,0 +1,20 @@ +# run all Cargo examples, as described here: +# +# Karol Kuczmarski. "Add examples to your Rust libraries" +# http://xion.io/post/code/rust-examples.html +# +# you should invoke this script by calling `sh` or another interpreter, rather +# than calling `souce`, to ensure that the script can find the manifest file for +# the application prototype + +# find the manifest file for the application prototype +MANIFEST="$(dirname -- $0)/Cargo.toml" + +# set up the command that runs each example +RUN_EXAMPLE="cargo run --manifest-path $MANIFEST --example" + +# run the examples +$RUN_EXAMPLE irisawa-hexlet; echo +$RUN_EXAMPLE three-spheres; echo +$RUN_EXAMPLE point-on-sphere; echo +$RUN_EXAMPLE kaleidocycle \ No newline at end of file diff --git a/app-proto/src/assembly.rs b/app-proto/src/assembly.rs new file mode 100644 index 0000000..68fcd8b --- /dev/null +++ b/app-proto/src/assembly.rs @@ -0,0 +1,935 @@ +use nalgebra::{DMatrix, DVector, DVectorView}; +use std::{ + cell::Cell, + collections::{BTreeMap, BTreeSet}, + cmp::Ordering, + fmt, + fmt::{Debug, Formatter}, + hash::{Hash, Hasher}, + rc::Rc, + sync::{atomic, atomic::AtomicU64} +}; +use sycamore::prelude::*; +use web_sys::{console, wasm_bindgen::JsValue}; /* DEBUG */ + +use crate::{ + components::{display::DisplayItem, outline::OutlineItem}, + engine::{ + Q, + change_half_curvature, + local_unif_to_std, + point, + project_point_to_normalized, + project_sphere_to_normalized, + realize_gram, + sphere, + ConfigNeighborhood, + ConfigSubspace, + ConstraintProblem, + DescentHistory, + Realization + }, + specified::SpecifiedValue +}; + +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 item has a key that identifies it within its assembly and +// each assembly has a key that identifies it within the sesssion +static NEXT_SERIAL: AtomicU64 = AtomicU64::new(0); + +pub trait Serial { + // a serial number that uniquely identifies this element + fn serial(&self) -> u64; + + // take the next serial number, panicking if that was the last one left + fn next_serial() -> u64 where Self: Sized { + // 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 + // + NEXT_SERIAL.fetch_update( + atomic::Ordering::SeqCst, atomic::Ordering::SeqCst, + |serial| serial.checked_add(1) + ).expect("Out of serial numbers for elements") + } +} + +impl Hash for dyn Serial { + fn hash(&self, state: &mut H) { + self.serial().hash(state) + } +} + +impl PartialEq for dyn Serial { + fn eq(&self, other: &Self) -> bool { + self.serial() == other.serial() + } +} + +impl Eq for dyn Serial {} + +impl PartialOrd for dyn Serial { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for dyn Serial { + fn cmp(&self, other: &Self) -> Ordering { + self.serial().cmp(&other.serial()) + } +} + +pub trait ProblemPoser { + fn pose(&self, problem: &mut ConstraintProblem); +} + +pub trait Element: Serial + ProblemPoser + DisplayItem { + // the default identifier for an element of this type + fn default_id() -> String where Self: Sized; + + // the default example of an element of this type + fn default(id: String, id_num: u64) -> Self where Self: Sized; + + // the default regulators that come with this element + fn default_regulators(self: Rc) -> Vec> { + Vec::new() + } + + fn id(&self) -> &String; + fn label(&self) -> &String; + fn representation(&self) -> Signal>; + fn ghost(&self) -> Signal; + + // the regulators the element is subject to. the assembly that owns the + // element is responsible for keeping this set up to date + fn regulators(&self) -> Signal>>; + + // project a representation vector for this kind of element onto its + // normalization variety + fn project_to_normalized(&self, rep: &mut DVector); + + // the configuration matrix column index that was assigned to the element + // last time the assembly was realized, or `None` if the element has never + // been through a realization + fn column_index(&self) -> Option; + + // assign the element a configuration matrix column index. this method must + // be used carefully to preserve invariant (1), described in the comment on + // the `tangent` field of the `Assembly` structure + fn set_column_index(&self, index: usize); +} + +impl Debug for dyn Element { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> { + self.id().fmt(f) + } +} + +impl Hash for dyn Element { + fn hash(&self, state: &mut H) { + ::hash(self, state) + } +} + +impl PartialEq for dyn Element { + fn eq(&self, other: &Self) -> bool { + ::eq(self, other) + } +} + +impl Eq for dyn Element {} + +impl PartialOrd for dyn Element { + fn partial_cmp(&self, other: &Self) -> Option { + ::partial_cmp(self, other) + } +} + +impl Ord for dyn Element { + fn cmp(&self, other: &Self) -> Ordering { + ::cmp(self, other) + } +} + +pub struct Sphere { + pub id: String, + pub label: String, + pub color: ElementColor, + pub representation: Signal>, + pub ghost: Signal, + pub regulators: Signal>>, + serial: u64, + column_index: Cell> +} + +impl Sphere { + const CURVATURE_COMPONENT: usize = 3; + + pub fn new( + id: String, + label: String, + color: ElementColor, + representation: DVector + ) -> Sphere { + Sphere { + id: id, + label: label, + color: color, + representation: create_signal(representation), + ghost: create_signal(false), + regulators: create_signal(BTreeSet::new()), + serial: Self::next_serial(), + column_index: None.into() + } + } +} + +impl Element for Sphere { + fn default_id() -> String { + "sphere".to_string() + } + + fn default(id: String, id_num: u64) -> Sphere { + Sphere::new( + id, + format!("Sphere {id_num}"), + [0.75_f32, 0.75_f32, 0.75_f32], + sphere(0.0, 0.0, 0.0, 1.0) + ) + } + + fn default_regulators(self: Rc) -> Vec> { + vec![Rc::new(HalfCurvatureRegulator::new(self))] + } + + fn id(&self) -> &String { + &self.id + } + + fn label(&self) -> &String { + &self.label + } + + fn representation(&self) -> Signal> { + self.representation + } + + fn ghost(&self) -> Signal { + self.ghost + } + + fn regulators(&self) -> Signal>> { + self.regulators + } + + fn project_to_normalized(&self, rep: &mut DVector) { + project_sphere_to_normalized(rep); + } + + fn column_index(&self) -> Option { + self.column_index.get() + } + + fn set_column_index(&self, index: usize) { + self.column_index.set(Some(index)); + } +} + +impl Serial for Sphere { + fn serial(&self) -> u64 { + self.serial + } +} + +impl ProblemPoser for Sphere { + fn pose(&self, problem: &mut ConstraintProblem) { + let index = self.column_index().expect( + format!("Sphere \"{}\" should be indexed before writing problem data", self.id).as_str() + ); + problem.gram.push_sym(index, index, 1.0); + problem.guess.set_column(index, &self.representation.get_clone_untracked()); + } +} + +pub struct Point { + pub id: String, + pub label: String, + pub color: ElementColor, + pub representation: Signal>, + pub ghost: Signal, + pub regulators: Signal>>, + serial: u64, + column_index: Cell> +} + +impl Point { + const WEIGHT_COMPONENT: usize = 3; + + pub fn new( + id: String, + label: String, + color: ElementColor, + representation: DVector + ) -> Point { + Point { + id, + label, + color, + representation: create_signal(representation), + ghost: create_signal(false), + regulators: create_signal(BTreeSet::new()), + serial: Self::next_serial(), + column_index: None.into() + } + } +} + +impl Element for Point { + fn default_id() -> String { + "point".to_string() + } + + fn default(id: String, id_num: u64) -> Point { + Point::new( + id, + format!("Point {id_num}"), + [0.75_f32, 0.75_f32, 0.75_f32], + point(0.0, 0.0, 0.0) + ) + } + + fn id(&self) -> &String { + &self.id + } + + fn label(&self) -> &String { + &self.label + } + + fn representation(&self) -> Signal> { + self.representation + } + + fn ghost(&self) -> Signal { + self.ghost + } + + fn regulators(&self) -> Signal>> { + self.regulators + } + + fn project_to_normalized(&self, rep: &mut DVector) { + project_point_to_normalized(rep); + } + + fn column_index(&self) -> Option { + self.column_index.get() + } + + fn set_column_index(&self, index: usize) { + self.column_index.set(Some(index)); + } +} + +impl Serial for Point { + fn serial(&self) -> u64 { + self.serial + } +} + +impl ProblemPoser for Point { + fn pose(&self, problem: &mut ConstraintProblem) { + let index = self.column_index().expect( + format!("Point \"{}\" should be indexed before writing problem data", self.id).as_str() + ); + problem.gram.push_sym(index, index, 0.0); + problem.frozen.push(Point::WEIGHT_COMPONENT, index, 0.5); + problem.guess.set_column(index, &self.representation.get_clone_untracked()); + } +} + +pub trait Regulator: Serial + ProblemPoser + OutlineItem { + fn subjects(&self) -> Vec>; + fn measurement(&self) -> ReadSignal; + fn set_point(&self) -> Signal; + + // this method is used to responsively precondition the assembly for + // realization when the regulator becomes a constraint, or is edited while + // acting as a constraint. it should track the set point, do any desired + // preconditioning when the set point is present, and use its return value + // to report whether the set is present. the default implementation does no + // preconditioning + fn try_activate(&self) -> bool { + self.set_point().with(|set_pt| set_pt.is_present()) + } +} + +impl Hash for dyn Regulator { + fn hash(&self, state: &mut H) { + ::hash(self, state) + } +} + +impl PartialEq for dyn Regulator { + fn eq(&self, other: &Self) -> bool { + ::eq(self, other) + } +} + +impl Eq for dyn Regulator {} + +impl PartialOrd for dyn Regulator { + fn partial_cmp(&self, other: &Self) -> Option { + ::partial_cmp(self, other) + } +} + +impl Ord for dyn Regulator { + fn cmp(&self, other: &Self) -> Ordering { + ::cmp(self, other) + } +} + +pub struct InversiveDistanceRegulator { + pub subjects: [Rc; 2], + pub measurement: ReadSignal, + pub set_point: Signal, + serial: u64 +} + +impl InversiveDistanceRegulator { + pub fn new(subjects: [Rc; 2]) -> InversiveDistanceRegulator { + let representations = subjects.each_ref().map(|subj| subj.representation()); + let measurement = create_memo(move || { + representations[0].with(|rep_0| + representations[1].with(|rep_1| + rep_0.dot(&(&*Q * rep_1)) + ) + ) + }); + + let set_point = create_signal(SpecifiedValue::from_empty_spec()); + let serial = Self::next_serial(); + + InversiveDistanceRegulator { subjects, measurement, set_point, serial } + } +} + +impl Regulator for InversiveDistanceRegulator { + fn subjects(&self) -> Vec> { + self.subjects.clone().into() + } + + fn measurement(&self) -> ReadSignal { + self.measurement + } + + fn set_point(&self) -> Signal { + self.set_point + } +} + +impl Serial for InversiveDistanceRegulator { + fn serial(&self) -> u64 { + self.serial + } +} + +impl ProblemPoser for InversiveDistanceRegulator { + fn pose(&self, problem: &mut ConstraintProblem) { + self.set_point.with_untracked(|set_pt| { + if let Some(val) = set_pt.value { + let [row, col] = self.subjects.each_ref().map( + |subj| subj.column_index().expect( + "Subjects should be indexed before inversive distance regulator writes problem data" + ) + ); + problem.gram.push_sym(row, col, val); + } + }); + } +} + +pub struct HalfCurvatureRegulator { + pub subject: Rc, + pub measurement: ReadSignal, + pub set_point: Signal, + serial: u64 +} + +impl HalfCurvatureRegulator { + pub fn new(subject: Rc) -> HalfCurvatureRegulator { + let measurement = subject.representation().map( + |rep| rep[Sphere::CURVATURE_COMPONENT] + ); + + let set_point = create_signal(SpecifiedValue::from_empty_spec()); + let serial = Self::next_serial(); + + HalfCurvatureRegulator { subject, measurement, set_point, serial } + } +} + +impl Regulator for HalfCurvatureRegulator { + fn subjects(&self) -> Vec> { + vec![self.subject.clone()] + } + + fn measurement(&self) -> ReadSignal { + self.measurement + } + + fn set_point(&self) -> Signal { + self.set_point + } + + fn try_activate(&self) -> bool { + match self.set_point.with(|set_pt| set_pt.value) { + Some(half_curv) => { + self.subject.representation().update( + |rep| change_half_curvature(rep, half_curv) + ); + true + } + None => false + } + } +} + +impl Serial for HalfCurvatureRegulator { + fn serial(&self) -> u64 { + self.serial + } +} + +impl ProblemPoser for HalfCurvatureRegulator { + fn pose(&self, problem: &mut ConstraintProblem) { + self.set_point.with_untracked(|set_pt| { + if let Some(val) = set_pt.value { + let col = self.subject.column_index().expect( + "Subject should be indexed before half-curvature regulator writes problem data" + ); + problem.frozen.push(Sphere::CURVATURE_COMPONENT, col, val); + } + }); + } +} + +// the velocity is expressed in uniform coordinates +pub struct ElementMotion<'a> { + pub element: Rc, + pub velocity: DVectorView<'a, f64> +} + +type AssemblyMotion<'a> = Vec>; + +// a complete, view-independent description of an assembly +#[derive(Clone)] +pub struct Assembly { + // elements and regulators + pub elements: Signal>>, + pub regulators: Signal>>, + + // solution variety tangent space. the basis vectors are stored in + // configuration matrix format, ordered according to the elements' column + // indices. when you realize the assembly, every element that's present + // during realization gets a column index and is reflected in the tangent + // space. since the methods in this module never assign column indices + // without later realizing the assembly, we get the following invariant: + // + // (1) if an element has a column index, its tangent motions can be found + // in that column of the tangent space basis matrices + // + pub tangent: Signal, + + // indexing + pub elements_by_id: Signal>>, + + // realization control + pub keep_realized: Signal, + pub needs_realization: Signal, + + // realization diagnostics + pub realization_status: Signal>, + pub descent_history: Signal +} + +impl Assembly { + pub fn new() -> Assembly { + // create an assembly + let assembly = Assembly { + elements: create_signal(BTreeSet::new()), + regulators: create_signal(BTreeSet::new()), + tangent: create_signal(ConfigSubspace::zero(0)), + elements_by_id: create_signal(BTreeMap::default()), + keep_realized: create_signal(true), + needs_realization: create_signal(false), + realization_status: create_signal(Ok(())), + descent_history: create_signal(DescentHistory::new()) + }; + + // realize the assembly whenever it becomes simultaneously true that + // we're trying to keep it realized and it needs realization + let assembly_for_effect = assembly.clone(); + create_effect(move || { + let should_realize = assembly_for_effect.keep_realized.get() + && assembly_for_effect.needs_realization.get(); + if should_realize { + assembly_for_effect.realize(); + } + }); + + assembly + } + + // --- inserting elements and regulators --- + + // insert an element into the assembly without checking whether we already + // have an element with the same identifier. any element that does have the + // same identifier will get kicked out of the `elements_by_id` index + fn insert_element_unchecked(&self, elt: impl Element + 'static) { + // insert the element + let id = elt.id().clone(); + let elt_rc = Rc::new(elt); + self.elements.update(|elts| elts.insert(elt_rc.clone())); + self.elements_by_id.update(|elts_by_id| elts_by_id.insert(id, elt_rc.clone())); + + // create and insert the element's default regulators + for reg in elt_rc.default_regulators() { + self.insert_regulator(reg); + } + } + + pub fn try_insert_element(&self, elt: impl Element + 'static) -> bool { + let can_insert = self.elements_by_id.with_untracked( + |elts_by_id| !elts_by_id.contains_key(elt.id()) + ); + if can_insert { + self.insert_element_unchecked(elt); + } + can_insert + } + + pub fn insert_element_default(&self) { + // find the next unused identifier in the default sequence + let default_id = T::default_id(); + let mut id_num = 1; + let mut id = format!("{default_id}{id_num}"); + while self.elements_by_id.with_untracked( + |elts_by_id| elts_by_id.contains_key(&id) + ) { + id_num += 1; + id = format!("{default_id}{id_num}"); + } + + // create and insert the default example of `T` + let _ = self.insert_element_unchecked(T::default(id, id_num)); + } + + pub fn insert_regulator(&self, regulator: Rc) { + // add the regulator to the assembly's regulator list + self.regulators.update( + |regs| regs.insert(regulator.clone()) + ); + + // add the regulator to each subject's regulator list + let subject_regulators: Vec<_> = regulator.subjects().into_iter().map( + |subj| subj.regulators() + ).collect(); + for regulators in subject_regulators { + regulators.update(|regs| regs.insert(regulator.clone())); + } + + // request a realization when the regulator becomes a constraint, or is + // edited while acting as a constraint + let self_for_effect = self.clone(); + create_effect(move || { + /* DEBUG */ + // log the regulator update + console_log!("Updated regulator with subjects {:?}", regulator.subjects()); + + if regulator.try_activate() { + self_for_effect.needs_realization.set(true); + } + }); + + /* DEBUG */ + // print an updated list of regulators + console_log!("Regulators:"); + self.regulators.with_untracked(|regs| { + for reg in regs.into_iter() { + console_log!( + " {:?}: {}", + reg.subjects(), + reg.set_point().with_untracked( + |set_pt| { + let spec = &set_pt.spec; + if spec.is_empty() { + "__".to_string() + } else { + spec.clone() + } + } + ) + ); + } + }); + } + + // --- realization --- + + pub fn realize(&self) { + // index the elements + self.elements.update_silent(|elts| { + for (index, elt) in elts.iter().enumerate() { + elt.set_column_index(index); + } + }); + + // set up the constraint problem + let problem = self.elements.with_untracked(|elts| { + let mut problem = ConstraintProblem::new(elts.len()); + for elt in elts { + elt.pose(&mut problem); + } + self.regulators.with_untracked(|regs| { + for reg in regs { + reg.pose(&mut problem); + } + }); + problem + }); + + /* DEBUG */ + // log the Gram matrix + console_log!("Gram matrix:\n{}", problem.gram); + + /* DEBUG */ + // log the initial configuration matrix + console_log!("Old configuration:{:>8.3}", problem.guess); + + // look for a configuration with the given Gram matrix + let Realization { result, history } = realize_gram( + &problem, 1.0e-12, 0.5, 0.9, 1.1, 200, 110 + ); + + /* DEBUG */ + // report the outcome of the search in the browser console + if let Err(ref message) = result { + console_log!("❌️ {message}"); + } else { + console_log!("✅️ Target accuracy achieved!"); + } + console_log!("Steps: {}", history.scaled_loss.len() - 1); + console_log!("Loss: {}", history.scaled_loss.last().unwrap()); + + // report the loss history + self.descent_history.set(history); + + match result { + Ok(ConfigNeighborhood { config, nbhd: tangent }) => { + /* DEBUG */ + // report the tangent dimension + console_log!("Tangent dimension: {}", tangent.dim()); + + // report the realization status + self.realization_status.set(Ok(())); + + // read out the solution + for elt in self.elements.get_clone_untracked() { + elt.representation().update( + |rep| rep.set_column(0, &config.column(elt.column_index().unwrap())) + ); + } + + // save the tangent space + self.tangent.set_silent(tangent); + + // clear the realization request flag + self.needs_realization.set(false); + }, + Err(message) => { + // report the realization status. the `Err(message)` we're + // setting the status to has a different type than the + // `Err(message)` we received from the match: we're changing the + // `Ok` type from `Realization` to `()` + self.realization_status.set(Err(message)) + } + } + } + + // --- deformation --- + + // project the given motion to the tangent space of the solution variety and + // move the assembly along it. the implementation is based on invariant (1) + // from above and the following additional invariant: + // + // (2) if an element is affected by a constraint, it has a column index + // + // we have this invariant because the assembly gets realized each time you + // add a constraint + pub fn deform(&self, motion: AssemblyMotion) { + /* KLUDGE */ + // when the tangent space is zero, deformation won't do anything, but + // the attempt to deform should be registered in the UI. this console + // message will do for now + if self.tangent.with(|tan| tan.dim() <= 0 && tan.assembly_dim() > 0) { + console::log_1(&JsValue::from("The assembly is rigid")); + } + + // give a column index to each moving element that doesn't have one yet. + // this temporarily breaks invariant (1), but the invariant will be + // restored when we realize the assembly at the end of the deformation. + // in the process, we find out how many matrix columns we'll need to + // hold the deformation + let realized_dim = self.tangent.with(|tan| tan.assembly_dim()); + let motion_dim = { + let mut next_column_index = realized_dim; + for elt_motion in motion.iter() { + let moving_elt = &elt_motion.element; + if moving_elt.column_index().is_none() { + moving_elt.set_column_index(next_column_index); + next_column_index += 1; + } + } + next_column_index + }; + + // project the element motions onto the tangent space of the solution + // variety and sum them to get a deformation of the whole assembly. the + // matrix `motion_proj` that holds the deformation has extra columns for + // any moving elements that aren't reflected in the saved tangent space + const ELEMENT_DIM: usize = 5; + let mut motion_proj = DMatrix::zeros(ELEMENT_DIM, motion_dim); + for elt_motion in motion { + // we can unwrap the column index because we know that every moving + // element has one at this point + let column_index = elt_motion.element.column_index().unwrap(); + + if column_index < realized_dim { + // this element had a column index when we started, so by + // invariant (1), it's reflected in the tangent space + let mut target_columns = motion_proj.columns_mut(0, realized_dim); + target_columns += self.tangent.with( + |tan| tan.proj(&elt_motion.velocity, column_index) + ); + } else { + // this element didn't have a column index when we started, so + // by invariant (2), it's unconstrained + let mut target_column = motion_proj.column_mut(column_index); + let unif_to_std = elt_motion.element.representation().with_untracked( + |rep| local_unif_to_std(rep.as_view()) + ); + target_column += unif_to_std * elt_motion.velocity; + } + } + + // step the assembly along the deformation. this changes the elements' + // normalizations, so we restore those afterward + for elt in self.elements.get_clone_untracked() { + elt.representation().update_silent(|rep| { + match elt.column_index() { + Some(column_index) => { + // step the element along the deformation and then + // restore its normalization + *rep += motion_proj.column(column_index); + elt.project_to_normalized(rep); + }, + None => { + console_log!("No velocity to unpack for fresh element \"{}\"", elt.id()) + } + }; + }); + } + + // request a realization to bring the configuration back onto the + // solution variety. this also gets the elements' column indices and the + // saved tangent space back in sync + self.needs_realization.set(true); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::engine; + + #[test] + #[should_panic(expected = "Sphere \"sphere\" should be indexed before writing problem data")] + fn unindexed_element_test() { + let _ = create_root(|| { + let elt = Sphere::default("sphere".to_string(), 0); + elt.pose(&mut ConstraintProblem::new(1)); + }); + } + + #[test] + #[should_panic(expected = "Subjects should be indexed before inversive distance regulator writes problem data")] + fn unindexed_subject_test_inversive_distance() { + let _ = create_root(|| { + let subjects = [0, 1].map( + |k| Rc::new(Sphere::default(format!("sphere{k}"), k)) as Rc + ); + subjects[0].set_column_index(0); + InversiveDistanceRegulator { + subjects: subjects, + measurement: create_memo(|| 0.0), + set_point: create_signal(SpecifiedValue::try_from("0.0".to_string()).unwrap()), + serial: InversiveDistanceRegulator::next_serial() + }.pose(&mut ConstraintProblem::new(2)); + }); + } + + #[test] + fn curvature_drift_test() { + const INITIAL_RADIUS: f64 = 0.25; + let _ = create_root(|| { + // set up an assembly containing a single sphere centered at the + // origin + let assembly = Assembly::new(); + let sphere_id = "sphere0"; + let _ = assembly.try_insert_element( + // we create the sphere by hand for two reasons: to choose the + // curvature (which can affect drift rate) and to make the test + // independent of `Sphere::default` + Sphere::new( + String::from(sphere_id), + String::from("Sphere 0"), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere(0.0, 0.0, 0.0, INITIAL_RADIUS) + ) + ); + + // nudge the sphere repeatedly along the `z` axis + const STEP_SIZE: f64 = 0.0025; + const STEP_CNT: usize = 400; + let sphere = assembly.elements_by_id.with(|elts_by_id| elts_by_id[sphere_id].clone()); + let velocity = DVector::from_column_slice(&[0.0, 0.0, STEP_SIZE, 0.0]); + for _ in 0..STEP_CNT { + assembly.deform( + vec![ + ElementMotion { + element: sphere.clone(), + velocity: velocity.as_view() + } + ] + ); + } + + // check how much the sphere's curvature has drifted + const INITIAL_HALF_CURV: f64 = 0.5 / INITIAL_RADIUS; + const DRIFT_TOL: f64 = 0.015; + let final_half_curv = sphere.representation().with_untracked( + |rep| rep[Sphere::CURVATURE_COMPONENT] + ); + assert!((final_half_curv / INITIAL_HALF_CURV - 1.0).abs() < DRIFT_TOL); + }); + } +} \ No newline at end of file diff --git a/app-proto/src/components.rs b/app-proto/src/components.rs new file mode 100644 index 0000000..7387d58 --- /dev/null +++ b/app-proto/src/components.rs @@ -0,0 +1,5 @@ +pub mod add_remove; +pub mod diagnostics; +pub mod display; +pub mod outline; +pub mod test_assembly_chooser; \ No newline at end of file diff --git a/app-proto/src/components/add_remove.rs b/app-proto/src/components/add_remove.rs new file mode 100644 index 0000000..3b0f9e0 --- /dev/null +++ b/app-proto/src/components/add_remove.rs @@ -0,0 +1,54 @@ +use std::rc::Rc; +use sycamore::prelude::*; + +use super::test_assembly_chooser::TestAssemblyChooser; +use crate::{ + AppState, + assembly::{InversiveDistanceRegulator, Point, Sphere} +}; + +#[component] +pub fn AddRemove() -> View { + view! { + div(id="add-remove") { + button( + on:click=|_| { + let state = use_context::(); + state.assembly.insert_element_default::(); + } + ) { "Add sphere" } + button( + on:click=|_| { + let state = use_context::(); + state.assembly.insert_element_default::(); + } + ) { "Add point" } + button( + class="emoji", /* KLUDGE */ // for convenience, we're using an emoji as a temporary icon for this button + disabled={ + let state = use_context::(); + state.selection.with(|sel| sel.len() != 2) + }, + on:click=|_| { + let state = use_context::(); + let subjects: [_; 2] = state.selection.with( + // the button is only enabled when two elements are + // selected, so we know the cast to a two-element array + // will succeed + |sel| sel + .clone() + .into_iter() + .collect::>() + .try_into() + .unwrap() + ); + state.assembly.insert_regulator( + Rc::new(InversiveDistanceRegulator::new(subjects)) + ); + state.selection.update(|sel| sel.clear()); + } + ) { "🔗" } + TestAssemblyChooser {} + } + } +} \ No newline at end of file diff --git a/app-proto/src/components/diagnostics.rs b/app-proto/src/components/diagnostics.rs new file mode 100644 index 0000000..a2f090a --- /dev/null +++ b/app-proto/src/components/diagnostics.rs @@ -0,0 +1,258 @@ +use charming::{ + Chart, + WasmRenderer, + component::{Axis, DataZoom, Grid}, + element::{AxisType, Symbol}, + series::{Line, Scatter}, +}; +use sycamore::prelude::*; + +use crate::AppState; + +#[derive(Clone)] +struct DiagnosticsState { + active_tab: Signal +} + +impl DiagnosticsState { + fn new(initial_tab: String) -> DiagnosticsState { + DiagnosticsState { + active_tab: create_signal(initial_tab) + } + } +} + +// a realization status indicator +#[component] +fn RealizationStatus() -> View { + let state = use_context::(); + let realization_status = state.assembly.realization_status; + view! { + div( + id="realization-status", + class=realization_status.with( + |status| match status { + Ok(_) => "", + Err(_) => "invalid" + } + ) + ) { + div(class="status") + div { + (realization_status.with( + |status| match status { + Ok(_) => "Target accuracy achieved".to_string(), + Err(message) => message.clone() + } + )) + } + } + } +} + +fn into_log10_time_point((step, value): (usize, f64)) -> Vec> { + vec![ + Some(step as f64), + if value == 0.0 { None } else { Some(value.abs().log10()) } + ] +} + +// the loss history from the last realization +#[component] +fn LossHistory() -> View { + const CONTAINER_ID: &str = "loss-history"; + let state = use_context::(); + let renderer = WasmRenderer::new_opt(None, Some(178)); + + on_mount(move || { + create_effect(move || { + // get the loss history + let scaled_loss: Vec<_> = state.assembly.descent_history.with( + |history| history.scaled_loss + .iter() + .enumerate() + .map(|(step, &loss)| (step, loss)) + .map(into_log10_time_point) + .collect() + ); + + // initialize the chart axes + let step_axis = Axis::new() + .type_(AxisType::Category) + .boundary_gap(false); + let scaled_loss_axis = Axis::new(); + + // load the chart data. when there's no history, we load the data + // point (0, None) to clear the chart. it would feel more natural to + // load empty data vectors, but that turns out not to clear the + // chart: it instead leads to previous data being re-used + let scaled_loss_series = Line::new().data( + if scaled_loss.len() > 0 { + scaled_loss + } else { + vec![vec![Some(0.0), None::]] + } + ); + let chart = Chart::new() + .animation(false) + .data_zoom(DataZoom::new().y_axis_index(0).right(40)) + .x_axis(step_axis) + .y_axis(scaled_loss_axis) + .grid(Grid::new().top(20).right(80).bottom(30).left(60)) + .series(scaled_loss_series); + renderer.render(CONTAINER_ID, &chart).unwrap(); + }); + }); + + view! { + div(id=CONTAINER_ID, class="diagnostics-chart") + } +} + +// the spectrum of the Hessian during the last realization +#[component] +fn SpectrumHistory() -> View { + const CONTAINER_ID: &str = "spectrum-history"; + let state = use_context::(); + let renderer = WasmRenderer::new(478, 178); + + on_mount(move || { + create_effect(move || { + // get the spectrum of the Hessian at each step, split into its + // positive, negative, and strictly-zero parts + let ( + hess_eigvals_zero, + hess_eigvals_nonzero + ): (Vec<_>, Vec<_>) = state.assembly.descent_history.with( + |history| history.hess_eigvals + .iter() + .enumerate() + .map( + |(step, eigvals)| eigvals.iter().map( + move |&val| (step, val) + ) + ) + .flatten() + .partition(|&(_, val)| val == 0.0) + ); + let zero_level = hess_eigvals_nonzero + .iter() + .map(|(_, val)| val.abs()) + .reduce(f64::min) + .map(|val| 0.1 * val) + .unwrap_or(1.0); + let ( + hess_eigvals_pos, + hess_eigvals_neg + ): (Vec<_>, Vec<_>) = hess_eigvals_nonzero + .into_iter() + .partition(|&(_, val)| val > 0.0); + + // initialize the chart axes + let step_axis = Axis::new() + .type_(AxisType::Category) + .boundary_gap(false); + let eigval_axis = Axis::new(); + + // load the chart data. when there's no history, we load the data + // point (0, None) to clear the chart. it would feel more natural to + // load empty data vectors, but that turns out not to clear the + // chart: it instead leads to previous data being re-used + let eigval_series_pos = Scatter::new() + .symbol_size(4.5) + .data( + if hess_eigvals_pos.len() > 0 { + hess_eigvals_pos + .into_iter() + .map(into_log10_time_point) + .collect() + } else { + vec![vec![Some(0.0), None::]] + } + ); + let eigval_series_neg = Scatter::new() + .symbol(Symbol::Diamond) + .symbol_size(6.0) + .data( + if hess_eigvals_neg.len() > 0 { + hess_eigvals_neg + .into_iter() + .map(into_log10_time_point) + .collect() + } else { + vec![vec![Some(0.0), None::]] + } + ); + let eigval_series_zero = Scatter::new() + .symbol(Symbol::Triangle) + .symbol_size(5.0) + .data( + if hess_eigvals_zero.len() > 0 { + hess_eigvals_zero + .into_iter() + .map(|(step, _)| (step, zero_level)) + .map(into_log10_time_point) + .collect() + } else { + vec![vec![Some(0.0), None::]] + } + ); + let chart = Chart::new() + .animation(false) + .data_zoom(DataZoom::new().y_axis_index(0).right(40)) + .x_axis(step_axis) + .y_axis(eigval_axis) + .grid(Grid::new().top(20).right(80).bottom(30).left(60)) + .series(eigval_series_pos) + .series(eigval_series_neg) + .series(eigval_series_zero); + renderer.render(CONTAINER_ID, &chart).unwrap(); + }); + }); + + view! { + div(id=CONTAINER_ID, class="diagnostics-chart") + } +} + +#[component(inline_props)] +fn DiagnosticsPanel(name: &'static str, children: Children) -> View { + let diagnostics_state = use_context::(); + view! { + div( + class="diagnostics-panel", + "hidden"=diagnostics_state.active_tab.with( + |active_tab| { + if active_tab == name { + None + } else { + Some("") + } + } + ) + ) { + (children) + } + } +} + +#[component] +pub fn Diagnostics() -> View { + let diagnostics_state = DiagnosticsState::new("loss".to_string()); + let active_tab = diagnostics_state.active_tab.clone(); + provide_context(diagnostics_state); + + view! { + div(id="diagnostics") { + div(id="diagnostics-bar") { + RealizationStatus {} + select(bind:value=active_tab) { + option(value="loss") { "Loss" } + option(value="spectrum") { "Spectrum" } + } + } + DiagnosticsPanel(name="loss") { LossHistory {} } + DiagnosticsPanel(name="spectrum") { SpectrumHistory {} } + } + } +} \ No newline at end of file diff --git a/app-proto/src/components/display.rs b/app-proto/src/components/display.rs new file mode 100644 index 0000000..1646c4e --- /dev/null +++ b/app-proto/src/components/display.rs @@ -0,0 +1,900 @@ +use core::array; +use nalgebra::{DMatrix, DVector, Rotation3, Vector3}; +use std::rc::Rc; +use sycamore::{prelude::*, motion::create_raf}; +use web_sys::{ + console, + window, + KeyboardEvent, + MouseEvent, + WebGl2RenderingContext, + WebGlBuffer, + WebGlProgram, + WebGlShader, + WebGlUniformLocation, + wasm_bindgen::{JsCast, JsValue} +}; + +use crate::{ + AppState, + assembly::{Element, ElementColor, ElementMotion, Point, Sphere} +}; + +// --- color --- + +const COLOR_SIZE: usize = 3; +type ColorWithOpacity = [f32; COLOR_SIZE + 1]; + +fn combine_channels(color: ElementColor, opacity: f32) -> ColorWithOpacity { + let mut color_with_opacity = [0.0; COLOR_SIZE + 1]; + color_with_opacity[..COLOR_SIZE].copy_from_slice(&color); + color_with_opacity[COLOR_SIZE] = opacity; + color_with_opacity +} + +// --- scene data --- + +struct SceneSpheres { + representations: Vec>, + colors_with_opacity: Vec, + highlights: Vec +} + +impl SceneSpheres { + fn new() -> SceneSpheres{ + SceneSpheres { + representations: Vec::new(), + colors_with_opacity: Vec::new(), + highlights: Vec::new() + } + } + + fn len_i32(&self) -> i32 { + self.representations.len().try_into().expect("Number of spheres must fit in a 32-bit integer") + } + + fn push(&mut self, representation: DVector, color: ElementColor, opacity: f32, highlight: f32) { + self.representations.push(representation); + self.colors_with_opacity.push(combine_channels(color, opacity)); + self.highlights.push(highlight); + } +} + +struct ScenePoints { + representations: Vec>, + colors_with_opacity: Vec, + highlights: Vec, + selections: Vec +} + +impl ScenePoints { + fn new() -> ScenePoints { + ScenePoints { + representations: Vec::new(), + colors_with_opacity: Vec::new(), + highlights: Vec::new(), + selections: Vec::new() + } + } + + fn push(&mut self, representation: DVector, color: ElementColor, opacity: f32, highlight: f32, selected: bool) { + self.representations.push(representation); + self.colors_with_opacity.push(combine_channels(color, opacity)); + self.highlights.push(highlight); + self.selections.push(if selected { 1.0 } else { 0.0 }); + } +} + +pub struct Scene { + spheres: SceneSpheres, + points: ScenePoints +} + +impl Scene { + fn new() -> Scene { + Scene { + spheres: SceneSpheres::new(), + points: ScenePoints::new() + } + } +} + +pub trait DisplayItem { + fn show(&self, scene: &mut Scene, selected: bool); + + // the smallest positive depth, represented as a multiple of `dir`, where + // the line generated by `dir` hits the element. returns `None` if the line + // misses the element + fn cast(&self, dir: Vector3, assembly_to_world: &DMatrix, pixel_size: f64) -> Option; +} + +impl DisplayItem for Sphere { + fn show(&self, scene: &mut Scene, selected: bool) { + /* SCAFFOLDING */ + const DEFAULT_OPACITY: f32 = 0.5; + const GHOST_OPACITY: f32 = 0.2; + const HIGHLIGHT: f32 = 0.2; + + let representation = self.representation.get_clone_untracked(); + let color = if selected { self.color.map(|channel| 0.2 + 0.8*channel) } else { self.color }; + let opacity = if self.ghost.get() { GHOST_OPACITY } else { DEFAULT_OPACITY }; + let highlight = if selected { 1.0 } else { HIGHLIGHT }; + scene.spheres.push(representation, color, opacity, highlight); + } + + // this method should be kept synchronized with `sphere_cast` in + // `spheres.frag`, which does essentially the same thing on the GPU side + fn cast(&self, dir: Vector3, assembly_to_world: &DMatrix, _pixel_size: f64) -> Option { + // if `a/b` is less than this threshold, we approximate + // `a*u^2 + b*u + c` by the linear function `b*u + c` + const DEG_THRESHOLD: f64 = 1e-9; + + let rep = self.representation.with_untracked(|rep| assembly_to_world * rep); + let a = -rep[3] * dir.norm_squared(); + let b = rep.rows_range(..3).dot(&dir); + let c = -rep[4]; + + let adjust = 4.0*a*c/(b*b); + if adjust < 1.0 { + // as long as `b` is non-zero, the linear approximation of + // + // a*u^2 + b*u + c + // + // at `u = 0` will reach zero at a finite depth `u_lin`. the root of + // the quadratic adjacent to `u_lin` is stored in `lin_root`. if + // both roots have the same sign, `lin_root` will be the one closer + // to `u = 0` + let square_rect_ratio = 1.0 + (1.0 - adjust).sqrt(); + let lin_root = -(2.0*c)/b / square_rect_ratio; + if a.abs() > DEG_THRESHOLD * b.abs() { + if lin_root > 0.0 { + Some(lin_root) + } else { + let other_root = -b/(2.*a) * square_rect_ratio; + (other_root > 0.0).then_some(other_root) + } + } else { + (lin_root > 0.0).then_some(lin_root) + } + } else { + // the line through `dir` misses the sphere completely + None + } + } +} + +impl DisplayItem for Point { + fn show(&self, scene: &mut Scene, selected: bool) { + /* SCAFFOLDING */ + const GHOST_OPACITY: f32 = 0.4; + const HIGHLIGHT: f32 = 0.5; + + let representation = self.representation.get_clone_untracked(); + let color = if selected { self.color.map(|channel| 0.2 + 0.8*channel) } else { self.color }; + let opacity = if self.ghost.get() { GHOST_OPACITY } else { 1.0 }; + let highlight = if selected { 1.0 } else { HIGHLIGHT }; + scene.points.push(representation, color, opacity, highlight, selected); + } + + /* SCAFFOLDING */ + fn cast(&self, dir: Vector3, assembly_to_world: &DMatrix, pixel_size: f64) -> Option { + let rep = self.representation.with_untracked(|rep| assembly_to_world * rep); + if rep[2] < 0.0 { + // this constant should be kept synchronized with `point.frag` + const POINT_RADIUS_PX: f64 = 4.0; + + // find the radius of the point in screen projection units + let point_radius_proj = POINT_RADIUS_PX * pixel_size; + + // find the squared distance between the screen projections of the + // ray and the point + let dir_proj = -dir.fixed_rows::<2>(0) / dir[2]; + let rep_proj = -rep.fixed_rows::<2>(0) / rep[2]; + let dist_sq = (dir_proj - rep_proj).norm_squared(); + + // if the ray hits the point, return its depth + if dist_sq < point_radius_proj * point_radius_proj { + Some(rep[2] / dir[2]) + } else { + None + } + } else { + None + } + } +} + +// --- WebGL utilities --- + +fn compile_shader( + context: &WebGl2RenderingContext, + shader_type: u32, + source: &str, +) -> WebGlShader { + let shader = context.create_shader(shader_type).unwrap(); + context.shader_source(&shader, source); + context.compile_shader(&shader); + shader +} + +fn set_up_program( + context: &WebGl2RenderingContext, + vertex_shader_source: &str, + fragment_shader_source: &str +) -> WebGlProgram { + // compile the shaders + let vertex_shader = compile_shader( + &context, + WebGl2RenderingContext::VERTEX_SHADER, + vertex_shader_source, + ); + let fragment_shader = compile_shader( + &context, + WebGl2RenderingContext::FRAGMENT_SHADER, + fragment_shader_source, + ); + + // create the program and attach the shaders + let program = context.create_program().unwrap(); + context.attach_shader(&program, &vertex_shader); + context.attach_shader(&program, &fragment_shader); + context.link_program(&program); + + /* DEBUG */ + // report whether linking succeeded + let link_status = context + .get_program_parameter(&program, WebGl2RenderingContext::LINK_STATUS) + .as_bool() + .unwrap(); + let link_msg = if link_status { + "Linked successfully" + } else { + "Linking failed" + }; + console::log_1(&JsValue::from(link_msg)); + + program +} + +fn get_uniform_array_locations( + context: &WebGl2RenderingContext, + program: &WebGlProgram, + var_name: &str, + member_name_opt: Option<&str> +) -> [Option; N] { + array::from_fn(|n| { + let name = match member_name_opt { + Some(member_name) => format!("{var_name}[{n}].{member_name}"), + None => format!("{var_name}[{n}]") + }; + context.get_uniform_location(&program, name.as_str()) + }) +} + +// bind the given vertex buffer object to the given vertex attribute +fn bind_to_attribute( + context: &WebGl2RenderingContext, + attr_index: u32, + attr_size: i32, + buffer: &Option +) { + context.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, buffer.as_ref()); + context.vertex_attrib_pointer_with_i32( + attr_index, + attr_size, + WebGl2RenderingContext::FLOAT, + false, // don't normalize + 0, // zero stride + 0, // zero offset + ); +} + +// load the given data into a new vertex buffer object +fn load_new_buffer( + context: &WebGl2RenderingContext, + data: &[f32] +) -> Option { + // create a buffer and bind it to ARRAY_BUFFER + let buffer = context.create_buffer(); + context.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, buffer.as_ref()); + + // load the given data into the buffer. this block is unsafe because + // `Float32Array::view` creates a raw view into our module's + // `WebAssembly.Memory` buffer. allocating more memory will change the + // buffer, invalidating the view, so we have to make sure we don't allocate + // any memory until the view is dropped. we're okay here because the view is + // used as soon as it's created + unsafe { + context.buffer_data_with_array_buffer_view( + WebGl2RenderingContext::ARRAY_BUFFER, + &js_sys::Float32Array::view(&data), + WebGl2RenderingContext::STATIC_DRAW, + ); + } + + buffer +} + +fn bind_new_buffer_to_attribute( + context: &WebGl2RenderingContext, + attr_index: u32, + attr_size: i32, + data: &[f32] +) { + let buffer = load_new_buffer(context, data); + bind_to_attribute(context, attr_index, attr_size, &buffer); +} + +// the direction in camera space that a mouse event is pointing along +fn event_dir(event: &MouseEvent) -> (Vector3, f64) { + let target: web_sys::Element = event.target().unwrap().unchecked_into(); + let rect = target.get_bounding_client_rect(); + let width = rect.width(); + let height = rect.height(); + let shortdim = width.min(height); + + // this constant should be kept synchronized with `spheres.frag` and + // `point.vert` + const FOCAL_SLOPE: f64 = 0.3; + + ( + Vector3::new( + FOCAL_SLOPE * (2.0*(f64::from(event.client_x()) - rect.left()) - width) / shortdim, + FOCAL_SLOPE * (2.0*(rect.bottom() - f64::from(event.client_y())) - height) / shortdim, + -1.0 + ), + FOCAL_SLOPE * 2.0 / shortdim + ) +} + +// --- display component --- + +#[component] +pub fn Display() -> View { + let state = use_context::(); + + // canvas + let display = create_node_ref(); + + // viewpoint + let assembly_to_world = create_signal(DMatrix::::identity(5, 5)); + + // navigation + let pitch_up = create_signal(0.0); + let pitch_down = create_signal(0.0); + let yaw_right = create_signal(0.0); + let yaw_left = create_signal(0.0); + let roll_ccw = create_signal(0.0); + let roll_cw = create_signal(0.0); + let zoom_in = create_signal(0.0); + let zoom_out = create_signal(0.0); + let turntable = create_signal(false); /* BENCHMARKING */ + + // manipulation + let translate_neg_x = create_signal(0.0); + let translate_pos_x = create_signal(0.0); + let translate_neg_y = create_signal(0.0); + let translate_pos_y = create_signal(0.0); + let translate_neg_z = create_signal(0.0); + let translate_pos_z = create_signal(0.0); + let shrink_neg = create_signal(0.0); + let shrink_pos = create_signal(0.0); + + // change listener + let scene_changed = create_signal(true); + create_effect(move || { + state.assembly.elements.with(|elts| { + for elt in elts { + elt.representation().track(); + elt.ghost().track(); + } + }); + state.selection.track(); + scene_changed.set(true); + }); + + /* INSTRUMENTS */ + const SAMPLE_PERIOD: i32 = 60; + let mut last_sample_time = 0.0; + let mut frames_since_last_sample = 0; + let mean_frame_interval = create_signal(0.0); + + let assembly_for_raf = state.assembly.clone(); + on_mount(move || { + // timing + let mut last_time = 0.0; + + // viewpoint + const ROT_SPEED: f64 = 0.4; // in radians per second + const ZOOM_SPEED: f64 = 0.15; // multiplicative rate per second + const TURNTABLE_SPEED: f64 = 0.1; /* BENCHMARKING */ + let mut orientation = DMatrix::::identity(5, 5); + let mut rotation = DMatrix::::identity(5, 5); + let mut location_z: f64 = 5.0; + + // manipulation + const TRANSLATION_SPEED: f64 = 0.15; // in length units per second + const SHRINKING_SPEED: f64 = 0.15; // in length units per second + + // display parameters + const LAYER_THRESHOLD: i32 = 0; /* DEBUG */ + const DEBUG_MODE: i32 = 0; /* DEBUG */ + + /* INSTRUMENTS */ + let performance = window().unwrap().performance().unwrap(); + + // get the display canvas + let canvas = display.get().unchecked_into::(); + let ctx = canvas + .get_context("webgl2") + .unwrap() + .unwrap() + .dyn_into::() + .unwrap(); + + // disable depth testing + ctx.disable(WebGl2RenderingContext::DEPTH_TEST); + + // set blend mode + ctx.enable(WebGl2RenderingContext::BLEND); + ctx.blend_func(WebGl2RenderingContext::SRC_ALPHA, WebGl2RenderingContext::ONE_MINUS_SRC_ALPHA); + + // set up the sphere rendering program + let sphere_program = set_up_program( + &ctx, + include_str!("identity.vert"), + include_str!("spheres.frag") + ); + + // set up the point rendering program + let point_program = set_up_program( + &ctx, + include_str!("point.vert"), + include_str!("point.frag") + ); + + /* DEBUG */ + // print the maximum number of vectors that can be passed as + // uniforms to a fragment shader. the OpenGL ES 3.0 standard + // requires this maximum to be at least 224, as discussed in the + // documentation of the GL_MAX_FRAGMENT_UNIFORM_VECTORS parameter + // here: + // + // https://registry.khronos.org/OpenGL-Refpages/es3.0/html/glGet.xhtml + // + // there are also other size limits. for example, on Aaron's + // machine, the the length of a float or genType array seems to be + // capped at 1024 elements + console::log_2( + &ctx.get_parameter(WebGl2RenderingContext::MAX_FRAGMENT_UNIFORM_VECTORS).unwrap(), + &JsValue::from("uniform vectors available") + ); + + // find the sphere program's vertex attribute + let viewport_position_attr = ctx.get_attrib_location(&sphere_program, "position") as u32; + + // find the sphere program's uniforms + const SPHERE_MAX: usize = 200; + let sphere_cnt_loc = ctx.get_uniform_location(&sphere_program, "sphere_cnt"); + let sphere_sp_locs = get_uniform_array_locations::( + &ctx, &sphere_program, "sphere_list", Some("sp") + ); + let sphere_lt_locs = get_uniform_array_locations::( + &ctx, &sphere_program, "sphere_list", Some("lt") + ); + let sphere_color_locs = get_uniform_array_locations::( + &ctx, &sphere_program, "color_list", None + ); + let sphere_highlight_locs = get_uniform_array_locations::( + &ctx, &sphere_program, "highlight_list", None + ); + let resolution_loc = ctx.get_uniform_location(&sphere_program, "resolution"); + let shortdim_loc = ctx.get_uniform_location(&sphere_program, "shortdim"); + let layer_threshold_loc = ctx.get_uniform_location(&sphere_program, "layer_threshold"); + let debug_mode_loc = ctx.get_uniform_location(&sphere_program, "debug_mode"); + + // load the viewport vertex positions into a new vertex buffer object + const VERTEX_CNT: usize = 6; + let viewport_positions: [f32; 3*VERTEX_CNT] = [ + // northwest triangle + -1.0, -1.0, 0.0, + -1.0, 1.0, 0.0, + 1.0, 1.0, 0.0, + // southeast triangle + -1.0, -1.0, 0.0, + 1.0, 1.0, 0.0, + 1.0, -1.0, 0.0 + ]; + let viewport_position_buffer = load_new_buffer(&ctx, &viewport_positions); + + // find the point program's vertex attributes + let point_position_attr = ctx.get_attrib_location(&point_program, "position") as u32; + let point_color_attr = ctx.get_attrib_location(&point_program, "color") as u32; + let point_highlight_attr = ctx.get_attrib_location(&point_program, "highlight") as u32; + let point_selection_attr = ctx.get_attrib_location(&point_program, "selected") as u32; + + // set up a repainting routine + let (_, start_animation_loop, _) = create_raf(move || { + // get the time step + let time = performance.now(); + let time_step = 0.001*(time - last_time); + last_time = time; + + // get the navigation state + let pitch_up_val = pitch_up.get(); + let pitch_down_val = pitch_down.get(); + let yaw_right_val = yaw_right.get(); + let yaw_left_val = yaw_left.get(); + let roll_ccw_val = roll_ccw.get(); + let roll_cw_val = roll_cw.get(); + let zoom_in_val = zoom_in.get(); + let zoom_out_val = zoom_out.get(); + let turntable_val = turntable.get(); /* BENCHMARKING */ + + // get the manipulation state + let translate_neg_x_val = translate_neg_x.get(); + let translate_pos_x_val = translate_pos_x.get(); + let translate_neg_y_val = translate_neg_y.get(); + let translate_pos_y_val = translate_pos_y.get(); + let translate_neg_z_val = translate_neg_z.get(); + let translate_pos_z_val = translate_pos_z.get(); + let shrink_neg_val = shrink_neg.get(); + let shrink_pos_val = shrink_pos.get(); + + // update the assembly's orientation + let ang_vel = { + let pitch = pitch_up_val - pitch_down_val; + let yaw = yaw_right_val - yaw_left_val; + let roll = roll_ccw_val - roll_cw_val; + if pitch != 0.0 || yaw != 0.0 || roll != 0.0 { + ROT_SPEED * Vector3::new(-pitch, yaw, roll).normalize() + } else { + Vector3::zeros() + } + } /* BENCHMARKING */ + if turntable_val { + Vector3::new(0.0, TURNTABLE_SPEED, 0.0) + } else { + Vector3::zeros() + }; + let mut rotation_sp = rotation.fixed_view_mut::<3, 3>(0, 0); + rotation_sp.copy_from( + Rotation3::from_scaled_axis(time_step * ang_vel).matrix() + ); + orientation = &rotation * &orientation; + + // update the assembly's location + let zoom = zoom_out_val - zoom_in_val; + location_z *= (time_step * ZOOM_SPEED * zoom).exp(); + + // manipulate the assembly + if state.selection.with(|sel| sel.len() == 1) { + let sel = state.selection.with( + |sel| sel.into_iter().next().unwrap().clone() + ); + let translate_x = translate_pos_x_val - translate_neg_x_val; + let translate_y = translate_pos_y_val - translate_neg_y_val; + let translate_z = translate_pos_z_val - translate_neg_z_val; + let shrink = shrink_pos_val - shrink_neg_val; + let translating = + translate_x != 0.0 + || translate_y != 0.0 + || translate_z != 0.0; + if translating || shrink != 0.0 { + let elt_motion = { + let u = if translating { + TRANSLATION_SPEED * Vector3::new( + translate_x, translate_y, translate_z + ).normalize() + } else { + Vector3::zeros() + }; + time_step * DVector::from_column_slice( + &[u[0], u[1], u[2], SHRINKING_SPEED * shrink] + ) + }; + assembly_for_raf.deform( + vec![ + ElementMotion { + element: sel, + velocity: elt_motion.as_view() + } + ] + ); + scene_changed.set(true); + } + } + + if scene_changed.get() { + const SPACE_DIM: usize = 3; + const COLOR_SIZE: usize = 3; + + /* INSTRUMENTS */ + // measure mean frame interval + frames_since_last_sample += 1; + if frames_since_last_sample >= SAMPLE_PERIOD { + mean_frame_interval.set((time - last_sample_time) / (SAMPLE_PERIOD as f64)); + last_sample_time = time; + frames_since_last_sample = 0; + } + + // --- get the assembly --- + + let mut scene = Scene::new(); + + // find the map from assembly space to world space + let location = { + let u = -location_z; + DMatrix::from_column_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, u, + 0.0, 0.0, 2.0*u, 1.0, u*u, + 0.0, 0.0, 0.0, 0.0, 1.0 + ]) + }; + let asm_to_world = &location * &orientation; + + // set up the scene + state.assembly.elements.with_untracked( + |elts| for elt in elts { + let selected = state.selection.with(|sel| sel.contains(elt)); + elt.show(&mut scene, selected); + } + ); + let sphere_cnt = scene.spheres.len_i32(); + + // --- draw the spheres --- + + // use the sphere rendering program + ctx.use_program(Some(&sphere_program)); + + // enable the sphere program's vertex attribute + ctx.enable_vertex_attrib_array(viewport_position_attr); + + // write the spheres in world coordinates + let sphere_reps_world: Vec<_> = scene.spheres.representations.into_iter().map( + |rep| (&asm_to_world * rep).cast::() + ).collect(); + + // set the resolution + let width = canvas.width() as f32; + let height = canvas.height() as f32; + ctx.uniform2f(resolution_loc.as_ref(), width, height); + ctx.uniform1f(shortdim_loc.as_ref(), width.min(height)); + + // pass the scene data + ctx.uniform1i(sphere_cnt_loc.as_ref(), sphere_cnt); + for n in 0..sphere_reps_world.len() { + let v = &sphere_reps_world[n]; + ctx.uniform3fv_with_f32_array( + sphere_sp_locs[n].as_ref(), + v.rows(0, 3).as_slice() + ); + ctx.uniform2fv_with_f32_array( + sphere_lt_locs[n].as_ref(), + v.rows(3, 2).as_slice() + ); + ctx.uniform4fv_with_f32_array( + sphere_color_locs[n].as_ref(), + &scene.spheres.colors_with_opacity[n] + ); + ctx.uniform1f( + sphere_highlight_locs[n].as_ref(), + scene.spheres.highlights[n] + ); + } + + // pass the display parameters + ctx.uniform1i(layer_threshold_loc.as_ref(), LAYER_THRESHOLD); + ctx.uniform1i(debug_mode_loc.as_ref(), DEBUG_MODE); + + // bind the viewport vertex position buffer to the position + // attribute in the vertex shader + bind_to_attribute(&ctx, viewport_position_attr, SPACE_DIM as i32, &viewport_position_buffer); + + // draw the scene + ctx.draw_arrays(WebGl2RenderingContext::TRIANGLES, 0, VERTEX_CNT as i32); + + // disable the sphere program's vertex attribute + ctx.disable_vertex_attrib_array(viewport_position_attr); + + // --- draw the points --- + + if !scene.points.representations.is_empty() { + // use the point rendering program + ctx.use_program(Some(&point_program)); + + // enable the point program's vertex attributes + ctx.enable_vertex_attrib_array(point_position_attr); + ctx.enable_vertex_attrib_array(point_color_attr); + ctx.enable_vertex_attrib_array(point_highlight_attr); + ctx.enable_vertex_attrib_array(point_selection_attr); + + // write the points in world coordinates + let asm_to_world_sp = asm_to_world.rows(0, SPACE_DIM); + let point_positions = DMatrix::from_columns( + &scene.points.representations.into_iter().map( + |rep| &asm_to_world_sp * rep + ).collect::>().as_slice() + ).cast::(); + + // load the point positions and colors into new buffers and + // bind them to the corresponding attributes in the vertex + // shader + bind_new_buffer_to_attribute(&ctx, point_position_attr, SPACE_DIM as i32, point_positions.as_slice()); + bind_new_buffer_to_attribute(&ctx, point_color_attr, (COLOR_SIZE + 1) as i32, scene.points.colors_with_opacity.concat().as_slice()); + bind_new_buffer_to_attribute(&ctx, point_highlight_attr, 1 as i32, scene.points.highlights.as_slice()); + bind_new_buffer_to_attribute(&ctx, point_selection_attr, 1 as i32, scene.points.selections.as_slice()); + + // draw the scene + ctx.draw_arrays(WebGl2RenderingContext::POINTS, 0, point_positions.ncols() as i32); + + // disable the point program's vertex attributes + ctx.disable_vertex_attrib_array(point_position_attr); + ctx.disable_vertex_attrib_array(point_color_attr); + ctx.disable_vertex_attrib_array(point_highlight_attr); + ctx.disable_vertex_attrib_array(point_selection_attr); + } + + // --- update the display state --- + + // update the viewpoint + assembly_to_world.set(asm_to_world); + + // clear the scene change flag + scene_changed.set( + pitch_up_val != 0.0 + || pitch_down_val != 0.0 + || yaw_left_val != 0.0 + || yaw_right_val != 0.0 + || roll_cw_val != 0.0 + || roll_ccw_val != 0.0 + || zoom_in_val != 0.0 + || zoom_out_val != 0.0 + || turntable_val /* BENCHMARKING */ + ); + } else { + frames_since_last_sample = 0; + mean_frame_interval.set(-1.0); + } + }); + start_animation_loop(); + }); + + let set_nav_signal = move |event: &KeyboardEvent, value: f64| { + let mut navigating = true; + let shift = event.shift_key(); + match event.key().as_str() { + "ArrowUp" if shift => zoom_in.set(value), + "ArrowDown" if shift => zoom_out.set(value), + "ArrowUp" => pitch_up.set(value), + "ArrowDown" => pitch_down.set(value), + "ArrowRight" if shift => roll_cw.set(value), + "ArrowLeft" if shift => roll_ccw.set(value), + "ArrowRight" => yaw_right.set(value), + "ArrowLeft" => yaw_left.set(value), + _ => navigating = false + }; + if navigating { + scene_changed.set(true); + event.prevent_default(); + } + }; + + let set_manip_signal = move |event: &KeyboardEvent, value: f64| { + let mut manipulating = true; + let shift = event.shift_key(); + match event.key().as_str() { + "d" | "D" => translate_pos_x.set(value), + "a" | "A" => translate_neg_x.set(value), + "w" | "W" if shift => translate_neg_z.set(value), + "s" | "S" if shift => translate_pos_z.set(value), + "w" | "W" => translate_pos_y.set(value), + "s" | "S" => translate_neg_y.set(value), + "]" | "}" => shrink_neg.set(value), + "[" | "{" => shrink_pos.set(value), + _ => manipulating = false + }; + if manipulating { + event.prevent_default(); + } + }; + + view! { + /* TO DO */ + // switch back to integer-valued parameters when that becomes possible + // again + canvas( + ref=display, + id="display", + width="600", + height="600", + tabindex="0", + on:keydown=move |event: KeyboardEvent| { + if event.key() == "Shift" { + // swap navigation inputs + roll_cw.set(yaw_right.get()); + roll_ccw.set(yaw_left.get()); + zoom_in.set(pitch_up.get()); + zoom_out.set(pitch_down.get()); + yaw_right.set(0.0); + yaw_left.set(0.0); + pitch_up.set(0.0); + pitch_down.set(0.0); + + // swap manipulation inputs + translate_pos_z.set(translate_neg_y.get()); + translate_neg_z.set(translate_pos_y.get()); + translate_pos_y.set(0.0); + translate_neg_y.set(0.0); + } else { + if event.key() == "Enter" { /* BENCHMARKING */ + turntable.set_fn(|turn| !turn); + scene_changed.set(true); + } + set_nav_signal(&event, 1.0); + set_manip_signal(&event, 1.0); + } + }, + on:keyup=move |event: KeyboardEvent| { + if event.key() == "Shift" { + // swap navigation inputs + yaw_right.set(roll_cw.get()); + yaw_left.set(roll_ccw.get()); + pitch_up.set(zoom_in.get()); + pitch_down.set(zoom_out.get()); + roll_cw.set(0.0); + roll_ccw.set(0.0); + zoom_in.set(0.0); + zoom_out.set(0.0); + + // swap manipulation inputs + translate_pos_y.set(translate_neg_z.get()); + translate_neg_y.set(translate_pos_z.get()); + translate_pos_z.set(0.0); + translate_neg_z.set(0.0); + } else { + set_nav_signal(&event, 0.0); + set_manip_signal(&event, 0.0); + } + }, + on:blur=move |_| { + pitch_up.set(0.0); + pitch_down.set(0.0); + yaw_right.set(0.0); + yaw_left.set(0.0); + roll_ccw.set(0.0); + roll_cw.set(0.0); + }, + on:click=move |event: MouseEvent| { + // find the nearest element along the pointer direction + let (dir, pixel_size) = event_dir(&event); + console::log_1(&JsValue::from(dir.to_string())); + let mut clicked: Option<(Rc, f64)> = None; + let tangible_elts = state.assembly.elements + .get_clone_untracked() + .into_iter() + .filter(|elt| !elt.ghost().get()); + for elt in tangible_elts { + match assembly_to_world.with(|asm_to_world| elt.cast(dir, asm_to_world, pixel_size)) { + Some(depth) => match clicked { + Some((_, best_depth)) => { + if depth < best_depth { + clicked = Some((elt, depth)) + } + }, + None => clicked = Some((elt, depth)) + } + None => () + }; + } + + // if we clicked something, select it + match clicked { + Some((elt, _)) => state.select(&elt, event.shift_key()), + None => state.selection.update(|sel| sel.clear()) + }; + } + ) + } +} \ No newline at end of file diff --git a/app-proto/src/components/identity.vert b/app-proto/src/components/identity.vert new file mode 100644 index 0000000..183a65f --- /dev/null +++ b/app-proto/src/components/identity.vert @@ -0,0 +1,7 @@ +#version 300 es + +in vec4 position; + +void main() { + gl_Position = position; +} \ No newline at end of file diff --git a/app-proto/src/components/outline.rs b/app-proto/src/components/outline.rs new file mode 100644 index 0000000..77d8575 --- /dev/null +++ b/app-proto/src/components/outline.rs @@ -0,0 +1,264 @@ +use itertools::Itertools; +use std::rc::Rc; +use sycamore::prelude::*; +use web_sys::{ + KeyboardEvent, + MouseEvent, + wasm_bindgen::JsCast +}; + +use crate::{ + AppState, + assembly::{ + Element, + HalfCurvatureRegulator, + InversiveDistanceRegulator, + Regulator + }, + specified::SpecifiedValue +}; + +// an editable view of a regulator +#[component(inline_props)] +fn RegulatorInput(regulator: Rc) -> View { + // get the regulator's measurement and set point signals + let measurement = regulator.measurement(); + let set_point = regulator.set_point(); + + // the `valid` signal tracks whether the last entered value is a valid set + // point specification + let valid = create_signal(true); + + // the `value` signal holds the current set point specification + let value = create_signal( + set_point.with_untracked(|set_pt| set_pt.spec.clone()) + ); + + // this `reset_value` closure resets the input value to the regulator's set + // point specification + let reset_value = move || { + batch(|| { + valid.set(true); + value.set(set_point.with(|set_pt| set_pt.spec.clone())); + }) + }; + + // reset the input value whenever the regulator's set point specification + // is updated + create_effect(reset_value); + + view! { + input( + r#type="text", + class=move || { + if valid.get() { + set_point.with(|set_pt| { + if set_pt.is_present() { + "regulator-input constraint" + } else { + "regulator-input" + } + }) + } else { + "regulator-input invalid" + } + }, + placeholder=measurement.with(|result| result.to_string()), + bind:value=value, + on:change=move |_| { + valid.set( + match SpecifiedValue::try_from(value.get_clone_untracked()) { + Ok(set_pt) => { + set_point.set(set_pt); + true + } + Err(_) => false + } + ) + }, + on:keydown={ + move |event: KeyboardEvent| { + match event.key().as_str() { + "Escape" => reset_value(), + _ => () + } + } + } + ) + } +} + +pub trait OutlineItem { + fn outline_item(self: Rc, element: &Rc) -> View; +} + +impl OutlineItem for InversiveDistanceRegulator { + fn outline_item(self: Rc, element: &Rc) -> View { + let other_subject_label = if self.subjects[0] == element.clone() { + self.subjects[1].label() + } else { + self.subjects[0].label() + }.clone(); + view! { + li(class="regulator") { + div(class="regulator-label") { (other_subject_label) } + div(class="regulator-type") { "Inversive distance" } + RegulatorInput(regulator=self) + div(class="status") + } + } + } +} + +impl OutlineItem for HalfCurvatureRegulator { + fn outline_item(self: Rc, _element: &Rc) -> View { + view! { + li(class="regulator") { + div(class="regulator-label") // for spacing + div(class="regulator-type") { "Half-curvature" } + RegulatorInput(regulator=self) + div(class="status") + } + } + } +} + +// a list item that shows an element in an outline view of an assembly +#[component(inline_props)] +fn ElementOutlineItem(element: Rc) -> View { + let state = use_context::(); + let class = { + let element_for_class = element.clone(); + state.selection.map( + move |sel| if sel.contains(&element_for_class) { "selected" } else { "" } + ) + }; + let label = element.label().clone(); + let representation = element.representation().clone(); + let rep_components = move || { + representation.with( + |rep| rep.iter().map( + |u| { + let u_str = format!("{:.3}", u).replace("-", "\u{2212}"); + view! { div { (u_str) } } + } + ).collect::>() + ) + }; + let regulated = element.regulators().map(|regs| regs.len() > 0); + let regulator_list = element.regulators().map( + |regs| regs + .clone() + .into_iter() + .sorted_by_key(|reg| reg.subjects().len()) + .collect::>() + ); + let details_node = create_node_ref(); + view! { + li { + details(ref=details_node) { + summary( + class=class.get(), + on:keydown={ + let element_for_handler = element.clone(); + move |event: KeyboardEvent| { + match event.key().as_str() { + "Enter" => { + state.select(&element_for_handler, event.shift_key()); + event.prevent_default(); + }, + "ArrowRight" if regulated.get() => { + let _ = details_node + .get() + .unchecked_into::() + .set_attribute("open", ""); + }, + "ArrowLeft" => { + let _ = details_node + .get() + .unchecked_into::() + .remove_attribute("open"); + }, + _ => () + } + } + } + ) { + div( + class="element-switch", + on:click=|event: MouseEvent| event.stop_propagation() + ) + div( + class="element", + on:click={ + let state_for_handler = state.clone(); + let element_for_handler = element.clone(); + move |event: MouseEvent| { + state_for_handler.select(&element_for_handler, event.shift_key()); + event.stop_propagation(); + event.prevent_default(); + } + } + ) { + div(class="element-label") { (label) } + div(class="element-representation") { (rep_components) } + input( + r#type="checkbox", + bind:checked=element.ghost(), + on:click=|event: MouseEvent| event.stop_propagation() + ) + } + } + ul(class="regulators") { + Keyed( + list=regulator_list, + view=move |reg| reg.outline_item(&element), + key=|reg| reg.serial() + ) + } + } + } + } +} + +// a component that lists the elements of the current assembly, showing each +// element's regulators in a collapsible sub-list. its implementation is based +// on Kate Morley's HTML + CSS tree views: +// +// https://iamkate.com/code/tree-views/ +// +#[component] +pub fn Outline() -> View { + let state = use_context::(); + + // list the elements alphabetically by ID + /* TO DO */ + // this code is designed to generalize easily to other sort keys. if we only + // ever wanted to sort by ID, we could do that more simply using the + // `elements_by_id` index + let element_list = state.assembly.elements.map( + |elts| elts + .clone() + .into_iter() + .sorted_by_key(|elt| elt.id().clone()) + .collect::>() + ); + + view! { + ul( + id="outline", + on:click={ + let state = use_context::(); + move |_| state.selection.update(|sel| sel.clear()) + } + ) { + Keyed( + list=element_list, + view=|elt| view! { + ElementOutlineItem(element=elt) + }, + key=|elt| elt.serial() + ) + } + } +} \ No newline at end of file diff --git a/app-proto/src/components/point.frag b/app-proto/src/components/point.frag new file mode 100644 index 0000000..194a072 --- /dev/null +++ b/app-proto/src/components/point.frag @@ -0,0 +1,19 @@ +#version 300 es + +precision highp float; + +in vec4 point_color; +in float point_highlight; +in float total_radius; + +out vec4 outColor; + +void main() { + float r = total_radius * length(2.*gl_PointCoord - vec2(1.)); + + const float POINT_RADIUS = 4.; + float border = smoothstep(POINT_RADIUS - 1., POINT_RADIUS, r); + float disk = 1. - smoothstep(total_radius - 1., total_radius, r); + vec4 color = mix(point_color, vec4(1.), border * point_highlight); + outColor = vec4(vec3(1.), disk) * color; +} \ No newline at end of file diff --git a/app-proto/src/components/point.vert b/app-proto/src/components/point.vert new file mode 100644 index 0000000..0b76bc1 --- /dev/null +++ b/app-proto/src/components/point.vert @@ -0,0 +1,24 @@ +#version 300 es + +in vec4 position; +in vec4 color; +in float highlight; +in float selected; + +out vec4 point_color; +out float point_highlight; +out float total_radius; + +// camera +const float focal_slope = 0.3; + +void main() { + total_radius = 5. + 0.5*selected; + + float depth = -focal_slope * position.z; + gl_Position = vec4(position.xy / depth, 0., 1.); + gl_PointSize = 2.*total_radius; + + point_color = color; + point_highlight = highlight; +} \ No newline at end of file diff --git a/app-proto/src/components/spheres.frag b/app-proto/src/components/spheres.frag new file mode 100644 index 0000000..fa317a8 --- /dev/null +++ b/app-proto/src/components/spheres.frag @@ -0,0 +1,235 @@ +#version 300 es + +precision highp float; + +out vec4 outColor; + +// --- inversive geometry --- + +struct vecInv { + vec3 sp; + vec2 lt; +}; + +// --- uniforms --- + +// assembly +const int SPHERE_MAX = 200; +uniform int sphere_cnt; +uniform vecInv sphere_list[SPHERE_MAX]; +uniform vec4 color_list[SPHERE_MAX]; +uniform float highlight_list[SPHERE_MAX]; + +// view +uniform vec2 resolution; +uniform float shortdim; + +// controls +uniform int layer_threshold; +uniform bool debug_mode; + +// light and camera +const float focal_slope = 0.3; +const vec3 light_dir = normalize(vec3(2., 2., 1.)); +const float ixn_threshold = 0.005; +const float INTERIOR_DIMMING = 0.7; + +// --- sRGB --- + +// map colors from RGB space to sRGB space, as specified in the sRGB standard +// (IEC 61966-2-1:1999) +// +// https://www.color.org/sRGB.pdf +// https://www.color.org/chardata/rgb/srgb.xalter +// +// in RGB space, color value is proportional to light intensity, so linear +// color-vector interpolation corresponds to physical light mixing. in sRGB +// space, the color encoding used by many monitors, we use more of the value +// interval to represent low intensities, and less of the interval to represent +// high intensities. this improves color quantization + +float sRGB(float t) { + if (t <= 0.0031308) { + return 12.92*t; + } else { + return 1.055*pow(t, 5./12.) - 0.055; + } +} + +vec3 sRGB(vec3 color) { + return vec3(sRGB(color.r), sRGB(color.g), sRGB(color.b)); +} + +// --- shading --- + +struct Fragment { + vec3 pt; + vec3 normal; + vec4 color; +}; + +Fragment sphere_shading(vecInv v, vec3 pt, vec4 base_color) { + // the expression for normal needs to be checked. it's supposed to give the + // negative gradient of the lorentz product between the impact point vector + // and the sphere vector with respect to the coordinates of the impact + // point. i calculated it in my head and decided that the result looked good + // enough for now + vec3 normal = normalize(-v.sp + 2.*v.lt.s*pt); + + float incidence = dot(normal, light_dir); + float illum = mix(0.4, 1.0, max(incidence, 0.0)); + return Fragment(pt, normal, vec4(illum * base_color.rgb, base_color.a)); +} + +float intersection_dist(Fragment a, Fragment b) { + float intersection_sin = length(cross(a.normal, b.normal)); + vec3 disp = a.pt - b.pt; + return max( + abs(dot(a.normal, disp)), + abs(dot(b.normal, disp)) + ) / intersection_sin; +} + +// --- ray-casting --- + +struct TaggedDepth { + float depth; + float dimming; + int id; +}; + +// if `a/b` is less than this threshold, we approximate `a*u^2 + b*u + c` by +// the linear function `b*u + c` +const float DEG_THRESHOLD = 1e-9; + +// the depths, represented as multiples of `dir`, where the line generated by +// `dir` hits the sphere represented by `v`. if both depths are positive, the +// smaller one is returned in the first component. if only one depth is +// positive, it could be returned in either component +vec2 sphere_cast(vecInv v, vec3 dir) { + float a = -v.lt.s * dot(dir, dir); + float b = dot(v.sp, dir); + float c = -v.lt.t; + + float adjust = 4.*a*c/(b*b); + if (adjust < 1.) { + // as long as `b` is non-zero, the linear approximation of + // + // a*u^2 + b*u + c + // + // at `u = 0` will reach zero at a finite depth `u_lin`. the root of the + // quadratic adjacent to `u_lin` is stored in `lin_root`. if both roots + // have the same sign, `lin_root` will be the one closer to `u = 0` + float square_rect_ratio = 1. + sqrt(1. - adjust); + float lin_root = -(2.*c)/b / square_rect_ratio; + if (abs(a) > DEG_THRESHOLD * abs(b)) { + return vec2(lin_root, -b/(2.*a) * square_rect_ratio); + } else { + return vec2(lin_root, -1.); + } + } else { + // the line through `dir` misses the sphere completely + return vec2(-1., -1.); + } +} + +void main() { + vec2 scr = (2.*gl_FragCoord.xy - resolution) / shortdim; + vec3 dir = vec3(focal_slope * scr, -1.); + + // cast rays through the spheres + const int LAYER_MAX = 12; + TaggedDepth top_hits [LAYER_MAX]; + int layer_cnt = 0; + for (int id = 0; id < sphere_cnt; ++id) { + // find out where the ray hits the sphere + vec2 hit_depths = sphere_cast(sphere_list[id], dir); + + // insertion-sort the points we hit into the hit list + float dimming = 1.; + for (int side = 0; side < 2; ++side) { + float depth = hit_depths[side]; + if (depth > 0.) { + for (int layer = layer_cnt; layer >= 0; --layer) { + if (layer < 1 || top_hits[layer-1].depth <= depth) { + // we're not as close to the screen as the hit before + // the empty slot, so insert here + if (layer < LAYER_MAX) { + top_hits[layer] = TaggedDepth(depth, dimming, id); + } + break; + } else { + // we're closer to the screen than the hit before the + // empty slot, so move that hit into the empty slot + top_hits[layer] = top_hits[layer-1]; + } + } + layer_cnt = min(layer_cnt + 1, LAYER_MAX); + dimming = INTERIOR_DIMMING; + } + } + } + + /* DEBUG */ + // in debug mode, show the layer count instead of the shaded image + if (debug_mode) { + // at the bottom of the screen, show the color scale instead of the + // layer count + if (gl_FragCoord.y < 10.) layer_cnt = int(16. * gl_FragCoord.x / resolution.x); + + // convert number to color + ivec3 bits = layer_cnt / ivec3(1, 2, 4); + vec3 color = mod(vec3(bits), 2.); + if (layer_cnt % 16 >= 8) { + color = mix(color, vec3(0.5), 0.5); + } + outColor = vec4(color, 1.); + return; + } + + // composite the sphere fragments + vec3 color = vec3(0.); + int layer = layer_cnt - 1; + TaggedDepth hit = top_hits[layer]; + vec4 sphere_color = color_list[hit.id]; + Fragment frag_next = sphere_shading( + sphere_list[hit.id], + hit.depth * dir, + vec4(hit.dimming * sphere_color.rgb, sphere_color.a) + ); + float highlight_next = highlight_list[hit.id]; + --layer; + for (; layer >= layer_threshold; --layer) { + // load the current fragment + Fragment frag = frag_next; + float highlight = highlight_next; + + // shade the next fragment + hit = top_hits[layer]; + sphere_color = color_list[hit.id]; + frag_next = sphere_shading( + sphere_list[hit.id], + hit.depth * dir, + vec4(hit.dimming * sphere_color.rgb, sphere_color.a) + ); + highlight_next = highlight_list[hit.id]; + + // highlight intersections + float ixn_dist = intersection_dist(frag, frag_next); + float max_highlight = max(highlight, highlight_next); + float ixn_highlight = 0.5 * max_highlight * (1. - smoothstep(2./3.*ixn_threshold, 1.5*ixn_threshold, ixn_dist)); + frag.color = mix(frag.color, vec4(1.), ixn_highlight); + frag_next.color = mix(frag_next.color, vec4(1.), ixn_highlight); + + // highlight cusps + float cusp_cos = abs(dot(dir, frag.normal)); + float cusp_threshold = 2.*sqrt(ixn_threshold * sphere_list[hit.id].lt.s); + float cusp_highlight = highlight * (1. - smoothstep(2./3.*cusp_threshold, 1.5*cusp_threshold, cusp_cos)); + frag.color = mix(frag.color, vec4(1.), cusp_highlight); + + // composite the current fragment + color = mix(color, frag.color.rgb, frag.color.a); + } + color = mix(color, frag_next.color.rgb, frag_next.color.a); + outColor = vec4(sRGB(color), 1.); +} \ No newline at end of file diff --git a/app-proto/src/components/test_assembly_chooser.rs b/app-proto/src/components/test_assembly_chooser.rs new file mode 100644 index 0000000..232cda3 --- /dev/null +++ b/app-proto/src/components/test_assembly_chooser.rs @@ -0,0 +1,947 @@ +use itertools::izip; +use std::{f64::consts::{FRAC_1_SQRT_2, PI}, rc::Rc}; +use nalgebra::Vector3; +use sycamore::prelude::*; +use web_sys::{console, wasm_bindgen::JsValue}; + +use crate::{ + AppState, + engine, + engine::DescentHistory, + assembly::{ + Assembly, + Element, + ElementColor, + InversiveDistanceRegulator, + Point, + Sphere + }, + specified::SpecifiedValue +}; + +// --- loaders --- + +/* DEBUG */ +// each of these functions loads an example assembly for testing. once we've +// done more work on saving and loading assemblies, we should come back to this +// code to see if it can be simplified + +fn load_gen_assemb(assembly: &Assembly) { + let _ = assembly.try_insert_element( + Sphere::new( + String::from("gemini_a"), + String::from("Castor"), + [1.00_f32, 0.25_f32, 0.00_f32], + engine::sphere(0.5, 0.5, 0.0, 1.0) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + String::from("gemini_b"), + String::from("Pollux"), + [0.00_f32, 0.25_f32, 1.00_f32], + engine::sphere(-0.5, -0.5, 0.0, 1.0) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + String::from("ursa_major"), + String::from("Ursa major"), + [0.25_f32, 0.00_f32, 1.00_f32], + engine::sphere(-0.5, 0.5, 0.0, 0.75) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + String::from("ursa_minor"), + String::from("Ursa minor"), + [0.25_f32, 1.00_f32, 0.00_f32], + engine::sphere(0.5, -0.5, 0.0, 0.5) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + String::from("moon_deimos"), + String::from("Deimos"), + [0.75_f32, 0.75_f32, 0.00_f32], + engine::sphere(0.0, 0.15, 1.0, 0.25) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + String::from("moon_phobos"), + String::from("Phobos"), + [0.00_f32, 0.75_f32, 0.50_f32], + engine::sphere(0.0, -0.15, -1.0, 0.25) + ) + ); +} + +fn load_low_curv_assemb(assembly: &Assembly) { + // create the spheres + let a = 0.75_f64.sqrt(); + let _ = assembly.try_insert_element( + Sphere::new( + "central".to_string(), + "Central".to_string(), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere(0.0, 0.0, 0.0, 1.0) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + "assemb_plane".to_string(), + "Assembly plane".to_string(), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere_with_offset(0.0, 0.0, 1.0, 0.0, 0.0) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + "side1".to_string(), + "Side 1".to_string(), + [1.00_f32, 0.00_f32, 0.25_f32], + engine::sphere_with_offset(1.0, 0.0, 0.0, 1.0, 0.0) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + "side2".to_string(), + "Side 2".to_string(), + [0.25_f32, 1.00_f32, 0.00_f32], + engine::sphere_with_offset(-0.5, a, 0.0, 1.0, 0.0) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + "side3".to_string(), + "Side 3".to_string(), + [0.00_f32, 0.25_f32, 1.00_f32], + engine::sphere_with_offset(-0.5, -a, 0.0, 1.0, 0.0) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + "corner1".to_string(), + "Corner 1".to_string(), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere(-4.0/3.0, 0.0, 0.0, 1.0/3.0) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + "corner2".to_string(), + "Corner 2".to_string(), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere(2.0/3.0, -4.0/3.0 * a, 0.0, 1.0/3.0) + ) + ); + let _ = assembly.try_insert_element( + Sphere::new( + String::from("corner3"), + String::from("Corner 3"), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere(2.0/3.0, 4.0/3.0 * a, 0.0, 1.0/3.0) + ) + ); + + // impose the desired tangencies and make the sides planar + let index_range = 1..=3; + let [central, assemb_plane] = ["central", "assemb_plane"].map( + |id| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[id].clone() + ) + ); + let sides = index_range.clone().map( + |k| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&format!("side{k}")].clone() + ) + ); + let corners = index_range.map( + |k| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&format!("corner{k}")].clone() + ) + ); + for plane in [assemb_plane.clone()].into_iter().chain(sides.clone()) { + // fix the curvature of each plane + let curvature = plane.regulators().with_untracked( + |regs| regs.first().unwrap().clone() + ); + curvature.set_point().set(SpecifiedValue::try_from("0".to_string()).unwrap()); + } + let all_perpendicular = [central.clone()].into_iter() + .chain(sides.clone()) + .chain(corners.clone()); + for sphere in all_perpendicular { + // make each side and packed sphere perpendicular to the assembly plane + let right_angle = InversiveDistanceRegulator::new([sphere, assemb_plane.clone()]); + right_angle.set_point.set(SpecifiedValue::try_from("0".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(right_angle)); + } + for sphere in sides.clone().chain(corners.clone()) { + // make each side and corner sphere tangent to the central sphere + let tangency = InversiveDistanceRegulator::new([sphere.clone(), central.clone()]); + tangency.set_point.set(SpecifiedValue::try_from("-1".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(tangency)); + } + for (side_index, side) in sides.enumerate() { + // make each side tangent to the two adjacent corner spheres + for (corner_index, corner) in corners.clone().enumerate() { + if side_index != corner_index { + let tangency = InversiveDistanceRegulator::new([side.clone(), corner]); + tangency.set_point.set(SpecifiedValue::try_from("-1".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(tangency)); + } + } + } +} + +fn load_pointed_assemb(assembly: &Assembly) { + let _ = assembly.try_insert_element( + Point::new( + format!("point_front"), + format!("Front point"), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::point(0.0, 0.0, FRAC_1_SQRT_2) + ) + ); + let _ = assembly.try_insert_element( + Point::new( + format!("point_back"), + format!("Back point"), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::point(0.0, 0.0, -FRAC_1_SQRT_2) + ) + ); + for index_x in 0..=1 { + for index_y in 0..=1 { + let x = index_x as f64 - 0.5; + let y = index_y as f64 - 0.5; + + let _ = assembly.try_insert_element( + Sphere::new( + format!("sphere{index_x}{index_y}"), + format!("Sphere {index_x}{index_y}"), + [0.5*(1.0 + x) as f32, 0.5*(1.0 + y) as f32, 0.5*(1.0 - x*y) as f32], + engine::sphere(x, y, 0.0, 1.0) + ) + ); + + let _ = assembly.try_insert_element( + Point::new( + format!("point{index_x}{index_y}"), + format!("Point {index_x}{index_y}"), + [0.5*(1.0 + x) as f32, 0.5*(1.0 + y) as f32, 0.5*(1.0 - x*y) as f32], + engine::point(x, y, 0.0) + ) + ); + } + } +} + +// to finish describing the tridiminished icosahedron, set the inversive +// distance regulators as follows: +// A-A -0.25 +// A-B " +// B-C " +// C-C " +// A-C -0.25 * φ^2 = -0.6545084971874737 +fn load_tridim_icosahedron_assemb(assembly: &Assembly) { + // create the vertices + const COLOR_A: ElementColor = [1.00_f32, 0.25_f32, 0.25_f32]; + const COLOR_B: ElementColor = [0.75_f32, 0.75_f32, 0.75_f32]; + const COLOR_C: ElementColor = [0.25_f32, 0.50_f32, 1.00_f32]; + let vertices = [ + Point::new( + "a1".to_string(), + "A₁".to_string(), + COLOR_A, + engine::point(0.25, 0.75, 0.75) + ), + Point::new( + "a2".to_string(), + "A₂".to_string(), + COLOR_A, + engine::point(0.75, 0.25, 0.75) + ), + Point::new( + "a3".to_string(), + "A₃".to_string(), + COLOR_A, + engine::point(0.75, 0.75, 0.25) + ), + Point::new( + "b1".to_string(), + "B₁".to_string(), + COLOR_B, + engine::point(0.75, -0.25, -0.25) + ), + Point::new( + "b2".to_string(), + "B₂".to_string(), + COLOR_B, + engine::point(-0.25, 0.75, -0.25) + ), + Point::new( + "b3".to_string(), + "B₃".to_string(), + COLOR_B, + engine::point(-0.25, -0.25, 0.75) + ), + Point::new( + "c1".to_string(), + "C₁".to_string(), + COLOR_C, + engine::point(0.0, -1.0, -1.0) + ), + Point::new( + "c2".to_string(), + "C₂".to_string(), + COLOR_C, + engine::point(-1.0, 0.0, -1.0) + ), + Point::new( + "c3".to_string(), + "C₃".to_string(), + COLOR_C, + engine::point(-1.0, -1.0, 0.0) + ) + ]; + for vertex in vertices { + let _ = assembly.try_insert_element(vertex); + } + + // create the faces + const COLOR_FACE: ElementColor = [0.75_f32, 0.75_f32, 0.75_f32]; + let frac_1_sqrt_6 = 1.0 / 6.0_f64.sqrt(); + let frac_2_sqrt_6 = 2.0 * frac_1_sqrt_6; + let faces = [ + Sphere::new( + "face1".to_string(), + "Face 1".to_string(), + COLOR_FACE, + engine::sphere_with_offset(frac_2_sqrt_6, -frac_1_sqrt_6, -frac_1_sqrt_6, -frac_1_sqrt_6, 0.0) + ), + Sphere::new( + "face2".to_string(), + "Face 2".to_string(), + COLOR_FACE, + engine::sphere_with_offset(-frac_1_sqrt_6, frac_2_sqrt_6, -frac_1_sqrt_6, -frac_1_sqrt_6, 0.0) + ), + Sphere::new( + "face3".to_string(), + "Face 3".to_string(), + COLOR_FACE, + engine::sphere_with_offset(-frac_1_sqrt_6, -frac_1_sqrt_6, frac_2_sqrt_6, -frac_1_sqrt_6, 0.0) + ) + ]; + for face in faces { + face.ghost().set(true); + let _ = assembly.try_insert_element(face); + } + + let index_range = 1..=3; + for j in index_range.clone() { + // make each face planar + let face = assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&format!("face{j}")].clone() + ); + let curvature_regulator = face.regulators().with_untracked( + |regs| regs.first().unwrap().clone() + ); + curvature_regulator.set_point().set( + SpecifiedValue::try_from("0".to_string()).unwrap() + ); + + // put each A vertex on the face it belongs to + let vertex_a = assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&format!("a{j}")].clone() + ); + let incidence_a = InversiveDistanceRegulator::new([face.clone(), vertex_a.clone()]); + incidence_a.set_point.set(SpecifiedValue::try_from("0".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(incidence_a)); + + // regulate the B-C vertex distances + let vertices_bc = ["b", "c"].map( + |series| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&format!("{series}{j}")].clone() + ) + ); + assembly.insert_regulator( + Rc::new(InversiveDistanceRegulator::new(vertices_bc)) + ); + + // get the pair of indices adjacent to `j` + let adjacent_indices = [j % 3 + 1, (j + 1) % 3 + 1]; + + for k in adjacent_indices.clone() { + for series in ["b", "c"] { + // put each B and C vertex on the faces it belongs to + let vertex = assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&format!("{series}{k}")].clone() + ); + let incidence = InversiveDistanceRegulator::new([face.clone(), vertex.clone()]); + incidence.set_point.set(SpecifiedValue::try_from("0".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(incidence)); + + // regulate the A-B and A-C vertex distances + assembly.insert_regulator( + Rc::new(InversiveDistanceRegulator::new([vertex_a.clone(), vertex])) + ); + } + } + + // regulate the A-A and C-C vertex distances + let adjacent_pairs = ["a", "c"].map( + |series| adjacent_indices.map( + |index| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&format!("{series}{index}")].clone() + ) + ) + ); + for pair in adjacent_pairs { + assembly.insert_regulator( + Rc::new(InversiveDistanceRegulator::new(pair)) + ); + } + } +} + +// to finish describing the dodecahedral circle packing, set the inversive +// distance regulators to -1. some of the regulators have already been set +fn load_dodeca_packing_assemb(assembly: &Assembly) { + // add the substrate + let _ = assembly.try_insert_element( + Sphere::new( + "substrate".to_string(), + "Substrate".to_string(), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere(0.0, 0.0, 0.0, 1.0) + ) + ); + let substrate = assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id["substrate"].clone() + ); + + // fix the substrate's curvature + substrate.regulators().with_untracked( + |regs| regs.first().unwrap().clone() + ).set_point().set( + SpecifiedValue::try_from("0.5".to_string()).unwrap() + ); + + // add the circles to be packed + const COLOR_A: ElementColor = [1.00_f32, 0.25_f32, 0.00_f32]; + const COLOR_B: ElementColor = [1.00_f32, 0.00_f32, 0.25_f32]; + const COLOR_C: ElementColor = [0.25_f32, 0.00_f32, 1.00_f32]; + let phi = 0.5 + 1.25_f64.sqrt(); /* TO DO */ // replace with std::f64::consts::PHI when that gets stabilized + let phi_inv = 1.0 / phi; + let coord_scale = (phi + 2.0).sqrt(); + let face_scales = [phi_inv, (13.0 / 12.0) / coord_scale]; + let face_radii = [phi_inv, 5.0 / 12.0]; + let mut faces = Vec::>::new(); + let subscripts = ["₀", "₁"]; + for j in 0..2 { + for k in 0..2 { + let small_coord = face_scales[k] * (2.0*(j as f64) - 1.0); + let big_coord = face_scales[k] * (2.0*(k as f64) - 1.0) * phi; + + let id_num = format!("{j}{k}"); + let label_sub = format!("{}{}", subscripts[j], subscripts[k]); + + // add the A face + let id_a = format!("a{id_num}"); + let _ = assembly.try_insert_element( + Sphere::new( + id_a.clone(), + format!("A{label_sub}"), + COLOR_A, + engine::sphere(0.0, small_coord, big_coord, face_radii[k]) + ) + ); + faces.push( + assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&id_a].clone() + ) + ); + + // add the B face + let id_b = format!("b{id_num}"); + let _ = assembly.try_insert_element( + Sphere::new( + id_b.clone(), + format!("B{label_sub}"), + COLOR_B, + engine::sphere(small_coord, big_coord, 0.0, face_radii[k]) + ) + ); + faces.push( + assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&id_b].clone() + ) + ); + + // add the C face + let id_c = format!("c{id_num}"); + let _ = assembly.try_insert_element( + Sphere::new( + id_c.clone(), + format!("C{label_sub}"), + COLOR_C, + engine::sphere(big_coord, 0.0, small_coord, face_radii[k]) + ) + ); + faces.push( + assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&id_c].clone() + ) + ); + } + } + + // make each face sphere perpendicular to the substrate + for face in faces { + let right_angle = InversiveDistanceRegulator::new([face, substrate.clone()]); + right_angle.set_point.set(SpecifiedValue::try_from("0".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(right_angle)); + } + + // set up the tangencies that define the packing + for [long_edge_plane, short_edge_plane] in [["a", "b"], ["b", "c"], ["c", "a"]] { + for k in 0..2 { + let long_edge_ids = [ + format!("{long_edge_plane}{k}0"), + format!("{long_edge_plane}{k}1") + ]; + let short_edge_ids = [ + format!("{short_edge_plane}0{k}"), + format!("{short_edge_plane}1{k}") + ]; + let [long_edge, short_edge] = [long_edge_ids, short_edge_ids].map( + |edge_ids| edge_ids.map( + |id| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&id].clone() + ) + ) + ); + + // set up the short-edge tangency + let short_tangency = InversiveDistanceRegulator::new(short_edge.clone()); + if k == 0 { + short_tangency.set_point.set(SpecifiedValue::try_from("-1".to_string()).unwrap()); + } + assembly.insert_regulator(Rc::new(short_tangency)); + + // set up the side tangencies + for i in 0..2 { + for j in 0..2 { + let side_tangency = InversiveDistanceRegulator::new( + [long_edge[i].clone(), short_edge[j].clone()] + ); + if i == 0 && k == 0 { + side_tangency.set_point.set(SpecifiedValue::try_from("-1".to_string()).unwrap()); + } + assembly.insert_regulator(Rc::new(side_tangency)); + } + } + } + } +} + +// the initial configuration of this test assembly deliberately violates the +// constraints, so loading the assembly will trigger a non-trivial realization +fn load_balanced_assemb(assembly: &Assembly) { + // create the spheres + const R_OUTER: f64 = 10.0; + const R_INNER: f64 = 4.0; + let spheres = [ + Sphere::new( + "outer".to_string(), + "Outer".to_string(), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere(0.0, 0.0, 0.0, R_OUTER) + ), + Sphere::new( + "a".to_string(), + "A".to_string(), + [1.00_f32, 0.00_f32, 0.25_f32], + engine::sphere(0.0, 4.0, 0.0, R_INNER) + ), + Sphere::new( + "b".to_string(), + "B".to_string(), + [0.00_f32, 0.25_f32, 1.00_f32], + engine::sphere(0.0, -4.0, 0.0, R_INNER) + ), + ]; + for sphere in spheres { + let _ = assembly.try_insert_element(sphere); + } + + // get references to the spheres + let [outer, a, b] = ["outer", "a", "b"].map( + |id| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[id].clone() + ) + ); + + // fix the diameters of the outer, sun, and moon spheres + for (sphere, radius) in [ + (outer.clone(), R_OUTER), + (a.clone(), R_INNER), + (b.clone(), R_INNER) + ] { + let curvature_regulator = sphere.regulators().with_untracked( + |regs| regs.first().unwrap().clone() + ); + let curvature = 0.5 / radius; + curvature_regulator.set_point().set( + SpecifiedValue::try_from(curvature.to_string()).unwrap() + ); + } + + // set the inversive distances between the spheres. as described above, the + // initial configuration deliberately violates these constraints + for inner in [a, b] { + let tangency = InversiveDistanceRegulator::new([outer.clone(), inner]); + tangency.set_point.set(SpecifiedValue::try_from("1".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(tangency)); + } +} + +// the initial configuration of this test assembly deliberately violates the +// constraints, so loading the assembly will trigger a non-trivial realization +fn load_off_center_assemb(assembly: &Assembly) { + // create a point almost at the origin and a sphere centered on the origin + let _ = assembly.try_insert_element( + Point::new( + "point".to_string(), + "Point".to_string(), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::point(1e-9, 0.0, 0.0) + ), + ); + let _ = assembly.try_insert_element( + Sphere::new( + "sphere".to_string(), + "Sphere".to_string(), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere(0.0, 0.0, 0.0, 1.0) + ), + ); + + // get references to the elements + let point_and_sphere = ["point", "sphere"].map( + |id| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[id].clone() + ) + ); + + // put the point on the sphere + let incidence = InversiveDistanceRegulator::new(point_and_sphere); + incidence.set_point.set(SpecifiedValue::try_from("0".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(incidence)); +} + +// setting the inversive distances between the vertices to -2 gives a regular +// tetrahedron with side length 1, whose insphere and circumsphere have radii +// sqrt(1/6) and sqrt(3/2), respectively. to measure those radii, set an +// inversive distance of -1 between the insphere and each face, and then set an +// inversive distance of 0 between the circumsphere and each vertex +fn load_radius_ratio_assemb(assembly: &Assembly) { + let index_range = 1..=4; + + // create the spheres + const GRAY: ElementColor = [0.75_f32, 0.75_f32, 0.75_f32]; + let spheres = [ + Sphere::new( + "sphere_faces".to_string(), + "Insphere".to_string(), + GRAY, + engine::sphere(0.0, 0.0, 0.0, 0.5) + ), + Sphere::new( + "sphere_vertices".to_string(), + "Circumsphere".to_string(), + GRAY, + engine::sphere(0.0, 0.0, 0.0, 0.25) + ) + ]; + for sphere in spheres { + let _ = assembly.try_insert_element(sphere); + } + + // create the vertices + let vertices = izip!( + index_range.clone(), + [ + [1.00_f32, 0.50_f32, 0.75_f32], + [1.00_f32, 0.75_f32, 0.50_f32], + [1.00_f32, 1.00_f32, 0.50_f32], + [0.75_f32, 0.50_f32, 1.00_f32] + ].into_iter(), + [ + engine::point(-0.6, -0.8, -0.6), + engine::point(-0.6, 0.8, 0.6), + engine::point(0.6, -0.8, 0.6), + engine::point(0.6, 0.8, -0.6) + ].into_iter() + ).map( + |(k, color, representation)| { + Point::new( + format!("v{k}"), + format!("Vertex {k}"), + color, + representation + ) + } + ); + for vertex in vertices { + let _ = assembly.try_insert_element(vertex); + } + + // create the faces + let base_dir = Vector3::new(1.0, 0.75, 1.0).normalize(); + let offset = base_dir.dot(&Vector3::new(-0.6, 0.8, 0.6)); + let faces = izip!( + index_range.clone(), + [ + [1.00_f32, 0.00_f32, 0.25_f32], + [1.00_f32, 0.25_f32, 0.00_f32], + [0.75_f32, 0.75_f32, 0.00_f32], + [0.25_f32, 0.00_f32, 1.00_f32] + ].into_iter(), + [ + engine::sphere_with_offset(base_dir[0], base_dir[1], base_dir[2], offset, 0.0), + engine::sphere_with_offset(base_dir[0], -base_dir[1], -base_dir[2], offset, 0.0), + engine::sphere_with_offset(-base_dir[0], base_dir[1], -base_dir[2], offset, 0.0), + engine::sphere_with_offset(-base_dir[0], -base_dir[1], base_dir[2], offset, 0.0) + ].into_iter() + ).map( + |(k, color, representation)| { + Sphere::new( + format!("f{k}"), + format!("Face {k}"), + color, + representation + ) + } + ); + for face in faces { + face.ghost().set(true); + let _ = assembly.try_insert_element(face); + } + + // impose the constraints + for j in index_range.clone() { + let [face_j, vertex_j] = [ + format!("f{j}"), + format!("v{j}") + ].map( + |id| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&id].clone() + ) + ); + + // make the faces planar + let curvature_regulator = face_j.regulators().with_untracked( + |regs| regs.first().unwrap().clone() + ); + curvature_regulator.set_point().set( + SpecifiedValue::try_from("0".to_string()).unwrap() + ); + + for k in index_range.clone().filter(|&index| index != j) { + let vertex_k = assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&format!("v{k}")].clone() + ); + + // fix the distances between the vertices + if j < k { + let distance_regulator = InversiveDistanceRegulator::new( + [vertex_j.clone(), vertex_k.clone()] + ); + assembly.insert_regulator(Rc::new(distance_regulator)); + } + + // put the vertices on the faces + let incidence_regulator = InversiveDistanceRegulator::new([face_j.clone(), vertex_k.clone()]); + incidence_regulator.set_point.set(SpecifiedValue::try_from("0".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(incidence_regulator)); + } + } +} + +// to finish setting up the problem, fix the following curvatures: +// sun 1 +// moon 5/3 = 1.666666666666666... +// chain1 2 +// a tiny `x` or `z` nudge of the outer sphere reliably prevents realization +// failures before they happen, or resolves them after they happen. the result +// depends sensitively on the translation direction, suggesting that realization +// is failing because the engine is having trouble breaking a symmetry +// /* TO DO */ +// the engine's performance on this problem is scale-dependent! with the current +// initial conditions, realization fails for any order of imposing the remaining +// curvature constraints. scaling everything up by a factor of ten, as done in +// the original problem, makes realization succeed reliably. one potentially +// relevant difference is that a lot of the numbers in the current initial +// conditions are exactly representable as floats, unlike the analogous numbers +// in the scaled-up problem. the inexact representations might break the +// symmetry that's getting the engine stuck +fn load_irisawa_hexlet_assemb(assembly: &Assembly) { + let index_range = 1..=6; + let colors = [ + [1.00_f32, 0.00_f32, 0.25_f32], + [1.00_f32, 0.25_f32, 0.00_f32], + [0.75_f32, 0.75_f32, 0.00_f32], + [0.25_f32, 1.00_f32, 0.00_f32], + [0.00_f32, 0.25_f32, 1.00_f32], + [0.25_f32, 0.00_f32, 1.00_f32] + ].into_iter(); + + // create the spheres + let spheres = [ + Sphere::new( + "outer".to_string(), + "Outer".to_string(), + [0.5_f32, 0.5_f32, 0.5_f32], + engine::sphere(0.0, 0.0, 0.0, 1.5) + ), + Sphere::new( + "sun".to_string(), + "Sun".to_string(), + [0.75_f32, 0.75_f32, 0.75_f32], + engine::sphere(0.0, -0.75, 0.0, 0.75) + ), + Sphere::new( + "moon".to_string(), + "Moon".to_string(), + [0.25_f32, 0.25_f32, 0.25_f32], + engine::sphere(0.0, 0.75, 0.0, 0.75) + ), + ].into_iter().chain( + index_range.clone().zip(colors).map( + |(k, color)| { + let ang = (k as f64) * PI/3.0; + Sphere::new( + format!("chain{k}"), + format!("Chain {k}"), + color, + engine::sphere(1.0 * ang.sin(), 0.0, 1.0 * ang.cos(), 0.5) + ) + } + ) + ); + for sphere in spheres { + let _ = assembly.try_insert_element(sphere); + } + + // put the outer sphere in ghost mode and fix its curvature + let outer = assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id["outer"].clone() + ); + outer.ghost().set(true); + let outer_curvature_regulator = outer.regulators().with_untracked( + |regs| regs.first().unwrap().clone() + ); + outer_curvature_regulator.set_point().set( + SpecifiedValue::try_from((1.0 / 3.0).to_string()).unwrap() + ); + + // impose the desired tangencies + let [outer, sun, moon] = ["outer", "sun", "moon"].map( + |id| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[id].clone() + ) + ); + let chain = index_range.map( + |k| assembly.elements_by_id.with_untracked( + |elts_by_id| elts_by_id[&format!("chain{k}")].clone() + ) + ); + for (chain_sphere, chain_sphere_next) in chain.clone().zip(chain.cycle().skip(1)) { + for (other_sphere, inversive_distance) in [ + (outer.clone(), "1"), + (sun.clone(), "-1"), + (moon.clone(), "-1"), + (chain_sphere_next.clone(), "-1") + ] { + let tangency = InversiveDistanceRegulator::new([chain_sphere.clone(), other_sphere]); + tangency.set_point.set(SpecifiedValue::try_from(inversive_distance.to_string()).unwrap()); + assembly.insert_regulator(Rc::new(tangency)); + } + } + + let outer_sun_tangency = InversiveDistanceRegulator::new([outer.clone(), sun]); + outer_sun_tangency.set_point.set(SpecifiedValue::try_from("1".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(outer_sun_tangency)); + + let outer_moon_tangency = InversiveDistanceRegulator::new([outer.clone(), moon]); + outer_moon_tangency.set_point.set(SpecifiedValue::try_from("1".to_string()).unwrap()); + assembly.insert_regulator(Rc::new(outer_moon_tangency)); +} + +// --- chooser --- + +/* DEBUG */ +#[component] +pub fn TestAssemblyChooser() -> View { + // create an effect that loads the selected test assembly + let assembly_name = create_signal("general".to_string()); + create_effect(move || { + // get name of chosen assembly + let name = assembly_name.get_clone(); + console::log_1( + &JsValue::from(format!("Showing assembly \"{}\"", name.clone())) + ); + + batch(|| { + let state = use_context::(); + let assembly = &state.assembly; + + // pause realization + assembly.keep_realized.set(false); + + // clear state + assembly.regulators.update(|regs| regs.clear()); + assembly.elements.update(|elts| elts.clear()); + assembly.elements_by_id.update(|elts_by_id| elts_by_id.clear()); + assembly.descent_history.set(DescentHistory::new()); + state.selection.update(|sel| sel.clear()); + + // load assembly + match name.as_str() { + "general" => load_gen_assemb(assembly), + "low-curv" => load_low_curv_assemb(assembly), + "pointed" => load_pointed_assemb(assembly), + "tridim-icosahedron" => load_tridim_icosahedron_assemb(assembly), + "dodeca-packing" => load_dodeca_packing_assemb(assembly), + "balanced" => load_balanced_assemb(assembly), + "off-center" => load_off_center_assemb(assembly), + "radius-ratio" => load_radius_ratio_assemb(assembly), + "irisawa-hexlet" => load_irisawa_hexlet_assemb(assembly), + _ => () + }; + + // resume realization + assembly.keep_realized.set(true); + }); + }); + + // build the chooser + view! { + select(bind:value=assembly_name) { + option(value="general") { "General" } + option(value="low-curv") { "Low-curvature" } + option(value="pointed") { "Pointed" } + option(value="tridim-icosahedron") { "Tridiminished icosahedron" } + option(value="dodeca-packing") { "Dodecahedral packing" } + option(value="balanced") { "Balanced" } + option(value="off-center") { "Off-center" } + option(value="radius-ratio") { "Radius ratio" } + option(value="irisawa-hexlet") { "Irisawa hexlet" } + option(value="empty") { "Empty" } + } + } +} \ No newline at end of file diff --git a/app-proto/src/engine.rs b/app-proto/src/engine.rs new file mode 100644 index 0000000..e6ffa25 --- /dev/null +++ b/app-proto/src/engine.rs @@ -0,0 +1,1006 @@ +use lazy_static::lazy_static; +use nalgebra::{Const, DMatrix, DVector, DVectorView, Dyn, SymmetricEigen}; +use std::fmt::{Display, Error, Formatter}; +use web_sys::{console, wasm_bindgen::JsValue}; /* DEBUG */ + +// --- elements --- + +pub fn point(x: f64, y: f64, z: f64) -> DVector { + 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 +pub fn sphere(center_x: f64, center_y: f64, center_z: f64, radius: f64) -> DVector { + let center_norm_sq = center_x * center_x + center_y * center_y + center_z * center_z; + DVector::from_column_slice(&[ + center_x / radius, + center_y / radius, + center_z / radius, + 0.5 / radius, + 0.5 * (center_norm_sq / radius - radius) + ]) +} + +// the sphere of curvature `curv` whose closest point to the origin has position +// `off * dir` and normal `dir`, where `dir` is a unit vector. setting the +// curvature to zero gives a plane +pub fn sphere_with_offset(dir_x: f64, dir_y: f64, dir_z: f64, off: f64, curv: f64) -> DVector { + let norm_sp = 1.0 + off * curv; + DVector::from_column_slice(&[ + norm_sp * dir_x, + norm_sp * dir_y, + norm_sp * dir_z, + 0.5 * curv, + off * (1.0 + 0.5 * off * curv) + ]) +} + +// project a sphere's representation vector to the normalization variety by +// contracting toward the last coordinate axis +pub fn project_sphere_to_normalized(rep: &mut DVector) { + let q_sp = rep.fixed_rows::<3>(0).norm_squared(); + let half_q_lt = -2.0 * rep[3] * rep[4]; + let half_q_lt_sq = half_q_lt * half_q_lt; + let scaling = half_q_lt + (q_sp + half_q_lt_sq).sqrt(); + rep.fixed_rows_mut::<4>(0).scale_mut(1.0 / scaling); +} + +// normalize a point's representation vector by scaling +pub fn project_point_to_normalized(rep: &mut DVector) { + rep.scale_mut(0.5 / rep[3]); +} + +// given a sphere's representation vector, change the sphere's half-curvature to +// `half-curv` and then restore normalization by contracting the representation +// vector toward the curvature axis +pub fn change_half_curvature(rep: &mut DVector, half_curv: f64) { + // set the sphere's half-curvature to the desired value + rep[3] = half_curv; + + // restore normalization by contracting toward the curvature axis + const SIZE_THRESHOLD: f64 = 1e-9; + let half_q_lt = -2.0 * half_curv * rep[4]; + let half_q_lt_sq = half_q_lt * half_q_lt; + let mut spatial = rep.fixed_rows_mut::<3>(0); + let q_sp = spatial.norm_squared(); + if q_sp < SIZE_THRESHOLD && half_q_lt_sq < SIZE_THRESHOLD { + spatial.copy_from_slice( + &[0.0, 0.0, (1.0 - 2.0 * half_q_lt).sqrt()] + ); + } else { + let scaling = half_q_lt + (q_sp + half_q_lt_sq).sqrt(); + spatial.scale_mut(1.0 / scaling); + rep[4] /= scaling; + } + + /* DEBUG */ + // verify normalization + let rep_for_debug = rep.clone(); + console::log_1(&JsValue::from( + format!( + "Sphere self-product after curvature change: {}", + rep_for_debug.dot(&(&*Q * &rep_for_debug)) + ) + )); +} + +// --- partial matrices --- + +pub struct MatrixEntry { + index: (usize, usize), + value: f64 +} + +pub struct PartialMatrix(Vec); + +impl PartialMatrix { + pub fn new() -> PartialMatrix { + PartialMatrix(Vec::::new()) + } + + pub fn push(&mut self, row: usize, col: usize, value: f64) { + let PartialMatrix(entries) = self; + entries.push(MatrixEntry { index: (row, col), value: value }); + } + + pub fn push_sym(&mut self, row: usize, col: usize, value: f64) { + self.push(row, col, value); + if row != col { + self.push(col, row, value); + } + } + + fn freeze(&self, a: &DMatrix) -> DMatrix { + let mut result = a.clone(); + for &MatrixEntry { index, value } in self { + result[index] = value; + } + result + } + + fn proj(&self, a: &DMatrix) -> DMatrix { + let mut result = DMatrix::::zeros(a.nrows(), a.ncols()); + for &MatrixEntry { index, .. } in self { + result[index] = a[index]; + } + result + } + + fn sub_proj(&self, rhs: &DMatrix) -> DMatrix { + let mut result = DMatrix::::zeros(rhs.nrows(), rhs.ncols()); + for &MatrixEntry { index, value } in self { + result[index] = value - rhs[index]; + } + result + } +} + +impl Display for PartialMatrix { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + for &MatrixEntry { index: (row, col), value } in self { + writeln!(f, " {row} {col} {value}")?; + } + Ok(()) + } +} + +impl IntoIterator for PartialMatrix { + type Item = MatrixEntry; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + let PartialMatrix(entries) = self; + entries.into_iter() + } +} + +impl<'a> IntoIterator for &'a PartialMatrix { + type Item = &'a MatrixEntry; + type IntoIter = std::slice::Iter<'a, MatrixEntry>; + + fn into_iter(self) -> Self::IntoIter { + let PartialMatrix(entries) = self; + entries.into_iter() + } +} + +// --- configuration subspaces --- + +#[derive(Clone)] +pub struct ConfigSubspace { + assembly_dim: usize, + basis_std: Vec>, + basis_proj: Vec> +} + +impl ConfigSubspace { + pub fn zero(assembly_dim: usize) -> ConfigSubspace { + ConfigSubspace { + assembly_dim: assembly_dim, + basis_proj: Vec::new(), + basis_std: Vec::new() + } + } + + // approximate the kernel of a symmetric endomorphism of the configuration + // space for `assembly_dim` elements. we consider an eigenvector to be part + // of the kernel if its eigenvalue is smaller than the constant `THRESHOLD` + fn symmetric_kernel(a: DMatrix, proj_to_std: DMatrix, assembly_dim: usize) -> ConfigSubspace { + // find a basis for the kernel. the basis is expressed in the projection + // coordinates, and it's orthonormal with respect to the projection + // inner product + const THRESHOLD: f64 = 0.1; + let eig = SymmetricEigen::new(proj_to_std.tr_mul(&a) * &proj_to_std); + let eig_vecs = eig.eigenvectors.column_iter(); + let eig_pairs = eig.eigenvalues.iter().zip(eig_vecs); + let basis_proj = DMatrix::from_columns( + eig_pairs.filter_map( + |(λ, v)| (λ.abs() < THRESHOLD).then_some(v) + ).collect::>().as_slice() + ); + + /* DEBUG */ + // print the eigenvalues + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + console::log_1(&JsValue::from( + format!("Eigenvalues used to find kernel:{}", eig.eigenvalues) + )); + + // express the basis in the standard coordinates + let basis_std = proj_to_std * &basis_proj; + + const ELEMENT_DIM: usize = 5; + const UNIFORM_DIM: usize = 4; + ConfigSubspace { + assembly_dim: assembly_dim, + basis_std: basis_std.column_iter().map( + |v| Into::>::into( + v.reshape_generic(Dyn(ELEMENT_DIM), Dyn(assembly_dim)) + ) + ).collect(), + basis_proj: basis_proj.column_iter().map( + |v| Into::>::into( + v.reshape_generic(Dyn(UNIFORM_DIM), Dyn(assembly_dim)) + ) + ).collect() + } + } + + pub fn dim(&self) -> usize { + self.basis_std.len() + } + + pub fn assembly_dim(&self) -> usize { + self.assembly_dim + } + + // find the projection onto this subspace of the motion where the element + // with the given column index has velocity `v`. the velocity is given in + // projection coordinates, and the projection is done with respect to the + // projection inner product + pub fn proj(&self, v: &DVectorView, column_index: usize) -> DMatrix { + if self.dim() == 0 { + const ELEMENT_DIM: usize = 5; + DMatrix::zeros(ELEMENT_DIM, self.assembly_dim) + } else { + self.basis_proj.iter().zip(self.basis_std.iter()).map( + |(b_proj, b_std)| b_proj.column(column_index).dot(&v) * b_std + ).sum() + } + } +} + +// --- descent history --- + +pub struct DescentHistory { + pub config: Vec>, + pub scaled_loss: Vec, + pub neg_grad: Vec>, + pub hess_eigvals: Vec::>, + pub base_step: Vec>, + pub backoff_steps: Vec +} + +impl DescentHistory { + pub fn new() -> DescentHistory { + DescentHistory { + config: Vec::>::new(), + scaled_loss: Vec::::new(), + neg_grad: Vec::>::new(), + hess_eigvals: Vec::>::new(), + base_step: Vec::>::new(), + backoff_steps: Vec::::new(), + } + } +} + +// --- constraint problems --- + +pub struct ConstraintProblem { + pub gram: PartialMatrix, + pub frozen: PartialMatrix, + pub guess: DMatrix, +} + +impl ConstraintProblem { + pub fn new(element_count: usize) -> ConstraintProblem { + const ELEMENT_DIM: usize = 5; + ConstraintProblem { + gram: PartialMatrix::new(), + frozen: PartialMatrix::new(), + guess: DMatrix::::zeros(ELEMENT_DIM, element_count) + } + } + + #[cfg(feature = "dev")] + pub fn from_guess(guess_columns: &[DVector]) -> ConstraintProblem { + ConstraintProblem { + gram: PartialMatrix::new(), + frozen: PartialMatrix::new(), + guess: DMatrix::from_columns(guess_columns) + } + } +} + +// --- gram matrix realization --- + +// the Lorentz form +lazy_static! { + pub static ref Q: DMatrix = 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 + ]); +} + +struct SearchState { + config: DMatrix, + err_proj: DMatrix, + loss: f64 +} + +impl SearchState { + fn from_config(gram: &PartialMatrix, config: DMatrix) -> 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 { + let mut result = DMatrix::::zeros(nrows, ncols); + result[index] = 1.0; + result +} + +// given a normalized vector `v` representing an element, build a basis for the +// element's linear configuration space consisting of: +// - the unit translation motions of the element +// - the unit shrinking motion of the element, if it's a sphere +// - one or two vectors whose coefficients vanish on the tangent space of the +// normalization variety +pub fn local_unif_to_std(v: DVectorView) -> DMatrix { + const ELEMENT_DIM: usize = 5; + const UNIFORM_DIM: usize = 4; + let curv = 2.0*v[3]; + if v.dot(&(&*Q * v)) < 0.5 { + // `v` represents a point. the normalization condition says that the + // curvature component of `v` is 1/2 + DMatrix::from_column_slice(ELEMENT_DIM, UNIFORM_DIM, &[ + curv, 0.0, 0.0, 0.0, v[0], + 0.0, curv, 0.0, 0.0, v[1], + 0.0, 0.0, curv, 0.0, v[2], + 0.0, 0.0, 0.0, 0.0, 1.0 + ]) + } else { + // `v` represents a sphere. the normalization condition says that the + // Lorentz product of `v` with itself is 1 + DMatrix::from_column_slice(ELEMENT_DIM, UNIFORM_DIM, &[ + curv, 0.0, 0.0, 0.0, v[0], + 0.0, curv, 0.0, 0.0, v[1], + 0.0, 0.0, curv, 0.0, v[2], + curv*v[0], curv*v[1], curv*v[2], curv*v[3], curv*v[4] + 1.0 + ]) + } +} + +// use backtracking line search to find a better configuration +fn seek_better_config( + gram: &PartialMatrix, + state: &SearchState, + base_step: &DMatrix, + 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 +} + +// a first-order neighborhood of a configuration +pub struct ConfigNeighborhood { + pub config: DMatrix, + pub nbhd: ConfigSubspace +} + +pub struct Realization { + pub result: Result, + pub history: DescentHistory +} + +// seek a matrix `config` that matches the partial matrix `problem.frozen` and +// has `config' * Q * config` matching the partial matrix `problem.gram`. start +// at `problem.guess`, set the frozen entries to their desired values, and then +// use a regularized Newton's method to seek the desired Gram matrix +pub fn realize_gram( + problem: &ConstraintProblem, + scaled_tol: f64, + min_efficiency: f64, + backoff: f64, + reg_scale: f64, + max_descent_steps: i32, + max_backoff_steps: i32 +) -> Realization { + // destructure the problem data + let ConstraintProblem { + gram, guess, frozen + } = problem; + + // 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 = frozen.into_iter().map( + |MatrixEntry { index: (row, col), .. }| col*element_dim + row + ).collect(); + + // use a regularized Newton's method with backtracking + let mut state = SearchState::from_config(gram, frozen.freeze(guess)); + let mut hess = DMatrix::zeros(element_dim, assembly_dim); + for _ in 0..max_descent_steps { + // 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::>::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>)); + } + } + hess = DMatrix::from_columns(hess_cols.as_slice()); + + // regularize the Hessian + let hess_eigvals = hess.symmetric_eigenvalues(); + let min_eigval = hess_eigvals.min(); + if min_eigval <= 0.0 { + hess -= reg_scale * min_eigval * DMatrix::identity(total_dim, total_dim); + } + history.hess_eigvals.push(hess_eigvals); + + // 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; + } + + // 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; } + + // compute the Newton step + /* TO DO */ + /* + we should change our regularization to ensure that the Hessian is + is positive-definite, rather than just positive-semidefinite. ideally, + that would guarantee the success of the Cholesky decomposition--- + although we'd still need the error-handling routine in case of + numerical hiccups + */ + let hess_cholesky = match hess.clone().cholesky() { + Some(cholesky) => cholesky, + None => return Realization { + result: Err("Cholesky decomposition failed".to_string()), + history + } + }; + let base_step_stacked = hess_cholesky.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 + if let Some((better_state, backoff_steps)) = seek_better_config( + gram, &state, &base_step, neg_grad.dot(&base_step), + min_efficiency, backoff, max_backoff_steps + ) { + state = better_state; + history.backoff_steps.push(backoff_steps); + } else { + return Realization { + result: Err("Line search failed".to_string()), + history + } + }; + } + let result = if state.loss < tol { + // express the uniform basis in the standard basis + const UNIFORM_DIM: usize = 4; + let total_dim_unif = UNIFORM_DIM * assembly_dim; + let mut unif_to_std = DMatrix::::zeros(total_dim, total_dim_unif); + for n in 0..assembly_dim { + let block_start = (element_dim * n, UNIFORM_DIM * n); + unif_to_std + .view_mut(block_start, (element_dim, UNIFORM_DIM)) + .copy_from(&local_unif_to_std(state.config.column(n))); + } + + // find the kernel of the Hessian. give it the uniform inner product + let tangent = ConfigSubspace::symmetric_kernel(hess, unif_to_std, assembly_dim); + + Ok(ConfigNeighborhood { config: state.config, nbhd: tangent }) + } else { + Err("Failed to reach target accuracy".to_string()) + }; + Realization { result, history } +} + +// --- tests --- + +#[cfg(feature = "dev")] +pub mod examples { + use std::f64::consts::PI; + + use super::*; + + // 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/ + // + pub fn realize_irisawa_hexlet(scaled_tol: f64) -> Realization { + let mut problem = ConstraintProblem::from_guess( + [ + 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::>().as_slice() + ); + + for s in 0..9 { + // each sphere is represented by a spacelike vector + problem.gram.push_sym(s, s, 1.0); + + // the circumscribing sphere is tangent to all of the other + // spheres, with matching orientation + if s > 0 { + problem.gram.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 { + problem.gram.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; + problem.gram.push_sym(s, s_next, -1.0); + } + } + + // the frozen entries fix the radii of the circumscribing sphere, the + // "sun" and "moon" spheres, and one of the chain spheres + for k in 0..4 { + problem.frozen.push(3, k, problem.guess[(3, k)]); + } + + realize_gram(&problem, scaled_tol, 0.5, 0.9, 1.1, 200, 110) + } + + // set up a kaleidocycle, made of points with fixed distances between them, + // and find its tangent space + pub fn realize_kaleidocycle(scaled_tol: f64) -> Realization { + const N_HINGES: usize = 6; + let mut problem = ConstraintProblem::from_guess( + (0..N_HINGES).step_by(2).flat_map( + |n| { + let ang_hor = (n as f64) * PI/3.0; + let ang_vert = ((n + 1) as f64) * PI/3.0; + let x_vert = ang_vert.cos(); + let y_vert = ang_vert.sin(); + [ + point(0.0, 0.0, 0.0), + point(ang_hor.cos(), ang_hor.sin(), 0.0), + point(x_vert, y_vert, -0.5), + point(x_vert, y_vert, 0.5) + ] + } + ).collect::>().as_slice() + ); + + const N_POINTS: usize = 2 * N_HINGES; + for block in (0..N_POINTS).step_by(2) { + let block_next = (block + 2) % N_POINTS; + for j in 0..2 { + // diagonal and hinge edges + for k in j..2 { + problem.gram.push_sym(block + j, block + k, if j == k { 0.0 } else { -0.5 }); + } + + // non-hinge edges + for k in 0..2 { + problem.gram.push_sym(block + j, block_next + k, -0.625); + } + } + } + + for k in 0..N_POINTS { + problem.frozen.push(3, k, problem.guess[(3, k)]) + } + + realize_gram(&problem, scaled_tol, 0.5, 0.9, 1.1, 200, 110) + } +} + +#[cfg(test)] +mod tests { + use nalgebra::Vector3; + use std::{f64::consts::{FRAC_1_SQRT_2, PI}, iter}; + + use super::{*, examples::*}; + + #[test] + fn freeze_test() { + let frozen = PartialMatrix(vec![ + MatrixEntry { index: (0, 0), value: 14.0 }, + MatrixEntry { index: (0, 2), value: 28.0 }, + MatrixEntry { index: (1, 1), value: 42.0 }, + MatrixEntry { index: (1, 2), value: 49.0 } + ]); + let config = DMatrix::::from_row_slice(2, 3, &[ + 1.0, 2.0, 3.0, + 4.0, 5.0, 6.0 + ]); + let expected_result = DMatrix::::from_row_slice(2, 3, &[ + 14.0, 2.0, 28.0, + 4.0, 42.0, 49.0 + ]); + assert_eq!(frozen.freeze(&config), expected_result); + } + + #[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::::from_row_slice(2, 3, &[ + 1.0, 2.0, 3.0, + 4.0, 5.0, 6.0 + ]); + let expected_result = DMatrix::::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 mut gram = PartialMatrix::new(); + for j in 0..3 { + for k in 0..3 { + gram.push(j, k, if j == k { 1.0 } else { -1.0 }); + } + } + let config = { + let a = 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); + } + + /* TO DO */ + // at the frozen indices, the optimization steps should have exact zeros, + // and the realized configuration should have the desired values + #[test] + fn frozen_entry_test() { + let mut problem = ConstraintProblem::from_guess(&[ + point(0.0, 0.0, 2.0), + sphere(0.0, 0.0, 0.0, 0.95) + ]); + for j in 0..2 { + for k in j..2 { + problem.gram.push_sym(j, k, if (j, k) == (1, 1) { 1.0 } else { 0.0 }); + } + } + problem.frozen.push(3, 0, problem.guess[(3, 0)]); + problem.frozen.push(3, 1, 0.5); + let Realization { result, history } = realize_gram( + &problem, 1.0e-12, 0.5, 0.9, 1.1, 200, 110 + ); + let config = result.unwrap().config; + for base_step in history.base_step.into_iter() { + for &MatrixEntry { index, .. } in &problem.frozen { + assert_eq!(base_step[index], 0.0); + } + } + for MatrixEntry { index, value } in problem.frozen { + assert_eq!(config[index], value); + } + } + + #[test] + fn irisawa_hexlet_test() { + // solve Irisawa's problem + const SCALED_TOL: f64 = 1.0e-12; + let config = realize_irisawa_hexlet(SCALED_TOL).result.unwrap().config; + + // check against Irisawa's solution + 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); + } + } + + #[test] + fn tangent_test_three_spheres() { + const SCALED_TOL: f64 = 1.0e-12; + const ELEMENT_DIM: usize = 5; + let mut problem = ConstraintProblem::from_guess(&[ + sphere(0.0, 0.0, 0.0, -2.0), + sphere(0.0, 0.0, 1.0, 1.0), + sphere(0.0, 0.0, -1.0, 1.0) + ]); + for j in 0..3 { + for k in j..3 { + problem.gram.push_sym(j, k, if j == k { 1.0 } else { -1.0 }); + } + } + for n in 0..ELEMENT_DIM { + problem.frozen.push(n, 0, problem.guess[(n, 0)]); + } + let Realization { result, history } = realize_gram( + &problem, SCALED_TOL, 0.5, 0.9, 1.1, 200, 110 + ); + let ConfigNeighborhood { config, nbhd: tangent } = result.unwrap(); + assert_eq!(config, problem.guess); + assert_eq!(history.scaled_loss.len(), 1); + + // list some motions that should form a basis for the tangent space of + // the solution variety + const UNIFORM_DIM: usize = 4; + let element_dim = problem.guess.nrows(); + let assembly_dim = problem.guess.ncols(); + let tangent_motions_unif = vec![ + basis_matrix((0, 1), UNIFORM_DIM, assembly_dim), + basis_matrix((1, 1), UNIFORM_DIM, assembly_dim), + basis_matrix((0, 2), UNIFORM_DIM, assembly_dim), + basis_matrix((1, 2), UNIFORM_DIM, assembly_dim), + DMatrix::::from_column_slice(UNIFORM_DIM, assembly_dim, &[ + 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, -0.5, -0.5, + 0.0, 0.0, -0.5, 0.5 + ]) + ]; + let tangent_motions_std = vec![ + basis_matrix((0, 1), element_dim, assembly_dim), + basis_matrix((1, 1), element_dim, assembly_dim), + basis_matrix((0, 2), element_dim, assembly_dim), + basis_matrix((1, 2), element_dim, assembly_dim), + DMatrix::::from_column_slice(element_dim, assembly_dim, &[ + 0.0, 0.0, 0.0, 0.00, 0.0, + 0.0, 0.0, -1.0, -0.25, -1.0, + 0.0, 0.0, -1.0, 0.25, 1.0 + ]) + ]; + + // confirm that the dimension of the tangent space is no greater than + // expected + assert_eq!(tangent.basis_std.len(), tangent_motions_std.len()); + + // confirm that the tangent space contains all the motions we expect it + // to. since we've already bounded the dimension of the tangent space, + // this confirms that the tangent space is what we expect it to be + let tol_sq = ((element_dim * assembly_dim) as f64) * SCALED_TOL * SCALED_TOL; + for (motion_unif, motion_std) in tangent_motions_unif.into_iter().zip(tangent_motions_std) { + let motion_proj: DMatrix<_> = motion_unif.column_iter().enumerate().map( + |(k, v)| tangent.proj(&v, k) + ).sum(); + assert!((motion_std - motion_proj).norm_squared() < tol_sq); + } + } + + fn translation_motion_unif(vel: &Vector3, assembly_dim: usize) -> Vec> { + let mut elt_motion = DVector::zeros(4); + elt_motion.fixed_rows_mut::<3>(0).copy_from(vel); + iter::repeat(elt_motion).take(assembly_dim).collect() + } + + fn rotation_motion_unif(ang_vel: &Vector3, points: Vec>) -> Vec> { + points.into_iter().map( + |pt| { + let vel = ang_vel.cross(&pt.fixed_rows::<3>(0)); + let mut elt_motion = DVector::zeros(4); + elt_motion.fixed_rows_mut::<3>(0).copy_from(&vel); + elt_motion + } + ).collect() + } + + #[test] + fn tangent_test_kaleidocycle() { + // set up a kaleidocycle and find its tangent space + const SCALED_TOL: f64 = 1.0e-12; + let Realization { result, history } = realize_kaleidocycle(SCALED_TOL); + let ConfigNeighborhood { config, nbhd: tangent } = result.unwrap(); + assert_eq!(history.scaled_loss.len(), 1); + + // list some motions that should form a basis for the tangent space of + // the solution variety + const N_HINGES: usize = 6; + let element_dim = config.nrows(); + let assembly_dim = config.ncols(); + let tangent_motions_unif = vec![ + // the translations along the coordinate axes + translation_motion_unif(&Vector3::new(1.0, 0.0, 0.0), assembly_dim), + translation_motion_unif(&Vector3::new(0.0, 1.0, 0.0), assembly_dim), + translation_motion_unif(&Vector3::new(0.0, 0.0, 1.0), assembly_dim), + + // the rotations about the coordinate axes + rotation_motion_unif(&Vector3::new(1.0, 0.0, 0.0), config.column_iter().collect()), + rotation_motion_unif(&Vector3::new(0.0, 1.0, 0.0), config.column_iter().collect()), + rotation_motion_unif(&Vector3::new(0.0, 0.0, 1.0), config.column_iter().collect()), + + // the twist motion. more precisely: a motion that keeps the center + // of mass stationary and preserves the distances between the + // vertices to first order. this has to be the twist as long as: + // - twisting is the kaleidocycle's only internal degree of + // freedom + // - every first-order motion of the kaleidocycle comes from an + // actual motion + (0..N_HINGES).step_by(2).flat_map( + |n| { + let ang_vert = ((n + 1) as f64) * PI/3.0; + let vel_vert_x = 4.0 * ang_vert.cos(); + let vel_vert_y = 4.0 * ang_vert.sin(); + [ + DVector::from_column_slice(&[0.0, 0.0, 5.0, 0.0]), + DVector::from_column_slice(&[0.0, 0.0, 1.0, 0.0]), + DVector::from_column_slice(&[-vel_vert_x, -vel_vert_y, -3.0, 0.0]), + DVector::from_column_slice(&[vel_vert_x, vel_vert_y, -3.0, 0.0]) + ] + } + ).collect::>() + ]; + let tangent_motions_std = tangent_motions_unif.iter().map( + |motion| DMatrix::from_columns( + &config.column_iter().zip(motion).map( + |(v, elt_motion)| local_unif_to_std(v) * elt_motion + ).collect::>() + ) + ).collect::>(); + + // confirm that the dimension of the tangent space is no greater than + // expected + assert_eq!(tangent.basis_std.len(), tangent_motions_unif.len()); + + // confirm that the tangent space contains all the motions we expect it + // to. since we've already bounded the dimension of the tangent space, + // this confirms that the tangent space is what we expect it to be + let tol_sq = ((element_dim * assembly_dim) as f64) * SCALED_TOL * SCALED_TOL; + for (motion_unif, motion_std) in tangent_motions_unif.into_iter().zip(tangent_motions_std) { + let motion_proj: DMatrix<_> = motion_unif.into_iter().enumerate().map( + |(k, v)| tangent.proj(&v.as_view(), k) + ).sum(); + assert!((motion_std - motion_proj).norm_squared() < tol_sq); + } + } + + fn translation(dis: Vector3) -> DMatrix { + const ELEMENT_DIM: usize = 5; + DMatrix::from_column_slice(ELEMENT_DIM, ELEMENT_DIM, &[ + 1.0, 0.0, 0.0, 0.0, dis[0], + 0.0, 1.0, 0.0, 0.0, dis[1], + 0.0, 0.0, 1.0, 0.0, dis[2], + 2.0*dis[0], 2.0*dis[1], 2.0*dis[2], 1.0, dis.norm_squared(), + 0.0, 0.0, 0.0, 0.0, 1.0 + ]) + } + + // confirm that projection onto a configuration subspace is equivariant with + // respect to Euclidean motions + #[test] + fn proj_equivar_test() { + // find a pair of spheres that meet at 120° + const SCALED_TOL: f64 = 1.0e-12; + let mut problem_orig = ConstraintProblem::from_guess(&[ + sphere(0.0, 0.0, 0.5, 1.0), + sphere(0.0, 0.0, -0.5, 1.0) + ]); + problem_orig.gram.push_sym(0, 0, 1.0); + problem_orig.gram.push_sym(1, 1, 1.0); + problem_orig.gram.push_sym(0, 1, 0.5); + let Realization { result: result_orig, history: history_orig } = realize_gram( + &problem_orig, SCALED_TOL, 0.5, 0.9, 1.1, 200, 110 + ); + let ConfigNeighborhood { config: config_orig, nbhd: tangent_orig } = result_orig.unwrap(); + assert_eq!(config_orig, problem_orig.guess); + assert_eq!(history_orig.scaled_loss.len(), 1); + + // find another pair of spheres that meet at 120°. we'll think of this + // solution as a transformed version of the original one + let guess_tfm = { + let a = 0.5 * FRAC_1_SQRT_2; + DMatrix::from_columns(&[ + sphere(a, 0.0, 7.0 + a, 1.0), + sphere(-a, 0.0, 7.0 - a, 1.0) + ]) + }; + let problem_tfm = ConstraintProblem { + gram: problem_orig.gram, + guess: guess_tfm, + frozen: problem_orig.frozen + }; + let Realization { result: result_tfm, history: history_tfm } = realize_gram( + &problem_tfm, SCALED_TOL, 0.5, 0.9, 1.1, 200, 110 + ); + let ConfigNeighborhood { config: config_tfm, nbhd: tangent_tfm } = result_tfm.unwrap(); + assert_eq!(config_tfm, problem_tfm.guess); + assert_eq!(history_tfm.scaled_loss.len(), 1); + + // project a nudge to the tangent space of the solution variety at the + // original solution + let motion_orig = DVector::from_column_slice(&[0.0, 0.0, 1.0, 0.0]); + let motion_orig_proj = tangent_orig.proj(&motion_orig.as_view(), 0); + + // project the equivalent nudge to the tangent space of the solution + // variety at the transformed solution + let motion_tfm = DVector::from_column_slice(&[FRAC_1_SQRT_2, 0.0, FRAC_1_SQRT_2, 0.0]); + let motion_tfm_proj = tangent_tfm.proj(&motion_tfm.as_view(), 0); + + // take the transformation that sends the original solution to the + // transformed solution and apply it to the motion that the original + // solution makes in response to the nudge + const ELEMENT_DIM: usize = 5; + let rot = DMatrix::from_column_slice(ELEMENT_DIM, ELEMENT_DIM, &[ + FRAC_1_SQRT_2, 0.0, -FRAC_1_SQRT_2, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, 0.0, + FRAC_1_SQRT_2, 0.0, FRAC_1_SQRT_2, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 1.0 + ]); + let transl = translation(Vector3::new(0.0, 0.0, 7.0)); + let motion_proj_tfm = transl * rot * motion_orig_proj; + + // confirm that the projection of the nudge is equivariant. we loosen + // the comparison tolerance because the transformation seems to + // introduce some numerical error + const SCALED_TOL_TFM: f64 = 1.0e-9; + let tol_sq = ((problem_orig.guess.nrows() * problem_orig.guess.ncols()) as f64) * SCALED_TOL_TFM * SCALED_TOL_TFM; + assert!((motion_proj_tfm - motion_tfm_proj).norm_squared() < tol_sq); + } +} \ No newline at end of file diff --git a/app-proto/src/lib.rs b/app-proto/src/lib.rs new file mode 100644 index 0000000..0d9bc4a --- /dev/null +++ b/app-proto/src/lib.rs @@ -0,0 +1 @@ +pub mod engine; \ No newline at end of file diff --git a/app-proto/src/main.rs b/app-proto/src/main.rs new file mode 100644 index 0000000..152d11c --- /dev/null +++ b/app-proto/src/main.rs @@ -0,0 +1,69 @@ +mod assembly; +mod components; +mod engine; +mod specified; + +#[cfg(test)] +mod tests; + +use std::{collections::BTreeSet, rc::Rc}; +use sycamore::prelude::*; + +use assembly::{Assembly, Element}; +use components::{ + add_remove::AddRemove, + diagnostics::Diagnostics, + display::Display, + outline::Outline +}; + +#[derive(Clone)] +struct AppState { + assembly: Assembly, + selection: Signal>> +} + +impl AppState { + fn new() -> AppState { + AppState { + assembly: Assembly::new(), + selection: create_signal(BTreeSet::default()) + } + } + + // in single-selection mode, select the given element. in multiple-selection + // mode, toggle whether the given element is selected + fn select(&self, element: &Rc, multi: bool) { + if multi { + self.selection.update(|sel| { + if !sel.remove(element) { + sel.insert(element.clone()); + } + }); + } else { + self.selection.update(|sel| { + sel.clear(); + sel.insert(element.clone()); + }); + } + } +} + +fn main() { + // set the console error panic hook + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); + + sycamore::render(|| { + provide_context(AppState::new()); + + view! { + div(id="sidebar") { + AddRemove {} + Outline {} + Diagnostics {} + } + Display {} + } + }); +} \ No newline at end of file diff --git a/app-proto/src/specified.rs b/app-proto/src/specified.rs new file mode 100644 index 0000000..cfe7fc3 --- /dev/null +++ b/app-proto/src/specified.rs @@ -0,0 +1,44 @@ +use std::num::ParseFloatError; + +// a real number described by a specification string. since the structure is +// read-only, we can guarantee that `spec` always specifies `value` in the +// following format +// ┌──────────────────────────────────────────────────────┬───────────┐ +// │ `spec` │ `value` │ +// ┝━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━┥ +// │ a string that parses to the floating-point value `x` │ `Some(x)` │ +// ├──────────────────────────────────────────────────────┼───────────┤ +// │ the empty string │ `None` │ +// └──────────────────────────────────────────────────────┴───────────┘ +#[readonly::make] +pub struct SpecifiedValue { + pub spec: String, + pub value: Option +} + +impl SpecifiedValue { + pub fn from_empty_spec() -> SpecifiedValue { + SpecifiedValue { spec: String::new(), value: None } + } + + pub fn is_present(&self) -> bool { + matches!(self.value, Some(_)) + } +} + +// a `SpecifiedValue` can be constructed from a specification string, formatted +// as described in the comment on the structure definition. the result is `Ok` +// if the specification is properly formatted, and `Error` if not +impl TryFrom for SpecifiedValue { + type Error = ParseFloatError; + + fn try_from(spec: String) -> Result { + if spec.is_empty() { + Ok(SpecifiedValue::from_empty_spec()) + } else { + spec.parse::().map( + |value| SpecifiedValue { spec: spec, value: Some(value) } + ) + } + } +} \ No newline at end of file diff --git a/app-proto/src/tests.rs b/app-proto/src/tests.rs new file mode 100644 index 0000000..2c5436b --- /dev/null +++ b/app-proto/src/tests.rs @@ -0,0 +1,14 @@ +use std::process::Command; + +// build and bundle the application, reporting success if there are no errors or +// warnings. to see this test fail while others succeed, try moving `index.html` +// or one of the assets that it links to +#[test] +fn trunk_build_test() { + let build_status = Command::new("trunk") + .arg("build") + .env("RUSTFLAGS", "-D warnings") + .status() + .expect("Call to Trunk failed"); + assert!(build_status.success()); +} \ No newline at end of file diff --git a/engine-proto/alg-test/ConstructionViewer.jl b/engine-proto/alg-test/ConstructionViewer.jl new file mode 100644 index 0000000..b9c8ffb --- /dev/null +++ b/engine-proto/alg-test/ConstructionViewer.jl @@ -0,0 +1,223 @@ +module Viewer + +using Blink +using Colors +using Printf + +using Main.Engine + +export ConstructionViewer, display!, opentools!, closetools! + +# === Blink utilities === + +append_to_head!(w, type, content) = @js w begin + @var element = document.createElement($type) + element.appendChild(document.createTextNode($content)) + document.head.appendChild(element) +end + +style!(w, stylesheet) = append_to_head!(w, "style", stylesheet) + +script!(w, code) = append_to_head!(w, "script", code) + +# === construction viewer === + +mutable struct ConstructionViewer + win::Window + + function ConstructionViewer() + # create window and open developer console + win = Window(Blink.Dict(:width => 620, :height => 830)) + + # set stylesheet + style!(win, """ + body { + background-color: #ccc; + } + + /* the maximum dimensions keep Ganja from blowing up the canvas */ + #view { + display: block; + width: 600px; + height: 600px; + margin-top: 10px; + margin-left: 10px; + border-radius: 10px; + background-color: #f0f0f0; + } + + #control-panel { + width: 600px; + height: 200px; + box-sizing: border-box; + padding: 5px 10px 5px 10px; + margin-top: 10px; + margin-left: 10px; + overflow-y: scroll; + border-radius: 10px; + background-color: #f0f0f0; + } + + #control-panel > div { + margin-top: 5px; + padding: 4px; + border-radius: 5px; + border: solid; + font-family: monospace; + } + """) + + # load Ganja.js. for an automatically updated web-hosted version, load from + # + # https://unpkg.com/ganja.js + # + # instead + loadjs!(win, "http://localhost:8000/ganja-1.0.204.js") + + # create global functions and variables + script!(win, """ + // create algebra + var CGA3 = Algebra(4, 1); + + // initialize element list and palette + var elements = []; + var palette = []; + + // declare handles for the view and its options + var view; + var viewOpt; + + // declare handles for the controls + var controlPanel; + var visToggles; + + // create scene function + function scene() { + commands = []; + for (let n = 0; n < elements.length; ++n) { + if (visToggles[n].checked) { + commands.push(palette[n], elements[n]); + } + } + return commands; + } + + function updateView() { + requestAnimationFrame(view.update.bind(view, scene)); + } + """) + + @js win begin + # create view + viewOpt = Dict( + :conformal => true, + :gl => true, + :devicePixelRatio => window.devicePixelRatio + ) + view = CGA3.graph(scene, viewOpt) + view.setAttribute(:id, "view") + view.removeAttribute(:style) + document.body.replaceChildren(view) + + # create control panel + controlPanel = document.createElement(:div) + controlPanel.setAttribute(:id, "control-panel") + document.body.appendChild(controlPanel) + end + + new(win) + end +end + +mprod(v, w) = + v[1]*w[1] + v[2]*w[2] + v[3]*w[3] + v[4]*w[4] - v[5]*w[5] + +function display!(viewer::ConstructionViewer, elements::Matrix) + # load elements + elements_full = [] + for elt in eachcol(Engine.unmix * elements) + if mprod(elt, elt) < 0.5 + elt_full = [0; elt; fill(0, 26)] + else + # `elt` is a spacelike vector, representing a generalized sphere, so we + # take its Hodge dual before passing it to Ganja.js. the dual represents + # the same generalized sphere, but Ganja.js only displays planes when + # they're represented by vectors in grade 4 rather than grade 1 + elt_full = [fill(0, 26); -elt[5]; -elt[4]; elt[3]; -elt[2]; elt[1]; 0] + end + push!(elements_full, elt_full) + end + @js viewer.win elements = $elements_full.map((elt) -> @new CGA3(elt)) + + # generate palette. this is Gadfly's `default_discrete_colors` palette, + # available under the MIT license + palette = distinguishable_colors( + length(elements_full), + [LCHab(70, 60, 240)], + transform = c -> deuteranopic(c, 0.5), + lchoices = Float64[65, 70, 75, 80], + cchoices = Float64[0, 50, 60, 70], + hchoices = range(0, stop=330, length=24) + ) + palette_packed = [RGB24(c).color for c in palette] + @js viewer.win palette = $palette_packed + + # create visibility toggles + @js viewer.win begin + controlPanel.replaceChildren() + visToggles = [] + end + for (elt, c) in zip(eachcol(elements), palette) + vec_str = join(map(t -> @sprintf("%.3f", t), elt), ", ") + color_str = "#$(hex(c))" + style_str = "background-color: $color_str; border-color: $color_str;" + @js viewer.win begin + @var toggle = document.createElement(:div) + toggle.setAttribute(:style, $style_str) + toggle.checked = true + toggle.addEventListener( + "click", + () -> begin + toggle.checked = !toggle.checked + toggle.style.backgroundColor = toggle.checked ? $color_str : "inherit"; + updateView() + end + ) + toggle.appendChild(document.createTextNode($vec_str)) + visToggles.push(toggle); + controlPanel.appendChild(toggle); + end + end + + # update view + @js viewer.win updateView() +end + +function opentools!(viewer::ConstructionViewer) + size(viewer.win, 1240, 830) + opentools(viewer.win) +end + +function closetools!(viewer::ConstructionViewer) + closetools(viewer.win) + size(viewer.win, 620, 830) +end + +end + +# ~~~ sandbox setup ~~~ + +elements = let + a = sqrt(BigFloat(3)/2) + sqrt(0.5) * BigFloat[ + 1 1 -1 -1 0 + 1 -1 1 -1 0 + 1 -1 -1 1 0 + 0.5 0.5 0.5 0.5 1+a + 0.5 0.5 0.5 0.5 1-a + ] +end + +# show construction +viewer = Viewer.ConstructionViewer() +Viewer.display!(viewer, elements) \ No newline at end of file diff --git a/engine-proto/alg-test/Engine.Algebraic.jl b/engine-proto/alg-test/Engine.Algebraic.jl new file mode 100644 index 0000000..a9b6667 --- /dev/null +++ b/engine-proto/alg-test/Engine.Algebraic.jl @@ -0,0 +1,203 @@ +module Algebraic + +export + codimension, dimension, + Construction, realize, + Element, Point, Sphere, + Relation, LiesOn, AlignsWithBy, mprod + +import Subscripts +using LinearAlgebra +using AbstractAlgebra +using Groebner +using ...HittingSet + +# --- commutative algebra --- + +# as of version 0.36.6, AbstractAlgebra only supports ideals in multivariate +# polynomial rings when the coefficients are integers. we use Groebner to extend +# support to rationals and to finite fields of prime order +Generic.reduce_gens(I::Generic.Ideal{U}) where {T <: FieldElement, U <: MPolyRingElem{T}} = + Generic.Ideal{U}(base_ring(I), groebner(gens(I))) + +function codimension(I::Generic.Ideal{U}, maxdepth = Inf) where {T <: RingElement, U <: MPolyRingElem{T}} + leading = [exponent_vector(f, 1) for f in gens(I)] + targets = [Set(findall(.!iszero.(exp_vec))) for exp_vec in leading] + length(HittingSet.solve(HittingSetProblem(targets), maxdepth)) +end + +dimension(I::Generic.Ideal{U}, maxdepth = Inf) where {T <: RingElement, U <: MPolyRingElem{T}} = + length(gens(base_ring(I))) - codimension(I, maxdepth) + +# --- primitve elements --- + +abstract type Element{T} end + +mutable struct Point{T} <: Element{T} + coords::Vector{MPolyRingElem{T}} + vec::Union{Vector{MPolyRingElem{T}}, Nothing} + rel::Nothing + + ## [to do] constructor argument never needed? + Point{T}( + coords::Vector{MPolyRingElem{T}} = MPolyRingElem{T}[], + vec::Union{Vector{MPolyRingElem{T}}, Nothing} = nothing + ) where T = new(coords, vec, nothing) +end + +function buildvec!(pt::Point) + coordring = parent(pt.coords[1]) + pt.vec = [one(coordring), dot(pt.coords, pt.coords), pt.coords...] +end + +mutable struct Sphere{T} <: Element{T} + coords::Vector{MPolyRingElem{T}} + vec::Union{Vector{MPolyRingElem{T}}, Nothing} + rel::Union{MPolyRingElem{T}, Nothing} + + ## [to do] constructor argument never needed? + Sphere{T}( + coords::Vector{MPolyRingElem{T}} = MPolyRingElem{T}[], + vec::Union{Vector{MPolyRingElem{T}}, Nothing} = nothing, + rel::Union{MPolyRingElem{T}, Nothing} = nothing + ) where T = new(coords, vec, rel) +end + +function buildvec!(sph::Sphere) + coordring = parent(sph.coords[1]) + sph.vec = sph.coords + sph.rel = mprod(sph.coords, sph.coords) + one(coordring) +end + +const coordnames = IdDict{Symbol, Vector{Union{Symbol, Nothing}}}( + nameof(Point) => [nothing, nothing, :xₚ, :yₚ, :zₚ], + nameof(Sphere) => [:rₛ, :sₛ, :xₛ, :yₛ, :zₛ] +) + +coordname(elt::Element, index) = coordnames[nameof(typeof(elt))][index] + +function pushcoordname!(coordnamelist, indexed_elt::Tuple{Any, Element}, coordindex) + eltindex, elt = indexed_elt + name = coordname(elt, coordindex) + if !isnothing(name) + subscript = Subscripts.sub(string(eltindex)) + push!(coordnamelist, Symbol(name, subscript)) + end +end + +function takecoord!(coordlist, indexed_elt::Tuple{Any, Element}, coordindex) + elt = indexed_elt[2] + if !isnothing(coordname(elt, coordindex)) + push!(elt.coords, popfirst!(coordlist)) + end +end + +# --- primitive relations --- + +abstract type Relation{T} end + +mprod(v, w) = (v[1]*w[2] + w[1]*v[2]) / 2 - dot(v[3:end], w[3:end]) + +# elements: point, sphere +struct LiesOn{T} <: Relation{T} + elements::Vector{Element{T}} + + LiesOn{T}(pt::Point{T}, sph::Sphere{T}) where T = new{T}([pt, sph]) +end + +equation(rel::LiesOn) = mprod(rel.elements[1].vec, rel.elements[2].vec) + +# elements: sphere, sphere +struct AlignsWithBy{T} <: Relation{T} + elements::Vector{Element{T}} + cos_angle::T + + AlignsWithBy{T}(sph1::Sphere{T}, sph2::Sphere{T}, cos_angle::T) where T = new{T}([sph1, sph2], cos_angle) +end + +equation(rel::AlignsWithBy) = mprod(rel.elements[1].vec, rel.elements[2].vec) - rel.cos_angle + +# --- constructions --- + +mutable struct Construction{T} + points::Vector{Point{T}} + spheres::Vector{Sphere{T}} + relations::Vector{Relation{T}} + + function Construction{T}(; elements = Vector{Element{T}}(), relations = Vector{Relation{T}}()) where T + allelements = union(elements, (rel.elements for rel in relations)...) + new{T}( + filter(elt -> isa(elt, Point), allelements), + filter(elt -> isa(elt, Sphere), allelements), + relations + ) + end +end + +function Base.push!(ctx::Construction{T}, elt::Point{T}) where T + push!(ctx.points, elt) +end + +function Base.push!(ctx::Construction{T}, elt::Sphere{T}) where T + push!(ctx.spheres, elt) +end + +function Base.push!(ctx::Construction{T}, rel::Relation{T}) where T + push!(ctx.relations, rel) + for elt in rel.elements + push!(ctx, elt) + end +end + +function realize(ctx::Construction{T}) where T + # collect coordinate names + coordnamelist = Symbol[] + eltenum = enumerate(Iterators.flatten((ctx.spheres, ctx.points))) + for coordindex in 1:5 + for indexed_elt in eltenum + pushcoordname!(coordnamelist, indexed_elt, coordindex) + end + end + + # construct coordinate ring + coordring, coordqueue = polynomial_ring(parent_type(T)(), coordnamelist, ordering = :degrevlex) + + # retrieve coordinates + for (_, elt) in eltenum + empty!(elt.coords) + end + for coordindex in 1:5 + for indexed_elt in eltenum + takecoord!(coordqueue, indexed_elt, coordindex) + end + end + + # construct coordinate vectors + for (_, elt) in eltenum + buildvec!(elt) + end + + # turn relations into equations + eqns = vcat( + equation.(ctx.relations), + [elt.rel for (_, elt) in eltenum if !isnothing(elt.rel)] + ) + + # add relations to center, orient, and scale the construction + # [to do] the scaling constraint, as written, can be impossible to satisfy + # when all of the spheres have to go through the origin + if !isempty(ctx.points) + append!(eqns, [sum(pt.coords[k] for pt in ctx.points) for k in 1:3]) + end + if !isempty(ctx.spheres) + append!(eqns, [sum(sph.coords[k] for sph in ctx.spheres) for k in 3:4]) + end + n_elts = length(ctx.points) + length(ctx.spheres) + if n_elts > 0 + push!(eqns, sum(elt.vec[2] for elt in Iterators.flatten((ctx.points, ctx.spheres))) - n_elts) + end + + (Generic.Ideal(coordring, eqns), eqns) +end + +end \ No newline at end of file diff --git a/engine-proto/alg-test/Engine.Numerical.jl b/engine-proto/alg-test/Engine.Numerical.jl new file mode 100644 index 0000000..d1e14bd --- /dev/null +++ b/engine-proto/alg-test/Engine.Numerical.jl @@ -0,0 +1,53 @@ +module Numerical + +using Random: default_rng +using LinearAlgebra +using AbstractAlgebra +using HomotopyContinuation: + Variable, Expression, AbstractSystem, System, LinearSubspace, + nvariables, isreal, witness_set, results +import GLMakie +using ..Algebraic + +# --- polynomial conversion --- + +# hat tip Sascha Timme +# https://github.com/JuliaHomotopyContinuation/HomotopyContinuation.jl/issues/520#issuecomment-1317681521 +function Base.convert(::Type{Expression}, f::MPolyRingElem) + variables = Variable.(symbols(parent(f))) + f_data = zip(coefficients(f), exponent_vectors(f)) + sum(cf * prod(variables .^ exp_vec) for (cf, exp_vec) in f_data) +end + +# create a ModelKit.System from an ideal in a multivariate polynomial ring. the +# variable ordering is taken from the polynomial ring +function System(I::Generic.Ideal) + eqns = Expression.(gens(I)) + variables = Variable.(symbols(base_ring(I))) + System(eqns, variables = variables) +end + +# --- sampling --- + +function real_samples(F::AbstractSystem, dim; rng = default_rng()) + # choose a random real hyperplane of codimension `dim` by intersecting + # hyperplanes whose normal vectors are uniformly distributed over the unit + # sphere + # [to do] guard against the unlikely event that one of the normals is zero + normals = transpose(hcat( + (normalize(randn(rng, nvariables(F))) for _ in 1:dim)... + )) + cut = LinearSubspace(normals, fill(0., dim)) + filter(isreal, results(witness_set(F, cut, seed = 0x1974abba))) +end + +AbstractAlgebra.evaluate(pt::Point, vals::Vector{<:RingElement}) = + GLMakie.Point3f([evaluate(u, vals) for u in pt.coords]) + +function AbstractAlgebra.evaluate(sph::Sphere, vals::Vector{<:RingElement}) + radius = 1 / evaluate(sph.coords[1], vals) + center = radius * [evaluate(u, vals) for u in sph.coords[3:end]] + GLMakie.Sphere(GLMakie.Point3f(center), radius) +end + +end \ No newline at end of file diff --git a/engine-proto/alg-test/Engine.jl b/engine-proto/alg-test/Engine.jl new file mode 100644 index 0000000..f6f92c5 --- /dev/null +++ b/engine-proto/alg-test/Engine.jl @@ -0,0 +1,76 @@ +include("HittingSet.jl") + +module Engine + +include("Engine.Algebraic.jl") +include("Engine.Numerical.jl") + +using .Algebraic +using .Numerical + +export Construction, mprod, codimension, dimension + +end + +# ~~~ sandbox setup ~~~ + +using Random +using Distributions +using LinearAlgebra +using AbstractAlgebra +using HomotopyContinuation +using GLMakie + +CoeffType = Rational{Int64} + +spheres = [Engine.Sphere{CoeffType}() for _ in 1:3] +tangencies = [ + Engine.AlignsWithBy{CoeffType}( + spheres[n], + spheres[mod1(n+1, length(spheres))], + CoeffType(1) + ) + for n in 1:3 +] +ctx_tan_sph = Engine.Construction{CoeffType}(elements = spheres, relations = tangencies) +ideal_tan_sph, eqns_tan_sph = Engine.realize(ctx_tan_sph) +freedom = Engine.dimension(ideal_tan_sph) +println("Three mutually tangent spheres: $freedom degrees of freedom") + +# --- test rational cut --- + +coordring = base_ring(ideal_tan_sph) +vbls = Variable.(symbols(coordring)) + +# test a random witness set +system = CompiledSystem(System(eqns_tan_sph, variables = vbls)) +norm2 = vec -> real(dot(conj.(vec), vec)) +rng = MersenneTwister(6071) +n_planes = 6 +samples = [] +for _ in 1:n_planes + real_solns = solution.(Engine.Numerical.real_samples(system, freedom, rng = rng)) + for soln in real_solns + if all(norm2(soln - samp) > 1e-4*length(gens(coordring)) for samp in samples) + push!(samples, soln) + end + end +end +println("Found $(length(samples)) sample solutions") + +# show a sample solution +function show_solution(ctx, vals) + # evaluate elements + real_vals = real.(vals) + disp_points = [Engine.Numerical.evaluate(pt, real_vals) for pt in ctx.points] + disp_spheres = [Engine.Numerical.evaluate(sph, real_vals) for sph in ctx.spheres] + + # create scene + scene = Scene() + cam3d!(scene) + scatter!(scene, disp_points, color = :green) + for sph in disp_spheres + mesh!(scene, sph, color = :gray) + end + scene +end \ No newline at end of file diff --git a/engine-proto/alg-test/HittingSet.jl b/engine-proto/alg-test/HittingSet.jl new file mode 100644 index 0000000..347c4d2 --- /dev/null +++ b/engine-proto/alg-test/HittingSet.jl @@ -0,0 +1,111 @@ +module HittingSet + +export HittingSetProblem, solve + +HittingSetProblem{T} = Pair{Set{T}, Vector{Pair{T, Set{Set{T}}}}} + +# `targets` should be a collection of Set objects +function HittingSetProblem(targets, chosen = Set()) + wholeset = union(targets...) + T = eltype(wholeset) + unsorted_moves = [ + elt => Set(filter(s -> elt ∉ s, targets)) + for elt in wholeset + ] + moves = sort(unsorted_moves, by = pair -> length(pair.second)) + Set{T}(chosen) => moves +end + +function Base.display(problem::HittingSetProblem{T}) where T + println("HittingSetProblem{$T}") + + chosen = problem.first + println(" {", join(string.(chosen), ", "), "}") + + moves = problem.second + for (choice, missed) in moves + println(" | ", choice) + for s in missed + println(" | | {", join(string.(s), ", "), "}") + end + end + println() +end + +function solve(pblm::HittingSetProblem{T}, maxdepth = Inf) where T + problems = Dict(pblm) + while length(first(problems).first) < maxdepth + subproblems = typeof(problems)() + for (chosen, moves) in problems + if isempty(moves) + return chosen + else + for (choice, missed) in moves + to_be_chosen = union(chosen, Set([choice])) + if isempty(missed) + return to_be_chosen + elseif !haskey(subproblems, to_be_chosen) + push!(subproblems, HittingSetProblem(missed, to_be_chosen)) + end + end + end + end + problems = subproblems + end + problems +end + +function test(n = 1) + T = [Int64, Int64, Symbol, Symbol][n] + targets = Set{T}.([ + [ + [1, 3, 5], + [2, 3, 4], + [1, 4], + [2, 3, 4, 5], + [4, 5] + ], + # example from Amit Chakrabarti's graduate-level algorithms class (CS 105) + # notes by Valika K. Wan and Khanh Do Ba, Winter 2005 + # https://www.cs.dartmouth.edu/~ac/Teach/CS105-Winter05/ + [ + [1, 3], [1, 4], [1, 5], + [1, 3], [1, 2, 4], [1, 2, 5], + [4, 3], [ 2, 4], [ 2, 5], + [6, 3], [6, 4], [ 5] + ], + [ + [:w, :x, :y], + [:x, :y, :z], + [:w, :z], + [:x, :y] + ], + # Wikipedia showcases this as an example of a problem where the greedy + # algorithm performs especially poorly + [ + [:a, :x, :t1], + [:a, :y, :t2], + [:a, :y, :t3], + [:a, :z, :t4], + [:a, :z, :t5], + [:a, :z, :t6], + [:a, :z, :t7], + [:b, :x, :t8], + [:b, :y, :t9], + [:b, :y, :t10], + [:b, :z, :t11], + [:b, :z, :t12], + [:b, :z, :t13], + [:b, :z, :t14] + ] + ][n]) + problem = HittingSetProblem(targets) + if isa(problem, HittingSetProblem{T}) + println("Correct type") + else + println("Wrong type: ", typeof(problem)) + end + problem +end + +end \ No newline at end of file diff --git a/engine-proto/ganja-test/ganja-test.html b/engine-proto/ganja-test/ganja-test.html new file mode 100644 index 0000000..0207dcc --- /dev/null +++ b/engine-proto/ganja-test/ganja-test.html @@ -0,0 +1,96 @@ + + + + + + + +

+ + + diff --git a/engine-proto/ganja-test/ganja-test.jl b/engine-proto/ganja-test/ganja-test.jl new file mode 100644 index 0000000..6c55061 --- /dev/null +++ b/engine-proto/ganja-test/ganja-test.jl @@ -0,0 +1,127 @@ +using Blink +using Colors + +# === utilities === + +append_to_head!(w, type, content) = @js w begin + @var element = document.createElement($type) + element.appendChild(document.createTextNode($content)) + document.head.appendChild(element) +end + +style!(w, stylesheet) = append_to_head!(w, "style", stylesheet) + +script!(w, code) = append_to_head!(w, "script", code) + +function add_element!(vec) + # add element + full_vec = [0; vec; fill(0, 26)] + n = @js win elements.push(@new CGA3($full_vec)) + + # generate palette. this is Gadfly's `default_discrete_colors` palette, + # available under the MIT license + palette = distinguishable_colors( + n, + [LCHab(70, 60, 240)], + transform = c -> deuteranopic(c, 0.5), + lchoices = Float64[65, 70, 75, 80], + cchoices = Float64[0, 50, 60, 70], + hchoices = range(0, stop=330, length=24) + ) + palette_packed = [RGB24(c).color for c in palette] + @js win palette = $palette_packed +end + +# === build page === + +# create window and open developer console +win = Window() +opentools(win) + +# set stylesheet +style!(win, """ + body { + background-color: #ffe0f0; + } + + /* needed to keep Ganja canvas from blowing up */ + canvas { + min-width: 600px; + max-width: 600px; + min-height: 600px; + max-height: 600px; + } +""") + +# load Ganja.js +loadjs!(win, "https://unpkg.com/ganja.js") + +# create global functions and variables +script!(win, """ + // create algebra + var CGA3 = Algebra(4, 1); + + // initialize element list and palette + var elements = []; + var palette = []; + + // declare visualization handle + var graph; + + // create scene function + function scene() { + commands = []; + for (let n = 0; n < elements.length; ++n) { + commands.push(palette[n], elements[n]); + } + return commands; + } + + function flip() { + let last = elements.length - 1; + for (let n = 0; n < last; ++n) { + // reflect + elements[n] = CGA3.Mul(CGA3.Mul(elements[last], elements[n]), elements[last]); + + // de-noise + for (let k = 6; k < elements[n].length; ++k) { + elements[n][k] = 0; + } + } + requestAnimationFrame(graph.update.bind(graph, scene)); + } +""") + +# set up controls +body!(win, """ +

+""", async = false) + +# === set up visualization === + +# list elements. in the default view, e4 + e5 is the point at infinity +elements = sqrt(0.5) * BigFloat[ + 1 1 -1 -1 0; + 1 -1 1 -1 0; + 1 -1 -1 1 0; + 0 0 0 0 -sqrt(6); + 1 1 1 1 2 +] + +# load elements +for vec in eachcol(elements) + add_element!(vec) +end + +# initialize visualization +@js win begin + graph = CGA3.graph( + scene, + Dict( + "conformal" => true, + "gl" => true, + "grid" => true + ) + ) + document.body.appendChild(graph) +end \ No newline at end of file diff --git a/engine-proto/gram-test/Engine.jl b/engine-proto/gram-test/Engine.jl new file mode 100644 index 0000000..6dfb6e9 --- /dev/null +++ b/engine-proto/gram-test/Engine.jl @@ -0,0 +1,573 @@ +module Engine + +using LinearAlgebra +using GenericLinearAlgebra +using SparseArrays +using Random +using Optim + +export + rand_on_shell, Q, DescentHistory, + realize_gram_gradient, realize_gram_newton, realize_gram_optim, + realize_gram_alt_proj, realize_gram + +# === guessing === + +sconh(t, u) = 0.5*(exp(t) + u*exp(-t)) + +function rand_on_sphere(rng::AbstractRNG, ::Type{T}, n) where T + out = randn(rng, T, n) + tries_left = 2 + while dot(out, out) < 1e-6 && tries_left > 0 + out = randn(rng, T, n) + tries_left -= 1 + end + normalize(out) +end + +##[TO DO] write a test to confirm that the outputs are on the correct shells +function rand_on_shell(rng::AbstractRNG, shell::T) where T <: Number + space_part = rand_on_sphere(rng, T, 4) + rapidity = randn(rng, T) + sig = sign(shell) + nullmix * [sconh(rapidity, sig)*space_part; sconh(rapidity, -sig)] +end + +rand_on_shell(rng::AbstractRNG, shells::Array{T}) where T <: Number = + hcat([rand_on_shell(rng, sh) for sh in shells]...) + +rand_on_shell(shells::Array{<:Number}) = rand_on_shell(Random.default_rng(), shells) + +# === elements === + +point(pos) = [pos; 0.5; 0.5 * dot(pos, pos)] + +plane(normal, offset) = [-normal; 0; -offset] + +function sphere(center, radius) + dist_sq = dot(center, center) + [ + center / radius; + 0.5 / radius; + 0.5 * (dist_sq / radius - radius) + ] +end + +# === Gram matrix realization === + +# basis changes +nullmix = [Matrix{Int64}(I, 3, 3) zeros(Int64, 3, 2); zeros(Int64, 2, 3) [-1 1; 1 1]//2] +unmix = [Matrix{Int64}(I, 3, 3) zeros(Int64, 3, 2); zeros(Int64, 2, 3) [-1 1; 1 1]] + +# the Lorentz form +Q = [Matrix{Int64}(I, 3, 3) zeros(Int64, 3, 2); zeros(Int64, 2, 3) [0 -2; -2 0]] + +# project a matrix onto the subspace of matrices whose entries vanish away from +# the given indices +function proj_to_entries(mat, indices) + result = zeros(size(mat)) + for (j, k) in indices + result[j, k] = mat[j, k] + end + result +end + +# the difference between the matrices `target` and `attempt`, projected onto the +# subspace of matrices whose entries vanish at each empty index of `target` +function proj_diff(target::SparseMatrixCSC{T, <:Any}, attempt::Matrix{T}) where T + J, K, values = findnz(target) + result = zeros(size(target)) + for (j, k, val) in zip(J, K, values) + result[j, k] = val - attempt[j, k] + end + result +end + +# a type for keeping track of gradient descent history +struct DescentHistory{T} + scaled_loss::Array{T} + neg_grad::Array{Matrix{T}} + base_step::Array{Matrix{T}} + hess::Array{Hermitian{T, Matrix{T}}} + slope::Array{T} + stepsize::Array{T} + positive::Array{Bool} + backoff_steps::Array{Int64} + last_line_L::Array{Matrix{T}} + last_line_loss::Array{T} + + function DescentHistory{T}( + scaled_loss = Array{T}(undef, 0), + neg_grad = Array{Matrix{T}}(undef, 0), + hess = Array{Hermitian{T, Matrix{T}}}(undef, 0), + base_step = Array{Matrix{T}}(undef, 0), + slope = Array{T}(undef, 0), + stepsize = Array{T}(undef, 0), + positive = Bool[], + backoff_steps = Int64[], + last_line_L = Array{Matrix{T}}(undef, 0), + last_line_loss = Array{T}(undef, 0) + ) where T + new(scaled_loss, neg_grad, hess, base_step, slope, stepsize, positive, backoff_steps, last_line_L, last_line_loss) + 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` +function realize_gram_gradient( + gram::SparseMatrixCSC{T, <:Any}, + guess::Matrix{T}; + scaled_tol = 1e-30, + min_efficiency = 0.5, + init_stepsize = 1.0, + backoff = 0.9, + max_descent_steps = 600, + max_backoff_steps = 110 +) where T <: Number + # start history + history = DescentHistory{T}() + + # scale tolerance + scale_adjustment = sqrt(T(nnz(gram))) + tol = scale_adjustment * scaled_tol + + # initialize variables + stepsize = init_stepsize + L = copy(guess) + + # do gradient descent + Δ_proj = proj_diff(gram, L'*Q*L) + loss = dot(Δ_proj, Δ_proj) + for _ 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 + slope = norm(neg_grad) + dir = neg_grad / slope + + # store 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, slope) + + # 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) + for backoff_steps in 0:max_backoff_steps + history.stepsize[end] = stepsize + L = L_last + stepsize * dir + Δ_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 * stepsize * slope + history.backoff_steps[end] = backoff_steps + break + end + stepsize *= backoff + end + + # [DEBUG] if we've hit a wall, quit + if history.backoff_steps[end] == max_backoff_steps + break + end + end + + # return the factorization and its history + push!(history.scaled_loss, loss / scale_adjustment) + L, history +end + +function basis_matrix(::Type{T}, j, k, dims) where T + result = zeros(T, dims) + result[j, k] = one(T) + result +end + +# seek a matrix `L` for which `L'QL` matches the sparse matrix `gram` at every +# explicit entry of `gram`. use Newton's method starting from `guess` +function realize_gram_newton( + gram::SparseMatrixCSC{T, <:Any}, + guess::Matrix{T}; + scaled_tol = 1e-30, + rate = 1, + max_steps = 100 +) 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 + + # use Newton's method + L = copy(guess) + for step in 0:max_steps + # evaluate the loss function + Δ_proj = proj_diff(gram, L'*Q*L) + loss = dot(Δ_proj, Δ_proj) + + # store the current loss + push!(history.scaled_loss, loss / scale_adjustment) + + # stop if the loss is tolerably low + if loss < tol || step > max_steps + 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 = Hermitian(hess) + push!(history.hess, hess) + + # compute the Newton step + step = hess \ reshape(neg_grad, total_dim) + L += rate * reshape(step, dims) + end + + # return the factorization and its history + L, history +end + +LinearAlgebra.eigen!(A::Symmetric{BigFloat, Matrix{BigFloat}}; sortby::Nothing) = + eigen!(Hermitian(A)) + +function convertnz(type, mat) + J, K, values = findnz(mat) + sparse(J, K, type.(values)) +end + +function realize_gram_optim( + gram::SparseMatrixCSC{T, <:Any}, + guess::Matrix{T} +) where T <: Number + # 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 loss function + scale_adjustment = length(constrained) + + function loss(L_vec) + L = reshape(L_vec, dims) + Δ_proj = proj_diff(gram, L'*Q*L) + dot(Δ_proj, Δ_proj) / scale_adjustment + end + + function loss_grad!(storage, L_vec) + L = reshape(L_vec, dims) + Δ_proj = proj_diff(gram, L'*Q*L) + storage .= reshape(-4*Q*L*Δ_proj, total_dim) / scale_adjustment + end + + function loss_hess!(storage, L_vec) + L = reshape(L_vec, dims) + Δ_proj = proj_diff(gram, L'*Q*L) + 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) / scale_adjustment + storage[:, (k-1)*element_dim + j] = reshape(deriv_grad, total_dim) + end + end + + optimize( + loss, loss_grad!, loss_hess!, + reshape(guess, total_dim), + Newton() + ) +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 +# explicit entry of `gram`. use gradient descent starting from `guess` +function realize_gram( + gram::SparseMatrixCSC{T, <:Any}, + guess::Matrix{T}, + frozen = nothing; + 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 + + # list the un-frozen indices + has_frozen = !isnothing(frozen) + if has_frozen + is_unfrozen = fill(true, size(guess)) + is_unfrozen[frozen] .= false + unfrozen = findall(is_unfrozen) + unfrozen_stacked = reshape(is_unfrozen, total_dim) + end + + # 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 = Hermitian(hess) + push!(history.hess, hess) + + # regularize the Hessian + min_eigval = minimum(eigvals(hess)) + 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) + if has_frozen + hess = hess[unfrozen_stacked, unfrozen_stacked] + neg_grad_compressed = neg_grad_stacked[unfrozen_stacked] + else + neg_grad_compressed = neg_grad_stacked + end + base_step_compressed = hess \ neg_grad_compressed + if has_frozen + base_step_stacked = zeros(total_dim) + base_step_stacked[unfrozen_stacked] .= base_step_compressed + else + base_step_stacked = base_step_compressed + end + 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 + +end \ No newline at end of file diff --git a/engine-proto/gram-test/basin-shapes.jl b/engine-proto/gram-test/basin-shapes.jl new file mode 100644 index 0000000..5c03c01 --- /dev/null +++ b/engine-proto/gram-test/basin-shapes.jl @@ -0,0 +1,99 @@ +include("Engine.jl") + +using LinearAlgebra +using SparseArrays + +function sphere_in_tetrahedron_shape() + # initialize the partial gram matrix for a sphere inscribed in a regular + # tetrahedron + J = Int64[] + K = Int64[] + values = BigFloat[] + for j in 1:5 + for k in 1:5 + push!(J, j) + push!(K, k) + if j == k + push!(values, 1) + elseif (j <= 4 && k <= 4) + push!(values, -1/BigFloat(3)) + else + push!(values, -1) + end + end + end + gram = sparse(J, K, values) + + # plot loss along a slice + loss_lin = [] + loss_sq = [] + mesh = range(0.9, 1.1, 101) + for t in mesh + L = hcat( + Engine.plane(normalize(BigFloat[ 1, 1, 1]), BigFloat(1)), + Engine.plane(normalize(BigFloat[ 1, -1, -1]), BigFloat(1)), + Engine.plane(normalize(BigFloat[-1, 1, -1]), BigFloat(1)), + Engine.plane(normalize(BigFloat[-1, -1, 1]), BigFloat(1)), + Engine.sphere(BigFloat[0, 0, 0], BigFloat(t)) + ) + Δ_proj = Engine.proj_diff(gram, L'*Engine.Q*L) + push!(loss_lin, norm(Δ_proj)) + push!(loss_sq, dot(Δ_proj, Δ_proj)) + end + mesh, loss_lin, loss_sq +end + +function circles_in_triangle_shape() + # initialize the partial gram matrix for a sphere inscribed in a regular + # tetrahedron + J = Int64[] + K = Int64[] + values = BigFloat[] + for j in 1:8 + for k in 1:8 + filled = false + if j == k + push!(values, 1) + filled = true + elseif (j == 1 || k == 1) + push!(values, 0) + filled = true + elseif (j == 2 || k == 2) + push!(values, -1) + filled = true + end + #=elseif (j <= 5 && j != 2 && k == 9 || k == 9 && k <= 5 && k != 2) + push!(values, 0) + filled = true + end=# + if filled + push!(J, j) + push!(K, k) + end + end + end + append!(J, [6, 4, 6, 5, 7, 5, 7, 3, 8, 3, 8, 4]) + append!(K, [4, 6, 5, 6, 5, 7, 3, 7, 3, 8, 4, 8]) + append!(values, fill(-1, 12)) + + # plot loss along a slice + loss_lin = [] + loss_sq = [] + mesh = range(0.99, 1.01, 101) + for t in mesh + L = hcat( + Engine.plane(BigFloat[0, 0, 1], BigFloat(0)), + Engine.sphere(BigFloat[0, 0, 0], BigFloat(t)), + Engine.plane(BigFloat[1, 0, 0], BigFloat(1)), + Engine.plane(BigFloat[cos(2pi/3), sin(2pi/3), 0], BigFloat(1)), + Engine.plane(BigFloat[cos(-2pi/3), sin(-2pi/3), 0], BigFloat(1)), + Engine.sphere(4//3*BigFloat[-1, 0, 0], BigFloat(1//3)), + Engine.sphere(4//3*BigFloat[cos(-pi/3), sin(-pi/3), 0], BigFloat(1//3)), + Engine.sphere(4//3*BigFloat[cos(pi/3), sin(pi/3), 0], BigFloat(1//3)) + ) + Δ_proj = Engine.proj_diff(gram, L'*Engine.Q*L) + push!(loss_lin, norm(Δ_proj)) + push!(loss_sq, dot(Δ_proj, Δ_proj)) + end + mesh, loss_lin, loss_sq +end \ No newline at end of file diff --git a/engine-proto/gram-test/circles-in-triangle.jl b/engine-proto/gram-test/circles-in-triangle.jl new file mode 100644 index 0000000..1bd22a7 --- /dev/null +++ b/engine-proto/gram-test/circles-in-triangle.jl @@ -0,0 +1,76 @@ +include("Engine.jl") + +using SparseArrays +using Random + +# initialize the partial gram matrix for a sphere inscribed in a regular +# tetrahedron +J = Int64[] +K = Int64[] +values = BigFloat[] +for j in 1:9 + for k in 1:9 + filled = false + if j == 9 + if k <= 5 && k != 2 + push!(values, 0) + filled = true + end + elseif k == 9 + if j <= 5 && j != 2 + push!(values, 0) + filled = true + end + elseif j == k + push!(values, 1) + filled = true + elseif j == 1 || k == 1 + push!(values, 0) + filled = true + elseif j == 2 || k == 2 + push!(values, -1) + filled = true + end + if filled + push!(J, j) + push!(K, k) + end + end +end +append!(J, [6, 4, 6, 5, 7, 5, 7, 3, 8, 3, 8, 4]) +append!(K, [4, 6, 5, 6, 5, 7, 3, 7, 3, 8, 4, 8]) +append!(values, fill(-1, 12)) +#= make construction rigid +append!(J, [3, 4, 4, 5]) +append!(K, [4, 3, 5, 4]) +append!(values, fill(-0.5, 4)) +=# +gram = sparse(J, K, values) + +# set initial guess +Random.seed!(58271) +guess = hcat( + Engine.plane(BigFloat[0, 0, 1], BigFloat(0)), + Engine.sphere(BigFloat[0, 0, 0], BigFloat(1//2)) + 0.1*Engine.rand_on_shell([BigFloat(-1)]), + Engine.plane(-BigFloat[1, 0, 0], BigFloat(-1)) + 0.1*Engine.rand_on_shell([BigFloat(-1)]), + Engine.plane(-BigFloat[cos(2pi/3), sin(2pi/3), 0], BigFloat(-1)) + 0.1*Engine.rand_on_shell([BigFloat(-1)]), + Engine.plane(-BigFloat[cos(-2pi/3), sin(-2pi/3), 0], BigFloat(-1)) + 0.1*Engine.rand_on_shell([BigFloat(-1)]), + Engine.sphere(BigFloat[-1, 0, 0], BigFloat(1//5)) + 0.1*Engine.rand_on_shell([BigFloat(-1)]), + Engine.sphere(BigFloat[cos(-pi/3), sin(-pi/3), 0], BigFloat(1//5)) + 0.1*Engine.rand_on_shell([BigFloat(-1)]), + Engine.sphere(BigFloat[cos(pi/3), sin(pi/3), 0], BigFloat(1//5)) + 0.1*Engine.rand_on_shell([BigFloat(-1)]), + BigFloat[0, 0, 0, 0, 1] +) +frozen = [CartesianIndex(j, 9) for j in 1:5] + +# complete the gram matrix using Newton's method with backtracking +L, success, history = Engine.realize_gram(gram, guess, frozen) +completed_gram = L'*Engine.Q*L +println("Completed Gram matrix:\n") +display(completed_gram) +if success + println("\nTarget accuracy achieved!") +else + println("\nFailed to reach target accuracy") +end +println("Steps: ", size(history.scaled_loss, 1)) +println("Loss: ", history.scaled_loss[end], "\n") \ No newline at end of file diff --git a/engine-proto/gram-test/ganja-1.0.204.js b/engine-proto/gram-test/ganja-1.0.204.js new file mode 100644 index 0000000..1e95e42 --- /dev/null +++ b/engine-proto/gram-test/ganja-1.0.204.js @@ -0,0 +1,1913 @@ +/** Ganja.js - Geometric Algebra - Not Just Algebra. + * @author Enki + * @link https://github.com/enkimute/ganja.js + */ + +/*********************************************************************************************************************/ +// +// Ganja.js is an Algebra generator for javascript. It generates a wide variety of Algebra's and supports operator +// overloading, algebraic literals and a variety of graphing options. +// +// Ganja.js is designed with prototyping and educational purposes in mind. Clean mathematical syntax is the primary +// target. +// +// Ganja.js exports only one function called *Algebra*. This function is used to generate Algebra classes. (say complex +// numbers, minkowski or 3D CGA). The returned class can be used to create, add, multiply etc, but also to upgrade +// javascript functions with algebraic literals, operator overloading, vectors, matrices and much more. +// +// As a simple example, multiplying two complex numbers 3+2i and 1+4i could be done like this : +// +// var complex = Algebra(0,1); +// var a = new complex([3,2]); +// var b = new complex([1,3]); +// var result = a.Mul(b); +// +// But the same can be written using operator overloading and algebraic literals. (where scientific notation with +// lowercase e is overloaded to directly specify generators (e1, e2, e12, ...)) +// +// var result = Algebra(0,1,()=>(3+2e1)*(1+4e1)); +// +// Please see github for user documentation and examples. +// +/*********************************************************************************************************************/ + +// Documentation below is for implementors. I'll assume you know about Clifford Algebra's, grades, its products, etc .. +// I'll also assume you are familiar with ES6. My style may feel a bith mathematical, advise is to read slow. + +(function (name, context, definition) { + if (typeof module != 'undefined' && module.exports) module.exports = definition(); + else if (typeof define == 'function' && define.amd) define(name, definition); + else context[name] = definition(); +}('Algebra', this, function () { + +/** Some helpers for eigenvalues for bivector split in high-d spaces **/ + function QR(M) { + // helpers + const {abs,sqrt} = Math; + const hyp = (a,b)=>abs(a)>abs(b)?abs(a)*sqrt(1+(b/a)**2):b==0?0:abs(b)*sqrt(1+(a/b)**2); + const [m,n] = [M.length, M[0].length]; + var qr = M.map(r=>r.map(c=>c)), Q = M.map(r=>r.map(c=>0)), R = M.map(r=>r.map(c=>0)), d = [], k, i, j, nrm; + // helper matrix + for (k=0; k=0; --k) { + for (i=0; i{ + var res = A.map(r=>r.map(c=>0)); + for(let i=0;iA[i][i]); + } + +/** The Algebra class generator. Possible calling signatures : + * Algebra([func]) => algebra with no dimensions, i.e. R. Optional function for the translator. + * Algebra(p,[func]) => 'p' positive dimensions and an optional function to pass to the translator. + * Algebra(p,q,[func]) => 'p' positive and 'q' negative dimensions and optional function. + * Algebra(p,q,r,[func]) => 'p' positive, 'q' negative and 'r' zero dimensions and optional function. + * Algebra({ => for custom basis, cayley, mixing, etc pass in an object as first parameter. + * [p:p], => optional 'p' for # of positive dimensions + * [q:q], => optional 'q' for # of negative dimensions + * [r:r], => optional 'r' for # of zero dimensions + * [metric:array], => alternative for p,q,r. e.g. ([1,1,1,-1] for spacetime) + * [basis:array], => array of strings with basis names. (e.g. ['1','e1','e2','e12']) + * [Cayley:Cayley], => optional custom Cayley table (strings). (e.g. [['1','e1'],['e1','-1']]) + * [mix:boolean], => Allows mixing of various algebras. (for space efficiency). + * [graded:boolean], => Use a graded algebra implementation. (automatic for +6D) + * [baseType:Float32Array] => optional basetype to use. (only for flat generator) + * },[func]) => optional function for the translator. + **/ + return function Algebra(p,q,r) { + // Resolve possible calling signatures so we know the numbers for p,q,r. Last argument can always be a function. + var fu=arguments[arguments.length-1],options=p; if (options instanceof Object) { + q = (p.q || (p.metric && p.metric.filter(x=>x==-1).length))| 0; + r = (p.r || (p.metric && p.metric.filter(x=>x==0).length)) | 0; + p = p.p === undefined ? (p.metric && p.metric.filter(x=>x==1).length) || 0 : p.p || 0; + } else { options={}; p=p|0; r=r|0; q=q|0; }; + + // Support for multi-dual-algebras + if (options.dual || (p==0 && q==0 && r<0)) { r=options.dual=options.dual||-r; // Create a dual number algebra if r<0 (old) or options.dual set(new) + options.basis = [...Array(r+1)].map((a,i)=>i?'e0'+i:'1'); options.metric = [1,...Array(r)]; options.tot=r+1; + options.Cayley = [...Array(r+1)].map((a,i)=>[...Array(r+1)].map((y,j)=>i*j==0?((i+j)?'e0'+(i+j):'1'):'0')); + } + if (options.over) options.baseType = Array; + + // Calculate the total number of dimensions. + var tot = options.tot = (options.tot||(p||0)+(q||0)+(r||0)||(options.basis&&options.basis.length))|0; + + // Unless specified, generate a full set of Clifford basis names. We generate them as an array of strings by starting + // from numbers in binary representation and changing the set bits into their relative position. + // Basis names are ordered first per grade, then lexically (not cyclic!). + // For 10 or more dimensions all names will be double digits ! 1e01 instead of 1e1 .. + var basis=(options.basis&&(options.basis.length==2**tot||r<0||options.Cayley)&&options.basis)||[...Array(2**tot)] // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined] + .map((x,xi)=>(((1<<30)+xi).toString(2)).slice(-tot||-1) // => ["000", "001", "010", "011", "100", "101", "110", "111"] (index of array in base 2) + .replace(/./g,(a,ai)=>a=='0'?'':String.fromCharCode(66+ai-(r!=0)))) // => ["", "3", "2", "23", "1", "13", "12", "123"] (1 bits replaced with their positions, 0's removed) + .sort((a,b)=>(a.toString().length==b.toString().length)?(a>b?1:b>a?-1:0):a.toString().length-b.toString().length) // => ["", "1", "2", "3", "12", "13", "23", "123"] (sorted numerically) + .map(x=>x&&'e'+(x.replace(/./g,x=>('0'+(x.charCodeAt(0)-65)).slice(tot>9?-2:-1) ))||'1') // => ["1", "e1", "e2", "e3", "e12", "e13", "e23", "e123"] (converted to commonly used basis names) + + // See if the basis names start from 0 or 1, store grade per component and lowest component per grade. + var low=basis.length==1?1:basis[1].match(/\d+/g)[0]*1, + grades=options.grades||(options.dual&&basis.map((x,i)=>i?1:0))||basis.map(x=>tot>9?(x.length-1)/2:x.length-1), + grade_start=grades.map((a,b,c)=>c[b-1]!=a?b:-1).filter(x=>x+1).concat([basis.length]); + + // String-simplify a concatenation of two basis blades. (and supports custom basis names e.g. e21 instead of e12) + // This is the function that implements e1e1 = +1/-1/0 and e1e2=-e2e1. The brm function creates the remap dictionary. + var simplify = (s,p,q,r)=>{ + var sign=1,c,l,t=[],f=true,ss=s.match(tot>9?/(\d\d)/g:/(\d)/g);if (!ss) return s; s=ss; l=s.length; + while (f) { f=false; + // implement Ex*Ex = metric. + for (var i=0; i=(p+r)) sign*=-1; else if ((s[i]-low)t[i+1]) { c=t[i];t[i]=t[i+1];t[i+1]=c;sign*=-1;f=true; break;} if (f) { s=t;t=[];l=s.length; } + } + var ret=(sign==0)?'0':((sign==1)?'':'-')+(t.length?'e'+t.join(''):'1'); return (brm&&brm[ret])||(brm&&brm['-'+ret]&&'-'+brm['-'+ret])||ret; + }, + brm=(x=>{ var ret={}; for (var i in basis) ret[basis[i]=='1'?'1':simplify(basis[i],p,q,r)] = basis[i]; return ret; })(basis); + + // As an alternative to the string fiddling, one can also bit-fiddle. In this case the basisvectors are represented by integers with 1 bit per generator set. + var simplify_bits = (A,B,p2)=>{ var n=p2||(p+q+r),t=0,ab=A&B,res=A^B; if (ab&((1<>1); t&=B; t^=ab>>(p+r); t^=t>>16; t^=t>>8; t^=t>>4; return [1-2*(27030>>(t&15)&1),res]; }, + bc = (v)=>{ v=v-((v>>1)& 0x55555555); v=(v&0x33333333)+((v>>2)&0x33333333); var c=((v+(v>>4)&0xF0F0F0F)*0x1010101)>>24; return c }; + + if (!options.graded && tot <= 6 || options.graded===false || options.Cayley) { + // Faster and degenerate-metric-resistant dualization. (a remapping table that maps items into their duals). + var drm=basis.map((a,i)=>{ return {a:a,i:i} }) + .sort((a,b)=>a.a.length>b.a.length?1:a.a.lengthx.i).reverse(), + drms=drm.map((x,i)=>(x==0||i==0)?1:simplify(basis[x]+basis[i])[0]=='-'?-1:1); + + /// Store the full metric (also for bivectors etc ..) + var metric = options.Cayley&&options.Cayley.map((x,i)=>x[i]) || basis.map((x,xi)=>simplify(x+x,p,q,r)|0); metric[0]=1; + + /// Generate multiplication tables for the outer and geometric products. + var mulTable = options.Cayley||basis.map(x=>basis.map(y=>(x==1)?y:(y==1)?x:simplify(x+y,p,q,r))); + + // subalgebra support. (must be bit-order basis blades, does no error checking.) + if (options.even) options.basis = basis.filter(x=>x.length%2==1); + if (options.basis && !options.Cayley && r>=0 && options.basis.length != 2**tot) { + metric = metric.filter((x,i)=>options.basis.indexOf(basis[i])!=-1); + mulTable = mulTable.filter((x,i)=>options.basis.indexOf(basis[i])!=-1).map(x=>x.filter((x,i)=>options.basis.indexOf(basis[i])!=-1)); + basis = options.basis; + } + + /// Convert Cayley table to product matrices. The outer product selects the strict sum of the GP (but without metric), the inner product + /// is the left contraction. + var gp=basis.map(x=>basis.map(x=>'0')), cp=gp.map(x=>gp.map(x=>'0')), cps=gp.map(x=>gp.map(x=>'0')), op=gp.map(x=>gp.map(x=>'0')), gpo={}; // Storage for our product tables. + basis.forEach((x,xi)=>basis.forEach((y,yi)=>{ var n = mulTable[xi][yi].replace(/^-/,''); if (!gpo[n]) gpo[n]=[]; gpo[n].push([xi,yi]); })); + basis.forEach((o,oi)=>{ + gpo[o].forEach(([xi,yi])=>op[oi][xi]=(grades[oi]==grades[xi]+grades[yi])?((mulTable[xi][yi]=='0')?'0':((mulTable[xi][yi][0]!='-')?'':'-')+'b['+yi+']*this['+xi+']'):'0'); + gpo[o].forEach(([xi,yi])=>{ + gp[oi][xi] =((gp[oi][xi]=='0')?'':gp[oi][xi]+'+') + ((mulTable[xi][yi]=='0')?'0':((mulTable[xi][yi][0]!='-')?'':'-')+'b['+yi+']*this['+xi+']'); + cp[oi][xi] =((cp[oi][xi]=='0')?'':cp[oi][xi]+'+') + ((grades[oi]==grades[yi]-grades[xi])?gp[oi][xi]:'0'); + cps[oi][xi]=((cps[oi][xi]=='0')?'':cps[oi][xi]+'+') + ((grades[oi]==Math.abs(grades[yi]-grades[xi]))?gp[oi][xi]:'0'); + }); + }); + + /// Flat Algebra Multivector Base Class. + var generator = class MultiVector extends (options.baseType||Float32Array) { + /// constructor - create a floating point array with the correct number of coefficients. + constructor(a) { super(a||basis.length); return this; } + + /// grade selection - return a only the part of the input with the specified grade. + Grade(grade,res) { res=res||new this.constructor(); for (var i=0,l=res.length; i1e-10) res.push(((this[i]==1)&&i?'':((this[i]==-1)&&i)?'-':(this[i].toFixed(10)*1))+(i==0?'':tot==1&&q==1?'i':basis[i].replace('e','e_'))); return res.join('+').replace(/\+-/g,'-')||'0'; } + + /// Reversion, Involutions, Conjugation for any number of grades, component acces shortcuts. + get Negative (){ var res = new this.constructor(); for (var i=0; ia[drm[i]]*drms[i]); var res = new this.constructor(); res[res.length-1]=1; return this.Mul(res); }; + get UnDual (){ if (r) return this.map((x,i,a)=>a[drm[i]]*drms[a.length-i-1]); var res = new this.constructor(); res[res.length-1]=1; return this.Div(res); }; + get Length (){ return options.over?Math.sqrt(Math.abs(this.Mul(this.Conjugate).s.s)):Math.sqrt(Math.abs(this.Mul(this.Conjugate).s)); }; + get VLength (){ var res = 0; for (var i=0; i'res['+xi+']=b['+xi+']+this['+xi+']').join(';\n').replace(/(b|this)\[(.*?)\]/g,(a,b,c)=>options.mix?'('+b+'.'+(c|0?basis[c]:'s')+'||0)':a)+';\nreturn res') + generator.prototype.Scale = new Function('b,res','res=res||new this.constructor();\n'+basis.map((x,xi)=>'res['+xi+']=b*this['+xi+']').join(';\n').replace(/(b|this)\[(.*?)\]/g,(a,b,c)=>options.mix?'('+b+'.'+(c|0?basis[c]:'s')+'||0)':a)+';\nreturn res') + generator.prototype.Sub = new Function('b,res','res=res||new this.constructor();\n'+basis.map((x,xi)=>'res['+xi+']=this['+xi+']-b['+xi+']').join(';\n').replace(/(b|this)\[(.*?)\]/g,(a,b,c)=>options.mix?'('+b+'.'+(c|0?basis[c]:'s')+'||0)':a)+';\nreturn res') + generator.prototype.Mul = new Function('b,res','res=res||new this.constructor();\n'+gp.map((r,ri)=>'res['+ri+']='+r.join('+').replace(/\+\-/g,'-').replace(/(\w*?)\[(.*?)\]/g,(a,b,c)=>options.mix?'('+b+'.'+(c|0?basis[c]:'s')+'||0)':a).replace(/\+0/g,'')+';').join('\n')+'\nreturn res;'); + generator.prototype.LDot = new Function('b,res','res=res||new this.constructor();\n'+cp.map((r,ri)=>'res['+ri+']='+r.join('+').replace(/\+\-/g,'-').replace(/\+0/g,'').replace(/(\w*?)\[(.*?)\]/g,(a,b,c)=>options.mix?'('+b+'.'+(c|0?basis[c]:'s')+'||0)':a)+';').join('\n')+'\nreturn res;'); + generator.prototype.Dot = new Function('b,res','res=res||new this.constructor();\n'+cps.map((r,ri)=>'res['+ri+']='+r.join('+').replace(/\+\-/g,'-').replace(/\+0/g,'').replace(/(\w*?)\[(.*?)\]/g,(a,b,c)=>options.mix?'('+b+'.'+(c|0?basis[c]:'s')+'||0)':a)+';').join('\n')+'\nreturn res;'); + generator.prototype.Wedge = new Function('b,res','res=res||new this.constructor();\n'+op.map((r,ri)=>'res['+ri+']='+r.join('+').replace(/\+\-/g,'-').replace(/\+0/g,'').replace(/(\w*?)\[(.*?)\]/g,(a,b,c)=>options.mix?'('+b+'.'+(c|0?basis[c]:'s')+'||0)':a)+';').join('\n')+'\nreturn res;'); +// generator.prototype.Vee = new Function('b,res','res=res||new this.constructor();\n'+op.map((r,ri)=>'res['+drm[ri]+']='+r.map(x=>x.replace(/\[(.*?)\]/g,function(a,b){return '['+(drm[b|0])+']'})).join('+').replace(/\+\-/g,'-').replace(/\+0/g,'').replace(/(\w*?)\[(.*?)\]/g,(a,b,c)=>options.mix?'('+b+'.'+(c|0?basis[c]:'s')+'||0)':a)+';').join('\n')+'\nreturn res;'); + /// Conforms to the new Chapter 11 now. + generator.prototype.Vee = new Function('b,res',('res=res||new this.constructor();\n'+op.map((r,ri)=>'res['+drm[ri]+']='+drms[ri]+'*('+r.map(x=>x.replace(/\[(.*?)\]/g,function(a,b){return '['+(drm[b|0])+']'+(drms[b|0]>0?"":"*-1")})).join('+').replace(/\+\-/g,'-').replace(/\+0/g,'').replace(/(\w*?)\[(.*?)\]/g,(a,b,c)=>options.mix?'('+b+'.'+(c|0?basis[c]:'s')+'||0)':a)+');').join('\n')+'\nreturn res;').replace(/(b\[)|(this\[)/g,a=>a=='b['?'this[':'b[')); + generator.prototype.eigenValues = eigenValues; + + /// Add getter and setters for the basis vectors/bivectors etc .. + basis.forEach((b,i)=>Object.defineProperty(generator.prototype, i?b:'s', { + configurable: true, get(){ return this[i] }, set(x){ this[i]=x; } + })); + + /// Graded generator for high-dimensional algebras. + } else { + + /// extra graded lookups. + var basisg = grade_start.slice(0,grade_start.length-1).map((x,i)=>basis.slice(x,grade_start[i+1])); + var counts = grade_start.map((x,i,a)=>i==a.length-1?0:a[i+1]-x).slice(0,tot+1); + var basis_bits = basis.map(x=>x=='1'?0:x.slice(1).match(tot>9?/\d\d/g:/\d/g).reduce((a,b)=>a+(1<<(b-low)),0)), + bits_basis = []; basis_bits.forEach((b,i)=>bits_basis[b]=i); + var metric = basisg.map((x,xi)=>x.map((y,yi)=>simplify_bits(basis_bits[grade_start[xi]+yi],basis_bits[grade_start[xi]+yi])[0])); + var drms = basisg.map((x,xi)=>x.map((y,yi)=>simplify_bits(basis_bits[grade_start[xi]+yi],(~basis_bits[grade_start[xi]+yi])&((1<(typeof x=="string")?"-"+x:-x):undefined):this[i]; + else { if (r[i]==undefined) r[i]=[]; for(var j=0,m=Math.max(this[i].length,b[i].length);jx&&x.map(y=>typeof y=="string"?y+"*"+s:y*s)); } + + // geometric product. + Mul(b,r) { + r=r||new this.constructor(); var gotstring=false; + for (var i=0,x,gsx; gsx=grade_start[i],x=this[i],ig.map(e=>e&&(!(e+'').match(/-{0,1}\w+/))?'('+e+')':e)) + return r; + } + // outer product. + Wedge(b,r) { + r=r||new this.constructor(); + for (var i=0,x,gsx; gsx=grade_start[i],x=this[i],ix).sort((a,b)=>((a.match(/\d+/)[0]|0)-(b.match(/\d+/)[0]|0))||((a.match(/\d+$/)[0]|0)-(b.match(/\d+$/)[0]|0))).map(x=>x.replace(/\/\/\d+$/,'')); + var r2 = 'float sum=0.0; float res=0.0;\n', g=0; + r.forEach(x=>{ + var cg = x.match(/\d+/)[0]|0; + if (cg != g) r2 += "sum += res*res;\nres = 0.0;\n"; + r2 += x.replace(/\[\d+\]/,'') + '\n'; + g=cg; + }); + r2+= "sum += res*res;\n"; + return r2; + } + // Inner product glsl output. + IPNS_GLSL(b,point_source) { + var r='',count=0,curg; + for (var i=0,x,gsx; gsx=grade_start[i],x=this[i],ix).sort((a,b)=>((a.match(/\d+/)[0]|0)-(b.match(/\d+/)[0]|0))||((a.match(/\d+$/)[0]|0)-(b.match(/\d+$/)[0]|0))).map(x=>x.replace(/\/\/\d+$/,'')); + var r2 = 'float sum=0.0; float res=0.0;\n', g=0; + r.forEach(x=>{ + var cg = x.match(/\d+/)[0]|0; + if (cg != g) r2 += "sum += res*res;\nres = 0.0;\n"; + r2 += x.replace(/\[\d+\]/,'') + '\n'; + g=cg; + }); + r2+= "sum += res*res;\n"; + return r2; + } + // Left contraction. + LDot(b,r) { + r=r||new this.constructor(); + for (var i=0,x,gsx; gsx=grade_start[i],x=this[i],ig&&g.map((c,ci)=>!c?undefined:((c+'').match(/[\+\-\*]/)?'('+c+')':c)+(gi==0?"":basisg[gi][ci])).filter(x=>x).join('+')).filter(x=>x).join('+').replace(/\+\-/g,'-')||"0"; } + get s () { if (this[0]) return this[0][0]||0; return 0; } + get Length () { var res=0; this.forEach((g,gi)=>g&&g.forEach((e,ei)=>res+=(e||0)**2*metric[gi][ei])); return Math.abs(res)**.5; } + get VLength () { var res=0; this.forEach((g,gi)=>g&&g.forEach((e,ei)=>res+=(e||0)**2)); return Math.abs(res)**.5; } + get Reverse () { var r=new this.constructor(); this.forEach((x,gi)=>x&&x.forEach((e,ei)=>{if(!r[gi])r[gi]=[]; r[gi][ei] = this[gi][ei]*[1,1,-1,-1][gi%4]; })); return r; } + get Involute () { var r=new this.constructor(); this.forEach((x,gi)=>x&&x.forEach((e,ei)=>{if(!r[gi])r[gi]=[]; r[gi][ei] = this[gi][ei]*[1,-1,1,-1][gi%4]; })); return r; } + get Conjugate () { var r=new this.constructor(); this.forEach((x,gi)=>x&&x.forEach((e,ei)=>{if(!r[gi])r[gi]=[]; r[gi][ei] = this[gi][ei]*[1,-1,-1,1][gi%4]; })); return r; } + get Dual() { var r=new this.constructor(); this.forEach((g,gi)=>{ if (!g) return; r[tot-gi]=[]; g.forEach((e,ei)=>r[tot-gi][counts[gi]-1-ei]=drms[gi][ei]*e); }); return r; } + get Normalized () { return this.Scale(1/this.Length); } + } + + + // This generator is UNDER DEVELOPMENT - I'm publishing it so I can test on observable. + } + + // Generate a new class for our algebra. It extends the javascript typed arrays (default float32 but can be specified in options). + var res = class Element extends generator { + + // constructor - create a floating point array with the correct number of coefficients. + constructor(a) { super(a); if (this.upgrade) this.upgrade(); return this; } + + // Grade selection. (implemented by parent class). + Grade(grade,res) { res=res||new Element(); return super.Grade(grade,res); } + + // Right and Left divide - Defined on the elements, shortcuts to multiplying with the inverse. + Div (b,res) { return this.Mul(b.Inverse,res); } + LDiv (b,res) { return b.Inverse.Mul(this,res); } + + + // Bivector split - we handle all real cases, still have to add the complex cases for those exception scenarios. + Split (iter=50) { + var k = Math.floor((p+q+r)/2), OB = this.map(x=>x), B = this.map(x=>x), m = 1; + var Wi = [...Array(k)].map((r,i)=>{ m = m*(i+1); var Wi = B.Scale(1/m); B = B.Wedge(OB); return Wi; }); + if (k<3) { // The quadratic case is easy to solve. (for spaces <6D) + var TDT = this.Dot(this).s, TWT = this.Wedge(this); + if (TWT.VLength < 1E-5) return [this]; // bivector was simple. + var D = 0.5*Math.sqrt( TDT**2 - TWT.Mul(TWT).s ); + var eigen = [0.5*TDT + D, 0.5*TDT - D].sort((a,b)=>Math.abs(a)6D, closed form solutions of the characteristic polyn. are impossible, use eigenvalues of companion matrix. + var Wis = Wi.map((W,i)=>W.Mul(W).s*(-1)**(k-i+(k%2)) ); + var matrix = [...Array(k)].map((r,i)=>[...Array(k)].map((c,j)=>(j == k-1)?Wis[k-i-1]:(i-1==j)?1:0)); + var eigen = eigenValues(matrix,iter).sort((a,b)=>Math.abs(a){ + var r = Math.floor(k/2), N = Element.Scalar(0), DN = Element.Scalar(0); + for (var i=0; i<=r; ++i) { N.Add( Wi[2*i+1].Scale(v**(r-i)), N); DN.Add( Wi[2*i].Scale(v**(r-i)), DN); } + if (DN.VLength == 0) return Element.Scalar(0); + var ret = N.Div(DN); sum.Add(ret, sum); return ret; + }); + return [this.Sub(sum),...res]; // Smallest eigvalue becomes B-rest + } + + // Factorize a motor + Factorize (iter=50) { + var S = this.Grade(2).Split(iter); + var P = this.Scale(1); + // if (P.s) { + var R = S.slice(0,S.length-1).map((Si,i)=>{ + var Mi = Element.Scalar(P.s).Add(Si); + var scale = Math.sqrt(Mi.Reverse.Mul(Mi).s); + return Mi.Scale(1/scale); + }); + R.push( R.reduce((tot,fact)=>tot.Mul(fact.Reverse), Element.Scalar(1)).Mul(P) ); + // } + return R; + } + + // exp - closed form exp. + Exp (taylor = false) { + if (r==1 && tot<=4 && Math.abs(this[0])<1E-9 && !options.over && !taylor) { + // https://www.researchgate.net/publication/360528787_Normalization_Square_Roots_and_the_Exponential_and_Logarithmic_Maps_in_Geometric_Algebras_of_Less_than_6D + // 0 1 2 3 4 5 + // 5 6 7 8 9 10 + var l = (this[8]*this[8] + this[9]*this[9] + this[10]*this[10]); + if (l==0) return new Element([1, 0,0,0,0, this[5], this[6], this[7], 0, 0, 0, 0,0,0,0, 0]); + var m = (this[5]*this[10] + this[6]*this[9] + this[7]*this[8]), a = Math.sqrt(l), c = Math.cos(a), s = Math.sin(a)/a, t = m/l*(c-s); + var test = Element.Element(c, 0,0,0,0, s*this[5] + t*this[10], s*this[6] + t*this[9], s*this[7] + t*this[8], s*this[8], s*this[9], s*this[10], 0,0,0,0, m*s); + //return test; // tbc .. investigate pss coeff?? + + var u = Math.sqrt(Math.abs(this.Dot(this).s)); if (Math.abs(u)<1E-5) return this.Add(Element.Scalar(1)); + var v = this.Wedge(this).Scale(-1/(2*u)); + var res2 = Element.Add(Element.Sub(Math.cos(u),v.Scale(Math.sin(u))),Element.Div(Element.Mul((Element.Add(Math.sin(u),v.Scale(Math.cos(u)))),this),(Element.Add(u,v)))); + //if ([...test].map(x=>x.toFixed(1))+'' != [...res2].map(x=>x.toFixed(1))+'') { console.log(test, res2); debugger } + + return res2; + } + if (!taylor && Math.abs(this[0])<1E-9 && !options.over) { + return this.Grade(2).Split().reduce((total,simple)=>{ + var square = simple.Mul(simple).s, len = Math.sqrt(Math.abs(square)); + if (len <= 1E-5) return total.Mul(Element.Scalar(1).Add(simple)); + if (square < 0) return total.Mul(Element.Scalar(Math.cos(len)).Add(simple.Scale(Math.sin(len)/len)) ); + return total.Mul(Element.Scalar(Math.cosh(len)).Add(simple.Scale(Math.sinh(len)/len)) ); + },Element.Scalar(1)); + } + if (options.dual) { var f=Math.exp(this.s); return this.map((x,i)=>i?x*f:f); } + var res = Element.Scalar(1), y=1, M= this.Scale(1), N=this.Scale(1); for (var x=1; x<15; x++) { res=res.Add(M.Scale(1/y)); M=M.Mul(N); y=y*(x+1); }; + return res; + } + + // Log - only for up to 3D PGA for now + Log (compat = false) { + if (options.over) return; + if (!compat) { + if (tot==4 && q==0 && r==1 && !options.over) { // https://www.researchgate.net/publication/360528787_Normalization_Square_Roots_and_the_Exponential_and_Logarithmic_Maps_in_Geometric_Algebras_of_Less_than_6D + if (Math.abs(this.s)>=.99999) return Element.Bivector(this[5],this[6],this[7],0,0,0).Scale(Math.sign(this.s)); + var a = 1/(1 - this[0]*this[0]), b = Math.acos(this[0])*Math.sqrt(a), c = a*this[15]*(1 - this[0]*b); + return Element.Bivector( c*this[10] + b*this[5], -c*this[9] + b*this[6], c*this[8] + b*this[7], b*this[8], b*this[9], b*this[10] ); + } + return this.Factorize().reduce((sum,bi)=>{ + var [ci,si] = [bi.s, bi.Grade(2)]; + var square = si.Mul(si).s; + var len = Math.sqrt(Math.abs(square)); + if (Math.abs(square) < 1E-5) return sum.Add(si); + if (square < 0) return sum.Add(si.Scale(Math.acos(ci)/len)); + return sum.Add(si.Scale(Math.acosh(ci)/len)); + },Element.Scalar(0)); + } + var b = this.Grade(2), bdb = Element.Dot(b,b).s; + if (Math.abs(bdb)<=1E-5) return this.s<0?b.Scale(-1):b; + var s = Math.sqrt(-bdb), bwb = Element.Wedge(b,b); + if (Math.abs(bwb[bwb.length-1])<=1E-5 || Math.abs(this.s)<=1E-5) return b.Scale(Math.atan2(s,this.s)/s); + var p = bwb.Scale(-1/(2*s)); + return Element.Mul(Element.Mul((Element.Add(Math.atan2(s,this.s),p.Scale(1/this.s))),b),Element.Sub(s,p)).Scale(1/(s*s)); + } + + // Helper for efficient inverses. (custom involutions - negates grades in arguments). + Map () { var res=new Element(); return super.Map(res,...arguments); } + + // Factories - Make it easy to generate vectors, bivectors, etc when using the functional API. None of the examples use this but + // users that have used other GA libraries will expect these calls. The Coeff() is used internally when translating algebraic literals. + static Element() { return new Element([...arguments]); }; + static Coeff() { return (new Element()).Coeff(...arguments); } + static Scalar(x) { return (new Element()).Coeff(0,x); } + static Vector() { return (new Element()).nVector(1,...arguments); } + static Bivector() { return (new Element()).nVector(2,...arguments); } + static Trivector() { return (new Element()).nVector(3,...arguments); } + static nVector(n) { return (new Element()).nVector(...arguments); } + + // Static operators. The parser will always translate operators to these static calls so that scalars, vectors, matrices and other non-multivectors can also be handled. + // The static operators typically handle functions and matrices, calling through to element methods for multivectors. They are intended to be flexible and allow as many + // types of arguments as possible. If performance is a consideration, one should use the generated element methods instead. (which only accept multivector arguments) + static toEl(x) { if (x instanceof Function) x=x(); if (!(x instanceof Element)) x=Element.Scalar(x); return x; } + + // Addition and subtraction. Subtraction with only one parameter is negation. + static Add(a,b,res) { + // Resolve expressions passed in. + while(a.call)a=a(); while(b.call)b=b(); if (a.Add && b.Add) return a.Add(b,res); + // If either is a string, the result is a string. + if ((typeof a=='string')||(typeof b=='string')) return a.toString()+b.toString(); + // If only one is an array, add the other element to each of the elements. + if ((a instanceof Array && !a.Add)^(b instanceof Array && !b.Add)) return (a instanceof Array)?a.map(x=>Element.Add(x,b)):b.map(x=>Element.Add(a,x)); + // If both are equal length arrays, add elements one-by-one + if ((a instanceof Array)&&(b instanceof Array)&&a.length==b.length) return a.map((x,xi)=>Element.Add(x,b[xi])); + // If they're both not elements let javascript resolve it. + if (!(a instanceof Element || b instanceof Element)) return a+b; + // Here we're left with scalars and multivectors, call through to generated code. + a=Element.toEl(a); b=Element.toEl(b); return a.Add(b,res); + } + + static Sub(a,b,res) { + // Resolve expressions passed in. + while(a.call)a=a(); while(b&&b.call) b=b(); if (a.Sub && b && b.Sub) return a.Sub(b,res); + // If only one is an array, add the other element to each of the elements. + if (b&&((a instanceof Array)^(b instanceof Array))) return (a instanceof Array)?a.map(x=>Element.Sub(x,b)):b.map(x=>Element.Sub(a,x)); + // If both are equal length arrays, add elements one-by-one + if (b&&(a instanceof Array)&&(b instanceof Array)&&a.length==b.length) return a.map((x,xi)=>Element.Sub(x,b[xi])); + // Negation + if (arguments.length==1) return Element.Mul(a,-1); + // If none are elements here, let js do it. + if (!(a instanceof Element || b instanceof Element)) return a-b; + // Here we're left with scalars and multivectors, call through to generated code. + a=Element.toEl(a); b=Element.toEl(b); return a.Sub(b,res); + } + + // The geometric product. (or matrix*matrix, matrix*vector, vector*vector product if called with 1D and 2D arrays) + static Mul(a,b,res) { + // Resolve expressions + while(a.call&&!a.length)a=a(); while(b.call&&!b.length)b=b(); if (a.Mul && b.Mul) return a.Mul(b,res); + // still functions -> experimental curry style (dont use this.) + if (a.call && b.call) return (ai,bi)=>Element.Mul(a(ai),b(bi)); + // scalar mul. + if (Number.isFinite(a) && b.Scale) return b.Scale(a); else if (Number.isFinite(b) && a.Scale) return a.Scale(b); + // Handle matrices and vectors. + if ((a instanceof Array)&&(b instanceof Array)) { + // vector times vector performs a dot product. (which internally uses the GP on each component) + if((!(a[0] instanceof Array) || (a[0] instanceof Element)) &&(!(b[0] instanceof Array) || (b[0] instanceof Element))) { var r=tot?Element.Scalar(0):0; a.forEach((x,i)=>r=Element.Add(r,Element.Mul(x,b[i]),r)); return r; } + // Array times vector + if(!(b[0] instanceof Array)) return a.map((x,i)=>Element.Mul(a[i],b)); + // Array times Array + var r=a.map((x,i)=>b[0].map((y,j)=>{ var r=tot?Element.Scalar(0):0; x.forEach((xa,k)=>r=Element.Add(r,Element.Mul(xa,b[k][j]))); return r; })); + // Return resulting array or scalar if 1 by 1. + if (r.length==1 && r[0].length==1) return r[0][0]; else return r; + } + // Only one is an array multiply each of its elements with the other. + if ((a instanceof Array)^(b instanceof Array)) return (a instanceof Array)?a.map(x=>Element.Mul(x,b)):b.map(x=>Element.Mul(a,x)); + // Try js multiplication, else call through to geometric product. + var r=a*b; if (!isNaN(r)) return r; + a=Element.toEl(a); b=Element.toEl(b); return a.Mul(b,res); + } + + // The inner product. (default is left contraction). + static LDot(a,b,res) { + // Expressions + while(a.call)a=a(); while(b.call)b=b(); //if (a.LDot) return a.LDot(b,res); + // Map elements in array + if (b instanceof Array && !(a instanceof Array)) return b.map(x=>Element.LDot(a,x)); + if (a instanceof Array && !(b instanceof Array)) return a.map(x=>Element.LDot(x,b)); + // js if numbers, else contraction product. + if (!(a instanceof Element || b instanceof Element)) return a*b; + a=Element.toEl(a);b=Element.toEl(b); return a.LDot(b,res); + } + + // The symmetric inner product. (default is left contraction). + static Dot(a,b,res) { + // Expressions + while(a.call)a=a(); while(b.call)b=b(); //if (a.LDot) return a.LDot(b,res); + // js if numbers, else contraction product. + if (!(a instanceof Element || b instanceof Element)) return a|b; + a=Element.toEl(a);b=Element.toEl(b); return a.Dot(b,res); + } + + // The outer product. (Grassman product - no use of metric) + static Wedge(a,b,res) { + // normal behavior for booleans/numbers + if (typeof a in {boolean:1,number:1} && typeof b in {boolean:1,number:1}) return a^b; + // Expressions + while(a.call)a=a(); while(b.call)b=b(); if (a.Wedge) return a.Wedge(Element.toEl(b),res); + // The outer product of two vectors is a matrix .. internally Mul not Wedge ! + if (a instanceof Array && b instanceof Array) return a.map(xa=>b.map(xb=>Element.Mul(xa,xb))); + // js, else generated wedge product. + if (!(a instanceof Element || b instanceof Element)) return a*b; + a=Element.toEl(a);b=Element.toEl(b); return a.Wedge(b,res); + } + + // The regressive product. (Dual of the outer product of the duals). + static Vee(a,b,res) { + // normal behavior for booleans/numbers + if (typeof a in {boolean:1,number:1} && typeof b in {boolean:1,number:1}) return a&b; + // Expressions + while(a.call)a=a(); while(b.call)b=b(); if (a.Vee) return a.Vee(Element.toEl(b),res); + // js, else generated vee product. (shortcut for dual of wedge of duals) + if (!(a instanceof Element || b instanceof Element)) return 0; + a=Element.toEl(a);b=Element.toEl(b); return a.Vee(b,res); + } + + // The sandwich product. Provided for convenience (>>> operator) + static sw(a,b) { + // Skip strings/colors + if (typeof b == "string" || typeof b =="number") return b; + // Expressions + while(a.call)a=a(); while(b.call)b=b(); if (a.sw) return a.sw(b); + // Map elements in array + if (b instanceof Array && !b.Add) return b.map(x=>Element.sw(a,x)); + // Call through. no specific generated code for it so just perform the muls. + a=Element.toEl(a); b=Element.toEl(b); return a.Mul(b).Mul(a.Reverse); + } + + // Division - scalars or cal through to element method. + static Div(a,b,res) { + // Expressions + while(a.call)a=a(); while(b.call)b=b(); + // For DDG experiments, I'll include a quick cholesky on matrices here. (vector/matrix) + if ((a instanceof Array) && (b instanceof Array) && (b[0] instanceof Array)) { + // factor + var R = b.flat(), i, j, k, sum, i_n, j_n, n=b[0].length, s=new Array(n), x=new Array(n), yi; + for (i=0;i=0; i--) for (x[i] /= R[i*n+i], j=i+1; ja.map((r,ri)=>Element.Conjugate(a[ri][ci]))); return Element.toEl(a).Conjugate; } + static Normalize(a) { return Element.toEl(a).Normalized; }; + static Length(a) { return Element.toEl(a).Length }; + + // Comparison operators always use length. Handle expressions, then js or length comparison + static eq(a,b) { if (!(a instanceof Element)||!(b instanceof Element)) return a==b; while(a.call)a=a(); while(b.call)b=b(); for (var i=0; i(b instanceof Element?b.Length:b); } + static lte(a,b) { while(a.call)a=a(); while(b.call)b=b(); return (a instanceof Element?a.Length:a)<=(b instanceof Element?b.Length:b); } + static gte(a,b) { while(a.call)a=a(); while(b.call)b=b(); return (a instanceof Element?a.Length:a)>=(b instanceof Element?b.Length:b); } + + // Debug output and printing multivectors. + static describe(x) { if (x===true) console.log(`Basis\n${basis}\nMetric\n${metric.slice(1,1+tot)}\nCayley\n${mulTable.map(x=>(x.map(x=>(' '+x).slice(-2-tot)))).join('\n')}\nMatrix Form:\n`+gp.map(x=>x.map(x=>x.match(/(-*b\[\d+\])/)).map(x=>x&&((x[1].match(/-/)||' ')+String.fromCharCode(65+1*x[1].match(/\d+/)))||' 0')).join('\n')); return {basis:basisg||basis,metric,mulTable,matrix:gp.map(x=>x.map(x=>x.replace(/\*this\[.+\]/,'').replace(/b\[(\d+)\]/,(a,x)=>(metric[x]==-1||metric[x]==0&&grades[x]>1&&(-1)**grades[x]==(metric[basis.indexOf(basis[x].replace('0',''))]||(-1)**grades[x])?'-':'')+basis[x]).replace('--','')))} } + + // Direct sum of algebras - experimental + static sum(B){ + var A = Element; + // Get the multiplication tabe and basis. + var T1 = A.describe().mulTable, T2 = B.describe().mulTable; + var B1 = A.describe().basis, B2 = B.describe().basis; + // Get the maximum index of T1, minimum of T2 and rename T2 if needed. + var max_T1 = B1.filter(x=>x.match(/e/)).map(x=>x.match(/\d/g)).flat().map(x=>x|0).sort((a,b)=>b-a)[0]; + var max_T2 = B2.filter(x=>x.match(/e/)).map(x=>x.match(/\d/g)).flat().map(x=>x|0).sort((a,b)=>b-a)[0]; + var min_T2 = B2.filter(x=>x.match(/e/)).map(x=>x.match(/\d/g)).flat().map(x=>x|0).sort((a,b)=>a-b)[0]; + // remapping .. + T2 = T2.map(x=>x.map(y=>y.match(/e/)?y.replace(/(\d)/g,(x)=>(x|0)+max_T1):y.replace("1","e"+(1+max_T2+max_T1)))); + B2 = B2.map((y,i)=>i==0?y.replace("1","e"+(1+max_T2+max_T1)):y.replace(/(\d)/g,(x)=>(x|0)+max_T1)); + // Build the new basis and multable.. + var basis = [...B1,...B2]; + var Cayley = T1.map((x,i)=>[...x,...T2[0].map(x=>"0")]).concat(T2.map((x,i)=>[...T1[0].map(x=>"0"),...x])) + // Build the new algebra. + var grades = [...B1.map(x=>x=="1"?0:x.length-1),...B2.map((x,i)=>i?x.length-1:0)]; + var a = Algebra({basis,Cayley,grades,tot:Math.log2(B1.length)+Math.log2(B2.length)}) + // And patch up .. + a.Scalar = function(x) { + var res = new a(); + for (var i=0; i function of 1 parameter will be called with that parameter from -1 to 1 and graphed on a canvas. Returned values should also be in the [-1 1] range + // graph(function(x,y)) => functions of 2 parameters will be called from -1 to 1 on both arguments. Returned values can be 0-1 for greyscale or an array of three RGB values. + // graph(array) => array of algebraic elements (points, lines, circles, segments, texts, colors, ..) is graphed. + // graph(function=>array) => same as above, for animation scenario's this function is called each frame. + // An optional second parameter is an options object { width, height, animate, camera, scale, grid, canvas } + static graph(f,options) { + // Store the original input + if (!f) return; var origf=f; + // generate default options. + options=options||{}; options.scale=options.scale||1; options.camera=options.camera||(tot!=4?Element.Scalar(1): ( Element.Bivector(0,0,0,0,0,options.p||0).Exp() ).Mul( Element.Bivector(0,0,0,0,options.h||0,0).Exp()) ); + if (options.conformal && tot==4) var ni = options.ni||this.Coeff(4,1,3,1), no = options.no||this.Coeff(4,0.5,3,-0.5), minus_no = no.Scale(-1); + var ww=options.width, hh=options.height, cvs=options.canvas, tpcam=new Element([0,0,0,0,0,0,0,0,0,0,0,-5,0,0,1,0]),tpy=this.Coeff(4,1),tp=new Element(), + // project 3D to 2D. This allows to render 3D and 2D PGA with the same code. + project=(o)=>{ if (!o) return o; while (o.call) o=o(); +// if (o instanceof Element && o.length == 32) o = new Element([o[0],o[1],o[2],o[3],o[4],o[6],o[7],o[8],o[10],o[11],o[13],o[16],o[17],o[19],o[22],o[26]]); + // Clip 3D lines so they don't go past infinity. + if (o instanceof Element && o.length == 16 && o[8]**2+o[9]**2+o[10]**2>0.0001) { + o = [[options.clip||2,1,0,0],[-(options.clip||2),1,0,0],[options.clip||2,0,1,0],[-(options.clip||2),0,1,0],[options.clip||2,0,0,1],[-(options.clip||2),0,0,1]].map(v=>{ + var r = Element.Vector(...v).Wedge(o); return r[14]?r.Scale(1/r[14], r):undefined; + }).filter(x=>x && Math.abs(x[13])<= (options.clip||2)+0.001 && Math.abs(x[12]) <= (options.clip||2)+0.001 && Math.abs(x[11]) <= (options.clip||2) + 0.001).slice(0,2); + return o.map(o=>(tpcam).Vee(options.camera.Mul(o).Mul(options.camera.Conjugate)).Wedge(tpy)); + } + // Convert 3D planes to polies. + if (o instanceof Element && o.length == 16 && o.Grade(1).Length>0.01) { + var m = Element.Add(1, Element.Mul(o.Normalized, Element.Coeff(3,1))).Normalized, e0 = 0; + o=Element.sw(m,[[-1,-1],[-1,1],[1,1],[-1,-1],[1,1],[1,-1]].map(([x,z])=>Element.Trivector(x*o.Length,e0,z*o.Length,1))); + return o.map(o=>(tpcam).Vee(options.camera.Mul(o).Mul(options.camera.Conjugate)).Wedge(tpy)); + } + return (tot==4 && o instanceof Element && o.length==16)?(tpcam).Vee(options.camera.Mul(o).Mul(options.camera.Conjugate)).Wedge(tpy):(o.length==2**tot)?Element.sw(options.camera,o):o; + }; + // gl escape. + if (options.gl && !(tot==4 && options.conformal)) return Element.graphGL(f,options); if (options.up) return Element.graphGL2(f,options); + // if we get an array or function without parameters, we render c2d or p2d SVG points/lines/circles/etc + if (!(f instanceof Function) || f.length===0) { + // Our current cursor, color, animation state and 2D mapping. + var lx,ly,lr,color,res,anim=false,to2d=(tot==5)?[0, 8, 11, 13, 19, 17, 22, 26]:(tot==3)?[0,1,2,3,4,5,6,7]:[0,7,9,10,13,12,14,15]; + // Make sure we have an array of elements. (if its an object, convert to array with elements and names.) + if (f instanceof Function) f=f(); if (!(f instanceof Array)) f=[].concat.apply([],Object.keys(f).map((k)=>typeof f[k]=='number'?[f[k]]:[f[k],k])); + // The build function generates the actual SVG. It will be called everytime the user interacts or the anim flag is set. + function build(f,or) { + // Make sure we have an aray. + if (or && f && f instanceof Function) f=f(); + // Reset position and color for cursor. + lx=-2;ly=options.conformal?-1.85:1.85;lr=0;color='#444'; + // Create the svg element. (master template string till end of function) + var svg=new DOMParser().parseFromString(` + ${// Add a grid (option) + options.grid?(()=>{ + if (tot==4 && !options.conformal) { + const lines3d = (n,from,to,j,l=0, ox=0, oy=0, alpha=1)=>[``,...[...Array(n+1)].map((x,i)=>{ + var f=from.map((x,i)=>x*(i==3?1:(options.gridSize||1))), t=to.map((x,i)=>x*(i==3?1:(options.gridSize||1))); f[j] = t[j] = (i-(n/2))/(n/2) * (options.gridSize||1); + var D3a = Element.Trivector(...f), D2a = project(D3a), D3b = Element.Trivector(...t), D2b = project(D3b); + var lx=options.scale*D2a[drm[2]]/D2a[drm[1]]; if (drm[1]==6||drm[1]==14) lx*=-1; var ly=-options.scale*D2a[drm[3]]/D2a[drm[1]]; + var lx2=options.scale*D2b[drm[2]]/D2b[drm[1]]; if (drm[1]==6||drm[1]==14) lx2*=-1; var ly2=-options.scale*D2b[drm[3]]/D2b[drm[1]]; + var r = ``; + if (l && i && i!= n) r += `${((from[j]<0?-1:1)*(i-(n/2))/(n/2)*(options.gridSize||1)).toFixed(1)}` + return r; + }),'']; + var front = Element.sw(options.camera,Element.Trivector(1,0,0,0)).Dual.Dot(Element.Vector(0,0,0,1)).s, ff = front>0?1:-1; + var left = Element.sw(options.camera,Element.Trivector(0,0,1,0)).Dual.Dot(Element.Vector(0,0,0,1)).s, ll = left>0?1:-1; + var fa = Math.max(0,Math.min(1,5*Math.abs(left))), la = Math.max(0,Math.min(1,5*Math.abs(front))); + return [ + ...lines3d(20,[-1,-1,-1,1],[1,-1,1,1],2,options.labels?ff:0, 0, 0.05), + ...lines3d(20,[-1,-1,-1,1],[1,-1,1,1],0,options.labels?ll:0, 0, 0.05), + ...lines3d(20,[-1,-1,ll,1],[1,1,ll,1],0,0,0,0,fa), + ...lines3d(20,[-1,1,ll,1],[1,-1,ll,1],1,!options.labels?0:(ff!=-1)?1:2, ll*ff*-0.05, 0, fa), + ...lines3d(20,[ff,1,-1,1],[ff,-1,1,1],1,!options.labels?0:(ll!=-1)?1:2, ll*ff*0.05, 0, la), + ...lines3d(20,[ff,-1,-1,1],[ff,1,1,1],2,0,0,0,la), + ].join(''); + } + const s = options.scale, n = (10/s)|0, cx = options.camera.e02, cy = options.camera.e01, alpha = Math.min(1,(s-0.2)*10); if (options.scale<0.1) return; + const lines = (n,dir,space,width,color)=>[...Array(2*n+1)].map((x,xi)=>``) + return [``,...lines(n*2,0,0.2,0.005,'#DDD'),...lines(n*2,1,0.2,0.005,'#DDD'),...lines(n,0,1,0.005,'#AAA'),...lines(n,1,1,0.005,'#AAA'),...lines(n,0,5,0.005,'#444'),...lines(n,1,5,0.005,'#444')] + .concat(options.labels?[...Array(4*n+1)].map((x,xi)=>(xi-n*2==0)?``:`${((xi-n*2)*0.2).toFixed(1)}`):[]) + .concat(options.labels?[...Array(4*n+1)].map((x,xi)=>`${((xi-n*2)*-0.2).toFixed(1)}`):[]).join('')+''; + })():''} + // Handle conformal 2D elements. + ${options.conformal?f.map&&f.map((o,oidx)=>{ + // Optional animation handling. + if((o==Element.graph && or!==false)||(oidx==0&&options.animate&&or!==false)) { anim=true; requestAnimationFrame(()=>{var r=build(origf,(!res)||(document.body.contains(res))).innerHTML; if (res) res.innerHTML=r; }); if (!options.animate) return; } + // Resolve expressions passed in. + while (o.call) o=o(); + if (options.ipns && o instanceof Element) o = o.Dual; + var sc = options.scale; + var lineWidth = options.lineWidth || 1; + var pointRadius = options.pointRadius || 1; + var dash_for_r2 = (r2, render_r, target_width) => { + // imaginary circles are dotted + if (r2 >= 0) return 'none'; + var half_circum = render_r*Math.PI; + var width = half_circum / Math.max(Math.round(half_circum / target_width), 1); + return `${width} ${width}`; + }; + // Arrays are rendered as segments or polygons. (2 or more elements) + if (o instanceof Array) { lx=ly=lr=0; o=o.map(o=>{ while(o.call)o=o(); return o.Scale(-1/o.Dot(ni).s); }); o.forEach((o)=>{lx+=sc*(o.e1);ly+=sc*(-o.e2)});lx/=o.length;ly/=o.length; return o.length>2?``:``; } + // Allow insertion of literal svg strings. + if (typeof o =='string' && o[0]=='<') { return o; } + // Strings are rendered at the current cursor position. + if (typeof o =='string') { var res2=(o[0]=='_')?'':` ${o} `; ly+=0.14; return res2; } + // Numbers change the current color. + if (typeof o =='number') { color='#'+(o+(1<<25)).toString(16).slice(-6); return ''; }; + // All other elements are rendered .. + var ni_part = o.Dot(no.Scale(-1)); // O_i + n_o O_oi + var no_part = ni.Scale(-1).Dot(o); // O_o + O_oi n_i + if (ni_part.VLength * 1e-6 > no_part.VLength) { + // direction or dual - nothing to render + return ""; + } + var no_ni_part = no_part.Dot(no.Scale(-1)); // O_oi + var no_only_part = ni.Wedge(no_part).Dot(no.Scale(-1)); // O_o + + /* Note: making 1e-6 smaller increases the maximum circle radius before they are drawn as lines */ + if (no_ni_part.VLength * 1e-6 > no_only_part.VLength) { + var is_flat = true; + var direction = no_ni_part; + } + else { + var is_flat = false; + var direction = no_only_part; + } + // normalize to make the direction unitary + var dl = direction.Length; + o = o.Scale(1/dl); + direction = direction.Scale(1/dl) + + var b0=direction.Grade(0).VLength>0.001,b1=direction.Grade(1).VLength>0.001,b2=direction.Grade(2).VLength>0.001; + if (!is_flat && b0 && !b1 && !b2) { + // Points + if (direction.s < 0) { o = Element.Sub(o); } + lx=sc*(o.e1); ly=sc*(-o.e2); lr=0; return res2=``; + } else if (is_flat && !b0 && b1 && !b2) { + // Lines. + var loc=minus_no.LDot(o).Div(o), att=ni.Dot(o); + lx=sc*(-loc.e1); ly=sc*(loc.e2); lr=Math.atan2(-o[14],o[13])/Math.PI*180; return ``; + } else if (!is_flat && !b0 && !b1 && b2) { + // Circles + var loc=o.Div(ni.LDot(o)); lx=sc*(-loc.e1); ly=sc*(loc.e2); + var r2=o.Mul(o.Conjugate).s; + var r = Math.sqrt(Math.abs(r2))*sc; + return ``; + } else if (!is_flat && !b0 && b1 && !b2) { + // Point Pairs. + lr=0; var ei=ni,eo=no, nix=o.Wedge(ei), sqr=o.LDot(o).s/nix.LDot(nix).s, r=Math.sqrt(Math.abs(sqr)), attitude=((ei.Wedge(eo)).LDot(nix)).Normalized.Mul(Element.Scalar(r)), pos=o.Div(nix); pos=pos.Div( pos.LDot(Element.Sub(ei))); + if (nix==0) { pos = o.Dot(Element.Coeff(4,-1)); sqr=-1; } + lx=sc*(pos.e1); ly=sc*(-pos.e2); + if (sqr==0) return ``; + // Draw imaginary pairs hollow + if (sqr > 0) var fill = color||'green', stroke = 'none', dash_array = 'none'; + else var fill = 'none', stroke = color||'green'; + lx=sc*(pos.e1+attitude.e1); ly=sc*(-pos.e2-attitude.e2); + var res2=``; + lx=sc*(pos.e1-attitude.e1); ly=sc*(-pos.e2+attitude.e2); + return res2+``; + } else { + /* Unrecognized */ + return ""; + } + // Handle projective 2D and 3D elements. + }):f.map&&f.map((o,oidx)=>{ if((o==Element.graph && or!==false)||(oidx==0&&options.animate&&or!==false)) { anim=true; requestAnimationFrame(()=>{var r=build(origf,(!res)||(document.body.contains(res))).innerHTML; if (res) res.innerHTML=r; }); if (!options.animate) return; } while (o instanceof Function) o=o(); o=(o instanceof Array)?o.map(project):project(o); if (o===undefined) return; + // dual option dualizes before render + if (options.dual && o instanceof Element) o = o.Dual; + // line segments and polygons + if (o instanceof Array && o.length>1) { lx=ly=lr=0; o.forEach((o)=>{while (o.call) o=o(); lx+=options.scale*((drm[1]==6||drm[1]==14)?-1:1)*o[drm[2]]/o[drm[1]];ly+=options.scale*o[drm[3]]/o[drm[1]]});lx/=o.length;ly/=o.length; return o.length>2?``:``; } + // svg + if (typeof o =='string' && o[0]=='<') { return o; } + // Labels + if (typeof o =='string') { var res2=(o[0]=='_')?'':` ${o} `; ly-=0.14; return res2; } + // Colors + if (typeof o =='number') { color='#'+(o+(1<<25)).toString(16).slice(-6); return ''; }; + // Points + if (o[to2d[6]]**2 >0.0001) { lx=options.scale*o[drm[2]]/o[drm[1]]; if (drm[1]==6||drm[1]==14) lx*=-1; ly=options.scale*o[drm[3]]/o[drm[1]]; lr=0; var res2=``; ly+=0.05; lx-=0.1; return res2; } + // Lines + if (o[to2d[2]]**2+o[to2d[3]]**2>0.0001) { var l=Math.sqrt(o[to2d[2]]**2+o[to2d[3]]**2); o[to2d[2]]/=l; o[to2d[3]]/=l; o[to2d[1]]/=l; lx=0.5; ly=options.scale*((drm[1]==6)?-1:-1)*o[to2d[1]]; lr=-Math.atan2(o[to2d[2]],o[to2d[3]])/Math.PI*180; var res2=``; ly+=0.05; return res2; } + // Vectors + if (o[to2d[4]]**2+o[to2d[5]]**2>0.0001) { lr=0; ly+=0.05; lx+=0.1; var res2=``; ly=ly+o.e01/4*3-0.05; lx=lx-o.e02/4*3; return res2; } + }).join()}`,'text/html').body; + // return the inside of the created svg element. + return svg.removeChild(svg.firstChild); + }; + // Create the initial svg and install the mousehandlers. + res=build(f); res.value=f; res.options=options; res.setAttribute("stroke-width",options.lineWidth*0.005||0.005); + res.remake = (animate)=>{ options.animate = animate; if (animate) { var r=build(origf,(!res)||(document.body.contains(res))).innerHTML; if (res) res.innerHTML=r; }; return res;}; + //onmousedown="if(evt.target==this)this.sel=undefined" + var mousex,mousey,cammove=false; + res.onwheel=(e)=>{ e.preventDefault(); options.scale = Math.min(5,Math.max(0.1,(options.scale||1)-e.deltaY*0.0001)); if (!anim) {var r=build(origf,(!res)||(document.body.contains(res))).innerHTML; if (res) res.innerHTML=r; } } + res.onmousedown=(e)=>{ if (e.target == res) res.sel=undefined; mousex = e.clientX; mousey = e.clientY; cammove = true; } + res.onmousemove=(e)=>{ + if (cammove && tot==4 && !options.conformal) { + if (!e.buttons) { cammove=false; return; }; + var [dx,dy] = [(options.scale || 1)*(e.clientX - mousex)*3, 3*(options.scale || 1)*(e.clientY - mousey)]; + [mousex,mousey] = [e.clientX,e.clientY]; + if (res.sel !== undefined && f[res.sel].set) { + var [cw,ch] = [res.clientWidth, res.clientHeight]; + var ox = (1/(options.scale || 1)) * ((e.offsetX / cw) - 0.5) * (cw>ch?(cw/ch):1); + var oy = (1/(options.scale || 1)) * ((e.offsetY / ch) - 0.5) * (ch>cw?(ch/cw):1); + var tb = Element.sw(options.camera,f[res.sel]); + var z = -(tb.e012/tb.e123+5)/5*4; tb.e023 = ox*z*tb.e123; tb.e013 = oy*z*tb.e123; + f[res.sel].set(Element.sw(options.camera.Reverse, tb)); + //f[res.sel].set( Element.sw(Element.sw(options.camera.Reverse,Element.Bivector(-dx/res.clientWidth,dy/res.clientHeight,0,0,0,0).Exp()),f[res.sel]) ); + } else { + options.h = (options.h||0) + dx/300; + options.p = (options.p||0) - dy/600; + if (options.camera) options.camera.set( ( Element.Bivector(0,0,0,0,0,options.p).Exp() ).Mul( Element.Bivector(0,0,0,0,options.h,0).Exp() )/*.Mul(options.camera)*/ ) + } + if (!anim) {var r=build(origf,(!res)||(document.body.contains(res))).innerHTML; if (res) res.innerHTML=r; } + return; + } + if (res.sel===undefined || f[res.sel] == undefined || f[res.sel].set == undefined || !e.buttons) return; + var resx=res.getBoundingClientRect().width,resy=res.getBoundingClientRect().height, + x=((e.clientX-res.getBoundingClientRect().left)/(resx/4||128)-2)*(resx>resy?resx/resy:1),y=((e.clientY-res.getBoundingClientRect().top)/(resy/4||128)-2)*(resy>resx?resy/resx:1); + x/=options.scale;y/=options.scale; + if (options.conformal) { f[res.sel].set(this.Coeff(1,x,2,-y).Add(no).Add(ni.Scale(0.5*(x*x+y*y))) ) } + else { f[res.sel][drm[2]]=((drm[1]==6)?-x:x)-((tot<4)?2*options.camera.e01:0); f[res.sel][drm[3]]=-y+((tot<4)?2*options.camera.e02:0); f[res.sel][drm[1]]=1; f[res.sel].set(f[res.sel].Normalized)} + if (!anim) {var r=build(origf,(!res)||(document.body.contains(res))).innerHTML; if (res) res.innerHTML=r; } + res.dispatchEvent(new CustomEvent('input')) }; + return res; + } + // 1d and 2d functions are rendered on a canvas. + cvs=cvs||document.createElement('canvas'); if(ww)cvs.width=ww; if(hh)cvs.height=hh; var w=cvs.width,h=cvs.height,context=cvs.getContext('2d'), data=context.getImageData(0,0,w,h); + // Grid support for the canvas. + const [x_from,x_to,y_from,y_to]=options.range||[-1,1,1,-1]; + function drawGrid() { + const [X,Y]=[x=>(x-x_from)*w/(x_to-x_from),y=>(y-y_from)*h/(y_to-y_from)] + context.strokeStyle = "#008800"; context.lineWidth = 1; + // X and Y axis + context.beginPath(); + context.moveTo(X(x_from), Y(0)); context.lineTo(X(x_to ), Y(0)); context.stroke(); + context.moveTo(X(0), Y(y_from)); context.lineTo(X(0), Y(y_to )); context.stroke(); + // Draw ticks + context.strokeStyle = "#00FF00"; context.lineWidth = 2; context.font = "10px Arial"; context.fillStyle = "#448844"; + for (var i=x_from,j=y_from,ii=0; ii<=10; ++ii) { + context.beginPath(); j+= (y_to-y_from)/10; i+=(x_to-x_from)/10; + context.moveTo(X(i), Y(-(y_to - y_from)/200)); context.lineTo(X(i), Y((y_to - y_from)/200)); context.stroke(); + if(i.toFixed(1)!=0) context.fillText(i.toFixed(1), X(i-(x_to-x_from)/100), Y(-(y_to-y_from)/40)); + context.moveTo(X((x_to-x_from)/200), Y(j)); context.lineTo(X(-(x_to-x_from)/200), Y(j)); context.stroke(); + if(j.toFixed(1)!=0) context.fillText(j.toFixed(1), X((x_to-x_from)/100), Y(j)); + } + } + // two parameter functions .. evaluate for both and set resulting color. + if (f.length==2) for (var px=0; pxx*255).concat([255]),py*w*4+px*4); } + // one parameter function.. go over x range, use result as y. + else if (f.length==1) for (var px=0; px 0 && res < h-1) data.data.set([0,0,0,255],res*w*4+px*4); } + context.putImageData(data,0,0); + if (f.length == 1 || f.length == 2) if (options.grid) drawGrid(); + return cvs; + } + + // webGL2 Graphing function. (for OPNS/IPNS implicit 2D and 1D surfaces in 3D space). + static graphGL2(f,options) { + // Create canvas, get webGL2 context. + var canvas=document.createElement('canvas'); canvas.style.width=options.width||''; canvas.style.height=options.height||''; canvas.style.backgroundColor='#EEE'; + if (options.width && options.width.match && options.width.match(/px/i)) canvas.width = parseFloat(options.width)*(options.devicePixelRatio||devicePixelRatio||1); if (options.height && options.height.match && options.height.match(/px/i)) canvas.height = parseFloat(options.height)*(options.devicePixelRatio||devicePixelRatio||1); + var gl=canvas.getContext('webgl2',{alpha:options.alpha||false,preserveDrawingBuffer:true,antialias:true,powerPreference:'high-performance'}); + var gl2=!!gl; if (!gl) gl=canvas.getContext('webgl',{alpha:options.alpha||false,preserveDrawingBuffer:true,antialias:true,powerPreference:'high-performance'}); + gl.clearColor(240/255,240/255,240/255,1.0); gl.enable(gl.DEPTH_TEST); if (!gl2) { gl.getExtension("EXT_frag_depth"); gl.va = gl.getExtension('OES_vertex_array_object'); } + else gl.va = { createVertexArrayOES : gl.createVertexArray.bind(gl), bindVertexArrayOES : gl.bindVertexArray.bind(gl), deleteVertexArrayOES : gl.deleteVertexArray.bind(gl) } + // Compile vertex and fragment shader, return program. + var compile=(vs,fs)=>{ + var s=[gl.VERTEX_SHADER,gl.FRAGMENT_SHADER].map((t,i)=>{ + var r=gl.createShader(t); gl.shaderSource(r,[vs,fs][i]); gl.compileShader(r); + return gl.getShaderParameter(r, gl.COMPILE_STATUS)&&r||console.error(gl.getShaderInfoLog(r)); + }); + var p = gl.createProgram(); gl.attachShader(p, s[0]); gl.attachShader(p, s[1]); gl.linkProgram(p); + gl.getProgramParameter(p, gl.LINK_STATUS)||console.error(gl.getProgramInfoLog(p)); + return p; + }; + // Create vertex array and buffers, upload vertices and optionally texture coordinates. + var createVA=function(vtx) { + var r = gl.va.createVertexArrayOES(); gl.va.bindVertexArrayOES(r); + var b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vtx), gl.STATIC_DRAW); + gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0); + return {r,b} + }, + // Destroy Vertex array and delete buffers. + destroyVA=function(va) { + if (va.b) gl.deleteBuffer(va.b); if (va.r) gl.va.deleteVertexArrayOES(va.r); + } + // Drawing function + var M=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,5,1]; + var draw=function(p, tp, vtx, color, color2, ratio, texc, va, b,color3,r,g){ + gl.useProgram(p); gl.uniformMatrix4fv(gl.getUniformLocation(p, "mv"),false,M); + gl.uniformMatrix4fv(gl.getUniformLocation(p, "p"),false, [5,0,0,0,0,5*(ratio||1),0,0,0,0,1,2,0,0,-1,0]) + gl.uniform3fv(gl.getUniformLocation(p, "color"),new Float32Array(color)); + gl.uniform3fv(gl.getUniformLocation(p, "color2"),new Float32Array(color2)); + if (color3) gl.uniform3fv(gl.getUniformLocation(p, "color3"),new Float32Array(color3)); + if (b) gl.uniform1fv(gl.getUniformLocation(p, "b"),(new Float32Array(counts[g])).map((x,i)=>b[g][i]||0)); + if (texc) gl.uniform1i(gl.getUniformLocation(p, "texc"),0); + if (r) gl.uniform1f(gl.getUniformLocation(p,"ratio"),r); + var v; if (!va) v = createVA(vtx); else gl.va.bindVertexArrayOESOES(va.r); + gl.drawArrays(tp, 0, (va&&va.tcount)||vtx.length/3); + if (v) destroyVA(v); + } + // Compile the OPNS renderer. (sphere tracing) + var programs = [], genprog = grade=>compile(`${gl2?"#version 300 es":""} + ${gl2?"in":"attribute"} vec4 position; ${gl2?"out":"varying"} vec4 Pos; uniform mat4 mv; uniform mat4 p; + void main() { Pos=mv*position; gl_Position = p*Pos; }`, + `${!gl2?"#extension GL_EXT_frag_depth : enable":"#version 300 es"} + precision highp float; + uniform vec3 color; uniform vec3 color2; + uniform vec3 color3; uniform float b[${counts[grade]}]; + uniform float ratio; ${gl2?"out vec4 col;":""} + ${gl2?"in":"varying"} vec4 Pos; + float product_len (in float z, in float y, in float x, in float[${counts[grade]}] b) { + ${this.nVector(options.up.length>tot?2:1,[])[options.IPNS?"IPNS_GLSL":"OPNS_GLSL"](this.nVector(grade,[]), options.up)} + return sqrt(abs(sum)); + } + vec3 find_root (in vec3 start, vec3 dir, in float thresh) { + vec3 orig=start; + float lastd = 1000.0; + const int count=${(options.maxSteps||80)}; + for (int i=0; i0.0) { + vec3 n = normalize(vec3( + product_len(d2[0]+h,d2[1],d2[2],b)-product_len(d2[0]-h,d2[1],d2[2],b), + product_len(d2[0],d2[1]+h,d2[2],b)-product_len(d2[0],d2[1]-h,d2[2],b), + product_len(d2[0],d2[1],d2[2]+h,b)-product_len(d2[0],d2[1],d2[2]-h,b) + )); + ${gl2?"gl_FragDepth":"gl_FragDepthEXT"} = dl2/50.0; + ${gl2?"col":"gl_FragColor"} = vec4(max(0.2,abs(dot(n,normalize(L-d2))))*color3 + pow(abs(dot(n,normalize(normalize(L-d2)+dir))),100.0),1.0); + } else discard; + }`),genprog2D = grade=>compile(`${gl2?"#version 300 es":""} + ${gl2?"in":"attribute"} vec4 position; ${gl2?"out":"varying"} vec4 Pos; uniform mat4 mv; uniform mat4 p; + void main() { Pos=mv*position; gl_Position = p*Pos; }`, + `${!gl2?"#extension GL_EXT_frag_depth : enable":"#version 300 es"} + precision highp float; + uniform vec3 color; uniform vec3 color2; + uniform vec3 color3; uniform float b[${counts[grade]}]; + uniform float ratio; ${gl2?"out vec4 col;":""} + ${gl2?"in":"varying"} vec4 Pos; + float product_len (in float z, in float y, in float x, in float[${counts[grade]}] b) { + ${this.nVector(1,[])[options.IPNS?"IPNS_GLSL":"OPNS_GLSL"](this.nVector(grade,[]), options.up)} + return sqrt(abs(sum)); + } + void main() { + vec3 p = -5.0*normalize(color2) -Pos[0]/5.0*color + color2 + vec3(0.0,Pos[1]/5.0*ratio,0.0); + float d2 = 1.0 - 150.0*pow(product_len( p[0]*5.0, p[1]*5.0, p[2]*5.0, b),2.0); + if (d2>0.0) { + ${gl2?"col":"gl_FragColor"} = vec4(color3,d2); + } else discard; + }`) + // canvas update will (re)render the content. + var armed=0; + canvas.update = (x)=>{ + // Start by updating canvas size if needed and viewport. + var s = getComputedStyle(canvas); if (s.width) { canvas.width = parseFloat(s.width)*(options.devicePixelRatio||devicePixelRatio||1); canvas.height = parseFloat(s.height)*(options.devicePixelRatio||devicePixelRatio||1); } + gl.viewport(0,0, canvas.width|0,canvas.height|0); var r=canvas.width/canvas.height; + // Defaults, resolve function input + var a,p=[],l=[],t=[],c=[.5,.5,.5],alpha=0,lastpos=[-2,2,0.2]; gl.clear(gl.COLOR_BUFFER_BIT+gl.DEPTH_BUFFER_BIT); while (x.call) x=x(); + // Loop over all items to render. + for (var i=0,ll=x.length;i>>24)&0xff)/255; c[0]=((e>>>16)&0xff)/255; c[1]=((e>>>8)&0xff)/255; c[2]=(e&0xff)/255; } + if (e instanceof Element){ + var tt = options.spin?-performance.now()*options.spin/1000:-options.h||0; tt+=Math.PI/2; var r = canvas.height/canvas.width; + var g=tot-1; while(!e[g]&&g>1) g--; + if (!programs[tot-1-g]) programs[tot-1-g] = (options.up.find(x=>x.match&&x.match("z")))?genprog(g):genprog2D(g); + gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + draw(programs[tot-1-g],gl.TRIANGLES,[-2,-2,0,-2,2,0,2,-2,0,-2,2,0,2,-2,0,2,2,0],[Math.cos(tt),0,-Math.sin(tt)],[Math.sin(tt),0,Math.cos(tt)],undefined,undefined,undefined,e,c,r,g); + gl.disable(gl.BLEND); + } + } + // if we're no longer in the page .. stop doing the work. + armed++; if (document.body.contains(canvas)) armed=0; if (armed==2) return; + canvas.value=x; if (options&&!options.animate) canvas.dispatchEvent(new CustomEvent('input')); + if (options&&options.animate) { requestAnimationFrame(canvas.update.bind(canvas,f,options)); } + if (options&&options.still) { canvas.value=x; canvas.dispatchEvent(new CustomEvent('input')); canvas.im.width=canvas.width; canvas.im.height=canvas.height; canvas.im.src = canvas.toDataURL(); } + } + // Basic mouse interactivity. needs more love. + var sel=-1; canvas.oncontextmenu = canvas.onmousedown = (e)=>{ e.preventDefault(); e.stopPropagation(); sel=-2; + var rc = canvas.getBoundingClientRect(), mx=(e.x-rc.left)/(rc.right-rc.left)*2-1, my=((e.y-rc.top)/(rc.bottom-rc.top)*-4+2)*canvas.height/canvas.width; + canvas.onwheel=e=>{e.preventDefault(); e.stopPropagation(); options.z = (options.z||5)+e.deltaY/100; if (!options.animate) requestAnimationFrame(canvas.update.bind(canvas,f,options));} + canvas.onmouseup=e=>sel=-1; canvas.onmouseleave=e=>sel=-1; + canvas.onmousemove=(e)=>{ + var rc = canvas.getBoundingClientRect(); + var mx =(e.movementX)/(rc.right-rc.left)*2, my=((e.movementY)/(rc.bottom-rc.top)*-2)*canvas.height/canvas.width; + if (sel==-2) { options.h = (options.h||0)+mx; if (!options.animate) requestAnimationFrame(canvas.update.bind(canvas,f,options)); return; }; if (sel < 0) return; + } + } + canvas.value = f.call?f():f; canvas.options = options; + if (options&&options.still) { + var i=new Image(); canvas.im = i; return requestAnimationFrame(canvas.update.bind(canvas,f,options)),i; + } else return requestAnimationFrame(canvas.update.bind(canvas,f,options)),canvas; + + } + + + // webGL Graphing function. (for parametric defined objects) + static graphGL(f,options) { + // Create a canvas, webgl2 context and set some default GL options. + var canvas=document.createElement('canvas'); canvas.style.width=options.width||''; canvas.style.height=options.height||''; canvas.style.backgroundColor='#EEE'; + if (options.width && options.width.match && options.width.match(/px/i)) canvas.width = parseFloat(options.width); if (options.height && options.height.match && options.height.match(/px/i)) canvas.height = parseFloat(options.height); + var gl=canvas.getContext('webgl',{alpha:options.alpha||false,antialias:true,preserveDrawingBuffer:options.still||true,powerPreference:'high-performance'}); + gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); if (!options.alpha) gl.clearColor(240/255,240/255,240/255,1.0); gl.getExtension("OES_standard_derivatives"); gl.va=gl.getExtension("OES_vertex_array_object"); + // Compile vertex and fragment shader, return program. + var compile=(vs,fs)=>{ + var s=[gl.VERTEX_SHADER,gl.FRAGMENT_SHADER].map((t,i)=>{ + var r=gl.createShader(t); gl.shaderSource(r,[vs,fs][i]); gl.compileShader(r); + return gl.getShaderParameter(r, gl.COMPILE_STATUS)&&r||console.error(gl.getShaderInfoLog(r)); + }); + var p = gl.createProgram(); gl.attachShader(p, s[0]); gl.attachShader(p, s[1]); gl.linkProgram(p); + gl.getProgramParameter(p, gl.LINK_STATUS)||console.error(gl.getProgramInfoLog(p)); + return p; + }; + // Create vertex array and buffers, upload vertices and optionally texture coordinates. + var createVA=function(vtx, texc, idx, clr) { + var r = gl.va.createVertexArrayOES(); gl.va.bindVertexArrayOES(r); + var b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vtx), gl.STATIC_DRAW); + gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0); + if (texc){ + var b2=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b2); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(texc), gl.STATIC_DRAW); + gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(1); + } + if (clr){ + var b3=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b3); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(clr), gl.STATIC_DRAW); + gl.vertexAttribPointer(texc?2:1, 3, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(texc?2:1); + } + if (idx) { + var b4=gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, b4); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW); + } + return {r,b,b2,b4,b3} + }, + // Destroy Vertex array and delete buffers. + destroyVA=function(va) { + [va.b,va.b2,va.b4,va.b3].forEach(x=>{if(x) gl.deleteBuffer(x)}); if (va.r) gl.va.deleteVertexArrayOES(va.r); + } + // Default modelview matrix, convert camera to matrix (biquaternion->matrix) + var M=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,5,1], mtx = (x,iscam=true)=>{ var t=options.spin?performance.now()*options.spin/1000:-options.h||0, t2=options.p||0; + var ct = Math.cos(t), st= Math.sin(t), ct2 = Math.cos(t2), st2 = Math.sin(t2), xx=options.posx||0, y=options.posy||0, z=options.posz||0, zoom=options.z||5; + if (tot==5) return [ct,st*-st2,st*ct2,0,0,ct2,st2,0,-st,ct*-st2,ct*ct2,0,xx*ct+z*-st,y*ct2+(xx*st+z*ct)*-st2,y*st2+xx*st+z*ct*ct2+zoom,1]; + x=x.Normalized; var y=x.Mul(x.Dual),X=x.e23,Y=-x.e13,Z=-x.e12,W=x.s; + var xx = X*X, xy = X*Y, xz = X*Z, xw = X*W, yy = Y*Y, yz = Y*Z, yw = Y*W, zz = Z*Z, zw = Z*W; + var mtx = [ 1-2*(yy+zz), 2*(xy+zw), 2*(xz-yw), 0, 2*(xy-zw), 1-2*(xx+zz), 2*(yz+xw), 0, 2*(xz+yw), 2*(yz-xw), 1-2*(xx+yy), 0, -2*y.e23, -2*y.e13, 2*y.e12+(iscam?5:0), 1]; + return mtx; + } + // Render the given vertices. (autocreates/destroys vertex array if not supplied). + var draw=function(p, tp, vtx, color, color2, ratio, texc, va, cbuf, allowcull=true){ + gl.useProgram(p); gl.uniformMatrix4fv(gl.getUniformLocation(p, "mv"),false,M); + gl.uniformMatrix4fv(gl.getUniformLocation(p, "p"),false, [5,0,0,0,0,5*(ratio||2),0,0,0,0,1,2,0,0,-1,0]) + gl.uniform3fv(gl.getUniformLocation(p, "color"),new Float32Array(color)); + gl.uniform3fv(gl.getUniformLocation(p, "color2"),new Float32Array(color2)); + //if (texc) gl.uniform1i(gl.getAttribLocation(p, "texc"),0); + var v; if (!va) v = createVA(vtx, texc, undefined, cbuf, p); else gl.va.bindVertexArrayOES(va.r); + if (options.cull && allowcull) gl.enable(gl.CULL_FACE); + if (va && va.b4) { + gl.drawElements(tp, va.tcount, gl.UNSIGNED_SHORT, 0); + } else { + gl.drawArrays(tp, 0, (va&&va.tcount)||vtx.length/3); + } + if (v) destroyVA(v); + if (options.cull) gl.disable(gl.CULL_FACE); + } + // Program for the geometry. Derivative based normals. Basic lambert shading. + var program = compile(`attribute vec4 position; varying vec4 Pos; uniform mat4 mv; uniform mat4 p; + void main() { gl_PointSize=12.0; Pos=mv*position; gl_Position = p*Pos; }`, + `#extension GL_OES_standard_derivatives : enable + precision highp float; uniform vec3 color; uniform vec3 color2; varying vec4 Pos; + void main() { vec3 ldir = normalize(Pos.xyz - vec3(2.0,2.0,-4.0)); + vec3 normal = normalize(cross(dFdx(Pos.xyz), dFdy(Pos.xyz))); float l=dot(normal,ldir); + vec3 E = normalize(-Pos.xyz); vec3 R = normalize(reflect(ldir,normal)); + gl_FragColor = vec4(max(0.0,l)*color+vec3(0.5*pow(max(dot(R,E),0.0),20.0))+color2, 1.0); }`); + var programSphere = compile(`attribute vec4 position; varying vec4 Pos; varying vec3 N; uniform mat4 mv; uniform mat4 p; + void main() { gl_PointSize=12.0; Pos=mv*position; N = normalize(position.xzy); gl_Position = p*Pos; }`, + `#extension GL_OES_standard_derivatives : enable + precision highp float; uniform vec3 color; uniform vec3 color2; varying vec4 Pos; varying vec3 N; + void main() { vec3 ldir = normalize(Pos.xyz - vec3(2.0,2.0,-4.0)); + vec3 normal = N; float l=dot(normal,ldir); + vec3 E = normalize(-Pos.xyz); vec3 R = normalize(reflect(ldir,normal)); + gl_FragColor = vec4(max(0.0,l)*color+vec3(0.5*pow(max(dot(R,E),0.0),20.0))+color2, 1.0); }`); + var programPoint = compile(`attribute vec4 position; varying vec4 Pos; uniform mat4 mv; uniform mat4 p; + void main() { gl_PointSize=${((options.pointRadius||1)*(options.devicePixelRatio||devicePixelRatio||1)*8.0).toFixed(2)}; Pos=mv*position; gl_Position = p*Pos; }`, + `precision highp float; uniform vec3 color; uniform vec3 color2; varying vec4 Pos; + void main() { float distanceToCenter = length(gl_PointCoord - vec2(0.5)); if (distanceToCenter>0.5) discard; + gl_FragColor = vec4(color+color2, (distanceToCenter<0.5?1.0:0.0)); }`); + var programline = compile(` + attribute vec4 position; // current point. + attribute vec2 texc; // x = +w or -w, alternating. y = opacity. + attribute vec4 col; // next point. (extrapolated for end point) + uniform vec3 color; // r=aspect g=thickness + uniform mat4 mv,p; // modelview and projection matrix + varying vec2 tc; + void main() { + // Convert to clipspace. + vec4 cp = p*mv*vec4(position.xyz,1.0); + vec2 cs = cp.xy / abs(cp.w); + vec4 np = p*mv*vec4(col.xyz,1.0); + vec2 ns = np.xy / abs(np.w); + // compensate aspect + cs.x *= color.r; + ns.x *= color.r; + // clipspace line direction. + vec2 dir = normalize(cs-ns); + // Calculate screenspace normal. + vec2 normal = vec2( -dir.y, dir.x); + // Line scaling and aspect fix. + normal *= color.g * 5.0; + normal.x /= color.r; + // Pass through texture coordinates for edge softening + tc = vec2(texc.x / abs(texc.x), texc.y); + gl_Position = cp + vec4(normal*texc.x,0.0,0.0); + }`, + `precision highp float; + uniform vec3 color2; + varying vec2 tc; + void main() { +// gl_FragColor = vec4(abs(tc.x),abs(tc.x),abs(tc.x),1.0-abs(tc.x)); + gl_FragColor = vec4(color2,(1.0-pow(abs(tc.x),2.0))*tc.y); + }`); + var programcol = compile(`attribute vec4 position; attribute vec3 col; varying vec3 Col; varying vec4 Pos; uniform mat4 mv; uniform mat4 p; + void main() { gl_PointSize=6.0; Pos=mv*position; gl_Position = p*Pos; Col=col; }`, + `#extension GL_OES_standard_derivatives : enable + precision highp float; uniform vec3 color; uniform vec3 color2; varying vec4 Pos; varying vec3 Col; + void main() { vec3 ldir = normalize(Pos.xyz - vec3(1.0,1.0,2.0)); + vec3 normal = normalize(cross(dFdx(Pos.xyz), dFdy(Pos.xyz))); float l=dot(normal,ldir); + vec3 E = normalize(-Pos.xyz); vec3 R = normalize(reflect(ldir,normal)); + gl_FragColor = vec4(max(0.3,l)*Col+vec3(pow(max(dot(R,E),0.0),20.0))+color2, 1.0); ${options.shader||''} }`); + var programmot = compile(`attribute vec4 position; attribute vec2 texc; attribute vec3 col; varying vec3 Col; varying vec4 Pos; uniform mat4 mv; uniform mat4 p; uniform vec3 color2; + void main() { gl_PointSize=2.0; float blend=fract(color2.x+texc.r)*0.5; Pos=mv*(position*(1.0-blend) + (blend)*vec4(col,1.0)); gl_Position = p*Pos; Col=vec3(length(col-position.xyz)*1.); gl_PointSize = 8.0 - Col.x; Col.y=sin(blend*2.*3.1415); }`, + `precision highp float; uniform vec3 color; uniform vec3 color2; varying vec4 Pos; varying vec3 Col; + void main() { float distanceToCenter = length(gl_PointCoord - vec2(0.5));gl_FragColor = vec4(1.0-pow(Col.x,2.0),0.0,0.0,(.6-Col.x*0.05)*(distanceToCenter<0.5?1.0:0.0)*Col.y); }`); + gl.lineWidth(options.lineWidth||1); // doesn't work yet (nobody supports it) + // Create a font texture, lucida console or otherwise monospaced. + var fw=33, font = Object.assign(document.createElement('canvas'),{width:(19+94)*fw,height:48}), + ctx = Object.assign(font.getContext('2d'),{font:'bold 48px lucida console, monospace'}), + ftx = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, ftx); + for (var i=33; i<127; i++) ctx.fillText(String.fromCharCode(i),(i-33)*fw,40); + var specialChars = "∞≅¹²³₀₁₂₃₄₅₆₇₈₉⋀⋁∆⋅"; specialChars.split('').forEach((x,i)=>ctx.fillText(x,(i-33+127)*fw,40)); + // 2.0 gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,94*fw,32,0,gl.RGBA,gl.UNSIGNED_BYTE,font); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, font); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + // Font rendering program. Renders billboarded fonts, transforms offset passed as color2. + var program2 = compile(`attribute vec4 position; attribute vec2 texc; varying vec2 tex; varying vec4 Pos; uniform mat4 mv; uniform mat4 p; uniform vec3 color2; + void main() { tex=texc; gl_PointSize=6.0; vec4 o=mv*vec4(color2,0.0); Pos=(-1.0/(o.z-mv[3][2]))*position+vec4(mv[3][0],mv[3][1],mv[3][2],0.0)+o; gl_Position = p*Pos; }`, + `precision highp float; uniform vec3 color; varying vec4 Pos; varying vec2 tex; + uniform sampler2D texm; void main() { vec4 c = texture2D(texm,tex); if (c.a<0.01) discard; gl_FragColor = vec4(color,c.a);}`); + // Helpers for line drawing. Convert line segments to triangles. + const line_to_tri = ([ax,ay,az,bx,by,bz]) => [ax,ay,az,ax,ay,az,bx,by,bz,bx,by,bz,ax,ay,az,bx,by,bz]; + const line_to_tri2 = ([ax,ay,az,bx,by,bz]) => [bx,by,bz,bx,by,bz,2*bx-ax,2*by-ay,2*bz-az,2*bx-ax,2*by-ay,2*bz-az,bx,by,bz,2*bx-ax,2*by-ay,2*bz-az]; + // Conformal space needs a bit extra magic to extract euclidean parametric representations. + if (tot==5 && options.conformal) var ni = Element.Coeff(4,1).Add(Element.Coeff(5,1)), no = Element.Coeff(4,0.5).Sub(Element.Coeff(5,0.5)); + var interprete = (x)=>{ + if (!(x instanceof Element)) return { tp:0 }; + if (options.ipns) x=x.Dual; + // tp = { 0:unknown 1:point 2:line, 3:plane, 4:circle, 5:sphere + var X2 = (x.Mul(x)).s, tp=0, weight2, opnix = ni.Wedge(x), ipnix = ni.LDot(x), + attitude, pos, normal, tg,btg,epsilon = 0.000001/(options.scale||1), I3=Element.Coeff(16,-1); + var x2zero = Math.abs(X2) < epsilon, ipnixzero = ipnix.VLength < epsilon, opnixzero = opnix.VLength < epsilon; + if (opnixzero && ipnixzero) { // free flat + } else if (opnixzero && !ipnixzero) { // bound flat (lines) + attitude = no.Wedge(ni).LDot(x); + weight2 = Math.abs(attitude.LDot(attitude).s)**.5; + pos = attitude.LDot(x.Reverse); //Inverse); + pos = [-pos.e15/pos.e45,-pos.e25/pos.e45,-pos.e34/pos.e45]; + if (x.Grade(3).VLength) { + normal = [attitude.e1/weight2,attitude.e2/weight2,attitude.e3/weight2]; tp=2; + } else if (x.Grade(2).VLength) { // point pair with ni + tp = 1; + } else { + normal = Element.LDot(Element.Mul(attitude,1/weight2),I3).Normalized; + var r=normal.Mul(Element.Coeff(3,1)); if (r[0]==-1) r[0]=1; else {r[0]+=1; r=r.Normalized;} + tg = [...r.Mul(Element.Coeff(1,1)).Mul(r.Conjugate)].slice(1,4); + btg = [...r.Mul(Element.Coeff(2,1)).Mul(r.Conjugate)].slice(1,4); + normal = [...normal.slice(1,4)]; tp=3; + } + } else if (!opnixzero && ipnixzero) { // dual bound flat + } else if (x2zero) { // bound vec,biv,tri (points) + if (options.ipns) x=x.Dual; + attitude = ni.Wedge(no).LDot(ni.Wedge(x)); + pos = [...(Element.LDot(1/(ni.LDot(x)).s,x)).slice(1,4)].map(x=>-x); + tp=1; + } else if (!x2zero) { // round (point pair,circle,sphere) + tp = x.Grade(3).VLength?4:x.Grade(2).VLength?6:5; + var nix = ni.Wedge(x), nix2 = (nix.Mul(nix)).s; + attitude = ni.Wedge(no).LDot(nix); + pos = [...(x.Mul(ni).Mul(x)).slice(1,4)].map(x=>-x/(2.0*nix2)); + weight2 = Math.abs((x.LDot(x)).s / nix2)**.5; + if (tp==4) { + if (x.LDot(x).s < 0) { weight2 = -weight2; } + normal = Element.LDot(Element.Mul(attitude,1/weight2),I3).Normalized; + var r=normal.Mul(Element.Coeff(3,1)); if (r[0]==-1) r[0]=1; else {r[0]+=1; r=r.Normalized;} + tg = [...r.Mul(Element.Coeff(1,1)).Mul(r.Conjugate)].slice(1,4); + btg = [...r.Mul(Element.Coeff(2,1)).Mul(r.Conjugate)].slice(1,4); + normal = [...normal.slice(1,4)]; + } else if (tp==6) { + weight2 = (x.LDot(x).s < 0)?-(weight2):weight2; + normal = Element.Mul(attitude.Normalized,weight2).slice(1,4); + } else { + normal = [...((Element.LDot(Element.Mul(attitude,1/weight2),I3)).Normalized).slice(1,4)]; + } + } + return {tp,pos:pos?pos.map(x=>x*(options.scale||1)):[0,0,0],normal,tg,btg,weight2:weight2*(options.scale||1)} + }; + // canvas update will (re)render the content. + var armed=0,sphere,e14 = Element.Coeff(14,1); + canvas.update = (x)=>{ + if (!canvas.parentElement) return; + // restore from still.. + if (options && !options.still && canvas.im && canvas.im.parentElement) { canvas.im.parentElement.insertBefore(canvas,canvas.im); canvas.im.parentElement.removeChild(canvas.im); } + // Start by updating canvas size if needed and viewport. + var s = getComputedStyle(canvas); if (s.width) { canvas.width = parseFloat(s.width)*(options.devicePixelRatio||devicePixelRatio||1); canvas.height = parseFloat(s.height)*(options.devicePixelRatio||devicePixelRatio||1); } + gl.viewport(0,0, canvas.width|0,canvas.height|0); var r=canvas.width/canvas.height; + // Defaults, resolve function input + var a,p=[],l=[],t=[],c=[.5,.5,.5],alpha=0,lastpos=[-1.95,1.5,0,1]; gl.clear(gl.COLOR_BUFFER_BIT+gl.DEPTH_BUFFER_BIT); while (x.call) x=x(); + // Create default camera matrix and initial lastposition (contra-compensated for camera) + M = mtx(options.camera); + var a = new this(); a.set([1,-2,1.90*canvas.height/canvas.width,0],1); a = options.camera.Conjugate.Mul(a.Dual).Mul(options.camera); + lastpos = a.slice(11,14).map((y,i)=>(i<=1?1:-1)*y/a[14]).reverse(); + var linediff = new this(); linediff.set([0,0,-0.12*2000/canvas.width*(options.fontSize||1),0],1); + linediff = options.camera.Conjugate.Mul(linediff.Dual).Mul(options.camera).slice(11,14).map((y,i)=>(i<=1?1:-1)*y/a[14]).reverse(); + // Grid. + if (options.grid) { + const gr = options.gridSize||1; + if (!options.gridLines) { options.gridLines=[[],[],[]]; for (var i=-gr; i<=gr; i+=gr/10) { + options.gridLines[0].push(i,-gr,gr, i,-gr,-gr, gr,-gr,i, -gr,-gr,i); + options.gridLines[1].push(i,gr,gr, i,-gr,gr, gr,i,gr, -gr,i,gr); + options.gridLines[2].push(-gr,i,gr, -gr,i,-gr, -gr,gr,i, -gr,-gr,i); + }} + var ltest = [], ltest2 = [], ttest = []; for (var j=0; j<3; ++j) for (var i=0; i{ while (x.call) x=x.call(); x=interprete(x);l.push.apply(l,x.pos); }); var d = {tp:-1}; } + else if (e instanceof Array && e.length==3) { e.forEach(x=>{ while (x.call) x=x.call(); x=interprete(x);t.push.apply(t,x.pos); }); var d = {tp:-1}; } + else var d = interprete(e); + if (d.tp) lastpos=d.pos; + if (d.tp==1) p.push.apply(p,d.pos); + if (d.tp==2) { l.push.apply(l,d.pos.map((x,i)=>x-d.normal[i]*3)); l.push.apply(l,d.pos.map((x,i)=>x+d.normal[i]*3)); } + if (d.tp==3) { t.push.apply(t,d.pos.map((x,i)=>x+d.tg[i]+d.btg[i])); t.push.apply(t,d.pos.map((x,i)=>x-d.tg[i]+d.btg[i])); t.push.apply(t,d.pos.map((x,i)=>x+d.tg[i]-d.btg[i])); + t.push.apply(t,d.pos.map((x,i)=>x-d.tg[i]+d.btg[i])); t.push.apply(t,d.pos.map((x,i)=>x+d.tg[i]-d.btg[i])); t.push.apply(t,d.pos.map((x,i)=>x-d.tg[i]-d.btg[i])); } + if (d.tp==4) { + var ne=0,la=0; + if (d.weight2<0) { c[0]=1;c[1]=0;c[2]=0; } + for (var j=0; j<65; j++) { + ne = d.pos.map((x,i)=>x+Math.cos(j/32*Math.PI)*d.weight2*d.tg[i]+Math.sin(j/32*Math.PI)*d.weight2*d.btg[i]); if (ne&&la&&(d.weight2>0||j%2==0)) { l.push.apply(l,la); l.push.apply(l,ne); }; la=ne; + } + } + if (d.tp==6) { + if (d.weight2<0) { c[0]=1;c[1]=0;c[2]=0; } + if (options.useUnnaturalLineDisplayForPointPairs) { + l.push.apply(l,d.pos.map((x,i)=>x-d.normal[i]*(options.scale||1))); + l.push.apply(l,d.pos.map((x,i)=>x+d.normal[i]*(options.scale||1))); + } + p.push.apply(p,d.pos.map((x,i)=>x-d.normal[i]*(options.scale||1))); + p.push.apply(p,d.pos.map((x,i)=>x+d.normal[i]*(options.scale||1))); + } + if (d.tp==5) { + if (!sphere) { + var pnts = [], tris=[], S=Math.sin, C=Math.cos, pi=Math.PI, W=96, H=48; + for (var j=0; jx.s); + gl.enable(gl.BLEND); gl.blendFunc(gl.CONSTANT_ALPHA, gl.ONE_MINUS_CONSTANT_ALPHA); gl.blendColor(1,1,1,1-(alpha||0.1)); gl.enable(gl.CULL_FACE) + draw(programSphere,gl.TRIANGLES,undefined,c,[0,0,0],r,undefined,sphere.va); + gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); + M = oldM; + } + if (i==ll-1 || d.tp==0) { + // render triangles, lines, points. + if (alpha) { gl.enable(gl.BLEND); gl.blendFunc(gl.CONSTANT_ALPHA, gl.ONE_MINUS_CONSTANT_ALPHA); gl.blendColor(1,1,1,1-alpha); } + if (t.length) { draw(program,gl.TRIANGLES,t,c,[0,0,0],r); t.forEach((x,i)=>{ if (i%9==0) lastpos=[0,0,0]; lastpos[i%3]+=x/3; }); t=[]; } + if (l.length) { + var ltest = [], ltest2 = [], ttest = []; for (var li=0; lix%(countx*county))); e.va3.tcount = (countx-1)*county*2*3; + } + if ( e.call && e.length==1 && !e.va2) { var countx=e.dx||256; + var temp=new Float32Array(3*countx),o=new Float32Array(3),et=[]; + for (var ii=0; ii{ + if (e instanceof Array && e.length==3) { tc++; e.forEach(x=>{ while (x.call) x=x.call(); x=interprete(x);et3.push.apply(et3,x.pos); }); var d = {tp:-1}; } + else { + var d = interprete(e); + if (d.tp==1) { pc++; et.push(...d.pos); } + if (d.tp==2) { lc++; et2.push(...d.pos.map((x,i)=>x-d.normal[i]*10),...d.pos.map((x,i)=>x+d.normal[i]*10)); } + } + }); + e.va = createVA(et,undefined); e.va.tcount = pc; + e.va2 = createVA(et2,undefined); e.va2.tcount = lc*2; + e.va3 = createVA(et3,undefined); e.va3.tcount = tc*3; + } + // render the vertex array. + if (e.va && e.va.tcount) { gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); draw(programPoint,gl.POINTS,undefined,[0,0,0],c,r,undefined,e.va); gl.disable(gl.BLEND); }; + if (e.va2 && e.va2.tcount) draw(program,gl.LINES,undefined,[0,0,0],c,r,undefined,e.va2); + if (e.va3 && e.va3.tcount) draw(program,gl.TRIANGLES,undefined,c,[0,0,0],r,undefined,e.va3); + } + if (alpha) gl.disable(gl.BLEND); // no alpha for text printing. + // setup a new color + if (typeof e == "number") { alpha=((e>>>24)&0xff)/255; c[0]=((e>>>16)&0xff)/255; c[1]=((e>>>8)&0xff)/255; c[2]=(e&0xff)/255; } + if (typeof(e)=='string') { + if (options.htmlText) { + if (!x['_'+i]) { console.log('creating div'); Object.defineProperty(x,'_'+i, {value: document.body.appendChild(document.createElement('div')), enumerable:false }) }; + var rc = canvas.getBoundingClientRect(), div = x['_'+i]; + var pos2 = Element.Mul( [[M[0],M[4],M[8],M[12]],[M[1],M[5],M[9],M[13]],[M[2],M[6],M[10],M[14]],[M[3],M[7],M[11],M[15]]], [...lastpos,1]).map(x=>x.s); + pos2 = Element.Mul( [[5,0,0,0],[0,5*(r||2),0,0],[0,0,1,-1],[0,0,2,0]], pos2).map(x=>x.s).map((x,i,a)=>x/a[3]); + Object.assign(div.style,{position:'fixed',pointerEvents:'none',left:rc.left + (rc.right-rc.left)*(pos2[0]/2+0.5),top: rc.top + (rc.bottom-rc.top)*(-pos2[1]/2+0.5) - 20}); + if (div.last != e) { div.innerHTML = e; div.last = e; if (self.renderMathInElement) self.renderMathInElement(div); } + } else { + gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); + var fw = 113, mapChar = (x)=>{ var c = x.charCodeAt(0)-33; if (c>=94) c = 94+specialChars.indexOf(x); return c/fw; } + draw(program2,gl.TRIANGLES, + [...Array(e.length*6*3)].map((x,i)=>{ var x=0,z=-0.2, o=x+(i/18|0)*1.1; return (0.05*(options.z||5))*[o,-1,z,o+1.2,-1,z,o,1,z,o+1.2,-1,z,o+1.2,1,z,o,1,z][i%18]}),c,lastpos,r, + [...Array(e.length*6*2)].map((x,i)=>{ var o=mapChar(e[i/12|0]); return [o,1,o+1/fw,1,o,0,o+1/fw,1,o+1/fw,0,o,0][i%12]})); gl.disable(gl.BLEND); lastpos[1]+=linediff[1]; lastpos[0]+=linediff[0]; lastpos[2]+=linediff[2]; + } + } + } + continue; + } + // PGA + if (options.dual && e instanceof Element) e = e.Dual; + // Convert planes to polygons. + if (e instanceof Element && e.Grade(1).Length > 0.001) { + var m = Element.Add(1, Element.Mul(e.Normalized, Element.Coeff(3,1))).Normalized, e0 = 0; + e=Element.sw(m,[[-1,-1],[-1,1],[1,1],[-1,-1],[1,1],[1,-1]].map(([x,z])=>Element.Trivector(x*e.Length,e0,z*e.Length,1))); + } + // Convert lines to line segments. + if (e instanceof Element && e.Grade(2).Length) + e=[e.LDot(e14).Wedge(e).Add(e.Wedge(Element.Coeff(1,1)).Mul(Element.Coeff(0,-(options.clip||3)))),e.LDot(e14).Wedge(e).Add(e.Wedge(Element.Coeff(1,1)).Mul(Element.Coeff(0,options.clip||3)))] + .map(x=>x[14]<0?x.Scale(-1):x); + // If euclidean point, store as point, store line segments and triangles. + if (e.e123) p.push.apply(p,e.slice(11,14).map((y,i)=>(i<=1?1:-1)*y/e[14]).reverse()); + if (e instanceof Array && e.length==2) l=l.concat.apply(l,e.map(x=>[...x.slice(11,14).map((y,i)=>(i<=1?1:-1)*y/x[14]).reverse()])); + if (e instanceof Array && e.length%3==0) t=t.concat.apply(t,e.map(x=>[...x.slice(11,14).map((y,i)=>(i<=1?1:-1)*y/x[14]).reverse()])); + // Render orbits of parametrised motors, as well as lists of points.. + function sw_mot_orig(A,R){ + var a0=A[0],a1=A[5],a2=A[6],a3=A[7],a4=A[8],a5=A[9],a6=A[10],a7=A[15]; + R[2] = -2*(a0*a3+a4*a7-a6*a2-a5*a1); + R[1] = -2*(a4*a1-a0*a2-a6*a3+a5*a7); + R[0] = 2*(a0*a1+a4*a2+a5*a3+a6*a7); + return R + } + if ( e.call && e.length==1) { var count=e.dx||64; + for (var ismot,xx,o=new Float32Array(3),ii=0; ii1) l.push(xx[0],xx[1],xx[2]); + var m = e(ii/(count-1)); + if (ii==0) ismot = m[0]||m[5]||m[6]||m[7]||m[8]||m[9]||m[10]; + xx = ismot?sw_mot_orig(m,o):m.slice(11,14).map((y,i)=>(i<=1?1:-1)*y).reverse(); //Element.sw(e(ii/(count-1)),o); + l.push(xx[0],xx[1],xx[2]); + } + } + if ( e.call && e.length==2 && !e.va) { var countx=e.dx||64,county=e.dy||32; + var temp=new Float32Array(3*countx*county),o=new Float32Array(3),et=[]; + for (var pp=0,ii=0; iix%(countx*county))); e.va.tcount = (countx-1)*county*2*3; + } + // Experimental display of motors using particle systems. + if (e instanceof Object && e.motor) { + if (!e.va || e.recalc) { + var seed = 1; function random() { var x = Math.sin(seed++) * 10000; return x - Math.floor(x); } + e.xRange = e.xRange === undefined ? 1:e.xRange; e.yRange = e.yRange === undefined ? 1:e.yRange; e.zRange = e.zRange === undefined ? 1:e.zRange; + var vtx=[], tx=[], vtx2=[]; + for (var i=0; i<(e.zRange===0?5000:60000); i++) { + var p = Element.Trivector(random()*(2*e.xRange)-e.xRange,random()*2*e.yRange-e.yRange,random()*2*e.zRange-e.zRange,1); +// var p2 = Element.sw(e.motor,p); + var p2 = e.motor.Mul(p).Mul(e.motor.Inverse); + tx.push(random(), random()); + vtx.push(...p.slice(11,14).reverse()); vtx2.push(...p2.slice(11,14).reverse()); + } + e.va = createVA(vtx,tx,undefined,vtx2); e.va.tcount = vtx.length/3; + e.recalc = false; + } + var time = performance.now()/1000; + gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.disable(gl.DEPTH_TEST); + draw(programmot, gl.POINTS,t,c,[time%1,0,0],r,undefined,e.va); + gl.disable(gl.BLEND); gl.enable(gl.DEPTH_TEST); + } + // we could also be an object with cached vertex array of triangles .. + else if (e.va || (e instanceof Object && e.data)) { + // Create the vertex array and store it for re-use. + if (!e.va) { + if (e.idx) { + var et = e.data.map(x=>[...x.slice(11,14).map((y,i)=>(i<=1?1:-1)*y/x[14]).reverse()]).flat(); + } else { + var et=[]; e.data.forEach(e=>{if (e instanceof Array && e.length==3) et=et.concat.apply(et,e.map(x=>[...x.slice(11,14).map((y,i)=>(i<=1?1:-1)*y/x[14]).reverse()]));}); + } + e.va = createVA(et,undefined,e.idx,e.color?new Float32Array(e.color):undefined); e.va.tcount = (e.idx && e.idx.length)?e.idx.length:e.data.length*3; + } + // render the vertex array. + var M5 = Element.Scalar(1).Add(Element.Coeff(7,2.5)); + if (e.transform) { + var M1 = mtx(e.transform, false); + var M2 = mtx(M5.Mul(options.camera), false); + M = Array(16).fill(0); + for (var ii=0; ii<4; ++ii) for (var jj=0; jj<4; ++jj) for (var kk=0; kk<4; ++kk) M[ii*4+kk] += M1[ii*4+jj] * M2[jj*4+kk]; + } + if (alpha) { gl.enable(gl.BLEND); gl.blendFunc(gl.CONSTANT_ALPHA, gl.ONE_MINUS_CONSTANT_ALPHA); gl.blendColor(1,1,1,1-alpha); } + draw(e.color?programcol:program,gl.TRIANGLES,t,c,[0,0,0],r,undefined,e.va); + if (alpha) gl.disable(gl.BLEND); + if (e.transform) { M=mtx(options.camera); } + } + // if we're a number (color), label or the last item, we output the collected items. + else if (typeof e=='number' || i==ll-1 || typeof e == 'string') { + // render triangles, lines, points. + if (alpha) { gl.enable(gl.BLEND); gl.blendFunc(gl.CONSTANT_ALPHA, gl.ONE_MINUS_CONSTANT_ALPHA); gl.blendColor(1,1,1,1-alpha); } + if (t.length) { draw(program,gl.TRIANGLES,t,c,[0,0,0],r); t.forEach((x,i)=>{ if (i%9==0) lastpos=[0,0,0]; lastpos[i%3]+=x/3; }); t=[]; } + if (l.length) { + var ltest = [], ltest2 = [], ttest = [], w = (options.lineWidth||1); for (var li=0; li>>24)&0xff)/255; c[0]=((e>>>16)&0xff)/255; c[1]=((e>>>8)&0xff)/255; c[2]=(e&0xff)/255; } + // render a label + if (typeof(e)=='string') { + if (options.htmlText) { + if (!canvas['_'+i]) { console.log('creating div'); Object.defineProperty(canvas,'_'+i, {value: document.body.appendChild(document.createElement('div')), enumerable:false }) }; + var rc = canvas.getBoundingClientRect(), div = canvas['_'+i]; + var pos2 = Element.Mul( [[M[0],M[4],M[8],M[12]],[M[1],M[5],M[9],M[13]],[M[2],M[6],M[10],M[14]],[M[3],M[7],M[11],M[15]]], [...lastpos,1]).map(x=>x.s); + pos2 = Element.Mul( [[5,0,0,0],[0,5*(r||2),0,0],[0,0,1,-1],[0,0,2,0]], pos2).map(x=>x.s).map((x,i,a)=>x/a[3]); + Object.assign(div.style,{position:'fixed',pointerEvents:'none',left:rc.left + (rc.right-rc.left)*(pos2[0]/2+0.5),top: rc.top + (rc.bottom-rc.top)*(-pos2[1]/2+0.5) - 20}); + if (div.last != e) { div.innerHTML = e; div.last = e; if (self.renderMathInElement) self.renderMathInElement(div,{output:'html'}); } + } else { + gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); gl.disable(gl.DEPTH_TEST); + var fw = 113, mapChar = (x)=>{ var c = x.charCodeAt(0)-33; if (c>=94) c = 94+specialChars.indexOf(x); return c/fw; } + draw(program2,gl.TRIANGLES, + [...Array(e.length*6*3)].map((x,i)=>{ var x=0,z=0.2, o=x+(i/18|0)*1.1; return 0.2*(options.fontSize||1)*2000/canvas.width*[o,-1,z,o+1.2,-1,z,o,1,z,o+1.2,-1,z,o+1.2,1,z,o,1,z][i%18]}),c,lastpos,r, + [...Array(e.length*6*2)].map((x,i)=>{ var o=mapChar(e[i/12|0]); return [o,1,o+1/fw,1,o,0,o+1/fw,1,o+1/fw,0,o,0][i%12]})); gl.disable(gl.BLEND); lastpos[0] += linediff[0];lastpos[1] += linediff[1];lastpos[2] += linediff[2]; + if (!options.noZ) gl.enable(gl.DEPTH_TEST); + } + } + } + }; + // if we're no longer in the page .. stop doing the work. + armed++; if (document.body.contains(canvas)) armed=0; if (armed==2) return; + canvas.value=x; if (options&&!options.animate) canvas.dispatchEvent(new CustomEvent('input')); canvas.options=options; + if (options&&options.animate) { requestAnimationFrame(canvas.update.bind(canvas,f,options)); } + if (options&&options.still) { canvas.value=x; canvas.dispatchEvent(new CustomEvent('input')); canvas.im.style.width=canvas.style.width; canvas.im.style.height=canvas.style.height; canvas.im.src = canvas.toDataURL(); + var p=canvas.parentElement; if (p) { p.insertBefore(canvas.im,canvas); p.removeChild(canvas); } + } + } + // Basic mouse interactivity. needs more love. + var sel=-1; canvas.oncontextmenu = canvas.onmousedown = (e)=>{e.preventDefault(); e.stopPropagation(); if (e.detail===0) return; + var rc = canvas.getBoundingClientRect(), mx=(e.x-rc.left)/(rc.right-rc.left)*2-1, my=((e.y-rc.top)/(rc.bottom-rc.top)*4-2)*canvas.height/canvas.width; + sel = (e.button==2)?-3:-2; canvas.value.forEach((x,i)=>{ + if (tot != 5) { if (x[14]) { + var pos2 = Element.Mul( [[M[0],M[4],M[8],M[12]],[M[1],M[5],M[9],M[13]],[M[2],M[6],M[10],M[14]],[M[3],M[7],M[11],M[15]]], [-x[13]/x[14],x[12]/x[14],x[11]/x[14],1]).map(x=>x.s); + pos2 = Element.Mul( [[5,0,0,0],[0,-5*(2),0,0],[0,0,1,-1],[0,0,2,0]], pos2).map(x=>x.s).map((x,i,a)=>x/a[3]); + if ((mx-pos2[0])**2 + ((my)-pos2[1])**2 < 0.001) sel=i; + }} else { + x = interprete(x); if (x.tp==1) { + var pos2 = Element.Mul( [[M[0],M[4],M[8],M[12]],[M[1],M[5],M[9],M[13]],[M[2],M[6],M[10],M[14]],[M[3],M[7],M[11],M[15]]], [...x.pos,1]).map(x=>x.s); + pos2 = Element.Mul( [[5,0,0,0],[0,5*(r||2),0,0],[0,0,1,-1],[0,0,2,0]], pos2).map(x=>x.s).map((x,i,a)=>x/a[3]); + if ((mx-pos2[0])**2 + ((-my)-pos2[1])**2 < 0.01) sel=i; + } + } + }); + canvas.onwheel=e=>{e.preventDefault(); e.stopPropagation(); options.z = (options.z||5)+e.deltaY/100; if (!options.animate) requestAnimationFrame(canvas.update.bind(canvas,f,options));} + canvas.onmouseup=e=>sel=-1; canvas.onmouseleave=e=>sel=-1; + var tx,ty; canvas.ontouchstart = (e)=>{e.preventDefault(); canvas.focus(); var x = e.changedTouches[0].pageX, y = e.changedTouches[0].pageY; tx=x; ty=y; } + canvas.ontouchmove = function (e) { e.preventDefault(); + var x = e.changedTouches[0].pageX, y = e.changedTouches[0].pageY, mx = (x-(tx||x))/1000, my = -(y-(ty||y))/1000; tx=x; ty=y; + options.h = (options.h||0)+mx; options.p = Math.max(-Math.PI/2,Math.min(Math.PI/2, (options.p||0)+my)); if (!options.animate) requestAnimationFrame(canvas.update.bind(canvas,f,options)); return; + }; + canvas.onmousemove=(e)=>{ + var rc = canvas.getBoundingClientRect(),x; if (sel>=0) { if (tot==5) x=interprete(canvas.value[sel]); else { x=canvas.value[sel]; x={pos:[-x[13]/x[14],-x[12]/x[14],x[11]/x[14]]}; }} + var mx =(e.movementX)/(rc.right-rc.left)*2, my=((e.movementY)/(rc.bottom-rc.top)*2)*canvas.height/canvas.width; + if (sel==-2) { options.h = (options.h||0)+(options.conformal?-1:1)*mx/2; options.p = Math.max(-Math.PI/2,Math.min(Math.PI/2, (options.p||0)-my/2)); if (options.camera) options.camera.set( ( Element.Bivector(0,0,0,0,0,options.p).Exp() ).Mul( Element.Bivector(0,0,0,0,options.h,0).Exp() )); if (!options.animate) requestAnimationFrame(canvas.update.bind(canvas,f,options)); return; }; + if (sel==-3) { var ct = Math.cos(options.h||0), st= Math.sin(options.h||0), ct2 = Math.cos(options.p||0), st2 = Math.sin(options.p||0); + if (e.shiftKey) { options.posy = (options.posy||0)+my; } else { options.posx = (options.posx||0)+mx*ct+my*st; options.posz = (options.posz||0)+mx*-st+my*ct*ct2; } if (!options.animate) requestAnimationFrame(canvas.update.bind(canvas,f,options));return; }; if (sel < 0) return; + if (tot==5) { + x.pos[0] += (e.buttons!=2)?Math.cos((options.h||0))*mx:Math.sin(-(options.h||0))*-my; x.pos[1]+=(e.buttons!=2)?-my:0; x.pos[2]+=(e.buttons!=2)?Math.sin((options.h||0))*mx:Math.cos(-(options.h||0))*-my; + canvas.value[sel].set(Element.Mul(ni,(x.pos[0]**2+x.pos[1]**2+x.pos[2]**2)*0.5).Sub(no)); canvas.value[sel].set(x.pos,1); } + else if (x) { + var [cw,ch] = [rc.width, rc.height]; + var ox = (1/(options.scale || 1)) * ((e.offsetX / cw) - 0.5); + var oy = (1/(options.scale || 1)) * ((e.offsetY / ch) - 0.5) * (ch/cw); + var tb = Element.sw(options.camera,canvas.value[sel]); + var z = -(tb.e012/tb.e123+5)/5*4; tb.e023 = ox*z*tb.e123; tb.e013 = oy*z*tb.e123; + canvas.value[sel].set(Element.sw(options.camera.Reverse, tb)); + } + if (!options.animate) requestAnimationFrame(canvas.update.bind(canvas,f,options)); + } + } + canvas.value = f.call?f():f; canvas.options=options; + if (options&&options.still) { + var i=new Image(); canvas.im = i; return requestAnimationFrame(canvas.update.bind(canvas,f,options)),canvas; + } else return requestAnimationFrame(canvas.update.bind(canvas,f,options)),canvas; + } + + // The inline function is a js to js translator that adds operator overloading and algebraic literals. + // It can be called with a function, a string, or used as a template function. + static inline(intxt) { + // If we are called as a template function. + if (arguments.length>1 || intxt instanceof Array) { + var args=[].slice.call(arguments,1); + return res.inline(new Function(args.map((x,i)=>'_template_'+i).join(),'return ('+intxt.map((x,i)=>(x||'')+(args[i]&&('_template_'+i)||'')).join('')+')')).apply(res,args); + } + // Get the source input text. + var txt = (intxt instanceof Function)?intxt.toString():`function(){return (${intxt})}`; + // Our tokenizer reads the text token by token and stores it in the tok array (as type/token tuples). + var tok = [], resi=[], t, possibleRegex=false, c, tokens = [/^[\s\uFFFF]|^[\u000A\u000D\u2028\u2029]|^\/\/[^\n]*\n|^\/\*[\s\S]*?\*\//g, // 0: whitespace/comments + /^\"\"|^\'\'|^\".*?[^\\]\"|^\'.*?[^\\]\'|^\`[\s\S]*?[^\\]\`/g, // 1: literal strings + /^\d+[.]{0,1}\d*[ei][\+\-_]{0,1}\d*|^\.\d+[ei][\+\-_]{0,1}\d*|^e_\d*/g, // 2: literal numbers in scientific notation (with small hack for i and e_ asciimath) + /^\d+[.]{0,1}\d*[E][+-]{0,1}\d*|^\.\d+[E][+-]{0,1}\d*|^0x\d+|^\d+[.]{0,1}\d*|^\.\d+/g, // 3: literal hex, nonsci numbers + /^\/.*?[^\\]\/[gmisuy]?/g, // 4: regex + /^(\.Normalized|\.Length|\.\.\.|>>>=|===|!==|>>>|<<=|>>=|=>|\|\||[<>\+\-\*%&|^\/!\=]=|\*\*|\+\+|\-\-|<<|>>|\&\&|\^\^|^[{}()\[\];.,<>\+\-\*%|&^!~?:=\/]{1})/g, // 5: punctuator + /^[$_\p{L}][$_\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\u200C\u200D]*/gu] // 6: identifier + while (txt.length) for (t in tokens) { + if (t == 4 && !possibleRegex) continue; + if (resi = txt.match(tokens[t])) { + c = resi[0]; if (t!=0) {possibleRegex = c == '(' || c == '=' || c == '[' || c == ',' || c == ';';} tok.push([t | 0, c]); txt = txt.slice(c.length); break; + }} // tokenise + // Translate algebraic literals. (scientific e-notation to "this.Coeff" + tok=tok.map(t=>(t[0]==2)?[2,'Element.Coeff('+basis.indexOf((!options.Cayley?simplify:(x)=>x)('e'+t[1].split(/e_|e|i/)[1]||1).replace('-',''))+','+(simplify(t[1].split(/e_|e|i/)[1]||1).match('-')?"-1*":"")+parseFloat(t[1][0]=='e'?1:t[1].split(/e_|e|i/)[0])+')']:t); + // String templates (limited support - needs fundamental changes.). + tok=tok.map(t=>(t[0]==1 && t[1][0]=='`')?[1,t[1].replace(/\$\{(.*?)\}/g,a=>"${"+Element.inline(a.slice(2,-1)).toString().match(/return \((.*)\)/)[1]+"}")]:t); + // We support two syntaxes, standard js or if you pass in a text, asciimath. + var syntax = (intxt instanceof Function)?[[['.Normalized','Normalize',2],['.Length','Length',2]],[['~','Conjugate',1],['!','Dual',1]],[['**','Pow',0,1]],[['^','Wedge'],['&','Vee'],['<<','LDot']],[['*','Mul'],['/','Div']],[['|','Dot']],[['>>>','sw',0,1]],[['-','Sub'],['+','Add']],[['%','%']],[['==','eq'],['!=','neq'],['<','lt'],['>','gt'],['<=','lte'],['>=','gte']]] + :[[['pi','Math.PI'],['sin','Math.sin']],[['ddot','this.Reverse'],['tilde','this.Involute'],['hat','this.Conjugate'],['bar','this.Dual']],[['^','Pow',0,1]],[['^^','Wedge'],['*','LDot']],[['**','Mul'],['/','Div']],[['-','Sub'],['+','Add']],[['<','lt'],['>','gt'],['<=','lte'],['>=','gte']]]; + // For asciimath, some fixed translations apply (like pi->Math.PI) etc .. + tok=tok.map(t=>(t[0]!=6)?t:[].concat.apply([],syntax).filter(x=>x[0]==t[1]).length?[6,[].concat.apply([],syntax).filter(x=>x[0]==t[1])[0][1]]:t); + // Now the token-stream is translated recursively. + function translate(tokens) { + // helpers : first token to the left of x that is not of a type in the skip list. + var left = (x=ti-1,skip=[0])=>{ while(x>=0&&~skip.indexOf(tokens[x][0])) x--; return x; }, + // first token to the right of x that is not of a type in the skip list. + right= (x=ti+1,skip=[0])=>{ while(x{tokens.splice(x,y-x+1,[tp,...(sub||tokens.slice(x,y+1))])}, + // match O-C pairs. returns the 'matching bracket' position + match = (O="(",C=")")=>{var o=1,x=ti+1; while(o){if(tokens[x][1]==O)o++;if(tokens[x][1]==C)o--; x++;}; return x-1;}; + // grouping (resolving brackets). + for (var ti=0,t,si;t=tokens[ti];ti++) if (t[1]=="(") glue(ti,si=match(),7,[[5,"("],...translate(tokens.slice(ti+1,si)),[5,")"]]); + // [] dot call and new + for (var ti=0,t,si; t=tokens[ti];ti++) { + if (t[1]=="[") { glue(ti,si=match("[","]"),7,[[5,"["],...translate(tokens.slice(ti+1,si)),[5,"]"]]); if (ti)ti--;} // matching [] + else if (t[1]==".") { glue(left(),right()); ti--; } // dot operator + else if (t[0]==7 && ti && left()>=0 && tokens[left()][0]>=6 && tokens[left()][1]!="return") { glue(left(),ti--) } // collate ( and [ + else if (t[1]=='new') { glue(ti,right()) }; // collate new keyword + } + // ++ and -- + for (var ti=0,t; t=tokens[ti];ti++) if (t[1]=="++" || t[1]=="--") glue(left(),ti); + // unary - and + are handled separately from syntax .. + for (var ti=0,t,si; t=tokens[ti];ti++) + if (t[1]=="-" && (left()<0 || (tokens[left()]||[])[1]=='return'||(tokens[left()]||[5])[0]==5)) glue(ti,right(),6,["Element.Sub(",tokens[right()],")"]); // unary minus works on all types. + else if (t[1]=="+" && (left()<0 || (tokens[left()]||[])[1]=='return'|| (tokens[left()]||[0])[0]==5 && (tokens[left()]||[0])[1][0]!=".")) glue(ti,ti+1); // unary plus is glued, only on scalars. + // now process all operators in the syntax list .. + for (var si=0,s; s=syntax[si]; si++) for (var ti=s[0][3]?tokens.length-1:0,t; t=tokens[ti];s[0][3]?ti--:ti++) for (var opi=0,op; op=s[opi]; opi++) if (t[1]==op[0]) { + // exception case .. ".Normalized" and ".Length" properties are re-routed (so they work on scalars etc ..) + if (op[2]==2) { var arg=tokens[left()]; glue(ti-1,ti,6,["Element."+op[1],"(",arg,")"]); } + // unary operators (all are to the left) + else if (op[2]) { var arg=tokens[right()]; glue(ti, right(), 6, ["Element."+op[1],"(",arg,")"]); } + // binary operators + else { var l=left(),r=right(),a1=tokens[l],a2=tokens[r]; if (op[0]==op[1]) glue(l,r,6,[a1,op[1],a2]); else glue(l,r,6,["Element."+op[1],"(",a1,",",a2,")"]); ti-=2; } + } + return tokens; + } + // Glue all back together and return as bound function. + return eval( ('('+(function f(t){return t.map(t=>t instanceof Array?f(t):typeof t == "string"?t:"").join('');})(translate(tok))+')') ); + } + } + + if ((p==2 || p==3) && (r==1)) { + res.arrow = res.inline(( from_point, to_point, w=0.03, aspect=0.8, camera=1 )=>{ + from_point = from_point/(-from_point|!1e0); to_point = to_point/(-to_point|!1e0); + var line = ( from_point & to_point ), l = line.Length; + var shape = [[0,w],[l-5*w,w],[l-5*w,aspect*5*w],[l,0],[l-5*w,-aspect*5*w],[l-5*w,-w],[0,-w]].map(([x,y])=>!(1e0+x*1e1+y*1e2)); + var sqrt = R => R==-1?1e12:(1+R).Normalized; + var R = ((to_point - from_point).UnDual).Normalized * 1e1; + var R2 = sqrt(from_point/!1e0) * sqrt(R); + var p2 = R2 >>> 1e3; + if (p2 != 0) { var p1 = (((~(camera+0e1) >>> 1e3)|line)/line).Normalized; return sqrt(p1/p2) * R2 >>> shape; } + return R2 >>> shape; + }) + } + + if (options.dual) { + Object.defineProperty(res.prototype, 'Inverse', {configurable:true, get(){ var s = 1/this.s**2; return this.map((x,i)=>i?-x*s:1/x ); }}); + } else { + // Matrix-free inverses up to 5D. Should translate this to an inline call for readability. + // http://repository.essex.ac.uk/17282/1/TechReport_CES-534.pdf + Object.defineProperty(res.prototype, 'Inverse', {configurable: true, get(){ + // Shirokov inverse .. + if (tot > 5) { + for (var N=2**(((tot+1)/2)|0), Uk=this.Scale(1), k=1; kres.prototype[x] = options.over.inline(res.prototype[x])); + res.prototype.Coeff = function() { for (var i=0,l=arguments.length; ix==0?undefined:(i?'('+x+')'+basis[i]:x.toString())).filter(x=>x).join(' + '); } + } + + // Experimental differential operator. + var _D, _DT, _DA, totd = basis.length; + function makeD(transpose=false){ + _DA = _DA || Algebra({ p:p,q:q,r:r,basis:options.basis,even:options.even,over:Algebra({dual:totd})}); // same algebra, but over dual numbers. + return (func)=>{ + var dfunc = _DA.inline(func); // convert input function to dual algebra + return (val,...args)=>{ // return a new function (the derivative w.r.t 1st param) + if (!(val instanceof res)) val = res.Scalar(val); // allow to be called with scalars. + args = args.map(x=>{ var r = _DA.Scalar(0); for (var i=0; ival.slice()); // call the function in the dual algebra. + if (transpose) for (var i=0; i 0.5) +n_neg = count(eig.values .< -0.5) +if n_pos + n_neg == size(gram, 1) + printgood("Non-degenerate subspace") +else + printbad("Degenerate subspace") +end +sig_rem = Int64[ones(1-n_pos); -ones(4-n_neg)] +unk = hcat(a, b, c) +M = matrix_space(F, 5, 5) +big_gram = M(F.([ + diagm(sig_rem) unk; + transpose(unk) gram +])) + +r, p, L, U = lu(big_gram) +if isone(p) + printgood("Found a solution") +else + printbad("Didn't find a solution") +end +solution = transpose(L) +mform = U * inv(solution) + +vals = [0, 0, 0, 1, 0, -3//4] +solution_ex = [evaluate(entry, vals) for entry in solution] +mform_ex = [evaluate(entry, vals) for entry in mform] + +std_basis = [ + 0 0 0 1 1; + 0 0 0 1 -1; + 1 0 0 0 0; + 0 1 0 0 0; + 0 0 1 0 0 +] +std_solution = M(F.(std_basis)) * solution +std_solution_ex = std_basis * solution_ex + +println("Minkowski form:") +display(mform_ex) + +big_gram_recovered = transpose(solution_ex) * mform_ex * solution_ex +valid = all(iszero.( + [evaluate(entry, vals) for entry in big_gram] - big_gram_recovered +)) +if valid + printgood("Recovered Gram matrix:") +else + printbad("Didn't recover Gram matrix. Instead, got:") +end +display(big_gram_recovered) + +# this should be a solution +hand_solution = [0 0 1 0 0; 0 0 -1 2 2; 0 0 0 1 -1; 1 0 0 0 0; 0 1 0 0 0] +unmix = Rational{Int64}[[1//2 1//2; 1//2 -1//2] zeros(Int64, 2, 3); zeros(Int64, 3, 2) Matrix{Int64}(I, 3, 3)] +hand_solution_diag = unmix * hand_solution +big_gram_hand_recovered = transpose(hand_solution_diag) * diagm([1; -ones(Int64, 4)]) * hand_solution_diag +println("Gram matrix from hand-written solution:") +display(big_gram_hand_recovered) \ No newline at end of file diff --git a/engine-proto/gram-test/gram-test.sage b/engine-proto/gram-test/gram-test.sage new file mode 100644 index 0000000..a95ce97 --- /dev/null +++ b/engine-proto/gram-test/gram-test.sage @@ -0,0 +1,27 @@ +F = QQ['a', 'b', 'c'].fraction_field() +a, b, c = F.gens() + +# three mutually tangent spheres which are all perpendicular to the x, y plane +gram = matrix([ + [-1, 0, 0, 0, 0], + [0, -1, a, b, c], + [0, a, -1, 1, 1], + [0, b, 1, -1, 1], + [0, c, 1, 1, -1] +]) + +P, L, U = gram.LU() +solution = (P * L).transpose() +mform = U * L.transpose().inverse() + +concrete = solution.subs({a: 0, b: 1, c: -3/4}) + +std_basis = matrix([ + [0, 0, 0, 1, 1], + [0, 0, 0, 1, -1], + [1, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [0, 0, 1, 0, 0] +]) +std_solution = std_basis * solution +std_concrete = std_basis * concrete \ No newline at end of file diff --git a/engine-proto/gram-test/irisawa-hexlet.jl b/engine-proto/gram-test/irisawa-hexlet.jl new file mode 100644 index 0000000..607db61 --- /dev/null +++ b/engine-proto/gram-test/irisawa-hexlet.jl @@ -0,0 +1,86 @@ +include("Engine.jl") + +using SparseArrays + +# 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/ +# + +# initialize the partial gram matrix +J = Int64[] +K = Int64[] +values = BigFloat[] +for s in 1:9 + # each sphere is represented by a spacelike vector + push!(J, s) + push!(K, s) + push!(values, 1) + + # the circumscribing sphere is internally tangent to all of the other spheres + if s > 1 + append!(J, [1, s]) + append!(K, [s, 1]) + append!(values, [1, 1]) + end + + if s > 3 + # each chain sphere is externally tangent to the "sun" and "moon" spheres + for n in 2:3 + append!(J, [s, n]) + append!(K, [n, s]) + append!(values, [-1, -1]) + end + + # each chain sphere is externally tangent to the next chain sphere + s_next = 4 + mod(s-3, 6) + append!(J, [s, s_next]) + append!(K, [s_next, s]) + append!(values, [-1, -1]) + end +end +gram = sparse(J, K, values) + +# make an initial guess +guess = hcat( + Engine.sphere(BigFloat[0, 0, 0], BigFloat(15)), + Engine.sphere(BigFloat[0, 0, -9], BigFloat(5)), + Engine.sphere(BigFloat[0, 0, 11], BigFloat(3)), + ( + Engine.sphere(9*BigFloat[cos(k*π/3), sin(k*π/3), 0], BigFloat(2.5)) + for k in 1:6 + )... +) +frozen = [CartesianIndex(4, k) for k in 1:4] + +# complete the gram matrix using Newton's method with backtracking +L, success, history = Engine.realize_gram(gram, guess, frozen) +completed_gram = L'*Engine.Q*L +println("Completed Gram matrix:\n") +display(completed_gram) +if success + println("\nTarget accuracy achieved!") +else + println("\nFailed to reach target accuracy") +end +println("Steps: ", size(history.scaled_loss, 1)) +println("Loss: ", history.scaled_loss[end], "\n") +if success + println("Chain diameters:") + println(" ", 1 / L[4,4], " sun (given)") + for k in 5:9 + println(" ", 1 / L[4,k], " sun") + 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") \ No newline at end of file diff --git a/engine-proto/gram-test/low-rank-test.jl b/engine-proto/gram-test/low-rank-test.jl new file mode 100644 index 0000000..d932a3d --- /dev/null +++ b/engine-proto/gram-test/low-rank-test.jl @@ -0,0 +1,49 @@ +using LowRankModels +using LinearAlgebra +using SparseArrays + +# testing Gram matrix recovery using the LowRankModels package + +# initialize the partial gram matrix for an arrangement of seven spheres in +# which spheres 1 through 5 are mutually tangent, and spheres 3 through 7 are +# also mutually tangent +I = Int64[] +J = Int64[] +values = Float64[] +for i in 1:7 + for j in 1:7 + if (i <= 5 && j <= 5) || (i >= 3 && j >= 3) + push!(I, i) + push!(J, j) + push!(values, i == j ? 1 : -1) + end + end +end +gram = sparse(I, J, values) + +# in this initial guess, the mutual tangency condition is satisfied for spheres +# 1 through 5 +X₀ = sqrt(0.5) * [ + 1 0 1 1 1; + 1 0 1 -1 -1; + 1 0 -1 1 -1; + 1 0 -1 -1 1; + 2 -sqrt(6) 0 0 0; + 0.2 0.3 -0.1 -0.2 0.1; + 0.1 -0.2 0.3 0.4 -0.1 +]' +Y₀ = diagm([-1, 1, 1, 1, 1]) * X₀ + +# search parameters +search_params = ProxGradParams( + 1.0; + max_iter = 100, + inner_iter = 1, + abs_tol = 1e-16, + rel_tol = 1e-9, + min_stepsize = 0.01 +) + +# complete gram matrix +model = GLRM(gram, QuadLoss(), ZeroReg(), ZeroReg(), 5, X = X₀, Y = Y₀) +X, Y, history = fit!(model, search_params) diff --git a/engine-proto/gram-test/overlap-test.jl b/engine-proto/gram-test/overlap-test.jl new file mode 100644 index 0000000..e75531a --- /dev/null +++ b/engine-proto/gram-test/overlap-test.jl @@ -0,0 +1,37 @@ +using LinearAlgebra +using AbstractAlgebra + +function printgood(msg) + printstyled("✓", color = :green) + println(" ", msg) +end + +function printbad(msg) + printstyled("✗", color = :red) + println(" ", msg) +end + +F, gens = rational_function_field(AbstractAlgebra.Rationals{BigInt}(), ["x", "t₁", "t₂", "t₃"]) +x = gens[1] +t = gens[2:4] + +# three mutually tangent spheres which are all perpendicular to the x, y plane +M = matrix_space(F, 7, 7) +gram = M(F[ + 1 -1 -1 -1 -1 t[1] t[2]; + -1 1 -1 -1 -1 x t[3] + -1 -1 1 -1 -1 -1 -1; + -1 -1 -1 1 -1 -1 -1; + -1 -1 -1 -1 1 -1 -1; + t[1] x -1 -1 -1 1 -1; + t[2] t[3] -1 -1 -1 -1 1 +]) + +r, p, L, U = lu(gram) +if isone(p) + printgood("Found a solution") +else + printbad("Didn't find a solution") +end +solution = transpose(L) +mform = U * inv(solution) diff --git a/engine-proto/gram-test/overlapping-pyramids.jl b/engine-proto/gram-test/overlapping-pyramids.jl new file mode 100644 index 0000000..a4ae01a --- /dev/null +++ b/engine-proto/gram-test/overlapping-pyramids.jl @@ -0,0 +1,90 @@ +include("Engine.jl") + +using SparseArrays +using AbstractAlgebra +using PolynomialRoots +using Random + +# initialize the partial gram matrix for an arrangement of seven spheres in +# which spheres 1 through 5 are mutually tangent, and spheres 3 through 7 are +# also mutually tangent +J = Int64[] +K = Int64[] +values = BigFloat[] +for j in 1:7 + for k in 1:7 + if (j <= 5 && k <= 5) || (j >= 3 && k >= 3) + push!(J, j) + push!(K, k) + push!(values, j == k ? 1 : -1) + end + end +end +gram = sparse(J, K, values) + +# set the independent variable +indep_val = -9//5 +gram[6, 1] = BigFloat(indep_val) +gram[1, 6] = gram[6, 1] + +# in this initial guess, the mutual tangency condition is satisfied for spheres +# 1 through 5 +Random.seed!(50793) +guess = let + a = sqrt(BigFloat(3)/2) + hcat( + sqrt(1/BigFloat(2)) * BigFloat[ + 1 1 -1 -1 0 + 1 -1 1 -1 0 + 1 -1 -1 1 0 + 0.5 0.5 0.5 0.5 1+a + 0.5 0.5 0.5 0.5 1-a + ] + 0.2*Engine.rand_on_shell(fill(BigFloat(-1), 5)), + Engine.rand_on_shell(fill(BigFloat(-1), 2)) + ) +end + +# complete the gram matrix using Newton's method with backtracking +L, success, history = Engine.realize_gram(gram, guess) +completed_gram = L'*Engine.Q*L +println("Completed Gram matrix:\n") +display(completed_gram) +if success + println("\nTarget accuracy achieved!") +else + println("\nFailed to reach target accuracy") +end +println("Steps: ", size(history.scaled_loss, 1)) +println("Loss: ", history.scaled_loss[end], "\n") + +# === algebraic check === + +#= +R, gens = polynomial_ring(AbstractAlgebra.Rationals{BigInt}(), ["x", "t₁", "t₂", "t₃"]) +x = gens[1] +t = gens[2:4] + +S, u = polynomial_ring(AbstractAlgebra.Rationals{BigInt}(), "u") + +M = matrix_space(R, 7, 7) +gram_symb = M(R[ + 1 -1 -1 -1 -1 t[1] t[2]; + -1 1 -1 -1 -1 x t[3] + -1 -1 1 -1 -1 -1 -1; + -1 -1 -1 1 -1 -1 -1; + -1 -1 -1 -1 1 -1 -1; + t[1] x -1 -1 -1 1 -1; + t[2] t[3] -1 -1 -1 -1 1 +]) +rank_constraints = det.([ + gram_symb[1:6, 1:6], + gram_symb[2:7, 2:7], + gram_symb[[1, 3, 4, 5, 6, 7], [1, 3, 4, 5, 6, 7]] +]) + +# solve for x and t +x_constraint = 25//16 * to_univariate(S, evaluate(rank_constraints[1], [2], [indep_val])) +t₂_constraint = 25//16 * to_univariate(S, evaluate(rank_constraints[3], [2], [indep_val])) +x_vals = PolynomialRoots.roots(x_constraint.coeffs) +t₂_vals = PolynomialRoots.roots(t₂_constraint.coeffs) +=# \ No newline at end of file diff --git a/engine-proto/gram-test/sphere-in-tetrahedron.jl b/engine-proto/gram-test/sphere-in-tetrahedron.jl new file mode 100644 index 0000000..5d479cf --- /dev/null +++ b/engine-proto/gram-test/sphere-in-tetrahedron.jl @@ -0,0 +1,76 @@ +include("Engine.jl") + +using SparseArrays +using Random + +# initialize the partial gram matrix for a sphere inscribed in a regular +# tetrahedron +J = Int64[] +K = Int64[] +values = BigFloat[] +for j in 1:6 + for k in 1:6 + filled = false + if j == 6 + if k <= 4 + push!(values, 0) + filled = true + end + elseif k == 6 + if j <= 4 + push!(values, 0) + filled = true + end + elseif j == k + push!(values, 1) + filled = true + elseif j <= 4 && k <= 4 + push!(values, -1/BigFloat(3)) + filled = true + else + push!(values, -1) + filled = true + end + if filled + push!(J, j) + push!(K, k) + end + end +end +gram = sparse(J, K, values) + +# set initial guess +Random.seed!(99230) +guess = hcat( + sqrt(1/BigFloat(3)) * BigFloat[ + 1 1 -1 -1 0 + 1 -1 1 -1 0 + 1 -1 -1 1 0 + 0 0 0 0 1.5 + 1 1 1 1 -0.5 + ] + 0.2*Engine.rand_on_shell(fill(BigFloat(-1), 5)), + BigFloat[0, 0, 0, 0, 1] +) +frozen = [CartesianIndex(j, 6) for j in 1:5] + +# complete the gram matrix using Newton's method with backtracking +L, success, history = Engine.realize_gram(gram, guess, frozen) +completed_gram = L'*Engine.Q*L +println("Completed Gram matrix:\n") +display(completed_gram) +if success + println("\nTarget accuracy achieved!") +else + println("\nFailed to reach target accuracy") +end +println("Steps: ", size(history.scaled_loss, 1)) +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") \ No newline at end of file diff --git a/engine-proto/gram-test/tetrahedron-radius-ratio.jl b/engine-proto/gram-test/tetrahedron-radius-ratio.jl new file mode 100644 index 0000000..9fec28e --- /dev/null +++ b/engine-proto/gram-test/tetrahedron-radius-ratio.jl @@ -0,0 +1,105 @@ +include("Engine.jl") + +using LinearAlgebra +using SparseArrays +using Random + +# initialize the partial gram matrix for a sphere inscribed in a regular +# tetrahedron +J = Int64[] +K = Int64[] +values = BigFloat[] +for j in 1:11 + for k in 1:11 + filled = false + if j == 11 + if k <= 4 + push!(values, 0) + filled = true + end + elseif k == 11 + if j <= 4 + push!(values, 0) + filled = true + end + elseif j == k + push!(values, j <= 6 ? 1 : 0) + filled = true + elseif j <= 4 + if k <= 4 + push!(values, -1/BigFloat(3)) + filled = true + elseif k == 5 + push!(values, -1) + filled = true + elseif 7 <= k <= 10 && k - j != 6 + push!(values, 0) + filled = true + end + elseif k <= 4 + if j == 5 + push!(values, -1) + filled = true + elseif 7 <= j <= 10 && j - k != 6 + push!(values, 0) + filled = true + end + elseif j == 6 && 7 <= k <= 10 || k == 6 && 7 <= j <= 10 + push!(values, 0) + filled = true + end + if filled + push!(J, j) + push!(K, k) + end + end +end +gram = sparse(J, K, values) + +# set initial guess +Random.seed!(99230) +guess = hcat( + sqrt(1/BigFloat(3)) * BigFloat[ + 1 1 -1 -1 0 0 + 1 -1 1 -1 0 0 + 1 -1 -1 1 0 0 + 0 0 0 0 1.5 0.5 + 1 1 1 1 -0.5 -1.5 + ] + 0.0*Engine.rand_on_shell(fill(BigFloat(-1), 6)), + Engine.point([-0.5, -0.5, -0.5] + 0.3*randn(3)), + Engine.point([-0.5, 0.5, 0.5] + 0.3*randn(3)), + Engine.point([ 0.5, -0.5, 0.5] + 0.3*randn(3)), + Engine.point([ 0.5, 0.5, -0.5] + 0.3*randn(3)), + BigFloat[0, 0, 0, 0, 1] +) +frozen = vcat( + [CartesianIndex(4, k) for k in 7:10], + [CartesianIndex(j, 11) for j in 1:5] +) + +# complete the gram matrix using Newton's method with backtracking +L, success, history = Engine.realize_gram(gram, guess, frozen) +completed_gram = L'*Engine.Q*L +println("Completed Gram matrix:\n") +display(completed_gram) +if success + println("\nTarget accuracy achieved!") +else + println("\nFailed to reach target accuracy") +end +println("Steps: ", size(history.scaled_loss, 1)) +println("Loss: ", history.scaled_loss[end]) +if success + infty = BigFloat[0, 0, 0, 0, 1] + radius_ratio = dot(infty, Engine.Q * L[:,5]) / dot(infty, Engine.Q * L[:,6]) + println("\nCircumradius / inradius: ", radius_ratio) +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") \ No newline at end of file diff --git a/notes/inversive.md b/notes/inversive.md index 6de7ef2..933eb35 100644 --- a/notes/inversive.md +++ b/notes/inversive.md @@ -2,28 +2,29 @@ (proposed by Alex Kontorovich as a practical system for doing 3D geometric calculations) -These coordinates are of form $I=(c, r, x, y, z)$ where we think of $c$ as the co-radius, $r$ as the radius, and $x, y, z$ as the "Euclidean" part, which we abbreviate $E_I$. There is an underlying basic quadratic form $Q(I_1,I_2) = (c_1r_2+c_2r_1)/2 - x_1x_2 -y_1y_2-z_1z_2$ which aids in calculation/verification of coordinates in this representation. We have: +These coordinates are of form $I=(c, b, x, y, z)$ where we think of $c$ as the co-radius, $b$ as the "bend" (reciprocal radius), and $x, y, z$ as the "Euclidean" part, which we abbreviate $E_I$. There is an underlying basic quadratic form $Q(I_1,I_2) = (c_1b_2+c_2b_1)/2 - x_1x_2 -y_1y_2-z_1z_2$ which aids in calculation/verification of coordinates in this representation. We have: -| Entity or Relationship | Representation | Comments/questions | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Sphere s with radius r>0 centered on P = (x,y,z) | $I_s = (1/c, 1/r, x/r, y/r, z/r)$ satisfying $Q(I_s,I_s) = -1$, i.e., $c = r/(\|P\|^2 - r^2)$. | Can also write $I_s = (\|P\|^2/r - r, 1/r, x/r. y/r, z/r)$ -- so there is no trouble if $\|E_{I_s}\| = r$, just get first coordinate to be 0. | -| Plane p with unit normal (x,y,z), a distance s from origin | $I_p = (2s, 0, x, y, z)$ | Note $Q(I_p, I_p)$ is still -1. Also, there are two representations for each plane through the origin, namely $(0,0,x,y,z)$ and $(0,0,-x,-y,-z)$ | -| Point P with Euclidean coordinates (x,y,z) | $I_P = (\|P\|^2, 1, x, y, z)$ | Note $Q(I_P,I_P) = 0$.  Because of this we might choose  some other scaling of the inversive coordinates, say $(\||P\||,1/\||P\||,x/\||P\||,y/\||P\||,z/\||P\||)$ instead, but that fails at the origin, and likely won't have some of the other nice properties listed below.  Note that scaling just the co-radius by $s$ and the radius by $1/s$ (which still preserves $Q=0$) dilates by a factor of $s$ about the origin, so that $(\|P\|, \|P\|, x, y, z)$, which might look symmetric, would actually have to represent the Euclidean point $(x/\||P\||, y/\||P\||, z/\||P\||)$ . | -| ∞, the "point at infinity" | $I_\infty = (1,0,0,0,0)$ | The only solution to $Q(I,I) = 0$ not covered by the above case. | -| P lies on sphere or plane given by I | $Q(I_P, I) = 0$ | | -| Sphere/planes represented by I and J are tangent | $Q(I,J) = 1$ (??, see note at right) | Seems as though this must be $Q(I,J) = \pm1$  ? For example, the $xy$ plane represented by (0,0,0,0,1)  is tangent to the unit circle centered at (0,0,1) rep'd by (0,1,0,0,1), but their Q-product is -1. And in general you can reflect any sphere tangent to any plane through the plane and it should flip the sign of $Q(I,J)$, if I am not mistaken. | -| Sphere/planes represented by I and J intersect (respectively, don't intersect) | $\|Q(I,J)\| < (\text{resp. }>)\; 1$ | Follows from the angle formula, at least conceptually. | -| P is center of sphere represented by I | Well, $Q(I_P, I)$ comes out to be $(\|P\|^2/r - r + \|P\|^2/r)/2 - \|P\|^2/r$ or just $-r/2$ . | Is it if and only if ?   No this probably doesn't work because center is not conformal quantity. | -| Distance between P and R is d | $Q(I_P, I_R) = d^2/2$ | | -| Distance between P and sphere/plane rep by I | | In the very simple case of a plane $I$ rep'd by $(2s, 0, x, y, z)$ and a point $P$ that lies on its perpendicular through the origin, rep'd by $(r^2, 1, rx, ry, rz)$ we get $Q(I, I_p) = s-r$, which is indeed the signed distance between $I$ and $P$. Not sure if this generalizes to other combinations? | -| Distance between sphere/planes rep by I and J | Note that for any two Euclidean-concentric spheres rep by $I$ and $J$ with radius $r$ and $s,$ $Q(I,J) = -\frac12\left(\frac rs  + \frac sr\right)$ depends only on the ratio of $r$ and $s$. So this can't give something that determines the Euclidean distance between the two spheres, which presumably grows as the two spheres are blown up proportionally. For another example, for any two parallel planes, $Q(I,J) = \pm1$. | Alex had said: Q(I,J)=cosh^2 (d/2) maybe where d is distance in usual hyperbolic metric. Or maybe cosh d. That may be right depending on what's meant by the hyperbolic metric there, but it seems like it won't determine a reasonable Euclidean distance between planes, which should differ between different pairs of parallel planes. | -| Sphere centered on P through R | | Probably just calculate distance etc. | -| Plane rep'd by I goes through center of sphere rep'd by J | I think this is equivalent to the plane being perpendicular to the sphere, i.e.$Q(I,J) = 0$. | | -| Dihedral angle between planes (or spheres?) rep by I and J | $\theta = \arccos(Q(I,J))$ | Aaron Fenyes points out: The angle between spheres in $S^3$ matches the angle between the planes they bound in $R^{(1,4)}$, which matches the angle between the spacelike vectors perpendicular to those planes. So we should have $Q(I,J) = \cos\theta$. Note that when the spheres do not intersect, we can interpret this as the "imaginary angle" between them, via $\cosh t = \cos it$. | -| R, P, S are collinear | Maybe just cross product of two differences is 0. Or, $R,P,S,\infty$ lie on a circle, or equivalently, $I_R,I_P,I_S,I_\infty$ span a plane (rather than a three-space). | Not a conformal property, but $R,P,S,\infty$ lying on a circle _is_. | -| Plane through noncollinear R, P, S | Should be, just solve Q(I, I_R) = 0 etc. | | -| circle | Maybe concentric sphere and the containing plane? Note it is easy to constrain the relationship between those two: they must be perpendicular. | Defn: circle is intersection of two spheres. That does cover lines. But you lose the canonicalness | -| line | Maybe two perpendicular containing planes? Maybe the plane perpendicular to the line and through origin, together with the point of the line on that plane? Or maybe just as a bag of collinear points? | The first is the limiting case of the possible circle rep, but it is not canonical. The second appears to be canonical, but I don't see a circle rep that corresponds to it. | +| Entity or Relationship | Representation | Comments/questions | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Sphere $s$ with radius $r>0$ centered on $P = (x,y,z)$ | $I_s = (\frac1{c}, \frac1{r}, \frac{x}{r}, \frac{y}{r}, \frac{z}{r})$ satisfying $Q(I_s,I_s) = -1,$ i.e., $c = r/(\|P\|^2 - r^2)$. | Note that $1/c = \|P\|^2/r - r$, so there is no trouble if $\|P\| = r$; we just get first coordinate to be 0. Using the point representation $I_P$ from below, let's orient the sphere so that its normals point into the "positive side," where $Q(I_P, I_s) > 0$. The vector $I_s$ then represents a sphere with outward normals, while $-I_s$ represents one with inward normals. | +| Plane $p$ with unit normal $(x,y,z)$ through the (Euclidean) point $(sx,sy,sz)$ | $I_p = (-2s, 0, -x, -y, -z)$ | Note that $Q(I_p, I_p)$ is still $-1$. We orient planes using the same convention we use for spheres. For example, $(-2, 0, -1/\sqrt3, -1/\sqrt3, -1/\sqrt3)$ and $(2, 0, 1/\sqrt3, 1/\sqrt3, 1/\sqrt3)$ represent planes that coincide in space, which have their normals pointing away from and toward the origin, respectively. Note that the ray from $(sx, sy, sz) \in p$ in direction $(-x, -y, -z)$ is the ray perpendicular to the plane through the origin; since $(-x, -y, -z)$ is a unit vector, $(sx, sy, sz)$ and hence $p$ is at distance $s$ from the origin. These coordinates are essentially the limit of a sphere's coordinates as its radius goes to infinity, or equivalently, as its bend goes to 0. | +| Point $P$ with Euclidean coordinates $(x,y,z)$ | $I_P = (\|P\|^2, 1, x, y, z)$ | Note $Q(I_P,I_P) = 0$. This gives us the freedom to choose a different normalization. For example, we could scale the representation shown here by $(\|P\|^2+1)^{-1}$, putting it on the sphere where the light cone intersects the plane where the first two coordinates sum to $1$. | +| ∞, the "point at infinity" | $I_\infty = (1,0,0,0,0)$ | The only solution to $Q(I,I) = 0$ not covered by (some normalization of) the above case. | +| Point $P$ lies on sphere or plane given by $I$ | $Q(I_P, I) = 0$ | Actually also works if $I$ is the coordinates of a point, in which case "lies on" simply means "coincides with". | +| Sphere/planes represented by $I$ and $J$ are tangent | If $I$ and $J$ have the same orientation where they touch, $Q(I,J) = -1$. If they have opposing orientations, $Q(I,J) = 1$. | For example, the $xy$ plane with normal $-e_z$, represented by $(0,0,0,0,1)$, is tangent with matching orientation to the unit sphere centered at $(0,0,1)$ with outward normals, represented by $(0,1,0,0,1).$ Accordingly, their $Q$ - product is $-1$. | +| Sphere/planes represented by $I$ and $J$ intersect (respectively, don't intersect) | $\lvert Q(I,J)\rvert \le (\text{resp. }>)\; 1$ | Follows from the angle formula and the tangency condition, at least conceptually. One subtlety: parallel planes have $Q$ - product $\pm 1$, because they intersect at infinity (and in fact, are "tangent" there)! | +| $P$ is center of sphere rep'd by $I$ | $Q(I, I_P) = -r/2$, where $1/r = 2Q(I_\infty, I)$ is the signed bend of the sphere, and $I_P$ is normalized in the standard way, which is to set $Q(I_\infty, I_P) = 1/2$ | This relationship is equivalent to both of the following. (1) The point $P$ has signed distance $-r$ from the sphere. (2) Inversion across the sphere maps $\infty$ to $P$. | +| Distance between points $P$ and $R$ is $d$ | $Q(I_P, I_R) = d^2/2$ | If $P$ and $R$ are represented by non-normalized vectors $V_P$ and $V_R$, the relation becomes $Q(V_P, V_R) = 2\,Q(V_P, I_\infty)\,Q(V_R, I_\infty)\,d^2$. This version of the relation makes it easier to see why $d$ goes to infinity as $P$ or $R$ approaches the point at infinity. | +| Signed distance between point rep'd by $V$ and sphere/plane rep'd by $I$ is $d$ | In general, $\frac{Q(I, V)}{2Q(I_\infty, V)} = Q(I_\infty, I)\,d^2 + d$. When $V$ is normalized in the usual way, this simplifies to $Q(I, V) = d^2/r + d$ for a sphere of radius $r$, and to $Q(I, V) = d$ for a plane. | We can use a Euclidean motion, represented linearly by a Lorentz transformation that fixes $I_\infty$, to put the point on the $z$ axis and put the nearest point on the sphere/plane at the origin with its normal pointing in the positive $z$ direction. Then the sphere/plane is represented by $I = (0, 1/r, 0, 0, -1)$, and the point can be represented by any multiple of $I_P = (d^2, 1, 0, 0, d)$, giving $Q(I, I_P) = d^2/2r + d.$ We turn this into a general expression by writing it in terms of Lorentz-invariant quantities and making it independent of the normalization of $I_P$. | +| Distance between sphere/planes rep by $I$ and $J$ | Note that for any two Euclidean-concentric spheres rep by $I$ and $J$ with radius $r$ and $s,$ $Q(I,J) = -\frac12\left(\frac rs + \frac sr\right)$ depends only on the ratio of $r$ and $s$. So this can't give something that determines the Euclidean distance between the two spheres, which presumably grows as the two spheres are blown up proportionally. For another example, for any two parallel planes, $Q(I,J) = \pm1$. | Alex had said: $Q(I,J)=\cosh(d/2)^2$ maybe where d is distance in usual hyperbolic metric. Or maybe $\cosh(d)$. That may be right depending on what's meant by the hyperbolic metric there, but it seems like it won't determine a reasonable Euclidean distance between planes, which should differ between different pairs of parallel planes. | +| Sphere centered on point $P$ through point $R$ | | Probably just calculate distance etc. | +| Plane rep'd by $I$ goes through center of sphere rep'd by $J$ | This is equivalent to the plane being perpendicular to the sphere: that is, $Q(I, J) = 0$. | | +| Dihedral angle between planes or spheres rep by $I$ and $J$ | $\theta = \arccos(Q(I,J))$ | Aaron Fenyes points out: The angle between spheres in $S^3$ matches the angle between the planes they bound in $R^{(1,4)}$, which matches the angle between the spacelike vectors perpendicular to those planes. So we should have $Q(I,J) = \cos(\theta)$. Note that when the spheres do not intersect, we can interpret this as the "imaginary angle" between them, via $\cosh(t) = \cos(it)$. | +| Points $R, P, S$ are collinear | Maybe just cross product of two differences is 0. Or, $R,P,S,\infty$ lie on a circle, or equivalently, $I_R,I_P,I_S,I_\infty$ span a plane (rather than a three-space). Or we can add two planes constrained to be perpendicular with one constrained to contain the origin, and all three points constrained to lie on both. But that's a lot of auxiliary entities and constraints... | $R,P,S$ lying on a line isn't a conformal property, but $R,P,S,\infty$ lying on a circle is. | +| Plane through noncollinear $R, P, S$ | Should be, just solve $Q(I, I_R) = 0$ etc. | | +| circle | Maybe concentric sphere and the containing plane? Note it is easy to constrain the relationship between those two: they must be perpendicular. | Defn: circle is intersection of two spheres. That does cover lines. But you lose the canonicalness | +| line | Maybe two perpendicular containing planes? Maybe the plane perpendicular to the line and through origin, together with the point of the line on that plane? Or maybe just as a bag of collinear points? | The first is the limiting case of the possible circle rep, but it is not canonical. However, there is a distinguished "standard" choice we could make: always choose one plane to contain the origin and the line, and the other to be the perpendicular plane containing the line. That choice or Plücker coordinates might be the best we can do. If we use the standardized perpendicular planes choice, then adding a line would be equivalent to adding two planes and the two constraints that one contains the origin and the other is perpendicular to it. That doesn't seem so bad. The second convention (perpendicular plane through the origin and a point on it) appears to be canonical, but there doesn't seem to be a circle representation that tends to it in the limit. | +| Inversion of entity represented by $v$ across sphere $s$, rep'd by $I_s$ | $v \mapsto v + 2Q(I_s, v)\,I_s$ | This is just an educated guess, but its behavior is consistent with inversion in at least two ways. (1) It fixes points on $s$ and spheres perpendicular to $s$. (2) It preserves dihedral angles with $s$. | The unification of spheres/planes is indeed attractive for a project like Dyna3. The relationship between this representation and Geometric Algebras is a bit murky; likely it somehow fits under the Geometric Algebra umbrella. @@ -40,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. 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. \ No newline at end of file