From 2fbff1b202089dbf014e8966ff7862c59a6fb273 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Feb 2026 00:19:40 +0000 Subject: [PATCH 01/16] Version bump to v2.0.718 --- version.scad | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.scad b/version.scad index 6fa9f611..cf6098ac 100644 --- a/version.scad +++ b/version.scad @@ -13,7 +13,7 @@ _BOSL2_VERSION = is_undef(_BOSL2_STD) && (is_undef(BOSL2_NO_STD_WARNING) || !BOS echo("Warning: version.scad included without std.scad; dependencies may be missing\nSet BOSL2_NO_STD_WARNING = true to mute this warning.") true : true; -BOSL_VERSION = [2,0,717]; +BOSL_VERSION = [2,0,718]; // Section: BOSL Library Version Functions From 0cd16e3fdd626f62e5c26146fdffccfdd7a2b55c Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Sun, 22 Feb 2026 10:23:41 -0800 Subject: [PATCH 02/16] Update version.scad --- version.scad | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/version.scad b/version.scad index cf6098ac..7e2f9e7f 100644 --- a/version.scad +++ b/version.scad @@ -13,7 +13,11 @@ _BOSL2_VERSION = is_undef(_BOSL2_STD) && (is_undef(BOSL2_NO_STD_WARNING) || !BOS echo("Warning: version.scad included without std.scad; dependencies may be missing\nSet BOSL2_NO_STD_WARNING = true to mute this warning.") true : true; +<<<<<<< HEAD BOSL_VERSION = [2,0,718]; +======= +BOSL_VERSION = [2,0,720]; +>>>>>>> upstream/master // Section: BOSL Library Version Functions From 5d039d098238683e52bd3c49890eb4bb1553be9d Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Mon, 27 Apr 2026 11:07:47 -0700 Subject: [PATCH 03/16] Create nurbs_interp.scad --- nurbs_interp.scad | 3605 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3605 insertions(+) create mode 100644 nurbs_interp.scad diff --git a/nurbs_interp.scad b/nurbs_interp.scad new file mode 100644 index 00000000..e41290e1 --- /dev/null +++ b/nurbs_interp.scad @@ -0,0 +1,3605 @@ +////////////////////////////////////////////////////////////////////// +// LibFile: nurbs_interp.scad +// NURBS Curve Interpolation through Data Points +// +// Given a set of data points, computes the NURBS control points and +// knot vector such that the resulting curve passes exactly through +// every data point. Supports two BOSL2 NURBS types: +// "clamped" - curve starts/ends at first/last data point +// "closed" - curve forms a smooth closed loop through all points +// +// Optional per-point derivative (tangent) constraints can be applied +// to both curve types via the deriv= parameter. The clamped +// type also accepts the start_deriv=/end_deriv= shorthand arguments. +// (Piegl & Tiller, "The NURBS Book", Section 9.2.2.) +// +// Algorithm from Piegl & Tiller, "The NURBS Book", Chapters 2 & 9. +// +// Requires BOSL2. To use, add these lines to the top of your file: +// include +// include +// include +// +// Author: Claude (Anthropic), 2026 +// License: BSD-2-Clause (same as BOSL2) +// Development Version 187 +////////////////////////////////////////////////////////////////////// + + +// ===================================================================== +// SECTION: Internal B-spline Basis Functions +// ===================================================================== + +// Cox-de Boor recursive B-spline basis function N_{i,p}(u). +// Returns 0 for out-of-range indices (safe for periodic evaluation). + +function _nip(i, p, u, U) = + let(maxidx = len(U) - 1) + (i < 0 || i + p + 1 > maxidx) ? 0 + : p == 0 + ? (u >= U[i] && u < U[i+1]) ? 1 + : (abs(u - U[i+1]) < 1e-12 && abs(U[i+1] - U[maxidx]) < 1e-12) ? 1 + : 0 + : let( + d1 = U[i+p] - U[i], + d2 = U[i+p+1] - U[i+1], + c1 = abs(d1) > 1e-15 + ? (u - U[i]) / d1 * _nip(i, p-1, u, U) : 0, + c2 = abs(d2) > 1e-15 + ? (U[i+p+1] - u) / d2 * _nip(i+1, p-1, u, U) : 0 + ) + c1 + c2; + + +// Derivative of B-spline basis N_{j,p}'(u). +// Standard recurrence (P&T §2.3 eq. 2.9); zero-length spans are guarded. + +function _dnip(j, p, u, U) = + p == 0 ? 0 + : let( + d1 = U[j+p] - U[j], + d2 = U[j+p+1] - U[j+1] + ) + (abs(d1) > 1e-15 ? p * _nip(j, p-1, u, U) / d1 : 0) + - (abs(d2) > 1e-15 ? p * _nip(j+1, p-1, u, U) / d2 : 0); + + +// Second derivative of B-spline basis N_{j,p}''(u). +// Same recurrence as _dnip applied once more (P&T §2.3 eq. 2.9); +// zero-length spans are guarded. Returns 0 for p ≤ 1. + +function _d2nip(j, p, u, U) = + p <= 1 ? 0 + : let( + d1 = U[j+p] - U[j], + d2 = U[j+p+1] - U[j+1] + ) + (abs(d1) > 1e-15 ? p * _dnip(j, p-1, u, U) / d1 : 0) + - (abs(d2) > 1e-15 ? p * _dnip(j+1, p-1, u, U) / d2 : 0); + + +// ===================================================================== +// SECTION: Input Helpers +// ===================================================================== + +// Validate and coerce a single derivative vector to the required dimension. +// +// dim == 2 (special case): +// Accepts a 3D BOSL2 direction constant (UP, DOWN, LEFT, RIGHT, BACK, FWD) +// by projecting it onto the data plane. The vector must lie in the XZ plane +// (Y=0, as UP/DOWN/LEFT/RIGHT/FWD/BACK are defined) or the XY plane (Z=0). +// Underlength inputs (1D) are zero-padded to 2D as in the general case. +// +// All dimensions (dim ≥ 2): +// Any vector shorter than dim is zero-padded to length dim. +// Vectors longer than dim (not handled by the dim=2 special case) error. + +function _force_deriv_dim(deriv, dim) = + dim == 2 && is_vector(deriv, 3) ? + // Special: 3D BOSL2 constant for 2D curve — project onto data plane. + assert(deriv.y == 0 || deriv.z == 0, + "\nDerivative for a 2D interpolation cannot be fully 3D. It must have either Y or Z component equal to zero.") + deriv.y == 0 ? [deriv.x, deriv.z] : point2d(deriv) + : // General: validate length ≤ dim, then zero-pad to exactly dim. + assert(is_vector(deriv) && len(deriv) >= 1 && len(deriv) <= dim, + str("\nDerivative must be a non-empty vector of dimension ", dim, " or less.")) + list_pad(deriv, dim, 0); + + +// Convert a curvature specification to a C''(t) constraint vector. +// +// Under natural-speed parameterization (|C'(t)| = v), curvature κ and +// the second derivative relate by: C''(t) = κ_vec_normal × v². +// Tangential acceleration is set to zero (arc-length parameterization at that point). +// +// curv_spec = signed scalar κ (dim=2), or a vector (any dim including 2D). +// Scalar (dim=2): positive = CCW (left), negative = CW (right). +// Vector: magnitude = |κ|; the perpendicular projection onto +// the plane normal to tang_dir provides the direction only. +// For dim=2 curves, accepts 3D BOSL2 direction constants +// (UP, DOWN, LEFT, RIGHT, etc.) — projected to 2D same as deriv=. +// tang_dir = tangent direction at the point (need not be normalized). +// dim = spatial dimension (len(points[0])). +// v2 = |C'(t)|² at the constrained point. + +function _curv_to_d2(curv_spec, tang_dir, dim, v2) = + let(t_hat = unit(tang_dir)) + (dim == 2 && is_num(curv_spec)) + ? // 2D signed scalar: rotate tangent 90° CCW to get the normal direction. + let(n_hat = [-t_hat[1], t_hat[0]]) + curv_spec * n_hat * v2 + : // Vector form (any dim, including 2D): magnitude from the input vector, + // direction from the perpendicular projection. + // Accepts 3D BOSL2 direction constants (UP, DOWN, etc.) for 2D curves + // via _force_deriv_dim projection, same as derivative constraints. + assert(is_vector(curv_spec) && len(curv_spec) >= 1 && + (len(curv_spec) <= dim || (dim == 2 && len(curv_spec) == 3)), + str("nurbs_interp: curvature constraint must be a signed scalar (2D) or a vector of dimension 1–", dim, + " (3D BOSL2 constants like UP/DOWN accepted for 2D curves)")) + let( + cv = _force_deriv_dim(curv_spec, dim), + mag = norm(cv), + cv_perp = cv - (cv * t_hat) * t_hat, + n_perp = norm(cv_perp) + ) + assert(n_perp > 1e-12, + "nurbs_interp: curvature constraint is parallel to the derivative at the same point — curvature must have a component perpendicular to the tangent direction") + mag * (cv_perp / n_perp) * v2; + + +// Merges start_deriv=/end_deriv= into a per-point list of length n+1. +// When dim is provided each non-undef, non-NaN entry is projected via +// _force_deriv_dim(): BOSL2 3D direction constants (UP, LEFT, …) map to the +// correct 2D or 3D vector, and shorter vectors are zero-padded. +// NaN corner-marker entries (0/0) pass through unchanged. +// Returns undef when no constraint is specified. +function _merge_deriv_list(n, deriv, dim=undef, start_deriv=undef, end_deriv=undef) = + let( + raw = !is_undef(deriv) ? deriv + : (!is_undef(start_deriv) || !is_undef(end_deriv)) + ? [for (k = [0:1:n]) + k == 0 && !is_undef(start_deriv) ? start_deriv + : k == n && !is_undef(end_deriv) ? end_deriv + : undef] + : undef + ) + is_undef(dim) || is_undef(raw) ? raw + : [for (v = raw) is_undef(v) || is_nan(v) ? v : _force_deriv_dim(v, dim)]; + + +// Merges start_curvature=/end_curvature= into a per-point list of length n+1. +// When dim is provided, vector entries are projected via _force_deriv_dim() +// (handles BOSL2 3D direction constants for 2D curves). Signed-scalar entries +// (valid for dim=2) are left as-is; the sign encodes the turn direction. +// Returns undef when no constraint is specified. +function _merge_curv_list(n, curvature, dim=undef, start_curvature=undef, end_curvature=undef) = + let( + raw = !is_undef(curvature) ? curvature + : (!is_undef(start_curvature) || !is_undef(end_curvature)) + ? [for (k = [0:1:n]) + k == 0 && !is_undef(start_curvature) ? start_curvature + : k == n && !is_undef(end_curvature) ? end_curvature + : undef] + : undef + ) + is_undef(dim) || is_undef(raw) ? raw + : [for (v = raw) (is_undef(v) || is_num(v)) ? v : _force_deriv_dim(v, dim)]; + + +// ===================================================================== +// SECTION: Parameterization +// ===================================================================== + + +// Dynamic centripetal parameterization (Balta et al., IEEE Access 2020 §III). +// Per-chord exponent inversely proportional to ln(chord_length): +// e_i = ln(chordmax/chordi) / ln(chordmax/chordmin) * (emax-emin) + emin +// Long chords get exponent emin=0.35 (compressed contribution). +// Short chords get exponent emax=0.65 (expanded contribution). +// Falls back to e=0.5 (standard centripetal) when all chords are equal. + +function _dynamic_dists(raw, emin=0.35, emax=0.65) = + let( + cmax = max(raw), + cmin = min(raw), + log_r = ln(cmax / cmin) + ) + // Divide each chord by cmin so that d/cmin ≥ 1 for every chord. + // This is required for correctness: pow(x, e) is an increasing function + // of e only when x > 1, so d > 1 ensures that the longer chords (with + // smaller exponent emin) are correctly compressed relative to shorter + // chords (with larger exponent emax). Normalizing by cmin also makes + // the result scale-invariant: λd/λcmin = d/cmin for any scale factor λ. + log_r < 1e-12 + ? [for (d = raw) sqrt(d / cmin)] // equal chords → uniform spacing + : [for (d = raw) + let(e = ln(cmax / d) / log_r * (emax - emin) + emin) + pow(d / cmin, e) + ]; + + + +// Foley-Neilson parameterization (Foley & Neilson 1987). +// Centripetal base with deflection-angle correction at each vertex. +function _foley_dists(points, closed) = + let( + n = len(points), + c = path_segment_lengths(points, closed=closed), + nc = len(c), + // Centripetal base: sqrt of each chord length. + d = [for (ci = c) sqrt(ci)], + // θ̂[i] = min(deflection angle at P[i], π/2) in radians. + // Deflection angle = 180° − interior angle at P[i]. + // Endpoints of an open curve contribute zero correction. + theta_hat = [for (i = [0:1:n-1]) + !closed && (i == 0 || i == n-1) ? 0 + : let(phi_deg = 180 - vector_angle(select(points, i-1, i+1))) + min(phi_deg * PI/180, PI/2) + ] + ) + [for (i = [0:1:nc-1]) + let( + di = d[i], + d_prev = d[(i - 1 + nc) % nc], + d_next = d[(i + 1) % nc], + th_L = theta_hat[i], + th_R = theta_hat[(i + 1) % n], + left = 3 * th_L * d_prev / (2 * (d_prev + di)), + right = 3 * th_R * d_next / (2 * (di + d_next)) + ) + di * (1 + left + right) + ]; + + +// Fang improved centripetal parameterization (Fang & Hung, CAD 2013, Eq. 10). +// Centripetal base + osculating-circle dragging tolerance (α = 0.1). +// At each interior point Pᵢ, eᵢ = α·(θᵢ·ℓᵢ/(2·sin(θᵢ/2)) + θᵢ₋₁·ℓᵢ₋₁/(2·sin(θᵢ₋₁/2))) +// where θᵢ is deflection angle at Pᵢ, ℓᵢ is shortest side of triangle Pᵢ₋₁PᵢPᵢ₊₁. +// Each chord increment is Δᵢ = √‖Lᵢ‖ + eᵢ + eᵢ₊₁ (corrections from both endpoints). + +function _fang_correction(points, closed) = + let(n = len(points)) + [for (i = [0:1:n-1]) + !closed && (i == 0 || i == n-1) ? 0 + : let( + tri = select(points, i-1, i+1), + ell = min(path_segment_lengths(tri, closed=true)), + theta_deg = 180 - vector_angle(select(points, i-1, i+1)) + ) + // θ·ℓ/(2·sin(θ/2)); limit as θ→0 is ℓ. + 0.1 * (abs(theta_deg) < 1e-6 ? ell + : theta_deg * PI/180 * ell / (2 * sin(theta_deg / 2))) + ]; + +function _fang_dists(points, closed) = + let( + c = path_segment_lengths(points, closed=closed), + nc = len(c), + ef = _fang_correction(points, closed) + ) + [for (i = [0:1:nc-1]) + sqrt(c[i]) + ef[i] + select(ef, i+1) + ]; + + +// Chord-length, centripetal, dynamic, Foley, or Fang parameterization. +// clamped: n+1 points -> n+1 values in [0, 1] with t_0=0, t_n=1. +// closed: n points -> n values in [0, 1) with t_0=0. +// method: "length" = chord-length +// "centripetal" = sqrt exponent (Lee 1989) +// "dynamic" = per-chord dynamic exponent (Balta et al. 2020) +// "foley" = centripetal + deflection-angle correction (Foley & Neilson 1987) +// "fang" = centripetal + osculating-circle correction (Fang & Hung 2013) + +function _interp_params(points, method="centripetal", closed=false) = + let( + raw = path_segment_lengths(points, closed=closed), + n = len(raw), + total_raw = sum(raw) + ) + // Degenerate: all points identical (e.g. a surface pole row/column). + // Return uniform spacing so surface parameter averages stay valid. + total_raw < 1e-10 + ? (closed + ? [for (i = [0:1:n-1]) i / n] + : [for (i = [0:1:n ]) i / n]) + : assert(min(raw) > 1e-10, + "nurbs_interp: consecutive duplicate data points detected") + let( + dists = method == "centripetal" ? [for (d = raw) sqrt(d)] + : method == "dynamic" ? _dynamic_dists(raw) + : method == "foley" ? _foley_dists(points, closed) + : method == "fang" ? _fang_dists(points, closed) + : raw, + total = sum(dists), + cs = cumsum(dists) + ) + closed ? [0, each [for (x = list_head(cs)) x / total]] + : [0, each [for (x = list_head(cs)) x / total], 1]; + + +// ===================================================================== +// SECTION: Knot Vector Construction +// ===================================================================== + +// Interior knots by averaging (Piegl & Tiller eq 9.8). + +function _avg_knots_interior(params, p) = + let( + n = len(params) - 1, + num_internal = n - p + ) + num_internal <= 0 + ? [] + : [for (j = [1:1:num_internal]) + sum([for (i = [j :1: j + p - 1]) params[i]]) / p + ]; + + +// Full clamped knot vector: (p+1) zeros, interior, (p+1) ones. + +function _full_clamped_knots(interior_knots, p) = + concat(repeat(0, p+1), interior_knots, repeat(1, p+1)); + + +// Periodic "bar knots" for closed B-splines. +// +// Returns [bar_knots, shifted_params] where bar_knots is n+1 +// monotonically increasing values with bar[0]=0, bar[n]=1, and +// shifted_params are the parameter values shifted to match. +// +// The raw bar knots are computed by averaging p consecutive values +// from the extended periodic parameter sequence t_m = params[m%n] + +// floor(m/n). This is guaranteed monotonic. We then shift so +// bar[0]=0, and shift params by the same amount. + +function _avg_knots_periodic(params, p) = + let( + n = len(params), + raw = [for (j = [0:1:n]) + sum([for (k = [0:1:p-1]) + let(m = j + k) + params[m % n] + floor(m / n) + ]) / p + ], + shift = raw[0], + bar_knots = add_scalar(raw, -shift), + shifted = [for (t = params) + let(s = t - shift) + s < 0 ? s + 1 : (s >= 1 ? s - 1 : s)] + ) + [bar_knots, shifted]; + + +// Repair degenerate periodic bar knots: if any span is smaller than +// eps × period, merge it into its neighbor and bisect the resulting +// larger span. Preserves the knot count (n+1 entries, n spans) and +// the endpoint values bar[0]=0, bar[n]=period. Recurses until no +// tiny spans remain. + +function _fix_tiny_spans(bar_knots, n, eps=1e-6) = + let( + T = bar_knots[n], + spans = [for (k = [0:1:n-1]) bar_knots[k+1] - bar_knots[k]], + min_span = min(spans) + ) + min_span >= eps * T ? bar_knots + : let( + k = min_index(spans), + // Remove an interior knot bounding the tiny span. + // For span 0 (first span), remove knot 1 and absorb into span 1. + // For span n-1 (last span), remove knot n-1 and absorb into span n-2. + // Otherwise, remove knot k+1 and absorb into the merged span at k. + remove_idx = k == 0 ? 1 + : k == n - 1 ? n - 1 + : k + 1, + merged = [for (i = [0:1:n]) if (i != remove_idx) bar_knots[i]], + absorb_k = k == 0 ? 0 : k - 1, + // Bisect the absorbing span to restore the knot count. + mid = (merged[absorb_k] + merged[absorb_k + 1]) / 2, + fixed = [for (i = [0:1:n-1]) // n entries in merged + each (i == absorb_k ? [merged[i], mid] : [merged[i]])] + ) + _fix_tiny_spans(fixed, n, eps); + + +// Insert extra knots into a base bar_knots vector, one per +// constraint parameter. For each constraint, finds the span +// containing its parameter value and inserts at the span midpoint. +// When multiple constraints compete, the one whose containing span +// is largest is processed first — this avoids splitting a small +// span when a larger one is available. Each insertion updates the +// knot vector before the next constraint is processed. +// +// bar_knots: base bar_knots from periodic or interior averaging. +// constraint_ts: list of parameter values identifying which span +// to split. For closed: raw params in [0,1). +// For clamped: params in [0,1]. +// +// Returns the augmented bar_knots with len(constraint_ts) extra entries. + +function _insert_constraint_knots(bar_knots, constraint_ts) = + len(constraint_ts) == 0 ? bar_knots + : let( + n = len(bar_knots), + // For each constraint, find its containing span and that span's width. + spans = [for (ci = [0:1:len(constraint_ts)-1]) + let( + t = constraint_ts[ci], + pos = [for (i = [0:1:n-2]) + if (bar_knots[i] <= t && t < bar_knots[i+1]) i], + idx = len(pos) > 0 ? pos[0] : n - 2, + w = bar_knots[idx+1] - bar_knots[idx] + ) + [ci, idx, w] + ], + // Pick the constraint whose span is largest. + best = max_index([for (s = spans) s[2]]), + ci = spans[best][0], + idx = spans[best][1], + mid = (bar_knots[idx] + bar_knots[idx+1]) / 2, + new_knots = [each [for (i = [0:1:idx]) bar_knots[i]], mid, + each [for (i = [idx+1:1:n-1]) bar_knots[i]]], + remaining = [for (i = [0:1:len(constraint_ts)-1]) + if (i != ci) constraint_ts[i]] + ) + _insert_constraint_knots(new_knots, remaining); + + +// Return k parameter values, each at the midpoint of one of the k +// widest spans in bar_knots. Used to target extra knot insertions +// and smoothness rows at the most under-resolved regions. +// +// When all k picks come from equal-width spans (the common case for +// uniformly-parameterized closed curves), spans are chosen at centred- +// stratified indices floor((2g+1)*n/(2*k_eff)) % n for g=0..k_eff-1. +// This places each pick at the centre of its equal-width quantile +// rather than at the quantile boundary. For n=18, k=4 the picks +// are spans 2, 6, 11, 15 instead of 0, 4, 9, 13. +// +// Centering is essential for closed curves: _extend_knot_vector wraps +// span widths across the seam (span n-1 into the pre-region, span 0 +// into the post-region). If an extra knot is inserted in span 0, the +// span width at the start of aug_bar differs from the width at the end, +// making the basis functions slightly asymmetric at the seam and +// causing a visible fold in the null-space solution. Centering keeps +// both boundary spans at their original (uniform) width. +// When the k widest spans are not all equal, the standard widest-first +// selection is used (knot insertion targets the most under-resolved +// regions regardless of position). + +function _widest_span_params(bar_knots, k) = + let( + n = len(bar_knots) - 1, + k_eff = min(k, n), + _echo = k > n ? echo(str("nurbs_interp: extra_pts=", k, + " exceeds the number of available knot spans (", n, + "); reduced to ", n, ".")) : 0, + spans = [for (i = [0:1:n-1]) bar_knots[i+1] - bar_knots[i]], + w_max = max(spans), + // Indices of spans at the maximum width (within floating-point tolerance). + // Stratification picks only from these so that constraint-narrowed spans + // (e.g. from _insert_constraint_knots) are never accidentally chosen. + eq_idxs = [for (i = [0:1:n-1]) if (abs(spans[i] - w_max) < 1e-10 * w_max) i], + n_eq = len(eq_idxs) + ) + // If all k_eff picks come from equal-width spans, use centred stratification + // over eq_idxs so that constraint-narrowed spans are never selected. + n_eq >= k_eff + ? [for (g = [0:1:k_eff-1]) + let(i = eq_idxs[floor((2 * g + 1) * n_eq / (2 * k_eff))]) + (bar_knots[i] + bar_knots[i+1]) / 2 + ] + // Otherwise use widest-first selection (non-uniform spans). + : let( + sorted = sort([for (i = [0:1:n-1]) [spans[i], i]]), + top_k = [for (i = [n-1:-1:n-k_eff]) sorted[i]] + ) + [for (s = top_k) (bar_knots[s[1]] + bar_knots[s[1]+1]) / 2]; + + +// Find knot spans containing multiple data parameters and return +// splitting midpoints. Two data points in the same span cause a +// rank-deficient collocation matrix; inserting a knot between them +// restores full rank. +// +// bar_knots: sorted knot vector with n_spans+1 entries. +// params: sorted or unsorted data parameter values. +// +// Returns a list of splitting parameter values — one midpoint between +// each consecutive pair of params that share a span. + +function _span_split_params(bar_knots, params) = + let( + n_spans = len(bar_knots) - 1, + sorted = sort(params), + n_p = len(sorted), + // For each sorted param, find its span index. + span_of = [for (t = sorted) + let(pos = [for (i = [0:1:n_spans-1]) + if (t >= bar_knots[i] && + (i < n_spans-1 ? t < bar_knots[i+1] + : t <= bar_knots[i+1])) i]) + len(pos) > 0 ? pos[0] : n_spans - 1 + ] + ) + // Midpoints between consecutive sorted params sharing a span. + [for (i = [0:1:n_p-2]) + if (span_of[i] == span_of[i+1]) + (sorted[i] + sorted[i+1]) / 2 + ]; + + +// Build one row of the L^T*L matrix for control-polygon regularization. +// order=1: first-difference penalty (penalizes polygon length/variation). +// order=2: second-difference penalty (penalizes polygon bending). +// periodic=true wraps the differences around for closed curves. +// +// For clamped (non-periodic): +// order=1 L^T*L: tridiag [1,-1,0..] [-1,2,-1,0..] .. [0..,-1,1] +// order=2 L^T*L: pentadiag boundary-adapted +// For closed (periodic): +// order=1 L^T*L: circulant [2,-1,0..0,-1] +// order=2 L^T*L: circulant [6,-4,1,0..0,1,-4] + +function _ltl_row(M, i, order, periodic=false) = + periodic + ? (order == 1 + ? [for (j = [0:1:M-1]) + j == i ? 2 + : j == (i+1)%M || j == (i-1+M)%M ? -1 + : 0] + : // order == 2 + [for (j = [0:1:M-1]) + j == i ? 6 + : j == (i+1)%M || j == (i-1+M)%M ? -4 + : j == (i+2)%M || j == (i-2+M)%M ? 1 + : 0]) + : // clamped (non-periodic) + (order == 1 + ? [for (j = [0:1:M-1]) + j == i ? (i == 0 || i == M-1 ? 1 : 2) + : (j == i+1 || j == i-1) ? -1 + : 0] + : // order == 2, L is (M-2)×M second-difference matrix. + // (L^T L)[i][j] = sum_{r=0}^{M-3} L[r][i]*L[r][j] + // where L[r][c] = (c==r ? 1 : c==r+1 ? -2 : c==r+2 ? 1 : 0). + // Nonzero only when |i-j| <= 2. + [for (j = [0:1:M-1]) + abs(i-j) > 2 ? 0 + : i == j + ? (i <= M-3 ? 1 : 0) // r=i: 1² + + (i >= 1 && i <= M-2 ? 4 : 0) // r=i-1: (-2)² + + (i >= 2 ? 1 : 0) // r=i-2: 1² + : abs(i-j) == 1 + ? let(lo = min(i,j)) + (lo <= M-3 ? -2 : 0) // r=lo: (1)(-2) + + (lo >= 1 && lo <= M-2 ? -2 : 0) // r=lo-1: (-2)(1) + : // abs(i-j) == 2 + (min(i,j) <= M-3 ? 1 : 0) // r=min: (1)(1) + ]); + + +// Solve the constrained optimization min P^T·R·P s.t. A·P = rhs +// via null-space method. +// +// R = M×M regularization matrix (positive semidefinite). +// A = N×M constraint matrix (interpolation + derivative + curvature). +// rhs = N×dim right-hand side (data points + constraint vectors). +// +// Algorithm: +// 1. Step A — minimum-norm particular solution x_p satisfying A·x_p = rhs +// exactly, via BOSL2 linear_solve() (handles underdetermined systems). +// 2. Step B — minimize x^T·R·x in the null space of A (if M > N): +// Q2 = null_space(A) basis vectors (returned as rows by BOSL2) +// H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) +// Solve H · z = -Q2^T · R_pd · x_p via Cholesky +// P = x_p + Q2 · z +// +// Returns list of M control points, or undef on rank-deficient A. + +function _nullspace_solve(R, A, rhs, eps=1e-6) = + let( + M = len(R), + N_rows = len(A), + // Step A: minimum-norm particular solution via BOSL2. + // linear_solve handles underdetermined (M > N_rows) systems + // by returning the minimum-norm solution via QR of A^T. + x_p = linear_solve(A, rhs) + ) + x_p == [] ? undef + : M == N_rows ? x_p // Square: unique solution, no null space. + : let( + // Step B: minimize x^T·R·x in the null space. + // null_space() returns null-space vectors as rows. + ns = null_space(A), + n_ns = len(ns) + ) + n_ns == 0 ? x_p // Full rank despite M > N; no null space. + : let( + Q2 = transpose(ns), // M × n_ns (columns are basis vectors) + // Regularize R for strict positive-definiteness. + R_pd = [for (i = [0:1:M-1]) + [for (j = [0:1:M-1]) + R[i][j] + (i == j ? eps : 0)]], + // H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) + // Symmetrize to counteract floating-point round-off. + RQ2 = R_pd * Q2, + H_raw = transpose(Q2) * RQ2, + H = (H_raw + transpose(H_raw)) / 2, + // g = Q2^T · R_pd · x_p (n_ns × dim) + g = transpose(Q2) * (R_pd * x_p), + // Solve H · z = -g (H is SPD → Cholesky is fastest) + z = linear_solve(H, -g, method="cholesky") + ) + // If H solve fails (degenerate), x_p alone still satisfies constraints. + z == [] ? x_p + : x_p + Q2 * z; + + +// Gauss-Legendre quadrature nodes and weights on [-1,1]. +// Returns [[nodes], [weights]] for n-point rule (n = 2..5). +// Exact for polynomials up to degree 2n-1. + +function _gauss_legendre(n) = + n == 2 ? [[-0.5773502691896258, 0.5773502691896258], + [1.0, 1.0]] + : n == 3 ? [[-0.7745966692414834, 0.0, 0.7745966692414834], + [0.5555555555555556, 0.8888888888888888, 0.5555555555555556]] + : n == 4 ? [[-0.8611363115940526, -0.3399810435848563, + 0.3399810435848563, 0.8611363115940526], + [0.3478548451374538, 0.6521451548625461, + 0.6521451548625461, 0.3478548451374538]] + : // n >= 5 + [[-0.9061798459386640, -0.5384693101056831, 0.0, + 0.5384693101056831, 0.9061798459386640], + [0.2369268850561891, 0.4786286704993665, 0.5688888888888889, + 0.4786286704993665, 0.2369268850561891]]; + + +// Bending-energy regularization matrix R for the null-space solver. +// R[j][k] = ∫ B''_j(t) B''_k(t) dt (integrated squared second derivative). +// For clamped: B_j = N_{j,p}, integrated over full domain. +// For closed/periodic: B_j = N_j + (j

1e-15) + let(a = U_full[i], b = U_full[i+1], + hw = (b - a) / 2, mid = (a + b) / 2) + for (g = [0:1:n_gauss-1]) + [mid + hw * gl_nodes[g], gl_wts[g] * hw] + ], + + // Precompute all M (aliased) second derivatives at each quad point. + d2_at = [for (q = quad_pts) + let(t = q[0]) + [for (j = [0:1:M-1]) + periodic + ? _d2nip(j, p, t, U_full) + + (j < p ? _d2nip(j + M, p, t, U_full) : 0) + : _d2nip(j, p, t, U_full) + ] + ], + nq = len(quad_pts) + ) + // Assemble R[j][k] = sum_q w_q * d2[q][j] * d2[q][k] + [for (j = [0:1:M-1]) + [for (k = [0:1:M-1]) + sum([for (q = [0:1:nq-1]) + quad_pts[q][1] * d2_at[q][j] * d2_at[q][k] + ]) + ] + ]; + + +// Full periodic knot vector for "closed" type evaluation. +// Uses BOSL2's _extend_knot_vector() to build the n+2p+1 entry knot vector +// that nurbs_curve() constructs internally for closed-type curves. +// Active evaluation domain: [U[p], U[n+p]]. + +function _full_closed_knots(bar_knots, n, p) = + _extend_knot_vector(bar_knots, 0, n + 2*p + 1); + + +// ===================================================================== +// SECTION: Collocation Matrices +// ===================================================================== + +// Standard collocation matrix for clamped type. + +function _collocation_matrix(params, n, p, U) = + [for (k = [0:1:n]) + [for (j = [0:1:n]) + _nip(j, p, params[k], U) + ] + ]; + + +// Periodic collocation matrix for closed type (n x n). +// +// BOSL2 wraps the first p control points to the end, creating n+p +// basis functions. Basis N_{j+n} aliases control point j for j= p + +function _collocation_matrix_periodic(params, n, p, U_periodic) = + [for (k = [0:1:n-1]) + [for (j = [0:1:n-1]) + _nip(j, p, params[k], U_periodic) + + (j < p ? _nip(j + n, p, params[k], U_periodic) : 0) + ] + ]; + + +// ===================================================================== +// SECTION: Degree Elevation +// ===================================================================== + +// Greville abscissae for B-spline basis of degree p with full knot +// vector U. Returns n+1 values where n = len(U) - p - 2. Each g_i +// is the average of knots U[i+1] .. U[i+p]. For a clamped knot +// vector, g_0 = 0 and g_n = 1. These are optimal collocation sites +// for the B-spline space and automatically satisfy the Schoenberg- +// Whitney condition for non-singular collocation. + +function _greville(U, p) = + let(n = len(U) - p - 2) + [for (i = [0:1:n]) + sum([for (j = [i+1:1:i+p]) U[j]]) / p + ]; + + +// Increment the multiplicity of every distinct value in a knot vector +// by 1. Walk the vector; at the end of each run of equal values emit +// one extra copy. Equivalent to the new_interior construction in +// _elevate_once_clamped but applied to the complete (full) knot vector. +// Used by _elevate_once_open. + +function _increment_knot_mults(U) = + [for (i = [0:1:len(U)-1]) each + [U[i], + if (i == len(U)-1 || abs(U[i+1] - U[i]) > 1e-14) U[i]] + ]; + + +// Single degree elevation of a clamped or open B-spline via exact collocation. +// +// The elevated curve lies in the degree-(p+1) B-spline space whose knot +// vector has each distinct value's multiplicity incremented by 1. +// Evaluating the original curve at the Greville abscissae of the new basis +// and solving the collocation system recovers the exact elevated control +// points (the new space contains the original curve exactly). +// +// Input: ctrl = control points (any dimension >= 1) +// p = current degree (>= 1) +// U = full expanded knot vector (all multiplicities present) +// Output: [new_ctrl, U_new, p+1] +// U_new is the full expanded elevated knot vector. + +function _elevate_once(ctrl, p, U) = + let( + n_old = len(ctrl) - 1, + dim = len(ctrl[0]), + p_new = p + 1, + U_new = _increment_knot_mults(U), + n_new = len(U_new) - p_new - 2, + grev = _greville(U_new, p_new), + C_vals = [for (u = grev) + let(row = [for (j = [0:1:n_old]) _nip(j, p, u, U)]) + [for (d = [0:1:dim-1]) + sum([for (j = [0:1:n_old]) row[j] * ctrl[j][d]])] + ], + A = [for (k = [0:1:n_new]) + [for (i = [0:1:n_new]) _nip(i, p_new, grev[k], U_new)] + ], + Q = linear_solve(A, C_vals) + ) + assert(Q != [], + "nurbs_elevate_degree: singular collocation (should not happen)") + [Q, U_new, p_new]; + + +// Function: nurbs_elevate_degree() +// Synopsis: Raises the degree of a closed or open NURBS. +// Topics: NURBS Curves +// See Also: nurbs_interp(), nurbs_curve() +// +// Usage: +// result = nurbs_elevate_degree(control, degree, [knots=], [mult=], [type=], [times=], [weights=]); +// result = nurbs_elevate_degree(nurbs_param_list, [times=]); +// +// Description: +// Raises the degree of a "closed" or "open" NURBS by `times` steps, producing +// a geometrically identical curve at the higher degree. Returns a NURBS parameter list +// of the form `[type, degree, control_points, knots, undef, weights]` that can be +// passed directly to {{nurbs_curve()}} and other NURBS functions. The returned `mult` +// parameter is always undef; the returned `weights` will be defined only if you provided +// weights in your input. If you give `times=0` your input parameters are returned unchanged. +// . +// An elevated curve has the same smoothness as the original at each knot. A degree-2 +// curve that is $C^1$ at its knots will still be $C^1$ after elevation to degree 3, +// not $C^2$ as a fresh cubic NURBS with simple knots would be. +// . +// Instead of providing separate parameters you can give a first parameter of the form of a +// NURBS parameter list: `[type, degree, control, knots, mult, weights]`. +// +// Arguments: +// control = Control points, or a NURBS parameter list `[type, degree, ctrl, knots, mult, weights]` +// degree = Degree of NURBS +// --- +// knots = Knot vector. Default: uniform +// mult = List of multiplicities of the knots. Default: all 1 +// type = `"clamped"` or `"open"`. Default: `"clamped"` +// times = Number of degree-elevation steps. Default: `1` +// weights = Weight at each control point + +function nurbs_elevate_degree(control, degree, knots=undef, + type="clamped", times=1, weights=undef, + mult=undef) = + // Accept a NURBS parameter list as the first argument. + is_list(control) && in_list(control[0], ["closed","open","clamped"]) ? + assert(len(control)>=6, "Invalid NURBS parameter list") + assert(num_defined([degree,mult,weights,knots])==0, + "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list") + times == 0 ? control + : nurbs_elevate_degree(control[2], control[1], control[3], + type=control[0], times=times, + weights=control[5], mult=control[4]) + : times == 0 + ? [type, degree, control, knots, mult, weights] + // Rational NURBS: lift to homogeneous space, elevate as a plain B-spline, + // then extract weights from the last coordinate. The recursive call handles + // all asserts, knot normalization, and the times loop. + : !is_undef(weights) + ? assert(len(weights) == len(control), + "nurbs_elevate_degree: weights must have same length as control points") + let( + homo = [for (i = idx(control)) [each control[i]*weights[i],weights[i]]], + r = nurbs_elevate_degree(homo, degree, knots=knots, type=type, times=times, mult=mult), + new_w = [for (pt = r[2]) last(pt)], + new_ctrl = [for (pt = r[2]) slice(pt,0,-2)/last(pt) ] + ) + [r[0], r[1], new_ctrl, r[3], undef, new_w] + // Non-rational B-spline path. + : assert(type == "clamped" || type == "open", + str("nurbs_elevate_degree: type must be \"clamped\" or \"open\", got \"", type, "\"")) + assert(is_num(times) && times >= 1, + "nurbs_elevate_degree: times must be a positive integer") + assert(is_num(degree) && degree >= 1, + "nurbs_elevate_degree: degree must be >= 1") + assert(is_list(control) && len(control) >= 2, + "nurbs_elevate_degree: need at least 2 control points") + assert(is_undef(knots) || is_undef(mult) || len(mult) == len(knots), + str("nurbs_elevate_degree: mult and knots must have the same length; got len(mult)=", + is_undef(mult) ? "undef" : len(mult), + " len(knots)=", + is_undef(knots) ? "undef" : len(knots))) + let( + // Normalize (knots, mult) → internal format for _elevate_once. + // + // clamped: xknots = [k0, interior..., km] — one copy each including endpoints. + // open: xknots = full expanded knot vector (all multiplicities present). + // + // Neither knots nor mult → BOSL2-compatible uniform knots. + // clamped → interior format [0, uniform interior..., 1] + // open → full expanded vector (length n+p+2, uniform) + // + // knots only (no mult): pass through unchanged. + // + // mult only (no knots): uniform positions 0..1 with given multiplicities. + // clamped: endpoint mult forced to degree+1; expand then strip. + // open: full expanded vector. + // + // knots + mult: explicit distinct positions with per-knot multiplicities. + // clamped: endpoint mult forced to degree+1; expand then strip. + // open: full expanded vector. + xknots = + is_undef(knots) && is_undef(mult) + ? ( type == "clamped" ? lerpn(0, 1, len(control) - degree + 1) + : lerpn(0, 1, len(control) + degree + 1) ) + : is_undef(mult) ? knots + : is_undef(knots) + ? let( + m = len(mult), + adj = type == "clamped" && m >= 2 + ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] + : mult, + pos = [for (i = [0:1:m-1]) m == 1 ? 0 : i / (m - 1)], + exp = [for (i = [0:1:m-1]) each repeat(pos[i], adj[i])] + ) + type == "clamped" + ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] + : exp + : let( + m = len(mult), + adj = type == "clamped" && m >= 2 + ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] + : mult, + exp = [for (i = [0:1:m-1]) each repeat(knots[i], adj[i])] + ) + type == "clamped" + ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] + : exp + ) + assert(type != "clamped" || len(xknots) >= 2, + "nurbs_elevate_degree: clamped knots must have at least 2 entries [first,...,last]") + assert(type != "open" || len(xknots) == len(control) + degree + 1, + str("nurbs_elevate_degree: open knots must have length len(control)+degree+1 = ", + len(control) + degree + 1, ", got ", len(xknots))) + let( + // _elevate_once works on the full expanded knot vector. + // Clamped xknots = [k0, interior..., km]; expand to full by adding p copies + // of each endpoint. Open xknots is already full. After elevation, strip the + // p+1 endpoint copies back off for clamped so the output stays in xknots format. + U_full = type == "clamped" + ? concat(repeat(xknots[0], degree), xknots, repeat(last(xknots), degree)) + : xknots, + r = _elevate_once(control, degree, U_full), + new_knots = type == "clamped" + ? slice(r[1], degree+1, -degree-2) + : r[1] + ) + times == 1 + ? [type, r[2], r[0], new_knots, undef, undef] + : nurbs_elevate_degree(r[0], r[2], new_knots, type=type, times=times-1); + + +// ===================================================================== +// SECTION: Local Rational Quadratic Interpolation (P&T §9.3.3) +// ===================================================================== + + +// ===================================================================== +// SECTION: Main Interpolation Function +// ===================================================================== + +// Function: nurbs_interp() +// Synopsis: Finds a NURBS curve passing through a point list with optional derivative constraints. +// Topics: NURBS Curves, Interpolation +// SynTags: Geom +// See Also: nurbs_curve(), debug_nurbs(), nurbs_interp_curve(), debug_nurbs_interp() +// +// Usage: +// nurbs_param = nurbs_interp(points, degree, [method=], [closed=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [deriv=], [extra_pts=], [smooth=]); +// +// Description: +// Given a list of data points and a NURBS degree, computes a curve of the specified degree +// that passes exactly through every data point. The computed curve always has +// uniform weights, but irregularly spaced knots, so it is actually a non-uniform B-spline. +// Data points may 2D or any higher dimension. Returns a NURBS parameter list of the form +// `[type, degree, control_points, knots, undef, undef, u]` that can be +// passed directly to {{nurbs_curve()}} and other NURBS functions. The extra return value `u`, +// described in detail below, enables you to locate your input points in the computed spline +// . +// When `closed=false` (the default) the output is a "clamped" NURBS. +// When `closed=true`, the interpolation treats the data points as a loop and produces a +// curve that is smooth at the closing point. The output will be a "closed" NURBS (unless you +// specify corners as described below). +// If you instead duplicate the closing point and set `closed=false` then the +// result will have a corner at the closing point. +// . +// **Parameterization** (`method=`) +// . +// In order to solve the interpolation problem, the algorithm first chooses +// the NURBS parameter value `u[k]` that will correspond to each `points[k]`. +// This parametrization step significantly affects the shape of the output curve, particularly when the +// data points are not evenly spaced. The following methods are supported: +// . +// - `"length"` — Base parameters values on the chord length, which is distance between the consecutive data points. +// Best when data points are fairly evenly spaced. +// - `"centripetal"` (default) — Base parameters values on the square root of the chord length. (Lee 1989). +// - `"dynamic"` — like centripetal, but the exponent 0.5 is replaced +// by a per-chord value chosen based on local spacing variation. Long chords +// get a smaller exponent and short chords a larger one, compressing the +// influence of outliers. Chord lengths are normalized, which makes the method scale +// invariant and prevents misbehavior at extreme scales. Scaling is not given in the original reference. (Balta et al. 2020). +// - `"foley"` — centripetal base, augmented by corrections at each point that +// are proportional to the local turn angle. Sharp bends pull parameter values +// closer together, which tends to reduce overshoot at corners (Foley & Neilson 1987). +// - `"fang"` — centripetal base, augmented by a correction based on the radius +// of the osculating circle at each point. Said to handles mixed straight-and-curved +// segments particularly well. This method is NOT scale invariant, so results will +// change if you scale your input data. (Fang & Hung 2013). +// . +// The other required input to the interpolation is the location of the knots. +// We place knots using a moving average of `degree` consecutive parameter values, which links +// the knots to the local parameter spacing. A consequence of this process for selection +// of the parameters and knot locations is that even if your input data has symmetry it is +// likely that the symmetry will be broken in the output. For closed curves, another +// consequence is that the resulting curve will depend on which point is chosen as the +// starting point for the interpolation. The algorithm chooses a starting point +// that is expected to provide the best behaved interpolation curve. Examining the +// knot positions with {{debug_nurbs_interp()}} may help you understand unexpected behavior +// you observe in the output. If your curve does not +// behave as desired you may be able to adjust it by imposing additional constraints or +// by giving it more freedom using `extra_pts`. +// . +// **Derivative constraints** (`deriv=`, `start_deriv=`, `end_deriv=`) +// . +// `deriv[k]` specifies the tangent direction and speed the curve must have +// as it passes through `points[k]`. The length of `deriv[k]` gives the speed +// as a multiple of `path_length(points)` which means a unit vector gives a natural +// speed that is a good starting point. +// The speed has a big effect on the shape of the curve, so if the local shape is +// not as you desire you should try increasing it, which will make the curve around +// the point flatter or decreasing it, which will make the curve more pointy. +// Set `deriv[k] = undef` to leave point `k` unconstrained. +// If you only want to set the derivative at the ends of a "clamped" curve you can use +// `start_deriv=` and `end_deriv=`, which set +// `deriv[0]` and `last(deriv)` without the need to provide a list of undefs for all the interior points. +// . +// **Curvature constraints** (`curvature=`, `start_curvature=`, `end_curvature=`) +// . +// The curvature at a point measures how tightly a curve bends. +// When a point has curvature $\kappa$ then a circle with radius $1/\kappa$ +// locally matches the curve at that point so both its first and second derivatives agree. +// This matched circle is called the osculating circle. When you set `curvature[k]` this +// constrains the curvature at `points[k]`. Every curvature-constrained point **must** also have a derivative constraint +// at the same index. Curvature constraints require a degree of at least 2. +// . +// In general curvature constraints require the curvature **vector**, which +// points in the direction of the osculating circle and has length equal to the curvature. +// The curvature vector must be orthogonal to the tangent vector at the point; +// when you specify a curvature vector any component parallel to the tangent is removed. +// The magnitude of the curvature is taken as the magnitude of your original input vector, +// even if subtracting the tangent component changes its length. +// For 2D curves you can also provide curvature as a scalar, with the sign indicating direction. +// (positive = left/CCW, negative = right/CW). +// . +// You can specify the curvature at the ends of "clamped" curves using +// `start_curvature=` and `end_curvature=`, which specify `curvature[0]` +// and `last(curvature)` without the need to create undefs for all the interior points. +// . +// **Corners** (`corners=`) +// . +// `corners=` is a list of interior point indices where the curve has +// a corner, a discontinuity in the derivative. You can also specify a corner +// at point `k` by setting `deriv[k]=NAN`. When you request corners, the +// algorithm chops up the input data into separate clamped splines that run from corner +// to corner. When `closed=true` this results in a "clamped" output spline, and the curve +// will start at one of your corner points. +// If you place corners close together, the effective degree of the short segment +// in between the corners may be reduced. These curve sections are assembled into a single +// NURBS so this process is transparent to the user. A limitation is that you cannot control +// the dervatives of the two segments that meet at a corner. If you need to do this you +// must construct your own sequence of clamped interpolations. +// . +// **Extra control points** (`extra_pts=`, `smooth=`) +// . +// By default, the solver uses exactly as many control points as are needed to +// satisfy the interpolation and constraint conditions, which gives a unique +// solution. This unique solution may be badly behaved, with undesirable oscillations. +// You can improve the behavior by requesting extra points. +// Specifying `extra_pts=N` inserts `N` additional control points and knots, making the +// system underdetermined: infinitely many curves pass through the data points and satisfy +// the constraints. The solver picks the one that satisfies +// a smoothness criterion specified by `smooth=`: +// . +// - `smooth=1` — minimises the sum of squared differences between consecutive +// control points. This tends to keep the control polygon short and reduces +// large-scale variation in the curve. +// - `smooth=2` — minimises the sum of squared second differences of the control +// points. This penalises bending in the control polygon, generally producing +// a fairer, less wiggly curve than `smooth=1`. +// - `smooth=3` (default) — minimises the integrated squared second derivative +// $\int \|\mathbf{C}''(t)\|^2 \, dt$, often called the *bending energy* of +// the curve. Unlike `smooth=2`, which only looks at the control polygon, +// this criterion acts directly on the curve shape and is the most +// mathematically principled choice for smooth interpolation. Requires +// `degree >= 2`. +// . +// The number of extra control points cannot exceed the number of knot spans. +// If you request too many, the number is capped and a warning is displayed. +// With `corners=`, the curve is split into independent clamped segments and +// the extra points are distributed across eligible segments proportionally +// to their control-point count, rounding up, so the total may +// exceed the requested number but will never be less. A segment is eligible when +// its effective degree is 3 or higher, or when it is degree 2 with `smooth=1`. +// . +// **Locating points in the spline** — In order to locate your original data +// points in the spline you need the `u` parameter value that you +// can pass to {{nurbs_curve()}}. The last return value `u` is a list +// where `u[k]` is the NURBS parameter at which the curve passes through +// `points[k]`. +// . +// **Smoothness** — The smoothness of B-splines is determined by the +// degree. If you request a degree $p$ spline then it will be $C^{p-1}$ at +// knot points and $C^\infty$ everywhere else. If you request corners then +// these are points where the curve is not differentiable; corners may +// also divide the curve into small segments that lack sufficient points +// to support an interpolation at your requested degree: a degree $p$ interpolation +// requires $p+1$ points. In this case, the intepolation is performed at a lower +// degree and elevated, which means it will be less smooth at knots. +// +// Arguments: +// points = List of data points to interpolate (2D or any higher dimension). +// degree = Degree of the NURBS. Degree 3 (cubic) is the most common choice. +// --- +// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` +// closed = If true treat point list as a loop . Default: `false` +// start_deriv = If `closed=false`, gives the tangent vector at the first point +// end_deriv = If `closed=false`, gives tangent vector at the last point. +// deriv = List of tangent vector constraints for every point, NAN at corners or undef at unconstrained points. Cannot be combined with `start_deriv=`/`end_deriv=`. +// start_curvature = If `closed=false` gives curvature at first point. (Requires matching derivative.) +// end_curvature = If `closed=false` gives curvature at last point. (Requires matching derivative.) +// curvature = List of curvature constraints for every point, or undef at unconstrained points. Each curvature constraint must be paired with a derivative constraint at the same point. Cannot be combined with `start_curvature=`/`end_curvature=`. +// corners = List of interior point indices where corners are permitted. Equivalent to setting entries of `deriv` to NAN. +// extra_pts = Number of extra control points to add to provide additional freedom to control undesirable oscillations. Default: 0 +// smooth = Smoothness criterion used with extra control points. Set to 1 (minimize control-polygon length), 2 (minimize control-polygon bending) or 3 (minimize curve bending energy). Default: 3 + +function nurbs_interp(points, degree, method="centripetal", closed=false, + deriv=undef, start_deriv=undef, end_deriv=undef, + curvature=undef, start_curvature=undef, end_curvature=undef, + corners=undef, extra_pts=0, smooth=3) = + assert(is_path(points, undef) && len(points) >= 2, + "nurbs_interp: points must be a path (list of same-dimension vectors) with at least 2 points") + assert(is_num(degree) && degree >= 1, + "nurbs_interp: degree must be >= 1") + assert(method == "length" || method == "centripetal" || method == "dynamic" + || method == "foley" || method == "fang", + str("nurbs_interp: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) + assert(is_undef(deriv) || (is_undef(start_deriv) && is_undef(end_deriv)), + "nurbs_interp: use deriv= OR start_deriv=/end_deriv=, not both") + assert(!closed || (is_undef(start_deriv) && is_undef(end_deriv)), + "nurbs_interp: start_deriv/end_deriv only supported for closed=false") + assert(is_undef(deriv) || len(deriv) == len(points), + str("nurbs_interp: deriv= must have same length as points (", + len(points), " points, ", is_undef(deriv) ? 0 : len(deriv), " deriv)")) + assert(is_undef(curvature) || (is_undef(start_curvature) && is_undef(end_curvature)), + "nurbs_interp: use curvature= OR start_curvature=/end_curvature=, not both") + assert(!closed || (is_undef(start_curvature) && is_undef(end_curvature)), + "nurbs_interp: start_curvature=/end_curvature= only supported for closed=false") + assert(is_undef(curvature) || len(curvature) == len(points), + str("nurbs_interp: curvature= must have same length as points (", + len(points), " points, ", is_undef(curvature) ? 0 : len(curvature), " curvature)")) + assert(is_undef(corners) || ( + !closed + ? (min(corners) >= 1 && max(corners) <= len(points)-2) + : (min(corners) >= 0 && max(corners) <= len(points)-1)), + str("nurbs_interp: corners= indices must be ", + !closed ? str("interior (1..", len(points)-2, ")") + : str("valid point indices (0..", len(points)-1, ")"))) + assert(is_num(extra_pts) && extra_pts >= 0 && extra_pts == floor(extra_pts), + str("nurbs_interp: extra_pts must be a non-negative integer, got ", extra_pts)) + assert(extra_pts == 0 || degree >= 2, + "nurbs_interp: extra_pts requires degree >= 2") + assert(smooth == 1 || smooth == 2 || smooth == 3, + str("nurbs_interp: smooth must be 1, 2, or 3, got ", smooth)) + assert(smooth != 3 || degree >= 2, + "nurbs_interp: smooth=3 (bending energy) requires degree >= 2") + let( + type = closed ? "closed" : "clamped", + raw = type == "clamped" + ? _nurbs_interp_clamped(points, degree, method, + deriv, start_deriv, end_deriv, + curvature, start_curvature, end_curvature, + corners, extra_pts, smooth) + : _nurbs_interp_closed(points, degree, method, deriv, curvature, + corners, extra_pts, smooth), + eff_type = is_string(raw[3]) ? raw[3] : type, + rot = raw[2], + n = len(points), + u = type == "closed" && !is_string(raw[3]) + ? list_rotate( + _interp_params(list_rotate(points, rot), method, closed=true), + -rot) + : type == "closed" + ? let( + aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], points[rot]], + aug_params = _interp_params(aug_pts, method) + ) + [for (j = [0:1:n-1]) aug_params[(j - rot + n) % n]] + : _interp_params(points, method) + ) + [eff_type, degree, raw[0], raw[1], undef, undef, u]; + + +// ---------- CLAMPED interpolation ---------- +// +// start_deriv=/end_deriv= and start_curvature=/end_curvature= are convenience shorthands. +// They are merged into eff_der / eff_curv lists here so that all +// constrained cases flow through a single solver +// (_nurbs_interp_clamped_constrained). + +function _nurbs_interp_clamped(points, degree, method, + deriv, start_deriv, end_deriv, + curvature, start_curvature, end_curvature, + corners, extra_pts=0, smooth=3) = + let(n = len(points) - 1, p = degree, dim = len(points[0])) + assert(n >= p, + str("nurbs_interp (clamped): need at least ", p+1, + " points for degree ", p, ", got ", n+1)) + let( + eff_der = _merge_deriv_list(n, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv), + eff_curv = _merge_curv_list(n, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature), + + // C0 corner joints from NaN entries in eff_der and/or corners= list. + // Must be interior points; cannot coincide with curvature constraints. + nan_corners = is_undef(eff_der) ? [] + : [for (k = [0:1:n]) if (is_nan(eff_der[k])) k], + explicit_corners = default(corners, []), + corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), + has_corners = len(corner_idxs) > 0, + bad_corner_end = [for (k = corner_idxs) if (k == 0 || k == n) k], + bad_corner_curv = is_undef(eff_curv) ? [] + : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], + // Explicit corners= entries must not also carry a derivative constraint. + // (NaN-in-deriv corners are fine — they ARE the corner syntax.) + bad_corner_der = is_undef(eff_der) ? [] + : [for (k = explicit_corners) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k], + + // Exclude NaN corner markers from the derivative-constraint count. + has_any_der = !is_undef(eff_der) && + len([for (k = [0:1:n]) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, + has_any_curv = !is_undef(eff_curv) && + len([for (k = [0:1:n]) if (!is_undef(eff_curv[k])) k]) > 0, + + // Every curvature-constrained point must also have a derivative + // constraint; the derivative direction defines the curve's tangent + // and is required to orient the curvature normal. + bad_curv_pts = is_undef(eff_curv) ? [] : + [for (k = [0:1:n]) + if (!is_undef(eff_curv[k]) && + (is_undef(eff_der) || is_undef(eff_der[k]))) + k] + ) + assert(bad_corner_end == [], + str("nurbs_interp: corner cannot be at the first or last point: ", bad_corner_end)) + assert(bad_corner_curv == [], + str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", bad_corner_curv)) + assert(bad_corner_der == [], + str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", bad_corner_der)) + assert(bad_curv_pts == [], + str("nurbs_interp: curvature constraint requires a derivative constraint ", + "at the same point(s): ", bad_curv_pts)) + has_corners + ? _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, + extra_pts=extra_pts, smooth=smooth) + : (has_any_der || has_any_curv || extra_pts > 0) + ? _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, extra_pts, smooth) + : _nurbs_interp_clamped_basic(points, p, method, smooth); + + +// Basic clamped interpolation (no derivatives). +// n+1 points -> n+1 control points. + +function _nurbs_interp_clamped_basic(points, p, method, smooth=3) = + let( + n = len(points) - 1, + M = n + 1, + dim = len(points[0]), + params = _interp_params(points, method), + int_kn = _avg_knots_interior(params, p), + U_full = _full_clamped_knots(int_kn, p), + N_mat = _collocation_matrix(params, n, p, U_full), + control = linear_solve(N_mat, points), + knots = [0, each int_kn, 1] + ) + assert(control != [], + "nurbs_interp (clamped): singular collocation matrix") + [control, knots, 0]; + + +// Assemble independently-solved clamped corner segments into one B-spline. +// +// All segments must be degree p. Returns [ctrl, xknots, 0] — the standard +// non-segmented result format that callers can pass directly to nurbs_curve / +// debug_nurbs with type="clamped". +// +// BOSL2 clamped knot convention: nurbs_curve() takes xknots of length +// len(control) - degree + 1 +// and internally prepends (degree) zeros and appends (degree) ones to form +// the full clamped knot vector. For a C0 corner at global parameter s_c, +// s_c must appear exactly p times in xknots (giving multiplicity p in the +// full vector = C^0 continuity for degree p). +// +// Segment local knots seg[1] = [0, int_kn..., 1] are remapped to the +// segment's global parameter interval [s_a, s_b] using +// k_global = s_a + (s_b - s_a) * k_local +// which is consistent with any chord-proportional parameterization. + +function _combine_corner_segs(segments, params, corner_idxs, p) = + let( + n_segs = len(segments), + // Global parameter at each corner junction. + cpar = [for (c = corner_idxs) params[c]], + // Global interval [s_a, s_b] for each segment. + seg_sa = [for (s = [0:1:n_segs-1]) s == 0 ? 0 : cpar[s-1]], + seg_sb = [for (s = [0:1:n_segs-1]) s == n_segs-1 ? 1 : cpar[s] ], + // Per-segment interior knots (exclude leading 0 and trailing 1), + // remapped from local [0,1] to the segment's global interval. + seg_gi = [for (s = [0:1:n_segs-1]) + let( + loc = [for (i = [1:1:len(segments[s][1])-2]) segments[s][1][i]], + sa = seg_sa[s], + sb = seg_sb[s] + ) + [for (k = loc) sa + (sb - sa) * k] + ], + // Build combined xknots: + // [0, seg0_int, corner0^p, seg1_int, corner1^p, ..., segN_int, 1] + interior = [for (s = [0:1:n_segs-1]) + each concat( + seg_gi[s], + s < n_segs-1 ? repeat(cpar[s], p) : [] + ) + ], + xknots = [0, each interior, 1], + // Combined control points: all of seg0, then seg[1:1:] for each later seg. + // The first control point of seg s (s >= 1) equals the last of seg s-1 + // because both are the clamped-endpoint interpolant of the shared corner + // data point — so we drop the duplicate. + ctrl = [ + each segments[0][0], + for (s = [1:1:n_segs-1]) + for (j = [1:1:len(segments[s][0])-1]) + segments[s][0][j] + ] + ) + [ctrl, xknots, 0]; + + +// Clamped interpolation with C0 corner joints. +// +// NaN entries in eff_der mark corners: the curve is split into independent +// clamped segments at each corner index. Each segment is solved at the +// highest degree possible: min(p, m-1) where m is the segment point count. +// Degree reduction silently handles short segments (e.g. only 2 or 3 data +// points between adjacent corners). +// +// Segments that needed degree reduction are degree-elevated back to p +// via nurbs_elevate_degree() so that all segments can be assembled into +// a single clamped B-spline. Elevated segments preserve their original +// lower-degree shape but have higher knot multiplicity, so they are +// less smooth at interior knots than natively degree-p segments. + +function _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, + extra_pts=0, smooth=3) = + let( + n = len(points) - 1, + params = _interp_params(points, method), + seg_bounds = [0, each corner_idxs, n], + n_segs = len(seg_bounds) - 1, + // Distribute extra_pts across eligible segments proportionally to + // their control-point count (= data-point count = seg_sizes[s]+1). + // Eligible = segments with seg_p >= 3, or seg_p == 2 when smooth == 1. + // Linear (seg_p==1) and quadratic with smooth!=1 get 0 extra_pts. + seg_sizes = [for (s = [0:1:n_segs-1]) + seg_bounds[s+1] - seg_bounds[s]], + seg_degrees = [for (sz = seg_sizes) min(p, sz)], + // Weight = control-point count for eligible segments, 0 for ineligible. + seg_weights = [for (s = [0:1:n_segs-1]) + let(sp = seg_degrees[s]) + (sp >= 3 || (sp == 2 && smooth == 1)) + ? seg_sizes[s] + 1 : 0], + total_weight = max(1, sum(seg_weights)), + // Round up per-segment allocation so total >= extra_pts. + seg_extra = extra_pts == 0 ? repeat(0, n_segs) + : [for (s = [0:1:n_segs-1]) + seg_weights[s] == 0 ? 0 + : ceil(extra_pts * seg_weights[s] / total_weight)], + raw_segments = [for (s = [0:1:n_segs-1]) + let( + i0 = seg_bounds[s], + i1 = seg_bounds[s+1], + seg_pts = [for (k = [i0:1:i1]) points[k]], + // Reduce degree if the segment has fewer than p+1 points. + seg_p = seg_degrees[s], + // Replace NaN corner markers with undef at shared endpoints. + seg_der = is_undef(eff_der) ? undef + : [for (k = [i0:1:i1]) + is_nan(eff_der[k]) ? undef : eff_der[k]], + seg_curv = is_undef(eff_curv) ? undef + : [for (k = [i0:1:i1]) eff_curv[k]], + r = _nurbs_interp_clamped(seg_pts, seg_p, method, + seg_der, undef, undef, + seg_curv, undef, undef, + extra_pts=seg_extra[s], + smooth=smooth) + ) + [r[0], r[1], seg_p] // [control, knots, degree] + ], + // Degree-elevate short segments to the full degree p. + segments = [for (seg = raw_segments) + seg[2] == p ? seg + : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], + type="clamped", times=p - seg[2])) + [elev[2], elev[3], p] + ] + ) + _combine_corner_segs(segments, params, corner_idxs, p); + + +// General clamped interpolation with per-point derivative and/or curvature +// constraints. +// +// eff_der: list of n+1 first-derivative specs (undef = unconstrained). +// eff_curv: list of n+1 curvature specs (undef = unconstrained). +// dim=2: signed scalar κ. dim≥3: curvature vector. +// +// Uses Method A (expanded-parameter knot averaging, P&T §9.2.2): for each +// constraint at index k, duplicate params[k] in an expanded sequence ũ — +// once per constraint type (deriv and curvature each add one duplication per +// constrained point). This provides one extra DOF per extra constraint. + +function _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, + extra_pts=0, smooth=3) = + let( + n = len(points) - 1, + dim = len(points[0]), + path_len = path_length(points), + path_len2 = path_len * path_len, + params = _interp_params(points, method), + + // First-derivative specs: [index, C'(t) vector]. + // eff_der entries are already dim-projected by _nurbs_interp_clamped. + der_specs = is_undef(eff_der) ? [] + : [for (k = [0:1:n]) if (!is_undef(eff_der[k])) + [k, eff_der[k] * path_len]], + + // Curvature specs: [index, C''(t) vector]. + // eff_der and eff_curv are already dim-projected. + // Tangent from eff_der[k] when available; otherwise estimated from chord. + // Speed² from |eff_der[k]|² × path_len² when derivative given. + curv_specs = is_undef(eff_curv) ? [] + : [for (k = [0:1:n]) if (!is_undef(eff_curv[k])) + let( + t_from_der = is_undef(eff_der) ? undef : eff_der[k], + tang_dir = !is_undef(t_from_der) ? t_from_der + : k == 0 ? points[1] - points[0] + : k == n ? points[n] - points[n-1] + : points[k+1] - points[k-1], + v2 = !is_undef(t_from_der) + ? path_len2 * (t_from_der * t_from_der) + : path_len2 + ) + [k, _curv_to_d2(eff_curv[k], tang_dir, dim, v2)] + ], + + n_extra_der = len(der_specs), + n_extra_curv = len(curv_specs), + _chk_curv_deg = assert(n_extra_curv == 0 || p >= 2, + "nurbs_interp: curvature constraints require degree >= 2"), + n_constraint = n_extra_der + n_extra_curv, + + // Build knots: average data params, insert at constraint spans, + // then insert extra_pts more at widest spans. + base_int = _avg_knots_interior(params, p), + base_bar = [0, each base_int, 1], + constraint_ts = [for (spec = der_specs) params[spec[0]], + for (spec = curv_specs) params[spec[0]]], + after_constr = _insert_constraint_knots(base_bar, constraint_ts), + // For extra_pts, insert knots at midpoints of the widest spans. + // _widest_span_params silently caps the request at the available span count. + extra_ts = extra_pts == 0 ? [] + : _widest_span_params(after_constr, extra_pts), + aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), + n_spans_pre = len(aug_bar_raw) - 1, + aug_bar_pre = _fix_tiny_spans(aug_bar_raw, n_spans_pre), + + // Split any knot span that contains multiple data parameters. + // Without this, two data points in the same span produce a + // rank-deficient collocation matrix (Schoenberg-Whitney condition). + occ_splits = _span_split_params(aug_bar_pre, params), + n_occ = len(occ_splits), + M = n + 1 + n_constraint + len(extra_ts) + n_occ, + aug_bar = n_occ == 0 ? aug_bar_pre + : _fix_tiny_spans( + sort([each aug_bar_pre, each occ_splits]), + n_spans_pre + n_occ), + int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], + U_full = _full_clamped_knots(int_kn, p), + + // Constraint matrix A: interpolation + derivative + curvature rows. + // Dimensions: N_rows × M where N_rows = (n+1) + n_constraint. + N_rows = n + 1 + n_constraint, + + // Interpolation rows: N_{j,p}(t_k) + interp_rows = [for (k = [0:1:n]) + [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] + ], + + // First-derivative rows: N'_{j,p}(t_k) + deriv_rows = [for (spec = der_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) _dnip(j, p, params[k], U_full)] + ], + + // Second-derivative rows: N''_{j,p}(t_k) + curv_rows = [for (spec = curv_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) _d2nip(j, p, params[k], U_full)] + ], + + A_constr = [each interp_rows, each deriv_rows, each curv_rows], + rhs_constr = [each points, + for (spec = der_specs) spec[1], + for (spec = curv_specs) spec[1]], + + knots = [0, each int_kn, 1] + ) + // When M == N_rows (square), try direct solve first. + // When M > N_rows (underdetermined from extra_pts or span splits), + // use null-space method: exact constraints + minimum-energy smoothing. + let( + direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] + ) + direct != [] + ? [direct, knots, 0] + : let( + R = smooth <= 2 + ? [for (i = [0:1:M-1]) _ltl_row(M, i, smooth)] + : _bending_energy_matrix(M, p, U_full), + control = _nullspace_solve(R, A_constr, rhs_constr) + ) + assert(!is_undef(control), + "nurbs_interp (clamped+constrained): rank-deficient constraint matrix") + [control, knots, 0]; + + +// ---------- CLOSED interpolation ---------- + +function _nurbs_interp_closed(points, degree, method, deriv, curvature, + corners, extra_pts=0, smooth=3) = + let(n = len(points), p = degree, dim = len(points[0])) + assert(n >= p + 1, + str("nurbs_interp (closed): need at least ", p+1, + " points for degree ", p, ", got ", n)) + let( + // Detect C0 corners from NaN entries in the RAW deriv list before projection, + // since _merge_deriv_list would leave NaN entries intact but we detect them here. + nan_corners = is_undef(deriv) ? [] + : [for (k = [0:1:n-1]) if (is_nan(deriv[k])) k], + explicit_corners = default(corners, []), + corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), + has_corners = len(corner_idxs) > 0, + + // Project derivative and curvature lists (handles BOSL2 direction constants, etc.) + eff_der = _merge_deriv_list(n-1, deriv, dim=dim), + eff_curv = _merge_curv_list(n-1, curvature, dim=dim), + + has_dl = !is_undef(eff_der) && + len([for (k = [0:1:n-1]) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, + has_cl = !is_undef(eff_curv) && + len([for (k = [0:1:n-1]) if (!is_undef(eff_curv[k])) k]) > 0, + + // Every curvature-constrained point must also have a derivative constraint. + bad_curv_pts = is_undef(eff_curv) ? [] : + [for (k = [0:1:n-1]) + if (!is_undef(eff_curv[k]) && + (is_undef(eff_der) || is_undef(eff_der[k]))) + k], + // Curvature at a corner is not allowed. + bad_corner_curv = is_undef(eff_curv) ? [] + : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], + // Derivative at an explicit corner is not allowed. + bad_corner_der = is_undef(eff_der) ? [] + : [for (k = explicit_corners) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k] + ) + assert(bad_curv_pts == [], + str("nurbs_interp: curvature constraint requires a derivative constraint ", + "at the same point(s): ", bad_curv_pts)) + assert(bad_corner_curv == [], + str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", + bad_corner_curv)) + assert(bad_corner_der == [], + str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", + bad_corner_der)) + // Basic and constrained solvers handle rotation search internally. + // Corner case uses its own rotation (to the first corner). + has_corners + ? _nurbs_interp_closed_corners(points, p, method, eff_der, eff_curv, corner_idxs, + extra_pts=extra_pts, smooth=smooth) + : (has_dl || has_cl || extra_pts > 0) + ? let( + _raw_c = _closed_constrained_solve(points, p, method, eff_der, eff_curv, + 0, extra_pts, smooth), + _chk = assert(!is_undef(_raw_c), + "nurbs_interp (closed+constrained): rank-deficient constraint matrix") + ) _raw_c + : _nurbs_interp_closed_basic(points, p, method, smooth); + + +// Closed interpolation with C0 corner joints. +// +// Converts the closed-with-corners problem into a clamped-with-corners +// problem: rotate data so the first corner is at the start, duplicate +// that point at the end to close the loop, remap remaining corners to +// the rotated frame, and delegate to _nurbs_interp_clamped_corners. +// +// The result is a clamped B-spline whose first and last control points +// coincide at the corner point. r[3] = "clamped" tells convenience +// functions to render with type="clamped" instead of "closed". + +function _nurbs_interp_closed_corners(points, p, method, deriv, curvature, + corner_idxs, extra_pts=0, smooth=3) = + let( + n = len(points), // n points (0..n-1), no repeat + rot = corner_idxs[0], + + // Augmented point list: rotated + closing duplicate of first corner. + aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], + points[rot]], + + // Remap remaining corners to rotated frame. + rot_corners = sort([for (i = [1:1:len(corner_idxs)-1]) + (corner_idxs[i] - rot + n) % n]), + + // Rotate and augment deriv list. + // NaN at the rotation point (now start/end) is cleaned to undef + // since the corner is handled structurally by the clamped endpoints. + aug_der = is_undef(deriv) ? undef : + let(rd = [for (k = [0:1:n-1]) deriv[(k + rot) % n]], + d0 = is_nan(rd[0]) ? undef : rd[0]) + [d0, for (k = [1:1:n-1]) rd[k], d0], + + // Rotate and augment curvature list. + aug_curv = is_undef(curvature) ? undef : + let(rc = [for (k = [0:1:n-1]) curvature[(k + rot) % n]]) + [rc[0], for (k = [1:1:n-1]) rc[k], rc[0]], + + // Solve as clamped with corners. + result = _nurbs_interp_clamped_corners(aug_pts, p, method, + aug_der, aug_curv, + rot_corners, + extra_pts=extra_pts, + smooth=smooth) + ) + // Return with the original rotation index and type override. + [result[0], result[1], rot, "clamped"]; + + +// Returns the maximum number of parameters that fall in any single active +// knot span for cyclic rotation r. A value of 1 is ideal (one parameter +// per span); values > 1 indicate span collisions that may (but do not +// always) cause a singular collocation matrix. + +function _closed_rotation_collision_count(points, n, p, method, r) = + let( + pts = select(points, r, r + n - 1), + rp = _interp_params(pts, method, closed=true), + bk = _fix_tiny_spans(_avg_knots_periodic(rp, p)[0], n), + U = _full_closed_knots(bk, n, p), + ps = add_scalar(rp, bk[p]) + ) + max([for (k = [0:1:n-1]) + len([for (t = ps) if (t >= U[p+k] && t < U[p+k+1]) t]) + ]); + + +// Find the best seam rotation for closed curve interpolation. +// The chord-ratio heuristic (argmax d[i+1]/d[i] + 1) is tried first. +// If it has span collisions, all n rotations are scored by collision +// count and the one with the fewest collisions is chosen. Mild +// collisions (max 2 params per span) often still produce a non-singular +// system, so the final check is deferred to linear_solve(). + +function _find_closed_rotation(points, n, p, method) = + let( + chords = path_segment_lengths(points, closed=true), + ratios = [for (i = [0:1:n-1]) chords[(i+1)%n] / max(chords[i], 1e-15)], + rot0 = (max_index(ratios) + 1) % n + ) + _closed_rotation_collision_count(points, n, p, method, rot0) <= 1 + ? rot0 + : let( + scores = [for (i = [0:1:n-1]) + [_closed_rotation_collision_count(points, n, p, method, i), i]], + best = min_index([for (s = scores) s[0]]) + ) + scores[best][1]; + + +// Solve a basic closed interpolation for a specific rotation. +// Returns [control, bar_knots, rot] or undef if singular. + +function _closed_basic_solve(points, n, p, method, rot, smooth=3) = + let( + dim = len(points[0]), + pts = select(points, rot, rot + n - 1), + raw_params = _interp_params(pts, method, closed=true), + bar_knots = _fix_tiny_spans(_avg_knots_periodic(raw_params, p)[0], n), + U_full = _full_closed_knots(bar_knots, n, p), + params = add_scalar(raw_params, bar_knots[p]), + N_mat = _collocation_matrix_periodic(params, n, p, U_full), + control = linear_solve(N_mat, pts) + ) + control != [] ? [control, bar_knots, rot] + : // Singular — fall back to constrained optimization. + let( + M = n, + R = smooth <= 2 + ? [for (i = [0:1:M-1]) _ltl_row(M, i, smooth, periodic=true)] + : _bending_energy_matrix(M, p, U_full, periodic=true), + ctrl = _nullspace_solve(R, N_mat, pts) + ) + is_undef(ctrl) ? undef : [ctrl, bar_knots, rot]; + + +// Control-point spread ratio: max extent of control points divided by +// max extent of data points. Values near 1 are ideal; large values +// indicate oscillation from ill-conditioning. + +function _ctrl_point_ratio(points, control) = + let( + pbound = pointlist_bounds(points), + cbound = pointlist_bounds(control), + pmax = max(pbound[1] - pbound[0]), + cmax = max(cbound[1] - cbound[0]) + ) + cmax / max(pmax, 1e-15); + + +// Basic closed interpolation — start-point independent. +// +// Implements the cyclic chord-length parameterization and cyclic knot +// averaging of Piegl & Tiller §9.2.4. In exact arithmetic the resulting +// curve is the same regardless of which data point is listed first; only +// the parametric origin changes (the curve is just reparameterized). +// +// The chord-ratio heuristic rotation is tried first. If the resulting +// control-point spread exceeds 2^p/p times the data spread (indicating +// oscillation), all n rotations are tried and the one with the smallest +// spread is selected. + +function _nurbs_interp_closed_basic(points, p, method, smooth=3) = + let( + n = len(points), + rot0 = _find_closed_rotation(points, n, p, method), + result0 = _closed_basic_solve(points, n, p, method, rot0, smooth) + ) + assert(!is_undef(result0), "nurbs_interp (closed): singular system") + let( + ratio0 = _ctrl_point_ratio(points, result0[0]), + threshold = pow(2, p) / p + ) + ratio0 <= threshold ? result0 + : let( + // Heuristic rotation produced excessive control-point spread. + // Try all rotations and pick the one with the smallest spread. + candidates = [for (r = [0:1:n-1]) + let(res = _closed_basic_solve(points, n, p, method, r, smooth)) + if (!is_undef(res)) + [_ctrl_point_ratio(points, res[0]), res]], + _chk = assert(len(candidates) > 0, + "nurbs_interp (closed): all rotations produce singular systems"), + best_idx = min_index([for (c = candidates) c[0]]), + best = candidates[best_idx][1], + _echo = echo(str("nurbs_interp (closed): rotation search chose ", + best[2], " (spread ratio ", + candidates[best_idx][0], ")")) + ) + best; + + +// Solve a constrained closed interpolation for a specific rotation. +// Returns [control, aug_bar, rot] or undef if singular. +// +// eff_der: list of n first-derivative specs (undef = unconstrained). +// eff_curv: list of n curvature specs (undef = unconstrained). +// dim=2: signed scalar κ or 2D vector. dim≥3: curvature vector. +// +// Knot construction: standard periodic averaging of N data params, +// then insert one knot per constraint at the midpoint of the span +// containing its parameter (largest span first). +// M control points use standard BOSL2 periodic aliasing: +// B_j(t) = N_j(t) + (j

= 2, + "nurbs_interp: curvature constraints require degree >= 2"), + n_constraint = n_extra_der + n_extra_curv, + + // Build bar_knots: standard periodic averaging of N data + // params, then insert knots for constraints and extra_pts. + base_bar = _avg_knots_periodic(raw_params, p)[0], + constraint_idxs = [for (spec = der_specs) spec[0], + for (spec = curv_specs) spec[0]], + constraint_ts = [for (k = constraint_idxs) raw_params[k]], + after_constr = _insert_constraint_knots(base_bar, constraint_ts), + // _widest_span_params silently caps the request at the available span count. + extra_ts = extra_pts == 0 ? [] + : _widest_span_params(after_constr, extra_pts), + aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), + // M_pre = span count of aug_bar_raw. Use len()-1 rather than + // n+n_constraint+extra_pts so it reflects the actual knots inserted. + M_pre = len(aug_bar_raw) - 1, + aug_bar_pre = _fix_tiny_spans(aug_bar_raw, M_pre), + + // Split any knot span that contains multiple data parameters. + // Without this, two data points in the same span produce a + // rank-deficient collocation matrix (§9.2.1 Schoenberg-Whitney). + occ_splits = _span_split_params(aug_bar_pre, raw_params), + n_occ = len(occ_splits), + M = M_pre + n_occ, + aug_bar = n_occ == 0 ? aug_bar_pre + : _fix_tiny_spans( + sort([each aug_bar_pre, each occ_splits]), + M), + T = aug_bar[M], + U_full = _full_closed_knots(aug_bar, M, p), + + // Map raw params into active domain [aug_bar[p], aug_bar[p]+T]. + // Nudge any shifted parameter that lands on or near a knot. + raw_shifted = add_scalar(raw_params, aug_bar[p]), + eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), + params = [for (k = [0:1:n-1]) + let( + u = raw_shifted[k], + d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) + ) + d_min < eps_knot ? u + eps_knot : u + ], + + // Constraint matrix A: interpolation + derivative + curvature rows. + N_rows = n + n_constraint, + + // Interpolation rows: aliased basis for M control points + interp_rows = [for (k = [0:1:n-1]) + [for (j = [0:1:M-1]) + _nip(j, p, params[k], U_full) + + (j < p ? _nip(j + M, p, params[k], U_full) : 0) + ] + ], + + // First-derivative rows: aliased derivative basis + deriv_rows = [for (spec = der_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) + _dnip(j, p, params[k], U_full) + + (j < p ? _dnip(j + M, p, params[k], U_full) : 0) + ] + ], + + // Second-derivative rows: aliased second-derivative basis + curv_rows = [for (spec = curv_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) + _d2nip(j, p, params[k], U_full) + + (j < p ? _d2nip(j + M, p, params[k], U_full) : 0) + ] + ], + + A_constr = [each interp_rows, each deriv_rows, each curv_rows], + rhs_constr = [each pts, + for (spec = der_specs) spec[1], + for (spec = curv_specs) spec[1]] + ) + // When M == N_rows (square), try direct solve first. + // When M > N_rows (underdetermined from extra_pts or span splits), + // use null-space method: exact constraints + minimum-energy smoothing. + let( + direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] + ) + direct != [] + ? [direct, aug_bar, rot] + : let( + R = smooth <= 2 + ? [for (i = [0:1:M-1]) _ltl_row(M, i, smooth, periodic=true)] + : _bending_energy_matrix(M, p, U_full, periodic=true), + ctrl = _nullspace_solve(R, A_constr, rhs_constr) + ) + is_undef(ctrl) ? undef : [ctrl, aug_bar, rot]; + + +// ===================================================================== +// SECTION: Debug / Visualization +// ===================================================================== + +// Module: debug_nurbs_interp() +// Synopsis: Interpolates a NURBS using {{nurbs_interp()}} and displays the curve with informative overlays. +// Topics: NURBS Curves, Interpolation, Debugging +// See Also: nurbs_interp(), nurbs_interp_curve(), debug_nurbs() +// +// Usage: +// debug_nurbs_interp(points, degree, [splinesteps=], [method=], +// [closed=], [deriv=], [start_deriv=], [end_deriv=], +// [curvature=], [start_curvature=], [end_curvature=], +// [corners=], [extra_pts=], [smooth=], +// [width=], [size=], [data_size=], [data_index=], +// [show_control=], [control_index=], [show_knots=], +// [show_deriv=], [show_curvature=]); +// +// Description: +// Calls {{nurbs_interp()}} with the supplied arguments and displays the +// resulting curve together with a informative overlays. All interpolation +// arguments are passed through unchanged; see {{nurbs_interp()}} for their +// descriptions. The overlays are: +// . +// - **Data points** — red circles (2D) or spheres (3D) at each input point. +// When `data_index=true` (the default), the point index is printed in red next +// to its marker. Set `data_size=0` to suppress display of the data point dots. +// - **Derivative constraints** — a black arrow at each derivative constrained data point. +// Arrow direction and length reflect the constraint vector, scaled to the average +// point spacing. When the derivative is NAN or a point has a corner, this is shown +// using a black diamond. Shown by default: set `show_deriv=false` to hide. +// - **Curvature constraints** — a transparent green overlay at each curvature-constrained point. +// In 2D the overlay is the osculating circle. In 3D the overlay is a cylinder created +// from the 3D osculating circle. Zero curvature appears as a short green bar. +// Shown by default: Set `show_curvature=false` to hide. +// - **Knots** — Green crosses mark each knot position. Not shown by default. +// Enable with `show_knots=true`. +// - **Control points and polygon** — If you set `show_control=true` then a gray control polygon +// Is displayed. If you additionally set `control_index=true` then blue control-point +// index labels appear. +// +// Arguments: +// points = List of 2-D or 3-D data points to interpolate through. +// degree = NURBS degree. +// splinesteps = Steps per knot span for curve rendering. Default: `16` +// --- +// method = Parameterization method; see {{nurbs_interp()}}. Default: `"centripetal"` +// closed = If true, interpolate as a closed loop; if false, interpolate as clamped. Default: `false` +// deriv = Per-point derivative constraints; see {{nurbs_interp()}}. Default: `undef` +// start_deriv = Derivative at first point. Default: `undef` +// end_deriv = Derivative at last point. Default: `undef` +// curvature = Per-point curvature constraints; see {{nurbs_interp()}}. Default: `undef` +// start_curvature = Curvature at first point. Default: `undef` +// end_curvature = Curvature at last point. Default: `undef` +// corners = Corner indices; see {{nurbs_interp()}}. Default: `undef` +// extra_pts = Extra control points; see {{nurbs_interp()}}. Default: `0` +// smooth = Smoothness criterion for `extra_pts`; see {{nurbs_interp()}}. Default: `3` +// width = Stroke width for the curve. Arrows and other overlays scale with this. Default: `1` +// size = Text size for labels on control points and data points. Default: `3*width` +// data_size = Radius of the red data-point markers. Set to `0` to hide data points and their labels. Default: equal to `width` +// data_index = Show index labels next to each data point. Only shown when `data_size > 0`. Default: `true` +// show_control = Show the control polygon. Default: `false` +// control_index = Show control-point index labels if `show_control=true`. Default: `false` +// show_knots = Show knot position markers on the curve. Default: `false` +// show_deriv = Show derivative-constraint arrows. Default: `true` +// show_curvature = Show curvature-constraint circles / disks. Default: `true` + +module debug_nurbs_interp(points, degree, splinesteps=16, method="centripetal", + closed=false, deriv=undef, + start_deriv=undef, end_deriv=undef, + curvature=undef, start_curvature=undef, end_curvature=undef, + corners=undef, extra_pts=0, smooth=3, + width=1, size=undef, data_size=undef, + show_control=false, show_knots=false, + show_deriv=true, show_curvature=true, + control_index=false, data_index=true) { + result = nurbs_interp(points, degree, method=method, + closed=closed, deriv=deriv, + start_deriv=start_deriv, end_deriv=end_deriv, + curvature=curvature, start_curvature=start_curvature, + end_curvature=end_curvature, corners=corners, + extra_pts=extra_pts, smooth=smooth); + + np = len(points); + dim = len(points[0]); + is2d = (dim == 2); + ds = default(data_size, width); + sz = default(size, 3 * width); + ctrl = result[2]; + arrow_scale = path_length(points) / np; + + // Helpers project BOSL2 direction constants and pad dimensions automatically. + eff_der = _merge_deriv_list(np-1, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv); + eff_curv = _merge_curv_list(np-1, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature); + + // --- Curve, control polygon, knot markers (delegated to debug_nurbs) --- + debug_nurbs(result, splinesteps=splinesteps, width=width, size=sz, + show_knots=show_knots, show_control=show_control, + show_index=control_index); + + // --- Corner marks (NaN-deriv corners + explicit corners= indices) --- + // 2D: rotated square stroke. 3D: octahedron wireframe. + nan_corner_idxs = is_undef(eff_der) ? [] + : [for (i = [0:1:np-1]) if (!is_undef(eff_der[i]) && is_nan(eff_der[i])) i]; + explicit_corner_idxs = default(corners, []); + all_corner_idxs = deduplicate(sort(concat(nan_corner_idxs, explicit_corner_idxs))); + for (i = all_corner_idxs) + color("black") + translate(points[i]) + if (is2d) + zrot(45) stroke(rect(3.5*width*ds), width=width/2, closed=true); + else + vnf_wireframe(octahedron(size=5*width), width=width/4); + + // --- Derivative arrows (black, half width, arrow2 endcap) --- + // Length = norm(eff_der[i]) * arrow_scale: preserves relative magnitudes; + // arrow_scale = path_length(points)/np gives a geometry-relative baseline. + if (show_deriv && !is_undef(eff_der)) + for (i = [0:1:np-1]) + if (!is_undef(eff_der[i]) && !is_nan(eff_der[i]) && norm(eff_der[i]) > 1e-12) + color("black") + stroke([points[i], points[i] + eff_der[i] * arrow_scale], + width=width/2, + endcap1="butt", endcap2="arrow2"); + + // --- Data points and index labels --- + if (ds > 0) + color("red") + move_copies(points) { + if (is2d) circle(r=ds, $fn=16); + else sphere(r=ds, $fn=16); + if (data_index) + if (is2d) + fwd(2*ds) text(text=str($idx), size=sz, anchor=BACK); + else + rot($vpr) back(ds + sz/3) text3d(text=str($idx), size=sz, anchor=CENTER); + } + + // --- Curvature overlays (rendered last so transparent objects don't occlude dots) --- + // Validator already asserted every curvature-constrained point has a derivative, + // so eff_der[i] is always defined and non-NaN here. + if (show_curvature && !is_undef(eff_curv)) + color([0,1,0,0.1]) + for (i = [0:1:np-1]) + if (!is_undef(eff_curv[i])) { + // cv is either a signed scalar (2D) or a dim-projected vector. + cv = eff_curv[i]; + kn = is_num(cv) ? abs(cv) : norm(cv); + T_hat = unit(eff_der[i]); + if (kn < 1e-12) { + // Zero curvature: fixed-length segment (0.6*arrow_scale) along + // the exact derivative direction. + half = 0.3 * arrow_scale; + stroke([points[i] - T_hat * half, + points[i] + T_hat * half], + width=2*width, endcaps="butt"); + } else { + // Non-zero curvature: osculating circle (2D) or cylinder (3D). + // N_hat: unit principal normal — component of cv perpendicular to T_hat. + N_hat = is_num(cv) + ? // Signed scalar (2D): rotate T_hat 90° left or right by sign(cv). + sign(cv) * [-T_hat[1], T_hat[0]] + : // Vector: strip tangential component via vector_perp, then unit. + unit(vector_perp(T_hat, cv)); + r = 1 / kn; + ctr = points[i] + N_hat * r; + // move(ctr) applies to both 2D and 3D branches. + move(ctr) + if (is2d) { + circle(r=r); + } else { + // Cylinder in the osculating plane: axis along binormal B̂ = T̂ × N̂. + // cyl(orient=binom) aligns the cylinder axis to B̂ without rot(). + binom = cross(T_hat, N_hat); + cyl(h=width, r=r, orient=binom); + } + } + } +} + + +// ===================================================================== +// SECTION: Interpolation System Builder (shared by curve & surface) +// ===================================================================== + +// Builds the collocation matrix and BOSL2-format knots for a single +// parameterized direction. Returns [N_mat, bosl2_knots]. + +function _build_interp_system(params, p, type, extra_pts=0) = + type == "clamped" ? _build_clamped_system(params, p, extra_pts) + : _build_closed_system(params, p, extra_pts); + +function _build_clamped_system(params, p, extra_pts=0) = + let( + n = len(params) - 1, + int_kn = _avg_knots_interior(params, p), + base_bar = [0, each int_kn, 1] + ) + extra_pts == 0 + ? let( + U_full = _full_clamped_knots(int_kn, p), + N_mat = _collocation_matrix(params, n, p, U_full), + knots = [0, each int_kn, 1] + ) + [N_mat, knots] + : let( + extra_ts = _widest_span_params(base_bar, extra_pts), + aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), + occ_splits = _span_split_params(aug_bar_raw, params), + n_occ = len(occ_splits), + // Use len(extra_ts), not extra_pts: _widest_span_params silently caps + // the request at the number of available spans. + M = n + 1 + len(extra_ts) + n_occ, + aug_bar_merged = n_occ == 0 ? aug_bar_raw + : sort([each aug_bar_raw, each occ_splits]), + aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), + aug_int = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], + U_full = _full_clamped_knots(aug_int, p), + // Rectangular (n+1) × M matrix: n+1 data rows, M control columns. + // _collocation_matrix uses a single n for both dimensions, so build inline. + N_mat = [for (k = [0:1:n]) + [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)]], + knots = [0, each aug_int, 1] + ) + [N_mat, knots]; + +function _build_closed_system(params, p, extra_pts=0) = + let( + n = len(params), + base_bar = _fix_tiny_spans(_avg_knots_periodic(params, p)[0], n) + ) + extra_pts == 0 + ? let( + U_full = _full_closed_knots(base_bar, n, p), + col_params = add_scalar(params, base_bar[p]), + T = base_bar[n], + eps_knot = T / n * (p == 2 ? 0.01 : 1e-6), + col_safe = [for (k = [0:1:n-1]) + let( + u = col_params[k], + d_min = min([for (j = [0:1:n + 2*p]) abs(u - U_full[j])]) + ) + d_min < eps_knot ? u + eps_knot : u + ], + N_mat = _collocation_matrix_periodic(col_safe, n, p, U_full) + ) + [N_mat, base_bar] + : let( + extra_ts = _widest_span_params(base_bar, extra_pts), + aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), + occ_splits = _span_split_params(aug_bar_raw, params), + n_occ = len(occ_splits), + // Use len(extra_ts), not extra_pts: _widest_span_params silently caps + // the request at the number of available spans. + M = n + len(extra_ts) + n_occ, + aug_bar_merged = n_occ == 0 ? aug_bar_raw + : sort([each aug_bar_raw, each occ_splits]), + aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), + T = aug_bar[M], + U_full = _full_closed_knots(aug_bar, M, p), + raw_shifted = add_scalar(params, aug_bar[p]), + eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), + col_safe = [for (k = [0:1:n-1]) + let( + u = raw_shifted[k], + d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) + ) + d_min < eps_knot ? u + eps_knot : u + ], + // Rectangular n × M matrix: n data rows, M control columns. + // _collocation_matrix_periodic uses a single n for both dimensions, so + // build inline. Periodic wrapping folds basis j < p by adding N_{j+M}. + N_mat = [for (k = [0:1:n-1]) + [for (j = [0:1:M-1]) + _nip(j, p, col_safe[k], U_full) + + (j < p ? _nip(j + M, p, col_safe[k], U_full) : 0) + ]] + ) + [N_mat, aug_bar]; + + +// Build a clamped interpolation system with optional start/end first-derivative rows. +// Extends _build_clamped_system by adding one extra DOF and one extra matrix row +// for each active boundary (start and/or end). Used for surface boundary tangents. +// +// has_sd / has_ed — whether a start / end derivative constraint is active. +// extra_pts — number of additional control points (widens the system). +// Returns [A_matrix, bosl2_knots]. Square when extra_pts==0, rectangular otherwise. +// Row order: interpolation rows (k=0..n), deriv_start (if any), deriv_end (if any). + +function _build_clamped_system_with_derivs(params, p, has_sd, has_ed, extra_pts=0) = + let( + n = len(params) - 1, + n_extra = (has_sd ? 1 : 0) + (has_ed ? 1 : 0), + // Average n+1 data params to get base interior knots, then + // insert extra knots for boundary constraints. Each insertion + // bisects the span containing the constraint parameter + // (largest span first). Constraint params 0 and 1 land in + // the first and last spans respectively. + base_int = _avg_knots_interior(params, p), + base_bar = [0, each base_int, 1], + constraint_ts = [if (has_sd) params[0], if (has_ed) params[n]], + after_constr = _insert_constraint_knots(base_bar, constraint_ts), + // Insert extra_pts knots at widest spans. + extra_ts = extra_pts == 0 ? [] + : _widest_span_params(after_constr, extra_pts), + aug_bar_raw = extra_pts == 0 ? after_constr + : _insert_constraint_knots(after_constr, extra_ts), + occ_splits = extra_pts == 0 ? [] + : _span_split_params(aug_bar_raw, params), + n_occ = len(occ_splits), + M = n + 1 + n_extra + len(extra_ts) + n_occ, + aug_bar_merged = n_occ == 0 ? aug_bar_raw + : sort([each aug_bar_raw, each occ_splits]), + aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), + int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], + U_full = _full_clamped_knots(int_kn, p), + interp_rows = [for (k = [0:1:n]) + [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] + ], + deriv_start = has_sd + ? [[for (j = [0:1:M-1]) _dnip(j, p, params[0], U_full)]] + : [], + deriv_end = has_ed + ? [[for (j = [0:1:M-1]) _dnip(j, p, params[n], U_full)]] + : [], + knots = [0, each int_kn, 1] + ) + [[each interp_rows, each deriv_start, each deriv_end], knots]; + + +// Precompute per-segment interpolation systems for edge-aware surface solves. +// All rows (or columns) share the same averaged parameterization, so the +// collocation matrices only need to be built once. +// +// params = averaged parameter values for this direction +// p = degree +// edge_idxs = sorted list of interior indices where C0 edges occur +// has_sd = if true, first segment gets a start-derivative row +// has_ed = if true, last segment gets an end-derivative row +// +// Returns a list of [N_mat, xknots, seg_p, i0, i1, seg_sd, seg_ed] +// per segment, where seg_sd/seg_ed indicate whether that segment's +// system includes a derivative row. + +function _build_edge_systems(params, p, edge_idxs, + has_sd=false, has_ed=false, extra_pts=0, label="") = + let( + n = len(params) - 1, + seg_bounds = [0, each edge_idxs, n], + n_segs = len(seg_bounds) - 1, + + // Pre-compute seg_p and available interior knot spans per segment. + // For a segment with n_pts data points at degree seg_p, the averaged + // interior knot vector has (n_pts-1) - seg_p entries = that many spans. + seg_n_pts = [for (s = [0:1:n_segs-1]) seg_bounds[s+1] - seg_bounds[s] + 1], + seg_p_arr = [for (npts = seg_n_pts) min(p, npts - 1)], + avail_spans = [for (i = [0:1:n_segs-1]) + max(0, seg_n_pts[i] - 1 - seg_p_arr[i])], + total_avail = sum(avail_spans), + k_use = min(extra_pts, total_avail), + + // Emit one diagnostic when extra_pts exceeds the combined span budget. + _echo = extra_pts > 0 && extra_pts > total_avail && label != "" + ? echo(str("nurbs_interp_surface: extra_pts (", label, "-direction)=", + extra_pts, " exceeds available knot spans across ", + n_segs, " segment(s) (max ", total_avail, " total); ", + "reduced to ", total_avail, ".")) + : 0, + + // Distribute k_use proportionally to avail_spans, capped per segment. + seg_ep = extra_pts == 0 || total_avail == 0 ? repeat(0, n_segs) + : [for (s = [0:1:n_segs-1]) + avail_spans[s] == 0 ? 0 + : min(avail_spans[s], + ceil(k_use * avail_spans[s] / total_avail))] + ) + [for (s = [0:1:n_segs-1]) + let( + i0 = seg_bounds[s], + i1 = seg_bounds[s+1], + seg_par = [for (k = [i0:1:i1]) params[k]], + // Remap to [0,1] + t0 = seg_par[0], + t1 = last(seg_par), + span = max(t1 - t0, 1e-15), + local_p = [for (t = seg_par) (t - t0) / span], + seg_p = seg_p_arr[s], + // Derivative extension requires at least seg_p+1 data points + // (same minimum as basic interpolation); each derivative row + // adds one control point and one equation, keeping the system + // square. Degree-reduced segments with fewer points silently + // skip the constraint. + n_pts = seg_n_pts[s], + seg_sd = has_sd && s == 0 && n_pts >= seg_p + 1, + seg_ed = has_ed && s == n_segs - 1 && n_pts >= seg_p + 1, + // extra_pts only applies when degree >= 2; silently skip for + // degree-reduced (seg_p < 2) segments. + cur_ep = seg_p >= 2 ? seg_ep[s] : 0, + sys = (seg_sd || seg_ed) + ? _build_clamped_system_with_derivs(local_p, seg_p, + seg_sd, seg_ed, cur_ep) + : _build_interp_system(local_p, seg_p, "clamped", cur_ep) + ) + [sys[0], sys[1], seg_p, i0, i1, seg_sd, seg_ed] + ]; + + +// Solve one row (or column) using precomputed edge-aware systems. +// Each segment is solved independently; short segments are degree-elevated. +// Results are assembled into a single clamped B-spline via _combine_corner_segs. +// +// systems = list from _build_edge_systems +// data = row/column data points (same length as params) +// params = averaged parameter values +// edge_idxs = edge index list (same as passed to _build_edge_systems) +// p = target degree +// start_deriv = derivative vector at start of first segment (undef if none) +// end_deriv = derivative vector at end of last segment (undef if none) + +function _solve_with_edges(systems, data, params, edge_idxs, p, + start_deriv=undef, end_deriv=undef, smooth=3) = + let( + raw_segments = [for (sys = systems) + let( + N_mat = sys[0], + knots = sys[1], + i0 = sys[3], + i1 = sys[4], + seg_p = sys[2], + seg_sd = sys[5], + seg_ed = sys[6], + seg_data = [for (k = [i0:1:i1]) data[k]], + rhs = concat(seg_data, + seg_sd ? [start_deriv] : [], + seg_ed ? [end_deriv] : []), + M = len(N_mat[0]), + N_rows = len(rhs), + // When M > N_rows the segment system is underdetermined (extra_pts). + // Use null-space method: exact interpolation + minimum bending energy. + ctrl = M > N_rows + ? let( + int_kn = [for (i = [1:1:len(knots)-2]) knots[i]], + U_full = _full_clamped_knots(int_kn, seg_p), + eff_smooth = (smooth == 3 && seg_p < 2) ? 2 : smooth, + R = eff_smooth <= 2 + ? [for (i = [0:1:M-1]) _ltl_row(M, i, eff_smooth)] + : _bending_energy_matrix(M, seg_p, U_full) + ) + _nullspace_solve(R, N_mat, rhs) + : linear_solve(N_mat, rhs) + ) + assert(ctrl != [] && !is_undef(ctrl), + str("nurbs_interp_surface: singular edge-segment system for rows/cols ", + i0, "-", i1, " (", i1-i0+1, " points, degree ", seg_p, + seg_sd ? ", start deriv" : "", + seg_ed ? ", end deriv" : "", ")")) + [ctrl, knots, seg_p] + ], + // Degree-elevate short segments to full degree p. + segments = [for (seg = raw_segments) + seg[2] == p ? seg + : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], + type="clamped", times=p - seg[2])) + [elev[2], elev[3], p] + ] + ) + _combine_corner_segs(segments, params, edge_idxs, p); + + +// ===================================================================== +// SECTION: Surface Interpolation +// ===================================================================== + +// Compute per-point tangent vectors for a degenerate apex row or column. +// Returns true if all points in pts are collinear (lie on a single line). +// Computes the direction from first to last point, then checks that every +// intermediate point projects onto that line within eps. Points that are +// all identical also pass (dn < eps branch). + +// Returns true if all points in pts are coplanar (lie in a single plane). +// For 2D points always returns true. For 3D: finds the plane through the +// first three non-collinear points (using their cross-product normal), then +// checks that all remaining points satisfy |dot(pt-p0, nhat)| < eps. +// Points that are all collinear (degenerate plane) also return true. + +function _is_coplanar_pts(pts, eps=1e-10) = + let(n = len(pts), dim = len(pts[0])) + n <= 3 || dim <= 2 ? true + : let( + p0 = pts[0], + d1 = pts[1] - p0, + // Index of first point not collinear with pts[0..1]. + nc = [for (i = [2:1:n-1]) + let(c = cross(d1, pts[i] - p0)) + if (norm(c) > eps) i][0] + ) + is_undef(nc) ? true // all collinear → trivially coplanar + : let( + normal = cross(d1, pts[nc] - p0), + nhat = normal / norm(normal) + ) + max([for (pt = pts) abs((pt - p0) * nhat)]) < eps; + + +// Plane normal for a set of 3D points (returns 3D vector, or undef if collinear). +// Always returns [0,0,1] for 2D points. + +function _pts_plane_normal(pts, eps=1e-10) = + let(dim = len(pts[0])) + dim <= 2 + ? [0, 0, 1] + : let( + p0 = pts[0], + d1 = last(pts) - p0, + nc = [for (i = [1:1:len(pts)-1]) + let(c = cross(d1, pts[i] - p0)) + if (norm(c) > eps) c][0] + ) + is_undef(nc) ? undef : nc; + + +// Used to auto-generate first_row_deriv / last_row_deriv / first_col_deriv / last_col_deriv +// when normal1=/normal2= or flat_end1=/flat_end2= is supplied. +// +// Apex edge (all boundary points identical): +// _apex_tangents(N, apex, ring) +// N defines the symmetry axis (user-supplied vector); magnitude sets derivative scale. +// Returns per-point outward vectors (apex→ring, projected ⊥ N) of magnitude norm(N). +// Pass the negated result for an end (u=1 or v=1) apex; see caller. +// +// Coplanar edge (boundary points coplanar and span a plane, i.e. non-collinear): +// _coplanar_inward_tangents(scales, edge, ring, periodic=false) +// At each edge point computes a unit vector perpendicular to the polygon edge tangent, +// lying in the edge plane, oriented toward the polygon interior. +// +// Interior orientation uses polygon winding: the signed area of the edge polygon +// projected onto the edge plane (via the area vector = Σ cross(edge[i], edge[(i+1)%n])). +// If the area vector aligns with P_hat (CCW when viewed from P_hat) the interior is to +// the LEFT of the traversal direction; cross(P_hat, T3) already points left and so is +// the inward normal. If CW (area vector opposes P_hat), cross(P_hat, T3) points right +// (outward) and is negated. This is robust for any non-convex polygon. +// +// scales: scalar or per-point list; positive = inward (closes surface), +// negative = outward (flares surface). Same convention at start and end edges. +// periodic=true uses wrapped central differences at the first/last point (for closed v/u). + +function _apex_tangents(N, apex, ring) = + let( + mag = norm(N), + N_hat = N / max(mag, 1e-15) + ) + [for (pt = ring) + let( + d = pt - apex, + d_perp = d - (d * N_hat) * N_hat, + n_perp = norm(d_perp) + ) + n_perp > 1e-12 ? mag * d_perp / n_perp : repeat(0, len(N)) + ]; + + +function _coplanar_inward_tangents(scales, edge, ring, periodic=false) = + let( + n = len(edge), + dim = len(edge[0]), + P = _pts_plane_normal(edge), + zero = repeat(0, dim), + sc = is_num(scales) ? repeat(scales, n) : scales + ) + is_undef(P) ? repeat(zero, n) + : let( + P_hat = P / norm(P), + // Polygon area vector = Σ cross(edge[i], edge[(i+1)%n]). + // Positive dot with P_hat → CCW when viewed from P_hat → interior is LEFT. + // Negative dot → CW → interior is RIGHT. + area_vec = sum([for (i = [0:1:n-1]) + cross(dim == 2 ? [edge[i][0], edge[i][1], 0] + : edge[i], + dim == 2 ? [edge[(i+1)%n][0], edge[(i+1)%n][1], 0] + : edge[(i+1)%n])]), + sign = (area_vec * P_hat) >= 0 ? 1 : -1 + ) + [for (j = [0:1:n-1]) + let( + jm = periodic ? (j == 0 ? n-1 : j-1) : max(0, j-1), + jp = periodic ? (j == n-1 ? 0 : j+1) : min(n-1, j+1), + // Incoming and outgoing edge vectors (lifted to 3D for 2D input). + seg1 = dim == 2 ? [edge[j][0]-edge[jm][0], edge[j][1]-edge[jm][1], 0] + : edge[j] - edge[jm], + seg2 = dim == 2 ? [edge[jp][0]-edge[j][0], edge[jp][1]-edge[j][1], 0] + : edge[jp] - edge[j], + s1 = norm(seg1), + s2 = norm(seg2), + // Inward normal to each adjacent edge (unit vector), using polygon + // winding sign. cross(P_hat, unit_edge) = 90° left rotation in plane. + // Angle-bisector (average of unit normals) is length-independent, so + // non-uniform sample spacing has no effect — unlike the chord-average + // tangent method it replaces. + n1 = s1 < 1e-12 ? undef : sign * cross(P_hat, seg1 / s1), + n2 = s2 < 1e-12 ? undef : sign * cross(P_hat, seg2 / s2), + bis = is_undef(n1) ? n2 : is_undef(n2) ? n1 : n1 + n2, + blen = is_undef(bis) ? 0 : norm(bis) + ) + blen < 1e-12 ? zero + : let( + in3 = bis / blen, + inward = dim == 2 ? [in3[0], in3[1]] : in3 + ) + sc[j] * inward + ]; + +// Averaged parameterization for the u-direction (across rows). +// For each column, compute chord-length params, then average. + +function _surface_params_u(points, method, periodic) = + let( + n_rows = len(points), + n_cols = len(points[0]), + col_params = [for (l = [0:1:n_cols-1]) + let(col = [for (k = [0:1:n_rows-1]) points[k][l]]) + _interp_params(col, method, closed=periodic) + ], + n_p = len(col_params[0]) + ) + [for (k = [0:1:n_p-1]) + sum([for (l = [0:1:n_cols-1]) col_params[l][k]]) / n_cols + ]; + + +// Averaged parameterization for the v-direction (across columns). +// For each row, compute chord-length params, then average. + +function _surface_params_v(points, method, periodic) = + let( + n_rows = len(points), + n_cols = len(points[0]), + row_params = [for (k = [0:1:n_rows-1]) + _interp_params(points[k], method, closed=periodic) + ], + n_p = len(row_params[0]) + ) + [for (l = [0:1:n_p-1]) + sum([for (k = [0:1:n_rows-1]) row_params[k][l]]) / n_rows + ]; + + +// Function&Module: nurbs_interp_surface() +// Synopsis: Returns a NURBS surface that passes through a grid of 3D data points. +// SynTags: Geom +// Topics: NURBS Surfaces, Interpolation +// See Also: nurbs_vnf(), nurbs_interp() +// +// Usage: As a function, returns a NURBS parameter list: +// nurbs_param = nurbs_interp_surface(points, degree, [method=], [row_wrap=], [col_wrap=], [normal1=], [normal2=], [flat_edges=], [flat_end1=], [flat_end2=], [row_edges=], [col_edges=], [extra_pts=], [smooth=], [first_row_deriv=], [last_row_deriv=], [first_col_deriv=], [last_col_deriv=]); +// Usage: As a module, renders the surface directly: +// nurbs_interp_surface(points, degree, [splineteps], ..., [data_color=], [data_size=],[style=], [reverse=], [triangulate=], [convexity=], [cp=], [atype=], ...) CHILDREN; +// Description: +// Finds the control points and knot vectors for a NURBS surface of the specified degree that passes +// exactly through every data point in a grid of 3D points. The result has +// uniform weights but non-uniform knots so it is actually a non-uniform B-spline. +// When called as a function, the return value is a NURBS parameter list +// `[type, degree, ctrl_grid, knots, undef, undef, uv]` that can be passed +// directly to `{{nurbs_vnf()}}`. The extra return value `uv`, +// described in detail below, enables you to locate your input points in the computed spline +// When called as a module, renders the NURBS surface as geometry. +// . +// Several of the parameters that correspond to parameters for {{nurbs_interp()}} +// can be given as either a scalar or 2-vector. When you give a 2-vector the +// first value applies along the first index of your point data, i.e. from row +// to row, or along columns. The second value applies along the second index, +// i.e. within rows. +// . +// Setting `row_wrap=true` smoothly connects the first and last rows in a loop, +// and `col_wrap=true` smoothly joins the first and last columns. Both false (the default) gives a +// surface with four edges. One true gives a tube; both true gives a torus. +// A tube by itself is not a valid closed manifold in OpenSCAD; you can make it valid by adding caps or +// you can close it into a ball by specifying degenerate edges where the entire edge collapses to +// one identical point. +// . +// **Boundary constraints** +// . +// Flat boundary (`row_wrap=false, col_wrap=false`) — `flat_edges=`. Applies when +// all four surface edges are coplanar. Set `flat_edges` to a 4-element list +// `[first_row, last_row, first_col, last_col]`; each entry is a scalar or per-point list +// giving the derivative scale for that edge (`undef` leaves the edge unconstrained). +// `flat_edges=s` expands to `[s,s,s,s]`. A positive value flares the surface +// outward from the edge; negative turns it inward. +// . +// End normals (one of `row_wrap`/`col_wrap` true, the other false) — `normal1=` and +// `normal2=`. Apply when the specified boundary edge is degenerate (all points +// identical, e.g. a cone tip). The surface is constrained to be normal to the given +// vector at that edge. The vector magnitude controls how broadly the surface spreads. +// . +// Flat ends (one of `row_wrap`/`col_wrap` true, the other false) — `flat_end1=` and +// `flat_end2=`. Apply when the specified boundary edge is coplanar and non-degenerate. +// Constrains the derivative to lie in the plane of the edge. Positive points inward +// (smooth cap attachment); negative flares outward. Scalar or per-point list. +// . +// **Advanced boundary derivatives** — `first_row_deriv=`, `last_row_deriv=`, +// `first_col_deriv=`, and `last_col_deriv=` enforce specific first partial derivatives +// along the four boundary edges. Each accepts a single vector (applied to every +// point on the edge) or a list of vectors (one per point). Vectors are scaled by +// total chord length, so a unit vector matches the parameterization speed. These +// require `row_wrap=false` (for row derivs) or `col_wrap=false` (for col derivs). +// . +// Use with care: the solver enforces derivatives exactly at data points but the +// surface may wander between them. When both u- and v-boundary derivatives are +// active, the cross-derivative is assumed zero at corners. +// . +// **Edges** — `row_edges=` and `col_edges=` insert edges or creases across the surface. +// Use `row_edges=` to specify the indices of rows that will be edges or creases, +// and `col_edges=` to specify the indices of columns that will be edges or creases. +// For a non-wrapped direction, indices must be interior (not first or last). +// If you place edges close together, the effective degree of a narrow patch between +// edges may be reduced. These patches are assembled into a single NURBS so this +// process is transparent to the user. +// . +// **Extra control points** (`extra_pts=`, `smooth=`) — By default the solver uses +// exactly the number of control points needed to satisfy the constraints, which +// gives a unique solution that may be badly behaved. Specifying `extra points=` +// and optionally `smooth=`, works the same way as in +// for {{nurbs_interp()}}. Both parameters can be scalars or 2-vectors to +// provide different values along the two directions. +// . +// **Locating points in the spline** — In order to locate your original data +// points in the spline you need the `u` and `v` nurbs parameter values that you +// can pass to {{nurbs_patch_points()}}. The last return value `uv` gives these: +// `uv[0][j]` is the u parameter for row `j` and `uv[1][k]` is the v parameter +// for column `k`, so the point `points[j][k]` lies at `(uv[0][j], uv[1][k])` +// in NURBS parameter space. +// . +// **Smoothness** — The smoothness of B-splines is determined by the +// degree. If you request a degree $p$ spline then it will be $C^{p-1}$ at +// knot points and $C^\infty$ everywhere else. If you request edges then +// these are points where the surface is not differentiable; edges may +// also divide the surface into smaller regions that lack sufficient points +// to support an interpolation of your requested degree: a degree $p$ interpolation +// requires $p+1$ points. In this case, the inteprolation is performed at a lower +// degree and elevated, which means it will be less smooth at knots. +// Arguments: +// points = Rectangular grid of 3D data points +// degree = scalar or 2-vector giving the degree of the B-spline in the two directions. +// splinesteps = (module) Scalar or 2-vector giving the number of segments between each knot in the two directions. Default: 16 +// --- +// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` +// row_wrap = If true, smoothly connect the first row to the last row. Default: false +// col_wrap = If true, smoothly connect the first column to the last column. Default: false +// extra_pts = Scalar or 2-vector giving the number of extra points in the two directions. Default: `0` +// smooth = Scalar or 2-vector giving the smoothness metric for extra points in the two directions: `1` (min polygon length), `2` (min bending), `3` (min bending energy). Default: `3` +// flat_edges = 4-element list `[first_row, last_row, first_col, last_col]` of derivative scales at the four coplanar boundary edges. Each entry is a scalar or per-point list; `undef` leaves that edge unconstrained. Shorthand: `flat_edges=s` → `[s,s,s,s]`. Requires `row_wrap=false, col_wrap=false`. +// normal1 = Surface normal at the first degenerate boundary edge (mixed wrap surface only). +// normal2 = Surface normal at the second degenerate boundary edge (mixed wrap surface only). +// flat_end1 = Inward derivative scale at the first coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. +// flat_end2 = Inward derivative scale at the second coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. +// row_edges = Row indices (or index) of rows that are treated as edges or creases. +// col_edges = Column indices (or index) of columns that are treated as edges or creases +// first_row_deriv = $\partial S/\partial u$ constraint along u=0 (first row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. +// last_row_deriv = $\partial S/\partial u$ constraint along u=1 (last row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. +// first_col_deriv = $\partial S/\partial v$ constraint along v=0 (first column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. +// last_col_deriv = $\partial S/\partial v$ constraint along v=1 (last column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. +// data_size = (module) Radius of data-point markers; 0 suppresses markers. Default: 0 +// data_color = (module) Color for data-point markers. Default: `"red"` +// style = (module) Triangulation style passed to `vnf_vertex_array()`. Default: `"default"` +// reverse = (module) If true, reverses face normals. Default: false +// triangulate = (module) If true, triangulates all quads. Default: false +// caps = (module) Cap both open boundary edges (mixed wrap only). Default: false +// cap1 = (module) Cap the first open boundary edge. +// cap2 = (module) Cap the second open boundary edge. +// cp = (module) Centerpoint for determining intersection anchors or centering the shape. Determines the base of the anchor vector. Can be "centroid", "mean", "box" or a 3D point. Default: "centroid" +// anchor = (module) Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `"origin"` +// spin = (module) Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0` +// orient = (module) Vector to rotate top toward, after spin. See [orient](attachments.scad#subsection-orient). Default: `UP` +// atype = (module) Select "hull" or "intersect" anchor type. Default: "hull" + +function nurbs_interp_surface(points, degree, method="centripetal", + row_wrap=false, col_wrap=false, + first_row_deriv=undef, last_row_deriv=undef, + first_col_deriv=undef, last_col_deriv=undef, + normal1=undef, normal2=undef, + flat_end1=undef, flat_end2=undef, + flat_edges=undef, + row_edges=undef, col_edges=undef, + extra_pts=0, smooth=3) = + // Preamble: extract shape/edge info needed for closed-direction dispatch. + let( + n_rows = len(points), + n_cols = len(points[0]), + ue_norm_pre = is_undef(row_edges) ? undef : force_list(row_edges), + ve_norm_pre = is_undef(col_edges) ? undef : force_list(col_edges), + has_ue_pre = !is_undef(ue_norm_pre) && len(ue_norm_pre) > 0, + has_ve_pre = !is_undef(ve_norm_pre) && len(ve_norm_pre) > 0 + ) + // col_edges on a closed v-direction: rotate columns so the first crease column + // becomes the v=0/v=1 boundary, append a copy at the end for the C0 seam, + // then recurse with col_wrap=false. Remaining crease indices are shifted + // into the rotated coordinate system. + has_ve_pre && col_wrap ? + let( + ve_sorted = sort(ve_norm_pre), + rot = ve_sorted[0], + new_pts = [for (row = points) + concat([for (l = [rot:1:n_cols-1]) row[l]], + [for (l = [0:1:rot-1]) row[l]], + [row[rot]])], + adj_ve_raw = [for (i = [1:1:len(ve_sorted)-1]) + let(j = (ve_sorted[i] - rot + n_cols) % n_cols) + if (j > 0) j], + adj_ve = len(adj_ve_raw) == 0 ? undef : adj_ve_raw + ) + let(inner = nurbs_interp_surface(new_pts, degree, method=method, + row_wrap=row_wrap, col_wrap=false, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, + row_edges=row_edges, col_edges=adj_ve, + extra_pts=extra_pts, smooth=smooth)) + [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], + [inner[6][0], + list_rotate(select(inner[6][1], 0, n_cols-1), -rot)]] + // row_edges on a closed u-direction: rotate rows so the first crease row + // becomes the u=0/u=1 boundary, append a copy at the end, recurse clamped. + : has_ue_pre && row_wrap ? + let( + ue_sorted = sort(ue_norm_pre), + rot = ue_sorted[0], + new_pts = concat([for (k = [rot:1:n_rows-1]) points[k]], + [for (k = [0:1:rot-1]) points[k]], + [points[rot]]), + adj_ue_raw = [for (i = [1:1:len(ue_sorted)-1]) + let(j = (ue_sorted[i] - rot + n_rows) % n_rows) + if (j > 0) j], + adj_ue = len(adj_ue_raw) == 0 ? undef : adj_ue_raw + ) + let(inner = nurbs_interp_surface(new_pts, degree, method=method, + row_wrap=false, col_wrap=col_wrap, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, + row_edges=adj_ue, col_edges=col_edges, + extra_pts=extra_pts, smooth=smooth)) + [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], + [list_rotate(select(inner[6][0], 0, n_rows-1), -rot), + inner[6][1]]] + // Normal path: both directions already clamped, or no conflicting edge constraints. + : let( + p_u = is_list(degree) ? degree[0] : degree, + p_v = is_list(degree) ? degree[1] : degree, + ep_u = is_list(extra_pts) ? extra_pts[0] : extra_pts, + ep_v = is_list(extra_pts) ? extra_pts[1] : extra_pts, + smooth_u = is_list(smooth) ? smooth[0] : smooth, + smooth_v = is_list(smooth) ? smooth[1] : smooth, + n_rows = len(points), + n_cols = len(points[0]), + dim = len(points[0][0]), + // Scalar-vector promotion: if the caller passes a single vector instead of + // a list of vectors, repeat() it to the required length. A single vector + // is detected as a list whose first element is a number, not a list. + first_row_deriv = is_undef(first_row_deriv) || is_list(first_row_deriv[0]) ? first_row_deriv + : repeat(first_row_deriv, n_cols), + last_row_deriv = is_undef(last_row_deriv) || is_list(last_row_deriv[0]) ? last_row_deriv + : repeat(last_row_deriv, n_cols), + first_col_deriv = is_undef(first_col_deriv) || is_list(first_col_deriv[0]) ? first_col_deriv + : repeat(first_col_deriv, n_rows), + last_col_deriv = is_undef(last_col_deriv) || is_list(last_col_deriv[0]) ? last_col_deriv + : repeat(last_col_deriv, n_rows), + // Treat an all-undef derivative list the same as undef. + has_sud = !is_undef(first_row_deriv) && num_defined(first_row_deriv) > 0, + has_eud = !is_undef(last_row_deriv) && num_defined(last_row_deriv) > 0, + has_svd = !is_undef(first_col_deriv) && num_defined(first_col_deriv) > 0, + has_evd = !is_undef(last_col_deriv) && num_defined(last_col_deriv) > 0, + has_sn = !is_undef(normal1), + has_en = !is_undef(normal2), + // normal1/normal2: apex edges only (all boundary points identical, e.g. cone tip). + // Auto-detect u=0/v=0 direction; u=0 (first row) takes priority. + start_u_apex = has_sn && max([for (pt = points[0]) norm(pt - points[0][0])]) < 1e-10, + start_v_apex = has_sn && max([for (k = [0:1:n_rows-1]) norm(points[k][0] - points[0][0])]) < 1e-10, + end_u_apex = has_en && max([for (pt = points[n_rows-1]) norm(pt - points[n_rows-1][0])]) < 1e-10, + end_v_apex = has_en && max([for (k = [0:1:n_rows-1]) norm(points[k][n_cols-1] - points[0][n_cols-1])]) < 1e-10, + has_sun = has_sn && start_u_apex, + has_eun = has_en && end_u_apex, + has_svn = has_sn && !start_u_apex && start_v_apex, + has_evn = has_en && !end_u_apex && end_v_apex, + start_u_degen = start_u_apex, + start_v_degen = start_v_apex, + end_u_degen = end_u_apex, + end_v_degen = end_v_apex, + // flat_end1/flat_end2: coplanar non-collinear edges (points span a plane). + // Scalar or per-point list. positive = closes inward, negative = flares outward. + // Direction is determined by the clamped direction of the surface: + // row_wrap=false → flat_end applies to row boundaries (u-direction, first/last row). + // col_wrap=false → flat_end applies to column boundaries (v-direction, first/last col). + // Exactly one direction must be clamped (enforced by assertion below). + has_fe1 = !is_undef(flat_end1), + has_fe2 = !is_undef(flat_end2), + has_fe1_u = has_fe1 && !row_wrap, + has_fe1_v = has_fe1 && !col_wrap, + has_fe2_u = has_fe2 && !row_wrap, + has_fe2_v = has_fe2 && !col_wrap, + // Boundary edges for coplanar validation. + fe1_edge = has_fe1_u ? points[0] + : has_fe1_v ? [for (k = [0:1:n_rows-1]) points[k][0]] + : [], + fe2_edge = has_fe2_u ? points[n_rows-1] + : has_fe2_v ? [for (k = [0:1:n_rows-1]) points[k][n_cols-1]] + : [], + fe1_ok = !has_fe1 || (_is_coplanar_pts(fe1_edge) && !is_undef(_pts_plane_normal(fe1_edge))), + fe2_ok = !has_fe2 || (_is_coplanar_pts(fe2_edge) && !is_undef(_pts_plane_normal(fe2_edge))), + // flat_edges= parsing: 4-element list [first_row, last_row, first_col, last_col]. + // Scalar shorthand: flat_edges=s expands to [s, s, s, s]. + fe_norm = !is_undef(flat_edges) && !is_list(flat_edges) + ? [flat_edges, flat_edges, flat_edges, flat_edges] + : flat_edges, + has_fe = !is_undef(fe_norm), + fe_su = has_fe ? fe_norm[0] : undef, + fe_eu = has_fe ? fe_norm[1] : undef, + fe_sv = has_fe ? fe_norm[2] : undef, + fe_ev = has_fe ? fe_norm[3] : undef, + has_fesu = has_fe && !is_undef(fe_su), + has_feeu = has_fe && !is_undef(fe_eu), + has_fesv = has_fe && !is_undef(fe_sv), + has_feev = has_fe && !is_undef(fe_ev), + // Edge (C0 discontinuity) support. Singleton promotion: scalar → list. + ue_norm = is_undef(row_edges) ? undef : force_list(row_edges), + ve_norm = is_undef(col_edges) ? undef : force_list(col_edges), + has_ue = !is_undef(ue_norm) && len(ue_norm) > 0, + has_ve = !is_undef(ve_norm) && len(ve_norm) > 0 + ) + assert(is_list(points) && n_rows >= 2, + "nurbs_interp_surface: need at least 2 rows") + assert(n_cols >= 2, + "nurbs_interp_surface: need at least 2 columns") + assert(min([for (row = points) len(row)]) == max([for (row = points) len(row)]), + "nurbs_interp_surface: all rows must have the same number of columns") + assert(is_num(p_u) && p_u >= 1 && is_num(p_v) && p_v >= 1, + "nurbs_interp_surface: degree must be >= 1") + assert(method == "length" || method == "centripetal" || method == "dynamic" + || method == "foley" || method == "fang", + str("nurbs_interp_surface: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) + assert(is_num(ep_u) && ep_u >= 0 && ep_u == floor(ep_u), + str("nurbs_interp_surface: extra_pts (u) must be a non-negative integer, got ", ep_u)) + assert(is_num(ep_v) && ep_v >= 0 && ep_v == floor(ep_v), + str("nurbs_interp_surface: extra_pts (v) must be a non-negative integer, got ", ep_v)) + assert(ep_u == 0 || p_u >= 2, + "nurbs_interp_surface: extra_pts in u-direction requires u-degree >= 2") + assert(ep_v == 0 || p_v >= 2, + "nurbs_interp_surface: extra_pts in v-direction requires v-degree >= 2") + assert(n_rows >= p_u + 1, + str("nurbs_interp_surface: need at least ", p_u+1, + " rows for u-degree ", p_u, ", got ", n_rows)) + assert(n_cols >= p_v + 1, + str("nurbs_interp_surface: need at least ", p_v+1, + " columns for v-degree ", p_v, ", got ", n_cols)) + assert(!(has_sud || has_eud || has_sun || has_eun || has_fesu || has_feeu || has_fe1_u || has_fe2_u) || !row_wrap, + "nurbs_interp_surface: u-direction derivative/normal/flat_end/flat_edges params require row_wrap=false") + assert(!(has_svd || has_evd || has_svn || has_evn || has_fesv || has_feev || has_fe1_v || has_fe2_v) || !col_wrap, + "nurbs_interp_surface: v-direction derivative/normal/flat_end/flat_edges params require col_wrap=false") + assert(!has_sud || len(first_row_deriv) == n_cols, + str("nurbs_interp_surface: first_row_deriv must have ", n_cols, + " entries (one per column), got ", is_undef(first_row_deriv) ? 0 : len(first_row_deriv))) + assert(!has_eud || len(last_row_deriv) == n_cols, + str("nurbs_interp_surface: last_row_deriv must have ", n_cols, + " entries (one per column), got ", is_undef(last_row_deriv) ? 0 : len(last_row_deriv))) + assert(!has_svd || len(first_col_deriv) == n_rows, + str("nurbs_interp_surface: first_col_deriv must have ", n_rows, + " entries (one per row), got ", is_undef(first_col_deriv) ? 0 : len(first_col_deriv))) + assert(!has_evd || len(last_col_deriv) == n_rows, + str("nurbs_interp_surface: last_col_deriv must have ", n_rows, + " entries (one per row), got ", is_undef(last_col_deriv) ? 0 : len(last_col_deriv))) + // normal1/normal2 assertions: apex edges only. + assert(!has_sn || (start_u_degen || start_v_degen), + "nurbs_interp_surface: normal1 requires a degenerate start edge (first row or first column must be all the same point)") + assert(!has_en || (end_u_degen || end_v_degen), + "nurbs_interp_surface: normal2 requires a degenerate end edge (last row or last column must be all the same point)") + assert(!has_sn || !(start_u_degen && start_v_degen), + "nurbs_interp_surface: normal1 is ambiguous — both u=0 and v=0 edges are degenerate; use first_row_deriv or first_col_deriv explicitly") + assert(!has_en || !(end_u_degen && end_v_degen), + "nurbs_interp_surface: normal2 is ambiguous — both u=1 and v=1 edges are degenerate; use last_row_deriv or last_col_deriv explicitly") + assert(!(has_sun && has_sud), + "nurbs_interp_surface: normal1 resolves to u-direction but first_row_deriv was also given") + assert(!(has_eun && has_eud), + "nurbs_interp_surface: normal2 resolves to u-direction but last_row_deriv was also given") + assert(!(has_svn && has_svd), + "nurbs_interp_surface: normal1 resolves to v-direction but first_col_deriv was also given") + assert(!(has_evn && has_evd), + "nurbs_interp_surface: normal2 resolves to v-direction but last_col_deriv was also given") + // flat_end1/flat_end2 assertions. + // Direction is determined by the clamped type; surface must be mixed clamped/closed. + assert(!has_fe1 || (row_wrap != col_wrap), + "nurbs_interp_surface: flat_end1 requires the surface to be clamped in one direction and closed in the other") + assert(!has_fe2 || (row_wrap != col_wrap), + "nurbs_interp_surface: flat_end2 requires the surface to be clamped in one direction and closed in the other") + assert(fe1_ok, + has_fe1_u + ? "nurbs_interp_surface: flat_end1 requires the first row (u=0 boundary) to be coplanar and non-collinear" + : "nurbs_interp_surface: flat_end1 requires the first column (v=0 boundary) to be coplanar and non-collinear. If your first row is coplanar, try row_wrap=true, col_wrap=false.") + assert(fe2_ok, + has_fe2_u + ? "nurbs_interp_surface: flat_end2 requires the last row (u=1 boundary) to be coplanar and non-collinear" + : "nurbs_interp_surface: flat_end2 requires the last column (v=1 boundary) to be coplanar and non-collinear. If your last row is coplanar, try row_wrap=true, col_wrap=false.") + assert(!(has_fe1_u && has_sud), + "nurbs_interp_surface: flat_end1 conflicts with first_row_deriv") + assert(!(has_fe2_u && has_eud), + "nurbs_interp_surface: flat_end2 conflicts with last_row_deriv") + assert(!(has_fe1_v && has_svd), + "nurbs_interp_surface: flat_end1 conflicts with first_col_deriv") + assert(!(has_fe2_v && has_evd), + "nurbs_interp_surface: flat_end2 conflicts with last_col_deriv") + assert(!(has_fe1_u && has_fesu), + "nurbs_interp_surface: flat_end1 conflicts with flat_edges[0] on same edge") + assert(!(has_fe2_u && has_feeu), + "nurbs_interp_surface: flat_end2 conflicts with flat_edges[1] on same edge") + assert(!(has_fe1_v && has_fesv), + "nurbs_interp_surface: flat_end1 conflicts with flat_edges[2] on same edge") + assert(!(has_fe2_v && has_feev), + "nurbs_interp_surface: flat_end2 conflicts with flat_edges[3] on same edge") + assert(!has_fe1 || is_num(flat_end1) || len(flat_end1) == (has_fe1_u ? n_cols : n_rows), + str("nurbs_interp_surface: flat_end1 list must have ", has_fe1_u ? n_cols : n_rows, " entries")) + assert(!has_fe2 || is_num(flat_end2) || len(flat_end2) == (has_fe2_u ? n_cols : n_rows), + str("nurbs_interp_surface: flat_end2 list must have ", has_fe2_u ? n_cols : n_rows, " entries")) + // flat_edges assertions. + assert(!has_fe || (is_list(fe_norm) && len(fe_norm) == 4), + "nurbs_interp_surface: flat_edges must be a scalar or 4-element list [first_row, last_row, first_col, last_col]") + assert(!(has_fesu && has_sud), + "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with first_row_deriv") + assert(!(has_feeu && has_eud), + "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with last_row_deriv") + assert(!(has_fesv && has_svd), + "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with first_col_deriv") + assert(!(has_feev && has_evd), + "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with last_col_deriv") + assert(!(has_fesu && has_sun), + "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with normal1 on same edge") + assert(!(has_feeu && has_eun), + "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with normal2 on same edge") + assert(!(has_fesv && has_svn), + "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with normal1 on same edge") + assert(!(has_feev && has_evn), + "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with normal2 on same edge") + assert(!has_fesu || !is_list(fe_su) || len(fe_su) == n_cols, + str("nurbs_interp_surface: flat_edges[0] scale list must have ", n_cols, " entries (one per column)")) + assert(!has_feeu || !is_list(fe_eu) || len(fe_eu) == n_cols, + str("nurbs_interp_surface: flat_edges[1] scale list must have ", n_cols, " entries (one per column)")) + assert(!has_fesv || !is_list(fe_sv) || len(fe_sv) == n_rows, + str("nurbs_interp_surface: flat_edges[2] scale list must have ", n_rows, " entries (one per row)")) + assert(!has_feev || !is_list(fe_ev) || len(fe_ev) == n_rows, + str("nurbs_interp_surface: flat_edges[3] scale list must have ", n_rows, " entries (one per row)")) + // Edge (C0) validation. + assert(!has_ue || !row_wrap, + "nurbs_interp_surface: row_edges requires row_wrap=false") + assert(!has_ve || !col_wrap, + "nurbs_interp_surface: col_edges requires col_wrap=false") + assert(!has_ue || (min(ue_norm) >= 1 && max(ue_norm) <= n_rows-2), + str("nurbs_interp_surface: row_edges indices must be interior (1..", n_rows-2, ")")) + assert(!has_ve || (min(ve_norm) >= 1 && max(ve_norm) <= n_cols-2), + str("nurbs_interp_surface: col_edges indices must be interior (1..", n_cols-2, ")")) + // row_edges / col_edges are compatible with same-direction boundary derivatives, + // normals, and flat_edges: the first/last segment of the edge-aware system + // carries the boundary derivative constraint. + let( + // Boundary plane for flat_edges=: cross product of two perimeter vectors. + // Guarded so degenerate geometry can't produce NaN when flat_edges is unused. + fe_e1 = has_fe ? (points[0][n_cols-1] - points[0][0]) : [1,0,0], + fe_e2 = has_fe ? (points[n_rows-1][0] - points[0][0]) : [0,1,0], + fe_N_raw = has_fe ? cross(fe_e1, fe_e2) : [0,0,1], + fe_N_hat = fe_N_raw / max(norm(fe_N_raw), 1e-15), + // Per-edge flat-outward derivative lists; undef when edge not active. + // Direction at each point: from adjacent interior point toward edge, + // projected into the boundary plane, then normalized and scaled. + flat_su_der = !has_fesu ? undef : + [for (j = [0:1:n_cols-1]) + let( + d = points[1][j] - points[0][j], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_su) ? fe_su[j] : fe_su + ) d_hat * s], + flat_eu_der = !has_feeu ? undef : + [for (j = [0:1:n_cols-1]) + let( + d = points[n_rows-1][j] - points[n_rows-2][j], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_eu) ? fe_eu[j] : fe_eu + ) d_hat * s], + flat_sv_der = !has_fesv ? undef : + [for (k = [0:1:n_rows-1]) + let( + d = points[k][1] - points[k][0], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_sv) ? fe_sv[k] : fe_sv + ) d_hat * s], + flat_ev_der = !has_feev ? undef : + [for (k = [0:1:n_rows-1]) + let( + d = points[k][n_cols-1] - points[k][n_cols-2], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_ev) ? fe_ev[k] : fe_ev + ) d_hat * s] + ) + assert(!has_fesu || min([for (j = [0:1:n_cols-1]) let(d = points[1][j] - points[0][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[0] (first_row) direction is perpendicular to the boundary plane at one or more points") + assert(!has_feeu || min([for (j = [0:1:n_cols-1]) let(d = points[n_rows-1][j] - points[n_rows-2][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[1] (last_row) direction is perpendicular to the boundary plane at one or more points") + assert(!has_fesv || min([for (k = [0:1:n_rows-1]) let(d = points[k][1] - points[k][0], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[2] (first_col) direction is perpendicular to the boundary plane at one or more points") + assert(!has_feev || min([for (k = [0:1:n_rows-1]) let(d = points[k][n_cols-1] - points[k][n_cols-2], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[3] (last_col) direction is perpendicular to the boundary plane at one or more points") + assert(!has_fe || is_coplanar(concat( + points[0], points[n_rows-1], + [for (k = [1:1:n_rows-2]) points[k][0]], + [for (k = [1:1:n_rows-2]) points[k][n_cols-1]]), eps=1e-6), + "nurbs_interp_surface: flat_edges= requires all four boundary edges to be coplanar") + let( + // Compute effective derivative lists. + // Priority: normal1/normal2 (apex) > flat_end1/flat_end2 (coplanar) > flat_edges > explicit *_der=. + // Apex (all boundary points identical): fan outward from apex, user axis vector N. + // End-edge apex tangents are negated because _apex_tangents() returns outward + // (apex→ring) vectors; negating gives inward (ring→apex), making the surface + // converge to the apex tip at the correct parametric direction. + // Coplanar (flat_end): _coplanar_inward_tangents() returns in-plane vectors + // oriented toward the polygon interior using the polygon winding order. + // Positive scale closes inward, negative flares outward. + // flat_end1 result is negated: _coplanar_inward_tangents returns outward + // for the start boundary; negating gives the correct inward direction. + // flat_end2 uses the same function without negation (end boundary sign matches). + // Periodic tangent differences used when the cross-direction is "closed". + first_row_deriv_eff = has_sun + ? _apex_tangents(normal1, points[0][0], points[1]) + : has_fe1_u + ? [for (v = _coplanar_inward_tangents(flat_end1, points[0], points[1], + periodic=col_wrap)) -v] + : has_fesu ? flat_su_der + : first_row_deriv, + last_row_deriv_eff = has_eun + ? [for (v = _apex_tangents(normal2, points[n_rows-1][0], points[n_rows-2])) -v] + : has_fe2_u + ? _coplanar_inward_tangents(flat_end2, points[n_rows-1], points[n_rows-2], + periodic=col_wrap) + : has_feeu ? flat_eu_der + : last_row_deriv, + first_col_deriv_eff = has_svn + ? _apex_tangents(normal1, points[0][0], + [for (k = [0:1:n_rows-1]) points[k][1]]) + : has_fe1_v + ? [for (v = _coplanar_inward_tangents(flat_end1, + [for (k = [0:1:n_rows-1]) points[k][0]], + [for (k = [0:1:n_rows-1]) points[k][1]], + periodic=row_wrap)) -v] + : has_fesv ? flat_sv_der + : first_col_deriv, + last_col_deriv_eff = has_evn + ? [for (v = _apex_tangents(normal2, points[0][n_cols-1], + [for (k = [0:1:n_rows-1]) points[k][n_cols-2]])) -v] + : has_fe2_v + ? _coplanar_inward_tangents(flat_end2, + [for (k = [0:1:n_rows-1]) points[k][n_cols-1]], + [for (k = [0:1:n_rows-1]) points[k][n_cols-2]], + periodic=row_wrap) + : has_feev ? flat_ev_der + : last_col_deriv, + has_sud_eff = has_sud || has_sun || has_fesu || has_fe1_u, + has_eud_eff = has_eud || has_eun || has_feeu || has_fe2_u, + has_svd_eff = has_svd || has_svn || has_fesv || has_fe1_v, + has_evd_eff = has_evd || has_evn || has_feev || has_fe2_v + ) + // row_edges / col_edges boundary-derivative segment-size checks. + // A derivative-carrying edge segment needs at least 3 rows/columns; + // with only 2 the degree-reduced knot vector becomes degenerate. + assert(!(has_ue && has_sud_eff && ue_norm[0] + 1 < 3), + !has_ue ? "" : + str("nurbs_interp_surface: row_edges=", ue_norm, + " creates a ", ue_norm[0]+1, "-row first segment (rows 0-", + ue_norm[0], ") which is too short to carry the start-u derivative constraint. ", + "Move the first row_edges index to at least 2")) + assert(!(has_ue && has_eud_eff && n_rows - last(ue_norm) < 3), + !has_ue ? "" : + str("nurbs_interp_surface: row_edges=", ue_norm, + " creates a ", n_rows - last(ue_norm), "-row last segment (rows ", + last(ue_norm), "-", n_rows-1, ") which is too short to carry the end-u derivative constraint. ", + "Move the last row_edges index to at most ", n_rows - 3)) + assert(!(has_ve && has_svd_eff && ve_norm[0] + 1 < 3), + !has_ve ? "" : + str("nurbs_interp_surface: col_edges=", ve_norm, + " creates a ", ve_norm[0]+1, "-column first segment (columns 0-", + ve_norm[0], ") which is too short to carry the start-v derivative constraint. ", + "Move the first col_edges index to at least 2")) + assert(!(has_ve && has_evd_eff && n_cols - last(ve_norm) < 3), + !has_ve ? "" : + str("nurbs_interp_surface: col_edges=", ve_norm, + " creates a ", n_cols - last(ve_norm), "-column last segment (columns ", + last(ve_norm), "-", n_cols-1, ") which is too short to carry the end-v derivative constraint. ", + "Move the last col_edges index to at most ", n_cols - 3)) + let( + // Averaged parameterization in each direction + u_params = _surface_params_u(points, method, row_wrap), + v_params = _surface_params_v(points, method, col_wrap), + + // Per-row v-direction path lengths for scaling v-boundary tangents. + // Follows the curve convention: user passes normalized vectors; code + // scales by total chord length so a unit vector gives natural speed. + v_path_lens = [for (k = [0:1:n_rows-1]) path_length(points[k])], + + // Per-column u-direction path lengths for scaling u-boundary tangents. + u_path_lens = [for (l = [0:1:n_cols-1]) + path_length([for (k = [0:1:n_rows-1]) points[k][l]])], + + // ----- Build v-direction system ----- + // When col_edges is active, precompute per-segment collocation systems. + // Otherwise use the standard (or derivative-extended) system. + v_edge_sys = has_ve + ? _build_edge_systems(v_params, p_v, ve_norm, + has_sd=has_svd_eff, + has_ed=has_evd_eff, + extra_pts=ep_v, label="v") : undef, + v_sys = has_ve ? undef + : (has_svd_eff || has_evd_eff) + ? _build_clamped_system_with_derivs(v_params, p_v, has_svd_eff, has_evd_eff, ep_v) + : _build_interp_system(v_params, p_v, col_wrap ? "closed" : "clamped", ep_v), + N_v = has_ve ? undef : v_sys[0], + // When underdetermined (extra_pts), build regularization matrix for v. + M_v = has_ve ? undef : len(N_v[0]), + N_rows_v = has_ve ? undef : len(N_v), + ns_v = !has_ve && M_v > N_rows_v, + R_reg_v = !ns_v ? undef + : let(vk = v_sys[1], + vint = !col_wrap + ? [for (i = [1:1:len(vk)-2]) vk[i]] + : undef, + vU = !col_wrap + ? _full_clamped_knots(vint, p_v) + : _full_closed_knots(vk, M_v, p_v)) + smooth_v <= 2 + ? [for (i = [0:1:M_v-1]) _ltl_row(M_v, i, smooth_v, periodic=col_wrap)] + : _bending_energy_matrix(M_v, p_v, vU, periodic=col_wrap), + + // ----- Pass 1: Interpolate rows in v-direction ----- + // With col_edges: solve each row via edge-aware segmented system. + // Without: same A_v matrix for every row; only the RHS changes per row. + R_raw = has_ve + ? [for (k = [0:1:n_rows-1]) + _solve_with_edges(v_edge_sys, points[k], + v_params, ve_norm, p_v, + start_deriv = has_svd_eff + ? _force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k] + : undef, + end_deriv = has_evd_eff + ? _force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k] + : undef, + smooth = smooth_v)] + : undef, + R = has_ve + ? [for (r = R_raw) r[0]] + : [for (k = [0:1:n_rows-1]) + let(rhs = concat( + points[k], + has_svd_eff + ? [_force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k]] + : [], + has_evd_eff + ? [_force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k]] + : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, rhs) + : linear_solve(N_v, rhs) + ], + + v_knots = has_ve ? R_raw[0][1] : v_sys[1], + n_v_ctrl = len(R[0]), + + // ----- Pass 1.5: Project u-boundary tangents into v-control space ----- + // ∂S/∂u along u=0 or u=1 is given at the n_cols data v-positions. + // To use them as derivative RHS in the u-direction column solves, we + // must express them in the v B-spline control basis — done by solving + // the same v-system. When col_edges is active, project through the + // edge-aware segmented system instead. + zero_v = repeat(0, dim), + _su_der_data = has_sud_eff + ? [for (l = [0:1:n_cols-1]) + _force_deriv_dim(first_row_deriv_eff[l], dim) * u_path_lens[l]] + : undef, + _eu_der_data = has_eud_eff + ? [for (l = [0:1:n_cols-1]) + _force_deriv_dim(last_row_deriv_eff[l], dim) * u_path_lens[l]] + : undef, + T_u_start = has_sud_eff + ? has_ve + ? _solve_with_edges(v_edge_sys, _su_der_data, + v_params, ve_norm, p_v, + start_deriv = has_svd_eff ? zero_v : undef, + end_deriv = has_evd_eff ? zero_v : undef, + smooth = smooth_v)[0] + : let(_rhs = concat(_su_der_data, + has_svd_eff ? [zero_v] : [], + has_evd_eff ? [zero_v] : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) + : linear_solve(N_v, _rhs) + : undef, + T_u_end = has_eud_eff + ? has_ve + ? _solve_with_edges(v_edge_sys, _eu_der_data, + v_params, ve_norm, p_v, + start_deriv = has_svd_eff ? zero_v : undef, + end_deriv = has_evd_eff ? zero_v : undef, + smooth = smooth_v)[0] + : let(_rhs = concat(_eu_der_data, + has_svd_eff ? [zero_v] : [], + has_evd_eff ? [zero_v] : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) + : linear_solve(N_v, _rhs) + : undef, + + // ----- Build u-direction system ----- + // When row_edges is active, precompute per-segment systems. + u_edge_sys = has_ue + ? _build_edge_systems(u_params, p_u, ue_norm, + has_sd=has_sud_eff, + has_ed=has_eud_eff, + extra_pts=ep_u, label="u") : undef, + u_sys = has_ue ? undef + : (has_sud_eff || has_eud_eff) + ? _build_clamped_system_with_derivs(u_params, p_u, has_sud_eff, has_eud_eff, ep_u) + : _build_interp_system(u_params, p_u, row_wrap ? "closed" : "clamped", ep_u), + N_u = has_ue ? undef : u_sys[0], + // When underdetermined (extra_pts), build regularization matrix for u. + M_u = has_ue ? undef : len(N_u[0]), + N_rows_u = has_ue ? undef : len(N_u), + ns_u = !has_ue && M_u > N_rows_u, + R_reg_u = !ns_u ? undef + : let(uk = u_sys[1], + uint = !row_wrap + ? [for (i = [1:1:len(uk)-2]) uk[i]] + : undef, + uU = !row_wrap + ? _full_clamped_knots(uint, p_u) + : _full_closed_knots(uk, M_u, p_u)) + smooth_u <= 2 + ? [for (i = [0:1:M_u-1]) _ltl_row(M_u, i, smooth_u, periodic=row_wrap)] + : _bending_energy_matrix(M_u, p_u, uU, periodic=row_wrap), + + // ----- Pass 2: Interpolate columns in u-direction ----- + // Transpose R so each entry is a column of intermediate points. + R_T = [for (j = [0:1:n_v_ctrl-1]) + [for (k = [0:1:n_rows-1]) R[k][j]]], + + // With row_edges: solve each column via edge-aware segmented system. + // Without: add u-tangent constraint rows to the RHS for each column j. + P_T_raw = has_ue + ? [for (j = [0:1:n_v_ctrl-1]) + _solve_with_edges(u_edge_sys, R_T[j], + u_params, ue_norm, p_u, + start_deriv = has_sud_eff ? T_u_start[j] : undef, + end_deriv = has_eud_eff ? T_u_end[j] : undef, + smooth = smooth_u)] + : undef, + P_T = has_ue + ? [for (r = P_T_raw) r[0]] + : [for (j = [0:1:n_v_ctrl-1]) + let(rhs = concat( + R_T[j], + has_sud_eff ? [T_u_start[j]] : [], + has_eud_eff ? [T_u_end[j]] : [])) + ns_u ? _nullspace_solve(R_reg_u, N_u, rhs) + : linear_solve(N_u, rhs) + ], + + u_knots = has_ue ? P_T_raw[0][1] : u_sys[1], + + // Transpose back to get the final control point grid. + n_u_ctrl = len(P_T[0]), + P = [for (i = [0:1:n_u_ctrl-1]) + [for (j = [0:1:n_v_ctrl-1]) P_T[j][i]]] + ) + [[row_wrap ? "closed" : "clamped", col_wrap ? "closed" : "clamped"], + [p_u, p_v], P, [u_knots, v_knots], undef, undef, + [u_params, v_params]]; + + +// Module: nurbs_interp_surface() +// See Also: nurbs_interp_surface() (function form, above) + +module nurbs_interp_surface(points, degree, splinesteps=16, + method="centripetal", + row_wrap=false, col_wrap=false, + style="default", reverse=false, triangulate=false, + caps=undef, cap1=undef, cap2=undef, + first_row_deriv=undef, last_row_deriv=undef, + first_col_deriv=undef, last_col_deriv=undef, + normal1=undef, normal2=undef, + flat_end1=undef, flat_end2=undef, + flat_edges=undef, + row_edges=undef, col_edges=undef, + extra_pts=0, smooth=3, + data_color="red", data_size=0, + atype="hull", convexity=10, cp="centroid", anchor="origin", spin=0, orient=UP +) + { + result = nurbs_interp_surface(points, degree, + method=method, row_wrap=row_wrap, col_wrap=col_wrap, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, + flat_edges=flat_edges, + row_edges=row_edges, col_edges=col_edges, + extra_pts=extra_pts, smooth=smooth); + nurbs_vnf(result, splinesteps=splinesteps, style=style, + reverse=reverse, triangulate=triangulate, + caps=caps, cap1=cap1, cap2=cap2, convexity=convexity, atype=atype, anchor=anchor, spin=spin, orient=orient) children(); + if (data_size > 0) + color(data_color) + for (row = points) + for (pt = row) + translate(pt) sphere(r=data_size, $fn=16); +} + + +// ===================================================================== +// SECTION: Usage Examples +// ===================================================================== +// +// ---- Example 1: CLAMPED (default) ---- +// +// include +// include +// include +// +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// debug_nurbs_interp(data, 3); +// +// +// ---- Example 2: CLOSED (debug view) ---- +// Do NOT repeat the first point at the end. +// +// include +// include +// include +// +// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; +// debug_nurbs_interp(data, 3, closed=true); +// +// +// ---- Example 3: Closed polygon ---- +// All data points should lie exactly on the boundary of the polygon. +// +// include +// include +// include +// +// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; +// path = nurbs_interp_curve(data, 3, splinesteps=16, closed=true); +// polygon(path); +// color("red") move_copies(data) circle(r=0.25, $fn=16); +// +// +// ---- Example 5: Get just the path ---- +// +// include +// include +// include +// +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// path = nurbs_interp_curve(data, 3, splinesteps=32); +// stroke(path, width=0.5); +// color("red") move_copies(data) circle(r=0.25, $fn=16); +// +// +// ---- Example 6: Low-level access ---- +// nurbs_interp() returns a NURBS parameter list that can be passed +// directly to nurbs_curve(), debug_nurbs(), etc. +// +// include +// include +// include +// +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// result = nurbs_interp(data, 3); +// curve = nurbs_curve(result, splinesteps=24); +// stroke(curve, width=0.5); +// +// +// ---- Example 7: 3D closed curve ---- +// +// include +// include +// include +// +// data3d = [[20,0,0],[0,20,10],[-20,0,20],[0,-20,10]]; +// path = nurbs_interp_curve(data3d, 3, splinesteps=32, closed=true); +// stroke(path, width=1, closed=true); +// color("red") move_copies(data3d) sphere(r=0.25, $fn=16); +// +// +// ---- Example 8: Parameterization methods for sharp turns ---- +// "length" (blue), "centripetal" (red), "dynamic" (orange) compared. +// For data with sudden direction changes or uneven chord spacing, +// "centripetal" and "dynamic" reduce unwanted oscillations. +// +// include +// include +// include +// +// sharp = [[0,0], [5,40],[6,40], [10,0], [50,0], [55,40],[56,42], [60,0]]; +// color("blue") stroke(nurbs_interp_curve(sharp, 3), width=0.1); +// color("red") stroke(nurbs_interp_curve(sharp, 3, method="centripetal"), width=0.1); +// color("orange") stroke(nurbs_interp_curve(sharp, 3, method="centripetal"), width=0.1); +// color("green") move_copies(sharp) circle(r=.1, $fn=16); +// +// +// ---- Example 9: Endpoint tangent control ---- +// Specify start and/or end tangent vectors. Each vector is automatically +// scaled by the total chord length; a unit vector produces natural +// arc-length speed. Magnitude > 1 increases pull, < 1 weakens it. +// BOSL2 direction constants (UP, RIGHT, etc.) work for 2D curves. +// +// include +// include +// include +// +// data = [[0,0], [20,30], [50,25], [80,0]]; +// // No tangent control (natural): +// color("gray") stroke(nurbs_interp_curve(data, 3), width=0.3); +// // Tangent: start going straight up, end going straight down: +// color("blue") stroke( +// nurbs_interp_curve(data, 3, start_deriv=[0,1], end_deriv=[0,-1]), +// width=0.3); +// // Tangent: start going right, end going right: +// color("red") stroke( +// nurbs_interp_curve(data, 3, start_deriv=[1,0], end_deriv=[1,0]), +// width=0.3); +// color("black") move_copies(data) circle(r=0.25, $fn=16); +// +// +// ---- Example 10: Start tangent only ---- +// +// include +// include +// include +// +// data = [[0,0], [20,30], [50,25], [80,0]]; +// color("gray") stroke(nurbs_interp_curve(data, 3), width=0.3); +// color("blue") stroke( +// nurbs_interp_curve(data, 3, start_deriv=[0,1]), +// width=0.3); +// color("black") move_copies(data) circle(r=0.25, $fn=16); +// +// +// ===================================================================== +// SECTION: Surface Interpolation Examples +// ===================================================================== +// +// ---- Example 11: Basic surface interpolation ---- +// A 4x5 grid of 3D data points → smooth interpolating surface. +// +// include +// include +// include +// +// data = [ +// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 10], [50, 50, 0], [80, 50, 5]], +// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 30], [50, 16, 20], [80, 16, 10]], +// [[-50,-16, 20], [-16,-16, 35], [ 16,-16, 40], [50,-16, 15], [80,-16, 25]], +// [[-50,-50, 0], [-16,-50, 10], [ 16,-50, 20], [50,-50, 0], [80,-50, 5]], +// ]; +// nurbs_interp_surface(data, 3, splinesteps=8); +// +// +// ---- Example 12: Different degrees per direction ---- +// Quadratic in u, cubic in v. +// +// include +// include +// include +// +// data = [ +// for (u = [-40:20:40]) +// [for (v = [-40:20:40]) +// [v, u, 15*sin(u*3)*cos(v*3)]] +// ]; +// nurbs_interp_surface(data, [2,3], splinesteps=8); +// +// +// ---- Example 13: Surface closed in one direction (tube) ---- +// Closed around the v-direction (the rings), clamped in u (along the +// axis). Uses 5 rings rather than 4: a cubic closed direction needs +// at least p+2 = 5 data rows/columns to have interior knot freedom. +// With only p+1 = 4, the system is solvable but the closed direction +// has no interior flexibility and produces results nearly identical to +// the clamped case. +// +// include +// include +// include +// +// r = 20; +// data = [for (u = [0:15:60]) // 5 rings: u = 0,15,30,45,60 +// [for (i = [0:1:5]) +// let(a = i * 360/6) +// [r*cos(a), r*sin(a), u]] +// ]; +// nurbs_interp_surface(data, 3, splinesteps=8, +// col_wrap=true); +// +// +// ---- Example 14: Surface closed in both directions (torus) ---- +// For ["closed","closed"] to produce a shape visibly different from +// ["clamped","closed"], two conditions must both be met: +// +// 1. ENOUGH POINTS: each direction needs at least p+2 points so the +// periodic system has at least one interior knot with genuine +// freedom. With exactly p+1 points the system is solvable but +// there is no interior flexibility, and the result looks nearly +// identical to the clamped case. +// +// 2. BALANCED PARAMETERIZATION: the data must form an actual closed +// loop in each direction. For chord-length parameterization the +// "closing" segment (last point back to first) is included in the +// parameter budget. If that segment is much longer than the inter- +// point distances the closed direction folds back on itself rather +// than forming a smooth loop. Use evenly-spaced data, or data +// whose first and last points coincide (so the closing chord is +// zero and parameter spacing is uniform). +// +// The canonical example is a torus: both directions sample a full +// 360° circle with even angular spacing, so the closing segment +// equals the inter-point spacing and parameterization is uniform. +// +// include +// include +// include +// +// R = 30; r = 10; // major / minor torus radii +// N = 6; // 6 samples each way (N > p+1 = 4 for cubic) +// data = [for (i = [0:1:N-1]) +// let(phi = i * 360/N) +// [for (j = [0:1:N-1]) +// let(theta = j * 360/N) +// [(R + r*cos(theta))*cos(phi), +// (R + r*cos(theta))*sin(phi), +// r*sin(theta)]] +// ]; +// nurbs_interp_surface(data, 3, splinesteps=12, +// row_wrap=true, col_wrap=true); +// +// +// ---- Example 15: Low-level surface access ---- +// nurbs_interp_surface() returns a NURBS parameter list that can be +// passed directly to nurbs_vnf(). +// +// include +// include +// include +// +// data = [ +// [[-30,30,0], [0,30,20], [30,30,0]], +// [[-30, 0,10],[0, 0,30], [30, 0,10]], +// [[-30,-30,0],[0,-30,15],[30,-30,0]], +// ]; +// result = nurbs_interp_surface(data, 2); +// vnf = nurbs_vnf(result, splinesteps=12); +// vnf_polyhedron(vnf); +// color("red") +// for (row = data) for (pt = row) +// translate(pt) sphere(r=1, $fn=16); From 2c09019edefcb7a77ec9bc6871c60f0d52fcb5fd Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Wed, 29 Apr 2026 15:42:21 -0700 Subject: [PATCH 04/16] Nurbs Interpolation Merge --- .openscad_docsgen_rc | 2 +- nurbs.scad | 3504 ++++++++++++++++++++++++++++++++++++++++ nurbs_interp.scad | 3605 ------------------------------------------ 3 files changed, 3505 insertions(+), 3606 deletions(-) delete mode 100644 nurbs_interp.scad diff --git a/.openscad_docsgen_rc b/.openscad_docsgen_rc index a82af844..695f491e 100644 --- a/.openscad_docsgen_rc +++ b/.openscad_docsgen_rc @@ -1,7 +1,7 @@ DocsDirectory: BOSL2.wiki/ TargetProfile: githubwiki ProjectName: The Belfry OpenScad Library, v2. (BOSL2) -GenerateDocs: Files, TOC, Index, Topics, CheatSheet, Sidebar, Glossary +GenerateDocs: Files, TOC, Index, Topics, CheatSheet, Sidebar SidebarHeader: ## Indices . diff --git a/nurbs.scad b/nurbs.scad index bd446558..030e5038 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -14,6 +14,7 @@ // FileSummary: NURBS and B-spline curves and surfaces. ////////////////////////////////////////////////////////////////////// + _BOSL2_NURBS = is_undef(_BOSL2_STD) && (is_undef(BOSL2_NO_STD_WARNING) || !BOSL2_NO_STD_WARNING) ? echo("Warning: nurbs.scad included without std.scad; dependencies may be missing\nSet BOSL2_NO_STD_WARNING = true to mute this warning.") true : true; @@ -336,6 +337,151 @@ function nurbs_curve(control,degree,splinesteps,u, mult,weights,type="clamped", ] ) reorder ? select(nurbs_pts,reorder[1]) : nurbs_pts; + +// Function: nurbs_elevate_degree() +// Synopsis: Raises the degree of a closed or open NURBS. +// Topics: NURBS Curves +// See Also: nurbs_interp(), nurbs_curve() +// +// Usage: +// result = nurbs_elevate_degree(control, degree, [knots=], [mult=], [type=], [times=], [weights=]); +// result = nurbs_elevate_degree(nurbs_param_list, [times=]); +// +// Description: +// Raises the degree of a "closed" or "open" NURBS by `times` steps, producing +// a geometrically identical curve at the higher degree. Returns a NURBS parameter list +// of the form `[type, degree, control_points, knots, undef, weights]` that can be +// passed directly to {{nurbs_curve()}} and other NURBS functions. The returned `mult` +// parameter is always undef; the returned `weights` will be defined only if you provided +// weights in your input. If you give `times=0` your input parameters are returned unchanged. +// . +// An elevated curve has the same smoothness as the original at each knot. A degree-2 +// curve that is $C^1$ at its knots will still be $C^1$ after elevation to degree 3, +// not $C^2$ as a fresh cubic NURBS with simple knots would be. +// . +// Instead of providing separate parameters you can give a first parameter of the form of a +// NURBS parameter list: `[type, degree, control, knots, mult, weights]`. +// +// Arguments: +// control = Control points, or a NURBS parameter list `[type, degree, ctrl, knots, mult, weights]` +// degree = Degree of NURBS +// --- +// knots = Knot vector. Default: uniform +// mult = List of multiplicities of the knots. Default: all 1 +// type = `"clamped"` or `"open"`. Default: `"clamped"` +// times = Number of degree-elevation steps. Default: `1` +// weights = Weight at each control point + +function nurbs_elevate_degree(control, degree, knots=undef, + type="clamped", times=1, weights=undef, + mult=undef) = + // Accept a NURBS parameter list as the first argument. + is_list(control) && in_list(control[0], ["closed","open","clamped"]) ? + assert(len(control)>=6, "Invalid NURBS parameter list") + assert(num_defined([degree,mult,weights,knots])==0, + "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list") + times == 0 ? control + : nurbs_elevate_degree(control[2], control[1], control[3], + type=control[0], times=times, + weights=control[5], mult=control[4]) + : times == 0 + ? [type, degree, control, knots, mult, weights] + // Rational NURBS: lift to homogeneous space, elevate as a plain B-spline, + // then extract weights from the last coordinate. The recursive call handles + // all asserts, knot normalization, and the times loop. + : !is_undef(weights) + ? assert(len(weights) == len(control), + "nurbs_elevate_degree: weights must have same length as control points") + let( + homo = [for (i = idx(control)) [each control[i]*weights[i],weights[i]]], + r = nurbs_elevate_degree(homo, degree, knots=knots, type=type, times=times, mult=mult), + new_w = [for (pt = r[2]) last(pt)], + new_ctrl = [for (pt = r[2]) slice(pt,0,-2)/last(pt) ] + ) + [r[0], r[1], new_ctrl, r[3], undef, new_w] + // Non-rational B-spline path. + : assert(type == "clamped" || type == "open", + str("nurbs_elevate_degree: type must be \"clamped\" or \"open\", got \"", type, "\"")) + assert(is_num(times) && times >= 1, + "nurbs_elevate_degree: times must be a positive integer") + assert(is_num(degree) && degree >= 1, + "nurbs_elevate_degree: degree must be >= 1") + assert(is_list(control) && len(control) >= 2, + "nurbs_elevate_degree: need at least 2 control points") + assert(is_undef(knots) || is_undef(mult) || len(mult) == len(knots), + str("nurbs_elevate_degree: mult and knots must have the same length; got len(mult)=", + is_undef(mult) ? "undef" : len(mult), + " len(knots)=", + is_undef(knots) ? "undef" : len(knots))) + let( + // Normalize (knots, mult) → internal format for _elevate_once. + // + // clamped: xknots = [k0, interior..., km] — one copy each including endpoints. + // open: xknots = full expanded knot vector (all multiplicities present). + // + // Neither knots nor mult → BOSL2-compatible uniform knots. + // clamped → interior format [0, uniform interior..., 1] + // open → full expanded vector (length n+p+2, uniform) + // + // knots only (no mult): pass through unchanged. + // + // mult only (no knots): uniform positions 0..1 with given multiplicities. + // clamped: endpoint mult forced to degree+1; expand then strip. + // open: full expanded vector. + // + // knots + mult: explicit distinct positions with per-knot multiplicities. + // clamped: endpoint mult forced to degree+1; expand then strip. + // open: full expanded vector. + xknots = + is_undef(knots) && is_undef(mult) + ? ( type == "clamped" ? lerpn(0, 1, len(control) - degree + 1) + : lerpn(0, 1, len(control) + degree + 1) ) + : is_undef(mult) ? knots + : is_undef(knots) + ? let( + m = len(mult), + adj = type == "clamped" && m >= 2 + ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] + : mult, + pos = [for (i = [0:1:m-1]) m == 1 ? 0 : i / (m - 1)], + exp = [for (i = [0:1:m-1]) each repeat(pos[i], adj[i])] + ) + type == "clamped" + ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] + : exp + : let( + m = len(mult), + adj = type == "clamped" && m >= 2 + ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] + : mult, + exp = [for (i = [0:1:m-1]) each repeat(knots[i], adj[i])] + ) + type == "clamped" + ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] + : exp + ) + assert(type != "clamped" || len(xknots) >= 2, + "nurbs_elevate_degree: clamped knots must have at least 2 entries [first,...,last]") + assert(type != "open" || len(xknots) == len(control) + degree + 1, + str("nurbs_elevate_degree: open knots must have length len(control)+degree+1 = ", + len(control) + degree + 1, ", got ", len(xknots))) + let( + // _elevate_once works on the full expanded knot vector. + // Clamped xknots = [k0, interior..., km]; expand to full by adding p copies + // of each endpoint. Open xknots is already full. After elevation, strip the + // p+1 endpoint copies back off for clamped so the output stays in xknots format. + U_full = type == "clamped" + ? concat(repeat(xknots[0], degree), xknots, repeat(last(xknots), degree)) + : xknots, + r = _elevate_once(control, degree, U_full), + new_knots = type == "clamped" + ? slice(r[1], degree+1, -degree-2) + : r[1] + ) + times == 1 + ? [type, r[2], r[0], new_knots, undef, undef] + : nurbs_elevate_degree(r[0], r[2], new_knots, type=type, times=times-1); + function _nurbs_pt(knot, control, u, r, p, k) = @@ -707,3 +853,3361 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k } + +////////////////////////////////////////////////////////////////////// +// +// NURBS curve and surface interpolation through data points. +// Given a set of data points, computes the control points and knot +// vector of a B-spline that passes exactly through every data point. +// Supports clamped curves (open-ended), closed curves (smooth loops), +// and surfaces of both types in each parametric direction. +// Optional per-point derivative and curvature constraints are supported. +// . +// Algorithm from Piegl & Tiller, "The NURBS Book", Chapters 2 & 9. +// +////////////////////////////////////////////////////////////////////// + + +// Internal B-spline Basis Functions + +// Cox-de Boor recursive B-spline basis function N_{i,p}(u). +// Returns 0 for out-of-range indices (safe for periodic evaluation). + +function _nip(i, p, u, U) = + let(maxidx = len(U) - 1) + (i < 0 || i + p + 1 > maxidx) ? 0 + : p == 0 + ? (u >= U[i] && u < U[i+1]) ? 1 + : (abs(u - U[i+1]) < 1e-12 && abs(U[i+1] - U[maxidx]) < 1e-12) ? 1 + : 0 + : let( + d1 = U[i+p] - U[i], + d2 = U[i+p+1] - U[i+1], + c1 = abs(d1) > 1e-15 + ? (u - U[i]) / d1 * _nip(i, p-1, u, U) : 0, + c2 = abs(d2) > 1e-15 + ? (U[i+p+1] - u) / d2 * _nip(i+1, p-1, u, U) : 0 + ) + c1 + c2; + + +// Derivative of B-spline basis N_{j,p}'(u). +// Standard recurrence (P&T §2.3 eq. 2.9); zero-length spans are guarded. + +function _dnip(j, p, u, U) = + p == 0 ? 0 + : let( + d1 = U[j+p] - U[j], + d2 = U[j+p+1] - U[j+1] + ) + (abs(d1) > 1e-15 ? p * _nip(j, p-1, u, U) / d1 : 0) + - (abs(d2) > 1e-15 ? p * _nip(j+1, p-1, u, U) / d2 : 0); + + +// Second derivative of B-spline basis N_{j,p}''(u). +// Same recurrence as _dnip applied once more (P&T §2.3 eq. 2.9); +// zero-length spans are guarded. Returns 0 for p ≤ 1. + +function _d2nip(j, p, u, U) = + p <= 1 ? 0 + : let( + d1 = U[j+p] - U[j], + d2 = U[j+p+1] - U[j+1] + ) + (abs(d1) > 1e-15 ? p * _dnip(j, p-1, u, U) / d1 : 0) + - (abs(d2) > 1e-15 ? p * _dnip(j+1, p-1, u, U) / d2 : 0); + + +// Input Helpers + +// Validate and coerce a single derivative vector to the required dimension. +// +// dim == 2 (special case): +// Accepts a 3D BOSL2 direction constant (UP, DOWN, LEFT, RIGHT, BACK, FWD) +// by projecting it onto the data plane. The vector must lie in the XZ plane +// (Y=0, as UP/DOWN/LEFT/RIGHT/FWD/BACK are defined) or the XY plane (Z=0). +// Underlength inputs (1D) are zero-padded to 2D as in the general case. +// +// All dimensions (dim ≥ 2): +// Any vector shorter than dim is zero-padded to length dim. +// Vectors longer than dim (not handled by the dim=2 special case) error. + +function _force_deriv_dim(deriv, dim) = + dim == 2 && is_vector(deriv, 3) ? + // Special: 3D BOSL2 constant for 2D curve — project onto data plane. + assert(deriv.y == 0 || deriv.z == 0, + "\nDerivative for a 2D interpolation cannot be fully 3D. It must have either Y or Z component equal to zero.") + deriv.y == 0 ? [deriv.x, deriv.z] : point2d(deriv) + : // General: validate length ≤ dim, then zero-pad to exactly dim. + assert(is_vector(deriv) && len(deriv) >= 1 && len(deriv) <= dim, + str("\nDerivative must be a non-empty vector of dimension ", dim, " or less.")) + list_pad(deriv, dim, 0); + + +// Convert a curvature specification to a C''(t) constraint vector. +// +// Under natural-speed parameterization (|C'(t)| = v), curvature κ and +// the second derivative relate by: C''(t) = κ_vec_normal × v². +// Tangential acceleration is set to zero (arc-length parameterization at that point). +// +// curv_spec = signed scalar κ (dim=2), or a vector (any dim including 2D). +// Scalar (dim=2): positive = CCW (left), negative = CW (right). +// Vector: magnitude = |κ|; the perpendicular projection onto +// the plane normal to tang_dir provides the direction only. +// For dim=2 curves, accepts 3D BOSL2 direction constants +// (UP, DOWN, LEFT, RIGHT, etc.) — projected to 2D same as deriv=. +// tang_dir = tangent direction at the point (need not be normalized). +// dim = spatial dimension (len(points[0])). +// v2 = |C'(t)|² at the constrained point. + +function _curv_to_d2(curv_spec, tang_dir, dim, v2) = + let(t_hat = unit(tang_dir)) + (dim == 2 && is_num(curv_spec)) + ? // 2D signed scalar: rotate tangent 90° CCW to get the normal direction. + let(n_hat = [-t_hat[1], t_hat[0]]) + curv_spec * n_hat * v2 + : // Vector form (any dim, including 2D): magnitude from the input vector, + // direction from the perpendicular projection. + // Accepts 3D BOSL2 direction constants (UP, DOWN, etc.) for 2D curves + // via _force_deriv_dim projection, same as derivative constraints. + assert(is_vector(curv_spec) && len(curv_spec) >= 1 && + (len(curv_spec) <= dim || (dim == 2 && len(curv_spec) == 3)), + str("nurbs_interp: curvature constraint must be a signed scalar (2D) or a vector of dimension 1–", dim, + " (3D BOSL2 constants like UP/DOWN accepted for 2D curves)")) + let( + cv = _force_deriv_dim(curv_spec, dim), + mag = norm(cv), + cv_perp = cv - (cv * t_hat) * t_hat, + n_perp = norm(cv_perp) + ) + assert(n_perp > 1e-12, + "nurbs_interp: curvature constraint is parallel to the derivative at the same point — curvature must have a component perpendicular to the tangent direction") + mag * (cv_perp / n_perp) * v2; + + +// Merges start_deriv=/end_deriv= into a per-point list of length n+1. +// When dim is provided each non-undef, non-NaN entry is projected via +// _force_deriv_dim(): BOSL2 3D direction constants (UP, LEFT, …) map to the +// correct 2D or 3D vector, and shorter vectors are zero-padded. +// NaN corner-marker entries (0/0) pass through unchanged. +// Returns undef when no constraint is specified. +function _merge_deriv_list(n, deriv, dim=undef, start_deriv=undef, end_deriv=undef) = + let( + raw = !is_undef(deriv) ? deriv + : (!is_undef(start_deriv) || !is_undef(end_deriv)) + ? [for (k = [0:1:n]) + k == 0 && !is_undef(start_deriv) ? start_deriv + : k == n && !is_undef(end_deriv) ? end_deriv + : undef] + : undef + ) + is_undef(dim) || is_undef(raw) ? raw + : [for (v = raw) is_undef(v) || is_nan(v) ? v : _force_deriv_dim(v, dim)]; + + +// Merges start_curvature=/end_curvature= into a per-point list of length n+1. +// When dim is provided, vector entries are projected via _force_deriv_dim() +// (handles BOSL2 3D direction constants for 2D curves). Signed-scalar entries +// (valid for dim=2) are left as-is; the sign encodes the turn direction. +// Returns undef when no constraint is specified. +function _merge_curv_list(n, curvature, dim=undef, start_curvature=undef, end_curvature=undef) = + let( + raw = !is_undef(curvature) ? curvature + : (!is_undef(start_curvature) || !is_undef(end_curvature)) + ? [for (k = [0:1:n]) + k == 0 && !is_undef(start_curvature) ? start_curvature + : k == n && !is_undef(end_curvature) ? end_curvature + : undef] + : undef + ) + is_undef(dim) || is_undef(raw) ? raw + : [for (v = raw) (is_undef(v) || is_num(v)) ? v : _force_deriv_dim(v, dim)]; + + +// Parameterization + + +// Dynamic centripetal parameterization (Balta et al., IEEE Access 2020 §III). +// Per-chord exponent inversely proportional to ln(chord_length): +// e_i = ln(chordmax/chordi) / ln(chordmax/chordmin) * (emax-emin) + emin +// Long chords get exponent emin=0.35 (compressed contribution). +// Short chords get exponent emax=0.65 (expanded contribution). +// Falls back to e=0.5 (standard centripetal) when all chords are equal. + +function _dynamic_dists(raw, emin=0.35, emax=0.65) = + let( + cmax = max(raw), + cmin = min(raw), + log_r = ln(cmax / cmin) + ) + // Divide each chord by cmin so that d/cmin ≥ 1 for every chord. + // This is required for correctness: pow(x, e) is an increasing function + // of e only when x > 1, so d > 1 ensures that the longer chords (with + // smaller exponent emin) are correctly compressed relative to shorter + // chords (with larger exponent emax). Normalizing by cmin also makes + // the result scale-invariant: λd/λcmin = d/cmin for any scale factor λ. + log_r < 1e-12 + ? [for (d = raw) sqrt(d / cmin)] // equal chords → uniform spacing + : [for (d = raw) + let(e = ln(cmax / d) / log_r * (emax - emin) + emin) + pow(d / cmin, e) + ]; + + + +// Foley-Neilson parameterization (Foley & Neilson 1987). +// Centripetal base with deflection-angle correction at each vertex. +function _foley_dists(points, closed) = + let( + n = len(points), + c = path_segment_lengths(points, closed=closed), + nc = len(c), + // Centripetal base: sqrt of each chord length. + d = [for (ci = c) sqrt(ci)], + // θ̂[i] = min(deflection angle at P[i], π/2) in radians. + // Deflection angle = 180° − interior angle at P[i]. + // Endpoints of an open curve contribute zero correction. + theta_hat = [for (i = [0:1:n-1]) + !closed && (i == 0 || i == n-1) ? 0 + : let(phi_deg = 180 - vector_angle(select(points, i-1, i+1))) + min(phi_deg * PI/180, PI/2) + ] + ) + [for (i = [0:1:nc-1]) + let( + di = d[i], + d_prev = d[(i - 1 + nc) % nc], + d_next = d[(i + 1) % nc], + th_L = theta_hat[i], + th_R = theta_hat[(i + 1) % n], + left = 3 * th_L * d_prev / (2 * (d_prev + di)), + right = 3 * th_R * d_next / (2 * (di + d_next)) + ) + di * (1 + left + right) + ]; + + +// Fang improved centripetal parameterization (Fang & Hung, CAD 2013, Eq. 10). +// Centripetal base + osculating-circle dragging tolerance (α = 0.1). +// At each interior point Pᵢ, eᵢ = α·(θᵢ·ℓᵢ/(2·sin(θᵢ/2)) + θᵢ₋₁·ℓᵢ₋₁/(2·sin(θᵢ₋₁/2))) +// where θᵢ is deflection angle at Pᵢ, ℓᵢ is shortest side of triangle Pᵢ₋₁PᵢPᵢ₊₁. +// Each chord increment is Δᵢ = √‖Lᵢ‖ + eᵢ + eᵢ₊₁ (corrections from both endpoints). + +function _fang_correction(points, closed) = + let(n = len(points)) + [for (i = [0:1:n-1]) + !closed && (i == 0 || i == n-1) ? 0 + : let( + tri = select(points, i-1, i+1), + ell = min(path_segment_lengths(tri, closed=true)), + theta_deg = 180 - vector_angle(select(points, i-1, i+1)) + ) + // θ·ℓ/(2·sin(θ/2)); limit as θ→0 is ℓ. + 0.1 * (abs(theta_deg) < 1e-6 ? ell + : theta_deg * PI/180 * ell / (2 * sin(theta_deg / 2))) + ]; + +function _fang_dists(points, closed) = + let( + c = path_segment_lengths(points, closed=closed), + nc = len(c), + ef = _fang_correction(points, closed) + ) + [for (i = [0:1:nc-1]) + sqrt(c[i]) + ef[i] + select(ef, i+1) + ]; + + +// Chord-length, centripetal, dynamic, Foley, or Fang parameterization. +// clamped: n+1 points -> n+1 values in [0, 1] with t_0=0, t_n=1. +// closed: n points -> n values in [0, 1) with t_0=0. +// method: "length" = chord-length +// "centripetal" = sqrt exponent (Lee 1989) +// "dynamic" = per-chord dynamic exponent (Balta et al. 2020) +// "foley" = centripetal + deflection-angle correction (Foley & Neilson 1987) +// "fang" = centripetal + osculating-circle correction (Fang & Hung 2013) + +function _interp_params(points, method="centripetal", closed=false) = + let( + raw = path_segment_lengths(points, closed=closed), + n = len(raw), + total_raw = sum(raw) + ) + // Degenerate: all points identical (e.g. a surface pole row/column). + // Return uniform spacing so surface parameter averages stay valid. + total_raw < 1e-10 + ? (closed + ? [for (i = [0:1:n-1]) i / n] + : [for (i = [0:1:n ]) i / n]) + : assert(min(raw) > 1e-10, + "nurbs_interp: consecutive duplicate data points detected") + let( + dists = method == "centripetal" ? [for (d = raw) sqrt(d)] + : method == "dynamic" ? _dynamic_dists(raw) + : method == "foley" ? _foley_dists(points, closed) + : method == "fang" ? _fang_dists(points, closed) + : raw, + total = sum(dists), + cs = cumsum(dists) + ) + closed ? [0, each [for (x = list_head(cs)) x / total]] + : [0, each [for (x = list_head(cs)) x / total], 1]; + + +// Knot Vector Construction + +// Interior knots by averaging (Piegl & Tiller eq 9.8). + +function _avg_knots_interior(params, p) = + let( + n = len(params) - 1, + num_internal = n - p + ) + num_internal <= 0 + ? [] + : [for (j = [1:1:num_internal]) + sum([for (i = [j :1: j + p - 1]) params[i]]) / p + ]; + + +// Full clamped knot vector: (p+1) zeros, interior, (p+1) ones. + +function _full_clamped_knots(interior_knots, p) = + concat(repeat(0, p+1), interior_knots, repeat(1, p+1)); + + +// Periodic "bar knots" for closed B-splines. +// +// Returns [bar_knots, shifted_params] where bar_knots is n+1 +// monotonically increasing values with bar[0]=0, bar[n]=1, and +// shifted_params are the parameter values shifted to match. +// +// The raw bar knots are computed by averaging p consecutive values +// from the extended periodic parameter sequence t_m = params[m%n] + +// floor(m/n). This is guaranteed monotonic. We then shift so +// bar[0]=0, and shift params by the same amount. + +function _avg_knots_periodic(params, p) = + let( + n = len(params), + raw = [for (j = [0:1:n]) + sum([for (k = [0:1:p-1]) + let(m = j + k) + params[m % n] + floor(m / n) + ]) / p + ], + shift = raw[0], + bar_knots = add_scalar(raw, -shift), + shifted = [for (t = params) + let(s = t - shift) + s < 0 ? s + 1 : (s >= 1 ? s - 1 : s)] + ) + [bar_knots, shifted]; + + +// Repair degenerate periodic bar knots: if any span is smaller than +// eps × period, merge it into its neighbor and bisect the resulting +// larger span. Preserves the knot count (n+1 entries, n spans) and +// the endpoint values bar[0]=0, bar[n]=period. Recurses until no +// tiny spans remain. + +function _fix_tiny_spans(bar_knots, n, eps=1e-6) = + let( + T = bar_knots[n], + spans = [for (k = [0:1:n-1]) bar_knots[k+1] - bar_knots[k]], + min_span = min(spans) + ) + min_span >= eps * T ? bar_knots + : let( + k = min_index(spans), + // Remove an interior knot bounding the tiny span. + // For span 0 (first span), remove knot 1 and absorb into span 1. + // For span n-1 (last span), remove knot n-1 and absorb into span n-2. + // Otherwise, remove knot k+1 and absorb into the merged span at k. + remove_idx = k == 0 ? 1 + : k == n - 1 ? n - 1 + : k + 1, + merged = [for (i = [0:1:n]) if (i != remove_idx) bar_knots[i]], + absorb_k = k == 0 ? 0 : k - 1, + // Bisect the absorbing span to restore the knot count. + mid = (merged[absorb_k] + merged[absorb_k + 1]) / 2, + fixed = [for (i = [0:1:n-1]) // n entries in merged + each (i == absorb_k ? [merged[i], mid] : [merged[i]])] + ) + _fix_tiny_spans(fixed, n, eps); + + +// Insert extra knots into a base bar_knots vector, one per +// constraint parameter. For each constraint, finds the span +// containing its parameter value and inserts at the span midpoint. +// When multiple constraints compete, the one whose containing span +// is largest is processed first — this avoids splitting a small +// span when a larger one is available. Each insertion updates the +// knot vector before the next constraint is processed. +// +// bar_knots: base bar_knots from periodic or interior averaging. +// constraint_ts: list of parameter values identifying which span +// to split. For closed: raw params in [0,1). +// For clamped: params in [0,1]. +// +// Returns the augmented bar_knots with len(constraint_ts) extra entries. + +function _insert_constraint_knots(bar_knots, constraint_ts) = + len(constraint_ts) == 0 ? bar_knots + : let( + n = len(bar_knots), + // For each constraint, find its containing span and that span's width. + spans = [for (ci = [0:1:len(constraint_ts)-1]) + let( + t = constraint_ts[ci], + pos = [for (i = [0:1:n-2]) + if (bar_knots[i] <= t && t < bar_knots[i+1]) i], + idx = len(pos) > 0 ? pos[0] : n - 2, + w = bar_knots[idx+1] - bar_knots[idx] + ) + [ci, idx, w] + ], + // Pick the constraint whose span is largest. + best = max_index([for (s = spans) s[2]]), + ci = spans[best][0], + idx = spans[best][1], + mid = (bar_knots[idx] + bar_knots[idx+1]) / 2, + new_knots = [each [for (i = [0:1:idx]) bar_knots[i]], mid, + each [for (i = [idx+1:1:n-1]) bar_knots[i]]], + remaining = [for (i = [0:1:len(constraint_ts)-1]) + if (i != ci) constraint_ts[i]] + ) + _insert_constraint_knots(new_knots, remaining); + + +// Return k parameter values, each at the midpoint of one of the k +// widest spans in bar_knots. Used to target extra knot insertions +// and smoothness rows at the most under-resolved regions. +// +// When all k picks come from equal-width spans (the common case for +// uniformly-parameterized closed curves), spans are chosen at centred- +// stratified indices floor((2g+1)*n/(2*k_eff)) % n for g=0..k_eff-1. +// This places each pick at the centre of its equal-width quantile +// rather than at the quantile boundary. For n=18, k=4 the picks +// are spans 2, 6, 11, 15 instead of 0, 4, 9, 13. +// +// Centering is essential for closed curves: _extend_knot_vector wraps +// span widths across the seam (span n-1 into the pre-region, span 0 +// into the post-region). If an extra knot is inserted in span 0, the +// span width at the start of aug_bar differs from the width at the end, +// making the basis functions slightly asymmetric at the seam and +// causing a visible fold in the null-space solution. Centering keeps +// both boundary spans at their original (uniform) width. +// When the k widest spans are not all equal, the standard widest-first +// selection is used (knot insertion targets the most under-resolved +// regions regardless of position). + +function _widest_span_params(bar_knots, k) = + let( + n = len(bar_knots) - 1, + k_eff = min(k, n), + _echo = k > n ? echo(str("nurbs_interp: extra_pts=", k, + " exceeds the number of available knot spans (", n, + "); reduced to ", n, ".")) : 0, + spans = [for (i = [0:1:n-1]) bar_knots[i+1] - bar_knots[i]], + w_max = max(spans), + // Indices of spans at the maximum width (within floating-point tolerance). + // Stratification picks only from these so that constraint-narrowed spans + // (e.g. from _insert_constraint_knots) are never accidentally chosen. + eq_idxs = [for (i = [0:1:n-1]) if (abs(spans[i] - w_max) < 1e-10 * w_max) i], + n_eq = len(eq_idxs) + ) + // If all k_eff picks come from equal-width spans, use centred stratification + // over eq_idxs so that constraint-narrowed spans are never selected. + n_eq >= k_eff + ? [for (g = [0:1:k_eff-1]) + let(i = eq_idxs[floor((2 * g + 1) * n_eq / (2 * k_eff))]) + (bar_knots[i] + bar_knots[i+1]) / 2 + ] + // Otherwise use widest-first selection (non-uniform spans). + : let( + sorted = sort([for (i = [0:1:n-1]) [spans[i], i]]), + top_k = [for (i = [n-1:-1:n-k_eff]) sorted[i]] + ) + [for (s = top_k) (bar_knots[s[1]] + bar_knots[s[1]+1]) / 2]; + + +// Find knot spans containing multiple data parameters and return +// splitting midpoints. Two data points in the same span cause a +// rank-deficient collocation matrix; inserting a knot between them +// restores full rank. +// +// bar_knots: sorted knot vector with n_spans+1 entries. +// params: sorted or unsorted data parameter values. +// +// Returns a list of splitting parameter values — one midpoint between +// each consecutive pair of params that share a span. + +function _span_split_params(bar_knots, params) = + let( + n_spans = len(bar_knots) - 1, + sorted = sort(params), + n_p = len(sorted), + // For each sorted param, find its span index. + span_of = [for (t = sorted) + let(pos = [for (i = [0:1:n_spans-1]) + if (t >= bar_knots[i] && + (i < n_spans-1 ? t < bar_knots[i+1] + : t <= bar_knots[i+1])) i]) + len(pos) > 0 ? pos[0] : n_spans - 1 + ] + ) + // Midpoints between consecutive sorted params sharing a span. + [for (i = [0:1:n_p-2]) + if (span_of[i] == span_of[i+1]) + (sorted[i] + sorted[i+1]) / 2 + ]; + + +// Build one row of the L^T*L matrix for control-polygon regularization. +// order=1: first-difference penalty (penalizes polygon length/variation). +// order=2: second-difference penalty (penalizes polygon bending). +// periodic=true wraps the differences around for closed curves. +// +// For clamped (non-periodic): +// order=1 L^T*L: tridiag [1,-1,0..] [-1,2,-1,0..] .. [0..,-1,1] +// order=2 L^T*L: pentadiag boundary-adapted +// For closed (periodic): +// order=1 L^T*L: circulant [2,-1,0..0,-1] +// order=2 L^T*L: circulant [6,-4,1,0..0,1,-4] + +function _ltl_row(M, i, order, periodic=false) = + periodic + ? (order == 1 + ? [for (j = [0:1:M-1]) + j == i ? 2 + : j == (i+1)%M || j == (i-1+M)%M ? -1 + : 0] + : // order == 2 + [for (j = [0:1:M-1]) + j == i ? 6 + : j == (i+1)%M || j == (i-1+M)%M ? -4 + : j == (i+2)%M || j == (i-2+M)%M ? 1 + : 0]) + : // clamped (non-periodic) + (order == 1 + ? [for (j = [0:1:M-1]) + j == i ? (i == 0 || i == M-1 ? 1 : 2) + : (j == i+1 || j == i-1) ? -1 + : 0] + : // order == 2, L is (M-2)×M second-difference matrix. + // (L^T L)[i][j] = sum_{r=0}^{M-3} L[r][i]*L[r][j] + // where L[r][c] = (c==r ? 1 : c==r+1 ? -2 : c==r+2 ? 1 : 0). + // Nonzero only when |i-j| <= 2. + [for (j = [0:1:M-1]) + abs(i-j) > 2 ? 0 + : i == j + ? (i <= M-3 ? 1 : 0) // r=i: 1² + + (i >= 1 && i <= M-2 ? 4 : 0) // r=i-1: (-2)² + + (i >= 2 ? 1 : 0) // r=i-2: 1² + : abs(i-j) == 1 + ? let(lo = min(i,j)) + (lo <= M-3 ? -2 : 0) // r=lo: (1)(-2) + + (lo >= 1 && lo <= M-2 ? -2 : 0) // r=lo-1: (-2)(1) + : // abs(i-j) == 2 + (min(i,j) <= M-3 ? 1 : 0) // r=min: (1)(1) + ]); + + +// Solve the constrained optimization min P^T·R·P s.t. A·P = rhs +// via null-space method. +// +// R = M×M regularization matrix (positive semidefinite). +// A = N×M constraint matrix (interpolation + derivative + curvature). +// rhs = N×dim right-hand side (data points + constraint vectors). +// +// Algorithm +// 1. Step A — minimum-norm particular solution x_p satisfying A·x_p = rhs +// exactly, via BOSL2 linear_solve() (handles underdetermined systems). +// 2. Step B — minimize x^T·R·x in the null space of A (if M > N): +// Q2 = null_space(A) basis vectors (returned as rows by BOSL2) +// H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) +// Solve H · z = -Q2^T · R_pd · x_p via Cholesky +// P = x_p + Q2 · z +// +// Returns list of M control points, or undef on rank-deficient A. + +function _nullspace_solve(R, A, rhs, eps=1e-6) = + let( + M = len(R), + N_rows = len(A), + // Step A: minimum-norm particular solution via BOSL2. + // linear_solve handles underdetermined (M > N_rows) systems + // by returning the minimum-norm solution via QR of A^T. + x_p = linear_solve(A, rhs) + ) + x_p == [] ? undef + : M == N_rows ? x_p // Square: unique solution, no null space. + : let( + // Step B: minimize x^T·R·x in the null space. + // null_space() returns null-space vectors as rows. + ns = null_space(A), + n_ns = len(ns) + ) + n_ns == 0 ? x_p // Full rank despite M > N; no null space. + : let( + Q2 = transpose(ns), // M × n_ns (columns are basis vectors) + // Regularize R for strict positive-definiteness. + R_pd = [for (i = [0:1:M-1]) + [for (j = [0:1:M-1]) + R[i][j] + (i == j ? eps : 0)]], + // H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) + // Symmetrize to counteract floating-point round-off. + RQ2 = R_pd * Q2, + H_raw = transpose(Q2) * RQ2, + H = (H_raw + transpose(H_raw)) / 2, + // g = Q2^T · R_pd · x_p (n_ns × dim) + g = transpose(Q2) * (R_pd * x_p), + // Solve H · z = -g (H is SPD → Cholesky is fastest) + z = linear_solve(H, -g, method="cholesky") + ) + // If H solve fails (degenerate), x_p alone still satisfies constraints. + z == [] ? x_p + : x_p + Q2 * z; + + +// Gauss-Legendre quadrature nodes and weights on [-1,1]. +// Returns [[nodes], [weights]] for n-point rule (n = 2..5). +// Exact for polynomials up to degree 2n-1. + +function _gauss_legendre(n) = + n == 2 ? [[-0.5773502691896258, 0.5773502691896258], + [1.0, 1.0]] + : n == 3 ? [[-0.7745966692414834, 0.0, 0.7745966692414834], + [0.5555555555555556, 0.8888888888888888, 0.5555555555555556]] + : n == 4 ? [[-0.8611363115940526, -0.3399810435848563, + 0.3399810435848563, 0.8611363115940526], + [0.3478548451374538, 0.6521451548625461, + 0.6521451548625461, 0.3478548451374538]] + : // n >= 5 + [[-0.9061798459386640, -0.5384693101056831, 0.0, + 0.5384693101056831, 0.9061798459386640], + [0.2369268850561891, 0.4786286704993665, 0.5688888888888889, + 0.4786286704993665, 0.2369268850561891]]; + + +// One step of the de Boor recurrence: lifts degree-(k-1) to degree-k basis values +// at parameter t in span s of U. +// b_prev[lj] = N_{s-(k-1)+lj, k-1}(t) for lj = 0..k-1 (k entries) +// Returns b[lj] = N_{s-k+lj, k}(t) for lj = 0..k (k+1 entries) + +function _deboor_step(b_prev, k, s, t, U) = + [for (lj = [0:1:k]) + let( + j = s - k + lj, + e1 = U[s + lj] - U[j], // U[j+k] - U[j] + e2 = U[s + lj + 1] - U[j + 1] // U[j+k+1] - U[j+1] + ) + (lj > 0 && abs(e1) > 1e-15 ? (t - U[j]) / e1 * b_prev[lj - 1] : 0) + + (lj < k && abs(e2) > 1e-15 ? (U[s+lj+1] - t) / e2 * b_prev[lj] : 0) + ]; + + +// Returns the (k+1)-element vector of non-zero degree-k basis values at t in span s: +// b[lj] = N_{s-k+lj, k}(t) for lj = 0..k. + +function _deboor_to_degree(s, k, t, U) = + k == 0 ? [1] + : _deboor_step(_deboor_to_degree(s, k - 1, t, U), k, s, t, U); + + +// Returns the (p+1)-element vector of non-zero degree-p second-derivative values +// at parameter t, which lies in knot span s of U. +// d2[lj] = N''_{s-p+lj, p}(t) for lj = 0..p. +// Uses the de Boor triangle to degree p-2, then lifts twice via the derivative +// recurrence (P&T §2.3 eq. 2.9): O(p²) work instead of M separate _d2nip() calls. + +function _d2nip_span(s, p, t, U) = + p <= 1 + ? [for (lj = [0:1:p]) 0] + : let( + // Degree-(p-2) basis: b2[lj] = N_{s-(p-2)+lj, p-2}(t) for lj = 0..p-2. + b2 = _deboor_to_degree(s, p - 2, t, U), + + // First lift: d1[lj] = N'_{s-(p-1)+lj, p-1}(t) for lj = 0..p-1. + // N'_{j,p-1} = (p-1)/(U[j+p-1]-U[j])*N_{j,p-2} - (p-1)/(U[j+p]-U[j+1])*N_{j+1,p-2} + // with N_{j,p-2} = b2[lj-1] and N_{j+1,p-2} = b2[lj]. + q1 = p - 1, + d1 = [for (lj = [0:1:q1]) + let( + j = s - q1 + lj, + e1 = U[s + lj] - U[j], // U[j+q1] - U[j] + e2 = U[s + lj + 1] - U[j + 1] // U[j+q1+1] - U[j+1] + ) + (lj > 0 && abs(e1) > 1e-15 ? q1 * b2[lj - 1] / e1 : 0) + - (lj < q1 && abs(e2) > 1e-15 ? q1 * b2[lj] / e2 : 0) + ], + + // Second lift: d2[lj] = N''_{s-p+lj, p}(t) for lj = 0..p. + // N''_{j,p} = p/(U[j+p]-U[j])*N'_{j,p-1} - p/(U[j+p+1]-U[j+1])*N'_{j+1,p-1} + // with N'_{j,p-1} = d1[lj-1] and N'_{j+1,p-1} = d1[lj]. + d2 = [for (lj = [0:1:p]) + let( + j = s - p + lj, + e1 = U[s + lj] - U[j], // U[j+p] - U[j] + e2 = U[s + lj + 1] - U[j + 1] // U[j+p+1] - U[j+1] + ) + (lj > 0 && abs(e1) > 1e-15 ? p * d1[lj - 1] / e1 : 0) + - (lj < p && abs(e2) > 1e-15 ? p * d1[lj] / e2 : 0) + ] + ) + d2; + + +// Bending-energy regularization matrix R for the null-space solver. +// R[j][k] = ∫ B''_j(t) B''_k(t) dt (integrated squared second derivative). +// For clamped: B_j = N_{j,p}, integrated over the full domain. +// For closed/periodic: B_j = N_j + (j

p → 0 for clamped; circular +// distance > p → 0 for periodic) with per-span second derivatives supplied by +// _d2nip_span: O(p²) per quadrature point instead of O(M·p²) with individual +// _d2nip() calls. + +function _bending_energy_matrix(M, p, U_full, periodic=false) = + let( + n_gauss = max(2, p - 1), + gl = _gauss_legendre(n_gauss), + gl_nodes = gl[0], + gl_wts = gl[1], + n_knots = len(U_full), + span_lo = periodic ? p : 0, + span_hi = periodic ? M + p - 1 : n_knots - 2, + + // Per-quadrature-point data: [span_index, weight, d2_local]. + // d2_local[lj] = N''_{s-p+lj, p}(t) for lj = 0..p (p+1 unaliased values). + quad_data = [for (i = [span_lo:1:span_hi]) + if (U_full[i+1] - U_full[i] > 1e-15) + let(a = U_full[i], b = U_full[i+1], + hw = (b - a) / 2, mid = (a + b) / 2) + for (g = [0:1:n_gauss-1]) + let(t = mid + hw * gl_nodes[g], + w = gl_wts[g] * hw) + [i, w, _d2nip_span(i, p, t, U_full)] + ], + nq = len(quad_data) + ) + // Banded assembly: skip entries where j and k have no overlapping support. + // Clamped: zero when |j-k| > p. + // Periodic: zero when circular distance min(|j-k|, M-|j-k|) > p. + [for (j = [0:1:M-1]) + [for (k = [0:1:M-1]) + (periodic ? min(abs(j - k), M - abs(j - k)) > p : abs(j - k) > p) + ? 0 + : sum([for (q = [0:1:nq-1]) + let( + s = quad_data[q][0], + w = quad_data[q][1], + d2v = quad_data[q][2], + // Local indices of global bases j and k in this span. + lj = j - (s - p), + lk = k - (s - p), + // Periodic aliasing: unaliased index j+M (resp. k+M) + // may also land in the support [s-p, s] of this span. + lj_a = periodic ? j + M - (s - p) : -1, + lk_a = periodic ? k + M - (s - p) : -1, + // Direct values (unaliased index in support of span s). + vj = (lj >= 0 && lj <= p) ? d2v[lj] : 0, + vk = (lk >= 0 && lk <= p) ? d2v[lk] : 0, + // Aliased values (only for j < p with j+M in support). + vj_a = (periodic && j < p && lj_a >= 0 && lj_a <= p) ? d2v[lj_a] : 0, + vk_a = (periodic && k < p && lk_a >= 0 && lk_a <= p) ? d2v[lk_a] : 0, + Bj = vj + vj_a, + Bk = vk + vk_a + ) + w * Bj * Bk + ]) + ] + ]; + + +// Regularization matrix dispatcher. +// Returns an M×M regularization matrix: L^T L difference matrix when smooth<=2, +// integrated squared second-derivative (bending energy) matrix otherwise. + +function _regularization_matrix(M, smooth, p, U_full, periodic=false) = + smooth <= 2 + ? [for (i = [0:1:M-1]) _ltl_row(M, i, smooth, periodic=periodic)] + : _bending_energy_matrix(M, p, U_full, periodic=periodic); + + +// Full periodic knot vector for "closed" type evaluation. +// Uses BOSL2's _extend_knot_vector() to build the n+2p+1 entry knot vector +// that nurbs_curve() constructs internally for closed-type curves. +// Active evaluation domain: [U[p], U[n+p]]. + +function _full_closed_knots(bar_knots, n, p) = + _extend_knot_vector(bar_knots, 0, n + 2*p + 1); + + +// Collocation Matrices + +// Standard collocation matrix for clamped type. + +function _collocation_matrix(params, n, p, U) = + [for (k = [0:1:n]) + [for (j = [0:1:n]) + _nip(j, p, params[k], U) + ] + ]; + + +// Periodic collocation matrix for closed type (n x n). +// +// BOSL2 wraps the first p control points to the end, creating n+p +// basis functions. Basis N_{j+n} aliases control point j for j= p + +function _collocation_matrix_periodic(params, n, p, U_periodic) = + [for (k = [0:1:n-1]) + [for (j = [0:1:n-1]) + _nip(j, p, params[k], U_periodic) + + (j < p ? _nip(j + n, p, params[k], U_periodic) : 0) + ] + ]; + + +// Degree Elevation + +// Greville abscissae for B-spline basis of degree p with full knot +// vector U. Returns n+1 values where n = len(U) - p - 2. Each g_i +// is the average of knots U[i+1] .. U[i+p]. For a clamped knot +// vector, g_0 = 0 and g_n = 1. These are optimal collocation sites +// for the B-spline space and automatically satisfy the Schoenberg- +// Whitney condition for non-singular collocation. + +function _greville(U, p) = + let(n = len(U) - p - 2) + [for (i = [0:1:n]) + sum([for (j = [i+1:1:i+p]) U[j]]) / p + ]; + + +// Increment the multiplicity of every distinct value in a knot vector +// by 1. Walk the vector; at the end of each run of equal values emit +// one extra copy. Equivalent to the new_interior construction in +// _elevate_once_clamped but applied to the complete (full) knot vector. +// Used by _elevate_once_open. + +function _increment_knot_mults(U) = + [for (i = [0:1:len(U)-1]) each + [U[i], + if (i == len(U)-1 || abs(U[i+1] - U[i]) > 1e-14) U[i]] + ]; + + +// Single degree elevation of a clamped or open B-spline via exact collocation. +// +// The elevated curve lies in the degree-(p+1) B-spline space whose knot +// vector has each distinct value's multiplicity incremented by 1. +// Evaluating the original curve at the Greville abscissae of the new basis +// and solving the collocation system recovers the exact elevated control +// points (the new space contains the original curve exactly). +// +// Input ctrl = control points (any dimension >= 1) +// p = current degree (>= 1) +// U = full expanded knot vector (all multiplicities present) +// Output [new_ctrl, U_new, p+1] +// U_new is the full expanded elevated knot vector. + +function _elevate_once(ctrl, p, U) = + let( + n_old = len(ctrl) - 1, + dim = len(ctrl[0]), + p_new = p + 1, + U_new = _increment_knot_mults(U), + n_new = len(U_new) - p_new - 2, + grev = _greville(U_new, p_new), + C_vals = [for (u = grev) + let(row = [for (j = [0:1:n_old]) _nip(j, p, u, U)]) + [for (d = [0:1:dim-1]) + sum([for (j = [0:1:n_old]) row[j] * ctrl[j][d]])] + ], + A = [for (k = [0:1:n_new]) + [for (i = [0:1:n_new]) _nip(i, p_new, grev[k], U_new)] + ], + Q = linear_solve(A, C_vals) + ) + assert(Q != [], + "nurbs_elevate_degree: singular collocation (should not happen)") + [Q, U_new, p_new]; + + + + +// Section: NURBS Interpolation + + +// Function: nurbs_interp() +// Synopsis: Finds a NURBS curve passing through a point list with optional derivative constraints. +// Topics: NURBS Curves, Interpolation +// See Also: nurbs_curve(), debug_nurbs(), debug_nurbs_interp() +// +// Usage: +// nurbs_param = nurbs_interp(points, degree, [method=], [closed=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [deriv=], [extra_pts=], [smooth=]); +// +// Description: +// Given a list of data points and a NURBS degree, computes a curve of the specified degree +// that passes exactly through every data point. The computed curve always has +// uniform weights, but irregularly spaced knots, so it is actually a non-uniform B-spline. +// Data points may 2D or any higher dimension. Returns a NURBS parameter list of the form +// `[type, degree, control_points, knots, undef, undef, u]` that can be +// passed directly to {{nurbs_curve()}} and other NURBS functions. The extra return value `u`, +// described in detail below, enables you to locate your input points in the computed spline +// . +// When `closed=false` (the default) the output is a "clamped" NURBS. +// When `closed=true`, the interpolation treats the data points as a loop and produces a +// curve that is smooth at the closing point. The output will be a "closed" NURBS (unless you +// specify corners as described below). +// If you instead duplicate the closing point and set `closed=false` then the +// result will have a corner at the closing point. +// . +// **Parameterization** (`method=`) +// . +// In order to solve the interpolation problem, the algorithm first chooses +// the NURBS parameter value `u[k]` that will correspond to each `points[k]`. +// This parametrization step significantly affects the shape of the output curve, particularly when the +// data points are not evenly spaced. The following methods are supported: +// . +// - `"length"` — Base parameters values on the chord length, which is distance between the consecutive data points. +// Best when data points are fairly evenly spaced. +// - `"centripetal"` (default) — Base parameters values on the square root of the chord length. (Lee 1989). +// - `"dynamic"` — like centripetal, but the exponent 0.5 is replaced +// by a per-chord value chosen based on local spacing variation. Long chords +// get a smaller exponent and short chords a larger one, compressing the +// influence of outliers. Chord lengths are normalized, which makes the method scale +// invariant and prevents misbehavior at extreme scales. Scaling is not given in the original reference. (Balta et al. 2020). +// - `"foley"` — centripetal base, augmented by corrections at each point that +// are proportional to the local turn angle. Sharp bends pull parameter values +// closer together, which tends to reduce overshoot at corners (Foley & Neilson 1987). +// - `"fang"` — centripetal base, augmented by a correction based on the radius +// of the osculating circle at each point. Said to handles mixed straight-and-curved +// segments particularly well. This method is NOT scale invariant, so results will +// change if you scale your input data. (Fang & Hung 2013). +// . +// The other required input to the interpolation is the location of the knots. +// We place knots using a moving average of `degree` consecutive parameter values, which links +// the knots to the local parameter spacing. A consequence of this process for selection +// of the parameters and knot locations is that even if your input data has symmetry it is +// likely that the symmetry will be broken in the output. For closed curves, another +// consequence is that the resulting curve will depend on which point is chosen as the +// starting point for the interpolation. The algorithm chooses a starting point +// that is expected to provide the best behaved interpolation curve. Examining the +// knot positions with {{debug_nurbs_interp()}} may help you understand unexpected behavior +// you observe in the output. If your curve does not +// behave as desired you may be able to adjust it by imposing additional constraints or +// by giving it more freedom using `extra_pts`. +// . +// **Derivative constraints** (`deriv=`, `start_deriv=`, `end_deriv=`) +// . +// `deriv[k]` specifies the tangent direction and speed the curve must have +// as it passes through `points[k]`. The length of `deriv[k]` gives the speed +// as a multiple of `path_length(points)` which means a unit vector gives a natural +// speed that is a good starting point. +// The speed has a big effect on the shape of the curve, so if the local shape is +// not as you desire you should try increasing it, which will make the curve around +// the point flatter or decreasing it, which will make the curve more pointy. +// Set `deriv[k] = undef` to leave point `k` unconstrained. +// If you only want to set the derivative at the ends of a "clamped" curve you can use +// `start_deriv=` and `end_deriv=`, which set +// `deriv[0]` and `last(deriv)` without the need to provide a list of undefs for all the interior points. +// . +// **Curvature constraints** (`curvature=`, `start_curvature=`, `end_curvature=`) +// . +// The curvature at a point measures how tightly a curve bends. +// When a point has curvature $\kappa$ then a circle with radius $1/\kappa$ +// locally matches the curve at that point so both its first and second derivatives agree. +// This matched circle is called the osculating circle. When you set `curvature[k]` this +// constrains the curvature at `points[k]`. Every curvature-constrained point **must** also have a derivative constraint +// at the same index. Curvature constraints require a degree of at least 2. +// . +// In general curvature constraints require the curvature **vector**, which +// points in the direction of the osculating circle and has length equal to the curvature. +// The curvature vector must be orthogonal to the tangent vector at the point; +// when you specify a curvature vector any component parallel to the tangent is removed. +// The magnitude of the curvature is taken as the magnitude of your original input vector, +// even if subtracting the tangent component changes its length. +// For 2D curves you can also provide curvature as a scalar, with the sign indicating direction. +// (positive = left/CCW, negative = right/CW). +// . +// You can specify the curvature at the ends of "clamped" curves using +// `start_curvature=` and `end_curvature=`, which specify `curvature[0]` +// and `last(curvature)` without the need to create undefs for all the interior points. +// . +// **Corners** (`corners=`) +// . +// `corners=` is a list of interior point indices where the curve has +// a corner, a discontinuity in the derivative. You can also specify a corner +// at point `k` by setting `deriv[k]=NAN`. When you request corners, the +// algorithm chops up the input data into separate clamped splines that run from corner +// to corner. When `closed=true` this results in a "clamped" output spline, and the curve +// will start at one of your corner points. +// If you place corners close together, the effective degree of the short segment +// in between the corners may be reduced. These curve sections are assembled into a single +// NURBS so this process is transparent to the user. A limitation is that you cannot control +// the dervatives of the two segments that meet at a corner. If you need to do this you +// must construct your own sequence of clamped interpolations. +// . +// **Extra control points** (`extra_pts=`, `smooth=`) +// . +// By default, the solver uses exactly as many control points as are needed to +// satisfy the interpolation and constraint conditions, which gives a unique +// solution. This unique solution may be badly behaved, with undesirable oscillations. +// You can improve the behavior by requesting extra points. +// Specifying `extra_pts=N` inserts `N` additional control points and knots, making the +// system underdetermined: infinitely many curves pass through the data points and satisfy +// the constraints. The solver picks the one that satisfies +// a smoothness criterion specified by `smooth=`: +// . +// - `smooth=1` — minimises the sum of squared differences between consecutive +// control points. This tends to keep the control polygon short and reduces +// large-scale variation in the curve. +// - `smooth=2` — minimises the sum of squared second differences of the control +// points. This penalises bending in the control polygon, generally producing +// a fairer, less wiggly curve than `smooth=1`. +// - `smooth=3` (default) — minimises the integrated squared second derivative +// $\int \|\mathbf{C}''(t)\|^2 \, dt$, often called the *bending energy* of +// the curve. Unlike `smooth=2`, which only looks at the control polygon, +// this criterion acts directly on the curve shape and is the most +// mathematically principled choice for smooth interpolation. Requires +// `degree >= 2`. +// . +// The number of extra control points cannot exceed the number of knot spans. +// If you request too many, the number is capped and a warning is displayed. +// With `corners=`, the curve is split into independent clamped segments and +// the extra points are distributed across eligible segments proportionally +// to their control-point count, rounding up, so the total may +// exceed the requested number but will never be less. A segment is eligible when +// its effective degree is 3 or higher, or when it is degree 2 with `smooth=1`. +// . +// **Locating points in the spline** — In order to locate your original data +// points in the spline you need the `u` parameter value that you +// can pass to {{nurbs_curve()}}. The last return value `u` is a list +// where `u[k]` is the NURBS parameter at which the curve passes through +// `points[k]`. +// . +// **Smoothness** — The smoothness of B-splines is determined by the +// degree. If you request a degree $p$ spline then it will be $C^{p-1}$ at +// knot points and $C^\infty$ everywhere else. If you request corners then +// these are points where the curve is not differentiable; corners may +// also divide the curve into small segments that lack sufficient points +// to support an interpolation at your requested degree: a degree $p$ interpolation +// requires $p+1$ points. In this case, the intepolation is performed at a lower +// degree and elevated, which means it will be less smooth at knots. +// +// Arguments: +// points = List of data points to interpolate (2D or any higher dimension). +// degree = Degree of the NURBS. Degree 3 (cubic) is the most common choice. +// --- +// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` +// closed = If true treat point list as a loop . Default: `false` +// start_deriv = If `closed=false`, gives the tangent vector at the first point +// end_deriv = If `closed=false`, gives tangent vector at the last point. +// deriv = List of tangent vector constraints for every point, NAN at corners or undef at unconstrained points. Cannot be combined with `start_deriv=`/`end_deriv=`. +// start_curvature = If `closed=false` gives curvature at first point. (Requires matching derivative.) +// end_curvature = If `closed=false` gives curvature at last point. (Requires matching derivative.) +// curvature = List of curvature constraints for every point, or undef at unconstrained points. Each curvature constraint must be paired with a derivative constraint at the same point. Cannot be combined with `start_curvature=`/`end_curvature=`. +// corners = List of interior point indices where corners are permitted. Equivalent to setting entries of `deriv` to NAN. +// extra_pts = Number of extra control points to add to provide additional freedom to control undesirable oscillations. Default: 0 +// smooth = Smoothness criterion used with extra control points. Set to 1 (minimize control-polygon length), 2 (minimize control-polygon bending) or 3 (minimize curve bending energy). Default: 3 + +function nurbs_interp(points, degree, method="centripetal", closed=false, + deriv=undef, start_deriv=undef, end_deriv=undef, + curvature=undef, start_curvature=undef, end_curvature=undef, + corners=undef, extra_pts=0, smooth=3) = + assert(is_path(points, undef) && len(points) >= 2, + "nurbs_interp: points must be a path (list of same-dimension vectors) with at least 2 points") + assert(is_num(degree) && degree >= 1, + "nurbs_interp: degree must be >= 1") + assert(method == "length" || method == "centripetal" || method == "dynamic" + || method == "foley" || method == "fang", + str("nurbs_interp: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) + assert(is_undef(deriv) || (is_undef(start_deriv) && is_undef(end_deriv)), + "nurbs_interp: use deriv= OR start_deriv=/end_deriv=, not both") + assert(!closed || (is_undef(start_deriv) && is_undef(end_deriv)), + "nurbs_interp: start_deriv/end_deriv only supported for closed=false") + assert(is_undef(deriv) || len(deriv) == len(points), + str("nurbs_interp: deriv= must have same length as points (", + len(points), " points, ", is_undef(deriv) ? 0 : len(deriv), " deriv)")) + assert(is_undef(curvature) || (is_undef(start_curvature) && is_undef(end_curvature)), + "nurbs_interp: use curvature= OR start_curvature=/end_curvature=, not both") + assert(!closed || (is_undef(start_curvature) && is_undef(end_curvature)), + "nurbs_interp: start_curvature=/end_curvature= only supported for closed=false") + assert(is_undef(curvature) || len(curvature) == len(points), + str("nurbs_interp: curvature= must have same length as points (", + len(points), " points, ", is_undef(curvature) ? 0 : len(curvature), " curvature)")) + assert(is_undef(corners) || ( + !closed + ? (min(corners) >= 1 && max(corners) <= len(points)-2) + : (min(corners) >= 0 && max(corners) <= len(points)-1)), + str("nurbs_interp: corners= indices must be ", + !closed ? str("interior (1..", len(points)-2, ")") + : str("valid point indices (0..", len(points)-1, ")"))) + assert(is_num(extra_pts) && extra_pts >= 0 && extra_pts == floor(extra_pts), + str("nurbs_interp: extra_pts must be a non-negative integer, got ", extra_pts)) + assert(extra_pts == 0 || degree >= 2, + "nurbs_interp: extra_pts requires degree >= 2") + assert(smooth == 1 || smooth == 2 || smooth == 3, + str("nurbs_interp: smooth must be 1, 2, or 3, got ", smooth)) + assert(smooth != 3 || degree >= 2, + "nurbs_interp: smooth=3 (bending energy) requires degree >= 2") + let( + type = closed ? "closed" : "clamped", + raw = type == "clamped" + ? _nurbs_interp_clamped(points, degree, method, + deriv, start_deriv, end_deriv, + curvature, start_curvature, end_curvature, + corners, extra_pts, smooth) + : _nurbs_interp_closed(points, degree, method, deriv, curvature, + corners, extra_pts, smooth), + eff_type = is_string(raw[3]) ? raw[3] : type, + rot = raw[2], + n = len(points), + u = type == "closed" && !is_string(raw[3]) + ? list_rotate( + _interp_params(list_rotate(points, rot), method, closed=true), + -rot) + : type == "closed" + ? let( + aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], points[rot]], + aug_params = _interp_params(aug_pts, method) + ) + [for (j = [0:1:n-1]) aug_params[(j - rot + n) % n]] + : _interp_params(points, method) + ) + [eff_type, degree, raw[0], raw[1], undef, undef, u]; + + +// ---------- CLAMPED interpolation ---------- +// +// start_deriv=/end_deriv= and start_curvature=/end_curvature= are convenience shorthands. +// They are merged into eff_der / eff_curv lists here so that all +// constrained cases flow through a single solver +// (_nurbs_interp_clamped_constrained). + +function _nurbs_interp_clamped(points, degree, method, + deriv, start_deriv, end_deriv, + curvature, start_curvature, end_curvature, + corners, extra_pts=0, smooth=3) = + let(n = len(points) - 1, p = degree, dim = len(points[0])) + assert(n >= p, + str("nurbs_interp (clamped): need at least ", p+1, + " points for degree ", p, ", got ", n+1)) + let( + eff_der = _merge_deriv_list(n, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv), + eff_curv = _merge_curv_list(n, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature), + + // C0 corner joints from NaN entries in eff_der and/or corners= list. + // Must be interior points; cannot coincide with curvature constraints. + nan_corners = is_undef(eff_der) ? [] + : [for (k = [0:1:n]) if (is_nan(eff_der[k])) k], + explicit_corners = default(corners, []), + corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), + has_corners = len(corner_idxs) > 0, + bad_corner_end = [for (k = corner_idxs) if (k == 0 || k == n) k], + bad_corner_curv = is_undef(eff_curv) ? [] + : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], + // Explicit corners= entries must not also carry a derivative constraint. + // (NaN-in-deriv corners are fine — they ARE the corner syntax.) + bad_corner_der = is_undef(eff_der) ? [] + : [for (k = explicit_corners) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k], + + // Exclude NaN corner markers from the derivative-constraint count. + has_any_der = !is_undef(eff_der) && + len([for (k = [0:1:n]) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, + has_any_curv = !is_undef(eff_curv) && + len([for (k = [0:1:n]) if (!is_undef(eff_curv[k])) k]) > 0, + + // Every curvature-constrained point must also have a derivative + // constraint; the derivative direction defines the curve's tangent + // and is required to orient the curvature normal. + bad_curv_pts = is_undef(eff_curv) ? [] : + [for (k = [0:1:n]) + if (!is_undef(eff_curv[k]) && + (is_undef(eff_der) || is_undef(eff_der[k]))) + k] + ) + assert(bad_corner_end == [], + str("nurbs_interp: corner cannot be at the first or last point: ", bad_corner_end)) + assert(bad_corner_curv == [], + str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", bad_corner_curv)) + assert(bad_corner_der == [], + str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", bad_corner_der)) + assert(bad_curv_pts == [], + str("nurbs_interp: curvature constraint requires a derivative constraint ", + "at the same point(s): ", bad_curv_pts)) + has_corners + ? _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, + extra_pts=extra_pts, smooth=smooth) + : (has_any_der || has_any_curv || extra_pts > 0) + ? _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, extra_pts, smooth) + : _nurbs_interp_clamped_basic(points, p, method, smooth); + + +// Basic clamped interpolation (no derivatives). +// n+1 points -> n+1 control points. + +function _nurbs_interp_clamped_basic(points, p, method, smooth=3) = + let( + n = len(points) - 1, + M = n + 1, + dim = len(points[0]), + params = _interp_params(points, method), + int_kn = _avg_knots_interior(params, p), + U_full = _full_clamped_knots(int_kn, p), + N_mat = _collocation_matrix(params, n, p, U_full), + control = linear_solve(N_mat, points), + knots = [0, each int_kn, 1] + ) + assert(control != [], + "nurbs_interp (clamped): singular collocation matrix") + [control, knots, 0]; + + +// Assemble independently-solved clamped corner segments into one B-spline. +// +// All segments must be degree p. Returns [ctrl, xknots, 0] — the standard +// non-segmented result format that callers can pass directly to nurbs_curve / +// debug_nurbs with type="clamped". +// +// BOSL2 clamped knot convention: nurbs_curve() takes xknots of length +// len(control) - degree + 1 +// and internally prepends (degree) zeros and appends (degree) ones to form +// the full clamped knot vector. For a C0 corner at global parameter s_c, +// s_c must appear exactly p times in xknots (giving multiplicity p in the +// full vector = C^0 continuity for degree p). +// +// Segment local knots seg[1] = [0, int_kn..., 1] are remapped to the +// segment's global parameter interval [s_a, s_b] using +// k_global = s_a + (s_b - s_a) * k_local +// which is consistent with any chord-proportional parameterization. + +function _combine_corner_segs(segments, params, corner_idxs, p) = + let( + n_segs = len(segments), + // Global parameter at each corner junction. + cpar = [for (c = corner_idxs) params[c]], + // Global interval [s_a, s_b] for each segment. + seg_sa = [for (s = [0:1:n_segs-1]) s == 0 ? 0 : cpar[s-1]], + seg_sb = [for (s = [0:1:n_segs-1]) s == n_segs-1 ? 1 : cpar[s] ], + // Per-segment interior knots (exclude leading 0 and trailing 1), + // remapped from local [0,1] to the segment's global interval. + seg_gi = [for (s = [0:1:n_segs-1]) + let( + loc = [for (i = [1:1:len(segments[s][1])-2]) segments[s][1][i]], + sa = seg_sa[s], + sb = seg_sb[s] + ) + [for (k = loc) sa + (sb - sa) * k] + ], + // Build combined xknots: + // [0, seg0_int, corner0^p, seg1_int, corner1^p, ..., segN_int, 1] + interior = [for (s = [0:1:n_segs-1]) + each concat( + seg_gi[s], + s < n_segs-1 ? repeat(cpar[s], p) : [] + ) + ], + xknots = [0, each interior, 1], + // Combined control points: all of seg0, then seg[1:1:] for each later seg. + // The first control point of seg s (s >= 1) equals the last of seg s-1 + // because both are the clamped-endpoint interpolant of the shared corner + // data point — so we drop the duplicate. + ctrl = [ + each segments[0][0], + for (s = [1:1:n_segs-1]) + for (j = [1:1:len(segments[s][0])-1]) + segments[s][0][j] + ] + ) + [ctrl, xknots, 0]; + + +// Clamped interpolation with C0 corner joints. +// +// NaN entries in eff_der mark corners: the curve is split into independent +// clamped segments at each corner index. Each segment is solved at the +// highest degree possible: min(p, m-1) where m is the segment point count. +// Degree reduction silently handles short segments (e.g. only 2 or 3 data +// points between adjacent corners). +// +// Segments that needed degree reduction are degree-elevated back to p +// via nurbs_elevate_degree() so that all segments can be assembled into +// a single clamped B-spline. Elevated segments preserve their original +// lower-degree shape but have higher knot multiplicity, so they are +// less smooth at interior knots than natively degree-p segments. + +function _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, + extra_pts=0, smooth=3) = + let( + n = len(points) - 1, + params = _interp_params(points, method), + seg_bounds = [0, each corner_idxs, n], + n_segs = len(seg_bounds) - 1, + // Distribute extra_pts across eligible segments proportionally to + // their control-point count (= data-point count = seg_sizes[s]+1). + // Eligible = segments with seg_p >= 3, or seg_p == 2 when smooth == 1. + // Linear (seg_p==1) and quadratic with smooth!=1 get 0 extra_pts. + seg_sizes = [for (s = [0:1:n_segs-1]) + seg_bounds[s+1] - seg_bounds[s]], + seg_degrees = [for (sz = seg_sizes) min(p, sz)], + // Weight = control-point count for eligible segments, 0 for ineligible. + seg_weights = [for (s = [0:1:n_segs-1]) + let(sp = seg_degrees[s]) + (sp >= 3 || (sp == 2 && smooth == 1)) + ? seg_sizes[s] + 1 : 0], + total_weight = max(1, sum(seg_weights)), + // Round up per-segment allocation so total >= extra_pts. + seg_extra = extra_pts == 0 ? repeat(0, n_segs) + : [for (s = [0:1:n_segs-1]) + seg_weights[s] == 0 ? 0 + : ceil(extra_pts * seg_weights[s] / total_weight)], + raw_segments = [for (s = [0:1:n_segs-1]) + let( + i0 = seg_bounds[s], + i1 = seg_bounds[s+1], + seg_pts = [for (k = [i0:1:i1]) points[k]], + // Reduce degree if the segment has fewer than p+1 points. + seg_p = seg_degrees[s], + // Replace NaN corner markers with undef at shared endpoints. + seg_der = is_undef(eff_der) ? undef + : [for (k = [i0:1:i1]) + is_nan(eff_der[k]) ? undef : eff_der[k]], + seg_curv = is_undef(eff_curv) ? undef + : [for (k = [i0:1:i1]) eff_curv[k]], + r = _nurbs_interp_clamped(seg_pts, seg_p, method, + seg_der, undef, undef, + seg_curv, undef, undef, + extra_pts=seg_extra[s], + smooth=smooth) + ) + [r[0], r[1], seg_p] // [control, knots, degree] + ], + // Degree-elevate short segments to the full degree p. + segments = [for (seg = raw_segments) + seg[2] == p ? seg + : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], + type="clamped", times=p - seg[2])) + [elev[2], elev[3], p] + ] + ) + _combine_corner_segs(segments, params, corner_idxs, p); + + +// General clamped interpolation with per-point derivative and/or curvature +// constraints. +// +// eff_der: list of n+1 first-derivative specs (undef = unconstrained). +// eff_curv: list of n+1 curvature specs (undef = unconstrained). +// dim=2: signed scalar κ. dim≥3: curvature vector. +// +// Uses Method A (expanded-parameter knot averaging, P&T §9.2.2): for each +// constraint at index k, duplicate params[k] in an expanded sequence ũ — +// once per constraint type (deriv and curvature each add one duplication per +// constrained point). This provides one extra DOF per extra constraint. + +function _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, + extra_pts=0, smooth=3) = + let( + n = len(points) - 1, + dim = len(points[0]), + path_len = path_length(points), + path_len2 = path_len * path_len, + params = _interp_params(points, method), + + // First-derivative specs: [index, C'(t) vector]. + // eff_der entries are already dim-projected by _nurbs_interp_clamped. + der_specs = is_undef(eff_der) ? [] + : [for (k = [0:1:n]) if (!is_undef(eff_der[k])) + [k, eff_der[k] * path_len]], + + // Curvature specs: [index, C''(t) vector]. + // eff_der and eff_curv are already dim-projected. + // Tangent from eff_der[k] when available; otherwise estimated from chord. + // Speed² from |eff_der[k]|² × path_len² when derivative given. + curv_specs = is_undef(eff_curv) ? [] + : [for (k = [0:1:n]) if (!is_undef(eff_curv[k])) + let( + t_from_der = is_undef(eff_der) ? undef : eff_der[k], + tang_dir = !is_undef(t_from_der) ? t_from_der + : k == 0 ? points[1] - points[0] + : k == n ? points[n] - points[n-1] + : points[k+1] - points[k-1], + v2 = !is_undef(t_from_der) + ? path_len2 * (t_from_der * t_from_der) + : path_len2 + ) + [k, _curv_to_d2(eff_curv[k], tang_dir, dim, v2)] + ], + + n_extra_der = len(der_specs), + n_extra_curv = len(curv_specs), + _chk_curv_deg = assert(n_extra_curv == 0 || p >= 2, + "nurbs_interp: curvature constraints require degree >= 2"), + n_constraint = n_extra_der + n_extra_curv, + + // Build knots: average data params, insert at constraint spans, + // then insert extra_pts more at widest spans. + base_int = _avg_knots_interior(params, p), + base_bar = [0, each base_int, 1], + constraint_ts = [for (spec = der_specs) params[spec[0]], + for (spec = curv_specs) params[spec[0]]], + after_constr = _insert_constraint_knots(base_bar, constraint_ts), + // For extra_pts, insert knots at midpoints of the widest spans. + // _widest_span_params silently caps the request at the available span count. + extra_ts = extra_pts == 0 ? [] + : _widest_span_params(after_constr, extra_pts), + aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), + n_spans_pre = len(aug_bar_raw) - 1, + aug_bar_pre = _fix_tiny_spans(aug_bar_raw, n_spans_pre), + + // Split any knot span that contains multiple data parameters. + // Without this, two data points in the same span produce a + // rank-deficient collocation matrix (Schoenberg-Whitney condition). + occ_splits = _span_split_params(aug_bar_pre, params), + n_occ = len(occ_splits), + M = n + 1 + n_constraint + len(extra_ts) + n_occ, + aug_bar = n_occ == 0 ? aug_bar_pre + : _fix_tiny_spans( + sort([each aug_bar_pre, each occ_splits]), + n_spans_pre + n_occ), + int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], + U_full = _full_clamped_knots(int_kn, p), + + // Constraint matrix A: interpolation + derivative + curvature rows. + // Dimensions: N_rows × M where N_rows = (n+1) + n_constraint. + N_rows = n + 1 + n_constraint, + + // Interpolation rows: N_{j,p}(t_k) + interp_rows = [for (k = [0:1:n]) + [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] + ], + + // First-derivative rows: N'_{j,p}(t_k) + deriv_rows = [for (spec = der_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) _dnip(j, p, params[k], U_full)] + ], + + // Second-derivative rows: N''_{j,p}(t_k) + curv_rows = [for (spec = curv_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) _d2nip(j, p, params[k], U_full)] + ], + + A_constr = [each interp_rows, each deriv_rows, each curv_rows], + rhs_constr = [each points, + for (spec = der_specs) spec[1], + for (spec = curv_specs) spec[1]], + + knots = [0, each int_kn, 1] + ) + // When M == N_rows (square), try direct solve first. + // When M > N_rows (underdetermined from extra_pts or span splits), + // use null-space method: exact constraints + minimum-energy smoothing. + let( + direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] + ) + direct != [] + ? [direct, knots, 0] + : let( + R = _regularization_matrix(M, smooth, p, U_full), + control = _nullspace_solve(R, A_constr, rhs_constr) + ) + assert(!is_undef(control), + "nurbs_interp (clamped+constrained): rank-deficient constraint matrix") + [control, knots, 0]; + + +// ---------- CLOSED interpolation ---------- + +function _nurbs_interp_closed(points, degree, method, deriv, curvature, + corners, extra_pts=0, smooth=3) = + let(n = len(points), p = degree, dim = len(points[0])) + assert(n >= p + 1, + str("nurbs_interp (closed): need at least ", p+1, + " points for degree ", p, ", got ", n)) + let( + // Detect C0 corners from NaN entries in the RAW deriv list before projection, + // since _merge_deriv_list would leave NaN entries intact but we detect them here. + nan_corners = is_undef(deriv) ? [] + : [for (k = [0:1:n-1]) if (is_nan(deriv[k])) k], + explicit_corners = default(corners, []), + corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), + has_corners = len(corner_idxs) > 0, + + // Project derivative and curvature lists (handles BOSL2 direction constants, etc.) + eff_der = _merge_deriv_list(n-1, deriv, dim=dim), + eff_curv = _merge_curv_list(n-1, curvature, dim=dim), + + has_dl = !is_undef(eff_der) && + len([for (k = [0:1:n-1]) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, + has_cl = !is_undef(eff_curv) && + len([for (k = [0:1:n-1]) if (!is_undef(eff_curv[k])) k]) > 0, + + // Every curvature-constrained point must also have a derivative constraint. + bad_curv_pts = is_undef(eff_curv) ? [] : + [for (k = [0:1:n-1]) + if (!is_undef(eff_curv[k]) && + (is_undef(eff_der) || is_undef(eff_der[k]))) + k], + // Curvature at a corner is not allowed. + bad_corner_curv = is_undef(eff_curv) ? [] + : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], + // Derivative at an explicit corner is not allowed. + bad_corner_der = is_undef(eff_der) ? [] + : [for (k = explicit_corners) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k] + ) + assert(bad_curv_pts == [], + str("nurbs_interp: curvature constraint requires a derivative constraint ", + "at the same point(s): ", bad_curv_pts)) + assert(bad_corner_curv == [], + str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", + bad_corner_curv)) + assert(bad_corner_der == [], + str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", + bad_corner_der)) + // Basic and constrained solvers handle rotation search internally. + // Corner case uses its own rotation (to the first corner). + has_corners + ? _nurbs_interp_closed_corners(points, p, method, eff_der, eff_curv, corner_idxs, + extra_pts=extra_pts, smooth=smooth) + : (has_dl || has_cl || extra_pts > 0) + ? let( + _raw_c = _closed_constrained_solve(points, p, method, eff_der, eff_curv, + 0, extra_pts, smooth), + _chk = assert(!is_undef(_raw_c), + "nurbs_interp (closed+constrained): rank-deficient constraint matrix") + ) _raw_c + : _nurbs_interp_closed_basic(points, p, method, smooth); + + +// Closed interpolation with C0 corner joints. +// +// Converts the closed-with-corners problem into a clamped-with-corners +// problem: rotate data so the first corner is at the start, duplicate +// that point at the end to close the loop, remap remaining corners to +// the rotated frame, and delegate to _nurbs_interp_clamped_corners. +// +// The result is a clamped B-spline whose first and last control points +// coincide at the corner point. r[3] = "clamped" tells convenience +// functions to render with type="clamped" instead of "closed". + +function _nurbs_interp_closed_corners(points, p, method, deriv, curvature, + corner_idxs, extra_pts=0, smooth=3) = + let( + n = len(points), // n points (0..n-1), no repeat + rot = corner_idxs[0], + + // Augmented point list: rotated + closing duplicate of first corner. + aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], + points[rot]], + + // Remap remaining corners to rotated frame. + rot_corners = sort([for (i = [1:1:len(corner_idxs)-1]) + (corner_idxs[i] - rot + n) % n]), + + // Rotate and augment deriv list. + // NaN at the rotation point (now start/end) is cleaned to undef + // since the corner is handled structurally by the clamped endpoints. + aug_der = is_undef(deriv) ? undef : + let(rd = [for (k = [0:1:n-1]) deriv[(k + rot) % n]], + d0 = is_nan(rd[0]) ? undef : rd[0]) + [d0, for (k = [1:1:n-1]) rd[k], d0], + + // Rotate and augment curvature list. + aug_curv = is_undef(curvature) ? undef : + let(rc = [for (k = [0:1:n-1]) curvature[(k + rot) % n]]) + [rc[0], for (k = [1:1:n-1]) rc[k], rc[0]], + + // Solve as clamped with corners. + result = _nurbs_interp_clamped_corners(aug_pts, p, method, + aug_der, aug_curv, + rot_corners, + extra_pts=extra_pts, + smooth=smooth) + ) + // Return with the original rotation index and type override. + [result[0], result[1], rot, "clamped"]; + + +// Returns the maximum number of parameters that fall in any single active +// knot span for cyclic rotation r. A value of 1 is ideal (one parameter +// per span); values > 1 indicate span collisions that may (but do not +// always) cause a singular collocation matrix. + +function _closed_rotation_collision_count(points, n, p, method, r) = + let( + pts = select(points, r, r + n - 1), + rp = _interp_params(pts, method, closed=true), + bk = _fix_tiny_spans(_avg_knots_periodic(rp, p)[0], n), + U = _full_closed_knots(bk, n, p), + ps = add_scalar(rp, bk[p]) + ) + max([for (k = [0:1:n-1]) + len([for (t = ps) if (t >= U[p+k] && t < U[p+k+1]) t]) + ]); + + +// Find the best seam rotation for closed curve interpolation. +// The chord-ratio heuristic (argmax d[i+1]/d[i] + 1) is tried first. +// If it has span collisions, all n rotations are scored by collision +// count and the one with the fewest collisions is chosen. Mild +// collisions (max 2 params per span) often still produce a non-singular +// system, so the final check is deferred to linear_solve(). + +function _find_closed_rotation(points, n, p, method) = + let( + chords = path_segment_lengths(points, closed=true), + ratios = [for (i = [0:1:n-1]) chords[(i+1)%n] / max(chords[i], 1e-15)], + rot0 = (max_index(ratios) + 1) % n + ) + _closed_rotation_collision_count(points, n, p, method, rot0) <= 1 + ? rot0 + : let( + scores = [for (i = [0:1:n-1]) + [_closed_rotation_collision_count(points, n, p, method, i), i]], + best = min_index([for (s = scores) s[0]]) + ) + scores[best][1]; + + +// Solve a basic closed interpolation for a specific rotation. +// Returns [control, bar_knots, rot] or undef if singular. + +function _closed_basic_solve(points, n, p, method, rot, smooth=3) = + let( + dim = len(points[0]), + pts = select(points, rot, rot + n - 1), + raw_params = _interp_params(pts, method, closed=true), + bar_knots = _fix_tiny_spans(_avg_knots_periodic(raw_params, p)[0], n), + U_full = _full_closed_knots(bar_knots, n, p), + params = add_scalar(raw_params, bar_knots[p]), + N_mat = _collocation_matrix_periodic(params, n, p, U_full), + control = linear_solve(N_mat, pts) + ) + control != [] ? [control, bar_knots, rot] + : // Singular — fall back to constrained optimization. + let( + M = n, + R = _regularization_matrix(M, smooth, p, U_full, periodic=true), + ctrl = _nullspace_solve(R, N_mat, pts) + ) + is_undef(ctrl) ? undef : [ctrl, bar_knots, rot]; + + +// Basic closed interpolation — start-point independent. +// +// Implements the cyclic chord-length parameterization and cyclic knot +// averaging of Piegl & Tiller §9.2.4. In exact arithmetic the resulting +// curve is the same regardless of which data point is listed first; only +// the parametric origin changes (the curve is just reparameterized). +// The chord-ratio heuristic selects the starting rotation. + +function _nurbs_interp_closed_basic(points, p, method, smooth=3) = + let( + n = len(points), + rot0 = _find_closed_rotation(points, n, p, method), + result0 = _closed_basic_solve(points, n, p, method, rot0, smooth) + ) + assert(!is_undef(result0), + "nurbs_interp (closed): singular system — try adding extra_pts= to relax the knot structure") + result0; + + +// Solve a constrained closed interpolation for a specific rotation. +// Returns [control, aug_bar, rot] or undef if singular. +// +// eff_der: list of n first-derivative specs (undef = unconstrained). +// eff_curv: list of n curvature specs (undef = unconstrained). +// dim=2: signed scalar κ or 2D vector. dim≥3: curvature vector. +// +// Knot construction: standard periodic averaging of N data params, +// then insert one knot per constraint at the midpoint of the span +// containing its parameter (largest span first). +// M control points use standard BOSL2 periodic aliasing: +// B_j(t) = N_j(t) + (j

= 2, + "nurbs_interp: curvature constraints require degree >= 2"), + n_constraint = n_extra_der + n_extra_curv, + + // Build bar_knots: standard periodic averaging of N data + // params, then insert knots for constraints and extra_pts. + base_bar = _avg_knots_periodic(raw_params, p)[0], + constraint_idxs = [for (spec = der_specs) spec[0], + for (spec = curv_specs) spec[0]], + constraint_ts = [for (k = constraint_idxs) raw_params[k]], + after_constr = _insert_constraint_knots(base_bar, constraint_ts), + // _widest_span_params silently caps the request at the available span count. + extra_ts = extra_pts == 0 ? [] + : _widest_span_params(after_constr, extra_pts), + aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), + // M_pre = span count of aug_bar_raw. Use len()-1 rather than + // n+n_constraint+extra_pts so it reflects the actual knots inserted. + M_pre = len(aug_bar_raw) - 1, + aug_bar_pre = _fix_tiny_spans(aug_bar_raw, M_pre), + + // Split any knot span that contains multiple data parameters. + // Without this, two data points in the same span produce a + // rank-deficient collocation matrix (§9.2.1 Schoenberg-Whitney). + occ_splits = _span_split_params(aug_bar_pre, raw_params), + n_occ = len(occ_splits), + M = M_pre + n_occ, + aug_bar = n_occ == 0 ? aug_bar_pre + : _fix_tiny_spans( + sort([each aug_bar_pre, each occ_splits]), + M), + T = aug_bar[M], + U_full = _full_closed_knots(aug_bar, M, p), + + // Map raw params into active domain [aug_bar[p], aug_bar[p]+T]. + // Nudge any shifted parameter that lands on or near a knot. + raw_shifted = add_scalar(raw_params, aug_bar[p]), + eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), + params = [for (k = [0:1:n-1]) + let( + u = raw_shifted[k], + d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) + ) + d_min < eps_knot ? u + eps_knot : u + ], + + // Constraint matrix A: interpolation + derivative + curvature rows. + N_rows = n + n_constraint, + + // Interpolation rows: aliased basis for M control points + interp_rows = [for (k = [0:1:n-1]) + [for (j = [0:1:M-1]) + _nip(j, p, params[k], U_full) + + (j < p ? _nip(j + M, p, params[k], U_full) : 0) + ] + ], + + // First-derivative rows: aliased derivative basis + deriv_rows = [for (spec = der_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) + _dnip(j, p, params[k], U_full) + + (j < p ? _dnip(j + M, p, params[k], U_full) : 0) + ] + ], + + // Second-derivative rows: aliased second-derivative basis + curv_rows = [for (spec = curv_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) + _d2nip(j, p, params[k], U_full) + + (j < p ? _d2nip(j + M, p, params[k], U_full) : 0) + ] + ], + + A_constr = [each interp_rows, each deriv_rows, each curv_rows], + rhs_constr = [each pts, + for (spec = der_specs) spec[1], + for (spec = curv_specs) spec[1]] + ) + // When M == N_rows (square), try direct solve first. + // When M > N_rows (underdetermined from extra_pts or span splits), + // use null-space method: exact constraints + minimum-energy smoothing. + let( + direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] + ) + direct != [] + ? [direct, aug_bar, rot] + : let( + R = _regularization_matrix(M, smooth, p, U_full, periodic=true), + ctrl = _nullspace_solve(R, A_constr, rhs_constr) + ) + is_undef(ctrl) ? undef : [ctrl, aug_bar, rot]; + + +// Section: Debug / Visualization + +// Module: debug_nurbs_interp() +// Synopsis: Interpolates a NURBS using {{nurbs_interp()}} and displays the curve with informative overlays. +// Topics: NURBS Curves, Interpolation, Debugging +// See Also: nurbs_interp(), debug_nurbs() +// +// Usage: +// debug_nurbs_interp(points, degree, [splinesteps=], [method=], [closed=], [deriv=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [extra_pts=], [smooth=], [width=], [size=], [data_size=], [data_index=], [show_control=], [control_index=], [show_knots=], [show_deriv=], [show_curvature=]); +// +// Description: +// Calls {{nurbs_interp()}} with the supplied arguments and displays the +// resulting curve together with a informative overlays. All interpolation +// arguments are passed through unchanged; see {{nurbs_interp()}} for their +// descriptions. The overlays are: +// . +// - **Data points** — red circles (2D) or spheres (3D) at each input point. +// When `data_index=true` (the default), the point index is printed in red next +// to its marker. Set `data_size=0` to suppress display of the data point dots. +// - **Derivative constraints** — a black arrow at each derivative constrained data point. +// Arrow direction and length reflect the constraint vector, scaled to the average +// point spacing. When the derivative is NAN or a point has a corner, this is shown +// using a black diamond. Shown by default: set `show_deriv=false` to hide. +// - **Curvature constraints** — a transparent green overlay at each curvature-constrained point. +// In 2D the overlay is the osculating circle. In 3D the overlay is a cylinder created +// from the 3D osculating circle. Zero curvature appears as a short green bar. +// Shown by default: Set `show_curvature=false` to hide. +// - **Knots** — Green crosses mark each knot position. Not shown by default. +// Enable with `show_knots=true`. +// - **Control points and polygon** — If you set `show_control=true` then a gray control polygon +// Is displayed. If you additionally set `control_index=true` then blue control-point +// index labels appear. +// +// Arguments: +// points = List of 2-D or 3-D data points to interpolate through. +// degree = NURBS degree. +// splinesteps = Steps per knot span for curve rendering. Default: `16` +// --- +// method = Parameterization method; see {{nurbs_interp()}}. Default: `"centripetal"` +// closed = If true, interpolate as a closed loop; if false, interpolate as clamped. Default: `false` +// deriv = Per-point derivative constraints; see {{nurbs_interp()}}. Default: `undef` +// start_deriv = Derivative at first point. Default: `undef` +// end_deriv = Derivative at last point. Default: `undef` +// curvature = Per-point curvature constraints; see {{nurbs_interp()}}. Default: `undef` +// start_curvature = Curvature at first point. Default: `undef` +// end_curvature = Curvature at last point. Default: `undef` +// corners = Corner indices; see {{nurbs_interp()}}. Default: `undef` +// extra_pts = Extra control points; see {{nurbs_interp()}}. Default: `0` +// smooth = Smoothness criterion for `extra_pts`; see {{nurbs_interp()}}. Default: `3` +// width = Stroke width for the curve. Arrows and other overlays scale with this. Default: `1` +// size = Text size for labels on control points and data points. Default: `3*width` +// data_size = Radius of the red data-point markers. Set to `0` to hide data points and their labels. Default: equal to `width` +// data_index = Show index labels next to each data point. Only shown when `data_size > 0`. Default: `true` +// show_control = Show the control polygon. Default: `false` +// control_index = Show control-point index labels if `show_control=true`. Default: `false` +// show_knots = Show knot position markers on the curve. Default: `false` +// show_deriv = Show derivative-constraint arrows. Default: `true` +// show_curvature = Show curvature-constraint circles / disks. Default: `true` + +module debug_nurbs_interp(points, degree, splinesteps=16, method="centripetal", + closed=false, deriv=undef, + start_deriv=undef, end_deriv=undef, + curvature=undef, start_curvature=undef, end_curvature=undef, + corners=undef, extra_pts=0, smooth=3, + width=1, size=undef, data_size=undef, + show_control=false, show_knots=false, + show_deriv=true, show_curvature=true, + control_index=false, data_index=true) { + result = nurbs_interp(points, degree, method=method, + closed=closed, deriv=deriv, + start_deriv=start_deriv, end_deriv=end_deriv, + curvature=curvature, start_curvature=start_curvature, + end_curvature=end_curvature, corners=corners, + extra_pts=extra_pts, smooth=smooth); + + np = len(points); + dim = len(points[0]); + is2d = (dim == 2); + ds = default(data_size, width); + sz = default(size, 3 * width); + ctrl = result[2]; + arrow_scale = path_length(points) / np; + + // Helpers project BOSL2 direction constants and pad dimensions automatically. + eff_der = _merge_deriv_list(np-1, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv); + eff_curv = _merge_curv_list(np-1, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature); + + // --- Curve, control polygon, knot markers (delegated to debug_nurbs) --- + debug_nurbs(result, splinesteps=splinesteps, width=width, size=sz, + show_knots=show_knots, show_control=show_control, + show_index=control_index); + + // --- Corner marks (NaN-deriv corners + explicit corners= indices) --- + // 2D: rotated square stroke. 3D: octahedron wireframe. + nan_corner_idxs = is_undef(eff_der) ? [] + : [for (i = [0:1:np-1]) if (!is_undef(eff_der[i]) && is_nan(eff_der[i])) i]; + explicit_corner_idxs = default(corners, []); + all_corner_idxs = deduplicate(sort(concat(nan_corner_idxs, explicit_corner_idxs))); + for (i = all_corner_idxs) + color("black") + translate(points[i]) + if (is2d) + zrot(45) stroke(rect(3.5*width*ds), width=width/2, closed=true); + else + vnf_wireframe(octahedron(size=5*width), width=width/4); + + // --- Derivative arrows (black, half width, arrow2 endcap) --- + // Length = norm(eff_der[i]) * arrow_scale: preserves relative magnitudes; + // arrow_scale = path_length(points)/np gives a geometry-relative baseline. + if (show_deriv && !is_undef(eff_der)) + for (i = [0:1:np-1]) + if (!is_undef(eff_der[i]) && !is_nan(eff_der[i]) && norm(eff_der[i]) > 1e-12) + color("black") + stroke([points[i], points[i] + eff_der[i] * arrow_scale], + width=width/2, + endcap1="butt", endcap2="arrow2"); + + // --- Data points and index labels --- + if (ds > 0) + color("red") + move_copies(points) { + if (is2d) circle(r=ds, $fn=16); + else sphere(r=ds, $fn=16); + if (data_index) + if (is2d) + fwd(2*ds) text(text=str($idx), size=sz, anchor=BACK); + else + rot($vpr) back(ds + sz/3) text3d(text=str($idx), size=sz, anchor=CENTER); + } + + // --- Curvature overlays (rendered last so transparent objects don't occlude dots) --- + // Validator already asserted every curvature-constrained point has a derivative, + // so eff_der[i] is always defined and non-NaN here. + if (show_curvature && !is_undef(eff_curv)) + color([0,1,0,0.1]) + for (i = [0:1:np-1]) + if (!is_undef(eff_curv[i])) { + // cv is either a signed scalar (2D) or a dim-projected vector. + cv = eff_curv[i]; + kn = is_num(cv) ? abs(cv) : norm(cv); + T_hat = unit(eff_der[i]); + if (kn < 1e-12) { + // Zero curvature: fixed-length segment (0.6*arrow_scale) along + // the exact derivative direction. + half = 0.3 * arrow_scale; + stroke([points[i] - T_hat * half, + points[i] + T_hat * half], + width=2*width, endcaps="butt"); + } else { + // Non-zero curvature: osculating circle (2D) or cylinder (3D). + // N_hat: unit principal normal — component of cv perpendicular to T_hat. + N_hat = is_num(cv) + ? // Signed scalar (2D): rotate T_hat 90° left or right by sign(cv). + sign(cv) * [-T_hat[1], T_hat[0]] + : // Vector: strip tangential component via vector_perp, then unit. + unit(vector_perp(T_hat, cv)); + r = 1 / kn; + ctr = points[i] + N_hat * r; + // move(ctr) applies to both 2D and 3D branches. + move(ctr) + if (is2d) { + circle(r=r); + } else { + // Cylinder in the osculating plane: axis along binormal B̂ = T̂ × N̂. + // cyl(orient=binom) aligns the cylinder axis to B̂ without rot(). + binom = cross(T_hat, N_hat); + cyl(h=width, r=r, orient=binom); + } + } + } +} + + +// Interpolation System Builder (shared by curve & surface) + +// Builds the collocation matrix and BOSL2-format knots for a single +// parameterized direction. Returns [N_mat, bosl2_knots]. + +function _build_interp_system(params, p, type, extra_pts=0) = + type == "clamped" ? _build_clamped_system(params, p, extra_pts) + : _build_closed_system(params, p, extra_pts); + +function _build_clamped_system(params, p, extra_pts=0) = + let( + n = len(params) - 1, + int_kn = _avg_knots_interior(params, p), + base_bar = [0, each int_kn, 1] + ) + extra_pts == 0 + ? let( + U_full = _full_clamped_knots(int_kn, p), + N_mat = _collocation_matrix(params, n, p, U_full), + knots = [0, each int_kn, 1] + ) + [N_mat, knots] + : let( + extra_ts = _widest_span_params(base_bar, extra_pts), + aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), + occ_splits = _span_split_params(aug_bar_raw, params), + n_occ = len(occ_splits), + // Use len(extra_ts), not extra_pts: _widest_span_params silently caps + // the request at the number of available spans. + M = n + 1 + len(extra_ts) + n_occ, + aug_bar_merged = n_occ == 0 ? aug_bar_raw + : sort([each aug_bar_raw, each occ_splits]), + aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), + aug_int = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], + U_full = _full_clamped_knots(aug_int, p), + // Rectangular (n+1) × M matrix: n+1 data rows, M control columns. + // _collocation_matrix uses a single n for both dimensions, so build inline. + N_mat = [for (k = [0:1:n]) + [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)]], + knots = [0, each aug_int, 1] + ) + [N_mat, knots]; + +function _build_closed_system(params, p, extra_pts=0) = + let( + n = len(params), + base_bar = _fix_tiny_spans(_avg_knots_periodic(params, p)[0], n) + ) + extra_pts == 0 + ? let( + U_full = _full_closed_knots(base_bar, n, p), + col_params = add_scalar(params, base_bar[p]), + T = base_bar[n], + eps_knot = T / n * (p == 2 ? 0.01 : 1e-6), + col_safe = [for (k = [0:1:n-1]) + let( + u = col_params[k], + d_min = min([for (j = [0:1:n + 2*p]) abs(u - U_full[j])]) + ) + d_min < eps_knot ? u + eps_knot : u + ], + N_mat = _collocation_matrix_periodic(col_safe, n, p, U_full) + ) + [N_mat, base_bar] + : let( + extra_ts = _widest_span_params(base_bar, extra_pts), + aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), + occ_splits = _span_split_params(aug_bar_raw, params), + n_occ = len(occ_splits), + // Use len(extra_ts), not extra_pts: _widest_span_params silently caps + // the request at the number of available spans. + M = n + len(extra_ts) + n_occ, + aug_bar_merged = n_occ == 0 ? aug_bar_raw + : sort([each aug_bar_raw, each occ_splits]), + aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), + T = aug_bar[M], + U_full = _full_closed_knots(aug_bar, M, p), + raw_shifted = add_scalar(params, aug_bar[p]), + eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), + col_safe = [for (k = [0:1:n-1]) + let( + u = raw_shifted[k], + d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) + ) + d_min < eps_knot ? u + eps_knot : u + ], + // Rectangular n × M matrix: n data rows, M control columns. + // _collocation_matrix_periodic uses a single n for both dimensions, so + // build inline. Periodic wrapping folds basis j < p by adding N_{j+M}. + N_mat = [for (k = [0:1:n-1]) + [for (j = [0:1:M-1]) + _nip(j, p, col_safe[k], U_full) + + (j < p ? _nip(j + M, p, col_safe[k], U_full) : 0) + ]] + ) + [N_mat, aug_bar]; + + +// Build a clamped interpolation system with optional start/end first-derivative rows. +// Extends _build_clamped_system by adding one extra DOF and one extra matrix row +// for each active boundary (start and/or end). Used for surface boundary tangents. +// +// has_sd / has_ed — whether a start / end derivative constraint is active. +// extra_pts — number of additional control points (widens the system). +// Returns [A_matrix, bosl2_knots]. Square when extra_pts==0, rectangular otherwise. +// Row order: interpolation rows (k=0..n), deriv_start (if any), deriv_end (if any). + +function _build_clamped_system_with_derivs(params, p, has_sd, has_ed, extra_pts=0) = + let( + n = len(params) - 1, + n_extra = (has_sd ? 1 : 0) + (has_ed ? 1 : 0), + // Average n+1 data params to get base interior knots, then + // insert extra knots for boundary constraints. Each insertion + // bisects the span containing the constraint parameter + // (largest span first). Constraint params 0 and 1 land in + // the first and last spans respectively. + base_int = _avg_knots_interior(params, p), + base_bar = [0, each base_int, 1], + constraint_ts = [if (has_sd) params[0], if (has_ed) params[n]], + after_constr = _insert_constraint_knots(base_bar, constraint_ts), + // Insert extra_pts knots at widest spans. + extra_ts = extra_pts == 0 ? [] + : _widest_span_params(after_constr, extra_pts), + aug_bar_raw = extra_pts == 0 ? after_constr + : _insert_constraint_knots(after_constr, extra_ts), + occ_splits = extra_pts == 0 ? [] + : _span_split_params(aug_bar_raw, params), + n_occ = len(occ_splits), + M = n + 1 + n_extra + len(extra_ts) + n_occ, + aug_bar_merged = n_occ == 0 ? aug_bar_raw + : sort([each aug_bar_raw, each occ_splits]), + aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), + int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], + U_full = _full_clamped_knots(int_kn, p), + interp_rows = [for (k = [0:1:n]) + [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] + ], + deriv_start = has_sd + ? [[for (j = [0:1:M-1]) _dnip(j, p, params[0], U_full)]] + : [], + deriv_end = has_ed + ? [[for (j = [0:1:M-1]) _dnip(j, p, params[n], U_full)]] + : [], + knots = [0, each int_kn, 1] + ) + [[each interp_rows, each deriv_start, each deriv_end], knots]; + + +// Precompute per-segment interpolation systems for edge-aware surface solves. +// All rows (or columns) share the same averaged parameterization, so the +// collocation matrices only need to be built once. +// +// params = averaged parameter values for this direction +// p = degree +// edge_idxs = sorted list of interior indices where C0 edges occur +// has_sd = if true, first segment gets a start-derivative row +// has_ed = if true, last segment gets an end-derivative row +// +// Returns a list of [N_mat, xknots, seg_p, i0, i1, seg_sd, seg_ed] +// per segment, where seg_sd/seg_ed indicate whether that segment's +// system includes a derivative row. + +function _build_edge_systems(params, p, edge_idxs, + has_sd=false, has_ed=false, extra_pts=0, label="") = + let( + n = len(params) - 1, + seg_bounds = [0, each edge_idxs, n], + n_segs = len(seg_bounds) - 1, + + // Pre-compute seg_p and available interior knot spans per segment. + // For a segment with n_pts data points at degree seg_p, the averaged + // interior knot vector has (n_pts-1) - seg_p entries = that many spans. + seg_n_pts = [for (s = [0:1:n_segs-1]) seg_bounds[s+1] - seg_bounds[s] + 1], + seg_p_arr = [for (npts = seg_n_pts) min(p, npts - 1)], + avail_spans = [for (i = [0:1:n_segs-1]) + max(0, seg_n_pts[i] - 1 - seg_p_arr[i])], + total_avail = sum(avail_spans), + k_use = min(extra_pts, total_avail), + + // Emit one diagnostic when extra_pts exceeds the combined span budget. + _echo = extra_pts > 0 && extra_pts > total_avail && label != "" + ? echo(str("nurbs_interp_surface: extra_pts (", label, "-direction)=", + extra_pts, " exceeds available knot spans across ", + n_segs, " segment(s) (max ", total_avail, " total); ", + "reduced to ", total_avail, ".")) + : 0, + + // Distribute k_use proportionally to avail_spans, capped per segment. + seg_ep = extra_pts == 0 || total_avail == 0 ? repeat(0, n_segs) + : [for (s = [0:1:n_segs-1]) + avail_spans[s] == 0 ? 0 + : min(avail_spans[s], + ceil(k_use * avail_spans[s] / total_avail))] + ) + [for (s = [0:1:n_segs-1]) + let( + i0 = seg_bounds[s], + i1 = seg_bounds[s+1], + seg_par = [for (k = [i0:1:i1]) params[k]], + // Remap to [0,1] + t0 = seg_par[0], + t1 = last(seg_par), + span = max(t1 - t0, 1e-15), + local_p = [for (t = seg_par) (t - t0) / span], + seg_p = seg_p_arr[s], + // Derivative extension requires at least seg_p+1 data points + // (same minimum as basic interpolation); each derivative row + // adds one control point and one equation, keeping the system + // square. Degree-reduced segments with fewer points silently + // skip the constraint. + n_pts = seg_n_pts[s], + seg_sd = has_sd && s == 0 && n_pts >= seg_p + 1, + seg_ed = has_ed && s == n_segs - 1 && n_pts >= seg_p + 1, + // extra_pts only applies when degree >= 2; silently skip for + // degree-reduced (seg_p < 2) segments. + cur_ep = seg_p >= 2 ? seg_ep[s] : 0, + sys = (seg_sd || seg_ed) + ? _build_clamped_system_with_derivs(local_p, seg_p, + seg_sd, seg_ed, cur_ep) + : _build_interp_system(local_p, seg_p, "clamped", cur_ep) + ) + [sys[0], sys[1], seg_p, i0, i1, seg_sd, seg_ed] + ]; + + +// Solve one row (or column) using precomputed edge-aware systems. +// Each segment is solved independently; short segments are degree-elevated. +// Results are assembled into a single clamped B-spline via _combine_corner_segs. +// +// systems = list from _build_edge_systems +// data = row/column data points (same length as params) +// params = averaged parameter values +// edge_idxs = edge index list (same as passed to _build_edge_systems) +// p = target degree +// start_deriv = derivative vector at start of first segment (undef if none) +// end_deriv = derivative vector at end of last segment (undef if none) + +function _solve_with_edges(systems, data, params, edge_idxs, p, + start_deriv=undef, end_deriv=undef, smooth=3) = + let( + raw_segments = [for (sys = systems) + let( + N_mat = sys[0], + knots = sys[1], + i0 = sys[3], + i1 = sys[4], + seg_p = sys[2], + seg_sd = sys[5], + seg_ed = sys[6], + seg_data = [for (k = [i0:1:i1]) data[k]], + rhs = concat(seg_data, + seg_sd ? [start_deriv] : [], + seg_ed ? [end_deriv] : []), + M = len(N_mat[0]), + N_rows = len(rhs), + // When M > N_rows the segment system is underdetermined (extra_pts). + // Use null-space method: exact interpolation + minimum bending energy. + ctrl = M > N_rows + ? let( + int_kn = [for (i = [1:1:len(knots)-2]) knots[i]], + U_full = _full_clamped_knots(int_kn, seg_p), + eff_smooth = (smooth == 3 && seg_p < 2) ? 2 : smooth, + R = _regularization_matrix(M, eff_smooth, seg_p, U_full) + ) + _nullspace_solve(R, N_mat, rhs) + : linear_solve(N_mat, rhs) + ) + assert(ctrl != [] && !is_undef(ctrl), + str("nurbs_interp_surface: singular edge-segment system for rows/cols ", + i0, "-", i1, " (", i1-i0+1, " points, degree ", seg_p, + seg_sd ? ", start deriv" : "", + seg_ed ? ", end deriv" : "", ")")) + [ctrl, knots, seg_p] + ], + // Degree-elevate short segments to full degree p. + segments = [for (seg = raw_segments) + seg[2] == p ? seg + : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], + type="clamped", times=p - seg[2])) + [elev[2], elev[3], p] + ] + ) + _combine_corner_segs(segments, params, edge_idxs, p); + + +// Section: NURBS Surface Interpolation + +// Function&Module: nurbs_interp_surface() +// Synopsis: Returns a NURBS surface that passes through a grid of 3D data points. +// SynTags: Geom +// Topics: NURBS Surfaces, Interpolation +// See Also: nurbs_vnf(), nurbs_interp() +// +// Usage: As a function, returns a NURBS parameter list: +// nurbs_param = nurbs_interp_surface(points, degree, [method=], [row_wrap=], [col_wrap=], [normal1=], [normal2=], [flat_edges=], [flat_end1=], [flat_end2=], [row_edges=], [col_edges=], [extra_pts=], [smooth=], [first_row_deriv=], [last_row_deriv=], [first_col_deriv=], [last_col_deriv=]); +// Usage: As a module, renders the surface directly: +// nurbs_interp_surface(points, degree, [splinesteps=], [row_wrap=], [col_wrap=], [method=], [extra_pts=], [smooth=], ...) CHILDREN; +// Description: +// Finds the control points and knot vectors for a NURBS surface of the specified degree that passes +// exactly through every data point in a grid of 3D points. The result has +// uniform weights but non-uniform knots so it is actually a non-uniform B-spline. +// When called as a function, the return value is a NURBS parameter list +// `[type, degree, ctrl_grid, knots, undef, undef, uv]` that can be passed +// directly to `{{nurbs_vnf()}}`. The extra return value `uv`, +// described in detail below, enables you to locate your input points in the computed spline +// When called as a module, renders the NURBS surface as geometry. +// . +// Several of the parameters that correspond to parameters for {{nurbs_interp()}} +// can be given as either a scalar or 2-vector. When you give a 2-vector the +// first value applies along the first index of your point data, i.e. from row +// to row, or along columns. The second value applies along the second index, +// i.e. within rows. +// . +// Setting `row_wrap=true` smoothly connects the first and last rows in a loop, +// and `col_wrap=true` smoothly joins the first and last columns. Both false (the default) gives a +// surface with four edges. One true gives a tube; both true gives a torus. +// A tube by itself is not a valid closed manifold in OpenSCAD; you can make it valid by adding caps or +// you can close it into a ball by specifying degenerate edges where the entire edge collapses to +// one identical point. +// . +// **Boundary constraints** +// . +// Flat boundary (`row_wrap=false, col_wrap=false`) — `flat_edges=`. Applies when +// all four surface edges are coplanar. Set `flat_edges` to a 4-element list +// `[first_row, last_row, first_col, last_col]`; each entry is a scalar or per-point list +// giving the derivative scale for that edge (`undef` leaves the edge unconstrained). +// `flat_edges=s` expands to `[s,s,s,s]`. A positive value flares the surface +// outward from the edge; negative turns it inward. +// . +// End normals (one of `row_wrap`/`col_wrap` true, the other false) — `normal1=` and +// `normal2=`. Apply when the specified boundary edge is degenerate (all points +// identical, e.g. a cone tip). The surface is constrained to be normal to the given +// vector at that edge. The vector magnitude controls how broadly the surface spreads. +// . +// Flat ends (one of `row_wrap`/`col_wrap` true, the other false) — `flat_end1=` and +// `flat_end2=`. Apply when the specified boundary edge is coplanar and non-degenerate. +// Constrains the derivative to lie in the plane of the edge. Positive points inward +// (smooth cap attachment); negative flares outward. Scalar or per-point list. +// . +// **Advanced boundary derivatives** — `first_row_deriv=`, `last_row_deriv=`, +// `first_col_deriv=`, and `last_col_deriv=` enforce specific first partial derivatives +// along the four boundary edges. Each accepts a single vector (applied to every +// point on the edge) or a list of vectors (one per point). Vectors are scaled by +// total chord length, so a unit vector matches the parameterization speed. These +// require `row_wrap=false` (for row derivs) or `col_wrap=false` (for col derivs). +// . +// Use with care: the solver enforces derivatives exactly at data points but the +// surface may wander between them. When both u- and v-boundary derivatives are +// active, the cross-derivative is assumed zero at corners. +// . +// **Edges** — `row_edges=` and `col_edges=` insert edges or creases across the surface. +// Use `row_edges=` to specify the indices of rows that will be edges or creases, +// and `col_edges=` to specify the indices of columns that will be edges or creases. +// For a non-wrapped direction, indices must be interior (not first or last). +// If you place edges close together, the effective degree of a narrow patch between +// edges may be reduced. These patches are assembled into a single NURBS so this +// process is transparent to the user. +// . +// **Extra control points** (`extra_pts=`, `smooth=`) — By default the solver uses +// exactly the number of control points needed to satisfy the constraints, which +// gives a unique solution that may be badly behaved. Specifying `extra points=` +// and optionally `smooth=`, works the same way as in +// for {{nurbs_interp()}}. Both parameters can be scalars or 2-vectors to +// provide different values along the two directions. +// . +// **Locating points in the spline** — In order to locate your original data +// points in the spline you need the `u` and `v` nurbs parameter values that you +// can pass to {{nurbs_patch_points()}}. The last return value `uv` gives these: +// `uv[0][j]` is the u parameter for row `j` and `uv[1][k]` is the v parameter +// for column `k`, so the point `points[j][k]` lies at `(uv[0][j], uv[1][k])` +// in NURBS parameter space. +// . +// **Smoothness** — The smoothness of B-splines is determined by the +// degree. If you request a degree p spline then it will be C^(p-1) at +// knot points and C^inf everywhere else. If you request edges then +// these are points where the surface is not differentiable; edges may +// also divide the surface into smaller regions that lack sufficient points +// to support an interpolation of your requested degree: a degree p interpolation +// requires p+1 points. In this case, the interpolation is performed at a lower +// degree and elevated, which means it will be less smooth at knots. +// Arguments: +// points = Rectangular grid of 3D data points +// degree = scalar or 2-vector giving the degree of the B-spline in the two directions. +// splinesteps = (module) Scalar or 2-vector giving the number of segments between each knot in the two directions. Default: 16 +// --- +// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` +// row_wrap = If true, smoothly connect the first row to the last row. Default: false +// col_wrap = If true, smoothly connect the first column to the last column. Default: false +// extra_pts = Scalar or 2-vector giving the number of extra points in the two directions. Default: `0` +// smooth = Scalar or 2-vector giving the smoothness metric for extra points in the two directions: `1` (min polygon length), `2` (min bending), `3` (min bending energy). Default: `3` +// flat_edges = 4-element list `[first_row, last_row, first_col, last_col]` of derivative scales at the four coplanar boundary edges. Each entry is a scalar or per-point list; `undef` leaves that edge unconstrained. Shorthand: `flat_edges=s` → `[s,s,s,s]`. Requires `row_wrap=false, col_wrap=false`. +// normal1 = Surface normal at the first degenerate boundary edge (mixed wrap surface only). +// normal2 = Surface normal at the second degenerate boundary edge (mixed wrap surface only). +// flat_end1 = Inward derivative scale at the first coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. +// flat_end2 = Inward derivative scale at the second coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. +// row_edges = Row indices (or index) of rows that are treated as edges or creases. +// col_edges = Column indices (or index) of columns that are treated as edges or creases +// first_row_deriv = dS/du constraint along u=0 (first row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. +// last_row_deriv = dS/du constraint along u=1 (last row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. +// first_col_deriv = dS/dv constraint along v=0 (first column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. +// last_col_deriv = dS/dv constraint along v=1 (last column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. +// data_size = (module) Radius of data-point markers; 0 suppresses markers. Default: 0 +// data_color = (module) Color for data-point markers. Default: `"red"` +// style = (module) Triangulation style passed to `vnf_vertex_array()`. Default: `"default"` +// reverse = (module) If true, reverses face normals. Default: false +// triangulate = (module) If true, triangulates all quads. Default: false +// caps = (module) Cap both open boundary edges (mixed wrap only). Default: false +// cap1 = (module) Cap the first open boundary edge. +// cap2 = (module) Cap the second open boundary edge. +// cp = (module) Centerpoint for determining intersection anchors or centering the shape. Determines the base of the anchor vector. Can be "centroid", "mean", "box" or a 3D point. Default: "centroid" +// anchor = (module) Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `"origin"` +// spin = (module) Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0` +// orient = (module) Vector to rotate top toward, after spin. See [orient](attachments.scad#subsection-orient). Default: `UP` +// atype = (module) Select "hull" or "intersect" anchor type. Default: "hull" + +function nurbs_interp_surface(points, degree, method="centripetal", + row_wrap=false, col_wrap=false, + first_row_deriv=undef, last_row_deriv=undef, + first_col_deriv=undef, last_col_deriv=undef, + normal1=undef, normal2=undef, + flat_end1=undef, flat_end2=undef, + flat_edges=undef, + row_edges=undef, col_edges=undef, + extra_pts=0, smooth=3) = + // Preamble: extract shape/edge info needed for closed-direction dispatch. + let( + n_rows = len(points), + n_cols = len(points[0]), + ue_norm_pre = is_undef(row_edges) ? undef : force_list(row_edges), + ve_norm_pre = is_undef(col_edges) ? undef : force_list(col_edges), + has_ue_pre = !is_undef(ue_norm_pre) && len(ue_norm_pre) > 0, + has_ve_pre = !is_undef(ve_norm_pre) && len(ve_norm_pre) > 0 + ) + // col_edges on a closed v-direction: rotate columns so the first crease column + // becomes the v=0/v=1 boundary, append a copy at the end for the C0 seam, + // then recurse with col_wrap=false. Remaining crease indices are shifted + // into the rotated coordinate system. + has_ve_pre && col_wrap ? + let( + ve_sorted = sort(ve_norm_pre), + rot = ve_sorted[0], + new_pts = [for (row = points) + concat([for (l = [rot:1:n_cols-1]) row[l]], + [for (l = [0:1:rot-1]) row[l]], + [row[rot]])], + adj_ve_raw = [for (i = [1:1:len(ve_sorted)-1]) + let(j = (ve_sorted[i] - rot + n_cols) % n_cols) + if (j > 0) j], + adj_ve = len(adj_ve_raw) == 0 ? undef : adj_ve_raw + ) + let(inner = nurbs_interp_surface(new_pts, degree, method=method, + row_wrap=row_wrap, col_wrap=false, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, + row_edges=row_edges, col_edges=adj_ve, + extra_pts=extra_pts, smooth=smooth)) + [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], + [inner[6][0], + list_rotate(select(inner[6][1], 0, n_cols-1), -rot)]] + // row_edges on a closed u-direction: rotate rows so the first crease row + // becomes the u=0/u=1 boundary, append a copy at the end, recurse clamped. + : has_ue_pre && row_wrap ? + let( + ue_sorted = sort(ue_norm_pre), + rot = ue_sorted[0], + new_pts = concat([for (k = [rot:1:n_rows-1]) points[k]], + [for (k = [0:1:rot-1]) points[k]], + [points[rot]]), + adj_ue_raw = [for (i = [1:1:len(ue_sorted)-1]) + let(j = (ue_sorted[i] - rot + n_rows) % n_rows) + if (j > 0) j], + adj_ue = len(adj_ue_raw) == 0 ? undef : adj_ue_raw + ) + let(inner = nurbs_interp_surface(new_pts, degree, method=method, + row_wrap=false, col_wrap=col_wrap, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, + row_edges=adj_ue, col_edges=col_edges, + extra_pts=extra_pts, smooth=smooth)) + [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], + [list_rotate(select(inner[6][0], 0, n_rows-1), -rot), + inner[6][1]]] + // Normal path: both directions already clamped, or no conflicting edge constraints. + : let( + p_u = is_list(degree) ? degree[0] : degree, + p_v = is_list(degree) ? degree[1] : degree, + ep_u = is_list(extra_pts) ? extra_pts[0] : extra_pts, + ep_v = is_list(extra_pts) ? extra_pts[1] : extra_pts, + smooth_u = is_list(smooth) ? smooth[0] : smooth, + smooth_v = is_list(smooth) ? smooth[1] : smooth, + n_rows = len(points), + n_cols = len(points[0]), + dim = len(points[0][0]), + // Scalar-vector promotion: if the caller passes a single vector instead of + // a list of vectors, repeat() it to the required length. A single vector + // is detected as a list whose first element is a number, not a list. + first_row_deriv = is_undef(first_row_deriv) || is_list(first_row_deriv[0]) ? first_row_deriv + : repeat(first_row_deriv, n_cols), + last_row_deriv = is_undef(last_row_deriv) || is_list(last_row_deriv[0]) ? last_row_deriv + : repeat(last_row_deriv, n_cols), + first_col_deriv = is_undef(first_col_deriv) || is_list(first_col_deriv[0]) ? first_col_deriv + : repeat(first_col_deriv, n_rows), + last_col_deriv = is_undef(last_col_deriv) || is_list(last_col_deriv[0]) ? last_col_deriv + : repeat(last_col_deriv, n_rows), + // Treat an all-undef derivative list the same as undef. + has_sud = !is_undef(first_row_deriv) && num_defined(first_row_deriv) > 0, + has_eud = !is_undef(last_row_deriv) && num_defined(last_row_deriv) > 0, + has_svd = !is_undef(first_col_deriv) && num_defined(first_col_deriv) > 0, + has_evd = !is_undef(last_col_deriv) && num_defined(last_col_deriv) > 0, + has_sn = !is_undef(normal1), + has_en = !is_undef(normal2), + // normal1/normal2: apex edges only (all boundary points identical, e.g. cone tip). + // Auto-detect u=0/v=0 direction; u=0 (first row) takes priority. + start_u_apex = has_sn && max([for (pt = points[0]) norm(pt - points[0][0])]) < 1e-10, + start_v_apex = has_sn && max([for (k = [0:1:n_rows-1]) norm(points[k][0] - points[0][0])]) < 1e-10, + end_u_apex = has_en && max([for (pt = points[n_rows-1]) norm(pt - points[n_rows-1][0])]) < 1e-10, + end_v_apex = has_en && max([for (k = [0:1:n_rows-1]) norm(points[k][n_cols-1] - points[0][n_cols-1])]) < 1e-10, + has_sun = has_sn && start_u_apex, + has_eun = has_en && end_u_apex, + has_svn = has_sn && !start_u_apex && start_v_apex, + has_evn = has_en && !end_u_apex && end_v_apex, + start_u_degen = start_u_apex, + start_v_degen = start_v_apex, + end_u_degen = end_u_apex, + end_v_degen = end_v_apex, + // flat_end1/flat_end2: coplanar non-collinear edges (points span a plane). + // Scalar or per-point list. positive = closes inward, negative = flares outward. + // Direction is determined by the clamped direction of the surface: + // row_wrap=false → flat_end applies to row boundaries (u-direction, first/last row). + // col_wrap=false → flat_end applies to column boundaries (v-direction, first/last col). + // Exactly one direction must be clamped (enforced by assertion below). + has_fe1 = !is_undef(flat_end1), + has_fe2 = !is_undef(flat_end2), + has_fe1_u = has_fe1 && !row_wrap, + has_fe1_v = has_fe1 && !col_wrap, + has_fe2_u = has_fe2 && !row_wrap, + has_fe2_v = has_fe2 && !col_wrap, + // Boundary edges for coplanar validation. + fe1_edge = has_fe1_u ? points[0] + : has_fe1_v ? [for (k = [0:1:n_rows-1]) points[k][0]] + : [], + fe2_edge = has_fe2_u ? points[n_rows-1] + : has_fe2_v ? [for (k = [0:1:n_rows-1]) points[k][n_cols-1]] + : [], + fe1_ok = !has_fe1 || (_is_coplanar_pts(fe1_edge) && !is_undef(_pts_plane_normal(fe1_edge))), + fe2_ok = !has_fe2 || (_is_coplanar_pts(fe2_edge) && !is_undef(_pts_plane_normal(fe2_edge))), + // flat_edges= parsing: 4-element list [first_row, last_row, first_col, last_col]. + // Scalar shorthand: flat_edges=s expands to [s, s, s, s]. + fe_norm = !is_undef(flat_edges) && !is_list(flat_edges) + ? [flat_edges, flat_edges, flat_edges, flat_edges] + : flat_edges, + has_fe = !is_undef(fe_norm), + fe_su = has_fe ? fe_norm[0] : undef, + fe_eu = has_fe ? fe_norm[1] : undef, + fe_sv = has_fe ? fe_norm[2] : undef, + fe_ev = has_fe ? fe_norm[3] : undef, + has_fesu = has_fe && !is_undef(fe_su), + has_feeu = has_fe && !is_undef(fe_eu), + has_fesv = has_fe && !is_undef(fe_sv), + has_feev = has_fe && !is_undef(fe_ev), + // Edge (C0 discontinuity) support. Singleton promotion: scalar → list. + ue_norm = is_undef(row_edges) ? undef : force_list(row_edges), + ve_norm = is_undef(col_edges) ? undef : force_list(col_edges), + has_ue = !is_undef(ue_norm) && len(ue_norm) > 0, + has_ve = !is_undef(ve_norm) && len(ve_norm) > 0 + ) + assert(is_list(points) && n_rows >= 2, + "nurbs_interp_surface: need at least 2 rows") + assert(n_cols >= 2, + "nurbs_interp_surface: need at least 2 columns") + assert(min([for (row = points) len(row)]) == max([for (row = points) len(row)]), + "nurbs_interp_surface: all rows must have the same number of columns") + assert(is_num(p_u) && p_u >= 1 && is_num(p_v) && p_v >= 1, + "nurbs_interp_surface: degree must be >= 1") + assert(method == "length" || method == "centripetal" || method == "dynamic" + || method == "foley" || method == "fang", + str("nurbs_interp_surface: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) + assert(is_num(ep_u) && ep_u >= 0 && ep_u == floor(ep_u), + str("nurbs_interp_surface: extra_pts (u) must be a non-negative integer, got ", ep_u)) + assert(is_num(ep_v) && ep_v >= 0 && ep_v == floor(ep_v), + str("nurbs_interp_surface: extra_pts (v) must be a non-negative integer, got ", ep_v)) + assert(ep_u == 0 || p_u >= 2, + "nurbs_interp_surface: extra_pts in u-direction requires u-degree >= 2") + assert(ep_v == 0 || p_v >= 2, + "nurbs_interp_surface: extra_pts in v-direction requires v-degree >= 2") + assert(n_rows >= p_u + 1, + str("nurbs_interp_surface: need at least ", p_u+1, + " rows for u-degree ", p_u, ", got ", n_rows)) + assert(n_cols >= p_v + 1, + str("nurbs_interp_surface: need at least ", p_v+1, + " columns for v-degree ", p_v, ", got ", n_cols)) + assert(!(has_sud || has_eud || has_sun || has_eun || has_fesu || has_feeu || has_fe1_u || has_fe2_u) || !row_wrap, + "nurbs_interp_surface: u-direction derivative/normal/flat_end/flat_edges params require row_wrap=false") + assert(!(has_svd || has_evd || has_svn || has_evn || has_fesv || has_feev || has_fe1_v || has_fe2_v) || !col_wrap, + "nurbs_interp_surface: v-direction derivative/normal/flat_end/flat_edges params require col_wrap=false") + assert(!has_sud || len(first_row_deriv) == n_cols, + str("nurbs_interp_surface: first_row_deriv must have ", n_cols, + " entries (one per column), got ", is_undef(first_row_deriv) ? 0 : len(first_row_deriv))) + assert(!has_eud || len(last_row_deriv) == n_cols, + str("nurbs_interp_surface: last_row_deriv must have ", n_cols, + " entries (one per column), got ", is_undef(last_row_deriv) ? 0 : len(last_row_deriv))) + assert(!has_svd || len(first_col_deriv) == n_rows, + str("nurbs_interp_surface: first_col_deriv must have ", n_rows, + " entries (one per row), got ", is_undef(first_col_deriv) ? 0 : len(first_col_deriv))) + assert(!has_evd || len(last_col_deriv) == n_rows, + str("nurbs_interp_surface: last_col_deriv must have ", n_rows, + " entries (one per row), got ", is_undef(last_col_deriv) ? 0 : len(last_col_deriv))) + // normal1/normal2 assertions: apex edges only. + assert(!has_sn || (start_u_degen || start_v_degen), + "nurbs_interp_surface: normal1 requires a degenerate start edge (first row or first column must be all the same point)") + assert(!has_en || (end_u_degen || end_v_degen), + "nurbs_interp_surface: normal2 requires a degenerate end edge (last row or last column must be all the same point)") + assert(!has_sn || !(start_u_degen && start_v_degen), + "nurbs_interp_surface: normal1 is ambiguous — both u=0 and v=0 edges are degenerate; use first_row_deriv or first_col_deriv explicitly") + assert(!has_en || !(end_u_degen && end_v_degen), + "nurbs_interp_surface: normal2 is ambiguous — both u=1 and v=1 edges are degenerate; use last_row_deriv or last_col_deriv explicitly") + assert(!(has_sun && has_sud), + "nurbs_interp_surface: normal1 resolves to u-direction but first_row_deriv was also given") + assert(!(has_eun && has_eud), + "nurbs_interp_surface: normal2 resolves to u-direction but last_row_deriv was also given") + assert(!(has_svn && has_svd), + "nurbs_interp_surface: normal1 resolves to v-direction but first_col_deriv was also given") + assert(!(has_evn && has_evd), + "nurbs_interp_surface: normal2 resolves to v-direction but last_col_deriv was also given") + // flat_end1/flat_end2 assertions. + // Direction is determined by the clamped type; surface must be mixed clamped/closed. + assert(!has_fe1 || (row_wrap != col_wrap), + "nurbs_interp_surface: flat_end1 requires the surface to be clamped in one direction and closed in the other") + assert(!has_fe2 || (row_wrap != col_wrap), + "nurbs_interp_surface: flat_end2 requires the surface to be clamped in one direction and closed in the other") + assert(fe1_ok, + has_fe1_u + ? "nurbs_interp_surface: flat_end1 requires the first row (u=0 boundary) to be coplanar and non-collinear" + : "nurbs_interp_surface: flat_end1 requires the first column (v=0 boundary) to be coplanar and non-collinear. If your first row is coplanar, try row_wrap=true, col_wrap=false.") + assert(fe2_ok, + has_fe2_u + ? "nurbs_interp_surface: flat_end2 requires the last row (u=1 boundary) to be coplanar and non-collinear" + : "nurbs_interp_surface: flat_end2 requires the last column (v=1 boundary) to be coplanar and non-collinear. If your last row is coplanar, try row_wrap=true, col_wrap=false.") + assert(!(has_fe1_u && has_sud), + "nurbs_interp_surface: flat_end1 conflicts with first_row_deriv") + assert(!(has_fe2_u && has_eud), + "nurbs_interp_surface: flat_end2 conflicts with last_row_deriv") + assert(!(has_fe1_v && has_svd), + "nurbs_interp_surface: flat_end1 conflicts with first_col_deriv") + assert(!(has_fe2_v && has_evd), + "nurbs_interp_surface: flat_end2 conflicts with last_col_deriv") + assert(!(has_fe1_u && has_fesu), + "nurbs_interp_surface: flat_end1 conflicts with flat_edges[0] on same edge") + assert(!(has_fe2_u && has_feeu), + "nurbs_interp_surface: flat_end2 conflicts with flat_edges[1] on same edge") + assert(!(has_fe1_v && has_fesv), + "nurbs_interp_surface: flat_end1 conflicts with flat_edges[2] on same edge") + assert(!(has_fe2_v && has_feev), + "nurbs_interp_surface: flat_end2 conflicts with flat_edges[3] on same edge") + assert(!has_fe1 || is_num(flat_end1) || len(flat_end1) == (has_fe1_u ? n_cols : n_rows), + str("nurbs_interp_surface: flat_end1 list must have ", has_fe1_u ? n_cols : n_rows, " entries")) + assert(!has_fe2 || is_num(flat_end2) || len(flat_end2) == (has_fe2_u ? n_cols : n_rows), + str("nurbs_interp_surface: flat_end2 list must have ", has_fe2_u ? n_cols : n_rows, " entries")) + // flat_edges assertions. + assert(!has_fe || (is_list(fe_norm) && len(fe_norm) == 4), + "nurbs_interp_surface: flat_edges must be a scalar or 4-element list [first_row, last_row, first_col, last_col]") + assert(!(has_fesu && has_sud), + "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with first_row_deriv") + assert(!(has_feeu && has_eud), + "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with last_row_deriv") + assert(!(has_fesv && has_svd), + "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with first_col_deriv") + assert(!(has_feev && has_evd), + "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with last_col_deriv") + assert(!(has_fesu && has_sun), + "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with normal1 on same edge") + assert(!(has_feeu && has_eun), + "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with normal2 on same edge") + assert(!(has_fesv && has_svn), + "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with normal1 on same edge") + assert(!(has_feev && has_evn), + "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with normal2 on same edge") + assert(!has_fesu || !is_list(fe_su) || len(fe_su) == n_cols, + str("nurbs_interp_surface: flat_edges[0] scale list must have ", n_cols, " entries (one per column)")) + assert(!has_feeu || !is_list(fe_eu) || len(fe_eu) == n_cols, + str("nurbs_interp_surface: flat_edges[1] scale list must have ", n_cols, " entries (one per column)")) + assert(!has_fesv || !is_list(fe_sv) || len(fe_sv) == n_rows, + str("nurbs_interp_surface: flat_edges[2] scale list must have ", n_rows, " entries (one per row)")) + assert(!has_feev || !is_list(fe_ev) || len(fe_ev) == n_rows, + str("nurbs_interp_surface: flat_edges[3] scale list must have ", n_rows, " entries (one per row)")) + // Edge (C0) validation. + assert(!has_ue || !row_wrap, + "nurbs_interp_surface: row_edges requires row_wrap=false") + assert(!has_ve || !col_wrap, + "nurbs_interp_surface: col_edges requires col_wrap=false") + assert(!has_ue || (min(ue_norm) >= 1 && max(ue_norm) <= n_rows-2), + str("nurbs_interp_surface: row_edges indices must be interior (1..", n_rows-2, ")")) + assert(!has_ve || (min(ve_norm) >= 1 && max(ve_norm) <= n_cols-2), + str("nurbs_interp_surface: col_edges indices must be interior (1..", n_cols-2, ")")) + // row_edges / col_edges are compatible with same-direction boundary derivatives, + // normals, and flat_edges: the first/last segment of the edge-aware system + // carries the boundary derivative constraint. + let( + // Boundary plane for flat_edges=: cross product of two perimeter vectors. + // Guarded so degenerate geometry can't produce NaN when flat_edges is unused. + fe_e1 = has_fe ? (points[0][n_cols-1] - points[0][0]) : [1,0,0], + fe_e2 = has_fe ? (points[n_rows-1][0] - points[0][0]) : [0,1,0], + fe_N_raw = has_fe ? cross(fe_e1, fe_e2) : [0,0,1], + fe_N_hat = fe_N_raw / max(norm(fe_N_raw), 1e-15), + // Per-edge flat-outward derivative lists; undef when edge not active. + // Direction at each point: from adjacent interior point toward edge, + // projected into the boundary plane, then normalized and scaled. + flat_su_der = !has_fesu ? undef : + [for (j = [0:1:n_cols-1]) + let( + d = points[1][j] - points[0][j], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_su) ? fe_su[j] : fe_su + ) d_hat * s], + flat_eu_der = !has_feeu ? undef : + [for (j = [0:1:n_cols-1]) + let( + d = points[n_rows-1][j] - points[n_rows-2][j], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_eu) ? fe_eu[j] : fe_eu + ) d_hat * s], + flat_sv_der = !has_fesv ? undef : + [for (k = [0:1:n_rows-1]) + let( + d = points[k][1] - points[k][0], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_sv) ? fe_sv[k] : fe_sv + ) d_hat * s], + flat_ev_der = !has_feev ? undef : + [for (k = [0:1:n_rows-1]) + let( + d = points[k][n_cols-1] - points[k][n_cols-2], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_ev) ? fe_ev[k] : fe_ev + ) d_hat * s] + ) + assert(!has_fesu || min([for (j = [0:1:n_cols-1]) let(d = points[1][j] - points[0][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[0] (first_row) direction is perpendicular to the boundary plane at one or more points") + assert(!has_feeu || min([for (j = [0:1:n_cols-1]) let(d = points[n_rows-1][j] - points[n_rows-2][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[1] (last_row) direction is perpendicular to the boundary plane at one or more points") + assert(!has_fesv || min([for (k = [0:1:n_rows-1]) let(d = points[k][1] - points[k][0], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[2] (first_col) direction is perpendicular to the boundary plane at one or more points") + assert(!has_feev || min([for (k = [0:1:n_rows-1]) let(d = points[k][n_cols-1] - points[k][n_cols-2], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[3] (last_col) direction is perpendicular to the boundary plane at one or more points") + assert(!has_fe || is_coplanar(concat( + points[0], points[n_rows-1], + [for (k = [1:1:n_rows-2]) points[k][0]], + [for (k = [1:1:n_rows-2]) points[k][n_cols-1]]), eps=1e-6), + "nurbs_interp_surface: flat_edges= requires all four boundary edges to be coplanar") + let( + // Compute effective derivative lists. + // Priority: normal1/normal2 (apex) > flat_end1/flat_end2 (coplanar) > flat_edges > explicit *_der=. + // Apex (all boundary points identical): fan outward from apex, user axis vector N. + // End-edge apex tangents are negated because _apex_tangents() returns outward + // (apex→ring) vectors; negating gives inward (ring→apex), making the surface + // converge to the apex tip at the correct parametric direction. + // Coplanar (flat_end): _coplanar_inward_tangents() returns in-plane vectors + // oriented toward the polygon interior using the polygon winding order. + // Positive scale closes inward, negative flares outward. + // flat_end1 result is negated: _coplanar_inward_tangents returns outward + // for the start boundary; negating gives the correct inward direction. + // flat_end2 uses the same function without negation (end boundary sign matches). + // Periodic tangent differences used when the cross-direction is "closed". + first_row_deriv_eff = has_sun + ? _apex_tangents(normal1, points[0][0], points[1]) + : has_fe1_u + ? [for (v = _coplanar_inward_tangents(flat_end1, points[0], points[1], + periodic=col_wrap)) -v] + : has_fesu ? flat_su_der + : first_row_deriv, + last_row_deriv_eff = has_eun + ? [for (v = _apex_tangents(normal2, points[n_rows-1][0], points[n_rows-2])) -v] + : has_fe2_u + ? _coplanar_inward_tangents(flat_end2, points[n_rows-1], points[n_rows-2], + periodic=col_wrap) + : has_feeu ? flat_eu_der + : last_row_deriv, + first_col_deriv_eff = has_svn + ? _apex_tangents(normal1, points[0][0], + [for (k = [0:1:n_rows-1]) points[k][1]]) + : has_fe1_v + ? [for (v = _coplanar_inward_tangents(flat_end1, + [for (k = [0:1:n_rows-1]) points[k][0]], + [for (k = [0:1:n_rows-1]) points[k][1]], + periodic=row_wrap)) -v] + : has_fesv ? flat_sv_der + : first_col_deriv, + last_col_deriv_eff = has_evn + ? [for (v = _apex_tangents(normal2, points[0][n_cols-1], + [for (k = [0:1:n_rows-1]) points[k][n_cols-2]])) -v] + : has_fe2_v + ? _coplanar_inward_tangents(flat_end2, + [for (k = [0:1:n_rows-1]) points[k][n_cols-1]], + [for (k = [0:1:n_rows-1]) points[k][n_cols-2]], + periodic=row_wrap) + : has_feev ? flat_ev_der + : last_col_deriv, + has_sud_eff = has_sud || has_sun || has_fesu || has_fe1_u, + has_eud_eff = has_eud || has_eun || has_feeu || has_fe2_u, + has_svd_eff = has_svd || has_svn || has_fesv || has_fe1_v, + has_evd_eff = has_evd || has_evn || has_feev || has_fe2_v + ) + // row_edges / col_edges boundary-derivative segment-size checks. + // A derivative-carrying edge segment needs at least 3 rows/columns; + // with only 2 the degree-reduced knot vector becomes degenerate. + assert(!(has_ue && has_sud_eff && ue_norm[0] + 1 < 3), + !has_ue ? "" : + str("nurbs_interp_surface: row_edges=", ue_norm, + " creates a ", ue_norm[0]+1, "-row first segment (rows 0-", + ue_norm[0], ") which is too short to carry the start-u derivative constraint. ", + "Move the first row_edges index to at least 2")) + assert(!(has_ue && has_eud_eff && n_rows - last(ue_norm) < 3), + !has_ue ? "" : + str("nurbs_interp_surface: row_edges=", ue_norm, + " creates a ", n_rows - last(ue_norm), "-row last segment (rows ", + last(ue_norm), "-", n_rows-1, ") which is too short to carry the end-u derivative constraint. ", + "Move the last row_edges index to at most ", n_rows - 3)) + assert(!(has_ve && has_svd_eff && ve_norm[0] + 1 < 3), + !has_ve ? "" : + str("nurbs_interp_surface: col_edges=", ve_norm, + " creates a ", ve_norm[0]+1, "-column first segment (columns 0-", + ve_norm[0], ") which is too short to carry the start-v derivative constraint. ", + "Move the first col_edges index to at least 2")) + assert(!(has_ve && has_evd_eff && n_cols - last(ve_norm) < 3), + !has_ve ? "" : + str("nurbs_interp_surface: col_edges=", ve_norm, + " creates a ", n_cols - last(ve_norm), "-column last segment (columns ", + last(ve_norm), "-", n_cols-1, ") which is too short to carry the end-v derivative constraint. ", + "Move the last col_edges index to at most ", n_cols - 3)) + let( + // Averaged parameterization in each direction + u_params = _surface_params_u(points, method, row_wrap), + v_params = _surface_params_v(points, method, col_wrap), + + // Per-row v-direction path lengths for scaling v-boundary tangents. + // Follows the curve convention: user passes normalized vectors; code + // scales by total chord length so a unit vector gives natural speed. + v_path_lens = [for (k = [0:1:n_rows-1]) path_length(points[k])], + + // Per-column u-direction path lengths for scaling u-boundary tangents. + u_path_lens = [for (l = [0:1:n_cols-1]) + path_length([for (k = [0:1:n_rows-1]) points[k][l]])], + + // ----- Build v-direction system ----- + // When col_edges is active, precompute per-segment collocation systems. + // Otherwise use the standard (or derivative-extended) system. + v_edge_sys = has_ve + ? _build_edge_systems(v_params, p_v, ve_norm, + has_sd=has_svd_eff, + has_ed=has_evd_eff, + extra_pts=ep_v, label="v") : undef, + v_sys = has_ve ? undef + : (has_svd_eff || has_evd_eff) + ? _build_clamped_system_with_derivs(v_params, p_v, has_svd_eff, has_evd_eff, ep_v) + : _build_interp_system(v_params, p_v, col_wrap ? "closed" : "clamped", ep_v), + N_v = has_ve ? undef : v_sys[0], + // When underdetermined (extra_pts), build regularization matrix for v. + M_v = has_ve ? undef : len(N_v[0]), + N_rows_v = has_ve ? undef : len(N_v), + ns_v = !has_ve && M_v > N_rows_v, + R_reg_v = !ns_v ? undef + : let(vk = v_sys[1], + vint = !col_wrap + ? [for (i = [1:1:len(vk)-2]) vk[i]] + : undef, + vU = !col_wrap + ? _full_clamped_knots(vint, p_v) + : _full_closed_knots(vk, M_v, p_v)) + _regularization_matrix(M_v, smooth_v, p_v, vU, periodic=col_wrap), + + // ----- Pass 1: Interpolate rows in v-direction ----- + // With col_edges: solve each row via edge-aware segmented system. + // Without: same A_v matrix for every row; only the RHS changes per row. + R_raw = has_ve + ? [for (k = [0:1:n_rows-1]) + _solve_with_edges(v_edge_sys, points[k], + v_params, ve_norm, p_v, + start_deriv = has_svd_eff + ? _force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k] + : undef, + end_deriv = has_evd_eff + ? _force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k] + : undef, + smooth = smooth_v)] + : undef, + R = has_ve + ? [for (r = R_raw) r[0]] + : [for (k = [0:1:n_rows-1]) + let(rhs = concat( + points[k], + has_svd_eff + ? [_force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k]] + : [], + has_evd_eff + ? [_force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k]] + : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, rhs) + : linear_solve(N_v, rhs) + ], + + v_knots = has_ve ? R_raw[0][1] : v_sys[1], + n_v_ctrl = len(R[0]), + + // ----- Pass 1.5: Project u-boundary tangents into v-control space ----- + // ∂S/∂u along u=0 or u=1 is given at the n_cols data v-positions. + // To use them as derivative RHS in the u-direction column solves, we + // must express them in the v B-spline control basis — done by solving + // the same v-system. When col_edges is active, project through the + // edge-aware segmented system instead. + zero_v = repeat(0, dim), + _su_der_data = has_sud_eff + ? [for (l = [0:1:n_cols-1]) + _force_deriv_dim(first_row_deriv_eff[l], dim) * u_path_lens[l]] + : undef, + _eu_der_data = has_eud_eff + ? [for (l = [0:1:n_cols-1]) + _force_deriv_dim(last_row_deriv_eff[l], dim) * u_path_lens[l]] + : undef, + T_u_start = has_sud_eff + ? has_ve + ? _solve_with_edges(v_edge_sys, _su_der_data, + v_params, ve_norm, p_v, + start_deriv = has_svd_eff ? zero_v : undef, + end_deriv = has_evd_eff ? zero_v : undef, + smooth = smooth_v)[0] + : let(_rhs = concat(_su_der_data, + has_svd_eff ? [zero_v] : [], + has_evd_eff ? [zero_v] : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) + : linear_solve(N_v, _rhs) + : undef, + T_u_end = has_eud_eff + ? has_ve + ? _solve_with_edges(v_edge_sys, _eu_der_data, + v_params, ve_norm, p_v, + start_deriv = has_svd_eff ? zero_v : undef, + end_deriv = has_evd_eff ? zero_v : undef, + smooth = smooth_v)[0] + : let(_rhs = concat(_eu_der_data, + has_svd_eff ? [zero_v] : [], + has_evd_eff ? [zero_v] : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) + : linear_solve(N_v, _rhs) + : undef, + + // ----- Build u-direction system ----- + // When row_edges is active, precompute per-segment systems. + u_edge_sys = has_ue + ? _build_edge_systems(u_params, p_u, ue_norm, + has_sd=has_sud_eff, + has_ed=has_eud_eff, + extra_pts=ep_u, label="u") : undef, + u_sys = has_ue ? undef + : (has_sud_eff || has_eud_eff) + ? _build_clamped_system_with_derivs(u_params, p_u, has_sud_eff, has_eud_eff, ep_u) + : _build_interp_system(u_params, p_u, row_wrap ? "closed" : "clamped", ep_u), + N_u = has_ue ? undef : u_sys[0], + // When underdetermined (extra_pts), build regularization matrix for u. + M_u = has_ue ? undef : len(N_u[0]), + N_rows_u = has_ue ? undef : len(N_u), + ns_u = !has_ue && M_u > N_rows_u, + R_reg_u = !ns_u ? undef + : let(uk = u_sys[1], + uint = !row_wrap + ? [for (i = [1:1:len(uk)-2]) uk[i]] + : undef, + uU = !row_wrap + ? _full_clamped_knots(uint, p_u) + : _full_closed_knots(uk, M_u, p_u)) + _regularization_matrix(M_u, smooth_u, p_u, uU, periodic=row_wrap), + + // ----- Pass 2: Interpolate columns in u-direction ----- + // Transpose R so each entry is a column of intermediate points. + R_T = [for (j = [0:1:n_v_ctrl-1]) + [for (k = [0:1:n_rows-1]) R[k][j]]], + + // With row_edges: solve each column via edge-aware segmented system. + // Without: add u-tangent constraint rows to the RHS for each column j. + P_T_raw = has_ue + ? [for (j = [0:1:n_v_ctrl-1]) + _solve_with_edges(u_edge_sys, R_T[j], + u_params, ue_norm, p_u, + start_deriv = has_sud_eff ? T_u_start[j] : undef, + end_deriv = has_eud_eff ? T_u_end[j] : undef, + smooth = smooth_u)] + : undef, + P_T = has_ue + ? [for (r = P_T_raw) r[0]] + : [for (j = [0:1:n_v_ctrl-1]) + let(rhs = concat( + R_T[j], + has_sud_eff ? [T_u_start[j]] : [], + has_eud_eff ? [T_u_end[j]] : [])) + ns_u ? _nullspace_solve(R_reg_u, N_u, rhs) + : linear_solve(N_u, rhs) + ], + + u_knots = has_ue ? P_T_raw[0][1] : u_sys[1], + + // Transpose back to get the final control point grid. + n_u_ctrl = len(P_T[0]), + P = [for (i = [0:1:n_u_ctrl-1]) + [for (j = [0:1:n_v_ctrl-1]) P_T[j][i]]] + ) + [[row_wrap ? "closed" : "clamped", col_wrap ? "closed" : "clamped"], + [p_u, p_v], P, [u_knots, v_knots], undef, undef, + [u_params, v_params]]; + + +module nurbs_interp_surface(points, degree, splinesteps=16, + method="centripetal", + row_wrap=false, col_wrap=false, + style="default", reverse=false, triangulate=false, + caps=undef, cap1=undef, cap2=undef, + first_row_deriv=undef, last_row_deriv=undef, + first_col_deriv=undef, last_col_deriv=undef, + normal1=undef, normal2=undef, + flat_end1=undef, flat_end2=undef, + flat_edges=undef, + row_edges=undef, col_edges=undef, + extra_pts=0, smooth=3, + data_color="red", data_size=0, + atype="hull", convexity=10, cp="centroid", anchor="origin", spin=0, orient=UP +) + { + result = nurbs_interp_surface(points, degree, + method=method, row_wrap=row_wrap, col_wrap=col_wrap, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, + flat_edges=flat_edges, + row_edges=row_edges, col_edges=col_edges, + extra_pts=extra_pts, smooth=smooth); + nurbs_vnf(result, splinesteps=splinesteps, style=style, + reverse=reverse, triangulate=triangulate, + caps=caps, cap1=cap1, cap2=cap2, convexity=convexity, atype=atype, anchor=anchor, spin=spin, orient=orient) children(); + if (data_size > 0) + color(data_color) + for (row = points) + for (pt = row) + translate(pt) sphere(r=data_size, $fn=16); +} + + + +// Compute per-point tangent vectors for a degenerate apex row or column. +// Returns true if all points in pts are collinear (lie on a single line). +// Computes the direction from first to last point, then checks that every +// intermediate point projects onto that line within eps. Points that are +// all identical also pass (dn < eps branch). + +// Returns true if all points in pts are coplanar (lie in a single plane). +// For 2D points always returns true. For 3D: finds the plane through the +// first three non-collinear points (using their cross-product normal), then +// checks that all remaining points satisfy |dot(pt-p0, nhat)| < eps. +// Points that are all collinear (degenerate plane) also return true. + +function _is_coplanar_pts(pts, eps=1e-10) = + let(n = len(pts), dim = len(pts[0])) + n <= 3 || dim <= 2 ? true + : let( + p0 = pts[0], + d1 = pts[1] - p0, + // Index of first point not collinear with pts[0..1]. + nc = [for (i = [2:1:n-1]) + let(c = cross(d1, pts[i] - p0)) + if (norm(c) > eps) i][0] + ) + is_undef(nc) ? true // all collinear → trivially coplanar + : let( + normal = cross(d1, pts[nc] - p0), + nhat = normal / norm(normal) + ) + max([for (pt = pts) abs((pt - p0) * nhat)]) < eps; + + +// Plane normal for a set of 3D points (returns 3D vector, or undef if collinear). +// Always returns [0,0,1] for 2D points. + +function _pts_plane_normal(pts, eps=1e-10) = + let(dim = len(pts[0])) + dim <= 2 + ? [0, 0, 1] + : let( + p0 = pts[0], + d1 = last(pts) - p0, + nc = [for (i = [1:1:len(pts)-1]) + let(c = cross(d1, pts[i] - p0)) + if (norm(c) > eps) c][0] + ) + is_undef(nc) ? undef : nc; + + +// Used to auto-generate first_row_deriv / last_row_deriv / first_col_deriv / last_col_deriv +// when normal1=/normal2= or flat_end1=/flat_end2= is supplied. +// +// Apex edge (all boundary points identical): +// _apex_tangents(N, apex, ring) +// N defines the symmetry axis (user-supplied vector); magnitude sets derivative scale. +// Returns per-point outward vectors (apex→ring, projected ⊥ N) of magnitude norm(N). +// Pass the negated result for an end (u=1 or v=1) apex; see caller. +// +// Coplanar edge (boundary points coplanar and span a plane, i.e. non-collinear): +// _coplanar_inward_tangents(scales, edge, ring, periodic=false) +// At each edge point computes a unit vector perpendicular to the polygon edge tangent, +// lying in the edge plane, oriented toward the polygon interior. +// +// Interior orientation uses polygon winding: the signed area of the edge polygon +// projected onto the edge plane (via the area vector = Σ cross(edge[i], edge[(i+1)%n])). +// If the area vector aligns with P_hat (CCW when viewed from P_hat) the interior is to +// the LEFT of the traversal direction; cross(P_hat, T3) already points left and so is +// the inward normal. If CW (area vector opposes P_hat), cross(P_hat, T3) points right +// (outward) and is negated. This is robust for any non-convex polygon. +// +// scales: scalar or per-point list; positive = inward (closes surface), +// negative = outward (flares surface). Same convention at start and end edges. +// periodic=true uses wrapped central differences at the first/last point (for closed v/u). + +function _apex_tangents(N, apex, ring) = + let( + mag = norm(N), + N_hat = N / max(mag, 1e-15) + ) + [for (pt = ring) + let( + d = pt - apex, + d_perp = d - (d * N_hat) * N_hat, + n_perp = norm(d_perp) + ) + n_perp > 1e-12 ? mag * d_perp / n_perp : repeat(0, len(N)) + ]; + + +function _coplanar_inward_tangents(scales, edge, ring, periodic=false) = + let( + n = len(edge), + dim = len(edge[0]), + P = _pts_plane_normal(edge), + zero = repeat(0, dim), + sc = is_num(scales) ? repeat(scales, n) : scales + ) + is_undef(P) ? repeat(zero, n) + : let( + P_hat = P / norm(P), + // Polygon area vector = Σ cross(edge[i], edge[(i+1)%n]). + // Positive dot with P_hat → CCW when viewed from P_hat → interior is LEFT. + // Negative dot → CW → interior is RIGHT. + area_vec = sum([for (i = [0:1:n-1]) + cross(dim == 2 ? [edge[i][0], edge[i][1], 0] + : edge[i], + dim == 2 ? [edge[(i+1)%n][0], edge[(i+1)%n][1], 0] + : edge[(i+1)%n])]), + sign = (area_vec * P_hat) >= 0 ? 1 : -1 + ) + [for (j = [0:1:n-1]) + let( + jm = periodic ? (j == 0 ? n-1 : j-1) : max(0, j-1), + jp = periodic ? (j == n-1 ? 0 : j+1) : min(n-1, j+1), + // Incoming and outgoing edge vectors (lifted to 3D for 2D input). + seg1 = dim == 2 ? [edge[j][0]-edge[jm][0], edge[j][1]-edge[jm][1], 0] + : edge[j] - edge[jm], + seg2 = dim == 2 ? [edge[jp][0]-edge[j][0], edge[jp][1]-edge[j][1], 0] + : edge[jp] - edge[j], + s1 = norm(seg1), + s2 = norm(seg2), + // Inward normal to each adjacent edge (unit vector), using polygon + // winding sign. cross(P_hat, unit_edge) = 90° left rotation in plane. + // Angle-bisector (average of unit normals) is length-independent, so + // non-uniform sample spacing has no effect — unlike the chord-average + // tangent method it replaces. + n1 = s1 < 1e-12 ? undef : sign * cross(P_hat, seg1 / s1), + n2 = s2 < 1e-12 ? undef : sign * cross(P_hat, seg2 / s2), + bis = is_undef(n1) ? n2 : is_undef(n2) ? n1 : n1 + n2, + blen = is_undef(bis) ? 0 : norm(bis) + ) + blen < 1e-12 ? zero + : let( + in3 = bis / blen, + inward = dim == 2 ? [in3[0], in3[1]] : in3 + ) + sc[j] * inward + ]; + +// Averaged parameterization for the u-direction (across rows). +// For each column, compute chord-length params, then average. + +function _surface_params_u(points, method, periodic) = + let( + n_rows = len(points), + n_cols = len(points[0]), + col_params = [for (l = [0:1:n_cols-1]) + let(col = [for (k = [0:1:n_rows-1]) points[k][l]]) + _interp_params(col, method, closed=periodic) + ], + n_p = len(col_params[0]) + ) + [for (k = [0:1:n_p-1]) + sum([for (l = [0:1:n_cols-1]) col_params[l][k]]) / n_cols + ]; + + +// Averaged parameterization for the v-direction (across columns). +// For each row, compute chord-length params, then average. + +function _surface_params_v(points, method, periodic) = + let( + n_rows = len(points), + n_cols = len(points[0]), + row_params = [for (k = [0:1:n_rows-1]) + _interp_params(points[k], method, closed=periodic) + ], + n_p = len(row_params[0]) + ) + [for (l = [0:1:n_p-1]) + sum([for (k = [0:1:n_rows-1]) row_params[k][l]]) / n_rows + ]; + + + + +// Section: Usage Examples +// +// Example(2D): Clamped curve (default) +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// debug_nurbs_interp(data, 3); +// +// Example(2D): Closed curve (debug view) +// // Do NOT repeat the first point at the end. +// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; +// debug_nurbs_interp(data, 3, closed=true); +// +// Example(2D): Closed polygon +// // All data points lie exactly on the polygon boundary. +// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; +// path = nurbs_interp_curve(data, 3, splinesteps=16, closed=true); +// polygon(path); +// color("red") move_copies(data) circle(r=0.25, $fn=16); +// +// Example(2D): Get just the path +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// path = nurbs_interp_curve(data, 3, splinesteps=32); +// stroke(path, width=0.5); +// color("red") move_copies(data) circle(r=0.25, $fn=16); +// +// Example(2D): Low-level NURBS parameter list +// // nurbs_interp() returns a BOSL2 NURBS parameter list compatible +// // with nurbs_curve(), debug_nurbs(), etc. +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// result = nurbs_interp(data, 3); +// curve = nurbs_curve(result, splinesteps=24); +// stroke(curve, width=0.5); +// +// Example(3D): 3D closed curve +// data3d = [[20,0,0],[0,20,10],[-20,0,20],[0,-20,10]]; +// path = nurbs_interp_curve(data3d, 3, splinesteps=32, closed=true); +// stroke(path, width=1, closed=true); +// color("red") move_copies(data3d) sphere(r=0.25, $fn=16); +// +// Example(2D): Parameterization methods for sharp turns +// // "length" (blue), "centripetal" (red), "dynamic" (orange) compared. +// // For data with sudden direction changes or uneven chord spacing, +// // "centripetal" and "dynamic" reduce unwanted oscillations. +// sharp = [[0,0], [5,40],[6,40], [10,0], [50,0], [55,40],[56,42], [60,0]]; +// color("blue") stroke(nurbs_interp_curve(sharp, 3), width=0.1); +// color("red") stroke(nurbs_interp_curve(sharp, 3, method="centripetal"), width=0.1); +// color("orange") stroke(nurbs_interp_curve(sharp, 3, method="dynamic"), width=0.1); +// color("green") move_copies(sharp) circle(r=.1, $fn=16); +// +// Example(2D): Endpoint tangent control +// // Specify start and/or end tangent vectors. Each vector is automatically +// // scaled by the total chord length; a unit vector produces natural +// // arc-length speed. Magnitude > 1 increases pull, < 1 weakens it. +// data = [[0,0], [20,30], [50,25], [80,0]]; +// // No tangent control (natural): +// color("gray") stroke(nurbs_interp_curve(data, 3), width=0.3); +// // Start going straight up, end going straight down: +// color("blue") stroke( +// nurbs_interp_curve(data, 3, start_deriv=[0,1], end_deriv=[0,-1]), +// width=0.3); +// // Start going right, end going right: +// color("red") stroke( +// nurbs_interp_curve(data, 3, start_deriv=[1,0], end_deriv=[1,0]), +// width=0.3); +// color("black") move_copies(data) circle(r=0.25, $fn=16); +// +// Example(2D): Start tangent only +// data = [[0,0], [20,30], [50,25], [80,0]]; +// color("gray") stroke(nurbs_interp_curve(data, 3), width=0.3); +// color("blue") stroke( +// nurbs_interp_curve(data, 3, start_deriv=[0,1]), +// width=0.3); +// color("black") move_copies(data) circle(r=0.25, $fn=16); +// +// +// Section: Surface Interpolation Examples +// +// Example(3D): Basic surface interpolation +// // A 4x5 grid of 3D data points produces a smooth interpolating surface. +// data = [ +// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 10], [50, 50, 0], [80, 50, 5]], +// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 30], [50, 16, 20], [80, 16, 10]], +// [[-50,-16, 20], [-16,-16, 35], [ 16,-16, 40], [50,-16, 15], [80,-16, 25]], +// [[-50,-50, 0], [-16,-50, 10], [ 16,-50, 20], [50,-50, 0], [80,-50, 5]], +// ]; +// nurbs_interp_surface(data, 3, splinesteps=8); +// +// Example(3D): Different degrees per direction +// // Quadratic in u (rows), cubic in v (columns). +// data = [ +// for (u = [-40:20:40]) +// [for (v = [-40:20:40]) +// [v, u, 15*sin(u*3)*cos(v*3)]] +// ]; +// nurbs_interp_surface(data, [2,3], splinesteps=8); +// +// Example(3D): Tube (surface closed in one direction) +// // Closed around the column direction (the rings), clamped along rows +// // (the axis). Uses 5 rings: a cubic closed direction needs at least +// // p+2 = 5 data points to have interior knot freedom. +// r = 20; +// data = [for (u = [0:15:60]) +// [for (i = [0:1:5]) +// let(a = i * 360/6) +// [r*cos(a), r*sin(a), u]] +// ]; +// nurbs_interp_surface(data, 3, splinesteps=8, col_wrap=true); +// +// Example(3D): Torus (surface closed in both directions) +// // Both directions sample a full 360 circle with even angular spacing, +// // so the closing segment equals the inter-point spacing and +// // parameterization is uniform. Each direction uses N=6 > p+1=4 +// // points to ensure interior knot freedom. +// R = 30; r = 10; +// N = 6; +// data = [for (i = [0:1:N-1]) +// let(phi = i * 360/N) +// [for (j = [0:1:N-1]) +// let(theta = j * 360/N) +// [(R + r*cos(theta))*cos(phi), +// (R + r*cos(theta))*sin(phi), +// r*sin(theta)]] +// ]; +// nurbs_interp_surface(data, 3, splinesteps=12, +// row_wrap=true, col_wrap=true); +// +// Example(3D): Low-level surface access +// // nurbs_interp_surface() returns a BOSL2 NURBS parameter list +// // compatible with nurbs_vnf(), debug_nurbs(), etc. +// data = [ +// [[-30,30,0], [0,30,20], [30,30,0]], +// [[-30, 0,10],[0, 0,30], [30, 0,10]], +// [[-30,-30,0],[0,-30,15],[30,-30,0]], +// ]; +// result = nurbs_interp_surface(data, 2); +// vnf = nurbs_vnf(result, splinesteps=12); +// vnf_polyhedron(vnf); +// color("red") +// for (row = data) for (pt = row) +// translate(pt) sphere(r=1, $fn=16); diff --git a/nurbs_interp.scad b/nurbs_interp.scad deleted file mode 100644 index e41290e1..00000000 --- a/nurbs_interp.scad +++ /dev/null @@ -1,3605 +0,0 @@ -////////////////////////////////////////////////////////////////////// -// LibFile: nurbs_interp.scad -// NURBS Curve Interpolation through Data Points -// -// Given a set of data points, computes the NURBS control points and -// knot vector such that the resulting curve passes exactly through -// every data point. Supports two BOSL2 NURBS types: -// "clamped" - curve starts/ends at first/last data point -// "closed" - curve forms a smooth closed loop through all points -// -// Optional per-point derivative (tangent) constraints can be applied -// to both curve types via the deriv= parameter. The clamped -// type also accepts the start_deriv=/end_deriv= shorthand arguments. -// (Piegl & Tiller, "The NURBS Book", Section 9.2.2.) -// -// Algorithm from Piegl & Tiller, "The NURBS Book", Chapters 2 & 9. -// -// Requires BOSL2. To use, add these lines to the top of your file: -// include -// include -// include -// -// Author: Claude (Anthropic), 2026 -// License: BSD-2-Clause (same as BOSL2) -// Development Version 187 -////////////////////////////////////////////////////////////////////// - - -// ===================================================================== -// SECTION: Internal B-spline Basis Functions -// ===================================================================== - -// Cox-de Boor recursive B-spline basis function N_{i,p}(u). -// Returns 0 for out-of-range indices (safe for periodic evaluation). - -function _nip(i, p, u, U) = - let(maxidx = len(U) - 1) - (i < 0 || i + p + 1 > maxidx) ? 0 - : p == 0 - ? (u >= U[i] && u < U[i+1]) ? 1 - : (abs(u - U[i+1]) < 1e-12 && abs(U[i+1] - U[maxidx]) < 1e-12) ? 1 - : 0 - : let( - d1 = U[i+p] - U[i], - d2 = U[i+p+1] - U[i+1], - c1 = abs(d1) > 1e-15 - ? (u - U[i]) / d1 * _nip(i, p-1, u, U) : 0, - c2 = abs(d2) > 1e-15 - ? (U[i+p+1] - u) / d2 * _nip(i+1, p-1, u, U) : 0 - ) - c1 + c2; - - -// Derivative of B-spline basis N_{j,p}'(u). -// Standard recurrence (P&T §2.3 eq. 2.9); zero-length spans are guarded. - -function _dnip(j, p, u, U) = - p == 0 ? 0 - : let( - d1 = U[j+p] - U[j], - d2 = U[j+p+1] - U[j+1] - ) - (abs(d1) > 1e-15 ? p * _nip(j, p-1, u, U) / d1 : 0) - - (abs(d2) > 1e-15 ? p * _nip(j+1, p-1, u, U) / d2 : 0); - - -// Second derivative of B-spline basis N_{j,p}''(u). -// Same recurrence as _dnip applied once more (P&T §2.3 eq. 2.9); -// zero-length spans are guarded. Returns 0 for p ≤ 1. - -function _d2nip(j, p, u, U) = - p <= 1 ? 0 - : let( - d1 = U[j+p] - U[j], - d2 = U[j+p+1] - U[j+1] - ) - (abs(d1) > 1e-15 ? p * _dnip(j, p-1, u, U) / d1 : 0) - - (abs(d2) > 1e-15 ? p * _dnip(j+1, p-1, u, U) / d2 : 0); - - -// ===================================================================== -// SECTION: Input Helpers -// ===================================================================== - -// Validate and coerce a single derivative vector to the required dimension. -// -// dim == 2 (special case): -// Accepts a 3D BOSL2 direction constant (UP, DOWN, LEFT, RIGHT, BACK, FWD) -// by projecting it onto the data plane. The vector must lie in the XZ plane -// (Y=0, as UP/DOWN/LEFT/RIGHT/FWD/BACK are defined) or the XY plane (Z=0). -// Underlength inputs (1D) are zero-padded to 2D as in the general case. -// -// All dimensions (dim ≥ 2): -// Any vector shorter than dim is zero-padded to length dim. -// Vectors longer than dim (not handled by the dim=2 special case) error. - -function _force_deriv_dim(deriv, dim) = - dim == 2 && is_vector(deriv, 3) ? - // Special: 3D BOSL2 constant for 2D curve — project onto data plane. - assert(deriv.y == 0 || deriv.z == 0, - "\nDerivative for a 2D interpolation cannot be fully 3D. It must have either Y or Z component equal to zero.") - deriv.y == 0 ? [deriv.x, deriv.z] : point2d(deriv) - : // General: validate length ≤ dim, then zero-pad to exactly dim. - assert(is_vector(deriv) && len(deriv) >= 1 && len(deriv) <= dim, - str("\nDerivative must be a non-empty vector of dimension ", dim, " or less.")) - list_pad(deriv, dim, 0); - - -// Convert a curvature specification to a C''(t) constraint vector. -// -// Under natural-speed parameterization (|C'(t)| = v), curvature κ and -// the second derivative relate by: C''(t) = κ_vec_normal × v². -// Tangential acceleration is set to zero (arc-length parameterization at that point). -// -// curv_spec = signed scalar κ (dim=2), or a vector (any dim including 2D). -// Scalar (dim=2): positive = CCW (left), negative = CW (right). -// Vector: magnitude = |κ|; the perpendicular projection onto -// the plane normal to tang_dir provides the direction only. -// For dim=2 curves, accepts 3D BOSL2 direction constants -// (UP, DOWN, LEFT, RIGHT, etc.) — projected to 2D same as deriv=. -// tang_dir = tangent direction at the point (need not be normalized). -// dim = spatial dimension (len(points[0])). -// v2 = |C'(t)|² at the constrained point. - -function _curv_to_d2(curv_spec, tang_dir, dim, v2) = - let(t_hat = unit(tang_dir)) - (dim == 2 && is_num(curv_spec)) - ? // 2D signed scalar: rotate tangent 90° CCW to get the normal direction. - let(n_hat = [-t_hat[1], t_hat[0]]) - curv_spec * n_hat * v2 - : // Vector form (any dim, including 2D): magnitude from the input vector, - // direction from the perpendicular projection. - // Accepts 3D BOSL2 direction constants (UP, DOWN, etc.) for 2D curves - // via _force_deriv_dim projection, same as derivative constraints. - assert(is_vector(curv_spec) && len(curv_spec) >= 1 && - (len(curv_spec) <= dim || (dim == 2 && len(curv_spec) == 3)), - str("nurbs_interp: curvature constraint must be a signed scalar (2D) or a vector of dimension 1–", dim, - " (3D BOSL2 constants like UP/DOWN accepted for 2D curves)")) - let( - cv = _force_deriv_dim(curv_spec, dim), - mag = norm(cv), - cv_perp = cv - (cv * t_hat) * t_hat, - n_perp = norm(cv_perp) - ) - assert(n_perp > 1e-12, - "nurbs_interp: curvature constraint is parallel to the derivative at the same point — curvature must have a component perpendicular to the tangent direction") - mag * (cv_perp / n_perp) * v2; - - -// Merges start_deriv=/end_deriv= into a per-point list of length n+1. -// When dim is provided each non-undef, non-NaN entry is projected via -// _force_deriv_dim(): BOSL2 3D direction constants (UP, LEFT, …) map to the -// correct 2D or 3D vector, and shorter vectors are zero-padded. -// NaN corner-marker entries (0/0) pass through unchanged. -// Returns undef when no constraint is specified. -function _merge_deriv_list(n, deriv, dim=undef, start_deriv=undef, end_deriv=undef) = - let( - raw = !is_undef(deriv) ? deriv - : (!is_undef(start_deriv) || !is_undef(end_deriv)) - ? [for (k = [0:1:n]) - k == 0 && !is_undef(start_deriv) ? start_deriv - : k == n && !is_undef(end_deriv) ? end_deriv - : undef] - : undef - ) - is_undef(dim) || is_undef(raw) ? raw - : [for (v = raw) is_undef(v) || is_nan(v) ? v : _force_deriv_dim(v, dim)]; - - -// Merges start_curvature=/end_curvature= into a per-point list of length n+1. -// When dim is provided, vector entries are projected via _force_deriv_dim() -// (handles BOSL2 3D direction constants for 2D curves). Signed-scalar entries -// (valid for dim=2) are left as-is; the sign encodes the turn direction. -// Returns undef when no constraint is specified. -function _merge_curv_list(n, curvature, dim=undef, start_curvature=undef, end_curvature=undef) = - let( - raw = !is_undef(curvature) ? curvature - : (!is_undef(start_curvature) || !is_undef(end_curvature)) - ? [for (k = [0:1:n]) - k == 0 && !is_undef(start_curvature) ? start_curvature - : k == n && !is_undef(end_curvature) ? end_curvature - : undef] - : undef - ) - is_undef(dim) || is_undef(raw) ? raw - : [for (v = raw) (is_undef(v) || is_num(v)) ? v : _force_deriv_dim(v, dim)]; - - -// ===================================================================== -// SECTION: Parameterization -// ===================================================================== - - -// Dynamic centripetal parameterization (Balta et al., IEEE Access 2020 §III). -// Per-chord exponent inversely proportional to ln(chord_length): -// e_i = ln(chordmax/chordi) / ln(chordmax/chordmin) * (emax-emin) + emin -// Long chords get exponent emin=0.35 (compressed contribution). -// Short chords get exponent emax=0.65 (expanded contribution). -// Falls back to e=0.5 (standard centripetal) when all chords are equal. - -function _dynamic_dists(raw, emin=0.35, emax=0.65) = - let( - cmax = max(raw), - cmin = min(raw), - log_r = ln(cmax / cmin) - ) - // Divide each chord by cmin so that d/cmin ≥ 1 for every chord. - // This is required for correctness: pow(x, e) is an increasing function - // of e only when x > 1, so d > 1 ensures that the longer chords (with - // smaller exponent emin) are correctly compressed relative to shorter - // chords (with larger exponent emax). Normalizing by cmin also makes - // the result scale-invariant: λd/λcmin = d/cmin for any scale factor λ. - log_r < 1e-12 - ? [for (d = raw) sqrt(d / cmin)] // equal chords → uniform spacing - : [for (d = raw) - let(e = ln(cmax / d) / log_r * (emax - emin) + emin) - pow(d / cmin, e) - ]; - - - -// Foley-Neilson parameterization (Foley & Neilson 1987). -// Centripetal base with deflection-angle correction at each vertex. -function _foley_dists(points, closed) = - let( - n = len(points), - c = path_segment_lengths(points, closed=closed), - nc = len(c), - // Centripetal base: sqrt of each chord length. - d = [for (ci = c) sqrt(ci)], - // θ̂[i] = min(deflection angle at P[i], π/2) in radians. - // Deflection angle = 180° − interior angle at P[i]. - // Endpoints of an open curve contribute zero correction. - theta_hat = [for (i = [0:1:n-1]) - !closed && (i == 0 || i == n-1) ? 0 - : let(phi_deg = 180 - vector_angle(select(points, i-1, i+1))) - min(phi_deg * PI/180, PI/2) - ] - ) - [for (i = [0:1:nc-1]) - let( - di = d[i], - d_prev = d[(i - 1 + nc) % nc], - d_next = d[(i + 1) % nc], - th_L = theta_hat[i], - th_R = theta_hat[(i + 1) % n], - left = 3 * th_L * d_prev / (2 * (d_prev + di)), - right = 3 * th_R * d_next / (2 * (di + d_next)) - ) - di * (1 + left + right) - ]; - - -// Fang improved centripetal parameterization (Fang & Hung, CAD 2013, Eq. 10). -// Centripetal base + osculating-circle dragging tolerance (α = 0.1). -// At each interior point Pᵢ, eᵢ = α·(θᵢ·ℓᵢ/(2·sin(θᵢ/2)) + θᵢ₋₁·ℓᵢ₋₁/(2·sin(θᵢ₋₁/2))) -// where θᵢ is deflection angle at Pᵢ, ℓᵢ is shortest side of triangle Pᵢ₋₁PᵢPᵢ₊₁. -// Each chord increment is Δᵢ = √‖Lᵢ‖ + eᵢ + eᵢ₊₁ (corrections from both endpoints). - -function _fang_correction(points, closed) = - let(n = len(points)) - [for (i = [0:1:n-1]) - !closed && (i == 0 || i == n-1) ? 0 - : let( - tri = select(points, i-1, i+1), - ell = min(path_segment_lengths(tri, closed=true)), - theta_deg = 180 - vector_angle(select(points, i-1, i+1)) - ) - // θ·ℓ/(2·sin(θ/2)); limit as θ→0 is ℓ. - 0.1 * (abs(theta_deg) < 1e-6 ? ell - : theta_deg * PI/180 * ell / (2 * sin(theta_deg / 2))) - ]; - -function _fang_dists(points, closed) = - let( - c = path_segment_lengths(points, closed=closed), - nc = len(c), - ef = _fang_correction(points, closed) - ) - [for (i = [0:1:nc-1]) - sqrt(c[i]) + ef[i] + select(ef, i+1) - ]; - - -// Chord-length, centripetal, dynamic, Foley, or Fang parameterization. -// clamped: n+1 points -> n+1 values in [0, 1] with t_0=0, t_n=1. -// closed: n points -> n values in [0, 1) with t_0=0. -// method: "length" = chord-length -// "centripetal" = sqrt exponent (Lee 1989) -// "dynamic" = per-chord dynamic exponent (Balta et al. 2020) -// "foley" = centripetal + deflection-angle correction (Foley & Neilson 1987) -// "fang" = centripetal + osculating-circle correction (Fang & Hung 2013) - -function _interp_params(points, method="centripetal", closed=false) = - let( - raw = path_segment_lengths(points, closed=closed), - n = len(raw), - total_raw = sum(raw) - ) - // Degenerate: all points identical (e.g. a surface pole row/column). - // Return uniform spacing so surface parameter averages stay valid. - total_raw < 1e-10 - ? (closed - ? [for (i = [0:1:n-1]) i / n] - : [for (i = [0:1:n ]) i / n]) - : assert(min(raw) > 1e-10, - "nurbs_interp: consecutive duplicate data points detected") - let( - dists = method == "centripetal" ? [for (d = raw) sqrt(d)] - : method == "dynamic" ? _dynamic_dists(raw) - : method == "foley" ? _foley_dists(points, closed) - : method == "fang" ? _fang_dists(points, closed) - : raw, - total = sum(dists), - cs = cumsum(dists) - ) - closed ? [0, each [for (x = list_head(cs)) x / total]] - : [0, each [for (x = list_head(cs)) x / total], 1]; - - -// ===================================================================== -// SECTION: Knot Vector Construction -// ===================================================================== - -// Interior knots by averaging (Piegl & Tiller eq 9.8). - -function _avg_knots_interior(params, p) = - let( - n = len(params) - 1, - num_internal = n - p - ) - num_internal <= 0 - ? [] - : [for (j = [1:1:num_internal]) - sum([for (i = [j :1: j + p - 1]) params[i]]) / p - ]; - - -// Full clamped knot vector: (p+1) zeros, interior, (p+1) ones. - -function _full_clamped_knots(interior_knots, p) = - concat(repeat(0, p+1), interior_knots, repeat(1, p+1)); - - -// Periodic "bar knots" for closed B-splines. -// -// Returns [bar_knots, shifted_params] where bar_knots is n+1 -// monotonically increasing values with bar[0]=0, bar[n]=1, and -// shifted_params are the parameter values shifted to match. -// -// The raw bar knots are computed by averaging p consecutive values -// from the extended periodic parameter sequence t_m = params[m%n] + -// floor(m/n). This is guaranteed monotonic. We then shift so -// bar[0]=0, and shift params by the same amount. - -function _avg_knots_periodic(params, p) = - let( - n = len(params), - raw = [for (j = [0:1:n]) - sum([for (k = [0:1:p-1]) - let(m = j + k) - params[m % n] + floor(m / n) - ]) / p - ], - shift = raw[0], - bar_knots = add_scalar(raw, -shift), - shifted = [for (t = params) - let(s = t - shift) - s < 0 ? s + 1 : (s >= 1 ? s - 1 : s)] - ) - [bar_knots, shifted]; - - -// Repair degenerate periodic bar knots: if any span is smaller than -// eps × period, merge it into its neighbor and bisect the resulting -// larger span. Preserves the knot count (n+1 entries, n spans) and -// the endpoint values bar[0]=0, bar[n]=period. Recurses until no -// tiny spans remain. - -function _fix_tiny_spans(bar_knots, n, eps=1e-6) = - let( - T = bar_knots[n], - spans = [for (k = [0:1:n-1]) bar_knots[k+1] - bar_knots[k]], - min_span = min(spans) - ) - min_span >= eps * T ? bar_knots - : let( - k = min_index(spans), - // Remove an interior knot bounding the tiny span. - // For span 0 (first span), remove knot 1 and absorb into span 1. - // For span n-1 (last span), remove knot n-1 and absorb into span n-2. - // Otherwise, remove knot k+1 and absorb into the merged span at k. - remove_idx = k == 0 ? 1 - : k == n - 1 ? n - 1 - : k + 1, - merged = [for (i = [0:1:n]) if (i != remove_idx) bar_knots[i]], - absorb_k = k == 0 ? 0 : k - 1, - // Bisect the absorbing span to restore the knot count. - mid = (merged[absorb_k] + merged[absorb_k + 1]) / 2, - fixed = [for (i = [0:1:n-1]) // n entries in merged - each (i == absorb_k ? [merged[i], mid] : [merged[i]])] - ) - _fix_tiny_spans(fixed, n, eps); - - -// Insert extra knots into a base bar_knots vector, one per -// constraint parameter. For each constraint, finds the span -// containing its parameter value and inserts at the span midpoint. -// When multiple constraints compete, the one whose containing span -// is largest is processed first — this avoids splitting a small -// span when a larger one is available. Each insertion updates the -// knot vector before the next constraint is processed. -// -// bar_knots: base bar_knots from periodic or interior averaging. -// constraint_ts: list of parameter values identifying which span -// to split. For closed: raw params in [0,1). -// For clamped: params in [0,1]. -// -// Returns the augmented bar_knots with len(constraint_ts) extra entries. - -function _insert_constraint_knots(bar_knots, constraint_ts) = - len(constraint_ts) == 0 ? bar_knots - : let( - n = len(bar_knots), - // For each constraint, find its containing span and that span's width. - spans = [for (ci = [0:1:len(constraint_ts)-1]) - let( - t = constraint_ts[ci], - pos = [for (i = [0:1:n-2]) - if (bar_knots[i] <= t && t < bar_knots[i+1]) i], - idx = len(pos) > 0 ? pos[0] : n - 2, - w = bar_knots[idx+1] - bar_knots[idx] - ) - [ci, idx, w] - ], - // Pick the constraint whose span is largest. - best = max_index([for (s = spans) s[2]]), - ci = spans[best][0], - idx = spans[best][1], - mid = (bar_knots[idx] + bar_knots[idx+1]) / 2, - new_knots = [each [for (i = [0:1:idx]) bar_knots[i]], mid, - each [for (i = [idx+1:1:n-1]) bar_knots[i]]], - remaining = [for (i = [0:1:len(constraint_ts)-1]) - if (i != ci) constraint_ts[i]] - ) - _insert_constraint_knots(new_knots, remaining); - - -// Return k parameter values, each at the midpoint of one of the k -// widest spans in bar_knots. Used to target extra knot insertions -// and smoothness rows at the most under-resolved regions. -// -// When all k picks come from equal-width spans (the common case for -// uniformly-parameterized closed curves), spans are chosen at centred- -// stratified indices floor((2g+1)*n/(2*k_eff)) % n for g=0..k_eff-1. -// This places each pick at the centre of its equal-width quantile -// rather than at the quantile boundary. For n=18, k=4 the picks -// are spans 2, 6, 11, 15 instead of 0, 4, 9, 13. -// -// Centering is essential for closed curves: _extend_knot_vector wraps -// span widths across the seam (span n-1 into the pre-region, span 0 -// into the post-region). If an extra knot is inserted in span 0, the -// span width at the start of aug_bar differs from the width at the end, -// making the basis functions slightly asymmetric at the seam and -// causing a visible fold in the null-space solution. Centering keeps -// both boundary spans at their original (uniform) width. -// When the k widest spans are not all equal, the standard widest-first -// selection is used (knot insertion targets the most under-resolved -// regions regardless of position). - -function _widest_span_params(bar_knots, k) = - let( - n = len(bar_knots) - 1, - k_eff = min(k, n), - _echo = k > n ? echo(str("nurbs_interp: extra_pts=", k, - " exceeds the number of available knot spans (", n, - "); reduced to ", n, ".")) : 0, - spans = [for (i = [0:1:n-1]) bar_knots[i+1] - bar_knots[i]], - w_max = max(spans), - // Indices of spans at the maximum width (within floating-point tolerance). - // Stratification picks only from these so that constraint-narrowed spans - // (e.g. from _insert_constraint_knots) are never accidentally chosen. - eq_idxs = [for (i = [0:1:n-1]) if (abs(spans[i] - w_max) < 1e-10 * w_max) i], - n_eq = len(eq_idxs) - ) - // If all k_eff picks come from equal-width spans, use centred stratification - // over eq_idxs so that constraint-narrowed spans are never selected. - n_eq >= k_eff - ? [for (g = [0:1:k_eff-1]) - let(i = eq_idxs[floor((2 * g + 1) * n_eq / (2 * k_eff))]) - (bar_knots[i] + bar_knots[i+1]) / 2 - ] - // Otherwise use widest-first selection (non-uniform spans). - : let( - sorted = sort([for (i = [0:1:n-1]) [spans[i], i]]), - top_k = [for (i = [n-1:-1:n-k_eff]) sorted[i]] - ) - [for (s = top_k) (bar_knots[s[1]] + bar_knots[s[1]+1]) / 2]; - - -// Find knot spans containing multiple data parameters and return -// splitting midpoints. Two data points in the same span cause a -// rank-deficient collocation matrix; inserting a knot between them -// restores full rank. -// -// bar_knots: sorted knot vector with n_spans+1 entries. -// params: sorted or unsorted data parameter values. -// -// Returns a list of splitting parameter values — one midpoint between -// each consecutive pair of params that share a span. - -function _span_split_params(bar_knots, params) = - let( - n_spans = len(bar_knots) - 1, - sorted = sort(params), - n_p = len(sorted), - // For each sorted param, find its span index. - span_of = [for (t = sorted) - let(pos = [for (i = [0:1:n_spans-1]) - if (t >= bar_knots[i] && - (i < n_spans-1 ? t < bar_knots[i+1] - : t <= bar_knots[i+1])) i]) - len(pos) > 0 ? pos[0] : n_spans - 1 - ] - ) - // Midpoints between consecutive sorted params sharing a span. - [for (i = [0:1:n_p-2]) - if (span_of[i] == span_of[i+1]) - (sorted[i] + sorted[i+1]) / 2 - ]; - - -// Build one row of the L^T*L matrix for control-polygon regularization. -// order=1: first-difference penalty (penalizes polygon length/variation). -// order=2: second-difference penalty (penalizes polygon bending). -// periodic=true wraps the differences around for closed curves. -// -// For clamped (non-periodic): -// order=1 L^T*L: tridiag [1,-1,0..] [-1,2,-1,0..] .. [0..,-1,1] -// order=2 L^T*L: pentadiag boundary-adapted -// For closed (periodic): -// order=1 L^T*L: circulant [2,-1,0..0,-1] -// order=2 L^T*L: circulant [6,-4,1,0..0,1,-4] - -function _ltl_row(M, i, order, periodic=false) = - periodic - ? (order == 1 - ? [for (j = [0:1:M-1]) - j == i ? 2 - : j == (i+1)%M || j == (i-1+M)%M ? -1 - : 0] - : // order == 2 - [for (j = [0:1:M-1]) - j == i ? 6 - : j == (i+1)%M || j == (i-1+M)%M ? -4 - : j == (i+2)%M || j == (i-2+M)%M ? 1 - : 0]) - : // clamped (non-periodic) - (order == 1 - ? [for (j = [0:1:M-1]) - j == i ? (i == 0 || i == M-1 ? 1 : 2) - : (j == i+1 || j == i-1) ? -1 - : 0] - : // order == 2, L is (M-2)×M second-difference matrix. - // (L^T L)[i][j] = sum_{r=0}^{M-3} L[r][i]*L[r][j] - // where L[r][c] = (c==r ? 1 : c==r+1 ? -2 : c==r+2 ? 1 : 0). - // Nonzero only when |i-j| <= 2. - [for (j = [0:1:M-1]) - abs(i-j) > 2 ? 0 - : i == j - ? (i <= M-3 ? 1 : 0) // r=i: 1² - + (i >= 1 && i <= M-2 ? 4 : 0) // r=i-1: (-2)² - + (i >= 2 ? 1 : 0) // r=i-2: 1² - : abs(i-j) == 1 - ? let(lo = min(i,j)) - (lo <= M-3 ? -2 : 0) // r=lo: (1)(-2) - + (lo >= 1 && lo <= M-2 ? -2 : 0) // r=lo-1: (-2)(1) - : // abs(i-j) == 2 - (min(i,j) <= M-3 ? 1 : 0) // r=min: (1)(1) - ]); - - -// Solve the constrained optimization min P^T·R·P s.t. A·P = rhs -// via null-space method. -// -// R = M×M regularization matrix (positive semidefinite). -// A = N×M constraint matrix (interpolation + derivative + curvature). -// rhs = N×dim right-hand side (data points + constraint vectors). -// -// Algorithm: -// 1. Step A — minimum-norm particular solution x_p satisfying A·x_p = rhs -// exactly, via BOSL2 linear_solve() (handles underdetermined systems). -// 2. Step B — minimize x^T·R·x in the null space of A (if M > N): -// Q2 = null_space(A) basis vectors (returned as rows by BOSL2) -// H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) -// Solve H · z = -Q2^T · R_pd · x_p via Cholesky -// P = x_p + Q2 · z -// -// Returns list of M control points, or undef on rank-deficient A. - -function _nullspace_solve(R, A, rhs, eps=1e-6) = - let( - M = len(R), - N_rows = len(A), - // Step A: minimum-norm particular solution via BOSL2. - // linear_solve handles underdetermined (M > N_rows) systems - // by returning the minimum-norm solution via QR of A^T. - x_p = linear_solve(A, rhs) - ) - x_p == [] ? undef - : M == N_rows ? x_p // Square: unique solution, no null space. - : let( - // Step B: minimize x^T·R·x in the null space. - // null_space() returns null-space vectors as rows. - ns = null_space(A), - n_ns = len(ns) - ) - n_ns == 0 ? x_p // Full rank despite M > N; no null space. - : let( - Q2 = transpose(ns), // M × n_ns (columns are basis vectors) - // Regularize R for strict positive-definiteness. - R_pd = [for (i = [0:1:M-1]) - [for (j = [0:1:M-1]) - R[i][j] + (i == j ? eps : 0)]], - // H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) - // Symmetrize to counteract floating-point round-off. - RQ2 = R_pd * Q2, - H_raw = transpose(Q2) * RQ2, - H = (H_raw + transpose(H_raw)) / 2, - // g = Q2^T · R_pd · x_p (n_ns × dim) - g = transpose(Q2) * (R_pd * x_p), - // Solve H · z = -g (H is SPD → Cholesky is fastest) - z = linear_solve(H, -g, method="cholesky") - ) - // If H solve fails (degenerate), x_p alone still satisfies constraints. - z == [] ? x_p - : x_p + Q2 * z; - - -// Gauss-Legendre quadrature nodes and weights on [-1,1]. -// Returns [[nodes], [weights]] for n-point rule (n = 2..5). -// Exact for polynomials up to degree 2n-1. - -function _gauss_legendre(n) = - n == 2 ? [[-0.5773502691896258, 0.5773502691896258], - [1.0, 1.0]] - : n == 3 ? [[-0.7745966692414834, 0.0, 0.7745966692414834], - [0.5555555555555556, 0.8888888888888888, 0.5555555555555556]] - : n == 4 ? [[-0.8611363115940526, -0.3399810435848563, - 0.3399810435848563, 0.8611363115940526], - [0.3478548451374538, 0.6521451548625461, - 0.6521451548625461, 0.3478548451374538]] - : // n >= 5 - [[-0.9061798459386640, -0.5384693101056831, 0.0, - 0.5384693101056831, 0.9061798459386640], - [0.2369268850561891, 0.4786286704993665, 0.5688888888888889, - 0.4786286704993665, 0.2369268850561891]]; - - -// Bending-energy regularization matrix R for the null-space solver. -// R[j][k] = ∫ B''_j(t) B''_k(t) dt (integrated squared second derivative). -// For clamped: B_j = N_{j,p}, integrated over full domain. -// For closed/periodic: B_j = N_j + (j

1e-15) - let(a = U_full[i], b = U_full[i+1], - hw = (b - a) / 2, mid = (a + b) / 2) - for (g = [0:1:n_gauss-1]) - [mid + hw * gl_nodes[g], gl_wts[g] * hw] - ], - - // Precompute all M (aliased) second derivatives at each quad point. - d2_at = [for (q = quad_pts) - let(t = q[0]) - [for (j = [0:1:M-1]) - periodic - ? _d2nip(j, p, t, U_full) - + (j < p ? _d2nip(j + M, p, t, U_full) : 0) - : _d2nip(j, p, t, U_full) - ] - ], - nq = len(quad_pts) - ) - // Assemble R[j][k] = sum_q w_q * d2[q][j] * d2[q][k] - [for (j = [0:1:M-1]) - [for (k = [0:1:M-1]) - sum([for (q = [0:1:nq-1]) - quad_pts[q][1] * d2_at[q][j] * d2_at[q][k] - ]) - ] - ]; - - -// Full periodic knot vector for "closed" type evaluation. -// Uses BOSL2's _extend_knot_vector() to build the n+2p+1 entry knot vector -// that nurbs_curve() constructs internally for closed-type curves. -// Active evaluation domain: [U[p], U[n+p]]. - -function _full_closed_knots(bar_knots, n, p) = - _extend_knot_vector(bar_knots, 0, n + 2*p + 1); - - -// ===================================================================== -// SECTION: Collocation Matrices -// ===================================================================== - -// Standard collocation matrix for clamped type. - -function _collocation_matrix(params, n, p, U) = - [for (k = [0:1:n]) - [for (j = [0:1:n]) - _nip(j, p, params[k], U) - ] - ]; - - -// Periodic collocation matrix for closed type (n x n). -// -// BOSL2 wraps the first p control points to the end, creating n+p -// basis functions. Basis N_{j+n} aliases control point j for j= p - -function _collocation_matrix_periodic(params, n, p, U_periodic) = - [for (k = [0:1:n-1]) - [for (j = [0:1:n-1]) - _nip(j, p, params[k], U_periodic) - + (j < p ? _nip(j + n, p, params[k], U_periodic) : 0) - ] - ]; - - -// ===================================================================== -// SECTION: Degree Elevation -// ===================================================================== - -// Greville abscissae for B-spline basis of degree p with full knot -// vector U. Returns n+1 values where n = len(U) - p - 2. Each g_i -// is the average of knots U[i+1] .. U[i+p]. For a clamped knot -// vector, g_0 = 0 and g_n = 1. These are optimal collocation sites -// for the B-spline space and automatically satisfy the Schoenberg- -// Whitney condition for non-singular collocation. - -function _greville(U, p) = - let(n = len(U) - p - 2) - [for (i = [0:1:n]) - sum([for (j = [i+1:1:i+p]) U[j]]) / p - ]; - - -// Increment the multiplicity of every distinct value in a knot vector -// by 1. Walk the vector; at the end of each run of equal values emit -// one extra copy. Equivalent to the new_interior construction in -// _elevate_once_clamped but applied to the complete (full) knot vector. -// Used by _elevate_once_open. - -function _increment_knot_mults(U) = - [for (i = [0:1:len(U)-1]) each - [U[i], - if (i == len(U)-1 || abs(U[i+1] - U[i]) > 1e-14) U[i]] - ]; - - -// Single degree elevation of a clamped or open B-spline via exact collocation. -// -// The elevated curve lies in the degree-(p+1) B-spline space whose knot -// vector has each distinct value's multiplicity incremented by 1. -// Evaluating the original curve at the Greville abscissae of the new basis -// and solving the collocation system recovers the exact elevated control -// points (the new space contains the original curve exactly). -// -// Input: ctrl = control points (any dimension >= 1) -// p = current degree (>= 1) -// U = full expanded knot vector (all multiplicities present) -// Output: [new_ctrl, U_new, p+1] -// U_new is the full expanded elevated knot vector. - -function _elevate_once(ctrl, p, U) = - let( - n_old = len(ctrl) - 1, - dim = len(ctrl[0]), - p_new = p + 1, - U_new = _increment_knot_mults(U), - n_new = len(U_new) - p_new - 2, - grev = _greville(U_new, p_new), - C_vals = [for (u = grev) - let(row = [for (j = [0:1:n_old]) _nip(j, p, u, U)]) - [for (d = [0:1:dim-1]) - sum([for (j = [0:1:n_old]) row[j] * ctrl[j][d]])] - ], - A = [for (k = [0:1:n_new]) - [for (i = [0:1:n_new]) _nip(i, p_new, grev[k], U_new)] - ], - Q = linear_solve(A, C_vals) - ) - assert(Q != [], - "nurbs_elevate_degree: singular collocation (should not happen)") - [Q, U_new, p_new]; - - -// Function: nurbs_elevate_degree() -// Synopsis: Raises the degree of a closed or open NURBS. -// Topics: NURBS Curves -// See Also: nurbs_interp(), nurbs_curve() -// -// Usage: -// result = nurbs_elevate_degree(control, degree, [knots=], [mult=], [type=], [times=], [weights=]); -// result = nurbs_elevate_degree(nurbs_param_list, [times=]); -// -// Description: -// Raises the degree of a "closed" or "open" NURBS by `times` steps, producing -// a geometrically identical curve at the higher degree. Returns a NURBS parameter list -// of the form `[type, degree, control_points, knots, undef, weights]` that can be -// passed directly to {{nurbs_curve()}} and other NURBS functions. The returned `mult` -// parameter is always undef; the returned `weights` will be defined only if you provided -// weights in your input. If you give `times=0` your input parameters are returned unchanged. -// . -// An elevated curve has the same smoothness as the original at each knot. A degree-2 -// curve that is $C^1$ at its knots will still be $C^1$ after elevation to degree 3, -// not $C^2$ as a fresh cubic NURBS with simple knots would be. -// . -// Instead of providing separate parameters you can give a first parameter of the form of a -// NURBS parameter list: `[type, degree, control, knots, mult, weights]`. -// -// Arguments: -// control = Control points, or a NURBS parameter list `[type, degree, ctrl, knots, mult, weights]` -// degree = Degree of NURBS -// --- -// knots = Knot vector. Default: uniform -// mult = List of multiplicities of the knots. Default: all 1 -// type = `"clamped"` or `"open"`. Default: `"clamped"` -// times = Number of degree-elevation steps. Default: `1` -// weights = Weight at each control point - -function nurbs_elevate_degree(control, degree, knots=undef, - type="clamped", times=1, weights=undef, - mult=undef) = - // Accept a NURBS parameter list as the first argument. - is_list(control) && in_list(control[0], ["closed","open","clamped"]) ? - assert(len(control)>=6, "Invalid NURBS parameter list") - assert(num_defined([degree,mult,weights,knots])==0, - "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list") - times == 0 ? control - : nurbs_elevate_degree(control[2], control[1], control[3], - type=control[0], times=times, - weights=control[5], mult=control[4]) - : times == 0 - ? [type, degree, control, knots, mult, weights] - // Rational NURBS: lift to homogeneous space, elevate as a plain B-spline, - // then extract weights from the last coordinate. The recursive call handles - // all asserts, knot normalization, and the times loop. - : !is_undef(weights) - ? assert(len(weights) == len(control), - "nurbs_elevate_degree: weights must have same length as control points") - let( - homo = [for (i = idx(control)) [each control[i]*weights[i],weights[i]]], - r = nurbs_elevate_degree(homo, degree, knots=knots, type=type, times=times, mult=mult), - new_w = [for (pt = r[2]) last(pt)], - new_ctrl = [for (pt = r[2]) slice(pt,0,-2)/last(pt) ] - ) - [r[0], r[1], new_ctrl, r[3], undef, new_w] - // Non-rational B-spline path. - : assert(type == "clamped" || type == "open", - str("nurbs_elevate_degree: type must be \"clamped\" or \"open\", got \"", type, "\"")) - assert(is_num(times) && times >= 1, - "nurbs_elevate_degree: times must be a positive integer") - assert(is_num(degree) && degree >= 1, - "nurbs_elevate_degree: degree must be >= 1") - assert(is_list(control) && len(control) >= 2, - "nurbs_elevate_degree: need at least 2 control points") - assert(is_undef(knots) || is_undef(mult) || len(mult) == len(knots), - str("nurbs_elevate_degree: mult and knots must have the same length; got len(mult)=", - is_undef(mult) ? "undef" : len(mult), - " len(knots)=", - is_undef(knots) ? "undef" : len(knots))) - let( - // Normalize (knots, mult) → internal format for _elevate_once. - // - // clamped: xknots = [k0, interior..., km] — one copy each including endpoints. - // open: xknots = full expanded knot vector (all multiplicities present). - // - // Neither knots nor mult → BOSL2-compatible uniform knots. - // clamped → interior format [0, uniform interior..., 1] - // open → full expanded vector (length n+p+2, uniform) - // - // knots only (no mult): pass through unchanged. - // - // mult only (no knots): uniform positions 0..1 with given multiplicities. - // clamped: endpoint mult forced to degree+1; expand then strip. - // open: full expanded vector. - // - // knots + mult: explicit distinct positions with per-knot multiplicities. - // clamped: endpoint mult forced to degree+1; expand then strip. - // open: full expanded vector. - xknots = - is_undef(knots) && is_undef(mult) - ? ( type == "clamped" ? lerpn(0, 1, len(control) - degree + 1) - : lerpn(0, 1, len(control) + degree + 1) ) - : is_undef(mult) ? knots - : is_undef(knots) - ? let( - m = len(mult), - adj = type == "clamped" && m >= 2 - ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] - : mult, - pos = [for (i = [0:1:m-1]) m == 1 ? 0 : i / (m - 1)], - exp = [for (i = [0:1:m-1]) each repeat(pos[i], adj[i])] - ) - type == "clamped" - ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] - : exp - : let( - m = len(mult), - adj = type == "clamped" && m >= 2 - ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] - : mult, - exp = [for (i = [0:1:m-1]) each repeat(knots[i], adj[i])] - ) - type == "clamped" - ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] - : exp - ) - assert(type != "clamped" || len(xknots) >= 2, - "nurbs_elevate_degree: clamped knots must have at least 2 entries [first,...,last]") - assert(type != "open" || len(xknots) == len(control) + degree + 1, - str("nurbs_elevate_degree: open knots must have length len(control)+degree+1 = ", - len(control) + degree + 1, ", got ", len(xknots))) - let( - // _elevate_once works on the full expanded knot vector. - // Clamped xknots = [k0, interior..., km]; expand to full by adding p copies - // of each endpoint. Open xknots is already full. After elevation, strip the - // p+1 endpoint copies back off for clamped so the output stays in xknots format. - U_full = type == "clamped" - ? concat(repeat(xknots[0], degree), xknots, repeat(last(xknots), degree)) - : xknots, - r = _elevate_once(control, degree, U_full), - new_knots = type == "clamped" - ? slice(r[1], degree+1, -degree-2) - : r[1] - ) - times == 1 - ? [type, r[2], r[0], new_knots, undef, undef] - : nurbs_elevate_degree(r[0], r[2], new_knots, type=type, times=times-1); - - -// ===================================================================== -// SECTION: Local Rational Quadratic Interpolation (P&T §9.3.3) -// ===================================================================== - - -// ===================================================================== -// SECTION: Main Interpolation Function -// ===================================================================== - -// Function: nurbs_interp() -// Synopsis: Finds a NURBS curve passing through a point list with optional derivative constraints. -// Topics: NURBS Curves, Interpolation -// SynTags: Geom -// See Also: nurbs_curve(), debug_nurbs(), nurbs_interp_curve(), debug_nurbs_interp() -// -// Usage: -// nurbs_param = nurbs_interp(points, degree, [method=], [closed=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [deriv=], [extra_pts=], [smooth=]); -// -// Description: -// Given a list of data points and a NURBS degree, computes a curve of the specified degree -// that passes exactly through every data point. The computed curve always has -// uniform weights, but irregularly spaced knots, so it is actually a non-uniform B-spline. -// Data points may 2D or any higher dimension. Returns a NURBS parameter list of the form -// `[type, degree, control_points, knots, undef, undef, u]` that can be -// passed directly to {{nurbs_curve()}} and other NURBS functions. The extra return value `u`, -// described in detail below, enables you to locate your input points in the computed spline -// . -// When `closed=false` (the default) the output is a "clamped" NURBS. -// When `closed=true`, the interpolation treats the data points as a loop and produces a -// curve that is smooth at the closing point. The output will be a "closed" NURBS (unless you -// specify corners as described below). -// If you instead duplicate the closing point and set `closed=false` then the -// result will have a corner at the closing point. -// . -// **Parameterization** (`method=`) -// . -// In order to solve the interpolation problem, the algorithm first chooses -// the NURBS parameter value `u[k]` that will correspond to each `points[k]`. -// This parametrization step significantly affects the shape of the output curve, particularly when the -// data points are not evenly spaced. The following methods are supported: -// . -// - `"length"` — Base parameters values on the chord length, which is distance between the consecutive data points. -// Best when data points are fairly evenly spaced. -// - `"centripetal"` (default) — Base parameters values on the square root of the chord length. (Lee 1989). -// - `"dynamic"` — like centripetal, but the exponent 0.5 is replaced -// by a per-chord value chosen based on local spacing variation. Long chords -// get a smaller exponent and short chords a larger one, compressing the -// influence of outliers. Chord lengths are normalized, which makes the method scale -// invariant and prevents misbehavior at extreme scales. Scaling is not given in the original reference. (Balta et al. 2020). -// - `"foley"` — centripetal base, augmented by corrections at each point that -// are proportional to the local turn angle. Sharp bends pull parameter values -// closer together, which tends to reduce overshoot at corners (Foley & Neilson 1987). -// - `"fang"` — centripetal base, augmented by a correction based on the radius -// of the osculating circle at each point. Said to handles mixed straight-and-curved -// segments particularly well. This method is NOT scale invariant, so results will -// change if you scale your input data. (Fang & Hung 2013). -// . -// The other required input to the interpolation is the location of the knots. -// We place knots using a moving average of `degree` consecutive parameter values, which links -// the knots to the local parameter spacing. A consequence of this process for selection -// of the parameters and knot locations is that even if your input data has symmetry it is -// likely that the symmetry will be broken in the output. For closed curves, another -// consequence is that the resulting curve will depend on which point is chosen as the -// starting point for the interpolation. The algorithm chooses a starting point -// that is expected to provide the best behaved interpolation curve. Examining the -// knot positions with {{debug_nurbs_interp()}} may help you understand unexpected behavior -// you observe in the output. If your curve does not -// behave as desired you may be able to adjust it by imposing additional constraints or -// by giving it more freedom using `extra_pts`. -// . -// **Derivative constraints** (`deriv=`, `start_deriv=`, `end_deriv=`) -// . -// `deriv[k]` specifies the tangent direction and speed the curve must have -// as it passes through `points[k]`. The length of `deriv[k]` gives the speed -// as a multiple of `path_length(points)` which means a unit vector gives a natural -// speed that is a good starting point. -// The speed has a big effect on the shape of the curve, so if the local shape is -// not as you desire you should try increasing it, which will make the curve around -// the point flatter or decreasing it, which will make the curve more pointy. -// Set `deriv[k] = undef` to leave point `k` unconstrained. -// If you only want to set the derivative at the ends of a "clamped" curve you can use -// `start_deriv=` and `end_deriv=`, which set -// `deriv[0]` and `last(deriv)` without the need to provide a list of undefs for all the interior points. -// . -// **Curvature constraints** (`curvature=`, `start_curvature=`, `end_curvature=`) -// . -// The curvature at a point measures how tightly a curve bends. -// When a point has curvature $\kappa$ then a circle with radius $1/\kappa$ -// locally matches the curve at that point so both its first and second derivatives agree. -// This matched circle is called the osculating circle. When you set `curvature[k]` this -// constrains the curvature at `points[k]`. Every curvature-constrained point **must** also have a derivative constraint -// at the same index. Curvature constraints require a degree of at least 2. -// . -// In general curvature constraints require the curvature **vector**, which -// points in the direction of the osculating circle and has length equal to the curvature. -// The curvature vector must be orthogonal to the tangent vector at the point; -// when you specify a curvature vector any component parallel to the tangent is removed. -// The magnitude of the curvature is taken as the magnitude of your original input vector, -// even if subtracting the tangent component changes its length. -// For 2D curves you can also provide curvature as a scalar, with the sign indicating direction. -// (positive = left/CCW, negative = right/CW). -// . -// You can specify the curvature at the ends of "clamped" curves using -// `start_curvature=` and `end_curvature=`, which specify `curvature[0]` -// and `last(curvature)` without the need to create undefs for all the interior points. -// . -// **Corners** (`corners=`) -// . -// `corners=` is a list of interior point indices where the curve has -// a corner, a discontinuity in the derivative. You can also specify a corner -// at point `k` by setting `deriv[k]=NAN`. When you request corners, the -// algorithm chops up the input data into separate clamped splines that run from corner -// to corner. When `closed=true` this results in a "clamped" output spline, and the curve -// will start at one of your corner points. -// If you place corners close together, the effective degree of the short segment -// in between the corners may be reduced. These curve sections are assembled into a single -// NURBS so this process is transparent to the user. A limitation is that you cannot control -// the dervatives of the two segments that meet at a corner. If you need to do this you -// must construct your own sequence of clamped interpolations. -// . -// **Extra control points** (`extra_pts=`, `smooth=`) -// . -// By default, the solver uses exactly as many control points as are needed to -// satisfy the interpolation and constraint conditions, which gives a unique -// solution. This unique solution may be badly behaved, with undesirable oscillations. -// You can improve the behavior by requesting extra points. -// Specifying `extra_pts=N` inserts `N` additional control points and knots, making the -// system underdetermined: infinitely many curves pass through the data points and satisfy -// the constraints. The solver picks the one that satisfies -// a smoothness criterion specified by `smooth=`: -// . -// - `smooth=1` — minimises the sum of squared differences between consecutive -// control points. This tends to keep the control polygon short and reduces -// large-scale variation in the curve. -// - `smooth=2` — minimises the sum of squared second differences of the control -// points. This penalises bending in the control polygon, generally producing -// a fairer, less wiggly curve than `smooth=1`. -// - `smooth=3` (default) — minimises the integrated squared second derivative -// $\int \|\mathbf{C}''(t)\|^2 \, dt$, often called the *bending energy* of -// the curve. Unlike `smooth=2`, which only looks at the control polygon, -// this criterion acts directly on the curve shape and is the most -// mathematically principled choice for smooth interpolation. Requires -// `degree >= 2`. -// . -// The number of extra control points cannot exceed the number of knot spans. -// If you request too many, the number is capped and a warning is displayed. -// With `corners=`, the curve is split into independent clamped segments and -// the extra points are distributed across eligible segments proportionally -// to their control-point count, rounding up, so the total may -// exceed the requested number but will never be less. A segment is eligible when -// its effective degree is 3 or higher, or when it is degree 2 with `smooth=1`. -// . -// **Locating points in the spline** — In order to locate your original data -// points in the spline you need the `u` parameter value that you -// can pass to {{nurbs_curve()}}. The last return value `u` is a list -// where `u[k]` is the NURBS parameter at which the curve passes through -// `points[k]`. -// . -// **Smoothness** — The smoothness of B-splines is determined by the -// degree. If you request a degree $p$ spline then it will be $C^{p-1}$ at -// knot points and $C^\infty$ everywhere else. If you request corners then -// these are points where the curve is not differentiable; corners may -// also divide the curve into small segments that lack sufficient points -// to support an interpolation at your requested degree: a degree $p$ interpolation -// requires $p+1$ points. In this case, the intepolation is performed at a lower -// degree and elevated, which means it will be less smooth at knots. -// -// Arguments: -// points = List of data points to interpolate (2D or any higher dimension). -// degree = Degree of the NURBS. Degree 3 (cubic) is the most common choice. -// --- -// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` -// closed = If true treat point list as a loop . Default: `false` -// start_deriv = If `closed=false`, gives the tangent vector at the first point -// end_deriv = If `closed=false`, gives tangent vector at the last point. -// deriv = List of tangent vector constraints for every point, NAN at corners or undef at unconstrained points. Cannot be combined with `start_deriv=`/`end_deriv=`. -// start_curvature = If `closed=false` gives curvature at first point. (Requires matching derivative.) -// end_curvature = If `closed=false` gives curvature at last point. (Requires matching derivative.) -// curvature = List of curvature constraints for every point, or undef at unconstrained points. Each curvature constraint must be paired with a derivative constraint at the same point. Cannot be combined with `start_curvature=`/`end_curvature=`. -// corners = List of interior point indices where corners are permitted. Equivalent to setting entries of `deriv` to NAN. -// extra_pts = Number of extra control points to add to provide additional freedom to control undesirable oscillations. Default: 0 -// smooth = Smoothness criterion used with extra control points. Set to 1 (minimize control-polygon length), 2 (minimize control-polygon bending) or 3 (minimize curve bending energy). Default: 3 - -function nurbs_interp(points, degree, method="centripetal", closed=false, - deriv=undef, start_deriv=undef, end_deriv=undef, - curvature=undef, start_curvature=undef, end_curvature=undef, - corners=undef, extra_pts=0, smooth=3) = - assert(is_path(points, undef) && len(points) >= 2, - "nurbs_interp: points must be a path (list of same-dimension vectors) with at least 2 points") - assert(is_num(degree) && degree >= 1, - "nurbs_interp: degree must be >= 1") - assert(method == "length" || method == "centripetal" || method == "dynamic" - || method == "foley" || method == "fang", - str("nurbs_interp: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) - assert(is_undef(deriv) || (is_undef(start_deriv) && is_undef(end_deriv)), - "nurbs_interp: use deriv= OR start_deriv=/end_deriv=, not both") - assert(!closed || (is_undef(start_deriv) && is_undef(end_deriv)), - "nurbs_interp: start_deriv/end_deriv only supported for closed=false") - assert(is_undef(deriv) || len(deriv) == len(points), - str("nurbs_interp: deriv= must have same length as points (", - len(points), " points, ", is_undef(deriv) ? 0 : len(deriv), " deriv)")) - assert(is_undef(curvature) || (is_undef(start_curvature) && is_undef(end_curvature)), - "nurbs_interp: use curvature= OR start_curvature=/end_curvature=, not both") - assert(!closed || (is_undef(start_curvature) && is_undef(end_curvature)), - "nurbs_interp: start_curvature=/end_curvature= only supported for closed=false") - assert(is_undef(curvature) || len(curvature) == len(points), - str("nurbs_interp: curvature= must have same length as points (", - len(points), " points, ", is_undef(curvature) ? 0 : len(curvature), " curvature)")) - assert(is_undef(corners) || ( - !closed - ? (min(corners) >= 1 && max(corners) <= len(points)-2) - : (min(corners) >= 0 && max(corners) <= len(points)-1)), - str("nurbs_interp: corners= indices must be ", - !closed ? str("interior (1..", len(points)-2, ")") - : str("valid point indices (0..", len(points)-1, ")"))) - assert(is_num(extra_pts) && extra_pts >= 0 && extra_pts == floor(extra_pts), - str("nurbs_interp: extra_pts must be a non-negative integer, got ", extra_pts)) - assert(extra_pts == 0 || degree >= 2, - "nurbs_interp: extra_pts requires degree >= 2") - assert(smooth == 1 || smooth == 2 || smooth == 3, - str("nurbs_interp: smooth must be 1, 2, or 3, got ", smooth)) - assert(smooth != 3 || degree >= 2, - "nurbs_interp: smooth=3 (bending energy) requires degree >= 2") - let( - type = closed ? "closed" : "clamped", - raw = type == "clamped" - ? _nurbs_interp_clamped(points, degree, method, - deriv, start_deriv, end_deriv, - curvature, start_curvature, end_curvature, - corners, extra_pts, smooth) - : _nurbs_interp_closed(points, degree, method, deriv, curvature, - corners, extra_pts, smooth), - eff_type = is_string(raw[3]) ? raw[3] : type, - rot = raw[2], - n = len(points), - u = type == "closed" && !is_string(raw[3]) - ? list_rotate( - _interp_params(list_rotate(points, rot), method, closed=true), - -rot) - : type == "closed" - ? let( - aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], points[rot]], - aug_params = _interp_params(aug_pts, method) - ) - [for (j = [0:1:n-1]) aug_params[(j - rot + n) % n]] - : _interp_params(points, method) - ) - [eff_type, degree, raw[0], raw[1], undef, undef, u]; - - -// ---------- CLAMPED interpolation ---------- -// -// start_deriv=/end_deriv= and start_curvature=/end_curvature= are convenience shorthands. -// They are merged into eff_der / eff_curv lists here so that all -// constrained cases flow through a single solver -// (_nurbs_interp_clamped_constrained). - -function _nurbs_interp_clamped(points, degree, method, - deriv, start_deriv, end_deriv, - curvature, start_curvature, end_curvature, - corners, extra_pts=0, smooth=3) = - let(n = len(points) - 1, p = degree, dim = len(points[0])) - assert(n >= p, - str("nurbs_interp (clamped): need at least ", p+1, - " points for degree ", p, ", got ", n+1)) - let( - eff_der = _merge_deriv_list(n, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv), - eff_curv = _merge_curv_list(n, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature), - - // C0 corner joints from NaN entries in eff_der and/or corners= list. - // Must be interior points; cannot coincide with curvature constraints. - nan_corners = is_undef(eff_der) ? [] - : [for (k = [0:1:n]) if (is_nan(eff_der[k])) k], - explicit_corners = default(corners, []), - corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), - has_corners = len(corner_idxs) > 0, - bad_corner_end = [for (k = corner_idxs) if (k == 0 || k == n) k], - bad_corner_curv = is_undef(eff_curv) ? [] - : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], - // Explicit corners= entries must not also carry a derivative constraint. - // (NaN-in-deriv corners are fine — they ARE the corner syntax.) - bad_corner_der = is_undef(eff_der) ? [] - : [for (k = explicit_corners) - if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k], - - // Exclude NaN corner markers from the derivative-constraint count. - has_any_der = !is_undef(eff_der) && - len([for (k = [0:1:n]) - if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, - has_any_curv = !is_undef(eff_curv) && - len([for (k = [0:1:n]) if (!is_undef(eff_curv[k])) k]) > 0, - - // Every curvature-constrained point must also have a derivative - // constraint; the derivative direction defines the curve's tangent - // and is required to orient the curvature normal. - bad_curv_pts = is_undef(eff_curv) ? [] : - [for (k = [0:1:n]) - if (!is_undef(eff_curv[k]) && - (is_undef(eff_der) || is_undef(eff_der[k]))) - k] - ) - assert(bad_corner_end == [], - str("nurbs_interp: corner cannot be at the first or last point: ", bad_corner_end)) - assert(bad_corner_curv == [], - str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", bad_corner_curv)) - assert(bad_corner_der == [], - str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", bad_corner_der)) - assert(bad_curv_pts == [], - str("nurbs_interp: curvature constraint requires a derivative constraint ", - "at the same point(s): ", bad_curv_pts)) - has_corners - ? _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, - extra_pts=extra_pts, smooth=smooth) - : (has_any_der || has_any_curv || extra_pts > 0) - ? _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, extra_pts, smooth) - : _nurbs_interp_clamped_basic(points, p, method, smooth); - - -// Basic clamped interpolation (no derivatives). -// n+1 points -> n+1 control points. - -function _nurbs_interp_clamped_basic(points, p, method, smooth=3) = - let( - n = len(points) - 1, - M = n + 1, - dim = len(points[0]), - params = _interp_params(points, method), - int_kn = _avg_knots_interior(params, p), - U_full = _full_clamped_knots(int_kn, p), - N_mat = _collocation_matrix(params, n, p, U_full), - control = linear_solve(N_mat, points), - knots = [0, each int_kn, 1] - ) - assert(control != [], - "nurbs_interp (clamped): singular collocation matrix") - [control, knots, 0]; - - -// Assemble independently-solved clamped corner segments into one B-spline. -// -// All segments must be degree p. Returns [ctrl, xknots, 0] — the standard -// non-segmented result format that callers can pass directly to nurbs_curve / -// debug_nurbs with type="clamped". -// -// BOSL2 clamped knot convention: nurbs_curve() takes xknots of length -// len(control) - degree + 1 -// and internally prepends (degree) zeros and appends (degree) ones to form -// the full clamped knot vector. For a C0 corner at global parameter s_c, -// s_c must appear exactly p times in xknots (giving multiplicity p in the -// full vector = C^0 continuity for degree p). -// -// Segment local knots seg[1] = [0, int_kn..., 1] are remapped to the -// segment's global parameter interval [s_a, s_b] using -// k_global = s_a + (s_b - s_a) * k_local -// which is consistent with any chord-proportional parameterization. - -function _combine_corner_segs(segments, params, corner_idxs, p) = - let( - n_segs = len(segments), - // Global parameter at each corner junction. - cpar = [for (c = corner_idxs) params[c]], - // Global interval [s_a, s_b] for each segment. - seg_sa = [for (s = [0:1:n_segs-1]) s == 0 ? 0 : cpar[s-1]], - seg_sb = [for (s = [0:1:n_segs-1]) s == n_segs-1 ? 1 : cpar[s] ], - // Per-segment interior knots (exclude leading 0 and trailing 1), - // remapped from local [0,1] to the segment's global interval. - seg_gi = [for (s = [0:1:n_segs-1]) - let( - loc = [for (i = [1:1:len(segments[s][1])-2]) segments[s][1][i]], - sa = seg_sa[s], - sb = seg_sb[s] - ) - [for (k = loc) sa + (sb - sa) * k] - ], - // Build combined xknots: - // [0, seg0_int, corner0^p, seg1_int, corner1^p, ..., segN_int, 1] - interior = [for (s = [0:1:n_segs-1]) - each concat( - seg_gi[s], - s < n_segs-1 ? repeat(cpar[s], p) : [] - ) - ], - xknots = [0, each interior, 1], - // Combined control points: all of seg0, then seg[1:1:] for each later seg. - // The first control point of seg s (s >= 1) equals the last of seg s-1 - // because both are the clamped-endpoint interpolant of the shared corner - // data point — so we drop the duplicate. - ctrl = [ - each segments[0][0], - for (s = [1:1:n_segs-1]) - for (j = [1:1:len(segments[s][0])-1]) - segments[s][0][j] - ] - ) - [ctrl, xknots, 0]; - - -// Clamped interpolation with C0 corner joints. -// -// NaN entries in eff_der mark corners: the curve is split into independent -// clamped segments at each corner index. Each segment is solved at the -// highest degree possible: min(p, m-1) where m is the segment point count. -// Degree reduction silently handles short segments (e.g. only 2 or 3 data -// points between adjacent corners). -// -// Segments that needed degree reduction are degree-elevated back to p -// via nurbs_elevate_degree() so that all segments can be assembled into -// a single clamped B-spline. Elevated segments preserve their original -// lower-degree shape but have higher knot multiplicity, so they are -// less smooth at interior knots than natively degree-p segments. - -function _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, - extra_pts=0, smooth=3) = - let( - n = len(points) - 1, - params = _interp_params(points, method), - seg_bounds = [0, each corner_idxs, n], - n_segs = len(seg_bounds) - 1, - // Distribute extra_pts across eligible segments proportionally to - // their control-point count (= data-point count = seg_sizes[s]+1). - // Eligible = segments with seg_p >= 3, or seg_p == 2 when smooth == 1. - // Linear (seg_p==1) and quadratic with smooth!=1 get 0 extra_pts. - seg_sizes = [for (s = [0:1:n_segs-1]) - seg_bounds[s+1] - seg_bounds[s]], - seg_degrees = [for (sz = seg_sizes) min(p, sz)], - // Weight = control-point count for eligible segments, 0 for ineligible. - seg_weights = [for (s = [0:1:n_segs-1]) - let(sp = seg_degrees[s]) - (sp >= 3 || (sp == 2 && smooth == 1)) - ? seg_sizes[s] + 1 : 0], - total_weight = max(1, sum(seg_weights)), - // Round up per-segment allocation so total >= extra_pts. - seg_extra = extra_pts == 0 ? repeat(0, n_segs) - : [for (s = [0:1:n_segs-1]) - seg_weights[s] == 0 ? 0 - : ceil(extra_pts * seg_weights[s] / total_weight)], - raw_segments = [for (s = [0:1:n_segs-1]) - let( - i0 = seg_bounds[s], - i1 = seg_bounds[s+1], - seg_pts = [for (k = [i0:1:i1]) points[k]], - // Reduce degree if the segment has fewer than p+1 points. - seg_p = seg_degrees[s], - // Replace NaN corner markers with undef at shared endpoints. - seg_der = is_undef(eff_der) ? undef - : [for (k = [i0:1:i1]) - is_nan(eff_der[k]) ? undef : eff_der[k]], - seg_curv = is_undef(eff_curv) ? undef - : [for (k = [i0:1:i1]) eff_curv[k]], - r = _nurbs_interp_clamped(seg_pts, seg_p, method, - seg_der, undef, undef, - seg_curv, undef, undef, - extra_pts=seg_extra[s], - smooth=smooth) - ) - [r[0], r[1], seg_p] // [control, knots, degree] - ], - // Degree-elevate short segments to the full degree p. - segments = [for (seg = raw_segments) - seg[2] == p ? seg - : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], - type="clamped", times=p - seg[2])) - [elev[2], elev[3], p] - ] - ) - _combine_corner_segs(segments, params, corner_idxs, p); - - -// General clamped interpolation with per-point derivative and/or curvature -// constraints. -// -// eff_der: list of n+1 first-derivative specs (undef = unconstrained). -// eff_curv: list of n+1 curvature specs (undef = unconstrained). -// dim=2: signed scalar κ. dim≥3: curvature vector. -// -// Uses Method A (expanded-parameter knot averaging, P&T §9.2.2): for each -// constraint at index k, duplicate params[k] in an expanded sequence ũ — -// once per constraint type (deriv and curvature each add one duplication per -// constrained point). This provides one extra DOF per extra constraint. - -function _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, - extra_pts=0, smooth=3) = - let( - n = len(points) - 1, - dim = len(points[0]), - path_len = path_length(points), - path_len2 = path_len * path_len, - params = _interp_params(points, method), - - // First-derivative specs: [index, C'(t) vector]. - // eff_der entries are already dim-projected by _nurbs_interp_clamped. - der_specs = is_undef(eff_der) ? [] - : [for (k = [0:1:n]) if (!is_undef(eff_der[k])) - [k, eff_der[k] * path_len]], - - // Curvature specs: [index, C''(t) vector]. - // eff_der and eff_curv are already dim-projected. - // Tangent from eff_der[k] when available; otherwise estimated from chord. - // Speed² from |eff_der[k]|² × path_len² when derivative given. - curv_specs = is_undef(eff_curv) ? [] - : [for (k = [0:1:n]) if (!is_undef(eff_curv[k])) - let( - t_from_der = is_undef(eff_der) ? undef : eff_der[k], - tang_dir = !is_undef(t_from_der) ? t_from_der - : k == 0 ? points[1] - points[0] - : k == n ? points[n] - points[n-1] - : points[k+1] - points[k-1], - v2 = !is_undef(t_from_der) - ? path_len2 * (t_from_der * t_from_der) - : path_len2 - ) - [k, _curv_to_d2(eff_curv[k], tang_dir, dim, v2)] - ], - - n_extra_der = len(der_specs), - n_extra_curv = len(curv_specs), - _chk_curv_deg = assert(n_extra_curv == 0 || p >= 2, - "nurbs_interp: curvature constraints require degree >= 2"), - n_constraint = n_extra_der + n_extra_curv, - - // Build knots: average data params, insert at constraint spans, - // then insert extra_pts more at widest spans. - base_int = _avg_knots_interior(params, p), - base_bar = [0, each base_int, 1], - constraint_ts = [for (spec = der_specs) params[spec[0]], - for (spec = curv_specs) params[spec[0]]], - after_constr = _insert_constraint_knots(base_bar, constraint_ts), - // For extra_pts, insert knots at midpoints of the widest spans. - // _widest_span_params silently caps the request at the available span count. - extra_ts = extra_pts == 0 ? [] - : _widest_span_params(after_constr, extra_pts), - aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), - n_spans_pre = len(aug_bar_raw) - 1, - aug_bar_pre = _fix_tiny_spans(aug_bar_raw, n_spans_pre), - - // Split any knot span that contains multiple data parameters. - // Without this, two data points in the same span produce a - // rank-deficient collocation matrix (Schoenberg-Whitney condition). - occ_splits = _span_split_params(aug_bar_pre, params), - n_occ = len(occ_splits), - M = n + 1 + n_constraint + len(extra_ts) + n_occ, - aug_bar = n_occ == 0 ? aug_bar_pre - : _fix_tiny_spans( - sort([each aug_bar_pre, each occ_splits]), - n_spans_pre + n_occ), - int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], - U_full = _full_clamped_knots(int_kn, p), - - // Constraint matrix A: interpolation + derivative + curvature rows. - // Dimensions: N_rows × M where N_rows = (n+1) + n_constraint. - N_rows = n + 1 + n_constraint, - - // Interpolation rows: N_{j,p}(t_k) - interp_rows = [for (k = [0:1:n]) - [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] - ], - - // First-derivative rows: N'_{j,p}(t_k) - deriv_rows = [for (spec = der_specs) - let(k = spec[0]) - [for (j = [0:1:M-1]) _dnip(j, p, params[k], U_full)] - ], - - // Second-derivative rows: N''_{j,p}(t_k) - curv_rows = [for (spec = curv_specs) - let(k = spec[0]) - [for (j = [0:1:M-1]) _d2nip(j, p, params[k], U_full)] - ], - - A_constr = [each interp_rows, each deriv_rows, each curv_rows], - rhs_constr = [each points, - for (spec = der_specs) spec[1], - for (spec = curv_specs) spec[1]], - - knots = [0, each int_kn, 1] - ) - // When M == N_rows (square), try direct solve first. - // When M > N_rows (underdetermined from extra_pts or span splits), - // use null-space method: exact constraints + minimum-energy smoothing. - let( - direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] - ) - direct != [] - ? [direct, knots, 0] - : let( - R = smooth <= 2 - ? [for (i = [0:1:M-1]) _ltl_row(M, i, smooth)] - : _bending_energy_matrix(M, p, U_full), - control = _nullspace_solve(R, A_constr, rhs_constr) - ) - assert(!is_undef(control), - "nurbs_interp (clamped+constrained): rank-deficient constraint matrix") - [control, knots, 0]; - - -// ---------- CLOSED interpolation ---------- - -function _nurbs_interp_closed(points, degree, method, deriv, curvature, - corners, extra_pts=0, smooth=3) = - let(n = len(points), p = degree, dim = len(points[0])) - assert(n >= p + 1, - str("nurbs_interp (closed): need at least ", p+1, - " points for degree ", p, ", got ", n)) - let( - // Detect C0 corners from NaN entries in the RAW deriv list before projection, - // since _merge_deriv_list would leave NaN entries intact but we detect them here. - nan_corners = is_undef(deriv) ? [] - : [for (k = [0:1:n-1]) if (is_nan(deriv[k])) k], - explicit_corners = default(corners, []), - corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), - has_corners = len(corner_idxs) > 0, - - // Project derivative and curvature lists (handles BOSL2 direction constants, etc.) - eff_der = _merge_deriv_list(n-1, deriv, dim=dim), - eff_curv = _merge_curv_list(n-1, curvature, dim=dim), - - has_dl = !is_undef(eff_der) && - len([for (k = [0:1:n-1]) - if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, - has_cl = !is_undef(eff_curv) && - len([for (k = [0:1:n-1]) if (!is_undef(eff_curv[k])) k]) > 0, - - // Every curvature-constrained point must also have a derivative constraint. - bad_curv_pts = is_undef(eff_curv) ? [] : - [for (k = [0:1:n-1]) - if (!is_undef(eff_curv[k]) && - (is_undef(eff_der) || is_undef(eff_der[k]))) - k], - // Curvature at a corner is not allowed. - bad_corner_curv = is_undef(eff_curv) ? [] - : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], - // Derivative at an explicit corner is not allowed. - bad_corner_der = is_undef(eff_der) ? [] - : [for (k = explicit_corners) - if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k] - ) - assert(bad_curv_pts == [], - str("nurbs_interp: curvature constraint requires a derivative constraint ", - "at the same point(s): ", bad_curv_pts)) - assert(bad_corner_curv == [], - str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", - bad_corner_curv)) - assert(bad_corner_der == [], - str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", - bad_corner_der)) - // Basic and constrained solvers handle rotation search internally. - // Corner case uses its own rotation (to the first corner). - has_corners - ? _nurbs_interp_closed_corners(points, p, method, eff_der, eff_curv, corner_idxs, - extra_pts=extra_pts, smooth=smooth) - : (has_dl || has_cl || extra_pts > 0) - ? let( - _raw_c = _closed_constrained_solve(points, p, method, eff_der, eff_curv, - 0, extra_pts, smooth), - _chk = assert(!is_undef(_raw_c), - "nurbs_interp (closed+constrained): rank-deficient constraint matrix") - ) _raw_c - : _nurbs_interp_closed_basic(points, p, method, smooth); - - -// Closed interpolation with C0 corner joints. -// -// Converts the closed-with-corners problem into a clamped-with-corners -// problem: rotate data so the first corner is at the start, duplicate -// that point at the end to close the loop, remap remaining corners to -// the rotated frame, and delegate to _nurbs_interp_clamped_corners. -// -// The result is a clamped B-spline whose first and last control points -// coincide at the corner point. r[3] = "clamped" tells convenience -// functions to render with type="clamped" instead of "closed". - -function _nurbs_interp_closed_corners(points, p, method, deriv, curvature, - corner_idxs, extra_pts=0, smooth=3) = - let( - n = len(points), // n points (0..n-1), no repeat - rot = corner_idxs[0], - - // Augmented point list: rotated + closing duplicate of first corner. - aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], - points[rot]], - - // Remap remaining corners to rotated frame. - rot_corners = sort([for (i = [1:1:len(corner_idxs)-1]) - (corner_idxs[i] - rot + n) % n]), - - // Rotate and augment deriv list. - // NaN at the rotation point (now start/end) is cleaned to undef - // since the corner is handled structurally by the clamped endpoints. - aug_der = is_undef(deriv) ? undef : - let(rd = [for (k = [0:1:n-1]) deriv[(k + rot) % n]], - d0 = is_nan(rd[0]) ? undef : rd[0]) - [d0, for (k = [1:1:n-1]) rd[k], d0], - - // Rotate and augment curvature list. - aug_curv = is_undef(curvature) ? undef : - let(rc = [for (k = [0:1:n-1]) curvature[(k + rot) % n]]) - [rc[0], for (k = [1:1:n-1]) rc[k], rc[0]], - - // Solve as clamped with corners. - result = _nurbs_interp_clamped_corners(aug_pts, p, method, - aug_der, aug_curv, - rot_corners, - extra_pts=extra_pts, - smooth=smooth) - ) - // Return with the original rotation index and type override. - [result[0], result[1], rot, "clamped"]; - - -// Returns the maximum number of parameters that fall in any single active -// knot span for cyclic rotation r. A value of 1 is ideal (one parameter -// per span); values > 1 indicate span collisions that may (but do not -// always) cause a singular collocation matrix. - -function _closed_rotation_collision_count(points, n, p, method, r) = - let( - pts = select(points, r, r + n - 1), - rp = _interp_params(pts, method, closed=true), - bk = _fix_tiny_spans(_avg_knots_periodic(rp, p)[0], n), - U = _full_closed_knots(bk, n, p), - ps = add_scalar(rp, bk[p]) - ) - max([for (k = [0:1:n-1]) - len([for (t = ps) if (t >= U[p+k] && t < U[p+k+1]) t]) - ]); - - -// Find the best seam rotation for closed curve interpolation. -// The chord-ratio heuristic (argmax d[i+1]/d[i] + 1) is tried first. -// If it has span collisions, all n rotations are scored by collision -// count and the one with the fewest collisions is chosen. Mild -// collisions (max 2 params per span) often still produce a non-singular -// system, so the final check is deferred to linear_solve(). - -function _find_closed_rotation(points, n, p, method) = - let( - chords = path_segment_lengths(points, closed=true), - ratios = [for (i = [0:1:n-1]) chords[(i+1)%n] / max(chords[i], 1e-15)], - rot0 = (max_index(ratios) + 1) % n - ) - _closed_rotation_collision_count(points, n, p, method, rot0) <= 1 - ? rot0 - : let( - scores = [for (i = [0:1:n-1]) - [_closed_rotation_collision_count(points, n, p, method, i), i]], - best = min_index([for (s = scores) s[0]]) - ) - scores[best][1]; - - -// Solve a basic closed interpolation for a specific rotation. -// Returns [control, bar_knots, rot] or undef if singular. - -function _closed_basic_solve(points, n, p, method, rot, smooth=3) = - let( - dim = len(points[0]), - pts = select(points, rot, rot + n - 1), - raw_params = _interp_params(pts, method, closed=true), - bar_knots = _fix_tiny_spans(_avg_knots_periodic(raw_params, p)[0], n), - U_full = _full_closed_knots(bar_knots, n, p), - params = add_scalar(raw_params, bar_knots[p]), - N_mat = _collocation_matrix_periodic(params, n, p, U_full), - control = linear_solve(N_mat, pts) - ) - control != [] ? [control, bar_knots, rot] - : // Singular — fall back to constrained optimization. - let( - M = n, - R = smooth <= 2 - ? [for (i = [0:1:M-1]) _ltl_row(M, i, smooth, periodic=true)] - : _bending_energy_matrix(M, p, U_full, periodic=true), - ctrl = _nullspace_solve(R, N_mat, pts) - ) - is_undef(ctrl) ? undef : [ctrl, bar_knots, rot]; - - -// Control-point spread ratio: max extent of control points divided by -// max extent of data points. Values near 1 are ideal; large values -// indicate oscillation from ill-conditioning. - -function _ctrl_point_ratio(points, control) = - let( - pbound = pointlist_bounds(points), - cbound = pointlist_bounds(control), - pmax = max(pbound[1] - pbound[0]), - cmax = max(cbound[1] - cbound[0]) - ) - cmax / max(pmax, 1e-15); - - -// Basic closed interpolation — start-point independent. -// -// Implements the cyclic chord-length parameterization and cyclic knot -// averaging of Piegl & Tiller §9.2.4. In exact arithmetic the resulting -// curve is the same regardless of which data point is listed first; only -// the parametric origin changes (the curve is just reparameterized). -// -// The chord-ratio heuristic rotation is tried first. If the resulting -// control-point spread exceeds 2^p/p times the data spread (indicating -// oscillation), all n rotations are tried and the one with the smallest -// spread is selected. - -function _nurbs_interp_closed_basic(points, p, method, smooth=3) = - let( - n = len(points), - rot0 = _find_closed_rotation(points, n, p, method), - result0 = _closed_basic_solve(points, n, p, method, rot0, smooth) - ) - assert(!is_undef(result0), "nurbs_interp (closed): singular system") - let( - ratio0 = _ctrl_point_ratio(points, result0[0]), - threshold = pow(2, p) / p - ) - ratio0 <= threshold ? result0 - : let( - // Heuristic rotation produced excessive control-point spread. - // Try all rotations and pick the one with the smallest spread. - candidates = [for (r = [0:1:n-1]) - let(res = _closed_basic_solve(points, n, p, method, r, smooth)) - if (!is_undef(res)) - [_ctrl_point_ratio(points, res[0]), res]], - _chk = assert(len(candidates) > 0, - "nurbs_interp (closed): all rotations produce singular systems"), - best_idx = min_index([for (c = candidates) c[0]]), - best = candidates[best_idx][1], - _echo = echo(str("nurbs_interp (closed): rotation search chose ", - best[2], " (spread ratio ", - candidates[best_idx][0], ")")) - ) - best; - - -// Solve a constrained closed interpolation for a specific rotation. -// Returns [control, aug_bar, rot] or undef if singular. -// -// eff_der: list of n first-derivative specs (undef = unconstrained). -// eff_curv: list of n curvature specs (undef = unconstrained). -// dim=2: signed scalar κ or 2D vector. dim≥3: curvature vector. -// -// Knot construction: standard periodic averaging of N data params, -// then insert one knot per constraint at the midpoint of the span -// containing its parameter (largest span first). -// M control points use standard BOSL2 periodic aliasing: -// B_j(t) = N_j(t) + (j

= 2, - "nurbs_interp: curvature constraints require degree >= 2"), - n_constraint = n_extra_der + n_extra_curv, - - // Build bar_knots: standard periodic averaging of N data - // params, then insert knots for constraints and extra_pts. - base_bar = _avg_knots_periodic(raw_params, p)[0], - constraint_idxs = [for (spec = der_specs) spec[0], - for (spec = curv_specs) spec[0]], - constraint_ts = [for (k = constraint_idxs) raw_params[k]], - after_constr = _insert_constraint_knots(base_bar, constraint_ts), - // _widest_span_params silently caps the request at the available span count. - extra_ts = extra_pts == 0 ? [] - : _widest_span_params(after_constr, extra_pts), - aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), - // M_pre = span count of aug_bar_raw. Use len()-1 rather than - // n+n_constraint+extra_pts so it reflects the actual knots inserted. - M_pre = len(aug_bar_raw) - 1, - aug_bar_pre = _fix_tiny_spans(aug_bar_raw, M_pre), - - // Split any knot span that contains multiple data parameters. - // Without this, two data points in the same span produce a - // rank-deficient collocation matrix (§9.2.1 Schoenberg-Whitney). - occ_splits = _span_split_params(aug_bar_pre, raw_params), - n_occ = len(occ_splits), - M = M_pre + n_occ, - aug_bar = n_occ == 0 ? aug_bar_pre - : _fix_tiny_spans( - sort([each aug_bar_pre, each occ_splits]), - M), - T = aug_bar[M], - U_full = _full_closed_knots(aug_bar, M, p), - - // Map raw params into active domain [aug_bar[p], aug_bar[p]+T]. - // Nudge any shifted parameter that lands on or near a knot. - raw_shifted = add_scalar(raw_params, aug_bar[p]), - eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), - params = [for (k = [0:1:n-1]) - let( - u = raw_shifted[k], - d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) - ) - d_min < eps_knot ? u + eps_knot : u - ], - - // Constraint matrix A: interpolation + derivative + curvature rows. - N_rows = n + n_constraint, - - // Interpolation rows: aliased basis for M control points - interp_rows = [for (k = [0:1:n-1]) - [for (j = [0:1:M-1]) - _nip(j, p, params[k], U_full) - + (j < p ? _nip(j + M, p, params[k], U_full) : 0) - ] - ], - - // First-derivative rows: aliased derivative basis - deriv_rows = [for (spec = der_specs) - let(k = spec[0]) - [for (j = [0:1:M-1]) - _dnip(j, p, params[k], U_full) - + (j < p ? _dnip(j + M, p, params[k], U_full) : 0) - ] - ], - - // Second-derivative rows: aliased second-derivative basis - curv_rows = [for (spec = curv_specs) - let(k = spec[0]) - [for (j = [0:1:M-1]) - _d2nip(j, p, params[k], U_full) - + (j < p ? _d2nip(j + M, p, params[k], U_full) : 0) - ] - ], - - A_constr = [each interp_rows, each deriv_rows, each curv_rows], - rhs_constr = [each pts, - for (spec = der_specs) spec[1], - for (spec = curv_specs) spec[1]] - ) - // When M == N_rows (square), try direct solve first. - // When M > N_rows (underdetermined from extra_pts or span splits), - // use null-space method: exact constraints + minimum-energy smoothing. - let( - direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] - ) - direct != [] - ? [direct, aug_bar, rot] - : let( - R = smooth <= 2 - ? [for (i = [0:1:M-1]) _ltl_row(M, i, smooth, periodic=true)] - : _bending_energy_matrix(M, p, U_full, periodic=true), - ctrl = _nullspace_solve(R, A_constr, rhs_constr) - ) - is_undef(ctrl) ? undef : [ctrl, aug_bar, rot]; - - -// ===================================================================== -// SECTION: Debug / Visualization -// ===================================================================== - -// Module: debug_nurbs_interp() -// Synopsis: Interpolates a NURBS using {{nurbs_interp()}} and displays the curve with informative overlays. -// Topics: NURBS Curves, Interpolation, Debugging -// See Also: nurbs_interp(), nurbs_interp_curve(), debug_nurbs() -// -// Usage: -// debug_nurbs_interp(points, degree, [splinesteps=], [method=], -// [closed=], [deriv=], [start_deriv=], [end_deriv=], -// [curvature=], [start_curvature=], [end_curvature=], -// [corners=], [extra_pts=], [smooth=], -// [width=], [size=], [data_size=], [data_index=], -// [show_control=], [control_index=], [show_knots=], -// [show_deriv=], [show_curvature=]); -// -// Description: -// Calls {{nurbs_interp()}} with the supplied arguments and displays the -// resulting curve together with a informative overlays. All interpolation -// arguments are passed through unchanged; see {{nurbs_interp()}} for their -// descriptions. The overlays are: -// . -// - **Data points** — red circles (2D) or spheres (3D) at each input point. -// When `data_index=true` (the default), the point index is printed in red next -// to its marker. Set `data_size=0` to suppress display of the data point dots. -// - **Derivative constraints** — a black arrow at each derivative constrained data point. -// Arrow direction and length reflect the constraint vector, scaled to the average -// point spacing. When the derivative is NAN or a point has a corner, this is shown -// using a black diamond. Shown by default: set `show_deriv=false` to hide. -// - **Curvature constraints** — a transparent green overlay at each curvature-constrained point. -// In 2D the overlay is the osculating circle. In 3D the overlay is a cylinder created -// from the 3D osculating circle. Zero curvature appears as a short green bar. -// Shown by default: Set `show_curvature=false` to hide. -// - **Knots** — Green crosses mark each knot position. Not shown by default. -// Enable with `show_knots=true`. -// - **Control points and polygon** — If you set `show_control=true` then a gray control polygon -// Is displayed. If you additionally set `control_index=true` then blue control-point -// index labels appear. -// -// Arguments: -// points = List of 2-D or 3-D data points to interpolate through. -// degree = NURBS degree. -// splinesteps = Steps per knot span for curve rendering. Default: `16` -// --- -// method = Parameterization method; see {{nurbs_interp()}}. Default: `"centripetal"` -// closed = If true, interpolate as a closed loop; if false, interpolate as clamped. Default: `false` -// deriv = Per-point derivative constraints; see {{nurbs_interp()}}. Default: `undef` -// start_deriv = Derivative at first point. Default: `undef` -// end_deriv = Derivative at last point. Default: `undef` -// curvature = Per-point curvature constraints; see {{nurbs_interp()}}. Default: `undef` -// start_curvature = Curvature at first point. Default: `undef` -// end_curvature = Curvature at last point. Default: `undef` -// corners = Corner indices; see {{nurbs_interp()}}. Default: `undef` -// extra_pts = Extra control points; see {{nurbs_interp()}}. Default: `0` -// smooth = Smoothness criterion for `extra_pts`; see {{nurbs_interp()}}. Default: `3` -// width = Stroke width for the curve. Arrows and other overlays scale with this. Default: `1` -// size = Text size for labels on control points and data points. Default: `3*width` -// data_size = Radius of the red data-point markers. Set to `0` to hide data points and their labels. Default: equal to `width` -// data_index = Show index labels next to each data point. Only shown when `data_size > 0`. Default: `true` -// show_control = Show the control polygon. Default: `false` -// control_index = Show control-point index labels if `show_control=true`. Default: `false` -// show_knots = Show knot position markers on the curve. Default: `false` -// show_deriv = Show derivative-constraint arrows. Default: `true` -// show_curvature = Show curvature-constraint circles / disks. Default: `true` - -module debug_nurbs_interp(points, degree, splinesteps=16, method="centripetal", - closed=false, deriv=undef, - start_deriv=undef, end_deriv=undef, - curvature=undef, start_curvature=undef, end_curvature=undef, - corners=undef, extra_pts=0, smooth=3, - width=1, size=undef, data_size=undef, - show_control=false, show_knots=false, - show_deriv=true, show_curvature=true, - control_index=false, data_index=true) { - result = nurbs_interp(points, degree, method=method, - closed=closed, deriv=deriv, - start_deriv=start_deriv, end_deriv=end_deriv, - curvature=curvature, start_curvature=start_curvature, - end_curvature=end_curvature, corners=corners, - extra_pts=extra_pts, smooth=smooth); - - np = len(points); - dim = len(points[0]); - is2d = (dim == 2); - ds = default(data_size, width); - sz = default(size, 3 * width); - ctrl = result[2]; - arrow_scale = path_length(points) / np; - - // Helpers project BOSL2 direction constants and pad dimensions automatically. - eff_der = _merge_deriv_list(np-1, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv); - eff_curv = _merge_curv_list(np-1, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature); - - // --- Curve, control polygon, knot markers (delegated to debug_nurbs) --- - debug_nurbs(result, splinesteps=splinesteps, width=width, size=sz, - show_knots=show_knots, show_control=show_control, - show_index=control_index); - - // --- Corner marks (NaN-deriv corners + explicit corners= indices) --- - // 2D: rotated square stroke. 3D: octahedron wireframe. - nan_corner_idxs = is_undef(eff_der) ? [] - : [for (i = [0:1:np-1]) if (!is_undef(eff_der[i]) && is_nan(eff_der[i])) i]; - explicit_corner_idxs = default(corners, []); - all_corner_idxs = deduplicate(sort(concat(nan_corner_idxs, explicit_corner_idxs))); - for (i = all_corner_idxs) - color("black") - translate(points[i]) - if (is2d) - zrot(45) stroke(rect(3.5*width*ds), width=width/2, closed=true); - else - vnf_wireframe(octahedron(size=5*width), width=width/4); - - // --- Derivative arrows (black, half width, arrow2 endcap) --- - // Length = norm(eff_der[i]) * arrow_scale: preserves relative magnitudes; - // arrow_scale = path_length(points)/np gives a geometry-relative baseline. - if (show_deriv && !is_undef(eff_der)) - for (i = [0:1:np-1]) - if (!is_undef(eff_der[i]) && !is_nan(eff_der[i]) && norm(eff_der[i]) > 1e-12) - color("black") - stroke([points[i], points[i] + eff_der[i] * arrow_scale], - width=width/2, - endcap1="butt", endcap2="arrow2"); - - // --- Data points and index labels --- - if (ds > 0) - color("red") - move_copies(points) { - if (is2d) circle(r=ds, $fn=16); - else sphere(r=ds, $fn=16); - if (data_index) - if (is2d) - fwd(2*ds) text(text=str($idx), size=sz, anchor=BACK); - else - rot($vpr) back(ds + sz/3) text3d(text=str($idx), size=sz, anchor=CENTER); - } - - // --- Curvature overlays (rendered last so transparent objects don't occlude dots) --- - // Validator already asserted every curvature-constrained point has a derivative, - // so eff_der[i] is always defined and non-NaN here. - if (show_curvature && !is_undef(eff_curv)) - color([0,1,0,0.1]) - for (i = [0:1:np-1]) - if (!is_undef(eff_curv[i])) { - // cv is either a signed scalar (2D) or a dim-projected vector. - cv = eff_curv[i]; - kn = is_num(cv) ? abs(cv) : norm(cv); - T_hat = unit(eff_der[i]); - if (kn < 1e-12) { - // Zero curvature: fixed-length segment (0.6*arrow_scale) along - // the exact derivative direction. - half = 0.3 * arrow_scale; - stroke([points[i] - T_hat * half, - points[i] + T_hat * half], - width=2*width, endcaps="butt"); - } else { - // Non-zero curvature: osculating circle (2D) or cylinder (3D). - // N_hat: unit principal normal — component of cv perpendicular to T_hat. - N_hat = is_num(cv) - ? // Signed scalar (2D): rotate T_hat 90° left or right by sign(cv). - sign(cv) * [-T_hat[1], T_hat[0]] - : // Vector: strip tangential component via vector_perp, then unit. - unit(vector_perp(T_hat, cv)); - r = 1 / kn; - ctr = points[i] + N_hat * r; - // move(ctr) applies to both 2D and 3D branches. - move(ctr) - if (is2d) { - circle(r=r); - } else { - // Cylinder in the osculating plane: axis along binormal B̂ = T̂ × N̂. - // cyl(orient=binom) aligns the cylinder axis to B̂ without rot(). - binom = cross(T_hat, N_hat); - cyl(h=width, r=r, orient=binom); - } - } - } -} - - -// ===================================================================== -// SECTION: Interpolation System Builder (shared by curve & surface) -// ===================================================================== - -// Builds the collocation matrix and BOSL2-format knots for a single -// parameterized direction. Returns [N_mat, bosl2_knots]. - -function _build_interp_system(params, p, type, extra_pts=0) = - type == "clamped" ? _build_clamped_system(params, p, extra_pts) - : _build_closed_system(params, p, extra_pts); - -function _build_clamped_system(params, p, extra_pts=0) = - let( - n = len(params) - 1, - int_kn = _avg_knots_interior(params, p), - base_bar = [0, each int_kn, 1] - ) - extra_pts == 0 - ? let( - U_full = _full_clamped_knots(int_kn, p), - N_mat = _collocation_matrix(params, n, p, U_full), - knots = [0, each int_kn, 1] - ) - [N_mat, knots] - : let( - extra_ts = _widest_span_params(base_bar, extra_pts), - aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), - occ_splits = _span_split_params(aug_bar_raw, params), - n_occ = len(occ_splits), - // Use len(extra_ts), not extra_pts: _widest_span_params silently caps - // the request at the number of available spans. - M = n + 1 + len(extra_ts) + n_occ, - aug_bar_merged = n_occ == 0 ? aug_bar_raw - : sort([each aug_bar_raw, each occ_splits]), - aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), - aug_int = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], - U_full = _full_clamped_knots(aug_int, p), - // Rectangular (n+1) × M matrix: n+1 data rows, M control columns. - // _collocation_matrix uses a single n for both dimensions, so build inline. - N_mat = [for (k = [0:1:n]) - [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)]], - knots = [0, each aug_int, 1] - ) - [N_mat, knots]; - -function _build_closed_system(params, p, extra_pts=0) = - let( - n = len(params), - base_bar = _fix_tiny_spans(_avg_knots_periodic(params, p)[0], n) - ) - extra_pts == 0 - ? let( - U_full = _full_closed_knots(base_bar, n, p), - col_params = add_scalar(params, base_bar[p]), - T = base_bar[n], - eps_knot = T / n * (p == 2 ? 0.01 : 1e-6), - col_safe = [for (k = [0:1:n-1]) - let( - u = col_params[k], - d_min = min([for (j = [0:1:n + 2*p]) abs(u - U_full[j])]) - ) - d_min < eps_knot ? u + eps_knot : u - ], - N_mat = _collocation_matrix_periodic(col_safe, n, p, U_full) - ) - [N_mat, base_bar] - : let( - extra_ts = _widest_span_params(base_bar, extra_pts), - aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), - occ_splits = _span_split_params(aug_bar_raw, params), - n_occ = len(occ_splits), - // Use len(extra_ts), not extra_pts: _widest_span_params silently caps - // the request at the number of available spans. - M = n + len(extra_ts) + n_occ, - aug_bar_merged = n_occ == 0 ? aug_bar_raw - : sort([each aug_bar_raw, each occ_splits]), - aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), - T = aug_bar[M], - U_full = _full_closed_knots(aug_bar, M, p), - raw_shifted = add_scalar(params, aug_bar[p]), - eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), - col_safe = [for (k = [0:1:n-1]) - let( - u = raw_shifted[k], - d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) - ) - d_min < eps_knot ? u + eps_knot : u - ], - // Rectangular n × M matrix: n data rows, M control columns. - // _collocation_matrix_periodic uses a single n for both dimensions, so - // build inline. Periodic wrapping folds basis j < p by adding N_{j+M}. - N_mat = [for (k = [0:1:n-1]) - [for (j = [0:1:M-1]) - _nip(j, p, col_safe[k], U_full) - + (j < p ? _nip(j + M, p, col_safe[k], U_full) : 0) - ]] - ) - [N_mat, aug_bar]; - - -// Build a clamped interpolation system with optional start/end first-derivative rows. -// Extends _build_clamped_system by adding one extra DOF and one extra matrix row -// for each active boundary (start and/or end). Used for surface boundary tangents. -// -// has_sd / has_ed — whether a start / end derivative constraint is active. -// extra_pts — number of additional control points (widens the system). -// Returns [A_matrix, bosl2_knots]. Square when extra_pts==0, rectangular otherwise. -// Row order: interpolation rows (k=0..n), deriv_start (if any), deriv_end (if any). - -function _build_clamped_system_with_derivs(params, p, has_sd, has_ed, extra_pts=0) = - let( - n = len(params) - 1, - n_extra = (has_sd ? 1 : 0) + (has_ed ? 1 : 0), - // Average n+1 data params to get base interior knots, then - // insert extra knots for boundary constraints. Each insertion - // bisects the span containing the constraint parameter - // (largest span first). Constraint params 0 and 1 land in - // the first and last spans respectively. - base_int = _avg_knots_interior(params, p), - base_bar = [0, each base_int, 1], - constraint_ts = [if (has_sd) params[0], if (has_ed) params[n]], - after_constr = _insert_constraint_knots(base_bar, constraint_ts), - // Insert extra_pts knots at widest spans. - extra_ts = extra_pts == 0 ? [] - : _widest_span_params(after_constr, extra_pts), - aug_bar_raw = extra_pts == 0 ? after_constr - : _insert_constraint_knots(after_constr, extra_ts), - occ_splits = extra_pts == 0 ? [] - : _span_split_params(aug_bar_raw, params), - n_occ = len(occ_splits), - M = n + 1 + n_extra + len(extra_ts) + n_occ, - aug_bar_merged = n_occ == 0 ? aug_bar_raw - : sort([each aug_bar_raw, each occ_splits]), - aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), - int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], - U_full = _full_clamped_knots(int_kn, p), - interp_rows = [for (k = [0:1:n]) - [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] - ], - deriv_start = has_sd - ? [[for (j = [0:1:M-1]) _dnip(j, p, params[0], U_full)]] - : [], - deriv_end = has_ed - ? [[for (j = [0:1:M-1]) _dnip(j, p, params[n], U_full)]] - : [], - knots = [0, each int_kn, 1] - ) - [[each interp_rows, each deriv_start, each deriv_end], knots]; - - -// Precompute per-segment interpolation systems for edge-aware surface solves. -// All rows (or columns) share the same averaged parameterization, so the -// collocation matrices only need to be built once. -// -// params = averaged parameter values for this direction -// p = degree -// edge_idxs = sorted list of interior indices where C0 edges occur -// has_sd = if true, first segment gets a start-derivative row -// has_ed = if true, last segment gets an end-derivative row -// -// Returns a list of [N_mat, xknots, seg_p, i0, i1, seg_sd, seg_ed] -// per segment, where seg_sd/seg_ed indicate whether that segment's -// system includes a derivative row. - -function _build_edge_systems(params, p, edge_idxs, - has_sd=false, has_ed=false, extra_pts=0, label="") = - let( - n = len(params) - 1, - seg_bounds = [0, each edge_idxs, n], - n_segs = len(seg_bounds) - 1, - - // Pre-compute seg_p and available interior knot spans per segment. - // For a segment with n_pts data points at degree seg_p, the averaged - // interior knot vector has (n_pts-1) - seg_p entries = that many spans. - seg_n_pts = [for (s = [0:1:n_segs-1]) seg_bounds[s+1] - seg_bounds[s] + 1], - seg_p_arr = [for (npts = seg_n_pts) min(p, npts - 1)], - avail_spans = [for (i = [0:1:n_segs-1]) - max(0, seg_n_pts[i] - 1 - seg_p_arr[i])], - total_avail = sum(avail_spans), - k_use = min(extra_pts, total_avail), - - // Emit one diagnostic when extra_pts exceeds the combined span budget. - _echo = extra_pts > 0 && extra_pts > total_avail && label != "" - ? echo(str("nurbs_interp_surface: extra_pts (", label, "-direction)=", - extra_pts, " exceeds available knot spans across ", - n_segs, " segment(s) (max ", total_avail, " total); ", - "reduced to ", total_avail, ".")) - : 0, - - // Distribute k_use proportionally to avail_spans, capped per segment. - seg_ep = extra_pts == 0 || total_avail == 0 ? repeat(0, n_segs) - : [for (s = [0:1:n_segs-1]) - avail_spans[s] == 0 ? 0 - : min(avail_spans[s], - ceil(k_use * avail_spans[s] / total_avail))] - ) - [for (s = [0:1:n_segs-1]) - let( - i0 = seg_bounds[s], - i1 = seg_bounds[s+1], - seg_par = [for (k = [i0:1:i1]) params[k]], - // Remap to [0,1] - t0 = seg_par[0], - t1 = last(seg_par), - span = max(t1 - t0, 1e-15), - local_p = [for (t = seg_par) (t - t0) / span], - seg_p = seg_p_arr[s], - // Derivative extension requires at least seg_p+1 data points - // (same minimum as basic interpolation); each derivative row - // adds one control point and one equation, keeping the system - // square. Degree-reduced segments with fewer points silently - // skip the constraint. - n_pts = seg_n_pts[s], - seg_sd = has_sd && s == 0 && n_pts >= seg_p + 1, - seg_ed = has_ed && s == n_segs - 1 && n_pts >= seg_p + 1, - // extra_pts only applies when degree >= 2; silently skip for - // degree-reduced (seg_p < 2) segments. - cur_ep = seg_p >= 2 ? seg_ep[s] : 0, - sys = (seg_sd || seg_ed) - ? _build_clamped_system_with_derivs(local_p, seg_p, - seg_sd, seg_ed, cur_ep) - : _build_interp_system(local_p, seg_p, "clamped", cur_ep) - ) - [sys[0], sys[1], seg_p, i0, i1, seg_sd, seg_ed] - ]; - - -// Solve one row (or column) using precomputed edge-aware systems. -// Each segment is solved independently; short segments are degree-elevated. -// Results are assembled into a single clamped B-spline via _combine_corner_segs. -// -// systems = list from _build_edge_systems -// data = row/column data points (same length as params) -// params = averaged parameter values -// edge_idxs = edge index list (same as passed to _build_edge_systems) -// p = target degree -// start_deriv = derivative vector at start of first segment (undef if none) -// end_deriv = derivative vector at end of last segment (undef if none) - -function _solve_with_edges(systems, data, params, edge_idxs, p, - start_deriv=undef, end_deriv=undef, smooth=3) = - let( - raw_segments = [for (sys = systems) - let( - N_mat = sys[0], - knots = sys[1], - i0 = sys[3], - i1 = sys[4], - seg_p = sys[2], - seg_sd = sys[5], - seg_ed = sys[6], - seg_data = [for (k = [i0:1:i1]) data[k]], - rhs = concat(seg_data, - seg_sd ? [start_deriv] : [], - seg_ed ? [end_deriv] : []), - M = len(N_mat[0]), - N_rows = len(rhs), - // When M > N_rows the segment system is underdetermined (extra_pts). - // Use null-space method: exact interpolation + minimum bending energy. - ctrl = M > N_rows - ? let( - int_kn = [for (i = [1:1:len(knots)-2]) knots[i]], - U_full = _full_clamped_knots(int_kn, seg_p), - eff_smooth = (smooth == 3 && seg_p < 2) ? 2 : smooth, - R = eff_smooth <= 2 - ? [for (i = [0:1:M-1]) _ltl_row(M, i, eff_smooth)] - : _bending_energy_matrix(M, seg_p, U_full) - ) - _nullspace_solve(R, N_mat, rhs) - : linear_solve(N_mat, rhs) - ) - assert(ctrl != [] && !is_undef(ctrl), - str("nurbs_interp_surface: singular edge-segment system for rows/cols ", - i0, "-", i1, " (", i1-i0+1, " points, degree ", seg_p, - seg_sd ? ", start deriv" : "", - seg_ed ? ", end deriv" : "", ")")) - [ctrl, knots, seg_p] - ], - // Degree-elevate short segments to full degree p. - segments = [for (seg = raw_segments) - seg[2] == p ? seg - : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], - type="clamped", times=p - seg[2])) - [elev[2], elev[3], p] - ] - ) - _combine_corner_segs(segments, params, edge_idxs, p); - - -// ===================================================================== -// SECTION: Surface Interpolation -// ===================================================================== - -// Compute per-point tangent vectors for a degenerate apex row or column. -// Returns true if all points in pts are collinear (lie on a single line). -// Computes the direction from first to last point, then checks that every -// intermediate point projects onto that line within eps. Points that are -// all identical also pass (dn < eps branch). - -// Returns true if all points in pts are coplanar (lie in a single plane). -// For 2D points always returns true. For 3D: finds the plane through the -// first three non-collinear points (using their cross-product normal), then -// checks that all remaining points satisfy |dot(pt-p0, nhat)| < eps. -// Points that are all collinear (degenerate plane) also return true. - -function _is_coplanar_pts(pts, eps=1e-10) = - let(n = len(pts), dim = len(pts[0])) - n <= 3 || dim <= 2 ? true - : let( - p0 = pts[0], - d1 = pts[1] - p0, - // Index of first point not collinear with pts[0..1]. - nc = [for (i = [2:1:n-1]) - let(c = cross(d1, pts[i] - p0)) - if (norm(c) > eps) i][0] - ) - is_undef(nc) ? true // all collinear → trivially coplanar - : let( - normal = cross(d1, pts[nc] - p0), - nhat = normal / norm(normal) - ) - max([for (pt = pts) abs((pt - p0) * nhat)]) < eps; - - -// Plane normal for a set of 3D points (returns 3D vector, or undef if collinear). -// Always returns [0,0,1] for 2D points. - -function _pts_plane_normal(pts, eps=1e-10) = - let(dim = len(pts[0])) - dim <= 2 - ? [0, 0, 1] - : let( - p0 = pts[0], - d1 = last(pts) - p0, - nc = [for (i = [1:1:len(pts)-1]) - let(c = cross(d1, pts[i] - p0)) - if (norm(c) > eps) c][0] - ) - is_undef(nc) ? undef : nc; - - -// Used to auto-generate first_row_deriv / last_row_deriv / first_col_deriv / last_col_deriv -// when normal1=/normal2= or flat_end1=/flat_end2= is supplied. -// -// Apex edge (all boundary points identical): -// _apex_tangents(N, apex, ring) -// N defines the symmetry axis (user-supplied vector); magnitude sets derivative scale. -// Returns per-point outward vectors (apex→ring, projected ⊥ N) of magnitude norm(N). -// Pass the negated result for an end (u=1 or v=1) apex; see caller. -// -// Coplanar edge (boundary points coplanar and span a plane, i.e. non-collinear): -// _coplanar_inward_tangents(scales, edge, ring, periodic=false) -// At each edge point computes a unit vector perpendicular to the polygon edge tangent, -// lying in the edge plane, oriented toward the polygon interior. -// -// Interior orientation uses polygon winding: the signed area of the edge polygon -// projected onto the edge plane (via the area vector = Σ cross(edge[i], edge[(i+1)%n])). -// If the area vector aligns with P_hat (CCW when viewed from P_hat) the interior is to -// the LEFT of the traversal direction; cross(P_hat, T3) already points left and so is -// the inward normal. If CW (area vector opposes P_hat), cross(P_hat, T3) points right -// (outward) and is negated. This is robust for any non-convex polygon. -// -// scales: scalar or per-point list; positive = inward (closes surface), -// negative = outward (flares surface). Same convention at start and end edges. -// periodic=true uses wrapped central differences at the first/last point (for closed v/u). - -function _apex_tangents(N, apex, ring) = - let( - mag = norm(N), - N_hat = N / max(mag, 1e-15) - ) - [for (pt = ring) - let( - d = pt - apex, - d_perp = d - (d * N_hat) * N_hat, - n_perp = norm(d_perp) - ) - n_perp > 1e-12 ? mag * d_perp / n_perp : repeat(0, len(N)) - ]; - - -function _coplanar_inward_tangents(scales, edge, ring, periodic=false) = - let( - n = len(edge), - dim = len(edge[0]), - P = _pts_plane_normal(edge), - zero = repeat(0, dim), - sc = is_num(scales) ? repeat(scales, n) : scales - ) - is_undef(P) ? repeat(zero, n) - : let( - P_hat = P / norm(P), - // Polygon area vector = Σ cross(edge[i], edge[(i+1)%n]). - // Positive dot with P_hat → CCW when viewed from P_hat → interior is LEFT. - // Negative dot → CW → interior is RIGHT. - area_vec = sum([for (i = [0:1:n-1]) - cross(dim == 2 ? [edge[i][0], edge[i][1], 0] - : edge[i], - dim == 2 ? [edge[(i+1)%n][0], edge[(i+1)%n][1], 0] - : edge[(i+1)%n])]), - sign = (area_vec * P_hat) >= 0 ? 1 : -1 - ) - [for (j = [0:1:n-1]) - let( - jm = periodic ? (j == 0 ? n-1 : j-1) : max(0, j-1), - jp = periodic ? (j == n-1 ? 0 : j+1) : min(n-1, j+1), - // Incoming and outgoing edge vectors (lifted to 3D for 2D input). - seg1 = dim == 2 ? [edge[j][0]-edge[jm][0], edge[j][1]-edge[jm][1], 0] - : edge[j] - edge[jm], - seg2 = dim == 2 ? [edge[jp][0]-edge[j][0], edge[jp][1]-edge[j][1], 0] - : edge[jp] - edge[j], - s1 = norm(seg1), - s2 = norm(seg2), - // Inward normal to each adjacent edge (unit vector), using polygon - // winding sign. cross(P_hat, unit_edge) = 90° left rotation in plane. - // Angle-bisector (average of unit normals) is length-independent, so - // non-uniform sample spacing has no effect — unlike the chord-average - // tangent method it replaces. - n1 = s1 < 1e-12 ? undef : sign * cross(P_hat, seg1 / s1), - n2 = s2 < 1e-12 ? undef : sign * cross(P_hat, seg2 / s2), - bis = is_undef(n1) ? n2 : is_undef(n2) ? n1 : n1 + n2, - blen = is_undef(bis) ? 0 : norm(bis) - ) - blen < 1e-12 ? zero - : let( - in3 = bis / blen, - inward = dim == 2 ? [in3[0], in3[1]] : in3 - ) - sc[j] * inward - ]; - -// Averaged parameterization for the u-direction (across rows). -// For each column, compute chord-length params, then average. - -function _surface_params_u(points, method, periodic) = - let( - n_rows = len(points), - n_cols = len(points[0]), - col_params = [for (l = [0:1:n_cols-1]) - let(col = [for (k = [0:1:n_rows-1]) points[k][l]]) - _interp_params(col, method, closed=periodic) - ], - n_p = len(col_params[0]) - ) - [for (k = [0:1:n_p-1]) - sum([for (l = [0:1:n_cols-1]) col_params[l][k]]) / n_cols - ]; - - -// Averaged parameterization for the v-direction (across columns). -// For each row, compute chord-length params, then average. - -function _surface_params_v(points, method, periodic) = - let( - n_rows = len(points), - n_cols = len(points[0]), - row_params = [for (k = [0:1:n_rows-1]) - _interp_params(points[k], method, closed=periodic) - ], - n_p = len(row_params[0]) - ) - [for (l = [0:1:n_p-1]) - sum([for (k = [0:1:n_rows-1]) row_params[k][l]]) / n_rows - ]; - - -// Function&Module: nurbs_interp_surface() -// Synopsis: Returns a NURBS surface that passes through a grid of 3D data points. -// SynTags: Geom -// Topics: NURBS Surfaces, Interpolation -// See Also: nurbs_vnf(), nurbs_interp() -// -// Usage: As a function, returns a NURBS parameter list: -// nurbs_param = nurbs_interp_surface(points, degree, [method=], [row_wrap=], [col_wrap=], [normal1=], [normal2=], [flat_edges=], [flat_end1=], [flat_end2=], [row_edges=], [col_edges=], [extra_pts=], [smooth=], [first_row_deriv=], [last_row_deriv=], [first_col_deriv=], [last_col_deriv=]); -// Usage: As a module, renders the surface directly: -// nurbs_interp_surface(points, degree, [splineteps], ..., [data_color=], [data_size=],[style=], [reverse=], [triangulate=], [convexity=], [cp=], [atype=], ...) CHILDREN; -// Description: -// Finds the control points and knot vectors for a NURBS surface of the specified degree that passes -// exactly through every data point in a grid of 3D points. The result has -// uniform weights but non-uniform knots so it is actually a non-uniform B-spline. -// When called as a function, the return value is a NURBS parameter list -// `[type, degree, ctrl_grid, knots, undef, undef, uv]` that can be passed -// directly to `{{nurbs_vnf()}}`. The extra return value `uv`, -// described in detail below, enables you to locate your input points in the computed spline -// When called as a module, renders the NURBS surface as geometry. -// . -// Several of the parameters that correspond to parameters for {{nurbs_interp()}} -// can be given as either a scalar or 2-vector. When you give a 2-vector the -// first value applies along the first index of your point data, i.e. from row -// to row, or along columns. The second value applies along the second index, -// i.e. within rows. -// . -// Setting `row_wrap=true` smoothly connects the first and last rows in a loop, -// and `col_wrap=true` smoothly joins the first and last columns. Both false (the default) gives a -// surface with four edges. One true gives a tube; both true gives a torus. -// A tube by itself is not a valid closed manifold in OpenSCAD; you can make it valid by adding caps or -// you can close it into a ball by specifying degenerate edges where the entire edge collapses to -// one identical point. -// . -// **Boundary constraints** -// . -// Flat boundary (`row_wrap=false, col_wrap=false`) — `flat_edges=`. Applies when -// all four surface edges are coplanar. Set `flat_edges` to a 4-element list -// `[first_row, last_row, first_col, last_col]`; each entry is a scalar or per-point list -// giving the derivative scale for that edge (`undef` leaves the edge unconstrained). -// `flat_edges=s` expands to `[s,s,s,s]`. A positive value flares the surface -// outward from the edge; negative turns it inward. -// . -// End normals (one of `row_wrap`/`col_wrap` true, the other false) — `normal1=` and -// `normal2=`. Apply when the specified boundary edge is degenerate (all points -// identical, e.g. a cone tip). The surface is constrained to be normal to the given -// vector at that edge. The vector magnitude controls how broadly the surface spreads. -// . -// Flat ends (one of `row_wrap`/`col_wrap` true, the other false) — `flat_end1=` and -// `flat_end2=`. Apply when the specified boundary edge is coplanar and non-degenerate. -// Constrains the derivative to lie in the plane of the edge. Positive points inward -// (smooth cap attachment); negative flares outward. Scalar or per-point list. -// . -// **Advanced boundary derivatives** — `first_row_deriv=`, `last_row_deriv=`, -// `first_col_deriv=`, and `last_col_deriv=` enforce specific first partial derivatives -// along the four boundary edges. Each accepts a single vector (applied to every -// point on the edge) or a list of vectors (one per point). Vectors are scaled by -// total chord length, so a unit vector matches the parameterization speed. These -// require `row_wrap=false` (for row derivs) or `col_wrap=false` (for col derivs). -// . -// Use with care: the solver enforces derivatives exactly at data points but the -// surface may wander between them. When both u- and v-boundary derivatives are -// active, the cross-derivative is assumed zero at corners. -// . -// **Edges** — `row_edges=` and `col_edges=` insert edges or creases across the surface. -// Use `row_edges=` to specify the indices of rows that will be edges or creases, -// and `col_edges=` to specify the indices of columns that will be edges or creases. -// For a non-wrapped direction, indices must be interior (not first or last). -// If you place edges close together, the effective degree of a narrow patch between -// edges may be reduced. These patches are assembled into a single NURBS so this -// process is transparent to the user. -// . -// **Extra control points** (`extra_pts=`, `smooth=`) — By default the solver uses -// exactly the number of control points needed to satisfy the constraints, which -// gives a unique solution that may be badly behaved. Specifying `extra points=` -// and optionally `smooth=`, works the same way as in -// for {{nurbs_interp()}}. Both parameters can be scalars or 2-vectors to -// provide different values along the two directions. -// . -// **Locating points in the spline** — In order to locate your original data -// points in the spline you need the `u` and `v` nurbs parameter values that you -// can pass to {{nurbs_patch_points()}}. The last return value `uv` gives these: -// `uv[0][j]` is the u parameter for row `j` and `uv[1][k]` is the v parameter -// for column `k`, so the point `points[j][k]` lies at `(uv[0][j], uv[1][k])` -// in NURBS parameter space. -// . -// **Smoothness** — The smoothness of B-splines is determined by the -// degree. If you request a degree $p$ spline then it will be $C^{p-1}$ at -// knot points and $C^\infty$ everywhere else. If you request edges then -// these are points where the surface is not differentiable; edges may -// also divide the surface into smaller regions that lack sufficient points -// to support an interpolation of your requested degree: a degree $p$ interpolation -// requires $p+1$ points. In this case, the inteprolation is performed at a lower -// degree and elevated, which means it will be less smooth at knots. -// Arguments: -// points = Rectangular grid of 3D data points -// degree = scalar or 2-vector giving the degree of the B-spline in the two directions. -// splinesteps = (module) Scalar or 2-vector giving the number of segments between each knot in the two directions. Default: 16 -// --- -// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` -// row_wrap = If true, smoothly connect the first row to the last row. Default: false -// col_wrap = If true, smoothly connect the first column to the last column. Default: false -// extra_pts = Scalar or 2-vector giving the number of extra points in the two directions. Default: `0` -// smooth = Scalar or 2-vector giving the smoothness metric for extra points in the two directions: `1` (min polygon length), `2` (min bending), `3` (min bending energy). Default: `3` -// flat_edges = 4-element list `[first_row, last_row, first_col, last_col]` of derivative scales at the four coplanar boundary edges. Each entry is a scalar or per-point list; `undef` leaves that edge unconstrained. Shorthand: `flat_edges=s` → `[s,s,s,s]`. Requires `row_wrap=false, col_wrap=false`. -// normal1 = Surface normal at the first degenerate boundary edge (mixed wrap surface only). -// normal2 = Surface normal at the second degenerate boundary edge (mixed wrap surface only). -// flat_end1 = Inward derivative scale at the first coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. -// flat_end2 = Inward derivative scale at the second coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. -// row_edges = Row indices (or index) of rows that are treated as edges or creases. -// col_edges = Column indices (or index) of columns that are treated as edges or creases -// first_row_deriv = $\partial S/\partial u$ constraint along u=0 (first row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. -// last_row_deriv = $\partial S/\partial u$ constraint along u=1 (last row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. -// first_col_deriv = $\partial S/\partial v$ constraint along v=0 (first column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. -// last_col_deriv = $\partial S/\partial v$ constraint along v=1 (last column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. -// data_size = (module) Radius of data-point markers; 0 suppresses markers. Default: 0 -// data_color = (module) Color for data-point markers. Default: `"red"` -// style = (module) Triangulation style passed to `vnf_vertex_array()`. Default: `"default"` -// reverse = (module) If true, reverses face normals. Default: false -// triangulate = (module) If true, triangulates all quads. Default: false -// caps = (module) Cap both open boundary edges (mixed wrap only). Default: false -// cap1 = (module) Cap the first open boundary edge. -// cap2 = (module) Cap the second open boundary edge. -// cp = (module) Centerpoint for determining intersection anchors or centering the shape. Determines the base of the anchor vector. Can be "centroid", "mean", "box" or a 3D point. Default: "centroid" -// anchor = (module) Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `"origin"` -// spin = (module) Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0` -// orient = (module) Vector to rotate top toward, after spin. See [orient](attachments.scad#subsection-orient). Default: `UP` -// atype = (module) Select "hull" or "intersect" anchor type. Default: "hull" - -function nurbs_interp_surface(points, degree, method="centripetal", - row_wrap=false, col_wrap=false, - first_row_deriv=undef, last_row_deriv=undef, - first_col_deriv=undef, last_col_deriv=undef, - normal1=undef, normal2=undef, - flat_end1=undef, flat_end2=undef, - flat_edges=undef, - row_edges=undef, col_edges=undef, - extra_pts=0, smooth=3) = - // Preamble: extract shape/edge info needed for closed-direction dispatch. - let( - n_rows = len(points), - n_cols = len(points[0]), - ue_norm_pre = is_undef(row_edges) ? undef : force_list(row_edges), - ve_norm_pre = is_undef(col_edges) ? undef : force_list(col_edges), - has_ue_pre = !is_undef(ue_norm_pre) && len(ue_norm_pre) > 0, - has_ve_pre = !is_undef(ve_norm_pre) && len(ve_norm_pre) > 0 - ) - // col_edges on a closed v-direction: rotate columns so the first crease column - // becomes the v=0/v=1 boundary, append a copy at the end for the C0 seam, - // then recurse with col_wrap=false. Remaining crease indices are shifted - // into the rotated coordinate system. - has_ve_pre && col_wrap ? - let( - ve_sorted = sort(ve_norm_pre), - rot = ve_sorted[0], - new_pts = [for (row = points) - concat([for (l = [rot:1:n_cols-1]) row[l]], - [for (l = [0:1:rot-1]) row[l]], - [row[rot]])], - adj_ve_raw = [for (i = [1:1:len(ve_sorted)-1]) - let(j = (ve_sorted[i] - rot + n_cols) % n_cols) - if (j > 0) j], - adj_ve = len(adj_ve_raw) == 0 ? undef : adj_ve_raw - ) - let(inner = nurbs_interp_surface(new_pts, degree, method=method, - row_wrap=row_wrap, col_wrap=false, - first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, - first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, - normal1=normal1, normal2=normal2, - flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, - row_edges=row_edges, col_edges=adj_ve, - extra_pts=extra_pts, smooth=smooth)) - [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], - [inner[6][0], - list_rotate(select(inner[6][1], 0, n_cols-1), -rot)]] - // row_edges on a closed u-direction: rotate rows so the first crease row - // becomes the u=0/u=1 boundary, append a copy at the end, recurse clamped. - : has_ue_pre && row_wrap ? - let( - ue_sorted = sort(ue_norm_pre), - rot = ue_sorted[0], - new_pts = concat([for (k = [rot:1:n_rows-1]) points[k]], - [for (k = [0:1:rot-1]) points[k]], - [points[rot]]), - adj_ue_raw = [for (i = [1:1:len(ue_sorted)-1]) - let(j = (ue_sorted[i] - rot + n_rows) % n_rows) - if (j > 0) j], - adj_ue = len(adj_ue_raw) == 0 ? undef : adj_ue_raw - ) - let(inner = nurbs_interp_surface(new_pts, degree, method=method, - row_wrap=false, col_wrap=col_wrap, - first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, - first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, - normal1=normal1, normal2=normal2, - flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, - row_edges=adj_ue, col_edges=col_edges, - extra_pts=extra_pts, smooth=smooth)) - [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], - [list_rotate(select(inner[6][0], 0, n_rows-1), -rot), - inner[6][1]]] - // Normal path: both directions already clamped, or no conflicting edge constraints. - : let( - p_u = is_list(degree) ? degree[0] : degree, - p_v = is_list(degree) ? degree[1] : degree, - ep_u = is_list(extra_pts) ? extra_pts[0] : extra_pts, - ep_v = is_list(extra_pts) ? extra_pts[1] : extra_pts, - smooth_u = is_list(smooth) ? smooth[0] : smooth, - smooth_v = is_list(smooth) ? smooth[1] : smooth, - n_rows = len(points), - n_cols = len(points[0]), - dim = len(points[0][0]), - // Scalar-vector promotion: if the caller passes a single vector instead of - // a list of vectors, repeat() it to the required length. A single vector - // is detected as a list whose first element is a number, not a list. - first_row_deriv = is_undef(first_row_deriv) || is_list(first_row_deriv[0]) ? first_row_deriv - : repeat(first_row_deriv, n_cols), - last_row_deriv = is_undef(last_row_deriv) || is_list(last_row_deriv[0]) ? last_row_deriv - : repeat(last_row_deriv, n_cols), - first_col_deriv = is_undef(first_col_deriv) || is_list(first_col_deriv[0]) ? first_col_deriv - : repeat(first_col_deriv, n_rows), - last_col_deriv = is_undef(last_col_deriv) || is_list(last_col_deriv[0]) ? last_col_deriv - : repeat(last_col_deriv, n_rows), - // Treat an all-undef derivative list the same as undef. - has_sud = !is_undef(first_row_deriv) && num_defined(first_row_deriv) > 0, - has_eud = !is_undef(last_row_deriv) && num_defined(last_row_deriv) > 0, - has_svd = !is_undef(first_col_deriv) && num_defined(first_col_deriv) > 0, - has_evd = !is_undef(last_col_deriv) && num_defined(last_col_deriv) > 0, - has_sn = !is_undef(normal1), - has_en = !is_undef(normal2), - // normal1/normal2: apex edges only (all boundary points identical, e.g. cone tip). - // Auto-detect u=0/v=0 direction; u=0 (first row) takes priority. - start_u_apex = has_sn && max([for (pt = points[0]) norm(pt - points[0][0])]) < 1e-10, - start_v_apex = has_sn && max([for (k = [0:1:n_rows-1]) norm(points[k][0] - points[0][0])]) < 1e-10, - end_u_apex = has_en && max([for (pt = points[n_rows-1]) norm(pt - points[n_rows-1][0])]) < 1e-10, - end_v_apex = has_en && max([for (k = [0:1:n_rows-1]) norm(points[k][n_cols-1] - points[0][n_cols-1])]) < 1e-10, - has_sun = has_sn && start_u_apex, - has_eun = has_en && end_u_apex, - has_svn = has_sn && !start_u_apex && start_v_apex, - has_evn = has_en && !end_u_apex && end_v_apex, - start_u_degen = start_u_apex, - start_v_degen = start_v_apex, - end_u_degen = end_u_apex, - end_v_degen = end_v_apex, - // flat_end1/flat_end2: coplanar non-collinear edges (points span a plane). - // Scalar or per-point list. positive = closes inward, negative = flares outward. - // Direction is determined by the clamped direction of the surface: - // row_wrap=false → flat_end applies to row boundaries (u-direction, first/last row). - // col_wrap=false → flat_end applies to column boundaries (v-direction, first/last col). - // Exactly one direction must be clamped (enforced by assertion below). - has_fe1 = !is_undef(flat_end1), - has_fe2 = !is_undef(flat_end2), - has_fe1_u = has_fe1 && !row_wrap, - has_fe1_v = has_fe1 && !col_wrap, - has_fe2_u = has_fe2 && !row_wrap, - has_fe2_v = has_fe2 && !col_wrap, - // Boundary edges for coplanar validation. - fe1_edge = has_fe1_u ? points[0] - : has_fe1_v ? [for (k = [0:1:n_rows-1]) points[k][0]] - : [], - fe2_edge = has_fe2_u ? points[n_rows-1] - : has_fe2_v ? [for (k = [0:1:n_rows-1]) points[k][n_cols-1]] - : [], - fe1_ok = !has_fe1 || (_is_coplanar_pts(fe1_edge) && !is_undef(_pts_plane_normal(fe1_edge))), - fe2_ok = !has_fe2 || (_is_coplanar_pts(fe2_edge) && !is_undef(_pts_plane_normal(fe2_edge))), - // flat_edges= parsing: 4-element list [first_row, last_row, first_col, last_col]. - // Scalar shorthand: flat_edges=s expands to [s, s, s, s]. - fe_norm = !is_undef(flat_edges) && !is_list(flat_edges) - ? [flat_edges, flat_edges, flat_edges, flat_edges] - : flat_edges, - has_fe = !is_undef(fe_norm), - fe_su = has_fe ? fe_norm[0] : undef, - fe_eu = has_fe ? fe_norm[1] : undef, - fe_sv = has_fe ? fe_norm[2] : undef, - fe_ev = has_fe ? fe_norm[3] : undef, - has_fesu = has_fe && !is_undef(fe_su), - has_feeu = has_fe && !is_undef(fe_eu), - has_fesv = has_fe && !is_undef(fe_sv), - has_feev = has_fe && !is_undef(fe_ev), - // Edge (C0 discontinuity) support. Singleton promotion: scalar → list. - ue_norm = is_undef(row_edges) ? undef : force_list(row_edges), - ve_norm = is_undef(col_edges) ? undef : force_list(col_edges), - has_ue = !is_undef(ue_norm) && len(ue_norm) > 0, - has_ve = !is_undef(ve_norm) && len(ve_norm) > 0 - ) - assert(is_list(points) && n_rows >= 2, - "nurbs_interp_surface: need at least 2 rows") - assert(n_cols >= 2, - "nurbs_interp_surface: need at least 2 columns") - assert(min([for (row = points) len(row)]) == max([for (row = points) len(row)]), - "nurbs_interp_surface: all rows must have the same number of columns") - assert(is_num(p_u) && p_u >= 1 && is_num(p_v) && p_v >= 1, - "nurbs_interp_surface: degree must be >= 1") - assert(method == "length" || method == "centripetal" || method == "dynamic" - || method == "foley" || method == "fang", - str("nurbs_interp_surface: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) - assert(is_num(ep_u) && ep_u >= 0 && ep_u == floor(ep_u), - str("nurbs_interp_surface: extra_pts (u) must be a non-negative integer, got ", ep_u)) - assert(is_num(ep_v) && ep_v >= 0 && ep_v == floor(ep_v), - str("nurbs_interp_surface: extra_pts (v) must be a non-negative integer, got ", ep_v)) - assert(ep_u == 0 || p_u >= 2, - "nurbs_interp_surface: extra_pts in u-direction requires u-degree >= 2") - assert(ep_v == 0 || p_v >= 2, - "nurbs_interp_surface: extra_pts in v-direction requires v-degree >= 2") - assert(n_rows >= p_u + 1, - str("nurbs_interp_surface: need at least ", p_u+1, - " rows for u-degree ", p_u, ", got ", n_rows)) - assert(n_cols >= p_v + 1, - str("nurbs_interp_surface: need at least ", p_v+1, - " columns for v-degree ", p_v, ", got ", n_cols)) - assert(!(has_sud || has_eud || has_sun || has_eun || has_fesu || has_feeu || has_fe1_u || has_fe2_u) || !row_wrap, - "nurbs_interp_surface: u-direction derivative/normal/flat_end/flat_edges params require row_wrap=false") - assert(!(has_svd || has_evd || has_svn || has_evn || has_fesv || has_feev || has_fe1_v || has_fe2_v) || !col_wrap, - "nurbs_interp_surface: v-direction derivative/normal/flat_end/flat_edges params require col_wrap=false") - assert(!has_sud || len(first_row_deriv) == n_cols, - str("nurbs_interp_surface: first_row_deriv must have ", n_cols, - " entries (one per column), got ", is_undef(first_row_deriv) ? 0 : len(first_row_deriv))) - assert(!has_eud || len(last_row_deriv) == n_cols, - str("nurbs_interp_surface: last_row_deriv must have ", n_cols, - " entries (one per column), got ", is_undef(last_row_deriv) ? 0 : len(last_row_deriv))) - assert(!has_svd || len(first_col_deriv) == n_rows, - str("nurbs_interp_surface: first_col_deriv must have ", n_rows, - " entries (one per row), got ", is_undef(first_col_deriv) ? 0 : len(first_col_deriv))) - assert(!has_evd || len(last_col_deriv) == n_rows, - str("nurbs_interp_surface: last_col_deriv must have ", n_rows, - " entries (one per row), got ", is_undef(last_col_deriv) ? 0 : len(last_col_deriv))) - // normal1/normal2 assertions: apex edges only. - assert(!has_sn || (start_u_degen || start_v_degen), - "nurbs_interp_surface: normal1 requires a degenerate start edge (first row or first column must be all the same point)") - assert(!has_en || (end_u_degen || end_v_degen), - "nurbs_interp_surface: normal2 requires a degenerate end edge (last row or last column must be all the same point)") - assert(!has_sn || !(start_u_degen && start_v_degen), - "nurbs_interp_surface: normal1 is ambiguous — both u=0 and v=0 edges are degenerate; use first_row_deriv or first_col_deriv explicitly") - assert(!has_en || !(end_u_degen && end_v_degen), - "nurbs_interp_surface: normal2 is ambiguous — both u=1 and v=1 edges are degenerate; use last_row_deriv or last_col_deriv explicitly") - assert(!(has_sun && has_sud), - "nurbs_interp_surface: normal1 resolves to u-direction but first_row_deriv was also given") - assert(!(has_eun && has_eud), - "nurbs_interp_surface: normal2 resolves to u-direction but last_row_deriv was also given") - assert(!(has_svn && has_svd), - "nurbs_interp_surface: normal1 resolves to v-direction but first_col_deriv was also given") - assert(!(has_evn && has_evd), - "nurbs_interp_surface: normal2 resolves to v-direction but last_col_deriv was also given") - // flat_end1/flat_end2 assertions. - // Direction is determined by the clamped type; surface must be mixed clamped/closed. - assert(!has_fe1 || (row_wrap != col_wrap), - "nurbs_interp_surface: flat_end1 requires the surface to be clamped in one direction and closed in the other") - assert(!has_fe2 || (row_wrap != col_wrap), - "nurbs_interp_surface: flat_end2 requires the surface to be clamped in one direction and closed in the other") - assert(fe1_ok, - has_fe1_u - ? "nurbs_interp_surface: flat_end1 requires the first row (u=0 boundary) to be coplanar and non-collinear" - : "nurbs_interp_surface: flat_end1 requires the first column (v=0 boundary) to be coplanar and non-collinear. If your first row is coplanar, try row_wrap=true, col_wrap=false.") - assert(fe2_ok, - has_fe2_u - ? "nurbs_interp_surface: flat_end2 requires the last row (u=1 boundary) to be coplanar and non-collinear" - : "nurbs_interp_surface: flat_end2 requires the last column (v=1 boundary) to be coplanar and non-collinear. If your last row is coplanar, try row_wrap=true, col_wrap=false.") - assert(!(has_fe1_u && has_sud), - "nurbs_interp_surface: flat_end1 conflicts with first_row_deriv") - assert(!(has_fe2_u && has_eud), - "nurbs_interp_surface: flat_end2 conflicts with last_row_deriv") - assert(!(has_fe1_v && has_svd), - "nurbs_interp_surface: flat_end1 conflicts with first_col_deriv") - assert(!(has_fe2_v && has_evd), - "nurbs_interp_surface: flat_end2 conflicts with last_col_deriv") - assert(!(has_fe1_u && has_fesu), - "nurbs_interp_surface: flat_end1 conflicts with flat_edges[0] on same edge") - assert(!(has_fe2_u && has_feeu), - "nurbs_interp_surface: flat_end2 conflicts with flat_edges[1] on same edge") - assert(!(has_fe1_v && has_fesv), - "nurbs_interp_surface: flat_end1 conflicts with flat_edges[2] on same edge") - assert(!(has_fe2_v && has_feev), - "nurbs_interp_surface: flat_end2 conflicts with flat_edges[3] on same edge") - assert(!has_fe1 || is_num(flat_end1) || len(flat_end1) == (has_fe1_u ? n_cols : n_rows), - str("nurbs_interp_surface: flat_end1 list must have ", has_fe1_u ? n_cols : n_rows, " entries")) - assert(!has_fe2 || is_num(flat_end2) || len(flat_end2) == (has_fe2_u ? n_cols : n_rows), - str("nurbs_interp_surface: flat_end2 list must have ", has_fe2_u ? n_cols : n_rows, " entries")) - // flat_edges assertions. - assert(!has_fe || (is_list(fe_norm) && len(fe_norm) == 4), - "nurbs_interp_surface: flat_edges must be a scalar or 4-element list [first_row, last_row, first_col, last_col]") - assert(!(has_fesu && has_sud), - "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with first_row_deriv") - assert(!(has_feeu && has_eud), - "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with last_row_deriv") - assert(!(has_fesv && has_svd), - "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with first_col_deriv") - assert(!(has_feev && has_evd), - "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with last_col_deriv") - assert(!(has_fesu && has_sun), - "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with normal1 on same edge") - assert(!(has_feeu && has_eun), - "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with normal2 on same edge") - assert(!(has_fesv && has_svn), - "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with normal1 on same edge") - assert(!(has_feev && has_evn), - "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with normal2 on same edge") - assert(!has_fesu || !is_list(fe_su) || len(fe_su) == n_cols, - str("nurbs_interp_surface: flat_edges[0] scale list must have ", n_cols, " entries (one per column)")) - assert(!has_feeu || !is_list(fe_eu) || len(fe_eu) == n_cols, - str("nurbs_interp_surface: flat_edges[1] scale list must have ", n_cols, " entries (one per column)")) - assert(!has_fesv || !is_list(fe_sv) || len(fe_sv) == n_rows, - str("nurbs_interp_surface: flat_edges[2] scale list must have ", n_rows, " entries (one per row)")) - assert(!has_feev || !is_list(fe_ev) || len(fe_ev) == n_rows, - str("nurbs_interp_surface: flat_edges[3] scale list must have ", n_rows, " entries (one per row)")) - // Edge (C0) validation. - assert(!has_ue || !row_wrap, - "nurbs_interp_surface: row_edges requires row_wrap=false") - assert(!has_ve || !col_wrap, - "nurbs_interp_surface: col_edges requires col_wrap=false") - assert(!has_ue || (min(ue_norm) >= 1 && max(ue_norm) <= n_rows-2), - str("nurbs_interp_surface: row_edges indices must be interior (1..", n_rows-2, ")")) - assert(!has_ve || (min(ve_norm) >= 1 && max(ve_norm) <= n_cols-2), - str("nurbs_interp_surface: col_edges indices must be interior (1..", n_cols-2, ")")) - // row_edges / col_edges are compatible with same-direction boundary derivatives, - // normals, and flat_edges: the first/last segment of the edge-aware system - // carries the boundary derivative constraint. - let( - // Boundary plane for flat_edges=: cross product of two perimeter vectors. - // Guarded so degenerate geometry can't produce NaN when flat_edges is unused. - fe_e1 = has_fe ? (points[0][n_cols-1] - points[0][0]) : [1,0,0], - fe_e2 = has_fe ? (points[n_rows-1][0] - points[0][0]) : [0,1,0], - fe_N_raw = has_fe ? cross(fe_e1, fe_e2) : [0,0,1], - fe_N_hat = fe_N_raw / max(norm(fe_N_raw), 1e-15), - // Per-edge flat-outward derivative lists; undef when edge not active. - // Direction at each point: from adjacent interior point toward edge, - // projected into the boundary plane, then normalized and scaled. - flat_su_der = !has_fesu ? undef : - [for (j = [0:1:n_cols-1]) - let( - d = points[1][j] - points[0][j], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_su) ? fe_su[j] : fe_su - ) d_hat * s], - flat_eu_der = !has_feeu ? undef : - [for (j = [0:1:n_cols-1]) - let( - d = points[n_rows-1][j] - points[n_rows-2][j], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_eu) ? fe_eu[j] : fe_eu - ) d_hat * s], - flat_sv_der = !has_fesv ? undef : - [for (k = [0:1:n_rows-1]) - let( - d = points[k][1] - points[k][0], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_sv) ? fe_sv[k] : fe_sv - ) d_hat * s], - flat_ev_der = !has_feev ? undef : - [for (k = [0:1:n_rows-1]) - let( - d = points[k][n_cols-1] - points[k][n_cols-2], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_ev) ? fe_ev[k] : fe_ev - ) d_hat * s] - ) - assert(!has_fesu || min([for (j = [0:1:n_cols-1]) let(d = points[1][j] - points[0][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[0] (first_row) direction is perpendicular to the boundary plane at one or more points") - assert(!has_feeu || min([for (j = [0:1:n_cols-1]) let(d = points[n_rows-1][j] - points[n_rows-2][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[1] (last_row) direction is perpendicular to the boundary plane at one or more points") - assert(!has_fesv || min([for (k = [0:1:n_rows-1]) let(d = points[k][1] - points[k][0], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[2] (first_col) direction is perpendicular to the boundary plane at one or more points") - assert(!has_feev || min([for (k = [0:1:n_rows-1]) let(d = points[k][n_cols-1] - points[k][n_cols-2], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[3] (last_col) direction is perpendicular to the boundary plane at one or more points") - assert(!has_fe || is_coplanar(concat( - points[0], points[n_rows-1], - [for (k = [1:1:n_rows-2]) points[k][0]], - [for (k = [1:1:n_rows-2]) points[k][n_cols-1]]), eps=1e-6), - "nurbs_interp_surface: flat_edges= requires all four boundary edges to be coplanar") - let( - // Compute effective derivative lists. - // Priority: normal1/normal2 (apex) > flat_end1/flat_end2 (coplanar) > flat_edges > explicit *_der=. - // Apex (all boundary points identical): fan outward from apex, user axis vector N. - // End-edge apex tangents are negated because _apex_tangents() returns outward - // (apex→ring) vectors; negating gives inward (ring→apex), making the surface - // converge to the apex tip at the correct parametric direction. - // Coplanar (flat_end): _coplanar_inward_tangents() returns in-plane vectors - // oriented toward the polygon interior using the polygon winding order. - // Positive scale closes inward, negative flares outward. - // flat_end1 result is negated: _coplanar_inward_tangents returns outward - // for the start boundary; negating gives the correct inward direction. - // flat_end2 uses the same function without negation (end boundary sign matches). - // Periodic tangent differences used when the cross-direction is "closed". - first_row_deriv_eff = has_sun - ? _apex_tangents(normal1, points[0][0], points[1]) - : has_fe1_u - ? [for (v = _coplanar_inward_tangents(flat_end1, points[0], points[1], - periodic=col_wrap)) -v] - : has_fesu ? flat_su_der - : first_row_deriv, - last_row_deriv_eff = has_eun - ? [for (v = _apex_tangents(normal2, points[n_rows-1][0], points[n_rows-2])) -v] - : has_fe2_u - ? _coplanar_inward_tangents(flat_end2, points[n_rows-1], points[n_rows-2], - periodic=col_wrap) - : has_feeu ? flat_eu_der - : last_row_deriv, - first_col_deriv_eff = has_svn - ? _apex_tangents(normal1, points[0][0], - [for (k = [0:1:n_rows-1]) points[k][1]]) - : has_fe1_v - ? [for (v = _coplanar_inward_tangents(flat_end1, - [for (k = [0:1:n_rows-1]) points[k][0]], - [for (k = [0:1:n_rows-1]) points[k][1]], - periodic=row_wrap)) -v] - : has_fesv ? flat_sv_der - : first_col_deriv, - last_col_deriv_eff = has_evn - ? [for (v = _apex_tangents(normal2, points[0][n_cols-1], - [for (k = [0:1:n_rows-1]) points[k][n_cols-2]])) -v] - : has_fe2_v - ? _coplanar_inward_tangents(flat_end2, - [for (k = [0:1:n_rows-1]) points[k][n_cols-1]], - [for (k = [0:1:n_rows-1]) points[k][n_cols-2]], - periodic=row_wrap) - : has_feev ? flat_ev_der - : last_col_deriv, - has_sud_eff = has_sud || has_sun || has_fesu || has_fe1_u, - has_eud_eff = has_eud || has_eun || has_feeu || has_fe2_u, - has_svd_eff = has_svd || has_svn || has_fesv || has_fe1_v, - has_evd_eff = has_evd || has_evn || has_feev || has_fe2_v - ) - // row_edges / col_edges boundary-derivative segment-size checks. - // A derivative-carrying edge segment needs at least 3 rows/columns; - // with only 2 the degree-reduced knot vector becomes degenerate. - assert(!(has_ue && has_sud_eff && ue_norm[0] + 1 < 3), - !has_ue ? "" : - str("nurbs_interp_surface: row_edges=", ue_norm, - " creates a ", ue_norm[0]+1, "-row first segment (rows 0-", - ue_norm[0], ") which is too short to carry the start-u derivative constraint. ", - "Move the first row_edges index to at least 2")) - assert(!(has_ue && has_eud_eff && n_rows - last(ue_norm) < 3), - !has_ue ? "" : - str("nurbs_interp_surface: row_edges=", ue_norm, - " creates a ", n_rows - last(ue_norm), "-row last segment (rows ", - last(ue_norm), "-", n_rows-1, ") which is too short to carry the end-u derivative constraint. ", - "Move the last row_edges index to at most ", n_rows - 3)) - assert(!(has_ve && has_svd_eff && ve_norm[0] + 1 < 3), - !has_ve ? "" : - str("nurbs_interp_surface: col_edges=", ve_norm, - " creates a ", ve_norm[0]+1, "-column first segment (columns 0-", - ve_norm[0], ") which is too short to carry the start-v derivative constraint. ", - "Move the first col_edges index to at least 2")) - assert(!(has_ve && has_evd_eff && n_cols - last(ve_norm) < 3), - !has_ve ? "" : - str("nurbs_interp_surface: col_edges=", ve_norm, - " creates a ", n_cols - last(ve_norm), "-column last segment (columns ", - last(ve_norm), "-", n_cols-1, ") which is too short to carry the end-v derivative constraint. ", - "Move the last col_edges index to at most ", n_cols - 3)) - let( - // Averaged parameterization in each direction - u_params = _surface_params_u(points, method, row_wrap), - v_params = _surface_params_v(points, method, col_wrap), - - // Per-row v-direction path lengths for scaling v-boundary tangents. - // Follows the curve convention: user passes normalized vectors; code - // scales by total chord length so a unit vector gives natural speed. - v_path_lens = [for (k = [0:1:n_rows-1]) path_length(points[k])], - - // Per-column u-direction path lengths for scaling u-boundary tangents. - u_path_lens = [for (l = [0:1:n_cols-1]) - path_length([for (k = [0:1:n_rows-1]) points[k][l]])], - - // ----- Build v-direction system ----- - // When col_edges is active, precompute per-segment collocation systems. - // Otherwise use the standard (or derivative-extended) system. - v_edge_sys = has_ve - ? _build_edge_systems(v_params, p_v, ve_norm, - has_sd=has_svd_eff, - has_ed=has_evd_eff, - extra_pts=ep_v, label="v") : undef, - v_sys = has_ve ? undef - : (has_svd_eff || has_evd_eff) - ? _build_clamped_system_with_derivs(v_params, p_v, has_svd_eff, has_evd_eff, ep_v) - : _build_interp_system(v_params, p_v, col_wrap ? "closed" : "clamped", ep_v), - N_v = has_ve ? undef : v_sys[0], - // When underdetermined (extra_pts), build regularization matrix for v. - M_v = has_ve ? undef : len(N_v[0]), - N_rows_v = has_ve ? undef : len(N_v), - ns_v = !has_ve && M_v > N_rows_v, - R_reg_v = !ns_v ? undef - : let(vk = v_sys[1], - vint = !col_wrap - ? [for (i = [1:1:len(vk)-2]) vk[i]] - : undef, - vU = !col_wrap - ? _full_clamped_knots(vint, p_v) - : _full_closed_knots(vk, M_v, p_v)) - smooth_v <= 2 - ? [for (i = [0:1:M_v-1]) _ltl_row(M_v, i, smooth_v, periodic=col_wrap)] - : _bending_energy_matrix(M_v, p_v, vU, periodic=col_wrap), - - // ----- Pass 1: Interpolate rows in v-direction ----- - // With col_edges: solve each row via edge-aware segmented system. - // Without: same A_v matrix for every row; only the RHS changes per row. - R_raw = has_ve - ? [for (k = [0:1:n_rows-1]) - _solve_with_edges(v_edge_sys, points[k], - v_params, ve_norm, p_v, - start_deriv = has_svd_eff - ? _force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k] - : undef, - end_deriv = has_evd_eff - ? _force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k] - : undef, - smooth = smooth_v)] - : undef, - R = has_ve - ? [for (r = R_raw) r[0]] - : [for (k = [0:1:n_rows-1]) - let(rhs = concat( - points[k], - has_svd_eff - ? [_force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k]] - : [], - has_evd_eff - ? [_force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k]] - : [])) - ns_v ? _nullspace_solve(R_reg_v, N_v, rhs) - : linear_solve(N_v, rhs) - ], - - v_knots = has_ve ? R_raw[0][1] : v_sys[1], - n_v_ctrl = len(R[0]), - - // ----- Pass 1.5: Project u-boundary tangents into v-control space ----- - // ∂S/∂u along u=0 or u=1 is given at the n_cols data v-positions. - // To use them as derivative RHS in the u-direction column solves, we - // must express them in the v B-spline control basis — done by solving - // the same v-system. When col_edges is active, project through the - // edge-aware segmented system instead. - zero_v = repeat(0, dim), - _su_der_data = has_sud_eff - ? [for (l = [0:1:n_cols-1]) - _force_deriv_dim(first_row_deriv_eff[l], dim) * u_path_lens[l]] - : undef, - _eu_der_data = has_eud_eff - ? [for (l = [0:1:n_cols-1]) - _force_deriv_dim(last_row_deriv_eff[l], dim) * u_path_lens[l]] - : undef, - T_u_start = has_sud_eff - ? has_ve - ? _solve_with_edges(v_edge_sys, _su_der_data, - v_params, ve_norm, p_v, - start_deriv = has_svd_eff ? zero_v : undef, - end_deriv = has_evd_eff ? zero_v : undef, - smooth = smooth_v)[0] - : let(_rhs = concat(_su_der_data, - has_svd_eff ? [zero_v] : [], - has_evd_eff ? [zero_v] : [])) - ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) - : linear_solve(N_v, _rhs) - : undef, - T_u_end = has_eud_eff - ? has_ve - ? _solve_with_edges(v_edge_sys, _eu_der_data, - v_params, ve_norm, p_v, - start_deriv = has_svd_eff ? zero_v : undef, - end_deriv = has_evd_eff ? zero_v : undef, - smooth = smooth_v)[0] - : let(_rhs = concat(_eu_der_data, - has_svd_eff ? [zero_v] : [], - has_evd_eff ? [zero_v] : [])) - ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) - : linear_solve(N_v, _rhs) - : undef, - - // ----- Build u-direction system ----- - // When row_edges is active, precompute per-segment systems. - u_edge_sys = has_ue - ? _build_edge_systems(u_params, p_u, ue_norm, - has_sd=has_sud_eff, - has_ed=has_eud_eff, - extra_pts=ep_u, label="u") : undef, - u_sys = has_ue ? undef - : (has_sud_eff || has_eud_eff) - ? _build_clamped_system_with_derivs(u_params, p_u, has_sud_eff, has_eud_eff, ep_u) - : _build_interp_system(u_params, p_u, row_wrap ? "closed" : "clamped", ep_u), - N_u = has_ue ? undef : u_sys[0], - // When underdetermined (extra_pts), build regularization matrix for u. - M_u = has_ue ? undef : len(N_u[0]), - N_rows_u = has_ue ? undef : len(N_u), - ns_u = !has_ue && M_u > N_rows_u, - R_reg_u = !ns_u ? undef - : let(uk = u_sys[1], - uint = !row_wrap - ? [for (i = [1:1:len(uk)-2]) uk[i]] - : undef, - uU = !row_wrap - ? _full_clamped_knots(uint, p_u) - : _full_closed_knots(uk, M_u, p_u)) - smooth_u <= 2 - ? [for (i = [0:1:M_u-1]) _ltl_row(M_u, i, smooth_u, periodic=row_wrap)] - : _bending_energy_matrix(M_u, p_u, uU, periodic=row_wrap), - - // ----- Pass 2: Interpolate columns in u-direction ----- - // Transpose R so each entry is a column of intermediate points. - R_T = [for (j = [0:1:n_v_ctrl-1]) - [for (k = [0:1:n_rows-1]) R[k][j]]], - - // With row_edges: solve each column via edge-aware segmented system. - // Without: add u-tangent constraint rows to the RHS for each column j. - P_T_raw = has_ue - ? [for (j = [0:1:n_v_ctrl-1]) - _solve_with_edges(u_edge_sys, R_T[j], - u_params, ue_norm, p_u, - start_deriv = has_sud_eff ? T_u_start[j] : undef, - end_deriv = has_eud_eff ? T_u_end[j] : undef, - smooth = smooth_u)] - : undef, - P_T = has_ue - ? [for (r = P_T_raw) r[0]] - : [for (j = [0:1:n_v_ctrl-1]) - let(rhs = concat( - R_T[j], - has_sud_eff ? [T_u_start[j]] : [], - has_eud_eff ? [T_u_end[j]] : [])) - ns_u ? _nullspace_solve(R_reg_u, N_u, rhs) - : linear_solve(N_u, rhs) - ], - - u_knots = has_ue ? P_T_raw[0][1] : u_sys[1], - - // Transpose back to get the final control point grid. - n_u_ctrl = len(P_T[0]), - P = [for (i = [0:1:n_u_ctrl-1]) - [for (j = [0:1:n_v_ctrl-1]) P_T[j][i]]] - ) - [[row_wrap ? "closed" : "clamped", col_wrap ? "closed" : "clamped"], - [p_u, p_v], P, [u_knots, v_knots], undef, undef, - [u_params, v_params]]; - - -// Module: nurbs_interp_surface() -// See Also: nurbs_interp_surface() (function form, above) - -module nurbs_interp_surface(points, degree, splinesteps=16, - method="centripetal", - row_wrap=false, col_wrap=false, - style="default", reverse=false, triangulate=false, - caps=undef, cap1=undef, cap2=undef, - first_row_deriv=undef, last_row_deriv=undef, - first_col_deriv=undef, last_col_deriv=undef, - normal1=undef, normal2=undef, - flat_end1=undef, flat_end2=undef, - flat_edges=undef, - row_edges=undef, col_edges=undef, - extra_pts=0, smooth=3, - data_color="red", data_size=0, - atype="hull", convexity=10, cp="centroid", anchor="origin", spin=0, orient=UP -) - { - result = nurbs_interp_surface(points, degree, - method=method, row_wrap=row_wrap, col_wrap=col_wrap, - first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, - first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, - normal1=normal1, normal2=normal2, - flat_end1=flat_end1, flat_end2=flat_end2, - flat_edges=flat_edges, - row_edges=row_edges, col_edges=col_edges, - extra_pts=extra_pts, smooth=smooth); - nurbs_vnf(result, splinesteps=splinesteps, style=style, - reverse=reverse, triangulate=triangulate, - caps=caps, cap1=cap1, cap2=cap2, convexity=convexity, atype=atype, anchor=anchor, spin=spin, orient=orient) children(); - if (data_size > 0) - color(data_color) - for (row = points) - for (pt = row) - translate(pt) sphere(r=data_size, $fn=16); -} - - -// ===================================================================== -// SECTION: Usage Examples -// ===================================================================== -// -// ---- Example 1: CLAMPED (default) ---- -// -// include -// include -// include -// -// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; -// debug_nurbs_interp(data, 3); -// -// -// ---- Example 2: CLOSED (debug view) ---- -// Do NOT repeat the first point at the end. -// -// include -// include -// include -// -// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; -// debug_nurbs_interp(data, 3, closed=true); -// -// -// ---- Example 3: Closed polygon ---- -// All data points should lie exactly on the boundary of the polygon. -// -// include -// include -// include -// -// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; -// path = nurbs_interp_curve(data, 3, splinesteps=16, closed=true); -// polygon(path); -// color("red") move_copies(data) circle(r=0.25, $fn=16); -// -// -// ---- Example 5: Get just the path ---- -// -// include -// include -// include -// -// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; -// path = nurbs_interp_curve(data, 3, splinesteps=32); -// stroke(path, width=0.5); -// color("red") move_copies(data) circle(r=0.25, $fn=16); -// -// -// ---- Example 6: Low-level access ---- -// nurbs_interp() returns a NURBS parameter list that can be passed -// directly to nurbs_curve(), debug_nurbs(), etc. -// -// include -// include -// include -// -// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; -// result = nurbs_interp(data, 3); -// curve = nurbs_curve(result, splinesteps=24); -// stroke(curve, width=0.5); -// -// -// ---- Example 7: 3D closed curve ---- -// -// include -// include -// include -// -// data3d = [[20,0,0],[0,20,10],[-20,0,20],[0,-20,10]]; -// path = nurbs_interp_curve(data3d, 3, splinesteps=32, closed=true); -// stroke(path, width=1, closed=true); -// color("red") move_copies(data3d) sphere(r=0.25, $fn=16); -// -// -// ---- Example 8: Parameterization methods for sharp turns ---- -// "length" (blue), "centripetal" (red), "dynamic" (orange) compared. -// For data with sudden direction changes or uneven chord spacing, -// "centripetal" and "dynamic" reduce unwanted oscillations. -// -// include -// include -// include -// -// sharp = [[0,0], [5,40],[6,40], [10,0], [50,0], [55,40],[56,42], [60,0]]; -// color("blue") stroke(nurbs_interp_curve(sharp, 3), width=0.1); -// color("red") stroke(nurbs_interp_curve(sharp, 3, method="centripetal"), width=0.1); -// color("orange") stroke(nurbs_interp_curve(sharp, 3, method="centripetal"), width=0.1); -// color("green") move_copies(sharp) circle(r=.1, $fn=16); -// -// -// ---- Example 9: Endpoint tangent control ---- -// Specify start and/or end tangent vectors. Each vector is automatically -// scaled by the total chord length; a unit vector produces natural -// arc-length speed. Magnitude > 1 increases pull, < 1 weakens it. -// BOSL2 direction constants (UP, RIGHT, etc.) work for 2D curves. -// -// include -// include -// include -// -// data = [[0,0], [20,30], [50,25], [80,0]]; -// // No tangent control (natural): -// color("gray") stroke(nurbs_interp_curve(data, 3), width=0.3); -// // Tangent: start going straight up, end going straight down: -// color("blue") stroke( -// nurbs_interp_curve(data, 3, start_deriv=[0,1], end_deriv=[0,-1]), -// width=0.3); -// // Tangent: start going right, end going right: -// color("red") stroke( -// nurbs_interp_curve(data, 3, start_deriv=[1,0], end_deriv=[1,0]), -// width=0.3); -// color("black") move_copies(data) circle(r=0.25, $fn=16); -// -// -// ---- Example 10: Start tangent only ---- -// -// include -// include -// include -// -// data = [[0,0], [20,30], [50,25], [80,0]]; -// color("gray") stroke(nurbs_interp_curve(data, 3), width=0.3); -// color("blue") stroke( -// nurbs_interp_curve(data, 3, start_deriv=[0,1]), -// width=0.3); -// color("black") move_copies(data) circle(r=0.25, $fn=16); -// -// -// ===================================================================== -// SECTION: Surface Interpolation Examples -// ===================================================================== -// -// ---- Example 11: Basic surface interpolation ---- -// A 4x5 grid of 3D data points → smooth interpolating surface. -// -// include -// include -// include -// -// data = [ -// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 10], [50, 50, 0], [80, 50, 5]], -// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 30], [50, 16, 20], [80, 16, 10]], -// [[-50,-16, 20], [-16,-16, 35], [ 16,-16, 40], [50,-16, 15], [80,-16, 25]], -// [[-50,-50, 0], [-16,-50, 10], [ 16,-50, 20], [50,-50, 0], [80,-50, 5]], -// ]; -// nurbs_interp_surface(data, 3, splinesteps=8); -// -// -// ---- Example 12: Different degrees per direction ---- -// Quadratic in u, cubic in v. -// -// include -// include -// include -// -// data = [ -// for (u = [-40:20:40]) -// [for (v = [-40:20:40]) -// [v, u, 15*sin(u*3)*cos(v*3)]] -// ]; -// nurbs_interp_surface(data, [2,3], splinesteps=8); -// -// -// ---- Example 13: Surface closed in one direction (tube) ---- -// Closed around the v-direction (the rings), clamped in u (along the -// axis). Uses 5 rings rather than 4: a cubic closed direction needs -// at least p+2 = 5 data rows/columns to have interior knot freedom. -// With only p+1 = 4, the system is solvable but the closed direction -// has no interior flexibility and produces results nearly identical to -// the clamped case. -// -// include -// include -// include -// -// r = 20; -// data = [for (u = [0:15:60]) // 5 rings: u = 0,15,30,45,60 -// [for (i = [0:1:5]) -// let(a = i * 360/6) -// [r*cos(a), r*sin(a), u]] -// ]; -// nurbs_interp_surface(data, 3, splinesteps=8, -// col_wrap=true); -// -// -// ---- Example 14: Surface closed in both directions (torus) ---- -// For ["closed","closed"] to produce a shape visibly different from -// ["clamped","closed"], two conditions must both be met: -// -// 1. ENOUGH POINTS: each direction needs at least p+2 points so the -// periodic system has at least one interior knot with genuine -// freedom. With exactly p+1 points the system is solvable but -// there is no interior flexibility, and the result looks nearly -// identical to the clamped case. -// -// 2. BALANCED PARAMETERIZATION: the data must form an actual closed -// loop in each direction. For chord-length parameterization the -// "closing" segment (last point back to first) is included in the -// parameter budget. If that segment is much longer than the inter- -// point distances the closed direction folds back on itself rather -// than forming a smooth loop. Use evenly-spaced data, or data -// whose first and last points coincide (so the closing chord is -// zero and parameter spacing is uniform). -// -// The canonical example is a torus: both directions sample a full -// 360° circle with even angular spacing, so the closing segment -// equals the inter-point spacing and parameterization is uniform. -// -// include -// include -// include -// -// R = 30; r = 10; // major / minor torus radii -// N = 6; // 6 samples each way (N > p+1 = 4 for cubic) -// data = [for (i = [0:1:N-1]) -// let(phi = i * 360/N) -// [for (j = [0:1:N-1]) -// let(theta = j * 360/N) -// [(R + r*cos(theta))*cos(phi), -// (R + r*cos(theta))*sin(phi), -// r*sin(theta)]] -// ]; -// nurbs_interp_surface(data, 3, splinesteps=12, -// row_wrap=true, col_wrap=true); -// -// -// ---- Example 15: Low-level surface access ---- -// nurbs_interp_surface() returns a NURBS parameter list that can be -// passed directly to nurbs_vnf(). -// -// include -// include -// include -// -// data = [ -// [[-30,30,0], [0,30,20], [30,30,0]], -// [[-30, 0,10],[0, 0,30], [30, 0,10]], -// [[-30,-30,0],[0,-30,15],[30,-30,0]], -// ]; -// result = nurbs_interp_surface(data, 2); -// vnf = nurbs_vnf(result, splinesteps=12); -// vnf_polyhedron(vnf); -// color("red") -// for (row = data) for (pt = row) -// translate(pt) sphere(r=1, $fn=16); From ab49c4622072a07f3ed20db857915e9ecddd5047 Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Wed, 29 Apr 2026 17:47:58 -0700 Subject: [PATCH 05/16] Refactor nurbs.scad --- nurbs.scad | 3637 +++++++++++++++++++------------------- tmp_cuboid_10.png | Bin 0 -> 21322 bytes tmp_prismoid_2200000.png | Bin 0 -> 22507 bytes tmp_prismoid_2200001.png | Bin 0 -> 26583 bytes tmp_prismoid_2200002.png | Bin 0 -> 27910 bytes tmp_prismoid_2200003.png | Bin 0 -> 29529 bytes tmp_prismoid_2200004.png | Bin 0 -> 29907 bytes tmp_prismoid_2200005.png | Bin 0 -> 29126 bytes tmp_prismoid_2200006.png | Bin 0 -> 26944 bytes tmp_prismoid_2200007.png | Bin 0 -> 27105 bytes tmp_prismoid_2200008.png | Bin 0 -> 25459 bytes tmp_prismoid_2200009.png | Bin 0 -> 17012 bytes tmp_prismoid_2200010.png | Bin 0 -> 26205 bytes tmp_prismoid_2200011.png | Bin 0 -> 28276 bytes tmp_prismoid_2200012.png | Bin 0 -> 28512 bytes tmp_prismoid_2200013.png | Bin 0 -> 28577 bytes tmp_prismoid_2200014.png | Bin 0 -> 28719 bytes tmp_prismoid_2200015.png | Bin 0 -> 29390 bytes tmp_prismoid_2200016.png | Bin 0 -> 27296 bytes tmp_prismoid_2200017.png | Bin 0 -> 25005 bytes 20 files changed, 1827 insertions(+), 1810 deletions(-) create mode 100644 tmp_cuboid_10.png create mode 100644 tmp_prismoid_2200000.png create mode 100644 tmp_prismoid_2200001.png create mode 100644 tmp_prismoid_2200002.png create mode 100644 tmp_prismoid_2200003.png create mode 100644 tmp_prismoid_2200004.png create mode 100644 tmp_prismoid_2200005.png create mode 100644 tmp_prismoid_2200006.png create mode 100644 tmp_prismoid_2200007.png create mode 100644 tmp_prismoid_2200008.png create mode 100644 tmp_prismoid_2200009.png create mode 100644 tmp_prismoid_2200010.png create mode 100644 tmp_prismoid_2200011.png create mode 100644 tmp_prismoid_2200012.png create mode 100644 tmp_prismoid_2200013.png create mode 100644 tmp_prismoid_2200014.png create mode 100644 tmp_prismoid_2200015.png create mode 100644 tmp_prismoid_2200016.png create mode 100644 tmp_prismoid_2200017.png diff --git a/nurbs.scad b/nurbs.scad index 030e5038..80583995 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -622,7 +622,7 @@ function is_nurbs_patch(x) = // Function: nurbs_patch_points() -// Synopsis: Computes specifies point(s) on a NURBS surface patch +// Synopsis: Computes specified point(s) on a NURBS surface patch // Topics: NURBS Patches // See Also: nurbs_vnf(), nurbs_curve() // Usage: @@ -1894,7 +1894,7 @@ function _elevate_once(ctrl, p, U) = // where `u[k]` is the NURBS parameter at which the curve passes through // `points[k]`. // . -// **Smoothness** — The smoothness of B-splines is determined by the +// **Smoothness** — The smoothness of B-splines is determined by the // degree. If you request a degree $p$ spline then it will be $C^{p-1}$ at // knot points and $C^\infty$ everywhere else. If you request corners then // these are points where the curve is not differentiable; corners may @@ -1918,6 +1918,19 @@ function _elevate_once(ctrl, p, U) = // corners = List of interior point indices where corners are permitted. Equivalent to setting entries of `deriv` to NAN. // extra_pts = Number of extra control points to add to provide additional freedom to control undesirable oscillations. Default: 0 // smooth = Smoothness criterion used with extra control points. Set to 1 (minimize control-polygon length), 2 (minimize control-polygon bending) or 3 (minimize curve bending energy). Default: 3 +// +// Example(2D,NoAxes): Clamped curve (default) +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// debug_nurbs_interp(data, 3); +// +// Example(2D,NoAxes): Closed curve (debug view) +// // Do NOT repeat the first point at the end. +// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; +// debug_nurbs_interp(data, 3, closed=true); + + + + function nurbs_interp(points, degree, method="centripetal", closed=false, deriv=undef, start_deriv=undef, end_deriv=undef, @@ -1986,1914 +1999,1918 @@ function nurbs_interp(points, degree, method="centripetal", closed=false, [eff_type, degree, raw[0], raw[1], undef, undef, u]; -// ---------- CLAMPED interpolation ---------- -// -// start_deriv=/end_deriv= and start_curvature=/end_curvature= are convenience shorthands. -// They are merged into eff_der / eff_curv lists here so that all -// constrained cases flow through a single solver -// (_nurbs_interp_clamped_constrained). -function _nurbs_interp_clamped(points, degree, method, - deriv, start_deriv, end_deriv, - curvature, start_curvature, end_curvature, - corners, extra_pts=0, smooth=3) = - let(n = len(points) - 1, p = degree, dim = len(points[0])) - assert(n >= p, - str("nurbs_interp (clamped): need at least ", p+1, - " points for degree ", p, ", got ", n+1)) - let( - eff_der = _merge_deriv_list(n, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv), - eff_curv = _merge_curv_list(n, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature), +// Section: Debug / Visualization - // C0 corner joints from NaN entries in eff_der and/or corners= list. - // Must be interior points; cannot coincide with curvature constraints. - nan_corners = is_undef(eff_der) ? [] - : [for (k = [0:1:n]) if (is_nan(eff_der[k])) k], - explicit_corners = default(corners, []), - corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), - has_corners = len(corner_idxs) > 0, - bad_corner_end = [for (k = corner_idxs) if (k == 0 || k == n) k], - bad_corner_curv = is_undef(eff_curv) ? [] - : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], - // Explicit corners= entries must not also carry a derivative constraint. - // (NaN-in-deriv corners are fine — they ARE the corner syntax.) - bad_corner_der = is_undef(eff_der) ? [] - : [for (k = explicit_corners) - if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k], +// Module: debug_nurbs_interp() +// Synopsis: Interpolates a NURBS using {{nurbs_interp()}} and displays the curve with informative overlays. +// Topics: NURBS Curves, Interpolation, Debugging +// See Also: nurbs_interp(), debug_nurbs() +// +// Usage: +// debug_nurbs_interp(points, degree, [splinesteps=], [method=], [closed=], [deriv=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [extra_pts=], [smooth=], [width=], [size=], [data_size=], [data_index=], [show_control=], [control_index=], [show_knots=], [show_deriv=], [show_curvature=]); +// +// Description: +// Calls {{nurbs_interp()}} with the supplied arguments and displays the +// resulting curve together with a informative overlays. All interpolation +// arguments are passed through unchanged; see {{nurbs_interp()}} for their +// descriptions. The overlays are: +// . +// - **Data points** — red circles (2D) or spheres (3D) at each input point. +// When `data_index=true` (the default), the point index is printed in red next +// to its marker. Set `data_size=0` to suppress display of the data point dots. +// - **Derivative constraints** — a black arrow at each derivative constrained data point. +// Arrow direction and length reflect the constraint vector, scaled to the average +// point spacing. When the derivative is NAN or a point has a corner, this is shown +// using a black diamond. Shown by default: set `show_deriv=false` to hide. +// - **Curvature constraints** — a transparent green overlay at each curvature-constrained point. +// In 2D the overlay is the osculating circle. In 3D the overlay is a cylinder created +// from the 3D osculating circle. Zero curvature appears as a short green bar. +// Shown by default: Set `show_curvature=false` to hide. +// - **Knots** — Green crosses mark each knot position. Not shown by default. +// Enable with `show_knots=true`. +// - **Control points and polygon** — If you set `show_control=true` then a gray control polygon +// Is displayed. If you additionally set `control_index=true` then blue control-point +// index labels appear. +// +// Arguments: +// points = List of 2-D or 3-D data points to interpolate through. +// degree = NURBS degree. +// splinesteps = Steps per knot span for curve rendering. Default: `16` +// --- +// method = Parameterization method; see {{nurbs_interp()}}. Default: `"centripetal"` +// closed = If true, interpolate as a closed loop; if false, interpolate as clamped. Default: `false` +// deriv = Per-point derivative constraints; see {{nurbs_interp()}}. Default: `undef` +// start_deriv = Derivative at first point. Default: `undef` +// end_deriv = Derivative at last point. Default: `undef` +// curvature = Per-point curvature constraints; see {{nurbs_interp()}}. Default: `undef` +// start_curvature = Curvature at first point. Default: `undef` +// end_curvature = Curvature at last point. Default: `undef` +// corners = Corner indices; see {{nurbs_interp()}}. Default: `undef` +// extra_pts = Extra control points; see {{nurbs_interp()}}. Default: `0` +// smooth = Smoothness criterion for `extra_pts`; see {{nurbs_interp()}}. Default: `3` +// width = Stroke width for the curve. Arrows and other overlays scale with this. Default: `1` +// size = Text size for labels on control points and data points. Default: `3*width` +// data_size = Radius of the red data-point markers. Set to `0` to hide data points and their labels. Default: equal to `width` +// data_index = Show index labels next to each data point. Only shown when `data_size > 0`. Default: `true` +// show_control = Show the control polygon. Default: `false` +// control_index = Show control-point index labels if `show_control=true`. Default: `false` +// show_knots = Show knot position markers on the curve. Default: `false` +// show_deriv = Show derivative-constraint arrows. Default: `true` +// show_curvature = Show curvature-constraint circles / disks. Default: `true` - // Exclude NaN corner markers from the derivative-constraint count. - has_any_der = !is_undef(eff_der) && - len([for (k = [0:1:n]) - if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, - has_any_curv = !is_undef(eff_curv) && - len([for (k = [0:1:n]) if (!is_undef(eff_curv[k])) k]) > 0, +module debug_nurbs_interp(points, degree, splinesteps=16, method="centripetal", + closed=false, deriv=undef, + start_deriv=undef, end_deriv=undef, + curvature=undef, start_curvature=undef, end_curvature=undef, + corners=undef, extra_pts=0, smooth=3, + width=1, size=undef, data_size=undef, + show_control=false, show_knots=false, + show_deriv=true, show_curvature=true, + control_index=false, data_index=true) { + result = nurbs_interp(points, degree, method=method, + closed=closed, deriv=deriv, + start_deriv=start_deriv, end_deriv=end_deriv, + curvature=curvature, start_curvature=start_curvature, + end_curvature=end_curvature, corners=corners, + extra_pts=extra_pts, smooth=smooth); - // Every curvature-constrained point must also have a derivative - // constraint; the derivative direction defines the curve's tangent - // and is required to orient the curvature normal. - bad_curv_pts = is_undef(eff_curv) ? [] : - [for (k = [0:1:n]) - if (!is_undef(eff_curv[k]) && - (is_undef(eff_der) || is_undef(eff_der[k]))) - k] - ) - assert(bad_corner_end == [], - str("nurbs_interp: corner cannot be at the first or last point: ", bad_corner_end)) - assert(bad_corner_curv == [], - str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", bad_corner_curv)) - assert(bad_corner_der == [], - str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", bad_corner_der)) - assert(bad_curv_pts == [], - str("nurbs_interp: curvature constraint requires a derivative constraint ", - "at the same point(s): ", bad_curv_pts)) - has_corners - ? _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, - extra_pts=extra_pts, smooth=smooth) - : (has_any_der || has_any_curv || extra_pts > 0) - ? _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, extra_pts, smooth) - : _nurbs_interp_clamped_basic(points, p, method, smooth); + np = len(points); + dim = len(points[0]); + is2d = (dim == 2); + ds = default(data_size, width); + sz = default(size, 3 * width); + ctrl = result[2]; + arrow_scale = path_length(points) / np; + // Helpers project BOSL2 direction constants and pad dimensions automatically. + eff_der = _merge_deriv_list(np-1, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv); + eff_curv = _merge_curv_list(np-1, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature); -// Basic clamped interpolation (no derivatives). -// n+1 points -> n+1 control points. + // --- Curve, control polygon, knot markers (delegated to debug_nurbs) --- + debug_nurbs(result, splinesteps=splinesteps, width=width, size=sz, + show_knots=show_knots, show_control=show_control, + show_index=control_index); -function _nurbs_interp_clamped_basic(points, p, method, smooth=3) = - let( - n = len(points) - 1, - M = n + 1, - dim = len(points[0]), - params = _interp_params(points, method), - int_kn = _avg_knots_interior(params, p), - U_full = _full_clamped_knots(int_kn, p), - N_mat = _collocation_matrix(params, n, p, U_full), - control = linear_solve(N_mat, points), - knots = [0, each int_kn, 1] - ) - assert(control != [], - "nurbs_interp (clamped): singular collocation matrix") - [control, knots, 0]; + // --- Corner marks (NaN-deriv corners + explicit corners= indices) --- + // 2D: rotated square stroke. 3D: octahedron wireframe. + nan_corner_idxs = is_undef(eff_der) ? [] + : [for (i = [0:1:np-1]) if (!is_undef(eff_der[i]) && is_nan(eff_der[i])) i]; + explicit_corner_idxs = default(corners, []); + all_corner_idxs = deduplicate(sort(concat(nan_corner_idxs, explicit_corner_idxs))); + for (i = all_corner_idxs) + color("black") + translate(points[i]) + if (is2d) + zrot(45) stroke(rect(3.5*width*ds), width=width/2, closed=true); + else + vnf_wireframe(octahedron(size=5*width), width=width/4); + // --- Derivative arrows (black, half width, arrow2 endcap) --- + // Length = norm(eff_der[i]) * arrow_scale: preserves relative magnitudes; + // arrow_scale = path_length(points)/np gives a geometry-relative baseline. + if (show_deriv && !is_undef(eff_der)) + for (i = [0:1:np-1]) + if (!is_undef(eff_der[i]) && !is_nan(eff_der[i]) && norm(eff_der[i]) > 1e-12) + color("black") + stroke([points[i], points[i] + eff_der[i] * arrow_scale], + width=width/2, + endcap1="butt", endcap2="arrow2"); -// Assemble independently-solved clamped corner segments into one B-spline. -// -// All segments must be degree p. Returns [ctrl, xknots, 0] — the standard -// non-segmented result format that callers can pass directly to nurbs_curve / -// debug_nurbs with type="clamped". -// -// BOSL2 clamped knot convention: nurbs_curve() takes xknots of length -// len(control) - degree + 1 -// and internally prepends (degree) zeros and appends (degree) ones to form -// the full clamped knot vector. For a C0 corner at global parameter s_c, -// s_c must appear exactly p times in xknots (giving multiplicity p in the -// full vector = C^0 continuity for degree p). -// -// Segment local knots seg[1] = [0, int_kn..., 1] are remapped to the -// segment's global parameter interval [s_a, s_b] using -// k_global = s_a + (s_b - s_a) * k_local -// which is consistent with any chord-proportional parameterization. - -function _combine_corner_segs(segments, params, corner_idxs, p) = - let( - n_segs = len(segments), - // Global parameter at each corner junction. - cpar = [for (c = corner_idxs) params[c]], - // Global interval [s_a, s_b] for each segment. - seg_sa = [for (s = [0:1:n_segs-1]) s == 0 ? 0 : cpar[s-1]], - seg_sb = [for (s = [0:1:n_segs-1]) s == n_segs-1 ? 1 : cpar[s] ], - // Per-segment interior knots (exclude leading 0 and trailing 1), - // remapped from local [0,1] to the segment's global interval. - seg_gi = [for (s = [0:1:n_segs-1]) - let( - loc = [for (i = [1:1:len(segments[s][1])-2]) segments[s][1][i]], - sa = seg_sa[s], - sb = seg_sb[s] - ) - [for (k = loc) sa + (sb - sa) * k] - ], - // Build combined xknots: - // [0, seg0_int, corner0^p, seg1_int, corner1^p, ..., segN_int, 1] - interior = [for (s = [0:1:n_segs-1]) - each concat( - seg_gi[s], - s < n_segs-1 ? repeat(cpar[s], p) : [] - ) - ], - xknots = [0, each interior, 1], - // Combined control points: all of seg0, then seg[1:1:] for each later seg. - // The first control point of seg s (s >= 1) equals the last of seg s-1 - // because both are the clamped-endpoint interpolant of the shared corner - // data point — so we drop the duplicate. - ctrl = [ - each segments[0][0], - for (s = [1:1:n_segs-1]) - for (j = [1:1:len(segments[s][0])-1]) - segments[s][0][j] - ] - ) - [ctrl, xknots, 0]; + // --- Data points and index labels --- + if (ds > 0) + color("red") + move_copies(points) { + if (is2d) circle(r=ds, $fn=16); + else sphere(r=ds, $fn=16); + if (data_index) + if (is2d) + fwd(2*ds) text(text=str($idx), size=sz, anchor=BACK); + else + rot($vpr) back(ds + sz/3) text3d(text=str($idx), size=sz, anchor=CENTER); + } + // --- Curvature overlays (rendered last so transparent objects don't occlude dots) --- + // Validator already asserted every curvature-constrained point has a derivative, + // so eff_der[i] is always defined and non-NaN here. + if (show_curvature && !is_undef(eff_curv)) + color([0,1,0,0.1]) + for (i = [0:1:np-1]) + if (!is_undef(eff_curv[i])) { + // cv is either a signed scalar (2D) or a dim-projected vector. + cv = eff_curv[i]; + kn = is_num(cv) ? abs(cv) : norm(cv); + T_hat = unit(eff_der[i]); + if (kn < 1e-12) { + // Zero curvature: fixed-length segment (0.6*arrow_scale) along + // the exact derivative direction. + half = 0.3 * arrow_scale; + stroke([points[i] - T_hat * half, + points[i] + T_hat * half], + width=2*width, endcaps="butt"); + } else { + // Non-zero curvature: osculating circle (2D) or cylinder (3D). + // N_hat: unit principal normal — component of cv perpendicular to T_hat. + N_hat = is_num(cv) + ? // Signed scalar (2D): rotate T_hat 90° left or right by sign(cv). + sign(cv) * [-T_hat[1], T_hat[0]] + : // Vector: strip tangential component via vector_perp, then unit. + unit(vector_perp(T_hat, cv)); + r = 1 / kn; + ctr = points[i] + N_hat * r; + // move(ctr) applies to both 2D and 3D branches. + move(ctr) + if (is2d) { + circle(r=r); + } else { + // Cylinder in the osculating plane: axis along binormal B̂ = T̂ × N̂. + // cyl(orient=binom) aligns the cylinder axis to B̂ without rot(). + binom = cross(T_hat, N_hat); + cyl(h=width, r=r, orient=binom); + } + } + } +} -// Clamped interpolation with C0 corner joints. -// -// NaN entries in eff_der mark corners: the curve is split into independent -// clamped segments at each corner index. Each segment is solved at the -// highest degree possible: min(p, m-1) where m is the segment point count. -// Degree reduction silently handles short segments (e.g. only 2 or 3 data -// points between adjacent corners). -// -// Segments that needed degree reduction are degree-elevated back to p -// via nurbs_elevate_degree() so that all segments can be assembled into -// a single clamped B-spline. Elevated segments preserve their original -// lower-degree shape but have higher knot multiplicity, so they are -// less smooth at interior knots than natively degree-p segments. -function _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, - extra_pts=0, smooth=3) = - let( - n = len(points) - 1, - params = _interp_params(points, method), - seg_bounds = [0, each corner_idxs, n], - n_segs = len(seg_bounds) - 1, - // Distribute extra_pts across eligible segments proportionally to - // their control-point count (= data-point count = seg_sizes[s]+1). - // Eligible = segments with seg_p >= 3, or seg_p == 2 when smooth == 1. - // Linear (seg_p==1) and quadratic with smooth!=1 get 0 extra_pts. - seg_sizes = [for (s = [0:1:n_segs-1]) - seg_bounds[s+1] - seg_bounds[s]], - seg_degrees = [for (sz = seg_sizes) min(p, sz)], - // Weight = control-point count for eligible segments, 0 for ineligible. - seg_weights = [for (s = [0:1:n_segs-1]) - let(sp = seg_degrees[s]) - (sp >= 3 || (sp == 2 && smooth == 1)) - ? seg_sizes[s] + 1 : 0], - total_weight = max(1, sum(seg_weights)), - // Round up per-segment allocation so total >= extra_pts. - seg_extra = extra_pts == 0 ? repeat(0, n_segs) - : [for (s = [0:1:n_segs-1]) - seg_weights[s] == 0 ? 0 - : ceil(extra_pts * seg_weights[s] / total_weight)], - raw_segments = [for (s = [0:1:n_segs-1]) - let( - i0 = seg_bounds[s], - i1 = seg_bounds[s+1], - seg_pts = [for (k = [i0:1:i1]) points[k]], - // Reduce degree if the segment has fewer than p+1 points. - seg_p = seg_degrees[s], - // Replace NaN corner markers with undef at shared endpoints. - seg_der = is_undef(eff_der) ? undef - : [for (k = [i0:1:i1]) - is_nan(eff_der[k]) ? undef : eff_der[k]], - seg_curv = is_undef(eff_curv) ? undef - : [for (k = [i0:1:i1]) eff_curv[k]], - r = _nurbs_interp_clamped(seg_pts, seg_p, method, - seg_der, undef, undef, - seg_curv, undef, undef, - extra_pts=seg_extra[s], - smooth=smooth) - ) - [r[0], r[1], seg_p] // [control, knots, degree] - ], - // Degree-elevate short segments to the full degree p. - segments = [for (seg = raw_segments) - seg[2] == p ? seg - : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], - type="clamped", times=p - seg[2])) - [elev[2], elev[3], p] - ] - ) - _combine_corner_segs(segments, params, corner_idxs, p); +// Section: NURBS Surface Interpolation -// General clamped interpolation with per-point derivative and/or curvature -// constraints. -// -// eff_der: list of n+1 first-derivative specs (undef = unconstrained). -// eff_curv: list of n+1 curvature specs (undef = unconstrained). -// dim=2: signed scalar κ. dim≥3: curvature vector. +// Function&Module: nurbs_interp_surface() +// Synopsis: Returns a NURBS surface that passes through a grid of 3D data points. +// SynTags: Geom +// Topics: NURBS Surfaces, Interpolation +// See Also: nurbs_vnf(), nurbs_interp() // -// Uses Method A (expanded-parameter knot averaging, P&T §9.2.2): for each -// constraint at index k, duplicate params[k] in an expanded sequence ũ — -// once per constraint type (deriv and curvature each add one duplication per -// constrained point). This provides one extra DOF per extra constraint. - -function _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, - extra_pts=0, smooth=3) = - let( - n = len(points) - 1, - dim = len(points[0]), - path_len = path_length(points), - path_len2 = path_len * path_len, - params = _interp_params(points, method), - - // First-derivative specs: [index, C'(t) vector]. - // eff_der entries are already dim-projected by _nurbs_interp_clamped. - der_specs = is_undef(eff_der) ? [] - : [for (k = [0:1:n]) if (!is_undef(eff_der[k])) - [k, eff_der[k] * path_len]], - - // Curvature specs: [index, C''(t) vector]. - // eff_der and eff_curv are already dim-projected. - // Tangent from eff_der[k] when available; otherwise estimated from chord. - // Speed² from |eff_der[k]|² × path_len² when derivative given. - curv_specs = is_undef(eff_curv) ? [] - : [for (k = [0:1:n]) if (!is_undef(eff_curv[k])) - let( - t_from_der = is_undef(eff_der) ? undef : eff_der[k], - tang_dir = !is_undef(t_from_der) ? t_from_der - : k == 0 ? points[1] - points[0] - : k == n ? points[n] - points[n-1] - : points[k+1] - points[k-1], - v2 = !is_undef(t_from_der) - ? path_len2 * (t_from_der * t_from_der) - : path_len2 - ) - [k, _curv_to_d2(eff_curv[k], tang_dir, dim, v2)] - ], - - n_extra_der = len(der_specs), - n_extra_curv = len(curv_specs), - _chk_curv_deg = assert(n_extra_curv == 0 || p >= 2, - "nurbs_interp: curvature constraints require degree >= 2"), - n_constraint = n_extra_der + n_extra_curv, - - // Build knots: average data params, insert at constraint spans, - // then insert extra_pts more at widest spans. - base_int = _avg_knots_interior(params, p), - base_bar = [0, each base_int, 1], - constraint_ts = [for (spec = der_specs) params[spec[0]], - for (spec = curv_specs) params[spec[0]]], - after_constr = _insert_constraint_knots(base_bar, constraint_ts), - // For extra_pts, insert knots at midpoints of the widest spans. - // _widest_span_params silently caps the request at the available span count. - extra_ts = extra_pts == 0 ? [] - : _widest_span_params(after_constr, extra_pts), - aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), - n_spans_pre = len(aug_bar_raw) - 1, - aug_bar_pre = _fix_tiny_spans(aug_bar_raw, n_spans_pre), - - // Split any knot span that contains multiple data parameters. - // Without this, two data points in the same span produce a - // rank-deficient collocation matrix (Schoenberg-Whitney condition). - occ_splits = _span_split_params(aug_bar_pre, params), - n_occ = len(occ_splits), - M = n + 1 + n_constraint + len(extra_ts) + n_occ, - aug_bar = n_occ == 0 ? aug_bar_pre - : _fix_tiny_spans( - sort([each aug_bar_pre, each occ_splits]), - n_spans_pre + n_occ), - int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], - U_full = _full_clamped_knots(int_kn, p), - - // Constraint matrix A: interpolation + derivative + curvature rows. - // Dimensions: N_rows × M where N_rows = (n+1) + n_constraint. - N_rows = n + 1 + n_constraint, - - // Interpolation rows: N_{j,p}(t_k) - interp_rows = [for (k = [0:1:n]) - [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] - ], - - // First-derivative rows: N'_{j,p}(t_k) - deriv_rows = [for (spec = der_specs) - let(k = spec[0]) - [for (j = [0:1:M-1]) _dnip(j, p, params[k], U_full)] - ], - - // Second-derivative rows: N''_{j,p}(t_k) - curv_rows = [for (spec = curv_specs) - let(k = spec[0]) - [for (j = [0:1:M-1]) _d2nip(j, p, params[k], U_full)] - ], - - A_constr = [each interp_rows, each deriv_rows, each curv_rows], - rhs_constr = [each points, - for (spec = der_specs) spec[1], - for (spec = curv_specs) spec[1]], - - knots = [0, each int_kn, 1] - ) - // When M == N_rows (square), try direct solve first. - // When M > N_rows (underdetermined from extra_pts or span splits), - // use null-space method: exact constraints + minimum-energy smoothing. - let( - direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] - ) - direct != [] - ? [direct, knots, 0] - : let( - R = _regularization_matrix(M, smooth, p, U_full), - control = _nullspace_solve(R, A_constr, rhs_constr) - ) - assert(!is_undef(control), - "nurbs_interp (clamped+constrained): rank-deficient constraint matrix") - [control, knots, 0]; - - -// ---------- CLOSED interpolation ---------- - -function _nurbs_interp_closed(points, degree, method, deriv, curvature, - corners, extra_pts=0, smooth=3) = - let(n = len(points), p = degree, dim = len(points[0])) - assert(n >= p + 1, - str("nurbs_interp (closed): need at least ", p+1, - " points for degree ", p, ", got ", n)) - let( - // Detect C0 corners from NaN entries in the RAW deriv list before projection, - // since _merge_deriv_list would leave NaN entries intact but we detect them here. - nan_corners = is_undef(deriv) ? [] - : [for (k = [0:1:n-1]) if (is_nan(deriv[k])) k], - explicit_corners = default(corners, []), - corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), - has_corners = len(corner_idxs) > 0, - - // Project derivative and curvature lists (handles BOSL2 direction constants, etc.) - eff_der = _merge_deriv_list(n-1, deriv, dim=dim), - eff_curv = _merge_curv_list(n-1, curvature, dim=dim), - - has_dl = !is_undef(eff_der) && - len([for (k = [0:1:n-1]) - if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, - has_cl = !is_undef(eff_curv) && - len([for (k = [0:1:n-1]) if (!is_undef(eff_curv[k])) k]) > 0, - - // Every curvature-constrained point must also have a derivative constraint. - bad_curv_pts = is_undef(eff_curv) ? [] : - [for (k = [0:1:n-1]) - if (!is_undef(eff_curv[k]) && - (is_undef(eff_der) || is_undef(eff_der[k]))) - k], - // Curvature at a corner is not allowed. - bad_corner_curv = is_undef(eff_curv) ? [] - : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], - // Derivative at an explicit corner is not allowed. - bad_corner_der = is_undef(eff_der) ? [] - : [for (k = explicit_corners) - if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k] - ) - assert(bad_curv_pts == [], - str("nurbs_interp: curvature constraint requires a derivative constraint ", - "at the same point(s): ", bad_curv_pts)) - assert(bad_corner_curv == [], - str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", - bad_corner_curv)) - assert(bad_corner_der == [], - str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", - bad_corner_der)) - // Basic and constrained solvers handle rotation search internally. - // Corner case uses its own rotation (to the first corner). - has_corners - ? _nurbs_interp_closed_corners(points, p, method, eff_der, eff_curv, corner_idxs, - extra_pts=extra_pts, smooth=smooth) - : (has_dl || has_cl || extra_pts > 0) - ? let( - _raw_c = _closed_constrained_solve(points, p, method, eff_der, eff_curv, - 0, extra_pts, smooth), - _chk = assert(!is_undef(_raw_c), - "nurbs_interp (closed+constrained): rank-deficient constraint matrix") - ) _raw_c - : _nurbs_interp_closed_basic(points, p, method, smooth); - - -// Closed interpolation with C0 corner joints. -// -// Converts the closed-with-corners problem into a clamped-with-corners -// problem: rotate data so the first corner is at the start, duplicate -// that point at the end to close the loop, remap remaining corners to -// the rotated frame, and delegate to _nurbs_interp_clamped_corners. -// -// The result is a clamped B-spline whose first and last control points -// coincide at the corner point. r[3] = "clamped" tells convenience -// functions to render with type="clamped" instead of "closed". - -function _nurbs_interp_closed_corners(points, p, method, deriv, curvature, - corner_idxs, extra_pts=0, smooth=3) = - let( - n = len(points), // n points (0..n-1), no repeat - rot = corner_idxs[0], - - // Augmented point list: rotated + closing duplicate of first corner. - aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], - points[rot]], - - // Remap remaining corners to rotated frame. - rot_corners = sort([for (i = [1:1:len(corner_idxs)-1]) - (corner_idxs[i] - rot + n) % n]), - - // Rotate and augment deriv list. - // NaN at the rotation point (now start/end) is cleaned to undef - // since the corner is handled structurally by the clamped endpoints. - aug_der = is_undef(deriv) ? undef : - let(rd = [for (k = [0:1:n-1]) deriv[(k + rot) % n]], - d0 = is_nan(rd[0]) ? undef : rd[0]) - [d0, for (k = [1:1:n-1]) rd[k], d0], - - // Rotate and augment curvature list. - aug_curv = is_undef(curvature) ? undef : - let(rc = [for (k = [0:1:n-1]) curvature[(k + rot) % n]]) - [rc[0], for (k = [1:1:n-1]) rc[k], rc[0]], - - // Solve as clamped with corners. - result = _nurbs_interp_clamped_corners(aug_pts, p, method, - aug_der, aug_curv, - rot_corners, - extra_pts=extra_pts, - smooth=smooth) - ) - // Return with the original rotation index and type override. - [result[0], result[1], rot, "clamped"]; - - -// Returns the maximum number of parameters that fall in any single active -// knot span for cyclic rotation r. A value of 1 is ideal (one parameter -// per span); values > 1 indicate span collisions that may (but do not -// always) cause a singular collocation matrix. +// Usage: As a function, returns a NURBS parameter list: +// nurbs_param = nurbs_interp_surface(points, degree, [method=], [row_wrap=], [col_wrap=], [normal1=], [normal2=], [flat_edges=], [flat_end1=], [flat_end2=], [row_edges=], [col_edges=], [extra_pts=], [smooth=], [first_row_deriv=], [last_row_deriv=], [first_col_deriv=], [last_col_deriv=]); +// Usage: As a module, renders the surface directly: +// nurbs_interp_surface(points, degree, [splinesteps=], [row_wrap=], [col_wrap=], [method=], [extra_pts=], [smooth=], ...) CHILDREN; +// Description: +// Finds the control points and knot vectors for a NURBS surface of the specified degree that passes +// exactly through every data point in a grid of 3D points. The result has +// uniform weights but non-uniform knots so it is actually a non-uniform B-spline. +// When called as a function, the return value is a NURBS parameter list +// `[type, degree, ctrl_grid, knots, undef, undef, uv]` that can be passed +// directly to `{{nurbs_vnf()}}`. The extra return value `uv`, +// described in detail below, enables you to locate your input points in the computed spline +// When called as a module, renders the NURBS surface as geometry. +// . +// Several of the parameters that correspond to parameters for {{nurbs_interp()}} +// can be given as either a scalar or 2-vector. When you give a 2-vector the +// first value applies along the first index of your point data, i.e. from row +// to row, or along columns. The second value applies along the second index, +// i.e. within rows. +// . +// Setting `row_wrap=true` smoothly connects the first and last rows in a loop, +// and `col_wrap=true` smoothly joins the first and last columns. Both false (the default) gives a +// surface with four edges. One true gives a tube; both true gives a torus. +// A tube by itself is not a valid closed manifold in OpenSCAD; you can make it valid by adding caps or +// you can close it into a ball by specifying degenerate edges where the entire edge collapses to +// one identical point. +// . +// **Boundary constraints** +// . +// Flat boundary (`row_wrap=false, col_wrap=false`) — `flat_edges=`. Applies when +// all four surface edges are coplanar. Set `flat_edges` to a 4-element list +// `[first_row, last_row, first_col, last_col]`; each entry is a scalar or per-point list +// giving the derivative scale for that edge (`undef` leaves the edge unconstrained). +// `flat_edges=s` expands to `[s,s,s,s]`. A positive value flares the surface +// outward from the edge; negative turns it inward. +// . +// End normals (one of `row_wrap`/`col_wrap` true, the other false) — `normal1=` and +// `normal2=`. Apply when the specified boundary edge is degenerate (all points +// identical, e.g. a cone tip). The surface is constrained to be normal to the given +// vector at that edge. The vector magnitude controls how broadly the surface spreads. +// . +// Flat ends (one of `row_wrap`/`col_wrap` true, the other false) — `flat_end1=` and +// `flat_end2=`. Apply when the specified boundary edge is coplanar and non-degenerate. +// Constrains the derivative to lie in the plane of the edge. Positive points inward +// (smooth cap attachment); negative flares outward. Scalar or per-point list. +// . +// **Advanced boundary derivatives** — `first_row_deriv=`, `last_row_deriv=`, +// `first_col_deriv=`, and `last_col_deriv=` enforce specific first partial derivatives +// along the four boundary edges. Each accepts a single vector (applied to every +// point on the edge) or a list of vectors (one per point). Vectors are scaled by +// total chord length, so a unit vector matches the parameterization speed. These +// require `row_wrap=false` (for row derivs) or `col_wrap=false` (for col derivs). +// . +// Use with care: the solver enforces derivatives exactly at data points but the +// surface may wander between them. When both u- and v-boundary derivatives are +// active, the cross-derivative is assumed zero at corners. +// . +// **Edges** — `row_edges=` and `col_edges=` insert edges or creases across the surface. +// Use `row_edges=` to specify the indices of rows that will be edges or creases, +// and `col_edges=` to specify the indices of columns that will be edges or creases. +// For a non-wrapped direction, indices must be interior (not first or last). +// If you place edges close together, the effective degree of a narrow patch between +// edges may be reduced. These patches are assembled into a single NURBS so this +// process is transparent to the user. +// . +// **Extra control points** (`extra_pts=`, `smooth=`) — By default the solver uses +// exactly the number of control points needed to satisfy the constraints, which +// gives a unique solution that may be badly behaved. Specifying `extra points=` +// and optionally `smooth=`, works the same way as in +// for {{nurbs_interp()}}. Both parameters can be scalars or 2-vectors to +// provide different values along the two directions. +// . +// **Locating points in the spline** — In order to locate your original data +// points in the spline you need the `u` and `v` nurbs parameter values that you +// can pass to {{nurbs_patch_points()}}. The last return value `uv` gives these: +// `uv[0][j]` is the u parameter for row `j` and `uv[1][k]` is the v parameter +// for column `k`, so the point `points[j][k]` lies at `(uv[0][j], uv[1][k])` +// in NURBS parameter space. +// . +// **Smoothness** — The smoothness of B-splines is determined by the +// degree. If you request a degree p spline then it will be C^(p-1) at +// knot points and C^inf everywhere else. If you request edges then +// these are points where the surface is not differentiable; edges may +// also divide the surface into smaller regions that lack sufficient points +// to support an interpolation of your requested degree: a degree p interpolation +// requires p+1 points. In this case, the interpolation is performed at a lower +// degree and elevated, which means it will be less smooth at knots. +// Arguments: +// points = Rectangular grid of 3D data points +// degree = scalar or 2-vector giving the degree of the B-spline in the two directions. +// splinesteps = (module) Scalar or 2-vector giving the number of segments between each knot in the two directions. Default: 16 +// --- +// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` +// row_wrap = If true, smoothly connect the first row to the last row. Default: false +// col_wrap = If true, smoothly connect the first column to the last column. Default: false +// extra_pts = Scalar or 2-vector giving the number of extra points in the two directions. Default: `0` +// smooth = Scalar or 2-vector giving the smoothness metric for extra points in the two directions: `1` (min polygon length), `2` (min bending), `3` (min bending energy). Default: `3` +// flat_edges = 4-element list `[first_row, last_row, first_col, last_col]` of derivative scales at the four coplanar boundary edges. Each entry is a scalar or per-point list; `undef` leaves that edge unconstrained. Shorthand: `flat_edges=s` → `[s,s,s,s]`. Requires `row_wrap=false, col_wrap=false`. +// normal1 = Surface normal at the first degenerate boundary edge (mixed wrap surface only). +// normal2 = Surface normal at the second degenerate boundary edge (mixed wrap surface only). +// flat_end1 = Inward derivative scale at the first coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. +// flat_end2 = Inward derivative scale at the second coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. +// row_edges = Row indices (or index) of rows that are treated as edges or creases. +// col_edges = Column indices (or index) of columns that are treated as edges or creases +// first_row_deriv = dS/du constraint along u=0 (first row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. +// last_row_deriv = dS/du constraint along u=1 (last row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. +// first_col_deriv = dS/dv constraint along v=0 (first column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. +// last_col_deriv = dS/dv constraint along v=1 (last column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. +// data_size = (module) Radius of data-point markers; 0 suppresses markers. Default: 0 +// data_color = (module) Color for data-point markers. Default: `"red"` +// style = (module) Triangulation style passed to `vnf_vertex_array()`. Default: `"default"` +// reverse = (module) If true, reverses face normals. Default: false +// triangulate = (module) If true, triangulates all quads. Default: false +// caps = (module) Cap both open boundary edges (mixed wrap only). Default: false +// cap1 = (module) Cap the first open boundary edge. +// cap2 = (module) Cap the second open boundary edge. +// cp = (module) Centerpoint for determining intersection anchors or centering the shape. Determines the base of the anchor vector. Can be "centroid", "mean", "box" or a 3D point. Default: "centroid" +// anchor = (module) Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `"origin"` +// spin = (module) Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0` +// orient = (module) Vector to rotate top toward, after spin. See [orient](attachments.scad#subsection-orient). Default: `UP` +// atype = (module) Select "hull" or "intersect" anchor type. Default: "hull" -function _closed_rotation_collision_count(points, n, p, method, r) = +function nurbs_interp_surface(points, degree, method="centripetal", + row_wrap=false, col_wrap=false, + first_row_deriv=undef, last_row_deriv=undef, + first_col_deriv=undef, last_col_deriv=undef, + normal1=undef, normal2=undef, + flat_end1=undef, flat_end2=undef, + flat_edges=undef, + row_edges=undef, col_edges=undef, + extra_pts=0, smooth=3) = + // Preamble: extract shape/edge info needed for closed-direction dispatch. let( - pts = select(points, r, r + n - 1), - rp = _interp_params(pts, method, closed=true), - bk = _fix_tiny_spans(_avg_knots_periodic(rp, p)[0], n), - U = _full_closed_knots(bk, n, p), - ps = add_scalar(rp, bk[p]) + n_rows = len(points), + n_cols = len(points[0]), + ue_norm_pre = is_undef(row_edges) ? undef : force_list(row_edges), + ve_norm_pre = is_undef(col_edges) ? undef : force_list(col_edges), + has_ue_pre = !is_undef(ue_norm_pre) && len(ue_norm_pre) > 0, + has_ve_pre = !is_undef(ve_norm_pre) && len(ve_norm_pre) > 0 ) - max([for (k = [0:1:n-1]) - len([for (t = ps) if (t >= U[p+k] && t < U[p+k+1]) t]) - ]); + // col_edges on a closed v-direction: rotate columns so the first crease column + // becomes the v=0/v=1 boundary, append a copy at the end for the C0 seam, + // then recurse with col_wrap=false. Remaining crease indices are shifted + // into the rotated coordinate system. + has_ve_pre && col_wrap ? + let( + ve_sorted = sort(ve_norm_pre), + rot = ve_sorted[0], + new_pts = [for (row = points) + concat([for (l = [rot:1:n_cols-1]) row[l]], + [for (l = [0:1:rot-1]) row[l]], + [row[rot]])], + adj_ve_raw = [for (i = [1:1:len(ve_sorted)-1]) + let(j = (ve_sorted[i] - rot + n_cols) % n_cols) + if (j > 0) j], + adj_ve = len(adj_ve_raw) == 0 ? undef : adj_ve_raw + ) + let(inner = nurbs_interp_surface(new_pts, degree, method=method, + row_wrap=row_wrap, col_wrap=false, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, + row_edges=row_edges, col_edges=adj_ve, + extra_pts=extra_pts, smooth=smooth)) + [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], + [inner[6][0], + list_rotate(select(inner[6][1], 0, n_cols-1), -rot)]] + // row_edges on a closed u-direction: rotate rows so the first crease row + // becomes the u=0/u=1 boundary, append a copy at the end, recurse clamped. + : has_ue_pre && row_wrap ? + let( + ue_sorted = sort(ue_norm_pre), + rot = ue_sorted[0], + new_pts = concat([for (k = [rot:1:n_rows-1]) points[k]], + [for (k = [0:1:rot-1]) points[k]], + [points[rot]]), + adj_ue_raw = [for (i = [1:1:len(ue_sorted)-1]) + let(j = (ue_sorted[i] - rot + n_rows) % n_rows) + if (j > 0) j], + adj_ue = len(adj_ue_raw) == 0 ? undef : adj_ue_raw + ) + let(inner = nurbs_interp_surface(new_pts, degree, method=method, + row_wrap=false, col_wrap=col_wrap, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, + row_edges=adj_ue, col_edges=col_edges, + extra_pts=extra_pts, smooth=smooth)) + [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], + [list_rotate(select(inner[6][0], 0, n_rows-1), -rot), + inner[6][1]]] + // Normal path: both directions already clamped, or no conflicting edge constraints. + : let( + p_u = is_list(degree) ? degree[0] : degree, + p_v = is_list(degree) ? degree[1] : degree, + ep_u = is_list(extra_pts) ? extra_pts[0] : extra_pts, + ep_v = is_list(extra_pts) ? extra_pts[1] : extra_pts, + smooth_u = is_list(smooth) ? smooth[0] : smooth, + smooth_v = is_list(smooth) ? smooth[1] : smooth, + n_rows = len(points), + n_cols = len(points[0]), + dim = len(points[0][0]), + // Scalar-vector promotion: if the caller passes a single vector instead of + // a list of vectors, repeat() it to the required length. A single vector + // is detected as a list whose first element is a number, not a list. + first_row_deriv = is_undef(first_row_deriv) || is_list(first_row_deriv[0]) ? first_row_deriv + : repeat(first_row_deriv, n_cols), + last_row_deriv = is_undef(last_row_deriv) || is_list(last_row_deriv[0]) ? last_row_deriv + : repeat(last_row_deriv, n_cols), + first_col_deriv = is_undef(first_col_deriv) || is_list(first_col_deriv[0]) ? first_col_deriv + : repeat(first_col_deriv, n_rows), + last_col_deriv = is_undef(last_col_deriv) || is_list(last_col_deriv[0]) ? last_col_deriv + : repeat(last_col_deriv, n_rows), + // Treat an all-undef derivative list the same as undef. + has_sud = !is_undef(first_row_deriv) && num_defined(first_row_deriv) > 0, + has_eud = !is_undef(last_row_deriv) && num_defined(last_row_deriv) > 0, + has_svd = !is_undef(first_col_deriv) && num_defined(first_col_deriv) > 0, + has_evd = !is_undef(last_col_deriv) && num_defined(last_col_deriv) > 0, + has_sn = !is_undef(normal1), + has_en = !is_undef(normal2), + // normal1/normal2: apex edges only (all boundary points identical, e.g. cone tip). + // Auto-detect u=0/v=0 direction; u=0 (first row) takes priority. + start_u_apex = has_sn && max([for (pt = points[0]) norm(pt - points[0][0])]) < 1e-10, + start_v_apex = has_sn && max([for (k = [0:1:n_rows-1]) norm(points[k][0] - points[0][0])]) < 1e-10, + end_u_apex = has_en && max([for (pt = points[n_rows-1]) norm(pt - points[n_rows-1][0])]) < 1e-10, + end_v_apex = has_en && max([for (k = [0:1:n_rows-1]) norm(points[k][n_cols-1] - points[0][n_cols-1])]) < 1e-10, + has_sun = has_sn && start_u_apex, + has_eun = has_en && end_u_apex, + has_svn = has_sn && !start_u_apex && start_v_apex, + has_evn = has_en && !end_u_apex && end_v_apex, + start_u_degen = start_u_apex, + start_v_degen = start_v_apex, + end_u_degen = end_u_apex, + end_v_degen = end_v_apex, + // flat_end1/flat_end2: coplanar non-collinear edges (points span a plane). + // Scalar or per-point list. positive = closes inward, negative = flares outward. + // Direction is determined by the clamped direction of the surface: + // row_wrap=false → flat_end applies to row boundaries (u-direction, first/last row). + // col_wrap=false → flat_end applies to column boundaries (v-direction, first/last col). + // Exactly one direction must be clamped (enforced by assertion below). + has_fe1 = !is_undef(flat_end1), + has_fe2 = !is_undef(flat_end2), + has_fe1_u = has_fe1 && !row_wrap, + has_fe1_v = has_fe1 && !col_wrap, + has_fe2_u = has_fe2 && !row_wrap, + has_fe2_v = has_fe2 && !col_wrap, + // Boundary edges for coplanar validation. + fe1_edge = has_fe1_u ? points[0] + : has_fe1_v ? [for (k = [0:1:n_rows-1]) points[k][0]] + : [], + fe2_edge = has_fe2_u ? points[n_rows-1] + : has_fe2_v ? [for (k = [0:1:n_rows-1]) points[k][n_cols-1]] + : [], + fe1_ok = !has_fe1 || (_is_coplanar_pts(fe1_edge) && !is_undef(_pts_plane_normal(fe1_edge))), + fe2_ok = !has_fe2 || (_is_coplanar_pts(fe2_edge) && !is_undef(_pts_plane_normal(fe2_edge))), + // flat_edges= parsing: 4-element list [first_row, last_row, first_col, last_col]. + // Scalar shorthand: flat_edges=s expands to [s, s, s, s]. + fe_norm = !is_undef(flat_edges) && !is_list(flat_edges) + ? [flat_edges, flat_edges, flat_edges, flat_edges] + : flat_edges, + has_fe = !is_undef(fe_norm), + fe_su = has_fe ? fe_norm[0] : undef, + fe_eu = has_fe ? fe_norm[1] : undef, + fe_sv = has_fe ? fe_norm[2] : undef, + fe_ev = has_fe ? fe_norm[3] : undef, + has_fesu = has_fe && !is_undef(fe_su), + has_feeu = has_fe && !is_undef(fe_eu), + has_fesv = has_fe && !is_undef(fe_sv), + has_feev = has_fe && !is_undef(fe_ev), + // Edge (C0 discontinuity) support. Singleton promotion: scalar → list. + ue_norm = is_undef(row_edges) ? undef : force_list(row_edges), + ve_norm = is_undef(col_edges) ? undef : force_list(col_edges), + has_ue = !is_undef(ue_norm) && len(ue_norm) > 0, + has_ve = !is_undef(ve_norm) && len(ve_norm) > 0 + ) + assert(is_list(points) && n_rows >= 2, + "nurbs_interp_surface: need at least 2 rows") + assert(n_cols >= 2, + "nurbs_interp_surface: need at least 2 columns") + assert(min([for (row = points) len(row)]) == max([for (row = points) len(row)]), + "nurbs_interp_surface: all rows must have the same number of columns") + assert(is_num(p_u) && p_u >= 1 && is_num(p_v) && p_v >= 1, + "nurbs_interp_surface: degree must be >= 1") + assert(method == "length" || method == "centripetal" || method == "dynamic" + || method == "foley" || method == "fang", + str("nurbs_interp_surface: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) + assert(is_num(ep_u) && ep_u >= 0 && ep_u == floor(ep_u), + str("nurbs_interp_surface: extra_pts (u) must be a non-negative integer, got ", ep_u)) + assert(is_num(ep_v) && ep_v >= 0 && ep_v == floor(ep_v), + str("nurbs_interp_surface: extra_pts (v) must be a non-negative integer, got ", ep_v)) + assert(ep_u == 0 || p_u >= 2, + "nurbs_interp_surface: extra_pts in u-direction requires u-degree >= 2") + assert(ep_v == 0 || p_v >= 2, + "nurbs_interp_surface: extra_pts in v-direction requires v-degree >= 2") + assert(n_rows >= p_u + 1, + str("nurbs_interp_surface: need at least ", p_u+1, + " rows for u-degree ", p_u, ", got ", n_rows)) + assert(n_cols >= p_v + 1, + str("nurbs_interp_surface: need at least ", p_v+1, + " columns for v-degree ", p_v, ", got ", n_cols)) + assert(!(has_sud || has_eud || has_sun || has_eun || has_fesu || has_feeu || has_fe1_u || has_fe2_u) || !row_wrap, + "nurbs_interp_surface: u-direction derivative/normal/flat_end/flat_edges params require row_wrap=false") + assert(!(has_svd || has_evd || has_svn || has_evn || has_fesv || has_feev || has_fe1_v || has_fe2_v) || !col_wrap, + "nurbs_interp_surface: v-direction derivative/normal/flat_end/flat_edges params require col_wrap=false") + assert(!has_sud || len(first_row_deriv) == n_cols, + str("nurbs_interp_surface: first_row_deriv must have ", n_cols, + " entries (one per column), got ", is_undef(first_row_deriv) ? 0 : len(first_row_deriv))) + assert(!has_eud || len(last_row_deriv) == n_cols, + str("nurbs_interp_surface: last_row_deriv must have ", n_cols, + " entries (one per column), got ", is_undef(last_row_deriv) ? 0 : len(last_row_deriv))) + assert(!has_svd || len(first_col_deriv) == n_rows, + str("nurbs_interp_surface: first_col_deriv must have ", n_rows, + " entries (one per row), got ", is_undef(first_col_deriv) ? 0 : len(first_col_deriv))) + assert(!has_evd || len(last_col_deriv) == n_rows, + str("nurbs_interp_surface: last_col_deriv must have ", n_rows, + " entries (one per row), got ", is_undef(last_col_deriv) ? 0 : len(last_col_deriv))) + // normal1/normal2 assertions: apex edges only. + assert(!has_sn || (start_u_degen || start_v_degen), + "nurbs_interp_surface: normal1 requires a degenerate start edge (first row or first column must be all the same point)") + assert(!has_en || (end_u_degen || end_v_degen), + "nurbs_interp_surface: normal2 requires a degenerate end edge (last row or last column must be all the same point)") + assert(!has_sn || !(start_u_degen && start_v_degen), + "nurbs_interp_surface: normal1 is ambiguous — both u=0 and v=0 edges are degenerate; use first_row_deriv or first_col_deriv explicitly") + assert(!has_en || !(end_u_degen && end_v_degen), + "nurbs_interp_surface: normal2 is ambiguous — both u=1 and v=1 edges are degenerate; use last_row_deriv or last_col_deriv explicitly") + assert(!(has_sun && has_sud), + "nurbs_interp_surface: normal1 resolves to u-direction but first_row_deriv was also given") + assert(!(has_eun && has_eud), + "nurbs_interp_surface: normal2 resolves to u-direction but last_row_deriv was also given") + assert(!(has_svn && has_svd), + "nurbs_interp_surface: normal1 resolves to v-direction but first_col_deriv was also given") + assert(!(has_evn && has_evd), + "nurbs_interp_surface: normal2 resolves to v-direction but last_col_deriv was also given") + // flat_end1/flat_end2 assertions. + // Direction is determined by the clamped type; surface must be mixed clamped/closed. + assert(!has_fe1 || (row_wrap != col_wrap), + "nurbs_interp_surface: flat_end1 requires the surface to be clamped in one direction and closed in the other") + assert(!has_fe2 || (row_wrap != col_wrap), + "nurbs_interp_surface: flat_end2 requires the surface to be clamped in one direction and closed in the other") + assert(fe1_ok, + has_fe1_u + ? "nurbs_interp_surface: flat_end1 requires the first row (u=0 boundary) to be coplanar and non-collinear" + : "nurbs_interp_surface: flat_end1 requires the first column (v=0 boundary) to be coplanar and non-collinear. If your first row is coplanar, try row_wrap=true, col_wrap=false.") + assert(fe2_ok, + has_fe2_u + ? "nurbs_interp_surface: flat_end2 requires the last row (u=1 boundary) to be coplanar and non-collinear" + : "nurbs_interp_surface: flat_end2 requires the last column (v=1 boundary) to be coplanar and non-collinear. If your last row is coplanar, try row_wrap=true, col_wrap=false.") + assert(!(has_fe1_u && has_sud), + "nurbs_interp_surface: flat_end1 conflicts with first_row_deriv") + assert(!(has_fe2_u && has_eud), + "nurbs_interp_surface: flat_end2 conflicts with last_row_deriv") + assert(!(has_fe1_v && has_svd), + "nurbs_interp_surface: flat_end1 conflicts with first_col_deriv") + assert(!(has_fe2_v && has_evd), + "nurbs_interp_surface: flat_end2 conflicts with last_col_deriv") + assert(!(has_fe1_u && has_fesu), + "nurbs_interp_surface: flat_end1 conflicts with flat_edges[0] on same edge") + assert(!(has_fe2_u && has_feeu), + "nurbs_interp_surface: flat_end2 conflicts with flat_edges[1] on same edge") + assert(!(has_fe1_v && has_fesv), + "nurbs_interp_surface: flat_end1 conflicts with flat_edges[2] on same edge") + assert(!(has_fe2_v && has_feev), + "nurbs_interp_surface: flat_end2 conflicts with flat_edges[3] on same edge") + assert(!has_fe1 || is_num(flat_end1) || len(flat_end1) == (has_fe1_u ? n_cols : n_rows), + str("nurbs_interp_surface: flat_end1 list must have ", has_fe1_u ? n_cols : n_rows, " entries")) + assert(!has_fe2 || is_num(flat_end2) || len(flat_end2) == (has_fe2_u ? n_cols : n_rows), + str("nurbs_interp_surface: flat_end2 list must have ", has_fe2_u ? n_cols : n_rows, " entries")) + // flat_edges assertions. + assert(!has_fe || (is_list(fe_norm) && len(fe_norm) == 4), + "nurbs_interp_surface: flat_edges must be a scalar or 4-element list [first_row, last_row, first_col, last_col]") + assert(!(has_fesu && has_sud), + "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with first_row_deriv") + assert(!(has_feeu && has_eud), + "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with last_row_deriv") + assert(!(has_fesv && has_svd), + "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with first_col_deriv") + assert(!(has_feev && has_evd), + "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with last_col_deriv") + assert(!(has_fesu && has_sun), + "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with normal1 on same edge") + assert(!(has_feeu && has_eun), + "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with normal2 on same edge") + assert(!(has_fesv && has_svn), + "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with normal1 on same edge") + assert(!(has_feev && has_evn), + "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with normal2 on same edge") + assert(!has_fesu || !is_list(fe_su) || len(fe_su) == n_cols, + str("nurbs_interp_surface: flat_edges[0] scale list must have ", n_cols, " entries (one per column)")) + assert(!has_feeu || !is_list(fe_eu) || len(fe_eu) == n_cols, + str("nurbs_interp_surface: flat_edges[1] scale list must have ", n_cols, " entries (one per column)")) + assert(!has_fesv || !is_list(fe_sv) || len(fe_sv) == n_rows, + str("nurbs_interp_surface: flat_edges[2] scale list must have ", n_rows, " entries (one per row)")) + assert(!has_feev || !is_list(fe_ev) || len(fe_ev) == n_rows, + str("nurbs_interp_surface: flat_edges[3] scale list must have ", n_rows, " entries (one per row)")) + // Edge (C0) validation. + assert(!has_ue || !row_wrap, + "nurbs_interp_surface: row_edges requires row_wrap=false") + assert(!has_ve || !col_wrap, + "nurbs_interp_surface: col_edges requires col_wrap=false") + assert(!has_ue || (min(ue_norm) >= 1 && max(ue_norm) <= n_rows-2), + str("nurbs_interp_surface: row_edges indices must be interior (1..", n_rows-2, ")")) + assert(!has_ve || (min(ve_norm) >= 1 && max(ve_norm) <= n_cols-2), + str("nurbs_interp_surface: col_edges indices must be interior (1..", n_cols-2, ")")) + // row_edges / col_edges are compatible with same-direction boundary derivatives, + // normals, and flat_edges: the first/last segment of the edge-aware system + // carries the boundary derivative constraint. + let( + // Boundary plane for flat_edges=: cross product of two perimeter vectors. + // Guarded so degenerate geometry can't produce NaN when flat_edges is unused. + fe_e1 = has_fe ? (points[0][n_cols-1] - points[0][0]) : [1,0,0], + fe_e2 = has_fe ? (points[n_rows-1][0] - points[0][0]) : [0,1,0], + fe_N_raw = has_fe ? cross(fe_e1, fe_e2) : [0,0,1], + fe_N_hat = fe_N_raw / max(norm(fe_N_raw), 1e-15), + // Per-edge flat-outward derivative lists; undef when edge not active. + // Direction at each point: from adjacent interior point toward edge, + // projected into the boundary plane, then normalized and scaled. + flat_su_der = !has_fesu ? undef : + [for (j = [0:1:n_cols-1]) + let( + d = points[1][j] - points[0][j], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_su) ? fe_su[j] : fe_su + ) d_hat * s], + flat_eu_der = !has_feeu ? undef : + [for (j = [0:1:n_cols-1]) + let( + d = points[n_rows-1][j] - points[n_rows-2][j], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_eu) ? fe_eu[j] : fe_eu + ) d_hat * s], + flat_sv_der = !has_fesv ? undef : + [for (k = [0:1:n_rows-1]) + let( + d = points[k][1] - points[k][0], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_sv) ? fe_sv[k] : fe_sv + ) d_hat * s], + flat_ev_der = !has_feev ? undef : + [for (k = [0:1:n_rows-1]) + let( + d = points[k][n_cols-1] - points[k][n_cols-2], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_ev) ? fe_ev[k] : fe_ev + ) d_hat * s] + ) + assert(!has_fesu || min([for (j = [0:1:n_cols-1]) let(d = points[1][j] - points[0][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[0] (first_row) direction is perpendicular to the boundary plane at one or more points") + assert(!has_feeu || min([for (j = [0:1:n_cols-1]) let(d = points[n_rows-1][j] - points[n_rows-2][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[1] (last_row) direction is perpendicular to the boundary plane at one or more points") + assert(!has_fesv || min([for (k = [0:1:n_rows-1]) let(d = points[k][1] - points[k][0], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[2] (first_col) direction is perpendicular to the boundary plane at one or more points") + assert(!has_feev || min([for (k = [0:1:n_rows-1]) let(d = points[k][n_cols-1] - points[k][n_cols-2], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[3] (last_col) direction is perpendicular to the boundary plane at one or more points") + assert(!has_fe || is_coplanar(concat( + points[0], points[n_rows-1], + [for (k = [1:1:n_rows-2]) points[k][0]], + [for (k = [1:1:n_rows-2]) points[k][n_cols-1]]), eps=1e-6), + "nurbs_interp_surface: flat_edges= requires all four boundary edges to be coplanar") + let( + // Compute effective derivative lists. + // Priority: normal1/normal2 (apex) > flat_end1/flat_end2 (coplanar) > flat_edges > explicit *_der=. + // Apex (all boundary points identical): fan outward from apex, user axis vector N. + // End-edge apex tangents are negated because _apex_tangents() returns outward + // (apex→ring) vectors; negating gives inward (ring→apex), making the surface + // converge to the apex tip at the correct parametric direction. + // Coplanar (flat_end): _coplanar_inward_tangents() returns in-plane vectors + // oriented toward the polygon interior using the polygon winding order. + // Positive scale closes inward, negative flares outward. + // flat_end1 result is negated: _coplanar_inward_tangents returns outward + // for the start boundary; negating gives the correct inward direction. + // flat_end2 uses the same function without negation (end boundary sign matches). + // Periodic tangent differences used when the cross-direction is "closed". + first_row_deriv_eff = has_sun + ? _apex_tangents(normal1, points[0][0], points[1]) + : has_fe1_u + ? [for (v = _coplanar_inward_tangents(flat_end1, points[0], points[1], + periodic=col_wrap)) -v] + : has_fesu ? flat_su_der + : first_row_deriv, + last_row_deriv_eff = has_eun + ? [for (v = _apex_tangents(normal2, points[n_rows-1][0], points[n_rows-2])) -v] + : has_fe2_u + ? _coplanar_inward_tangents(flat_end2, points[n_rows-1], points[n_rows-2], + periodic=col_wrap) + : has_feeu ? flat_eu_der + : last_row_deriv, + first_col_deriv_eff = has_svn + ? _apex_tangents(normal1, points[0][0], + [for (k = [0:1:n_rows-1]) points[k][1]]) + : has_fe1_v + ? [for (v = _coplanar_inward_tangents(flat_end1, + [for (k = [0:1:n_rows-1]) points[k][0]], + [for (k = [0:1:n_rows-1]) points[k][1]], + periodic=row_wrap)) -v] + : has_fesv ? flat_sv_der + : first_col_deriv, + last_col_deriv_eff = has_evn + ? [for (v = _apex_tangents(normal2, points[0][n_cols-1], + [for (k = [0:1:n_rows-1]) points[k][n_cols-2]])) -v] + : has_fe2_v + ? _coplanar_inward_tangents(flat_end2, + [for (k = [0:1:n_rows-1]) points[k][n_cols-1]], + [for (k = [0:1:n_rows-1]) points[k][n_cols-2]], + periodic=row_wrap) + : has_feev ? flat_ev_der + : last_col_deriv, + has_sud_eff = has_sud || has_sun || has_fesu || has_fe1_u, + has_eud_eff = has_eud || has_eun || has_feeu || has_fe2_u, + has_svd_eff = has_svd || has_svn || has_fesv || has_fe1_v, + has_evd_eff = has_evd || has_evn || has_feev || has_fe2_v + ) + // row_edges / col_edges boundary-derivative segment-size checks. + // A derivative-carrying edge segment needs at least 3 rows/columns; + // with only 2 the degree-reduced knot vector becomes degenerate. + assert(!(has_ue && has_sud_eff && ue_norm[0] + 1 < 3), + !has_ue ? "" : + str("nurbs_interp_surface: row_edges=", ue_norm, + " creates a ", ue_norm[0]+1, "-row first segment (rows 0-", + ue_norm[0], ") which is too short to carry the start-u derivative constraint. ", + "Move the first row_edges index to at least 2")) + assert(!(has_ue && has_eud_eff && n_rows - last(ue_norm) < 3), + !has_ue ? "" : + str("nurbs_interp_surface: row_edges=", ue_norm, + " creates a ", n_rows - last(ue_norm), "-row last segment (rows ", + last(ue_norm), "-", n_rows-1, ") which is too short to carry the end-u derivative constraint. ", + "Move the last row_edges index to at most ", n_rows - 3)) + assert(!(has_ve && has_svd_eff && ve_norm[0] + 1 < 3), + !has_ve ? "" : + str("nurbs_interp_surface: col_edges=", ve_norm, + " creates a ", ve_norm[0]+1, "-column first segment (columns 0-", + ve_norm[0], ") which is too short to carry the start-v derivative constraint. ", + "Move the first col_edges index to at least 2")) + assert(!(has_ve && has_evd_eff && n_cols - last(ve_norm) < 3), + !has_ve ? "" : + str("nurbs_interp_surface: col_edges=", ve_norm, + " creates a ", n_cols - last(ve_norm), "-column last segment (columns ", + last(ve_norm), "-", n_cols-1, ") which is too short to carry the end-v derivative constraint. ", + "Move the last col_edges index to at most ", n_cols - 3)) + let( + // Averaged parameterization in each direction + u_params = _surface_params_u(points, method, row_wrap), + v_params = _surface_params_v(points, method, col_wrap), + // Per-row v-direction path lengths for scaling v-boundary tangents. + // Follows the curve convention: user passes normalized vectors; code + // scales by total chord length so a unit vector gives natural speed. + v_path_lens = [for (k = [0:1:n_rows-1]) path_length(points[k])], -// Find the best seam rotation for closed curve interpolation. -// The chord-ratio heuristic (argmax d[i+1]/d[i] + 1) is tried first. -// If it has span collisions, all n rotations are scored by collision -// count and the one with the fewest collisions is chosen. Mild -// collisions (max 2 params per span) often still produce a non-singular -// system, so the final check is deferred to linear_solve(). + // Per-column u-direction path lengths for scaling u-boundary tangents. + u_path_lens = [for (l = [0:1:n_cols-1]) + path_length([for (k = [0:1:n_rows-1]) points[k][l]])], -function _find_closed_rotation(points, n, p, method) = - let( - chords = path_segment_lengths(points, closed=true), - ratios = [for (i = [0:1:n-1]) chords[(i+1)%n] / max(chords[i], 1e-15)], - rot0 = (max_index(ratios) + 1) % n - ) - _closed_rotation_collision_count(points, n, p, method, rot0) <= 1 - ? rot0 - : let( - scores = [for (i = [0:1:n-1]) - [_closed_rotation_collision_count(points, n, p, method, i), i]], - best = min_index([for (s = scores) s[0]]) - ) - scores[best][1]; + // ----- Build v-direction system ----- + // When col_edges is active, precompute per-segment collocation systems. + // Otherwise use the standard (or derivative-extended) system. + v_edge_sys = has_ve + ? _build_edge_systems(v_params, p_v, ve_norm, + has_sd=has_svd_eff, + has_ed=has_evd_eff, + extra_pts=ep_v, label="v") : undef, + v_sys = has_ve ? undef + : (has_svd_eff || has_evd_eff) + ? _build_clamped_system_with_derivs(v_params, p_v, has_svd_eff, has_evd_eff, ep_v) + : _build_interp_system(v_params, p_v, col_wrap ? "closed" : "clamped", ep_v), + N_v = has_ve ? undef : v_sys[0], + // When underdetermined (extra_pts), build regularization matrix for v. + M_v = has_ve ? undef : len(N_v[0]), + N_rows_v = has_ve ? undef : len(N_v), + ns_v = !has_ve && M_v > N_rows_v, + R_reg_v = !ns_v ? undef + : let(vk = v_sys[1], + vint = !col_wrap + ? [for (i = [1:1:len(vk)-2]) vk[i]] + : undef, + vU = !col_wrap + ? _full_clamped_knots(vint, p_v) + : _full_closed_knots(vk, M_v, p_v)) + _regularization_matrix(M_v, smooth_v, p_v, vU, periodic=col_wrap), + + // ----- Pass 1: Interpolate rows in v-direction ----- + // With col_edges: solve each row via edge-aware segmented system. + // Without: same A_v matrix for every row; only the RHS changes per row. + R_raw = has_ve + ? [for (k = [0:1:n_rows-1]) + _solve_with_edges(v_edge_sys, points[k], + v_params, ve_norm, p_v, + start_deriv = has_svd_eff + ? _force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k] + : undef, + end_deriv = has_evd_eff + ? _force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k] + : undef, + smooth = smooth_v)] + : undef, + R = has_ve + ? [for (r = R_raw) r[0]] + : [for (k = [0:1:n_rows-1]) + let(rhs = concat( + points[k], + has_svd_eff + ? [_force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k]] + : [], + has_evd_eff + ? [_force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k]] + : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, rhs) + : linear_solve(N_v, rhs) + ], + + v_knots = has_ve ? R_raw[0][1] : v_sys[1], + n_v_ctrl = len(R[0]), + + // ----- Pass 1.5: Project u-boundary tangents into v-control space ----- + // ∂S/∂u along u=0 or u=1 is given at the n_cols data v-positions. + // To use them as derivative RHS in the u-direction column solves, we + // must express them in the v B-spline control basis — done by solving + // the same v-system. When col_edges is active, project through the + // edge-aware segmented system instead. + zero_v = repeat(0, dim), + _su_der_data = has_sud_eff + ? [for (l = [0:1:n_cols-1]) + _force_deriv_dim(first_row_deriv_eff[l], dim) * u_path_lens[l]] + : undef, + _eu_der_data = has_eud_eff + ? [for (l = [0:1:n_cols-1]) + _force_deriv_dim(last_row_deriv_eff[l], dim) * u_path_lens[l]] + : undef, + T_u_start = has_sud_eff + ? has_ve + ? _solve_with_edges(v_edge_sys, _su_der_data, + v_params, ve_norm, p_v, + start_deriv = has_svd_eff ? zero_v : undef, + end_deriv = has_evd_eff ? zero_v : undef, + smooth = smooth_v)[0] + : let(_rhs = concat(_su_der_data, + has_svd_eff ? [zero_v] : [], + has_evd_eff ? [zero_v] : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) + : linear_solve(N_v, _rhs) + : undef, + T_u_end = has_eud_eff + ? has_ve + ? _solve_with_edges(v_edge_sys, _eu_der_data, + v_params, ve_norm, p_v, + start_deriv = has_svd_eff ? zero_v : undef, + end_deriv = has_evd_eff ? zero_v : undef, + smooth = smooth_v)[0] + : let(_rhs = concat(_eu_der_data, + has_svd_eff ? [zero_v] : [], + has_evd_eff ? [zero_v] : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) + : linear_solve(N_v, _rhs) + : undef, + + // ----- Build u-direction system ----- + // When row_edges is active, precompute per-segment systems. + u_edge_sys = has_ue + ? _build_edge_systems(u_params, p_u, ue_norm, + has_sd=has_sud_eff, + has_ed=has_eud_eff, + extra_pts=ep_u, label="u") : undef, + u_sys = has_ue ? undef + : (has_sud_eff || has_eud_eff) + ? _build_clamped_system_with_derivs(u_params, p_u, has_sud_eff, has_eud_eff, ep_u) + : _build_interp_system(u_params, p_u, row_wrap ? "closed" : "clamped", ep_u), + N_u = has_ue ? undef : u_sys[0], + // When underdetermined (extra_pts), build regularization matrix for u. + M_u = has_ue ? undef : len(N_u[0]), + N_rows_u = has_ue ? undef : len(N_u), + ns_u = !has_ue && M_u > N_rows_u, + R_reg_u = !ns_u ? undef + : let(uk = u_sys[1], + uint = !row_wrap + ? [for (i = [1:1:len(uk)-2]) uk[i]] + : undef, + uU = !row_wrap + ? _full_clamped_knots(uint, p_u) + : _full_closed_knots(uk, M_u, p_u)) + _regularization_matrix(M_u, smooth_u, p_u, uU, periodic=row_wrap), + // ----- Pass 2: Interpolate columns in u-direction ----- + // Transpose R so each entry is a column of intermediate points. + R_T = [for (j = [0:1:n_v_ctrl-1]) + [for (k = [0:1:n_rows-1]) R[k][j]]], -// Solve a basic closed interpolation for a specific rotation. -// Returns [control, bar_knots, rot] or undef if singular. + // With row_edges: solve each column via edge-aware segmented system. + // Without: add u-tangent constraint rows to the RHS for each column j. + P_T_raw = has_ue + ? [for (j = [0:1:n_v_ctrl-1]) + _solve_with_edges(u_edge_sys, R_T[j], + u_params, ue_norm, p_u, + start_deriv = has_sud_eff ? T_u_start[j] : undef, + end_deriv = has_eud_eff ? T_u_end[j] : undef, + smooth = smooth_u)] + : undef, + P_T = has_ue + ? [for (r = P_T_raw) r[0]] + : [for (j = [0:1:n_v_ctrl-1]) + let(rhs = concat( + R_T[j], + has_sud_eff ? [T_u_start[j]] : [], + has_eud_eff ? [T_u_end[j]] : [])) + ns_u ? _nullspace_solve(R_reg_u, N_u, rhs) + : linear_solve(N_u, rhs) + ], -function _closed_basic_solve(points, n, p, method, rot, smooth=3) = - let( - dim = len(points[0]), - pts = select(points, rot, rot + n - 1), - raw_params = _interp_params(pts, method, closed=true), - bar_knots = _fix_tiny_spans(_avg_knots_periodic(raw_params, p)[0], n), - U_full = _full_closed_knots(bar_knots, n, p), - params = add_scalar(raw_params, bar_knots[p]), - N_mat = _collocation_matrix_periodic(params, n, p, U_full), - control = linear_solve(N_mat, pts) - ) - control != [] ? [control, bar_knots, rot] - : // Singular — fall back to constrained optimization. - let( - M = n, - R = _regularization_matrix(M, smooth, p, U_full, periodic=true), - ctrl = _nullspace_solve(R, N_mat, pts) - ) - is_undef(ctrl) ? undef : [ctrl, bar_knots, rot]; + u_knots = has_ue ? P_T_raw[0][1] : u_sys[1], + // Transpose back to get the final control point grid. + n_u_ctrl = len(P_T[0]), + P = [for (i = [0:1:n_u_ctrl-1]) + [for (j = [0:1:n_v_ctrl-1]) P_T[j][i]]] + ) + [[row_wrap ? "closed" : "clamped", col_wrap ? "closed" : "clamped"], + [p_u, p_v], P, [u_knots, v_knots], undef, undef, + [u_params, v_params]]; -// Basic closed interpolation — start-point independent. -// -// Implements the cyclic chord-length parameterization and cyclic knot -// averaging of Piegl & Tiller §9.2.4. In exact arithmetic the resulting -// curve is the same regardless of which data point is listed first; only -// the parametric origin changes (the curve is just reparameterized). -// The chord-ratio heuristic selects the starting rotation. -function _nurbs_interp_closed_basic(points, p, method, smooth=3) = - let( - n = len(points), - rot0 = _find_closed_rotation(points, n, p, method), - result0 = _closed_basic_solve(points, n, p, method, rot0, smooth) - ) - assert(!is_undef(result0), - "nurbs_interp (closed): singular system — try adding extra_pts= to relax the knot structure") - result0; +module nurbs_interp_surface(points, degree, splinesteps=16, + method="centripetal", + row_wrap=false, col_wrap=false, + style="default", reverse=false, triangulate=false, + caps=undef, cap1=undef, cap2=undef, + first_row_deriv=undef, last_row_deriv=undef, + first_col_deriv=undef, last_col_deriv=undef, + normal1=undef, normal2=undef, + flat_end1=undef, flat_end2=undef, + flat_edges=undef, + row_edges=undef, col_edges=undef, + extra_pts=0, smooth=3, + data_color="red", data_size=0, + atype="hull", convexity=10, cp="centroid", anchor="origin", spin=0, orient=UP +) + { + result = nurbs_interp_surface(points, degree, + method=method, row_wrap=row_wrap, col_wrap=col_wrap, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, + flat_edges=flat_edges, + row_edges=row_edges, col_edges=col_edges, + extra_pts=extra_pts, smooth=smooth); + nurbs_vnf(result, splinesteps=splinesteps, style=style, + reverse=reverse, triangulate=triangulate, + caps=caps, cap1=cap1, cap2=cap2, convexity=convexity, atype=atype, anchor=anchor, spin=spin, orient=orient) children(); + if (data_size > 0) + color(data_color) + for (row = points) + for (pt = row) + translate(pt) sphere(r=data_size, $fn=16); +} -// Solve a constrained closed interpolation for a specific rotation. -// Returns [control, aug_bar, rot] or undef if singular. -// -// eff_der: list of n first-derivative specs (undef = unconstrained). -// eff_curv: list of n curvature specs (undef = unconstrained). -// dim=2: signed scalar κ or 2D vector. dim≥3: curvature vector. +// ---------- CLAMPED interpolation ---------- // -// Knot construction: standard periodic averaging of N data params, -// then insert one knot per constraint at the midpoint of the span -// containing its parameter (largest span first). -// M control points use standard BOSL2 periodic aliasing: -// B_j(t) = N_j(t) + (j

= p, + str("nurbs_interp (clamped): need at least ", p+1, + " points for degree ", p, ", got ", n+1)) let( - n = len(points), - dim = len(points[0]), - path_len = path_length(points, closed=true), - path_len2 = path_len * path_len, - - // Rotate data, deriv, and curvature lists by the same offset so constraint - // associations are preserved after rotation. - pts = select(points, rot, rot + n - 1), - der_r = is_undef(eff_der) ? undef : select(eff_der, rot, rot + n - 1), - curv_r = is_undef(eff_curv) ? undef : select(eff_curv, rot, rot + n - 1), - - raw_params = _interp_params(pts, method, closed=true), - - // First-derivative specs: [index, C'(t) vector]. - // eff_der entries are already dim-projected by _nurbs_interp_closed. - der_specs = is_undef(der_r) ? [] - : [for (k = [0:1:n-1]) if (!is_undef(der_r[k])) - [k, der_r[k] * path_len]], - - // Curvature specs: [index, C''(t) vector]. - // eff_curv entries are already dim-projected by _nurbs_interp_closed. - // Tangent from explicit derivative (required by caller; validated upstream). - curv_specs = is_undef(curv_r) ? [] - : [for (k = [0:1:n-1]) if (!is_undef(curv_r[k])) - let( - tang_dir = der_r[k], - v2 = path_len2 * (tang_dir * tang_dir) - ) - [k, _curv_to_d2(curv_r[k], tang_dir, dim, v2)] - ], - - n_extra_der = len(der_specs), - n_extra_curv = len(curv_specs), - _chk_curv_deg = assert(n_extra_curv == 0 || p >= 2, - "nurbs_interp: curvature constraints require degree >= 2"), - n_constraint = n_extra_der + n_extra_curv, - - // Build bar_knots: standard periodic averaging of N data - // params, then insert knots for constraints and extra_pts. - base_bar = _avg_knots_periodic(raw_params, p)[0], - constraint_idxs = [for (spec = der_specs) spec[0], - for (spec = curv_specs) spec[0]], - constraint_ts = [for (k = constraint_idxs) raw_params[k]], - after_constr = _insert_constraint_knots(base_bar, constraint_ts), - // _widest_span_params silently caps the request at the available span count. - extra_ts = extra_pts == 0 ? [] - : _widest_span_params(after_constr, extra_pts), - aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), - // M_pre = span count of aug_bar_raw. Use len()-1 rather than - // n+n_constraint+extra_pts so it reflects the actual knots inserted. - M_pre = len(aug_bar_raw) - 1, - aug_bar_pre = _fix_tiny_spans(aug_bar_raw, M_pre), - - // Split any knot span that contains multiple data parameters. - // Without this, two data points in the same span produce a - // rank-deficient collocation matrix (§9.2.1 Schoenberg-Whitney). - occ_splits = _span_split_params(aug_bar_pre, raw_params), - n_occ = len(occ_splits), - M = M_pre + n_occ, - aug_bar = n_occ == 0 ? aug_bar_pre - : _fix_tiny_spans( - sort([each aug_bar_pre, each occ_splits]), - M), - T = aug_bar[M], - U_full = _full_closed_knots(aug_bar, M, p), + eff_der = _merge_deriv_list(n, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv), + eff_curv = _merge_curv_list(n, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature), - // Map raw params into active domain [aug_bar[p], aug_bar[p]+T]. - // Nudge any shifted parameter that lands on or near a knot. - raw_shifted = add_scalar(raw_params, aug_bar[p]), - eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), - params = [for (k = [0:1:n-1]) - let( - u = raw_shifted[k], - d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) - ) - d_min < eps_knot ? u + eps_knot : u - ], + // C0 corner joints from NaN entries in eff_der and/or corners= list. + // Must be interior points; cannot coincide with curvature constraints. + nan_corners = is_undef(eff_der) ? [] + : [for (k = [0:1:n]) if (is_nan(eff_der[k])) k], + explicit_corners = default(corners, []), + corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), + has_corners = len(corner_idxs) > 0, + bad_corner_end = [for (k = corner_idxs) if (k == 0 || k == n) k], + bad_corner_curv = is_undef(eff_curv) ? [] + : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], + // Explicit corners= entries must not also carry a derivative constraint. + // (NaN-in-deriv corners are fine — they ARE the corner syntax.) + bad_corner_der = is_undef(eff_der) ? [] + : [for (k = explicit_corners) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k], - // Constraint matrix A: interpolation + derivative + curvature rows. - N_rows = n + n_constraint, + // Exclude NaN corner markers from the derivative-constraint count. + has_any_der = !is_undef(eff_der) && + len([for (k = [0:1:n]) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, + has_any_curv = !is_undef(eff_curv) && + len([for (k = [0:1:n]) if (!is_undef(eff_curv[k])) k]) > 0, - // Interpolation rows: aliased basis for M control points - interp_rows = [for (k = [0:1:n-1]) - [for (j = [0:1:M-1]) - _nip(j, p, params[k], U_full) - + (j < p ? _nip(j + M, p, params[k], U_full) : 0) - ] - ], + // Every curvature-constrained point must also have a derivative + // constraint; the derivative direction defines the curve's tangent + // and is required to orient the curvature normal. + bad_curv_pts = is_undef(eff_curv) ? [] : + [for (k = [0:1:n]) + if (!is_undef(eff_curv[k]) && + (is_undef(eff_der) || is_undef(eff_der[k]))) + k] + ) + assert(bad_corner_end == [], + str("nurbs_interp: corner cannot be at the first or last point: ", bad_corner_end)) + assert(bad_corner_curv == [], + str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", bad_corner_curv)) + assert(bad_corner_der == [], + str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", bad_corner_der)) + assert(bad_curv_pts == [], + str("nurbs_interp: curvature constraint requires a derivative constraint ", + "at the same point(s): ", bad_curv_pts)) + has_corners + ? _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, + extra_pts=extra_pts, smooth=smooth) + : (has_any_der || has_any_curv || extra_pts > 0) + ? _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, extra_pts, smooth) + : _nurbs_interp_clamped_basic(points, p, method, smooth); - // First-derivative rows: aliased derivative basis - deriv_rows = [for (spec = der_specs) - let(k = spec[0]) - [for (j = [0:1:M-1]) - _dnip(j, p, params[k], U_full) - + (j < p ? _dnip(j + M, p, params[k], U_full) : 0) - ] - ], - // Second-derivative rows: aliased second-derivative basis - curv_rows = [for (spec = curv_specs) - let(k = spec[0]) - [for (j = [0:1:M-1]) - _d2nip(j, p, params[k], U_full) - + (j < p ? _d2nip(j + M, p, params[k], U_full) : 0) - ] - ], +// Basic clamped interpolation (no derivatives). +// n+1 points -> n+1 control points. - A_constr = [each interp_rows, each deriv_rows, each curv_rows], - rhs_constr = [each pts, - for (spec = der_specs) spec[1], - for (spec = curv_specs) spec[1]] - ) - // When M == N_rows (square), try direct solve first. - // When M > N_rows (underdetermined from extra_pts or span splits), - // use null-space method: exact constraints + minimum-energy smoothing. +function _nurbs_interp_clamped_basic(points, p, method, smooth=3) = let( - direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] + n = len(points) - 1, + M = n + 1, + dim = len(points[0]), + params = _interp_params(points, method), + int_kn = _avg_knots_interior(params, p), + U_full = _full_clamped_knots(int_kn, p), + N_mat = _collocation_matrix(params, n, p, U_full), + control = linear_solve(N_mat, points), + knots = [0, each int_kn, 1] ) - direct != [] - ? [direct, aug_bar, rot] - : let( - R = _regularization_matrix(M, smooth, p, U_full, periodic=true), - ctrl = _nullspace_solve(R, A_constr, rhs_constr) - ) - is_undef(ctrl) ? undef : [ctrl, aug_bar, rot]; - + assert(control != [], + "nurbs_interp (clamped): singular collocation matrix") + [control, knots, 0]; -// Section: Debug / Visualization -// Module: debug_nurbs_interp() -// Synopsis: Interpolates a NURBS using {{nurbs_interp()}} and displays the curve with informative overlays. -// Topics: NURBS Curves, Interpolation, Debugging -// See Also: nurbs_interp(), debug_nurbs() +// Assemble independently-solved clamped corner segments into one B-spline. // -// Usage: -// debug_nurbs_interp(points, degree, [splinesteps=], [method=], [closed=], [deriv=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [extra_pts=], [smooth=], [width=], [size=], [data_size=], [data_index=], [show_control=], [control_index=], [show_knots=], [show_deriv=], [show_curvature=]); +// All segments must be degree p. Returns [ctrl, xknots, 0] — the standard +// non-segmented result format that callers can pass directly to nurbs_curve / +// debug_nurbs with type="clamped". // -// Description: -// Calls {{nurbs_interp()}} with the supplied arguments and displays the -// resulting curve together with a informative overlays. All interpolation -// arguments are passed through unchanged; see {{nurbs_interp()}} for their -// descriptions. The overlays are: -// . -// - **Data points** — red circles (2D) or spheres (3D) at each input point. -// When `data_index=true` (the default), the point index is printed in red next -// to its marker. Set `data_size=0` to suppress display of the data point dots. -// - **Derivative constraints** — a black arrow at each derivative constrained data point. -// Arrow direction and length reflect the constraint vector, scaled to the average -// point spacing. When the derivative is NAN or a point has a corner, this is shown -// using a black diamond. Shown by default: set `show_deriv=false` to hide. -// - **Curvature constraints** — a transparent green overlay at each curvature-constrained point. -// In 2D the overlay is the osculating circle. In 3D the overlay is a cylinder created -// from the 3D osculating circle. Zero curvature appears as a short green bar. -// Shown by default: Set `show_curvature=false` to hide. -// - **Knots** — Green crosses mark each knot position. Not shown by default. -// Enable with `show_knots=true`. -// - **Control points and polygon** — If you set `show_control=true` then a gray control polygon -// Is displayed. If you additionally set `control_index=true` then blue control-point -// index labels appear. +// BOSL2 clamped knot convention: nurbs_curve() takes xknots of length +// len(control) - degree + 1 +// and internally prepends (degree) zeros and appends (degree) ones to form +// the full clamped knot vector. For a C0 corner at global parameter s_c, +// s_c must appear exactly p times in xknots (giving multiplicity p in the +// full vector = C^0 continuity for degree p). // -// Arguments: -// points = List of 2-D or 3-D data points to interpolate through. -// degree = NURBS degree. -// splinesteps = Steps per knot span for curve rendering. Default: `16` -// --- -// method = Parameterization method; see {{nurbs_interp()}}. Default: `"centripetal"` -// closed = If true, interpolate as a closed loop; if false, interpolate as clamped. Default: `false` -// deriv = Per-point derivative constraints; see {{nurbs_interp()}}. Default: `undef` -// start_deriv = Derivative at first point. Default: `undef` -// end_deriv = Derivative at last point. Default: `undef` -// curvature = Per-point curvature constraints; see {{nurbs_interp()}}. Default: `undef` -// start_curvature = Curvature at first point. Default: `undef` -// end_curvature = Curvature at last point. Default: `undef` -// corners = Corner indices; see {{nurbs_interp()}}. Default: `undef` -// extra_pts = Extra control points; see {{nurbs_interp()}}. Default: `0` -// smooth = Smoothness criterion for `extra_pts`; see {{nurbs_interp()}}. Default: `3` -// width = Stroke width for the curve. Arrows and other overlays scale with this. Default: `1` -// size = Text size for labels on control points and data points. Default: `3*width` -// data_size = Radius of the red data-point markers. Set to `0` to hide data points and their labels. Default: equal to `width` -// data_index = Show index labels next to each data point. Only shown when `data_size > 0`. Default: `true` -// show_control = Show the control polygon. Default: `false` -// control_index = Show control-point index labels if `show_control=true`. Default: `false` -// show_knots = Show knot position markers on the curve. Default: `false` -// show_deriv = Show derivative-constraint arrows. Default: `true` -// show_curvature = Show curvature-constraint circles / disks. Default: `true` +// Segment local knots seg[1] = [0, int_kn..., 1] are remapped to the +// segment's global parameter interval [s_a, s_b] using +// k_global = s_a + (s_b - s_a) * k_local +// which is consistent with any chord-proportional parameterization. -module debug_nurbs_interp(points, degree, splinesteps=16, method="centripetal", - closed=false, deriv=undef, - start_deriv=undef, end_deriv=undef, - curvature=undef, start_curvature=undef, end_curvature=undef, - corners=undef, extra_pts=0, smooth=3, - width=1, size=undef, data_size=undef, - show_control=false, show_knots=false, - show_deriv=true, show_curvature=true, - control_index=false, data_index=true) { - result = nurbs_interp(points, degree, method=method, - closed=closed, deriv=deriv, - start_deriv=start_deriv, end_deriv=end_deriv, - curvature=curvature, start_curvature=start_curvature, - end_curvature=end_curvature, corners=corners, - extra_pts=extra_pts, smooth=smooth); +function _combine_corner_segs(segments, params, corner_idxs, p) = + let( + n_segs = len(segments), + // Global parameter at each corner junction. + cpar = [for (c = corner_idxs) params[c]], + // Global interval [s_a, s_b] for each segment. + seg_sa = [for (s = [0:1:n_segs-1]) s == 0 ? 0 : cpar[s-1]], + seg_sb = [for (s = [0:1:n_segs-1]) s == n_segs-1 ? 1 : cpar[s] ], + // Per-segment interior knots (exclude leading 0 and trailing 1), + // remapped from local [0,1] to the segment's global interval. + seg_gi = [for (s = [0:1:n_segs-1]) + let( + loc = [for (i = [1:1:len(segments[s][1])-2]) segments[s][1][i]], + sa = seg_sa[s], + sb = seg_sb[s] + ) + [for (k = loc) sa + (sb - sa) * k] + ], + // Build combined xknots: + // [0, seg0_int, corner0^p, seg1_int, corner1^p, ..., segN_int, 1] + interior = [for (s = [0:1:n_segs-1]) + each concat( + seg_gi[s], + s < n_segs-1 ? repeat(cpar[s], p) : [] + ) + ], + xknots = [0, each interior, 1], + // Combined control points: all of seg0, then seg[1:1:] for each later seg. + // The first control point of seg s (s >= 1) equals the last of seg s-1 + // because both are the clamped-endpoint interpolant of the shared corner + // data point — so we drop the duplicate. + ctrl = [ + each segments[0][0], + for (s = [1:1:n_segs-1]) + for (j = [1:1:len(segments[s][0])-1]) + segments[s][0][j] + ] + ) + [ctrl, xknots, 0]; - np = len(points); - dim = len(points[0]); - is2d = (dim == 2); - ds = default(data_size, width); - sz = default(size, 3 * width); - ctrl = result[2]; - arrow_scale = path_length(points) / np; - // Helpers project BOSL2 direction constants and pad dimensions automatically. - eff_der = _merge_deriv_list(np-1, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv); - eff_curv = _merge_curv_list(np-1, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature); +// Clamped interpolation with C0 corner joints. +// +// NaN entries in eff_der mark corners: the curve is split into independent +// clamped segments at each corner index. Each segment is solved at the +// highest degree possible: min(p, m-1) where m is the segment point count. +// Degree reduction silently handles short segments (e.g. only 2 or 3 data +// points between adjacent corners). +// +// Segments that needed degree reduction are degree-elevated back to p +// via nurbs_elevate_degree() so that all segments can be assembled into +// a single clamped B-spline. Elevated segments preserve their original +// lower-degree shape but have higher knot multiplicity, so they are +// less smooth at interior knots than natively degree-p segments. - // --- Curve, control polygon, knot markers (delegated to debug_nurbs) --- - debug_nurbs(result, splinesteps=splinesteps, width=width, size=sz, - show_knots=show_knots, show_control=show_control, - show_index=control_index); +function _nurbs_interp_clamped_corners(points, p, method, eff_der, eff_curv, corner_idxs, + extra_pts=0, smooth=3) = + let( + n = len(points) - 1, + params = _interp_params(points, method), + seg_bounds = [0, each corner_idxs, n], + n_segs = len(seg_bounds) - 1, + // Distribute extra_pts across eligible segments proportionally to + // their control-point count (= data-point count = seg_sizes[s]+1). + // Eligible = segments with seg_p >= 3, or seg_p == 2 when smooth == 1. + // Linear (seg_p==1) and quadratic with smooth!=1 get 0 extra_pts. + seg_sizes = [for (s = [0:1:n_segs-1]) + seg_bounds[s+1] - seg_bounds[s]], + seg_degrees = [for (sz = seg_sizes) min(p, sz)], + // Weight = control-point count for eligible segments, 0 for ineligible. + seg_weights = [for (s = [0:1:n_segs-1]) + let(sp = seg_degrees[s]) + (sp >= 3 || (sp == 2 && smooth == 1)) + ? seg_sizes[s] + 1 : 0], + total_weight = max(1, sum(seg_weights)), + // Round up per-segment allocation so total >= extra_pts. + seg_extra = extra_pts == 0 ? repeat(0, n_segs) + : [for (s = [0:1:n_segs-1]) + seg_weights[s] == 0 ? 0 + : ceil(extra_pts * seg_weights[s] / total_weight)], + raw_segments = [for (s = [0:1:n_segs-1]) + let( + i0 = seg_bounds[s], + i1 = seg_bounds[s+1], + seg_pts = [for (k = [i0:1:i1]) points[k]], + // Reduce degree if the segment has fewer than p+1 points. + seg_p = seg_degrees[s], + // Replace NaN corner markers with undef at shared endpoints. + seg_der = is_undef(eff_der) ? undef + : [for (k = [i0:1:i1]) + is_nan(eff_der[k]) ? undef : eff_der[k]], + seg_curv = is_undef(eff_curv) ? undef + : [for (k = [i0:1:i1]) eff_curv[k]], + r = _nurbs_interp_clamped(seg_pts, seg_p, method, + seg_der, undef, undef, + seg_curv, undef, undef, + extra_pts=seg_extra[s], + smooth=smooth) + ) + [r[0], r[1], seg_p] // [control, knots, degree] + ], + // Degree-elevate short segments to the full degree p. + segments = [for (seg = raw_segments) + seg[2] == p ? seg + : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], + type="clamped", times=p - seg[2])) + [elev[2], elev[3], p] + ] + ) + _combine_corner_segs(segments, params, corner_idxs, p); - // --- Corner marks (NaN-deriv corners + explicit corners= indices) --- - // 2D: rotated square stroke. 3D: octahedron wireframe. - nan_corner_idxs = is_undef(eff_der) ? [] - : [for (i = [0:1:np-1]) if (!is_undef(eff_der[i]) && is_nan(eff_der[i])) i]; - explicit_corner_idxs = default(corners, []); - all_corner_idxs = deduplicate(sort(concat(nan_corner_idxs, explicit_corner_idxs))); - for (i = all_corner_idxs) - color("black") - translate(points[i]) - if (is2d) - zrot(45) stroke(rect(3.5*width*ds), width=width/2, closed=true); - else - vnf_wireframe(octahedron(size=5*width), width=width/4); - // --- Derivative arrows (black, half width, arrow2 endcap) --- - // Length = norm(eff_der[i]) * arrow_scale: preserves relative magnitudes; - // arrow_scale = path_length(points)/np gives a geometry-relative baseline. - if (show_deriv && !is_undef(eff_der)) - for (i = [0:1:np-1]) - if (!is_undef(eff_der[i]) && !is_nan(eff_der[i]) && norm(eff_der[i]) > 1e-12) - color("black") - stroke([points[i], points[i] + eff_der[i] * arrow_scale], - width=width/2, - endcap1="butt", endcap2="arrow2"); +// General clamped interpolation with per-point derivative and/or curvature +// constraints. +// +// eff_der: list of n+1 first-derivative specs (undef = unconstrained). +// eff_curv: list of n+1 curvature specs (undef = unconstrained). +// dim=2: signed scalar κ. dim≥3: curvature vector. +// +// Uses Method A (expanded-parameter knot averaging, P&T §9.2.2): for each +// constraint at index k, duplicate params[k] in an expanded sequence ũ — +// once per constraint type (deriv and curvature each add one duplication per +// constrained point). This provides one extra DOF per extra constraint. - // --- Data points and index labels --- - if (ds > 0) - color("red") - move_copies(points) { - if (is2d) circle(r=ds, $fn=16); - else sphere(r=ds, $fn=16); - if (data_index) - if (is2d) - fwd(2*ds) text(text=str($idx), size=sz, anchor=BACK); - else - rot($vpr) back(ds + sz/3) text3d(text=str($idx), size=sz, anchor=CENTER); - } +function _nurbs_interp_clamped_constrained(points, p, method, eff_der, eff_curv, + extra_pts=0, smooth=3) = + let( + n = len(points) - 1, + dim = len(points[0]), + path_len = path_length(points), + path_len2 = path_len * path_len, + params = _interp_params(points, method), - // --- Curvature overlays (rendered last so transparent objects don't occlude dots) --- - // Validator already asserted every curvature-constrained point has a derivative, - // so eff_der[i] is always defined and non-NaN here. - if (show_curvature && !is_undef(eff_curv)) - color([0,1,0,0.1]) - for (i = [0:1:np-1]) - if (!is_undef(eff_curv[i])) { - // cv is either a signed scalar (2D) or a dim-projected vector. - cv = eff_curv[i]; - kn = is_num(cv) ? abs(cv) : norm(cv); - T_hat = unit(eff_der[i]); - if (kn < 1e-12) { - // Zero curvature: fixed-length segment (0.6*arrow_scale) along - // the exact derivative direction. - half = 0.3 * arrow_scale; - stroke([points[i] - T_hat * half, - points[i] + T_hat * half], - width=2*width, endcaps="butt"); - } else { - // Non-zero curvature: osculating circle (2D) or cylinder (3D). - // N_hat: unit principal normal — component of cv perpendicular to T_hat. - N_hat = is_num(cv) - ? // Signed scalar (2D): rotate T_hat 90° left or right by sign(cv). - sign(cv) * [-T_hat[1], T_hat[0]] - : // Vector: strip tangential component via vector_perp, then unit. - unit(vector_perp(T_hat, cv)); - r = 1 / kn; - ctr = points[i] + N_hat * r; - // move(ctr) applies to both 2D and 3D branches. - move(ctr) - if (is2d) { - circle(r=r); - } else { - // Cylinder in the osculating plane: axis along binormal B̂ = T̂ × N̂. - // cyl(orient=binom) aligns the cylinder axis to B̂ without rot(). - binom = cross(T_hat, N_hat); - cyl(h=width, r=r, orient=binom); - } - } - } -} + // First-derivative specs: [index, C'(t) vector]. + // eff_der entries are already dim-projected by _nurbs_interp_clamped. + der_specs = is_undef(eff_der) ? [] + : [for (k = [0:1:n]) if (!is_undef(eff_der[k])) + [k, eff_der[k] * path_len]], + // Curvature specs: [index, C''(t) vector]. + // eff_der and eff_curv are already dim-projected. + // Tangent from eff_der[k] when available; otherwise estimated from chord. + // Speed² from |eff_der[k]|² × path_len² when derivative given. + curv_specs = is_undef(eff_curv) ? [] + : [for (k = [0:1:n]) if (!is_undef(eff_curv[k])) + let( + t_from_der = is_undef(eff_der) ? undef : eff_der[k], + tang_dir = !is_undef(t_from_der) ? t_from_der + : k == 0 ? points[1] - points[0] + : k == n ? points[n] - points[n-1] + : points[k+1] - points[k-1], + v2 = !is_undef(t_from_der) + ? path_len2 * (t_from_der * t_from_der) + : path_len2 + ) + [k, _curv_to_d2(eff_curv[k], tang_dir, dim, v2)] + ], -// Interpolation System Builder (shared by curve & surface) + n_extra_der = len(der_specs), + n_extra_curv = len(curv_specs), + _chk_curv_deg = assert(n_extra_curv == 0 || p >= 2, + "nurbs_interp: curvature constraints require degree >= 2"), + n_constraint = n_extra_der + n_extra_curv, -// Builds the collocation matrix and BOSL2-format knots for a single -// parameterized direction. Returns [N_mat, bosl2_knots]. + // Build knots: average data params, insert at constraint spans, + // then insert extra_pts more at widest spans. + base_int = _avg_knots_interior(params, p), + base_bar = [0, each base_int, 1], + constraint_ts = [for (spec = der_specs) params[spec[0]], + for (spec = curv_specs) params[spec[0]]], + after_constr = _insert_constraint_knots(base_bar, constraint_ts), + // For extra_pts, insert knots at midpoints of the widest spans. + // _widest_span_params silently caps the request at the available span count. + extra_ts = extra_pts == 0 ? [] + : _widest_span_params(after_constr, extra_pts), + aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), + n_spans_pre = len(aug_bar_raw) - 1, + aug_bar_pre = _fix_tiny_spans(aug_bar_raw, n_spans_pre), -function _build_interp_system(params, p, type, extra_pts=0) = - type == "clamped" ? _build_clamped_system(params, p, extra_pts) - : _build_closed_system(params, p, extra_pts); + // Split any knot span that contains multiple data parameters. + // Without this, two data points in the same span produce a + // rank-deficient collocation matrix (Schoenberg-Whitney condition). + occ_splits = _span_split_params(aug_bar_pre, params), + n_occ = len(occ_splits), + M = n + 1 + n_constraint + len(extra_ts) + n_occ, + aug_bar = n_occ == 0 ? aug_bar_pre + : _fix_tiny_spans( + sort([each aug_bar_pre, each occ_splits]), + n_spans_pre + n_occ), + int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], + U_full = _full_clamped_knots(int_kn, p), -function _build_clamped_system(params, p, extra_pts=0) = - let( - n = len(params) - 1, - int_kn = _avg_knots_interior(params, p), - base_bar = [0, each int_kn, 1] - ) - extra_pts == 0 - ? let( - U_full = _full_clamped_knots(int_kn, p), - N_mat = _collocation_matrix(params, n, p, U_full), - knots = [0, each int_kn, 1] - ) - [N_mat, knots] - : let( - extra_ts = _widest_span_params(base_bar, extra_pts), - aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), - occ_splits = _span_split_params(aug_bar_raw, params), - n_occ = len(occ_splits), - // Use len(extra_ts), not extra_pts: _widest_span_params silently caps - // the request at the number of available spans. - M = n + 1 + len(extra_ts) + n_occ, - aug_bar_merged = n_occ == 0 ? aug_bar_raw - : sort([each aug_bar_raw, each occ_splits]), - aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), - aug_int = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], - U_full = _full_clamped_knots(aug_int, p), - // Rectangular (n+1) × M matrix: n+1 data rows, M control columns. - // _collocation_matrix uses a single n for both dimensions, so build inline. - N_mat = [for (k = [0:1:n]) - [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)]], - knots = [0, each aug_int, 1] - ) - [N_mat, knots]; + // Constraint matrix A: interpolation + derivative + curvature rows. + // Dimensions: N_rows × M where N_rows = (n+1) + n_constraint. + N_rows = n + 1 + n_constraint, -function _build_closed_system(params, p, extra_pts=0) = - let( - n = len(params), - base_bar = _fix_tiny_spans(_avg_knots_periodic(params, p)[0], n) - ) - extra_pts == 0 - ? let( - U_full = _full_closed_knots(base_bar, n, p), - col_params = add_scalar(params, base_bar[p]), - T = base_bar[n], - eps_knot = T / n * (p == 2 ? 0.01 : 1e-6), - col_safe = [for (k = [0:1:n-1]) - let( - u = col_params[k], - d_min = min([for (j = [0:1:n + 2*p]) abs(u - U_full[j])]) - ) - d_min < eps_knot ? u + eps_knot : u - ], - N_mat = _collocation_matrix_periodic(col_safe, n, p, U_full) - ) - [N_mat, base_bar] - : let( - extra_ts = _widest_span_params(base_bar, extra_pts), - aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), - occ_splits = _span_split_params(aug_bar_raw, params), - n_occ = len(occ_splits), - // Use len(extra_ts), not extra_pts: _widest_span_params silently caps - // the request at the number of available spans. - M = n + len(extra_ts) + n_occ, - aug_bar_merged = n_occ == 0 ? aug_bar_raw - : sort([each aug_bar_raw, each occ_splits]), - aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), - T = aug_bar[M], - U_full = _full_closed_knots(aug_bar, M, p), - raw_shifted = add_scalar(params, aug_bar[p]), - eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), - col_safe = [for (k = [0:1:n-1]) - let( - u = raw_shifted[k], - d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) - ) - d_min < eps_knot ? u + eps_knot : u + // Interpolation rows: N_{j,p}(t_k) + interp_rows = [for (k = [0:1:n]) + [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] + ], + + // First-derivative rows: N'_{j,p}(t_k) + deriv_rows = [for (spec = der_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) _dnip(j, p, params[k], U_full)] ], - // Rectangular n × M matrix: n data rows, M control columns. - // _collocation_matrix_periodic uses a single n for both dimensions, so - // build inline. Periodic wrapping folds basis j < p by adding N_{j+M}. - N_mat = [for (k = [0:1:n-1]) - [for (j = [0:1:M-1]) - _nip(j, p, col_safe[k], U_full) - + (j < p ? _nip(j + M, p, col_safe[k], U_full) : 0) - ]] - ) - [N_mat, aug_bar]; + // Second-derivative rows: N''_{j,p}(t_k) + curv_rows = [for (spec = curv_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) _d2nip(j, p, params[k], U_full)] + ], -// Build a clamped interpolation system with optional start/end first-derivative rows. -// Extends _build_clamped_system by adding one extra DOF and one extra matrix row -// for each active boundary (start and/or end). Used for surface boundary tangents. -// -// has_sd / has_ed — whether a start / end derivative constraint is active. -// extra_pts — number of additional control points (widens the system). -// Returns [A_matrix, bosl2_knots]. Square when extra_pts==0, rectangular otherwise. -// Row order: interpolation rows (k=0..n), deriv_start (if any), deriv_end (if any). + A_constr = [each interp_rows, each deriv_rows, each curv_rows], + rhs_constr = [each points, + for (spec = der_specs) spec[1], + for (spec = curv_specs) spec[1]], -function _build_clamped_system_with_derivs(params, p, has_sd, has_ed, extra_pts=0) = + knots = [0, each int_kn, 1] + ) + // When M == N_rows (square), try direct solve first. + // When M > N_rows (underdetermined from extra_pts or span splits), + // use null-space method: exact constraints + minimum-energy smoothing. let( - n = len(params) - 1, - n_extra = (has_sd ? 1 : 0) + (has_ed ? 1 : 0), - // Average n+1 data params to get base interior knots, then - // insert extra knots for boundary constraints. Each insertion - // bisects the span containing the constraint parameter - // (largest span first). Constraint params 0 and 1 land in - // the first and last spans respectively. - base_int = _avg_knots_interior(params, p), - base_bar = [0, each base_int, 1], - constraint_ts = [if (has_sd) params[0], if (has_ed) params[n]], - after_constr = _insert_constraint_knots(base_bar, constraint_ts), - // Insert extra_pts knots at widest spans. - extra_ts = extra_pts == 0 ? [] - : _widest_span_params(after_constr, extra_pts), - aug_bar_raw = extra_pts == 0 ? after_constr - : _insert_constraint_knots(after_constr, extra_ts), - occ_splits = extra_pts == 0 ? [] - : _span_split_params(aug_bar_raw, params), - n_occ = len(occ_splits), - M = n + 1 + n_extra + len(extra_ts) + n_occ, - aug_bar_merged = n_occ == 0 ? aug_bar_raw - : sort([each aug_bar_raw, each occ_splits]), - aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), - int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], - U_full = _full_clamped_knots(int_kn, p), - interp_rows = [for (k = [0:1:n]) - [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] - ], - deriv_start = has_sd - ? [[for (j = [0:1:M-1]) _dnip(j, p, params[0], U_full)]] - : [], - deriv_end = has_ed - ? [[for (j = [0:1:M-1]) _dnip(j, p, params[n], U_full)]] - : [], - knots = [0, each int_kn, 1] + direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] ) - [[each interp_rows, each deriv_start, each deriv_end], knots]; - + direct != [] + ? [direct, knots, 0] + : let( + R = _regularization_matrix(M, smooth, p, U_full), + control = _nullspace_solve(R, A_constr, rhs_constr) + ) + assert(!is_undef(control), + "nurbs_interp (clamped+constrained): rank-deficient constraint matrix") + [control, knots, 0]; -// Precompute per-segment interpolation systems for edge-aware surface solves. -// All rows (or columns) share the same averaged parameterization, so the -// collocation matrices only need to be built once. -// -// params = averaged parameter values for this direction -// p = degree -// edge_idxs = sorted list of interior indices where C0 edges occur -// has_sd = if true, first segment gets a start-derivative row -// has_ed = if true, last segment gets an end-derivative row +// ---------- INTERNAL FUNCTIONS ------------ // -// Returns a list of [N_mat, xknots, seg_p, i0, i1, seg_sd, seg_ed] -// per segment, where seg_sd/seg_ed indicate whether that segment's -// system includes a derivative row. +// ---------- CLOSED interpolation ---------- -function _build_edge_systems(params, p, edge_idxs, - has_sd=false, has_ed=false, extra_pts=0, label="") = +function _nurbs_interp_closed(points, degree, method, deriv, curvature, + corners, extra_pts=0, smooth=3) = + let(n = len(points), p = degree, dim = len(points[0])) + assert(n >= p + 1, + str("nurbs_interp (closed): need at least ", p+1, + " points for degree ", p, ", got ", n)) let( - n = len(params) - 1, - seg_bounds = [0, each edge_idxs, n], - n_segs = len(seg_bounds) - 1, - - // Pre-compute seg_p and available interior knot spans per segment. - // For a segment with n_pts data points at degree seg_p, the averaged - // interior knot vector has (n_pts-1) - seg_p entries = that many spans. - seg_n_pts = [for (s = [0:1:n_segs-1]) seg_bounds[s+1] - seg_bounds[s] + 1], - seg_p_arr = [for (npts = seg_n_pts) min(p, npts - 1)], - avail_spans = [for (i = [0:1:n_segs-1]) - max(0, seg_n_pts[i] - 1 - seg_p_arr[i])], - total_avail = sum(avail_spans), - k_use = min(extra_pts, total_avail), - - // Emit one diagnostic when extra_pts exceeds the combined span budget. - _echo = extra_pts > 0 && extra_pts > total_avail && label != "" - ? echo(str("nurbs_interp_surface: extra_pts (", label, "-direction)=", - extra_pts, " exceeds available knot spans across ", - n_segs, " segment(s) (max ", total_avail, " total); ", - "reduced to ", total_avail, ".")) - : 0, - - // Distribute k_use proportionally to avail_spans, capped per segment. - seg_ep = extra_pts == 0 || total_avail == 0 ? repeat(0, n_segs) - : [for (s = [0:1:n_segs-1]) - avail_spans[s] == 0 ? 0 - : min(avail_spans[s], - ceil(k_use * avail_spans[s] / total_avail))] - ) - [for (s = [0:1:n_segs-1]) - let( - i0 = seg_bounds[s], - i1 = seg_bounds[s+1], - seg_par = [for (k = [i0:1:i1]) params[k]], - // Remap to [0,1] - t0 = seg_par[0], - t1 = last(seg_par), - span = max(t1 - t0, 1e-15), - local_p = [for (t = seg_par) (t - t0) / span], - seg_p = seg_p_arr[s], - // Derivative extension requires at least seg_p+1 data points - // (same minimum as basic interpolation); each derivative row - // adds one control point and one equation, keeping the system - // square. Degree-reduced segments with fewer points silently - // skip the constraint. - n_pts = seg_n_pts[s], - seg_sd = has_sd && s == 0 && n_pts >= seg_p + 1, - seg_ed = has_ed && s == n_segs - 1 && n_pts >= seg_p + 1, - // extra_pts only applies when degree >= 2; silently skip for - // degree-reduced (seg_p < 2) segments. - cur_ep = seg_p >= 2 ? seg_ep[s] : 0, - sys = (seg_sd || seg_ed) - ? _build_clamped_system_with_derivs(local_p, seg_p, - seg_sd, seg_ed, cur_ep) - : _build_interp_system(local_p, seg_p, "clamped", cur_ep) - ) - [sys[0], sys[1], seg_p, i0, i1, seg_sd, seg_ed] - ]; + // Detect C0 corners from NaN entries in the RAW deriv list before projection, + // since _merge_deriv_list would leave NaN entries intact but we detect them here. + nan_corners = is_undef(deriv) ? [] + : [for (k = [0:1:n-1]) if (is_nan(deriv[k])) k], + explicit_corners = default(corners, []), + corner_idxs = deduplicate(sort(concat(nan_corners, explicit_corners))), + has_corners = len(corner_idxs) > 0, + // Project derivative and curvature lists (handles BOSL2 direction constants, etc.) + eff_der = _merge_deriv_list(n-1, deriv, dim=dim), + eff_curv = _merge_curv_list(n-1, curvature, dim=dim), -// Solve one row (or column) using precomputed edge-aware systems. -// Each segment is solved independently; short segments are degree-elevated. -// Results are assembled into a single clamped B-spline via _combine_corner_segs. -// -// systems = list from _build_edge_systems -// data = row/column data points (same length as params) -// params = averaged parameter values -// edge_idxs = edge index list (same as passed to _build_edge_systems) -// p = target degree -// start_deriv = derivative vector at start of first segment (undef if none) -// end_deriv = derivative vector at end of last segment (undef if none) + has_dl = !is_undef(eff_der) && + len([for (k = [0:1:n-1]) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k]) > 0, + has_cl = !is_undef(eff_curv) && + len([for (k = [0:1:n-1]) if (!is_undef(eff_curv[k])) k]) > 0, -function _solve_with_edges(systems, data, params, edge_idxs, p, - start_deriv=undef, end_deriv=undef, smooth=3) = - let( - raw_segments = [for (sys = systems) - let( - N_mat = sys[0], - knots = sys[1], - i0 = sys[3], - i1 = sys[4], - seg_p = sys[2], - seg_sd = sys[5], - seg_ed = sys[6], - seg_data = [for (k = [i0:1:i1]) data[k]], - rhs = concat(seg_data, - seg_sd ? [start_deriv] : [], - seg_ed ? [end_deriv] : []), - M = len(N_mat[0]), - N_rows = len(rhs), - // When M > N_rows the segment system is underdetermined (extra_pts). - // Use null-space method: exact interpolation + minimum bending energy. - ctrl = M > N_rows - ? let( - int_kn = [for (i = [1:1:len(knots)-2]) knots[i]], - U_full = _full_clamped_knots(int_kn, seg_p), - eff_smooth = (smooth == 3 && seg_p < 2) ? 2 : smooth, - R = _regularization_matrix(M, eff_smooth, seg_p, U_full) - ) - _nullspace_solve(R, N_mat, rhs) - : linear_solve(N_mat, rhs) - ) - assert(ctrl != [] && !is_undef(ctrl), - str("nurbs_interp_surface: singular edge-segment system for rows/cols ", - i0, "-", i1, " (", i1-i0+1, " points, degree ", seg_p, - seg_sd ? ", start deriv" : "", - seg_ed ? ", end deriv" : "", ")")) - [ctrl, knots, seg_p] - ], - // Degree-elevate short segments to full degree p. - segments = [for (seg = raw_segments) - seg[2] == p ? seg - : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], - type="clamped", times=p - seg[2])) - [elev[2], elev[3], p] - ] + // Every curvature-constrained point must also have a derivative constraint. + bad_curv_pts = is_undef(eff_curv) ? [] : + [for (k = [0:1:n-1]) + if (!is_undef(eff_curv[k]) && + (is_undef(eff_der) || is_undef(eff_der[k]))) + k], + // Curvature at a corner is not allowed. + bad_corner_curv = is_undef(eff_curv) ? [] + : [for (k = corner_idxs) if (!is_undef(eff_curv[k])) k], + // Derivative at an explicit corner is not allowed. + bad_corner_der = is_undef(eff_der) ? [] + : [for (k = explicit_corners) + if (!is_undef(eff_der[k]) && !is_nan(eff_der[k])) k] ) - _combine_corner_segs(segments, params, edge_idxs, p); - + assert(bad_curv_pts == [], + str("nurbs_interp: curvature constraint requires a derivative constraint ", + "at the same point(s): ", bad_curv_pts)) + assert(bad_corner_curv == [], + str("nurbs_interp: curvature constraint cannot coincide with a corner at: ", + bad_corner_curv)) + assert(bad_corner_der == [], + str("nurbs_interp: derivative constraint cannot coincide with a corner at: ", + bad_corner_der)) + // Basic and constrained solvers handle rotation search internally. + // Corner case uses its own rotation (to the first corner). + has_corners + ? _nurbs_interp_closed_corners(points, p, method, eff_der, eff_curv, corner_idxs, + extra_pts=extra_pts, smooth=smooth) + : (has_dl || has_cl || extra_pts > 0) + ? let( + _raw_c = _closed_constrained_solve(points, p, method, eff_der, eff_curv, + 0, extra_pts, smooth), + _chk = assert(!is_undef(_raw_c), + "nurbs_interp (closed+constrained): rank-deficient constraint matrix") + ) _raw_c + : _nurbs_interp_closed_basic(points, p, method, smooth); -// Section: NURBS Surface Interpolation -// Function&Module: nurbs_interp_surface() -// Synopsis: Returns a NURBS surface that passes through a grid of 3D data points. -// SynTags: Geom -// Topics: NURBS Surfaces, Interpolation -// See Also: nurbs_vnf(), nurbs_interp() +// Closed interpolation with C0 corner joints. // -// Usage: As a function, returns a NURBS parameter list: -// nurbs_param = nurbs_interp_surface(points, degree, [method=], [row_wrap=], [col_wrap=], [normal1=], [normal2=], [flat_edges=], [flat_end1=], [flat_end2=], [row_edges=], [col_edges=], [extra_pts=], [smooth=], [first_row_deriv=], [last_row_deriv=], [first_col_deriv=], [last_col_deriv=]); -// Usage: As a module, renders the surface directly: -// nurbs_interp_surface(points, degree, [splinesteps=], [row_wrap=], [col_wrap=], [method=], [extra_pts=], [smooth=], ...) CHILDREN; -// Description: -// Finds the control points and knot vectors for a NURBS surface of the specified degree that passes -// exactly through every data point in a grid of 3D points. The result has -// uniform weights but non-uniform knots so it is actually a non-uniform B-spline. -// When called as a function, the return value is a NURBS parameter list -// `[type, degree, ctrl_grid, knots, undef, undef, uv]` that can be passed -// directly to `{{nurbs_vnf()}}`. The extra return value `uv`, -// described in detail below, enables you to locate your input points in the computed spline -// When called as a module, renders the NURBS surface as geometry. -// . -// Several of the parameters that correspond to parameters for {{nurbs_interp()}} -// can be given as either a scalar or 2-vector. When you give a 2-vector the -// first value applies along the first index of your point data, i.e. from row -// to row, or along columns. The second value applies along the second index, -// i.e. within rows. -// . -// Setting `row_wrap=true` smoothly connects the first and last rows in a loop, -// and `col_wrap=true` smoothly joins the first and last columns. Both false (the default) gives a -// surface with four edges. One true gives a tube; both true gives a torus. -// A tube by itself is not a valid closed manifold in OpenSCAD; you can make it valid by adding caps or -// you can close it into a ball by specifying degenerate edges where the entire edge collapses to -// one identical point. -// . -// **Boundary constraints** -// . -// Flat boundary (`row_wrap=false, col_wrap=false`) — `flat_edges=`. Applies when -// all four surface edges are coplanar. Set `flat_edges` to a 4-element list -// `[first_row, last_row, first_col, last_col]`; each entry is a scalar or per-point list -// giving the derivative scale for that edge (`undef` leaves the edge unconstrained). -// `flat_edges=s` expands to `[s,s,s,s]`. A positive value flares the surface -// outward from the edge; negative turns it inward. -// . -// End normals (one of `row_wrap`/`col_wrap` true, the other false) — `normal1=` and -// `normal2=`. Apply when the specified boundary edge is degenerate (all points -// identical, e.g. a cone tip). The surface is constrained to be normal to the given -// vector at that edge. The vector magnitude controls how broadly the surface spreads. -// . -// Flat ends (one of `row_wrap`/`col_wrap` true, the other false) — `flat_end1=` and -// `flat_end2=`. Apply when the specified boundary edge is coplanar and non-degenerate. -// Constrains the derivative to lie in the plane of the edge. Positive points inward -// (smooth cap attachment); negative flares outward. Scalar or per-point list. -// . -// **Advanced boundary derivatives** — `first_row_deriv=`, `last_row_deriv=`, -// `first_col_deriv=`, and `last_col_deriv=` enforce specific first partial derivatives -// along the four boundary edges. Each accepts a single vector (applied to every -// point on the edge) or a list of vectors (one per point). Vectors are scaled by -// total chord length, so a unit vector matches the parameterization speed. These -// require `row_wrap=false` (for row derivs) or `col_wrap=false` (for col derivs). -// . -// Use with care: the solver enforces derivatives exactly at data points but the -// surface may wander between them. When both u- and v-boundary derivatives are -// active, the cross-derivative is assumed zero at corners. -// . -// **Edges** — `row_edges=` and `col_edges=` insert edges or creases across the surface. -// Use `row_edges=` to specify the indices of rows that will be edges or creases, -// and `col_edges=` to specify the indices of columns that will be edges or creases. -// For a non-wrapped direction, indices must be interior (not first or last). -// If you place edges close together, the effective degree of a narrow patch between -// edges may be reduced. These patches are assembled into a single NURBS so this -// process is transparent to the user. -// . -// **Extra control points** (`extra_pts=`, `smooth=`) — By default the solver uses -// exactly the number of control points needed to satisfy the constraints, which -// gives a unique solution that may be badly behaved. Specifying `extra points=` -// and optionally `smooth=`, works the same way as in -// for {{nurbs_interp()}}. Both parameters can be scalars or 2-vectors to -// provide different values along the two directions. -// . -// **Locating points in the spline** — In order to locate your original data -// points in the spline you need the `u` and `v` nurbs parameter values that you -// can pass to {{nurbs_patch_points()}}. The last return value `uv` gives these: -// `uv[0][j]` is the u parameter for row `j` and `uv[1][k]` is the v parameter -// for column `k`, so the point `points[j][k]` lies at `(uv[0][j], uv[1][k])` -// in NURBS parameter space. -// . -// **Smoothness** — The smoothness of B-splines is determined by the -// degree. If you request a degree p spline then it will be C^(p-1) at -// knot points and C^inf everywhere else. If you request edges then -// these are points where the surface is not differentiable; edges may -// also divide the surface into smaller regions that lack sufficient points -// to support an interpolation of your requested degree: a degree p interpolation -// requires p+1 points. In this case, the interpolation is performed at a lower -// degree and elevated, which means it will be less smooth at knots. -// Arguments: -// points = Rectangular grid of 3D data points -// degree = scalar or 2-vector giving the degree of the B-spline in the two directions. -// splinesteps = (module) Scalar or 2-vector giving the number of segments between each knot in the two directions. Default: 16 -// --- -// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` -// row_wrap = If true, smoothly connect the first row to the last row. Default: false -// col_wrap = If true, smoothly connect the first column to the last column. Default: false -// extra_pts = Scalar or 2-vector giving the number of extra points in the two directions. Default: `0` -// smooth = Scalar or 2-vector giving the smoothness metric for extra points in the two directions: `1` (min polygon length), `2` (min bending), `3` (min bending energy). Default: `3` -// flat_edges = 4-element list `[first_row, last_row, first_col, last_col]` of derivative scales at the four coplanar boundary edges. Each entry is a scalar or per-point list; `undef` leaves that edge unconstrained. Shorthand: `flat_edges=s` → `[s,s,s,s]`. Requires `row_wrap=false, col_wrap=false`. -// normal1 = Surface normal at the first degenerate boundary edge (mixed wrap surface only). -// normal2 = Surface normal at the second degenerate boundary edge (mixed wrap surface only). -// flat_end1 = Inward derivative scale at the first coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. -// flat_end2 = Inward derivative scale at the second coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. -// row_edges = Row indices (or index) of rows that are treated as edges or creases. -// col_edges = Column indices (or index) of columns that are treated as edges or creases -// first_row_deriv = dS/du constraint along u=0 (first row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. -// last_row_deriv = dS/du constraint along u=1 (last row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. -// first_col_deriv = dS/dv constraint along v=0 (first column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. -// last_col_deriv = dS/dv constraint along v=1 (last column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. -// data_size = (module) Radius of data-point markers; 0 suppresses markers. Default: 0 -// data_color = (module) Color for data-point markers. Default: `"red"` -// style = (module) Triangulation style passed to `vnf_vertex_array()`. Default: `"default"` -// reverse = (module) If true, reverses face normals. Default: false -// triangulate = (module) If true, triangulates all quads. Default: false -// caps = (module) Cap both open boundary edges (mixed wrap only). Default: false -// cap1 = (module) Cap the first open boundary edge. -// cap2 = (module) Cap the second open boundary edge. -// cp = (module) Centerpoint for determining intersection anchors or centering the shape. Determines the base of the anchor vector. Can be "centroid", "mean", "box" or a 3D point. Default: "centroid" -// anchor = (module) Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `"origin"` -// spin = (module) Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0` -// orient = (module) Vector to rotate top toward, after spin. See [orient](attachments.scad#subsection-orient). Default: `UP` -// atype = (module) Select "hull" or "intersect" anchor type. Default: "hull" +// Converts the closed-with-corners problem into a clamped-with-corners +// problem: rotate data so the first corner is at the start, duplicate +// that point at the end to close the loop, remap remaining corners to +// the rotated frame, and delegate to _nurbs_interp_clamped_corners. +// +// The result is a clamped B-spline whose first and last control points +// coincide at the corner point. r[3] = "clamped" tells convenience +// functions to render with type="clamped" instead of "closed". -function nurbs_interp_surface(points, degree, method="centripetal", - row_wrap=false, col_wrap=false, - first_row_deriv=undef, last_row_deriv=undef, - first_col_deriv=undef, last_col_deriv=undef, - normal1=undef, normal2=undef, - flat_end1=undef, flat_end2=undef, - flat_edges=undef, - row_edges=undef, col_edges=undef, - extra_pts=0, smooth=3) = - // Preamble: extract shape/edge info needed for closed-direction dispatch. +function _nurbs_interp_closed_corners(points, p, method, deriv, curvature, + corner_idxs, extra_pts=0, smooth=3) = let( - n_rows = len(points), - n_cols = len(points[0]), - ue_norm_pre = is_undef(row_edges) ? undef : force_list(row_edges), - ve_norm_pre = is_undef(col_edges) ? undef : force_list(col_edges), - has_ue_pre = !is_undef(ue_norm_pre) && len(ue_norm_pre) > 0, - has_ve_pre = !is_undef(ve_norm_pre) && len(ve_norm_pre) > 0 + n = len(points), // n points (0..n-1), no repeat + rot = corner_idxs[0], + + // Augmented point list: rotated + closing duplicate of first corner. + aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], + points[rot]], + + // Remap remaining corners to rotated frame. + rot_corners = sort([for (i = [1:1:len(corner_idxs)-1]) + (corner_idxs[i] - rot + n) % n]), + + // Rotate and augment deriv list. + // NaN at the rotation point (now start/end) is cleaned to undef + // since the corner is handled structurally by the clamped endpoints. + aug_der = is_undef(deriv) ? undef : + let(rd = [for (k = [0:1:n-1]) deriv[(k + rot) % n]], + d0 = is_nan(rd[0]) ? undef : rd[0]) + [d0, for (k = [1:1:n-1]) rd[k], d0], + + // Rotate and augment curvature list. + aug_curv = is_undef(curvature) ? undef : + let(rc = [for (k = [0:1:n-1]) curvature[(k + rot) % n]]) + [rc[0], for (k = [1:1:n-1]) rc[k], rc[0]], + + // Solve as clamped with corners. + result = _nurbs_interp_clamped_corners(aug_pts, p, method, + aug_der, aug_curv, + rot_corners, + extra_pts=extra_pts, + smooth=smooth) ) - // col_edges on a closed v-direction: rotate columns so the first crease column - // becomes the v=0/v=1 boundary, append a copy at the end for the C0 seam, - // then recurse with col_wrap=false. Remaining crease indices are shifted - // into the rotated coordinate system. - has_ve_pre && col_wrap ? - let( - ve_sorted = sort(ve_norm_pre), - rot = ve_sorted[0], - new_pts = [for (row = points) - concat([for (l = [rot:1:n_cols-1]) row[l]], - [for (l = [0:1:rot-1]) row[l]], - [row[rot]])], - adj_ve_raw = [for (i = [1:1:len(ve_sorted)-1]) - let(j = (ve_sorted[i] - rot + n_cols) % n_cols) - if (j > 0) j], - adj_ve = len(adj_ve_raw) == 0 ? undef : adj_ve_raw - ) - let(inner = nurbs_interp_surface(new_pts, degree, method=method, - row_wrap=row_wrap, col_wrap=false, - first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, - first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, - normal1=normal1, normal2=normal2, - flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, - row_edges=row_edges, col_edges=adj_ve, - extra_pts=extra_pts, smooth=smooth)) - [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], - [inner[6][0], - list_rotate(select(inner[6][1], 0, n_cols-1), -rot)]] - // row_edges on a closed u-direction: rotate rows so the first crease row - // becomes the u=0/u=1 boundary, append a copy at the end, recurse clamped. - : has_ue_pre && row_wrap ? - let( - ue_sorted = sort(ue_norm_pre), - rot = ue_sorted[0], - new_pts = concat([for (k = [rot:1:n_rows-1]) points[k]], - [for (k = [0:1:rot-1]) points[k]], - [points[rot]]), - adj_ue_raw = [for (i = [1:1:len(ue_sorted)-1]) - let(j = (ue_sorted[i] - rot + n_rows) % n_rows) - if (j > 0) j], - adj_ue = len(adj_ue_raw) == 0 ? undef : adj_ue_raw + // Return with the original rotation index and type override. + [result[0], result[1], rot, "clamped"]; + + +// Returns the maximum number of parameters that fall in any single active +// knot span for cyclic rotation r. A value of 1 is ideal (one parameter +// per span); values > 1 indicate span collisions that may (but do not +// always) cause a singular collocation matrix. + +function _closed_rotation_collision_count(points, n, p, method, r) = + let( + pts = select(points, r, r + n - 1), + rp = _interp_params(pts, method, closed=true), + bk = _fix_tiny_spans(_avg_knots_periodic(rp, p)[0], n), + U = _full_closed_knots(bk, n, p), + ps = add_scalar(rp, bk[p]) + ) + max([for (k = [0:1:n-1]) + len([for (t = ps) if (t >= U[p+k] && t < U[p+k+1]) t]) + ]); + + +// Find the best seam rotation for closed curve interpolation. +// The chord-ratio heuristic (argmax d[i+1]/d[i] + 1) is tried first. +// If it has span collisions, all n rotations are scored by collision +// count and the one with the fewest collisions is chosen. Mild +// collisions (max 2 params per span) often still produce a non-singular +// system, so the final check is deferred to linear_solve(). + +function _find_closed_rotation(points, n, p, method) = + let( + chords = path_segment_lengths(points, closed=true), + ratios = [for (i = [0:1:n-1]) chords[(i+1)%n] / max(chords[i], 1e-15)], + rot0 = (max_index(ratios) + 1) % n + ) + _closed_rotation_collision_count(points, n, p, method, rot0) <= 1 + ? rot0 + : let( + scores = [for (i = [0:1:n-1]) + [_closed_rotation_collision_count(points, n, p, method, i), i]], + best = min_index([for (s = scores) s[0]]) ) - let(inner = nurbs_interp_surface(new_pts, degree, method=method, - row_wrap=false, col_wrap=col_wrap, - first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, - first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, - normal1=normal1, normal2=normal2, - flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, - row_edges=adj_ue, col_edges=col_edges, - extra_pts=extra_pts, smooth=smooth)) - [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], - [list_rotate(select(inner[6][0], 0, n_rows-1), -rot), - inner[6][1]]] - // Normal path: both directions already clamped, or no conflicting edge constraints. - : let( - p_u = is_list(degree) ? degree[0] : degree, - p_v = is_list(degree) ? degree[1] : degree, - ep_u = is_list(extra_pts) ? extra_pts[0] : extra_pts, - ep_v = is_list(extra_pts) ? extra_pts[1] : extra_pts, - smooth_u = is_list(smooth) ? smooth[0] : smooth, - smooth_v = is_list(smooth) ? smooth[1] : smooth, - n_rows = len(points), - n_cols = len(points[0]), - dim = len(points[0][0]), - // Scalar-vector promotion: if the caller passes a single vector instead of - // a list of vectors, repeat() it to the required length. A single vector - // is detected as a list whose first element is a number, not a list. - first_row_deriv = is_undef(first_row_deriv) || is_list(first_row_deriv[0]) ? first_row_deriv - : repeat(first_row_deriv, n_cols), - last_row_deriv = is_undef(last_row_deriv) || is_list(last_row_deriv[0]) ? last_row_deriv - : repeat(last_row_deriv, n_cols), - first_col_deriv = is_undef(first_col_deriv) || is_list(first_col_deriv[0]) ? first_col_deriv - : repeat(first_col_deriv, n_rows), - last_col_deriv = is_undef(last_col_deriv) || is_list(last_col_deriv[0]) ? last_col_deriv - : repeat(last_col_deriv, n_rows), - // Treat an all-undef derivative list the same as undef. - has_sud = !is_undef(first_row_deriv) && num_defined(first_row_deriv) > 0, - has_eud = !is_undef(last_row_deriv) && num_defined(last_row_deriv) > 0, - has_svd = !is_undef(first_col_deriv) && num_defined(first_col_deriv) > 0, - has_evd = !is_undef(last_col_deriv) && num_defined(last_col_deriv) > 0, - has_sn = !is_undef(normal1), - has_en = !is_undef(normal2), - // normal1/normal2: apex edges only (all boundary points identical, e.g. cone tip). - // Auto-detect u=0/v=0 direction; u=0 (first row) takes priority. - start_u_apex = has_sn && max([for (pt = points[0]) norm(pt - points[0][0])]) < 1e-10, - start_v_apex = has_sn && max([for (k = [0:1:n_rows-1]) norm(points[k][0] - points[0][0])]) < 1e-10, - end_u_apex = has_en && max([for (pt = points[n_rows-1]) norm(pt - points[n_rows-1][0])]) < 1e-10, - end_v_apex = has_en && max([for (k = [0:1:n_rows-1]) norm(points[k][n_cols-1] - points[0][n_cols-1])]) < 1e-10, - has_sun = has_sn && start_u_apex, - has_eun = has_en && end_u_apex, - has_svn = has_sn && !start_u_apex && start_v_apex, - has_evn = has_en && !end_u_apex && end_v_apex, - start_u_degen = start_u_apex, - start_v_degen = start_v_apex, - end_u_degen = end_u_apex, - end_v_degen = end_v_apex, - // flat_end1/flat_end2: coplanar non-collinear edges (points span a plane). - // Scalar or per-point list. positive = closes inward, negative = flares outward. - // Direction is determined by the clamped direction of the surface: - // row_wrap=false → flat_end applies to row boundaries (u-direction, first/last row). - // col_wrap=false → flat_end applies to column boundaries (v-direction, first/last col). - // Exactly one direction must be clamped (enforced by assertion below). - has_fe1 = !is_undef(flat_end1), - has_fe2 = !is_undef(flat_end2), - has_fe1_u = has_fe1 && !row_wrap, - has_fe1_v = has_fe1 && !col_wrap, - has_fe2_u = has_fe2 && !row_wrap, - has_fe2_v = has_fe2 && !col_wrap, - // Boundary edges for coplanar validation. - fe1_edge = has_fe1_u ? points[0] - : has_fe1_v ? [for (k = [0:1:n_rows-1]) points[k][0]] - : [], - fe2_edge = has_fe2_u ? points[n_rows-1] - : has_fe2_v ? [for (k = [0:1:n_rows-1]) points[k][n_cols-1]] - : [], - fe1_ok = !has_fe1 || (_is_coplanar_pts(fe1_edge) && !is_undef(_pts_plane_normal(fe1_edge))), - fe2_ok = !has_fe2 || (_is_coplanar_pts(fe2_edge) && !is_undef(_pts_plane_normal(fe2_edge))), - // flat_edges= parsing: 4-element list [first_row, last_row, first_col, last_col]. - // Scalar shorthand: flat_edges=s expands to [s, s, s, s]. - fe_norm = !is_undef(flat_edges) && !is_list(flat_edges) - ? [flat_edges, flat_edges, flat_edges, flat_edges] - : flat_edges, - has_fe = !is_undef(fe_norm), - fe_su = has_fe ? fe_norm[0] : undef, - fe_eu = has_fe ? fe_norm[1] : undef, - fe_sv = has_fe ? fe_norm[2] : undef, - fe_ev = has_fe ? fe_norm[3] : undef, - has_fesu = has_fe && !is_undef(fe_su), - has_feeu = has_fe && !is_undef(fe_eu), - has_fesv = has_fe && !is_undef(fe_sv), - has_feev = has_fe && !is_undef(fe_ev), - // Edge (C0 discontinuity) support. Singleton promotion: scalar → list. - ue_norm = is_undef(row_edges) ? undef : force_list(row_edges), - ve_norm = is_undef(col_edges) ? undef : force_list(col_edges), - has_ue = !is_undef(ue_norm) && len(ue_norm) > 0, - has_ve = !is_undef(ve_norm) && len(ve_norm) > 0 + scores[best][1]; + + +// Solve a basic closed interpolation for a specific rotation. +// Returns [control, bar_knots, rot] or undef if singular. + +function _closed_basic_solve(points, n, p, method, rot, smooth=3) = + let( + dim = len(points[0]), + pts = select(points, rot, rot + n - 1), + raw_params = _interp_params(pts, method, closed=true), + bar_knots = _fix_tiny_spans(_avg_knots_periodic(raw_params, p)[0], n), + U_full = _full_closed_knots(bar_knots, n, p), + params = add_scalar(raw_params, bar_knots[p]), + N_mat = _collocation_matrix_periodic(params, n, p, U_full), + control = linear_solve(N_mat, pts) ) - assert(is_list(points) && n_rows >= 2, - "nurbs_interp_surface: need at least 2 rows") - assert(n_cols >= 2, - "nurbs_interp_surface: need at least 2 columns") - assert(min([for (row = points) len(row)]) == max([for (row = points) len(row)]), - "nurbs_interp_surface: all rows must have the same number of columns") - assert(is_num(p_u) && p_u >= 1 && is_num(p_v) && p_v >= 1, - "nurbs_interp_surface: degree must be >= 1") - assert(method == "length" || method == "centripetal" || method == "dynamic" - || method == "foley" || method == "fang", - str("nurbs_interp_surface: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) - assert(is_num(ep_u) && ep_u >= 0 && ep_u == floor(ep_u), - str("nurbs_interp_surface: extra_pts (u) must be a non-negative integer, got ", ep_u)) - assert(is_num(ep_v) && ep_v >= 0 && ep_v == floor(ep_v), - str("nurbs_interp_surface: extra_pts (v) must be a non-negative integer, got ", ep_v)) - assert(ep_u == 0 || p_u >= 2, - "nurbs_interp_surface: extra_pts in u-direction requires u-degree >= 2") - assert(ep_v == 0 || p_v >= 2, - "nurbs_interp_surface: extra_pts in v-direction requires v-degree >= 2") - assert(n_rows >= p_u + 1, - str("nurbs_interp_surface: need at least ", p_u+1, - " rows for u-degree ", p_u, ", got ", n_rows)) - assert(n_cols >= p_v + 1, - str("nurbs_interp_surface: need at least ", p_v+1, - " columns for v-degree ", p_v, ", got ", n_cols)) - assert(!(has_sud || has_eud || has_sun || has_eun || has_fesu || has_feeu || has_fe1_u || has_fe2_u) || !row_wrap, - "nurbs_interp_surface: u-direction derivative/normal/flat_end/flat_edges params require row_wrap=false") - assert(!(has_svd || has_evd || has_svn || has_evn || has_fesv || has_feev || has_fe1_v || has_fe2_v) || !col_wrap, - "nurbs_interp_surface: v-direction derivative/normal/flat_end/flat_edges params require col_wrap=false") - assert(!has_sud || len(first_row_deriv) == n_cols, - str("nurbs_interp_surface: first_row_deriv must have ", n_cols, - " entries (one per column), got ", is_undef(first_row_deriv) ? 0 : len(first_row_deriv))) - assert(!has_eud || len(last_row_deriv) == n_cols, - str("nurbs_interp_surface: last_row_deriv must have ", n_cols, - " entries (one per column), got ", is_undef(last_row_deriv) ? 0 : len(last_row_deriv))) - assert(!has_svd || len(first_col_deriv) == n_rows, - str("nurbs_interp_surface: first_col_deriv must have ", n_rows, - " entries (one per row), got ", is_undef(first_col_deriv) ? 0 : len(first_col_deriv))) - assert(!has_evd || len(last_col_deriv) == n_rows, - str("nurbs_interp_surface: last_col_deriv must have ", n_rows, - " entries (one per row), got ", is_undef(last_col_deriv) ? 0 : len(last_col_deriv))) - // normal1/normal2 assertions: apex edges only. - assert(!has_sn || (start_u_degen || start_v_degen), - "nurbs_interp_surface: normal1 requires a degenerate start edge (first row or first column must be all the same point)") - assert(!has_en || (end_u_degen || end_v_degen), - "nurbs_interp_surface: normal2 requires a degenerate end edge (last row or last column must be all the same point)") - assert(!has_sn || !(start_u_degen && start_v_degen), - "nurbs_interp_surface: normal1 is ambiguous — both u=0 and v=0 edges are degenerate; use first_row_deriv or first_col_deriv explicitly") - assert(!has_en || !(end_u_degen && end_v_degen), - "nurbs_interp_surface: normal2 is ambiguous — both u=1 and v=1 edges are degenerate; use last_row_deriv or last_col_deriv explicitly") - assert(!(has_sun && has_sud), - "nurbs_interp_surface: normal1 resolves to u-direction but first_row_deriv was also given") - assert(!(has_eun && has_eud), - "nurbs_interp_surface: normal2 resolves to u-direction but last_row_deriv was also given") - assert(!(has_svn && has_svd), - "nurbs_interp_surface: normal1 resolves to v-direction but first_col_deriv was also given") - assert(!(has_evn && has_evd), - "nurbs_interp_surface: normal2 resolves to v-direction but last_col_deriv was also given") - // flat_end1/flat_end2 assertions. - // Direction is determined by the clamped type; surface must be mixed clamped/closed. - assert(!has_fe1 || (row_wrap != col_wrap), - "nurbs_interp_surface: flat_end1 requires the surface to be clamped in one direction and closed in the other") - assert(!has_fe2 || (row_wrap != col_wrap), - "nurbs_interp_surface: flat_end2 requires the surface to be clamped in one direction and closed in the other") - assert(fe1_ok, - has_fe1_u - ? "nurbs_interp_surface: flat_end1 requires the first row (u=0 boundary) to be coplanar and non-collinear" - : "nurbs_interp_surface: flat_end1 requires the first column (v=0 boundary) to be coplanar and non-collinear. If your first row is coplanar, try row_wrap=true, col_wrap=false.") - assert(fe2_ok, - has_fe2_u - ? "nurbs_interp_surface: flat_end2 requires the last row (u=1 boundary) to be coplanar and non-collinear" - : "nurbs_interp_surface: flat_end2 requires the last column (v=1 boundary) to be coplanar and non-collinear. If your last row is coplanar, try row_wrap=true, col_wrap=false.") - assert(!(has_fe1_u && has_sud), - "nurbs_interp_surface: flat_end1 conflicts with first_row_deriv") - assert(!(has_fe2_u && has_eud), - "nurbs_interp_surface: flat_end2 conflicts with last_row_deriv") - assert(!(has_fe1_v && has_svd), - "nurbs_interp_surface: flat_end1 conflicts with first_col_deriv") - assert(!(has_fe2_v && has_evd), - "nurbs_interp_surface: flat_end2 conflicts with last_col_deriv") - assert(!(has_fe1_u && has_fesu), - "nurbs_interp_surface: flat_end1 conflicts with flat_edges[0] on same edge") - assert(!(has_fe2_u && has_feeu), - "nurbs_interp_surface: flat_end2 conflicts with flat_edges[1] on same edge") - assert(!(has_fe1_v && has_fesv), - "nurbs_interp_surface: flat_end1 conflicts with flat_edges[2] on same edge") - assert(!(has_fe2_v && has_feev), - "nurbs_interp_surface: flat_end2 conflicts with flat_edges[3] on same edge") - assert(!has_fe1 || is_num(flat_end1) || len(flat_end1) == (has_fe1_u ? n_cols : n_rows), - str("nurbs_interp_surface: flat_end1 list must have ", has_fe1_u ? n_cols : n_rows, " entries")) - assert(!has_fe2 || is_num(flat_end2) || len(flat_end2) == (has_fe2_u ? n_cols : n_rows), - str("nurbs_interp_surface: flat_end2 list must have ", has_fe2_u ? n_cols : n_rows, " entries")) - // flat_edges assertions. - assert(!has_fe || (is_list(fe_norm) && len(fe_norm) == 4), - "nurbs_interp_surface: flat_edges must be a scalar or 4-element list [first_row, last_row, first_col, last_col]") - assert(!(has_fesu && has_sud), - "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with first_row_deriv") - assert(!(has_feeu && has_eud), - "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with last_row_deriv") - assert(!(has_fesv && has_svd), - "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with first_col_deriv") - assert(!(has_feev && has_evd), - "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with last_col_deriv") - assert(!(has_fesu && has_sun), - "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with normal1 on same edge") - assert(!(has_feeu && has_eun), - "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with normal2 on same edge") - assert(!(has_fesv && has_svn), - "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with normal1 on same edge") - assert(!(has_feev && has_evn), - "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with normal2 on same edge") - assert(!has_fesu || !is_list(fe_su) || len(fe_su) == n_cols, - str("nurbs_interp_surface: flat_edges[0] scale list must have ", n_cols, " entries (one per column)")) - assert(!has_feeu || !is_list(fe_eu) || len(fe_eu) == n_cols, - str("nurbs_interp_surface: flat_edges[1] scale list must have ", n_cols, " entries (one per column)")) - assert(!has_fesv || !is_list(fe_sv) || len(fe_sv) == n_rows, - str("nurbs_interp_surface: flat_edges[2] scale list must have ", n_rows, " entries (one per row)")) - assert(!has_feev || !is_list(fe_ev) || len(fe_ev) == n_rows, - str("nurbs_interp_surface: flat_edges[3] scale list must have ", n_rows, " entries (one per row)")) - // Edge (C0) validation. - assert(!has_ue || !row_wrap, - "nurbs_interp_surface: row_edges requires row_wrap=false") - assert(!has_ve || !col_wrap, - "nurbs_interp_surface: col_edges requires col_wrap=false") - assert(!has_ue || (min(ue_norm) >= 1 && max(ue_norm) <= n_rows-2), - str("nurbs_interp_surface: row_edges indices must be interior (1..", n_rows-2, ")")) - assert(!has_ve || (min(ve_norm) >= 1 && max(ve_norm) <= n_cols-2), - str("nurbs_interp_surface: col_edges indices must be interior (1..", n_cols-2, ")")) - // row_edges / col_edges are compatible with same-direction boundary derivatives, - // normals, and flat_edges: the first/last segment of the edge-aware system - // carries the boundary derivative constraint. + control != [] ? [control, bar_knots, rot] + : // Singular — fall back to constrained optimization. + let( + M = n, + R = _regularization_matrix(M, smooth, p, U_full, periodic=true), + ctrl = _nullspace_solve(R, N_mat, pts) + ) + is_undef(ctrl) ? undef : [ctrl, bar_knots, rot]; + + +// Basic closed interpolation — start-point independent. +// +// Implements the cyclic chord-length parameterization and cyclic knot +// averaging of Piegl & Tiller §9.2.4. In exact arithmetic the resulting +// curve is the same regardless of which data point is listed first; only +// the parametric origin changes (the curve is just reparameterized). +// The chord-ratio heuristic selects the starting rotation. + +function _nurbs_interp_closed_basic(points, p, method, smooth=3) = let( - // Boundary plane for flat_edges=: cross product of two perimeter vectors. - // Guarded so degenerate geometry can't produce NaN when flat_edges is unused. - fe_e1 = has_fe ? (points[0][n_cols-1] - points[0][0]) : [1,0,0], - fe_e2 = has_fe ? (points[n_rows-1][0] - points[0][0]) : [0,1,0], - fe_N_raw = has_fe ? cross(fe_e1, fe_e2) : [0,0,1], - fe_N_hat = fe_N_raw / max(norm(fe_N_raw), 1e-15), - // Per-edge flat-outward derivative lists; undef when edge not active. - // Direction at each point: from adjacent interior point toward edge, - // projected into the boundary plane, then normalized and scaled. - flat_su_der = !has_fesu ? undef : - [for (j = [0:1:n_cols-1]) - let( - d = points[1][j] - points[0][j], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_su) ? fe_su[j] : fe_su - ) d_hat * s], - flat_eu_der = !has_feeu ? undef : - [for (j = [0:1:n_cols-1]) - let( - d = points[n_rows-1][j] - points[n_rows-2][j], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_eu) ? fe_eu[j] : fe_eu - ) d_hat * s], - flat_sv_der = !has_fesv ? undef : - [for (k = [0:1:n_rows-1]) - let( - d = points[k][1] - points[k][0], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_sv) ? fe_sv[k] : fe_sv - ) d_hat * s], - flat_ev_der = !has_feev ? undef : - [for (k = [0:1:n_rows-1]) - let( - d = points[k][n_cols-1] - points[k][n_cols-2], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_ev) ? fe_ev[k] : fe_ev - ) d_hat * s] + n = len(points), + rot0 = _find_closed_rotation(points, n, p, method), + result0 = _closed_basic_solve(points, n, p, method, rot0, smooth) ) - assert(!has_fesu || min([for (j = [0:1:n_cols-1]) let(d = points[1][j] - points[0][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[0] (first_row) direction is perpendicular to the boundary plane at one or more points") - assert(!has_feeu || min([for (j = [0:1:n_cols-1]) let(d = points[n_rows-1][j] - points[n_rows-2][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[1] (last_row) direction is perpendicular to the boundary plane at one or more points") - assert(!has_fesv || min([for (k = [0:1:n_rows-1]) let(d = points[k][1] - points[k][0], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[2] (first_col) direction is perpendicular to the boundary plane at one or more points") - assert(!has_feev || min([for (k = [0:1:n_rows-1]) let(d = points[k][n_cols-1] - points[k][n_cols-2], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[3] (last_col) direction is perpendicular to the boundary plane at one or more points") - assert(!has_fe || is_coplanar(concat( - points[0], points[n_rows-1], - [for (k = [1:1:n_rows-2]) points[k][0]], - [for (k = [1:1:n_rows-2]) points[k][n_cols-1]]), eps=1e-6), - "nurbs_interp_surface: flat_edges= requires all four boundary edges to be coplanar") + assert(!is_undef(result0), + "nurbs_interp (closed): singular system — try adding extra_pts= to relax the knot structure") + result0; + + +// Solve a constrained closed interpolation for a specific rotation. +// Returns [control, aug_bar, rot] or undef if singular. +// +// eff_der: list of n first-derivative specs (undef = unconstrained). +// eff_curv: list of n curvature specs (undef = unconstrained). +// dim=2: signed scalar κ or 2D vector. dim≥3: curvature vector. +// +// Knot construction: standard periodic averaging of N data params, +// then insert one knot per constraint at the midpoint of the span +// containing its parameter (largest span first). +// M control points use standard BOSL2 periodic aliasing: +// B_j(t) = N_j(t) + (j

flat_end1/flat_end2 (coplanar) > flat_edges > explicit *_der=. - // Apex (all boundary points identical): fan outward from apex, user axis vector N. - // End-edge apex tangents are negated because _apex_tangents() returns outward - // (apex→ring) vectors; negating gives inward (ring→apex), making the surface - // converge to the apex tip at the correct parametric direction. - // Coplanar (flat_end): _coplanar_inward_tangents() returns in-plane vectors - // oriented toward the polygon interior using the polygon winding order. - // Positive scale closes inward, negative flares outward. - // flat_end1 result is negated: _coplanar_inward_tangents returns outward - // for the start boundary; negating gives the correct inward direction. - // flat_end2 uses the same function without negation (end boundary sign matches). - // Periodic tangent differences used when the cross-direction is "closed". - first_row_deriv_eff = has_sun - ? _apex_tangents(normal1, points[0][0], points[1]) - : has_fe1_u - ? [for (v = _coplanar_inward_tangents(flat_end1, points[0], points[1], - periodic=col_wrap)) -v] - : has_fesu ? flat_su_der - : first_row_deriv, - last_row_deriv_eff = has_eun - ? [for (v = _apex_tangents(normal2, points[n_rows-1][0], points[n_rows-2])) -v] - : has_fe2_u - ? _coplanar_inward_tangents(flat_end2, points[n_rows-1], points[n_rows-2], - periodic=col_wrap) - : has_feeu ? flat_eu_der - : last_row_deriv, - first_col_deriv_eff = has_svn - ? _apex_tangents(normal1, points[0][0], - [for (k = [0:1:n_rows-1]) points[k][1]]) - : has_fe1_v - ? [for (v = _coplanar_inward_tangents(flat_end1, - [for (k = [0:1:n_rows-1]) points[k][0]], - [for (k = [0:1:n_rows-1]) points[k][1]], - periodic=row_wrap)) -v] - : has_fesv ? flat_sv_der - : first_col_deriv, - last_col_deriv_eff = has_evn - ? [for (v = _apex_tangents(normal2, points[0][n_cols-1], - [for (k = [0:1:n_rows-1]) points[k][n_cols-2]])) -v] - : has_fe2_v - ? _coplanar_inward_tangents(flat_end2, - [for (k = [0:1:n_rows-1]) points[k][n_cols-1]], - [for (k = [0:1:n_rows-1]) points[k][n_cols-2]], - periodic=row_wrap) - : has_feev ? flat_ev_der - : last_col_deriv, - has_sud_eff = has_sud || has_sun || has_fesu || has_fe1_u, - has_eud_eff = has_eud || has_eun || has_feeu || has_fe2_u, - has_svd_eff = has_svd || has_svn || has_fesv || has_fe1_v, - has_evd_eff = has_evd || has_evn || has_feev || has_fe2_v + n = len(points), + dim = len(points[0]), + path_len = path_length(points, closed=true), + path_len2 = path_len * path_len, + + // Rotate data, deriv, and curvature lists by the same offset so constraint + // associations are preserved after rotation. + pts = select(points, rot, rot + n - 1), + der_r = is_undef(eff_der) ? undef : select(eff_der, rot, rot + n - 1), + curv_r = is_undef(eff_curv) ? undef : select(eff_curv, rot, rot + n - 1), + + raw_params = _interp_params(pts, method, closed=true), + + // First-derivative specs: [index, C'(t) vector]. + // eff_der entries are already dim-projected by _nurbs_interp_closed. + der_specs = is_undef(der_r) ? [] + : [for (k = [0:1:n-1]) if (!is_undef(der_r[k])) + [k, der_r[k] * path_len]], + + // Curvature specs: [index, C''(t) vector]. + // eff_curv entries are already dim-projected by _nurbs_interp_closed. + // Tangent from explicit derivative (required by caller; validated upstream). + curv_specs = is_undef(curv_r) ? [] + : [for (k = [0:1:n-1]) if (!is_undef(curv_r[k])) + let( + tang_dir = der_r[k], + v2 = path_len2 * (tang_dir * tang_dir) + ) + [k, _curv_to_d2(curv_r[k], tang_dir, dim, v2)] + ], + + n_extra_der = len(der_specs), + n_extra_curv = len(curv_specs), + _chk_curv_deg = assert(n_extra_curv == 0 || p >= 2, + "nurbs_interp: curvature constraints require degree >= 2"), + n_constraint = n_extra_der + n_extra_curv, + + // Build bar_knots: standard periodic averaging of N data + // params, then insert knots for constraints and extra_pts. + base_bar = _avg_knots_periodic(raw_params, p)[0], + constraint_idxs = [for (spec = der_specs) spec[0], + for (spec = curv_specs) spec[0]], + constraint_ts = [for (k = constraint_idxs) raw_params[k]], + after_constr = _insert_constraint_knots(base_bar, constraint_ts), + // _widest_span_params silently caps the request at the available span count. + extra_ts = extra_pts == 0 ? [] + : _widest_span_params(after_constr, extra_pts), + aug_bar_raw = _insert_constraint_knots(after_constr, extra_ts), + // M_pre = span count of aug_bar_raw. Use len()-1 rather than + // n+n_constraint+extra_pts so it reflects the actual knots inserted. + M_pre = len(aug_bar_raw) - 1, + aug_bar_pre = _fix_tiny_spans(aug_bar_raw, M_pre), + + // Split any knot span that contains multiple data parameters. + // Without this, two data points in the same span produce a + // rank-deficient collocation matrix (§9.2.1 Schoenberg-Whitney). + occ_splits = _span_split_params(aug_bar_pre, raw_params), + n_occ = len(occ_splits), + M = M_pre + n_occ, + aug_bar = n_occ == 0 ? aug_bar_pre + : _fix_tiny_spans( + sort([each aug_bar_pre, each occ_splits]), + M), + T = aug_bar[M], + U_full = _full_closed_knots(aug_bar, M, p), + + // Map raw params into active domain [aug_bar[p], aug_bar[p]+T]. + // Nudge any shifted parameter that lands on or near a knot. + raw_shifted = add_scalar(raw_params, aug_bar[p]), + eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), + params = [for (k = [0:1:n-1]) + let( + u = raw_shifted[k], + d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) + ) + d_min < eps_knot ? u + eps_knot : u + ], + + // Constraint matrix A: interpolation + derivative + curvature rows. + N_rows = n + n_constraint, + + // Interpolation rows: aliased basis for M control points + interp_rows = [for (k = [0:1:n-1]) + [for (j = [0:1:M-1]) + _nip(j, p, params[k], U_full) + + (j < p ? _nip(j + M, p, params[k], U_full) : 0) + ] + ], + + // First-derivative rows: aliased derivative basis + deriv_rows = [for (spec = der_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) + _dnip(j, p, params[k], U_full) + + (j < p ? _dnip(j + M, p, params[k], U_full) : 0) + ] + ], + + // Second-derivative rows: aliased second-derivative basis + curv_rows = [for (spec = curv_specs) + let(k = spec[0]) + [for (j = [0:1:M-1]) + _d2nip(j, p, params[k], U_full) + + (j < p ? _d2nip(j + M, p, params[k], U_full) : 0) + ] + ], + + A_constr = [each interp_rows, each deriv_rows, each curv_rows], + rhs_constr = [each pts, + for (spec = der_specs) spec[1], + for (spec = curv_specs) spec[1]] ) - // row_edges / col_edges boundary-derivative segment-size checks. - // A derivative-carrying edge segment needs at least 3 rows/columns; - // with only 2 the degree-reduced knot vector becomes degenerate. - assert(!(has_ue && has_sud_eff && ue_norm[0] + 1 < 3), - !has_ue ? "" : - str("nurbs_interp_surface: row_edges=", ue_norm, - " creates a ", ue_norm[0]+1, "-row first segment (rows 0-", - ue_norm[0], ") which is too short to carry the start-u derivative constraint. ", - "Move the first row_edges index to at least 2")) - assert(!(has_ue && has_eud_eff && n_rows - last(ue_norm) < 3), - !has_ue ? "" : - str("nurbs_interp_surface: row_edges=", ue_norm, - " creates a ", n_rows - last(ue_norm), "-row last segment (rows ", - last(ue_norm), "-", n_rows-1, ") which is too short to carry the end-u derivative constraint. ", - "Move the last row_edges index to at most ", n_rows - 3)) - assert(!(has_ve && has_svd_eff && ve_norm[0] + 1 < 3), - !has_ve ? "" : - str("nurbs_interp_surface: col_edges=", ve_norm, - " creates a ", ve_norm[0]+1, "-column first segment (columns 0-", - ve_norm[0], ") which is too short to carry the start-v derivative constraint. ", - "Move the first col_edges index to at least 2")) - assert(!(has_ve && has_evd_eff && n_cols - last(ve_norm) < 3), - !has_ve ? "" : - str("nurbs_interp_surface: col_edges=", ve_norm, - " creates a ", n_cols - last(ve_norm), "-column last segment (columns ", - last(ve_norm), "-", n_cols-1, ") which is too short to carry the end-v derivative constraint. ", - "Move the last col_edges index to at most ", n_cols - 3)) + // When M == N_rows (square), try direct solve first. + // When M > N_rows (underdetermined from extra_pts or span splits), + // use null-space method: exact constraints + minimum-energy smoothing. let( - // Averaged parameterization in each direction - u_params = _surface_params_u(points, method, row_wrap), - v_params = _surface_params_v(points, method, col_wrap), + direct = M == N_rows ? linear_solve(A_constr, rhs_constr) : [] + ) + direct != [] + ? [direct, aug_bar, rot] + : let( + R = _regularization_matrix(M, smooth, p, U_full, periodic=true), + ctrl = _nullspace_solve(R, A_constr, rhs_constr) + ) + is_undef(ctrl) ? undef : [ctrl, aug_bar, rot]; - // Per-row v-direction path lengths for scaling v-boundary tangents. - // Follows the curve convention: user passes normalized vectors; code - // scales by total chord length so a unit vector gives natural speed. - v_path_lens = [for (k = [0:1:n_rows-1]) path_length(points[k])], - // Per-column u-direction path lengths for scaling u-boundary tangents. - u_path_lens = [for (l = [0:1:n_cols-1]) - path_length([for (k = [0:1:n_rows-1]) points[k][l]])], - // ----- Build v-direction system ----- - // When col_edges is active, precompute per-segment collocation systems. - // Otherwise use the standard (or derivative-extended) system. - v_edge_sys = has_ve - ? _build_edge_systems(v_params, p_v, ve_norm, - has_sd=has_svd_eff, - has_ed=has_evd_eff, - extra_pts=ep_v, label="v") : undef, - v_sys = has_ve ? undef - : (has_svd_eff || has_evd_eff) - ? _build_clamped_system_with_derivs(v_params, p_v, has_svd_eff, has_evd_eff, ep_v) - : _build_interp_system(v_params, p_v, col_wrap ? "closed" : "clamped", ep_v), - N_v = has_ve ? undef : v_sys[0], - // When underdetermined (extra_pts), build regularization matrix for v. - M_v = has_ve ? undef : len(N_v[0]), - N_rows_v = has_ve ? undef : len(N_v), - ns_v = !has_ve && M_v > N_rows_v, - R_reg_v = !ns_v ? undef - : let(vk = v_sys[1], - vint = !col_wrap - ? [for (i = [1:1:len(vk)-2]) vk[i]] - : undef, - vU = !col_wrap - ? _full_clamped_knots(vint, p_v) - : _full_closed_knots(vk, M_v, p_v)) - _regularization_matrix(M_v, smooth_v, p_v, vU, periodic=col_wrap), +// Interpolation System Builder (shared by curve & surface) - // ----- Pass 1: Interpolate rows in v-direction ----- - // With col_edges: solve each row via edge-aware segmented system. - // Without: same A_v matrix for every row; only the RHS changes per row. - R_raw = has_ve - ? [for (k = [0:1:n_rows-1]) - _solve_with_edges(v_edge_sys, points[k], - v_params, ve_norm, p_v, - start_deriv = has_svd_eff - ? _force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k] - : undef, - end_deriv = has_evd_eff - ? _force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k] - : undef, - smooth = smooth_v)] - : undef, - R = has_ve - ? [for (r = R_raw) r[0]] - : [for (k = [0:1:n_rows-1]) - let(rhs = concat( - points[k], - has_svd_eff - ? [_force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k]] - : [], - has_evd_eff - ? [_force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k]] - : [])) - ns_v ? _nullspace_solve(R_reg_v, N_v, rhs) - : linear_solve(N_v, rhs) - ], +// Builds the collocation matrix and BOSL2-format knots for a single +// parameterized direction. Returns [N_mat, bosl2_knots]. - v_knots = has_ve ? R_raw[0][1] : v_sys[1], - n_v_ctrl = len(R[0]), +function _build_interp_system(params, p, type, extra_pts=0) = + type == "clamped" ? _build_clamped_system(params, p, extra_pts) + : _build_closed_system(params, p, extra_pts); - // ----- Pass 1.5: Project u-boundary tangents into v-control space ----- - // ∂S/∂u along u=0 or u=1 is given at the n_cols data v-positions. - // To use them as derivative RHS in the u-direction column solves, we - // must express them in the v B-spline control basis — done by solving - // the same v-system. When col_edges is active, project through the - // edge-aware segmented system instead. - zero_v = repeat(0, dim), - _su_der_data = has_sud_eff - ? [for (l = [0:1:n_cols-1]) - _force_deriv_dim(first_row_deriv_eff[l], dim) * u_path_lens[l]] - : undef, - _eu_der_data = has_eud_eff - ? [for (l = [0:1:n_cols-1]) - _force_deriv_dim(last_row_deriv_eff[l], dim) * u_path_lens[l]] - : undef, - T_u_start = has_sud_eff - ? has_ve - ? _solve_with_edges(v_edge_sys, _su_der_data, - v_params, ve_norm, p_v, - start_deriv = has_svd_eff ? zero_v : undef, - end_deriv = has_evd_eff ? zero_v : undef, - smooth = smooth_v)[0] - : let(_rhs = concat(_su_der_data, - has_svd_eff ? [zero_v] : [], - has_evd_eff ? [zero_v] : [])) - ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) - : linear_solve(N_v, _rhs) - : undef, - T_u_end = has_eud_eff - ? has_ve - ? _solve_with_edges(v_edge_sys, _eu_der_data, - v_params, ve_norm, p_v, - start_deriv = has_svd_eff ? zero_v : undef, - end_deriv = has_evd_eff ? zero_v : undef, - smooth = smooth_v)[0] - : let(_rhs = concat(_eu_der_data, - has_svd_eff ? [zero_v] : [], - has_evd_eff ? [zero_v] : [])) - ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) - : linear_solve(N_v, _rhs) - : undef, +function _build_clamped_system(params, p, extra_pts=0) = + let( + n = len(params) - 1, + int_kn = _avg_knots_interior(params, p), + base_bar = [0, each int_kn, 1] + ) + extra_pts == 0 + ? let( + U_full = _full_clamped_knots(int_kn, p), + N_mat = _collocation_matrix(params, n, p, U_full), + knots = [0, each int_kn, 1] + ) + [N_mat, knots] + : let( + extra_ts = _widest_span_params(base_bar, extra_pts), + aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), + occ_splits = _span_split_params(aug_bar_raw, params), + n_occ = len(occ_splits), + // Use len(extra_ts), not extra_pts: _widest_span_params silently caps + // the request at the number of available spans. + M = n + 1 + len(extra_ts) + n_occ, + aug_bar_merged = n_occ == 0 ? aug_bar_raw + : sort([each aug_bar_raw, each occ_splits]), + aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), + aug_int = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], + U_full = _full_clamped_knots(aug_int, p), + // Rectangular (n+1) × M matrix: n+1 data rows, M control columns. + // _collocation_matrix uses a single n for both dimensions, so build inline. + N_mat = [for (k = [0:1:n]) + [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)]], + knots = [0, each aug_int, 1] + ) + [N_mat, knots]; + +function _build_closed_system(params, p, extra_pts=0) = + let( + n = len(params), + base_bar = _fix_tiny_spans(_avg_knots_periodic(params, p)[0], n) + ) + extra_pts == 0 + ? let( + U_full = _full_closed_knots(base_bar, n, p), + col_params = add_scalar(params, base_bar[p]), + T = base_bar[n], + eps_knot = T / n * (p == 2 ? 0.01 : 1e-6), + col_safe = [for (k = [0:1:n-1]) + let( + u = col_params[k], + d_min = min([for (j = [0:1:n + 2*p]) abs(u - U_full[j])]) + ) + d_min < eps_knot ? u + eps_knot : u + ], + N_mat = _collocation_matrix_periodic(col_safe, n, p, U_full) + ) + [N_mat, base_bar] + : let( + extra_ts = _widest_span_params(base_bar, extra_pts), + aug_bar_raw = _insert_constraint_knots(base_bar, extra_ts), + occ_splits = _span_split_params(aug_bar_raw, params), + n_occ = len(occ_splits), + // Use len(extra_ts), not extra_pts: _widest_span_params silently caps + // the request at the number of available spans. + M = n + len(extra_ts) + n_occ, + aug_bar_merged = n_occ == 0 ? aug_bar_raw + : sort([each aug_bar_raw, each occ_splits]), + aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), + T = aug_bar[M], + U_full = _full_closed_knots(aug_bar, M, p), + raw_shifted = add_scalar(params, aug_bar[p]), + eps_knot = T / M * (p == 2 ? 0.01 : 1e-6), + col_safe = [for (k = [0:1:n-1]) + let( + u = raw_shifted[k], + d_min = min([for (j = [0:1:M + 2*p]) abs(u - U_full[j])]) + ) + d_min < eps_knot ? u + eps_knot : u + ], + // Rectangular n × M matrix: n data rows, M control columns. + // _collocation_matrix_periodic uses a single n for both dimensions, so + // build inline. Periodic wrapping folds basis j < p by adding N_{j+M}. + N_mat = [for (k = [0:1:n-1]) + [for (j = [0:1:M-1]) + _nip(j, p, col_safe[k], U_full) + + (j < p ? _nip(j + M, p, col_safe[k], U_full) : 0) + ]] + ) + [N_mat, aug_bar]; + + +// Build a clamped interpolation system with optional start/end first-derivative rows. +// Extends _build_clamped_system by adding one extra DOF and one extra matrix row +// for each active boundary (start and/or end). Used for surface boundary tangents. +// +// has_sd / has_ed — whether a start / end derivative constraint is active. +// extra_pts — number of additional control points (widens the system). +// Returns [A_matrix, bosl2_knots]. Square when extra_pts==0, rectangular otherwise. +// Row order: interpolation rows (k=0..n), deriv_start (if any), deriv_end (if any). + +function _build_clamped_system_with_derivs(params, p, has_sd, has_ed, extra_pts=0) = + let( + n = len(params) - 1, + n_extra = (has_sd ? 1 : 0) + (has_ed ? 1 : 0), + // Average n+1 data params to get base interior knots, then + // insert extra knots for boundary constraints. Each insertion + // bisects the span containing the constraint parameter + // (largest span first). Constraint params 0 and 1 land in + // the first and last spans respectively. + base_int = _avg_knots_interior(params, p), + base_bar = [0, each base_int, 1], + constraint_ts = [if (has_sd) params[0], if (has_ed) params[n]], + after_constr = _insert_constraint_knots(base_bar, constraint_ts), + // Insert extra_pts knots at widest spans. + extra_ts = extra_pts == 0 ? [] + : _widest_span_params(after_constr, extra_pts), + aug_bar_raw = extra_pts == 0 ? after_constr + : _insert_constraint_knots(after_constr, extra_ts), + occ_splits = extra_pts == 0 ? [] + : _span_split_params(aug_bar_raw, params), + n_occ = len(occ_splits), + M = n + 1 + n_extra + len(extra_ts) + n_occ, + aug_bar_merged = n_occ == 0 ? aug_bar_raw + : sort([each aug_bar_raw, each occ_splits]), + aug_bar = _fix_tiny_spans(aug_bar_merged, len(aug_bar_merged) - 1), + int_kn = [for (i = [1:1:len(aug_bar)-2]) aug_bar[i]], + U_full = _full_clamped_knots(int_kn, p), + interp_rows = [for (k = [0:1:n]) + [for (j = [0:1:M-1]) _nip(j, p, params[k], U_full)] + ], + deriv_start = has_sd + ? [[for (j = [0:1:M-1]) _dnip(j, p, params[0], U_full)]] + : [], + deriv_end = has_ed + ? [[for (j = [0:1:M-1]) _dnip(j, p, params[n], U_full)]] + : [], + knots = [0, each int_kn, 1] + ) + [[each interp_rows, each deriv_start, each deriv_end], knots]; - // ----- Build u-direction system ----- - // When row_edges is active, precompute per-segment systems. - u_edge_sys = has_ue - ? _build_edge_systems(u_params, p_u, ue_norm, - has_sd=has_sud_eff, - has_ed=has_eud_eff, - extra_pts=ep_u, label="u") : undef, - u_sys = has_ue ? undef - : (has_sud_eff || has_eud_eff) - ? _build_clamped_system_with_derivs(u_params, p_u, has_sud_eff, has_eud_eff, ep_u) - : _build_interp_system(u_params, p_u, row_wrap ? "closed" : "clamped", ep_u), - N_u = has_ue ? undef : u_sys[0], - // When underdetermined (extra_pts), build regularization matrix for u. - M_u = has_ue ? undef : len(N_u[0]), - N_rows_u = has_ue ? undef : len(N_u), - ns_u = !has_ue && M_u > N_rows_u, - R_reg_u = !ns_u ? undef - : let(uk = u_sys[1], - uint = !row_wrap - ? [for (i = [1:1:len(uk)-2]) uk[i]] - : undef, - uU = !row_wrap - ? _full_clamped_knots(uint, p_u) - : _full_closed_knots(uk, M_u, p_u)) - _regularization_matrix(M_u, smooth_u, p_u, uU, periodic=row_wrap), - // ----- Pass 2: Interpolate columns in u-direction ----- - // Transpose R so each entry is a column of intermediate points. - R_T = [for (j = [0:1:n_v_ctrl-1]) - [for (k = [0:1:n_rows-1]) R[k][j]]], +// Precompute per-segment interpolation systems for edge-aware surface solves. +// All rows (or columns) share the same averaged parameterization, so the +// collocation matrices only need to be built once. +// +// params = averaged parameter values for this direction +// p = degree +// edge_idxs = sorted list of interior indices where C0 edges occur +// has_sd = if true, first segment gets a start-derivative row +// has_ed = if true, last segment gets an end-derivative row +// +// Returns a list of [N_mat, xknots, seg_p, i0, i1, seg_sd, seg_ed] +// per segment, where seg_sd/seg_ed indicate whether that segment's +// system includes a derivative row. - // With row_edges: solve each column via edge-aware segmented system. - // Without: add u-tangent constraint rows to the RHS for each column j. - P_T_raw = has_ue - ? [for (j = [0:1:n_v_ctrl-1]) - _solve_with_edges(u_edge_sys, R_T[j], - u_params, ue_norm, p_u, - start_deriv = has_sud_eff ? T_u_start[j] : undef, - end_deriv = has_eud_eff ? T_u_end[j] : undef, - smooth = smooth_u)] - : undef, - P_T = has_ue - ? [for (r = P_T_raw) r[0]] - : [for (j = [0:1:n_v_ctrl-1]) - let(rhs = concat( - R_T[j], - has_sud_eff ? [T_u_start[j]] : [], - has_eud_eff ? [T_u_end[j]] : [])) - ns_u ? _nullspace_solve(R_reg_u, N_u, rhs) - : linear_solve(N_u, rhs) - ], +function _build_edge_systems(params, p, edge_idxs, + has_sd=false, has_ed=false, extra_pts=0, label="") = + let( + n = len(params) - 1, + seg_bounds = [0, each edge_idxs, n], + n_segs = len(seg_bounds) - 1, - u_knots = has_ue ? P_T_raw[0][1] : u_sys[1], + // Pre-compute seg_p and available interior knot spans per segment. + // For a segment with n_pts data points at degree seg_p, the averaged + // interior knot vector has (n_pts-1) - seg_p entries = that many spans. + seg_n_pts = [for (s = [0:1:n_segs-1]) seg_bounds[s+1] - seg_bounds[s] + 1], + seg_p_arr = [for (npts = seg_n_pts) min(p, npts - 1)], + avail_spans = [for (i = [0:1:n_segs-1]) + max(0, seg_n_pts[i] - 1 - seg_p_arr[i])], + total_avail = sum(avail_spans), + k_use = min(extra_pts, total_avail), - // Transpose back to get the final control point grid. - n_u_ctrl = len(P_T[0]), - P = [for (i = [0:1:n_u_ctrl-1]) - [for (j = [0:1:n_v_ctrl-1]) P_T[j][i]]] + // Emit one diagnostic when extra_pts exceeds the combined span budget. + _echo = extra_pts > 0 && extra_pts > total_avail && label != "" + ? echo(str("nurbs_interp_surface: extra_pts (", label, "-direction)=", + extra_pts, " exceeds available knot spans across ", + n_segs, " segment(s) (max ", total_avail, " total); ", + "reduced to ", total_avail, ".")) + : 0, + + // Distribute k_use proportionally to avail_spans, capped per segment. + seg_ep = extra_pts == 0 || total_avail == 0 ? repeat(0, n_segs) + : [for (s = [0:1:n_segs-1]) + avail_spans[s] == 0 ? 0 + : min(avail_spans[s], + ceil(k_use * avail_spans[s] / total_avail))] ) - [[row_wrap ? "closed" : "clamped", col_wrap ? "closed" : "clamped"], - [p_u, p_v], P, [u_knots, v_knots], undef, undef, - [u_params, v_params]]; + [for (s = [0:1:n_segs-1]) + let( + i0 = seg_bounds[s], + i1 = seg_bounds[s+1], + seg_par = [for (k = [i0:1:i1]) params[k]], + // Remap to [0,1] + t0 = seg_par[0], + t1 = last(seg_par), + span = max(t1 - t0, 1e-15), + local_p = [for (t = seg_par) (t - t0) / span], + seg_p = seg_p_arr[s], + // Derivative extension requires at least seg_p+1 data points + // (same minimum as basic interpolation); each derivative row + // adds one control point and one equation, keeping the system + // square. Degree-reduced segments with fewer points silently + // skip the constraint. + n_pts = seg_n_pts[s], + seg_sd = has_sd && s == 0 && n_pts >= seg_p + 1, + seg_ed = has_ed && s == n_segs - 1 && n_pts >= seg_p + 1, + // extra_pts only applies when degree >= 2; silently skip for + // degree-reduced (seg_p < 2) segments. + cur_ep = seg_p >= 2 ? seg_ep[s] : 0, + sys = (seg_sd || seg_ed) + ? _build_clamped_system_with_derivs(local_p, seg_p, + seg_sd, seg_ed, cur_ep) + : _build_interp_system(local_p, seg_p, "clamped", cur_ep) + ) + [sys[0], sys[1], seg_p, i0, i1, seg_sd, seg_ed] + ]; -module nurbs_interp_surface(points, degree, splinesteps=16, - method="centripetal", - row_wrap=false, col_wrap=false, - style="default", reverse=false, triangulate=false, - caps=undef, cap1=undef, cap2=undef, - first_row_deriv=undef, last_row_deriv=undef, - first_col_deriv=undef, last_col_deriv=undef, - normal1=undef, normal2=undef, - flat_end1=undef, flat_end2=undef, - flat_edges=undef, - row_edges=undef, col_edges=undef, - extra_pts=0, smooth=3, - data_color="red", data_size=0, - atype="hull", convexity=10, cp="centroid", anchor="origin", spin=0, orient=UP -) - { - result = nurbs_interp_surface(points, degree, - method=method, row_wrap=row_wrap, col_wrap=col_wrap, - first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, - first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, - normal1=normal1, normal2=normal2, - flat_end1=flat_end1, flat_end2=flat_end2, - flat_edges=flat_edges, - row_edges=row_edges, col_edges=col_edges, - extra_pts=extra_pts, smooth=smooth); - nurbs_vnf(result, splinesteps=splinesteps, style=style, - reverse=reverse, triangulate=triangulate, - caps=caps, cap1=cap1, cap2=cap2, convexity=convexity, atype=atype, anchor=anchor, spin=spin, orient=orient) children(); - if (data_size > 0) - color(data_color) - for (row = points) - for (pt = row) - translate(pt) sphere(r=data_size, $fn=16); -} +// Solve one row (or column) using precomputed edge-aware systems. +// Each segment is solved independently; short segments are degree-elevated. +// Results are assembled into a single clamped B-spline via _combine_corner_segs. +// +// systems = list from _build_edge_systems +// data = row/column data points (same length as params) +// params = averaged parameter values +// edge_idxs = edge index list (same as passed to _build_edge_systems) +// p = target degree +// start_deriv = derivative vector at start of first segment (undef if none) +// end_deriv = derivative vector at end of last segment (undef if none) + +function _solve_with_edges(systems, data, params, edge_idxs, p, + start_deriv=undef, end_deriv=undef, smooth=3) = + let( + raw_segments = [for (sys = systems) + let( + N_mat = sys[0], + knots = sys[1], + i0 = sys[3], + i1 = sys[4], + seg_p = sys[2], + seg_sd = sys[5], + seg_ed = sys[6], + seg_data = [for (k = [i0:1:i1]) data[k]], + rhs = concat(seg_data, + seg_sd ? [start_deriv] : [], + seg_ed ? [end_deriv] : []), + M = len(N_mat[0]), + N_rows = len(rhs), + // When M > N_rows the segment system is underdetermined (extra_pts). + // Use null-space method: exact interpolation + minimum bending energy. + ctrl = M > N_rows + ? let( + int_kn = [for (i = [1:1:len(knots)-2]) knots[i]], + U_full = _full_clamped_knots(int_kn, seg_p), + eff_smooth = (smooth == 3 && seg_p < 2) ? 2 : smooth, + R = _regularization_matrix(M, eff_smooth, seg_p, U_full) + ) + _nullspace_solve(R, N_mat, rhs) + : linear_solve(N_mat, rhs) + ) + assert(ctrl != [] && !is_undef(ctrl), + str("nurbs_interp_surface: singular edge-segment system for rows/cols ", + i0, "-", i1, " (", i1-i0+1, " points, degree ", seg_p, + seg_sd ? ", start deriv" : "", + seg_ed ? ", end deriv" : "", ")")) + [ctrl, knots, seg_p] + ], + // Degree-elevate short segments to full degree p. + segments = [for (seg = raw_segments) + seg[2] == p ? seg + : let(elev = nurbs_elevate_degree(seg[0], seg[2], seg[1], + type="clamped", times=p - seg[2])) + [elev[2], elev[3], p] + ] + ) + _combine_corner_segs(segments, params, edge_idxs, p); diff --git a/tmp_cuboid_10.png b/tmp_cuboid_10.png new file mode 100644 index 0000000000000000000000000000000000000000..e4c1baecf94652dedd71a68149e88093d4d57d81 GIT binary patch literal 21322 zcmcF~^;a8R)O85KEl6<+q-b%6VnK_$w75f&0>wQ@DNb=M)&d2J6nBDqad&rjfAhTS z{r-e+t*lw|LuTgQx##Y4_C9;U)l}Z&V3K130010$IcW_5011jXKSc*3j?b!ZMF0Rz ze0gaJEziuOA6}`Hx^CAiE+?h?4&GMYR&>KQf3N-8^+e;FPdpQpAJf?AXu-))xF9Kk zoZNYwAgrAs=He?97y<_Nq{1BFJ^Y?Y_JciGA1Z4Ur@6-uTc+Gk?w)@iXJyGW$&N?u z-(0?#nsn9Gy_wYCS#%i@N=fy2V>XEo(kluTKCbs0PG>1l%^o(O%hUAJz-N5zT&zk1 zfvnGlgDCO2g8u&<)eG-I5EZ&1gSp@K@1x8hNU^BZ(?fp}l`X#cuqq;BnA$P_J=rq^tXZIh zhJG4kA`WG$N!pUk1-m6sEg>$);S>gh$rZJV|JG0l>${Hm^4~xqJU%@HOSx|#vYc_S z|2<-fVv-6fcG;&QZ^QavZuK|}06IDt=jbag%rKB|I!}mT=MPH^f`K_*%P^*VRw=nA zi6AXebwYJ~8d#tR9LCQ;Lm*8Q?j=o#aZ9AZrUIl0MGAw{|BWNRM1$mMeT)F0bIR5Q zMw`Nr2<)2|mtU}fx?z)x+gHb}Wi6i*xcqaXO?^6s4xr-^Q)J!Xx7+k!a%aSAl!qv)Ul}&DUudO zG;tb!36JTKYvPC)U2NOlC}aN5Z~A`< zG8nzX()aRrZZnn2lhs)e(vXhF7}}~Qpnx%}JiP^`nZ{Lof}@iIT)jF?LAkbZgI5^! zKZQ9_b1N3+k*&b$)^%ron^Y&z_v|Tb#a){+gXLLOtBX|yr4@(o;7C3_HUwidcdm2< zr%qsnI#iPc!*69Lm@j;(pT7tG<_d)pL)Ak6j62n1N7j>)z9J4@?-J4SQ&)S|hC8uX zKJ_J|u%gce^^-)?pa(Ah*&8NfZb$XGevo!D*x%*)8Z=ScMhG!XA@gasbaWJbIqFa6 zg0hWbU8gB~LEf%c(-547(@*N)V7oX|c?84;Su>;3ATf2JM9Fq%jjMU_N=3NzeSz;| z&=~s1$T*R)2a^JLFk9%66a%v|`?sTsOkg;;uBdua=tNW#WBEzHg3RA}SA%U&{0pm^ zc7;?5XscG)N++zdn_RbE6}e)8k9QjS#>XuUeJF@kUoPm4OQ1%lN=ooybk}$?B1=m;()XVo{DZdai@Zaa91U^Zq zf~)(%u@P03412k-Pqk%uOc_na`}Q-D8Ffe>FYj==%XPP|pT+p3jQbyaboj?khV+1U zHPS#abr6&doO4h$+7?Riof({?oPEkm9VbXHflI!QJ1w_`vG(X}X>D}#6rp}NJ@swg z12KfGNL=b&^o}pnK??IcFr+^ck|NcDu_3Sr2a*!m zsV9`7s9N{7^$wnjy5SjAz%S_gy5rqx4JdedafgKJ4c*aatcaL1-&!^WdRBPFnIWUD zCZVzWzpaU#KM@88nu>I*8+2NoAL`qnH--4tA3l9Rjer9#;HLW+j-NFh{4t!c7mEc% z>tSTzkh0fHOLo6N_|-jGPex!U=4NDb-7rZ!X3Sib_^IFVFohx{4P47cz}0PUz{v77 z=KpTr4j@*7b=>5%#fF52NulQ3%K-`oX7_@rxBJL!7SIEWo-t&Q3svDmOzxCot23yg^nJmCHX5} z0o{!7_H*ktO<9fU8_0~eU^vKkh|?qxLo@(GB*cOT1dyed8PNKu?+2>&sMuhRfd_d# zQCe9%M0?`Vt~yl;je5Fd7ve}-Zl7Cu=+mG}cHmX`WWNV-9-UxThi;IMSX+hQ(Soa( zt*GZ~Vt8&~Lgch~gmJJ~98>`E$n7V%uLl>3f+qC@vAu$=QL4 z>E<+Z-@zg#;_`e4;Id8=s_bFRl!Nzz9&*lhpWZGusqrAa1pwr8DpbNcKFtzX&E7ZS zf;nN!Kfz9Yt;qhZeL-++=CSEA;ij5+nn3%1%?%@OAhb5Up9OSz?Y|FaSV(r+Yf63$AmJGW~-LGDFw^ zb~em!;-pXPr<&yfN^_>O3qus~w$yjZWTm)B-kR|}oG{jmh;oK_+=qZ)T~fco{=iAK z`|R@zgD9Bt&+-QR6sqqRmd3d4FLe;yEUx?1!)a#OCeVfV0o)9GD(1}Cap89i0pS0l z>oqqQOd`U$Dqg)!#!-yhabeO$HHckeXZxzsB{Niz%9aS?!Tu@WcrK%g{!5du=+}pr zq1cmF!l7yoQMQEZL0pQDB&MxEf;>1IM_`}+X z|3UdICk0t)wxQ*YjdN%7sRcJ#O~%>;OzCLA8gTId>(-jYs9r z>u>1d^|JW|s_^9yvkS&i!@6N6YQxe-Ge(AjVuY3~aoLi9llOghlRQl}Ps`RPx+S1#}kK*-pWy-fjDs{u9q0Pg!l4?iQ3SPvMSo$7qf}@UMoRrN2FFf)&Qd_@7 z!|;<+%|Knk-=$gTjm{ajh5)~%=^Nb7kWQ!jxb$58Mynv=v$DvwQNx8@H3cUzcGzdI zDv~45@MMjY^@b{Tev$wLk}b8gYGBH~99fHhdEqb)>mDEKX6oJe4PLqJ2@tuOB-G%S z|ENeLKDFcG4GE+?AL*=AsEnI*WF3Int&$5yLEbp3aWau^@?thlZWP?Nliud!zAlVu zuRC+{q)eYnPqZ2OJzuRf6XqG!@r3-@CSTS7SoG_6dD2x4cM?%jX2og00s)0GB>8CA z@3~xT{~c~sSkUC7+kealAKurEfge5POun1iO@%Wpm&1A*T~ijTW!D{Pw{nD~)Vr*S{U&cg9* zOY;*I`kglll1bVkeup9pe1n!iq$s*Dl>CC13ID~p&W;&inrVynUI-pCJGd5!{Qkh( zZDxFgj}OG@J@PRnrF0i{_nSryq5)|`^r3AT9qs=T)oo)aV$~rke9Y>shotJJs~-TJPX5{yPO80kQ`%edyG*wjCkNP@q#F4Z8QQsPzw1oP9uQ zro)#8hkT;F!2Ngg<@<77dpPs2E>{SP+o|bH+Em@7$d^yspjGSvxK^adBi1A4n(^2y z@-pABV#3MrlgYfqe%khULRX7Vh&s+uB^zabVMhsR%)Cn?@&_m~_}{iTmN%UF)f#pl zzMol(qiJlM&-Ps3F*N+4_n<%@&KeT#f=>^!L+hb^OTjIHcBPIgIF6h}oIZ>cn@4s_ zn)yZ)@cPmy*kD%*isq3!Ch)fl0PNiIGGrGlSWV3vGz}J}&Edn0A>%gD_>}0eSyrP# zx{Wi(#`7VXp?^0KiPQYP)c5V)rDNis5H_rkn4F*{U^`-HB z-}kfG${TpIDC;`Ghwqw08u2ch0VWq&sJn-u}` z@R__1m;5c)*j0QA9EF5Y@#Eev58~+cFpojDI-n8wayj6ILtj6ox;AF1#%^J=KcUmI zs`@(okMHZ1yMG>7C@ogQGHaO$8sWt(V>oR$V_Yzdi&C@N(PTAZI42TTtfFQbHt9ZN z$Jl9ZY@%8jh)G@}C=ECqC<_iDfrB|PGzIgajk1%RlqrU>kuhVI!G~xPkTj#0q2Lsw z3w%rZ_WM(*ARY=7C$Q_cxkQOkW)QRGHwE|1WuN1_y<%+^oj)IP-DqDsn*5udVO+Us z^|-#gM1a?g^IV-)T5%8rK?1?7_yN&C`d)MH_fddAYXEwi$c->idDlq| z!+C*tjyMPqD4V1B#8UF|JXOY*^mDp~4`NTd>UHSFim|lR0Oxqi;`UZ-7+nfUtvC@z z9-5P;40e@cHsgAj))${%TKX~E_;T%NEWVSOL}FB*-lv$)Eq>b`>$kG#wPTp3v=oYy zQEg=o%%9AD>~GR6(lRtueygqe3kE${M^|;S9Pyw+Dc6;RfjlFfwJ_4{5NSb^C;7NkO@9BoVJpc z1&IiWqT=Ad4mN50yfyrA<8d_m_axHzaeZOg{RxG{AX!C|edoDT+&4d$HbJh-)|TAX zHvVSu0;<38T<&)yu3M^icH|hqM!b=nOa?va!+y)%hx=Ijq<^A1&-|{vrBs$JDYqn^ zZo{vvX4<#nu8z}TL(;6DQ4hzMutpBD$$ivA|t1y7E zW@CJ=lUm!+4w0-VR_DmLgbla_3gXaq3|m_QACDHg{Pz$eqgz2Un@K{|$NPpIqsF^! z@r8Ql!A7?O$A+z+6at?S1G}PrxlI&uv5KJ(Kpl12`A07B`43ma^z^IJQW7K~^K)Gz zFLf!Yr%KDnwx=V<71s&33-#fDvkA(iN@q9K(baUXy#rPiif(mET8xKyAFC&k< zxp|wZ%C$1@qTD|5Tjki{ghZc*(6`hI1Wfo&qo~XF=Bi)pjARM^l~M4RDxNCQF~oK) zH)!zQ8i)+c3JvWE4RwYk^z&MAZvbXe^SHd2^TA(wqJ*HU4-GPr+pU-5ScA!>VZEi@ zkt8Q`Ro3fWLNL@Ir*YEVaMT!l=*nY)2Isa(86TGEPSuf*yU2>~^sAw{-|$oL@17p6 z=x8>Wol<*hvqohl5@_qe_>&a6I`fk?J3)J8S3ErnU_BAEKo^=jY0$WX5eOc!$Qcar_QH;6^@&!EEOECeH38X(8q z#QMpKKg`t^)g($?r`cr%OV&d%4Jogn3T-Q->!vSN66^{D0JqI7r4*P$0M%S~h)n8f z;J2??Hs!@0_3ZLM>VK+j-go^DO=Dw`AZi%>w$=sO{R&R<9m-udGF{N4dMlJbrf-~m zKl*KXV!jcI{t_eHdbwXxt}th^kl=ne`{kqs?}N_Kj(EV1(2l{KPutDfPL|JB?91)8 ztM>o`r5t>&FPrbSahVSw-}aSYcUq-RT=9ba;RVtmZ2J5b+%-5j=I+OfA6bdZn~p;n zvu>x#4AHKS7V3QO&Q<--e>yl&iHe#B+%(OMytBJMUTU%)))RDGK8%SeTloBdwrz5K zNJf1N_c7!02`O-wk697F&%e7~_Bk@{e;LXptMJ!$>dV6GqVP0)e8%JiRui3p z#;s(1Boe0OFN1{uG$;0^Lj^dMz_C5dPy8miZpddVDW$ymeBf-OmsRhQn07s>%n$$r zIH?|gdM}+>kol;`hFcQDvek#PRH?ub{>(KR~t ziEJI4O*V9t&IK-*5o|1LWdpK87bRLxBL)D$*CYZk0O_v1&b?Fy6=`W;PK9ZhZ1FCIAZJQGZQMNu%`?DMLBJIdCC@f zx~yvDpe$sPCO3hm(%zGD$H~zB?3xc#@Bg$=9+IDEH*GLB`=MOxEFt^xyMuLV)rsH9 z0|4(-{Ao|s-J@>j;(4a+dDwDL=Z{fKkQdZ8#l#9SwcN__I(BAK3Dg(_$BSf^%wWwA zo7Rpd9m}i*qT(xOiwPgbS}!DXeWaBJUnXuC-QC~w(uhB<-4I2#ZtMm;)H$;2*B<6Y z{?k=J5>q?%W3yaO(slo1+{PYfbDJREAof}u3$U7kmEW9iq{sidso7m+;)=tys>*J;uzp3JU7TqlC1!o$cv+mLa2Ia?Y z4u9`KwwKZI@$uzW>&IK-UgrpzoBa|Fp_AqVm=OSaM&vgT7G}OvO?+w^JY-sh1cP}x z90|hF)=zwQuEM3``w%a8=f9%HlAF!=`TU22gTuuRM`=lwfW6bgy#K>y!pQaYcCGzf ztD9ZZ$&S^KpxxEJOa5dWSq$mN%xRz)cOR+u)f9=}igBZB{|^uUt-MGbHlyZ8MdNz~ z2F;o2X?0xz_x7MP>c`b42Bp-O17q$@r^9;Pbj?OBC{~d`x$|dkdAb|6l?A)=-H2xX8-YDbFji1pLP_MX#AQQF6J$P zR%dG$m!H#FCl5WOv~+Ou9$r12sJYkyI>`Uth~agt-{u=?T z$~^>}%a9g16ytzqH{E>HeDUw1`P%2W;ia1-;K1wn`iKHXgWfE4a~~ciC`v{J4AtO{ z^2C|@TAn$NQtE>=QhV_7IN~UJvl!{1zvOfB#L;?Nsj=a4yr`2j|LO`mjFc=2NOCV{ zYQ=o??_W$=S(bbPgS!Wbk?&bI0)w`{QTuOFznr-Y`Hsnyq_F81xc$upo8j^c*<(kU zGXK;%4AUG+qhGCfS#ES|yX#^UyPvlk*|}(-feY<4TW$~aYZm_7KR)i;33l||$WTlP z#h@iH8U840V(1krhl$TfgoEaHuqA67BY2l70X$v_D1Yd{;sCMc)RmRxmLUq0ubedp zQq}s+6Np~jm3D;d9Ich*vSS5o zD(ze+t+Z8cg((%?Xe$#Y%;YQf;qUsF(xojT--Jf;sKCGF|FK2}e`mN)E1gBt9^AI`Fr^^jQf%-TW+qy9&us9=d<^S2JvQ_8v;(mddmg~?##!{@G z{UQqok=01F-?rjIVsB4;N~ZoBjgGx(wd%IvY=JT2bRljyey1$mDlH8Jo+0dEr8wxx z`J;~Yu2wOldQg#sKDkUzV&k5I0$hXhXhOwhfKVDqwLUV)qRYvzh0*|N2F>-BD8NivV1X5G zT-2{It66B?|HK(1dHVUmdFLx~K9h`zDOB?o~534ox4iTSosD{Xe^mPnJU3mS6 z2kB$Q>;*$zjsY+PyXC1>Hu0@d{f0@= z3|JPk`@V{?hXG=Dh9Y7qO2DU?WPhE_iOvh$WGb)ncpH&PeQ!mT%LW^5j_-Q{r@Q;L zck=Url%c)z72vh?UQY6+TJHqwUI2|XxGcraBo#>x{|f$kODkjm`@j4$Q+w3dC=Y#& z-cehuFemMPRRTexCXt51ag>X5=Yq_+mETU-xAPVBF`Td<+5EDz)Porsxp)~ey!ny& z9DCxv?5Vv}f0nEyM}*Tf1S}iD6J1`o$VuZ7b>GXc88=^GC{BVLem~;Q?BmULCpAx{ z4H3LNa;xagce^r92$vhMNL7=zfNgFMm$#mFRP|eIXzP|zA}EK3Ie;kA=jL!uf$l9@ z6v%fmWQ;w#$QIEflHkM!+Q|Y4QpJR0U^Uw=y1!~q&NQO;^;c|~_V(|cH`p{1!`5OQ zZ9P9l4#vo%OI8g~q5EHLlnpz2M>uVv)Jzo4s?!|u27i?U5`V`58Y_HtqVhH`1`Q@# z5_Y3CYZQH$QU&cEEf`PCC)$~Nu6KnNW_<~GSSS>`X=t-^F!Vld*daFukjMd4R-8jG zHhxHGeTE00bR{V%vy(&QR@ovG!th~O|BB|uwtLApHQWBx8W}uf?3`1-93IEE)_q#S zLRl#=i_H=ha2~F?^4C4xotTr~pjgL{v}PecY@hqR9Xq>&^bCrg*lbeJ@pNehVs%xKF)o*a(OBaK z^*nf7Y6SBs1?8os6|N@i5wC3+R@d@pB_S4g5Jud3{l$OSHhA3Gq ztNw-J2nXDDX4}tI9+*=yrb=dApu^QSTPeXR+jMj1bB#8^FgGz$ zxxvL9YOBGWWdkH142HHiu7sNXB?Se7zc5u{(BKlWIP1~&k*16K?8}fCdQNGRbeU0W zDY(12)m39-qaX?LXD6p+r1$9fQ}Pse9WPd;RrPT46i)2TRgnO*PEM*>u{seU9F`zK zADxNBe81Ej)_0`@Q6bEj+NL^2{QeYiF?$Y{{kv-V%t9P}B0LlymNp!ML5QFMj*g6R z8VWiO)zV$|sJ)+%a6B!oXDzsggQX+{b+VJgQlE;c{B5EKIr!G@jXHlBNdsd9F~GSW zEP5vZ%wLhI5dINZJAjAvtD(>zsU`DOa4*TD-HmhyTwxx%O2WtJwU)l#o z`IC7vgUh}yBig!#e*N?_z>iRD?hH~^724k~l=0$%M6SNsF)@$5gowsl*cv_Miu*O{ z11@N-+aQ~mr*PI^ZQm6RzpS!H<^SyLaeStUF_x%~*0{%G?0e+1@^XkKei?^D;a^Bp zJsm@MIJC{|IaOQW1_z03Y1O zL66*_*TCqfjQUhTPQD-APp&DNW2wHUNQ#dkxV{N>y{)B_gd;S~d=sl{WL#h=D>G>0 zv|LOIVLEg6rUiwdEAFdY6-9v{x|^Jr<*bNmv;`5&YN=$aA4F*L!*~(@`eKDqKW_cI z_yLK+8~_T$6^P=3@%Dh3X`Uw&4&Dhju^oIk*Lnj9%^0IK0ftQH%lM@!o=?tSk}wj= z=W(v^Au++i<0a{WXw-^T(1j6guCtgJciQ+LKd6R`><%i^(>HT|-Gzls1jW&=+6I$- z*21;|la9EEjKpyx%K@Y@ASe}yZjy9JI>i!UAd{^xEk{QWfCkIKgC9SRU{~jZ2gd$) z0t(R&({<108$Hnqd;v#xFUt!V0f=-!!q#|Mkg@!7RsI6SORb3t6Lb*tQs1pe_j0oO zn6EEs`35l_x)3HuxjaEI07)IPs8)>R6qu(G9W&u`vt*pXXSY3nJyuu$7#WNe(# zAN6t;8!$RM>(q|2v*NXL#G=b?*jRma+|)KT-+2CbkrD9!c>Mh7W&#I-vPYErUtKF_ z)Y>mKR^_h!r4n{$$uDcUu0#lSL(WeIBmRdC--Jll-yoC#+fZG&_ByX59U#{&<~N-R z1o&a#!>p@n5fK>BsE~;R8#I`$J-SjVU{CloTe*ceX|1ijMkq6iB|2>S4V70c#&;bJ zJ7td>;?JE3v7vW<{{CXb_&Fh4q}q0>l3sB?zW+7|1uN}|&#uhI{*UfJ!&Wlhtb3@? z|Ela|Twi1TFB1@1q6CY>z=B~BG4IJ5RFheEq8NrCK*wTg{^5`YssA$F*!rxm7 znZx*d2q9JePYKcp%Fd@YIon0DLE7#h{5_P0Y2)E$&x>>42=!ULa1Jek; zu7OzgS1fub2&Pz~%VFH`WeCAp)a(x8lUPPtjvEIv(z#)+YoRPWX^l!ftxwk@`1!C$ zwz@@3K^S6{jnIgYME&j$Pp3`(k72tetGbQMS#uy+H2CLhz>m1mU}r7s;dcdys0?fe zCimPoGiCgwYHX(m3_WBRwiVO2{`ZHLBx?bd!xD z;}*xiOd+|%9t}^5Mbnt>8bR|1OpPmrWy_M;bJe1*a9Pn~8 z^G`gWdw;5=?euFo*4or0Z_D|>ePOoIO>LdwLyX7c+Wl3VUeonggs#b6s?5L8;;>MU z&^1~m+YP+;wxD<>1M8P1*jAr@=JiX3RoP;kNQHAM{D~%QV1h|1lPBofc1)MXKAxwn zv|P{E@e2s>@lN8rcER17W;gb~Mkv%qnhEzOFRkT&&(4N3>OLd9AGvFD{RxcKwOJ)@ ze{0;<5PBGbtnzsr>u33%pFJEuOnjqm-MK$lsj}$LL!j)c$x!-WR_ ze9fs8EE+IL=6{F5h8N(p>{ZI5Tf6Ms+ZJN} z4^cMo@>z1Hg_i2qP0h^k@$v%sa+Oi{k}lzf9vk?l<3DdGRDFD4i$Fgx@ja>k#090w`lKx`AkZ# z>JiserwG4cejn+Fi0i|fo7d>9GNH6646$|jA=AI*)!#7>g-4!}$tr7vOvznX@s)bL zs3vI(|GrU{rdCjBuo`}agX6ZAT2)q7``5Vx(NRe2j28OlWVdlP`O$jn>STB4>xw_v zB5=84t68JCmLC~#nr2v9@>7~MKPt*rx(h!q>^J2vw(ta@LO$xxLqZ|?Si49U`T1^5 z_o8Tk)Tb|DHml_j2qY%ci7&-}osJ+Wi|)Pfwus~g+1zYc7-0~U2pkbc`d1p|3BQm- z0u1ZeY~GcmO5gPf7gWX%S`TNS!4cHkNmZ^lk>qtWM!1N>CUC8iHMTDhYSUU|vf;v{ z)(cv|sR|9w2WZ(M{ySjUe<=r&&qS1o7H}pvF^hpkJoM5d(7G@-)@nDCF=OB|r@!J| zIz87;l{=yYL3*F*4ps;6y1w&UlPM7~-o9AkXv-w*pjVJ>iADt@(wl1+srkqKk(Wp0 zoJ6kA?79e@TqXph7B zbsS2JAQi^URr6*%mQjHDgFx#Uf^tpDkd&tLJA8R3O4f%WA!bm?I$-eM$lrAg)s}Am z(--tuzMCZ0Bh?atz6!6y^pB%91RW6JFs*SmkGt$Af4-xgoQelvJ5NLOq;qz zgNfYj$oBrLkPtI#$QH6gRhwyhJ&O|*^J#)XC4>Jc-L4Bk=^jsBp40ER@@^6EC1~CQ zfn$FuMTx`Ss8Q&KRhaw(u)7o)_-v*hBkGMpj@BKf!7^f}dVhhc!9&iAD6%T?yMFqw z9a#3@XCz}^2z0RT|4|gL0Q=_BssE<_)*?!mD zh`u0zy~KO-zvARFe_YQA`aY((AToyQ&VrjKz<==ZuH^c7>Ar`8;pq3hVb z^9FfRy6cQ#cH2%Cwegq!t%W*Aucs@40;Mzsi&Ep4LqsiiJvZ{qH=3l>bFgpw{x^OM zh>|M^V{oRvrhKlIv4daqGLzHQRg|KFm9g$6;zZ&!b2?O;jhP5eRl z;Dk>WS;_=YPM(ja&BlhUd;+ekYKGX=9sK;hAbkJn8NE9fqGbobUwq3~Wh_=BM&Q^5 z*lXu~8N`Oc$6cs>8jJOB9_XEhQGd4J27;@sIZlp5Pj`Rfl^eIw{!pP$*iW}xMzR2q zoC44|VPclqtG9@7pL}wUz@6`od2-MLNxak@% zy|WzHboHiO4Vh4sjA{XZp8jth8%#ZL_3w0-+r|oWdqL-J8 z4HwJj$tZ-3Dgg98ltA%H0!f4)UkSCxQUZFVZH=AUzQs3O5_uX1oxIr}j|D-Zrp-hl za?AkWIIPWOC!?0ir&qXav88Ea?0vu;99ca3om)qy&;&xk98X8Mo$*hrufKjTZ2SHO zDcc{r)rRzm9-Xk_1lRuXxUnD)%3;E+#*`}gxBRcqt=Y`Re$Vkd{7P`K$fBsu=l5Mi z#e0-ZujRI<1zp3LSCX8asWik@6ML5fNsb?DY^PYto1J%lyu>R_8@o*jX%?!4_x1v0 zN+YET+;MW^_uS5vJrIO(FfKg7txfcgDesLBTPIaDckqF8HU|BreB1;15n4JPtqI-` zOhbz)T{14s6dTbg_uL#zP0~OB6b8O>8aLketrJnD%~M3{*kek&q|RaCxhh*!pH+<4 z`d@R^h!J8#@dAQN7^1j(Qk`&+^I7vg+!9NuiS+yZLrhsMbkh3O{7#@xki=&eJYVg- zz9$b{9j&XZ;JXe#+>2GwN!8P#LvPL~XeTL?ga!a%pt0PumrWjI11bM_hhm))82Yz zjrg4^2YieQR8I@f`5_sA2CgYxej?12-S$UG(O*RW%VEZ_aK+Pn!LGdJe(A(NU?}yT zrL5j*f3jae84!eMS+^PadvzuxLf`F`KLZsl1SlEU__V*j zzgX`aIlX*P?nowF+h1$m_OKMH>bI|&kT&a~J$*{LTeebr?&aX(`P*0W(t$;T565!p~uZVk`Sol8q-A@FP->-*Wi%3JXm6XV?MX_RQ_G`O4m4)d1? zOwRH-o|`d>40yiC&g3+Gtan!aDoLBof%w)&kxyU*?F-bueqj_5R@l-Oyjxg$==gQv z=&rm0asGb^*rVS*y#Ij~K?s|E%Ib{rtz8-Kl-}(p@8!wccWUw4xHIq)pdFht_x4d% zRdEptbs+V*c}NQMV%j@u5*3Fo7Njb#HQiW56A{JYQ*b7^s(>5>)4&Sx0+V_0-0dU? z%B<3S#ZD~tOd~l27^GCdobah^{s={aQK1ZNI8(@LdnirU7?Cnt&R2s~U$zAhA7LO; z2#Ly%MqXBP*QF(eep<8z#$-A8x6o9#zaQj!`tZJoN!fvrSoltDH{Z%@QL00j=0*mm z3bNm9(^+@sddA3S>u;}rjsH^>i9to>-@mkRHe%A{*&B=mr?fN`9NIq(n*?XB(bXN8 zR!9FZ%ji{2f5$8{H%O6(Gk&$vN$!X}~3WT<;XysK;QpuH_fsTRLZQW*#C=(DEg#oEs#(J88pGL(LdS_qh&v?^0oJ1#Hpt#8B z8b+3USk<%k4O6__L35EoQ64dqo}0OgJ8;33Nm&j5H}9!EY#zdo7%nFzG>5xohIj8`dFUMB4SfUF-c%{^QW&^;vHIifEBjRN4lmMVr!gkV>ym!c8w4 z5u+uBXyk6O5;c^(r;rtq{m+sCa%I+SCE6^bVU(!RwYe@_s4SD?HdFSYj zbf*WCvl-i&^jE3IsgeW?(*;oIj5~gRA4o?p2oEQil-iGweNOjM}N z*dhb}AL>NXYBe_b_c58z1p$OSBuEJV!OGccu$|w_-dC(04x`2N!7YR;iV#J_?Ho_ixGfK3 zs2n711}j@Hew^TsA<9bD!TW`_CoI}^7~X9XF(eL#hk}n}qn zwN#;hY4Y!fQo!GV3edjOSKlE+>2EPUa6?c1YAu+RP@o6#vteMHGUfh)sB>hi&}oqZ zb+6X;wJ3{x^0QrogY)QrwQ?A=-8s^e_yA<$ILRV3w<~{y%05@MV`;^dpIEu_d``2*#ZFQ}U}Jv#sQ`L3@<0Aa6<;v5+61R_SXqj_adky=={RZ7)m)X0 zU87B0y{<5|EMUC!I&%g{E7F-M&aY;b{PA>mhvnxPMF2_qM@0}qVdkFt0`Jo zAMEJXA^M!iD3qVUaBv<+2X;tzumRbWenh-8evg_Q4L-@*q^d*oZ+UcwRWwS_;XNhh zRfWoVT>Z4PN!YI=rR+9YyMHl z+>cdO!vDVN*M0Wcj9dAmf3u${MpJ*EPcvMvsr3vQ)E=jTjuA*}XxAxMC=D^4>T2XLydaq%_6t&e zki+K$zyd+wcO%C+E8Y^L0lU!@b!}%o1qhE{3Ic2QOGXfQ!>t~f_+x9bEY8(YSo=Fy zmSqjEO5^36@spi$V-Z8Qb4HW|7+$HM^66@&IB#};jY$pjDA@xE^l^&Mp+b;lV2DIX z@BtHwK0u<>`T;YTtg{CO4YcZ{v`W>&s;?6MEf(E!Wcd=^!1%t*?i}|U&(J)LCJVP4 zWOU6WH9;P~RY5MIH-zguFg#W8!)Y3Nf4y86!e%}lO31b5Ia)w4PHu{gt3drq8$trh zBtASuT|M~nV%f3@$+@6BbH)DUesyH6o?NvKd#(dG;Fdw5ndJOpqKb)>SH4rSR+L6RVvW(z?ZBL@&l zU?9d+HqdDzYh%bFt{0)ea*j_H9Hf5^mqKgeXG;Ph1xgg?@K4I4M#L1AC!LWBv6pn_ zZ+$zl)VI(lilr*DGYr@3Wi3{Y{FThS0Q~qytFsr!E9>T}JYeDi2#X$5nERVrV?Vbj z>T`X#v~ax|9J}bT(uweRrt?@!@S=)AQgPIW@t!xNeE7w+^i#^@;~msf(};AUMXzRjwr!MhbPkA4slpi7L4m|CMnCYxxX$&~~w^l^G9)p~BL zBV%2&y~wwFJip5fGXD8~laNnHyvW$>OF-}6wx1J{Ho$_Ead~BoAz+G;ajD7HV`4&4 z=rg*Z(^cK{E07429I?ZjNBWfPS?R)c=c(f4+|aX}xRs#gOcbyA2Df8lfn^vW4cW<| zC8)~s6bwOf2+~GSdxQ=<&LUqe($`xMFEU)=()K^9PsgRhQw4rgvg68Q>0Q+o0=n?C2oar+*2?g?W?a@AX3$Fz5%W} zZ&65Hnw?1gBt%Ac;26oG=J$Y@EInxp@@GYk5Sgm{`YP}wC~)mEI4H>f2b?N#D@Wrj zhq%H|<6gd~r%d25Thh1eMDrH$Q0-D2!Vz`!0d&zrp2e{#TfUDvuG;f*PI)HhQy`x6 zem0v~PnkV*YnBvgHSc{ZgC-Os5LTRbY#)wirR&aV8~n}yPzYl_yFAX#Yqq_* zzxsZE6N`$U@Y1QE;hG|rij6n1Y!Y!cWVO3+Ic@N3kY)M|nuri?-zO)>dDC$jG?Xc4 zEk|V~y&y`LOF&8qVx=5e)Gpz(NcZj^UgXn>rl%a&qtMp!jQEhGO+1q~E3JmDf3ZH- zI%+2|OWnCJ|Jxw$)8*1R+yW;~wt?2yzWgKo=QLT2-pq4gLQ27-naTM%pB?gdH#@BB zz(1v`Gjsa``tv!I?~5bZE}6NuBzkGan58h`-9Zp+?W2%GgKWzId-PMy|3)QNSu^!Q zRvA=^)i&C_i8zJMX*h&A zY?8xCsQWq~k)A$eKoo}r4?#t4<9pi`vL6@iDXr%&-YZ9QV1!~9lTK?uxk$)XdS4VOpQ7`G==U-wHi^|3TpFe ztJ!dBYBkbPR1&aVC$$@l#uK2^x!8`Xi`mj0R2h?e5=N3(W@XRpI9RT}K34@A^Ne^D z25tPdoUinIV)AvbA+da?o_UJm6egH#*fVE$4E`)O)0BFz;rgxVxmbzs)5WgngGX6= z*m5)vsT3CNC?xn~W3Iwc$Rcl-?Vq=x!0}EP&(*W}w>u6|=_HK^<^oUGmt{Peye4OM zonA%s9)$1WXM0BgV!~d981welS-k7w>)kh-jm4SSH8 z@?o88ChZ-gzJ;7vFk2TJ$V#jeCXf@dPG`lCj)yO3Ye7d)si(tLmY%6kqLQhdU{CL% zlswpyyhPU&hpeghI)d0A@vRZ>aP3`H#w9T6;|w!$palZuX0uUES0rIjuI3zhA;#eM&wPjuWnI{CV>3 z$jvO&Qd5iK+*mMg&hCGk)Yzk#*EvMvVX@qvT+@aXEJwS4hipE$e#IwEGm;Ym@<139 z!RjxHhnzh^H_FY~nP*KibOktdE8!Li?Wjx;0sg=oya)x}RrDcn5@4DvM0vONU*8QA z@>UtwTwjNrBAu!OrycP$tH4!c=vPufbaQ9)i6_@1?ZXN@K^zVRoj0Wr>Ie zFWsyqB)C#5-bdSXM)c+fX8bHhn4Y4>ld-Y`w>fjPcE%&kTmcs`=7@*=j5 z_rD|tB#LN!fV%5;A`a6HM)EvxPY9O=E{aM~aJmNYFe4%Kk#lxh8a@QV!Z}0UyBy!n+9b!oEeLQ8st{ddOpw{UAO52}JuTQlm&&f69Dafm(L@THvqM*UH4Kb3 zPNg!8@;nVn+zwP&d7q;;^3x6r)o0CO9de>*>rr-xJFy4z!%+{`1$@xV;gk)J@vXti9uf3mDA zzXDd2`i-QC_PKdKzYAtNeqY61DCRIe0YgL} z-_@g*EE;Unz!%+@cP{a<9LC>CzZ1GZHIQ4i@mzP|5n=bu)tY852A15 zMEm1p63nI1mAKztd##{0?%w~HXhC!pT^3ls8bUTirw;GK5g!AP6xGT9Tx2P+@=W}W z7D)$1qfO%V?^rRHgAyWm0O@2p3up%L`@0JiN=qku(xUqs&;FNlGMQE6k^~EXjgK7*y1;6*QuW0Y`C$uYc+p}Y# zBq;nQ1_CcI%SXY3F5(@P=PcRON5>mP%4qz`%bgEbg%6!Vd6_SZlw)-IfDoJ0llyhg z32Ey$Z_dVFc+iiJ#~?|~rrv;7`?B+*mK# zb@_h8Xm$?_G>|T?h6P{8uoN&s@YsS>jv?6@o0hTBH(7o%{E_KyqMD?dTJKF|Tr>_@ z6=T|7>wFo=%6*5SA_3gg;YU=e!x{FV*7Ya;2kh?zu=2-QznA%(|3Rx;%bQoHJxv6W zGBJ3FOw$iJE|mvGl9ykAQ>vQ=1iErk%ZNFrEE%C(b3=^Ac7*rD?@JZ2or`bC>wIMi z*Bgdv^+t6I$&*V&&$lK+`o6xekEVC~0XCUDIDcwA9Ta+7BQD{VFsDFHUQZS}!rJ1t zAUj)elSUSbd^FjjpV6R6ETcv*Me$?~+%*+T#g8Mg=u&^J-*3AyktL%wH<$c)*~6vi zarhOHC11UrU%XG*`W8oHUj>NBzZQG4b~c`jWfyd~bj7wD(`jiG|B~(C>hRuGRRUQO z%9GnsxbxeDbtxv2EdFs57_n_zG)tD}&U6u4g9UOnr3tle7F?20^ZGb)>@M`pki*tYulR1sGTJpWaN(M%oxUBr@ddDofeA(R| zjtz1g8c?ka@nio&xFnkACa!xO)>`orpV3w(UVRhvvb@IK)tZoQAX*Qt>v zK6L1O0dj_lX0b>xJI`8ZWr}sJ(qa7D4PPL_;KV!M5gA(C{X$@#W-s{BvbTb-f5O zS>>ARwdx@zE<#@`ey>!&U(^Gm7^>6aV$w7oZRQMmE(%#AgH1KV##hHIKMA13jw6ze z_Blmu|F#{;Fp+;szb0VF>hq+$#0&`}KD*OUG0raB|J|x=-`EhK*?TqCK$hbh!GV)a zP8`RPF|PuOIXrk348Nt#hW!doQM@ii>wK-NuI82x$vjVt(fkuw5$4zi{(;`WPn^tG zx_hn6(j>epx9EOB(Mt3~Db1M$>`q=EL1DrLVXG>p*3`tq#clNvs6|^;iKFwH$}Lgn z>z6%y-6!22%F7ikQ9lJRuLCWv-!cPTsIj^kKVF(;Va|JB@6#+A`` z<>%mNqj9Z$?WF>>z0$wCM~ihFZw$WIouXPm3U)$Xqv-6$Q8E=nhSazy=!h20~iT>r4{=>im!a@9Q!z#2?FG3U>{3M1HLd9D5%+6 z@>&&QYuh~C%6K_gs^p&-eC3FB3swH&g1Y}(!f^_SLrQ=+R12NHp0B_AeK%zrJzVsrr5;OD5flK zabr({?N3jP>VWwp`dSV;_wD3AP)#?#Y67kfU}DK93F7R?5+!bpu!pyHcmI|eZTpXr z@;%z@8U7QW4!-9``$h()JJQUIHP-gzR5o3HR?g1+#roDS3*$0?bg$`nFgXufc!<^W z=jJ=ADRw|{bhI5)rSCrDrRZ~kuE~LLrF|anlCM<%k4F;W96hhsnyjqS9^G;s+!M|C4QqF6)g1vDnK)V+`s#J6wo(5F`8O6{ literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200000.png b/tmp_prismoid_2200000.png new file mode 100644 index 0000000000000000000000000000000000000000..1f27415fbf9bb386dbde95ac9d5353809bb03b4c GIT binary patch literal 22507 zcmd>mWn2_r_%AFgDJ>w4bR*p%DBZOnwRDH1#KHms0us{Q-Q6i70@5AAD&5_6$KUVBoG`42^oP70=Bf(E7b#k zkli%pq>-vdDRzJ_LY8_8Rw^n;tib>1NXTKfNZ@~m03R~o12oS8{huu$cn-?{`5zha zZ>QM(4kHqh6q3T*H`*VO4>~b2i27T*15ouXR^PpR3)cT;Mm*PIHTBX-`ExjyY;{_S zVSM`2$jttg9YOQ*OTze)xsr+O6T9h_0eac|K%}4`8!-3{@j|Gh9ypZJ>hUO*EFw(; zc6%;deC0E0EfLmSUS7U(Rch6BrVH_!d65Aw*x8gB|1$Hw^Xsxe>>nvpkKmB|NEfNfk+?< z6!*4IKzm>lO-}~O|6fg{mR{Z#0CDM|0m5qUU@DCSYQmF(JCfS|Rb~$?Dry_@lWApJ z`=Nm+kB(fFL&$^Ig8X0TJn^OA2V~B9A^##5_z7t9|GZBOT{-17^cZpEtqmmsgXrN% zXz1}pG_ew(;w%w1m}=POhk@wU{OC5?hE3a-pi1EORJzn4BQK$!?|CITzViLv?p8dY z>iLES3PN}3k+Q`BmD0Vo8P_R26V?t3!|fKHzO<2l*+yCrAt+OXl66#ppu?=6D}K*%J*(5TAGh=F{bW1+7 z6i+hm%dO`m#q(9|qiu6*ICkJ`U>K(Ag(~P$G?w6XRQiWMs`ODHI&EMGB_DzA;>lwp zAHGo>QMc^Z01L6vDNx{{2f;I&>4i75iGC^-yW<2gJ4)p+BZFI*$vmZcs6e|sF4?M? z`xvbj1z!Vs^CyrCx*a5Ky5>Oz@R4<=-uEp!r!sGKS zRAY4{-K*L}vUItK$B-I%^cX=v24-b~su5IhBDvveZfq(3`PwXDmlTf5c45?Zp7p1% zTEI)+iLNIp8|f1+GE-o=U4XM1!M@0o1Hm7j8il@Oj`%UONfRRzR}&2rg#ad1iaacC zJm@HpojDtwU= z=SmJlAvH^UP?3d(SD@uyKP4|}6*K^1;q_O9| zKA0Y~kMxZZT)~)QmAk=HQv`J+_?0PtOL1)Zv5YG++lZ95r%I3c6Wj=FnB8wGLr@W# z$}m2YeHemOO_A)M8o>E!xvAze!M`;rq9=l=``7CTIVyceS+Cw_D4vIgOXD>*gK_Pl zIGv}Bb3Zm*1=ERYA_;6@t6#OXcFP&Fb#906-Cl`c#4_XiW;)Mi=%hUBx zxy(NcWGDQ1KA}rk_eLb5mv1zMWE*?miP-wp< zMxRo$_m0NpW&_2wk|`T&22$AlOOm879EpTZMi{(Ld%WzHsnZii{m8-{E%{jO*{{}M zNzf9oU%9#NmV2ADkQFt94<(n&OnIdq2ZRC5`5C8vDy9HI%dIqJqppb&ZK!HMYJfm( ziohIPg=`+LCJk?^H^d0)BU(aeq)6@S`9p}F__8o@JMdCT6^azWK!Y?ju5|M{ipP!1<{OSX( z6iq$Y&d#pW+}^J2tfWVT9akvnC{C$60-oh3FatR-yU`hp|Ma|to|aXL1_plXvmtjr zb0??6+hjF+3PGeYp~7%VX8}jFgP@-3h%Q8lJQgm&N9tbQ_&k z(dUX}Iz^wulwGK9a?8&Ky#v;kJMcSxbc-bT&zjQSioT(Jc{Kc#uA`skr^288D{N4Z zo6J%$2tbMW2p$*9~ablaEl_G0w-{qP+eLtVqX|o z`-kIaX=2eqn+dLoI4AE5SdpzZUEEIfT}{HvT{hgTf}$AloMa+Gc{`!p-B}RlDo;+J1Yh#j!o-O4y;-0hPe0bfB#vLCa^?DK=SfvnY zxwtiN{EaMiZ1*^j>weM(arHY^EQl5Bo>I5#TD7Lhp(v)g3qIm?$s{8R^mBg7H#7nu+P=wV#gIg!DmFt@&SX8sy}d!}hjBKbxij-1yw6-|7*C!U zITQQNefR@u${g*@deW|)VMV$ykDCLAeDCFSx}EIUXslVSjv9Ymdy{9!Bouc?+;UvO7`v8>0t6gQQ59~ZV|4qw z!M3>TT-5CQ6~Uj>XR0pLHP3=GLh&q#-^U>gojR_>&f-Z7V?%9tHu5X_>{h!VMbfk{ zgMo>h#6#s{2@n59j}jfk1G{EA9_+q2t5PP|9|`NkJ640=Mc>zB<}-pQP<0k7V&U|? zJopEdz4X0A-?0C_o;7{;hMUFFWmRLJE)Z!z0xUExEoGHEQ(S-=%&rKk=!=N+On?!o zO2{pU!Z47*DN{7~V2f7`r};OEE*Qxj)r84HDZh#Ep_IDs=Hq;0g>TrC{rfczMI-zh z^ga=ZyZNxIn9DhW!;wS2Mv}hKYq`zjh@lD7$3hH{czJU9-`i(Bn z{#+FK;h6z%v`=1F$Zs5K3BUE9F+@qlwQozSKdw}pbYqWD*OHU{7#76sjQAjhk9hfK zsxcnk_S|7qn#3TsUz44wJ+`^od9}(%Z{hx{pej^03PCV;8dn4@&!B2fP_L&VL=LN zJXqA_=6A<~LN~vgutI>>;c~0xFma;5VDpo?md068`r+#DdwgVFYRaAVk?fUD-(p%k zXC_O$!l07sGDflvi$Ma*a-)^7dO>=iT9k*{HnD<4Fm%cG7l{>W*oY-V1Y!3Ck~0OP zMJnmqBUIu(%rYaAk+5If13$0Anfz4~&oY%kA`e8lGw((1XW36KV`Xp@ipDNKh9E8R z&ML%cb9|Z=q)R;{Rtg#sng5Faaj&P?ey-}P2N^`ppxG&^d{64usKMr|om^q}`(T}1 zL$%m9Vt4yo&n8Kt5|rRN;~NC|{%^tb82(3{{gbKmy&fK8pHWFTBa-gE;&gA6eWs{u zA;&iif9$+kVY7Jcxj+A--?dSUAuZo5czlsWn8ja%Dsg^Va&{oFObfrNh zW@Tw9!&`o=N&ySn_s{w9qs}oS($W@PpPq0?T@NP9C0wR>z2i=Aj=CjFo;M$zlpWun zp$$9l4#qR>i8MAgYEj#y*35+13Py`|A85#_7_^qH{k~g=-T6yCjd3&|7Krm5 zJfV$_q}Np$n9LJjbUs{CZ)3j1Cq$6BeDB$*5(C{T)=(62q6T>UY0iQ*)pqEXz~EAI zRdl%Re@dOBFS2g!z8ZUso5yevnjb^G^(IZt_&8S*hb>iL3|0Cu2ck#5r_PUb-p7;4 zbwynSbLndQVaZD$C*Oq~u69#j=m?qJUp@xB%r|#;q(gpqyZsHZ)O0{AS#NFf>6Z8# zOL>7(@~iHQP9^{Kl37U5JFl4-C0|9Dk*7t8ygNkB?br2LL#DX-`Sye>32CeIKAjRO z#T_0(3JckrIP8~nId&_l!icmWU&Ze_%h56&H4c+UUU?n09Hr#kr6vxWsUn#98r1}% zd?mofExl56W!G219BT{aEX(Po4xf4%6p($?{lxf`SV~HT%!4?dpLEp$%A{sTgT{aW z2@P_g<7K*-dncWd568$L9xM)p8k?FId|SOGN636Xx~&8pUYJYM$OCB-%V*1m2U=6 z+&Wb5-{jz3u9BWqP9F!RhMi;Y;BYCdC=pcreiKX-aj}P6bw2Czng)*wAUDLC8Vm)H z3)^pNShP;n$dh6egRTxo5PuDbpxUb(^&>o3l|I@EHS+@YE@v|)$@sAuDfIIpN{DxY z%U(y;-NYZuOKtFwRr_Br>=mSfv7W^t6xE(3Z;Y^F$ObJF)zcB)ui}`f*puIQm{}Y$X*C4@swKpK(3?l4tFWHBqC*52Lo{ z!vcXX^JIK*rI=78q=EwC6@iCTqy2JBfyN8L3>N2;G_H8E-B^59Nrd1g45V;ai)V2RPN_8r~-EcDJ*V*08+ zyK%cqs3<2`a>Rz)1r!JNI+BnbkuJacO3c+IaBMue3=PMyo*SQLZ)jEG)60srOoGbL zyaW{%Tt%><%F9)Eoc4L2RXr~n;A;p%DmG21xYCG2xQz~W!`~dr`5bi$>P1*);3vvg zfB3k<-a)Wm68lGG?#7ze*n?EhDtAlltVKWC2sMy)_s(ura#wbDZ;ynl>o@B~;ygYS z27=P3!BfGJ`9QEP6kIkY*|2iS$&y;41^=z=({n^hJJNzX{2q|^csq&C8moaQ_4lCX z5M6X&FD9W}k8)7Iu(!C(U&Ak>xkH6>fAX_J1mdB5^KZgOA}~?}$%Ne@i_?>AmsLjg zCLeRX`Y8*~rT#&D>(Zs!)tDD4=fqi3MW_a@Op|MZ+1I530VQ}LtY1UMDMikS($AY; z=EZG<?TLi-8i6XP#0{(xtNjiBctu? zpN14b%B$tMcG=|dq1jtRGX!s>;-K~`F)wP)cTds<4vH5AqB2Dx)UNWy+~yLF#EVwW zi|2}Wm*QwR_Z^ObVU*QL#yj!_kS@zj;eFNkRSB)}L25cNG5H?Dh{WG_UxSpraqD>5 z!%%U_J(m5XRCjjI&G22Q<;K?yg&H+o zZ8ilO$+cy_$ggFkMVbAKS2No`;ry6i-0@z=Lr;WIuIOf_r+-BHPvnt4+!=VG$#?v! z|J`2N&Z?Ps9T9b#CKH2{&R3%)LnCuV7v*dx(#3Pg&tI+M3H_sC)J9QhojY=c_hPmH z459)bc6xdI>B~?mI})SQxU>=TZBGdrT}iQ$q)iQ(eN%MvAkVEPWFKlKPf#I{_?#EZ z@mueu8e-VQsj)#(O;ic1s`coD;BsvHDR2EQ=uT_0yIi3}0X5^3Mo-u&Ixx%XUX*tP zuT@jtl9dlh(_>V9A(vwTz4sv}ziP6L56J(j>etU%)HSPE9VCldA#|L{1KQMNzwj^M z1IOkh%%PAd)Syv#wi1l*w);QW>GT@FIMn?3Wq6622zA2o^!rGSMr#C71kg&Q}FtV=%P{VWCl9;FNO*DKny>KmtN5o((QSC5x=4n zhS%&UN)^oMz{0TG6+89tE<84hu<-v~!V)MmOUWrba{ZzrS#QMW{IWmgXyPdg!;-`Iq%C%3qQW@gp;I*6qX^ zv*2IM%qR!jTE~Yr{nwjweEF3J`JG`O%KWN$Xp)&QfRE8%DEV0~F6h7%{KQ8oc%ZmK z`-&pS1DX|jzg0fW?jd9e01~N)F=qy%c<#eiYg7msQt;y!fdoIFC&CITeFf$0kJ#}q zn6~chM@^@1c}a>j{LaT{rdjWAIbQAJ!kzXjl*`S&l>$N+`d)m_{vp+&y_e>Pa{oZP zj?Xit+V3Q05*V?_0Ag=kC-|3eJok5BdD2gbqq(o>h?{ojw}0`RXwJ)PAU3)@)( zhaUN$$ghK%t-x$2KY`Qs;G-0`*Py2;)fU7k9?41j;Nryol2#({b0KGI2REFHaNZ6P znQ-2Q3hpbv2^&aC%4eieaJf1(CnLWqjo$Dt=#Q+v*!px(Kf=JWL;rD<#bxxa)4lFPu|DNNb$EPa{b`e-r)G{^)55gLO{pHy_I0be)abN0e4qG;2EZojS#K z>JUi5%fUbYgwwf<3Xesqr3-9OlJnV&7bwJ!ik(e{V3Abmo!Hr%d|jCpi*OK5GK-=T zzl{=H5jyhiE4W<~E`sQRo(u**-G-)(4L{has@ww58-}vbOEdHMx$`z<#TFl zKg6f`I9(nd_u)c}4;Sj?igGkohkiIb++ILT#COpoucxggc(;Fht?ER5MjfUonzvO8 zs=f)xPGW9y`pvppu3IT`wd~K7Q3LrhXA;l}ARNt)mvaWAw6(m)3th1Gl4{I?u17;k z{Jl!P2xe>^1hefFvjvksfjp?4YqWO>_3)U-7E=2zH?UEzGo!*hg`lLEH;+c zT^;^jsxoSyQWfP$9%lQ%D5)5%MY#xh-1^vxPS;kRWU`~T;(vD#O)2WMa$iyKXr1L< z#5wRe4vEwyp<##ZEvR|r*71Q&wg4hAssHr&AhSOiR5YVquel5WyY0`#s+na64deg> z-JC2`qSW}w!@z?7+clxs>8x4uql%w#mtX5;hf*?UUYDKQY8PyP!tKjS2tGGZ>jJpc zs0F-^S?CUEK2y`v7MBfX?}``+M#iNZt;e!B4sc0%-d>#UmRIV=0aNLzk@O$Ep25r= zO~$X-W%;Pz<$Jkbnx}f#thq`l>gm2aZM*p^m6hT~vd*j@({j~o727t3yYMj{x!4`y zvJ?W>G#S8YWfk*2Rh4^{Zi%1bfiS1?m{efm$WHJHZ#z*65C~3_4CvA{F}l9bv~SGR zTx)vD$P-I8ICV#l?pF5@mQ4WDYm`LRN!|uY34z}`?LK^GYAa>y)hcSZpDZme$1WbY zHmEH1t}NZTu6YnT>YyvcYHV}t3&k#L2PDOgk6w>oaTc!q4w7)W44Rbt>ufZJM@Hh1 zyD!nUMtXOmuD)YXCYNkVpatCk$zl90&3q zkn!T)A|b0z+8~s0b~XT#7htvFNJ~7E;W30eD))GQXaI#35S@h7Gx&5dkPDg#>?lx> z(F-z>KpB_#%8gI*&Shb^TLAx723%d|&l*AzgQhbvZcPa2BOB1iiZ2#+f2Dl#$yvRP zGzUj%KW1wxP?sPfivpUNXSDCRMt=KO9?0aLoZ-XV7d|ABVE8jI9ux~z7{Ey@C1^Pn zSKvtbJB5V&=)gxBC^9m5KIly}$J+uyfpbfb3ch)@HbdQd?NB6e2S&rm%7`(#_FJif zW{Qi>t%x98oJH>^qM5FAPvXd}#fTo3j0DQ0f&>ELJ zE*VHrKXT-|I*^`rDag;yX(+_7S|ac4He^r{tnbkXRJ~3F%&D!n_;apY4+5MkmbdUs zS&(D2zeJRi^_VrWsim`W{+E(Z=d2JryZ5TR3L)(RVsXP8Oc~-5acKFyRDSnItPsP8 zW@SJ-%<%zP00ehK%`=lWd)Kew+eJk%R$Ql9s9SNnV+`uupw@5F4c7@Jc2bwxT6qm& zQWt5|$mc?@P`(li4ka1$)}*D$Sk&k4kywhf6+B;xM}x5E%`i6$b9&!}x@84yQQlnb zqmhVs)nX%~f0BW})BEEx0xhTQOauz;>a@?gWQJN(RtCu!r)wiS32*E>*=pv};mq!> zv=8?TbRA0;TtK$G0Ak4;N9t6}x}#&bIpVAOJPcJSyczXqrJEYNYX7_DBwK6aJ@b-M zIBr3A85ds*&nCsQKvy6iHVEp={E;Thy-SxNj=N%d0-ai>e0nJ7&Yx$2bA>S*u+?UJ zjzc9AN>Bj07f3hIuiZay6syhX6>73RClOTk@=$W5Q}KL3Me~SbVc=8_nVDJ5@yVq` z7eY7m`?U~FAV7pnZ2Eyw2wmGhcWy$xRweaw64At_@dQ*qiGjgRw#@~WWK*M0-A)E# zB}zIN6aHP3%$yN!0SyOoDY#1KvFrtxb?=~ zQ8P;(BW%h;3x7eCidv}-DBXT-iC+#?3A<&vSH^ZSnTFr)%J1!MIW3&d7OC(8#8~+^ zWjmJ|wQp^Q`6kmyN@S90=XY@)`=wH-4r6-d@@omkBa2@z6kGG(azxEuaD$j?0-i=Q zMbEg*Y;E+`7q3mjJl=QvhwW9J6Ib1gq=t;HE*}|`9VbY+@1wd6qwZ8{EUtPeS1lCZa&iC=#C%+9q z2{vk(DCvY=c&k*M7Q(3YI(r3+p1qphi@s0Z*N+`Zo61 zvM{VaDGdJZKst*k7C^qltKRl$sX6j5)s6rx8t>oIuf{5de0obm^EIZDWcyHpOQ6q% zB$d5p>AaJsqIkY{fX2K(S}+0FH7Ldp5oTSw_$hhip3FPqvDfb_yFm0oCMeA;ElH;o7oqZ&`dCztS}pP z_@U=s60U65$nzF;cb)HwKK;ZO4~tUX?1i1YW0;@)tlz*rUO!^D+H(+I1zRinTpSbG z)a=^^quNU>mLOOlyYq9#?be7MI(JbKj3ffBU{Mb;K8v5+O374ysv#2ehEX+Sr=ptNkKxjRF1pbMJsAvT>XDnPoC|3jdyn zFv^P09vh1oZXHXf56w67j~D$Z6VLuM<#X^qn134VCilbnAVX0xK^|+ z68Afi?Mz8(9{_v52}L{q4Cc%KKqKPwdAHuBVz0 zYd@Z05sRICcPt!j`l3`#+SNtH9x`&pxUov{>!Fu1&(0wcyyu~JYKT+M|JC>+CHi1# zwX!;!o$Nk7`-{oqJx!!!tLCLj9oSoG_yqWs3PC7=4+_bviSUms>(N7+}yltCIGzwh`YT@RO}M`GETgVq=> zK$S^=*c`c;Q)SnWGr>EIHOgj!uUhGxke^3QjBC&`xCdo-1)Di%K3wAdYQ zBvZHgKlto4R#mCiF>@!W?BnMVe&vG=$D8ag)EB#Z{PqagnLU8zkpiUZYIlI|Qqz;L z^Gb(<&9P&^qh_({jU?l2r2*5bLGxxZ^{{h#7woRd`6=PO5(5KOYSsQs9$V9P)#s|0 zn_C;nPyNf&yGtGGy9laDlM!0FIk=@x{T_RRjJwCY!fXGNNVzu81J*2?3!=Mm8QjDp zx@aM@{du*~%sXjL>&Ejk)3^oX!rt3W=&S_`Pp*IW$qy98;F-FL(af{((}ydyE<{il z776EzyOyC_Nr;c2|Q}i?eP?3VYxCFq~vcJeEfmBv683CTe`P4=>l}OoAaxVt|vY2f(->*lgGLR*%y~38XTR|b92M1xpgO- z=^VmsKaZDNDK$*QpWD>P;uroY>m_8`w6i}0WkijsQHgg$s{NRwqM{Z)m%PTjSuz74 zTAAMfpvZQvPgO2aKWtZ}nd)*`YpXk5D{#x|}iunaj%;&9vib)%ZhdBa8` znaOnY`+b?^{D*|bbbUNTz+Lk_%DcuxyF?D*lJ_vB+2+er~0jP0y*o0Inu+lSmiCeq#I~26?59q3P;ide-N(N&Oomx zXHX(!e7@A=cot3Rr38s*Qj=Vdl{x?An35T`r!>b%vL!#aH*X`Stu`rK>3&W-mssA9lj?T2*&ha!8Z%$H=Y z0L?kRD5G{==j9X=4~mE4D%INzuI|xwYB&5OhrF(pzCcvpGYr3K_K!&dGr#3r6AM(* z1$JFae@O~nCHf#g0(J%e^9ea6b~}}YG95HJ0&JWNYj*6RRkb42F@XMFj_uASBM%UE z-yZiqrbfIRvcX)lFBk$nS)TbCbL1?xTb49u<$VR5YCj{*he(ZU8+ z&Dux1zT-J>0-oC4Sc>$K4e(uNpomXz5L=SjJf`2&<%aWm!zevdUJOaw=ud3Vr&o>q z>CDc|7WFy#+piehcm_=E)q3kOQtOP9-ZuerA*8R@W1deKQGPC|p4Te&S5Lkc9F>&p zzW#9skRz9YEs&n=JnH<9(yhyO#+N@#)zmP2Qtnr7r>cDT+;iz{EI#%Lgw@Kc7Gx4g3-Sks6 zZmjjSvfdc+BolIj!vSgJ(M5i!q)b&cVGQY+L235$i;FcOj|?!TaT>jn6`A*kTtcnX zr4r}Vo{Flf2Hjy7%Sn5VH{`sC0vu2O2Z3x%^cU;BH^WAAMqXblDl{rpvL#?Yo^P2G zvu-n=h0%1TQkxMW{*G{TJ05nNrLBK*Fh$LH(&iNC>20Sg96W>nPMOlQUlWM&IT?DnvtaeazNgxR*%TiV=gJxlP0&n^f>Z zH{xtwd)Mr3({xtpv9r9u_CFihiX*OIuJyD%yC1U?HAe+y)FL+uQfLKNcRvzntfr;; z%^F;PG4UmBeHVS(rcqnI-*I-!KnJ}lG$?u*nXNx*4i+M1qA~`*L4=Y&>I_zjO>0d< z764r@qD~PVk)FV6WB&+m#(^*qW@RxHc3)5x>vgP)xU+cWg ztYcv&<(`zj4ie}SJ1xHq$lgy)xKP9)j%w+< zwbiA^o>dBiV?i@LEjjD2R9?9dma%LS^M~64@IhHce+FT{PP=Aty9g~v|)5n$lcq0h$e+uQ4qLgh7eJ@vc z2U8_C_&{)%oNr>fx!6SLtJH-Db5+e~%66JHE`X|fMnT9E2=-6->d#A@rD%9(eqnJf zXZL)hm`peQJ#+j|$Uy<6(!$TJ?1T=`f1pnA%f(s00R*yFvG|}8XIa$AqSp>uCZq;D z&~#N7vqm`5qUa z2n6HNps4oHAVAcD5&rx?r#H))4X#qbvSOIAVJC4!u4UH-IC!A1#rUfMs1vm_a{+a}|0I+!0ZieBk2f&tw z7@9|!9D3mNO0;<(Ff9Z8Ts9(olm?F)V1%#dr+C4b=s8`_6CLG~ zf)<`UCBOlB$b|NhzJlLc{TKg40w`virYi+n;Qu8EEh2y@7lp<{GAo;O?u4A$fLtg zFiz+uoIc-n-Bn#4`*L*&IrjhY;iX5}!Ny=8`SA{Tf>`y>@IRyhjBC7XhX81Qu)FbG zq%6y4)lg1BJ-x(Po!*v77zp^MFCo<|+kjUo?og5Ptk?p^D%Haj$UD}-yH(mX8Lg~% z=TKQ;SK_SSQt})bof%X_=7Vmt^G=TnZ7=J)i?ZdgMl=B`DrkcMZwGerWou&~!;{m2 z{>wSZ4#&DO2AGBhoWSUVZkT&dBwFwZsXTPLw`v>9S99ijt696@5@0${gKr<+UOdH) z05oi@acS_aozqKz@}38?PQ5Lh4N2Q(=m}{Yj=;v|#3D%~3JgTCUutZZ*{2lMy=dRWKp`~KdZw|| z+|2%wn7gW?u`XH~HvH|KCHlOK6j&qZBE_@nc}P3bowiB#4C~pK{0fHg`Ih2R+W?i3 zS9!glHx?~g#et02l>-mA9$54kvOX#u^LTK$0u6H^TLzIQJ|agX9trb~H~(8G;7bNf zOZ$r+pZ57J^&n1_17{xImZ-Z1q=L~waBMK>#dm4L(WTg+1NQk@%+6x^c(4WW`@aO( zNzMu(I$z?j%RdRt$7-xXy(h`Qb#G}LFzgeNm8`$1!bE9yrQHZJz;l2TBnSN`yF1{A z(4nd*%GVk8(R}aaKCO|z0&9+=f4MXBoEvzPM;b&G#GGivUt5D;{}zY)nYL#m0dznD zX7R_=heVbJT-E)!Vt6>krdts^3-}FGX&kLe@wI{(I63+NYT;G7+(z|_Eef7{$)~ec z2BjqVRyMV1!5H>$YUy38julV(pC~7FG(bzZ4~ror#vJW{<7qZ8asE&1IxDlv`319L z%qZ(pKf(^=wQ}=io?O5|odKvCiPr|1AiJ2JWUjn%U`~Y^zw*dlv!54Gw5TqaWF72I zQ%BE4QvdnaKcYt+%3nAPntom_Hr{v%{7M%*DolRwq$ln3Ox}We!sz#0-n+;I@rHq^ zHat2WP?Y#tUc1|t>P-H9F&+wb)t3OvZ;+4LjUe_FrTo)_pIJs&Y*_q#ax52#NR%LU z1{=TxMgYyt<=1lnhI^Y)ijLAHca;X>v^W8(x(GFfXC`P=ee>x|%*1 zvv68$*`wM%7pVOgBAO`wmJKJq^4*YoFBqVmo6nfe7--;h)7ct+KtrQcyRKLRu!jJ; z1{A~L*-GcgN&!(XDq6`p2xH+!u~B=K*D^QYXYJC|oV(ZW_;`Zo-NQ1-r>6(uT6~lyIFfN#F;IrhRT^0H63gw6;BNdhLCknDlw#&}nRNi5|MVdj*J!bF>F&Et zmnlniFOaDAeOJE)y}yC|C$QpPO6<*5C!3s4ez?0l&_S)p#KQ)BjL9qC(bIc=M3MVm z9aj44*FfI@VyCu11r~P@140WAg%APl9P_kKHWGlIZzti+@Reo-jd~)8-ZqYqmf6jP(;G8Y-;je}Wxo+as zLAxxe-NE1qfQ~EXs5_TToiD%^nU#izItLZ{^+maVoN6J}S(BwxBX+5|4iTtyJu~^7 z3*XYjP4;KXlSjQ~Dv@2ao1K1>ksoP>QR}57`flf@#bGDum<|TqhfN<%)S4fqJ9F+y zDY4$Y|73{~iPV=s(X3~Galw(ge7ADd9Rj*vw67jKHJWaIDSqTS0c}OJyZk9t3>}Du z-EJhCB+0b7@8p3~8YNedP?qn;h^VPW+)Mac92Q~#uc072=zdhR8HdWS+waD8f#2%a z%On{z^yU20R3%kv0N<45Jk#@^q3~L!WpU_IA9rog{Y%7VoF?X-AeS!T#qnUp_M3>| z@M5Zi-g=+zrfhU}2ord}q0Yryhm*1G{fCR*NRlmU-^B)RxvmS7A`uK(7=MW?wuXst zf$tU5y1`>~sz7C7U}$J4AnQ7;bhIv`mYQHAU$j5Q&R#_i5*RZ4Dskp_7#7!66Wi8d z1Q5M()BZ$nFqeZdVWzWMLW}u8QYaes?RSRc^@E%Qy~ewh(aa;t*!7Bt4_E-W)n@J? z!s?Q&{#(7l)Z0$R?RBgbB~k@84tTgQj}f0;*3qJK6I03S()>89Z@uB#&3k1Q zCMQ)yA+l?-B`au0)KJjvYCF&T<4a&q90P~e_@vi&MRsvJ?k4?kwik$(s!8y+j=vGe zyjIKfQ6rhY-mNOcT86g8dT9pp{S$HlYg_@g8VkL_YPt*Xvd~-NzpUl!LuX`%KKB=k zi%HM0T`O)qxIzsd4z$kN>)z+P@`F{WxF34AZWXYO0s^{Hmtw9Tw;(ZuG91IS4SR=6jPo{GAWm z%W1Q`J(CZGQz2O08;cipmQ(M7|JgNsHB2CHC9p0C1=9FiR5y%SPZEjOA-KDH@fPqI zPSWPXSv12eK9?UUbD;f@qI)(5Ir~0g3+p%g$KcXqwu8;wTfV;=_P-0AY zt@RjVBg<+L!EjNRwwT$c>V=)NF-W&_z{Awz>a?So5V~G1`tYkxRup2qDg~F4h`pRu zA2+`}m8N^ZMz(i_wEIpTs`}eb|5M@^-1?X z>~5nL$0bft&x)5K=ZZ$0W)+T=-!=0{9L2Ap<#OppK08RAN@a=D2zy2qMGw^dt++x| z8#2-{IOU|hY}J8T1RK$PaqH&yIww*(2cX`a|70t92A-`But4-U1N5r~ZKCr80(M6x z;z8D6HR7h3m!Owq&w^w1-hp$mZ9OjO=3S?a5(knx&;0{`cT%6yP|fbQvZG&^E($y1 zm-A0u`#f8EcMwD2y7}*Q^%_lmK)D>+m#AC$uNZmkdY*{M#-NK%Pejb%zma%f&x$L$ zPaChq&5*P)m2B)&lP428fe5xqtHgZP^7qTq1WZ1?f7+`r^gfDNCd?l;7$>ST*Z%^t zITVUJ)-Cq^KBt8->Z9Ydpz)*S#Y&Y?mroeZP2;v7fl?1Y5ivHE*!A7tv)c8EU2a5~%`AMdlrf8{{&*mo-T?GK2+D=uUf|k!rRBJS5-QaiESMl>ou`BaoJ;f+k zyAZ){R2Gwm-pwnAXv(|z?oLj%qK<=AF27l8I_=0fxEw1xPlv;TT7PPsVuz!Fe_~_# z^F2~qO;i4g1$>0`j>n7{&D*BnV%^3QW z<+i*47ieEwsYJu2_~JX_6rec~Y*UMbM56QWw*XLO25X))gip7s-S-5@E-uvA|E85VfA5jm zt#Tn2DTuZa&<06BtZvS8Q|O238mK<@&|<3=BHdA#mlY-;O{PxWvpZ03SE@3KG8E}R{JN)*C zI%Mp1Wilyzwkb{Ae8|aOiQ6zzf#^Hi$7olHa9Wzl$XiCcw`Ct>PoctwanJ$ajhB(u zYIEH5(pNO4H9n5PA1kPgSYl#ySIP&h!cm7~J|E_ctR`fe_KjZeLePGy+0lL(no#b* zF1;bE5#+9Hsoy2IW92Jf4gGZPligFgba(yTpx=5hc;-Rgm3MNn@yrtQJy@@lYsdME zxm_1_DVgB;nMeUX!{9D1J1oCO#@%b|z6Zs5bM>T04*zEz65E7I&0i5!Y2RlrFFZ0Z z7v?)o1RLpO{giBL(2u7}A$eUGqzgGAoFngmPJ&;_x*iYK@4cgt>D{vBhQEN?@J zQk5}W$R-U3Fl?O`nS11`y-_X3X36}vbG?zNM9%Ghx5wNZAJf&5*tXGPs>5{;XuHZ|xq({jNV3vthoC8{p3&kI`9{Blh*kpKpb=?$aO+fd1~)H&VitdRyc=^uhF z$Vn{?sF6nOM7kh$qH(APqlGDvJL5D2JB(vhzM6yXH^mmeN%`;wDutH4&iL%Jy$|u4 z+{DTL(2V^$Z;{1Go$-EE^dUML`0al$>I1*?(5j$dp?ASno<9S!6~}sL?jCep<;d=K ze(Boti-mMHU6&NSMyHQ50GM3&=5RDT^e8yHFO{T!T^B(GpWfAMlcTj`es2&hme#n> zTkbezq#c#hZ3H>+8w9VNmbId1-EY~*I-FNM2aN3n2jP{JxAG`*uz%AWhCRq%#0EmZdVpl8FZB^F$MaE&_I3KjHa| zPC5Z4)jEADHFz#~F6&*2p*obAXref^c7(|PtBxy=hq8P7Gh;CJu_Rf?I`&HTC4=ny zIwD&tdj{FJv6bvQk3As_g^(g<+9-)sLTK#UlgLt*c+d1af4uMK{r&tipO5=K*E#1t z+jV`<_xg5tcM3Cc?xb@lm1&`%@b$;{7OuuW=nl+1ys?ZcIxLF0P2+RQj@x(Zg1q(c z;^R0zMt~1-WwgQKIM|dcNrjnwk-dQ~UK{IEYr*nWByUxRL0v}z>0%gL0sqH5o>Sp$ zoB$=>LjHVcj!Hwjg?jTDV|~F=UNeS)#hK324~_P z2c5=F-)1YJYcWdNDQ<-8BU5}3IB|O!Uyhx2+%VlK5XYa%9}8&gwtw9r!UYW}rSdHR z@Q4i0DOwDG9~^EcB^Fl}_>L(u>EWA>V=ed~A0Mg4@B&WJd>dCuVTvF5nV*4TTA=;e zsvfuO8YhIkAx5f2^nEVWQEnJ|3nT(C<}Cu+)&T&{z9=o)>Zk47rDxGlh+G?rBJbh? z5vZpN!&S1BMDNo8AacGj<^iB5N}4zzu>B*0FkJB5(#27)SQ${$>JZHcDan!gZ?r3> z--c(D;ixQ6Q0z8;`B<4ww7UR|7Zhs+fE&ocr;Yb)m*(}{X(=K zU-&E_nNE z;{e(YaEge=S;kG(xb0UI43WZr1AS9jrv*8NEUmh z`^mVEuBls%AjMUd2HNxi~as_bd`bj}{|zYqXjzB^LB+cPfUE(ByytPDW!lM)uc zT&@1BBq>QLm0S!ktdB2**I0JaDY?HCLqJ63610tjH-5j?H4DcILpGN{tcNf1#8xZ$ z2pg$`>h&lnqBjtJFkwQIK$HIx18`OLjBdm^xvWH>A!4rsDq_${-@oR@aT^K@qya2+ zAF3a1t(gc118i>yz&s8XwULrp((_=TP(oq6d1+7sC^LgMMh7&)lVY)l9lB!rwP@xn zMTP_hUZFO~aRM-2WYh*TW#n97h-Mcn!@67hVs0EDl?${C1uu$chPyAPqn_RouBD<3 z)O$(-VL@p2bj(J=m)QLeL1+`M)h7Zw!7sJl+=mF*^-c&gQoN9;dosFfQm0OUCaxH0 zqnw$jF|B~d9DM2Zo$R#4mTN?IjAg2~UdTs1EoQuFSQ&kMJORSlypNw2j*kIl;EM;J zT3;ic2}%o9@vwOfo`J|2y<4ghbnR`}gk}1mIN-8bj)d%rt`brTq`fRJjh8C`h}0{A{~>QfnmGCJ9o1LreqqK3*f96Nkj-N#$6 z{*V!nw+~2OE0I|4>|csG+PY=?wM5B(9Ym5Vw+?@DsfFCT7|#UN#ei5aWq*JFa+mm{ zV$Ao$#4S)q-hPTk*oG^0wNqCJI9F`Oqx8)o(2VkM0>5tb;I~K95rq2s*V3e$c(LZY zbD!J4d8;dgC;O-*6|f_BPQY&QN7T)pi9L#X>?ebKWZ@^SzPXUI?{Evl!V3{;~u+&;yhh%_uNZs9xoSp@x`Y~qomFRTD zM$uXK`#md}8BV?vCge9#559l6mq{BpF)=~#k9Z4Q17Bg>OcIubN6|!AM#8@j-14sb z28a6q8hpjx+x(&Pk%fL&a*(I3htH-xOQfTk=!nJyH)3`AYoD-n<7Twm*NzVSQ%EWl z5h1w?Dy%%gBi??`gNZc!}Qrx?VHzb3M zg|HFBC1Cj}px-+r)WlEXMZS8J1&==c>Fht+cN34yiu|-Kz5TJ?y5@y?EDa+Uq^1*r z6TorI>qgY@)btodTn(CeR z$O_W)4KABaWH+m;5BD4@9~2^5DZ9Vw3sy)Ir#nN<$LV*`OEi>AWwF$Dlu(TgR`+^7Vc@#>*28AzYT@tC#R^|nCslesJ^HS%n*fr!u{mVz z%dB}Q;vLMWR8w`Z!ct>3R?&2asm7nX{U%Z1$RcRM>k#TO7)Hxc_oRF0wYluK$?#+m z(#R^AR7mqtaWGFG0+r3gzMc?WDmCuFQ2*I;ke*+A(1tkqI;Ti2yi<#fRr0o-cDg&* z$CMG>8`}Q)aO0&aQa~rBLItjj&jmL<5Fyh%b8avGERw3ld{m zN(v+SGIQrgoqLh>v`~ad8gcS*p=yE7LvE>7!P(%%9*ZvLb(yHf{0AL%@Hjl8Uve4m zP<4txXTa7C;9A4@6V&ActmEUZp)`ktzQE(~9wnc}(x-dPO0WOOnoLn-P3{w&c@Xkw z5|3@QdZF+e){2NYmlj6-mal*bWO3L~F1~cN+E{R8*1!M0sIytfp+d%npTi zer&FP;odDcu(_P%*b@q>$@eY(Zkd&DXZ0*W;YZQ0E2;SYGu)+3f=wdaW$*!#dTA?L z{DOdcI^?8b)Ms9WZ?maEEE;VN!;kN`Ss8#KII4fi3XU5|I-If9mFL&uASbh(wWL=G z7?-F`JTsre^wfZiUUpg34I9z8%5rMcea8jNl|@_*Ir)~{2iT=c5lO9fF1k?%y9IO} z0Ajkao>{PN6Vtyo0D~Hh7I1Y=7};SKxwivBC)O5UPvuBd&b$ z4!$)?FNdEvb`CV&^y2{oF0QYh3j4dh=VdjvGIhUoMDk5;W>15Nh3|k$Q9#>59y+Lon`J2^0Vl%kUEuwSLnLm_Eosfu3{HTFUI=|>#pTFz? z`_#3I`j+Enuq4AXEcX~!O!|ucuw%V&mpb1j@sY-XUwHP>Q4Bfy$Rga>{yxK>ObQ+MF=n>?9JAh;NLS9nH{*t|`vz z&+Koun!QYG#SAsY*tu^OYnuu)3MZV}5G`a>?%p$V(OCMs$S*{sEBQJXn$u4#7teO> zJy|=+toC_WJ{;D4N3hz`fB-ppbmBqGo#cH*g@uZTcRqg3b8hMqzixnLAFNHn8zsFb zx20jzVmPOBIPRVsUsG*@`5w`{k9{Y#qfm45{ou5O@r7cH!ce^3aYD7k75l9h8z^Fs ziaOb*TdD~r4Lx~8bFpPsH2+7+`hBMXzNaTDPVg38hb0{=T$-~SAsoj9H|Yq%4dS1x z(OO9p=~IploUYIKaXQ^l>VBA1V68*!?euOpGxb&GW(V_$NV*qxqHXSUsMR#loSEB1 z8JcN3{3hu4Qhg}OYS6>V01Pzgux07ZW!@Ly@`WuBTRXL;Ie1w7HxEh1m8!XNZ!YahTSuU zrbP9WFqj@7ot}=RFB>l9f2=&?c$rb)=wTdHub?I!i=fwfjV(U+6>BUiiykKDl}E1` zAQuOx&m~)%{UsQ4-^cS;@3{{2@XUNyA_N_qwt=L$he8ODAIop{IDE^;?@NebqPbTH z3~IKuJkvD=H@Y9+!Nc;N)*P_5H6j&L_En1uM1I{i-wEMpy_f@hVxYc8iB2#mbMjq* z=sxJIht#{!yyle(2<-2Q6AHlJN7x&Y6WN`!1o*mp1Plx7M2dp#xswH`pu0M^Rgnfl zP&+MjfKBW22Uh(k>!kMRgD%zcO(a!1PNvbwvxh)u@3%!2Gv(Vv(luV%TT}2U-(uIq z#{ae$3AQ?x_T;0Zfw!8H*(>ZnwXYf64j3v2LMr#ud6aHX4L5hj9-F6b-eJ*r8n__v zg6sTYgVzT$jszAE_@cO_!*Rz)b0T!>lY8QyT4Pk(){{l-?{2uGh%w@pv@k;vpyZ{a z`HQ4RuR%B%JPT-`pK=&0hO4#>k+l9%+nPlK20!e>?SQ$0qLRx4)Z{+1h;hq)F?hUarGmY} zS~2`CDW*vHpH^?Ohpp7aZK}o5f^eZye#~8n+bjmSihsI92@)Mxae$P4&g0I4(HTRW zl=F2SL^IEcH>fNcSS$U+jM&x?Gs^d-5%DAW+3jVEu6KHKh~@)j)#-`Fu^XB+U^%_@G0x=fyBvyTl1f_bfi5fDeQJ~}4m zj1lP0O_@If2|{Ir`X;fMLidu@OY{=)7&&mayS;RNX`TKge(JFWHy!l7Yq(i~dX3e! zJ8Lc#e@FSbiJY&#pL>G=#~uSAG3PfO0N;UamPpyzNxkB(!uOfVTNIT+JKi zkN~pdb*7Cwz4HfxtnThgjQ>7M{q6ku+U4TU2~$4S^uR^oxF4VKoMAtas0E9VePVr2 zi`KX$1QB1e2 zmL0~VQ+(@eNsoE4e0N7kl%hL>yyTxOGnzO9L|s?hj^y$p12OxC2^zVfkC7j9(Ixl@ z675(tNgfH1t)X!){i^142+a3OGP^$~qSKk?own->2($!IeXJ-_qL*qf=*pI5J1lVM zdSsSaSRkt~!0^qOAyqsM0-qJbL;5se;IK$REQeBosbpMnzNJc083BUcToBI&jNJ1_ z*qC63w9QLy?%SDUQwbu`fSO^pzs(fqjYt#dO^VEFfcCc_lM6vSemXXS5{N^f#&jPy z($L2G<;|1@k3rdixU^;VUJ$}Y3sY|I{PI07Jrwc61cX!JkYx(M0D=Dv{CEHZTmaYz z*ci`JR9fCrVSZ8`pTWNn{zvkYt)jG{F5ucRBO80>#p@JA{yWUT0jgmLC6pw6_he@a$td7C00rPDUU&k z!y4n6Q$)V~F%N;iK>?BsjdTMAS^)3I3jysn-JB3gUEqzYUy8^E= z)xRlWchCQ84+8oRL3+Xd6h~Ref*rHaIBoym${_$NqJ?CO{cCRlBv=oK>P1s*gU&%f zya7Rtz~Pq#iIU6q%^xKGYXkl%U;($M=HoD&0MmtHF;5GycNpz${>Yv}5pZ1y^#61X z4=IGEHXsc+DBHNVGLAxZ`Ogjb)4s|};M>R+a_#&XSqKb@{B;af?4)1R6UU}Qz@MS6 L*@gSsuJQi^8(ozP literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200001.png b/tmp_prismoid_2200001.png new file mode 100644 index 0000000000000000000000000000000000000000..268f26f68733a1a64ca705731b1ae9ba376cbafc GIT binary patch literal 26583 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 z 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 literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200002.png b/tmp_prismoid_2200002.png new file mode 100644 index 0000000000000000000000000000000000000000..34a7de7b9952683da04138af94c34ff08be980a8 GIT binary patch literal 27910 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 GLjMoqZO-Zd literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200003.png b/tmp_prismoid_2200003.png new file mode 100644 index 0000000000000000000000000000000000000000..c9f193a86754a02ed6cd0238c3a35b8bbf202044 GIT binary patch literal 29529 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 bp*OP0-08#nPPISV#OHwy)xW literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200004.png b/tmp_prismoid_2200004.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0ceef9406bcf44f91dcf6c22176249f6fe11c4 GIT binary patch literal 29907 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 literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200005.png b/tmp_prismoid_2200005.png new file mode 100644 index 0000000000000000000000000000000000000000..d7877e1b9e739bde8168e7c1bd71a24a43500baa GIT binary patch literal 29126 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 literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200006.png b/tmp_prismoid_2200006.png new file mode 100644 index 0000000000000000000000000000000000000000..6419df1b7d248e91ad0c2e7d60e9e2f5798b6684 GIT binary patch literal 26944 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 LQR_Kc-YV>W-8-)^ literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200007.png b/tmp_prismoid_2200007.png new file mode 100644 index 0000000000000000000000000000000000000000..77fd359b7fae2ede388075309fa7dcba8e0b1e8e GIT binary patch literal 27105 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 Vo+Yx}RYfg@Dmi%Q{{cqDSI__e literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200008.png b/tmp_prismoid_2200008.png new file mode 100644 index 0000000000000000000000000000000000000000..02c8c139bd3c591d186d26bddc7a06f5e764ab9f GIT binary patch literal 25459 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 h;QW*b95W1OXB(7)XkX^Qtn!F4N6Og zfG83Ib2jS#eV(({^PcnNd^+o8Efy2Mz4skE?)$p0O~_phMdFJz7a!j}3y~(L0tI!-KWXfP7M0Q|g zOemSfSZ=0Yg~~AcNyGJ>?sP(f2wq|Mc02R<+aa=d>E3>Pb^R+QYRyau#i@+3mHsHJ zB<5+!8XWiM>wfI-&Z^BV1 zcrd1(80>Dt2(Kee^&Fja0XS-25rdeFyy%X^NGrlPA+Aep)NompG@K45pM;U#AY6ml zZ_K=xLC7mZpb^nRXc-h6ocryVINRHKeE1fbH0E1*o+X0yCC2s49e4JQu$-Qs326!> z^yHUYYd@QiJI(0~B6&`}t_7xG%S{hu7BQQn;RV z_5snNB=y}_ss|s&5hMI~QdtTS?}-Y8z`KF?M)(4StQc%>Vpni19)X6#hc@ng9ZfHOih1*w>>fUW zz83R{?;|>5z!U3qx=rM;omQMlB!kbw=sYCDg1KQ(f&zKQM`VnkIYM3!gusk0v30XX zV|rzaW(wP&8yp`Jf;E<|J{EvSt3jX|dutCpQP9MIag?4)2)@tWXHv!=J;YE!zsq9j zk3xCe8KqUqE9>xmgTZiQczNPPaC2q@PkN!PUNhnvGb6sn(QB{u@JVcbZi{a2l_h1S z!hvBa1oRRNNT7s%)k*NSXvDagpV^hKmKsAmLk9?5O z%1gyE_DsYj?`&`d?knXUrYp4{ zL-klVjv_v{c>tBj*DFg)X~EhQy4iOlOTUmc00K*idEMT>N4UmWrBC?l>Fy{2L)3UU zK0ldp@V=ql%yRR4&Z>B2UeSQ6n!9IUeyb`7&@g$b*|X#9)H7hX#JGow^RV&D7a8M*pW}Aw>a|$} zm+ZZX&BRV=qsAAyTRI1X}gXF!{K|z}a%4!bq9bHz_WzIz)gj zi|MX_?}&zHr4KITUXx*M$C8N8*>V@XYw|e5@UFfcKh9+(F}mKiC+_>f^jb4bT1ryV z0@E2z{KcViTM028?q{z0z_ZESAYVKwkForCyo;v$OQ%rXO0Q4(rLuLa7Cx_dh~4hx z=!O~#?~3a-Is6>VNK52>Y#%q2GwjMdf1iXy;{w^*#Li1y)8;*Y(qE;G>uPhmy6l;G z&BsfJ=|X(2--2<~M(k;0&y;W1on7)bJ3HY?5D0<5xc6hD#gmhaWXe=ZPZ~LWODXT} z$9}SW;Dd){KOl#tOqX};@ez(rnAY3P1s=>A3T%yE>!*A0ghjuIJ(FXh^m{{rbq^1{mnr4O|&9h z1pPQvF_mI%=&@BKL&qz8+(WtF$z^I50+Z>x?5rpTso7@h9>Z63Ovv;W2OU4NSQF)< zpDMDJZ`qg_f0^6}y3auN$5@wH=P&tH-;L*PHZVv+3)Y!&P&b-A@Zn!^wc74}yNapl zEIEoX+Al2SJmov0!*X4SN6u4TV&-ypi9N1}dRQN=cqZ_X#*2zsiX z7##P~s*Fy-KQ*IlD}cLZe=C-Slly6}gr=^ijXL^({q<~IT%4}SQ63a;uk~}zhU(#_ zMQjC~i%g!a^ou-4nCGuz(XBF4L81P*Omg_nRIQ0XT|t4Cz#-OGLW~~WIp~}!9OFyA zvd*$fwRY){J~}M5pj4^!*XU&3v*qsJHFl;^J=FvXFI$<2<-C$*FpJ_sdQ@V*5}prM z9R#6$VuEfnVzcaIn8I(-y|O=UM@!{O?+XrdIENoIxGxFUJVz()PNZ4VI8NcdsARbw z!hKy}?|QKGqq@Ijdn^e3mh&lwq6h1(Li~e>8>>pT_kJCJR(03)F}{=#-~zp|azA?_ zABa;}`oO7>XTgs5)*BQ2zndQ+JXe#^tfhgpNB?t9n}f2s$Rf?wlJ@n!2b?~9!u6*2 zzs8spFb{JmCc4LEsY$9H7L}L!*evC~hG-93)9HBKe2poAtFjqiihJ3wpeidUQaLgd z@W|*h!q~8ndeS2_aU>n;vDAdM?AQN9x{w_oZxoC6zK?;xtk>es~DRA3t@} z*k?Q8g1*=bW5#}Bl)HUB9^*=_XDI6*E0=L(dBx5}rV;b8Ko_mx(Y!HrJpH;>NK~H* z1F^Ko{$7cM&Jc0wsv|kA^YBYeah0;pBdJnokF(2cZD~41+X!x~4wG57?r_642ceEB zp-((dtLhE1vztL%bozMx{d$YmqHr3TZSFh+gN=OrG<&~Rsr*u@_=x(Jjj3{_l6v=n z@~+w5S&l1lqiN*OJn!HaWpG%4-#mGe(&M2C6Y)IL*2UWAXswo+#sfy)uPOVRjPhu_ zK#wm~C&{BJBmE9fADJpA@4Bw^@_r%ND5B$YmdEf5PK!+WVm=31JjqCnC#9S0c_XBO z%2s8bzvPvZsf$*(AtrYHG?5bhkpv1C5q}(>Ckb!*T_%8ti+|9aRkHVV?fBTxtMK&& z-+PF|!qVcixM6$?Z6%E0JSE9Z?#r53cpKx{F2$boZZw;&(hM0(%hB0y@doEj4(p-$ zHviTt_a8n~bL?zPC^(6DG0toPFqjY)_#Lz`V9(6$!tZ5K5+#)kZ zQe3%+ytUEjbu{CEOSs1kd@N|rmn@Cp7yl{3Aa91nyjiMvTE=S5ES4`YfwGUnXrl#p zb~^@4>vkVywGa^PnIc5IPpGdVF&L&Y`vrxS*Q~^kCc`b}qpr(ii&_-vo%^&k_oMcT zba6^8(NUi)L(qgiM7+KCCCTh_18FT)QS&LmaZk5`xqXeX{iLB`MVf6x z5l*tw?3t+??x`^=4(x=KSuKfh{}CB*%%jANdPJbaewSg5<$T`!@VdxjeC)zK2yg|2 zBpwwtqDxs$8)le+gpSrCsrSU$Fc8T^y8qW<nC;;4JSzgN{@l4<$I@RN!`~tbI(}!&CAK%XpX27c$& z;Ytg7vk^?;=i}of+rv2%I=bQdO>X5W{Yip0A3{HmxwPg!aTCDdX5Cji25$3PgYA7Z zi%CxMbLDF5K+?WdHT|oKQ_tVn&vGcE)?NA{tf-@;v0N z$I5p7gt^ywoll{AVq*;$gb)8orx0Tv1epoDXn%% zy>}g*ExThTnmZCnh#79XmGxAwtl+pr-?wmcrUYuFe`5?AoXU|wWWU88wBjn6{+%?*EJ7+GyMYdU9qF=C=y;$s2nH_qxG@PSqV`Gz?l*B6G z`D^R>OhYs7?Dq?yma9|a#rlqi3qdxYxD0EQGBYw{G%ZXFULO4%X%%|hDVOwSZE<6) zDEyQ!X0dabhL-k2TkYIgsnNEmV+b5B%ZjCR3ch81(-3Z8V9-Poh97qPw8d(7v_ROi zhm~zLkakZiJ+PHl$aXi0ivlv+`+nJWw&`Xw=q7_pcU zp1SVLa*5&*kTk6iUskB19AO^dZdMwd$lq#m|KV}4%~iu~P^ABBsaU*Q0QiTyARIbb29f{;z-wUY+`-m z<7<}Gyie+;haplIu9o_E!6}1rIpVF=THz-hjAv%WvG4TI22z=QFI(}6LI%Nl%3#T9 zyF})8yiV}J@Tk@R9mm!o^Nvw8mU=yVK&nW4?@FMTz_;7E(&({N;W;nI?dkgHajwD6 zt~VNDpR$#u(`n#(cqRBcUwWy07bAYh44Hj5GS@r%Cb@WsQzH&&Jze#ls(GgE8XU~IdV~?$(|diwmV<&pbuNkCt@?0fZjc$4!nf}1;nP!fLSa$Sbh>GZE8oJAnStI4ewkPQVw5N~kn<%7SaaCG5k8ed zUk{0Vk>CNQ_Us#fF-Ylhuz@~O)*^v%!(O&>qf%rcFgCC?sqODXRGYg*$!wL652YneuD!C*I!z2d7?D$h6O0w;s6yA65a$@#e?Q znbHfovsA7W<)i@ilokZfk7cV)y@PW%mGvvjcFaBVJYc`?Pw11HrAz}uUQY)3wvgCO zD%JFQ(bZwIcZo*C+~bbjG5|Ea4$^Jz-arl}GPj>Y&ehe=XnHs7l_Vf4+{%^#{e7eaUubvGD_T-yw-ujte#dWTN3h0$Lq9|0jSGqoSSzvo&-HyUc!)uPVjw@=+96u-kghe#uX|9SBdzh+Rr(b@?_z z4&Ur5Y9mYv)*x}<41mBN0nj*Ne>eM6-RH_kT~}5_TqI~cZrVOKNkSYbgQ^FnT#7Je z=9)m&MS*Ua#a<8c0ttiJfP`@!(f6{^RfS_XKCXTqw z+CHa8tXT6(20bD}wi%O(F8PxtEpg<%7@VJ^_<(gHvx+sDXed?M>5^(O?@zg5GclXY zZCANX3`^M;PrrmSP4TGo>dDX7aiaP{4KKnkH9TnYvNLfmus-^187Z*7M)9(Bh=0H% z(%ZN6*5|7ARuGwbevE4E7E2pFiiG4nSITn8m!)<|yfMrnS{rek$dRUvFb|N1?l+_N z6$)EQn_ZqixNF`yaGr-JzMxW+L(S9v)<|-(QC2s#mr0G=nGrwe7k6Ke9e5ZN9={k! zJn(Z%RF?c@tNV2?=4=D*=ZEzp~{~4R!X|;l+ zr`4+2s|b4o#qlK54?lqTta(mc$eo9OPstYSd})mSd;{7|4_p?66z z`JINyLOBSbugjEKm1)RN4bRujcSMYm=`|fPj^#a$e5U%bp!E1DZ~NTn42+)c(G@2G zx)g+OWxv?&(i>x=>naz0jGdROKA!5|@fSqI(GQVn%2S+qbaC5=j!j)aPrs0IZoloi z>&S0-7Rj%emnx{(KF1Pya%|^a*{6?j>PGC+*>~d$z+L6him&SKnCs>C^h>w~dr9pV z?8nx6wjGWv(@sYmd7Ao}afw_sxXbsnb?{k4rg#6TI(e5Q8zzqK69`WZ%EUx;-(+CA8wd+IzkuXttP z*1LLgV&Yb34S7{0-Di1$g;E<%k#`KfkzPx2)8cu`zcbtX@l(I*nEvRp+P6)&*n}te z)kwn2h31#u-~`-uuf3Ol{ZQ~H*7r%?2j)%>8q_CdY)iQv6LpOsSvW*oQK)i#LtTdB zUScrSe(WqzvM9de1pr4)&hD-xA-`%CZ?nEx% zXVdSPBignhO|Q-tJ?`FJm5iTbDb!?BX7>AFHahW2sgq)iYCepo-|i*oda=SnYJ2E$ z0;>^B?J)UuG7{SVQCfE?*Q#uE;;tUK26ddaDcks&d?fC$>tV}6Db+2-(o13Zn?<7L za&d^=(8~up`vt7<$yW-XTZ(yA?t&gEpQXE|%0&=WSpmHc(^27p>$HsHm`dLMSofp7 z9v?0m5M!k2_`B{rH>4eZ(CB2{7}li1xnc9b$6tN7ggE(wS%KcSiN_f&ZCM>--Guqa zNmyN)ts%L&;MvUZAES25Q4|xf#&il{bC*jXq=-=PS3h9xD16VZ7^Bz9u1F4jGtmEN zwyG1l=fOEcHgoL3=_2nb`uYt7mReYPSmuMH5+y%lHxudLCxeniT6{4iCpKyP<(k>n z#>aW}ejuZ#t@i5$J0=I*zUi}rJolT*F7j7y8o5X=*nM8h*SiEY-G5iw>hLML$9PQ3 z^h(rd8Z*cZzuuyc>3SpYpQDh6-%}t=Gj5`K(WkiwI~=SAW(X%CTH*ovRJbss?J1Wc z;-!XkjnR6brL}&I(I>4ZZxuXzaKG`Qe@Rl)H9!TfTEcAGLw~Hj9~E*}@7i53y}Q`k z&nb~Ayi?J77&fy1u0NXJq3b}PH6g1MqWYmwGFJZ3uy@eBP)#qn?4oUbcadGL+gICv zx!FZ>#NfQBW6NZ-CvLV`@nGGtsD) z87(HGOZU04`8=*#uR%n`O-}h?8s6(sW@2A*k(5rv8h3W0y5zd!t=JMhS#6W(o%-&h zCQ_`mixC(6qmZ8~S-ba9*-}r0mHs6cDipDA0&-W!bdF1xm-i|^CpijMgODON_FObO zWy@)3;P~}z3dPXvRCR2f`A}j%Jmd-|;>!}<_GUT4XT%<-(-x${Y&Kz7@?LDlLFWR$ zH6Jl3ij3R}r9uyi=+)i{qh=>Dsol;K6cmik$|z%16DS@(?tJf2Fvy#f7kzPGLlO={ zD9wi*%_qn?5=2w52sB5xTvn8qXUqRjeHPu!d}9j7Qe zWn9&Hdw>y!e)r()2$feuq_^-=2eT&mo>z*#T2)$=b(i(j$Nh6YK?w6|`2OA3w)O+C}VTd-@`DMvK@&@rV#v!K$txq~s8w(DgIyY)^0h5I%O+hQjaB8xlw;hR_JtJ?0fkw^N z+HDgmS2kk25R}Z=xFXQLGztXCOwxtAVbb6EIK$dl?s#bl=&9Wh0PZ(@_d><9v1O$H ztJ5dLl3|jIxNcBgjfaNc93s1ZFYrV9OyiFiWn00|Q-i$&>4#=z8dgItCmp?;)wVlZ zA5il(EUg>ORu&-yV3}=wq5Y6+cZ|uqW-smE9w+Y`Q&Nv}TWeSBer+2U#c$L7NU)>k zqfo@GV!*EZSx}EXXIbXachefH8y99WE#A_;75V;lylCp0dPA$=P?cF5jO$2vwIw&C zm%&GprL@KKUoCD!gmJMh2`a1LWC`+ zzZ8G}>XRyLwEY~p?6wkW@ zGwW8XJeH95R<8~$^-jlXievsmL7y`(W!&PZtC~hGIp2@zO3TJBa(FUg5>aN0q*Pi;Mnm2B zJ^?WqucO9CHlJ__{v<{YB^_v2x9}=35dYcz3vNGF`do1xB@PYBZuFHui}-BellgWB z+jyfN2<*^&3oUVv&zkm+7)M4200_~@5Yv?i&;UBo-}ABdXH$)?q;r8JzdkQy2A6}_ zVAUJ_``3iHkz>kXVMTEHOWtj=$uG{5ZlBMg&$lt#%PbFwaD}>bAx))3) zy<1Kx_EvMn zhw8S+QA!gwVfj0gTj$83`6v2O(0}xYx*dcUn=G&0pk`=V=4f0GDe;I z^kPQ8oShmP_RQ(u8H84Jb~-wraRVyDnURr13!D~ zOhZEC&62pSDc2JK^@SE#bkoI5d3CN0Y2!{-v-;S`zcYa^@0+i z+bRt;LQ>XrD6GaiL1!{!f98HdxJZAN|1(gc%eQEMS<}bP(|S^If86TuJ%wh>&)U|} zU-u%0Y19?0V@={YG8=DVJ(|8p^VD55UvlcFON)>Cxf$;x>$)|`CWSj9m0hwwUO!63 zv^W$s=dY=FEVS1M=UAO3@E+-0Hhnh+bD$iGWoavclH=eoIvj6%`y)b5<0RbcBbJB+ z^P5Tnw!?|)ACuXaC1^Li*}{UYFSt#(x_bYJ_?6fBq0fIqT*zoYHqNs5>g}ha`zFzM zT$iI&J*w@Q)^&zr`PNF}pPo4C;YHxCK79BI{t7?MW24~SQD~(JXMM=1&Rt?57^e+( z<56da>8i-wuSyR`16|#NLAl!G5hR5M)Mev2`t)+7R7>7n#eRd}=S(UgJ&NZW;nitE zsP|z$GQH=yWRDRaj8l|)+S5YBjUvwkxz{7yWUQ93TgE>kyyQ$3RfV|Ku=yz3Pa7_B z!9SRY9)ZHr+uPq`_C>(!MLCSAxK=gD{jYum2un`{^)j~S! z;$PS({T=`ka^MAw1OOCzarJ^NXruzTDCg(YKhVb>G+40Dz8nUCC9d{78$da+1&u%) zjQB&SLtak}5u1WzIjA~&-5RO8Mfi1=0s_X;BuH_y5ft|1%WV&kSuh4clD@ zxKKVh7+A&3n(D6{NO=g9B(tOXznSBIGm0Lo^q~!zq_G?XrUMEtx|eyv!OL@5PXK;( zX7n-qzp0`FJgZB{*$1G%^C$d(Dl$%WPyWj*@_+&v0pT+SYM(za4d{)+Wc`cMm|bC# zEDSM#^mIV4=eyirYe0Qqh%%`w4*$(5A3>+DGjdt}IYk`km3pDzzbo=@M~42LJ@x=% zoRJr)Rl@8}FncusRu3~M<|tlD+5Sh^bcX>Zri{_~9JgRC!vh%FO9+gP@QK(teg}N) z1m-v(GbOSChs0q=xZo(Wa0twnSc3kKA*ADg#QF@Ub%z>sqEe}K>cWVIf9w!irixe{Rw+A86!)LmKIyd$I)q?}*Vd>*K@~^wF$z^o} z|Fy=Tn;_MAM+(LNV@7GJa{=h~MGn;tX@j(|_<&H#3<#xvl?7{M3zVTM#eY#rT>H-& zO+c>EP>A1RnSnoVJ14@2UQMurpyIj}hdx>`!TIX{`KWoL6_MV2IObGHmv!KCW~BvEn+1KG5_zKA>!T zWiw8A>%ucMV%Qd=W%_#7q}hX7S5su+<>r}luqd0>ZK&3qY(T4fS4IH$Yewn$^H3FrPp~0bmT}xJeUC7ng%x428V|;uYnXU3Av2kX+97z zHZ}E`8rYapeRI3X~t|uti5oR~CHC0#^S4;0;PI)uipjX60G+wu7DuRD?qo|ZY_<733-9nwB zY$6IxPEb`2*0s!rm6e&y z7Mt;tr`yvM)n94tnjW%i2(B)@(d2GXSGnlxyg3eF4fSOa*X?Ot6L$p$x#*WEp~v0j zB_$dWUZK}++0EYO;uK!}x&BS_{=v83hauOvHzsx#EP6WORW1Yi@dQT8;*8-vy?l=kFN*UY6;k?7`3B2Dj%B<|U<0s~GR9m(4 zUkuF`CFF9;2c5KoL#y4k!XxMY`eluhd|ik89$l}Fer?ap*;v15MSWe*W9+>-qjLI| zf0ftzYl`Fax091qDSOKoCUj8rOl*^*T&QHTo88OyRf&#BlbFE>bkGShBvlypyi&+ z;~D1(sbB)jxAhf{#4c{3_~K?!oT1=^r_Al85?fAW89m?#+RRpJX;Umr&#excc~DLqU&FHsKhKUqq9vw-KDO>sT@oU{@73MAc0E;q~T zRbYUQkDP~y80|6HPruMMPJVaaBPYN3&;!pjjs0UG2K(94HU5jS1a9%^84%@E<=zE} zyVZD5Q{uN$IDtdzB@~Sv1#O4?`h7Lbwp4SyL=0K!Y#JHXre<9f+-3Wz7wt~@tyO? z<>}pT&z`mR=dvkykV>8OwD|6jDH=_VE=54Zo~3~r88ejlnT~(>DGfmwelplJuZMu* zy>cD7e%;j>XEFALot3^yc@MS`wq^r0m}&Ne@+&771d_ngvY2I;5(EJi&D>{4@!CGI zK1`KI!B;gstG^WxzFAivozQ%tQc8I%`JKFnmMKs9pyN`=J=bJo28UJq=l&2HTYnF+ z-}0Cd(UPw^1-_+zQLZ*Wzim<+s*G}w9zLJ?rSjB$;VLjc2LDQUsNY{+31Kr8XLG!% zi7W0@l2jFblH}JfN^!{k(;K_>6bbxaZ`3wtn;%GBm1qUlrOMA;_gC?vtJu^BL26z) z8^QREZ{wq|x(vIqU_mpn{NVRHj?uzC;rOAU5=n0^jnaX8G4ARR3whT_BvT$v1?1i$ zL?A%SCQ<9FRBH2KVmZ06)O`C(Y-=VB=kTUSPUTvOJJ$6>=r7&H1_L>?v2-E&cpcA_ zBbUhJz1F21zV4SrZIP+CZ+y1tD%~L5DY!b=4>Nr+49fF0R%Pk4S%@aq(*Pj77)jx8Y6{p~z=r@t=_%@>k9V5TXxw$@KZ$njPnedk3j zwN)?ZJvM#wd2qKX`(V3o4(~}Ce`~JU;d`MM{i1mIBwIt%DTcM%eYLX0Bq5H zWb^DM@f#_VT07H+Mnwe$ygO@AYVH3M^;xj& z#qT2zyA;Dco{trp2{&yN4Hs1d=G!}8J9}*-5CpxHwh@>f31ObEB})C^Krp7uCrEO~ zUwrq|FroHf2;;ZXvtQe0jDCRG7(wgWthAG>9=Yp(v5mFhVkW@S3S06{4+^*q2hFZTZKW_{ zt*_c72K;jFC~7Hr1a^LB3x|tPM2tGNsEzS>Fm?5ZkEn-t3rH>um9(=)L(LN}ip|bHeMl755a+FRYJU7=0m4Rj%D```=!aTsvM{gJV*Lr+_bV>O=?4e^Oe#X^ zLyfQQ&(yrDH>@q;UKeC44c_*RN+v0?2vuQ1D?GOKj!A)hQiW2&4rB`Lu1>9&cbOaq7?f& ztjb~sy|<5jIA#!kY4cH8k7zrn)KG#-jWWHF&YTTt4=x1V*Bt_8UgHL7+w*%}mm5c9 zr3Lkx$f$)f%66Qp9GMN;=X|JzEKiSX(o|K5E}iUN!$5vJhPan#&DoeFU(cmkXPXQ^ z!YY`*T|994;?cBT5!{eVO8^}*lW^=;Y805f44-SX)#I-HJcR#A03;P}gc}N5ICM1{ z*@d;hJc_4YSt2%HrvmZj3@6_2@wf|Sb6$Mk$nUZV@ognVP!UUhYjvz3=UlMk7A~%F z*)4k*PX?c&udwn^#AGed8SrGb{8*i{baFjA`7LLY6!=x$>_ceU_=M?DK{(OC)SNZ- zMLnkZ*oT#@(@6=ZyT_mEr};Q)h6*l&wC(big^LF$?VGqQ6gzLqJ4u;7p}4Qg3H>~E zo2|8r8@K+Y`dgx%grH&ie$3+(LOKT}S6glNK81nVQqrywc)Ydaa zV|fEZ_wERYFt9&XxC{b4otLU3Ah^daM(z;S${2(tNCQy*C`m)XpO)4#>=G^UZ29Am z`kNeW?CQ+1AOtvPxC5Z$1uM$1XlZlQEYUvB+Vb@R*cIi>4_O%9^|sg%5sULWZS3&6 zi+8Qb!QC2^OxX^nz>fhPIR@{J!nQks3jWI+Gx?WndG>{VQH=4|XAhpxxj}eK!4BNXMU3Zf7Fpwc$#3#E_p&VnZxb1>Xs9 zq1#oc3m_A$1K39R=EoSb_{5kvYU@=eK#yaSRx{9Ip3e@y`b!oS5BTGZSMMofbbSP2E6sGg zw3+t3GMJ79B@Se8g7m?*`+lsZTu15Vrd^`=!6k|11R7YytzzF&t1>TzNVaQ&vkSq< zd~i4sAwb@I24~kanXFv3FL&ITt-)DFif!-@Nd6w%o*~Hp{`3}zSPL;iP$(Uo^MmNB}-55E3rdY(%y(Zmw7+| zB^X+ufq#`V8!smOaCI@&hdYs#JViu|GPd}Ps*r~W3ldUNDB?W;2k2_a z+8F)-6d&Or4xs3G^=2J4xPCq{)~{MMeLN2iSn$G=|9~7w9^lEQPCOeFQcXJEx9^RT z!Q^cyol$%dfJ2JG@n23u^2%5G2%g|`j!!X}`2+O9UkojqZUjr^#*3f2wGZUU2l8l9 zgE^|QUZ+RvGVgDbo7x43m_MztI#=pD=G|yP-p_t|LuaR47kCC z?|l4H+5&D)J$XBXbpTZkF{0LZDu)Gx=t&Y+j{s>lxKZU`uaqwAO#`w_0o-U+cffV+?=KA?2;Vf*$}u`ixdS~I4n8+M~mgB1_tBp0F49? z5>P#u5F9V(cn*@r7hG}4s8)~zqb>nyKE57d`inziTxI^(-PvL!C`_IY^QUz&1(*Vx zu8_08V*lN_oIof5z!iP49b2A~U~x<2W`FPbFrnbl$J|ysQWCcZdx-(g2%aKjj=B$Z z>3$bn{zE|KC-3Dya`CC6zBi_8&GpVUijaWEP($$OX8c#j?8hq|Zh!h_k1~VaL05V$ zeic90BAY^~5R%oR-1?)e)d9BWBeNVn2m@GCnDjm2hlD&6t&&?E70#qXJbx65kwF!} zUQNgGPTv0mQ~4eul{P4=$cqaK!bnv(eWl;S5BUWi%{vkJ+7q%GVmd5jZ2p<+e+1BO zAXvjNHjIxdUUeA&>7XkNe{2gy=z)5lAx0zqZ(}Gt!4m>gX2Wu?Wk=At$fd`EtHQZ3l%f|2NeF{g|o&&8q?p1IZP7!J^+xMFi`%y zX`5A0Gr4kXcObL9?iTim~D@J||N3PR|4wKWE_%kMw04A8Bstc}<;OhjUG*DA@b#o#AfT{vo+vHdaVPZw%rN*gp~+VrWL)eni^R-Tr9$)^8j6sD*xL9<1C z!oP2&D-f)LFbyh;;y=?Lil_tH{bg&OOPLAWttgWyiMmS+tI*D%=^JJT>yH?$KbroZ zu)lU)1M_1W<4^IgLxFROFtMe_f8bJZXL1@!aP6<1eXT(EL-x5>{yj_p_Bue*l19m# z+d93i{XW7CxW$Bh>5m5?@InoM+lxIC`#1gE2+N4R^t`0|*L(~tbzOR1dQIwpW(Wj2 zd;Y%w{xxg@6M=`q;h)cRz{)*;P&!=&3}nn!CP4JR6Xh=yoyi0g??^QH*ZyDaM`e&O$aGzg~l|GWPG9}Id<)6ZHD`H#fNTOe{EzDWMh z?rZ0EFVDU9*Kio19W5m*D(V&t;E5DZcvry6BLS-g4`2D8Y43ymGGA$g^siD_Ko)kv zqW-OIK=RFER+suaFq8o7;ZT3pf3~>^Y;!+*o#3x+ z=E3X&aL>Pri##|zB0>dD|2dEjI1pT#_$Plp;)FD9wU*p{)mH)nev}n7S{ zLIdyz&gG-51gLt9;s7`iG}r#{Nl6LB3VcTe!G&9a5dYo+e8_+g@OU2l|NRF(Fc0DX zzQb+&y(sc{zz70~gFZ<8qu~j6l8u)2s&Dbd0a-581}mnQ|t#P6;n``<&!l7aC0*ze~U{wEV3O*K#falo2B;T7E4 zXC9W{$6pZ(7=ZVtGU?F;$>VNt}S}%3xlm3o-*5^ks9*@WX3-_*dI<-1T_)a-Ts+f_TTr$%q9suZ0U( zdM78$*yvSt;Hy}dDShbR@FUVd9#XQH$)kzlMW_=9)vv7nNWxFzzli@tr2nn&?4|`MhdT&_wD4Ic59`0iq|=GRjjbfRbp3bA2q-y|8>2A@F^Vpb z&*-#+6`NGyOQ09fpWRV34%&YeF$eO!{~cMlXkZjLazrwe{~byrE;#hs4j0+s5C570JEJ&Uljm>C zcmEFI|M$}WSE^T6L({E!-dp!S3lN7-#kZ_8?xnEgHeZ;!D_s1q&#Cy15)%_fiN*>8 z;tl^9hg{b?_)(j#8KWb{c;x@HtvsE;H^^ayC)slUlV?O6q=ZOP{FcIQ{&7vn7`n*Q)VVYW0ITwS#}WVXYvrj+rSqq^PVWv;{e%UgUv8PJ=&nlb%b8@GNu_tdE{~-OMqWTJp5M(y72)jEE9%wim0e zc(Bl@@>dGa8unh2*XKC{s3HDzbIp$1g>yJT2({_r zEbuWGQOK91^86|ttxY#)&jv2B3N!U?M_NXA?z81kn4bgdG%hCsMi-OHqjWmGPBx-E zqpB;a?lrxdB_|r&Yegx^jc4LXY+lcqti(xPOE6or=&8+_kc#NRuX#}>3vf;KB?nO! zi*T*#r?!)^bkV28jh+Ls<^AWJroEg4Y8F{b)}SdQZ5jIN+K9R`({BcUa9dvbo&#<} z(lV8>?D3bnNPBr4{hn$~vW@)Uv@>L1dH1BZA-kunebGH9RX(>GU&&s}cQ)I_XMTfN zg%h!9nT>Gx#$swiO!ed zOpBj zDVzJ}`#R3I=Lo{C3)o~U&1a)<66B7o>X>y%y0KsbEQ;qO`tJw|d`W#$RM#n0+N$g0 zVgX)j?XnGWVW!H#;y>VHXt*h|e~9R=SY-0g+AZ66v^=4~nJ!qBh@oVSj+GTDbWWf| zGxK6cuf`TNcQxV)US8Tr`7x=$q7~)xOb`o*joxH$?nf!*pHR}^R`Iq}@7(x?H{8{i z@Dy~%$1J)UtPl?rrz3foQhMkQDB18*hw3A**)@|m_%GTjt(1wJp4~wVFGmGh&4;s2 zo7e8oTdIs6=<`s2Fm&w)l2tOlO^gQ>OgPK)wkTy*!;$&!=RL_uEd=$N05gx*j>U#Zxi$v1 z&eGBm=6F?rHejX`V(_IpH7F4>fu`j~PPVH+cC)3L-i>Cvv1Ay*vYqX8h2xJ;c2>(1+YKZngdM<*MscEXC+;j9!gjqz5fGhEKNx z-@h<*3I)Jk{A94cNqu3J%v;e@3v2#p#f`sdQ)hOF7DIO38-%dl%bnkCGtU{0Tw`c} zBj!0^-+;QVoMgesbdAs07KqjUF=yB2VQi^il~#uoeib5 zE;g*|e@7Q4srTLi1o}!VwQ1=(#yU|xOR=ZS0iA_Et=m=x=5Do zfRd_ns1OS9ogO{5aVeJJuAVBUvAX-O^;nEgPnUEv=tt5ko*(F)@_ex5p2OTR8no-vPKmDno#xJFCVsS=g~y* z`wOBgd-{l%Q`G&TyDWZy=uYwyN>e8ptftZTPgGq4a4n)gfyzI{6b?k@t=&w8!dZE?1sFxc%}1NKv} z4A-oed>w?_hz)M-@n)O`RBrZ$#Ok)&TM+7Y#3%tM;~%`beZA+P-ytd@b!BMe&y&sK zZO59!U4ywom)P@*&e$ExCE0%3?l9k3FqZVz_zpWr();4s<(d)bS^UP^RY+(C8gbBP zV4A6h7^o+WO1deqW3GS4uMA|y(h`z;Rni@M$TrLMd|Kx%%vb{PZ^c@JBbu9iA~*|# z*AY!o@pHRr{&m|R$93*KOWbYo%V+jx`?7lYx2xmvyP#iy*a&1giYmQ+*Hkn>pmOa* zAo5wm_^KQm6N%MV{uEP3ZUIHI>x{Ut^nS@;WCu&%$fBo*&R5|-S+qgOsZ6<9uV+a%`kDKhad_@#tPc*Q=f)34 z^-GK@>7f0zS`>jJDDuxWbEEGU=-a?J?mUM zjz%x$Oq8K$X$)OPqmHi7PDi-H-aa`vi~R1(z0OV64d-BiWm}B)BE^S4t*5Iv?!1DX z_XXeFf-Ff5N|c}UnQ28vhyzkNE2AYGnshFkEFkim#DSV4q*F&9A>iTa4)9$gKLl36 zM^&5>=md{Bfhk*Zy{vWI;>zT?tElg-!DsT>Mc9WhR11sKPz1@r?RJRmB+-6q1B`LJ zT0~IUQubNk+4dSC5$e9`ursvC^KR$4ygN)qkbEGDVlSW8Zyg}E4l%ieqUdzjq&21Z2IJ}KtTdmD&g7=Bd%qRRU` zK364Nqtk#I3B(*WYa)C3?_Ku%HT2q4+T7*d)yQCtb=-ugIq%;$j-lBv)?aX8%yZMe zyXw!q4y(hd*z+r_Y++*Xg=k~nfM=Q=)`w@rU-z;a*lrDl3RGzaE$6hCu|jFb>+gix z?+daYI=yhGq7i32cH~95l=GFmdR*a%9=Fh=NlJVr1lz^o9fKLrJ52i6St8uF%!iBJ zj$7ViZz681^WJ8AGzk}8T7WeZtO{2sW$!$Ae}iF*a8Q;JrWTG=JeeDnGp=L*Lq&~Y zFmf*%(Q8zxwn4eqx;zMTAt&fk19f=Ny!&99XOQ(e>}&M7dag_>_K9`-XGy;U;!}rT zhr%{EY!kzDVzuilCUxZy99=2`Afs?rnwd#@sDXD6^Gy#XQFy){p z^vMTdGM4NSaq00aVEp|tw}lH4FNrcY72G%Y$%Uw*f(d32ljm4gbAC6h&hiL;a_1aV z-6Pk}Ub-$U%uBs^1&F$$@oq$mhrkGrl5b&S?OCl+Yixa=wPNh zj4pyeJ2_3t#2Oh9tHwV&hh8fdtE-5^Px2_vYkmOC27*+8g6XN?Jaa9_ii;&iQnG>4 z)-$OROJP-bZ$js)0jBkPzT5G{5STB4ov=Yj!6{PM+o+-s_pkyOkBp_`xe-mrxp&Ac zF~`IcR(~3-f&7K4!Ol!Rgfy& z{Qdi=p5}EN15qpmVO*1j-}OJzDMWtnf6G}Mk!_8a>wIwo$Go~uX++E&UiZ{g^($(N zZT1uW@g0PDARPoc=+jyJGtvQ;;8D-j^6ui1OfY2VP0whE!O>bzn2jhvCu3K9q=fUo2i0E!4UKEGdI~oTeZk(Wt*kT%9@8mSWWZy ze{h}+d1yNDU9hzvEF&8YU(JY|*@=wOsjMA|V8wU+`%wCR@(BNJ(AH+OdHkMx2-P1ODqR#AswTDaT-%4o`(K^VQs- z`|<{NhOHL-pPcYRx(0r9t*3G`#9tqYDqut3Jl2FhOhK<`W$ip=bZ5Sv)&!@1mm)(X zabYBjS)J6$>>P;$&u_?}j* zd;w}=HQr}o-wKyd?FJpAco0sxhK31<6PU$r*~x2?&!YK{4xe?BKX`A}nP%Dt9cV&2 zNTx~Y6R6-)e-?u?VcUOxy3U&uez)z}|@C(EI9&(oQB_F8XD=+y!M1PApIV3-# zBXuOk{?e(Dh>~0Q5#kgaFWA#6jmE1DhV&71TR=ObWRMB02#geIdM6j}sE){11P_bF z8$AT(?yqh5O|0rn`UH}~kb7~Oz<$>@E&_3!sR2&pmD)=jZE9)|E5@XwxPp(6G}keq z@2cye7~s>6n{@+(lp=`PM?V9zdiOL5xc*l>6xrElbyFL$L6&=ev`Jt#0z}TR$KUxf z>_0!nyCzz+G;HLj&gnSybu@5zFnb6{lj`}5rjy@V(b!1(0;S_EO3On?hwq-(2n zPak^e*-?#0>U*7iY%*Y??u>vl4vQgy=1nAV%G1sKp6ukrA(`?AfVcZ;-o1rLhs&YN z?QW_=8PVTD>q{@mZAdWI^At*YKjrZA#y3vSuSBI2>5y$A8Tsw4ZBO$cB6&S+myr?{ z|0V`;E*6v^iwl?IE7G=AH}`hBMV2h;b`kuf^PoyEnpDe`9^AEC_h)1_QupjFV`MZl zxxhDV`uNH>VEy@x{zQJHf{w397%1S^OEOF?J@IDTn-AO2gA_E_+1;K){Smre^?BHt z4Ls+P5f7XmwX8Oex+VAFR&h!ab1S75Z@PGe7NxP_w);LSeJ%M>Z7)Rtu?mJ^!zVm^ z$KbtDAIzYA0`fBiEGEx8=e+};zp2eRJ3diIEuQz!q{u{a{hfrjJ8pM7%~32E#B1rp z9CIP#Md>8@XBgL^Scg$`a$MMDu%c0gIcxJ_1g(Z)$3}_7`p<&hJj^bco zO1Ump+FQ#x-mK4t#SnVFc|ddlbvFaoh%F_Zgj; zcdxB#FJRm8;ff>~cLl;doOn2=7YhECV|u+l!Cf=rK2+wv?#Jc2KanpTO{BmNv$m;G zo2T6DS;r?UxlaYfYHrabSiuoh&TQPm3>h0MtL35FaRERlI}F;M@f}Etvy8qO1X=23 zZ7kcjoviuff)jQUF?0Rzt(KeYhUblDZR;aLF^E|a=Bf?a<_&YP$r|rC+bUmnGsVgu z6Cy2ViKAhjbjLAk2M=Lc@I)>E3m_~Gz=(bvA011DyYVA{5RM`FgW+^BC5!a*t50zX| z%3K0QMeSOn?yo>Lw)iQN(!10}NM{iI>oD3s<$g;Byy8aaOslQPBo1d^005`Y-{oX= z=Qq}$|NIbg*)@8v7B&aCLa)oXc`gihp>Cp+eu+T$gQ+4CN<*G^lN&q3UzdgBJMTt2 za{d0K*S~bWJk`i6nEW|jWV=Y%bF68Z1+QAuWeKrM!z&^3c#wWPABkp*8Q(Awu^|px zf82d=*&8Fw%?MUfa*NvjWs!yTtUW(sdU+TbezFjQqR$9;;8N8c{pzzS|-Qp4W%7 z8TXt=`|Zz$ke9U>(w8ptZf3`=$k~%+Va>emhEG^`zrIKoc?aiS)8Sm4JnfeFzYZy{ zu9gXcLf^6c>A~QVgZDqr3{Y43=Z{_Qz9K63amc+yNZ-U}Z@7ZVUbctKRtlIZxi?Vd z>%GY`&>j%6ie?(yzbXhi)stqUiP=hgnCNj-ZUr{1n-m*n%zFF`UfGHQgEbDRO8RLcg=uD72?0$uGIM)?D7 z4^(rl|CpV)_0$`7ct>`i{$u)`+{!6HMa*o46wE91!&;me;v4b}1qYUTSW(Mu%ntBP zL1p>K#2~Zt1G;#T8l_DBp)Nj~jP1I5)hP9vwLSfF>w}|G1qvoAk5b$R=@1$XT3sAN z;l(MZgHO@Orm9J;H)}q0PF-U)Kl7(H$n8B_6uxo}W&xHMML3qVbyl_3A=b6ZpYZ9I zj^o8NFF*tKyoJY-p$RL*_`*~3!)d^vfE)=chu@CO*)bf&SGNkr!)aN&!Kf<;}{jx?I4;dZ`Zq@@{#ByGOhRk}<;qOF@ z-uVB?i0;El9ZrQk-`o7!hBq0xC5E)?&46aOg5f?WnJmwRY@$Tpo2cs9w-&9y5U`!pOuyX|g+_-C(@d0S z4Utw>@RCGgVy~~!*pRKb5@Y)eNDN=%1tP~oUfy4XgkzDX)@X~10#=EwBCQNitGpi* z4Z{kfk-w@U_10`DMaXBgg;GCmhEorrdaV0-L`sK~)_#PzeX*F?Qp)MgGfkgD#yIpUyf}XLksT1@_+D5;^(30zf%b-RImV-Ww-{gNiQY) z0{|*n`(BPu7%#?Qv;{^1x8Hig4{dgUY~85m=Akek64hc1T0p%zTc@^rvCLIL|FfY9 z5iv{v)H4{1Gz7RY&Rk=4!Hng4T5(b<0@nb#J}X(%qq!<_Zlk~j*29O}6NFi;2K$a# zG}&hRljEqDt!S?E$P$biy*-g|$vc<^WMZR&2ho$#(JafWw{lQbW(F5YkT#*1m%x|O z@gFwgrhCF~9<$||AMpi7daCN_-&-XWETpWpI*{GVI z0)vHp2M-bng5KOSnXhv8J5B0ezuY?yVL^92D}@4zZ8AWWYrqTJ-!Ye$XKK-|mJ>D?rpkZ09s)RcP6R_E&A&0V zxP-_DfPyCBvwn4Nzud$jO3Y;#qw=&y8JXS}_f`gY;n`>PknNqL`FGpn{u*g}Dn`$b zcg`k&zrWz>*2j;C1=w%?Mso5`g5Gp!&kFFxxY}**9Eyp<36942GdDRt)%<;eONpfL z`gf&2G$cbNv1(MyK{n7%!oaLxPSKY~Am?4zN9M7d$a@tNAz7OULTYhtU<8OFryXx| z6@X#$M@;23K*tKZ7ros$dm4Kik7&ZwMedXw(K13gmb{7IilSBcKpru0|7B-ALQ?j8 zT)^XGtwVSgY|-NyiZ+43iRYP)KIG^cxuO$`NK1RZEjf_u)F+9@*Z)Z_?-l&YXHE3) zzmMbup^xqh!L;Hx;Cv%mdN`318nX{XbU}guW`Nkl}P>Xy?v^ zsR2;=@D0_G*!APD)hAXg8HsE*ns-%T@Co-n@bnuBpoiMA7L%4UmQP9ID}ehLP-=DIVQ_-2}4_wdWXpDYuv zB;eu@3prw?eAwjuX7rKgthf!{z+VNjg+Y_!UgvUR&Gqp?qCa9-l)>(B-{k%+2EYeq zp0Y1GI?2Y=MnVQzy><}HE92iPf!F+ih6%}+_Q{B_u(uy%=1UGZ-rU;WV@`_x|LQ-| zov6szc_<=I28Rv!Q? zZ9-rXa4A(e%Oz3`ZMoj{223qvU<@#dfJ*39;o#db%it3*Jk)}d>ls^jL!jzoac zRt{8=jG}bf5l+a~nb`Pq;|0pZ04>Wa zMU6nv`wevQYFFYd^lg`;s&V&g4*B-RFJ5=wqLj2+iX`(egeik z7*IYE-lBoHzVYOSVaJ#%PbI#!XQ3=4Z;JIFF>PS>=^@(tYV`YTx{N=$%|tJf3CrrS z04soeVq7V;+7A=*OtBXwOKy`%Hm#AgH``7cXY*$Q)e%>QKJQWT%le};k$GD`)6&aC0bV9AuT{wK*{a4ZDSFA0Xy@nBgxQ-!;NwV zkU}w%Lkz4`CusZTY{e17%TYzOgh_O!zdbXy&8YXooPn{h`NC&3+bIJA#eA$qpfsn+ zYHZj^ayE$@k;I={^o{dZf@71qo&ElpOb4yI9g;jUv|PFL2Xg<7zbZ;V0=mLJ;(SSx zpXV4V?MKr)gr>#d>Tr7^SyJZ92&no0uW6*JNaS8sr_jU+Y2xxq=X8Dk>>=&SV;oBF`8B+F`7Zw#EFNsgRnE;V|)c&Dnf;#wS?r_%4Q!-3C zQWI{lmbq@oIQZY0nWqiR>1c-3oZkiAtQ1ubf7t|v?c<4jVqTJi=kZ^bDUkOxA!{BW}9bxGA1%bG%cj4s=3 z*TyFUvz~3=8P1MqWb9ql=ZR@ZiBTxA5>BrPwd=asZSl_*x1rjd)+YI)VF6S2{UZ{%|`#amj^V+jc>Q) z+FJq+D^jFibBtPrv|^J~J})^&;`Z)k35^Hbjtk7v5Zs|kU2`LsI>0y&kBA3Zihl5~ zMr=1RId{H$zdn)X@EWK~3i|RCqeV#UHF^J7|?T5LCVoS~ZYztm^ zDVHmlOA>3&>b95;Zv%9F?T-F_&3kNHh2437_}&-#4NO>w%=QWvIfPMb-yecAHj9HL@1Oxm zlY-;7G?Yl|;oHDXuw~o&xDxC!F1Qi+V%u?z2c#d{E5CPHeRI4Ik@@za(9J>>Z#awB zy-6%Kz1GGg{{W*_DWiHHovFdVxPquvrK<&bc}`;3J~|4Aj@yZ>oh+P)`2@`yI{Zjn z>Ab#H5(pt6C|0~19!gPNKl;+Z959}7zsvRGZ5R-m+3x?)SYV$ta%JUtTrkzN$lkF$ zKb*Cfl!<#xhw-yx^fJ@i^a5n`Nww;TlP)-btNnn$h~(`qF%Wj@9nBeNRXO{zKp2Wa zD87)PtH5AfU@HOD5=wCf?d4%gt(jAC&c=B^;mdRKh1 zn9Ux-ekL<*t6FYHmWzpeu_WKc0>Tu`679gv=dqlpW2oD3o9m)1aQ#OKzA1XGNdbQI zdzVJ}8tpn0afctK5*>Lh)y^11wp&Zkv7!8Z&Z$^KhcV9U&_yVS+UwVEu;Iamu;QnT zzXfnT>}e(;)!nsLgSbo-9M!e<1rc?_gfc-q=7Lp+7(4@z3nt$~cy^IK!qi4z`_89O z9R0U)<3ByU2L}~GyLv(~_QrGLuYN=lTzLIS{GxC6e0@4FJG*L-epn0hZ~9iJRluVu z5n25!;)?dYU51zOo=98`83y8Qvbr_EAc>gviw(rzc=eaE{*>ARTw|n4kPhOZ%Tjlk%~k3+A2un-%F{OFLhsQLEzcXR zrnPDe-qcsX7a}+DbUANF&MIU}QHf zVI=MSG}bQ3>$%oGFBPKl&I+Mt@-}(Lc(sz>&RO`_5zV<+xdZ&WsN5pA4}pZqMfMaEM&-X( zIV#}MkF^fQda0;^m6v&uhQIeIZ!eY-?Q{D&yyNRe+?Tu^U3-}>OYvnG>t;@grzGJF z;~>h2vNeD`4hyW6eps^n14#o(PK1)0 zH++tgHoe_KA5N-GGA@FNJ#JQhHMD+CKbqddsaNto)xY_aF6zfg=s+^HT&_7y9RLK* zq|lZJ^u4!zH61Ec5tGIQsWEuUwe%Y7WZ{$mU1#C<)Fw;S>Ix8i$Yu$fP$=|<`b+)d z{bv+**xBzLr3nD;9VX)7Z*_PpF~rI2A9cjKMX`3J`URQoK$?*P;(K`L(@%&j*d27p zX2>-{G(bEReRXxvPK!&z?x_~Yk@oGI;*~WwyIS@6R^K-Ny07dnk%!9h>QqoBO)uE+ zj&eULIY^H#vKt(SQ<@U2K4DY?GSx5WHfTGIZrq3aOGv`V#Ma}`S}n@_)ki|!{b)|< zscuV8fG)lMt`+L-DJiLbZnV(_m4sa?TlIF{m&E67xdjMOVY z1$wm=qu}SUa`9U!`f|_`h|da3m|~pd*?V7M>-2xuK5`3Xpy*h(Z+_W88Jm5bV7!9@ z*4#NUtEfWWps3c;ZMI)6*R1$PQGF~`;J3Zxe8}f{)$r11MAKVOh;bl5b$fI&F7#k- z=;x+AU;RJ_ht`l+2;zYf<0_nr?V#5U@OJ$sCv+mEs z3hw9Uk9Y%`T;|=cqa~Y~r>kj|qVt(Db(;PI_h7%e2#{$jz$uKd z<|WR)i7Ei@UALb=RV)1^=7qi3*(QzAXvN$t5iTrq9RPxNp6?yt)GTmA{h9zSl`HoJvU z`<}n)hdJ-a(y3xkweoxnWC+s_ z?F%w9#OYUls}Z(>#h_(wg-;8myxJ=elI!~;z0FC{h#&!8-cXf9GzSu!UODlw9pp(q zzHlnPEAK(aT+v*=mxr;U?BP@nbuxCbN-Qj_!SP+2p3i7)r_hQRN}rx9^Z1m$j{pWn2tpYxZH4|xc`;1`zjEyZw%%6#OVhplZd@vsm~oEgmET7-oof!APtL9J8lPnF9p2xL z((J{8$=D0+r;A5V+s@*yI$j*~Wdwhb?@l+5eN~UeG0nSz3OOCjEB(j;7RmQ6=3zw> z8@)J?(it!*5@{H6GzlANzm5(Sk%Q-GhV1h92Nv22?bXM9I$6v7wp;x+_o{CWP;QsQ z=%8%|GWLUP_oWZ`pzCwDv2rE|eL_44fKo}>ez&3xM_JYP%N2FklJKl_JUE#^2f3$I zx6}&VH7+cEm0l^_uqXCB(!)XE%|+uTnn$_Qoz8lH8z5ktE_^6d*`0DAQSDE)f(0ek zF-~zuqef6;l7@{&$i-9ops&e|XFoW!CwBcl=WJ__ynSmZcnl=>{KlKo*GO2f`VSlX zI5>m5T|(Ajd3Tf%X@R=IdbvvC)3$OF203bE5)i4^^&!(g9}hM;f3WIu#F~Z z1Q6vxD}JZdrU1JZ=F%IM>?e&dX&{1RhR)q^yA^u1v=|8>RDkkqwx-;Oevxpc}%u58LEg;|hav%}~E(QCMD@xpsp&EAjX zu=~R}F7F^92=Y7Y$WE-Twm;P(Hu&|?BXjZ_uQZ(U!4Jka?5z{bzT-n)gspw~zdox$ zG>KQs4maCA{T#th*yG$5N((6pr&XwkU$}V9czJ_>+54I({mFIV0Tk=lVU& z`7MI5!mhl>5513<1>dTR=RF<;zFb$kEJvwh-&gXINlWLaY)8Ycq}OX;-h;C~FVABk zJ^N?STC>dr_^ePJW#<^0S+L>^pWuhW#pXoBA>s|5aKev)lJ8*VWW9$huP11cOgC^p zN{YAZ)r4ebz&XDzW5TUnmnZU5R|!PGQ~_{pPc3#G-k^dNNQWD==N(pYsQ4BY_Xw(c zu;zk}$ry9ZS@V`iQeZSlz_>N=d;%4I3*E(hKH_H{9t}XSP{89yw*Yzks+1=MVNlX4 zt|~3BV60CN`Sa-E+1kD!^P`{k1pGT1~Mre;9%6 zCJS`s>Op$^oGRwNn66nI_0<7cZ;(Cc<#A8!enHs%xaFjY?TVuK!)toDH8eJQW}>$d zftI!ZAWtTb0Oj=6612z@{{Ig=@11&Wv#!Mr8M4x zZNsR>9T)6YV(Mn~rjzvx8a}ED03PQMDr885{+35oviuC>lRXxCRrr{9!O%;|YpIId zKS~4eiSQH{bGH1tZ*IT?3P6Va7Z4x1s1k=9)2<@6ps}r9W=2l3V96A01a_z$aeqhW zABHET=XTr>uS5hm6gN@|;cyBxAT6LLS(M|%y8$~9qbOl zhEFj3pzcRVz$pK^OV~ICAb5RI>Dju5UK@nCnQJ1!<7=#g1R1kU_h9f1y?>VA~S~he{I>X%G(sZUK2c zQjJJ>CCQ@dA}#>sGXf_0R_G)V`yKX8vo8WofqV!{>CUAY2C$wa5^R{uCDNIP6yxGz z@%ND}C33>Yudg-Yuo?7jS+dFl)Qj56!O@5!&?O?QKgj8taQ~u1um56U-v%4j(E-wC zny?zz#$6J?EinNVylK>SSBDR4>x9YYqs9D4@9s7KWEcSd5YBWzM#6hHwl z<=96&6Mf4{5tYASc->#pAvh&gD@HsWz1)6I?M=uc-e1L_!T~F?5_3kN(5b^@u@5UZ z#mPTCF|??T)LMA8+A{!`aSWWt;N>A(*!BqN<8y}sUOPFxwl9viwKJ;K((&i;&vMHD zdMpC;4R8eXkpUTL2T+N!&x9sc17Ud6G5{ut2rOpGAZU?6vFP)0 z260%Sfw-p}JUuQ4oOjpcZ!?=PeCFs2KW73y1?7OaN}7!Q^Kp z0WMZCrp1#_*TolcDWIo#L5dDEue+4A)l?N7$Xx?2N2{ic=zpge`_m(a#k9Nbj-*e`07=XJjW1`?r$tmWdVu`P zx_vbP(nE{5+Fho!J1SMIC!b>5bp^62tMd(uCh6TirZN7XAD1NHi*Sulp|7k-%Or0E zMx@xl3!1leH{%_f7m^0)BJ5FJ_t!_8zqdkl-~0SYhW;B6)AmY#@9&{EB7<9t=xro@ zGnj*k?kFUjrO8e<6|JM+;<)*HXW0CmY#ha1j{$_7_d9>u?;1Sf-jUe~Fj!MFvf~Sr zN{(27=u~dNvyfxXp)NIix(~oLn>zeDb$ zR?|ZP1qkG&x0;r#BbqyTo3JnZ@4qN;c3e$$hoBs7hdi{YMIIm?8KHk;QqF35`m?M+ zV_AFK4Zt86lAltvh!K)@E9Kp~px-$vM_XS$#0dSh7PnmYA&I`51 z2q2@$>z6y1lnvNlTo1i-H6GJXgZ8{KF zvql^%N$_BlbPA$!EK$xj0;+AsARJq8N2gpoHEn@v`?Z%>1L06i3PHy|$GgL^P z7W6n>Sz6N77kTS`tgSBsbd)-X+iHrQ{+-8ZmLNei=Iy8o@#97Qra~$^UgkgFA)V=b zeD4Kp9eG=zP#aEcKzNs^t>uLl z^W%7mY|$g!0`hX~Xvtl00~7>7BzuEB)>?g{Ia0^dr>_R>Ze3beY>N1DMs;J!R-}1J zoI-10?>N8umgi{;ps=Ii#xu0l+sujx6a71_i8$R~RU~5{W zqcR4@wmscnFI!i%``mB((5zuwC=!4OSt2%!O;4}*X^PO3n zA$FV|+sBLZsv%W5>SQ&jj#%3HgIZfLZ>`ODnqmGwJUWk1!#;nQHKaN2zn^P{g_a(~ zBYzC+^%r=496#mIYdW5`VAcxSgj31!y#Y2NRcLj9l2DaiYF~uQX_)QRGIVXdX4O~; za5{Njk;UYvw^EImZ)o@*ZIQe}62vZ-X)aT!ApvSc_cHjDz789YRG{PtDsdqE!j}erENnhQtqG)(_p$nYSWXGb)~{d5IJ&X1XB zY|5aGPut}hpzy#i?x!iedJ#HLUfC_kMi~7&UK6tTA<0xo_>35WC&t2T1ZB#yI?t6=SnA%8Z^CCqtt+9n_bYddMmjCraXuuuAN zK^!23#d-1&h_n$HCGT-?jI4?-DlI0MhBHp497t8WNuUAsqA3zELz&q~$aBRZf%iQk z(4(082E1_2~*$+-<~)fM{d`)-;}=2(wzbRB-#bpiIW6u#;u z5Qt&m?{5KgIbVe!T%3PvMbu5RA>&}n0e!n^oX$Fmc67p9vjirpb<7`NuP8rS(QM-DB6~M2d$-p>hJ&b`PGlP z61q_40Mr;58|On_62Kr=)1Qz$MD-qxT5$GG-VN?wSR|u00Gk*4fO0@J-K}!r_*JI? z2(%hU^yvKA(yp)kdW}EjUUcn`|Jfps3rR31fssu4G=j2c!P(0ulIK@bsY=6|8sn=F zzq%Hl5D%kP^zDRAORg^k&i1fJq#EkSg`=_T)um>v`+RkIeGQN9Q0h*~X3CvtM0DH@ zGDkS^i)>DH4`XeH4nty;Ngx#9WEW9P1@YdN3mXS-qf{ILknmqv80KF~vf?&!L-xm0 zOlN!12N@tzv%9`M$e&5S_`hyvx%)=UF{tbC6Qg6(E+GjBmC}x=w7d24uA{1XdnY+x zOH(5MD42{Jterpdv)lS%$(N?KU+`(Px(E1K1ProvW65cCZFu>Qfe0H?0YhN#qRa$% z+qf&%cQZzz@bK)ywT{I5Cr!dfDPVWilBig2^$o=?Ms+xaIsuS&GAiR#SS19 z{ka}v5fc0u>#J8rp|opKfmiYag-y>l05OEc-6MXWIrlLns340{JBpq_ z4bRc)ygxI0n`+eyiNruMgKyfNfO4@UZG<2NbeY%n<9Z+1pcQDC7t$`AzLOc-)k z;YdZq1`>W3Eb&aVcyW3$0 z$NBHGmELKP5~fhyYR%{=E9qhhCE}&@3mG9x#s$Mu;A=SbNyu}NU6(ufJ=y+pXx(W# z^y-*u7HLJb@(K>7F|1iA4gk>nYora-_eR+q(+s$t&)Wwd73MdLm`HZwN#0#uj*D^0 zhZGS%5m41CfUl^%=UFs<=zxn24IPBy8Y_+jp{SM(Npx7;g$wF|T5&r-34r#7TIzTS zY+v&96GQeQZHEa*jVgS2(v28KuWznv=w;&1uQfBoNWXgMSr)(BP#1NtL77CJJ*zFo zPny@Mnv*Vbu*N10Hgg}HzjV}BL#*d%GOp$G1ejWGoX5ORSW5WH^om7O9K=^RlF?$} zDTWcZZGE0dopnqek;BuMXlS4meK_CjNW#Z)iL%HFKhbw{y&9uQIq%PFmDqX{n05-sGCL&W!=0)tO6O*zRE^)tBFb2Ch`zqk#h&pU${5&^~!_EHpmSX-1KuU-8r8ii+~&|TmB(qiRIvI z$2{N)^z`*hjA~0^WnpjXtK#qPab%u1Q>TYGN^*krQBsRkuUFc<&n7zF^uEKG$quuu z%pXytDcMm}?~$2_zROCMWebl}RjXw8F6yeuqtQDs;+ec%pm4C{z`m}hnNI; zy8dUP|L^ubES0bKGUgwrcz*vL?|Kq_ZU z$M#5rht63|RA12L>c}5GUXewqt{oKrljj-!M+!ly{Cj=85YzIOJ;dJ7$cYC{eY_#+ z)1~B#@eIH;t{s4iYo_%IByn0D#rt=&M)CK%PlRmBFAQx+r8BMhs4+b+&yS?H*B>N( zEkr%THf=hUwSMW`YHe;FX3c(GMD(1JmxtuqYQ-Mh!^-~YE-3n9vd9r)j(1A(w&r(IFj(KS5zRm3f{s7zb?{{hixHi#ore-r+BWMjJ z^YwupmJg*S7$#xdy1y8$O`V~ed}9hX%r^%J^m{Au=h!guvp<(-RSqv}h^!X&x&=lf z9*((``xz>nN6VU>efFmRgpIy?WBhV9RC~2~SMsQ+bAY_bVuIx^z0XKm*No5EL9LnO zVYpG6&cHko!)PnXtR=yk|KbkzTHS5_!&5rE00W5ZcEe&}pO62#uX-Ub8Kj*MlQeG+Xc1HwT*TqHVL((2m`X6hjfzk9@;OWPQ*j*StjG zhc&OJ44{JmX63J9Q!MnRL3CVw`1QcfSwP(3XZglM@5Kq3tX$2DsdT?HFM+OkxJ#4L zd!|wMRBSpKFZKpzc1?WKvLP3$eE>s-bS3Tv^6W_?d)Vh;|7 zJ#tTZ7w8ftUQ?KHQjD*YHiry|G%cj0?lk5ikDEHh`SthM2X!xDf{(@;dCPZhm(o;>9q0d^JwhXT-1+q zTCt-NIg{mB{&4bBIOY&>QtV108AeX>+QXRh`I#klPZ!$Lb;G>c1YoBNFqsyia(+fN z+~+0;N|u(DG6A=dboIr7W z&o7onhD=Qwwo%JUq~j1m`e=Njeq8A%s-=4sRuv1}2Fya=RXd&Y>?ub$dQSRFQ|+?D z$92puQ9iNSsWzJ>d+#{R`P0`m$<080sjh1lseGF&b-AVO$ag#T;w?e=1-ds{5Qmq~ z>AR*=lwZO|KJI0|eIoz!<>^fFG0p{wG0sOAn>45oO&|6<;;Dy2ja`OWRZAQq-DeLMVd}-xO zB#QpP#9}&1vAKbl<+K<(-V=(f#JA*n9hrl@d=NBMyV75fqnna6aoNcpI@=I+=ljd% z_&8HXx9ypWyo0rCw&zDw5~~i*r>Z}Qi+4k;?&1+eRbTOoY!|h{X8kBdB?zjR7if7} z$l5gEx~$*Fov3P;(1Ex_Vg{ksbhKkEcm5g=T)py*;eq~P?P(vvr7UW#V^X$fsxS!0 zb0(7L_{h*Z!1CShr$-T-5oFtg1Lp0C!7daz@GnhrbpD5D5qgu$n2EvN$xL%c^L$SI zGhQ9tcJ3f|FYWZdn9B98H1)p@ zg2We9NRjm>joCDz=d&=O0thWt)a}b@(z@n&WwMhJZbkg^GC$at$BPfoXR{P72g^0v z2H|6-;r|dhX8==Qgm*D{T%I(iea_#F*97yoW1sizQvgfgfiC?}(nn>q*Xxk#-o{Nd z`lrxX$Pf|l!yC|(n1vrKVp1Kn?`86946Y!Slwye`>bY7N(HTj{wzLH%QM=u2)t#JB z3>=-C-GYiAIz}?{$Mm#SPdEACF)l>J)bqH_xoR%Aeu!!$<7x`k6krg2Dc^mNbTwg{ z{(cWgUgV(tE7^b*`%EPm2{nfNq;~zv2u37}il_J2Fand~7Ywmz1L;|6A;Yb@z&S!W zD5wXB)L-%My&Nfw#}ynBwk3AzSyHCP+ZD>z=sCB~8<9C%HlF*c;1!$xNB=Q0G^9u5 z8b2rI*8Reytp@ z%fw+D)xP#UD-doM`PBr?=VF%1Q?9_d_v5@2MCtBoO5=>ELJ=19(GJpgXm7KPZ$o))2Csf9v zVy@C%QH+S&e=uL5>(5YW&}tdR?AKR|Ji1F6gt0`CShT1sLc4u$$l^Cm$0;=6>cr#t zK?F8ih3B5XO+WBQxH^X4w#9>#mid#s4-K{5{RJDn*=9b7L~lm8-1eN0A)wz$u-geQ z^Qie@GQ}u=Z2Vfvw7$Kf-TGG?#{gmQ6YwrfRa$r7w@Nj-wNDj?VVP~x;Oas}edK1n zFPJ2k#ryy}nsoGPexeF~3r9e{V=&7jRM^Fdqf&*pscB~pcF*}XLo>&=_X(>)c9G%2d|H#>Kp zaM&z!?4F~eTks4n+$!{Vl#~!aqqEZ_`nD|^QHJe*xmPvi2!A24#*YmUf$0h>=FluG z6?d7pd??qx>QZFP?A&WNy5BU4fcK->Jf^RZQ7yML3?p=LPmp(A>;1eo#Z&YBbX8cG z8Ddwy!;=qWgeD8#;mHupj8+K(S=x)t*cLD6taH|d;PVlOv;BKhwL4hAY(yjb3lJWJ z=>E|$i=92VhO^GnZUKGktNKpy<>?34KDzV7^p_npX6x0*_C6bu8feVI!iH`mYt)@Z z{3%gn49QFffkh}88}TH(-!^usFmqEVHX2ddNw;%o$wIAZCD=EcKZQGr3pVI7U zzW$5mr&EU7ydD?q8YvRTp(%IU+B7bmtgtFo4BLv05sI;JvVb`}(8FY>zPlOWJIKpS z-{e_A5s@J0>UI>6+1hH9b&tt;+U&7&tKrjcqVgo5s@-iXK0PPvR{#7r47I))k$MkV z_jaa%>QujXEYRdEjA?YhZa11!GbNiJ$eActxH71Th}xUBw0*y81j5-dKM3(I+-;t) z>2q%r^b-^rH%gOqCj)gT_g)ypLCKe6H8Rt5V+396I5F!kd%FD5q4_VMj%^;kcPTNv z4+rL61%(Ze4G;nf%=Wy)_f0~Vtw*ZXs7NC}Xzdbnvq1EnR97)c$AQ&`l*g>@y^ISW zvR&eLe>66F`>I#GuX+qH+q*|WZW}qw#cb6Ank3^5Jlzhvi$yyDJLn?huGe0l(eNY% z5V1|V$*MR}|E`S_7K5*Dt@n-L8Q8EemJDeuvy}q}kaM0H5`&0V9fV&wF>eQ8DbzS= zHVHP|wrbnPn}%+zrn(!AYJ*CMA2|`qNF&R77OVONG=90#ixJMig6u1))N9EW>|T}= z|A-I0;FBCMHwpbtKV3(DNshlZGVlGxqymlGq)*eJSS*xW9teug&+co=KUVN^nM9v@ znV7DZzO*}Bx1t~3FQ_Ea++6$1<@&Wv77 zR2DEvZ?hOx7;i;j!!5=`l!({*{VCq3q$sYC(igXZY5M5ogX7OY>@uTTMh-Wk&R0@V z4@b#n!qOa4QWNc*FZOQ5Py>6T2C_&^W=iwVHr)CCRS+^Q+ zugJ?l;>xVOarHMz&3y2w#ITY)wQOd06O9%KEBn%qVRgVjAL_d06(`WXh!a7Grsr2o zlD#eg4>DR}G*M|S{ZsJx#hvRA8tix2==NYhPqI-6X%LMrB&FwKe3n&~r*2?0Gx3Vw ze(mZWGM|3+Q!$u7z65w=1JCha*cQ7O(6$u6HnY zmT0U+8DFu8p=7IiqL$+vGEY5d&hMhD{vq~bJF3rW3E-`M_IKsj@2aiQIgxK>Pcv*A z+u34Q5<|-;4|qtG7^3iGu3XpbNj)F#{V-NtPuCvCARDPRDC5{bgwM9QuW-|aPO!G~ zGHwmc=cuQ3Z#L0SqiYs$m@nbJzu899WivYFK*9UfotEwx$7CL6Y5)C?lM_{G1R@=K zR2$Wkb8bXB({mKcQsQ_GUrzu|dNwRzf?e+}W9z&eCFt{djeM zW!QbT{u$mEpGvjdC%^K3>x30D@ldbomNDcunOM&gwVSUH73o_%Bkv4Rtz+LvvXTK{ zU;Rfy%fVB17LT(Yo0V(Q(;X_0($c+eqDB%0ROg&up^yJ=eST>BgxfJOo zGh9JVdMCJB+A%4M4rx@OCH*R>o9Y;q$Jd3%X~h2_mH&gm<88Ys%y9dOqQy^NO%WMo18Gij+m$)ef@pR<^jNKv~pK5z^T|2g5@Kox%Gt z0a4}Hff~!gS6kzoM}+%vD zsuX>$uWY&bTbwN9ao~1YR7beTLvy~|U$5A+#S`Ie2zy`fQwhfgwhi|)-uS!VOQ#!E zZ_e|!w}+FFasl(6MDIfh-Z-tVNauG|1+5tQ-t7{PB`DDkxnuZ`~$duDySsPK3lv~UI;{Ib*QK|I#Ir{#yu(*NK z5firT>wZ~}?OPP9SiX0;E0T{8ZV1-)0rRG>H^|;&S+rVz(q6@~3gx6*LRcsTC|~U2 zWxmzKTz*d=F%Z=MPOtf@1|AJXW^y>MAp2~JR$*l%`t(}mNGkO?+!7QfJ9h=d`B2w6 z#DzN=?A2{1{JruoNss@gW5zgycf5aJHtcr!8T=6e{QMw_-pNaeQ?D%5t z>Xh~R*~0Kt-?&E&bx0p<(2dC%X~}Dr5KQ;UKn_a&L5P3NLuR+7pM!&j^F5(r5M2l{ zhTSR;etGhNLXiU5JhYsE;`dlQ#fXJGktwnX;#=U`r)CfY&u`_>81UZNDt;*+8BZuo zT~a^x)2E2u&)kq!}Lhz{4fXUh}bktMiz+KkuFoR5aK=9Gdtv z8!a=boDje#Mg(41;#b!qgr4wBtB?jsJ3USpBLhA53D9fYEe0C8tC6Xq-S7QhSC4p% z{G6(pIbTVf%2x!E&lZMvqZcz)(RT)43bqYa+Vs?Y>r3AF1^~o7H=7dX#C)eRQ`xWE zh4nv#z}o3*Msu@XrVcSssn%G@0P+cte@X;jkxQLv zi5;;xQO`05#=~+Q;tNCo9K5>g-CT^;;{pYC>7V;T@9K; zv1@`Ib%br0Gexhc$z7ykkH{z#x_{WusmdY**or7Z4hGf$6zQ(Ot*#ToP*h}ozG+6I z>CWhgLXuD^?*sM#6bOFU7fEkm)yf?qN9#+Zo6FB;n2d=6p2Y$TcNka#f^oqDSM!FI$Nyv7f))^+;vw#c1WN0+)>a!U}Cn$#}3SWa`uznHxC`Xm3+LKEA zTnUPW$M|*`HJH_(E_=Nf$ngva#aiS*37#mGG6m>T=Sj5#tXH^#f8CKLFTY`F5|T61 z*kdH2Y9ON8moo(02g7M8I4OYOAEpFl&mfC=_3V=c5#GmN6rhr~50@hVWKkTeb|Aa= zy%05&=dpt{*lwulXW9C%^>iFCMBvN&@EF+JazjxO`bhgEMtu|V!f-z3u(MKLgPFHQ zf?=3#|E2BN%~>IK*rFNB@5ws!!JcU0gt>)5M-oc_IqtX@pW4nf{tkzww3Q+r9#EAb_-1>4Ja=~N5tk2k!pYBM)d9586fi3OclLy>VfDHFsw<%AZn z`)o|-n4sLDGCrL5sG={hxT7)!lOSxja`=i+$dI`L*J?B z29=~eTq4ZFU=ZgJd55V$KEMA$MSGAs3tPEcxVyxK!XUvm-3s(3cL-u9U53N66S?)t zk`z6z3Pe|HO@l9i$TbMB?o&19D=|-Jk-I|RD}QQN-nnVb%i=~*6m-OByaf>@I5!T~ zGv>*Ku@7kgmlWHV4U&f7u`=tUQN_95{&1?&?C&~gFuf?=;-rQ*&iRk@v?Tg!0|74X6*FAu8t4fYn89T zAxDGyo$NA2#TezDxawj9_R@rfM;N(5=PO{=Eq*5T9B!;uM40%?HV|vtYFvOD3#no> z>+N^07qli{;#1Oo6BS=1q&EG<^^jiTT z-eruoPo~2xt|tpSzj*39(~F_P2kuv?(sH4=-201_CaRtm>K>dCLHAU|dW5-ZC>1T3 zA~TK;p2Yxfmy4rFxcOJz7NNWqF2m|P+bF5X->{$Q-*_wVByax{t#p`=f;Pkx0|w3u z-Eqs+p=s@p_w~moNZ05Eufq(lvDPrax2d$~LEVcXjek`fWr|VyD=9S!j}$x=`&^2T zU~n@pfq%~z(7p|kelWk^X=wQ>amvJ!ulO;vf@~fpBK9p;gkQc+aCAT)SMk?3A$QM~ z_`J^a8kDeYG<%FSG_zVw*(40|BAea65=u(v9ex{N{)+7?85oeitJD}T7VO7T!|SL; zi8&UOMl2T|fd;2&?bEvPBhN@i;FiFo2{tQ=)R68e-j*nKK%#%N3911tni$}KcuCo5H6K)v$g(weA6l{UQlO?b_t{f>X8$p5&vgkjo=vU z2>x+nsX@va$*7ZORTWE=r~-Jme-A{URYL5&1nTt2`-g~jZA-cvCArEMIp=)I$gXCxHzzj4QZoHyJr_8+fIIL0)OW(&FYy z@b@khgSnHGNVx_Kp};zTLOY_7w}z1uXXZY>ju4!JR|xY}8~*_I&86J27@~ELoU!$+ za3}w<8US$Wt>jRpCD*~wfwy|k zX|$P?vAGwdwNnqgzop&$Y%z|OmG_!&X?-7&jIVEeb-8I@ZA!>9a$@2Sl$0Cun@SVy zZ4fY9L7Y9qF(Wx!%v6DJOLD1+{tuAS&M^u&>)9N0x5O{>nBFvS#v4pya=y6Y29J#j z6bujM7T^Eq8~F4M@_BP>FsTgw4jkgkMR#y_?rCy4{UBpDs?h+@YfE(28@tmbS^`U5 z2K|Qo)rUS&g^Tp5FXi_SsNf@u7VO|IvF0z>*tI3Wu!gG8dirF)dJtzh&h+Y&Q_AF@ zh@4c7(2VY{T@C+y4-O>J$LmO9POvSpXAF99K$d}%8cleh+F}e{Y^?Ls=@m^(09nK- z+588I%Y4CT%pOjRjhyq8hf0r)V1lP$L>X+a>XNw8_$aC0ge?m`^b~#9(2{`qT^oK) zY!=3WIvDfGasLukyXk#vWW~f+<5%RTO2YX!VTr_05*PZE?DUTO^C|4V;O9R*AHWZW zQ(Ko&$yHa<44jWqZ$&I9Ba7LE2%3ohbL<6*l|?oY__MI&HdK=99dY3G$=~-Y;JyCv zzj+i(fF~aJjQ$K|&mOh5Tk^a2AhPZUSXZ%=N<&tLf6P)y-^3R<@jToe;0vqHDC9$a>kf=PN1ov zwZe~vyQN~$dg-4=y$|^tOM_m1im>YF?)>*`fpm}=r4ztaTMVHn9t+QbaY?lti%X=E zObM8MVk_mz-^C(y=4da>huOh_AnI*?dMH#DMq)^Gyea^%^%k>#+SZ%BbpBRl)^XiA z2(|tvPC%%Th5{Fb|Kvaayr8TgU5ujT{tONPVF`#Nov$et;yS;+HPZJYPER)T<{%ji NRVA%Qr3&VO{{xXlVgLXD literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200011.png b/tmp_prismoid_2200011.png new file mode 100644 index 0000000000000000000000000000000000000000..3827c889f7c25c49a6d60c7f0e02a10bfebe37f3 GIT binary patch literal 28276 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 W6IY9Hi@}FZcXCq7Zz?1ZLH`G}wi62g literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200013.png b/tmp_prismoid_2200013.png new file mode 100644 index 0000000000000000000000000000000000000000..b4ef2505203adea95b860bee3d636636aac6b110 GIT binary patch literal 28577 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 literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200015.png b/tmp_prismoid_2200015.png new file mode 100644 index 0000000000000000000000000000000000000000..fc8fde9ef05b899cff7cbbe1df182b7f27f80f8c GIT binary patch literal 29390 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 VU^1(J|JYYjRZ;6jojfe!{{fbvumJ!7 literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200016.png b/tmp_prismoid_2200016.png new file mode 100644 index 0000000000000000000000000000000000000000..8bf4144c64eb077ba139ec848df9d52045381444 GIT binary patch literal 27296 zcmXtgbzGEPwDrv35JM@_A>B2AfFKf5N_Pq~fJjSsONlhnAl)?}-6bJNcL|6z(jCM1 zy!U?hkNHi&oM%7hoPE~bYp*@wYAW*hFiIE*1j2v&Mn(e!f&_p-;8ko0@JVxxVh!*F zcGi%84XPZW-T~gcFxPwgL0K8Z0ldZrfy1mokN*1?;6VvIfUjpm{_hh=KsLtzy#}xT z_eZh29TpHs67*I^O3MTMuMH=IMt1tamvt}JmvQJ3_WYd8V|qG8>~wJsIu1I>cTDIC z)^3YLu&0VSq8N|xqx0|YB z&4keW(U#`^O7dd2m`quSuwDwxMh6?qQhQ_|B9p5H19d`O+&lcjVyHcp7d!oYbiwkU z>7~G$B*^Sgi&8|&Ub6jeqlqpx6tw!Hl)|{kW>zW))7ScF6_-<@eeX}$v*xSVulT~` zn^J)c?2>rVU)C7#Y0Yp@l0`?d>Jto5Hf$CfUcYTt(s?}19LYFhI?PFjDYX>m_mMt> zaX&4dpuZ^es(?CWFAjgynf0Bg8T@@>YOB_13)N~bg|n1RYknYv%oG%WKp9Ejn;M3s zj(!x7n8Jr&ubkhkI1A%oN(MqW)xM(W!y}FwI?2M#EcGzlur-2v>=tLn_^TrS{_Jg0 zq{LwZL2EfB2i{u~nQr`BbsSKUT8Vv4l1C1DhbM6p{mISD^{~C+&?+MUqDGer`D83F zAoIrq&#gA72f_&gkG&)&SXbhX1W8 z=I5JZurc8vX3K{}zP~x6`W7?~X1+I&Y?p=6LFxze$CWnBkvN}9oX$s(t=S-AxCS_L zkYy6P`J_u#!~z<`eu2v5PsO=BMG58uoIz(hE_tSt*~RS&#j0 zmx)je)`>D;2z`fnYw+8CLo%)cUWH^`5#j*)LC8uTj;D-u-cs9Q#CpDwD4lRY(<>9r;G#z zPSnS2Nf-?IR0o#3FvfImOivm#uioHyL3BK~p^8?Sh|fN9pa0%uSYIAX?My^`UyLUQ ze;;l0+8`c&hd+<|CA9dOvwwvt4A$noSPg?6=4MX=;1b2S+ns)kq&JnicX z?ci#H8Fe;3PNv6HCk4#;?qNW~Dv&}J)P>Zh5FCd4Id6ZpE?l$foO8eu`6Yd|_c-4+ z4CD|l#v@e~39O0r0O$8&9GJfqx~ckDNO1DYbEIu!x+J29CLz#sE;uo8;M%3jo#Fa? zxTU%ZZ@-9eYmo$v85x4)vUg1r&4#G}&KFw``5Z8*^Wx#coFYXjj_#_LET&XzJ4!be z5{g1&rXKjKL`B{!t|>Lc=`Qq>a$Moo+B`bu^qlo{C3^-2MVq+j62l3{jWzJUw3*)^r z6*jwjSelRb2l{gi!8ra_4+7un9C2&C)du)vz`-4%FS;KIRN*c>;(c$2G5ng=WA&T< zk#9R0n}{ez3VXP2@{^_&mo|#_=Q9$&r6&=HQ3hNrav$j+s=WYldL^=h^7| zr35*|we68pjlO=#@3>tA8kM;HCM*8mFmyS9Nw2{b`X&KyMRcd%q z{{)?M4|9m31-ZS|Si;K;DXh@zr7_Kod*b_aTHc&(6$RSfLn(WXf;u-&UJbt<3K;T{ zu3CXqE!}dSj?O{2$?%BOK(K9X5NM{XXs1G?-qeKDfI9R@H(&B~3t(EEqCq4kc-8cZ zubZukWJr|@$NwHiNBiGYWvhchdU+`qg4zpIpLqRG+|NkEM7j~F{z;HGl7Vx;LPl{L zt#Qt*6{*7x%`QQzFH8s|h$5aT&QG23KT~{5WaOeAgk1p9n+a&J)#tOF^L)y(&)Dmv zx|;sVoh+CS^bwY)Au>b<;mKR!!~J)0bLijkd1bMoMYVpz=cuc#ed~)~E}~=di}(+% zKCGKU1{-zC-kR))KR@->SBp&78Yrm$oFBaM*_lZG?f)R$=H>gtu{0yhB5S#7RD6Yf zYmU#FEjAOFTd#$*MgD&wh5D9vLP14gmYk&Hx+-nA2g1y+On*$r^QiurP^1#STFqhC z>`Sy5XqNkOq?;TsYlj8mDYdYP1U&Dh`@wL?ATYDig{Pw{bwo-N+z-W=q0) zl~!&X78`jU7??k|+sx|x_PxAO*r+y*Cd>N-xn>U#T)nFut+QBqOwA>+nZ?;4{_CvZ zNLY_E<6H~0hAQG3&>{f^XbTP|_UV*6UBCQg$$VEZcUdvSe$fjcVBi@sC}47OrZ7p- zTe_;}Z8Q|wKYv1P3_o{z#ue5v7f3RF4(u=voskoegUG!xO`RDjWyfRcR?xdUTyu*n z#j^?&>dn<+Gw^=ghMIo6O#PRsFcd_#*dZtvrf;blIM@m*pU~CuuZAw1ib}B%zbOWA zNv-pfRzL3}vLA8PVdIo&eqXbOz%TirZp1sxHNe~+8|}_`G2AEw_J>(haLR|wo!+mb z)M!(5Q^3PNJ|;83<|s#5mC!M++Ov0?RG$>vKueV9iF%7>qW3$xm9YIzB6e$*$LN^^ zY^W9#gw&4WFQhT7KEmzVcn5MD1FRXkLO{$yA^PkfkVQdl*FBOmscYoz%5$$(Ih*~x z1Dw;*kIE3I$7syYL7JJsQIYvQLAa(?_))SOSnuO?;!g|ts|fC~jeF6~0ZkncNJSq> zmFE8Ib@EZ@>I4r6;UE>%d^{LhHmyeW1Wmb1UNZ~{7`{-|nK2? z+;4NP*^i^#zpZ2Vx|ZsHuRrqcWOibTHd)={rdD~MK z`A`FFhhT^oiR7bl-BE^Gxtg)(kwm{Azbl0zosP@(SK96`WKnGwM(x&5(0x}cifg>n ze@4lt)eHUtV9aw3t+Kg#j_ccv3NMtakUahSnp<|F63HLGll+j*Aw?^1Ah zWz)~%T%q^uoABXB>x&-3hv%k0%v1acN~(6H!fLdhy;KRYrS2Wf1{G!xt!`AAajoHZ zec%C2>I${(C}ymf+>^B}>9$YMC4O#C@H_5hbYHpG`zgba3E7tlqt)iA6#~J+ zCz0nn4p*V8L?f@M8bL~km3@_)KS?ecSPiJd6d&X7e+gYOXF@p>4<|?~F3cULR05Aw z)P0Ni^@i*pxZF>#c?*cz-tG}y(oGH_w_r!fxh|wozRhH<~9z@+o z&d_PRd_gwQw)Nv`No6FgS#o5GkN6V5i_FL+F*+3Aju8b##s^iW+ezOsSclbR zeKqva_n_$>wu!*Qf{|AOxZW7#1V8*GK-J>1^WAa1Prnp|6QEdMnggO1g2iOXoLXr*;?yx%DCwDA4T+Fvz z#IaZpMGkhL&k^D6rb7W>j1P6~FT%*ViekqKKsm`6?^voWYS?{uPM77U7`qXw^Km#( z4FDWem=3cZT0b)YczN6}%wjz-FnDT!uw594V~nchi&Gcj0k! zwbSt3@7^oy`H$5Q+{KTPjv3#N$AY2AhL8FxGK_bwd$buCH<)`tHR{0rr`G1P@JwsT zo7QOm76gBnqSh!M^Gs|iN4iY4<=euI6G8jqeot=D7W1?2mDU9L4zT7@?O5aEQs zC{(58j<35A(tRP7rrW-Wbm#s1=O*Bk_DO+Fo^FIU zN>9&N3|fU*-c|IJx7qq<{3#}(c;I603y;Y{Yc{2@ep-oeac%?hO=NO%V2k_V@1><* zK@4Y3uf=Kb#1Ard-`IIldh zK8x##L*mIU@QU;BF0;OjdXt{e3d4qW^jLH_uk+(pE}!+J?iY8UNzNU*%0aV!_Uf)U zL=CJP5y$f$vS$A#M~$l#m`ttSg+#I&4IXHhM4BQ!3M**G7BlClH!Q;^xsX-6?PP2C zUtj8~q(mgDrN-&}>Jh1SSy~l{`UCa>sNn;5Vn0V@e~f)sTZiDZG!U>A03&i(x2z*~ zLv=$mzs1cAtUh-cCtTOlq5KY_wr#1>gjze0T7?x?Jh&d3ya;gRmj5U?5hEo&WS z$6075RC#UZCz7J%5l!b(1jGZf;H6Mv#D!yE2f+__=C^y1CgSg!PR>WZEk1Lmhq^Gc zVb4r!efak{a^stR5vJRZF4B++>%XPFD(!cV(Q+hD*X*@=)x_h0n_+6o2wBD}q)Tx# za}*H{!f888($RqVe}%tDKhh<3U=99Z{aMVMpx!5G4_6{?@y%7)NsFj@N z?v&A=2EFGP%_$yJ&e}YxDF#dca7Oy0-AUm`7JALfxK0v={U{vLCu+)^O zuyur~XAs|w&__#NQT-0jw)dz|p(edlw+ z?B25`^P!{|-*V5XVzX(}UPZ<`?o3s_+-^1y$i}%~5DELV{O{vZV*r0hR~XGjev49M z#wBY^T@}LH6*98M8*Y8!695{r-5wnKY#1rSip;OI>JV`U6$J?aS+g4mYJA4RFAXlmxBYt%H#*!zEh#IE(oZ6Ma zpoY3hh*iwE3JHEPJ4u@BkR|RbraGS)ek7cU1)%*Tew`_BYyFQa&a&!2h_-pJreN0kJKkR8>$HG3>Cqf8$KWJcCq7VZ5r9>$ybvPa^chvV85eh;7|O zvh{BJ#N>9PlZqyGbMu{lEl!gGVEu5}PO$ci~xL>n~p z80~k$=>NfY^H*vQ4tm~<;rO8Cu)Y78_tAU7ERMb+#=B+vjzcoDcjQoFw*P$N%kTG? zm%9c=39hY6c;dUoVPD}Xi^+*d@xM)$3b;Y{@O?PSBP{EFPI|IX8TVOoImNBrDy;J3 zh`#$(#Pt^Zx6~Dt9CN~WG3eQk9Cf9b!n>KE5;nf8F=<*V{=a{h-4=rmlox8Pj*r$5 z=avjEq{FZ6e=l8>wGls>oD?bM;Pbe>*tN&y4|?fhdAS|VAKa09yGhwJ9N5l~n^U@;zuVI|zl}$)Gl8Lx@58?l@)NmI z)(@oDo0(dxtf(mn832Su4Ihn$LBDHDZeA*$nPO^wqF74PrN`vebKorM0eSox8h%Hf zl^>uEzlfY;yVE`t&xNisE22Rw(^{RHztZY}bdE_K^u&Sa{P~pcpKz5Q$YzO@b>JGL zud*32gRCg7Vx`f$@BtbuO)xxo)wny_0N*1qBZbLs&jEts)s+me}uxv5sx{^t}_cj-x&D5-zu=`F_^6t~@lIZX7 z3sG-mdbfwkla!dU1L%PeSLnwRZGmli{aSX>HLt2>vV~Xm*QfsfMr2gHV8wBK=kdGG zDm6kn;@eysqB9+ zp4Y!ST{c^;-(w^!B&6}DX-g+*_cucS&p8uc*7?hgR8((_hIjlLX7T8q`&^Gh~aCC@APZ_O4QDc4ug_hl;sd@Q+7z*HXL zN-5`w2g@yAO^DGKJCkm=^Xc~W!`7`w-MDSumpnd?Gnj&vHD){az%2TuAO>U}&DQ6j zpEKl_Sf|*!t>43+8rDB|G`$MPa)xBhushtf+fktgoD47ulNAtyK3n@WtT7!6&5lqi zrK!K}4tyCQkb2^jSP`~D*y=4pjI{}W}rKAGB`=om{{vlph!Ltlo@z9UnJ z`1&O4?npz;YC}2H=<0zp$#&;^S#e0$`-AScXWl*}u6!Op-kN=mlD@+Ad`~c0a$UWc z59`HmtHXZV`G(Wul5)km1U?N`#WA7ii9=H7DFpWZ7;^%abVU8m(umchYT+qMuC}19 zs{huI{b;Iyg~?vT&h15VhwW|lpZU=oI|rl0rFTSd0v$XH&Hv(gZR6pneq<0<+#ia^~xi|I;#LB`fA_vON+qt2(c0Y541KF6T8op>K^uuNCr0hiG zG>ak7Znl8M*zV%@Iuz}~Z=c1>_Hi5E#aK;>?^zX4b-uIhTsxo4Sc1C7^=e3TQ1MU?nmi_ecss3+gT1eB z&uE7$_dM;<8%0C8CnJ zU@7rO%x0%4+{*MYV#j+tqb#JI7(M9lg$!$GEH(gg0n}9%_gcQdufAs8eKR9pY_NC# z>+x(t=K0q8v0moQT;kia=2*a-^l$ZkX-uc7SMQNK-k;tNg8Yc)D=;K<$=oLyO; zC{Qd=Z)Vh*Fd<6$Opq79W**#5gg&`*&JuqXbIU^&1n?Su=SlNopgFI@)8@lw*F8%n z>RSzJpHV>*|AS=fwPU>t<468i?u>0#o2kZ12ps>*rOC^1>J1b4w#{Lk5#|th^^b=q z0GHKRK`rs7ipgfOuX)c83Q@KlH(en`x2d|D%q9}bv4l{5fMOZZbGTn_yZr4v#n#_W zjis6?CSonj|74h?no+bgU_N=PQ@6k#kLn8zjPWkY@olz35)c>ZmwwYh081JC{C-MP z^{uEzw==`d!}Z2Eq0$X;8>3)Iw-qPdeyJvf;!7dSK8Cv#xV@ z_t96gnt9Y%&U^OaL^WiWGH2W4c@4Gg2fmw)DN(s;AE9D9oZpV_oI4>^2?;XvMR@?s z_{YI@0IFYaRtCPmkz6*wj5&GO?Y1|={d)L9cG;bI{F>$^0?|^)*tR&^sZIbps;$Xe z_gF(MV81re#?Q}@!~{&I_?#h2_G48!T}WMZlD0x|VnuK^V{rOhja@S_hc!RKA6wtzT;Bf%R3T9 zNM<*^`KPBs{Bxa0x+D}iRi&5{-{=DW!M0)k`e7xSMH;zHP5R>OQsYN`QB;@n7PLG; zs!xfFQM6(cq^75ca~$iwYGISD7r<)Qynvuh1a+bQG-I1wrsC4S~` z=Tup(p;&$ApG0dSZVh^xwuEe#n}x#D)n-MZy9Nfi_a4!%j9m3yUOkAJ-Bn&KuZ#Vk zRiGZ4$oM1;h@9}Wu1n-TuyJQ(Zd zg49KKHj|XCA(D7f?_Z82+iJTSzJkn>KD*g>)j1LE26nDnr z7>j-><`bjSgw^z>RKWdzoo%ibDbY<%=t3;WOu7-dygA7`d-K@*L)ntyj7W(Y zC-K}h{hm^hZKpLdZxh$p{_cP#U|SJKYzfB!*D9LT-qZS&Yn?OsvO;~}$)V~c^9 zNBzf*W)?vNhu*CHdW!8Yki7JnVEQN-Z;<&;J4BMLyi{r@?W~}4RvA(H!dzi7Y8i-N z^=zREIIBS3K-JUR{+zEF1U z>RMBVngnnJri1 zgHF#?#XSqapplgJkKwUjBOu(|i^Txlz(z)8?WU09aDi88rHu(SofO7c91HmgBbI=C zIPZ`0witb-np^wDM7%(*?@y`lN^9oiB=4?5IEU}UJ(fti8G|>75i&Bqyi`ZBhAK{= z2pY!m_uMDIJN;-O${fgBFg=${V0BKm##^R4GWrPxj?iRP5ksQyigser0>uxsY(<6* zM)smiXE5KLqwE!X9s%FC;8rKyP5@C}T$d}zI68>vhD@*Mt}c%-Mcmjio@sBqZvF*J zfOh^hGl3T@lW#^y%JOby2yCZL^tG3R#80(5_qx7b8xWUX)>7CSTn*(u4l?f`*|-`{ zPV$ou|NL53H(Lh8qFahrC@p~(F&W-|$^A;5AT9vn$xc_+5gOJ}D!#)vr*v~N_i$Du z>%LU|d4j`NTc0WF+9EEuHrxrEvLxnx!E42nR+Klt_h~KJ*xK-N!zXV${eHsU>OqlQ z&_S^WFHyj;0nmGK@&y6FEG=GyWH$;3h4{73a7JitQ;q=pZ)IDU>y+_bosBFgf}p znQEKc0=QWUPUJ`s>sP#YxIuO}{%_gPIV*(X9<2rDFZWo!j(S|YkH;-};~McmM<8MU zBq$88$s^0RUEX=zCjy=fFs!@0j}Gf*^~Kng{drN8;%B>A$;`5Fby!xmV`Ngb^4`@i^R~O69C3nWF;cS27(PoKKhL zYwTI@1j3)-Ctp0sMPn4P+L!C`rQ);6U6p;Icu8E(d`EJlaW!;iXYFO%`vpt?QE1T z&ajf<#%Udk`bb*kaS*g;;Z`g-3)-L$gE2eQtSHf8-R3c>JQ&Nlq7wX6l$Y9mrmvlUK0Wc>!D1r8KcxMj z0HLWAOtZmL_WN{crdy6h8BPi>F8aZNUioVBT zpAE}x{W*t#SWm3rq3p!b$5#XZI>_v1JH3FWv}?HyrwMZElso8(t`}>JC1V|Ys4{#k z9}QdHH{tp^`ko}okEXKAqzXvGs>E|fFp(*{{XGuF(dxyIVHrZ3$NhzV(V_glH!l4F z1TXiiW-?}Ju6xB3m`z5Hggy2N$jidINY=lR+?ehNX9h5%pdgzTP%V;t4JG8SHysO4 zsQ~DqEK(h(Y?Ir zA4ZcGv+lo3#PR;xH(1s}mFM$8d{?!6iF*f2iza6q2rk_#&K?Q$lE_o^^VbCUN>e_Y zp@Bj@OnJm?=RB5>svZh2&;16?G?s#x7k@SnUN$IqJfLrF`|y*|lO{1ZVRKNsPh<*~ zK``X#^7yrNu8R2S1n;XO5yaQA7FSq;D8$WMQP#HR6k})gkA#igeZ124HyroL_8$C%v+Kp_uDC`wk3M?_cIZ|f9>!)Ez*ih zpd+L2W~Nyd0wHqju|DoD@oPta1?pe1jz4yl1=2zWS+{Tsfe+LktAP`=G#WRw4=rz; zOj$*Zjx0CqX_`b$^cI>3KXj(*`!GrsB91c5GQ{xyf_ z7PB_H)3SsKe=}AVKU2%CO3YTo?WDaPkk8jQ3SU%xbw1H?udHihi3NLKB1wMp{nfmH z-gbn=$M>FZiCa)k;456jcGtoFSdO#JA03IqgI3vrL!aC`fJjB(oucwte*4|p-DLDj z_-O9L7prT9LYOt_l9^4E#FO_q6x|y)3^4ru#+`p<&6=6JS`@VT{pS*3oY&hzHjeKE zJWt-&{rS}q)L#1RO|p965fHto2q|{!-DnHS8ze(Ez689jGao7?%%Xg8wA2+O@sHhf zH1pPTb8pzzHsiU|-#L|Ur8f=e-|uQFFQ(0rR$pg=s`0q2>D1xSiOlGvpB5ANiXG4v zj}sb|lC0nYQ;*fo^?R|k@b|F7`$75PS@fO70`5OO%#V~8c!qUO8xaW}=mXkdVItLT z7>Y!pPlwLFtmCigl0_0>8RJD*ASn|^i-!qoOiW1+)2Tz`e|H{?PN@8CGF7sO-FVC$(=1R;*a zx`1D$!W0YHbU8)}y@}Dx)u*DPU%~s4vFPx^rcx?I!ZX zUH{&ywlM|0hQR(71SB0LN6ii)#he*Yin&bpNi?1NAN0u!rS+)@SdJI!rfGS1r}scC zd+DR)hxef zx_O^diQXbJFarqhmdJK@7f8v{(u|L3O!mUteF+QvEyU1O#4Q&wMT8Xm@7MdH5_$;B z3uVmA*30#4KDL`y5KBW~OuBU1H{U(^$bYTzZl^DGSknKOP3vXV-|N4712F|x8HnIS zX5BCQmnt(W9p0us6?H!_LP!i8CkL`d3fmi3y7imgZ3M&NdN9}cC4<1pLm7AtfB1wm&^T5%Yb^! z;q^V!vn-zz@m~O+o-|ex1BB(tKBU6DB@Fa5@_~^%LoApAdIh|RMc+n&FrNt&sGVG+W++&OqBB3o$o=@Z;-urB(3N$&u14{6qU1$GIV9@tTv=dcBo?rCC zPm`#qtv5g0E^f{f*zGhYYX#F3K3aMm_pg))MUWAHu8=$c`JwM|G4V@{`(zN$pnNIY zLp$M5$({?oBK39wiU+Sb0$5E#h&h%`qaJ>{CRcQN>`W9*8;AGbT~!F$EF3@$+FO?~ zKh(jC$W)C7@iu#u)01HOuXcudn_Zer`lAPn)$=kvH&dIO%RG%*+%`1tu2+P)6Qw)# z@+BGaAx)FX`}>Oxhe-g9QenV6E2MnnvNvM~aBH>mRfvvQX(>Lo*f+pVkMpt8x(Mcz z?fbQk^1rWAVD3h}cpAi+D8D^|6iOr)TR)bE{AxcfwaRKPr=(~F$ds0U^;9ePt)8cw z0Kt{c&wl4R1&sv617keU?FAOhQy#4E8RWLf_f<3203njMycJr_r6V}$?De|iQ_h~{ zW)Pt`xsWZn1yK-1n@gk9nm}}Xv~=ejJvPBIEafr!yEZY}=@U7Zn@ZaA@Cl=47YgBn z{qU!U$v#Zkl7QU1)2f)VR9PQHpq-W}dwy7A#=BkTu$G-89j1{8*yPw-bXd&ygyy?1 zyU-r(x!I_f)p}oO+r>fzfB4yQi#zHqqGSZ;e=ZlLfxOtCuSLZw|DjsKX*Q)FRw1N* zq4r;@#m*j}tjCUH*fL)X+9VylLX9WTVRqbKEN0}IgoitRNDgDq*}wg zl7wp!cv8v)C8RHjIMaU94F^4Ka-XtL62| zn<#+rdy@+c!L-XR;TR`!k7q9Y8b5Uk80oBs6?+6&_ z1sWZW=HJO}ZJ*t7HV}DD_m`Iv{&FJjqbDBA1vE`{1E1xda|T#h4}U6Lp_c93)D)5o z^{za8S5oUeaP52<1r5R=?kV{Ttk+M$$Vo`|%26q}Z>^5MCch1d88qCQ3o|1Dy(8C6 zGNlV}O!~N)o#a*C)Sh0TO>qfS#hyKZS#Wv3ZSJ9T5RVpuLxkR+3uj7HeV|rth^=D_ zE{N|Uzrc0AKaqB`Q{jWpQ6q@^h-5lL9L-CxJ`KFkz7+7H=<%b0uFs>AWUo`A0cm!9 z$`|Yxw5qA3U_%--X5-^H$AA}pMbG=nDbr7Cgw}n2bWz|M?;ilP*WLl^!3>rKBRfEj$G`Ie7~APQvrjsYDZIZFCAWJSo<4ukuN2%Z@&%Dn`2#Ty8a#OATI#z2U-RyUzDQHOIG;inxt@HP!Bp&y$Zh7+? zG9~gyQ;TrPL2?}U?ox)>K&$WG%NMz^sXZeXP;d>Y;7HL9R=oY|C%6(_*0rJlpF>b( z;AjA3o78T;%SbFcpsTIEH*6h987C+C!eJ?^G^!-R8UDH(;MV>inEulagsH!^ppf2H zl!gSe-(zuBtyH-K+>j}~T9Miwq4GRU4pDFiDG6(XVP6)h?6qdO#t~NL4d}KB- zAZ->_iA#G81~gh%>yv}xcOO7l_w zv5P@{x~SK*)w&Yo*Oi+|esT%(*2fUn_UlV+&-?_%9UIF5i|+79(Q z+*00bz-h371@SW_&D?hC74OYLF^R1Ta8|4{?V9V8xwDy<)Cc<3$#zDX)br9u+I*ix z1*D-0@wK=!-+F?9!NmMYP&y|ASK+hmo!^zxh3x+P2=_S3uZ(z!9s81nojN2JAb-*nfjqaH$$;|Ro^=~AQPtdE@^{rOKn zHKfBu+SczptNv1#Qcx2o)5L9bZu~-A z*z@O!ZX(o*4OtE`nTc%pxV!*B1I!LqfFH`$AhLQ(N%+~E5yr>K5ZkB6&+T=Mt%3d19Z2Z&oOc&_(xXfhMA5KXQb zd(#wu&{*g~f>NG`pFx5cf0|pZ5g6>VC=I4}vLC{uyeE=5oPtaYBq@L|Vs5 z)kMy+^eRyoOK(DI54yG{_rZkOgBHT1=gd5JUFy5^6M`h!O_uF%OFq0MuF?f>_(v8> zVTZHRGZh9SPPKj=5}%lH2K?Nbo5(Q@fY{7d7S#Me`e~+R8zMT&FoRNKQo9)Cjr+oo zM?9&oi#k`wH1zeWE?CN$dNkHb0qLpgg!u03|LKGP1iF;2<3Koi18DNrGejDkV;JS$ z4UQkh(tC^8(&3#_3-al0=F*rukZXW#hhXdpQN0t;D(NR{Jji`DxkLJHM4MMqYn|&5 z>-kdK&DWE&G`=7m9}6IuiJEZUto@+PVbuI?I;m9a<`R{eOQtISG}Lf?b;wd!$N99< z$lx7j7Cp)83-YpV`i<|!DQNa>cn~Ym(92)XT~mbAG5<$XuGit`%jxTW4)?jDIy;#C zmt>?rJ->s0x2cpFx7oD4*3)0c92Z>*D$L8{_~7UHswLYd`YRitXqH^dmFv_07zl`@ z90J$0Y@sY23$2Ikw;Ja_LEo7%>-v{P=Is`W?Ue({ylYrPuhnHI-gWQya`OQ(hyH03 zZC6F&9oL^#1|!f1u%34P8#rcw_9%m09^}z#9-*Byst3LKPr~)@3xZ^x``OP=lJO~B zCXT68w=~x0BtKHqE)9a>6gn>9a|FWAKIQv zCuO@#dSiR(7MS09MgHrq;-Fh9&;bN`JB7UFXwUi63GS{Gf*!Fanx%As%j3{SOpTa; zuVEekwAwei+``Tt?b!P&IxWG`n?Mnaf5U`5R+k|i8!*Qh&F$79*n@b6&X8TF7RB~u zUwMKSRluY*O%s)*{m}*flj1)Z4b-9KNt(Oh1eTAAfDV&4#hsr+!@H4kyn@>lw{rim z0r)m;lxCNtK2yJjDd?l9;8eI45283pO8W{XkN%dcWn*8??N zXS;$Mm(v_K;Ul2V6rcL-jtrwhN&=*|QYSf{4hoBNf?Y+*hQL2>z!P1zlEGha^OUEi zjP{?K@)qtoTactaTWk%4bUZnsTS>4m&lyelwC%Q*VO48;Zy|7l9H2X$Q`$2!U)?^4 ze)(xq3U5E|y9Kl=(!Tej&=`dnE2hWj!W@o;+K6^An#1IMq}q#BFZiyO!IP@>z8tN7 zuBBzW25X6@EVU+$ABsxjC{2{M3o29F6RyT-#j}KhY@&&QRv;@9HsGr-6KtCnCLs=( z>lOz@E~zaC&m<-fH~gQ|RF8@aKYG-qp-2{a*ti~^`N~_PhMSvv>B`W)jk(QImF{ku zhn$8fi z_cuTrryJ8cO#4xa%r>!b3;3J$uTB+4F&q*9RgZT zUztli^4Te98RN{ZC^2|bIAbf%o&8!jgfCHKubd->{RrqnD5?9nEc%ua49yO>h7t4f z%Te3zYg_OHjJ|4E&RfjmAb&QkZn$}(w1oAn_-k(!>rkF#APzsfIkBS9=b= zCdf!;lQ0W>ky<3ot2030a`8|>qAe8vrS>ak&9VC%-`B&c?Dr=_Thxv}d?&7s)?#L- zrs9^}Xy52cS~27eKntXXP`A7Fm>+H~+ZxEiik8r+2c z78a)JA#VFO!@0em8#^fL>fC2!2W~xk{|3-b-6KW-?G#pBCw{vsioze?{{3+L)kp=q zx+iSa^v!{dp$8rw>!s@n={`nPq&_pBIV`dUTm=+?>t9`#|3*($7z*F`T>h-g*tx>P zF~jn0it`uuYRjve3%lwD8lR3iCVC}W5o2EndX&%V7j}`8&+M%nRgl0s z`E42a1YY3ZXz=cU^+F|aq60+km~T$lKOEIsO*Xq#sC>NK)?#*S)je|OsoUXM9Z_!~ z5HCxTSzv=Qh(yD_9;cMdY)DBX@i6zCEc4xdwGqCUfK!L)mV1oF9alyFt&ALh%?GBI#-1$A zI9&nifA6O!RS6|d1}MIXy2eA1eCK1*gx}6T++pi294mPcrM3IKI(=m*Sx5q7ax8|K zabnsu#$9)lyhK9@{mr;QHS?c-1HA@*Z?1v}4`>7mlkuzpMh*jufLhR+ zcw@Oy*F8rIl`Y}!fG4@fNY#v*E35BYrnW$*Z5DqAs#kCZ4A;UW_?DTgw z4DY(^u6*s8_r6r>qg8!-@oWRtm3$xa`2Ok+PcvPu3fYB6&{c(}t&e|(+5)p9@1(Hb z{hx%)S}T$mjFp|-yt90p+1Dqx69aB-yVm}BtOSnilM_V_-ket-KM|SgZ(Y`cTIAa z|5PV0xy&E+H2UIGlOVuj4R6F%aQZqOO3845R^wJdFxNISu18byjMqG+6ff8S~lx-c?N zpE?cbJhW+#anVo70CZ+Nh!+ds0GdzRB6!&-jJ7FStajpa4Fg$TnnDFczgka#kP?M_ z&5P#%>olj-k#DC>M2^w3(1roBNv>VUW({D>Ngf^~>MVV1B1u&kxxg~9$p*SfTaTdT z4hNgT^-vukWSf_cVY(Q4H_s2QZ6}QBRpJ$Wrrlrzt}W$^h{FtsSd)*+!zMIK?CQ84 zmIg>Q3xVyoQfHq(ZDqEo=4pr6!$31TrmuMgXDghyVvLR%8ur4BMfc{cHtHG4Kv@2Q z7=0gw^^>3Oo9=~T1l4h>J?;B}1k_F~zPD}%_ff*X9}66`-xkHoG5jxAnq9M%xu}Lf za95c~Z=8HwXP4S87?u8bORp@&D#a@pkLz2qaF zGtI8ks$S|>fRqiPGRey4aoBd5B+b-)mkmg~IAH(%761h3?!)+T{7P0}RQpVaHDfU+ znX_0TS5hSNlI&XO+X{}+%E(9Z$&{&IccGs%bAU)jHc-1vN4-U}P-$nrhLw4DkM`xi zT9$*I>i+ut33GZ%XaHkNoST7Bt8~#&JWzmoR>i#Bne4Z#5AHQ;K3lJ`$kd8j(JeE#@07{VAR zNDKr72`OQOl%R+b64D_#I;A8O1xACkK{qnGLlhYUq`Rb~1ZkvELi`=y-~PVWv%7ol zd7k^c>r$B?prrrEb+e(0kCWxwd9k`g$N(WoiBy}ojNhx)pXt-*KGY{ZIUf~chrV~; zQ9qmcC|Q2>#n5;U-PL`_;)E#_ysN@qHzU5FpB_;u)~E?*dJI3hciiPqk~l!!-obK8 z$FRTY_BKc@r&_WF1u0ugM-2TwW2PTkcY*kXGz>FA__gfRA?*vCaNkzPkT)P);Y5LV zn>$S`ih(e?w)bI<@2(mdi_d@9)@21+h{&r6iKBi#2&!p_~iIk`6 z779%nM|VJR7v!}gTmy3Zf@SWSg?qU^mpX7W~>& z{TC(6nSJ5E&22o}8k(&49zaKI7yxog1-xa%VV`LRmCV8(8De@N%L%&3u;h50;pAYV z6b#EeJLjo1o#DM%a0SSKC|8T>-%DNPiaI*u#PG}~LwsedA0?5Yg79R5Y&w@USY;r^ z>FuFz&-RULnUxh=Tx}rjPn9Uex1@|KvR<415XjXJS9v$1OUk27ODI*t`H*-$DVG&H z;7m>c&i}8}sPQkg_RWD!8l}Hx6qQYscUomptv98;JqS?;Mm-74Ip0)8qDc+%Ka=`< z79~G60tNguZ}!LfF`EsFO_Gy0<9?FY|8WVPR99bVLFFdBC-sj<}T#53qSrB{@ zNNNB?YHj+2W|LkW`njJd^935xn5YKt>$sTuPoLs1>ry|HRn11H?|rS9lJl{DNRXgS(CO!Fe6sLr|Ybc5KBFs|UH*@W6!52|nqz1PlN;BPfsXQXnC z%XBT%K@>E*nPZH3FI!Y_>SJ##0`Y`F&8kbzg9ojJZxVvpxf1M>=t6Bx`SPliSYh7^ z-9Lbms{HZV-)UtQ$|?Bb(&FJbwXJd~c40yAG!(+L$)V1nO-mWc!{~Q@982;sYnT59 zCfY*54sj68s#lfcV0^8dvliZfVRHFW0EZ(=u#DID(F|SGbR#lflg&|(Z(e!w2>;r{ z*P&Iq1XmRMS>KU6x2B6;aMMz3naQQlgYMYnFVAZzcNm>09&KGLcArm25G_;-6Kk~&>1~jixi`koQ(XS*g z%OC96r;U{XclFjAAF>Xb`%IhTyei-|D+av5B0?5=nI1Tz_`5rBZ=q)x2^&)i2X7F zD^=_muK5uvL}Aj$A5D$orr|CJ;tDT3utKDN(>q+6ckY}2Q$p_7zGzfmaZW(|`3ELkORy-*t7M?w&Z2%iFSc$Ky+ z=4|%mw&%Le-^_+r$L_!UWK%rF5cTXuM0i_3!CHv(E7{1A$FSZasrlxJiNJBzg#bMbtHda5HN^of9q2Uab)c+ zmceaB!b*2&WI=8R2l!kNc)BBZ#O2*ozKe!5$!RBKJNEP@a`hs4A`I!{-IRtjkcbb! z37ZyLWOo%Yk&Q)I-r)~@Zx2lB;%c5UQ|&Z0VchXmXLYhbgw4T@2-Cq@d_xvQ*ssU^ z;?@!x6o2UgBTPERuw-M`5E3YLX_7Z;zZo{GH2`f!o6mNpCjV_%JVou#MndxQk{b-* zGi&vdvSrR#niR5fW#Wf9!Gk;QBOt^5E`#d3oX60qF>%~#&pc&nHj)P5 zA$TI}ZNAGV4s;84hf?Lhlobe{xVWa4*d4Thex>VazyzZds6qlhsmB zXyqpiUO8W0eF!ng|8wzXi>O27LvV%UD_ThIc(96Z9BVKjtg>XyanFR*(woPq3(13< z;+KX^!86Up(_;2iD~C-Un&YCG|145UpzWoSTmUPer&&E+DFG-JMN1I%vtBpkhRm|m8X`3+p*12e`5gz0mi%!h}XPr_h-gh-fqb0{tlNKL|Q(>*4> z-#Ch;H``5bVmbyLh{2UfEhJxe%P0X^YWMsV#D`LBm&&43YHI!RL{!&GP5D6FmL)>! zg9bMVyh@KiC1*X;IQh5mIeT{0uyU=M?vS9_tssv=8eoR*oB23RFQ4v^DeO@lG8 zv!!W<+{%-Sd8e*^c3>z_;-ZX3Zw69sEVZ|{Q!-DTo3fFpr;4dLcx_H-4uj+p4#ePi z&Ff$ewI`b61-o22ce8wU&_M(1d%zHHqU_*wiOQ8*$jH#J;CqgIWW8t`p8C>$A&ZR9 z{0Su|C*qhdZYT0jXwbl(%k-cKbWpg%VoycMQd;Gk2Vcbe6Cta7Zkux8cYSc|yx2Zh z@K9LP<7RlD=Q56gA|mcZ9;WTJv|v04E+7tRR?ne>;w;lw(1jr@@jP6$ClRg&rX@-(qrC0SNpY>At!eeO5!)M_ zb%2ui^vw3PujBCe5EOHu`K7i?Fgi z=G`OvLmh(Mm<5|T(;(yHpEirwi}-R#z~f`{k@HPe_l``3~MU%sxk? zwC_%sH|42M1s}e*EuKCMJ97Cwp<_vccu=J$`}gX-4g8Q9DKHnj41Xu++r@g=M1k2{ z$hNR9%vP;b2puBk!B*T>N_S5q!lwK6r{gJuAeLdQc{0XTOVL2Tr8l)OeaE$lfwJSe zsb8DLW#hl=;WMPXIAAaS{@8S6b-G{TVLPRy>qHUA1bToJp(Tygy%hWQvMsRo1Bd^; z7kBK~E>fmUrWha3nS|EiM(?=@scl{9NN5H1<$8FXOL?boMasaM@9)3APG~_=7`df2 zr3ssrw~|=q@3Ct??S0GfC+ooeL%_5)QbImTb_$$GmGRQHdmyB}RxOv!bxxe^q--Aj z3`waT+|bv#-W{6Ah6uLG&#aAE&lvAzFgq5y_eOoSr}S`h@-6Dsgn4GF*p^$`dy^RgwM_XfSc^urmLYo!x_sBeeI0xfg9U?RmpMha3N zjo*~_n`-Yr^({SeO5Md)R+=812~*T11kQKQXBh=hos*Coe5uaQcyZYMp6~Scb`z!< z#xR3ZOI_dCSkv%R)P2lm{tjmS?>0w_Zx`o5Mc0p^akoOMsGnOBArFjbvKj~daC^Wo zV;i5D2m9icdO!9+q;QlR&@P$BiCPiO2i*jSNBl#;Nb4TzoW!vFO;@%yqE;&{i0lLb zV8;w=vtgc_+kPLn&($qX-)CC1_1hw^23=z;YGP_F2Q=EqqWfC*r_8gZAJNx})vNs% zLLPW$d~H(T)>yx3-ZfnQ75{Lqr&$3~1%{=Id|ce45Y6TL62l|M!@^Cuj= zYP8Qw9ObA?lPkODvf})==w-GpMElQlDdV}tzh}rW12b#I#2`Clu0nK%Ne$aCa zpS~Z?Wv*4C)Z+Qx(tsuxi+peQ(%};=NDy9AP=X*)Wa0PoEm4vo2qbWUpZ^Wt2*>Z= zNekZ@SKkp{5(yeFFLxfCe!Ow@J%Z?Ydld77(6nB}a|;8myp8oxEuo#H+V0KjC`Z8Z zM)i<8whKMnFS(JL`8F=8&VqvZb%&M@!nnPc(g-IKWWNI#N$EfHK|F5Tjp7w_pm2SF zGOcxBWTp-Uo@kW_5O7gOcm0{FXLky^ca;+v)Xbq|Q$8$D$SR+8B#*B&m3dIdRXrFz zKxs6#QT!mx54u0VmdEEHbf|7MAS4`mtm7)$Yc0Wr6OG#}%uncbhXTL-)KEL$=Q;X@ zvKnKB@1&R#`QEsAp#iy(yCrKQ|H$_T4BvhH7@u$G98jn5CITdVMv;gM*;8R1NjFpRu%YG;84Gl% z;^m8pVf*zj=Wn?Eh><7V{{)Dxat2ohm|`Ct@n)Udr&`fOnd5D{D&-?nJb}Ve{o$od zHH-YC&oNCXrm(4oXQBqKpp%sTsjo;;shi9bHd(OGt9%d0BNmiiS%1)^|Kpbw z{7TcSEiXC-g7e9nS0c9O*SBuu+}}b#+BG?UZW-$IJnPUK{iZRCfY%TuDVkhuJ)kM& z5WP*@)%=d)Zt=z*H$V2inMtD3uPpGd{|qOFp8XVz47#2DJf%Rv`VH~7W|IQjw^v19kUPcBQ0>6!MbGv#Z{BMB zrNSF@-|SF}&voh*DxWz>;c4az)kNYrr_+Drhvmb__8f)Q(R9m1y`5P8t&ekkIzMN; zo0z*EFAM2DBI>%m%b^+iDp0m80wQh$6NU!j+$QPqdeK;7Rljo7dW|W8BQ{pdg@zRDi zJB9go)xuN~_56KOGDRPeZGN_h=-wwoC3cMeWU?1H;4Ea2Jl*Sx+U#UO?dbUfzTD+vdlpL|o`kAy;NK&{nxvPlduCs5)s|nyhTz|+$NG@? zS{h%KFM^@yyHZi)d~xL1nJ?K`2~;ZuMSgF_U%uTGIuaGfcdT#!pn2cSSh|UBjA!NM zENVREu$q#Cyc|kYR6&9igO}V#c@GA~muNLIES|JTtgkys3tEk+NKb3+W`Ac;Dl zy&t!u%__rU+uqhIwKsC*c#;pph+;2wlS^t{ zGAg1xjSgd?4z6>)O*bL4bx%vt^>}7)FWr5fIIL)P~#xeY)^||04WZ1 z75(CSN^<+{NT9it>Kz)d-~Ez8aSR%5`|oUV`7#NRdOBZtqp%YCLcdrCV>3T%dsdbw zKqp|Vlv!=lXV|#(X+kFF2F;E(0dZ2b>+ySNsVg(zV-A1a`?4ZBjXkJ~H^Od)PRhN7 zNS9ZO_R%E|t5%vCIWvYgDXxQwjnn0G3l0pxt%`(?mP@OnGN0`Z0I}(|-KT;1MH{f6 z{;;j3co)>mPVi9`Auskx9j8yrFa$et)sHN~qzBdIY**UE#!VJeDv zo!>o)uwTK3@>44o>pY9l?-}@CUAF2cdlHJV=OlmMFD8&kYOsdF*2P&{f&O#LX}WH! z@pn-%Tcgj`)c&XEV_7u}QECQ4XW_i|e6HW_+$+*4S}6a5YW3je0AZwTa20z&vPcRj z+52$gsQ9)1%XS>|?{b+Jgnp({%pV=Rr|KRqS2j!jmSqt<+j0HoK_EVdfm(`|6=mio ze+YdEa=NyrYJs0V>kRO~UfZ*cRSfql9#5)FC!BO-F5EEonOMP-p(yS|j(f5tfRwy&}Ujh-+;aVI$5w#=36E+nY|X3Ms>Z3Ge0J9;(A4 zUbgfA*#2@~?y>Eq(BcJ`6Tvf_+zI2#T#(7acK*p7T1$?XEe0?P$`G(WS?v~|mFSs# z$koxjTZXU{3ya_EkF_e zb0`ri{bDc3LrHLe!uS;;Q-jq28VOM)%H~;}Ul@FHTUn3Y(v%J7a%hZVLSmZkw-s}i zGF-+~)x2*e#$>$m8l+#PHMw1B zZ3$_5&!loSY6-$(1i|D87OF#*Clc4DKYw>D)W&$Z(MbIM9y(dwaRqCybo#47&{sIZ zrSb5R5DCNk4u~r<{#KF2ZD0A7IFufg-!n3vl(c+nA~Aze{s z0@EqAuUn)hhj^|l31aX}L`Ew^DrYD0?$yvHC`s{LTX+;vFmG=mx%#$tQe^=V8KJ_E zzuKeyz%#bT4(#~uxXfOjeJR-Qp7OGwGXTU(RIFs!=8xA;31do%G)E6O=lt^u9^|?e zqWZ zV*{~n(lf%vc~acfkB2m40JI?aI<|~LR~BVFa(C8Gw>l{Rl)63-+exHIx!&IgYIme* zJE|BFoNQE9NF&}dUG6~lxB;6$6(5gvo610p;MYUYKQ(a>a3#3wjZh6EXoh;3eloK3xr>f(N#m@z4pgl@{&~e7XlwDM3iSM!qhT zfJTX2VKOlQZUd*3))2wZri4UZdO0b^ygZX zp?G_rAr%redAo?RU5Y$34^+KrMPKZsFTqit&kE@^YasjsbMpZq#9uNIS~DiHJHvIjyo~1PD9Vg)2-Q4z7me0?D)8AKjsHJ3)?&{sizv8E80J=bP7)?qI3(SW zqSYMQIUz9j{+l7BzNSpDjZoYBU5W*p=Y5{srQ9zoQM~(Fl&-zVoUtPk#}7~wAWTI! z)WLRvltg9mtlB{)H}T}Bsyg*c!=K58by1=`S;B&Iod&hUWdSgNr$_Zb5ThEbdB?Vj zqYMafEvy0Y>ZJUYffvKzQsA@y<2ZCTPRmzWi3yj7Fe&#LnuVM_D)r$j_T zE2q+rDzomZfEm2y$<+H;J^=D(m#9MYXg$<-w4JCVdP=9Or`Y46mGNFAThGEY0`psx z86Q>k#TmKHCpm3}T?f$|L=_*Tsq6USNTSBXo{nESa?~N{L5l0g{yZ(#d?Km~fivw7 zFvOD5d$k8PG-x*`a|qvE`>1rlzE%6m)=i!{L={E?=4(ufN){oqe@` zz3bL}@(@jeGh~sx08O8BOw@%rpNuwAa7o%o+jtY@S0iR-9?7p`u4<#=`sy3}-o{id z-$>#fh*6UUKGeu|xVDHM!2&1>_$2$KC`I%bW~>Wkk+5SEmIZ$-+XAEu;?O+c-u=5; zkf`Lz zgccR1P5}P>ASc0y9$PH)LF%6>{Y|sX46#l~a;h$-r>E!l<+`WSQl3?RPW2Ln?G$wh-^c@- z?ys*i0-1T?qFes1$>KL+NnLnZ!&^@bQh&sAZiVTD19h2c!e#fzO%BDEH%|FPPFZRr zZQO%C&K$M!mDRrh#uO_nAYPu(vDo?W=&i)x2=!MjR;T7hM~De2II}__vLzC2lNFVW zFg`B#mqc#NoU$rXGK0vQkIpYYt6L{8z7SG*n3~7A$|oBD5=HhVDXxiW{5bvw9R9-{ zOwW+Mlnm}<^n{xwEOD^gmUhN-0I=?=fZ7 z9t``}nu`SQ}15~Wl~^SG4g34 ze@zcVh1eZ1^buz0!0NMqfGwN|ZHvtO@DS|We~Wfi%56U33Q|3LpB&IB^ci(~-Jq}* zWWZX+ma^cFrdT{Z$FQ~=-^Q+q^_qS8Lg! z`v@8@3e;=}43F6S{%_NWu<=mrAn%EFd4IfZBkbAAxm8aBttiq0?OWI0Y;d`5wv_CW zhAf2rOzpWE4c@2k*wz%{wSh6*BsXmEwQ{FKIaPVPSC(QS{ql+)hDvZN5Xd_x7ef`) ztp!C(sMPjt7u)^U$J>e3A(hLST{a^vb7|HOf4k8_f);u82|jDE2_gAq3I41!)SF$z zXqtZlRl!1e@2Uv-unDACWMB*Ry{aMyU)v(#@@S|j0#T4?p>|qA>hBl> zgF9(rQL4DW<~oo`mkx$oxzX)b?}+NcMD2JdzEL{nmK`@uI<}n*u>P|o5xipBZ zVd8>w&)cABot@4!Wogr@bOJoau_M&hD=BU&JvTt)eS|UIi}UQ(+@O#}5RGg3rMGVE zis!NSxmsw{5EEn=wID^25PepKD7(GrrjXzCB%NT|IX_WD=HEzs{?Hm#lOLhy|1|lP zjkf}$$)j?JV;BXA(Z*-eP$B}=SGgezK5WNb;C1>IO^K}Qx7MT}!RGRY?zQP)y)fo` zpOVUIU+7>e`euutHlzc=dn`F;cbRO|nArvgUn{EgnKC0u*d)Dg|NTr{*>>vrfGAQn z6`1BoB{zW-zUKAE!$(^X<0@BJT@u5y%0H%HcFdhc^3_4=y5k@pO?WYO#rR;i<4)$a>V@|~ zXnJP-Mb!=90n%~yNQi{3Z%MsVFi4A}AGCLeOE1ooT@}zZ^7|3-FR988DM(g)taPma zZ0@qOFTAAQxwVy)N}7cZl3_Y@H# zZk}g^K5jCZ7_JJU{(?N~WY3)V>`9>_cOQ*%Iv;~VQn%R+Na29NcjaY8-d#?*w#qb= zs6rZ)2m67EZ>b^nx;V09g6N_JsTRREcPKZOr7gQcXXE=P@w^7l>BXxuICrii`ACUn zplV88BsYLvv<)wT3?;6Qj=gx5yzk8KOy%ZkWj#_pzfgT}NB%OPJ1@?A&v9vB6I+@(g>fR{#?Ql>15v;pO&UtY7`-m%q)8qfn|Yg+F3~ z##jwrvzZ5i8i&jjOR-BUOM^1IGg|WmDjsSr${WO^tru8%S%pKG0}8qJgEWasJ3x5u zkI`~v#}{LSzFJ!aBZ52#ShMd}BxhX0_8pLy1j4{jf^`W2=4CixgTVQUTRFyDi21wo zs#Xd&FGd-lN@HhdclwlMGEIKE%1)ea=Bl)9zh`n1_Hhd@Se_+X+_ovOniI#AM-0k-fVP7vr2-_ z^&8QE68bNG6V09i!>EfkYJ7f&0C4+7gS>am-|w@-#Ky%dkpCSVp#pskqCS3&7TyfR zn7rX~xdeG!#%2hPx_|&h0s#xPEiu^DzvFZ(;622ZEG>!+ki`%nY=42kP)tZ?5^`kW z6^Hsu0W>$@)EWc@AO^VBsArm5`Mc-~zN0f+b^#P57o0!o#h*doP8#5(8nNeOVsZh7 zh)xetmMbPIylG)D9N2cJ{vexx!Vh>M92;X-OYcJgZcUitln>G%XuvB1oeL~6dQWHx zjIK{)T&C+eGphS`Xttt@$od{@zb~lOVuGZE!hY&yX@3ff9h%OD2ob5I8XChHE~<(w zEFGW(9pkB@|LerXYQ#alVRP3)fM5Lo9jp0&Ks*TN{C^W!QBaoI`DBX%`Nw~4Zh_Nb z5z}$)8aE$lxf7btrMfpg>z3_;>JBMs7tgj-IGJ p3Hre%2qUG{;-(3L+3@cCBWhF|AS8OztV6)>k^JL_WpbFn{{jAsjP3vc literal 0 HcmV?d00001 diff --git a/tmp_prismoid_2200017.png b/tmp_prismoid_2200017.png new file mode 100644 index 0000000000000000000000000000000000000000..692bac187ddf72ffb860312dbc11a2cef9c5f5e6 GIT binary patch literal 25005 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

`t^$%C-`wzBMb|$QW1y(!l^VZ?% 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 literal 0 HcmV?d00001 From ecfc506d766e7ec2feba54f2716eadcd05bf802f Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Thu, 30 Apr 2026 16:09:01 -0700 Subject: [PATCH 06/16] Update nurbs.scad --- nurbs.scad | 4943 ++++++++++++++++++++++++++-------------------------- 1 file changed, 2471 insertions(+), 2472 deletions(-) diff --git a/nurbs.scad b/nurbs.scad index 80583995..1cb98012 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -69,7 +69,7 @@ _BOSL2_NURBS = is_undef(_BOSL2_STD) && (is_undef(BOSL2_NO_STD_WARNING) || !BOSL2 // use it to evaluate the NURBS over its entire domain by giving a splinesteps value. This specifies the number of segments // to use between each knot and guarantees a point exactly at each knot. This may be important if you set the knot multiplicity // to the degree somewhere in your curve, which creates a corner at the knot, because it guarantees a sharp corner regardless -// of the number of points. If you don't give `u` or `splinesteps` then `splinesteps=16` is used as the default evaluation. +// of the number of points. If you don't give `u` or `splinesteps` then `splinesteps=16` is used as the default evaluation. // . // Instead of providing separate parameters you can give a first parameter of the form of a NURBS parameter list: `[type, degree, control, knots, mult, weights]`. // Arguments: @@ -179,7 +179,7 @@ _BOSL2_NURBS = is_undef(_BOSL2_STD) && (is_undef(BOSL2_NO_STD_WARNING) || !BOSL2 // control = [[1,0],[1,2],[-1,2],[-1,0]]; // w = [1,1/3,1/3,1]; // debug_nurbs(control, 3, weights=w, width=0.1, size=.2); -// Example(2D,NoAxes): Gluing two semi-circles together gives a whole circle. Note that this is a clamped not closed NURBS. The interface uses a knot of multiplicity 3 where the clamped ends of the semi-circles meet. +// Example(2D,NoAxes): Gluing two semi-circles together gives a whole circle. Note that this is a clamped not a closed NURBS. The interface uses a knot of multiplicity 3 where the clamped ends of the semi-circles meet. // control = [[1,0],[1,2],[-1,2],[-1,0],[-1,-2],[1,-2],[1,0]]; // w = [1,1/3,1/3,1,1/3,1/3,1]; // debug_nurbs(control, 3, splinesteps=16,weights=w,mult=[1,3,1],width=.1,size=.2); @@ -338,149 +338,6 @@ function nurbs_curve(control,degree,splinesteps,u, mult,weights,type="clamped", ) reorder ? select(nurbs_pts,reorder[1]) : nurbs_pts; -// Function: nurbs_elevate_degree() -// Synopsis: Raises the degree of a closed or open NURBS. -// Topics: NURBS Curves -// See Also: nurbs_interp(), nurbs_curve() -// -// Usage: -// result = nurbs_elevate_degree(control, degree, [knots=], [mult=], [type=], [times=], [weights=]); -// result = nurbs_elevate_degree(nurbs_param_list, [times=]); -// -// Description: -// Raises the degree of a "closed" or "open" NURBS by `times` steps, producing -// a geometrically identical curve at the higher degree. Returns a NURBS parameter list -// of the form `[type, degree, control_points, knots, undef, weights]` that can be -// passed directly to {{nurbs_curve()}} and other NURBS functions. The returned `mult` -// parameter is always undef; the returned `weights` will be defined only if you provided -// weights in your input. If you give `times=0` your input parameters are returned unchanged. -// . -// An elevated curve has the same smoothness as the original at each knot. A degree-2 -// curve that is $C^1$ at its knots will still be $C^1$ after elevation to degree 3, -// not $C^2$ as a fresh cubic NURBS with simple knots would be. -// . -// Instead of providing separate parameters you can give a first parameter of the form of a -// NURBS parameter list: `[type, degree, control, knots, mult, weights]`. -// -// Arguments: -// control = Control points, or a NURBS parameter list `[type, degree, ctrl, knots, mult, weights]` -// degree = Degree of NURBS -// --- -// knots = Knot vector. Default: uniform -// mult = List of multiplicities of the knots. Default: all 1 -// type = `"clamped"` or `"open"`. Default: `"clamped"` -// times = Number of degree-elevation steps. Default: `1` -// weights = Weight at each control point - -function nurbs_elevate_degree(control, degree, knots=undef, - type="clamped", times=1, weights=undef, - mult=undef) = - // Accept a NURBS parameter list as the first argument. - is_list(control) && in_list(control[0], ["closed","open","clamped"]) ? - assert(len(control)>=6, "Invalid NURBS parameter list") - assert(num_defined([degree,mult,weights,knots])==0, - "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list") - times == 0 ? control - : nurbs_elevate_degree(control[2], control[1], control[3], - type=control[0], times=times, - weights=control[5], mult=control[4]) - : times == 0 - ? [type, degree, control, knots, mult, weights] - // Rational NURBS: lift to homogeneous space, elevate as a plain B-spline, - // then extract weights from the last coordinate. The recursive call handles - // all asserts, knot normalization, and the times loop. - : !is_undef(weights) - ? assert(len(weights) == len(control), - "nurbs_elevate_degree: weights must have same length as control points") - let( - homo = [for (i = idx(control)) [each control[i]*weights[i],weights[i]]], - r = nurbs_elevate_degree(homo, degree, knots=knots, type=type, times=times, mult=mult), - new_w = [for (pt = r[2]) last(pt)], - new_ctrl = [for (pt = r[2]) slice(pt,0,-2)/last(pt) ] - ) - [r[0], r[1], new_ctrl, r[3], undef, new_w] - // Non-rational B-spline path. - : assert(type == "clamped" || type == "open", - str("nurbs_elevate_degree: type must be \"clamped\" or \"open\", got \"", type, "\"")) - assert(is_num(times) && times >= 1, - "nurbs_elevate_degree: times must be a positive integer") - assert(is_num(degree) && degree >= 1, - "nurbs_elevate_degree: degree must be >= 1") - assert(is_list(control) && len(control) >= 2, - "nurbs_elevate_degree: need at least 2 control points") - assert(is_undef(knots) || is_undef(mult) || len(mult) == len(knots), - str("nurbs_elevate_degree: mult and knots must have the same length; got len(mult)=", - is_undef(mult) ? "undef" : len(mult), - " len(knots)=", - is_undef(knots) ? "undef" : len(knots))) - let( - // Normalize (knots, mult) → internal format for _elevate_once. - // - // clamped: xknots = [k0, interior..., km] — one copy each including endpoints. - // open: xknots = full expanded knot vector (all multiplicities present). - // - // Neither knots nor mult → BOSL2-compatible uniform knots. - // clamped → interior format [0, uniform interior..., 1] - // open → full expanded vector (length n+p+2, uniform) - // - // knots only (no mult): pass through unchanged. - // - // mult only (no knots): uniform positions 0..1 with given multiplicities. - // clamped: endpoint mult forced to degree+1; expand then strip. - // open: full expanded vector. - // - // knots + mult: explicit distinct positions with per-knot multiplicities. - // clamped: endpoint mult forced to degree+1; expand then strip. - // open: full expanded vector. - xknots = - is_undef(knots) && is_undef(mult) - ? ( type == "clamped" ? lerpn(0, 1, len(control) - degree + 1) - : lerpn(0, 1, len(control) + degree + 1) ) - : is_undef(mult) ? knots - : is_undef(knots) - ? let( - m = len(mult), - adj = type == "clamped" && m >= 2 - ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] - : mult, - pos = [for (i = [0:1:m-1]) m == 1 ? 0 : i / (m - 1)], - exp = [for (i = [0:1:m-1]) each repeat(pos[i], adj[i])] - ) - type == "clamped" - ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] - : exp - : let( - m = len(mult), - adj = type == "clamped" && m >= 2 - ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] - : mult, - exp = [for (i = [0:1:m-1]) each repeat(knots[i], adj[i])] - ) - type == "clamped" - ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] - : exp - ) - assert(type != "clamped" || len(xknots) >= 2, - "nurbs_elevate_degree: clamped knots must have at least 2 entries [first,...,last]") - assert(type != "open" || len(xknots) == len(control) + degree + 1, - str("nurbs_elevate_degree: open knots must have length len(control)+degree+1 = ", - len(control) + degree + 1, ", got ", len(xknots))) - let( - // _elevate_once works on the full expanded knot vector. - // Clamped xknots = [k0, interior..., km]; expand to full by adding p copies - // of each endpoint. Open xknots is already full. After elevation, strip the - // p+1 endpoint copies back off for clamped so the output stays in xknots format. - U_full = type == "clamped" - ? concat(repeat(xknots[0], degree), xknots, repeat(last(xknots), degree)) - : xknots, - r = _elevate_once(control, degree, U_full), - new_knots = type == "clamped" - ? slice(r[1], degree+1, -degree-2) - : r[1] - ) - times == 1 - ? [type, r[2], r[0], new_knots, undef, undef] - : nurbs_elevate_degree(r[0], r[2], new_knots, type=type, times=times-1); @@ -544,16 +401,18 @@ function _calc_mult(knots) = // show_weights = if true then display any non-unity weights. Default: true if weights vector is supplied, false otherwise // show_knots = If true then show the knots on the spline curve. Default: false // show_control = If true then show the control points and its polygon. Default: true -// Example(2D,Med,NoAxes): The default display includes the control point polygon with its vertices numbered, and the NURBS curve -// pts = [[5,0],[0,20],[33,43],[37,88],[60,62],[44,22],[77,44],[79,22],[44,3],[22,7]]; -// debug_nurbs(pts,4,type="closed"); +// +// nurbs_curve(nurbs_interp(data, 3, start_deriv=[0,1]), splinesteps=32), +// // Example(2D,Med,NoAxes): If you want to see the knots set `show_knots=true`: // pts = [[5,0],[0,20],[33,43],[37,88],[60,62],[44,22],[77,44],[79,22],[44,3],[22,7]]; // debug_nurbs(pts,4,type="clamped",show_knots=true); +// // Example(2D,Med,NoAxes): Non-unity weights are displayed if you give a weight vector // pts = [[5,0],[0,20],[33,43],[37,88],[60,62],[44,22],[77,44],[79,22],[44,3],[22,7]]; // weights = [1,1,1,7,1,1,7,1,1,1]; // debug_nurbs(pts,4,type="closed",weights=weights); +// module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,type="clamped",knots, show_weights, show_knots=false, show_index=true, show_control=true) { @@ -604,2342 +463,2610 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ } -// Section: NURBS Surfaces - - -// Function: is_nurbs_patch() -// Synopsis: Returns true if the given item looks like a NURBS patch. -// Topics: NURBS Patches, Type Checking -// Usage: -// bool = is_nurbs_patch(x); -// Description: -// Returns true if the given item looks like a NURBS patch. (a 2D array of 3D points.) -// Arguments: -// x = The value to check the type of. -function is_nurbs_patch(x) = - is_list(x) && is_list(x[0]) && is_vector(x[0][0]) && len(x[0]) == len(x[len(x)-1]); - - -// Function: nurbs_patch_points() -// Synopsis: Computes specified point(s) on a NURBS surface patch -// Topics: NURBS Patches -// See Also: nurbs_vnf(), nurbs_curve() +// Function: nurbs_interp() +// Synopsis: Finds a NURBS curve passing through a point list with optional derivative constraints. +// Topics: NURBS Curves, Interpolation +// See Also: nurbs_curve(), debug_nurbs(), debug_nurbs_interp() +// // Usage: -// pointgrid = nurbs_patch_points(patch, degree, [splinesteps], [u=], [v=], [weights=], [type=], [mult=], [knots=]); +// nurbs_param = nurbs_interp(points, degree, [method=], [closed=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [deriv=], [extra_pts=], [smooth=]); +// // Description: -// Sample a NURBS patch on a point set. If you give splinesteps then it will sampled uniformly in the spline -// parameter between the knots, ensuring that a sample appears at every knot. If you instead give u and v then -// the values at those points in parameter space will be returned. The various NURBS parameters can all be -// single values, if the NURBS has the same parameters in both directions, or pairs listing the value for the -// two directions. If you want uniform knots in one direction and specified knots in the other you can -// give `undef` as the knot vector, e.g., `[undef,vknots]` to have uniform knots in the first dimension and -// specified knots in the second one. You can do the same thing with the `mult` parameter. -// Arguments: -// patch = rectangular list of control points in any dimension, or a NURBS parameter list -// degree = a scalar or 2-vector giving the degree of the NURBS in the two directions -// splinesteps = a scalar or 2-vector giving the number of segments between each knot in the two directions -// --- -// u = evaluation points in the u direction of the patch -// v = evaluation points in the v direction of the patch -// mult = a single list or pair of lists giving the knot multiplicity in the two directions. Default: all 1 -// knots = a single list or pair of lists giving the knot vector in each of the two directions. Default: uniform -// weights = a matrix whose size corresponds to `patch` giving the weight at each control point in the patch. Default: all 1 -// type = a single string or pair of strings giving the NURBS type, where each entry is one of "clamped", "open" or "closed". Default: "clamped" -// Example(3D,NoScale): Computing points on a patch using ranges -// patch = [ -// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], -// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], -// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], -// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], -// ]; -// pts = nurbs_patch_points(patch, 3, u=[0:.1:1], v=[0:.3:1]); -// move_copies(flatten(pts)) sphere(r=2,$fn=16); -// Example(3D,NoScale): Computing points using splinesteps -// patch = [ -// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], -// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], -// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], -// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], -// ]; -// pts = nurbs_patch_points(patch, 3, splinesteps=5); -// move_copies(flatten(pts)) sphere(r=2,$fn=16); - -function nurbs_patch_points(patch, degree, splinesteps, u, v, weights, type=["clamped","clamped"], mult=[undef,undef], knots=[undef,undef]) = - is_list(patch) && _valid_surface_type(patch[0]) ? - assert(len(patch)>=6, "NURBS parameter list is invalid") - assert(num_defined([degree,weights])==0 && mult==[undef,undef] && knots==[undef,undef], - "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list") - nurbs_patch_points(patch[2], patch[1], splinesteps, u, v, patch[5], patch[0], knots=patch[3],mult=patch[4]) - : assert(is_undef(splinesteps) || !any_defined([u,v]), "Cannot combine splinesteps with u and v") - is_def(weights) ? - assert(is_matrix(weights,len(patch),len(patch[0])), "The weights parameter must be a matrix that matches the size of the patch array") - let( - patch = [for(i=idx(patch)) [for (j=idx(patch[0])) [each patch[i][j]*weights[i][j], weights[i][j]]]], - pts = nurbs_patch_points(patch=patch, degree=degree, splinesteps=splinesteps, u=u, v=v, type=type, mult=mult, knots=knots) - ) - [for(row=pts) [for (pt=row) select(pt,0,-2)/last(pt)]] - : - assert(is_undef(u) || is_range(u) || is_vector(u) || is_finite(u), "Input u is invalid") - assert(is_undef(v) || is_range(v) || is_vector(v) || is_finite(v), "Input v is invalid") - assert(num_defined([u,v])!=1, "Must define both u and v (when using)") - let( - u=is_range(u) ? list(u) : u, - v=is_range(v) ? list(v) : v, - degree = force_list(degree,2), - type = force_list(type,2), - splinesteps = is_undef(splinesteps) ? [undef,undef] : force_list(splinesteps,2), - mult = is_vector(mult) || is_undef(mult) ? [mult,mult] - : assert((is_undef(mult[0]) || is_vector(mult[0])) && (is_undef(mult[1]) || is_vector(mult[1])), "mult must be a vector or list of two vectors") - mult, - knots = is_vector(knots) || is_undef(knots) ? [knots,knots] - : assert((is_undef(knots[0]) || is_vector(knots[0])) && (is_undef(knots[1]) || is_vector(knots[1])), "knots must be a vector or list of two vectors") - knots - ) - is_num(u) && is_num(v)? nurbs_curve([for (control=patch) nurbs_curve(control, degree[1], u=v, type=type[1], mult=mult[1], knots=knots[1])], - degree[0], u=u, type=type[0], mult=mult[0], knots=knots[0]) - : is_num(u) ? nurbs_patch_points(patch, degree, u=[u], v=v, knots=knots, mult=mult, type=type)[0] - : is_num(v) ? column(nurbs_patch_points(patch, degree, u=u, v=[v], knots=knots, mult=mult, type=type),0) - : - let( - vsplines = [for (i = idx(patch[0])) nurbs_curve(column(patch,i), degree[0], splinesteps=splinesteps[0],u=u, type=type[0],mult=mult[0],knots=knots[0])] - ) - [for (i = idx(vsplines[0])) nurbs_curve(column(vsplines,i), degree[1], splinesteps=splinesteps[1], u=v, mult=mult[1], knots=knots[1], type=type[1])]; - - -// Function&Module: nurbs_vnf() -// Synopsis: Generates a (possibly non-manifold) VNF for a single NURBS surface patch. -// SynTags: VNF -// Topics: NURBS Patches -// See Also: nurbs_patch_points() -// Usage: (as a function) -// vnf = nurbs_vnf(patch, degree, [splinesteps], [mult=], [knots=], [weights=], [type=], [style=], [reverse=], [triangulate=], [caps=], [caps1=], [caps2=]); -// Usage: (as a module) -// nurbs_vnf(patch, degree, [splinesteps], [mult=], [knots=], [weights=], [type=], [style=], [reverse=], [triangulate=], [caps=], [caps1=], [caps2=], [convexity=],[atype=],[cp=], [cp=], [atype=], ...) CHILDREN; -// Description: -// Compute a (possibly non-manifold) VNF for a NURBS. The input patch must be an array of control points or a NURBS parameter list. If weights is given it -// must be an array of weights that matches the size of the control points. The style parameter -// gives the {{vnf_vertex_array()}} style to use. The other parameters may specify the NURBS parameters in the two directions -// by giving a single value, which applies to both directions, or a list of two values to specify different values in each direction. -// You can specify undef for for a direction to keep the default, such as `mult=[undef,v_multiplicity]`. +// Given a list of data points and a NURBS degree, computes a curve of the specified degree +// that passes exactly through every data point. The computed curve always has +// uniform weights, but irregularly spaced knots, so it is actually a non-uniform B-spline. +// Data points may 2D or any higher dimension. Returns a NURBS parameter list of the form +// `[type, degree, control_points, knots, undef, undef, u]` that can be +// passed directly to {{nurbs_curve()}} and other NURBS functions. The extra return value `u`, +// described in detail below, enables you to locate your input points in the computed spline // . -// Instead of providing separate parameters you can give a first parameter as a NURBS parameter list: `[type, degree, control, knots, mult, weights]`. +// When `closed=false` (the default) the output is a "clamped" NURBS. +// When `closed=true`, the interpolation treats the data points as a loop and produces a +// curve that is smooth at the closing point. The output will be a "closed" NURBS (unless you +// specify corners as described below). +// If you instead duplicate the closing point and set `closed=false` then the +// result will have a corner at the closing point. +// . +// **Parameterization** (`method=`) +// . +// In order to solve the interpolation problem, the algorithm first chooses +// the NURBS parameter value `u[k]` that will correspond to each `points[k]`. +// This parametrization step significantly affects the shape of the output curve, particularly when the +// data points are not evenly spaced. The following methods are supported: +// . +// - `"length"` — Base parameters values on the chord length, which is distance between the consecutive data points. +// Best when data points are fairly evenly spaced. +// - `"centripetal"` (default) — Base parameters values on the square root of the chord length. (Lee 1989). +// - `"dynamic"` — like centripetal, but the exponent 0.5 is replaced +// by a per-chord value chosen based on local spacing variation. Long chords +// get a smaller exponent and short chords a larger one, compressing the +// influence of outliers. Chord lengths are normalized, which makes the method scale +// invariant and prevents misbehavior at extreme scales. Scaling is not given in the original reference. (Balta et al. 2020). +// - `"foley"` — centripetal base, augmented by corrections at each point that +// are proportional to the local turn angle. Sharp bends pull parameter values +// closer together, which tends to reduce overshoot at corners (Foley & Neilson 1987). +// - `"fang"` — centripetal base, augmented by a correction based on the radius +// of the osculating circle at each point. Said to handles mixed straight-and-curved +// segments particularly well. This method is NOT scale invariant, so results will +// change if you scale your input data. (Fang & Hung 2013). +// . +// The other required input to the interpolation is the location of the knots. +// We place knots using a moving average of `degree` consecutive parameter values, which links +// the knots to the local parameter spacing. A consequence of this process for selection +// of the parameters and knot locations is that even if your input data has symmetry it is +// likely that the symmetry will be broken in the output. For closed curves, another +// consequence is that the resulting curve will depend on which point is chosen as the +// starting point for the interpolation. The algorithm chooses a starting point +// that is expected to provide the best behaved interpolation curve. Examining the +// knot positions with {{debug_nurbs_interp()}} may help you understand unexpected behavior +// you observe in the output. If your curve does not +// behave as desired you may be able to adjust it by imposing additional constraints or +// by giving it more freedom using `extra_pts`. +// . +// **Derivative constraints** (`deriv=`, `start_deriv=`, `end_deriv=`) +// . +// `deriv[k]` specifies the tangent direction and speed the curve must have +// as it passes through `points[k]`. The length of `deriv[k]` gives the speed +// as a multiple of `path_length(points)` which means a unit vector gives a natural +// speed that is a good starting point. +// The speed has a big effect on the shape of the curve, so if the local shape is +// not as you desire you should try increasing it, which will make the curve around +// the point flatter or decreasing it, which will make the curve more pointy. +// Set `deriv[k] = undef` to leave point `k` unconstrained. +// If you only want to set the derivative at the ends of a "clamped" curve you can use +// `start_deriv=` and `end_deriv=`, which set +// `deriv[0]` and `last(deriv)` without the need to provide a list of undefs for all the interior points. +// . +// **Curvature constraints** (`curvature=`, `start_curvature=`, `end_curvature=`) +// . +// The curvature at a point measures how tightly a curve bends. +// When a point has curvature $\kappa$ then a circle with radius $1/\kappa$ +// locally matches the curve at that point so both its first and second derivatives agree. +// This matched circle is called the osculating circle. When you set `curvature[k]` this +// constrains the curvature at `points[k]`. Every curvature-constrained point **must** also have a derivative constraint +// at the same index. Curvature constraints require a degree of at least 2. +// . +// In general curvature constraints require the curvature **vector**, which +// points in the direction of the osculating circle and has length equal to the curvature. +// The curvature vector must be orthogonal to the tangent vector at the point; +// when you specify a curvature vector any component parallel to the tangent is removed. +// The magnitude of the curvature is taken as the magnitude of your original input vector, +// even if subtracting the tangent component changes its length. +// For 2D curves you can also provide curvature as a scalar, with the sign indicating direction. +// (positive = left/CCW, negative = right/CW). +// . +// You can specify the curvature at the ends of "clamped" curves using +// `start_curvature=` and `end_curvature=`, which specify `curvature[0]` +// and `last(curvature)` without the need to create undefs for all the interior points. +// . +// **Corners** (`corners=`) +// . +// `corners=` is a list of interior point indices where the curve has +// a corner, a discontinuity in the derivative. You can also specify a corner +// at point `k` by setting `deriv[k]=NAN`. When you request corners, the +// algorithm chops up the input data into separate clamped splines that run from corner +// to corner. When `closed=true` this results in a "clamped" output spline, and the curve +// will start at one of your corner points. +// If you place corners close together, the effective degree of the short segment +// in between the corners may be reduced. These curve sections are assembled into a single +// NURBS so this process is transparent to the user. A limitation is that you cannot control +// the dervatives of the two segments that meet at a corner. If you need to do this you +// must construct your own sequence of clamped interpolations. +// . +// **Extra control points** (`extra_pts=`, `smooth=`) +// . +// By default, the solver uses exactly as many control points as are needed to +// satisfy the interpolation and constraint conditions, which gives a unique +// solution. This unique solution may be badly behaved, with undesirable oscillations. +// You can improve the behavior by requesting extra points. +// Specifying `extra_pts=N` inserts `N` additional control points and knots, making the +// system underdetermined: infinitely many curves pass through the data points and satisfy +// the constraints. The solver picks the one that satisfies +// a smoothness criterion specified by `smooth=`: +// . +// - `smooth=1` — minimises the sum of squared differences between consecutive +// control points. This tends to keep the control polygon short and reduces +// large-scale variation in the curve. +// - `smooth=2` — minimises the sum of squared second differences of the control +// points. This penalises bending in the control polygon, generally producing +// a fairer, less wiggly curve than `smooth=1`. +// - `smooth=3` (default) — minimises the integrated squared second derivative +// $\int \|\mathbf{C}''(t)\|^2 \, dt$, often called the *bending energy* of +// the curve. Unlike `smooth=2`, which only looks at the control polygon, +// this criterion acts directly on the curve shape and is the most +// mathematically principled choice for smooth interpolation. Requires +// `degree >= 2`. +// . +// The number of extra control points cannot exceed the number of knot spans. +// If you request too many, the number is capped and a warning is displayed. +// With `corners=`, the curve is split into independent clamped segments and +// the extra points are distributed across eligible segments proportionally +// to their control-point count, rounding up, so the total may +// exceed the requested number but will never be less. A segment is eligible when +// its effective degree is 3 or higher, or when it is degree 2 with `smooth=1`. +// . +// **Locating points in the spline** — In order to locate your original data +// points in the spline you need the `u` parameter value that you +// can pass to {{nurbs_curve()}}. The last return value `u` is a list +// where `u[k]` is the NURBS parameter at which the curve passes through +// `points[k]`. +// . +// **Smoothness** — The smoothness of B-splines is determined by the +// degree. If you request a degree $p$ spline then it will be $C^{p-1}$ at +// knot points and $C^\infty$ everywhere else. If you request corners then +// these are points where the curve is not differentiable; corners may +// also divide the curve into small segments that lack sufficient points +// to support an interpolation at your requested degree: a degree $p$ interpolation +// requires $p+1$ points. In this case, the intepolation is performed at a lower +// degree and elevated, which means it will be less smooth at knots. +// // Arguments: -// patch = rectangular list of control points in any dimension, or a NURBS parameter list -// degree = a scalar or 2-vector giving the degree of the NURBS in the two directions -// splinesteps = a scalar or 2-vector giving the number of segments between each knot in the two directions. Default: 16 +// points = List of data points to interpolate (2D or any higher dimension). +// degree = Degree of the NURBS. Degree 3 (cubic) is the most common choice. // --- -// mult = a single list or pair of lists giving the knot multiplicity in the two directions. Default: all 1 -// knots = a single list of pair of lists giving the knot vector in each of the two directions. Default: uniform -// weights = a single list or pair of lists giving the weight at each control point in the. Default: all 1 -// type = a single string or pair of strings giving the NURBS type, where each entry is one of "clamped", "open" or "closed". Default: "clamped" -// caps = If true, add endcap faces to both ends. The type must be ["clamped","closed"] or ["closed","clamped"] to enable caps. -// cap1 = If true, add an endcap face to the first end. -// cap2 = If true, add an endcap face to the second end. -// reverse = If true, reverse all face normals. -// style = {{vnf_vertex_array ()}} style to use for triangulating the surface. Default: "default" -// triangulate = If true, triangulates endcaps to resolve possible CGAL issues. This can be an expensive operation if the endcaps are complex. Default: false -// cp = (module) Centerpoint for determining intersection anchors or centering the shape. Determines the base of the anchor vector. Can be "centroid", "mean", "box" or a 3D point. Default: "centroid" -// anchor = (module) Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `"origin"` -// spin = (module) Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0` -// orient = (module) Vector to rotate top toward, after spin. See [orient](attachments.scad#subsection-orient). Default: `UP` -// atype = (module) Select "hull" or "intersect" anchor type. Default: "hull" -// Example(3D): Quadratic B-spline surface -// patch = [ -// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], -// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], -// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], -// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], -// ]; -// vnf = nurbs_vnf(patch, 2); -// vnf_polyhedron(vnf); -// Example(3D): Cubic B-spline surface -// patch = [ -// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], -// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], -// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], -// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], -// ]; -// vnf = nurbs_vnf(patch, 3); -// vnf_polyhedron(vnf); -// Example(3D): Cubic B-spline surface, closed in one direction -// patch = [ -// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], -// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], -// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], -// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], -// ]; -// vnf = nurbs_vnf(patch, 3, type=["closed","clamped"]); -// vnf_polyhedron(vnf); -// Example(3D): B-spline surface cubic in one direction, quadratic in the other -// patch = [ -// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], -// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], -// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], -// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], -// ]; -// vnf = nurbs_vnf(patch, [3,2],type=["closed","clamped"]); -// vnf_polyhedron(vnf); -// Example(3D): The sphere can be represented using NURBS -// patch = [ -// [[0,0,1], [0,0,1], [0,0,1], [0,0,1], [0,0,1], [0,0,1], [0,0,1]], -// [[2,0,1], [2,4,1], [-2,4,1], [-2,0,1], [-2,-4,1], [2,-4,1], [2,0,1]], -// [[2,0,-1],[2,4,-1],[-2,4,-1],[-2,0,-1],[-2,-4,-1], [2,-4,-1],[2,0,-1]], -// [[0,0,-1],[0,0,-1],[0,0,-1], [0,0,-1], [0,0,-1], [0,0,-1], [0,0,-1]] -// ]; -// weights = [ -// [9,3,3,9,3,3,9], -// [3,1,1,3,1,1,3], -// [3,1,1,3,1,1,3], -// [9,3,3,9,3,3,9], -// ]/9; -// vknots = [0, 1/2, 1/2, 1/2, 1]; -// vnf = nurbs_vnf(patch, 3,weights=weights, knots=[undef,vknots]); -// vnf_polyhedron(vnf); -function nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, knots, style="default", reverse=false, triangulate=false, caps,cap1,cap2) = - is_list(patch) && _valid_surface_type(patch[0]) ? - assert(len(patch)>=6, "NURBS parameter list is invalid") - assert(num_defined([degree,mult,weights,knots]==0), - "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list") - nurbs_vnf(patch[2], patch[1], splinesteps, patch[5], patch[0], knots=patch[3], mult=patch[4], style=style,caps=caps,cap1=cap1,cap2=cap2, - reverse=reverse, triangulate=triangulate) - : assert(is_nurbs_patch(patch),"Input patch is not a rectangular aray of points") - assert(_valid_surface_type(type), "type must be one of or a list of two of: \"closed\", \"clamped\" and \"open\"") - let(havecaps = num_true([caps,cap1,cap2])>0) - assert(!havecaps || type==["clamped","closed"] || type==["closed","clamped"], - "Surface must be [\"closed\",\"clamped\"] or [\"clamped\",\"closed\"] to for caps to be created") - let( - type = force_list(type,2), - havecaps = num_true([caps,cap1,cap2])>0, - flip = havecaps && type[0]=="closed", - pts = nurbs_patch_points(patch=patch, degree=degree, splinesteps=splinesteps, type=type, mult=mult, knots=knots, weights=weights), - tpts = flip ? (transpose(pts)) : pts - ) - vnf_vertex_array(tpts, style=style, row_wrap=type[flip?1:0]=="closed", col_wrap=type[flip?0:1]=="closed",reverse=reverse,triangulate=triangulate, - caps=caps,cap1=cap1,cap2=cap2); - +// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` +// closed = If true treat point list as a loop . Default: `false` +// start_deriv = If `closed=false`, gives the tangent vector at the first point +// end_deriv = If `closed=false`, gives tangent vector at the last point. +// deriv = List of tangent vector constraints for every point, NAN at corners or undef at unconstrained points. Cannot be combined with `start_deriv=`/`end_deriv=`. +// start_curvature = If `closed=false` gives curvature at first point. (Requires matching derivative.) +// end_curvature = If `closed=false` gives curvature at last point. (Requires matching derivative.) +// curvature = List of curvature constraints for every point, or undef at unconstrained points. Each curvature constraint must be paired with a derivative constraint at the same point. Cannot be combined with `start_curvature=`/`end_curvature=`. +// corners = List of interior point indices where corners are permitted. Equivalent to setting entries of `deriv` to NAN. +// extra_pts = Number of extra control points to add to provide additional freedom to control undesirable oscillations. Default: 0 +// smooth = Smoothness criterion used with extra control points. Set to 1 (minimize control-polygon length), 2 (minimize control-polygon bending) or 3 (minimize curve bending energy). Default: 3 +// +// Example(2D): Clamped curve (default) +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// path = nurbs_curve(nurbs_interp(data, 3)); +// stroke(path); +// +// Example(2D): Closed curve +// // Do NOT repeat the first point at the end. +// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; +// path = nurbs_curve(nurbs_interp(data, 3, closed = true)); +// stroke(path, closed = true); +// +// Example(2D): Closed polygon +// // All data points lie exactly on the polygon boundary. +// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; +// path = nurbs_curve(nurbs_interp(data, 3, closed=true), splinesteps=16); +// polygon(path); +// color("red") move_copies(data) circle(r=0.25, $fn=16); +// +// Example(2D): Get just the path +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// path = nurbs_curve(nurbs_interp(data, 3), splinesteps=32); +// stroke(path, width=0.5); +// color("red") move_copies(data) circle(r=0.25, $fn=16); +// +// Example(2D): Low-level NURBS parameter list +// // nurbs_interp() returns a BOSL2 NURBS parameter list compatible +// // with nurbs_curve(), debug_nurbs(), etc. +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// result = nurbs_interp(data, 3); +// curve = nurbs_curve(result, splinesteps=24); +// stroke(curve, width=0.5); +// +// Example(3D): 3D closed curve +// data3d = [[20,0,0],[0,20,10],[-20,0,20],[0,-20,10]]; +// path = nurbs_curve(nurbs_interp(data3d, 3, closed=true), splinesteps=32); +// stroke(path, width=1, closed=true); +// color("red") move_copies(data3d) sphere(r=0.25, $fn=16); +// +// Example(2D,Big): Parameterization methods for sharp turns +// // "length" (blue), "centripetal" (red), "dynamic" (orange) compared. +// // For data with sudden direction changes or uneven chord spacing, +// // "centripetal" and "dynamic" reduce unwanted oscillations. +// sharp = [[0,0], [5,40],[6,40], [10,0], [50,0], [55,40],[56,42], [60,0]]; +// color("blue") stroke(nurbs_curve(nurbs_interp(sharp, 3, method = "centripetal"), splinesteps=32), width=0.1); +// color("red") stroke(nurbs_curve(nurbs_interp(sharp, 3, method="foley"), splinesteps=32), width=0.1); +// color("orange") stroke(nurbs_curve(nurbs_interp(sharp, 3, method="dynamic"), splinesteps=32), width=0.1); +// color("green") move_copies(sharp) circle(r=.1, $fn=16); +// +// Example(2D): Endpoint tangent control +// // Specify start and/or end tangent vectors. Each vector is automatically +// // scaled by the total chord length; a unit vector produces natural +// // arc-length speed. Magnitude > 1 increases pull, < 1 weakens it. +// data = [[0,0], [20,30], [50,25], [80,0]]; +// // No tangent control (natural): +// color("gray") stroke(nurbs_curve(nurbs_interp(data, 3), splinesteps=32), width=0.3); +// // Start going straight up, end going straight down: +// color("blue") stroke( +// nurbs_curve(nurbs_interp(data, 3, start_deriv=[1,0], end_deriv=[1,0]), splinesteps=32), +// width=0.3); +// // Start going right, end going right: +// color("red") stroke( +// nurbs_curve(nurbs_interp(data, 3, start_deriv=[1,0], end_deriv=[1,0]), splinesteps=32), +// width=0.3); +// color("black") move_copies(data) circle(r=0.25, $fn=16); +// +// Example(2D): Start tangent only +// data = [[0,0], [20,30], [50,25], [80,0]]; +// color("gray") stroke(nurbs_curve(nurbs_interp(data, 3), splinesteps=32), width=0.3); +// color("blue") stroke( +// nurbs_curve(nurbs_interp(data, 3, start_deriv=[0,1]), splinesteps=32), +// width=0.3); +// color("black") move_copies(data) circle(r=0.25, $fn=16); +// +// -function _valid_surface_type(type) = - in_list(type,["closed","clamped","open"]) ? true - : !is_list(type) || len(type)!=2 ? false - : _valid_surface_type(type[0]) && _valid_surface_type(type[1]); - +function nurbs_interp(points, degree, method="centripetal", closed=false, + deriv=undef, start_deriv=undef, end_deriv=undef, + curvature=undef, start_curvature=undef, end_curvature=undef, + corners=undef, extra_pts=0, smooth=3) = + assert(is_path(points, undef) && len(points) >= 2, + "nurbs_interp: points must be a path (list of same-dimension vectors) with at least 2 points") + assert(is_num(degree) && degree >= 1, + "nurbs_interp: degree must be >= 1") + assert(method == "length" || method == "centripetal" || method == "dynamic" + || method == "foley" || method == "fang", + str("nurbs_interp: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) + assert(is_undef(deriv) || (is_undef(start_deriv) && is_undef(end_deriv)), + "nurbs_interp: use deriv= OR start_deriv=/end_deriv=, not both") + assert(!closed || (is_undef(start_deriv) && is_undef(end_deriv)), + "nurbs_interp: start_deriv/end_deriv only supported for closed=false") + assert(is_undef(deriv) || len(deriv) == len(points), + str("nurbs_interp: deriv= must have same length as points (", + len(points), " points, ", is_undef(deriv) ? 0 : len(deriv), " deriv)")) + assert(is_undef(curvature) || (is_undef(start_curvature) && is_undef(end_curvature)), + "nurbs_interp: use curvature= OR start_curvature=/end_curvature=, not both") + assert(!closed || (is_undef(start_curvature) && is_undef(end_curvature)), + "nurbs_interp: start_curvature=/end_curvature= only supported for closed=false") + assert(is_undef(curvature) || len(curvature) == len(points), + str("nurbs_interp: curvature= must have same length as points (", + len(points), " points, ", is_undef(curvature) ? 0 : len(curvature), " curvature)")) + assert(is_undef(corners) || ( + !closed + ? (min(corners) >= 1 && max(corners) <= len(points)-2) + : (min(corners) >= 0 && max(corners) <= len(points)-1)), + str("nurbs_interp: corners= indices must be ", + !closed ? str("interior (1..", len(points)-2, ")") + : str("valid point indices (0..", len(points)-1, ")"))) + assert(is_num(extra_pts) && extra_pts >= 0 && extra_pts == floor(extra_pts), + str("nurbs_interp: extra_pts must be a non-negative integer, got ", extra_pts)) + assert(extra_pts == 0 || degree >= 2, + "nurbs_interp: extra_pts requires degree >= 2") + assert(smooth == 1 || smooth == 2 || smooth == 3, + str("nurbs_interp: smooth must be 1, 2, or 3, got ", smooth)) + assert(smooth != 3 || degree >= 2, + "nurbs_interp: smooth=3 (bending energy) requires degree >= 2") + let( + type = closed ? "closed" : "clamped", + raw = type == "clamped" + ? _nurbs_interp_clamped(points, degree, method, + deriv, start_deriv, end_deriv, + curvature, start_curvature, end_curvature, + corners, extra_pts, smooth) + : _nurbs_interp_closed(points, degree, method, deriv, curvature, + corners, extra_pts, smooth), + eff_type = is_string(raw[3]) ? raw[3] : type, + rot = raw[2], + n = len(points), + u = type == "closed" && !is_string(raw[3]) + ? list_rotate( + _interp_params(list_rotate(points, rot), method, closed=true), + -rot) + : type == "closed" + ? let( + aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], points[rot]], + aug_params = _interp_params(aug_pts, method) + ) + [for (j = [0:1:n-1]) aug_params[(j - rot + n) % n]] + : _interp_params(points, method) + ) + [eff_type, degree, raw[0], raw[1], undef, undef, u]; -module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, knots, style="default", reverse=false, triangulate=false, - convexity=2, cp="centroid", anchor="origin", spin=0, orient=UP, atype="hull", caps, cap1, cap2) -{ - if (is_list(patch) && _valid_surface_type(patch[0])){ - assert(len(patch)>=6, "NURBS parameter list is invalid"); - assert(num_defined([degree,mult,weights,knots]==0), - "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list"); - nurbs_vnf(patch[2], patch[1], splinesteps, patch[5], patch[0], mult=patch[4], knots=patch[3], style=style, reverse=reverse, triangulate=triangulate, - convexity=convexity, cp=cp, anchor=anchor, spin=spin, orient=orient, atype=atype, caps=caps, cap1=cap1, cap2=cap2) children(); - } - else { - type = force_list(type,2); - havecaps = num_true([caps,cap1,cap2])>0; - dummy = - assert(is_nurbs_patch(patch),"Input patch is not a rectangular aray of points") - assert(_valid_surface_type(type), "type must be one of or a list of two of: \"closed\", \"clamped\" and \"open\"") - assert(!havecaps || type==["clamped","closed"] || type==["closed","clamped"], - "Surface must be [\"closed\",\"clamped\"] or [\"clamped\",\"closed\"] to for caps to be created"); - flip = havecaps && type[0]=="closed"; - pts = nurbs_patch_points(patch=patch, degree=degree, splinesteps=splinesteps, type=type, mult=mult, knots=knots, weights=weights); - tpts = flip ? (transpose(pts)) : pts; - vnf_vertex_array(tpts, style=style, row_wrap=type[flip?1:0]=="closed", col_wrap=type[flip?0:1]=="closed", reverse=reverse, triangulate=triangulate, cp=cp, - convexity=convexity, anchor=anchor, spin=spin, orient=orient, atype=atype, caps=caps, cap1=cap1, cap2=cap2) children(); - } -} -////////////////////////////////////////////////////////////////////// +// Module: debug_nurbs_interp() +// Synopsis: Interpolates a NURBS using {{nurbs_interp()}} and displays the curve with informative overlays. +// Topics: NURBS Curves, Interpolation, Debugging +// See Also: nurbs_interp(), debug_nurbs() +// +// Usage: +// debug_nurbs_interp(points, degree, [splinesteps=], [method=], [closed=], [deriv=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [extra_pts=], [smooth=], [width=], [size=], [data_size=], [data_index=], [show_control=], [control_index=], [show_knots=], [show_deriv=], [show_curvature=]); // -// NURBS curve and surface interpolation through data points. -// Given a set of data points, computes the control points and knot -// vector of a B-spline that passes exactly through every data point. -// Supports clamped curves (open-ended), closed curves (smooth loops), -// and surfaces of both types in each parametric direction. -// Optional per-point derivative and curvature constraints are supported. +// Description: +// Calls {{nurbs_interp()}} with the supplied arguments and displays the +// resulting curve together with a informative overlays. All interpolation +// arguments are passed through unchanged; see {{nurbs_interp()}} for their +// descriptions. The overlays are: // . -// Algorithm from Piegl & Tiller, "The NURBS Book", Chapters 2 & 9. +// - **Data points** — red circles (2D) or spheres (3D) at each input point. +// When `data_index=true` (the default), the point index is printed in red next +// to its marker. Set `data_size=0` to suppress display of the data point dots. +// - **Derivative constraints** — a black arrow at each derivative constrained data point. +// Arrow direction and length reflect the constraint vector, scaled to the average +// point spacing. When the derivative is NAN or a point has a corner, this is shown +// using a black diamond. Shown by default: set `show_deriv=false` to hide. +// - **Curvature constraints** — a transparent green overlay at each curvature-constrained point. +// In 2D the overlay is the osculating circle. In 3D the overlay is a cylinder created +// from the 3D osculating circle. Zero curvature appears as a short green bar. +// Shown by default: Set `show_curvature=false` to hide. +// - **Knots** — Green crosses mark each knot position. Not shown by default. +// Enable with `show_knots=true`. +// - **Control points and polygon** — If you set `show_control=true` then a gray control polygon +// Is displayed. If you additionally set `control_index=true` then blue control-point +// index labels appear. // -////////////////////////////////////////////////////////////////////// +// Arguments: +// points = List of 2-D or 3-D data points to interpolate through. +// degree = NURBS degree. +// splinesteps = Steps per knot span for curve rendering. Default: `16` +// --- +// method = Parameterization method; see {{nurbs_interp()}}. Default: `"centripetal"` +// closed = If true, interpolate as a closed loop; if false, interpolate as clamped. Default: `false` +// deriv = Per-point derivative constraints; see {{nurbs_interp()}}. Default: `undef` +// start_deriv = Derivative at first point. Default: `undef` +// end_deriv = Derivative at last point. Default: `undef` +// curvature = Per-point curvature constraints; see {{nurbs_interp()}}. Default: `undef` +// start_curvature = Curvature at first point. Default: `undef` +// end_curvature = Curvature at last point. Default: `undef` +// corners = Corner indices; see {{nurbs_interp()}}. Default: `undef` +// extra_pts = Extra control points; see {{nurbs_interp()}}. Default: `0` +// smooth = Smoothness criterion for `extra_pts`; see {{nurbs_interp()}}. Default: `3` +// width = Stroke width for the curve. Arrows and other overlays scale with this. Default: `1` +// size = Text size for labels on control points and data points. Default: `3*width` +// data_size = Radius of the red data-point markers. Set to `0` to hide data points and their labels. Default: equal to `width` +// data_index = Show index labels next to each data point. Only shown when `data_size > 0`. Default: `true` +// show_control = Show the control polygon. Default: `false` +// control_index = Show control-point index labels if `show_control=true`. Default: `false` +// show_knots = Show knot position markers on the curve. Default: `false` +// show_deriv = Show derivative-constraint arrows. Default: `true` +// show_curvature = Show curvature-constraint circles / disks. Default: `true` +module debug_nurbs_interp(points, degree, splinesteps=16, method="centripetal", + closed=false, deriv=undef, + start_deriv=undef, end_deriv=undef, + curvature=undef, start_curvature=undef, end_curvature=undef, + corners=undef, extra_pts=0, smooth=3, + width=1, size=undef, data_size=undef, + show_control=false, show_knots=false, + show_deriv=true, show_curvature=true, + control_index=false, data_index=true) { + result = nurbs_interp(points, degree, method=method, + closed=closed, deriv=deriv, + start_deriv=start_deriv, end_deriv=end_deriv, + curvature=curvature, start_curvature=start_curvature, + end_curvature=end_curvature, corners=corners, + extra_pts=extra_pts, smooth=smooth); -// Internal B-spline Basis Functions + np = len(points); + dim = len(points[0]); + is2d = (dim == 2); + ds = default(data_size, width); + sz = default(size, 3 * width); + ctrl = result[2]; + arrow_scale = path_length(points) / np; -// Cox-de Boor recursive B-spline basis function N_{i,p}(u). -// Returns 0 for out-of-range indices (safe for periodic evaluation). + // Helpers project BOSL2 direction constants and pad dimensions automatically. + eff_der = _merge_deriv_list(np-1, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv); + eff_curv = _merge_curv_list(np-1, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature); -function _nip(i, p, u, U) = - let(maxidx = len(U) - 1) - (i < 0 || i + p + 1 > maxidx) ? 0 - : p == 0 - ? (u >= U[i] && u < U[i+1]) ? 1 - : (abs(u - U[i+1]) < 1e-12 && abs(U[i+1] - U[maxidx]) < 1e-12) ? 1 - : 0 - : let( - d1 = U[i+p] - U[i], - d2 = U[i+p+1] - U[i+1], - c1 = abs(d1) > 1e-15 - ? (u - U[i]) / d1 * _nip(i, p-1, u, U) : 0, - c2 = abs(d2) > 1e-15 - ? (U[i+p+1] - u) / d2 * _nip(i+1, p-1, u, U) : 0 - ) - c1 + c2; - - -// Derivative of B-spline basis N_{j,p}'(u). -// Standard recurrence (P&T §2.3 eq. 2.9); zero-length spans are guarded. + // --- Curve, control polygon, knot markers (delegated to debug_nurbs) --- + debug_nurbs(result, splinesteps=splinesteps, width=width, size=sz, + show_knots=show_knots, show_control=show_control, + show_index=control_index); -function _dnip(j, p, u, U) = - p == 0 ? 0 - : let( - d1 = U[j+p] - U[j], - d2 = U[j+p+1] - U[j+1] - ) - (abs(d1) > 1e-15 ? p * _nip(j, p-1, u, U) / d1 : 0) - - (abs(d2) > 1e-15 ? p * _nip(j+1, p-1, u, U) / d2 : 0); + // --- Corner marks (NaN-deriv corners + explicit corners= indices) --- + // 2D: rotated square stroke. 3D: octahedron wireframe. + nan_corner_idxs = is_undef(eff_der) ? [] + : [for (i = [0:1:np-1]) if (!is_undef(eff_der[i]) && is_nan(eff_der[i])) i]; + explicit_corner_idxs = default(corners, []); + all_corner_idxs = deduplicate(sort(concat(nan_corner_idxs, explicit_corner_idxs))); + for (i = all_corner_idxs) + color("black") + translate(points[i]) + if (is2d) + zrot(45) stroke(rect(3.5*width*ds), width=width/2, closed=true); + else + vnf_wireframe(octahedron(size=5*width), width=width/4); + // --- Derivative arrows (black, half width, arrow2 endcap) --- + // Length = norm(eff_der[i]) * arrow_scale: preserves relative magnitudes; + // arrow_scale = path_length(points)/np gives a geometry-relative baseline. + if (show_deriv && !is_undef(eff_der)) + for (i = [0:1:np-1]) + if (!is_undef(eff_der[i]) && !is_nan(eff_der[i]) && norm(eff_der[i]) > 1e-12) + color("black") + stroke([points[i], points[i] + eff_der[i] * arrow_scale], + width=width/2, + endcap1="butt", endcap2="arrow2"); -// Second derivative of B-spline basis N_{j,p}''(u). -// Same recurrence as _dnip applied once more (P&T §2.3 eq. 2.9); -// zero-length spans are guarded. Returns 0 for p ≤ 1. + // --- Data points and index labels --- + if (ds > 0) + color("red") + move_copies(points) { + if (is2d) circle(r=ds, $fn=16); + else sphere(r=ds, $fn=16); + if (data_index) + if (is2d) + fwd(2*ds) text(text=str($idx), size=sz, anchor=BACK); + else + rot($vpr) back(ds + sz/3) text3d(text=str($idx), size=sz, anchor=CENTER); + } -function _d2nip(j, p, u, U) = - p <= 1 ? 0 - : let( - d1 = U[j+p] - U[j], - d2 = U[j+p+1] - U[j+1] - ) - (abs(d1) > 1e-15 ? p * _dnip(j, p-1, u, U) / d1 : 0) - - (abs(d2) > 1e-15 ? p * _dnip(j+1, p-1, u, U) / d2 : 0); + // --- Curvature overlays (rendered last so transparent objects don't occlude dots) --- + // Validator already asserted every curvature-constrained point has a derivative, + // so eff_der[i] is always defined and non-NaN here. + if (show_curvature && !is_undef(eff_curv)) + color([0,1,0,0.1]) + for (i = [0:1:np-1]) + if (!is_undef(eff_curv[i])) { + // cv is either a signed scalar (2D) or a dim-projected vector. + cv = eff_curv[i]; + kn = is_num(cv) ? abs(cv) : norm(cv); + T_hat = unit(eff_der[i]); + if (kn < 1e-12) { + // Zero curvature: fixed-length segment (0.6*arrow_scale) along + // the exact derivative direction. + half = 0.3 * arrow_scale; + stroke([points[i] - T_hat * half, + points[i] + T_hat * half], + width=2*width, endcaps="butt"); + } else { + // Non-zero curvature: osculating circle (2D) or cylinder (3D). + // N_hat: unit principal normal — component of cv perpendicular to T_hat. + N_hat = is_num(cv) + ? // Signed scalar (2D): rotate T_hat 90° left or right by sign(cv). + sign(cv) * [-T_hat[1], T_hat[0]] + : // Vector: strip tangential component via vector_perp, then unit. + unit(vector_perp(T_hat, cv)); + r = 1 / kn; + ctr = points[i] + N_hat * r; + // move(ctr) applies to both 2D and 3D branches. + move(ctr) + if (is2d) { + circle(r=r); + } else { + // Cylinder in the osculating plane: axis along binormal B̂ = T̂ × N̂. + // cyl(orient=binom) aligns the cylinder axis to B̂ without rot(). + binom = cross(T_hat, N_hat); + cyl(h=width, r=r, orient=binom); + } + } + } +} -// Input Helpers -// Validate and coerce a single derivative vector to the required dimension. -// -// dim == 2 (special case): -// Accepts a 3D BOSL2 direction constant (UP, DOWN, LEFT, RIGHT, BACK, FWD) -// by projecting it onto the data plane. The vector must lie in the XZ plane -// (Y=0, as UP/DOWN/LEFT/RIGHT/FWD/BACK are defined) or the XY plane (Z=0). -// Underlength inputs (1D) are zero-padded to 2D as in the general case. +// Function: nurbs_elevate_degree() +// Synopsis: Raises the degree of a closed or open NURBS. +// Topics: NURBS Curves +// See Also: nurbs_interp(), nurbs_curve() // -// All dimensions (dim ≥ 2): -// Any vector shorter than dim is zero-padded to length dim. -// Vectors longer than dim (not handled by the dim=2 special case) error. - -function _force_deriv_dim(deriv, dim) = - dim == 2 && is_vector(deriv, 3) ? - // Special: 3D BOSL2 constant for 2D curve — project onto data plane. - assert(deriv.y == 0 || deriv.z == 0, - "\nDerivative for a 2D interpolation cannot be fully 3D. It must have either Y or Z component equal to zero.") - deriv.y == 0 ? [deriv.x, deriv.z] : point2d(deriv) - : // General: validate length ≤ dim, then zero-pad to exactly dim. - assert(is_vector(deriv) && len(deriv) >= 1 && len(deriv) <= dim, - str("\nDerivative must be a non-empty vector of dimension ", dim, " or less.")) - list_pad(deriv, dim, 0); - - -// Convert a curvature specification to a C''(t) constraint vector. +// Usage: +// result = nurbs_elevate_degree(control, degree, [knots=], [mult=], [type=], [times=], [weights=]); +// result = nurbs_elevate_degree(nurbs_param_list, [times=]); // -// Under natural-speed parameterization (|C'(t)| = v), curvature κ and -// the second derivative relate by: C''(t) = κ_vec_normal × v². -// Tangential acceleration is set to zero (arc-length parameterization at that point). +// Description: +// Raises the degree of a "closed" or "open" NURBS by `times` steps, producing +// a geometrically identical curve at the higher degree. Returns a NURBS parameter list +// of the form `[type, degree, control_points, knots, undef, weights]` that can be +// passed directly to {{nurbs_curve()}} and other NURBS functions. The returned `mult` +// parameter is always undef; the returned `weights` will be defined only if you provided +// weights in your input. If you give `times=0` your input parameters are returned unchanged. +// . +// An elevated curve has the same smoothness as the original at each knot. A degree-2 +// curve that is $C^1$ at its knots will still be $C^1$ after elevation to degree 3, +// not $C^2$ as a fresh cubic NURBS with simple knots would be. +// . +// Instead of providing separate parameters you can give a first parameter of the form of a +// NURBS parameter list: `[type, degree, control, knots, mult, weights]`. // -// curv_spec = signed scalar κ (dim=2), or a vector (any dim including 2D). -// Scalar (dim=2): positive = CCW (left), negative = CW (right). -// Vector: magnitude = |κ|; the perpendicular projection onto -// the plane normal to tang_dir provides the direction only. -// For dim=2 curves, accepts 3D BOSL2 direction constants -// (UP, DOWN, LEFT, RIGHT, etc.) — projected to 2D same as deriv=. -// tang_dir = tangent direction at the point (need not be normalized). -// dim = spatial dimension (len(points[0])). -// v2 = |C'(t)|² at the constrained point. +// Arguments: +// control = Control points, or a NURBS parameter list `[type, degree, ctrl, knots, mult, weights]` +// degree = Degree of NURBS +// --- +// knots = Knot vector. Default: uniform +// mult = List of multiplicities of the knots. Default: all 1 +// type = `"clamped"` or `"open"`. Default: `"clamped"` +// times = Number of degree-elevation steps. Default: `1` +// weights = Weight at each control point -function _curv_to_d2(curv_spec, tang_dir, dim, v2) = - let(t_hat = unit(tang_dir)) - (dim == 2 && is_num(curv_spec)) - ? // 2D signed scalar: rotate tangent 90° CCW to get the normal direction. - let(n_hat = [-t_hat[1], t_hat[0]]) - curv_spec * n_hat * v2 - : // Vector form (any dim, including 2D): magnitude from the input vector, - // direction from the perpendicular projection. - // Accepts 3D BOSL2 direction constants (UP, DOWN, etc.) for 2D curves - // via _force_deriv_dim projection, same as derivative constraints. - assert(is_vector(curv_spec) && len(curv_spec) >= 1 && - (len(curv_spec) <= dim || (dim == 2 && len(curv_spec) == 3)), - str("nurbs_interp: curvature constraint must be a signed scalar (2D) or a vector of dimension 1–", dim, - " (3D BOSL2 constants like UP/DOWN accepted for 2D curves)")) +function nurbs_elevate_degree(control, degree, knots=undef, + type="clamped", times=1, weights=undef, + mult=undef) = + // Accept a NURBS parameter list as the first argument. + is_list(control) && in_list(control[0], ["closed","open","clamped"]) ? + assert(len(control)>=6, "Invalid NURBS parameter list") + assert(num_defined([degree,mult,weights,knots])==0, + "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list") + times == 0 ? control + : nurbs_elevate_degree(control[2], control[1], control[3], + type=control[0], times=times, + weights=control[5], mult=control[4]) + : times == 0 + ? [type, degree, control, knots, mult, weights] + // Rational NURBS: lift to homogeneous space, elevate as a plain B-spline, + // then extract weights from the last coordinate. The recursive call handles + // all asserts, knot normalization, and the times loop. + : !is_undef(weights) + ? assert(len(weights) == len(control), + "nurbs_elevate_degree: weights must have same length as control points") let( - cv = _force_deriv_dim(curv_spec, dim), - mag = norm(cv), - cv_perp = cv - (cv * t_hat) * t_hat, - n_perp = norm(cv_perp) + homo = [for (i = idx(control)) [each control[i]*weights[i],weights[i]]], + r = nurbs_elevate_degree(homo, degree, knots=knots, type=type, times=times, mult=mult), + new_w = [for (pt = r[2]) last(pt)], + new_ctrl = [for (pt = r[2]) slice(pt,0,-2)/last(pt) ] ) - assert(n_perp > 1e-12, - "nurbs_interp: curvature constraint is parallel to the derivative at the same point — curvature must have a component perpendicular to the tangent direction") - mag * (cv_perp / n_perp) * v2; - - -// Merges start_deriv=/end_deriv= into a per-point list of length n+1. -// When dim is provided each non-undef, non-NaN entry is projected via -// _force_deriv_dim(): BOSL2 3D direction constants (UP, LEFT, …) map to the -// correct 2D or 3D vector, and shorter vectors are zero-padded. -// NaN corner-marker entries (0/0) pass through unchanged. -// Returns undef when no constraint is specified. -function _merge_deriv_list(n, deriv, dim=undef, start_deriv=undef, end_deriv=undef) = + [r[0], r[1], new_ctrl, r[3], undef, new_w] + // Non-rational B-spline path. + : assert(type == "clamped" || type == "open", + str("nurbs_elevate_degree: type must be \"clamped\" or \"open\", got \"", type, "\"")) + assert(is_num(times) && times >= 1, + "nurbs_elevate_degree: times must be a positive integer") + assert(is_num(degree) && degree >= 1, + "nurbs_elevate_degree: degree must be >= 1") + assert(is_list(control) && len(control) >= 2, + "nurbs_elevate_degree: need at least 2 control points") + assert(is_undef(knots) || is_undef(mult) || len(mult) == len(knots), + str("nurbs_elevate_degree: mult and knots must have the same length; got len(mult)=", + is_undef(mult) ? "undef" : len(mult), + " len(knots)=", + is_undef(knots) ? "undef" : len(knots))) let( - raw = !is_undef(deriv) ? deriv - : (!is_undef(start_deriv) || !is_undef(end_deriv)) - ? [for (k = [0:1:n]) - k == 0 && !is_undef(start_deriv) ? start_deriv - : k == n && !is_undef(end_deriv) ? end_deriv - : undef] - : undef - ) - is_undef(dim) || is_undef(raw) ? raw - : [for (v = raw) is_undef(v) || is_nan(v) ? v : _force_deriv_dim(v, dim)]; - - -// Merges start_curvature=/end_curvature= into a per-point list of length n+1. -// When dim is provided, vector entries are projected via _force_deriv_dim() -// (handles BOSL2 3D direction constants for 2D curves). Signed-scalar entries -// (valid for dim=2) are left as-is; the sign encodes the turn direction. -// Returns undef when no constraint is specified. -function _merge_curv_list(n, curvature, dim=undef, start_curvature=undef, end_curvature=undef) = + // Normalize (knots, mult) → internal format for _elevate_once. + // + // clamped: xknots = [k0, interior..., km] — one copy each including endpoints. + // open: xknots = full expanded knot vector (all multiplicities present). + // + // Neither knots nor mult → BOSL2-compatible uniform knots. + // clamped → interior format [0, uniform interior..., 1] + // open → full expanded vector (length n+p+2, uniform) + // + // knots only (no mult): pass through unchanged. + // + // mult only (no knots): uniform positions 0..1 with given multiplicities. + // clamped: endpoint mult forced to degree+1; expand then strip. + // open: full expanded vector. + // + // knots + mult: explicit distinct positions with per-knot multiplicities. + // clamped: endpoint mult forced to degree+1; expand then strip. + // open: full expanded vector. + xknots = + is_undef(knots) && is_undef(mult) + ? ( type == "clamped" ? lerpn(0, 1, len(control) - degree + 1) + : lerpn(0, 1, len(control) + degree + 1) ) + : is_undef(mult) ? knots + : is_undef(knots) + ? let( + m = len(mult), + adj = type == "clamped" && m >= 2 + ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] + : mult, + pos = [for (i = [0:1:m-1]) m == 1 ? 0 : i / (m - 1)], + exp = [for (i = [0:1:m-1]) each repeat(pos[i], adj[i])] + ) + type == "clamped" + ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] + : exp + : let( + m = len(mult), + adj = type == "clamped" && m >= 2 + ? [degree+1, each [for (i = [1:1:m-2]) mult[i]], degree+1] + : mult, + exp = [for (i = [0:1:m-1]) each repeat(knots[i], adj[i])] + ) + type == "clamped" + ? [for (i = [degree : 1 : len(exp) - degree - 1]) exp[i]] + : exp + ) + assert(type != "clamped" || len(xknots) >= 2, + "nurbs_elevate_degree: clamped knots must have at least 2 entries [first,...,last]") + assert(type != "open" || len(xknots) == len(control) + degree + 1, + str("nurbs_elevate_degree: open knots must have length len(control)+degree+1 = ", + len(control) + degree + 1, ", got ", len(xknots))) let( - raw = !is_undef(curvature) ? curvature - : (!is_undef(start_curvature) || !is_undef(end_curvature)) - ? [for (k = [0:1:n]) - k == 0 && !is_undef(start_curvature) ? start_curvature - : k == n && !is_undef(end_curvature) ? end_curvature - : undef] - : undef + // _elevate_once works on the full expanded knot vector. + // Clamped xknots = [k0, interior..., km]; expand to full by adding p copies + // of each endpoint. Open xknots is already full. After elevation, strip the + // p+1 endpoint copies back off for clamped so the output stays in xknots format. + U_full = type == "clamped" + ? concat(repeat(xknots[0], degree), xknots, repeat(last(xknots), degree)) + : xknots, + r = _elevate_once(control, degree, U_full), + new_knots = type == "clamped" + ? slice(r[1], degree+1, -degree-2) + : r[1] ) - is_undef(dim) || is_undef(raw) ? raw - : [for (v = raw) (is_undef(v) || is_num(v)) ? v : _force_deriv_dim(v, dim)]; + times == 1 + ? [type, r[2], r[0], new_knots, undef, undef] + : nurbs_elevate_degree(r[0], r[2], new_knots, type=type, times=times-1); -// Parameterization +// Section: NURBS Surfaces -// Dynamic centripetal parameterization (Balta et al., IEEE Access 2020 §III). -// Per-chord exponent inversely proportional to ln(chord_length): -// e_i = ln(chordmax/chordi) / ln(chordmax/chordmin) * (emax-emin) + emin -// Long chords get exponent emin=0.35 (compressed contribution). -// Short chords get exponent emax=0.65 (expanded contribution). -// Falls back to e=0.5 (standard centripetal) when all chords are equal. -function _dynamic_dists(raw, emin=0.35, emax=0.65) = - let( - cmax = max(raw), - cmin = min(raw), - log_r = ln(cmax / cmin) - ) - // Divide each chord by cmin so that d/cmin ≥ 1 for every chord. - // This is required for correctness: pow(x, e) is an increasing function - // of e only when x > 1, so d > 1 ensures that the longer chords (with - // smaller exponent emin) are correctly compressed relative to shorter - // chords (with larger exponent emax). Normalizing by cmin also makes - // the result scale-invariant: λd/λcmin = d/cmin for any scale factor λ. - log_r < 1e-12 - ? [for (d = raw) sqrt(d / cmin)] // equal chords → uniform spacing - : [for (d = raw) - let(e = ln(cmax / d) / log_r * (emax - emin) + emin) - pow(d / cmin, e) - ]; +// Function: is_nurbs_patch() +// Synopsis: Returns true if the given item looks like a NURBS patch. +// Topics: NURBS Patches, Type Checking +// Usage: +// bool = is_nurbs_patch(x); +// Description: +// Returns true if the given item looks like a NURBS patch. (a 2D array of 3D points.) +// Arguments: +// x = The value to check the type of. +function is_nurbs_patch(x) = + is_list(x) && is_list(x[0]) && is_vector(x[0][0]) && len(x[0]) == len(x[len(x)-1]); -// Foley-Neilson parameterization (Foley & Neilson 1987). -// Centripetal base with deflection-angle correction at each vertex. -function _foley_dists(points, closed) = +// Function: nurbs_patch_points() +// Synopsis: Computes specified point(s) on a NURBS surface patch +// Topics: NURBS Patches +// See Also: nurbs_vnf(), nurbs_curve() +// Usage: +// pointgrid = nurbs_patch_points(patch, degree, [splinesteps], [u=], [v=], [weights=], [type=], [mult=], [knots=]); +// Description: +// Sample a NURBS patch on a point set. If you give splinesteps then it will sampled uniformly in the spline +// parameter between the knots, ensuring that a sample appears at every knot. If you instead give u and v then +// the values at those points in parameter space will be returned. The various NURBS parameters can all be +// single values, if the NURBS has the same parameters in both directions, or pairs listing the value for the +// two directions. If you want uniform knots in one direction and specified knots in the other you can +// give `undef` as the knot vector, e.g., `[undef,vknots]` to have uniform knots in the first dimension and +// specified knots in the second one. You can do the same thing with the `mult` parameter. +// Arguments: +// patch = rectangular list of control points in any dimension, or a NURBS parameter list +// degree = a scalar or 2-vector giving the degree of the NURBS in the two directions +// splinesteps = a scalar or 2-vector giving the number of segments between each knot in the two directions +// --- +// u = evaluation points in the u direction of the patch +// v = evaluation points in the v direction of the patch +// mult = a single list or pair of lists giving the knot multiplicity in the two directions. Default: all 1 +// knots = a single list or pair of lists giving the knot vector in each of the two directions. Default: uniform +// weights = a matrix whose size corresponds to `patch` giving the weight at each control point in the patch. Default: all 1 +// type = a single string or pair of strings giving the NURBS type, where each entry is one of "clamped", "open" or "closed". Default: "clamped" +// Example(3D,NoScale): Computing points on a patch using ranges +// patch = [ +// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], +// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], +// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], +// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], +// ]; +// pts = nurbs_patch_points(patch, 3, u=[0:.1:1], v=[0:.3:1]); +// move_copies(flatten(pts)) sphere(r=2,$fn=16); +// Example(3D,NoScale): Computing points using splinesteps +// patch = [ +// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], +// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], +// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], +// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], +// ]; +// pts = nurbs_patch_points(patch, 3, splinesteps=5); +// move_copies(flatten(pts)) sphere(r=2,$fn=16); + +function nurbs_patch_points(patch, degree, splinesteps, u, v, weights, type=["clamped","clamped"], mult=[undef,undef], knots=[undef,undef]) = + is_list(patch) && _valid_surface_type(patch[0]) ? + assert(len(patch)>=6, "NURBS parameter list is invalid") + assert(num_defined([degree,weights])==0 && mult==[undef,undef] && knots==[undef,undef], + "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list") + nurbs_patch_points(patch[2], patch[1], splinesteps, u, v, patch[5], patch[0], knots=patch[3],mult=patch[4]) + : assert(is_undef(splinesteps) || !any_defined([u,v]), "Cannot combine splinesteps with u and v") + is_def(weights) ? + assert(is_matrix(weights,len(patch),len(patch[0])), "The weights parameter must be a matrix that matches the size of the patch array") + let( + patch = [for(i=idx(patch)) [for (j=idx(patch[0])) [each patch[i][j]*weights[i][j], weights[i][j]]]], + pts = nurbs_patch_points(patch=patch, degree=degree, splinesteps=splinesteps, u=u, v=v, type=type, mult=mult, knots=knots) + ) + [for(row=pts) [for (pt=row) select(pt,0,-2)/last(pt)]] + : + assert(is_undef(u) || is_range(u) || is_vector(u) || is_finite(u), "Input u is invalid") + assert(is_undef(v) || is_range(v) || is_vector(v) || is_finite(v), "Input v is invalid") + assert(num_defined([u,v])!=1, "Must define both u and v (when using)") let( - n = len(points), - c = path_segment_lengths(points, closed=closed), - nc = len(c), - // Centripetal base: sqrt of each chord length. - d = [for (ci = c) sqrt(ci)], - // θ̂[i] = min(deflection angle at P[i], π/2) in radians. - // Deflection angle = 180° − interior angle at P[i]. - // Endpoints of an open curve contribute zero correction. - theta_hat = [for (i = [0:1:n-1]) - !closed && (i == 0 || i == n-1) ? 0 - : let(phi_deg = 180 - vector_angle(select(points, i-1, i+1))) - min(phi_deg * PI/180, PI/2) - ] + u=is_range(u) ? list(u) : u, + v=is_range(v) ? list(v) : v, + degree = force_list(degree,2), + type = force_list(type,2), + splinesteps = is_undef(splinesteps) ? [undef,undef] : force_list(splinesteps,2), + mult = is_vector(mult) || is_undef(mult) ? [mult,mult] + : assert((is_undef(mult[0]) || is_vector(mult[0])) && (is_undef(mult[1]) || is_vector(mult[1])), "mult must be a vector or list of two vectors") + mult, + knots = is_vector(knots) || is_undef(knots) ? [knots,knots] + : assert((is_undef(knots[0]) || is_vector(knots[0])) && (is_undef(knots[1]) || is_vector(knots[1])), "knots must be a vector or list of two vectors") + knots ) - [for (i = [0:1:nc-1]) - let( - di = d[i], - d_prev = d[(i - 1 + nc) % nc], - d_next = d[(i + 1) % nc], - th_L = theta_hat[i], - th_R = theta_hat[(i + 1) % n], - left = 3 * th_L * d_prev / (2 * (d_prev + di)), - right = 3 * th_R * d_next / (2 * (di + d_next)) - ) - di * (1 + left + right) - ]; - - -// Fang improved centripetal parameterization (Fang & Hung, CAD 2013, Eq. 10). -// Centripetal base + osculating-circle dragging tolerance (α = 0.1). -// At each interior point Pᵢ, eᵢ = α·(θᵢ·ℓᵢ/(2·sin(θᵢ/2)) + θᵢ₋₁·ℓᵢ₋₁/(2·sin(θᵢ₋₁/2))) -// where θᵢ is deflection angle at Pᵢ, ℓᵢ is shortest side of triangle Pᵢ₋₁PᵢPᵢ₊₁. -// Each chord increment is Δᵢ = √‖Lᵢ‖ + eᵢ + eᵢ₊₁ (corrections from both endpoints). - -function _fang_correction(points, closed) = - let(n = len(points)) - [for (i = [0:1:n-1]) - !closed && (i == 0 || i == n-1) ? 0 - : let( - tri = select(points, i-1, i+1), - ell = min(path_segment_lengths(tri, closed=true)), - theta_deg = 180 - vector_angle(select(points, i-1, i+1)) - ) - // θ·ℓ/(2·sin(θ/2)); limit as θ→0 is ℓ. - 0.1 * (abs(theta_deg) < 1e-6 ? ell - : theta_deg * PI/180 * ell / (2 * sin(theta_deg / 2))) - ]; - -function _fang_dists(points, closed) = + is_num(u) && is_num(v)? nurbs_curve([for (control=patch) nurbs_curve(control, degree[1], u=v, type=type[1], mult=mult[1], knots=knots[1])], + degree[0], u=u, type=type[0], mult=mult[0], knots=knots[0]) + : is_num(u) ? nurbs_patch_points(patch, degree, u=[u], v=v, knots=knots, mult=mult, type=type)[0] + : is_num(v) ? column(nurbs_patch_points(patch, degree, u=u, v=[v], knots=knots, mult=mult, type=type),0) + : let( - c = path_segment_lengths(points, closed=closed), - nc = len(c), - ef = _fang_correction(points, closed) + vsplines = [for (i = idx(patch[0])) nurbs_curve(column(patch,i), degree[0], splinesteps=splinesteps[0],u=u, type=type[0],mult=mult[0],knots=knots[0])] ) - [for (i = [0:1:nc-1]) - sqrt(c[i]) + ef[i] + select(ef, i+1) - ]; - - -// Chord-length, centripetal, dynamic, Foley, or Fang parameterization. -// clamped: n+1 points -> n+1 values in [0, 1] with t_0=0, t_n=1. -// closed: n points -> n values in [0, 1) with t_0=0. -// method: "length" = chord-length -// "centripetal" = sqrt exponent (Lee 1989) -// "dynamic" = per-chord dynamic exponent (Balta et al. 2020) -// "foley" = centripetal + deflection-angle correction (Foley & Neilson 1987) -// "fang" = centripetal + osculating-circle correction (Fang & Hung 2013) - -function _interp_params(points, method="centripetal", closed=false) = - let( - raw = path_segment_lengths(points, closed=closed), - n = len(raw), - total_raw = sum(raw) - ) - // Degenerate: all points identical (e.g. a surface pole row/column). - // Return uniform spacing so surface parameter averages stay valid. - total_raw < 1e-10 - ? (closed - ? [for (i = [0:1:n-1]) i / n] - : [for (i = [0:1:n ]) i / n]) - : assert(min(raw) > 1e-10, - "nurbs_interp: consecutive duplicate data points detected") - let( - dists = method == "centripetal" ? [for (d = raw) sqrt(d)] - : method == "dynamic" ? _dynamic_dists(raw) - : method == "foley" ? _foley_dists(points, closed) - : method == "fang" ? _fang_dists(points, closed) - : raw, - total = sum(dists), - cs = cumsum(dists) - ) - closed ? [0, each [for (x = list_head(cs)) x / total]] - : [0, each [for (x = list_head(cs)) x / total], 1]; - + [for (i = idx(vsplines[0])) nurbs_curve(column(vsplines,i), degree[1], splinesteps=splinesteps[1], u=v, mult=mult[1], knots=knots[1], type=type[1])]; -// Knot Vector Construction + +// Function&Module: nurbs_vnf() +// Synopsis: Generates a (possibly non-manifold) VNF for a single NURBS surface patch. +// SynTags: VNF +// Topics: NURBS Patches +// See Also: nurbs_patch_points() +// Usage: (as a function) +// vnf = nurbs_vnf(patch, degree, [splinesteps], [mult=], [knots=], [weights=], [type=], [style=], [reverse=], [triangulate=], [caps=], [caps1=], [caps2=]); +// Usage: (as a module) +// nurbs_vnf(patch, degree, [splinesteps], [mult=], [knots=], [weights=], [type=], [style=], [reverse=], [triangulate=], [caps=], [caps1=], [caps2=], [convexity=],[atype=],[cp=], [cp=], [atype=], ...) CHILDREN; +// Description: +// Compute a (possibly non-manifold) VNF for a NURBS. The input patch must be an array of control points or a NURBS parameter list. If weights is given it +// must be an array of weights that matches the size of the control points. The style parameter +// gives the {{vnf_vertex_array()}} style to use. The other parameters may specify the NURBS parameters in the two directions +// by giving a single value, which applies to both directions, or a list of two values to specify different values in each direction. +// You can specify undef for for a direction to keep the default, such as `mult=[undef,v_multiplicity]`. +// . +// Instead of providing separate parameters you can give a first parameter as a NURBS parameter list: `[type, degree, control, knots, mult, weights]`. +// Arguments: +// patch = rectangular list of control points in any dimension, or a NURBS parameter list +// degree = a scalar or 2-vector giving the degree of the NURBS in the two directions +// splinesteps = a scalar or 2-vector giving the number of segments between each knot in the two directions. Default: 16 +// --- +// mult = a single list or pair of lists giving the knot multiplicity in the two directions. Default: all 1 +// knots = a single list of pair of lists giving the knot vector in each of the two directions. Default: uniform +// weights = a single list or pair of lists giving the weight at each control point in the. Default: all 1 +// type = a single string or pair of strings giving the NURBS type, where each entry is one of "clamped", "open" or "closed". Default: "clamped" +// caps = If true, add endcap faces to both ends. The type must be ["clamped","closed"] or ["closed","clamped"] to enable caps. +// cap1 = If true, add an endcap face to the first end. +// cap2 = If true, add an endcap face to the second end. +// reverse = If true, reverse all face normals. +// style = {{vnf_vertex_array ()}} style to use for triangulating the surface. Default: "default" +// triangulate = If true, triangulates endcaps to resolve possible CGAL issues. This can be an expensive operation if the endcaps are complex. Default: false +// cp = (module) Centerpoint for determining intersection anchors or centering the shape. Determines the base of the anchor vector. Can be "centroid", "mean", "box" or a 3D point. Default: "centroid" +// anchor = (module) Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `"origin"` +// spin = (module) Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0` +// orient = (module) Vector to rotate top toward, after spin. See [orient](attachments.scad#subsection-orient). Default: `UP` +// atype = (module) Select "hull" or "intersect" anchor type. Default: "hull" +// Example(3D): Quadratic B-spline surface +// patch = [ +// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], +// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], +// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], +// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], +// ]; +// vnf = nurbs_vnf(patch, 2); +// vnf_polyhedron(vnf); +// Example(3D): Cubic B-spline surface +// patch = [ +// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], +// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], +// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], +// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], +// ]; +// vnf = nurbs_vnf(patch, 3); +// vnf_polyhedron(vnf); +// Example(3D): Cubic B-spline surface, closed in one direction +// patch = [ +// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], +// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], +// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], +// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], +// ]; +// vnf = nurbs_vnf(patch, 3, type=["closed","clamped"]); +// vnf_polyhedron(vnf); +// Example(3D): B-spline surface cubic in one direction, quadratic in the other +// patch = [ +// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 20], [50, 50, 0]], +// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 40], [50, 16, 20]], +// [[-50,-16, 20], [-16,-16, 40], [ 16,-16, 40], [50,-16, 20]], +// [[-50,-50, 0], [-16,-50, 20], [ 16,-50, 20], [50,-50, 0]], +// ]; +// vnf = nurbs_vnf(patch, [3,2],type=["closed","clamped"]); +// vnf_polyhedron(vnf); +// Example(3D): The sphere can be represented using NURBS +// patch = [ +// [[0,0,1], [0,0,1], [0,0,1], [0,0,1], [0,0,1], [0,0,1], [0,0,1]], +// [[2,0,1], [2,4,1], [-2,4,1], [-2,0,1], [-2,-4,1], [2,-4,1], [2,0,1]], +// [[2,0,-1],[2,4,-1],[-2,4,-1],[-2,0,-1],[-2,-4,-1], [2,-4,-1],[2,0,-1]], +// [[0,0,-1],[0,0,-1],[0,0,-1], [0,0,-1], [0,0,-1], [0,0,-1], [0,0,-1]] +// ]; +// weights = [ +// [9,3,3,9,3,3,9], +// [3,1,1,3,1,1,3], +// [3,1,1,3,1,1,3], +// [9,3,3,9,3,3,9], +// ]/9; +// vknots = [0, 1/2, 1/2, 1/2, 1]; +// vnf = nurbs_vnf(patch, 3,weights=weights, knots=[undef,vknots]); +// vnf_polyhedron(vnf); +function nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, knots, style="default", reverse=false, triangulate=false, caps,cap1,cap2) = + is_list(patch) && _valid_surface_type(patch[0]) ? + assert(len(patch)>=6, "NURBS parameter list is invalid") + assert(num_defined([degree,mult,weights,knots]==0), + "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list") + nurbs_vnf(patch[2], patch[1], splinesteps, patch[5], patch[0], knots=patch[3], mult=patch[4], style=style,caps=caps,cap1=cap1,cap2=cap2, + reverse=reverse, triangulate=triangulate) + : assert(is_nurbs_patch(patch),"Input patch is not a rectangular aray of points") + assert(_valid_surface_type(type), "type must be one of or a list of two of: \"closed\", \"clamped\" and \"open\"") + let(havecaps = num_true([caps,cap1,cap2])>0) + assert(!havecaps || type==["clamped","closed"] || type==["closed","clamped"], + "Surface must be [\"closed\",\"clamped\"] or [\"clamped\",\"closed\"] to for caps to be created") + let( + type = force_list(type,2), + havecaps = num_true([caps,cap1,cap2])>0, + flip = havecaps && type[0]=="closed", + pts = nurbs_patch_points(patch=patch, degree=degree, splinesteps=splinesteps, type=type, mult=mult, knots=knots, weights=weights), + tpts = flip ? (transpose(pts)) : pts + ) + vnf_vertex_array(tpts, style=style, row_wrap=type[flip?1:0]=="closed", col_wrap=type[flip?0:1]=="closed",reverse=reverse,triangulate=triangulate, + caps=caps,cap1=cap1,cap2=cap2); -// Interior knots by averaging (Piegl & Tiller eq 9.8). -function _avg_knots_interior(params, p) = - let( - n = len(params) - 1, - num_internal = n - p - ) - num_internal <= 0 - ? [] - : [for (j = [1:1:num_internal]) - sum([for (i = [j :1: j + p - 1]) params[i]]) / p - ]; +function _valid_surface_type(type) = + in_list(type,["closed","clamped","open"]) ? true + : !is_list(type) || len(type)!=2 ? false + : _valid_surface_type(type[0]) && _valid_surface_type(type[1]); + -// Full clamped knot vector: (p+1) zeros, interior, (p+1) ones. +module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, knots, style="default", reverse=false, triangulate=false, + convexity=2, cp="centroid", anchor="origin", spin=0, orient=UP, atype="hull", caps, cap1, cap2) +{ + if (is_list(patch) && _valid_surface_type(patch[0])){ + assert(len(patch)>=6, "NURBS parameter list is invalid"); + assert(num_defined([degree,mult,weights,knots]==0), + "Cannot give degree, mult, weights or knots when you provide a NURBS parameter list"); + nurbs_vnf(patch[2], patch[1], splinesteps, patch[5], patch[0], mult=patch[4], knots=patch[3], style=style, reverse=reverse, triangulate=triangulate, + convexity=convexity, cp=cp, anchor=anchor, spin=spin, orient=orient, atype=atype, caps=caps, cap1=cap1, cap2=cap2) children(); + } + else { + type = force_list(type,2); + havecaps = num_true([caps,cap1,cap2])>0; + dummy = + assert(is_nurbs_patch(patch),"Input patch is not a rectangular aray of points") + assert(_valid_surface_type(type), "type must be one of or a list of two of: \"closed\", \"clamped\" and \"open\"") + assert(!havecaps || type==["clamped","closed"] || type==["closed","clamped"], + "Surface must be [\"closed\",\"clamped\"] or [\"clamped\",\"closed\"] to for caps to be created"); + flip = havecaps && type[0]=="closed"; + pts = nurbs_patch_points(patch=patch, degree=degree, splinesteps=splinesteps, type=type, mult=mult, knots=knots, weights=weights); + tpts = flip ? (transpose(pts)) : pts; + vnf_vertex_array(tpts, style=style, row_wrap=type[flip?1:0]=="closed", col_wrap=type[flip?0:1]=="closed", reverse=reverse, triangulate=triangulate, cp=cp, + convexity=convexity, anchor=anchor, spin=spin, orient=orient, atype=atype, caps=caps, cap1=cap1, cap2=cap2) children(); + } +} -function _full_clamped_knots(interior_knots, p) = - concat(repeat(0, p+1), interior_knots, repeat(1, p+1)); -// Periodic "bar knots" for closed B-splines. -// -// Returns [bar_knots, shifted_params] where bar_knots is n+1 -// monotonically increasing values with bar[0]=0, bar[n]=1, and -// shifted_params are the parameter values shifted to match. +// Function&Module: nurbs_interp_surface() +// Synopsis: Returns a NURBS surface that passes through a grid of 3D data points. +// SynTags: Geom +// Topics: NURBS Surfaces, Interpolation +// See Also: nurbs_vnf(), nurbs_interp() // -// The raw bar knots are computed by averaging p consecutive values -// from the extended periodic parameter sequence t_m = params[m%n] + -// floor(m/n). This is guaranteed monotonic. We then shift so -// bar[0]=0, and shift params by the same amount. - -function _avg_knots_periodic(params, p) = - let( - n = len(params), - raw = [for (j = [0:1:n]) - sum([for (k = [0:1:p-1]) - let(m = j + k) - params[m % n] + floor(m / n) - ]) / p - ], - shift = raw[0], - bar_knots = add_scalar(raw, -shift), - shifted = [for (t = params) - let(s = t - shift) - s < 0 ? s + 1 : (s >= 1 ? s - 1 : s)] - ) - [bar_knots, shifted]; - - -// Repair degenerate periodic bar knots: if any span is smaller than -// eps × period, merge it into its neighbor and bisect the resulting -// larger span. Preserves the knot count (n+1 entries, n spans) and -// the endpoint values bar[0]=0, bar[n]=period. Recurses until no -// tiny spans remain. - -function _fix_tiny_spans(bar_knots, n, eps=1e-6) = - let( - T = bar_knots[n], - spans = [for (k = [0:1:n-1]) bar_knots[k+1] - bar_knots[k]], - min_span = min(spans) - ) - min_span >= eps * T ? bar_knots - : let( - k = min_index(spans), - // Remove an interior knot bounding the tiny span. - // For span 0 (first span), remove knot 1 and absorb into span 1. - // For span n-1 (last span), remove knot n-1 and absorb into span n-2. - // Otherwise, remove knot k+1 and absorb into the merged span at k. - remove_idx = k == 0 ? 1 - : k == n - 1 ? n - 1 - : k + 1, - merged = [for (i = [0:1:n]) if (i != remove_idx) bar_knots[i]], - absorb_k = k == 0 ? 0 : k - 1, - // Bisect the absorbing span to restore the knot count. - mid = (merged[absorb_k] + merged[absorb_k + 1]) / 2, - fixed = [for (i = [0:1:n-1]) // n entries in merged - each (i == absorb_k ? [merged[i], mid] : [merged[i]])] - ) - _fix_tiny_spans(fixed, n, eps); - - -// Insert extra knots into a base bar_knots vector, one per -// constraint parameter. For each constraint, finds the span -// containing its parameter value and inserts at the span midpoint. -// When multiple constraints compete, the one whose containing span -// is largest is processed first — this avoids splitting a small -// span when a larger one is available. Each insertion updates the -// knot vector before the next constraint is processed. +// Usage: As a function, returns a NURBS parameter list: +// nurbs_param = nurbs_interp_surface(points, degree, [method=], [row_wrap=], [col_wrap=], [normal1=], [normal2=], [flat_edges=], [flat_end1=], [flat_end2=], [row_edges=], [col_edges=], [extra_pts=], [smooth=], [first_row_deriv=], [last_row_deriv=], [first_col_deriv=], [last_col_deriv=]); +// Usage: As a module, renders the surface directly: +// nurbs_interp_surface(points, degree, [splinesteps=], [row_wrap=], [col_wrap=], [method=], [extra_pts=], [smooth=], ...) CHILDREN; +// Description: +// Finds the control points and knot vectors for a NURBS surface of the specified degree that passes +// exactly through every data point in a grid of 3D points. The result has +// uniform weights but non-uniform knots so it is actually a non-uniform B-spline. +// When called as a function, the return value is a NURBS parameter list +// `[type, degree, ctrl_grid, knots, undef, undef, uv]` that can be passed +// directly to `{{nurbs_vnf()}}`. The extra return value `uv`, +// described in detail below, enables you to locate your input points in the computed spline +// When called as a module, renders the NURBS surface as geometry. +// . +// Several of the parameters that correspond to parameters for {{nurbs_interp()}} +// can be given as either a scalar or 2-vector. When you give a 2-vector the +// first value applies along the first index of your point data, i.e. from row +// to row, or along columns. The second value applies along the second index, +// i.e. within rows. +// . +// Setting `row_wrap=true` smoothly connects the first and last rows in a loop, +// and `col_wrap=true` smoothly joins the first and last columns. Both false (the default) gives a +// surface with four edges. One true gives a tube; both true gives a torus. +// A tube by itself is not a valid closed manifold in OpenSCAD; you can make it valid by adding caps or +// you can close it into a ball by specifying degenerate edges where the entire edge collapses to +// one identical point. +// . +// **Boundary constraints** +// . +// Flat boundary (`row_wrap=false, col_wrap=false`) — `flat_edges=`. Applies when +// all four surface edges are coplanar. Set `flat_edges` to a 4-element list +// `[first_row, last_row, first_col, last_col]`; each entry is a scalar or per-point list +// giving the derivative scale for that edge (`undef` leaves the edge unconstrained). +// `flat_edges=s` expands to `[s,s,s,s]`. A positive value flares the surface +// outward from the edge; negative turns it inward. +// . +// End normals (one of `row_wrap`/`col_wrap` true, the other false) — `normal1=` and +// `normal2=`. Apply when the specified boundary edge is degenerate (all points +// identical, e.g. a cone tip). The surface is constrained to be normal to the given +// vector at that edge. The vector magnitude controls how broadly the surface spreads. +// . +// Flat ends (one of `row_wrap`/`col_wrap` true, the other false) — `flat_end1=` and +// `flat_end2=`. Apply when the specified boundary edge is coplanar and non-degenerate. +// Constrains the derivative to lie in the plane of the edge. Positive points inward +// (smooth cap attachment); negative flares outward. Scalar or per-point list. +// . +// **Advanced boundary derivatives** — `first_row_deriv=`, `last_row_deriv=`, +// `first_col_deriv=`, and `last_col_deriv=` enforce specific first partial derivatives +// along the four boundary edges. Each accepts a single vector (applied to every +// point on the edge) or a list of vectors (one per point). Vectors are scaled by +// total chord length, so a unit vector matches the parameterization speed. These +// require `row_wrap=false` (for row derivs) or `col_wrap=false` (for col derivs). +// . +// Use with care: the solver enforces derivatives exactly at data points but the +// surface may wander between them. The basic constraints above apply in special cases where the geometry guarantees +// well-defined behavior along an entire edge, including the points in between data points. +// When both row and column boundary derivatives are +// active, the cross-derivative $\partial^2 S/\partial u \partial v$ is assumed to be zero at corners. +// . +// **Edges** — `row_edges=` and `col_edges=` insert edges or creases across the surface. +// Use `row_edges=` to specify the indices of rows that will be edges or creases, +// and `col_edges=` to specify the indices of columns that will be edges or creases. +// For a non-wrapped direction, indices must be interior (not first or last). +// If you place edges close together, the effective degree of a narrow patch between +// edges may be reduced. These patches are assembled into a single NURBS so this +// process is transparent to the user. +// . +// **Extra control points** (`extra_pts=`, `smooth=`) — By default the solver uses +// exactly the number of control points needed to satisfy the constraints, which +// gives a unique solution that may be badly behaved. Specifying `extra points=` +// and optionally `smooth=`, works the same way as in +// for {{nurbs_interp()}}. Both parameters can be scalars or 2-vectors to +// provide different values along the two directions. +// . +// **Locating points in the spline** — In order to locate your original data +// points in the spline you need the `u` and `v` nurbs parameter values that you +// can pass to {{nurbs_patch_points()}}. The last return value `uv` gives these: +// `uv[0][j]` is the u parameter for row `j` and `uv[1][k]` is the v parameter +// for column `k`, so the point `points[j][k]` lies at `(uv[0][j], uv[1][k])` +// in NURBS parameter space. +// . +// **Smoothness** — The smoothness of B-splines is determined by the +// degree. If you request a degree p spline then it will be $C^{p-1}$ at +// knot points and $C^\infty$ everywhere else. If you request edges then +// these are points where the surface is not differentiable; edges may +// also divide the surface into smaller regions that lack sufficient points +// to support an interpolation of your requested degree: a degree p interpolation +// requires p+1 points. In this case, the interpolation is performed at a lower +// degree and elevated, which means it will be less smooth at knots. +// Arguments: +// points = Rectangular grid of 3D data points +// degree = scalar or 2-vector giving the degree of the B-spline in the two directions. +// splinesteps = (module) Scalar or 2-vector giving the number of segments between each knot in the two directions. Default: 16 +// --- +// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` +// row_wrap = If true, smoothly connect the first row to the last row. Default: false +// col_wrap = If true, smoothly connect the first column to the last column. Default: false +// extra_pts = Scalar or 2-vector giving the number of extra points in the two directions. Default: `0` +// smooth = Scalar or 2-vector giving the smoothness metric for extra points in the two directions: `1` (min polygon length), `2` (min bending), `3` (min bending energy). Default: `3` +// flat_edges = 4-element list `[first_row, last_row, first_col, last_col]` of derivative scales at the four coplanar boundary edges. Each entry is a scalar or per-point list; `undef` leaves that edge unconstrained. Shorthand: `flat_edges=s` → `[s,s,s,s]`. Requires `row_wrap=false, col_wrap=false`. +// normal1 = Surface normal at the first degenerate boundary edge (mixed wrap surface only). +// normal2 = Surface normal at the second degenerate boundary edge (mixed wrap surface only). +// flat_end1 = Inward derivative scale at the first coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. +// flat_end2 = Inward derivative scale at the second coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. +// row_edges = Row indices (or index) of rows that are treated as edges or creases. +// col_edges = Column indices (or index) of columns that are treated as edges or creases +// first_row_deriv = $\partial S/\partial u$ constraint along u=0 (first row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. +// last_row_deriv = $\partial S/\partial u$ constraint along u=1 (last row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. +// first_col_deriv = $\partial S/\partial v$ constraint along v=0 (first column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. +// last_col_deriv = $\partial S/\partial v$ constraint along v=1 (last column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. +// data_size = (module) Radius of data-point markers; 0 suppresses markers. Default: 0 +// data_color = (module) Color for data-point markers. Default: `"red"` +// style = (module) Triangulation style passed to `vnf_vertex_array()`. Default: `"default"` +// reverse = (module) If true, reverses face normals. Default: false +// triangulate = (module) If true, triangulates all quads. Default: false +// caps = (module) Cap both open boundary edges (mixed wrap only). Default: false +// cap1 = (module) Cap the first open boundary edge. +// cap2 = (module) Cap the second open boundary edge. +// cp = (module) Centerpoint for determining intersection anchors or centering the shape. Determines the base of the anchor vector. Can be "centroid", "mean", "box" or a 3D point. Default: "centroid" +// anchor = (module) Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `"origin"` +// spin = (module) Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0` +// orient = (module) Vector to rotate top toward, after spin. See [orient](attachments.scad#subsection-orient). Default: `UP` +// atype = (module) Select "hull" or "intersect" anchor type. Default: "hull" // -// bar_knots: base bar_knots from periodic or interior averaging. -// constraint_ts: list of parameter values identifying which span -// to split. For closed: raw params in [0,1). -// For clamped: params in [0,1]. +// Example(3D): Basic surface interpolation +// // A 4x5 grid of 3D data points produces a smooth interpolating surface. +// data = [ +// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 10], [50, 50, 0], [80, 50, 5]], +// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 30], [50, 16, 20], [80, 16, 10]], +// [[-50,-16, 20], [-16,-16, 35], [ 16,-16, 40], [50,-16, 15], [80,-16, 25]], +// [[-50,-50, 0], [-16,-50, 10], [ 16,-50, 20], [50,-50, 0], [80,-50, 5]], +// ]; +// nurbs_interp_surface(data, 3, splinesteps=8); // -// Returns the augmented bar_knots with len(constraint_ts) extra entries. - -function _insert_constraint_knots(bar_knots, constraint_ts) = - len(constraint_ts) == 0 ? bar_knots - : let( - n = len(bar_knots), - // For each constraint, find its containing span and that span's width. - spans = [for (ci = [0:1:len(constraint_ts)-1]) - let( - t = constraint_ts[ci], - pos = [for (i = [0:1:n-2]) - if (bar_knots[i] <= t && t < bar_knots[i+1]) i], - idx = len(pos) > 0 ? pos[0] : n - 2, - w = bar_knots[idx+1] - bar_knots[idx] - ) - [ci, idx, w] - ], - // Pick the constraint whose span is largest. - best = max_index([for (s = spans) s[2]]), - ci = spans[best][0], - idx = spans[best][1], - mid = (bar_knots[idx] + bar_knots[idx+1]) / 2, - new_knots = [each [for (i = [0:1:idx]) bar_knots[i]], mid, - each [for (i = [idx+1:1:n-1]) bar_knots[i]]], - remaining = [for (i = [0:1:len(constraint_ts)-1]) - if (i != ci) constraint_ts[i]] - ) - _insert_constraint_knots(new_knots, remaining); - - -// Return k parameter values, each at the midpoint of one of the k -// widest spans in bar_knots. Used to target extra knot insertions -// and smoothness rows at the most under-resolved regions. +// Example(3D): Different degrees per direction +// // Quadratic in u (rows), cubic in v (columns). +// data = [ +// for (u = [-40:20:40]) +// [for (v = [-40:20:40]) +// [v, u, 15*sin(u*3)*cos(v*3)]] +// ]; +// nurbs_interp_surface(data, [2,3], splinesteps=8); // -// When all k picks come from equal-width spans (the common case for -// uniformly-parameterized closed curves), spans are chosen at centred- -// stratified indices floor((2g+1)*n/(2*k_eff)) % n for g=0..k_eff-1. -// This places each pick at the centre of its equal-width quantile -// rather than at the quantile boundary. For n=18, k=4 the picks -// are spans 2, 6, 11, 15 instead of 0, 4, 9, 13. +// Example(3D): Tube (surface closed in one direction) +// // Closed around the column direction (the rings), clamped along rows +// // (the axis). Uses 5 rings: a cubic closed direction needs at least +// // p+2 = 5 data points to have interior knot freedom. +// r = 20; +// data = [for (u = [0:15:60]) +// [for (i = [0:1:5]) +// let(a = i * 360/6) +// [r*cos(a), r*sin(a), u]] +// ]; +// nurbs_interp_surface(data, 3, splinesteps=8, col_wrap=true); // -// Centering is essential for closed curves: _extend_knot_vector wraps -// span widths across the seam (span n-1 into the pre-region, span 0 -// into the post-region). If an extra knot is inserted in span 0, the -// span width at the start of aug_bar differs from the width at the end, -// making the basis functions slightly asymmetric at the seam and -// causing a visible fold in the null-space solution. Centering keeps -// both boundary spans at their original (uniform) width. -// When the k widest spans are not all equal, the standard widest-first -// selection is used (knot insertion targets the most under-resolved -// regions regardless of position). +// Example(3D): Torus (surface closed in both directions) +// // Both directions sample a full 360 circle with even angular spacing, +// // so the closing segment equals the inter-point spacing and +// // parameterization is uniform. Each direction uses N=6 > p+1=4 +// // points to ensure interior knot freedom. +// R = 30; r = 10; +// N = 6; +// data = [for (i = [0:1:N-1]) +// let(phi = i * 360/N) +// [for (j = [0:1:N-1]) +// let(theta = j * 360/N) +// [(R + r*cos(theta))*cos(phi), +// (R + r*cos(theta))*sin(phi), +// r*sin(theta)]] +// ]; +// nurbs_interp_surface(data, 3, splinesteps=12, +// row_wrap=true, col_wrap=true); +// +// Example(3D): Low-level surface access +// // nurbs_interp_surface() returns a BOSL2 NURBS parameter list +// // compatible with nurbs_vnf(), debug_nurbs(), etc. +// data = [ +// [[-30,30,0], [0,30,20], [30,30,0]], +// [[-30, 0,10],[0, 0,30], [30, 0,10]], +// [[-30,-30,0],[0,-30,15],[30,-30,0]], +// ]; +// result = nurbs_interp_surface(data, 2); +// vnf = nurbs_vnf(result, splinesteps=12); +// vnf_polyhedron(vnf); +// color("red") +// for (row = data) for (pt = row) +// translate(pt) sphere(r=1, $fn=16); -function _widest_span_params(bar_knots, k) = + +function nurbs_interp_surface(points, degree, method="centripetal", + row_wrap=false, col_wrap=false, + first_row_deriv=undef, last_row_deriv=undef, + first_col_deriv=undef, last_col_deriv=undef, + normal1=undef, normal2=undef, + flat_end1=undef, flat_end2=undef, + flat_edges=undef, + row_edges=undef, col_edges=undef, + extra_pts=0, smooth=3) = + // Preamble: extract shape/edge info needed for closed-direction dispatch. let( - n = len(bar_knots) - 1, - k_eff = min(k, n), - _echo = k > n ? echo(str("nurbs_interp: extra_pts=", k, - " exceeds the number of available knot spans (", n, - "); reduced to ", n, ".")) : 0, - spans = [for (i = [0:1:n-1]) bar_knots[i+1] - bar_knots[i]], - w_max = max(spans), - // Indices of spans at the maximum width (within floating-point tolerance). - // Stratification picks only from these so that constraint-narrowed spans - // (e.g. from _insert_constraint_knots) are never accidentally chosen. - eq_idxs = [for (i = [0:1:n-1]) if (abs(spans[i] - w_max) < 1e-10 * w_max) i], - n_eq = len(eq_idxs) - ) - // If all k_eff picks come from equal-width spans, use centred stratification - // over eq_idxs so that constraint-narrowed spans are never selected. - n_eq >= k_eff - ? [for (g = [0:1:k_eff-1]) - let(i = eq_idxs[floor((2 * g + 1) * n_eq / (2 * k_eff))]) - (bar_knots[i] + bar_knots[i+1]) / 2 - ] - // Otherwise use widest-first selection (non-uniform spans). - : let( - sorted = sort([for (i = [0:1:n-1]) [spans[i], i]]), - top_k = [for (i = [n-1:-1:n-k_eff]) sorted[i]] - ) - [for (s = top_k) (bar_knots[s[1]] + bar_knots[s[1]+1]) / 2]; - - -// Find knot spans containing multiple data parameters and return -// splitting midpoints. Two data points in the same span cause a -// rank-deficient collocation matrix; inserting a knot between them -// restores full rank. -// -// bar_knots: sorted knot vector with n_spans+1 entries. -// params: sorted or unsorted data parameter values. -// -// Returns a list of splitting parameter values — one midpoint between -// each consecutive pair of params that share a span. - -function _span_split_params(bar_knots, params) = - let( - n_spans = len(bar_knots) - 1, - sorted = sort(params), - n_p = len(sorted), - // For each sorted param, find its span index. - span_of = [for (t = sorted) - let(pos = [for (i = [0:1:n_spans-1]) - if (t >= bar_knots[i] && - (i < n_spans-1 ? t < bar_knots[i+1] - : t <= bar_knots[i+1])) i]) - len(pos) > 0 ? pos[0] : n_spans - 1 - ] - ) - // Midpoints between consecutive sorted params sharing a span. - [for (i = [0:1:n_p-2]) - if (span_of[i] == span_of[i+1]) - (sorted[i] + sorted[i+1]) / 2 - ]; - - -// Build one row of the L^T*L matrix for control-polygon regularization. -// order=1: first-difference penalty (penalizes polygon length/variation). -// order=2: second-difference penalty (penalizes polygon bending). -// periodic=true wraps the differences around for closed curves. -// -// For clamped (non-periodic): -// order=1 L^T*L: tridiag [1,-1,0..] [-1,2,-1,0..] .. [0..,-1,1] -// order=2 L^T*L: pentadiag boundary-adapted -// For closed (periodic): -// order=1 L^T*L: circulant [2,-1,0..0,-1] -// order=2 L^T*L: circulant [6,-4,1,0..0,1,-4] - -function _ltl_row(M, i, order, periodic=false) = - periodic - ? (order == 1 - ? [for (j = [0:1:M-1]) - j == i ? 2 - : j == (i+1)%M || j == (i-1+M)%M ? -1 - : 0] - : // order == 2 - [for (j = [0:1:M-1]) - j == i ? 6 - : j == (i+1)%M || j == (i-1+M)%M ? -4 - : j == (i+2)%M || j == (i-2+M)%M ? 1 - : 0]) - : // clamped (non-periodic) - (order == 1 - ? [for (j = [0:1:M-1]) - j == i ? (i == 0 || i == M-1 ? 1 : 2) - : (j == i+1 || j == i-1) ? -1 - : 0] - : // order == 2, L is (M-2)×M second-difference matrix. - // (L^T L)[i][j] = sum_{r=0}^{M-3} L[r][i]*L[r][j] - // where L[r][c] = (c==r ? 1 : c==r+1 ? -2 : c==r+2 ? 1 : 0). - // Nonzero only when |i-j| <= 2. - [for (j = [0:1:M-1]) - abs(i-j) > 2 ? 0 - : i == j - ? (i <= M-3 ? 1 : 0) // r=i: 1² - + (i >= 1 && i <= M-2 ? 4 : 0) // r=i-1: (-2)² - + (i >= 2 ? 1 : 0) // r=i-2: 1² - : abs(i-j) == 1 - ? let(lo = min(i,j)) - (lo <= M-3 ? -2 : 0) // r=lo: (1)(-2) - + (lo >= 1 && lo <= M-2 ? -2 : 0) // r=lo-1: (-2)(1) - : // abs(i-j) == 2 - (min(i,j) <= M-3 ? 1 : 0) // r=min: (1)(1) - ]); - - -// Solve the constrained optimization min P^T·R·P s.t. A·P = rhs -// via null-space method. -// -// R = M×M regularization matrix (positive semidefinite). -// A = N×M constraint matrix (interpolation + derivative + curvature). -// rhs = N×dim right-hand side (data points + constraint vectors). -// -// Algorithm -// 1. Step A — minimum-norm particular solution x_p satisfying A·x_p = rhs -// exactly, via BOSL2 linear_solve() (handles underdetermined systems). -// 2. Step B — minimize x^T·R·x in the null space of A (if M > N): -// Q2 = null_space(A) basis vectors (returned as rows by BOSL2) -// H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) -// Solve H · z = -Q2^T · R_pd · x_p via Cholesky -// P = x_p + Q2 · z -// -// Returns list of M control points, or undef on rank-deficient A. - -function _nullspace_solve(R, A, rhs, eps=1e-6) = - let( - M = len(R), - N_rows = len(A), - // Step A: minimum-norm particular solution via BOSL2. - // linear_solve handles underdetermined (M > N_rows) systems - // by returning the minimum-norm solution via QR of A^T. - x_p = linear_solve(A, rhs) - ) - x_p == [] ? undef - : M == N_rows ? x_p // Square: unique solution, no null space. - : let( - // Step B: minimize x^T·R·x in the null space. - // null_space() returns null-space vectors as rows. - ns = null_space(A), - n_ns = len(ns) - ) - n_ns == 0 ? x_p // Full rank despite M > N; no null space. - : let( - Q2 = transpose(ns), // M × n_ns (columns are basis vectors) - // Regularize R for strict positive-definiteness. - R_pd = [for (i = [0:1:M-1]) - [for (j = [0:1:M-1]) - R[i][j] + (i == j ? eps : 0)]], - // H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) - // Symmetrize to counteract floating-point round-off. - RQ2 = R_pd * Q2, - H_raw = transpose(Q2) * RQ2, - H = (H_raw + transpose(H_raw)) / 2, - // g = Q2^T · R_pd · x_p (n_ns × dim) - g = transpose(Q2) * (R_pd * x_p), - // Solve H · z = -g (H is SPD → Cholesky is fastest) - z = linear_solve(H, -g, method="cholesky") + n_rows = len(points), + n_cols = len(points[0]), + ue_norm_pre = is_undef(row_edges) ? undef : force_list(row_edges), + ve_norm_pre = is_undef(col_edges) ? undef : force_list(col_edges), + has_ue_pre = !is_undef(ue_norm_pre) && len(ue_norm_pre) > 0, + has_ve_pre = !is_undef(ve_norm_pre) && len(ve_norm_pre) > 0 ) - // If H solve fails (degenerate), x_p alone still satisfies constraints. - z == [] ? x_p - : x_p + Q2 * z; - - -// Gauss-Legendre quadrature nodes and weights on [-1,1]. -// Returns [[nodes], [weights]] for n-point rule (n = 2..5). -// Exact for polynomials up to degree 2n-1. - -function _gauss_legendre(n) = - n == 2 ? [[-0.5773502691896258, 0.5773502691896258], - [1.0, 1.0]] - : n == 3 ? [[-0.7745966692414834, 0.0, 0.7745966692414834], - [0.5555555555555556, 0.8888888888888888, 0.5555555555555556]] - : n == 4 ? [[-0.8611363115940526, -0.3399810435848563, - 0.3399810435848563, 0.8611363115940526], - [0.3478548451374538, 0.6521451548625461, - 0.6521451548625461, 0.3478548451374538]] - : // n >= 5 - [[-0.9061798459386640, -0.5384693101056831, 0.0, - 0.5384693101056831, 0.9061798459386640], - [0.2369268850561891, 0.4786286704993665, 0.5688888888888889, - 0.4786286704993665, 0.2369268850561891]]; - - -// One step of the de Boor recurrence: lifts degree-(k-1) to degree-k basis values -// at parameter t in span s of U. -// b_prev[lj] = N_{s-(k-1)+lj, k-1}(t) for lj = 0..k-1 (k entries) -// Returns b[lj] = N_{s-k+lj, k}(t) for lj = 0..k (k+1 entries) - -function _deboor_step(b_prev, k, s, t, U) = - [for (lj = [0:1:k]) + // col_edges on a closed v-direction: rotate columns so the first crease column + // becomes the v=0/v=1 boundary, append a copy at the end for the C0 seam, + // then recurse with col_wrap=false. Remaining crease indices are shifted + // into the rotated coordinate system. + has_ve_pre && col_wrap ? let( - j = s - k + lj, - e1 = U[s + lj] - U[j], // U[j+k] - U[j] - e2 = U[s + lj + 1] - U[j + 1] // U[j+k+1] - U[j+1] + ve_sorted = sort(ve_norm_pre), + rot = ve_sorted[0], + new_pts = [for (row = points) + concat([for (l = [rot:1:n_cols-1]) row[l]], + [for (l = [0:1:rot-1]) row[l]], + [row[rot]])], + adj_ve_raw = [for (i = [1:1:len(ve_sorted)-1]) + let(j = (ve_sorted[i] - rot + n_cols) % n_cols) + if (j > 0) j], + adj_ve = len(adj_ve_raw) == 0 ? undef : adj_ve_raw ) - (lj > 0 && abs(e1) > 1e-15 ? (t - U[j]) / e1 * b_prev[lj - 1] : 0) - + (lj < k && abs(e2) > 1e-15 ? (U[s+lj+1] - t) / e2 * b_prev[lj] : 0) - ]; - - -// Returns the (k+1)-element vector of non-zero degree-k basis values at t in span s: -// b[lj] = N_{s-k+lj, k}(t) for lj = 0..k. - -function _deboor_to_degree(s, k, t, U) = - k == 0 ? [1] - : _deboor_step(_deboor_to_degree(s, k - 1, t, U), k, s, t, U); - - -// Returns the (p+1)-element vector of non-zero degree-p second-derivative values -// at parameter t, which lies in knot span s of U. -// d2[lj] = N''_{s-p+lj, p}(t) for lj = 0..p. -// Uses the de Boor triangle to degree p-2, then lifts twice via the derivative -// recurrence (P&T §2.3 eq. 2.9): O(p²) work instead of M separate _d2nip() calls. - -function _d2nip_span(s, p, t, U) = - p <= 1 - ? [for (lj = [0:1:p]) 0] - : let( - // Degree-(p-2) basis: b2[lj] = N_{s-(p-2)+lj, p-2}(t) for lj = 0..p-2. - b2 = _deboor_to_degree(s, p - 2, t, U), - - // First lift: d1[lj] = N'_{s-(p-1)+lj, p-1}(t) for lj = 0..p-1. - // N'_{j,p-1} = (p-1)/(U[j+p-1]-U[j])*N_{j,p-2} - (p-1)/(U[j+p]-U[j+1])*N_{j+1,p-2} - // with N_{j,p-2} = b2[lj-1] and N_{j+1,p-2} = b2[lj]. - q1 = p - 1, - d1 = [for (lj = [0:1:q1]) - let( - j = s - q1 + lj, - e1 = U[s + lj] - U[j], // U[j+q1] - U[j] - e2 = U[s + lj + 1] - U[j + 1] // U[j+q1+1] - U[j+1] - ) - (lj > 0 && abs(e1) > 1e-15 ? q1 * b2[lj - 1] / e1 : 0) - - (lj < q1 && abs(e2) > 1e-15 ? q1 * b2[lj] / e2 : 0) - ], - - // Second lift: d2[lj] = N''_{s-p+lj, p}(t) for lj = 0..p. - // N''_{j,p} = p/(U[j+p]-U[j])*N'_{j,p-1} - p/(U[j+p+1]-U[j+1])*N'_{j+1,p-1} - // with N'_{j,p-1} = d1[lj-1] and N'_{j+1,p-1} = d1[lj]. - d2 = [for (lj = [0:1:p]) - let( - j = s - p + lj, - e1 = U[s + lj] - U[j], // U[j+p] - U[j] - e2 = U[s + lj + 1] - U[j + 1] // U[j+p+1] - U[j+1] - ) - (lj > 0 && abs(e1) > 1e-15 ? p * d1[lj - 1] / e1 : 0) - - (lj < p && abs(e2) > 1e-15 ? p * d1[lj] / e2 : 0) - ] - ) - d2; - - -// Bending-energy regularization matrix R for the null-space solver. -// R[j][k] = ∫ B''_j(t) B''_k(t) dt (integrated squared second derivative). -// For clamped: B_j = N_{j,p}, integrated over the full domain. -// For closed/periodic: B_j = N_j + (j

p → 0 for clamped; circular -// distance > p → 0 for periodic) with per-span second derivatives supplied by -// _d2nip_span: O(p²) per quadrature point instead of O(M·p²) with individual -// _d2nip() calls. - -function _bending_energy_matrix(M, p, U_full, periodic=false) = + let(inner = nurbs_interp_surface(new_pts, degree, method=method, + row_wrap=row_wrap, col_wrap=false, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, + row_edges=row_edges, col_edges=adj_ve, + extra_pts=extra_pts, smooth=smooth)) + [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], + [inner[6][0], + list_rotate(select(inner[6][1], 0, n_cols-1), -rot)]] + // row_edges on a closed u-direction: rotate rows so the first crease row + // becomes the u=0/u=1 boundary, append a copy at the end, recurse clamped. + : has_ue_pre && row_wrap ? + let( + ue_sorted = sort(ue_norm_pre), + rot = ue_sorted[0], + new_pts = concat([for (k = [rot:1:n_rows-1]) points[k]], + [for (k = [0:1:rot-1]) points[k]], + [points[rot]]), + adj_ue_raw = [for (i = [1:1:len(ue_sorted)-1]) + let(j = (ue_sorted[i] - rot + n_rows) % n_rows) + if (j > 0) j], + adj_ue = len(adj_ue_raw) == 0 ? undef : adj_ue_raw + ) + let(inner = nurbs_interp_surface(new_pts, degree, method=method, + row_wrap=false, col_wrap=col_wrap, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, + row_edges=adj_ue, col_edges=col_edges, + extra_pts=extra_pts, smooth=smooth)) + [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], + [list_rotate(select(inner[6][0], 0, n_rows-1), -rot), + inner[6][1]]] + // Normal path: both directions already clamped, or no conflicting edge constraints. + : let( + p_u = is_list(degree) ? degree[0] : degree, + p_v = is_list(degree) ? degree[1] : degree, + ep_u = is_list(extra_pts) ? extra_pts[0] : extra_pts, + ep_v = is_list(extra_pts) ? extra_pts[1] : extra_pts, + smooth_u = is_list(smooth) ? smooth[0] : smooth, + smooth_v = is_list(smooth) ? smooth[1] : smooth, + n_rows = len(points), + n_cols = len(points[0]), + dim = len(points[0][0]), + // Scalar-vector promotion: if the caller passes a single vector instead of + // a list of vectors, repeat() it to the required length. A single vector + // is detected as a list whose first element is a number, not a list. + first_row_deriv = is_undef(first_row_deriv) || is_list(first_row_deriv[0]) ? first_row_deriv + : repeat(first_row_deriv, n_cols), + last_row_deriv = is_undef(last_row_deriv) || is_list(last_row_deriv[0]) ? last_row_deriv + : repeat(last_row_deriv, n_cols), + first_col_deriv = is_undef(first_col_deriv) || is_list(first_col_deriv[0]) ? first_col_deriv + : repeat(first_col_deriv, n_rows), + last_col_deriv = is_undef(last_col_deriv) || is_list(last_col_deriv[0]) ? last_col_deriv + : repeat(last_col_deriv, n_rows), + // Treat an all-undef derivative list the same as undef. + has_sud = !is_undef(first_row_deriv) && num_defined(first_row_deriv) > 0, + has_eud = !is_undef(last_row_deriv) && num_defined(last_row_deriv) > 0, + has_svd = !is_undef(first_col_deriv) && num_defined(first_col_deriv) > 0, + has_evd = !is_undef(last_col_deriv) && num_defined(last_col_deriv) > 0, + has_sn = !is_undef(normal1), + has_en = !is_undef(normal2), + // normal1/normal2: apex edges only (all boundary points identical, e.g. cone tip). + // Auto-detect u=0/v=0 direction; u=0 (first row) takes priority. + start_u_apex = has_sn && max([for (pt = points[0]) norm(pt - points[0][0])]) < 1e-10, + start_v_apex = has_sn && max([for (k = [0:1:n_rows-1]) norm(points[k][0] - points[0][0])]) < 1e-10, + end_u_apex = has_en && max([for (pt = points[n_rows-1]) norm(pt - points[n_rows-1][0])]) < 1e-10, + end_v_apex = has_en && max([for (k = [0:1:n_rows-1]) norm(points[k][n_cols-1] - points[0][n_cols-1])]) < 1e-10, + has_sun = has_sn && start_u_apex, + has_eun = has_en && end_u_apex, + has_svn = has_sn && !start_u_apex && start_v_apex, + has_evn = has_en && !end_u_apex && end_v_apex, + start_u_degen = start_u_apex, + start_v_degen = start_v_apex, + end_u_degen = end_u_apex, + end_v_degen = end_v_apex, + // flat_end1/flat_end2: coplanar non-collinear edges (points span a plane). + // Scalar or per-point list. positive = closes inward, negative = flares outward. + // Direction is determined by the clamped direction of the surface: + // row_wrap=false → flat_end applies to row boundaries (u-direction, first/last row). + // col_wrap=false → flat_end applies to column boundaries (v-direction, first/last col). + // Exactly one direction must be clamped (enforced by assertion below). + has_fe1 = !is_undef(flat_end1), + has_fe2 = !is_undef(flat_end2), + has_fe1_u = has_fe1 && !row_wrap, + has_fe1_v = has_fe1 && !col_wrap, + has_fe2_u = has_fe2 && !row_wrap, + has_fe2_v = has_fe2 && !col_wrap, + // Boundary edges for coplanar validation. + fe1_edge = has_fe1_u ? points[0] + : has_fe1_v ? [for (k = [0:1:n_rows-1]) points[k][0]] + : [], + fe2_edge = has_fe2_u ? points[n_rows-1] + : has_fe2_v ? [for (k = [0:1:n_rows-1]) points[k][n_cols-1]] + : [], + fe1_ok = !has_fe1 || (_is_coplanar_pts(fe1_edge) && !is_undef(_pts_plane_normal(fe1_edge))), + fe2_ok = !has_fe2 || (_is_coplanar_pts(fe2_edge) && !is_undef(_pts_plane_normal(fe2_edge))), + // flat_edges= parsing: 4-element list [first_row, last_row, first_col, last_col]. + // Scalar shorthand: flat_edges=s expands to [s, s, s, s]. + fe_norm = !is_undef(flat_edges) && !is_list(flat_edges) + ? [flat_edges, flat_edges, flat_edges, flat_edges] + : flat_edges, + has_fe = !is_undef(fe_norm), + fe_su = has_fe ? fe_norm[0] : undef, + fe_eu = has_fe ? fe_norm[1] : undef, + fe_sv = has_fe ? fe_norm[2] : undef, + fe_ev = has_fe ? fe_norm[3] : undef, + has_fesu = has_fe && !is_undef(fe_su), + has_feeu = has_fe && !is_undef(fe_eu), + has_fesv = has_fe && !is_undef(fe_sv), + has_feev = has_fe && !is_undef(fe_ev), + // Edge (C0 discontinuity) support. Singleton promotion: scalar → list. + ue_norm = is_undef(row_edges) ? undef : force_list(row_edges), + ve_norm = is_undef(col_edges) ? undef : force_list(col_edges), + has_ue = !is_undef(ue_norm) && len(ue_norm) > 0, + has_ve = !is_undef(ve_norm) && len(ve_norm) > 0 + ) + assert(is_list(points) && n_rows >= 2, + "nurbs_interp_surface: need at least 2 rows") + assert(n_cols >= 2, + "nurbs_interp_surface: need at least 2 columns") + assert(min([for (row = points) len(row)]) == max([for (row = points) len(row)]), + "nurbs_interp_surface: all rows must have the same number of columns") + assert(is_num(p_u) && p_u >= 1 && is_num(p_v) && p_v >= 1, + "nurbs_interp_surface: degree must be >= 1") + assert(method == "length" || method == "centripetal" || method == "dynamic" + || method == "foley" || method == "fang", + str("nurbs_interp_surface: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) + assert(is_num(ep_u) && ep_u >= 0 && ep_u == floor(ep_u), + str("nurbs_interp_surface: extra_pts (u) must be a non-negative integer, got ", ep_u)) + assert(is_num(ep_v) && ep_v >= 0 && ep_v == floor(ep_v), + str("nurbs_interp_surface: extra_pts (v) must be a non-negative integer, got ", ep_v)) + assert(ep_u == 0 || p_u >= 2, + "nurbs_interp_surface: extra_pts in u-direction requires u-degree >= 2") + assert(ep_v == 0 || p_v >= 2, + "nurbs_interp_surface: extra_pts in v-direction requires v-degree >= 2") + assert(n_rows >= p_u + 1, + str("nurbs_interp_surface: need at least ", p_u+1, + " rows for u-degree ", p_u, ", got ", n_rows)) + assert(n_cols >= p_v + 1, + str("nurbs_interp_surface: need at least ", p_v+1, + " columns for v-degree ", p_v, ", got ", n_cols)) + assert(!(has_sud || has_eud || has_sun || has_eun || has_fesu || has_feeu || has_fe1_u || has_fe2_u) || !row_wrap, + "nurbs_interp_surface: u-direction derivative/normal/flat_end/flat_edges params require row_wrap=false") + assert(!(has_svd || has_evd || has_svn || has_evn || has_fesv || has_feev || has_fe1_v || has_fe2_v) || !col_wrap, + "nurbs_interp_surface: v-direction derivative/normal/flat_end/flat_edges params require col_wrap=false") + assert(!has_sud || len(first_row_deriv) == n_cols, + str("nurbs_interp_surface: first_row_deriv must have ", n_cols, + " entries (one per column), got ", is_undef(first_row_deriv) ? 0 : len(first_row_deriv))) + assert(!has_eud || len(last_row_deriv) == n_cols, + str("nurbs_interp_surface: last_row_deriv must have ", n_cols, + " entries (one per column), got ", is_undef(last_row_deriv) ? 0 : len(last_row_deriv))) + assert(!has_svd || len(first_col_deriv) == n_rows, + str("nurbs_interp_surface: first_col_deriv must have ", n_rows, + " entries (one per row), got ", is_undef(first_col_deriv) ? 0 : len(first_col_deriv))) + assert(!has_evd || len(last_col_deriv) == n_rows, + str("nurbs_interp_surface: last_col_deriv must have ", n_rows, + " entries (one per row), got ", is_undef(last_col_deriv) ? 0 : len(last_col_deriv))) + // normal1/normal2 assertions: apex edges only. + assert(!has_sn || (start_u_degen || start_v_degen), + "nurbs_interp_surface: normal1 requires a degenerate start edge (first row or first column must be all the same point)") + assert(!has_en || (end_u_degen || end_v_degen), + "nurbs_interp_surface: normal2 requires a degenerate end edge (last row or last column must be all the same point)") + assert(!has_sn || !(start_u_degen && start_v_degen), + "nurbs_interp_surface: normal1 is ambiguous — both u=0 and v=0 edges are degenerate; use first_row_deriv or first_col_deriv explicitly") + assert(!has_en || !(end_u_degen && end_v_degen), + "nurbs_interp_surface: normal2 is ambiguous — both u=1 and v=1 edges are degenerate; use last_row_deriv or last_col_deriv explicitly") + assert(!(has_sun && has_sud), + "nurbs_interp_surface: normal1 resolves to u-direction but first_row_deriv was also given") + assert(!(has_eun && has_eud), + "nurbs_interp_surface: normal2 resolves to u-direction but last_row_deriv was also given") + assert(!(has_svn && has_svd), + "nurbs_interp_surface: normal1 resolves to v-direction but first_col_deriv was also given") + assert(!(has_evn && has_evd), + "nurbs_interp_surface: normal2 resolves to v-direction but last_col_deriv was also given") + // flat_end1/flat_end2 assertions. + // Direction is determined by the clamped type; surface must be mixed clamped/closed. + assert(!has_fe1 || (row_wrap != col_wrap), + "nurbs_interp_surface: flat_end1 requires the surface to be clamped in one direction and closed in the other") + assert(!has_fe2 || (row_wrap != col_wrap), + "nurbs_interp_surface: flat_end2 requires the surface to be clamped in one direction and closed in the other") + assert(fe1_ok, + has_fe1_u + ? "nurbs_interp_surface: flat_end1 requires the first row (u=0 boundary) to be coplanar and non-collinear" + : "nurbs_interp_surface: flat_end1 requires the first column (v=0 boundary) to be coplanar and non-collinear. If your first row is coplanar, try row_wrap=true, col_wrap=false.") + assert(fe2_ok, + has_fe2_u + ? "nurbs_interp_surface: flat_end2 requires the last row (u=1 boundary) to be coplanar and non-collinear" + : "nurbs_interp_surface: flat_end2 requires the last column (v=1 boundary) to be coplanar and non-collinear. If your last row is coplanar, try row_wrap=true, col_wrap=false.") + assert(!(has_fe1_u && has_sud), + "nurbs_interp_surface: flat_end1 conflicts with first_row_deriv") + assert(!(has_fe2_u && has_eud), + "nurbs_interp_surface: flat_end2 conflicts with last_row_deriv") + assert(!(has_fe1_v && has_svd), + "nurbs_interp_surface: flat_end1 conflicts with first_col_deriv") + assert(!(has_fe2_v && has_evd), + "nurbs_interp_surface: flat_end2 conflicts with last_col_deriv") + assert(!(has_fe1_u && has_fesu), + "nurbs_interp_surface: flat_end1 conflicts with flat_edges[0] on same edge") + assert(!(has_fe2_u && has_feeu), + "nurbs_interp_surface: flat_end2 conflicts with flat_edges[1] on same edge") + assert(!(has_fe1_v && has_fesv), + "nurbs_interp_surface: flat_end1 conflicts with flat_edges[2] on same edge") + assert(!(has_fe2_v && has_feev), + "nurbs_interp_surface: flat_end2 conflicts with flat_edges[3] on same edge") + assert(!has_fe1 || is_num(flat_end1) || len(flat_end1) == (has_fe1_u ? n_cols : n_rows), + str("nurbs_interp_surface: flat_end1 list must have ", has_fe1_u ? n_cols : n_rows, " entries")) + assert(!has_fe2 || is_num(flat_end2) || len(flat_end2) == (has_fe2_u ? n_cols : n_rows), + str("nurbs_interp_surface: flat_end2 list must have ", has_fe2_u ? n_cols : n_rows, " entries")) + // flat_edges assertions. + assert(!has_fe || (is_list(fe_norm) && len(fe_norm) == 4), + "nurbs_interp_surface: flat_edges must be a scalar or 4-element list [first_row, last_row, first_col, last_col]") + assert(!(has_fesu && has_sud), + "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with first_row_deriv") + assert(!(has_feeu && has_eud), + "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with last_row_deriv") + assert(!(has_fesv && has_svd), + "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with first_col_deriv") + assert(!(has_feev && has_evd), + "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with last_col_deriv") + assert(!(has_fesu && has_sun), + "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with normal1 on same edge") + assert(!(has_feeu && has_eun), + "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with normal2 on same edge") + assert(!(has_fesv && has_svn), + "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with normal1 on same edge") + assert(!(has_feev && has_evn), + "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with normal2 on same edge") + assert(!has_fesu || !is_list(fe_su) || len(fe_su) == n_cols, + str("nurbs_interp_surface: flat_edges[0] scale list must have ", n_cols, " entries (one per column)")) + assert(!has_feeu || !is_list(fe_eu) || len(fe_eu) == n_cols, + str("nurbs_interp_surface: flat_edges[1] scale list must have ", n_cols, " entries (one per column)")) + assert(!has_fesv || !is_list(fe_sv) || len(fe_sv) == n_rows, + str("nurbs_interp_surface: flat_edges[2] scale list must have ", n_rows, " entries (one per row)")) + assert(!has_feev || !is_list(fe_ev) || len(fe_ev) == n_rows, + str("nurbs_interp_surface: flat_edges[3] scale list must have ", n_rows, " entries (one per row)")) + // Edge (C0) validation. + assert(!has_ue || !row_wrap, + "nurbs_interp_surface: row_edges requires row_wrap=false") + assert(!has_ve || !col_wrap, + "nurbs_interp_surface: col_edges requires col_wrap=false") + assert(!has_ue || (min(ue_norm) >= 1 && max(ue_norm) <= n_rows-2), + str("nurbs_interp_surface: row_edges indices must be interior (1..", n_rows-2, ")")) + assert(!has_ve || (min(ve_norm) >= 1 && max(ve_norm) <= n_cols-2), + str("nurbs_interp_surface: col_edges indices must be interior (1..", n_cols-2, ")")) + // row_edges / col_edges are compatible with same-direction boundary derivatives, + // normals, and flat_edges: the first/last segment of the edge-aware system + // carries the boundary derivative constraint. let( - n_gauss = max(2, p - 1), - gl = _gauss_legendre(n_gauss), - gl_nodes = gl[0], - gl_wts = gl[1], - n_knots = len(U_full), - span_lo = periodic ? p : 0, - span_hi = periodic ? M + p - 1 : n_knots - 2, - - // Per-quadrature-point data: [span_index, weight, d2_local]. - // d2_local[lj] = N''_{s-p+lj, p}(t) for lj = 0..p (p+1 unaliased values). - quad_data = [for (i = [span_lo:1:span_hi]) - if (U_full[i+1] - U_full[i] > 1e-15) - let(a = U_full[i], b = U_full[i+1], - hw = (b - a) / 2, mid = (a + b) / 2) - for (g = [0:1:n_gauss-1]) - let(t = mid + hw * gl_nodes[g], - w = gl_wts[g] * hw) - [i, w, _d2nip_span(i, p, t, U_full)] - ], - nq = len(quad_data) + // Boundary plane for flat_edges=: cross product of two perimeter vectors. + // Guarded so degenerate geometry can't produce NaN when flat_edges is unused. + fe_e1 = has_fe ? (points[0][n_cols-1] - points[0][0]) : [1,0,0], + fe_e2 = has_fe ? (points[n_rows-1][0] - points[0][0]) : [0,1,0], + fe_N_raw = has_fe ? cross(fe_e1, fe_e2) : [0,0,1], + fe_N_hat = fe_N_raw / max(norm(fe_N_raw), 1e-15), + // Per-edge flat-outward derivative lists; undef when edge not active. + // Direction at each point: from adjacent interior point toward edge, + // projected into the boundary plane, then normalized and scaled. + flat_su_der = !has_fesu ? undef : + [for (j = [0:1:n_cols-1]) + let( + d = points[1][j] - points[0][j], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_su) ? fe_su[j] : fe_su + ) d_hat * s], + flat_eu_der = !has_feeu ? undef : + [for (j = [0:1:n_cols-1]) + let( + d = points[n_rows-1][j] - points[n_rows-2][j], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_eu) ? fe_eu[j] : fe_eu + ) d_hat * s], + flat_sv_der = !has_fesv ? undef : + [for (k = [0:1:n_rows-1]) + let( + d = points[k][1] - points[k][0], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_sv) ? fe_sv[k] : fe_sv + ) d_hat * s], + flat_ev_der = !has_feev ? undef : + [for (k = [0:1:n_rows-1]) + let( + d = points[k][n_cols-1] - points[k][n_cols-2], + d_flat = d - (d * fe_N_hat) * fe_N_hat, + d_hat = d_flat / max(norm(d_flat), 1e-15), + s = is_list(fe_ev) ? fe_ev[k] : fe_ev + ) d_hat * s] ) - // Banded assembly: skip entries where j and k have no overlapping support. - // Clamped: zero when |j-k| > p. - // Periodic: zero when circular distance min(|j-k|, M-|j-k|) > p. - [for (j = [0:1:M-1]) - [for (k = [0:1:M-1]) - (periodic ? min(abs(j - k), M - abs(j - k)) > p : abs(j - k) > p) - ? 0 - : sum([for (q = [0:1:nq-1]) - let( - s = quad_data[q][0], - w = quad_data[q][1], - d2v = quad_data[q][2], - // Local indices of global bases j and k in this span. - lj = j - (s - p), - lk = k - (s - p), - // Periodic aliasing: unaliased index j+M (resp. k+M) - // may also land in the support [s-p, s] of this span. - lj_a = periodic ? j + M - (s - p) : -1, - lk_a = periodic ? k + M - (s - p) : -1, - // Direct values (unaliased index in support of span s). - vj = (lj >= 0 && lj <= p) ? d2v[lj] : 0, - vk = (lk >= 0 && lk <= p) ? d2v[lk] : 0, - // Aliased values (only for j < p with j+M in support). - vj_a = (periodic && j < p && lj_a >= 0 && lj_a <= p) ? d2v[lj_a] : 0, - vk_a = (periodic && k < p && lk_a >= 0 && lk_a <= p) ? d2v[lk_a] : 0, - Bj = vj + vj_a, - Bk = vk + vk_a - ) - w * Bj * Bk - ]) - ] - ]; - - -// Regularization matrix dispatcher. -// Returns an M×M regularization matrix: L^T L difference matrix when smooth<=2, -// integrated squared second-derivative (bending energy) matrix otherwise. - -function _regularization_matrix(M, smooth, p, U_full, periodic=false) = - smooth <= 2 - ? [for (i = [0:1:M-1]) _ltl_row(M, i, smooth, periodic=periodic)] - : _bending_energy_matrix(M, p, U_full, periodic=periodic); - - -// Full periodic knot vector for "closed" type evaluation. -// Uses BOSL2's _extend_knot_vector() to build the n+2p+1 entry knot vector -// that nurbs_curve() constructs internally for closed-type curves. -// Active evaluation domain: [U[p], U[n+p]]. - -function _full_closed_knots(bar_knots, n, p) = - _extend_knot_vector(bar_knots, 0, n + 2*p + 1); - + assert(!has_fesu || min([for (j = [0:1:n_cols-1]) let(d = points[1][j] - points[0][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[0] (first_row) direction is perpendicular to the boundary plane at one or more points") + assert(!has_feeu || min([for (j = [0:1:n_cols-1]) let(d = points[n_rows-1][j] - points[n_rows-2][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[1] (last_row) direction is perpendicular to the boundary plane at one or more points") + assert(!has_fesv || min([for (k = [0:1:n_rows-1]) let(d = points[k][1] - points[k][0], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[2] (first_col) direction is perpendicular to the boundary plane at one or more points") + assert(!has_feev || min([for (k = [0:1:n_rows-1]) let(d = points[k][n_cols-1] - points[k][n_cols-2], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, + "nurbs_interp_surface: flat_edges[3] (last_col) direction is perpendicular to the boundary plane at one or more points") + assert(!has_fe || is_coplanar(concat( + points[0], points[n_rows-1], + [for (k = [1:1:n_rows-2]) points[k][0]], + [for (k = [1:1:n_rows-2]) points[k][n_cols-1]]), eps=1e-6), + "nurbs_interp_surface: flat_edges= requires all four boundary edges to be coplanar") + let( + // Compute effective derivative lists. + // Priority: normal1/normal2 (apex) > flat_end1/flat_end2 (coplanar) > flat_edges > explicit *_der=. + // Apex (all boundary points identical): fan outward from apex, user axis vector N. + // End-edge apex tangents are negated because _apex_tangents() returns outward + // (apex→ring) vectors; negating gives inward (ring→apex), making the surface + // converge to the apex tip at the correct parametric direction. + // Coplanar (flat_end): _coplanar_inward_tangents() returns in-plane vectors + // oriented toward the polygon interior using the polygon winding order. + // Positive scale closes inward, negative flares outward. + // flat_end1 result is negated: _coplanar_inward_tangents returns outward + // for the start boundary; negating gives the correct inward direction. + // flat_end2 uses the same function without negation (end boundary sign matches). + // Periodic tangent differences used when the cross-direction is "closed". + first_row_deriv_eff = has_sun + ? _apex_tangents(normal1, points[0][0], points[1]) + : has_fe1_u + ? [for (v = _coplanar_inward_tangents(flat_end1, points[0], points[1], + periodic=col_wrap)) -v] + : has_fesu ? flat_su_der + : first_row_deriv, + last_row_deriv_eff = has_eun + ? [for (v = _apex_tangents(normal2, points[n_rows-1][0], points[n_rows-2])) -v] + : has_fe2_u + ? _coplanar_inward_tangents(flat_end2, points[n_rows-1], points[n_rows-2], + periodic=col_wrap) + : has_feeu ? flat_eu_der + : last_row_deriv, + first_col_deriv_eff = has_svn + ? _apex_tangents(normal1, points[0][0], + [for (k = [0:1:n_rows-1]) points[k][1]]) + : has_fe1_v + ? [for (v = _coplanar_inward_tangents(flat_end1, + [for (k = [0:1:n_rows-1]) points[k][0]], + [for (k = [0:1:n_rows-1]) points[k][1]], + periodic=row_wrap)) -v] + : has_fesv ? flat_sv_der + : first_col_deriv, + last_col_deriv_eff = has_evn + ? [for (v = _apex_tangents(normal2, points[0][n_cols-1], + [for (k = [0:1:n_rows-1]) points[k][n_cols-2]])) -v] + : has_fe2_v + ? _coplanar_inward_tangents(flat_end2, + [for (k = [0:1:n_rows-1]) points[k][n_cols-1]], + [for (k = [0:1:n_rows-1]) points[k][n_cols-2]], + periodic=row_wrap) + : has_feev ? flat_ev_der + : last_col_deriv, + has_sud_eff = has_sud || has_sun || has_fesu || has_fe1_u, + has_eud_eff = has_eud || has_eun || has_feeu || has_fe2_u, + has_svd_eff = has_svd || has_svn || has_fesv || has_fe1_v, + has_evd_eff = has_evd || has_evn || has_feev || has_fe2_v + ) + // row_edges / col_edges boundary-derivative segment-size checks. + // A derivative-carrying edge segment needs at least 3 rows/columns; + // with only 2 the degree-reduced knot vector becomes degenerate. + assert(!(has_ue && has_sud_eff && ue_norm[0] + 1 < 3), + !has_ue ? "" : + str("nurbs_interp_surface: row_edges=", ue_norm, + " creates a ", ue_norm[0]+1, "-row first segment (rows 0-", + ue_norm[0], ") which is too short to carry the start-u derivative constraint. ", + "Move the first row_edges index to at least 2")) + assert(!(has_ue && has_eud_eff && n_rows - last(ue_norm) < 3), + !has_ue ? "" : + str("nurbs_interp_surface: row_edges=", ue_norm, + " creates a ", n_rows - last(ue_norm), "-row last segment (rows ", + last(ue_norm), "-", n_rows-1, ") which is too short to carry the end-u derivative constraint. ", + "Move the last row_edges index to at most ", n_rows - 3)) + assert(!(has_ve && has_svd_eff && ve_norm[0] + 1 < 3), + !has_ve ? "" : + str("nurbs_interp_surface: col_edges=", ve_norm, + " creates a ", ve_norm[0]+1, "-column first segment (columns 0-", + ve_norm[0], ") which is too short to carry the start-v derivative constraint. ", + "Move the first col_edges index to at least 2")) + assert(!(has_ve && has_evd_eff && n_cols - last(ve_norm) < 3), + !has_ve ? "" : + str("nurbs_interp_surface: col_edges=", ve_norm, + " creates a ", n_cols - last(ve_norm), "-column last segment (columns ", + last(ve_norm), "-", n_cols-1, ") which is too short to carry the end-v derivative constraint. ", + "Move the last col_edges index to at most ", n_cols - 3)) + let( + // Averaged parameterization in each direction + u_params = _surface_params_u(points, method, row_wrap), + v_params = _surface_params_v(points, method, col_wrap), -// Collocation Matrices + // Per-row v-direction path lengths for scaling v-boundary tangents. + // Follows the curve convention: user passes normalized vectors; code + // scales by total chord length so a unit vector gives natural speed. + v_path_lens = [for (k = [0:1:n_rows-1]) path_length(points[k])], -// Standard collocation matrix for clamped type. + // Per-column u-direction path lengths for scaling u-boundary tangents. + u_path_lens = [for (l = [0:1:n_cols-1]) + path_length([for (k = [0:1:n_rows-1]) points[k][l]])], -function _collocation_matrix(params, n, p, U) = - [for (k = [0:1:n]) - [for (j = [0:1:n]) - _nip(j, p, params[k], U) - ] - ]; + // ----- Build v-direction system ----- + // When col_edges is active, precompute per-segment collocation systems. + // Otherwise use the standard (or derivative-extended) system. + v_edge_sys = has_ve + ? _build_edge_systems(v_params, p_v, ve_norm, + has_sd=has_svd_eff, + has_ed=has_evd_eff, + extra_pts=ep_v, label="v") : undef, + v_sys = has_ve ? undef + : (has_svd_eff || has_evd_eff) + ? _build_clamped_system_with_derivs(v_params, p_v, has_svd_eff, has_evd_eff, ep_v) + : _build_interp_system(v_params, p_v, col_wrap ? "closed" : "clamped", ep_v), + N_v = has_ve ? undef : v_sys[0], + // When underdetermined (extra_pts), build regularization matrix for v. + M_v = has_ve ? undef : len(N_v[0]), + N_rows_v = has_ve ? undef : len(N_v), + ns_v = !has_ve && M_v > N_rows_v, + R_reg_v = !ns_v ? undef + : let(vk = v_sys[1], + vint = !col_wrap + ? [for (i = [1:1:len(vk)-2]) vk[i]] + : undef, + vU = !col_wrap + ? _full_clamped_knots(vint, p_v) + : _full_closed_knots(vk, M_v, p_v)) + _regularization_matrix(M_v, smooth_v, p_v, vU, periodic=col_wrap), + // ----- Pass 1: Interpolate rows in v-direction ----- + // With col_edges: solve each row via edge-aware segmented system. + // Without: same A_v matrix for every row; only the RHS changes per row. + R_raw = has_ve + ? [for (k = [0:1:n_rows-1]) + _solve_with_edges(v_edge_sys, points[k], + v_params, ve_norm, p_v, + start_deriv = has_svd_eff + ? _force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k] + : undef, + end_deriv = has_evd_eff + ? _force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k] + : undef, + smooth = smooth_v)] + : undef, + R = has_ve + ? [for (r = R_raw) r[0]] + : [for (k = [0:1:n_rows-1]) + let(rhs = concat( + points[k], + has_svd_eff + ? [_force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k]] + : [], + has_evd_eff + ? [_force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k]] + : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, rhs) + : linear_solve(N_v, rhs) + ], -// Periodic collocation matrix for closed type (n x n). -// -// BOSL2 wraps the first p control points to the end, creating n+p -// basis functions. Basis N_{j+n} aliases control point j for j= p + v_knots = has_ve ? R_raw[0][1] : v_sys[1], + n_v_ctrl = len(R[0]), -function _collocation_matrix_periodic(params, n, p, U_periodic) = - [for (k = [0:1:n-1]) - [for (j = [0:1:n-1]) - _nip(j, p, params[k], U_periodic) - + (j < p ? _nip(j + n, p, params[k], U_periodic) : 0) - ] - ]; + // ----- Pass 1.5: Project u-boundary tangents into v-control space ----- + // ∂S/∂u along u=0 or u=1 is given at the n_cols data v-positions. + // To use them as derivative RHS in the u-direction column solves, we + // must express them in the v B-spline control basis — done by solving + // the same v-system. When col_edges is active, project through the + // edge-aware segmented system instead. + zero_v = repeat(0, dim), + _su_der_data = has_sud_eff + ? [for (l = [0:1:n_cols-1]) + _force_deriv_dim(first_row_deriv_eff[l], dim) * u_path_lens[l]] + : undef, + _eu_der_data = has_eud_eff + ? [for (l = [0:1:n_cols-1]) + _force_deriv_dim(last_row_deriv_eff[l], dim) * u_path_lens[l]] + : undef, + T_u_start = has_sud_eff + ? has_ve + ? _solve_with_edges(v_edge_sys, _su_der_data, + v_params, ve_norm, p_v, + start_deriv = has_svd_eff ? zero_v : undef, + end_deriv = has_evd_eff ? zero_v : undef, + smooth = smooth_v)[0] + : let(_rhs = concat(_su_der_data, + has_svd_eff ? [zero_v] : [], + has_evd_eff ? [zero_v] : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) + : linear_solve(N_v, _rhs) + : undef, + T_u_end = has_eud_eff + ? has_ve + ? _solve_with_edges(v_edge_sys, _eu_der_data, + v_params, ve_norm, p_v, + start_deriv = has_svd_eff ? zero_v : undef, + end_deriv = has_evd_eff ? zero_v : undef, + smooth = smooth_v)[0] + : let(_rhs = concat(_eu_der_data, + has_svd_eff ? [zero_v] : [], + has_evd_eff ? [zero_v] : [])) + ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) + : linear_solve(N_v, _rhs) + : undef, + // ----- Build u-direction system ----- + // When row_edges is active, precompute per-segment systems. + u_edge_sys = has_ue + ? _build_edge_systems(u_params, p_u, ue_norm, + has_sd=has_sud_eff, + has_ed=has_eud_eff, + extra_pts=ep_u, label="u") : undef, + u_sys = has_ue ? undef + : (has_sud_eff || has_eud_eff) + ? _build_clamped_system_with_derivs(u_params, p_u, has_sud_eff, has_eud_eff, ep_u) + : _build_interp_system(u_params, p_u, row_wrap ? "closed" : "clamped", ep_u), + N_u = has_ue ? undef : u_sys[0], + // When underdetermined (extra_pts), build regularization matrix for u. + M_u = has_ue ? undef : len(N_u[0]), + N_rows_u = has_ue ? undef : len(N_u), + ns_u = !has_ue && M_u > N_rows_u, + R_reg_u = !ns_u ? undef + : let(uk = u_sys[1], + uint = !row_wrap + ? [for (i = [1:1:len(uk)-2]) uk[i]] + : undef, + uU = !row_wrap + ? _full_clamped_knots(uint, p_u) + : _full_closed_knots(uk, M_u, p_u)) + _regularization_matrix(M_u, smooth_u, p_u, uU, periodic=row_wrap), -// Degree Elevation + // ----- Pass 2: Interpolate columns in u-direction ----- + // Transpose R so each entry is a column of intermediate points. + R_T = [for (j = [0:1:n_v_ctrl-1]) + [for (k = [0:1:n_rows-1]) R[k][j]]], -// Greville abscissae for B-spline basis of degree p with full knot -// vector U. Returns n+1 values where n = len(U) - p - 2. Each g_i -// is the average of knots U[i+1] .. U[i+p]. For a clamped knot -// vector, g_0 = 0 and g_n = 1. These are optimal collocation sites -// for the B-spline space and automatically satisfy the Schoenberg- -// Whitney condition for non-singular collocation. + // With row_edges: solve each column via edge-aware segmented system. + // Without: add u-tangent constraint rows to the RHS for each column j. + P_T_raw = has_ue + ? [for (j = [0:1:n_v_ctrl-1]) + _solve_with_edges(u_edge_sys, R_T[j], + u_params, ue_norm, p_u, + start_deriv = has_sud_eff ? T_u_start[j] : undef, + end_deriv = has_eud_eff ? T_u_end[j] : undef, + smooth = smooth_u)] + : undef, + P_T = has_ue + ? [for (r = P_T_raw) r[0]] + : [for (j = [0:1:n_v_ctrl-1]) + let(rhs = concat( + R_T[j], + has_sud_eff ? [T_u_start[j]] : [], + has_eud_eff ? [T_u_end[j]] : [])) + ns_u ? _nullspace_solve(R_reg_u, N_u, rhs) + : linear_solve(N_u, rhs) + ], -function _greville(U, p) = - let(n = len(U) - p - 2) - [for (i = [0:1:n]) - sum([for (j = [i+1:1:i+p]) U[j]]) / p - ]; + u_knots = has_ue ? P_T_raw[0][1] : u_sys[1], + // Transpose back to get the final control point grid. + n_u_ctrl = len(P_T[0]), + P = [for (i = [0:1:n_u_ctrl-1]) + [for (j = [0:1:n_v_ctrl-1]) P_T[j][i]]] + ) + [[row_wrap ? "closed" : "clamped", col_wrap ? "closed" : "clamped"], + [p_u, p_v], P, [u_knots, v_knots], undef, undef, + [u_params, v_params]]; -// Increment the multiplicity of every distinct value in a knot vector -// by 1. Walk the vector; at the end of each run of equal values emit -// one extra copy. Equivalent to the new_interior construction in -// _elevate_once_clamped but applied to the complete (full) knot vector. -// Used by _elevate_once_open. -function _increment_knot_mults(U) = - [for (i = [0:1:len(U)-1]) each - [U[i], - if (i == len(U)-1 || abs(U[i+1] - U[i]) > 1e-14) U[i]] - ]; +module nurbs_interp_surface(points, degree, splinesteps=16, + method="centripetal", + row_wrap=false, col_wrap=false, + style="default", reverse=false, triangulate=false, + caps=undef, cap1=undef, cap2=undef, + first_row_deriv=undef, last_row_deriv=undef, + first_col_deriv=undef, last_col_deriv=undef, + normal1=undef, normal2=undef, + flat_end1=undef, flat_end2=undef, + flat_edges=undef, + row_edges=undef, col_edges=undef, + extra_pts=0, smooth=3, + data_color="red", data_size=0, + atype="hull", convexity=10, cp="centroid", anchor="origin", spin=0, orient=UP +) + { + result = nurbs_interp_surface(points, degree, + method=method, row_wrap=row_wrap, col_wrap=col_wrap, + first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, + first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, + normal1=normal1, normal2=normal2, + flat_end1=flat_end1, flat_end2=flat_end2, + flat_edges=flat_edges, + row_edges=row_edges, col_edges=col_edges, + extra_pts=extra_pts, smooth=smooth); + nurbs_vnf(result, splinesteps=splinesteps, style=style, + reverse=reverse, triangulate=triangulate, + caps=caps, cap1=cap1, cap2=cap2, convexity=convexity, atype=atype, anchor=anchor, spin=spin, orient=orient) children(); + if (data_size > 0) + color(data_color) + for (row = points) + for (pt = row) + translate(pt) sphere(r=data_size, $fn=16); +} -// Single degree elevation of a clamped or open B-spline via exact collocation. +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // -// The elevated curve lies in the degree-(p+1) B-spline space whose knot -// vector has each distinct value's multiplicity incremented by 1. -// Evaluating the original curve at the Greville abscissae of the new basis -// and solving the collocation system recovers the exact elevated control -// points (the new space contains the original curve exactly). +// Code after this point was written by Claude to provide interpolation. +// Algorithm from Piegl & Tiller, "The NURBS Book", Chapters 2 & 9. // -// Input ctrl = control points (any dimension >= 1) -// p = current degree (>= 1) -// U = full expanded knot vector (all multiplicities present) -// Output [new_ctrl, U_new, p+1] -// U_new is the full expanded elevated knot vector. +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -function _elevate_once(ctrl, p, U) = - let( - n_old = len(ctrl) - 1, - dim = len(ctrl[0]), - p_new = p + 1, - U_new = _increment_knot_mults(U), - n_new = len(U_new) - p_new - 2, - grev = _greville(U_new, p_new), - C_vals = [for (u = grev) - let(row = [for (j = [0:1:n_old]) _nip(j, p, u, U)]) - [for (d = [0:1:dim-1]) - sum([for (j = [0:1:n_old]) row[j] * ctrl[j][d]])] - ], - A = [for (k = [0:1:n_new]) - [for (i = [0:1:n_new]) _nip(i, p_new, grev[k], U_new)] - ], - Q = linear_solve(A, C_vals) - ) - assert(Q != [], - "nurbs_elevate_degree: singular collocation (should not happen)") - [Q, U_new, p_new]; +// Internal B-spline Basis Functions + +// Cox-de Boor recursive B-spline basis function N_{i,p}(u). +// Returns 0 for out-of-range indices (safe for periodic evaluation). + +function _nip(i, p, u, U) = + let(maxidx = len(U) - 1) + (i < 0 || i + p + 1 > maxidx) ? 0 + : p == 0 + ? (u >= U[i] && u < U[i+1]) ? 1 + : (abs(u - U[i+1]) < 1e-12 && abs(U[i+1] - U[maxidx]) < 1e-12) ? 1 + : 0 + : let( + d1 = U[i+p] - U[i], + d2 = U[i+p+1] - U[i+1], + c1 = abs(d1) > 1e-15 + ? (u - U[i]) / d1 * _nip(i, p-1, u, U) : 0, + c2 = abs(d2) > 1e-15 + ? (U[i+p+1] - u) / d2 * _nip(i+1, p-1, u, U) : 0 + ) + c1 + c2; +// Derivative of B-spline basis N_{j,p}'(u). +// Standard recurrence (P&T §2.3 eq. 2.9); zero-length spans are guarded. +function _dnip(j, p, u, U) = + p == 0 ? 0 + : let( + d1 = U[j+p] - U[j], + d2 = U[j+p+1] - U[j+1] + ) + (abs(d1) > 1e-15 ? p * _nip(j, p-1, u, U) / d1 : 0) + - (abs(d2) > 1e-15 ? p * _nip(j+1, p-1, u, U) / d2 : 0); -// Section: NURBS Interpolation +// Second derivative of B-spline basis N_{j,p}''(u). +// Same recurrence as _dnip applied once more (P&T §2.3 eq. 2.9); +// zero-length spans are guarded. Returns 0 for p ≤ 1. -// Function: nurbs_interp() -// Synopsis: Finds a NURBS curve passing through a point list with optional derivative constraints. -// Topics: NURBS Curves, Interpolation -// See Also: nurbs_curve(), debug_nurbs(), debug_nurbs_interp() -// -// Usage: -// nurbs_param = nurbs_interp(points, degree, [method=], [closed=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [deriv=], [extra_pts=], [smooth=]); +function _d2nip(j, p, u, U) = + p <= 1 ? 0 + : let( + d1 = U[j+p] - U[j], + d2 = U[j+p+1] - U[j+1] + ) + (abs(d1) > 1e-15 ? p * _dnip(j, p-1, u, U) / d1 : 0) + - (abs(d2) > 1e-15 ? p * _dnip(j+1, p-1, u, U) / d2 : 0); + + +// Input Helpers + +// Validate and coerce a single derivative vector to the required dimension. // -// Description: -// Given a list of data points and a NURBS degree, computes a curve of the specified degree -// that passes exactly through every data point. The computed curve always has -// uniform weights, but irregularly spaced knots, so it is actually a non-uniform B-spline. -// Data points may 2D or any higher dimension. Returns a NURBS parameter list of the form -// `[type, degree, control_points, knots, undef, undef, u]` that can be -// passed directly to {{nurbs_curve()}} and other NURBS functions. The extra return value `u`, -// described in detail below, enables you to locate your input points in the computed spline -// . -// When `closed=false` (the default) the output is a "clamped" NURBS. -// When `closed=true`, the interpolation treats the data points as a loop and produces a -// curve that is smooth at the closing point. The output will be a "closed" NURBS (unless you -// specify corners as described below). -// If you instead duplicate the closing point and set `closed=false` then the -// result will have a corner at the closing point. -// . -// **Parameterization** (`method=`) -// . -// In order to solve the interpolation problem, the algorithm first chooses -// the NURBS parameter value `u[k]` that will correspond to each `points[k]`. -// This parametrization step significantly affects the shape of the output curve, particularly when the -// data points are not evenly spaced. The following methods are supported: -// . -// - `"length"` — Base parameters values on the chord length, which is distance between the consecutive data points. -// Best when data points are fairly evenly spaced. -// - `"centripetal"` (default) — Base parameters values on the square root of the chord length. (Lee 1989). -// - `"dynamic"` — like centripetal, but the exponent 0.5 is replaced -// by a per-chord value chosen based on local spacing variation. Long chords -// get a smaller exponent and short chords a larger one, compressing the -// influence of outliers. Chord lengths are normalized, which makes the method scale -// invariant and prevents misbehavior at extreme scales. Scaling is not given in the original reference. (Balta et al. 2020). -// - `"foley"` — centripetal base, augmented by corrections at each point that -// are proportional to the local turn angle. Sharp bends pull parameter values -// closer together, which tends to reduce overshoot at corners (Foley & Neilson 1987). -// - `"fang"` — centripetal base, augmented by a correction based on the radius -// of the osculating circle at each point. Said to handles mixed straight-and-curved -// segments particularly well. This method is NOT scale invariant, so results will -// change if you scale your input data. (Fang & Hung 2013). -// . -// The other required input to the interpolation is the location of the knots. -// We place knots using a moving average of `degree` consecutive parameter values, which links -// the knots to the local parameter spacing. A consequence of this process for selection -// of the parameters and knot locations is that even if your input data has symmetry it is -// likely that the symmetry will be broken in the output. For closed curves, another -// consequence is that the resulting curve will depend on which point is chosen as the -// starting point for the interpolation. The algorithm chooses a starting point -// that is expected to provide the best behaved interpolation curve. Examining the -// knot positions with {{debug_nurbs_interp()}} may help you understand unexpected behavior -// you observe in the output. If your curve does not -// behave as desired you may be able to adjust it by imposing additional constraints or -// by giving it more freedom using `extra_pts`. -// . -// **Derivative constraints** (`deriv=`, `start_deriv=`, `end_deriv=`) -// . -// `deriv[k]` specifies the tangent direction and speed the curve must have -// as it passes through `points[k]`. The length of `deriv[k]` gives the speed -// as a multiple of `path_length(points)` which means a unit vector gives a natural -// speed that is a good starting point. -// The speed has a big effect on the shape of the curve, so if the local shape is -// not as you desire you should try increasing it, which will make the curve around -// the point flatter or decreasing it, which will make the curve more pointy. -// Set `deriv[k] = undef` to leave point `k` unconstrained. -// If you only want to set the derivative at the ends of a "clamped" curve you can use -// `start_deriv=` and `end_deriv=`, which set -// `deriv[0]` and `last(deriv)` without the need to provide a list of undefs for all the interior points. -// . -// **Curvature constraints** (`curvature=`, `start_curvature=`, `end_curvature=`) -// . -// The curvature at a point measures how tightly a curve bends. -// When a point has curvature $\kappa$ then a circle with radius $1/\kappa$ -// locally matches the curve at that point so both its first and second derivatives agree. -// This matched circle is called the osculating circle. When you set `curvature[k]` this -// constrains the curvature at `points[k]`. Every curvature-constrained point **must** also have a derivative constraint -// at the same index. Curvature constraints require a degree of at least 2. -// . -// In general curvature constraints require the curvature **vector**, which -// points in the direction of the osculating circle and has length equal to the curvature. -// The curvature vector must be orthogonal to the tangent vector at the point; -// when you specify a curvature vector any component parallel to the tangent is removed. -// The magnitude of the curvature is taken as the magnitude of your original input vector, -// even if subtracting the tangent component changes its length. -// For 2D curves you can also provide curvature as a scalar, with the sign indicating direction. -// (positive = left/CCW, negative = right/CW). -// . -// You can specify the curvature at the ends of "clamped" curves using -// `start_curvature=` and `end_curvature=`, which specify `curvature[0]` -// and `last(curvature)` without the need to create undefs for all the interior points. -// . -// **Corners** (`corners=`) -// . -// `corners=` is a list of interior point indices where the curve has -// a corner, a discontinuity in the derivative. You can also specify a corner -// at point `k` by setting `deriv[k]=NAN`. When you request corners, the -// algorithm chops up the input data into separate clamped splines that run from corner -// to corner. When `closed=true` this results in a "clamped" output spline, and the curve -// will start at one of your corner points. -// If you place corners close together, the effective degree of the short segment -// in between the corners may be reduced. These curve sections are assembled into a single -// NURBS so this process is transparent to the user. A limitation is that you cannot control -// the dervatives of the two segments that meet at a corner. If you need to do this you -// must construct your own sequence of clamped interpolations. -// . -// **Extra control points** (`extra_pts=`, `smooth=`) -// . -// By default, the solver uses exactly as many control points as are needed to -// satisfy the interpolation and constraint conditions, which gives a unique -// solution. This unique solution may be badly behaved, with undesirable oscillations. -// You can improve the behavior by requesting extra points. -// Specifying `extra_pts=N` inserts `N` additional control points and knots, making the -// system underdetermined: infinitely many curves pass through the data points and satisfy -// the constraints. The solver picks the one that satisfies -// a smoothness criterion specified by `smooth=`: -// . -// - `smooth=1` — minimises the sum of squared differences between consecutive -// control points. This tends to keep the control polygon short and reduces -// large-scale variation in the curve. -// - `smooth=2` — minimises the sum of squared second differences of the control -// points. This penalises bending in the control polygon, generally producing -// a fairer, less wiggly curve than `smooth=1`. -// - `smooth=3` (default) — minimises the integrated squared second derivative -// $\int \|\mathbf{C}''(t)\|^2 \, dt$, often called the *bending energy* of -// the curve. Unlike `smooth=2`, which only looks at the control polygon, -// this criterion acts directly on the curve shape and is the most -// mathematically principled choice for smooth interpolation. Requires -// `degree >= 2`. -// . -// The number of extra control points cannot exceed the number of knot spans. -// If you request too many, the number is capped and a warning is displayed. -// With `corners=`, the curve is split into independent clamped segments and -// the extra points are distributed across eligible segments proportionally -// to their control-point count, rounding up, so the total may -// exceed the requested number but will never be less. A segment is eligible when -// its effective degree is 3 or higher, or when it is degree 2 with `smooth=1`. -// . -// **Locating points in the spline** — In order to locate your original data -// points in the spline you need the `u` parameter value that you -// can pass to {{nurbs_curve()}}. The last return value `u` is a list -// where `u[k]` is the NURBS parameter at which the curve passes through -// `points[k]`. -// . -// **Smoothness** — The smoothness of B-splines is determined by the -// degree. If you request a degree $p$ spline then it will be $C^{p-1}$ at -// knot points and $C^\infty$ everywhere else. If you request corners then -// these are points where the curve is not differentiable; corners may -// also divide the curve into small segments that lack sufficient points -// to support an interpolation at your requested degree: a degree $p$ interpolation -// requires $p+1$ points. In this case, the intepolation is performed at a lower -// degree and elevated, which means it will be less smooth at knots. +// dim == 2 (special case): +// Accepts a 3D BOSL2 direction constant (UP, DOWN, LEFT, RIGHT, BACK, FWD) +// by projecting it onto the data plane. The vector must lie in the XZ plane +// (Y=0, as UP/DOWN/LEFT/RIGHT/FWD/BACK are defined) or the XY plane (Z=0). +// Underlength inputs (1D) are zero-padded to 2D as in the general case. // -// Arguments: -// points = List of data points to interpolate (2D or any higher dimension). -// degree = Degree of the NURBS. Degree 3 (cubic) is the most common choice. -// --- -// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` -// closed = If true treat point list as a loop . Default: `false` -// start_deriv = If `closed=false`, gives the tangent vector at the first point -// end_deriv = If `closed=false`, gives tangent vector at the last point. -// deriv = List of tangent vector constraints for every point, NAN at corners or undef at unconstrained points. Cannot be combined with `start_deriv=`/`end_deriv=`. -// start_curvature = If `closed=false` gives curvature at first point. (Requires matching derivative.) -// end_curvature = If `closed=false` gives curvature at last point. (Requires matching derivative.) -// curvature = List of curvature constraints for every point, or undef at unconstrained points. Each curvature constraint must be paired with a derivative constraint at the same point. Cannot be combined with `start_curvature=`/`end_curvature=`. -// corners = List of interior point indices where corners are permitted. Equivalent to setting entries of `deriv` to NAN. -// extra_pts = Number of extra control points to add to provide additional freedom to control undesirable oscillations. Default: 0 -// smooth = Smoothness criterion used with extra control points. Set to 1 (minimize control-polygon length), 2 (minimize control-polygon bending) or 3 (minimize curve bending energy). Default: 3 +// All dimensions (dim ≥ 2): +// Any vector shorter than dim is zero-padded to length dim. +// Vectors longer than dim (not handled by the dim=2 special case) error. + +function _force_deriv_dim(deriv, dim) = + dim == 2 && is_vector(deriv, 3) ? + // Special: 3D BOSL2 constant for 2D curve — project onto data plane. + assert(deriv.y == 0 || deriv.z == 0, + "\nDerivative for a 2D interpolation cannot be fully 3D. It must have either Y or Z component equal to zero.") + deriv.y == 0 ? [deriv.x, deriv.z] : point2d(deriv) + : // General: validate length ≤ dim, then zero-pad to exactly dim. + assert(is_vector(deriv) && len(deriv) >= 1 && len(deriv) <= dim, + str("\nDerivative must be a non-empty vector of dimension ", dim, " or less.")) + list_pad(deriv, dim, 0); + + +// Convert a curvature specification to a C''(t) constraint vector. // -// Example(2D,NoAxes): Clamped curve (default) -// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; -// debug_nurbs_interp(data, 3); +// Under natural-speed parameterization (|C'(t)| = v), curvature κ and +// the second derivative relate by: C''(t) = κ_vec_normal × v². +// Tangential acceleration is set to zero (arc-length parameterization at that point). // -// Example(2D,NoAxes): Closed curve (debug view) -// // Do NOT repeat the first point at the end. -// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; -// debug_nurbs_interp(data, 3, closed=true); +// curv_spec = signed scalar κ (dim=2), or a vector (any dim including 2D). +// Scalar (dim=2): positive = CCW (left), negative = CW (right). +// Vector: magnitude = |κ|; the perpendicular projection onto +// the plane normal to tang_dir provides the direction only. +// For dim=2 curves, accepts 3D BOSL2 direction constants +// (UP, DOWN, LEFT, RIGHT, etc.) — projected to 2D same as deriv=. +// tang_dir = tangent direction at the point (need not be normalized). +// dim = spatial dimension (len(points[0])). +// v2 = |C'(t)|² at the constrained point. + +function _curv_to_d2(curv_spec, tang_dir, dim, v2) = + let(t_hat = unit(tang_dir)) + (dim == 2 && is_num(curv_spec)) + ? // 2D signed scalar: rotate tangent 90° CCW to get the normal direction. + let(n_hat = [-t_hat[1], t_hat[0]]) + curv_spec * n_hat * v2 + : // Vector form (any dim, including 2D): magnitude from the input vector, + // direction from the perpendicular projection. + // Accepts 3D BOSL2 direction constants (UP, DOWN, etc.) for 2D curves + // via _force_deriv_dim projection, same as derivative constraints. + assert(is_vector(curv_spec) && len(curv_spec) >= 1 && + (len(curv_spec) <= dim || (dim == 2 && len(curv_spec) == 3)), + str("nurbs_interp: curvature constraint must be a signed scalar (2D) or a vector of dimension 1–", dim, + " (3D BOSL2 constants like UP/DOWN accepted for 2D curves)")) + let( + cv = _force_deriv_dim(curv_spec, dim), + mag = norm(cv), + cv_perp = cv - (cv * t_hat) * t_hat, + n_perp = norm(cv_perp) + ) + assert(n_perp > 1e-12, + "nurbs_interp: curvature constraint is parallel to the derivative at the same point — curvature must have a component perpendicular to the tangent direction") + mag * (cv_perp / n_perp) * v2; +// Merges start_deriv=/end_deriv= into a per-point list of length n+1. +// When dim is provided each non-undef, non-NaN entry is projected via +// _force_deriv_dim(): BOSL2 3D direction constants (UP, LEFT, …) map to the +// correct 2D or 3D vector, and shorter vectors are zero-padded. +// NaN corner-marker entries (0/0) pass through unchanged. +// Returns undef when no constraint is specified. +function _merge_deriv_list(n, deriv, dim=undef, start_deriv=undef, end_deriv=undef) = + let( + raw = !is_undef(deriv) ? deriv + : (!is_undef(start_deriv) || !is_undef(end_deriv)) + ? [for (k = [0:1:n]) + k == 0 && !is_undef(start_deriv) ? start_deriv + : k == n && !is_undef(end_deriv) ? end_deriv + : undef] + : undef + ) + is_undef(dim) || is_undef(raw) ? raw + : [for (v = raw) is_undef(v) || is_nan(v) ? v : _force_deriv_dim(v, dim)]; +// Merges start_curvature=/end_curvature= into a per-point list of length n+1. +// When dim is provided, vector entries are projected via _force_deriv_dim() +// (handles BOSL2 3D direction constants for 2D curves). Signed-scalar entries +// (valid for dim=2) are left as-is; the sign encodes the turn direction. +// Returns undef when no constraint is specified. +function _merge_curv_list(n, curvature, dim=undef, start_curvature=undef, end_curvature=undef) = + let( + raw = !is_undef(curvature) ? curvature + : (!is_undef(start_curvature) || !is_undef(end_curvature)) + ? [for (k = [0:1:n]) + k == 0 && !is_undef(start_curvature) ? start_curvature + : k == n && !is_undef(end_curvature) ? end_curvature + : undef] + : undef + ) + is_undef(dim) || is_undef(raw) ? raw + : [for (v = raw) (is_undef(v) || is_num(v)) ? v : _force_deriv_dim(v, dim)]; -function nurbs_interp(points, degree, method="centripetal", closed=false, - deriv=undef, start_deriv=undef, end_deriv=undef, - curvature=undef, start_curvature=undef, end_curvature=undef, - corners=undef, extra_pts=0, smooth=3) = - assert(is_path(points, undef) && len(points) >= 2, - "nurbs_interp: points must be a path (list of same-dimension vectors) with at least 2 points") - assert(is_num(degree) && degree >= 1, - "nurbs_interp: degree must be >= 1") - assert(method == "length" || method == "centripetal" || method == "dynamic" - || method == "foley" || method == "fang", - str("nurbs_interp: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) - assert(is_undef(deriv) || (is_undef(start_deriv) && is_undef(end_deriv)), - "nurbs_interp: use deriv= OR start_deriv=/end_deriv=, not both") - assert(!closed || (is_undef(start_deriv) && is_undef(end_deriv)), - "nurbs_interp: start_deriv/end_deriv only supported for closed=false") - assert(is_undef(deriv) || len(deriv) == len(points), - str("nurbs_interp: deriv= must have same length as points (", - len(points), " points, ", is_undef(deriv) ? 0 : len(deriv), " deriv)")) - assert(is_undef(curvature) || (is_undef(start_curvature) && is_undef(end_curvature)), - "nurbs_interp: use curvature= OR start_curvature=/end_curvature=, not both") - assert(!closed || (is_undef(start_curvature) && is_undef(end_curvature)), - "nurbs_interp: start_curvature=/end_curvature= only supported for closed=false") - assert(is_undef(curvature) || len(curvature) == len(points), - str("nurbs_interp: curvature= must have same length as points (", - len(points), " points, ", is_undef(curvature) ? 0 : len(curvature), " curvature)")) - assert(is_undef(corners) || ( - !closed - ? (min(corners) >= 1 && max(corners) <= len(points)-2) - : (min(corners) >= 0 && max(corners) <= len(points)-1)), - str("nurbs_interp: corners= indices must be ", - !closed ? str("interior (1..", len(points)-2, ")") - : str("valid point indices (0..", len(points)-1, ")"))) - assert(is_num(extra_pts) && extra_pts >= 0 && extra_pts == floor(extra_pts), - str("nurbs_interp: extra_pts must be a non-negative integer, got ", extra_pts)) - assert(extra_pts == 0 || degree >= 2, - "nurbs_interp: extra_pts requires degree >= 2") - assert(smooth == 1 || smooth == 2 || smooth == 3, - str("nurbs_interp: smooth must be 1, 2, or 3, got ", smooth)) - assert(smooth != 3 || degree >= 2, - "nurbs_interp: smooth=3 (bending energy) requires degree >= 2") + +// Parameterization + + +// Dynamic centripetal parameterization (Balta et al., IEEE Access 2020 §III). +// Per-chord exponent inversely proportional to ln(chord_length): +// e_i = ln(chordmax/chordi) / ln(chordmax/chordmin) * (emax-emin) + emin +// Long chords get exponent emin=0.35 (compressed contribution). +// Short chords get exponent emax=0.65 (expanded contribution). +// Falls back to e=0.5 (standard centripetal) when all chords are equal. + +function _dynamic_dists(raw, emin=0.35, emax=0.65) = let( - type = closed ? "closed" : "clamped", - raw = type == "clamped" - ? _nurbs_interp_clamped(points, degree, method, - deriv, start_deriv, end_deriv, - curvature, start_curvature, end_curvature, - corners, extra_pts, smooth) - : _nurbs_interp_closed(points, degree, method, deriv, curvature, - corners, extra_pts, smooth), - eff_type = is_string(raw[3]) ? raw[3] : type, - rot = raw[2], - n = len(points), - u = type == "closed" && !is_string(raw[3]) - ? list_rotate( - _interp_params(list_rotate(points, rot), method, closed=true), - -rot) - : type == "closed" - ? let( - aug_pts = [for (k = [0:1:n-1]) points[(k + rot) % n], points[rot]], - aug_params = _interp_params(aug_pts, method) - ) - [for (j = [0:1:n-1]) aug_params[(j - rot + n) % n]] - : _interp_params(points, method) + cmax = max(raw), + cmin = min(raw), + log_r = ln(cmax / cmin) ) - [eff_type, degree, raw[0], raw[1], undef, undef, u]; + // Divide each chord by cmin so that d/cmin ≥ 1 for every chord. + // This is required for correctness: pow(x, e) is an increasing function + // of e only when x > 1, so d > 1 ensures that the longer chords (with + // smaller exponent emin) are correctly compressed relative to shorter + // chords (with larger exponent emax). Normalizing by cmin also makes + // the result scale-invariant: λd/λcmin = d/cmin for any scale factor λ. + log_r < 1e-12 + ? [for (d = raw) sqrt(d / cmin)] // equal chords → uniform spacing + : [for (d = raw) + let(e = ln(cmax / d) / log_r * (emax - emin) + emin) + pow(d / cmin, e) + ]; -// Section: Debug / Visualization +// Foley-Neilson parameterization (Foley & Neilson 1987). +// Centripetal base with deflection-angle correction at each vertex. +function _foley_dists(points, closed) = + let( + n = len(points), + c = path_segment_lengths(points, closed=closed), + nc = len(c), + // Centripetal base: sqrt of each chord length. + d = [for (ci = c) sqrt(ci)], + // θ̂[i] = min(deflection angle at P[i], π/2) in radians. + // Deflection angle = 180° − interior angle at P[i]. + // Endpoints of an open curve contribute zero correction. + theta_hat = [for (i = [0:1:n-1]) + !closed && (i == 0 || i == n-1) ? 0 + : let(phi_deg = 180 - vector_angle(select(points, i-1, i+1))) + min(phi_deg * PI/180, PI/2) + ] + ) + [for (i = [0:1:nc-1]) + let( + di = d[i], + d_prev = d[(i - 1 + nc) % nc], + d_next = d[(i + 1) % nc], + th_L = theta_hat[i], + th_R = theta_hat[(i + 1) % n], + left = 3 * th_L * d_prev / (2 * (d_prev + di)), + right = 3 * th_R * d_next / (2 * (di + d_next)) + ) + di * (1 + left + right) + ]; -// Module: debug_nurbs_interp() -// Synopsis: Interpolates a NURBS using {{nurbs_interp()}} and displays the curve with informative overlays. -// Topics: NURBS Curves, Interpolation, Debugging -// See Also: nurbs_interp(), debug_nurbs() -// -// Usage: -// debug_nurbs_interp(points, degree, [splinesteps=], [method=], [closed=], [deriv=], [start_deriv=], [end_deriv=], [curvature=], [start_curvature=], [end_curvature=], [corners=], [extra_pts=], [smooth=], [width=], [size=], [data_size=], [data_index=], [show_control=], [control_index=], [show_knots=], [show_deriv=], [show_curvature=]); -// -// Description: -// Calls {{nurbs_interp()}} with the supplied arguments and displays the -// resulting curve together with a informative overlays. All interpolation -// arguments are passed through unchanged; see {{nurbs_interp()}} for their -// descriptions. The overlays are: -// . -// - **Data points** — red circles (2D) or spheres (3D) at each input point. -// When `data_index=true` (the default), the point index is printed in red next -// to its marker. Set `data_size=0` to suppress display of the data point dots. -// - **Derivative constraints** — a black arrow at each derivative constrained data point. -// Arrow direction and length reflect the constraint vector, scaled to the average -// point spacing. When the derivative is NAN or a point has a corner, this is shown -// using a black diamond. Shown by default: set `show_deriv=false` to hide. -// - **Curvature constraints** — a transparent green overlay at each curvature-constrained point. -// In 2D the overlay is the osculating circle. In 3D the overlay is a cylinder created -// from the 3D osculating circle. Zero curvature appears as a short green bar. -// Shown by default: Set `show_curvature=false` to hide. -// - **Knots** — Green crosses mark each knot position. Not shown by default. -// Enable with `show_knots=true`. -// - **Control points and polygon** — If you set `show_control=true` then a gray control polygon -// Is displayed. If you additionally set `control_index=true` then blue control-point -// index labels appear. -// -// Arguments: -// points = List of 2-D or 3-D data points to interpolate through. -// degree = NURBS degree. -// splinesteps = Steps per knot span for curve rendering. Default: `16` -// --- -// method = Parameterization method; see {{nurbs_interp()}}. Default: `"centripetal"` -// closed = If true, interpolate as a closed loop; if false, interpolate as clamped. Default: `false` -// deriv = Per-point derivative constraints; see {{nurbs_interp()}}. Default: `undef` -// start_deriv = Derivative at first point. Default: `undef` -// end_deriv = Derivative at last point. Default: `undef` -// curvature = Per-point curvature constraints; see {{nurbs_interp()}}. Default: `undef` -// start_curvature = Curvature at first point. Default: `undef` -// end_curvature = Curvature at last point. Default: `undef` -// corners = Corner indices; see {{nurbs_interp()}}. Default: `undef` -// extra_pts = Extra control points; see {{nurbs_interp()}}. Default: `0` -// smooth = Smoothness criterion for `extra_pts`; see {{nurbs_interp()}}. Default: `3` -// width = Stroke width for the curve. Arrows and other overlays scale with this. Default: `1` -// size = Text size for labels on control points and data points. Default: `3*width` -// data_size = Radius of the red data-point markers. Set to `0` to hide data points and their labels. Default: equal to `width` -// data_index = Show index labels next to each data point. Only shown when `data_size > 0`. Default: `true` -// show_control = Show the control polygon. Default: `false` -// control_index = Show control-point index labels if `show_control=true`. Default: `false` -// show_knots = Show knot position markers on the curve. Default: `false` -// show_deriv = Show derivative-constraint arrows. Default: `true` -// show_curvature = Show curvature-constraint circles / disks. Default: `true` -module debug_nurbs_interp(points, degree, splinesteps=16, method="centripetal", - closed=false, deriv=undef, - start_deriv=undef, end_deriv=undef, - curvature=undef, start_curvature=undef, end_curvature=undef, - corners=undef, extra_pts=0, smooth=3, - width=1, size=undef, data_size=undef, - show_control=false, show_knots=false, - show_deriv=true, show_curvature=true, - control_index=false, data_index=true) { - result = nurbs_interp(points, degree, method=method, - closed=closed, deriv=deriv, - start_deriv=start_deriv, end_deriv=end_deriv, - curvature=curvature, start_curvature=start_curvature, - end_curvature=end_curvature, corners=corners, - extra_pts=extra_pts, smooth=smooth); +// Fang improved centripetal parameterization (Fang & Hung, CAD 2013, Eq. 10). +// Centripetal base + osculating-circle dragging tolerance (α = 0.1). +// At each interior point Pᵢ, eᵢ = α·(θᵢ·ℓᵢ/(2·sin(θᵢ/2)) + θᵢ₋₁·ℓᵢ₋₁/(2·sin(θᵢ₋₁/2))) +// where θᵢ is deflection angle at Pᵢ, ℓᵢ is shortest side of triangle Pᵢ₋₁PᵢPᵢ₊₁. +// Each chord increment is Δᵢ = √‖Lᵢ‖ + eᵢ + eᵢ₊₁ (corrections from both endpoints). - np = len(points); - dim = len(points[0]); - is2d = (dim == 2); - ds = default(data_size, width); - sz = default(size, 3 * width); - ctrl = result[2]; - arrow_scale = path_length(points) / np; +function _fang_correction(points, closed) = + let(n = len(points)) + [for (i = [0:1:n-1]) + !closed && (i == 0 || i == n-1) ? 0 + : let( + tri = select(points, i-1, i+1), + ell = min(path_segment_lengths(tri, closed=true)), + theta_deg = 180 - vector_angle(select(points, i-1, i+1)) + ) + // θ·ℓ/(2·sin(θ/2)); limit as θ→0 is ℓ. + 0.1 * (abs(theta_deg) < 1e-6 ? ell + : theta_deg * PI/180 * ell / (2 * sin(theta_deg / 2))) + ]; - // Helpers project BOSL2 direction constants and pad dimensions automatically. - eff_der = _merge_deriv_list(np-1, deriv, dim=dim, start_deriv=start_deriv, end_deriv=end_deriv); - eff_curv = _merge_curv_list(np-1, curvature, dim=dim, start_curvature=start_curvature, end_curvature=end_curvature); +function _fang_dists(points, closed) = + let( + c = path_segment_lengths(points, closed=closed), + nc = len(c), + ef = _fang_correction(points, closed) + ) + [for (i = [0:1:nc-1]) + sqrt(c[i]) + ef[i] + select(ef, i+1) + ]; - // --- Curve, control polygon, knot markers (delegated to debug_nurbs) --- - debug_nurbs(result, splinesteps=splinesteps, width=width, size=sz, - show_knots=show_knots, show_control=show_control, - show_index=control_index); - // --- Corner marks (NaN-deriv corners + explicit corners= indices) --- - // 2D: rotated square stroke. 3D: octahedron wireframe. - nan_corner_idxs = is_undef(eff_der) ? [] - : [for (i = [0:1:np-1]) if (!is_undef(eff_der[i]) && is_nan(eff_der[i])) i]; - explicit_corner_idxs = default(corners, []); - all_corner_idxs = deduplicate(sort(concat(nan_corner_idxs, explicit_corner_idxs))); - for (i = all_corner_idxs) - color("black") - translate(points[i]) - if (is2d) - zrot(45) stroke(rect(3.5*width*ds), width=width/2, closed=true); - else - vnf_wireframe(octahedron(size=5*width), width=width/4); +// Chord-length, centripetal, dynamic, Foley, or Fang parameterization. +// clamped: n+1 points -> n+1 values in [0, 1] with t_0=0, t_n=1. +// closed: n points -> n values in [0, 1) with t_0=0. +// method: "length" = chord-length +// "centripetal" = sqrt exponent (Lee 1989) +// "dynamic" = per-chord dynamic exponent (Balta et al. 2020) +// "foley" = centripetal + deflection-angle correction (Foley & Neilson 1987) +// "fang" = centripetal + osculating-circle correction (Fang & Hung 2013) - // --- Derivative arrows (black, half width, arrow2 endcap) --- - // Length = norm(eff_der[i]) * arrow_scale: preserves relative magnitudes; - // arrow_scale = path_length(points)/np gives a geometry-relative baseline. - if (show_deriv && !is_undef(eff_der)) - for (i = [0:1:np-1]) - if (!is_undef(eff_der[i]) && !is_nan(eff_der[i]) && norm(eff_der[i]) > 1e-12) - color("black") - stroke([points[i], points[i] + eff_der[i] * arrow_scale], - width=width/2, - endcap1="butt", endcap2="arrow2"); +function _interp_params(points, method="centripetal", closed=false) = + let( + raw = path_segment_lengths(points, closed=closed), + n = len(raw), + total_raw = sum(raw) + ) + // Degenerate: all points identical (e.g. a surface pole row/column). + // Return uniform spacing so surface parameter averages stay valid. + total_raw < 1e-10 + ? (closed + ? [for (i = [0:1:n-1]) i / n] + : [for (i = [0:1:n ]) i / n]) + : assert(min(raw) > 1e-10, + "nurbs_interp: consecutive duplicate data points detected") + let( + dists = method == "centripetal" ? [for (d = raw) sqrt(d)] + : method == "dynamic" ? _dynamic_dists(raw) + : method == "foley" ? _foley_dists(points, closed) + : method == "fang" ? _fang_dists(points, closed) + : raw, + total = sum(dists), + cs = cumsum(dists) + ) + closed ? [0, each [for (x = list_head(cs)) x / total]] + : [0, each [for (x = list_head(cs)) x / total], 1]; - // --- Data points and index labels --- - if (ds > 0) - color("red") - move_copies(points) { - if (is2d) circle(r=ds, $fn=16); - else sphere(r=ds, $fn=16); - if (data_index) - if (is2d) - fwd(2*ds) text(text=str($idx), size=sz, anchor=BACK); - else - rot($vpr) back(ds + sz/3) text3d(text=str($idx), size=sz, anchor=CENTER); - } - // --- Curvature overlays (rendered last so transparent objects don't occlude dots) --- - // Validator already asserted every curvature-constrained point has a derivative, - // so eff_der[i] is always defined and non-NaN here. - if (show_curvature && !is_undef(eff_curv)) - color([0,1,0,0.1]) - for (i = [0:1:np-1]) - if (!is_undef(eff_curv[i])) { - // cv is either a signed scalar (2D) or a dim-projected vector. - cv = eff_curv[i]; - kn = is_num(cv) ? abs(cv) : norm(cv); - T_hat = unit(eff_der[i]); - if (kn < 1e-12) { - // Zero curvature: fixed-length segment (0.6*arrow_scale) along - // the exact derivative direction. - half = 0.3 * arrow_scale; - stroke([points[i] - T_hat * half, - points[i] + T_hat * half], - width=2*width, endcaps="butt"); - } else { - // Non-zero curvature: osculating circle (2D) or cylinder (3D). - // N_hat: unit principal normal — component of cv perpendicular to T_hat. - N_hat = is_num(cv) - ? // Signed scalar (2D): rotate T_hat 90° left or right by sign(cv). - sign(cv) * [-T_hat[1], T_hat[0]] - : // Vector: strip tangential component via vector_perp, then unit. - unit(vector_perp(T_hat, cv)); - r = 1 / kn; - ctr = points[i] + N_hat * r; - // move(ctr) applies to both 2D and 3D branches. - move(ctr) - if (is2d) { - circle(r=r); - } else { - // Cylinder in the osculating plane: axis along binormal B̂ = T̂ × N̂. - // cyl(orient=binom) aligns the cylinder axis to B̂ without rot(). - binom = cross(T_hat, N_hat); - cyl(h=width, r=r, orient=binom); - } - } - } -} +// Knot Vector Construction +// Interior knots by averaging (Piegl & Tiller eq 9.8). +function _avg_knots_interior(params, p) = + let( + n = len(params) - 1, + num_internal = n - p + ) + num_internal <= 0 + ? [] + : [for (j = [1:1:num_internal]) + sum([for (i = [j :1: j + p - 1]) params[i]]) / p + ]; -// Section: NURBS Surface Interpolation -// Function&Module: nurbs_interp_surface() -// Synopsis: Returns a NURBS surface that passes through a grid of 3D data points. -// SynTags: Geom -// Topics: NURBS Surfaces, Interpolation -// See Also: nurbs_vnf(), nurbs_interp() -// -// Usage: As a function, returns a NURBS parameter list: -// nurbs_param = nurbs_interp_surface(points, degree, [method=], [row_wrap=], [col_wrap=], [normal1=], [normal2=], [flat_edges=], [flat_end1=], [flat_end2=], [row_edges=], [col_edges=], [extra_pts=], [smooth=], [first_row_deriv=], [last_row_deriv=], [first_col_deriv=], [last_col_deriv=]); -// Usage: As a module, renders the surface directly: -// nurbs_interp_surface(points, degree, [splinesteps=], [row_wrap=], [col_wrap=], [method=], [extra_pts=], [smooth=], ...) CHILDREN; -// Description: -// Finds the control points and knot vectors for a NURBS surface of the specified degree that passes -// exactly through every data point in a grid of 3D points. The result has -// uniform weights but non-uniform knots so it is actually a non-uniform B-spline. -// When called as a function, the return value is a NURBS parameter list -// `[type, degree, ctrl_grid, knots, undef, undef, uv]` that can be passed -// directly to `{{nurbs_vnf()}}`. The extra return value `uv`, -// described in detail below, enables you to locate your input points in the computed spline -// When called as a module, renders the NURBS surface as geometry. -// . -// Several of the parameters that correspond to parameters for {{nurbs_interp()}} -// can be given as either a scalar or 2-vector. When you give a 2-vector the -// first value applies along the first index of your point data, i.e. from row -// to row, or along columns. The second value applies along the second index, -// i.e. within rows. -// . -// Setting `row_wrap=true` smoothly connects the first and last rows in a loop, -// and `col_wrap=true` smoothly joins the first and last columns. Both false (the default) gives a -// surface with four edges. One true gives a tube; both true gives a torus. -// A tube by itself is not a valid closed manifold in OpenSCAD; you can make it valid by adding caps or -// you can close it into a ball by specifying degenerate edges where the entire edge collapses to -// one identical point. -// . -// **Boundary constraints** -// . -// Flat boundary (`row_wrap=false, col_wrap=false`) — `flat_edges=`. Applies when -// all four surface edges are coplanar. Set `flat_edges` to a 4-element list -// `[first_row, last_row, first_col, last_col]`; each entry is a scalar or per-point list -// giving the derivative scale for that edge (`undef` leaves the edge unconstrained). -// `flat_edges=s` expands to `[s,s,s,s]`. A positive value flares the surface -// outward from the edge; negative turns it inward. -// . -// End normals (one of `row_wrap`/`col_wrap` true, the other false) — `normal1=` and -// `normal2=`. Apply when the specified boundary edge is degenerate (all points -// identical, e.g. a cone tip). The surface is constrained to be normal to the given -// vector at that edge. The vector magnitude controls how broadly the surface spreads. -// . -// Flat ends (one of `row_wrap`/`col_wrap` true, the other false) — `flat_end1=` and -// `flat_end2=`. Apply when the specified boundary edge is coplanar and non-degenerate. -// Constrains the derivative to lie in the plane of the edge. Positive points inward -// (smooth cap attachment); negative flares outward. Scalar or per-point list. -// . -// **Advanced boundary derivatives** — `first_row_deriv=`, `last_row_deriv=`, -// `first_col_deriv=`, and `last_col_deriv=` enforce specific first partial derivatives -// along the four boundary edges. Each accepts a single vector (applied to every -// point on the edge) or a list of vectors (one per point). Vectors are scaled by -// total chord length, so a unit vector matches the parameterization speed. These -// require `row_wrap=false` (for row derivs) or `col_wrap=false` (for col derivs). -// . -// Use with care: the solver enforces derivatives exactly at data points but the -// surface may wander between them. When both u- and v-boundary derivatives are -// active, the cross-derivative is assumed zero at corners. -// . -// **Edges** — `row_edges=` and `col_edges=` insert edges or creases across the surface. -// Use `row_edges=` to specify the indices of rows that will be edges or creases, -// and `col_edges=` to specify the indices of columns that will be edges or creases. -// For a non-wrapped direction, indices must be interior (not first or last). -// If you place edges close together, the effective degree of a narrow patch between -// edges may be reduced. These patches are assembled into a single NURBS so this -// process is transparent to the user. -// . -// **Extra control points** (`extra_pts=`, `smooth=`) — By default the solver uses -// exactly the number of control points needed to satisfy the constraints, which -// gives a unique solution that may be badly behaved. Specifying `extra points=` -// and optionally `smooth=`, works the same way as in -// for {{nurbs_interp()}}. Both parameters can be scalars or 2-vectors to -// provide different values along the two directions. -// . -// **Locating points in the spline** — In order to locate your original data -// points in the spline you need the `u` and `v` nurbs parameter values that you -// can pass to {{nurbs_patch_points()}}. The last return value `uv` gives these: -// `uv[0][j]` is the u parameter for row `j` and `uv[1][k]` is the v parameter -// for column `k`, so the point `points[j][k]` lies at `(uv[0][j], uv[1][k])` -// in NURBS parameter space. -// . -// **Smoothness** — The smoothness of B-splines is determined by the -// degree. If you request a degree p spline then it will be C^(p-1) at -// knot points and C^inf everywhere else. If you request edges then -// these are points where the surface is not differentiable; edges may -// also divide the surface into smaller regions that lack sufficient points -// to support an interpolation of your requested degree: a degree p interpolation -// requires p+1 points. In this case, the interpolation is performed at a lower -// degree and elevated, which means it will be less smooth at knots. -// Arguments: -// points = Rectangular grid of 3D data points -// degree = scalar or 2-vector giving the degree of the B-spline in the two directions. -// splinesteps = (module) Scalar or 2-vector giving the number of segments between each knot in the two directions. Default: 16 -// --- -// method = Parameterization method: `"length"`, `"centripetal"`, `"dynamic"`, `"foley"`, or `"fang"`. Default: `"centripetal"` -// row_wrap = If true, smoothly connect the first row to the last row. Default: false -// col_wrap = If true, smoothly connect the first column to the last column. Default: false -// extra_pts = Scalar or 2-vector giving the number of extra points in the two directions. Default: `0` -// smooth = Scalar or 2-vector giving the smoothness metric for extra points in the two directions: `1` (min polygon length), `2` (min bending), `3` (min bending energy). Default: `3` -// flat_edges = 4-element list `[first_row, last_row, first_col, last_col]` of derivative scales at the four coplanar boundary edges. Each entry is a scalar or per-point list; `undef` leaves that edge unconstrained. Shorthand: `flat_edges=s` → `[s,s,s,s]`. Requires `row_wrap=false, col_wrap=false`. -// normal1 = Surface normal at the first degenerate boundary edge (mixed wrap surface only). -// normal2 = Surface normal at the second degenerate boundary edge (mixed wrap surface only). -// flat_end1 = Inward derivative scale at the first coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. -// flat_end2 = Inward derivative scale at the second coplanar non-degenerate boundary edge (mixed wrap surface). Scalar or per-point list. -// row_edges = Row indices (or index) of rows that are treated as edges or creases. -// col_edges = Column indices (or index) of columns that are treated as edges or creases -// first_row_deriv = dS/du constraint along u=0 (first row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. -// last_row_deriv = dS/du constraint along u=1 (last row). Single vector or list of vectors (one per column). Requires `row_wrap=false`. -// first_col_deriv = dS/dv constraint along v=0 (first column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. -// last_col_deriv = dS/dv constraint along v=1 (last column). Single vector or list of vectors (one per row). Requires `col_wrap=false`. -// data_size = (module) Radius of data-point markers; 0 suppresses markers. Default: 0 -// data_color = (module) Color for data-point markers. Default: `"red"` -// style = (module) Triangulation style passed to `vnf_vertex_array()`. Default: `"default"` -// reverse = (module) If true, reverses face normals. Default: false -// triangulate = (module) If true, triangulates all quads. Default: false -// caps = (module) Cap both open boundary edges (mixed wrap only). Default: false -// cap1 = (module) Cap the first open boundary edge. -// cap2 = (module) Cap the second open boundary edge. -// cp = (module) Centerpoint for determining intersection anchors or centering the shape. Determines the base of the anchor vector. Can be "centroid", "mean", "box" or a 3D point. Default: "centroid" -// anchor = (module) Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `"origin"` -// spin = (module) Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0` -// orient = (module) Vector to rotate top toward, after spin. See [orient](attachments.scad#subsection-orient). Default: `UP` -// atype = (module) Select "hull" or "intersect" anchor type. Default: "hull" +// Full clamped knot vector: (p+1) zeros, interior, (p+1) ones. -function nurbs_interp_surface(points, degree, method="centripetal", - row_wrap=false, col_wrap=false, - first_row_deriv=undef, last_row_deriv=undef, - first_col_deriv=undef, last_col_deriv=undef, - normal1=undef, normal2=undef, - flat_end1=undef, flat_end2=undef, - flat_edges=undef, - row_edges=undef, col_edges=undef, - extra_pts=0, smooth=3) = - // Preamble: extract shape/edge info needed for closed-direction dispatch. +function _full_clamped_knots(interior_knots, p) = + concat(repeat(0, p+1), interior_knots, repeat(1, p+1)); + + +// Periodic "bar knots" for closed B-splines. +// +// Returns [bar_knots, shifted_params] where bar_knots is n+1 +// monotonically increasing values with bar[0]=0, bar[n]=1, and +// shifted_params are the parameter values shifted to match. +// +// The raw bar knots are computed by averaging p consecutive values +// from the extended periodic parameter sequence t_m = params[m%n] + +// floor(m/n). This is guaranteed monotonic. We then shift so +// bar[0]=0, and shift params by the same amount. + +function _avg_knots_periodic(params, p) = let( - n_rows = len(points), - n_cols = len(points[0]), - ue_norm_pre = is_undef(row_edges) ? undef : force_list(row_edges), - ve_norm_pre = is_undef(col_edges) ? undef : force_list(col_edges), - has_ue_pre = !is_undef(ue_norm_pre) && len(ue_norm_pre) > 0, - has_ve_pre = !is_undef(ve_norm_pre) && len(ve_norm_pre) > 0 + n = len(params), + raw = [for (j = [0:1:n]) + sum([for (k = [0:1:p-1]) + let(m = j + k) + params[m % n] + floor(m / n) + ]) / p + ], + shift = raw[0], + bar_knots = add_scalar(raw, -shift), + shifted = [for (t = params) + let(s = t - shift) + s < 0 ? s + 1 : (s >= 1 ? s - 1 : s)] ) - // col_edges on a closed v-direction: rotate columns so the first crease column - // becomes the v=0/v=1 boundary, append a copy at the end for the C0 seam, - // then recurse with col_wrap=false. Remaining crease indices are shifted - // into the rotated coordinate system. - has_ve_pre && col_wrap ? - let( - ve_sorted = sort(ve_norm_pre), - rot = ve_sorted[0], - new_pts = [for (row = points) - concat([for (l = [rot:1:n_cols-1]) row[l]], - [for (l = [0:1:rot-1]) row[l]], - [row[rot]])], - adj_ve_raw = [for (i = [1:1:len(ve_sorted)-1]) - let(j = (ve_sorted[i] - rot + n_cols) % n_cols) - if (j > 0) j], - adj_ve = len(adj_ve_raw) == 0 ? undef : adj_ve_raw - ) - let(inner = nurbs_interp_surface(new_pts, degree, method=method, - row_wrap=row_wrap, col_wrap=false, - first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, - first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, - normal1=normal1, normal2=normal2, - flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, - row_edges=row_edges, col_edges=adj_ve, - extra_pts=extra_pts, smooth=smooth)) - [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], - [inner[6][0], - list_rotate(select(inner[6][1], 0, n_cols-1), -rot)]] - // row_edges on a closed u-direction: rotate rows so the first crease row - // becomes the u=0/u=1 boundary, append a copy at the end, recurse clamped. - : has_ue_pre && row_wrap ? - let( - ue_sorted = sort(ue_norm_pre), - rot = ue_sorted[0], - new_pts = concat([for (k = [rot:1:n_rows-1]) points[k]], - [for (k = [0:1:rot-1]) points[k]], - [points[rot]]), - adj_ue_raw = [for (i = [1:1:len(ue_sorted)-1]) - let(j = (ue_sorted[i] - rot + n_rows) % n_rows) - if (j > 0) j], - adj_ue = len(adj_ue_raw) == 0 ? undef : adj_ue_raw - ) - let(inner = nurbs_interp_surface(new_pts, degree, method=method, - row_wrap=false, col_wrap=col_wrap, - first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, - first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, - normal1=normal1, normal2=normal2, - flat_end1=flat_end1, flat_end2=flat_end2, flat_edges=flat_edges, - row_edges=adj_ue, col_edges=col_edges, - extra_pts=extra_pts, smooth=smooth)) - [inner[0], inner[1], inner[2], inner[3], inner[4], inner[5], - [list_rotate(select(inner[6][0], 0, n_rows-1), -rot), - inner[6][1]]] - // Normal path: both directions already clamped, or no conflicting edge constraints. + [bar_knots, shifted]; + + +// Repair degenerate periodic bar knots: if any span is smaller than +// eps × period, merge it into its neighbor and bisect the resulting +// larger span. Preserves the knot count (n+1 entries, n spans) and +// the endpoint values bar[0]=0, bar[n]=period. Recurses until no +// tiny spans remain. + +function _fix_tiny_spans(bar_knots, n, eps=1e-6) = + let( + T = bar_knots[n], + spans = [for (k = [0:1:n-1]) bar_knots[k+1] - bar_knots[k]], + min_span = min(spans) + ) + min_span >= eps * T ? bar_knots : let( - p_u = is_list(degree) ? degree[0] : degree, - p_v = is_list(degree) ? degree[1] : degree, - ep_u = is_list(extra_pts) ? extra_pts[0] : extra_pts, - ep_v = is_list(extra_pts) ? extra_pts[1] : extra_pts, - smooth_u = is_list(smooth) ? smooth[0] : smooth, - smooth_v = is_list(smooth) ? smooth[1] : smooth, - n_rows = len(points), - n_cols = len(points[0]), - dim = len(points[0][0]), - // Scalar-vector promotion: if the caller passes a single vector instead of - // a list of vectors, repeat() it to the required length. A single vector - // is detected as a list whose first element is a number, not a list. - first_row_deriv = is_undef(first_row_deriv) || is_list(first_row_deriv[0]) ? first_row_deriv - : repeat(first_row_deriv, n_cols), - last_row_deriv = is_undef(last_row_deriv) || is_list(last_row_deriv[0]) ? last_row_deriv - : repeat(last_row_deriv, n_cols), - first_col_deriv = is_undef(first_col_deriv) || is_list(first_col_deriv[0]) ? first_col_deriv - : repeat(first_col_deriv, n_rows), - last_col_deriv = is_undef(last_col_deriv) || is_list(last_col_deriv[0]) ? last_col_deriv - : repeat(last_col_deriv, n_rows), - // Treat an all-undef derivative list the same as undef. - has_sud = !is_undef(first_row_deriv) && num_defined(first_row_deriv) > 0, - has_eud = !is_undef(last_row_deriv) && num_defined(last_row_deriv) > 0, - has_svd = !is_undef(first_col_deriv) && num_defined(first_col_deriv) > 0, - has_evd = !is_undef(last_col_deriv) && num_defined(last_col_deriv) > 0, - has_sn = !is_undef(normal1), - has_en = !is_undef(normal2), - // normal1/normal2: apex edges only (all boundary points identical, e.g. cone tip). - // Auto-detect u=0/v=0 direction; u=0 (first row) takes priority. - start_u_apex = has_sn && max([for (pt = points[0]) norm(pt - points[0][0])]) < 1e-10, - start_v_apex = has_sn && max([for (k = [0:1:n_rows-1]) norm(points[k][0] - points[0][0])]) < 1e-10, - end_u_apex = has_en && max([for (pt = points[n_rows-1]) norm(pt - points[n_rows-1][0])]) < 1e-10, - end_v_apex = has_en && max([for (k = [0:1:n_rows-1]) norm(points[k][n_cols-1] - points[0][n_cols-1])]) < 1e-10, - has_sun = has_sn && start_u_apex, - has_eun = has_en && end_u_apex, - has_svn = has_sn && !start_u_apex && start_v_apex, - has_evn = has_en && !end_u_apex && end_v_apex, - start_u_degen = start_u_apex, - start_v_degen = start_v_apex, - end_u_degen = end_u_apex, - end_v_degen = end_v_apex, - // flat_end1/flat_end2: coplanar non-collinear edges (points span a plane). - // Scalar or per-point list. positive = closes inward, negative = flares outward. - // Direction is determined by the clamped direction of the surface: - // row_wrap=false → flat_end applies to row boundaries (u-direction, first/last row). - // col_wrap=false → flat_end applies to column boundaries (v-direction, first/last col). - // Exactly one direction must be clamped (enforced by assertion below). - has_fe1 = !is_undef(flat_end1), - has_fe2 = !is_undef(flat_end2), - has_fe1_u = has_fe1 && !row_wrap, - has_fe1_v = has_fe1 && !col_wrap, - has_fe2_u = has_fe2 && !row_wrap, - has_fe2_v = has_fe2 && !col_wrap, - // Boundary edges for coplanar validation. - fe1_edge = has_fe1_u ? points[0] - : has_fe1_v ? [for (k = [0:1:n_rows-1]) points[k][0]] - : [], - fe2_edge = has_fe2_u ? points[n_rows-1] - : has_fe2_v ? [for (k = [0:1:n_rows-1]) points[k][n_cols-1]] - : [], - fe1_ok = !has_fe1 || (_is_coplanar_pts(fe1_edge) && !is_undef(_pts_plane_normal(fe1_edge))), - fe2_ok = !has_fe2 || (_is_coplanar_pts(fe2_edge) && !is_undef(_pts_plane_normal(fe2_edge))), - // flat_edges= parsing: 4-element list [first_row, last_row, first_col, last_col]. - // Scalar shorthand: flat_edges=s expands to [s, s, s, s]. - fe_norm = !is_undef(flat_edges) && !is_list(flat_edges) - ? [flat_edges, flat_edges, flat_edges, flat_edges] - : flat_edges, - has_fe = !is_undef(fe_norm), - fe_su = has_fe ? fe_norm[0] : undef, - fe_eu = has_fe ? fe_norm[1] : undef, - fe_sv = has_fe ? fe_norm[2] : undef, - fe_ev = has_fe ? fe_norm[3] : undef, - has_fesu = has_fe && !is_undef(fe_su), - has_feeu = has_fe && !is_undef(fe_eu), - has_fesv = has_fe && !is_undef(fe_sv), - has_feev = has_fe && !is_undef(fe_ev), - // Edge (C0 discontinuity) support. Singleton promotion: scalar → list. - ue_norm = is_undef(row_edges) ? undef : force_list(row_edges), - ve_norm = is_undef(col_edges) ? undef : force_list(col_edges), - has_ue = !is_undef(ue_norm) && len(ue_norm) > 0, - has_ve = !is_undef(ve_norm) && len(ve_norm) > 0 + k = min_index(spans), + // Remove an interior knot bounding the tiny span. + // For span 0 (first span), remove knot 1 and absorb into span 1. + // For span n-1 (last span), remove knot n-1 and absorb into span n-2. + // Otherwise, remove knot k+1 and absorb into the merged span at k. + remove_idx = k == 0 ? 1 + : k == n - 1 ? n - 1 + : k + 1, + merged = [for (i = [0:1:n]) if (i != remove_idx) bar_knots[i]], + absorb_k = k == 0 ? 0 : k - 1, + // Bisect the absorbing span to restore the knot count. + mid = (merged[absorb_k] + merged[absorb_k + 1]) / 2, + fixed = [for (i = [0:1:n-1]) // n entries in merged + each (i == absorb_k ? [merged[i], mid] : [merged[i]])] + ) + _fix_tiny_spans(fixed, n, eps); + + +// Insert extra knots into a base bar_knots vector, one per +// constraint parameter. For each constraint, finds the span +// containing its parameter value and inserts at the span midpoint. +// When multiple constraints compete, the one whose containing span +// is largest is processed first — this avoids splitting a small +// span when a larger one is available. Each insertion updates the +// knot vector before the next constraint is processed. +// +// bar_knots: base bar_knots from periodic or interior averaging. +// constraint_ts: list of parameter values identifying which span +// to split. For closed: raw params in [0,1). +// For clamped: params in [0,1]. +// +// Returns the augmented bar_knots with len(constraint_ts) extra entries. + +function _insert_constraint_knots(bar_knots, constraint_ts) = + len(constraint_ts) == 0 ? bar_knots + : let( + n = len(bar_knots), + // For each constraint, find its containing span and that span's width. + spans = [for (ci = [0:1:len(constraint_ts)-1]) + let( + t = constraint_ts[ci], + pos = [for (i = [0:1:n-2]) + if (bar_knots[i] <= t && t < bar_knots[i+1]) i], + idx = len(pos) > 0 ? pos[0] : n - 2, + w = bar_knots[idx+1] - bar_knots[idx] + ) + [ci, idx, w] + ], + // Pick the constraint whose span is largest. + best = max_index([for (s = spans) s[2]]), + ci = spans[best][0], + idx = spans[best][1], + mid = (bar_knots[idx] + bar_knots[idx+1]) / 2, + new_knots = [each [for (i = [0:1:idx]) bar_knots[i]], mid, + each [for (i = [idx+1:1:n-1]) bar_knots[i]]], + remaining = [for (i = [0:1:len(constraint_ts)-1]) + if (i != ci) constraint_ts[i]] + ) + _insert_constraint_knots(new_knots, remaining); + + +// Return k parameter values, each at the midpoint of one of the k +// widest spans in bar_knots. Used to target extra knot insertions +// and smoothness rows at the most under-resolved regions. +// +// When all k picks come from equal-width spans (the common case for +// uniformly-parameterized closed curves), spans are chosen at centred- +// stratified indices floor((2g+1)*n/(2*k_eff)) % n for g=0..k_eff-1. +// This places each pick at the centre of its equal-width quantile +// rather than at the quantile boundary. For n=18, k=4 the picks +// are spans 2, 6, 11, 15 instead of 0, 4, 9, 13. +// +// Centering is essential for closed curves: _extend_knot_vector wraps +// span widths across the seam (span n-1 into the pre-region, span 0 +// into the post-region). If an extra knot is inserted in span 0, the +// span width at the start of aug_bar differs from the width at the end, +// making the basis functions slightly asymmetric at the seam and +// causing a visible fold in the null-space solution. Centering keeps +// both boundary spans at their original (uniform) width. +// When the k widest spans are not all equal, the standard widest-first +// selection is used (knot insertion targets the most under-resolved +// regions regardless of position). + +function _widest_span_params(bar_knots, k) = + let( + n = len(bar_knots) - 1, + k_eff = min(k, n), + _echo = k > n ? echo(str("nurbs_interp: extra_pts=", k, + " exceeds the number of available knot spans (", n, + "); reduced to ", n, ".")) : 0, + spans = [for (i = [0:1:n-1]) bar_knots[i+1] - bar_knots[i]], + w_max = max(spans), + // Indices of spans at the maximum width (within floating-point tolerance). + // Stratification picks only from these so that constraint-narrowed spans + // (e.g. from _insert_constraint_knots) are never accidentally chosen. + eq_idxs = [for (i = [0:1:n-1]) if (abs(spans[i] - w_max) < 1e-10 * w_max) i], + n_eq = len(eq_idxs) ) - assert(is_list(points) && n_rows >= 2, - "nurbs_interp_surface: need at least 2 rows") - assert(n_cols >= 2, - "nurbs_interp_surface: need at least 2 columns") - assert(min([for (row = points) len(row)]) == max([for (row = points) len(row)]), - "nurbs_interp_surface: all rows must have the same number of columns") - assert(is_num(p_u) && p_u >= 1 && is_num(p_v) && p_v >= 1, - "nurbs_interp_surface: degree must be >= 1") - assert(method == "length" || method == "centripetal" || method == "dynamic" - || method == "foley" || method == "fang", - str("nurbs_interp_surface: method must be \"length\", \"centripetal\", \"dynamic\", \"foley\", or \"fang\", got \"", method, "\"")) - assert(is_num(ep_u) && ep_u >= 0 && ep_u == floor(ep_u), - str("nurbs_interp_surface: extra_pts (u) must be a non-negative integer, got ", ep_u)) - assert(is_num(ep_v) && ep_v >= 0 && ep_v == floor(ep_v), - str("nurbs_interp_surface: extra_pts (v) must be a non-negative integer, got ", ep_v)) - assert(ep_u == 0 || p_u >= 2, - "nurbs_interp_surface: extra_pts in u-direction requires u-degree >= 2") - assert(ep_v == 0 || p_v >= 2, - "nurbs_interp_surface: extra_pts in v-direction requires v-degree >= 2") - assert(n_rows >= p_u + 1, - str("nurbs_interp_surface: need at least ", p_u+1, - " rows for u-degree ", p_u, ", got ", n_rows)) - assert(n_cols >= p_v + 1, - str("nurbs_interp_surface: need at least ", p_v+1, - " columns for v-degree ", p_v, ", got ", n_cols)) - assert(!(has_sud || has_eud || has_sun || has_eun || has_fesu || has_feeu || has_fe1_u || has_fe2_u) || !row_wrap, - "nurbs_interp_surface: u-direction derivative/normal/flat_end/flat_edges params require row_wrap=false") - assert(!(has_svd || has_evd || has_svn || has_evn || has_fesv || has_feev || has_fe1_v || has_fe2_v) || !col_wrap, - "nurbs_interp_surface: v-direction derivative/normal/flat_end/flat_edges params require col_wrap=false") - assert(!has_sud || len(first_row_deriv) == n_cols, - str("nurbs_interp_surface: first_row_deriv must have ", n_cols, - " entries (one per column), got ", is_undef(first_row_deriv) ? 0 : len(first_row_deriv))) - assert(!has_eud || len(last_row_deriv) == n_cols, - str("nurbs_interp_surface: last_row_deriv must have ", n_cols, - " entries (one per column), got ", is_undef(last_row_deriv) ? 0 : len(last_row_deriv))) - assert(!has_svd || len(first_col_deriv) == n_rows, - str("nurbs_interp_surface: first_col_deriv must have ", n_rows, - " entries (one per row), got ", is_undef(first_col_deriv) ? 0 : len(first_col_deriv))) - assert(!has_evd || len(last_col_deriv) == n_rows, - str("nurbs_interp_surface: last_col_deriv must have ", n_rows, - " entries (one per row), got ", is_undef(last_col_deriv) ? 0 : len(last_col_deriv))) - // normal1/normal2 assertions: apex edges only. - assert(!has_sn || (start_u_degen || start_v_degen), - "nurbs_interp_surface: normal1 requires a degenerate start edge (first row or first column must be all the same point)") - assert(!has_en || (end_u_degen || end_v_degen), - "nurbs_interp_surface: normal2 requires a degenerate end edge (last row or last column must be all the same point)") - assert(!has_sn || !(start_u_degen && start_v_degen), - "nurbs_interp_surface: normal1 is ambiguous — both u=0 and v=0 edges are degenerate; use first_row_deriv or first_col_deriv explicitly") - assert(!has_en || !(end_u_degen && end_v_degen), - "nurbs_interp_surface: normal2 is ambiguous — both u=1 and v=1 edges are degenerate; use last_row_deriv or last_col_deriv explicitly") - assert(!(has_sun && has_sud), - "nurbs_interp_surface: normal1 resolves to u-direction but first_row_deriv was also given") - assert(!(has_eun && has_eud), - "nurbs_interp_surface: normal2 resolves to u-direction but last_row_deriv was also given") - assert(!(has_svn && has_svd), - "nurbs_interp_surface: normal1 resolves to v-direction but first_col_deriv was also given") - assert(!(has_evn && has_evd), - "nurbs_interp_surface: normal2 resolves to v-direction but last_col_deriv was also given") - // flat_end1/flat_end2 assertions. - // Direction is determined by the clamped type; surface must be mixed clamped/closed. - assert(!has_fe1 || (row_wrap != col_wrap), - "nurbs_interp_surface: flat_end1 requires the surface to be clamped in one direction and closed in the other") - assert(!has_fe2 || (row_wrap != col_wrap), - "nurbs_interp_surface: flat_end2 requires the surface to be clamped in one direction and closed in the other") - assert(fe1_ok, - has_fe1_u - ? "nurbs_interp_surface: flat_end1 requires the first row (u=0 boundary) to be coplanar and non-collinear" - : "nurbs_interp_surface: flat_end1 requires the first column (v=0 boundary) to be coplanar and non-collinear. If your first row is coplanar, try row_wrap=true, col_wrap=false.") - assert(fe2_ok, - has_fe2_u - ? "nurbs_interp_surface: flat_end2 requires the last row (u=1 boundary) to be coplanar and non-collinear" - : "nurbs_interp_surface: flat_end2 requires the last column (v=1 boundary) to be coplanar and non-collinear. If your last row is coplanar, try row_wrap=true, col_wrap=false.") - assert(!(has_fe1_u && has_sud), - "nurbs_interp_surface: flat_end1 conflicts with first_row_deriv") - assert(!(has_fe2_u && has_eud), - "nurbs_interp_surface: flat_end2 conflicts with last_row_deriv") - assert(!(has_fe1_v && has_svd), - "nurbs_interp_surface: flat_end1 conflicts with first_col_deriv") - assert(!(has_fe2_v && has_evd), - "nurbs_interp_surface: flat_end2 conflicts with last_col_deriv") - assert(!(has_fe1_u && has_fesu), - "nurbs_interp_surface: flat_end1 conflicts with flat_edges[0] on same edge") - assert(!(has_fe2_u && has_feeu), - "nurbs_interp_surface: flat_end2 conflicts with flat_edges[1] on same edge") - assert(!(has_fe1_v && has_fesv), - "nurbs_interp_surface: flat_end1 conflicts with flat_edges[2] on same edge") - assert(!(has_fe2_v && has_feev), - "nurbs_interp_surface: flat_end2 conflicts with flat_edges[3] on same edge") - assert(!has_fe1 || is_num(flat_end1) || len(flat_end1) == (has_fe1_u ? n_cols : n_rows), - str("nurbs_interp_surface: flat_end1 list must have ", has_fe1_u ? n_cols : n_rows, " entries")) - assert(!has_fe2 || is_num(flat_end2) || len(flat_end2) == (has_fe2_u ? n_cols : n_rows), - str("nurbs_interp_surface: flat_end2 list must have ", has_fe2_u ? n_cols : n_rows, " entries")) - // flat_edges assertions. - assert(!has_fe || (is_list(fe_norm) && len(fe_norm) == 4), - "nurbs_interp_surface: flat_edges must be a scalar or 4-element list [first_row, last_row, first_col, last_col]") - assert(!(has_fesu && has_sud), - "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with first_row_deriv") - assert(!(has_feeu && has_eud), - "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with last_row_deriv") - assert(!(has_fesv && has_svd), - "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with first_col_deriv") - assert(!(has_feev && has_evd), - "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with last_col_deriv") - assert(!(has_fesu && has_sun), - "nurbs_interp_surface: flat_edges[0] (first_row) conflicts with normal1 on same edge") - assert(!(has_feeu && has_eun), - "nurbs_interp_surface: flat_edges[1] (last_row) conflicts with normal2 on same edge") - assert(!(has_fesv && has_svn), - "nurbs_interp_surface: flat_edges[2] (first_col) conflicts with normal1 on same edge") - assert(!(has_feev && has_evn), - "nurbs_interp_surface: flat_edges[3] (last_col) conflicts with normal2 on same edge") - assert(!has_fesu || !is_list(fe_su) || len(fe_su) == n_cols, - str("nurbs_interp_surface: flat_edges[0] scale list must have ", n_cols, " entries (one per column)")) - assert(!has_feeu || !is_list(fe_eu) || len(fe_eu) == n_cols, - str("nurbs_interp_surface: flat_edges[1] scale list must have ", n_cols, " entries (one per column)")) - assert(!has_fesv || !is_list(fe_sv) || len(fe_sv) == n_rows, - str("nurbs_interp_surface: flat_edges[2] scale list must have ", n_rows, " entries (one per row)")) - assert(!has_feev || !is_list(fe_ev) || len(fe_ev) == n_rows, - str("nurbs_interp_surface: flat_edges[3] scale list must have ", n_rows, " entries (one per row)")) - // Edge (C0) validation. - assert(!has_ue || !row_wrap, - "nurbs_interp_surface: row_edges requires row_wrap=false") - assert(!has_ve || !col_wrap, - "nurbs_interp_surface: col_edges requires col_wrap=false") - assert(!has_ue || (min(ue_norm) >= 1 && max(ue_norm) <= n_rows-2), - str("nurbs_interp_surface: row_edges indices must be interior (1..", n_rows-2, ")")) - assert(!has_ve || (min(ve_norm) >= 1 && max(ve_norm) <= n_cols-2), - str("nurbs_interp_surface: col_edges indices must be interior (1..", n_cols-2, ")")) - // row_edges / col_edges are compatible with same-direction boundary derivatives, - // normals, and flat_edges: the first/last segment of the edge-aware system - // carries the boundary derivative constraint. + // If all k_eff picks come from equal-width spans, use centred stratification + // over eq_idxs so that constraint-narrowed spans are never selected. + n_eq >= k_eff + ? [for (g = [0:1:k_eff-1]) + let(i = eq_idxs[floor((2 * g + 1) * n_eq / (2 * k_eff))]) + (bar_knots[i] + bar_knots[i+1]) / 2 + ] + // Otherwise use widest-first selection (non-uniform spans). + : let( + sorted = sort([for (i = [0:1:n-1]) [spans[i], i]]), + top_k = [for (i = [n-1:-1:n-k_eff]) sorted[i]] + ) + [for (s = top_k) (bar_knots[s[1]] + bar_knots[s[1]+1]) / 2]; + + +// Find knot spans containing multiple data parameters and return +// splitting midpoints. Two data points in the same span cause a +// rank-deficient collocation matrix; inserting a knot between them +// restores full rank. +// +// bar_knots: sorted knot vector with n_spans+1 entries. +// params: sorted or unsorted data parameter values. +// +// Returns a list of splitting parameter values — one midpoint between +// each consecutive pair of params that share a span. + +function _span_split_params(bar_knots, params) = + let( + n_spans = len(bar_knots) - 1, + sorted = sort(params), + n_p = len(sorted), + // For each sorted param, find its span index. + span_of = [for (t = sorted) + let(pos = [for (i = [0:1:n_spans-1]) + if (t >= bar_knots[i] && + (i < n_spans-1 ? t < bar_knots[i+1] + : t <= bar_knots[i+1])) i]) + len(pos) > 0 ? pos[0] : n_spans - 1 + ] + ) + // Midpoints between consecutive sorted params sharing a span. + [for (i = [0:1:n_p-2]) + if (span_of[i] == span_of[i+1]) + (sorted[i] + sorted[i+1]) / 2 + ]; + + +// Build one row of the L^T*L matrix for control-polygon regularization. +// order=1: first-difference penalty (penalizes polygon length/variation). +// order=2: second-difference penalty (penalizes polygon bending). +// periodic=true wraps the differences around for closed curves. +// +// For clamped (non-periodic): +// order=1 L^T*L: tridiag [1,-1,0..] [-1,2,-1,0..] .. [0..,-1,1] +// order=2 L^T*L: pentadiag boundary-adapted +// For closed (periodic): +// order=1 L^T*L: circulant [2,-1,0..0,-1] +// order=2 L^T*L: circulant [6,-4,1,0..0,1,-4] + +function _ltl_row(M, i, order, periodic=false) = + periodic + ? (order == 1 + ? [for (j = [0:1:M-1]) + j == i ? 2 + : j == (i+1)%M || j == (i-1+M)%M ? -1 + : 0] + : // order == 2 + [for (j = [0:1:M-1]) + j == i ? 6 + : j == (i+1)%M || j == (i-1+M)%M ? -4 + : j == (i+2)%M || j == (i-2+M)%M ? 1 + : 0]) + : // clamped (non-periodic) + (order == 1 + ? [for (j = [0:1:M-1]) + j == i ? (i == 0 || i == M-1 ? 1 : 2) + : (j == i+1 || j == i-1) ? -1 + : 0] + : // order == 2, L is (M-2)×M second-difference matrix. + // (L^T L)[i][j] = sum_{r=0}^{M-3} L[r][i]*L[r][j] + // where L[r][c] = (c==r ? 1 : c==r+1 ? -2 : c==r+2 ? 1 : 0). + // Nonzero only when |i-j| <= 2. + [for (j = [0:1:M-1]) + abs(i-j) > 2 ? 0 + : i == j + ? (i <= M-3 ? 1 : 0) // r=i: 1² + + (i >= 1 && i <= M-2 ? 4 : 0) // r=i-1: (-2)² + + (i >= 2 ? 1 : 0) // r=i-2: 1² + : abs(i-j) == 1 + ? let(lo = min(i,j)) + (lo <= M-3 ? -2 : 0) // r=lo: (1)(-2) + + (lo >= 1 && lo <= M-2 ? -2 : 0) // r=lo-1: (-2)(1) + : // abs(i-j) == 2 + (min(i,j) <= M-3 ? 1 : 0) // r=min: (1)(1) + ]); + + +// Solve the constrained optimization min P^T·R·P s.t. A·P = rhs +// via null-space method. +// +// R = M×M regularization matrix (positive semidefinite). +// A = N×M constraint matrix (interpolation + derivative + curvature). +// rhs = N×dim right-hand side (data points + constraint vectors). +// +// Algorithm +// 1. Step A — minimum-norm particular solution x_p satisfying A·x_p = rhs +// exactly, via BOSL2 linear_solve() (handles underdetermined systems). +// 2. Step B — minimize x^T·R·x in the null space of A (if M > N): +// Q2 = null_space(A) basis vectors (returned as rows by BOSL2) +// H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) +// Solve H · z = -Q2^T · R_pd · x_p via Cholesky +// P = x_p + Q2 · z +// +// Returns list of M control points, or undef on rank-deficient A. + +function _nullspace_solve(R, A, rhs, eps=1e-6) = let( - // Boundary plane for flat_edges=: cross product of two perimeter vectors. - // Guarded so degenerate geometry can't produce NaN when flat_edges is unused. - fe_e1 = has_fe ? (points[0][n_cols-1] - points[0][0]) : [1,0,0], - fe_e2 = has_fe ? (points[n_rows-1][0] - points[0][0]) : [0,1,0], - fe_N_raw = has_fe ? cross(fe_e1, fe_e2) : [0,0,1], - fe_N_hat = fe_N_raw / max(norm(fe_N_raw), 1e-15), - // Per-edge flat-outward derivative lists; undef when edge not active. - // Direction at each point: from adjacent interior point toward edge, - // projected into the boundary plane, then normalized and scaled. - flat_su_der = !has_fesu ? undef : - [for (j = [0:1:n_cols-1]) - let( - d = points[1][j] - points[0][j], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_su) ? fe_su[j] : fe_su - ) d_hat * s], - flat_eu_der = !has_feeu ? undef : - [for (j = [0:1:n_cols-1]) - let( - d = points[n_rows-1][j] - points[n_rows-2][j], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_eu) ? fe_eu[j] : fe_eu - ) d_hat * s], - flat_sv_der = !has_fesv ? undef : - [for (k = [0:1:n_rows-1]) - let( - d = points[k][1] - points[k][0], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_sv) ? fe_sv[k] : fe_sv - ) d_hat * s], - flat_ev_der = !has_feev ? undef : - [for (k = [0:1:n_rows-1]) - let( - d = points[k][n_cols-1] - points[k][n_cols-2], - d_flat = d - (d * fe_N_hat) * fe_N_hat, - d_hat = d_flat / max(norm(d_flat), 1e-15), - s = is_list(fe_ev) ? fe_ev[k] : fe_ev - ) d_hat * s] + M = len(R), + N_rows = len(A), + // Step A: minimum-norm particular solution via BOSL2. + // linear_solve handles underdetermined (M > N_rows) systems + // by returning the minimum-norm solution via QR of A^T. + x_p = linear_solve(A, rhs) ) - assert(!has_fesu || min([for (j = [0:1:n_cols-1]) let(d = points[1][j] - points[0][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[0] (first_row) direction is perpendicular to the boundary plane at one or more points") - assert(!has_feeu || min([for (j = [0:1:n_cols-1]) let(d = points[n_rows-1][j] - points[n_rows-2][j], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[1] (last_row) direction is perpendicular to the boundary plane at one or more points") - assert(!has_fesv || min([for (k = [0:1:n_rows-1]) let(d = points[k][1] - points[k][0], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[2] (first_col) direction is perpendicular to the boundary plane at one or more points") - assert(!has_feev || min([for (k = [0:1:n_rows-1]) let(d = points[k][n_cols-1] - points[k][n_cols-2], d_flat = d - (d * fe_N_hat) * fe_N_hat) norm(d_flat)]) > 1e-10, - "nurbs_interp_surface: flat_edges[3] (last_col) direction is perpendicular to the boundary plane at one or more points") - assert(!has_fe || is_coplanar(concat( - points[0], points[n_rows-1], - [for (k = [1:1:n_rows-2]) points[k][0]], - [for (k = [1:1:n_rows-2]) points[k][n_cols-1]]), eps=1e-6), - "nurbs_interp_surface: flat_edges= requires all four boundary edges to be coplanar") - let( - // Compute effective derivative lists. - // Priority: normal1/normal2 (apex) > flat_end1/flat_end2 (coplanar) > flat_edges > explicit *_der=. - // Apex (all boundary points identical): fan outward from apex, user axis vector N. - // End-edge apex tangents are negated because _apex_tangents() returns outward - // (apex→ring) vectors; negating gives inward (ring→apex), making the surface - // converge to the apex tip at the correct parametric direction. - // Coplanar (flat_end): _coplanar_inward_tangents() returns in-plane vectors - // oriented toward the polygon interior using the polygon winding order. - // Positive scale closes inward, negative flares outward. - // flat_end1 result is negated: _coplanar_inward_tangents returns outward - // for the start boundary; negating gives the correct inward direction. - // flat_end2 uses the same function without negation (end boundary sign matches). - // Periodic tangent differences used when the cross-direction is "closed". - first_row_deriv_eff = has_sun - ? _apex_tangents(normal1, points[0][0], points[1]) - : has_fe1_u - ? [for (v = _coplanar_inward_tangents(flat_end1, points[0], points[1], - periodic=col_wrap)) -v] - : has_fesu ? flat_su_der - : first_row_deriv, - last_row_deriv_eff = has_eun - ? [for (v = _apex_tangents(normal2, points[n_rows-1][0], points[n_rows-2])) -v] - : has_fe2_u - ? _coplanar_inward_tangents(flat_end2, points[n_rows-1], points[n_rows-2], - periodic=col_wrap) - : has_feeu ? flat_eu_der - : last_row_deriv, - first_col_deriv_eff = has_svn - ? _apex_tangents(normal1, points[0][0], - [for (k = [0:1:n_rows-1]) points[k][1]]) - : has_fe1_v - ? [for (v = _coplanar_inward_tangents(flat_end1, - [for (k = [0:1:n_rows-1]) points[k][0]], - [for (k = [0:1:n_rows-1]) points[k][1]], - periodic=row_wrap)) -v] - : has_fesv ? flat_sv_der - : first_col_deriv, - last_col_deriv_eff = has_evn - ? [for (v = _apex_tangents(normal2, points[0][n_cols-1], - [for (k = [0:1:n_rows-1]) points[k][n_cols-2]])) -v] - : has_fe2_v - ? _coplanar_inward_tangents(flat_end2, - [for (k = [0:1:n_rows-1]) points[k][n_cols-1]], - [for (k = [0:1:n_rows-1]) points[k][n_cols-2]], - periodic=row_wrap) - : has_feev ? flat_ev_der - : last_col_deriv, - has_sud_eff = has_sud || has_sun || has_fesu || has_fe1_u, - has_eud_eff = has_eud || has_eun || has_feeu || has_fe2_u, - has_svd_eff = has_svd || has_svn || has_fesv || has_fe1_v, - has_evd_eff = has_evd || has_evn || has_feev || has_fe2_v + x_p == [] ? undef + : M == N_rows ? x_p // Square: unique solution, no null space. + : let( + // Step B: minimize x^T·R·x in the null space. + // null_space() returns null-space vectors as rows. + ns = null_space(A), + n_ns = len(ns) ) - // row_edges / col_edges boundary-derivative segment-size checks. - // A derivative-carrying edge segment needs at least 3 rows/columns; - // with only 2 the degree-reduced knot vector becomes degenerate. - assert(!(has_ue && has_sud_eff && ue_norm[0] + 1 < 3), - !has_ue ? "" : - str("nurbs_interp_surface: row_edges=", ue_norm, - " creates a ", ue_norm[0]+1, "-row first segment (rows 0-", - ue_norm[0], ") which is too short to carry the start-u derivative constraint. ", - "Move the first row_edges index to at least 2")) - assert(!(has_ue && has_eud_eff && n_rows - last(ue_norm) < 3), - !has_ue ? "" : - str("nurbs_interp_surface: row_edges=", ue_norm, - " creates a ", n_rows - last(ue_norm), "-row last segment (rows ", - last(ue_norm), "-", n_rows-1, ") which is too short to carry the end-u derivative constraint. ", - "Move the last row_edges index to at most ", n_rows - 3)) - assert(!(has_ve && has_svd_eff && ve_norm[0] + 1 < 3), - !has_ve ? "" : - str("nurbs_interp_surface: col_edges=", ve_norm, - " creates a ", ve_norm[0]+1, "-column first segment (columns 0-", - ve_norm[0], ") which is too short to carry the start-v derivative constraint. ", - "Move the first col_edges index to at least 2")) - assert(!(has_ve && has_evd_eff && n_cols - last(ve_norm) < 3), - !has_ve ? "" : - str("nurbs_interp_surface: col_edges=", ve_norm, - " creates a ", n_cols - last(ve_norm), "-column last segment (columns ", - last(ve_norm), "-", n_cols-1, ") which is too short to carry the end-v derivative constraint. ", - "Move the last col_edges index to at most ", n_cols - 3)) + n_ns == 0 ? x_p // Full rank despite M > N; no null space. + : let( + Q2 = transpose(ns), // M × n_ns (columns are basis vectors) + // Regularize R for strict positive-definiteness. + R_pd = [for (i = [0:1:M-1]) + [for (j = [0:1:M-1]) + R[i][j] + (i == j ? eps : 0)]], + // H = Q2^T · R_pd · Q2 (n_ns × n_ns, SPD) + // Symmetrize to counteract floating-point round-off. + RQ2 = R_pd * Q2, + H_raw = transpose(Q2) * RQ2, + H = (H_raw + transpose(H_raw)) / 2, + // g = Q2^T · R_pd · x_p (n_ns × dim) + g = transpose(Q2) * (R_pd * x_p), + // Solve H · z = -g (H is SPD → Cholesky is fastest) + z = linear_solve(H, -g, method="cholesky") + ) + // If H solve fails (degenerate), x_p alone still satisfies constraints. + z == [] ? x_p + : x_p + Q2 * z; + + +// Gauss-Legendre quadrature nodes and weights on [-1,1]. +// Returns [[nodes], [weights]] for n-point rule (n = 2..5). +// Exact for polynomials up to degree 2n-1. + +function _gauss_legendre(n) = + n == 2 ? [[-0.5773502691896258, 0.5773502691896258], + [1.0, 1.0]] + : n == 3 ? [[-0.7745966692414834, 0.0, 0.7745966692414834], + [0.5555555555555556, 0.8888888888888888, 0.5555555555555556]] + : n == 4 ? [[-0.8611363115940526, -0.3399810435848563, + 0.3399810435848563, 0.8611363115940526], + [0.3478548451374538, 0.6521451548625461, + 0.6521451548625461, 0.3478548451374538]] + : // n >= 5 + [[-0.9061798459386640, -0.5384693101056831, 0.0, + 0.5384693101056831, 0.9061798459386640], + [0.2369268850561891, 0.4786286704993665, 0.5688888888888889, + 0.4786286704993665, 0.2369268850561891]]; + + +// One step of the de Boor recurrence: lifts degree-(k-1) to degree-k basis values +// at parameter t in span s of U. +// b_prev[lj] = N_{s-(k-1)+lj, k-1}(t) for lj = 0..k-1 (k entries) +// Returns b[lj] = N_{s-k+lj, k}(t) for lj = 0..k (k+1 entries) + +function _deboor_step(b_prev, k, s, t, U) = + [for (lj = [0:1:k]) + let( + j = s - k + lj, + e1 = U[s + lj] - U[j], // U[j+k] - U[j] + e2 = U[s + lj + 1] - U[j + 1] // U[j+k+1] - U[j+1] + ) + (lj > 0 && abs(e1) > 1e-15 ? (t - U[j]) / e1 * b_prev[lj - 1] : 0) + + (lj < k && abs(e2) > 1e-15 ? (U[s+lj+1] - t) / e2 * b_prev[lj] : 0) + ]; + + +// Returns the (k+1)-element vector of non-zero degree-k basis values at t in span s: +// b[lj] = N_{s-k+lj, k}(t) for lj = 0..k. + +function _deboor_to_degree(s, k, t, U) = + k == 0 ? [1] + : _deboor_step(_deboor_to_degree(s, k - 1, t, U), k, s, t, U); + + +// Returns the (p+1)-element vector of non-zero degree-p second-derivative values +// at parameter t, which lies in knot span s of U. +// d2[lj] = N''_{s-p+lj, p}(t) for lj = 0..p. +// Uses the de Boor triangle to degree p-2, then lifts twice via the derivative +// recurrence (P&T §2.3 eq. 2.9): O(p²) work instead of M separate _d2nip() calls. + +function _d2nip_span(s, p, t, U) = + p <= 1 + ? [for (lj = [0:1:p]) 0] + : let( + // Degree-(p-2) basis: b2[lj] = N_{s-(p-2)+lj, p-2}(t) for lj = 0..p-2. + b2 = _deboor_to_degree(s, p - 2, t, U), + + // First lift: d1[lj] = N'_{s-(p-1)+lj, p-1}(t) for lj = 0..p-1. + // N'_{j,p-1} = (p-1)/(U[j+p-1]-U[j])*N_{j,p-2} - (p-1)/(U[j+p]-U[j+1])*N_{j+1,p-2} + // with N_{j,p-2} = b2[lj-1] and N_{j+1,p-2} = b2[lj]. + q1 = p - 1, + d1 = [for (lj = [0:1:q1]) + let( + j = s - q1 + lj, + e1 = U[s + lj] - U[j], // U[j+q1] - U[j] + e2 = U[s + lj + 1] - U[j + 1] // U[j+q1+1] - U[j+1] + ) + (lj > 0 && abs(e1) > 1e-15 ? q1 * b2[lj - 1] / e1 : 0) + - (lj < q1 && abs(e2) > 1e-15 ? q1 * b2[lj] / e2 : 0) + ], + + // Second lift: d2[lj] = N''_{s-p+lj, p}(t) for lj = 0..p. + // N''_{j,p} = p/(U[j+p]-U[j])*N'_{j,p-1} - p/(U[j+p+1]-U[j+1])*N'_{j+1,p-1} + // with N'_{j,p-1} = d1[lj-1] and N'_{j+1,p-1} = d1[lj]. + d2 = [for (lj = [0:1:p]) + let( + j = s - p + lj, + e1 = U[s + lj] - U[j], // U[j+p] - U[j] + e2 = U[s + lj + 1] - U[j + 1] // U[j+p+1] - U[j+1] + ) + (lj > 0 && abs(e1) > 1e-15 ? p * d1[lj - 1] / e1 : 0) + - (lj < p && abs(e2) > 1e-15 ? p * d1[lj] / e2 : 0) + ] + ) + d2; + + +// Bending-energy regularization matrix R for the null-space solver. +// R[j][k] = ∫ B''_j(t) B''_k(t) dt (integrated squared second derivative). +// For clamped: B_j = N_{j,p}, integrated over the full domain. +// For closed/periodic: B_j = N_j + (j

p → 0 for clamped; circular +// distance > p → 0 for periodic) with per-span second derivatives supplied by +// _d2nip_span: O(p²) per quadrature point instead of O(M·p²) with individual +// _d2nip() calls. + +function _bending_energy_matrix(M, p, U_full, periodic=false) = let( - // Averaged parameterization in each direction - u_params = _surface_params_u(points, method, row_wrap), - v_params = _surface_params_v(points, method, col_wrap), + n_gauss = max(2, p - 1), + gl = _gauss_legendre(n_gauss), + gl_nodes = gl[0], + gl_wts = gl[1], + n_knots = len(U_full), + span_lo = periodic ? p : 0, + span_hi = periodic ? M + p - 1 : n_knots - 2, - // Per-row v-direction path lengths for scaling v-boundary tangents. - // Follows the curve convention: user passes normalized vectors; code - // scales by total chord length so a unit vector gives natural speed. - v_path_lens = [for (k = [0:1:n_rows-1]) path_length(points[k])], + // Per-quadrature-point data: [span_index, weight, d2_local]. + // d2_local[lj] = N''_{s-p+lj, p}(t) for lj = 0..p (p+1 unaliased values). + quad_data = [for (i = [span_lo:1:span_hi]) + if (U_full[i+1] - U_full[i] > 1e-15) + let(a = U_full[i], b = U_full[i+1], + hw = (b - a) / 2, mid = (a + b) / 2) + for (g = [0:1:n_gauss-1]) + let(t = mid + hw * gl_nodes[g], + w = gl_wts[g] * hw) + [i, w, _d2nip_span(i, p, t, U_full)] + ], + nq = len(quad_data) + ) + // Banded assembly: skip entries where j and k have no overlapping support. + // Clamped: zero when |j-k| > p. + // Periodic: zero when circular distance min(|j-k|, M-|j-k|) > p. + [for (j = [0:1:M-1]) + [for (k = [0:1:M-1]) + (periodic ? min(abs(j - k), M - abs(j - k)) > p : abs(j - k) > p) + ? 0 + : sum([for (q = [0:1:nq-1]) + let( + s = quad_data[q][0], + w = quad_data[q][1], + d2v = quad_data[q][2], + // Local indices of global bases j and k in this span. + lj = j - (s - p), + lk = k - (s - p), + // Periodic aliasing: unaliased index j+M (resp. k+M) + // may also land in the support [s-p, s] of this span. + lj_a = periodic ? j + M - (s - p) : -1, + lk_a = periodic ? k + M - (s - p) : -1, + // Direct values (unaliased index in support of span s). + vj = (lj >= 0 && lj <= p) ? d2v[lj] : 0, + vk = (lk >= 0 && lk <= p) ? d2v[lk] : 0, + // Aliased values (only for j < p with j+M in support). + vj_a = (periodic && j < p && lj_a >= 0 && lj_a <= p) ? d2v[lj_a] : 0, + vk_a = (periodic && k < p && lk_a >= 0 && lk_a <= p) ? d2v[lk_a] : 0, + Bj = vj + vj_a, + Bk = vk + vk_a + ) + w * Bj * Bk + ]) + ] + ]; - // Per-column u-direction path lengths for scaling u-boundary tangents. - u_path_lens = [for (l = [0:1:n_cols-1]) - path_length([for (k = [0:1:n_rows-1]) points[k][l]])], - // ----- Build v-direction system ----- - // When col_edges is active, precompute per-segment collocation systems. - // Otherwise use the standard (or derivative-extended) system. - v_edge_sys = has_ve - ? _build_edge_systems(v_params, p_v, ve_norm, - has_sd=has_svd_eff, - has_ed=has_evd_eff, - extra_pts=ep_v, label="v") : undef, - v_sys = has_ve ? undef - : (has_svd_eff || has_evd_eff) - ? _build_clamped_system_with_derivs(v_params, p_v, has_svd_eff, has_evd_eff, ep_v) - : _build_interp_system(v_params, p_v, col_wrap ? "closed" : "clamped", ep_v), - N_v = has_ve ? undef : v_sys[0], - // When underdetermined (extra_pts), build regularization matrix for v. - M_v = has_ve ? undef : len(N_v[0]), - N_rows_v = has_ve ? undef : len(N_v), - ns_v = !has_ve && M_v > N_rows_v, - R_reg_v = !ns_v ? undef - : let(vk = v_sys[1], - vint = !col_wrap - ? [for (i = [1:1:len(vk)-2]) vk[i]] - : undef, - vU = !col_wrap - ? _full_clamped_knots(vint, p_v) - : _full_closed_knots(vk, M_v, p_v)) - _regularization_matrix(M_v, smooth_v, p_v, vU, periodic=col_wrap), +// Regularization matrix dispatcher. +// Returns an M×M regularization matrix: L^T L difference matrix when smooth<=2, +// integrated squared second-derivative (bending energy) matrix otherwise. - // ----- Pass 1: Interpolate rows in v-direction ----- - // With col_edges: solve each row via edge-aware segmented system. - // Without: same A_v matrix for every row; only the RHS changes per row. - R_raw = has_ve - ? [for (k = [0:1:n_rows-1]) - _solve_with_edges(v_edge_sys, points[k], - v_params, ve_norm, p_v, - start_deriv = has_svd_eff - ? _force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k] - : undef, - end_deriv = has_evd_eff - ? _force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k] - : undef, - smooth = smooth_v)] - : undef, - R = has_ve - ? [for (r = R_raw) r[0]] - : [for (k = [0:1:n_rows-1]) - let(rhs = concat( - points[k], - has_svd_eff - ? [_force_deriv_dim(first_col_deriv_eff[k], dim) * v_path_lens[k]] - : [], - has_evd_eff - ? [_force_deriv_dim(last_col_deriv_eff[k], dim) * v_path_lens[k]] - : [])) - ns_v ? _nullspace_solve(R_reg_v, N_v, rhs) - : linear_solve(N_v, rhs) - ], +function _regularization_matrix(M, smooth, p, U_full, periodic=false) = + smooth <= 2 + ? [for (i = [0:1:M-1]) _ltl_row(M, i, smooth, periodic=periodic)] + : _bending_energy_matrix(M, p, U_full, periodic=periodic); - v_knots = has_ve ? R_raw[0][1] : v_sys[1], - n_v_ctrl = len(R[0]), - // ----- Pass 1.5: Project u-boundary tangents into v-control space ----- - // ∂S/∂u along u=0 or u=1 is given at the n_cols data v-positions. - // To use them as derivative RHS in the u-direction column solves, we - // must express them in the v B-spline control basis — done by solving - // the same v-system. When col_edges is active, project through the - // edge-aware segmented system instead. - zero_v = repeat(0, dim), - _su_der_data = has_sud_eff - ? [for (l = [0:1:n_cols-1]) - _force_deriv_dim(first_row_deriv_eff[l], dim) * u_path_lens[l]] - : undef, - _eu_der_data = has_eud_eff - ? [for (l = [0:1:n_cols-1]) - _force_deriv_dim(last_row_deriv_eff[l], dim) * u_path_lens[l]] - : undef, - T_u_start = has_sud_eff - ? has_ve - ? _solve_with_edges(v_edge_sys, _su_der_data, - v_params, ve_norm, p_v, - start_deriv = has_svd_eff ? zero_v : undef, - end_deriv = has_evd_eff ? zero_v : undef, - smooth = smooth_v)[0] - : let(_rhs = concat(_su_der_data, - has_svd_eff ? [zero_v] : [], - has_evd_eff ? [zero_v] : [])) - ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) - : linear_solve(N_v, _rhs) - : undef, - T_u_end = has_eud_eff - ? has_ve - ? _solve_with_edges(v_edge_sys, _eu_der_data, - v_params, ve_norm, p_v, - start_deriv = has_svd_eff ? zero_v : undef, - end_deriv = has_evd_eff ? zero_v : undef, - smooth = smooth_v)[0] - : let(_rhs = concat(_eu_der_data, - has_svd_eff ? [zero_v] : [], - has_evd_eff ? [zero_v] : [])) - ns_v ? _nullspace_solve(R_reg_v, N_v, _rhs) - : linear_solve(N_v, _rhs) - : undef, +// Full periodic knot vector for "closed" type evaluation. +// Uses BOSL2's _extend_knot_vector() to build the n+2p+1 entry knot vector +// that nurbs_curve() constructs internally for closed-type curves. +// Active evaluation domain: [U[p], U[n+p]]. + +function _full_closed_knots(bar_knots, n, p) = + _extend_knot_vector(bar_knots, 0, n + 2*p + 1); + + +// Collocation Matrices + +// Standard collocation matrix for clamped type. + +function _collocation_matrix(params, n, p, U) = + [for (k = [0:1:n]) + [for (j = [0:1:n]) + _nip(j, p, params[k], U) + ] + ]; + + +// Periodic collocation matrix for closed type (n x n). +// +// BOSL2 wraps the first p control points to the end, creating n+p +// basis functions. Basis N_{j+n} aliases control point j for j= p + +function _collocation_matrix_periodic(params, n, p, U_periodic) = + [for (k = [0:1:n-1]) + [for (j = [0:1:n-1]) + _nip(j, p, params[k], U_periodic) + + (j < p ? _nip(j + n, p, params[k], U_periodic) : 0) + ] + ]; + + +// Degree Elevation + +// Greville abscissae for B-spline basis of degree p with full knot +// vector U. Returns n+1 values where n = len(U) - p - 2. Each g_i +// is the average of knots U[i+1] .. U[i+p]. For a clamped knot +// vector, g_0 = 0 and g_n = 1. These are optimal collocation sites +// for the B-spline space and automatically satisfy the Schoenberg- +// Whitney condition for non-singular collocation. + +function _greville(U, p) = + let(n = len(U) - p - 2) + [for (i = [0:1:n]) + sum([for (j = [i+1:1:i+p]) U[j]]) / p + ]; - // ----- Build u-direction system ----- - // When row_edges is active, precompute per-segment systems. - u_edge_sys = has_ue - ? _build_edge_systems(u_params, p_u, ue_norm, - has_sd=has_sud_eff, - has_ed=has_eud_eff, - extra_pts=ep_u, label="u") : undef, - u_sys = has_ue ? undef - : (has_sud_eff || has_eud_eff) - ? _build_clamped_system_with_derivs(u_params, p_u, has_sud_eff, has_eud_eff, ep_u) - : _build_interp_system(u_params, p_u, row_wrap ? "closed" : "clamped", ep_u), - N_u = has_ue ? undef : u_sys[0], - // When underdetermined (extra_pts), build regularization matrix for u. - M_u = has_ue ? undef : len(N_u[0]), - N_rows_u = has_ue ? undef : len(N_u), - ns_u = !has_ue && M_u > N_rows_u, - R_reg_u = !ns_u ? undef - : let(uk = u_sys[1], - uint = !row_wrap - ? [for (i = [1:1:len(uk)-2]) uk[i]] - : undef, - uU = !row_wrap - ? _full_clamped_knots(uint, p_u) - : _full_closed_knots(uk, M_u, p_u)) - _regularization_matrix(M_u, smooth_u, p_u, uU, periodic=row_wrap), - // ----- Pass 2: Interpolate columns in u-direction ----- - // Transpose R so each entry is a column of intermediate points. - R_T = [for (j = [0:1:n_v_ctrl-1]) - [for (k = [0:1:n_rows-1]) R[k][j]]], +// Increment the multiplicity of every distinct value in a knot vector +// by 1. Walk the vector; at the end of each run of equal values emit +// one extra copy. Equivalent to the new_interior construction in +// _elevate_once_clamped but applied to the complete (full) knot vector. +// Used by _elevate_once_open. - // With row_edges: solve each column via edge-aware segmented system. - // Without: add u-tangent constraint rows to the RHS for each column j. - P_T_raw = has_ue - ? [for (j = [0:1:n_v_ctrl-1]) - _solve_with_edges(u_edge_sys, R_T[j], - u_params, ue_norm, p_u, - start_deriv = has_sud_eff ? T_u_start[j] : undef, - end_deriv = has_eud_eff ? T_u_end[j] : undef, - smooth = smooth_u)] - : undef, - P_T = has_ue - ? [for (r = P_T_raw) r[0]] - : [for (j = [0:1:n_v_ctrl-1]) - let(rhs = concat( - R_T[j], - has_sud_eff ? [T_u_start[j]] : [], - has_eud_eff ? [T_u_end[j]] : [])) - ns_u ? _nullspace_solve(R_reg_u, N_u, rhs) - : linear_solve(N_u, rhs) - ], +function _increment_knot_mults(U) = + [for (i = [0:1:len(U)-1]) each + [U[i], + if (i == len(U)-1 || abs(U[i+1] - U[i]) > 1e-14) U[i]] + ]; - u_knots = has_ue ? P_T_raw[0][1] : u_sys[1], - // Transpose back to get the final control point grid. - n_u_ctrl = len(P_T[0]), - P = [for (i = [0:1:n_u_ctrl-1]) - [for (j = [0:1:n_v_ctrl-1]) P_T[j][i]]] +// Single degree elevation of a clamped or open B-spline via exact collocation. +// +// The elevated curve lies in the degree-(p+1) B-spline space whose knot +// vector has each distinct value's multiplicity incremented by 1. +// Evaluating the original curve at the Greville abscissae of the new basis +// and solving the collocation system recovers the exact elevated control +// points (the new space contains the original curve exactly). +// +// Input ctrl = control points (any dimension >= 1) +// p = current degree (>= 1) +// U = full expanded knot vector (all multiplicities present) +// Output [new_ctrl, U_new, p+1] +// U_new is the full expanded elevated knot vector. + +function _elevate_once(ctrl, p, U) = + let( + n_old = len(ctrl) - 1, + dim = len(ctrl[0]), + p_new = p + 1, + U_new = _increment_knot_mults(U), + n_new = len(U_new) - p_new - 2, + grev = _greville(U_new, p_new), + C_vals = [for (u = grev) + let(row = [for (j = [0:1:n_old]) _nip(j, p, u, U)]) + [for (d = [0:1:dim-1]) + sum([for (j = [0:1:n_old]) row[j] * ctrl[j][d]])] + ], + A = [for (k = [0:1:n_new]) + [for (i = [0:1:n_new]) _nip(i, p_new, grev[k], U_new)] + ], + Q = linear_solve(A, C_vals) ) - [[row_wrap ? "closed" : "clamped", col_wrap ? "closed" : "clamped"], - [p_u, p_v], P, [u_knots, v_knots], undef, undef, - [u_params, v_params]]; + assert(Q != [], + "nurbs_elevate_degree: singular collocation (should not happen)") + [Q, U_new, p_new]; + + -module nurbs_interp_surface(points, degree, splinesteps=16, - method="centripetal", - row_wrap=false, col_wrap=false, - style="default", reverse=false, triangulate=false, - caps=undef, cap1=undef, cap2=undef, - first_row_deriv=undef, last_row_deriv=undef, - first_col_deriv=undef, last_col_deriv=undef, - normal1=undef, normal2=undef, - flat_end1=undef, flat_end2=undef, - flat_edges=undef, - row_edges=undef, col_edges=undef, - extra_pts=0, smooth=3, - data_color="red", data_size=0, - atype="hull", convexity=10, cp="centroid", anchor="origin", spin=0, orient=UP -) - { - result = nurbs_interp_surface(points, degree, - method=method, row_wrap=row_wrap, col_wrap=col_wrap, - first_row_deriv=first_row_deriv, last_row_deriv=last_row_deriv, - first_col_deriv=first_col_deriv, last_col_deriv=last_col_deriv, - normal1=normal1, normal2=normal2, - flat_end1=flat_end1, flat_end2=flat_end2, - flat_edges=flat_edges, - row_edges=row_edges, col_edges=col_edges, - extra_pts=extra_pts, smooth=smooth); - nurbs_vnf(result, splinesteps=splinesteps, style=style, - reverse=reverse, triangulate=triangulate, - caps=caps, cap1=cap1, cap2=cap2, convexity=convexity, atype=atype, anchor=anchor, spin=spin, orient=orient) children(); - if (data_size > 0) - color(data_color) - for (row = points) - for (pt = row) - translate(pt) sphere(r=data_size, $fn=16); -} // ---------- CLAMPED interpolation ---------- @@ -4089,7 +4216,7 @@ function _surface_params_v(points, method, periodic) = -// Section: Usage Examples + // // Example(2D): Clamped curve (default) // data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; @@ -4100,131 +4227,3 @@ function _surface_params_v(points, method, periodic) = // data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; // debug_nurbs_interp(data, 3, closed=true); // -// Example(2D): Closed polygon -// // All data points lie exactly on the polygon boundary. -// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; -// path = nurbs_interp_curve(data, 3, splinesteps=16, closed=true); -// polygon(path); -// color("red") move_copies(data) circle(r=0.25, $fn=16); -// -// Example(2D): Get just the path -// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; -// path = nurbs_interp_curve(data, 3, splinesteps=32); -// stroke(path, width=0.5); -// color("red") move_copies(data) circle(r=0.25, $fn=16); -// -// Example(2D): Low-level NURBS parameter list -// // nurbs_interp() returns a BOSL2 NURBS parameter list compatible -// // with nurbs_curve(), debug_nurbs(), etc. -// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; -// result = nurbs_interp(data, 3); -// curve = nurbs_curve(result, splinesteps=24); -// stroke(curve, width=0.5); -// -// Example(3D): 3D closed curve -// data3d = [[20,0,0],[0,20,10],[-20,0,20],[0,-20,10]]; -// path = nurbs_interp_curve(data3d, 3, splinesteps=32, closed=true); -// stroke(path, width=1, closed=true); -// color("red") move_copies(data3d) sphere(r=0.25, $fn=16); -// -// Example(2D): Parameterization methods for sharp turns -// // "length" (blue), "centripetal" (red), "dynamic" (orange) compared. -// // For data with sudden direction changes or uneven chord spacing, -// // "centripetal" and "dynamic" reduce unwanted oscillations. -// sharp = [[0,0], [5,40],[6,40], [10,0], [50,0], [55,40],[56,42], [60,0]]; -// color("blue") stroke(nurbs_interp_curve(sharp, 3), width=0.1); -// color("red") stroke(nurbs_interp_curve(sharp, 3, method="centripetal"), width=0.1); -// color("orange") stroke(nurbs_interp_curve(sharp, 3, method="dynamic"), width=0.1); -// color("green") move_copies(sharp) circle(r=.1, $fn=16); -// -// Example(2D): Endpoint tangent control -// // Specify start and/or end tangent vectors. Each vector is automatically -// // scaled by the total chord length; a unit vector produces natural -// // arc-length speed. Magnitude > 1 increases pull, < 1 weakens it. -// data = [[0,0], [20,30], [50,25], [80,0]]; -// // No tangent control (natural): -// color("gray") stroke(nurbs_interp_curve(data, 3), width=0.3); -// // Start going straight up, end going straight down: -// color("blue") stroke( -// nurbs_interp_curve(data, 3, start_deriv=[0,1], end_deriv=[0,-1]), -// width=0.3); -// // Start going right, end going right: -// color("red") stroke( -// nurbs_interp_curve(data, 3, start_deriv=[1,0], end_deriv=[1,0]), -// width=0.3); -// color("black") move_copies(data) circle(r=0.25, $fn=16); -// -// Example(2D): Start tangent only -// data = [[0,0], [20,30], [50,25], [80,0]]; -// color("gray") stroke(nurbs_interp_curve(data, 3), width=0.3); -// color("blue") stroke( -// nurbs_interp_curve(data, 3, start_deriv=[0,1]), -// width=0.3); -// color("black") move_copies(data) circle(r=0.25, $fn=16); -// -// -// Section: Surface Interpolation Examples -// -// Example(3D): Basic surface interpolation -// // A 4x5 grid of 3D data points produces a smooth interpolating surface. -// data = [ -// [[-50, 50, 0], [-16, 50, 20], [ 16, 50, 10], [50, 50, 0], [80, 50, 5]], -// [[-50, 16, 20], [-16, 16, 40], [ 16, 16, 30], [50, 16, 20], [80, 16, 10]], -// [[-50,-16, 20], [-16,-16, 35], [ 16,-16, 40], [50,-16, 15], [80,-16, 25]], -// [[-50,-50, 0], [-16,-50, 10], [ 16,-50, 20], [50,-50, 0], [80,-50, 5]], -// ]; -// nurbs_interp_surface(data, 3, splinesteps=8); -// -// Example(3D): Different degrees per direction -// // Quadratic in u (rows), cubic in v (columns). -// data = [ -// for (u = [-40:20:40]) -// [for (v = [-40:20:40]) -// [v, u, 15*sin(u*3)*cos(v*3)]] -// ]; -// nurbs_interp_surface(data, [2,3], splinesteps=8); -// -// Example(3D): Tube (surface closed in one direction) -// // Closed around the column direction (the rings), clamped along rows -// // (the axis). Uses 5 rings: a cubic closed direction needs at least -// // p+2 = 5 data points to have interior knot freedom. -// r = 20; -// data = [for (u = [0:15:60]) -// [for (i = [0:1:5]) -// let(a = i * 360/6) -// [r*cos(a), r*sin(a), u]] -// ]; -// nurbs_interp_surface(data, 3, splinesteps=8, col_wrap=true); -// -// Example(3D): Torus (surface closed in both directions) -// // Both directions sample a full 360 circle with even angular spacing, -// // so the closing segment equals the inter-point spacing and -// // parameterization is uniform. Each direction uses N=6 > p+1=4 -// // points to ensure interior knot freedom. -// R = 30; r = 10; -// N = 6; -// data = [for (i = [0:1:N-1]) -// let(phi = i * 360/N) -// [for (j = [0:1:N-1]) -// let(theta = j * 360/N) -// [(R + r*cos(theta))*cos(phi), -// (R + r*cos(theta))*sin(phi), -// r*sin(theta)]] -// ]; -// nurbs_interp_surface(data, 3, splinesteps=12, -// row_wrap=true, col_wrap=true); -// -// Example(3D): Low-level surface access -// // nurbs_interp_surface() returns a BOSL2 NURBS parameter list -// // compatible with nurbs_vnf(), debug_nurbs(), etc. -// data = [ -// [[-30,30,0], [0,30,20], [30,30,0]], -// [[-30, 0,10],[0, 0,30], [30, 0,10]], -// [[-30,-30,0],[0,-30,15],[30,-30,0]], -// ]; -// result = nurbs_interp_surface(data, 2); -// vnf = nurbs_vnf(result, splinesteps=12); -// vnf_polyhedron(vnf); -// color("red") -// for (row = data) for (pt = row) -// translate(pt) sphere(r=1, $fn=16); From c69239a214e1877e0380499cb71e8df189ad39ee Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Sat, 2 May 2026 12:51:03 -0700 Subject: [PATCH 07/16] Weekend Update --- nurbs.scad | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/nurbs.scad b/nurbs.scad index 1cb98012..6a8f568a 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -710,8 +710,30 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // width=0.3); // color("black") move_copies(data) circle(r=0.25, $fn=16); // +// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): We can generate a heart shape with a clamped NURBS where the first and last data points are co-incident, and we insert a corner at data point 4. +// data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20], [0,10]]; +// debug_nurbs_interp(data, 3, closed = false, method = "centripetal", corners=[4]); +// path = nurbs_curve(nurbs_interp(data, 3, closed = false, method = "centripetal", corners=[4])); +// right(75) stroke(path, closed = true); +// +// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): The same data but with a closed NURBS. Note that we do not repeat the starting point for a closed NURBS but instead insert a corner there. +// data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20]]; +// debug_nurbs_interp(data, 3, closed = true, method = "centripetal", corners=[0,4]); +// path = nurbs_curve(nurbs_interp(data, 3, closed = true, method = "centripetal", corners = [0,4])); +// right(75) stroke(path, closed = true); +// +// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): For better shape control we can add derivitive and curvature control to data points to a closed NURBS. +// data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20]]; +// debug_nurbs_interp(data, 3, closed = true, method = "centripetal", +// deriv = [NAN,[1,-1]*0.8,undef,undef,NAN,undef,undef,[1,1]*0.8], +// curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06]); +// path = nurbs_curve(nurbs_interp(data, 3, closed = true, method = "centripetal", +// deriv = [NAN,[1,-1]*0.8,undef,undef,NAN,undef,undef,[1,1]*0.8], +// curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06])); +// right(75) stroke(path, closed = true); // + function nurbs_interp(points, degree, method="centripetal", closed=false, deriv=undef, start_deriv=undef, end_deriv=undef, curvature=undef, start_curvature=undef, end_curvature=undef, From 0bd528d068aa16f0002252778b8e1cd8916ceaf7 Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Sat, 2 May 2026 13:19:29 -0700 Subject: [PATCH 08/16] Update nurbs.scad --- nurbs.scad | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/nurbs.scad b/nurbs.scad index 6a8f568a..9d2903ef 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -722,7 +722,7 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // path = nurbs_curve(nurbs_interp(data, 3, closed = true, method = "centripetal", corners = [0,4])); // right(75) stroke(path, closed = true); // -// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): For better shape control we can add derivitive and curvature control to data points to a closed NURBS. +// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): For better shape control we can add derivitive constraints and curvature control at data points 1 and 6 // data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20]]; // debug_nurbs_interp(data, 3, closed = true, method = "centripetal", // deriv = [NAN,[1,-1]*0.8,undef,undef,NAN,undef,undef,[1,1]*0.8], @@ -730,8 +730,18 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // path = nurbs_curve(nurbs_interp(data, 3, closed = true, method = "centripetal", // deriv = [NAN,[1,-1]*0.8,undef,undef,NAN,undef,undef,[1,1]*0.8], // curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06])); -// right(75) stroke(path, closed = true); +// right(75) stroke(path, closed = true); // +// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): Finer control of derivitive direction by specifying the angle. +// data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20]]; +// debug_nurbs_interp(data, 3, closed = true, method = "centripetal", +// deriv = [NAN,polar_to_xy(1.1313,-40),undef,undef,NAN,undef,undef,polar_to_xy(1.1313,40)], +// curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06]); +// path3 = nurbs_curve(nurbs_interp(data, 3, closed = true, method = "centripetal", +// deriv = [NAN,polar_to_xy(1.1313,-40),undef,undef,NAN,undef,undef,polar_to_xy(1.1313,40)], +// curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06])); +// right(75) stroke(path3, closed = true); + function nurbs_interp(points, degree, method="centripetal", closed=false, From 5affc8baebb27c12f6a91e095a2ea618b0a2892d Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Tue, 5 May 2026 19:33:13 -0700 Subject: [PATCH 09/16] Tuesday Update --- nurbs.scad | 124 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 80 insertions(+), 44 deletions(-) diff --git a/nurbs.scad b/nurbs.scad index 9d2903ef..05a58d16 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -657,7 +657,7 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // // Example(2D): Get just the path // data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; -// path = nurbs_curve(nurbs_interp(data, 3), splinesteps=32); +// path = nurbs_curve(nurbs_interp(data, 3), splinesteps=16); // stroke(path, width=0.5); // color("red") move_copies(data) circle(r=0.25, $fn=16); // @@ -671,44 +671,24 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // // Example(3D): 3D closed curve // data3d = [[20,0,0],[0,20,10],[-20,0,20],[0,-20,10]]; -// path = nurbs_curve(nurbs_interp(data3d, 3, closed=true), splinesteps=32); +// path = nurbs_curve(nurbs_interp(data3d, 3, closed=true)); // stroke(path, width=1, closed=true); // color("red") move_copies(data3d) sphere(r=0.25, $fn=16); // -// Example(2D,Big): Parameterization methods for sharp turns -// // "length" (blue), "centripetal" (red), "dynamic" (orange) compared. -// // For data with sudden direction changes or uneven chord spacing, -// // "centripetal" and "dynamic" reduce unwanted oscillations. -// sharp = [[0,0], [5,40],[6,40], [10,0], [50,0], [55,40],[56,42], [60,0]]; -// color("blue") stroke(nurbs_curve(nurbs_interp(sharp, 3, method = "centripetal"), splinesteps=32), width=0.1); -// color("red") stroke(nurbs_curve(nurbs_interp(sharp, 3, method="foley"), splinesteps=32), width=0.1); -// color("orange") stroke(nurbs_curve(nurbs_interp(sharp, 3, method="dynamic"), splinesteps=32), width=0.1); -// color("green") move_copies(sharp) circle(r=.1, $fn=16); -// -// Example(2D): Endpoint tangent control +// Example(2D,Med): Endpoint tangent control // // Specify start and/or end tangent vectors. Each vector is automatically // // scaled by the total chord length; a unit vector produces natural // // arc-length speed. Magnitude > 1 increases pull, < 1 weakens it. // data = [[0,0], [20,30], [50,25], [80,0]]; // // No tangent control (natural): -// color("gray") stroke(nurbs_curve(nurbs_interp(data, 3), splinesteps=32), width=0.3); +// color("gray") stroke(nurbs_curve(nurbs_interp(data, 3)), width=0.3); // // Start going straight up, end going straight down: // color("blue") stroke( -// nurbs_curve(nurbs_interp(data, 3, start_deriv=[1,0], end_deriv=[1,0]), splinesteps=32), -// width=0.3); +// nurbs_curve(nurbs_interp(data, 3, start_deriv=[0,1], end_deriv=[0,-1])), width=0.3); // // Start going right, end going right: // color("red") stroke( -// nurbs_curve(nurbs_interp(data, 3, start_deriv=[1,0], end_deriv=[1,0]), splinesteps=32), -// width=0.3); -// color("black") move_copies(data) circle(r=0.25, $fn=16); -// -// Example(2D): Start tangent only -// data = [[0,0], [20,30], [50,25], [80,0]]; -// color("gray") stroke(nurbs_curve(nurbs_interp(data, 3), splinesteps=32), width=0.3); -// color("blue") stroke( -// nurbs_curve(nurbs_interp(data, 3, start_deriv=[0,1]), splinesteps=32), -// width=0.3); -// color("black") move_copies(data) circle(r=0.25, $fn=16); +// nurbs_curve(nurbs_interp(data, 3, start_deriv=[1,0], end_deriv=[1,0])), width=0.3); +// color("black") move_copies(data) circle(r=0.5, $fn=16); // // Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): We can generate a heart shape with a clamped NURBS where the first and last data points are co-incident, and we insert a corner at data point 4. // data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20], [0,10]]; @@ -732,16 +712,25 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06])); // right(75) stroke(path, closed = true); // -// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): Finer control of derivitive direction by specifying the angle. +// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): Finer control of derivitive direction made easier by specifying the angle. // data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20]]; // debug_nurbs_interp(data, 3, closed = true, method = "centripetal", -// deriv = [NAN,polar_to_xy(1.1313,-40),undef,undef,NAN,undef,undef,polar_to_xy(1.1313,40)], +// deriv = [NAN,polar_to_xy(1.1,-40),undef,undef,NAN,undef,undef,polar_to_xy(1.1,40)], // curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06]); // path3 = nurbs_curve(nurbs_interp(data, 3, closed = true, method = "centripetal", -// deriv = [NAN,polar_to_xy(1.1313,-40),undef,undef,NAN,undef,undef,polar_to_xy(1.1313,40)], +// deriv = [NAN,polar_to_xy(1.1,-40),undef,undef,NAN,undef,undef,polar_to_xy(1.1,40)], // curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06])); // right(75) stroke(path3, closed = true); - +// +// Example(2D,Big): Parameterization methods for sharp turns +// // "length" (blue), "centripetal" (red), "dynamic" (orange) compared. +// // For data with sudden direction changes or uneven chord spacing, +// // "centripetal" and "dynamic" reduce unwanted oscillations. +// sharp = [[0,0], [5,40],[6,40], [10,0], [50,0], [55,40],[56,42], [60,0]]; +// color("blue") stroke(nurbs_curve(nurbs_interp(sharp, 3, method = "centripetal"), splinesteps=32), width=0.1); +// color("red") stroke(nurbs_curve(nurbs_interp(sharp, 3, method="foley"), splinesteps=32), width=0.1); +// color("orange") stroke(nurbs_curve(nurbs_interp(sharp, 3, method="dynamic"), splinesteps=32), width=0.1); +// color("green") move_copies(sharp) circle(r=.1, $fn=16); function nurbs_interp(points, degree, method="centripetal", closed=false, @@ -1531,6 +1520,21 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // ]; // nurbs_interp_surface(data, [2,3], splinesteps=8); // +// Example(3D): Low-level surface access +// // nurbs_interp_surface() returns a BOSL2 NURBS parameter list +// // compatible with nurbs_vnf(), debug_nurbs(), etc. +// data = [ +// [[-30,30,0], [0,30,20], [30,30,0]], +// [[-30, 0,10],[0, 0,30], [30, 0,10]], +// [[-30,-30,0],[0,-30,15],[30,-30,0]], +// ]; +// result = nurbs_interp_surface(data, 2); +// vnf = nurbs_vnf(result, splinesteps=12); +// vnf_polyhedron(vnf); +// color("red") +// for (row = data) for (pt = row) +// translate(pt) sphere(r=1, $fn=16); +// // Example(3D): Tube (surface closed in one direction) // // Closed around the column direction (the rings), clamped along rows // // (the axis). Uses 5 rings: a cubic closed direction needs at least @@ -1561,20 +1565,52 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // nurbs_interp_surface(data, 3, splinesteps=12, // row_wrap=true, col_wrap=true); // -// Example(3D): Low-level surface access -// // nurbs_interp_surface() returns a BOSL2 NURBS parameter list -// // compatible with nurbs_vnf(), debug_nurbs(), etc. -// data = [ -// [[-30,30,0], [0,30,20], [30,30,0]], -// [[-30, 0,10],[0, 0,30], [30, 0,10]], -// [[-30,-30,0],[0,-30,15],[30,-30,0]], +// Example(3D): EGG +// // ~103 long, ~82 wide. Smooth parametric ovoid. +// // Blunt at +z, pointed at -z. +// // Profile: r = 40·sin(φ)·(1 − 0.25·cos(φ)), z = −52·cos(φ) +// // The asymmetry term shifts the belly toward the blunt end. +// // Grid: 9 rings × 8 angles +// egg = [for (i = [0:8]) +// let(phi = i * 180/8, +// r = 40 * sin(phi) * (1 - 0.25*cos(phi)), +// z = -52 * cos(phi)) +// [for (j = [0:7]) +// let(theta = j * 45) +// [r*cos(theta), r*sin(theta), z] +// ] // ]; -// result = nurbs_interp_surface(data, 2); -// vnf = nurbs_vnf(result, splinesteps=12); -// vnf_polyhedron(vnf); -// color("red") -// for (row = data) for (pt = row) -// translate(pt) sphere(r=1, $fn=16); +// nurbs_interp_surface(egg, 3, col_wrap = true); +// +// Example(3D,VPT=[10,-25,60],VPR=[100,0,30],VPD=375): A Mushroom +// shape = [ repeat([0,0,-1],8), +// for(i=[0:5]) path3d(regular_ngon(n = 8, side = 15),i*15), +// path3d(regular_ngon(n = 8, side = 50), 5 * 15), +// path3d(regular_ngon(n = 8, side = 55), 6.5 * 15), +// repeat([0,0,8*15],8) +// ]; +// nurbs_interp_surface(shape, 3, normal1 = DOWN, normal2 = UP, col_wrap = true, row_edges = 7); +// +// Example(3D): Handle Grip +// data = [[[0,7],[15,10],[30,10],[40,0],[30,-10],[15,-10],[0,-7]], +// [[0.5,6],[12,9],[30,8],[35,0],[30,-8],[12,-9],[0.5,-6]]]; +// path1 = nurbs_curve(nurbs_interp(data[0],3,closed=true, +// deriv = [undef,undef,undef,FWD,undef,undef,undef], +// curvature = [undef,undef,undef,-.1,undef,undef,undef], +// extra_pts = 6, smooth = 3)); +// path2 = nurbs_curve(nurbs_interp(data[1],3,closed=true, +// deriv = [undef,undef,undef,FWD,undef,undef,undef], +// curvature = [undef,undef,undef,-.2,undef,undef,undef], +// extra_pts = 6, smooth = 3)); +// // The 2 NURBS curves have different path lengths, so we resample them. +// samples = 20; +// paths = [resample_path(path2,samples), resample_path(path1,samples)]; +// shape = [ +// repeat([15,0,0],samples), +// for(i=[0:10]) path3d(paths[i%2],i*10), +// repeat([15,0,100],samples) +// ]; +// nurbs_interp_surface(shape, 3, col_wrap = true, normal1 = [0,0,-3], normal2 = [0,0,3]); function nurbs_interp_surface(points, degree, method="centripetal", From a9d977dd068c99638ab4b6d4f3ab027aa1707be8 Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Wed, 6 May 2026 16:31:50 -0700 Subject: [PATCH 10/16] Update nurbs.scad --- nurbs.scad | 61 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/nurbs.scad b/nurbs.scad index 05a58d16..d8d6f0f7 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -859,6 +859,29 @@ function nurbs_interp(points, degree, method="centripetal", closed=false, // show_knots = Show knot position markers on the curve. Default: `false` // show_deriv = Show derivative-constraint arrows. Default: `true` // show_curvature = Show curvature-constraint circles / disks. Default: `true` +// +// +// Example(2D,NoAxes): Keyhole Shape: Simply interpolating a NURBS through the data points yields disappointing results. +// data = [[0,0],[0,10],[-5,20],[5,30],[15,20],[10,10],[10,0],[0,0]]; +// debug_nurbs_interp(data,3, method="centripetal"); +// +// Example(2D,NoAxes,VPT=[3,15,0],VPD=130): Keyhole Shap: Adding derivative constraints causes unwanted oscillation. +// data = [[0,0],[0,10],[-5,20],[5,30],[15,20],[10,10],[10,0],[0,0]]; +// debug_nurbs_interp(data,3, method="centripetal", +// deriv=[undef,NAN,UP,RIGHT*1.3,DOWN,NAN,NAN,undef]); +// +// Example(2D,NoAxes): Keyhole Shape: Adding extra points calms oscillations. +// data = [[0,0],[0,10],[-5,20],[5,30],[15,20],[10,10],[10,0],[0,0]]; +// debug_nurbs_interp(data,3, method="centripetal", +// deriv=[undef,NAN,UP,RIGHT*1.3,DOWN,NAN,NAN,undef], +// extra_pts = 1, smooth = 3); +// +// Example(2D,NoAxes): Keyhole Shape: Constrained curvature at point 3 improves the shape. +// data = [[0,0],[0,10],[-5,20],[5,30],[15,20],[10,10],[10,0],[0,0]]; +// debug_nurbs_interp(data,3, method="centripetal", +// deriv=[undef,NAN,UP,RIGHT*1.3,DOWN,NAN,NAN,undef], +// curvature=[undef,undef,undef,-.1,undef,undef,undef,undef], +// extra_pts = 1, smooth = 3); module debug_nurbs_interp(points, degree, splinesteps=16, method="centripetal", closed=false, deriv=undef, @@ -1592,25 +1615,25 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // nurbs_interp_surface(shape, 3, normal1 = DOWN, normal2 = UP, col_wrap = true, row_edges = 7); // // Example(3D): Handle Grip -// data = [[[0,7],[15,10],[30,10],[40,0],[30,-10],[15,-10],[0,-7]], -// [[0.5,6],[12,9],[30,8],[35,0],[30,-8],[12,-9],[0.5,-6]]]; -// path1 = nurbs_curve(nurbs_interp(data[0],3,closed=true, -// deriv = [undef,undef,undef,FWD,undef,undef,undef], -// curvature = [undef,undef,undef,-.1,undef,undef,undef], -// extra_pts = 6, smooth = 3)); -// path2 = nurbs_curve(nurbs_interp(data[1],3,closed=true, -// deriv = [undef,undef,undef,FWD,undef,undef,undef], -// curvature = [undef,undef,undef,-.2,undef,undef,undef], -// extra_pts = 6, smooth = 3)); -// // The 2 NURBS curves have different path lengths, so we resample them. -// samples = 20; -// paths = [resample_path(path2,samples), resample_path(path1,samples)]; -// shape = [ -// repeat([15,0,0],samples), -// for(i=[0:10]) path3d(paths[i%2],i*10), -// repeat([15,0,100],samples) -// ]; -// nurbs_interp_surface(shape, 3, col_wrap = true, normal1 = [0,0,-3], normal2 = [0,0,3]); +// data = [[[0.5,6],[12,9],[30,8],[35,0],[30,-8],[12,-9],[0.5,-6]], +// [[0,9],[15,12],[30,12],[40,0],[30,-12],[15,-12],[0,-9]]]; +// path1 = nurbs_curve(nurbs_interp(data[0],3,closed=true, +// deriv = [undef,undef,undef,FWD,undef,undef,undef], +// curvature = [undef,undef,undef,-.1,undef,undef,undef], +// extra_pts = 6, smooth = 3)); +// path2 = nurbs_curve(nurbs_interp(data[1],3,closed=true, +// deriv = [undef,undef,undef,FWD,undef,undef,undef], +// curvature = [undef,undef,undef,-.2,undef,undef,undef], +// extra_pts = 6, smooth = 3)); +// //The 2 NURBS curves have different path lengths, so we resample them. +// samples = 20; +// paths = [resample_path(path1,samples), resample_path(path2,samples)]; +// shape = [ +// repeat([15,0,-2],samples), +// for(i=[0:10]) path3d(paths[i%2],i*12), +// repeat([15,0,124],samples) +// ]; +// nurbs_interp_surface(shape, 3, col_wrap = true, normal1 = [0,0,-3], normal2 = [0,0,3]); function nurbs_interp_surface(points, degree, method="centripetal", From 4cc971de960748c1a03d410f3156ce50a10741c1 Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Thu, 7 May 2026 10:40:14 -0700 Subject: [PATCH 11/16] Update nurbs.scad --- nurbs.scad | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nurbs.scad b/nurbs.scad index d8d6f0f7..06f91288 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -4304,6 +4304,7 @@ function _surface_params_v(points, method, periodic) = sum([for (k = [0:1:n_rows-1]) row_params[k][l]]) / n_rows ]; +<<<<<<< Updated upstream @@ -4318,3 +4319,5 @@ function _surface_params_v(points, method, periodic) = // data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; // debug_nurbs_interp(data, 3, closed=true); // +======= +>>>>>>> Stashed changes From 86fd7b4adb35f02f3d192d6d6c53acae7be1f481 Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Thu, 7 May 2026 10:56:55 -0700 Subject: [PATCH 12/16] Update nurbs.scad --- nurbs.scad | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/nurbs.scad b/nurbs.scad index 06f91288..1847db31 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -4304,20 +4304,4 @@ function _surface_params_v(points, method, periodic) = sum([for (k = [0:1:n_rows-1]) row_params[k][l]]) / n_rows ]; -<<<<<<< Updated upstream - - - -// -// Example(2D): Clamped curve (default) -// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; -// debug_nurbs_interp(data, 3); -// -// Example(2D): Closed curve (debug view) -// // Do NOT repeat the first point at the end. -// data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; -// debug_nurbs_interp(data, 3, closed=true); -// -======= ->>>>>>> Stashed changes From 50587843535911acaaf684d13f88d11fc75761a9 Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Wed, 13 May 2026 09:50:54 -0700 Subject: [PATCH 13/16] Update nurbs.scad --- nurbs.scad | 175 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 135 insertions(+), 40 deletions(-) diff --git a/nurbs.scad b/nurbs.scad index 1847db31..18c95c96 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -860,12 +860,45 @@ function nurbs_interp(points, degree, method="centripetal", closed=false, // show_deriv = Show derivative-constraint arrows. Default: `true` // show_curvature = Show curvature-constraint circles / disks. Default: `true` // +// Example(2D,NoAxes,Med): Unconstrained NURBS through the same data points vary depending on the paramaterization method chosen +// data = [[0,0], [20,30], [35,120], [50,30], [70,0]]; +// method = ["length", "centripetal", "dynamic", "foley", "fang"]; +// color = ["blue","lime","yellow","orange","red"]; +// for (i = [0:4]) { +// color(color[i]) { +// debug_nurbs_interp(data, 3, closed = true, method = method[i], size = 5, data_size = 3); +// move([80,100-i*15]) text(method[i]); +// } +// } +// +// +// Example(2D,NoAxes,Med): Adding extra points reduces the differences between the methods. +// data = [[0,0], [20,30], [35,120], [50,30], [70,0]]; +// method = ["length", "centripetal", "dynamic", "foley", "fang"]; +// color = ["blue","lime","yellow","orange","red"]; +// for (i = [0:4]) { +// color(color[i]) { +// debug_nurbs_interp(data, 3, closed = true, method = method[i], extra_pts = 3, size = 5, data_size = 3); +// move([80,100-i*15]) text(method[i]); +// } +// } +// +// Example(2D,NoAxes,Med): Switching from the default to smooth = 1 improves things further. +// data = [[0,0], [20,30], [35,120], [50,30], [70,0]]; +// method = ["length", "centripetal", "dynamic", "foley", "fang"]; +// color = ["blue","lime","yellow","orange","red"]; +// for (i = [0:4]) { +// color(color[i]) { +// debug_nurbs_interp(data, 3, closed = true, method = method[i], extra_pts = 3, smooth = 1, size = 5, data_size = 3); +// move([80,100-i*15]) text(method[i]); +// } +// } // // Example(2D,NoAxes): Keyhole Shape: Simply interpolating a NURBS through the data points yields disappointing results. // data = [[0,0],[0,10],[-5,20],[5,30],[15,20],[10,10],[10,0],[0,0]]; // debug_nurbs_interp(data,3, method="centripetal"); // -// Example(2D,NoAxes,VPT=[3,15,0],VPD=130): Keyhole Shap: Adding derivative constraints causes unwanted oscillation. +// Example(2D,NoAxes,VPT=[3,15,0],VPD=130): Keyhole Shape: Adding derivative constraints causes unwanted oscillation. // data = [[0,0],[0,10],[-5,20],[5,30],[15,20],[10,10],[10,0],[0,0]]; // debug_nurbs_interp(data,3, method="centripetal", // deriv=[undef,NAN,UP,RIGHT*1.3,DOWN,NAN,NAN,undef]); @@ -1558,6 +1591,89 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // for (row = data) for (pt = row) // translate(pt) sphere(r=1, $fn=16); // +// Example(3D,VPD=320,VPT=[8,10,13]): Basic surface interpolation with flat edges +// // Same derivitive for all four edges +// surface = [ +// [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], +// [[-50, 25, 0], [-16, 25, 40], [ 16, 25, 30], [50, 25, 20], [80, 25, 0]], +// [[-50, 0, 0], [-16, 0, 40], [ 16, 0, 30], [50, 0, 30], [80, 0, 0]], +// [[-50,-25, 0], [-16,-25, 35], [ 16,-25, 40], [50,-25, 15], [80,-25, 0]], +// [[-50,-50, 0], [-16,-50, 0], [ 16,-50, 0], [50,-50, 0], [80,-50, 0]], +// ]; +// nurbs_interp_surface(surface,3, flat_edges = 0); +// +// Example(3D,VPD=320,VPT=[8,10,13]): Different derivitives for each edge +// // Edge specification is [first row, last row, first col, last col] +// surface = [ +// [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], +// [[-50, 25, 0], [-16, 25, 40], [ 16, 25, 30], [50, 25, 20], [80, 25, 0]], +// [[-50, 0, 0], [-16, 0, 40], [ 16, 0, 30], [50, 0, 30], [80, 0, 0]], +// [[-50,-25, 0], [-16,-25, 35], [ 16,-25, 40], [50,-25, 15], [80,-25, 0]], +// [[-50,-50, 0], [-16,-50, 0], [ 16,-50, 0], [50,-50, 0], [80,-50, 0]], +// ]; +// nurbs_interp_surface(surface,3, flat_edges = [1,0,2,1]); +// +// Example(3D,VPD=320,VPT=[8,10,13]): Constraining only column edges. +// surface = [ +// [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], +// [[-50, 25, 0], [-16, 25, 40], [ 16, 25, 30], [50, 25, 20], [80, 25, 0]], +// [[-50, 0, 0], [-16, 0, 40], [ 16, 0, 30], [50, 0, 30], [80, 0, 0]], +// [[-50,-25, 0], [-16,-25, 35], [ 16,-25, 40], [50,-25, 15], [80,-25, 0]], +// [[-50,-50, 0], [-16,-50, 0], [ 16,-50, 0], [50,-50, 0], [80,-50, 0]], +// ]; +// nurbs_interp_surface(surface,3, flat_edges = [undef,undef,1,1]); +// +// Example(3D,VPD=320,VPT=[8,10,13]):Constraining only row edges. +// surface = [ +// [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], +// [[-50, 25, 0], [-16, 25, 40], [ 16, 25, 30], [50, 25, 20], [80, 25, 0]], +// [[-50, 0, 0], [-16, 0, 40], [ 16, 0, 30], [50, 0, 30], [80, 0, 0]], +// [[-50,-25, 0], [-16,-25, 35], [ 16,-25, 40], [50,-25, 15], [80,-25, 0]], +// [[-50,-50, 0], [-16,-50, 0], [ 16,-50, 0], [50,-50, 0], [80,-50, 0]], +// ]; +// nurbs_interp_surface(surface,3, flat_edges = [1,1,undef,undef]); +// +// Example(3D,VPD=320,VPT=[8,10,13]): Individual constraints for each point on last row +// surface = [ +// [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], +// [[-50, 25, 0], [-16, 25, 40], [ 16, 25, 30], [50, 25, 20], [80, 25, 0]], +// [[-50, 0, 0], [-16, 0, 40], [ 16, 0, 30], [50, 0, 30], [80, 0, 0]], +// [[-50,-25, 0], [-16,-25, 35], [ 16,-25, 40], [50,-25, 15], [80,-25, 0]], +// [[-50,-50, 0], [-16,-50, 0], [ 16,-50, 0], [50,-50, 0], [80,-50, 0]], +// ]; +// nurbs_interp_surface(surface,3, flat_edges = [undef,[3,2,4,2,3],undef,undef]); +// +// Example(3D,VPD=320,VPT=[8,10,13]): Corner seam in column 3 +// surface = [ +// [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], +// [[-50, 25, 0], [-16, 25, 40], [ 16, 25, 30], [50, 25, 20], [80, 25, 0]], +// [[-50, 0, 0], [-16, 0, 40], [ 16, 0, 30], [50, 0, 30], [80, 0, 0]], +// [[-50,-25, 0], [-16,-25, 35], [ 16,-25, 40], [50,-25, 15], [80,-25, 0]], +// [[-50,-50, 0], [-16,-50, 0], [ 16,-50, 0], [50,-50, 0], [80,-50, 0]], +// ]; +// nurbs_interp_surface(surface,3, col_edges = 3); +// +// Example(3D,VPD=320,VPT=[8,10,13]): Corner seam in row 3 +// surface = [ +// [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], +// [[-50, 25, 0], [-16, 25, 40], [ 16, 25, 30], [50, 25, 20], [80, 25, 0]], +// [[-50, 0, 0], [-16, 0, 40], [ 16, 0, 30], [50, 0, 30], [80, 0, 0]], +// [[-50,-25, 0], [-16,-25, 35], [ 16,-25, 40], [50,-25, 15], [80,-25, 0]], +// [[-50,-50, 0], [-16,-50, 0], [ 16,-50, 0], [50,-50, 0], [80,-50, 0]], +// ]; +// nurbs_interp_surface(surface,3, row_edges = 3); +// +// Example(3D,VPD=320,VPT=[8,10,13]): Setting first and last row/column derivitives +// surface = [ +// [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], +// [[-50, 25, 0], [-16, 25, 40], [ 16, 25, 30], [50, 25, 20], [80, 25, 0]], +// [[-50, 0, 0], [-16, 0, 40], [ 16, 0, 30], [50, 0, 30], [80, 0, 0]], +// [[-50,-25, 0], [-16,-25, 35], [ 16,-25, 40], [50,-25, 15], [80,-25, 0]], +// [[-50,-50, 0], [-16,-50, 0], [ 16,-50, 0], [50,-50, 0], [80,-50, 0]], +// ]; +// nurbs_interp_surface(surface,3, first_row_deriv = UP+FWD, last_row_deriv = DOWN+FWD, +// first_col_deriv = UP+RIGHT/2, last_col_deriv = DOWN+RIGHT/2); +// // Example(3D): Tube (surface closed in one direction) // // Closed around the column direction (the rings), clamped along rows // // (the axis). Uses 5 rings: a cubic closed direction needs at least @@ -1569,26 +1685,24 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // [r*cos(a), r*sin(a), u]] // ]; // nurbs_interp_surface(data, 3, splinesteps=8, col_wrap=true); -// -// Example(3D): Torus (surface closed in both directions) -// // Both directions sample a full 360 circle with even angular spacing, -// // so the closing segment equals the inter-point spacing and -// // parameterization is uniform. Each direction uses N=6 > p+1=4 -// // points to ensure interior knot freedom. -// R = 30; r = 10; -// N = 6; -// data = [for (i = [0:1:N-1]) -// let(phi = i * 360/N) -// [for (j = [0:1:N-1]) -// let(theta = j * 360/N) -// [(R + r*cos(theta))*cos(phi), -// (R + r*cos(theta))*sin(phi), -// r*sin(theta)]] +// +// Example(3D,VPR=[80,0,45],VPT=[0,0,20],VPD = 320): Rotated star cross section surface closed in one direction. +// // Degenerate end rows close the shape in the other direction. +// surface = [ repeat([0,0,-15],14), +// for(i=[0:4]) zrot(i*15,path3d(star(or=15,ir=13, n=7),i*15)), +// repeat([0,0,5*15],14) +// ]; +// nurbs_interp_surface(surface, 3, col_wrap = true); +// +// Example(3D,VPR=[80,0,45],VPT=[0,0,20],VPD = 320): Controlling end shape with normals. +// surface = [ repeat([0,0,-15],14), +// for(i=[0:4]) zrot(i*15,path3d(star(or=15,ir=13, n=7),i*15)), +// repeat([0,0,5*15],14) // ]; -// nurbs_interp_surface(data, 3, splinesteps=12, -// row_wrap=true, col_wrap=true); +// nurbs_interp_surface(surface, 3, col_wrap = true, normal1 = DOWN*4, normal2 = UP*2); +// // -// Example(3D): EGG +// Example(3D): EGG (surface closed in both directions) // // ~103 long, ~82 wide. Smooth parametric ovoid. // // Blunt at +z, pointed at -z. // // Profile: r = 40·sin(φ)·(1 − 0.25·cos(φ)), z = −52·cos(φ) @@ -1605,7 +1719,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // ]; // nurbs_interp_surface(egg, 3, col_wrap = true); // -// Example(3D,VPT=[10,-25,60],VPR=[100,0,30],VPD=375): A Mushroom +// Example(3D,VPT=[10,-25,60],VPR=[100,0,30],VPD=375): A Mushroom (surface closed in both directions) // shape = [ repeat([0,0,-1],8), // for(i=[0:5]) path3d(regular_ngon(n = 8, side = 15),i*15), // path3d(regular_ngon(n = 8, side = 50), 5 * 15), @@ -1614,26 +1728,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // ]; // nurbs_interp_surface(shape, 3, normal1 = DOWN, normal2 = UP, col_wrap = true, row_edges = 7); // -// Example(3D): Handle Grip -// data = [[[0.5,6],[12,9],[30,8],[35,0],[30,-8],[12,-9],[0.5,-6]], -// [[0,9],[15,12],[30,12],[40,0],[30,-12],[15,-12],[0,-9]]]; -// path1 = nurbs_curve(nurbs_interp(data[0],3,closed=true, -// deriv = [undef,undef,undef,FWD,undef,undef,undef], -// curvature = [undef,undef,undef,-.1,undef,undef,undef], -// extra_pts = 6, smooth = 3)); -// path2 = nurbs_curve(nurbs_interp(data[1],3,closed=true, -// deriv = [undef,undef,undef,FWD,undef,undef,undef], -// curvature = [undef,undef,undef,-.2,undef,undef,undef], -// extra_pts = 6, smooth = 3)); -// //The 2 NURBS curves have different path lengths, so we resample them. -// samples = 20; -// paths = [resample_path(path1,samples), resample_path(path2,samples)]; -// shape = [ -// repeat([15,0,-2],samples), -// for(i=[0:10]) path3d(paths[i%2],i*12), -// repeat([15,0,124],samples) -// ]; -// nurbs_interp_surface(shape, 3, col_wrap = true, normal1 = [0,0,-3], normal2 = [0,0,3]); + function nurbs_interp_surface(points, degree, method="centripetal", From 040bc6b0b94221ed1d68f9d7db52c24a3942593f Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Sat, 16 May 2026 13:56:17 -0700 Subject: [PATCH 14/16] Update nurbs.scad --- nurbs.scad | 65 +++++++++++++++++++++++++----------------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/nurbs.scad b/nurbs.scad index 18c95c96..0662f485 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -642,14 +642,12 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // path = nurbs_curve(nurbs_interp(data, 3)); // stroke(path); // -// Example(2D): Closed curve -// // Do NOT repeat the first point at the end. +// Example(2D): Closed curve - Do NOT repeat the first point at the end. // data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; // path = nurbs_curve(nurbs_interp(data, 3, closed = true)); // stroke(path, closed = true); // -// Example(2D): Closed polygon -// // All data points lie exactly on the polygon boundary. +// Example(2D): Closed polygon - All data points lie exactly on the polygon boundary. // data = [[0,0], [30,50], [60,40], [80,10], [50,-20], [20,-10]]; // path = nurbs_curve(nurbs_interp(data, 3, closed=true), splinesteps=16); // polygon(path); @@ -661,9 +659,7 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // stroke(path, width=0.5); // color("red") move_copies(data) circle(r=0.25, $fn=16); // -// Example(2D): Low-level NURBS parameter list -// // nurbs_interp() returns a BOSL2 NURBS parameter list compatible -// // with nurbs_curve(), debug_nurbs(), etc. +// Example(2D): Low-level NURBS parameter list - nurbs_interp() returns a BOSL2 NURBS parameter list compatible with nurbs_curve(), debug_nurbs(), etc. // data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; // result = nurbs_interp(data, 3); // curve = nurbs_curve(result, splinesteps=24); @@ -674,11 +670,24 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // path = nurbs_curve(nurbs_interp(data3d, 3, closed=true)); // stroke(path, width=1, closed=true); // color("red") move_copies(data3d) sphere(r=0.25, $fn=16); + +// Example(2D): Corner added at data point 3 +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// path = nurbs_curve(nurbs_interp(data, 3, corners = [3])); +// stroke(path); +// +// Example(2D): Clamped curve (default) +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// path = nurbs_curve(nurbs_interp(data, 3)); +// stroke(path); // -// Example(2D,Med): Endpoint tangent control -// // Specify start and/or end tangent vectors. Each vector is automatically -// // scaled by the total chord length; a unit vector produces natural -// // arc-length speed. Magnitude > 1 increases pull, < 1 weakens it. +// Example(2D): Clamped curve (default) +// data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; +// path = nurbs_curve(nurbs_interp(data, 3)); +// stroke(path); +// +// +// Example(2D,Med): Endpoint tangent control - Specify start and/or end tangent vectors. Each vector is automatically scaled by the total chord length; a unit vector produces natural arc-length speed. Magnitude > 1 increases pull, < 1 weakens it. // data = [[0,0], [20,30], [50,25], [80,0]]; // // No tangent control (natural): // color("gray") stroke(nurbs_curve(nurbs_interp(data, 3)), width=0.3); @@ -702,7 +711,7 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // path = nurbs_curve(nurbs_interp(data, 3, closed = true, method = "centripetal", corners = [0,4])); // right(75) stroke(path, closed = true); // -// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): For better shape control we can add derivitive constraints and curvature control at data points 1 and 6 +// Example(2D,NoAxes,Med,VPT=[37.5,0,0],VPD=275): For better shape control we can add derivitive constraints and curvature control at data points 1 and 7 // data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20]]; // debug_nurbs_interp(data, 3, closed = true, method = "centripetal", // deriv = [NAN,[1,-1]*0.8,undef,undef,NAN,undef,undef,[1,1]*0.8], @@ -722,10 +731,8 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06])); // right(75) stroke(path3, closed = true); // -// Example(2D,Big): Parameterization methods for sharp turns +// Example(2D,Big): Parameterization methods for sharp turns. For data with sudden direction changes or uneven chord spacing, "centripetal" and "dynamic" reduce unwanted oscillations. // // "length" (blue), "centripetal" (red), "dynamic" (orange) compared. -// // For data with sudden direction changes or uneven chord spacing, -// // "centripetal" and "dynamic" reduce unwanted oscillations. // sharp = [[0,0], [5,40],[6,40], [10,0], [50,0], [55,40],[56,42], [60,0]]; // color("blue") stroke(nurbs_curve(nurbs_interp(sharp, 3, method = "centripetal"), splinesteps=32), width=0.1); // color("red") stroke(nurbs_curve(nurbs_interp(sharp, 3, method="foley"), splinesteps=32), width=0.1); @@ -1576,9 +1583,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // ]; // nurbs_interp_surface(data, [2,3], splinesteps=8); // -// Example(3D): Low-level surface access -// // nurbs_interp_surface() returns a BOSL2 NURBS parameter list -// // compatible with nurbs_vnf(), debug_nurbs(), etc. +// Example(3D): Low-level surface access - nurbs_interp_surface() returns a BOSL2 NURBS parameter list compatible with nurbs_vnf(), debug_nurbs(), etc. // data = [ // [[-30,30,0], [0,30,20], [30,30,0]], // [[-30, 0,10],[0, 0,30], [30, 0,10]], @@ -1591,8 +1596,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // for (row = data) for (pt = row) // translate(pt) sphere(r=1, $fn=16); // -// Example(3D,VPD=320,VPT=[8,10,13]): Basic surface interpolation with flat edges -// // Same derivitive for all four edges +// Example(3D,VPD=320,VPT=[8,10,13]): Basic surface interpolation with flat edges, using the same derivitive for all four edges // surface = [ // [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], // [[-50, 25, 0], [-16, 25, 40], [ 16, 25, 30], [50, 25, 20], [80, 25, 0]], @@ -1602,7 +1606,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // ]; // nurbs_interp_surface(surface,3, flat_edges = 0); // -// Example(3D,VPD=320,VPT=[8,10,13]): Different derivitives for each edge +// Example(3D,VPD=320,VPT=[8,10,13]): Different derivitives for each edge. // // Edge specification is [first row, last row, first col, last col] // surface = [ // [[-50, 50, 0], [-16, 50, 0], [ 16, 50, 0], [50, 50, 0], [80, 50, 0]], @@ -1674,10 +1678,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // nurbs_interp_surface(surface,3, first_row_deriv = UP+FWD, last_row_deriv = DOWN+FWD, // first_col_deriv = UP+RIGHT/2, last_col_deriv = DOWN+RIGHT/2); // -// Example(3D): Tube (surface closed in one direction) -// // Closed around the column direction (the rings), clamped along rows -// // (the axis). Uses 5 rings: a cubic closed direction needs at least -// // p+2 = 5 data points to have interior knot freedom. +// Example(3D): Tube - Surface closed around the column direction (the rings), clamped along rows (the axis). Uses 5 rings: a cubic closed direction needs at least p+2 = 5 data points to have interior knot freedom. // r = 20; // data = [for (u = [0:15:60]) // [for (i = [0:1:5]) @@ -1686,8 +1687,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // ]; // nurbs_interp_surface(data, 3, splinesteps=8, col_wrap=true); // -// Example(3D,VPR=[80,0,45],VPT=[0,0,20],VPD = 320): Rotated star cross section surface closed in one direction. -// // Degenerate end rows close the shape in the other direction. +// Example(3D,VPR=[80,0,45],VPT=[0,0,20],VPD = 320): Rotated star cross section surface closed in one direction. Degenerate end rows close the shape in the other direction. // surface = [ repeat([0,0,-15],14), // for(i=[0:4]) zrot(i*15,path3d(star(or=15,ir=13, n=7),i*15)), // repeat([0,0,5*15],14) @@ -1702,12 +1702,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // nurbs_interp_surface(surface, 3, col_wrap = true, normal1 = DOWN*4, normal2 = UP*2); // // -// Example(3D): EGG (surface closed in both directions) -// // ~103 long, ~82 wide. Smooth parametric ovoid. -// // Blunt at +z, pointed at -z. -// // Profile: r = 40·sin(φ)·(1 − 0.25·cos(φ)), z = −52·cos(φ) -// // The asymmetry term shifts the belly toward the blunt end. -// // Grid: 9 rings × 8 angles +// Example(3D): EGG Smooth parametric ovoid. ~103 long, ~82 wide. Blunt at +z, pointed at -z. Profile: r = 40·sin(φ)·(1 − 0.25·cos(φ)), z = −52·cos(φ) The asymmetry term shifts the belly toward the blunt end. Grid: 9 rings × 8 angles // egg = [for (i = [0:8]) // let(phi = i * 180/8, // r = 40 * sin(phi) * (1 - 0.25*cos(phi)), @@ -1718,8 +1713,8 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // ] // ]; // nurbs_interp_surface(egg, 3, col_wrap = true); -// -// Example(3D,VPT=[10,-25,60],VPR=[100,0,30],VPD=375): A Mushroom (surface closed in both directions) +// +// Example(3D,VPT=[10,-25,60],VPR=[100,0,30],VPD=375): A Mushroom // shape = [ repeat([0,0,-1],8), // for(i=[0:5]) path3d(regular_ngon(n = 8, side = 15),i*15), // path3d(regular_ngon(n = 8, side = 50), 5 * 15), From dd101c029e73b0e52414405472fce31d1ca13da7 Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Sun, 17 May 2026 12:57:10 -0700 Subject: [PATCH 15/16] Update nurbs.scad --- nurbs.scad | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/nurbs.scad b/nurbs.scad index 0662f485..8ae88b83 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -676,7 +676,7 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // path = nurbs_curve(nurbs_interp(data, 3, corners = [3])); // stroke(path); // -// Example(2D): Clamped curve (default) +// Example(2D): Controlling the curvature at data point 3 // data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; // path = nurbs_curve(nurbs_interp(data, 3)); // stroke(path); @@ -1709,7 +1709,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // z = -52 * cos(phi)) // [for (j = [0:7]) // let(theta = j * 45) -// [r*cos(theta), r*sin(theta), z] +// [r*cos(theta), r*sin(theta), z] // ] // ]; // nurbs_interp_surface(egg, 3, col_wrap = true); @@ -1723,6 +1723,30 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // ]; // nurbs_interp_surface(shape, 3, normal1 = DOWN, normal2 = UP, col_wrap = true, row_edges = 7); // +// Example(3D,VPR[80,0,40]): A 3d Heart Shape +// data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20]]; +// depth = function(x) 0.5 + sin(180 * x / 31) * 6; +// heart_shape_2d = nurbs_curve(nurbs_interp(data, 3, closed = true, +// deriv = [NAN,polar_to_xy(1.1,-40),undef,undef,NAN,undef,undef,polar_to_xy(1.1,40)], +// curvature = [undef,-0.06,undef,undef,undef,undef,undef,-0.06])); +// points = [ +// for (i = [-31:2:31]) +// flatten(polygon_line_intersection(heart_shape_2d,[[i,25],[i,-30]])), +// ]; +// span = [ +// for (i = [0:len(points)-1]) +// abs(points[i][1].y-points[i][0].y), +// ]; +// samples = 11; +// surface = [ +// repeat([-31.1,7,0], samples), +// for (i = [0:len(points)-1]) +// move(points[i][0]-[0,span[i]/2], yrot(90, path3d(resample_path(ellipse([depth(i),span[i]/2]),samples),0))), +// repeat([31.1,7,0], samples), +// ]; +// xrot(90) +// nurbs_interp_surface(surface,3, method = "foley", col_wrap = true, splinesteps = 3, extra_pts = 5, smooth = 1, normal1 = RIGHT/2, normal2 = LEFT/2); +// From 895dc6fad72dae82869969774f5ef49397986a12 Mon Sep 17 00:00:00 2001 From: Richard Milewski Date: Tue, 19 May 2026 14:16:50 -0700 Subject: [PATCH 16/16] Fix 3d Heart --- nurbs.scad | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nurbs.scad b/nurbs.scad index 8ae88b83..352ebe18 100644 --- a/nurbs.scad +++ b/nurbs.scad @@ -670,7 +670,7 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // path = nurbs_curve(nurbs_interp(data3d, 3, closed=true)); // stroke(path, width=1, closed=true); // color("red") move_copies(data3d) sphere(r=0.25, $fn=16); - +// // Example(2D): Corner added at data point 3 // data = [[0,0], [10,30], [25,15], [40,35], [60,10], [80,25]]; // path = nurbs_curve(nurbs_interp(data, 3, corners = [3])); @@ -686,7 +686,6 @@ module debug_nurbs(control,degree,splinesteps=16,width=1, size, mult,weights,typ // path = nurbs_curve(nurbs_interp(data, 3)); // stroke(path); // -// // Example(2D,Med): Endpoint tangent control - Specify start and/or end tangent vectors. Each vector is automatically scaled by the total chord length; a unit vector produces natural arc-length speed. Magnitude > 1 increases pull, < 1 weakens it. // data = [[0,0], [20,30], [50,25], [80,0]]; // // No tangent control (natural): @@ -1723,7 +1722,7 @@ module nurbs_vnf(patch, degree, splinesteps=16, weights, type="clamped", mult, k // ]; // nurbs_interp_surface(shape, 3, normal1 = DOWN, normal2 = UP, col_wrap = true, row_edges = 7); // -// Example(3D,VPR[80,0,40]): A 3d Heart Shape +// Example(3D,VPR[80,0,40]): A 3d Heart Shape - Based on the 2d Shape from nurbs_interp() example 14. // data = [[0,10], [25,20], [30,0], [20,-15], [0,-30], [-20,-15], [-30,0], [-25,20]]; // depth = function(x) 0.5 + sin(180 * x / 31) * 6; // heart_shape_2d = nurbs_curve(nurbs_interp(data, 3, closed = true,