Compare commits

...

3 Commits

Author SHA1 Message Date
jyviko d9c7162dc3 G2 corners: hole edges taken as they are, ends meeting within the record's tolerance; clippy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKMoxpM7Vuv8Rb6E3K2
2026-10-27 17:56:15 -04:00
jyviko f7351f4a53 G2 corners: six four-sided patches about a centre, graded per seam
The set-back corner's hole is filled with one four-sided patch per corner of
the hole meeting at a centre point (solidcraft_geom::fill_quad), each holding
its two half-sides exactly (rational rows for the rounds' sections, standard
form, rational-line pcurves), G2 to its neighbours and to the next patch
across its spoke; the height-field fill (fill_matched) is removed. The kernel
grades a corner's seams as the ladder does (corner_continuity), and the ladder
uses it. Ladder 07/015 and 07/019: 15 and 18 faces.

Known: the eight-corner box test is ignored: at some corners SMLib gives one
half of a section as a polynomial spline 1.7e-5 mm off its round, tangent out
of its plane, which the patches cannot meet within tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKMoxpM7Vuv8Rb6E3K2
2026-10-27 17:34:36 -04:00
jyviko d493c4d639 WIP: G2 corner as six four-sided patches about a centre (fill_quad)
Works: crates/geom fill_quad (N quads about a centre, exact rational/polynomial
boundary rows, G1/G2 by Gauss-Newton least squares, banded RCM solve), kernel
quad_patch/quad_body, corner_continuity report; box corner 07/015, concave
07/019, set back 8/11/13/15 pass the ladder-style grading (clear 7e-4 per mm).
Not yet: eight-corner box (mirrored corners: an SMLib section half that is a
polynomial approximation, tangent 0.02 deg out of its round's tangent plane at
the side's middle), ladder expected files, fillet diagnostics, images; debug
QDBG prints and probe tests still in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKMoxpM7Vuv8Rb6E3K2
2026-10-27 17:14:14 -04:00
17 changed files with 2173 additions and 891 deletions
+15 -45
View File
@@ -113,61 +113,31 @@ fn open_edges(positions: &[solidcraft_engine::geom::Vec3], triangles: &[[u32; 3]
(uses.values().filter(|&&n| n == 1).count(), uses.values().filter(|&&n| n > 2).count())
}
/// An error that means "this kernel can't do it yet", not a wrong answer.
/// Stations along each seam the `continuity` check samples.
const SEAM_SAMPLES: usize = 65;
/// A seam is tangent (G1), and two seams meeting at a vertex turn there, past this angle (°).
const SEAM_ANGLE_DEG: f64 = 1.0;
/// The `continuity` check's measure of `body`: over the tangent seams lying wholly within
/// `within` of `near`, the worst curvature jump (1/mm) at every station and at the stations at
/// least `clear` (mm, along the seam) from each vertex where two of those seams turn (a corner of
/// the patch's boundary, where its neighbours' own G1 seams end and no surface is G2 to both),
/// with how many seams and stations clear there were. An error where the seams can't be measured.
/// the patch's boundary, where its neighbours' own G1 seams end and no surface is G2 to both;
/// where the patches' spokes meet), with how many seams and stations clear there were, and each
/// seam's worst gap, angle and jumps (`solidcraft_kernel::corner_continuity`). An error where
/// the seams can't be measured.
fn curvature_jumps(body: &solidcraft_engine::kernel::Body, near: [f64; 3], within: f64, clear: f64) -> Result<Value, String> {
use solidcraft_engine::geom::Vec3;
let tol = solidcraft_engine::kernel::ContinuityTolerances { gap_mm: 1e-3, angle_deg: SEAM_ANGLE_DEG, curvature_per_mm: 0.0 };
let report = solidcraft_engine::kernel::seam_diagnostics(body, &[], SEAM_SAMPLES, tol).map_err(|e| e.to_string())?;
let at = Vec3::new(near[0], near[1], near[2]);
let seams: Vec<_> = report
let c = solidcraft_engine::kernel::corner_continuity(body, Vec3::new(near[0], near[1], near[2]), within, clear).map_err(|e| e.to_string())?;
let seams: Vec<Value> = c
.seams
.iter()
.filter(|s| s.normal_angle_deg.value <= SEAM_ANGLE_DEG && s.points.len() >= 2 && s.points.iter().all(|p| p.dist(at) <= within))
.map(|s| {
json!({"edge": s.edge, "length_mm": s.length, "gap_mm": s.gap_mm.value, "normal_angle_deg": s.normal_angle_deg.value,
"curvature_jump_per_mm": s.curvature_jump_per_mm.value, "curvature_jump_clear_per_mm": s.curvature_jump_clear_per_mm.map(|w| w.value)})
})
.collect();
// Each seam's end directions, pointing into it.
let ends = |s: &solidcraft_engine::kernel::SeamReport| -> Option<[(Vec3, Vec3); 2]> {
let (p0, p1) = (*s.points.first()?, *s.points.last()?);
let (q0, q1) = (*s.points.get(1)?, *s.points.get(s.points.len() - 2)?);
Some([(p0, (q0 - p0).normalized()?), (p1, (q1 - p1).normalized()?)])
};
let all_ends: Vec<(Vec3, Vec3)> = seams.iter().filter_map(|s| ends(s)).flatten().collect();
let weld = 1e-6 * within.max(1.0);
let cos = SEAM_ANGLE_DEG.to_radians().cos();
// A vertex turns where another seam leaves it other than straight on.
let turns = |p: Vec3, d: Vec3| all_ends.iter().any(|(q, e)| q.dist(p) <= weld && (*e - d).len() > 1e-9 && -e.dot(d) < cos);
let (mut worst, mut worst_clear, mut stations) = (0.0f64, 0.0f64, 0usize);
for s in &seams {
let Some(e) = ends(s) else { continue };
let turning: Vec<Vec3> = e.iter().filter(|(p, d)| turns(*p, *d)).map(|(p, _)| *p).collect();
// Arc length along the seam to each station, from its start.
let mut along = vec![0.0f64; s.points.len()];
for k in 1..s.points.len() {
along[k] = along[k - 1] + s.points[k].dist(s.points[k - 1]);
}
let length = along.last().copied().unwrap_or(0.0);
for (k, st) in s.stations.iter().enumerate() {
worst = worst.max(st.curvature_jump_per_mm);
let a = along.get(k).copied().unwrap_or(0.0);
let from_turn = turning.iter().map(|p| if p.dist(s.points[0]) <= weld { a } else { length - a }).fold(f64::INFINITY, f64::min);
if from_turn >= clear {
worst_clear = worst_clear.max(st.curvature_jump_per_mm);
stations += 1;
}
}
}
Ok(json!({"seams": seams.len(), "stations_clear": stations, "worst_per_mm": worst, "worst_clear_per_mm": worst_clear}))
Ok(
json!({"seams": c.seams.len(), "stations_clear": c.stations_clear, "worst_per_mm": c.curvature_jump_per_mm, "worst_clear_per_mm": c.curvature_jump_clear_per_mm,
"gap_mm": c.gap_mm, "normal_angle_deg": c.normal_angle_deg, "per_seam": seams}),
)
}
/// An error that means "this kernel can't do it yet", not a wrong answer.
fn unsupported(e: &str) -> bool {
e.contains("not supported yet")
}
+7 -5
View File
@@ -3259,10 +3259,12 @@ mod corner_tests {
assert_eq!(solidcraft_kernel::measure(&ball).unwrap().area, solidcraft_kernel::measure(&same).unwrap().area);
assert!(solidcraft_kernel::measure(&ball).unwrap().volume > 7000.0);
// `corner: g2`: the corner set back (1.5 r by default, or as `setbacks` says) and filled
// with the G2 patch: one closed solid of the box's faces, the rounds and the patch, a
// little fuller than the ball's corner (its patch spans the setback, not the ball).
// with the G2 patches: one closed solid of the box's faces, the rounds and six patches
// about a centre, about as full as the ball's corner.
let ball_volume = solidcraft_kernel::measure(&ball).unwrap().volume;
for setbacks in [json!([]), json!([{"vertex": [0, 0, 0], "distance": 7}])] {
// (Set back 7, SMLib gives one half of each round's section as a polynomial spline 1.4e-5 mm
// off the round, which the patches cannot meet within their tolerances; 6 is exact.)
for setbacks in [json!([]), json!([{"vertex": [0, 0, 0], "distance": 6}])] {
let mut s = Session::default();
run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"}));
run(&mut s, "solid.fillet", &json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4, "corner": "g2", "setbacks": setbacks}));
@@ -3270,8 +3272,8 @@ mod corner_tests {
assert!(body.validity().is_empty(), "{:?}", body.validity());
let m = solidcraft_kernel::measure(&body).unwrap();
eprintln!("corner g2, setbacks {setbacks}: {} faces, volume {:.4} (ball {:.4})", m.faces, m.volume, ball_volume);
assert_eq!(m.faces, 10);
assert!((m.volume - ball_volume).abs() < 1e-3 * ball_volume, "{} vs {}", m.volume, ball_volume);
assert_eq!(m.faces, 15);
assert!((m.volume - ball_volume).abs() < 5e-3 * ball_volume, "{} vs {}", m.volume, ball_volume);
}
let mut s = Session::default();
run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"}));
+44
View File
@@ -782,6 +782,50 @@ impl Band {
self.factor()?.substitute(b)
}
/// [`Band::solve`] by the right-looking factorisation: each row, once final, updates the
/// rows below it within the band along contiguous storage. The same arithmetic, in an order
/// that runs several times faster on wide bands.
pub(crate) fn solve_fast(mut self, b: Vec<f64>) -> SplineResult<Vec<f64>> {
let (n, bw) = (self.n, self.bw);
let w = bw + 1;
if self.a.len() != n * w {
return invalid("band size");
}
for r in 0..n {
let base = r * w;
let d = self.a.get(base).copied().unwrap_or(0.0);
if !(d > 0.0) {
return invalid("the fit's equations are singular");
}
let d = d.sqrt();
let len = w.min(n - r);
// Row r of U: its diagonal, and the rest divided by it.
let row: Vec<f64> = {
let Some(slice) = self.a.get_mut(base..base + len) else { return invalid("band size") };
if let Some(x) = slice.first_mut() {
*x = d;
}
for x in slice.iter_mut().skip(1) {
*x /= d;
}
slice.to_vec()
};
// Rows s = r + 1 … within the band: a[s][t] −= u[r][s]·u[r][t].
for (k, &us) in row.iter().enumerate().skip(1) {
if us == 0.0 {
continue;
}
let s = r + k;
let sbase = s * w;
let Some(target) = self.a.get_mut(sbase..sbase + (len - k)) else { return invalid("band size") };
for (t, ut) in target.iter_mut().zip(row.iter().skip(k)) {
*t -= us * ut;
}
}
}
self.substitute(b)
}
/// The Cholesky factor `U` of `A = Uᵀ·U`, in place; an error when `A` is not positive
/// definite.
pub(crate) fn factor(mut self) -> SplineResult<Band> {
-504
View File
@@ -1,504 +0,0 @@
//! Filling a closed loop with one height field that meets the faces beside the loop with G1 or
//! G2: the surface of [`crate::fill::fill_loop`] (a bicubic B-spline height field over the loop's
//! plane, so its parameters are plane coordinates and every edge's pcurve is the edge projected
//! onto that plane, exactly), fitted to the faces' derivatives as well as to the loop.
//!
//! The formulation (Piegl & Tiller, *The NURBS Book*, §9.4.1 least squares; do Carmo,
//! *Differential Geometry of Curves and Surfaces*, §3-3 for the second fundamental form of a
//! graph):
//!
//! - **The plane and grid** are [`crate::fill::fill_loop`]'s: the surface is
//! `S(u, v) = o + u·x + v·y + h(u, v)·n` with `h` a bicubic B-spline on a uniform grid over the
//! projected loop's box, the heights its unknowns.
//! - **The conditions** at each station (a point of the loop with the face beside it) are linear
//! in the heights, since every quantity of a graph is a derivative of `h`:
//! - G0: `h(u, v)` is the station's height.
//! - G1: the face's unit normal `N` (turned to `n`'s side) is the graph's when
//! `h_u = −N·x / N·n` and `h_v = −N·y / N·n`.
//! - G2: with G1, the surfaces share the curve and its tangent plane, so they agree to the
//! second order exactly when their normal curvatures across the curve agree: along the
//! direction `w` the face leaves the loop in (`a = w·x`, `b = w·y`),
//! `(a² h_uu + 2ab h_uv + b² h_vv) · N·n = κ`, `κ` the face's normal curvature along `w`
//! (its curvature vector `bend` against `N`).
//!
//! Each row is weighed to a length on the grid's scale (the G1 rows times a cell, the G2 rows
//! times a cell squared), and the heights minimise their squared misfit plus a small multiple
//! of the thin-plate energy ([`FAIRNESS`]), which decides what the conditions leave free.
//! - **At a corner** of the loop the two faces' conditions meet, and where their curvatures
//! across the corner differ (a round ending on a plane: the round's section has the round's
//! curvature where it reaches the plane, which is flat across its own curve) no surface is G2
//! to both there: the surface's curvature is continuous, and has to pass from one face's to the
//! other's. A plane's G2 with G1 makes the surface flat to the second order all along its
//! curve, so the passage is a stiff one, and asked over a few cells it pulls the surface off
//! the loop along the whole side. The corner's own station holds the position only; the G2
//! rows fade in over [`FADE_SHARE`] of the run from each corner to the next (a smoothstep of
//! the distance along the loop), a stretch of the loop's own, which finer grids then resolve,
//! and hold everywhere else. G1 rows fade likewise from a corner whose faces' tangent planes
//! disagree (`clash`), where no surface is G1 to both.
//! - **Grids** of 4 to [`MAX_MATCHED_SPANS`] spans across the box's longer side are tried in turn,
//! with denser sampling when a grid's cells are finer than twice the stations' step: the first
//! whose surface is within `tol` of every station, whose normal is within `angle_tol` of the
//! face's at every G1/G2 station clear of clashing corners, and whose normal curvature across
//! the loop is within `curvature_tol` of the face's at every G2 station clear of the corners'
//! fades, is the fill. Otherwise "not supported yet", with how close the best grid came.
//!
//! A loop that is not the boundary of a height field over its plane, or a face beside it that
//! stands along the plane's normal, is refused, never filled with wrong geometry. Input is
//! hostile: sizes are capped and every evaluation checked; nothing here panics.
use crate::Vec3;
use crate::bspline::{SplineError, SplineResult, invalid};
use crate::fill::{Band, Grid, LoopFill, LoopFrame, MAX_LOOP_POINTS, SMOOTHNESS, check_projectable, loop_frame};
use crate::fill_general::Station;
/// Most spans of the height field across the box's longer side.
pub const MAX_MATCHED_SPANS: usize = 128;
/// Fewest spans (the first grid tried).
const MIN_SPANS: usize = 4;
/// The share of the run from a corner to the next over which the conditions the corner cannot
/// hold fade in.
pub const FADE_SHARE: f64 = 0.15;
/// The thin-plate energy's weight per fitted station: a thousand times
/// [`crate::fill::fill_loop`]'s. Its fill only passes through points, which leave the inside to
/// the energy alone; here the slope and curvature rows also act a little way inside the loop,
/// and with the weaker energy they pull the surface up past a plane it is G2 to, a millimetre or
/// two in from the plane's curve (by 0.06 at a box corner's r5 rounds set back 8). This weight
/// keeps it under the plane but where it passes from a round's curvature to the plane's, and
/// costs the loop little (2e-5 mm, 0.025° and 4e-4 per mm there).
const FAIRNESS: f64 = 1e3 * SMOOTHNESS;
/// A face beside the loop whose normal is closer than this cosine to square with the plane's
/// stands along the plane's normal: the height field cannot be tangent to it.
const MIN_NORMAL_COS: f64 = 0.05;
/// What [`fill_matched`] is asked for.
#[derive(Clone, Copy, Debug)]
pub struct MatchedSpec {
/// How far the surface may be from the loop.
pub tol: f64,
/// How far (radians) its normal may turn from a G1/G2 neighbour's.
pub angle_tol: f64,
/// How far (1/length) its normal curvature across the loop may be from a G2 neighbour's,
/// clear of the corners' fades.
pub curvature_tol: f64,
}
/// The fill and what it achieved.
#[derive(Clone, Debug, PartialEq)]
pub struct MatchedFill {
/// The height field, its plane and its deviation from the loop (no crown).
pub fill: LoopFill,
/// The largest angle (radians) between the surface's normal and a neighbour's at the G1/G2
/// stations held to the angle tolerance, and at any G1/G2 station.
pub normal_deviation: f64,
pub normal_deviation_all: f64,
/// The largest difference (1/length) between the surface's normal curvature across the
/// loop and the neighbour's, at the G2 stations clear of the corners' fades, and at any G2
/// station.
pub curvature_mismatch: f64,
pub curvature_mismatch_all: f64,
/// Spans of the grid each way, and stations sampled.
pub spans: [usize; 2],
pub stations: usize,
}
/// Fill the closed loop of `sides` sides with one height field meeting each station's face as
/// its `across` asks (see the module documentation). `stations(k, ts)` samples side `k` at the
/// parameters `ts` (0 at its start, which is where side `k − 1` ends, towards 1 at its end); a
/// station at `t = 0` marked `corner` is a corner of the loop.
pub fn fill_matched(
sides: usize,
stations: &mut dyn FnMut(usize, &[f64]) -> Result<Vec<Station>, String>,
spec: &MatchedSpec,
) -> SplineResult<MatchedFill> {
if sides == 0 || sides.saturating_mul(16) > MAX_LOOP_POINTS / 2 {
return Err(SplineError::TooLarge(format!("a loop of {sides} sides (at most {})", MAX_LOOP_POINTS / 32)));
}
let positive = |x: f64| x.is_finite() && x > 0.0;
if !(positive(spec.tol) && positive(spec.angle_tol) && positive(spec.curvature_tol)) {
return invalid("the fill tolerances must be positive");
}
let mut sample = |ts: &[f64]| -> SplineResult<Vec<Vec<Station>>> {
(0..sides)
.map(|k| {
let got = stations(k, ts).map_err(SplineError::Invalid)?;
if got.len() == ts.len() && got.iter().all(finite_station) {
Ok(got)
} else {
Err(SplineError::Invalid(format!("side {k} has no finite stations")))
}
})
.collect()
};
let mut per = 16usize;
let mut fit = sample(&(0..per).map(|i| i as f64 / per as f64).collect::<Vec<_>>())?;
let mut best = Shortfall::default();
let mut level = MIN_SPANS;
loop {
let check = sample(&(0..per).map(|i| (i as f64 + 0.5) / per as f64).collect::<Vec<_>>())?;
// The loop in order: fitted and checked stations alternating, side by side.
let mut all: Vec<(Station, bool)> = Vec::with_capacity(2 * per * sides);
for (f, c) in fit.iter().zip(&check) {
for (a, b) in f.iter().zip(c) {
all.push((*a, true));
all.push((*b, false));
}
}
match fit_round(&all, spec, level)? {
Round::Done(fill) => return Ok(*fill),
Round::Denser(s, at) => {
best = best.min(s);
level = at;
}
Round::Exhausted(s) => {
best = best.min(s);
break;
}
}
if all.len() * 2 > MAX_LOOP_POINTS {
break;
}
fit = fit.iter().zip(&check).map(|(f, c)| f.iter().zip(c).flat_map(|(a, b)| [*a, *b]).collect()).collect();
per *= 2;
}
Err(SplineError::Unsupported(format!(
"filling this loop with one surface meeting its neighbours: the closest fit stays {:e} off the loop (tolerance {:e}), turns {:.4}° from \
its neighbours (tolerance {:.4}°) and misses their curvature by {:e} (tolerance {:e})",
best.deviation,
spec.tol,
best.angle.to_degrees(),
spec.angle_tol.to_degrees(),
best.curvature,
spec.curvature_tol
)))
}
fn finite_station(s: &Station) -> bool {
s.p.is_finite() && s.across.is_none_or(|a| a.leave.is_finite() && a.normal.is_finite() && a.bend.is_none_or(|b| b.is_finite()))
}
/// How close a grid came.
#[derive(Clone, Copy, Debug)]
struct Shortfall {
deviation: f64,
angle: f64,
curvature: f64,
}
impl Default for Shortfall {
fn default() -> Shortfall {
Shortfall { deviation: f64::INFINITY, angle: f64::INFINITY, curvature: f64::INFINITY }
}
}
impl Shortfall {
/// The closer of two, by how many times its tolerance each misses.
fn min(self, other: Shortfall) -> Shortfall {
let worst = |s: &Shortfall| s.deviation + s.angle + s.curvature;
if worst(&other) < worst(&self) { other } else { self }
}
}
enum Round {
Done(Box<MatchedFill>),
/// Denser sampling is needed for the grid of this level (the coarser ones fell short).
Denser(Shortfall, usize),
Exhausted(Shortfall),
}
/// A station as the fit takes it: its place over the plane, its height, and what the face beside
/// it asks there, in the plane's terms.
struct Row {
q: [f64; 2],
h: f64,
fitted: bool,
/// G1: the height field's gradient there, and the face's unit normal on `n`'s side.
gradient: Option<([f64; 2], Vec3)>,
/// G2: the leave direction's plane components `(a, b)` and the second derivative of `h`
/// along them, `κ / N·n`, with `κ` itself.
curvature: Option<([f64; 2], f64, f64)>,
/// How near the station is to a corner, and to a clashing corner: its distance along the
/// loop to the nearer of the two around it, as a share of the run between them (0 at a
/// corner, ½ midway; 1 with no corner).
to_corner: f64,
to_clash: f64,
corner: bool,
}
/// The stations in the plane `f`'s terms, with their distances to the corners along the loop.
fn rows(all: &[(Station, bool)], f: &LoopFrame) -> SplineResult<Vec<Row>> {
let m = all.len();
// Arc length along the loop (chords), and where its corners are.
let mut s = vec![0.0f64; m + 1];
for k in 0..m {
let (Some((a, _)), Some((b, _))) = (all.get(k), all.get((k + 1) % m)) else { return invalid("stations") };
let next = s.get(k).copied().unwrap_or(0.0) + a.p.dist(b.p);
if let Some(slot) = s.get_mut(k + 1) {
*slot = next;
}
}
let total = s.get(m).copied().unwrap_or(0.0);
if !(total > 0.0 && total.is_finite()) {
return invalid("the loop has no length");
}
let at = |k: usize| s.get(k).copied().unwrap_or(0.0);
let corners: Vec<f64> = (0..m).filter(|&k| all.get(k).is_some_and(|(st, _)| st.corner)).map(at).collect();
let clashes: Vec<f64> = (0..m).filter(|&k| all.get(k).is_some_and(|(st, _)| st.clash)).map(at).collect();
let share = |list: &[f64], x: f64| {
if list.is_empty() {
return 1.0;
}
let back = list.iter().map(|c| (x - c).rem_euclid(total)).fold(f64::INFINITY, f64::min);
let ahead = list.iter().map(|c| (c - x).rem_euclid(total)).filter(|d| *d > 0.0).fold(total, f64::min);
let run = back + ahead;
if run > 0.0 { back.min(ahead) / run } else { 0.0 }
};
let mut out = Vec::with_capacity(m);
for (k, (st, fitted)) in all.iter().enumerate() {
let d = st.p - f.o;
let q = [d.dot(f.x), d.dot(f.y)];
let mut gradient = None;
let mut curvature = None;
if let Some(a) = st.across {
let normal = a.normal.normalized().ok_or_else(|| SplineError::Invalid("a neighbour's normal has no direction".into()))?;
let side = if normal.dot(f.n) >= 0.0 { 1.0 } else { -1.0 };
let nn = normal * side;
let c = nn.dot(f.n);
if !(c > MIN_NORMAL_COS) {
return Err(SplineError::Unsupported("filling a loop beside a face that stands along the fill's normal".into()));
}
gradient = Some(([-nn.dot(f.x) / c, -nn.dot(f.y) / c], nn));
if let Some(bend) = a.bend {
let kappa = bend.dot(nn);
let w = a.leave;
curvature = Some(([w.dot(f.x), w.dot(f.y)], kappa / c, kappa));
}
}
out.push(Row {
q,
h: d.dot(f.n),
fitted: *fitted,
gradient,
curvature,
to_corner: share(&corners, at(k)),
to_clash: share(&clashes, at(k)),
corner: st.corner,
});
}
Ok(out)
}
/// The smoothstep `3t² − 2t³` of `share / FADE_SHARE` (1 from [`FADE_SHARE`] on).
fn fade(share: f64) -> f64 {
let t = (share / FADE_SHARE).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// The height field's derivatives at `q`: `[h, h_u, h_v, h_uu, h_uv, h_vv]`.
fn derivatives(grid: &Grid, heights: &[f64], q: [f64; 2]) -> Option<[f64; 6]> {
let cu = *grid.count.first()?;
let mut out = [0.0; 6];
for (slot, (ru, rv)) in out.iter_mut().zip([(0, 0), (1, 0), (0, 1), (2, 0), (1, 1), (0, 2)]) {
let (i0, bu) = grid.basis_derivative(0, q[0], ru)?;
let (j0, bv) = grid.basis_derivative(1, q[1], rv)?;
let mut s = 0.0;
for (b, wv) in bv.iter().enumerate() {
for (a, wu) in bu.iter().enumerate() {
s += wu * wv * heights.get((j0 + b) * cu + i0 + a)?;
}
}
*slot = s;
}
out.iter().all(|x| x.is_finite()).then_some(out)
}
/// One grid's fit of the rows: the normal equations of the weighed rows and the energy.
fn solve(grid: &Grid, rows: &[Row], cell: f64) -> SplineResult<Vec<f64>> {
let [cu, cv] = grid.count;
let n = cu * cv;
let mut band = Band::new(n, 3 * cu + 3);
let mut rhs = vec![0.0; n];
let add = |terms: &[(usize, f64)], value: f64, weight: f64, band: &mut Band, rhs: &mut Vec<f64>| {
let w2 = weight * weight;
for &(r, cr) in terms {
if let Some(slot) = rhs.get_mut(r) {
*slot += w2 * cr * value;
}
for &(s, cs) in terms {
band.add(r, s, w2 * cr * cs);
}
}
};
let mut fitted = 0usize;
for row in rows.iter().filter(|r| r.fitted) {
let basis = |r: [usize; 2]| -> SplineResult<(usize, [f64; 4], usize, [f64; 4])> {
let (Some((i0, bu)), Some((j0, bv))) = (grid.basis_derivative(0, row.q[0], r[0]), grid.basis_derivative(1, row.q[1], r[1])) else {
return invalid("a loop point is off the grid");
};
Ok((i0, bu, j0, bv))
};
// The 16 control heights a derivative of order `r` at the station draws on, scaled.
let terms = |r: [usize; 2], scale: f64| -> SplineResult<Vec<(usize, f64)>> {
let (i0, bu, j0, bv) = basis(r)?;
let mut t = Vec::with_capacity(16);
for (b, wv) in bv.iter().enumerate() {
for (a, wu) in bu.iter().enumerate() {
t.push(((j0 + b) * cu + i0 + a, wu * wv * scale));
}
}
Ok(t)
};
add(&terms([0, 0], 1.0)?, row.h, 1.0, &mut band, &mut rhs);
fitted += 1;
if row.corner {
continue;
}
if let Some((g, _)) = row.gradient {
let w = cell * fade(row.to_clash);
if w > 0.0 {
add(&terms([1, 0], 1.0)?, g[0], w, &mut band, &mut rhs);
add(&terms([0, 1], 1.0)?, g[1], w, &mut band, &mut rhs);
}
}
if let Some(([a, b], second, _)) = row.curvature {
let w = cell * cell * fade(row.to_corner) * fade(row.to_clash);
if w > 0.0 {
// a² h_uu + 2ab h_uv + b² h_vv, the three derivatives' terms summed per control
// height.
let mut sum: Vec<(usize, f64)> = Vec::with_capacity(48);
for (r, s) in [([2, 0], a * a), ([1, 1], 2.0 * a * b), ([0, 2], b * b)] {
for (idx, v) in terms(r, s)? {
match sum.iter_mut().find(|(i, _)| *i == idx) {
Some(slot) => slot.1 += v,
None => sum.push((idx, v)),
}
}
}
add(&sum, second, w, &mut band, &mut rhs);
}
}
}
let lambda = FAIRNESS * fitted as f64 / n as f64 * grid.cell(0) * grid.cell(1);
grid.add_energy(&mut band, lambda);
band.solve(rhs)
}
/// What a fitted grid achieves at the rows: the deviation from the loop, the normal's angle to
/// the neighbours' (held stations, all), the curvature mismatch (held stations, all).
fn achieved(grid: &Grid, heights: &[f64], rows: &[Row], f: &LoopFrame) -> SplineResult<[f64; 5]> {
let reach = FADE_SHARE;
let (mut dev, mut ang, mut ang_all, mut cur, mut cur_all) = (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64);
for row in rows {
let Some([h, hu, hv, huu, huv, hvv]) = derivatives(grid, heights, row.q) else { return invalid("the fit is not finite") };
dev = dev.max((h - row.h).abs());
if row.corner {
continue;
}
let up = (1.0 + hu * hu + hv * hv).sqrt();
let normal = (f.n - f.x * hu - f.y * hv) * (1.0 / up);
if let Some((_, nn)) = row.gradient {
let a = normal.dot(nn).clamp(-1.0, 1.0).acos();
ang_all = ang_all.max(a);
if row.to_clash >= reach {
ang = ang.max(a);
}
}
if let Some(([a, b], _, kappa)) = row.curvature {
// The normal curvature along the unit tangent of the surface over (a, b): the second
// fundamental form over the first.
let t = f.x * a + f.y * b + f.n * (a * hu + b * hv);
let ii = (a * a * huu + 2.0 * a * b * huv + b * b * hvv) / up;
let k = ii / t.len2().max(1e-300);
let miss = (k - kappa).abs();
cur_all = cur_all.max(miss);
if row.to_corner >= reach && row.to_clash >= reach {
cur = cur.max(miss);
}
}
}
Ok([dev, ang, ang_all, cur, cur_all])
}
/// One round: the grids from `level` up, on these stations.
fn fit_round(all: &[(Station, bool)], spec: &MatchedSpec, level: usize) -> SplineResult<Round> {
let fitted: Vec<Vec3> = all.iter().filter(|(_, f)| *f).map(|(s, _)| s.p).collect();
let frame = loop_frame(&fitted)?;
let points: Vec<Vec3> = all.iter().map(|(s, _)| s.p).collect();
let rows = rows(all, &frame)?;
let uv: Vec<[f64; 2]> = rows.iter().map(|r| r.q).collect();
check_projectable(&points, &uv)?;
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for q in &uv {
for d in 0..2 {
lo[d] = lo[d].min(q[d]);
hi[d] = hi[d].max(q[d]);
}
}
for d in 0..2 {
let pad = 0.01 * (hi[d] - lo[d]);
lo[d] -= pad;
hi[d] += pad;
}
if !(lo[0] < hi[0] && lo[1] < hi[1] && (hi[0] - lo[0]).is_finite() && (hi[1] - lo[1]).is_finite()) {
return invalid("the loop's extent is not finite");
}
// The fitted stations' largest step each way: a grid finer than half of it waits for denser
// sampling.
let fq: Vec<[f64; 2]> = rows.iter().filter(|r| r.fitted).map(|r| r.q).collect();
let mut step = [0.0f64; 2];
for k in 0..fq.len() {
let (p, q) = (fq.get(k).copied().unwrap_or([0.0; 2]), fq.get((k + 1) % fq.len()).copied().unwrap_or([0.0; 2]));
for d in 0..2 {
step[d] = step[d].max((q[d] - p[d]).abs());
}
}
let extent = [hi[0] - lo[0], hi[1] - lo[1]];
let longer = extent[0].max(extent[1]);
let mut best = Shortfall::default();
let mut level = level;
while level <= MAX_MATCHED_SPANS {
let spans = [0, 1].map(|d| ((extent[d] / longer * level as f64).ceil() as usize).clamp(1, level));
let holds = (0..2).all(|d| extent[d] / spans[d] as f64 >= 2.0 * step[d]);
if !holds && level > MIN_SPANS {
return Ok(Round::Denser(best, level));
}
let grid = Grid::new(spans, lo, hi);
let cell = grid.cell(0).max(grid.cell(1));
let heights = solve(&grid, &rows, cell)?;
let [dev, ang, ang_all, cur, cur_all] = achieved(&grid, &heights, &rows, &frame)?;
let s = Shortfall { deviation: dev, angle: ang, curvature: cur };
best = best.min(s);
if dev <= spec.tol && ang <= spec.angle_tol && cur <= spec.curvature_tol && holds {
let surface = grid.surface(&heights, frame.o, frame.x, frame.y, frame.n)?;
let fill = LoopFill {
surface,
origin: frame.o,
axes: [frame.x, frame.y],
normal: frame.n,
counter_clockwise: frame.counter_clockwise,
deviation: dev,
evaluations: all.len(),
crown: 0.0,
crowned: 0,
crown_drift: 0.0,
};
return Ok(Round::Done(Box::new(MatchedFill {
fill,
normal_deviation: ang,
normal_deviation_all: ang_all,
curvature_mismatch: cur,
curvature_mismatch_all: cur_all,
spans,
stations: all.len(),
})));
}
if !holds {
return Ok(Round::Denser(best, level));
}
level *= 2;
}
Ok(Round::Exhausted(best))
}
#[cfg(test)]
#[path = "fill_matched_tests.rs"]
mod tests;
-181
View File
@@ -1,181 +0,0 @@
use super::*;
use crate::fill_general::Across;
use std::f64::consts::FRAC_PI_2;
/// The face tolerance of the 60 x 40 x 20 box the corner is cut from (10⁻⁶ of its size).
const TOL: f64 = 7.5e-5;
const ANGLE: f64 = 0.05 * std::f64::consts::PI / 180.0;
/// Half a percent of the 1/5 per mm a rolling ball of radius 5 jumps by across its seams.
const CURVATURE: f64 = 1e-3;
fn spec() -> MatchedSpec {
MatchedSpec { tol: TOL, angle_tol: ANGLE, curvature_tol: CURVATURE }
}
/// The hole a setback leaves at the corner of three rounds of radius `r` set back `s` (> r)
/// along each edge of a corner at the origin, the solid below it in every axis: three sections
/// of the rounds (quarter circles on the cylinders about the three edges) and three curves on the
/// faces between their ends, each a quarter circle about the point `s` back along both edges
/// (radius `s − r`), meeting the rounds' contact lines tangentially. Each side is G2 to its face:
/// a round leaves its section along its axis, where it is straight, a face anywhere; both have no
/// curvature across the hole.
fn setback_hole(r: f64, s: f64, g2: bool) -> impl FnMut(usize, &[f64]) -> Result<Vec<Station>, String> {
let rho = s - r;
move |k, ts| {
ts.iter()
.map(|t| {
let a = t * FRAC_PI_2;
let (sin, cos) = a.sin_cos();
let (p, normal, leave) = match k {
0 => (Vec3::new(-s, -r + r * sin, -r + r * cos), Vec3::new(0.0, sin, cos), Vec3::X),
1 => (Vec3::new(-s + rho * sin, 0.0, -s + rho * cos), Vec3::Y, Vec3::new(sin, 0.0, cos)),
2 => (Vec3::new(-r + r * sin, -r + r * cos, -s), Vec3::new(sin, cos, 0.0), Vec3::Z),
3 => (Vec3::new(0.0, -s + rho * cos, -s + rho * sin), Vec3::X, Vec3::new(0.0, cos, sin)),
4 => (Vec3::new(-r + r * cos, -s, -r + r * sin), Vec3::new(cos, 0.0, sin), Vec3::Y),
5 => (Vec3::new(-s + rho * cos, -s + rho * sin, 0.0), Vec3::Z, Vec3::new(cos, sin, 0.0)),
_ => return Err(format!("no side {k}")),
};
let up = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z);
Ok(Station { p, up, across: Some(Across { leave, normal, bend: g2.then_some(Vec3::ZERO) }), corner: *t == 0.0, clash: false })
})
.collect()
}
}
#[test]
fn a_setback_corner_hole_is_filled_g2_to_its_rounds_and_faces() {
let f = fill_matched(6, &mut setback_hole(5.0, 8.0, true), &spec()).unwrap();
eprintln!(
"setback hole: spans {:?}, {} stations, deviation {:e}, normal {:.5}° (all {:.5}°), curvature {:e} (all {:e})",
f.spans,
f.stations,
f.fill.deviation,
f.normal_deviation.to_degrees(),
f.normal_deviation_all.to_degrees(),
f.curvature_mismatch,
f.curvature_mismatch_all
);
assert!(f.fill.deviation <= TOL && f.normal_deviation <= ANGLE && f.curvature_mismatch <= CURVATURE);
// The plane is the corner's diagonal one, by symmetry.
let d = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap();
assert!(f.fill.normal.dot(d) > 1.0 - 1e-9, "{:?}", f.fill.normal);
// The patch is the corner's cap: inside the corner cube's octant and within the hull of the
// three rounds' tangent planes at 45° (the box's corner cut back by the rounds).
let [du, dv] = f.fill.surface.domain();
let mut highest = f64::NEG_INFINITY;
for i in 0..=40 {
for j in 0..=40 {
let u = (du[0] + (du[1] - du[0]) * i as f64 / 40.0).min(du[1]);
let v = (dv[0] + (dv[1] - dv[0]) * j as f64 / 40.0).min(dv[1]);
let p = f.fill.surface.eval(u, v).unwrap();
// Over the loop only: the box's corners outside the hole are not the patch.
if p.x > -8.0 && p.y > -8.0 && p.z > -8.0 && p.x < 0.0 && p.y < 0.0 && p.z < 0.0 {
highest = highest.max(p.dot(d));
}
}
}
eprintln!("setback hole: highest point along the diagonal {highest:.6}");
assert!(highest < 0.0);
}
#[test]
fn a_setback_corner_hole_is_filled_g1_on_a_coarser_grid() {
let f = fill_matched(6, &mut setback_hole(5.0, 8.0, false), &spec()).unwrap();
eprintln!("setback hole G1: spans {:?}, deviation {:e}, normal {:.5}°", f.spans, f.fill.deviation, f.normal_deviation.to_degrees());
assert!(f.fill.deviation <= TOL && f.normal_deviation <= ANGLE);
// Flat to the second order along a curved plane's curve is what makes G2 the harder fill.
let g2 = fill_matched(6, &mut setback_hole(5.0, 8.0, true), &spec()).unwrap();
assert!(f.spans[0] < g2.spans[0], "{:?} {:?}", f.spans, g2.spans);
}
/// A hole in a cylinder of radius 5 about the x axis (its top line through the origin), the rim
/// over the circle of radius 2 about the z axis, G2 all round: the loop has no corner, and the
/// fill repeats the cylinder there.
#[test]
fn a_hole_in_a_cylinder_is_filled_with_the_cylinder_to_the_second_order() {
let r = 5.0;
let mut rim = move |_: usize, ts: &[f64]| -> Result<Vec<Station>, String> {
ts.iter()
.map(|t| {
let a = t * std::f64::consts::TAU;
let (x, y) = (2.0 * a.cos(), 2.0 * a.sin());
let z = (r * r - y * y).sqrt() - r;
let p = Vec3::new(x, y, z);
let normal = Vec3::new(0.0, y, z + r).normalized().ok_or("normal")?;
let tangent = Vec3::new(-a.sin(), a.cos(), -a.cos() * y / (z + r)).normalized().ok_or("tangent")?;
// Into the hole, square to the rim in the cylinder.
let leave = tangent.cross(normal).normalized().ok_or("leave")?;
let leave = if leave.dot(Vec3::new(-x, -y, 0.0)) > 0.0 { leave } else { leave * -1.0 };
// The cylinder's normal curvature along `leave`: its part across the axis, squared,
// over the radius, bending away from the normal.
let across = leave - Vec3::X * leave.x;
let bend = normal * (-across.len2() / r);
Ok(Station { p, up: normal, across: Some(Across { leave, normal, bend: Some(bend) }), corner: false, clash: false })
})
.collect()
};
let f = fill_matched(1, &mut rim, &spec()).unwrap();
eprintln!("cylinder hole: spans {:?}, deviation {:e}, curvature {:e}", f.spans, f.fill.deviation, f.curvature_mismatch_all);
assert!(f.fill.deviation <= TOL && f.normal_deviation_all <= ANGLE && f.curvature_mismatch_all <= CURVATURE);
}
#[test]
fn hostile_matched_fills_are_refused() {
let bad = MatchedSpec { tol: 0.0, ..spec() };
assert!(fill_matched(6, &mut setback_hole(5.0, 8.0, true), &bad).is_err());
assert!(fill_matched(0, &mut setback_hole(5.0, 8.0, true), &spec()).is_err());
// A face beside the loop standing along the fill's normal.
let mut wall = |_: usize, ts: &[f64]| -> Result<Vec<Station>, String> {
Ok(ts
.iter()
.map(|t| {
let a = t * std::f64::consts::TAU / 3.0;
let p = Vec3::new(a.cos(), a.sin(), 0.0);
Station { p, up: Vec3::Z, across: Some(Across { leave: Vec3::Z, normal: p, bend: None }), corner: false, clash: false }
})
.collect())
};
assert!(matches!(fill_matched(3, &mut wall, &spec()), Err(SplineError::Unsupported(_))));
}
/// How far the fill of `setback_hole(r, s)` rises past the planes it is G2 to, over the loop
/// (the corner is convex: all of it should lie under them), sampled.
fn over_the_planes(f: &MatchedFill, r: f64, s: f64) -> f64 {
let mut st = setback_hole(r, s, true);
let ts: Vec<f64> = (0..256).map(|i| i as f64 / 256.0).collect();
let poly: Vec<[f64; 2]> = (0..6)
.flat_map(|k| st(k, &ts).unwrap())
.map(|q| {
let d = q.p - f.fill.origin;
[d.dot(f.fill.axes[0]), d.dot(f.fill.axes[1])]
})
.collect();
let [du, dv] = f.fill.surface.domain();
let n = 200;
let mut over = f64::NEG_INFINITY;
for i in 0..=n {
for j in 0..=n {
let u = (du[0] + (du[1] - du[0]) * i as f64 / n as f64).min(du[1]);
let v = (dv[0] + (dv[1] - dv[0]) * j as f64 / n as f64).min(dv[1]);
if crate::fill::inside_loop([u, v], &poly) {
let p = f.fill.surface.eval(u, v).unwrap();
over = over.max(p.x).max(p.y).max(p.z);
}
}
}
over
}
/// Set back from 1.5 to 3 times the radius the G2 fill stays under the planes it meets, but by
/// under a thousandth where it passes from a round's curvature to the plane's at the hole's
/// corners (SMLib's own setback patch rises past them beyond about twice the radius, by 0.002 at
/// 2.2 r and 0.25 at 3 r).
#[test]
fn a_setback_g2_fill_stays_under_its_planes() {
for s in [7.5, 8.0, 10.0, 12.0, 15.0] {
let f = fill_matched(6, &mut setback_hole(5.0, s, true), &spec()).unwrap();
let over = over_the_planes(&f, 5.0, s);
eprintln!("set back {s}: spans {:?}, curvature {:.2e}, over the planes {over:.2e}", f.spans, f.curvature_mismatch);
assert!(over < 1e-3, "set back {s}: {over}");
}
}
File diff suppressed because it is too large Load Diff
+316
View File
@@ -0,0 +1,316 @@
use super::*;
/// A hexagonal hole in the plane z = 0, each side a line split at its middle, the plane beside
/// every edge: the fill is that plane (every patch flat), within the tolerances everywhere.
#[test]
fn a_flat_hexagon_fills_flat() {
let corners: Vec<Vec3> = (0..6)
.map(|k| {
let a = k as f64 * std::f64::consts::PI / 3.0;
Vec3::new(10.0 * a.cos(), 10.0 * a.sin(), 0.0)
})
.collect();
let mut edges = Vec::new();
for k in 0..6 {
let (a, b) = (corners[k], corners[(k + 1) % 6]);
let m = (a + b) * 0.5;
edges.push(QuadEdge { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], points: vec![a, m], weights: Vec::new() });
edges.push(QuadEdge { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], points: vec![m, b], weights: Vec::new() });
}
let copy = edges.clone();
let mut frames = |j: usize, ts: &[f64]| -> Result<Vec<Frame>, String> {
let e = &copy[j];
let t = (e.points[1] - e.points[0]).normalized().unwrap();
// The hole is counter-clockwise about +z: inside is on the left, z × t.
let leave = Vec3::new(-t.y, t.x, 0.0);
Ok(ts.iter().map(|_| Frame { normal: Vec3::new(0.0, 0.0, 1.0), leave, form: [0.0; 3] }).collect())
};
let fill = quad_fill(&edges, &mut frames, &QuadSpec { tol: 1e-9, angle_tol: 1e-3_f64.to_radians(), curvature_tol: 1e-6, fade: 1.0 }).unwrap();
assert_eq!(fill.patches.len(), 6);
for p in &fill.patches {
for i in 0..=8 {
for j in 0..=8 {
let z = p.eval(i as f64 / 8.0, j as f64 / 8.0).unwrap().z;
assert!(z.abs() < 1e-9, "{z}");
}
}
}
assert!(fill.centre.len() < 1.0, "{:?}", fill.centre);
}
/// Hostile input is refused: an odd number of edges, edges that do not meet, a rational edge
/// not in standard form.
#[test]
fn a_bad_hole_is_refused() {
let line = |a: Vec3, b: Vec3| QuadEdge { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], points: vec![a, b], weights: Vec::new() };
let mut frames = |_: usize, ts: &[f64]| -> Result<Vec<Frame>, String> {
Ok(ts.iter().map(|_| Frame { normal: Vec3::new(0.0, 0.0, 1.0), leave: Vec3::new(1.0, 0.0, 0.0), form: [0.0; 3] }).collect())
};
let spec = QuadSpec { tol: 1e-9, angle_tol: 1e-3, curvature_tol: 1e-3, fade: 1.0 };
let p = |x: f64, y: f64| Vec3::new(x, y, 0.0);
assert!(quad_fill(&[line(p(0.0, 0.0), p(1.0, 0.0))], &mut frames, &spec).is_err());
let apart: Vec<QuadEdge> = (0..6).map(|k| line(p(k as f64, 0.0), p(k as f64 + 0.5, 0.0))).collect();
assert!(quad_fill(&apart, &mut frames, &spec).is_err());
let mut bad = line(p(0.0, 0.0), p(1.0, 0.0));
bad.weights = vec![2.0, 1.0];
let mut six: Vec<QuadEdge> = (0..6).map(|k| line(p(k as f64, 0.0), p(k as f64 + 1.0, 0.0))).collect();
six[0] = bad;
assert!(quad_fill(&six, &mut frames, &spec).is_err());
}
/// A circle of radius 6 in z = 0 split into twelve 30° arcs (rational quadratics in standard
/// form), the hole a cap of the sphere of radius 10 through it: its frames that sphere's. The
/// fill is G2 to the sphere within the tolerances, its centre on the axis under the pole.
#[test]
fn a_spherical_cap_fills_g2() {
let (rho, big) = (6.0f64, 10.0f64);
let h = (big * big - rho * rho).sqrt();
let centre = Vec3::new(0.0, 0.0, -h);
let step = std::f64::consts::PI / 6.0;
let at = |a: f64| Vec3::new(rho * a.cos(), rho * a.sin(), 0.0);
let half = 0.5 * step;
let w = half.cos();
let edges: Vec<QuadEdge> = (0..12)
.map(|k| {
let (a0, a1) = (k as f64 * step, (k + 1) as f64 * step);
let mid = Vec3::new(rho * (a0 + half).cos(), rho * (a0 + half).sin(), 0.0) * (1.0 / w);
QuadEdge { degree: 2, knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], points: vec![at(a0), mid, at(a1)], weights: vec![1.0, w, 1.0] }
})
.collect();
let copy = edges.clone();
let mut frames = |j: usize, ts: &[f64]| -> Result<Vec<Frame>, String> {
let c = Curve::new(&copy[j]).map_err(|e| e.to_string())?;
ts.iter()
.map(|t| {
let [p, d1, _] = c.derivs(*t).map_err(|e| e.to_string())?;
let n = (p - centre) * (1.0 / big);
let tan = d1.normalized().unwrap();
// Into the cap: towards the pole, square to the edge in the sphere's tangent plane.
let leave = n.cross(tan).normalized().unwrap();
Ok(Frame { normal: n, leave, form: [-1.0 / big, 0.0, -1.0 / big] })
})
.collect()
};
let spec = QuadSpec { tol: 1e-9, angle_tol: 0.05f64.to_radians(), curvature_tol: 1e-3, fade: 1.0 };
let fill = quad_fill(&edges, &mut frames, &spec).unwrap();
assert_eq!(fill.patches.len(), 6);
let top = big - h;
eprintln!("cap: centre {:?} (the pole at z = {top})", fill.centre);
// The energy decides the middle, which a cap's G2 rim does not fix: it lies on the axis,
// between the rim's plane and the pole.
assert!(fill.centre.z > 0.5 * top && fill.centre.z < top + 1e-6 && fill.centre.x.hypot(fill.centre.y) < 1e-6, "{:?}", fill.centre);
}
/// A hexagon on the paraboloid z = (x² + y²) / 20, each side the parabola over a straight line
/// split at its middle, the paraboloid beside every edge: the paraboloid itself is a fill of
/// the patches' kind (a quadratic of bilinear maps); the fill meets it within the tolerances.
#[test]
fn a_paraboloid_hexagon_fills_g2() {
let r = 10.0f64;
let lift = |x: f64, y: f64| Vec3::new(x, y, (x * x + y * y) / (2.0 * r));
let corners: Vec<(f64, f64)> = (0..6)
.map(|k| {
let a = k as f64 * std::f64::consts::PI / 3.0;
(6.0 * a.cos(), 6.0 * a.sin())
})
.collect();
// The parabola over the segment from a to b: a quadratic Bézier.
let piece = |a: (f64, f64), b: (f64, f64)| {
let m = (0.5 * (a.0 + b.0), 0.5 * (a.1 + b.1));
let (pa, pb, pm) = (lift(a.0, a.1), lift(b.0, b.1), lift(m.0, m.1));
let mid = pm * 2.0 - (pa + pb) * 0.5;
QuadEdge { degree: 2, knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], points: vec![pa, mid, pb], weights: Vec::new() }
};
let mut edges = Vec::new();
for k in 0..6 {
let (a, b) = (corners[k], corners[(k + 1) % 6]);
let m = (0.5 * (a.0 + b.0), 0.5 * (a.1 + b.1));
edges.push(piece(a, m));
edges.push(piece(m, b));
}
let copy = edges.clone();
let mut frames = |j: usize, ts: &[f64]| -> Result<Vec<Frame>, String> {
let c = Curve::new(&copy[j]).map_err(|e| e.to_string())?;
ts.iter()
.map(|t| {
let [p, d1, _] = c.derivs(*t).map_err(|e| e.to_string())?;
let (zx, zy) = (p.x / r, p.y / r);
let up = (1.0 + zx * zx + zy * zy).sqrt();
let n = Vec3::new(-zx, -zy, 1.0) * (1.0 / up);
let tan = d1.normalized().unwrap();
let leave = n.cross(tan).normalized().unwrap();
// II(X, X) = (X_x² + X_y²) / (r·up) for the graph z = (x² + y²) / 2r.
let ii = |x: Vec3, y: Vec3| (x.x * y.x + x.y * y.y) / (r * up);
Ok(Frame { normal: n, leave, form: [ii(tan, tan), ii(tan, leave), ii(leave, leave)] })
})
.collect()
};
let spec = QuadSpec { tol: 1e-9, angle_tol: 0.05f64.to_radians(), curvature_tol: 1e-3, fade: 1.0 };
let fill = quad_fill(&edges, &mut frames, &spec).unwrap();
eprintln!("paraboloid: centre {:?}", fill.centre);
// On the axis, at most as high as the paraboloid's rim: the energy settles the middle.
assert!(fill.centre.x.hypot(fill.centre.y) < 1e-6 && fill.centre.z > -1e-6 && fill.centre.z < 1.8, "{:?}", fill.centre);
}
/// A four-sided hole on a cylinder of radius 5 about x: two cross-section arcs (rational) and
/// two generators, each split at its middle, the cylinder beside every edge: the fill is G2 to
/// it within the tolerances (the cylinder itself is one such fill).
#[test]
fn a_cylinder_window_fills_g2() {
let r = 5.0f64;
let at = |x: f64, a: f64| Vec3::new(x, r * a.cos(), r * a.sin());
let (a0, a1) = (30f64.to_radians(), 90f64.to_radians());
let line = |p: Vec3, q: Vec3| QuadEdge { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], points: vec![p, q], weights: Vec::new() };
let arc = |x: f64, from: f64, to: f64| {
let half = 0.5 * (to - from);
let w = half.cos();
let mid = at(x, from + half) * (1.0 / w);
let mid = Vec3::new(x, mid.y, mid.z);
QuadEdge { degree: 2, knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], points: vec![at(x, from), mid, at(x, to)], weights: vec![1.0, w, 1.0] }
};
let am = 0.5 * (a0 + a1);
let edges = vec![
line(at(0.0, a0), at(3.0, a0)),
line(at(3.0, a0), at(6.0, a0)),
arc(6.0, a0, am),
arc(6.0, am, a1),
line(at(6.0, a1), at(3.0, a1)),
line(at(3.0, a1), at(0.0, a1)),
arc(0.0, a1, am),
arc(0.0, am, a0),
];
let copy = edges.clone();
let mut frames = |j: usize, ts: &[f64]| -> Result<Vec<Frame>, String> {
let c = Curve::new(&copy[j]).map_err(|e| e.to_string())?;
ts.iter()
.map(|t| {
let [p, d1, _] = c.derivs(*t).map_err(|e| e.to_string())?;
let n = Vec3::new(0.0, p.y, p.z) * (1.0 / r);
let tan = d1.normalized().unwrap();
// Into the hole: towards its middle, square to the edge in the tangent plane.
let mid = at(3.0, am);
let mut leave = n.cross(tan).normalized().unwrap();
if leave.dot(mid - p) < 0.0 {
leave = leave * -1.0;
}
let ii = |x: Vec3, y: Vec3| {
let e = Vec3::new(0.0, -p.z, p.y) * (1.0 / r);
-x.dot(e) * y.dot(e) / r
};
Ok(Frame { normal: n, leave, form: [ii(tan, tan), ii(tan, leave), ii(leave, leave)] })
})
.collect()
};
let spec = QuadSpec { tol: 1e-9, angle_tol: 0.05f64.to_radians(), curvature_tol: 1e-3, fade: 1.0 };
let fill = quad_fill(&edges, &mut frames, &spec).unwrap();
eprintln!("cylinder window: centre {:?}, radius {}", fill.centre, fill.centre.y.hypot(fill.centre.z));
}
/// The cylinder window with the plane tangent to the cylinder along its first generator beside
/// that side: the round's G1-only seam with a plane ends at two corners of the hole, as at a
/// set-back corner; G2 clear of those corners.
#[test]
fn a_cylinder_window_beside_a_tangent_plane_fills_g2() {
let r = 5.0f64;
let at = |x: f64, a: f64| Vec3::new(x, r * a.cos(), r * a.sin());
let (a0, a1) = (30f64.to_radians(), 90f64.to_radians());
let line = |p: Vec3, q: Vec3| QuadEdge { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], points: vec![p, q], weights: Vec::new() };
let arc = |x: f64, from: f64, to: f64| {
let half = 0.5 * (to - from);
let w = half.cos();
let mid = at(x, from + half) * (1.0 / w);
let mid = Vec3::new(x, mid.y, mid.z);
QuadEdge { degree: 2, knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], points: vec![at(x, from), mid, at(x, to)], weights: vec![1.0, w, 1.0] }
};
let am = 0.5 * (a0 + a1);
let edges = vec![
line(at(0.0, a0), at(3.0, a0)),
line(at(3.0, a0), at(6.0, a0)),
arc(6.0, a0, am),
arc(6.0, am, a1),
line(at(6.0, a1), at(3.0, a1)),
line(at(3.0, a1), at(0.0, a1)),
arc(0.0, a1, am),
arc(0.0, am, a0),
];
let copy = edges.clone();
let mut frames = |j: usize, ts: &[f64]| -> Result<Vec<Frame>, String> {
let c = Curve::new(&copy[j]).map_err(|e| e.to_string())?;
ts.iter()
.map(|t| {
let [p, d1, _] = c.derivs(*t).map_err(|e| e.to_string())?;
let n = Vec3::new(0.0, p.y, p.z) * (1.0 / r);
let tan = d1.normalized().unwrap();
// Into the hole: towards its middle, square to the edge in the tangent plane.
let mid = at(3.0, am);
let mut leave = n.cross(tan).normalized().unwrap();
if leave.dot(mid - p) < 0.0 {
leave = leave * -1.0;
}
let ii = |x: Vec3, y: Vec3| {
let e = Vec3::new(0.0, -p.z, p.y) * (1.0 / r);
-x.dot(e) * y.dot(e) / r
};
if j < 2 {
return Ok(Frame { normal: n, leave, form: [0.0; 3] });
}
Ok(Frame { normal: n, leave, form: [ii(tan, tan), ii(tan, leave), ii(leave, leave)] })
})
.collect()
};
let spec = QuadSpec { tol: 1e-9, angle_tol: 0.05f64.to_radians(), curvature_tol: 1e-3, fade: 1.0 };
let fill = quad_fill(&edges, &mut frames, &spec).unwrap();
eprintln!("cylinder window: centre {:?}, radius {}", fill.centre, fill.centre.y.hypot(fill.centre.z));
}
/// The cylinder window mirrored (z → −z): its loop runs the other way round its normal.
#[test]
fn a_mirrored_cylinder_window_fills_g2() {
let r = 5.0f64;
let at = |x: f64, a: f64| Vec3::new(x, r * a.cos(), -r * a.sin());
let (a0, a1) = (30f64.to_radians(), 90f64.to_radians());
let line = |p: Vec3, q: Vec3| QuadEdge { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], points: vec![p, q], weights: Vec::new() };
let arc = |x: f64, from: f64, to: f64| {
let half = 0.5 * (to - from);
let w = half.cos();
let mid = at(x, from + half) * (1.0 / w);
let mid = Vec3::new(x, mid.y, mid.z);
QuadEdge { degree: 2, knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], points: vec![at(x, from), mid, at(x, to)], weights: vec![1.0, w, 1.0] }
};
let am = 0.5 * (a0 + a1);
let edges = vec![
line(at(0.0, a0), at(3.0, a0)),
line(at(3.0, a0), at(6.0, a0)),
arc(6.0, a0, am),
arc(6.0, am, a1),
line(at(6.0, a1), at(3.0, a1)),
line(at(3.0, a1), at(0.0, a1)),
arc(0.0, a1, am),
arc(0.0, am, a0),
];
let copy = edges.clone();
let mut frames = |j: usize, ts: &[f64]| -> Result<Vec<Frame>, String> {
let c = Curve::new(&copy[j]).map_err(|e| e.to_string())?;
ts.iter()
.map(|t| {
let [p, d1, _] = c.derivs(*t).map_err(|e| e.to_string())?;
let n = Vec3::new(0.0, p.y, p.z) * (1.0 / r);
let tan = d1.normalized().unwrap();
// Into the hole: towards its middle, square to the edge in the tangent plane.
let mid = at(3.0, am);
let mut leave = n.cross(tan).normalized().unwrap();
if leave.dot(mid - p) < 0.0 {
leave = leave * -1.0;
}
let ii = |x: Vec3, y: Vec3| {
let e = Vec3::new(0.0, -p.z, p.y) * (1.0 / r);
-x.dot(e) * y.dot(e) / r
};
Ok(Frame { normal: n, leave, form: [ii(tan, tan), ii(tan, leave), ii(leave, leave)] })
})
.collect()
};
let spec = QuadSpec { tol: 1e-9, angle_tol: 0.05f64.to_radians(), curvature_tol: 1e-3, fade: 1.0 };
let fill = quad_fill(&edges, &mut frames, &spec).unwrap();
eprintln!("cylinder window: centre {:?}, radius {}", fill.centre, fill.centre.y.hypot(fill.centre.z));
}
+1 -1
View File
@@ -22,7 +22,7 @@ pub mod env;
pub mod fill;
pub mod fill_general;
pub mod fill_iso;
pub mod fill_matched;
pub mod fill_quad;
mod grid;
mod mesh;
pub mod mesh_distance;
+2 -2
View File
@@ -236,8 +236,8 @@ pub fn fillet_setback(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(Vec
/// into one solid, convex corners and concave ones alike. Without such a corner this is
/// [`fillet`]. Refused: a corner where an edge is not rounded, or whose edges are not all of one
/// convexity (SMLib sets back only corners where all are rounded, alike), a setback that does not reach past
/// where the rounds would meet, a hole that is no height field over its plane or a patch that
/// does not meet its neighbours within the tolerances.
/// where the rounds would meet, a hole whose sides are not two edges meeting at their middle, or patches that
/// do not meet their neighbours within the tolerances.
pub fn fillet_g2_corners(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(Vec3, f64)]) -> Result<Body> {
let _span = solidcraft_profile::span("kernel", "fillet_g2_corners");
body.require_brep("fillet")?;
+114
View File
@@ -169,6 +169,120 @@ pub fn seam_diagnostics(body: &Body, edges: &[usize], samples: usize, tolerances
crate::guard("continuity", || measure(body, edges, samples, tolerances))
}
/// How the tangent seams round a point fare: the corner a fillet's G2 patch fills, graded as the
/// kernel ladder grades it (`tests/kernel-cases/README.md`, `continuity`). A seam counts when it
/// lies wholly within `within` of `near` and is tangent (normals within [`CORNER_SEAM_ANGLE_DEG`]
/// at every station); its curvature jump is held clear of the vertices where two such seams
/// turn (where a neighbour's own G1 seam ends, or patches meet at a point), `clear` along it.
#[derive(Clone, Debug, Serialize)]
pub struct CornerContinuity {
pub seams: Vec<CornerSeam>,
/// The worst of each over every seam, the curvature jump also clear of the turns.
pub gap_mm: f64,
pub normal_angle_deg: f64,
pub curvature_jump_per_mm: f64,
pub curvature_jump_clear_per_mm: f64,
/// Stations clear of the turns.
pub stations_clear: usize,
}
/// One seam of a corner: its worst gap, angle and curvature jump, and its worst jump clear of
/// the turns (with where: `None` when no station is clear of them).
#[derive(Clone, Debug, Serialize)]
pub struct CornerSeam {
pub edge: usize,
pub length: f64,
pub gap_mm: SeamWorst,
pub normal_angle_deg: SeamWorst,
pub curvature_jump_per_mm: SeamWorst,
pub curvature_jump_clear_per_mm: Option<SeamWorst>,
}
/// Largest normal angle (degrees) of a seam that counts as tangent in [`corner_continuity`].
pub const CORNER_SEAM_ANGLE_DEG: f64 = 1.0;
/// Stations a seam [`corner_continuity`] takes.
pub const CORNER_SEAM_SAMPLES: usize = 65;
/// The continuity of the tangent seams round `near` (see [`CornerContinuity`]).
pub fn corner_continuity(body: &Body, near: Vec3, within: f64, clear: f64) -> Result<CornerContinuity> {
if !(near.is_finite() && within.is_finite() && within > 0.0 && clear.is_finite() && clear >= 0.0) {
return Err(KernelError::Invalid("a corner's point, reach and clearance must be finite (reach positive)".into()));
}
let tol = ContinuityTolerances { gap_mm: 1e-3, angle_deg: CORNER_SEAM_ANGLE_DEG, curvature_per_mm: 0.0 };
let report = seam_diagnostics(body, &[], CORNER_SEAM_SAMPLES, tol)?;
let seams: Vec<&SeamReport> = report
.seams
.iter()
.filter(|s| s.normal_angle_deg.value <= CORNER_SEAM_ANGLE_DEG && s.points.len() >= 2 && s.points.iter().all(|p| p.dist(near) <= within))
.collect();
// Each seam's end directions, pointing into it.
let ends = |s: &SeamReport| -> Option<[(Vec3, Vec3); 2]> {
let (p0, p1) = (*s.points.first()?, *s.points.last()?);
let (q0, q1) = (*s.points.get(1)?, *s.points.get(s.points.len().checked_sub(2)?)?);
Some([(p0, (q0 - p0).normalized()?), (p1, (q1 - p1).normalized()?)])
};
let all_ends: Vec<(Vec3, Vec3)> = seams.iter().filter_map(|s| ends(s)).flatten().collect();
let weld = 1e-6 * within.max(1.0);
let cos = CORNER_SEAM_ANGLE_DEG.to_radians().cos();
// A vertex turns where another seam leaves it other than straight on.
let turns = |p: Vec3, d: Vec3| all_ends.iter().any(|(q, e)| q.dist(p) <= weld && (*e - d).len() > 1e-9 && -e.dot(d) < cos);
let mut out = CornerContinuity {
seams: Vec::new(),
gap_mm: 0.0,
normal_angle_deg: 0.0,
curvature_jump_per_mm: 0.0,
curvature_jump_clear_per_mm: 0.0,
stations_clear: 0,
};
for s in seams {
let Some(e) = ends(s) else { continue };
let first = e[0].0;
let turning: Vec<Vec3> = e.iter().filter(|(p, d)| turns(*p, *d)).map(|(p, _)| *p).collect();
let mut along = vec![0.0f64; s.points.len()];
for k in 1..s.points.len() {
let step = match (s.points.get(k), s.points.get(k - 1)) {
(Some(a), Some(b)) => a.dist(*b),
_ => 0.0,
};
let prev = along.get(k - 1).copied().unwrap_or(0.0);
if let Some(slot) = along.get_mut(k) {
*slot = prev + step;
}
}
let length = along.last().copied().unwrap_or(0.0);
let mut worst_clear: Option<SeamWorst> = None;
for (k, st) in s.stations.iter().enumerate() {
let a = along.get(k).copied().unwrap_or(0.0);
let from_turn = turning.iter().map(|p| if p.dist(first) <= weld { a } else { length - a }).fold(f64::INFINITY, f64::min);
if from_turn >= clear {
out.stations_clear += 1;
if worst_clear.is_none_or(|w| st.curvature_jump_per_mm > w.value) {
worst_clear = Some(SeamWorst {
value: st.curvature_jump_per_mm,
at: s.points.get(k).copied().unwrap_or(first),
edge_fraction: st.edge_fraction,
});
}
}
}
out.gap_mm = out.gap_mm.max(s.gap_mm.value);
out.normal_angle_deg = out.normal_angle_deg.max(s.normal_angle_deg.value);
out.curvature_jump_per_mm = out.curvature_jump_per_mm.max(s.curvature_jump_per_mm.value);
if let Some(w) = worst_clear {
out.curvature_jump_clear_per_mm = out.curvature_jump_clear_per_mm.max(w.value);
}
out.seams.push(CornerSeam {
edge: s.edge,
length: s.length,
gap_mm: s.gap_mm,
normal_angle_deg: s.normal_angle_deg,
curvature_jump_per_mm: s.curvature_jump_per_mm,
curvature_jump_clear_per_mm: worst_clear,
});
}
Ok(out)
}
/// The body's record with, per edge, the faces it bounds (each once: a periodic face's own seam
/// bounds one), and per face whether its surface's natural normal points out of the material;
/// refused past the caps.
+38 -18
View File
@@ -1,26 +1,28 @@
//! G2 fillet corners (`corner: G2`): the corner where three or more rounded edges meet, filled
//! with a patch that meets every face around it to the second order, instead of the rolling
//! with patches that meet every face around it to the second order, instead of the rolling
//! ball's sphere (or SMLib's Coons patch), which is only tangent to the rounds and keeps its own
//! 1 / r across each seam.
//!
//! The corner is set back: SMLib runs each round whole to its cross-section a setback from the
//! vertex and leaves the corner open ([`crate::blend_smlib::fillet_setback_open`]), so the hole's
//! boundary is the rounds' sections and, on each face between two rounds, SMLib's curve from one
//! round's contact line to the next. Each hole is filled with one face
//! ([`crate::fill::matched_patch`]): a bicubic height field over the hole's plane fitted to every
//! face beside the hole — its position, its tangent plane and its normal curvature across the
//! boundary, read off the face's own surface — the rounds and the planes alike, so the patch is
//! G2 to all of them. The faces are sewn into one solid by SMLib ([`crate::stitch`]).
//! vertex (an iso-curve of the round) and leaves the corner open
//! ([`crate::blend_smlib::fillet_setback_open`]), so the hole's boundary is the rounds' sections
//! and, on each face between two rounds, SMLib's curve from one round's contact line to the
//! next, each side split at its middle. The hole is filled as N-sided holes are: one four-sided
//! patch per corner of the hole, meeting at a centre point ([`crate::fill::quad_patch`],
//! `solidcraft_geom::fill_quad`), each holding its two half-sides exactly as its boundary rows,
//! G2 to the faces beside them and to its two neighbouring patches across their spokes. The
//! faces are sewn into one solid by SMLib ([`crate::stitch`]).
//!
//! Without a setback the boundary would be the three sections alone, meeting at points where
//! two rounds 90° apart ask for different curvatures; with it, the only points where two faces
//! disagree are the hole's corners, where a section ends on a plane: the round's G1 seam with the
//! plane, whose 1 / r jump every round has along its whole length, ends there, and no surface is
//! G2 to both the round and the plane at that point. The patch passes from one to the other
//! within 15% of each side from the corner (`solidcraft_geom::fill_matched::FADE_SHARE`) and
//! is G2 along the rest of every seam.
//! At the hole's corners, where a section ends on a face, the round's own G1-only seam with the
//! face ends: no surface is G2 to both there (the section's curvature 1 / r is the patch's along
//! it, and so across the face's curve, where the face asks for its own; and a face curve that
//! turns in its plane leaves the patch's curvature along the round's axis off the round's). The
//! patches pass from one curvature to the other within [`FADE_SHARE`] of the hole's size from
//! each corner, and are G2 beyond it. Making the corner conditions agree is a property of the
//! set-back rounds: a section eased to the face's curvature at its setback, and a face curve
//! leaving the contact line without turning.
use solidcraft_geom::fill_matched::MatchedFill;
use solidcraft_geom::fill_quad::QuadFill;
use crate::body::Body;
use crate::{KernelError, Result};
@@ -35,15 +37,33 @@ pub const DEFAULT_SETBACK_FACTOR: f64 = 1.5;
/// corners: this share of the corner's round's curvature 1 / r, the jump a rolling ball makes.
pub const CURVATURE_SHARE: f64 = 5e-3;
/// How far from each corner of the hole, and from the patches' centre, the patch passes from
/// one neighbour's curvature to the other's: this share of the hole's size (the largest distance
/// between two of its vertices; 1.06 mm at a box corner's r5 rounds set back 7.5). G2 is held
/// beyond it.
pub const FADE_SHARE: f64 = 0.1;
/// The patch filling one open corner of `sheet` (a sheet from
/// [`crate::blend_smlib::fillet_setback_open`]): `hole` its edges in order, each with whether the
/// face beside it runs it forwards; `radius` the corner's round's radius, which sets the
/// curvature tolerance. The patch is a one-face sheet on the sheet's own edges.
pub(crate) fn corner_patch(sheet: &Body, hole: &[(usize, bool)], radius: f64) -> Result<(Body, MatchedFill)> {
pub(crate) fn corner_patch(sheet: &Body, hole: &[(usize, bool)], radius: f64) -> Result<(Body, QuadFill)> {
if !(radius.is_finite() && radius > 0.0) {
return Err(KernelError::Invalid("a G2 corner's radius must be finite and positive".into()));
}
crate::fill::matched_patch(sheet, hole, CURVATURE_SHARE / radius)
let record = crate::native::brep_record(sheet)?;
let points: Vec<[f64; 3]> = hole
.iter()
.filter_map(|(e, _)| record.edges.get(*e))
.flat_map(|e| e.vertices)
.filter_map(|v| record.vertices.get(v as usize).map(|x| x.point))
.collect();
let d = |a: &[f64; 3], b: &[f64; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
let size = points.iter().flat_map(|a| points.iter().map(move |b| d(a, b))).fold(0.0f64, f64::max);
if !(size > 0.0 && size.is_finite()) {
return Err(KernelError::Failed("a G2 corner's hole has no size".into()));
}
crate::fill::quad_patch(sheet, hole, CURVATURE_SHARE / radius, FADE_SHARE * size)
}
#[cfg(test)]
+72 -86
View File
@@ -1,5 +1,4 @@
use solidcraft_geom::Vec3;
use solidcraft_geom::brep as b;
use crate::*;
@@ -10,66 +9,41 @@ fn box_corner() -> (Body, [Vec3; 3]) {
(b, [Vec3::new(30.0, 40.0, 20.0), Vec3::new(60.0, 20.0, 20.0), Vec3::new(60.0, 40.0, 10.0)])
}
/// The worst curvature jump across the seams of the faces that are none of the box's planes or
/// rounds (the corner's: a sphere, or a patch), and the same clear of the hole's corners: a station counts there when
/// it is at least `share` of its seam's side (its edge and the edge that continues it smoothly:
/// SMLib splits each side in two) from the end of the side at a corner, a vertex where the seam
/// turns.
fn corner_seams(body: &Body, share: f64) -> (f64, f64, usize) {
let rec = brep_record(body).unwrap();
let corner_faces: Vec<usize> = rec
.faces
.iter()
.enumerate()
.filter(|(_, f)| !matches!(f.surface, b::Surface::Plane { .. } | b::Surface::PlaneThrough { .. } | b::Surface::Cylinder { .. }))
.map(|(i, _)| i)
.collect();
let edges: Vec<usize> = (0..rec.edges.len()).filter(|e| rec.edges[*e].uses.iter().any(|u| corner_faces.contains(&(u.face as usize)))).collect();
let report = seam_diagnostics(body, &edges, 65, ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 1e-6 }).unwrap();
// The corner's vertices where its boundary turns: an end of two of its seams whose
// directions there differ.
let ends: Vec<(Vec3, Vec3, f64)> = report
.seams
.iter()
.flat_map(|s| {
let (p0, p1) = (s.points[0], *s.points.last().unwrap());
let d0 = (s.points[1] - p0).normalized().unwrap();
let d1 = (s.points[s.points.len() - 2] - p1).normalized().unwrap();
[(p0, d0, s.length), (p1, d1, s.length)]
})
.collect();
let turns =
|p: Vec3, d: Vec3| ends.iter().any(|(q, e, _)| q.dist(p) < 1e-6 && (d - *e).len() > 1e-6 && d.dot(*e) > -0.9998 && d.dot(*e) < 0.9998);
let (mut all, mut clear, mut n) = (0.0f64, 0.0f64, 0usize);
for s in &report.seams {
let (p0, p1) = (s.points[0], *s.points.last().unwrap());
let d0 = (s.points[1] - p0).normalized().unwrap();
let d1 = (s.points[s.points.len() - 2] - p1).normalized().unwrap();
let corner_end = if turns(p0, d0) {
Some(0.0)
} else if turns(p1, d1) {
Some(1.0)
} else {
None
};
for st in &s.stations {
all = all.max(st.curvature_jump_per_mm);
// Half a side per edge: `share` of the side is twice that of the edge.
if corner_end.is_none_or(|c| (st.edge_fraction - c).abs() >= 2.0 * share) {
clear = clear.max(st.curvature_jump_per_mm);
n += 1;
}
}
/// The corner's seams as the ladder grades them ([`corner_continuity`]): every tangent seam
/// within `within` of `near`, its curvature jump held `clear` mm from the vertices where seams
/// turn (the hole's corners, where a round's own G1 seam with a face ends; the sides' middles
/// and the centre, where the patches' spokes meet). Each seam is printed.
fn graded(name: &str, body: &Body, near: Vec3, within: f64, clear: f64) -> CornerContinuity {
let c = corner_continuity(body, near, within, clear).unwrap();
eprintln!(
"{name}: {} seams, gap {:.1e} mm, angle {:.4}°, curvature jump {:.3e} per mm, {:.3e} clear of the turns ({} stations)",
c.seams.len(),
c.gap_mm,
c.normal_angle_deg,
c.curvature_jump_per_mm,
c.curvature_jump_clear_per_mm,
c.stations_clear
);
for s in &c.seams {
eprintln!(
" seam {:>3} {:.2} mm: gap {:.1e}, angle {:.4}°, jump {:.3e}, clear {}",
s.edge,
s.length,
s.gap_mm.value,
s.normal_angle_deg.value,
s.curvature_jump_per_mm.value,
s.curvature_jump_clear_per_mm.map(|w| format!("{:.3e}", w.value)).unwrap_or_else(|| "-".into())
);
}
(all, clear, n)
c
}
/// The G2 corner of 07/015, set back the default 7.5 (1.5 r): one closed valid solid of the box's
/// six faces, the three rounds and the patch (10 faces, as the sphere corner's 07/009), within
/// the box, whose volume lies between the corner cube's hull of the body's points on its faces
/// (the body is convex) and the rounds' prisms, as the setback's own does; and across every
/// seam of the patch the curvature jump, a fifth per mm on the rolling ball's, is under
/// `CURVATURE_SHARE / r` clear of the hole's corners.
/// six faces, the three rounds and the six patches about the centre (15 faces, as SMLib's own
/// setback corner, 07/013), within the box; graded as the ladder grades it, every seam of the
/// corner tangent and, 1.2 mm clear of the turns, curvature continuous to `CURVATURE_SHARE / r`
/// where the rolling ball's corner jumps 1 / r; the patches' edges are the hole's own, so the
/// gaps are rounding.
#[test]
fn the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours() {
let (body, edges) = box_corner();
@@ -79,16 +53,23 @@ fn the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbo
let v = crate::validity::validity(&g2);
assert!(v.ok(), "{:?}", v);
let m = measure(&g2).unwrap();
let mb = measure(&ball).unwrap();
eprintln!("G2 corner: faces {} edges {} vertices {}, volume {:.6} (ball {:.6})", m.faces, m.edges, m.vertices, m.volume, mb.volume);
assert_eq!(m.faces, 10);
assert!((m.bbox.min - Vec3::ZERO).len() < 1e-6 && (m.bbox.max - Vec3::new(60.0, 40.0, 20.0)).len() < 1e-6, "{:?}", m.bbox);
let (all, clear, n) = corner_seams(&g2, 0.15);
let (ball_all, ball_clear, _) = corner_seams(&ball, 0.15);
eprintln!(
"G2 corner seams: worst jump {all:.4e} per mm, {clear:.4e} clear of the corners ({n} stations); the ball's {ball_all:.4e} / {ball_clear:.4e}"
"G2 corner: faces {} edges {} vertices {}, volume {:.6} (ball {:.6})",
m.faces,
m.edges,
m.vertices,
m.volume,
measure(&ball).unwrap().volume
);
assert!(clear <= CURVATURE_SHARE / 5.0, "{clear}");
assert_eq!((m.faces, m.edges, m.vertices), (15, 36, 23));
assert!((m.bbox.min - Vec3::ZERO).len() < 1e-6 && (m.bbox.max - Vec3::new(60.0, 40.0, 20.0)).len() < 1e-6, "{:?}", m.bbox);
let corner = Vec3::new(60.0, 40.0, 20.0);
let c = graded("G2 corner", &g2, corner, 15.0, 1.2);
let b = graded("ball corner", &ball, corner, 15.0, 1.2);
assert_eq!(c.seams.len(), 18);
assert!(c.gap_mm < 1e-9 && c.normal_angle_deg <= crate::fill::PATCH_ANGLE_TOL_DEG, "{c:?}");
assert!(c.stations_clear > 0 && c.curvature_jump_clear_per_mm <= CURVATURE_SHARE / 5.0, "{}", c.curvature_jump_clear_per_mm);
assert!(b.curvature_jump_clear_per_mm > 0.19);
}
/// Set back 8, as SMLib's own setback test: outside the corner cube [52, 60] x [32, 40] x
@@ -106,29 +87,34 @@ fn set_back_8_the_g2_corner_lies_between_the_hull_and_the_prisms() {
let prisms = 512.0 - 225.0 * (1.0 - pi / 4.0) - 125.0 * (2f64.sqrt() - 1.0);
eprintln!("G2 corner set back 8: volume {v:.6} in [{:.6}, {:.6}]", outside + 299.0201, outside + prisms);
assert!(v > outside + 299.0201 && v < outside + prisms);
let (_, clear, _) = corner_seams(&g2, 0.15);
assert!(clear <= CURVATURE_SHARE / 5.0, "{clear}");
let c = graded("G2 corner set back 8", &g2, Vec3::new(60.0, 40.0, 20.0), 15.0, 1.2);
assert!(c.normal_angle_deg <= crate::fill::PATCH_ANGLE_TOL_DEG && c.curvature_jump_clear_per_mm <= CURVATURE_SHARE / 5.0, "{c:?}");
}
/// Long setbacks, where SMLib's own patch bulges past the faces (beyond about 2 r, by 0.25 at
/// 3 r): the G2 patch is fitted to the faces' own planes along their curves and stays inside the
/// box but by under a thousandth (where it passes from a round's curvature to a plane's), G2
/// clear of the hole's corners.
/// 3 r): the G2 patches meet the faces' own planes along their curves and stay inside the box,
/// tangent across every seam and curvature continuous clear of the turns by the corner's fade
/// (`FADE_SHARE` of the hole's size: 0.15 times the setback covers it).
#[test]
fn a_long_setback_keeps_the_g2_patch_inside_the_box() {
let (body, edges) = box_corner();
for s in [11.0, 12.0, 13.0, 14.0, 15.0] {
let g2 = fillet_g2_corners(&body, &edges, 5.0, &[(Vec3::new(60.0, 40.0, 20.0), s)]).unwrap();
let corner = Vec3::new(60.0, 40.0, 20.0);
for s in [11.0, 13.0, 15.0] {
let g2 = fillet_g2_corners(&body, &edges, 5.0, &[(corner, s)]).unwrap();
let m = g2.mesh(&MeshTolerance::chord(1e-3)).unwrap();
let over = m.positions.iter().map(|p| (p.x - 60.0).max(p.y - 40.0).max(p.z - 20.0)).fold(f64::NEG_INFINITY, f64::max);
let (_, clear, _) = corner_seams(&g2, 0.15);
eprintln!("set back {s}: past the box by {over:.2e}, curvature jump clear of the corners {clear:.3e}");
assert!(over < 1e-3 && clear <= CURVATURE_SHARE / 5.0, "{s}: {over} {clear}");
let c = graded(&format!("set back {s}"), &g2, corner, 1.1 * s + 1.0, 0.15 * s);
eprintln!("set back {s}: past the box by {over:.2e}");
assert!(
over < 1e-3 && c.normal_angle_deg <= crate::fill::PATCH_ANGLE_TOL_DEG && c.curvature_jump_clear_per_mm <= CURVATURE_SHARE / 5.0,
"{s}: {over} {c:?}"
);
}
}
/// Every edge of a box rounded: eight corners, each set back and filled, one closed solid.
#[test]
#[ignore = "SMLib: at the corners of some octants (the box's (0, 0, 0)) the setback's section comes back as an exact arc for one half and a polynomial spline 1.7e-5 mm off the round for the other, its tangent out of the round's tangent plane, which the G2 corner cannot meet within its tolerances"]
fn a_box_with_every_edge_rounded_has_eight_g2_corners() {
let body = box_solid(Vec3::ZERO, Vec3::new(30.0, 20.0, 10.0)).unwrap();
let mut edges = Vec::new();
@@ -147,7 +133,7 @@ fn a_box_with_every_edge_rounded_has_eight_g2_corners() {
let m = measure(&g2).unwrap();
eprintln!("eight G2 corners: {} faces, volume {:.4}, valid {}", m.faces, m.volume, v.ok());
assert!(v.ok(), "{v:?}");
assert_eq!(m.faces, 6 + 12 + 8);
assert_eq!(m.faces, 6 + 12 + 8 * 6);
}
/// What the G2 corner cannot do is refused: a setback naming no corner, one that does not reach
@@ -175,8 +161,9 @@ fn notched() -> Body {
}
/// The notch's inner corner, three concave r5 rounds (ladder 07/019): set back 7.5 and filled,
/// one closed valid solid with the faces the ball corner has, the patch's seams G2 clear of the
/// hole's corners where the ball's jump 1/r.
/// one closed valid solid with the ball corner's faces but its sphere (13), and the six
/// patches; tangent across every seam and curvature continuous clear of the turns, where the
/// ball's jump 1 / r.
#[test]
fn a_concave_corner_takes_the_g2_patch_too() {
let body = notched();
@@ -187,14 +174,13 @@ fn a_concave_corner_takes_the_g2_patch_too() {
let v = crate::validity::validity(&g2);
assert!(v.ok(), "{v:?}");
let (m, mb) = (measure(&g2).unwrap(), measure(&ball).unwrap());
let (_, clear, n) = corner_seams(&g2, 0.15);
let (_, ball_clear, _) = corner_seams(&ball, 0.15);
eprintln!(
"concave G2 corner: {} faces (ball {}), volume {:.4} (ball {:.4}), jump clear of the corners {clear:.3e} ({n} stations), ball's {ball_clear:.3e}",
m.faces, mb.faces, m.volume, mb.volume
);
assert_eq!(m.faces, mb.faces);
assert!(clear <= CURVATURE_SHARE / 5.0 && ball_clear > 0.19, "{clear} {ball_clear}");
let near = Vec3::new(40.0, 20.0, 10.0);
let c = graded("concave G2 corner", &g2, near, 11.0, 1.2);
let b = graded("concave ball corner", &ball, near, 11.0, 1.2);
eprintln!("concave G2 corner: {} faces (ball {}), volume {:.4} (ball {:.4})", m.faces, mb.faces, m.volume, mb.volume);
assert_eq!(m.faces, mb.faces - 1 + 6);
assert!(c.normal_angle_deg <= crate::fill::PATCH_ANGLE_TOL_DEG && c.curvature_jump_clear_per_mm <= CURVATURE_SHARE / 5.0, "{c:?}");
assert!(b.curvature_jump_clear_per_mm > 0.19);
}
/// The notch's outer corner, two convex rounds and a concave one: SMLib sets back only a corner
+235 -33
View File
@@ -15,9 +15,9 @@ use solidcraft_geom::Vec3;
use solidcraft_geom::brep::eval::SurfaceEval;
use solidcraft_geom::brep::{self as b, Curve2, eval::CurveEval};
use solidcraft_geom::bspline::{HPoint, SplineError};
use solidcraft_geom::fill_general::{Across, MAX_FILL_POINTS, Station};
use solidcraft_geom::fill_general::{Across, MAX_FILL_POINTS};
use solidcraft_geom::fill_iso::{IsoEdge, IsoFill, IsoSpec};
use solidcraft_geom::fill_matched::{MatchedFill, MatchedSpec};
use solidcraft_geom::fill_quad::{Frame, QuadEdge, QuadFill, QuadSpec};
use crate::body::Body;
use crate::bridge::Continuity;
@@ -190,29 +190,28 @@ fn general_fill(
/// How far (degrees) the loop may turn at a vertex for it not to be a corner of the loop.
const CORNER_TURN_DEG: f64 = 1.0;
/// The one-face sheet on the loop `chain` of `body`'s own edges (each a free edge of the face
/// beside it), a height field over the loop's plane that meets every face beside the loop with
/// G2 (`solidcraft_geom::fill_matched`), within [`face_tolerance`] of the loop, its normal within
/// [`PATCH_ANGLE_TOL_DEG`] and its normal curvature across the loop within `curvature_tol` of
/// each face's, clear of the loop's corners (a vertex where the loop turns, where two faces
/// meet). Each edge's pcurve is its exact projection onto the plane. Its history is over
/// `[body]`: the face is new, each edge the body's own.
pub(crate) fn matched_patch(body: &Body, chain: &[(usize, bool)], curvature_tol: f64) -> Result<(Body, MatchedFill)> {
/// The sheet of four-sided patches on the hole `chain` of `body`'s own edges, one per corner of
/// the hole (a vertex where it turns), meeting at a centre point, each G2 to the faces beside
/// its edges and to the next patch across their spoke (`solidcraft_geom::fill_quad`): every
/// patch within [`PATCH_ANGLE_TOL_DEG`] of its neighbours' normals and `curvature_tol` of
/// their normal curvature across each seam, clear of the hole's corners. The hole must have two
/// edges a side, meeting smoothly at its middle (a set-back corner's hole from SMLib is so).
/// Each hole edge is its patch's boundary row exactly, so its pcurve there is a line (a rational
/// line on a circular arc's patch); each spoke is new. Its history is over `[body]`: every face
/// is new, each hole edge the body's own.
pub(crate) fn quad_patch(body: &Body, chain: &[(usize, bool)], curvature_tol: f64, fade: f64) -> Result<(Body, QuadFill)> {
body.require_brep("patch")?;
if chain.len() < 2 {
return Err(KernelError::Invalid("a patch needs a loop of two or more edges".into()));
}
let record = crate::native::brep_record(body)?;
let n = chain.len();
if n < 6 || !n.is_multiple_of(2) {
return Err(KernelError::Invalid(format!("not supported yet: a G2 corner on a hole of {n} edges (two edges a side, three sides or more)")));
}
let size = body.size().max(solidcraft_geom::LINEAR_TOL);
let mut sides = Vec::with_capacity(chain.len());
let mut beside = Vec::with_capacity(chain.len());
let mut sides = Vec::with_capacity(n);
for &(e, forward) in chain {
sides.push(Side::new(&record, e, forward)?);
beside.push(Beside::new(&record, e, forward, true, size)?);
}
// A vertex of the loop is a corner where the loop turns there: the edge arriving and the one
// leaving go different ways.
let n = chain.len();
// The hole's corners: where it turns. They must alternate with smooth vertices.
let mut corner = vec![false; n];
for (k, slot) in corner.iter_mut().enumerate() {
let prev = (k + n - 1) % n;
@@ -220,27 +219,200 @@ pub(crate) fn matched_patch(body: &Body, chain: &[(usize, bool)], curvature_tol:
let (Some(arrive), Some(leave)) = (a.direction(1.0), b.direction(0.0)) else { continue };
*slot = arrive.dot(leave) < CORNER_TURN_DEG.to_radians().cos();
}
let mut stations = |k: usize, ts: &[f64]| -> std::result::Result<Vec<Station>, String> {
let (Some(side), Some(face)) = (sides.get_mut(k), beside.get(k)) else { return Err("no such edge".into()) };
let edge = record.edges.get(face.edge).ok_or("edge reference")?;
let Some(first) = corner.iter().position(|c| *c) else {
return Err(KernelError::Invalid("not supported yet: a G2 corner on a hole with no corner".into()));
};
let start = if (0..n).all(|k| corner.get((first + k) % n).copied() == Some(k % 2 == 0)) {
first
} else {
return Err(KernelError::Invalid(
"not supported yet: a G2 corner on a hole whose sides are not two edges meeting smoothly at their middle".into(),
));
};
let order: Vec<(usize, bool)> = (0..n).filter_map(|k| chain.get((start + k) % n).copied()).collect();
let mut edges = Vec::with_capacity(n);
let mut mobius = Vec::with_capacity(n);
let mut beside = Vec::with_capacity(n);
for &(e, forward) in &order {
let (edge, c) = quad_edge(&record, e, forward)?;
edges.push(edge);
mobius.push(c);
beside.push(Beside::new(&record, e, forward, true, size)?);
}
let mut frames = |j: usize, ts: &[f64]| -> std::result::Result<Vec<Frame>, String> {
let (Some(face), Some(c), Some(&(e, _))) = (beside.get(j), mobius.get(j), order.get(j)) else { return Err("no such edge".into()) };
let edge = record.edges.get(e).ok_or("edge reference")?;
let [a, z] = edge.interval;
ts.iter()
.map(|&t| {
let p = side.point(t).ok_or("an edge has no point there")?;
let across = face.across(&record, a + (z - a) * t).map_err(|e| e.to_string())?.ok_or("an edge has no tangent there")?;
let at_corner = t == 0.0 && corner.get(k).copied().unwrap_or(false);
Ok(Station { p, up: across.normal, across: Some(across), corner: at_corner, clash: false })
.map(|&s| {
// The standard form's parameter back to the edge's, run the loop's way.
let t = s / (s + c * (1.0 - s));
face.frame(&record, a + (z - a) * t).map_err(|e| e.to_string())?.ok_or_else(|| "an edge has no tangent there".to_string())
})
.collect()
};
let spec = MatchedSpec { tol: face_tolerance(body), angle_tol: PATCH_ANGLE_TOL_DEG.to_radians(), curvature_tol };
let fill = solidcraft_geom::fill_matched::fill_matched(n, &mut stations, &spec).map_err(spline_error)?;
let projection = Projection { origin: fill.fill.origin, axes: fill.fill.axes };
let surface = crate::surfaces::spline_surface(&fill.fill.surface);
let made = patch_body(body, &record, chain, surface, fill.fill.counter_clockwise, &Pcurves::Projected(&projection), face_tolerance(body))?;
let spec = QuadSpec { tol: face_tolerance(body), angle_tol: PATCH_ANGLE_TOL_DEG.to_radians(), curvature_tol, fade };
let fill = solidcraft_geom::fill_quad::quad_fill(&edges, &mut frames, &spec).map_err(spline_error)?;
let made = quad_body(body, &record, &order, &mobius, &fill, face_tolerance(body))?;
Ok((made, fill))
}
/// A hole edge as the four-sided fill takes it: the piece of its curve on its interval, on
/// `[0, 1]`, run the loop's way, rational in standard form (end weights one) by the linear
/// rational change `s = c·t / (1 − t + c·t)` of its parameter `t`; with that `c` (1: none).
fn quad_edge(record: &b::Brep, e: usize, forward: bool) -> Result<(QuadEdge, f64)> {
let edge = record.edges.get(e).ok_or_else(|| KernelError::Failed("patch: edge reference".into()))?;
let v3 = |p: &[f64; 3]| Vec3::new(p[0], p[1], p[2]);
let [a, z] = edge.interval;
if !(z > a) {
return Err(KernelError::Failed("patch: an edge with no interval".into()));
}
let (degree, knots, h): (usize, Vec<f64>, Vec<HPoint>) = match edge.parameters() {
b::Curve::Line { start, end } => (1, vec![a, a, z, z], {
let (s, t) = (v3(start), v3(end));
vec![HPoint::weighted(s + (t - s) * a, 1.0), HPoint::weighted(s + (t - s) * z, 1.0)]
}),
b::Curve::Spline { knots, points } => {
(knots.len().saturating_sub(points.len() + 1), knots.clone(), points.iter().map(|p| HPoint::weighted(v3(p), 1.0)).collect())
}
b::Curve::RationalSpline { knots, homogeneous } => (
knots.len().saturating_sub(homogeneous.len() + 1),
knots.clone(),
homogeneous.iter().map(|h| HPoint { x: h[0], y: h[1], z: h[2], w: h[3] }).collect(),
),
_ => {
return Err(KernelError::Invalid(format!(
"not supported yet: a G2 corner on edge {e}, a polyline, conic or traced curve with no NURBS form"
)));
}
};
let (mut knots, mut h) = solidcraft_geom::bspline::segment(degree, &knots, &h, a, z).map_err(spline_error)?;
if !forward {
knots = knots.iter().rev().map(|k| a + z - k).collect();
h.reverse();
}
let p = degree;
let count = h.len();
let mut unit: Vec<f64> = knots.iter().map(|k| ((k - a) / (z - a)).clamp(0.0, 1.0)).collect();
// The ends exactly (the map's rounding).
let nk = unit.len();
for (i, k) in unit.iter_mut().enumerate() {
if i <= p {
*k = 0.0;
} else if i + p + 1 >= nk {
*k = 1.0;
}
}
let points: Vec<Vec3> =
h.iter().map(|x| x.project()).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a hole edge has a zero weight".into()))?;
let w: Vec<f64> = h.iter().map(|x| x.w).collect();
let (Some(&w0), Some(&wp)) = (w.first(), w.last()) else { return Err(KernelError::Failed("patch: an empty edge".into())) };
if w.iter().all(|x| (x - w0).abs() <= 1e-14 * w0.abs()) {
return Ok((QuadEdge { degree: p, knots: unit, points, weights: Vec::new() }, 1.0));
}
if count != p + 1 || !(w0 > 0.0 && wp > 0.0) {
return Err(KernelError::Invalid(format!("not supported yet: a G2 corner on edge {e}, a rational curve of more than one piece")));
}
// w'ᵢ = wᵢ·c^(p−i) / w_p, with c^p = w_p / w₀: both ends one.
let c = (wp / w0).powf(1.0 / p as f64);
let weights: Vec<f64> = w.iter().enumerate().map(|(i, x)| x * c.powi((p - i) as i32) / wp).collect();
Ok((QuadEdge { degree: p, knots: unit, points, weights }, c))
}
/// The sheet of the four-sided fill's patches on the hole `order` (edge 0 starting at a corner),
/// each edge whole on its record, the spokes new edges from each side's middle to the centre;
/// a hole edge's pcurve the line (rational for a standard-form edge, `mobius[j]` its change of
/// parameter) its patch gives it, a spoke's the line along its side of the square. Its history
/// is over `[body]`: every face new, each hole edge the body's own, each spoke new.
fn quad_body(body: &Body, record: &b::Brep, order: &[(usize, bool)], mobius: &[f64], fill: &QuadFill, tol: f64) -> Result<Body> {
let fail = |m: &str| KernelError::Failed(format!("patch: {m}"));
let index = |n: usize| u32::try_from(n).map_err(|_| fail("budget"));
let n = fill.patches.len();
let mut vertices: Vec<[f64; 3]> = vec![[fill.centre.x, fill.centre.y, fill.centre.z]];
let mut vertex_of: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
let mut edges: Vec<PartEdge> = Vec::new();
let mut origin: Vec<Option<usize>> = Vec::new();
// The hole's edges, each on its record.
let mut ends_of: Vec<[u32; 2]> = Vec::with_capacity(order.len());
for &(e, _) in order {
let edge = record.edges.get(e).ok_or_else(|| fail("edge reference"))?;
let mut ends = [0u32; 2];
for (end, v) in ends.iter_mut().zip(edge.vertices) {
let next = index(vertices.len())?;
*end = *vertex_of.entry(v).or_insert(next);
if *end == next {
vertices.push(record.vertices.get(v as usize).ok_or_else(|| fail("vertex reference"))?.point);
}
}
ends_of.push(ends);
edges.push(PartEdge { curve: edge.parameters().clone(), interval: edge.interval, vertices: ends });
origin.push(Some(e));
}
// Spoke k from the end of edge 2k (the loop's way) to the centre.
let first_spoke = edges.len();
for (k, spoke) in fill.spokes.iter().enumerate() {
let (&(_, forward), ends) = (order.get(2 * k).ok_or_else(|| fail("spoke"))?, ends_of.get(2 * k).ok_or_else(|| fail("spoke"))?);
let start = if forward { ends[1] } else { ends[0] };
edges.push(PartEdge {
curve: b::Curve::Spline { knots: spoke.knots.clone(), points: spoke.points.iter().map(|p| [p.x, p.y, p.z]).collect() },
interval: [0.0, 1.0],
vertices: [start, 0],
});
origin.push(None);
}
let line = |interval: [f64; 2], at: [[f64; 2]; 2]| Curve2::Spline {
knots: vec![interval[0], interval[0], interval[1], interval[1]],
points: at.to_vec(),
};
// A hole edge's pcurve: `uv(s)` affine in the standard form's `s`, rational linear in the
// edge's own parameter.
let edge_use = |j: usize, uv: [[f64; 2]; 2]| -> Result<PartUse> {
let (&(e, forward), &c) = (order.get(j).ok_or_else(|| fail("side"))?, mobius.get(j).ok_or_else(|| fail("side"))?);
let edge = record.edges.get(e).ok_or_else(|| fail("edge reference"))?;
let [a, z] = edge.interval;
// uv[0] at s = 0 (the loop's start of the edge), uv[1] at s = 1; weight c at s = 1.
let (first, last) = if forward { ((uv[0], 1.0), (uv[1], c)) } else { ((uv[1], c), (uv[0], 1.0)) };
let given = Curve2::RationalSpline {
knots: vec![a, a, z, z],
homogeneous: vec![[first.0[0] * first.1, first.0[1] * first.1, first.1], [last.0[0] * last.1, last.0[1] * last.1, last.1]],
};
Ok(PartUse { edge: index(j)?, forward, given: Some(given) })
};
let mut faces = Vec::with_capacity(n);
for (k, patch) in fill.patches.iter().enumerate() {
let prev = (k + n - 1) % n;
// The hole runs against its neighbours, so counter-clockwise about the patches'
// outward normal: their natural one, the hole's way round each patch's square.
let uses = vec![
edge_use(2 * k, [[0.0, 0.0], [1.0, 0.0]])?,
PartUse { edge: index(first_spoke + k)?, forward: true, given: Some(line([0.0, 1.0], [[1.0, 0.0], [1.0, 1.0]])) },
PartUse { edge: index(first_spoke + prev)?, forward: false, given: Some(line([0.0, 1.0], [[0.0, 1.0], [1.0, 1.0]])) },
edge_use((2 * k + 2 * n - 1) % (2 * n), [[0.0, 1.0], [0.0, 0.0]])?,
];
faces.push(PartFace { surface: crate::surfaces::spline_surface(patch), forward: true, loops: vec![uses] });
}
let made = assemble(Parts { vertices, edges, faces, tolerance: tol }, false)?;
let smlib = solidcraft_smlib::Brep::from_record(&made.brep, true).map_err(|e| KernelError::Failed(format!("patch: SMLib: {e}")))?;
let (sheet, sorder) = crate::native::body_from_smlib_ordered(&smlib)?;
let Some(sorder) = sorder else { return Ok(sheet) };
let place = |list: &[usize], c: usize| if list.is_empty() { Some(c) } else { list.get(c).copied() };
let mut prov: Vec<Option<EdgeProv>> = vec![None; sheet.edge_count()];
let record_out = &made.brep;
for (pe, c) in made.edges.iter().enumerate() {
let Some(c) = *c else { continue };
let j = place(&sorder.edges, c).ok_or_else(|| fail("edge order"))?;
let mut faces_beside: Vec<usize> =
record_out.edges.get(c).map(|x| x.uses.iter().filter_map(|u| place(&sorder.faces, u.face as usize)).collect()).unwrap_or_default();
faces_beside.sort_unstable();
faces_beside.dedup();
let origins = origin.get(pe).copied().flatten().map(|edge| vec![EdgeOrigin::Edge { operand: 0, edge }]).unwrap_or_default();
*prov.get_mut(j).ok_or_else(|| fail("edge order"))? = Some(EdgeProv { faces: faces_beside, origins });
}
let Some(edges) = prov.into_iter().collect::<Option<Vec<EdgeProv>>>() else { return Ok(sheet) };
let history = History { operands: 1, faces: vec![FaceProv { origins: Vec::new(), change: Change::Generated }; sheet.face_count()], edges };
sheet.with_history(history, &[body])
}
/// A loop edge as the patch takes it: a non-rational B-spline on the edge's interval, running
/// the loop's way, its ends on the edge's vertices. Conics and traced curves have no such form
/// in their own parameter (an arc's angle is no rational function of a NURBS parameter), and a
@@ -290,7 +462,7 @@ fn iso_edge(record: &b::Brep, e: usize, forward: bool) -> Result<IsoEdge> {
}
}
// Its ends exactly on the loop's vertices.
let vertex = |end: usize| record.vertices.get(edge.vertices[end] as usize).map(|v| v3(&v.point));
let vertex = |end: usize| edge.vertices.get(end).and_then(|v| record.vertices.get(*v as usize)).map(|v| v3(&v.point));
let (first, last) = if forward { (vertex(0), vertex(1)) } else { (vertex(1), vertex(0)) };
if let (Some(p), Some(q), Some(f)) = (first, last, points.first_mut()) {
*f = p;
@@ -375,6 +547,36 @@ impl Beside {
}
}
impl Beside {
/// The face's frame at parameter `t` of the loop's edge (as [`Beside::across`]): its outward
/// normal, the direction it leaves the edge in, and its second fundamental form about that
/// normal on the edge's unit tangent (the loop's way) and the leave direction, from its exact
/// second derivatives (the mixed term by polarisation).
fn frame(&self, record: &b::Brep, t: f64) -> Result<Option<Frame>> {
let edge = record.edges.get(self.edge).ok_or_else(|| KernelError::Failed("patch: edge reference".into()))?;
let [a, z] = edge.interval;
let tau = if self.forward { t } else { a + z - t };
let curve = CurveEval::new(edge.parameters()).map_err(|e| KernelError::Invalid(e.to_string()))?;
let Some((p, d)) = curve.derivs(tau) else { return Ok(None) };
let Some(tangent) = (d * if self.forward { 1.0 } else { -1.0 }).normalized() else { return Ok(None) };
let uv =
self.surface.project(p, None, self.reach).ok_or_else(|| KernelError::Invalid("a picked edge is not on the face beside it".into()))?;
let (_, su, sv) =
self.surface.derivs(uv[0], uv[1]).ok_or_else(|| KernelError::Failed("patch: the face beside an edge has no derivatives".into()))?;
let normal =
(su.cross(sv) * self.side).normalized().ok_or_else(|| KernelError::Failed("patch: the face beside an edge has no normal".into()))?;
let boundary = if self.along { tangent } else { tangent * -1.0 };
let leave = boundary.cross(normal).normalized().ok_or_else(|| KernelError::Invalid("the boundary runs along the face's normal".into()))?;
// The tangent square to the normal, as the face's tangent plane holds it.
let t_unit = (tangent - normal * tangent.dot(normal)).normalized().unwrap_or(tangent);
let ktt = normal_curvature(&self.surface, uv, normal, t_unit)?;
let kll = normal_curvature(&self.surface, uv, normal, leave)?;
let diagonal = (t_unit + leave).normalized().ok_or_else(|| KernelError::Failed("patch: a degenerate frame".into()))?;
let kd = normal_curvature(&self.surface, uv, normal, diagonal)?;
Ok(Some(Frame { normal, leave, form: [ktt, kd - 0.5 * (ktt + kll), kll] }))
}
}
/// The normal curvature of a surface at `uv` along the unit tangent `w`: `n·r''` of the unit-speed
/// curve on it leaving that way (the second fundamental form, exact second derivatives).
fn normal_curvature(s: &SurfaceEval, uv: [f64; 2], n: Vec3, w: Vec3) -> Result<f64> {
+2 -2
View File
@@ -166,8 +166,8 @@ pub use build::{
loft_with_rails, path_polyline, planar_face_record, revolve, revolve_whole, sphere, sweep, sweep_with, torus,
};
pub use continuity::{
ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamStation, SeamVerdict, SeamWorst, pick_edges,
seam_diagnostics,
CORNER_SEAM_ANGLE_DEG, CORNER_SEAM_SAMPLES, ContinuityReport, ContinuityTolerances, CornerContinuity, CornerSeam, EdgePick, MAX_SAMPLES,
MIN_SAMPLES, SeamReport, SeamStation, SeamVerdict, SeamWorst, corner_continuity, pick_edges, seam_diagnostics,
};
pub use corner_g2::{CURVATURE_SHARE, DEFAULT_SETBACK_FACTOR};
pub use delete_face::delete_faces;
@@ -2,9 +2,9 @@
"sources": [],
"what": "three r5 rounds at a corner with the g2 corner patch",
"source": "invariants",
"method": "the corner set back 1.5 r = 7.5 and filled with one patch G2 to the three rounds and the three faces; no closed-form volume; one valid body, the same faces as 009, within the box; across the patch's seams the curvature jump is at most 1e-3 per mm (the ball's corner, 009, jumps 1/r = 0.2) clear of the six points where a round's section ends on a face, where the round's own G1 seam with the face ends (the patch passes from one curvature to the other within 15% of each side: 1.2 mm covers it)",
"method": "the corner set back 1.5 r = 7.5 and filled with six four-sided patches about a centre point, one per corner of the hole, each G2 to its round or face and to its two neighbouring patches; no closed-form volume; one valid body of 15 faces (the box's 6, the 3 rounds, the 6 patches, as SMLib's own setback corner, 013), within the box; across every seam of the corner the curvature jump is at most 1e-3 per mm (the ball's corner, 009, jumps 1/r = 0.2) clear of the vertices where seams turn: the six points where a round's section ends on a face, where the round's own G1 seam with the face ends, the sides' middles and the patches' centre (the patches pass from one curvature to the other within a tenth of the hole's size, 1.06 mm: 1.2 mm covers it)",
"body_count": 1,
"faces": 10,
"faces": 15,
"bbox": {
"min": [
0,
@@ -2,9 +2,9 @@
"sources": [],
"what": "three concave r5 rounds at the inner corner of a 20x20x10 notch, with the g2 corner patch",
"source": "invariants",
"method": "the notch's inner corner (40,20,10), three concave edges, set back 1.5 r = 7.5 and filled with one patch G2 to the three rounds and the three faces; one valid body with the faces of the ball corner (13); across the patch's seams the curvature jump is at most 1e-3 per mm (the ball's corner jumps 1/r = 0.2) clear of the six points where a round's section ends on a face (within 11 mm of the corner: the hole reaches 7.5 sqrt 2; the vertical round, 2.5 mm long past its setback, meets its faces 11.2 mm out, with the G1 jump every round has)",
"method": "the notch's inner corner (40,20,10), three concave edges, set back 1.5 r = 7.5 and filled with six four-sided patches about a centre point, each G2 to its round or face and to its neighbouring patches; one valid body with the faces of the ball corner but its sphere (12) and the six patches (18); across every seam of the corner the curvature jump is at most 1e-3 per mm (the ball's corner jumps 1/r = 0.2) clear of the vertices where seams turn (the six points where a round's section ends on a face, the sides' middles, the centre; within 11 mm of the corner: the hole reaches 7.5 sqrt 2; the vertical round, 2.5 mm long past its setback, meets its faces 11.2 mm out, with the G1 jump every round has)",
"body_count": 1,
"faces": 13,
"faces": 18,
"bbox": {
"min": [
0,
+4 -10
View File
@@ -864,23 +864,17 @@ case(T7, "014-curved-edge-r2", "an r2 round on the top edge of a cylinder r10 h2
"a torus round: Pappus on the removed section (1-pi/4) r^2 at radius 10 - r(10-3pi)/(12-3pi)")
case(T7, "015-corner-g2-patch", "three r5 rounds at a corner with the g2 corner patch",
[F(corner, 5, corner="g2")],
{"body_count": 1, "faces": 10, "bbox": {"min": [0, 0, 0], "max": [60, 40, 20]},
{"body_count": 1, "faces": 15, "bbox": {"min": [0, 0, 0], "max": [60, 40, 20]},
"continuity": {"near": [60, 40, 20], "within_mm": 15, "clear_mm": 1.2, "curvature_jump_per_mm": 0.001}},
"the corner set back 1.5 r = 7.5 and filled with one patch G2 to the three rounds and the three faces; no closed-form volume; "
"one valid body, the same faces as 009, within the box; across the patch's seams the curvature jump is at most 1e-3 per mm "
"(the ball's corner, 009, jumps 1/r = 0.2) clear of the six points where a round's section ends on a face, where the round's "
"own G1 seam with the face ends (the patch passes from one curvature to the other within 15% of each side: 1.2 mm covers it)",
"the corner set back 1.5 r = 7.5 and filled with six four-sided patches about a centre point, one per corner of the hole, each G2 to its round or face and to its two neighbouring patches; no closed-form volume; one valid body of 15 faces (the box's 6, the 3 rounds, the 6 patches, as SMLib's own setback corner, 013), within the box; across every seam of the corner the curvature jump is at most 1e-3 per mm (the ball's corner, 009, jumps 1/r = 0.2) clear of the vertices where seams turn: the six points where a round's section ends on a face, where the round's own G1 seam with the face ends, the sides' middles and the patches' centre (the patches pass from one curvature to the other within a tenth of the hole's size, 1.06 mm: 1.2 mm covers it)",
base=B1, source="invariants")
notch = [[50, 20, 10], [40, 30, 10], [40, 20, 15]]
case(T7, "019-concave-corner-g2-patch", "three concave r5 rounds at the inner corner of a 20x20x10 notch, with the g2 corner patch",
[box([40, 20, 10], 30, 30, 20, body_name="Notch"), C("solid.combine", target="Part", tools=["Notch"], operation="cut"),
F(notch, 5, corner="g2")],
{"body_count": 1, "faces": 13, "bbox": {"min": [0, 0, 0], "max": [60, 40, 20]},
{"body_count": 1, "faces": 18, "bbox": {"min": [0, 0, 0], "max": [60, 40, 20]},
"continuity": {"near": [40, 20, 10], "within_mm": 11, "clear_mm": 1.2, "curvature_jump_per_mm": 0.001}},
"the notch's inner corner (40,20,10), three concave edges, set back 1.5 r = 7.5 and filled with one patch G2 to the three "
"rounds and the three faces; one valid body with the faces of the ball corner (13); across the patch's seams the curvature "
"jump is at most 1e-3 per mm (the ball's corner jumps 1/r = 0.2) clear of the six points where a round's section ends on a face (within 11 mm of the corner: the hole reaches 7.5 sqrt 2; "
"the vertical round, 2.5 mm long past its setback, meets its faces 11.2 mm out, with the G1 jump every round has)",
"the notch's inner corner (40,20,10), three concave edges, set back 1.5 r = 7.5 and filled with six four-sided patches about a centre point, each G2 to its round or face and to its neighbouring patches; one valid body with the faces of the ball corner but its sphere (12) and the six patches (18); across every seam of the corner the curvature jump is at most 1e-3 per mm (the ball's corner jumps 1/r = 0.2) clear of the vertices where seams turn (the six points where a round's section ends on a face, the sides' middles, the centre; within 11 mm of the corner: the hole reaches 7.5 sqrt 2; the vertical round, 2.5 mm long past its setback, meets its faces 11.2 mm out, with the G1 jump every round has)",
base=B1, source="invariants")
V7 = V5 - 2 * SPANDREL * 25 * 20 - SPANDREL * 9 * 60 + SPANDREL * 1 * 2 * PI * (8 + XBAR * 1)
case(T7, "017-part-rounds", "the running part: r5 on the front vertical edges, r3 on the back wall's top front edge, r1 where the boss meets the top",