use glam::{Vec2, Vec3, Mat3, Quat};
#[allow(unused_imports)]
use glam::FloatExt as _;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct NodeId(pub u32);
impl NodeId {
pub const INVALID: NodeId = NodeId(u32::MAX);
}
impl Default for NodeId {
fn default() -> Self { NodeId::INVALID }
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if *self == NodeId::INVALID { write!(f, "INVALID") } else { write!(f, "N{}", self.0) }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PortId(pub u8);
impl PortId {
pub const A: PortId = PortId(0);
pub const B: PortId = PortId(1);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Connection {
pub from: NodeId,
pub to: NodeId,
pub port: PortId,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PrimitiveKind {
Sphere { radius: f32 },
Capsule { half_height: f32, radius: f32 },
Box { half_extents: Vec3 },
Ellipsoid{ radii: Vec3 },
Torus { major: f32, minor: f32 },
Cylinder { half_height: f32, radius: f32 },
Cone { half_height: f32, half_angle_rad: f32 },
Plane { normal: Vec3, offset: f32 },
HexPrism { height: f32, radius: f32 },
RoundBox { half_extents: Vec3, rounding: f32 },
Link { length: f32, r1: f32, r2: f32 },
OctaHedron { size: f32 },
}
impl PrimitiveKind {
pub fn label(&self) -> &'static str {
match self {
PrimitiveKind::Sphere {..} => "Sphere",
PrimitiveKind::Capsule {..} => "Capsule",
PrimitiveKind::Box {..} => "Box",
PrimitiveKind::Ellipsoid {..} => "Ellipsoid",
PrimitiveKind::Torus {..} => "Torus",
PrimitiveKind::Cylinder {..} => "Cylinder",
PrimitiveKind::Cone {..} => "Cone",
PrimitiveKind::Plane {..} => "Plane",
PrimitiveKind::HexPrism {..} => "HexPrism",
PrimitiveKind::RoundBox {..} => "RoundBox",
PrimitiveKind::Link {..} => "Link",
PrimitiveKind::OctaHedron {..} => "Octahedron",
}
}
pub fn evaluate(&self, p: Vec3) -> f32 {
match self {
PrimitiveKind::Sphere { radius } => p.length() - radius,
PrimitiveKind::Capsule { half_height, radius } => {
let h = *half_height;
let py = p.y.clamp(-h, h);
(p - Vec3::new(0.0, py, 0.0)).length() - radius
}
PrimitiveKind::Box { half_extents } => {
let q = p.abs() - *half_extents;
q.max(Vec3::ZERO).length() + q.x.max(q.y).max(q.z).min(0.0)
}
PrimitiveKind::Ellipsoid { radii } => {
let k0 = (p / *radii).length();
let k1 = (p / (*radii * *radii)).length();
k0 * (k0 - 1.0) / k1
}
PrimitiveKind::Torus { major, minor } => {
let q = Vec2::new(Vec2::new(p.x, p.z).length() - major, p.y);
q.length() - minor
}
PrimitiveKind::Cylinder { half_height, radius } => {
let d = Vec2::new(Vec2::new(p.x, p.z).length(), p.y.abs()) - Vec2::new(*radius, *half_height);
d.x.max(d.y).min(0.0) + d.max(Vec2::ZERO).length()
}
PrimitiveKind::Cone { half_height, half_angle_rad } => {
let h = *half_height;
let q = Vec2::new(Vec2::new(p.x, p.z).length(), p.y);
let tip_to_p = q - Vec2::new(0.0, h);
let c = Vec2::new(half_angle_rad.sin(), half_angle_rad.cos());
let w = tip_to_p - c * (tip_to_p.dot(c).clamp(-2.0 * h, 0.0));
w.length() * (if w.x < 0.0 { -1.0 } else { 1.0 })
}
PrimitiveKind::Plane { normal, offset } => {
p.dot(*normal) + offset
}
PrimitiveKind::HexPrism { height, radius } => {
let k = Vec3::new(-0.866_025, 0.5, 0.577_350);
let mut q = p.abs();
let dot = (2.0 * k.x * q.x + 2.0 * k.y * q.y).min(0.0);
let qx = q.x - dot * 2.0 * k.x;
let qy = q.y - dot * 2.0 * k.y;
let d = Vec2::new(
(Vec2::new(qx, qy) - Vec2::new(qx.clamp(-k.z * radius, k.z * radius), *radius)).length() * if qy < *radius { -1.0 } else { 1.0 },
q.z - height,
);
d.x.max(d.y).min(0.0) + d.max(Vec2::ZERO).length()
}
PrimitiveKind::RoundBox { half_extents, rounding } => {
let q = p.abs() - *half_extents;
q.max(Vec3::ZERO).length() + q.x.max(q.y).max(q.z).min(0.0) - rounding
}
PrimitiveKind::Link { length, r1, r2 } => {
let q = Vec3::new(p.x, (p.y.abs() - length).max(0.0), p.z);
Vec2::new(Vec2::new(q.x, q.y).length() - r1, q.z).length() - r2
}
PrimitiveKind::OctaHedron { size } => {
let p = p.abs();
let m = p.x + p.y + p.z - size;
let q = if 3.0 * p.x < m {
p
} else if 3.0 * p.y < m {
Vec3::new(p.y, p.x, p.z)
} else if 3.0 * p.z < m {
Vec3::new(p.z, p.x, p.y)
} else {
return m * 0.577_350;
};
let k = (m / 2.0).clamp(0.0, size * 0.5);
Vec3::new(q.x - k, q.y - k, q.z + k - size).length()
}
}
}
pub fn emit_glsl(&self, p: &str) -> String {
match self {
PrimitiveKind::Sphere { radius } =>
format!("(length({p}) - {radius:.6})"),
PrimitiveKind::Capsule { half_height, radius } =>
format!("(length({p} - vec3(0,clamp({p}.y,{h:.6},{hh:.6}),0)) - {r:.6})",
p = p, h = -half_height, hh = *half_height, r = radius),
PrimitiveKind::Box { half_extents } =>
format!("({{ vec3 _q = abs({p}) - vec3({:.6},{:.6},{:.6}); \
length(max(_q,0.0)) + min(max(_q.x,max(_q.y,_q.z)),0.0); }})",
half_extents.x, half_extents.y, half_extents.z),
PrimitiveKind::Ellipsoid { radii } =>
format!("({{ vec3 _r = vec3({:.6},{:.6},{:.6}); \
float _k0 = length({p}/_r); \
float _k1 = length({p}/(_r*_r)); \
_k0*(_k0-1.0)/_k1; }})",
radii.x, radii.y, radii.z),
PrimitiveKind::Torus { major, minor } =>
format!("({{ vec2 _q = vec2(length({p}.xz)-{:.6},{p}.y); \
length(_q)-{:.6}; }})",
major, minor),
PrimitiveKind::Cylinder { half_height, radius } =>
format!("({{ vec2 _d = abs(vec2(length({p}.xz),{p}.y)) - vec2({:.6},{:.6}); \
min(max(_d.x,_d.y),0.0) + length(max(_d,0.0)); }})",
radius, half_height),
PrimitiveKind::Plane { normal, offset } =>
format!("(dot({p}, vec3({:.6},{:.6},{:.6})) + {:.6})",
normal.x, normal.y, normal.z, offset),
_ => format!("(length({p}) - 0.5)"), }
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum CombinatorKind {
Union,
Subtract,
Intersect,
SmoothUnion { k: f32 },
SmoothSubtract { k: f32 },
SmoothIntersect { k: f32 },
Blend { alpha: f32 },
Twist { strength: f32 },
Bend { strength: f32 },
Elongate { amount: Vec3 },
Onion { thickness: f32 },
Extrude { depth: f32 },
Revolve { offset: f32 },
Displace { amplitude: f32, frequency: f32 },
Mirror { axis_mask: [bool; 3] },
Scale { factor: f32 },
Round { radius: f32 },
}
impl CombinatorKind {
pub fn label(&self) -> &'static str {
match self {
CombinatorKind::Union => "Union",
CombinatorKind::Subtract => "Subtract",
CombinatorKind::Intersect => "Intersect",
CombinatorKind::SmoothUnion {..} => "SmoothUnion",
CombinatorKind::SmoothSubtract {..} => "SmoothSubtract",
CombinatorKind::SmoothIntersect{..} => "SmoothIntersect",
CombinatorKind::Blend {..} => "Blend",
CombinatorKind::Twist {..} => "Twist",
CombinatorKind::Bend {..} => "Bend",
CombinatorKind::Elongate {..} => "Elongate",
CombinatorKind::Onion {..} => "Onion",
CombinatorKind::Extrude {..} => "Extrude",
CombinatorKind::Revolve {..} => "Revolve",
CombinatorKind::Displace {..} => "Displace",
CombinatorKind::Mirror {..} => "Mirror",
CombinatorKind::Scale {..} => "Scale",
CombinatorKind::Round {..} => "Round",
}
}
pub fn combine(&self, a: f32, b: f32, p: Vec3) -> f32 {
match self {
CombinatorKind::Union => a.min(b),
CombinatorKind::Subtract => a.max(-b),
CombinatorKind::Intersect => a.max(b),
CombinatorKind::SmoothUnion { k } => {
let h = (0.5 + 0.5 * (b - a) / k).clamp(0.0, 1.0);
a.lerp(b, h) - k * h * (1.0 - h)
}
CombinatorKind::SmoothSubtract { k } => {
let h = (0.5 - 0.5 * (b + a) / k).clamp(0.0, 1.0);
a.lerp(-b, h) + k * h * (1.0 - h)
}
CombinatorKind::SmoothIntersect { k } => {
let h = (0.5 - 0.5 * (b - a) / k).clamp(0.0, 1.0);
a.lerp(b, h) + k * h * (1.0 - h)
}
CombinatorKind::Blend { alpha } => a * (1.0 - alpha) + b * alpha,
CombinatorKind::Twist { strength } => {
let c = (strength * p.y).cos();
let s = (strength * p.y).sin();
let twisted_x = c * p.x - s * p.z;
let twisted_z = s * p.x + c * p.z;
let _twisted_p = Vec3::new(twisted_x, p.y, twisted_z);
a }
CombinatorKind::Round { radius } => a - radius,
CombinatorKind::Onion { thickness } => a.abs() - thickness,
CombinatorKind::Scale { factor } => a * factor,
_ => a.min(b),
}
}
pub fn is_binary(&self) -> bool {
!matches!(self,
CombinatorKind::Twist {..} |
CombinatorKind::Bend {..} |
CombinatorKind::Elongate {..} |
CombinatorKind::Onion {..} |
CombinatorKind::Extrude {..} |
CombinatorKind::Revolve {..} |
CombinatorKind::Displace {..} |
CombinatorKind::Mirror {..} |
CombinatorKind::Scale {..} |
CombinatorKind::Round {..}
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NodeTransform {
pub translation: Vec3,
pub rotation: Quat,
pub scale: Vec3,
}
impl Default for NodeTransform {
fn default() -> Self {
Self {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
}
}
}
impl NodeTransform {
pub fn world_to_local(&self, world_p: Vec3) -> Vec3 {
let p = world_p - self.translation;
let p = self.rotation.inverse().mul_vec3(p);
p / self.scale
}
pub fn display(&self) -> String {
let (ax, ay, az) = self.rotation.to_euler(glam::EulerRot::XYZ);
format!(
"T ({:.3},{:.3},{:.3}) R ({:.1}°,{:.1}°,{:.1}°) S ({:.3},{:.3},{:.3})",
self.translation.x, self.translation.y, self.translation.z,
ax.to_degrees(), ay.to_degrees(), az.to_degrees(),
self.scale.x, self.scale.y, self.scale.z
)
}
}
#[derive(Debug, Clone)]
pub struct SdfNode {
pub id: NodeId,
pub label: String,
pub canvas_pos: Vec2,
pub transform: NodeTransform,
pub visible: bool,
pub locked: bool,
pub material_id: Option<u32>,
pub payload: NodePayload,
}
#[derive(Debug, Clone)]
pub enum NodePayload {
Primitive(PrimitiveKind),
Combinator(CombinatorKind),
Constant(f32),
Reference { graph_name: String },
}
impl SdfNode {
pub fn new_primitive(id: NodeId, kind: PrimitiveKind) -> Self {
let label = kind.label().to_string();
Self {
id,
label,
canvas_pos: Vec2::ZERO,
transform: NodeTransform::default(),
visible: true,
locked: false,
material_id: None,
payload: NodePayload::Primitive(kind),
}
}
pub fn new_combinator(id: NodeId, kind: CombinatorKind) -> Self {
let label = kind.label().to_string();
Self {
id,
label,
canvas_pos: Vec2::ZERO,
transform: NodeTransform::default(),
visible: true,
locked: false,
material_id: None,
payload: NodePayload::Combinator(kind),
}
}
pub fn accepts_input(&self, port: PortId) -> bool {
match &self.payload {
NodePayload::Primitive(_) => false,
NodePayload::Constant(_) => false,
NodePayload::Reference {..} => false,
NodePayload::Combinator(c) => {
if port == PortId::A { true }
else { c.is_binary() }
}
}
}
pub fn header_text(&self) -> String {
match &self.payload {
NodePayload::Primitive(p) => format!("[{}] {}", p.label(), self.label),
NodePayload::Combinator(c) => format!("[{}] {}", c.label(), self.label),
NodePayload::Constant(v) => format!("[Const] {:.3}", v),
NodePayload::Reference { graph_name } => format!("[Ref] {}", graph_name),
}
}
pub fn output_port_pos(&self) -> Vec2 {
self.canvas_pos + Vec2::new(160.0, 30.0)
}
pub fn input_port_pos_a(&self) -> Vec2 {
self.canvas_pos + Vec2::new(0.0, 20.0)
}
pub fn input_port_pos_b(&self) -> Vec2 {
self.canvas_pos + Vec2::new(0.0, 40.0)
}
}
type EvalCache = HashMap<NodeId, f32>;
#[derive(Debug, Clone)]
pub struct NodeGraph {
nodes: HashMap<NodeId, SdfNode>,
connections: Vec<Connection>,
next_id: u32,
pub root: NodeId,
pub dirty: bool,
pub name: String,
}
impl NodeGraph {
pub fn new(name: impl Into<String>) -> Self {
Self {
nodes: HashMap::new(),
connections: Vec::new(),
next_id: 1,
root: NodeId::INVALID,
dirty: false,
name: name.into(),
}
}
fn alloc_id(&mut self) -> NodeId {
let id = NodeId(self.next_id);
self.next_id += 1;
id
}
pub fn add_primitive(&mut self, kind: PrimitiveKind) -> NodeId {
let id = self.alloc_id();
let node = SdfNode::new_primitive(id, kind);
self.nodes.insert(id, node);
self.dirty = true;
id
}
pub fn add_combinator(&mut self, kind: CombinatorKind) -> NodeId {
let id = self.alloc_id();
let node = SdfNode::new_combinator(id, kind);
self.nodes.insert(id, node);
self.dirty = true;
id
}
pub fn remove_node(&mut self, id: NodeId) -> Option<SdfNode> {
let node = self.nodes.remove(&id)?;
self.connections.retain(|c| c.from != id && c.to != id);
if self.root == id { self.root = NodeId::INVALID; }
self.dirty = true;
Some(node)
}
pub fn get(&self, id: NodeId) -> Option<&SdfNode> {
self.nodes.get(&id)
}
pub fn get_mut(&mut self, id: NodeId) -> Option<&mut SdfNode> {
self.dirty = true;
self.nodes.get_mut(&id)
}
pub fn nodes(&self) -> impl Iterator<Item = &SdfNode> {
self.nodes.values()
}
pub fn node_count(&self) -> usize { self.nodes.len() }
pub fn connect(&mut self, from: NodeId, to: NodeId, port: PortId) -> Result<(), String> {
let to_node = self.nodes.get(&to)
.ok_or_else(|| format!("node {} not found", to))?;
if !to_node.accepts_input(port) {
return Err(format!("node {} does not accept input on port {:?}", to, port));
}
if !self.nodes.contains_key(&from) {
return Err(format!("source node {} not found", from));
}
if self.would_create_cycle(from, to) {
return Err(format!("connecting {} → {} would create a cycle", from, to));
}
self.connections.retain(|c| !(c.to == to && c.port == port));
self.connections.push(Connection { from, to, port });
self.dirty = true;
Ok(())
}
pub fn disconnect(&mut self, to: NodeId, port: PortId) {
self.connections.retain(|c| !(c.to == to && c.port == port));
self.dirty = true;
}
pub fn connections(&self) -> &[Connection] { &self.connections }
pub fn input_of(&self, to: NodeId, port: PortId) -> Option<NodeId> {
self.connections.iter()
.find(|c| c.to == to && c.port == port)
.map(|c| c.from)
}
fn would_create_cycle(&self, from: NodeId, to: NodeId) -> bool {
let mut visited = Vec::new();
self.dfs_upstream(to, &mut visited);
visited.contains(&from)
}
fn dfs_upstream(&self, node: NodeId, visited: &mut Vec<NodeId>) {
if visited.contains(&node) { return; }
visited.push(node);
for c in &self.connections {
if c.from == node {
self.dfs_upstream(c.to, visited);
}
}
}
pub fn topo_sort(&self) -> Vec<NodeId> {
let mut result = Vec::new();
let mut visited = std::collections::HashSet::new();
for &id in self.nodes.keys() {
self.topo_visit(id, &mut visited, &mut result);
}
result
}
fn topo_visit(&self, id: NodeId, visited: &mut std::collections::HashSet<NodeId>, result: &mut Vec<NodeId>) {
if visited.contains(&id) { return; }
visited.insert(id);
for c in &self.connections {
if c.to == id {
self.topo_visit(c.from, visited, result);
}
}
result.push(id);
}
pub fn evaluate_at(&self, node: NodeId, p: Vec3, cache: &mut EvalCache) -> f32 {
if let Some(&cached) = cache.get(&node) { return cached; }
let Some(n) = self.nodes.get(&node) else { return f32::MAX; };
if !n.visible { return f32::MAX; }
let local_p = n.transform.world_to_local(p);
let value = match &n.payload {
NodePayload::Constant(v) => *v,
NodePayload::Primitive(prim) => prim.evaluate(local_p),
NodePayload::Reference { .. } => f32::MAX,
NodePayload::Combinator(comb) => {
let a_id = self.input_of(node, PortId::A);
let b_id = self.input_of(node, PortId::B);
let a = a_id.map(|id| self.evaluate_at(id, p, cache)).unwrap_or(f32::MAX);
let b = b_id.map(|id| self.evaluate_at(id, p, cache)).unwrap_or(f32::MAX);
comb.combine(a, b, local_p)
}
};
cache.insert(node, value);
value
}
pub fn sample(&self, p: Vec3) -> f32 {
if self.root == NodeId::INVALID { return f32::MAX; }
let mut cache = EvalCache::new();
self.evaluate_at(self.root, p, &mut cache)
}
pub fn gradient(&self, p: Vec3) -> Vec3 {
const EPS: f32 = 0.001;
let dx = self.sample(p + Vec3::X * EPS) - self.sample(p - Vec3::X * EPS);
let dy = self.sample(p + Vec3::Y * EPS) - self.sample(p - Vec3::Y * EPS);
let dz = self.sample(p + Vec3::Z * EPS) - self.sample(p - Vec3::Z * EPS);
Vec3::new(dx, dy, dz).normalize_or_zero()
}
pub fn compile_glsl(&self) -> String {
if self.root == NodeId::INVALID {
return "float sdf_body(vec3 p) { return 1e10; }".to_string();
}
let order = self.topo_sort();
let mut lines = Vec::new();
lines.push("float sdf_body(vec3 p) {".to_string());
for &nid in &order {
let Some(node) = self.nodes.get(&nid) else { continue; };
if !node.visible { continue; }
let var = format!("_d{}", nid.0);
let t = &node.transform;
let tp = if t.translation != Vec3::ZERO || t.rotation != Quat::IDENTITY || t.scale != Vec3::ONE {
let tname = format!("_p{}", nid.0);
let tx = t.translation.x; let ty = t.translation.y; let tz = t.translation.z;
let sx = t.scale.x; let sy = t.scale.y; let sz = t.scale.z;
lines.push(format!(" vec3 {tname} = (p - vec3({tx:.6},{ty:.6},{tz:.6})) / vec3({sx:.6},{sy:.6},{sz:.6});"));
tname
} else {
"p".to_string()
};
let expr = match &node.payload {
NodePayload::Constant(v) => format!("{v:.6}"),
NodePayload::Primitive(prim) => prim.emit_glsl(&tp),
NodePayload::Reference { .. } => "1e10".to_string(),
NodePayload::Combinator(c) => {
let a_var = self.input_of(nid, PortId::A)
.map(|id| format!("_d{}", id.0))
.unwrap_or_else(|| "1e10".to_string());
let b_var = self.input_of(nid, PortId::B)
.map(|id| format!("_d{}", id.0))
.unwrap_or_else(|| "1e10".to_string());
emit_combinator_glsl(c, &a_var, &b_var, &tp)
}
};
lines.push(format!(" float {var} = {expr};"));
}
let root_var = format!("_d{}", self.root.0);
lines.push(format!(" return {root_var};"));
lines.push("}".to_string());
lines.join("\n")
}
pub fn auto_layout(&mut self) {
let order = self.topo_sort();
let total = order.len();
for (i, &id) in order.iter().enumerate() {
if let Some(node) = self.nodes.get_mut(&id) {
let col = i as f32;
let row = (i % 3) as f32;
node.canvas_pos = Vec2::new(col * 220.0, row * 120.0 - total as f32 * 20.0);
}
}
}
pub fn to_text(&self) -> String {
let mut out = format!("# NodeGraph: {}\n", self.name);
out.push_str(&format!("root: {}\n", self.root));
out.push_str("nodes:\n");
let mut sorted: Vec<_> = self.nodes.keys().cloned().collect();
sorted.sort();
for id in sorted {
let n = &self.nodes[&id];
out.push_str(&format!(" {} {} [{}]\n", id, n.label, n.header_text()));
}
out.push_str("connections:\n");
for c in &self.connections {
out.push_str(&format!(" {} -> {}:{}\n", c.from, c.to, c.port.0));
}
out
}
pub fn stats(&self) -> NodeGraphStats {
let prim_count = self.nodes.values()
.filter(|n| matches!(n.payload, NodePayload::Primitive(_)))
.count();
let comb_count = self.nodes.values()
.filter(|n| matches!(n.payload, NodePayload::Combinator(_)))
.count();
let smooth_ops = self.nodes.values()
.filter(|n| matches!(&n.payload, NodePayload::Combinator(c) if
matches!(c, CombinatorKind::SmoothUnion{..} | CombinatorKind::SmoothSubtract{..} | CombinatorKind::SmoothIntersect{..})
))
.count();
NodeGraphStats {
total_nodes: self.nodes.len(),
prim_count,
comb_count,
smooth_ops,
connections: self.connections.len(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NodeGraphStats {
pub total_nodes: usize,
pub prim_count: usize,
pub comb_count: usize,
pub smooth_ops: usize,
pub connections: usize,
}
impl std::fmt::Display for NodeGraphStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} nodes ({} prim, {} comb, {} smooth, {} edges)",
self.total_nodes, self.prim_count, self.comb_count,
self.smooth_ops, self.connections)
}
}
fn emit_combinator_glsl(c: &CombinatorKind, a: &str, b: &str, _p: &str) -> String {
match c {
CombinatorKind::Union => format!("min({a},{b})"),
CombinatorKind::Subtract => format!("max({a},-{b})"),
CombinatorKind::Intersect => format!("max({a},{b})"),
CombinatorKind::SmoothUnion { k } => format!(
"({{ float _h = clamp(0.5+0.5*({b}-{a})/{k:.6},0.0,1.0); \
mix({a},{b},_h) - {k:.6}*_h*(1.0-_h); }})", k=k),
CombinatorKind::SmoothSubtract { k } => format!(
"({{ float _h = clamp(0.5-0.5*({b}+{a})/{k:.6},0.0,1.0); \
mix({a},-{b},_h) + {k:.6}*_h*(1.0-_h); }})", k=k),
CombinatorKind::SmoothIntersect { k } => format!(
"({{ float _h = clamp(0.5-0.5*({b}-{a})/{k:.6},0.0,1.0); \
mix({a},{b},_h) + {k:.6}*_h*(1.0-_h); }})", k=k),
CombinatorKind::Blend { alpha } => format!("mix({a},{b},{alpha:.6})"),
CombinatorKind::Round { radius } => format!("({a} - {radius:.6})"),
CombinatorKind::Onion { thickness } => format!("(abs({a}) - {thickness:.6})"),
CombinatorKind::Scale { factor } => format!("({a} * {factor:.6})"),
_ => format!("min({a},{b})"),
}
}
#[derive(Debug, Clone, Default)]
pub struct NodeSelection {
pub selected: Vec<NodeId>,
pub hovered: Option<NodeId>,
pub drag: Option<(NodeId, Vec2)>,
pub wiring: Option<(NodeId, Vec2)>,
}
impl NodeSelection {
pub fn is_selected(&self, id: NodeId) -> bool { self.selected.contains(&id) }
pub fn select_only(&mut self, id: NodeId) { self.selected.clear(); self.selected.push(id); }
pub fn toggle_select(&mut self, id: NodeId) {
if let Some(pos) = self.selected.iter().position(|&x| x == id) {
self.selected.remove(pos);
} else {
self.selected.push(id);
}
}
pub fn clear(&mut self) { self.selected.clear(); }
}
#[derive(Debug, Clone)]
pub struct NodeClipboard {
pub nodes: Vec<SdfNode>,
pub connections: Vec<Connection>,
}
#[derive(Debug, Clone)]
pub enum NodeEdit {
AddNode { id: NodeId },
RemoveNode { node: SdfNode, connections: Vec<Connection> },
Connect { connection: Connection, replaced: Option<Connection> },
Disconnect { connection: Connection },
MoveNode { id: NodeId, old_pos: Vec2, new_pos: Vec2 },
EditPrimitive { id: NodeId, old: PrimitiveKind, new_kind: PrimitiveKind },
EditCombinator{ id: NodeId, old: CombinatorKind, new_kind: CombinatorKind },
SetRoot { old: NodeId, new_root: NodeId },
Batch { edits: Vec<NodeEdit> },
}
#[derive(Debug)]
pub struct SdfNodeEditor {
pub graph: NodeGraph,
pub selection: NodeSelection,
pub clipboard: Option<NodeClipboard>,
undo_stack: Vec<NodeEdit>,
redo_stack: Vec<NodeEdit>,
pub pan: Vec2,
pub zoom: f32,
pub show_glsl: bool,
glsl_cache: Option<String>,
pub snap_grid: bool,
pub grid_size: f32,
}
impl SdfNodeEditor {
pub fn new(graph_name: impl Into<String>) -> Self {
Self {
graph: NodeGraph::new(graph_name),
selection: NodeSelection::default(),
clipboard: None,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
pan: Vec2::ZERO,
zoom: 1.0,
show_glsl: false,
glsl_cache: None,
snap_grid: true,
grid_size: 20.0,
}
}
pub fn add_primitive(&mut self, kind: PrimitiveKind) -> NodeId {
let id = self.graph.add_primitive(kind);
self.undo_stack.push(NodeEdit::AddNode { id });
self.redo_stack.clear();
self.invalidate_glsl();
id
}
pub fn add_combinator(&mut self, kind: CombinatorKind) -> NodeId {
let id = self.graph.add_combinator(kind);
self.undo_stack.push(NodeEdit::AddNode { id });
self.redo_stack.clear();
self.invalidate_glsl();
id
}
pub fn remove_selected(&mut self) {
let ids: Vec<_> = self.selection.selected.drain(..).collect();
let mut batch = Vec::new();
for id in ids {
let conns: Vec<_> = self.graph.connections()
.iter().filter(|c| c.from == id || c.to == id).cloned().collect();
if let Some(node) = self.graph.remove_node(id) {
batch.push(NodeEdit::RemoveNode { node, connections: conns });
}
}
if !batch.is_empty() {
self.undo_stack.push(NodeEdit::Batch { edits: batch });
self.redo_stack.clear();
self.invalidate_glsl();
}
}
pub fn connect(&mut self, from: NodeId, to: NodeId, port: PortId) {
let replaced = self.graph.connections().iter()
.find(|c| c.to == to && c.port == port).cloned();
if let Ok(()) = self.graph.connect(from, to, port) {
let conn = Connection { from, to, port };
self.undo_stack.push(NodeEdit::Connect { connection: conn, replaced });
self.redo_stack.clear();
self.invalidate_glsl();
}
}
pub fn set_root(&mut self, id: NodeId) {
let old = self.graph.root;
self.graph.root = id;
self.undo_stack.push(NodeEdit::SetRoot { old, new_root: id });
self.redo_stack.clear();
self.invalidate_glsl();
}
pub fn undo(&mut self) {
if let Some(edit) = self.undo_stack.pop() {
self.redo_stack.push(edit);
self.graph.dirty = true;
self.invalidate_glsl();
}
}
pub fn redo(&mut self) {
if let Some(edit) = self.redo_stack.pop() {
self.undo_stack.push(edit);
self.graph.dirty = true;
self.invalidate_glsl();
}
}
pub fn copy_selected(&mut self) {
let nodes: Vec<_> = self.selection.selected.iter()
.filter_map(|&id| self.graph.get(id).cloned())
.collect();
let sel_set: std::collections::HashSet<NodeId> = self.selection.selected.iter().cloned().collect();
let connections: Vec<_> = self.graph.connections().iter()
.filter(|c| sel_set.contains(&c.from) && sel_set.contains(&c.to))
.cloned()
.collect();
self.clipboard = Some(NodeClipboard { nodes, connections });
}
pub fn paste(&mut self) {
let Some(cb) = self.clipboard.clone() else { return; };
let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
for mut node in cb.nodes {
let old_id = node.id;
node.canvas_pos += Vec2::new(30.0, 30.0); let new_id = self.graph.alloc_id();
node.id = new_id;
id_map.insert(old_id, new_id);
self.graph.nodes.insert(new_id, node);
}
for conn in cb.connections {
if let (Some(&nf), Some(&nt)) = (id_map.get(&conn.from), id_map.get(&conn.to)) {
let _ = self.graph.connect(nf, nt, conn.port);
}
}
self.graph.dirty = true;
self.invalidate_glsl();
}
pub fn canvas_to_world(&self, canvas_p: Vec2) -> Vec2 {
(canvas_p - self.pan) / self.zoom
}
pub fn world_to_canvas(&self, world_p: Vec2) -> Vec2 {
world_p * self.zoom + self.pan
}
pub fn zoom_at(&mut self, canvas_p: Vec2, delta: f32) {
let world_before = self.canvas_to_world(canvas_p);
self.zoom = (self.zoom * (1.0 + delta * 0.1)).clamp(0.1, 10.0);
let world_after = self.canvas_to_world(canvas_p);
self.pan += (world_after - world_before) * self.zoom;
}
pub fn snap(&self, pos: Vec2) -> Vec2 {
if !self.snap_grid { return pos; }
let g = self.grid_size;
Vec2::new((pos.x / g).round() * g, (pos.y / g).round() * g)
}
pub fn begin_drag(&mut self, node: NodeId, cursor: Vec2) {
if let Some(n) = self.graph.get(node) {
let offset = cursor - n.canvas_pos;
self.selection.drag = Some((node, offset));
}
}
pub fn update_drag(&mut self, cursor: Vec2) {
if let Some((id, offset)) = self.selection.drag {
let raw = cursor - offset;
let snapped = self.snap(raw);
if let Some(n) = self.graph.get_mut(id) {
n.canvas_pos = snapped;
}
}
}
pub fn end_drag(&mut self) {
if let Some((id, _)) = self.selection.drag.take() {
if let Some(n) = self.graph.get(id) {
let pos = n.canvas_pos;
self.undo_stack.push(NodeEdit::MoveNode {
id, old_pos: pos, new_pos: pos,
});
}
}
}
fn invalidate_glsl(&mut self) { self.glsl_cache = None; }
pub fn glsl_output(&mut self) -> &str {
if self.glsl_cache.is_none() {
self.glsl_cache = Some(self.graph.compile_glsl());
}
self.glsl_cache.as_deref().unwrap()
}
pub fn status_line(&self) -> String {
let stats = self.graph.stats();
format!(
"SDF Editor — {} | pan ({:.0},{:.0}) zoom {:.2}× | {} sel | {}",
self.graph.name,
self.pan.x, self.pan.y, self.zoom,
self.selection.selected.len(),
stats
)
}
pub fn palette_labels() -> Vec<(&'static str, &'static str)> {
vec![
("Sphere", "prim"),
("Capsule", "prim"),
("Box", "prim"),
("RoundBox", "prim"),
("Ellipsoid", "prim"),
("Torus", "prim"),
("Cylinder", "prim"),
("Cone", "prim"),
("Plane", "prim"),
("HexPrism", "prim"),
("OctaHedron", "prim"),
("SmoothUnion", "comb"),
("SmoothSubtract", "comb"),
("SmoothIntersect", "comb"),
("Union", "comb"),
("Subtract", "comb"),
("Intersect", "comb"),
("Blend", "comb"),
("Twist", "comb"),
("Bend", "comb"),
("Elongate", "comb"),
("Onion", "comb"),
("Extrude", "comb"),
("Revolve", "comb"),
("Displace", "comb"),
("Mirror", "comb"),
("Scale", "comb"),
("Round", "comb"),
]
}
}
impl SdfNodeEditor {
pub fn default_body_graph() -> Self {
let mut ed = SdfNodeEditor::new("body_scaffold");
let g = &mut ed.graph;
let torso = g.add_primitive(PrimitiveKind::Ellipsoid {
radii: Vec3::new(0.25, 0.38, 0.18),
});
g.get_mut(torso).unwrap().label = "Torso".into();
g.get_mut(torso).unwrap().canvas_pos = Vec2::new(400.0, 200.0);
let head = g.add_primitive(PrimitiveKind::Sphere { radius: 0.13 });
g.get_mut(head).unwrap().label = "Head".into();
g.get_mut(head).unwrap().canvas_pos = Vec2::new(400.0, 40.0);
g.get_mut(head).unwrap().transform.translation = Vec3::new(0.0, -0.60, 0.0);
let neck = g.add_primitive(PrimitiveKind::Capsule { half_height: 0.06, radius: 0.055 });
g.get_mut(neck).unwrap().label = "Neck".into();
g.get_mut(neck).unwrap().canvas_pos = Vec2::new(400.0, 120.0);
g.get_mut(neck).unwrap().transform.translation = Vec3::new(0.0, -0.48, 0.0);
let arm_l = g.add_primitive(PrimitiveKind::Capsule { half_height: 0.14, radius: 0.07 });
g.get_mut(arm_l).unwrap().label = "ArmL".into();
g.get_mut(arm_l).unwrap().canvas_pos = Vec2::new(200.0, 200.0);
g.get_mut(arm_l).unwrap().transform.translation = Vec3::new(-0.35, -0.32, 0.0);
let arm_r = g.add_primitive(PrimitiveKind::Capsule { half_height: 0.14, radius: 0.07 });
g.get_mut(arm_r).unwrap().label = "ArmR".into();
g.get_mut(arm_r).unwrap().canvas_pos = Vec2::new(600.0, 200.0);
g.get_mut(arm_r).unwrap().transform.translation = Vec3::new( 0.35, -0.32, 0.0);
let thigh_l = g.add_primitive(PrimitiveKind::Capsule { half_height: 0.20, radius: 0.10 });
g.get_mut(thigh_l).unwrap().label = "ThighL".into();
g.get_mut(thigh_l).unwrap().canvas_pos = Vec2::new(300.0, 400.0);
g.get_mut(thigh_l).unwrap().transform.translation = Vec3::new(-0.13, 0.25, 0.0);
let thigh_r = g.add_primitive(PrimitiveKind::Capsule { half_height: 0.20, radius: 0.10 });
g.get_mut(thigh_r).unwrap().label = "ThighR".into();
g.get_mut(thigh_r).unwrap().canvas_pos = Vec2::new(500.0, 400.0);
g.get_mut(thigh_r).unwrap().transform.translation = Vec3::new( 0.13, 0.25, 0.0);
let shin_l = g.add_primitive(PrimitiveKind::Capsule { half_height: 0.18, radius: 0.075 });
g.get_mut(shin_l).unwrap().label = "ShinL".into();
g.get_mut(shin_l).unwrap().canvas_pos = Vec2::new(300.0, 560.0);
g.get_mut(shin_l).unwrap().transform.translation = Vec3::new(-0.14, 0.60, 0.0);
let shin_r = g.add_primitive(PrimitiveKind::Capsule { half_height: 0.18, radius: 0.075 });
g.get_mut(shin_r).unwrap().label = "ShinR".into();
g.get_mut(shin_r).unwrap().canvas_pos = Vec2::new(500.0, 560.0);
g.get_mut(shin_r).unwrap().transform.translation = Vec3::new( 0.14, 0.60, 0.0);
let k = 0.08;
let merge_legs_l = g.add_combinator(CombinatorKind::SmoothUnion { k });
g.get_mut(merge_legs_l).unwrap().label = "LegL".into();
g.get_mut(merge_legs_l).unwrap().canvas_pos = Vec2::new(300.0, 480.0);
let merge_legs_r = g.add_combinator(CombinatorKind::SmoothUnion { k });
g.get_mut(merge_legs_r).unwrap().label = "LegR".into();
g.get_mut(merge_legs_r).unwrap().canvas_pos = Vec2::new(500.0, 480.0);
let merge_both_legs = g.add_combinator(CombinatorKind::SmoothUnion { k });
g.get_mut(merge_both_legs).unwrap().label = "Legs".into();
g.get_mut(merge_both_legs).unwrap().canvas_pos = Vec2::new(400.0, 560.0);
let merge_arms = g.add_combinator(CombinatorKind::SmoothUnion { k });
g.get_mut(merge_arms).unwrap().label = "Arms".into();
g.get_mut(merge_arms).unwrap().canvas_pos = Vec2::new(400.0, 200.0);
let merge_upper = g.add_combinator(CombinatorKind::SmoothUnion { k: 0.06 });
g.get_mut(merge_upper).unwrap().label = "Upper".into();
g.get_mut(merge_upper).unwrap().canvas_pos = Vec2::new(400.0, 280.0);
let root = g.add_combinator(CombinatorKind::SmoothUnion { k: 0.10 });
g.get_mut(root).unwrap().label = "Root".into();
g.get_mut(root).unwrap().canvas_pos = Vec2::new(400.0, 660.0);
let _ = g.connect(thigh_l, merge_legs_l, PortId::A);
let _ = g.connect(shin_l, merge_legs_l, PortId::B);
let _ = g.connect(thigh_r, merge_legs_r, PortId::A);
let _ = g.connect(shin_r, merge_legs_r, PortId::B);
let _ = g.connect(merge_legs_l, merge_both_legs, PortId::A);
let _ = g.connect(merge_legs_r, merge_both_legs, PortId::B);
let _ = g.connect(arm_l, merge_arms, PortId::A);
let _ = g.connect(arm_r, merge_arms, PortId::B);
let _ = g.connect(torso, merge_upper, PortId::A);
let _ = g.connect(merge_arms, merge_upper, PortId::B);
let _ = g.connect(merge_upper, root, PortId::A);
let _ = g.connect(merge_both_legs, root, PortId::B);
g.root = root;
let merge_head = g.add_combinator(CombinatorKind::SmoothUnion { k: 0.05 });
g.get_mut(merge_head).unwrap().label = "HeadNeck".into();
g.get_mut(merge_head).unwrap().canvas_pos = Vec2::new(400.0, 80.0);
let _ = g.connect(head, merge_head, PortId::A);
let _ = g.connect(neck, merge_head, PortId::B);
let merge_full_upper = g.add_combinator(CombinatorKind::SmoothUnion { k: 0.06 });
g.get_mut(merge_full_upper).unwrap().label = "FullUpper".into();
g.get_mut(merge_full_upper).unwrap().canvas_pos = Vec2::new(400.0, 160.0);
let _ = g.connect(merge_head, merge_full_upper, PortId::A);
let _ = g.connect(merge_upper, merge_full_upper, PortId::B);
g.connections.retain(|c| !(c.to == root && c.port == PortId::A));
let _ = g.connect(merge_full_upper, root, PortId::A);
ed.graph.dirty = false;
ed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sphere_sdf() {
let prim = PrimitiveKind::Sphere { radius: 1.0 };
assert!((prim.evaluate(Vec3::ZERO) - (-1.0)).abs() < 1e-5);
assert!((prim.evaluate(Vec3::X) - 0.0).abs() < 1e-5);
assert!((prim.evaluate(Vec3::X * 2.0) - 1.0).abs() < 1e-5);
}
#[test]
fn smooth_union_combines() {
let c = CombinatorKind::SmoothUnion { k: 0.1 };
let d = c.combine(0.5, 0.5, Vec3::ZERO);
assert!(d <= 0.5);
}
#[test]
fn graph_sample_sphere() {
let mut ed = SdfNodeEditor::new("test");
let id = ed.add_primitive(PrimitiveKind::Sphere { radius: 1.0 });
ed.set_root(id);
assert!((ed.graph.sample(Vec3::ZERO) + 1.0).abs() < 1e-4);
}
#[test]
fn cycle_detection() {
let mut g = NodeGraph::new("cycle_test");
let a = g.add_combinator(CombinatorKind::Union);
let b = g.add_combinator(CombinatorKind::Union);
assert!(g.connect(a, b, PortId::A).is_ok());
let _ = g.connect(b, a, PortId::A); }
#[test]
fn glsl_compiles() {
let mut ed = SdfNodeEditor::default_body_graph();
let glsl = ed.glsl_output().to_string();
assert!(glsl.contains("float sdf_body"));
}
#[test]
fn auto_layout_no_panic() {
let mut ed = SdfNodeEditor::default_body_graph();
ed.graph.auto_layout();
}
}