Skip to main content

ogeom_fillet/
sheet.rs

1//! Rounds on sheets: where two faces of an open shell meet at an edge, and
2//! in the corner between two faces of separate shapes.
3//!
4//! A solid's fillet is a wedge cut away or fused on. A sheet has no volume
5//! for a boolean to take a wedge from, so here the faces are rebuilt: each
6//! face the ball touches loses the strip between the corner and its line of
7//! contact, its boundary is re-routed along that line, and the round shares
8//! the edge there with it. The shared edges are built once and used by both
9//! faces, so the result's topology is exact by construction rather than
10//! sewn within a tolerance.
11//!
12//! Between two planes the rolling ball's envelope is a cylinder whose axis
13//! is where the two planes' offsets meet, and the lines of contact are
14//! straight: every curve and every trim here is closed-form. Curved faces
15//! are rounded in `sheet_curved`: exactly where the supports share a
16//! direction or an axis, marched otherwise.
17
18use 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
35/// Round edges of a sheet where two of its faces meet, with a rolling ball
36/// of `radius`.
37///
38/// The ball rolls on the concave side of each corner: the side where the
39/// two faces make an angle under a half turn. Each of the two faces is
40/// rebuilt on its own surface without the strip between the edge and the
41/// line the ball touches it along, and the round, tangent to both, fills
42/// the gap, sharing an edge with each. Its normal follows the sheet's:
43/// where the faces' normals point into the corner, the round's points to
44/// the ball's centre.
45///
46/// The round's surface:
47///
48/// - between two planes, or planes and cylinders all along the straight
49///   edge's direction, a cylinder of `radius` along it;
50/// - between planes square to an axis and cylinders, cones, spheres and
51///   tori about it, meeting along a circle or arc about the axis, a torus
52///   about the axis with `radius` for its tube;
53/// - otherwise (B-spline, swept or other faces, or an edge with no closed
54///   form) a B-spline surface fitted through the ball's arcs as it is
55///   marched along an open edge, its borders the lines of contact, within
56///   two tenths of a micron at unit scale; the edges it shares are widened
57///   to say so.
58///
59/// The round ends in the ball's section at each end of an open edge (the
60/// plane square to a straight edge, the half-plane through the axis of an
61/// arc, the march's section elsewhere), and those ends become part of the
62/// sheet's free boundary. A closed edge is rounded all the way round.
63///
64/// Edges are rounded one after another, each on the sheet the previous one
65/// left, so two rounds may trim the same face from different sides.
66///
67/// A placed sheet, a sheet with a placed face, or a placed edge to round
68/// (a sheet placed inside a compound and explored from it) is read where
69/// it stands: the sheet is first rebuilt with its placements baked in,
70/// and that rebuilt sheet is rounded, its edges new nodes on the same
71/// geometry. Placed edges elsewhere on a face's boundary (a prism's far
72/// end edges) are restated where the face is rebuilt.
73///
74/// History: the sheet is modified into the result; every rebuilt face and
75/// every shortened boundary edge is modified into its new self; each
76/// rounded edge is deleted and generates its round; where the sheet was
77/// baked first, every other face and edge is modified into its baked twin.
78///
79/// # Errors
80///
81/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
82/// name, where:
83///
84/// - `radius` is not a positive length, or `edges` is empty;
85/// - `sheet` is not a shell;
86/// - an edge is not an edge of the sheet with a face on each side (a free
87///   edge, an edge of three faces, an edge an earlier round in the same
88///   call consumed);
89/// - a face beside an edge runs along it twice;
90/// - the two faces continue each other, fold onto each other, or are
91///   oriented inconsistently across the edge;
92/// - no ball of `radius` seats in the corner, a ball does not fit inside a
93///   curved face it rolls on, it touches the faces at the ends of a
94///   diameter, or its round would be a torus crossing its own axis;
95/// - the edge is closed and its round would have to be marched;
96/// - the ball does not seat between the faces at an end of a marched edge;
97/// - at an end of the edge, a face's boundary does not leave it along an
98///   edge of the sheet's free boundary lying in the round's end section (for
99///   two planes, square to the edge and straight), or other faces meet there
100///   too;
101/// - the ball sets back past the far end of a face's boundary edge, or some
102///   other part of a face's boundary comes within reach of the strip the
103///   round takes (checked against a sampled distance held low by the
104///   sampling step, so an edge only near the strip may be refused).
105///
106/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) where a march
107/// gives too few stations or its band misses its fit target.
108pub 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    // A placed edge on a face's boundary is restated where the face is
132    // rebuilt; a placed sheet, face or rounded edge is baked first.
133    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, &current, edge, radius, tol)?;
163        current = step.shape.clone();
164        steps.push(step.history);
165    }
166    Ok(Built::new(current, History::chain(&steps)))
167}
168
169/// Round the corner between two faces of separate shapes with a rolling
170/// ball of `radius`.
171///
172/// The ball rolls on the side each face's normal points to: it touches
173/// the front of both. Reverse a face to roll it on that face's other side.
174///
175/// Where the faces' surfaces share a direction or an axis the round has a
176/// closed form:
177///
178/// - two planes meet on a line, and planes and cylinders all along one
179///   direction share it: the ball's centre runs along that direction, it
180///   touches each face along a line, and the round is a cylinder of
181///   `radius` running over the stretch both faces reach (where each face's
182///   line of contact crosses it), ending square to the direction;
183/// - planes square to an axis and cylinders, cones, spheres and tori about
184///   it share the axis: the ball's centre turns about it, it touches each
185///   face along a circle, and the round is a torus about the axis with
186///   `radius` for its tube, running over the turn both faces reach and
187///   ending in half-planes through the axis, or all the way round where
188///   both lines of contact close on their faces.
189///
190/// Where such surfaces cross in more than one corner the ball touches both
191/// faces in (the walls of two drums fused side by side), every corner is
192/// rounded, each over its own run, as long as the corners stand apart.
193///
194/// Otherwise (a B-spline face, a cylinder at a slant to a plane) the ball
195/// is marched along where the two surfaces cross, or, where they do not
196/// cross and the round bridges a gap, along where the surfaces offset by
197/// `radius` cross. A plane's or a cylinder's window is widened and a
198/// B-spline patch continued past its face for the march, each written back
199/// as the face's own surface. The round is a B-spline band fitted through
200/// the ball's arcs over the stretch both faces reach, ending in the ball's
201/// sections there, or, where the ball touches both faces all the way round
202/// a closed seat (a tube through a floor at a slant, two pipes crossing),
203/// fitted closed round the loop with no ends. Where both faces let the
204/// ball go round a closed seat, never at the same place, there is a round
205/// over each stretch both hold it. It rides both faces within a tenth of a
206/// degree.
207///
208/// With `trim`, each face is cut back to its line of contact (everything of
209/// it on the round's side of that line goes) and the result is one shell
210/// of the two trimmed faces and the round, sharing an edge along each line
211/// of contact; where a face's line of contact runs past the round's ends
212/// (or closes round on itself), the rest of it stays free boundary. Over
213/// several corners each face is cut back along every line of contact it
214/// holds, and the shell holds every round; over several runs each face is
215/// cut once along its line, which every run's round shares, and the line
216/// between the runs stays free boundary. Without it the faces are left
217/// as they are and the result is the round alone, or a compound of the
218/// rounds, its normal following the faces' as in the shell.
219///
220/// History: each face generates every round; with `trim`, each face is
221/// modified into its trimmed self.
222///
223/// # Errors
224///
225/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
226/// name, where:
227///
228/// - `radius` is not a positive length;
229/// - an argument is not a face, or the two are one face;
230/// - a face is placed;
231/// - a face is a trimmed or offset surface and the seat has no closed
232///   form;
233/// - for a marched round, no ball seats on the front of both surfaces
234///   along where they (or their offsets) meet, a line of contact does not
235///   cut its face from boundary to boundary, or more than one corner holds
236///   a ball touching both faces; with `trim`, a line of contact closed
237///   round its face does not part it into pieces covering it once;
238/// - two planes are parallel, or no ball of `radius` touches the front of
239///   both surfaces, or it does not fit inside a curved face, or more than
240///   one does and touches both faces in corners close enough that their
241///   rounds could meet;
242/// - the ball touching both surfaces misses a face, or touches the two at
243///   the ends of a diameter, or its round would be a torus crossing its
244///   own axis;
245/// - for two planes, a face does not reach its line of contact (it lies
246///   wholly beyond it, or wholly between it and the corner);
247/// - where the seat has a closed form, a line of contact crosses its face
248///   more than once; on a marched seat each crossing is a stretch and the
249///   round a run over each overlap, but with `trim` a line crossing a
250///   face's seam, or over a stretch no run lies along, is refused;
251/// - the stretches the two faces reach do not overlap;
252/// - one face's line of contact closes on itself across the face's own
253///   boundary (round a seam) while the round spans only part of the turn;
254/// - with `trim`, cutting a face back over one stretch leaves it in more
255///   than one piece, or
256///   leaves a placed face, or, over several corners, a later corner's line
257///   of contact misses what the earlier cuts left of the face or the cut
258///   along it rebuilds an earlier one's edge.
259pub 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    // The ball in front of both: a centre `radius` in front of each plane.
299    // Its contact with one plane then lies on the side the other plane's
300    // normal points to, and that direction, within the plane and square to
301    // the meeting line, is the in-plane part of the other's normal.
302    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    // Each trimmed face meets the round along the stretch of its contact
349    // edge the round spans. Where the round ends short of an end of that
350    // edge, the edge is split there and the rest stays free boundary.
351    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        // The end of the round each end of the contact edge is nearer.
368        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
434/// Refuse a radius that rounds nothing.
435fn 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
442/// The rolling ball's envelope between two planes over one stretch of the
443/// line they meet on.
444struct Round {
445    /// The stretch's ends on the meeting line, in the caller's order.
446    ends: [Point; 2],
447    /// Where the ball touches each face at each end: `contacts[face][end]`.
448    contacts: [[Point; 2]; 2],
449    /// The ball's centre at each end.
450    centres: [Point; 2],
451    /// The direction the cylinder's axis runs: the arc from the first
452    /// face's contact to the second's turns positively about it.
453    axis: Vector,
454    /// The end the cylinder's height starts at.
455    start: usize,
456    /// The ball's radius.
457    radius: f64,
458    /// The angle the arc sweeps: the corner's supplement.
459    sweep: f64,
460    /// How far each line of contact stands from the corner, along its face.
461    setback: f64,
462}
463
464impl Round {
465    /// The ball seated in the corner the two faces make along the stretch
466    /// from `ends[0]` to `ends[1]`, each face leaving the corner along its
467    /// unit `sides` direction, square to the stretch.
468    ///
469    /// The corner's opening `θ` is the angle between the two directions.
470    /// The ball's centre lies on their bisector at `r / sin(θ/2)` from the
471    /// corner, and touches each face `r / tan(θ/2)` along it.
472    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    /// The stretch's length.
524    fn length(&self) -> f64 {
525        self.ends[0].distance(self.ends[1])
526    }
527}
528
529/// The round's face, sharing its lines of contact with the faces it meets:
530/// `vertices[face][end]` are the contacts' vertices and `rails[face]` the
531/// edge along each line of contact, running from the end the cylinder's
532/// height starts at. Oriented so its normal agrees with `normal`, the first
533/// face's, along their shared edge.
534fn 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    // An arc of the ball's circle at height `h` up the axis, from the first
549    // face's contact to the second's: its frame is the cylinder's moved up
550    // the axis, so its trim on the cylinder is a straight line.
551    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    // The cylinder's own normal points away from its axis.
567    Ok(if radial.dot(normal) > 0.0 {
568        face
569    } else {
570        face.reversed()
571    })
572}
573
574/// A planar face as a rebuild reads it: its surface, its normal as the
575/// face is oriented, and its boundary as stored.
576struct 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    /// Where `edge` stands in the boundary: its wire and its place in it.
630    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    /// A straight edge between two vertices in the face's plane, its exact
651    /// trim on that plane attached.
652    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    /// The face again, on its own surface, with `count` edges of wire `w`
682    /// from `first` on (cyclically) replaced by `pieces`, which must walk
683    /// from where the replaced run began to where it ended.
684    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    /// The unit direction, in the plane and square to the edge at `(w, i)`,
710    /// in which the face lies beside it.
711    ///
712    /// Read from the winding of the edge's loop in the plane's chart: the
713    /// face lies left of an outer loop walked counter-clockwise and right
714    /// of a hole's. The outer loop is the one enclosing the most area.
715    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
754/// Points along an edge in its stored direction, placed in the world.
755fn 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
771/// A vertex's point, placed in the world.
772fn 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
782/// A face's surface, cloned out of the model.
783fn 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
793/// What of a face lies beyond its line of contact, the edge it gains along
794/// that line, and the stretch of the line it spans, measured from `foot`
795/// along `along`.
796struct Reach {
797    face: Shape,
798    edge: Shape,
799    span: (f64, f64),
800}
801
802/// Cut `face` back to the line through `foot + offset` along `along`,
803/// keeping the side `offset` points to.
804fn 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
874/// One face beside a sheet edge being rounded: where the edge sits in its
875/// boundary and the boundary edges that leave the edge's ends.
876struct Side {
877    flat: Flat,
878    wire: usize,
879    at: usize,
880    /// The direction, in the face and square to the edge, the face lies in.
881    inward: Vector,
882    /// The end of the edge (0 its start, 1 its end) the loop arrives at.
883    arrives: usize,
884    /// The boundary edge arriving at the rounded edge, as the loop walks it.
885    before: Shape,
886    /// The one leaving it.
887    after: Shape,
888    /// Where `before` starts.
889    from: (Shape, Point),
890    /// Where `after` ends.
891    to: (Shape, Point),
892}
893
894/// Round one edge of a shell.
895#[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    // Which side of the sheet the corner is concave on, and whether the
1016    // two faces agree about it: each face's normal points toward the other
1017    // face, or both point away.
1018    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    // The ends: square to the edge, straight, free, and long enough.
1032    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    // Nothing else meets the edge's ends.
1071    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    // Nothing else of either face comes into the strip the round takes.
1090    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    // Assembly: the contact vertices and rails, the round, the two faces
1120    // rebuilt around them, and the shell with the faces exchanged.
1121    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
1195/// Whether an edge keeps clear of the strip a round takes from a face: the
1196/// rectangle from the corner at `origin`, `extent.0` along `along` and
1197/// `extent.1` along `inward`, widened by the confusion distance.
1198///
1199/// Exact for a straight edge. A curved one is held to its bounding box,
1200/// which can refuse an edge that only comes near the strip but never
1201/// passes one that enters it.
1202fn 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
1236/// Whether the segment from `a` to `b` meets the box from `lo` to `hi`:
1237/// the segment clipped against each slab in turn.
1238fn 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}