Skip to main content

ogeom_algo/
pole.rs

1//! Degenerate edges restored where a face's boundary crosses a pole.
2//!
3//! Where [`closed_at_poles`](crate::closed_at_poles) lays a straight run
4//! across every pole of a wire whose other joints all meet, this solves a
5//! wire whose joints also jump whole periods, and says which way round
6//! each pole is crossed.
7//!
8//! Where a surface collapses a whole row of its chart to one point (a
9//! sphere's pole, a cone's apex), a boundary through that point arrives at
10//! the row at one parameter and leaves it at another. In space the two are
11//! the same place; in the chart they are a stretch of the row apart, and
12//! the edge that spans it is a degenerate one: no curve in space, a pcurve
13//! along the row. Without it the stretch is left to whoever reads the
14//! chart, and on a row that repeats it is ambiguous: the boundary of a
15//! sphere walking its seam up and down crosses each pole by a whole turn,
16//! which a reader taking the shortest way across reads as no turn at all,
17//! and the face meshes to nothing. `check` passes such a boundary, since
18//! its two ends meet on the surface.
19
20use ogeom_core::{OgeomResult, Tolerances};
21use ogeom_geom::{Curve2d as _, Line2d, Surface as _, SurfaceGeometry};
22use ogeom_math::{Point2, Vector2};
23use ogeom_topo::{EdgeData, EdgeRepr, Location, Model, Shape};
24
25use crate::build::edge_vertices;
26
27/// The most joints at poles one wire is solved for; each has three
28/// candidate turns to try.
29const MOST_POLES: usize = 4;
30
31/// The wires of `face` whose crossings of poles a reader of the chart
32/// takes the wrong way, each paired with a rebuilt wire holding a
33/// degenerate edge across every pole it crosses; empty where none is.
34///
35/// A joint is at a pole where the end of one edge's pcurve and the start
36/// of the next's stand apart in the chart along one parameter direction,
37/// and the whole row of the chart through them, sampled across the
38/// surface's domain, lifts onto the joint vertex within its tolerance: the
39/// surface collapses that row. Every other joint must meet, or stand a
40/// whole number of periods apart where the chart repeats (a seam's two
41/// columns); a wire with any other gap is left as it is.
42///
43/// Across a repeating row each crossing may run the shortest way or a
44/// whole turn either way more. The wire is solved for the one choice
45/// under which its chart, unrolled, closes on itself and encloses area
46/// the right way round: positive for a face's first wire, negative for
47/// its holes, read with the face stored forward. Where exactly one choice
48/// does and it is not the shortest way at every pole, each pole gets a
49/// degenerate edge spanning its crossing; where the shortest way is the
50/// answer, a reader of the chart already reads it, and nothing is made.
51/// Only unplaced pcurves and vertices are read; a placed boundary is left
52/// as it is. The wires are made with [`Model::add_wire`], the face itself
53/// untouched: the caller puts them in.
54///
55/// # Errors
56///
57/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the face,
58/// its surface, an edge or a pcurve is not in `model`.
59pub fn pole_crossings(
60    model: &mut Model,
61    face: &Shape,
62    tol: Tolerances,
63) -> OgeomResult<Vec<(Shape, Shape)>> {
64    let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
65        ogeom_core::ogeom_bail!(Dangling, "face is not in this model");
66    };
67    let surface_id = data.surface;
68    let Some(surface) = model.geometry().surface(surface_id).cloned() else {
69        ogeom_core::ogeom_bail!(Dangling, "face names a surface not in this model");
70    };
71    let mut rebuilt = Vec::new();
72    let stored = face.oriented(ogeom_topo::Orientation::Forward);
73    for (w, wire) in model.ordered_children_of(&stored)?.into_iter().enumerate() {
74        let edges = model.ordered_children_of(&wire)?;
75        let Some(spans) = pole_spans(model, &surface, surface_id, &edges, w == 0, tol)? else {
76            continue;
77        };
78        let mut ring = Vec::with_capacity(edges.len() + spans.len());
79        let mut spans = spans.into_iter().peekable();
80        for (i, edge) in edges.iter().enumerate() {
81            ring.push(edge.clone());
82            if let Some(pole) = spans.next_if(|pole| pole.after == i) {
83                ring.push(degenerate_edge(model, &pole, surface_id, tol)?);
84            }
85        }
86        let new = model.add_wire(&ring)?;
87        rebuilt.push((wire, new));
88    }
89    Ok(rebuilt)
90}
91
92/// A degenerate edge to make: after which edge of the wire, at which
93/// vertex, and its pcurve's ends.
94struct Pole {
95    after: usize,
96    vertex: Shape,
97    from: Point2,
98    to: Point2,
99}
100
101/// One edge as the wire walks it in the chart: its points in order, first
102/// and last its ends.
103type Walked = Vec<Point2>;
104
105/// The degenerate edges `edges` (a wire, `outer` where it is a face's
106/// first) needs at its poles, or `None` where it needs none or cannot be
107/// solved.
108fn pole_spans(
109    model: &Model,
110    surface: &SurfaceGeometry,
111    surface_id: ogeom_topo::SurfaceId,
112    edges: &[Shape],
113    outer: bool,
114    tol: Tolerances,
115) -> OgeomResult<Option<Vec<Pole>>> {
116    let n = edges.len();
117    let mut walked: Vec<Walked> = Vec::with_capacity(n);
118    for edge in edges {
119        let Some(points) = walk(model, surface_id, edge, tol)? else {
120            return Ok(None);
121        };
122        walked.push(points);
123    }
124    let ((u0, u1), (v0, v1)) = surface.domain();
125    let periods = [
126        (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(u1 - u0),
127        (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(v1 - v0),
128    ];
129    // Each joint: met, at a pole, or a whole number of periods apart.
130    let mut shifts = vec![Vector2::new(0.0, 0.0); n];
131    let mut poles: Vec<(usize, Shape, usize, f64)> = Vec::new();
132    for i in 0..n {
133        let j = (i + 1) % n;
134        let (end, start) = (walked[i][walked[i].len() - 1], walked[j][0]);
135        let gap = start - end;
136        if gap.x.abs() <= tol.parametric() && gap.y.abs() <= tol.parametric() {
137            continue;
138        }
139        let axis = if gap.y.abs() <= tol.parametric() {
140            Some(0)
141        } else if gap.x.abs() <= tol.parametric() {
142            Some(1)
143        } else {
144            None
145        };
146        if let Some(axis) = axis
147            && let Some(vertex) =
148                collapsed_row(model, surface, &edges[i], &edges[j], end, axis, tol)?
149        {
150            poles.push((i, vertex, axis, if axis == 0 { gap.x } else { gap.y }));
151            continue;
152        }
153        let whole = |value: f64, period: Option<f64>| {
154            value.abs() <= tol.parametric()
155                || period
156                    .is_some_and(|p| (value - (value / p).round() * p).abs() <= tol.parametric())
157        };
158        if whole(gap.x, periods[0]) && whole(gap.y, periods[1]) {
159            shifts[i] = gap;
160            continue;
161        }
162        return Ok(None);
163    }
164    if poles.is_empty() || poles.len() > MOST_POLES {
165        return Ok(None);
166    }
167    // Each pole's crossing: the shortest way, or a turn either way more
168    // where its row repeats.
169    let shortest: Vec<f64> = poles
170        .iter()
171        .map(|(_, _, axis, gap)| periods[*axis].map_or(*gap, |p| gap - (gap / p).round() * p))
172        .collect();
173    let turns: Vec<&[i32]> = poles
174        .iter()
175        .map(|(_, _, axis, _)| -> &[i32] {
176            if periods[*axis].is_some() {
177                &[-1, 0, 1]
178            } else {
179                &[0]
180            }
181        })
182        .collect();
183    let mut solutions: Vec<Vec<f64>> = Vec::new();
184    let mut choice = vec![0usize; poles.len()];
185    loop {
186        let crossing: Vec<f64> = (0..poles.len())
187            .map(|k| {
188                let p = periods[poles[k].2].unwrap_or(0.0);
189                shortest[k] + f64::from(turns[k][choice[k]]) * p
190            })
191            .collect();
192        if let Some(area) = unrolled_area(&walked, &shifts, &poles, &crossing, tol)
193            && (if outer { area } else { -area }) > tol.parametric() * tol.parametric()
194        {
195            solutions.push(crossing);
196        }
197        // The next choice, odometer fashion.
198        let mut k = 0;
199        loop {
200            if k == poles.len() {
201                break;
202            }
203            choice[k] += 1;
204            if choice[k] < turns[k].len() {
205                break;
206            }
207            choice[k] = 0;
208            k += 1;
209        }
210        if k == poles.len() {
211            break;
212        }
213    }
214    let [crossing] = &solutions[..] else {
215        return Ok(None);
216    };
217    if crossing
218        .iter()
219        .zip(&shortest)
220        .all(|(c, s)| (c - s).abs() <= tol.parametric())
221    {
222        return Ok(None);
223    }
224    Ok(Some(
225        poles
226            .into_iter()
227            .zip(crossing)
228            .map(|((i, vertex, axis, _), span)| {
229                let from = walked[i][walked[i].len() - 1];
230                let step = if axis == 0 {
231                    Vector2::new(*span, 0.0)
232                } else {
233                    Vector2::new(0.0, *span)
234                };
235                Pole {
236                    after: i,
237                    vertex,
238                    from,
239                    to: from + step,
240                }
241            })
242            .collect(),
243    ))
244}
245
246/// The signed area the wire encloses in its chart unrolled: each edge
247/// placed where the one before it, and the joint between, leave off,
248/// whole-period joints closed and each pole crossed by its `crossing`.
249/// `None` where the unrolled wire does not come back to where it began.
250fn unrolled_area(
251    walked: &[Walked],
252    shifts: &[Vector2],
253    poles: &[(usize, Shape, usize, f64)],
254    crossing: &[f64],
255    tol: Tolerances,
256) -> Option<f64> {
257    let mut offset = Vector2::new(0.0, 0.0);
258    let mut path: Vec<Point2> = Vec::new();
259    for (i, points) in walked.iter().enumerate() {
260        path.extend(points.iter().map(|p| *p + offset));
261        // Where the next edge starts, unrolled, against where it is stored.
262        let end = points[points.len() - 1] + offset;
263        let stored_next = walked[(i + 1) % walked.len()][0];
264        let next = match poles.iter().position(|(at, ..)| *at == i) {
265            Some(k) => {
266                let step = if poles[k].2 == 0 {
267                    Vector2::new(crossing[k], 0.0)
268                } else {
269                    Vector2::new(0.0, crossing[k])
270                };
271                end + step
272            }
273            None => end + (stored_next - points[points.len() - 1] - shifts[i]),
274        };
275        offset = next - stored_next;
276    }
277    if offset.x.abs() > tol.parametric() || offset.y.abs() > tol.parametric() {
278        return None;
279    }
280    let mut twice = 0.0;
281    for k in 0..path.len() {
282        let (a, b) = (path[k], path[(k + 1) % path.len()]);
283        twice += a.x.mul_add(b.y, -(b.x * a.y));
284    }
285    Some(twice / 2.0)
286}
287
288/// The vertex `edge` and `next` meet at, where the whole row of the chart
289/// through `at` along `axis` lifts onto it within its tolerance.
290fn collapsed_row(
291    model: &Model,
292    surface: &SurfaceGeometry,
293    edge: &Shape,
294    next: &Shape,
295    at: Point2,
296    axis: usize,
297    tol: Tolerances,
298) -> OgeomResult<Option<Shape>> {
299    /// Samples across the row.
300    const PIECES: u32 = 16;
301    let (Some((_, end)), Some((start, _))) =
302        (edge_vertices(model, edge)?, edge_vertices(model, next)?)
303    else {
304        return Ok(None);
305    };
306    if !end.is_same(&start) || !end.location().is_identity() {
307        return Ok(None);
308    }
309    let Some(vertex) = model.node(&end).and_then(|n| n.data().as_vertex()) else {
310        return Ok(None);
311    };
312    let reach = vertex.tolerance.get().max(tol.confusion());
313    let ((u0, u1), (v0, v1)) = surface.domain();
314    let (lo, hi) = if axis == 0 { (u0, u1) } else { (v0, v1) };
315    if !(lo.is_finite() && hi.is_finite()) {
316        return Ok(None);
317    }
318    for k in 0..=PIECES {
319        let t = (hi - lo).mul_add(f64::from(k) / f64::from(PIECES), lo);
320        let uv = if axis == 0 {
321            Point2::new(t, at.y)
322        } else {
323            Point2::new(at.x, t)
324        };
325        let Ok(lifted) = surface.point_at(uv.x, uv.y, tol) else {
326            return Ok(None);
327        };
328        if lifted.distance(vertex.point) > reach {
329            return Ok(None);
330        }
331    }
332    Ok(Some(end))
333}
334
335/// `edge`'s pcurve on the surface as the wire walks it, sampled: a seam's
336/// side by the occurrence's orientation. `None` for an edge with no
337/// unplaced pcurve there.
338fn walk(
339    model: &Model,
340    surface_id: ogeom_topo::SurfaceId,
341    edge: &Shape,
342    tol: Tolerances,
343) -> OgeomResult<Option<Walked>> {
344    /// Samples along each edge, for the area its wire encloses.
345    const PIECES: u32 = 16;
346    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
347        return Ok(None);
348    };
349    let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
350    let (id, range) = match data.pcurve_for(surface_id, edge.location()) {
351        Some(EdgeRepr::PCurve {
352            curve,
353            range,
354            location,
355            ..
356        }) if location.is_identity() => (*curve, *range),
357        Some(EdgeRepr::Seam {
358            forward,
359            reversed: back,
360            range,
361            location,
362            ..
363        }) if location.is_identity() => (if reversed { *back } else { *forward }, *range),
364        _ => return Ok(None),
365    };
366    let Some(pcurve) = model.geometry().pcurve(id) else {
367        return Ok(None);
368    };
369    let mut points = Vec::with_capacity(PIECES as usize + 1);
370    for k in 0..=PIECES {
371        let f = f64::from(k) / f64::from(PIECES);
372        let f = if reversed { 1.0 - f } else { f };
373        points.push(pcurve.point_at((range.1 - range.0).mul_add(f, range.0), tol)?);
374    }
375    Ok(Some(points))
376}
377
378/// A degenerate edge at `pole`'s vertex, its pcurve the chart segment it
379/// spans.
380fn degenerate_edge(
381    model: &mut Model,
382    pole: &Pole,
383    surface: ogeom_topo::SurfaceId,
384    tol: Tolerances,
385) -> OgeomResult<Shape> {
386    let mut data = EdgeData::new();
387    data.degenerate = true;
388    let edge = model.add_edge(data, &[pole.vertex.clone(), pole.vertex.clone()])?;
389    crate::build::attach_pcurve(
390        model,
391        &edge,
392        Line2d::segment(pole.from, pole.to, tol)?.into(),
393        surface,
394        Location::identity(),
395        (0.0, pole.from.distance(pole.to)),
396    )?;
397    Ok(edge)
398}