1use 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#[derive(Debug, Clone)]
26pub struct Projected {
27 pub edge: Shape,
29 pub face: Shape,
31 pub tolerance: f64,
35}
36
37pub 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 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 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 unwrap(&mut chart, &seats[seat].surface);
159 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 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 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
222type Landed = (f64, Point, Point2);
225
226struct Seat {
228 face: Shape,
229 surface_id: SurfaceId,
230 surface: SurfaceGeometry,
232 rings: Vec<Vec<Point2>>,
234}
235
236fn 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
260fn 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
302fn 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}