Skip to main content

brep_kernel/geometry/
coons.rs

1//! Four-boundary Coons surface fill.
2//!
3//! [`coons_bilinear`] takes four boundary curves in cyclic order and returns
4//! the bilinearly blended Coons patch through them as ONE NURBS surface.
5//! [`coons_with_ribbons`] is the separate constructor that additionally
6//! interpolates prescribed cross-boundary derivative fields.  Geometry only:
7//! no face, no naming, no feature, no schema.
8//!
9//! # What is exact
10//!
11//! * **The boundaries.**  The patch's four boundary iso-curves are the input
12//!   curves themselves — same degree, same knots, same control points — after
13//!   orientation normalisation and representation compatibility, which are
14//!   exact algebra: reversal and affine knot re-mapping are bit-exact, degree
15//!   elevation and knot insertion are exact to round-off (a curve that needed
16//!   neither comes back with every float unchanged).  Nothing is sampled and
17//!   refitted.  The one exception is
18//!   the four corner control points, which are shared by two boundaries each
19//!   and are set to the midpoint of the two incident endpoints; when the
20//!   corners meet exactly (the ordinary case) that midpoint IS both endpoints
21//!   and every boundary comes back bit for bit.  [`CoonsReport::boundary_residual`]
22//!   is the measured worst disagreement, and it is `0.0` in that case.
23//! * **Four straight lines** give exactly the bilinear patch through the four
24//!   corners: degree (1, 1), a 2x2 control net.
25//! * **The blend.**  The ruled halves and the corner bilinear are expressed in
26//!   the patch's own knot vectors through the Greville abscissae — the closed
27//!   form of "degree-elevate a linear function and insert knots" — so the
28//!   interior is the Coons formula evaluated in NURBS arithmetic, not an
29//!   approximation of it.
30//!
31//! # What is approximate
32//!
33//! * **The interior, against any surface you had in mind.**  A Coons patch is
34//!   defined by its boundaries; it reproduces a sphere, a torus or anything
35//!   else only to the extent that bilinear blending happens to agree with it.
36//!   The tests record the deviation for a spherical quadrilateral rather than
37//!   claiming a bound.
38//! * **Rational inputs.**  With rational boundaries the blend is carried out
39//!   in homogeneous space, so the interior is the projective Coons patch, not
40//!   the Euclidean one.  The boundaries stay exact either way.  With all
41//!   weights equal the two coincide.
42//!
43//! # What refuses
44//!
45//! Every refusal carries the number that was measured — see [`CoonsRefusal`].
46//! A boundary that is a point, corners that do not meet inside the kernel's
47//! vertex identity band, rational boundaries whose corner weights cannot be
48//! reconciled, a blend that would put a control point at infinity, and — for
49//! the ribbon constructor — rational input or a ribbon that contradicts the
50//! boundary tangent it starts on.
51
52use crate::{
53    model_scale, KernelTolerances, NurbsCurve, NurbsSurface, Vec3, Vec4, VERTEX_MATCH_FLOOR,
54};
55
56#[path = "coons/compat.rs"]
57pub(crate) mod compat;
58#[path = "coons/ribbons.rs"]
59mod ribbons;
60
61pub use compat::CompatWork;
62pub use ribbons::coons_with_ribbons;
63
64
65
66/// Dimensionless part-per-diagonal factor of the corner-meeting band.  This is
67/// the factor `BrepSolid::validate` uses for edge-endpoint-vs-vertex identity,
68/// quoted here for the same question one stage earlier: two boundary ends that
69/// meet inside this band ARE one corner, and a patch built on them can be sewn
70/// without the validator then rejecting the very corner it was built from.
71const CORNER_IDENTITY_K: f64 = 2e-5;
72
73/// Relative band on the rational corner-weight closure (see
74/// [`CoonsRefusal::CornerWeightClosure`]).  Dimensionless: it compares a ratio
75/// against 1.
76pub(crate) const WEIGHT_CLOSURE_BAND: f64 = 1e-9;
77
78/// Uniform samples per boundary used to size the patch (`model_scale`) and to
79/// measure achieved continuity.
80const BOUNDARY_SAMPLES: usize = 32;
81
82/// A Coons constructor's refusal, with the measurement that caused it.
83///
84/// Following the kernel's fail-safe contract: where the construction cannot be
85/// certified it refuses, and the refusal carries the number a caller (or a
86/// test, or a user) needs in order to act — never a bare "failed".
87#[derive(Clone, Debug, PartialEq)]
88pub enum CoonsRefusal {
89    /// Boundary `boundary` has no extent: it is a point, not a curve.
90    DegenerateBoundary {
91        boundary: usize,
92        extent: f64,
93        band: f64,
94    },
95    /// Boundary `corner`'s end does not meet boundary `(corner + 1) % 4`'s
96    /// start.  `gap` is the measured distance between them.
97    CornerGap {
98        corner: usize,
99        gap: f64,
100        band: f64,
101    },
102    /// The four boundaries are rational and their corner weights cannot all be
103    /// matched by re-scaling: going once round the loop multiplies the weights
104    /// by `ratio` instead of 1.  A Moebius re-parameterisation of one boundary
105    /// would fix it, at the cost of that boundary's parameterisation — which
106    /// this constructor will not change behind the caller's back.
107    CornerWeightClosure { ratio: f64, band: f64 },
108    /// The blend put a control point at or beyond infinity: the rational net
109    /// weight at `(row, column)` is not positive.  Two opposite boundaries
110    /// whose rational shoulder weights are small enough (arcs approaching a
111    /// half turn) cancel the corner bilinear's weight entirely.
112    NonPositiveWeight {
113        row: usize,
114        column: usize,
115        weight: f64,
116    },
117    /// Degree/knot compatibility could not be reached exactly in the named
118    /// direction.
119    Incompatible { direction: char, detail: String },
120    /// The ribbon constructor was handed rational geometry.  A prescribed 3D
121    /// cross-derivative does not determine a homogeneous ribbon, so this is
122    /// refused rather than approximated.  `weight_spread` is the measured
123    /// `max |w - 1|` over the offending curve's control points.
124    RationalRibbon {
125        boundary: usize,
126        weight_spread: f64,
127    },
128    /// A ribbon disagrees with the boundary tangent it starts or ends on, so
129    /// no patch can carry both.  `gap` is the measured vector difference.
130    RibbonCornerTangent {
131        corner: usize,
132        gap: f64,
133        band: f64,
134    },
135    /// Ribbons were prescribed on one boundary of a direction but not its
136    /// opposite.  A direction is blended cubically or linearly as a whole, so
137    /// half a pair has no meaning; give both or neither.
138    RibbonPairing { direction: char, prescribed: usize },
139    /// An underlying kernel geometry error, verbatim.
140    Geometry(String),
141}
142
143impl std::fmt::Display for CoonsRefusal {
144    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        match self {
146            Self::DegenerateBoundary {
147                boundary,
148                extent,
149                band,
150            } => write!(
151                formatter,
152                "coons: boundary {boundary} is degenerate — extent {extent:.3e} is inside the \
153                 identity band {band:.3e}"
154            ),
155            Self::CornerGap { corner, gap, band } => write!(
156                formatter,
157                "coons: boundary {corner} does not meet boundary {} — gap {gap:.3e} exceeds the \
158                 vertex identity band {band:.3e}",
159                (corner + 1) % 4
160            ),
161            Self::CornerWeightClosure { ratio, band } => write!(
162                formatter,
163                "coons: rational corner weights do not close — the loop multiplies weights by \
164                 {ratio:.12} (band {band:.3e} about 1)"
165            ),
166            Self::NonPositiveWeight {
167                row,
168                column,
169                weight,
170            } => write!(
171                formatter,
172                "coons: blended control point ({row}, {column}) has non-positive weight \
173                 {weight:.3e} — the patch would pass through infinity"
174            ),
175            Self::Incompatible { direction, detail } => {
176                write!(formatter, "coons: {direction} direction incompatible — {detail}")
177            }
178            Self::RationalRibbon {
179                boundary,
180                weight_spread,
181            } => write!(
182                formatter,
183                "coons: ribbon constructor is polynomial-only — boundary {boundary} is rational \
184                 (max |w - 1| = {weight_spread:.3e})"
185            ),
186            Self::RibbonCornerTangent { corner, gap, band } => write!(
187                formatter,
188                "coons: the ribbon at corner {corner} contradicts the boundary tangent there — \
189                 gap {gap:.3e} exceeds {band:.3e}"
190            ),
191            Self::RibbonPairing {
192                direction,
193                prescribed,
194            } => write!(
195                formatter,
196                "coons: the {direction} direction has {prescribed} of its 2 ribbons — give both \
197                 boundaries of a direction a ribbon, or neither"
198            ),
199            Self::Geometry(message) => formatter.write_str(message),
200        }
201    }
202}
203
204/// The lossy exit for stringly callers, exactly like [`crate::KernelRefusal`]'s.
205impl From<CoonsRefusal> for String {
206    fn from(refusal: CoonsRefusal) -> Self {
207        refusal.to_string()
208    }
209}
210
211/// What the constructor had to do, and what it measured doing it.
212///
213/// Nothing here is inferred: every field is either a decision the constructor
214/// took (and can be replayed) or a number it measured.
215#[derive(Clone, Debug)]
216pub struct CoonsReport {
217    /// Which boundaries were reversed to make the cyclic order run
218    /// consistently round the patch.  Index is the caller's boundary index.
219    pub reversed: [bool; 4],
220    /// Distance from boundary `k`'s end to boundary `(k + 1) % 4`'s start,
221    /// after orientation normalisation.
222    pub corner_gaps: [f64; 4],
223    /// The band those gaps were judged against.
224    pub corner_band: f64,
225    /// Factor each boundary's homogeneous control points were scaled by to
226    /// make the corner weights agree.  All `1.0` for polynomial input.
227    pub weight_scales: [f64; 4],
228    /// Product of end weights over product of start weights: `1.0` when the
229    /// rational corner weights close.
230    pub weight_closure: f64,
231    /// Degree elevation / knot insertion / domain re-mapping per boundary.
232    pub work: [CompatWork; 4],
233    pub degree_u: usize,
234    pub degree_v: usize,
235    pub control_rows: usize,
236    pub control_columns: usize,
237    /// Smallest rational weight in the blended control net.
238    pub min_weight: f64,
239    /// Worst distance between a boundary iso-curve's control point and the
240    /// compatible input curve's — `0.0` when the boundaries came back bit for
241    /// bit.  Homogeneous for [`coons_bilinear`]; Euclidean for
242    /// [`coons_with_ribbons`], whose input is polynomial by contract, so the
243    /// two coincide there.
244    pub boundary_residual: f64,
245    /// Ribbon measurements, present only for [`coons_with_ribbons`].
246    pub ribbons: Option<RibbonReport>,
247}
248
249/// The measured continuity of a ribbon patch.  Read [`Self::cross_derivative_residual`]
250/// before calling anything G1: it is the number that says whether the
251/// prescribed cross-derivative field was actually met.
252#[derive(Clone, Debug)]
253pub struct RibbonReport {
254    /// Which boundaries carried a prescribed ribbon.
255    pub prescribed: [bool; 4],
256    /// Worst disagreement between a ribbon's end vector and the tangent of the
257    /// boundary it meets there.
258    pub corner_tangent_gap: f64,
259    /// Worst disagreement between the two corner twists a pair of ribbons
260    /// prescribes at the same corner.  The patch carries their mean, so this
261    /// is the quantity that limits exact ribbon interpolation.
262    pub twist_discrepancy: f64,
263    /// Worst `|dS/dn - T|` measured on the built patch along every boundary
264    /// that carried a ribbon.
265    pub cross_derivative_residual: f64,
266    /// Worst `|T|` over the same samples — the scale
267    /// [`Self::cross_derivative_residual`] should be read against.
268    pub cross_derivative_scale: f64,
269}
270
271/// The bilinearly blended Coons patch through four boundary curves given in
272/// cyclic order.
273///
274/// See the module documentation for the contract.  Use
275/// [`coons_bilinear_reported`] when the measurements matter — which reversals
276/// were applied, what the corners actually measured, whether the boundaries
277/// came back bit for bit.
278pub fn coons_bilinear(boundaries: [NurbsCurve; 4]) -> Result<NurbsSurface, CoonsRefusal> {
279    coons_bilinear_reported(boundaries).map(|(surface, _)| surface)
280}
281
282/// [`coons_bilinear`] plus the [`CoonsReport`] of what it did and measured.
283pub fn coons_bilinear_reported(
284    boundaries: [NurbsCurve; 4],
285) -> Result<(NurbsSurface, CoonsReport), CoonsRefusal> {
286    let prepared = prepare(&boundaries)?;
287    build_bilinear(prepared)
288}
289
290/// Homogeneous distance between two control points.
291fn homogeneous_distance(first: Vec4, second: Vec4) -> f64 {
292    ((first.x - second.x).powi(2)
293        + (first.y - second.y).powi(2)
294        + (first.z - second.z).powi(2)
295        + (first.w - second.w).powi(2))
296    .sqrt()
297}
298
299/// Midpoint of two homogeneous control points.  Exactly either one when they
300/// are equal: `(a + a) * 0.5 == a` in IEEE arithmetic.
301fn homogeneous_midpoint(first: Vec4, second: Vec4) -> Vec4 {
302    first.add(second).scale(0.5)
303}
304
305/// Uniform 3D samples of a curve over its own domain.
306fn samples(curve: &NurbsCurve, count: usize) -> Result<Vec<Vec3>, String> {
307    let [start, end] = curve.domain()?;
308    (0..=count)
309        .map(|index| curve.evaluate(start + (end - start) * index as f64 / count as f64))
310        .collect()
311}
312
313/// The corner-meeting band: the kernel's vertex identity band at this patch's
314/// size.  Same expression `BrepSolid::validate` judges an edge end against its
315/// vertex with — `heal_band(diagonal, 2e-5)` floored at [`VERTEX_MATCH_FLOOR`]
316/// — so a patch whose corners this accepts is a patch the validator will
317/// accept once it is sewn.
318pub(crate) fn corner_band(scale: f64) -> f64 {
319    KernelTolerances::default()
320        .heal_band(scale, CORNER_IDENTITY_K)
321        .max(VERTEX_MATCH_FLOOR)
322}
323
324/// The four boundaries, oriented into one cyclic traversal, weight-reconciled
325/// and made representation-compatible in each direction.
326pub(crate) struct Prepared {
327    /// v = 0, running with +u.
328    c0: NurbsCurve,
329    /// v = 1, running with +u.
330    c1: NurbsCurve,
331    /// u = 0, running with +v.
332    d0: NurbsCurve,
333    /// u = 1, running with +v.
334    d1: NurbsCurve,
335    report: CoonsReport,
336}
337
338/// The cyclic traversal both constructors start from: four boundaries turned
339/// the same way round the loop, their rational corner weights reconciled, and
340/// the measurements that licensed it.
341pub(crate) struct Oriented {
342    /// `[0]` runs P00 -> P10, `[1]` P10 -> P11, `[2]` P11 -> P01, `[3]` P01 -> P00.
343    pub(crate) curves: Vec<NurbsCurve>,
344    pub(crate) reversed: [bool; 4],
345    pub(crate) corner_gaps: [f64; 4],
346    pub(crate) band: f64,
347    pub(crate) weight_scales: [f64; 4],
348    pub(crate) weight_closure: f64,
349}
350
351/// Orient, check and weight-reconcile — everything both constructors do before
352/// they choose their blend.
353pub(crate) fn orient_and_reconcile(
354    boundaries: &[NurbsCurve; 4],
355) -> Result<Oriented, CoonsRefusal> {
356    let mut points: Vec<Vec3> = Vec::with_capacity(4 * (BOUNDARY_SAMPLES + 1));
357    for boundary in boundaries {
358        points.extend(samples(boundary, BOUNDARY_SAMPLES).map_err(CoonsRefusal::Geometry)?);
359    }
360    let scale = model_scale(points.iter().copied());
361    let band = corner_band(scale);
362
363    // A boundary with no extent cannot carry a patch side.
364    for (index, boundary) in boundaries.iter().enumerate() {
365        let boundary_points =
366            samples(boundary, BOUNDARY_SAMPLES).map_err(CoonsRefusal::Geometry)?;
367        let extent = model_scale(boundary_points.iter().copied());
368        let spread = boundary_points
369            .iter()
370            .map(|point| point.sub(boundary_points[0]).length())
371            .fold(0.0f64, f64::max);
372        let extent = extent.min(spread);
373        if extent <= band {
374            return Err(CoonsRefusal::DegenerateBoundary {
375                boundary: index,
376                extent,
377                band,
378            });
379        }
380    }
381
382    // Orientation: chain end-to-start round the loop.  Boundary 0's own
383    // direction is chosen first, by which of its ends is nearer to boundary 1.
384    let end_point = |curve: &NurbsCurve| -> Result<Vec3, CoonsRefusal> {
385        let [_, end] = curve.domain().map_err(CoonsRefusal::Geometry)?;
386        curve.evaluate(end).map_err(CoonsRefusal::Geometry)
387    };
388    let start_point = |curve: &NurbsCurve| -> Result<Vec3, CoonsRefusal> {
389        let [start, _] = curve.domain().map_err(CoonsRefusal::Geometry)?;
390        curve.evaluate(start).map_err(CoonsRefusal::Geometry)
391    };
392    let first_forward = end_point(&boundaries[0])?;
393    let first_backward = start_point(&boundaries[0])?;
394    let next_start = start_point(&boundaries[1])?;
395    let next_end = end_point(&boundaries[1])?;
396    let forward_gap = first_forward
397        .sub(next_start)
398        .length()
399        .min(first_forward.sub(next_end).length());
400    let backward_gap = first_backward
401        .sub(next_start)
402        .length()
403        .min(first_backward.sub(next_end).length());
404    let mut reversed = [false; 4];
405    reversed[0] = backward_gap < forward_gap;
406
407    let mut oriented: Vec<NurbsCurve> = Vec::with_capacity(4);
408    oriented.push(if reversed[0] {
409        boundaries[0].reversed().map_err(CoonsRefusal::Geometry)?
410    } else {
411        boundaries[0].clone()
412    });
413    let mut corner_gaps = [0.0f64; 4];
414    for index in 1..4 {
415        let previous_end = end_point(&oriented[index - 1])?;
416        let candidate_start = start_point(&boundaries[index])?;
417        let candidate_end = end_point(&boundaries[index])?;
418        let forward = previous_end.sub(candidate_start).length();
419        let backward = previous_end.sub(candidate_end).length();
420        reversed[index] = backward < forward;
421        corner_gaps[index - 1] = forward.min(backward);
422        oriented.push(if reversed[index] {
423            boundaries[index]
424                .reversed()
425                .map_err(CoonsRefusal::Geometry)?
426        } else {
427            boundaries[index].clone()
428        });
429    }
430    corner_gaps[3] = end_point(&oriented[3])?
431        .sub(start_point(&oriented[0])?)
432        .length();
433    for (corner, &gap) in corner_gaps.iter().enumerate() {
434        if gap > band {
435            return Err(CoonsRefusal::CornerGap { corner, gap, band });
436        }
437    }
438
439    // Rational corner weights.  Two curves meeting at a corner only share ONE
440    // homogeneous point when their weights there agree, and a curve may be
441    // re-scaled freely (it is the same curve).  Propagating the scale round
442    // the loop closes iff the end weights multiply to the start weights.
443    let start_weight = |curve: &NurbsCurve| curve.control_points[0].w;
444    let end_weight = |curve: &NurbsCurve| curve.control_points[curve.control_points.len() - 1].w;
445    let mut weight_scales = [1.0f64; 4];
446    for index in 1..4 {
447        weight_scales[index] =
448            weight_scales[index - 1] * end_weight(&oriented[index - 1]) / start_weight(&oriented[index]);
449    }
450    let weight_closure =
451        weight_scales[3] * end_weight(&oriented[3]) / start_weight(&oriented[0]);
452    if (weight_closure - 1.0).abs() > WEIGHT_CLOSURE_BAND {
453        return Err(CoonsRefusal::CornerWeightClosure {
454            ratio: weight_closure,
455            band: WEIGHT_CLOSURE_BAND,
456        });
457    }
458    for (index, curve) in oriented.iter_mut().enumerate() {
459        if weight_scales[index] != 1.0 {
460            let scaled = curve
461                .control_points
462                .iter()
463                .map(|point| point.scale(weight_scales[index]))
464                .collect();
465            *curve = NurbsCurve::new(curve.degree, curve.knots.clone(), scaled)
466                .map_err(CoonsRefusal::Geometry)?;
467        }
468    }
469
470    Ok(Oriented {
471        curves: oriented,
472        reversed,
473        corner_gaps,
474        band,
475        weight_scales,
476        weight_closure,
477    })
478}
479
480/// The report skeleton every constructor fills: what orientation measured,
481/// with the blend-specific fields still to be written.
482pub(crate) fn report_skeleton(oriented: &Oriented, work: [CompatWork; 4]) -> CoonsReport {
483    CoonsReport {
484        reversed: oriented.reversed,
485        corner_gaps: oriented.corner_gaps,
486        corner_band: oriented.band,
487        weight_scales: oriented.weight_scales,
488        weight_closure: oriented.weight_closure,
489        work,
490        degree_u: 0,
491        degree_v: 0,
492        control_rows: 0,
493        control_columns: 0,
494        min_weight: f64::INFINITY,
495        boundary_residual: 0.0,
496        ribbons: None,
497    }
498}
499
500/// Orientation plus the two-curve compatibility the bilinear blend needs.
501fn prepare(boundaries: &[NurbsCurve; 4]) -> Result<Prepared, CoonsRefusal> {
502    let oriented = orient_and_reconcile(boundaries)?;
503    // Chain corners: curves[0] runs P00 -> P10, [1] P10 -> P11,
504    // [2] P11 -> P01, [3] P01 -> P00.
505    let c0 = oriented.curves[0].clone();
506    let d1 = oriented.curves[1].clone();
507    let c1 = oriented.curves[2].reversed().map_err(CoonsRefusal::Geometry)?;
508    let d0 = oriented.curves[3].reversed().map_err(CoonsRefusal::Geometry)?;
509
510    let (u_family, u_work) = compat::make_family_compatible(&[c0, c1], 1).map_err(|detail| {
511        CoonsRefusal::Incompatible {
512            direction: 'u',
513            detail,
514        }
515    })?;
516    let (v_family, v_work) = compat::make_family_compatible(&[d0, d1], 1).map_err(|detail| {
517        CoonsRefusal::Incompatible {
518            direction: 'v',
519            detail,
520        }
521    })?;
522
523    // Work records go back into the CALLER's boundary order.
524    let mut work = [CompatWork::default(); 4];
525    work[0] = u_work[0];
526    work[2] = u_work[1];
527    work[3] = v_work[0];
528    work[1] = v_work[1];
529
530    let mut report = report_skeleton(&oriented, work);
531    report.degree_u = u_family[0].degree;
532    report.degree_v = v_family[0].degree;
533    report.control_rows = u_family[0].control_points.len();
534    report.control_columns = v_family[0].control_points.len();
535    Ok(Prepared {
536        c0: u_family[0].clone(),
537        c1: u_family[1].clone(),
538        d0: v_family[0].clone(),
539        d1: v_family[1].clone(),
540        report,
541    })
542}
543
544/// Assemble the bilinearly blended patch from the prepared boundaries.
545fn build_bilinear(prepared: Prepared) -> Result<(NurbsSurface, CoonsReport), CoonsRefusal> {
546    let Prepared {
547        c0,
548        c1,
549        d0,
550        d1,
551        mut report,
552    } = prepared;
553    let rows = c0.control_points.len();
554    let columns = d0.control_points.len();
555    let eta = compat::greville(&c0.knots, c0.degree);
556    let xi = compat::greville(&d0.knots, d0.degree);
557
558    // The four corners: one homogeneous point each, shared by two boundaries.
559    let corner_00 = homogeneous_midpoint(c0.control_points[0], d0.control_points[0]);
560    let corner_10 = homogeneous_midpoint(c0.control_points[rows - 1], d1.control_points[0]);
561    let corner_01 = homogeneous_midpoint(c1.control_points[0], d0.control_points[columns - 1]);
562    let corner_11 =
563        homogeneous_midpoint(c1.control_points[rows - 1], d1.control_points[columns - 1]);
564
565    let mut net: Vec<Vec<Vec4>> = vec![
566        vec![
567            Vec4 {
568                x: 0.0,
569                y: 0.0,
570                z: 0.0,
571                w: 1.0
572            };
573            columns
574        ];
575        rows
576    ];
577    for (row, net_row) in net.iter_mut().enumerate() {
578        for (column, cell) in net_row.iter_mut().enumerate() {
579            // S = ruled_v + ruled_u - bilinear, in homogeneous coordinates.
580            let ruled_v = c0.control_points[row]
581                .scale(1.0 - xi[column])
582                .add(c1.control_points[row].scale(xi[column]));
583            let ruled_u = d0.control_points[column]
584                .scale(1.0 - eta[row])
585                .add(d1.control_points[column].scale(eta[row]));
586            let bilinear = corner_00
587                .scale((1.0 - eta[row]) * (1.0 - xi[column]))
588                .add(corner_10.scale(eta[row] * (1.0 - xi[column])))
589                .add(corner_01.scale((1.0 - eta[row]) * xi[column]))
590                .add(corner_11.scale(eta[row] * xi[column]));
591            *cell = ruled_v.add(ruled_u).add(bilinear.scale(-1.0));
592        }
593    }
594    // The boundary rows are ASSIGNED, not summed: `a + (b - a)` is not `b` in
595    // floating point, and the boundaries are the one thing this constructor
596    // promises exactly.
597    for row in 0..rows {
598        net[row][0] = c0.control_points[row];
599        net[row][columns - 1] = c1.control_points[row];
600    }
601    for column in 0..columns {
602        net[0][column] = d0.control_points[column];
603        net[rows - 1][column] = d1.control_points[column];
604    }
605    net[0][0] = corner_00;
606    net[rows - 1][0] = corner_10;
607    net[0][columns - 1] = corner_01;
608    net[rows - 1][columns - 1] = corner_11;
609
610    let mut min_weight = f64::INFINITY;
611    let mut worst = (0usize, 0usize);
612    for (row, net_row) in net.iter().enumerate() {
613        for (column, cell) in net_row.iter().enumerate() {
614            if cell.w < min_weight {
615                min_weight = cell.w;
616                worst = (row, column);
617            }
618        }
619    }
620    if !(min_weight > 0.0) || !min_weight.is_finite() {
621        return Err(CoonsRefusal::NonPositiveWeight {
622            row: worst.0,
623            column: worst.1,
624            weight: min_weight,
625        });
626    }
627    report.min_weight = min_weight;
628    report.boundary_residual = boundary_residual(&net, &c0, &c1, &d0, &d1);
629
630    let surface = NurbsSurface::new(
631        c0.degree,
632        d0.degree,
633        c0.knots.clone(),
634        d0.knots.clone(),
635        net,
636    )
637    .map_err(CoonsRefusal::Geometry)?;
638    Ok((surface, report))
639}
640
641/// Worst homogeneous distance between the assembled boundary rows and the
642/// compatible boundary curves they were built from.  Non-zero only where two
643/// boundaries disagreed about a corner.
644fn boundary_residual(
645    net: &[Vec<Vec4>],
646    c0: &NurbsCurve,
647    c1: &NurbsCurve,
648    d0: &NurbsCurve,
649    d1: &NurbsCurve,
650) -> f64 {
651    let rows = net.len();
652    let columns = net[0].len();
653    let mut worst = 0.0f64;
654    for row in 0..rows {
655        worst = worst.max(homogeneous_distance(net[row][0], c0.control_points[row]));
656        worst = worst.max(homogeneous_distance(
657            net[row][columns - 1],
658            c1.control_points[row],
659        ));
660    }
661    for column in 0..columns {
662        worst = worst.max(homogeneous_distance(net[0][column], d0.control_points[column]));
663        worst = worst.max(homogeneous_distance(
664            net[rows - 1][column],
665            d1.control_points[column],
666        ));
667    }
668    worst
669}