Skip to main content

brep_kernel/feature_pipeline/pmi/annotations/
angle.rs

1//! Angle dimension (`ANG`): the angle between two planar faces / planes /
2//! straight edges, folded to acute, obtuse or reflex.
3
4use super::{boolean_field, decimals_of, dimension_fields, id_field, options_field, params, reference_field, schema_entry, PmiTypeDef};
5use crate::feature_pipeline::pmi::resolve::{a3, class_of, direction_of, resolve_reference};
6use crate::feature_pipeline::pmi::{format_dimension, PmiAnnotation, PmiContext, PmiGeometry, Resolved, ToleranceBlock};
7use crate::feature_pipeline::SelectionProbe;
8use crate::{SelectionGeometry, Vec3};
9
10pub const DEF: PmiTypeDef = PmiTypeDef {
11    type_id: "angle",
12    short_name: "ANG",
13    icon: "\u{2220}",
14    long_name: "\u{2220} Angle dimension",
15    label: "Angle dimension",
16    applicable,
17    schema,
18    resolve,
19};
20
21/// Exactly two of faces / edges / planes.
22fn applicable(probe: &SelectionProbe) -> bool {
23    probe.faces + probe.edges + probe.planes == 2
24        && probe.vertices == 0
25        && probe.solids == 0
26        && probe.sketches == 0
27}
28
29fn schema() -> serde_json::Value {
30    let mut fields = params(vec![
31        ("id", id_field()),
32        (
33            "targets",
34            reference_field(
35                "Targets",
36                &["FACE", "EDGE", "PLANE"],
37                true,
38                2,
39                2,
40                "Two planar faces, planes or straight edges",
41            ),
42        ),
43        (
44            "angleType",
45            options_field(
46                "Angle",
47                &["acute", "obtuse", "reflex"],
48                "acute",
49                "Which of the four angles between the elements to dimension",
50            ),
51        ),
52        (
53            "reverseElementOrder",
54            boolean_field("Reverse order", false, "Sweep the arc from the second element to the first"),
55        ),
56    ]);
57    dimension_fields(&mut fields, 1);
58    schema_entry(&DEF, fields)
59}
60
61/// The arc plane direction of an element: a line's direction; for a plane,
62/// the in-plane direction perpendicular to the dihedral axis (`normal ×
63/// axis`), so the arc lies between the two faces as drawn on a drawing.
64fn arc_direction(geometry: &SelectionGeometry, axis: Vec3) -> Vec3 {
65    match geometry {
66        SelectionGeometry::Plane { normal, .. } => normal.cross(axis),
67        other => direction_of(other).unwrap_or(Vec3::new(1.0, 0.0, 0.0)),
68    }
69}
70
71/// The arc vertex: for two lines their closest points' midpoint; for two
72/// planes a point on the intersection line nearest the origins' midpoint;
73/// mixed: the line's crossing of the plane, else its foot.
74fn vertex_of(first: &SelectionGeometry, second: &SelectionGeometry, axis: Vec3) -> Vec3 {
75    let origin = |g: &SelectionGeometry| g.representative_point();
76    match (first, second) {
77        (
78            SelectionGeometry::Plane {
79                origin: oa,
80                normal: na,
81            },
82            SelectionGeometry::Plane {
83                origin: ob,
84                normal: nb,
85            },
86        ) => {
87            // Solve for a point on both planes closest to the origins' midpoint.
88            let mid = oa.add(*ob).scale(0.5);
89            let na = *na;
90            let nb = *nb;
91            let da = oa.sub(mid).dot(na);
92            let db = ob.sub(mid).dot(nb);
93            let nn = na.dot(nb);
94            let det = 1.0 - nn * nn;
95            if det.abs() < 1e-12 {
96                return mid;
97            }
98            let ka = (da - db * nn) / det;
99            let kb = (db - da * nn) / det;
100            let point = mid.add(na.scale(ka)).add(nb.scale(kb));
101            // Slide along the axis to the midpoint's foot.
102            let t = mid.sub(point).dot(axis);
103            point.add(axis.scale(t))
104        }
105        (SelectionGeometry::Plane { origin: po, normal }, line) | (line, SelectionGeometry::Plane { origin: po, normal }) => {
106            let lo = origin(line);
107            let ld = direction_of(line).unwrap_or(Vec3::new(1.0, 0.0, 0.0));
108            let denom = ld.dot(*normal);
109            if denom.abs() > 1e-9 {
110                let t = po.sub(lo).dot(*normal) / denom;
111                lo.add(ld.scale(t))
112            } else {
113                lo
114            }
115        }
116        (a, b) => {
117            let (oa, da) = (origin(a), direction_of(a).unwrap_or(Vec3::new(1.0, 0.0, 0.0)));
118            let (ob, db) = (origin(b), direction_of(b).unwrap_or(Vec3::new(0.0, 1.0, 0.0)));
119            let w = oa.sub(ob);
120            let aa = da.dot(da);
121            let bb = da.dot(db);
122            let cc = db.dot(db);
123            let dd = da.dot(w);
124            let ee = db.dot(w);
125            let denom = aa * cc - bb * bb;
126            if denom.abs() < 1e-12 {
127                return oa.add(ob).scale(0.5);
128            }
129            let s = (bb * ee - cc * dd) / denom;
130            let t = (aa * ee - bb * dd) / denom;
131            oa.add(da.scale(s)).add(ob.add(db.scale(t))).scale(0.5)
132        }
133    }
134}
135
136fn resolve(annotation: &PmiAnnotation, context: &PmiContext<'_>) -> Result<Resolved, String> {
137    let targets = annotation.references("targets");
138    let [first, second] = targets.as_slice() else {
139        return Err("select two planar faces, planes or straight edges".into());
140    };
141    let mut ga = resolve_reference(context.scene, first)?;
142    let mut gb = resolve_reference(context.scene, second)?;
143    if annotation.flag("reverseElementOrder") {
144        std::mem::swap(&mut ga, &mut gb);
145    }
146    for (name, geometry) in [(first, &ga), (second, &gb)] {
147        if direction_of(geometry).is_none() || matches!(geometry, SelectionGeometry::Circle { .. } | SelectionGeometry::Axis { .. }) {
148            return Err(format!(
149                "'{name}' is a {} — an angle needs planar faces, planes or straight edges",
150                class_of(geometry)
151            ));
152        }
153    }
154    let na = direction_of(&ga).expect("checked");
155    let nb = direction_of(&gb).expect("checked");
156    let axis = na.cross(nb);
157    if axis.length() < 1e-7 {
158        return Err("the two elements are parallel — there is no angle to dimension".into());
159    }
160    let axis = axis.normalized().map_err(|e| e.to_string())?;
161    let vertex = vertex_of(&ga, &gb, axis);
162    let u = arc_direction(&ga, axis).normalized().map_err(|e| e.to_string())?;
163    let v = arc_direction(&gb, axis).normalized().map_err(|e| e.to_string())?;
164    // The signed sweep from u to v about the axis, in (0, 360).
165    let mut sweep = u.cross(v).dot(axis).atan2(u.dot(v)).to_degrees();
166    if sweep < 0.0 {
167        sweep += 360.0;
168    }
169    let interior = if sweep > 180.0 { 360.0 - sweep } else { sweep };
170    let (dir_a, dir_b, degrees) = match annotation.text("angleType").to_ascii_lowercase().as_str() {
171        "obtuse" => {
172            if interior >= 90.0 {
173                (u, v, sweep.min(360.0 - sweep))
174            } else {
175                (u, v.scale(-1.0), 180.0 - interior)
176            }
177        }
178        "reflex" => {
179            let acute = if interior <= 90.0 { (u, v) } else { (u, v.scale(-1.0)) };
180            let inner = if interior <= 90.0 { interior } else { 180.0 - interior };
181            (acute.0, acute.1, 360.0 - (180.0 - inner))
182        }
183        _ => {
184            if interior <= 90.0 {
185                (u, v, interior)
186            } else {
187                (u, v.scale(-1.0), 180.0 - interior)
188            }
189        }
190    };
191    // Re-orient so `degrees` is the positive sweep from dir_a to dir_b about axis.
192    let signed = dir_a.cross(dir_b).dot(axis).atan2(dir_a.dot(dir_b)).to_degrees();
193    let axis = if signed < 0.0 { axis.scale(-1.0) } else { axis };
194    let decimals = decimals_of(annotation, context, 1.0);
195    let tolerance = ToleranceBlock::read(annotation, context.env)?;
196    let text = format_dimension(degrees, decimals, &tolerance, annotation.flag("isReference"), "", "\u{00B0}");
197    // Default label: along the bisector of the drawn arc.
198    let half = (degrees * 0.5).to_radians();
199    let bisector = rotate(dir_a, axis, half);
200    let scale = ga.representative_point().sub(vertex).length().max(gb.representative_point().sub(vertex).length()).max(1.0);
201    let default_label = vertex.add(bisector.scale(scale * 0.6));
202    Ok(Resolved {
203        text,
204        value: Some(degrees),
205        unit: "deg",
206        references: targets,
207        geometry: PmiGeometry::Angular {
208            vertex: a3(vertex),
209            dir_a: a3(dir_a),
210            dir_b: a3(dir_b),
211            axis: a3(axis),
212            degrees,
213        },
214        default_label: a3(default_label),
215    })
216}
217
218/// Rodrigues rotation of `v` about unit `axis` by `angle` radians.
219pub fn rotate(v: Vec3, axis: Vec3, angle: f64) -> Vec3 {
220    v.rotate_about(axis, angle)
221}