Thanks @jyviko.
This commit is contained in:
1 parent
77bdcc47e9
commit
fac3f96bdd
24 files changed
+2688
-77
No files matched your search
+96
-26
@@ -191,44 +191,115 @@ pub fn frames(face: &mt::Face, edges: &[mt::Edge], leave: Vec3, count: usize) ->
|
||||
let leave =
|
||||
leave.normalized().filter(|l| l.is_finite()).ok_or_else(|| KernelError::Invalid("the leave direction must be a finite vector".into()))?;
|
||||
let (pts, length) = chain_samples(edges, count)?;
|
||||
let surf = face.surface();
|
||||
let (Some(range_u), Some(range_v)) = surf.try_range_tuple() else {
|
||||
return Err(KernelError::Failed("bridge: the support surface is unbounded".into()));
|
||||
let support = Support::of(face, length)?;
|
||||
let mut walk = Walk::new(length);
|
||||
let along = |n: Vec3| -> Result<Vec3> {
|
||||
(leave - n * leave.dot(n))
|
||||
.normalized()
|
||||
.ok_or_else(|| KernelError::Invalid("the leave direction is normal to the support surface: it has no tangent plane there".to_string()))
|
||||
};
|
||||
// A face's own edges may sit a fit tolerance off its surface (a cylinder's wall as a NURBS
|
||||
// next to its exact ring), so the projection is only asked to be close, not exact.
|
||||
let tol = (length * 1e-4).max(1e-6);
|
||||
// Finite-difference step: a thousandth of the chain, so curvature over the chain is read
|
||||
// locally but round-off stays far below the geometry.
|
||||
let mut step = (length * 1e-3).clamp(1e-6, 0.5);
|
||||
let mut out = Vec::with_capacity(pts.len());
|
||||
// The station before this one, as the hint for the projection: it keeps the search on the
|
||||
// same side of a periodic or split surface.
|
||||
let mut hint: Option<(f64, f64)> = None;
|
||||
for p in &pts {
|
||||
let project = |hint: Option<(f64, f64)>| surf.search_nearest_parameter(p3(*p), hint, 100);
|
||||
let close = |uv: (f64, f64)| from_p3(surf.subs(uv.0, uv.1)).dist(*p) <= tol;
|
||||
out.push(support.station(*p, &along, &mut walk, true)?.0);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The frames of `face` at stations on its own boundary, each leaving the face straight across
|
||||
/// that boundary: `stations` are points on the boundary with the boundary's direction there *as
|
||||
/// the face's own boundary runs* (its wires as [`mt::Face::boundaries`] gives them), and the leave
|
||||
/// direction is that direction × the face's outward normal — in the face's tangent plane, at
|
||||
/// right angles to the boundary, away from the face. `length` is the size of the boundary the
|
||||
/// stations are on (it sets how closely a station must lie on the surface and the step the shape
|
||||
/// is read with). Each frame comes with the face's unit normal there (as the face is oriented).
|
||||
/// With `shape` false only the leave direction and the normal are read (`bend` and
|
||||
/// `bend_change` are zero), which needs no room on the surface beside the station.
|
||||
pub fn frames_across(face: &mt::Face, stations: &[(Vec3, Vec3)], length: f64, shape: bool) -> Result<Vec<(Frame, Vec3)>> {
|
||||
if stations.len() > 1 << 16 || !(length.is_finite() && length > 0.0) {
|
||||
return Err(KernelError::Invalid("frames across a boundary need at most 65536 stations on a boundary of finite size".into()));
|
||||
}
|
||||
let support = Support::of(face, length)?;
|
||||
let outward = if face.orientation() { 1.0 } else { -1.0 };
|
||||
let mut walk = Walk::new(length);
|
||||
let mut out = Vec::with_capacity(stations.len());
|
||||
for (p, tangent) in stations {
|
||||
if !p.is_finite() || !tangent.is_finite() {
|
||||
return Err(KernelError::Invalid("a boundary station must be finite".into()));
|
||||
}
|
||||
let away = |n: Vec3| -> Result<Vec3> {
|
||||
tangent.cross(n * outward).normalized().ok_or_else(|| KernelError::Invalid("the boundary runs along the face's normal".to_string()))
|
||||
};
|
||||
let (frame, normal) = support.station(*p, &away, &mut walk, shape)?;
|
||||
out.push((frame, normal * outward));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A support surface read at stations: the surface, its parameter box, and how close a station
|
||||
/// must lie to it.
|
||||
struct Support {
|
||||
surf: mt::Surface,
|
||||
range_u: (f64, f64),
|
||||
range_v: (f64, f64),
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
/// What reading one station hands on to the next: the station's parameters (the hint for the
|
||||
/// next projection, which keeps the search on the same side of a periodic or split surface) and
|
||||
/// the finite-difference step that worked.
|
||||
struct Walk {
|
||||
hint: Option<(f64, f64)>,
|
||||
step: f64,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
fn new(length: f64) -> Walk {
|
||||
// Finite-difference step: a thousandth of the chain, so curvature over the chain is read
|
||||
// locally but round-off stays far below the geometry.
|
||||
Walk { hint: None, step: (length * 1e-3).clamp(1e-6, 0.5) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Support {
|
||||
fn of(face: &mt::Face, length: f64) -> Result<Support> {
|
||||
let surf = face.surface();
|
||||
let (Some(range_u), Some(range_v)) = surf.try_range_tuple() else {
|
||||
return Err(KernelError::Failed("bridge: the support surface is unbounded".into()));
|
||||
};
|
||||
// A face's own edges may sit a fit tolerance off its surface (a cylinder's wall as a NURBS
|
||||
// next to its exact ring), so the projection is only asked to be close, not exact.
|
||||
let tol = (length * 1e-4).max(1e-6);
|
||||
Ok(Support { surf, range_u, range_v, tol })
|
||||
}
|
||||
|
||||
/// The frame at `p`, leaving in `leave(n)` (`n` the surface's unit normal there), and that
|
||||
/// normal. Without `read_shape`, the frame's shape is left at zero.
|
||||
fn station(&self, p: Vec3, leave: &dyn Fn(Vec3) -> Result<Vec3>, walk: &mut Walk, read_shape: bool) -> Result<(Frame, Vec3)> {
|
||||
let (surf, range_u, range_v, tol) = (&self.surf, self.range_u, self.range_v, self.tol);
|
||||
let project = |hint: Option<(f64, f64)>| surf.search_nearest_parameter(p3(p), hint, 100);
|
||||
let close = |uv: (f64, f64)| from_p3(surf.subs(uv.0, uv.1)).dist(p) <= tol;
|
||||
// The station before this one is the hint where it lands close; a chain that wraps the
|
||||
// surface (or a periodic one) falls back to a free search.
|
||||
let (u, v) = match project(hint).filter(|uv| close(*uv)).or_else(|| project(None).filter(|uv| close(*uv))) {
|
||||
let (u, v) = match project(walk.hint).filter(|uv| close(*uv)).or_else(|| project(None).filter(|uv| close(*uv))) {
|
||||
Some(uv) => uv,
|
||||
None => return Err(KernelError::Invalid("a picked edge is not on the support surface".into())),
|
||||
};
|
||||
hint = Some((u, v));
|
||||
walk.hint = Some((u, v));
|
||||
let at = from_p3(surf.subs(u, v));
|
||||
if at.dist(*p) > tol {
|
||||
if at.dist(p) > tol {
|
||||
return Err(KernelError::Invalid("a picked edge is not on the support surface".into()));
|
||||
}
|
||||
let normal = surf.normal(u, v);
|
||||
let n = Vec3::new(normal.x, normal.y, normal.z)
|
||||
.normalized()
|
||||
.ok_or_else(|| KernelError::Failed("bridge: the support surface has no normal".into()))?;
|
||||
let w = (leave - n * leave.dot(n))
|
||||
.normalized()
|
||||
.ok_or_else(|| KernelError::Invalid("the leave direction is normal to the support surface: it has no tangent plane there".to_string()))?;
|
||||
let w = leave(n)?;
|
||||
if !read_shape {
|
||||
return Ok((Frame { p, leave: w, bend: Vec3::ZERO, bend_change: Vec3::ZERO }, n));
|
||||
}
|
||||
// Parameter-space direction that leaves in `w` at unit 3D speed: S_u·du + S_v·dv = w.
|
||||
let su = tangent(&surf, u, v, range_u, true);
|
||||
let sv = tangent(&surf, u, v, range_v, false);
|
||||
let su = tangent(surf, u, v, range_u, true);
|
||||
let sv = tangent(surf, u, v, range_v, false);
|
||||
let (a, b, c) = (su.dot(su), su.dot(sv), sv.dot(sv));
|
||||
let det = a * c - b * b;
|
||||
let scale = a.max(c).max(1e-30);
|
||||
@@ -260,7 +331,7 @@ pub fn frames(face: &mt::Face, edges: &[mt::Edge], leave: Vec3, count: usize) ->
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut h = step;
|
||||
let mut h = walk.step;
|
||||
let mut chosen = pick(h);
|
||||
for _ in 0..8 {
|
||||
if chosen.is_some() {
|
||||
@@ -299,10 +370,9 @@ pub fn frames(face: &mt::Face, edges: &[mt::Edge], leave: Vec3, count: usize) ->
|
||||
// against the leave direction: the curvature vector is the same either way, the change of
|
||||
// curvature turns round with the direction of travel.
|
||||
let bend_change = if t.dot(w) < 0.0 { -bend_change } else { bend_change };
|
||||
out.push(Frame { p: *p, leave: w, bend, bend_change });
|
||||
step = h;
|
||||
walk.step = h;
|
||||
Ok((Frame { p, leave: w, bend, bend_change }, n))
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A first derivative of the surface in one parameter, by central differences (one-sided where
|
||||
|
||||
+274
-12
@@ -1,16 +1,259 @@
|
||||
//! Filling a closed loop of a body's edges that is not planar with one face: a B-spline height
|
||||
//! field over the loop's best-fit plane (`solidcraft_geom::fill::fill_loop`), bounded by the
|
||||
//! body's own edges, whole. Loops that do not bound a height field over a plane are refused.
|
||||
//! Filling a closed loop of a body's edges that is not planar with one face, bounded by the
|
||||
//! body's own edges, whole: a B-spline height field over the loop's best-fit plane
|
||||
//! (`solidcraft_geom::fill::fill_loop`) when the patch only has to meet the edges (G0) and the
|
||||
//! loop bounds one; otherwise the general fill (`solidcraft_geom::fill_general`), developed from
|
||||
//! the loop itself, which also meets the faces beside the loop with G1 or G2 and passes through
|
||||
//! given points.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::bspline::SplineError;
|
||||
use solidcraft_geom::fill_general::{Across, FillSpec, MAX_FILL_POINTS, Station};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3};
|
||||
use crate::bridge::{Continuity, frames_across};
|
||||
use crate::plane_fit::EdgeSampler;
|
||||
use crate::record::{Rebuild, rebuilt_body};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// How far (degrees) a G1 or G2 patch's normal may turn from its neighbour's along the loop.
|
||||
pub const PATCH_ANGLE_TOL_DEG: f64 = 0.05;
|
||||
|
||||
/// A spline error as the patch reports it.
|
||||
fn spline_error(e: SplineError) -> KernelError {
|
||||
match e {
|
||||
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
|
||||
other => KernelError::Invalid(format!("cannot fill the loop: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a patch on a loop of edges must do besides filling it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PatchSpec {
|
||||
/// How every edge's neighbour is met (G0, G1 or G2).
|
||||
pub continuity: Continuity,
|
||||
/// Edges met otherwise: a point on the edge and its continuity.
|
||||
pub edges: Vec<(Vec3, Continuity)>,
|
||||
/// Points the patch passes through.
|
||||
pub points: Vec<Vec3>,
|
||||
/// How full the patch's middle is, `crown` times the loop's size (−0.9…3): the bump of
|
||||
/// [`patch_edges_crowned`], laid along the general fill's own normal there (to the side its
|
||||
/// G1/G2 neighbours' normals face) on control points clear of the loop, so the loop and the
|
||||
/// conditions across it do not move. A general fill needs a G1 or G2 edge to crown.
|
||||
pub crown: f64,
|
||||
}
|
||||
|
||||
impl Default for PatchSpec {
|
||||
fn default() -> PatchSpec {
|
||||
PatchSpec { continuity: Continuity::G0, edges: Vec::new(), points: Vec::new(), crown: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl PatchSpec {
|
||||
/// Whether this is the plain patch of [`patch_edges_crowned`]: G0 everywhere, no points.
|
||||
pub fn is_plain(&self) -> bool {
|
||||
self.continuity == Continuity::G0 && self.edges.iter().all(|(_, g)| *g == Continuity::G0) && self.points.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a general fill achieved (all zero for a planar or height-field patch, which meet their
|
||||
/// edges within [`face_tolerance`] and ask nothing else).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct PatchReport {
|
||||
/// Whether the general fill made the face.
|
||||
pub general: bool,
|
||||
/// The farthest loop station from the surface.
|
||||
pub gap: f64,
|
||||
/// The largest angle (degrees) between the patch's normal and a G1/G2 neighbour's, away from
|
||||
/// corners where the neighbours themselves disagree, and anywhere.
|
||||
pub normal_deviation_deg: f64,
|
||||
pub normal_deviation_all_deg: f64,
|
||||
/// The largest difference (1/mm) between the patch's normal curvature across a G2 edge and
|
||||
/// the neighbour's.
|
||||
pub curvature_mismatch: f64,
|
||||
/// The farthest point from the surface.
|
||||
pub point_gap: f64,
|
||||
}
|
||||
|
||||
/// [`crate::patch_edges`] meeting the faces beside the loop with the continuity `spec` asks for
|
||||
/// and passing through its points; a plain spec is exactly [`patch_edges_crowned`]. Its history
|
||||
/// is over `[body]`: the face is new, its edges the body's own.
|
||||
pub fn patch_edges_with(body: &Body, at: &[Vec3], spec: &PatchSpec) -> Result<Body> {
|
||||
patch_edges_report(body, at, spec).map(|(b, _)| b)
|
||||
}
|
||||
|
||||
/// [`patch_edges_with`] and what the fill achieved.
|
||||
pub fn patch_edges_report(body: &Body, at: &[Vec3], spec: &PatchSpec) -> Result<(Body, PatchReport)> {
|
||||
if at.is_empty() || at.len() > 10_000 {
|
||||
return Err(KernelError::Invalid("pick the edges of a closed loop".into()));
|
||||
}
|
||||
// A range test also throws out NaN.
|
||||
if !(solidcraft_geom::fill::MIN_CROWN..=solidcraft_geom::fill::MAX_CROWN).contains(&spec.crown) {
|
||||
return Err(KernelError::Invalid("a patch's crown must be between -0.9 and 3".into()));
|
||||
}
|
||||
let g3 = |g: Continuity| g == Continuity::G3;
|
||||
if g3(spec.continuity) || spec.edges.iter().any(|(_, g)| g3(*g)) {
|
||||
return Err(KernelError::Invalid("not supported yet: a patch meets its neighbours with g0, g1 or g2".into()));
|
||||
}
|
||||
if spec.edges.len() > 10_000 || spec.edges.iter().any(|(p, _)| !p.is_finite()) {
|
||||
return Err(KernelError::Invalid("pick at most 10000 edges, each by a finite point, for their continuity".into()));
|
||||
}
|
||||
if spec.points.len() > MAX_FILL_POINTS || spec.points.iter().any(|p| !p.is_finite()) {
|
||||
return Err(KernelError::Invalid(format!("a patch passes through at most {MAX_FILL_POINTS} finite points")));
|
||||
}
|
||||
if spec.is_plain() {
|
||||
return patch_edges_crowned(body, at, spec.crown).map(|b| (b, PatchReport::default()));
|
||||
}
|
||||
body.require_brep("patch")?;
|
||||
crate::guard("patch", || {
|
||||
let chain = crate::surfaces::picked_loop(body, at)?;
|
||||
let mut orders = vec![spec.continuity; chain.len()];
|
||||
for (p, g) in &spec.edges {
|
||||
let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?;
|
||||
let k = chain
|
||||
.iter()
|
||||
.position(|c| c.id() == e.id())
|
||||
.ok_or_else(|| KernelError::Invalid("a continuity pick is not on an edge of the loop".into()))?;
|
||||
if let Some(slot) = orders.get_mut(k) {
|
||||
*slot = *g;
|
||||
}
|
||||
}
|
||||
if spec.crown != 0.0 && orders.iter().all(|g| *g == Continuity::G0) {
|
||||
return Err(KernelError::Invalid("not supported yet: a crown on a patch through points with no g1 or g2 edge".into()));
|
||||
}
|
||||
general_fill(body, &chain, &orders, &spec.points, spec.crown)
|
||||
})
|
||||
}
|
||||
|
||||
/// One edge of the loop as the general fill samples it.
|
||||
struct Side {
|
||||
sampler: EdgeSampler,
|
||||
start: Vec3,
|
||||
/// The face beside the edge, and whether its boundary runs the edge the way the loop does.
|
||||
face: mt::Face,
|
||||
along: bool,
|
||||
order: Continuity,
|
||||
/// The loop has a corner at the edge's start, and the neighbours' tangent planes disagree
|
||||
/// there.
|
||||
corner: bool,
|
||||
clash: bool,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn point(&mut self, t: f64) -> Option<Vec3> {
|
||||
// The loop's corners are its vertices (an imported edge's curve may end a little off).
|
||||
if t <= 0.0 { Some(self.start) } else { self.sampler.at(t) }
|
||||
}
|
||||
|
||||
/// The loop's unit tangent at `t`, by a central difference (one-sided at the ends).
|
||||
fn tangent(&mut self, t: f64) -> Option<Vec3> {
|
||||
const STEP: f64 = 1e-4;
|
||||
let (a, b) = ((t - STEP).max(0.0), (t + STEP).min(1.0));
|
||||
let (p, q) = (self.sampler.at(a)?, self.sampler.at(b)?);
|
||||
(q - p).normalized()
|
||||
}
|
||||
|
||||
/// The face's frames at points of this edge with the loop's tangents there.
|
||||
fn frames(&self, stations: &[(Vec3, Vec3)], size: f64, shape: bool) -> Result<Vec<(crate::Frame, Vec3)>> {
|
||||
let along: Vec<(Vec3, Vec3)> = stations.iter().map(|(p, t)| (*p, if self.along { *t } else { *t * -1.0 })).collect();
|
||||
frames_across(&self.face, &along, size, shape)
|
||||
}
|
||||
}
|
||||
|
||||
/// The general fill of the closed loop `chain` of `body`'s edges, edge `k` meeting the face beside
|
||||
/// it with `orders[k]`, passing through `points`, its middle made fuller by `crown` (see
|
||||
/// [`PatchSpec::crown`]). Call it under [`crate::guard`].
|
||||
pub(crate) fn general_fill(body: &Body, chain: &[mt::Edge], orders: &[Continuity], points: &[Vec3], crown: f64) -> Result<(Body, PatchReport)> {
|
||||
let tol = face_tolerance(body);
|
||||
let size = body.size().max(solidcraft_geom::LINEAR_TOL);
|
||||
let mut sides: Vec<Side> = Vec::with_capacity(chain.len());
|
||||
for (e, order) in chain.iter().zip(orders) {
|
||||
let faces: Vec<&mt::Face> = body.solid.face_iter().filter(|f| f.edge_iter().any(|x| x.id() == e.id())).collect();
|
||||
let face = match faces.as_slice() {
|
||||
[f] => (*f).clone(),
|
||||
[f, ..] if *order == Continuity::G0 => (*f).clone(),
|
||||
[] => return Err(KernelError::Failed("patch: an edge of the loop bounds no face".into())),
|
||||
_ => return Err(KernelError::Invalid("an edge of the loop lies between two faces: g1 and g2 need the one face beside it".into())),
|
||||
};
|
||||
let along = face
|
||||
.boundaries()
|
||||
.iter()
|
||||
.flat_map(|w| w.edge_iter())
|
||||
.find(|x| x.id() == e.id())
|
||||
.map(|x| x.orientation() == e.orientation())
|
||||
.ok_or_else(|| KernelError::Failed("patch: the face beside an edge does not hold it".into()))?;
|
||||
let sampler = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid("an edge has no parameter range".into()))?;
|
||||
sides.push(Side { sampler, start: from_p3(e.front().point()), face, along, order: *order, corner: false, clash: false });
|
||||
}
|
||||
// The corners: where the loop's tangent turns, and where the faces beside it disagree.
|
||||
let n = sides.len();
|
||||
for k in 0..n {
|
||||
let prev = (k + n - 1) % n;
|
||||
let (Some(t_in), Some(t_out)) = (sides.get_mut(prev).and_then(|s| s.tangent(1.0)), sides.get_mut(k).and_then(|s| s.tangent(0.0))) else {
|
||||
return Err(KernelError::Invalid("an edge of the loop has no tangent".into()));
|
||||
};
|
||||
let Some(vertex) = sides.get(k).map(|s| s.start) else { continue };
|
||||
let corner = t_in.dot(t_out) < CORNER_DEG.to_radians().cos();
|
||||
let normal = |s: Option<&Side>, t: Vec3| -> Result<Vec3> {
|
||||
let s = s.ok_or_else(|| KernelError::Failed("patch: side".into()))?;
|
||||
let f = s.frames(&[(vertex, t)], size, false)?;
|
||||
f.first().map(|f| f.1).ok_or_else(|| KernelError::Failed("patch: frame".into()))
|
||||
};
|
||||
let g1 = |s: Option<&Side>| s.is_some_and(|s| s.order != Continuity::G0);
|
||||
let clash = if g1(sides.get(prev)) || g1(sides.get(k)) {
|
||||
let (a, b) = (normal(sides.get(prev), t_in)?, normal(sides.get(k), t_out)?);
|
||||
a.dot(b).abs() < (2.0 * PATCH_ANGLE_TOL_DEG).to_radians().cos()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if let Some(s) = sides.get_mut(k) {
|
||||
s.corner = corner;
|
||||
s.clash = clash;
|
||||
}
|
||||
}
|
||||
let mut stations = |k: usize, ts: &[f64]| -> std::result::Result<Vec<Station>, String> {
|
||||
let side = sides.get_mut(k).ok_or("no such side")?;
|
||||
let mut at = Vec::with_capacity(ts.len());
|
||||
for &t in ts {
|
||||
let (Some(p), Some(tangent)) = (side.point(t), side.tangent(t)) else { return Err("an edge cannot be sampled".into()) };
|
||||
at.push((p, tangent));
|
||||
}
|
||||
let frames = side.frames(&at, size, side.order == Continuity::G2).map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::with_capacity(ts.len());
|
||||
for ((&t, (p, tangent)), (frame, normal)) in ts.iter().zip(&at).zip(&frames) {
|
||||
let up = tangent.cross(frame.leave).normalized().ok_or("the loop runs across its neighbour")?;
|
||||
let across = (side.order != Continuity::G0).then(|| Across {
|
||||
leave: frame.leave,
|
||||
normal: *normal,
|
||||
bend: (side.order == Continuity::G2).then_some(frame.bend),
|
||||
});
|
||||
let first = t <= 0.0;
|
||||
out.push(Station { p: *p, up, across, corner: first && side.corner, clash: first && side.clash });
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let spec = FillSpec { tol, angle_tol: PATCH_ANGLE_TOL_DEG.to_radians(), crown, points };
|
||||
let fill = solidcraft_geom::fill_general::fill_general(chain.len(), &mut stations, &spec).map_err(spline_error)?;
|
||||
// The wire runs counter-clockwise about the surface's normal.
|
||||
let wire: mt::Wire =
|
||||
if fill.counter_clockwise { chain.to_vec().into() } else { chain.iter().rev().map(mt::Edge::inverse).collect::<Vec<_>>().into() };
|
||||
let face = mt::Face::try_new(vec![wire], crate::spline_conv::surface(&fill.surface)?).map_err(|e| KernelError::Failed(format!("fill: {e}")))?;
|
||||
let mut rec = Rebuild::default();
|
||||
rec.made(&face, Vec::new());
|
||||
let report = PatchReport {
|
||||
general: true,
|
||||
gap: fill.deviation,
|
||||
normal_deviation_deg: fill.normal_deviation.to_degrees(),
|
||||
normal_deviation_all_deg: fill.normal_deviation_all.to_degrees(),
|
||||
curvature_mismatch: fill.curvature_mismatch,
|
||||
point_gap: fill.point_deviation,
|
||||
};
|
||||
Ok((rebuilt_body(crate::surfaces::surface_body(vec![face]), &[&body.solid], &rec), report))
|
||||
}
|
||||
|
||||
/// The loop turns by more than this (degrees) at a corner: its stations hold position only.
|
||||
const CORNER_DEG: f64 = 1.0;
|
||||
|
||||
/// A surface body of one face filling the closed loop `chain` of `body`'s edges (each edge
|
||||
/// oriented along the loop, each ending where the next starts), within [`face_tolerance`] of
|
||||
/// every edge. Its history is over `[body]`: the face is new, its edges the body's own. Call it
|
||||
@@ -34,10 +277,16 @@ pub(crate) fn fill_loop_crowned(body: &Body, chain: &[mt::Edge], crown: f64) ->
|
||||
// The loop's corners are its vertices (an imported edge's curve may end a little off).
|
||||
if t <= 0.0 { Some(*start) } else { sampler.at(t) }
|
||||
};
|
||||
let fill = solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol, crown).map_err(|e| match e {
|
||||
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
|
||||
other => KernelError::Invalid(format!("cannot fill the loop: {other}")),
|
||||
})?;
|
||||
let fill = match solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol, crown) {
|
||||
Ok(fill) => fill,
|
||||
// Not a height field over its plane (or too wavy for one): the general fill, developed
|
||||
// from the loop itself, when no crown asks for the height field's bump.
|
||||
Err(SplineError::Unsupported(_)) if crown == 0.0 => {
|
||||
let orders = vec![Continuity::G0; chain.len()];
|
||||
return general_fill(body, chain, &orders, &[], 0.0).map(|(b, _)| b);
|
||||
}
|
||||
Err(e) => return Err(spline_error(e)),
|
||||
};
|
||||
// The wire runs counter-clockwise about the surface's normal.
|
||||
let wire: mt::Wire =
|
||||
if fill.counter_clockwise { chain.to_vec().into() } else { chain.iter().rev().map(mt::Edge::inverse).collect::<Vec<_>>().into() };
|
||||
@@ -489,9 +738,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_that_does_not_bound_a_height_field_is_refused() {
|
||||
fn a_loop_that_does_not_bound_a_height_field_fills_with_one_face() {
|
||||
// Half a round post's side: two straight edges up and two half circles, whose plane
|
||||
// stands on the post's axis; the half circles run along its normal at their ends.
|
||||
// stands on the post's axis; the half circles run along its normal at their ends, so no
|
||||
// height field over it bounds them (the height-field fill refuses it). The general fill
|
||||
// (#759) fills it with one face on its four edges.
|
||||
let post = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
let halves = crate::split_by_plane(&post, &Plane::from_normal(Vec3::ZERO, Vec3::X).unwrap()).unwrap();
|
||||
let half = halves.into_iter().find(|b| measure(b).unwrap().bbox.min.x > -1e-6).unwrap();
|
||||
@@ -499,9 +750,16 @@ mod tests {
|
||||
(-4..=4).map(|k| Vec2::from_angle(k as f64 * 0.35).extend(10.0) * 1.0).map(|p| Vec3::new(p.x * 10.0, p.y * 10.0, 10.0)).collect();
|
||||
let side = copy_faces(&half, &at).unwrap();
|
||||
let rim = free_edge_points(&side);
|
||||
let err = patch_edges(&side, &rim).unwrap_err().to_string();
|
||||
eprintln!("half a post's side: {err}");
|
||||
assert!(err.contains("not supported yet"), "{err}");
|
||||
let fill = patch_edges(&side, &rim).unwrap();
|
||||
assert_eq!(fill.face_count(), 1, "one face");
|
||||
let face = fill.solid.face_iter().next().unwrap().clone();
|
||||
let (off, tol) = (edges_off_surface(&face), super::face_tolerance(&side));
|
||||
assert!(off <= tol, "an edge is {off} off the face (tolerance {tol})");
|
||||
// Its area is at least its shadow on the plane x = 0 (the 20 × 20 square the loop bounds
|
||||
// there) and, a least-bending fill, no more than the half side it spans.
|
||||
let area = measure(&fill).unwrap().area;
|
||||
eprintln!("half a post's side: one face of area {area}, its edges within {off:e}");
|
||||
assert!(area > 400.0 && area < std::f64::consts::PI * 10.0 * 20.0, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -611,3 +869,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fill_general_tests.rs"]
|
||||
mod general_tests;
|
||||
@@ -0,0 +1,506 @@
|
||||
//! The general fill on bodies (#759): G1 and G2 to the faces beside the loop, loops that are not
|
||||
//! height fields, N-sided loops and points the patch passes through. Every number is checked
|
||||
//! against the analytic surface the loop was cut from, or against a quadrature of the fitted
|
||||
//! surface itself, never against the mesher alone.
|
||||
|
||||
use mt::{BoundedCurve, InnerSpace, ParametricCurve, ParametricSurface, ParametricSurface3D};
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use super::{PATCH_ANGLE_TOL_DEG, PatchReport, PatchSpec, face_tolerance, patch_edges_report};
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
use crate::bridge::Continuity;
|
||||
use crate::{BoolOp, boolean, box_solid, copy_faces, cylinder, extrude, measure, patch_edges, stitch, torus};
|
||||
|
||||
/// The surface's parameters nearest `p`: the nearest of a grid of samples (or `hint`), then
|
||||
/// Gauss–Newton within the domain.
|
||||
fn nearest(s: &mt::Surface, p: Vec3, hint: Option<(f64, f64)>) -> (f64, f64) {
|
||||
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { panic!("a bounded surface") };
|
||||
let p = p3(p);
|
||||
let (mut u, mut v) = match hint {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
let n = 100;
|
||||
let mut best = (f64::MAX, u0, v0);
|
||||
for i in 0..=n {
|
||||
for j in 0..=n {
|
||||
let (u, v) = (u0 + (u1 - u0) * i as f64 / n as f64, v0 + (v1 - v0) * j as f64 / n as f64);
|
||||
let d = (s.subs(u, v) - p).magnitude();
|
||||
if d < best.0 {
|
||||
best = (d, u, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
(best.1, best.2)
|
||||
}
|
||||
};
|
||||
for _ in 0..60 {
|
||||
let (q, a, b) = (s.subs(u, v), s.uder(u, v), s.vder(u, v));
|
||||
let r = q - p;
|
||||
let (aa, ab, bb) = (a.dot(a), a.dot(b), b.dot(b));
|
||||
let det = aa * bb - ab * ab;
|
||||
if det.abs() < 1e-300 {
|
||||
break;
|
||||
}
|
||||
let (ga, gb) = (a.dot(r), b.dot(r));
|
||||
u = (u - (bb * ga - ab * gb) / det).clamp(u0, u1);
|
||||
v = (v - (aa * gb - ab * ga) / det).clamp(v0, v1);
|
||||
}
|
||||
(u, v)
|
||||
}
|
||||
|
||||
fn v3(v: mt::Vector3) -> Vec3 {
|
||||
Vec3::new(v.x, v.y, v.z)
|
||||
}
|
||||
|
||||
/// Points along every edge of the loop: `per` on each.
|
||||
fn rim_points(face: &mt::Face, per: usize) -> Vec<Vec3> {
|
||||
let mut out = Vec::new();
|
||||
for e in face.boundaries().iter().flat_map(|w| w.edge_iter()) {
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
for k in 0..per {
|
||||
out.push(from_p3(c.subs(t0 + (t1 - t0) * (k as f64 + 0.5) / per as f64)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Along the loop, densely: the farthest a rim point is from the patch, the largest angle
|
||||
/// (degrees) between the patch's normal and the neighbour's exact one (`normal(p)`, one sign for
|
||||
/// the whole loop: a fold would show as 180°), and the largest difference between the patch's
|
||||
/// normal curvature across the loop and `curvature(p)`, the neighbour's.
|
||||
fn along_the_loop(fill: &Body, normal: &dyn Fn(Vec3) -> Vec3, curvature: &dyn Fn(Vec3) -> f64) -> (f64, f64, f64) {
|
||||
let face = fill.solid.face_iter().next().unwrap().clone();
|
||||
let s = face.surface();
|
||||
let pts = rim_points(&face, 200);
|
||||
let mut found = Vec::with_capacity(pts.len());
|
||||
let mut hint = None;
|
||||
for p in &pts {
|
||||
let uv = nearest(&s, *p, hint);
|
||||
hint = Some(uv);
|
||||
found.push(uv);
|
||||
}
|
||||
// One sign for the whole loop: the one most rim points agree with.
|
||||
let patch_normal = |(u, v): (f64, f64)| v3(s.normal(u, v));
|
||||
let agree: f64 = pts.iter().zip(&found).map(|(p, uv)| patch_normal(*uv).dot(normal(*p)).signum()).sum();
|
||||
let sign = agree.signum();
|
||||
let (mut gap, mut angle, mut kappa) = (0.0f64, 0.0f64, 0.0f64);
|
||||
for (k, (p, &(u, v))) in pts.iter().zip(&found).enumerate() {
|
||||
gap = gap.max(from_p3(s.subs(u, v)).dist(*p));
|
||||
let n = patch_normal((u, v));
|
||||
let want = normal(*p) * sign;
|
||||
angle = angle.max(n.dot(want).clamp(-1.0, 1.0).acos().to_degrees());
|
||||
// Across the loop: the tangent-plane direction square to the rim.
|
||||
let next = pts.get(k + 1).or(pts.get(k.wrapping_sub(1))).copied().unwrap();
|
||||
let t = (next - *p).normalized().unwrap();
|
||||
let Some(d) = n.cross(t).normalized() else { continue };
|
||||
let (su, sv) = (v3(s.uder(u, v)), v3(s.vder(u, v)));
|
||||
// d = a·su + b·sv.
|
||||
let (aa, ab, bb) = (su.dot(su), su.dot(sv), sv.dot(sv));
|
||||
let det = aa * bb - ab * ab;
|
||||
let (a, b) = ((bb * d.dot(su) - ab * d.dot(sv)) / det, (aa * d.dot(sv) - ab * d.dot(su)) / det);
|
||||
// The second derivatives by central differences of the surface's own points.
|
||||
let h = 1e-3;
|
||||
let e = |du: f64, dv: f64| from_p3(s.subs(u + du * h, v + dv * h));
|
||||
let suu = (e(1.0, 0.0) - e(0.0, 0.0) * 2.0 + e(-1.0, 0.0)) * (1.0 / (h * h));
|
||||
let svv = (e(0.0, 1.0) - e(0.0, 0.0) * 2.0 + e(0.0, -1.0)) * (1.0 / (h * h));
|
||||
let suv = (e(1.0, 1.0) - e(1.0, -1.0) - e(-1.0, 1.0) + e(-1.0, -1.0)) * (0.25 / (h * h));
|
||||
let second = suu * (a * a) + suv * (2.0 * a * b) + svv * (b * b);
|
||||
kappa = kappa.max((second.dot(want) - curvature(*p)).abs());
|
||||
}
|
||||
(gap, angle, kappa)
|
||||
}
|
||||
|
||||
/// `∫∫ f(S, S_u × S_v) du dv` over the face's own region of its surface (its loop taken into
|
||||
/// parameter space by the nearest parameters of points along it), by the midpoint rule on a
|
||||
/// `n × n` grid over the region's box: the quadrature the mesher's area is checked against.
|
||||
fn quadrature(face: &mt::Face, n: usize, f: &dyn Fn(Vec3, Vec3) -> f64) -> f64 {
|
||||
let s = face.surface();
|
||||
let mut poly: Vec<(f64, f64)> = Vec::new();
|
||||
let mut hint = None;
|
||||
for p in rim_points(face, 400) {
|
||||
let uv = nearest(&s, p, hint);
|
||||
hint = Some(uv);
|
||||
poly.push(uv);
|
||||
}
|
||||
let (mut lo, mut hi) = ((f64::MAX, f64::MAX), (f64::MIN, f64::MIN));
|
||||
for &(u, v) in &poly {
|
||||
lo = (lo.0.min(u), lo.1.min(v));
|
||||
hi = (hi.0.max(u), hi.1.max(v));
|
||||
}
|
||||
let inside = |u: f64, v: f64| {
|
||||
let mut w = false;
|
||||
for k in 0..poly.len() {
|
||||
let (a, b) = (poly[k], poly[(k + 1) % poly.len()]);
|
||||
if (a.1 > v) != (b.1 > v) && u < a.0 + (v - a.1) / (b.1 - a.1) * (b.0 - a.0) {
|
||||
w = !w;
|
||||
}
|
||||
}
|
||||
w
|
||||
};
|
||||
let (du, dv) = ((hi.0 - lo.0) / n as f64, (hi.1 - lo.1) / n as f64);
|
||||
let mut sum = 0.0;
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let (u, v) = (lo.0 + (i as f64 + 0.5) * du, lo.1 + (j as f64 + 0.5) * dv);
|
||||
if inside(u, v) {
|
||||
sum += f(from_p3(s.subs(u, v)), v3(s.uder(u, v)).cross(v3(s.vder(u, v)))) * du * dv;
|
||||
}
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
/// The fill is one face on the open body's own edges, within the fill's tolerance of them; its
|
||||
/// mesh has no folded triangle (each faces the way its corners' normals do); and the mesher's
|
||||
/// area agrees with a quadrature of the surface over its own region. Returns the area.
|
||||
fn check_face(name: &str, open: &Body, fill: &Body) -> f64 {
|
||||
assert!(fill.is_surface());
|
||||
assert_eq!(fill.face_count(), 1, "{name}: one face");
|
||||
let own: Vec<mt::EdgeID> = open.solid.edge_iter().map(|e| e.id()).collect();
|
||||
assert!(fill.solid.edge_iter().all(|e| own.contains(&e.id())), "{name}: an edge that is not the body's");
|
||||
let face = fill.solid.face_iter().next().unwrap().clone();
|
||||
let s = face.surface();
|
||||
let tol = face_tolerance(open);
|
||||
let mut off = 0.0f64;
|
||||
let mut hint = None;
|
||||
for p in rim_points(&face, 64) {
|
||||
let uv = nearest(&s, p, hint);
|
||||
hint = Some(uv);
|
||||
off = off.max(from_p3(s.subs(uv.0, uv.1)).dist(p));
|
||||
}
|
||||
assert!(off <= tol * 1.01, "{name}: an edge is {off} off the face (tolerance {tol})");
|
||||
let m = fill.tessellate(0.01).unwrap();
|
||||
assert!(!m.triangles.is_empty(), "{name}: the face meshes");
|
||||
for t in &m.triangles {
|
||||
let [a, b, c] = m.tri(t).unwrap();
|
||||
let v = (b - a).cross(c - a);
|
||||
let n: Vec3 = t.iter().map(|&i| m.normals[i as usize]).fold(Vec3::ZERO, |s, x| s + x);
|
||||
if v.len() > 1e-10 {
|
||||
assert!(v.dot(n) > 0.0, "{name}: a folded triangle");
|
||||
}
|
||||
}
|
||||
let area = measure(fill).unwrap().area;
|
||||
let quad = quadrature(&face, 500, &|_, n| n.len());
|
||||
eprintln!("{name}: one face, edges within {off:e} (tolerance {tol:e}), mesh area {area:.4}, quadrature {quad:.4}");
|
||||
assert!((area - quad).abs() < 3e-3 * quad, "{name}: mesh area {area} against the surface's {quad}");
|
||||
area
|
||||
}
|
||||
|
||||
fn spec(g: Continuity) -> PatchSpec {
|
||||
PatchSpec { continuity: g, ..PatchSpec::default() }
|
||||
}
|
||||
|
||||
fn report(name: &str, r: &PatchReport) {
|
||||
eprintln!(
|
||||
"{name}: fill reports gap {:e}, normals within {:.4}° ({:.4}° near clashing corners), curvature mismatch {:e}, points within {:e}",
|
||||
r.gap, r.normal_deviation_deg, r.normal_deviation_all_deg, r.curvature_mismatch, r.point_gap
|
||||
);
|
||||
}
|
||||
|
||||
/// A cylinder about z of radius 10 and 30 high with its top face taken off; points around the rim.
|
||||
fn open_tube() -> (Body, Vec<Vec3>) {
|
||||
let solid = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 30.0).unwrap();
|
||||
let open = copy_faces(&solid, &[&around(10.0, 15.0)[..], &[Vec3::ZERO]].concat()).unwrap();
|
||||
assert!(open.is_surface());
|
||||
let rim = (0..8).map(|k| Vec2::from_angle(k as f64 * 0.785).extend(0.0) * 10.0 + Vec3::Z * 30.0).collect();
|
||||
(open, rim)
|
||||
}
|
||||
|
||||
/// `body` without its planar faces but those at height `z0` or `z1` (the ends of a cylinder):
|
||||
/// a cut solid's walls (and ends) as a surface, the faces the cut made taken away.
|
||||
fn curved_and_ends(body: &Body, ends: &[f64]) -> Body {
|
||||
let drop: Vec<usize> = body
|
||||
.solid
|
||||
.face_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| match f.surface() {
|
||||
mt::Surface::Plane(p) => !ends.iter().any(|z| (p.origin().z - z).abs() < 1e-9 && p.normal().z.abs() > 1.0 - 1e-12),
|
||||
_ => false,
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let open = crate::remove_faces(body, &drop).unwrap();
|
||||
assert!(open.is_surface());
|
||||
open
|
||||
}
|
||||
|
||||
/// A point on each edge that bounds only one face of `body` (its free edges): the middle of
|
||||
/// each, where the edge picker samples it.
|
||||
fn free_edge_points(body: &Body) -> Vec<Vec3> {
|
||||
let mut uses: std::collections::HashMap<mt::EdgeID, (usize, mt::Edge)> = std::collections::HashMap::new();
|
||||
for f in body.solid.face_iter() {
|
||||
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
|
||||
uses.entry(e.id()).or_insert((0, e.clone())).0 += 1;
|
||||
}
|
||||
}
|
||||
let mut points = uses
|
||||
.values()
|
||||
.filter(|(n, _)| *n == 1)
|
||||
.map(|(_, e)| {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
from_p3(c.subs(0.5 * (t0 + t1)))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// In a fixed order, whatever the map's.
|
||||
points.sort_by(|a, b| (a.z, a.y, a.x).partial_cmp(&(b.z, b.y, b.x)).unwrap());
|
||||
points
|
||||
}
|
||||
|
||||
/// Points round the circle of radius `r` about z at height `z`, every 30°.
|
||||
fn around(r: f64, z: f64) -> Vec<Vec3> {
|
||||
(0..12).map(|k| Vec2::from_angle(0.1 + k as f64 * std::f64::consts::TAU / 12.0).extend(0.0) * r + Vec3::Z * z).collect()
|
||||
}
|
||||
|
||||
fn radial(p: Vec3) -> Vec3 {
|
||||
Vec3::new(p.x, p.y, 0.0).normalized().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opened_tube_end_capped_g1_and_g2_meets_the_wall() {
|
||||
let (open, rim) = open_tube();
|
||||
let mut kappas = Vec::new();
|
||||
for g in [Continuity::G1, Continuity::G2] {
|
||||
let name = format!("tube end {}", g.as_str());
|
||||
let started = std::time::Instant::now();
|
||||
let (fill, r) = patch_edges_report(&open, &rim, &spec(g)).unwrap();
|
||||
eprintln!("{name}: filled in {:?}", started.elapsed());
|
||||
report(&name, &r);
|
||||
assert!(r.general);
|
||||
let area = check_face(&name, &open, &fill);
|
||||
// The wall's normal is radial and it does not bend along its axis.
|
||||
let (gap, angle, kappa) = along_the_loop(&fill, &radial, &|_| 0.0);
|
||||
eprintln!(
|
||||
"{name}: along the rim (1600 points): within {gap:e} of it, normal within {angle:.5}° of the wall's, curvature across {kappa:e} /mm (the wall's 0)"
|
||||
);
|
||||
assert!(angle < 0.1, "{name}: the normal turns {angle}° from the wall's");
|
||||
kappas.push(kappa);
|
||||
// A dome over the rim, not a flat lid: more than the disc.
|
||||
assert!(area > std::f64::consts::PI * 100.0 * 1.05, "{name}: {area}");
|
||||
// Sealed: the tube's volume plus the dome's, the dome's by the divergence theorem over the
|
||||
// patch (∫∫ (z − 30)·n_z dA), independent of the mesher.
|
||||
let closed = stitch(&[&open, &fill], 0.0).unwrap();
|
||||
assert!(!closed.is_surface(), "{name}: the cap closes the tube");
|
||||
let face = fill.solid.face_iter().next().unwrap().clone();
|
||||
let dome = quadrature(&face, 500, &|p, n| (p.z - 30.0) * n.z).abs();
|
||||
let (got, want) = (measure(&closed).unwrap().volume, std::f64::consts::PI * 100.0 * 30.0 + dome);
|
||||
eprintln!("{name}: sealed volume {got:.3}, tube + dome {want:.3}");
|
||||
assert!((got - want).abs() < 2e-4 * want, "{name}: {got} vs {want}");
|
||||
}
|
||||
eprintln!("tube end: curvature across the rim g1 {:e}, g2 {:e} /mm", kappas[0], kappas[1]);
|
||||
assert!(kappas[1] < kappas[0], "g2 holds the wall's curvature closer than g1");
|
||||
}
|
||||
|
||||
/// A cylinder about z (radius 10, 40 high) with a window `half` either side of the x axis
|
||||
/// through its wall, from z = 15 to 25; the wall and both ends without the faces the cut made (the
|
||||
/// window's edges bound only the wall), and a point on each of the window's edges.
|
||||
fn windowed(half: f64) -> (Body, Body, Vec<Vec3>) {
|
||||
let solid = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 40.0).unwrap();
|
||||
let x = 10.0 * half.cos();
|
||||
let tool = box_solid(Vec3::new(x, -11.0, 15.0), Vec3::new(11.0, 11.0, 25.0)).unwrap();
|
||||
let cut = boolean(&solid, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
let open = curved_and_ends(&cut, &[0.0, 40.0]);
|
||||
let rim = free_edge_points(&open);
|
||||
(solid, open, rim)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_cut_through_a_cylinder_wall_past_half_way_fills_g1_close_to_the_cylinder() {
|
||||
// 240° round: seen along any direction the window folds over itself, so it bounds no height
|
||||
// field; the old fill refused it.
|
||||
let half = 2.0 * std::f64::consts::PI / 3.0;
|
||||
let (solid, open, rim) = windowed(half);
|
||||
for g in [Continuity::G0, Continuity::G1] {
|
||||
let name = format!("window {}", g.as_str());
|
||||
let (fill, r) = patch_edges_report(&open, &rim, &spec(g)).unwrap();
|
||||
report(&name, &r);
|
||||
let area = check_face(&name, &open, &fill);
|
||||
let (gap, angle, _) = along_the_loop(&fill, &radial, &|_| 0.0);
|
||||
eprintln!("{name}: along the window (1600 points): within {gap:e}, normal within {angle:.5}° of the wall's");
|
||||
// How far the fill strays from the cylinder it replaces, inside.
|
||||
let face = fill.solid.face_iter().next().unwrap().clone();
|
||||
let s = face.surface();
|
||||
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { panic!() };
|
||||
let mut stray = 0.0f64;
|
||||
for i in 0..=40 {
|
||||
for j in 0..=40 {
|
||||
let p = from_p3(s.subs(u0 + (u1 - u0) * i as f64 / 40.0, v0 + (v1 - v0) * j as f64 / 40.0));
|
||||
let ang = p.y.atan2(p.x);
|
||||
if ang.abs() < half - 0.05 && p.z > 15.5 && p.z < 24.5 {
|
||||
stray = stray.max((Vec2::new(p.x, p.y).len() - 10.0).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
let exact = 10.0 * 2.0 * half * 10.0;
|
||||
let closed = stitch(&[&open, &fill], 0.0).unwrap();
|
||||
let (got, want) = (measure(&closed).unwrap().volume, measure(&solid).unwrap().volume);
|
||||
eprintln!(
|
||||
"{name}: area {area:.3} (the cylinder's window {exact:.3}), the fill within {stray:.4} of the cylinder inside, sealed volume {got:.2} against the whole cylinder's {want:.2}"
|
||||
);
|
||||
assert!(!closed.is_surface());
|
||||
if g == Continuity::G1 {
|
||||
assert!(angle < 0.1, "{name}: the normal turns {angle}° from the wall's");
|
||||
assert!((area - exact).abs() < 0.01 * exact && stray < 0.25, "{name}: {area} vs {exact}, stray {stray}");
|
||||
assert!((got - want).abs() < 0.005 * want, "{name}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saddle_window_in_a_torus_fills_g0_and_g1() {
|
||||
// The inner wall of a torus (major 20, minor 8) is saddle-shaped; a window 240° round it
|
||||
// between z = ±3 bounds no height field.
|
||||
let solid = torus(Vec3::ZERO, 20.0, 8.0).unwrap();
|
||||
let tool = box_solid(Vec3::new(-6.0, -13.0, -3.0), Vec3::new(13.0, 13.0, 3.0)).unwrap();
|
||||
let cut = boolean(&solid, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
let open = curved_and_ends(&cut, &[]);
|
||||
let rim = free_edge_points(&open);
|
||||
// The torus's normal: from the tube's centre circle.
|
||||
let normal = |p: Vec3| {
|
||||
let c = Vec3::new(p.x, p.y, 0.0).normalized().unwrap() * 20.0;
|
||||
(p - c).normalized().unwrap()
|
||||
};
|
||||
for g in [Continuity::G0, Continuity::G1] {
|
||||
let name = format!("torus window {}", g.as_str());
|
||||
let (fill, r) = patch_edges_report(&open, &rim, &spec(g)).unwrap();
|
||||
report(&name, &r);
|
||||
check_face(&name, &open, &fill);
|
||||
let (gap, angle, _) = along_the_loop(&fill, &normal, &|_| 0.0);
|
||||
eprintln!("{name}: along the window (1600 points): within {gap:e}, normal within {angle:.5}° of the torus's");
|
||||
if g == Continuity::G1 {
|
||||
assert!(angle < 0.1, "{name}: {angle}°");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cylinder about z (radius 10, 40 high) with a pentagonal pocket cut into its wall from the
|
||||
/// +x side (the pentagon in the yz plane about z = 20, circumradius 6): the wall and both ends
|
||||
/// copied out, and a point on each of the pocket rim's five edges.
|
||||
fn pentagon_window() -> (Body, Vec<Vec3>) {
|
||||
let solid = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 40.0).unwrap();
|
||||
let corners: Vec<Vec2> = (0..5).map(|k| Vec2::from_angle(0.3 + k as f64 * std::f64::consts::TAU / 5.0) * 6.0 + Vec2::new(0.0, 20.0)).collect();
|
||||
let prism = extrude(&Plane::YZ, &[Region2 { outer: Loop2::polygon(&corners), holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap();
|
||||
let cut = boolean(&solid, &prism, BoolOp::Cut).unwrap().unwrap();
|
||||
let open = curved_and_ends(&cut, &[0.0, 40.0]);
|
||||
let rim = free_edge_points(&open);
|
||||
(open, rim)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_five_sided_window_fills_as_one_face_g1_and_through_a_point() {
|
||||
let (open, rim) = pentagon_window();
|
||||
let (fill, r) = patch_edges_report(&open, &rim, &spec(Continuity::G1)).unwrap();
|
||||
report("pentagon g1", &r);
|
||||
assert!(fill.edge_count() >= 5, "the rim's own edges (five, or more where a seam splits one)");
|
||||
check_face("pentagon g1", &open, &fill);
|
||||
let (gap, angle, _) = along_the_loop(&fill, &radial, &|_| 0.0);
|
||||
eprintln!("pentagon g1: along the rim: within {gap:e}, normal within {angle:.5}° of the wall's");
|
||||
assert!(angle < 0.1, "{angle}°");
|
||||
// Through a point half a millimetre out from the wall in the middle (G0 and G1).
|
||||
let point = Vec3::new(10.5, 0.0, 20.0);
|
||||
for g in [Continuity::G0, Continuity::G1] {
|
||||
let name = format!("pentagon {} through a point", g.as_str());
|
||||
let spec = PatchSpec { continuity: g, points: vec![point], ..PatchSpec::default() };
|
||||
let (fill, r) = patch_edges_report(&open, &rim, &spec).unwrap();
|
||||
report(&name, &r);
|
||||
check_face(&name, &open, &fill);
|
||||
let s = fill.solid.face_iter().next().unwrap().surface();
|
||||
let (u, v) = nearest(&s, point, None);
|
||||
let off = from_p3(s.subs(u, v)).dist(point);
|
||||
eprintln!("{name}: the point is {off:e} off the surface");
|
||||
assert!(off <= face_tolerance(&open), "{name}: {off}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hexagonal_rim_fills_through_a_point_and_g1_to_walls_meeting_at_angles_holds_away_from_the_corners() {
|
||||
// A hexagonal prism's top cut by a sideways cylinder: six walls meeting at 120°.
|
||||
let corners: Vec<Vec2> = (0..6).map(|k| Vec2::from_angle(0.3 + k as f64 * std::f64::consts::TAU / 6.0) * 20.0).collect();
|
||||
let prism = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&corners), holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
|
||||
let tool = cylinder(Vec3::new(0.0, -60.0, 50.0), Vec3::Y, 40.0, 120.0).unwrap();
|
||||
let solid = boolean(&prism, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
let mut keep = vec![Vec3::ZERO];
|
||||
let mut rim = Vec::new();
|
||||
for k in 0..6 {
|
||||
let m = corners[k].lerp(corners[(k + 1) % 6], 0.5);
|
||||
keep.push(m.extend(5.0));
|
||||
rim.push(m.extend(50.0 - (1600.0 - m.x * m.x).sqrt()));
|
||||
}
|
||||
let open = copy_faces(&solid, &keep).unwrap();
|
||||
let plain = patch_edges(&open, &rim).unwrap();
|
||||
let sealed = |f: &Body| measure(&stitch(&[&open, f], 0.0).unwrap()).unwrap().volume;
|
||||
// Six sides, G0, through a point 3 mm above the middle of the plain fill: one face.
|
||||
let s = plain.solid.face_iter().next().unwrap().surface();
|
||||
let (u, v) = nearest(&s, Vec3::new(0.0, 0.0, 12.0), None);
|
||||
let point = from_p3(s.subs(u, v)) + Vec3::Z * 3.0;
|
||||
let (lifted, r) = patch_edges_report(&open, &rim, &PatchSpec { points: vec![point], ..PatchSpec::default() }).unwrap();
|
||||
report("hexagon through a point", &r);
|
||||
assert_eq!(lifted.edge_count(), 6, "the rim's own six edges");
|
||||
check_face("hexagon through a point", &open, &lifted);
|
||||
let s = lifted.solid.face_iter().next().unwrap().surface();
|
||||
let (u, v) = nearest(&s, point, None);
|
||||
let off = from_p3(s.subs(u, v)).dist(point);
|
||||
eprintln!("hexagon through a point: the point is {off:e} off the surface; sealed {:.2} against {:.2} plain", sealed(&lifted), sealed(&plain));
|
||||
assert!(off <= face_tolerance(&open) && sealed(&lifted) > sealed(&plain));
|
||||
// G1 to the walls: at a corner no surface is tangent to both walls (its tangent plane there
|
||||
// holds both rim edges), so G1 is asked only away from the corners. On walls standing square
|
||||
// to the patch the corner is pinched too sharply for the finest grid to hold the rim within
|
||||
// the fill's tolerance, and the fill says so.
|
||||
match patch_edges_report(&open, &rim, &spec(Continuity::G1)) {
|
||||
Ok((fill, r)) => {
|
||||
report("hexagon g1", &r);
|
||||
check_face("hexagon g1", &open, &fill);
|
||||
assert!(r.normal_deviation_deg <= PATCH_ANGLE_TOL_DEG);
|
||||
assert!(sealed(&fill) > sealed(&plain));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("hexagon g1: {e}");
|
||||
assert!(e.to_string().contains("not supported yet"), "{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_edge_continuity_and_hostile_specs() {
|
||||
let (open, rim) = pentagon_window();
|
||||
// G1 on two edges only.
|
||||
let spec = PatchSpec { edges: vec![(rim[0], Continuity::G1), (rim[2], Continuity::G2)], ..PatchSpec::default() };
|
||||
let (fill, r) = patch_edges_report(&open, &rim, &spec).unwrap();
|
||||
report("pentagon, two edges g1/g2", &r);
|
||||
check_face("pentagon, two edges g1/g2", &open, &fill);
|
||||
let bad = |spec: PatchSpec| patch_edges_report(&open, &rim, &spec).is_err();
|
||||
let g1 = PatchSpec { continuity: Continuity::G1, ..PatchSpec::default() };
|
||||
assert!(bad(PatchSpec { continuity: Continuity::G3, ..g1.clone() }));
|
||||
assert!(bad(PatchSpec { edges: vec![(rim[0], Continuity::G3)], ..g1.clone() }));
|
||||
assert!(bad(PatchSpec { edges: vec![(Vec3::new(0.0, 0.0, 40.0), Continuity::G1)], ..g1.clone() }), "an edge off the loop");
|
||||
assert!(bad(PatchSpec { edges: vec![(Vec3::new(f64::NAN, 0.0, 0.0), Continuity::G1)], ..g1.clone() }));
|
||||
assert!(bad(PatchSpec { points: vec![Vec3::new(f64::INFINITY, 0.0, 0.0)], ..g1.clone() }));
|
||||
assert!(bad(PatchSpec { points: vec![Vec3::new(10.0, 0.0, 20.0); 65], ..g1.clone() }));
|
||||
assert!(bad(PatchSpec { points: vec![Vec3::new(-10.0, 0.0, 20.0)], ..g1.clone() }), "a point off the far side");
|
||||
for crown in [f64::NAN, 3.5, -1.0] {
|
||||
assert!(bad(PatchSpec { crown, ..g1.clone() }), "crown {crown}");
|
||||
}
|
||||
assert!(bad(PatchSpec { crown: 0.1, points: vec![Vec3::new(10.2, 0.0, 20.0)], ..PatchSpec::default() }), "a crown with no g1 edge");
|
||||
// A crown fills the middle out from the wall (the side the wall's normals face) and leaves
|
||||
// the loop, and G1 across it, as they were.
|
||||
let (plain, _) = patch_edges_report(&open, &rim, &g1).unwrap();
|
||||
let (full, r) = patch_edges_report(&open, &rim, &PatchSpec { crown: 0.1, ..g1.clone() }).unwrap();
|
||||
report("pentagon g1 crown 0.1", &r);
|
||||
check_face("pentagon g1 crown 0.1", &open, &full);
|
||||
let (_, angle, _) = along_the_loop(&full, &radial, &|_| 0.0);
|
||||
let middle = |b: &Body| {
|
||||
let s = b.solid.face_iter().next().unwrap().surface();
|
||||
let (u, v) = nearest(&s, Vec3::new(10.0, 0.0, 20.0), None);
|
||||
let p = from_p3(s.subs(u, v));
|
||||
Vec2::new(p.x, p.y).len()
|
||||
};
|
||||
let (a, b) = (middle(&plain), middle(&full));
|
||||
eprintln!("pentagon g1: the middle {a:.4} from the axis plain, {b:.4} with crown 0.1; normal within {angle:.5}° of the wall's");
|
||||
assert!(b > a + 0.5 && angle < 0.1, "{a} {b} {angle}");
|
||||
// The edges of a closed solid lie between two faces: g1 has no one face to meet.
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let top = [Vec3::new(5.0, 0.0, 10.0), Vec3::new(10.0, 5.0, 10.0), Vec3::new(5.0, 10.0, 10.0), Vec3::new(0.0, 5.0, 10.0)];
|
||||
assert!(patch_edges_report(&b, &top, &PatchSpec { continuity: Continuity::G1, ..PatchSpec::default() }).is_err());
|
||||
}
|
||||
@@ -122,14 +122,14 @@ pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, sample_grid};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
|
||||
sweep_with, torus,
|
||||
};
|
||||
pub use delete_face::delete_faces;
|
||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
|
||||
pub use fill::patch_edges_crowned;
|
||||
pub use fill::{PATCH_ANGLE_TOL_DEG, PatchReport, PatchSpec, patch_edges_crowned, patch_edges_report, patch_edges_with};
|
||||
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
|
||||
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
||||
pub use iges_out::iges_export_bodies;
|
||||
|
||||
Reference in new issue
Block a user