From e47208c89fce28b5c79371f5604150994b6c5d0f Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Tue, 20 Oct 2026 11:51:48 +0200 Subject: [PATCH 1/2] Add a version to save files --- neuronify-core/src/app.rs | 20 +- neuronify-core/src/serialization.rs | 230 ---------------- .../src/serialization/fixtures/v1.neuronify | Bin 0 -> 16574 bytes neuronify-core/src/serialization/mod.rs | 253 ++++++++++++++++++ neuronify-core/src/serialization/schema.rs | 107 ++++++++ neuronify-core/src/serialization/v1.rs | 33 +++ 6 files changed, 399 insertions(+), 244 deletions(-) delete mode 100644 neuronify-core/src/serialization.rs create mode 100644 neuronify-core/src/serialization/fixtures/v1.neuronify create mode 100644 neuronify-core/src/serialization/mod.rs create mode 100644 neuronify-core/src/serialization/schema.rs create mode 100644 neuronify-core/src/serialization/v1.rs diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index adb6b31a..027acdb3 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -4,13 +4,10 @@ use crate::measurement::voltmeter::{RollingWindow, VoltageSeries, Voltmeter}; use crate::rendering::{ collect_connections, collect_spheres, collect_voltmeter_traces, ConnectionData, 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; @@ -943,15 +940,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 +957,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. 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 0000000000000000000000000000000000000000..eac93b20f7b8387c180130aba8122391400ffe60 GIT binary patch literal 16574 zcmeHO3w%`Nm7f8HIw}T@mG)QhTDu58$yBs-Dcg{QL_rJk5M^Cy>z$c9nJY7MhnYLX z7`sLk#0LZv6|~|5RD4B5)c7Qc6(0q)zT$(fB5GBr#a7g6*Y5v+?jv(EnHgXg+U{d{8O&*wYnd;NU=K7Gpu&1r6KE?Z2vg0P&hf^ZvQ z72$Tm9fUT*LxhJ3PZFLYyhwP7@G{{Q!fS-r3BMyqd z1%PV_%L%s;RuR?^hRtZHf(^c=^45-7l_%G*4O&Ne>r>TEJJ5B=ShfRQN40N0cOSPs zwQYdg_W9nHzT{~tAMDbnwtel=TgM$K?Q54U>u|l7u$|q9M@^LbY|W5wkf0Bc1!1u_8gve zN1xVlDs;AVgS~E>dgjpE=WhLt=tDU^U$eBS-^xwDkoLw+_u{jg?&#k(c&=*;`)=Iu zWAP{ZKJr-C>u;U6wG!hDp1bwlb+Es4$I!dhVgAN#K6mXpf1qncsxt84I{rMRt+ndm zJ>KE(P^Dcqde7Ui>nIBhLgy@^JK!7Nb>P}-o)+s4uy<%Vk=LjUGA{U^Y9PQ z!OFCiJD+hSI}~zj!JPj84m(r%{(-#oKbLWZj4n7r?dSI#Rr~AJW12tf_w%I_PLlFu$hFTFLdwTO&QU!NTMDqGB! zTTUD||L6aHcM%z%Y+CT3-tWpPM~xnz@L1^UJ5KuM8w-kX3>!JS-!;iAig4@~)BWH6 zb1{F+UB0Y-A#&3lKhGbft(`p8+SwGAv3EE|j{Day#~rh}h%M_+FQw_}r|9G57*pSM8;n~S8E4dGOGy3e0v98I8OP?MP1j(*~a1eTYrDy;IK!Z zQs4j9`u<-Z--(+-pZ@;5d)TLJ5RUrYr+J=e_rS7CC2}WZ^ySCCv#{|5_e^6c`F547 z?0|iI`vuBy&(K_U31JE08N#!KO@!YP{)6xp;V*>$Bz#TyE8#l1sVM(g1W$eL{fgpGtZ z32zZTBz#2JO4vqN%Gp>(xQTExVFh6&;TFPOg!>3<3HK9nZaO-(O8#heoIR5yfq>bC0npu?}2|sUSHg2%` z4x4dMb)YLObHbd!+*ey;WpQ&f$)@k9ZXMSleHkCaW;`!ED^oR{&aD&VbY8pcTS7l# z?$+*Gr~L<4N25bT4(W5Z4wN=;kv48PKxDE#e)DLdQ-_>3Ziq$*y)w|h#?g(pNKNHq zZ2D6*#~+^&In;Yb%cZuxXS7G7r%QYDxhreNZ?4+xc zv~$9`sVPwi*Jf+!M#a<!~=soyF*S5|f#<6jI|#SWkzux(S~L0#h|Lq9kah8p^XwQ6bo2R}xw{rY7}FLfoWg zGDcWeWzJz+I!VXU)Px3cjmppontATbY-YGJ-H1n{^3#ND%+!Y~wOTwrTsiL85hI7K z@pED@5$v6&4^wf&R5F-r7~SKi1YDR+Yhq6(rO(9VAx03fn8Ju+MB!7oC~cWzxe+!_ zk7-Fxa#9PM=*z&Sxh8Cp4i@8Z84V*Om6Van#$gy{C|5B!nQA42w~Ef9ENEdh9ws|P z8_5c&ToFYeWf326hcZshW->a2Ht5NS(QxL7pAJ_VVw#Mv*+hIq%oqeArZEkXv$GjW z3n4?`fGqNi6NRn{dDyyR2F|Fpj21Du zmWYe(h_an$IRUEaC3FkncgnN}f4kOr8plwqQ)4ufokY(>o&ax-Sck^crdq#JZQd%_4F zcnpw4pnzKo1_Cu9l#Ej=$LYL2lTAOBZw+dD-k&Udv>~IQlw2EUjOBd_{$Jsj624D1_#3WQQ z*+fG8I64rRXduBX4n~xgQ{WnO6ce}H8(2K71!z{5zVX(+Oku{=M#TX}}FwVy$*yYXs89OPN1gc8w}*nTqvevT``hJgO!yM%RUvR z8zV@nDV)QcD0D)|CW&!aeNrqM4cNNL>_a$RYK>nMF_PRDBK}cWxs#wLtvQ2CduF_IU{_4DF$G!7 zWAPL+)Go_dY-9@qN!SWEGE)qNc@s8~p0l-twU%NoFfu1Eg3%>v;gyrQvPP`=mB?|W z1gmn_`?^|pSbb?KK=G)M+_623aw}pG z?{;_WBzsv7=`7ib?ZHncOU_c3+#2U)dCHs3NmCeMMat{Nrv>|wFDPDIm1{nWMI37IL`F81LEeu-Nk%9JR$5H`>buUZXS&# z5KAoffjK`y8WtYxS*+y;!_zjd1@&pI(F$g3Zro%EZiRufjkuX$6^C^=W?*~KSZtZ< z9+AbTP9hU~kEmh9;TU@po55~Dl7ZYkD=-%pC~F5v19w=x)M>6v-0s9tK{HAmyZ`Qf z*p{<4KmtlOG8GAAr5J}RG%3NW{nNELPpOQUQ=F%BSbuGG*gJz$RUCUg_Y~B?sbD5! zUzePT2?;6)BPl9iPS#_kx1-Na6Sx3pD0_=+=OSmpb0kr7-0qt^NrD7xl{I$=AM3E$ zeW>KQLyim4EECbdT%ExEB34xRND39SZ&V^GLF(;F=<+^PRSJ4farQKXxt8O+$kmD3q|yeCrlt-hQaI+snH!YBohmL3 zX6q&jpq0x#CAvE(xm$5MV0d&F&(RfjVKT6J!{rCY5WhI{fKx0}R!%b5XL?4JqZ1C% zSuP0>nRH!X89to1GjyqSXak28G(~(Ge8^AR~%N<>h;nBdHhG89|4WW6>FF7EEa&4`go8F02`rrR%~S-W**DAu>e zVFUX1FDswNE6N3g>j^IqenWVV@IK*pghjlQTuiuva3x_0;g1hv_T(rOBA<4D-%N~+k!oT7n$!)wmZs>}a zyZJwOxa6i@9z}Gysbg>{$s*&MIrp} z_qtjP<_2xaKOg1Wukhdn$GrN^hIilj#j@S#S;#z>UU1CR!7fklje|K_PZ{)0Bh zYR5x)W9IPm-YnR2z#|8*Ipm5jhVx#?T~+g67>-xi!l=ZAuI$YCh>S+M7yUJVScJZ9uUyWTVW@maOs zt@t`nu%{djuw~6T4lLrg#U7G;ledp7K{!WTNd_-TIttz8l2e73bQ3!+_lUoX-){Gi zTKVk`59w}xyUjzohu`E4D0jq1NRoG-T+*W?wRt+W^P9XT<#Nes=SdHjoOZk+jw?u9cALVplavMH_8ljT`125vF$vAM1G7d&r^{f z<4ND~n*%k@b5o#ILmc`x@4e4Or}%uk@eeM>p_~AX@rQCYpWkGnT#^($nKqYn1-WDr zU6PaWWOhOL~o55(+Nqb&@17T$1Fkgo#U%43MC4Ns<8) zLM}<7RszZ;1^hm-{3Z^wl5i(MN^L7!_Xv$aD1k4b?t}|&7Z4WiOPuawa|{LNWlcPO zhw!o90ZcD6i@#&)2uZ{IRmWMsjRN-wp& zol3qzxSEjd;TT5LeCZ#e9Nd%hl1|_{T+7L{_dM)zDdPiD=7V9ir#-! z%lk!Nkl9f3g^==+94kw{5K>o?gNc#*toNS&?zjH-^_SnuTvLNTpKlR=P))uA${w-!s;e#X z_Fnk<`wl7__|3xxJ!-MG_hkKEyVdtyS);9XZ)iQQ-rFyEG=JZ+@ Result, String> { + let mut serializer = postcard::Serializer { + output: postcard::ser_flavors::StdVec::new(), + }; + serialize(world, &mut v1::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); + match version { + 1 => v1::deserialize(body), + _ => Err(format!( + "The save file has version {version}, \ + but this Neuronify reads versions 1 to {VERSION}" + )), + } +} + +/// 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() { + assert_fixture(&load(include_bytes!("fixtures/v1.neuronify")).unwrap()); + } + + /// 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) { + 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), 30); + assert_eq!(count::(world), 1); + assert_eq!(count::(world), 145); + 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..85c4dcc4 --- /dev/null +++ b/neuronify-core/src/serialization/v1.rs @@ -0,0 +1,33 @@ +//! The first version of the save file format. + +use crate::components::*; +use crate::measurement::voltmeter::{VoltageSeries, Voltmeter}; + +schema!( + save, + 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, +); From 4e388a1f96a272872012adceaed83fc54f87bde7 Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Tue, 20 Oct 2026 11:44:48 +0200 Subject: [PATCH 2/2] Add text notes --- Cargo.lock | 6 +- neuronify-core/Cargo.toml | 6 +- neuronify-core/assets/DejaVu-LICENSE | 187 ++++++++ neuronify-core/assets/DejaVuSans.ttf | Bin 0 -> 76296 bytes neuronify-core/src/app.rs | 76 +++- neuronify-core/src/components.rs | 15 + neuronify-core/src/legacy/convert.rs | 29 +- neuronify-core/src/legacy/tests.rs | 19 + neuronify-core/src/rendering/mod.rs | 2 + neuronify-core/src/rendering/notes.rs | 413 ++++++++++++++++++ .../src/serialization/fixtures/v2.neuronify | Bin 0 -> 16807 bytes neuronify-core/src/serialization/mod.rs | 42 +- neuronify-core/src/serialization/v1.rs | 1 - neuronify-core/src/serialization/v2.rs | 50 +++ neuronify-core/src/tools.rs | 7 +- 15 files changed, 825 insertions(+), 28 deletions(-) create mode 100644 neuronify-core/assets/DejaVu-LICENSE create mode 100644 neuronify-core/assets/DejaVuSans.ttf create mode 100644 neuronify-core/src/rendering/notes.rs create mode 100644 neuronify-core/src/serialization/fixtures/v2.neuronify create mode 100644 neuronify-core/src/serialization/v2.rs 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 0000000000000000000000000000000000000000..c3f9130c273951e1af0d36f0fb98e86efac52100 GIT binary patch literal 76296 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

v9xe@vh3)s1IIX6+9h(A@3GBFGJrn1 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 RtNbqT8kOITyjqW~{|0y}`py6V literal 0 HcmV?d00001 diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index 027acdb3..9044a85e 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -2,7 +2,8 @@ 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::tools::*; use chrono::{DateTime, Duration, Utc}; @@ -50,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)] @@ -249,6 +251,7 @@ impl Neuronify { drag_offset: Vec3::ZERO, resizing_voltmeter: None, load_error: None, + note_renderer: NoteRenderer::new(application), } } @@ -508,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>() @@ -746,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; @@ -1037,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) { @@ -1056,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) { @@ -1350,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/fixtures/v2.neuronify b/neuronify-core/src/serialization/fixtures/v2.neuronify new file mode 100644 index 0000000000000000000000000000000000000000..884b58f32ddedb5161b2ec98016ca38b524fb9fe GIT binary patch literal 16807 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 literal 0 HcmV?d00001 diff --git a/neuronify-core/src/serialization/mod.rs b/neuronify-core/src/serialization/mod.rs index 0b974eed..5fa393ee 100644 --- a/neuronify-core/src/serialization/mod.rs +++ b/neuronify-core/src/serialization/mod.rs @@ -19,9 +19,10 @@ use postcard::ser_flavors::Flavor; #[macro_use] mod schema; mod v1; +mod v2; /// The version that `save` writes. -pub const VERSION: u32 = 1; +pub const VERSION: u32 = 2; const MAGIC: &[u8] = b"neuronify"; @@ -29,7 +30,7 @@ pub fn save(world: &World) -> Result, String> { let mut serializer = postcard::Serializer { output: postcard::ser_flavors::StdVec::new(), }; - serialize(world, &mut v1::SaveContext, &mut serializer) + serialize(world, &mut v2::SaveContext, &mut serializer) .map_err(|e| format!("Could not save: {e}"))?; let body = serializer .output @@ -49,13 +50,23 @@ pub fn load(bytes: &[u8]) -> Result { .split_first_chunk() .ok_or("The save file ends before its version")?; let version = u32::from_le_bytes(*version); - match version { - 1 => v1::deserialize(body), - _ => Err(format!( - "The save file has version {version}, \ - but this Neuronify reads versions 1 to {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. @@ -118,7 +129,13 @@ mod tests { #[test] fn loads_version_1() { - assert_fixture(&load(include_bytes!("fixtures/v1.neuronify")).unwrap()); + // 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`. @@ -214,7 +231,7 @@ mod tests { } /// Checks a fixture after it has been upgraded to the current version. - fn assert_fixture(world: &World) { + fn assert_fixture(world: &World, notes: usize) { assert_eq!(world.len(), 190); assert_eq!(count::(world), 125); assert_eq!(count::(world), 44); @@ -230,9 +247,10 @@ mod tests { assert_eq!(count::(world), 1); assert_eq!(count::(world), 1); assert_eq!(count::(world), 14); - assert_eq!(count::(world), 30); + assert_eq!(count::(world), notes); + assert_eq!(count::(world), notes); assert_eq!(count::(world), 1); - assert_eq!(count::(world), 145); + assert_eq!(count::(world), 145 + notes); assert_eq!(count::(world), 1); assert_eq!(count::(world), 14); assert_eq!(count::(world), 14); diff --git a/neuronify-core/src/serialization/v1.rs b/neuronify-core/src/serialization/v1.rs index 85c4dcc4..a9dab07a 100644 --- a/neuronify-core/src/serialization/v1.rs +++ b/neuronify-core/src/serialization/v1.rs @@ -4,7 +4,6 @@ use crate::components::*; use crate::measurement::voltmeter::{VoltageSeries, Voltmeter}; schema!( - save, Position, LeakyNeuron, LeakyDynamics, 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)]