From 51f9ec68d80cfa65eee7ae3015b6495886b4aa0e Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Wed, 21 Oct 2026 16:45:43 +0200 Subject: [PATCH 01/10] Draw compartments as tapered tubes with shadows, SSAO and bloom --- neuronify-core/src/app.rs | 69 ++++++++++++++--- neuronify-core/src/constants.rs | 3 + neuronify-core/src/landing.rs | 39 +++++++++- neuronify-core/src/rendering/bounds.rs | 19 +++-- neuronify-core/src/rendering/colors.rs | 11 ++- neuronify-core/src/rendering/connections.rs | 2 + neuronify-core/src/rendering/cylinders.rs | 85 +++++++++++++++++++++ neuronify-core/src/rendering/floor.rs | 39 ++++++++++ neuronify-core/src/rendering/gpu_types.rs | 11 +++ neuronify-core/src/rendering/lighting.rs | 56 ++++++++++++++ neuronify-core/src/rendering/mod.rs | 9 ++- neuronify-core/src/rendering/scene.rs | 17 +++++ neuronify-core/src/rendering/spheres.rs | 27 +++++-- 13 files changed, 361 insertions(+), 26 deletions(-) create mode 100644 neuronify-core/src/rendering/cylinders.rs create mode 100644 neuronify-core/src/rendering/floor.rs create mode 100644 neuronify-core/src/rendering/lighting.rs create mode 100644 neuronify-core/src/rendering/scene.rs diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index f5a0fa35..4ba2786c 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -3,9 +3,10 @@ use crate::constants::*; use crate::examples::Example; use crate::landing::{LandingChoice, LandingScreen}; use crate::measurement::voltmeter::{RollingWindow, VoltageSeries, Voltmeter}; +use crate::rendering::lighting::{configure_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, create_floor, + ConnectionData, CylinderData, NoteRenderer, Sphere, }; use crate::tools::*; use chrono::{DateTime, Duration, Utc}; @@ -20,8 +21,9 @@ 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, }; use crate::input::{Keyboard, Mouse, TouchGesture, Touches}; @@ -41,6 +43,9 @@ pub struct Neuronify { pub connection_lines: Lines, pub connection_spheres: Spheres, pub connection_buffer: InstanceBuffer, + pub cylinders: Cylinders, + pub cylinder_buffer: InstanceBuffer, + pub floor: MeshPipeline, pub iterations: u32, pub last_update: DateTime, pub fps: f64, @@ -179,7 +184,9 @@ impl Neuronify { radius: sphere.radius, color: sphere.color, }, - &SphereMaterial::default(), + &SphereMaterial { + color: shaded(Expression::InputColor), + }, ) .unwrap(); @@ -197,7 +204,9 @@ impl Neuronify { width: connection.strength.clone() * 0.3, color: connection.start_color.clone(), }, - &LineMaterial::default(), + &LineMaterial { + color: shaded(Expression::InputColor), + }, ) .unwrap(); @@ -208,10 +217,33 @@ 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 floor = create_floor(&application.rendering_descriptor(), &application.device).unwrap(); + + configure_lighting(application); + let mut world = hecs::World::new(); #[allow(unused_mut)] let mut opened_file_from_command_line = false; @@ -246,6 +278,9 @@ impl Neuronify { connection_lines, connection_spheres, connection_buffer, + cylinders, + cylinder_buffer, + floor, tool: Tool::Select, previous_creation: None, connection_tool: None, @@ -1094,13 +1129,22 @@ 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 scene = collect_scene(world); let mut connections = collect_connections(world, &self.tool, &self.connection_tool); 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.connection_buffer .update(&application.device, &application.queue, &connections); @@ -1123,12 +1167,19 @@ 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.connection_lines.render(data); self.connection_spheres.render(data); self.note_renderer.render(data); } + fn render_shadow(&mut self, data: &mut ShadowRenderData) { + self.spheres.render_shadow(data); + self.cylinders.render_shadow(data); + } + fn gui(&mut self, _application: &visula::Application, context: &egui::Context) { self.sync_pointer_with_gui(context); if let Some(landing) = &mut self.landing { diff --git a/neuronify-core/src/constants.rs b/neuronify-core/src/constants.rs index aa4f0bda..d722fb12 100644 --- a/neuronify-core/src/constants.rs +++ b/neuronify-core/src/constants.rs @@ -48,3 +48,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/landing.rs b/neuronify-core/src/landing.rs index 02b3c1d1..d6c10b6e 100644 --- a/neuronify-core/src/landing.rs +++ b/neuronify-core/src/landing.rs @@ -14,8 +14,8 @@ 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, }; use crate::simulation; use crate::tools::Tool; @@ -467,7 +467,12 @@ fn top_down_shapes(area: Rect, world: &World, bounds: &Bounds) -> Vec { .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); + let mut shapes: Vec = scene + .cylinders + .iter() + .map(|cylinder| cylinder_from_above(cylinder, scale, to_screen)) + .collect(); for connection in collect_connections(world, &Tool::Select, &None) { let start = connection.position_a; @@ -499,7 +504,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 +514,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, diff --git a/neuronify-core/src/rendering/bounds.rs b/neuronify-core/src/rendering/bounds.rs index 52d8b173..660b81a6 100644 --- a/neuronify-core/src/rendering/bounds.rs +++ b/neuronify-core/src/rendering/bounds.rs @@ -2,7 +2,7 @@ use glam::Vec3; use crate::components::*; 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,10 +40,13 @@ 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 scene = collect_scene(world); + for sphere in &scene.spheres { + points.extend(footprint(sphere.position, sphere.radius)); + } + for cylinder in &scene.cylinders { + points.extend(footprint(cylinder.start, cylinder.start_radius)); + points.extend(footprint(cylinder.end, cylinder.end_radius)); } for connection in collect_connections(world, &Tool::Select, &None) { points.push(connection.position_a); @@ -67,6 +70,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] diff --git a/neuronify-core/src/rendering/colors.rs b/neuronify-core/src/rendering/colors.rs index 93685486..9a86833f 100644 --- a/neuronify-core/src/rendering/colors.rs +++ b/neuronify-core/src/rendering/colors.rs @@ -54,10 +54,17 @@ pub fn terminal_color() -> Vec3 { Vec3::new(136.0 / 255.0, 57.0 / 255.0, 239.0 / 255.0) } +pub fn floor_color() -> Vec3 { + srgb(56, 58, 80) +} + +/// How far a neuron at threshold has gone from the colour of its type towards white. +pub const VOLTAGE_TINT: f32 = 0.55; + 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(), + NeuronType::Excitatory => blue().lerp(blue().lerp(base(), VOLTAGE_TINT), v), + NeuronType::Inhibitory => red().lerp(red().lerp(mantle(), VOLTAGE_TINT), v), } } diff --git a/neuronify-core/src/rendering/connections.rs b/neuronify-core/src/rendering/connections.rs index 338946fb..bcec8385 100644 --- a/neuronify-core/src/rendering/connections.rs +++ b/neuronify-core/src/rendering/connections.rs @@ -21,9 +21,11 @@ pub fn collect_connections( tool: &Tool, connection_tool: &Option, ) -> Vec { + // Cable segments between compartments are drawn as cylinders instead let connection_info: Vec<(Entity, Entity, Entity, f32, bool)> = world .query::<&Connection>() .iter() + .filter(|(e, c)| c.directional || world.get::<&CompartmentCurrent>(*e).is_err()) .map(|(e, c)| { let is_voltmeter = world.get::<&Voltmeter>(e).is_ok(); ( diff --git a/neuronify-core/src/rendering/cylinders.rs b/neuronify-core/src/rendering/cylinders.rs new file mode 100644 index 00000000..5b86375d --- /dev/null +++ b/neuronify-core/src/rendering/cylinders.rs @@ -0,0 +1,85 @@ +use std::collections::HashMap; + +use glam::Vec3; +use hecs::Entity; + +use crate::components::*; +use crate::rendering::colors::*; +use crate::rendering::gpu_types::CylinderData; + +/// 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, and from a soma +/// to its first compartment. +/// +/// Directional links are axon terminals and stay lines with a synapse marker. +pub fn collect_cylinders(world: &hecs::World, radii: &HashMap) -> Vec { + let radius = |entity: Entity| radii.get(&entity).copied().unwrap_or(ROOT_RADIUS); + 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)) => neurocolor( + &neuron_type, + ((compartment.voltage + 50.0) / 200.0).clamp(0.0, 1.0) as f32, + ), + _ => crust(), + }; + Some(CylinderData { + start, + start_radius: radius(connection.from), + end, + end_radius: radius(connection.to), + color, + _padding: Default::default(), + }) + }) + .collect() +} diff --git a/neuronify-core/src/rendering/floor.rs b/neuronify-core/src/rendering/floor.rs new file mode 100644 index 00000000..5fa90629 --- /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::*; +use crate::rendering::colors::floor_color; + +/// A plane under the network that catches the shadows. +pub fn create_floor( + rendering_descriptor: &RenderingDescriptor, + device: &wgpu::Device, +) -> 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(floor_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/gpu_types.rs b/neuronify-core/src/rendering/gpu_types.rs index a225c535..34c2a028 100644 --- a/neuronify-core/src/rendering/gpu_types.rs +++ b/neuronify-core/src/rendering/gpu_types.rs @@ -23,6 +23,17 @@ 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 LineData { diff --git a/neuronify-core/src/rendering/lighting.rs b/neuronify-core/src/rendering/lighting.rs new file mode 100644 index 00000000..9f338068 --- /dev/null +++ b/neuronify-core/src/rendering/lighting.rs @@ -0,0 +1,56 @@ +//! 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, Tonemapping}; +use visula::Expression; + +/// Share of each colour that comes from lighting. The rest is the flat colour. +const SHADING: f32 = 0.5; +/// 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 +/// almost half of it while lit surfaces stay at their own colour. +const LIGHT_INTENSITY: f32 = 0.25; +const LIGHT_BRIGHTNESS: f32 = 2.3; + +/// 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) { + application.light.direction = Vec3::new(-0.3, -1.0, -0.2).normalize(); + application.light.intensity = LIGHT_INTENSITY; + application.light.color = Vec3::splat(LIGHT_BRIGHTNESS); + application.light.shadow_distance = 300.0; + // Colours stay as they are, since they show the voltage + application.post_processor.config.tonemapping = Tonemapping::None; + // 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.config.ssao = Some(visula::post_process::config::SsaoConfig { + radius: 2.0, + bias: 0.02, + intensity: 0.75, + }); + application.post_processor.enable_ssao( + &application.device, + &application.queue, + application.config.width, + application.config.height, + &application.camera, + &application.depth_texture, + ); + } +} diff --git a/neuronify-core/src/rendering/mod.rs b/neuronify-core/src/rendering/mod.rs index 3fbf6fd1..cf0ae3b7 100644 --- a/neuronify-core/src/rendering/mod.rs +++ b/neuronify-core/src/rendering/mod.rs @@ -1,15 +1,22 @@ pub mod bounds; pub mod colors; pub mod connections; +pub mod cylinders; +pub mod floor; pub mod gpu_types; +pub mod lighting; pub mod notes; +pub mod scene; pub mod spheres; pub mod voltmeter; pub use bounds::{canvas_bounds, circuit_bounds, Bounds}; pub use colors::{neurocolor, srgb, srgb_component}; 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 gpu_types::{ConnectionData, CylinderData, LineData, MeshInstanceData, Sphere}; pub use notes::NoteRenderer; +pub use scene::{collect_scene, Scene}; pub use spheres::collect_spheres; pub use voltmeter::collect_voltmeter_traces; diff --git a/neuronify-core/src/rendering/scene.rs b/neuronify-core/src/rendering/scene.rs new file mode 100644 index 00000000..6c069f64 --- /dev/null +++ b/neuronify-core/src/rendering/scene.rs @@ -0,0 +1,17 @@ +use crate::rendering::cylinders::{collect_cylinders, compartment_radii}; +use crate::rendering::gpu_types::{CylinderData, Sphere}; +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 fn collect_scene(world: &hecs::World) -> Scene { + let radii = compartment_radii(world); + Scene { + spheres: collect_spheres(world, &radii), + cylinders: collect_cylinders(world, &radii), + } +} diff --git a/neuronify-core/src/rendering/spheres.rs b/neuronify-core/src/rendering/spheres.rs index 272185bf..4f5eeb77 100644 --- a/neuronify-core/src/rendering/spheres.rs +++ b/neuronify-core/src/rendering/spheres.rs @@ -1,11 +1,20 @@ +use std::collections::HashMap; + use glam::Vec3; +use hecs::Entity; use crate::components::*; use crate::constants::*; use crate::rendering::colors::*; use crate::rendering::gpu_types::Sphere; -pub fn collect_spheres(world: &hecs::World) -> Vec { +/// How long a soma keeps glowing after it fires, in simulated seconds. +const FLASH_DURATION: f64 = 0.01; +/// How much brighter than white a flash starts, so that only flashes bloom. +const FLASH_BRIGHTNESS: f32 = 3.0; + +/// `radii` comes from `compartment_radii`, so the joints match the cylinders. +pub fn collect_spheres(world: &hecs::World, radii: &HashMap) -> Vec { let mut spheres = Vec::new(); let lif_neuron_spheres: Vec = world @@ -17,10 +26,15 @@ pub fn collect_spheres(world: &hecs::World) -> Vec { .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() + red().lerp(red().lerp(mantle(), VOLTAGE_TINT), value) } else { - value * base() + (1.0 - value) * blue() + blue().lerp(blue().lerp(base(), VOLTAGE_TINT), value) }; + let remaining = (1.0 - dynamics.time_since_fire / FLASH_DURATION).max(0.0) as f32; + let color = color.lerp( + Vec3::new(1.0, 1.0, 0.55) * FLASH_BRIGHTNESS, + remaining * remaining, + ); Sphere { position: position.position, color, @@ -56,12 +70,15 @@ 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, + radius: radii + .get(&entity) + .copied() + .unwrap_or(COMPARTMENT_SPHERE_SCALE * NODE_RADIUS), _padding: Default::default(), } }) From 5a304409e7e864ab42940abe67039fd2fe8d5cd3 Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Fri, 23 Oct 2026 22:12:51 +0200 Subject: [PATCH 02/10] Draw connections as bent tubes --- neuronify-core/src/app.rs | 34 +-- neuronify-core/src/constants.rs | 1 - neuronify-core/src/landing.rs | 23 +- neuronify-core/src/rendering/connections.rs | 124 ++--------- neuronify-core/src/rendering/cylinders.rs | 35 ++- neuronify-core/src/rendering/mod.rs | 2 + neuronify-core/src/rendering/paths.rs | 230 ++++++++++++++++++++ neuronify-core/src/rendering/scene.rs | 6 +- neuronify-core/src/rendering/spheres.rs | 61 ++++-- 9 files changed, 349 insertions(+), 167 deletions(-) create mode 100644 neuronify-core/src/rendering/paths.rs diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index 4ba2786c..b185fdc7 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -5,8 +5,8 @@ use crate::landing::{LandingChoice, LandingScreen}; use crate::measurement::voltmeter::{RollingWindow, VoltageSeries, Voltmeter}; use crate::rendering::lighting::{configure_lighting, shaded}; use crate::rendering::{ - canvas_bounds, collect_connections, collect_scene, collect_voltmeter_traces, create_floor, - ConnectionData, CylinderData, NoteRenderer, Sphere, + canvas_bounds, collect_connections, collect_scene, collect_voltmeter_traces, compartment_radii, + connection_paths, create_floor, ConnectionData, CylinderData, NoteRenderer, Sphere, }; use crate::tools::*; use chrono::{DateTime, Duration, Utc}; @@ -600,6 +600,8 @@ impl Neuronify { 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)); let connections_to_delete = world .query::<&Connection>() .with::<&Deletable>() @@ -609,20 +611,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) } diff --git a/neuronify-core/src/constants.rs b/neuronify-core/src/constants.rs index d722fb12..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; diff --git a/neuronify-core/src/landing.rs b/neuronify-core/src/landing.rs index d6c10b6e..51753679 100644 --- a/neuronify-core/src/landing.rs +++ b/neuronify-core/src/landing.rs @@ -12,7 +12,6 @@ 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_scene, collect_voltmeter_traces, Bounds, ConnectionData, CylinderData, @@ -474,28 +473,14 @@ fn top_down_shapes(area: Rect, world: &World, bounds: &Bounds) -> Vec { .map(|cylinder| cylinder_from_above(cylinder, scale, to_screen)) .collect(); - 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) { 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( diff --git a/neuronify-core/src/rendering/connections.rs b/neuronify-core/src/rendering/connections.rs index bcec8385..cd9b3c1a 100644 --- a/neuronify-core/src/rendering/connections.rs +++ b/neuronify-core/src/rendering/connections.rs @@ -1,131 +1,39 @@ -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::gpu_types::ConnectionData; +use crate::rendering::paths::connection_colors; 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, ) -> Vec { - // Cable segments between compartments are drawn as cylinders instead - let connection_info: Vec<(Entity, Entity, Entity, f32, bool)> = world + let mut connections: Vec = world .query::<&Connection>() + .with::<&Voltmeter>() .iter() - .filter(|(e, c)| c.directional || world.get::<&CompartmentCurrent>(*e).is_err()) - .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; + let (start_color, end_color) = connection_colors(world, connection.from, connection.to); + Some(ConnectionData { position_a: start, position_b: end, - strength, - directional: dir_val, + strength: connection.strength as f32, + directional: 0.0, start_color, end_color, _padding: Default::default(), - }); - } - } + }) + }) + .collect(); if *tool == Tool::StaticConnection { if let Some(connection) = connection_tool { diff --git a/neuronify-core/src/rendering/cylinders.rs b/neuronify-core/src/rendering/cylinders.rs index 5b86375d..1a283166 100644 --- a/neuronify-core/src/rendering/cylinders.rs +++ b/neuronify-core/src/rendering/cylinders.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use glam::Vec3; use hecs::Entity; @@ -6,6 +6,7 @@ use hecs::Entity; use crate::components::*; use crate::rendering::colors::*; use crate::rendering::gpu_types::CylinderData; +use crate::rendering::paths::ConnectionPath; /// Radius where a process leaves the soma. const ROOT_RADIUS: f32 = 0.30; @@ -43,13 +44,15 @@ pub fn compartment_radii(world: &hecs::World) -> HashMap { .collect() } -/// Tapered tubes for the cable segments between compartments, and from a soma -/// to its first compartment. -/// -/// Directional links are axon terminals and stay lines with a synapse marker. -pub fn collect_cylinders(world: &hecs::World, radii: &HashMap) -> Vec { +/// 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, +) -> Vec { let radius = |entity: Entity| radii.get(&entity).copied().unwrap_or(ROOT_RADIUS); - world + let mut cylinders: Vec = world .query::<&Connection>() .with::<&CompartmentCurrent>() .iter() @@ -81,5 +84,21 @@ pub fn collect_cylinders(world: &hecs::World, radii: &HashMap) -> V _padding: Default::default(), }) }) - .collect() + .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(), + }); + } + } + cylinders } diff --git a/neuronify-core/src/rendering/mod.rs b/neuronify-core/src/rendering/mod.rs index cf0ae3b7..46365b87 100644 --- a/neuronify-core/src/rendering/mod.rs +++ b/neuronify-core/src/rendering/mod.rs @@ -6,6 +6,7 @@ pub mod floor; pub mod gpu_types; pub mod lighting; pub mod notes; +pub mod paths; pub mod scene; pub mod spheres; pub mod voltmeter; @@ -17,6 +18,7 @@ pub use cylinders::{collect_cylinders, compartment_radii}; pub use floor::create_floor; pub use gpu_types::{ConnectionData, CylinderData, LineData, MeshInstanceData, Sphere}; 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..79d43c73 --- /dev/null +++ b/neuronify-core/src/rendering/paths.rs @@ -0,0 +1,230 @@ +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::*; + +/// 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, +} + +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()) +} + +pub(crate) fn connection_colors(world: &hecs::World, from: Entity, to: Entity) -> (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) { + ( + neurocolor(&neuron_type, start_value), + neurocolor(&neuron_type, end_value), + ) + } else { + (crust(), crust()) + } +} + +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, +) -> 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); + Some(( + entity, + ConnectionPath { + points: bent_line(start, end, bend, trim), + radius, + start_color, + end_color, + directional: connection.directional, + }, + )) + }) + .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, + } + } + + #[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 index 6c069f64..9350ce80 100644 --- a/neuronify-core/src/rendering/scene.rs +++ b/neuronify-core/src/rendering/scene.rs @@ -1,5 +1,6 @@ use crate::rendering::cylinders::{collect_cylinders, compartment_radii}; use crate::rendering::gpu_types::{CylinderData, Sphere}; +use crate::rendering::paths::connection_paths; use crate::rendering::spheres::collect_spheres; /// The solid shapes of the 3D view, which the example previews also draw from above. @@ -10,8 +11,9 @@ pub struct Scene { pub fn collect_scene(world: &hecs::World) -> Scene { let radii = compartment_radii(world); + let paths = connection_paths(world, &radii); Scene { - spheres: collect_spheres(world, &radii), - cylinders: collect_cylinders(world, &radii), + spheres: collect_spheres(world, &radii, &paths), + cylinders: collect_cylinders(world, &radii, &paths), } } diff --git a/neuronify-core/src/rendering/spheres.rs b/neuronify-core/src/rendering/spheres.rs index 4f5eeb77..285719b3 100644 --- a/neuronify-core/src/rendering/spheres.rs +++ b/neuronify-core/src/rendering/spheres.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use glam::Vec3; use hecs::Entity; @@ -7,6 +7,7 @@ use crate::components::*; use crate::constants::*; use crate::rendering::colors::*; use crate::rendering::gpu_types::Sphere; +use crate::rendering::paths::ConnectionPath; /// How long a soma keeps glowing after it fires, in simulated seconds. const FLASH_DURATION: f64 = 0.01; @@ -14,7 +15,11 @@ const FLASH_DURATION: f64 = 0.01; const FLASH_BRIGHTNESS: f32 = 3.0; /// `radii` comes from `compartment_radii`, so the joints match the cylinders. -pub fn collect_spheres(world: &hecs::World, radii: &HashMap) -> Vec { +pub fn collect_spheres( + world: &hecs::World, + radii: &HashMap, + paths: &BTreeMap, +) -> Vec { let mut spheres = Vec::new(); let lif_neuron_spheres: Vec = world @@ -84,19 +89,45 @@ pub fn collect_spheres(world: &hecs::World, radii: &HashMap) -> Vec }) .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(); + + let synapse_spheres: Vec = paths + .values() + .filter(|path| path.directional) + .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() @@ -108,7 +139,7 @@ pub fn collect_spheres(world: &hecs::World, radii: &HashMap) -> Vec 1.0 }; Sphere { - position: start + diff * progress, + position: path.point_at(progress), color: crust(), radius: NODE_RADIUS * TRIGGER_SPHERE_SCALE, _padding: Default::default(), @@ -122,6 +153,8 @@ pub fn collect_spheres(world: &hecs::World, radii: &HashMap) -> Vec 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 From 0639b4dfc111d5398d7c63bdec1d1122753230fa Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Sat, 24 Oct 2026 10:49:42 +0200 Subject: [PATCH 03/10] Add a light theme --- neuronify-core/src/app.rs | 53 +++++- neuronify-core/src/landing.rs | 192 ++++++++++++++++---- neuronify-core/src/rendering/bounds.rs | 6 +- neuronify-core/src/rendering/colors.rs | 76 +++++++- neuronify-core/src/rendering/connections.rs | 14 +- neuronify-core/src/rendering/cylinders.rs | 7 +- neuronify-core/src/rendering/floor.rs | 4 +- neuronify-core/src/rendering/lighting.rs | 55 +++--- neuronify-core/src/rendering/mod.rs | 2 +- neuronify-core/src/rendering/paths.rs | 17 +- neuronify-core/src/rendering/scene.rs | 9 +- neuronify-core/src/rendering/spheres.rs | 19 +- 12 files changed, 337 insertions(+), 117 deletions(-) diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index b185fdc7..99c32134 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -3,10 +3,10 @@ use crate::constants::*; use crate::examples::Example; use crate::landing::{LandingChoice, LandingScreen}; use crate::measurement::voltmeter::{RollingWindow, VoltageSeries, Voltmeter}; -use crate::rendering::lighting::{configure_lighting, shaded}; +use crate::rendering::lighting::{configure_lighting, set_theme_lighting, shaded}; use crate::rendering::{ canvas_bounds, collect_connections, collect_scene, collect_voltmeter_traces, compartment_radii, - connection_paths, create_floor, ConnectionData, CylinderData, NoteRenderer, Sphere, + connection_paths, create_floor, ConnectionData, CylinderData, NoteRenderer, Sphere, Theme, }; use crate::tools::*; use chrono::{DateTime, Duration, Utc}; @@ -31,6 +31,9 @@ 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 previous_creation: Option, pub connection_tool: Option, pub stimulation_tool: Option, @@ -240,9 +243,18 @@ impl Neuronify { ) .unwrap(); - let floor = create_floor(&application.rendering_descriptor(), &application.device).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); + configure_lighting(application, theme); let mut world = hecs::World::new(); #[allow(unused_mut)] @@ -282,6 +294,8 @@ impl Neuronify { cylinder_buffer, floor, tool: Tool::Select, + theme, + applied_theme: theme, previous_creation: None, connection_tool: None, stimulation_tool: None, @@ -351,6 +365,7 @@ impl Neuronify { self.release_tools(); return; } + let palette = self.theme.palette(); let Neuronify { tool, mouse, @@ -601,7 +616,7 @@ impl Neuronify { world.despawn(entity).unwrap(); } // Erase what is drawn, which for most connections is a bent path - let paths = connection_paths(world, &compartment_radii(world)); + let paths = connection_paths(world, &compartment_radii(world), &palette); let connections_to_delete = world .query::<&Connection>() .with::<&Deletable>() @@ -1122,6 +1137,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); @@ -1140,8 +1166,10 @@ impl visula::Simulation for Neuronify { application.light.shadow_extent = (camera.distance * 2.0).clamp(50.0, 625.0); let world = &self.world; - let scene = collect_scene(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 @@ -1185,9 +1213,13 @@ impl visula::Simulation for Neuronify { } 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, @@ -1256,6 +1288,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/landing.rs b/neuronify-core/src/landing.rs index 51753679..79f85692 100644 --- a/neuronify-core/src/landing.rs +++ b/neuronify-core/src/landing.rs @@ -14,17 +14,46 @@ use crate::constants::*; use crate::examples::{Example, EXAMPLE_CATEGORIES}; use crate::rendering::{ circuit_bounds, collect_connections, collect_scene, collect_voltmeter_traces, Bounds, - ConnectionData, CylinderData, + 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; @@ -109,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, @@ -133,6 +169,7 @@ enum Picked { pub struct LandingScreen { previews_by_category: Vec>, can_go_back: bool, + theme: Theme, still_drawing_deadline: Option>, } @@ -145,6 +182,7 @@ impl LandingScreen { LandingScreen { previews_by_category, can_go_back, + theme: Theme::default(), still_drawing_deadline: None, } } @@ -163,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]) @@ -202,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() { @@ -227,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 { @@ -258,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, ), ] @@ -281,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 @@ -312,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 }, @@ -327,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, ), } @@ -352,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); @@ -374,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; }; @@ -391,6 +452,7 @@ fn paint_live_preview(painter: &Painter, thumbnail: Rect, preview: &mut Preview) thumbnail.shrink(THUMBNAIL_PADDING), &preview.world, bounds, + palette, )); } } @@ -399,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 { @@ -408,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, @@ -432,6 +495,7 @@ fn paint_still_preview( ); preview.still_drawing = Some(StillDrawing { size_in_pixels, + theme, texture, unfinished: Some(unfinished), }); @@ -460,20 +524,20 @@ 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 scene = collect_scene(world); + let scene = collect_scene(world, palette); let mut shapes: Vec = scene .cylinders .iter() .map(|cylinder| cylinder_from_above(cylinder, scale, to_screen)) .collect(); - for wire in collect_connections(world, &Tool::Select, &None) { + for wire in collect_connections(world, &Tool::Select, &None, palette) { shapes.push(Shape::line_segment( [to_screen(wire.position_a), to_screen(wire.position_b)], Stroke::new( @@ -670,10 +734,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); } @@ -699,7 +763,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( @@ -708,6 +772,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)), @@ -716,7 +781,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) } @@ -813,7 +878,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, @@ -837,7 +907,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); @@ -873,6 +943,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(); @@ -892,7 +996,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/rendering/bounds.rs b/neuronify-core/src/rendering/bounds.rs index 660b81a6..827be330 100644 --- a/neuronify-core/src/rendering/bounds.rs +++ b/neuronify-core/src/rendering/bounds.rs @@ -1,6 +1,7 @@ use glam::Vec3; use crate::components::*; +use crate::rendering::colors::Theme; use crate::rendering::connections::collect_connections; use crate::rendering::scene::collect_scene; use crate::tools::Tool; @@ -40,7 +41,8 @@ pub fn canvas_bounds(world: &hecs::World) -> Option { fn circuit_points(world: &hecs::World) -> Vec { let mut points = Vec::new(); - let scene = collect_scene(world); + let palette = Theme::default().palette(); + let scene = collect_scene(world, &palette); for sphere in &scene.spheres { points.extend(footprint(sphere.position, sphere.radius)); } @@ -48,7 +50,7 @@ fn circuit_points(world: &hecs::World) -> Vec { points.extend(footprint(cylinder.start, cylinder.start_radius)); points.extend(footprint(cylinder.end, cylinder.end_radius)); } - for connection in collect_connections(world, &Tool::Select, &None) { + for connection in collect_connections(world, &Tool::Select, &None, &palette) { points.push(connection.position_a); points.push(connection.position_b); } diff --git a/neuronify-core/src/rendering/colors.rs b/neuronify-core/src/rendering/colors.rs index 9a86833f..236b6fba 100644 --- a/neuronify-core/src/rendering/colors.rs +++ b/neuronify-core/src/rendering/colors.rs @@ -54,17 +54,75 @@ pub fn terminal_color() -> Vec3 { Vec3::new(136.0 / 255.0, 57.0 / 255.0, 239.0 / 255.0) } -pub fn floor_color() -> Vec3 { - srgb(56, 58, 80) -} - /// How far a neuron at threshold has gone from the colour of its type towards white. pub const VOLTAGE_TINT: f32 = 0.55; -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 => blue().lerp(blue().lerp(base(), VOLTAGE_TINT), v), - NeuronType::Inhibitory => red().lerp(red().lerp(mantle(), VOLTAGE_TINT), v), +#[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 cd9b3c1a..385812e3 100644 --- a/neuronify-core/src/rendering/connections.rs +++ b/neuronify-core/src/rendering/connections.rs @@ -1,9 +1,7 @@ -use glam::Vec3; - use crate::components::*; use crate::measurement::voltmeter::Voltmeter; +use crate::rendering::colors::Palette; use crate::rendering::gpu_types::ConnectionData; -use crate::rendering::paths::connection_colors; use crate::ConnectionTool; use crate::Tool; @@ -14,6 +12,7 @@ pub fn collect_connections( world: &hecs::World, tool: &Tool, connection_tool: &Option, + palette: &Palette, ) -> Vec { let mut connections: Vec = world .query::<&Connection>() @@ -22,14 +21,13 @@ pub fn collect_connections( .filter_map(|(_, connection)| { let start = world.get::<&Position>(connection.from).ok()?.position; let end = world.get::<&Position>(connection.to).ok()?.position; - let (start_color, end_color) = connection_colors(world, connection.from, connection.to); Some(ConnectionData { position_a: start, position_b: end, strength: connection.strength as f32, directional: 0.0, - start_color, - end_color, + start_color: palette.neutral, + end_color: palette.neutral, _padding: Default::default(), }) }) @@ -42,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 index 1a283166..af6d42b4 100644 --- a/neuronify-core/src/rendering/cylinders.rs +++ b/neuronify-core/src/rendering/cylinders.rs @@ -4,7 +4,7 @@ use glam::Vec3; use hecs::Entity; use crate::components::*; -use crate::rendering::colors::*; +use crate::rendering::colors::Palette; use crate::rendering::gpu_types::CylinderData; use crate::rendering::paths::ConnectionPath; @@ -50,6 +50,7 @@ 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 @@ -69,11 +70,11 @@ pub fn collect_cylinders( world.get::<&Compartment>(colored_by), world.get::<&NeuronType>(colored_by), ) { - (Ok(compartment), Ok(neuron_type)) => neurocolor( + (Ok(compartment), Ok(neuron_type)) => palette.neurocolor( &neuron_type, ((compartment.voltage + 50.0) / 200.0).clamp(0.0, 1.0) as f32, ), - _ => crust(), + _ => palette.neutral, }; Some(CylinderData { start, diff --git a/neuronify-core/src/rendering/floor.rs b/neuronify-core/src/rendering/floor.rs index 5fa90629..b5b0ebc4 100644 --- a/neuronify-core/src/rendering/floor.rs +++ b/neuronify-core/src/rendering/floor.rs @@ -3,12 +3,12 @@ use visula::primitives::mesh_primitive::MeshVertexAttributes; use visula::{Expression, MeshGeometry, MeshMaterial, MeshPipeline, RenderingDescriptor}; use crate::constants::*; -use crate::rendering::colors::floor_color; /// 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, @@ -19,7 +19,7 @@ pub fn create_floor( }, // Plain lighting, since `shaded` loses the shadows on meshes in visula aa83a2b &MeshMaterial { - color: Expression::from(floor_color().extend(1.0)).directional_lit(), + color: Expression::from(color.extend(1.0)).directional_lit(), }, )?; let corner = |x: f32, z: f32| MeshVertexAttributes { diff --git a/neuronify-core/src/rendering/lighting.rs b/neuronify-core/src/rendering/lighting.rs index 9f338068..3347ae13 100644 --- a/neuronify-core/src/rendering/lighting.rs +++ b/neuronify-core/src/rendering/lighting.rs @@ -2,29 +2,25 @@ //! which shows the shapes and their shadows. use glam::Vec3; -use visula::post_process::config::{BloomConfig, Tonemapping}; +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; -/// 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 -/// almost half of it while lit surfaces stay at their own colour. -const LIGHT_INTENSITY: f32 = 0.25; -const LIGHT_BRIGHTNESS: f32 = 2.3; /// 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) { +pub fn configure_lighting(application: &mut visula::Application, theme: Theme) { application.light.direction = Vec3::new(-0.3, -1.0, -0.2).normalize(); - application.light.intensity = LIGHT_INTENSITY; - application.light.color = Vec3::splat(LIGHT_BRIGHTNESS); 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, @@ -38,19 +34,30 @@ pub fn configure_lighting(application: &mut visula::Application) { ); // visula's SSAO expects a multisampled depth buffer, which the web build does not have #[cfg(not(target_arch = "wasm32"))] - { - application.post_processor.config.ssao = Some(visula::post_process::config::SsaoConfig { - radius: 2.0, - bias: 0.02, - intensity: 0.75, - }); - application.post_processor.enable_ssao( - &application.device, - &application.queue, - application.config.width, - application.config.height, - &application.camera, - &application.depth_texture, - ); - } + 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 46365b87..fe5d29ee 100644 --- a/neuronify-core/src/rendering/mod.rs +++ b/neuronify-core/src/rendering/mod.rs @@ -12,7 +12,7 @@ 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 cylinders::{collect_cylinders, compartment_radii}; pub use floor::create_floor; diff --git a/neuronify-core/src/rendering/paths.rs b/neuronify-core/src/rendering/paths.rs index 79d43c73..224c537e 100644 --- a/neuronify-core/src/rendering/paths.rs +++ b/neuronify-core/src/rendering/paths.rs @@ -68,7 +68,12 @@ pub(crate) fn is_drawn_as_path( && (connection.directional || world.get::<&CompartmentCurrent>(entity).is_err()) } -pub(crate) fn connection_colors(world: &hecs::World, from: Entity, to: Entity) -> (Vec3, Vec3) { +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 @@ -93,11 +98,11 @@ pub(crate) fn connection_colors(world: &hecs::World, from: Entity, to: Entity) - (orange(), orange()) } else if let Ok(neuron_type) = world.get::<&NeuronType>(from) { ( - neurocolor(&neuron_type, start_value), - neurocolor(&neuron_type, end_value), + palette.neurocolor(&neuron_type, start_value), + palette.neurocolor(&neuron_type, end_value), ) } else { - (crust(), crust()) + (palette.neutral, palette.neutral) } } @@ -136,6 +141,7 @@ fn bent_line(start: Vec3, end: Vec3, bend: f32, trim: f32) -> Vec { pub fn connection_paths( world: &hecs::World, radii: &HashMap, + palette: &Palette, ) -> BTreeMap { let pairs: HashSet<(Entity, Entity)> = world .query::<&Connection>() @@ -168,7 +174,8 @@ pub fn connection_paths( 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); + let (start_color, end_color) = + connection_colors(world, connection.from, connection.to, palette); Some(( entity, ConnectionPath { diff --git a/neuronify-core/src/rendering/scene.rs b/neuronify-core/src/rendering/scene.rs index 9350ce80..e077d055 100644 --- a/neuronify-core/src/rendering/scene.rs +++ b/neuronify-core/src/rendering/scene.rs @@ -1,3 +1,4 @@ +use crate::rendering::colors::Palette; use crate::rendering::cylinders::{collect_cylinders, compartment_radii}; use crate::rendering::gpu_types::{CylinderData, Sphere}; use crate::rendering::paths::connection_paths; @@ -9,11 +10,11 @@ pub struct Scene { pub cylinders: Vec, } -pub fn collect_scene(world: &hecs::World) -> Scene { +pub fn collect_scene(world: &hecs::World, palette: &Palette) -> Scene { let radii = compartment_radii(world); - let paths = connection_paths(world, &radii); + let paths = connection_paths(world, &radii, palette); Scene { - spheres: collect_spheres(world, &radii, &paths), - cylinders: collect_cylinders(world, &radii, &paths), + spheres: collect_spheres(world, &radii, &paths, palette), + cylinders: collect_cylinders(world, &radii, &paths, palette), } } diff --git a/neuronify-core/src/rendering/spheres.rs b/neuronify-core/src/rendering/spheres.rs index 285719b3..bd97cf99 100644 --- a/neuronify-core/src/rendering/spheres.rs +++ b/neuronify-core/src/rendering/spheres.rs @@ -1,6 +1,5 @@ use std::collections::{BTreeMap, HashMap}; -use glam::Vec3; use hecs::Entity; use crate::components::*; @@ -19,6 +18,7 @@ pub fn collect_spheres( world: &hecs::World, radii: &HashMap, paths: &BTreeMap, + palette: &Palette, ) -> Vec { let mut spheres = Vec::new(); @@ -29,17 +29,14 @@ pub fn collect_spheres( 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 { - red().lerp(red().lerp(mantle(), VOLTAGE_TINT), value) + let neuron_type = if world.get::<&Inhibitory>(entity).is_ok() { + NeuronType::Inhibitory } else { - blue().lerp(blue().lerp(base(), VOLTAGE_TINT), value) + NeuronType::Excitatory }; + let color = palette.soma(&neuron_type, value); let remaining = (1.0 - dynamics.time_since_fire / FLASH_DURATION).max(0.0) as f32; - let color = color.lerp( - Vec3::new(1.0, 1.0, 0.55) * FLASH_BRIGHTNESS, - remaining * remaining, - ); + let color = color.lerp(palette.flash * FLASH_BRIGHTNESS, remaining * remaining); Sphere { position: position.position, color, @@ -79,7 +76,7 @@ pub fn collect_spheres( let value = ((compartment.voltage + 50.0) / 200.0) as f32; Sphere { position: position.position, - color: neurocolor(neuron_type, value), + color: palette.neurocolor(neuron_type, value), radius: radii .get(&entity) .copied() @@ -140,7 +137,7 @@ pub fn collect_spheres( }; Sphere { position: path.point_at(progress), - color: crust(), + color: palette.spike, radius: NODE_RADIUS * TRIGGER_SPHERE_SCALE, _padding: Default::default(), } From 1dda0266c505e778fe27ed0b49456caaf91865d2 Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Sat, 24 Oct 2026 10:54:35 +0200 Subject: [PATCH 04/10] Give each element type its own shape --- neuronify-core/src/app.rs | 30 +- neuronify-core/src/landing.rs | 10 + neuronify-core/src/rendering/bounds.rs | 12 +- neuronify-core/src/rendering/cylinders.rs | 15 +- neuronify-core/src/rendering/gpu_types.rs | 10 + neuronify-core/src/rendering/mod.rs | 3 +- neuronify-core/src/rendering/paths.rs | 5 + neuronify-core/src/rendering/scene.rs | 14 +- neuronify-core/src/rendering/shapes.rs | 338 ++++++++++++++++++++++ neuronify-core/src/rendering/spheres.rs | 45 +-- neuronify-core/src/simulation/lif.rs | 7 + 11 files changed, 444 insertions(+), 45 deletions(-) create mode 100644 neuronify-core/src/rendering/shapes.rs diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index 99c32134..27e02a03 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -7,6 +7,7 @@ use crate::rendering::lighting::{configure_lighting, set_theme_lighting, shaded} use crate::rendering::{ canvas_bounds, collect_connections, collect_scene, collect_voltmeter_traces, compartment_radii, connection_paths, create_floor, ConnectionData, CylinderData, NoteRenderer, Sphere, Theme, + TorusData, }; use crate::tools::*; use chrono::{DateTime, Duration, Utc}; @@ -23,7 +24,8 @@ use visula::winit::event::{ElementState, Event, MouseButton, TouchPhase, WindowE use visula::{ winit::keyboard::ModifiersKeyState, CustomEvent, CylinderGeometry, CylinderMaterial, Cylinders, Expression, InstanceBuffer, LineGeometry, LineMaterial, Lines, MeshPipeline, RenderData, - Renderable, ShadowRenderData, SphereGeometry, SphereMaterial, Spheres, + Renderable, ShadowRenderData, SphereGeometry, SphereMaterial, Spheres, Torus, TorusGeometry, + TorusMaterial, }; use crate::input::{Keyboard, Mouse, TouchGesture, Touches}; @@ -48,6 +50,8 @@ pub struct Neuronify { 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, @@ -243,6 +247,23 @@ impl Neuronify { ) .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, @@ -292,6 +313,8 @@ impl Neuronify { connection_buffer, cylinders, cylinder_buffer, + rings, + ring_buffer, floor, tool: Tool::Select, theme, @@ -1178,6 +1201,9 @@ impl visula::Simulation for Neuronify { 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); @@ -1202,6 +1228,7 @@ impl visula::Simulation for Neuronify { 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); @@ -1210,6 +1237,7 @@ impl visula::Simulation for Neuronify { 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) { diff --git a/neuronify-core/src/landing.rs b/neuronify-core/src/landing.rs index 79f85692..dea0bcde 100644 --- a/neuronify-core/src/landing.rs +++ b/neuronify-core/src/landing.rs @@ -536,6 +536,16 @@ fn top_down_shapes(area: Rect, world: &World, bounds: &Bounds, palette: &Palette .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 wire in collect_connections(world, &Tool::Select, &None, palette) { shapes.push(Shape::line_segment( diff --git a/neuronify-core/src/rendering/bounds.rs b/neuronify-core/src/rendering/bounds.rs index 827be330..497c5e85 100644 --- a/neuronify-core/src/rendering/bounds.rs +++ b/neuronify-core/src/rendering/bounds.rs @@ -50,6 +50,12 @@ fn circuit_points(world: &hecs::World) -> Vec { 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); @@ -94,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(); @@ -126,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)); } @@ -150,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/cylinders.rs b/neuronify-core/src/rendering/cylinders.rs index af6d42b4..11f4ffa9 100644 --- a/neuronify-core/src/rendering/cylinders.rs +++ b/neuronify-core/src/rendering/cylinders.rs @@ -4,7 +4,8 @@ use glam::Vec3; use hecs::Entity; use crate::components::*; -use crate::rendering::colors::Palette; +use crate::constants::NODE_RADIUS; +use crate::rendering::colors::{terminal_color, Palette}; use crate::rendering::gpu_types::CylinderData; use crate::rendering::paths::ConnectionPath; @@ -100,6 +101,18 @@ pub fn collect_cylinders( _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/gpu_types.rs b/neuronify-core/src/rendering/gpu_types.rs index 34c2a028..2fa84b06 100644 --- a/neuronify-core/src/rendering/gpu_types.rs +++ b/neuronify-core/src/rendering/gpu_types.rs @@ -34,6 +34,16 @@ pub struct CylinderData { 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/mod.rs b/neuronify-core/src/rendering/mod.rs index fe5d29ee..df80217f 100644 --- a/neuronify-core/src/rendering/mod.rs +++ b/neuronify-core/src/rendering/mod.rs @@ -8,6 +8,7 @@ pub mod lighting; pub mod notes; pub mod paths; pub mod scene; +pub mod shapes; pub mod spheres; pub mod voltmeter; @@ -16,7 +17,7 @@ pub use colors::{srgb, srgb_component, Palette, Theme}; pub use connections::collect_connections; pub use cylinders::{collect_cylinders, compartment_radii}; pub use floor::create_floor; -pub use gpu_types::{ConnectionData, CylinderData, LineData, MeshInstanceData, Sphere}; +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}; diff --git a/neuronify-core/src/rendering/paths.rs b/neuronify-core/src/rendering/paths.rs index 224c537e..c93bd060 100644 --- a/neuronify-core/src/rendering/paths.rs +++ b/neuronify-core/src/rendering/paths.rs @@ -7,6 +7,7 @@ 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; @@ -26,6 +27,8 @@ pub struct ConnectionPath { 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 { @@ -184,6 +187,7 @@ pub fn connection_paths( start_color, end_color, directional: connection.directional, + inhibitory: is_inhibitory(world, connection.from), }, )) }) @@ -201,6 +205,7 @@ mod tests { start_color: Vec3::ONE, end_color: Vec3::ONE, directional: trim > 0.0, + inhibitory: false, } } diff --git a/neuronify-core/src/rendering/scene.rs b/neuronify-core/src/rendering/scene.rs index e077d055..24fef6ff 100644 --- a/neuronify-core/src/rendering/scene.rs +++ b/neuronify-core/src/rendering/scene.rs @@ -1,20 +1,28 @@ use crate::rendering::colors::Palette; use crate::rendering::cylinders::{collect_cylinders, compartment_radii}; -use crate::rendering::gpu_types::{CylinderData, Sphere}; +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: collect_spheres(world, &radii, &paths, palette), - cylinders: collect_cylinders(world, &radii, &paths, palette), + 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 bd97cf99..0c71e5cb 100644 --- a/neuronify-core/src/rendering/spheres.rs +++ b/neuronify-core/src/rendering/spheres.rs @@ -7,11 +7,7 @@ use crate::constants::*; use crate::rendering::colors::*; use crate::rendering::gpu_types::Sphere; use crate::rendering::paths::ConnectionPath; - -/// How long a soma keeps glowing after it fires, in simulated seconds. -const FLASH_DURATION: f64 = 0.01; -/// How much brighter than white a flash starts, so that only flashes bloom. -const FLASH_BRIGHTNESS: f32 = 3.0; +use crate::rendering::shapes::{generator_color, soma_color}; /// `radii` comes from `compartment_radii`, so the joints match the cylinders. pub fn collect_spheres( @@ -25,45 +21,22 @@ pub fn collect_spheres( 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 neuron_type = if world.get::<&Inhibitory>(entity).is_ok() { - NeuronType::Inhibitory - } else { - NeuronType::Excitatory - }; - let color = palette.soma(&neuron_type, value); - let remaining = (1.0 - dynamics.time_since_fire / FLASH_DURATION).max(0.0) as f32; - let color = color.lerp(palette.flash * FLASH_BRIGHTNESS, remaining * remaining); - 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(), }) @@ -107,9 +80,10 @@ pub fn collect_spheres( }) .collect(); + // Inhibitory synapses end in a bar instead, see `collect_cylinders` let synapse_spheres: Vec = paths .values() - .filter(|path| path.directional) + .filter(|path| path.directional && !path.inhibitory) .filter_map(|path| { Some(Sphere { position: *path.points.last()?, @@ -147,7 +121,6 @@ pub fn collect_spheres( .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()); diff --git a/neuronify-core/src/simulation/lif.rs b/neuronify-core/src/simulation/lif.rs index c1e87eee..46fb21dd 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 From 1c18af5500b614aa1fdd0711ab9a88f761f43941 Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Wed, 21 Oct 2026 16:49:36 +0200 Subject: [PATCH 05/10] Add an option to spawn neurons with dendrites --- neuronify-core/src/app.rs | 15 + neuronify-core/src/components.rs | 3 + neuronify-core/src/dendrites.rs | 396 ++++++++++++++++++ neuronify-core/src/lib.rs | 1 + .../src/serialization/fixtures/v3.neuronify | Bin 0 -> 16807 bytes neuronify-core/src/serialization/mod.rs | 33 +- neuronify-core/src/serialization/v2.rs | 1 - neuronify-core/src/serialization/v3.rs | 39 ++ 8 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 neuronify-core/src/dendrites.rs create mode 100644 neuronify-core/src/serialization/fixtures/v3.neuronify create mode 100644 neuronify-core/src/serialization/v3.rs diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index 27e02a03..82df3b9b 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -1,5 +1,6 @@ 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}; @@ -14,6 +15,7 @@ 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; @@ -36,6 +38,7 @@ pub struct Neuronify { 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, @@ -319,6 +322,7 @@ impl Neuronify { tool: Tool::Select, theme, applied_theme: theme, + dendrites: false, previous_creation: None, connection_tool: None, stimulation_tool: None, @@ -455,6 +459,9 @@ impl Neuronify { if self.tool == Tool::InhibitoryNeuron { world.insert_one(entity, Inhibitory).unwrap(); } + if self.dendrites { + spawn_dendrites(world, entity); + } self.previous_creation = Some(PreviousCreation { entity }); } Tool::CurrentSource => { @@ -635,6 +642,11 @@ 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(); } @@ -1309,6 +1321,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"); + } }); } }); diff --git a/neuronify-core/src/components.rs b/neuronify-core/src/components.rs index 0bba6623..68b118ea 100644 --- a/neuronify-core/src/components.rs +++ b/neuronify-core/src/components.rs @@ -239,6 +239,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/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/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/serialization/fixtures/v3.neuronify b/neuronify-core/src/serialization/fixtures/v3.neuronify new file mode 100644 index 0000000000000000000000000000000000000000..dc5d0de44403a9a6366099ba8731df31c81241d4 GIT binary patch literal 16807 zcmeHO37AyXm97HBSSrxiM9GZ!3>k#6sfdOQGHJRCT2TYCh%y;5`l{+x*ORJxMJ=># zld)YuT%ZAQ0VBAe;?^Rfwkut2#0}KA5tNJ~N;D9IiMVD={{OtC>veT?0W*+fX81n- zzIE!{bI&>VoO73YFQH{qMnaF&clUTay}#GfRbHW#dF9}}}uDuwr zl(39&Ct)RFHDSoKrYhLrX((^b&8R#rz&2>P^5$o&?Y3{@VWZjhZ5-IT<-#6LdvZ%J zr|t2)D}Bk+P(Hw+Pj30fp*N2`TG~sOEXmp9vb|%{4G#V3T`OBT7XR&-)UuX;H>_XZ zYU|cKc1vz@%d%?H*|z1d-B9jt?MR0V$1b*QyEv}hZfeUq?H!XwIPIpkU9EXMt+qbR zV^!#E>IQqAHucP-x6a=FOVNjNJf22rQ@<6Pen;*d6cDk*9%YfOAE$q8t{g1?- z?ECnW8#lc(YkMWe88CbM18ZS_+pfX)uEqR~-E!fYwO-%G+me;ON7nM|NiEG)kL~jg ze}gLRvC;e9hQ)6V`tV?X*(gpT|ILyKTP(@P&jk~=?>iJT+K#OPmG-#99?!$yK?gI_ zR_uPpmF!T=twnSC|2ynX<@-DG(*In>6*Ic%2xC6Kf8dxGSB+}?tmnBmj5|{nT-Kwa z!MDd9im6m|gl8|D{@d+8dC*bC^QXH$d3Aj7JgZjjyvSSIB6vwP7r8rv8 zD8(_cBu81Sl#Cn0r8s(?SBhikv{D?iCY0h>Z8Z2^zNf1QI<@EHqYn42?qXhkxP0OA zpI+J3%-uI=*2N#KzPSq-fBsw@bHo)hy5N{=?ZAu2DZjaFOdbuRqB-)0j%WTjeO* ztB2RBx5`QCrK~PI!8-WbA|06LVh=>z*UeX}$?Op$pH9N?+T?+jfeaX-&Si zQ?`61{F7UJZEqcMvSh~Mz#X{>4JS$a`0renTlec4X)pg^j6V$ZCu3Qy2>1D!}@W!61u<|YhbQ< z?6uN{tyMcF&6W01vm2o2^lHk)hO(-uZFqof=u*9ONuzc3(fHCI7}-!%$wW0JttHY%O34^XT%E27DW<8@>y?CDSSk@crTG_nCj`@4YNn<{jXL2` z!)h|4gJ7`*wU80l(n?fKg*6eUT@cbUni7F=~%remxkV^Zb1#oii+uskxct9_fCP@Mi%<$X+06ugL+0c623qPmaDLh z{M~5iD{D~n4)8igs_WO)C}1nI0@NiFTXN1s%GH6WD*lXJ1N{`sd~;^ zldpiHrxi7!=<%eH%BYEql1%A|ke-au+gXgRCoq`_Qz12`g!EJ>t7qVIpKr3JhLt$Y zRD%VUDJlfp>`Ghm*DNCbgfmeHLQG>C!slnxlomvWzyVq087B%|75ECOXLO9IoX&Jihjc9w(khf1 zwI1CEDS=?n$k4aP4fp+IjA^up$+tvYY(JS<{(XKveDm=!BBZ4HIVDWe(jQL}3fH6OW{mK~-honOE)~GY}uwNOYB1ZIZtv0f~ zshl}cD#tMY@%EC%ymZzq#)BAJScXTJ>XKKAt#kF)hXCZxD*c?O1+VVoo3xu?R8d6q(3vVZDGAiVhN@GwM-{` z<62Dlg+vBwB!!?tIeA-!0N1#3x^z)|qwC>|q{hOIB(sKQ;Bt{MJzvqTJj2=C&mh;G zdS^P*aizb}JI*ksE37c6Uzw=EnkFV#@{LsbXy}9GQ^*_+#$dr^%Eh#ZIYlUFs0FM} zpr+CL2bD5hdvF%rjsm6a09J{6`L!$_)0oWr~*v_r@uiE&tck}Mi^*t*H=LpWV( zjwBV09>~-gsp)>@1@Ea55ziXQ)7*n#4d)h6-{x8@;~9Fd)%R8B)Bg` z{G+gPCqYk|a|W5#%(&;ks+gJjBxEs<#Z$;ot1M%&ku3})VJlqEOfeMZO(=u(oTT ztt6HTY~)?K-?TS z8=3a1!FXbUkHJNVCxo4CpSexeGsj>F#1e~rVBVLIhJ^=v7IXQ*@U)F6v-0s9sLDNbMyZ`on*p{<4KmtlOG8GAArWl7SHYxt=yi>IpPpOQU z)9uG`Sbr^b*gK6=RSbJQ=M+@OsbD5!UzeDU3GpikBPl9iPSj$hx1!HV6Sx3pC~J#s zEIpk?=`&Y(xJgyy{Djpqp&`o=SV23EXXdj?wT1R-9BgilrS62anK}kACorzZ z^$=xS_RA^_!%TtZBF*{}(X|*0{1mvi1J#aix4s$FSy38W2E=8;DwYP$MN?Ul%~|FK zMvtqt*yE^~P}JIhbR57EVP#4-9z^ydsVqkzYOI5$v#Eok%eqljsplQVSl8Q_rCOnW@}SqO*aLs};KghDUet99?16B?Egm z+B6V}N4>O7 z4#YtfS2$9`%-Y1vtm8g}W0jO-8!{5^8meGlfnIPFDs)UuiU`UV6YTj>24m-zsMUtR z#qC|V7cr7a1I{+hbozxgbF;1t#`0D_1Q&$?UXtONEnLLys^))OpyB^Oq2oC%T<(ns zEL~EM3$}j<;J2p$`{0sL{^D{@`mIQwgUhuyE!z5OTP_};3Vb%WLij0j>5>!ic^qV$ z{Yc}OgHN0&<4`W1iF4%GV9BZzd5{kokzHd@CxCVgbxVY2)`jL;C1Ul!Xm;ogvErP6L_zAY$8G~@Q@_PG73 zrt;ce_q{iw^KPIP?Kz^UX70*A9P+Pw-gCF3uRz<~?paV1cC+Wis)8|pFzo)+!Ec|@ z;fLout8EVzjG4#Nd9!HGUXS-(eb}Ne26f&I)S^9~xwm20Kd!ocFM1CA(e3?KJ*@6U z&lBqxoObvbWu<$XzAbByzI7Wvt-0yMulJ(opl#b0ek=OOUi8#&c(3{DbM(FF`F{7I zFB`^?(me|z;lsxA9p4{6{+Kf?V*C6kt0t&G<^2U|Fq|J?sg0n zjJfx5yW6=^P!kS4{*w6CTl)WRTOA5~TXY`nFN)S7o1y66qCNlkrf)#yvBP`sdC$nA7n!_HmQswBshpdu5wk;@{#Yp*ynV9P5D?rmxUxzNd!8wL?sdEkVGXB=#WGu5$KTQ_~s^w_U?{iqg%Xq!mWfH z^5hjeNBnXggyda2ht&SkokNmW@PzJ=?qWwb>28vQ)!}-8q}2q6^hhD8S5MEOWrvmb zyOgfHk}#jpM0k?0mhco|9bqHkO~QwS>nZVO!o!62$CC!)5h?SrC_ZC&!U<`43<!(+y!pZR=-ase{w_G(&-6$R(5LkfhYelscpqa>)ccq(@1T>2^p`S7h=X(yz!Rq2Q1O85V6R6kzlFoBAlyTcQrpbd{e;^Yg!d36`X%1&n86hTV#9eu z6)`!`i3j^lRNk`LZ>yr+YQL4rw*BTR+YRNTa#XF-_bhJV=1UynQA5PM`35F#^m3Nb_Y`4sSn+h?KyS zg67MzY}+rnvfbX-*Jh`O>CHx%A}{GWPgNqtoHoB3_maBx&YM21wEw0a>ib5+jsc^7 z`us<~>G|(Rk4jGd#Rr={`0gnC`;LF-{qCWgKVP?Ml>PTWdHDESW&LG=y`JViN04GI z$e)J0#X zD%PRs%T9lT9d^F|b|24#uHK(0{RWPH;L4_LUA_slzT}%PyVcNA!QfWNq!p`n^`O@B6Z7 ko9)ifd@#SWZwhGM?q&VH{Upbe1B5j)*&t+q{A;fI3ttiwhyVZp literal 0 HcmV?d00001 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..81f3146f --- /dev/null +++ b/neuronify-core/src/serialization/v3.rs @@ -0,0 +1,39 @@ +//! Dendrites are marked, so they keep their look after loading. + +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, +); + +/// Version 3 only adds components, so a version 2 world is already a version 3 world. +pub fn upgrade_from_v2(_world: &mut World) {} From fb78659079cdd476ce328bfa87a859c785f9fd56 Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Sun, 25 Oct 2026 14:31:12 +0100 Subject: [PATCH 06/10] Let adaptation build up while a neuron is refractory --- neuronify-core/src/simulation/lif.rs | 40 +++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/neuronify-core/src/simulation/lif.rs b/neuronify-core/src/simulation/lif.rs index 46fb21dd..e1030a06 100644 --- a/neuronify-core/src/simulation/lif.rs +++ b/neuronify-core/src/simulation/lif.rs @@ -88,14 +88,14 @@ 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); @@ -326,3 +326,35 @@ pub fn run_headless(world: &mut World, steps: usize, dt: f64) -> Vec(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()); + } +} From 8da711883292b74028c7f1756611af9edee48eaf Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Sun, 25 Oct 2026 14:32:08 +0100 Subject: [PATCH 07/10] Load burst neurons from Neuronify 1 --- neuronify-core/src/components.rs | 21 +++++++++++++++++++++ neuronify-core/src/examples.rs | 6 ++++++ neuronify-core/src/legacy/convert.rs | 12 ++++++++++++ neuronify-core/src/legacy/tests.rs | 10 ++++++++++ neuronify-core/src/serialization/v3.rs | 4 +++- neuronify-core/src/simulation/lif.rs | 26 ++++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 1 deletion(-) diff --git a/neuronify-core/src/components.rs b/neuronify-core/src/components.rs index 68b118ea..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, 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/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/serialization/v3.rs b/neuronify-core/src/serialization/v3.rs index 81f3146f..8b00d40d 100644 --- a/neuronify-core/src/serialization/v3.rs +++ b/neuronify-core/src/serialization/v3.rs @@ -1,4 +1,5 @@ -//! Dendrites are marked, so they keep their look after loading. +//! 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}; @@ -33,6 +34,7 @@ schema!( SpatialDynamics, NeuronType, Dendrite, + BurstCurrent, ); /// Version 3 only adds components, so a version 2 world is already a version 3 world. diff --git a/neuronify-core/src/simulation/lif.rs b/neuronify-core/src/simulation/lif.rs index e1030a06..32e9057d 100644 --- a/neuronify-core/src/simulation/lif.rs +++ b/neuronify-core/src/simulation/lif.rs @@ -101,6 +101,14 @@ pub fn lif_step(world: &mut World, dt: f64, time: f64) { 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)>() @@ -331,6 +339,24 @@ 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(); From f15eaa4bc5ab699e4a36c0ab97b2bff875b664b5 Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Sun, 25 Oct 2026 14:35:15 +0100 Subject: [PATCH 08/10] Show adaptation and burst currents as gauges --- neuronify-core/src/app.rs | 9 +- neuronify-core/src/rendering/colors.rs | 8 + neuronify-core/src/rendering/gauges.rs | 281 +++++++++++++++++++++++++ neuronify-core/src/rendering/mod.rs | 2 + 4 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 neuronify-core/src/rendering/gauges.rs diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index 82df3b9b..d5f0262a 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -7,8 +7,8 @@ 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_scene, collect_voltmeter_traces, compartment_radii, - connection_paths, create_floor, ConnectionData, CylinderData, NoteRenderer, Sphere, Theme, - TorusData, + connection_paths, create_floor, ConnectionData, CylinderData, GaugeRenderer, NoteRenderer, + Sphere, Theme, TorusData, }; use crate::tools::*; use chrono::{DateTime, Duration, Utc}; @@ -68,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, } @@ -346,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, } @@ -1220,6 +1222,8 @@ impl visula::Simulation for Neuronify { .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"))] @@ -1244,6 +1248,7 @@ impl visula::Simulation for Neuronify { 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) { diff --git a/neuronify-core/src/rendering/colors.rs b/neuronify-core/src/rendering/colors.rs index 236b6fba..22b53256 100644 --- a/neuronify-core/src/rendering/colors.rs +++ b/neuronify-core/src/rendering/colors.rs @@ -54,6 +54,14 @@ pub fn terminal_color() -> Vec3 { Vec3::new(136.0 / 255.0, 57.0 / 255.0, 239.0 / 255.0) } +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; 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/mod.rs b/neuronify-core/src/rendering/mod.rs index df80217f..7842a832 100644 --- a/neuronify-core/src/rendering/mod.rs +++ b/neuronify-core/src/rendering/mod.rs @@ -3,6 +3,7 @@ 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; @@ -17,6 +18,7 @@ pub use colors::{srgb, srgb_component, Palette, Theme}; pub use connections::collect_connections; 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}; From e60131f38265df6578bf490dd7d2848a007a4ba2 Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Sun, 25 Oct 2026 15:05:55 +0100 Subject: [PATCH 09/10] Add adaptive and burst neurons to the editor --- neuronify-core/src/app.rs | 14 +++++++++++--- neuronify-core/src/tools.rs | 4 ++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index d5f0262a..593cef73 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -439,7 +439,10 @@ impl Neuronify { false }; match tool { - Tool::ExcitatoryNeuron | Tool::InhibitoryNeuron => { + Tool::ExcitatoryNeuron + | Tool::InhibitoryNeuron + | Tool::AdaptiveNeuron + | Tool::BurstNeuron => { if previous_too_near { return; } @@ -458,8 +461,13 @@ 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); diff --git a/neuronify-core/src/tools.rs b/neuronify-core/src/tools.rs index 1b3ed760..8dead8c9 100644 --- a/neuronify-core/src/tools.rs +++ b/neuronify-core/src/tools.rs @@ -7,6 +7,8 @@ pub enum Tool { Select, ExcitatoryNeuron, InhibitoryNeuron, + AdaptiveNeuron, + BurstNeuron, CurrentSource, TouchSensor, RegularSpikeGenerator, @@ -46,6 +48,8 @@ impl ToolCategory { ToolCategory::Neurons => 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"), From 549f2c5f4a1c92557c8674ef9703f36f905930ec Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Mon, 26 Oct 2026 16:43:59 +0100 Subject: [PATCH 10/10] Activate touch sensors in view mode --- neuronify-core/src/app.rs | 14 ++++++-- neuronify-core/src/simulation/mod.rs | 2 +- neuronify-core/src/simulation/stimulation.rs | 36 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index 593cef73..092bdcd9 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -418,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; } 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