use super::{id_field, number_field, options_field, params, reference_field, schema_entry, PmiTypeDef};
use crate::feature_pipeline::pmi::resolve::{a3, class_of, edge_endpoints, face_axial_span, perpendicular_in_plane, resolve_reference};
use crate::feature_pipeline::pmi::{CenterCircle, PmiAnnotation, PmiContext, PmiGeometry, Resolved};
use crate::feature_pipeline::SelectionProbe;
use crate::{SelectionGeometry, Vec3};
pub const DEF: PmiTypeDef = PmiTypeDef {
type_id: "centerline",
short_name: "CL",
icon: "\u{2104}",
long_name: "\u{2104} Center mark & line",
label: "Center mark & line",
applicable,
schema,
resolve,
};
const MAX_TARGETS: usize = 64;
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()),
(
"style",
options_field(
"Style",
&["mark", "line"],
"mark",
"mark: a centre cross on each round feature · line: a chain line along an axis, through centres, or between two parallel edges",
),
),
(
"targets",
reference_field(
"Targets",
&["FACE", "EDGE"],
true,
1,
MAX_TARGETS,
"Circular edges, cylindrical / conical / spherical faces — or, for a line, two parallel straight edges",
),
),
(
"extension",
number_field("Extension", 2.0, 0.5, "How far the lines reach past the circle (mark) or the ends (line)"),
),
(
"orientation",
reference_field(
"Orientation",
&["FACE", "EDGE"],
false,
0,
1,
"Optional (mark): a straight edge, planar face or cylinder one arm of the cross runs parallel to; empty = the model X axis",
),
),
]),
)
}
fn resolve(annotation: &PmiAnnotation, context: &PmiContext<'_>) -> Result<Resolved, String> {
let targets = annotation.references("targets");
if targets.is_empty() {
return Err("select the round features (circular edges, holes, cylinders) to mark".into());
}
if targets.len() > MAX_TARGETS {
return Err(format!("at most {MAX_TARGETS} targets"));
}
let extension = annotation.number("extension", context.env, 2.0)?;
if !extension.is_finite() || extension < 0.0 {
return Err(format!("extension must be zero or more, not {extension}"));
}
let mut frames = Vec::with_capacity(targets.len());
for target in &targets {
frames.push((target.as_str(), resolve_reference(context.scene, target)?));
}
if annotation.text("style").eq_ignore_ascii_case("line") {
line(&frames, extension, context, targets.clone())
} else {
let orientation = match annotation.references("orientation").first() {
Some(name) => Some((name.clone(), resolve_reference(context.scene, name)?)),
None => None,
};
let mut references = targets.clone();
if let Some((name, _)) = &orientation {
references.push(name.clone());
}
mark(&frames, orientation.as_ref().map(|(name, geometry)| (name.as_str(), geometry)), extension, context, references)
}
}
const DEGENERATE: f64 = 1e-6;
fn in_plane(d: Vec3, axis: Vec3) -> Option<Vec3> {
let planar = d.sub(axis.scale(d.dot(axis)));
if planar.length() < DEGENERATE {
return None;
}
planar.normalized().ok()
}
fn arm_direction(center: Vec3, axis: Option<Vec3>, reference: Option<(&str, &SelectionGeometry)>) -> Result<Vec3, String> {
let x = Vec3::new(1.0, 0.0, 0.0);
let Some((name, geometry)) = reference else {
return Ok(match axis {
Some(axis) => in_plane(x, axis).or_else(|| in_plane(Vec3::new(0.0, 1.0, 0.0), axis)).unwrap_or(x),
None => x,
});
};
let square = || format!("orientation '{name}' is square to the marked circle's plane — pick an edge or face that has a direction in that plane");
let direction = |d: Vec3| -> Result<Vec3, String> {
match axis {
Some(axis) => in_plane(d, axis).ok_or_else(square),
None => d.normalized().map_err(|_| square()),
}
};
match *geometry {
SelectionGeometry::Line { direction: d, .. } => direction(d),
SelectionGeometry::Plane { normal, .. } => match axis {
Some(axis) => {
let trace = normal.cross(axis);
if trace.length() < DEGENERATE {
return Err(format!(
"orientation face '{name}' is parallel to the marked circle's plane — pick a face or edge that crosses it"
));
}
trace.normalized().map_err(|_| square())
}
None => direction(normal.cross(Vec3::new(0.0, 0.0, 1.0))),
},
SelectionGeometry::Axis { origin, direction: d, .. } | SelectionGeometry::Circle { center: origin, axis: d, .. } => {
let d = d.normalized().map_err(|_| format!("orientation '{name}' has no axis direction"))?;
match axis {
Some(axis) => match in_plane(d, axis) {
Some(v) => Ok(v),
None => {
let toward = origin.sub(center);
in_plane(toward.sub(d.scale(toward.dot(d))), axis).ok_or_else(|| {
format!("orientation '{name}' shares the marked circle's axis — pick an edge or face beside it")
})
}
},
None => direction(d),
}
}
ref other => Err(format!(
"orientation '{name}' is a {} — orient a center mark by a straight edge, a planar face or a cylinder",
class_of(other)
)),
}
}
fn round(name: &str, geometry: &SelectionGeometry, context: &PmiContext<'_>) -> Result<CenterCircle, String> {
match *geometry {
SelectionGeometry::Circle { center, axis, radius } => {
let axis = axis.normalized().map_err(|_| format!("'{name}' has no axis direction"))?;
Ok(CenterCircle { center: a3(center), axis: a3(axis), radius, depth: 0.0, x_dir: [0.0; 3] })
}
SelectionGeometry::Axis { origin, direction, radius: Some(radius) } => {
let direction = direction.normalized().map_err(|_| format!("'{name}' has no axis direction"))?;
let (low, high) = face_axial_span(context.scene, name, origin, direction)?;
Ok(CenterCircle {
center: a3(origin.add(direction.scale(high))),
axis: a3(direction),
radius,
depth: high - low,
x_dir: [0.0; 3],
})
}
SelectionGeometry::Axis { radius: None, .. } => Err(format!(
"'{name}' has no constant radius (a cone or a general revolution) — pick a circular edge on it"
)),
SelectionGeometry::Sphere { center, radius } => Ok(CenterCircle { center: a3(center), axis: [0.0; 3], radius, depth: 0.0, x_dir: [0.0; 3] }),
ref other => Err(format!(
"'{name}' is a {} — a center mark needs a circular edge, a cylinder, a cone or a sphere",
class_of(other)
)),
}
}
fn mark(
frames: &[(&str, SelectionGeometry)],
orientation: Option<(&str, &SelectionGeometry)>,
extension: f64,
context: &PmiContext<'_>,
references: Vec<String>,
) -> Result<Resolved, String> {
let mut marks = Vec::with_capacity(frames.len());
for (name, geometry) in frames {
let mut mark = round(name, geometry, context)?;
let axis = Vec3::new(mark.axis[0], mark.axis[1], mark.axis[2]);
let center = Vec3::new(mark.center[0], mark.center[1], mark.center[2]);
let axis = (axis.length() > 1e-12).then_some(axis);
mark.x_dir = a3(arm_direction(center, axis, orientation)?);
marks.push(mark);
}
let first = &marks[0];
let axis = Vec3::new(first.axis[0], first.axis[1], first.axis[2]);
let center = Vec3::new(first.center[0], first.center[1], first.center[2]);
let out = perpendicular_in_plane(if axis.length() > 1e-12 { axis } else { Vec3::new(0.0, 0.0, 1.0) }, Vec3::new(1.0, 1.0, 0.0));
let default_label = center.add(out.scale((first.radius + extension) * 1.25 + 1.0));
Ok(Resolved {
text: String::new(),
value: None,
unit: "",
references,
geometry: PmiGeometry::CenterMark { marks, extension },
default_label: a3(default_label),
})
}
struct Station {
point: Vec3,
clearance: f64,
}
fn line(
frames: &[(&str, SelectionGeometry)],
extension: f64,
context: &PmiContext<'_>,
references: Vec<String>,
) -> Result<Resolved, String> {
let (a, b) = match frames {
[(name, SelectionGeometry::Axis { origin, direction, .. })] => {
let direction = direction.normalized().map_err(|_| format!("'{name}' has no axis direction"))?;
let (low, high) = face_axial_span(context.scene, name, *origin, direction)?;
(
origin.add(direction.scale(low - extension)),
origin.add(direction.scale(high + extension)),
)
}
[(_, SelectionGeometry::Line { .. }), (_, SelectionGeometry::Line { .. })] => midline(frames[0].0, frames[1].0, extension, context)?,
[(name, other)] => {
return Err(format!(
"'{name}' is a {} — a centerline on one target needs a cylindrical or conical face (or pick two round features, or two parallel edges)",
class_of(other)
))
}
_ => through_centres(frames, extension)?,
};
let span = b.sub(a);
let mid = a.add(span.scale(0.5));
let side = perpendicular_in_plane(span, Vec3::new(1.0, 1.0, 1.0));
let default_label = mid.add(side.scale(span.length() * 0.1 + 2.0));
Ok(Resolved {
text: String::new(),
value: None,
unit: "",
references,
geometry: PmiGeometry::CenterLine { a: a3(a), b: a3(b) },
default_label: a3(default_label),
})
}
fn through_centres(frames: &[(&str, SelectionGeometry)], extension: f64) -> Result<(Vec3, Vec3), String> {
let mut stations: Vec<Station> = Vec::with_capacity(frames.len());
let mut reference: Option<Vec3> = None;
for (name, geometry) in frames {
let station = match *geometry {
SelectionGeometry::Circle { center, radius, .. } | SelectionGeometry::Sphere { center, radius } => Station { point: center, clearance: radius },
SelectionGeometry::Axis { origin, direction, radius } => {
let level = reference.unwrap_or(origin);
let d = direction.normalized().map_err(|_| format!("'{name}' has no axis direction"))?;
Station {
point: origin.add(d.scale(level.sub(origin).dot(d))),
clearance: radius.unwrap_or(0.0),
}
}
SelectionGeometry::Point { position } => Station { point: position, clearance: 0.0 },
ref other => {
return Err(format!(
"'{name}' is a {} — a centerline through centres needs circular edges, cylinders, cones or spheres",
class_of(other)
))
}
};
reference.get_or_insert(station.point);
stations.push(station);
}
let (mut i, mut j, mut best) = (0, 0, 0.0f64);
for (p, s) in stations.iter().enumerate() {
for (q, t) in stations.iter().enumerate().skip(p + 1) {
let d = s.point.sub(t.point).length();
if d > best {
(i, j, best) = (p, q, d);
}
}
}
if best < 1e-9 {
return Err("the picked features share one centre — pick features whose centres are apart, or use the mark style".into());
}
let d = stations[j].point.sub(stations[i].point).scale(1.0 / best);
let tolerance = 1e-6 * best.max(1.0);
for (k, station) in stations.iter().enumerate() {
let rel = station.point.sub(stations[i].point);
if rel.sub(d.scale(rel.dot(d))).length() > tolerance {
return Err(format!("the centre of '{}' is not on the line through the others", frames[k].0));
}
}
Ok((
stations[i].point.sub(d.scale(stations[i].clearance + extension)),
stations[j].point.add(d.scale(stations[j].clearance + extension)),
))
}
fn midline(first: &str, second: &str, extension: f64, context: &PmiContext<'_>) -> Result<(Vec3, Vec3), String> {
let (a0, a1) = edge_endpoints(context.scene, first)?;
let (b0, b1) = edge_endpoints(context.scene, second)?;
let da = a1.sub(a0);
let db = b1.sub(b0);
let (length_a, length_b) = (da.length(), db.length());
if length_a < 1e-9 || length_b < 1e-9 {
return Err("a straight edge has no length".into());
}
let d = da.scale(1.0 / length_a);
if d.cross(db.scale(1.0 / length_b)).length() > 1e-6 {
return Err(format!("'{first}' and '{second}' are not parallel — a centerline between edges needs parallel ones"));
}
let offset = b0.sub(a0).sub(d.scale(b0.sub(a0).dot(d)));
if offset.length() < 1e-9 {
return Err(format!("'{first}' and '{second}' lie on one line"));
}
let middle = a0.add(offset.scale(0.5));
let stations = [0.0, length_a, b0.sub(a0).dot(d), b1.sub(a0).dot(d)];
let low = stations.iter().cloned().fold(f64::INFINITY, f64::min);
let high = stations.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
Ok((middle.add(d.scale(low - extension)), middle.add(d.scale(high + extension))))
}