use std::collections::{HashMap, HashSet};
use crate::feature_pipeline::features::common;
use crate::feature_pipeline::{FeatureContext, 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, String> {
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 names = common::unique_reference_names(ctx.param("faces"));
if names.is_empty() {
return Ok(result);
}
let mut opening = Vec::new();
let mut handles = HashSet::new();
for name in &names {
match ctx.scene.resolve_face(name) {
Some(face) => {
opening.push(face);
handles.insert(face.handle);
}
None => result.unresolved.push(name.clone()),
}
}
if !result.unresolved.is_empty() {
return Ok(result);
}
if handles.len() != 1 {
return Ok(result);
}
let handle = *handles.iter().next().unwrap();
let opening_ids: Vec<u64> = opening.iter().map(|face| face.face_id).collect();
let target_name = ctx
.scene
.solids
.iter()
.find(|(_, resident)| **resident == handle)
.map(|(name, _)| name.clone())
.ok_or_else(|| "offset_shell: opening faces resolve to no scene-resident solid".to_string())?;
let (source_names, record) = crate::with_registered_solid_str(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 = crate::offset_shell(solid, &opening_ids, distance);
Ok((source_names, record))
})?;
let record = match record {
Ok(record) => record,
Err(error) if error.contains("cannot produce a shell") => return Ok(result),
Err(error)
if error.contains("completion produced non-integral genus")
|| error.contains("non-watertight shell")
|| error.contains("unwelded rim") =>
{
return Err(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})"
));
}
Err(error) => return Err(error),
};
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 new_name = format!("{target_name}_{feature_id}");
for edge in &mut shell.edges {
if edge.name.as_deref().is_some_and(is_derived_edge_name) {
edge.name = None;
}
}
result.added.push(common::register_added(shell, &new_name));
let replace = ctx
.param("replaceOriginalSolid")
.and_then(|value| value.as_bool())
.unwrap_or(true);
if replace {
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",
"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"
],
"timestampDependency": "parentSolid",
"multiple": true,
"default_value": [],
"hint": "Pick one or more faces to remove as shell 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."
}
}
})
}