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_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;
93const POINT_SHARE: f64 = 0.05;
96const PER_CURVE: usize = 32;
98
99pub fn make_filling_n(
156 model: &mut Model,
157 boundary: &[FillBoundary],
158 constraints: &[Shape],
159 tolerance: f64,
160 tol: Tolerances,
161) -> OgeomResult<Filled> {
162 if !(tolerance.is_finite() && tolerance > 0.0) {
163 ogeom_bail!(
164 Construction,
165 "a filling's tolerance is positive and finite; got {tolerance}"
166 );
167 }
168 if boundary.is_empty() {
169 ogeom_bail!(Construction, "a filling needs at least one boundary edge");
170 }
171 let mut sides = Vec::with_capacity(boundary.len());
172 for (i, entry) in boundary.iter().enumerate() {
173 sides.push(read_side(model, i, entry, tolerance, tol)?);
174 }
175 for i in 0..sides.len() {
176 for j in (i + 1)..sides.len() {
177 if sides[i].given.is_same(&sides[j].given) {
178 ogeom_bail!(Construction, "side {j} is side {i}'s edge again");
179 }
180 }
181 }
182 let mut order = chain(model, &mut sides, tol)?;
183 face_the_supports(model, &mut sides, &mut order)?;
184
185 let outline = loop_points(&sides, &order, tol)?;
187 let frame = frame_of(&outline, tol)?;
188 let chart_outline: Vec<Point2> = outline.iter().map(|p| frame.chart(*p)).collect();
189 if crosses_itself(&chart_outline) {
190 ogeom_bail!(
191 Construction,
192 "the boundary loop crosses itself seen along the normal of the \
193 plane it spans; the hole is not a height field over that plane"
194 );
195 }
196 let interior = constraint_points(model, constraints, tol)?;
197 for (k, (p, _)) in interior.iter().enumerate() {
198 if !inside(&chart_outline, frame.chart(*p)) {
199 ogeom_bail!(
200 Construction,
201 "a point of constraint {k} at {p:?} lies outside the hole seen \
202 along the normal of the plane the boundary spans"
203 );
204 }
205 }
206
207 let (mut lo, mut hi) = (
209 Point2::new(f64::INFINITY, f64::INFINITY),
210 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
211 );
212 for q in &chart_outline {
213 lo = Point2::new(lo.x.min(q.x), lo.y.min(q.y));
214 hi = Point2::new(hi.x.max(q.x), hi.y.max(q.y));
215 }
216 let margin = MARGIN * (hi.x - lo.x).max(hi.y - lo.y);
217 let domain = (
218 (lo.x - margin, hi.x + margin),
219 (lo.y - margin, hi.y + margin),
220 );
221 let (du, dv) = (domain.0.1 - domain.0.0, domain.1.1 - domain.1.0);
222 let size = du.max(dv);
223
224 let mut traces = Vec::with_capacity(sides.len());
225 let mut lengths = Vec::with_capacity(sides.len());
226 for side in &sides {
227 traces.push(trace(side, &frame, tolerance, tol)?);
228 lengths.push(side_length(side, tol)?);
229 }
230
231 let smoothing = smoothing_for(&sides, tolerance, size);
232 let mut last_miss = String::new();
233 let mut fallback = None;
235 for base in NETS {
236 #[expect(
237 clippy::cast_possible_truncation,
238 clippy::cast_sign_loss,
239 clippy::cast_precision_loss,
240 reason = "a control count of a few dozen, from a positive ratio"
241 )]
242 let count = |extent: f64| -> usize {
243 ((base as f64 * extent / size).round() as usize).max(DEGREE + 2)
244 };
245 let controls = (count(du), count(dv));
246 let samples = 4 * controls.0.max(controls.1) + 8;
247
248 let mut conditions = Vec::new();
249 for (side, length) in sides.iter().zip(&lengths) {
250 side_conditions(
251 side,
252 *length / size,
253 size,
254 samples,
255 &frame,
256 &mut conditions,
257 tol,
258 )?;
259 }
260 for (p, share) in &interior {
261 conditions.push(Condition {
262 at: frame.chart(*p),
263 order: (0, 0),
264 target: frame.height(*p),
265 weight: share.sqrt() / size,
266 });
267 }
268 let fit = fit_height(&frame, domain, controls, &conditions, smoothing, tol)?;
269 let surface = fit.surface;
270
271 let mut reports = Vec::with_capacity(sides.len());
272 for (side, pcurve) in sides.iter().zip(&traces) {
273 reports.push(measure_side(side, pcurve, &surface, 3 * samples + 1, tol)?);
274 }
275 let mut constraint_gap = 0.0f64;
276 for (p, _) in &interior {
277 let q = frame.chart(*p);
278 constraint_gap = constraint_gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
279 }
280
281 if let Some(miss) = first_miss(&sides, &reports, constraint_gap, tolerance) {
282 last_miss = format!("at {}x{} controls, {miss}", controls.0, controls.1);
283 continue;
284 }
285 fallback = Some((surface, reports, constraint_gap));
286 if fit.residual <= smoothing {
288 break;
289 }
290 }
291 let Some((surface, reports, constraint_gap)) = fallback else {
292 ogeom_bail!(
293 NotDone,
294 "the filling misses its tolerance of {tolerance} on the finest net: {last_miss}"
295 )
296 };
297 let face = build(model, &mut sides, &order, &traces, &reports, surface, tol)?;
298 let mut history = History::new();
299 let mut reports = reports;
300 for (side, report) in sides.iter().zip(&mut reports) {
301 history.generate(&side.given, face.clone());
302 if !side.edge.is_same(&side.given) {
303 history.generate(&side.given, side.edge.clone());
304 }
305 report.edge = side.edge.clone();
306 }
307 for constraint in constraints {
308 history.generate(constraint, face.clone());
309 }
310 Ok(Filled {
311 built: Built::new(face, history),
312 sides: reports,
313 constraint_gap,
314 })
315}
316
317struct Support {
319 face: Shape,
320 surface: SurfaceGeometry,
321 pcurve: PlanarCurve,
322 prange: (f64, f64),
323}
324
325struct Side {
327 entry: usize,
328 given: Shape,
330 edge: Shape,
333 placed: bool,
335 curve: Curve,
337 range: (f64, f64),
338 edge_tolerance: f64,
339 order: usize,
341 support: Option<Support>,
342 reversed: bool,
344}
345
346impl Side {
347 fn parameter(&self, f: f64) -> f64 {
348 (self.range.1 - self.range.0).mul_add(f, self.range.0)
349 }
350
351 fn support_at(&self, t: f64, tol: Tolerances) -> OgeomResult<Option<(Point2, Vector)>> {
354 let Some(support) = &self.support else {
355 return Ok(None);
356 };
357 let f = (t - self.range.0) / (self.range.1 - self.range.0);
358 let pt = (support.prange.1 - support.prange.0).mul_add(f, support.prange.0);
359 let uv = support.pcurve.point_at(pt, tol)?;
360 let normal = support.surface.normal_at(uv.x, uv.y, tol)?.vector();
361 Ok(Some((uv, normal)))
362 }
363}
364
365fn read_side(
366 model: &Model,
367 i: usize,
368 entry: &FillBoundary,
369 tolerance: f64,
370 tol: Tolerances,
371) -> OgeomResult<Side> {
372 let edge = &entry.edge;
373 if model.kind_of(edge)? != ShapeType::Edge {
374 ogeom_bail!(Construction, "side {i} is not an edge");
375 }
376 let order = match entry.continuity {
377 Continuity::C0 => 0,
378 Continuity::G1 => 1,
379 Continuity::G2 => 2,
380 other => ogeom_bail!(
381 Construction,
382 "side {i} asks {other:?}: parametric continuity between two \
383 surfaces' charts is not what a filling meets; ask for G1 or G2"
384 ),
385 };
386 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
387 ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
388 };
389 let Some(EdgeRepr::Curve3d {
390 curve,
391 range,
392 location: own,
393 }) = data.curve3d()
394 else {
395 ogeom_bail!(Construction, "side {i}'s edge has no 3D curve");
396 };
397 let Some(curve) = model.geometry().curve(*curve).cloned() else {
398 ogeom_bail!(Dangling, "side {i}'s curve is not in this model");
399 };
400 let range = *range;
401 let edge_tolerance = data.tolerance.get();
402 let placed = !(edge.location().is_identity() && own.is_identity());
403 let curve = if placed {
404 curve
405 .transformed(&own.composed(model.datums())?, tol)?
406 .transformed(&edge.transform(model.datums())?, tol)?
407 } else {
408 curve
409 };
410 let edge = edge.oriented(Orientation::Forward);
411
412 let support = match &entry.support {
413 None if order > 0 => ogeom_bail!(
414 Construction,
415 "side {i} asks {:?} but names no support face to meet",
416 entry.continuity
417 ),
418 None => None,
419 Some(face) => Some(read_support(
420 model,
421 i,
422 face,
423 &edge,
424 &curve,
425 range,
426 tolerance.max(edge_tolerance),
427 tol,
428 )?),
429 };
430 Ok(Side {
431 entry: i,
432 given: edge.clone(),
433 edge,
434 placed,
435 curve,
436 range,
437 edge_tolerance,
438 order,
439 support,
440 reversed: false,
441 })
442}
443
444#[expect(
445 clippy::too_many_arguments,
446 reason = "the side's edge, curve and range are read once by the caller"
447)]
448fn read_support(
449 model: &Model,
450 i: usize,
451 face: &Shape,
452 edge: &Shape,
453 curve: &Curve,
454 range: (f64, f64),
455 reach: f64,
456 tol: Tolerances,
457) -> OgeomResult<Support> {
458 if model.kind_of(face)? != ShapeType::Face {
459 ogeom_bail!(Construction, "side {i}'s support is not a face");
460 }
461 let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
462 ogeom_bail!(Dangling, "side {i}'s support is not in this model");
463 };
464 let face_placed = !(face.location().is_identity() && face_data.location.is_identity());
465 let holds = explore(model, face, Filter::OfType(ShapeType::Edge))?
466 .iter()
467 .any(|e| e.is_same(edge));
468 if !holds {
469 ogeom_bail!(
470 Construction,
471 "side {i}'s support face does not hold the side's edge at its placement"
472 );
473 }
474 let surface_id = face_data.surface;
475 let Some(surface) = model.geometry().surface(surface_id).cloned() else {
476 ogeom_bail!(Dangling, "side {i}'s support surface is not in this model");
477 };
478 let surface = if face_placed {
479 surface
480 .transformed(&face_data.location.composed(model.datums())?, tol)?
481 .transformed(&face.transform(model.datums())?, tol)?
482 } else {
483 surface
484 };
485 let Some(edge_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 stored = match edge_data
491 .pcurve_for(surface_id, edge.location())
492 .filter(|_| !face_placed)
493 {
494 Some(
495 EdgeRepr::PCurve { curve, range, .. }
496 | EdgeRepr::Seam {
497 forward: curve,
498 range,
499 ..
500 },
501 ) => model
502 .geometry()
503 .pcurve(*curve)
504 .cloned()
505 .map(|c| (c, *range)),
506 _ => None,
507 };
508 let off = |pcurve: &PlanarCurve, prange: (f64, f64)| -> OgeomResult<f64> {
512 let mut worst = 0.0f64;
513 for k in 0..=16 {
514 let f = f64::from(k) / 16.0;
515 let t = (range.1 - range.0).mul_add(f, range.0);
516 let pt = (prange.1 - prange.0).mul_add(f, prange.0);
517 let uv = pcurve.point_at(pt, tol)?;
518 let on = surface.point_at(uv.x, uv.y, tol)?;
519 worst = worst.max(on.distance(curve.point_at(t, tol)?));
520 }
521 Ok(worst)
522 };
523 let stored = match stored {
524 Some((pcurve, prange)) => {
525 let worst = off(&pcurve, prange)?;
526 (worst <= reach).then_some((pcurve, prange, worst))
527 }
528 None => None,
529 };
530 let (pcurve, prange, worst) = if let Some(found) = stored {
531 found
532 } else {
533 let (fitted, _, _, _, _) =
534 ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, &surface, tol)?;
535 let worst = off(&fitted, range)?;
536 (fitted, range, worst)
537 };
538 if worst > reach {
539 ogeom_bail!(
540 Construction,
541 "side {i}'s edge stands {worst} off its support face, past {reach}"
542 );
543 }
544 Ok(Support {
545 face: face.clone(),
546 surface,
547 pcurve,
548 prange,
549 })
550}
551
552#[derive(Clone)]
555struct End {
556 vertex: Shape,
557 at: Point,
558 reach: f64,
559}
560
561fn ends_of(model: &Model, side: &Side) -> OgeomResult<(End, End)> {
563 let Some((a, b)) = edge_vertices(model, &side.given)? else {
564 ogeom_bail!(
565 Construction,
566 "side {} has no vertices, so it cannot be shown to join the loop",
567 side.entry
568 );
569 };
570 let end = |vertex: Shape| -> OgeomResult<End> {
571 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
572 ogeom_bail!(Construction, "side {}'s vertex holds no point", side.entry);
573 };
574 Ok(End {
575 at: vertex.transform(model.datums())?.apply(data.point),
576 reach: data.tolerance.get().max(side.edge_tolerance),
577 vertex,
578 })
579 };
580 Ok((end(a)?, end(b)?))
581}
582
583fn chain(model: &Model, sides: &mut [Side], tol: Tolerances) -> OgeomResult<Vec<usize>> {
588 let mut ends = Vec::with_capacity(sides.len());
589 for side in sides.iter() {
590 let (a, b) = ends_of(model, side)?;
591 ends.push((a, b));
592 }
593 let same = |a: &End, b: &End| -> OgeomResult<bool> {
594 Ok(a.vertex.is_same(&b.vertex) || model.same_position(&a.vertex, &b.vertex, tol)?)
595 };
596 let near = |a: &End, b: &End| a.at.distance(b.at) <= a.reach + b.reach + tol.confusion();
597 let meets = |a: &End, b: &End| -> OgeomResult<bool> { Ok(same(a, b)? || near(a, b)) };
598 let n = sides.len();
599 let mut used = vec![false; n];
600 used[0] = true;
601 let mut order = vec![0];
602 let first = ends[0].0.clone();
603 let mut cursor = ends[0].1.clone();
604 while order.len() < n {
605 let last = sides[order[order.len() - 1]].entry;
606 let mut found: Vec<(usize, bool)> = Vec::new();
609 for strict in [true, false] {
610 for j in 0..n {
611 if used[j] {
612 continue;
613 }
614 let joins = |e: &End| -> OgeomResult<bool> {
615 if strict {
616 same(e, &cursor)
617 } else {
618 meets(e, &cursor)
619 }
620 };
621 if joins(&ends[j].0)? {
622 found.push((j, false));
623 } else if joins(&ends[j].1)? {
624 found.push((j, true));
625 }
626 }
627 if !found.is_empty() {
628 break;
629 }
630 }
631 let (j, reversed) = match found.as_slice() {
632 [one] => *one,
633 [] => ogeom_bail!(
634 Construction,
635 "the boundary does not close: no side continues where side {last} ends"
636 ),
637 _ => ogeom_bail!(
638 Construction,
639 "{} sides continue where side {last} ends; a filling's boundary \
640 is one simple loop",
641 found.len()
642 ),
643 };
644 used[j] = true;
645 sides[j].reversed = reversed;
646 cursor = if reversed {
647 ends[j].0.clone()
648 } else {
649 ends[j].1.clone()
650 };
651 order.push(j);
652 }
653 if !meets(&cursor, &first)? {
654 ogeom_bail!(
655 Construction,
656 "the boundary does not close: the last side ends away from where the first begins"
657 );
658 }
659 Ok(order)
660}
661
662fn face_the_supports(model: &Model, sides: &mut [Side], order: &mut [usize]) -> OgeomResult<()> {
665 let (mut agree, mut disagree) = (0usize, 0usize);
666 for side in sides.iter() {
667 let Some(support) = &side.support else {
668 continue;
669 };
670 let uses: Vec<Orientation> =
671 explore(model, &support.face, Filter::OfType(ShapeType::Edge))?
672 .iter()
673 .filter(|e| e.is_same(&side.given))
674 .map(Shape::orientation)
675 .collect();
676 let Some(&first) = uses.first() else {
677 continue;
678 };
679 if uses.iter().any(|o| *o != first)
680 || !matches!(first, Orientation::Forward | Orientation::Reversed)
681 {
682 continue;
683 }
684 if (first == Orientation::Forward) == !side.reversed {
685 disagree += 1;
686 } else {
687 agree += 1;
688 }
689 }
690 if disagree > agree {
691 order.reverse();
692 for side in sides.iter_mut() {
693 side.reversed = !side.reversed;
694 }
695 }
696 Ok(())
697}
698
699fn loop_points(sides: &[Side], order: &[usize], tol: Tolerances) -> OgeomResult<Vec<Point>> {
701 let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
702 for &i in order {
703 let side = &sides[i];
704 for k in 0..OUTLINE_SAMPLES {
705 #[expect(
706 clippy::cast_precision_loss,
707 reason = "a sample index, far below the mantissa"
708 )]
709 let mut f = k as f64 / OUTLINE_SAMPLES as f64;
710 if side.reversed {
711 f = 1.0 - f;
712 }
713 out.push(side.curve.point_at(side.parameter(f), tol)?);
714 }
715 }
716 Ok(out)
717}
718
719fn frame_of(outline: &[Point], tol: Tolerances) -> OgeomResult<PlaneFrame> {
723 let Ok(origin) = Point::centroid(outline) else {
724 ogeom_bail!(Construction, "the boundary loop has no points");
725 };
726 let mut area = Vector::ZERO;
727 let mut reach = 0.0f64;
728 for (k, p) in outline.iter().enumerate() {
729 let q = outline[(k + 1) % outline.len()];
730 area += (*p - origin).cross(q - origin) * 0.5;
731 reach = reach.max(p.distance(origin));
732 }
733 let magnitude = area.magnitude();
734 if magnitude <= 1e-9 * reach * reach || magnitude <= tol.confusion() * tol.confusion() {
735 ogeom_bail!(
736 Construction,
737 "the boundary loop encloses no area seen from any plane"
738 );
739 }
740 let n = area * (1.0 / magnitude);
741 let b1 = Direction::new(n, tol)?.any_perpendicular().vector();
742 let b2 = n.cross(b1);
743 let (mut sxx, mut syy, mut sxy) = (0.0f64, 0.0f64, 0.0f64);
744 for p in outline {
745 let d = *p - origin;
746 let (x, y) = (d.dot(b1), d.dot(b2));
747 sxx += x * x;
748 syy += y * y;
749 sxy += x * y;
750 }
751 let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
752 let e1 = b1 * theta.cos() + b2 * theta.sin();
753 let e2 = n.cross(e1);
754 Ok(PlaneFrame { origin, e1, e2, n })
755}
756
757fn crosses_itself(polygon: &[Point2]) -> bool {
760 let n = polygon.len();
761 let orient = |a: Point2, b: Point2, c: Point2| (b - a).cross(c - a);
762 for i in 0..n {
763 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
764 for j in (i + 2)..n {
765 if i == 0 && j == n - 1 {
766 continue;
767 }
768 let (c, d) = (polygon[j], polygon[(j + 1) % n]);
769 if orient(a, b, c) * orient(a, b, d) < 0.0 && orient(c, d, a) * orient(c, d, b) < 0.0 {
770 return true;
771 }
772 }
773 }
774 false
775}
776
777fn inside(polygon: &[Point2], q: Point2) -> bool {
779 let n = polygon.len();
780 let mut winding = 0i32;
781 for i in 0..n {
782 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
783 let side = (b - a).cross(q - a);
784 if a.y <= q.y {
785 if b.y > q.y && side > 0.0 {
786 winding += 1;
787 }
788 } else if b.y <= q.y && side < 0.0 {
789 winding -= 1;
790 }
791 }
792 winding != 0
793}
794
795fn constraint_points(
798 model: &Model,
799 constraints: &[Shape],
800 tol: Tolerances,
801) -> OgeomResult<Vec<(Point, f64)>> {
802 let mut out = Vec::new();
803 for (k, shape) in constraints.iter().enumerate() {
804 let placement = shape.transform(model.datums())?;
805 match model.kind_of(shape)? {
806 ShapeType::Vertex => {
807 let Some(data) = model.node(shape).and_then(|n| n.data().as_vertex()) else {
808 ogeom_bail!(Construction, "constraint {k} holds no vertex data");
809 };
810 out.push((placement.apply(data.point), POINT_SHARE));
811 }
812 ShapeType::Edge => {
813 let Some(data) = model.node(shape).and_then(|n| n.data().as_edge()) else {
814 ogeom_bail!(Construction, "constraint {k} holds no edge data");
815 };
816 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
817 ogeom_bail!(Construction, "constraint {k} has no 3D curve");
818 };
819 let Some(curve) = model.geometry().curve(*curve) else {
820 ogeom_bail!(Dangling, "constraint {k}'s curve is not in this model");
821 };
822 for i in 0..=PER_CURVE {
823 #[expect(
824 clippy::cast_precision_loss,
825 reason = "a sample index, far below the mantissa"
826 )]
827 let f = i as f64 / PER_CURVE as f64;
828 let t = (range.1 - range.0).mul_add(f, range.0);
829 out.push((placement.apply(curve.point_at(t, tol)?), POINT_SHARE / 4.0));
830 }
831 }
832 other => ogeom_bail!(
833 Construction,
834 "constraint {k} is a {other:?}; a filling passes through vertices and edges"
835 ),
836 }
837 }
838 Ok(out)
839}
840
841fn untrimmed(curve: &Curve) -> &Curve {
843 match curve {
844 Curve::Trimmed(t) if !t.is_reversed() => untrimmed(t.basis()),
845 other => other,
846 }
847}
848
849fn trace(
854 side: &Side,
855 frame: &PlaneFrame,
856 tolerance: f64,
857 tol: Tolerances,
858) -> OgeomResult<PlanarCurve> {
859 let truth = |t: f64| -> OgeomResult<Point2> { Ok(frame.chart(side.curve.point_at(t, tol)?)) };
860 let (t0, t1) = side.range;
861 let exact: Option<PlanarCurve> = match untrimmed(&side.curve) {
862 Curve::BSpline(spline) => {
863 let mut control = Vec::with_capacity(spline.control_points().len());
864 for w in spline.control_points() {
865 control.push(Weighted::new(frame.chart(w.point()), w.weight, tol)?);
866 }
867 BSpline2d::rational(spline.knots().clone(), control)
868 .ok()
869 .map(PlanarCurve::BSpline)
870 }
871 _ => match side.curve.kind() {
872 CurveKind::Line => {
873 let (q0, q1) = (truth(t0)?, truth(t1)?);
874 let d = (q1 - q0) * (1.0 / (t1 - t0));
875 let c = q0 - d * t0;
876 Trig2d::new(c, d, Vector2::ZERO, Vector2::ZERO, side.range)
877 .ok()
878 .map(PlanarCurve::Trig)
879 }
880 CurveKind::Circle | CurveKind::Ellipse => {
881 let ts = [
884 t0,
885 (t1 - t0).mul_add(1.0 / 3.0, t0),
886 (t1 - t0).mul_add(2.0 / 3.0, t0),
887 ];
888 let rows = ts.map(|t| [1.0, t.cos(), t.sin()]);
889 let values = [truth(ts[0])?, truth(ts[1])?, truth(ts[2])?];
890 match (
891 solve3(rows, values.map(|q| q.x)),
892 solve3(rows, values.map(|q| q.y)),
893 ) {
894 (Some(x), Some(y)) => Trig2d::new(
895 Point2::new(x[0], y[0]),
896 Vector2::ZERO,
897 Vector2::new(x[1], y[1]),
898 Vector2::new(x[2], y[2]),
899 side.range,
900 )
901 .ok()
902 .map(PlanarCurve::Trig),
903 _ => None,
904 }
905 }
906 _ => None,
907 },
908 };
909 if let Some(curve) = exact {
910 let mut worst = 0.0f64;
911 let mut scale = 1.0f64;
912 for k in 0..=32 {
913 let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
914 let q = truth(t)?;
915 scale = scale.max(q.to_vector().magnitude());
916 worst = worst.max(curve.point_at(t, tol)?.distance(q));
917 }
918 if worst <= 1e-12 * scale + 1e-3 * tol.confusion() {
919 return Ok(curve);
920 }
921 }
922 const SAMPLES: usize = 96;
923 let mut parameters = Vec::with_capacity(SAMPLES + 1);
924 let mut points = Vec::with_capacity(SAMPLES + 1);
925 for k in 0..=SAMPLES {
926 #[expect(
927 clippy::cast_precision_loss,
928 reason = "a sample index, far below the mantissa"
929 )]
930 let t = (t1 - t0).mul_add(k as f64 / SAMPLES as f64, t0);
931 parameters.push(t);
932 points.push(truth(t)?);
933 }
934 let fitted = ogeom_geom::fit::fit_points_2d_at(¶meters, &points, 3, tolerance * 1e-2, tol)?;
935 Ok(PlanarCurve::BSpline(fitted.curve))
936}
937
938fn solve3(rows: [[f64; 3]; 3], rhs: [f64; 3]) -> Option<[f64; 3]> {
940 let det = |m: [[f64; 3]; 3]| {
941 m[0][0] * m[1][1].mul_add(m[2][2], -m[1][2] * m[2][1])
942 - m[0][1] * m[1][0].mul_add(m[2][2], -m[1][2] * m[2][0])
943 + m[0][2] * m[1][0].mul_add(m[2][1], -m[1][1] * m[2][0])
944 };
945 let d = det(rows);
946 if d.abs() < 1e-12 {
947 return None;
948 }
949 let mut out = [0.0; 3];
950 for (c, slot) in out.iter_mut().enumerate() {
951 let mut m = rows;
952 for r in 0..3 {
953 m[r][c] = rhs[r];
954 }
955 *slot = det(m) / d;
956 }
957 Some(out)
958}
959
960fn smoothing_for(sides: &[Side], tolerance: f64, size: f64) -> f64 {
988 let order = sides.iter().map(|s| s.order).max().unwrap_or(0);
989 let mut eps = tolerance / size;
990 if order >= 1 {
991 eps = eps.min(tolerance);
992 }
993 if order >= 2 {
994 eps = eps.min(tolerance * size);
995 }
996 (BUDGET * eps).powi(2).max(LEAST_SMOOTHING)
997}
998
999fn side_length(side: &Side, tol: Tolerances) -> OgeomResult<f64> {
1001 let mut length = 0.0;
1002 let mut previous = side.curve.point_at(side.range.0, tol)?;
1003 for k in 1..=OUTLINE_SAMPLES {
1004 #[expect(
1005 clippy::cast_precision_loss,
1006 reason = "a sample index, far below the mantissa"
1007 )]
1008 let f = k as f64 / OUTLINE_SAMPLES as f64;
1009 let p = side.curve.point_at(side.parameter(f), tol)?;
1010 length += p.distance(previous);
1011 previous = p;
1012 }
1013 Ok(length)
1014}
1015
1016fn side_conditions(
1021 side: &Side,
1022 share: f64,
1023 size: f64,
1024 samples: usize,
1025 frame: &PlaneFrame,
1026 out: &mut Vec<Condition>,
1027 tol: Tolerances,
1028) -> OgeomResult<()> {
1029 #[expect(
1030 clippy::cast_precision_loss,
1031 reason = "a sample count, far below the mantissa"
1032 )]
1033 let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1034 for k in 0..=samples {
1035 #[expect(
1036 clippy::cast_precision_loss,
1037 reason = "a sample index, far below the mantissa"
1038 )]
1039 let t = side.parameter(k as f64 / samples as f64);
1040 let p = side.curve.point_at(t, tol)?;
1041 let at = frame.chart(p);
1042 out.push(Condition {
1043 at,
1044 order: (0, 0),
1045 target: frame.height(p),
1046 weight: root / size,
1047 });
1048 if side.order == 0 {
1049 continue;
1050 }
1051 let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1052 continue;
1053 };
1054 let lift = normal.dot(frame.n);
1055 if lift.abs() < MIN_LIFT {
1056 ogeom_bail!(
1057 Construction,
1058 "side {}'s support stands within {:.1} degrees of square to the \
1059 plane the boundary spans; the hole is not a height field over it",
1060 side.entry,
1061 lift.abs().asin().to_degrees()
1062 );
1063 }
1064 let normal = if lift < 0.0 { normal * -1.0 } else { normal };
1067 let lift = lift.abs();
1068 let (hu, hv) = (-normal.dot(frame.e1) / lift, -normal.dot(frame.e2) / lift);
1069 out.push(Condition {
1070 at,
1071 order: (1, 0),
1072 target: hu,
1073 weight: root,
1074 });
1075 out.push(Condition {
1076 at,
1077 order: (0, 1),
1078 target: hv,
1079 weight: root,
1080 });
1081 if side.order < 2 {
1082 continue;
1083 }
1084 let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1088 let sense = if curvature.normal.dot_vector(normal) < 0.0 {
1089 -1.0
1090 } else {
1091 1.0
1092 };
1093 let (dmax, dmin) = (
1094 curvature.max_direction.vector(),
1095 curvature.min_direction.vector(),
1096 );
1097 let second = |a: Vector, b: Vector| {
1098 sense
1099 * curvature.max.mul_add(
1100 a.dot(dmax) * b.dot(dmax),
1101 curvature.min * a.dot(dmin) * b.dot(dmin),
1102 )
1103 / lift
1104 };
1105 let su = frame.e1 + frame.n * hu;
1106 let sv = frame.e2 + frame.n * hv;
1107 for (order, target) in [
1108 ((2, 0), second(su, su)),
1109 ((1, 1), second(su, sv)),
1110 ((0, 2), second(sv, sv)),
1111 ] {
1112 out.push(Condition {
1113 at,
1114 order,
1115 target,
1116 weight: root * size,
1117 });
1118 }
1119 }
1120 Ok(())
1121}
1122
1123fn measure_side(
1128 side: &Side,
1129 pcurve: &PlanarCurve,
1130 surface: &BSplineSurface,
1131 stations: usize,
1132 tol: Tolerances,
1133) -> OgeomResult<FillSide> {
1134 let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, None::<f64>);
1135 for k in 0..stations {
1136 #[expect(
1137 clippy::cast_precision_loss,
1138 reason = "a station index, far below the mantissa"
1139 )]
1140 let t = side.parameter(k as f64 / (stations - 1) as f64);
1141 let p = side.curve.point_at(t, tol)?;
1142 let uv = pcurve.point_at(t, tol)?;
1143 gap = gap.max(surface.point_at(uv.x, uv.y, tol)?.distance(p));
1144 let (Some((suv, theirs)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1145 continue;
1146 };
1147 let ours = surface.normal_at(uv.x, uv.y, tol)?.vector();
1148 angle = angle.max(ours.cross(theirs).magnitude().atan2(ours.dot(theirs).abs()));
1149 let across = ours.cross(side.curve.d1_at(t, tol)?);
1150 let signed = |c: SurfaceCurvature| -> Option<f64> {
1151 let k = c.normal_curvature(across)?;
1152 Some(if c.normal.dot_vector(ours) < 0.0 {
1153 -k
1154 } else {
1155 k
1156 })
1157 };
1158 let a = surface.curvature_at(uv.x, uv.y, tol).ok().and_then(signed);
1159 let b = support
1160 .surface
1161 .curvature_at(suv.x, suv.y, tol)
1162 .ok()
1163 .and_then(signed);
1164 if let (Some(a), Some(b)) = (a, b) {
1165 curvature = Some(curvature.unwrap_or(0.0).max((a - b).abs()));
1166 }
1167 }
1168 let supported = side.support.is_some();
1169 Ok(FillSide {
1170 edge: side.edge.clone(),
1171 gap,
1172 angle: supported.then_some(angle),
1173 curvature: curvature.filter(|_| supported),
1174 stations,
1175 })
1176}
1177
1178fn first_miss(
1181 sides: &[Side],
1182 reports: &[FillSide],
1183 constraint_gap: f64,
1184 tolerance: f64,
1185) -> Option<String> {
1186 for (side, report) in sides.iter().zip(reports) {
1187 let i = side.entry;
1188 if report.gap > tolerance {
1189 return Some(format!("side {i} stands {} off its edge", report.gap));
1190 }
1191 if side.order >= 1 {
1192 let angle = report.angle.unwrap_or(f64::INFINITY);
1193 if angle > tolerance {
1194 return Some(format!(
1195 "side {i} meets its support {angle} radians from tangent"
1196 ));
1197 }
1198 }
1199 if side.order >= 2 {
1200 match report.curvature {
1201 Some(c) if c <= tolerance => {}
1202 Some(c) => {
1203 return Some(format!(
1204 "side {i}'s normal curvature differs from its support's by {c}"
1205 ));
1206 }
1207 None => {
1208 return Some(format!(
1209 "side {i}'s curvature could not be read on both surfaces"
1210 ));
1211 }
1212 }
1213 }
1214 }
1215 (constraint_gap > tolerance)
1216 .then(|| format!("a constraint stands {constraint_gap} off the surface"))
1217}
1218
1219fn build(
1223 model: &mut Model,
1224 sides: &mut [Side],
1225 order: &[usize],
1226 traces: &[PlanarCurve],
1227 reports: &[FillSide],
1228 surface: BSplineSurface,
1229 tol: Tolerances,
1230) -> OgeomResult<Shape> {
1231 stand_in(model, sides, order, tol)?;
1232 let walk: Vec<Shape> = order
1233 .iter()
1234 .map(|&i| {
1235 let side = &sides[i];
1236 if side.reversed {
1237 side.edge.reversed()
1238 } else {
1239 side.edge.clone()
1240 }
1241 })
1242 .collect();
1243 let wire = make_wire(model, &walk, tol)?.shape;
1244 let face = make_face(model, SurfaceGeometry::BSpline(surface), &[wire], tol)?.shape;
1245 let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
1246 ogeom_bail!(Dangling, "the face just built is not in this model");
1247 };
1248 let surface_id = data.surface;
1249 for ((side, pcurve), report) in sides.iter().zip(traces).zip(reports) {
1250 attach_pcurve(
1251 model,
1252 &side.edge,
1253 pcurve.clone(),
1254 surface_id,
1255 Location::identity(),
1256 side.range,
1257 )?;
1258 if report.gap > side.edge_tolerance {
1259 let widened = Tolerance::new(report.gap + tol.confusion())?;
1261 model.widen(&side.edge, widened)?;
1262 if let Some((a, b)) = edge_vertices(model, &side.edge)? {
1263 model.widen(&a, widened)?;
1264 model.widen(&b, widened)?;
1265 }
1266 }
1267 }
1268 Ok(face)
1269}
1270
1271fn stand_in(
1278 model: &mut Model,
1279 sides: &mut [Side],
1280 order: &[usize],
1281 tol: Tolerances,
1282) -> OgeomResult<()> {
1283 let n = order.len();
1284 let mut corners: Vec<(Shape, bool)> = Vec::with_capacity(n);
1287 for k in 0..n {
1288 let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % n]]);
1289 let (a0, a1) = ends_of(model, a)?;
1290 let (b0, b1) = ends_of(model, b)?;
1291 let (arrive, leave) = (
1292 if a.reversed { a0 } else { a1 },
1293 if b.reversed { b1 } else { b0 },
1294 );
1295 let (from, to) = (
1296 a.curve
1297 .point_at(if a.reversed { a.range.0 } else { a.range.1 }, tol)?,
1298 b.curve
1299 .point_at(if b.reversed { b.range.1 } else { b.range.0 }, tol)?,
1300 );
1301 if arrive.vertex.is_same(&leave.vertex) && arrive.vertex.location().is_identity() {
1302 corners.push((arrive.vertex, true));
1303 continue;
1304 }
1305 let vertex = make_vertex(model, from.midpoint(to)).shape;
1306 let reach = (0.5 * from.distance(to) + tol.confusion())
1307 .max(a.edge_tolerance)
1308 .max(b.edge_tolerance);
1309 model.widen(&vertex, Tolerance::new(reach)?)?;
1310 corners.push((vertex, false));
1311 }
1312 for k in 0..n {
1313 let side = &sides[order[k]];
1314 let (start, end) = (&corners[(k + n - 1) % n], &corners[k]);
1315 if !side.placed && start.1 && end.1 {
1316 continue;
1317 }
1318 let (from, to) = if side.reversed {
1319 (&end.0, &start.0)
1320 } else {
1321 (&start.0, &end.0)
1322 };
1323 let edge = make_edge_between(model, side.curve.clone(), side.range, from, to, tol)?.shape;
1324 model.widen(&edge, Tolerance::new(side.edge_tolerance)?)?;
1325 sides[order[k]].edge = edge;
1326 }
1327 Ok(())
1328}