use std::collections::{HashMap, HashSet};
use crate::feature_pipeline::features::common;
use crate::feature_pipeline::{FeatureContext, FeatureRefusal, FeatureResult};
use crate::OffsetFaceRole;
pub fn execute(ctx: &FeatureContext) -> FeatureResult {
match build(ctx) {
Ok(result) => result,
Err(error) => ctx.fail(error),
}
}
fn is_derived_edge_name(name: &str) -> bool {
if !name.contains('|') {
return false;
}
let Some(bracket) = name.rfind('[') else {
return false;
};
let rest = &name[bracket + 1..];
let Some(close) = rest.find(']') else {
return false;
};
let index = &rest[..close];
if index.is_empty() || !index.chars().all(|c| c.is_ascii_digit()) {
return false;
}
let tail = &rest[close + 1..];
tail.is_empty()
|| (tail.len() > 1
&& tail.starts_with('_')
&& tail[1..].chars().all(|c| c.is_ascii_digit()))
}
fn unique_name(counts: &mut HashMap<String, usize>, base: String) -> String {
let count = counts.entry(base.clone()).or_insert(0);
let name = if *count == 0 {
base.clone()
} else {
format!("{base}_{count}")
};
*count += 1;
name
}
fn build(ctx: &FeatureContext) -> Result<FeatureResult, FeatureRefusal> {
let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
let distance = match ctx.number("distance") {
Ok(distance) if distance.is_finite() && distance != 0.0 => distance,
_ => return Ok(result),
};
let mut names = common::unique_reference_names(ctx.param("faces"));
if let Some(legacy) = common::first_reference_name(ctx.param("solid")) {
if !names.contains(&legacy) {
names.push(legacy);
}
}
if names.is_empty() {
result.fulfilment = Some(crate::feature_pipeline::Fulfilment {
requested: Vec::new(),
applied: Vec::new(),
rejected: vec![crate::feature_pipeline::Rejected {
name: "faces".to_string(),
kind: crate::feature_pipeline::RejectedKind::NothingSelected,
reason: "names no opening face and no solid, so nothing was shelled".to_string(),
}],
extended: Vec::new(),
followed: Vec::new(),
});
return Ok(result);
}
let mut opening = Vec::new();
let mut face_handles = Vec::new();
let mut solids: Vec<(String, u32)> = Vec::new();
for name in &names {
let exact_face = ctx.scene.faces.get(name).copied();
let solid = exact_face.is_none().then(|| ctx.scene.resolve_solid(name)).flatten();
if let Some(handle) = solid {
if !solids.iter().any(|(_, known)| *known == handle) {
solids.push((name.clone(), handle));
}
} else if let Some(face) = exact_face.or_else(|| ctx.scene.resolve_face(name)) {
if !face_handles.contains(&face.handle) {
face_handles.push(face.handle);
}
opening.push(face);
} else {
result.unresolved.push(name.clone());
}
}
if !result.unresolved.is_empty() {
return Ok(result);
}
if solids.len() > 1 {
return Err(crate::KernelRefusal::input(
crate::KernelStage::Collect,
"offset_shell_two_solids",
format!(
"offset_shell: the selection names {} solids ({}); a shell is built from one solid",
solids.len(),
solids.iter().map(|(name, _)| format!("`{name}`")).collect::<Vec<_>>().join(", ")
),
)
.into());
}
if face_handles.len() > 1 {
return Err(crate::KernelRefusal::input(
crate::KernelStage::Collect,
"offset_shell_faces_on_two_solids",
format!(
"offset_shell: the opening faces lie on {} different solids; pick openings on one solid",
face_handles.len()
),
)
.into());
}
let handle = match (solids.first(), face_handles.first()) {
(Some((solid_name, named)), Some(on_faces)) if named != on_faces => {
return Err(crate::KernelRefusal::input(
crate::KernelStage::Collect,
"offset_shell_faces_off_solid",
format!("offset_shell: the opening faces are not on the named solid `{solid_name}`"),
)
.into())
}
(Some((_, named)), _) => *named,
(None, Some(on_faces)) => *on_faces,
(None, None) => unreachable!("every name resolved to a face or a solid"),
};
let opening_ids = opening.iter().map(|face| face.face_id).collect::<Vec<u64>>();
let target_name = ctx
.scene
.solids
.iter()
.find(|(_, resident)| **resident == handle)
.map(|(name, _)| name.clone())
.ok_or_else(|| {
crate::KernelRefusal::internal(
crate::KernelStage::Collect,
"offset_shell_no_resident_solid",
"offset_shell: opening faces resolve to no scene-resident solid",
)
})?;
let (source_names, record) = crate::with_registered_solid_typed(handle, |solid| {
let source_names: HashMap<u64, String> = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter_map(|face| face.name.as_ref().map(|name| (face.id, name.clone())))
.collect();
let record = if opening_ids.is_empty() {
crate::offset_shell_closed(solid, distance)
} else {
crate::offset_shell(solid, &opening_ids, distance)
};
Ok((source_names, record))
})?;
let record = match record {
Ok(record) => record,
Err(error) if crate::is_offset_shell_no_retained_face(&error) => return Ok(result),
Err(error) if crate::is_offset_shell_open_curved_rim(&error) => {
return Err(error
.with_message(|error| {
format!(
"offset shell could not close a watertight shell around the selected opening. \
Some openings carved by a complex CURVED cut (e.g. a rim-tangent fillet) \
are not yet supported — the wall along such a rim is neither ruled nor \
planar. Fix: run the offset shell EARLIER in the history, before that cut \
(reorder it above the curved boolean). (kernel detail: {error})"
)
})
.into());
}
Err(error) => return Err(error.into()),
};
let mut shell = record.solid;
let images = record.face_images;
let mut counts: HashMap<String, usize> = HashMap::new();
let mut face_index = 0usize;
for shell_record in &mut shell.shells {
for face in &mut shell_record.faces {
let image = images.get(face_index).ok_or_else(|| {
"offset_shell: result face has no matching image (provenance lost)".to_string()
})?;
let source_name = source_names
.get(&image.source_face_id)
.cloned()
.unwrap_or_else(|| format!("{target_name}_Face"));
let base = match image.role {
OffsetFaceRole::Offset => format!("{source_name}_Offset"),
OffsetFaceRole::Source | OffsetFaceRole::Wall => source_name,
};
face.name = Some(unique_name(&mut counts, base));
face_index += 1;
}
}
let feature_id = if ctx.id.is_empty() {
ctx.feature_type.as_str()
} else {
ctx.id.as_str()
};
let replace = ctx
.param("replaceOriginalSolid")
.and_then(|value| value.as_bool())
.unwrap_or(true);
let legacy_name = format!("{target_name}_{feature_id}");
let new_name = if replace { target_name.clone() } else { legacy_name.clone() };
for edge in &mut shell.edges {
if edge.name.as_deref().is_some_and(is_derived_edge_name) {
edge.name = None;
}
}
if !replace {
common::namespace_copy_names(&mut shell, feature_id);
}
result.added.push(common::register_added(shell, &new_name));
if replace {
result.solid_aliases.push((legacy_name, target_name.clone()));
result.removed.push(target_name);
}
Ok(result)
}
pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
probe.faces > 0
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"type": "O.S",
"shortName": "O.S",
"longName": "Offset Shell",
"ribbonPath": "Home/Modeling/Offset Shell",
"commandSize": "Compact",
"displayBuilder": false,
"inputParamsSchema": {
"id": {
"type": "string",
"default_value": null,
"hint": "Optional identifier used when naming the generated solid and faces"
},
"distance": {
"type": "number",
"default_value": 1,
"hint": "Signed distance applied to the retained BREP face supports."
},
"faces": {
"type": "reference_selection",
"selectionFilter": [
"FACE",
"SOLID"
],
"timestampDependency": "parentSolid",
"multiple": true,
"default_value": [],
"hint": "Pick the faces to remove as shell openings, or a solid alone to shell it closed (a hollow with an inner void). Faces and their solid together shell that solid with those openings."
},
"replaceOriginalSolid": {
"type": "boolean",
"label": "REPLACE ORIGINAL SOLID",
"default_value": true,
"hint": "When enabled, remove the source solid and leave only the shell result in the scene."
}
}
})
}