Compare commits

...

8 Commits

Author SHA1 Message Date
jyviko 5ceb187308 solidcore: affine maps from an application's numbers, and ops::affine
The kernel builds the maps (math.rs, not upstream), in Transform's
row-vector convention (rows 0-2 the images of the axes, row 3 the
translation):
- Transform::rotation(point, axis, angle): Rodrigues' formula about the
  line through point along axis (made unit), transposed for rows;
- Transform::uniform_scaling(centre, factor), keeping its centre;
- Transform::reflection(origin, normal): I - 2 n n^T and 2 (origin.n) n,
  every entry exact for an axis normal, so twice is the identity exactly.
Each refuses numbers not finite, an axis or normal of no length, and a
zero factor (InvalidInput, "math::Transform").

solidcore-ops::affine forwards them as the matrices Body::transform and
curve::transform take: translation, rotation, scaling (a positive factor;
a mirror is reflection), reflection and then (first, then second, through
Transform::concat; a result not finite is refused), each Argument for what
it refuses. The ban list keeps the new constructors and concat for ops:
the kernel computes, ops calls (ban_list.py, clippy.toml).

Tests: kernel, a quarter turn about z takes x to y and a turn keeps its
line and lengths, a scale keeps its centre, an axis mirror maps exactly
and twice is the identity, a slanted mirror keeps its plane, refusals.
Ops, the mirror in y = 0 is the y-mirror matrix, axis mirrors twice are
the identity exactly, x to y under a quarter turn, a slanted turn keeps
its line, a scale keeps its centre, two moves stack to their sum exactly,
every result regular at REGULAR_RATIO, refusals and an overflowing stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:02:55 -05:00
jyviko 8263253ad4 solidcore: the curve from two views is traced to the kernel's tolerance
curves_from_views intersected its two sheets through intersect_surfaces,
which leaves the tolerance to the intersector's default: a thousandth of
the size of the surfaces' domains (SurfaceIntersector's constructor).
The sheets reach 2 D / sin(theta) past the curves, so at model scale
(curves about 170 mm across) that default is near a millimetre and the
span test accepted a five-point Hermite curve 5.75e-3 off both views,
against the 5e-6 its docs state. At the 10 mm scale of the first test
the angle test alone held it close, so nothing showed.

New intersect_surfaces_within(k, s1, s2, approx_tol_3d): intersect_surfaces
with the tolerance passed on to GlobalSurfaceIntersect (intersect_surfaces
calls it with None, unchanged). curves_from_views passes the kernel's
default approximation tolerance, approx_tol3d(k, USE_DEFAULT), half its
zone tolerance; the docs say the spans are checked at interior samples
(docs/ssi.md 4.3), not certified.

Tests (kernel and ops): the reported side and plan views, quadratic
Beziers about 170 mm across. The result is evaluated by de Boor's
algorithm on its own knots and control points, and the views by
Bernstein's form with the parameter at each x found by bisection, none of
the kernel's evaluators: at 513 parameters per curve each point is within
2 x 5e-6 x (1 + slope) of both views along their other axis, and the
curve runs from the plan view's start to the side view's end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
2026-11-11 18:35:03 -05:00
jyviko 77d3eb5cd8 solidcore: the curve seen in two views
intersect::views::curves_from_views(k, a, a_dir, b, b_dir): the 3D
curves seen as a along a_dir and as b along b_dir, where the two curves'
extrusions meet. Each curve is moved back along its unit direction and
swept forward (create_extrude) from -reach to +reach, and the two sheets
are intersected (intersect_surfaces); the meeting curves come back as
B-splines (from_parameters::b_spline_part, now crate-visible). The reach
is a bound, not a search: a point seen on both, p + s a = q + t b, has
|s| sin(theta) <= |q - p| <= D, D the diagonal of the box around both
control polygons (the convex hull of a positive-weight NURBS curve), so
each sheet reaches REACH_MARGIN (2) times D / sin(theta) on either side
and the meeting curves end on the curves' own ends. Directions not
finite, of no length or within EFF_ZERO_RAD of parallel are
InvalidInput; views that do not meet are an intersect failure with the
phrase NO_MEETING (views_do_not_meet reads it), never an empty Ok.
intersect_surfaces' own phrase is NO_SURFACE_INTERSECTION.

solidcore-ops: curve::two_view(a, a_dir, b, b_dir) -> Vec<Curve>
converts the record splines (curve_knots::net_of) and the meeting curves
(from_parameters::record_of), Argument for bad or parallel directions
and Empty when the views do not meet.

