mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9c7162dc3 | |||
| f7351f4a53 | |||
| d493c4d639 |
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"}));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 = ©[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(©[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(©[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(©[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(©[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(©[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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user