Skip to main content

ogeom_bool/
ball_chart.rs

1//! A ball, or a sphere face, restated on a chart whose poles stand clear of the other
2//! operand: of its curved faces, and of its planes other than one through
3//! a pole.
4//!
5//! A sphere's chart collapses at its poles and folds at its seam. Where the
6//! other operand's boundary passes through a pole (a drill whose wall runs
7//! along the ball's axis), every section through it starts and ends on the
8//! pole, hugs the seam beside it, and leaves the chart no well-placed
9//! crossing to split at. The same ball charted about another axis meets the
10//! same boundary with its poles well away, and the sections are ordinary
11//! loops. A ball that is one face (a seam and two poles, no trim) can be
12//! restated that way exactly: the surface is the same set of points, only
13//! the parameterization moves. A trimmed sphere face of any solid is
14//! restated too, where the new chart's seam can leave its trim at a vertex,
15//! or, for a band whose two loops go round the new axis, where the seam
16//! crosses each loop once: its trim edges take fitted images in the new
17//! chart, and an edge the band's seam crosses is cut there, in every face
18//! of the solid that holds it. A compound operand is taken member by
19//! member.
20
21use ogeom_algo::{Built, History, make_sphere};
22use ogeom_core::{OgeomError, OgeomResult, Tolerances};
23use ogeom_geom::{Curve2d as _, Curve3d as _, Surface as _};
24use ogeom_geom::{PlanarCurve, SurfaceGeometry, Transformable};
25use ogeom_math::{Direction, Frame, Point, Point2, Vector};
26use ogeom_topo::{EdgeRepr, Model, NodeData, Orientation, Shape, ShapeType, explore_unique};
27
28/// How close a pole may come to the other operand's surfaces, as a
29/// fraction of the ball's radius, before the chart is turned. A section a
30/// thousandth and a half of the radius beside a pole is already marched
31/// across the chart's collapse and snapped onto the pole.
32pub const TOUCHING: f64 = 1e-2;
33
34/// A ball operand: the solid, its one face, and the sphere it lies on in
35/// world space.
36struct Ball {
37    solid: Shape,
38    face: Shape,
39    centre: Point,
40    radius: f64,
41    axis: Vector,
42    x: Vector,
43}
44
45/// How many charts of a ball whose poles stand clear of the other operand
46/// a boolean is tried on, the best first, before it is refused.
47const CHARTS_TRIED: usize = 6;
48
49/// `run` on the operands, where either is or holds a whole ball or a sphere
50/// face with a pole on or beside the other's surfaces, with that ball or
51/// face first charted about an axis whose poles stand clear; the history
52/// then runs from the operands as given. Otherwise `run` on the operands
53/// as given.
54///
55/// Where the run on the best such chart refuses, it is run on the next
56/// best, up to [`CHARTS_TRIED`] charts: each is the same ball, restated
57/// exactly, so any that resolves the boolean resolves it rightly. Where
58/// every one refuses, the boolean is refused by name, the last refusal
59/// with it. The chart the operands come with is not taken: there a
60/// section beside a pole can close where the chart's longitude has
61/// collapsed, and give a cut that is valid but short, or a face wound
62/// inward.
63pub(crate) fn with_poles_clear(
64    model: &mut Model,
65    a: &Shape,
66    b: &Shape,
67    tol: Tolerances,
68    mut run: impl FnMut(&mut Model, &Shape, &Shape) -> OgeomResult<Built>,
69) -> OgeomResult<Built> {
70    #[cfg(feature = "test-hooks")]
71    if crate::work::is_unturned() {
72        return run(model, a, b);
73    }
74    let mut last: Option<OgeomError> = None;
75    for rank in 0..CHARTS_TRIED {
76        let Some((ta, tb, before)) = turned(model, a, b, rank, tol)? else {
77            break;
78        };
79        #[cfg(feature = "test-hooks")]
80        let refused = crate::work::is_turn_refused(rank);
81        #[cfg(not(feature = "test-hooks"))]
82        let refused = false;
83        let result = if refused {
84            Err(ogeom_core::ogeom_err!(
85                NotDone,
86                "the turned run is taken as refused"
87            ))
88        } else {
89            run(model, &ta, &tb)
90        };
91        match result {
92            Ok(built) => return Ok(Built::new(built.shape, before.then(&built.history))),
93            Err(e @ (OgeomError::Cancelled | OgeomError::Dangling(_))) => return Err(e),
94            Err(e) => last = Some(e),
95        }
96    }
97    match last {
98        Some(e) => Err(ogeom_core::ogeom_err!(
99            NotDone,
100            "a ball's pole stands on or beside the other operand, and the boolean on every \
101             chart of the ball whose poles stand clear of it is refused (the last: {e}); on \
102             its own chart a section beside a pole is not resolved soundly; see \
103             docs/CAPABILITIES.md, bool.booleans"
104        )),
105        None => run(model, a, b),
106    }
107}
108
109/// The operands with each whole ball whose pole stands on or beside the
110/// other's surfaces charted about an axis whose poles stand clear, and the
111/// history from the operands as given; `None` where no ball needs it.
112fn turned(
113    model: &mut Model,
114    a: &Shape,
115    b: &Shape,
116    rank: usize,
117    tol: Tolerances,
118) -> OgeomResult<Option<(Shape, Shape, History)>> {
119    let mut before = History::new();
120    let mut any = false;
121    let (mut ta, mut tb) = (a.clone(), b.clone());
122    for (operand, other) in [(&mut ta, b), (&mut tb, a)] {
123        if let Some((made, history)) = operand_turned(model, operand, other, rank, tol)? {
124            before = before.then(&history);
125            *operand = made;
126            any = true;
127        }
128    }
129    Ok(any.then_some((ta, tb, before)))
130}
131
132/// `operand` with its ball, or its sphere faces, charted clear of `other`
133/// as [`turned`] states, and the history from `operand`; `None` where
134/// nothing needs it. A compound is taken member by member, each placed as
135/// the compound places it.
136fn operand_turned(
137    model: &mut Model,
138    operand: &Shape,
139    other: &Shape,
140    rank: usize,
141    tol: Tolerances,
142) -> OgeomResult<Option<(Shape, History)>> {
143    if model.kind_of(operand)? == ShapeType::Compound {
144        let mut before = History::new();
145        let mut any = false;
146        let mut members = Vec::new();
147        for member in model.children_of(operand)? {
148            if let Some((made, history)) = operand_turned(model, &member, other, rank, tol)? {
149                before = before.then(&history);
150                members.push(made);
151                any = true;
152            } else {
153                members.push(member);
154            }
155        }
156        if !any {
157            return Ok(None);
158        }
159        let made = model.add_compound(&members)?;
160        before.modify(operand, made.clone());
161        return Ok(Some((made, before)));
162    }
163    let Some(ball) = whole_ball(model, operand, tol)? else {
164        return faces_turned(model, operand, other, rank, tol);
165    };
166    let walls = walls_of(model, other, tol)?;
167    let clearance = |p: Point| clearance_of(&walls, p, tol);
168    let poles = |axis: Vector| {
169        clearance(ball.centre + axis * ball.radius).min(clearance(ball.centre - axis * ball.radius))
170    };
171    if poles(ball.axis) > ball.radius * TOUCHING {
172        return Ok(None);
173    }
174    // Candidate axes in the ball's own frame: its other two axes, the
175    // diagonals of each pair, and the four diagonals of the cube, ranked by
176    // how far the nearer pole stands from the other's surfaces (the earlier
177    // candidate first among equals). The axis of rank `rank` among those
178    // whose poles stand clear: the best at rank 0.
179    let candidates = candidate_axes(ball.x, ball.axis);
180    let x = ball.x;
181    let y = ball.axis.cross(ball.x);
182    let mut ranked: Vec<(f64, Vector)> = Vec::new();
183    for candidate in candidates {
184        let Ok(axis) = candidate.normalized(tol) else {
185            continue;
186        };
187        let score = poles(axis);
188        if score > ball.radius * TOUCHING {
189            ranked.push((score, axis));
190        }
191    }
192    ranked.sort_by(|a, b| b.0.total_cmp(&a.0));
193    let Some(&(_, axis)) = ranked.get(rank) else {
194        return Ok(None);
195    };
196    // The seam on the side of the new axis that stands furthest from
197    // the other's surfaces, read at the seam's point on the equator.
198    let across = if axis.cross(x).magnitude() > 0.5 {
199        x
200    } else {
201        y
202    };
203    let Ok(u) = axis.cross(across).normalized(tol) else {
204        return Ok(None);
205    };
206    let w = axis.cross(u);
207    let seam = [u, -u, w, -w]
208        .into_iter()
209        .map(|d| (clearance(ball.centre + d * ball.radius), d))
210        .fold(None, |kept: Option<(f64, Vector)>, (c, d)| match kept {
211            Some((k, _)) if k >= c => kept,
212            _ => Some((c, d)),
213        })
214        .map_or(u, |(_, d)| d);
215    let frame = Frame::new(
216        ball.centre,
217        Direction::new(axis, tol)?,
218        Direction::new(seam, tol)?,
219        tol,
220    )?;
221    let made = make_sphere(model, frame, ball.radius, tol)?.shape;
222    let Some(face) = explore_unique(model, &made, ShapeType::Face)?
223        .first()
224        .cloned()
225    else {
226        return Ok(None);
227    };
228    let mut before = History::new();
229    before.modify(&ball.solid, made.clone());
230    for shell in explore_unique(model, &ball.solid, ShapeType::Shell)? {
231        for new_shell in explore_unique(model, &made, ShapeType::Shell)? {
232            before.modify(&shell, new_shell);
233        }
234    }
235    before.modify(&ball.face, face);
236    for kind in [ShapeType::Edge, ShapeType::Vertex] {
237        for sub in explore_unique(model, &ball.solid, kind)? {
238            before.delete(&sub);
239        }
240    }
241    Ok(Some((made, before)))
242}
243
244/// The ball `shape` is, where it is a solid of one face on a sphere with no
245/// trim (a seam and its two poles), its material inside, placed as its
246/// placement puts it: a ball made in its stead stands in world space, so
247/// a scale or a reflection is taken whole.
248fn whole_ball(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Option<Ball>> {
249    if model.kind_of(shape)? != ShapeType::Solid {
250        return Ok(None);
251    }
252    let faces = explore_unique(model, shape, ShapeType::Face)?;
253    let [face] = faces.as_slice() else {
254        return Ok(None);
255    };
256    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
257        return Ok(None);
258    };
259    if !data.location.is_identity() {
260        return Ok(None);
261    }
262    let placement = face.transform(model.datums())?;
263    let Some(SurfaceGeometry::Sphere(stored)) = model.geometry().surface(data.surface) else {
264        return Ok(None);
265    };
266    // The boundary: one edge with extent, used twice (the seam), and
267    // degenerate edges (the poles).
268    let mut seams = Vec::new();
269    for wire in model.children_of(face)? {
270        for edge in model.children_of(&wire)? {
271            let Some(NodeData::Edge(e)) = model.node(&edge).map(|n| n.data()) else {
272                return Ok(None);
273            };
274            if matches!(e.curve3d(), Some(EdgeRepr::Curve3d { .. })) {
275                seams.push(edge.node());
276            }
277        }
278    }
279    if seams.len() != 2 || seams[0] != seams[1] {
280        return Ok(None);
281    }
282    let sphere = stored.sphere();
283    let frame = sphere.frame();
284    // Material inside: the face's outward side, its natural normal or the
285    // reverse, points away from the centre. Read on the sphere as stored,
286    // where the face's flag states it; a placement moves the material with
287    // the sphere, whichever way it turns the chart.
288    let (u, v) = (0.5, 0.25);
289    let at = stored.point_at(u, v, tol)?;
290    let normal = stored.normal_at(u, v, tol)?.vector();
291    let reversed = face.orientation() == ogeom_topo::Orientation::Reversed;
292    let outward = (at - sphere.centre()).dot(normal) > 0.0;
293    if outward == reversed {
294        return Ok(None);
295    }
296    Ok(Some(Ball {
297        solid: shape.clone(),
298        face: face.clone(),
299        centre: placement.apply(sphere.centre()),
300        radius: sphere.radius() * placement.scale_factor().abs(),
301        axis: placement.apply_vector(frame.z().vector()).normalized(tol)?,
302        x: placement.apply_vector(frame.x().vector()).normalized(tol)?,
303    }))
304}
305
306/// The surfaces of `shape`'s faces, placed in world space.
307fn walls_of(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Vec<SurfaceGeometry>> {
308    let mut out = Vec::new();
309    for face in explore_unique(model, shape, ShapeType::Face)? {
310        let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
311            continue;
312        };
313        let Some(surface) = model.geometry().surface(data.surface) else {
314            continue;
315        };
316        out.push(surface.transformed(&face.transform(model.datums())?, tol)?);
317    }
318    Ok(out)
319}
320
321/// How far `p`, a pole, stands from `walls`: the nearest of its distances
322/// to their curved surfaces, and to their planes other than one through
323/// it. A plane through the pole cuts a meridian, split there exactly; one
324/// a hair beside it cuts a small circle round the pole, whose image the
325/// chart cannot hold.
326fn clearance_of(walls: &[SurfaceGeometry], p: Point, tol: Tolerances) -> f64 {
327    walls
328        .iter()
329        .filter_map(|wall| match wall {
330            SurfaceGeometry::Plane(plane) => {
331                Some(plane.plane().distance_to(p)).filter(|d| *d > tol.confusion())
332            }
333            _ => ogeom_algo::project_on_surface(wall, p, 8, tol)
334                .ok()
335                .map(|found| found.distance),
336        })
337        .fold(f64::INFINITY, f64::min)
338}
339
340/// Candidate axes in a ball's own frame (`x` and its axis `z`): its three
341/// axes, the diagonals of each pair, and the four diagonals of the cube.
342fn candidate_axes(x: Vector, z: Vector) -> Vec<Vector> {
343    let y = z.cross(x);
344    let mut candidates = vec![x, y];
345    for (p, q) in [(x, y), (y, z), (z, x)] {
346        candidates.push(p + q);
347        candidates.push(p - q);
348    }
349    for (s, t) in [(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] {
350        candidates.push(x * s + y * t + z);
351    }
352    candidates
353}
354
355/// How near a new pole may come to the trim of the face it is put on, as a
356/// fraction of the radius, and how wide a stretch of longitude the trim
357/// must leave free for a seam it does not cross. The trim's image beside a
358/// pole swings fast; one well clear of it is a tame curve.
359const CLEAR_OF_TRIM: f64 = 0.1;
360
361/// Samples taken along each trim edge.
362const EDGE_SAMPLES: usize = 128;
363
364/// Samples each trim edge's image is fitted through, and the fit's target
365/// at them in radians of the chart. The image is then held to a tenth of
366/// its edge's tolerance in space between the samples.
367const FIT_SAMPLES: usize = 512;
368const FIT_IN_CHART: f64 = 1e-11;
369
370/// How far in longitude a trim sample must stand from the seam, other than
371/// at the vertex the seam leaves the face at.
372const OFF_SEAM: f64 = 1e-6;
373
374/// Meridians a band's seam is tried on, evenly spaced round the new axis,
375/// and the points along each between the two loops its clearance of the
376/// other operand's walls is read at.
377const BAND_SEAMS: usize = 8;
378const BAND_PROBES: usize = 8;
379
380/// A shape the restating replaces and what it became, or `None` where it
381/// is gone.
382type Record = (Shape, Option<Shape>);
383
384/// A trim edge cut where a new chart's seam crosses it: the edge, forward,
385/// and its pieces in its own direction.
386type Split = (Shape, Vec<Shape>);
387
388/// A loop started where a band's seam crosses it: its edges, the vertex
389/// it starts at, and the edge the seam cut there, if any.
390type Started = (Vec<Shape>, Shape, Vec<Split>);
391
392/// `solid` with each sphere face whose pole stands on or beside `other`'s
393/// surfaces restated on a chart whose poles stand clear, and what
394/// became of the shapes it replaced; `None` where no face needs it or none
395/// can be restated, or the restated solid does not hold.
396///
397/// The face keeps its boundary edges, each given an image in the new
398/// chart; its old seam and pole edges are artefacts of the old chart and
399/// go. The new chart's seam crosses the trim only at a vertex: a pole
400/// inside the face alone takes a seam from that vertex up to it and a pole
401/// edge, two poles inside take a seam between them, and a seam the face
402/// does not reach takes no edge at all. A band, no pole inside and its two
403/// loops once round the axis each way, takes a seam between them on a
404/// meridian crossing each loop once and reaching no other, the one of
405/// [`BAND_SEAMS`] standing furthest from `other`'s walls; a loop edge it
406/// crosses inside is cut there, and every face holding that edge takes the
407/// pieces. A face whose trim the new chart would have to split otherwise
408/// is left as it is.
409///
410/// A solid at a rigid placement is restated in its own frame and placed
411/// as it was. One placed with a scale or a reflection, or with a pinned
412/// face on a left-handed sphere, is baked into world space first, which
413/// states each sphere on a right-handed frame, and the baked solid is
414/// restated: baking a face already restated would re-derive images that
415/// end on its seam.
416fn faces_turned(
417    model: &mut Model,
418    solid: &Shape,
419    other: &Shape,
420    rank: usize,
421    tol: Tolerances,
422) -> OgeomResult<Option<(Shape, History)>> {
423    if model.kind_of(solid)? != ShapeType::Solid {
424        return Ok(None);
425    }
426    let bare = solid
427        .located(ogeom_topo::Location::identity())
428        .oriented(Orientation::Forward);
429    let mut pinned_any = false;
430    let mut left_handed = false;
431    for face in explore_unique(model, &bare, ShapeType::Face)? {
432        if let Some(pinned) = pinned_sphere(model, &face)? {
433            pinned_any = true;
434            left_handed |= pinned.sphere.frame().handedness() != ogeom_math::Handedness::Right;
435        }
436    }
437    if !pinned_any {
438        return Ok(None);
439    }
440    let placement = solid.transform(model.datums())?;
441    let rigid =
442        (placement.scale_factor().abs() - 1.0).abs() <= 1e-9 && placement.preserves_handedness();
443    let (target, baked) = if rigid && !left_handed {
444        (solid.clone(), None)
445    } else {
446        let baked = ogeom_algo::baked_shape(model, solid, tol)?;
447        (baked.shape, Some(baked.history))
448    };
449    let Some((made, records)) = rigid_faces_turned(model, &target, other, rank, tol)? else {
450        return Ok(None);
451    };
452    let mut history = History::new();
453    for (old, new) in records {
454        match new {
455            Some(new) => history.modify(&old, new),
456            None => history.delete(&old),
457        }
458    }
459    Ok(Some(match baked {
460        Some(baked) => (made, baked.then(&history)),
461        None => (made, history),
462    }))
463}
464
465/// [`faces_turned`] on a solid at a rigid placement, with what became of
466/// each shape it replaced.
467fn rigid_faces_turned(
468    model: &mut Model,
469    solid: &Shape,
470    other: &Shape,
471    rank: usize,
472    tol: Tolerances,
473) -> OgeomResult<Option<(Shape, Vec<Record>)>> {
474    // The solid's own node is restated in its own frame, `other`'s walls
475    // brought into that frame, and the result placed as the solid is.
476    let placement = solid.location().clone();
477    let bare = solid
478        .located(ogeom_topo::Location::identity())
479        .oriented(Orientation::Forward);
480    let to_local = solid.transform(model.datums())?.inverse()?;
481    let mut walls: Option<Vec<SurfaceGeometry>> = None;
482    let mut records = Vec::new();
483    // Each trim edge a restated face cut at its seam, with the face that
484    // cut it.
485    let mut splits: Vec<(Split, ogeom_topo::TShapeId)> = Vec::new();
486    let shells = model.children_of(&bare)?;
487    let mut turned_in: Vec<Vec<Option<Shape>>> = Vec::with_capacity(shells.len());
488    for shell in &shells {
489        let faces = model.children_of(shell)?;
490        let mut turned_here = Vec::with_capacity(faces.len());
491        for face in &faces {
492            let mut turned = None;
493            if let Some(pinned) = pinned_sphere(model, face)?
494                && pinned.sphere.frame().handedness() == ogeom_math::Handedness::Right
495            {
496                let walls = match &walls {
497                    Some(w) => w,
498                    None => walls.insert(
499                        walls_of(model, other, tol)?
500                            .iter()
501                            .map(|w| w.transformed(&to_local, tol))
502                            .collect::<OgeomResult<_>>()?,
503                    ),
504                };
505                if let Some((new, cut)) = face_turned(model, face, &pinned, walls, rank, tol)? {
506                    records.push((face.clone(), Some(new.clone())));
507                    for gone in &pinned.dropped {
508                        records.push((gone.clone(), None));
509                    }
510                    for (old, pieces) in &cut {
511                        for piece in pieces {
512                            records.push((old.clone(), Some(piece.clone())));
513                        }
514                    }
515                    splits.extend(cut.into_iter().map(|s| (s, face.node())));
516                    turned = Some(new);
517                }
518            }
519            turned_here.push(turned);
520        }
521        turned_in.push(turned_here);
522    }
523    // A restated face reads its trim edges uncut: where another restated
524    // face cut one of them, the solid is left as it is.
525    for (shell, turned_here) in shells.iter().zip(&turned_in) {
526        for (face, turned) in model.children_of(shell)?.iter().zip(turned_here) {
527            if turned.is_none() {
528                continue;
529            }
530            for edge in explore_unique(model, face, ShapeType::Edge)? {
531                if splits
532                    .iter()
533                    .any(|((old, _), by)| old.node() == edge.node() && *by != face.node())
534                {
535                    return Ok(None);
536                }
537            }
538        }
539    }
540    let cut: Vec<Split> = splits.into_iter().map(|(s, _)| s).collect();
541    let mut changed = false;
542    let mut rebuilt_shells = Vec::with_capacity(shells.len());
543    for (shell, turned_here) in shells.into_iter().zip(turned_in) {
544        let faces = model.children_of(&shell)?;
545        let mut kept = Vec::with_capacity(faces.len());
546        let mut touched = false;
547        for (face, turned) in faces.into_iter().zip(turned_here) {
548            if let Some(new) = turned {
549                kept.push(new);
550                touched = true;
551            } else if let Some(new) = with_pieces(model, &face, &cut)? {
552                records.push((face, Some(new.clone())));
553                kept.push(new);
554                touched = true;
555            } else {
556                kept.push(face);
557            }
558        }
559        if touched {
560            let new_shell = model.add_shell(&kept)?;
561            records.push((shell, Some(new_shell.clone())));
562            rebuilt_shells.push(new_shell);
563            changed = true;
564        } else {
565            rebuilt_shells.push(shell);
566        }
567    }
568    if !changed {
569        return Ok(None);
570    }
571    let shells = rebuilt_shells;
572    let made = model.add_solid(&shells)?;
573    if !ogeom_algo::check(model, &made, tol)?.is_valid() {
574        return Ok(None);
575    }
576    let placed = made.moved(&placement).composed(solid.orientation());
577    let mut records: Vec<Record> = records
578        .into_iter()
579        .map(|(old, new)| (old.moved(&placement), new.map(|n| n.moved(&placement))))
580        .collect();
581    records.push((solid.clone(), Some(placed.clone())));
582    Ok(Some((placed, records)))
583}
584
585/// `face` on its own surface with each edge `splits` cut replaced by its
586/// pieces, run the way the face runs the edge; `None` where it holds none
587/// of them, or holds one at a placement.
588fn with_pieces(model: &mut Model, face: &Shape, splits: &[Split]) -> OgeomResult<Option<Shape>> {
589    if splits.is_empty() {
590        return Ok(None);
591    }
592    let Some(node) = model.node(face) else {
593        ogeom_core::ogeom_bail!(Dangling, "face is not in this model");
594    };
595    let NodeData::Face(data) = node.data() else {
596        return Ok(None);
597    };
598    let mut data = (**data).clone();
599    data.triangulation = None;
600    let wires = node.children().to_vec();
601    let mut any = false;
602    let mut made = Vec::with_capacity(wires.len());
603    for wire in wires {
604        let Some(held) = model.node(&wire) else {
605            ogeom_core::ogeom_bail!(Dangling, "wire is not in this model");
606        };
607        let mut edges = Vec::with_capacity(held.children().len() + 1);
608        for edge in held.children() {
609            let Some((_, pieces)) = splits.iter().find(|(old, _)| old.node() == edge.node()) else {
610                edges.push(edge.clone());
611                continue;
612            };
613            if !edge.location().is_identity() {
614                return Ok(None);
615            }
616            any = true;
617            if edge.orientation() == Orientation::Reversed {
618                edges.extend(pieces.iter().rev().map(Shape::reversed));
619            } else {
620                edges.extend(pieces.iter().cloned());
621            }
622        }
623        let rebuilt = model.add_wire(&edges)?;
624        made.push(
625            rebuilt
626                .located(wire.location().clone())
627                .oriented(wire.orientation()),
628        );
629    }
630    if !any {
631        return Ok(None);
632    }
633    let new = model.add_face(data, &made)?;
634    Ok(Some(
635        new.located(face.location().clone())
636            .oriented(face.orientation()),
637    ))
638}
639
640/// A sphere face with a pole in its chart, its trim chained into loops.
641struct Pinned {
642    sphere: ogeom_math::Sphere,
643    /// The face's poles in space.
644    poles: Vec<Point>,
645    /// The loops of its trim, seam and pole edges left out: each the edges
646    /// in the order the face runs them, oriented as it uses them.
647    rings: Vec<Vec<Shape>>,
648    /// The seam and pole edges, and the pole vertices no loop keeps.
649    dropped: Vec<Shape>,
650}
651
652/// The face as a [`Pinned`] sphere face, where it lies on a sphere at no
653/// placement, has a pole, and its other edges chain head to
654/// tail into closed loops one way only.
655fn pinned_sphere(model: &Model, face: &Shape) -> OgeomResult<Option<Pinned>> {
656    pinned_sphere_with(model, face, true)
657}
658
659/// [`pinned_sphere`], with `need_poles` false also for a face whose trim
660/// keeps clear of both poles (no pole edge in its boundary).
661fn pinned_sphere_with(
662    model: &Model,
663    face: &Shape,
664    need_poles: bool,
665) -> OgeomResult<Option<Pinned>> {
666    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
667        return Ok(None);
668    };
669    if !face.location().is_identity() || !data.location.is_identity() {
670        return Ok(None);
671    }
672    let Some(SurfaceGeometry::Sphere(stored)) = model.geometry().surface(data.surface) else {
673        return Ok(None);
674    };
675    let sphere = stored.sphere();
676    // The wires as the face stores them, whichever way the shell uses it.
677    let forward = face.oriented(Orientation::Forward);
678    let mut uses: ogeom_core::FastMap<ogeom_topo::TShapeId, usize> = ogeom_core::FastMap::default();
679    let mut edges = Vec::new();
680    for wire in model.children_of(&forward)? {
681        for edge in model.children_of(&wire)? {
682            *uses.entry(edge.node()).or_default() += 1;
683            edges.push(edge);
684        }
685    }
686    let mut poles = Vec::new();
687    let mut dropped: Vec<Shape> = Vec::new();
688    let mut kept: Vec<(Shape, Shape, Shape)> = Vec::new();
689    for edge in edges {
690        let Some(NodeData::Edge(e)) = model.node(&edge).map(|n| n.data()) else {
691            return Ok(None);
692        };
693        if !edge.location().is_identity() {
694            return Ok(None);
695        }
696        let Some((start, end)) = ogeom_algo::edge_vertices(model, &edge)? else {
697            return Ok(None);
698        };
699        if e.degenerate {
700            let Some(NodeData::Vertex(v)) = model.node(&start).map(|n| n.data()) else {
701                return Ok(None);
702            };
703            if !start.location().is_identity() {
704                return Ok(None);
705            }
706            poles.push(v.point);
707            if !dropped.iter().any(|d| d.node() == edge.node()) {
708                dropped.push(edge.clone());
709                dropped.push(start);
710            }
711            continue;
712        }
713        match uses.get(&edge.node()) {
714            Some(1) => {}
715            // The seam, up one side of the chart and down the other.
716            Some(2) => {
717                if !dropped.iter().any(|d| d.node() == edge.node()) {
718                    dropped.push(edge.clone());
719                }
720                continue;
721            }
722            _ => return Ok(None),
723        }
724        if !matches!(e.curve3d(), Some(EdgeRepr::Curve3d { location, .. }) if location.is_identity())
725        {
726            return Ok(None);
727        }
728        kept.push((edge, start, end));
729    }
730    if (need_poles && poles.is_empty()) || kept.is_empty() {
731        return Ok(None);
732    }
733    // A pole vertex a kept edge still ends at is a corner of the trim.
734    dropped.retain(|d| {
735        !kept
736            .iter()
737            .any(|(_, s, e)| s.node() == d.node() || e.node() == d.node())
738    });
739    let mut rings = Vec::new();
740    let mut used = vec![false; kept.len()];
741    while let Some(first) = used.iter().position(|u| !u) {
742        used[first] = true;
743        let origin = kept[first].1.node();
744        let mut ring = vec![kept[first].0.clone()];
745        let mut at = kept[first].2.node();
746        while at != origin {
747            let next: Vec<usize> = (0..kept.len())
748                .filter(|&i| !used[i] && kept[i].1.node() == at)
749                .collect();
750            let [i] = next.as_slice() else {
751                return Ok(None);
752            };
753            used[*i] = true;
754            ring.push(kept[*i].0.clone());
755            at = kept[*i].2.node();
756        }
757        rings.push(ring);
758    }
759    Ok(Some(Pinned {
760        sphere,
761        poles,
762        rings,
763        dropped,
764    }))
765}
766
767/// The face restated on its sphere charted about an axis whose poles stand
768/// clear of `walls` and of the face's own trim, where a pole of its chart
769/// stands on or beside `walls`, and the trim edges its new seam cut;
770/// `None` otherwise, or where no candidate axis lays a chart the trim
771/// fits.
772fn face_turned(
773    model: &mut Model,
774    face: &Shape,
775    pinned: &Pinned,
776    walls: &[SurfaceGeometry],
777    rank: usize,
778    tol: Tolerances,
779) -> OgeomResult<Option<(Shape, Vec<Split>)>> {
780    // The curved walls rank the candidate axes, as for a face no plane
781    // stands beside; the planes, a pole beside which turns the chart as a
782    // curved wall's does, only bar an axis whose pole they pass beside.
783    let (flats, curved): (Vec<SurfaceGeometry>, Vec<SurfaceGeometry>) = walls
784        .iter()
785        .cloned()
786        .partition(|w| matches!(w, SurfaceGeometry::Plane(_)));
787    let clearance = |p: Point| clearance_of(&curved, p, tol);
788    let (centre, radius) = (pinned.sphere.centre(), pinned.sphere.radius());
789    let beside = |p: Point| clearance_of(walls, p, tol) <= radius * TOUCHING;
790    if !pinned.poles.iter().any(|&p| beside(p)) {
791        return Ok(None);
792    }
793    let mut trim: Vec<Point> = Vec::new();
794    for ring in &pinned.rings {
795        trim.extend(ring_points(model, ring, tol)?);
796    }
797    let near_trim = |p: Point| -> f64 {
798        trim.iter()
799            .map(|q| q.distance(p))
800            .fold(f64::INFINITY, f64::min)
801    };
802    let frame = pinned.sphere.frame();
803    let mut scored: Vec<(f64, Vector)> = Vec::new();
804    for candidate in candidate_axes(frame.x().vector(), frame.z().vector()) {
805        let Ok(axis) = candidate.normalized(tol) else {
806            continue;
807        };
808        let ends = [centre + axis * radius, centre - axis * radius];
809        if ends.iter().any(|&p| near_trim(p) <= radius * CLEAR_OF_TRIM) {
810            continue;
811        }
812        if ends
813            .iter()
814            .any(|&p| clearance_of(&flats, p, tol) <= radius * TOUCHING)
815        {
816            continue;
817        }
818        let score = clearance(ends[0]).min(clearance(ends[1]));
819        if score > radius * TOUCHING {
820            scored.push((score, axis));
821        }
822    }
823    scored.sort_by(|a, b| b.0.total_cmp(&a.0));
824    // The restatement of rank `rank` among those that hold: the best at 0.
825    let mut held = 0;
826    for (_, axis) in scored {
827        if let Some(made) = restated(model, face, pinned, axis, &clearance, None, tol)? {
828            if held == rank {
829                return Ok(Some(made));
830            }
831            held += 1;
832        }
833    }
834    Ok(None)
835}
836
837/// `count` steps of an edge's parameters and points, in the order the edge
838/// as given runs.
839fn edge_samples(
840    model: &Model,
841    edge: &Shape,
842    count: usize,
843    tol: Tolerances,
844) -> OgeomResult<Vec<(f64, Point)>> {
845    let Some(NodeData::Edge(e)) = model.node(edge).map(|n| n.data()) else {
846        return Ok(Vec::new());
847    };
848    let Some(EdgeRepr::Curve3d { curve, range, .. }) = e.curve3d() else {
849        return Ok(Vec::new());
850    };
851    let Some(curve) = model.geometry().curve(*curve) else {
852        return Ok(Vec::new());
853    };
854    let mut out = Vec::with_capacity(count + 1);
855    for k in 0..=count {
856        #[allow(clippy::cast_precision_loss)]
857        let s = k as f64 / count as f64;
858        let s = if edge.orientation() == Orientation::Reversed {
859            1.0 - s
860        } else {
861            s
862        };
863        let t = range.0 + (range.1 - range.0) * s;
864        out.push((t, curve.point_at(t, tol)?));
865    }
866    Ok(out)
867}
868
869/// A loop's points in the order it runs.
870fn ring_points(model: &Model, ring: &[Shape], tol: Tolerances) -> OgeomResult<Vec<Point>> {
871    let mut out = Vec::new();
872    for edge in ring {
873        out.extend(
874            edge_samples(model, edge, EDGE_SAMPLES, tol)?
875                .into_iter()
876                .map(|(_, p)| p),
877        );
878    }
879    Ok(out)
880}
881
882/// Longitude and latitude of `p` in a sphere chart about `frame`.
883fn chart_of(frame: &Frame, p: Point) -> (f64, f64) {
884    let local = frame.to_local(p);
885    (
886        local.y.atan2(local.x),
887        local.z.atan2(local.x.hypot(local.y)),
888    )
889}
890
891/// `u` moved by whole turns to within half a turn of `near`.
892fn unwrapped(u: f64, near: f64) -> f64 {
893    let turn = core::f64::consts::TAU;
894    u - turn * ((u - near) / turn).round()
895}
896
897/// The longitudes of a loop of points in a chart about `frame`, carried
898/// along the loop from the first one.
899fn longitudes(frame: &Frame, ring: &[Point]) -> Vec<f64> {
900    let mut out: Vec<f64> = Vec::with_capacity(ring.len());
901    for &p in ring {
902        let u = chart_of(frame, p).0;
903        out.push(out.last().map_or(u, |&before| unwrapped(u, before)));
904    }
905    out
906}
907
908/// Whole turns a loop of longitudes makes about the axis.
909fn winding(us: &[f64]) -> i64 {
910    let (Some(first), Some(last)) = (us.first(), us.last()) else {
911        return 0;
912    };
913    #[allow(clippy::cast_possible_truncation)]
914    let turns = ((last - first) / core::f64::consts::TAU).round() as i64;
915    turns
916}
917
918/// The least and greatest of some longitudes.
919fn span(us: &[f64]) -> (f64, f64) {
920    us.iter()
921        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &u| {
922            (lo.min(u), hi.max(u))
923        })
924}
925
926/// Whether some turn of longitude `u` lies within `(lo, hi)` widened by
927/// [`OFF_SEAM`].
928fn reaches(lo: f64, hi: f64, u: f64) -> bool {
929    let turn = core::f64::consts::TAU;
930    let first = u + turn * ((lo - OFF_SEAM - u) / turn).ceil();
931    first <= hi + OFF_SEAM
932}
933
934/// The index of the sample beside the one place a loop's carried
935/// longitudes pass longitude `u` (some whole turn of it), or `None` where
936/// they pass it other than once.
937fn crossed_once(us: &[f64], u: f64) -> Option<usize> {
938    let turn = core::f64::consts::TAU;
939    let mut count = 0.0;
940    let mut found = None;
941    for (i, w) in us.windows(2).enumerate() {
942        let (lo, hi) = if w[0] <= w[1] {
943            (w[0], w[1])
944        } else {
945            (w[1], w[0])
946        };
947        // The turns of `u` in `(lo, hi]`.
948        let passed = (((hi - u) / turn).floor() - ((lo - u) / turn).floor()).max(0.0);
949        if passed > 0.0 {
950            count += passed;
951            found = Some(i);
952        }
953    }
954    if count == 1.0 { found } else { None }
955}
956
957/// The surface a face lies on.
958fn face_surface(model: &Model, face: &Shape) -> Option<ogeom_topo::SurfaceId> {
959    match model.node(face).map(|n| n.data()) {
960        Some(NodeData::Face(data)) => Some(data.surface),
961        _ => None,
962    }
963}
964
965/// `ring`, a loop once round `chart`'s axis, started at the vertex where it
966/// crosses the chart's seam meridian (longitude 0), the edge it crosses
967/// inside cut there: the loop, that vertex, and the cut. `None` where it
968/// crosses that meridian other than once, or the edge will not cut.
969fn started_on_seam(
970    model: &mut Model,
971    ring: &[Shape],
972    chart: &Frame,
973    old_surface: ogeom_topo::SurfaceId,
974    tol: Tolerances,
975) -> OgeomResult<Option<Started>> {
976    use core::f64::consts::FRAC_PI_2;
977    let mut samples: Vec<(usize, f64, f64)> = Vec::new();
978    for (k, edge) in ring.iter().enumerate() {
979        for (t, p) in edge_samples(model, edge, EDGE_SAMPLES, tol)? {
980            samples.push((k, t, chart_of(chart, p).0));
981        }
982    }
983    // Longitude changes sign at the seam and, a half turn round, where it
984    // wraps; only the seam's is wanted.
985    let crossings: Vec<(usize, f64, f64, f64)> = samples
986        .windows(2)
987        .filter(|w| {
988            w[0].0 == w[1].0
989                && (w[0].2 < 0.0) != (w[1].2 < 0.0)
990                && w[0].2.abs() < FRAC_PI_2
991                && w[1].2.abs() < FRAC_PI_2
992        })
993        .map(|w| (w[0].0, w[0].1, w[1].1, w[0].2))
994        .collect();
995    let [(k, lo, hi, u_lo)] = crossings[..] else {
996        return Ok(None);
997    };
998    let (mut lo, mut hi) = (lo, hi);
999    let Some(NodeData::Edge(data)) = model.node(&ring[k]).map(|n| n.data()) else {
1000        return Ok(None);
1001    };
1002    let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
1003        return Ok(None);
1004    };
1005    let Some(curve) = model.geometry().curve(*curve).cloned() else {
1006        return Ok(None);
1007    };
1008    let below = u_lo < 0.0;
1009    for _ in 0..200 {
1010        let mid = f64::midpoint(lo, hi);
1011        if mid == lo || mid == hi {
1012            break;
1013        }
1014        if (chart_of(chart, curve.point_at(mid, tol)?).0 < 0.0) == below {
1015            lo = mid;
1016        } else {
1017            hi = mid;
1018        }
1019    }
1020    let t = f64::midpoint(lo, hi);
1021    let at = curve.point_at(t, tol)?;
1022    let Some((start, end)) = ogeom_algo::edge_vertices(model, &ring[k])? else {
1023        return Ok(None);
1024    };
1025    for (vertex, from) in [(&start, k), (&end, k + 1)] {
1026        let Some(NodeData::Vertex(v)) = model.node(vertex).map(|n| n.data()) else {
1027            return Ok(None);
1028        };
1029        if v.point.distance(at) <= v.tolerance.get().max(tol.confusion()) {
1030            let mut started = ring.to_vec();
1031            started.rotate_left(from % ring.len());
1032            return Ok(Some((started, vertex.clone(), Vec::new())));
1033        }
1034    }
1035    let edge = ring[k].oriented(Orientation::Forward);
1036    let Some((pieces, vertex)) = cut_edge(model, &edge, t, old_surface, tol)? else {
1037        return Ok(None);
1038    };
1039    let [head, tail] = pieces.as_slice() else {
1040        return Ok(None);
1041    };
1042    let run = if ring[k].orientation() == Orientation::Reversed {
1043        [tail.reversed(), head.reversed()]
1044    } else {
1045        [head.clone(), tail.clone()]
1046    };
1047    let mut started: Vec<Shape> = ring[..k].to_vec();
1048    started.extend(run);
1049    started.extend(ring[k + 1..].iter().cloned());
1050    started.rotate_left(k + 1);
1051    Ok(Some((started, vertex, vec![(edge, pieces)])))
1052}
1053
1054/// `edge` (forward) cut at `t` on its curve: its two pieces in its own
1055/// direction, each with the edge's pcurves on surfaces other than `skip`
1056/// cut where they pass the cut point, and the vertex between them. `None`
1057/// where the edge is degenerate or placed, it is a seam of another
1058/// surface, or a pcurve passes nowhere within its tolerance of that point.
1059fn cut_edge(
1060    model: &mut Model,
1061    edge: &Shape,
1062    t: f64,
1063    skip: ogeom_topo::SurfaceId,
1064    tol: Tolerances,
1065) -> OgeomResult<Option<(Vec<Shape>, Shape)>> {
1066    let Some(NodeData::Edge(data)) = model.node(edge).map(|n| n.data()) else {
1067        return Ok(None);
1068    };
1069    let data = data.clone();
1070    if data.degenerate {
1071        return Ok(None);
1072    }
1073    let Some(EdgeRepr::Curve3d {
1074        curve,
1075        location,
1076        range,
1077    }) = data.curve3d()
1078    else {
1079        return Ok(None);
1080    };
1081    if !location.is_identity() || !(range.0 < t && t < range.1) {
1082        return Ok(None);
1083    }
1084    let range = *range;
1085    let Some(geometry) = model.geometry().curve(*curve).cloned() else {
1086        return Ok(None);
1087    };
1088    let Some((start, end)) = ogeom_algo::edge_vertices(model, edge)? else {
1089        return Ok(None);
1090    };
1091    let at = geometry.point_at(t, tol)?;
1092    let reach = data.tolerance.get().max(tol.confusion());
1093    let mut cuts = Vec::new();
1094    for repr in &data.representations {
1095        match repr {
1096            EdgeRepr::PCurve {
1097                curve,
1098                surface,
1099                location,
1100                range,
1101            } => {
1102                if *surface == skip {
1103                    continue;
1104                }
1105                if !location.is_identity() {
1106                    return Ok(None);
1107                }
1108                let (Some(pcurve), Some(on)) = (
1109                    model.geometry().pcurve(*curve),
1110                    model.geometry().surface(*surface),
1111                ) else {
1112                    return Ok(None);
1113                };
1114                let Some(s) = pcurve_parameter_at(on, pcurve, *range, at, reach, tol)? else {
1115                    return Ok(None);
1116                };
1117                if !(range.0 < s && s < range.1) {
1118                    return Ok(None);
1119                }
1120                cuts.push((pcurve.clone(), *surface, (range.0, s), (s, range.1)));
1121            }
1122            EdgeRepr::Seam { surface, .. } if *surface != skip => return Ok(None),
1123            _ => {}
1124        }
1125    }
1126    let vertex = model.add_vertex(ogeom_topo::VertexData::new(at));
1127    let head =
1128        ogeom_algo::make_edge_between(model, geometry.clone(), (range.0, t), &start, &vertex, tol)?
1129            .shape;
1130    let tail =
1131        ogeom_algo::make_edge_between(model, geometry, (t, range.1), &vertex, &end, tol)?.shape;
1132    for (pcurve, surface, first, second) in cuts {
1133        let identity = ogeom_topo::Location::identity();
1134        ogeom_algo::attach_pcurve(
1135            model,
1136            &head,
1137            pcurve.clone(),
1138            surface,
1139            identity.clone(),
1140            first,
1141        )?;
1142        ogeom_algo::attach_pcurve(model, &tail, pcurve, surface, identity, second)?;
1143    }
1144    model.widen(&head, data.tolerance)?;
1145    model.widen(&tail, data.tolerance)?;
1146    Ok(Some((vec![head, tail], vertex)))
1147}
1148
1149/// The parameter in `range` at which `pcurve` on `surface` passes nearest
1150/// `p`, where it passes within `reach` of it: the nearest of evenly spaced
1151/// samples, refined by golden section between its neighbours.
1152fn pcurve_parameter_at(
1153    surface: &SurfaceGeometry,
1154    pcurve: &PlanarCurve,
1155    range: (f64, f64),
1156    p: Point,
1157    reach: f64,
1158    tol: Tolerances,
1159) -> OgeomResult<Option<f64>> {
1160    let gap = |s: f64| -> OgeomResult<f64> {
1161        let uv = pcurve.point_at(s, tol)?;
1162        Ok(surface.point_at(uv.x, uv.y, tol)?.distance(p))
1163    };
1164    #[allow(clippy::cast_precision_loss)]
1165    let step = |k: usize| range.0 + (range.1 - range.0) * k as f64 / FIT_SAMPLES as f64;
1166    let mut nearest = (f64::INFINITY, 0);
1167    for k in 0..=FIT_SAMPLES {
1168        let d = gap(step(k))?;
1169        if d < nearest.0 {
1170            nearest = (d, k);
1171        }
1172    }
1173    let (mut lo, mut hi) = (
1174        step(nearest.1.saturating_sub(1)),
1175        step((nearest.1 + 1).min(FIT_SAMPLES)),
1176    );
1177    let golden = (5.0_f64.sqrt() - 1.0) / 2.0;
1178    for _ in 0..100 {
1179        let (a, b) = (hi - golden * (hi - lo), lo + golden * (hi - lo));
1180        if gap(a)? < gap(b)? {
1181            hi = b;
1182        } else {
1183            lo = a;
1184        }
1185    }
1186    let s = f64::midpoint(lo, hi);
1187    Ok((gap(s)? <= reach).then_some(s))
1188}
1189
1190/// The face restated on its sphere charted about `axis`, and the trim
1191/// edges a band's seam cut, where the trim fits that chart crossing its
1192/// seam only at a vertex, or once across each of a band's two loops;
1193/// `None` where it does not, or an image of the trim misses its edge.
1194fn restated(
1195    model: &mut Model,
1196    face: &Shape,
1197    pinned: &Pinned,
1198    axis: Vector,
1199    clearance: &dyn Fn(Point) -> f64,
1200    known: Option<[bool; 2]>,
1201    tol: Tolerances,
1202) -> OgeomResult<Option<(Shape, Vec<Split>)>> {
1203    use core::f64::consts::{FRAC_PI_2, TAU};
1204    use ogeom_algo::Containment;
1205    let (centre, radius) = (pinned.sphere.centre(), pinned.sphere.radius());
1206    let deflection = ogeom_mesh::Deflection::default();
1207    let mut inside = [false; 2];
1208    if let Some(known) = known {
1209        inside = known;
1210    } else {
1211        for (k, sign) in [1.0, -1.0].into_iter().enumerate() {
1212            let pole = centre + axis * (radius * sign);
1213            match ogeom_algo::classify_on_face(model, face, pole, deflection, tol)? {
1214                Containment::In => inside[k] = true,
1215                Containment::Out => {}
1216                Containment::On => return Ok(None),
1217            }
1218        }
1219    }
1220    // A pole inside alone is put north.
1221    let axis = if inside == [false, true] { -axis } else { axis };
1222    let poles_in = inside.iter().filter(|&&i| i).count();
1223    let reference = pinned.sphere.frame();
1224    let Ok(across) = axis
1225        .cross(reference.x().vector())
1226        .normalized(tol)
1227        .or_else(|_| axis.cross(reference.y().vector()).normalized(tol))
1228    else {
1229        return Ok(None);
1230    };
1231    let provisional = Frame::new(
1232        centre,
1233        Direction::new(axis, tol)?,
1234        Direction::new(across, tol)?,
1235        tol,
1236    )?;
1237    let mut points: Vec<Vec<Point>> = Vec::with_capacity(pinned.rings.len());
1238    for ring in &pinned.rings {
1239        points.push(ring_points(model, ring, tol)?);
1240    }
1241    let turns: Vec<i64> = points
1242        .iter()
1243        .map(|r| winding(&longitudes(&provisional, r)))
1244        .collect();
1245    let around: Vec<usize> = (0..turns.len()).filter(|&i| turns[i] != 0).collect();
1246    // The seam's longitude in the provisional chart, and where a pole is
1247    // inside alone, the loop round it and the edge whose start the seam
1248    // leaves the face at.
1249    let (seam_u, through) = if poles_in == 1 {
1250        let [outer] = around.as_slice() else {
1251            return Ok(None);
1252        };
1253        let outer = *outer;
1254        if turns[outer].abs() != 1 {
1255            return Ok(None);
1256        }
1257        let mut found = None;
1258        for k in 0..pinned.rings[outer].len() {
1259            // The loop from this edge's start round to it again crosses
1260            // the meridian there only, and no other loop reaches it.
1261            let mut ring = pinned.rings[outer].clone();
1262            ring.rotate_left(k);
1263            let us = longitudes(&provisional, &ring_points(model, &ring, tol)?);
1264            let Some((&first, rest)) = us.split_first() else {
1265                return Ok(None);
1266            };
1267            let inner = &rest[..rest.len().saturating_sub(1)];
1268            let (lo, hi) = span(inner);
1269            let (below, above) = if turns[outer] > 0 {
1270                (first, first + TAU)
1271            } else {
1272                (first - TAU, first)
1273            };
1274            let alone = lo > below + OFF_SEAM && hi < above - OFF_SEAM;
1275            let clear = alone
1276                && points.iter().enumerate().all(|(i, r)| {
1277                    if i == outer {
1278                        return true;
1279                    }
1280                    let (lo, hi) = span(&longitudes(&provisional, r));
1281                    !reaches(lo, hi, first)
1282                });
1283            if clear {
1284                found = Some((first, Some((outer, k))));
1285                break;
1286            }
1287        }
1288        let Some(found) = found else {
1289            return Ok(None);
1290        };
1291        found
1292    } else if poles_in == 0 && !around.is_empty() {
1293        // A band: the two loops round the axis, the seam on the meridian
1294        // standing furthest from the walls of those that cross each loop
1295        // once and reach no other loop.
1296        let [first, second] = around.as_slice() else {
1297            return Ok(None);
1298        };
1299        if turns[*first].abs() != 1 || turns[*second] != -turns[*first] {
1300            return Ok(None);
1301        }
1302        let carried: Vec<Vec<f64>> = points.iter().map(|r| longitudes(&provisional, r)).collect();
1303        let mut best: Option<(f64, f64)> = None;
1304        for k in 0..BAND_SEAMS {
1305            #[allow(clippy::cast_precision_loss)]
1306            let u = TAU * k as f64 / BAND_SEAMS as f64;
1307            let mut latitudes = Vec::with_capacity(2);
1308            let mut fits = true;
1309            for (i, us) in carried.iter().enumerate() {
1310                if !around.contains(&i) {
1311                    let (lo, hi) = span(us);
1312                    fits &= !reaches(lo, hi, u);
1313                    continue;
1314                }
1315                match crossed_once(us, u) {
1316                    Some(at) => latitudes.push(chart_of(&provisional, points[i][at]).1),
1317                    None => fits = false,
1318                }
1319            }
1320            let [a, b] = latitudes.as_slice() else {
1321                continue;
1322            };
1323            if !fits {
1324                continue;
1325            }
1326            let meridian = across * u.cos() + axis.cross(across) * u.sin();
1327            let score = (0..=BAND_PROBES)
1328                .map(|j| {
1329                    #[allow(clippy::cast_precision_loss)]
1330                    let v = a + (b - a) * j as f64 / BAND_PROBES as f64;
1331                    clearance(centre + (meridian * v.cos() + axis * v.sin()) * radius)
1332                })
1333                .fold(f64::INFINITY, f64::min);
1334            if best.is_none_or(|(s, _)| score > s) {
1335                best = Some((score, u));
1336            }
1337        }
1338        let Some((_, u)) = best else {
1339            return Ok(None);
1340        };
1341        (u, None)
1342    } else {
1343        if !around.is_empty() {
1344            return Ok(None);
1345        }
1346        // The middle of the widest stretch of longitude no loop reaches.
1347        let mut spans: Vec<(f64, f64)> = points
1348            .iter()
1349            .map(|r| {
1350                let (lo, hi) = span(&longitudes(&provisional, r));
1351                let start = lo.rem_euclid(TAU);
1352                (start, start + (hi - lo))
1353            })
1354            .collect();
1355        spans.sort_by(|a, b| a.0.total_cmp(&b.0));
1356        let Some(&(first, _)) = spans.first() else {
1357            return Ok(None);
1358        };
1359        let mut best: Option<(f64, f64)> = None;
1360        let mut reach = f64::NEG_INFINITY;
1361        for &(lo, hi) in &spans {
1362            if lo > reach && reach > f64::NEG_INFINITY && best.is_none_or(|(w, _)| lo - reach > w) {
1363                best = Some((lo - reach, f64::midpoint(lo, reach)));
1364            }
1365            reach = reach.max(hi);
1366        }
1367        let wrap = first + TAU - reach;
1368        if best.is_none_or(|(w, _)| wrap > w) {
1369            best = Some((wrap, f64::midpoint(reach, first + TAU)));
1370        }
1371        let Some((width, middle)) = best else {
1372            return Ok(None);
1373        };
1374        if width <= CLEAR_OF_TRIM {
1375            return Ok(None);
1376        }
1377        (middle, None)
1378    };
1379    let seam_x = across * seam_u.cos() + axis.cross(across) * seam_u.sin();
1380    let chart = Frame::new(
1381        centre,
1382        Direction::new(axis, tol)?,
1383        Direction::new(seam_x, tol)?,
1384        tol,
1385    )?;
1386    let sphere = ogeom_math::Sphere::new(chart, radius, tol)?;
1387    let geometry: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
1388    let surface = model.geometry_mut().add_surface(geometry.clone());
1389
1390    // A band's two loops cut where the seam crosses them, each then
1391    // starting there, with the vertex and its latitude.
1392    let mut rings = pinned.rings.clone();
1393    let mut splits: Vec<Split> = Vec::new();
1394    let mut band_ends: Vec<(usize, Shape, f64)> = Vec::new();
1395    if poles_in == 0 && !around.is_empty() {
1396        let Some(old_surface) = face_surface(model, face) else {
1397            return Ok(None);
1398        };
1399        for &i in &around {
1400            let Some((ring, vertex, cut)) =
1401                started_on_seam(model, &rings[i], &chart, old_surface, tol)?
1402            else {
1403                return Ok(None);
1404            };
1405            let Some(NodeData::Vertex(v)) = model.node(&vertex).map(|n| n.data()) else {
1406                return Ok(None);
1407            };
1408            band_ends.push((i, vertex.clone(), chart_of(&chart, v.point).1));
1409            rings[i] = ring;
1410            splits.extend(cut);
1411        }
1412    }
1413
1414    // Each loop's edges given their image, the longitude carried along the
1415    // loop from where it starts: the vertex on the seam for a loop round
1416    // the axis, else wherever puts the whole loop inside one turn.
1417    let mut wires: Vec<(Vec<Shape>, f64)> = Vec::new();
1418    let mut seam_ends: Option<(f64, f64)> = None;
1419    for (i, ring) in rings.iter().enumerate() {
1420        let mut ring = ring.clone();
1421        if let Some((outer, k)) = through
1422            && outer == i
1423        {
1424            ring.rotate_left(k);
1425        }
1426        let mut samples: Vec<Vec<(f64, Point)>> = Vec::with_capacity(ring.len());
1427        for edge in &ring {
1428            samples.push(edge_samples(model, edge, FIT_SAMPLES, tol)?);
1429        }
1430        let flat: Vec<Point> = samples.iter().flatten().map(|(_, p)| *p).collect();
1431        let us = longitudes(&chart, &flat);
1432        let (Some(&first), Some(&last)) = (us.first(), us.last()) else {
1433            return Ok(None);
1434        };
1435        let turn = winding(&us);
1436        let shift = match turn {
1437            0 => -TAU * (span(&us).0 / TAU).floor(),
1438            1 => -first,
1439            -1 => TAU - first,
1440            _ => return Ok(None),
1441        };
1442        if turn != 0 {
1443            seam_ends = Some((first + shift, last + shift));
1444        }
1445        let mut area = 0.0;
1446        let mut at = 0;
1447        for (edge, along) in ring.iter().zip(&samples) {
1448            let mut params = Vec::with_capacity(along.len());
1449            let mut image_points = Vec::with_capacity(along.len());
1450            for (j, &(t, p)) in along.iter().enumerate() {
1451                params.push(t);
1452                image_points.push(Point2::new(us[at + j] + shift, chart_of(&chart, p).1));
1453            }
1454            at += along.len();
1455            for w in image_points.windows(2) {
1456                area += (w[0].x * w[1].y - w[1].x * w[0].y) / 2.0;
1457            }
1458            if edge.orientation() == Orientation::Reversed {
1459                params.reverse();
1460                image_points.reverse();
1461            }
1462            let (Some(&lo), Some(&hi)) = (params.first(), params.last()) else {
1463                return Ok(None);
1464            };
1465            let fitted =
1466                ogeom_geom::fit::fit_points_2d_at(&params, &image_points, 3, FIT_IN_CHART, tol)?;
1467            let image: PlanarCurve = fitted.curve.into();
1468            // The image is honest in space between its samples too.
1469            let Some(NodeData::Edge(e)) = model.node(edge).map(|n| n.data()) else {
1470                return Ok(None);
1471            };
1472            let Some(EdgeRepr::Curve3d { curve, .. }) = e.curve3d() else {
1473                return Ok(None);
1474            };
1475            let Some(curve) = model.geometry().curve(*curve) else {
1476                return Ok(None);
1477            };
1478            let budget = e.tolerance.get().max(tol.confusion()) * 0.1;
1479            for pair in params.windows(2) {
1480                let t = f64::midpoint(pair[0], pair[1]);
1481                let uv = image.point_at(t, tol)?;
1482                let Ok(on) = geometry.point_at(uv.x, uv.y, tol) else {
1483                    return Ok(None);
1484                };
1485                if on.distance(curve.point_at(t, tol)?) > budget {
1486                    return Ok(None);
1487                }
1488            }
1489            ogeom_algo::attach_pcurve(
1490                model,
1491                edge,
1492                image,
1493                surface,
1494                ogeom_topo::Location::identity(),
1495                (lo, hi),
1496            )?;
1497        }
1498        wires.push((ring, area));
1499    }
1500
1501    // The seam and pole edges the new chart needs, and the outer loop
1502    // first.
1503    let (outer, holes): (Vec<Shape>, Vec<Vec<Shape>>) = match (poles_in, through) {
1504        (0, None) if !band_ends.is_empty() => {
1505            let [low, high] = band_ends.as_slice() else {
1506                return Ok(None);
1507            };
1508            let (low, high) = if low.2 <= high.2 {
1509                (low, high)
1510            } else {
1511                (high, low)
1512            };
1513            // Up the seam where the lower loop ends, down where it starts.
1514            let sides = if turns[low.0] > 0 {
1515                (TAU, 0.0)
1516            } else {
1517                (0.0, TAU)
1518            };
1519            let seam = seam_between(
1520                model,
1521                surface,
1522                &chart,
1523                radius,
1524                (&low.1, low.2),
1525                (&high.1, high.2),
1526                sides,
1527                tol,
1528            )?;
1529            let mut edges = wires[low.0].0.clone();
1530            edges.push(seam.clone());
1531            edges.extend(wires[high.0].0.iter().cloned());
1532            edges.push(seam.reversed());
1533            let holes = wires
1534                .into_iter()
1535                .enumerate()
1536                .filter(|(i, _)| *i != low.0 && *i != high.0)
1537                .map(|(_, (w, _))| w)
1538                .collect();
1539            (edges, holes)
1540        }
1541        (1, Some((ring_index, _))) => {
1542            let Some((u_start, u_end)) = seam_ends else {
1543                return Ok(None);
1544            };
1545            let ring = wires[ring_index].0.clone();
1546            let Some((start, _)) = ogeom_algo::edge_vertices(model, &ring[0])? else {
1547                return Ok(None);
1548            };
1549            let Some(NodeData::Vertex(v)) = model.node(&start).map(|n| n.data()) else {
1550                return Ok(None);
1551            };
1552            let low = chart_of(&chart, v.point).1;
1553            let north = model.add_vertex(ogeom_topo::VertexData::new(centre + axis * radius));
1554            let seam = seam_between(
1555                model,
1556                surface,
1557                &chart,
1558                radius,
1559                (&start, low),
1560                (&north, FRAC_PI_2),
1561                (u_end, u_start),
1562                tol,
1563            )?;
1564            let top = pole_at(model, surface, &north, FRAC_PI_2, tol)?;
1565            let mut edges = ring;
1566            edges.push(seam.clone());
1567            // Across the top from where the loop ended back to where it
1568            // started.
1569            edges.push(if u_end > u_start { top.reversed() } else { top });
1570            edges.push(seam.reversed());
1571            let holes = wires
1572                .into_iter()
1573                .enumerate()
1574                .filter(|(i, _)| *i != ring_index)
1575                .map(|(_, (w, _))| w)
1576                .collect();
1577            (edges, holes)
1578        }
1579        (2, None) => {
1580            let Some(hole_area) = wires.first().map(|(_, a)| *a) else {
1581                return Ok(None);
1582            };
1583            // Counter-clockwise round the chart where the holes run the
1584            // other way.
1585            let ccw = hole_area < 0.0;
1586            let south = model.add_vertex(ogeom_topo::VertexData::new(centre - axis * radius));
1587            let north = model.add_vertex(ogeom_topo::VertexData::new(centre + axis * radius));
1588            let sides = if ccw { (TAU, 0.0) } else { (0.0, TAU) };
1589            let seam = seam_between(
1590                model,
1591                surface,
1592                &chart,
1593                radius,
1594                (&south, -FRAC_PI_2),
1595                (&north, FRAC_PI_2),
1596                sides,
1597                tol,
1598            )?;
1599            let top = pole_at(model, surface, &north, FRAC_PI_2, tol)?;
1600            let bottom = pole_at(model, surface, &south, -FRAC_PI_2, tol)?;
1601            let edges = if ccw {
1602                vec![bottom, seam.clone(), top.reversed(), seam.reversed()]
1603            } else {
1604                vec![seam.clone(), top, seam.reversed(), bottom.reversed()]
1605            };
1606            (edges, wires.into_iter().map(|(w, _)| w).collect())
1607        }
1608        (0, None) => {
1609            let Some(widest) =
1610                (0..wires.len()).max_by(|&a, &b| wires[a].1.abs().total_cmp(&wires[b].1.abs()))
1611            else {
1612                return Ok(None);
1613            };
1614            let mut outer = Vec::new();
1615            let mut holes = Vec::new();
1616            for (i, (w, _)) in wires.into_iter().enumerate() {
1617                if i == widest {
1618                    outer = w;
1619                } else {
1620                    holes.push(w);
1621                }
1622            }
1623            (outer, holes)
1624        }
1625        _ => return Ok(None),
1626    };
1627    let mut made_wires = vec![ogeom_algo::make_wire(model, &outer, tol)?.shape];
1628    for hole in &holes {
1629        made_wires.push(ogeom_algo::make_wire(model, hole, tol)?.shape);
1630    }
1631    let Some(NodeData::Face(old)) = model.node(face).map(|n| n.data()) else {
1632        return Ok(None);
1633    };
1634    let held = old.tolerance;
1635    let data = ogeom_topo::FaceData::new(surface, ogeom_topo::Location::identity());
1636    let made = model.add_face(data, &made_wires)?;
1637    model.widen(&made, held)?;
1638    Ok(Some((made.oriented(face.orientation()), splits)))
1639}
1640
1641/// A pole at `at`, latitude `v` of a sphere chart: an edge with no length,
1642/// its image across the chart from longitude 0 to a whole turn.
1643fn pole_at(
1644    model: &mut Model,
1645    surface: ogeom_topo::SurfaceId,
1646    at: &Shape,
1647    v: f64,
1648    tol: Tolerances,
1649) -> OgeomResult<Shape> {
1650    let mut data = ogeom_topo::EdgeData::new();
1651    data.degenerate = true;
1652    let edge = model.add_edge(data, &[at.clone(), at.clone()])?;
1653    let (from, to) = (Point2::new(0.0, v), Point2::new(core::f64::consts::TAU, v));
1654    ogeom_algo::attach_pcurve(
1655        model,
1656        &edge,
1657        ogeom_geom::Line2d::segment(from, to, tol)?.into(),
1658        surface,
1659        ogeom_topo::Location::identity(),
1660        (0.0, from.distance(to)),
1661    )?;
1662    Ok(edge)
1663}
1664
1665/// The seam of a sphere chart about `chart` from `low` up to `high` (each
1666/// a vertex and its latitude), its image up the side of the chart at
1667/// longitude `sides.0` where the edge runs forward and at `sides.1` where
1668/// it runs back.
1669#[allow(clippy::too_many_arguments)]
1670fn seam_between(
1671    model: &mut Model,
1672    surface: ogeom_topo::SurfaceId,
1673    chart: &Frame,
1674    radius: f64,
1675    low: (&Shape, f64),
1676    high: (&Shape, f64),
1677    sides: (f64, f64),
1678    tol: Tolerances,
1679) -> OgeomResult<Shape> {
1680    let ((from, bottom), (to, top)) = (low, high);
1681    // Its plane is spanned by the chart's x and axis, so the circle's
1682    // normal is -y and its angle is the latitude.
1683    let meridian = ogeom_math::Circle::new(
1684        Frame::new(chart.origin(), -chart.y(), chart.x(), tol)?,
1685        radius,
1686        tol,
1687    )?;
1688    let seam = ogeom_algo::make_edge_between(
1689        model,
1690        ogeom_geom::CircleCurve::new(meridian).into(),
1691        (bottom, top),
1692        from,
1693        to,
1694        tol,
1695    )?
1696    .shape;
1697    let side = |u: f64| -> OgeomResult<PlanarCurve> {
1698        Ok(ogeom_geom::Line2d::segment(Point2::new(u, bottom), Point2::new(u, top), tol)?.into())
1699    };
1700    ogeom_algo::attach_seam(
1701        model,
1702        &seam,
1703        side(sides.0)?,
1704        side(sides.1)?,
1705        surface,
1706        ogeom_topo::Location::identity(),
1707        (0.0, top - bottom),
1708    )?;
1709    Ok(seam)
1710}
1711
1712/// How near, in radians round the sphere's centre, a sphere face's trim
1713/// may come to a pole of its chart, which it does not run through, before
1714/// the face is re-charted. Beside a pole the trim's image sweeps half a
1715/// turn of longitude within twice its distance from the pole, and no
1716/// fitted image follows it there.
1717pub const POLE_MARGIN: f64 = 0.05;
1718
1719/// What [`rechart_clear_of_poles`] did with a face.
1720#[derive(Debug, Clone, PartialEq)]
1721pub enum Recharted {
1722    /// The face is no sphere face, or its trim keeps clear of its chart's
1723    /// poles, or runs through them: left as it is.
1724    Untouched,
1725    /// The face now lies on its sphere charted about another axis, whose
1726    /// poles stand clear of its trim.
1727    Restated,
1728    /// The trim comes within [`POLE_MARGIN`] of a pole and no chart about
1729    /// another axis clears it: why, by name.
1730    Refused(String),
1731}
1732
1733/// Re-chart `face` in place where it is a sphere face whose trim comes
1734/// within [`POLE_MARGIN`] of a pole of its chart without running through
1735/// it: the same sphere charted about the axis, among a ball's candidate
1736/// axes, whose poles stand furthest from the trim, its trim edges given
1737/// images on that chart, and the seam and pole edges it needs. The surface,
1738/// the edges and their curves are unchanged; only the chart and the
1739/// pcurves are. A face whose trim runs through a pole (a meridian rim) or
1740/// keeps clear of both is left untouched, read and not changed.
1741///
1742/// # Errors
1743///
1744/// Whatever reading or building the face reports.
1745pub fn rechart_clear_of_poles(
1746    model: &mut Model,
1747    face: &Shape,
1748    tol: Tolerances,
1749) -> OgeomResult<Recharted> {
1750    let bare = face
1751        .located(ogeom_topo::Location::identity())
1752        .oriented(Orientation::Forward);
1753    let Some(NodeData::Face(data)) = model.node(&bare).map(|n| n.data()) else {
1754        return Ok(Recharted::Untouched);
1755    };
1756    if !matches!(
1757        model.geometry().surface(data.surface),
1758        Some(SurfaceGeometry::Sphere(_))
1759    ) {
1760        return Ok(Recharted::Untouched);
1761    }
1762    let Some(pinned) = pinned_sphere_with(model, &bare, false)? else {
1763        return Ok(Recharted::Untouched);
1764    };
1765    if pinned.sphere.frame().handedness() != ogeom_math::Handedness::Right {
1766        return Ok(Recharted::Untouched);
1767    }
1768    let (centre, radius) = (pinned.sphere.centre(), pinned.sphere.radius());
1769    let mut trim: Vec<Point> = Vec::new();
1770    let mut corners: Vec<Point> = Vec::new();
1771    for ring in &pinned.rings {
1772        trim.extend(ring_points(model, ring, tol)?);
1773        for edge in ring {
1774            if let Some((start, _)) = ogeom_algo::edge_vertices(model, edge)?
1775                && let Some(NodeData::Vertex(v)) = model.node(&start).map(|n| n.data())
1776            {
1777                corners.push(v.point);
1778            }
1779        }
1780    }
1781    // The angle round the centre from `p` to the pole along `axis`.
1782    let angle = |p: Point, axis: Vector| -> f64 {
1783        let d = p - centre;
1784        let length = d.magnitude();
1785        if length <= 0.0 {
1786            return 0.0;
1787        }
1788        (d.dot(axis) / length).clamp(-1.0, 1.0).acos()
1789    };
1790    let nearest = |axis: Vector| -> f64 {
1791        trim.iter()
1792            .map(|&p| angle(p, axis).min(angle(p, -axis)))
1793            .fold(f64::INFINITY, f64::min)
1794    };
1795    // A pole the trim runs through (a pole edge, or a corner on it) is the
1796    // chart's to cut there, and is not read as one the trim passes beside.
1797    let reach = tol.confusion().max(data.tolerance.get());
1798    let z = pinned.sphere.frame().z().vector();
1799    let mut beside = f64::INFINITY;
1800    for pole_axis in [z, -z] {
1801        let pole = centre + pole_axis * radius;
1802        let through = pinned.poles.iter().any(|p| p.distance(pole) <= reach)
1803            || corners.iter().any(|p| p.distance(pole) <= reach);
1804        if through {
1805            continue;
1806        }
1807        let near = trim
1808            .iter()
1809            .map(|&p| angle(p, pole_axis))
1810            .fold(f64::INFINITY, f64::min);
1811        beside = beside.min(near);
1812    }
1813    if beside > POLE_MARGIN {
1814        return Ok(Recharted::Untouched);
1815    }
1816    let frame = pinned.sphere.frame();
1817    let mut scored: Vec<(f64, Vector)> = Vec::new();
1818    for candidate in candidate_axes(frame.x().vector(), z) {
1819        let Ok(axis) = candidate.normalized(tol) else {
1820            continue;
1821        };
1822        let score = nearest(axis);
1823        if score > CLEAR_OF_TRIM {
1824            scored.push((score, axis));
1825        }
1826    }
1827    scored.sort_by(|a, b| b.0.total_cmp(&a.0));
1828    let near_trim = |p: Point| -> f64 {
1829        trim.iter()
1830            .map(|q| q.distance(p))
1831            .fold(f64::INFINITY, f64::min)
1832    };
1833    let mut ring_samples: Vec<Vec<Point>> = Vec::with_capacity(pinned.rings.len());
1834    for ring in &pinned.rings {
1835        ring_samples.push(ring_points(model, ring, tol)?);
1836    }
1837    for (_, axis) in scored {
1838        let Some(known) = poles_inside(centre, axis, frame.x().vector(), &ring_samples, tol) else {
1839            continue;
1840        };
1841        let Some((made, cut)) =
1842            restated(model, &bare, &pinned, axis, &near_trim, Some(known), tol)?
1843        else {
1844            continue;
1845        };
1846        // Cutting the trim edges another face holds is a change to that
1847        // face too; in place, only a face whose own boundary takes the new
1848        // chart's seam and poles is restated.
1849        if !cut.is_empty() {
1850            continue;
1851        }
1852        let Some(NodeData::Face(new)) = model.node(&made).map(|n| n.data()) else {
1853            continue;
1854        };
1855        let surface = new.surface;
1856        let wires = model.children_of(&made.oriented(Orientation::Forward))?;
1857        model.rechart_face(&bare, surface, &wires)?;
1858        return Ok(Recharted::Restated);
1859    }
1860    Ok(Recharted::Refused(format!(
1861        "a sphere face's trim passes {beside:.3e} rad from a pole of its chart, and no chart \
1862         of its sphere about another axis keeps the trim {CLEAR_OF_TRIM} rad clear of its \
1863         poles; see docs/CAPABILITIES.md, bool.booleans"
1864    )))
1865}
1866
1867/// Which poles of the sphere about `centre` charted about `axis` lie
1868/// inside a face whose loops, as the face runs them, pass through `rings`:
1869/// the north pole first. Read off the loops alone, never off the face's
1870/// own chart, which beside its old poles is the thing in doubt.
1871///
1872/// A face keeps its interior on the left of each loop about its outward
1873/// normal. A loop winding once round the axis counter-clockwise, seen from
1874/// outside, keeps the north pole on its left and the south pole on its
1875/// right; winding clockwise, the other way about. A loop winding no turn
1876/// keeps both poles on its left where it runs clockwise in the chart (a
1877/// hole) and neither where it runs counter-clockwise (round a patch of its
1878/// own). A pole is inside where every loop keeps it on its left. `None`
1879/// for a loop winding more than once.
1880fn poles_inside(
1881    centre: Point,
1882    axis: Vector,
1883    reference: Vector,
1884    rings: &[Vec<Point>],
1885    tol: Tolerances,
1886) -> Option<[bool; 2]> {
1887    let across = axis
1888        .cross(reference)
1889        .normalized(tol)
1890        .or_else(|_| {
1891            axis.cross(axis.cross(reference) + reference)
1892                .normalized(tol)
1893        })
1894        .ok()?;
1895    let frame = Frame::new(
1896        centre,
1897        Direction::new(axis, tol).ok()?,
1898        Direction::new(across, tol).ok()?,
1899        tol,
1900    )
1901    .ok()?;
1902    let mut inside = [true, true];
1903    for ring in rings {
1904        let us = longitudes(&frame, ring);
1905        match winding(&us) {
1906            1 => inside[1] = false,
1907            -1 => inside[0] = false,
1908            0 => {
1909                let vs: Vec<f64> = ring.iter().map(|&p| chart_of(&frame, p).1).collect();
1910                let n = us.len();
1911                let area: f64 = (0..n)
1912                    .map(|i| {
1913                        let j = (i + 1) % n;
1914                        us[i] * vs[j] - us[j] * vs[i]
1915                    })
1916                    .sum();
1917                if area > 0.0 {
1918                    inside = [false, false];
1919                }
1920            }
1921            _ => return None,
1922        }
1923    }
1924    Some(inside)
1925}