1use ogeom_algo::{
5 Built, History, attach_pcurve, edge_vertices, make_edge_between, make_face, make_vertex,
6 make_wire,
7};
8use ogeom_core::{OgeomResult, Tolerance, Tolerances, ogeom_bail};
9use ogeom_geom::{
10 BSpline2d, BSplineSurface, Continuity, Curve, Curve2d as _, Curve3d as _, CurveKind,
11 PlanarCurve, Surface as _, SurfaceCurvature, SurfaceGeometry, Transformable as _, Trig2d,
12};
13use ogeom_math::{Direction, Point, Point2, Vector, Vector2, Weighted};
14use ogeom_topo::{
15 EdgeRepr, Filter, Location, Model, NodeData, Orientation, Shape, ShapeType, explore,
16};
17
18use crate::fill_patch::{Condition, DEGREE, PlaneFrame, fit_free, fit_height};
19
20#[derive(Debug, Clone)]
22pub struct FillBoundary {
23 pub edge: Shape,
30 pub support: Option<Shape>,
37 pub continuity: Continuity,
41}
42
43#[derive(Debug, Clone, PartialEq)]
46pub struct FillSide {
47 pub edge: Shape,
50 pub gap: f64,
53 pub angle: Option<f64>,
56 pub curvature: Option<f64>,
61 pub stations: usize,
63}
64
65#[derive(Debug, Clone)]
67pub struct Filled {
68 pub built: Built,
70 pub sides: Vec<FillSide>,
72 pub constraint_gap: f64,
76}
77
78const OUTLINE_SAMPLES: usize = 64;
80const MARGIN: f64 = 0.05;
82const NETS: [usize; 6] = [8, 12, 18, 27, 40, 60];
84const BUDGET: f64 = 0.1;
87const LEAST_SMOOTHING: f64 = 1e-12;
90const MIN_LIFT: f64 = 0.1;
94const POINT_SHARE: f64 = 0.05;
97const PER_CURVE: usize = 32;
99const KEEP: f64 = 0.25;
103const STEEP_SAMPLES: usize = 256;
106const CHART_SAMPLES: usize = 128;
108const MOST_TURN: f64 = 0.9;
110const CORNER: f64 = 0.0175;
113
114pub fn make_filling_n(
190 model: &mut Model,
191 boundary: &[FillBoundary],
192 constraints: &[Shape],
193 tolerance: f64,
194 tol: Tolerances,
195) -> OgeomResult<Filled> {
196 if !(tolerance.is_finite() && tolerance > 0.0) {
197 ogeom_bail!(
198 Construction,
199 "a filling's tolerance is positive and finite; got {tolerance}"
200 );
201 }
202 if boundary.is_empty() {
203 ogeom_bail!(Construction, "a filling needs at least one boundary edge");
204 }
205 let mut sides = Vec::with_capacity(boundary.len());
206 for (i, entry) in boundary.iter().enumerate() {
207 sides.push(read_side(model, i, entry, tolerance, tol)?);
208 }
209 for i in 0..sides.len() {
210 for j in (i + 1)..sides.len() {
211 if sides[i].given.is_same(&sides[j].given) {
212 ogeom_bail!(Construction, "side {j} is side {i}'s edge again");
213 }
214 }
215 }
216 let mut order = chain(model, &mut sides, tol)?;
217 face_the_supports(model, &mut sides, &mut order)?;
218
219 let outline = loop_points(&sides, &order, tol)?;
221 let frame = frame_of(&outline, tol)?;
222 let chart_outline: Vec<Point2> = outline.iter().map(|p| frame.chart(*p)).collect();
223 if crosses_itself(&chart_outline) {
224 ogeom_bail!(
225 Construction,
226 "the boundary loop crosses itself seen along the normal of the \
227 plane it spans; the hole is not a height field over that plane"
228 );
229 }
230 let interior = constraint_points(model, constraints, tol)?;
231 for (k, (p, _)) in interior.iter().enumerate() {
232 if !inside(&chart_outline, frame.chart(*p)) {
233 ogeom_bail!(
234 Construction,
235 "a point of constraint {k} at {p:?} lies outside the hole seen \
236 along the normal of the plane the boundary spans"
237 );
238 }
239 }
240
241 let corners = corners_of(&sides, &order, tol)?;
248 tangent_corners(&sides, &order, &corners, tolerance, tol)?;
249 let steepest = steepest_support(&sides, &frame, tol)?;
250 let free =
251 kept_turn(frame.n, &corners) < KEEP || steepest.is_some_and(|(_, lift)| lift < MIN_LIFT);
252 if free && !interior.is_empty() {
253 let why = match steepest {
254 Some((i, lift)) if lift < MIN_LIFT => format!(
255 "side {i}'s support stands at {:.1} degrees to the plane the \
256 boundary spans, past the {:.1} a height over that plane takes",
257 lift.acos().to_degrees(),
258 MIN_LIFT.acos().to_degrees()
259 ),
260 _ => "a corner of the boundary loop is seen smooth along the normal \
261 of the plane it spans"
262 .to_owned(),
263 };
264 ogeom_bail!(
265 Construction,
266 "{why}, so the filling is drawn over a chart of the loop's own, \
267 which places no interior constraints"
268 );
269 }
270 let mut traces = Vec::with_capacity(sides.len());
271 let mut lengths = Vec::with_capacity(sides.len());
272 for side in &sides {
273 if !free {
274 traces.push(trace(side, &frame, tolerance, tol)?);
275 }
276 lengths.push(side_length(side, tol)?);
277 }
278 let chart_outline = if free {
279 traces = boundary_chart(&sides, &order, &corners, tolerance, tol)?;
280 traced_outline(&sides, &order, &traces, tol)?
281 } else {
282 chart_outline
283 };
284
285 let (mut lo, mut hi) = (
287 Point2::new(f64::INFINITY, f64::INFINITY),
288 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
289 );
290 for q in &chart_outline {
291 lo = Point2::new(lo.x.min(q.x), lo.y.min(q.y));
292 hi = Point2::new(hi.x.max(q.x), hi.y.max(q.y));
293 }
294 let margin = MARGIN * (hi.x - lo.x).max(hi.y - lo.y);
295 let domain = (
296 (lo.x - margin, hi.x + margin),
297 (lo.y - margin, hi.y + margin),
298 );
299 let (du, dv) = (domain.0.1 - domain.0.0, domain.1.1 - domain.1.0);
300 let size = du.max(dv);
301
302 let smoothing = smoothing_for(&sides, tolerance, size);
303 let mut last_miss = String::new();
304 let mut fallback = None;
306 for base in NETS {
307 #[expect(
308 clippy::cast_possible_truncation,
309 clippy::cast_sign_loss,
310 clippy::cast_precision_loss,
311 reason = "a control count of a few dozen, from a positive ratio"
312 )]
313 let count = |extent: f64| -> usize {
314 ((base as f64 * extent / size).round() as usize).max(DEGREE + 2)
315 };
316 let controls = (count(du), count(dv));
317 let samples = 4 * controls.0.max(controls.1) + 8;
318
319 let fit = if free {
320 let mut conditions = Vec::new();
321 for ((side, length), pcurve) in sides.iter().zip(&lengths).zip(&traces) {
322 free_conditions(
323 side,
324 pcurve,
325 *length / size,
326 size,
327 samples,
328 &mut conditions,
329 tol,
330 )?;
331 }
332 fit_free(domain, controls, &conditions, smoothing, tol)?
333 } else {
334 let mut conditions = Vec::new();
335 for (side, length) in sides.iter().zip(&lengths) {
336 side_conditions(
337 side,
338 *length / size,
339 size,
340 samples,
341 &frame,
342 &mut conditions,
343 tol,
344 )?;
345 }
346 for (p, share) in &interior {
347 conditions.push(Condition::partial(
348 frame.chart(*p),
349 (0, 0),
350 [frame.height(*p)],
351 share.sqrt() / size,
352 ));
353 }
354 fit_height(&frame, domain, controls, &conditions, smoothing, tol)?
355 };
356 let surface = fit.surface;
357
358 let mut reports = Vec::with_capacity(sides.len());
359 for (side, pcurve) in sides.iter().zip(&traces) {
360 reports.push(measure_side(side, pcurve, &surface, 3 * samples + 1, tol)?);
361 }
362 let mut constraint_gap = 0.0f64;
363 for (p, _) in &interior {
364 let q = frame.chart(*p);
365 constraint_gap = constraint_gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
366 }
367
368 if let Some(miss) = first_miss(&sides, &reports, constraint_gap, tolerance) {
369 last_miss = format!("at {}x{} controls, {miss}", controls.0, controls.1);
370 continue;
371 }
372 if free && folds(&surface, &chart_outline, domain, tol)? {
373 last_miss = format!(
374 "at {}x{} controls, the patch folds over inside the hole",
375 controls.0, controls.1
376 );
377 continue;
378 }
379 fallback = Some((surface, reports, constraint_gap));
380 if fit.residual <= smoothing {
382 break;
383 }
384 }
385 let Some((surface, reports, constraint_gap)) = fallback else {
386 ogeom_bail!(
387 NotDone,
388 "the filling misses its tolerance of {tolerance} on the finest net: {last_miss}"
389 )
390 };
391 let face = build(model, &mut sides, &order, &traces, &reports, surface, tol)?;
392 let mut history = History::new();
393 let mut reports = reports;
394 for (side, report) in sides.iter().zip(&mut reports) {
395 history.generate(&side.given, face.clone());
396 if !side.edge.is_same(&side.given) {
397 history.generate(&side.given, side.edge.clone());
398 }
399 report.edge = side.edge.clone();
400 }
401 for constraint in constraints {
402 history.generate(constraint, face.clone());
403 }
404 Ok(Filled {
405 built: Built::new(face, history),
406 sides: reports,
407 constraint_gap,
408 })
409}
410
411struct Support {
413 face: Shape,
414 surface: SurfaceGeometry,
415 pcurve: PlanarCurve,
416 prange: (f64, f64),
417 material: Option<f64>,
422}
423
424struct Side {
426 entry: usize,
427 given: Shape,
429 edge: Shape,
432 placed: bool,
434 curve: Curve,
436 range: (f64, f64),
437 edge_tolerance: f64,
438 order: usize,
440 support: Option<Support>,
441 reversed: bool,
443}
444
445impl Side {
446 fn parameter(&self, f: f64) -> f64 {
447 (self.range.1 - self.range.0).mul_add(f, self.range.0)
448 }
449
450 fn support_at(&self, t: f64, tol: Tolerances) -> OgeomResult<Option<(Point2, Vector)>> {
453 let Some(support) = &self.support else {
454 return Ok(None);
455 };
456 let f = (t - self.range.0) / (self.range.1 - self.range.0);
457 let pt = (support.prange.1 - support.prange.0).mul_add(f, support.prange.0);
458 let uv = support.pcurve.point_at(pt, tol)?;
459 let normal = support.surface.normal_at(uv.x, uv.y, tol)?.vector();
460 Ok(Some((uv, normal)))
461 }
462}
463
464fn read_side(
465 model: &Model,
466 i: usize,
467 entry: &FillBoundary,
468 tolerance: f64,
469 tol: Tolerances,
470) -> OgeomResult<Side> {
471 let edge = &entry.edge;
472 if model.kind_of(edge)? != ShapeType::Edge {
473 ogeom_bail!(Construction, "side {i} is not an edge");
474 }
475 let order = match entry.continuity {
476 Continuity::C0 => 0,
477 Continuity::G1 => 1,
478 Continuity::G2 => 2,
479 other => ogeom_bail!(
480 Construction,
481 "side {i} asks {other:?}: parametric continuity between two \
482 surfaces' charts is not what a filling meets; ask for G1 or G2"
483 ),
484 };
485 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
486 ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
487 };
488 let Some(EdgeRepr::Curve3d {
489 curve,
490 range,
491 location: own,
492 }) = data.curve3d()
493 else {
494 ogeom_bail!(Construction, "side {i}'s edge has no 3D curve");
495 };
496 let Some(curve) = model.geometry().curve(*curve).cloned() else {
497 ogeom_bail!(Dangling, "side {i}'s curve is not in this model");
498 };
499 let range = *range;
500 let edge_tolerance = data.tolerance.get();
501 let placed = !(edge.location().is_identity() && own.is_identity());
502 let curve = if placed {
503 curve
504 .transformed(&own.composed(model.datums())?, tol)?
505 .transformed(&edge.transform(model.datums())?, tol)?
506 } else {
507 curve
508 };
509 let edge = edge.oriented(Orientation::Forward);
510
511 let support = match &entry.support {
512 None if order > 0 => ogeom_bail!(
513 Construction,
514 "side {i} asks {:?} but names no support face to meet",
515 entry.continuity
516 ),
517 None => None,
518 Some(face) => Some(read_support(
519 model,
520 i,
521 face,
522 &edge,
523 &curve,
524 range,
525 tolerance.max(edge_tolerance),
526 tol,
527 )?),
528 };
529 Ok(Side {
530 entry: i,
531 given: edge.clone(),
532 edge,
533 placed,
534 curve,
535 range,
536 edge_tolerance,
537 order,
538 support,
539 reversed: false,
540 })
541}
542
543#[expect(
544 clippy::too_many_arguments,
545 reason = "the side's edge, curve and range are read once by the caller"
546)]
547fn read_support(
548 model: &Model,
549 i: usize,
550 face: &Shape,
551 edge: &Shape,
552 curve: &Curve,
553 range: (f64, f64),
554 reach: f64,
555 tol: Tolerances,
556) -> OgeomResult<Support> {
557 if model.kind_of(face)? != ShapeType::Face {
558 ogeom_bail!(Construction, "side {i}'s support is not a face");
559 }
560 let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
561 ogeom_bail!(Dangling, "side {i}'s support is not in this model");
562 };
563 let face_placed = !(face.location().is_identity() && face_data.location.is_identity());
564 let uses: Vec<Orientation> = explore(model, face, Filter::OfType(ShapeType::Edge))?
567 .iter()
568 .filter(|e| e.is_same(edge))
569 .map(Shape::orientation)
570 .collect();
571 let sense = |o: Orientation| match o {
572 Orientation::Forward => Some(1.0),
573 Orientation::Reversed => Some(-1.0),
574 _ => None,
575 };
576 let material = match uses.as_slice() {
577 [first, rest @ ..] if rest.iter().all(|o| o == first) => sense(face.orientation())
578 .zip(sense(*first))
579 .map(|(f, e)| f * e),
580 _ => None,
581 };
582 if uses.is_empty() {
583 ogeom_bail!(
584 Construction,
585 "side {i}'s support face does not hold the side's edge at its placement"
586 );
587 }
588 let surface_id = face_data.surface;
589 let Some(surface) = model.geometry().surface(surface_id).cloned() else {
590 ogeom_bail!(Dangling, "side {i}'s support surface is not in this model");
591 };
592 let surface = if face_placed {
593 surface
594 .transformed(&face_data.location.composed(model.datums())?, tol)?
595 .transformed(&face.transform(model.datums())?, tol)?
596 } else {
597 surface
598 };
599 let Some(edge_data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
600 ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
601 };
602 let stored = match edge_data
605 .pcurve_for(surface_id, edge.location())
606 .filter(|_| !face_placed)
607 {
608 Some(
609 EdgeRepr::PCurve { curve, range, .. }
610 | EdgeRepr::Seam {
611 forward: curve,
612 range,
613 ..
614 },
615 ) => model
616 .geometry()
617 .pcurve(*curve)
618 .cloned()
619 .map(|c| (c, *range)),
620 _ => None,
621 };
622 let off = |pcurve: &PlanarCurve, prange: (f64, f64)| -> OgeomResult<f64> {
626 let mut worst = 0.0f64;
627 for k in 0..=16 {
628 let f = f64::from(k) / 16.0;
629 let t = (range.1 - range.0).mul_add(f, range.0);
630 let pt = (prange.1 - prange.0).mul_add(f, prange.0);
631 let uv = pcurve.point_at(pt, tol)?;
632 let on = surface.point_at(uv.x, uv.y, tol)?;
633 worst = worst.max(on.distance(curve.point_at(t, tol)?));
634 }
635 Ok(worst)
636 };
637 let stored = match stored {
638 Some((pcurve, prange)) => {
639 let worst = off(&pcurve, prange)?;
640 (worst <= reach).then_some((pcurve, prange, worst))
641 }
642 None => None,
643 };
644 let (pcurve, prange, worst) = if let Some(found) = stored {
645 found
646 } else {
647 let (fitted, _, _, _, _) =
648 ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, &surface, tol)?;
649 let worst = off(&fitted, range)?;
650 (fitted, range, worst)
651 };
652 if worst > reach {
653 ogeom_bail!(
654 Construction,
655 "side {i}'s edge stands {worst} off its support face, past {reach}"
656 );
657 }
658 Ok(Support {
659 face: face.clone(),
660 surface,
661 pcurve,
662 prange,
663 material,
664 })
665}
666
667#[derive(Clone)]
670struct End {
671 vertex: Shape,
672 at: Point,
673 reach: f64,
674}
675
676fn ends_of(model: &Model, side: &Side) -> OgeomResult<(End, End)> {
678 let Some((a, b)) = edge_vertices(model, &side.given)? else {
679 ogeom_bail!(
680 Construction,
681 "side {} has no vertices, so it cannot be shown to join the loop",
682 side.entry
683 );
684 };
685 let end = |vertex: Shape| -> OgeomResult<End> {
686 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
687 ogeom_bail!(Construction, "side {}'s vertex holds no point", side.entry);
688 };
689 Ok(End {
690 at: vertex.transform(model.datums())?.apply(data.point),
691 reach: data.tolerance.get().max(side.edge_tolerance),
692 vertex,
693 })
694 };
695 Ok((end(a)?, end(b)?))
696}
697
698fn chain(model: &Model, sides: &mut [Side], tol: Tolerances) -> OgeomResult<Vec<usize>> {
703 let mut ends = Vec::with_capacity(sides.len());
704 for side in sides.iter() {
705 let (a, b) = ends_of(model, side)?;
706 ends.push((a, b));
707 }
708 let same = |a: &End, b: &End| -> OgeomResult<bool> {
709 Ok(a.vertex.is_same(&b.vertex) || model.same_position(&a.vertex, &b.vertex, tol)?)
710 };
711 let near = |a: &End, b: &End| a.at.distance(b.at) <= a.reach + b.reach + tol.confusion();
712 let meets = |a: &End, b: &End| -> OgeomResult<bool> { Ok(same(a, b)? || near(a, b)) };
713 let n = sides.len();
714 let mut used = vec![false; n];
715 used[0] = true;
716 let mut order = vec![0];
717 let first = ends[0].0.clone();
718 let mut cursor = ends[0].1.clone();
719 while order.len() < n {
720 let last = sides[order[order.len() - 1]].entry;
721 let mut found: Vec<(usize, bool)> = Vec::new();
724 for strict in [true, false] {
725 for j in 0..n {
726 if used[j] {
727 continue;
728 }
729 let joins = |e: &End| -> OgeomResult<bool> {
730 if strict {
731 same(e, &cursor)
732 } else {
733 meets(e, &cursor)
734 }
735 };
736 if joins(&ends[j].0)? {
737 found.push((j, false));
738 } else if joins(&ends[j].1)? {
739 found.push((j, true));
740 }
741 }
742 if !found.is_empty() {
743 break;
744 }
745 }
746 let (j, reversed) = match found.as_slice() {
747 [one] => *one,
748 [] => ogeom_bail!(
749 Construction,
750 "the boundary does not close: no side continues where side {last} ends"
751 ),
752 _ => ogeom_bail!(
753 Construction,
754 "{} sides continue where side {last} ends; a filling's boundary \
755 is one simple loop",
756 found.len()
757 ),
758 };
759 used[j] = true;
760 sides[j].reversed = reversed;
761 cursor = if reversed {
762 ends[j].0.clone()
763 } else {
764 ends[j].1.clone()
765 };
766 order.push(j);
767 }
768 if !meets(&cursor, &first)? {
769 ogeom_bail!(
770 Construction,
771 "the boundary does not close: the last side ends away from where the first begins"
772 );
773 }
774 Ok(order)
775}
776
777fn face_the_supports(model: &Model, sides: &mut [Side], order: &mut [usize]) -> OgeomResult<()> {
780 let (mut agree, mut disagree) = (0usize, 0usize);
781 for side in sides.iter() {
782 let Some(support) = &side.support else {
783 continue;
784 };
785 let uses: Vec<Orientation> =
786 explore(model, &support.face, Filter::OfType(ShapeType::Edge))?
787 .iter()
788 .filter(|e| e.is_same(&side.given))
789 .map(Shape::orientation)
790 .collect();
791 let Some(&first) = uses.first() else {
792 continue;
793 };
794 if uses.iter().any(|o| *o != first)
795 || !matches!(first, Orientation::Forward | Orientation::Reversed)
796 {
797 continue;
798 }
799 if (first == Orientation::Forward) == !side.reversed {
800 disagree += 1;
801 } else {
802 agree += 1;
803 }
804 }
805 if disagree > agree {
806 order.reverse();
807 for side in sides.iter_mut() {
808 side.reversed = !side.reversed;
809 }
810 }
811 Ok(())
812}
813
814fn loop_points(sides: &[Side], order: &[usize], tol: Tolerances) -> OgeomResult<Vec<Point>> {
816 let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
817 for &i in order {
818 let side = &sides[i];
819 for k in 0..OUTLINE_SAMPLES {
820 #[expect(
821 clippy::cast_precision_loss,
822 reason = "a sample index, far below the mantissa"
823 )]
824 let mut f = k as f64 / OUTLINE_SAMPLES as f64;
825 if side.reversed {
826 f = 1.0 - f;
827 }
828 out.push(side.curve.point_at(side.parameter(f), tol)?);
829 }
830 }
831 Ok(out)
832}
833
834fn frame_of(outline: &[Point], tol: Tolerances) -> OgeomResult<PlaneFrame> {
838 let Ok(origin) = Point::centroid(outline) else {
839 ogeom_bail!(Construction, "the boundary loop has no points");
840 };
841 let mut area = Vector::ZERO;
842 let mut reach = 0.0f64;
843 for (k, p) in outline.iter().enumerate() {
844 let q = outline[(k + 1) % outline.len()];
845 area += (*p - origin).cross(q - origin) * 0.5;
846 reach = reach.max(p.distance(origin));
847 }
848 let magnitude = area.magnitude();
849 if magnitude <= 1e-9 * reach * reach || magnitude <= tol.confusion() * tol.confusion() {
850 ogeom_bail!(
851 Construction,
852 "the boundary loop encloses no area seen from any plane"
853 );
854 }
855 let n = area * (1.0 / magnitude);
856 let b1 = Direction::new(n, tol)?.any_perpendicular().vector();
857 let b2 = n.cross(b1);
858 let (mut sxx, mut syy, mut sxy) = (0.0f64, 0.0f64, 0.0f64);
859 for p in outline {
860 let d = *p - origin;
861 let (x, y) = (d.dot(b1), d.dot(b2));
862 sxx += x * x;
863 syy += y * y;
864 sxy += x * y;
865 }
866 let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
867 let e1 = b1 * theta.cos() + b2 * theta.sin();
868 let e2 = n.cross(e1);
869 Ok(PlaneFrame { origin, e1, e2, n })
870}
871
872fn corners_of(
877 sides: &[Side],
878 order: &[usize],
879 tol: Tolerances,
880) -> OgeomResult<Vec<(Vector, Vector, f64)>> {
881 let walked = |side: &Side, end: bool| -> OgeomResult<Option<Vector>> {
882 let t = if end == side.reversed {
883 side.range.0
884 } else {
885 side.range.1
886 };
887 let d = side.curve.d1_at(t, tol)?;
888 let d = if side.reversed { d * -1.0 } else { d };
889 Ok(Direction::new(d, tol).ok().map(Direction::vector))
890 };
891 let mut out = Vec::with_capacity(order.len());
892 for (k, &i) in order.iter().enumerate() {
893 let next = &sides[order[(k + 1) % order.len()]];
894 let corner = match (walked(&sides[i], true)?, walked(next, false)?) {
895 (Some(a), Some(b)) => {
896 let turn = a.cross(b).magnitude().atan2(a.dot(b));
897 (a, b, if turn > CORNER { turn } else { 0.0 })
898 }
899 _ => (Vector::ZERO, Vector::ZERO, 0.0),
900 };
901 out.push(corner);
902 }
903 Ok(out)
904}
905
906fn kept_turn(n: Vector, corners: &[(Vector, Vector, f64)]) -> f64 {
909 let flat = |v: Vector| v - n * v.dot(n);
910 let mut worst = 1.0f64;
911 for (a, b, turn) in corners {
912 if *turn > 0.0 {
913 let (a, b) = (flat(*a), flat(*b));
914 let seen = a.cross(b).magnitude().atan2(a.dot(b));
915 worst = worst.min((seen / turn).min(1.0));
916 }
917 }
918 worst
919}
920
921fn boundary_chart(
933 sides: &[Side],
934 order: &[usize],
935 corners: &[(Vector, Vector, f64)],
936 tolerance: f64,
937 tol: Tolerances,
938) -> OgeomResult<Vec<PlanarCurve>> {
939 use std::f64::consts::{PI, TAU};
940 let mut walks: Vec<(Vec<f64>, Vec<f64>, f64)> =
943 vec![(Vec::new(), Vec::new(), 0.0); sides.len()];
944 let mut total = 0.0;
945 for &i in order {
946 let side = &sides[i];
947 let mut params = Vec::with_capacity(CHART_SAMPLES + 1);
948 let mut along = Vec::with_capacity(CHART_SAMPLES + 1);
949 let mut previous = side.curve.point_at(side.range.0, tol)?;
950 let mut length = 0.0;
951 for k in 0..=CHART_SAMPLES {
952 #[expect(
953 clippy::cast_precision_loss,
954 reason = "a sample index, far below the mantissa"
955 )]
956 let t = side.parameter(k as f64 / CHART_SAMPLES as f64);
957 let p = side.curve.point_at(t, tol)?;
958 length += p.distance(previous);
959 previous = p;
960 params.push(t);
961 along.push(length);
962 }
963 if side.reversed {
964 for a in &mut along {
965 *a = length - *a;
966 }
967 }
968 total += length;
969 walks[i] = (params, along, length);
970 }
971 if total <= tol.confusion() {
972 ogeom_bail!(Construction, "the boundary loop has no length");
973 }
974 let turns: Vec<f64> = corners.iter().map(|c| c.2.min(MOST_TURN * PI)).collect();
975 let sum: f64 = turns.iter().sum();
976 let scale = if sum > MOST_TURN * TAU {
977 MOST_TURN * TAU / sum
978 } else {
979 1.0
980 };
981 let bend = sum.mul_add(-scale, TAU) / total;
982 let arc = |start: Point2, heading: f64, s: f64| -> Point2 {
985 let delta = bend * s;
986 if delta.abs() < 1e-9 {
987 start + Vector2::new(heading.cos(), heading.sin()) * s
988 } else {
989 start
990 + Vector2::new(
991 (heading + delta).sin() - heading.sin(),
992 heading.cos() - (heading + delta).cos(),
993 ) * (1.0 / bend)
994 }
995 };
996 let mut starts = vec![(Point2::new(0.0, 0.0), 0.0, 0.0); sides.len()];
997 let (mut at, mut heading, mut walked) = (Point2::new(0.0, 0.0), 0.0f64, 0.0f64);
998 for (k, &i) in order.iter().enumerate() {
999 starts[i] = (at, heading, walked);
1000 let length = walks[i].2;
1001 at = arc(at, heading, length);
1002 heading += bend.mul_add(length, turns[k] * scale);
1003 walked += length;
1004 }
1005 let drift = at - Point2::new(0.0, 0.0);
1006 if drift.magnitude() > 0.5 * total / TAU {
1007 ogeom_bail!(
1008 Construction,
1009 "the boundary loop's corners leave no chart drawn from it closing"
1010 );
1011 }
1012 let mut out = Vec::with_capacity(sides.len());
1013 for (i, (params, along, _)) in walks.iter().enumerate() {
1014 let (start, heading, before) = starts[i];
1015 let points: Vec<Point2> = along
1016 .iter()
1017 .map(|&s| arc(start, heading, s) - drift * ((before + s) / total))
1018 .collect();
1019 let fitted = ogeom_geom::fit::fit_points_2d_at(params, &points, 3, tolerance * 1e-2, tol)?;
1020 out.push(PlanarCurve::BSpline(fitted.curve));
1021 }
1022 Ok(out)
1023}
1024fn crosses_itself(polygon: &[Point2]) -> bool {
1032 let n = polygon.len();
1033 let reach = polygon
1034 .iter()
1035 .map(|q| q.x.abs().max(q.y.abs()))
1036 .fold(0.0f64, f64::max);
1037 let floor = 1e-9 * reach;
1038 let side = |a: Point2, b: Point2, c: Point2| -> i8 {
1040 let ab = b - a;
1041 let o = ab.cross(c - a);
1042 if o.abs() <= floor * ab.magnitude() {
1043 0
1044 } else if o > 0.0 {
1045 1
1046 } else {
1047 -1
1048 }
1049 };
1050 for i in 0..n {
1051 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
1052 for j in (i + 2)..n {
1053 if i == 0 && j == n - 1 {
1054 continue;
1055 }
1056 let (c, d) = (polygon[j], polygon[(j + 1) % n]);
1057 if side(a, b, c) * side(a, b, d) < 0 && side(c, d, a) * side(c, d, b) < 0 {
1058 return true;
1059 }
1060 }
1061 }
1062 false
1063}
1064
1065fn inside(polygon: &[Point2], q: Point2) -> bool {
1067 let n = polygon.len();
1068 let mut winding = 0i32;
1069 for i in 0..n {
1070 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
1071 let side = (b - a).cross(q - a);
1072 if a.y <= q.y {
1073 if b.y > q.y && side > 0.0 {
1074 winding += 1;
1075 }
1076 } else if b.y <= q.y && side < 0.0 {
1077 winding -= 1;
1078 }
1079 }
1080 winding != 0
1081}
1082
1083fn constraint_points(
1086 model: &Model,
1087 constraints: &[Shape],
1088 tol: Tolerances,
1089) -> OgeomResult<Vec<(Point, f64)>> {
1090 let mut out = Vec::new();
1091 for (k, shape) in constraints.iter().enumerate() {
1092 let placement = shape.transform(model.datums())?;
1093 match model.kind_of(shape)? {
1094 ShapeType::Vertex => {
1095 let Some(data) = model.node(shape).and_then(|n| n.data().as_vertex()) else {
1096 ogeom_bail!(Construction, "constraint {k} holds no vertex data");
1097 };
1098 out.push((placement.apply(data.point), POINT_SHARE));
1099 }
1100 ShapeType::Edge => {
1101 let Some(data) = model.node(shape).and_then(|n| n.data().as_edge()) else {
1102 ogeom_bail!(Construction, "constraint {k} holds no edge data");
1103 };
1104 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1105 ogeom_bail!(Construction, "constraint {k} has no 3D curve");
1106 };
1107 let Some(curve) = model.geometry().curve(*curve) else {
1108 ogeom_bail!(Dangling, "constraint {k}'s curve is not in this model");
1109 };
1110 for i in 0..=PER_CURVE {
1111 #[expect(
1112 clippy::cast_precision_loss,
1113 reason = "a sample index, far below the mantissa"
1114 )]
1115 let f = i as f64 / PER_CURVE as f64;
1116 let t = (range.1 - range.0).mul_add(f, range.0);
1117 out.push((placement.apply(curve.point_at(t, tol)?), POINT_SHARE / 4.0));
1118 }
1119 }
1120 other => ogeom_bail!(
1121 Construction,
1122 "constraint {k} is a {other:?}; a filling passes through vertices and edges"
1123 ),
1124 }
1125 }
1126 Ok(out)
1127}
1128
1129fn untrimmed(curve: &Curve) -> &Curve {
1131 match curve {
1132 Curve::Trimmed(t) if !t.is_reversed() => untrimmed(t.basis()),
1133 other => other,
1134 }
1135}
1136
1137fn trace(
1142 side: &Side,
1143 frame: &PlaneFrame,
1144 tolerance: f64,
1145 tol: Tolerances,
1146) -> OgeomResult<PlanarCurve> {
1147 let truth = |t: f64| -> OgeomResult<Point2> { Ok(frame.chart(side.curve.point_at(t, tol)?)) };
1148 let (t0, t1) = side.range;
1149 let exact: Option<PlanarCurve> = match untrimmed(&side.curve) {
1150 Curve::BSpline(spline) => {
1151 let mut control = Vec::with_capacity(spline.control_points().len());
1152 for w in spline.control_points() {
1153 control.push(Weighted::new(frame.chart(w.point()), w.weight, tol)?);
1154 }
1155 BSpline2d::rational(spline.knots().clone(), control)
1156 .ok()
1157 .map(PlanarCurve::BSpline)
1158 }
1159 _ => match side.curve.kind() {
1160 CurveKind::Line => {
1161 let (q0, q1) = (truth(t0)?, truth(t1)?);
1162 let d = (q1 - q0) * (1.0 / (t1 - t0));
1163 let c = q0 - d * t0;
1164 Trig2d::new(c, d, Vector2::ZERO, Vector2::ZERO, side.range)
1165 .ok()
1166 .map(PlanarCurve::Trig)
1167 }
1168 CurveKind::Circle | CurveKind::Ellipse => {
1169 let ts = [
1172 t0,
1173 (t1 - t0).mul_add(1.0 / 3.0, t0),
1174 (t1 - t0).mul_add(2.0 / 3.0, t0),
1175 ];
1176 let rows = ts.map(|t| [1.0, t.cos(), t.sin()]);
1177 let values = [truth(ts[0])?, truth(ts[1])?, truth(ts[2])?];
1178 match (
1179 solve3(rows, values.map(|q| q.x)),
1180 solve3(rows, values.map(|q| q.y)),
1181 ) {
1182 (Some(x), Some(y)) => Trig2d::new(
1183 Point2::new(x[0], y[0]),
1184 Vector2::ZERO,
1185 Vector2::new(x[1], y[1]),
1186 Vector2::new(x[2], y[2]),
1187 side.range,
1188 )
1189 .ok()
1190 .map(PlanarCurve::Trig),
1191 _ => None,
1192 }
1193 }
1194 _ => None,
1195 },
1196 };
1197 if let Some(curve) = exact {
1198 let mut worst = 0.0f64;
1199 let mut scale = 1.0f64;
1200 for k in 0..=32 {
1201 let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
1202 let q = truth(t)?;
1203 scale = scale.max(q.to_vector().magnitude());
1204 worst = worst.max(curve.point_at(t, tol)?.distance(q));
1205 }
1206 if worst <= 1e-12 * scale + 1e-3 * tol.confusion() {
1207 return Ok(curve);
1208 }
1209 }
1210 const SAMPLES: usize = 96;
1211 let mut parameters = Vec::with_capacity(SAMPLES + 1);
1212 let mut points = Vec::with_capacity(SAMPLES + 1);
1213 for k in 0..=SAMPLES {
1214 #[expect(
1215 clippy::cast_precision_loss,
1216 reason = "a sample index, far below the mantissa"
1217 )]
1218 let t = (t1 - t0).mul_add(k as f64 / SAMPLES as f64, t0);
1219 parameters.push(t);
1220 points.push(truth(t)?);
1221 }
1222 let fitted = ogeom_geom::fit::fit_points_2d_at(¶meters, &points, 3, tolerance * 1e-2, tol)?;
1223 Ok(PlanarCurve::BSpline(fitted.curve))
1224}
1225
1226fn solve3(rows: [[f64; 3]; 3], rhs: [f64; 3]) -> Option<[f64; 3]> {
1228 let det = |m: [[f64; 3]; 3]| {
1229 m[0][0] * m[1][1].mul_add(m[2][2], -m[1][2] * m[2][1])
1230 - m[0][1] * m[1][0].mul_add(m[2][2], -m[1][2] * m[2][0])
1231 + m[0][2] * m[1][0].mul_add(m[2][1], -m[1][1] * m[2][0])
1232 };
1233 let d = det(rows);
1234 if d.abs() < 1e-12 {
1235 return None;
1236 }
1237 let mut out = [0.0; 3];
1238 for (c, slot) in out.iter_mut().enumerate() {
1239 let mut m = rows;
1240 for r in 0..3 {
1241 m[r][c] = rhs[r];
1242 }
1243 *slot = det(m) / d;
1244 }
1245 Some(out)
1246}
1247
1248fn smoothing_for(sides: &[Side], tolerance: f64, size: f64) -> f64 {
1276 let order = sides.iter().map(|s| s.order).max().unwrap_or(0);
1277 let mut eps = tolerance / size;
1278 if order >= 1 {
1279 eps = eps.min(tolerance);
1280 }
1281 if order >= 2 {
1282 eps = eps.min(tolerance * size);
1283 }
1284 (BUDGET * eps).powi(2).max(LEAST_SMOOTHING)
1285}
1286
1287fn side_length(side: &Side, tol: Tolerances) -> OgeomResult<f64> {
1289 let mut length = 0.0;
1290 let mut previous = side.curve.point_at(side.range.0, tol)?;
1291 for k in 1..=OUTLINE_SAMPLES {
1292 #[expect(
1293 clippy::cast_precision_loss,
1294 reason = "a sample index, far below the mantissa"
1295 )]
1296 let f = k as f64 / OUTLINE_SAMPLES as f64;
1297 let p = side.curve.point_at(side.parameter(f), tol)?;
1298 length += p.distance(previous);
1299 previous = p;
1300 }
1301 Ok(length)
1302}
1303
1304fn side_conditions(
1309 side: &Side,
1310 share: f64,
1311 size: f64,
1312 samples: usize,
1313 frame: &PlaneFrame,
1314 out: &mut Vec<Condition<1>>,
1315 tol: Tolerances,
1316) -> OgeomResult<()> {
1317 #[expect(
1318 clippy::cast_precision_loss,
1319 reason = "a sample count, far below the mantissa"
1320 )]
1321 let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1322 for k in 0..=samples {
1323 #[expect(
1324 clippy::cast_precision_loss,
1325 reason = "a sample index, far below the mantissa"
1326 )]
1327 let t = side.parameter(k as f64 / samples as f64);
1328 let p = side.curve.point_at(t, tol)?;
1329 let at = frame.chart(p);
1330 out.push(Condition::partial(
1331 at,
1332 (0, 0),
1333 [frame.height(p)],
1334 root / size,
1335 ));
1336 if side.order == 0 {
1337 continue;
1338 }
1339 let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1340 continue;
1341 };
1342 let lift = normal.dot(frame.n);
1343 if lift.abs() < MIN_LIFT {
1344 ogeom_bail!(
1345 Construction,
1346 "side {}'s support stands at {:.1} degrees to the plane the \
1347 boundary spans, past the {:.1} a height over that plane takes",
1348 side.entry,
1349 lift.abs().acos().to_degrees(),
1350 MIN_LIFT.acos().to_degrees()
1351 );
1352 }
1353 let normal = if lift < 0.0 { normal * -1.0 } else { normal };
1356 let lift = lift.abs();
1357 let (hu, hv) = (-normal.dot(frame.e1) / lift, -normal.dot(frame.e2) / lift);
1358 out.push(Condition::partial(at, (1, 0), [hu], root));
1359 out.push(Condition::partial(at, (0, 1), [hv], root));
1360 if side.order < 2 {
1361 continue;
1362 }
1363 let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1367 let sense = if curvature.normal.dot_vector(normal) < 0.0 {
1368 -1.0
1369 } else {
1370 1.0
1371 };
1372 let (dmax, dmin) = (
1373 curvature.max_direction.vector(),
1374 curvature.min_direction.vector(),
1375 );
1376 let second = |a: Vector, b: Vector| {
1377 sense
1378 * curvature.max.mul_add(
1379 a.dot(dmax) * b.dot(dmax),
1380 curvature.min * a.dot(dmin) * b.dot(dmin),
1381 )
1382 / lift
1383 };
1384 let su = frame.e1 + frame.n * hu;
1385 let sv = frame.e2 + frame.n * hv;
1386 for (order, target) in [
1387 ((2, 0), second(su, su)),
1388 ((1, 1), second(su, sv)),
1389 ((0, 2), second(sv, sv)),
1390 ] {
1391 out.push(Condition::partial(at, order, [target], root * size));
1392 }
1393 }
1394 Ok(())
1395}
1396
1397fn free_conditions(
1401 side: &Side,
1402 pcurve: &PlanarCurve,
1403 share: f64,
1404 size: f64,
1405 samples: usize,
1406 out: &mut Vec<Condition<3>>,
1407 tol: Tolerances,
1408) -> OgeomResult<()> {
1409 #[expect(
1410 clippy::cast_precision_loss,
1411 reason = "a sample count, far below the mantissa"
1412 )]
1413 let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1414 for k in 0..=samples {
1415 #[expect(
1416 clippy::cast_precision_loss,
1417 reason = "a sample index, far below the mantissa"
1418 )]
1419 let t = side.parameter(k as f64 / samples as f64);
1420 let p = side.curve.point_at(t, tol)?;
1421 let at = pcurve.point_at(t, tol)?;
1422 out.push(Condition::partial(at, (0, 0), [p.x, p.y, p.z], root / size));
1423 if side.order == 0 {
1424 continue;
1425 }
1426 let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1427 continue;
1428 };
1429 let Some(material) = support.material else {
1430 ogeom_bail!(
1431 Construction,
1432 "side {}'s support uses its edge both ways or neither, so which \
1433 side of it the filling leaves from is not known",
1434 side.entry
1435 );
1436 };
1437 let d1 = side.curve.d1_at(t, tol)?;
1442 let c1 = pcurve.d1_at(t, tol)?;
1443 let walk = if side.reversed { -1.0 } else { 1.0 };
1444 let speed = c1.magnitude();
1445 if speed <= f64::MIN_POSITIVE {
1446 continue;
1447 }
1448 let along = c1 * (walk / speed);
1449 let outward = Vector2::new(along.y, -along.x);
1450 let into = normal.cross(d1) * material;
1451 let Ok(into) = Direction::new(into, tol) else {
1452 continue;
1453 };
1454 let into = into.vector();
1455 let ribbon = into * (d1.magnitude() / speed);
1456 out.push(Condition::along(
1457 at,
1458 outward,
1459 false,
1460 [ribbon.x, ribbon.y, ribbon.z],
1461 root,
1462 ));
1463 if side.order < 2 {
1464 continue;
1465 }
1466 let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1471 let Some(bend) = curvature.normal_curvature(into) else {
1472 continue;
1473 };
1474 let second = curvature.normal.vector() * (bend * ribbon.dot(ribbon));
1475 out.push(Condition::along(
1476 at,
1477 outward,
1478 true,
1479 [second.x, second.y, second.z],
1480 root * size,
1481 ));
1482 }
1483 Ok(())
1484}
1485
1486fn steepest_support(
1490 sides: &[Side],
1491 frame: &PlaneFrame,
1492 tol: Tolerances,
1493) -> OgeomResult<Option<(usize, f64)>> {
1494 let mut steepest: Option<(usize, f64)> = None;
1495 for side in sides.iter().filter(|s| s.order > 0) {
1496 for k in 0..=STEEP_SAMPLES {
1497 #[expect(
1498 clippy::cast_precision_loss,
1499 reason = "a sample index, far below the mantissa"
1500 )]
1501 let t = side.parameter(k as f64 / STEEP_SAMPLES as f64);
1502 let Some((_, normal)) = side.support_at(t, tol)? else {
1503 continue;
1504 };
1505 let lift = normal.dot(frame.n).abs();
1506 if steepest.is_none_or(|(_, least)| lift < least) {
1507 steepest = Some((side.entry, lift));
1508 }
1509 }
1510 }
1511 Ok(steepest)
1512}
1513
1514fn tangent_corners(
1520 sides: &[Side],
1521 order: &[usize],
1522 corners: &[(Vector, Vector, f64)],
1523 tolerance: f64,
1524 tol: Tolerances,
1525) -> OgeomResult<()> {
1526 for (k, (arrive, leave, _)) in corners.iter().enumerate() {
1527 let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % order.len()]]);
1528 let end = |side: &Side, last: bool| {
1529 if last == side.reversed {
1530 side.range.0
1531 } else {
1532 side.range.1
1533 }
1534 };
1535 for (side, at, other, tangent) in
1536 [(a, end(a, true), b, *leave), (b, end(b, false), a, *arrive)]
1537 {
1538 if side.order == 0 {
1539 continue;
1540 }
1541 let Some((_, normal)) = side.support_at(at, tol)? else {
1542 continue;
1543 };
1544 let off = normal.dot(tangent).abs().min(1.0).asin();
1545 if off > tolerance {
1546 ogeom_bail!(
1547 Construction,
1548 "sides {} and {} meet at a corner no surface is tangent to \
1549 both supports at: side {}'s support stands {:.1} degrees off \
1550 side {}'s tangent there, past the tolerance of {tolerance} \
1551 radians",
1552 side.entry,
1553 other.entry,
1554 side.entry,
1555 off.to_degrees(),
1556 other.entry,
1557 );
1558 }
1559 }
1560 }
1561 Ok(())
1562}
1563
1564fn traced_outline(
1566 sides: &[Side],
1567 order: &[usize],
1568 traces: &[PlanarCurve],
1569 tol: Tolerances,
1570) -> OgeomResult<Vec<Point2>> {
1571 let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
1572 for &i in order {
1573 let side = &sides[i];
1574 for k in 0..OUTLINE_SAMPLES {
1575 #[expect(
1576 clippy::cast_precision_loss,
1577 reason = "a sample index, far below the mantissa"
1578 )]
1579 let mut f = k as f64 / OUTLINE_SAMPLES as f64;
1580 if side.reversed {
1581 f = 1.0 - f;
1582 }
1583 out.push(traces[i].point_at(side.parameter(f), tol)?);
1584 }
1585 }
1586 Ok(out)
1587}
1588
1589fn folds(
1593 surface: &BSplineSurface,
1594 outline: &[Point2],
1595 domain: ((f64, f64), (f64, f64)),
1596 tol: Tolerances,
1597) -> OgeomResult<bool> {
1598 const GRID: usize = 40;
1599 let ((ua, ub), (va, vb)) = domain;
1600 let mut normals = vec![None; (GRID + 1) * (GRID + 1)];
1601 let mut largest = 0.0f64;
1602 for i in 0..=GRID {
1603 for j in 0..=GRID {
1604 #[expect(
1605 clippy::cast_precision_loss,
1606 reason = "a grid index, far below the mantissa"
1607 )]
1608 let q = Point2::new(
1609 (ub - ua).mul_add(i as f64 / GRID as f64, ua),
1610 (vb - va).mul_add(j as f64 / GRID as f64, va),
1611 );
1612 if !inside(outline, q) {
1613 continue;
1614 }
1615 let (du, dv) = surface.d1_at(q.x, q.y, tol)?;
1616 let normal = du.cross(dv);
1617 largest = largest.max(normal.magnitude());
1618 normals[i * (GRID + 1) + j] = Some(normal);
1619 }
1620 }
1621 for i in 0..=GRID {
1622 for j in 0..=GRID {
1623 let Some(here) = normals[i * (GRID + 1) + j] else {
1624 continue;
1625 };
1626 if here.magnitude() <= 1e-6 * largest {
1627 return Ok(true);
1628 }
1629 let right = if i < GRID {
1630 normals[(i + 1) * (GRID + 1) + j]
1631 } else {
1632 None
1633 };
1634 let up = if j < GRID {
1635 normals[i * (GRID + 1) + j + 1]
1636 } else {
1637 None
1638 };
1639 if [right, up]
1640 .into_iter()
1641 .flatten()
1642 .any(|n| n.dot(here) <= 0.0)
1643 {
1644 return Ok(true);
1645 }
1646 }
1647 }
1648 Ok(false)
1649}
1650
1651fn measure_side(
1656 side: &Side,
1657 pcurve: &PlanarCurve,
1658 surface: &BSplineSurface,
1659 stations: usize,
1660 tol: Tolerances,
1661) -> OgeomResult<FillSide> {
1662 let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, None::<f64>);
1663 for k in 0..stations {
1664 #[expect(
1665 clippy::cast_precision_loss,
1666 reason = "a station index, far below the mantissa"
1667 )]
1668 let t = side.parameter(k as f64 / (stations - 1) as f64);
1669 let p = side.curve.point_at(t, tol)?;
1670 let uv = pcurve.point_at(t, tol)?;
1671 gap = gap.max(surface.point_at(uv.x, uv.y, tol)?.distance(p));
1672 let (Some((suv, theirs)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1673 continue;
1674 };
1675 let ours = surface.normal_at(uv.x, uv.y, tol)?.vector();
1676 angle = angle.max(ours.cross(theirs).magnitude().atan2(ours.dot(theirs).abs()));
1677 let across = ours.cross(side.curve.d1_at(t, tol)?);
1678 let signed = |c: SurfaceCurvature| -> Option<f64> {
1679 let k = c.normal_curvature(across)?;
1680 Some(if c.normal.dot_vector(ours) < 0.0 {
1681 -k
1682 } else {
1683 k
1684 })
1685 };
1686 let a = surface.curvature_at(uv.x, uv.y, tol).ok().and_then(signed);
1687 let b = support
1688 .surface
1689 .curvature_at(suv.x, suv.y, tol)
1690 .ok()
1691 .and_then(signed);
1692 if let (Some(a), Some(b)) = (a, b) {
1693 curvature = Some(curvature.unwrap_or(0.0).max((a - b).abs()));
1694 }
1695 }
1696 let supported = side.support.is_some();
1697 Ok(FillSide {
1698 edge: side.edge.clone(),
1699 gap,
1700 angle: supported.then_some(angle),
1701 curvature: curvature.filter(|_| supported),
1702 stations,
1703 })
1704}
1705
1706fn first_miss(
1709 sides: &[Side],
1710 reports: &[FillSide],
1711 constraint_gap: f64,
1712 tolerance: f64,
1713) -> Option<String> {
1714 for (side, report) in sides.iter().zip(reports) {
1715 let i = side.entry;
1716 if report.gap > tolerance {
1717 return Some(format!("side {i} stands {} off its edge", report.gap));
1718 }
1719 if side.order >= 1 {
1720 let angle = report.angle.unwrap_or(f64::INFINITY);
1721 if angle > tolerance {
1722 return Some(format!(
1723 "side {i} meets its support {angle} radians from tangent"
1724 ));
1725 }
1726 }
1727 if side.order >= 2 {
1728 match report.curvature {
1729 Some(c) if c <= tolerance => {}
1730 Some(c) => {
1731 return Some(format!(
1732 "side {i}'s normal curvature differs from its support's by {c}"
1733 ));
1734 }
1735 None => {
1736 return Some(format!(
1737 "side {i}'s curvature could not be read on both surfaces"
1738 ));
1739 }
1740 }
1741 }
1742 }
1743 (constraint_gap > tolerance)
1744 .then(|| format!("a constraint stands {constraint_gap} off the surface"))
1745}
1746
1747fn build(
1751 model: &mut Model,
1752 sides: &mut [Side],
1753 order: &[usize],
1754 traces: &[PlanarCurve],
1755 reports: &[FillSide],
1756 surface: BSplineSurface,
1757 tol: Tolerances,
1758) -> OgeomResult<Shape> {
1759 stand_in(model, sides, order, tol)?;
1760 let walk: Vec<Shape> = order
1761 .iter()
1762 .map(|&i| {
1763 let side = &sides[i];
1764 if side.reversed {
1765 side.edge.reversed()
1766 } else {
1767 side.edge.clone()
1768 }
1769 })
1770 .collect();
1771 let wire = make_wire(model, &walk, tol)?.shape;
1772 let face = make_face(model, SurfaceGeometry::BSpline(surface), &[wire], tol)?.shape;
1773 let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
1774 ogeom_bail!(Dangling, "the face just built is not in this model");
1775 };
1776 let surface_id = data.surface;
1777 for ((side, pcurve), report) in sides.iter().zip(traces).zip(reports) {
1778 attach_pcurve(
1779 model,
1780 &side.edge,
1781 pcurve.clone(),
1782 surface_id,
1783 Location::identity(),
1784 side.range,
1785 )?;
1786 if report.gap > side.edge_tolerance {
1787 let widened = Tolerance::new(report.gap + tol.confusion())?;
1789 model.widen(&side.edge, widened)?;
1790 if let Some((a, b)) = edge_vertices(model, &side.edge)? {
1791 model.widen(&a, widened)?;
1792 model.widen(&b, widened)?;
1793 }
1794 }
1795 }
1796 Ok(face)
1797}
1798
1799fn stand_in(
1806 model: &mut Model,
1807 sides: &mut [Side],
1808 order: &[usize],
1809 tol: Tolerances,
1810) -> OgeomResult<()> {
1811 let n = order.len();
1812 let mut corners: Vec<(Shape, bool)> = Vec::with_capacity(n);
1815 for k in 0..n {
1816 let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % n]]);
1817 let (a0, a1) = ends_of(model, a)?;
1818 let (b0, b1) = ends_of(model, b)?;
1819 let (arrive, leave) = (
1820 if a.reversed { a0 } else { a1 },
1821 if b.reversed { b1 } else { b0 },
1822 );
1823 let (from, to) = (
1824 a.curve
1825 .point_at(if a.reversed { a.range.0 } else { a.range.1 }, tol)?,
1826 b.curve
1827 .point_at(if b.reversed { b.range.1 } else { b.range.0 }, tol)?,
1828 );
1829 if arrive.vertex.is_same(&leave.vertex) && arrive.vertex.location().is_identity() {
1830 corners.push((arrive.vertex, true));
1831 continue;
1832 }
1833 let vertex = make_vertex(model, from.midpoint(to)).shape;
1834 let reach = (0.5 * from.distance(to) + tol.confusion())
1835 .max(a.edge_tolerance)
1836 .max(b.edge_tolerance);
1837 model.widen(&vertex, Tolerance::new(reach)?)?;
1838 corners.push((vertex, false));
1839 }
1840 for k in 0..n {
1841 let side = &sides[order[k]];
1842 let (start, end) = (&corners[(k + n - 1) % n], &corners[k]);
1843 if !side.placed && start.1 && end.1 {
1844 continue;
1845 }
1846 let (from, to) = if side.reversed {
1847 (&end.0, &start.0)
1848 } else {
1849 (&start.0, &end.0)
1850 };
1851 let edge = make_edge_between(model, side.curve.clone(), side.range, from, to, tol)?.shape;
1852 model.widen(&edge, Tolerance::new(side.edge_tolerance)?)?;
1853 sides[order[k]].edge = edge;
1854 }
1855 Ok(())
1856}