diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index f5a0fa35..092bdcd9 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -1,17 +1,21 @@ use crate::components::*; use crate::constants::*; +use crate::dendrites::{dendrite_tree, spawn_dendrites}; use crate::examples::Example; use crate::landing::{LandingChoice, LandingScreen}; use crate::measurement::voltmeter::{RollingWindow, VoltageSeries, Voltmeter}; +use crate::rendering::lighting::{configure_lighting, set_theme_lighting, shaded}; use crate::rendering::{ - canvas_bounds, collect_connections, collect_spheres, collect_voltmeter_traces, ConnectionData, - NoteRenderer, Sphere, + canvas_bounds, collect_connections, collect_scene, collect_voltmeter_traces, compartment_radii, + connection_paths, create_floor, ConnectionData, CylinderData, GaugeRenderer, NoteRenderer, + Sphere, Theme, TorusData, }; use crate::tools::*; use chrono::{DateTime, Duration, Utc}; use glam::Vec3; use hecs::Entity; use std::cmp::Ordering; +use std::collections::HashSet; use std::io::BufReader; use std::io::Read; use std::io::Write; @@ -20,8 +24,10 @@ use std::thread; use visula::winit::dpi::PhysicalPosition; use visula::winit::event::{ElementState, Event, MouseButton, TouchPhase, WindowEvent}; use visula::{ - winit::keyboard::ModifiersKeyState, CustomEvent, InstanceBuffer, LineGeometry, LineMaterial, - Lines, RenderData, Renderable, SphereGeometry, SphereMaterial, Spheres, + winit::keyboard::ModifiersKeyState, CustomEvent, CylinderGeometry, CylinderMaterial, Cylinders, + Expression, InstanceBuffer, LineGeometry, LineMaterial, Lines, MeshPipeline, RenderData, + Renderable, ShadowRenderData, SphereGeometry, SphereMaterial, Spheres, Torus, TorusGeometry, + TorusMaterial, }; use crate::input::{Keyboard, Mouse, TouchGesture, Touches}; @@ -29,6 +35,10 @@ use crate::simulation; pub struct Neuronify { pub tool: Tool, + pub theme: Theme, + /// The theme the floor and lighting were last set up for + applied_theme: Theme, + pub dendrites: bool, pub previous_creation: Option, pub connection_tool: Option, pub stimulation_tool: Option, @@ -41,6 +51,11 @@ pub struct Neuronify { pub connection_lines: Lines, pub connection_spheres: Spheres, pub connection_buffer: InstanceBuffer, + pub cylinders: Cylinders, + pub cylinder_buffer: InstanceBuffer, + pub rings: Torus, + pub ring_buffer: InstanceBuffer, + pub floor: MeshPipeline, pub iterations: u32, pub last_update: DateTime, pub fps: f64, @@ -53,6 +68,7 @@ pub struct Neuronify { pub resizing_voltmeter: Option<(Entity, ResizeCorner)>, pub load_error: Option, pub note_renderer: NoteRenderer, + pub gauge_renderer: GaugeRenderer, pub landing: Option, pub frame_scene_requested: bool, } @@ -179,7 +195,9 @@ impl Neuronify { radius: sphere.radius, color: sphere.color, }, - &SphereMaterial::default(), + &SphereMaterial { + color: shaded(Expression::InputColor), + }, ) .unwrap(); @@ -197,7 +215,9 @@ impl Neuronify { width: connection.strength.clone() * 0.3, color: connection.start_color.clone(), }, - &LineMaterial::default(), + &LineMaterial { + color: shaded(Expression::InputColor), + }, ) .unwrap(); @@ -208,10 +228,59 @@ impl Neuronify { radius: connection.directional.clone() * (0.5 * NODE_RADIUS), color: crate::rendering::colors::terminal_color().into(), }, - &SphereMaterial::default(), + &SphereMaterial { + color: shaded(Expression::InputColor), + }, ) .unwrap(); + let cylinder_buffer = InstanceBuffer::::new(&application.device); + let cylinder = cylinder_buffer.instance(); + let cylinders = Cylinders::new( + &application.rendering_descriptor(), + &CylinderGeometry { + start: cylinder.start, + end: cylinder.end, + start_radius: cylinder.start_radius, + end_radius: cylinder.end_radius, + color: cylinder.color, + }, + &CylinderMaterial { + color: shaded(Expression::InputColor), + }, + ) + .unwrap(); + + let ring_buffer = InstanceBuffer::::new(&application.device); + let ring = ring_buffer.instance(); + let rings = Torus::new( + &application.rendering_descriptor(), + &TorusGeometry { + position: ring.position, + major_radius: ring.major_radius, + minor_radius: ring.minor_radius, + rotation: ring.rotation, + color: ring.color, + }, + &TorusMaterial { + color: shaded(Expression::InputColor), + }, + ) + .unwrap(); + + let theme = match application.window.theme() { + Some(visula::winit::window::Theme::Light) => Theme::Light, + _ => Theme::Dark, + }; + let floor = create_floor( + &application.rendering_descriptor(), + &application.device, + theme.palette().floor, + ) + .unwrap(); + + configure_lighting(application, theme); + let mut world = hecs::World::new(); #[allow(unused_mut)] let mut opened_file_from_command_line = false; @@ -246,7 +315,15 @@ impl Neuronify { connection_lines, connection_spheres, connection_buffer, + cylinders, + cylinder_buffer, + rings, + ring_buffer, + floor, tool: Tool::Select, + theme, + applied_theme: theme, + dendrites: false, previous_creation: None, connection_tool: None, stimulation_tool: None, @@ -270,6 +347,7 @@ impl Neuronify { resizing_voltmeter: None, load_error: None, note_renderer: NoteRenderer::new(application), + gauge_renderer: GaugeRenderer::new(application), landing: (!opened_file_from_command_line).then(|| LandingScreen::new(false)), frame_scene_requested: opened_file_from_command_line, } @@ -316,6 +394,7 @@ impl Neuronify { self.release_tools(); return; } + let palette = self.theme.palette(); let Neuronify { tool, mouse, @@ -339,9 +418,17 @@ impl Neuronify { let mouse_position = ground_position(application, mouse_physical_position); if !self.edit_enabled { - match *move_origin { - Some(origin) => pan(application, mouse_position - origin), - None => *move_origin = Some(mouse_position), + let touching = stimulation_tool.is_some() + || (move_origin.is_none() && simulation::touch_sensor_near(world, mouse_position)); + if touching { + *stimulation_tool = Some(StimulationTool { + position: mouse_position, + }); + } else { + match *move_origin { + Some(origin) => pan(application, mouse_position - origin), + None => *move_origin = Some(mouse_position), + } } return; } @@ -360,7 +447,10 @@ impl Neuronify { false }; match tool { - Tool::ExcitatoryNeuron | Tool::InhibitoryNeuron => { + Tool::ExcitatoryNeuron + | Tool::InhibitoryNeuron + | Tool::AdaptiveNeuron + | Tool::BurstNeuron => { if previous_too_near { return; } @@ -379,8 +469,16 @@ impl Neuronify { neuron_type, Deletable {}, )); - if self.tool == Tool::InhibitoryNeuron { - world.insert_one(entity, Inhibitory).unwrap(); + match self.tool { + Tool::InhibitoryNeuron => world.insert_one(entity, Inhibitory).unwrap(), + Tool::AdaptiveNeuron => world + .insert_one(entity, AdaptationCurrent::default()) + .unwrap(), + Tool::BurstNeuron => world.insert_one(entity, BurstCurrent::default()).unwrap(), + _ => {} + } + if self.dendrites { + spawn_dendrites(world, entity); } self.previous_creation = Some(PreviousCreation { entity }); } @@ -562,9 +660,16 @@ impl Neuronify { } }) .collect::>(); + let to_delete: HashSet = to_delete + .iter() + .flat_map(|&entity| dendrite_tree(world, entity)) + .chain(to_delete.iter().copied()) + .collect(); for entity in to_delete { world.despawn(entity).unwrap(); } + // Erase what is drawn, which for most connections is a bent path + let paths = connection_paths(world, &compartment_radii(world), &palette); let connections_to_delete = world .query::<&Connection>() .with::<&Deletable>() @@ -574,20 +679,22 @@ impl Neuronify { world.get::<&Position>(connection.from), world.get::<&Position>(connection.to), ) { - let a = from.position; - let b = to.position; - let p = mouse_position; - let ab = b - a; - let ap = p - a; - let t = ap.dot(ab) / ab.dot(ab); - let d = t * ab; - let point_on_line = a + d; - let distance_from_line = p.distance(point_on_line); - if distance_from_line < ERASE_RADIUS && (0.0..=1.0).contains(&t) { - Some(entity) + let near = if let Some(path) = paths.get(&entity) { + path.distance_to(mouse_position) + .is_some_and(|distance| distance < ERASE_RADIUS) } else { - None - } + let a = from.position; + let b = to.position; + let p = mouse_position; + let ab = b - a; + let ap = p - a; + let t = ap.dot(ab) / ab.dot(ab); + let d = t * ab; + let point_on_line = a + d; + let distance_from_line = p.distance(point_on_line); + distance_from_line < ERASE_RADIUS && (0.0..=1.0).contains(&t) + }; + near.then_some(entity) } else { Some(entity) } @@ -1083,6 +1190,17 @@ impl visula::Simulation for Neuronify { } } + if self.applied_theme != self.theme { + self.floor = create_floor( + &application.rendering_descriptor(), + &application.device, + self.theme.palette().floor, + ) + .unwrap(); + set_theme_lighting(application, self.theme); + self.applied_theme = self.theme; + } + simulation::stimulate_nearby(&mut self.world, &self.stimulation_tool); simulation::step_frame(&mut self.world, &mut self.time, self.iterations); @@ -1094,18 +1212,34 @@ impl visula::Simulation for Neuronify { self.frame_scene(application); } + let camera = &application.camera_controller.current_transform; + application.light.shadow_center = camera.center; + // visula blurs shadows over a fixed number of shadow map texels, + // so a map that reaches well past the view gives softer shadows + application.light.shadow_extent = (camera.distance * 2.0).clamp(50.0, 625.0); + let world = &self.world; - let spheres = collect_spheres(world); - let mut connections = collect_connections(world, &self.tool, &self.connection_tool); + let palette = self.theme.palette(); + let scene = collect_scene(world, &palette); + let mut connections = + collect_connections(world, &self.tool, &self.connection_tool, &palette); connections.extend(collect_voltmeter_traces(world)); self.sphere_buffer - .update(&application.device, &application.queue, &spheres); + .update(&application.device, &application.queue, &scene.spheres); + + self.cylinder_buffer + .update(&application.device, &application.queue, &scene.cylinders); + + self.ring_buffer + .update(&application.device, &application.queue, &scene.rings); self.connection_buffer .update(&application.device, &application.queue, &connections); self.note_renderer.update(application, &self.world); + self.gauge_renderer + .update(application, &self.world, &palette); let time_diff = Utc::now() - self.last_update; #[cfg(not(target_arch = "wasm32"))] @@ -1123,16 +1257,30 @@ impl visula::Simulation for Neuronify { } fn render(&mut self, data: &mut RenderData) { + self.floor.render(data); self.spheres.render(data); + self.cylinders.render(data); + self.rings.render(data); self.connection_lines.render(data); self.connection_spheres.render(data); self.note_renderer.render(data); + self.gauge_renderer.render(data); + } + + fn render_shadow(&mut self, data: &mut ShadowRenderData) { + self.spheres.render_shadow(data); + self.cylinders.render_shadow(data); + self.rings.render_shadow(data); } fn gui(&mut self, _application: &visula::Application, context: &egui::Context) { + context.set_theme(match self.theme { + Theme::Dark => egui::Theme::Dark, + Theme::Light => egui::Theme::Light, + }); self.sync_pointer_with_gui(context); if let Some(landing) = &mut self.landing { - match landing.show(context) { + match landing.show(context, self.theme) { Some(LandingChoice::Example { world, time }) => self.open_world(*world, time), Some(LandingChoice::EmptyCanvas) => self.open_world(hecs::World::new(), 0.0), Some(LandingChoice::BackToSimulation) => self.landing = None, @@ -1194,6 +1342,9 @@ impl visula::Simulation for Neuronify { for (tool_value, label) in category.tools() { ui.selectable_value(&mut self.tool, tool_value, label); } + if *category == ToolCategory::Neurons { + ui.checkbox(&mut self.dendrites, "With dendrites"); + } }); } }); @@ -1201,6 +1352,11 @@ impl visula::Simulation for Neuronify { ui.label(format!("FPS: {:.0}", self.fps)); ui.label("Simulation speed"); ui.add(egui::Slider::new(&mut self.iterations, 1..=20)); + ui.horizontal(|ui| { + ui.label("Theme"); + ui.selectable_value(&mut self.theme, Theme::Dark, "Dark"); + ui.selectable_value(&mut self.theme, Theme::Light, "Light"); + }); }); if let Some(active_entity) = self.active_entity { egui::Window::new("Selection").show(context, |ui| { diff --git a/neuronify-core/src/components.rs b/neuronify-core/src/components.rs index 0bba6623..874b03a5 100644 --- a/neuronify-core/src/components.rs +++ b/neuronify-core/src/components.rs @@ -84,6 +84,27 @@ impl Default for AdaptationCurrent { } } +/// Neuronify 1's burst neuron. A spike after a quiet spell starts a boost current +/// that drains linearly and keeps the neuron firing until it runs out. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct BurstCurrent { + /// The current at the start of a burst + pub peak: f64, + /// How fast the current drains, in amperes per second + pub drain: f64, + pub boost: f64, +} + +impl Default for BurstCurrent { + fn default() -> Self { + Self { + peak: 1.2e-9, + drain: 6e-9, + boost: 0.0, + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct CurrentClamp { pub current_output: f64, @@ -239,6 +260,9 @@ pub struct StaticConnectionSource {} #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Deletable {} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Dendrite; + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Position { pub position: Vec3, diff --git a/neuronify-core/src/constants.rs b/neuronify-core/src/constants.rs index aa4f0bda..2fc95366 100644 --- a/neuronify-core/src/constants.rs +++ b/neuronify-core/src/constants.rs @@ -34,7 +34,6 @@ pub const TRIGGER_SPHERE_SCALE: f32 = 0.5; pub const FHN_FIRE_VOLTAGE: f64 = 1.0; -pub const BEZIER_SEGMENTS: usize = 16; pub const BEZIER_BEND_FRACTION: f32 = 0.2; pub const MIN_CREATION_DISTANCE_AXON: f32 = 2.0 * NODE_RADIUS; @@ -48,3 +47,6 @@ pub const CAMERA_MAX_DISTANCE: f32 = 200.0; pub const CAMERA_DEFAULT_DISTANCE: f32 = 50.0; pub const CAMERA_FIELD_OF_VIEW_DEGREES: f32 = 40.0; pub const CAMERA_FRAMING_MARGIN: f32 = 1.15; + +pub const FLOOR_HEIGHT: f32 = -NODE_RADIUS; +pub const FLOOR_SIZE: f32 = 100_000.0; diff --git a/neuronify-core/src/dendrites.rs b/neuronify-core/src/dendrites.rs new file mode 100644 index 00000000..8453595f --- /dev/null +++ b/neuronify-core/src/dendrites.rs @@ -0,0 +1,396 @@ +use glam::Vec3; +use hecs::Entity; +use std::f32::consts::{PI, TAU}; + +use crate::components::*; +use crate::constants::*; + +/// Apical dendrites grow this way, which is up on the screen with the default camera. +const APICAL_DIRECTION: Vec3 = Vec3::X; +/// Arms of the star that an inhibitory neuron grows. +const STAR_ARMS: usize = 5; +/// Spacing between compartments, which is the rest length of the springs. +const SEGMENT_LENGTH: f32 = 2.0 * NODE_RADIUS; +/// How far each step may turn sideways, as a fraction of the step length. +const WANDER: f32 = 0.45; +/// The apical dendrite runs nearly straight. +const APICAL_WANDER: f32 = 0.12; + +fn wander( + direction: Vec3, + angle: f32, + seed: f32, + dendrite: usize, + step: usize, + scale: Vec3, + amount: f32, +) -> Vec3 { + let dx = (seed * scale.x + dendrite as f32 * 13.7 + step as f32 * 17.3).sin() * amount; + let dz = (seed * scale.z + dendrite as f32 * 7.7 + step as f32 * 23.1).cos() * amount; + let wandered = (direction + Vec3::new(dx, 0.0, dz)).normalize_or_zero(); + if wandered.length_squared() < 0.01 { + Vec3::new(angle.cos(), 0.0, angle.sin()) + } else { + wandered + } +} + +fn spawn_compartment(world: &mut hecs::World, position: Vec3, neuron_type: NeuronType) -> Entity { + world.spawn(( + Position { position }, + neuron_type, + 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, + }, + Dendrite, + StaticConnectionSource {}, + Deletable {}, + Selectable { selected: false }, + SpatialDynamics { + velocity: Vec3::ZERO, + acceleration: Vec3::ZERO, + }, + )) +} + +/// A dendrite or branch of `steps` compartments, starting at `parent`. +struct Chain { + angle: f32, + seed: f32, + dendrite: usize, + steps: usize, + scale: Vec3, + wander: f32, +} + +fn spawn_chain( + world: &mut hecs::World, + parent: Entity, + origin: Vec3, + chain: Chain, + neuron_type: &NeuronType, +) -> Vec<(Entity, Vec3)> { + let mut direction = Vec3::new(chain.angle.cos(), 0.0, chain.angle.sin()); + let mut position = origin; + let mut previous = parent; + let mut spawned = Vec::with_capacity(chain.steps); + for step in 0..chain.steps { + direction = wander( + direction, + chain.angle, + chain.seed, + chain.dendrite, + step, + chain.scale, + chain.wander, + ); + position += direction * SEGMENT_LENGTH; + let compartment = spawn_compartment(world, position, neuron_type.clone()); + world.spawn(( + Connection { + from: previous, + to: compartment, + strength: 1.0, + directional: false, + }, + Deletable {}, + CompartmentCurrent { + capacitance: COUPLING_CAPACITANCE, + }, + )); + spawned.push((compartment, position)); + previous = compartment; + } + spawned +} + +const TRUNK_SCALE: Vec3 = Vec3::new(7.3, 0.0, 11.1); +const BRANCH_SCALE: Vec3 = Vec3::new(5.9, 0.0, 8.7); + +/// Grows dendrites out of `soma` in the shape of its type. +/// +/// An excitatory neuron is pyramidal, with a long apical dendrite along +/// `APICAL_DIRECTION` that ends in a tuft, and short basal dendrites on the +/// other side. An inhibitory neuron gets a compact star. +/// +/// The dendrites are wired from the soma outwards, so a spike in the soma travels +/// out along them, while nothing flows back into the soma. +pub fn spawn_dendrites(world: &mut hecs::World, soma: Entity) { + let (Some(origin), Some(neuron_type)) = ( + world.get::<&Position>(soma).ok().map(|p| p.position), + world.get::<&NeuronType>(soma).ok().map(|t| (*t).clone()), + ) else { + return; + }; + // The shape follows from the entity id, so that each neuron looks different + let golden_ratio = (1.0 + 5.0_f32.sqrt()) / 2.0; + let seed = soma.id() as f32 * golden_ratio; + match neuron_type { + NeuronType::Excitatory => spawn_pyramidal(world, soma, origin, seed, &neuron_type), + NeuronType::Inhibitory => spawn_star(world, soma, origin, seed, &neuron_type), + } +} + +fn spawn_pyramidal( + world: &mut hecs::World, + soma: Entity, + origin: Vec3, + seed: f32, + neuron_type: &NeuronType, +) { + let apical = APICAL_DIRECTION.z.atan2(APICAL_DIRECTION.x); + let branch = |angle: f32, dendrite: usize| Chain { + angle, + seed: seed + dendrite as f32 * 5.0, + dendrite, + steps: 2, + scale: BRANCH_SCALE, + wander: WANDER, + }; + let trunk = spawn_chain( + world, + soma, + origin, + Chain { + angle: apical, + seed, + dendrite: 0, + steps: 6, + scale: TRUNK_SCALE, + wander: APICAL_WANDER, + }, + neuron_type, + ); + // An oblique branch partway up, and a tuft of two at the top + if let Some(&(fork, position)) = trunk.get(2) { + spawn_chain(world, fork, position, branch(apical + 1.0, 1), neuron_type); + } + if let Some(&(top, position)) = trunk.last() { + spawn_chain(world, top, position, branch(apical - 0.6, 2), neuron_type); + spawn_chain(world, top, position, branch(apical + 0.6, 3), neuron_type); + } + // Basal dendrites spread out on the other side of the soma + for (i, offset) in [-1.3_f32, -0.45, 0.45, 1.3].into_iter().enumerate() { + let dendrite = 4 + i; + let angle = apical + PI + offset + (seed * 2.3 + i as f32).sin() * 0.15; + let arm = spawn_chain( + world, + soma, + origin, + Chain { + angle, + seed, + dendrite, + steps: 3, + scale: TRUNK_SCALE, + wander: WANDER, + }, + neuron_type, + ); + if let Some(&(fork, position)) = arm.get(1) { + let outwards = angle + 0.9 * offset.signum(); + spawn_chain( + world, + fork, + position, + branch(outwards, dendrite), + neuron_type, + ); + } + } +} + +fn spawn_star( + world: &mut hecs::World, + soma: Entity, + origin: Vec3, + seed: f32, + neuron_type: &NeuronType, +) { + for dendrite in 0..STAR_ARMS { + let angle = TAU * dendrite as f32 / STAR_ARMS as f32 + seed * 0.5; + let arm = spawn_chain( + world, + soma, + origin, + Chain { + angle, + seed, + dendrite, + steps: 3, + scale: TRUNK_SCALE, + wander: WANDER, + }, + neuron_type, + ); + if let Some(&(fork, position)) = arm.get(1) { + let angle = angle + 1.1 + (seed * 3.1 + dendrite as f32 * 2.7).sin() * 0.6; + spawn_chain( + world, + fork, + position, + Chain { + angle, + seed: seed + dendrite as f32 * 5.0, + dendrite, + steps: 2, + scale: BRANCH_SCALE, + wander: WANDER, + }, + neuron_type, + ); + } + } +} + +/// The dendrite compartments that grow out of `soma`. +pub fn dendrite_tree(world: &hecs::World, soma: Entity) -> Vec { + let mut tree = Vec::new(); + let mut frontier = vec![soma]; + while let Some(parent) = frontier.pop() { + for (_, connection) in world.query::<&Connection>().iter() { + if connection.from == parent + && world.get::<&Dendrite>(connection.to).is_ok() + && !tree.contains(&connection.to) + { + tree.push(connection.to); + frontier.push(connection.to); + } + } + } + tree +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::{fhn_step, lif_step}; + use std::collections::HashSet; + + fn driven_neuron(world: &mut hecs::World) -> Entity { + let neuron = world.spawn(( + LeakyNeuron::default(), + LeakyDynamics::default(), + LeakCurrent::default(), + Position { + position: Vec3::ZERO, + }, + NeuronType::Excitatory, + )); + let clamp = world.spawn(( + CurrentClamp { + current_output: 5e-9, + }, + Position { + position: Vec3::new(0.0, 0.0, -1.0), + }, + )); + world.spawn(( + Connection { + from: clamp, + to: neuron, + strength: 1.0, + directional: true, + }, + ImmediateFireSynapse::default(), + )); + neuron + } + + /// Frames in which `neuron` fired, and the highest voltage seen in `tree`. + fn run(world: &mut hecs::World, neuron: Entity, tree: &[Entity]) -> (Vec, f64) { + let steps_per_frame = 4; + let mut spikes = Vec::new(); + let mut peak = f64::MIN; + let mut time = 0.0; + for frame in 0..1000 { + for _ in 0..steps_per_frame { + lif_step(world, LIF_DT, time); + time += LIF_DT; + } + let recently_fired: HashSet = world + .query::<&LeakyDynamics>() + .iter() + .filter(|(_, d)| d.time_since_fire < steps_per_frame as f64 * LIF_DT) + .map(|(e, _)| e) + .collect(); + if recently_fired.contains(&neuron) { + spikes.push(frame); + } + fhn_step(world, FHN_CDT, &recently_fired); + for &compartment in tree { + peak = peak.max(world.get::<&Compartment>(compartment).unwrap().voltage); + } + } + (spikes, peak) + } + + #[test] + fn spikes_travel_out_but_not_back() { + let mut plain = hecs::World::new(); + let plain_neuron = driven_neuron(&mut plain); + let (plain_spikes, _) = run(&mut plain, plain_neuron, &[]); + + let mut branched = hecs::World::new(); + let branched_neuron = driven_neuron(&mut branched); + spawn_dendrites(&mut branched, branched_neuron); + let tree = dendrite_tree(&branched, branched_neuron); + let (branched_spikes, peak) = run(&mut branched, branched_neuron, &tree); + + assert!(!plain_spikes.is_empty()); + assert_eq!(plain_spikes, branched_spikes); + assert!(peak > BRIDGE_VOLTAGE_THRESHOLD, "peak was {peak}"); + } + + #[test] + fn the_tree_holds_every_compartment() { + let mut world = hecs::World::new(); + let neuron = driven_neuron(&mut world); + let other = driven_neuron(&mut world); + spawn_dendrites(&mut world, neuron); + let spawned = world.query::<&Dendrite>().iter().count(); + spawn_dendrites(&mut world, other); + let tree = dendrite_tree(&world, neuron); + assert_eq!(tree.len(), spawned); + assert!(dendrite_tree(&world, other) + .iter() + .all(|compartment| !tree.contains(compartment))); + } + + /// How far the dendrites of a fresh neuron reach along and against `APICAL_DIRECTION`. + fn reach(neuron_type: NeuronType) -> (f32, f32) { + let mut world = hecs::World::new(); + let soma = world.spawn(( + Position { + position: Vec3::ZERO, + }, + neuron_type, + )); + spawn_dendrites(&mut world, soma); + dendrite_tree(&world, soma) + .iter() + .map(|&compartment| { + let position = world.get::<&Position>(compartment).unwrap().position; + position.dot(APICAL_DIRECTION) + }) + .fold((0.0, 0.0), |(up, down), along| { + (up.max(along), down.min(along)) + }) + } + + #[test] + fn pyramidal_neurons_reach_up_and_interneurons_stay_compact() { + let (up, down) = reach(NeuronType::Excitatory); + assert!(up > 12.0, "the apical dendrite reached {up}"); + assert!(down < -3.0, "the basal dendrites reached {down}"); + let (up, down) = reach(NeuronType::Inhibitory); + assert!(up < 9.0 && down > -9.0, "the star reached {up} and {down}"); + } +} diff --git a/neuronify-core/src/examples.rs b/neuronify-core/src/examples.rs index 1b852856..9e300a3e 100644 --- a/neuronify-core/src/examples.rs +++ b/neuronify-core/src/examples.rs @@ -65,6 +65,12 @@ pub const EXAMPLE_CATEGORIES: &[ExampleCategory] = &[ and recovers after a while.", nfy_contents: include_str!("../examples/adaptation.nfy"), }, + Example { + name: "Burst", + description: "A burst neuron keeps firing for a while \ + after a single touch.", + nfy_contents: include_str!("../examples/burst.nfy"), + }, ], }, ExampleCategory { diff --git a/neuronify-core/src/landing.rs b/neuronify-core/src/landing.rs index 02b3c1d1..dea0bcde 100644 --- a/neuronify-core/src/landing.rs +++ b/neuronify-core/src/landing.rs @@ -12,20 +12,48 @@ use hecs::World; use crate::constants::*; use crate::examples::{Example, EXAMPLE_CATEGORIES}; -use crate::rendering::colors::terminal_color; use crate::rendering::{ - circuit_bounds, collect_connections, collect_spheres, collect_voltmeter_traces, Bounds, - ConnectionData, + circuit_bounds, collect_connections, collect_scene, collect_voltmeter_traces, Bounds, + ConnectionData, CylinderData, Palette, Theme, }; use crate::simulation; use crate::tools::Tool; -const BACKGROUND: Color32 = Color32::from_rgb(30, 30, 46); -const SURFACE: Color32 = Color32::from_rgb(49, 50, 68); -const SURFACE_HOVERED: Color32 = Color32::from_rgb(69, 71, 90); -const ACCENT: Color32 = Color32::from_rgb(137, 180, 250); -const TEXT: Color32 = Color32::from_rgb(205, 214, 244); -const SUBTEXT: Color32 = Color32::from_rgb(166, 173, 200); +struct Colors { + background: Color32, + surface: Color32, + surface_hovered: Color32, + accent: Color32, + text: Color32, + subtext: Color32, +} + +const DARK: Colors = Colors { + background: Color32::from_rgb(30, 30, 46), + surface: Color32::from_rgb(49, 50, 68), + surface_hovered: Color32::from_rgb(69, 71, 90), + accent: Color32::from_rgb(137, 180, 250), + text: Color32::from_rgb(205, 214, 244), + subtext: Color32::from_rgb(166, 173, 200), +}; + +const LIGHT: Colors = Colors { + background: Color32::from_rgb(236, 239, 245), + surface: Color32::from_rgb(255, 255, 255), + surface_hovered: Color32::from_rgb(246, 248, 253), + accent: Color32::from_rgb(30, 102, 245), + text: Color32::from_rgb(76, 79, 105), + subtext: Color32::from_rgb(92, 95, 119), +}; + +impl Colors { + fn of(theme: Theme) -> &'static Colors { + match theme { + Theme::Dark => &DARK, + Theme::Light => &LIGHT, + } + } +} const MAX_CONTENT_WIDTH: f32 = 1100.0; const PAGE_MARGIN: f32 = 16.0; @@ -110,10 +138,17 @@ impl Preview { struct StillDrawing { size_in_pixels: [usize; 2], + theme: Theme, texture: TextureHandle, unfinished: Option, } +impl StillDrawing { + fn is_for(&self, size_in_pixels: [usize; 2], theme: Theme) -> bool { + self.size_in_pixels == size_in_pixels && self.theme == theme + } +} + struct UnfinishedDrawing { mesh_in_pixels: Mesh, next_triangle: usize, @@ -134,6 +169,7 @@ enum Picked { pub struct LandingScreen { previews_by_category: Vec>, can_go_back: bool, + theme: Theme, still_drawing_deadline: Option>, } @@ -146,6 +182,7 @@ impl LandingScreen { LandingScreen { previews_by_category, can_go_back, + theme: Theme::default(), still_drawing_deadline: None, } } @@ -164,13 +201,14 @@ impl LandingScreen { } } - pub fn show(&mut self, context: &egui::Context) -> Option { + pub fn show(&mut self, context: &egui::Context, theme: Theme) -> Option { let mut picked = None; + self.theme = theme; self.still_drawing_deadline = Some(Utc::now() + Duration::microseconds(STILL_DRAWING_MICROSECONDS_PER_FRAME)); #[allow(deprecated)] egui::CentralPanel::default() - .frame(egui::Frame::NONE.fill(BACKGROUND)) + .frame(egui::Frame::NONE.fill(Colors::of(theme).background)) .show(context, |ui| { ScrollArea::vertical() .auto_shrink([false, false]) @@ -203,16 +241,19 @@ impl LandingScreen { } fn contents(&mut self, ui: &mut Ui, width: f32) -> Option { + let colors = Colors::of(self.theme); let mut picked = None; ui.add_space(40.0); ui.horizontal(|ui| { - ui.label(RichText::new("Neuronify").size(34.0).color(TEXT)); + ui.label(RichText::new("Neuronify").size(34.0).color(colors.text)); if self.can_go_back { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let button = egui::Button::new( - RichText::new("Back to simulation").size(15.0).color(TEXT), + RichText::new("Back to simulation") + .size(15.0) + .color(colors.text), ) - .fill(SURFACE) + .fill(colors.surface) .corner_radius(CARD_RADIUS) .min_size(vec2(0.0, 36.0)); if ui.add(button).clicked() { @@ -228,12 +269,12 @@ impl LandingScreen { Open an example below, or start from an empty canvas.", ) .size(16.0) - .color(SUBTEXT), + .color(colors.subtext), ); for (category_index, category) in EXAMPLE_CATEGORIES.iter().enumerate() { ui.add_space(32.0); - ui.label(RichText::new(category.name).size(20.0).color(TEXT)); + ui.label(RichText::new(category.name).size(20.0).color(colors.text)); ui.add_space(12.0); let mut cards = Vec::new(); if category_index == 0 { @@ -259,13 +300,19 @@ impl LandingScreen { (example.name, example.description) } }; + let colors = Colors::of(self.theme); let painter = ui.painter(); [ - painter.layout(name.to_owned(), FontId::proportional(16.0), TEXT, width), + painter.layout( + name.to_owned(), + FontId::proportional(16.0), + colors.text, + width, + ), painter.layout( description.to_owned(), FontId::proportional(13.5), - SUBTEXT, + colors.subtext, width, ), ] @@ -282,6 +329,8 @@ impl LandingScreen { let card_width = (width - CARD_GAP * (columns - 1) as f32) / columns as f32; let text_width = card_width - 2.0 * CARD_PADDING; let thumbnail_size = vec2(text_width, (text_width * THUMBNAIL_ASPECT).round()); + let theme = self.theme; + let colors = Colors::of(theme); let mut clicked = None; for row in cards.chunks(columns) { let texts: Vec<[Arc; 2]> = row @@ -313,12 +362,12 @@ impl LandingScreen { rect, CARD_RADIUS, if highlighted { - SURFACE_HOVERED + colors.surface_hovered } else { - SURFACE + colors.surface }, if highlighted { - Stroke::new(1.5_f32, ACCENT) + Stroke::new(1.5_f32, colors.accent) } else { Stroke::NONE }, @@ -328,20 +377,26 @@ impl LandingScreen { rect.min + vec2(CARD_PADDING, CARD_PADDING), thumbnail_size, ); - painter.rect_filled(thumbnail, THUMBNAIL_RADIUS, BACKGROUND); + painter.rect_filled(thumbnail, THUMBNAIL_RADIUS, colors.background); let thumbnail_painter = ui.painter_at(thumbnail); match card { - Card::EmptyCanvas => paint_plus(&thumbnail_painter, thumbnail), + Card::EmptyCanvas => { + paint_plus(&thumbnail_painter, thumbnail, colors.subtext) + } Card::Preview(index) => { let preview = &mut self.previews_by_category[category][index]; match preview.mode { - PreviewMode::Live => { - paint_live_preview(&thumbnail_painter, thumbnail, preview) - } + PreviewMode::Live => paint_live_preview( + &thumbnail_painter, + thumbnail, + preview, + &theme.palette(), + ), PreviewMode::Still => paint_still_preview( &thumbnail_painter, thumbnail, preview, + theme, &mut self.still_drawing_deadline, ), } @@ -353,8 +408,8 @@ impl LandingScreen { ); let description_position = name_position + vec2(0.0, name.size().y + TITLE_GAP); let label = name.text().to_owned(); - painter.galley(name_position, name, TEXT); - painter.galley(description_position, description, SUBTEXT); + painter.galley(name_position, name, colors.text); + painter.galley(description_position, description, colors.subtext); response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label)); if response.on_hover_cursor(CursorIcon::PointingHand).clicked() { clicked = Some(card); @@ -375,7 +430,12 @@ fn rendered_line_width(strength: f32, scale: f32) -> f32 { (strength * CONNECTION_WIDTH_PER_STRENGTH * scale).max(MIN_LINE_WIDTH) } -fn paint_live_preview(painter: &Painter, thumbnail: Rect, preview: &mut Preview) { +fn paint_live_preview( + painter: &Painter, + thumbnail: Rect, + preview: &mut Preview, + palette: &Palette, +) { let Some(bounds) = &preview.bounds else { return; }; @@ -392,6 +452,7 @@ fn paint_live_preview(painter: &Painter, thumbnail: Rect, preview: &mut Preview) thumbnail.shrink(THUMBNAIL_PADDING), &preview.world, bounds, + palette, )); } } @@ -400,6 +461,7 @@ fn paint_still_preview( painter: &Painter, thumbnail: Rect, preview: &mut Preview, + theme: Theme, still_drawing_deadline: &mut Option>, ) { let Some(bounds) = &preview.bounds else { @@ -409,18 +471,18 @@ fn paint_still_preview( let pixels = (thumbnail.size() * pixels_per_point).round(); let size_in_pixels = [pixels.x as usize, pixels.y as usize]; let finished = preview.still_drawing.as_ref().is_some_and(|drawing| { - drawing.size_in_pixels == size_in_pixels && drawing.unfinished.is_none() + drawing.is_for(size_in_pixels, theme) && drawing.unfinished.is_none() }); if let Some(deadline) = (!finished).then(|| still_drawing_deadline.take()).flatten() { if preview .still_drawing .as_ref() - .is_none_or(|drawing| drawing.size_in_pixels != size_in_pixels) + .is_none_or(|drawing| !drawing.is_for(size_in_pixels, theme)) { let area = Rect::from_min_size(Pos2::ZERO, thumbnail.size()).shrink(THUMBNAIL_PADDING); let unfinished = UnfinishedDrawing { mesh_in_pixels: tessellate_untextured_in_pixels( - top_down_shapes(area, &preview.world, bounds), + top_down_shapes(area, &preview.world, bounds, &theme.palette()), pixels_per_point, ), next_triangle: 0, @@ -433,6 +495,7 @@ fn paint_still_preview( ); preview.still_drawing = Some(StillDrawing { size_in_pixels, + theme, texture, unfinished: Some(unfinished), }); @@ -461,36 +524,37 @@ fn paint_still_preview( } } -fn top_down_shapes(area: Rect, world: &World, bounds: &Bounds) -> Vec { +fn top_down_shapes(area: Rect, world: &World, bounds: &Bounds, palette: &Palette) -> Vec { let size = bounds.size(); let scale = (area.width() / size.z.max(THUMBNAIL_MIN_CANVAS_EXTENT)) .min(area.height() / size.x.max(THUMBNAIL_MIN_CANVAS_EXTENT)); let center = bounds.center(); let to_screen = |p: Vec3| area.center() + vec2(p.z - center.z, center.x - p.x) * scale; - let mut shapes = Vec::new(); + let scene = collect_scene(world, palette); + let mut shapes: Vec = scene + .cylinders + .iter() + .map(|cylinder| cylinder_from_above(cylinder, scale, to_screen)) + .collect(); + for ring in &scene.rings { + shapes.push(Shape::circle_stroke( + to_screen(ring.position), + ring.major_radius * scale, + Stroke::new( + 2.0 * ring.minor_radius * scale, + egui_color_from_linear(ring.color), + ), + )); + } - for connection in collect_connections(world, &Tool::Select, &None) { - let start = connection.position_a; - let vector = connection.position_b - start; - if vector.length() <= f32::EPSILON { - continue; - } - let end_before_terminal = - start + vector - connection.directional * vector.normalize() * NODE_RADIUS * 2.0; + for wire in collect_connections(world, &Tool::Select, &None, palette) { shapes.push(Shape::line_segment( - [to_screen(start), to_screen(end_before_terminal)], + [to_screen(wire.position_a), to_screen(wire.position_b)], Stroke::new( - rendered_line_width(connection.strength, scale), - egui_color_from_linear(connection.start_color), + rendered_line_width(wire.strength, scale), + egui_color_from_linear(wire.start_color), ), )); - if connection.directional > 0.0 { - shapes.push(Shape::circle_filled( - to_screen(end_before_terminal), - 0.5 * NODE_RADIUS * scale, - egui_color_from_linear(terminal_color()), - )); - } } shapes.extend(joined_voltmeter_traces( @@ -499,7 +563,7 @@ fn top_down_shapes(area: Rect, world: &World, bounds: &Bounds) -> Vec { to_screen, )); - for sphere in collect_spheres(world) { + for sphere in scene.spheres { shapes.push(Shape::circle_filled( to_screen(sphere.position), sphere.radius * scale, @@ -509,6 +573,32 @@ fn top_down_shapes(area: Rect, world: &World, bounds: &Bounds) -> Vec { shapes } +fn cylinder_from_above( + cylinder: &CylinderData, + scale: f32, + to_screen: impl Fn(Vec3) -> Pos2, +) -> Shape { + let color = egui_color_from_linear(cylinder.color); + let start = to_screen(cylinder.start); + let end = to_screen(cylinder.end); + let along = end - start; + if along.length() <= f32::EPSILON { + let radius = cylinder.start_radius.max(cylinder.end_radius); + return Shape::circle_filled(start, radius * scale, color); + } + let across = along.normalized().rot90() * scale; + Shape::convex_polygon( + vec![ + start + across * cylinder.start_radius, + end + across * cylinder.end_radius, + end - across * cylinder.end_radius, + start - across * cylinder.start_radius, + ], + color, + Stroke::NONE, + ) +} + fn joined_voltmeter_traces( segments: Vec, scale: f32, @@ -654,10 +744,10 @@ fn edge_crossing_at_row(p: Vec2, q: Vec2, y: f32) -> Option { (!horizontal && spans_row).then(|| p.x + (y - p.y) * (q.x - p.x) / (q.y - p.y)) } -fn paint_plus(painter: &Painter, area: Rect) { +fn paint_plus(painter: &Painter, area: Rect, color: Color32) { let center = area.center(); let arm = area.height() * 0.15; - let stroke = Stroke::new(2.0_f32, SUBTEXT); + let stroke = Stroke::new(2.0_f32, color); painter.line_segment([center - vec2(arm, 0.0), center + vec2(arm, 0.0)], stroke); painter.line_segment([center - vec2(0.0, arm), center + vec2(0.0, arm)], stroke); } @@ -683,7 +773,7 @@ mod tests { events: Vec, screen: Vec2, ) -> (egui::FullOutput, Option) { - run_at(context, landing, events, screen, 0.0) + run_at(context, landing, events, screen, 0.0, Theme::Dark) } fn run_at( @@ -692,6 +782,7 @@ mod tests { events: Vec, screen: Vec2, time: f64, + theme: Theme, ) -> (egui::FullOutput, Option) { let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, screen)), @@ -700,7 +791,7 @@ mod tests { ..Default::default() }; let mut choice = None; - let output = context.run_ui(input, |ui| choice = landing.show(ui.ctx())); + let output = context.run_ui(input, |ui| choice = landing.show(ui.ctx(), theme)); (output, choice) } @@ -797,7 +888,12 @@ mod tests { fn unfinished_drawing(world: &World, size: Vec2, pixels_per_point: f32) -> UnfinishedDrawing { let pixels = size * pixels_per_point; let area = Rect::from_min_size(Pos2::ZERO, size).shrink(THUMBNAIL_PADDING); - let shapes = top_down_shapes(area, world, &circuit_bounds(world).unwrap()); + let shapes = top_down_shapes( + area, + world, + &circuit_bounds(world).unwrap(), + &Theme::Dark.palette(), + ); UnfinishedDrawing { mesh_in_pixels: tessellate_untextured_in_pixels(shapes, pixels_per_point), next_triangle: 0, @@ -821,7 +917,7 @@ mod tests { |point: Pos2| image.pixels[(point.y * 2.0) as usize * 400 + (point.x * 2.0) as usize]; let resting = egui_color_from_linear(colors::blue()); let bounds = circuit_bounds(&world).unwrap(); - for shape in top_down_shapes(area, &world, &bounds) { + for shape in top_down_shapes(area, &world, &bounds, &Theme::Dark.palette()) { if let Shape::Circle(circle) = shape { assert_eq!(circle.fill, resting); let drawn = pixel(circle.center); @@ -857,6 +953,40 @@ mod tests { assert_eq!(in_pieces.premultiplied_pixels, at_once.premultiplied_pixels); } + #[test] + fn follows_the_theme() { + let screen = vec2(1280.0, 800.0); + for theme in [Theme::Dark, Theme::Light] { + let context = egui::Context::default(); + let mut landing = LandingScreen::new(false); + let (output, _) = run_at(&context, &mut landing, vec![], screen, 0.0, theme); + let background = Colors::of(theme).background; + assert!(output.shapes.iter().any(|clipped| matches!( + &clipped.shape, + Shape::Rect(rect) if rect.fill == background + ))); + } + } + + #[test] + fn still_previews_are_drawn_again_when_the_theme_changes() { + let context = egui::Context::default(); + let mut landing = LandingScreen::new(false); + landing.previews_by_category[0][0].mode = PreviewMode::Still; + for theme in [Theme::Dark, Theme::Light] { + run_at( + &context, + &mut landing, + vec![], + vec2(1280.0, 800.0), + 0.0, + theme, + ); + let preview = &landing.previews_by_category[0][0]; + assert_eq!(preview.still_drawing.as_ref().unwrap().theme, theme); + } + } + #[test] fn live_previews_are_hidden_while_warming_up_and_then_fade_in() { let context = egui::Context::default(); @@ -876,7 +1006,15 @@ mod tests { let mut next_frame = |landing: &mut LandingScreen| { landing.update(4); frame += 1; - run_at(&context, landing, vec![], screen, frame as f64 / 60.0).0 + run_at( + &context, + landing, + vec![], + screen, + frame as f64 / 60.0, + Theme::Dark, + ) + .0 }; let output = next_frame(&mut landing); diff --git a/neuronify-core/src/legacy/convert.rs b/neuronify-core/src/legacy/convert.rs index 51217075..4cc9dca0 100644 --- a/neuronify-core/src/legacy/convert.rs +++ b/neuronify-core/src/legacy/convert.rs @@ -47,6 +47,18 @@ fn spawn_node(world: &mut World, node: &LegacyNode) -> Entity { entity } "neurons/AdaptationNeuron.qml" => spawn_adaptation_neuron(world, node, pos), + "neurons/BurstNeuron.qml" => { + let entity = spawn_leaky_neuron(world, node, pos); + world.insert_one(entity, BurstCurrent::default()).unwrap(); + entity + } + "neurons/BurstInhibitoryNeuron.qml" => { + let entity = spawn_leaky_neuron(world, node, pos); + world + .insert(entity, (BurstCurrent::default(), Inhibitory)) + .unwrap(); + entity + } "generators/CurrentClamp.qml" => { let current = node.engine.current_output.unwrap_or(2e-9); world.spawn(( diff --git a/neuronify-core/src/legacy/tests.rs b/neuronify-core/src/legacy/tests.rs index 3c476d36..d479190b 100644 --- a/neuronify-core/src/legacy/tests.rs +++ b/neuronify-core/src/legacy/tests.rs @@ -13,6 +13,8 @@ const LEAKY_NFY: &str = include_str!("../../examples/leaky.nfy"); const ADAPTATION_NFY: &str = include_str!("../../examples/adaptation.nfy"); +const BURST_NFY: &str = include_str!("../../examples/burst.nfy"); + const INHIBITORY_NFY: &str = include_str!("../../examples/inhibitory.nfy"); const TUTORIAL_2_CIRCUITS_NFY: &str = include_str!("../../examples/tutorial_2_circuits.nfy"); @@ -431,3 +433,11 @@ fn test_annotation_loads_with_size() { "leaky.nfy should produce at least one annotation" ); } + +#[test] +fn test_burst_neuron_loads_with_burst_current() { + let sim = parse_legacy_nfy(BURST_NFY).unwrap(); + let mut world = hecs::World::new(); + spawn_legacy_simulation(&mut world, &sim); + assert_eq!(world.query::<&BurstCurrent>().iter().count(), 1); +} diff --git a/neuronify-core/src/lib.rs b/neuronify-core/src/lib.rs index ed4d8ce2..d79b57fe 100644 --- a/neuronify-core/src/lib.rs +++ b/neuronify-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod components; pub mod constants; +pub mod dendrites; pub mod examples; pub mod input; pub mod landing; diff --git a/neuronify-core/src/rendering/bounds.rs b/neuronify-core/src/rendering/bounds.rs index 52d8b173..497c5e85 100644 --- a/neuronify-core/src/rendering/bounds.rs +++ b/neuronify-core/src/rendering/bounds.rs @@ -1,8 +1,9 @@ use glam::Vec3; use crate::components::*; +use crate::rendering::colors::Theme; use crate::rendering::connections::collect_connections; -use crate::rendering::spheres::collect_spheres; +use crate::rendering::scene::collect_scene; use crate::tools::Tool; #[derive(Clone, Copy, Debug, PartialEq)] @@ -40,12 +41,22 @@ pub fn canvas_bounds(world: &hecs::World) -> Option { fn circuit_points(world: &hecs::World) -> Vec { let mut points = Vec::new(); - for sphere in collect_spheres(world) { - let reach = Vec3::new(sphere.radius, 0.0, sphere.radius); - points.push(sphere.position - reach); - points.push(sphere.position + reach); + let palette = Theme::default().palette(); + let scene = collect_scene(world, &palette); + for sphere in &scene.spheres { + points.extend(footprint(sphere.position, sphere.radius)); } - for connection in collect_connections(world, &Tool::Select, &None) { + for cylinder in &scene.cylinders { + points.extend(footprint(cylinder.start, cylinder.start_radius)); + points.extend(footprint(cylinder.end, cylinder.end_radius)); + } + for ring in &scene.rings { + points.extend(footprint( + ring.position, + ring.major_radius + ring.minor_radius, + )); + } + for connection in collect_connections(world, &Tool::Select, &None, &palette) { points.push(connection.position_a); points.push(connection.position_b); } @@ -67,6 +78,12 @@ fn bounds_around(points: &[Vec3]) -> Option { Some(bounds) } +fn footprint(center: Vec3, radius: f32) -> [Vec3; 2] { + let reach = Vec3::new(radius, 0.0, radius); + let center = center.with_y(0.0); + [center - reach, center + reach] +} + fn note_corners(center: Vec3, size: &NoteSize) -> [Vec3; 2] { let half_extent = Vec3::new(size.height * 0.5, 0.0, size.width * 0.5); [center - half_extent, center + half_extent] @@ -83,7 +100,7 @@ fn voltmeter_frame_corners(position: Vec3, size: &VoltmeterSize) -> [Vec3; 2] { #[cfg(test)] mod tests { use super::*; - use crate::constants::NODE_RADIUS; + use crate::rendering::shapes::PAD_RADIUS; fn world_with_clamp_and_voltmeter() -> hecs::World { let mut world = hecs::World::new(); @@ -115,7 +132,7 @@ mod tests { #[test] fn circuit_bounds_cover_neurons_and_voltmeters() { let bounds = circuit_bounds(&world_with_clamp_and_voltmeter()).unwrap(); - assert_eq!(bounds.min, Vec3::new(-NODE_RADIUS, 0.0, -NODE_RADIUS)); + assert_eq!(bounds.min, Vec3::new(-PAD_RADIUS, 0.0, -PAD_RADIUS)); assert_eq!(bounds.max, Vec3::new(12.0, 0.0, 13.0)); } @@ -139,7 +156,7 @@ mod tests { circuit_bounds(&world_with_clamp_and_voltmeter()) ); let bounds = canvas_bounds(&world).unwrap(); - assert_eq!(bounds.min, Vec3::new(-12.0, 0.0, -NODE_RADIUS)); + assert_eq!(bounds.min, Vec3::new(-12.0, 0.0, -PAD_RADIUS)); assert_eq!(bounds.max, Vec3::new(12.0, 0.0, 23.0)); } } diff --git a/neuronify-core/src/rendering/colors.rs b/neuronify-core/src/rendering/colors.rs index 93685486..22b53256 100644 --- a/neuronify-core/src/rendering/colors.rs +++ b/neuronify-core/src/rendering/colors.rs @@ -54,10 +54,83 @@ pub fn terminal_color() -> Vec3 { Vec3::new(136.0 / 255.0, 57.0 / 255.0, 239.0 / 255.0) } -pub fn neurocolor(neuron_type: &NeuronType, value: f32) -> Vec3 { - let v = 1.0 / (1.0 + (-5.0 * (value - 0.5)).exp()); - match *neuron_type { - NeuronType::Excitatory => v * base() + (1.0 - v) * blue(), - NeuronType::Inhibitory => v * mantle() + (1.0 - v) * red(), +pub fn adaptation_gauge_color() -> Vec3 { + srgb(32, 170, 150) +} + +pub fn burst_gauge_color() -> Vec3 { + srgb(228, 92, 152) +} + +/// How far a neuron at threshold has gone from the colour of its type towards white. +pub const VOLTAGE_TINT: f32 = 0.55; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Theme { + #[default] + Dark, + Light, +} + +/// The colours of one theme. +/// +/// The voltage of a neuron runs from the colour of its type at rest towards a pale tint of +/// it, so it lights up as it nears threshold and still looks different from a flash. The +/// light theme starts from softer colours. +#[derive(Clone, Copy, Debug)] +pub struct Palette { + pub floor: Vec3, + pub excitatory: Vec3, + pub excitatory_active: Vec3, + pub inhibitory: Vec3, + pub inhibitory_active: Vec3, + /// A spike on its way along a synapse + pub spike: Vec3, + /// A soma right after it fired + pub flash: Vec3, + /// Connections from anything that is not a neuron or a source + pub neutral: Vec3, +} + +impl Theme { + pub fn palette(self) -> Palette { + match self { + Theme::Dark => Palette { + floor: srgb(56, 58, 80), + excitatory: blue(), + excitatory_active: blue().lerp(base(), VOLTAGE_TINT), + inhibitory: red(), + inhibitory_active: red().lerp(mantle(), VOLTAGE_TINT), + spike: crust(), + flash: Vec3::new(1.0, 1.0, 0.55), + neutral: crust(), + }, + Theme::Light => Palette { + floor: srgb(236, 239, 245), + excitatory: srgb(66, 105, 199), + excitatory_active: srgb(66, 105, 199).lerp(base(), VOLTAGE_TINT), + inhibitory: srgb(184, 61, 85), + inhibitory_active: srgb(184, 61, 85).lerp(mantle(), VOLTAGE_TINT), + spike: crust(), + flash: Vec3::new(1.0, 1.0, 0.55), + neutral: srgb(140, 143, 161), + }, + } + } +} + +impl Palette { + /// A soma, from rest at 0 to threshold at 1. + pub fn soma(&self, neuron_type: &NeuronType, value: f32) -> Vec3 { + match *neuron_type { + NeuronType::Excitatory => self.excitatory.lerp(self.excitatory_active, value), + NeuronType::Inhibitory => self.inhibitory.lerp(self.inhibitory_active, value), + } + } + + /// A compartment, which turns over mostly around the middle of the range. + pub fn neurocolor(&self, neuron_type: &NeuronType, value: f32) -> Vec3 { + let v = 1.0 / (1.0 + (-5.0 * (value - 0.5)).exp()); + self.soma(neuron_type, v) } } diff --git a/neuronify-core/src/rendering/connections.rs b/neuronify-core/src/rendering/connections.rs index 338946fb..385812e3 100644 --- a/neuronify-core/src/rendering/connections.rs +++ b/neuronify-core/src/rendering/connections.rs @@ -1,129 +1,37 @@ -use std::collections::HashSet; - -use glam::Vec3; -use hecs::Entity; - use crate::components::*; -use crate::constants::*; use crate::measurement::voltmeter::Voltmeter; -use crate::rendering::colors::*; +use crate::rendering::colors::Palette; use crate::rendering::gpu_types::ConnectionData; use crate::ConnectionTool; use crate::Tool; -fn quadratic_bezier(p0: Vec3, p1: Vec3, p2: Vec3, t: f32) -> Vec3 { - let u = 1.0 - t; - u * u * p0 + 2.0 * u * t * p1 + t * t * p2 -} - +/// Flat lines for the voltmeter wires and for the connection being dragged out. +/// +/// Every other connection is drawn as a tube along its `ConnectionPath`. pub fn collect_connections( world: &hecs::World, tool: &Tool, connection_tool: &Option, + palette: &Palette, ) -> Vec { - let connection_info: Vec<(Entity, Entity, Entity, f32, bool)> = world + let mut connections: Vec = world .query::<&Connection>() + .with::<&Voltmeter>() .iter() - .map(|(e, c)| { - let is_voltmeter = world.get::<&Voltmeter>(e).is_ok(); - ( - e, - c.from, - c.to, - c.strength as f32, - if is_voltmeter { false } else { c.directional }, - ) - }) - .collect(); - - let connection_pairs: HashSet<(Entity, Entity)> = connection_info - .iter() - .map(|(_, from, to, _, _)| (*from, *to)) - .collect(); - - let mut connections: Vec = Vec::new(); - - for &(_edge_entity, from, to, strength, directional) in &connection_info { - let start = world - .get::<&Position>(from) - .expect("Connection from broken") - .position; - let end = world - .get::<&Position>(to) - .expect("Connection to broken") - .position; - let value = |target: Entity| -> f32 { - if let Ok(compartment) = world.get::<&Compartment>(target) { - ((compartment.voltage + 50.0) / 200.0) as f32 - } else if let Ok(dynamics) = world.get::<&LeakyDynamics>(target) { - let neuron = world.get::<&LeakyNeuron>(target).ok(); - if let Some(neuron) = neuron { - ((dynamics.voltage - neuron.resting_potential) - / (neuron.threshold - neuron.resting_potential)) - .clamp(0.0, 1.0) as f32 - } else { - 0.5 - } - } else { - 1.0 - } - }; - let start_value = value(to); - let end_value = value(from); - let (start_color, end_color) = if world.get::<&CurrentClamp>(from).is_ok() { - (yellow(), yellow()) - } else if world.get::<&GeneratorDynamics>(from).is_ok() { - (orange(), orange()) - } else if let Ok(neuron_type) = world.get::<&NeuronType>(from) { - ( - neurocolor(&neuron_type, start_value), - neurocolor(&neuron_type, end_value), - ) - } else { - (crust(), crust()) - }; - - let is_reciprocal = connection_pairs.contains(&(to, from)); - let dir_val = if directional { 1.0 } else { 0.0 }; - - if is_reciprocal { - let segments = BEZIER_SEGMENTS; - let diff = end - start; - let up = Vec3::new(0.0, 1.0, 0.0); - let right = diff.cross(up); - let bend_amount = BEZIER_BEND_FRACTION * diff.length(); - let control = (start + end) * 0.5 + right.normalize_or_zero() * bend_amount; - - for i in 0..segments { - let t0 = i as f32 / segments as f32; - let t1 = (i + 1) as f32 / segments as f32; - let p0 = quadratic_bezier(start, control, end, t0); - let p1 = quadratic_bezier(start, control, end, t1); - let c0 = start_color.lerp(end_color, t0); - let c1 = start_color.lerp(end_color, t1); - let is_last = i == segments - 1; - connections.push(ConnectionData { - position_a: p0, - position_b: p1, - strength, - directional: if is_last { dir_val } else { 0.0 }, - start_color: c0, - end_color: c1, - _padding: Default::default(), - }); - } - } else { - connections.push(ConnectionData { + .filter_map(|(_, connection)| { + let start = world.get::<&Position>(connection.from).ok()?.position; + let end = world.get::<&Position>(connection.to).ok()?.position; + Some(ConnectionData { position_a: start, position_b: end, - strength, - directional: dir_val, - start_color, - end_color, + strength: connection.strength as f32, + directional: 0.0, + start_color: palette.neutral, + end_color: palette.neutral, _padding: Default::default(), - }); - } - } + }) + }) + .collect(); if *tool == Tool::StaticConnection { if let Some(connection) = connection_tool { @@ -132,8 +40,8 @@ pub fn collect_connections( position_b: connection.end, strength: 1.0, directional: 1.0, - start_color: Vec3::new(0.8, 0.8, 0.8), - end_color: Vec3::new(0.8, 0.8, 0.8), + start_color: palette.neutral, + end_color: palette.neutral, _padding: Default::default(), }); } diff --git a/neuronify-core/src/rendering/cylinders.rs b/neuronify-core/src/rendering/cylinders.rs new file mode 100644 index 00000000..11f4ffa9 --- /dev/null +++ b/neuronify-core/src/rendering/cylinders.rs @@ -0,0 +1,118 @@ +use std::collections::{BTreeMap, HashMap}; + +use glam::Vec3; +use hecs::Entity; + +use crate::components::*; +use crate::constants::NODE_RADIUS; +use crate::rendering::colors::{terminal_color, Palette}; +use crate::rendering::gpu_types::CylinderData; +use crate::rendering::paths::ConnectionPath; + +/// Radius where a process leaves the soma. +const ROOT_RADIUS: f32 = 0.30; +/// Factor the radius shrinks by for every three units away from the nearest soma. +const TAPER: f32 = 0.85; +/// Floor so that long processes never disappear. +const MIN_RADIUS: f32 = 0.16; + +fn radius_from_soma_distance(distance: f32) -> f32 { + (ROOT_RADIUS * TAPER.powf(distance / 3.0)).max(MIN_RADIUS) +} + +/// Radius of every compartment, thick next to a soma and tapering away from it. +/// +/// Spheres and cylinders both use these, so that a chain of compartments +/// bends smoothly at each joint. +pub fn compartment_radii(world: &hecs::World) -> HashMap { + let somas: Vec = world + .query::<&Position>() + .with::<&LeakyNeuron>() + .iter() + .map(|(_, position)| position.position) + .collect(); + world + .query::<&Position>() + .with::<&Compartment>() + .iter() + .map(|(entity, position)| { + let distance = somas + .iter() + .map(|soma| soma.distance(position.position)) + .fold(f32::MAX, f32::min); + (entity, radius_from_soma_distance(distance)) + }) + .collect() +} + +/// Tapered tubes for the cable segments between compartments, from a soma to +/// its first compartment, and along the paths of all other connections. +pub fn collect_cylinders( + world: &hecs::World, + radii: &HashMap, + paths: &BTreeMap, + palette: &Palette, +) -> Vec { + let radius = |entity: Entity| radii.get(&entity).copied().unwrap_or(ROOT_RADIUS); + let mut cylinders: Vec = world + .query::<&Connection>() + .with::<&CompartmentCurrent>() + .iter() + .filter(|(_, connection)| !connection.directional) + .filter_map(|(_, connection)| { + let start = world.get::<&Position>(connection.from).ok()?.position; + let end = world.get::<&Position>(connection.to).ok()?.position; + let colored_by = if world.get::<&Compartment>(connection.to).is_ok() { + connection.to + } else { + connection.from + }; + let color = match ( + world.get::<&Compartment>(colored_by), + world.get::<&NeuronType>(colored_by), + ) { + (Ok(compartment), Ok(neuron_type)) => palette.neurocolor( + &neuron_type, + ((compartment.voltage + 50.0) / 200.0).clamp(0.0, 1.0) as f32, + ), + _ => palette.neutral, + }; + Some(CylinderData { + start, + start_radius: radius(connection.from), + end, + end_radius: radius(connection.to), + color, + _padding: Default::default(), + }) + }) + .collect(); + for path in paths.values() { + let segments = path.points.len().saturating_sub(1); + for (i, w) in path.points.windows(2).enumerate() { + cylinders.push(CylinderData { + start: w[0], + start_radius: path.radius, + end: w[1], + end_radius: path.radius, + color: path + .start_color + .lerp(path.end_color, (i as f32 + 0.5) / segments as f32), + _padding: Default::default(), + }); + } + // The bar across the end of an inhibitory synapse, as in ⊣ + if let (true, [.., before, end]) = (path.directional && path.inhibitory, &path.points[..]) { + let direction = (*end - *before).normalize_or_zero(); + cylinders.push(CylinderData { + start: *end - direction * 0.12, + start_radius: 0.6 * NODE_RADIUS, + end: *end + direction * 0.12, + end_radius: 0.6 * NODE_RADIUS, + color: terminal_color(), + _padding: Default::default(), + }); + } + } + cylinders +} diff --git a/neuronify-core/src/rendering/floor.rs b/neuronify-core/src/rendering/floor.rs new file mode 100644 index 00000000..b5b0ebc4 --- /dev/null +++ b/neuronify-core/src/rendering/floor.rs @@ -0,0 +1,39 @@ +use glam::{Quat, Vec3}; +use visula::primitives::mesh_primitive::MeshVertexAttributes; +use visula::{Expression, MeshGeometry, MeshMaterial, MeshPipeline, RenderingDescriptor}; + +use crate::constants::*; + +/// A plane under the network that catches the shadows. +pub fn create_floor( + rendering_descriptor: &RenderingDescriptor, + device: &wgpu::Device, + color: Vec3, +) -> Result { + let mut floor = MeshPipeline::new( + rendering_descriptor, + &MeshGeometry { + position: Vec3::new(0.0, FLOOR_HEIGHT, 0.0).into(), + rotation: Quat::IDENTITY.into(), + scale: Vec3::splat(FLOOR_SIZE).into(), + }, + // Plain lighting, since `shaded` loses the shadows on meshes in visula aa83a2b + &MeshMaterial { + color: Expression::from(color.extend(1.0)).directional_lit(), + }, + )?; + let corner = |x: f32, z: f32| MeshVertexAttributes { + position: [x, 0.0, z], + normal: [0.0, 1.0, 0.0], + uv: [x + 0.5, z + 0.5], + color: [255, 255, 255, 255], + }; + let vertices = [ + corner(-0.5, -0.5), + corner(0.5, -0.5), + corner(0.5, 0.5), + corner(-0.5, 0.5), + ]; + floor.set_mesh_data(device, &vertices, &[0, 1, 2, 0, 2, 3]); + Ok(floor) +} diff --git a/neuronify-core/src/rendering/gauges.rs b/neuronify-core/src/rendering/gauges.rs new file mode 100644 index 00000000..99f864b5 --- /dev/null +++ b/neuronify-core/src/rendering/gauges.rs @@ -0,0 +1,281 @@ +//! Gauges for how strongly the adaptation and burst currents act on a neuron right now: +//! a ring around the soma that fills for adaptation, and a bar beside it for bursting. +//! They face the camera and cast no shadows. + +use glam::{Quat, Vec3}; +use visula::{ + CylinderGeometry, CylinderMaterial, Cylinders, Expression, InstanceBuffer, RenderData, + Renderable, SphereGeometry, SphereMaterial, Spheres, Torus, TorusGeometry, TorusMaterial, +}; + +use crate::components::*; +use crate::constants::NODE_RADIUS; +use crate::rendering::colors::*; +use crate::rendering::gpu_types::{CylinderData, Sphere, TorusData}; +use crate::rendering::lighting::shaded; +use crate::rendering::shapes::is_inhibitory; + +/// Adaptation conductance, in spikes' worth, at which the ring is about two thirds full. +const ADAPTATION_SCALE: f64 = 2.0; +/// How far the empty part of a gauge goes from the colour of the neuron towards the floor. +const TRACK_MIX: f32 = 0.7; +/// Gauges float this far towards the camera, above the tubes around the soma. +const LIFT: f32 = 0.5 * NODE_RADIUS; +const RING_RADIUS: f32 = 1.32 * NODE_RADIUS; +const RING_THICKNESS: f32 = 0.075 * NODE_RADIUS; +const BAR_LENGTH: f32 = 1.8 * NODE_RADIUS; +const BAR_THICKNESS: f32 = 0.14 * NODE_RADIUS; + +#[derive(Default)] +struct Gauges { + spheres: Vec, + cylinders: Vec, + rings: Vec, +} + +impl Gauges { + fn stroke(&mut self, points: &[Vec3], radius: f32, color: Vec3) { + for pair in points.windows(2) { + self.cylinders.push(CylinderData { + start: pair[0], + start_radius: radius, + end: pair[1], + end_radius: radius, + color, + _padding: Default::default(), + }); + } + for &position in points { + self.spheres.push(Sphere { + position, + color, + radius, + _padding: Default::default(), + }); + } + } +} + +pub struct GaugeRenderer { + spheres: Spheres, + sphere_buffer: InstanceBuffer, + cylinders: Cylinders, + cylinder_buffer: InstanceBuffer, + rings: Torus, + ring_buffer: InstanceBuffer, +} + +impl GaugeRenderer { + pub fn new(application: &visula::Application) -> Self { + let sphere_buffer = InstanceBuffer::::new(&application.device); + let sphere = sphere_buffer.instance(); + let spheres = Spheres::new( + &application.rendering_descriptor(), + &SphereGeometry { + position: sphere.position, + radius: sphere.radius, + color: sphere.color, + }, + &SphereMaterial { + color: shaded(Expression::InputColor), + }, + ) + .unwrap(); + + let cylinder_buffer = InstanceBuffer::::new(&application.device); + let cylinder = cylinder_buffer.instance(); + let cylinders = Cylinders::new( + &application.rendering_descriptor(), + &CylinderGeometry { + start: cylinder.start, + end: cylinder.end, + start_radius: cylinder.start_radius, + end_radius: cylinder.end_radius, + color: cylinder.color, + }, + &CylinderMaterial { + color: shaded(Expression::InputColor), + }, + ) + .unwrap(); + + let ring_buffer = InstanceBuffer::::new(&application.device); + let ring = ring_buffer.instance(); + let rings = Torus::new( + &application.rendering_descriptor(), + &TorusGeometry { + position: ring.position, + major_radius: ring.major_radius, + minor_radius: ring.minor_radius, + rotation: ring.rotation, + color: ring.color, + }, + &TorusMaterial { + color: shaded(Expression::InputColor), + }, + ) + .unwrap(); + + Self { + spheres, + sphere_buffer, + cylinders, + cylinder_buffer, + rings, + ring_buffer, + } + } + + pub fn update( + &mut self, + application: &visula::Application, + world: &hecs::World, + palette: &Palette, + ) { + let camera = &application.camera_controller.current_transform; + let gauges = collect_gauges(world, palette, camera.position(), camera.forward, camera.up); + self.sphere_buffer + .update(&application.device, &application.queue, &gauges.spheres); + self.cylinder_buffer + .update(&application.device, &application.queue, &gauges.cylinders); + self.ring_buffer + .update(&application.device, &application.queue, &gauges.rings); + } + + /// There is no `render_shadow`, so gauges cast no shadows. + pub fn render(&mut self, data: &mut RenderData) { + self.spheres.render(data); + self.cylinders.render(data); + self.rings.render(data); + } +} + +fn adaptation_level(adaptation: &AdaptationCurrent) -> f32 { + let scale = ADAPTATION_SCALE * adaptation.adaptation.max(f64::MIN_POSITIVE); + (1.0 - (-adaptation.conductance / scale).exp()) as f32 +} + +fn burst_level(burst: &BurstCurrent) -> f32 { + (burst.boost / burst.peak.max(f64::MIN_POSITIVE)).clamp(0.0, 1.0) as f32 +} + +fn collect_gauges( + world: &hecs::World, + palette: &Palette, + eye: Vec3, + forward: Vec3, + view_up: Vec3, +) -> Gauges { + let screen_right = forward.cross(view_up).normalize(); + let mut gauges = Gauges::default(); + for (entity, position) in world.query::<&Position>().with::<&LeakyNeuron>().iter() { + let adaptation = world + .get::<&AdaptationCurrent>(entity) + .ok() + .map(|adaptation| adaptation_level(&adaptation)); + let burst = world + .get::<&BurstCurrent>(entity) + .ok() + .map(|burst| burst_level(&burst)); + if adaptation.is_none() && burst.is_none() { + continue; + } + let neuron_type = if is_inhibitory(world, entity) { + NeuronType::Inhibitory + } else { + NeuronType::Excitatory + }; + let track = palette + .soma(&neuron_type, 0.0) + .lerp(palette.floor, TRACK_MIX); + let normal = (eye - position.position).normalize(); + let right = (screen_right - normal * screen_right.dot(normal)).normalize(); + let up = normal.cross(right); + let center = position.position + normal * LIFT; + let front = normal * 0.04; + + if let Some(level) = adaptation { + gauges.rings.push(TorusData { + position: center, + major_radius: RING_RADIUS, + rotation: Quat::from_rotation_arc(Vec3::Y, normal), + color: track, + minor_radius: RING_THICKNESS, + }); + if level > 0.005 { + let steps = (64.0 * level).ceil() as usize; + let points: Vec = (0..=steps) + .map(|step| { + let angle = std::f32::consts::TAU * level * step as f32 / steps as f32; + center + front + (up * angle.cos() + right * angle.sin()) * RING_RADIUS + }) + .collect(); + gauges.stroke(&points, 1.3 * RING_THICKNESS, adaptation_gauge_color()); + } + } + + if let Some(level) = burst { + let offset = if adaptation.is_some() { 1.75 } else { 1.6 } * NODE_RADIUS; + let bottom = center + right * offset - up * 0.5 * BAR_LENGTH; + gauges.stroke(&[bottom, bottom + up * BAR_LENGTH], BAR_THICKNESS, track); + if level > 0.005 { + gauges.stroke( + &[bottom + front, bottom + front + up * BAR_LENGTH * level], + 1.2 * BAR_THICKNESS, + burst_gauge_color(), + ); + } + } + } + gauges +} + +#[cfg(test)] +mod tests { + use super::*; + + fn neuron(world: &mut hecs::World) -> hecs::Entity { + world.spawn(( + Position { + position: Vec3::ZERO, + }, + LeakyNeuron::default(), + NeuronType::Excitatory, + )) + } + + #[test] + fn adaptation_gets_a_ring_and_bursting_a_bar() { + let mut world = hecs::World::new(); + neuron(&mut world); + let adaptive = neuron(&mut world); + world + .insert_one(adaptive, AdaptationCurrent::default()) + .unwrap(); + let bursting = neuron(&mut world); + world.insert_one(bursting, BurstCurrent::default()).unwrap(); + + let gauges = collect_gauges( + &world, + &Theme::Dark.palette(), + Vec3::new(0.0, 50.0, 0.0), + Vec3::NEG_Y, + Vec3::X, + ); + assert_eq!(gauges.rings.len(), 1); + assert_eq!(gauges.cylinders.len(), 1); + } + + #[test] + fn gauges_fill_with_the_current() { + let mut adaptation = AdaptationCurrent::default(); + assert_eq!(adaptation_level(&adaptation), 0.0); + adaptation.conductance = 4.0 * adaptation.adaptation; + assert!(adaptation_level(&adaptation) > 0.8); + + let mut burst = BurstCurrent::default(); + assert_eq!(burst_level(&burst), 0.0); + burst.boost = burst.peak; + assert_eq!(burst_level(&burst), 1.0); + } +} diff --git a/neuronify-core/src/rendering/gpu_types.rs b/neuronify-core/src/rendering/gpu_types.rs index a225c535..2fa84b06 100644 --- a/neuronify-core/src/rendering/gpu_types.rs +++ b/neuronify-core/src/rendering/gpu_types.rs @@ -23,6 +23,27 @@ pub struct ConnectionData { pub _padding: [f32; 2], } +#[repr(C, align(16))] +#[derive(Clone, Copy, Instance, Pod, Zeroable)] +pub struct CylinderData { + pub start: Vec3, + pub start_radius: f32, + pub end: Vec3, + pub end_radius: f32, + pub color: Vec3, + pub _padding: f32, +} + +#[repr(C, align(16))] +#[derive(Clone, Copy, Instance, Pod, Zeroable)] +pub struct TorusData { + pub position: Vec3, + pub major_radius: f32, + pub rotation: Quat, + pub color: Vec3, + pub minor_radius: f32, +} + #[repr(C, align(16))] #[derive(Clone, Copy, Instance, Pod, Zeroable)] pub struct LineData { diff --git a/neuronify-core/src/rendering/lighting.rs b/neuronify-core/src/rendering/lighting.rs new file mode 100644 index 00000000..3347ae13 --- /dev/null +++ b/neuronify-core/src/rendering/lighting.rs @@ -0,0 +1,63 @@ +//! A look between flat colours, which carry the voltage, and full lighting, +//! which shows the shapes and their shadows. + +use glam::Vec3; +use visula::post_process::config::{BloomConfig, SsaoConfig, Tonemapping}; +use visula::Expression; + +use crate::rendering::colors::Theme; + +/// Share of each colour that comes from lighting. The rest is the flat colour. +const SHADING: f32 = 0.5; + +/// The colour, partly lit, without a specular highlight that would read as a voltage. +pub fn shaded(color: Expression) -> Expression { + color.clone() * (1.0 - SHADING) + color.directional_lit() * SHADING +} + +pub fn configure_lighting(application: &mut visula::Application, theme: Theme) { + application.light.direction = Vec3::new(-0.3, -1.0, -0.2).normalize(); + application.light.shadow_distance = 300.0; + // Colours stay as they are, since they show the voltage + application.post_processor.config.tonemapping = Tonemapping::None; + set_theme_lighting(application, theme); + // Only flashes are brighter than white, so only they bloom + application.post_processor.config.bloom = Some(BloomConfig { + threshold: 1.1, + intensity: 2.5, + mip_levels: 5, + }); + application.post_processor.enable_bloom( + &application.device, + application.config.width, + application.config.height, + ); + // visula's SSAO expects a multisampled depth buffer, which the web build does not have + #[cfg(not(target_arch = "wasm32"))] + application.post_processor.enable_ssao( + &application.device, + &application.queue, + application.config.width, + application.config.height, + &application.camera, + &application.depth_texture, + ); +} + +/// visula's ambient and fill light are fixed, so a shadow keeps about a fifth of the +/// light. A weaker main light, brightened again through its colour, lets shadows keep +/// more of it while lit surfaces stay at their own colour. A light floor takes darker +/// shadows than a dark one before they look heavy. +pub fn set_theme_lighting(application: &mut visula::Application, theme: Theme) { + let (intensity, brightness, occlusion) = match theme { + Theme::Dark => (0.25, 2.3, 0.75), + Theme::Light => (0.35, 1.9, 1.0), + }; + application.light.intensity = intensity; + application.light.color = Vec3::splat(brightness); + application.post_processor.config.ssao = Some(SsaoConfig { + radius: 2.0, + bias: 0.02, + intensity: occlusion, + }); +} diff --git a/neuronify-core/src/rendering/mod.rs b/neuronify-core/src/rendering/mod.rs index 3fbf6fd1..7842a832 100644 --- a/neuronify-core/src/rendering/mod.rs +++ b/neuronify-core/src/rendering/mod.rs @@ -1,15 +1,27 @@ pub mod bounds; pub mod colors; pub mod connections; +pub mod cylinders; +pub mod floor; +pub mod gauges; pub mod gpu_types; +pub mod lighting; pub mod notes; +pub mod paths; +pub mod scene; +pub mod shapes; pub mod spheres; pub mod voltmeter; pub use bounds::{canvas_bounds, circuit_bounds, Bounds}; -pub use colors::{neurocolor, srgb, srgb_component}; +pub use colors::{srgb, srgb_component, Palette, Theme}; pub use connections::collect_connections; -pub use gpu_types::{ConnectionData, LineData, MeshInstanceData, Sphere}; +pub use cylinders::{collect_cylinders, compartment_radii}; +pub use floor::create_floor; +pub use gauges::GaugeRenderer; +pub use gpu_types::{ConnectionData, CylinderData, LineData, MeshInstanceData, Sphere, TorusData}; pub use notes::NoteRenderer; +pub use paths::{connection_paths, ConnectionPath}; +pub use scene::{collect_scene, Scene}; pub use spheres::collect_spheres; pub use voltmeter::collect_voltmeter_traces; diff --git a/neuronify-core/src/rendering/paths.rs b/neuronify-core/src/rendering/paths.rs new file mode 100644 index 00000000..c93bd060 --- /dev/null +++ b/neuronify-core/src/rendering/paths.rs @@ -0,0 +1,242 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use glam::Vec3; +use hecs::Entity; + +use crate::components::*; +use crate::constants::*; +use crate::measurement::voltmeter::Voltmeter; +use crate::rendering::colors::*; +use crate::rendering::shapes::is_inhibitory; + +/// Spacing between the joints along a connection, the same as between compartments. +const JOINT_SPACING: f32 = 2.0 * NODE_RADIUS; +/// Largest sideways bend of a connection, as a fraction of its length. +const MAX_BEND: f32 = 0.1; +/// Radius of a connection with strength 1, the same as the thinnest axon. +const CONNECTION_RADIUS: f32 = 0.16; +/// Distance from the synapse marker to the centre of the target. +const SYNAPSE_GAP: f32 = 2.0 * NODE_RADIUS; + +/// The tube a connection is drawn as, from the source towards the target. +/// +/// Directional connections stop short of the target, where the synapse marker goes. +pub struct ConnectionPath { + pub points: Vec, + pub radius: f32, + pub start_color: Vec3, + pub end_color: Vec3, + pub directional: bool, + /// The source inhibits its target, so the path ends in a bar rather than a knob + pub inhibitory: bool, +} + +impl ConnectionPath { + /// The point a fraction `t` of the way along the path. + pub fn point_at(&self, t: f32) -> Vec3 { + let total: f32 = self.points.windows(2).map(|w| w[0].distance(w[1])).sum(); + let mut remaining = t.clamp(0.0, 1.0) * total; + for w in self.points.windows(2) { + let length = w[0].distance(w[1]); + if remaining <= length && length > 0.0 { + return w[0].lerp(w[1], remaining / length); + } + remaining -= length; + } + *self.points.last().unwrap_or(&Vec3::ZERO) + } + + /// Distance from `point` to the nearest segment it lies alongside, if any. + pub fn distance_to(&self, point: Vec3) -> Option { + self.points + .windows(2) + .filter_map(|w| { + let ab = w[1] - w[0]; + let t = (point - w[0]).dot(ab) / ab.length_squared(); + (0.0..=1.0) + .contains(&t) + .then(|| point.distance(w[0] + t * ab)) + }) + .reduce(f32::min) + } +} + +/// Cable segments between compartments and voltmeter wires are drawn in other ways. +pub(crate) fn is_drawn_as_path( + world: &hecs::World, + entity: Entity, + connection: &Connection, +) -> bool { + world.get::<&Voltmeter>(entity).is_err() + && (connection.directional || world.get::<&CompartmentCurrent>(entity).is_err()) +} + +fn connection_colors( + world: &hecs::World, + from: Entity, + to: Entity, + palette: &Palette, +) -> (Vec3, Vec3) { + let value = |target: Entity| -> f32 { + if let Ok(compartment) = world.get::<&Compartment>(target) { + ((compartment.voltage + 50.0) / 200.0) as f32 + } else if let Ok(dynamics) = world.get::<&LeakyDynamics>(target) { + let neuron = world.get::<&LeakyNeuron>(target).ok(); + if let Some(neuron) = neuron { + ((dynamics.voltage - neuron.resting_potential) + / (neuron.threshold - neuron.resting_potential)) + .clamp(0.0, 1.0) as f32 + } else { + 0.5 + } + } else { + 1.0 + } + }; + let start_value = value(to); + let end_value = value(from); + if world.get::<&CurrentClamp>(from).is_ok() { + (yellow(), yellow()) + } else if world.get::<&GeneratorDynamics>(from).is_ok() { + (orange(), orange()) + } else if let Ok(neuron_type) = world.get::<&NeuronType>(from) { + ( + palette.neurocolor(&neuron_type, start_value), + palette.neurocolor(&neuron_type, end_value), + ) + } else { + (palette.neutral, palette.neutral) + } +} + +fn quadratic_bezier(p0: Vec3, p1: Vec3, p2: Vec3, t: f32) -> Vec3 { + let u = 1.0 - t; + u * u * p0 + 2.0 * u * t * p1 + t * t * p2 +} + +/// Samples a bent line from `start` to `end`, cut `trim` short of `end`. +fn bent_line(start: Vec3, end: Vec3, bend: f32, trim: f32) -> Vec { + let diff = end - start; + let length = diff.length(); + let side = diff.cross(Vec3::Y).normalize_or_zero(); + let control = (start + end) * 0.5 + side * bend * length; + let steps = ((length / JOINT_SPACING).ceil() as usize).max(1); + let curve: Vec = (0..=steps) + .map(|i| quadratic_bezier(start, control, end, i as f32 / steps as f32)) + .collect(); + let mut keep = curve.windows(2).map(|w| w[0].distance(w[1])).sum::() - trim; + let mut points = vec![start]; + for w in curve.windows(2) { + let segment = w[0].distance(w[1]); + if keep <= segment { + if keep > 0.0 { + points.push(w[0].lerp(w[1], keep / segment)); + } + break; + } + keep -= segment; + points.push(w[1]); + } + points +} + +/// Paths for the connections drawn as tubes, keyed by connection entity. +pub fn connection_paths( + world: &hecs::World, + radii: &HashMap, + palette: &Palette, +) -> BTreeMap { + let pairs: HashSet<(Entity, Entity)> = world + .query::<&Connection>() + .iter() + .map(|(_, c)| (c.from, c.to)) + .collect(); + world + .query::<&Connection>() + .iter() + .filter(|(entity, connection)| is_drawn_as_path(world, *entity, connection)) + .filter_map(|(entity, connection)| { + let start = world.get::<&Position>(connection.from).ok()?.position; + let end = world.get::<&Position>(connection.to).ok()?.position; + let from_compartment = world.get::<&Compartment>(connection.from).is_ok(); + // Reciprocal pairs bend to opposite sides so both stay visible. + // An axon terminal continues its chain of compartments straight. + let bend = if pairs.contains(&(connection.to, connection.from)) { + BEZIER_BEND_FRACTION + } else if from_compartment { + 0.0 + } else { + ((entity.id() as f32 * 0.618_034).fract() * 2.0 - 1.0) * MAX_BEND + }; + let trim = if connection.directional { + SYNAPSE_GAP + } else { + 0.0 + }; + let radius = match radii.get(&connection.from) { + Some(&radius) if from_compartment => radius, + _ => (CONNECTION_RADIUS * connection.strength as f32).max(0.05), + }; + let (start_color, end_color) = + connection_colors(world, connection.from, connection.to, palette); + Some(( + entity, + ConnectionPath { + points: bent_line(start, end, bend, trim), + radius, + start_color, + end_color, + directional: connection.directional, + inhibitory: is_inhibitory(world, connection.from), + }, + )) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(bend: f32, trim: f32) -> ConnectionPath { + ConnectionPath { + points: bent_line(Vec3::ZERO, Vec3::new(20.0, 0.0, 0.0), bend, trim), + radius: CONNECTION_RADIUS, + start_color: Vec3::ONE, + end_color: Vec3::ONE, + directional: trim > 0.0, + inhibitory: false, + } + } + + #[test] + fn stops_short_of_the_target() { + let path = path(MAX_BEND, SYNAPSE_GAP); + let end = *path.points.last().unwrap(); + assert!((end.distance(Vec3::new(20.0, 0.0, 0.0)) - SYNAPSE_GAP).abs() < 0.1); + assert!(path + .points + .windows(2) + .all(|w| w[0].distance(w[1]) <= JOINT_SPACING * 1.1)); + } + + #[test] + fn spikes_and_erasing_follow_the_bend() { + let path = path(MAX_BEND, SYNAPSE_GAP); + assert_eq!(path.point_at(0.0), Vec3::ZERO); + assert_eq!(path.point_at(1.0), *path.points.last().unwrap()); + let middle = path.point_at(0.5); + assert!( + middle.z.abs() > 0.5, + "the middle should be bent, got {middle}" + ); + assert!(path.distance_to(middle).unwrap() < 1e-4); + assert!(path.distance_to(Vec3::new(10.0, 0.0, 0.0)).unwrap() > 0.5); + } + + #[test] + fn a_connection_shorter_than_the_gap_has_no_tube() { + let points = bent_line(Vec3::ZERO, Vec3::X, 0.0, SYNAPSE_GAP); + assert_eq!(points, vec![Vec3::ZERO]); + } +} diff --git a/neuronify-core/src/rendering/scene.rs b/neuronify-core/src/rendering/scene.rs new file mode 100644 index 00000000..24fef6ff --- /dev/null +++ b/neuronify-core/src/rendering/scene.rs @@ -0,0 +1,28 @@ +use crate::rendering::colors::Palette; +use crate::rendering::cylinders::{collect_cylinders, compartment_radii}; +use crate::rendering::gpu_types::{CylinderData, Sphere, TorusData}; +use crate::rendering::paths::connection_paths; +use crate::rendering::shapes::{collect_rings, collect_shapes}; +use crate::rendering::spheres::collect_spheres; + +/// The solid shapes of the 3D view, which the example previews also draw from above. +pub struct Scene { + pub spheres: Vec, + pub cylinders: Vec, + pub rings: Vec, +} + +pub fn collect_scene(world: &hecs::World, palette: &Palette) -> Scene { + let radii = compartment_radii(world); + let paths = connection_paths(world, &radii, palette); + let mut spheres = collect_spheres(world, &radii, &paths, palette); + let mut cylinders = collect_cylinders(world, &radii, &paths, palette); + let shapes = collect_shapes(world, palette); + spheres.extend(shapes.spheres); + cylinders.extend(shapes.cylinders); + Scene { + spheres, + cylinders, + rings: collect_rings(world, palette), + } +} diff --git a/neuronify-core/src/rendering/shapes.rs b/neuronify-core/src/rendering/shapes.rs new file mode 100644 index 00000000..ad231236 --- /dev/null +++ b/neuronify-core/src/rendering/shapes.rs @@ -0,0 +1,338 @@ +//! Shapes that tell the element types apart without labels. +//! +//! A touch sensor is a flat pad with a pointing hand, a current source is a pad with an +//! arrow towards what it drives, and a spike generator is a soma inside a ring. + +use std::collections::HashMap; +use std::f32::consts::{FRAC_PI_6, PI}; + +use glam::{Quat, Vec2, Vec3}; +use hecs::Entity; + +use crate::components::*; +use crate::constants::*; +use crate::rendering::colors::*; +use crate::rendering::gpu_types::{CylinderData, Sphere, TorusData}; + +/// How long a soma or generator keeps glowing after it fires, in simulated seconds. +const FLASH_DURATION: f64 = 0.01; +/// Up and right on the screen with the default camera. +const SCREEN_UP: Vec3 = Vec3::X; +const SCREEN_RIGHT: Vec3 = Vec3::Z; +/// Where the arrow of a current source without a target points. +const UNCONNECTED_ARROW: Vec3 = SCREEN_UP; +pub const PAD_RADIUS: f32 = 1.25 * NODE_RADIUS; +const PAD_HALF_HEIGHT: f32 = 0.2; +const GLYPH_HEIGHT: f32 = PAD_HALF_HEIGHT + 0.04; + +struct Digit { + width: f32, + side: f32, + valley: f32, +} + +/// The index finger and the knuckles of the hand on a touch pad, after Neuronify 1's icon. +const HAND: [Digit; 4] = [ + Digit { + width: 1.0, + side: 2.55, + valley: 0.45, + }, + Digit { + width: 0.55, + side: 1.36, + valley: 0.5, + }, + Digit { + width: 0.5, + side: 1.32, + valley: 0.56, + }, + Digit { + width: 0.45, + side: 1.18, + valley: 0.6, + }, +]; +const HAND_SCALE: f32 = 0.45 * NODE_RADIUS; +const HAND_TILT: f32 = FRAC_PI_6; +const FINGERTIP_STEPS: usize = 8; + +pub fn is_inhibitory(world: &hecs::World, entity: Entity) -> bool { + world.get::<&Inhibitory>(entity).is_ok() + || world + .get::<&NeuronType>(entity) + .is_ok_and(|neuron_type| matches!(*neuron_type, NeuronType::Inhibitory)) +} + +/// How much brighter than white a flash starts, so that only flashes bloom. +const FLASH_BRIGHTNESS: f32 = 3.0; + +fn flash(time_since_fire: f64) -> f32 { + let remaining = (1.0 - time_since_fire / FLASH_DURATION).max(0.0) as f32; + remaining * remaining +} + +/// The colour of a soma, from its voltage and how recently it fired. +pub fn soma_color( + world: &hecs::World, + entity: Entity, + neuron: &LeakyNeuron, + dynamics: &LeakyDynamics, + palette: &Palette, +) -> Vec3 { + let value = ((dynamics.voltage - neuron.resting_potential) + / (neuron.threshold - neuron.resting_potential)) + .clamp(0.0, 1.0) as f32; + let neuron_type = if is_inhibitory(world, entity) { + NeuronType::Inhibitory + } else { + NeuronType::Excitatory + }; + palette.soma(&neuron_type, value).lerp( + palette.flash * FLASH_BRIGHTNESS, + flash(dynamics.time_since_fire), + ) +} + +pub fn generator_color(dynamics: &GeneratorDynamics, palette: &Palette) -> Vec3 { + orange().lerp( + palette.flash * FLASH_BRIGHTNESS, + flash(dynamics.time_since_fire), + ) +} + +fn glyph_color() -> Vec3 { + srgb(255, 244, 214) +} + +/// The outline of the hand, centred on the pad, with x to the right and y up on the screen. +fn hand_outline() -> Vec { + let mut points = vec![Vec2::ZERO]; + let mut left = 0.0; + for digit in HAND { + let radius = digit.width / 2.0; + points.extend((0..=FINGERTIP_STEPS).map(|step| { + let angle = PI * step as f32 / FINGERTIP_STEPS as f32; + Vec2::new( + left + radius - radius * angle.cos(), + digit.side + radius * angle.sin(), + ) + })); + left += digit.width; + points.push(Vec2::new(left, digit.valley)); + } + let rotation = Vec2::from_angle(HAND_TILT); + let points: Vec = points + .iter() + .map(|&point| rotation.rotate(point) * HAND_SCALE) + .collect(); + let min = points.iter().copied().reduce(Vec2::min).unwrap_or_default(); + let max = points.iter().copied().reduce(Vec2::max).unwrap_or_default(); + let center = (min + max) / 2.0; + points.iter().map(|&point| point - center).collect() +} + +#[derive(Default)] +pub struct Shapes { + pub cylinders: Vec, + pub spheres: Vec, +} + +impl Shapes { + fn pad(&mut self, center: Vec3, color: Vec3) { + self.cylinders.push(CylinderData { + start: center - Vec3::Y * PAD_HALF_HEIGHT, + start_radius: PAD_RADIUS, + end: center + Vec3::Y * PAD_HALF_HEIGHT, + end_radius: PAD_RADIUS, + color, + _padding: Default::default(), + }); + } + + fn stroke(&mut self, points: &[Vec3], radius: f32, color: Vec3) { + for pair in points.windows(2) { + self.cylinders.push(CylinderData { + start: pair[0], + start_radius: radius, + end: pair[1], + end_radius: radius, + color, + _padding: Default::default(), + }); + } + for &position in points { + self.spheres.push(Sphere { + position, + color, + radius, + _padding: Default::default(), + }); + } + } +} + +/// The touch pads with their hands, and the current source pads with their arrows. +pub fn collect_shapes(world: &hecs::World, palette: &Palette) -> Shapes { + let mut shapes = Shapes::default(); + let hand = hand_outline(); + for (_, (dynamics, position)) in world + .query::<(&GeneratorDynamics, &Position)>() + .with::<&TouchSensor>() + .iter() + { + let center = position.position; + shapes.pad(center, generator_color(dynamics, palette)); + let outline: Vec = hand + .iter() + .map(|point| { + center + Vec3::Y * GLYPH_HEIGHT + SCREEN_RIGHT * point.x + SCREEN_UP * point.y + }) + .collect(); + shapes.stroke(&outline, 0.06 * NODE_RADIUS, glyph_color()); + } + + let targets: HashMap = world + .query::<&Connection>() + .iter() + .filter(|(_, connection)| world.get::<&CurrentClamp>(connection.from).is_ok()) + .filter_map(|(_, connection)| { + let target = world.get::<&Position>(connection.to).ok()?; + Some((connection.from, target.position)) + }) + .collect(); + for (entity, position) in world.query::<&Position>().with::<&CurrentClamp>().iter() { + let center = position.position; + shapes.pad(center, yellow()); + let forward = targets + .get(&entity) + .map(|target| (*target - center).with_y(0.0).normalize_or_zero()) + .filter(|direction| *direction != Vec3::ZERO) + .unwrap_or(UNCONNECTED_ARROW); + let side = Vec3::Y.cross(forward); + let point = |u: f32, v: f32| { + center + Vec3::Y * GLYPH_HEIGHT + (side * u + forward * v) * 0.85 * NODE_RADIUS + }; + let color = glyph_color(); + let radius = 0.11 * NODE_RADIUS; + shapes.stroke(&[point(0.0, -0.62), point(0.0, 0.62)], radius, color); + shapes.stroke( + &[point(-0.4, 0.2), point(0.0, 0.62), point(0.4, 0.2)], + radius, + color, + ); + } + shapes +} + +/// The rings around spike generators, which flash with the generator. +pub fn collect_rings(world: &hecs::World, palette: &Palette) -> Vec { + world + .query::<(&GeneratorDynamics, &Position)>() + .without::<&TouchSensor>() + .iter() + .map(|(_, (dynamics, position))| TorusData { + position: position.position, + major_radius: 1.6 * NODE_RADIUS, + rotation: Quat::IDENTITY, + color: generator_color(dynamics, palette), + minor_radius: 0.15 * NODE_RADIUS, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::{lif_step, stimulate_nearby}; + use crate::StimulationTool; + + #[test] + fn generators_have_rings_and_touch_sensors_are_pads() { + let mut world = hecs::World::new(); + world.spawn(( + Position { + position: Vec3::ZERO, + }, + GeneratorDynamics::default(), + )); + world.spawn(( + Position { position: Vec3::X }, + GeneratorDynamics::default(), + TouchSensor, + )); + let palette = Theme::Light.palette(); + assert_eq!(collect_rings(&world, &palette).len(), 1); + let pad = collect_shapes(&world, &palette).cylinders[0]; + assert!((pad.end - pad.start).cross(Vec3::Y).length() < 1e-6); + } + + #[test] + fn touch_pads_carry_a_hand_inside_the_pad() { + let mut world = hecs::World::new(); + world.spawn(( + Position { position: Vec3::X }, + GeneratorDynamics::default(), + TouchSensor, + )); + let shapes = collect_shapes(&world, &Theme::Dark.palette()); + let (pad, hand) = shapes.cylinders.split_first().unwrap(); + assert!(!hand.is_empty()); + for stroke in hand { + for end in [stroke.start, stroke.end] { + assert!(end.y > pad.end.y); + let reach = (end - Vec3::X).with_y(0.0).length() + stroke.start_radius; + assert!(reach < PAD_RADIUS, "{reach}"); + } + } + } + + #[test] + fn touch_sensors_stop_flashing_after_a_touch() { + let mut world = hecs::World::new(); + world.spawn(( + Position { + position: Vec3::ZERO, + }, + GeneratorDynamics::default(), + TouchSensor, + )); + let palette = Theme::Dark.palette(); + let pad_color = |world: &hecs::World| collect_shapes(world, &palette).cylinders[0].color; + let resting = pad_color(&world); + let touch = StimulationTool { + position: Vec3::ZERO, + }; + stimulate_nearby(&mut world, &Some(touch)); + assert_ne!(pad_color(&world), resting); + let steps = (FLASH_DURATION / LIF_DT).ceil() as usize; + for step in 0..=steps { + lif_step(&mut world, LIF_DT, step as f64 * LIF_DT); + } + assert_eq!(pad_color(&world), resting); + } + + #[test] + fn current_source_arrow_points_at_its_target() { + let mut world = hecs::World::new(); + let source = world.spawn(( + Position { + position: Vec3::ZERO, + }, + CurrentClamp::default(), + )); + let target = world.spawn((Position { + position: Vec3::new(0.0, 0.0, 5.0), + },)); + world.spawn((Connection { + from: source, + to: target, + strength: 1.0, + directional: true, + },)); + let shapes = collect_shapes(&world, &Theme::Dark.palette()); + let shaft = &shapes.cylinders[1]; + assert!((shaft.end - shaft.start).normalize().dot(Vec3::Z) > 0.99); + } +} diff --git a/neuronify-core/src/rendering/spheres.rs b/neuronify-core/src/rendering/spheres.rs index 272185bf..0c71e5cb 100644 --- a/neuronify-core/src/rendering/spheres.rs +++ b/neuronify-core/src/rendering/spheres.rs @@ -1,53 +1,42 @@ -use glam::Vec3; +use std::collections::{BTreeMap, HashMap}; + +use hecs::Entity; use crate::components::*; use crate::constants::*; use crate::rendering::colors::*; use crate::rendering::gpu_types::Sphere; +use crate::rendering::paths::ConnectionPath; +use crate::rendering::shapes::{generator_color, soma_color}; -pub fn collect_spheres(world: &hecs::World) -> Vec { +/// `radii` comes from `compartment_radii`, so the joints match the cylinders. +pub fn collect_spheres( + world: &hecs::World, + radii: &HashMap, + paths: &BTreeMap, + palette: &Palette, +) -> Vec { let mut spheres = Vec::new(); let lif_neuron_spheres: Vec = world .query::<(&LeakyNeuron, &LeakyDynamics, &Position)>() .iter() - .map(|(entity, (neuron, dynamics, position))| { - let value = ((dynamics.voltage - neuron.resting_potential) - / (neuron.threshold - neuron.resting_potential)) - .clamp(0.0, 1.0) as f32; - let is_inhibitory = world.get::<&Inhibitory>(entity).is_ok(); - let color = if is_inhibitory { - value * mantle() + (1.0 - value) * red() - } else { - value * base() + (1.0 - value) * blue() - }; - Sphere { - position: position.position, - color, - radius: NODE_RADIUS, - _padding: Default::default(), - } - }) - .collect(); - - let current_clamp_spheres: Vec = world - .query::<&Position>() - .with::<&CurrentClamp>() - .iter() - .map(|(_, position)| Sphere { + .map(|(entity, (neuron, dynamics, position))| Sphere { position: position.position, - color: yellow(), + color: soma_color(world, entity, neuron, dynamics, palette), radius: NODE_RADIUS, _padding: Default::default(), }) .collect(); + // Touch sensors and current sources are flat pads, see `collect_shapes` let generator_spheres: Vec = world .query::<(&Position, &GeneratorDynamics)>() + .without::<&TouchSensor>() .iter() - .map(|(_, (position, _))| Sphere { + .map(|(_, (position, dynamics))| Sphere { position: position.position, - color: orange(), + color: generator_color(dynamics, palette), radius: NODE_RADIUS, _padding: Default::default(), }) @@ -56,30 +45,60 @@ pub fn collect_spheres(world: &hecs::World) -> Vec { let compartment_spheres: Vec = world .query::<(&Compartment, &Position, &NeuronType)>() .iter() - .map(|(_, (compartment, position, neuron_type))| { + .map(|(entity, (compartment, position, neuron_type))| { let value = ((compartment.voltage + 50.0) / 200.0) as f32; Sphere { position: position.position, - color: neurocolor(neuron_type, value), - radius: COMPARTMENT_SPHERE_SCALE * NODE_RADIUS, + color: palette.neurocolor(neuron_type, value), + radius: radii + .get(&entity) + .copied() + .unwrap_or(COMPARTMENT_SPHERE_SCALE * NODE_RADIUS), _padding: Default::default(), } }) .collect(); + let joint_spheres: Vec = paths + .values() + .flat_map(|path| { + let segments = path.points.len().saturating_sub(1); + let joints = path + .points + .iter() + .enumerate() + .skip(1) + .take(segments.saturating_sub(1)); + joints.map(move |(i, &position)| Sphere { + position, + color: path + .start_color + .lerp(path.end_color, i as f32 / segments as f32), + radius: path.radius, + _padding: Default::default(), + }) + }) + .collect(); + + // Inhibitory synapses end in a bar instead, see `collect_cylinders` + let synapse_spheres: Vec = paths + .values() + .filter(|path| path.directional && !path.inhibitory) + .filter_map(|path| { + Some(Sphere { + position: *path.points.last()?, + color: terminal_color(), + radius: 0.5 * NODE_RADIUS, + _padding: Default::default(), + }) + }) + .collect(); + let trigger_spheres: Vec = world - .query::<(&CurrentSynapse, &Connection)>() + .query::<&CurrentSynapse>() .iter() - .flat_map(|(_, (synapse, connection))| { - let start = world - .get::<&Position>(connection.from) - .map(|p| p.position) - .unwrap_or(Vec3::ZERO); - let end = world - .get::<&Position>(connection.to) - .map(|p| p.position) - .unwrap_or(Vec3::ZERO); - let diff = end - start; + .filter_map(|(entity, synapse)| Some((paths.get(&entity)?, synapse))) + .flat_map(|(path, synapse)| { synapse .triggers .iter() @@ -91,8 +110,8 @@ pub fn collect_spheres(world: &hecs::World) -> Vec { 1.0 }; Sphere { - position: start + diff * progress, - color: crust(), + position: path.point_at(progress), + color: palette.spike, radius: NODE_RADIUS * TRIGGER_SPHERE_SCALE, _padding: Default::default(), } @@ -102,9 +121,10 @@ pub fn collect_spheres(world: &hecs::World) -> Vec { .collect(); spheres.extend(lif_neuron_spheres.iter()); - spheres.extend(current_clamp_spheres.iter()); spheres.extend(generator_spheres.iter()); spheres.extend(compartment_spheres.iter()); + spheres.extend(joint_spheres.iter()); + spheres.extend(synapse_spheres.iter()); spheres.extend(trigger_spheres.iter()); spheres diff --git a/neuronify-core/src/serialization/fixtures/v3.neuronify b/neuronify-core/src/serialization/fixtures/v3.neuronify new file mode 100644 index 00000000..dc5d0de4 Binary files /dev/null and b/neuronify-core/src/serialization/fixtures/v3.neuronify differ diff --git a/neuronify-core/src/serialization/mod.rs b/neuronify-core/src/serialization/mod.rs index 5fa393ee..db9314c5 100644 --- a/neuronify-core/src/serialization/mod.rs +++ b/neuronify-core/src/serialization/mod.rs @@ -20,9 +20,10 @@ use postcard::ser_flavors::Flavor; mod schema; mod v1; mod v2; +mod v3; /// The version that `save` writes. -pub const VERSION: u32 = 2; +pub const VERSION: u32 = 3; const MAGIC: &[u8] = b"neuronify"; @@ -30,7 +31,7 @@ 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) + serialize(world, &mut v3::SaveContext, &mut serializer) .map_err(|e| format!("Could not save: {e}"))?; let body = serializer .output @@ -53,6 +54,7 @@ pub fn load(bytes: &[u8]) -> Result { let mut world = match version { 1 => v1::deserialize(body)?, 2 => v2::deserialize(body)?, + 3 => v3::deserialize(body)?, _ => { return Err(format!( "The save file has version {version}, \ @@ -63,6 +65,7 @@ pub fn load(bytes: &[u8]) -> Result { for from in version..VERSION { match from { 1 => v2::upgrade_from_v1(&mut world), + 2 => v3::upgrade_from_v2(&mut world), _ => unreachable!("no upgrade from version {from}"), } } @@ -138,6 +141,32 @@ mod tests { assert_fixture(&load(include_bytes!("fixtures/v2.neuronify")).unwrap(), 29); } + #[test] + fn loads_version_3() { + assert_fixture(&load(include_bytes!("fixtures/v3.neuronify")).unwrap(), 29); + } + + #[test] + fn keeps_dendrites() { + let mut world = World::new(); + let soma = world.spawn(( + Position { + position: glam::Vec3::ZERO, + }, + LeakyNeuron::default(), + LeakyDynamics::default(), + LeakCurrent::default(), + NeuronType::Excitatory, + Deletable {}, + )); + crate::dendrites::spawn_dendrites(&mut world, soma); + let dendrites = count::(&world); + assert!(dendrites > 0); + + let loaded = load(&save(&world).unwrap()).unwrap(); + assert_eq!(count::(&loaded), dendrites); + } + /// 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` diff --git a/neuronify-core/src/serialization/v2.rs b/neuronify-core/src/serialization/v2.rs index a71eff1a..40dd8b0a 100644 --- a/neuronify-core/src/serialization/v2.rs +++ b/neuronify-core/src/serialization/v2.rs @@ -5,7 +5,6 @@ use crate::measurement::voltmeter::{VoltageSeries, Voltmeter}; use hecs::World; schema!( - save, Position, LeakyNeuron, LeakyDynamics, diff --git a/neuronify-core/src/serialization/v3.rs b/neuronify-core/src/serialization/v3.rs new file mode 100644 index 00000000..8b00d40d --- /dev/null +++ b/neuronify-core/src/serialization/v3.rs @@ -0,0 +1,41 @@ +//! Dendrites are marked, so they keep their look after loading, +//! and burst neurons from Neuronify 1 keep their burst current. + +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, + Dendrite, + BurstCurrent, +); + +/// Version 3 only adds components, so a version 2 world is already a version 3 world. +pub fn upgrade_from_v2(_world: &mut World) {} diff --git a/neuronify-core/src/simulation/lif.rs b/neuronify-core/src/simulation/lif.rs index c1e87eee..32e9057d 100644 --- a/neuronify-core/src/simulation/lif.rs +++ b/neuronify-core/src/simulation/lif.rs @@ -21,6 +21,13 @@ pub fn lif_step(world: &mut World, dt: f64, time: f64) { } } + for (_, dynamics) in world + .query_mut::<&mut GeneratorDynamics>() + .with::<&TouchSensor>() + { + dynamics.time_since_fire += dt; + } + { let mut rng = rand::thread_rng(); let poisson_entities: Vec = world @@ -81,19 +88,27 @@ pub fn lif_step(world: &mut World, dt: f64, time: f64) { for (_, (adapt, neuron, dynamics)) in world.query_mut::<(&mut AdaptationCurrent, &LeakyNeuron, &LeakyDynamics)>() { - if !dynamics.enabled { - adapt.current = 0.0; - continue; - } adapt.conductance -= adapt.conductance / adapt.time_constant * dt; if dynamics.fired { adapt.conductance += adapt.adaptation; } + if !dynamics.enabled { + adapt.current = 0.0; + continue; + } let v = dynamics.voltage; let em = neuron.resting_potential; adapt.current = -adapt.conductance * (v - em); } + for (_, (burst, dynamics)) in world.query_mut::<(&mut BurstCurrent, &mut LeakyDynamics)>() { + if dynamics.fired && burst.boost < 1e-12 { + burst.boost = burst.peak; + } + burst.boost = (burst.boost - burst.drain * dt).max(0.0); + dynamics.received_currents += burst.boost; + } + { let leak_currents: Vec<(hecs::Entity, f64)> = world .query::<(&LeakCurrent, &LeakyDynamics)>() @@ -319,3 +334,53 @@ pub fn run_headless(world: &mut World, steps: usize, dt: f64) -> Vec(neuron).unwrap().voltage = 0.0; + + let spikes = run_headless(&mut world, 5000, 0.0001); + + assert!(spikes.len() > 3); + assert!(spikes.last().unwrap().time < 0.25); + assert_eq!(world.get::<&BurstCurrent>(neuron).unwrap().boost, 0.0); + } + + #[test] + fn adaptation_builds_up_and_slows_firing() { + let mut world = World::new(); + let neuron = world.spawn(( + LeakyNeuron::default(), + LeakyDynamics::default(), + LeakCurrent::default(), + AdaptationCurrent::default(), + )); + let clamp = world.spawn((CurrentClamp { + current_output: 2e-9, + },)); + world.spawn((Connection { + from: clamp, + to: neuron, + strength: 1.0, + directional: true, + },)); + + let spikes = run_headless(&mut world, 5000, 0.0001); + + assert!(world.get::<&AdaptationCurrent>(neuron).unwrap().conductance > 0.0); + let intervals: Vec = spikes.windows(2).map(|w| w[1].time - w[0].time).collect(); + assert!(intervals.len() > 2); + assert!(intervals.last().unwrap() > intervals.first().unwrap()); + } +} diff --git a/neuronify-core/src/simulation/mod.rs b/neuronify-core/src/simulation/mod.rs index 846b28c2..05c15f51 100644 --- a/neuronify-core/src/simulation/mod.rs +++ b/neuronify-core/src/simulation/mod.rs @@ -6,7 +6,7 @@ pub mod stimulation; pub use fhn::fhn_step; pub use lif::{lif_step, run_headless, SpikeRecord}; pub use spatial::{apply_spatial_forces, integrate_motion}; -pub use stimulation::stimulate_nearby; +pub use stimulation::{stimulate_nearby, touch_sensor_near}; use std::collections::HashSet; diff --git a/neuronify-core/src/simulation/stimulation.rs b/neuronify-core/src/simulation/stimulation.rs index 108929d5..04c14e79 100644 --- a/neuronify-core/src/simulation/stimulation.rs +++ b/neuronify-core/src/simulation/stimulation.rs @@ -1,7 +1,17 @@ +use glam::Vec3; + use crate::components::*; use crate::constants::*; use crate::StimulationTool; +pub fn touch_sensor_near(world: &hecs::World, position: Vec3) -> bool { + world + .query::<&Position>() + .with::<&TouchSensor>() + .iter() + .any(|(_, sensor)| sensor.position.distance(position) < ERASE_RADIUS) +} + pub fn stimulate_nearby(world: &mut hecs::World, stimulation_tool: &Option) { let Some(stim) = stimulation_tool else { return; @@ -33,3 +43,29 @@ pub fn stimulate_nearby(world: &mut hecs::World, stimulation_tool: &Option vec![ (Tool::ExcitatoryNeuron, "Excitatory Neuron"), (Tool::InhibitoryNeuron, "Inhibitory Neuron"), + (Tool::AdaptiveNeuron, "Adaptive Neuron"), + (Tool::BurstNeuron, "Burst Neuron"), (Tool::CurrentSource, "Current Source"), (Tool::TouchSensor, "Touch Sensor"), (Tool::RegularSpikeGenerator, "Spike Generator"),