1use core::f64::consts::PI;
19
20use ogeom_algo::{
21 Built, History, attach_pcurve, edge_vertices, make_edge_between, make_half_space,
22 make_natural_face, make_vertex, make_wire, shape_bounds,
23};
24use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
25use ogeom_geom::{CircleCurve, Curve, CylinderSurface, PlaneSurface, SurfaceGeometry};
26use ogeom_geom::{Curve3d as _, Surface as _};
27use ogeom_math::{Circle, Cylinder, Direction, Frame, Plane, Point, Vector};
28use ogeom_topo::{
29 FaceData, Location, Model, NodeData, Orientation, Shape, ShapeType, SurfaceId, explore_unique,
30};
31
32use crate::facepair::{meet, planar_face_of};
33use crate::support::{edge_curve, face_from_edges, segment_between};
34
35pub fn fillet_sheet_edges(
109 model: &mut Model,
110 sheet: &Shape,
111 edges: &[Shape],
112 radius: f64,
113 tol: Tolerances,
114) -> OgeomResult<Built> {
115 usable_radius(radius, tol)?;
116 if edges.is_empty() {
117 ogeom_bail!(Construction, "no edges were named to round");
118 }
119 let kind = model.kind_of(sheet)?;
120 if kind != ShapeType::Shell {
121 ogeom_bail!(
122 Construction,
123 "a sheet's edges are rounded on a shell; got a {kind:?}"
124 );
125 }
126 for edge in edges {
127 if model.kind_of(edge)? != ShapeType::Edge {
128 ogeom_bail!(Construction, "a sheet is rounded along its edges");
129 }
130 }
131 let mut placed =
134 !sheet.location().is_identity() || edges.iter().any(|e| !e.location().is_identity());
135 for face in explore_unique(model, sheet, ShapeType::Face)? {
136 let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
137 ogeom_bail!(Dangling, "face is not in this model");
138 };
139 placed |= !face.location().is_identity() || !data.location.is_identity();
140 }
141 let mut current = sheet.clone();
142 let mut steps = Vec::with_capacity(edges.len() + 1);
143 let edges: Vec<Shape> = if placed {
144 let baked = ogeom_algo::baked_shape(model, sheet, tol)?;
145 current = baked.shape.clone();
146 let mut twins = Vec::with_capacity(edges.len());
147 for edge in edges {
148 let Some(twin) = baked.history.modified(edge).first().cloned() else {
149 ogeom_bail!(
150 Construction,
151 "an edge to round is not an edge of the sheet at its placement"
152 );
153 };
154 twins.push(twin);
155 }
156 steps.push(baked.history);
157 twins
158 } else {
159 edges.to_vec()
160 };
161 for edge in &edges {
162 let step = round_sheet_edge(model, ¤t, edge, radius, tol)?;
163 current = step.shape.clone();
164 steps.push(step.history);
165 }
166 Ok(Built::new(current, History::chain(&steps)))
167}
168
169pub fn fillet_faces(
260 model: &mut Model,
261 a: &Shape,
262 b: &Shape,
263 radius: f64,
264 trim: bool,
265 tol: Tolerances,
266) -> OgeomResult<Built> {
267 usable_radius(radius, tol)?;
268 let mut planar = true;
269 for (name, face) in [("first", a), ("second", b)] {
270 let kind = model.kind_of(face)?;
271 if kind != ShapeType::Face {
272 ogeom_bail!(
273 Construction,
274 "fillet_faces rounds between two faces; the {name} argument is a {kind:?}"
275 );
276 }
277 planar &= matches!(surface_of(model, face)?, SurfaceGeometry::Plane(_));
278 }
279 if a.is_same(b) {
280 ogeom_bail!(Construction, "a face is not rounded against itself");
281 }
282 if !planar {
283 return crate::sheet_curved::fillet_faces(model, a, b, radius, trim, tol);
284 }
285 let (origin_a, normal_a) = planar_face_of(model, a, tol)?;
286 let (origin_b, normal_b) = planar_face_of(model, b, tol)?;
287 let cross = normal_a.cross(normal_b);
288 let magnitude = cross.magnitude();
289 if magnitude <= tol.angular() {
290 ogeom_bail!(
291 Construction,
292 "the two faces are parallel; their planes meet nowhere for a ball to roll along"
293 );
294 }
295 let along = cross / magnitude;
296 let foot = meet(origin_a, normal_a, origin_b, normal_b, along, tol)?;
297
298 let cosine = normal_a.dot(normal_b);
303 let inward = [
304 (normal_b - normal_a * cosine).normalized(tol)?,
305 (normal_a - normal_b * cosine).normalized(tol)?,
306 ];
307 let setback = Round::between([foot, foot + along], inward, radius, tol)?.setback;
308 let reaches = [
309 reach(model, a, foot, inward[0] * setback, along, tol)?,
310 reach(model, b, foot, inward[1] * setback, along, tol)?,
311 ];
312 let lo = reaches[0].span.0.max(reaches[1].span.0);
313 let hi = reaches[0].span.1.min(reaches[1].span.1);
314 if hi - lo <= tol.confusion() {
315 ogeom_bail!(
316 Construction,
317 "the two faces reach the round over stretches of the corner that do not overlap"
318 );
319 }
320 let round = Round::between([foot + along * lo, foot + along * hi], inward, radius, tol)?;
321 let (s, e) = (round.start, 1 - round.start);
322
323 let mut history = History::new();
324 if !trim {
325 let vertices = round
326 .contacts
327 .map(|row| row.map(|p| make_vertex(model, p).shape));
328 let rails = [
329 segment_between(
330 model,
331 (&vertices[0][s], round.contacts[0][s]),
332 (&vertices[0][e], round.contacts[0][e]),
333 tol,
334 )?,
335 segment_between(
336 model,
337 (&vertices[1][s], round.contacts[1][s]),
338 (&vertices[1][e], round.contacts[1][e]),
339 tol,
340 )?,
341 ];
342 let face = round_face(model, &round, &vertices, &rails, normal_a, tol)?;
343 history.generate(a, face.clone());
344 history.generate(b, face.clone());
345 return Ok(Built::new(face, history));
346 }
347
348 let mut flats = Vec::with_capacity(2);
352 let mut rows = Vec::with_capacity(2);
353 let mut runs = Vec::with_capacity(2);
354 for (f, (reached, normal)) in reaches.iter().zip([normal_a, normal_b]).enumerate() {
355 let flat = Flat::read(model, &reached.face)?;
356 if flat.normal.dot(normal) <= 0.0 {
357 ogeom_bail!(
358 Invariant,
359 "trimming a face turned it over; the round would face the wrong way"
360 );
361 }
362 let (w, i) = flat.locate(&reached.edge)?;
363 let Some((p, q)) = edge_vertices(model, &flat.loops[w][i])? else {
364 ogeom_bail!(Construction, "the face's line of contact has no vertices");
365 };
366 let (pp, qp) = (vertex_point(model, &p)?, vertex_point(model, &q)?);
367 let p_first = (pp - foot).dot(along) <= (qp - foot).dot(along);
369 let (k_p, k_q) = if p_first { (0, 1) } else { (1, 0) };
370 let mut at = |vertex: &Shape, point: Point, k: usize| {
371 if point.distance(round.contacts[f][k]) <= tol.confusion() {
372 vertex.clone()
373 } else {
374 make_vertex(model, round.contacts[f][k]).shape
375 }
376 };
377 let (at_p, at_q) = (at(&p, pp, k_p), at(&q, qp, k_q));
378 rows.push(if p_first { [at_p, at_q] } else { [at_q, at_p] });
379 runs.push(((w, i), (p, pp), (q, qp), (k_p, k_q)));
380 flats.push(flat);
381 }
382 let vertices = [rows[0].clone(), rows[1].clone()];
383 let rails = [
384 flats[0].segment(
385 model,
386 (&vertices[0][s], round.contacts[0][s]),
387 (&vertices[0][e], round.contacts[0][e]),
388 tol,
389 )?,
390 flats[1].segment(
391 model,
392 (&vertices[1][s], round.contacts[1][s]),
393 (&vertices[1][e], round.contacts[1][e]),
394 tol,
395 )?,
396 ];
397 let blend = round_face(model, &round, &vertices, &rails, normal_a, tol)?;
398
399 let mut shell_faces = Vec::with_capacity(3);
400 for (f, (flat, ((w, i), (p, pp), (q, qp), (k_p, k_q)))) in flats.iter().zip(runs).enumerate() {
401 let mut pieces = Vec::with_capacity(3);
402 if !vertices[f][k_p].is_same(&p) {
403 pieces.push(flat.segment(
404 model,
405 (&p, pp),
406 (&vertices[f][k_p], round.contacts[f][k_p]),
407 tol,
408 )?);
409 }
410 pieces.push(if k_p == s {
411 rails[f].clone()
412 } else {
413 rails[f].reversed()
414 });
415 if !vertices[f][k_q].is_same(&q) {
416 pieces.push(flat.segment(
417 model,
418 (&vertices[f][k_q], round.contacts[f][k_q]),
419 (&q, qp),
420 tol,
421 )?);
422 }
423 let face = flat.rebuilt(model, w, i, 1, pieces, tol)?;
424 history.modify(if f == 0 { a } else { b }, face.clone());
425 shell_faces.push(face);
426 }
427 shell_faces.insert(1, blend.clone());
428 let shell = model.add_shell(&shell_faces)?;
429 history.generate(a, blend.clone());
430 history.generate(b, blend);
431 Ok(Built::new(shell, history))
432}
433
434fn usable_radius(radius: f64, tol: Tolerances) -> OgeomResult<()> {
436 if !radius.is_finite() || radius <= tol.confusion() {
437 ogeom_bail!(Construction, "a round of radius {radius} rounds nothing");
438 }
439 Ok(())
440}
441
442struct Round {
445 ends: [Point; 2],
447 contacts: [[Point; 2]; 2],
449 centres: [Point; 2],
451 axis: Vector,
454 start: usize,
456 radius: f64,
458 sweep: f64,
460 setback: f64,
462}
463
464impl Round {
465 fn between(
473 ends: [Point; 2],
474 sides: [Vector; 2],
475 radius: f64,
476 tol: Tolerances,
477 ) -> OgeomResult<Self> {
478 let [first, second] = sides;
479 let opening = first.cross(second).magnitude().atan2(first.dot(second));
480 if opening <= tol.angular() {
481 ogeom_bail!(
482 Construction,
483 "the two faces fold onto each other; no ball fits between them"
484 );
485 }
486 if opening >= PI - tol.angular() {
487 ogeom_bail!(
488 Construction,
489 "the two faces continue each other across the corner; there is no corner to round"
490 );
491 }
492 let half = opening / 2.0;
493 let setback = radius / half.tan();
494 let depth = radius / half.sin();
495 let bisector = (first + second).normalized(tol)?;
496 let travel = ends[1] - ends[0];
497 let length = travel.magnitude();
498 if length <= tol.confusion() {
499 ogeom_bail!(Construction, "the stretch to round has no length");
500 }
501 let along = travel / length;
502 let centres = ends.map(|p| p + bisector * depth);
503 let contacts = [
504 ends.map(|p| p + first * setback),
505 ends.map(|p| p + second * setback),
506 ];
507 let turn = (contacts[0][0] - centres[0])
508 .cross(contacts[1][0] - centres[0])
509 .dot(along);
510 let (axis, start) = if turn > 0.0 { (along, 0) } else { (-along, 1) };
511 Ok(Self {
512 ends,
513 contacts,
514 centres,
515 axis,
516 start,
517 radius,
518 sweep: PI - opening,
519 setback,
520 })
521 }
522
523 fn length(&self) -> f64 {
525 self.ends[0].distance(self.ends[1])
526 }
527}
528
529fn round_face(
535 model: &mut Model,
536 round: &Round,
537 vertices: &[[Shape; 2]; 2],
538 rails: &[Shape; 2],
539 normal: Vector,
540 tol: Tolerances,
541) -> OgeomResult<Shape> {
542 let (s, e) = (round.start, 1 - round.start);
543 let base = round.centres[s];
544 let radial = (round.contacts[0][s] - base) / round.radius;
545 let axis_dir = Direction::new(round.axis, tol)?;
546 let radial_dir = Direction::new(radial, tol)?;
547 let length = round.length();
548 let arc = |model: &mut Model, h: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
552 let frame = Frame::new(base + round.axis * h, axis_dir, radial_dir, tol)?;
553 let circle = Circle::new(frame, round.radius, tol)?;
554 let curve = Curve::Circle(CircleCurve::new(circle));
555 Ok(make_edge_between(model, curve, (0.0, round.sweep), from, to, tol)?.shape)
556 };
557 let frame = Frame::new(base, axis_dir, radial_dir, tol)?;
558 let surface = CylinderSurface::new(Cylinder::new(frame, round.radius, tol)?, (0.0, length))?;
559 let edges = vec![
560 arc(model, 0.0, &vertices[0][s], &vertices[1][s])?,
561 rails[1].clone(),
562 arc(model, length, &vertices[0][e], &vertices[1][e])?.reversed(),
563 rails[0].reversed(),
564 ];
565 let face = face_from_edges(model, surface.into(), &edges, tol)?;
566 Ok(if radial.dot(normal) > 0.0 {
568 face
569 } else {
570 face.reversed()
571 })
572}
573
574struct Flat {
577 face: Shape,
578 surface_id: SurfaceId,
579 surface: SurfaceGeometry,
580 frame: Frame,
581 normal: Vector,
582 tolerance: ogeom_core::Tolerance,
583 wires: Vec<Shape>,
584 loops: Vec<Vec<Shape>>,
585}
586
587impl Flat {
588 fn read(model: &Model, face: &Shape) -> OgeomResult<Self> {
589 let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
590 ogeom_bail!(Dangling, "face is not in this model");
591 };
592 if !face.location().is_identity() || !data.location.is_identity() {
593 ogeom_bail!(
594 Construction,
595 "a placed face is not rounded; bake its placement into its geometry first"
596 );
597 }
598 let (surface_id, tolerance) = (data.surface, data.tolerance);
599 let surface = surface_of(model, face)?;
600 let SurfaceGeometry::Plane(plane) = &surface else {
601 ogeom_bail!(
602 Construction,
603 "only planar faces are rounded here; a face on a {:?} surface is not",
604 surface.kind()
605 );
606 };
607 let frame = plane.plane().frame();
608 let mut normal = frame.z().vector();
609 if face.orientation() == Orientation::Reversed {
610 normal = -normal;
611 }
612 let wires = model.children_of(&face.oriented(Orientation::Forward))?;
613 let mut loops = Vec::with_capacity(wires.len());
614 for wire in &wires {
615 loops.push(model.children_of(&wire.oriented(Orientation::Forward))?);
616 }
617 Ok(Self {
618 face: face.clone(),
619 surface_id,
620 surface,
621 frame,
622 normal,
623 tolerance,
624 wires,
625 loops,
626 })
627 }
628
629 fn locate(&self, edge: &Shape) -> OgeomResult<(usize, usize)> {
631 let mut hits = Vec::new();
632 for (w, ring) in self.loops.iter().enumerate() {
633 for (i, e) in ring.iter().enumerate() {
634 if e.is_same(edge) {
635 hits.push((w, i));
636 }
637 }
638 }
639 match hits.as_slice() {
640 [one] => Ok(*one),
641 [] => ogeom_bail!(Construction, "the edge is not on the face's boundary"),
642 _ => ogeom_bail!(
643 Construction,
644 "the face's boundary runs along the edge twice (a seam); it has no one side \
645 to round"
646 ),
647 }
648 }
649
650 fn segment(
653 &self,
654 model: &mut Model,
655 from: (&Shape, Point),
656 to: (&Shape, Point),
657 tol: Tolerances,
658 ) -> OgeomResult<Shape> {
659 let edge = segment_between(model, from, to, tol)?;
660 let (curve, range) = edge_curve(model, &edge, tol)?;
661 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &self.surface, tol) else {
662 ogeom_bail!(
663 Invariant,
664 "a straight edge in a face's plane has no trim on that plane"
665 );
666 };
667 attach_pcurve(
668 model,
669 &edge,
670 pcurve,
671 self.surface_id,
672 Location::identity(),
673 range,
674 )?;
675 if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(|n| n.data_mut()) {
676 data.assert_same_parameter(true);
677 }
678 Ok(edge)
679 }
680
681 fn rebuilt(
685 &self,
686 model: &mut Model,
687 w: usize,
688 first: usize,
689 count: usize,
690 pieces: Vec<Shape>,
691 tol: Tolerances,
692 ) -> OgeomResult<Shape> {
693 let ring = &self.loops[w];
694 let n = ring.len();
695 let mut edges: Vec<Shape> = (count..n).map(|j| ring[(first + j) % n].clone()).collect();
696 edges.extend(pieces);
697 let wire = make_wire(model, &edges, tol)?
698 .shape
699 .oriented(self.wires[w].orientation());
700 let mut wires = self.wires.clone();
701 wires[w] = wire;
702 let mut data = FaceData::new(self.surface_id, Location::identity());
703 data.tolerance = self.tolerance;
704 Ok(model
705 .add_face(data, &wires)?
706 .oriented(self.face.orientation()))
707 }
708
709 fn inward(&self, model: &Model, w: usize, i: usize, tol: Tolerances) -> OgeomResult<Vector> {
716 let chart = |p: Point| {
717 let local = self.frame.to_local(p);
718 (local.x, local.y)
719 };
720 let mut areas = Vec::with_capacity(self.loops.len());
721 for ring in &self.loops {
722 let mut polygon = Vec::new();
723 for edge in ring {
724 let mut samples = edge_samples(model, edge, 8, tol)?;
725 if edge.orientation() == Orientation::Reversed {
726 samples.reverse();
727 }
728 samples.pop();
729 polygon.extend(samples.into_iter().map(chart));
730 }
731 let mut area = 0.0;
732 for (k, p) in polygon.iter().enumerate() {
733 let q = polygon[(k + 1) % polygon.len()];
734 area += p.0 * q.1 - p.1 * q.0;
735 }
736 areas.push(area);
737 }
738 let outer = areas
739 .iter()
740 .enumerate()
741 .max_by(|x, y| x.1.abs().total_cmp(&y.1.abs()))
742 .map_or(0, |(k, _)| k);
743 let Some((from, to)) = edge_vertices(model, &self.loops[w][i])? else {
744 ogeom_bail!(Construction, "the edge has no vertices");
745 };
746 let tangent = vertex_point(model, &to)? - vertex_point(model, &from)?;
747 let left = self.frame.z().vector().cross(tangent);
748 let on_left = (areas[w] > 0.0) == (w == outer);
749 let side = if on_left { left } else { -left };
750 side.normalized(tol)
751 }
752}
753
754fn edge_samples(
756 model: &Model,
757 edge: &Shape,
758 count: usize,
759 tol: Tolerances,
760) -> OgeomResult<Vec<Point>> {
761 let (curve, (lo, hi)) = edge_curve(model, edge, tol)?;
762 (0..=count)
763 .map(|k| {
764 #[allow(clippy::cast_precision_loss)]
765 let t = lo + (hi - lo) * (k as f64) / (count as f64);
766 curve.point_at(t, tol)
767 })
768 .collect()
769}
770
771fn vertex_point(model: &Model, vertex: &Shape) -> OgeomResult<Point> {
773 let Some(point) = model
774 .node(vertex)
775 .and_then(|n| n.data().as_vertex().map(|v| v.point))
776 else {
777 ogeom_bail!(Dangling, "vertex is not in this model");
778 };
779 Ok(vertex.transform(model.datums())?.apply(point))
780}
781
782fn surface_of(model: &Model, face: &Shape) -> OgeomResult<SurfaceGeometry> {
784 let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
785 ogeom_bail!(Dangling, "face is not in this model");
786 };
787 let Some(surface) = model.geometry().surface(data.surface) else {
788 ogeom_bail!(Dangling, "face refers to a surface not in this model");
789 };
790 Ok(surface.clone())
791}
792
793struct Reach {
797 face: Shape,
798 edge: Shape,
799 span: (f64, f64),
800}
801
802fn reach(
805 model: &mut Model,
806 face: &Shape,
807 foot: Point,
808 offset: Vector,
809 along: Vector,
810 tol: Tolerances,
811) -> OgeomResult<Reach> {
812 let contact = foot + offset;
813 let inward = offset.normalized(tol)?;
814 let mut size = 1.0_f64;
815 for corner in shape_bounds(model, face, tol)?.corners() {
816 size = size.max(corner.distance(contact));
817 }
818 let size = size * 4.0;
819 let plane = Plane::through(contact, Direction::new(inward, tol)?);
820 let wall = make_natural_face(
821 model,
822 PlaneSurface::over(plane, (-size, size), (-size, size))?.into(),
823 )?
824 .shape;
825 let keep = make_half_space(model, &wall, contact + inward * size * 0.5, tol)?.shape;
826 let kept = ogeom_bool::common(model, face, &keep, tol)?.shape;
827 let pieces = explore_unique(model, &kept, ShapeType::Face)?;
828 let reach = tol.confusion() * 10.0;
829 let off_line = |p: Point| {
830 let d = p - contact;
831 (d - along * d.dot(along)).magnitude()
832 };
833 let mut found = Vec::new();
834 for piece in &pieces {
835 for edge in explore_unique(model, piece, ShapeType::Edge)? {
836 let (curve, (lo, hi)) = edge_curve(model, &edge, tol)?;
837 let Curve::Line(line) = &curve else {
838 continue;
839 };
840 if line.axis().direction.vector().cross(along).magnitude() > tol.angular() {
841 continue;
842 }
843 let (p, q) = (curve.point_at(lo, tol)?, curve.point_at(hi, tol)?);
844 if off_line(p) > reach || off_line(q) > reach {
845 continue;
846 }
847 let (tp, tq) = ((p - foot).dot(along), (q - foot).dot(along));
848 found.push((piece.clone(), edge, (tp.min(tq), tp.max(tq))));
849 }
850 }
851 match (pieces.len(), found.len()) {
852 (0, _) => ogeom_bail!(
853 Construction,
854 "a face lies wholly between the corner and the line the ball touches it along; \
855 it does not reach the round"
856 ),
857 (_, 0) => ogeom_bail!(
858 Construction,
859 "a face lies wholly beyond the line the ball would touch it along; the ball \
860 does not reach it"
861 ),
862 (1, 1) => {
863 let (face, edge, span) = found.remove(0);
864 Ok(Reach { face, edge, span })
865 }
866 _ => ogeom_bail!(
867 Construction,
868 "the line the ball touches a face along crosses it more than once; the stretch \
869 the round spans is ambiguous"
870 ),
871 }
872}
873
874struct Side {
877 flat: Flat,
878 wire: usize,
879 at: usize,
880 inward: Vector,
882 arrives: usize,
884 before: Shape,
886 after: Shape,
888 from: (Shape, Point),
890 to: (Shape, Point),
892}
893
894#[allow(clippy::too_many_lines, reason = "one rebuild, checked then assembled")]
896fn round_sheet_edge(
897 model: &mut Model,
898 shell: &Shape,
899 edge: &Shape,
900 radius: f64,
901 tol: Tolerances,
902) -> OgeomResult<Built> {
903 let faces = explore_unique(model, shell, ShapeType::Face)?;
904 let mut face_edges = Vec::with_capacity(faces.len());
905 for face in &faces {
906 face_edges.push(explore_unique(model, face, ShapeType::Edge)?);
907 }
908 let users_of = |e: &Shape| -> Vec<usize> {
909 face_edges
910 .iter()
911 .enumerate()
912 .filter(|(_, edges)| edges.iter().any(|x| x.is_same(e)))
913 .map(|(k, _)| k)
914 .collect()
915 };
916 let users = users_of(edge);
917 match users.len() {
918 0 => ogeom_bail!(
919 Construction,
920 "the edge is not an edge of the sheet (an earlier round in the same call may \
921 have consumed it)"
922 ),
923 1 => ogeom_bail!(
924 Construction,
925 "the edge bounds one face of the sheet only; a round needs a face on each side"
926 ),
927 2 => {}
928 n => ogeom_bail!(
929 Construction,
930 "the edge is shared by {n} faces of the sheet; a round has two sides"
931 ),
932 }
933 let (curve, _) = edge_curve(model, edge, tol)?;
934 let planar = [&faces[users[0]], &faces[users[1]]]
935 .into_iter()
936 .map(|face| surface_of(model, face))
937 .collect::<OgeomResult<Vec<_>>>()?
938 .iter()
939 .all(|s| matches!(s, SurfaceGeometry::Plane(_)));
940 let Curve::Line(line) = &curve else {
941 return crate::sheet_curved::round_sheet_edge(
942 model,
943 shell,
944 edge,
945 [&faces[users[0]], &faces[users[1]]],
946 &|e| users_of(e).len(),
947 radius,
948 tol,
949 );
950 };
951 if !planar {
952 return crate::sheet_curved::round_sheet_edge(
953 model,
954 shell,
955 edge,
956 [&faces[users[0]], &faces[users[1]]],
957 &|e| users_of(e).len(),
958 radius,
959 tol,
960 );
961 }
962 let along = line.axis().direction.vector();
963 let Some((v0, v1)) = edge_vertices(model, &edge.oriented(Orientation::Forward))? else {
964 ogeom_bail!(Construction, "the edge has no vertices");
965 };
966 if v0.is_same(&v1) {
967 ogeom_bail!(
968 Construction,
969 "the edge closes on itself; a straight edge between planes has two ends"
970 );
971 }
972 let ends = [vertex_point(model, &v0)?, vertex_point(model, &v1)?];
973 let end_vertices = [v0.clone(), v1.clone()];
974
975 let mut sides = Vec::with_capacity(2);
976 for &k in &users {
977 let flat = Flat::read(model, &faces[k])?;
978 let (wire, at) = flat.locate(edge)?;
979 let ring = &flat.loops[wire];
980 let n = ring.len();
981 if n < 3 {
982 ogeom_bail!(
983 Construction,
984 "a face bounded by {n} edges has no boundary edges to end a round on"
985 );
986 }
987 let before = ring[(at + n - 1) % n].clone();
988 let after = ring[(at + 1) % n].clone();
989 let Some((arrive, _)) = edge_vertices(model, &ring[at])? else {
990 ogeom_bail!(Construction, "the edge has no vertices");
991 };
992 let arrives = usize::from(!arrive.is_same(&end_vertices[0]));
993 let (Some((from, _)), Some((_, to))) = (
994 edge_vertices(model, &before)?,
995 edge_vertices(model, &after)?,
996 ) else {
997 ogeom_bail!(Construction, "a boundary edge has no vertices");
998 };
999 let inward = flat.inward(model, wire, at, tol)?;
1000 let from_point = vertex_point(model, &from)?;
1001 let to_point = vertex_point(model, &to)?;
1002 sides.push(Side {
1003 flat,
1004 wire,
1005 at,
1006 inward,
1007 arrives,
1008 before,
1009 after,
1010 from: (from, from_point),
1011 to: (to, to_point),
1012 });
1013 }
1014
1015 let toward = [
1019 sides[0].flat.normal.dot(sides[1].inward),
1020 sides[1].flat.normal.dot(sides[0].inward),
1021 ];
1022 if (toward[0] > 0.0) != (toward[1] > 0.0) {
1023 ogeom_bail!(
1024 Construction,
1025 "the two faces are oriented inconsistently across the edge; the round has no \
1026 side to face"
1027 );
1028 }
1029 let round = Round::between(ends, [sides[0].inward, sides[1].inward], radius, tol)?;
1030
1031 for side in &sides {
1033 for (boundary, near, far) in [
1034 (&side.before, ends[side.arrives], side.from.1),
1035 (&side.after, ends[1 - side.arrives], side.to.1),
1036 ] {
1037 let (curve, _) = edge_curve(model, boundary, tol)?;
1038 let Curve::Line(line) = &curve else {
1039 ogeom_bail!(
1040 Construction,
1041 "a face's boundary leaves an end of the edge along a curve; the round \
1042 ends on straight boundary edges only"
1043 );
1044 };
1045 if line.axis().direction.vector().dot(along).abs() > tol.angular() {
1046 ogeom_bail!(
1047 Construction,
1048 "a face's boundary leaves an end of the edge at an angle to it; the round \
1049 ends square to the edge, so only square ends are rounded"
1050 );
1051 }
1052 if users_of(boundary).len() != 1 {
1053 ogeom_bail!(
1054 Construction,
1055 "the round would run into another face of the sheet at an end of the \
1056 edge; only an edge whose ends lie on the sheet's free boundary is rounded"
1057 );
1058 }
1059 let span = (far - near).dot(side.inward);
1060 if span <= round.setback + tol.confusion() {
1061 ogeom_bail!(
1062 Construction,
1063 "the round sets back {} from the edge, past the far end of a face's \
1064 boundary edge {span} long",
1065 round.setback
1066 );
1067 }
1068 }
1069 }
1070 for vertex in &end_vertices {
1072 let mut meeting = 0;
1073 for edge_of in explore_unique(model, shell, ShapeType::Edge)? {
1074 if let Some((p, q)) = edge_vertices(model, &edge_of)?
1075 && (p.is_same(vertex) || q.is_same(vertex))
1076 {
1077 meeting += 1;
1078 }
1079 }
1080 if meeting != 3 {
1081 ogeom_bail!(
1082 Construction,
1083 "{meeting} edges of the sheet meet at an end of the edge; only an edge whose \
1084 ends lie on the sheet's free boundary, with one boundary edge of each face \
1085 leaving it, is rounded"
1086 );
1087 }
1088 }
1089 let length = round.length();
1091 for side in &sides {
1092 for (w, ring) in side.flat.loops.iter().enumerate() {
1093 for (i, other) in ring.iter().enumerate() {
1094 let n = ring.len();
1095 if w == side.wire
1096 && (i == side.at || i == (side.at + 1) % n || i == (side.at + n - 1) % n)
1097 {
1098 continue;
1099 }
1100 if !clear_of_strip(
1101 model,
1102 other,
1103 ends[0],
1104 (ends[1] - ends[0]) / length,
1105 side.inward,
1106 (length, round.setback),
1107 tol,
1108 )? {
1109 ogeom_bail!(
1110 Construction,
1111 "another part of a face's boundary comes within the strip the round \
1112 takes from it; the round would cross it"
1113 );
1114 }
1115 }
1116 }
1117 }
1118
1119 let (s, e) = (round.start, 1 - round.start);
1122 let vertices = round
1123 .contacts
1124 .map(|row| row.map(|p| make_vertex(model, p).shape));
1125 let rails = [
1126 sides[0].flat.segment(
1127 model,
1128 (&vertices[0][s], round.contacts[0][s]),
1129 (&vertices[0][e], round.contacts[0][e]),
1130 tol,
1131 )?,
1132 sides[1].flat.segment(
1133 model,
1134 (&vertices[1][s], round.contacts[1][s]),
1135 (&vertices[1][e], round.contacts[1][e]),
1136 tol,
1137 )?,
1138 ];
1139 let blend = round_face(model, &round, &vertices, &rails, sides[0].flat.normal, tol)?;
1140
1141 let mut history = History::new();
1142 let mut replaced = Vec::with_capacity(2);
1143 for (f, side) in sides.iter().enumerate() {
1144 let (k_in, k_out) = (side.arrives, 1 - side.arrives);
1145 let lead = side.flat.segment(
1146 model,
1147 (&side.from.0, side.from.1),
1148 (&vertices[f][k_in], round.contacts[f][k_in]),
1149 tol,
1150 )?;
1151 let rail = if k_in == s {
1152 rails[f].clone()
1153 } else {
1154 rails[f].reversed()
1155 };
1156 let tail = side.flat.segment(
1157 model,
1158 (&vertices[f][k_out], round.contacts[f][k_out]),
1159 (&side.to.0, side.to.1),
1160 tol,
1161 )?;
1162 let n = side.flat.loops[side.wire].len();
1163 let face = side.flat.rebuilt(
1164 model,
1165 side.wire,
1166 (side.at + n - 1) % n,
1167 3,
1168 vec![lead.clone(), rail, tail.clone()],
1169 tol,
1170 )?;
1171 history.modify(&side.flat.face, face.clone());
1172 history.modify(&side.before, lead);
1173 history.modify(&side.after, tail);
1174 replaced.push((side.flat.face.clone(), face));
1175 }
1176 let mut shell_faces: Vec<Shape> = faces
1177 .iter()
1178 .map(|face| {
1179 replaced
1180 .iter()
1181 .find(|(old, _)| old.is_same(face))
1182 .map_or_else(|| face.clone(), |(_, new)| new.clone())
1183 })
1184 .collect();
1185 shell_faces.push(blend.clone());
1186 let rounded = model.add_shell(&shell_faces)?;
1187 history.modify(shell, rounded.clone());
1188 history.delete(edge);
1189 history.delete(&v0);
1190 history.delete(&v1);
1191 history.generate(edge, blend);
1192 Ok(Built::new(rounded, history))
1193}
1194
1195fn clear_of_strip(
1203 model: &Model,
1204 edge: &Shape,
1205 origin: Point,
1206 along: Vector,
1207 inward: Vector,
1208 extent: (f64, f64),
1209 tol: Tolerances,
1210) -> OgeomResult<bool> {
1211 let margin = tol.confusion();
1212 let lo = (-margin, -margin);
1213 let hi = (extent.0 + margin, extent.1 + margin);
1214 let flat = |p: Point| {
1215 let d = p - origin;
1216 (d.dot(along), d.dot(inward))
1217 };
1218 let (curve, (t0, t1)) = edge_curve(model, edge, tol)?;
1219 if let Curve::Line(_) = curve {
1220 let a = flat(curve.point_at(t0, tol)?);
1221 let b = flat(curve.point_at(t1, tol)?);
1222 return Ok(!segment_meets_box(a, b, lo, hi));
1223 }
1224 let (mut min, mut max) = (
1225 (f64::INFINITY, f64::INFINITY),
1226 (f64::NEG_INFINITY, f64::NEG_INFINITY),
1227 );
1228 for corner in shape_bounds(model, edge, tol)?.corners() {
1229 let p = flat(corner);
1230 min = (min.0.min(p.0), min.1.min(p.1));
1231 max = (max.0.max(p.0), max.1.max(p.1));
1232 }
1233 Ok(max.0 < lo.0 || min.0 > hi.0 || max.1 < lo.1 || min.1 > hi.1)
1234}
1235
1236fn segment_meets_box(a: (f64, f64), b: (f64, f64), lo: (f64, f64), hi: (f64, f64)) -> bool {
1239 let d = (b.0 - a.0, b.1 - a.1);
1240 let (mut enter, mut leave) = (0.0_f64, 1.0_f64);
1241 for (p, q) in [
1242 (-d.0, a.0 - lo.0),
1243 (d.0, hi.0 - a.0),
1244 (-d.1, a.1 - lo.1),
1245 (d.1, hi.1 - a.1),
1246 ] {
1247 if p.abs() <= f64::MIN_POSITIVE {
1248 if q < 0.0 {
1249 return false;
1250 }
1251 continue;
1252 }
1253 let r = q / p;
1254 if p < 0.0 {
1255 enter = enter.max(r);
1256 } else {
1257 leave = leave.min(r);
1258 }
1259 if enter > leave {
1260 return false;
1261 }
1262 }
1263 true
1264}