diff --git a/.gitignore b/.gitignore index 0c0af5ef..53f3318a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /.claude/ +/neuronify/dist diff --git a/Cargo.lock b/Cargo.lock index 52033d1a..33937c91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1393,8 +1393,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1526,7 +1528,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows 0.58.0", + "windows", ] [[package]] @@ -2264,6 +2266,7 @@ dependencies = [ "crude-profiler", "egui", "futures", + "getrandom 0.2.15", "glam", "hecs", "js-sys", @@ -4127,7 +4130,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "visula" version = "0.1.0" -source = "git+https://github.com/dragly/visula?rev=5b2ca173ea036853ce4d52cd8ef91da139852f68#5b2ca173ea036853ce4d52cd8ef91da139852f68" +source = "git+https://github.com/dragly/visula?rev=aa83a2b63633c688faf1efcb6f2f963390c06eb7#aa83a2b63633c688faf1efcb6f2f963390c06eb7" dependencies = [ "bytemuck", "catppuccin", @@ -4135,7 +4138,6 @@ dependencies = [ "chrono", "console_error_panic_hook", "console_log", - "crude-profiler", "egui", "egui-wgpu", "egui-winit", @@ -4180,7 +4182,7 @@ dependencies = [ [[package]] name = "visula_core" version = "0.1.0" -source = "git+https://github.com/dragly/visula?rev=5b2ca173ea036853ce4d52cd8ef91da139852f68#5b2ca173ea036853ce4d52cd8ef91da139852f68" +source = "git+https://github.com/dragly/visula?rev=aa83a2b63633c688faf1efcb6f2f963390c06eb7#aa83a2b63633c688faf1efcb6f2f963390c06eb7" dependencies = [ "bytemuck", "glam", @@ -4195,7 +4197,7 @@ dependencies = [ [[package]] name = "visula_derive" version = "0.1.0" -source = "git+https://github.com/dragly/visula?rev=5b2ca173ea036853ce4d52cd8ef91da139852f68#5b2ca173ea036853ce4d52cd8ef91da139852f68" +source = "git+https://github.com/dragly/visula?rev=aa83a2b63633c688faf1efcb6f2f963390c06eb7#aa83a2b63633c688faf1efcb6f2f963390c06eb7" dependencies = [ "proc-macro-crate 1.3.1", "proc-macro2", @@ -4691,9 +4693,9 @@ dependencies = [ "web-sys", "wgpu-naga-bridge", "wgpu-types", - "windows 0.62.2", + "windows", "windows-core 0.62.2", - "windows-result 0.4.1", + "windows-result", ] [[package]] @@ -4751,16 +4753,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.62.2" @@ -4791,30 +4783,17 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-result", + "windows-strings", ] [[package]] @@ -4828,17 +4807,6 @@ dependencies = [ "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -4850,17 +4818,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -4888,15 +4845,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -4906,16 +4854,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-strings" version = "0.5.1" diff --git a/README.md b/README.md index 01c8c37b..f831f451 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,8 @@ but with new features and the ability to embed simulations into websites and onl Neuronify 1 was originally written in C++, while Neuronify 2 is written in Rust. - +To run Neuronify in the browser, +install [trunk](https://trunkrs.dev) and run `trunk serve --port ` in the `neuronify` folder. +Adding `?file=examples/leaky.nfy` to the URL shows that file +the way it would look embedded on a web page, with the editor hidden. +It can be any legacy `.nfy` file or `.neuronify` save file the page can fetch. diff --git a/neuronify-core/Cargo.toml b/neuronify-core/Cargo.toml index 2d1de9b3..59651350 100644 --- a/neuronify-core/Cargo.toml +++ b/neuronify-core/Cargo.toml @@ -7,9 +7,9 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -visula = { git = "https://github.com/dragly/visula", rev = "5b2ca173ea036853ce4d52cd8ef91da139852f68" } -visula_derive = { git = "https://github.com/dragly/visula", rev = "5b2ca173ea036853ce4d52cd8ef91da139852f68" } -visula_core = { git = "https://github.com/dragly/visula", rev = "5b2ca173ea036853ce4d52cd8ef91da139852f68" } +visula = { git = "https://github.com/dragly/visula", rev = "aa83a2b63633c688faf1efcb6f2f963390c06eb7" } +visula_derive = { git = "https://github.com/dragly/visula", rev = "aa83a2b63633c688faf1efcb6f2f963390c06eb7" } +visula_core = { git = "https://github.com/dragly/visula", rev = "aa83a2b63633c688faf1efcb6f2f963390c06eb7" } wgpu = { version = "29", features = ["webgl"] } glam = { version = "0.30", features = ["bytemuck", "serde"] } bytemuck = { version = "1.24", features = ["derive"] } @@ -38,7 +38,11 @@ serde_derive = "1.0.204" serde = { version = "1.0.204", features = ["derive"] } serde_json = "1.0.122" postcard = { version = "1.0.8", features = ["use-std"] } -web-sys = { version = "0.3.69", features = ["Request", "Response", "RequestInit", "Headers"] } +web-sys = { version = "0.3.69", features = ["Location", "Response", "UrlSearchParams", "Window"] } chrono = { version = "0.4.38", features = ["serde"] } rfd = "0.15.2" winit = "0.30" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# rand 0.8 depends on getrandom 0.2, which only builds for the web with its js feature +getrandom = { version = "0.2", features = ["js"] } diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index ba2442ea..aa5a7cee 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -52,6 +52,7 @@ pub struct Neuronify { pub dragging_entity: Option, pub drag_offset: Vec3, pub resizing_voltmeter: Option<(Entity, ResizeCorner)>, + pub load_error: Option, } #[derive(Debug)] @@ -187,6 +188,7 @@ impl Neuronify { dragging_entity: None, drag_offset: Vec3::ZERO, resizing_voltmeter: None, + load_error: None, } } @@ -894,13 +896,16 @@ impl Neuronify { self.world = deserialize(&mut context, &mut deserializer).unwrap(); } - pub fn from_slice(application: &mut visula::Application, bytes: &[u8]) -> Neuronify { - let mut neuronify = Neuronify::new(application); - let mut context = LoadContext::new(); - let mut deserializer = postcard::Deserializer::from_bytes(bytes); - neuronify.world = deserialize(&mut context, &mut deserializer).unwrap(); - neuronify.edit_enabled = false; - neuronify + /// Replaces the world with a legacy `.nfy` file or a `.neuronify` save file. + pub fn load_from_bytes(&mut self, bytes: &[u8]) -> Result<(), String> { + self.world = crate::serialization::load_from_bytes(bytes)?; + self.time = 0.0; + self.previous_creation = None; + self.connection_tool = None; + self.active_entity = None; + self.dragging_entity = None; + self.resizing_voltmeter = None; + Ok(()) } } @@ -914,6 +919,14 @@ impl visula::Simulation for Neuronify { } } fn update(&mut self, application: &mut visula::Application) { + #[cfg(target_arch = "wasm32")] + if let Some(fetched) = crate::web::take_fetched_file() { + if let Err(error) = fetched.and_then(|bytes| self.load_from_bytes(&bytes)) { + log::error!("{error}"); + self.load_error = Some(error); + } + } + let Neuronify { connection_tool, world, @@ -975,6 +988,11 @@ impl visula::Simulation for Neuronify { } fn gui(&mut self, _application: &visula::Application, context: &egui::Context) { + if let Some(error) = &self.load_error { + egui::Window::new("Could not load file").show(context, |ui| { + ui.label(error); + }); + } egui::Area::new("edit_button_area".into()) .anchor(egui::Align2::RIGHT_BOTTOM, [-10.0, -10.0]) .show(context, |ui| { diff --git a/neuronify-core/src/lib.rs b/neuronify-core/src/lib.rs index 219f55ef..f3c265ab 100644 --- a/neuronify-core/src/lib.rs +++ b/neuronify-core/src/lib.rs @@ -1,24 +1,3 @@ -#[cfg(target_arch = "wasm32")] -use js_sys::Uint8Array; -#[cfg(target_arch = "wasm32")] -use std::borrow::BorrowMut; -#[cfg(target_arch = "wasm32")] -use std::sync::Arc; -#[cfg(target_arch = "wasm32")] -use visula::winit::event::{Event, WindowEvent}; -#[cfg(target_arch = "wasm32")] -use visula::winit::platform::web::EventLoopExtWebSys; -#[cfg(target_arch = "wasm32")] -use visula::{create_event_loop, initialize_logger, Application, CustomEvent, RunConfig}; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen::prelude::*; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen_futures::JsFuture; -#[cfg(target_arch = "wasm32")] -use web_sys::{Request, RequestInit, Response}; -#[cfg(target_arch = "wasm32")] -use winit::event_loop::EventLoop; - pub mod app; pub mod components; pub mod constants; @@ -29,6 +8,8 @@ pub mod rendering; pub mod serialization; pub mod simulation; pub mod tools; +#[cfg(target_arch = "wasm32")] +mod web; pub use app::{Error, Neuronify}; pub use components::*; @@ -37,65 +18,17 @@ pub use input::{Keyboard, Mouse}; pub use simulation::{fhn_step, lif_step, run_headless, SpikeRecord}; pub use tools::*; -#[cfg(target_arch = "wasm32")] -struct Bundle { - application: Application, - simulation: Neuronify, -} - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen] -pub struct WasmWrapper { - event_loop: EventLoop, - bundles: Vec, -} - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen] -pub async fn initialize() -> WasmWrapper { - initialize_logger(); - let event_loop = create_event_loop(); - let bundles: Vec = Vec::new(); - WasmWrapper { - event_loop, - bundles, - } -} - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen] -pub async fn load(wrapper: &mut WasmWrapper, canvas: &str, url: &str) -> Result<(), JsValue> { - let window = visula::create_window_with_config( - &RunConfig { - canvas_name: canvas.to_owned(), - }, - todo!("Need ActiveEventLoop from ApplicationHandler"), - ); - let mut application = Application::new(window).await; - - let mut opts = RequestInit::new(); - opts.method("GET"); - let request = Request::new_with_str_and_init(url, &opts)?; - let window = web_sys::window().ok_or("No global `window` exists")?; - let response_value = JsFuture::from(window.fetch_with_request(&request)).await?; - let response: Response = response_value.dyn_into()?; - let buffer = JsFuture::from(response.array_buffer()?).await?; - let uint8_array = Uint8Array::new(&buffer); - let vec = uint8_array.to_vec(); - let simulation = Neuronify::from_slice(&mut application, &vec); - wrapper.bundles.push(Bundle { - application, - simulation, - }); - Ok(()) -} - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen] -pub async fn start(mut wrapper: WasmWrapper) -> Result<(), JsValue> { - Ok(()) -} - pub fn run() { - visula::run(Neuronify::new); + // On the web, a page can name a file with `?file=`. + // Neuronify then shows it the way it would look embedded on a web page, + // with the editor hidden until the reader presses Edit. + #[cfg(target_arch = "wasm32")] + let embedded = web::fetch_requested_file(); + #[cfg(not(target_arch = "wasm32"))] + let embedded = false; + visula::run(move |application| { + let mut neuronify = Neuronify::new(application); + neuronify.edit_enabled = !embedded; + neuronify + }); } diff --git a/neuronify-core/src/serialization.rs b/neuronify-core/src/serialization.rs index 06e95289..b11fefcc 100644 --- a/neuronify-core/src/serialization.rs +++ b/neuronify-core/src/serialization.rs @@ -129,3 +129,102 @@ component_id!( SpatialDynamics, NeuronType, ); + +/// Reads a legacy `.nfy` file (JSON) or a `.neuronify` save file (postcard). +pub fn load_from_bytes(bytes: &[u8]) -> Result { + if !bytes.starts_with(b"{") { + return load_save_file(bytes); + } + // A save file with 123 archetypes starts with `{` too, + // so try it as a save file before reporting why it is not a legacy file + load_legacy(bytes).or_else(|legacy_error| load_save_file(bytes).map_err(|_| legacy_error)) +} + +fn load_legacy(bytes: &[u8]) -> Result { + let contents = std::str::from_utf8(bytes).map_err(|e| format!("File is not UTF-8: {e}"))?; + let simulation = crate::legacy::parse_legacy_nfy(contents)?; + let mut world = World::new(); + crate::legacy::convert::spawn_legacy_simulation(&mut world, &simulation); + Ok(world) +} + +fn load_save_file(bytes: &[u8]) -> Result { + let mut context = LoadContext::new(); + let mut deserializer = postcard::Deserializer::from_bytes(bytes); + deserialize(&mut context, &mut deserializer) + .map_err(|e| format!("Could not read save file: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use postcard::ser_flavors::Flavor; + + const LEAKY_NFY: &str = include_str!("../examples/leaky.nfy"); + + fn save(world: &World) -> Vec { + let mut serializer = postcard::Serializer { + output: postcard::ser_flavors::StdVec::new(), + }; + serialize(world, &mut SaveContext, &mut serializer).unwrap(); + serializer.output.finalize().unwrap() + } + + #[test] + fn loads_legacy_file() { + let world = load_from_bytes(LEAKY_NFY.as_bytes()).unwrap(); + assert!(!world.is_empty()); + } + + #[test] + fn loads_save_file() { + let original = load_from_bytes(LEAKY_NFY.as_bytes()).unwrap(); + let loaded = load_from_bytes(&save(&original)).unwrap(); + assert_eq!(loaded.len(), original.len()); + } + + #[test] + fn loads_save_file_starting_with_brace() { + // A save file starts with its number of archetypes, and 123 is `{` + let mut world = World::new(); + for mask in 1..=123u32 { + let mut builder = EntityBuilder::new(); + if mask & 1 != 0 { + builder.add(Position { + position: glam::Vec3::ZERO, + }); + } + if mask & 2 != 0 { + builder.add(Inhibitory); + } + if mask & 4 != 0 { + builder.add(TouchSensor); + } + if mask & 8 != 0 { + builder.add(Deletable {}); + } + if mask & 16 != 0 { + builder.add(StaticConnectionSource {}); + } + if mask & 32 != 0 { + builder.add(Selectable { selected: false }); + } + if mask & 64 != 0 { + builder.add(Annotation { + text: String::new(), + }); + } + world.spawn(builder.build()); + } + let bytes = save(&world); + assert_eq!(bytes[0], b'{'); + + let loaded = load_from_bytes(&bytes).unwrap(); + assert_eq!(loaded.len(), world.len()); + } + + #[test] + fn rejects_html() { + assert!(load_from_bytes(b"").is_err()); + } +} diff --git a/neuronify-core/src/web.rs b/neuronify-core/src/web.rs new file mode 100644 index 00000000..df467071 --- /dev/null +++ b/neuronify-core/src/web.rs @@ -0,0 +1,58 @@ +//! Loading a file named in the page URL, as in `index.html?file=examples/leaky.nfy`. +//! +//! The fetch is asynchronous and visula's event loop never returns on the web, +//! so the result is left here for `Neuronify::update` to pick up. + +use std::cell::RefCell; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::Response; + +thread_local! { + static FETCHED: RefCell, String>>> = const { RefCell::new(None) }; +} + +/// Starts fetching the file named by the `file` query parameter. +/// Returns false if the page names no file. +pub fn fetch_requested_file() -> bool { + let Some(url) = requested_file() else { + return false; + }; + wasm_bindgen_futures::spawn_local(async move { + let result = fetch_bytes(&url).await.map_err(|e| { + // fetch rejects with a TypeError, while fetch_bytes rejects with a string + let reason = e + .dyn_ref::() + .map(|error| String::from(error.message())) + .or_else(|| e.as_string()) + .unwrap_or_else(|| format!("{e:?}")); + format!("Could not fetch {url}: {reason}") + }); + FETCHED.with(|fetched| *fetched.borrow_mut() = Some(result)); + }); + true +} + +/// Returns the fetched file once, after the fetch has finished. +pub fn take_fetched_file() -> Option, String>> { + FETCHED.with(|fetched| fetched.borrow_mut().take()) +} + +fn requested_file() -> Option { + let search = web_sys::window()?.location().search().ok()?; + web_sys::UrlSearchParams::new_with_str(&search) + .ok()? + .get("file") +} + +async fn fetch_bytes(url: &str) -> Result, JsValue> { + let window = web_sys::window().ok_or("no global window")?; + let response: Response = JsFuture::from(window.fetch_with_str(url)) + .await? + .dyn_into()?; + if !response.ok() { + return Err(format!("HTTP {}", response.status()).into()); + } + let buffer = JsFuture::from(response.array_buffer()?).await?; + Ok(js_sys::Uint8Array::new(&buffer).to_vec()) +} diff --git a/neuronify/Trunk.toml b/neuronify/Trunk.toml new file mode 100644 index 00000000..e2c39d2e --- /dev/null +++ b/neuronify/Trunk.toml @@ -0,0 +1,16 @@ +# Run `trunk serve --port ` in this directory, +# then open http://localhost:/ for the app +# or http://localhost:/?file=examples/leaky.nfy for a single file. +# Add `--address 0.0.0.0` to reach it from other devices on the network, such as a phone. + +[build] +target = "index.html" +release = true + +[serve] +# A missing file should give a 404, not index.html +no_spa = true + +[watch] +# Rebuild on changes to the core library too, not only this crate +watch = [".", "../neuronify-core"] diff --git a/neuronify/index.html b/neuronify/index.html new file mode 100644 index 00000000..14adcff5 --- /dev/null +++ b/neuronify/index.html @@ -0,0 +1,29 @@ + + + + + + Neuronify + + + + + + + +