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 may sit from the sampled feet.
32    pub tolerance: f64,
33}
34
35/// Project every edge of `wire` onto the faces of `target`.
36///
37/// `stations` is how finely each edge is sampled; the fit is held to
38/// `tolerance` against those samples. Both are the caller's, because both
39/// are the answer's accuracy and this cannot guess what it is for.
40///
41/// # Errors
42///
43/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
44/// `stations` is under four or `tolerance` is not a usable distance, and
45/// whatever the fit refuses.
46pub fn normal_projection(
47    model: &mut Model,
48    target: &Shape,
49    wire: &Shape,
50    stations: usize,
51    tolerance: f64,
52    tol: Tolerances,
53) -> OgeomResult<(Vec<Projected>, Built)> {
54    if stations < 4 {
55        ogeom_bail!(
56            Construction,
57            "a projection sampled at {stations} stations is a guess"
58        );
59    }
60    if !tolerance.is_finite() || tolerance <= 0.0 {
61        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
62    }
63
64    // The faces to land on, with their surfaces in world space and their
65    // trims as chart rings: a foot outside the trim is on the surface but
66    // not on the face, and landing there would be a projection onto
67    // geometry the shape does not have.
68    let mut seats: Vec<Seat> = Vec::new();
69    let deflection = ogeom_mesh::Deflection::default();
70    for face in explore_unique(model, target, ShapeType::Face)? {
71        let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data().clone()) else {
72            continue;
73        };
74        let Some(surface) = model.geometry().surface(data.surface).cloned() else {
75            continue;
76        };
77        let placement = face.transform(model.datums())?;
78        if (placement.scale_factor().abs() - 1.0).abs() > 1e-9 {
79            ogeom_bail!(
80                Construction,
81                "a scaled placement changes a surface's parameterization out \
82                 from under its pcurves; bake the scale before projecting"
83            );
84        }
85        let rings = ogeom_mesh::face_boundary(model, &face, deflection, tol)?;
86        seats.push(Seat {
87            face,
88            surface_id: data.surface,
89            surface: surface.transformed(&placement, tol)?,
90            rings,
91        });
92    }
93    if seats.is_empty() {
94        ogeom_bail!(Construction, "a shape with no faces catches nothing");
95    }
96
97    let mut out = Vec::new();
98    let mut history = History::new();
99    for edge in explore_unique(model, wire, ShapeType::Edge)? {
100        let (curve, range) = {
101            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
102                continue;
103            };
104            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
105                continue;
106            };
107            let Some(geometry) = model.geometry().curve(*curve).cloned() else {
108                continue;
109            };
110            (geometry, *range)
111        };
112        // Walk the edge, landing each station on the nearest face that will
113        // have it, and break the run wherever the face changes or nothing
114        // catches; each run becomes one projected edge.
115        let mut run: Vec<(usize, Point, Point2)> = Vec::new();
116        let mut runs: Vec<(usize, Vec<(Point, Point2)>)> = Vec::new();
117        let mut flush = |run: &mut Vec<(usize, Point, Point2)>| {
118            if run.len() >= 4 {
119                let seat = run[0].0;
120                runs.push((seat, run.iter().map(|(_, p, uv)| (*p, *uv)).collect()));
121            }
122            run.clear();
123        };
124        for k in 0..=stations {
125            #[expect(
126                clippy::cast_precision_loss,
127                reason = "a station index, far below the mantissa"
128            )]
129            let t = (range.1 - range.0).mul_add(k as f64 / stations as f64, range.0);
130            let at = curve.point_at(t, tol)?;
131            let landed = nearest_seat(&seats, at, tol)?;
132            match landed {
133                Some((seat, point, uv)) => {
134                    if run.first().is_some_and(|(held, _, _)| *held != seat) {
135                        flush(&mut run);
136                    }
137                    run.push((seat, point, uv));
138                }
139                None => flush(&mut run),
140            }
141        }
142        flush(&mut run);
143
144        for (seat, samples) in runs {
145            let points: Vec<Point> = samples.iter().map(|(p, _)| *p).collect();
146            let mut chart: Vec<Point2> = samples.iter().map(|(_, uv)| *uv).collect();
147            // A periodic chart's parameters come back folded into the
148            // surface's own window, so a run crossing the seam arrives torn,
149            // and a fit through a tear is a fit through a jump it cannot
150            // make. Unwrapped, the run is continuous again.
151            unwrap(&mut chart, &seats[seat].surface);
152            // Fitted together, so the two descriptions share a parameter:
153            // the pcurve rides the same knots as the curve.
154            let (fitted, on_face, _) =
155                ogeom_geom::fit::fit_points_joint(&points, &chart, &chart, 3, tolerance, tol)?;
156            let curve: Curve = fitted.curve.into();
157            let built = ogeom_algo::make_edge(model, curve, (0.0, 1.0), tol)?.shape;
158            let pcurve: PlanarCurve = on_face.into();
159            ogeom_algo::attach_pcurve(
160                model,
161                &built,
162                pcurve,
163                seats[seat].surface_id,
164                ogeom_topo::Location::identity(),
165                (0.0, 1.0),
166            )?;
167            history.generate(&edge, built.clone());
168            out.push(Projected {
169                edge: built,
170                face: seats[seat].face.clone(),
171                tolerance: fitted.error,
172            });
173        }
174    }
175
176    let edges: Vec<Shape> = out.iter().map(|p| p.edge.clone()).collect();
177    let result = model.add_compound(&edges)?;
178    history.modify(wire, result.clone());
179    Ok((out, Built::new(result, history)))
180}
181
182/// A face ready to catch a projection.
183struct Seat {
184    face: Shape,
185    surface_id: SurfaceId,
186    /// The surface in world space.
187    surface: SurfaceGeometry,
188    /// The face's trim, as chart rings.
189    rings: Vec<Vec<Point2>>,
190}
191
192/// The nearest face whose *trim* holds the foot, with the foot and its
193/// chart position.
194fn nearest_seat(
195    seats: &[Seat],
196    at: Point,
197    tol: Tolerances,
198) -> OgeomResult<Option<(usize, Point, Point2)>> {
199    let mut best: Option<(usize, Point, Point2, f64)> = None;
200    for (i, seat) in seats.iter().enumerate() {
201        let projection = ogeom_algo::project_on_surface(&seat.surface, at, 24, tol)?;
202        let (u, v) = projection.parameters;
203        let uv = Point2::new(u, v);
204        if !inside_rings(&seat.rings, uv) {
205            continue;
206        }
207        let foot = seat.surface.point_at(u, v, tol)?;
208        let distance = foot.distance(at);
209        if best.as_ref().is_none_or(|(.., held)| distance < *held) {
210            best = Some((i, foot, uv, distance));
211        }
212    }
213    Ok(best.map(|(i, foot, uv, _)| (i, foot, uv)))
214}
215
216/// Undo the folding a periodic chart applies: every step longer than half a
217/// period is that period the other way.
218fn unwrap(chart: &mut [Point2], surface: &SurfaceGeometry) {
219    let ((u0, u1), (v0, v1)) = surface.domain();
220    let periods = [
221        if surface.is_periodic_u() {
222            u1 - u0
223        } else {
224            0.0
225        },
226        if surface.is_periodic_v() {
227            v1 - v0
228        } else {
229            0.0
230        },
231    ];
232    for k in 1..chart.len() {
233        let previous = chart[k - 1];
234        let mut here = chart[k];
235        for (axis, period) in periods.iter().enumerate() {
236            if *period <= 0.0 {
237                continue;
238            }
239            let (was, is) = if axis == 0 {
240                (previous.x, here.x)
241            } else {
242                (previous.y, here.y)
243            };
244            let shifted = (is - was) / period;
245            let turns = shifted.round();
246            if turns.abs() >= 1.0 {
247                if axis == 0 {
248                    here.x = turns.mul_add(-period, is);
249                } else {
250                    here.y = turns.mul_add(-period, is);
251                }
252            }
253        }
254        chart[k] = here;
255    }
256}
257
258/// Even-odd containment against a face's chart rings, holes included by the
259/// same counting.
260fn inside_rings(rings: &[Vec<Point2>], p: Point2) -> bool {
261    let mut inside = false;
262    for ring in rings {
263        for i in 0..ring.len() {
264            let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
265            if (a.y > p.y) != (b.y > p.y) {
266                let x = (b.x - a.x).mul_add((p.y - a.y) / (b.y - a.y), a.x);
267                if x > p.x {
268                    inside = !inside;
269                }
270            }
271        }
272    }
273    inside
274}