Skip to main content

brep_kernel/feature_pipeline/pmi/
layout.rs

1//! PMI presentation layout — the polylines an annotation is DRAWN with, laid
2//! out from its resolved geometry and its label position. ONE implementation
3//! serves the viewport overlay (screen-constant sizes, from the camera's
4//! world-per-pixel) and the AP242 polyline presentation (fixed model-unit
5//! sizes from the view's text size), so the file shows what the screen shows.
6
7use super::{font, PmiGeometry, PmiPlane};
8use crate::feature_pipeline::pmi::annotations::angle::rotate;
9use crate::feature_pipeline::pmi::resolve::{a3, perpendicular_in_plane, v3};
10use crate::Vec3;
11
12/// Sizing and orientation inputs.
13#[derive(Debug, Clone, Copy)]
14pub struct LayoutStyle {
15    /// Arrowhead length (world units).
16    pub arrow: f64,
17    /// Text cap height (world units) — sizes datum / FCF frames.
18    pub text_height: f64,
19    /// The unit viewing direction (eye → target): arrowheads and frames lie
20    /// in the plane perpendicular to it.
21    pub view_dir: [f64; 3],
22    /// The camera's unit up vector.
23    pub view_up: [f64; 3],
24    /// The annotation's picked plane: the drawing lies IN it (label and
25    /// measured points projected onto it, its normal and text direction
26    /// replacing the camera's). `None` aligns to the camera.
27    pub plane: Option<PmiPlane>,
28}
29
30/// The drawn presentation.
31#[derive(Debug, Clone, Default, PartialEq)]
32pub struct Presentation {
33    /// Open polylines: dimension lines, extension lines, leaders, arcs, trace
34    /// segments.
35    pub polylines: Vec<Vec<[f64; 3]>>,
36    /// Filled arrowheads (and the datum triangle), as triangles.
37    pub arrows: Vec<[[f64; 3]; 3]>,
38    /// Closed frames (datum box, FCF cells) — drawn in the STEP presentation;
39    /// the viewport draws its label chip instead.
40    pub frames: Vec<Vec<[f64; 3]>>,
41    /// The text and its anchor (the label position) — the viewport's chip.
42    pub text: String,
43    pub text_anchor: [f64; 3],
44    /// The text as placed runs — one centred on the label, or the datum
45    /// letter in its box, or one per FCF cell — which the STEP presentation
46    /// strokes into polylines ([`TextRun::strokes`]).
47    pub texts: Vec<TextRun>,
48}
49
50/// A run of text centred on `anchor`, read along `right` with `up`, at cap
51/// height `height`.
52#[derive(Debug, Clone, PartialEq)]
53pub struct TextRun {
54    pub text: String,
55    pub anchor: [f64; 3],
56    pub right: [f64; 3],
57    pub up: [f64; 3],
58    pub height: f64,
59}
60
61impl TextRun {
62    /// The run stroked with the PMI font, centred on its anchor.
63    pub fn strokes(&self) -> Vec<Vec<[f64; 3]>> {
64        let width = font::text_width(&self.text, self.height);
65        let origin = v3(self.anchor)
66            .sub(v3(self.right).scale(width * 0.5))
67            .sub(v3(self.up).scale(self.height * 0.5));
68        font::stroke_text(&self.text, a3(origin), self.right, self.up, self.height)
69    }
70}
71
72fn unit_or(v: Vec3, fallback: Vec3) -> Vec3 {
73    if v.length() < 1e-12 {
74        fallback
75    } else {
76        v.normalized().unwrap_or(fallback)
77    }
78}
79
80/// The in-view width direction of an arrow along `dir`.
81fn arrow_width_dir(dir: Vec3, style: &LayoutStyle) -> Vec3 {
82    let view = v3(style.view_dir);
83    let w = view.cross(dir);
84    if w.length() < 1e-6 {
85        dir.perpendicular().unwrap_or(Vec3::new(0.0, 1.0, 0.0))
86    } else {
87        w.normalized().unwrap_or(Vec3::new(0.0, 1.0, 0.0))
88    }
89}
90
91/// An arrowhead with its tip at `tip`, pointing along `dir` (the body
92/// trails behind the tip).
93fn arrowhead(tip: Vec3, dir: Vec3, style: &LayoutStyle) -> [[f64; 3]; 3] {
94    let dir = unit_or(dir, Vec3::new(1.0, 0.0, 0.0));
95    let w = arrow_width_dir(dir, style);
96    let base = tip.sub(dir.scale(style.arrow));
97    let half = style.arrow * 0.35;
98    [a3(tip), a3(base.add(w.scale(half))), a3(base.sub(w.scale(half)))]
99}
100
101/// A small closed square around `center` in the view plane (the leader's
102/// dot end style).
103fn dot(center: Vec3, style: &LayoutStyle) -> Vec<[f64; 3]> {
104    let view = v3(style.view_dir);
105    let up = perpendicular_in_plane(view, v3(style.view_up));
106    let right = view.cross(up);
107    let r = style.arrow * 0.25;
108    let mut out = Vec::with_capacity(9);
109    for k in 0..=8 {
110        let angle = k as f64 * std::f64::consts::TAU / 8.0;
111        out.push(a3(center.add(right.scale(r * angle.cos())).add(up.scale(r * angle.sin()))));
112    }
113    out
114}
115
116/// A rectangle of `width` × `height` whose left edge is centred at `left` in
117/// the view plane.
118fn frame_rect(left: Vec3, width: f64, height: f64, right: Vec3, up: Vec3) -> Vec<[f64; 3]> {
119    let h = up.scale(height * 0.5);
120    let w = right.scale(width);
121    vec![
122        a3(left.sub(h)),
123        a3(left.add(w).sub(h)),
124        a3(left.add(w).add(h)),
125        a3(left.add(h)),
126        a3(left.sub(h)),
127    ]
128}
129
130/// A centre mark: a short cross at `center` (its arms a quarter of the
131/// radius), a gap, then a long dash along each arm out to `extension` past
132/// the circle. Sized by the feature, so it scales with the part.
133fn center_cross(center: Vec3, u: Vec3, v: Vec3, radius: f64, extension: f64) -> Vec<Vec<[f64; 3]>> {
134    let arm = radius * 0.25;
135    let gap = radius * 0.12;
136    let reach = radius + extension;
137    let mut out = vec![
138        vec![a3(center.sub(u.scale(arm))), a3(center.add(u.scale(arm)))],
139        vec![a3(center.sub(v.scale(arm))), a3(center.add(v.scale(arm)))],
140    ];
141    for dir in [u, u.scale(-1.0), v, v.scale(-1.0)] {
142        out.push(vec![a3(center.add(dir.scale(arm + gap))), a3(center.add(dir.scale(reach)))]);
143    }
144    out
145}
146
147/// A chain line from `a` to `b`: long dashes and short dashes separated by
148/// gaps, a long dash at each end. The pattern is sized from the arrowhead
149/// (`unit`), and the long dashes stretch so a whole number of cycles fills
150/// the line; a line too short for one cycle is drawn solid.
151pub(crate) fn chain_line(a: Vec3, b: Vec3, unit: f64) -> Vec<Vec<[f64; 3]>> {
152    let span = b.sub(a);
153    let length = span.length();
154    if length < 1e-12 {
155        return Vec::new();
156    }
157    let d = span.scale(1.0 / length);
158    let long = unit * 6.0;
159    let short = unit * 1.2;
160    let gap = unit * 0.6;
161    let cycles = if unit > 0.0 { ((length - long) / (long + short + 2.0 * gap)).floor() } else { 0.0 };
162    if !(cycles >= 1.0) {
163        return vec![vec![a3(a), a3(b)]];
164    }
165    let cycles = cycles as usize;
166    let long = (length - cycles as f64 * (short + 2.0 * gap)) / (cycles + 1) as f64;
167    let mut out = Vec::with_capacity(2 * cycles + 1);
168    let mut at = 0.0;
169    for k in 0..=cycles {
170        out.push(vec![a3(a.add(d.scale(at))), a3(a.add(d.scale((at + long).min(length))))]);
171        at += long;
172        if k < cycles {
173            at += gap;
174            out.push(vec![a3(a.add(d.scale(at))), a3(a.add(d.scale(at + short)))]);
175            at += short + gap;
176        }
177    }
178    out
179}
180
181/// Lay out `geometry` with its label at `label`.
182pub fn present(geometry: &PmiGeometry, label: [f64; 3], text: &str, style: &LayoutStyle) -> Presentation {
183    let mut out = Presentation {
184        text: text.to_string(),
185        text_anchor: label,
186        ..Default::default()
187    };
188    // In a picked annotation plane the drawing lies in the plane: the
189    // plane's normal (facing the camera) and text direction stand in for
190    // the camera's, and the label / measured points project onto it.
191    let plane = style.plane;
192    let project = |p: Vec3| -> Vec3 {
193        match &plane {
194            Some(plane) => v3(plane.project(a3(p))),
195            None => p,
196        }
197    };
198    let in_plane;
199    let style = match &plane {
200        Some(plane) => {
201            let n = v3(plane.normal);
202            in_plane = LayoutStyle {
203                view_dir: a3(n.scale(-1.0)),
204                view_up: plane.y_axis(),
205                ..*style
206            };
207            &in_plane
208        }
209        None => style,
210    };
211    let label = project(v3(label));
212    out.text_anchor = a3(label);
213    let view = v3(style.view_dir);
214    let up = perpendicular_in_plane(view, v3(style.view_up));
215    let right = view.cross(up);
216    match geometry {
217        PmiGeometry::None | PmiGeometry::Note { .. } => {}
218        PmiGeometry::Linear { a, b, .. } => {
219            let a = project(v3(*a));
220            let b = project(v3(*b));
221            let span = b.sub(a);
222            let length = span.length();
223            if length < 1e-9 {
224                out.polylines.push(vec![a3(label), a3(a)]);
225                return out;
226            }
227            let d = span.scale(1.0 / length);
228            let rel = label.sub(a);
229            let t = rel.dot(d);
230            let off = rel.sub(d.scale(t));
231            let a_line = a.add(off);
232            let b_line = b.add(off);
233            // The dimension line always reaches the label's station.
234            let lo = t.min(0.0);
235            let hi = t.max(length);
236            out.polylines.push(vec![a3(a.add(off).add(d.scale(lo))), a3(a.add(off).add(d.scale(hi)))]);
237            if off.length() > 1e-9 {
238                let n = off.normalized().unwrap_or(Vec3::new(0.0, 1.0, 0.0));
239                let overshoot = n.scale(style.arrow * 0.6);
240                out.polylines.push(vec![a3(a), a3(a_line.add(overshoot))]);
241                out.polylines.push(vec![a3(b), a3(b_line.add(overshoot))]);
242            }
243            if length > style.arrow * 2.5 {
244                out.arrows.push(arrowhead(a_line, d.scale(-1.0), style));
245                out.arrows.push(arrowhead(b_line, d, style));
246            } else {
247                // Too short for inside arrows: outside, pointing at the ends.
248                out.arrows.push(arrowhead(a_line, d, style));
249                out.arrows.push(arrowhead(b_line, d.scale(-1.0), style));
250                out.polylines.push(vec![a3(a_line.sub(d.scale(style.arrow * 1.5))), a3(a_line)]);
251                out.polylines.push(vec![a3(b_line), a3(b_line.add(d.scale(style.arrow * 1.5)))]);
252            }
253        }
254        PmiGeometry::Radial {
255            center,
256            axis,
257            radius,
258            diameter,
259            sphere,
260        } => {
261            let center = project(v3(*center));
262            let axis = if *sphere || plane.is_some() { view } else { v3(*axis) };
263            let u = perpendicular_in_plane(axis, label.sub(center));
264            let p = center.add(u.scale(*radius));
265            let outside = label.sub(center).length() > *radius;
266            if *diameter {
267                let q = center.sub(u.scale(*radius));
268                out.polylines.push(vec![a3(q), a3(p)]);
269                out.arrows.push(arrowhead(p, u, style));
270                out.arrows.push(arrowhead(q, u.scale(-1.0), style));
271                if outside {
272                    out.polylines.push(vec![a3(p), a3(label)]);
273                }
274            } else {
275                if outside {
276                    out.polylines.push(vec![a3(label), a3(p)]);
277                    out.arrows.push(arrowhead(p, u.scale(-1.0), style));
278                } else {
279                    out.polylines.push(vec![a3(center), a3(p)]);
280                    out.arrows.push(arrowhead(p, u, style));
281                }
282            }
283        }
284        PmiGeometry::Angular {
285            vertex,
286            dir_a,
287            dir_b,
288            axis,
289            degrees,
290        } => {
291            let vertex = project(v3(*vertex));
292            let axis = v3(*axis);
293            let dir_a = v3(*dir_a);
294            let dir_b = v3(*dir_b);
295            let rel = label.sub(vertex);
296            let planar = rel.sub(axis.scale(rel.dot(axis)));
297            let rho = planar.length().max(style.arrow * 2.0);
298            let steps = ((degrees / 5.0).ceil() as usize).max(8);
299            let mut arc = Vec::with_capacity(steps + 1);
300            for k in 0..=steps {
301                let angle = degrees.to_radians() * k as f64 / steps as f64;
302                arc.push(a3(vertex.add(rotate(dir_a, axis, angle).scale(rho))));
303            }
304            let start = v3(arc[0]);
305            let end = v3(arc[steps]);
306            out.polylines.push(arc);
307            let overshoot = rho + style.arrow * 0.6;
308            out.polylines.push(vec![a3(vertex), a3(vertex.add(dir_a.scale(overshoot)))]);
309            out.polylines.push(vec![a3(vertex), a3(vertex.add(dir_b.scale(overshoot)))]);
310            let tangent_start = axis.cross(dir_a);
311            let tangent_end = axis.cross(dir_b);
312            out.arrows.push(arrowhead(start, tangent_start.scale(-1.0), style));
313            out.arrows.push(arrowhead(end, tangent_end, style));
314        }
315        PmiGeometry::Leader { targets, dot: use_dot, balloon, .. } => {
316            let radius = style.text_height * (text.chars().count() as f64 * font::ADVANCE * 0.5 + 0.7).max(1.0);
317            if *balloon {
318                out.frames.push((0..=48).map(|i| {
319                    let angle = std::f64::consts::TAU * i as f64 / 48.0;
320                    a3(label.add(right.scale(radius * angle.cos())).add(up.scale(radius * angle.sin())))
321                }).collect());
322            }
323            for target in targets {
324                let target = v3(*target);
325                let start = if *balloon { label.add(unit_or(target.sub(label), right).scale(radius)) } else { label };
326                out.polylines.push(vec![a3(start), a3(target)]);
327                if *use_dot {
328                    out.polylines.push(dot(target, style));
329                } else {
330                    out.arrows.push(arrowhead(target, target.sub(label), style));
331                }
332            }
333        }
334        PmiGeometry::Hole { anchor, .. } => {
335            let anchor = v3(*anchor);
336            out.polylines.push(vec![a3(label), a3(anchor)]);
337            out.arrows.push(arrowhead(anchor, anchor.sub(label), style));
338        }
339        PmiGeometry::Explode {
340            center,
341            translate,
342            trace,
343            ..
344        } => {
345            // The trace is an insertion arrow (owner 2026-11-07, the
346            // assembly-instructions look): one thin solid line from where the
347            // parts are exploded to their seat, a filled head at the seat.
348            if *trace {
349                let seat = v3(*center);
350                let exploded = seat.add(v3(*translate));
351                let span = seat.sub(exploded);
352                if span.length() > 1e-9 {
353                    out.polylines.push(vec![a3(exploded), a3(seat)]);
354                    out.arrows.push(arrowhead(seat, span, style));
355                }
356            }
357        }
358        // A hide step draws nothing at all.
359        PmiGeometry::Hide { .. } => {}
360        PmiGeometry::Datum { anchor, letter, .. } => {
361            let anchor = v3(*anchor);
362            let dir = unit_or(label.sub(anchor), up);
363            // The datum triangle: base ON the feature, apex toward the label.
364            let apex = anchor.add(dir.scale(style.arrow));
365            let w = arrow_width_dir(dir, style);
366            let half = style.arrow * 0.5;
367            out.arrows.push([a3(apex), a3(anchor.add(w.scale(half))), a3(anchor.sub(w.scale(half)))]);
368            out.polylines.push(vec![a3(apex), a3(label)]);
369            let side = style.text_height * 1.8;
370            let width = side.max(style.text_height * font::ADVANCE * letter.chars().count() as f64 + style.text_height);
371            out.frames.push(frame_rect(label.sub(right.scale(width * 0.5)), width, side, right, up));
372            out.texts.push(TextRun {
373                text: letter.clone(),
374                anchor: a3(label),
375                right: a3(right),
376                up: a3(up),
377                height: style.text_height,
378            });
379        }
380        PmiGeometry::CenterMark { marks, extension } => {
381            for mark in marks {
382                let axis = v3(mark.axis);
383                // A hole's or a shaft's mark goes on the end facing the
384                // viewer, not on the rim behind the part.
385                let mut center = v3(mark.center);
386                if mark.depth > 0.0 && axis.dot(view) > 0.0 {
387                    center = center.sub(axis.scale(mark.depth));
388                }
389                // The cross is fixed in the model, never turned by the
390                // camera: it lies in the circle's plane (a sphere's in the
391                // model XY plane), one arm along the resolved `x_dir`, the
392                // other square to it. In a plane (a picked one, parallel to
393                // the circle; or the view-parallel plane a file export lays
394                // a row in) each point is projected onto it, so the file
395                // carries the cross as the view shows it.
396                let axis = if axis.length() < 1e-12 { Vec3::new(0.0, 0.0, 1.0) } else { axis };
397                let x_dir = v3(mark.x_dir);
398                let x_dir = if x_dir.length() > 1e-12 {
399                    x_dir
400                } else if axis.cross(Vec3::new(1.0, 0.0, 0.0)).length() > 1e-6 {
401                    Vec3::new(1.0, 0.0, 0.0)
402                } else {
403                    Vec3::new(0.0, 1.0, 0.0)
404                };
405                let u = perpendicular_in_plane(axis, x_dir);
406                let v = unit_or(axis.cross(u), up);
407                out.polylines.extend(
408                    center_cross(center, u, v, mark.radius, *extension)
409                        .into_iter()
410                        .map(|line| line.into_iter().map(|p| a3(project(v3(p)))).collect::<Vec<_>>()),
411                );
412            }
413        }
414        PmiGeometry::CenterLine { a, b } => {
415            out.polylines.extend(chain_line(project(v3(*a)), project(v3(*b)), style.arrow));
416        }
417        PmiGeometry::Fcf { anchor, frame, .. } => {
418            let anchor = v3(*anchor);
419            out.polylines.push(vec![a3(label), a3(anchor)]);
420            out.arrows.push(arrowhead(anchor, anchor.sub(label), style));
421            let height = style.text_height * 1.8;
422            let char_w = style.text_height * font::ADVANCE;
423            let mut cells: Vec<(f64, &str)> = vec![(height, frame.symbol.as_str())];
424            cells.push((char_w * frame.zone.chars().count() as f64 + style.text_height, frame.zone.as_str()));
425            for datum in &frame.datums {
426                cells.push((char_w * datum.chars().count() as f64 + style.text_height, datum.as_str()));
427            }
428            let total: f64 = cells.iter().map(|(w, _)| w).sum();
429            let mut left = label.sub(right.scale(total * 0.5));
430            for (width, cell_text) in cells {
431                out.frames.push(frame_rect(left, width, height, right, up));
432                out.texts.push(TextRun {
433                    text: cell_text.to_string(),
434                    anchor: a3(left.add(right.scale(width * 0.5))),
435                    right: a3(right),
436                    up: a3(up),
437                    height: style.text_height,
438                });
439                left = left.add(right.scale(width));
440            }
441        }
442    }
443    // Every other annotation reads as one run centred on its label. The
444    // display-only steps are not annotations: an explode step draws no label
445    // anywhere (owner 2026-10-26 / 2026-10-27: "Exploding a part should not
446    // result in any additional text being displayed") and a hide step draws
447    // nothing, so neither gets the fallback run — no consumer has to strip it.
448    let display_only = matches!(geometry, PmiGeometry::Explode { .. } | PmiGeometry::Hide { .. });
449    if out.texts.is_empty() && !text.is_empty() && !display_only {
450        out.texts.push(TextRun {
451            text: text.to_string(),
452            anchor: a3(label),
453            right: a3(right),
454            up: a3(up),
455            height: style.text_height,
456        });
457    }
458    out
459}