mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
brep: schema 4 types, validator and truck adapter
The plain B-rep follows SMLib's flat form with STEP's names: regions (infinite first), face/wire/vertex shells, oriented faces, faces with a domain and edge or vertex loops, oriented edges with a pcurve each, edges with an interval, a tolerance and their list of uses, vertices with a tolerance. Curves and surfaces are unbounded; their bounds are the edge interval and the face domain. Loops are relative to the surface's natural normal. Schema 3's read-only intersection and revolution records go. The validator keeps schema 3's caps and adds the structural rules: references, loop shape and connectivity, regions and closed shells, the pcurve interval, natural parameter ranges, every edge's uses (primary first, upper = !forward) and the canonical order of shells, faces, edges and vertices. A reversed face is walked backwards, as truck's edge_iter walks it, so truck's face and edge indices are the canonical ones. The truck adapter writes truck's absolute wires as the loops, numbers everything as the kernel does, and builds regions from the shells (a tessellated nesting test only when there are several closed shells). Pcurves come from pcurve::pcurves only; until it supports a face, encode fails and the body is a cache miss. The envelope signature becomes SCBREP05. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
+284
-351
@@ -1,58 +1,156 @@
|
||||
//! SolidCraft-owned portable B-rep data, independent of a modelling backend.
|
||||
//! Millimetres, radians, right-handed Z-up. See docs/native-brep.md for semantics.
|
||||
//! Validation checks structure and resource budgets, not geometric equivalence or trust.
|
||||
//!
|
||||
//! The records follow SMLib's flat form (`SmBrepData`): one record per logical use, the mated
|
||||
//! halves a kernel keeps at run time left for it to rebuild. Names are STEP's B-rep names; each
|
||||
//! type says its SMLib counterpart and STEP entity. Curves and surfaces are unbounded: an edge's
|
||||
//! bounds are its `interval`, a face's its `domain` and loops.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod curve2;
|
||||
pub mod envelope;
|
||||
pub mod history;
|
||||
mod topology;
|
||||
pub use curve2::Curve2;
|
||||
|
||||
pub const VERSION: u32 = 3;
|
||||
pub const VERSION: u32 = 4;
|
||||
pub const MAX_ITEMS: usize = 1_000_000;
|
||||
pub const MAX_SHELLS: usize = 1024;
|
||||
const MAX_DEPTH: usize = 16;
|
||||
/// Most control points along one direction of a spline (a helical thread flank written as one
|
||||
/// spline has thousands along the helix); the item budget bounds a surface's grid as a whole.
|
||||
pub const MAX_SPLINE_CONTROLS: usize = 8192;
|
||||
const TAU: f64 = std::f64::consts::TAU;
|
||||
|
||||
/// A body (SMLib `SmBrep`, STEP `MANIFOLD_SOLID_BREP`/`BREP_WITH_VOIDS`/`SHELL_BASED_SURFACE_MODEL`).
|
||||
/// Every list is in canonical order (docs/native-brep.md, "Canonical order"): a face's index
|
||||
/// here is its face index everywhere, and so for edges and vertices.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Brep {
|
||||
pub version: u32,
|
||||
/// Intersection tolerance, mm (SMLib's per-brep `XSectTol3d`).
|
||||
pub tolerance: f64,
|
||||
/// `regions[0]` is the infinite region, then the bounded regions.
|
||||
pub regions: Vec<Region>,
|
||||
pub shells: Vec<Shell>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Shell {
|
||||
pub vertices: Vec<[f64; 3]>,
|
||||
pub edges: Vec<Edge>,
|
||||
pub faces: Vec<Face>,
|
||||
pub edges: Vec<Edge>,
|
||||
pub vertices: Vec<Vertex>,
|
||||
}
|
||||
|
||||
/// A connected volume of space (SMLib `SmRegionData`). The infinite region has no outer shell
|
||||
/// and is never a void. A bounded region's `shells[0]` is its outer shell, a closed face shell;
|
||||
/// the rest are inner shells. A closed shell separates two regions and is listed by both: as
|
||||
/// the outer shell of the one it encloses and as an inner shell of the one around it. An open
|
||||
/// shell, a wire shell or a vertex shell lies in one region and is listed by that one only.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Edge {
|
||||
pub vertices: [u32; 2],
|
||||
pub curve: Curve,
|
||||
pub struct Region {
|
||||
pub shells: Vec<u32>,
|
||||
/// A bounded region that is empty space (a void), not material. False for the infinite region.
|
||||
pub void: bool,
|
||||
}
|
||||
|
||||
/// A connected boundary component (SMLib `SmShellData`; STEP `CLOSED_SHELL`/`OPEN_SHELL`,
|
||||
/// `WIRE_SHELL`, `VERTEX_SHELL`). One record stands for both runtime sides of a shell.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Shell {
|
||||
/// Connected faces, with wire edges embedded in them (`wires`, edges no face uses).
|
||||
Faces { faces: Vec<OrientedFace>, wires: Vec<u32> },
|
||||
/// A connected graph of wire edges and no faces.
|
||||
Wire { edges: Vec<u32> },
|
||||
/// One isolated point.
|
||||
Vertex { vertex: u32 },
|
||||
}
|
||||
|
||||
/// A face as one shell uses it (SMLib `SmFaceuseData`, STEP `ORIENTED_FACE`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OrientedFace {
|
||||
pub face: u32,
|
||||
/// The face's normal in this shell is its surface's natural normal (∂S/∂u × ∂S/∂v). In a
|
||||
/// closed shell the normals point out of the material.
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
/// A bounded piece of a surface (SMLib `SmFaceData`, STEP `ADVANCED_FACE`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Face {
|
||||
pub loops: Vec<Vec<EdgeUse>>,
|
||||
pub surface: Surface,
|
||||
pub forward: bool,
|
||||
/// The modelled rectangle in surface parameters, `[[u0, u1], [v0, v1]]`, inside the
|
||||
/// surface's natural domain and around every loop.
|
||||
pub domain: [[f64; 2]; 2],
|
||||
/// `loops[0]` is the outer loop, the rest inner. Loops are stored relative to the surface's
|
||||
/// natural normal, whatever side a shell uses: seen from that normal, the outer loop runs
|
||||
/// counter-clockwise and inner loops clockwise.
|
||||
pub loops: Vec<Loop>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
/// A face boundary (SMLib `SmLoopData`; STEP `FACE_BOUND` of an `EDGE_LOOP` or `VERTEX_LOOP`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Loop {
|
||||
/// A closed chain of oriented edges, in traversal order (STEP `EDGE_LOOP`).
|
||||
Edges { edges: Vec<OrientedEdge> },
|
||||
/// A one-vertex loop: a pole or a collapsed boundary (STEP `VERTEX_LOOP`). Inner only.
|
||||
Vertex { vertex: u32 },
|
||||
}
|
||||
|
||||
/// One occurrence of an edge in a face's loop (SMLib `SmEUData`, one mated pair of runtime
|
||||
/// edgeuses; STEP `ORIENTED_EDGE` with its `PCURVE`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeUse {
|
||||
pub struct OrientedEdge {
|
||||
pub edge: u32,
|
||||
/// The loop runs along the edge from interval min (start vertex) to max (end vertex).
|
||||
pub forward: bool,
|
||||
/// The edge in this face's (u, v), on the edge's own interval and running min → max
|
||||
/// whatever `forward` says. A seam edge has two uses on one face, each with its pcurve.
|
||||
pub pcurve: Curve2,
|
||||
}
|
||||
|
||||
/// Right-handed orthonormal frame; z = x_axis cross y_axis.
|
||||
/// A bounded piece of a curve (SMLib `SmEdgeData`, STEP `EDGE_CURVE`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Edge {
|
||||
pub curve: Curve,
|
||||
/// The modelled interval in the curve's own parameter, min < max.
|
||||
pub interval: [f64; 2],
|
||||
/// `[start, end]`, at interval min and max; equal for a closed edge.
|
||||
pub vertices: [u32; 2],
|
||||
/// mm.
|
||||
pub tolerance: f64,
|
||||
/// Every oriented edge on this edge, each once, the primary use (the first in canonical
|
||||
/// order) first and the rest in radial order: right-hand rule about the edge's direction.
|
||||
/// Empty for a wire edge.
|
||||
pub uses: Vec<EdgeUseRef>,
|
||||
}
|
||||
|
||||
/// Where one use of an edge sits (SMLib `SmEUData`'s radial fields): `faces[face]`,
|
||||
/// `loops[loop]`, `edges[position]`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeUseRef {
|
||||
pub face: u32,
|
||||
pub r#loop: u32,
|
||||
pub position: u32,
|
||||
/// Going round the edge, the radial cycle enters this use at the side on the surface's
|
||||
/// natural normal (SMLib's selected member of the mated pair). It follows from the loop
|
||||
/// convention: always `!forward` of the use it names.
|
||||
pub upper: bool,
|
||||
}
|
||||
|
||||
/// A point (SMLib `SmVertexData`, STEP `VERTEX_POINT`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Vertex {
|
||||
pub point: [f64; 3],
|
||||
/// mm.
|
||||
pub tolerance: f64,
|
||||
}
|
||||
|
||||
/// Right-handed orthonormal frame; z = x_axis cross y_axis (STEP `AXIS2_PLACEMENT_3D`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Frame {
|
||||
@@ -61,116 +159,61 @@ pub struct Frame {
|
||||
pub y_axis: [f64; 3],
|
||||
}
|
||||
|
||||
/// Unbounded 3D curves, each with its parameter `t` (docs/native-brep.md, "Curves").
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Curve {
|
||||
Line {
|
||||
start: [f64; 3],
|
||||
end: [f64; 3],
|
||||
},
|
||||
CircleArc {
|
||||
frame: Frame,
|
||||
radius: f64,
|
||||
angle: [f64; 2],
|
||||
},
|
||||
EllipseArc {
|
||||
frame: Frame,
|
||||
radii: [f64; 2],
|
||||
angle: [f64; 2],
|
||||
},
|
||||
Polyline {
|
||||
points: Vec<[f64; 3]>,
|
||||
},
|
||||
Spline {
|
||||
knots: Vec<f64>,
|
||||
points: Vec<[f64; 3]>,
|
||||
},
|
||||
RationalSpline {
|
||||
knots: Vec<f64>,
|
||||
homogeneous: Vec<[f64; 4]>,
|
||||
},
|
||||
Intersection {
|
||||
knots: Vec<f64>,
|
||||
points: Vec<[f64; 3]>,
|
||||
fitting_tolerance: f64,
|
||||
faces: Option<[u32; 2]>,
|
||||
},
|
||||
/// The line through both points: `start + t * (end - start)`, t = 0 at `start` and 1 at
|
||||
/// `end` (STEP `LINE`; truck's `Line`).
|
||||
Line { start: [f64; 3], end: [f64; 3] },
|
||||
/// `origin + radius * (cos t * x + sin t * y)`, t the angle from x (STEP `CIRCLE`).
|
||||
Circle { frame: Frame, radius: f64 },
|
||||
/// `origin + radii[0] cos t * x + radii[1] sin t * y` (STEP `ELLIPSE`).
|
||||
Ellipse { frame: Frame, radii: [f64; 2] },
|
||||
/// Straight segments; segment i spans t in [i, i + 1] (STEP `POLYLINE`).
|
||||
Polyline { points: Vec<[f64; 3]> },
|
||||
/// A B-spline on its knots (STEP `B_SPLINE_CURVE_WITH_KNOTS`).
|
||||
Spline { knots: Vec<f64>, points: Vec<[f64; 3]> },
|
||||
/// A NURBS curve: homogeneous `[x*w, y*w, z*w, w]` coefficients (STEP `RATIONAL_B_SPLINE_CURVE`).
|
||||
RationalSpline { knots: Vec<f64>, homogeneous: Vec<[f64; 4]> },
|
||||
/// The intersection of two surfaces, traced from `leader` (a polyline or spline near it)
|
||||
/// to within `tolerance`: the procedural curve itself, exactly as its producer holds it.
|
||||
SurfaceIntersection {
|
||||
surfaces: [Box<Surface>; 2],
|
||||
leader: Box<Curve>,
|
||||
tolerance: f64,
|
||||
},
|
||||
/// to within `tolerance`: the procedural curve itself, exactly as its producer holds it
|
||||
/// (STEP `INTERSECTION_CURVE`). t is the leader's parameter.
|
||||
SurfaceIntersection { surfaces: [Box<Surface>; 2], leader: Box<Curve>, tolerance: f64 },
|
||||
}
|
||||
|
||||
/// Unbounded surfaces, each with its (u, v) (docs/native-brep.md, "Surfaces"). Natural
|
||||
/// normals are ∂S/∂u × ∂S/∂v.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Surface {
|
||||
Plane {
|
||||
origin: [f64; 3],
|
||||
u_axis: [f64; 3],
|
||||
v_axis: [f64; 3],
|
||||
},
|
||||
/// The plane through three points, parameter (1, 0) at `u_point` and (0, 1) at `v_point`:
|
||||
/// the points themselves, as its producer holds them (axes worked out from them would round).
|
||||
PlaneThrough {
|
||||
origin: [f64; 3],
|
||||
u_point: [f64; 3],
|
||||
v_point: [f64; 3],
|
||||
},
|
||||
Cylinder {
|
||||
frame: Frame,
|
||||
radius: f64,
|
||||
angle: [f64; 2],
|
||||
height: [f64; 2],
|
||||
},
|
||||
Cone {
|
||||
frame: Frame,
|
||||
radius_at_origin: f64,
|
||||
half_angle: f64,
|
||||
angle: [f64; 2],
|
||||
height: [f64; 2],
|
||||
},
|
||||
Sphere {
|
||||
frame: Frame,
|
||||
radius: f64,
|
||||
longitude: [f64; 2],
|
||||
latitude: [f64; 2],
|
||||
},
|
||||
Torus {
|
||||
frame: Frame,
|
||||
major_radius: f64,
|
||||
minor_radius: f64,
|
||||
longitude: [f64; 2],
|
||||
meridian: [f64; 2],
|
||||
},
|
||||
Spline {
|
||||
u_knots: Vec<f64>,
|
||||
v_knots: Vec<f64>,
|
||||
points: Vec<Vec<[f64; 3]>>,
|
||||
},
|
||||
RationalSpline {
|
||||
u_knots: Vec<f64>,
|
||||
v_knots: Vec<f64>,
|
||||
homogeneous: Vec<Vec<[f64; 4]>>,
|
||||
},
|
||||
Revolution {
|
||||
profile: Box<Curve>,
|
||||
origin: [f64; 3],
|
||||
axis: [f64; 3],
|
||||
angle: [f64; 2],
|
||||
},
|
||||
/// A full revolution of `profile` about `origin` and unit `axis`, then mapped by the
|
||||
/// column-major affine `matrix`, with the normal reversed unless `forward`: the
|
||||
/// revolution exactly as its producer holds it, nothing baked into the profile.
|
||||
PlacedRevolution {
|
||||
profile: Box<Curve>,
|
||||
origin: [f64; 3],
|
||||
axis: [f64; 3],
|
||||
matrix: [[f64; 4]; 4],
|
||||
forward: bool,
|
||||
},
|
||||
/// `origin + u * u_axis + v * v_axis` (STEP `PLANE`).
|
||||
Plane { origin: [f64; 3], u_axis: [f64; 3], v_axis: [f64; 3] },
|
||||
/// `origin + u * (u_point - origin) + v * (v_point - origin)`: the plane through three points,
|
||||
/// as its producer holds them (axes worked out from them would round; truck's `Plane`).
|
||||
PlaneThrough { origin: [f64; 3], u_point: [f64; 3], v_point: [f64; 3] },
|
||||
/// `origin + v z + radius (cos u x + sin u y)`: u the angle, v the height (STEP
|
||||
/// `CYLINDRICAL_SURFACE`).
|
||||
Cylinder { frame: Frame, radius: f64 },
|
||||
/// `origin + v z + r(v) (cos u x + sin u y)`, `r(v) = radius_at_origin + v tan(half_angle)`,
|
||||
/// v ≥ the apex's height (STEP `CONICAL_SURFACE`).
|
||||
Cone { frame: Frame, radius_at_origin: f64, half_angle: f64 },
|
||||
/// `origin + radius (cos v (cos u x + sin u y) + sin v z)`: u the longitude, v the latitude
|
||||
/// in [-π/2, π/2] (STEP `SPHERICAL_SURFACE`).
|
||||
Sphere { frame: Frame, radius: f64 },
|
||||
/// `origin + (R + r cos v)(cos u x + sin u y) + r sin v z`: u the longitude, v the meridian
|
||||
/// angle (STEP `TOROIDAL_SURFACE`).
|
||||
Torus { frame: Frame, major_radius: f64, minor_radius: f64 },
|
||||
/// A tensor-product B-spline: `points[i][j]`, i along u (STEP `B_SPLINE_SURFACE_WITH_KNOTS`).
|
||||
Spline { u_knots: Vec<f64>, v_knots: Vec<f64>, points: Vec<Vec<[f64; 3]>> },
|
||||
/// A NURBS surface: homogeneous `[x*w, y*w, z*w, w]` coefficients.
|
||||
RationalSpline { u_knots: Vec<f64>, v_knots: Vec<f64>, homogeneous: Vec<Vec<[f64; 4]>> },
|
||||
/// `profile` revolved about `origin` and unit `axis` (right-hand rule), then mapped by the
|
||||
/// column-major affine `matrix`: the revolution exactly as its producer holds it, nothing
|
||||
/// baked into the profile (truck's `RevolutedCurve` under a `Processor`). When `forward`,
|
||||
/// u is the profile's parameter and v the angle in [0, 2π); otherwise u and v swap (u the
|
||||
/// angle, v the profile's), which reverses the natural normal.
|
||||
PlacedRevolution { profile: Box<Curve>, origin: [f64; 3], axis: [f64; 3], matrix: [[f64; 4]; 4], forward: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
@@ -201,7 +244,7 @@ fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
|
||||
budget.add(values.len())?;
|
||||
numbers(values.iter().copied())?;
|
||||
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
|
||||
require((2..=MAX_SPLINE_CONTROLS).contains(&count) && (1..=16).contains(°ree) && degree < count, "spline degree or size")?;
|
||||
require((2..=4096).contains(&count) && (1..=16).contains(°ree) && degree < count, "spline degree or size")?;
|
||||
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
|
||||
require(values.get(degree) < values.get(count), "empty spline domain")?;
|
||||
// No knot may have multiplicity greater than degree + 1.
|
||||
@@ -233,17 +276,87 @@ fn frame(f: &Frame, budget: &mut Budget) -> Result<()> {
|
||||
let (x, y) = (vector(f.x_axis), vector(f.y_axis));
|
||||
require((x.len() - 1.).abs() <= 1e-8 && (y.len() - 1.).abs() <= 1e-8 && x.dot(y).abs() <= 1e-8, "nonorthonormal frame")
|
||||
}
|
||||
fn range(r: &[f64; 2]) -> Result<()> {
|
||||
numbers(*r)?;
|
||||
require(r[0] < r[1], "empty parameter range")
|
||||
}
|
||||
fn angle(r: &[f64; 2]) -> Result<()> {
|
||||
range(r)?;
|
||||
require(r[1] - r[0] <= std::f64::consts::TAU + 1e-12, "angular range exceeds one turn")
|
||||
}
|
||||
fn radius(r: f64) -> Result<()> {
|
||||
require(r.is_finite() && (1e-12..=1e12).contains(&r), "radius")
|
||||
}
|
||||
/// A tolerance in mm.
|
||||
fn tolerance(t: f64) -> Result<()> {
|
||||
require(t.is_finite() && t > 0. && t <= 1e3, "tolerance")
|
||||
}
|
||||
|
||||
/// A parameter's natural range: periodic with period 2π, or between two bounds (infinite for
|
||||
/// an unbounded direction).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Natural {
|
||||
Periodic,
|
||||
Range(f64, f64),
|
||||
}
|
||||
const UNBOUNDED: Natural = Natural::Range(f64::NEG_INFINITY, f64::INFINITY);
|
||||
|
||||
/// The active interval of a spline's knots (already validated).
|
||||
fn knot_domain(k: &[f64], count: usize) -> Natural {
|
||||
let degree = k.len().saturating_sub(count + 1);
|
||||
match (k.get(degree), k.get(count)) {
|
||||
(Some(a), Some(b)) => Natural::Range(*a, *b),
|
||||
_ => Natural::Range(0., 0.),
|
||||
}
|
||||
}
|
||||
fn curve_natural(c: &Curve) -> Natural {
|
||||
match c {
|
||||
Curve::Line { .. } => UNBOUNDED,
|
||||
Curve::Circle { .. } | Curve::Ellipse { .. } => Natural::Periodic,
|
||||
Curve::Polyline { points } => Natural::Range(0., points.len().saturating_sub(1) as f64),
|
||||
Curve::Spline { knots, points } => knot_domain(knots, points.len()),
|
||||
Curve::RationalSpline { knots, homogeneous } => knot_domain(knots, homogeneous.len()),
|
||||
Curve::SurfaceIntersection { leader, .. } => curve_natural(leader),
|
||||
}
|
||||
}
|
||||
fn surface_natural(s: &Surface) -> [Natural; 2] {
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
match s {
|
||||
Surface::Plane { .. } | Surface::PlaneThrough { .. } => [UNBOUNDED; 2],
|
||||
Surface::Cylinder { .. } => [Natural::Periodic, UNBOUNDED],
|
||||
Surface::Cone { radius_at_origin, half_angle, .. } => {
|
||||
[Natural::Periodic, Natural::Range(-radius_at_origin / half_angle.tan(), f64::INFINITY)]
|
||||
}
|
||||
Surface::Sphere { .. } => [Natural::Periodic, Natural::Range(-FRAC_PI_2, FRAC_PI_2)],
|
||||
Surface::Torus { .. } => [Natural::Periodic; 2],
|
||||
Surface::Spline { u_knots, v_knots, points } => {
|
||||
[knot_domain(u_knots, points.len()), knot_domain(v_knots, points.first().map_or(0, Vec::len))]
|
||||
}
|
||||
Surface::RationalSpline { u_knots, v_knots, homogeneous } => {
|
||||
[knot_domain(u_knots, homogeneous.len()), knot_domain(v_knots, homogeneous.first().map_or(0, Vec::len))]
|
||||
}
|
||||
Surface::PlacedRevolution { profile, forward: true, .. } => [curve_natural(profile), Natural::Periodic],
|
||||
Surface::PlacedRevolution { profile, forward: false, .. } => [Natural::Periodic, curve_natural(profile)],
|
||||
}
|
||||
}
|
||||
impl Curve {
|
||||
/// The range of `t`. Call on a validated record.
|
||||
pub fn natural(&self) -> Natural {
|
||||
curve_natural(self)
|
||||
}
|
||||
}
|
||||
impl Surface {
|
||||
/// The ranges of u and v. Call on a validated record.
|
||||
pub fn natural(&self) -> [Natural; 2] {
|
||||
surface_natural(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// `[min, max]`: finite, min < max, inside `natural` (at most one turn long where periodic).
|
||||
fn interval(r: &[f64; 2], natural: Natural) -> Result<()> {
|
||||
numbers(*r)?;
|
||||
require(r[0] < r[1], "empty parameter interval")?;
|
||||
match natural {
|
||||
Natural::Periodic => {
|
||||
let slack = 1e-12 * (1. + r[0].abs().max(r[1].abs()));
|
||||
require(r[1] - r[0] <= TAU + slack, "parameter interval exceeds one turn")
|
||||
}
|
||||
Natural::Range(a, b) => require(a <= r[0] && r[1] <= b, "parameter interval outside the natural domain"),
|
||||
}
|
||||
}
|
||||
|
||||
fn curve(c: &Curve, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
require(depth <= MAX_DEPTH, "geometry depth")?;
|
||||
budget.add(1)?;
|
||||
@@ -252,17 +365,13 @@ fn curve(c: &Curve, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
points(&[*start, *end], budget)?;
|
||||
require(start != end, "collapsed line")
|
||||
}
|
||||
Curve::CircleArc { frame: f, radius: r, angle: a } => {
|
||||
Curve::Circle { frame: f, radius: r } => {
|
||||
frame(f, budget)?;
|
||||
radius(*r)?;
|
||||
angle(a)
|
||||
radius(*r)
|
||||
}
|
||||
Curve::EllipseArc { frame: f, radii, angle: a } => {
|
||||
Curve::Ellipse { frame: f, radii } => {
|
||||
frame(f, budget)?;
|
||||
for r in radii {
|
||||
radius(*r)?;
|
||||
}
|
||||
angle(a)
|
||||
radii.iter().try_for_each(|r| radius(*r))
|
||||
}
|
||||
Curve::Polyline { points: p } => {
|
||||
require(p.len() >= 2, "empty polyline")?;
|
||||
@@ -278,11 +387,6 @@ fn curve(c: &Curve, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
points(p, budget)?;
|
||||
weights(p)
|
||||
}
|
||||
Curve::Intersection { knots: k, points: p, fitting_tolerance, .. } => {
|
||||
require((1e-12..=1.0).contains(fitting_tolerance), "intersection fitting tolerance")?;
|
||||
knots(k, p.len(), budget)?;
|
||||
points(p, budget)
|
||||
}
|
||||
Curve::SurfaceIntersection { surfaces, leader, tolerance } => {
|
||||
require(tolerance.is_finite() && (0.0..=1e3).contains(tolerance), "intersection tolerance")?;
|
||||
for s in surfaces {
|
||||
@@ -319,34 +423,20 @@ fn surface(s: &Surface, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
let o = vector(*origin);
|
||||
require((vector(*u_point) - o).cross(vector(*v_point) - o).len() > 1e-12, "degenerate plane")
|
||||
}
|
||||
Surface::Cylinder { frame: f, radius: r, angle: a, height } => {
|
||||
Surface::Cylinder { frame: f, radius: r } | Surface::Sphere { frame: f, radius: r } => {
|
||||
frame(f, budget)?;
|
||||
radius(*r)?;
|
||||
angle(a)?;
|
||||
range(height)
|
||||
radius(*r)
|
||||
}
|
||||
Surface::Cone { frame: f, radius_at_origin, half_angle, angle: a, height } => {
|
||||
Surface::Cone { frame: f, radius_at_origin, half_angle } => {
|
||||
frame(f, budget)?;
|
||||
numbers([*radius_at_origin, *half_angle])?;
|
||||
require(*radius_at_origin >= 0. && *half_angle > 0. && *half_angle < std::f64::consts::FRAC_PI_2, "cone dimensions")?;
|
||||
angle(a)?;
|
||||
range(height)?;
|
||||
require(height.iter().all(|h| radius_at_origin + h * half_angle.tan() >= 0.), "negative cone radius")
|
||||
require(*radius_at_origin >= 0. && *half_angle >= 1e-12 && *half_angle < std::f64::consts::FRAC_PI_2, "cone dimensions")
|
||||
}
|
||||
Surface::Sphere { frame: f, radius: r, longitude, latitude } => {
|
||||
frame(f, budget)?;
|
||||
radius(*r)?;
|
||||
angle(longitude)?;
|
||||
range(latitude)?;
|
||||
require(latitude[0] >= -std::f64::consts::FRAC_PI_2 && latitude[1] <= std::f64::consts::FRAC_PI_2, "sphere latitude")
|
||||
}
|
||||
Surface::Torus { frame: f, major_radius, minor_radius, longitude, meridian } => {
|
||||
Surface::Torus { frame: f, major_radius, minor_radius } => {
|
||||
frame(f, budget)?;
|
||||
radius(*major_radius)?;
|
||||
radius(*minor_radius)?;
|
||||
require(major_radius > minor_radius, "ring torus radii")?;
|
||||
angle(longitude)?;
|
||||
angle(meridian)
|
||||
require(major_radius > minor_radius, "ring torus radii")
|
||||
}
|
||||
Surface::Spline { u_knots, v_knots, points: p } => grid(u_knots, v_knots, p, budget),
|
||||
Surface::RationalSpline { u_knots, v_knots, homogeneous: p } => {
|
||||
@@ -356,12 +446,6 @@ fn surface(s: &Surface, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Surface::Revolution { profile, origin, axis, angle: a } => {
|
||||
points(&[*origin, *axis], budget)?;
|
||||
require((vector(*axis).len() - 1.0).abs() <= 1e-8, "revolution axis is not unit length")?;
|
||||
angle(a)?;
|
||||
curve(profile, depth + 1, budget)
|
||||
}
|
||||
Surface::PlacedRevolution { profile, origin, axis, matrix, .. } => {
|
||||
points(&[*origin, *axis], budget)?;
|
||||
require((vector(*axis).len() - 1.0).abs() <= 1e-8, "revolution axis is not unit length")?;
|
||||
@@ -374,197 +458,46 @@ fn surface(s: &Surface, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
}
|
||||
|
||||
impl Brep {
|
||||
/// Validate portable structure without calling a kernel or evaluating geometry.
|
||||
/// Does not certify watertightness, surface coincidence, or authoritative state.
|
||||
/// Validate portable structure without calling a kernel or evaluating geometry: resource
|
||||
/// budgets, geometry records, parameter ranges, references, loops, regions, every edge's
|
||||
/// list of uses and the canonical order. Does not certify watertightness, that edges lie on
|
||||
/// their surfaces or that pcurves lift onto their edges.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require(self.version == VERSION, "unsupported version")?;
|
||||
require(!self.shells.is_empty() && self.shells.len() <= 1024, "shell count")?;
|
||||
tolerance(self.tolerance)?;
|
||||
require(!self.shells.is_empty() && self.shells.len() <= MAX_SHELLS, "shell count")?;
|
||||
require(!self.regions.is_empty() && self.regions.len() <= 2 * MAX_SHELLS + 1, "region count")?;
|
||||
let mut budget = Budget(0);
|
||||
for shell in &self.shells {
|
||||
points(&shell.vertices, &mut budget)?;
|
||||
budget.add(shell.edges.len())?;
|
||||
budget.add(shell.faces.len())?;
|
||||
require(!shell.faces.is_empty(), "empty shell")?;
|
||||
for edge in &shell.edges {
|
||||
require(edge.vertices.iter().all(|&v| (v as usize) < shell.vertices.len()), "edge vertices")?;
|
||||
curve(&edge.curve, 0, &mut budget)?;
|
||||
if let Curve::Intersection { faces: Some(hints), .. } = &edge.curve {
|
||||
require(hints.iter().all(|&i| (i as usize) < shell.faces.len()), "intersection face hint")?;
|
||||
}
|
||||
}
|
||||
for face in &shell.faces {
|
||||
require(!face.loops.is_empty(), "face loops")?;
|
||||
surface(&face.surface, 0, &mut budget)?;
|
||||
for wire in &face.loops {
|
||||
require(!wire.is_empty(), "empty wire")?;
|
||||
budget.add(wire.len())?;
|
||||
let mut first = None;
|
||||
let mut last = None;
|
||||
for use_ in wire {
|
||||
let edge = shell.edges.get(use_.edge as usize).ok_or(Invalid("edge reference"))?;
|
||||
let [a, b] = edge.vertices;
|
||||
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
|
||||
require(last.is_none_or(|v| v == start), "disconnected wire")?;
|
||||
first.get_or_insert(start);
|
||||
last = Some(end);
|
||||
budget.add(self.regions.len() + self.faces.len() + self.edges.len())?;
|
||||
for v in &self.vertices {
|
||||
points(&[v.point], &mut budget)?;
|
||||
tolerance(v.tolerance)?;
|
||||
}
|
||||
for e in &self.edges {
|
||||
curve(&e.curve, 0, &mut budget)?;
|
||||
interval(&e.interval, curve_natural(&e.curve))?;
|
||||
require(e.vertices.iter().all(|&v| (v as usize) < self.vertices.len()), "edge vertices")?;
|
||||
tolerance(e.tolerance)?;
|
||||
budget.add(e.uses.len())?;
|
||||
}
|
||||
for f in &self.faces {
|
||||
surface(&f.surface, 0, &mut budget)?;
|
||||
let [u, v] = surface_natural(&f.surface);
|
||||
interval(&f.domain[0], u)?;
|
||||
interval(&f.domain[1], v)?;
|
||||
budget.add(f.loops.len())?;
|
||||
for l in &f.loops {
|
||||
if let Loop::Edges { edges } = l {
|
||||
budget.add(edges.len())?;
|
||||
for e in edges {
|
||||
curve2::curve2(&e.pcurve, &mut budget)?;
|
||||
}
|
||||
require(first == last, "open wire")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
topology::check(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn triangle() -> Brep {
|
||||
let vertices = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
|
||||
let edges = (0..3)
|
||||
.map(|i| Edge { vertices: [i as u32, ((i + 1) % 3) as u32], curve: Curve::Line { start: vertices[i], end: vertices[(i + 1) % 3] } })
|
||||
.collect();
|
||||
Brep {
|
||||
version: VERSION,
|
||||
shells: vec![Shell {
|
||||
vertices,
|
||||
edges,
|
||||
faces: vec![Face {
|
||||
loops: vec![(0..3).map(|edge| EdgeUse { edge, forward: true }).collect()],
|
||||
surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] },
|
||||
forward: true,
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn portable_open_face_round_trip_without_a_kernel() {
|
||||
let body = triangle();
|
||||
body.validate().unwrap();
|
||||
let bytes = serde_json::to_vec(&body).unwrap();
|
||||
let restored: Brep = serde_json::from_slice(&bytes).unwrap();
|
||||
restored.validate().unwrap();
|
||||
assert_eq!(body, restored);
|
||||
assert!(!String::from_utf8(bytes).unwrap().contains("truck"));
|
||||
}
|
||||
#[test]
|
||||
fn topology_references_and_wire_orientation_are_checked() {
|
||||
let mut b = triangle();
|
||||
b.shells[0].faces[0].loops[0][0].edge = u32::MAX;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].faces[0].loops[0][1].forward = false;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].edges[0].vertices[0] = u32::MAX;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.version += 1;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].vertices[0][0] = f64::NAN;
|
||||
assert!(b.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn closed_edge_can_share_its_endpoint_vertex() {
|
||||
let mut body = triangle();
|
||||
body.shells[0].vertices.truncate(1);
|
||||
body.shells[0].edges =
|
||||
vec![Edge { vertices: [0, 0], curve: Curve::Polyline { points: vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 0.]] } }];
|
||||
body.shells[0].faces[0].loops = vec![vec![EdgeUse { edge: 0, forward: true }]];
|
||||
body.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_domains_weights_and_nesting_are_bounded() {
|
||||
let mut budget = Budget(0);
|
||||
assert!(knots(&[0., 0., 1., 1.], 2, &mut budget).is_ok());
|
||||
for bad in [vec![0., 0., 0., 0.], vec![0., 1., 0., 1.], vec![0., 0., f64::INFINITY, 1.]] {
|
||||
assert!(knots(&bad, 2, &mut Budget(0)).is_err());
|
||||
}
|
||||
let c = Curve::RationalSpline { knots: vec![0., 0., 1., 1.], homogeneous: vec![[0., 0., 0., 0.], [1., 0., 0., 1.]] };
|
||||
assert!(curve(&c, 0, &mut Budget(0)).is_err());
|
||||
let p = Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [2., 0., 0.] };
|
||||
assert!(surface(&p, 0, &mut Budget(0)).is_err());
|
||||
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
|
||||
assert!(Budget(MAX_ITEMS).add(1).is_err());
|
||||
// Control points along one direction: up to the cap, not one more (a clamped cubic).
|
||||
let clamped = |count: usize| {
|
||||
let mut k = vec![0.0; 4];
|
||||
k.extend((1..count - 3).map(|i| i as f64));
|
||||
k.extend([(count - 3) as f64; 4]);
|
||||
k
|
||||
};
|
||||
assert!(knots(&clamped(MAX_SPLINE_CONTROLS), MAX_SPLINE_CONTROLS, &mut Budget(0)).is_ok());
|
||||
assert!(knots(&clamped(MAX_SPLINE_CONTROLS + 1), MAX_SPLINE_CONTROLS + 1, &mut Budget(0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_procedural_records_are_validated_and_round_trip() {
|
||||
let plane = |z: f64| Box::new(Surface::Plane { origin: [0., 0., z], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] });
|
||||
let leader = Box::new(Curve::Polyline { points: vec![[0.; 3], [0.; 3], [1., 0., 0.]] });
|
||||
let c = Curve::SurfaceIntersection { surfaces: [plane(0.), plane(1.)], leader: leader.clone(), tolerance: 1e-6 };
|
||||
curve(&c, 0, &mut Budget(0)).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Curve>(&serde_json::to_string(&c).unwrap()).unwrap(), c);
|
||||
let line = Box::new(Curve::Line { start: [0.; 3], end: [1.; 3] });
|
||||
for bad in [
|
||||
Curve::SurfaceIntersection { surfaces: [plane(0.), plane(1.)], leader: line.clone(), tolerance: 1e-6 },
|
||||
Curve::SurfaceIntersection { surfaces: [plane(0.), plane(1.)], leader: leader.clone(), tolerance: f64::NAN },
|
||||
Curve::SurfaceIntersection { surfaces: [plane(f64::INFINITY), plane(1.)], leader, tolerance: 1e-6 },
|
||||
] {
|
||||
assert!(curve(&bad, 0, &mut Budget(0)).is_err());
|
||||
}
|
||||
let identity = [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]];
|
||||
let s = Surface::PlacedRevolution { profile: line.clone(), origin: [0.; 3], axis: [0., 0., 1.], matrix: identity, forward: false };
|
||||
surface(&s, 0, &mut Budget(0)).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Surface>(&serde_json::to_string(&s).unwrap()).unwrap(), s);
|
||||
let mut flat = identity;
|
||||
flat[2] = [0.; 4];
|
||||
let bad = Surface::PlacedRevolution { profile: line, origin: [0.; 3], axis: [0., 0., 1.], matrix: flat, forward: true };
|
||||
assert!(surface(&bad, 0, &mut Budget(0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_records_have_independent_frames_and_domains() {
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
let f = Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
|
||||
let surfaces = [
|
||||
Surface::Cylinder { frame: f.clone(), radius: 2., angle: [0., TAU], height: [-1., 3.] },
|
||||
Surface::Cone { frame: f.clone(), radius_at_origin: 2., half_angle: 0.2, angle: [0., TAU], height: [0., 3.] },
|
||||
Surface::Sphere { frame: f.clone(), radius: 2., longitude: [0., TAU], latitude: [-FRAC_PI_2, FRAC_PI_2] },
|
||||
Surface::Torus { frame: f.clone(), major_radius: 4., minor_radius: 1., longitude: [0., TAU], meridian: [0., TAU] },
|
||||
Surface::Revolution {
|
||||
profile: Box::new(Curve::Line { start: [2., 0., 0.], end: [2., 0., 3.] }),
|
||||
origin: [0.; 3],
|
||||
axis: [0., 0., 1.],
|
||||
angle: [0., TAU],
|
||||
},
|
||||
];
|
||||
for s in surfaces {
|
||||
surface(&s, 0, &mut Budget(0)).unwrap();
|
||||
let encoded = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Surface>(&encoded).unwrap(), s);
|
||||
assert!(!encoded.contains("columns") && !encoded.contains("forward"));
|
||||
}
|
||||
for c in [
|
||||
Curve::CircleArc { frame: f.clone(), radius: 2., angle: [0., FRAC_PI_2] },
|
||||
Curve::EllipseArc { frame: f.clone(), radii: [2., 3.], angle: [0., TAU] },
|
||||
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1.; 3]], fitting_tolerance: 1e-6, faces: None },
|
||||
] {
|
||||
curve(&c, 0, &mut Budget(0)).unwrap();
|
||||
let encoded = serde_json::to_string(&c).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Curve>(&encoded).unwrap(), c);
|
||||
assert!(!encoded.contains("leader") && !encoded.contains("surfaces"));
|
||||
}
|
||||
let mut bad = f;
|
||||
bad.y_axis = bad.x_axis;
|
||||
assert!(frame(&bad, &mut Budget(0)).is_err());
|
||||
assert!(angle(&[0., TAU + 0.1]).is_err());
|
||||
assert!(range(&[2., 1.]).is_err());
|
||||
assert!(radius(f64::NAN).is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].edges[0].curve =
|
||||
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1., 0., 0.]], fitting_tolerance: 1e-6, faces: Some([0, 1]) };
|
||||
assert!(b.validate().is_err());
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -31,7 +31,6 @@ impl Curve2 {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // called by the schema 4 validator
|
||||
pub(super) fn curve2(c: &Curve2, budget: &mut Budget) -> Result<()> {
|
||||
budget.add(1)?;
|
||||
match c {
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
use super::*;
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
|
||||
fn segment(a: f64, b: f64) -> Curve2 {
|
||||
Curve2::Spline { knots: vec![a, a, b, b], points: vec![[a, 0.], [b, 1.]] }
|
||||
}
|
||||
fn plane() -> Surface {
|
||||
Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }
|
||||
}
|
||||
fn a_frame() -> Frame {
|
||||
Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }
|
||||
}
|
||||
|
||||
/// Every edge's uses as the canonical walk meets them (faces are all forward here).
|
||||
fn list_uses(b: &mut Brep) {
|
||||
for e in &mut b.edges {
|
||||
e.uses.clear();
|
||||
}
|
||||
for (fi, f) in b.faces.iter().enumerate() {
|
||||
for (li, l) in f.loops.iter().enumerate() {
|
||||
if let Loop::Edges { edges } = l {
|
||||
for (p, u) in edges.iter().enumerate() {
|
||||
let at = EdgeUseRef { face: fi as u32, r#loop: li as u32, position: p as u32, upper: !u.forward };
|
||||
b.edges[u.edge as usize].uses.push(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Planar faces, one per vertex cycle, grouped into shells; edges and vertices numbered in
|
||||
/// canonical order as the cycles first meet them.
|
||||
fn faceted(points: &[[f64; 3]], shells: &[&[&[usize]]], regions: Vec<Region>) -> Brep {
|
||||
let mut b = Brep { version: VERSION, tolerance: 1e-7, regions, shells: Vec::new(), faces: Vec::new(), edges: Vec::new(), vertices: Vec::new() };
|
||||
let mut vertex_of = vec![None; points.len()];
|
||||
let mut vertex = |b: &mut Brep, p: usize| -> u32 {
|
||||
*vertex_of[p].get_or_insert_with(|| {
|
||||
b.vertices.push(Vertex { point: points[p], tolerance: 1e-7 });
|
||||
b.vertices.len() as u32 - 1
|
||||
})
|
||||
};
|
||||
let mut by_pair = std::collections::HashMap::new();
|
||||
for cycles in shells {
|
||||
let mut uses = Vec::new();
|
||||
for cycle in cycles.iter() {
|
||||
let mut loop_ = Vec::new();
|
||||
for k in 0..cycle.len() {
|
||||
let (a, z) = (cycle[k], cycle[(k + 1) % cycle.len()]);
|
||||
let (edge, forward) = match by_pair.get(&(z, a)) {
|
||||
Some(&e) => (e, false),
|
||||
None => {
|
||||
let vs = [vertex(&mut b, a), vertex(&mut b, z)];
|
||||
b.edges.push(Edge {
|
||||
curve: Curve::Line { start: points[a], end: points[z] },
|
||||
interval: [0., 1.],
|
||||
vertices: vs,
|
||||
tolerance: 1e-7,
|
||||
uses: vec![],
|
||||
});
|
||||
let e = b.edges.len() as u32 - 1;
|
||||
by_pair.insert((a, z), e);
|
||||
(e, true)
|
||||
}
|
||||
};
|
||||
loop_.push(OrientedEdge { edge, forward, pcurve: segment(0., 1.) });
|
||||
}
|
||||
uses.push(OrientedFace { face: b.faces.len() as u32, forward: true });
|
||||
b.faces.push(Face { surface: plane(), domain: [[-10., 10.], [-10., 10.]], loops: vec![Loop::Edges { edges: loop_ }] });
|
||||
}
|
||||
b.shells.push(Shell::Faces { faces: uses, wires: vec![] });
|
||||
}
|
||||
list_uses(&mut b);
|
||||
b
|
||||
}
|
||||
|
||||
fn infinite(shells: Vec<u32>) -> Region {
|
||||
Region { shells, void: false }
|
||||
}
|
||||
const SQUARE: [[f64; 3]; 4] = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
|
||||
fn sheet() -> Brep {
|
||||
faceted(&SQUARE, &[&[&[0, 1, 2, 3]]], vec![infinite(vec![0])])
|
||||
}
|
||||
const TETRA: [[f64; 3]; 4] = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]];
|
||||
const TETRA_FACES: [&[usize]; 4] = [&[0, 2, 1], &[0, 1, 3], &[1, 2, 3], &[0, 3, 2]];
|
||||
fn tetra() -> Brep {
|
||||
faceted(&TETRA, &[&TETRA_FACES], vec![infinite(vec![0]), Region { shells: vec![0], void: false }])
|
||||
}
|
||||
/// A tetrahedron with a tetrahedral void inside it.
|
||||
fn hollow() -> Brep {
|
||||
let mut points = TETRA.to_vec();
|
||||
points.extend(TETRA.map(|p| p.map(|x| 0.2 + 0.1 * x)));
|
||||
let inner: Vec<Vec<usize>> = TETRA_FACES.iter().map(|c| c.iter().rev().map(|i| i + 4).collect()).collect();
|
||||
let inner: Vec<&[usize]> = inner.iter().map(Vec::as_slice).collect();
|
||||
faceted(
|
||||
&points,
|
||||
&[&TETRA_FACES, &inner],
|
||||
vec![infinite(vec![0]), Region { shells: vec![0, 1], void: false }, Region { shells: vec![1], void: true }],
|
||||
)
|
||||
}
|
||||
/// The side of a cylinder: one face bounded by two circles and a seam edge used twice.
|
||||
fn tube() -> Brep {
|
||||
let f = a_frame();
|
||||
let circle = |z: f64| Curve::Circle { frame: Frame { origin: [1., 2., 3. + z], ..f.clone() }, radius: 2. };
|
||||
let edge = |curve, interval, vertices| Edge { curve, interval, vertices, tolerance: 1e-7, uses: vec![] };
|
||||
let mut b = Brep {
|
||||
version: VERSION,
|
||||
tolerance: 1e-7,
|
||||
regions: vec![infinite(vec![0])],
|
||||
shells: vec![Shell::Faces { faces: vec![OrientedFace { face: 0, forward: true }], wires: vec![] }],
|
||||
faces: vec![Face {
|
||||
surface: Surface::Cylinder { frame: f.clone(), radius: 2. },
|
||||
domain: [[0., TAU], [0., 5.]],
|
||||
loops: vec![Loop::Edges {
|
||||
edges: vec![
|
||||
OrientedEdge { edge: 0, forward: true, pcurve: segment(0., TAU) },
|
||||
OrientedEdge { edge: 1, forward: true, pcurve: segment(0., 1.) },
|
||||
OrientedEdge { edge: 2, forward: false, pcurve: segment(0., TAU) },
|
||||
OrientedEdge { edge: 1, forward: false, pcurve: segment(0., 1.) },
|
||||
],
|
||||
}],
|
||||
}],
|
||||
edges: vec![
|
||||
edge(circle(0.), [0., TAU], [0, 0]),
|
||||
edge(Curve::Line { start: [3., 2., 3.], end: [3., 2., 8.] }, [0., 1.], [0, 1]),
|
||||
edge(circle(5.), [0., TAU], [1, 1]),
|
||||
],
|
||||
vertices: vec![Vertex { point: [3., 2., 3.], tolerance: 1e-7 }, Vertex { point: [3., 2., 8.], tolerance: 1e-7 }],
|
||||
};
|
||||
list_uses(&mut b);
|
||||
b
|
||||
}
|
||||
/// The sheet with a vertex loop inside it, a wire shell and a vertex shell beside it.
|
||||
fn sheet_with_lower_forms() -> Brep {
|
||||
let mut b = sheet();
|
||||
let n = b.vertices.len() as u32;
|
||||
// Vertex order: edge vertices first (the wire edge's), then the vertex loop's, then the shell's.
|
||||
b.vertices.extend([[2., 0., 0.], [3., 0., 0.], [0.5, 0.5, 0.], [5., 5., 5.]].map(|point| Vertex { point, tolerance: 1e-7 }));
|
||||
b.edges.push(Edge {
|
||||
curve: Curve::Line { start: [2., 0., 0.], end: [3., 0., 0.] },
|
||||
interval: [0., 1.],
|
||||
vertices: [n, n + 1],
|
||||
tolerance: 1e-7,
|
||||
uses: vec![],
|
||||
});
|
||||
b.faces[0].loops.push(Loop::Vertex { vertex: n + 2 });
|
||||
b.shells.push(Shell::Wire { edges: vec![4] });
|
||||
b.shells.push(Shell::Vertex { vertex: n + 3 });
|
||||
b.regions[0].shells.extend([1, 2]);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_form_validates_and_round_trips_without_a_kernel() {
|
||||
for (name, body) in [("sheet", sheet()), ("closed", tetra()), ("void", hollow()), ("seam", tube()), ("lower forms", sheet_with_lower_forms())] {
|
||||
body.validate().unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let bytes = serde_json::to_vec(&body).unwrap();
|
||||
let restored: Brep = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(body, restored, "{name}");
|
||||
assert!(!String::from_utf8(bytes).unwrap().contains("truck"));
|
||||
}
|
||||
}
|
||||
|
||||
/// One defect each; every one is refused.
|
||||
#[test]
|
||||
fn one_defect_fixtures_are_refused() {
|
||||
let cases: Vec<(&str, &str, Brep)> = vec![
|
||||
("version", "unsupported version", {
|
||||
let mut b = sheet();
|
||||
b.version = 3;
|
||||
b
|
||||
}),
|
||||
("tolerance", "tolerance", {
|
||||
let mut b = sheet();
|
||||
b.tolerance = f64::NAN;
|
||||
b
|
||||
}),
|
||||
("vertex tolerance", "tolerance", {
|
||||
let mut b = sheet();
|
||||
b.vertices[0].tolerance = 0.;
|
||||
b
|
||||
}),
|
||||
("coordinate", "non-finite or excessive coordinate", {
|
||||
let mut b = sheet();
|
||||
b.vertices[0].point[0] = f64::INFINITY;
|
||||
b
|
||||
}),
|
||||
("edge reference", "edge reference", {
|
||||
let mut b = sheet();
|
||||
if let Loop::Edges { edges } = &mut b.faces[0].loops[0] {
|
||||
edges[0].edge = u32::MAX;
|
||||
}
|
||||
b
|
||||
}),
|
||||
("edge vertices", "edge vertices", {
|
||||
let mut b = sheet();
|
||||
b.edges[0].vertices[0] = u32::MAX;
|
||||
b
|
||||
}),
|
||||
("disconnected loop", "disconnected loop", {
|
||||
let mut b = sheet();
|
||||
if let Loop::Edges { edges } = &mut b.faces[0].loops[0] {
|
||||
edges.swap(1, 2);
|
||||
}
|
||||
list_uses(&mut b);
|
||||
b
|
||||
}),
|
||||
("reversed use", "disconnected loop", {
|
||||
let mut b = sheet();
|
||||
if let Loop::Edges { edges } = &mut b.faces[0].loops[0] {
|
||||
edges[1].forward = false;
|
||||
}
|
||||
list_uses(&mut b);
|
||||
b
|
||||
}),
|
||||
("outer vertex loop", "outer loop is not an edge loop", {
|
||||
let mut b = sheet_with_lower_forms();
|
||||
b.faces[0].loops.swap(0, 1);
|
||||
b
|
||||
}),
|
||||
("missing use", "edge uses do not list the edge's uses", {
|
||||
let mut b = tetra();
|
||||
b.edges[0].uses.pop();
|
||||
b
|
||||
}),
|
||||
("repeated use", "edge use listed twice or not a use of the edge", {
|
||||
let mut b = tetra();
|
||||
let first = b.edges[0].uses[0];
|
||||
b.edges[0].uses[1] = first;
|
||||
b
|
||||
}),
|
||||
("primary not first", "edge's primary use is not its first", {
|
||||
let mut b = tetra();
|
||||
b.edges[0].uses.reverse();
|
||||
b
|
||||
}),
|
||||
("upper", "edge use listed twice or not a use of the edge", {
|
||||
let mut b = tetra();
|
||||
b.edges[0].uses[1].upper = !b.edges[0].uses[1].upper;
|
||||
b
|
||||
}),
|
||||
("wire edge with uses", "wire edge with face uses", {
|
||||
let mut b = sheet_with_lower_forms();
|
||||
let u = b.edges[0].uses[0];
|
||||
b.edges[4].uses.push(u);
|
||||
b
|
||||
}),
|
||||
("pcurve interval", "pcurve interval differs from its edge's", {
|
||||
let mut b = tube();
|
||||
if let Loop::Edges { edges } = &mut b.faces[0].loops[0] {
|
||||
edges[0].pcurve = segment(0., TAU - 1e-3);
|
||||
}
|
||||
b
|
||||
}),
|
||||
("edge interval past one turn", "parameter interval exceeds one turn", {
|
||||
let mut b = tube();
|
||||
b.edges[0].interval = [0., TAU + 0.1];
|
||||
b
|
||||
}),
|
||||
("edge interval outside a spline", "parameter interval outside the natural domain", {
|
||||
let mut b = sheet();
|
||||
b.edges[0].curve = Curve::Spline { knots: vec![0., 0., 1., 1.], points: vec![SQUARE[0], SQUARE[1]] };
|
||||
b.edges[0].interval = [0., 1.5];
|
||||
b
|
||||
}),
|
||||
("empty edge interval", "empty parameter interval", {
|
||||
let mut b = sheet();
|
||||
b.edges[0].interval = [1., 1.];
|
||||
b
|
||||
}),
|
||||
("face domain outside the sphere", "parameter interval outside the natural domain", {
|
||||
let mut b = sheet();
|
||||
b.faces[0].surface = Surface::Sphere { frame: a_frame(), radius: 2. };
|
||||
b.faces[0].domain = [[0., TAU], [-FRAC_PI_2 - 0.1, FRAC_PI_2]];
|
||||
b
|
||||
}),
|
||||
("face domain past one turn", "parameter interval exceeds one turn", {
|
||||
let mut b = tube();
|
||||
b.faces[0].domain[0] = [0., TAU + 0.1];
|
||||
b
|
||||
}),
|
||||
("face domain outside a spline surface", "parameter interval outside the natural domain", {
|
||||
let mut b = sheet();
|
||||
b.faces[0].surface = Surface::Spline {
|
||||
u_knots: vec![0., 0., 1., 1.],
|
||||
v_knots: vec![0., 0., 1., 1.],
|
||||
points: vec![vec![[0.; 3], [0., 1., 0.]], vec![[1., 0., 0.], [1., 1., 0.]]],
|
||||
};
|
||||
b
|
||||
}),
|
||||
("edges out of order", "edges out of canonical order", {
|
||||
let mut b = sheet();
|
||||
b.edges.swap(0, 1);
|
||||
for f in &mut b.faces {
|
||||
for l in &mut f.loops {
|
||||
if let Loop::Edges { edges } = l {
|
||||
for u in edges {
|
||||
u.edge = [1, 0, 2, 3][u.edge as usize];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
list_uses(&mut b);
|
||||
b
|
||||
}),
|
||||
("vertices out of order", "vertices out of canonical order", {
|
||||
let mut b = sheet();
|
||||
b.vertices.swap(0, 1);
|
||||
for e in &mut b.edges {
|
||||
e.vertices = e.vertices.map(|v| [1, 0, 2, 3][v as usize]);
|
||||
}
|
||||
b
|
||||
}),
|
||||
("faces out of order", "faces out of canonical order", {
|
||||
let mut b = tetra();
|
||||
if let Shell::Faces { faces, .. } = &mut b.shells[0] {
|
||||
faces.swap(0, 1);
|
||||
}
|
||||
b
|
||||
}),
|
||||
("orphan vertex", "vertices out of canonical order", {
|
||||
let mut b = sheet();
|
||||
b.vertices.push(Vertex { point: [9.; 3], tolerance: 1e-7 });
|
||||
b
|
||||
}),
|
||||
("void infinite region", "the infinite region is a void", {
|
||||
let mut b = sheet();
|
||||
b.regions[0].void = true;
|
||||
b
|
||||
}),
|
||||
("open outer shell", "an open, wire or vertex shell lies in one region", {
|
||||
let mut b = sheet();
|
||||
b.regions.push(Region { shells: vec![0], void: false });
|
||||
b
|
||||
}),
|
||||
("closed shell in one region", "a closed shell bounds two regions and encloses one", {
|
||||
let mut b = tetra();
|
||||
b.regions.pop();
|
||||
b
|
||||
}),
|
||||
("bounded region without shells", "bounded region without an outer shell", {
|
||||
let mut b = tetra();
|
||||
b.regions.push(Region { shells: vec![], void: true });
|
||||
b
|
||||
}),
|
||||
("unlisted shell", "a closed shell bounds two regions and encloses one", {
|
||||
let mut b = hollow();
|
||||
b.regions[1].shells.pop();
|
||||
b.regions.pop();
|
||||
b
|
||||
}),
|
||||
("face used three times", "face used by neither one nor two oriented faces", {
|
||||
let mut b = tetra();
|
||||
if let Shell::Faces { faces, .. } = &mut b.shells[0] {
|
||||
faces.push(OrientedFace { face: 0, forward: false });
|
||||
faces.push(OrientedFace { face: 0, forward: true });
|
||||
}
|
||||
b
|
||||
}),
|
||||
("face twice in one shell", "a face's two sides", {
|
||||
let mut b = tetra();
|
||||
if let Shell::Faces { faces, .. } = &mut b.shells[0] {
|
||||
faces.push(OrientedFace { face: 0, forward: false });
|
||||
}
|
||||
b
|
||||
}),
|
||||
("cone domain past its apex", "parameter interval outside the natural domain", {
|
||||
let mut b = sheet();
|
||||
b.faces[0].surface = Surface::Cone { frame: a_frame(), radius_at_origin: 1., half_angle: 0.25 * std::f64::consts::PI };
|
||||
b.faces[0].domain = [[0., 1.], [-2., 1.]];
|
||||
b
|
||||
}),
|
||||
];
|
||||
for (name, expected, body) in cases {
|
||||
let error = body.validate().err().unwrap_or_else(|| panic!("{name} was accepted"));
|
||||
assert_eq!(error.0, expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reversed_face_is_walked_backwards() {
|
||||
// The sheet seen from below: its first (only) side is reversed, so the canonical walk
|
||||
// meets its loop's edges last to first.
|
||||
let mut b = sheet();
|
||||
if let Shell::Faces { faces, .. } = &mut b.shells[0] {
|
||||
faces[0].forward = false;
|
||||
}
|
||||
assert!(b.validate().is_err());
|
||||
// Renumber the edges (and vertices) in the backward walk's order.
|
||||
let Loop::Edges { edges } = &mut b.faces[0].loops[0] else { panic!("an edge loop") };
|
||||
let order: Vec<u32> = edges.iter().rev().map(|u| u.edge).collect();
|
||||
let mut new = vec![0; order.len()];
|
||||
for (i, e) in order.iter().enumerate() {
|
||||
new[*e as usize] = i as u32;
|
||||
}
|
||||
for u in edges.iter_mut() {
|
||||
u.edge = new[u.edge as usize];
|
||||
}
|
||||
let mut edges_ = b.edges.clone();
|
||||
for (old, e) in b.edges.iter().enumerate() {
|
||||
edges_[new[old] as usize] = e.clone();
|
||||
}
|
||||
b.edges = edges_;
|
||||
let mut seen = Vec::new();
|
||||
for e in &b.edges {
|
||||
for v in e.vertices {
|
||||
if !seen.contains(&v) {
|
||||
seen.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let vertices = seen.iter().map(|&v| b.vertices[v as usize].clone()).collect();
|
||||
b.vertices = vertices;
|
||||
for e in &mut b.edges {
|
||||
e.vertices = e.vertices.map(|v| seen.iter().position(|&s| s == v).unwrap() as u32);
|
||||
}
|
||||
for e in &mut b.edges {
|
||||
e.uses.clear();
|
||||
}
|
||||
let Loop::Edges { edges } = &b.faces[0].loops[0] else { panic!("an edge loop") };
|
||||
for (p, u) in edges.iter().enumerate().rev() {
|
||||
b.edges[u.edge as usize].uses.push(EdgeUseRef { face: 0, r#loop: 0, position: p as u32, upper: !u.forward });
|
||||
}
|
||||
b.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_domains_weights_and_nesting_are_bounded() {
|
||||
let mut budget = Budget(0);
|
||||
assert!(knots(&[0., 0., 1., 1.], 2, &mut budget).is_ok());
|
||||
for bad in [vec![0., 0., 0., 0.], vec![0., 1., 0., 1.], vec![0., 0., f64::INFINITY, 1.]] {
|
||||
assert!(knots(&bad, 2, &mut Budget(0)).is_err());
|
||||
}
|
||||
let c = Curve::RationalSpline { knots: vec![0., 0., 1., 1.], homogeneous: vec![[0., 0., 0., 0.], [1., 0., 0., 1.]] };
|
||||
assert!(curve(&c, 0, &mut Budget(0)).is_err());
|
||||
let p = Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [2., 0., 0.] };
|
||||
assert!(surface(&p, 0, &mut Budget(0)).is_err());
|
||||
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
|
||||
assert!(Budget(MAX_ITEMS).add(1).is_err());
|
||||
assert_eq!(curve_natural(&Curve::Spline { knots: vec![0., 0., 0., 1., 2., 2., 2.], points: vec![[0.; 3]; 4] }), Natural::Range(0., 2.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_procedural_records_are_validated_and_round_trip() {
|
||||
let plane = |z: f64| Box::new(Surface::Plane { origin: [0., 0., z], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] });
|
||||
let leader = Box::new(Curve::Polyline { points: vec![[0.; 3], [0.; 3], [1., 0., 0.]] });
|
||||
let c = Curve::SurfaceIntersection { surfaces: [plane(0.), plane(1.)], leader: leader.clone(), tolerance: 1e-6 };
|
||||
curve(&c, 0, &mut Budget(0)).unwrap();
|
||||
assert_eq!(curve_natural(&c), Natural::Range(0., 2.));
|
||||
assert_eq!(serde_json::from_str::<Curve>(&serde_json::to_string(&c).unwrap()).unwrap(), c);
|
||||
let line = Box::new(Curve::Line { start: [0.; 3], end: [1.; 3] });
|
||||
for bad in [
|
||||
Curve::SurfaceIntersection { surfaces: [plane(0.), plane(1.)], leader: line.clone(), tolerance: 1e-6 },
|
||||
Curve::SurfaceIntersection { surfaces: [plane(0.), plane(1.)], leader: leader.clone(), tolerance: f64::NAN },
|
||||
Curve::SurfaceIntersection { surfaces: [plane(f64::INFINITY), plane(1.)], leader, tolerance: 1e-6 },
|
||||
] {
|
||||
assert!(curve(&bad, 0, &mut Budget(0)).is_err());
|
||||
}
|
||||
let identity = [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]];
|
||||
let s = Surface::PlacedRevolution { profile: line.clone(), origin: [0.; 3], axis: [0., 0., 1.], matrix: identity, forward: false };
|
||||
surface(&s, 0, &mut Budget(0)).unwrap();
|
||||
assert_eq!(surface_natural(&s), [Natural::Periodic, UNBOUNDED]);
|
||||
assert_eq!(serde_json::from_str::<Surface>(&serde_json::to_string(&s).unwrap()).unwrap(), s);
|
||||
let mut flat = identity;
|
||||
flat[2] = [0.; 4];
|
||||
let bad = Surface::PlacedRevolution { profile: line, origin: [0.; 3], axis: [0., 0., 1.], matrix: flat, forward: true };
|
||||
assert!(surface(&bad, 0, &mut Budget(0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_records_are_unbounded_with_independent_frames() {
|
||||
let f = a_frame();
|
||||
let surfaces = [
|
||||
Surface::Cylinder { frame: f.clone(), radius: 2. },
|
||||
Surface::Cone { frame: f.clone(), radius_at_origin: 2., half_angle: 0.2 },
|
||||
Surface::Sphere { frame: f.clone(), radius: 2. },
|
||||
Surface::Torus { frame: f.clone(), major_radius: 4., minor_radius: 1. },
|
||||
];
|
||||
for s in surfaces {
|
||||
surface(&s, 0, &mut Budget(0)).unwrap();
|
||||
let encoded = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Surface>(&encoded).unwrap(), s);
|
||||
assert!(!encoded.contains("\"angle\"") && !encoded.contains("height") && !encoded.contains("longitude"));
|
||||
}
|
||||
for c in [Curve::Circle { frame: f.clone(), radius: 2. }, Curve::Ellipse { frame: f.clone(), radii: [2., 3.] }] {
|
||||
curve(&c, 0, &mut Budget(0)).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Curve>(&serde_json::to_string(&c).unwrap()).unwrap(), c);
|
||||
assert_eq!(curve_natural(&c), Natural::Periodic);
|
||||
// A periodic edge may start anywhere and run up to one turn.
|
||||
interval(&[-1., TAU - 1.], Natural::Periodic).unwrap();
|
||||
}
|
||||
let mut bad = f;
|
||||
bad.y_axis = bad.x_axis;
|
||||
assert!(frame(&bad, &mut Budget(0)).is_err());
|
||||
assert!(interval(&[2., 1.], UNBOUNDED).is_err());
|
||||
assert!(radius(f64::NAN).is_err());
|
||||
assert!(surface(&Surface::Torus { frame: a_frame(), major_radius: 1., minor_radius: 2. }, 0, &mut Budget(0)).is_err());
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! The structural rules of the topology records: references, loops, regions and shells, each
|
||||
//! edge's list of uses and the canonical order (docs/native-brep.md). Indices are hostile:
|
||||
//! every lookup is checked, and work is linear in the records.
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::{Brep, EdgeUseRef, Invalid, Loop, Result, Shell, require};
|
||||
|
||||
/// How far a pcurve's interval may differ from its edge's: this fraction of the interval's
|
||||
/// magnitude (at least 1), room for a linear remap's rounding and nothing more.
|
||||
pub(super) const PCURVE_INTERVAL: f64 = 1e-9;
|
||||
|
||||
fn get<'a, T>(items: &'a [T], i: u32, what: &'static str) -> Result<&'a T> {
|
||||
items.get(i as usize).ok_or(Invalid(what))
|
||||
}
|
||||
|
||||
/// The next index in a first-appearance order: `seen[i]` is set the first time `i` turns up,
|
||||
/// and that must be in index order (the first new one is `next`).
|
||||
struct Order {
|
||||
seen: Vec<bool>,
|
||||
next: usize,
|
||||
what: &'static str,
|
||||
}
|
||||
impl Order {
|
||||
fn new(n: usize, what: &'static str) -> Order {
|
||||
Order { seen: vec![false; n], next: 0, what }
|
||||
}
|
||||
fn visit(&mut self, i: u32) -> Result<bool> {
|
||||
let slot = self.seen.get_mut(i as usize).ok_or(Invalid(self.what))?;
|
||||
if *slot {
|
||||
return Ok(false);
|
||||
}
|
||||
require(i as usize == self.next, self.what)?;
|
||||
*slot = true;
|
||||
self.next += 1;
|
||||
Ok(true)
|
||||
}
|
||||
fn complete(&self) -> Result<()> {
|
||||
require(self.next == self.seen.len(), self.what)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn check(b: &Brep) -> Result<()> {
|
||||
// Regions and shells: which regions list each shell, and as whose outer shell.
|
||||
let mut listed = vec![0u32; b.shells.len()];
|
||||
let mut outer_of = vec![0u32; b.shells.len()];
|
||||
let infinite = b.regions.first().ok_or(Invalid("region count"))?;
|
||||
require(!infinite.void, "the infinite region is a void")?;
|
||||
for (r, region) in b.regions.iter().enumerate() {
|
||||
require(region.shells.len() <= b.shells.len(), "region shells")?;
|
||||
for (k, &s) in region.shells.iter().enumerate() {
|
||||
require(!region.shells.get(..k).is_some_and(|before| before.contains(&s)), "shell listed twice in a region")?;
|
||||
let n = listed.get_mut(s as usize).ok_or(Invalid("region shell reference"))?;
|
||||
*n += 1;
|
||||
if r > 0 && k == 0 {
|
||||
require(matches!(get(&b.shells, s, "region shell reference")?, Shell::Faces { .. }), "outer shell is not a face shell")?;
|
||||
if let Some(n) = outer_of.get_mut(s as usize) {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
require(r == 0 || !region.shells.is_empty(), "bounded region without an outer shell")?;
|
||||
}
|
||||
|
||||
// Faces: who uses each one. Loops: references, shape and connectivity.
|
||||
let mut face_uses: Vec<Vec<(usize, bool)>> = vec![Vec::new(); b.faces.len()];
|
||||
for (s, shell) in b.shells.iter().enumerate() {
|
||||
match shell {
|
||||
Shell::Faces { faces, wires } => {
|
||||
require(!faces.is_empty(), "empty shell")?;
|
||||
for f in faces {
|
||||
face_uses.get_mut(f.face as usize).ok_or(Invalid("oriented face reference"))?.push((s, f.forward));
|
||||
}
|
||||
for &e in wires {
|
||||
require(get(&b.edges, e, "wire edge reference")?.uses.is_empty(), "wire edge with face uses")?;
|
||||
}
|
||||
}
|
||||
Shell::Wire { edges } => {
|
||||
require(!edges.is_empty(), "empty wire shell")?;
|
||||
for &e in edges {
|
||||
require(get(&b.edges, e, "wire edge reference")?.uses.is_empty(), "wire edge with face uses")?;
|
||||
}
|
||||
}
|
||||
Shell::Vertex { vertex } => {
|
||||
get(&b.vertices, *vertex, "vertex shell reference")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for uses in &face_uses {
|
||||
match uses.as_slice() {
|
||||
[_] => {}
|
||||
[(a, fa), (b, fb)] => require(a != b && fa != fb, "a face's two sides")?,
|
||||
_ => return Err(Invalid("face used by neither one nor two oriented faces")),
|
||||
}
|
||||
}
|
||||
for f in &b.faces {
|
||||
require(matches!(f.loops.first(), Some(Loop::Edges { .. })), "outer loop is not an edge loop")?;
|
||||
for l in &f.loops {
|
||||
match l {
|
||||
Loop::Vertex { vertex } => {
|
||||
get(&b.vertices, *vertex, "vertex loop reference")?;
|
||||
}
|
||||
Loop::Edges { edges } => {
|
||||
require(!edges.is_empty(), "empty loop")?;
|
||||
let (mut first, mut last) = (None, None);
|
||||
for u in edges {
|
||||
let edge = get(&b.edges, u.edge, "edge reference")?;
|
||||
let [a, z] = edge.vertices;
|
||||
let (start, end) = if u.forward { (a, z) } else { (z, a) };
|
||||
require(last.is_none_or(|v| v == start), "disconnected loop")?;
|
||||
first.get_or_insert(start);
|
||||
last = Some(end);
|
||||
let p = u.pcurve.interval().ok_or(Invalid("pcurve interval"))?;
|
||||
let scale = PCURVE_INTERVAL * (1f64).max(edge.interval[0].abs()).max(edge.interval[1].abs());
|
||||
require(
|
||||
(p[0] - edge.interval[0]).abs() <= scale && (p[1] - edge.interval[1]).abs() <= scale,
|
||||
"pcurve interval differs from its edge's",
|
||||
)?;
|
||||
}
|
||||
require(first == last, "open loop")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shells: a closed face shell (every edge on its faces used at least twice among them)
|
||||
// separates two regions and encloses one; any other shell lies in one region.
|
||||
let mut count = vec![0u32; b.edges.len()];
|
||||
for (s, shell) in b.shells.iter().enumerate() {
|
||||
let (listed, outer) = (listed.get(s).copied().unwrap_or(0), outer_of.get(s).copied().unwrap_or(0));
|
||||
let closed = match shell {
|
||||
Shell::Faces { faces, .. } => {
|
||||
let edges = || {
|
||||
faces.iter().filter_map(|f| b.faces.get(f.face as usize)).flat_map(|f| &f.loops).flat_map(|l| match l {
|
||||
Loop::Edges { edges } => edges.as_slice(),
|
||||
Loop::Vertex { .. } => &[],
|
||||
})
|
||||
};
|
||||
for u in edges() {
|
||||
if let Some(n) = count.get_mut(u.edge as usize) {
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let closed = edges().all(|u| count.get(u.edge as usize).is_some_and(|n| *n >= 2));
|
||||
for u in edges() {
|
||||
if let Some(n) = count.get_mut(u.edge as usize) {
|
||||
*n = 0;
|
||||
}
|
||||
}
|
||||
closed
|
||||
}
|
||||
Shell::Wire { .. } | Shell::Vertex { .. } => false,
|
||||
};
|
||||
if closed {
|
||||
require(listed == 2 && outer == 1, "a closed shell bounds two regions and encloses one")?;
|
||||
} else {
|
||||
require(listed == 1 && outer == 0, "an open, wire or vertex shell lies in one region")?;
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical order. Shells and faces in first appearance walking regions → shells →
|
||||
// oriented faces; each face is walked in the direction its first oriented face gives.
|
||||
let mut shells = Order::new(b.shells.len(), "shells out of canonical order");
|
||||
let mut faces = Order::new(b.faces.len(), "faces out of canonical order");
|
||||
let mut direction = vec![true; b.faces.len()];
|
||||
for region in &b.regions {
|
||||
for &s in ®ion.shells {
|
||||
if !shells.visit(s)? {
|
||||
continue;
|
||||
}
|
||||
if let Some(Shell::Faces { faces: uses, .. }) = b.shells.get(s as usize) {
|
||||
for f in uses {
|
||||
if faces.visit(f.face)?
|
||||
&& let Some(d) = direction.get_mut(f.face as usize)
|
||||
{
|
||||
*d = f.forward;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shells.complete()?;
|
||||
faces.complete()?;
|
||||
|
||||
// Edges in first appearance walking faces → loops → oriented edges (backwards in a face
|
||||
// whose first side is reversed), then wire edges walking shells; each edge's uses in that
|
||||
// walk's order, the first one primary.
|
||||
let mut edges = Order::new(b.edges.len(), "edges out of canonical order");
|
||||
let mut walked: Vec<Vec<EdgeUseRef>> = vec![Vec::new(); b.edges.len()];
|
||||
for (fi, f) in b.faces.iter().enumerate() {
|
||||
let forward = direction.get(fi).copied().unwrap_or(true);
|
||||
for (li, l) in f.loops.iter().enumerate() {
|
||||
let Loop::Edges { edges: uses } = l else { continue };
|
||||
let n = uses.len();
|
||||
for k in 0..n {
|
||||
let p = if forward { k } else { n - 1 - k };
|
||||
let u = uses.get(p).ok_or(Invalid("edge reference"))?;
|
||||
edges.visit(u.edge)?;
|
||||
let at = EdgeUseRef { face: index(fi)?, r#loop: index(li)?, position: index(p)?, upper: !u.forward };
|
||||
walked.get_mut(u.edge as usize).ok_or(Invalid("edge reference"))?.push(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
for shell in &b.shells {
|
||||
let wires = match shell {
|
||||
Shell::Faces { wires, .. } => wires.as_slice(),
|
||||
Shell::Wire { edges } => edges.as_slice(),
|
||||
Shell::Vertex { .. } => &[],
|
||||
};
|
||||
for &e in wires {
|
||||
edges.visit(e)?;
|
||||
}
|
||||
}
|
||||
edges.complete()?;
|
||||
for (edge, walked) in b.edges.iter().zip(&walked) {
|
||||
require(edge.uses.len() == walked.len(), "edge uses do not list the edge's uses")?;
|
||||
require(edge.uses.first() == walked.first(), "edge's primary use is not its first")?;
|
||||
// As many entries as uses, and each one a use not listed before: each use once.
|
||||
let mut left: HashSet<EdgeUseRef> = walked.iter().copied().collect();
|
||||
for u in &edge.uses {
|
||||
require(left.remove(u), "edge use listed twice or not a use of the edge")?;
|
||||
}
|
||||
}
|
||||
|
||||
// Vertices in first appearance walking edges (start, end), then vertex loops walking
|
||||
// faces → loops, then vertex shells walking shells.
|
||||
let mut vertices = Order::new(b.vertices.len(), "vertices out of canonical order");
|
||||
for e in &b.edges {
|
||||
for &v in &e.vertices {
|
||||
vertices.visit(v)?;
|
||||
}
|
||||
}
|
||||
for f in &b.faces {
|
||||
for l in &f.loops {
|
||||
if let Loop::Vertex { vertex } = l {
|
||||
vertices.visit(*vertex)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for shell in &b.shells {
|
||||
if let Shell::Vertex { vertex } = shell {
|
||||
vertices.visit(*vertex)?;
|
||||
}
|
||||
}
|
||||
vertices.complete()
|
||||
}
|
||||
|
||||
fn index(i: usize) -> Result<u32> {
|
||||
u32::try_from(i).map_err(|_| Invalid("index"))
|
||||
}
|
||||
+75
-57
@@ -1818,6 +1818,80 @@ impl Body {
|
||||
self.lumps_with_clearance(2.0 * (self.size() * 1e-4).max(1e-6))
|
||||
}
|
||||
|
||||
/// Each shell's mesh at `tol` with its signed volume (positive: it bounds material).
|
||||
fn shell_meshes(&self, tol: f64) -> Result<Vec<(f64, Mesh)>> {
|
||||
let shells: Vec<mt::Shell> = self.solid.boundaries().clone();
|
||||
if shells.len() > shell_groups::MAX_SHELLS
|
||||
|| shells.iter().flat_map(mt::Shell::vertex_iter).take(shell_groups::MAX_POINTS + 1).count() > shell_groups::MAX_POINTS
|
||||
|| shells.iter().flat_map(mt::Shell::face_iter).take(shell_groups::MAX_TRIANGLES + 1).count() > shell_groups::MAX_TRIANGLES
|
||||
{
|
||||
return Err(KernelError::Failed("lumps: topology exceeds the shell grouping limit".into()));
|
||||
}
|
||||
let (mut point_count, mut triangle_count) = (0usize, 0usize);
|
||||
let data: Vec<(f64, Mesh)> = shells
|
||||
.iter()
|
||||
.map(|sh| {
|
||||
let meshed = sh.robust_triangulation(tol);
|
||||
if meshed.face_iter().any(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())) {
|
||||
return Err(KernelError::Failed("lumps: cannot mesh a complete shell boundary".into()));
|
||||
}
|
||||
let pm = meshed.to_polygon();
|
||||
point_count = point_count
|
||||
.checked_add(pm.positions().len())
|
||||
.ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?;
|
||||
triangle_count = triangle_count
|
||||
.checked_add(pm.faces().triangle_iter().take(shell_groups::MAX_TRIANGLES + 1).count())
|
||||
.ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?;
|
||||
if point_count > shell_groups::MAX_POINTS || triangle_count > shell_groups::MAX_TRIANGLES {
|
||||
return Err(KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()));
|
||||
}
|
||||
let mut m = Mesh::default();
|
||||
for p in pm.positions() {
|
||||
m.positions.push(Vec3::new(p.x, p.y, p.z));
|
||||
}
|
||||
for t in pm.faces().triangle_iter() {
|
||||
let idx = |k: usize| {
|
||||
t.get(k).and_then(|x| u32::try_from(x.pos).ok()).ok_or_else(|| KernelError::Failed("lumps: invalid mesh index".into()))
|
||||
};
|
||||
let indices = [idx(0)?, idx(1)?, idx(2)?];
|
||||
let [a, b, c] = m.tri(&indices).ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))?;
|
||||
// STEP sphere poles can produce zero-area duplicate triangles.
|
||||
// They have no boundary area or volume and are not ray crossings.
|
||||
if (b - a).cross(c - a) == Vec3::ZERO {
|
||||
continue;
|
||||
}
|
||||
m.triangles.push(indices);
|
||||
m.tri_face.push(0);
|
||||
}
|
||||
let origin = m.positions.first().copied().ok_or_else(|| KernelError::Failed("lumps: empty mesh".into()))?;
|
||||
let v = m
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| {
|
||||
m.tri(t)
|
||||
.map(|[a, b, c]| (a - origin).dot((b - origin).cross(c - origin)) / 6.0)
|
||||
.ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.sum();
|
||||
Ok((v, m))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// The shell directly around each of the body's shells (`None` for one in the open) and
|
||||
/// whether each bounds material (else a void), for closed shells; see `shell_groups`.
|
||||
pub(crate) fn shell_nesting(&self) -> Result<(Vec<bool>, Vec<Option<usize>>)> {
|
||||
guard("shell nesting", || {
|
||||
let data = self.shell_meshes((self.size() * 1e-3).max(1e-3))?;
|
||||
let (volumes, meshes): (Vec<_>, Vec<_>) = data.into_iter().unzip();
|
||||
let parents = shell_groups::parents(&meshes, &volumes, (self.size() * 1e-8).max(1e-9))?;
|
||||
Ok((volumes.iter().map(|v| *v > 0.0).collect(), parents))
|
||||
})
|
||||
}
|
||||
|
||||
fn lumps_with_clearance(&self, clearance: f64) -> Result<Vec<Body>> {
|
||||
if self.mesh.is_some() || self.solid.boundaries().len() < 2 {
|
||||
return Ok(vec![self.clone()]);
|
||||
@@ -1825,63 +1899,7 @@ impl Body {
|
||||
let tol = (self.size() * 1e-3).max(1e-3);
|
||||
guard("lumps", || {
|
||||
let shells: Vec<mt::Shell> = self.solid.boundaries().clone();
|
||||
if shells.len() > shell_groups::MAX_SHELLS
|
||||
|| shells.iter().flat_map(mt::Shell::vertex_iter).take(shell_groups::MAX_POINTS + 1).count() > shell_groups::MAX_POINTS
|
||||
|| shells.iter().flat_map(mt::Shell::face_iter).take(shell_groups::MAX_TRIANGLES + 1).count() > shell_groups::MAX_TRIANGLES
|
||||
{
|
||||
return Err(KernelError::Failed("lumps: topology exceeds the shell grouping limit".into()));
|
||||
}
|
||||
let (mut point_count, mut triangle_count) = (0usize, 0usize);
|
||||
let data: Vec<(f64, Mesh)> = shells
|
||||
.iter()
|
||||
.map(|sh| {
|
||||
let meshed = sh.robust_triangulation(tol);
|
||||
if meshed.face_iter().any(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())) {
|
||||
return Err(KernelError::Failed("lumps: cannot mesh a complete shell boundary".into()));
|
||||
}
|
||||
let pm = meshed.to_polygon();
|
||||
point_count = point_count
|
||||
.checked_add(pm.positions().len())
|
||||
.ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?;
|
||||
triangle_count = triangle_count
|
||||
.checked_add(pm.faces().triangle_iter().take(shell_groups::MAX_TRIANGLES + 1).count())
|
||||
.ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?;
|
||||
if point_count > shell_groups::MAX_POINTS || triangle_count > shell_groups::MAX_TRIANGLES {
|
||||
return Err(KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()));
|
||||
}
|
||||
let mut m = Mesh::default();
|
||||
for p in pm.positions() {
|
||||
m.positions.push(Vec3::new(p.x, p.y, p.z));
|
||||
}
|
||||
for t in pm.faces().triangle_iter() {
|
||||
let idx = |k: usize| {
|
||||
t.get(k).and_then(|x| u32::try_from(x.pos).ok()).ok_or_else(|| KernelError::Failed("lumps: invalid mesh index".into()))
|
||||
};
|
||||
let indices = [idx(0)?, idx(1)?, idx(2)?];
|
||||
let [a, b, c] = m.tri(&indices).ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))?;
|
||||
// STEP sphere poles can produce zero-area duplicate triangles.
|
||||
// They have no boundary area or volume and are not ray crossings.
|
||||
if (b - a).cross(c - a) == Vec3::ZERO {
|
||||
continue;
|
||||
}
|
||||
m.triangles.push(indices);
|
||||
m.tri_face.push(0);
|
||||
}
|
||||
let origin = m.positions.first().copied().ok_or_else(|| KernelError::Failed("lumps: empty mesh".into()))?;
|
||||
let v = m
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| {
|
||||
m.tri(t)
|
||||
.map(|[a, b, c]| (a - origin).dot((b - origin).cross(c - origin)) / 6.0)
|
||||
.ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.sum();
|
||||
Ok((v, m))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let data = self.shell_meshes(tol)?;
|
||||
let (volumes, meshes): (Vec<_>, Vec<_>) = data.into_iter().unzip();
|
||||
let groups = shell_groups::groups(&meshes, &volumes, (self.size() * 1e-8).max(1e-9).max(clearance))?;
|
||||
if groups.len() == 1 {
|
||||
|
||||
+12
-34
@@ -8,14 +8,14 @@ use std::sync::Arc;
|
||||
mod adapter;
|
||||
mod binary;
|
||||
mod metadata;
|
||||
#[allow(dead_code)]
|
||||
mod pcurve;
|
||||
mod preview;
|
||||
pub use preview::native_preview_mesh;
|
||||
|
||||
// 01/02 were development-only prototypes; independent metadata records start at 03; 04 stores
|
||||
// procedural intersections and placed revolutions exactly, and the measured runtime values.
|
||||
const MAGIC: &[u8; 8] = b"SCBREP04";
|
||||
// procedural intersections and placed revolutions exactly, and the measured runtime values; 05
|
||||
// nests geometry schema 4 (regions, unbounded geometry, pcurves, canonical order).
|
||||
const MAGIC: &[u8; 8] = b"SCBREP05";
|
||||
// A body's own limits. A design's cache holds many bodies within the container's budgets, and
|
||||
// decoding is linear in the bytes, so one large part (an imported casting) fits.
|
||||
const MAX_BYTES: usize = 256 << 20;
|
||||
@@ -39,10 +39,6 @@ struct Runtime {
|
||||
size: f64,
|
||||
/// The orientation mesh's missing-face count at its tolerance.
|
||||
coarse_missing: Option<(f64, u64)>,
|
||||
/// [`Body::boundary_tol`]: the accuracy the file it was read from declares. Written only
|
||||
/// when there is one, so a body without it is stored as before.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
boundary_tol: Option<f64>,
|
||||
}
|
||||
fn invalid(detail: impl std::fmt::Display) -> KernelError {
|
||||
KernelError::Invalid(format!("native B-rep: {detail}"))
|
||||
@@ -72,9 +68,8 @@ pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
|
||||
if body.mesh.is_some() {
|
||||
return Err(invalid("mesh bodies are source blobs, not B-rep"));
|
||||
}
|
||||
let runtime =
|
||||
Runtime { size: body.size, coarse_missing: body.coarse_missing.map(|(tol, n)| (tol, n as u64)), boundary_tol: body.boundary_tol };
|
||||
let saved = SavedBody { solid: adapter::encode(&body.solid)?, metadata: metadata::encode(body)?, runtime };
|
||||
let runtime = Runtime { size: body.size, coarse_missing: body.coarse_missing.map(|(tol, n)| (tol, n as u64)) };
|
||||
let saved = SavedBody { solid: adapter::encode(body, pcurve::pcurves)?, metadata: metadata::encode(body)?, runtime };
|
||||
// Structural validation does not invoke another geometric reconstruction.
|
||||
validate_topology(&saved)?;
|
||||
let value = serde_json::to_value(saved).map_err(invalid)?;
|
||||
@@ -105,17 +100,13 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
|
||||
let saved: SavedBody = serde_json::from_value(value).map_err(invalid)?;
|
||||
validate_topology(&saved)?;
|
||||
let solid = adapter::decode(saved.solid)?;
|
||||
let Runtime { size, coarse_missing, boundary_tol } = saved.runtime;
|
||||
if !(size.is_finite() && size >= 0.0)
|
||||
|| coarse_missing.is_some_and(|(tol, n)| !tol.is_finite() || n > MAX_ITEMS as u64)
|
||||
|| boundary_tol.is_some_and(|t| !(t.is_finite() && t > 0.0))
|
||||
{
|
||||
let Runtime { size, coarse_missing } = saved.runtime;
|
||||
if !(size.is_finite() && size >= 0.0) || coarse_missing.is_some_and(|(tol, n)| !tol.is_finite() || n > MAX_ITEMS as u64) {
|
||||
return Err(invalid("runtime values"));
|
||||
}
|
||||
let mut body = Body {
|
||||
solid: Arc::new(solid),
|
||||
size,
|
||||
boundary_tol,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: coarse_missing.map(|(tol, n)| (tol, n as usize)),
|
||||
mesh: None,
|
||||
@@ -136,8 +127,7 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
|
||||
|
||||
fn validate_topology(saved: &SavedBody) -> Result<()> {
|
||||
saved.solid.validate().map_err(invalid)?;
|
||||
let faces: usize = saved.solid.shells.iter().map(|s| s.faces.len()).sum();
|
||||
let edges: usize = saved.solid.shells.iter().map(|s| s.edges.len()).sum();
|
||||
let (faces, edges) = (saved.solid.faces.len(), saved.solid.edges.len());
|
||||
let saved = &saved.metadata;
|
||||
if saved.version != solidcraft_geom::brep::envelope::VERSION {
|
||||
return Err(invalid("metadata version"));
|
||||
@@ -239,18 +229,6 @@ mod tests {
|
||||
assert!((crate::measure(&a).unwrap().volume - crate::measure(&b).unwrap().volume).abs() < 1e-7);
|
||||
}
|
||||
}
|
||||
/// A body read from a file keeps the accuracy the file declares through the model cache, so
|
||||
/// a reopened design plans its faces as the import did; one without it is stored as before.
|
||||
#[test]
|
||||
fn a_files_declared_accuracy_survives_the_model_cache() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(4., 3., 1.)).unwrap();
|
||||
let plain = encode_native_body(&cube).unwrap();
|
||||
assert_eq!(decode_native_body(&plain).unwrap().boundary_tol, None);
|
||||
let read = cube.with_boundary_tol(Some(1e-5));
|
||||
let bytes = encode_native_body(&read).unwrap();
|
||||
assert_ne!(bytes, plain);
|
||||
assert_eq!(decode_native_body(&bytes).unwrap().boundary_tol, Some(1e-5));
|
||||
}
|
||||
#[test]
|
||||
fn native_spheres_tori_and_open_faces_round_trip_without_conversion() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
@@ -277,7 +255,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|body| {
|
||||
let bytes = encode_native_body(body).unwrap();
|
||||
assert_eq!(&bytes[..8], b"SCBREP04");
|
||||
assert_eq!(&bytes[..8], b"SCBREP05");
|
||||
let mut raw = &bytes[8..];
|
||||
let value = binary::read(&mut raw, 0, &mut 2_000_000).unwrap();
|
||||
assert!(raw.is_empty());
|
||||
@@ -285,7 +263,7 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
let actual = serde_json::json!(records);
|
||||
let expected: Value = serde_json::from_str(include_str!("native/fixtures/box-fillet-v4.json")).unwrap();
|
||||
let expected: Value = serde_json::from_str(include_str!("native/fixtures/box-fillet-v5.json")).unwrap();
|
||||
compare_wire(&expected, &actual);
|
||||
}
|
||||
|
||||
@@ -328,9 +306,9 @@ mod tests {
|
||||
for key in ["vertices", "index"] {
|
||||
let mut v = value.clone();
|
||||
if key == "vertices" {
|
||||
v["solid"]["shells"][0]["edges"][0]["vertices"] = serde_json::json!([u64::MAX, 0]);
|
||||
v["solid"]["edges"][0]["vertices"] = serde_json::json!([u64::MAX, 0]);
|
||||
} else {
|
||||
v["solid"]["shells"][0]["faces"][0]["loops"][0][0]["edge"] = serde_json::json!(u64::MAX);
|
||||
v["solid"]["faces"][0]["loops"][0]["edges"][0]["edge"] = serde_json::json!(u64::MAX);
|
||||
}
|
||||
let mut bad = MAGIC.to_vec();
|
||||
binary::write(&v, &mut bad, 0).unwrap();
|
||||
|
||||
+277
-200
@@ -1,12 +1,23 @@
|
||||
//! The truck adapter. Persisted data belongs to solidcraft_geom::brep, never truck.
|
||||
//!
|
||||
//! Truck keeps a face's wires relative to its surface (`absolute_boundaries`) and flips the
|
||||
//! face with an orientation flag, which is schema 4's convention: loops are written as truck
|
||||
//! holds them and the flag becomes the oriented face's `forward`. Faces, edges and vertices are
|
||||
//! numbered in canonical order, which is truck's own (`face_iter`, and first appearance through
|
||||
//! `edge_iter`, which walks a reversed face's wires backwards), so every index the kernel hands
|
||||
//! out (history, face sources, paint) means the same face or edge in the record.
|
||||
use super::{Result, invalid};
|
||||
use mt::Invertible;
|
||||
use crate::Body;
|
||||
use mt::{Invertible, ParametricCurve};
|
||||
use solidcraft_geom::brep as b;
|
||||
use std::collections::HashMap;
|
||||
use truck_modeling as mt;
|
||||
|
||||
#[path = "analytic.rs"]
|
||||
mod analytic;
|
||||
use analytic::{arc, full_turn, load_analytic, revolved};
|
||||
#[cfg(test)]
|
||||
use analytic::revolved;
|
||||
use analytic::{arc, load_analytic};
|
||||
fn point(p: mt::Point3) -> [f64; 3] {
|
||||
[p.x, p.y, p.z]
|
||||
}
|
||||
@@ -31,6 +42,9 @@ fn index(i: usize) -> Result<u32> {
|
||||
fn depth(d: usize) -> Result<()> {
|
||||
if d > 16 { Err(invalid("geometry nesting")) } else { Ok(()) }
|
||||
}
|
||||
fn unsupported(what: &str) -> crate::KernelError {
|
||||
crate::KernelError::Failed(format!("native B-rep: not supported yet: {what}"))
|
||||
}
|
||||
|
||||
// Truck evaluates the full knot interval. The portable domain is the standard
|
||||
// active interval, so only coincident (clamped) domains can cross this adapter.
|
||||
@@ -45,7 +59,6 @@ fn compatible_curve(c: &b::Curve) -> Result<()> {
|
||||
match c {
|
||||
b::Curve::Spline { knots, points } => clamped(knots, points.len()),
|
||||
b::Curve::RationalSpline { knots, homogeneous } => clamped(knots, homogeneous.len()),
|
||||
b::Curve::Intersection { knots, points, .. } => clamped(knots, points.len()),
|
||||
// The producer's own trace: its surfaces must load; its leader is kept as it was.
|
||||
b::Curve::SurfaceIntersection { surfaces, .. } => surfaces.iter().try_for_each(|s| compatible_surface(s)),
|
||||
_ => Ok(()),
|
||||
@@ -68,124 +81,261 @@ fn compatible_surface(s: &b::Surface) -> Result<()> {
|
||||
clamped(u_knots, homogeneous.len())?;
|
||||
clamped(v_knots, homogeneous.first().map_or(0, Vec::len))
|
||||
}
|
||||
b::Surface::Revolution { profile, angle, .. } => {
|
||||
full_turn(angle)?;
|
||||
compatible_curve(profile)
|
||||
}
|
||||
b::Surface::PlaneThrough { .. } => Ok(()),
|
||||
b::Surface::PlacedRevolution { profile, .. } => compatible_curve(profile),
|
||||
b::Surface::Cylinder { angle, .. } | b::Surface::Cone { angle, .. } => full_turn(angle),
|
||||
b::Surface::Sphere { longitude, .. } | b::Surface::Torus { longitude, .. } => full_turn(longitude),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
fn compatible(body: &b::Brep) -> Result<()> {
|
||||
for shell in &body.shells {
|
||||
for edge in &shell.edges {
|
||||
compatible_curve(&edge.curve)?;
|
||||
}
|
||||
for face in &shell.faces {
|
||||
compatible_surface(&face.surface)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn encode(solid: &mt::Solid) -> Result<b::Brep> {
|
||||
let mut shells = Vec::new();
|
||||
for shell in solid.compress().boundaries {
|
||||
let vertices = shell.vertices.into_iter().map(point).collect();
|
||||
let edges = shell
|
||||
.edges
|
||||
.into_iter()
|
||||
.map(|e| Ok(b::Edge { vertices: [index(e.vertices.0)?, index(e.vertices.1)?], curve: save_curve(&e.curve, 0)? }))
|
||||
.collect::<Result<_>>()?;
|
||||
let faces = shell
|
||||
.faces
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let surface = save_surface(&f.surface, 0)?;
|
||||
Ok(b::Face {
|
||||
loops: f
|
||||
.boundaries
|
||||
.into_iter()
|
||||
.map(|w| {
|
||||
let mut uses = w
|
||||
.into_iter()
|
||||
.map(|e| Ok(b::EdgeUse { edge: index(e.index)?, forward: e.orientation }))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// Wire direction is explicit; Face.forward changes only the normal.
|
||||
if !f.orientation {
|
||||
reverse_wire(&mut uses);
|
||||
}
|
||||
Ok(uses)
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
surface,
|
||||
forward: f.orientation,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
shells.push(b::Shell { vertices, edges, faces });
|
||||
/// The body's working tolerance, mm: its "same point" tolerance (`fill::planarity_tolerance`).
|
||||
fn working_tolerance(body: &Body) -> f64 {
|
||||
crate::fill::planarity_tolerance(body)
|
||||
}
|
||||
|
||||
/// The record of `body`'s solid, with each face's pcurves from `pcurves` (the face, then the
|
||||
/// tolerance), for its wires and edges in `face.boundaries()` order.
|
||||
pub(super) fn encode(body: &Body, pcurves: impl Fn(&mt::Face, f64) -> Result<Vec<Vec<b::Curve2>>>) -> Result<b::Brep> {
|
||||
let solid = &body.solid;
|
||||
let tolerance = working_tolerance(body);
|
||||
let shells = solid.boundaries();
|
||||
let regions = regions(body)?;
|
||||
|
||||
// Faces in truck's order; edges and vertices in first appearance.
|
||||
let mut face_index = HashMap::new();
|
||||
let mut edge_index = HashMap::new();
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
let mut uses: Vec<Vec<b::EdgeUseRef>> = Vec::new();
|
||||
let mut faces = Vec::new();
|
||||
let mut shell_records = Vec::new();
|
||||
for shell in shells {
|
||||
let mut oriented = Vec::new();
|
||||
for face in shell.face_iter() {
|
||||
let fi = faces.len();
|
||||
if face_index.insert(face.id(), fi).is_some() {
|
||||
return Err(unsupported("a face in two shells"));
|
||||
}
|
||||
oriented.push(b::OrientedFace { face: index(fi)?, forward: face.orientation() });
|
||||
let wires = face.absolute_boundaries();
|
||||
// The canonical walk: wires in order, each backwards on a reversed face.
|
||||
for (li, wire) in wires.iter().enumerate() {
|
||||
let n = wire.len();
|
||||
for k in 0..n {
|
||||
let position = if face.orientation() { k } else { n - 1 - k };
|
||||
let e = wire.get(position).ok_or_else(|| invalid("wire position"))?;
|
||||
let i = *edge_index.entry(e.id()).or_insert_with(|| {
|
||||
edges.push(e.absolute_clone());
|
||||
uses.push(Vec::new());
|
||||
edges.len() - 1
|
||||
});
|
||||
let at = b::EdgeUseRef { face: index(fi)?, r#loop: index(li)?, position: index(position)?, upper: !e.orientation() };
|
||||
uses.get_mut(i).ok_or_else(|| invalid("edge use"))?.push(at);
|
||||
}
|
||||
}
|
||||
faces.push(face_record(face, wires, &edge_index, &pcurves, tolerance)?);
|
||||
}
|
||||
shell_records.push(b::Shell::Faces { faces: oriented, wires: Vec::new() });
|
||||
}
|
||||
let result = b::Brep { version: b::VERSION, shells };
|
||||
let mut vertex_index = HashMap::new();
|
||||
let mut vertices = Vec::new();
|
||||
let mut edge_records = Vec::with_capacity(edges.len());
|
||||
for (e, uses) in edges.iter().zip(uses) {
|
||||
if uses.len() > 2 {
|
||||
return Err(unsupported("the radial order of an edge with more than two uses"));
|
||||
}
|
||||
let mut vertex = |x: &mt::Vertex| {
|
||||
*vertex_index.entry(x.id()).or_insert_with(|| {
|
||||
vertices.push(b::Vertex { point: point(x.point()), tolerance });
|
||||
vertices.len() - 1
|
||||
})
|
||||
};
|
||||
let ends = [vertex(e.absolute_front()), vertex(e.absolute_back())];
|
||||
let curve = e.curve();
|
||||
let (t0, t1) = curve.try_range_tuple().ok_or_else(|| invalid("unbounded edge curve"))?;
|
||||
edge_records.push(b::Edge {
|
||||
curve: save_curve(&curve, 0)?,
|
||||
interval: [t0, t1],
|
||||
vertices: [index(ends[0])?, index(ends[1])?],
|
||||
tolerance,
|
||||
uses,
|
||||
});
|
||||
}
|
||||
let result = b::Brep { version: b::VERSION, tolerance, regions, shells: shell_records, faces, edges: edge_records, vertices };
|
||||
result.validate().map_err(invalid)?;
|
||||
compatible(&result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// A face's record: its surface, its loops as truck holds them and their pcurves, and the
|
||||
/// domain around the pcurves.
|
||||
fn face_record(
|
||||
face: &mt::Face,
|
||||
wires: &[mt::Wire],
|
||||
edge_index: &HashMap<mt::EdgeID, usize>,
|
||||
pcurves: &impl Fn(&mt::Face, f64) -> Result<Vec<Vec<b::Curve2>>>,
|
||||
tolerance: f64,
|
||||
) -> Result<b::Face> {
|
||||
let surface = save_surface(&face.surface(), 0)?;
|
||||
// `pcurves` lists the oriented wires: a reversed face's run backwards.
|
||||
let mut curves = pcurves(face, tolerance)?;
|
||||
if !face.orientation() {
|
||||
curves.iter_mut().for_each(|w| w.reverse());
|
||||
}
|
||||
if curves.len() != wires.len() || curves.iter().zip(wires).any(|(c, w)| c.len() != w.len()) {
|
||||
return Err(invalid("pcurves do not match the face's wires"));
|
||||
}
|
||||
let mut loops = Vec::with_capacity(wires.len());
|
||||
for (wire, curves) in wires.iter().zip(curves) {
|
||||
let edges = wire
|
||||
.edge_iter()
|
||||
.zip(curves)
|
||||
.map(|(e, pcurve)| {
|
||||
let i = edge_index.get(&e.id()).ok_or_else(|| invalid("edge index"))?;
|
||||
Ok(b::OrientedEdge { edge: index(*i)?, forward: e.orientation(), pcurve })
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
loops.push(b::Loop::Edges { edges });
|
||||
}
|
||||
let domain = domain(&surface, &loops)?;
|
||||
Ok(b::Face { surface, domain, loops })
|
||||
}
|
||||
|
||||
/// The rectangle around a face's pcurves (their control polygons, which hold them), clamped to
|
||||
/// the surface's natural domain; at most one turn in a periodic direction, about its middle.
|
||||
fn domain(surface: &b::Surface, loops: &[b::Loop]) -> Result<[[f64; 2]; 2]> {
|
||||
let mut d = [[f64::INFINITY, f64::NEG_INFINITY]; 2];
|
||||
let mut add = |uv: [f64; 2]| {
|
||||
for (r, x) in d.iter_mut().zip(uv) {
|
||||
r[0] = r[0].min(x);
|
||||
r[1] = r[1].max(x);
|
||||
}
|
||||
};
|
||||
for l in loops {
|
||||
let b::Loop::Edges { edges } = l else { continue };
|
||||
for e in edges {
|
||||
match &e.pcurve {
|
||||
b::Curve2::Spline { points, .. } => points.iter().for_each(|q| add(*q)),
|
||||
b::Curve2::RationalSpline { homogeneous, .. } => homogeneous.iter().for_each(|[x, y, w]| add([x / w, y / w])),
|
||||
}
|
||||
}
|
||||
}
|
||||
for (r, natural) in d.iter_mut().zip(surface.natural()) {
|
||||
match natural {
|
||||
b::Natural::Range(lo, hi) => *r = [r[0].max(lo), r[1].min(hi)],
|
||||
b::Natural::Periodic if r[1] - r[0] > std::f64::consts::TAU => {
|
||||
let middle = 0.5 * (r[0] + r[1]);
|
||||
*r = [middle - std::f64::consts::PI, middle + std::f64::consts::PI];
|
||||
}
|
||||
b::Natural::Periodic => {}
|
||||
}
|
||||
if !(r[0].is_finite() && r[1].is_finite() && r[0] < r[1]) {
|
||||
return Err(invalid("face domain"));
|
||||
}
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The infinite region and the bounded ones, from the shells as truck holds them: an open shell
|
||||
/// lies in the infinite region; a closed one bounds material when it faces out (each truck
|
||||
/// solid's shells are oriented) and a void when it faces in, and lies in the region around it.
|
||||
/// Bounded regions come in the order of their outer shells.
|
||||
fn regions(body: &Body) -> Result<Vec<b::Region>> {
|
||||
let shells = body.solid.boundaries();
|
||||
let closed: Vec<bool> = shells.iter().map(|s| s.shell_condition() == mt::ShellCondition::Closed).collect();
|
||||
let all: Vec<u32> = (0..shells.len()).map(index).collect::<Result<_>>()?;
|
||||
let mut regions = Vec::new();
|
||||
match closed.iter().filter(|c| **c).count() {
|
||||
0 => regions.push(b::Region { shells: all, void: false }),
|
||||
1 => {
|
||||
regions.push(b::Region { shells: all, void: false });
|
||||
let s = closed.iter().position(|c| *c).ok_or_else(|| invalid("closed shell"))?;
|
||||
regions.push(b::Region { shells: vec![index(s)?], void: false });
|
||||
}
|
||||
n if n < shells.len() => return Err(unsupported("open shells beside several closed shells")),
|
||||
_ => {
|
||||
let (material, parents) = body.shell_nesting()?;
|
||||
let inside =
|
||||
|p: Option<usize>| -> Result<Vec<u32>> { parents.iter().enumerate().filter(|(_, q)| **q == p).map(|(i, _)| index(i)).collect() };
|
||||
regions.push(b::Region { shells: inside(None)?, void: false });
|
||||
for (s, m) in material.iter().enumerate() {
|
||||
let mut shells = vec![index(s)?];
|
||||
shells.extend(inside(Some(s))?);
|
||||
regions.push(b::Region { shells, void: !m });
|
||||
}
|
||||
// The canonical shell order walks the regions; truck's must be the same.
|
||||
let mut seen = Vec::new();
|
||||
for s in regions.iter().flat_map(|r| &r.shells) {
|
||||
if !seen.contains(s) {
|
||||
seen.push(*s);
|
||||
}
|
||||
}
|
||||
if seen != all {
|
||||
return Err(unsupported("shells in an order other than canonical (a void before another body)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(regions)
|
||||
}
|
||||
|
||||
fn compatible(body: &b::Brep) -> Result<()> {
|
||||
body.edges.iter().try_for_each(|e| compatible_curve(&e.curve))?;
|
||||
body.faces.iter().try_for_each(|f| compatible_surface(&f.surface))
|
||||
}
|
||||
|
||||
pub(super) fn decode(body: b::Brep) -> Result<mt::Solid> {
|
||||
body.validate().map_err(invalid)?;
|
||||
compatible(&body)?;
|
||||
let shells = body.shells.into_iter().map(shell).collect::<Result<Vec<_>>>()?;
|
||||
let b::Brep { shells, faces, edges, vertices, .. } = body;
|
||||
let vertices: Vec<mt::Vertex> = vertices.into_iter().map(|v| mt::Vertex::new(p(v.point))).collect();
|
||||
let vertex = |i: u32| vertices.get(i as usize).ok_or_else(|| invalid("edge vertices"));
|
||||
let edges = edges
|
||||
.into_iter()
|
||||
.map(|e| Ok(mt::Edge::new_unchecked(vertex(e.vertices[0])?, vertex(e.vertices[1])?, load_edge_curve(e.curve, e.interval)?)))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let mut faces: Vec<Option<b::Face>> = faces.into_iter().map(Some).collect();
|
||||
let shells = shells
|
||||
.into_iter()
|
||||
.map(|s| match s {
|
||||
b::Shell::Faces { faces: uses, wires } if wires.is_empty() => uses
|
||||
.into_iter()
|
||||
.map(|u| {
|
||||
// A face in two shells (both its sides used) is refused here: truck has one.
|
||||
let f = faces.get_mut(u.face as usize).and_then(Option::take).ok_or_else(|| unsupported("a face in two shells"))?;
|
||||
face(f, u.forward, &edges)
|
||||
})
|
||||
.collect::<Result<mt::Shell>>(),
|
||||
b::Shell::Faces { .. } => Err(unsupported("wire edges embedded in a shell")),
|
||||
b::Shell::Wire { .. } | b::Shell::Vertex { .. } => Err(unsupported("wire and vertex shells")),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if shells.iter().any(|s| s.shell_condition() == mt::ShellCondition::Irregular) {
|
||||
return Err(invalid("non-manifold shell"));
|
||||
}
|
||||
Ok(mt::Solid::new_unchecked(shells))
|
||||
}
|
||||
/// A shell's topology as recorded, references already checked: built as the kernel built it,
|
||||
/// A face's topology as recorded, references already checked: built as the kernel built it,
|
||||
/// without truck's checks for wires that touch themselves (a seam edge used twice, a closed edge
|
||||
/// on one vertex), which imported and modelled faces have.
|
||||
fn shell(s: b::Shell) -> Result<mt::Shell> {
|
||||
let vertices: Vec<mt::Vertex> = s.vertices.into_iter().map(|v| mt::Vertex::new(p(v))).collect();
|
||||
let vertex = |i: u32| vertices.get(i as usize).ok_or_else(|| invalid("edge vertices"));
|
||||
let edges = s
|
||||
.edges
|
||||
/// on one vertex), which imported and modelled faces have. Pcurves are not needed (truck has none).
|
||||
fn face(f: b::Face, forward: bool, edges: &[mt::Edge]) -> Result<mt::Face> {
|
||||
let wires = f
|
||||
.loops
|
||||
.into_iter()
|
||||
.map(|e| Ok(mt::Edge::new_unchecked(vertex(e.vertices[0])?, vertex(e.vertices[1])?, load_curve(e.curve)?)))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
s.faces
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let mut loops = f.loops;
|
||||
if !f.forward {
|
||||
for wire in &mut loops {
|
||||
reverse_wire(wire);
|
||||
}
|
||||
}
|
||||
let wires = loops
|
||||
.map(|l| match l {
|
||||
b::Loop::Edges { edges: uses } => uses
|
||||
.into_iter()
|
||||
.map(|w| {
|
||||
w.into_iter()
|
||||
.map(|u| {
|
||||
let e = edges.get(u.edge as usize).ok_or_else(|| invalid("edge reference"))?;
|
||||
Ok(if u.forward { e.clone() } else { e.inverse() })
|
||||
})
|
||||
.collect::<Result<mt::Wire>>()
|
||||
.map(|u| {
|
||||
let e = edges.get(u.edge as usize).ok_or_else(|| invalid("edge reference"))?;
|
||||
Ok(if u.forward { e.clone() } else { e.inverse() })
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let mut face = mt::Face::new_unchecked(wires, load_surface(f.surface)?);
|
||||
if !f.forward {
|
||||
face.invert();
|
||||
}
|
||||
Ok(face)
|
||||
.collect::<Result<mt::Wire>>(),
|
||||
b::Loop::Vertex { .. } => Err(unsupported("vertex loops")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn reverse_wire(wire: &mut [b::EdgeUse]) {
|
||||
wire.reverse();
|
||||
for edge in wire {
|
||||
edge.forward = !edge.forward;
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let mut face = mt::Face::new_unchecked(wires, load_surface(f.surface, f.domain)?);
|
||||
if !forward {
|
||||
face.invert();
|
||||
}
|
||||
Ok(face)
|
||||
}
|
||||
|
||||
/// Exact: every record holds the producer's own numbers, so loading it gives back the same
|
||||
/// curve, never a refit.
|
||||
fn save_curve(c: &mt::Curve, d: usize) -> Result<b::Curve> {
|
||||
@@ -271,11 +421,30 @@ fn save_surface(s: &mt::Surface, d: usize) -> Result<b::Surface> {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// An edge's curve for truck, which bounds an edge by its curve's range: the record itself
|
||||
/// where the interval is that range (all this adapter writes), else the piece on the interval
|
||||
/// for the kinds that have one in closed form.
|
||||
fn load_edge_curve(c: b::Curve, interval: [f64; 2]) -> Result<mt::Curve> {
|
||||
match c {
|
||||
b::Curve::Line { start, end } if interval != [0., 1.] => {
|
||||
let at = |t: f64| p(start) + (p(end) - p(start)) * t;
|
||||
Ok(mt::Curve::Line(mt::Line(at(interval[0]), at(interval[1]))))
|
||||
}
|
||||
b::Curve::Circle { frame, radius } => arc(&frame, [radius, radius], interval),
|
||||
b::Curve::Ellipse { frame, radii } => arc(&frame, radii, interval),
|
||||
b::Curve::Line { .. } => load_curve(c),
|
||||
c => match c.natural() {
|
||||
b::Natural::Range(a, z) if [a, z] == interval => load_curve(c),
|
||||
_ => Err(unsupported("a spline or intersection edge on part of its curve")),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn load_curve(c: b::Curve) -> Result<mt::Curve> {
|
||||
Ok(match c {
|
||||
b::Curve::Line { start, end } => mt::Curve::Line(mt::Line(p(start), p(end))),
|
||||
b::Curve::CircleArc { frame, radius, angle } => arc(&frame, [radius, radius], angle)?,
|
||||
b::Curve::EllipseArc { frame, radii, angle } => arc(&frame, radii, angle)?,
|
||||
b::Curve::Circle { frame, radius } => arc(&frame, [radius, radius], [0., std::f64::consts::TAU])?,
|
||||
b::Curve::Ellipse { frame, radii } => arc(&frame, radii, [0., std::f64::consts::TAU])?,
|
||||
b::Curve::Spline { knots, points } => {
|
||||
mt::Curve::BSplineCurve(mt::BSplineCurve::try_new(knots.into(), points.into_iter().map(p).collect()).map_err(invalid)?)
|
||||
}
|
||||
@@ -283,9 +452,6 @@ fn load_curve(c: b::Curve) -> Result<mt::Curve> {
|
||||
mt::BSplineCurve::try_new(knots.into(), homogeneous.into_iter().map(v4).collect()).map_err(invalid)?,
|
||||
)),
|
||||
b::Curve::Polyline { .. } => return Err(invalid("standalone polyline unsupported by this adapter")),
|
||||
b::Curve::Intersection { knots, points, .. } => {
|
||||
mt::Curve::BSplineCurve(mt::BSplineCurve::try_new(knots.into(), points.into_iter().map(p).collect()).map_err(invalid)?)
|
||||
}
|
||||
b::Curve::SurfaceIntersection { surfaces: [s0, s1], leader, tolerance } => {
|
||||
let leader = match *leader {
|
||||
b::Curve::Polyline { points } => mt::Leader::Polyline(mt::PolylineCurve(points.into_iter().map(p).collect())),
|
||||
@@ -294,12 +460,16 @@ fn load_curve(c: b::Curve) -> Result<mt::Curve> {
|
||||
}
|
||||
_ => return Err(invalid("intersection leader")),
|
||||
};
|
||||
let (s0, s1) = (load_surface(*s0)?, load_surface(*s1)?);
|
||||
let (s0, s1) = (load_surface(*s0, UNIT)?, load_surface(*s1, UNIT)?);
|
||||
mt::Curve::IntersectionCurve(mt::IntersectionCurve::new_unchecked(Box::new(s0), Box::new(s1), leader, tolerance))
|
||||
}
|
||||
})
|
||||
}
|
||||
fn load_surface(s: b::Surface) -> Result<mt::Surface> {
|
||||
/// The domain an intersection's surfaces load with: they have no face of their own.
|
||||
const UNIT: [[f64; 2]; 2] = [[0., 1.]; 2];
|
||||
/// A surface for truck; `domain` (the face's) bounds the profile of an analytic surface,
|
||||
/// which truck holds as a revolution.
|
||||
fn load_surface(s: b::Surface, domain: [[f64; 2]; 2]) -> Result<mt::Surface> {
|
||||
Ok(match s {
|
||||
b::Surface::Plane { origin, u_axis, v_axis } => mt::Surface::Plane(mt::Plane::new(p(origin), p(origin) + v(u_axis), p(origin) + v(v_axis))),
|
||||
b::Surface::Spline { u_knots, v_knots, points } => mt::Surface::BSplineSurface(
|
||||
@@ -310,10 +480,6 @@ fn load_surface(s: b::Surface) -> Result<mt::Surface> {
|
||||
mt::BSplineSurface::try_new((u_knots.into(), v_knots.into()), homogeneous.into_iter().map(|r| r.into_iter().map(v4).collect()).collect())
|
||||
.map_err(invalid)?,
|
||||
)),
|
||||
b::Surface::Revolution { profile, origin, axis, angle } => {
|
||||
full_turn(&angle)?;
|
||||
revolved(load_curve(*profile)?, p(origin), v(axis), false)
|
||||
}
|
||||
b::Surface::PlaneThrough { origin, u_point, v_point } => mt::Surface::Plane(mt::Plane::new(p(origin), p(u_point), p(v_point))),
|
||||
b::Surface::PlacedRevolution { profile, origin, axis, matrix: [x, y, z, w], forward } => {
|
||||
let placed = PlacedRevolution {
|
||||
@@ -323,99 +489,10 @@ fn load_surface(s: b::Surface) -> Result<mt::Surface> {
|
||||
};
|
||||
mt::Surface::RevolutedCurve(serde_json::from_value(serde_json::to_value(placed).map_err(invalid)?).map_err(invalid)?)
|
||||
}
|
||||
analytic => load_analytic(analytic)?,
|
||||
analytic => load_analytic(analytic, domain[1])?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mt::{BoundedCurve, InnerSpace, ParametricCurve, Transformed};
|
||||
|
||||
#[test]
|
||||
fn refuses_domain_or_axis_changes_instead_of_approximating() {
|
||||
let curve = b::Curve::Spline { knots: vec![0., 1., 2., 3.], points: vec![[0.; 3], [1.; 3]] };
|
||||
assert!(compatible_curve(&curve).is_err());
|
||||
let plane = b::Surface::Plane { origin: [1e12; 3], u_axis: [1e-6, 0., 0.], v_axis: [0., 1., 0.] };
|
||||
assert!(compatible_surface(&plane).is_err());
|
||||
assert!(clamped(&[0., 0., 1., 1.], 2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_records_decode_as_their_mathematical_loci_and_normals() {
|
||||
use mt::{MetricSpace, ParametricSurface, ParametricSurface3D};
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
let frame = b::Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
|
||||
let circle = load_curve(b::Curve::CircleArc { frame: frame.clone(), radius: 2., angle: [0., FRAC_PI_2] }).unwrap();
|
||||
assert!(circle.subs(0.).distance(p([3., 2., 3.])) < 1e-12);
|
||||
assert!(circle.subs(1.).distance(p([1., 4., 3.])) < 1e-12);
|
||||
let ellipse = load_curve(b::Curve::EllipseArc { frame: frame.clone(), radii: [2., 3.], angle: [0., FRAC_PI_2] }).unwrap();
|
||||
assert!(ellipse.subs(1.).distance(p([1., 5., 3.])) < 1e-12);
|
||||
let cases = [
|
||||
(b::Surface::Cylinder { frame: frame.clone(), radius: 2., angle: [0., TAU], height: [-1., 3.] }, 0.5, [3., 2., 4.], [1., 0., 0.]),
|
||||
(
|
||||
b::Surface::Cone { frame: frame.clone(), radius_at_origin: 2., half_angle: 0.2, angle: [0., TAU], height: [0., 3.] },
|
||||
0.5,
|
||||
[3. + 1.5 * 0.2f64.tan(), 2., 4.5],
|
||||
[1., 0., -0.2f64.tan()],
|
||||
),
|
||||
(
|
||||
b::Surface::Sphere { frame: frame.clone(), radius: 2., longitude: [0., TAU], latitude: [-FRAC_PI_2, FRAC_PI_2] },
|
||||
0.5,
|
||||
[3., 2., 3.],
|
||||
[1., 0., 0.],
|
||||
),
|
||||
(
|
||||
b::Surface::Torus { frame, major_radius: 4., minor_radius: 1., longitude: [0., TAU], meridian: [0., TAU] },
|
||||
0.,
|
||||
[6., 2., 3.],
|
||||
[1., 0., 0.],
|
||||
),
|
||||
];
|
||||
for (record, parameter, expected, normal) in cases {
|
||||
let surface = load_surface(record).unwrap();
|
||||
assert!(surface.subs(0., parameter).distance(p(expected)) < 1e-10);
|
||||
assert!(surface.normal(0., parameter).normalize().dot(v(normal).normalize()) > 1. - 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersections_and_revolutions_reload_exactly() {
|
||||
use solidcraft_geom::Vec3;
|
||||
let cyl = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 40.).unwrap();
|
||||
let block = crate::box_solid(Vec3::new(-20., -20., 20.), Vec3::new(20., 20., 60.)).unwrap();
|
||||
let block = crate::transform(&block, Vec3::ZERO, Vec3::new(0., 0., 20.), Vec3::X, 0.4).unwrap();
|
||||
let cut = crate::boolean(&cyl, &block, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let curve = cut.solid.edge_iter().map(|e| e.curve()).find(|c| matches!(c, mt::Curve::IntersectionCurve(_))).unwrap();
|
||||
let record = save_curve(&curve, 0).unwrap();
|
||||
assert!(matches!(record, b::Curve::SurfaceIntersection { .. }), "the procedural curve itself, not a fit");
|
||||
let restored = load_curve(record.clone()).unwrap();
|
||||
let (a, b) = curve.range_tuple();
|
||||
for i in 0..=64 {
|
||||
let t = a + (b - a) * i as f64 / 64.;
|
||||
assert_eq!(curve.subs(t), restored.subs(t), "sample {i}");
|
||||
}
|
||||
assert_eq!(save_curve(&restored, 0).unwrap(), record);
|
||||
// The whole body: the same records again, and the same volume to the last bit.
|
||||
let bytes = crate::encode_native_body(&cut).unwrap();
|
||||
let decoded = crate::decode_native_body(&bytes).unwrap();
|
||||
assert_eq!(crate::encode_native_body(&decoded).unwrap(), bytes);
|
||||
assert_eq!(crate::measure(&decoded).unwrap().volume, crate::measure(&cut).unwrap().volume);
|
||||
// A revolution keeps its own placement, reversed normal included.
|
||||
let line = mt::Curve::Line(mt::Line(mt::Point3::new(2., 0., 0.), mt::Point3::new(2.5, 0., 3.)));
|
||||
let mt::Surface::RevolutedCurve(mut placed) = revolved(line, mt::Point3::new(0., 1., 0.), mt::Vector3::new(0., 0., 1.), true) else {
|
||||
panic!("a revolution")
|
||||
};
|
||||
placed.transform_by(mt::Matrix4::from_translation(mt::Vector3::new(1., 2., 3.)) * mt::Matrix4::from_angle_x(mt::Rad(0.3)));
|
||||
let placed = mt::Surface::RevolutedCurve(placed);
|
||||
let record = save_surface(&placed, 0).unwrap();
|
||||
assert!(matches!(record, b::Surface::PlacedRevolution { forward: false, .. }));
|
||||
let restored = load_surface(record.clone()).unwrap();
|
||||
assert_eq!(save_surface(&restored, 0).unwrap(), record);
|
||||
use mt::{ParametricSurface, ParametricSurface3D};
|
||||
for (u, v) in [(0., 0.), (1.3, 0.4), (4., 1.)] {
|
||||
assert_eq!(placed.subs(u, v), restored.subs(u, v));
|
||||
assert_eq!(placed.normal(u, v), restored.normal(u, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "adapter_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use super::*;
|
||||
use mt::{BoundedCurve, InnerSpace, Transformed};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
#[test]
|
||||
fn refuses_domain_or_axis_changes_instead_of_approximating() {
|
||||
let curve = b::Curve::Spline { knots: vec![0., 1., 2., 3.], points: vec![[0.; 3], [1.; 3]] };
|
||||
assert!(compatible_curve(&curve).is_err());
|
||||
let plane = b::Surface::Plane { origin: [1e12; 3], u_axis: [1e-6, 0., 0.], v_axis: [0., 1., 0.] };
|
||||
assert!(compatible_surface(&plane).is_err());
|
||||
assert!(clamped(&[0., 0., 1., 1.], 2).is_ok());
|
||||
// A spline edge on part of its curve would need truck to cut it: refused, not refitted.
|
||||
let spline = b::Curve::Spline { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1.; 3]] };
|
||||
assert!(load_edge_curve(spline.clone(), [0., 0.5]).is_err());
|
||||
assert!(load_edge_curve(spline, [0., 1.]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_records_decode_as_their_mathematical_loci_and_normals() {
|
||||
use mt::{MetricSpace, ParametricSurface, ParametricSurface3D};
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
let frame = b::Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
|
||||
let circle = load_edge_curve(b::Curve::Circle { frame: frame.clone(), radius: 2. }, [0., FRAC_PI_2]).unwrap();
|
||||
assert!(circle.subs(0.).distance(p([3., 2., 3.])) < 1e-12);
|
||||
assert!(circle.subs(1.).distance(p([1., 4., 3.])) < 1e-12);
|
||||
let ellipse = load_edge_curve(b::Curve::Ellipse { frame: frame.clone(), radii: [2., 3.] }, [0., FRAC_PI_2]).unwrap();
|
||||
assert!(ellipse.subs(1.).distance(p([1., 5., 3.])) < 1e-12);
|
||||
// Past the seam: an arc from -π/2 to π/2.
|
||||
let across = load_edge_curve(b::Curve::Circle { frame: frame.clone(), radius: 2. }, [-FRAC_PI_2, FRAC_PI_2]).unwrap();
|
||||
assert!(across.subs(0.).distance(p([1., 0., 3.])) < 1e-12);
|
||||
// A line edge on part of its line.
|
||||
let line = load_edge_curve(b::Curve::Line { start: [0.; 3], end: [1., 0., 0.] }, [-1., 2.]).unwrap();
|
||||
assert!(line.subs(0.).distance(p([-1., 0., 0.])) < 1e-12 && line.subs(1.).distance(p([2., 0., 0.])) < 1e-12);
|
||||
let cases = [
|
||||
(b::Surface::Cylinder { frame: frame.clone(), radius: 2. }, [-1., 3.], 0.5, [3., 2., 4.], [1., 0., 0.]),
|
||||
(
|
||||
b::Surface::Cone { frame: frame.clone(), radius_at_origin: 2., half_angle: 0.2 },
|
||||
[0., 3.],
|
||||
0.5,
|
||||
[3. + 1.5 * 0.2f64.tan(), 2., 4.5],
|
||||
[1., 0., -0.2f64.tan()],
|
||||
),
|
||||
(b::Surface::Sphere { frame: frame.clone(), radius: 2. }, [-FRAC_PI_2, FRAC_PI_2], 0.5, [3., 2., 3.], [1., 0., 0.]),
|
||||
(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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every face, edge and vertex index of the record is the kernel's own: faces in `face_iter`
|
||||
/// order, edges in `Body::edges` order (first appearance through `edge_iter`, which walks a
|
||||
/// reversed face backwards).
|
||||
fn same_indices(body: &Body, record: &b::Brep) {
|
||||
assert_eq!(record.faces.len(), body.face_count());
|
||||
let edges = Body::unique_edges(&body.solid);
|
||||
assert_eq!(record.edges.len(), edges.len());
|
||||
for (r, e) in record.edges.iter().zip(&edges) {
|
||||
let ends = [e.absolute_front().point(), e.absolute_back().point()];
|
||||
let recorded = r.vertices.map(|i| p(record.vertices[i as usize].point));
|
||||
assert_eq!(recorded, ends);
|
||||
}
|
||||
for (r, f) in record.faces.iter().zip(body.solid.face_iter()) {
|
||||
let b::Loop::Edges { edges } = &r.loops[0] else { panic!("an edge loop") };
|
||||
assert_eq!(edges.len(), f.absolute_boundaries()[0].len());
|
||||
}
|
||||
let history = crate::history::edge_faces(&body.solid);
|
||||
for (i, faces) in history.iter().enumerate() {
|
||||
let mut recorded: Vec<usize> = record.edges[i].uses.iter().map(|u| u.face as usize).collect();
|
||||
recorded.dedup();
|
||||
assert_eq!(&recorded, faces, "edge {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_bodies_encode_in_the_kernels_order_and_decode_exactly() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
let slot = crate::box_solid(Vec3::new(10., -1., 4.), Vec3::new(20., 31., 11.)).unwrap();
|
||||
let notched = crate::boolean(&cube, &slot, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let sheet = crate::copy_faces(&cube, &[Vec3::new(20., 15., 10.)]).unwrap();
|
||||
let inner = crate::box_solid(Vec3::new(5., 5., 2.), Vec3::new(10., 10., 6.)).unwrap();
|
||||
let hollow = crate::boolean(&cube, &inner, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
for (name, body) in [("cube", cube), ("notched", notched), ("sheet", sheet), ("hollow", hollow)] {
|
||||
assert!(body.solid.face_iter().any(|f| !f.orientation()) || name == "sheet", "{name}: a reversed face is walked");
|
||||
let record = encode(&body, super::super::pcurve::pcurves).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
same_indices(&body, &record);
|
||||
let decoded = decode(record.clone()).unwrap();
|
||||
let again = Body::new(decoded).unwrap();
|
||||
let record2 = encode(&again, super::super::pcurve::pcurves).unwrap();
|
||||
assert_eq!(record, record2, "{name}: decoded body re-encodes to the same record");
|
||||
match name {
|
||||
"sheet" => assert_eq!(record.regions, vec![b::Region { shells: vec![0], void: false }]),
|
||||
"hollow" => {
|
||||
assert_eq!(record.shells.len(), 2);
|
||||
assert_eq!(
|
||||
record.regions,
|
||||
vec![
|
||||
b::Region { shells: vec![0], void: false },
|
||||
b::Region { shells: vec![0, 1], void: false },
|
||||
b::Region { shells: vec![1], void: true },
|
||||
]
|
||||
);
|
||||
}
|
||||
_ => assert_eq!(record.regions, vec![b::Region { shells: vec![0], void: false }, b::Region { shells: vec![0], void: false }]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_truck_cannot_hold_are_refused() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(4., 3., 1.)).unwrap();
|
||||
let record = encode(&cube, super::super::pcurve::pcurves).unwrap();
|
||||
let mut wire = record.clone();
|
||||
wire.shells.push(b::Shell::Vertex { vertex: 0 });
|
||||
assert!(decode(wire).is_err());
|
||||
let mut vertex_loop = record;
|
||||
vertex_loop.faces[0].loops.push(b::Loop::Vertex { vertex: 0 });
|
||||
assert!(decode(vertex_loop).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersections_and_revolutions_reload_exactly() {
|
||||
let cyl = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 40.).unwrap();
|
||||
let block = crate::box_solid(Vec3::new(-20., -20., 20.), Vec3::new(20., 20., 60.)).unwrap();
|
||||
let block = crate::transform(&block, Vec3::ZERO, Vec3::new(0., 0., 20.), Vec3::X, 0.4).unwrap();
|
||||
let cut = crate::boolean(&cyl, &block, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let curve = cut.solid.edge_iter().map(|e| e.curve()).find(|c| matches!(c, mt::Curve::IntersectionCurve(_))).unwrap();
|
||||
let record = save_curve(&curve, 0).unwrap();
|
||||
assert!(matches!(record, b::Curve::SurfaceIntersection { .. }), "the procedural curve itself, not a fit");
|
||||
let (a, z) = curve.range_tuple();
|
||||
let restored = load_edge_curve(record.clone(), [a, z]).unwrap();
|
||||
for i in 0..=64 {
|
||||
let t = a + (z - a) * i as f64 / 64.;
|
||||
assert_eq!(curve.subs(t), restored.subs(t), "sample {i}");
|
||||
}
|
||||
assert_eq!(save_curve(&restored, 0).unwrap(), record);
|
||||
// A revolution keeps its own placement, reversed normal included.
|
||||
let line = mt::Curve::Line(mt::Line(mt::Point3::new(2., 0., 0.), mt::Point3::new(2.5, 0., 3.)));
|
||||
let mt::Surface::RevolutedCurve(mut placed) = revolved(line, mt::Point3::new(0., 1., 0.), mt::Vector3::new(0., 0., 1.), true) else {
|
||||
panic!("a revolution")
|
||||
};
|
||||
placed.transform_by(mt::Matrix4::from_translation(mt::Vector3::new(1., 2., 3.)) * mt::Matrix4::from_angle_x(mt::Rad(0.3)));
|
||||
let placed = mt::Surface::RevolutedCurve(placed);
|
||||
let record = save_surface(&placed, 0).unwrap();
|
||||
assert!(matches!(record, b::Surface::PlacedRevolution { forward: false, .. }));
|
||||
let restored = load_surface(record.clone(), UNIT).unwrap();
|
||||
assert_eq!(save_surface(&restored, 0).unwrap(), record);
|
||||
use mt::{ParametricSurface, ParametricSurface3D};
|
||||
for (u, v) in [(0., 0.), (1.3, 0.4), (4., 1.)] {
|
||||
assert_eq!(placed.subs(u, v), restored.subs(u, v));
|
||||
assert_eq!(placed.normal(u, v), restored.normal(u, v));
|
||||
}
|
||||
// The whole body: the same records again, and the same volume to the last bit.
|
||||
let bytes = crate::encode_native_body(&cut).unwrap();
|
||||
let decoded = crate::decode_native_body(&bytes).unwrap();
|
||||
assert_eq!(crate::encode_native_body(&decoded).unwrap(), bytes);
|
||||
assert_eq!(crate::measure(&decoded).unwrap().volume, crate::measure(&cut).unwrap().volume);
|
||||
}
|
||||
@@ -1,14 +1,6 @@
|
||||
//! Analytic wire records are mathematical loci, independent of backend parameterizations.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn full_turn(angle: &[f64; 2]) -> Result<()> {
|
||||
if (angle[1] - angle[0] - std::f64::consts::TAU).abs() > 1e-12 {
|
||||
Err(invalid("partial angular surface domains need another adapter representation"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact rational conic locus. The adapter reparameterizes angle by rational spans.
|
||||
pub(super) fn arc(frame: &b::Frame, radii: [f64; 2], angle: [f64; 2]) -> Result<mt::Curve> {
|
||||
let span = angle[1] - angle[0];
|
||||
@@ -36,32 +28,32 @@ pub(super) fn revolved(profile: mt::Curve, origin: mt::Point3, axis: mt::Vector3
|
||||
mt::Surface::RevolutedCurve(s)
|
||||
}
|
||||
|
||||
pub(super) fn load_analytic(surface: b::Surface) -> Result<mt::Surface> {
|
||||
/// 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.
|
||||
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));
|
||||
Ok(match surface {
|
||||
b::Surface::Cylinder { frame, radius, angle, height } => {
|
||||
full_turn(&angle)?;
|
||||
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)
|
||||
}
|
||||
b::Surface::Cone { frame, radius_at_origin, half_angle, angle, height } => {
|
||||
full_turn(&angle)?;
|
||||
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)
|
||||
}
|
||||
b::Surface::Sphere { frame, radius, longitude, latitude } => {
|
||||
full_turn(&longitude)?;
|
||||
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], latitude)?, p(frame.origin), z, true)
|
||||
revolved(arc(&meridian, [radius; 2], [-FRAC_PI_2, FRAC_PI_2])?, p(frame.origin), z, true)
|
||||
}
|
||||
b::Surface::Torus { frame, major_radius, minor_radius, longitude, meridian } => {
|
||||
full_turn(&longitude)?;
|
||||
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], meridian)?, p(frame.origin), z, true)
|
||||
revolved(arc(&profile, [minor_radius; 2], [0., TAU])?, p(frame.origin), z, true)
|
||||
}
|
||||
_ => return Err(invalid("expected analytic surface")),
|
||||
})
|
||||
|
||||
@@ -113,6 +113,22 @@ pub(super) fn meshes_meet(a: &Mesh, b: &Mesh, gap: f64) -> Result<bool> {
|
||||
}
|
||||
|
||||
pub(super) fn groups(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result<Vec<Vec<usize>>> {
|
||||
let parent = parents(meshes, volumes, gap)?;
|
||||
let mut groups = Vec::new();
|
||||
for (i, v) in volumes.iter().enumerate() {
|
||||
if *v <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut group = vec![i];
|
||||
group.extend(parent.iter().enumerate().filter_map(|(j, p)| (*p == Some(i)).then_some(j)));
|
||||
groups.push(group);
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// The shell directly around each closed shell (`None` for one in the open): outward shells
|
||||
/// (positive volume) bound material, inward ones voids, and nesting alternates them.
|
||||
pub(super) fn parents(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result<Vec<Option<usize>>> {
|
||||
if meshes.is_empty() || meshes.len() != volumes.len() || meshes.len() > MAX_SHELLS {
|
||||
return Err(failed("shell count exceeds the grouping limit or is invalid"));
|
||||
}
|
||||
@@ -229,14 +245,5 @@ pub(super) fn groups(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result<Vec<V
|
||||
}
|
||||
parent.push(owner);
|
||||
}
|
||||
let mut groups = Vec::new();
|
||||
for (i, v) in volumes.iter().enumerate() {
|
||||
if *v <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut group = vec![i];
|
||||
group.extend(parent.iter().enumerate().filter_map(|(j, p)| (*p == Some(i)).then_some(j)));
|
||||
groups.push(group);
|
||||
}
|
||||
Ok(groups)
|
||||
Ok(parent)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user