1use 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#[derive(Debug, Clone)]
23pub struct SectionView {
24 pub outline: Vec<Vec<Point2>>,
28 pub drawing: Drawing,
30 pub remainder: Shape,
32}
33
34pub 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
64pub 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#[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 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 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
149fn 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
155fn 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
183pub 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#[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 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 let mut y = lo.1 + spacing * 0.5;
249 while y < hi.1 {
250 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
456 !view.drawing.visible.is_empty(),
457 "the far side draws in outline"
458 );
459 }
460}