use crate::feature_pipeline::features::common;
use crate::feature_pipeline::{FeatureContext, FeatureResult, SketchProfile};
use crate::{extrude_profile_brep, extrude_profile_brep_draft, BrepSolid, NurbsCurve, 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 draft_deg = optional_number(ctx, "draftAngle")?;
let name = common::normalize_profile_alias(
common::first_reference_name(ctx.param("profile")).ok_or("extrude: missing `profile` reference selection")?,
);
let mut face_profile_storage: Option<SketchProfile> = None;
let (profile, from_face): (&SketchProfile, bool) = match ctx.scene.resolve_profile(&name) {
Some(profile) => (profile, false),
None => match ctx.scene.resolve_face(&name) {
Some(face) => {
let built = super::face_profile::face_profile(face)
.map_err(|error| format!("extrude: {error}"))?;
(&*face_profile_storage.insert(built), true)
}
None => {
return Err(format!(
"extrude: profile '{name}' not found (no sketch profile or resident face)"
));
}
},
};
let distance = ctx.number("distance")?;
let back = optional_number(ctx, "distanceBack")?;
let direction = profile.z_axis;
let (solid, caps) = if draft_deg.abs() > 1e-12 {
build_drafted(ctx, profile, from_face, &name, direction, distance, back, draft_deg)?
} else {
build_straight(ctx, profile, from_face, &name, direction, distance, back)?
};
let mut result = common::finalize_solid_grouped(ctx, solid, &ctx.id, &caps.containers());
if result.error.is_some() {
return Ok(result);
}
if !from_face && common::consume_profile_sketch(ctx) {
let sketch_name = name
.strip_suffix(":PROFILE")
.unwrap_or(&name)
.to_string();
if !result.removed.contains(&sketch_name) {
result.removed.push(sketch_name);
}
}
Ok(result)
}
fn build_straight(
ctx: &FeatureContext,
profile: &SketchProfile,
from_face: bool,
name: &str,
direction: Vec3,
distance: f64,
back: f64,
) -> Result<(BrepSolid, common::CapNames), String> {
let total = distance + back;
if total.abs() <= 1e-9 {
return Err("extrude: total extrusion length (distance + distanceBack) is zero".into());
}
let dir = direction.normalized()?;
let base_offset = dir.scale(-back);
let tag = if ctx.id.is_empty() {
String::new()
} else {
format!("{}:", ctx.id)
};
let cap_base = if from_face {
format!("{tag}{name}")
} else {
let sketch_base = name.strip_suffix(":PROFILE").unwrap_or(name);
format!("{tag}{sketch_base}:PROFILE")
};
let caps = common::CapNames::new(&cap_base, &profile.regions);
let mut region_solids = Vec::with_capacity(profile.regions.len());
for (region_index, region) in profile.regions.iter().enumerate() {
let outer = region.first().ok_or("extrude: profile has no outer loop")?;
let count = outer.curves.len();
if count < 2 {
return Err(format!("extrude: outer loop needs >= 2 curves, got {count}"));
}
let outer_curves: Vec<NurbsCurve> = if back.abs() > 1e-12 {
outer
.curves
.iter()
.map(|curve| common::translate_curve(curve, base_offset))
.collect::<Result<_, _>>()?
} else {
outer.curves.clone()
};
let mut solid = extrude_profile_brep(&outer_curves, direction, total)?;
{
let faces = &mut solid
.shells
.get_mut(0)
.ok_or("extrude builder produced no shell")?
.faces;
if faces.len() != count + 2 {
return Err(format!(
"extrude builder produced {} faces, expected {}",
faces.len(),
count + 2
));
}
for (index, face) in faces.iter_mut().take(count).enumerate() {
let raw = outer
.edge_names
.get(index)
.cloned()
.flatten()
.unwrap_or_else(|| "EDGE".to_string());
face.name = Some(format!("{tag}{raw}_SW"));
}
let (start, end) = &caps.per_loop[region_index];
faces[count].name = Some(start.clone());
faces[count + 1].name = Some(end.clone());
}
for (loop_index, hole) in region.iter().enumerate().skip(1) {
let hole_curves: Vec<NurbsCurve> = if back.abs() > 1e-12 {
hole.curves
.iter()
.map(|curve| common::translate_curve(curve, base_offset))
.collect::<Result<_, _>>()?
} else {
hole.curves.clone()
};
solid = common::subtract_hole_prism(
"extrude",
solid,
&hole_curves,
direction,
total,
&ctx.id,
&common::hole_key(hole, loop_index),
)?;
}
region_solids.push(solid);
}
let solid = common::union_region_solids(region_solids)
.map_err(|error| format!("extrude: {error}"))?;
common::stamp_sweep_roles_multi(&solid, "_SW", &caps.starts(), &caps.ends());
Ok((solid, caps))
}
#[allow(clippy::too_many_arguments)]
fn build_drafted(
ctx: &FeatureContext,
profile: &SketchProfile,
from_face: bool,
name: &str,
direction: Vec3,
distance: f64,
back: f64,
draft_deg: f64,
) -> Result<(BrepSolid, common::CapNames), String> {
if back < -1e-12 {
return Err("extrude: draft (`draftAngle`) needs `distanceBack` >= 0".into());
}
let two_sided = back > 1e-12;
if !two_sided && distance.abs() <= 1e-9 {
return Err("extrude: draft (`draftAngle`) needs a non-zero `distance`".into());
}
if two_sided && distance < -1e-9 {
return Err(
"extrude: two-sided draft (`draftAngle` + `distanceBack`) needs `distance` >= 0 — \
both spans leave the sketch plane in opposite directions"
.into(),
);
}
if let Some(holed) = profile.regions.iter().find(|region| region.len() > 1) {
return Err(format!(
"extrude: draft (`draftAngle`) does not support profiles with holes \
({} hole loop(s) in the profile); several separate regions do draft",
holed.len() - 1
));
}
let feature_base = if ctx.id.is_empty() { "Extrude" } else { ctx.id.as_str() };
let tag = if ctx.id.is_empty() {
String::new()
} else {
format!("{}:", ctx.id)
};
let cap_base = if from_face {
format!("{tag}{name}")
} else {
let sketch_base = name.strip_suffix(":PROFILE").unwrap_or(name);
format!("{tag}{sketch_base}:PROFILE")
};
let caps = common::CapNames::new(&cap_base, &profile.regions);
let mut region_solids = Vec::with_capacity(profile.regions.len());
for (region_index, region) in profile.regions.iter().enumerate() {
let outer = region.first().ok_or("extrude: profile has no outer loop")?;
let count = outer.curves.len();
if count < 2 {
return Err(format!("extrude: outer loop needs >= 2 curves, got {count}"));
}
let (start_name, end_name) = caps.per_loop[region_index].clone();
let drafted_leg = |leg_direction: Vec3,
leg_distance: f64,
near_name: &str,
far_name: &str|
-> Result<BrepSolid, String> {
let mut solid = extrude_profile_brep_draft(
&outer.curves,
leg_direction,
leg_distance,
draft_deg.to_radians(),
Some(feature_base),
)
.map_err(|error| format!("extrude: {error}"))?;
let faces = &mut solid
.shells
.get_mut(0)
.ok_or("extrude draft builder produced no shell")?
.faces;
if faces.len() != count + 2 {
return Err(format!(
"extrude draft builder produced {} faces, expected {}",
faces.len(),
count + 2
));
}
for (index, face) in faces.iter_mut().take(count).enumerate() {
let raw = outer
.edge_names
.get(index)
.cloned()
.flatten()
.unwrap_or_else(|| "EDGE".to_string());
face.name = Some(format!("{tag}{raw}_SW"));
}
faces[count].name = Some(near_name.to_string());
faces[count + 1].name = Some(far_name.to_string());
Ok(solid)
};
let solid = if !two_sided {
drafted_leg(direction, distance, &start_name, &end_name)?
} else if distance.abs() <= 1e-9 {
drafted_leg(direction.scale(-1.0), back, &end_name, &start_name)?
} else {
let internal_name = format!("{feature_base}_PARTING_INTERNAL");
let front = drafted_leg(direction, distance, &internal_name, &end_name)?;
let back_leg = drafted_leg(direction.scale(-1.0), back, &internal_name, &start_name)?;
let solid = common::union_region_solids(vec![front, back_leg])
.map_err(|error| format!("extrude: two-sided draft {error}"))?;
let survivor = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| {
face.name
.as_deref()
.is_some_and(|name| name.starts_with(&internal_name))
});
if survivor.is_some() {
return Err(
"extrude: two-sided draft union kept an internal profile-plane cap — \
the two legs did not glue across the sketch plane"
.into(),
);
}
solid
};
region_solids.push(solid);
}
let solid = common::union_region_solids(region_solids)
.map_err(|error| format!("extrude: draft {error}"))?;
common::stamp_sweep_roles_multi(&solid, "_SW", &caps.starts(), &caps.ends());
for face in solid.shells.iter().flat_map(|shell| shell.faces.iter()) {
if let Some(name) = face.name.as_deref() {
if let Some((stem, tail)) = name.rsplit_once('_') {
if stem.ends_with("_SW") && tail.chars().all(|c| c.is_ascii_digit()) {
common::stamp_face_role(name, "sidewall", "SIDEWALL");
}
}
}
}
Ok((solid, caps))
}
fn optional_number(ctx: &FeatureContext, key: &str) -> Result<f64, String> {
match ctx.param(key) {
None | Some(serde_json::Value::Null) => Ok(0.0),
Some(_) => ctx.number(key),
}
}
pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
probe.has_profile()
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"type": "E",
"shortName": "E",
"longName": "Extrude",
"displayBuilder": false,
"inputParamsSchema": {
"id": {
"type": "string",
"default_value": null,
"hint": "unique identifier for the extrude feature"
},
"profile": {
"type": "reference_selection",
"selectionFilter": [
"FACE",
"SKETCH"
],
"multiple": false,
"default_value": null,
"hint": "Select the profile to extrude"
},
"consumeProfileSketch": {
"type": "boolean",
"default_value": true,
"hint": "Remove the referenced sketch after creating the extrusion. Turn off to keep it in the scene."
},
"distance": {
"type": "number",
"default_value": 10,
"hint": "Extrude distance when no path is provided"
},
"distanceBack": {
"type": "number",
"default_value": 10,
"hint": "Optional backward extrude distance (two-sided extrude)"
},
"draftAngle": {
"type": "number",
"default_value": 0,
"hint": "Draft/taper angle in degrees (positive tapers inward → smaller far cap). Line/arc profiles, single region, no holes; with distanceBack the sketch plane is the parting plane and both sides taper inward away from it; 0 = straight extrude."
},
"boolean": common::optional_boolean_schema()
}
})
}