use std::collections::HashSet;
use std::sync::Arc;
use glam::{Quat, Vec3};
use uzor_urx_3d::{Light, Mesh, MeshLit, Node, NodeMesh, PhongMaterial, Scene3D, Vertex};
use crate::cluster::ClusterRegistry;
use crate::graph::{Graph, NodeIndex};
use crate::particle::Particle;
use crate::render::category_color_default;
use crate::style::{DashPattern, NodeMarker};
pub const EDGE_TINT_RGB: [f32; 3] = [0.486, 0.518, 0.588];
pub const EDGE_ALPHA: f32 = 0.45;
pub const EDGE_TINT: [f32; 4] = [EDGE_TINT_RGB[0], EDGE_TINT_RGB[1], EDGE_TINT_RGB[2], EDGE_ALPHA];
pub const EDGE_WIDTH_SCALE_MAX: f32 = 6.0 / 1.75;
pub fn edge_width_scale(weight: f32, max_scale: f32) -> f32 {
let w = weight.max(0.0);
((1.0 + w.sqrt()) * 0.5).min(max_scale)
}
pub const CLUSTER_EDGE_TINT: [f32; 4] = [0.788, 0.663, 0.306, 1.0];
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Graph3DEdgeStyle {
pub tint_rgb: [f32; 3],
pub alpha: f32,
pub width_scale_max: f32,
pub cluster_edge_tint: [f32; 4],
}
impl Graph3DEdgeStyle {
pub fn edge_tint(&self) -> [f32; 4] {
[self.tint_rgb[0], self.tint_rgb[1], self.tint_rgb[2], self.alpha]
}
}
impl Default for Graph3DEdgeStyle {
fn default() -> Self {
Self { tint_rgb: EDGE_TINT_RGB, alpha: EDGE_ALPHA, width_scale_max: EDGE_WIDTH_SCALE_MAX, cluster_edge_tint: CLUSTER_EDGE_TINT }
}
}
fn color_tint(color: &str, alpha: f32) -> [f32; 4] {
let hex = color.trim_start_matches('#');
if hex.len() != 6 {
return [1.0, 1.0, 1.0, alpha.clamp(0.0, 1.0)];
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255);
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, alpha.clamp(0.0, 1.0)]
}
fn category_tint(category: &str) -> [f32; 4] {
color_tint(&category_color_default(category), 1.0)
}
#[derive(Clone)]
pub struct Graph3DMarkerMeshes {
pub ring: Arc<MeshLit>,
pub warning: Arc<MeshLit>,
}
impl Default for Graph3DMarkerMeshes {
fn default() -> Self {
Self {
ring: Arc::new(MeshLit::torus(0.85, 0.08, 24, 8, [1.0, 1.0, 1.0, 1.0])),
warning: Arc::new(MeshLit::cone(0.7, 1.4, 16, [1.0, 1.0, 1.0, 1.0])),
}
}
}
const DEFAULT_NODE_MATERIAL: PhongMaterial = PhongMaterial {
ambient_strength: 0.35,
diffuse_strength: 0.7,
specular_strength: 0.15,
shininess: 24.0,
};
#[derive(Debug, Clone, Copy)]
pub struct Graph3DLighting {
pub node_material: PhongMaterial,
pub ambient: [f32; 3],
pub light_direction: Vec3,
pub light_color: [f32; 3],
pub light_intensity: f32,
}
impl Default for Graph3DLighting {
fn default() -> Self {
Self {
node_material: DEFAULT_NODE_MATERIAL,
ambient: [0.5, 0.5, 0.55],
light_direction: Vec3::new(-0.4, -1.0, -0.3),
light_color: [1.0, 1.0, 1.0],
light_intensity: 0.75,
}
}
}
pub fn build_node_instances<N, E>(
graph: &Graph<N, E>,
particles: &[Particle],
mesh: &Arc<MeshLit>,
hidden: &HashSet<NodeIndex>,
material: PhongMaterial,
) -> Vec<Node> {
graph
.nodes()
.filter_map(|(id, node)| {
if hidden.contains(&id) {
return None;
}
let p = particles.get(id.index())?;
let tint = if let Some(style) = &node.style {
let color;
let fill = if let Some(fill) = style.fill.as_deref() {
fill
} else {
color = category_color_default(&node.category);
color.as_str()
};
color_tint(fill, style.alpha.unwrap_or(1.0))
} else {
category_tint(&node.category)
};
Some(
Node::new_lit(mesh.clone())
.with_translation(Vec3::new(p.x, p.y, p.z))
.with_scale(Vec3::splat(node.radius.max(0.01)))
.with_tint(tint)
.with_material(material),
)
})
.collect()
}
pub fn build_node_marker_instances<N, E>(
graph: &Graph<N, E>,
particles: &[Particle],
meshes: &Graph3DMarkerMeshes,
hidden: &HashSet<NodeIndex>,
material: PhongMaterial,
) -> Vec<Node> {
let mut instances = Vec::new();
for (id, node) in graph.nodes() {
if hidden.contains(&id) {
continue;
}
let Some(style) = &node.style else { continue };
if style.marker.is_none() && style.outline.is_none() {
continue;
}
let Some(particle) = particles.get(id.index()) else { continue };
let position = Vec3::new(particle.x, particle.y, particle.z);
let radius = node.radius.max(0.01);
let fallback;
let marker_color = if let Some(outline) = style.outline.as_deref() {
outline
} else if let Some(fill) = style.fill.as_deref() {
fill
} else {
fallback = category_color_default(&node.category);
fallback.as_str()
};
let tint = color_tint(marker_color, style.alpha.unwrap_or(1.0));
let ring = |scale: f32, translation: Vec3| {
Node::new_lit(meshes.ring.clone())
.with_translation(translation)
.with_scale(Vec3::splat(scale))
.with_tint(tint)
.with_material(material)
};
match style.marker {
Some(NodeMarker::DoubleRing) => {
instances.push(ring(radius * 1.25, position));
instances.push(ring(radius * 1.55, position));
}
Some(NodeMarker::Boundary) => {
instances.push(ring(radius * 1.35, position));
}
Some(NodeMarker::Frontier) => {
instances.push(ring(radius * 0.72, position + Vec3::Y * radius * 1.2));
}
Some(NodeMarker::Warning) => {
instances.push(
Node::new_lit(meshes.warning.clone())
.with_translation(position + Vec3::Y * radius * 1.25)
.with_scale(Vec3::splat(radius * 0.55))
.with_tint(tint)
.with_material(material),
);
}
None => {
instances.push(ring(radius * 1.25, position));
}
}
}
instances
}
pub fn build_edge_instances<N, E>(
graph: &Graph<N, E>,
particles: &[Particle],
mesh: &Arc<Mesh>,
hidden: &HashSet<NodeIndex>,
style: &Graph3DEdgeStyle,
) -> Vec<Node> {
let mut instances = Vec::new();
for (_, edge) in graph.edges() {
if hidden.contains(&edge.from) || hidden.contains(&edge.to) {
continue;
}
let (Some(a), Some(b)) = (particles.get(edge.from.index()), particles.get(edge.to.index())) else { continue };
let from = Vec3::new(a.x, a.y, a.z);
let to = Vec3::new(b.x, b.y, b.z);
let delta = to - from;
let length = delta.length();
if length < 1e-5 {
continue;
}
let dir = delta / length;
let element_style = edge.style.as_ref();
let lateral_offset = element_style
.and_then(|value| value.lateral_offset)
.filter(|value| value.is_finite())
.unwrap_or(0.0);
let lateral = stable_edge_perpendicular(dir) * lateral_offset;
let offset_from = from + lateral;
let offset_to = to + lateral;
let width_scale = element_style
.and_then(|value| value.width)
.map(|width| width.max(0.1) / 1.75)
.unwrap_or_else(|| edge_width_scale(edge.weight, style.width_scale_max));
let tint = if let Some(element_style) = element_style {
let alpha = element_style.alpha.unwrap_or(style.alpha);
element_style
.tint
.as_deref()
.map(|color| color_tint(color, alpha))
.unwrap_or([style.tint_rgb[0], style.tint_rgb[1], style.tint_rgb[2], alpha.clamp(0.0, 1.0)])
} else {
style.edge_tint()
};
let spans = element_style
.and_then(|value| value.dash.as_ref())
.and_then(DashPattern::resolved)
.map(|pattern| dashed_spans(length, &pattern))
.unwrap_or_else(|| vec![(0.0, length)]);
for (start, end) in spans {
instances.push(line_instance(mesh, offset_from + dir * start, offset_from + dir * end, width_scale, tint));
}
if element_style.is_some_and(|value| value.directed) {
let target_radius = graph.get_node(edge.to).map(|node| node.radius.max(0.01)).unwrap_or(0.01);
let tip = offset_to - dir * (target_radius * 1.1).min(length * 0.45);
let arrow_length = (target_radius * 1.1).max(2.0).min(length * 0.35);
let back = tip - dir * arrow_length;
let mut side = dir.cross(Vec3::Y);
if side.length_squared() < 1e-6 {
side = dir.cross(Vec3::X);
}
side = side.normalize_or_zero() * arrow_length * 0.45;
instances.push(line_instance(mesh, tip, back + side, width_scale, tint));
instances.push(line_instance(mesh, tip, back - side, width_scale, tint));
}
}
instances
}
fn stable_edge_perpendicular(direction: Vec3) -> Vec3 {
let absolute = direction.abs();
let reference = if absolute.y <= absolute.x && absolute.y <= absolute.z {
Vec3::Y
} else if absolute.x <= absolute.z {
Vec3::X
} else {
Vec3::Z
};
direction.cross(reference).normalize_or_zero()
}
fn line_instance(mesh: &Arc<Mesh>, from: Vec3, to: Vec3, width_scale: f32, tint: [f32; 4]) -> Node {
let delta = to - from;
let length = delta.length();
Node::new_line(mesh.clone())
.with_translation(from)
.with_rotation(Quat::from_rotation_arc(Vec3::Y, delta / length))
.with_scale(Vec3::new(width_scale, length, 1.0))
.with_tint(tint)
}
fn dashed_spans(length: f32, pattern: &[f32]) -> Vec<(f32, f32)> {
let mut spans = Vec::new();
let mut cursor = 0.0;
let mut index = 0usize;
while cursor < length {
let run = pattern[index % pattern.len()];
let end = (cursor + run).min(length);
if index % 2 == 0 && end > cursor {
spans.push((cursor, end));
}
cursor = end;
index += 1;
}
spans
}
fn arm_default_lighting(scene: &mut Scene3D, lighting: &Graph3DLighting) {
scene.ambient = lighting.ambient;
scene.push_light(Light::directional(lighting.light_direction, lighting.light_color, lighting.light_intensity));
}
pub fn build_scene<N, E>(
graph: &Graph<N, E>,
particles: &[Particle],
node_mesh: &Arc<MeshLit>,
edge_mesh: &Arc<Mesh>,
hidden: &HashSet<NodeIndex>,
lighting: &Graph3DLighting,
edge_style: &Graph3DEdgeStyle,
) -> Scene3D {
let mut scene = build_scene_base(graph, particles, node_mesh, edge_mesh, hidden, lighting, edge_style);
if graph.nodes().any(|(_, node)| {
node.style
.as_ref()
.is_some_and(|style| style.marker.is_some() || style.outline.is_some())
}) {
scene.nodes.extend(build_node_marker_instances(
graph,
particles,
&Graph3DMarkerMeshes::default(),
hidden,
lighting.node_material,
));
}
scene
}
pub fn build_scene_with_markers<N, E>(
graph: &Graph<N, E>,
particles: &[Particle],
node_mesh: &Arc<MeshLit>,
edge_mesh: &Arc<Mesh>,
marker_meshes: &Graph3DMarkerMeshes,
hidden: &HashSet<NodeIndex>,
lighting: &Graph3DLighting,
edge_style: &Graph3DEdgeStyle,
) -> Scene3D {
let mut scene = build_scene_base(graph, particles, node_mesh, edge_mesh, hidden, lighting, edge_style);
scene.nodes.extend(build_node_marker_instances(graph, particles, marker_meshes, hidden, lighting.node_material));
scene
}
fn build_scene_base<N, E>(
graph: &Graph<N, E>,
particles: &[Particle],
node_mesh: &Arc<MeshLit>,
edge_mesh: &Arc<Mesh>,
hidden: &HashSet<NodeIndex>,
lighting: &Graph3DLighting,
edge_style: &Graph3DEdgeStyle,
) -> Scene3D {
let mut scene = Scene3D::new();
arm_default_lighting(&mut scene, lighting);
scene.nodes.extend(build_edge_instances(graph, particles, edge_mesh, hidden, edge_style));
scene.nodes.extend(build_node_instances(graph, particles, node_mesh, hidden, lighting.node_material));
scene
}
pub fn sort_line_nodes_back_to_front(nodes: &mut [Node], eye: Vec3) {
let mut line_slots: Vec<usize> = Vec::new();
let mut by_depth: Vec<(usize, f32)> = Vec::new();
for (i, n) in nodes.iter().enumerate() {
if let NodeMesh::Line(_) = &n.geometry {
line_slots.push(i);
by_depth.push((i, line_node_depth_key(n, eye)));
}
}
if line_slots.len() < 2 {
return;
}
by_depth.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let sorted: Vec<Node> = by_depth.into_iter().map(|(i, _)| nodes[i].clone()).collect();
for (&slot, node) in line_slots.iter().zip(sorted) {
nodes[slot] = node;
}
}
fn line_node_depth_key(node: &Node, eye: Vec3) -> f32 {
let to = node.translation + node.rotation * (Vec3::Y * node.scale.y);
let mid = (node.translation + to) * 0.5;
(mid - eye).length_squared()
}
pub fn build_cluster_edge_instances(particles: &[Particle], mesh: &Arc<Mesh>, clusters: &ClusterRegistry, cluster_edge_tint: [f32; 4]) -> Vec<Node> {
let mut out = Vec::new();
for cluster in clusters.collapsed_clusters() {
let Some(rep) = particles.get(cluster.representative.index()) else { continue };
let from = Vec3::new(rep.x, rep.y, rep.z);
for edge in cluster.aggregated_edges() {
let Some(other) = particles.get(edge.outside.index()) else { continue };
let to = Vec3::new(other.x, other.y, other.z);
let delta = to - from;
let length = delta.length();
if length < 1e-5 {
continue;
}
let dir = delta / length;
let rotation = Quat::from_rotation_arc(Vec3::Y, dir);
out.push(
Node::new_line(mesh.clone())
.with_translation(from)
.with_rotation(rotation)
.with_scale(Vec3::new(1.0, length, 1.0))
.with_tint(cluster_edge_tint),
);
}
}
out
}
const DEFAULT_GRID_LINE_ALPHA: f32 = 0.15;
const DEFAULT_GRID_STRONG_LINE_ALPHA: f32 = 0.25;
const DEFAULT_GRID_TINT_RGB: [f32; 3] = [0.60, 0.63, 0.70];
const DEFAULT_GRID_STRONG_LINE_EVERY: i64 = 5;
const DEFAULT_GRID_Y_MARGIN_FRACTION: f32 = 0.08;
const DEFAULT_GRID_Y_MARGIN_MIN: f32 = 4.0;
pub const GRID_MIN_SCREEN_PX: f64 = 40.0;
pub const GRID_MAX_SCREEN_PX: f64 = 160.0;
const DEFAULT_GRID_TARGET_SCREEN_PX: f64 = 80.0;
const DEFAULT_GRID_MAX_AXIS_LABELS: usize = 40;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Graph3DGridConfig {
pub line_alpha: f32,
pub strong_line_alpha: f32,
pub tint_rgb: [f32; 3],
pub strong_line_every: i64,
pub y_margin_fraction: f32,
pub y_margin_min: f32,
pub target_screen_px: f64,
pub max_axis_labels: usize,
}
impl Default for Graph3DGridConfig {
fn default() -> Self {
Self {
line_alpha: DEFAULT_GRID_LINE_ALPHA,
strong_line_alpha: DEFAULT_GRID_STRONG_LINE_ALPHA,
tint_rgb: DEFAULT_GRID_TINT_RGB,
strong_line_every: DEFAULT_GRID_STRONG_LINE_EVERY,
y_margin_fraction: DEFAULT_GRID_Y_MARGIN_FRACTION,
y_margin_min: DEFAULT_GRID_Y_MARGIN_MIN,
target_screen_px: DEFAULT_GRID_TARGET_SCREEN_PX,
max_axis_labels: DEFAULT_GRID_MAX_AXIS_LABELS,
}
}
}
fn snap_to_nice_step(raw: f64) -> f64 {
let raw = raw.max(1e-12);
let exponent = raw.log10().floor();
let base = 10f64.powi(exponent as i32);
let fraction = raw / base;
const CANDIDATES: [f64; 4] = [1.0, 2.0, 5.0, 10.0];
let mut best = CANDIDATES[0];
let mut best_ratio = f64::MAX;
for &candidate in &CANDIDATES {
let ratio = (fraction / candidate).max(candidate / fraction);
if ratio < best_ratio {
best_ratio = ratio;
best = candidate;
}
}
best * base
}
pub fn grid_step_for_scale(camera_distance: f32, fov_y_radians: f32, viewport_height_px: f64, config: &Graph3DGridConfig) -> f64 {
let half_fov_tan = (fov_y_radians * 0.5).tan().max(1e-6) as f64;
let distance = (camera_distance.max(1e-3)) as f64;
let px_per_world_unit = (viewport_height_px.max(1.0) * 0.5) / (distance * half_fov_tan);
if !px_per_world_unit.is_finite() || px_per_world_unit <= 0.0 {
return 1.0;
}
snap_to_nice_step(config.target_screen_px / px_per_world_unit)
}
#[derive(Debug, Clone, Copy)]
pub struct GridLine {
pub from: Vec3,
pub to: Vec3,
pub tick_value: f64,
pub strong: bool,
}
#[derive(Debug, Clone)]
pub struct GridPlan {
pub lines: Vec<GridLine>,
pub step: f64,
}
fn grid_ground_y(min: Vec3, max: Vec3, config: &Graph3DGridConfig) -> f32 {
let extent = max - min;
let largest = extent.x.max(extent.y).max(extent.z).max(0.0);
let margin = (largest * config.y_margin_fraction).max(config.y_margin_min);
min.y - margin
}
fn grid_ticks(min: f64, max: f64, step: f64, strong_line_every: i64) -> Vec<(f64, bool)> {
if !min.is_finite() || !max.is_finite() || !step.is_finite() || step <= 0.0 || min > max {
return Vec::new();
}
let start_index = (min / step).floor() as i64;
let end_index = (max / step).ceil() as i64;
(start_index..=end_index)
.map(|index| (index as f64 * step, index.rem_euclid(strong_line_every) == 0))
.collect()
}
pub fn build_grid_plan(min: Vec3, max: Vec3, step: f64, config: &Graph3DGridConfig) -> GridPlan {
let step = if step.is_finite() && step > 0.0 { step } else { 1.0 };
let grid_y = grid_ground_y(min, max, config);
let x_ticks = grid_ticks(min.x as f64, max.x as f64, step, config.strong_line_every);
let z_ticks = grid_ticks(min.z as f64, max.z as f64, step, config.strong_line_every);
let z_min = z_ticks.first().map_or(min.z as f64, |t| t.0);
let z_max = z_ticks.last().map_or(max.z as f64, |t| t.0);
let x_min = x_ticks.first().map_or(min.x as f64, |t| t.0);
let x_max = x_ticks.last().map_or(max.x as f64, |t| t.0);
let mut lines = Vec::with_capacity(x_ticks.len() + z_ticks.len());
for (x, strong) in x_ticks {
lines.push(GridLine {
from: Vec3::new(x as f32, grid_y, z_min as f32),
to: Vec3::new(x as f32, grid_y, z_max as f32),
tick_value: x,
strong,
});
}
for (z, strong) in z_ticks {
lines.push(GridLine {
from: Vec3::new(x_min as f32, grid_y, z as f32),
to: Vec3::new(x_max as f32, grid_y, z as f32),
tick_value: z,
strong,
});
}
GridPlan { lines, step }
}
pub fn build_grid_instances(plan: &GridPlan, mesh: &Arc<Mesh>, config: &Graph3DGridConfig) -> Vec<Node> {
plan.lines
.iter()
.filter_map(|line| {
let delta = line.to - line.from;
let length = delta.length();
if length < 1e-5 {
return None;
}
let dir = delta / length;
let rotation = Quat::from_rotation_arc(Vec3::Y, dir);
let alpha = if line.strong { config.strong_line_alpha } else { config.line_alpha };
let tint = [config.tint_rgb[0], config.tint_rgb[1], config.tint_rgb[2], alpha];
Some(
Node::new_line(mesh.clone())
.with_translation(line.from)
.with_rotation(rotation)
.with_scale(Vec3::new(1.0, length, 1.0))
.with_tint(tint),
)
})
.collect()
}
pub fn format_tick_value(value: f64, step: f64) -> String {
if step >= 1.0 || step <= 0.0 {
format!("{:.0}", value.round())
} else {
let decimals = (-step.log10()).ceil().max(0.0) as usize;
format!("{value:.decimals$}")
}
}
pub const GPU_PICK_ID_LEVELS: u32 = 32;
pub const MAX_GPU_PICKABLE_NODE_INDEX: u32 = GPU_PICK_ID_LEVELS * GPU_PICK_ID_LEVELS * GPU_PICK_ID_LEVELS - 1;
const ACES_A: f32 = 2.51;
const ACES_B: f32 = 0.03;
const ACES_C: f32 = 2.43;
const ACES_D: f32 = 0.59;
const ACES_E: f32 = 0.14;
fn aces_filmic(x: f32) -> f32 {
let x = x.max(0.0);
((x * (ACES_A * x + ACES_B)) / (x * (ACES_C * x + ACES_D) + ACES_E)).clamp(0.0, 1.0)
}
fn srgb_encode(linear: f32) -> f32 {
let l = linear.clamp(0.0, 1.0);
if l <= 0.0031308 {
l * 12.92
} else {
1.055 * l.powf(1.0 / 2.4) - 0.055
}
}
fn forward_channel_byte(x: f32) -> u8 {
let tonemapped = aces_filmic(x);
let gamma = tonemapped.powf(1.0 / 2.2);
let encoded = srgb_encode(gamma);
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
}
const ACES_INVERSE_SEARCH_MAX: f32 = 16.0;
const ACES_INVERSE_SEARCH_ITERS: u32 = 40;
fn linear_for_target_byte(target_byte: u8) -> f32 {
let mut lo = 0.0f32;
let mut hi = ACES_INVERSE_SEARCH_MAX;
for _ in 0..ACES_INVERSE_SEARCH_ITERS {
let mid = (lo + hi) * 0.5;
if forward_channel_byte(mid) < target_byte {
lo = mid;
} else {
hi = mid;
}
}
hi
}
fn level_to_byte(level: u32) -> u8 {
let level = level.min(GPU_PICK_ID_LEVELS - 1);
((level * 255) / (GPU_PICK_ID_LEVELS - 1)) as u8
}
fn byte_to_level(byte: u8) -> u32 {
let numerator = byte as u32 * (GPU_PICK_ID_LEVELS - 1);
((numerator * 2 + 255) / (255 * 2)).min(GPU_PICK_ID_LEVELS - 1)
}
pub fn encode_node_id_tint(id: NodeIndex) -> [f32; 4] {
let idx = id.0.min(MAX_GPU_PICKABLE_NODE_INDEX);
let r_level = idx % GPU_PICK_ID_LEVELS;
let g_level = (idx / GPU_PICK_ID_LEVELS) % GPU_PICK_ID_LEVELS;
let b_level = (idx / (GPU_PICK_ID_LEVELS * GPU_PICK_ID_LEVELS)) % GPU_PICK_ID_LEVELS;
[
linear_for_target_byte(level_to_byte(r_level)),
linear_for_target_byte(level_to_byte(g_level)),
linear_for_target_byte(level_to_byte(b_level)),
1.0,
]
}
pub fn decode_node_id_pixel(rgba: [u8; 4]) -> NodeIndex {
let r = byte_to_level(rgba[0]);
let g = byte_to_level(rgba[1]);
let b = byte_to_level(rgba[2]);
NodeIndex(r + g * GPU_PICK_ID_LEVELS + b * GPU_PICK_ID_LEVELS * GPU_PICK_ID_LEVELS)
}
pub fn decode_gpu_pick_pixel(rgba: [u8; 4], node_count: u32) -> Option<NodeIndex> {
let id = decode_node_id_pixel(rgba);
if id.0 >= node_count || id.0 == MAX_GPU_PICKABLE_NODE_INDEX {
None
} else {
Some(id)
}
}
pub fn build_id_pass_mesh(rings: u32, slices: u32) -> Mesh {
let lit = MeshLit::sphere(1.0, rings, slices, [1.0, 1.0, 1.0, 1.0]);
let vertices = lit.vertices.iter().map(|v| Vertex { pos: v.pos, _pad0: 0.0, color: v.color }).collect();
Mesh { vertices, indices: lit.indices }
}
pub fn build_id_pass_scene<N, E>(
graph: &Graph<N, E>,
particles: &[Particle],
id_pass_mesh: &Arc<Mesh>,
hidden: &HashSet<NodeIndex>,
) -> Scene3D {
let mut scene = Scene3D::new();
scene.clear_color = [1.0, 1.0, 1.0, 1.0];
scene.nodes = graph
.nodes()
.filter_map(|(id, node)| {
if hidden.contains(&id) {
return None;
}
let p = particles.get(id.index())?;
Some(
Node::new(id_pass_mesh.clone())
.with_translation(Vec3::new(p.x, p.y, p.z))
.with_scale(Vec3::splat(node.radius.max(0.01)))
.with_tint(encode_node_id_tint(id)),
)
})
.collect();
scene
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::Graph;
use crate::style::{EdgeVisualStyle, NodeVisualStyle};
type DemoGraph = Graph<(), ()>;
fn unit_mesh() -> Arc<MeshLit> {
Arc::new(MeshLit::sphere(1.0, 4, 4, [1.0, 1.0, 1.0, 1.0]))
}
fn unit_edge_quad_mesh() -> Arc<Mesh> {
Arc::new(Mesh::unit_edge_quad([1.0, 1.0, 1.0, 1.0]))
}
#[test]
fn build_node_instances_emits_one_lit_node_per_graph_node_with_translation_scale_and_tint() {
let mut graph = DemoGraph::new();
graph.push_node((), "a", "cat-a", 2.0);
let particles = vec![Particle::at3(1.0, 2.0, 3.0)];
let mesh = unit_mesh();
let nodes = build_node_instances(&graph, &particles, &mesh, &HashSet::new(), DEFAULT_NODE_MATERIAL);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].translation, Vec3::new(1.0, 2.0, 3.0));
assert_eq!(nodes[0].scale, Vec3::splat(2.0));
assert_eq!(nodes[0].color_tint, category_tint("cat-a"));
assert!(nodes[0].is_lit());
assert_eq!(nodes[0].material.ambient_strength, DEFAULT_NODE_MATERIAL.ambient_strength, "Wave C node-material softening must actually be wired into build_node_instances");
}
#[test]
fn build_node_instances_uses_the_caller_supplied_material_not_a_hardcoded_one() {
let mut graph = DemoGraph::new();
graph.push_node((), "a", "cat-a", 2.0);
let particles = vec![Particle::at3(1.0, 2.0, 3.0)];
let mesh = unit_mesh();
let custom = PhongMaterial { ambient_strength: 0.9, diffuse_strength: 0.1, specular_strength: 0.0, shininess: 4.0 };
let nodes = build_node_instances(&graph, &particles, &mesh, &HashSet::new(), custom);
assert_eq!(nodes[0].material.ambient_strength, 0.9, "Wave G2b configurability gate: the material parameter must actually be threaded through, not ignored");
}
#[test]
fn unstyled_elements_preserve_existing_3d_category_and_global_edge_defaults() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "cat-a", 2.0);
let b = graph.push_node((), "b", "cat-b", 2.0);
graph.push_edge(a, b, 1.0, ());
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(0.0, 5.0, 0.0)];
let nodes = build_node_instances(&graph, &particles, &unit_mesh(), &HashSet::new(), DEFAULT_NODE_MATERIAL);
let edges = build_edge_instances(&graph, &particles, &unit_edge_quad_mesh(), &HashSet::new(), &Graph3DEdgeStyle::default());
assert_eq!(nodes[0].color_tint, category_tint("cat-a"));
assert_eq!(nodes[1].color_tint, category_tint("cat-b"));
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].color_tint, EDGE_TINT);
assert_eq!(edges[0].scale.x, 1.0);
}
#[test]
fn build_node_instances_and_marker_instances_apply_per_node_tint_alpha_outline_and_double_ring() {
let mut graph = DemoGraph::new();
let id = graph.push_node((), "root", "cat-a", 2.0);
graph.set_node_style(id, Some(NodeVisualStyle {
fill: Some("#204060".into()),
outline: Some("#ff8000".into()),
alpha: Some(0.5),
marker: Some(NodeMarker::DoubleRing),
..NodeVisualStyle::default()
}));
let particles = vec![Particle::at3(1.0, 2.0, 3.0)];
let mesh = unit_mesh();
let marker_meshes = Graph3DMarkerMeshes::default();
let nodes = build_node_instances(&graph, &particles, &mesh, &HashSet::new(), DEFAULT_NODE_MATERIAL);
let markers = build_node_marker_instances(&graph, &particles, &marker_meshes, &HashSet::new(), DEFAULT_NODE_MATERIAL);
assert_eq!(nodes[0].color_tint, color_tint("#204060", 0.5));
assert_eq!(markers.len(), 2, "DoubleRing must compose two shared torus instances");
assert!(markers.iter().all(|node| node.color_tint == color_tint("#ff8000", 0.5)));
assert!(markers[0].scale.x < markers[1].scale.x);
}
#[test]
fn all_semantic_node_marker_variants_construct_distinct_additive_3d_geometry() {
let mut graph = DemoGraph::new();
for (index, marker) in [
NodeMarker::DoubleRing,
NodeMarker::Boundary,
NodeMarker::Frontier,
NodeMarker::Warning,
]
.into_iter()
.enumerate()
{
let id = graph.push_node((), format!("n{index}"), "x", 2.0);
graph.set_node_style(id, Some(NodeVisualStyle { marker: Some(marker), ..NodeVisualStyle::default() }));
}
let particles = (0..4).map(|index| Particle::at3(index as f32 * 10.0, 0.0, 0.0)).collect::<Vec<_>>();
let markers = build_node_marker_instances(
&graph,
&particles,
&Graph3DMarkerMeshes::default(),
&HashSet::new(),
DEFAULT_NODE_MATERIAL,
);
assert_eq!(markers.len(), 5, "double-ring emits two instances; boundary, frontier, and warning emit one each");
assert_ne!(markers[3].translation.y, particles[2].y, "frontier ring is lifted above its node");
assert_ne!(markers[4].translation.y, particles[3].y, "warning cone is lifted above its node");
}
#[test]
fn build_edge_instances_places_the_translation_at_the_from_endpoint_not_the_midpoint() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
graph.push_edge(a, b, 1.0, ());
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(0.0, 5.0, 0.0)];
let mesh = unit_edge_quad_mesh();
let edges = build_edge_instances(&graph, &particles, &mesh, &HashSet::new(), &Graph3DEdgeStyle::default());
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].translation, Vec3::ZERO, "translation must be the FROM endpoint, not the midpoint — see the module doc");
assert!((edges[0].scale.y - 5.0).abs() < 1e-5);
assert_eq!(edges[0].scale.x, 1.0, "a weight-1.0 edge must recover scale.x == 1.0 exactly — the byte/pixel-compatibility the per-instance-width wave's own gate required");
assert_eq!(edges[0].color_tint, EDGE_TINT);
assert!(matches!(edges[0].geometry, uzor_urx_3d::NodeMesh::Line(_)), "edges must use the dedicated edge-quad geometry, not a cylinder");
let rotated_axis = edges[0].rotation * Vec3::Y;
assert!((rotated_axis - Vec3::Y).length() < 1e-4);
}
#[test]
fn build_edge_instances_rotation_aligns_the_line_axis_to_an_arbitrary_edge_direction() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
graph.push_edge(a, b, 1.0, ());
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(3.0, 4.0, 0.0)];
let mesh = unit_edge_quad_mesh();
let edges = build_edge_instances(&graph, &particles, &mesh, &HashSet::new(), &Graph3DEdgeStyle::default());
let expected_dir = Vec3::new(3.0, 4.0, 0.0).normalize();
let rotated_axis = edges[0].rotation * Vec3::Y;
assert!((rotated_axis - expected_dir).length() < 1e-4);
assert!((edges[0].scale.y - 5.0).abs() < 1e-4);
}
#[test]
fn edge_width_scale_of_weight_1_is_the_identity_scale() {
assert_eq!(edge_width_scale(1.0, EDGE_WIDTH_SCALE_MAX), 1.0, "a weight-1.0 edge must recover exactly scale 1.0 — the byte/pixel-compatibility gate");
}
#[test]
fn edge_width_scale_of_a_lower_weight_is_visibly_thinner_than_weight_1() {
let thinner = edge_width_scale(0.6, EDGE_WIDTH_SCALE_MAX);
let baseline = edge_width_scale(1.0, EDGE_WIDTH_SCALE_MAX);
assert!(thinner < baseline, "weight 0.6 must scale visibly thinner than weight 1.0: {thinner} vs {baseline}");
assert!(baseline - thinner > 0.05, "the difference must be large enough to actually READ as a different width on screen, not a sub-pixel rounding wash: {thinner} vs {baseline}");
}
#[test]
fn edge_width_scale_is_monotonically_increasing_in_weight() {
let low = edge_width_scale(0.1, EDGE_WIDTH_SCALE_MAX);
let mid = edge_width_scale(1.0, EDGE_WIDTH_SCALE_MAX);
let high = edge_width_scale(4.0, EDGE_WIDTH_SCALE_MAX);
assert!(low < mid, "{low} should be < {mid}");
assert!(mid < high, "{mid} should be < {high}");
}
#[test]
fn edge_width_scale_clamps_at_the_sane_max_for_an_extreme_weight() {
let extreme = edge_width_scale(10_000.0, EDGE_WIDTH_SCALE_MAX);
assert_eq!(extreme, EDGE_WIDTH_SCALE_MAX, "an extreme weight must clamp at the caller-supplied max_scale, not blow the line out unbounded");
let clamped_px = 1.75 * EDGE_WIDTH_SCALE_MAX;
assert!((clamped_px - 6.0).abs() < 0.01, "EDGE_WIDTH_SCALE_MAX against the 1.75px default base must land at ~6px, got {clamped_px}");
}
#[test]
fn edge_width_scale_never_goes_negative_for_a_negative_weight() {
assert!(edge_width_scale(-5.0, EDGE_WIDTH_SCALE_MAX) > 0.0, "a defensively-clamped negative weight must still produce a positive scale");
}
#[test]
fn edge_width_scale_clamps_at_a_caller_supplied_max_scale_not_just_the_default() {
let custom_max = 2.0;
assert_eq!(edge_width_scale(10_000.0, custom_max), custom_max);
assert_ne!(edge_width_scale(10_000.0, custom_max), EDGE_WIDTH_SCALE_MAX);
}
#[test]
fn build_edge_instances_threads_the_edge_weight_into_scale_x_via_edge_width_scale() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
graph.push_edge(a, b, 0.6, ());
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(0.0, 5.0, 0.0)];
let mesh = unit_edge_quad_mesh();
let edges = build_edge_instances(&graph, &particles, &mesh, &HashSet::new(), &Graph3DEdgeStyle::default());
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].scale.x, edge_width_scale(0.6, EDGE_WIDTH_SCALE_MAX), "scale.x must carry exactly edge_width_scale(edge.weight, style.width_scale_max), not the old hardcoded 1.0");
assert_ne!(edges[0].scale.x, 1.0, "a weight-0.6 edge must NOT recover the identity scale");
}
#[test]
fn build_edge_instances_uses_the_caller_supplied_style_tint() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
graph.push_edge(a, b, 1.0, ());
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(0.0, 5.0, 0.0)];
let mesh = unit_edge_quad_mesh();
let style = Graph3DEdgeStyle { tint_rgb: [1.0, 0.0, 0.0], alpha: 0.9, ..Graph3DEdgeStyle::default() };
let edges = build_edge_instances(&graph, &particles, &mesh, &HashSet::new(), &style);
assert_eq!(edges[0].color_tint, [1.0, 0.0, 0.0, 0.9]);
assert_ne!(edges[0].color_tint, EDGE_TINT);
}
#[test]
fn build_edge_instances_constructs_dashed_directed_segments_with_per_edge_tint_alpha_and_width() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
let edge = graph.push_edge(a, b, 1.0, ());
graph.set_edge_style(edge, Some(EdgeVisualStyle {
tint: Some("#336699".into()),
alpha: Some(0.25),
width: Some(3.5),
dash: Some(DashPattern::Pattern(vec![4.0, 2.0])),
lateral_offset: None,
directed: true,
}));
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(0.0, 30.0, 0.0)];
let mesh = unit_edge_quad_mesh();
let edges = build_edge_instances(&graph, &particles, &mesh, &HashSet::new(), &Graph3DEdgeStyle::default());
assert_eq!(edges.len(), 7, "five on-spans plus two solid arrowhead wings");
assert!(edges.iter().all(|node| node.color_tint == color_tint("#336699", 0.25)));
assert!(edges.iter().all(|node| (node.scale.x - 2.0).abs() < 1e-6), "3.5px override maps to 2x the 1.75px base");
assert!((edges[0].scale.y - 4.0).abs() < 1e-6);
assert_eq!(edges[1].translation, Vec3::new(0.0, 6.0, 0.0));
}
#[test]
fn build_edge_instances_offsets_parallel_lines_and_arrowheads_in_opposite_world_directions() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
let positive = graph.push_edge(a, b, 1.0, ());
let negative = graph.push_edge(a, b, 1.0, ());
graph.set_edge_style(positive, Some(EdgeVisualStyle {
lateral_offset: Some(2.0),
directed: true,
..EdgeVisualStyle::default()
}));
graph.set_edge_style(negative, Some(EdgeVisualStyle {
lateral_offset: Some(-2.0),
directed: true,
..EdgeVisualStyle::default()
}));
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(0.0, 10.0, 0.0)];
let mesh = unit_edge_quad_mesh();
let edges = build_edge_instances(&graph, &particles, &mesh, &HashSet::new(), &Graph3DEdgeStyle::default());
assert_eq!(edges.len(), 6, "each solid directed edge emits one segment and two arrow wings");
assert_eq!(edges[0].translation, Vec3::new(0.0, 0.0, -2.0));
assert_eq!(edges[3].translation, Vec3::new(0.0, 0.0, 2.0));
assert!((edges[1].translation.z - edges[4].translation.z + 4.0).abs() < 1e-6, "arrowhead tips must move with their edge");
assert_eq!(edges[0].scale.y, edges[3].scale.y);
}
#[test]
fn stable_edge_perpendicular_is_finite_and_orthogonal_for_axis_aligned_directions() {
for direction in [Vec3::X, Vec3::Y, Vec3::Z, -Vec3::X, -Vec3::Y, -Vec3::Z] {
let perpendicular = stable_edge_perpendicular(direction);
assert!(perpendicular.is_finite());
assert!((perpendicular.length() - 1.0).abs() < 1e-6);
assert!(perpendicular.dot(direction).abs() < 1e-6);
}
}
#[test]
fn build_cluster_edge_instances_keeps_scale_x_at_the_identity_regardless_of_summed_weight() {
let mut graph = DemoGraph::new();
let member = graph.push_node((), "member", "x", 1.0);
let outside = graph.push_node((), "outside", "x", 4.0);
graph.push_edge(member, outside, 5.0, ());
let mut clusters = ClusterRegistry::default();
let id = clusters.define(&graph, vec![member]).expect("define must succeed for a real member");
let mut particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(10.0, 0.0, 0.0)];
assert!(clusters.collapse_3d(id, &mut graph, &mut particles), "collapse_3d must succeed");
let mesh = unit_edge_quad_mesh();
let edges = build_cluster_edge_instances(&particles, &mesh, &clusters, CLUSTER_EDGE_TINT);
assert!(!edges.is_empty(), "expected at least one aggregated cross-cluster edge (summed weight 5.0)");
for e in &edges {
assert_eq!(e.scale.x, 1.0, "cluster synthetic edges deliberately stay at the BASE width regardless of summed weight — see the function's own doc comment");
}
}
#[test]
fn build_edge_instances_skips_a_coincident_degenerate_edge() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
graph.push_edge(a, b, 1.0, ());
let particles = vec![Particle::at3(2.0, 2.0, 2.0), Particle::at3(2.0, 2.0, 2.0)];
let mesh = unit_edge_quad_mesh();
let edges = build_edge_instances(&graph, &particles, &mesh, &HashSet::new(), &Graph3DEdgeStyle::default());
assert!(edges.is_empty(), "a zero-length edge has no well-defined direction — must not emit a NaN-rotation node");
}
#[test]
fn build_edge_instances_skips_an_edge_touching_a_hidden_node() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
let c = graph.push_node((), "c", "x", 1.0);
graph.push_edge(a, b, 1.0, ());
graph.push_edge(b, c, 1.0, ());
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(0.0, 5.0, 0.0), Particle::at3(0.0, 10.0, 0.0)];
let mesh = unit_edge_quad_mesh();
let hidden: HashSet<NodeIndex> = [b].into_iter().collect();
let edges = build_edge_instances(&graph, &particles, &mesh, &hidden, &Graph3DEdgeStyle::default());
assert!(edges.is_empty(), "both edges touch the hidden node b — neither may be emitted");
}
#[test]
fn build_node_instances_emits_no_instance_for_a_hidden_node() {
let mut graph = DemoGraph::new();
graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(5.0, 0.0, 0.0)];
let mesh = unit_mesh();
let hidden: HashSet<NodeIndex> = [b].into_iter().collect();
let nodes = build_node_instances(&graph, &particles, &mesh, &hidden, DEFAULT_NODE_MATERIAL);
assert_eq!(nodes.len(), 1, "the hidden node must emit zero instances, the other node must still emit one");
assert_eq!(nodes[0].translation, Vec3::new(0.0, 0.0, 0.0), "the surviving instance must belong to node a, not the hidden node b");
}
#[test]
fn build_scene_lights_the_scene_so_lit_tints_are_not_ambient_only_black() {
let mut graph = DemoGraph::new();
graph.push_node((), "a", "x", 1.0);
let particles = vec![Particle::at3(0.0, 0.0, 0.0)];
let node_mesh = unit_mesh();
let edge_mesh = unit_edge_quad_mesh();
let scene = build_scene(&graph, &particles, &node_mesh, &edge_mesh, &HashSet::new(), &Graph3DLighting::default(), &Graph3DEdgeStyle::default());
assert_eq!(scene.nodes.len(), 1);
assert!(!scene.lights.is_empty());
}
#[test]
fn build_scene_hides_a_node_and_its_touching_edges_when_given_a_non_empty_hidden_set() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
graph.push_edge(a, b, 1.0, ());
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(5.0, 0.0, 0.0)];
let node_mesh = unit_mesh();
let edge_mesh = unit_edge_quad_mesh();
let hidden: HashSet<NodeIndex> = [b].into_iter().collect();
let scene = build_scene(&graph, &particles, &node_mesh, &edge_mesh, &hidden, &Graph3DLighting::default(), &Graph3DEdgeStyle::default());
assert_eq!(scene.nodes.len(), 1, "one surviving node sphere, zero edges (the edge touches the hidden node)");
}
#[test]
fn build_cluster_edge_instances_draws_one_edge_per_aggregated_outside_neighbor() {
let mut graph = DemoGraph::new();
let outside = graph.push_node((), "outside", "x", 4.0);
let mut members = Vec::new();
for i in 0..3 {
members.push(graph.push_node((), format!("m{i}"), "cluster", 4.0));
}
graph.push_edge(members[0], outside, 2.0, ());
graph.push_edge(outside, members[1], 3.0, ());
let mut particles = vec![Particle::at3(0.0, 0.0, 0.0); graph.node_count()];
particles[outside.index()] = Particle::at3(50.0, 0.0, 0.0);
let mut registry = ClusterRegistry::default();
let id = registry.define(&graph, members.clone()).expect("non-empty cluster");
assert!(registry.collapse_3d(id, &mut graph, &mut particles));
let mesh = unit_edge_quad_mesh();
let edges = build_cluster_edge_instances(&particles, &mesh, ®istry, CLUSTER_EDGE_TINT);
assert_eq!(edges.len(), 1, "both raw cross-cluster edges aggregate onto the same outside node, so exactly one synthetic edge is drawn");
assert_eq!(edges[0].color_tint, CLUSTER_EDGE_TINT);
}
fn line_node_at(mesh: &Arc<Mesh>, from: Vec3, to: Vec3) -> Node {
let delta = to - from;
let length = delta.length();
let dir = delta / length;
Node::new_line(mesh.clone()).with_translation(from).with_rotation(Quat::from_rotation_arc(Vec3::Y, dir)).with_scale(Vec3::new(1.0, length, 1.0))
}
#[test]
fn sort_line_nodes_back_to_front_orders_edges_farthest_first_from_the_eye() {
let mesh = unit_edge_quad_mesh();
let eye = Vec3::new(0.0, 0.0, 0.0);
let near = line_node_at(&mesh, Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 1.0, 0.0));
let mid = line_node_at(&mesh, Vec3::new(50.0, 0.0, 0.0), Vec3::new(50.0, 1.0, 0.0));
let far = line_node_at(&mesh, Vec3::new(100.0, 0.0, 0.0), Vec3::new(100.0, 1.0, 0.0));
let mut nodes = vec![near.clone(), far.clone(), mid.clone()];
sort_line_nodes_back_to_front(&mut nodes, eye);
let depths: Vec<f32> = nodes.iter().map(|n| line_node_depth_key(n, eye)).collect();
assert!(depths[0] > depths[1] && depths[1] > depths[2], "must be strictly farthest-first (back-to-front): {depths:?}");
assert_eq!(nodes[0].translation, far.translation);
assert_eq!(nodes[1].translation, mid.translation);
assert_eq!(nodes[2].translation, near.translation);
}
#[test]
fn sort_line_nodes_back_to_front_leaves_non_line_nodes_at_their_original_slots() {
let line_mesh = unit_edge_quad_mesh();
let node_mesh = unit_mesh();
let eye = Vec3::ZERO;
let sphere_a = Node::new_lit(node_mesh.clone()).with_translation(Vec3::new(1.0, 0.0, 0.0));
let sphere_b = Node::new_lit(node_mesh.clone()).with_translation(Vec3::new(2.0, 0.0, 0.0));
let near = line_node_at(&line_mesh, Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 1.0, 0.0));
let far = line_node_at(&line_mesh, Vec3::new(100.0, 0.0, 0.0), Vec3::new(100.0, 1.0, 0.0));
let mut nodes = vec![sphere_a.clone(), near.clone(), sphere_b.clone(), far.clone()];
sort_line_nodes_back_to_front(&mut nodes, eye);
assert_eq!(nodes[0].translation, sphere_a.translation, "slot 0 (a sphere) must be untouched");
assert_eq!(nodes[2].translation, sphere_b.translation, "slot 2 (a sphere) must be untouched");
assert_eq!(nodes[1].translation, far.translation);
assert_eq!(nodes[3].translation, near.translation);
}
#[test]
fn sort_line_nodes_back_to_front_is_a_no_op_for_zero_or_one_line_nodes() {
let mesh = unit_edge_quad_mesh();
let mut empty: Vec<Node> = Vec::new();
sort_line_nodes_back_to_front(&mut empty, Vec3::ZERO);
assert!(empty.is_empty());
let solo = line_node_at(&mesh, Vec3::new(5.0, 0.0, 0.0), Vec3::new(5.0, 1.0, 0.0));
let mut one = vec![solo.clone()];
sort_line_nodes_back_to_front(&mut one, Vec3::ZERO);
assert_eq!(one[0].translation, solo.translation);
}
#[test]
fn sort_line_nodes_back_to_front_orders_real_build_edge_instances_output() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 1.0);
let b = graph.push_node((), "b", "x", 1.0);
let c = graph.push_node((), "c", "x", 1.0);
let d = graph.push_node((), "d", "x", 1.0);
graph.push_edge(a, b, 1.0, ());
graph.push_edge(c, d, 1.0, ());
let particles = vec![
Particle::at3(200.0, 0.0, 0.0),
Particle::at3(200.0, 10.0, 0.0),
Particle::at3(5.0, 0.0, 0.0),
Particle::at3(5.0, 10.0, 0.0),
];
let mesh = unit_edge_quad_mesh();
let mut edges = build_edge_instances(&graph, &particles, &mesh, &HashSet::new(), &Graph3DEdgeStyle::default());
assert_eq!(edges.len(), 2);
let eye = Vec3::ZERO;
sort_line_nodes_back_to_front(&mut edges, eye);
let depths: Vec<f32> = edges.iter().map(|n| line_node_depth_key(n, eye)).collect();
assert!(depths[0] > depths[1], "the far a-b edge must sort before the near c-d edge: {depths:?}");
}
#[test]
fn forward_channel_byte_round_trips_through_linear_for_target_byte_at_every_level_boundary() {
for level in 0..GPU_PICK_ID_LEVELS {
let target = level_to_byte(level);
let x = linear_for_target_byte(target);
let observed = forward_channel_byte(x);
assert!(
observed.abs_diff(target) <= 1,
"level {level}: target byte {target}, bisection-derived x={x} forward-simulated back to {observed}"
);
}
}
#[test]
fn encode_decode_node_id_round_trips_through_this_modules_own_forward_model_across_the_index_range() {
let mut ids: Vec<u32> = vec![0, 1, 2, 10_000, MAX_GPU_PICKABLE_NODE_INDEX - 1, MAX_GPU_PICKABLE_NODE_INDEX];
ids.extend((0..=MAX_GPU_PICKABLE_NODE_INDEX).step_by(97));
for id in ids {
let tint = encode_node_id_tint(NodeIndex(id));
let rgba = [forward_channel_byte(tint[0]), forward_channel_byte(tint[1]), forward_channel_byte(tint[2]), 255];
let decoded = decode_node_id_pixel(rgba);
assert_eq!(decoded, NodeIndex(id), "round trip failed for id={id}, tint={tint:?}, rgba={rgba:?}");
}
}
#[test]
fn decode_gpu_pick_pixel_rejects_the_white_background_sentinel_and_out_of_bounds_indices() {
let white_rgba = [255u8, 255, 255, 255];
assert_eq!(decode_node_id_pixel(white_rgba).0, MAX_GPU_PICKABLE_NODE_INDEX, "a pure white readback must decode to the reserved sentinel index");
assert_eq!(decode_gpu_pick_pixel(white_rgba, 50), None, "the sentinel index must never be reported as a real pick, regardless of node_count");
let tint = encode_node_id_tint(NodeIndex(5));
let rgba = [forward_channel_byte(tint[0]), forward_channel_byte(tint[1]), forward_channel_byte(tint[2]), 255];
assert_eq!(decode_gpu_pick_pixel(rgba, 50), Some(NodeIndex(5)), "an in-bounds real id must decode through cleanly");
assert_eq!(decode_gpu_pick_pixel(rgba, 3), None, "the SAME bytes must be rejected once node_count no longer covers that index");
}
#[test]
fn build_id_pass_scene_emits_one_unlit_node_per_graph_node_tinted_by_its_encoded_id_with_a_white_clear_color() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 2.0);
let b = graph.push_node((), "b", "x", 2.0);
let particles = vec![Particle::at3(1.0, 2.0, 3.0), Particle::at3(-4.0, 0.0, 5.0)];
let id_pass_mesh = Arc::new(build_id_pass_mesh(4, 4));
let scene = build_id_pass_scene(&graph, &particles, &id_pass_mesh, &HashSet::new());
assert_eq!(scene.clear_color, [1.0, 1.0, 1.0, 1.0]);
assert_eq!(scene.nodes.len(), 2);
assert!(scene.nodes.iter().all(|n| !n.is_lit()), "the id-pass must use Unlit geometry, not the Lit/Phong pipeline");
assert_eq!(scene.nodes[0].color_tint, encode_node_id_tint(a));
assert_eq!(scene.nodes[0].translation, Vec3::new(1.0, 2.0, 3.0));
assert_eq!(scene.nodes[1].color_tint, encode_node_id_tint(b));
assert_eq!(scene.nodes[1].translation, Vec3::new(-4.0, 0.0, 5.0));
}
#[test]
fn build_id_pass_scene_emits_no_node_for_a_hidden_node() {
let mut graph = DemoGraph::new();
let a = graph.push_node((), "a", "x", 2.0);
let b = graph.push_node((), "b", "x", 2.0);
let particles = vec![Particle::at3(1.0, 2.0, 3.0), Particle::at3(-4.0, 0.0, 5.0)];
let id_pass_mesh = Arc::new(build_id_pass_mesh(4, 4));
let hidden: HashSet<NodeIndex> = [b].into_iter().collect();
let scene = build_id_pass_scene(&graph, &particles, &id_pass_mesh, &hidden);
assert_eq!(scene.nodes.len(), 1, "the hidden node must not emit an id-pass sphere");
assert_eq!(scene.nodes[0].color_tint, encode_node_id_tint(a), "the remaining visible node must still be present and correctly tinted");
}
#[test]
fn semantic_marker_does_not_change_index_encoded_id_pass_identity() {
let mut graph = DemoGraph::new();
let id = graph.push_node((), "warning", "x", 2.0);
graph.set_node_style(id, Some(NodeVisualStyle {
fill: Some("#ff0000".into()),
marker: Some(NodeMarker::Warning),
..NodeVisualStyle::default()
}));
let particles = vec![Particle::at3(0.0, 0.0, 0.0)];
let id_pass_mesh = Arc::new(build_id_pass_mesh(4, 4));
let scene = build_id_pass_scene(&graph, &particles, &id_pass_mesh, &HashSet::new());
assert_eq!(scene.nodes.len(), 1, "marker geometry must not add pick-pass identities");
assert_eq!(scene.nodes[0].color_tint, encode_node_id_tint(id));
assert_eq!(decode_node_id_pixel([
forward_channel_byte(scene.nodes[0].color_tint[0]),
forward_channel_byte(scene.nodes[0].color_tint[1]),
forward_channel_byte(scene.nodes[0].color_tint[2]),
255,
]), id);
}
#[test]
fn snap_to_nice_step_always_lands_on_a_1_2_5_ladder_rung_across_a_wide_magnitude_range() {
fn is_nice(step: f64) -> bool {
for k in -6..=6 {
let base = 10f64.powi(k);
for c in [1.0, 2.0, 5.0] {
if (step - c * base).abs() < base * 1e-6 {
return true;
}
}
}
false
}
let mut raw = 0.001_f64;
while raw <= 10_000.0 {
let step = snap_to_nice_step(raw);
assert!(is_nice(step), "raw={raw} produced a non-ladder step={step}");
raw *= 1.31;
}
}
#[test]
fn snap_to_nice_step_is_monotonic_non_decreasing_in_raw() {
let mut raw = 0.001_f64;
let mut previous = snap_to_nice_step(raw);
while raw <= 10_000.0 {
let step = snap_to_nice_step(raw);
assert!(step >= previous - 1e-9, "raw={raw}: step {step} regressed below previous {previous}");
previous = step;
raw *= 1.05;
}
}
#[test]
fn grid_step_for_scale_matches_the_snap_to_nice_step_of_the_target_px_formula() {
let distance = 500.0_f32;
let fov_y = 60_f32.to_radians();
let viewport_height_px = 900.0_f64;
let half_fov_tan = (fov_y * 0.5).tan() as f64;
let px_per_world_unit = (viewport_height_px * 0.5) / (distance as f64 * half_fov_tan);
let config = Graph3DGridConfig::default();
let expected = snap_to_nice_step(config.target_screen_px / px_per_world_unit);
assert_eq!(grid_step_for_scale(distance, fov_y, viewport_height_px, &config), expected);
}
#[test]
fn grid_step_for_scale_produces_apparent_spacing_within_the_target_band() {
let fov_y = 60_f32.to_radians();
let viewport_height_px = 900.0_f64;
let config = Graph3DGridConfig::default();
for distance in [10.0_f32, 100.0, 500.0, 5_000.0, 50_000.0] {
let step = grid_step_for_scale(distance, fov_y, viewport_height_px, &config);
let half_fov_tan = (fov_y * 0.5).tan() as f64;
let px_per_world_unit = (viewport_height_px * 0.5) / (distance as f64 * half_fov_tan);
let apparent_px = step * px_per_world_unit;
assert!(
apparent_px >= GRID_MIN_SCREEN_PX - 1e-6 && apparent_px <= GRID_MAX_SCREEN_PX + 1e-6,
"distance={distance} step={step} apparent_px={apparent_px} escaped [{GRID_MIN_SCREEN_PX}, {GRID_MAX_SCREEN_PX}]"
);
}
}
#[test]
fn grid_step_for_scale_shifts_with_a_caller_supplied_target_screen_px() {
let distance = 500.0_f32;
let fov_y = 60_f32.to_radians();
let viewport_height_px = 900.0_f64;
let default_step = grid_step_for_scale(distance, fov_y, viewport_height_px, &Graph3DGridConfig::default());
let wide_config = Graph3DGridConfig { target_screen_px: 400.0, ..Graph3DGridConfig::default() };
let wide_step = grid_step_for_scale(distance, fov_y, viewport_height_px, &wide_config);
assert!(wide_step > default_step, "a larger target_screen_px must produce a coarser (larger) step: default={default_step} wide={wide_step}");
}
#[test]
fn build_grid_plan_line_count_matches_x_and_z_tick_counts_for_a_known_aabb_and_step() {
let min = Vec3::new(-12.0, -3.0, -7.0);
let max = Vec3::new(22.0, 5.0, 18.0);
let step = 10.0;
let plan = build_grid_plan(min, max, step, &Graph3DGridConfig::default());
let expected_x_ticks = ((min.x as f64 / step).floor() as i64..=(max.x as f64 / step).ceil() as i64).count();
let expected_z_ticks = ((min.z as f64 / step).floor() as i64..=(max.z as f64 / step).ceil() as i64).count();
assert_eq!(plan.lines.len(), expected_x_ticks + expected_z_ticks);
assert_eq!(plan.step, step);
}
#[test]
fn build_grid_plan_marks_every_5th_tick_from_world_origin_as_strong() {
let config = Graph3DGridConfig::default();
let plan = build_grid_plan(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0), 10.0, &config);
assert!(plan.lines.iter().all(|l| l.strong), "tick index 0 (world origin) must always be a strong line");
let plan = build_grid_plan(Vec3::new(0.0, 0.0, -5.0), Vec3::new(50.0, 0.0, 5.0), 10.0, &config);
let x_strong: Vec<f64> = plan.lines.iter().filter(|l| l.strong && l.to.z != l.from.z).map(|l| l.tick_value).collect();
for value in &x_strong {
let index = (value / 10.0).round() as i64;
assert_eq!(index.rem_euclid(5), 0, "strong tick {value} must land on a multiple-of-5 index");
}
}
#[test]
fn build_grid_plan_honors_a_caller_supplied_strong_line_every() {
let config = Graph3DGridConfig { strong_line_every: 3, ..Graph3DGridConfig::default() };
let plan = build_grid_plan(Vec3::new(0.0, 0.0, -30.0), Vec3::new(0.0, 0.0, 30.0), 10.0, &config);
for line in &plan.lines {
let index = (line.tick_value / 10.0).round() as i64;
assert_eq!(line.strong, index.rem_euclid(3) == 0, "tick {} strong={} must follow strong_line_every=3", line.tick_value, line.strong);
}
}
#[test]
fn build_grid_instances_emits_one_node_per_planned_line_tinted_by_strong_alpha() {
let config = Graph3DGridConfig::default();
let plan = build_grid_plan(Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 0.0, 10.0), 10.0, &config);
let mesh = unit_edge_quad_mesh();
let nodes = build_grid_instances(&plan, &mesh, &config);
assert_eq!(nodes.len(), plan.lines.len());
for (node, line) in nodes.iter().zip(plan.lines.iter()) {
assert!(matches!(node.geometry, uzor_urx_3d::NodeMesh::Line(_)));
let expected_alpha = if line.strong { config.strong_line_alpha } else { config.line_alpha };
assert!((node.color_tint[3] - expected_alpha).abs() < 1e-6);
}
}
#[test]
fn build_grid_instances_uses_the_caller_supplied_tint_and_alpha() {
let config = Graph3DGridConfig::default();
let plan = build_grid_plan(Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 0.0, 10.0), 10.0, &config);
let mesh = unit_edge_quad_mesh();
let custom = Graph3DGridConfig { tint_rgb: [1.0, 0.0, 1.0], line_alpha: 0.9, strong_line_alpha: 0.99, ..Graph3DGridConfig::default() };
let nodes = build_grid_instances(&plan, &mesh, &custom);
for (node, line) in nodes.iter().zip(plan.lines.iter()) {
let expected_alpha = if line.strong { 0.99 } else { 0.9 };
assert_eq!(node.color_tint, [1.0, 0.0, 1.0, expected_alpha]);
}
}
#[test]
fn build_grid_instances_skips_a_degenerate_zero_length_line() {
let plan = GridPlan { lines: vec![GridLine { from: Vec3::ZERO, to: Vec3::ZERO, tick_value: 0.0, strong: true }], step: 1.0 };
let mesh = unit_edge_quad_mesh();
assert!(build_grid_instances(&plan, &mesh, &Graph3DGridConfig::default()).is_empty());
}
#[test]
fn format_tick_value_is_integer_at_or_above_a_step_of_one_and_decimal_below_it() {
assert_eq!(format_tick_value(42.0, 10.0), "42");
assert_eq!(format_tick_value(-3.0, 1.0), "-3");
assert_eq!(format_tick_value(1.5, 0.5), "1.5");
assert_eq!(format_tick_value(0.07, 0.05), "0.07");
}
}