use serde_json::Value;
use crate::feature_pipeline::features::common;
use crate::feature_pipeline::{FeatureContext, FeatureResult, SketchProfile};
use crate::{
loft_profile_brep, loft_profile_brep_guided, loft_profile_brep_guided_frame, NurbsCurve,
};
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 guide = resolve_guide(ctx)?;
let rotate_to_guide = matches!(ctx.param("rotateToGuide"), Some(Value::Bool(true)));
let names: Vec<String> = common::reference_names(ctx.param("profiles"))
.into_iter()
.map(common::normalize_profile_alias)
.collect();
if names.len() < 2 {
return Err(format!(
"loft: need at least 2 section profiles, got {}",
names.len()
));
}
let mut section_profiles: Vec<SketchProfile> = Vec::with_capacity(names.len());
let mut from_face: Vec<bool> = Vec::with_capacity(names.len());
for name in &names {
let (profile, face_sourced) = match ctx.scene.resolve_profile(name) {
Some(profile) => (profile.clone(), false),
None => match ctx.scene.resolve_face(name) {
Some(face) => (
super::face_profile::face_profile(face)
.map_err(|error| format!("loft: {error}"))?,
true,
),
None => {
return Err(format!(
"loft: profile '{name}' not found (no sketch profile or resident face)"
));
}
},
};
let outer = profile
.regions
.first()
.and_then(|region| region.first())
.ok_or_else(|| format!("loft: profile '{name}' has no outer loop"))?;
if outer.curves.len() < 2 {
return Err(format!(
"loft: profile '{name}' outer loop needs >= 2 curves, got {}",
outer.curves.len()
));
}
section_profiles.push(profile);
from_face.push(face_sourced);
}
let region_count = section_profiles[0].regions.len();
for (index, profile) in section_profiles.iter().enumerate() {
if profile.regions.len() != region_count {
return Err(format!(
"loft: every section must have the same loop structure; section 0 has \
{region_count} region(s), section {index} ('{}') has {}",
names[index],
profile.regions.len()
));
}
}
for region_index in 0..region_count {
let loop_count = section_profiles[0].regions[region_index].len();
for (index, profile) in section_profiles.iter().enumerate() {
if profile.regions[region_index].len() != loop_count {
return Err(format!(
"loft: every section must have the same loop structure; in region \
{region_index} section 0 has {} hole loop(s), section {index} ('{}') has {}",
loop_count - 1,
names[index],
profile.regions[region_index].len() - 1
));
}
}
for loop_index in 0..loop_count {
let count = section_profiles[0].regions[region_index][loop_index].curves.len();
for (index, profile) in section_profiles.iter().enumerate() {
let got = profile.regions[region_index][loop_index].curves.len();
if got != count {
return Err(format!(
"loft: all sections must have the same curve count in loop {loop_index}; \
section 0 has {count}, section {index} ('{}') has {got}",
names[index]
));
}
}
}
}
let base = if ctx.id.is_empty() { "Loft" } else { ctx.id.as_str() };
let caps = common::CapNames::new(base, §ion_profiles[0].regions);
let mut region_solids = Vec::with_capacity(region_count);
for region_index in 0..region_count {
let first_region = §ion_profiles[0].regions[region_index];
let first_edge_names: Vec<Option<String>> = first_region[0].edge_names.clone();
let sections: Vec<Vec<NurbsCurve>> = section_profiles
.iter()
.map(|profile| profile.regions[region_index][0].curves.clone())
.collect();
let side_count = sections[0].len();
let mut solid = loft_sections(§ions, guide.as_ref(), rotate_to_guide)?;
let mut face_names: Vec<String> = Vec::with_capacity(side_count + 2);
for index in 0..side_count {
let edge = first_edge_names
.get(index)
.cloned()
.flatten()
.unwrap_or_else(|| "EDGE".to_string());
face_names.push(format!("{edge}_LF"));
}
let (start, end) = &caps.per_loop[region_index];
face_names.push(start.clone());
face_names.push(end.clone());
let faces = &mut solid
.shells
.get_mut(0)
.ok_or("loft builder produced no shell")?
.faces;
if faces.len() != face_names.len() {
return Err(format!(
"loft builder produced {} faces, expected {} ({} sides + 2 caps)",
faces.len(),
face_names.len(),
side_count
));
}
for (face, name) in faces.iter_mut().zip(&face_names) {
face.name = Some(name.clone());
}
if first_region.len() > 1 {
solid = common::subtract_region_holes(
solid,
§ion_profiles[0],
first_region,
"loft",
base,
None,
&mut |loop_index, _depth| {
let hole_sections: Vec<Vec<NurbsCurve>> = section_profiles
.iter()
.map(|profile| profile.regions[region_index][loop_index].curves.clone())
.collect();
loft_sections(&hole_sections, guide.as_ref(), rotate_to_guide)
},
)?;
}
region_solids.push(solid);
}
let solid = common::union_region_solids(region_solids)
.map_err(|error| format!("loft: {error}"))?;
common::stamp_sweep_roles_multi(&solid, "_LF", &caps.starts(), &caps.ends());
let mut result = common::finalize_solid_grouped(ctx, solid, base, &caps.containers());
if result.error.is_some() {
return Ok(result);
}
for (index, name) in names.iter().enumerate() {
if from_face[index] {
continue;
}
common::consume_sketch(ctx, name, &mut result);
}
Ok(result)
}
fn has_guide(ctx: &FeatureContext) -> bool {
match ctx.param("guide") {
None | Some(serde_json::Value::Null) => false,
Some(serde_json::Value::String(text)) => !text.trim().is_empty(),
Some(serde_json::Value::Array(items)) => items.iter().any(|value| !value.is_null()),
Some(_) => true,
}
}
fn resolve_guide(ctx: &FeatureContext) -> Result<Option<NurbsCurve>, String> {
if !has_guide(ctx) {
return Ok(None);
}
let name = common::first_reference_name(ctx.param("guide"))
.ok_or("loft: `guide` is set but names nothing selectable")?;
let chain = common::resolve_path(ctx, &name).map_err(|error| format!("loft: {error}"))?;
match chain.len() {
1 => Ok(Some(chain.into_iter().next().expect("one curve"))),
0 => Err(format!("loft: guide '{name}' resolved to no curves")),
other => Err(format!(
"loft: guide '{name}' is a {other}-segment chain; the guided loft rides ONE spine \
curve — pick a single edge, or a sketch whose open chain is one segment"
)),
}
}
fn loft_sections(
sections: &[Vec<NurbsCurve>],
guide: Option<&NurbsCurve>,
rotate_to_guide: bool,
) -> Result<crate::BrepSolid, String> {
match (guide, rotate_to_guide) {
(None, _) => loft_profile_brep(sections),
(Some(guide), false) => loft_profile_brep_guided(sections, guide, None),
(Some(guide), true) => loft_profile_brep_guided_frame(sections, guide, None),
}
}
pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
probe.has_profile() || probe.edges > 0
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"type": "LOFT",
"shortName": "LOFT",
"longName": "Loft",
"displayBuilder": false,
"inputParamsSchema": {
"id": {
"type": "string",
"default_value": null,
"hint": "unique identifier for the loft feature"
},
"profiles": {
"type": "reference_selection",
"selectionFilter": [
"SKETCH",
"FACE"
],
"multiple": true,
"default_value": [],
"hint": "Select 2+ profiles (faces) to loft"
},
"consumeProfileSketch": {
"type": "boolean",
"default_value": true,
"hint": "Remove referenced sketches after creating the loft. Turn off to keep them in the scene."
},
"guide": {
"type": "reference_selection",
"selectionFilter": [
"EDGE"
],
"multiple": false,
"default_value": null,
"label": "Guide curve (optional)",
"hint": "Optional single guide curve (§5.8): the loft's spine follows it, bending the sections along the guide instead of the straight centroid-to-centroid path."
},
"rotateToGuide": {
"type": "boolean",
"default_value": false,
"label": "Rotate sections to guide",
"hint": "With a guide: rotate the sections into the guide's moving frame (like a sweep) instead of keeping their own orientation. Sections are still hit exactly at their own stations."
},
"boolean": common::optional_boolean_schema()
}
})
}