1use ogeom_algo::{Built, History, attach_pcurve, edge_vertices, make_face, make_wire};
5use ogeom_core::{OgeomResult, Tolerance, Tolerances, ogeom_bail};
6use ogeom_geom::{
7 BSpline2d, BSplineSurface, Continuity, Curve, Curve2d as _, Curve3d as _, CurveKind,
8 PlanarCurve, Surface as _, SurfaceCurvature, SurfaceGeometry, Trig2d,
9};
10use ogeom_math::{Direction, Point, Point2, Vector, Vector2, Weighted};
11use ogeom_topo::{
12 EdgeRepr, Filter, Location, Model, NodeData, Orientation, Shape, ShapeType, explore,
13};
14
15use crate::fill_patch::{Condition, DEGREE, PlaneFrame, fit_height};
16
17#[derive(Debug, Clone)]
19pub struct FillBoundary {
20 pub edge: Shape,
23 pub support: Option<Shape>,
28 pub continuity: Continuity,
32}
33
34#[derive(Debug, Clone, PartialEq)]
37pub struct FillSide {
38 pub edge: Shape,
40 pub gap: f64,
43 pub angle: Option<f64>,
46 pub curvature: Option<f64>,
51 pub stations: usize,
53}
54
55#[derive(Debug, Clone)]
57pub struct Filled {
58 pub built: Built,
60 pub sides: Vec<FillSide>,
62 pub constraint_gap: f64,
66}
67
68const OUTLINE_SAMPLES: usize = 64;
70const MARGIN: f64 = 0.05;
72const NETS: [usize; 6] = [8, 12, 18, 27, 40, 60];
74const SMOOTHING: f64 = 1e-12;
78const MIN_LIFT: f64 = 0.1;
81const POINT_SHARE: f64 = 0.05;
84const PER_CURVE: usize = 32;
86
87pub fn make_filling_n(
136 model: &mut Model,
137 boundary: &[FillBoundary],
138 constraints: &[Shape],
139 tolerance: f64,
140 tol: Tolerances,
141) -> OgeomResult<Filled> {
142 if !(tolerance.is_finite() && tolerance > 0.0) {
143 ogeom_bail!(
144 Construction,
145 "a filling's tolerance is positive and finite; got {tolerance}"
146 );
147 }
148 if boundary.is_empty() {
149 ogeom_bail!(Construction, "a filling needs at least one boundary edge");
150 }
151 let mut sides = Vec::with_capacity(boundary.len());
152 for (i, entry) in boundary.iter().enumerate() {
153 sides.push(read_side(model, i, entry, tolerance, tol)?);
154 }
155 for i in 0..sides.len() {
156 for j in (i + 1)..sides.len() {
157 if sides[i].edge.node() == sides[j].edge.node() {
158 ogeom_bail!(Construction, "side {j} is side {i}'s edge again");
159 }
160 }
161 }
162 let mut order = chain(model, &mut sides, tol)?;
163 face_the_supports(model, &mut sides, &mut order)?;
164
165 let outline = loop_points(&sides, &order, tol)?;
167 let frame = frame_of(&outline, tol)?;
168 let chart_outline: Vec<Point2> = outline.iter().map(|p| frame.chart(*p)).collect();
169 if crosses_itself(&chart_outline) {
170 ogeom_bail!(
171 Construction,
172 "the boundary loop crosses itself seen along the normal of the \
173 plane it spans; the hole is not a height field over that plane"
174 );
175 }
176 let interior = constraint_points(model, constraints, tol)?;
177 for (k, (p, _)) in interior.iter().enumerate() {
178 if !inside(&chart_outline, frame.chart(*p)) {
179 ogeom_bail!(
180 Construction,
181 "a point of constraint {k} at {p:?} lies outside the hole seen \
182 along the normal of the plane the boundary spans"
183 );
184 }
185 }
186
187 let (mut lo, mut hi) = (
189 Point2::new(f64::INFINITY, f64::INFINITY),
190 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
191 );
192 for q in &chart_outline {
193 lo = Point2::new(lo.x.min(q.x), lo.y.min(q.y));
194 hi = Point2::new(hi.x.max(q.x), hi.y.max(q.y));
195 }
196 let margin = MARGIN * (hi.x - lo.x).max(hi.y - lo.y);
197 let domain = (
198 (lo.x - margin, hi.x + margin),
199 (lo.y - margin, hi.y + margin),
200 );
201 let (du, dv) = (domain.0.1 - domain.0.0, domain.1.1 - domain.1.0);
202 let size = du.max(dv);
203
204 let mut traces = Vec::with_capacity(sides.len());
205 let mut lengths = Vec::with_capacity(sides.len());
206 for side in &sides {
207 traces.push(trace(side, &frame, tolerance, tol)?);
208 lengths.push(side_length(side, tol)?);
209 }
210
211 let mut last_miss = String::new();
212 for base in NETS {
213 #[expect(
214 clippy::cast_possible_truncation,
215 clippy::cast_sign_loss,
216 clippy::cast_precision_loss,
217 reason = "a control count of a few dozen, from a positive ratio"
218 )]
219 let count = |extent: f64| -> usize {
220 ((base as f64 * extent / size).round() as usize).max(DEGREE + 2)
221 };
222 let controls = (count(du), count(dv));
223 let samples = 4 * controls.0.max(controls.1) + 8;
224
225 let mut conditions = Vec::new();
226 for (side, length) in sides.iter().zip(&lengths) {
227 side_conditions(
228 side,
229 *length / size,
230 size,
231 samples,
232 &frame,
233 &mut conditions,
234 tol,
235 )?;
236 }
237 for (p, share) in &interior {
238 conditions.push(Condition {
239 at: frame.chart(*p),
240 order: (0, 0),
241 target: frame.height(*p),
242 weight: share.sqrt() / size,
243 });
244 }
245 let surface = fit_height(&frame, domain, controls, &conditions, SMOOTHING, tol)?;
246
247 let mut reports = Vec::with_capacity(sides.len());
248 for (side, pcurve) in sides.iter().zip(&traces) {
249 reports.push(measure_side(side, pcurve, &surface, 3 * samples + 1, tol)?);
250 }
251 let mut constraint_gap = 0.0f64;
252 for (p, _) in &interior {
253 let q = frame.chart(*p);
254 constraint_gap = constraint_gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
255 }
256
257 match first_miss(&sides, &reports, constraint_gap, tolerance) {
258 Some(miss) => {
259 last_miss = format!("at {}x{} controls, {miss}", controls.0, controls.1);
260 }
261 None => {
262 let face = build(model, &sides, &order, &traces, &reports, surface, tol)?;
263 let mut history = History::new();
264 for side in &sides {
265 history.generate(&side.edge, face.clone());
266 }
267 for constraint in constraints {
268 history.generate(constraint, face.clone());
269 }
270 return Ok(Filled {
271 built: Built::new(face, history),
272 sides: reports,
273 constraint_gap,
274 });
275 }
276 }
277 }
278 ogeom_bail!(
279 NotDone,
280 "the filling misses its tolerance of {tolerance} on the finest net: {last_miss}"
281 )
282}
283
284struct Support {
286 face: Shape,
287 surface: SurfaceGeometry,
288 pcurve: PlanarCurve,
289 prange: (f64, f64),
290}
291
292struct Side {
294 entry: usize,
295 edge: Shape,
297 curve: Curve,
298 range: (f64, f64),
299 edge_tolerance: f64,
300 order: usize,
302 support: Option<Support>,
303 reversed: bool,
305}
306
307impl Side {
308 fn parameter(&self, f: f64) -> f64 {
309 (self.range.1 - self.range.0).mul_add(f, self.range.0)
310 }
311
312 fn support_at(&self, t: f64, tol: Tolerances) -> OgeomResult<Option<(Point2, Vector)>> {
315 let Some(support) = &self.support else {
316 return Ok(None);
317 };
318 let f = (t - self.range.0) / (self.range.1 - self.range.0);
319 let pt = (support.prange.1 - support.prange.0).mul_add(f, support.prange.0);
320 let uv = support.pcurve.point_at(pt, tol)?;
321 let normal = support.surface.normal_at(uv.x, uv.y, tol)?.vector();
322 Ok(Some((uv, normal)))
323 }
324}
325
326fn read_side(
327 model: &Model,
328 i: usize,
329 entry: &FillBoundary,
330 tolerance: f64,
331 tol: Tolerances,
332) -> OgeomResult<Side> {
333 let edge = &entry.edge;
334 if model.kind_of(edge)? != ShapeType::Edge {
335 ogeom_bail!(Construction, "side {i} is not an edge");
336 }
337 if !edge.location().is_identity() {
338 ogeom_bail!(
339 Construction,
340 "side {i}'s edge is placed; bake its placement into its geometry first"
341 );
342 }
343 let order = match entry.continuity {
344 Continuity::C0 => 0,
345 Continuity::G1 => 1,
346 Continuity::G2 => 2,
347 other => ogeom_bail!(
348 Construction,
349 "side {i} asks {other:?}: parametric continuity between two \
350 surfaces' charts is not what a filling meets; ask for G1 or G2"
351 ),
352 };
353 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
354 ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
355 };
356 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
357 ogeom_bail!(Construction, "side {i}'s edge has no 3D curve");
358 };
359 let Some(curve) = model.geometry().curve(*curve).cloned() else {
360 ogeom_bail!(Dangling, "side {i}'s curve is not in this model");
361 };
362 let range = *range;
363 let edge_tolerance = data.tolerance.get();
364 let edge = edge.oriented(Orientation::Forward);
365
366 let support = match &entry.support {
367 None if order > 0 => ogeom_bail!(
368 Construction,
369 "side {i} asks {:?} but names no support face to meet",
370 entry.continuity
371 ),
372 None => None,
373 Some(face) => Some(read_support(
374 model,
375 i,
376 face,
377 &edge,
378 &curve,
379 range,
380 tolerance.max(edge_tolerance),
381 tol,
382 )?),
383 };
384 Ok(Side {
385 entry: i,
386 edge,
387 curve,
388 range,
389 edge_tolerance,
390 order,
391 support,
392 reversed: false,
393 })
394}
395
396#[expect(
397 clippy::too_many_arguments,
398 reason = "the side's edge, curve and range are read once by the caller"
399)]
400fn read_support(
401 model: &Model,
402 i: usize,
403 face: &Shape,
404 edge: &Shape,
405 curve: &Curve,
406 range: (f64, f64),
407 reach: f64,
408 tol: Tolerances,
409) -> OgeomResult<Support> {
410 if model.kind_of(face)? != ShapeType::Face {
411 ogeom_bail!(Construction, "side {i}'s support is not a face");
412 }
413 let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
414 ogeom_bail!(Dangling, "side {i}'s support is not in this model");
415 };
416 if !face.location().is_identity() || !face_data.location.is_identity() {
417 ogeom_bail!(
418 Construction,
419 "side {i}'s support is placed; bake its placement into its geometry first"
420 );
421 }
422 let holds = explore(model, face, Filter::OfType(ShapeType::Edge))?
423 .iter()
424 .any(|e| e.node() == edge.node());
425 if !holds {
426 ogeom_bail!(
427 Construction,
428 "side {i}'s support face does not hold the side's edge"
429 );
430 }
431 let surface_id = face_data.surface;
432 let Some(surface) = model.geometry().surface(surface_id).cloned() else {
433 ogeom_bail!(Dangling, "side {i}'s support surface is not in this model");
434 };
435 let Some(edge_data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
436 ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
437 };
438 let stored = match edge_data.pcurve_for(surface_id, edge.location()) {
439 Some(
440 EdgeRepr::PCurve { curve, range, .. }
441 | EdgeRepr::Seam {
442 forward: curve,
443 range,
444 ..
445 },
446 ) => model
447 .geometry()
448 .pcurve(*curve)
449 .cloned()
450 .map(|c| (c, *range)),
451 _ => None,
452 };
453 let (pcurve, prange) = if let Some(found) = stored {
454 found
455 } else {
456 let (fitted, _, _, _, _) =
457 ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, &surface, tol)?;
458 (fitted, range)
459 };
460 let mut worst = 0.0f64;
462 for k in 0..=16 {
463 let f = f64::from(k) / 16.0;
464 let t = (range.1 - range.0).mul_add(f, range.0);
465 let pt = (prange.1 - prange.0).mul_add(f, prange.0);
466 let uv = pcurve.point_at(pt, tol)?;
467 let on = surface.point_at(uv.x, uv.y, tol)?;
468 worst = worst.max(on.distance(curve.point_at(t, tol)?));
469 }
470 if worst > reach {
471 ogeom_bail!(
472 Construction,
473 "side {i}'s edge stands {worst} off its support face, past {reach}"
474 );
475 }
476 Ok(Support {
477 face: face.clone(),
478 surface,
479 pcurve,
480 prange,
481 })
482}
483
484fn chain(model: &Model, sides: &mut [Side], tol: Tolerances) -> OgeomResult<Vec<usize>> {
487 let mut ends = Vec::with_capacity(sides.len());
488 for side in sides.iter() {
489 let Some(pair) = edge_vertices(model, &side.edge)? else {
490 ogeom_bail!(
491 Construction,
492 "side {} has no vertices, so it cannot be shown to join the loop",
493 side.entry
494 );
495 };
496 ends.push(pair);
497 }
498 let meets = |a: &Shape, b: &Shape| -> OgeomResult<bool> {
499 Ok(a.is_same(b) || model.same_position(a, b, tol)?)
500 };
501 let n = sides.len();
502 let mut used = vec![false; n];
503 used[0] = true;
504 let mut order = vec![0];
505 let first = ends[0].0.clone();
506 let mut cursor = ends[0].1.clone();
507 while order.len() < n {
508 let last = sides[order[order.len() - 1]].entry;
509 let mut found: Vec<(usize, bool)> = Vec::new();
510 for j in 0..n {
511 if used[j] {
512 continue;
513 }
514 if meets(&ends[j].0, &cursor)? {
515 found.push((j, false));
516 } else if meets(&ends[j].1, &cursor)? {
517 found.push((j, true));
518 }
519 }
520 let (j, reversed) = match found.as_slice() {
521 [one] => *one,
522 [] => ogeom_bail!(
523 Construction,
524 "the boundary does not close: no side continues where side {last} ends"
525 ),
526 _ => ogeom_bail!(
527 Construction,
528 "{} sides continue where side {last} ends; a filling's boundary \
529 is one simple loop",
530 found.len()
531 ),
532 };
533 used[j] = true;
534 sides[j].reversed = reversed;
535 cursor = if reversed {
536 ends[j].0.clone()
537 } else {
538 ends[j].1.clone()
539 };
540 order.push(j);
541 }
542 if !meets(&cursor, &first)? {
543 ogeom_bail!(
544 Construction,
545 "the boundary does not close: the last side ends away from where the first begins"
546 );
547 }
548 Ok(order)
549}
550
551fn face_the_supports(model: &Model, sides: &mut [Side], order: &mut [usize]) -> OgeomResult<()> {
554 let (mut agree, mut disagree) = (0usize, 0usize);
555 for side in sides.iter() {
556 let Some(support) = &side.support else {
557 continue;
558 };
559 let uses: Vec<Orientation> =
560 explore(model, &support.face, Filter::OfType(ShapeType::Edge))?
561 .iter()
562 .filter(|e| e.node() == side.edge.node())
563 .map(Shape::orientation)
564 .collect();
565 let Some(&first) = uses.first() else {
566 continue;
567 };
568 if uses.iter().any(|o| *o != first)
569 || !matches!(first, Orientation::Forward | Orientation::Reversed)
570 {
571 continue;
572 }
573 if (first == Orientation::Forward) == !side.reversed {
574 disagree += 1;
575 } else {
576 agree += 1;
577 }
578 }
579 if disagree > agree {
580 order.reverse();
581 for side in sides.iter_mut() {
582 side.reversed = !side.reversed;
583 }
584 }
585 Ok(())
586}
587
588fn loop_points(sides: &[Side], order: &[usize], tol: Tolerances) -> OgeomResult<Vec<Point>> {
590 let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
591 for &i in order {
592 let side = &sides[i];
593 for k in 0..OUTLINE_SAMPLES {
594 #[expect(
595 clippy::cast_precision_loss,
596 reason = "a sample index, far below the mantissa"
597 )]
598 let mut f = k as f64 / OUTLINE_SAMPLES as f64;
599 if side.reversed {
600 f = 1.0 - f;
601 }
602 out.push(side.curve.point_at(side.parameter(f), tol)?);
603 }
604 }
605 Ok(out)
606}
607
608fn frame_of(outline: &[Point], tol: Tolerances) -> OgeomResult<PlaneFrame> {
612 let Ok(origin) = Point::centroid(outline) else {
613 ogeom_bail!(Construction, "the boundary loop has no points");
614 };
615 let mut area = Vector::ZERO;
616 let mut reach = 0.0f64;
617 for (k, p) in outline.iter().enumerate() {
618 let q = outline[(k + 1) % outline.len()];
619 area += (*p - origin).cross(q - origin) * 0.5;
620 reach = reach.max(p.distance(origin));
621 }
622 let magnitude = area.magnitude();
623 if magnitude <= 1e-9 * reach * reach || magnitude <= tol.confusion() * tol.confusion() {
624 ogeom_bail!(
625 Construction,
626 "the boundary loop encloses no area seen from any plane"
627 );
628 }
629 let n = area * (1.0 / magnitude);
630 let b1 = Direction::new(n, tol)?.any_perpendicular().vector();
631 let b2 = n.cross(b1);
632 let (mut sxx, mut syy, mut sxy) = (0.0f64, 0.0f64, 0.0f64);
633 for p in outline {
634 let d = *p - origin;
635 let (x, y) = (d.dot(b1), d.dot(b2));
636 sxx += x * x;
637 syy += y * y;
638 sxy += x * y;
639 }
640 let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
641 let e1 = b1 * theta.cos() + b2 * theta.sin();
642 let e2 = n.cross(e1);
643 Ok(PlaneFrame { origin, e1, e2, n })
644}
645
646fn crosses_itself(polygon: &[Point2]) -> bool {
649 let n = polygon.len();
650 let orient = |a: Point2, b: Point2, c: Point2| (b - a).cross(c - a);
651 for i in 0..n {
652 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
653 for j in (i + 2)..n {
654 if i == 0 && j == n - 1 {
655 continue;
656 }
657 let (c, d) = (polygon[j], polygon[(j + 1) % n]);
658 if orient(a, b, c) * orient(a, b, d) < 0.0 && orient(c, d, a) * orient(c, d, b) < 0.0 {
659 return true;
660 }
661 }
662 }
663 false
664}
665
666fn inside(polygon: &[Point2], q: Point2) -> bool {
668 let n = polygon.len();
669 let mut winding = 0i32;
670 for i in 0..n {
671 let (a, b) = (polygon[i], polygon[(i + 1) % n]);
672 let side = (b - a).cross(q - a);
673 if a.y <= q.y {
674 if b.y > q.y && side > 0.0 {
675 winding += 1;
676 }
677 } else if b.y <= q.y && side < 0.0 {
678 winding -= 1;
679 }
680 }
681 winding != 0
682}
683
684fn constraint_points(
687 model: &Model,
688 constraints: &[Shape],
689 tol: Tolerances,
690) -> OgeomResult<Vec<(Point, f64)>> {
691 let mut out = Vec::new();
692 for (k, shape) in constraints.iter().enumerate() {
693 let placement = shape.transform(model.datums())?;
694 match model.kind_of(shape)? {
695 ShapeType::Vertex => {
696 let Some(data) = model.node(shape).and_then(|n| n.data().as_vertex()) else {
697 ogeom_bail!(Construction, "constraint {k} holds no vertex data");
698 };
699 out.push((placement.apply(data.point), POINT_SHARE));
700 }
701 ShapeType::Edge => {
702 let Some(data) = model.node(shape).and_then(|n| n.data().as_edge()) else {
703 ogeom_bail!(Construction, "constraint {k} holds no edge data");
704 };
705 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
706 ogeom_bail!(Construction, "constraint {k} has no 3D curve");
707 };
708 let Some(curve) = model.geometry().curve(*curve) else {
709 ogeom_bail!(Dangling, "constraint {k}'s curve is not in this model");
710 };
711 for i in 0..=PER_CURVE {
712 #[expect(
713 clippy::cast_precision_loss,
714 reason = "a sample index, far below the mantissa"
715 )]
716 let f = i as f64 / PER_CURVE as f64;
717 let t = (range.1 - range.0).mul_add(f, range.0);
718 out.push((placement.apply(curve.point_at(t, tol)?), POINT_SHARE / 4.0));
719 }
720 }
721 other => ogeom_bail!(
722 Construction,
723 "constraint {k} is a {other:?}; a filling passes through vertices and edges"
724 ),
725 }
726 }
727 Ok(out)
728}
729
730fn untrimmed(curve: &Curve) -> &Curve {
732 match curve {
733 Curve::Trimmed(t) if !t.is_reversed() => untrimmed(t.basis()),
734 other => other,
735 }
736}
737
738fn trace(
743 side: &Side,
744 frame: &PlaneFrame,
745 tolerance: f64,
746 tol: Tolerances,
747) -> OgeomResult<PlanarCurve> {
748 let truth = |t: f64| -> OgeomResult<Point2> { Ok(frame.chart(side.curve.point_at(t, tol)?)) };
749 let (t0, t1) = side.range;
750 let exact: Option<PlanarCurve> = match untrimmed(&side.curve) {
751 Curve::BSpline(spline) => {
752 let mut control = Vec::with_capacity(spline.control_points().len());
753 for w in spline.control_points() {
754 control.push(Weighted::new(frame.chart(w.point()), w.weight, tol)?);
755 }
756 BSpline2d::rational(spline.knots().clone(), control)
757 .ok()
758 .map(PlanarCurve::BSpline)
759 }
760 _ => match side.curve.kind() {
761 CurveKind::Line => {
762 let (q0, q1) = (truth(t0)?, truth(t1)?);
763 let d = (q1 - q0) * (1.0 / (t1 - t0));
764 let c = q0 - d * t0;
765 Trig2d::new(c, d, Vector2::ZERO, Vector2::ZERO, side.range)
766 .ok()
767 .map(PlanarCurve::Trig)
768 }
769 CurveKind::Circle | CurveKind::Ellipse => {
770 let ts = [
773 t0,
774 (t1 - t0).mul_add(1.0 / 3.0, t0),
775 (t1 - t0).mul_add(2.0 / 3.0, t0),
776 ];
777 let rows = ts.map(|t| [1.0, t.cos(), t.sin()]);
778 let values = [truth(ts[0])?, truth(ts[1])?, truth(ts[2])?];
779 match (
780 solve3(rows, values.map(|q| q.x)),
781 solve3(rows, values.map(|q| q.y)),
782 ) {
783 (Some(x), Some(y)) => Trig2d::new(
784 Point2::new(x[0], y[0]),
785 Vector2::ZERO,
786 Vector2::new(x[1], y[1]),
787 Vector2::new(x[2], y[2]),
788 side.range,
789 )
790 .ok()
791 .map(PlanarCurve::Trig),
792 _ => None,
793 }
794 }
795 _ => None,
796 },
797 };
798 if let Some(curve) = exact {
799 let mut worst = 0.0f64;
800 let mut scale = 1.0f64;
801 for k in 0..=32 {
802 let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
803 let q = truth(t)?;
804 scale = scale.max(q.to_vector().magnitude());
805 worst = worst.max(curve.point_at(t, tol)?.distance(q));
806 }
807 if worst <= 1e-12 * scale + 1e-3 * tol.confusion() {
808 return Ok(curve);
809 }
810 }
811 const SAMPLES: usize = 96;
812 let mut parameters = Vec::with_capacity(SAMPLES + 1);
813 let mut points = Vec::with_capacity(SAMPLES + 1);
814 for k in 0..=SAMPLES {
815 #[expect(
816 clippy::cast_precision_loss,
817 reason = "a sample index, far below the mantissa"
818 )]
819 let t = (t1 - t0).mul_add(k as f64 / SAMPLES as f64, t0);
820 parameters.push(t);
821 points.push(truth(t)?);
822 }
823 let fitted = ogeom_geom::fit::fit_points_2d_at(¶meters, &points, 3, tolerance * 1e-2, tol)?;
824 Ok(PlanarCurve::BSpline(fitted.curve))
825}
826
827fn solve3(rows: [[f64; 3]; 3], rhs: [f64; 3]) -> Option<[f64; 3]> {
829 let det = |m: [[f64; 3]; 3]| {
830 m[0][0] * m[1][1].mul_add(m[2][2], -m[1][2] * m[2][1])
831 - m[0][1] * m[1][0].mul_add(m[2][2], -m[1][2] * m[2][0])
832 + m[0][2] * m[1][0].mul_add(m[2][1], -m[1][1] * m[2][0])
833 };
834 let d = det(rows);
835 if d.abs() < 1e-12 {
836 return None;
837 }
838 let mut out = [0.0; 3];
839 for (c, slot) in out.iter_mut().enumerate() {
840 let mut m = rows;
841 for r in 0..3 {
842 m[r][c] = rhs[r];
843 }
844 *slot = det(m) / d;
845 }
846 Some(out)
847}
848
849fn side_length(side: &Side, tol: Tolerances) -> OgeomResult<f64> {
851 let mut length = 0.0;
852 let mut previous = side.curve.point_at(side.range.0, tol)?;
853 for k in 1..=OUTLINE_SAMPLES {
854 #[expect(
855 clippy::cast_precision_loss,
856 reason = "a sample index, far below the mantissa"
857 )]
858 let f = k as f64 / OUTLINE_SAMPLES as f64;
859 let p = side.curve.point_at(side.parameter(f), tol)?;
860 length += p.distance(previous);
861 previous = p;
862 }
863 Ok(length)
864}
865
866fn side_conditions(
871 side: &Side,
872 share: f64,
873 size: f64,
874 samples: usize,
875 frame: &PlaneFrame,
876 out: &mut Vec<Condition>,
877 tol: Tolerances,
878) -> OgeomResult<()> {
879 #[expect(
880 clippy::cast_precision_loss,
881 reason = "a sample count, far below the mantissa"
882 )]
883 let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
884 for k in 0..=samples {
885 #[expect(
886 clippy::cast_precision_loss,
887 reason = "a sample index, far below the mantissa"
888 )]
889 let t = side.parameter(k as f64 / samples as f64);
890 let p = side.curve.point_at(t, tol)?;
891 let at = frame.chart(p);
892 out.push(Condition {
893 at,
894 order: (0, 0),
895 target: frame.height(p),
896 weight: root / size,
897 });
898 if side.order == 0 {
899 continue;
900 }
901 let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
902 continue;
903 };
904 let lift = normal.dot(frame.n);
905 if lift.abs() < MIN_LIFT {
906 ogeom_bail!(
907 Construction,
908 "side {}'s support stands within {:.1} degrees of square to the \
909 plane the boundary spans; the hole is not a height field over it",
910 side.entry,
911 lift.abs().asin().to_degrees()
912 );
913 }
914 let normal = if lift < 0.0 { normal * -1.0 } else { normal };
917 let lift = lift.abs();
918 let (hu, hv) = (-normal.dot(frame.e1) / lift, -normal.dot(frame.e2) / lift);
919 out.push(Condition {
920 at,
921 order: (1, 0),
922 target: hu,
923 weight: root,
924 });
925 out.push(Condition {
926 at,
927 order: (0, 1),
928 target: hv,
929 weight: root,
930 });
931 if side.order < 2 {
932 continue;
933 }
934 let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
938 let sense = if curvature.normal.dot_vector(normal) < 0.0 {
939 -1.0
940 } else {
941 1.0
942 };
943 let (dmax, dmin) = (
944 curvature.max_direction.vector(),
945 curvature.min_direction.vector(),
946 );
947 let second = |a: Vector, b: Vector| {
948 sense
949 * curvature.max.mul_add(
950 a.dot(dmax) * b.dot(dmax),
951 curvature.min * a.dot(dmin) * b.dot(dmin),
952 )
953 / lift
954 };
955 let su = frame.e1 + frame.n * hu;
956 let sv = frame.e2 + frame.n * hv;
957 for (order, target) in [
958 ((2, 0), second(su, su)),
959 ((1, 1), second(su, sv)),
960 ((0, 2), second(sv, sv)),
961 ] {
962 out.push(Condition {
963 at,
964 order,
965 target,
966 weight: root * size,
967 });
968 }
969 }
970 Ok(())
971}
972
973fn measure_side(
978 side: &Side,
979 pcurve: &PlanarCurve,
980 surface: &BSplineSurface,
981 stations: usize,
982 tol: Tolerances,
983) -> OgeomResult<FillSide> {
984 let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, None::<f64>);
985 for k in 0..stations {
986 #[expect(
987 clippy::cast_precision_loss,
988 reason = "a station index, far below the mantissa"
989 )]
990 let t = side.parameter(k as f64 / (stations - 1) as f64);
991 let p = side.curve.point_at(t, tol)?;
992 let uv = pcurve.point_at(t, tol)?;
993 gap = gap.max(surface.point_at(uv.x, uv.y, tol)?.distance(p));
994 let (Some((suv, theirs)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
995 continue;
996 };
997 let ours = surface.normal_at(uv.x, uv.y, tol)?.vector();
998 angle = angle.max(ours.cross(theirs).magnitude().atan2(ours.dot(theirs).abs()));
999 let across = ours.cross(side.curve.d1_at(t, tol)?);
1000 let signed = |c: SurfaceCurvature| -> Option<f64> {
1001 let k = c.normal_curvature(across)?;
1002 Some(if c.normal.dot_vector(ours) < 0.0 {
1003 -k
1004 } else {
1005 k
1006 })
1007 };
1008 let a = surface.curvature_at(uv.x, uv.y, tol).ok().and_then(signed);
1009 let b = support
1010 .surface
1011 .curvature_at(suv.x, suv.y, tol)
1012 .ok()
1013 .and_then(signed);
1014 if let (Some(a), Some(b)) = (a, b) {
1015 curvature = Some(curvature.unwrap_or(0.0).max((a - b).abs()));
1016 }
1017 }
1018 let supported = side.support.is_some();
1019 Ok(FillSide {
1020 edge: side.edge.clone(),
1021 gap,
1022 angle: supported.then_some(angle),
1023 curvature: curvature.filter(|_| supported),
1024 stations,
1025 })
1026}
1027
1028fn first_miss(
1031 sides: &[Side],
1032 reports: &[FillSide],
1033 constraint_gap: f64,
1034 tolerance: f64,
1035) -> Option<String> {
1036 for (side, report) in sides.iter().zip(reports) {
1037 let i = side.entry;
1038 if report.gap > tolerance {
1039 return Some(format!("side {i} stands {} off its edge", report.gap));
1040 }
1041 if side.order >= 1 {
1042 let angle = report.angle.unwrap_or(f64::INFINITY);
1043 if angle > tolerance {
1044 return Some(format!(
1045 "side {i} meets its support {angle} radians from tangent"
1046 ));
1047 }
1048 }
1049 if side.order >= 2 {
1050 match report.curvature {
1051 Some(c) if c <= tolerance => {}
1052 Some(c) => {
1053 return Some(format!(
1054 "side {i}'s normal curvature differs from its support's by {c}"
1055 ));
1056 }
1057 None => {
1058 return Some(format!(
1059 "side {i}'s curvature could not be read on both surfaces"
1060 ));
1061 }
1062 }
1063 }
1064 }
1065 (constraint_gap > tolerance)
1066 .then(|| format!("a constraint stands {constraint_gap} off the surface"))
1067}
1068
1069fn build(
1072 model: &mut Model,
1073 sides: &[Side],
1074 order: &[usize],
1075 traces: &[PlanarCurve],
1076 reports: &[FillSide],
1077 surface: BSplineSurface,
1078 tol: Tolerances,
1079) -> OgeomResult<Shape> {
1080 let walk: Vec<Shape> = order
1081 .iter()
1082 .map(|&i| {
1083 let side = &sides[i];
1084 if side.reversed {
1085 side.edge.reversed()
1086 } else {
1087 side.edge.clone()
1088 }
1089 })
1090 .collect();
1091 let wire = make_wire(model, &walk, tol)?.shape;
1092 let face = make_face(model, SurfaceGeometry::BSpline(surface), &[wire], tol)?.shape;
1093 let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
1094 ogeom_bail!(Dangling, "the face just built is not in this model");
1095 };
1096 let surface_id = data.surface;
1097 for ((side, pcurve), report) in sides.iter().zip(traces).zip(reports) {
1098 attach_pcurve(
1099 model,
1100 &side.edge,
1101 pcurve.clone(),
1102 surface_id,
1103 Location::identity(),
1104 side.range,
1105 )?;
1106 if report.gap > side.edge_tolerance {
1107 let widened = Tolerance::new(report.gap + tol.confusion())?;
1109 model.widen(&side.edge, widened)?;
1110 if let Some((a, b)) = edge_vertices(model, &side.edge)? {
1111 model.widen(&a, widened)?;
1112 model.widen(&b, widened)?;
1113 }
1114 }
1115 }
1116 Ok(face)
1117}