Skip to main content

ogeom_hlr/
section.rs

1//! Section views: cut the part, draw what the plane reveals.
2//!
3//! A section view is a boolean wearing drawing clothes. The material on the
4//! plane's positive side is removed (through the general cut, against a
5//! proxy box sized off the shape's own bounds, so every downstream guarantee
6//! the boolean makes holds here too), and the faces the cut created *on* the
7//! plane become the section outline: the closed loops a draughtsman hatches.
8//! The rest of the drawing is the cut solid through the projection machinery,
9//! viewed straight down the plane's normal.
10//!
11//! A broken-out section is the same construction with the proxy box shrunk
12//! to a window: the cut reveals the interior only where the break is.
13
14use crate::project::{Drawing, View, project};
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_math::{Frame, Plane, Point, Point2};
17use ogeom_mesh::Deflection;
18use ogeom_topo::{Filter, Model, NodeData, Shape, ShapeType, explore};
19
20/// A section view: the outline on the cutting plane, and the drawing of what
21/// remains behind it.
22#[derive(Debug, Clone)]
23pub struct SectionView {
24    /// The closed loops where the plane cut material, in the plane's own
25    /// `(x, y)` coordinates: one outer loop per cut face, holes after it, in
26    /// the face's own wire order.
27    pub outline: Vec<Vec<Point2>>,
28    /// The remaining solid, projected along the plane's normal.
29    pub drawing: Drawing,
30    /// The solid the cut produced, for measuring or further sectioning.
31    pub remainder: Shape,
32}
33
34/// Cut `solid` at `plane` and draw the section.
35///
36/// Material on the plane's `+z` side is removed. The outline loops are the
37/// cut faces' boundaries in the plane's `(x, y)`; the drawing views the
38/// remainder along the plane's normal.
39///
40/// # Errors
41///
42/// As the boolean cut and [`project()`]; and
43/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
44/// plane misses the solid entirely.
45pub fn section(
46    model: &mut Model,
47    solid: &Shape,
48    plane: &Plane,
49    deflection: Deflection,
50    tol: Tolerances,
51) -> OgeomResult<SectionView> {
52    let reach = reach_of(model, solid, tol)?;
53    section_with_window(
54        model,
55        solid,
56        plane,
57        (-reach, -reach),
58        (reach, reach),
59        deflection,
60        tol,
61    )
62}
63
64/// Cut only within a window of the plane: the broken-out section.
65///
66/// The window is a rectangle in the plane's `(x, y)`, and only material on
67/// the `+z` side behind that rectangle is removed.
68///
69/// # Errors
70///
71/// As [`section()`].
72pub fn broken_section(
73    model: &mut Model,
74    solid: &Shape,
75    plane: &Plane,
76    window_min: (f64, f64),
77    window_max: (f64, f64),
78    deflection: Deflection,
79    tol: Tolerances,
80) -> OgeomResult<SectionView> {
81    section_with_window(model, solid, plane, window_min, window_max, deflection, tol)
82}
83
84/// The shared construction: a proxy box over the window, cut, outline, draw.
85#[allow(clippy::too_many_arguments)]
86fn section_with_window(
87    model: &mut Model,
88    solid: &Shape,
89    plane: &Plane,
90    window_min: (f64, f64),
91    window_max: (f64, f64),
92    deflection: Deflection,
93    tol: Tolerances,
94) -> OgeomResult<SectionView> {
95    let reach = reach_of(model, solid, tol)?;
96    let frame = plane.frame();
97    // The proxy box: one face exactly on the plane, extending along +z; its
98    // footprint is the window. Slight overshoot along z keeps the far face
99    // clear of the solid.
100    let corner = frame.to_world(Point::new(window_min.0, window_min.1, 0.0));
101    let box_frame = Frame::new(corner, frame.z(), frame.x(), tol)?;
102    let sizes = (
103        window_max.0 - window_min.0,
104        window_max.1 - window_min.1,
105        reach,
106    );
107    if sizes.0 <= tol.confusion() || sizes.1 <= tol.confusion() {
108        ogeom_bail!(Construction, "a section window must have area");
109    }
110    let proxy = ogeom_algo::make_box(model, box_frame, sizes, tol)?;
111    let cut = ogeom_bool::cut(model, solid, &proxy.shape, tol)?;
112
113    // The cut faces on the plane are the section outline. Their rings come
114    // from the same boundary machinery the triangulator trusts (walked in
115    // order, seams and orientations resolved) and lift from the face's own
116    // chart into the section plane's coordinates through the surface.
117    let mut outline = Vec::new();
118    for face in explore(model, &cut.shape, Filter::OfType(ShapeType::Face))? {
119        let Some(surface) = face_on_plane(model, &face, plane, tol)? else {
120            continue;
121        };
122        let placement = face.transform(model.datums())?;
123        for ring in ogeom_mesh::face_boundary(model, &face, deflection, tol)? {
124            let mut loop_points = Vec::with_capacity(ring.len());
125            for uv in ring {
126                use ogeom_geom::Surface as _;
127                let world = placement.apply(surface.point_at(uv.x, uv.y, tol)?);
128                let local = frame.to_local(world);
129                loop_points.push(Point2::new(local.x, local.y));
130            }
131            if loop_points.len() >= 3 {
132                outline.push(loop_points);
133            }
134        }
135    }
136    if outline.is_empty() {
137        ogeom_bail!(Construction, "the section plane misses the solid");
138    }
139
140    let view = View::looking(-frame.z().vector(), frame.y().vector(), tol)?;
141    let drawing = project(model, &cut.shape, &view, deflection, tol)?;
142    Ok(SectionView {
143        outline,
144        drawing,
145        remainder: cut.shape,
146    })
147}
148
149/// A margin that certainly covers the solid from any plane through it.
150fn reach_of(model: &Model, solid: &Shape, tol: Tolerances) -> OgeomResult<f64> {
151    let bounds = ogeom_algo::shape_bounds(model, solid, tol)?;
152    Ok(bounds.diagonal().max(1.0) * 2.0)
153}
154
155/// The face's surface, when the face lies on the section plane.
156fn face_on_plane(
157    model: &Model,
158    face: &Shape,
159    plane: &Plane,
160    tol: Tolerances,
161) -> OgeomResult<Option<ogeom_geom::SurfaceGeometry>> {
162    let Some(node) = model.node(face) else {
163        return Ok(None);
164    };
165    let NodeData::Face(data) = node.data() else {
166        return Ok(None);
167    };
168    let Some(surface) = model.geometry().surface(data.surface) else {
169        return Ok(None);
170    };
171    let ogeom_geom::SurfaceGeometry::Plane(planar) = surface else {
172        return Ok(None);
173    };
174    let placement = face.transform(model.datums())?;
175    let own = planar.plane().frame();
176    let origin = placement.apply(own.origin());
177    let normal = placement.apply_vector(own.z().vector());
178    let aligned = normal.cross(plane.frame().z().vector()).magnitude() <= 1e-9;
179    let on = plane.distance_to(origin).abs() <= tol.confusion() * 1e3;
180    Ok((aligned && on).then(|| surface.clone()))
181}
182
183/// Cut away one quarter of the part and draw the half-section.
184///
185/// The plane's frame states the whole convention: material on the `+z`
186/// side is removed, but only over the frame's `+x` half; the split line
187/// is the frame's own `y` axis. The section outline covers the cut half,
188/// hatched by the draughtsman's convention through [`hatch`]; the drawing
189/// shows the other half in outside view, which is what a half-section is
190/// for.
191///
192/// # Errors
193///
194/// As [`section()`].
195pub fn half_section(
196    model: &mut Model,
197    solid: &Shape,
198    plane: &Plane,
199    deflection: Deflection,
200    tol: Tolerances,
201) -> OgeomResult<SectionView> {
202    let reach = reach_of(model, solid, tol)?;
203    section_with_window(
204        model,
205        solid,
206        plane,
207        (0.0, -reach),
208        (reach, reach),
209        deflection,
210        tol,
211    )
212}
213
214/// Hatch the section outline: parallel lines at `angle`, `spacing` apart,
215/// clipped to the material by the even-odd rule.
216///
217/// The outline loops are taken as [`SectionView::outline`] hands them over
218/// (outer loops and holes together), so a hole interrupts the hatching
219/// exactly as it interrupts the material. Each returned pair is one hatch
220/// stroke in the plane's `(x, y)`.
221#[must_use]
222pub fn hatch(outline: &[Vec<Point2>], spacing: f64, angle: f64) -> Vec<(Point2, Point2)> {
223    if !(spacing.is_finite() && spacing > 0.0) || outline.is_empty() {
224        return Vec::new();
225    }
226    let (c, s) = (angle.cos(), angle.sin());
227    // Into the hatch frame: strokes run along local x, lines stack in y.
228    let into = |p: Point2| Point2::new(p.x * c + p.y * s, -p.x * s + p.y * c);
229    let back = |p: Point2| Point2::new(p.x * c - p.y * s, p.x * s + p.y * c);
230    let mut lo = (f64::INFINITY, f64::INFINITY);
231    let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
232    let turned: Vec<Vec<Point2>> = outline
233        .iter()
234        .map(|ring| ring.iter().map(|p| into(*p)).collect())
235        .collect();
236    for ring in &turned {
237        for p in ring {
238            lo = (lo.0.min(p.x), lo.1.min(p.y));
239            hi = (hi.0.max(p.x), hi.1.max(p.y));
240        }
241    }
242    if !(lo.0.is_finite() && hi.0.is_finite()) {
243        return Vec::new();
244    }
245    let mut out = Vec::new();
246    // The first line half a step in, so a shape exactly one spacing tall
247    // still receives a stroke.
248    let mut y = lo.1 + spacing * 0.5;
249    while y < hi.1 {
250        // Every crossing of this scanline with every loop edge; sorted,
251        // then paired even-odd: inside between the first and second,
252        // outside between the second and third, and so on.
253        let mut crossings: Vec<f64> = Vec::new();
254        for ring in &turned {
255            let n = ring.len();
256            for i in 0..n {
257                let (a, b) = (ring[i], ring[(i + 1) % n]);
258                if (a.y <= y) == (b.y <= y) {
259                    continue;
260                }
261                crossings.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y));
262            }
263        }
264        crossings.sort_by(|p, q| p.partial_cmp(q).unwrap_or(core::cmp::Ordering::Equal));
265        for pair in crossings.as_chunks::<2>().0 {
266            if pair[1] - pair[0] > f64::EPSILON {
267                out.push((back(Point2::new(pair[0], y)), back(Point2::new(pair[1], y))));
268            }
269        }
270        y += spacing;
271    }
272    out
273}
274
275#[cfg(test)]
276#[allow(clippy::unwrap_used, clippy::expect_used)]
277mod tests {
278    use super::*;
279    use ogeom_math::{Direction, Vector};
280
281    const T: Tolerances = Tolerances::millimetres();
282
283    fn fine() -> Deflection {
284        Deflection {
285            chord: 1e-3,
286            ..Deflection::default()
287        }
288    }
289
290    /// Signed shoelace area of a loop.
291    fn area(points: &[Point2]) -> f64 {
292        let mut sum = 0.0;
293        for pair in points.windows(2) {
294            sum += pair[0].x.mul_add(pair[1].y, -(pair[1].x * pair[0].y));
295        }
296        if let (Some(first), Some(last)) = (points.first(), points.last()) {
297            sum += last.x.mul_add(first.y, -(first.x * last.y));
298        }
299        sum / 2.0
300    }
301
302    /// Total material area: outer loops positive, holes negative, by winding.
303    fn material(outline: &[Vec<Point2>]) -> f64 {
304        outline.iter().map(|l| area(l)).sum::<f64>().abs()
305    }
306
307    #[test]
308    fn a_box_sections_into_its_cross_section() {
309        let mut model = Model::new();
310        let solid = ogeom_algo::make_box(&mut model, Frame::WORLD, (10.0, 6.0, 4.0), T).unwrap();
311        // The plane x = 5, normal +x: the half x > 5 is removed.
312        let plane = Plane::new(
313            Frame::new(Point::new(5.0, 0.0, 0.0), Direction::X, Direction::Y, T).unwrap(),
314        );
315        let view = section(&mut model, &solid.shape, &plane, fine(), T).unwrap();
316        assert!(
317            (material(&view.outline) - 24.0).abs() < 1e-6,
318            "6 x 4 revealed, got {} from {} loops {:?}",
319            material(&view.outline),
320            view.outline.len(),
321            view.outline.iter().map(|l| area(l)).collect::<Vec<_>>()
322        );
323
324        // Half the box remains.
325        let volume = ogeom_algo::volume_properties(&model, &view.remainder, fine(), T)
326            .unwrap()
327            .mass;
328        assert!((volume - 120.0).abs() < 0.1);
329        assert!(!view.drawing.visible.is_empty());
330    }
331
332    #[test]
333    fn a_bored_block_sections_through_its_hole() {
334        let mut model = Model::new();
335        let block = ogeom_algo::make_box(&mut model, Frame::WORLD, (10.0, 6.0, 4.0), T).unwrap();
336        let bore_frame =
337            Frame::new(Point::new(5.0, 3.0, -1.0), Direction::Z, Direction::X, T).unwrap();
338        let bore = ogeom_algo::make_cylinder(&mut model, bore_frame, 1.0, 6.0, T).unwrap();
339        let part = ogeom_bool::cut(&mut model, &block.shape, &bore.shape, T).unwrap();
340
341        // Section through the bore, half a radius off its axis: the plane
342        // exactly through the axis meets the wall along its rulings, a
343        // configuration the boolean still refuses (honestly), so the test
344        // sections where the answer is just as exact: the slot's width is
345        // the chord at that offset.
346        let plane = Plane::new(
347            Frame::new(Point::new(0.0, 2.5, 0.0), Direction::Y, Direction::Z, T).unwrap(),
348        );
349        let view = section(&mut model, &part.shape, &plane, fine(), T).unwrap();
350        let chord = 2.0 * (1.0_f64 - 0.25).sqrt();
351        let expected = 10.0f64.mul_add(4.0, -(chord * 4.0));
352        assert!(
353            (material(&view.outline) - expected).abs() < 1e-3,
354            "revealed {} against {expected}",
355            material(&view.outline)
356        );
357    }
358
359    #[test]
360    fn a_broken_section_reveals_only_its_window() {
361        let mut model = Model::new();
362        let solid = ogeom_algo::make_box(&mut model, Frame::WORLD, (10.0, 6.0, 4.0), T).unwrap();
363        let plane = Plane::new(
364            Frame::new(Point::new(5.0, 0.0, 0.0), Direction::X, Direction::Y, T).unwrap(),
365        );
366        // A window over y in [1, 3] and z in [1, 3]: four square units.
367        let view = broken_section(
368            &mut model,
369            &solid.shape,
370            &plane,
371            (1.0, 1.0),
372            (3.0, 3.0),
373            fine(),
374            T,
375        )
376        .unwrap();
377        assert!((material(&view.outline) - 4.0).abs() < 1e-6);
378        // Only the window's pocket is missing.
379        let volume = ogeom_algo::volume_properties(&model, &view.remainder, fine(), T)
380            .unwrap()
381            .mass;
382        let expected = 240.0 - 4.0 * 5.0;
383        assert!(
384            (volume - expected).abs() < 0.1,
385            "{volume} against {expected}"
386        );
387    }
388
389    #[test]
390    fn a_plane_that_misses_the_solid_is_refused() {
391        let mut model = Model::new();
392        let solid = ogeom_algo::make_box(&mut model, Frame::WORLD, (10.0, 6.0, 4.0), T).unwrap();
393        let plane = Plane::new(
394            Frame::new(Point::new(50.0, 0.0, 0.0), Direction::X, Direction::Y, T).unwrap(),
395        );
396        assert!(section(&mut model, &solid.shape, &plane, fine(), T).is_err());
397        let _ = Vector::ZERO;
398    }
399    #[test]
400    fn a_half_section_of_a_bored_cylinder_hatches_the_cut_half() {
401        // A drum with a coaxial bore, half-sectioned on its own axis: the
402        // cut quarter shows the wall as hatchable loops, the other half
403        // stays in outside view in the drawing.
404        let mut model = Model::new();
405        let drum = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 10.0, 30.0, T)
406            .unwrap()
407            .shape;
408        let bore = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 4.0, 30.0, T)
409            .unwrap()
410            .shape;
411        let part = ogeom_bool::cut(&mut model, &drum, &bore, T).unwrap().shape;
412        // The section plane holds the axis: its frame's z is the cut
413        // normal, its y the split line, the axis itself.
414        let plane = Plane::new(Frame::new(Point::ORIGIN, Direction::X, Direction::Z, T).unwrap());
415        let view = half_section(&mut model, &part, &plane, fine(), T).unwrap();
416
417        // The cut half: material where the plane met the wall, all of it in
418        // the +x half of the plane's own chart, adding up to the wall's
419        // half-area (two 6 x 30 rectangles of it stand in the section).
420        assert!(!view.outline.is_empty(), "the section cut material");
421        let mut total = 0.0;
422        for ring in &view.outline {
423            for p in ring {
424                assert!(p.x >= -1e-6, "the outline stays on the cut half: {p:?}");
425            }
426            total += area(ring).abs();
427        }
428        assert!(
429            (total - 2.0 * 6.0 * 30.0).abs() < 5.0,
430            "the section shows the bored wall: {total}"
431        );
432
433        // The convention's hatching: strokes exist, and every one lies in
434        // the material by the even-odd rule that made it.
435        let strokes = hatch(&view.outline, 1.5, core::f64::consts::FRAC_PI_4);
436        assert!(strokes.len() > 20, "the section hatches: {}", strokes.len());
437        for (a, b) in &strokes {
438            let mid = Point2::new(f64::midpoint(a.x, b.x), f64::midpoint(a.y, b.y));
439            let mut crossings = 0;
440            for ring in &view.outline {
441                let n = ring.len();
442                for i in 0..n {
443                    let (p, q) = (ring[i], ring[(i + 1) % n]);
444                    if (p.y <= mid.y) != (q.y <= mid.y)
445                        && p.x + (q.x - p.x) * (mid.y - p.y) / (q.y - p.y) > mid.x
446                    {
447                        crossings += 1;
448                    }
449                }
450            }
451            assert!(crossings % 2 == 1, "a stroke lies outside the material");
452        }
453
454        // The far half remains: the drawing sees the uncut side's outline.
455        assert!(
456            !view.drawing.visible.is_empty(),
457            "the far side draws in outline"
458        );
459    }
460}