use crate::feature_pipeline::features::common;
use crate::feature_pipeline::{FeatureContext, FeatureResult};
use crate::{
extrude_profile_brep, make_arc, make_cylinder_brep, make_line, make_sphere_brep_framed,
revolve_profile_brep_named, sew_solid, sweep_profile_along_chain_with_stations,
sweep_profile_along_path, AnalyticSurface, BooleanOperation, BooleanOptions, BrepSolid,
NurbsCurve, Vec3,
};
use std::collections::{HashMap, HashSet};
const EPS: f64 = 1e-8;
struct Segment {
start: Vec3,
end: Vec3,
curve: Option<NurbsCurve>,
}
impl Segment {
fn ends(&self) -> (Vec3, Vec3) {
(self.start, self.end)
}
fn direction_at(&self, from_start: bool) -> Option<Vec3> {
let raw = match &self.curve {
None => {
if from_start {
self.end.sub(self.start)
} else {
self.start.sub(self.end)
}
}
Some(curve) => {
let [t0, t1] = curve.domain().ok()?;
let t = if from_start { t0 } else { t1 };
let tangent = curve.derivatives(t, 1).ok()?[1];
if from_start {
tangent
} else {
tangent.scale(-1.0)
}
}
};
raw.normalized().ok()
}
}
pub fn execute(ctx: &FeatureContext) -> FeatureResult {
match build(ctx) {
Ok(result) => result,
Err(error) => ctx.fail(error),
}
}
fn build(ctx: &FeatureContext) -> Result<FeatureResult, String> {
let radius = ctx.number("radius")?;
if !(radius > 0.0) {
return Err("tube: requires a positive radius".into());
}
let bend = common::number_or_default(ctx, "bendRadius", 0.0);
if bend < 0.0 {
return Err("tube: bend radius cannot be negative".into());
}
if bend > 0.0 && bend <= radius {
return Err(format!(
"tube: a bend radius of {bend} is not larger than the tube radius {radius}; the \
section would sweep back through itself at the corner"
));
}
let inner = common::number_or_default(ctx, "innerRadius", 0.0);
if inner < 0.0 {
return Err("tube: inside radius cannot be negative".into());
}
if inner > 0.0 && inner >= radius {
return Err("tube: inside radius must be smaller than the outer radius".into());
}
let curves = resolve_path_edges(ctx)?;
let mut segments: Vec<Segment> = Vec::with_capacity(curves.len());
for curve in &curves {
segments.push(classify_edge(curve, radius)?);
}
let (nodes, edge_nodes) = build_graph(&segments);
let components = connected_components(&edge_nodes, nodes.len());
let name = if ctx.id.is_empty() { "Tube" } else { ctx.id.as_str() };
let multi = components.len() > 1;
let mut bodies: Vec<(String, BrepSolid)> = Vec::with_capacity(components.len());
for (index, component) in components.iter().enumerate() {
let body_name = if multi {
format!("{name}[{index}]")
} else {
name.to_string()
};
let solid = assemble_component(
&segments,
component,
&nodes,
&edge_nodes,
radius,
inner,
bend,
&body_name,
)?;
bodies.push((body_name, solid));
}
Ok(common::finalize_solids(ctx, bodies))
}
#[allow(clippy::too_many_arguments)]
fn assemble_component(
segments: &[Segment],
component: &[usize],
nodes: &[Vec3],
edge_nodes: &[(usize, usize)],
radius: f64,
inner: f64,
bend: f64,
name: &str,
) -> Result<BrepSolid, String> {
let mut last: Option<String> = None;
for (rung, &clearance) in JOINT_CLEARANCE_LADDER.iter().enumerate() {
let exact_only = rung + 1 < JOINT_CLEARANCE_LADDER.len();
let attempt = (|| {
let outer = build_assembly(
segments, component, nodes, edge_nodes, radius, name, "", clearance, exact_only,
bend,
)?;
if inner <= 0.0 {
return Ok(outer);
}
let cutter = build_assembly(
segments, component, nodes, edge_nodes, inner, name, "_Inner", clearance,
exact_only, bend,
)?;
common::subtract_solid(outer, cutter)
.map_err(|error| format!("tube: hollow subtract (outer − inner) failed: {error}"))
})();
match attempt {
Ok(solid) => return Ok(solid),
Err(error) => last = Some(error),
}
}
Err(last.unwrap_or_else(|| "tube: internal error — empty clearance ladder".into()))
}
#[allow(clippy::too_many_arguments)]
fn build_assembly(
segments: &[Segment],
component: &[usize],
nodes: &[Vec3],
edge_nodes: &[(usize, usize)],
radius: f64,
name: &str,
suffix: &str,
clearance: f64,
exact_only: bool,
bend: f64,
) -> Result<BrepSolid, String> {
if component.is_empty() {
return Err("tube: internal error — empty component".into());
}
let mut degree: HashMap<usize, usize> = HashMap::new();
for &edge in component {
let (a, b) = edge_nodes[edge];
*degree.entry(a).or_default() += 1;
*degree.entry(b).or_default() += 1;
}
let chains = build_chains(segments, component, edge_nodes, bend);
let swept_through: HashSet<usize> = chains
.iter()
.flat_map(|chain| chain.nodes.iter().copied())
.collect();
let is_junction =
|node: usize| degree.get(&node).copied().unwrap_or(0) >= 2 && !swept_through.contains(&node);
if chains.len() == 1 {
return named_chain_or_segment(
&chains[0], segments, nodes, edge_nodes, radius, bend, name, suffix, 0,
);
}
let mut junction_nodes: Vec<usize> = degree
.iter()
.filter(|(&node, &d)| d >= 2 && !swept_through.contains(&node))
.map(|(&node, _)| node)
.collect();
junction_nodes.sort_unstable();
let joint_index: HashMap<usize, usize> = junction_nodes
.iter()
.enumerate()
.map(|(index, &node)| (node, index))
.collect();
let joint_radius = |node: usize| {
if node_needs_clearance(segments, component, edge_nodes, node) {
radius * clearance
} else {
radius
}
};
let joint_frame = |node: usize| {
joint_ball_frame(
segments,
component,
edge_nodes,
nodes,
node,
radius,
joint_radius(node),
)
};
let start = junction_nodes[0];
let mut solid = named_sphere(
nodes[start],
joint_radius(start),
name,
suffix,
joint_index[&start],
joint_frame(start),
)?;
let mut sphered: HashSet<usize> = [start].into_iter().collect();
let mut covered: HashSet<usize> = [start].into_iter().collect();
let mut added: HashSet<usize> = HashSet::new();
let mut segment_local = 0usize;
loop {
let next = (0..chains.len()).find(|index| {
!added.contains(index) && {
let (a, b) = chains[*index].ends(edge_nodes);
covered.contains(&a) || covered.contains(&b)
}
});
let Some(index) = next else {
break; };
added.insert(index);
let (a, b) = chains[index].ends(edge_nodes);
let body = named_chain_or_segment(
&chains[index], segments, nodes, edge_nodes, radius, bend, name, suffix,
segment_local,
)?;
segment_local += 1;
solid = union_solid(solid, body, exact_only)?;
covered.insert(a);
covered.insert(b);
for node in [a, b] {
if is_junction(node) && sphered.insert(node) {
let sphere = named_sphere(
nodes[node],
joint_radius(node),
name,
suffix,
joint_index[&node],
joint_frame(node),
)?;
solid = union_solid(solid, sphere, exact_only)?;
}
}
}
Ok(solid)
}
#[allow(clippy::too_many_arguments)]
fn named_chain_or_segment(
chain: &Chain,
segments: &[Segment],
nodes: &[Vec3],
edge_nodes: &[(usize, usize)],
radius: f64,
bend: f64,
name: &str,
suffix: &str,
local: usize,
) -> Result<BrepSolid, String> {
if chain.segments.len() == 1 {
return named_segment(&segments[chain.segments[0]], radius, name, suffix, local);
}
let (path, length) = chain_path(chain, segments, nodes, edge_nodes, bend)?;
named_chain(&path, length, radius, bend, name, suffix, local)
}
struct Chain {
segments: Vec<usize>,
nodes: Vec<usize>,
}
impl Chain {
fn ends(&self, edge_nodes: &[(usize, usize)]) -> (usize, usize) {
let first = edge_nodes[self.segments[0]];
let last = edge_nodes[*self.segments.last().expect("non-empty chain")];
let start = match self.nodes.first() {
Some(&next) if first.0 == next => first.1,
_ => first.0,
};
let end = match self.nodes.last() {
Some(&previous) if last.1 == previous => last.0,
_ => last.1,
};
(start, end)
}
}
fn node_is_bend(
segments: &[Segment],
component: &[usize],
edge_nodes: &[(usize, usize)],
node: usize,
bend: f64,
) -> bool {
if !(bend > 0.0) {
return false;
}
let incident: Vec<usize> = component
.iter()
.copied()
.filter(|&edge| {
let (a, b) = edge_nodes[edge];
a == node || b == node
})
.collect();
incident.len() == 2 && incident.iter().all(|&edge| segments[edge].curve.is_none())
}
fn build_chains(
segments: &[Segment],
component: &[usize],
edge_nodes: &[(usize, usize)],
bend: f64,
) -> Vec<Chain> {
let mut bends: HashSet<usize> = HashSet::new();
for &edge in component {
let (a, b) = edge_nodes[edge];
for node in [a, b] {
if node_is_bend(segments, component, edge_nodes, node, bend) {
bends.insert(node);
}
}
}
let touched: HashSet<usize> = component
.iter()
.flat_map(|&edge| {
let (a, b) = edge_nodes[edge];
[a, b]
})
.collect();
if !touched.is_empty() && touched.iter().all(|node| bends.contains(node)) {
let first = touched.iter().copied().min().expect("non-empty component");
let mut order: Vec<usize> = vec![first];
let mut visited: HashSet<usize> = [first].into_iter().collect();
let mut walked: HashSet<usize> = HashSet::new();
let mut node = first;
while let Some(edge) = component.iter().copied().find(|&edge| {
if walked.contains(&edge) {
return false;
}
let (a, b) = edge_nodes[edge];
a == node || b == node
}) {
walked.insert(edge);
let (a, b) = edge_nodes[edge];
node = if a == node { b } else { a };
if !visited.insert(node) {
break;
}
order.push(node);
}
bends.remove(&first);
if let Some(&opposite) = order.get(order.len() / 2) {
bends.remove(&opposite);
}
}
let next_at = |node: usize, from: usize| -> Option<usize> {
component.iter().copied().find(|&edge| {
if edge == from {
return false;
}
let (a, b) = edge_nodes[edge];
a == node || b == node
})
};
let mut used: HashSet<usize> = HashSet::new();
let mut chains: Vec<Chain> = Vec::new();
for &seed in component {
if used.contains(&seed) {
continue;
}
let mut chain_segments = vec![seed];
let mut chain_nodes: Vec<usize> = Vec::new();
used.insert(seed);
for forward in [true, false] {
let (a, b) = edge_nodes[seed];
let mut node = if forward { b } else { a };
let mut from = seed;
while bends.contains(&node) {
let Some(next) = next_at(node, from) else { break };
if used.contains(&next) {
break;
}
used.insert(next);
if forward {
chain_segments.push(next);
chain_nodes.push(node);
} else {
chain_segments.insert(0, next);
chain_nodes.insert(0, node);
}
let (na, nb) = edge_nodes[next];
node = if na == node { nb } else { na };
from = next;
}
}
chains.push(Chain {
segments: chain_segments,
nodes: chain_nodes,
});
}
chains
}
fn chain_path(
chain: &Chain,
segments: &[Segment],
nodes: &[Vec3],
edge_nodes: &[(usize, usize)],
bend: f64,
) -> Result<(Vec<NurbsCurve>, f64), String> {
let mut setback: Vec<(f64, f64)> = vec![(0.0, 0.0); chain.segments.len()];
let mut geometry: Vec<Option<(Vec3, Vec3, Vec3, f64)>> = vec![None; chain.nodes.len()];
for (index, &node) in chain.nodes.iter().enumerate() {
let before = chain.segments[index];
let after = chain.segments[index + 1];
let arm = |edge: usize| -> Result<Vec3, String> {
let (a, b) = edge_nodes[edge];
let from_start = if a == node {
true
} else if b == node {
false
} else {
return Err("tube: internal error — chain node is not on its segment".into());
};
segments[edge]
.direction_at(from_start)
.ok_or_else(|| "tube: a path edge has no usable direction at a corner".to_string())
};
let (first, second) = (arm(before)?, arm(after)?);
let angle = first.cross(second).length().atan2(first.dot(second));
let turn = std::f64::consts::PI - angle;
if turn.abs() <= 1e-9 {
continue; }
if angle <= 1e-9 {
return Err(format!(
"tube: the path doubles back on itself at ({:.4}, {:.4}, {:.4}); a bend needs two distinct directions",
nodes[node].x, nodes[node].y, nodes[node].z
));
}
let half = angle / 2.0;
let distance = bend / half.tan();
let Ok(bisector) = first.add(second).normalized() else {
continue;
};
let centre = nodes[node].add(bisector.scale(bend / half.sin()));
geometry[index] = Some((
nodes[node].add(first.scale(distance)),
nodes[node].add(second.scale(distance)),
centre,
turn,
));
setback[index].1 = distance;
setback[index + 1].0 = distance;
}
let mut path: Vec<NurbsCurve> = Vec::new();
let mut length = 0.0;
for (index, &edge) in chain.segments.iter().enumerate() {
let (a, b) = edge_nodes[edge];
let entering = if index == 0 {
match chain.nodes.first() {
Some(&next) if a == next => b,
_ => a,
}
} else {
chain.nodes[index - 1]
};
let (from, to) = if entering == a {
(nodes[a], nodes[b])
} else {
(nodes[b], nodes[a])
};
let along = to.sub(from);
let span = along.length();
let (head, tail) = setback[index];
if head + tail >= span - EPS {
return Err(format!(
"tube: a bend radius of {bend} needs {:.6} of the {:.6} long edge at its \
corners; use a smaller bend radius, or 0 to join with a ball instead",
head + tail,
span
));
}
let direction = along.normalized().map_err(|_| {
"tube: degenerate (zero-length) path edge".to_string()
})?;
let start = from.add(direction.scale(head));
let end = to.sub(direction.scale(tail));
path.push(make_line(start, end)?);
length += end.sub(start).length();
if let Some((_, _, centre, turn)) = geometry.get(index).copied().flatten() {
let entry = path
.last()
.expect("a line was just pushed")
.evaluate(1.0)
.map_err(|error| format!("tube: bend entry: {error}"))?;
let leaving = geometry[index]
.map(|(_, exit, _, _)| exit)
.expect("bend geometry present");
let ex = entry.sub(centre).normalized().map_err(|_| {
"tube: a bend's tangent point coincides with its centre".to_string()
})?;
let radial = leaving.sub(centre);
let ey = radial
.sub(ex.scale(radial.dot(ex)))
.normalized()
.map_err(|_| "tube: a bend's two tangent points are collinear".to_string())?;
path.push(make_arc(centre, ex, ey, bend, 0.0, turn)?);
length += bend * turn;
}
}
Ok((path, length))
}
fn chain_pieces(
path: &[NurbsCurve],
radius: f64,
) -> Result<Vec<BrepSolid>, String> {
use std::f64::consts::{PI, TAU};
let mut pieces = Vec::with_capacity(path.len());
for curve in path {
let [t0, t1] = curve.domain()?;
let start = curve.evaluate(t0)?;
let end = curve.evaluate(t1)?;
let tangent = curve.derivatives(t0, 1)?[1].normalized()?;
let radial = tangent.perpendicular()?;
let binormal = tangent.cross(radial).normalized()?;
let section = vec![
make_arc(start, radial, binormal, radius, 0.0, PI)?,
make_arc(start, radial, binormal, radius, PI, TAU)?,
];
let end_tangent = curve.derivatives(t1, 1)?[1].normalized()?;
if end_tangent.sub(tangent).length() <= 1e-9 {
pieces.push(extrude_profile_brep(§ion, tangent, end.sub(start).length())?);
continue;
}
let turn = tangent.cross(end_tangent).length().atan2(tangent.dot(end_tangent));
let axis = tangent.cross(end_tangent).normalized()?;
let chord = end.sub(start);
let inward = axis.cross(tangent).normalized()?;
let bend_radius = chord.length() / (2.0 * (turn / 2.0).sin());
let centre = start.add(inward.scale(bend_radius));
pieces.push(revolve_profile_brep_named(§ion, centre, axis, turn, &[], &[])?);
}
Ok(pieces)
}
fn stitch_pieces(pieces: &[BrepSolid], joints: &[Vec3], radius: f64) -> BrepSolid {
let interior = |solid: &BrepSolid, face: &crate::FaceRecord| -> bool {
if !matches!(face.surface.analytic(), Some(AnalyticSurface::Plane { .. })) {
return false;
}
joints.iter().any(|point| {
face.loops.iter().flat_map(|entry| &entry.coedges).all(|coedge| {
solid
.edges
.iter()
.find(|edge| edge.id == coedge.edge_id)
.and_then(|edge| solid.vertices.iter().find(|v| v.id == edge.start_vertex_id))
.is_some_and(|vertex| (vertex.point.sub(*point).length() - radius).abs() < 1e-6)
})
})
};
let mut out = pieces[0].clone();
out.vertices.clear();
out.edges.clear();
out.shells.truncate(1);
out.shells[0].faces.clear();
out.genus = 0;
let (mut vertex_base, mut edge_base, mut face_base) = (1u64, 1u64, 1u64);
for piece in pieces {
let vertex_max = piece.vertices.iter().map(|v| v.id).max().unwrap_or(0);
let edge_max = piece.edges.iter().map(|e| e.id).max().unwrap_or(0);
let mut face_max = 0u64;
for vertex in &piece.vertices {
let mut copy = vertex.clone();
copy.id += vertex_base;
out.vertices.push(copy);
}
for edge in &piece.edges {
let mut copy = edge.clone();
copy.id += edge_base;
copy.start_vertex_id += vertex_base;
copy.end_vertex_id += vertex_base;
out.edges.push(copy);
}
for face in piece.shells.iter().flat_map(|shell| &shell.faces) {
face_max = face_max.max(face.id);
if interior(piece, face) {
continue;
}
let mut copy = face.clone();
copy.id += face_base;
for entry in &mut copy.loops {
entry.id += face_base;
for coedge in &mut entry.coedges {
coedge.id += face_base;
coedge.edge_id += edge_base;
}
}
out.shells[0].faces.push(copy);
}
vertex_base += vertex_max + 1;
edge_base += edge_max + 1;
face_base += face_max + 1;
}
out
}
fn sewn_chain(
path: &[NurbsCurve],
radius: f64,
name: &str,
suffix: &str,
local: usize,
) -> Option<BrepSolid> {
let pieces = chain_pieces(path, radius).ok()?;
if pieces.len() < 2 {
return None;
}
let mut joints = Vec::with_capacity(path.len().saturating_sub(1));
for curve in path.iter().take(path.len() - 1) {
let [_, t1] = curve.domain().ok()?;
joints.push(curve.evaluate(t1).ok()?);
}
let stitched = stitch_pieces(&pieces, &joints, radius);
let (mut sewn, report) = sew_solid(&stitched, 1e-7).ok()?;
if report.open_edges_after != 0 || !report.oriented_outward || !sewn.validate().is_empty() {
return None;
}
let side = format!("{name}{suffix}_Seg{local}_S");
let names: Vec<String> = sewn
.shells
.first()?
.faces
.iter()
.map(|face| {
if matches!(face.surface.analytic(), Some(AnalyticSurface::Plane { .. })) {
String::new()
} else {
side.clone()
}
})
.collect();
let mut cap = 0usize;
let names: Vec<String> = names
.into_iter()
.map(|entry| {
if !entry.is_empty() {
return entry;
}
cap += 1;
if cap == 1 {
format!("{name}{suffix}_Seg{local}_B")
} else {
format!("{name}{suffix}_Seg{local}_T")
}
})
.collect();
name_faces(&mut sewn, &names);
Some(sewn)
}
fn named_chain(
path: &[NurbsCurve],
length: f64,
radius: f64,
bend: f64,
name: &str,
suffix: &str,
local: usize,
) -> Result<BrepSolid, String> {
use std::f64::consts::{PI, TAU};
let x = Vec3::new(1.0, 0.0, 0.0);
let y = Vec3::new(0.0, 1.0, 0.0);
let origin = Vec3::default();
let profile = [
make_arc(origin, x, y, radius, 0.0, PI)?,
make_arc(origin, x, y, radius, PI, TAU)?,
];
if let Some(sewn) = sewn_chain(path, radius, name, suffix, local) {
return Ok(sewn);
}
let stations = if bend > 0.0 {
((32.0 * length / (PI * bend)).ceil() as usize).clamp(32, 1024)
} else {
32
};
let piece_names: Vec<String> = (0..path.len())
.map(|piece| format!("{name}{suffix}_Seg{local} piece {piece}"))
.collect();
let mut swept = sweep_profile_along_chain_with_stations(
&profile,
path,
&piece_names,
stations,
"raise the tube's bend radius so the corner is rounded, or set it to 0 to \
join the segments with a ball instead",
)
.map_err(|error| format!("tube: sweeping the section along a bent path failed: {error}"))?;
let faces = face_count(&swept);
if faces != profile.len() + 2 {
return Err(format!(
"tube: swept chain produced {faces} faces, expected {} ({} sides + 2 caps)",
profile.len() + 2,
profile.len()
));
}
let side = format!("{name}{suffix}_Seg{local}_S");
name_faces(
&mut swept,
&[
side.clone(),
side,
format!("{name}{suffix}_Seg{local}_B"),
format!("{name}{suffix}_Seg{local}_T"),
],
);
Ok(swept)
}
const JOINT_CLEARANCE_LADDER: [f64; 6] = [1.0, 1.0005, 1.002, 1.007, 1.02, 1.1];
fn node_needs_clearance(
segments: &[Segment],
component: &[usize],
edge_nodes: &[(usize, usize)],
node: usize,
) -> bool {
let Some(arms) = node_arms(segments, component, edge_nodes, node) else {
return true;
};
if arms.iter().any(|&(edge, _)| segments[edge].curve.is_some()) {
return true;
}
for (index, &(_, a)) in arms.iter().enumerate() {
for &(_, b) in &arms[index + 1..] {
if a.dot(b) > -1.0 + 1e-9 {
return true;
}
}
}
false
}
fn node_arms(
segments: &[Segment],
component: &[usize],
edge_nodes: &[(usize, usize)],
node: usize,
) -> Option<Vec<(usize, Vec3)>> {
let mut arms: Vec<(usize, Vec3)> = Vec::new();
for &edge in component {
let (a, b) = edge_nodes[edge];
let from_start = if a == node {
true
} else if b == node {
false
} else {
continue;
};
arms.push((edge, segments[edge].direction_at(from_start)?));
}
Some(arms)
}
fn joint_ball_frame(
segments: &[Segment],
component: &[usize],
edge_nodes: &[(usize, usize)],
nodes: &[Vec3],
node: usize,
radius: f64,
ball_radius: f64,
) -> Option<(Vec3, Vec3)> {
let arms = node_arms(segments, component, edge_nodes, node)?;
let mut pairs: Vec<(f64, usize, usize)> = Vec::new();
for (index, &(_, a)) in arms.iter().enumerate() {
for (offset, &(_, b)) in arms[index + 1..].iter().enumerate() {
let angle = a.cross(b).length().atan2(a.dot(b));
pairs.push(((angle - std::f64::consts::FRAC_PI_2).abs(), index, index + 1 + offset));
}
}
pairs.sort_by(|left, right| {
left.0
.partial_cmp(&right.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then(left.1.cmp(&right.1))
.then(left.2.cmp(&right.2))
});
for (_, left, right) in pairs {
let (_, a) = arms[left];
let (_, b) = arms[right];
let (Ok(axis), Ok(seam)) = (a.sub(b).normalized(), a.add(b).normalized()) else {
continue; };
if seam_is_buried(segments, &arms, nodes[node], ball_radius, radius, axis, seam) {
return Some((axis, seam));
}
}
None
}
fn seam_is_buried(
segments: &[Segment],
arms: &[(usize, Vec3)],
center: Vec3,
ball_radius: f64,
radius: f64,
axis: Vec3,
seam: Vec3,
) -> bool {
const STATIONS: usize = 64;
let limit = radius * SEAM_BURIAL_MARGIN;
(0..=STATIONS).all(|station| {
let angle = std::f64::consts::PI * (station as f64 / STATIONS as f64 - 0.5);
let point = center.add(
seam.scale(ball_radius * angle.cos())
.add(axis.scale(ball_radius * angle.sin())),
);
arms.iter()
.any(|&(edge, _)| distance_to_arm_axis(&segments[edge], point) < limit)
})
}
const SEAM_BURIAL_MARGIN: f64 = 0.95;
fn distance_to_arm_axis(segment: &Segment, point: Vec3) -> f64 {
const CURVE_SAMPLES: usize = 128;
match &segment.curve {
None => {
let (start, end) = segment.ends();
let along = end.sub(start);
let length_squared = along.dot(along);
let t = if length_squared > EPS {
(point.sub(start).dot(along) / length_squared).clamp(0.0, 1.0)
} else {
0.0
};
point.sub(start.add(along.scale(t))).length()
}
Some(curve) => {
let Ok([t0, t1]) = curve.domain() else {
return f64::INFINITY;
};
(0..=CURVE_SAMPLES)
.filter_map(|station| {
let t = t0 + (t1 - t0) * station as f64 / CURVE_SAMPLES as f64;
curve.evaluate(t).ok().map(|p| point.sub(p).length())
})
.fold(f64::INFINITY, f64::min)
}
}
}
fn named_segment(
segment: &Segment,
radius: f64,
name: &str,
suffix: &str,
local: usize,
) -> Result<BrepSolid, String> {
match &segment.curve {
None => named_cylinder(segment.ends(), radius, name, suffix, local),
Some(curve) => named_swept(curve, radius, name, suffix, local),
}
}
fn named_cylinder(
(a, b): (Vec3, Vec3),
radius: f64,
name: &str,
suffix: &str,
local: usize,
) -> Result<BrepSolid, String> {
let axis = b.sub(a);
let length = axis.length();
if !(length > EPS) {
return Err("tube: degenerate (zero-length) path edge".into());
}
let mut cylinder = make_cylinder_brep(a, axis, radius, length)?;
name_faces(
&mut cylinder,
&[
format!("{name}{suffix}_Seg{local}_S"),
format!("{name}{suffix}_Seg{local}_B"),
format!("{name}{suffix}_Seg{local}_T"),
],
);
Ok(cylinder)
}
fn named_swept(
curve: &NurbsCurve,
radius: f64,
name: &str,
suffix: &str,
local: usize,
) -> Result<BrepSolid, String> {
use std::f64::consts::{PI, TAU};
let x = Vec3::new(1.0, 0.0, 0.0);
let y = Vec3::new(0.0, 1.0, 0.0);
let origin = Vec3::default();
let profile = [
make_arc(origin, x, y, radius, 0.0, PI)?,
make_arc(origin, x, y, radius, PI, TAU)?,
];
let mut swept = sweep_profile_along_path(&profile, curve, None)
.map_err(|error| format!("tube: sweeping the section along a curved edge failed: {error}"))?;
let faces = face_count(&swept);
if faces != profile.len() + 2 {
return Err(format!(
"tube: swept curved segment produced {faces} faces, expected {} ({} sides + 2 caps)",
profile.len() + 2,
profile.len()
));
}
let side = format!("{name}{suffix}_Seg{local}_S");
name_faces(
&mut swept,
&[
side.clone(),
side,
format!("{name}{suffix}_Seg{local}_B"),
format!("{name}{suffix}_Seg{local}_T"),
],
);
Ok(swept)
}
fn named_sphere(
center: Vec3,
radius: f64,
name: &str,
suffix: &str,
joint: usize,
frame: Option<(Vec3, Vec3)>,
) -> Result<BrepSolid, String> {
let (axis, seam) = match frame {
Some((axis, seam)) => (axis, Some(seam)),
None => (Vec3::new(0.0, 0.0, 1.0), None),
};
let mut sphere = make_sphere_brep_framed(center, radius, axis, seam)?;
let joint_name = format!("{name}{suffix}_Joint{joint}");
let names: Vec<String> = (0..face_count(&sphere)).map(|_| joint_name.clone()).collect();
name_faces(&mut sphere, &names);
Ok(sphere)
}
fn union_solid(a: BrepSolid, b: BrepSolid, exact_only: bool) -> Result<BrepSolid, String> {
let options = BooleanOptions {
merge_coplanar_faces: true,
..BooleanOptions::default()
};
let result = if exact_only {
crate::boolean_operation_with_diagnostics(&a, &b, BooleanOperation::Union, &options)
.map(|outcome| outcome.value)
} else {
crate::boolean_operation(&a, &b, BooleanOperation::Union, &options)
};
result.map_err(|error| format!("tube: joining segments/joints failed: {error}"))
}
fn face_count(solid: &BrepSolid) -> usize {
solid.shells.first().map_or(0, |shell| shell.faces.len())
}
fn name_faces(solid: &mut BrepSolid, names: &[String]) {
if let Some(shell) = solid.shells.first_mut() {
for (face, face_name) in shell.faces.iter_mut().zip(names) {
face.name = Some(face_name.clone());
}
}
}
fn resolve_path_edges(ctx: &FeatureContext) -> Result<Vec<NurbsCurve>, String> {
let names = common::reference_names(ctx.param("path"));
if names.is_empty() {
return Err("Tube requires at least one EDGE selection for the path.".into());
}
let mut curves: Vec<NurbsCurve> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for name in &names {
if !seen.insert(name.clone()) {
continue;
}
if let Some(path) = ctx.scene.resolve_path(name) {
curves.extend(path.iter().cloned());
continue;
}
if let Some(edge) = ctx.scene.resolve_edge(name) {
curves.push(common::edge_curve(edge).map_err(|error| format!("tube: {error}"))?);
continue;
}
return Err(format!(
"tube: path '{name}' not found (no sketch path or resident edge)"
));
}
Ok(curves)
}
fn classify_edge(curve: &NurbsCurve, radius: f64) -> Result<Segment, String> {
let [t0, t1] = curve.domain()?;
let start = curve.evaluate(t0)?;
let end = curve.evaluate(t1)?;
let chord = end.sub(start);
let chord_length = chord.length();
if chord_length <= EPS {
return Err("tube: path has a zero-length edge".into());
}
let tolerance = 1e-6 * chord_length.max(1.0);
let mut straight = true;
for station in 1..STRAIGHTNESS_SAMPLES {
let t = t0 + (t1 - t0) * station as f64 / STRAIGHTNESS_SAMPLES as f64;
let point = curve.evaluate(t)?;
let along = point.sub(start).dot(chord) / (chord_length * chord_length);
let projected = start.add(chord.scale(along.clamp(0.0, 1.0)));
if point.sub(projected).length() > tolerance {
straight = false;
break;
}
}
if straight {
return Ok(Segment { start, end, curve: None });
}
refuse_fold_over(curve, radius)?;
Ok(Segment {
start,
end,
curve: Some(curve.clone()),
})
}
const STRAIGHTNESS_SAMPLES: usize = 16;
fn refuse_fold_over(curve: &NurbsCurve, radius: f64) -> Result<(), String> {
let [t0, t1] = curve.domain()?;
let mut tightest = f64::INFINITY;
for station in 0..=CURVATURE_SAMPLES {
let t = t0 + (t1 - t0) * station as f64 / CURVATURE_SAMPLES as f64;
let derivatives = curve.derivatives(t, 2)?;
let speed = derivatives[1].length();
if speed <= EPS {
continue;
}
let curvature = derivatives[1].cross(derivatives[2]).length() / speed.powi(3);
if curvature > 0.0 {
tightest = tightest.min(1.0 / curvature);
}
}
if radius >= tightest {
return Err(format!(
"tube: radius {radius} reaches the path's curvature radius {tightest:.6} — the tube \
would fold through itself; use a smaller radius or a gentler path"
));
}
Ok(())
}
const CURVATURE_SAMPLES: usize = 128;
fn build_graph(segments: &[Segment]) -> (Vec<Vec3>, Vec<(usize, usize)>) {
let scale = segments
.iter()
.flat_map(|segment| [segment.start, segment.end])
.map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs()))
.fold(0.0_f64, f64::max)
.max(1.0);
let tolerance = scale * 1e-6;
let mut nodes: Vec<Vec3> = Vec::new();
let mut node_index = |point: Vec3, nodes: &mut Vec<Vec3>| -> usize {
for (index, &node) in nodes.iter().enumerate() {
if node.sub(point).length() <= tolerance {
return index;
}
}
nodes.push(point);
nodes.len() - 1
};
let mut edge_nodes = Vec::with_capacity(segments.len());
for segment in segments {
let ia = node_index(segment.start, &mut nodes);
let ib = node_index(segment.end, &mut nodes);
edge_nodes.push((ia, ib));
}
(nodes, edge_nodes)
}
fn connected_components(edge_nodes: &[(usize, usize)], node_count: usize) -> Vec<Vec<usize>> {
let mut parent: Vec<usize> = (0..node_count).collect();
fn find(parent: &mut [usize], mut x: usize) -> usize {
while parent[x] != x {
parent[x] = parent[parent[x]];
x = parent[x];
}
x
}
for &(a, b) in edge_nodes {
let ra = find(&mut parent, a);
let rb = find(&mut parent, b);
if ra != rb {
parent[ra] = rb;
}
}
let mut order: Vec<usize> = Vec::new();
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
for (edge, &(a, _)) in edge_nodes.iter().enumerate() {
let root = find(&mut parent, a);
groups.entry(root).or_insert_with(|| {
order.push(root);
Vec::new()
});
groups.get_mut(&root).unwrap().push(edge);
}
order.into_iter().map(|root| groups.remove(&root).unwrap()).collect()
}
pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
probe.edges > 0
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"type": "TU",
"shortName": "TU",
"longName": "Tube",
"displayBuilder": false,
"inputParamsSchema": {
"id": {
"type": "string",
"default_value": null,
"hint": "Unique identifier for the tube feature"
},
"path": {
"type": "reference_selection",
"selectionFilter": [
"EDGE"
],
"timestampDependency": "selection",
"multiple": true,
"default_value": null,
"hint": "Select one or more edges (straight or curved — an arc, spline or helix edge is swept). Each edge becomes one tube segment; connected edges are joined with a sphere at every junction; disconnected groups become separate tubes."
},
"radius": {
"type": "number",
"default_value": 5,
"hint": "Outer radius of the tube"
},
"innerRadius": {
"type": "number",
"default_value": 0,
"hint": "Optional inner radius for hollow tubes (0 for solid)"
},
"bendRadius": {
"type": "number",
"default_value": 0,
"hint": "Round every corner where two straight edges meet into a bend of this radius, swept as one tangent-continuous body (0 joins them with a sphere instead). Must exceed the tube radius."
},
"boolean": {
"type": "boolean_operation",
"default_value": {
"targets": [],
"operation": "NONE",
"mergeCoplanarFaces": true
},
"hint": "Optional boolean operation with target solids"
}
}
})
}