diff --git a/Cargo.lock b/Cargo.lock index 33937c91..52e4868a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4130,7 +4130,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "visula" version = "0.1.0" -source = "git+https://github.com/dragly/visula?rev=aa83a2b63633c688faf1efcb6f2f963390c06eb7#aa83a2b63633c688faf1efcb6f2f963390c06eb7" +source = "git+https://github.com/dragly/visula?rev=49367fc7b9ca7fcb1fc4a4fe4172ff2766eab56c#49367fc7b9ca7fcb1fc4a4fe4172ff2766eab56c" dependencies = [ "bytemuck", "catppuccin", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "visula_core" version = "0.1.0" -source = "git+https://github.com/dragly/visula?rev=aa83a2b63633c688faf1efcb6f2f963390c06eb7#aa83a2b63633c688faf1efcb6f2f963390c06eb7" +source = "git+https://github.com/dragly/visula?rev=49367fc7b9ca7fcb1fc4a4fe4172ff2766eab56c#49367fc7b9ca7fcb1fc4a4fe4172ff2766eab56c" dependencies = [ "bytemuck", "glam", @@ -4197,7 +4197,7 @@ dependencies = [ [[package]] name = "visula_derive" version = "0.1.0" -source = "git+https://github.com/dragly/visula?rev=aa83a2b63633c688faf1efcb6f2f963390c06eb7#aa83a2b63633c688faf1efcb6f2f963390c06eb7" +source = "git+https://github.com/dragly/visula?rev=49367fc7b9ca7fcb1fc4a4fe4172ff2766eab56c#49367fc7b9ca7fcb1fc4a4fe4172ff2766eab56c" dependencies = [ "proc-macro-crate 1.3.1", "proc-macro2", diff --git a/neuronify-core/Cargo.toml b/neuronify-core/Cargo.toml index 59651350..98249e99 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 = "aa83a2b63633c688faf1efcb6f2f963390c06eb7" } -visula_derive = { git = "https://github.com/dragly/visula", rev = "aa83a2b63633c688faf1efcb6f2f963390c06eb7" } -visula_core = { git = "https://github.com/dragly/visula", rev = "aa83a2b63633c688faf1efcb6f2f963390c06eb7" } +visula = { git = "https://github.com/dragly/visula", rev = "49367fc7b9ca7fcb1fc4a4fe4172ff2766eab56c" } +visula_derive = { git = "https://github.com/dragly/visula", rev = "49367fc7b9ca7fcb1fc4a4fe4172ff2766eab56c" } +visula_core = { git = "https://github.com/dragly/visula", rev = "49367fc7b9ca7fcb1fc4a4fe4172ff2766eab56c" } wgpu = { version = "29", features = ["webgl"] } glam = { version = "0.30", features = ["bytemuck", "serde"] } bytemuck = { version = "1.24", features = ["derive"] } diff --git a/neuronify-core/assets/DejaVu-LICENSE b/neuronify-core/assets/DejaVu-LICENSE new file mode 100644 index 00000000..df52c170 --- /dev/null +++ b/neuronify-core/assets/DejaVu-LICENSE @@ -0,0 +1,187 @@ +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. +Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) + + +Bitstream Vera Fonts Copyright +------------------------------ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is +a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. + +Arev Fonts Copyright +------------------------------ + +Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and +associated documentation files (the "Font Software"), to reproduce +and distribute the modifications to the Bitstream Vera Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to +the following conditions: + +The above copyright and trademark notices and this permission notice +shall be included in all copies of one or more of the Font Software +typefaces. + +The Font Software may be modified, altered, or added to, and in +particular the designs of glyphs or characters in the Fonts may be +modified and additional glyphs or characters may be added to the +Fonts, only if the fonts are renamed to names not containing either +the words "Tavmjong Bah" or the word "Arev". + +This License becomes null and void to the extent applicable to Fonts +or Font Software that has been modified and is distributed under the +"Tavmjong Bah Arev" names. + +The Font Software may be sold as part of a larger software package but +no copy of one or more of the Font Software typefaces may be sold by +itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL +TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the name of Tavmjong Bah shall not +be used in advertising or otherwise to promote the sale, use or other +dealings in this Font Software without prior written authorization +from Tavmjong Bah. For further information, contact: tavmjong @ free +. fr. + +TeX Gyre DJV Math +----------------- +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. + +Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski +(on behalf of TeX users groups) are in public domain. + +Letters imported from Euler Fraktur from AMSfonts are (c) American +Mathematical Society (see below). +Bitstream Vera Fonts Copyright +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera +is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license (“Fonts”) and associated +documentation +files (the “Font Software”), to reproduce and distribute the Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, +and/or sell copies of the Font Software, and to permit persons to whom +the Font Software is furnished to do so, subject to the following +conditions: + +The above copyright and trademark notices and this permission notice +shall be +included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional +glyphs or characters may be added to the Fonts, only if the fonts are +renamed +to names not containing either the words “Bitstream” or the word “Vera”. + +This License becomes null and void to the extent applicable to Fonts or +Font Software +that has been modified and is distributed under the “Bitstream Vera” +names. + +The Font Software may be sold as part of a larger software package but +no copy +of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, +SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN +ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR +INABILITY TO USE +THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. +Except as contained in this notice, the names of GNOME, the GNOME +Foundation, +and Bitstream Inc., shall not be used in advertising or otherwise to promote +the sale, use or other dealings in this Font Software without prior written +authorization from the GNOME Foundation or Bitstream Inc., respectively. +For further information, contact: fonts at gnome dot org. + +AMSFonts (v. 2.2) copyright + +The PostScript Type 1 implementation of the AMSFonts produced by and +previously distributed by Blue Sky Research and Y&Y, Inc. are now freely +available for general use. This has been accomplished through the +cooperation +of a consortium of scientific publishers with Blue Sky Research and Y&Y. +Members of this consortium include: + +Elsevier Science IBM Corporation Society for Industrial and Applied +Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS) + +In order to assure the authenticity of these fonts, copyright will be +held by +the American Mathematical Society. This is not meant to restrict in any way +the legitimate use of the fonts, such as (but not limited to) electronic +distribution of documents containing these fonts, inclusion of these fonts +into other public domain or commercial font collections or computer +applications, use of the outline data to create derivative fonts and/or +faces, etc. However, the AMS does require that the AMS copyright notice be +removed from any derivative versions of the fonts which have been altered in +any way. In addition, to ensure the fidelity of TeX documents using Computer +Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces, +has requested that any alterations which yield different font metrics be +given a different name. + +$Id$ diff --git a/neuronify-core/assets/DejaVuSans.ttf b/neuronify-core/assets/DejaVuSans.ttf new file mode 100644 index 00000000..c3f9130c Binary files /dev/null and b/neuronify-core/assets/DejaVuSans.ttf differ diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index adb6b31a..9044a85e 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -2,15 +2,13 @@ use crate::components::*; use crate::constants::*; use crate::measurement::voltmeter::{RollingWindow, VoltageSeries, Voltmeter}; use crate::rendering::{ - collect_connections, collect_spheres, collect_voltmeter_traces, ConnectionData, Sphere, + collect_connections, collect_spheres, collect_voltmeter_traces, ConnectionData, NoteRenderer, + Sphere, }; -use crate::serialization::{LoadContext, SaveContext}; use crate::tools::*; use chrono::{DateTime, Duration, Utc}; use glam::Vec3; -use hecs::serialize::column::*; use hecs::Entity; -use postcard::ser_flavors::Flavor; use std::cmp::Ordering; use std::collections::HashSet; use std::io::BufReader; @@ -53,6 +51,7 @@ pub struct Neuronify { pub drag_offset: Vec3, pub resizing_voltmeter: Option<(Entity, ResizeCorner)>, pub load_error: Option, + pub note_renderer: NoteRenderer, } #[derive(Debug)] @@ -252,6 +251,7 @@ impl Neuronify { drag_offset: Vec3::ZERO, resizing_voltmeter: None, load_error: None, + note_renderer: NoteRenderer::new(application), } } @@ -511,6 +511,23 @@ impl Neuronify { position: mouse_position, }) } + Tool::Note => { + if previous_too_near { + return; + } + let entity = world.spawn(( + Position { + position: mouse_position, + }, + Annotation { + text: "New note".to_string(), + }, + NoteSize::default(), + Deletable {}, + )); + *active_entity = Some(entity); + self.previous_creation = Some(PreviousCreation { entity }); + } Tool::Erase => { let to_delete = world .query::<&Position>() @@ -749,12 +766,33 @@ impl Neuronify { } if !found_voltmeter { - if let Some((entity, entity_pos)) = world + // Notes lie under the circuit, + // so anything near the pointer wins over the note around it + let nearby = world .query::<&Position>() + .without::<&Annotation>() .iter() .min_by(|a, b| nearest(&mouse_position, a, b)) - .and_then(|v| within_selection_range(mouse_position, v)) - { + .and_then(|v| { + within_selection_range(mouse_position, v) + }); + let note = || { + world + .query::<(&Position, &NoteSize)>() + .with::<&Annotation>() + .iter() + .filter(|(_, (position, size))| { + let offset = mouse_position - position.position; + offset.x.abs() <= size.height * 0.5 + && offset.z.abs() <= size.width * 0.5 + }) + // The renderer draws later notes on top + .max_by_key(|(entity, _)| entity.id()) + .map(|(entity, (position, _))| { + (entity, position.position) + }) + }; + if let Some((entity, entity_pos)) = nearby.or_else(note) { *active_entity = Some(entity); *dragging_entity = Some(entity); *drag_offset = entity_pos - mouse_position; @@ -943,15 +981,9 @@ impl Neuronify { } pub fn save(&self, path: PathBuf) { - let mut context = SaveContext; - let mut serializer = postcard::Serializer { - output: postcard::ser_flavors::StdVec::new(), - }; - serialize(&self.world, &mut context, &mut serializer).unwrap(); + let bytes = crate::serialization::save(&self.world).unwrap(); let mut writer = std::fs::File::create(path.with_extension("neuronify")).unwrap(); - writer - .write_all(&serializer.output.finalize().unwrap()) - .unwrap(); + writer.write_all(&bytes).unwrap(); } pub fn load_legacy_string(&mut self, contents: &str) { @@ -966,13 +998,14 @@ impl Neuronify { } pub fn loadfile(&mut self, path: PathBuf) { - let mut context = LoadContext::new(); let reader = std::fs::File::open(path).unwrap(); let mut bufreader = BufReader::new(reader); let mut bytes: Vec = Vec::new(); bufreader.read_to_end(&mut bytes).unwrap(); - let mut deserializer = postcard::Deserializer::from_bytes(&bytes); - self.world = deserialize(&mut context, &mut deserializer).unwrap(); + if let Err(error) = self.load_from_bytes(&bytes) { + log::error!("{error}"); + self.load_error = Some(error); + } } /// Replaces the world with a legacy `.nfy` file or a `.neuronify` save file. @@ -1045,6 +1078,8 @@ impl visula::Simulation for Neuronify { self.connection_buffer .update(&application.device, &application.queue, &connections); + self.note_renderer.update(application, &self.world); + let time_diff = Utc::now() - self.last_update; #[cfg(not(target_arch = "wasm32"))] if time_diff < Duration::milliseconds(TARGET_FRAME_MS) { @@ -1064,6 +1099,7 @@ impl visula::Simulation for Neuronify { self.spheres.render(data); self.connection_lines.render(data); self.connection_spheres.render(data); + self.note_renderer.render(data); } fn gui(&mut self, _application: &visula::Application, context: &egui::Context) { @@ -1358,6 +1394,30 @@ impl visula::Simulation for Neuronify { } }); } + if self.world.get::<&Annotation>(active_entity).is_ok() { + ui.collapsing("Note", |ui| { + if let Ok(mut annotation) = + self.world.get::<&mut Annotation>(active_entity) + { + ui.label("Text:"); + ui.add( + egui::TextEdit::multiline(&mut annotation.text) + .desired_rows(4) + .desired_width(f32::INFINITY), + ); + } + if let Ok(mut size) = self.world.get::<&mut NoteSize>(active_entity) { + egui::Grid::new("note_size_settings").show(ui, |ui| { + ui.label("Width:"); + ui.add(egui::Slider::new(&mut size.width, 2.0..=30.0)); + ui.end_row(); + ui.label("Height:"); + ui.add(egui::Slider::new(&mut size.height, 1.0..=20.0)); + ui.end_row(); + }); + } + }); + } }); } } diff --git a/neuronify-core/src/components.rs b/neuronify-core/src/components.rs index 6ae7f279..0bba6623 100644 --- a/neuronify-core/src/components.rs +++ b/neuronify-core/src/components.rs @@ -165,6 +165,21 @@ pub struct Annotation { pub text: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NoteSize { + pub width: f32, + pub height: f32, +} + +impl Default for NoteSize { + fn default() -> Self { + Self { + width: 7.2, + height: 4.8, + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct GeneratorDynamics { pub fired: bool, diff --git a/neuronify-core/src/legacy/convert.rs b/neuronify-core/src/legacy/convert.rs index 4e0e6df7..51217075 100644 --- a/neuronify-core/src/legacy/convert.rs +++ b/neuronify-core/src/legacy/convert.rs @@ -7,9 +7,14 @@ use crate::{Connection, Deletable, NeuronType, Position}; use super::{LegacyEdge, LegacyNode, LegacySimulation}; +const PIXELS_PER_UNIT: f32 = 50.0 / 2.0; + fn convert_position(x: f64, y: f64) -> Vec3 { - let scale = 50.0 / 2.0; - Vec3::new(-(y as f32 - 540.0) / scale, 0.0, (x as f32 - 960.0) / scale) + Vec3::new( + -(y as f32 - 540.0) / PIXELS_PER_UNIT, + 0.0, + (x as f32 - 960.0) / PIXELS_PER_UNIT, + ) } pub fn spawn_legacy_simulation(world: &mut World, sim: &LegacySimulation) -> Vec { @@ -66,9 +71,25 @@ fn spawn_node(world: &mut World, node: &LegacyNode) -> Entity { Deletable {}, )), "meters/SpikeDetector.qml" => world.spawn((pos,)), - s if s.starts_with("annotations/") => { + "annotations/NextTutorial.qml" => world.spawn((pos,)), + "annotations/Note.qml" => { let text = node.text.clone().unwrap_or_default(); - world.spawn((pos, Annotation { text })) + let default_size = NoteSize::default(); + let width = node + .width + .unwrap_or((default_size.width * PIXELS_PER_UNIT).into()); + let height = node + .height + .unwrap_or((default_size.height * PIXELS_PER_UNIT).into()); + // QML places an item by its top left corner, but a note is drawn around its position + let center = Position { + position: convert_position(node.x + width / 2.0, node.y + height / 2.0), + }; + let size = NoteSize { + width: width as f32 / PIXELS_PER_UNIT, + height: height as f32 / PIXELS_PER_UNIT, + }; + world.spawn((center, Annotation { text }, size, Deletable {})) } _ => world.spawn((pos,)), } diff --git a/neuronify-core/src/legacy/tests.rs b/neuronify-core/src/legacy/tests.rs index 78a9f0d7..3c476d36 100644 --- a/neuronify-core/src/legacy/tests.rs +++ b/neuronify-core/src/legacy/tests.rs @@ -412,3 +412,22 @@ fn test_inhibitory_suppresses_firing() { alone_spikes.len() ); } + +#[test] +fn test_annotation_loads_with_size() { + let sim = parse_legacy_nfy(LEAKY_NFY).unwrap(); + let mut world = hecs::World::new(); + spawn_legacy_simulation(&mut world, &sim); + + let mut annotation_count = 0; + for (_entity, (annotation, size)) in world.query::<(&Annotation, &NoteSize)>().iter() { + annotation_count += 1; + assert!(!annotation.text.is_empty()); + assert!(size.width > 0.0); + assert!(size.height > 0.0); + } + assert!( + annotation_count > 0, + "leaky.nfy should produce at least one annotation" + ); +} diff --git a/neuronify-core/src/rendering/mod.rs b/neuronify-core/src/rendering/mod.rs index 3a0dec52..59be0d83 100644 --- a/neuronify-core/src/rendering/mod.rs +++ b/neuronify-core/src/rendering/mod.rs @@ -1,11 +1,13 @@ pub mod colors; pub mod connections; pub mod gpu_types; +pub mod notes; pub mod spheres; pub mod voltmeter; pub use colors::{neurocolor, srgb, srgb_component}; pub use connections::collect_connections; pub use gpu_types::{ConnectionData, LineData, MeshInstanceData, Sphere}; +pub use notes::NoteRenderer; pub use spheres::collect_spheres; pub use voltmeter::collect_voltmeter_traces; diff --git a/neuronify-core/src/rendering/notes.rs b/neuronify-core/src/rendering/notes.rs new file mode 100644 index 00000000..d7398d32 --- /dev/null +++ b/neuronify-core/src/rendering/notes.rs @@ -0,0 +1,413 @@ +use glam::{Mat4, Vec3, Vec4}; +use hecs::Entity; +use std::collections::{HashMap, HashSet}; +use visula::{ + text::{tessellate_text_wrapped, TessellatedGeometry}, + Expression, PolygonDelegate, PolygonVertex, Polygons, +}; + +use crate::components::{Annotation, NoteSize, Position}; + +/// DejaVu Sans 2.37, cut down to Latin, Greek, punctuation, super- and subscripts, +/// arrows and math operators with +/// `pyftsubset DejaVuSans.ttf --unicodes=U+0020-007E,U+00A0-017F,U+0370-03FF,U+2000-209F,U+2190-22FF +/// --no-hinting --layout-features=''`. +/// Other characters are left out of the note. +pub const NOTE_FONT_DATA: &[u8] = include_bytes!("../../assets/DejaVuSans.ttf"); + +pub const NOTE_TEXT_HEIGHT: f32 = 0.8; +pub const NOTE_MARGIN: f32 = 0.4; +pub const NOTE_BACKGROUND_Y: f32 = 0.01; +pub const NOTE_TEXT_Y: f32 = 0.02; + +/// The geometry of one note around its own center, rebuilt only when its text or size changes. +struct NoteGeometry { + text: String, + width: f32, + height: f32, + half_extent: [f32; 2], + background: TessellatedGeometry, + glyphs: TessellatedGeometry, +} + +/// Draws every note with two pipelines, one for the backgrounds and one for the text. +/// Moving a note only uploads new vertices. +pub struct NoteRenderer { + backgrounds: Polygons, + text: Polygons, + notes: HashMap, + uploaded: Vec<(Entity, Vec3)>, +} + +impl NoteRenderer { + pub fn new(application: &visula::Application) -> Self { + let polygons = |color: Vec4, y: f32| { + Polygons::new( + &application.rendering_descriptor(), + &PolygonDelegate { + color: color.into(), + transform: Expression::from(note_plane(y)), + }, + &[], + &[], + ) + .unwrap() + }; + Self { + backgrounds: polygons(Vec4::new(0.95, 0.95, 0.85, 1.0), NOTE_BACKGROUND_Y), + text: polygons(Vec4::new(0.1, 0.1, 0.15, 1.0), NOTE_TEXT_Y), + notes: HashMap::new(), + uploaded: Vec::new(), + } + } + + pub fn update(&mut self, application: &visula::Application, world: &hecs::World) { + let mut changed = false; + let mut placed = Vec::new(); + for (entity, (annotation, position, size)) in + world.query::<(&Annotation, &Position, &NoteSize)>().iter() + { + let current = self.notes.get(&entity).is_some_and(|note| { + note.text == annotation.text + && note.width == size.width + && note.height == size.height + }); + if !current { + self.notes + .insert(entity, build_note_geometry(&annotation.text, size)); + changed = true; + } + placed.push((entity, position.position)); + } + // Later notes are drawn on top of earlier ones + placed.sort_by_key(|(entity, _)| entity.id()); + let present: HashSet = placed.iter().map(|(entity, _)| *entity).collect(); + self.notes.retain(|entity, _| present.contains(entity)); + + if !changed && placed == self.uploaded { + return; + } + let (backgrounds, text) = layout(&self.notes, &placed); + self.backgrounds.update_geometry( + &application.device, + &application.queue, + &backgrounds.vertices, + &backgrounds.indices, + ); + self.text.update_geometry( + &application.device, + &application.queue, + &text.vertices, + &text.indices, + ); + self.uploaded = placed; + } + + pub fn render(&self, data: &mut visula::RenderData) { + use visula::Renderable; + self.backgrounds.render(data); + self.text.render(data); + } +} + +/// Places every note's geometry at its position, in drawing order, +/// returning the backgrounds and the text. +fn layout( + notes: &HashMap, + placed: &[(Entity, Vec3)], +) -> (TessellatedGeometry, TessellatedGeometry) { + let rectangles: Vec = placed + .iter() + .map(|(entity, position)| Rectangle::around(*position, notes[entity].half_extent)) + .collect(); + let mut backgrounds = TessellatedGeometry::default(); + let mut text = TessellatedGeometry::default(); + for (index, (entity, position)) in placed.iter().enumerate() { + let note = ¬es[entity]; + // The notes are flat, so a note on top hides whatever lies under it + let covers: Vec = rectangles[index + 1..] + .iter() + .filter(|cover| cover.overlaps(&rectangles[index])) + .copied() + .collect(); + backgrounds.merge(&uncovered(translated(¬e.background, *position), &covers)); + text.merge(&uncovered(translated(¬e.glyphs, *position), &covers)); + } + (backgrounds, text) +} + +fn build_note_geometry(text: &str, size: &NoteSize) -> NoteGeometry { + let width = size.width.max(0.5); + let height = size.height.max(0.5); + let inner_width = (width - 2.0 * NOTE_MARGIN).max(0.5); + let glyphs = if text.is_empty() { + TessellatedGeometry::default() + } else { + match tessellate_text_wrapped(NOTE_FONT_DATA, text, NOTE_TEXT_HEIGHT, inner_width) { + // Lines start at the top left and go down, so shift the block to center it + Ok(wrapped) => shifted( + &wrapped.geometry, + -wrapped.width * 0.5, + wrapped.height * 0.5, + ), + Err(e) => { + log::error!("Failed to tessellate note text: {e:?}"); + TessellatedGeometry::default() + } + } + }; + NoteGeometry { + text: text.to_string(), + width: size.width, + height: size.height, + half_extent: [width * 0.5, height * 0.5], + background: rectangle(width, height), + glyphs, + } +} + +/// Maps the polygons' local (x, y, 0) onto the ground at height `y`. +/// Local x goes to world +Z (screen right) and local y to world +X (screen up), +/// matching how `convert_position` rotates the legacy QML scene. +fn note_plane(y: f32) -> Mat4 { + Mat4::from_cols( + Vec4::new(0.0, 0.0, 1.0, 0.0), + Vec4::new(1.0, 0.0, 0.0, 0.0), + Vec4::new(0.0, 1.0, 0.0, 0.0), + Vec4::new(0.0, y, 0.0, 1.0), + ) +} + +/// Moves geometry around a note's center to the note's position on the ground. +fn translated(geometry: &TessellatedGeometry, position: Vec3) -> TessellatedGeometry { + shifted(geometry, position.z, position.x) +} + +fn shifted(geometry: &TessellatedGeometry, dx: f32, dy: f32) -> TessellatedGeometry { + TessellatedGeometry { + vertices: geometry + .vertices + .iter() + .map(|v| PolygonVertex { + position: [v.position[0] + dx, v.position[1] + dy], + }) + .collect(), + indices: geometry.indices.clone(), + } +} + +/// An axis-aligned rectangle in the plane of the notes. +#[derive(Clone, Copy)] +struct Rectangle { + min: [f32; 2], + max: [f32; 2], +} + +impl Rectangle { + fn around(position: Vec3, half_extent: [f32; 2]) -> Self { + let [x, y] = [position.z, position.x]; + Self { + min: [x - half_extent[0], y - half_extent[1]], + max: [x + half_extent[0], y + half_extent[1]], + } + } + + fn bounding(points: &[[f32; 2]]) -> Self { + points.iter().fold( + Self { + min: [f32::MAX; 2], + max: [f32::MIN; 2], + }, + |bounds, point| Self { + min: [bounds.min[0].min(point[0]), bounds.min[1].min(point[1])], + max: [bounds.max[0].max(point[0]), bounds.max[1].max(point[1])], + }, + ) + } + + fn overlaps(&self, other: &Rectangle) -> bool { + (0..2).all(|axis| self.min[axis] < other.max[axis] && other.min[axis] < self.max[axis]) + } + + fn contains(&self, other: &Rectangle) -> bool { + (0..2).all(|axis| self.min[axis] <= other.min[axis] && other.max[axis] <= self.max[axis]) + } +} + +/// Removes the parts of the triangles that lie under any of the covers. +fn uncovered(geometry: TessellatedGeometry, covers: &[Rectangle]) -> TessellatedGeometry { + if covers.is_empty() { + return geometry; + } + let mut result = TessellatedGeometry::default(); + for triangle in geometry.indices.as_chunks::<3>().0 { + let mut pieces = vec![triangle + .iter() + .map(|&index| geometry.vertices[index as usize].position) + .collect::>()]; + for cover in covers { + pieces = pieces + .iter() + .flat_map(|piece| outside(piece, cover)) + .collect(); + } + for piece in pieces { + let first = result.vertices.len() as u32; + result + .vertices + .extend(piece.iter().map(|&position| PolygonVertex { position })); + for corner in 1..piece.len() as u32 - 1 { + result + .indices + .extend([first, first + corner, first + corner + 1]); + } + } + } + result +} + +/// Splits a convex polygon into the convex pieces that lie outside the cover. +fn outside(polygon: &[[f32; 2]], cover: &Rectangle) -> Vec> { + let bounds = Rectangle::bounding(polygon); + if !bounds.overlaps(cover) { + return vec![polygon.to_vec()]; + } + if cover.contains(&bounds) { + return vec![]; + } + // The outside of the cover as four regions that don't overlap: + // left and right of it, and below and above it in between + let regions: [&[(usize, f32, bool)]; 4] = [ + &[(0, cover.min[0], true)], + &[(0, cover.max[0], false)], + &[ + (0, cover.min[0], false), + (0, cover.max[0], true), + (1, cover.min[1], true), + ], + &[ + (0, cover.min[0], false), + (0, cover.max[0], true), + (1, cover.max[1], false), + ], + ]; + regions + .iter() + .map(|half_planes| { + half_planes + .iter() + .fold(polygon.to_vec(), |piece, &(axis, bound, below)| { + clip(&piece, axis, bound, below) + }) + }) + .filter(|piece| piece.len() >= 3) + .collect() +} + +/// Keeps the part of a convex polygon at or below `bound` along `axis`, or at or above it. +fn clip(polygon: &[[f32; 2]], axis: usize, bound: f32, below: bool) -> Vec<[f32; 2]> { + let inside = |point: &[f32; 2]| { + if below { + point[axis] <= bound + } else { + point[axis] >= bound + } + }; + let mut result = Vec::new(); + for (index, current) in polygon.iter().enumerate() { + let previous = &polygon[(index + polygon.len() - 1) % polygon.len()]; + if inside(current) != inside(previous) { + let t = (bound - previous[axis]) / (current[axis] - previous[axis]); + result.push([ + previous[0] + t * (current[0] - previous[0]), + previous[1] + t * (current[1] - previous[1]), + ]); + } + if inside(current) { + result.push(*current); + } + } + result +} + +fn rectangle(width: f32, height: f32) -> TessellatedGeometry { + let (u, v) = (width * 0.5, height * 0.5); + TessellatedGeometry { + vertices: [[-u, -v], [u, -v], [u, v], [-u, v]] + .into_iter() + .map(|position| PolygonVertex { position }) + .collect(), + indices: vec![0, 1, 2, 0, 2, 3], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tessellates_short_text() { + let wrapped = + tessellate_text_wrapped(NOTE_FONT_DATA, "Hello", NOTE_TEXT_HEIGHT, 100.0).unwrap(); + assert!(!wrapped.geometry.vertices.is_empty()); + assert!(!wrapped.geometry.indices.is_empty()); + assert!(wrapped.width > 0.0); + assert!(wrapped.height > 0.0); + } + + #[test] + fn wraps_long_text_into_multiple_lines() { + let text = + "This is a long note that should wrap onto several lines when given a narrow width"; + let narrow = tessellate_text_wrapped(NOTE_FONT_DATA, text, NOTE_TEXT_HEIGHT, 4.0).unwrap(); + let wide = tessellate_text_wrapped(NOTE_FONT_DATA, text, NOTE_TEXT_HEIGHT, 100.0).unwrap(); + assert!( + narrow.height > wide.height, + "narrow width should produce more lines (taller): narrow={} wide={}", + narrow.height, + wide.height + ); + } + + fn area(geometry: &TessellatedGeometry) -> f32 { + geometry + .indices + .as_chunks::<3>() + .0 + .iter() + .map(|triangle| { + let [a, b, c] = triangle.map(|i| geometry.vertices[i as usize].position); + ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() / 2.0 + }) + .sum() + } + + #[test] + fn hides_what_a_note_on_top_covers() { + let corner = Rectangle { + min: [0.0, 0.0], + max: [3.0, 3.0], + }; + let visible = uncovered(rectangle(2.0, 2.0), &[corner]); + assert!((area(&visible) - 3.0).abs() < 1e-5); + assert!(visible + .vertices + .iter() + .all(|v| v.position[0] <= 1e-6 || v.position[1] <= 1e-6)); + + let middle = Rectangle { + min: [-0.5, -0.5], + max: [0.5, 0.5], + }; + let visible = uncovered(rectangle(2.0, 2.0), &[middle]); + assert!((area(&visible) - 3.0).abs() < 1e-5); + } + + #[test] + fn centers_text_in_the_note() { + let note = build_note_geometry("Hello there", &NoteSize::default()); + let xs = note.glyphs.vertices.iter().map(|v| v.position[0]); + let (min, max) = xs.fold((f32::MAX, f32::MIN), |(a, b), x| (a.min(x), b.max(x))); + assert!((min + max).abs() < 0.1, "min={min} max={max}"); + } +} diff --git a/neuronify-core/src/serialization.rs b/neuronify-core/src/serialization.rs deleted file mode 100644 index b11fefcc..00000000 --- a/neuronify-core/src/serialization.rs +++ /dev/null @@ -1,230 +0,0 @@ -use crate::components::*; -use crate::measurement::voltmeter::{VoltageSeries, Voltmeter}; -use hecs::{serialize::column::*, *}; -use serde::{Deserialize, Serialize}; -use std::any::TypeId; - -macro_rules! component_id { - ($($names:tt,)*) => { - #[derive(Deserialize, Serialize)] - enum ComponentId { - $($names),* - } - pub struct SaveContext; - impl SerializeContext for SaveContext { - fn component_count(&self, archetype: &Archetype) -> usize { - archetype.component_types() - .filter(|&t| $(t == TypeId::of::<$names>()) ||*) - .count() - } - - fn serialize_component_ids( - &mut self, - archetype: &Archetype, - mut out: S, - ) -> Result { - $(try_serialize_id::<$names, _, _>(archetype, &ComponentId::$names, &mut out)?;)* - out.end() - } - - fn serialize_components( - &mut self, - archetype: &Archetype, - mut out: S, - ) -> Result { - $(try_serialize::<$names, _>(archetype, &mut out)?;)* - out.end() - } - } - - pub struct LoadContext { - components: Vec, - } - - impl Default for LoadContext { - fn default() -> Self { - Self::new() - } - } - - impl LoadContext { - pub fn new() -> LoadContext { - LoadContext { components: vec![] } - } - } - - impl DeserializeContext for LoadContext { - fn deserialize_component_ids<'de, A>( - &mut self, - mut seq: A, - ) -> Result - where - A: serde::de::SeqAccess<'de>, - { - self.components.clear(); // Discard data from the previous archetype - let mut batch = ColumnBatchType::new(); - while let Some(id) = seq.next_element()? { - match id { - $( - ComponentId::$names => { - batch.add::<$names>(); - } - )* - } - self.components.push(id); - } - Ok(batch) - } - - fn deserialize_components<'de, A>( - &mut self, - entity_count: u32, - mut seq: A, - batch: &mut ColumnBatchBuilder, - ) -> Result<(), A::Error> - where - A: serde::de::SeqAccess<'de>, - { - // Decode component data in the order that the component IDs appeared - for component in &self.components { - match *component { - $( - ComponentId::$names => { - deserialize_column::<$names, _>(entity_count, &mut seq, batch)?; - } - )* - } - } - Ok(()) - } - } - - } -} - -component_id!( - Position, - LeakyNeuron, - LeakyDynamics, - LeakCurrent, - AdaptationCurrent, - CurrentClamp, - CurrentSynapse, - ImmediateFireSynapse, - Inhibitory, - TouchSensor, - GeneratorDynamics, - RegularSpikeGenerator, - PoissonGenerator, - VoltmeterSize, - Annotation, - StaticConnectionSource, - Deletable, - Selectable, - Voltmeter, - VoltageSeries, - Connection, - CompartmentCurrent, - Compartment, - 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/serialization/fixtures/v1.neuronify b/neuronify-core/src/serialization/fixtures/v1.neuronify new file mode 100644 index 00000000..eac93b20 Binary files /dev/null and b/neuronify-core/src/serialization/fixtures/v1.neuronify differ diff --git a/neuronify-core/src/serialization/fixtures/v2.neuronify b/neuronify-core/src/serialization/fixtures/v2.neuronify new file mode 100644 index 00000000..884b58f3 Binary files /dev/null and b/neuronify-core/src/serialization/fixtures/v2.neuronify differ diff --git a/neuronify-core/src/serialization/mod.rs b/neuronify-core/src/serialization/mod.rs new file mode 100644 index 00000000..5fa393ee --- /dev/null +++ b/neuronify-core/src/serialization/mod.rs @@ -0,0 +1,271 @@ +//! Save files (`.neuronify`) and scenarios. +//! +//! A save file starts with `MAGIC` and its version as a little-endian `u32`, +//! followed by the world in the hecs column format. +//! +//! Each version has a module with the components it can hold. +//! postcard writes a component id as its position in that list, +//! so a list must not change once files have been saved with it. +//! To change the list, or the fields of a component, add a version: +//! copy the newest module to `v{N+1}`, make the change there, +//! write `upgrade_from_v{N}` to turn a version N world into a version N+1 world, +//! and save a fixture with the `write_fixture` test. +//! If the fields of a component change, +//! the older modules need a copy of the old struct for the upgrade to convert. + +use hecs::{serialize::column::serialize, World}; +use postcard::ser_flavors::Flavor; + +#[macro_use] +mod schema; +mod v1; +mod v2; + +/// The version that `save` writes. +pub const VERSION: u32 = 2; + +const MAGIC: &[u8] = b"neuronify"; + +pub fn save(world: &World) -> Result, String> { + let mut serializer = postcard::Serializer { + output: postcard::ser_flavors::StdVec::new(), + }; + serialize(world, &mut v2::SaveContext, &mut serializer) + .map_err(|e| format!("Could not save: {e}"))?; + let body = serializer + .output + .finalize() + .map_err(|e| format!("Could not save: {e}"))?; + let mut bytes = MAGIC.to_vec(); + bytes.extend_from_slice(&VERSION.to_le_bytes()); + bytes.extend_from_slice(&body); + Ok(bytes) +} + +/// Reads a save file of any version and upgrades it to the current one. +pub fn load(bytes: &[u8]) -> Result { + let (version, body) = bytes + .strip_prefix(MAGIC) + .ok_or("This is not a Neuronify save file")? + .split_first_chunk() + .ok_or("The save file ends before its version")?; + let version = u32::from_le_bytes(*version); + let mut world = match version { + 1 => v1::deserialize(body)?, + 2 => v2::deserialize(body)?, + _ => { + return Err(format!( + "The save file has version {version}, \ + but this Neuronify reads versions 1 to {VERSION}" + )) + } + }; + for from in version..VERSION { + match from { + 1 => v2::upgrade_from_v1(&mut world), + _ => unreachable!("no upgrade from version {from}"), + } + } + Ok(world) +} + +/// Reads a legacy `.nfy` file (JSON) or a `.neuronify` save file. +pub fn load_from_bytes(bytes: &[u8]) -> Result { + if bytes.starts_with(MAGIC) { + load(bytes) + } else { + load_legacy(bytes) + } +} + +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) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::*; + use crate::measurement::voltmeter::{VoltageSeries, Voltmeter}; + + const LEAKY_NFY: &str = include_str!("../../examples/leaky.nfy"); + + #[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 bytes = save(&original).unwrap(); + assert!(bytes.starts_with(MAGIC)); + + let loaded = load_from_bytes(&bytes).unwrap(); + assert_eq!(loaded.len(), original.len()); + } + + #[test] + fn rejects_html() { + assert!(load_from_bytes(b"").is_err()); + } + + #[test] + fn rejects_newer_versions() { + let mut bytes = MAGIC.to_vec(); + bytes.extend_from_slice(&(VERSION + 1).to_le_bytes()); + assert!(load(&bytes).is_err_and(|e| e.contains("reads versions 1 to"))); + } + + #[test] + fn rejects_truncated_header() { + assert!(load(b"neuronify\x01").is_err()); + } + + #[test] + fn loads_version_1() { + // Version 1 turned the link to the next tutorial into an empty note + assert_fixture(&load(include_bytes!("fixtures/v1.neuronify")).unwrap(), 30); + } + + #[test] + fn loads_version_2() { + assert_fixture(&load(include_bytes!("fixtures/v2.neuronify")).unwrap(), 29); + } + + /// Writes `fixture_world` as `fixtures/v{VERSION}.neuronify`. + /// Run it when adding a version, so that version keeps loading: + /// `cargo test -p neuronify-core write_fixture -- --ignored` + #[test] + #[ignore] + fn write_fixture() { + let path = format!( + "{}/src/serialization/fixtures/v{VERSION}.neuronify", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::write(path, save(&fixture_world()).unwrap()).unwrap(); + } + + /// Bundled examples, plus the components that none of them have, + /// spawned the way the editor tools spawn them. + fn fixture_world() -> World { + let examples = [ + include_str!("../../examples/adaptation.nfy"), + include_str!("../../examples/burst.nfy"), + include_str!("../../examples/disinhibition.nfy"), + include_str!("../../examples/frPlot.nfy"), + include_str!("../../examples/generators.nfy"), + include_str!("../../examples/if_response.nfy"), + include_str!("../../examples/inhibitory.nfy"), + include_str!("../../examples/input_summation.nfy"), + include_str!("../../examples/lateral_inhibition.nfy"), + include_str!("../../examples/leaky.nfy"), + include_str!("../../examples/tutorial_1_intro.nfy"), + include_str!("../../examples/two_neuron_oscillator.nfy"), + include_str!("../../examples/visualInput.nfy"), + ]; + let mut world = World::new(); + for example in examples { + let simulation = crate::legacy::parse_legacy_nfy(example).unwrap(); + crate::legacy::convert::spawn_legacy_simulation(&mut world, &simulation); + } + let position = |x: f32| Position { + position: glam::Vec3::new(x, 0.0, 30.0), + }; + world.spawn(( + position(0.0), + RegularSpikeGenerator::default(), + GeneratorDynamics::default(), + Deletable {}, + )); + world.spawn(( + position(3.0), + PoissonGenerator::default(), + GeneratorDynamics::default(), + Deletable {}, + )); + let neuron = world.query::<&LeakyNeuron>().iter().next().unwrap().0; + let compartment = world.spawn(( + position(6.0), + NeuronType::Excitatory, + Compartment { + voltage: -10.0, + m: -0.625, + h: 0.0, + n: 0.0, + influence: 0.0, + capacitance: 1.0, + injected_current: 0.0, + fire_impulse: 0.0, + }, + StaticConnectionSource {}, + Deletable {}, + Selectable { selected: false }, + SpatialDynamics { + velocity: glam::Vec3::ZERO, + acceleration: glam::Vec3::ZERO, + }, + )); + world.spawn(( + Connection { + from: neuron, + to: compartment, + strength: 1.0, + directional: false, + }, + Deletable {}, + CompartmentCurrent { + capacitance: crate::constants::COUPLING_CAPACITANCE, + }, + )); + world + } + + fn count(world: &World) -> usize { + world.query::<&T>().iter().count() + } + + /// Checks a fixture after it has been upgraded to the current version. + fn assert_fixture(world: &World, notes: usize) { + assert_eq!(world.len(), 190); + assert_eq!(count::(world), 125); + assert_eq!(count::(world), 44); + assert_eq!(count::(world), 44); + assert_eq!(count::(world), 44); + assert_eq!(count::(world), 1); + assert_eq!(count::(world), 10); + assert_eq!(count::(world), 45); + assert_eq!(count::(world), 14); + assert_eq!(count::(world), 7); + assert_eq!(count::(world), 14); + assert_eq!(count::(world), 16); + assert_eq!(count::(world), 1); + assert_eq!(count::(world), 1); + assert_eq!(count::(world), 14); + assert_eq!(count::(world), notes); + assert_eq!(count::(world), notes); + assert_eq!(count::(world), 1); + assert_eq!(count::(world), 145 + notes); + assert_eq!(count::(world), 1); + assert_eq!(count::(world), 14); + assert_eq!(count::(world), 14); + assert_eq!(count::(world), 79); + assert_eq!(count::(world), 1); + assert_eq!(count::(world), 1); + assert_eq!(count::(world), 1); + assert_eq!(count::(world), 45); + + for (_, connection) in world.query::<&Connection>().iter() { + assert!(world.contains(connection.from) && world.contains(connection.to)); + } + assert!(world + .query::<&Annotation>() + .iter() + .any(|(_, a)| a.text == "Touch the sensor to make the leaky neuron fire.")); + } +} diff --git a/neuronify-core/src/serialization/schema.rs b/neuronify-core/src/serialization/schema.rs new file mode 100644 index 00000000..b9f3ddc6 --- /dev/null +++ b/neuronify-core/src/serialization/schema.rs @@ -0,0 +1,107 @@ +/// Defines the component ids of one save file version and a `deserialize` that reads it. +/// With `save` first, it also defines the `SaveContext` that writes it. +/// Only the current version has one. +macro_rules! schema { + (save, $($names:ident,)*) => { + schema!($($names,)*); + + pub struct SaveContext; + + impl ::hecs::serialize::column::SerializeContext for SaveContext { + fn component_count(&self, archetype: &::hecs::Archetype) -> usize { + archetype.component_types() + .filter(|&t| $(t == ::std::any::TypeId::of::<$names>()) ||*) + .count() + } + + fn serialize_component_ids( + &mut self, + archetype: &::hecs::Archetype, + mut out: S, + ) -> Result { + $(::hecs::serialize::column::try_serialize_id::<$names, _, _>( + archetype, + &ComponentId::$names, + &mut out, + )?;)* + out.end() + } + + fn serialize_components( + &mut self, + archetype: &::hecs::Archetype, + mut out: S, + ) -> Result { + $(::hecs::serialize::column::try_serialize::<$names, _>(archetype, &mut out)?;)* + out.end() + } + } + }; + ($($names:ident,)*) => { + #[derive(::serde::Deserialize, ::serde::Serialize)] + enum ComponentId { + $($names),* + } + + struct LoadContext { + components: Vec, + } + + impl ::hecs::serialize::column::DeserializeContext for LoadContext { + fn deserialize_component_ids<'de, A>( + &mut self, + mut seq: A, + ) -> Result<::hecs::ColumnBatchType, A::Error> + where + A: ::serde::de::SeqAccess<'de>, + { + self.components.clear(); // Discard data from the previous archetype + let mut batch = ::hecs::ColumnBatchType::new(); + while let Some(id) = seq.next_element()? { + match id { + $( + ComponentId::$names => { + batch.add::<$names>(); + } + )* + } + self.components.push(id); + } + Ok(batch) + } + + fn deserialize_components<'de, A>( + &mut self, + entity_count: u32, + mut seq: A, + batch: &mut ::hecs::ColumnBatchBuilder, + ) -> Result<(), A::Error> + where + A: ::serde::de::SeqAccess<'de>, + { + // Decode component data in the order that the component IDs appeared + for component in &self.components { + match *component { + $( + ComponentId::$names => { + ::hecs::serialize::column::deserialize_column::<$names, _>( + entity_count, + &mut seq, + batch, + )?; + } + )* + } + } + Ok(()) + } + } + + pub fn deserialize(bytes: &[u8]) -> Result<::hecs::World, String> { + let mut context = LoadContext { components: vec![] }; + let mut deserializer = ::postcard::Deserializer::from_bytes(bytes); + ::hecs::serialize::column::deserialize(&mut context, &mut deserializer) + .map_err(|e| format!("Could not read save file: {e}")) + } + }; +} diff --git a/neuronify-core/src/serialization/v1.rs b/neuronify-core/src/serialization/v1.rs new file mode 100644 index 00000000..a9dab07a --- /dev/null +++ b/neuronify-core/src/serialization/v1.rs @@ -0,0 +1,32 @@ +//! The first version of the save file format. + +use crate::components::*; +use crate::measurement::voltmeter::{VoltageSeries, Voltmeter}; + +schema!( + Position, + LeakyNeuron, + LeakyDynamics, + LeakCurrent, + AdaptationCurrent, + CurrentClamp, + CurrentSynapse, + ImmediateFireSynapse, + Inhibitory, + TouchSensor, + GeneratorDynamics, + RegularSpikeGenerator, + PoissonGenerator, + VoltmeterSize, + Annotation, + StaticConnectionSource, + Deletable, + Selectable, + Voltmeter, + VoltageSeries, + Connection, + CompartmentCurrent, + Compartment, + SpatialDynamics, + NeuronType, +); diff --git a/neuronify-core/src/serialization/v2.rs b/neuronify-core/src/serialization/v2.rs new file mode 100644 index 00000000..a71eff1a --- /dev/null +++ b/neuronify-core/src/serialization/v2.rs @@ -0,0 +1,50 @@ +//! Notes get a size and can be deleted. + +use crate::components::*; +use crate::measurement::voltmeter::{VoltageSeries, Voltmeter}; +use hecs::World; + +schema!( + save, + Position, + LeakyNeuron, + LeakyDynamics, + LeakCurrent, + AdaptationCurrent, + CurrentClamp, + CurrentSynapse, + ImmediateFireSynapse, + Inhibitory, + TouchSensor, + GeneratorDynamics, + RegularSpikeGenerator, + PoissonGenerator, + VoltmeterSize, + Annotation, + NoteSize, + StaticConnectionSource, + Deletable, + Selectable, + Voltmeter, + VoltageSeries, + Connection, + CompartmentCurrent, + Compartment, + SpatialDynamics, + NeuronType, +); + +/// Gives every note the default size and lets it be deleted, +/// like notes made with the note tool. +pub fn upgrade_from_v1(world: &mut World) { + let notes: Vec<_> = world + .query::<&Annotation>() + .iter() + .map(|(entity, _)| entity) + .collect(); + for entity in notes { + world + .insert(entity, (NoteSize::default(), Deletable {})) + .unwrap(); + } +} diff --git a/neuronify-core/src/tools.rs b/neuronify-core/src/tools.rs index 36bfc2c3..1b3ed760 100644 --- a/neuronify-core/src/tools.rs +++ b/neuronify-core/src/tools.rs @@ -16,6 +16,7 @@ pub enum Tool { Axon, Erase, Stimulate, + Note, } #[derive(Clone, Debug, PartialEq)] @@ -23,6 +24,7 @@ pub enum ToolCategory { Interaction, Neurons, Connections, + Annotations, } impl ToolCategory { @@ -31,6 +33,7 @@ impl ToolCategory { ToolCategory::Interaction => "Interaction", ToolCategory::Neurons => "Neurons", ToolCategory::Connections => "Connections", + ToolCategory::Annotations => "Annotations", } } pub fn tools(&self) -> Vec<(Tool, &str)> { @@ -53,14 +56,16 @@ impl ToolCategory { (Tool::StaticConnection, "Static Connection"), (Tool::Axon, "Axon"), ], + ToolCategory::Annotations => vec![(Tool::Note, "Note")], } } } -pub const TOOL_CATEGORIES: [ToolCategory; 3] = [ +pub const TOOL_CATEGORIES: [ToolCategory; 4] = [ ToolCategory::Interaction, ToolCategory::Neurons, ToolCategory::Connections, + ToolCategory::Annotations, ]; #[derive(Clone, Debug, Deserialize, Serialize)]