Skip to main content

ogeom_offset/
project.rs

1//! Normal projection: a wire dropped onto a shape along its faces' normals.
2//!
3//! The projection of a point onto a surface is its foot (the point where
4//! the displacement is perpendicular to both tangents), and the projection
5//! of a curve is the curve through the feet. That curve almost never has a
6//! closed form, so it is sampled, fitted to a stated tolerance, and carries
7//! the pcurve fitted *with* it: same parameter in space and in the chart,
8//! which is what makes the result an edge a face can be split along.
9//!
10//! Which face a point lands on is decided by measurement, not by order:
11//! every face is asked, the nearest foot inside a face's own trim wins, and
12//! a sample no face claims ends the run it was in. So a wire projected onto
13//! a solid comes back as one edge per stretch that actually landed, and the
14//! stretches that fell off the shape are simply absent.
15
16use ogeom_algo::{Built, History};
17use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
18use ogeom_geom::{
19    Curve, Curve3d as _, PlanarCurve, Surface as _, SurfaceGeometry, Transformable as _,
20};
21use ogeom_math::{Point, Point2};
22use ogeom_topo::{EdgeRepr, Model, NodeData, Shape, ShapeType, SurfaceId, explore_unique};
23
24/// One projected stretch: the edge that was built, and the face it lies on.
25#[derive(Debug, Clone)]
26pub struct Projected {
27    /// The edge, with its pcurve attached on the face's surface.
28    pub edge: Shape,
29    /// The face it landed on.
30    pub face: Shape,
31    /// How far the fitted curve and its pcurve may sit from the feet they
32    /// trace and from each other, measured between the stations as well as
33    /// at them; the edge carries it as its tolerance.
34    pub tolerance: f64,
35}
36
37/// Project every edge of `wire` onto the faces of `target`.
38///
39/// `stations` is how finely each edge is sampled to start with; the fit
40/// is held to `tolerance` against the feet at those samples and between
41/// them, the sampling refined where it misses, and states what it reached
42/// where it cannot. Both are the caller's, because both are the answer's
43/// accuracy and this cannot guess what it is for.
44///
45/// # Errors
46///
47/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
48/// `stations` is under four or `tolerance` is not a usable distance, and
49/// whatever the fit refuses.
50pub fn normal_projection(
51    model: &mut Model,
52    target: &Shape,
53    wire: &Shape,
54    stations: usize,
55    tolerance: f64,
56    tol: Tolerances,
57) -> OgeomResult<(Vec<Projected>, Built)> {
58    if stations < 4 {
59        ogeom_bail!(
60            Construction,
61            "a projection sampled at {stations} stations is a guess"
62        );
63    }
64    if !tolerance.is_finite() || tolerance <= 0.0 {
65        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
66    }
67
68    // The faces to land on, with their surfaces in world space and their
69    // trims as chart rings: a foot outside the trim is on the surface but
70    // not on the face, and landing there would be a projection onto
71    // geometry the shape does not have.
72    let mut seats: Vec<Seat> = Vec::new();
73    let deflection = ogeom_mesh::Deflection::default();
74    for face in explore_unique(model, target, ShapeType::Face)? {
75        let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data().clone()) else {
76            continue;
77        };
78        let Some(surface) = model.geometry().surface(data.surface).cloned() else {
79            continue;
80        };
81        let placement = face.transform(model.datums())?;
82        if (placement.scale_factor().abs() - 1.0).abs() > 1e-9 {
83            ogeom_bail!(
84                Construction,
85                "a scaled placement changes a surface's parameterization out \
86                 from under its pcurves; bake the scale before projecting"
87            );
88        }
89        let rings = ogeom_mesh::face_boundary(model, &face, deflection, tol)?;
90        seats.push(Seat {
91            face,
92            surface_id: data.surface,
93            surface: surface.transformed(&placement, tol)?,
94            rings,
95        });
96    }
97    if seats.is_empty() {
98        ogeom_bail!(Construction, "a shape with no faces catches nothing");
99    }
100
101    let mut out = Vec::new();
102    let mut history = History::new();
103    for edge in explore_unique(model, wire, ShapeType::Edge)? {
104        let (curve, range) = {
105            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
106                continue;
107            };
108            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
109                continue;
110            };
111            let Some(geometry) = model.geometry().curve(*curve).cloned() else {
112                continue;
113            };
114            (geometry, *range)
115        };
116        // Walk the edge, landing each station on the nearest face that will
117        // have it, and break the run wherever the face changes or nothing
118        // catches; each run becomes one projected edge.
119        let mut run: Vec<(usize, f64, Point, Point2)> = Vec::new();
120        let mut runs: Vec<(usize, Vec<Landed>)> = Vec::new();
121        let mut flush = |run: &mut Vec<(usize, f64, Point, Point2)>| {
122            if run.len() >= 4 {
123                let seat = run[0].0;
124                runs.push((
125                    seat,
126                    run.iter().map(|(_, t, p, uv)| (*t, *p, *uv)).collect(),
127                ));
128            }
129            run.clear();
130        };
131        for k in 0..=stations {
132            #[expect(
133                clippy::cast_precision_loss,
134                reason = "a station index, far below the mantissa"
135            )]
136            let t = (range.1 - range.0).mul_add(k as f64 / stations as f64, range.0);
137            let at = curve.point_at(t, tol)?;
138            let landed = nearest_seat(&seats, at, tol)?;
139            match landed {
140                Some((seat, point, uv)) => {
141                    if run.first().is_some_and(|(held, ..)| *held != seat) {
142                        flush(&mut run);
143                    }
144                    run.push((seat, t, point, uv));
145                }
146                None => flush(&mut run),
147            }
148        }
149        flush(&mut run);
150
151        for (seat, samples) in runs {
152            let points: Vec<Point> = samples.iter().map(|(_, p, _)| *p).collect();
153            let mut chart: Vec<Point2> = samples.iter().map(|(_, _, uv)| *uv).collect();
154            // A periodic chart's parameters come back folded into the
155            // surface's own window, so a run crossing the seam arrives torn,
156            // and a fit through a tear is a fit through a jump it cannot
157            // make. Unwrapped, the run is continuous again.
158            unwrap(&mut chart, &seats[seat].surface);
159            // The run over [0, 1]: its stations where the walk left them,
160            // and the foot anywhere between, landed on the run's own face
161            // and unwrapped against the station before it.
162            let (t0, t1) = (samples[0].0, samples[samples.len() - 1].0);
163            let ts: Vec<f64> = samples.iter().map(|(t, ..)| (t - t0) / (t1 - t0)).collect();
164            let surface = &seats[seat].surface;
165            let foot = |s: f64| -> OgeomResult<(Point, Point2)> {
166                let k = ts.partition_point(|x| *x <= s).clamp(1, ts.len()) - 1;
167                if ts[k].to_bits() == s.to_bits() {
168                    return Ok((points[k], chart[k]));
169                }
170                let at = curve.point_at(t0 + (t1 - t0) * s, tol)?;
171                let projection = ogeom_algo::project_on_surface(surface, at, 24, tol)?;
172                let mut uv = [
173                    chart[k],
174                    Point2::new(projection.parameters.0, projection.parameters.1),
175                ];
176                unwrap(&mut uv, surface);
177                Ok((surface.point_at(uv[1].x, uv[1].y, tol)?, uv[1]))
178            };
179            // Fitted together, so the two descriptions share a parameter:
180            // the pcurve rides the same knots as the curve, and both are
181            // measured against the foot between the stations as well as at
182            // them.
183            let (fitted, on_face) = ogeom_geom::fit::fit_trace_sampled(
184                foot,
185                |uv| surface.point_at(uv.x, uv.y, tol),
186                &ts,
187                3,
188                tolerance,
189                tol,
190            )?;
191            let curve: Curve = fitted.curve.into();
192            let built = ogeom_algo::make_edge(model, curve, (0.0, 1.0), tol)?.shape;
193            // The curve and its pcurve stand as far apart as the fit
194            // measured them; the edge carries that.
195            if fitted.error > tol.confusion() {
196                model.widen(&built, ogeom_core::Tolerance::new(fitted.error)?)?;
197            }
198            let pcurve: PlanarCurve = on_face.into();
199            ogeom_algo::attach_pcurve(
200                model,
201                &built,
202                pcurve,
203                seats[seat].surface_id,
204                ogeom_topo::Location::identity(),
205                (0.0, 1.0),
206            )?;
207            history.generate(&edge, built.clone());
208            out.push(Projected {
209                edge: built,
210                face: seats[seat].face.clone(),
211                tolerance: fitted.error,
212            });
213        }
214    }
215
216    let edges: Vec<Shape> = out.iter().map(|p| p.edge.clone()).collect();
217    let result = model.add_compound(&edges)?;
218    history.modify(wire, result.clone());
219    Ok((out, Built::new(result, history)))
220}
221
222/// A station landed on a face: its parameter on the edge, the foot, and
223/// the foot's chart position.
224type Landed = (f64, Point, Point2);
225
226/// A face ready to catch a projection.
227struct Seat {
228    face: Shape,
229    surface_id: SurfaceId,
230    /// The surface in world space.
231    surface: SurfaceGeometry,
232    /// The face's trim, as chart rings.
233    rings: Vec<Vec<Point2>>,
234}
235
236/// The nearest face whose *trim* holds the foot, with the foot and its
237/// chart position.
238fn nearest_seat(
239    seats: &[Seat],
240    at: Point,
241    tol: Tolerances,
242) -> OgeomResult<Option<(usize, Point, Point2)>> {
243    let mut best: Option<(usize, Point, Point2, f64)> = None;
244    for (i, seat) in seats.iter().enumerate() {
245        let projection = ogeom_algo::project_on_surface(&seat.surface, at, 24, tol)?;
246        let (u, v) = projection.parameters;
247        let uv = Point2::new(u, v);
248        if !inside_rings(&seat.rings, uv) {
249            continue;
250        }
251        let foot = seat.surface.point_at(u, v, tol)?;
252        let distance = foot.distance(at);
253        if best.as_ref().is_none_or(|(.., held)| distance < *held) {
254            best = Some((i, foot, uv, distance));
255        }
256    }
257    Ok(best.map(|(i, foot, uv, _)| (i, foot, uv)))
258}
259
260/// Undo the folding a periodic chart applies: every step longer than half a
261/// period is that period the other way.
262fn unwrap(chart: &mut [Point2], surface: &SurfaceGeometry) {
263    let ((u0, u1), (v0, v1)) = surface.domain();
264    let periods = [
265        if surface.is_periodic_u() {
266            u1 - u0
267        } else {
268            0.0
269        },
270        if surface.is_periodic_v() {
271            v1 - v0
272        } else {
273            0.0
274        },
275    ];
276    for k in 1..chart.len() {
277        let previous = chart[k - 1];
278        let mut here = chart[k];
279        for (axis, period) in periods.iter().enumerate() {
280            if *period <= 0.0 {
281                continue;
282            }
283            let (was, is) = if axis == 0 {
284                (previous.x, here.x)
285            } else {
286                (previous.y, here.y)
287            };
288            let shifted = (is - was) / period;
289            let turns = shifted.round();
290            if turns.abs() >= 1.0 {
291                if axis == 0 {
292                    here.x = turns.mul_add(-period, is);
293                } else {
294                    here.y = turns.mul_add(-period, is);
295                }
296            }
297        }
298        chart[k] = here;
299    }
300}
301
302/// Even-odd containment against a face's chart rings, holes included by the
303/// same counting.
304fn inside_rings(rings: &[Vec<Point2>], p: Point2) -> bool {
305    let mut inside = false;
306    for ring in rings {
307        for i in 0..ring.len() {
308            let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
309            if (a.y > p.y) != (b.y > p.y) {
310                let x = (b.x - a.x).mul_add((p.y - a.y) / (b.y - a.y), a.x);
311                if x > p.x {
312                    inside = !inside;
313                }
314            }
315        }
316    }
317    inside
318}