mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-07 04:12:15 +00:00
Wave 85/509: kernel: a body's record for SMLib takes a larger tolerance where an edge needs it (and 3 more)
4 commits, squashed; the originals stay reachable from tag archive/merge-main-develop. -e35b4d744jyviko: kernel: revolutions of a line square to the axis go into SMLib as planes -00e43aaccjyviko: kernel: an operand SMLib's check refuses goes through SMLib's healer -94c159825jyviko: kernel: SMLib's round faces decode as truck's own revolutions are laid out -ff0f7ccfajyviko: kernel: a body's record for SMLib takes a larger tolerance where an edge needs it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
This commit is contained in:
@@ -98,10 +98,33 @@ pub fn brep_record(body: &Body) -> Result<solidcraft_geom::brep::Brep> {
|
||||
/// 9e-7 where the record as given is exact to 1e-7.
|
||||
#[cfg(feature = "smlib")]
|
||||
pub fn smlib_body(body: &Body) -> Result<solidcraft_smlib::Brep> {
|
||||
let record = brep_record(body)?;
|
||||
let record = tolerant_record(body)?;
|
||||
solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| KernelError::Failed(format!("SMLib: {e}")))
|
||||
}
|
||||
|
||||
/// [`brep_record`] for another kernel: where an edge lies a little farther from a face it bounds
|
||||
/// than the body's working tolerance (truck's offsets leave edges some 3e-5 mm off their faces),
|
||||
/// the record takes a tolerance ten, then a hundred times as large for the body, its edges and
|
||||
/// vertices, as the kernel it goes to works to tolerances (SMLib's own are far larger). Past
|
||||
/// that it is refused.
|
||||
#[cfg(feature = "smlib")]
|
||||
fn tolerant_record(body: &Body) -> Result<solidcraft_geom::brep::Brep> {
|
||||
guard("B-rep record", || {
|
||||
if body.mesh.is_some() {
|
||||
return Err(invalid("mesh bodies are source blobs, not B-rep"));
|
||||
}
|
||||
let working = crate::fill::face_tolerance(body);
|
||||
let mut last = None;
|
||||
for factor in [1.0, 10.0, 100.0] {
|
||||
match adapter::encode_within(body, working * factor)? {
|
||||
Ok(record) => return Ok(record),
|
||||
Err(e) => last = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last.unwrap_or_else(|| invalid("no record")))
|
||||
})
|
||||
}
|
||||
|
||||
/// The truck solid of a schema 4 record, as a saved body's is built: tests compare records
|
||||
/// made elsewhere (the STEP reader's) with truck's own import.
|
||||
#[cfg(test)]
|
||||
@@ -174,13 +197,28 @@ pub(crate) fn body_from_smlib_ordered(brep: &solidcraft_smlib::Brep) -> Result<(
|
||||
})
|
||||
}
|
||||
|
||||
/// `body` in SMLib ([`smlib_body`]), with where each of SMLib's faces and edges is in the body's
|
||||
/// face and edge order: SMLib's export of the imported body, checked to number every face,
|
||||
/// edge and vertex as the body's record does. `None` where it does not.
|
||||
/// `body` in SMLib ([`smlib_body`], healed where SMLib's check refuses it as given), with where
|
||||
/// each of SMLib's faces and edges is in the body's face and edge order: SMLib's export of the
|
||||
/// imported body, checked to number every face, edge and vertex as the body's record does.
|
||||
/// `None` where it does not.
|
||||
#[cfg(feature = "smlib")]
|
||||
pub(crate) fn smlib_body_ordered(body: &Body) -> Result<(solidcraft_smlib::Brep, Option<solidcraft_smlib::history::Order>)> {
|
||||
let record = brep_record(body)?;
|
||||
let brep = solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| KernelError::Failed(format!("SMLib: {e}")))?;
|
||||
use solidcraft_smlib::{Brep, CheckLevel};
|
||||
let record = tolerant_record(body)?;
|
||||
let import = |trusted: bool| Brep::from_record(&record, trusted).map_err(|e| KernelError::Failed(format!("SMLib: {e}")));
|
||||
let valid = |b: &Brep| b.check(CheckLevel::All).is_ok_and(|c| c.is_valid());
|
||||
// A record SMLib's check refuses as given (a gap a little over the record's tolerance, a
|
||||
// hole listed before its face's outer loop) goes through SMLib's healer; healed, it is kept
|
||||
// only where SMLib then finds it valid.
|
||||
let trusted = import(true)?;
|
||||
let brep = if valid(&trusted) {
|
||||
trusted
|
||||
} else {
|
||||
match import(false) {
|
||||
Ok(healed) if valid(&healed) => healed,
|
||||
_ => trusted,
|
||||
}
|
||||
};
|
||||
let order = match brep.to_native_ordered() {
|
||||
Ok((back, order)) => same_numbering(&record, &back).then_some(order),
|
||||
Err(e) => {
|
||||
|
||||
@@ -97,8 +97,14 @@ fn working_tolerance(body: &Body) -> f64 {
|
||||
/// The record of `body`'s solid. Each face's pcurves come from the records written for it
|
||||
/// (`brep::pcurve`, the routine every backend shares), within the body's working tolerance.
|
||||
pub(super) fn encode(body: &Body) -> Result<b::Brep> {
|
||||
encode_within(body, working_tolerance(body))?
|
||||
}
|
||||
|
||||
/// The record of `body`'s solid with `tolerance` for the body, its edges and vertices.
|
||||
/// `Ok(Err(_))`: some edge lies farther than that from a face it bounds, so no pcurve fits it
|
||||
/// within the tolerance.
|
||||
pub(super) fn encode_within(body: &Body, tolerance: f64) -> Result<Result<b::Brep>> {
|
||||
let solid = &body.solid;
|
||||
let tolerance = working_tolerance(body);
|
||||
let shells = solid.boundaries();
|
||||
let regions = regions(body)?;
|
||||
|
||||
@@ -140,7 +146,10 @@ pub(super) fn encode(body: &Body) -> Result<b::Brep> {
|
||||
uses.get_mut(i).ok_or_else(|| invalid("edge use"))?.push(at);
|
||||
}
|
||||
}
|
||||
faces.push(face_record(face, wires, &edge_index, &edges, tolerance)?);
|
||||
match face_record(face, wires, &edge_index, &edges, tolerance)? {
|
||||
Ok(f) => faces.push(f),
|
||||
Err(e) => return Ok(Err(e)),
|
||||
}
|
||||
}
|
||||
shell_records.push(b::Shell::Faces { faces: oriented, wires: Vec::new() });
|
||||
}
|
||||
@@ -163,7 +172,7 @@ pub(super) fn encode(body: &Body) -> Result<b::Brep> {
|
||||
let result = b::Brep { version: b::VERSION, tolerance, regions, shells: shell_records, faces, edges: edge_records, vertices };
|
||||
result.validate().map_err(invalid)?;
|
||||
compatible(&result, Producer::Own)?;
|
||||
Ok(result)
|
||||
Ok(Ok(result))
|
||||
}
|
||||
|
||||
/// A face's record: its surface, its loops as truck holds them (relative to the surface) and
|
||||
@@ -174,7 +183,7 @@ fn face_record(
|
||||
edge_index: &HashMap<mt::EdgeID, usize>,
|
||||
edges: &[(mt::Edge, b::Curve, [f64; 2])],
|
||||
tolerance: f64,
|
||||
) -> Result<b::Face> {
|
||||
) -> Result<Result<b::Face>> {
|
||||
let surface = save_surface(&face.surface(), 0)?;
|
||||
let mut uses = Vec::with_capacity(wires.len());
|
||||
for wire in wires {
|
||||
@@ -188,7 +197,10 @@ fn face_record(
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
uses.push(l);
|
||||
}
|
||||
let curves = b::pcurve::pcurves(&surface, &uses, tolerance).map_err(pcurve_error)?;
|
||||
let curves = match b::pcurve::pcurves(&surface, &uses, tolerance) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Ok(Err(pcurve_error(e))),
|
||||
};
|
||||
let loops = uses
|
||||
.iter()
|
||||
.zip(curves)
|
||||
@@ -197,7 +209,7 @@ fn face_record(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let domain = domain(&surface, &loops)?;
|
||||
Ok(b::Face { surface, domain, loops })
|
||||
Ok(Ok(b::Face { surface, domain, loops }))
|
||||
}
|
||||
|
||||
/// `face`'s record on its own, its edges numbered in first appearance: what [`encode`] writes
|
||||
@@ -214,7 +226,7 @@ pub(super) fn face_alone(face: &mt::Face, tolerance: f64) -> Result<b::Face> {
|
||||
slot.insert(edges.len() - 1);
|
||||
}
|
||||
}
|
||||
face_record(face, wires, &edge_index, &edges, tolerance)
|
||||
face_record(face, wires, &edge_index, &edges, tolerance)?
|
||||
}
|
||||
|
||||
/// The kernel's error for the pcurve routine's.
|
||||
|
||||
@@ -70,9 +70,10 @@ fn analytic_records_decode_as_their_mathematical_loci_and_normals() {
|
||||
(b::Surface::Torus { frame, major_radius: 4., minor_radius: 1. }, [0., TAU], 0., [6., 2., 3.], [1., 0., 0.]),
|
||||
];
|
||||
for (record, height, parameter, expected, normal) in cases {
|
||||
// Truck's revolution as truck makes its own: (u, v) = (the profile's parameter, the angle).
|
||||
let surface = load_surface(record, [[0., TAU], height]).unwrap();
|
||||
assert!(surface.subs(0., parameter).distance(p(expected)) < 1e-10);
|
||||
assert!(surface.normal(0., parameter).normalize().dot(v(normal).normalize()) > 1. - 1e-10);
|
||||
assert!(surface.subs(parameter, 0.).distance(p(expected)) < 1e-10);
|
||||
assert!(surface.normal(parameter, 0.).normalize().dot(v(normal).normalize()) > 1. - 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,11 @@ pub(super) fn revolved(profile: mt::Curve, origin: mt::Point3, axis: mt::Vector3
|
||||
|
||||
/// An unbounded analytic surface as a truck revolution, its profile over `height` (the face's
|
||||
/// v range) for a cylinder or cone, and the whole meridian for a sphere or torus. Truck's
|
||||
/// revolution has its own (u, v); the record's loops, not its parameters, bound the face.
|
||||
/// revolution has its own (u, v); the record's loops, not its parameters, bound the face. The
|
||||
/// profile runs against the record's v so the revolution's (profile, angle) has the record's
|
||||
/// natural normal unswapped, as truck's own revolutions are: the tessellator meets a cone's
|
||||
/// apex or a sphere's pole at the profile's end, where it places it right (swapped, a drilled
|
||||
/// hole's tip came out 41 % too large).
|
||||
pub(super) fn load_analytic(surface: b::Surface, height: [f64; 2]) -> Result<mt::Surface> {
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
let axis = |f: &b::Frame| v(f.x_axis).cross(v(f.y_axis));
|
||||
@@ -38,22 +42,22 @@ pub(super) fn load_analytic(surface: b::Surface, height: [f64; 2]) -> Result<mt:
|
||||
b::Surface::Cylinder { frame, radius } => {
|
||||
let z = axis(&frame);
|
||||
let at = |h| p(frame.origin) + v(frame.x_axis) * radius + z * h;
|
||||
revolved(mt::Curve::Line(mt::Line(at(height[0]), at(height[1]))), p(frame.origin), z, true)
|
||||
revolved(mt::Curve::Line(mt::Line(at(height[1]), at(height[0]))), p(frame.origin), z, false)
|
||||
}
|
||||
b::Surface::Cone { frame, radius_at_origin, half_angle } => {
|
||||
let z = axis(&frame);
|
||||
let at = |h| p(frame.origin) + v(frame.x_axis) * (radius_at_origin + h * half_angle.tan()) + z * h;
|
||||
revolved(mt::Curve::Line(mt::Line(at(height[0]), at(height[1]))), p(frame.origin), z, true)
|
||||
revolved(mt::Curve::Line(mt::Line(at(height[1]), at(height[0]))), p(frame.origin), z, false)
|
||||
}
|
||||
b::Surface::Sphere { frame, radius } => {
|
||||
let z = axis(&frame);
|
||||
let meridian = b::Frame { origin: frame.origin, x_axis: frame.x_axis, y_axis: vector(z) };
|
||||
revolved(arc(&meridian, [radius; 2], [-FRAC_PI_2, FRAC_PI_2])?, p(frame.origin), z, true)
|
||||
revolved(arc(&meridian, [radius; 2], [-FRAC_PI_2, FRAC_PI_2])?.inverse(), p(frame.origin), z, false)
|
||||
}
|
||||
b::Surface::Torus { frame, major_radius, minor_radius } => {
|
||||
let z = axis(&frame);
|
||||
let profile = b::Frame { origin: point(p(frame.origin) + v(frame.x_axis) * major_radius), x_axis: frame.x_axis, y_axis: vector(z) };
|
||||
revolved(arc(&profile, [minor_radius; 2], [0., TAU])?, p(frame.origin), z, true)
|
||||
revolved(arc(&profile, [minor_radius; 2], [0., TAU])?.inverse(), p(frame.origin), z, false)
|
||||
}
|
||||
_ => return Err(invalid("expected analytic surface")),
|
||||
})
|
||||
|
||||
@@ -93,3 +93,35 @@ fn a_cavity_comes_back_from_smlib() {
|
||||
let got = super::smlib_body(&back).unwrap().volume(1e-7).unwrap();
|
||||
assert!((got - (64_000.0 - 125.0)).abs() < 1e-6, "{got}");
|
||||
}
|
||||
|
||||
/// A square revolved off the axis through three quarters of a turn: its flat ends are planes
|
||||
/// square to the axis (annular sectors), its walls cylinders.
|
||||
#[test]
|
||||
fn a_revolved_annulus_goes_with_its_flat_faces() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let square =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(4., 12.), Vec2::new(12., 12.), Vec2::new(12., 20.), Vec2::new(4., 20.)]), holes: vec![] };
|
||||
let plane = Plane { y: v(0., 0., 1.), ..Plane::XZ };
|
||||
let body = crate::revolve(&plane, &[square], Vec2::ZERO, Vec2::Y, 1.5 * PI).unwrap().pop().unwrap();
|
||||
let s = super::smlib_body(&body).unwrap();
|
||||
let check = s.check(solidcraft_smlib::CheckLevel::All).unwrap();
|
||||
assert!(check.is_valid(), "{check}");
|
||||
let exact = 0.75 * PI * (144.0 - 16.0) * 8.0;
|
||||
let got = s.volume(1e-7).unwrap();
|
||||
assert!((got - exact).abs() <= 1e-6 * exact, "SMLib volume {got}, exact {exact}");
|
||||
}
|
||||
/// A drill point (a cone ending in its apex) through SMLib and back measures as it went in.
|
||||
#[test]
|
||||
fn a_cone_apex_comes_back_measuring_as_it_went() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let r = 3.4;
|
||||
let h = r / (59f64.to_radians()).tan();
|
||||
let profile =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 3.0 - h), Vec2::new(r, 3.0), Vec2::new(r, 16.0), Vec2::new(0.0, 16.0)]), holes: vec![] };
|
||||
let plane = Plane { origin: v(20.0, 15.0, 0.0), x: v(1., 0., 0.), y: v(0., 0., 1.) };
|
||||
let tool = crate::revolve(&plane, &[profile], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap();
|
||||
let back = super::body_from_smlib(&super::smlib_body(&tool).unwrap()).unwrap();
|
||||
let (m0, m1) = (crate::measure(&tool).unwrap(), crate::measure(&back).unwrap());
|
||||
assert!((m1.area - m0.area).abs() < 1e-5 * m0.area, "area {} -> {}", m0.area, m1.area);
|
||||
assert!((m1.volume - m0.volume).abs() < 1e-5 * m0.volume, "volume {} -> {}", m0.volume, m1.volume);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Revolutions and extruded circles as SMLib's analytic surfaces.
|
||||
//!
|
||||
//! A `placed_revolution` record holds a surface the way truck does (a profile turned about an
|
||||
//! axis, then placed); SMLib has no such surface, but every one truck makes for a cylinder, cone,
|
||||
//! sphere or torus is one of those exactly: a line along the axis, a line in a plane through it, a
|
||||
//! axis, then placed); SMLib has no such surface, but every one truck makes for a plane, cylinder, cone,
|
||||
//! sphere or torus is one of those exactly: a line square to the axis, a line along it, a line in a plane through it, a
|
||||
//! circle centred on it, a circle in a plane through it. A rational spline that is a circular arc
|
||||
//! swept straight along its axis (truck's extruded circles) is a cylinder. Each such face becomes
|
||||
//! that analytic surface with the face's range on it, checked point by point over the face's
|
||||
@@ -21,7 +21,7 @@ use solidcraft_geom::brep::eval::{CurveEval, SurfaceEval};
|
||||
|
||||
use super::{R, Unsupported};
|
||||
|
||||
const NOT_ANALYTIC: Unsupported = Unsupported::NotYet("revolved surfaces other than cylinders, cones, spheres and tori");
|
||||
const NOT_ANALYTIC: Unsupported = Unsupported::NotYet("revolved surfaces other than planes, cylinders, cones, spheres and tori");
|
||||
|
||||
/// `brep` with every revolution and extruded circle replaced by its analytic surface (borrowed
|
||||
/// when it has none).
|
||||
@@ -44,8 +44,10 @@ pub(super) fn analytic(brep: &b::Brep) -> R<Cow<'_, b::Brep>> {
|
||||
// The angle about the axis starts at 0: the frame turns to the face's first angle (SMLib
|
||||
// refuses an analytic range past 360°). A torus's tube angle can't turn so: it starts
|
||||
// within the first turn, where SMLib's degree domains are at home.
|
||||
turn_frame(&mut surface, range[0][0]);
|
||||
range[0] = [0.0, range[0][1] - range[0][0]];
|
||||
if !matches!(surface, b::Surface::Plane { .. }) {
|
||||
turn_frame(&mut surface, range[0][0]);
|
||||
range[0] = [0.0, range[0][1] - range[0][0]];
|
||||
}
|
||||
if matches!(surface, b::Surface::Torus { .. }) {
|
||||
range[1] = first_turn(range[1]);
|
||||
}
|
||||
@@ -255,7 +257,21 @@ impl Revolution {
|
||||
let (h0, h1) = (height(at(t[0])?), height(at(t[1])?));
|
||||
let (dh, dr) = (d.dot(self.z), d.dot(x));
|
||||
if dh.abs() <= 1e-12 * len {
|
||||
return Err(NOT_ANALYTIC); // a disc: a plane, which a revolution face never is in the kernel
|
||||
// A disc or an annulus: the plane square to the axis, its range the box round the
|
||||
// sector the line sweeps (each end's radius at both end angles and every quarter
|
||||
// turn between: in the plane a point is linear in the radius).
|
||||
let (ra, rb) = (radial(at(t[0])?), radial(at(t[1])?));
|
||||
let mut angles = vec![angle[0], angle[1]];
|
||||
let mut q = (angle[0] / FRAC_PI_2).ceil() * FRAC_PI_2;
|
||||
while q < angle[1] && angles.len() < 16 {
|
||||
angles.push(q);
|
||||
q += FRAC_PI_2;
|
||||
}
|
||||
let corners: Vec<[f64; 2]> = angles.iter().flat_map(|a| [ra, rb].map(|r| [r * a.cos(), r * a.sin()])).collect();
|
||||
let lo = |k: usize| corners.iter().map(|c| c[k]).fold(f64::INFINITY, f64::min);
|
||||
let hi = |k: usize| corners.iter().map(|c| c[k]).fold(f64::NEG_INFINITY, f64::max);
|
||||
let plane = b::Surface::Plane { origin: arr(foot), u_axis: arr(x), v_axis: arr(y) };
|
||||
return Ok((plane, [[lo(0), hi(0)], [lo(1), hi(1)]]));
|
||||
}
|
||||
let r0 = radial(s) - dr / dh * height(s);
|
||||
let v = sorted(h0, h1);
|
||||
@@ -329,6 +345,10 @@ fn analytic_uv(s: &b::Surface, p: Vec3, near: [f64; 2]) -> Option<[f64; 2]> {
|
||||
}
|
||||
a
|
||||
};
|
||||
if let b::Surface::Plane { origin, u_axis, v_axis } = s {
|
||||
let q = p - v3(*origin);
|
||||
return Some([q.dot(v3(*u_axis)), q.dot(v3(*v_axis))]);
|
||||
}
|
||||
let (frame, round) = match s {
|
||||
b::Surface::Cylinder { frame, .. } | b::Surface::Cone { frame, .. } | b::Surface::Sphere { frame, .. } => (frame, 0.0),
|
||||
b::Surface::Torus { frame, major_radius, .. } => (frame, *major_radius),
|
||||
|
||||
Reference in New Issue
Block a user