Tests (kernel and ops): a side view z = f(x) along y and a plan view
y = g(x) along z, their control points evenly spaced in x so the
parameter of each sampled x is closed form: every sampled point of the
result lies on both extrusions within the default approximation
tolerance (the gap along the view's axis allowed 1 + slope times it),
and the result spans the side view's whole run; parallel, zero and NaN
directions are refused; plan and side runs apart in x do not meet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
2026-11-11 18:00:16 -05:00
jyviko 7207472fab solidcore: a free curve under an affine map
curve::transform(curve, matrix) -> curve: the spline or rational spline
with every control point carried by the kernel's N_CrvTransform, its
degree, knots and weights as they were, so the image is exact up to the
rounding of each carried point (none for 0 and ±1 entries on a spline).
The matrix is Body::transform's (rows 0-2 the images of the axes, row 3
the translation, p' = p·A + t), its entries moved to the kernel's column
places; a matrix Transform::is_regular does not prove regular at
REGULAR_RATIO (singular, projective, not finite) is an Argument before
the curve is read. Reflections, non-uniform scales and shears are maps
like any other; a closed periodic curve stays closed, so the record's
spline is taken on any knots (curve_knots::net_of; nurb_of adds the
clamped check on it).

Tests: a cubic reflected about y = 0 has its CVs mirrored exactly and its
knots unchanged, twice is the curve again; a rational arc keeps its
weights and knots; a quarter turn and a move follow Body::transform's
convention exactly; a closed curve keeps its repeated points; singular,
projective and NaN matrices and a line are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
2026-11-11 18:00:16 -05:00
jyviko 2c9d3bfa4b Move a sheet's CV rows, columns or points, with a soft-select falloff
solidcore_ops::surface_cvs::move_net moves picked rows (u indices),
columns (v indices) or control points by a delta. With a Falloff (a
radius in index steps and a Linear or Smooth shape) the points around
the picks move too, each by its weight: 1 at a pick, falling with its
index distance (along u for rows, along v for columns, the square ring
for points), 0 past the radius. net_weights gives the table, for a
preview; an index distance and a weight are no geometric quantity.

The move itself is the kernel's: srf_basic::Surface::displace_cpts
moves each listed point by its fraction of the delta (a rational
point's weighted coordinates by w t delta, its weight kept), all or
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
2026-11-11 14:19:58 -05:00
jyviko a920fa2625 solidcore: boundary patches of five or more curves, the certified n-sided fill
surface::n_sided(curves) -> (Brep, bound): the loop's curves turned end to end
as boundary takes them, the kernel's certified n-sided fill (nurbs::n_sided),
its N faces stitched into one sheet; refused for a rational curve
(Unsupported) or a boundary or inner join past the zone tolerance
(PastTolerance). surface::boundary now takes five or more curves through it.
Tests: a regular pentagon of lines fills flat with five faces joined along five
inner edges; five cubics on a saddle bound their patch within the bound; five
arcs on a sphere are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
2026-11-11 14:19:58 -05:00
jyviko 058c05c686 solidcore: a certified n-sided fill of a loop of curves
nurbs::n_sided runs the kernel's fill (N four-sided patches round a centre) and
certifies it: each half of each boundary curve against the patch side it is
(certified_distance plus split and extraction rounding, in the loop's own
frame), and every inner join between two patches (G0). Tangency across the
inner curves is the fill's aim within its angle tolerance and is stated as
not certified. Rational curves refused; equal weights taken as polynomial
(gordon's helpers now shared within the crate). Tests: a regular pentagon of
lines fills flat within 1e-9; a rational side is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
2026-11-11 14:19:58 -05:00
jyviko 741a2308f4 solidcore: loft_rails' skin-and-certificate loop as one crate function
The loop that places stations at span middles, skins the wires, bounds
each rail against the skin on every span (certificate::span_bound),
refines unproved spans at their middles and refuses past the wire cap
moves, unchanged, into loft_rails::skin_and_certify, which loft_rails
calls with its own station placement. Plain, Wire and StationAt become
crate-visible for it. No behaviour change; a two-rail sweep will place
its stations through the same loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
2026-11-11 14:19:57 -05:00
18 changed files with 2120 additions and 37 deletions
+4 -2
View File
@@ -15,7 +15,9 @@ FLOATS = ["sqrt", "cbrt", "hypot", "sin", "cos", "tan", "sin_cos", "asin", "acos
"ln", "ln_1p", "log", "log2", "log10", "mul_add", "recip", "to_radians", "to_degrees"]
# Per type: (file, type, path) and the methods of that type kept. A name kept on one type opens
# nothing on another. Kept: constructors from given values, field reads and writes, and the
# kernel's certified queries on a transform (`is_regular`, `row_lengths`, `of_placement`), which ops calls rather than computes.
# kernel's certified queries on a transform (`is_regular`, `row_lengths`, `of_placement`), and the
# kernel's maps built from an application's numbers and their composition (`rotation`,
# `uniform_scaling`, `reflection`, `concat`), which ops calls rather than computes.
TYPES = [
("geom/vector3d.rs", "Vec3", "solidcore::Vec3",
{"new", "from_array", "to_array", "set_uninitialized", "is_initialized", "is_undef",
@@ -25,7 +27,7 @@ TYPES = [
"component", "component_mut"}),
("math.rs", "Transform", "solidcore::Transform",
{"translation", "x_axis", "y_axis", "z_axis", "translation_part", "is_regular", "row_lengths",
"of_placement"}),
"of_placement", "rotation", "uniform_scaling", "reflection", "concat"}),
("geom/transform.rs", "Transform", "solidcore::geom::transform::Transform",
{"new", "matrix", "type_code", "type_string", "class_string", "is_kind_of", "read_from_db"}),
("geom/matrix.rs", "Matrix", "solidcore::geom::matrix::Matrix",
-1
View File
@@ -127,7 +127,6 @@ disallowed-methods = [
{ path = "solidcore::Vec2::max_dimension", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Vec2::rotate", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Vec2::unitize", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Transform::concat", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Transform::from_axes", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Transform::transform_point", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Transform::transform_vector", reason = "ops computes no geometry (docs/layers.md §4)" },
+184
View File
@@ -0,0 +1,184 @@
//! Affine maps from an application's numbers, as the matrices [`crate::Body::transform`] and
//! [`crate::curve::transform`] take: rows 0-2 the images of x, y and z, row 3 the translation
//! (`p' = p·A + t`), the fourth column `(0, 0, 0, 1)`. A move, a turn about a line, a scale
//! about a point, a mirror in a plane, and one map after another. The maps are the kernel's
//! ([`solidcore::Transform::translation`], [`solidcore::Transform::rotation`],
//! [`solidcore::Transform::uniform_scaling`], [`solidcore::Transform::reflection`],
//! [`solidcore::Transform::concat`]); ops checks the numbers and passes them on.
use solidcore::{Transform, Vec3};
use crate::{Error, Result};
/// An affine map in the row-vector convention of [`crate::Body::transform`].
pub type Matrix = [[f64; 4]; 4];
/// Whether every number of `v` is finite.
fn finite(v: &[f64]) -> bool {
v.iter().all(|x| x.is_finite())
}
/// `v` as the kernel's vector.
fn vec3([x, y, z]: [f64; 3]) -> Vec3 {
Vec3::new(x, y, z)
}
/// The kernel's map, or [`Error::Argument`] for the numbers it refused.
fn taken(map: solidcore::Result<Transform>) -> Result<Matrix> {
map.map(|t| t.m).map_err(|_| Error::Argument)
}
/// The move by `by`.
///
/// # Errors
/// [`Error::Argument`] for a number not finite.
pub fn translation(by: [f64; 3]) -> Result<Matrix> {
if !finite(&by) {
return Err(Error::Argument);
}
Ok(Transform::translation(vec3(by)).m)
}
/// The turn by `angle` radians, right-handed about `axis`, about the line through `point`
/// along `axis` (any length but none).
///
/// # Errors
/// [`Error::Argument`] for a point, axis or angle not finite, or an axis of no length.
pub fn rotation(point: [f64; 3], axis: [f64; 3], angle: f64) -> Result<Matrix> {
taken(Transform::rotation(vec3(point), vec3(axis), angle))
}
/// The scale by `factor` in every direction about `centre`, which it keeps.
///
/// # Errors
/// [`Error::Argument`] for a centre not finite, or a factor not finite and positive (a mirror
/// is [`reflection`]).
pub fn scaling(centre: [f64; 3], factor: f64) -> Result<Matrix> {
if factor.is_nan() || factor <= 0.0 {
return Err(Error::Argument);
}
taken(Transform::uniform_scaling(vec3(centre), factor))
}
/// The mirror in the plane through `origin` square to `normal` (any length but none).
///
/// # Errors
/// [`Error::Argument`] for an origin or normal not finite, or a normal of no length.
pub fn reflection(origin: [f64; 3], normal: [f64; 3]) -> Result<Matrix> {
taken(Transform::reflection(vec3(origin), vec3(normal)))
}
/// `first`, then `second` (`p·A1 + t1`, then that `·A2 + t2`): steps stacked into one map.
///
/// # Errors
/// [`Error::Argument`] for a map with a number not finite, given or made.
pub fn then(first: &Matrix, second: &Matrix) -> Result<Matrix> {
if !finite(first.as_flattened()) || !finite(second.as_flattened()) {
return Err(Error::Argument);
}
let both = Transform { m: *first }.concat(&Transform { m: *second }).m;
if !finite(both.as_flattened()) {
return Err(Error::Argument);
}
Ok(both)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::body::REGULAR_RATIO;
use solidcore::geom::certified::Verdict;
/// The mirror in the plane y = 0.
const MIRROR_Y: Matrix = [
[1.0, 0.0, 0.0, 0.0],
[0.0, -1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
const IDENTITY: Matrix = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
/// `p` under `m`, in the row-vector convention.
fn apply(m: &Matrix, p: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|j| p[0] * m[0][j] + p[1] * m[1][j] + p[2] * m[2][j] + m[3][j])
}
fn near(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
fn regular(m: &Matrix) -> bool {
Transform { m: *m }.is_regular(REGULAR_RATIO) == Verdict::Yes
}
#[test]
fn a_mirror_in_y_0_is_the_mirror_matrix_and_twice_is_the_identity() {
assert_eq!(reflection([0.0; 3], [0.0, 1.0, 0.0]).unwrap(), MIRROR_Y);
// any length of an axis normal, any origin on its plane's side: exact
for (origin, normal) in [
([3.0, 5.0, -2.0], [0.0, 4.0, 0.0]),
([1.0, 2.0, 7.5], [0.0, 0.0, -1.0]),
([-6.0, 0.0, 1.0], [2.0, 0.0, 0.0]),
] {
let m = reflection(origin, normal).unwrap();
assert!(regular(&m));
assert_eq!(then(&m, &m).unwrap(), IDENTITY);
}
}
#[test]
fn a_quarter_turn_about_z_takes_x_to_y_and_a_turn_keeps_its_line() {
let q = std::f64::consts::FRAC_PI_2;
let m = rotation([0.0; 3], [0.0, 0.0, 1.0], q).unwrap();
assert!(regular(&m));
assert!(near(apply(&m, [1.0, 0.0, 0.0]), [0.0, 1.0, 0.0], 1e-15));
assert!(near(apply(&m, [0.0, 1.0, 0.0]), [-1.0, 0.0, 0.0], 1e-15));
// about the line through (10, 0, 0) along a slanted axis: the line's points stay
let m = rotation([10.0, 0.0, 0.0], [1.0, 2.0, 2.0], 0.7).unwrap();
assert!(regular(&m));
assert!(near(apply(&m, [10.0, 0.0, 0.0]), [10.0, 0.0, 0.0], 1e-13));
assert!(near(apply(&m, [13.0, 6.0, 6.0]), [13.0, 6.0, 6.0], 1e-12));
}
#[test]
fn a_scale_keeps_its_centre_and_moves_stack() {
let c = [1.0, -2.0, 3.0];
let m = scaling(c, 2.5).unwrap();
assert!(regular(&m));
assert!(near(apply(&m, c), c, 1e-14));
assert!(near(apply(&m, [2.0, -2.0, 3.0]), [3.5, -2.0, 3.0], 1e-14));
let (a, b) = ([1.5, -2.0, 3.0], [0.25, 4.0, -1.0]);
let sum = [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
let stacked = then(&translation(a).unwrap(), &translation(b).unwrap()).unwrap();
assert_eq!(stacked, translation(sum).unwrap());
assert!(regular(&stacked));
}
#[test]
fn numbers_not_finite_or_of_no_size_are_refused() {
assert_eq!(translation([f64::NAN, 0.0, 0.0]), Err(Error::Argument));
assert_eq!(rotation([0.0; 3], [0.0; 3], 1.0), Err(Error::Argument));
assert_eq!(
rotation([0.0; 3], [0.0, 0.0, 1.0], f64::INFINITY),
Err(Error::Argument)
);
assert_eq!(scaling([0.0; 3], 0.0), Err(Error::Argument));
assert_eq!(scaling([0.0; 3], -1.0), Err(Error::Argument));
assert_eq!(scaling([0.0; 3], f64::NAN), Err(Error::Argument));
assert_eq!(scaling([f64::NAN, 0.0, 0.0], 2.0), Err(Error::Argument));
assert_eq!(reflection([0.0; 3], [0.0; 3]), Err(Error::Argument));
assert_eq!(
reflection([0.0, f64::NAN, 0.0], [0.0, 1.0, 0.0]),
Err(Error::Argument)
);
// a stack whose numbers overflow
let far = translation([1e308, 0.0, 0.0]).unwrap();
assert_eq!(then(&far, &far), Err(Error::Argument));
}
}
+427 -4
View File
@@ -1,15 +1,24 @@
//! Free curves: a B-spline from the control points a designer places, one of its control points
//! moved, and its curvature comb. The curve is the record's ([`Curve`]); the knots and the
//! curvature are the kernel's ([`solidcore::nurbs::knots::clamped_uniform`],
//! moved, the curve under an affine map, the curve seen in two views, and its curvature comb.
//! The curve is the record's ([`Curve`]); the knots, the map's carrying of the control points,
//! the views' meeting and the curvature are the kernel's
//! ([`solidcore::nurbs::knots::clamped_uniform`],
//! [`solidcore::nurbs::crv_basic::Curve::transform`],
//! [`solidcore::intersect::views::curves_from_views`],
//! [`solidcore::query::continuity::curve_comb`]). Ops checks the inputs and converts.
use solidcore::curve::from_parameters::from_parameters;
use solidcore::geom::certified::Verdict;
use solidcore::intersect::views::{curves_from_views, views_do_not_meet};
use solidcore::nurbs::knots::{clamped_uniform, MAX_DEGREE};
use solidcore::nurbs::math::{MatrixType, RealMatrix};
use solidcore::query::continuity;
use solidcore::SolidError;
use solidcore_record::brep::Curve;
use solidcore::{SolidError, Vec3};
use solidcore_record::brep::{Curve, Invalid};
use crate::body::REGULAR_RATIO;
use crate::continuity::{tooth, CombTooth, MAX_COMB_TEETH, MIN_COMB_TEETH};
use crate::curve_knots::{net_of, record_of};
use crate::session::run;
use crate::sweep::{coord_ok, CURVE_MAX_POINTS};
use crate::{Error, Result};
@@ -85,6 +94,85 @@ fn periodic_twin(knots: &[f64], points: &[[f64; 3]], index: usize) -> Option<usi
}
}
/// `curve` under the affine `matrix`, as [`Body::transform`](crate::Body::transform) takes it:
/// rows 0-2 the images of x, y and z, row 3 the translation (`p' = p·A + t`), the fourth column
/// `(0, 0, 0, 1)`. Every control point is carried by the map
/// ([`solidcore::nurbs::crv_basic::Curve::transform`], `N_CrvTransform`); the degree, the knots
/// and the weights stay as they were. An affine map
/// commutes with the convex combinations a NURBS curve is made of, so the result is the image of
/// the curve itself, to the rounding of each carried point (none for a map whose entries are 0
/// and ±1 on a spline's points). Reflections, non-uniform scales and shears are maps like any
/// other. A spline or a rational spline, clamped or closed (a closed curve's repeated points are
/// carried alike, so it stays closed).
///
/// # Errors
/// [`Error::Argument`], before the curve is read, for a matrix the kernel does not prove regular
/// ([`solidcore::Transform::is_regular`] at [`REGULAR_RATIO`]: an entry not finite, a fourth
/// column that is not `(0, 0, 0, 1)`, a smallest scale not proved above the ratio of the
/// largest); [`Error::Argument`] for another kind of curve, one the record refuses, or an image
/// the record refuses (a coordinate carried past what a double holds); the kernel's status
/// otherwise.
pub fn transform(curve: &Curve, matrix: &[[f64; 4]; 4]) -> Result<Curve> {
let map = solidcore::Transform { m: *matrix };
if map.is_regular(REGULAR_RATIO) != Verdict::Yes {
return Err(Error::Argument);
}
let mut nurb = net_of(curve)?;
// The kernel's matrix acts on a column (`x' = r00 x + r01 y + r02 z + r03`): the record's
// row-vector matrix, its entries moved to their transposed places.
let [[a00, a01, a02, a03], [a10, a11, a12, a13], [a20, a21, a22, a23], [a30, a31, a32, a33]] =
*matrix;
let rows = vec![
vec![a00, a10, a20, a30],
vec![a01, a11, a21, a31],
vec![a02, a12, a22, a32],
vec![a03, a13, a23, a33],
];
run("curve::transform", |_| {
let columns = RealMatrix::from_rows(3, 3, rows, MatrixType::Full, 3)?;
nurb.transform(&columns)?;
Ok(record_of(&nurb))
})?
}
/// The 3D curves seen as `a` along `a_dir` and as `b` along `b_dir` (a side view and a plan
/// view, say): where `a` extruded both ways along `a_dir` meets `b` extruded along `b_dir`, far
/// enough that none of it is left out ([`curves_from_views`], whose docs give the bound). Each
/// curve is a spline within the kernel's default approximation tolerance of both extrusions.
/// The directions need not be unit vectors; `a` and `b` are splines or rational splines, open or
/// closed.
///
/// # Errors
/// [`Error::Argument`] for a direction not finite or past the coordinate bound, of no length,
/// or (within the kernel's angular zero) parallel to the other, or a curve that is not a spline
/// the record takes; [`Error::Empty`] when the views do not meet; [`Error::Record`] for a
/// meeting curve the record cannot hold; the kernel's status otherwise.
pub fn two_view(a: &Curve, a_dir: [f64; 3], b: &Curve, b_dir: [f64; 3]) -> Result<Vec<Curve>> {
if !coord_ok(&a_dir) || !coord_ok(&b_dir) {
return Err(Error::Argument);
}
let (a, b) = (net_of(a)?, net_of(b)?);
let ([ax, ay, az], [bx, by, bz]) = (a_dir, b_dir);
run("curve::two_view", |k| {
let curves =
match curves_from_views(k, &a, Vec3::new(ax, ay, az), &b, Vec3::new(bx, by, bz)) {
Ok(curves) => curves,
Err(SolidError::InvalidInput { .. }) => return Ok(Err(Error::Argument)),
Err(e) if views_do_not_meet(&e) => return Ok(Err(Error::Empty)),
Err(e) => return Err(e),
};
let mut records = Vec::new();
for c in &curves {
let made = solidcore::curve::from_parameters::record_of(k, c, &c.natural_interval())?;
match solidcore_convert::record_curve(made.curve) {
Ok(record) => records.push(record),
Err(Invalid(why)) => return Ok(Err(Error::Record(why.to_owned()))),
}
}
Ok(Ok(records))
})?
}
/// The curvature comb of `curve` over `interval` of its parameter: the kernel's curvature vector
/// at `teeth` even steps, both ends included. Stations where the curve has no tangent are left
/// out.
@@ -257,6 +345,341 @@ mod tests {
assert_eq!(points[4], SQUARE[0]);
}
/// The matrix of the reflection about the plane y = 0.
const MIRROR_Y: [[f64; 4]; 4] = [
[1.0, 0.0, 0.0, 0.0],
[0.0, -1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
#[test]
fn a_cubic_reflected_about_y_0_has_its_cvs_mirrored_exactly() {
let wave = [
[0.0, 1.0, 0.5],
[2.0, 3.0, -1.0],
[4.0, -2.0, 0.25],
[6.0, 0.5, 2.0],
[8.0, 4.0, 0.0],
];
let c = cv_curve(&wave, 3).unwrap();
let image = transform(&c, &MIRROR_Y).unwrap();
let (
Curve::Spline { knots, points },
Curve::Spline {
knots: k1,
points: p1,
},
) = (&c, &image)
else {
panic!("a spline: {image:?}")
};
assert_eq!(k1, knots);
let mirrored: Vec<[f64; 3]> = points.iter().map(|&[x, y, z]| [x, -y, z]).collect();
assert_eq!(p1, &mirrored);
// Twice is the curve again.
assert_eq!(transform(&image, &MIRROR_Y).unwrap(), c);
}
#[test]
fn a_rational_curve_keeps_its_weights_and_knots() {
// A quarter circle of radius 10 about the origin, as a rational quadratic.
let w = std::f64::consts::FRAC_1_SQRT_2;
let arc = Curve::RationalSpline {
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
homogeneous: vec![
[10.0, 0.0, 0.0, 1.0],
[10.0 * w, 10.0 * w, 0.0, w],
[0.0, 10.0, 0.0, 1.0],
],
};
let image = transform(&arc, &MIRROR_Y).unwrap();
let (
Curve::RationalSpline { knots, homogeneous },
Curve::RationalSpline {
knots: k1,
homogeneous: h1,
},
) = (&arc, &image)
else {
panic!("a rational spline: {image:?}")
};
assert_eq!(k1, knots);
for (p, q) in homogeneous.iter().zip(h1) {
assert_eq!(q[3], p[3], "the weight");
let mirrored = [p[0], -p[1], p[2]];
assert!(
q.iter().zip(mirrored).all(|(a, b)| (a - b).abs() < 1e-12),
"{q:?} against {mirrored:?}"
);
}
}
#[test]
fn a_motion_takes_rows_as_the_axes_images_and_row_3_as_the_move() {
// A quarter turn about z (x to y, y to -x), then a move by (1, 2, 3): the convention of
// `Body::transform`.
let turn = [
[0.0, 1.0, 0.0, 0.0],
[-1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[1.0, 2.0, 3.0, 1.0],
];
let image = transform(&cv_curve(&SQUARE, 3).unwrap(), &turn).unwrap();
let Curve::Spline { points, .. } = &image else {
panic!("a spline: {image:?}")
};
let moved: Vec<[f64; 3]> = SQUARE
.iter()
.map(|&[x, y, z]| [1.0 - y, 2.0 + x, 3.0 + z])
.collect();
assert_eq!(points, &moved);
}
#[test]
fn a_closed_curve_stays_closed() {
let c = crate::curve_fit::closed_cv_curve(&SQUARE, 3).unwrap();
let image = transform(&c, &MIRROR_Y).unwrap();
let (Curve::Spline { knots, .. }, Curve::Spline { knots: k1, points }) = (&c, &image)
else {
panic!("a spline: {image:?}")
};
assert_eq!(k1, knots);
// points 0, 1 and 2 are repeated as 4, 5 and 6
for (p, copy) in points.iter().zip(&points[4..]) {
assert_eq!(p, copy);
}
assert_eq!(points[1], [10.0, 0.0, 0.0]);
}
#[test]
fn a_singular_matrix_or_another_curve_is_refused() {
let c = cv_curve(&SQUARE, 3).unwrap();
let mut flat = MIRROR_Y;
flat[1][1] = 0.0;
assert_eq!(transform(&c, &flat), Err(Error::Argument));
let mut projective = MIRROR_Y;
projective[0][3] = 1.0;
assert_eq!(transform(&c, &projective), Err(Error::Argument));
let mut nan = MIRROR_Y;
nan[3][0] = f64::NAN;
assert_eq!(transform(&c, &nan), Err(Error::Argument));
let line = Curve::Line {
start: [0.0; 3],
end: [1.0; 3],
};
assert_eq!(transform(&line, &MIRROR_Y), Err(Error::Argument));
}
/// The kernel's default approximation tolerance of a traced curve: half its zone tolerance
/// of 1e-5.
const APPROX_TOL: f64 = 5e-6;
/// The side view, z = f(x) in the plane y = 0: a cubic Bézier curve whose control points
/// are evenly spaced in x, so x = 9 t; its slope |dz/dx| at most 1 (its polygon's).
fn side() -> Curve {
cv_curve(
&[
[0.0, 0.0, 0.0],
[3.0, 0.0, 2.0],
[6.0, 0.0, -1.0],
[9.0, 0.0, 1.0],
],
3,
)
.unwrap()
}
/// The plan view, y = g(x) in the plane z = 0, x = shift - 3 + 15 t; |dy/dx| at most 0.6.
fn plan(shift: f64) -> Curve {
cv_curve(
&[
[shift - 3.0, 1.0, 0.0],
[shift + 2.0, 4.0, 0.0],
[shift + 7.0, 2.0, 0.0],
[shift + 12.0, 5.0, 0.0],
],
3,
)
.unwrap()
}
/// The point of the spline `c` at `t`, by the kernel's evaluator.
fn at(c: &Curve, t: f64) -> [f64; 3] {
let p = net_of(c)
.unwrap()
.eval(t, solidcore::nurbs::knots::Side::Left)
.unwrap();
[p.x, p.y, p.z]
}
#[test]
fn a_side_and_a_plan_view_give_the_curve_on_both_extrusions() {
let (a, b) = (side(), plan(0.0));
let curves = two_view(&a, [0.0, 1.0, 0.0], &b, [0.0, 0.0, 2.0]).unwrap();
assert!(!curves.is_empty());
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &curves {
let (Curve::Spline { knots, .. } | Curve::RationalSpline { knots, .. }) = c else {
panic!("a spline: {c:?}")
};
let (t0, t1) = (knots[0], knots[knots.len() - 1]);
for i in 0..=64 {
let x = at(c, t0 + (t1 - t0) * f64::from(i) / 64.0);
assert!(
(-APPROX_TOL..=9.0 + APPROX_TOL).contains(&x[0]),
"past the side view: {x:?}"
);
(lo, hi) = (lo.min(x[0]), hi.max(x[0]));
// On the side view's extrusion along y: a gap measured along z is at most the
// distance times sqrt(1 + slope²), below 1 + slope, the slope at most 1.
let on_a = at(&a, (x[0] / 9.0).clamp(0.0, 1.0));
assert!(
(on_a[2] - x[2]).abs() <= 2.0 * APPROX_TOL,
"{x:?} against {on_a:?}"
);
// On the plan view's extrusion along z, the slope at most 0.6.
let on_b = at(&b, (x[0] + 3.0) / 15.0);
assert!(
(on_b[1] - x[1]).abs() <= 1.6 * APPROX_TOL,
"{x:?} against {on_b:?}"
);
}
}
// The whole of the side view's run is seen.
assert!(
lo <= APPROX_TOL && hi >= 9.0 - APPROX_TOL,
"from {lo} to {hi}"
);
}
/// A side view in y = 0 (seen along y) and a plan view in z = 0 (seen along z) at model
/// scale, about 170 mm across: quadratic Bézier curves, both increasing in x.
const SIDE_Q: [[f64; 3]; 3] = [
[-92.06693847750134, 0.0, -39.76423812596792],
[0.0, 0.0, 18.451591211372346],
[73.55298836314594, 0.0, 15.024361734430357],
];
const PLAN_Q: [[f64; 3]; 3] = [
[-16.758544054811637, -82.27159357644084, 0.0],
[42.459806193714414, -83.42331426400659, 0.0],
[76.37870944288886, -34.86770213654586, 0.0],
];
/// The point of the quadratic Bézier curve `q` at `t` and its derivative, in Bernstein's
/// form (none of the kernel's evaluators).
fn bernstein(q: &[[f64; 3]; 3], t: f64) -> ([f64; 3], [f64; 3]) {
let (b0, b1, b2) = ((1.0 - t) * (1.0 - t), 2.0 * t * (1.0 - t), t * t);
(
[0, 1, 2].map(|i| b0 * q[0][i] + b1 * q[1][i] + b2 * q[2][i]),
[0, 1, 2]
.map(|i| 2.0 * (1.0 - t) * (q[1][i] - q[0][i]) + 2.0 * t * (q[2][i] - q[1][i])),
)
}
/// The parameter of `q` (increasing in x) where its x is `x`, by bisection on [0, 1].
fn at_x(q: &[[f64; 3]; 3], x: f64) -> f64 {
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..100 {
let m = 0.5 * (lo + hi);
if bernstein(q, m).0[0] < x {
lo = m;
} else {
hi = m;
}
}
0.5 * (lo + hi)
}
/// The point at `t` of the record spline with `knots` and homogeneous points `pw`, by
/// de Boor's algorithm (none of the kernel's evaluators).
fn de_boor(knots: &[f64], pw: &[[f64; 4]], t: f64) -> [f64; 3] {
let n = pw.len();
let p = knots.len() - n - 1;
let k = (p..n).rev().find(|&k| knots[k] <= t).unwrap_or(p);
let mut d: Vec<[f64; 4]> = (0..=p).map(|j| pw[j + k - p]).collect();
for r in 1..=p {
for j in (r..=p).rev() {
let (lo, hi) = (knots[j + k - p], knots[j + 1 + k - r]);
let alpha = (t - lo) / (hi - lo);
d[j] = [0, 1, 2, 3].map(|i| (1.0 - alpha) * d[j - 1][i] + alpha * d[j][i]);
}
}
let h = d[p];
[h[0] / h[3], h[1] / h[3], h[2] / h[3]]
}
#[test]
fn views_at_model_scale_give_the_curve_within_the_tolerance() {
let side = cv_curve(&SIDE_Q, 2).unwrap();
let plan = cv_curve(&PLAN_Q, 2).unwrap();
let curves = two_view(&side, [0.0, 1.0, 0.0], &plan, [0.0, 0.0, 1.0]).unwrap();
assert!(!curves.is_empty());
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &curves {
let (knots, pw): (&[f64], Vec<[f64; 4]>) = match c {
Curve::Spline { knots, points } => (
knots,
points.iter().map(|&[x, y, z]| [x, y, z, 1.0]).collect(),
),
Curve::RationalSpline { knots, homogeneous } => (knots, homogeneous.clone()),
_ => panic!("a spline: {c:?}"),
};
let p = knots.len() - pw.len() - 1;
let (t0, t1) = (knots[p], knots[pw.len()]);
for i in 0..=512 {
let x = de_boor(knots, &pw, t0 + (t1 - t0) * f64::from(i) / 512.0);
(lo, hi) = (lo.min(x[0]), hi.max(x[0]));
// A gap along the view's other axis is at most the distance times
// sqrt(1 + slope²), below 1 + slope; twice the tolerance, as the intersector
// checks its spans at interior samples.
let (q, d) = bernstein(&SIDE_Q, at_x(&SIDE_Q, x[0]));
let allowed = 2.0 * APPROX_TOL * (1.0 + (d[2] / d[0]).abs());
assert!(
(q[2] - x[2]).abs() <= allowed,
"off the side view: {x:?} against {q:?}"
);
let (q, d) = bernstein(&PLAN_Q, at_x(&PLAN_Q, x[0]));
let allowed = 2.0 * APPROX_TOL * (1.0 + (d[1] / d[0]).abs());
assert!(
(q[1] - x[1]).abs() <= allowed,
"off the plan view: {x:?} against {q:?}"
);
}
}
// The overlap of the two runs is seen: from the plan view's start to the side view's end.
assert!(
(lo - PLAN_Q[0][0]).abs() <= 2.0 * APPROX_TOL
&& (hi - SIDE_Q[2][0]).abs() <= 2.0 * APPROX_TOL,
"from {lo} to {hi}"
);
}
#[test]
fn parallel_directions_are_refused_and_views_apart_are_empty() {
let (a, b) = (side(), plan(0.0));
let y = [0.0, 1.0, 0.0];
assert_eq!(two_view(&a, y, &b, [0.0, -2.0, 0.0]), Err(Error::Argument));
assert_eq!(two_view(&a, y, &b, [0.0; 3]), Err(Error::Argument));
assert_eq!(
two_view(&a, y, &b, [f64::NAN, 0.0, 1.0]),
Err(Error::Argument)
);
let line = Curve::Line {
start: [0.0; 3],
end: [1.0; 3],
};
assert_eq!(
two_view(&line, y, &b, [0.0, 0.0, 1.0]),
Err(Error::Argument)
);
// The plan view moved to x in [20, 35], past the side view's [0, 9].
assert_eq!(
two_view(&a, y, &plan(23.0), [0.0, 0.0, 1.0]),
Err(Error::Empty)
);
}
#[test]
fn an_arcs_comb_points_at_its_centre_with_the_inverse_radius() {
// A quarter circle of radius 10 about (1, 2, 3) in its z plane, as a rational quadratic.
+18 -6
View File
@@ -131,6 +131,24 @@ fn within(
/// # Errors
/// [`Error::Argument`] for another curve, one the record refuses, or knots not clamped.
pub(crate) fn nurb_of(curve: &Curve) -> Result<NurbCurve> {
let nurb = net_of(curve)?;
let (knots, degree) = (nurb.knots(), nurb.degree());
let flat =
|ends: Option<&[f64]>| ends.is_some_and(|e| e.windows(2).all(|w| w.first() == w.get(1)));
let last = knots.len().checked_sub(degree + 1);
if !flat(knots.get(..=degree)) || !flat(last.and_then(|s| knots.get(s..))) {
return Err(Error::Argument);
}
Ok(nurb)
}
/// The kernel's curve of a record spline or rational spline that is valid, on any knots the
/// record takes (clamped, or a closed curve's periodic ones): its control points and their
/// weights, its degree and knots as they are.
///
/// # Errors
/// [`Error::Argument`] for another curve, or one the record refuses.
pub(crate) fn net_of(curve: &Curve) -> Result<NurbCurve> {
check_curve(curve, &mut Budget::input()).map_err(|_| Error::Argument)?;
let (knots, pw): (&[f64], Vec<CPoint>) = match curve {
Curve::Spline { knots, points } => (
@@ -153,12 +171,6 @@ pub(crate) fn nurb_of(curve: &Curve) -> Result<NurbCurve> {
.len()
.checked_sub(pw.len() + 1)
.ok_or(Error::Argument)?;
let flat =
|ends: Option<&[f64]>| ends.is_some_and(|e| e.windows(2).all(|w| w.first() == w.get(1)));
let last = knots.len().checked_sub(degree + 1);
if !flat(knots.get(..=degree)) || !flat(last.and_then(|s| knots.get(s..))) {
return Err(Error::Argument);
}
NurbCurve::new(pw, degree, knots.to_vec()).map_err(|_| Error::Argument)
}
+2
View File
@@ -18,6 +18,7 @@
//! the kernel's entities;
//! - `finish`: the result contract, from what the input was (crate-internal: the families use it);
//! - [`provenance`]: the hook an application's construction history rides on.
//! - [`affine`]: the maps a body or a curve is moved by, from an application's numbers.
//!
//! The families: [`measure`] (mass properties, area, box, point class, gap samples, the picks'
//! distances and normals), [`contact`] (whether bodies meet, their boundary distance),
@@ -29,6 +30,7 @@
#![deny(clippy::disallowed_methods)]
#![cfg_attr(not(test), deny(clippy::float_arithmetic))]
pub mod affine;
pub mod blend;
pub mod body;
pub mod boolean;
+152 -7
View File
@@ -16,10 +16,12 @@ use solidcore::nurbs::b_spline_curve::{
BSplineCurve, BSplineCurveForm, CanonicalData, Ends, KnotType,
};
use solidcore::nurbs::b_spline_surface::{BSplineSurface, SurfParamType};
use solidcore::nurbs::n_sided::{n_sided as kernel_n_sided, NSidedRefusal, MAX_SIDES, MIN_SIDES};
use solidcore::primitive::api_curve_ops::make_curves_compatible;
use solidcore::query::api_queries::point_distance;
use solidcore::surface::surface::Surface;
use solidcore::topology::brep::p3::new_brep;
use solidcore::topology::brep::p4::stitch_faces;
use solidcore::topology::brep::sheet::face_on;
use solidcore::topology::Brep;
use solidcore::SolidError;
@@ -237,7 +239,8 @@ fn compatible(a: BSplineCurve, b: BSplineCurve) -> solidcore::Result<[BSplineCur
/// curve, point for point at the same fraction of its range (a rational curve, an arc, stays the
/// same curve but not the same parameter: the kernel gives the four common end weights). Three curves bound the same patch
/// with its fourth side collapsed to the point where the third curve meets the first (a
/// degenerate Coons patch: one side of its box is that corner). Each curve is taken over its
/// degenerate Coons patch: one side of its box is that corner). Five or more bound the
/// certified n-sided fill ([`n_sided`]), whose certificate this drops. Each curve is taken over its
/// natural range (a line from its start to its end, a spline over its knots' domain).
///
/// The ends must meet within the model's zone tolerance: the kernel's tolerance within which
@@ -248,15 +251,14 @@ fn compatible(a: BSplineCurve, b: BSplineCurve) -> solidcore::Result<[BSplineCur
///
/// # Errors
/// [`Error::Argument`] for fewer than three curves, or a curve refused (a circle, an ellipse, a
/// traced intersection curve, numbers the kernel refuses); [`Error::Unsupported`] for five or
/// more curves (no n-sided patch yet); [`Error::BoundaryGap`] for curves that do not meet; the
/// kernel's status.
/// traced intersection curve, numbers the kernel refuses); [`Error::BoundaryGap`] for curves
/// that do not meet; for five or more, those of [`n_sided`]; the kernel's status.
pub fn boundary(curves: &[Curve]) -> Result<Record> {
if curves.len() < 3 {
return Err(Error::Argument);
}
if curves.len() > 4 {
return Err(Error::Unsupported("a patch bounded by five or more curves"));
return n_sided(curves).map(|(sheet, _)| sheet);
}
record(run("surface::boundary", |k| {
let mut sides = refused!(splines(k, curves)?);
@@ -286,6 +288,66 @@ pub fn boundary(curves: &[Curve]) -> Result<Record> {
}))
}
/// The n-sided patch bounded by `curves` (three or more, meeting end to end round one loop in
/// the order given, each turned to run on from the one before, as [`boundary`] takes them): the
/// kernel's fill, N four-sided faces round a centre, each bounded by halves of two of the curves
/// and two inner curves from their middles to the centre, stitched into one sheet; and the
/// certified bound within which every curve is the sheet's boundary (`solidcore::nurbs::n_sided`).
/// The faces meet along the inner curves within the model's zone tolerance (G0, certified);
/// tangency across them is the fill's aim, within its angle tolerance, not certified.
///
/// # Errors
/// As [`boundary`] for the curves and their joins; [`Error::Unsupported`] for a rational curve
/// (an arc: the fill takes polynomial curves) or more than 64 curves; [`Error::PastTolerance`]
/// for a boundary or an inner join not proved within the zone tolerance; the kernel's status.
pub fn n_sided(curves: &[Curve]) -> Result<(Record, f64)> {
if curves.len() < MIN_SIDES {
return Err(Error::Argument);
}
if curves.len() > MAX_SIDES {
return Err(Error::Unsupported(
"an n-sided patch of more than 64 curves",
));
}
let mut bound = None;
let made = run("surface::n_sided", |k| {
let mut sides = refused!(splines(k, curves)?);
let tol = context_zone_tol_3d(k, None, SmallTopoType::NormalSize);
refused!(looped(&mut sides, tol)?);
match kernel_n_sided(&sides, tol)? {
Ok(fill) => {
bound = Some(fill.bound);
sheet_of(k, fill.patches)
}
Err(NSidedRefusal::Rational) => Ok(Err(Error::Unsupported(
"a rational curve bounding an n-sided patch",
))),
Err(NSidedRefusal::PastTolerance { bound }) => Ok(Err(Error::PastTolerance { bound })),
Err(NSidedRefusal::Joins { gap }) => Ok(Err(Error::PastTolerance { bound: gap })),
}
});
let sheet = record(made)?;
Ok((sheet, bound.ok_or(Error::Exception)?))
}
/// A sheet of one face on each of `patches`, over its whole natural box, stitched along the
/// sides they share.
fn sheet_of(k: &mut Kernel, patches: Vec<BSplineSurface>) -> solidcore::Result<Out<Brep>> {
let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
for s in patches {
let s = Surface::from(s);
let bx = s.natural_uv_domain_with_nurb(k)?;
face_on(k, &mut b, s, &bx)?;
}
b.refresh_cached_topology();
let tol = b.tolerance();
if let Err(e) = stitch_faces(k, &mut b, tol, None, false) {
return Ok(Err(status(e)));
}
b.refresh_cached_topology();
finish(k, b, Expect::FACES)
}
/// The ruled surface between `a` and `b`, one face: `S(s, t) = (1 - t) a(s) + t b(s)`, `s` the
/// same fraction of each curve's natural range (the two first made compatible, exactly, as in
/// [`boundary`]), `t` from 0 on `a` to 1 on `b`. So the surface's sides are the two curves
@@ -561,7 +623,7 @@ mod tests {
}
#[test]
fn too_few_or_too_many_curves_and_circles_are_refused() {
fn too_few_curves_and_circles_are_refused_and_five_fill() {
assert_eq!(
boundary(&[line(P[0], P[1]), line(P[1], P[0])]),
Err(Error::Argument)
@@ -576,7 +638,8 @@ mod tests {
let pentagon: Vec<Curve> = (0..5)
.map(|i| line(corners[i], corners[(i + 1) % 5]))
.collect();
assert!(matches!(boundary(&pentagon), Err(Error::Unsupported(_))));
// five curves: the n-sided fill, five faces
assert_eq!(boundary(&pentagon).unwrap().faces.len(), 5);
let circle = Curve::Circle {
frame: Frame {
origin: [0.0; 3],
@@ -590,4 +653,86 @@ mod tests {
Err(Error::Argument)
);
}
/// The corners of a regular pentagon of radius 10 about the z axis, at height `z(x, y)`.
fn pentagon(z: impl Fn(f64, f64) -> f64) -> Vec<[f64; 3]> {
(0..5)
.map(|i| {
let a = std::f64::consts::TAU * f64::from(i) / 5.0;
let (x, y) = (10.0 * a.cos(), 10.0 * a.sin());
[x, y, z(x, y)]
})
.collect()
}
#[test]
fn a_regular_pentagon_of_lines_fills_flat() {
let p = pentagon(|_, _| 0.0);
let sides: Vec<Curve> = (0..5).map(|i| line(p[i], p[(i + 1) % 5])).collect();
let (sheet, bound) = n_sided(&sides).unwrap();
assert!(bound <= 1e-9, "{bound}");
assert_eq!(sheet.faces.len(), 5);
// five faces round a centre, stitched along the five inner curves
assert_eq!(sheet.edges.iter().filter(|e| e.uses.len() == 2).count(), 5);
// planar data, planar faces: every point of every face in z = 0
for face in &sheet.faces {
for (s, t) in [(0.0, 0.0), (0.3, 0.6), (1.0, 0.5), (0.5, 1.0)] {
assert!(at(face, s, t)[2].abs() < 1e-9);
}
}
}
#[test]
fn five_splines_on_a_saddle_bound_their_patch() {
// each side a cubic whose control points lie on z = x y / 50 at thirds of the chord
let z = |x: f64, y: f64| x * y / 50.0;
let p = pentagon(z);
let sides: Vec<Curve> = (0..5)
.map(|i| {
let (a, b) = (p[i], p[(i + 1) % 5]);
let third = |t: f64| {
let (x, y) = (a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]));
[x, y, z(x, y)]
};
Curve::Spline {
knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0],
points: vec![a, third(1.0 / 3.0), third(2.0 / 3.0), b],
}
})
.collect();
let (sheet, bound) = n_sided(&sides).unwrap();
assert!(bound <= 1e-6, "{bound}");
assert_eq!(sheet.faces.len(), 5);
// the corners are vertices of the sheet: its boundary runs through them
for corner in &p {
assert!(
sheet.vertices.iter().any(|v| near(v.point, *corner, 1e-6)),
"{corner:?}"
);
}
}
#[test]
fn five_arcs_on_a_sphere_are_refused() {
// five arcs of the circle z = 6 on the sphere of radius 10 (radius 8), as rational
// quadratics
let half = std::f64::consts::PI / 5.0;
let w = half.cos();
let arcs: Vec<Curve> = (0..5)
.map(|i| {
let a = 2.0 * half * f64::from(i);
let on = |t: f64, r: f64| [r * t.cos(), r * t.sin(), 6.0];
let (s, m, e) = (on(a, 8.0), on(a + half, 8.0 / w), on(a + 2.0 * half, 8.0));
Curve::RationalSpline {
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
homogeneous: vec![
[s[0], s[1], s[2], 1.0],
[m[0] * w, m[1] * w, m[2] * w, w],
[e[0], e[1], e[2], 1.0],
],
}
})
.collect();
assert!(matches!(n_sided(&arcs), Err(Error::Unsupported(_))));
}
}
+301
View File
@@ -79,6 +79,147 @@ pub fn move_cvs(sheet: &Brep, moves: &[((usize, usize), [f64; 3])]) -> Result<Br
}))
}
/// What [`move_net`] moves: whole rows (u indices), whole columns (v indices) or single control
/// points `(i, j)`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Picks {
/// Every point of these u indices.
Rows(Vec<usize>),
/// Every point of these v indices.
Columns(Vec<usize>),
/// These points, `(u index, v index)`.
Cvs(Vec<(usize, usize)>),
}
/// The shape of a soft selection's falloff over the index distance `d` from the picks, with
/// `s = d / (radius + 1)`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Shape {
/// `1 - s`.
Linear,
/// `1 - (3 s² - 2 s³)`: flat at the picks and at the radius's edge.
Smooth,
}
/// A soft selection: the points within `radius` index steps of the picks move too, by the
/// weight [`Shape`] gives their distance.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Falloff {
/// The most index steps from a pick a point moves at: 0 moves the picks alone.
pub radius: u32,
/// How the weight falls from 1 at the picks.
pub shape: Shape,
}
/// The weight of a point `d` index steps from the nearest pick: 1 at a pick, falling by
/// `falloff`'s shape, 0 past its radius (and for every point off the picks without a falloff).
#[must_use]
pub fn falloff_weight(falloff: Option<Falloff>, d: usize) -> f64 {
let Some(f) = falloff else {
return if d == 0 { 1.0 } else { 0.0 };
};
let Ok(d) = u32::try_from(d) else {
return 0.0;
};
if d > f.radius {
return 0.0;
}
let s = f64::from(d) / (f64::from(f.radius) + 1.0);
match f.shape {
Shape::Linear => 1.0 - s,
Shape::Smooth => 1.0 - s * s * (3.0 - 2.0 * s),
}
}
/// The weight table of a `rows` by `cols` net (row by row, as [`Net::points`]): each point's
/// [`falloff_weight`] at its index distance from the nearest pick. A row pick's distance is
/// along u (`|i - row|`), a column pick's along v (`|j - column|`), a point pick's the larger of
/// the two (the square ring of the net around it).
///
/// # Errors
/// [`Error::Argument`] for no picks, a pick past the net, or a net of no points or more than
/// [`REBUILD_MAX_SAMPLES`].
pub fn net_weights(
rows: usize,
cols: usize,
picks: &Picks,
falloff: Option<Falloff>,
) -> Result<Vec<f64>> {
let total = rows
.checked_mul(cols)
.filter(|&n| n > 0 && n <= REBUILD_MAX_SAMPLES)
.ok_or(Error::Argument)?;
let (empty, inside) = match picks {
Picks::Rows(p) => (p.is_empty(), p.iter().all(|&i| i < rows)),
Picks::Columns(p) => (p.is_empty(), p.iter().all(|&j| j < cols)),
Picks::Cvs(p) => (p.is_empty(), p.iter().all(|&(i, j)| i < rows && j < cols)),
};
if empty || !inside {
return Err(Error::Argument);
}
let nearest = |i: usize, j: usize| -> usize {
let d = match picks {
Picks::Rows(p) => p.iter().map(|&r| i.abs_diff(r)).min(),
Picks::Columns(p) => p.iter().map(|&c| j.abs_diff(c)).min(),
Picks::Cvs(p) => p
.iter()
.map(|&(a, b)| i.abs_diff(a).max(j.abs_diff(b)))
.min(),
};
d.unwrap_or(usize::MAX)
};
let mut table = Vec::new();
table
.try_reserve_exact(total)
.map_err(|_| Error::Argument)?;
for i in 0..rows {
for j in 0..cols {
table.push(falloff_weight(falloff, nearest(i, j)));
}
}
Ok(table)
}
/// `sheet` with the picked control points moved by `delta`, and with a `falloff` the points
/// around them by their weight of it ([`net_weights`]): the kernel moves each point of weight
/// `t` by `t delta` (`Surface::displace_cpts`). The knots and degrees are kept; the result is a
/// new one-face sheet on the moved surface, its whole box.
///
/// # Errors
/// As [`control_net`] and [`net_weights`]; [`Error::Argument`] for a `delta` not finite or
/// past the coordinate bound, or a moved point past it; the kernel's status otherwise.
pub fn move_net(
sheet: &Brep,
picks: &Picks,
delta: [f64; 3],
falloff: Option<Falloff>,
) -> Result<Brep> {
if !coord_ok(&delta) {
return Err(Error::Argument);
}
let ((u, v), points) = untrimmed(sheet)?;
let net = control_net(sheet)?;
let weights = net_weights(net.rows, net.cols, picks, falloff)?;
let moves: Vec<((usize, usize), f64)> = (0..net.rows)
.flat_map(|i| (0..net.cols).map(move |j| (i, j)))
.zip(weights)
.filter(|&(_, t)| t > 0.0)
.collect();
let mut surface = kernel_surface(u, v, points)?;
let moved = record(run("surface_cvs::move_net", |k| {
surface.displace_cpts(&moves, Vec3::new(delta[0], delta[1], delta[2]))?;
sheet_of(k, BSplineSurface::from_gw_nurb(&surface))
}))?;
if !control_net(&moved)?
.points
.iter()
.all(|p| coord_ok(p.as_slice()))
{
return Err(Error::Argument);
}
Ok(moved)
}
/// `sheet`'s surface refitted to degrees `(deg_u, deg_v)` with `(spans_u, spans_v)` uniform
/// spans on `[0, 1]²`, and the kernel's certified bound of how far the refit is from the
/// surface at every parameter ([`certified_distance`]), kept when within `tol`. The surface,
@@ -374,6 +515,166 @@ mod tests {
);
}
#[test]
fn a_row_move_moves_exactly_that_row() {
let s = long_sheet();
let net = control_net(&s).unwrap();
let delta = [0.0, 0.0, 1.0];
let last = net.rows - 1;
for falloff in [
None,
Some(Falloff {
radius: 0,
shape: Shape::Smooth,
}),
] {
let moved = move_net(&s, &Picks::Rows(vec![last]), delta, falloff).unwrap();
let after = control_net(&moved).unwrap();
assert_eq!((after.rows, after.cols), (net.rows, net.cols));
for (n, (p, q)) in net.points.iter().zip(&after.points).enumerate() {
if n / net.cols == last {
assert_eq!(*q, [p[0], p[1], p[2] + 1.0], "point {n}");
} else {
assert_eq!(p, q, "point {n}");
}
}
}
// a column the same way
let moved = move_net(&s, &Picks::Columns(vec![0]), delta, None).unwrap();
let after = control_net(&moved).unwrap();
for (n, (p, q)) in net.points.iter().zip(&after.points).enumerate() {
if n % net.cols == 0 {
assert_eq!(*q, [p[0], p[1], p[2] + 1.0], "point {n}");
} else {
assert_eq!(p, q, "point {n}");
}
}
}
#[test]
fn falloff_weights_are_one_at_the_pick_zero_past_the_radius_and_monotone() {
assert_eq!(falloff_weight(None, 0), 1.0);
assert_eq!(falloff_weight(None, 1), 0.0);
for shape in [Shape::Linear, Shape::Smooth] {
let f = Some(Falloff { radius: 3, shape });
let w: Vec<f64> = (0..8).map(|d| falloff_weight(f, d)).collect();
assert_eq!(w[0], 1.0, "{shape:?}");
assert!(
w[1..=3].iter().all(|&x| x > 0.0 && x < 1.0),
"{shape:?}: {w:?}"
);
assert!(w[4..].iter().all(|&x| x == 0.0), "{shape:?}: {w:?}");
assert!(
w.windows(2).all(|p| p[1] < p[0] || p[1] == 0.0),
"{shape:?}: {w:?}"
);
// the table: a point pick's distance is its square ring on the net
let table = net_weights(9, 7, &Picks::Cvs(vec![(4, 3)]), f).unwrap();
assert_eq!(table.len(), 63);
for i in 0..9_usize {
for j in 0..7_usize {
let d = i.abs_diff(4).max(j.abs_diff(3));
assert_eq!(table[i * 7 + j], w[d], "{shape:?} at ({i}, {j})");
}
}
}
// the smooth shape is flat at the pick: it falls less over the first step
let (linear, smooth) = (
falloff_weight(
Some(Falloff {
radius: 3,
shape: Shape::Linear,
}),
1,
),
falloff_weight(
Some(Falloff {
radius: 3,
shape: Shape::Smooth,
}),
1,
),
);
assert!(smooth > linear, "{smooth} {linear}");
// rows and columns by their one index
let rows = net_weights(
5,
3,
&Picks::Rows(vec![0]),
Some(Falloff {
radius: 1,
shape: Shape::Linear,
}),
)
.unwrap();
assert_eq!(
rows,
vec![1.0, 1.0, 1.0, 0.5, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
);
// bad picks
assert_eq!(
net_weights(5, 3, &Picks::Rows(vec![]), None),
Err(Error::Argument)
);
assert_eq!(
net_weights(5, 3, &Picks::Rows(vec![5]), None),
Err(Error::Argument)
);
assert_eq!(
net_weights(5, 3, &Picks::Columns(vec![3]), None),
Err(Error::Argument)
);
assert_eq!(
net_weights(5, 3, &Picks::Cvs(vec![(0, 3)]), None),
Err(Error::Argument)
);
assert_eq!(
net_weights(0, 3, &Picks::Rows(vec![0]), None),
Err(Error::Argument)
);
}
#[test]
fn a_soft_move_moves_each_point_by_its_weight() {
let s = bicubic();
let net = control_net(&s).unwrap();
let f = Some(Falloff {
radius: 1,
shape: Shape::Linear,
});
let picks = Picks::Cvs(vec![(1, 1)]);
let moved = move_net(&s, &picks, [0.0, 0.0, 2.0], f).unwrap();
let after = control_net(&moved).unwrap();
let weights = net_weights(net.rows, net.cols, &picks, f).unwrap();
for (n, ((p, q), w)) in net
.points
.iter()
.zip(&after.points)
.zip(&weights)
.enumerate()
{
assert_eq!((q[0], q[1]), (p[0], p[1]), "point {n}");
assert!(
(q[2] - (p[2] + 2.0 * w)).abs() <= 1e-15 * (1.0 + p[2].abs()),
"point {n}: {p:?} to {q:?}, weight {w}"
);
}
// the pick by the whole delta, its ring by half, the rest not at all
assert_eq!(weights[5], 1.0);
assert_eq!(weights[0], 0.5);
assert_eq!(weights[3 * 4 + 3], 0.0);
assert_eq!(after.points[15], net.points[15]);
// bad moves
assert_eq!(
move_net(&s, &picks, [f64::NAN, 0.0, 0.0], f),
Err(Error::Argument)
);
assert_eq!(
move_net(&s, &Picks::Cvs(vec![(4, 0)]), [0.0; 3], f),
Err(Error::Argument)
);
}
/// The Coons sheet of four bent cubic Bézier sides: a bicubic Bézier patch.
fn bicubic() -> Brep {
let side = |points: [[f64; 3]; 4]| Curve::Spline {
+1 -1
View File
@@ -427,7 +427,7 @@ pub fn record_of(k: &Kernel, c: &Curve, interval: &Extent1d) -> Result<RecordCur
/// The B-spline a curve is (every kernel line, conic and spline is one, its NURB made on a
/// copy where it has none yet); `None` for the curves that are not.
fn b_spline_part<'c>(k: &Kernel, c: &'c Curve) -> Result<Option<Cow<'c, BSplineCurve>>> {
pub(crate) fn b_spline_part<'c>(k: &Kernel, c: &'c Curve) -> Result<Option<Cow<'c, BSplineCurve>>> {
use crate::curve::curve::CurveKind;
Ok(Some(match c.kind() {
CurveKind::BSpline(b) => Cow::Borrowed(b),
+1
View File
@@ -16,3 +16,4 @@ pub mod surface_intersector;
pub mod surface_silhouette;
pub mod surface_tracer;
pub mod topology_solver;
pub mod views;
@@ -7,6 +7,7 @@
//! |-------------------------------------------------------|--------------------------------|
//! | [`intersect_curves`], [`CurveIntersections`] | `SmApiIntersectCurves` |
//! | [`intersect_surfaces`] | `SmApiIntersectSurfaces` |
//! | [`intersect_surfaces_within`] | (not upstream) |
//! | [`intersect_faces`], [`FaceBodies`], [`Intersections`] | `SmApiIntersectFaces` |
//! | [`intersect_breps`], [`BrepOperands`] | `SmApiIntersectBreps` |
//! | [`intersect_curve_surface`], [`CurveHits`] | `SmApiIntersectCurveSurface` |
@@ -69,6 +70,9 @@ use crate::topology::FaceId;
const OP: &str = "intersect::api_intersectors::intersect_brep_with_plane";
const OP_CURVES: &str = "intersect::api_intersectors::intersect_curves";
const OP_SURFACES: &str = "intersect::api_intersectors::intersect_surfaces";
/// The phrase of [`intersect_surfaces`]'s failure when the surfaces do not meet at all.
pub(crate) const NO_SURFACE_INTERSECTION: &str = "the surfaces do not intersect";
const OP_BREPS: &str = "intersect::api_intersectors::intersect_breps";
const OP_CURVE_SURFACE: &str = "intersect::api_intersectors::intersect_curve_surface";
const OP_CURVE_FACE: &str = "intersect::api_intersectors::intersect_curve_face";
@@ -177,6 +181,23 @@ pub fn intersect_surfaces(
k: &Kernel,
surface1: &Surface,
surface2: &Surface,
) -> (Vec<Curve>, Result<()>) {
intersect_surfaces_within(k, surface1, surface2, None)
}
/// [`intersect_surfaces`] traced to `approx_tol_3d` (`GlobalSurfaceIntersect`'s
/// approximation tolerance; `None`: the intersector's default, a thousandth of the size
/// of the surfaces' domains, which on large surfaces is far coarser than the kernel's
/// tolerances). Not upstream: `SmApiIntersectSurfaces` takes no tolerance.
///
/// # Errors
///
/// In the status: those of [`intersect_surfaces`].
pub fn intersect_surfaces_within(
k: &Kernel,
surface1: &Surface,
surface2: &Surface,
approx_tol_3d: Option<f64>,
) -> (Vec<Curve>, Result<()>) {
let domains = surface1
.natural_uv_domain_with_nurb(k)
@@ -191,7 +212,7 @@ pub fn intersect_surfaces(
surface2,
&domain2,
[true, true],
None,
approx_tol_3d,
None,
);
// check result
@@ -199,7 +220,7 @@ pub fn intersect_surfaces(
if result.curves_3d.is_empty() {
Err(SolidError::intersect_failed(
OP_SURFACES,
"the surfaces do not intersect",
NO_SURFACE_INTERSECTION,
))
} else {
Ok(())
+395
View File
@@ -0,0 +1,395 @@
//! A curve from two views: the 3D curves seen as one curve along one direction and as another
//! along a second, where the two curves' extrusions along their directions meet.
//!
//! Upstream: none.
//!
//! | Rust | Upstream |
//! |-------------------------|-----------------|
//! | [`curves_from_views`] | (not upstream) |
//! | [`views_do_not_meet`] | (not upstream) |
//!
//! Each curve is extruded along its unit direction both ways from itself, far enough that no
//! meeting point is left out, and the two extrusions are intersected
//! ([`intersect_surfaces_within`]) at the kernel's default approximation tolerance
//! ([`approx_tol3d`] of [`USE_DEFAULT`]: half its zone tolerance). The intersector's own default,
//! a thousandth of the surfaces' size, is not taken: these sheets reach far past the curves, so
//! at model scale it is a thousand times coarser than the curves need.
//!
//! How far is a bound, not a search: a point `X` seen on both curves is
//! `X = p + s â = q + t b̂` with `p` on the first curve and `q` on the second. Its component
//! across `b̂` gives `s (â - (â·b̂) b̂) = (q - p) - ((q - p)·b̂) b̂`, so `|s| sin θ <= |q - p| <= D`,
//! with `θ` the angle between the directions and `D` the diagonal of the box around both
//! curves' control polygons (a NURBS curve with positive weights lies in the convex hull of its
//! control points, so in that box); `|t|` likewise. Each extrusion
//! reaches [`REACH_MARGIN`] times `D / sin θ` on either side, so the meeting curves end on the
//! curves' own ends, never on an extrusion's far edge.
//!
//! Deviations: none (no upstream counterpart). No recursion.
#![allow(clippy::neg_cmp_op_on_partial_ord)] // NaN-refusing tests
use crate::curve::curve::Curve;
use crate::curve::from_parameters::b_spline_part;
use crate::error::intersect::IntersectFailure;
use crate::error::{Result, SolidError};
use crate::geom::tol::{approx_tol3d, USE_DEFAULT};
use crate::geom::vector3d::Vec3;
use crate::intersect::api_intersectors::{intersect_surfaces_within, NO_SURFACE_INTERSECTION};
use crate::kernel::Kernel;
use crate::math::EFF_ZERO_RAD;
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::nurbs::crv_basic::Curve as NurbCurve;
use crate::nurbs::geom::MinMaxBox;
use crate::surface::surface::Surface;
use crate::sweep::api_surfaces::create_extrude;
const OP: &str = "intersect::views::curves_from_views";
/// The phrase of [`curves_from_views`]'s failure when the two views have no point in common
/// (read with [`views_do_not_meet`]).
pub const NO_MEETING: &str = "the views do not meet";
/// How many times the bound on a meeting point's distance from its curve each extrusion
/// reaches on either side of the curve: past the bound by as much again, so that no meeting
/// curve ends on an extrusion's far edge.
pub const REACH_MARGIN: f64 = 2.0;
/// The 3D curves seen as `a` along `a_dir` and as `b` along `b_dir`: where `a` extruded along
/// `a_dir` meets `b` extruded along `b_dir`, both ways from each curve (the module docs give
/// how far). Each curve is returned as a B-spline, as the intersector traces it to the kernel's
/// default approximation tolerance ([`approx_tol3d`] of [`USE_DEFAULT`]) of both extrusions: each
/// of its spans checked at interior samples against both (docs/ssi.md, §4.3), not certified.
/// The directions need not be
/// unit vectors, nor the curves planar: a curve that is not flat across its direction is seen
/// as its shadow along it.
///
/// # Errors
///
/// `InvalidInput` for a direction not finite or shorter than the kernel's zero, directions
/// within [`EFF_ZERO_RAD`] of parallel (either way), or curves whose box has no size or one
/// not finite; [`crate::SolidError::Failed`] with [`IntersectFailure::Failed`] of
/// [`NO_MEETING`] when the extrusions do not meet ([`views_do_not_meet`]); `Unsupported` for a
/// meeting curve with no B-spline form; those of the extrusions and of
/// [`intersect_surfaces_within`].
pub fn curves_from_views(
k: &Kernel,
a: &NurbCurve,
a_dir: Vec3,
b: &NurbCurve,
b_dir: Vec3,
) -> Result<Vec<Curve>> {
let finite = |v: Vec3| v.x.is_finite() && v.y.is_finite() && v.z.is_finite();
if !finite(a_dir) || !finite(b_dir) {
return Err(SolidError::invalid_input(OP, "a direction not finite"));
}
let (a_unit, b_unit) = (a_dir.unitize()?, b_dir.unitize()?);
let sine = a_unit.cross(b_unit).length();
if !(sine >= EFF_ZERO_RAD) {
return Err(SolidError::invalid_input(OP, "the directions are parallel"));
}
let span = union(&a.bbox()?, &b.bbox()?).diagonal();
if !(span.is_finite() && span > 0.0) {
return Err(SolidError::invalid_input(
OP,
"the curves' box has no size or is not finite",
));
}
let reach = REACH_MARGIN * span / sine;
if !reach.is_finite() {
return Err(SolidError::invalid_input(
OP,
"the extrusions' reach is not finite",
));
}
let a_sweep = both_ways(a, a_unit, reach)?;
let b_sweep = both_ways(b, b_unit, reach)?;
let tol = approx_tol3d(k, USE_DEFAULT);
let (curves, status) = intersect_surfaces_within(k, &a_sweep, &b_sweep, Some(tol));
match status {
Ok(()) => curves.iter().map(|c| spline(k, c)).collect(),
Err(e)
if curves.is_empty()
&& e.intersect_failure()
== Some(&IntersectFailure::Failed(NO_SURFACE_INTERSECTION)) =>
{
Err(SolidError::intersect_failed(OP, NO_MEETING))
}
Err(e) => Err(e),
}
}
/// Whether `e` is [`curves_from_views`]'s refusal of two views that do not meet.
#[must_use]
pub fn views_do_not_meet(e: &SolidError) -> bool {
e.intersect_failure() == Some(&IntersectFailure::Failed(NO_MEETING))
}
/// The smallest box holding both boxes.
fn union(a: &MinMaxBox, b: &MinMaxBox) -> MinMaxBox {
MinMaxBox::new(
a.xl.min(b.xl),
a.xr.max(b.xr),
a.yb.min(b.yb),
a.yt.max(b.yt),
a.zn.min(b.zn),
a.zf.max(b.zf),
)
}
/// `curve` extruded along the unit vector `unit` from `-reach` to `reach`: a copy moved back by
/// `reach`, swept forward by twice it ([`create_extrude`]: linear in u, the curve's parameter
/// in v).
fn both_ways(curve: &NurbCurve, unit: Vec3, reach: f64) -> Result<Surface> {
let mut start = curve.clone();
start.translate(unit * -reach);
let mut start = BSplineCurve::from_nurb(start, 3, false);
Ok(Surface::from(create_extrude(
&mut start,
unit * (2.0 * reach),
)?))
}
/// The meeting curve `c` as a B-spline ([`b_spline_part`]: a traced curve is one already, a
/// line made of its two points).
fn spline(k: &Kernel, c: &Curve) -> Result<Curve> {
let base = b_spline_part(k, c)?.ok_or(SolidError::Unsupported {
operation: OP,
reason: "a meeting curve with no B-spline form",
})?;
Ok(Curve::from(base.into_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nurbs::knots::Side;
use crate::nurbs::math::{CPoint, NOW};
/// The cubic Bézier curve on `points`.
fn bezier(points: [[f64; 3]; 4]) -> NurbCurve {
let pw = points
.iter()
.map(|&[x, y, z]| CPoint::new(x, y, z, NOW))
.collect();
NurbCurve::new(pw, 3, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).unwrap()
}
/// The side view, z = f(x) in the plane y = 0, its control points evenly spaced in x so
/// that x = 9 t; its slope |dz/dx| is at most 1 (that of its control polygon).
fn side() -> NurbCurve {
bezier([
[0.0, 0.0, 0.0],
[3.0, 0.0, 2.0],
[6.0, 0.0, -1.0],
[9.0, 0.0, 1.0],
])
}
/// The plan view, y = g(x) in the plane z = 0, x = -3 + 15 t (wider than the side view, so
/// the curve seen in both ends on the side view's ends); |dy/dx| at most 0.6.
fn plan(shift: f64) -> NurbCurve {
bezier([
[shift - 3.0, 1.0, 0.0],
[shift + 2.0, 4.0, 0.0],
[shift + 7.0, 2.0, 0.0],
[shift + 12.0, 5.0, 0.0],
])
}
#[test]
fn a_side_and_a_plan_view_meet_in_the_curve_seen_in_both() {
let k = Kernel::default();
let tol = approx_tol3d(&k, USE_DEFAULT);
let (a, b) = (side(), plan(0.0));
let curves = curves_from_views(
&k,
&a,
Vec3::new(0.0, 1.0, 0.0),
&b,
Vec3::new(0.0, 0.0, 3.0),
)
.unwrap();
assert!(!curves.is_empty());
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &curves {
let iv = c.natural_interval();
for i in 0..=64 {
let s = iv.min() + (iv.max() - iv.min()) * f64::from(i) / 64.0;
let x = c.evaluate_point(&k, s).unwrap();
assert!(
(-tol..=9.0 + tol).contains(&x.x),
"past the side view: {x:?}"
);
(lo, hi) = (lo.min(x.x), hi.max(x.x));
// On the side view's extrusion along y: its height there is the side view's.
// A gap measured along z is at most the distance times sqrt(1 + slope²),
// below 1 + slope, the slope at most 1.
let on_a = a.eval((x.x / 9.0).clamp(0.0, 1.0), Side::Left).unwrap();
assert!((on_a.z - x.z).abs() <= 2.0 * tol, "{x:?} against {on_a:?}");
// On the plan view's extrusion along z, the slope at most 0.6.
let on_b = b.eval((x.x + 3.0) / 15.0, Side::Left).unwrap();
assert!((on_b.y - x.y).abs() <= 1.6 * tol, "{x:?} against {on_b:?}");
}
}
// The whole of the side view's run is seen.
assert!(lo <= tol && hi >= 9.0 - tol, "from {lo} to {hi}");
}
/// A side view in y = 0 (seen along y) and a plan view in z = 0 (seen along z) at model
/// scale, about 170 mm across: quadratic Bézier curves, both increasing in x.
const SIDE_Q: [[f64; 3]; 3] = [
[-92.06693847750134, 0.0, -39.76423812596792],
[0.0, 0.0, 18.451591211372346],
[73.55298836314594, 0.0, 15.024361734430357],
];
const PLAN_Q: [[f64; 3]; 3] = [
[-16.758544054811637, -82.27159357644084, 0.0],
[42.459806193714414, -83.42331426400659, 0.0],
[76.37870944288886, -34.86770213654586, 0.0],
];
/// The quadratic Bézier curve on `q`.
fn quadratic(q: [[f64; 3]; 3]) -> NurbCurve {
let pw = q
.iter()
.map(|&[x, y, z]| CPoint::new(x, y, z, NOW))
.collect();
NurbCurve::new(pw, 2, vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]).unwrap()
}
/// The point of the quadratic Bézier curve `q` at `t`, in Bernstein's form (none of the
/// kernel's evaluators).
fn bernstein(q: &[[f64; 3]; 3], t: f64) -> [f64; 3] {
let (b0, b1, b2) = ((1.0 - t) * (1.0 - t), 2.0 * t * (1.0 - t), t * t);
[0, 1, 2].map(|i| b0 * q[0][i] + b1 * q[1][i] + b2 * q[2][i])
}
/// Its derivative at `t`.
fn bernstein_d(q: &[[f64; 3]; 3], t: f64) -> [f64; 3] {
[0, 1, 2].map(|i| 2.0 * (1.0 - t) * (q[1][i] - q[0][i]) + 2.0 * t * (q[2][i] - q[1][i]))
}
/// The parameter of `q` (increasing in x) where its x is `x`, by bisection on [0, 1].
fn at_x(q: &[[f64; 3]; 3], x: f64) -> f64 {
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..100 {
let m = 0.5 * (lo + hi);
if bernstein(q, m)[0] < x {
lo = m;
} else {
hi = m;
}
}
0.5 * (lo + hi)
}
/// The point at `t` of the B-spline with the full knot vector `knots` and the homogeneous
/// control points `pw`, by de Boor's algorithm (none of the kernel's evaluators).
fn de_boor(knots: &[f64], pw: &[[f64; 4]], t: f64) -> [f64; 3] {
let n = pw.len();
let p = knots.len() - n - 1;
let k = (p..n).rev().find(|&k| knots[k] <= t).unwrap_or(p);
let mut d: Vec<[f64; 4]> = (0..=p).map(|j| pw[j + k - p]).collect();
for r in 1..=p {
for j in (r..=p).rev() {
let (lo, hi) = (knots[j + k - p], knots[j + 1 + k - r]);
let alpha = (t - lo) / (hi - lo);
d[j] = [0, 1, 2, 3].map(|i| (1.0 - alpha) * d[j - 1][i] + alpha * d[j][i]);
}
}
let h = d[p];
[h[0] / h[3], h[1] / h[3], h[2] / h[3]]
}
/// The full knot vector and homogeneous control points of a meeting curve (a B-spline).
fn net(c: &Curve) -> (Vec<f64>, Vec<[f64; 4]>) {
let crate::curve::curve::CurveKind::BSpline(b) = c.kind() else {
panic!("a B-spline: {c:?}")
};
let can = b.canonical().unwrap();
let knots = can
.knots
.iter()
.zip(&can.knot_multiplicities)
.flat_map(|(k, m)| std::iter::repeat_n(*k, *m))
.collect();
let pw = can
.control_points
.iter()
.enumerate()
.map(|(i, p)| {
let w = can.weights.get(i).copied().unwrap_or(1.0);
[p.x * w, p.y * w, p.z * w, w]
})
.collect();
(knots, pw)
}
#[test]
fn views_at_model_scale_meet_within_the_tolerance() {
// The intersector's own default tolerance, a thousandth of these sheets' size, left
// this curve 5.75e-3 off its views; it is traced to the kernel's tolerance.
let k = Kernel::default();
let tol = approx_tol3d(&k, USE_DEFAULT);
let (a, b) = (quadratic(SIDE_Q), quadratic(PLAN_Q));
let curves = curves_from_views(&k, &a, Vec3::Y, &b, Vec3::Z).unwrap();
assert!(!curves.is_empty());
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &curves {
let (knots, pw) = net(c);
let p = knots.len() - pw.len() - 1;
let (t0, t1) = (knots[p], knots[pw.len()]);
for i in 0..=512 {
let x = de_boor(&knots, &pw, t0 + (t1 - t0) * f64::from(i) / 512.0);
(lo, hi) = (lo.min(x[0]), hi.max(x[0]));
// A gap along the view's other axis is at most the distance times
// sqrt(1 + slope²), below 1 + slope; twice the tolerance, as the intersector
// checks its spans at interior samples.
let s = at_x(&SIDE_Q, x[0]);
let (q, d) = (bernstein(&SIDE_Q, s), bernstein_d(&SIDE_Q, s));
let allowed = 2.0 * tol * (1.0 + (d[2] / d[0]).abs());
assert!(
(q[2] - x[2]).abs() <= allowed,
"off the side view: {x:?} against {q:?}"
);
let s = at_x(&PLAN_Q, x[0]);
let (q, d) = (bernstein(&PLAN_Q, s), bernstein_d(&PLAN_Q, s));
let allowed = 2.0 * tol * (1.0 + (d[1] / d[0]).abs());
assert!(
(q[1] - x[1]).abs() <= allowed,
"off the plan view: {x:?} against {q:?}"
);
}
}
// The overlap of the two runs is seen: from the plan view's start to the side view's end.
assert!(
(lo - PLAN_Q[0][0]).abs() <= 2.0 * tol && (hi - SIDE_Q[2][0]).abs() <= 2.0 * tol,
"from {lo} to {hi}"
);
}
#[test]
fn parallel_or_zero_directions_are_refused() {
let k = Kernel::default();
let (a, b) = (side(), plan(0.0));
let y = Vec3::new(0.0, 1.0, 0.0);
for (da, db) in [
(y, Vec3::new(0.0, -2.0, 0.0)),
(y, y),
(Vec3::ZERO, Vec3::Z),
(y, Vec3::new(f64::NAN, 0.0, 1.0)),
] {
let e = curves_from_views(&k, &a, da, &b, db).unwrap_err();
assert!(matches!(e, SolidError::InvalidInput { .. }), "{e:?}");
assert!(!views_do_not_meet(&e));
}
}
#[test]
fn views_whose_runs_do_not_overlap_do_not_meet() {
let k = Kernel::default();
// The plan view moved to x in [20, 35], past the side view's [0, 9].
let e = curves_from_views(&k, &side(), Vec3::Y, &plan(23.0), Vec3::Z).unwrap_err();
assert!(views_do_not_meet(&e), "{e:?}");
}
}
+187
View File
@@ -13,6 +13,7 @@
//! | [`Transform::concat`] | `SmTransform::ConcatTransform` / `smgu_4x4Mult`|
//! | [`Transform::transform_point`] | `SmTransform::TransformPoint` |
//! | [`Transform::transform_vector`] | `SmTransform::TransformVector` |
//! | [`Transform::rotation`], [`Transform::uniform_scaling`], [`Transform::reflection`] | (not upstream) |
//! | [`EFF_ZERO`] etc. | `SM_EFF_ZERO` etc. |
//! | [`is_infinite_param`] | `SM_IS_INFINITE` |
//! | [`max2`], [`min2`], [`max3`], [`min3`], [`max4`], [`min4`] | `smos_Max`, `smos_Min`, `smos_3Max`, `smos_3Min`, `smos_4Max`, `smos_4Min` |
@@ -409,10 +410,196 @@ impl Transform {
}
}
/// The operation name of the refusals of the maps built from their numbers.
const OP_MAPS: &str = "math::Transform";
/// Whether every coordinate of `v` is finite.
fn finite3(v: Vec3) -> bool {
v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
}
/// Not upstream: the rigid motions and scalings an application asks for by their numbers
/// (a turn about a line, a scale about a point, a mirror in a plane), built here so the
/// layers above the kernel only pass the numbers on. Each is in the row-vector convention
/// of [`Transform`]: rows 0 to 2 the images of x, y and z, row 3 the translation.
impl Transform {
/// The linear map whose rows are `x`, `y` and `z`, about the point `fixed`, which it
/// keeps: `p' = (p - fixed) A + fixed`, its translation `fixed - fixed A`.
fn about(fixed: Point3, x: Vec3, y: Vec3, z: Vec3) -> Self {
let image = x * fixed.x + y * fixed.y + z * fixed.z;
Self::from_axes(x, y, z, fixed - image)
}
/// The turn by `angle` radians, right-handed about `axis`, about the line through
/// `point` along `axis` (any length, made unit here): Rodrigues' rotation formula,
/// `R = c I + s [k]x + (1 - c) k kᵀ` with `k` the unit axis, `c` and `s` the cosine and
/// sine of the angle, transposed for the row-vector convention.
///
/// # Errors
///
/// `InvalidInput` for a point, axis or angle not finite, or an axis whose squared
/// length is below [`EFF_ZERO_SQ`].
pub fn rotation(point: Point3, axis: Vec3, angle: f64) -> crate::error::Result<Self> {
use crate::error::SolidError;
if !(finite3(point) && finite3(axis) && angle.is_finite()) {
return Err(SolidError::invalid_input(
OP_MAPS,
"a turn's numbers not finite",
));
}
let k = axis
.unitize()
.map_err(|_| SolidError::invalid_input(OP_MAPS, "a turn's axis of no length"))?;
let (s, c) = angle.sin_cos();
let v = 1.0 - c;
let (x, y, z) = (k.x, k.y, k.z);
Ok(Self::about(
point,
Vec3::new(c + x * x * v, x * y * v + z * s, x * z * v - y * s),
Vec3::new(x * y * v - z * s, c + y * y * v, y * z * v + x * s),
Vec3::new(x * z * v + y * s, y * z * v - x * s, c + z * z * v),
))
}
/// The scale by `factor` in every direction about `centre`, which it keeps. A negative
/// factor turns the scale through the centre as well.
///
/// # Errors
///
/// `InvalidInput` for a centre or factor not finite, or a factor of zero (a map with
/// no inverse).
pub fn uniform_scaling(centre: Point3, factor: f64) -> crate::error::Result<Self> {
use crate::error::SolidError;
if !(finite3(centre) && factor.is_finite()) || factor == 0.0 {
return Err(SolidError::invalid_input(
OP_MAPS,
"a scale's numbers not finite, or a factor of zero",
));
}
Ok(Self::about(
centre,
Vec3::new(factor, 0.0, 0.0),
Vec3::new(0.0, factor, 0.0),
Vec3::new(0.0, 0.0, factor),
))
}
/// The mirror in the plane through `origin` square to `normal` (any length, made unit
/// here): `p' = p - 2 ((p - origin)·n) n`, its linear part `I - 2 n nᵀ` (symmetric, so
/// the same in either convention) and its translation `2 (origin·n) n`. For a normal
/// along an axis every entry is exact, so the mirror twice is the identity exactly.
///
/// # Errors
///
/// `InvalidInput` for an origin or normal not finite, or a normal whose squared length
/// is below [`EFF_ZERO_SQ`].
pub fn reflection(origin: Point3, normal: Vec3) -> crate::error::Result<Self> {
use crate::error::SolidError;
if !(finite3(origin) && finite3(normal)) {
return Err(SolidError::invalid_input(
OP_MAPS,
"a mirror's numbers not finite",
));
}
let n = normal
.unitize()
.map_err(|_| SolidError::invalid_input(OP_MAPS, "a mirror's normal of no length"))?;
let row = |e: Vec3, ni: f64| e - n * (2.0 * ni);
Ok(Self::from_axes(
row(Vec3::X, n.x),
row(Vec3::Y, n.y),
row(Vec3::Z, n.z),
n * (2.0 * origin.dot(n)),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The largest difference between two matrices' entries.
fn apart(a: &Transform, b: &Transform) -> f64 {
a.m.iter()
.flatten()
.zip(b.m.iter().flatten())
.map(|(x, y)| (x - y).abs())
.fold(0.0, f64::max)
}
#[test]
fn a_quarter_turn_about_z_takes_x_to_y_and_keeps_its_line() {
use crate::geom::certified::Verdict;
let q = core::f64::consts::FRAC_PI_2;
let turn = Transform::rotation(Point3::ZERO, Vec3::new(0.0, 0.0, 2.0), q).unwrap();
let want = Transform::from_axes(Vec3::Y, -Vec3::X, Vec3::Z, Point3::ZERO);
assert!(apart(&turn, &want) <= 1e-15, "{turn:?}");
// about the line through (10, 0, 0) along z: that point stays, (11, 0, 0) goes to
// (10, 1, 0)
let p = Point3::new(10.0, 0.0, 0.0);
let turn = Transform::rotation(p, Vec3::Z, q).unwrap();
assert!(turn.transform_point(p).distance(p) <= 1e-14);
let image = turn.transform_point(Point3::new(11.0, 0.0, 0.0));
assert!(
image.distance(Point3::new(10.0, 1.0, 0.0)) <= 1e-14,
"{image:?}"
);
// a turn about a skew axis keeps lengths and its axis
let axis = Vec3::new(1.0, 2.0, 2.0);
let turn = Transform::rotation(p, axis, 0.7).unwrap();
assert!(
turn.transform_point(p + axis * 3.0)
.distance(p + axis * 3.0)
<= 1e-12
);
for r in turn.row_lengths() {
assert!((r - 1.0).abs() <= 1e-14);
}
assert_eq!(turn.is_regular(1e-9), Verdict::Yes);
}
#[test]
fn a_scale_keeps_its_centre_and_a_mirror_twice_is_the_identity() {
let c = Point3::new(1.0, -2.0, 3.0);
let scale = Transform::uniform_scaling(c, 2.5).unwrap();
assert!(scale.transform_point(c).distance(c) <= 1e-14);
let p = scale.transform_point(c + Vec3::X);
assert!(p.distance(c + Vec3::X * 2.5) <= 1e-14, "{p:?}");
// the mirror in y = 5, an axis normal of any length: every entry exact
let mirror =
Transform::reflection(Point3::new(7.0, 5.0, -1.0), Vec3::new(0.0, 3.0, 0.0)).unwrap();
assert_eq!(
mirror.transform_point(Point3::new(1.0, 2.0, 3.0)),
Point3::new(1.0, 8.0, 3.0)
);
assert_eq!(mirror.concat(&mirror), Transform::IDENTITY);
// a slanted mirror keeps its plane's points and reverses its normal
let n = Vec3::new(1.0, 1.0, 0.0);
let mirror = Transform::reflection(c, n).unwrap();
assert!(mirror.transform_point(c).distance(c) <= 1e-14);
let back = mirror.transform_point(c + n);
assert!(back.distance(c - n) <= 1e-14, "{back:?}");
}
#[test]
fn maps_from_numbers_refuse_what_is_not_finite_or_has_no_size() {
let bad = |r: crate::error::Result<Transform>| matches!(r, Err(crate::error::SolidError::InvalidInput { operation, .. }) if operation == OP_MAPS);
assert!(bad(Transform::rotation(Point3::ZERO, Vec3::ZERO, 1.0)));
assert!(bad(Transform::rotation(Point3::ZERO, Vec3::Z, f64::NAN)));
assert!(bad(Transform::rotation(
Point3::new(f64::INFINITY, 0.0, 0.0),
Vec3::Z,
1.0
)));
assert!(bad(Transform::uniform_scaling(Point3::ZERO, 0.0)));
assert!(bad(Transform::uniform_scaling(Point3::ZERO, f64::NAN)));
assert!(bad(Transform::reflection(Point3::ZERO, Vec3::ZERO)));
assert!(bad(Transform::reflection(
Point3::ZERO,
Vec3::new(f64::NAN, 1.0, 0.0)
)));
}
#[test]
fn a_rigid_motion_is_regular_and_a_flattening_is_not() {
use crate::geom::certified::Verdict;
+1
View File
@@ -31,6 +31,7 @@ pub mod iges;
pub(crate) mod index;
pub mod knots;
pub mod math;
pub mod n_sided;
pub mod nurbs_crv;
pub mod nurbs_srf;
pub mod spiral;
+5 -5
View File
@@ -119,7 +119,7 @@ fn bad(reason: &'static str) -> SolidError {
/// equal is the polynomial curve on its points divided by that weight, exactly (a record
/// spline's kernel curve carries a weight of 1 on every point). Only unequal weights make it
/// rational.
fn nurb_of(c: &BSplineCurve) -> Result<Curve> {
pub(crate) fn nurb_of(c: &BSplineCurve) -> Result<Curve> {
let mut nurb = c
.nurb
.clone()
@@ -182,7 +182,7 @@ fn surface(net: Vec<Vec<Vec3>>, p: usize, q: usize, u: &[f64], v: &[f64]) -> Res
}
/// `c` moved by minus `o`, into the network's own frame.
fn moved(c: &Curve, o: CPoint) -> Result<Curve> {
pub(crate) fn moved(c: &Curve, o: CPoint) -> Result<Curve> {
let pw = c
.cpts()
.iter()
@@ -192,7 +192,7 @@ fn moved(c: &Curve, o: CPoint) -> Result<Curve> {
}
/// `s` moved by minus `o`, into the network's own frame.
fn moved_surface(s: &Surface, o: CPoint) -> Result<Surface> {
pub(crate) fn moved_surface(s: &Surface, o: CPoint) -> Result<Surface> {
let net = s
.rows()
.map(|row| {
@@ -206,7 +206,7 @@ fn moved_surface(s: &Surface, o: CPoint) -> Result<Surface> {
}
/// The largest coordinate of `points`.
fn largest<'a>(points: impl Iterator<Item = &'a CPoint>) -> f64 {
pub(crate) fn largest<'a>(points: impl Iterator<Item = &'a CPoint>) -> f64 {
points
.map(|c| c.x.abs().max(c.y.abs()).max(c.z.abs()))
.fold(0.0_f64, f64::max)
@@ -226,7 +226,7 @@ fn room<T>(count: usize) -> Result<Vec<T>> {
}
/// Whether `x` is at most `tol`; a NaN is not.
fn within(x: f64, tol: f64) -> bool {
pub(crate) fn within(x: f64, tol: f64) -> bool {
matches!(x.partial_cmp(&tol), Some(Ordering::Less | Ordering::Equal))
}
+311
View File
@@ -0,0 +1,311 @@
//! An n-sided patch bounded by curves meeting end to end round one loop, certified: the kernel's
//! fill (`N_FillNSidedHole`, [`BSplineSurface::create_n_sided_patch`]) splits each boundary
//! curve at its middle and fills the hole with N four-sided patches round a centre, each bounded
//! by halves of two boundary curves and two inner curves from their middles to the centre.
//!
//! Upstream: none (not upstream; solidcore-native). The fill is upstream's; the certificate is
//! this module's.
//!
//! | Rust | Upstream |
//! |---------------|----------|
//! | [`n_sided`] | none |
//!
//! What is certified, and what is not:
//! - **The boundary**: each half of each boundary curve against the patch side it is (the side
//! whose certified distance to it is least, either way round, both on `[0, 1]`):
//! [`certified_distance`], plus the rounding of extracting the side and splitting the curve by
//! knot insertion (`γ_(12 (d + 1))` of twice the largest coordinate each, a coordinate error
//! `e` moving a point by at most `2 e`), counted in the loop's own frame (minus its first
//! control point, each moved coordinate rounding once). The largest is returned.
//! - **The inner joins (G0)**: every patch side that is no boundary half against the nearest
//! side of another patch, certified the same way; the largest must be within the tolerance.
//! - **Tangency across the inner curves (G1)** is the fill's aim, within its angle tolerance,
//! and is **not** certified here.
//!
//! Deviations: none (no upstream counterpart). Non-rational curves only (a curve whose weights
//! are all one value is its polynomial form). No recursion.
use crate::error::{Result, SolidError};
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::nurbs::b_spline_surface::BSplineSurface;
use crate::nurbs::crv_basic::Curve;
use crate::nurbs::crv_deviation::certified_distance;
use crate::nurbs::geom::Interval;
use crate::nurbs::gordon::{largest, moved, moved_surface, nurb_of, within};
use crate::nurbs::math::gamma;
use crate::nurbs::srf_basic::{Direction, Surface};
use crate::nurbs::srf_tool::p1::extract_iso_crv;
const OP: &str = "nurbs::n_sided::n_sided";
/// The fewest curves of an n-sided loop.
pub const MIN_SIDES: usize = 3;
/// The most curves of an n-sided loop.
pub const MAX_SIDES: usize = 64;
/// The angle (degrees) within which the fill matches the cross-boundary derivatives it builds
/// across its inner curves: the fill's own tests' value.
const ANGLE_DEG: f64 = 1.0;
/// The N patches of an n-sided fill and its certificates.
#[derive(Debug, Clone)]
pub struct NSided {
/// The patches, one per boundary curve.
pub patches: Vec<BSplineSurface>,
/// The largest certified distance between a boundary curve's half and its patch side.
pub bound: f64,
/// The largest certified distance between the two patch sides of an inner join.
pub joins: f64,
}
/// Why a loop has no certified n-sided patch here.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum NSidedRefusal {
/// A curve is rational (its weights not all one value): the fill takes polynomial curves.
Rational,
/// A boundary curve is not proved within the tolerance of the patches: the bound.
PastTolerance {
/// The largest certified distance.
bound: f64,
},
/// Two patches are not proved to meet within the tolerance along an inner curve: the
/// largest certified gap.
Joins {
/// The largest certified gap between two patches' sides.
gap: f64,
},
}
fn bad(reason: &'static str) -> SolidError {
SolidError::invalid_input(OP, reason)
}
/// `c` on `[0, 1]`, as it runs or reversed.
fn on_unit(c: &Curve, reversed: bool) -> Result<Curve> {
let mut c = c.clone();
if reversed {
c.reverse()?;
}
c.reparam_to_interval(Interval::new(0.0, 1.0))?;
Ok(c)
}
/// The four sides of patch `s`, each on `[0, 1]`.
fn sides(s: &Surface) -> Result<[Curve; 4]> {
let b = s.parameter_bounds();
Ok([
on_unit(&extract_iso_crv(s, b.vb, Direction::U)?, false)?,
on_unit(&extract_iso_crv(s, b.vt, Direction::U)?, false)?,
on_unit(&extract_iso_crv(s, b.ul, Direction::V)?, false)?,
on_unit(&extract_iso_crv(s, b.ur, Direction::V)?, false)?,
])
}
/// The least certified distance from `c` (on `[0, 1]`) to `side` either way round.
fn nearest(c: &Curve, side: &Curve) -> Result<f64> {
let forward = certified_distance(c, side)?;
let backward = certified_distance(c, &on_unit(side, true)?)?;
Ok(forward.min(backward))
}
/// The n-sided fill of `curves` (each running on from the one before, the last back to the
/// first), its boundary and inner joins certified within `tol` (module docs).
///
/// # Errors
///
/// `InvalidInput` for fewer than [`MIN_SIDES`] or more than [`MAX_SIDES`] curves, a curve
/// without its NURB or `tol` not finite and positive; those of the fill (curves that do not
/// meet within its model tolerance, ...), the extraction, the split and the certificates.
pub fn n_sided(
curves: &[BSplineCurve],
tol: f64,
) -> Result<std::result::Result<NSided, NSidedRefusal>> {
if !(MIN_SIDES..=MAX_SIDES).contains(&curves.len()) {
return Err(bad("fewer than three or more than 64 curves"));
}
if !(tol.is_finite() && tol > 0.0) {
return Err(bad("a tolerance not finite and positive"));
}
let nurbs: Vec<Curve> = curves.iter().map(nurb_of).collect::<Result<_>>()?;
if nurbs.iter().any(Curve::is_rational) {
return Ok(Err(NSidedRefusal::Rational));
}
// the fill, on copies of the polynomial curves (it changes its inputs)
let mut bounding: Vec<BSplineCurve> = nurbs
.iter()
.map(|c| BSplineCurve::from_gw_nurb(3, c))
.collect();
let mut across: Vec<Option<BSplineCurve>> = bounding.iter().map(|_| None).collect();
let patches =
BSplineSurface::create_n_sided_patch(&mut bounding, &mut across, ANGLE_DEG, None)?;
// the loop's own frame
let origin = nurbs
.first()
.and_then(|c| c.cpts().first().copied())
.ok_or_else(|| bad("a curve without points"))?;
let curves_local: Vec<Curve> = nurbs
.iter()
.map(|c| moved(c, origin))
.collect::<Result<_>>()?;
let mut local: Vec<Surface> = Vec::new();
for p in &patches {
let s = p
.nurb
.as_ref()
.ok_or_else(|| bad("a patch without its NURB"))?;
local.push(moved_surface(s, origin)?);
}
let size = largest(
curves_local
.iter()
.flat_map(Curve::cpts)
.chain(local.iter().flat_map(Surface::rows).flatten()),
);
let degree = curves_local
.iter()
.map(Curve::degree)
.chain(local.iter().map(|s| {
let (p, q) = s.degrees();
p.max(q)
}))
.max()
.unwrap_or(0);
// splitting a curve and extracting a side (knot insertion each), and the two moves
let rounding = 2.0 * (4.0 * gamma(12 * (degree + 1)) * size) + 2.0 * (2.0 * gamma(1) * size);
let all_sides: Vec<[Curve; 4]> = local.iter().map(sides).collect::<Result<_>>()?;
// which patch sides are boundary halves
let mut boundary_side = vec![[false; 4]; all_sides.len()];
let mut bound = 0.0_f64;
for c in &curves_local {
let (a, b) = c.param_bounds();
let (first, second) = c.split(0.5 * (a + b))?;
for half in [first, second] {
let half = on_unit(&half, false)?;
let mut best: Option<(f64, usize, usize)> = None;
for (i, four) in all_sides.iter().enumerate() {
for (j, side) in four.iter().enumerate() {
let d = nearest(&half, side)?;
if best.is_none_or(|(b, _, _)| d < b) {
best = Some((d, i, j));
}
}
}
let Some((d, i, j)) = best else {
return Err(bad("a fill without patches"));
};
if let Some(flag) = boundary_side.get_mut(i).and_then(|f| f.get_mut(j)) {
*flag = true;
}
bound = bound.max((d + rounding) * (1.0 + gamma(3)));
}
}
if !within(bound, tol) {
return Ok(Err(NSidedRefusal::PastTolerance { bound }));
}
// the inner joins: each side that is no boundary half against the nearest side of another
// patch
let mut joins = 0.0_f64;
for (i, four) in all_sides.iter().enumerate() {
for (j, side) in four.iter().enumerate() {
if boundary_side
.get(i)
.and_then(|f| f.get(j))
.copied()
.unwrap_or(true)
{
continue;
}
let mut best = f64::INFINITY;
for (other, theirs) in all_sides.iter().enumerate() {
if other == i {
continue;
}
for s in theirs {
best = best.min(nearest(side, s)?);
}
}
joins = joins.max((best + rounding) * (1.0 + gamma(3)));
}
}
if !within(joins, tol) {
return Ok(Err(NSidedRefusal::Joins { gap: joins }));
}
Ok(Ok(NSided {
patches,
bound,
joins,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Vec3;
use crate::nurbs::math::CPoint;
/// The polynomial curve of degree `p` on `points` with clamped uniform knots.
fn spline(points: &[[f64; 3]], p: usize) -> BSplineCurve {
let n = points.len();
let mut knots = vec![0.0; p + 1];
for i in 1..n - p {
knots.push(i as f64 / (n - p) as f64);
}
knots.extend(vec![1.0; p + 1]);
let pw = points
.iter()
.map(|q| CPoint::from_point(Vec3::new(q[0], q[1], q[2])))
.collect();
BSplineCurve::from_gw_nurb(3, &Curve::new(pw, p, knots).unwrap())
}
/// The corners of a regular pentagon of radius 10 in z = 0.
fn pentagon() -> Vec<[f64; 3]> {
(0..5)
.map(|i| {
let a = std::f64::consts::TAU * f64::from(i) / 5.0;
[10.0 * a.cos(), 10.0 * a.sin(), 0.0]
})
.collect()
}
#[test]
fn a_pentagon_of_lines_is_filled_flat() {
let p = pentagon();
let lines: Vec<BSplineCurve> = (0..5).map(|i| spline(&[p[i], p[(i + 1) % 5]], 1)).collect();
let f = n_sided(&lines, 1e-6).unwrap().unwrap();
assert_eq!(f.patches.len(), 5);
assert!(
f.bound <= 1e-9 && f.joins <= 1e-9,
"{} {}",
f.bound,
f.joins
);
// planar data, planar patches: every control point in z = 0
for s in &f.patches {
for c in s.nurb.as_ref().unwrap().rows().flatten() {
assert!(c.z.abs() < 1e-9, "{c:?}");
}
}
}
#[test]
fn rational_curves_are_refused() {
let p = pentagon();
let mut curves: Vec<BSplineCurve> =
(0..5).map(|i| spline(&[p[i], p[(i + 1) % 5]], 1)).collect();
let pw = vec![
CPoint::new(p[0][0], p[0][1], 0.0, 1.0),
CPoint::new(2.0 * p[1][0], 2.0 * p[1][1], 0.0, 2.0),
];
curves[0] =
BSplineCurve::from_gw_nurb(3, &Curve::new(pw, 1, vec![0.0, 0.0, 1.0, 1.0]).unwrap());
assert_eq!(
n_sided(&curves, 1e-6).unwrap().unwrap_err(),
NSidedRefusal::Rational
);
}
}
+71 -1
View File
@@ -42,6 +42,7 @@
//! | [`Surface::average_len`], [`AverageLen`] | `N_SrfGetAverageLen` |
//! | [`Direction`] | `NL_UDIR` / `NL_VDIR` / `NL_UVDIR` |
//! | [`WEIGHT_MIN`], [`WEIGHT_MAX`], [`WEIGHT_TOL`] | `NL_WMIN`, `NL_WMAX`, `NL_WTOL` |
//! | [`Surface::displace_cpts`] | none |
//!
//! Deviations:
//! - [`Surface`] owns its control net and knot arrays (upstream points at caller- or
@@ -92,7 +93,7 @@ use crate::nurbs::geom::{
dist_pt_pt, dist_signed_pt_plane, plane_pt_normal_to_implicit, Rectangle,
};
use crate::nurbs::knots::{KnotVector, MAX_DEGREE};
use crate::nurbs::math::{combine2_pts, combine4_pts, CPoint, MODEL_TOL, NOW};
use crate::nurbs::math::{combine2_pts, combine4_pts, CPoint, MODEL_TOL, NOW, NOZ};
/// Minimum valid weight (`NL_WMIN`).
pub const WEIGHT_MIN: f64 = 1.0e-03;
@@ -107,6 +108,7 @@ const OP_NEW: &str = "nurbs::srf_basic::Surface::new";
const MIXED_RATIONALITY: &str = "control points mix rational and non-rational";
const OP_WITH_INDICES: &str = "nurbs::srf_basic::Surface::with_indices";
const OP_CPT: &str = "nurbs::srf_basic::Surface::cpt";
const OP_DISPLACE: &str = "nurbs::srf_basic::Surface::displace_cpts";
const OP_SET_CPTS: &str = "nurbs::srf_basic::Surface::set_cpts_and_knots";
const OP_SET_SIZE: &str = "nurbs::srf_basic::Surface::set_size_indices";
const OP_COUNTS: &str = "nurbs::srf_basic::Surface::counts_are_valid";
@@ -449,6 +451,45 @@ impl Surface {
self.rows().map(<[CPoint]>::to_vec).collect()
}
/// Moves control points by fractions of one displacement (not upstream): each
/// `((i, j), t)` of `moves` moves the Euclidean point of `Pw[i][j]` by `t * delta`, its
/// weight kept (a rational point's weighted coordinates move by `w t delta`). A point
/// listed twice moves by the sum of its fractions. All or nothing: on a refusal the net
/// is unchanged.
///
/// # Errors
///
/// `InvalidInput` for an index past the net, a fraction or `delta` not finite, a 2D net
/// (no `z`), or a moved point not finite.
pub fn displace_cpts(&mut self, moves: &[((usize, usize), f64)], delta: Vec3) -> Result<()> {
if !(delta.x.is_finite() && delta.y.is_finite() && delta.z.is_finite()) {
return Err(invalid(OP_DISPLACE, "a displacement not finite"));
}
let mut next = self.clone();
for &((i, j), t) in moves {
if !t.is_finite() {
return Err(invalid(OP_DISPLACE, "a fraction not finite"));
}
let c = next.cpt_mut(i, j)?;
if c.z == NOZ {
return Err(invalid(OP_DISPLACE, "a 2D control net"));
}
let w = if c.w == NOW { 1.0 } else { c.w };
let moved = CPoint {
x: c.x + w * t * delta.x,
y: c.y + w * t * delta.y,
z: c.z + w * t * delta.z,
w: c.w,
};
if !(moved.x.is_finite() && moved.y.is_finite() && moved.z.is_finite()) {
return Err(invalid(OP_DISPLACE, "a moved point not finite"));
}
*c = moved;
}
*self = next;
Ok(())
}
/// The u knots `U[0..=r]` (`N_SrfGetKnots`).
#[must_use]
pub fn u_knots(&self) -> &[f64] {
@@ -1223,6 +1264,35 @@ mod tests {
.unwrap()
}
#[test]
fn displaced_points_move_by_their_fraction_of_the_delta() {
let mut s = bilinear();
let d = Vec3::new(0.0, 0.0, 2.0);
// a point listed twice moves by the sum of its fractions
s.displace_cpts(&[((1, 1), 0.5), ((0, 0), 1.0), ((1, 1), 0.25)], d)
.unwrap();
assert_eq!(s.cpt(0, 0).unwrap(), CPoint::new(0.0, 0.0, 2.0, NOW));
assert_eq!(s.cpt(1, 1).unwrap(), CPoint::new(1.0, 1.0, 1.5, NOW));
assert_eq!(s.cpt(0, 1).unwrap(), CPoint::new(0.0, 1.0, 0.0, NOW));
assert_eq!(s.cpt(1, 0).unwrap(), CPoint::new(1.0, 0.0, 0.0, NOW));
// a rational point keeps its weight; its weighted coordinates move by w t delta
let mut r = bilinear();
r.non_rat_to_rat();
*r.cpt_mut(1, 0).unwrap() = CPoint::new(2.0, 0.0, 0.0, 2.0);
r.displace_cpts(&[((1, 0), 1.0)], d).unwrap();
assert_eq!(r.cpt(1, 0).unwrap(), CPoint::new(2.0, 0.0, 4.0, 2.0));
// all or nothing: a bad move after a good one leaves the net as it was
let before = bilinear();
let mut t = before.clone();
assert!(t.displace_cpts(&[((0, 0), 1.0), ((2, 0), 1.0)], d).is_err());
assert!(t.displace_cpts(&[((0, 0), f64::NAN)], d).is_err());
assert!(t
.displace_cpts(&[((0, 0), 1.0)], Vec3::new(f64::INFINITY, 0.0, 0.0))
.is_err());
assert!(t.displace_cpts(&[((0, 0), f64::MAX)], d).is_err());
assert_eq!(t, before);
}
#[test]
fn construct_and_access() {
let s = bilinear();
+37 -8
View File
@@ -160,7 +160,7 @@ pub struct RailedLoft {
/// A section curve as plain numbers: degree, full knots, Euclidean control points, weights
/// (`None` for a polynomial curve).
#[derive(Debug, Clone)]
struct Plain {
pub(crate) struct Plain {
degree: usize,
knots: Vec<f64>,
points: Vec<Point3>,
@@ -361,7 +361,7 @@ fn wire_point(curves: &[(Plain, NurbCurve)], g: f64) -> Result<Point3> {
}
/// A station's curves, and per rail its parameter and point there.
type StationAt = (Vec<Plain>, Vec<(f64, Point3)>);
pub(crate) type StationAt = (Vec<Plain>, Vec<(f64, Point3)>);
/// An affine move: `x` goes to `to + m (x - from)`.
struct Affine {
@@ -710,11 +710,11 @@ impl<'a> Loft<'a> {
/// A wire of the loft: a section's curves or a station's, at loft parameter `(span, f)`, and
/// per rail the rail's parameter and point there.
struct Wire {
span: usize,
f: f64,
curves: Vec<Plain>,
hits: Vec<(f64, Point3)>,
pub(crate) struct Wire {
pub(crate) span: usize,
pub(crate) f: f64,
pub(crate) curves: Vec<Plain>,
pub(crate) hits: Vec<(f64, Point3)>,
}
/// The loft through `sections` guided by `rails` (module docs).
@@ -759,6 +759,35 @@ pub fn loft_rails(
hits,
});
}
skin_and_certify(
k,
wires,
spans,
rails,
opts,
&mut |k: &Kernel, i: usize, f: f64| loft.station(k, i, f),
)
}
/// The loft's skin-and-certificate loop (module docs, "Where the stations go, and the
/// certificate"): from the wires `wires` (the sections, each with its rails' crossings, `spans`
/// spans between them), a station placed by `station(k, span, f)` at each span's middle, the
/// wires skinned, each rail bounded against the skin on every span between neighbouring wires,
/// and a span whose bound is above the tolerance given a station at its middle, until every
/// span is certified; past [`RailLoftOptions::max_wires`] refused, naming the bound reached.
///
/// # Errors
///
/// [`crate::error::sweep::SweepFailure::RailPastStationCap`] past the wire cap; those of
/// `station`, the skin and the bound.
pub(crate) fn skin_and_certify(
k: &mut Kernel,
mut wires: Vec<Wire>,
spans: usize,
rails: &[&Curve],
opts: &RailLoftOptions,
station: &mut dyn FnMut(&Kernel, usize, f64) -> Result<StationAt>,
) -> Result<RailedLoft> {
// one station at each span's middle
let mut pending: Vec<(usize, f64)> = (0..spans).map(|i| (i, 0.5)).collect();
// the largest bound the last skin proved (none before the first)
@@ -776,7 +805,7 @@ pub fn loft_rails(
.into_error());
}
for (i, f) in pending.drain(..) {
let (curves, hits) = loft.station(k, i, f)?;
let (curves, hits) = station(&*k, i, f)?;
wires.push(Wire {
span: i,
f,