use crate::feature_pipeline::features::common;
use crate::feature_pipeline::{AddedSolid, FeatureContext, FeatureResult};
use crate::{
boolean_operation, make_cylinder_brep, make_line, revolve_profile_brep_named, transform_brep,
AffineTransform, BooleanOperation, BooleanOptions, BrepSolid, NurbsCurve, Vec3,
};
const BACK_OFFSET: f64 = 1e-3;
pub fn execute(ctx: &FeatureContext) -> FeatureResult {
match build(ctx) {
Ok(result) => result,
Err(error) => ctx.fail(error),
}
}
struct Placement {
position: Vec3,
name: String,
}
struct BooleanParam {
operation: String,
target: Option<String>,
merge_coplanar_faces: bool,
}
fn build(ctx: &FeatureContext) -> Result<FeatureResult, String> {
let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
let boolean = read_boolean_param(ctx);
if boolean.operation == "NONE" {
return Ok(result);
}
let Some(target_name) = boolean.target.clone() else {
return Ok(result);
};
let Some(target_handle) = ctx.scene.resolve_solid(&target_name) else {
result.unresolved.push(target_name);
return Ok(result);
};
let picked = common::first_reference_name(ctx.param("face"))
.ok_or("hole: requires a sketch selection (`face` names the placement sketch)")?;
let aliased = common::normalize_profile_alias(picked);
let sketch_name = aliased
.strip_suffix(":PROFILE")
.map(str::to_string)
.unwrap_or(aliased);
let frame = ctx.scene.resolve_frame(&sketch_name).ok_or_else(|| {
format!("hole: placement sketch '{sketch_name}' not found (no resolved sketch frame in the scene)")
})?;
let placements = resolve_placements(ctx, &sketch_name);
if placements.is_empty() {
return Err("hole: no placement points (each hole needs a sketch center)".into());
}
let sketch_normal = frame
.z_axis
.normalized()
.map_err(|_| "hole: sketch plane normal is zero-length".to_string())?;
let target_bbox = with_solid_bbox(target_handle)?;
let normal = flip_into_target(sketch_normal, &placements, target_bbox);
let hole_type = ctx
.string("holeType")
.unwrap_or_else(|| "SIMPLE".to_string())
.to_uppercase();
let diameter = ctx.number("diameter").unwrap_or(0.0).abs();
let radius = (diameter * 0.5).max(1e-4);
let depth_input = ctx.number("depth").unwrap_or(0.0).abs();
let through_all = ctx
.param("throughAll")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let straight_depth = if through_all {
let diag = target_bbox
.map(|(min, max)| max.sub(min).length())
.unwrap_or(0.0);
let reach = if diag > 0.0 { diag * 1.5 } else { 50.0 };
depth_input.max(reach)
} else {
depth_input
};
if straight_depth <= 0.0 {
return Err("hole: straight depth must be positive (set `depth` or `throughAll`)".into());
}
let master_prefix = if ctx.id.is_empty() {
"MASTER_HOLE".to_string()
} else {
format!("{}_MASTER_HOLE", ctx.id)
};
let master = build_master_cutter(&hole_type, radius, straight_depth, ctx, &master_prefix)?;
let mut cutters: Vec<BrepSolid> = Vec::with_capacity(placements.len());
for placement in &placements {
let origin = placement.position.sub(normal.scale(BACK_OFFSET));
let transform = AffineTransform::new(placement_matrix(normal, origin)?)?;
let mut placed = transform_brep(&master, transform, false)?;
reprefix_faces(&mut placed, &master_prefix, &placement.name);
cutters.push(placed);
}
let options = BooleanOptions {
merge_coplanar_faces: boolean.merge_coplanar_faces,
..BooleanOptions::default()
};
let solid = subtract_cutters(target_handle, cutters, &options)?;
let face_names = common::collect_face_names(&solid);
let edge_names = common::collect_edge_names(&solid);
let handle = crate::register_solid_value(solid);
result.added.push(AddedSolid {
handle,
name: target_name.clone(),
face_names,
edge_names,
..AddedSolid::default()
});
result.removed = vec![target_name];
Ok(result)
}
fn resolve_placements(ctx: &FeatureContext, sketch: &str) -> Vec<Placement> {
let prefix = format!("{sketch}:P");
let mut entries: Vec<(String, Vec3)> = ctx
.scene
.model_points_with_prefix(&prefix)
.into_iter()
.filter(|(name, _)| name.len() > prefix.len())
.collect();
let sort_key = |name: &str| -> (u8, i64, String) {
let pid = &name[prefix.len()..];
match pid.parse::<i64>() {
Ok(number) => (0, number, String::new()),
Err(_) => (1, 0, pid.to_string()),
}
};
entries.sort_by(|(a, _), (b, _)| sort_key(a).cmp(&sort_key(b)));
let quantize = |value: f64| (value * 1e7).round() as i64;
let mut seen = std::collections::HashSet::new();
let mut placements = Vec::with_capacity(entries.len());
for (name, position) in entries {
if seen.insert((quantize(position.x), quantize(position.y), quantize(position.z))) {
placements.push(Placement { position, name });
}
}
placements
}
fn flip_into_target(normal: Vec3, placements: &[Placement], bbox: Option<(Vec3, Vec3)>) -> Vec3 {
let Some((min, max)) = bbox else {
return normal;
};
let mut lo = placements[0].position;
let mut hi = placements[0].position;
for placement in placements {
let p = placement.position;
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
let center = lo.add(hi).scale(0.5);
let clamped = Vec3::new(
center.x.clamp(min.x, max.x),
center.y.clamp(min.y, max.y),
center.z.clamp(min.z, max.z),
);
let mut to_target = clamped.sub(center);
if to_target.length_squared() < 1e-12 {
to_target = min.add(max).scale(0.5).sub(center);
}
if to_target.length_squared() > 1e-10 && normal.dot(to_target) < 0.0 {
normal.scale(-1.0)
} else {
normal
}
}
fn subtract_cutters(
target_handle: u32,
cutters: Vec<BrepSolid>,
options: &BooleanOptions,
) -> Result<BrepSolid, String> {
let mut current: Option<BrepSolid> = None;
for cutter in cutters {
let cutter_handle = crate::register_solid_value(cutter);
let folded = match current.take() {
None => crate::with_two_registered_solids(target_handle, cutter_handle, |target, tool| {
boolean_operation(target, tool, BooleanOperation::Subtract, options)
}),
Some(running) => {
let running_handle = crate::register_solid_value(running);
let folded =
crate::with_two_registered_solids(running_handle, cutter_handle, |run, tool| {
boolean_operation(run, tool, BooleanOperation::Subtract, options)
});
crate::free_registered_solid(running_handle);
folded
}
};
crate::free_registered_solid(cutter_handle);
current = Some(folded.map_err(|error| format!("hole subtract failed: {error}"))?);
}
current.ok_or_else(|| "hole: no cutter produced".to_string())
}
fn build_master_cutter(
hole_type: &str,
radius: f64,
straight_depth: f64,
ctx: &FeatureContext,
master_prefix: &str,
) -> Result<BrepSolid, String> {
match hole_type {
"SIMPLE" | "THREADED" => build_simple_cutter(radius, straight_depth, master_prefix),
"COUNTERSINK" => {
let sink_dia = ctx.number("countersinkDiameter").unwrap_or(0.0).abs();
let sink_angle = ctx.number("countersinkAngle").unwrap_or(82.0).clamp(1.0, 179.0);
build_countersink_cutter(radius, straight_depth, sink_dia, sink_angle, master_prefix)
}
"COUNTERBORE" => {
let bore_dia = ctx.number("counterboreDiameter").unwrap_or(0.0).abs();
let bore_depth = ctx.number("counterboreDepth").unwrap_or(0.0).abs();
build_counterbore_cutter(radius, straight_depth, bore_dia, bore_depth, master_prefix)
}
other => Err(format!("hole: unknown holeType '{other}'")),
}
}
fn build_simple_cutter(
radius: f64,
straight_depth: f64,
master_prefix: &str,
) -> Result<BrepSolid, String> {
let mut solid = make_cylinder_brep(
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
radius,
straight_depth,
)?;
stamp_faces(&mut solid, master_prefix, &["_Hole_S", "_Hole_B", "_Hole_T"])?;
Ok(solid)
}
fn build_countersink_cutter(
radius: f64,
straight_depth: f64,
sink_dia: f64,
sink_angle: f64,
master_prefix: &str,
) -> Result<BrepSolid, String> {
let sink_radius = radius.max(sink_dia * 0.5);
let angle_rad = sink_angle * std::f64::consts::PI / 180.0;
let sink_height = (sink_radius - radius) / (angle_rad * 0.5).tan();
if !(sink_height > 0.0) {
return Err(
"hole: countersink has no recess (countersinkDiameter must exceed diameter)".into(),
);
}
let core_depth = (straight_depth - sink_height).max(0.0);
let (profile, side_names) = if core_depth <= 0.0 {
(
vec![
make_line(pt(0.0, 0.0), pt(sink_radius, 0.0))?,
make_line(pt(sink_radius, 0.0), pt(radius, sink_height))?,
make_line(pt(radius, sink_height), pt(0.0, sink_height))?,
make_line(pt(0.0, sink_height), pt(0.0, 0.0))?,
],
vec![
named(master_prefix, "_CSK_T"),
named(master_prefix, "_CSK_S"),
named(master_prefix, "_CSK_B"),
None,
],
)
} else {
(
vec![
make_line(pt(0.0, 0.0), pt(sink_radius, 0.0))?,
make_line(pt(sink_radius, 0.0), pt(radius, sink_height))?,
make_line(pt(radius, sink_height), pt(radius, straight_depth))?,
make_line(pt(radius, straight_depth), pt(0.0, straight_depth))?,
make_line(pt(0.0, straight_depth), pt(0.0, 0.0))?,
],
vec![
named(master_prefix, "_CSK_T"),
named(master_prefix, "_CSK_S"),
named(master_prefix, "_Hole_S"),
named(master_prefix, "_Hole_B"),
None,
],
)
};
revolve_full_turn(&profile, &side_names)
}
fn build_counterbore_cutter(
radius: f64,
straight_depth: f64,
bore_dia: f64,
bore_depth: f64,
master_prefix: &str,
) -> Result<BrepSolid, String> {
if !(bore_depth > 0.0) {
return Err("hole: counterbore has no recess (set counterboreDepth)".into());
}
let bore_radius = radius.max(bore_dia * 0.5);
let core_depth = (straight_depth - bore_depth).max(0.0);
let (profile, side_names) = if core_depth <= 0.0 {
(
vec![
make_line(pt(0.0, 0.0), pt(bore_radius, 0.0))?,
make_line(pt(bore_radius, 0.0), pt(bore_radius, bore_depth))?,
make_line(pt(bore_radius, bore_depth), pt(0.0, bore_depth))?,
make_line(pt(0.0, bore_depth), pt(0.0, 0.0))?,
],
vec![
named(master_prefix, "_CBore_T"),
named(master_prefix, "_CBore_S"),
named(master_prefix, "_CBore_B"),
None,
],
)
} else {
(
vec![
make_line(pt(0.0, 0.0), pt(bore_radius, 0.0))?,
make_line(pt(bore_radius, 0.0), pt(bore_radius, bore_depth))?,
make_line(pt(bore_radius, bore_depth), pt(radius, bore_depth))?,
make_line(pt(radius, bore_depth), pt(radius, straight_depth))?,
make_line(pt(radius, straight_depth), pt(0.0, straight_depth))?,
make_line(pt(0.0, straight_depth), pt(0.0, 0.0))?,
],
vec![
named(master_prefix, "_CBore_T"),
named(master_prefix, "_CBore_S"),
named(master_prefix, "_Shoulder"),
named(master_prefix, "_Hole_S"),
named(master_prefix, "_Hole_B"),
None,
],
)
};
revolve_full_turn(&profile, &side_names)
}
fn pt(radial: f64, axial: f64) -> Vec3 {
Vec3::new(radial, axial, 0.0)
}
fn named(prefix: &str, suffix: &str) -> Option<String> {
Some(format!("{prefix}{suffix}"))
}
fn revolve_full_turn(
profile: &[NurbsCurve],
side_names: &[Option<String>],
) -> Result<BrepSolid, String> {
revolve_profile_brep_named(
profile,
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
std::f64::consts::TAU,
side_names,
&[],
)
}
fn stamp_faces(solid: &mut BrepSolid, prefix: &str, suffixes: &[&str]) -> Result<(), String> {
let faces = &mut solid
.shells
.get_mut(0)
.ok_or("hole cutter produced no shell")?
.faces;
if faces.len() != suffixes.len() {
return Err(format!(
"hole cutter produced {} faces, expected {}",
faces.len(),
suffixes.len()
));
}
for (face, suffix) in faces.iter_mut().zip(suffixes) {
face.name = Some(format!("{prefix}{suffix}"));
}
Ok(())
}
fn reprefix_faces(solid: &mut BrepSolid, master_prefix: &str, hole_prefix: &str) {
if master_prefix == hole_prefix {
return;
}
for shell in &mut solid.shells {
for face in &mut shell.faces {
if let Some(name) = &face.name {
if let Some(rest) = name.strip_prefix(master_prefix) {
face.name = Some(format!("{hole_prefix}{rest}"));
}
}
}
}
}
fn placement_matrix(normal: Vec3, origin: Vec3) -> Result<[f64; 16], String> {
let up = normal.normalized()?;
let reference = if up.y.abs() < 0.9 {
Vec3::new(0.0, 1.0, 0.0)
} else {
Vec3::new(1.0, 0.0, 0.0)
};
let mut x = reference.cross(up);
if x.length_squared() < 1e-12 {
x = Vec3::new(1.0, 0.0, 0.0);
}
let x = x.normalized()?;
let z = x.cross(up).normalized()?;
Ok([
x.x, up.x, z.x, origin.x, x.y, up.y, z.y, origin.y, x.z, up.z, z.z, origin.z, 0.0, 0.0, 0.0, 1.0,
])
}
fn with_solid_bbox(handle: u32) -> Result<Option<(Vec3, Vec3)>, String> {
crate::with_registered_solid_str(handle, |solid| {
let mut min = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut max = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for vertex in &solid.vertices {
let p = vertex.point;
min = Vec3::new(min.x.min(p.x), min.y.min(p.y), min.z.min(p.z));
max = Vec3::new(max.x.max(p.x), max.y.max(p.y), max.z.max(p.z));
}
if !min.x.is_finite() {
return Ok(None);
}
Ok(Some((min, max)))
})
}
fn read_boolean_param(ctx: &FeatureContext) -> BooleanParam {
let boolean = ctx.param("boolean");
let operation = boolean
.and_then(|value| value.get("operation"))
.and_then(|value| value.as_str())
.unwrap_or("SUBTRACT")
.to_uppercase();
let target = boolean
.and_then(|value| value.get("targets"))
.and_then(|value| value.as_array())
.and_then(|array| {
array.iter().find_map(|entry| {
entry
.as_str()
.or_else(|| entry.get("name").and_then(|name| name.as_str()))
})
})
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty());
let merge_coplanar_faces = boolean
.and_then(|value| value.get("mergeCoplanarFaces"))
.and_then(|value| value.as_bool())
.unwrap_or(true);
BooleanParam {
operation,
target,
merge_coplanar_faces,
}
}
pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
probe.sketches > 0
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"type": "H",
"shortName": "H",
"longName": "Hole",
"displayBuilder": false,
"inputParamsSchema": {
"id": {
"type": "string",
"default_value": null,
"hint": "Unique identifier for the hole feature"
},
"face": {
"type": "reference_selection",
"label": "Placement (sketch)",
"selectionFilter": [
"SKETCH"
],
"multiple": false,
"minSelections": 1,
"default_value": null,
"hint": "Select a sketch to place the hole"
},
"holeType": {
"type": "options",
"label": "Hole type",
"options": [
"SIMPLE",
"COUNTERSINK",
"COUNTERBORE",
"THREADED"
],
"default_value": "SIMPLE",
"hint": "Choose the hole style"
},
"diameter": {
"type": "number",
"label": "Diameter",
"default_value": 6,
"min": 0,
"step": 0.1,
"hint": "Straight hole diameter"
},
"depth": {
"type": "number",
"label": "Depth",
"default_value": 10,
"min": 0,
"step": 0.1,
"hint": "Straight portion depth (ignored when Through All)"
},
"throughAll": {
"type": "boolean",
"label": "Through all",
"default_value": false,
"hint": "Cut through the entire target thickness"
},
"countersinkDiameter": {
"type": "number",
"label": "Countersink diameter",
"default_value": 10,
"min": 0,
"step": 0.1,
"hint": "Major diameter of the countersink"
},
"countersinkAngle": {
"type": "number",
"label": "Countersink angle (deg)",
"default_value": 82,
"min": 1,
"max": 179,
"step": 1,
"hint": "Included angle of the countersink"
},
"counterboreDiameter": {
"type": "number",
"label": "Counterbore diameter",
"default_value": 10,
"min": 0,
"step": 0.1,
"hint": "Major diameter of the counterbore"
},
"counterboreDepth": {
"type": "number",
"label": "Counterbore depth",
"default_value": 3,
"min": 0,
"step": 0.1,
"hint": "Depth of the counterbore recess"
},
"boolean": {
"type": "boolean_operation",
"label": "Boolean",
"default_value": {
"targets": [],
"operation": "SUBTRACT",
"mergeCoplanarFaces": true
},
"hint": "Targets to cut; defaults to the selected body"
}
}
})
}