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, Factor, PlaneFrame, fit_free, fit_height_with};
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 FURTHER: [usize; 2] = [90, 135];
90const BUDGET: f64 = 0.1;
93const LEAST_SMOOTHING: f64 = 1e-12;
96const MIN_LIFT: f64 = 0.1;
100const POINT_SHARE: f64 = 0.05;
103const PER_CURVE: usize = 32;
105const KEEP: f64 = 0.25;
109const STEEP_SAMPLES: usize = 256;
112const CHART_SAMPLES: usize = 128;
114const MOST_TURN: f64 = 0.9;
116const CORNER: f64 = 0.0175;
119
120pub fn make_filling_n(
196 model: &mut Model,
197 boundary: &[FillBoundary],
198 constraints: &[Shape],
199 tolerance: f64,
200 tol: Tolerances,
201) -> OgeomResult<Filled> {
202 make_filling_n_within(model, boundary, constraints, tolerance, &[], tol)
203}
204
205pub(crate) fn make_filling_n_within(
213 model: &mut Model,
214 boundary: &[FillBoundary],
215 constraints: &[Shape],
216 tolerance: f64,
217 within: &[(Vector, Point)],
218 tol: Tolerances,
219) -> OgeomResult<Filled> {
220 if within.is_empty() {
221 return filling(model, boundary, constraints, tolerance, None, tol);
222 }
223 let region = |p: Point| -> (f64, Vector) {
226 let mut worst = (f64::NEG_INFINITY, within[0].0);
227 for (normal, on) in within {
228 let d = normal.dot(p - *on);
229 if d > worst.0 {
230 worst = (d, *normal);
231 }
232 }
233 worst
234 };
235 make_filling_n_region(model, boundary, constraints, tolerance, ®ion, tol)
236}
237
238pub(crate) type Region<'a> = &'a (dyn Fn(Point) -> (f64, Vector) + Sync);
242
243pub(crate) fn make_filling_n_region(
248 model: &mut Model,
249 boundary: &[FillBoundary],
250 constraints: &[Shape],
251 tolerance: f64,
252 region: Region<'_>,
253 tol: Tolerances,
254) -> OgeomResult<Filled> {
255 filling(model, boundary, constraints, tolerance, Some(region), tol)
256}
257
258pub(crate) fn make_filling_n_fixed(
270 model: &mut Model,
271 boundary: &[FillBoundary],
272 constraints: &[Shape],
273 controls: usize,
274 region: Option<Region<'_>>,
275 tol: Tolerances,
276) -> OgeomResult<(Filled, f64)> {
277 let tolerance = crate::marched::band_fit_target(tol);
278 filling_with(
279 model,
280 boundary,
281 constraints,
282 tolerance,
283 region,
284 Some(controls),
285 tol,
286 )
287}
288
289fn filling(
291 model: &mut Model,
292 boundary: &[FillBoundary],
293 constraints: &[Shape],
294 tolerance: f64,
295 within: Option<Region<'_>>,
296 tol: Tolerances,
297) -> OgeomResult<Filled> {
298 Ok(filling_with(model, boundary, constraints, tolerance, within, None, tol)?.0)
299}
300
301#[allow(clippy::too_many_lines, reason = "one filling, read, fitted and built")]
305fn filling_with(
306 model: &mut Model,
307 boundary: &[FillBoundary],
308 constraints: &[Shape],
309 tolerance: f64,
310 within: Option<Region<'_>>,
311 fixed: Option<usize>,
312 tol: Tolerances,
313) -> OgeomResult<(Filled, f64)> {
314 if !(tolerance.is_finite() && tolerance > 0.0) {
315 ogeom_bail!(
316 Construction,
317 "a filling's tolerance is positive and finite; got {tolerance}"
318 );
319 }
320 if boundary.is_empty() {
321 ogeom_bail!(Construction, "a filling needs at least one boundary edge");
322 }
323 let mut sides = Vec::with_capacity(boundary.len());
324 for (i, entry) in boundary.iter().enumerate() {
325 sides.push(read_side(model, i, entry, tolerance, tol)?);
326 }
327 for i in 0..sides.len() {
328 for j in (i + 1)..sides.len() {
329 if sides[i].given.is_same(&sides[j].given) {
330 ogeom_bail!(Construction, "side {j} is side {i}'s edge again");
331 }
332 }
333 }
334 let mut order = chain(model, &mut sides, tol)?;
335 face_the_supports(model, &mut sides, &mut order)?;
336
337 let outline = loop_points(&sides, &order, tol)?;
339 let frame = frame_of(&outline, tol)?;
340 let chart_outline: Vec<Point2> = outline.iter().map(|p| frame.chart(*p)).collect();
341 if crosses_itself(&chart_outline) {
342 ogeom_bail!(
343 Construction,
344 "the boundary loop crosses itself seen along the normal of the \
345 plane it spans; the hole is not a height field over that plane"
346 );
347 }
348 let interior = constraint_points(model, constraints, tol)?;
349 for (k, (p, _)) in interior.iter().enumerate() {
350 if !inside(&chart_outline, frame.chart(*p)) {
351 ogeom_bail!(
352 Construction,
353 "a point of constraint {k} at {p:?} lies outside the hole seen \
354 along the normal of the plane the boundary spans"
355 );
356 }
357 }
358
359 let corners = corners_of(&sides, &order, tol)?;
366 tangent_corners(&sides, &order, &corners, tolerance, tol)?;
367 let steepest = steepest_support(&sides, &frame, tol)?;
368 let free =
369 kept_turn(frame.n, &corners) < KEEP || steepest.is_some_and(|(_, lift)| lift < MIN_LIFT);
370 if free && !interior.is_empty() {
371 let why = match steepest {
372 Some((i, lift)) if lift < MIN_LIFT => format!(
373 "side {i}'s support stands at {:.1} degrees to the plane the \
374 boundary spans, past the {:.1} a height over that plane takes",
375 lift.acos().to_degrees(),
376 MIN_LIFT.acos().to_degrees()
377 ),
378 _ => "a corner of the boundary loop is seen smooth along the normal \
379 of the plane it spans"
380 .to_owned(),
381 };
382 ogeom_bail!(
383 Construction,
384 "{why}, so the filling is drawn over a chart of the loop's own, \
385 which places no interior constraints"
386 );
387 }
388 let mut traces = Vec::with_capacity(sides.len());
389 let mut lengths = Vec::with_capacity(sides.len());
390 for side in &sides {
391 if !free {
392 traces.push(trace(side, &frame, tolerance, tol)?);
393 }
394 lengths.push(side_length(side, tol)?);
395 }
396 let chart_outline = if free {
397 traces = boundary_chart(&sides, &order, &corners, tolerance, tol)?;
398 traced_outline(&sides, &order, &traces, tol)?
399 } else {
400 chart_outline
401 };
402
403 let (mut lo, mut hi) = (
405 Point2::new(f64::INFINITY, f64::INFINITY),
406 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
407 );
408 for q in &chart_outline {
409 lo = Point2::new(lo.x.min(q.x), lo.y.min(q.y));
410 hi = Point2::new(hi.x.max(q.x), hi.y.max(q.y));
411 }
412 let margin = MARGIN * (hi.x - lo.x).max(hi.y - lo.y);
413 let domain = (
414 (lo.x - margin, hi.x + margin),
415 (lo.y - margin, hi.y + margin),
416 );
417 let (du, dv) = (domain.0.1 - domain.0.0, domain.1.1 - domain.1.0);
418 let size = du.max(dv);
419
420 let smoothing = smoothing_for(&sides, tolerance, size);
421 let mut last_miss = String::new();
422 let mut fallback = None;
424 let mut sides_met: Option<usize> = None;
427 let mut first_met: Option<FirstMet> = None;
428 let nets: Vec<usize> = match fixed {
429 Some(controls) => vec![controls],
430 None => NETS.into_iter().chain(FURTHER).collect(),
431 };
432 let mut region_past = f64::NEG_INFINITY;
433 for (round, base) in nets.into_iter().enumerate() {
434 if round >= NETS.len() && fallback.is_some() {
435 break;
436 }
437 #[expect(
438 clippy::cast_possible_truncation,
439 clippy::cast_sign_loss,
440 clippy::cast_precision_loss,
441 reason = "a control count of a few dozen, from a positive ratio"
442 )]
443 let count = |extent: f64| -> usize {
444 ((base as f64 * extent / size).round() as usize).max(DEGREE + 2)
445 };
446 let controls = (count(du), count(dv));
447 let samples = 4 * controls.0.max(controls.1) + 8;
448
449 let mut factor = None;
450 let fit = if free {
451 let mut conditions = Vec::new();
452 for ((side, length), pcurve) in sides.iter().zip(&lengths).zip(&traces) {
453 free_conditions(
454 side,
455 pcurve,
456 *length / size,
457 size,
458 samples,
459 &mut conditions,
460 tol,
461 )?;
462 }
463 fit_free(domain, controls, &conditions, smoothing, tol)?
464 } else {
465 let mut conditions = Vec::new();
466 for (side, length) in sides.iter().zip(&lengths) {
467 side_conditions(
468 side,
469 *length / size,
470 size,
471 samples,
472 &frame,
473 &mut conditions,
474 tol,
475 )?;
476 }
477 for (p, share) in &interior {
478 conditions.push(Condition::partial(
479 frame.chart(*p),
480 (0, 0),
481 [frame.height(*p)],
482 share.sqrt() / size,
483 ));
484 }
485 let (fit, kept) =
486 fit_height_with(&frame, domain, controls, &conditions, smoothing, None, tol)?;
487 factor = Some(kept);
488 fit
489 };
490 let surface = fit.surface;
491 let reports = measure_sides(&sides, &traces, &surface, 3 * samples + 1, tol)?;
492 let mut constraint_gap = 0.0f64;
493 for (p, _) in &interior {
494 let q = frame.chart(*p);
495 constraint_gap = constraint_gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
496 }
497
498 if let Some(miss) = first_miss(&sides, &reports, constraint_gap, tolerance) {
499 last_miss = format!("at {}x{} controls, {miss}", controls.0, controls.1);
500 if fixed.is_none() {
501 continue;
502 }
503 }
504 if sides_met.is_none() {
505 sides_met = Some(round);
506 if let (Some(_), Some(factor)) = (within, factor.take()) {
507 first_met = Some((surface.clone(), reports.clone(), constraint_gap, factor));
508 }
509 }
510 if free && folds(&surface, &chart_outline, domain, tol)? {
511 last_miss = format!(
512 "at {}x{} controls, the patch folds over inside the hole",
513 controls.0, controls.1
514 );
515 continue;
516 }
517 if let Some(region) = within {
518 let (past, at) = past_planes(&surface, &chart_outline, domain, region, tol)?;
519 region_past = past;
520 if past > tolerance && fixed.is_none() {
521 last_miss = format!(
522 "at {}x{} controls, the patch stands {past} past a plane it is \
523 held within, at {at:?}",
524 controls.0, controls.1
525 );
526 continue;
527 }
528 }
529 fallback = Some((surface, reports, constraint_gap));
530 if fit.residual <= smoothing {
532 break;
533 }
534 }
535 if let (None, false, Some(region), Some(from), None) =
539 (&fallback, free, within, sides_met, fixed)
540 {
541 let held = HeldFit {
542 sides: &sides,
543 lengths: &lengths,
544 traces: &traces,
545 frame: &frame,
546 domain,
547 size,
548 interior: &interior,
549 smoothing,
550 tolerance,
551 region,
552 outline: &chart_outline,
553 first: first_met.as_ref(),
554 };
555 let mut repeat = Vec::new();
559 match held.fit(from, false, &mut repeat, tol)? {
560 Ok(found) => fallback = Some(found),
561 Err(miss) => match held.fit(from, true, &mut repeat, tol)? {
562 Ok(found) => fallback = Some(found),
563 Err(firm) => {
564 last_miss =
565 format!("{last_miss}; held within its planes, {miss}; held firmer, {firm}");
566 }
567 },
568 }
569 }
570 let Some((surface, reports, constraint_gap)) = fallback else {
571 ogeom_bail!(
572 NotDone,
573 "the filling misses its tolerance of {tolerance} on the finest net: {last_miss}"
574 )
575 };
576 let face = build(model, &mut sides, &order, &traces, &reports, surface, tol)?;
577 let mut history = History::new();
578 let mut reports = reports;
579 for (side, report) in sides.iter().zip(&mut reports) {
580 history.generate(&side.given, face.clone());
581 if !side.edge.is_same(&side.given) {
582 history.generate(&side.given, side.edge.clone());
583 }
584 report.edge = side.edge.clone();
585 }
586 for constraint in constraints {
587 history.generate(constraint, face.clone());
588 }
589 Ok((
590 Filled {
591 built: Built::new(face, history),
592 sides: reports,
593 constraint_gap,
594 },
595 region_past,
596 ))
597}
598
599struct Support {
601 face: Shape,
602 surface: SurfaceGeometry,
603 pcurve: PlanarCurve,
604 prange: (f64, f64),
605 material: Option<f64>,
610}
611
612struct Side {
614 entry: usize,
615 given: Shape,
617 edge: Shape,
620 placed: bool,
622 curve: Curve,
624 range: (f64, f64),
625 edge_tolerance: f64,
626 order: usize,
628 support: Option<Support>,
629 reversed: bool,
631}
632
633impl Side {
634 fn parameter(&self, f: f64) -> f64 {
635 (self.range.1 - self.range.0).mul_add(f, self.range.0)
636 }
637
638 fn support_at(&self, t: f64, tol: Tolerances) -> OgeomResult<Option<(Point2, Vector)>> {
641 let Some(support) = &self.support else {
642 return Ok(None);
643 };
644 let f = (t - self.range.0) / (self.range.1 - self.range.0);
645 let pt = (support.prange.1 - support.prange.0).mul_add(f, support.prange.0);
646 let uv = support.pcurve.point_at(pt, tol)?;
647 let normal = support.surface.normal_at(uv.x, uv.y, tol)?.vector();
648 Ok(Some((uv, normal)))
649 }
650}
651
652fn read_side(
653 model: &Model,
654 i: usize,
655 entry: &FillBoundary,
656 tolerance: f64,
657 tol: Tolerances,
658) -> OgeomResult<Side> {
659 let edge = &entry.edge;
660 if model.kind_of(edge)? != ShapeType::Edge {
661 ogeom_bail!(Construction, "side {i} is not an edge");
662 }
663 let order = match entry.continuity {
664 Continuity::C0 => 0,
665 Continuity::G1 => 1,
666 Continuity::G2 => 2,
667 other => ogeom_bail!(
668 Construction,
669 "side {i} asks {other:?}: parametric continuity between two \
670 surfaces' charts is not what a filling meets; ask for G1 or G2"
671 ),
672 };
673 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
674 ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
675 };
676 let Some(EdgeRepr::Curve3d {
677 curve,
678 range,
679 location: own,
680 }) = data.curve3d()
681 else {
682 ogeom_bail!(Construction, "side {i}'s edge has no 3D curve");
683 };
684 let Some(curve) = model.geometry().curve(*curve).cloned() else {
685 ogeom_bail!(Dangling, "side {i}'s curve is not in this model");
686 };
687 let range = *range;
688 let edge_tolerance = data.tolerance.get();
689 let placed = !(edge.location().is_identity() && own.is_identity());
690 let curve = if placed {
691 curve
692 .transformed(&own.composed(model.datums())?, tol)?
693 .transformed(&edge.transform(model.datums())?, tol)?
694 } else {
695 curve
696 };
697 let edge = edge.oriented(Orientation::Forward);
698
699 let support = match &entry.support {
700 None if order > 0 => ogeom_bail!(
701 Construction,
702 "side {i} asks {:?} but names no support face to meet",
703 entry.continuity
704 ),
705 None => None,
706 Some(face) => Some(read_support(
707 model,
708 i,
709 face,
710 &edge,
711 &curve,
712 range,
713 tolerance.max(edge_tolerance),
714 tol,
715 )?),
716 };
717 Ok(Side {
718 entry: i,
719 given: edge.clone(),
720 edge,
721 placed,
722 curve,
723 range,
724 edge_tolerance,
725 order,
726 support,
727 reversed: false,
728 })
729}
730
731#[expect(
732 clippy::too_many_arguments,
733 reason = "the side's edge, curve and range are read once by the caller"
734)]
735fn read_support(
736 model: &Model,
737 i: usize,
738 face: &Shape,
739 edge: &Shape,
740 curve: &Curve,
741 range: (f64, f64),
742 reach: f64,
743 tol: Tolerances,
744) -> OgeomResult<Support> {
745 if model.kind_of(face)? != ShapeType::Face {
746 ogeom_bail!(Construction, "side {i}'s support is not a face");
747 }
748 let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
749 ogeom_bail!(Dangling, "side {i}'s support is not in this model");
750 };
751 let face_placed = !(face.location().is_identity() && face_data.location.is_identity());
752 let uses: Vec<Orientation> = explore(model, face, Filter::OfType(ShapeType::Edge))?
755 .iter()
756 .filter(|e| e.is_same(edge))
757 .map(Shape::orientation)
758 .collect();
759 let sense = |o: Orientation| match o {
760 Orientation::Forward => Some(1.0),
761 Orientation::Reversed => Some(-1.0),
762 _ => None,
763 };
764 let material = match uses.as_slice() {
765 [first, rest @ ..] if rest.iter().all(|o| o == first) => sense(face.orientation())
766 .zip(sense(*first))
767 .map(|(f, e)| f * e),
768 _ => None,
769 };
770 if uses.is_empty() {
771 ogeom_bail!(
772 Construction,
773 "side {i}'s support face does not hold the side's edge at its placement"
774 );
775 }
776 let surface_id = face_data.surface;
777 let Some(surface) = model.geometry().surface(surface_id).cloned() else {
778 ogeom_bail!(Dangling, "side {i}'s support surface is not in this model");
779 };
780 let surface = if face_placed {
781 surface
782 .transformed(&face_data.location.composed(model.datums())?, tol)?
783 .transformed(&face.transform(model.datums())?, tol)?
784 } else {
785 surface
786 };
787 let Some(edge_data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
788 ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
789 };
790 let stored = match edge_data
793 .pcurve_for(surface_id, edge.location())
794 .filter(|_| !face_placed)
795 {
796 Some(
797 EdgeRepr::PCurve { curve, range, .. }
798 | EdgeRepr::Seam {
799 forward: curve,
800 range,
801 ..
802 },
803 ) => model
804 .geometry()
805 .pcurve(*curve)
806 .cloned()
807 .map(|c| (c, *range)),
808 _ => None,
809 };
810 let off = |pcurve: &PlanarCurve, prange: (f64, f64)| -> OgeomResult<f64> {
814 let mut worst = 0.0f64;
815 for k in 0..=16 {
816 let f = f64::from(k) / 16.0;
817 let t = (range.1 - range.0).mul_add(f, range.0);
818 let pt = (prange.1 - prange.0).mul_add(f, prange.0);
819 let uv = pcurve.point_at(pt, tol)?;
820 let on = surface.point_at(uv.x, uv.y, tol)?;
821 worst = worst.max(on.distance(curve.point_at(t, tol)?));
822 }
823 Ok(worst)
824 };
825 let stored = match stored {
826 Some((pcurve, prange)) => {
827 let worst = off(&pcurve, prange)?;
828 (worst <= reach).then_some((pcurve, prange, worst))
829 }
830 None => None,
831 };
832 let (pcurve, prange, worst) = if let Some(found) = stored {
833 found
834 } else {
835 let (fitted, _, _, _, _) =
836 ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, &surface, tol)?;
837 let worst = off(&fitted, range)?;
838 (fitted, range, worst)
839 };
840 if worst > reach {
841 ogeom_bail!(
842 Construction,
843 "side {i}'s edge stands {worst} off its support face, past {reach}"
844 );
845 }
846 Ok(Support {
847 face: face.clone(),
848 surface,
849 pcurve,
850 prange,
851 material,
852 })
853}
854
855#[derive(Clone)]
858struct End {
859 vertex: Shape,
860 at: Point,
861 reach: f64,
862}
863
864fn ends_of(model: &Model, side: &Side) -> OgeomResult<(End, End)> {
866 let Some((a, b)) = edge_vertices(model, &side.given)? else {
867 ogeom_bail!(
868 Construction,
869 "side {} has no vertices, so it cannot be shown to join the loop",
870 side.entry
871 );
872 };
873 let end = |vertex: Shape| -> OgeomResult<End> {
874 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
875 ogeom_bail!(Construction, "side {}'s vertex holds no point", side.entry);
876 };
877 Ok(End {
878 at: vertex.transform(model.datums())?.apply(data.point),
879 reach: data.tolerance.get().max(side.edge_tolerance),
880 vertex,
881 })
882 };
883 Ok((end(a)?, end(b)?))
884}
885
886fn chain(model: &Model, sides: &mut [Side], tol: Tolerances) -> OgeomResult<Vec<usize>> {
891 let mut ends = Vec::with_capacity(sides.len());
892 for side in sides.iter() {
893 let (a, b) = ends_of(model, side)?;
894 ends.push((a, b));
895 }
896 let same = |a: &End, b: &End| -> OgeomResult<bool> {
897 Ok(a.vertex.is_same(&b.vertex) || model.same_position(&a.vertex, &b.vertex, tol)?)
898 };
899 let near = |a: &End, b: &End| a.at.distance(b.at) <= a.reach + b.reach + tol.confusion();
900 let meets = |a: &End, b: &End| -> OgeomResult<bool> { Ok(same(a, b)? || near(a, b)) };
901 let n = sides.len();
902 let mut used = vec![false; n];
903 used[0] = true;
904 let mut order = vec![0];
905 let first = ends[0].0.clone();
906 let mut cursor = ends[0].1.clone();
907 while order.len() < n {
908 let last = sides[order[order.len() - 1]].entry;
909 let mut found: Vec<(usize, bool)> = Vec::new();
912 for strict in [true, false] {
913 for j in 0..n {
914 if used[j] {
915 continue;
916 }
917 let joins = |e: &End| -> OgeomResult<bool> {
918 if strict {
919 same(e, &cursor)
920 } else {
921 meets(e, &cursor)
922 }
923 };
924 if joins(&ends[j].0)? {
925 found.push((j, false));
926 } else if joins(&ends[j].1)? {
927 found.push((j, true));
928 }
929 }
930 if !found.is_empty() {
931 break;
932 }
933 }
934 let (j, reversed) = match found.as_slice() {
935 [one] => *one,
936 [] => ogeom_bail!(
937 Construction,
938 "the boundary does not close: no side continues where side {last} ends"
939 ),
940 _ => ogeom_bail!(
941 Construction,
942 "{} sides continue where side {last} ends; a filling's boundary \
943 is one simple loop",
944 found.len()
945 ),
946 };
947 used[j] = true;
948 sides[j].reversed = reversed;
949 cursor = if reversed {
950 ends[j].0.clone()
951 } else {
952 ends[j].1.clone()
953 };
954 order.push(j);
955 }
956 if !meets(&cursor, &first)? {
957 ogeom_bail!(
958 Construction,
959 "the boundary does not close: the last side ends away from where the first begins"
960 );
961 }
962 Ok(order)
963}
964
965fn face_the_supports(model: &Model, sides: &mut [Side], order: &mut [usize]) -> OgeomResult<()> {
968 let (mut agree, mut disagree) = (0usize, 0usize);
969 for side in sides.iter() {
970 let Some(support) = &side.support else {
971 continue;
972 };
973 let uses: Vec<Orientation> =
974 explore(model, &support.face, Filter::OfType(ShapeType::Edge))?
975 .iter()
976 .filter(|e| e.is_same(&side.given))
977 .map(Shape::orientation)
978 .collect();
979 let Some(&first) = uses.first() else {
980 continue;
981 };
982 if uses.iter().any(|o| *o != first)
983 || !matches!(first, Orientation::Forward | Orientation::Reversed)
984 {
985 continue;
986 }
987 if (first == Orientation::Forward) == !side.reversed {
988 disagree += 1;
989 } else {
990 agree += 1;
991 }
992 }
993 if disagree > agree {
994 order.reverse();
995 for side in sides.iter_mut() {
996 side.reversed = !side.reversed;
997 }
998 }
999 Ok(())
1000}
1001
1002fn loop_points(sides: &[Side], order: &[usize], tol: Tolerances) -> OgeomResult<Vec<Point>> {
1004 let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
1005 for &i in order {
1006 let side = &sides[i];
1007 for k in 0..OUTLINE_SAMPLES {
1008 #[expect(
1009 clippy::cast_precision_loss,
1010 reason = "a sample index, far below the mantissa"
1011 )]
1012 let mut f = k as f64 / OUTLINE_SAMPLES as f64;
1013 if side.reversed {
1014 f = 1.0 - f;
1015 }
1016 out.push(side.curve.point_at(side.parameter(f), tol)?);
1017 }
1018 }
1019 Ok(out)
1020}
1021
1022fn frame_of(outline: &[Point], tol: Tolerances) -> OgeomResult<PlaneFrame> {
1026 let Ok(origin) = Point::centroid(outline) else {
1027 ogeom_bail!(Construction, "the boundary loop has no points");
1028 };
1029 let mut area = Vector::ZERO;
1030 let mut reach = 0.0f64;
1031 for (k, p) in outline.iter().enumerate() {
1032 let q = outline[(k + 1) % outline.len()];
1033 area += (*p - origin).cross(q - origin) * 0.5;
1034 reach = reach.max(p.distance(origin));
1035 }
1036 let magnitude = area.magnitude();
1037 if magnitude <= 1e-9 * reach * reach || magnitude <= tol.confusion() * tol.confusion() {
1038 ogeom_bail!(
1039 Construction,
1040 "the boundary loop encloses no area seen from any plane"
1041 );
1042 }
1043 let n = area * (1.0 / magnitude);
1044 let b1 = Direction::new(n, tol)?.any_perpendicular().vector();
1045 let b2 = n.cross(b1);
1046 let (mut sxx, mut syy, mut sxy) = (0.0f64, 0.0f64, 0.0f64);
1047 for p in outline {
1048 let d = *p - origin;
1049 let (x, y) = (d.dot(b1), d.dot(b2));
1050 sxx += x * x;
1051 syy += y * y;
1052 sxy += x * y;
1053 }
1054 let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
1055 let e1 = b1 * theta.cos() + b2 * theta.sin();
1056 let e2 = n.cross(e1);
1057 Ok(PlaneFrame { origin, e1, e2, n })
1058}
1059
1060fn corners_of(
1065 sides: &[Side],
1066 order: &[usize],
1067 tol: Tolerances,
1068) -> OgeomResult<Vec<(Vector, Vector, f64)>> {
1069 let walked = |side: &Side, end: bool| -> OgeomResult<Option<Vector>> {
1070 let t = if end == side.reversed {
1071 side.range.0
1072 } else {
1073 side.range.1
1074 };
1075 let d = side.curve.d1_at(t, tol)?;
1076 let d = if side.reversed { d * -1.0 } else { d };
1077 Ok(Direction::new(d, tol).ok().map(Direction::vector))
1078 };
1079 let mut out = Vec::with_capacity(order.len());
1080 for (k, &i) in order.iter().enumerate() {
1081 let next = &sides[order[(k + 1) % order.len()]];
1082 let corner = match (walked(&sides[i], true)?, walked(next, false)?) {
1083 (Some(a), Some(b)) => {
1084 let turn = a.cross(b).magnitude().atan2(a.dot(b));
1085 (a, b, if turn > CORNER { turn } else { 0.0 })
1086 }
1087 _ => (Vector::ZERO, Vector::ZERO, 0.0),
1088 };
1089 out.push(corner);
1090 }
1091 Ok(out)
1092}
1093
1094fn kept_turn(n: Vector, corners: &[(Vector, Vector, f64)]) -> f64 {
1097 let flat = |v: Vector| v - n * v.dot(n);
1098 let mut worst = 1.0f64;
1099 for (a, b, turn) in corners {
1100 if *turn > 0.0 {
1101 let (a, b) = (flat(*a), flat(*b));
1102 let seen = a.cross(b).magnitude().atan2(a.dot(b));
1103 worst = worst.min((seen / turn).min(1.0));
1104 }
1105 }
1106 worst
1107}
1108
1109fn boundary_chart(
1121 sides: &[Side],
1122 order: &[usize],
1123 corners: &[(Vector, Vector, f64)],
1124 tolerance: f64,
1125 tol: Tolerances,
1126) -> OgeomResult<Vec<PlanarCurve>> {
1127 use std::f64::consts::{PI, TAU};
1128 let mut walks: Vec<(Vec<f64>, Vec<f64>, f64)> =
1131 vec![(Vec::new(), Vec::new(), 0.0); sides.len()];
1132 let mut total = 0.0;
1133 for &i in order {
1134 let side = &sides[i];
1135 let mut params = Vec::with_capacity(CHART_SAMPLES + 1);
1136 let mut along = Vec::with_capacity(CHART_SAMPLES + 1);
1137 let mut previous = side.curve.point_at(side.range.0, tol)?;
1138 let mut length = 0.0;
1139 for k in 0..=CHART_SAMPLES {
1140 #[expect(
1141 clippy::cast_precision_loss,
1142 reason = "a sample index, far below the mantissa"
1143 )]
1144 let t = side.parameter(k as f64 / CHART_SAMPLES as f64);
1145 let p = side.curve.point_at(t, tol)?;
1146 length += p.distance(previous);
1147 previous = p;
1148 params.push(t);
1149 along.push(length);
1150 }
1151 if side.reversed {
1152 for a in &mut along {
1153 *a = length - *a;
1154 }
1155 }
1156 total += length;
1157 walks[i] = (params, along, length);
1158 }
1159 if total <= tol.confusion() {
1160 ogeom_bail!(Construction, "the boundary loop has no length");
1161 }
1162 let turns: Vec<f64> = corners.iter().map(|c| c.2.min(MOST_TURN * PI)).collect();
1163 let sum: f64 = turns.iter().sum();
1164 let scale = if sum > MOST_TURN * TAU {
1165 MOST_TURN * TAU / sum
1166 } else {
1167 1.0
1168 };
1169 let bend = sum.mul_add(-scale, TAU) / total;
1170 let arc = |start: Point2, heading: f64, s: f64| -> Point2 {
1173 let delta = bend * s;
1174 if delta.abs() < 1e-9 {
1175 start + Vector2::new(heading.cos(), heading.sin()) * s
1176 } else {
1177 start
1178 + Vector2::new(
1179 (heading + delta).sin() - heading.sin(),
1180 heading.cos() - (heading + delta).cos(),
1181 ) * (1.0 / bend)
1182 }
1183 };
1184 let mut starts = vec![(Point2::new(0.0, 0.0), 0.0, 0.0); sides.len()];
1185 let (mut at, mut heading, mut walked) = (Point2::new(0.0, 0.0), 0.0f64, 0.0f64);
1186 for (k, &i) in order.iter().enumerate() {
1187 starts[i] = (at, heading, walked);
1188 let length = walks[i].2;
1189 at = arc(at, heading, length);
1190 heading += bend.mul_add(length, turns[k] * scale);
1191 walked += length;
1192 }
1193 let drift = at - Point2::new(0.0, 0.0);
1194 if drift.magnitude() > 0.5 * total / TAU {
1195 ogeom_bail!(
1196 Construction,
1197 "the boundary loop's corners leave no chart drawn from it closing"
1198 );
1199 }
1200 let mut out = Vec::with_capacity(sides.len());
1201 for (i, (params, along, _)) in walks.iter().enumerate() {
1202 let (start, heading, before) = starts[i];
1203 let points: Vec<Point2> = along
1204 .iter()
1205 .map(|&s| arc(start, heading, s) - drift * ((before + s) / total))
1206 .collect();
1207 let fitted = ogeom_geom::fit::fit_points_2d_at(params, &points, 3, tolerance * 1e-2, tol)?;
1208 out.push(PlanarCurve::BSpline(fitted.curve));
1209 }
1210 Ok(out)
1211}
1212fn crosses_itself(polygon: &[Point2]) -> bool {
1220 let n = polygon.len();
1221 let reach = polygon
1222 .iter()
1223 .map(|q| q.x.abs().max(q.y.abs()))
1224 .fold(0.0f64, f64::max);
1225 let floor = 1e-9 * reach;
1226 let side = |a: Point2, b: Point2, c: Point2| -> i8 {
1228 let ab = b - a;
1229 let o = ab.cross(c - a);
1230 if o.abs() <= floor * ab.magnitude() {
1231 0
1232 } else if o > 0.0 {
1233 1
1234 } else {
1235 -1
1236 }
1237 };
1238 for i in 0..n {
1239 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
1240 for j in (i + 2)..n {
1241 if i == 0 && j == n - 1 {
1242 continue;
1243 }
1244 let (c, d) = (polygon[j], polygon[(j + 1) % n]);
1245 if side(a, b, c) * side(a, b, d) < 0 && side(c, d, a) * side(c, d, b) < 0 {
1246 return true;
1247 }
1248 }
1249 }
1250 false
1251}
1252
1253fn inside(polygon: &[Point2], q: Point2) -> bool {
1255 let n = polygon.len();
1256 let mut winding = 0i32;
1257 for i in 0..n {
1258 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
1259 let side = (b - a).cross(q - a);
1260 if a.y <= q.y {
1261 if b.y > q.y && side > 0.0 {
1262 winding += 1;
1263 }
1264 } else if b.y <= q.y && side < 0.0 {
1265 winding -= 1;
1266 }
1267 }
1268 winding != 0
1269}
1270
1271fn constraint_points(
1274 model: &Model,
1275 constraints: &[Shape],
1276 tol: Tolerances,
1277) -> OgeomResult<Vec<(Point, f64)>> {
1278 let mut out = Vec::new();
1279 for (k, shape) in constraints.iter().enumerate() {
1280 let placement = shape.transform(model.datums())?;
1281 match model.kind_of(shape)? {
1282 ShapeType::Vertex => {
1283 let Some(data) = model.node(shape).and_then(|n| n.data().as_vertex()) else {
1284 ogeom_bail!(Construction, "constraint {k} holds no vertex data");
1285 };
1286 out.push((placement.apply(data.point), POINT_SHARE));
1287 }
1288 ShapeType::Edge => {
1289 let Some(data) = model.node(shape).and_then(|n| n.data().as_edge()) else {
1290 ogeom_bail!(Construction, "constraint {k} holds no edge data");
1291 };
1292 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1293 ogeom_bail!(Construction, "constraint {k} has no 3D curve");
1294 };
1295 let Some(curve) = model.geometry().curve(*curve) else {
1296 ogeom_bail!(Dangling, "constraint {k}'s curve is not in this model");
1297 };
1298 for i in 0..=PER_CURVE {
1299 #[expect(
1300 clippy::cast_precision_loss,
1301 reason = "a sample index, far below the mantissa"
1302 )]
1303 let f = i as f64 / PER_CURVE as f64;
1304 let t = (range.1 - range.0).mul_add(f, range.0);
1305 out.push((placement.apply(curve.point_at(t, tol)?), POINT_SHARE / 4.0));
1306 }
1307 }
1308 other => ogeom_bail!(
1309 Construction,
1310 "constraint {k} is a {other:?}; a filling passes through vertices and edges"
1311 ),
1312 }
1313 }
1314 Ok(out)
1315}
1316
1317fn untrimmed(curve: &Curve) -> &Curve {
1319 match curve {
1320 Curve::Trimmed(t) if !t.is_reversed() => untrimmed(t.basis()),
1321 other => other,
1322 }
1323}
1324
1325fn trace(
1330 side: &Side,
1331 frame: &PlaneFrame,
1332 tolerance: f64,
1333 tol: Tolerances,
1334) -> OgeomResult<PlanarCurve> {
1335 let truth = |t: f64| -> OgeomResult<Point2> { Ok(frame.chart(side.curve.point_at(t, tol)?)) };
1336 let (t0, t1) = side.range;
1337 let exact: Option<PlanarCurve> = match untrimmed(&side.curve) {
1338 Curve::BSpline(spline) => {
1339 let mut control = Vec::with_capacity(spline.control_points().len());
1340 for w in spline.control_points() {
1341 control.push(Weighted::new(frame.chart(w.point()), w.weight, tol)?);
1342 }
1343 BSpline2d::rational(spline.knots().clone(), control)
1344 .ok()
1345 .map(PlanarCurve::BSpline)
1346 }
1347 _ => match side.curve.kind() {
1348 CurveKind::Line => {
1349 let (q0, q1) = (truth(t0)?, truth(t1)?);
1350 let d = (q1 - q0) * (1.0 / (t1 - t0));
1351 let c = q0 - d * t0;
1352 Trig2d::new(c, d, Vector2::ZERO, Vector2::ZERO, side.range)
1353 .ok()
1354 .map(PlanarCurve::Trig)
1355 }
1356 CurveKind::Circle | CurveKind::Ellipse => {
1357 let ts = [
1360 t0,
1361 (t1 - t0).mul_add(1.0 / 3.0, t0),
1362 (t1 - t0).mul_add(2.0 / 3.0, t0),
1363 ];
1364 let rows = ts.map(|t| [1.0, t.cos(), t.sin()]);
1365 let values = [truth(ts[0])?, truth(ts[1])?, truth(ts[2])?];
1366 match (
1367 solve3(rows, values.map(|q| q.x)),
1368 solve3(rows, values.map(|q| q.y)),
1369 ) {
1370 (Some(x), Some(y)) => Trig2d::new(
1371 Point2::new(x[0], y[0]),
1372 Vector2::ZERO,
1373 Vector2::new(x[1], y[1]),
1374 Vector2::new(x[2], y[2]),
1375 side.range,
1376 )
1377 .ok()
1378 .map(PlanarCurve::Trig),
1379 _ => None,
1380 }
1381 }
1382 _ => None,
1383 },
1384 };
1385 if let Some(curve) = exact {
1386 let mut worst = 0.0f64;
1387 let mut scale = 1.0f64;
1388 for k in 0..=32 {
1389 let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
1390 let q = truth(t)?;
1391 scale = scale.max(q.to_vector().magnitude());
1392 worst = worst.max(curve.point_at(t, tol)?.distance(q));
1393 }
1394 if worst <= 1e-12 * scale + 1e-3 * tol.confusion() {
1395 return Ok(curve);
1396 }
1397 }
1398 const SAMPLES: usize = 96;
1399 let mut parameters = Vec::with_capacity(SAMPLES + 1);
1400 let mut points = Vec::with_capacity(SAMPLES + 1);
1401 for k in 0..=SAMPLES {
1402 #[expect(
1403 clippy::cast_precision_loss,
1404 reason = "a sample index, far below the mantissa"
1405 )]
1406 let t = (t1 - t0).mul_add(k as f64 / SAMPLES as f64, t0);
1407 parameters.push(t);
1408 points.push(truth(t)?);
1409 }
1410 let fitted = ogeom_geom::fit::fit_points_2d_at(¶meters, &points, 3, tolerance * 1e-2, tol)?;
1411 Ok(PlanarCurve::BSpline(fitted.curve))
1412}
1413
1414fn solve3(rows: [[f64; 3]; 3], rhs: [f64; 3]) -> Option<[f64; 3]> {
1416 let det = |m: [[f64; 3]; 3]| {
1417 m[0][0] * m[1][1].mul_add(m[2][2], -m[1][2] * m[2][1])
1418 - m[0][1] * m[1][0].mul_add(m[2][2], -m[1][2] * m[2][0])
1419 + m[0][2] * m[1][0].mul_add(m[2][1], -m[1][1] * m[2][0])
1420 };
1421 let d = det(rows);
1422 if d.abs() < 1e-12 {
1423 return None;
1424 }
1425 let mut out = [0.0; 3];
1426 for (c, slot) in out.iter_mut().enumerate() {
1427 let mut m = rows;
1428 for r in 0..3 {
1429 m[r][c] = rhs[r];
1430 }
1431 *slot = det(m) / d;
1432 }
1433 Some(out)
1434}
1435
1436fn smoothing_for(sides: &[Side], tolerance: f64, size: f64) -> f64 {
1464 let order = sides.iter().map(|s| s.order).max().unwrap_or(0);
1465 let mut eps = tolerance / size;
1466 if order >= 1 {
1467 eps = eps.min(tolerance);
1468 }
1469 if order >= 2 {
1470 eps = eps.min(tolerance * size);
1471 }
1472 (BUDGET * eps).powi(2).max(LEAST_SMOOTHING)
1473}
1474
1475fn side_length(side: &Side, tol: Tolerances) -> OgeomResult<f64> {
1477 let mut length = 0.0;
1478 let mut previous = side.curve.point_at(side.range.0, tol)?;
1479 for k in 1..=OUTLINE_SAMPLES {
1480 #[expect(
1481 clippy::cast_precision_loss,
1482 reason = "a sample index, far below the mantissa"
1483 )]
1484 let f = k as f64 / OUTLINE_SAMPLES as f64;
1485 let p = side.curve.point_at(side.parameter(f), tol)?;
1486 length += p.distance(previous);
1487 previous = p;
1488 }
1489 Ok(length)
1490}
1491
1492fn side_conditions(
1497 side: &Side,
1498 share: f64,
1499 size: f64,
1500 samples: usize,
1501 frame: &PlaneFrame,
1502 out: &mut Vec<Condition<1>>,
1503 tol: Tolerances,
1504) -> OgeomResult<()> {
1505 #[expect(
1506 clippy::cast_precision_loss,
1507 reason = "a sample count, far below the mantissa"
1508 )]
1509 let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1510 for k in 0..=samples {
1511 #[expect(
1512 clippy::cast_precision_loss,
1513 reason = "a sample index, far below the mantissa"
1514 )]
1515 let t = side.parameter(k as f64 / samples as f64);
1516 let p = side.curve.point_at(t, tol)?;
1517 let at = frame.chart(p);
1518 out.push(Condition::partial(
1519 at,
1520 (0, 0),
1521 [frame.height(p)],
1522 root / size,
1523 ));
1524 if side.order == 0 {
1525 continue;
1526 }
1527 let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1528 continue;
1529 };
1530 let lift = normal.dot(frame.n);
1531 if lift.abs() < MIN_LIFT {
1532 ogeom_bail!(
1533 Construction,
1534 "side {}'s support stands at {:.1} degrees to the plane the \
1535 boundary spans, past the {:.1} a height over that plane takes",
1536 side.entry,
1537 lift.abs().acos().to_degrees(),
1538 MIN_LIFT.acos().to_degrees()
1539 );
1540 }
1541 let normal = if lift < 0.0 { normal * -1.0 } else { normal };
1544 let lift = lift.abs();
1545 let (hu, hv) = (-normal.dot(frame.e1) / lift, -normal.dot(frame.e2) / lift);
1546 out.push(Condition::partial(at, (1, 0), [hu], root));
1547 out.push(Condition::partial(at, (0, 1), [hv], root));
1548 if side.order < 2 {
1549 continue;
1550 }
1551 let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1555 let sense = if curvature.normal.dot_vector(normal) < 0.0 {
1556 -1.0
1557 } else {
1558 1.0
1559 };
1560 let (dmax, dmin) = (
1561 curvature.max_direction.vector(),
1562 curvature.min_direction.vector(),
1563 );
1564 let second = |a: Vector, b: Vector| {
1565 sense
1566 * curvature.max.mul_add(
1567 a.dot(dmax) * b.dot(dmax),
1568 curvature.min * a.dot(dmin) * b.dot(dmin),
1569 )
1570 / lift
1571 };
1572 let su = frame.e1 + frame.n * hu;
1573 let sv = frame.e2 + frame.n * hv;
1574 for (order, target) in [
1575 ((2, 0), second(su, su)),
1576 ((1, 1), second(su, sv)),
1577 ((0, 2), second(sv, sv)),
1578 ] {
1579 out.push(Condition::partial(at, order, [target], root * size));
1580 }
1581 }
1582 Ok(())
1583}
1584
1585fn free_conditions(
1589 side: &Side,
1590 pcurve: &PlanarCurve,
1591 share: f64,
1592 size: f64,
1593 samples: usize,
1594 out: &mut Vec<Condition<3>>,
1595 tol: Tolerances,
1596) -> OgeomResult<()> {
1597 #[expect(
1598 clippy::cast_precision_loss,
1599 reason = "a sample count, far below the mantissa"
1600 )]
1601 let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1602 for k in 0..=samples {
1603 #[expect(
1604 clippy::cast_precision_loss,
1605 reason = "a sample index, far below the mantissa"
1606 )]
1607 let t = side.parameter(k as f64 / samples as f64);
1608 let p = side.curve.point_at(t, tol)?;
1609 let at = pcurve.point_at(t, tol)?;
1610 out.push(Condition::partial(at, (0, 0), [p.x, p.y, p.z], root / size));
1611 if side.order == 0 {
1612 continue;
1613 }
1614 let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1615 continue;
1616 };
1617 let Some(material) = support.material else {
1618 ogeom_bail!(
1619 Construction,
1620 "side {}'s support uses its edge both ways or neither, so which \
1621 side of it the filling leaves from is not known",
1622 side.entry
1623 );
1624 };
1625 let d1 = side.curve.d1_at(t, tol)?;
1630 let c1 = pcurve.d1_at(t, tol)?;
1631 let walk = if side.reversed { -1.0 } else { 1.0 };
1632 let speed = c1.magnitude();
1633 if speed <= f64::MIN_POSITIVE {
1634 continue;
1635 }
1636 let along = c1 * (walk / speed);
1637 let outward = Vector2::new(along.y, -along.x);
1638 let into = normal.cross(d1) * material;
1639 let Ok(into) = Direction::new(into, tol) else {
1640 continue;
1641 };
1642 let into = into.vector();
1643 let ribbon = into * (d1.magnitude() / speed);
1644 out.push(Condition::along(
1645 at,
1646 outward,
1647 false,
1648 [ribbon.x, ribbon.y, ribbon.z],
1649 root,
1650 ));
1651 if side.order < 2 {
1652 continue;
1653 }
1654 let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1659 let Some(bend) = curvature.normal_curvature(into) else {
1660 continue;
1661 };
1662 let second = curvature.normal.vector() * (bend * ribbon.dot(ribbon));
1663 out.push(Condition::along(
1664 at,
1665 outward,
1666 true,
1667 [second.x, second.y, second.z],
1668 root * size,
1669 ));
1670 }
1671 Ok(())
1672}
1673
1674fn steepest_support(
1678 sides: &[Side],
1679 frame: &PlaneFrame,
1680 tol: Tolerances,
1681) -> OgeomResult<Option<(usize, f64)>> {
1682 let mut steepest: Option<(usize, f64)> = None;
1683 for side in sides.iter().filter(|s| s.order > 0) {
1684 for k in 0..=STEEP_SAMPLES {
1685 #[expect(
1686 clippy::cast_precision_loss,
1687 reason = "a sample index, far below the mantissa"
1688 )]
1689 let t = side.parameter(k as f64 / STEEP_SAMPLES as f64);
1690 let Some((_, normal)) = side.support_at(t, tol)? else {
1691 continue;
1692 };
1693 let lift = normal.dot(frame.n).abs();
1694 if steepest.is_none_or(|(_, least)| lift < least) {
1695 steepest = Some((side.entry, lift));
1696 }
1697 }
1698 }
1699 Ok(steepest)
1700}
1701
1702fn tangent_corners(
1708 sides: &[Side],
1709 order: &[usize],
1710 corners: &[(Vector, Vector, f64)],
1711 tolerance: f64,
1712 tol: Tolerances,
1713) -> OgeomResult<()> {
1714 for (k, (arrive, leave, _)) in corners.iter().enumerate() {
1715 let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % order.len()]]);
1716 let end = |side: &Side, last: bool| {
1717 if last == side.reversed {
1718 side.range.0
1719 } else {
1720 side.range.1
1721 }
1722 };
1723 for (side, at, other, tangent) in
1724 [(a, end(a, true), b, *leave), (b, end(b, false), a, *arrive)]
1725 {
1726 if side.order == 0 {
1727 continue;
1728 }
1729 let Some((_, normal)) = side.support_at(at, tol)? else {
1730 continue;
1731 };
1732 let off = normal.dot(tangent).abs().min(1.0).asin();
1733 if off > tolerance {
1734 ogeom_bail!(
1735 Construction,
1736 "sides {} and {} meet at a corner no surface is tangent to \
1737 both supports at: side {}'s support stands {:.1} degrees off \
1738 side {}'s tangent there, past the tolerance of {tolerance} \
1739 radians",
1740 side.entry,
1741 other.entry,
1742 side.entry,
1743 off.to_degrees(),
1744 other.entry,
1745 );
1746 }
1747 }
1748 }
1749 Ok(())
1750}
1751
1752fn traced_outline(
1754 sides: &[Side],
1755 order: &[usize],
1756 traces: &[PlanarCurve],
1757 tol: Tolerances,
1758) -> OgeomResult<Vec<Point2>> {
1759 let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
1760 for &i in order {
1761 let side = &sides[i];
1762 for k in 0..OUTLINE_SAMPLES {
1763 #[expect(
1764 clippy::cast_precision_loss,
1765 reason = "a sample index, far below the mantissa"
1766 )]
1767 let mut f = k as f64 / OUTLINE_SAMPLES as f64;
1768 if side.reversed {
1769 f = 1.0 - f;
1770 }
1771 out.push(traces[i].point_at(side.parameter(f), tol)?);
1772 }
1773 }
1774 Ok(out)
1775}
1776
1777fn past_planes(
1786 surface: &BSplineSurface,
1787 outline: &[Point2],
1788 domain: ((f64, f64), (f64, f64)),
1789 region: Region<'_>,
1790 tol: Tolerances,
1791) -> OgeomResult<(f64, Point)> {
1792 let mut worst = (f64::NEG_INFINITY, Point::ORIGIN);
1793 for q in Grid::over(surface, outline, domain).points {
1794 let p = surface.point_at(q.x, q.y, tol)?;
1795 let (d, _) = region(p);
1796 if d > worst.0 {
1797 worst = (d, p);
1798 }
1799 }
1800 Ok(worst)
1801}
1802
1803const HOLD_ROUNDS: usize = 16;
1806
1807const FIRM_ROUNDS: usize = 32;
1811
1812const FIRMER: [usize; 1] = [202];
1816
1817struct HeldFit<'a> {
1820 sides: &'a [Side],
1821 lengths: &'a [f64],
1822 traces: &'a [PlanarCurve],
1823 frame: &'a PlaneFrame,
1824 domain: ((f64, f64), (f64, f64)),
1825 size: f64,
1826 interior: &'a [(Point, f64)],
1827 smoothing: f64,
1828 tolerance: f64,
1829 region: Region<'a>,
1830 outline: &'a [Point2],
1831 first: Option<&'a FirstMet>,
1834}
1835
1836type FirstMet = (BSplineSurface, Vec<FillSide>, f64, Factor);
1839
1840impl HeldFit<'_> {
1841 #[allow(clippy::type_complexity, reason = "a fit, or why none held")]
1856 fn fit(
1857 &self,
1858 from: usize,
1859 firm: bool,
1860 repeat: &mut Vec<usize>,
1861 tol: Tolerances,
1862 ) -> OgeomResult<Result<(BSplineSurface, Vec<FillSide>, f64), String>> {
1863 let ((ua, ub), (va, vb)) = self.domain;
1864 let (du, dv) = (ub - ua, vb - va);
1865 let mut miss = String::new();
1866 let firmer: &[usize] = if firm { &FIRMER } else { &[] };
1867 for (net, base) in NETS
1868 .into_iter()
1869 .chain(FURTHER)
1870 .chain(firmer.iter().copied())
1871 .enumerate()
1872 .skip(from)
1873 {
1874 if firm && repeat.contains(&net) {
1875 continue;
1876 }
1877 #[expect(
1878 clippy::cast_possible_truncation,
1879 clippy::cast_sign_loss,
1880 clippy::cast_precision_loss,
1881 reason = "a control count of a few dozen, from a positive ratio"
1882 )]
1883 let count = |extent: f64| -> usize {
1884 ((base as f64 * extent / self.size).round() as usize).max(DEGREE + 2)
1885 };
1886 let controls = (count(du), count(dv));
1887 let samples = 4 * controls.0.max(controls.1) + 8;
1888 let mut conditions = Vec::new();
1889 for (side, length) in self.sides.iter().zip(self.lengths) {
1890 side_conditions(
1891 side,
1892 *length / self.size,
1893 self.size,
1894 samples,
1895 self.frame,
1896 &mut conditions,
1897 tol,
1898 )?;
1899 }
1900 for (p, share) in self.interior {
1901 conditions.push(Condition::partial(
1902 self.frame.chart(*p),
1903 (0, 0),
1904 [self.frame.height(*p)],
1905 share.sqrt() / self.size,
1906 ));
1907 }
1908 let weight = 1.0 / self.size;
1910 let mut held: Vec<Point2> = Vec::new();
1911 let mut holds: Vec<(usize, f64)> = Vec::new();
1914 let rounds = if firm {
1915 HOLD_ROUNDS + FIRM_ROUNDS
1916 } else {
1917 HOLD_ROUNDS
1918 };
1919 let mut twice = false;
1922 let mut settled = false;
1923 let mut grid: Option<Grid> = None;
1924 let mut last: Option<Factor> = None;
1927 let mut changed: Vec<usize> = Vec::new();
1928 for round in 0..rounds {
1929 let (surface, reports, gap) = match self.first {
1930 Some(first) if net == from && round == 0 => {
1931 (first.0.clone(), first.1.clone(), first.2)
1932 }
1933 _ => {
1934 let again = match (&last, self.first) {
1935 (Some(factor), _) => Some((factor, changed.as_slice())),
1936 (None, Some(first)) if net == from => {
1937 Some((&first.3, changed.as_slice()))
1938 }
1939 _ => None,
1940 };
1941 let (fit, factor) = fit_height_with(
1942 self.frame,
1943 self.domain,
1944 controls,
1945 &conditions,
1946 self.smoothing,
1947 again,
1948 tol,
1949 )?;
1950 last = Some(factor);
1951 changed.clear();
1952 let surface = fit.surface;
1953 let reports =
1954 measure_sides(self.sides, self.traces, &surface, 3 * samples + 1, tol)?;
1955 let mut gap = 0.0f64;
1956 for (p, _) in self.interior {
1957 let q = self.frame.chart(*p);
1958 gap = gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
1959 }
1960 (surface, reports, gap)
1961 }
1962 };
1963 if let Some(side) = first_miss(self.sides, &reports, gap, self.tolerance) {
1964 miss = format!("at {}x{} controls, {side}", controls.0, controls.1);
1965 settled = true;
1966 break;
1967 }
1968 let grid =
1969 grid.get_or_insert_with(|| Grid::over(&surface, self.outline, self.domain));
1970 let strays = grid.strays(&surface, self.region, tol)?;
1971 let past = strays.iter().map(|s| s.1).fold(f64::NEG_INFINITY, f64::max);
1972 if past <= self.tolerance {
1973 return Ok(Ok((surface, reports, gap)));
1974 }
1975 miss = format!(
1976 "at {}x{} controls, the patch stands {past} past a plane it is held within",
1977 controls.0, controls.1
1978 );
1979 let mut added = false;
1980 let cells = (controls.0 - DEGREE, controls.1 - DEGREE);
1984 let mut worst: std::collections::BTreeMap<(usize, usize), Stray> =
1985 std::collections::BTreeMap::new();
1986 for stray in strays.into_iter().filter(|s| s.1 > 0.5 * self.tolerance) {
1987 let key = cell_of(stray.0, self.domain, cells);
1988 match worst.get(&key) {
1989 Some(kept) if kept.1 >= stray.1 => {}
1990 _ => {
1991 worst.insert(key, stray);
1992 }
1993 }
1994 }
1995 for (q, d, normal, p) in worst.into_values() {
1996 let before = held
1997 .iter()
1998 .position(|h| (h.x - q.x).abs() + (h.y - q.y).abs() <= 1e-12);
1999 if before.is_some() {
2000 twice = true;
2001 if !firm {
2002 continue;
2003 }
2004 }
2005 let on = p - normal * d;
2008 let lift = normal.dot(self.frame.n);
2009 if lift.abs() < MIN_LIFT {
2010 continue;
2011 }
2012 let foot = self.frame.origin + self.frame.e1 * q.x + self.frame.e2 * q.y;
2015 let height = (-0.5 * self.tolerance - normal.dot(foot - on)) / lift;
2016 if let Some(k) = before {
2017 let (at, w) = holds[k];
2018 holds[k].1 = w * 2.0;
2019 conditions[at] = Condition::partial(q, (0, 0), [height], w * 2.0);
2020 changed.push(at);
2021 } else {
2022 conditions.push(Condition::partial(q, (0, 0), [height], weight));
2023 holds.push((conditions.len() - 1, weight));
2024 changed.push(conditions.len() - 1);
2025 held.push(q);
2026 }
2027 added = true;
2028 }
2029 if !added {
2030 settled = true;
2031 break;
2032 }
2033 }
2034 if !firm && settled && !twice {
2035 repeat.push(net);
2036 }
2037 }
2038 Ok(Err(miss))
2039 }
2040}
2041
2042type Stray = (Point2, f64, Vector, Point);
2045
2046struct Crossings {
2051 rows: Vec<Vec<usize>>,
2052}
2053
2054impl Crossings {
2055 fn of(polygon: &[Point2], heights: &[f64]) -> Self {
2056 let n = polygon.len();
2057 let rows = heights
2058 .iter()
2059 .map(|&y| {
2060 (0..n)
2061 .filter(|&i| {
2062 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
2063 (a.y <= y && b.y > y) || (a.y > y && b.y <= y)
2064 })
2065 .collect()
2066 })
2067 .collect();
2068 Self { rows }
2069 }
2070
2071 fn inside(&self, polygon: &[Point2], row: usize, q: Point2) -> bool {
2073 let n = polygon.len();
2074 let mut winding = 0i32;
2075 for &i in &self.rows[row] {
2076 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
2077 let side = (b - a).cross(q - a);
2078 if a.y <= q.y {
2079 if b.y > q.y && side > 0.0 {
2080 winding += 1;
2081 }
2082 } else if b.y <= q.y && side < 0.0 {
2083 winding -= 1;
2084 }
2085 }
2086 winding != 0
2087 }
2088}
2089
2090fn measure_sides(
2092 sides: &[Side],
2093 traces: &[PlanarCurve],
2094 surface: &BSplineSurface,
2095 samples: usize,
2096 tol: Tolerances,
2097) -> OgeomResult<Vec<FillSide>> {
2098 let pairs: Vec<(&Side, &PlanarCurve)> = sides.iter().zip(traces).collect();
2099 ogeom_core::parallel::map_ordered(&pairs, |_, (side, pcurve)| {
2100 measure_side(side, pcurve, surface, samples, tol)
2101 })
2102 .into_iter()
2103 .collect()
2104}
2105
2106const STRAY_CHUNK: usize = 4096;
2108
2109fn cell_of(q: Point2, domain: ((f64, f64), (f64, f64)), cells: (usize, usize)) -> (usize, usize) {
2112 let ((ua, ub), (va, vb)) = domain;
2113 #[expect(
2114 clippy::cast_possible_truncation,
2115 clippy::cast_sign_loss,
2116 clippy::cast_precision_loss,
2117 reason = "a cell index from a share of the domain, clamped into range"
2118 )]
2119 let at = |t: f64, lo: f64, hi: f64, n: usize| -> usize {
2120 (((t - lo) / (hi - lo) * n as f64).floor().max(0.0) as usize).min(n - 1)
2121 };
2122 (at(q.x, ua, ub, cells.0), at(q.y, va, vb, cells.1))
2123}
2124
2125struct Grid {
2128 points: Vec<Point2>,
2129}
2130
2131impl Grid {
2132 fn over(
2135 surface: &BSplineSurface,
2136 outline: &[Point2],
2137 domain: ((f64, f64), (f64, f64)),
2138 ) -> Self {
2139 let ((ua, ub), (va, vb)) = domain;
2140 let spans = surface
2141 .u_knots()
2142 .control_point_count()
2143 .max(surface.v_knots().control_point_count());
2144 let grid = (4 * spans).max(64);
2145 #[expect(
2146 clippy::cast_precision_loss,
2147 reason = "a grid index, far below the mantissa"
2148 )]
2149 let ys: Vec<f64> = (0..=grid)
2150 .map(|j| (vb - va).mul_add(j as f64 / grid as f64, va))
2151 .collect();
2152 let rows = Crossings::of(outline, &ys);
2153 let mut points = Vec::new();
2154 for i in 0..=grid {
2155 #[expect(
2156 clippy::cast_precision_loss,
2157 reason = "a grid index, far below the mantissa"
2158 )]
2159 let x = (ub - ua).mul_add(i as f64 / grid as f64, ua);
2160 for (j, &y) in ys.iter().enumerate() {
2161 let q = Point2::new(x, y);
2162 if rows.inside(outline, j, q) {
2163 points.push(q);
2164 }
2165 }
2166 }
2167 Self { points }
2168 }
2169
2170 fn strays(
2173 &self,
2174 surface: &BSplineSurface,
2175 region: Region<'_>,
2176 tol: Tolerances,
2177 ) -> OgeomResult<Vec<Stray>> {
2178 let chunks: Vec<&[Point2]> = self.points.chunks(STRAY_CHUNK).collect();
2180 let parts = ogeom_core::parallel::map_ordered(&chunks, |_, chunk| {
2181 let mut found = Vec::new();
2182 for &q in *chunk {
2183 let p = surface.point_at(q.x, q.y, tol)?;
2184 let (d, normal) = region(p);
2185 if d > 0.0 {
2186 found.push((q, d, normal, p));
2187 }
2188 }
2189 Ok::<_, ogeom_core::OgeomError>(found)
2190 });
2191 let mut found = Vec::new();
2192 for part in parts {
2193 found.extend(part?);
2194 }
2195 Ok(found)
2196 }
2197}
2198
2199fn folds(
2200 surface: &BSplineSurface,
2201 outline: &[Point2],
2202 domain: ((f64, f64), (f64, f64)),
2203 tol: Tolerances,
2204) -> OgeomResult<bool> {
2205 const GRID: usize = 40;
2206 let ((ua, ub), (va, vb)) = domain;
2207 let mut normals = vec![None; (GRID + 1) * (GRID + 1)];
2208 let mut largest = 0.0f64;
2209 for i in 0..=GRID {
2210 for j in 0..=GRID {
2211 #[expect(
2212 clippy::cast_precision_loss,
2213 reason = "a grid index, far below the mantissa"
2214 )]
2215 let q = Point2::new(
2216 (ub - ua).mul_add(i as f64 / GRID as f64, ua),
2217 (vb - va).mul_add(j as f64 / GRID as f64, va),
2218 );
2219 if !inside(outline, q) {
2220 continue;
2221 }
2222 let (du, dv) = surface.d1_at(q.x, q.y, tol)?;
2223 let normal = du.cross(dv);
2224 largest = largest.max(normal.magnitude());
2225 normals[i * (GRID + 1) + j] = Some(normal);
2226 }
2227 }
2228 for i in 0..=GRID {
2229 for j in 0..=GRID {
2230 let Some(here) = normals[i * (GRID + 1) + j] else {
2231 continue;
2232 };
2233 if here.magnitude() <= 1e-6 * largest {
2234 return Ok(true);
2235 }
2236 let right = if i < GRID {
2237 normals[(i + 1) * (GRID + 1) + j]
2238 } else {
2239 None
2240 };
2241 let up = if j < GRID {
2242 normals[i * (GRID + 1) + j + 1]
2243 } else {
2244 None
2245 };
2246 if [right, up]
2247 .into_iter()
2248 .flatten()
2249 .any(|n| n.dot(here) <= 0.0)
2250 {
2251 return Ok(true);
2252 }
2253 }
2254 }
2255 Ok(false)
2256}
2257
2258fn measure_side(
2263 side: &Side,
2264 pcurve: &PlanarCurve,
2265 surface: &BSplineSurface,
2266 stations: usize,
2267 tol: Tolerances,
2268) -> OgeomResult<FillSide> {
2269 let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, None::<f64>);
2270 for k in 0..stations {
2271 #[expect(
2272 clippy::cast_precision_loss,
2273 reason = "a station index, far below the mantissa"
2274 )]
2275 let t = side.parameter(k as f64 / (stations - 1) as f64);
2276 let p = side.curve.point_at(t, tol)?;
2277 let uv = pcurve.point_at(t, tol)?;
2278 gap = gap.max(surface.point_at(uv.x, uv.y, tol)?.distance(p));
2279 let (Some((suv, theirs)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
2280 continue;
2281 };
2282 let ours = surface.normal_at(uv.x, uv.y, tol)?.vector();
2283 angle = angle.max(ours.cross(theirs).magnitude().atan2(ours.dot(theirs).abs()));
2284 let across = ours.cross(side.curve.d1_at(t, tol)?);
2285 let signed = |c: SurfaceCurvature| -> Option<f64> {
2286 let k = c.normal_curvature(across)?;
2287 Some(if c.normal.dot_vector(ours) < 0.0 {
2288 -k
2289 } else {
2290 k
2291 })
2292 };
2293 let a = surface.curvature_at(uv.x, uv.y, tol).ok().and_then(signed);
2294 let b = support
2295 .surface
2296 .curvature_at(suv.x, suv.y, tol)
2297 .ok()
2298 .and_then(signed);
2299 if let (Some(a), Some(b)) = (a, b) {
2300 curvature = Some(curvature.unwrap_or(0.0).max((a - b).abs()));
2301 }
2302 }
2303 let supported = side.support.is_some();
2304 Ok(FillSide {
2305 edge: side.edge.clone(),
2306 gap,
2307 angle: supported.then_some(angle),
2308 curvature: curvature.filter(|_| supported),
2309 stations,
2310 })
2311}
2312
2313fn first_miss(
2316 sides: &[Side],
2317 reports: &[FillSide],
2318 constraint_gap: f64,
2319 tolerance: f64,
2320) -> Option<String> {
2321 for (side, report) in sides.iter().zip(reports) {
2322 let i = side.entry;
2323 if report.gap > tolerance {
2324 return Some(format!("side {i} stands {} off its edge", report.gap));
2325 }
2326 if side.order >= 1 {
2327 let angle = report.angle.unwrap_or(f64::INFINITY);
2328 if angle > tolerance {
2329 return Some(format!(
2330 "side {i} meets its support {angle} radians from tangent"
2331 ));
2332 }
2333 }
2334 if side.order >= 2 {
2335 match report.curvature {
2336 Some(c) if c <= tolerance => {}
2337 Some(c) => {
2338 return Some(format!(
2339 "side {i}'s normal curvature differs from its support's by {c}"
2340 ));
2341 }
2342 None => {
2343 return Some(format!(
2344 "side {i}'s curvature could not be read on both surfaces"
2345 ));
2346 }
2347 }
2348 }
2349 }
2350 (constraint_gap > tolerance)
2351 .then(|| format!("a constraint stands {constraint_gap} off the surface"))
2352}
2353
2354fn build(
2358 model: &mut Model,
2359 sides: &mut [Side],
2360 order: &[usize],
2361 traces: &[PlanarCurve],
2362 reports: &[FillSide],
2363 surface: BSplineSurface,
2364 tol: Tolerances,
2365) -> OgeomResult<Shape> {
2366 stand_in(model, sides, order, tol)?;
2367 let walk: Vec<Shape> = order
2368 .iter()
2369 .map(|&i| {
2370 let side = &sides[i];
2371 if side.reversed {
2372 side.edge.reversed()
2373 } else {
2374 side.edge.clone()
2375 }
2376 })
2377 .collect();
2378 let wire = make_wire(model, &walk, tol)?.shape;
2379 let face = make_face(model, SurfaceGeometry::BSpline(surface), &[wire], tol)?.shape;
2380 let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
2381 ogeom_bail!(Dangling, "the face just built is not in this model");
2382 };
2383 let surface_id = data.surface;
2384 for ((side, pcurve), report) in sides.iter().zip(traces).zip(reports) {
2385 attach_pcurve(
2386 model,
2387 &side.edge,
2388 pcurve.clone(),
2389 surface_id,
2390 Location::identity(),
2391 side.range,
2392 )?;
2393 if report.gap > side.edge_tolerance {
2394 let widened = Tolerance::new(report.gap + tol.confusion())?;
2396 model.widen(&side.edge, widened)?;
2397 if let Some((a, b)) = edge_vertices(model, &side.edge)? {
2398 model.widen(&a, widened)?;
2399 model.widen(&b, widened)?;
2400 }
2401 }
2402 }
2403 Ok(face)
2404}
2405
2406fn stand_in(
2413 model: &mut Model,
2414 sides: &mut [Side],
2415 order: &[usize],
2416 tol: Tolerances,
2417) -> OgeomResult<()> {
2418 let n = order.len();
2419 let mut corners: Vec<(Shape, bool)> = Vec::with_capacity(n);
2422 for k in 0..n {
2423 let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % n]]);
2424 let (a0, a1) = ends_of(model, a)?;
2425 let (b0, b1) = ends_of(model, b)?;
2426 let (arrive, leave) = (
2427 if a.reversed { a0 } else { a1 },
2428 if b.reversed { b1 } else { b0 },
2429 );
2430 let (from, to) = (
2431 a.curve
2432 .point_at(if a.reversed { a.range.0 } else { a.range.1 }, tol)?,
2433 b.curve
2434 .point_at(if b.reversed { b.range.1 } else { b.range.0 }, tol)?,
2435 );
2436 if arrive.vertex.is_same(&leave.vertex) && arrive.vertex.location().is_identity() {
2437 corners.push((arrive.vertex, true));
2438 continue;
2439 }
2440 let vertex = make_vertex(model, from.midpoint(to)).shape;
2441 let reach = (0.5 * from.distance(to) + tol.confusion())
2442 .max(a.edge_tolerance)
2443 .max(b.edge_tolerance);
2444 model.widen(&vertex, Tolerance::new(reach)?)?;
2445 corners.push((vertex, false));
2446 }
2447 for k in 0..n {
2448 let side = &sides[order[k]];
2449 let (start, end) = (&corners[(k + n - 1) % n], &corners[k]);
2450 if !side.placed && start.1 && end.1 {
2451 continue;
2452 }
2453 let (from, to) = if side.reversed {
2454 (&end.0, &start.0)
2455 } else {
2456 (&start.0, &end.0)
2457 };
2458 let edge = make_edge_between(model, side.curve.clone(), side.range, from, to, tol)?.shape;
2459 model.widen(&edge, Tolerance::new(side.edge_tolerance)?)?;
2460 sides[order[k]].edge = edge;
2461 }
2462 Ok(())
2463}