use super::{boolean_field, id_field, number_field, params, reference_field, schema_entry, string_field, PmiTypeDef};
use crate::feature_pipeline::pmi::resolve::{a3, resolve_reference};
use crate::feature_pipeline::pmi::{format_number, PmiAnnotation, PmiContext, PmiGeometry, Resolved};
use crate::feature_pipeline::{FeatureDescriptor, SelectionProbe};
use crate::{SelectionGeometry, Vec3};
pub const DEF: PmiTypeDef = PmiTypeDef {
type_id: "holeCallout",
short_name: "HOLE",
icon: "\u{2334}",
long_name: "\u{2334} Hole callout",
label: "Hole callout",
applicable,
schema,
resolve,
};
fn applicable(probe: &SelectionProbe) -> bool {
probe.faces + probe.edges == 1
&& probe.vertices == 0
&& probe.planes == 0
&& probe.solids == 0
&& probe.sketches == 0
}
fn schema() -> serde_json::Value {
schema_entry(
&DEF,
params(vec![
("id", id_field()),
(
"target",
reference_field("Target", &["FACE", "EDGE"], false, 1, 1, "The hole's wall face, or a circular edge on it"),
),
("quantity", number_field("Quantity", 0.0, 1.0, "0 = count the holes of the same feature automatically")),
("showQuantity", boolean_field("Show quantity", true, "Prefix the callout with `N×`")),
("beforeText", string_field("Before", "", "Text before the callout")),
("afterText", string_field("After", "", "Text after the callout")),
]),
)
}
fn wall_face_name(target: &str) -> Option<String> {
if target.contains("_Hole_S") && !target.contains('|') {
return Some(target.to_string());
}
let (body, _) = target.rsplit_once('[').unwrap_or((target, ""));
body.split('|')
.find(|part| part.contains("_Hole_S"))
.map(String::from)
}
fn parse_wall(name: &str) -> Option<(String, String, String)> {
let (chain, local) = crate::split_component_namespace(name);
let stem = local.strip_suffix("_Hole_S")?;
let (sketch, point) = stem.rsplit_once(':')?;
let prefix = if chain.is_empty() {
String::new()
} else {
format!("{}:", chain.join(":"))
};
Some((prefix, sketch.to_string(), point.to_string()))
}
fn hole_feature<'a>(request: &'a crate::feature_pipeline::HistoryRequest, sketch_id: &str) -> Option<&'a FeatureDescriptor> {
request.features.iter().find(|feature| {
if feature.feature_type != "H" {
return false;
}
let face = feature.input_params.get("face");
let named = match face {
Some(serde_json::Value::String(name)) => Some(name.trim()),
Some(serde_json::Value::Array(items)) => items.first().and_then(serde_json::Value::as_str).map(str::trim),
_ => None,
};
named == Some(sketch_id)
})
}
fn hole_number(feature: &FeatureDescriptor, key: &str, context: &PmiContext<'_>, default: f64) -> f64 {
match feature.input_params.get(key) {
Some(serde_json::Value::Number(n)) => n.as_f64().unwrap_or(default),
Some(serde_json::Value::String(s)) => context.env.eval(s.trim()).unwrap_or(default),
_ => default,
}
}
fn describe_hole(feature: &FeatureDescriptor, context: &PmiContext<'_>, decimals: usize) -> String {
let hole_type = feature
.input_params
.get("holeType")
.and_then(serde_json::Value::as_str)
.unwrap_or("SIMPLE")
.to_ascii_uppercase();
let diameter = hole_number(feature, "diameter", context, 0.0);
let depth = hole_number(feature, "depth", context, 0.0);
let through = feature
.input_params
.get("throughAll")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let depth_text = if through {
" THRU ALL".to_string()
} else {
format!(" \u{21A7}{}", format_number(depth, decimals))
};
let mut text = format!("\u{2300}{}{depth_text}", format_number(diameter, decimals));
match hole_type.as_str() {
"THREADED" => text.push_str(" THD"),
"COUNTERSINK" => {
let sink_dia = hole_number(feature, "countersinkDiameter", context, 0.0);
let sink_angle = hole_number(feature, "countersinkAngle", context, 90.0);
text.push_str(&format!(
"\n\u{2335} \u{2300}{} \u{00D7} {}\u{00B0}",
format_number(sink_dia, decimals),
format_number(sink_angle, 0)
));
}
"COUNTERBORE" => {
let bore_dia = hole_number(feature, "counterboreDiameter", context, 0.0);
let bore_depth = hole_number(feature, "counterboreDepth", context, 0.0);
text.push_str(&format!(
"\n\u{2334} \u{2300}{} \u{21A7}{}",
format_number(bore_dia, decimals),
format_number(bore_depth, decimals)
));
}
_ => {}
}
text
}
fn resolve(annotation: &PmiAnnotation, context: &PmiContext<'_>) -> Result<Resolved, String> {
let targets = annotation.references("target");
let Some(target) = targets.first() else {
return Err("select a hole wall face or a circular edge on it".into());
};
let geometry = resolve_reference(context.scene, target)?;
let (anchor, normal, radius) = match geometry {
SelectionGeometry::Axis {
origin,
direction,
radius,
} => (origin, direction, radius),
SelectionGeometry::Circle { center, axis, radius } => (center, axis, Some(radius)),
other => {
return Err(format!(
"'{target}' is not a hole: pick the cylindrical wall or a circular edge ({} found)",
crate::feature_pipeline::pmi::resolve::class_of(&other)
))
}
};
let decimals = 2;
let wall = wall_face_name(target);
let parsed = wall.as_deref().and_then(parse_wall);
let feature = parsed
.as_ref()
.filter(|(prefix, _, _)| prefix.is_empty())
.and_then(|(_, sketch, _)| hole_feature(context.request, sketch));
let mut body = match (feature, parsed.as_ref()) {
(Some(feature), _) => describe_hole(feature, context, decimals),
_ => match radius {
Some(radius) => format!("\u{2300}{}", format_number(radius * 2.0, decimals)),
None => return Err(format!("'{target}' has no constant diameter")),
},
};
let explicit = annotation.number("quantity", context.env, 0.0)?.round().max(0.0) as usize;
let count = if explicit > 0 {
explicit
} else if let Some((prefix, sketch, _)) = &parsed {
let stem = format!("{prefix}{sketch}:P");
let mut points: Vec<&str> = context
.scene
.faces
.keys()
.filter_map(|name| {
let rest = name.strip_prefix(&stem)?;
let point = rest.strip_suffix("_Hole_S")?;
(!point.contains(':')).then_some(point)
})
.collect();
points.sort_unstable();
points.dedup();
points.len().max(1)
} else {
1
};
if annotation.flag("showQuantity") && count > 1 {
body = format!("{count}\u{00D7} {body}");
}
let before = annotation.text("beforeText");
let after = annotation.text("afterText");
let text = format!("{}{}{}", if before.is_empty() { String::new() } else { format!("{before} ") }, body, if after.is_empty() { String::new() } else { format!(" {after}") });
let default_label = anchor.add(normal.scale(radius.unwrap_or(1.0) * 2.0 + 2.0)).add(Vec3::new(1.0, 1.0, 0.0).scale(radius.unwrap_or(1.0)));
Ok(Resolved {
text,
value: radius.map(|r| r * 2.0),
unit: "mm",
references: targets,
geometry: PmiGeometry::Hole {
anchor: a3(anchor),
normal: a3(normal),
},
default_label: a3(default_label),
})
}