use crate::feature_pipeline::features::common;
use crate::feature_pipeline::{AddedSolid, FeatureContext, FeatureResult};
use crate::{rib_from_profile, NurbsCurve, PointClass, RibExtrusion, SolidClassifier, Vec3};
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 mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
let (target_name, target_handle) = match read_target_name(ctx) {
Some(name) => match ctx.scene.resolve_solid(&name) {
Some(handle) => (name, handle),
None => {
result.unresolved.push(name);
return Ok(result);
}
},
None => return Err("rib: select a target SOLID to fuse the rib into".into()),
};
let profile_name = common::normalize_profile_alias(
common::first_reference_name(ctx.param("profile"))
.ok_or("rib: select an open sketch chain (polyline) for the profile")?,
);
let profile =
common::resolve_path(ctx, &profile_name).map_err(|error| format!("rib: {error}"))?;
if profile.is_empty() {
return Err(format!("rib: profile '{profile_name}' has no curves"));
}
if profile.iter().any(|curve| curve.degree != 1) {
return Err(
"rib: the profile must be a single OPEN polyline chain (arcs and closed loops \
are not supported in V1)"
.into(),
);
}
let thickness = common::number_or_default(ctx, "thickness", 0.0);
let plane_normal = common::sketch_plane_frame(ctx, std::slice::from_ref(&profile_name))
.map(|frame| frame.z_axis);
let extrusion = read_extrusion(ctx)?;
let extrude_dir =
resolve_extrude_dir(ctx, &profile, plane_normal, extrusion, target_handle)?;
let fused = crate::with_registered_solid_str(target_handle, |target| {
rib_from_profile(
target,
&profile,
thickness,
extrude_dir,
plane_normal,
extrusion,
None,
)
})?;
let solid_name = target_name.clone();
let face_names = common::collect_face_names(&fused);
let edge_names = common::collect_edge_names(&fused);
let handle = crate::register_solid_value(fused);
result.added.push(AddedSolid {
handle,
name: solid_name,
face_names,
edge_names,
..AddedSolid::default()
});
result.removed = vec![target_name];
common::consume_sketch(ctx, &common::sketch_base_name(ctx, &profile_name), &mut result);
Ok(result)
}
fn read_extrusion(ctx: &FeatureContext) -> Result<RibExtrusion, String> {
let raw = ctx
.param("extrusionDirection")
.and_then(|value| value.as_str())
.map(|text| text.trim().to_uppercase())
.filter(|text| !text.is_empty());
match raw.as_deref() {
None | Some("PARALLEL_TO_SKETCH") => Ok(RibExtrusion::ParallelToSketch),
Some("NORMAL_TO_SKETCH") => Ok(RibExtrusion::NormalToSketch),
Some(other) => Err(format!(
"rib: unknown extrusion direction '{other}' (expected PARALLEL_TO_SKETCH or \
NORMAL_TO_SKETCH)"
)),
}
}
fn resolve_extrude_dir(
ctx: &FeatureContext,
profile: &[NurbsCurve],
plane_normal: Option<Vec3>,
extrusion: RibExtrusion,
target_handle: u32,
) -> Result<Vec3, String> {
let np = match plane_normal {
Some(normal) => normal,
None => profile_normal(profile)?,
};
let axis = match extrusion {
RibExtrusion::ParallelToSketch => {
let vertices = chain_vertices(profile)?;
let chord = vertices
.last()
.expect("a chain has vertices")
.sub(vertices[0]);
np.cross(chord).normalized().map_err(|_| {
"rib: the profile's chord is degenerate, so a Parallel-to-Sketch rib has no \
direction to grow in"
.to_string()
})?
}
RibExtrusion::NormalToSketch => np,
};
let mode = ctx
.param("direction")
.and_then(|value| value.as_str())
.map(|text| text.trim().to_uppercase())
.filter(|text| !text.is_empty())
.unwrap_or_else(|| "AUTO".to_string());
match mode.as_str() {
"NORMAL" => Ok(axis),
"-NORMAL" => Ok(axis.scale(-1.0)),
"AUTO" => auto_material_side(profile, axis, extrusion, target_handle),
other => Err(format!(
"rib: unknown extrude direction option '{other}' (expected AUTO, NORMAL, or -NORMAL)"
)),
}
}
fn auto_material_side(
profile: &[NurbsCurve],
axis: Vec3,
extrusion: RibExtrusion,
target_handle: u32,
) -> Result<Vec3, String> {
if matches!(extrusion, RibExtrusion::NormalToSketch) {
let profile_centroid = chain_centroid(profile)?;
let solid_centroid = target_vertex_centroid(target_handle)?;
let to_solid = solid_centroid.sub(profile_centroid);
return Ok(if axis.dot(to_solid) < 0.0 {
axis.scale(-1.0)
} else {
axis
});
}
let samples = chain_samples(profile)?;
crate::with_registered_solid_str(target_handle, |solid| {
let reach = crate::spatial::Aabb::from_points(
solid.vertices.iter().map(|vertex| vertex.point),
)
.diagonal();
if !(reach > 0.0) || !reach.is_finite() {
return Err("rib: the target solid has no extent to aim the rib at".into());
}
let classifier = SolidClassifier::new(solid, 1e-6)?;
let steps = 48;
let mut best: Option<(usize, f64, Vec3)> = None;
for signed in [axis, axis.scale(-1.0)] {
let mut hits = 0usize;
let mut total = 0.0;
for sample in &samples {
for step in 1..=steps {
let distance = reach * step as f64 / steps as f64;
let point = sample.add(signed.scale(distance));
if classifier.classify(point)?.class == PointClass::In {
hits += 1;
total += distance;
break;
}
}
}
let mean = if hits == 0 {
f64::INFINITY
} else {
total / hits as f64
};
let better = match best {
None => true,
Some((best_hits, best_mean, _)) => {
hits > best_hits || (hits == best_hits && mean < best_mean)
}
};
if better {
best = Some((hits, mean, signed));
}
}
match best {
Some((hits, _, direction)) if hits > 0 => Ok(direction),
_ => Err(
"rib: neither side of the profile reaches the part, so the rib has nothing to \
land on; move the sketch or set `direction` explicitly"
.into(),
),
}
})
}
fn chain_samples(profile: &[NurbsCurve]) -> Result<Vec<Vec3>, String> {
let mut samples = Vec::new();
for curve in profile {
let [start, end] = curve.domain()?;
for step in 1..=3 {
samples.push(curve.evaluate(start + (end - start) * step as f64 / 4.0)?);
}
}
if samples.is_empty() {
return Err("rib: profile has no points to aim from".into());
}
Ok(samples)
}
fn chain_vertices(profile: &[NurbsCurve]) -> Result<Vec<Vec3>, String> {
let mut vertices = Vec::with_capacity(profile.len() + 1);
for (index, curve) in profile.iter().enumerate() {
let [start, end] = curve.domain()?;
let v_start = curve.evaluate(start)?;
let v_end = curve.evaluate(end)?;
if index == 0 {
vertices.push(v_start);
}
vertices.push(v_end);
}
Ok(vertices)
}
fn profile_normal(profile: &[NurbsCurve]) -> Result<Vec3, String> {
let vertices = chain_vertices(profile)?;
if vertices.len() < 3 {
return Err(
"rib: a straight (single-segment) profile from an edge has no plane; pick a SKETCH \
chain (a sketch publishes the plane it was drawn on) or bend the chain"
.into(),
);
}
let mut normal = Vec3::default();
for i in 1..vertices.len() - 1 {
let a = vertices[i].sub(vertices[i - 1]);
let b = vertices[i + 1].sub(vertices[i]);
normal = normal.add(a.cross(b));
}
normal.normalized().map_err(|_| {
"rib: profile is collinear and publishes no plane of its own; its plane normal cannot be \
determined (pick a SKETCH chain instead)"
.to_string()
})
}
fn chain_centroid(profile: &[NurbsCurve]) -> Result<Vec3, String> {
let vertices = chain_vertices(profile)?;
if vertices.is_empty() {
return Err("rib: empty profile has no centroid".into());
}
let mut sum = Vec3::default();
for vertex in &vertices {
sum = sum.add(*vertex);
}
Ok(sum.scale(1.0 / vertices.len() as f64))
}
fn target_vertex_centroid(handle: u32) -> Result<Vec3, String> {
crate::with_registered_solid_str(handle, |solid| {
if solid.vertices.is_empty() {
return Err("rib: target solid has no vertices for AUTO extrude direction".into());
}
let mut sum = Vec3::default();
for vertex in &solid.vertices {
sum = sum.add(vertex.point);
}
Ok(sum.scale(1.0 / solid.vertices.len() as f64))
})
}
fn read_target_name(ctx: &FeatureContext) -> Option<String> {
match ctx.param("targetSolid")? {
serde_json::Value::Array(items) => items.iter().find_map(common::reference_name),
other => common::reference_name(other),
}
}
pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
probe.solids > 0 || probe.sketches > 0 || probe.edges > 0
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"type": "RIB",
"shortName": "RIB",
"longName": "Rib",
"displayBuilder": false,
"inputParamsSchema": {
"id": {
"type": "string",
"default_value": null,
"hint": "unique identifier for the rib feature"
},
"targetSolid": {
"type": "reference_selection",
"selectionFilter": [
"SOLID"
],
"multiple": false,
"default_value": null,
"hint": "Select the part the rib fuses into"
},
"profile": {
"type": "reference_selection",
"selectionFilter": [
"SKETCH",
"EDGE"
],
"multiple": true,
"default_value": [],
"hint": "Select an OPEN sketch chain (polyline) to thicken into a rib"
},
"thickness": {
"type": "number",
"default_value": 2,
"step": 0.1,
"hint": "Total rib wall thickness (offset ±thickness/2 about the profile)"
},
"extrusionDirection": {
"type": "options",
"options": [
"PARALLEL_TO_SKETCH",
"NORMAL_TO_SKETCH"
],
"default_value": "PARALLEL_TO_SKETCH",
"hint": "Parallel to sketch: the rib grows across the sketch with its thickness normal to the plane (a gusset). Normal to sketch: the chain is thickened in its plane and driven off it"
},
"direction": {
"type": "options",
"options": [
"AUTO",
"NORMAL",
"-NORMAL"
],
"default_value": "AUTO",
"hint": "Which side the material goes: AUTO grows toward the part; NORMAL/-NORMAL force the ± side"
},
"consumeProfileSketch": {
"type": "boolean",
"default_value": true,
"hint": "Remove the referenced sketch after creating the rib"
}
}
})
}