diff --git a/Cargo.lock b/Cargo.lock index 683f64be2e..e91aaca243 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4432,6 +4432,9 @@ dependencies = [ [[package]] name = "solidcraft-step" version = "0.18.1" +dependencies = [ + "solidcraft-geom", +] [[package]] name = "solidcraft-ui-egui" diff --git a/crates/step/Cargo.toml b/crates/step/Cargo.toml index e92b2635d7..72e4f7afc6 100644 --- a/crates/step/Cargo.toml +++ b/crates/step/Cargo.toml @@ -7,3 +7,6 @@ license.workspace = true [lints] workspace = true + +[dependencies] +solidcraft-geom = { workspace = true } diff --git a/crates/step/src/lib.rs b/crates/step/src/lib.rs index edbed383df..3a6c4720a0 100644 --- a/crates/step/src/lib.rs +++ b/crates/step/src/lib.rs @@ -1,8 +1,8 @@ //! STEP (ISO 10303) for SolidCraft, independent of any modelling backend. [`p21`] reads the //! exchange structure, [`read`] turns its geometry entities into the plain curves and surfaces //! of [`geom`], and [`write`] writes them back. Written from the public ISO 10303-21 and -42 -//! specifications. Topology (vertices to solids) builds on the kernel-neutral B-rep and comes -//! next. +//! specifications. [`topology`] reads solids (vertices to shells) into the kernel-neutral B-rep, +//! schema 4, and [`units`] the units of a representation context. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -10,6 +10,7 @@ mod eval; pub mod geom; pub mod p21; pub mod read; +pub mod topology; pub mod units; pub mod write; diff --git a/crates/step/src/topology.rs b/crates/step/src/topology.rs new file mode 100644 index 0000000000..37503f4440 --- /dev/null +++ b/crates/step/src/topology.rs @@ -0,0 +1,1324 @@ +//! STEP topology (ISO 10303-42 topology and geometric model entities) into the kernel-neutral +//! B-rep, schema 4 ([`solidcraft_geom::brep`], docs/native-brep.md). Written from the public +//! ISO 10303-42 specification. +//! +//! Each MANIFOLD_SOLID_BREP, BREP_WITH_VOIDS and SHELL_BASED_SURFACE_MODEL becomes one +//! [`Brep`]: a closed shell separates the infinite region from a solid (or, as a void, a solid +//! from its cavity); an open shell lies in the infinite region. A solid that cannot be read is +//! skipped with its reason and the rest of the file is still read. +//! +//! Geometry: LINE, CIRCLE, ELLIPSE, B-spline curves and POLYLINE map to the schema's curve kinds +//! (SURFACE_CURVE and its subtypes by their 3D curve, TRIMMED_CURVE by its basis: the edge's +//! vertices bound it); PLANE, CYLINDRICAL_, CONICAL_, SPHERICAL_ and (ring) TOROIDAL_SURFACE and +//! B-spline surfaces map to the surface kinds (RECTANGULAR_TRIMMED_SURFACE by its basis). Anything +//! else refuses its solid ("not supported yet: …"). +//! +//! Orientation: an EDGE_CURVE's interval is the parameters of its vertices on its curve, from +//! min to max; the schema edge runs with the STEP edge or against it (`same_sense` .F. on a +//! closed curve, or vertices in decreasing parameter order on an open one), and each oriented +//! edge's `forward` says how the loop runs along the schema edge. Loops are stored relative to +//! the surface's natural normal: FACE_BOUND orientation and ADVANCED_FACE same_sense both +//! reverse a loop, and same_sense (with ORIENTED_FACE and ORIENTED_CLOSED_SHELL orientation) +//! gives the oriented face's side. +//! +//! Tolerances: the representation context's distance uncertainty (default [`DEFAULT_TOLERANCE`] +//! when the file has none, never below [`MIN_TOLERANCE`]) is the brep's tolerance and every +//! vertex's and edge's to start with. A vertex measurably off its curve or surface gets that +//! distance (with margin) as its tolerance; a face whose edges lie off its surface gets its +//! pcurves fitted at a looser tolerance, which its edges then carry. +//! +//! Pcurves are computed from the 3D edges and our surfaces ([`pcurve::pcurves`]); the file's +//! own PCURVE entities are not used. +//! +//! Input is hostile: every reference is checked, nesting and counts are capped, and the work of +//! a whole file is bounded (shared shells count each time a solid uses them). + +use std::collections::{HashMap, HashSet}; +use std::f64::consts::TAU; + +use solidcraft_geom::Vec3; +use solidcraft_geom::brep::eval::{CurveEval, SurfaceEval}; +use solidcraft_geom::brep::pcurve::{self, EdgeUse}; +use solidcraft_geom::brep::{ + self, Brep, Curve as BCurve, Curve2, Edge, EdgeUseRef, Face, Frame, Loop, Natural, OrientedEdge, OrientedFace, Region, Shell, + Surface as BSurface, Vertex, +}; + +use crate::geom::{BSplineCurve, BSplineSurface, Curve, P3, Placement, Surface, dot, scale, sub}; +use crate::p21::{Entity, Exchange, Param}; +use crate::read::{R, Reader}; +use crate::units; + +/// Tolerance (mm) when the file states no uncertainty. +pub const DEFAULT_TOLERANCE: f64 = 1e-3; +/// The least tolerance (mm) a record gets, whatever the file states. +pub const MIN_TOLERANCE: f64 = 1e-7; +/// The loosest tolerance (mm) a face's pcurves are fitted at before the solid is refused. +pub const MAX_FIT_TOLERANCE: f64 = 0.1; +/// Most faces in one solid. +pub const MAX_FACES: usize = 500_000; +/// Most edges in one loop, and in all of one face's loops. +pub const MAX_LOOP: usize = 100_000; +/// Most bounds of one face. +pub const MAX_BOUNDS: usize = 10_000; +/// Most faces read in one file, counting a shell again for each solid that uses it. +pub const MAX_FILE_FACES: usize = 5_000_000; +/// Most solid items read from one file. +pub const MAX_SOLIDS: usize = 100_000; +/// Deepest nesting of ORIENTED_EDGE in ORIENTED_EDGE, ORIENTED_FACE in ORIENTED_FACE. +const MAX_NESTING: usize = 4; +/// Most control points in one direction of a schema spline. +const MAX_SPLINE_POINTS: usize = 4096; +/// Highest schema spline degree. +const MAX_SPLINE_DEGREE: usize = 16; +/// Samples along a curve when projecting a vertex onto it. +const MAX_SAMPLES: usize = 4096; +/// How far (mm) a vertex may lie from its curve before the edge is refused. +const MAX_VERTEX_GAP: f64 = 1.0; + +/// A solid (or sheet) read from the file. +#[derive(Clone, Debug)] +pub struct Solid { + /// The STEP item (MANIFOLD_SOLID_BREP, BREP_WITH_VOIDS or SHELL_BASED_SURFACE_MODEL). + pub item: u64, + /// The item's own name, else its product's. + pub name: String, + /// The name of the product whose shape holds the item, when the file says. + pub product: Option, + /// In the coordinates of the item's own representation (assembly placements not applied). + pub brep: Brep, + /// No closed shell: a surface model of open shells only. + pub sheet: bool, + /// What was repaired or noted on the way (faces whose same_sense disagreed with their + /// loops, edges whose same_sense disagreed with their vertices, raised tolerances). + pub notes: Vec, +} + +/// A solid item that could not be read, and why. +#[derive(Clone, Debug, PartialEq)] +pub struct Failure { + pub item: u64, + pub message: String, +} + +/// Every solid of a file: those read and those refused. +#[derive(Clone, Debug, Default)] +pub struct Solids { + pub solids: Vec, + pub failures: Vec, +} + +fn entity(ex: &Exchange, id: u64) -> R<&Entity> { + ex.get(id).ok_or_else(|| format!("missing entity #{id}")) +} + +fn rid(p: Option<&Param>, what: &str) -> R { + p.and_then(Param::as_ref_id).ok_or_else(|| format!("{what}: expected a reference")) +} + +fn refs(p: Option<&Param>, what: &str) -> R> { + let list = p.and_then(Param::as_list).ok_or_else(|| format!("{what}: expected a list"))?; + list.iter().map(|x| x.as_ref_id().ok_or_else(|| format!("{what}: expected references"))).collect() +} + +fn flag(p: Option<&Param>, what: &str) -> R { + p.and_then(Param::as_bool).ok_or_else(|| format!("{what}: expected a boolean")) +} + +fn unsupported(what: impl std::fmt::Display) -> String { + format!("not supported yet: {what}") +} + +fn index(i: usize) -> R { + u32::try_from(i).map_err(|_| "too many items".to_string()) +} + +fn v3(p: P3) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +/// Items and context of a representation entity (plain or complex). +fn representation(e: &Entity) -> Option<(Vec, Option)> { + let p = if e.is_complex() { + e.record("REPRESENTATION") + .or_else(|| e.records.iter().find(|r| r.name.ends_with("REPRESENTATION") && r.params.len() >= 3).map(|r| r.params.as_slice()))? + } else if e.name().ends_with("REPRESENTATION") { + e.params() + } else { + return None; + }; + let items = p.get(1)?.as_list()?.iter().filter_map(Param::as_ref_id).collect(); + Some((items, p.get(2).and_then(Param::as_ref_id))) +} + +const SOLID_ITEMS: [&str; 4] = ["MANIFOLD_SOLID_BREP", "BREP_WITH_VOIDS", "SHELL_BASED_SURFACE_MODEL", "FACETED_BREP"]; + +/// Where each solid item sits: its representation's context, and the product that representation +/// (or one related to it) is the shape of. +struct Index { + context: HashMap, + product: HashMap, +} + +impl Index { + fn new(ex: &Exchange) -> Index { + let mut ids: Vec = ex.entities.keys().copied().collect(); + ids.sort_unstable(); + let mut context = HashMap::new(); + let mut rep_of: HashMap = HashMap::new(); + let mut shape_of: HashMap = HashMap::new(); // representation → PRODUCT_DEFINITION_SHAPE + let mut links: HashMap> = HashMap::new(); + for &id in &ids { + let Some(e) = ex.get(id) else { continue }; + if let Some((items, ctx)) = representation(e) { + for item in items { + if let Some(c) = ctx { + context.entry(item).or_insert(c); + } + rep_of.entry(item).or_insert(id); + } + } + let p = e.params(); + let rf = |i: usize| p.get(i).and_then(Param::as_ref_id); + match e.name() { + "SHAPE_DEFINITION_REPRESENTATION" => { + if let (Some(d), Some(r)) = (rf(0), rf(1)) { + shape_of.entry(r).or_insert(d); + } + } + "SHAPE_REPRESENTATION_RELATIONSHIP" | "REPRESENTATION_RELATIONSHIP" if !e.is_complex() => { + if let (Some(a), Some(b)) = (rf(2), rf(3)) { + links.entry(a).or_default().push(b); + links.entry(b).or_default().push(a); + } + } + _ => {} + } + } + let name_of = |pds: u64| -> Option { + let pd = ex.get(pds)?.params().get(2)?.as_ref_id()?; + let pdf = ex.get(pd)?.params().get(2)?.as_ref_id()?; + let product = ex.get(pdf)?.params().get(2)?.as_ref_id()?; + let p = ex.get(product)?; + (p.name() == "PRODUCT").then(|| p.params().get(1).or(p.params().first())).flatten()?.as_str().map(str::to_string) + }; + let mut product = HashMap::new(); + for (&item, &rep) in &rep_of { + if !ex.get(item).is_some_and(|e| SOLID_ITEMS.contains(&e.name())) { + continue; + } + let direct = shape_of.get(&rep).copied(); + let linked = || links.get(&rep).into_iter().flatten().find_map(|r| shape_of.get(r).copied()); + if let Some(name) = direct.or_else(linked).and_then(name_of) { + product.insert(item, name); + } + } + Index { context, product } + } +} + +/// Read every solid of the file. A solid that cannot be read is listed in +/// [`Solids::failures`] with its reason; the others are still read. +pub fn read_solids(ex: &Exchange) -> Solids { + let mut items: Vec = ex.entities.iter().filter(|(_, e)| SOLID_ITEMS.contains(&e.name())).map(|(id, _)| *id).collect(); + items.sort_unstable(); + let index = Index::new(ex); + let mut out = Solids::default(); + let mut budget = MAX_FILE_FACES; + let mut readers: HashMap, units::Context)> = HashMap::new(); + for (n, item) in items.into_iter().enumerate() { + if n >= MAX_SOLIDS { + out.failures.push(Failure { item, message: format!("more than {MAX_SOLIDS} solids in one file") }); + continue; + } + let ctx = index.context.get(&item).copied(); + let key = ctx.unwrap_or(0); + let (reader, context) = readers.entry(key).or_insert_with(|| { + let c = ctx.map(|c| units::context(ex, c)).unwrap_or_default(); + (Reader::new(ex, c.units), c) + }); + let context = *context; + match read_with(ex, reader, item, context, &mut budget) { + Ok(mut s) => { + s.product = index.product.get(&item).cloned(); + if s.name.is_empty() + && let Some(p) = &s.product + { + s.name = p.clone(); + } + out.solids.push(s); + } + Err(message) => out.failures.push(Failure { item, message }), + } + } + out +} + +/// Read one solid item in the given context. +pub fn read_solid(ex: &Exchange, item: u64, context: units::Context) -> R { + let mut reader = Reader::new(ex, context.units); + let mut budget = MAX_FILE_FACES; + read_with(ex, &mut reader, item, context, &mut budget) +} + +/// The base tolerance of a context. +pub fn tolerance(context: &units::Context) -> f64 { + context.uncertainty.unwrap_or(DEFAULT_TOLERANCE).clamp(MIN_TOLERANCE, MAX_FIT_TOLERANCE) +} + +fn read_with<'a>(ex: &'a Exchange, reader: &mut Reader<'a>, item: u64, context: units::Context, budget: &mut usize) -> R { + let e = entity(ex, item)?; + let p = e.params(); + let name = p.first().and_then(Param::as_str).unwrap_or("").to_string(); + let mut b = Builder::new(ex, reader, tolerance(&context), budget); + let (shells, kind) = match e.name() { + "MANIFOLD_SOLID_BREP" => (vec![b.shell(rid(p.get(1), "outer shell")?)?], Kind::Solid), + "BREP_WITH_VOIDS" => { + let mut shells = vec![b.shell(rid(p.get(1), "outer shell")?)?]; + let voids = refs(p.get(2), "voids")?; + if voids.len() >= brep::MAX_SHELLS { + return Err(format!("{} voids", voids.len())); + } + for v in voids { + shells.push(b.shell(v)?); + } + (shells, Kind::Voids) + } + "SHELL_BASED_SURFACE_MODEL" => { + let ids = refs(p.get(1), "surface model shells")?; + if ids.is_empty() || ids.len() > brep::MAX_SHELLS { + return Err(format!("a surface model with {} shells", ids.len())); + } + let mut shells = Vec::with_capacity(ids.len()); + for s in ids { + shells.push(b.shell(s)?); + } + (shells, Kind::Model) + } + n => return Err(unsupported(n)), + }; + b.finish(item, name, shells, kind) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Solid, + Voids, + Model, +} + +/// A curve the edge can be read on, and how its parameter relates to its vertices. +struct EdgeB { + curve: BCurve, + interval: [f64; 2], + vertices: [u32; 2], + tolerance: f64, +} + +enum LoopB { + Edges(Vec<(u32, bool)>), + Vertex(u32), +} + +struct FaceB { + step: u64, + surface: BSurface, + /// Relative to the surface's natural normal. + loops: Vec, + /// The index in `loops` of the FACE_OUTER_BOUND, if one is marked. + outer: Option, + /// The face's normal is the surface's natural normal. + forward: bool, +} + +struct Builder<'b, 'a> { + ex: &'a Exchange, + reader: &'b mut Reader<'a>, + tol: f64, + budget: &'b mut usize, + vertex_ids: HashMap, + vertices: Vec, + /// STEP edge → schema edge, and whether the schema edge runs with the STEP edge. + edge_ids: HashMap, + edges: Vec, + face_ids: HashSet, + faces: Vec, + sense_mismatches: usize, +} + +impl<'b, 'a> Builder<'b, 'a> { + fn new(ex: &'a Exchange, reader: &'b mut Reader<'a>, tol: f64, budget: &'b mut usize) -> Self { + Builder { + ex, + reader, + tol, + budget, + vertex_ids: HashMap::new(), + vertices: Vec::new(), + edge_ids: HashMap::new(), + edges: Vec::new(), + face_ids: HashSet::new(), + faces: Vec::new(), + sense_mismatches: 0, + } + } + + fn vertex(&mut self, id: u64) -> R { + if let Some(v) = self.vertex_ids.get(&id) { + return Ok(*v); + } + let e = entity(self.ex, id)?; + if e.name() != "VERTEX_POINT" { + return Err(format!("#{id}: {}", unsupported(format!("vertex {}", e.name())))); + } + let point = self.reader.point(rid(e.params().get(1), "vertex point")?)?; + let i = index(self.vertices.len())?; + self.vertices.push(Vertex { point, tolerance: self.tol }); + self.vertex_ids.insert(id, i); + Ok(i) + } + + fn point(&self, v: u32) -> R { + self.vertices.get(v as usize).map(|v| v.point).ok_or_else(|| "vertex reference".to_string()) + } + + /// A vertex `gap` mm from where its edge's curve puts it: refused if far off, else its + /// tolerance covers the gap. + fn raise_vertex(&mut self, v: u32, gap: f64, edge: u64) -> R<()> { + if !(gap <= MAX_VERTEX_GAP) { + return Err(format!("edge #{edge}: a vertex lies {gap} mm off its curve")); + } + if let Some(x) = self.vertices.get_mut(v as usize) { + x.tolerance = x.tolerance.max(1.5 * gap); + } + Ok(()) + } + + /// The schema edge of an EDGE_CURVE, and whether it runs with the STEP edge. + fn edge(&mut self, id: u64) -> R<(u32, bool)> { + if let Some(e) = self.edge_ids.get(&id) { + return Ok(*e); + } + let e = entity(self.ex, id)?; + if e.name() != "EDGE_CURVE" { + return Err(format!("#{id}: {}", unsupported(format!("edge {}", e.name())))); + } + let p = e.params(); + let (vs_id, ve_id) = (rid(p.get(1), "edge start")?, rid(p.get(2), "edge end")?); + let (vs, ve) = (self.vertex(vs_id)?, self.vertex(ve_id)?); + let same_sense = flag(p.get(4), "edge same_sense")?; + let curve = self.edge_geometry(rid(p.get(3), "edge geometry")?, 0)?; + let (ps, pe) = (self.point(vs)?, self.point(ve)?); + let closed = vs == ve; + let near = closed || v3(ps).dist(v3(pe)) <= self.tol; + let mapped = map_edge(&curve, ps, pe, same_sense, closed, near, self.tol).map_err(|m| format!("edge #{id}: {m}"))?; + if mapped.sense_mismatch { + self.sense_mismatches += 1; + } + let vertices = if mapped.with_step { [vs, ve] } else { [ve, vs] }; + // The gaps as the record will be read: its curve at its interval's ends. + let eval = CurveEval::new(&mapped.curve).map_err(|m| format!("edge #{id}: {m}"))?; + for (t, v) in mapped.interval.into_iter().zip(vertices) { + let at = eval.point(t).ok_or_else(|| format!("edge #{id}: no point at {t}"))?; + let gap = at.dist(v3(self.point(v)?)); + self.raise_vertex(v, gap, id)?; + } + let i = index(self.edges.len())?; + self.edges.push(EdgeB { curve: mapped.curve, interval: mapped.interval, vertices, tolerance: self.tol }); + self.edge_ids.insert(id, (i, mapped.with_step)); + Ok((i, mapped.with_step)) + } + + /// An edge's curve: SURFACE_CURVE and its subtypes by their 3D curve, TRIMMED_CURVE by its + /// basis (the edge's vertices bound it). + fn edge_geometry(&mut self, id: u64, depth: usize) -> R { + if depth > MAX_NESTING { + return Err("edge geometry nested too deeply".into()); + } + let e = entity(self.ex, id)?; + if !e.is_complex() { + match e.name() { + "SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" | "BOUNDED_SURFACE_CURVE" => { + return self.edge_geometry(rid(e.params().get(1), "surface curve 3D curve")?, depth + 1); + } + "TRIMMED_CURVE" => return self.edge_geometry(rid(e.params().get(1), "trimmed curve basis")?, depth + 1), + _ => {} + } + } + let c = self.reader.curve(id)?; + let mut c: &Curve = &c; + // Already read as part of something else: unwrap the same way. + for _ in 0..=MAX_NESTING { + match c { + Curve::SurfaceCurve { curve_3d, .. } => c = curve_3d, + Curve::Trimmed { basis, .. } => c = basis, + _ => return Ok(c.clone()), + } + } + Err("edge geometry nested too deeply".into()) + } + + /// An ORIENTED_EDGE (or a bare EDGE_CURVE) as a loop uses it: its schema edge and whether + /// the loop runs along it. + fn oriented_edge(&mut self, id: u64, depth: usize) -> R<(u32, bool)> { + if depth > MAX_NESTING { + return Err("oriented edges nested too deeply".into()); + } + let e = entity(self.ex, id)?; + if e.name() == "ORIENTED_EDGE" { + let p = e.params(); + let (edge, along) = self.oriented_edge(rid(p.get(3), "oriented edge element")?, depth + 1)?; + let orientation = flag(p.get(4), "oriented edge orientation")?; + return Ok((edge, along == orientation)); + } + let (edge, with_step) = self.edge(id)?; + Ok((edge, with_step)) + } + + fn ends(&self, (edge, forward): (u32, bool)) -> R<[u32; 2]> { + let e = self.edges.get(edge as usize).ok_or("edge reference")?; + let [a, z] = e.vertices; + Ok(if forward { [a, z] } else { [z, a] }) + } + + /// A face bound: its loop as the face uses it (bound orientation applied), and whether it + /// is the FACE_OUTER_BOUND. + fn bound(&mut self, id: u64, edges_so_far: &mut usize) -> R<(LoopB, bool)> { + let b = entity(self.ex, id)?; + let outer = match b.name() { + "FACE_OUTER_BOUND" => true, + "FACE_BOUND" => false, + n => return Err(format!("#{id}: {}", unsupported(format!("face bound {n}")))), + }; + let p = b.params(); + let lp_id = rid(p.get(1), "face bound loop")?; + let orientation = flag(p.get(2), "face bound orientation")?; + let lp = entity(self.ex, lp_id)?; + match lp.name() { + "EDGE_LOOP" => { + let ids = refs(lp.params().get(1), "edge loop")?; + *edges_so_far = edges_so_far.saturating_add(ids.len()); + if ids.is_empty() || *edges_so_far > MAX_LOOP { + return Err(format!("edge loop #{lp_id} with {} edges", ids.len())); + } + let mut uses = Vec::with_capacity(ids.len()); + for oe in ids { + uses.push(self.oriented_edge(oe, 0)?); + } + // Connected and closed, as the loop is written. + let mut last = None; + for u in &uses { + let [s, e] = self.ends(*u)?; + if last.is_some_and(|l| l != s) { + return Err(format!("edge loop #{lp_id} is not connected")); + } + last = Some(e); + } + if let (Some(first), Some(last)) = (uses.first(), last) + && self.ends(*first)?[0] != last + { + return Err(format!("edge loop #{lp_id} is not closed")); + } + if !orientation { + reverse(&mut uses); + } + Ok((LoopB::Edges(uses), outer)) + } + "VERTEX_LOOP" => Ok((LoopB::Vertex(self.vertex(rid(lp.params().get(1), "vertex loop")?)?), outer)), + n => Err(format!("#{lp_id}: {}", unsupported(format!("loop {n}")))), + } + } + + /// A face as a shell uses it: its index and whether its normal there is its surface's + /// natural normal. + fn face(&mut self, id: u64, depth: usize) -> R<(u32, bool)> { + if depth > MAX_NESTING { + return Err("oriented faces nested too deeply".into()); + } + let e = entity(self.ex, id)?; + let p = e.params(); + if e.name() == "ORIENTED_FACE" { + let (f, forward) = self.face(rid(p.get(2), "oriented face element")?, depth + 1)?; + return Ok((f, forward == flag(p.get(3), "oriented face orientation")?)); + } + if !matches!(e.name(), "ADVANCED_FACE" | "FACE_SURFACE") { + return Err(format!("#{id}: {}", unsupported(format!("face {}", e.name())))); + } + if !self.face_ids.insert(id) { + return Err(format!("face #{id} is used twice")); + } + *self.budget = self.budget.checked_sub(1).ok_or_else(|| format!("more than {MAX_FILE_FACES} faces in one file"))?; + if self.faces.len() >= MAX_FACES { + return Err(format!("more than {MAX_FACES} faces")); + } + let surface = self.reader.surface(rid(p.get(2), "face geometry")?).map_err(|m| format!("face #{id}: {m}"))?; + let surface = map_surface(&surface).map_err(|m| format!("face #{id}: {m}"))?; + let same_sense = flag(p.get(3), "face same_sense")?; + let bounds = refs(p.get(1), "face bounds")?; + if bounds.len() > MAX_BOUNDS { + return Err(format!("face #{id} with {} bounds", bounds.len())); + } + let mut loops = Vec::with_capacity(bounds.len()); + let mut outer = None; + let mut edges = 0; + for b in bounds { + let (mut l, is_outer) = self.bound(b, &mut edges).map_err(|m| format!("face #{id}: {m}"))?; + if !same_sense && let LoopB::Edges(uses) = &mut l { + reverse(uses); + } + if is_outer && outer.is_none() { + outer = Some(loops.len()); + } + loops.push(l); + } + if !loops.iter().any(|l| matches!(l, LoopB::Edges(_))) { + return Err(format!("face #{id}: {}", unsupported("a face without an edge loop"))); + } + let i = index(self.faces.len())?; + self.faces.push(FaceB { step: id, surface, loops, outer, forward: same_sense }); + Ok((i, same_sense)) + } + + fn shell(&mut self, id: u64) -> R> { + let e = entity(self.ex, id)?; + let p = e.params(); + let (faces, flip) = match e.name() { + "CLOSED_SHELL" | "OPEN_SHELL" => (refs(p.get(1), "shell faces")?, false), + "ORIENTED_CLOSED_SHELL" | "ORIENTED_OPEN_SHELL" => { + let inner = rid(p.get(2), "oriented shell element")?; + let inner_e = entity(self.ex, inner)?; + if !matches!(inner_e.name(), "CLOSED_SHELL" | "OPEN_SHELL") { + return Err(format!("#{inner}: {}", unsupported(format!("shell {}", inner_e.name())))); + } + (refs(inner_e.params().get(1), "shell faces")?, !flag(p.get(3), "oriented shell orientation")?) + } + n => return Err(format!("#{id}: {}", unsupported(format!("shell {n}")))), + }; + if faces.is_empty() { + return Err(format!("shell #{id} has no faces")); + } + if faces.len() > MAX_FACES { + return Err(format!("shell #{id} with {} faces", faces.len())); + } + let mut out = Vec::with_capacity(faces.len()); + for f in faces { + let (face, forward) = self.face(f, 0)?; + out.push((face, forward != flip)); + } + Ok(out) + } + + /// Pcurves, outer loops, domains and the canonical order: the finished record. + fn finish(mut self, item: u64, name: String, shells: Vec>, kind: Kind) -> R { + let mut notes = Vec::new(); + if self.sense_mismatches > 0 { + notes.push(format!("{} edges run against their same_sense (read by their vertices)", self.sense_mismatches)); + } + // Each face's pcurves; a face whose loops disagree with its same_sense is turned over. + let mut pcs: Vec>> = Vec::with_capacity(self.faces.len()); + let mut domains = Vec::with_capacity(self.faces.len()); + let mut turned = vec![false; self.faces.len()]; + let (mut failed, mut first_failure, mut loosened) = (0usize, None, 0usize); + for fi in 0..self.faces.len() { + match self.face_pcurves(fi) { + Ok((pc, domain, flipped, tol)) => { + if flipped && let Some(t) = turned.get_mut(fi) { + *t = true; + } + if tol > self.tol { + loosened += 1; + } + pcs.push(pc); + domains.push(domain); + } + Err(m) => { + failed += 1; + first_failure.get_or_insert_with(|| format!("face #{}: {m}", self.faces.get(fi).map_or(0, |f| f.step))); + pcs.push(Vec::new()); + domains.push([[0.0; 2]; 2]); + } + } + } + if let Some(first) = first_failure { + return Err(format!("pcurves failed on {failed} of {} faces; first: {first}", self.faces.len())); + } + let flipped = turned.iter().filter(|t| **t).count(); + if flipped > 0 { + notes.push(format!("{flipped} faces turned over: their loops disagree with their same_sense")); + } + if loosened > 0 { + notes.push(format!("{loosened} faces have their pcurves fitted looser than {} mm", self.tol)); + } + let shells: Vec> = shells + .into_iter() + .map(|s| s.into_iter().map(|(f, fwd)| (f, if turned.get(f as usize).copied().unwrap_or(false) { !fwd } else { fwd })).collect()) + .collect(); + + // Regions: which shells are closed (every edge used at least twice among its faces). + let closed: Vec = shells.iter().map(|s| self.closed(s)).collect(); + let mut regions = vec![Region { shells: Vec::new(), void: false }]; + match kind { + Kind::Solid | Kind::Voids => { + if let Some(k) = closed.iter().position(|c| !c) { + return Err(format!("{} shell is not closed", if k == 0 { "the outer" } else { "a void" })); + } + regions[0].shells.push(0); + regions.push(Region { shells: (0..index(shells.len())?).collect(), void: false }); + for v in 1..shells.len() { + regions.push(Region { shells: vec![index(v)?], void: true }); + } + } + Kind::Model => { + regions[0].shells = (0..index(shells.len())?).collect(); + for (k, c) in closed.iter().enumerate() { + if *c { + regions.push(Region { shells: vec![index(k)?], void: false }); + } + } + } + } + let sheet = !closed.iter().any(|c| *c); + + // Canonical order: faces walking regions → shells → oriented faces. + let nf = self.faces.len(); + let mut face_new = vec![u32::MAX; nf]; + let mut face_order = Vec::with_capacity(nf); + let mut direction = vec![true; nf]; + let mut seen_shell = vec![false; shells.len()]; + for r in ®ions { + for &s in &r.shells { + let Some(seen) = seen_shell.get_mut(s as usize) else { continue }; + if std::mem::replace(seen, true) { + continue; + } + for &(f, fwd) in shells.get(s as usize).into_iter().flatten() { + let slot = face_new.get_mut(f as usize).ok_or("face reference")?; + if *slot == u32::MAX { + *slot = index(face_order.len())?; + face_order.push(f); + if let Some(d) = direction.get_mut(f as usize) { + *d = fwd; + } + } + } + } + } + // Edges walking faces → loops (outer first) → oriented edges, backwards in a face whose + // first side is reversed; their uses in that order. + let outer_first: Vec> = (0..nf).map(|f| self.loop_order(f)).collect(); + let mut edge_new = vec![u32::MAX; self.edges.len()]; + let mut edge_order = Vec::with_capacity(self.edges.len()); + let mut uses: Vec> = vec![Vec::new(); self.edges.len()]; + for (fnew, &f) in face_order.iter().enumerate() { + let face = self.faces.get(f as usize).ok_or("face reference")?; + let forward = direction.get(f as usize).copied().unwrap_or(true); + for (lnew, &l) in outer_first.get(f as usize).into_iter().flatten().enumerate() { + let Some(LoopB::Edges(es)) = face.loops.get(l) else { continue }; + let n = es.len(); + for k in 0..n { + let p = if forward { k } else { n - 1 - k }; + let &(e, fwd) = es.get(p).ok_or("edge reference")?; + let slot = edge_new.get_mut(e as usize).ok_or("edge reference")?; + if *slot == u32::MAX { + *slot = index(edge_order.len())?; + edge_order.push(e); + } + let at = EdgeUseRef { face: index(fnew)?, r#loop: index(lnew)?, position: index(p)?, upper: !fwd }; + uses.get_mut(e as usize).ok_or("edge reference")?.push(at); + } + } + } + // Vertices walking edges (start, end), then vertex loops. + let mut vertex_new = vec![u32::MAX; self.vertices.len()]; + let mut vertex_order = Vec::with_capacity(self.vertices.len()); + let mut visit = |v: u32, vertex_new: &mut Vec| -> R { + let slot = vertex_new.get_mut(v as usize).ok_or("vertex reference")?; + if *slot == u32::MAX { + *slot = index(vertex_order.len())?; + vertex_order.push(v); + } + Ok(*slot) + }; + for &e in &edge_order { + let edge = self.edges.get(e as usize).ok_or("edge reference")?; + for v in edge.vertices { + visit(v, &mut vertex_new)?; + } + } + for &f in &face_order { + for &l in outer_first.get(f as usize).into_iter().flatten() { + if let Some(LoopB::Vertex(v)) = self.faces.get(f as usize).and_then(|x| x.loops.get(l)) { + visit(*v, &mut vertex_new)?; + } + } + } + let vmap = |v: u32| vertex_new.get(v as usize).copied().filter(|x| *x != u32::MAX).ok_or("vertex reference".to_string()); + let emap = |e: u32| edge_new.get(e as usize).copied().filter(|x| *x != u32::MAX).ok_or("edge reference".to_string()); + let fmap = |f: u32| face_new.get(f as usize).copied().filter(|x| *x != u32::MAX).ok_or("face reference".to_string()); + + // Vertex loops: their vertex's distance from the surface is its tolerance. + for &f in &face_order { + let Some(face) = self.faces.get(f as usize) else { continue }; + let surface = SurfaceEval::new(&face.surface).map_err(|m| m.to_string())?; + for l in &face.loops { + if let LoopB::Vertex(v) = l + && let Some(x) = self.vertices.get_mut(*v as usize) + { + let p = v3(x.point); + let gap = surface.project(p, None, f64::INFINITY).and_then(|q| surface.point(q)).map_or(f64::INFINITY, |s| s.dist(p)); + if !(gap <= MAX_VERTEX_GAP) { + return Err(format!("face #{}: a vertex loop's vertex lies {gap} mm off its surface", face.step)); + } + x.tolerance = x.tolerance.max(1.5 * gap); + } + } + } + + let mut vertices = Vec::with_capacity(vertex_order.len()); + for &v in &vertex_order { + vertices.push(self.vertices.get(v as usize).cloned().ok_or("vertex reference")?); + } + let mut edges = Vec::with_capacity(edge_order.len()); + for &e in &edge_order { + let edge = self.edges.get(e as usize).ok_or("edge reference")?; + edges.push(Edge { + curve: edge.curve.clone(), + interval: edge.interval, + vertices: [vmap(edge.vertices[0])?, vmap(edge.vertices[1])?], + tolerance: edge.tolerance, + uses: uses.get(e as usize).cloned().unwrap_or_default(), + }); + } + let mut faces = Vec::with_capacity(face_order.len()); + for &f in &face_order { + let face = self.faces.get(f as usize).ok_or("face reference")?; + let pc = pcs.get(f as usize).ok_or("face reference")?; + let mut loops = Vec::with_capacity(face.loops.len()); + let mut edge_loop = 0; + // pcurves come in the face's edge-loop order; loops go out outer first. + let mut fitted: Vec>> = Vec::with_capacity(face.loops.len()); + for l in &face.loops { + fitted.push(match l { + LoopB::Edges(_) => { + edge_loop += 1; + pc.get(edge_loop - 1) + } + LoopB::Vertex(_) => None, + }); + } + for &l in outer_first.get(f as usize).into_iter().flatten() { + match face.loops.get(l) { + Some(LoopB::Edges(es)) => { + let curves = fitted.get(l).copied().flatten().ok_or("pcurves")?; + let mut out = Vec::with_capacity(es.len()); + for (&(e, forward), c) in es.iter().zip(curves) { + out.push(OrientedEdge { edge: emap(e)?, forward, pcurve: c.clone() }); + } + loops.push(Loop::Edges { edges: out }); + } + Some(LoopB::Vertex(v)) => loops.push(Loop::Vertex { vertex: vmap(*v)? }), + None => return Err("loop reference".into()), + } + } + let domain = *domains.get(f as usize).ok_or("face reference")?; + faces.push(Face { surface: face.surface.clone(), domain, loops }); + } + let mut out_shells = Vec::with_capacity(shells.len()); + for s in &shells { + let mut faces = Vec::with_capacity(s.len()); + for &(f, forward) in s { + faces.push(OrientedFace { face: fmap(f)?, forward }); + } + out_shells.push(Shell::Faces { faces, wires: Vec::new() }); + } + let brep = Brep { version: brep::VERSION, tolerance: self.tol, regions, shells: out_shells, faces, edges, vertices }; + brep.validate().map_err(|e| format!("the record is invalid: {e}"))?; + brep.check_geometry().map_err(|e| format!("the record's geometry disagrees: {e}"))?; + Ok(Solid { item, name, product: None, brep, sheet, notes }) + } + + /// Every edge of the shell's faces used at least twice among them. + fn closed(&self, shell: &[(u32, bool)]) -> bool { + let mut count: HashMap = HashMap::new(); + for &(f, _) in shell { + for l in self.faces.get(f as usize).map(|f| f.loops.as_slice()).unwrap_or(&[]) { + if let LoopB::Edges(es) = l { + for (e, _) in es { + *count.entry(*e).or_default() += 1; + } + } + } + } + count.values().all(|n| *n >= 2) + } + + /// The face's loops, outer first (the rest in the file's order). + fn loop_order(&self, f: usize) -> Vec { + let Some(face) = self.faces.get(f) else { return Vec::new() }; + let outer = face.outer.unwrap_or(0); + std::iter::once(outer).chain((0..face.loops.len()).filter(|l| *l != outer)).collect() + } + + /// The pcurves of a face's edge loops (in its edge-loop order), its domain, whether it was + /// turned over, and the tolerance they were fitted at. Picks the outer loop when the file + /// marks none (or marks one that another loop encloses), and turns the face over when its + /// outer loop runs clockwise about the natural normal on a surface where that cannot be a + /// face's outer boundary. + fn face_pcurves(&mut self, fi: usize) -> R<(Vec>, [[f64; 2]; 2], bool, f64)> { + let face = self.faces.get(fi).ok_or("face reference")?; + let surface = face.surface.clone(); + let edge_loops: Vec> = face + .loops + .iter() + .filter_map(|l| match l { + LoopB::Edges(es) => Some(es.clone()), + LoopB::Vertex(_) => None, + }) + .collect(); + // Which loop index in `face.loops` each edge loop is. + let positions: Vec = face.loops.iter().enumerate().filter(|(_, l)| matches!(l, LoopB::Edges(_))).map(|(i, _)| i).collect(); + let mut tol = self.tol; + let pcs = loop { + let mut uses = Vec::with_capacity(edge_loops.len()); + for l in &edge_loops { + let mut u = Vec::with_capacity(l.len()); + for &(e, forward) in l { + let edge = self.edges.get(e as usize).ok_or("edge reference")?; + u.push(EdgeUse { edge: e, curve: &edge.curve, interval: edge.interval, forward }); + } + uses.push(u); + } + match pcurve::pcurves(&surface, &uses, tol) { + Ok(p) => break p, + Err(pcurve::Error::Failed(m)) if !m.contains("not supported") && tol * 10.0 <= MAX_FIT_TOLERANCE * (1.0 + 1e-9) => { + tol *= 10.0; + } + Err(e) => return Err(e.to_string()), + } + }; + if tol > self.tol { + for l in &edge_loops { + for &(e, _) in l { + if let Some(edge) = self.edges.get_mut(e as usize) { + edge.tolerance = edge.tolerance.max(tol); + } + } + } + } + // Signed areas of the loops that close in (u, v). + let periods = natural_periods(&surface); + let shapes: Vec> = edge_loops.iter().zip(&pcs).map(|(l, pc)| loop_area(l, pc, periods)).collect(); + let face = self.faces.get_mut(fi).ok_or("face reference")?; + let marked = face.outer.and_then(|o| positions.iter().position(|p| *p == o)); + let largest = shapes.iter().enumerate().filter_map(|(i, a)| a.map(|a| (i, a.abs()))).max_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i); + let outer = match (marked, largest) { + // A marked outer loop that closes in (u, v) but another encloses more: that one. + (Some(m), Some(l)) if shapes.get(m).copied().flatten().is_some() => l, + (Some(m), _) => m, + (None, Some(l)) if shapes.iter().all(Option::is_some) => l, + (None, _) => 0, + }; + face.outer = positions.get(outer).copied(); + let can_turn = matches!( + surface, + BSurface::Plane { .. } | BSurface::Cylinder { .. } | BSurface::Cone { .. } | BSurface::Spline { .. } | BSurface::RationalSpline { .. } + ); + let flip = can_turn && shapes.get(outer).copied().flatten().is_some_and(|a| a < 0.0); + if flip { + for l in &mut face.loops { + if let LoopB::Edges(es) = l { + reverse(es); + } + } + face.forward = !face.forward; + } + let pcs: Vec> = if flip { pcs.into_iter().map(|l| l.into_iter().rev().collect()).collect() } else { pcs }; + let domain = face_domain(&surface, &pcs)?; + Ok((pcs, domain, flip, tol)) + } +} + +/// The loop run the other way. +fn reverse(uses: &mut [(u32, bool)]) { + uses.reverse(); + for u in uses { + u.1 = !u.1; + } +} + +fn natural_periods(s: &BSurface) -> [bool; 2] { + s.natural().map(|n| matches!(n, Natural::Periodic)) +} + +/// The signed area a loop encloses in (u, v) (positive counter-clockwise), or `None` when it +/// does not close there (it winds around a periodic direction) or is degenerate. +fn loop_area(l: &[(u32, bool)], pcs: &[Curve2], periodic: [bool; 2]) -> Option { + let mut pts: Vec<[f64; 2]> = Vec::new(); + for (&(_, forward), c) in l.iter().zip(pcs) { + let [a, b] = c.interval()?; + for k in 0..=16 { + let s = f64::from(k) / 16.0; + let t = if forward { a + (b - a) * s } else { b - (b - a) * s }; + pts.push(c.eval(t)?); + } + } + let (first, last) = (*pts.first()?, *pts.last()?); + for d in 0..2 { + if periodic.get(d).copied().unwrap_or(false) && (first.get(d)? - last.get(d)?).abs() > 1.0 { + return None; + } + } + let mut area = 0.0; + let mut magnitude = 0.0; + for w in pts.windows(2) { + let (p, q) = (w.first()?, w.get(1)?); + let c = (p[0] - first[0]) * (q[1] - first[1]) - (q[0] - first[0]) * (p[1] - first[1]); + area += c; + magnitude += c.abs(); + } + (area.abs() > 1e-9 * magnitude && magnitude > 0.0).then_some(0.5 * area) +} + +/// The parameter box around every pcurve's control points, padded a little, within the +/// surface's natural domain (at most one turn where periodic). +fn face_domain(surface: &BSurface, pcs: &[Vec]) -> R<[[f64; 2]; 2]> { + let mut lo = [f64::INFINITY; 2]; + let mut hi = [f64::NEG_INFINITY; 2]; + for c in pcs.iter().flatten() { + let pts: Vec<[f64; 2]> = match c { + Curve2::Spline { points, .. } => points.clone(), + Curve2::RationalSpline { homogeneous, .. } => homogeneous.iter().filter(|h| h[2] > 0.0).map(|h| [h[0] / h[2], h[1] / h[2]]).collect(), + }; + for p in pts { + for d in 0..2 { + lo[d] = lo[d].min(p[d]); + hi[d] = hi[d].max(p[d]); + } + } + } + let natural = surface.natural(); + let mut out = [[0.0; 2]; 2]; + for d in 0..2 { + let (a, b) = (lo[d], hi[d]); + if !(a.is_finite() && b.is_finite() && a <= b) { + return Err("a face without pcurves".into()); + } + let pad = 1e-6 * (1.0 + (b - a)) + 1e-9 * a.abs().max(b.abs()); + let (mut x, mut y) = (a - pad, b + pad); + match natural.get(d) { + Some(Natural::Periodic) => { + if y - x > TAU { + x = a; + y = a + TAU; + } + } + Some(Natural::Range(n0, n1)) => { + x = x.max(*n0); + y = y.min(*n1); + } + None => {} + } + if !(x < y) { + return Err("an empty face domain".into()); + } + if let Some(slot) = out.get_mut(d) { + *slot = [x, y]; + } + } + Ok(out) +} + +fn frame(p: &Placement) -> Frame { + Frame { origin: p.origin, x_axis: p.ref_direction, y_axis: p.y() } +} + +/// Knots repeated by their multiplicities, for a spline of `count` points and `degree`. +fn full_knots(knots: &[f64], mults: &[usize], count: usize, degree: usize) -> R> { + if !(1..=MAX_SPLINE_DEGREE).contains(°ree) { + return Err(unsupported(format!("a B-spline of degree {degree}"))); + } + if !(2..=MAX_SPLINE_POINTS).contains(&count) { + return Err(unsupported(format!("a B-spline with {count} control points"))); + } + if knots.len() != mults.len() { + return Err("B-spline knots and multiplicities differ in number".into()); + } + let total = mults.iter().try_fold(0usize, |a, m| a.checked_add(*m)).ok_or("B-spline knot count")?; + if total != count + degree + 1 { + return Err(format!("B-spline with {total} knots for {count} points of degree {degree}")); + } + let mut v = Vec::with_capacity(total); + for (k, m) in knots.iter().zip(mults) { + v.extend(std::iter::repeat_n(*k, *m)); + } + Ok(v) +} + +fn map_bspline_curve(b: &BSplineCurve) -> R { + let knots = full_knots(&b.knots, &b.multiplicities, b.points.len(), b.degree)?; + Ok(match &b.weights { + None => BCurve::Spline { knots, points: b.points.clone() }, + Some(w) => { + if w.len() != b.points.len() { + return Err("B-spline weights and points differ in number".into()); + } + BCurve::RationalSpline { knots, homogeneous: b.points.iter().zip(w).map(|(p, w)| [p[0] * w, p[1] * w, p[2] * w, *w]).collect() } + } + }) +} + +fn map_bspline_surface(b: &BSplineSurface) -> R { + let nu = b.points.len(); + let nv = b.points.first().map_or(0, Vec::len); + let u_knots = full_knots(&b.u_knots, &b.u_multiplicities, nu, b.u_degree)?; + let v_knots = full_knots(&b.v_knots, &b.v_multiplicities, nv, b.v_degree)?; + if b.points.iter().any(|r| r.len() != nv) { + return Err("ragged B-spline surface".into()); + } + Ok(match &b.weights { + None => BSurface::Spline { u_knots, v_knots, points: b.points.clone() }, + Some(w) => { + if w.len() != nu || w.iter().any(|r| r.len() != nv) { + return Err("B-spline surface weights and points differ in number".into()); + } + let homogeneous = + b.points.iter().zip(w).map(|(row, wr)| row.iter().zip(wr).map(|(p, w)| [p[0] * w, p[1] * w, p[2] * w, *w]).collect()).collect(); + BSurface::RationalSpline { u_knots, v_knots, homogeneous } + } + }) +} + +/// A face's surface as a schema surface. +fn map_surface(s: &Surface) -> R { + map_surface_in(s, 0) +} + +fn map_surface_in(s: &Surface, depth: usize) -> R { + if depth > MAX_NESTING { + return Err("surfaces nested too deeply".into()); + } + Ok(match s { + Surface::Plane { position } => BSurface::Plane { origin: position.origin, u_axis: position.ref_direction, v_axis: position.y() }, + Surface::Cylinder { position, radius } => BSurface::Cylinder { frame: frame(position), radius: *radius }, + Surface::Cone { position, radius, semi_angle } => { + if !(*radius >= 0.0) || !(semi_angle.abs() > 1e-12 && semi_angle.abs() < std::f64::consts::FRAC_PI_2) { + return Err(unsupported(format!("a cone of radius {radius} and semi-angle {semi_angle}"))); + } + BSurface::Cone { frame: frame(position), radius_at_origin: *radius, half_angle: *semi_angle } + } + Surface::Sphere { position, radius } => BSurface::Sphere { frame: frame(position), radius: *radius }, + Surface::Torus { position, major_radius, minor_radius } => { + if !(major_radius > minor_radius) { + return Err(unsupported("a spindle or horn torus")); + } + BSurface::Torus { frame: frame(position), major_radius: *major_radius, minor_radius: *minor_radius } + } + Surface::DegenerateTorus { .. } => return Err(unsupported("DEGENERATE_TOROIDAL_SURFACE")), + Surface::BSpline(b) => map_bspline_surface(b)?, + Surface::RectangularTrimmed { basis, .. } => map_surface_in(basis, depth + 1)?, + Surface::Revolution { .. } => return Err(unsupported("SURFACE_OF_REVOLUTION")), + Surface::Extrusion { .. } => return Err(unsupported("SURFACE_OF_LINEAR_EXTRUSION")), + Surface::Offset { .. } => return Err(unsupported("OFFSET_SURFACE")), + }) +} + +/// An edge on its schema curve. +#[derive(Debug)] +struct Mapped { + curve: BCurve, + interval: [f64; 2], + /// The schema edge (interval min → max) runs from the STEP edge's start to its end. + with_step: bool, + /// The file's same_sense disagrees with the vertices' order on an open curve. + sense_mismatch: bool, +} + +fn angle_of(a: f64, b: f64) -> f64 { + let t = b.atan2(a); + if t < 0.0 { t + TAU } else { t } +} + +/// `x` reduced to [0, 2π). +fn turn(x: f64) -> f64 { + let r = x.rem_euclid(TAU); + if r >= TAU { 0.0 } else { r } +} + +/// The edge from `ps` to `pe` on `curve`: its schema curve and interval (min → max) and which +/// way it runs. `closed`: the same vertex at both ends; `near`: the ends coincide within the +/// tolerance (a full turn on a periodic curve). +fn map_edge(curve: &Curve, ps: P3, pe: P3, same_sense: bool, closed: bool, near: bool, tol: f64) -> R { + match curve { + Curve::Line { origin, direction, magnitude } => { + if closed { + return Err("a closed edge on a line".into()); + } + let m = *magnitude; + let param = |p: P3| dot(sub(p, *origin), *direction) / m; + let (ts, te) = (param(ps), param(pe)); + if !((te - ts).abs() * m > 1e-12 * (1.0 + ts.abs().max(te.abs()) * m)) { + return Err("a degenerate edge on a line".into()); + } + let with_step = ts < te; + let (lo, hi) = if with_step { (ts, te) } else { (te, ts) }; + let start = crate::geom::add(*origin, scale(*direction, lo * m)); + let end = crate::geom::add(start, scale(*direction, m)); + Ok(Mapped { curve: BCurve::Line { start, end }, interval: [0.0, hi - lo], with_step, sense_mismatch: with_step != same_sense }) + } + Curve::Circle { position, radius } => { + conic(BCurve::Circle { frame: frame(position), radius: *radius }, position, [*radius; 2], ps, pe, same_sense, near) + } + Curve::Ellipse { position, semi_axis_1, semi_axis_2 } => { + let radii = [*semi_axis_1, *semi_axis_2]; + conic(BCurve::Ellipse { frame: frame(position), radii }, position, radii, ps, pe, same_sense, near) + } + Curve::BSpline(b) => open_or_closed(map_bspline_curve(b)?, ps, pe, same_sense, closed, tol), + Curve::Polyline(points) => { + if points.windows(2).any(|w| w.first() == w.get(1)) { + return Err("a polyline with a repeated point".into()); + } + if !(2..=MAX_SPLINE_POINTS * 16).contains(&points.len()) { + return Err(unsupported(format!("a polyline of {} points", points.len()))); + } + open_or_closed(BCurve::Polyline { points: points.clone() }, ps, pe, same_sense, closed, tol) + } + Curve::Hyperbola { .. } => Err(unsupported("HYPERBOLA")), + Curve::Parabola { .. } => Err(unsupported("PARABOLA")), + Curve::OffsetCurve3d { .. } => Err(unsupported("OFFSET_CURVE_3D")), + Curve::Trimmed { .. } | Curve::SurfaceCurve { .. } => Err(unsupported("nested trimmed or surface curves")), + } +} + +/// A circle or ellipse: the vertices' angles, the arc between them in the edge's sense (a full +/// turn when they coincide). +fn conic(curve: BCurve, position: &Placement, radii: [f64; 2], ps: P3, pe: P3, same_sense: bool, near: bool) -> R { + let y = position.y(); + let param = |p: P3| { + let q = sub(p, position.origin); + angle_of(dot(q, position.ref_direction) / radii[0], dot(q, y) / radii[1]) + }; + let (ts, te) = (param(ps), param(pe)); + // Along the curve from `from` to `to`, increasing. + let span = |from: f64, to: f64| if near { TAU } else { turn(to - from) }; + let (lo, d) = if same_sense { (ts, span(ts, te)) } else { (te, span(te, ts)) }; + if !(d > 0.0) { + return Err("a degenerate edge on a conic".into()); + } + Ok(Mapped { curve, interval: [lo, lo + d], with_step: same_sense, sense_mismatch: false }) +} + +/// A spline or polyline: the vertices' parameters by projection. On an open curve the edge runs +/// the way its vertices go; on a closed one (its ends coincide) the edge's sense decides, and a +/// vertex at the seam takes whichever end the sense needs. +fn open_or_closed(curve: BCurve, ps: P3, pe: P3, same_sense: bool, closed: bool, tol: f64) -> R { + let eval = CurveEval::new(&curve).map_err(|e| e.to_string())?; + let Natural::Range(a, b) = curve.natural() else { return Err("a periodic spline".into()) }; + if !(a < b) { + return Err("an empty spline domain".into()); + } + let (pa, pb) = (eval.point(a).ok_or("spline start")?, eval.point(b).ok_or("spline end")?); + let curve_closed = pa.dist(pb) <= tol.max(1e-9 * (1.0 + pa.len())); + if closed { + if !curve_closed { + return Err("a closed edge on an open curve".into()); + } + return Ok(Mapped { curve, interval: [a, b], with_step: true, sense_mismatch: false }); + } + let knots = match &curve { + BCurve::Spline { knots, .. } | BCurve::RationalSpline { knots, .. } => knots.clone(), + BCurve::Polyline { points } => (0..points.len()).map(|i| i as f64).collect(), + _ => Vec::new(), + }; + let ts = project(&eval, a, b, &knots, v3(ps)).ok_or("no parameter for the edge's start")?; + let te = project(&eval, a, b, &knots, v3(pe)).ok_or("no parameter for the edge's end")?; + let eps = 1e-9 * (b - a); + if !curve_closed { + if !((te - ts).abs() > eps) { + return Err("a degenerate edge on a spline".into()); + } + let with_step = ts < te; + let interval = if with_step { [ts, te] } else { [te, ts] }; + return Ok(Mapped { curve, interval, with_step, sense_mismatch: with_step != same_sense }); + } + let (mut from, mut to) = if same_sense { (ts, te) } else { (te, ts) }; + let at_seam = |t: f64| t - a <= eps || b - t <= eps; + if !(from + eps < to) { + if at_seam(from) && b - from <= eps { + from = a; + } + if at_seam(to) && to - a <= eps { + to = b; + } + } + if !(from + eps < to) { + return Err(unsupported("an edge across a closed spline's seam")); + } + Ok(Mapped { curve, interval: [from, to], with_step: same_sense, sense_mismatch: false }) +} + +/// The parameter in [a, b] of the curve's point nearest `p`: the nearest of samples along each +/// knot span, refined by golden-section search between its neighbours. +fn project(c: &CurveEval, a: f64, b: f64, knots: &[f64], p: Vec3) -> Option { + let mut breaks: Vec = knots.iter().copied().filter(|k| *k > a && *k < b).collect(); + breaks.dedup(); + let spans = breaks.len() + 1; + let per = (MAX_SAMPLES / spans).clamp(1, 8); + let mut ts = Vec::with_capacity(spans * per + 1); + let mut lo = a; + for hi in breaks.into_iter().chain(std::iter::once(b)) { + for k in 0..per { + ts.push(lo + (hi - lo) * k as f64 / per as f64); + } + lo = hi; + } + ts.push(b); + if ts.len() > MAX_SAMPLES { + let stride = ts.len().div_ceil(MAX_SAMPLES); + let last = *ts.last()?; + ts = ts.into_iter().step_by(stride).collect(); + ts.push(last); + } + let dist = |t: f64| c.point(t).map_or(f64::INFINITY, |q| q.dist(p)); + let (best, _) = ts.iter().enumerate().map(|(i, t)| (i, dist(*t))).min_by(|x, y| x.1.total_cmp(&y.1))?; + let mut x0 = *ts.get(best.saturating_sub(1))?; + let mut x1 = *ts.get((best + 1).min(ts.len() - 1))?; + let g = 0.5 * (5f64.sqrt() - 1.0); + let (mut c1, mut c2) = (x1 - g * (x1 - x0), x0 + g * (x1 - x0)); + let (mut f1, mut f2) = (dist(c1), dist(c2)); + for _ in 0..200 { + if x1 - x0 <= 1e-15 * (1.0 + x0.abs().max(x1.abs())) { + break; + } + if f1 < f2 { + x1 = c2; + c2 = c1; + f2 = f1; + c1 = x1 - g * (x1 - x0); + f1 = dist(c1); + } else { + x0 = c1; + c1 = c2; + f1 = f2; + c2 = x0 + g * (x1 - x0); + f2 = dist(c2); + } + } + let t = 0.5 * (x0 + x1); + // The ends themselves when they are as near (a vertex at the end of the curve). + let t = [a, b, t].into_iter().min_by(|x, y| dist(*x).total_cmp(&dist(*y)))?; + let near_end = |e: f64| (dist(e) - dist(t)).abs() <= 1e-12 * (1.0 + p.len()); + Some( + if near_end(a) { + a + } else if near_end(b) { + b + } else { + t + } + .clamp(a, b), + ) +} + +#[cfg(test)] +#[path = "topology_tests.rs"] +mod tests; diff --git a/crates/step/src/topology_tests.rs b/crates/step/src/topology_tests.rs new file mode 100644 index 0000000000..2cc2ca0286 --- /dev/null +++ b/crates/step/src/topology_tests.rs @@ -0,0 +1,723 @@ +//! STEP topology into schema 4: small solids written here entity by entity (boxes, a cylinder +//! with its seam, a plate with a through hole, sheets, a void, faces and edges written against +//! their geometry's sense), each read, validated, spot-checked and its faces' sides checked +//! against the material; hostile files are errors, never panics. Expected values are analytic. + +use std::fmt::Write as _; + +use super::*; +use crate::p21; + +type V = [f64; 3]; + +fn real(x: f64) -> String { + format!("{x:.17E}") +} +fn sub3(a: V, b: V) -> V { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn len3(a: V) -> f64 { + (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt() +} +fn neg(a: V) -> V { + [-a[0], -a[1], -a[2]] +} + +/// Writes a STEP data section one entity at a time. +#[derive(Default)] +struct W { + s: String, + n: u64, +} + +impl W { + fn add(&mut self, body: impl AsRef) -> u64 { + self.n += 1; + let _ = writeln!(self.s, "#{}={};", self.n, body.as_ref()); + self.n + } + fn pt(&mut self, p: V) -> u64 { + self.add(format!("CARTESIAN_POINT('',({},{},{}))", real(p[0]), real(p[1]), real(p[2]))) + } + fn dir(&mut self, d: V) -> u64 { + self.add(format!("DIRECTION('',({},{},{}))", real(d[0]), real(d[1]), real(d[2]))) + } + fn axis(&mut self, o: V, z: V, x: V) -> u64 { + let (o, z, x) = (self.pt(o), self.dir(z), self.dir(x)); + self.add(format!("AXIS2_PLACEMENT_3D('',#{o},#{z},#{x})")) + } + fn vertex(&mut self, p: V) -> u64 { + let p = self.pt(p); + self.add(format!("VERTEX_POINT('',#{p})")) + } + /// A line edge from `a` to `b`; with `same_sense` false its LINE runs from b to a. + fn line_edge(&mut self, (va, pa): (u64, V), (vb, pb): (u64, V), same_sense: bool, magnitude: f64) -> u64 { + let (o, d) = if same_sense { (pa, sub3(pb, pa)) } else { (pb, sub3(pa, pb)) }; + let (o, d) = (self.pt(o), self.dir(d)); + let v = self.add(format!("VECTOR('',#{d},{})", real(magnitude))); + let l = self.add(format!("LINE('',#{o},#{v})")); + self.add(format!("EDGE_CURVE('',#{va},#{vb},#{l},{})", b(same_sense))) + } + fn circle(&mut self, c: V, z: V, x: V, r: f64) -> u64 { + let a = self.axis(c, z, x); + self.add(format!("CIRCLE('',#{a},{})", real(r))) + } + fn oe(&mut self, edge: u64, orientation: bool) -> u64 { + self.add(format!("ORIENTED_EDGE('',*,*,#{edge},{})", b(orientation))) + } + fn bound(&mut self, oes: &[u64], outer: bool, orientation: bool) -> u64 { + let l = self.add(format!("EDGE_LOOP('',({}))", list(oes))); + self.add(format!("{}('',#{l},{})", if outer { "FACE_OUTER_BOUND" } else { "FACE_BOUND" }, b(orientation))) + } + fn plane(&mut self, o: V, z: V, x: V) -> u64 { + let a = self.axis(o, z, x); + self.add(format!("PLANE('',#{a})")) + } + fn face(&mut self, bounds: &[u64], surface: u64, same_sense: bool) -> u64 { + self.add(format!("ADVANCED_FACE('',({}),#{surface},{})", list(bounds), b(same_sense))) + } + /// The file: millimetres, radians, an uncertainty of 1e-7 mm, every item in one + /// representation. + fn file(mut self, items: &[u64]) -> p21::Exchange { + let l = self.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))"); + let a = self.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))"); + let s = self.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())"); + let u = self.add(format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#{l},'distance_accuracy_value','')")); + let c = self.add(format!( + "(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{u}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{l},#{a},#{s}))REPRESENTATION_CONTEXT('',''))" + )); + self.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({}),#{c})", list(items))); + parse(&self.s) + } +} + +fn parse(data: &str) -> p21::Exchange { + let t = format!("ISO-10303-21;\nHEADER;FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n{data}ENDSEC;\nEND-ISO-10303-21;\n"); + match p21::parse(&t) { + Ok(ex) => ex, + Err(e) => panic!("{e}\n{t}"), + } +} + +fn b(x: bool) -> &'static str { + if x { ".T." } else { ".F." } +} +fn list(ids: &[u64]) -> String { + ids.iter().map(|i| format!("#{i}")).collect::>().join(",") +} + +/// How a box is written. +#[derive(Clone, Copy, Default)] +struct BoxStyle { + /// Every other face on a plane facing into the box, with same_sense .F. + flip_faces: bool, + /// Every other edge with same_sense .F. (its line running from its end to its start). + flip_edges: bool, + /// Every face's bound written the other way round with orientation .F. + flip_bounds: bool, + /// One face's same_sense written wrong (its plane facing in, same_sense .T.). + wrong_face: bool, + /// One edge's same_sense written wrong (its line running from start to end, same_sense .F.). + wrong_edge: bool, +} + +/// The box from `lo` to `hi`: its faces (outward normals) and its top and bottom faces' +/// extra inner bounds' places (written by `inner`, given the face's axis sign). +fn box_faces(w: &mut W, lo: V, hi: V, style: BoxStyle, inner: &mut dyn FnMut(&mut W, f64) -> Vec) -> Vec { + let corner = |i: usize| [if i & 1 == 0 { lo[0] } else { hi[0] }, if i & 2 == 0 { lo[1] } else { hi[1] }, if i & 4 == 0 { lo[2] } else { hi[2] }]; + let vs: Vec<(u64, V)> = (0..8).map(|i| (w.vertex(corner(i)), corner(i))).collect(); + let mut edges: std::collections::HashMap<(usize, usize), u64> = Default::default(); + let mut faces = Vec::new(); + let mut count = 0; + for a in 0..3 { + for side in [false, true] { + let (bx, cx) = ((a + 1) % 3, (a + 2) % 3); + let bit = |axis: usize| 1usize << axis; + let base = if side { bit(a) } else { 0 }; + let mut ring = [base, base | bit(bx), base | bit(bx) | bit(cx), base | bit(cx)]; + if !side { + ring.reverse(); + } + let mut oes = Vec::new(); + for k in 0..4 { + let (p, q) = (ring[k], ring[(k + 1) % 4]); + let key = (p.min(q), p.max(q)); + let e = match edges.get(&key) { + Some(e) => *e, + None => { + let n = edges.len(); + let flip = style.flip_edges && n % 2 == 1; + let e = if style.wrong_edge && n == 3 { + // Its line runs from start to end, but it says it runs against it. + let (va, pa) = vs[key.0]; + let (vb, pb) = vs[key.1]; + let (o, d) = (w.pt(pa), w.dir(sub3(pb, pa))); + let v = w.add(format!("VECTOR('',#{d},1.)")); + let l = w.add(format!("LINE('',#{o},#{v})")); + w.add(format!("EDGE_CURVE('',#{va},#{vb},#{l},.F.)")) + } else { + w.line_edge(vs[key.0], vs[key.1], !flip, if n.is_multiple_of(3) { 1.0 } else { 2.5 }) + }; + edges.insert(key, e); + e + } + }; + oes.push(w.oe(e, p == key.0)); + } + let mut normal = [0.0; 3]; + normal[a] = if side { 1.0 } else { -1.0 }; + let mut x = [0.0; 3]; + x[bx] = 1.0; + let center = { + let mut c = [0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1]), 0.5 * (lo[2] + hi[2])]; + c[a] = if side { hi[a] } else { lo[a] }; + c + }; + let mut bounds = if style.flip_bounds { + let rev: Vec = oes + .iter() + .rev() + .map(|&oe| { + // The same edges, the other way round. + let line = w.s.lines().find(|l| l.starts_with(&format!("#{oe}="))).map(str::to_string).unwrap_or_default(); + let flipped = if line.ends_with(".T.);") { line.replace(".T.);", ".F.);") } else { line.replace(".F.);", ".T.);") }; + let body = flipped.split_once('=').map(|(_, b)| b.trim_end_matches(';').to_string()).unwrap_or_default(); + w.add(body) + }) + .collect(); + vec![w.bound(&rev, true, false)] + } else { + vec![w.bound(&oes, true, true)] + }; + if a == 2 { + bounds.extend(inner(w, normal[2])); + } + let flip = style.flip_faces && count % 2 == 1; + let wrong = style.wrong_face && count == 4; + let plane = w.plane(center, if flip || wrong { neg(normal) } else { normal }, x); + faces.push(w.face(&bounds, plane, !flip)); + count += 1; + } + } + faces +} + +fn closed_shell(w: &mut W, faces: &[u64]) -> u64 { + w.add(format!("CLOSED_SHELL('',({}))", list(faces))) +} + +fn one_solid(ex: &p21::Exchange) -> Solid { + let r = read_solids(ex); + assert!(r.failures.is_empty(), "{:?}", r.failures); + assert_eq!(r.solids.len(), 1); + let s = r.solids.into_iter().next().unwrap(); + s.brep.validate().unwrap(); + s.brep.check_geometry().unwrap(); + s +} + +fn counts(b: &Brep) -> [usize; 5] { + [b.regions.len(), b.shells.len(), b.faces.len(), b.edges.len(), b.vertices.len()] +} + +/// Each oriented face of each shell: a point inside it and its normal there in that shell. +fn sides(b: &Brep) -> Vec<(usize, V, V)> { + let mut out = Vec::new(); + for (si, s) in b.shells.iter().enumerate() { + let Shell::Faces { faces, .. } = s else { continue }; + for of in faces { + let f = &b.faces[of.face as usize]; + let eval = SurfaceEval::new(&f.surface).unwrap(); + // Inside the outer loop: its first use's middle, nudged to its left. + let Loop::Edges { edges } = &f.loops[0] else { panic!("outer vertex loop") }; + let u = &edges[0]; + let [t0, t1] = u.pcurve.interval().unwrap(); + let m = 0.5 * (t0 + t1); + let (a, c) = (u.pcurve.eval(m).unwrap(), u.pcurve.eval(m + 1e-4 * (t1 - t0)).unwrap()); + let along = if u.forward { [c[0] - a[0], c[1] - a[1]] } else { [a[0] - c[0], a[1] - c[1]] }; + let n = (along[0].hypot(along[1])).max(1e-300); + let left = [-along[1] / n, along[0] / n]; + let uv = [a[0] + 1e-3 * left[0], a[1] + 1e-3 * left[1]]; + let (p, su, sv) = eval.derivs(uv[0], uv[1]).unwrap(); + let mut nn = su.cross(sv); + if !of.forward { + nn = nn * -1.0; + } + out.push((si, [p.x, p.y, p.z], [nn.x, nn.y, nn.z])); + } + } + out +} + +/// Every face of shell `shell` faces away from `inside(p)`. +#[track_caller] +fn faces_away(b: &Brep, shell: usize, inside: impl Fn(V) -> V) { + for (s, p, n) in sides(b) { + if s == shell { + let i = inside(p); + let d = sub3(p, i); + assert!(d[0] * n[0] + d[1] * n[1] + d[2] * n[2] > 0.0, "shell {shell}: face at {p:?} has normal {n:?}"); + } + } +} + +fn cube(style: BoxStyle) -> p21::Exchange { + let mut w = W::default(); + let faces = box_faces(&mut w, [0.0; 3], [10.0, 20.0, 30.0], style, &mut |_, _| Vec::new()); + let shell = closed_shell(&mut w, &faces); + let solid = w.add(format!("MANIFOLD_SOLID_BREP('cube',#{shell})")); + w.file(&[solid]) +} + +#[test] +fn a_cube_is_two_regions_six_faces_twelve_edges_eight_vertices() { + for style in [ + BoxStyle::default(), + BoxStyle { flip_faces: true, ..Default::default() }, + BoxStyle { flip_edges: true, ..Default::default() }, + BoxStyle { flip_bounds: true, ..Default::default() }, + BoxStyle { flip_faces: true, flip_edges: true, flip_bounds: true, ..Default::default() }, + ] { + let s = one_solid(&cube(style)); + assert_eq!(s.name, "cube"); + assert!(!s.sheet); + assert!(s.notes.is_empty(), "{:?}", s.notes); + assert_eq!(counts(&s.brep), [2, 1, 6, 12, 8]); + assert_eq!(s.brep.regions[0].shells, [0]); + assert_eq!(s.brep.regions[1].shells, [0]); + assert!((s.brep.tolerance - 1e-7).abs() < 1e-20); + // Same_sense .F. faces are the reversed sides of their planes. + let reversed = match &s.brep.shells[0] { + Shell::Faces { faces, .. } => faces.iter().filter(|f| !f.forward).count(), + _ => panic!(), + }; + assert_eq!(reversed, if style.flip_faces { 3 } else { 0 }); + faces_away(&s.brep, 0, |_| [5.0, 10.0, 15.0]); + // Every edge is a line on [0, length / magnitude], used twice, once each way. + for e in &s.brep.edges { + assert_eq!(e.uses.len(), 2); + let BCurve::Line { start, end } = &e.curve else { panic!("{:?}", e.curve) }; + let [a, z] = e.vertices.map(|v| s.brep.vertices[v as usize].point); + assert!(len3(sub3(*start, a)) < 1e-12, "{start:?} {a:?}"); + let m = len3(sub3(*end, *start)); + assert!((e.interval[1] * m - len3(sub3(z, a))).abs() < 1e-9 && e.interval[0] == 0.0); + // Once each way about the faces' normals in the shell. + let Shell::Faces { faces, .. } = &s.brep.shells[0] else { panic!() }; + let side = |u: &EdgeUseRef| u.upper == faces.iter().find(|f| f.face == u.face).unwrap().forward; + assert_ne!(side(&e.uses[0]), side(&e.uses[1])); + } + } +} + +#[test] +fn faces_and_edges_written_against_their_sense_are_read_by_their_loops_and_vertices() { + let s = one_solid(&cube(BoxStyle { wrong_face: true, wrong_edge: true, ..Default::default() })); + assert_eq!(counts(&s.brep), [2, 1, 6, 12, 8]); + assert!(s.notes.iter().any(|n| n.contains("1 faces turned over")), "{:?}", s.notes); + assert!(s.notes.iter().any(|n| n.contains("1 edges run against")), "{:?}", s.notes); + faces_away(&s.brep, 0, |_| [5.0, 10.0, 15.0]); +} + +/// The cylinder of radius `r` from z = z0 to z1 about (cx, cy): its vertices on the seam at +/// angle 0, its circles (counter-clockwise about +z) and its seam line (upwards). +struct Cyl { + circles: [u64; 2], + seam: u64, + surface: u64, +} + +fn cylinder(w: &mut W, c: [f64; 2], r: f64, z: [f64; 2]) -> Cyl { + let p = |h: f64| [c[0] + r, c[1], h]; + let (v0, v1) = (w.vertex(p(z[0])), w.vertex(p(z[1]))); + let circle = |w: &mut W, v: u64, h: f64| { + let k = w.circle([c[0], c[1], h], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], r); + w.add(format!("EDGE_CURVE('',#{v},#{v},#{k},.T.)")) + }; + let circles = [circle(w, v0, z[0]), circle(w, v1, z[1])]; + let seam = w.line_edge((v0, p(z[0])), (v1, p(z[1])), true, 1.0); + let a = w.axis([c[0], c[1], 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]); + let surface = w.add(format!("CYLINDRICAL_SURFACE('',#{a},{})", real(r))); + Cyl { circles, seam, surface } +} + +/// The cylinder's side as a face whose normal points out of the cylinder (`outward`) or into it. +fn side(w: &mut W, c: &Cyl, outward: bool) -> u64 { + let [b, t] = c.circles; + // Counter-clockwise in (angle, height): about the outward normal. + let ring = [(b, true), (c.seam, true), (t, false), (c.seam, false)]; + let oes: Vec = + if outward { ring.iter().map(|&(e, o)| w.oe(e, o)).collect() } else { ring.iter().rev().map(|&(e, o)| w.oe(e, !o)).collect() }; + let bound = w.bound(&oes, true, true); + w.face(&[bound], c.surface, outward) +} + +#[test] +fn a_cylinder_has_a_seam_used_twice_by_its_side() { + let mut w = W::default(); + let c = cylinder(&mut w, [1.0, 2.0], 5.0, [0.0, 10.0]); + let side_face = side(&mut w, &c, true); + let ob = w.oe(c.circles[0], false); + let bottom_bound = w.bound(&[ob], true, true); + let bottom_plane = w.plane([1.0, 2.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]); + let bottom = w.face(&[bottom_bound], bottom_plane, false); + let ot = w.oe(c.circles[1], true); + let top_bound = w.bound(&[ot], true, true); + let top_plane = w.plane([1.0, 2.0, 10.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]); + let top = w.face(&[top_bound], top_plane, true); + let shell = closed_shell(&mut w, &[bottom, side_face, top]); + let solid = w.add(format!("MANIFOLD_SOLID_BREP('',#{shell})")); + let s = one_solid(&w.file(&[solid])); + assert_eq!(counts(&s.brep), [2, 1, 3, 3, 2]); + let seam = s.brep.edges.iter().find(|e| matches!(e.curve, BCurve::Line { .. })).unwrap(); + assert_eq!(seam.uses.len(), 2); + assert_eq!(seam.uses[0].face, seam.uses[1].face); + for e in &s.brep.edges { + if matches!(e.curve, BCurve::Circle { .. }) { + assert!((e.interval[1] - e.interval[0] - TAU).abs() < 1e-12); + assert_eq!(e.vertices[0], e.vertices[1]); + } + } + faces_away(&s.brep, 0, |p| [1.0, 2.0, p[2].clamp(1.0, 9.0)]); + // The side face spans one turn of the cylinder and its height. + let f = s.brep.faces.iter().find(|f| matches!(f.surface, BSurface::Cylinder { .. })).unwrap(); + assert!((f.domain[0][1] - f.domain[0][0] - TAU).abs() < 1e-9 && f.domain[1][0] < 1e-6 && f.domain[1][1] > 10.0 - 1e-6, "{:?}", f.domain); +} + +/// A 20 × 20 × 5 plate with a hole of radius 3 through it; with `marked`, its square bounds are +/// FACE_OUTER_BOUNDs, otherwise every bound is a FACE_BOUND and the hole is listed first. +fn plate(marked: bool) -> p21::Exchange { + let mut w = W::default(); + let c = cylinder(&mut w, [10.0, 10.0], 3.0, [0.0, 5.0]); + let mut inner = |w: &mut W, up: f64| { + // About the face's outward normal the hole runs clockwise. + let oe = if up > 0.0 { w.oe(c.circles[1], false) } else { w.oe(c.circles[0], true) }; + vec![w.bound(&[oe], false, true)] + }; + let mut faces = box_faces(&mut w, [0.0; 3], [20.0, 20.0, 5.0], BoxStyle::default(), &mut inner); + if !marked { + let text = w.s.replace("FACE_OUTER_BOUND", "FACE_BOUND"); + w.s = text; + // The hole first. + let mut lines: Vec = w.s.lines().map(str::to_string).collect(); + for l in &mut lines { + if l.contains("ADVANCED_FACE") && l.matches('#').count() == 4 { + let (head, rest) = l.split_once("',(").unwrap(); + let (bounds, tail) = rest.split_once(')').unwrap(); + let parts: Vec<&str> = bounds.split(',').rev().collect(); + *l = format!("{head}',({}){tail}", parts.join(",")); + } + } + w.s = lines.join("\n") + "\n"; + } + faces.push(side(&mut w, &c, false)); + let shell = closed_shell(&mut w, &faces); + let solid = w.add(format!("MANIFOLD_SOLID_BREP('plate',#{shell})")); + w.file(&[solid]) +} + +#[test] +fn a_plate_with_a_through_hole() { + for marked in [true, false] { + let s = one_solid(&plate(marked)); + assert_eq!(counts(&s.brep), [2, 1, 7, 15, 10], "marked {marked}"); + let holed: Vec<&Face> = s.brep.faces.iter().filter(|f| f.loops.len() == 2).collect(); + assert_eq!(holed.len(), 2); + for f in holed { + let Loop::Edges { edges } = &f.loops[0] else { panic!() }; + assert_eq!(edges.len(), 4, "the square is the outer loop"); + } + faces_away(&s.brep, 0, |p| { + let r = ((p[0] - 10.0).powi(2) + (p[1] - 10.0).powi(2)).sqrt(); + if (r - 3.0).abs() < 1e-6 { + // On the hole: material lies away from the axis. + [10.0 + (p[0] - 10.0) * 2.0, 10.0 + (p[1] - 10.0) * 2.0, p[2]] + } else { + [p[0].clamp(0.5, 19.5), p[1].clamp(0.5, 19.5), 2.5] + } + }); + } +} + +#[test] +fn a_surface_model_of_open_shells_is_a_sheet() { + let mut w = W::default(); + let mut faces = box_faces(&mut w, [0.0; 3], [1.0, 1.0, 1.0], BoxStyle::default(), &mut |_, _| Vec::new()); + faces.pop(); + let open = w.add(format!("OPEN_SHELL('',({}))", list(&faces))); + let single = box_faces(&mut w, [5.0; 3], [6.0; 3], BoxStyle::default(), &mut |_, _| Vec::new()); + let one = w.add(format!("OPEN_SHELL('',(#{}))", single[0])); + let model = w.add(format!("SHELL_BASED_SURFACE_MODEL('sheet',(#{open},#{one}))")); + let s = one_solid(&w.file(&[model])); + assert!(s.sheet); + assert_eq!(counts(&s.brep), [1, 2, 6, 12 + 4, 8 + 4]); + assert_eq!(s.brep.regions[0].shells, [0, 1]); + let once = s.brep.edges.iter().filter(|e| e.uses.len() == 1).count(); + assert_eq!(once, 4 + 4); + + // A closed shell in a surface model encloses a region of its own. + let mut w = W::default(); + let faces = box_faces(&mut w, [0.0; 3], [1.0; 3], BoxStyle::default(), &mut |_, _| Vec::new()); + let shell = closed_shell(&mut w, &faces); + let model = w.add(format!("SHELL_BASED_SURFACE_MODEL('',(#{shell}))")); + let s = one_solid(&w.file(&[model])); + assert!(!s.sheet); + assert_eq!(counts(&s.brep), [2, 1, 6, 12, 8]); +} + +#[test] +fn a_solid_with_a_void() { + let mut w = W::default(); + let outer = box_faces(&mut w, [0.0; 3], [10.0; 3], BoxStyle::default(), &mut |_, _| Vec::new()); + let outer = closed_shell(&mut w, &outer); + let inner = box_faces(&mut w, [4.0; 3], [6.0; 3], BoxStyle { flip_faces: true, ..Default::default() }, &mut |_, _| Vec::new()); + let inner = closed_shell(&mut w, &inner); + let void = w.add(format!("ORIENTED_CLOSED_SHELL('',*,#{inner},.F.)")); + let solid = w.add(format!("BREP_WITH_VOIDS('',#{outer},(#{void}))")); + let s = one_solid(&w.file(&[solid])); + assert_eq!(counts(&s.brep), [3, 2, 12, 24, 16]); + assert_eq!(s.brep.regions[0], Region { shells: vec![0], void: false }); + assert_eq!(s.brep.regions[1], Region { shells: vec![0, 1], void: false }); + assert_eq!(s.brep.regions[2], Region { shells: vec![1], void: true }); + faces_away(&s.brep, 0, |_| [5.0; 3]); + // The void's faces face into the cavity, out of the material around it. + for (shell, p, n) in sides(&s.brep) { + if shell == 1 { + let d = sub3([5.0; 3], p); + assert!(d[0] * n[0] + d[1] * n[1] + d[2] * n[2] > 0.0, "{p:?} {n:?}"); + } + } +} + +/// A unit square sheet on z = 0 whose edge from (1, 0) to (1, 1) is a B-spline (quadratic, its +/// middle point off centre so its parameter is not proportional to length), written along or +/// against its curve. +fn spline_sheet(same_sense: bool, closed_basis: bool) -> p21::Exchange { + let mut w = W::default(); + let ps = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]; + let vs: Vec<(u64, V)> = ps.iter().map(|p| (w.vertex(*p), *p)).collect(); + let e0 = w.line_edge(vs[0], vs[1], true, 1.0); + let e1 = if closed_basis { + // A closed polygon spline with its seam halfway up x = 1: the edge from (1, 0) to + // (1, 1) along it crosses the seam. + let pts: Vec = + [[1.0, 0.5, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.5, 0.0]].iter().map(|p| w.pt(*p)).collect(); + let c = w + .add(format!("B_SPLINE_CURVE_WITH_KNOTS('',1,({}),.POLYLINE_FORM.,.T.,.F.,(2,1,1,1,1,2),(0.,1.,2.,3.,4.,5.),.UNSPECIFIED.)", list(&pts))); + w.add(format!("EDGE_CURVE('',#{},#{},#{c},.T.)", vs[1].0, vs[2].0)) + } else { + let order: [V; 3] = + if same_sense { [[1.0, 0.0, 0.0], [1.0, 0.2, 0.0], [1.0, 1.0, 0.0]] } else { [[1.0, 1.0, 0.0], [1.0, 0.8, 0.0], [1.0, 0.0, 0.0]] }; + let pts: Vec = order.iter().map(|p| w.pt(*p)).collect(); + let c = w.add(format!("B_SPLINE_CURVE_WITH_KNOTS('',2,({}),.UNSPECIFIED.,.F.,.F.,(3,3),(2.,5.),.UNSPECIFIED.)", list(&pts))); + let sc = w.add(format!("SURFACE_CURVE('',#{c},(),.CURVE_3D.)")); + w.add(format!("EDGE_CURVE('',#{},#{},#{sc},{})", vs[1].0, vs[2].0, b(same_sense))) + }; + let e2 = w.line_edge(vs[2], vs[3], false, 1.0); + let e3 = w.line_edge(vs[3], vs[0], true, 1.0); + let oes: Vec = [e0, e1, e2, e3].iter().map(|e| w.oe(*e, true)).collect(); + let bound = w.bound(&oes, true, true); + let plane = w.plane([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]); + let face = w.face(&[bound], plane, true); + let shell = w.add(format!("OPEN_SHELL('',(#{face}))")); + let model = w.add(format!("SHELL_BASED_SURFACE_MODEL('',(#{shell}))")); + w.file(&[model]) +} + +#[test] +fn spline_edges_get_their_vertices_parameters() { + for same_sense in [true, false] { + let s = one_solid(&spline_sheet(same_sense, false)); + assert_eq!(counts(&s.brep), [1, 1, 1, 4, 4]); + let e = s.brep.edges.iter().find(|e| matches!(e.curve, BCurve::Spline { .. })).unwrap(); + assert_eq!(e.interval, [2.0, 5.0]); + let BCurve::Spline { knots, .. } = &e.curve else { panic!() }; + assert_eq!(knots, &[2.0, 2.0, 2.0, 5.0, 5.0, 5.0]); + let [a, z] = e.vertices.map(|v| s.brep.vertices[v as usize].point); + assert_eq!((a, z), if same_sense { ([1.0, 0.0, 0.0], [1.0, 1.0, 0.0]) } else { ([1.0, 1.0, 0.0], [1.0, 0.0, 0.0]) }); + } + // An edge across a closed spline's seam is refused, for now. + let r = read_solids(&spline_sheet(true, true)); + assert_eq!(r.solids.len(), 0); + assert!(r.failures[0].message.contains("across a closed spline's seam"), "{:?}", r.failures); +} + +#[test] +fn vertex_parameters_on_lines_conics_and_splines() { + let tol = 1e-7; + // A line written with magnitude 2: the record's line spans one magnitude, the interval is + // in its parameter. + let line = Curve::Line { origin: [1.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0], magnitude: 2.0 }; + let m = map_edge(&line, [1.0, 7.0, 0.0], [1.0, 3.0, 0.0], true, false, false, tol).unwrap(); + assert_eq!(m.interval, [0.0, 2.0]); + assert!(!m.with_step && m.sense_mismatch); + assert_eq!(m.curve, BCurve::Line { start: [1.0, 3.0, 0.0], end: [1.0, 5.0, 0.0] }); + assert!(map_edge(&line, [1.0, 7.0, 0.0], [1.0, 7.0, 0.0], true, true, true, tol).is_err()); + // A circle, either way round, and a full turn. + let position = Placement { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], ref_direction: [1.0, 0.0, 0.0] }; + let circle = Curve::Circle { position, radius: 2.0 }; + let (s, e) = ([0.0, 2.0, 0.0], [-2.0, 0.0, 0.0]); + let m = map_edge(&circle, s, e, true, false, false, tol).unwrap(); + assert!((m.interval[0] - FRAC_PI_2).abs() < 1e-15 && (m.interval[1] - PI).abs() < 1e-15 && m.with_step); + let m = map_edge(&circle, s, e, false, false, false, tol).unwrap(); + assert!((m.interval[0] - PI).abs() < 1e-15 && (m.interval[1] - (TAU + FRAC_PI_2)).abs() < 1e-14 && !m.with_step, "{:?}", m.interval); + let m = map_edge(&circle, e, e, false, true, true, tol).unwrap(); + assert!((m.interval[1] - m.interval[0] - TAU).abs() < 1e-15); + // An ellipse's parameter is not the polar angle. + let ellipse = Curve::Ellipse { position, semi_axis_1: 4.0, semi_axis_2: 1.0 }; + let t: f64 = 0.7; + let p = [4.0 * t.cos(), t.sin(), 0.0]; + let m = map_edge(&ellipse, [4.0, 0.0, 0.0], p, true, false, false, tol).unwrap(); + assert!(m.interval[0].abs() < 1e-15 && (m.interval[1] - t).abs() < 1e-14, "{:?}", m.interval); + // Unsupported curves. + let hyperbola = Curve::Hyperbola { position, semi_axis: 1.0, semi_imag_axis: 1.0 }; + assert!(map_edge(&hyperbola, s, e, true, false, false, tol).unwrap_err().contains("not supported yet: HYPERBOLA")); +} + +use std::f64::consts::{FRAC_PI_2, PI}; + +/// The file's solids: `cube` with `edit` applied to its text. +fn edited(edit: impl Fn(&str) -> String) -> Solids { + let mut w = W::default(); + let faces = box_faces(&mut w, [0.0; 3], [1.0; 3], BoxStyle::default(), &mut |_, _| Vec::new()); + let shell = closed_shell(&mut w, &faces); + let solid = w.add(format!("MANIFOLD_SOLID_BREP('',#{shell})")); + let mut extra = W { s: String::new(), n: w.n }; + extra.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',(#{solid}),#{})", w.n + 100)); + let text = edit(&(w.s + &extra.s)); + read_solids(&parse(&text)) +} + +#[test] +fn hostile_topology_is_an_error_never_a_panic() { + // Sanity: unedited, the cube reads. + assert_eq!(edited(str::to_string).solids.len(), 1); + let cases: Vec<(&str, Box String>)> = vec![ + ("missing shell", Box::new(|t: &str| t.replace("MANIFOLD_SOLID_BREP('',#", "MANIFOLD_SOLID_BREP('',#9999"))), + ("missing face", Box::new(|t: &str| t.replacen("CLOSED_SHELL('',(#", "CLOSED_SHELL('',(#9999,#", 1))), + ( + "a face twice", + Box::new(|t: &str| { + let (head, rest) = t.split_once("CLOSED_SHELL('',(#").unwrap(); + let first: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + format!("{head}CLOSED_SHELL('',(#{first},#{rest}") + }), + ), + ( + "a face missing", + Box::new(|t: &str| { + let (head, rest) = t.split_once("CLOSED_SHELL('',(#").unwrap(); + let (_, rest) = rest.split_once(',').unwrap(); + format!("{head}CLOSED_SHELL('',({rest}") + }), + ), + ( + "a cycle of oriented faces", + Box::new(|t: &str| format!("{t}#9000=ORIENTED_FACE('',*,#9000,.T.);").replacen("CLOSED_SHELL('',(#", "CLOSED_SHELL('',(#9000,#", 1)), + ), + ( + "a cycle of oriented edges", + Box::new(|t: &str| format!("{t}#9000=ORIENTED_EDGE('',*,*,#9000,.T.);").replacen("EDGE_LOOP('',(#", "EDGE_LOOP('',(#9000,#", 1)), + ), + ( + "an open loop", + Box::new(|t: &str| { + let (head, rest) = t.split_once("EDGE_LOOP('',(#").unwrap(); + let (_, rest) = rest.split_once(',').unwrap(); + format!("{head}EDGE_LOOP('',({rest}") + }), + ), + ("an unsupported vertex", Box::new(|t: &str| t.replacen("=VERTEX_POINT(", "=VERTEX_X(", 1))), + ("an unsupported edge", Box::new(|t: &str| t.replacen("=EDGE_CURVE(", "=SUBEDGE(", 1))), + ( + "a vertex that is a direction", + Box::new(|t: &str| { + let (head, rest) = t.split_once("VERTEX_POINT('',#").unwrap(); + let (_, rest) = rest.split_once(')').unwrap(); + let d: String = t.split_once("=DIRECTION").unwrap().0.rsplit('#').next().unwrap().to_string(); + format!("{head}VERTEX_POINT('',#{d}){rest}") + }), + ), + ("a face on a hyperbola's plane", Box::new(|t: &str| t.replacen("PLANE('',", "HYPERBOLA('',", 1))), + ( + "a huge loop", + Box::new(|t: &str| { + let (head, rest) = t.split_once("EDGE_LOOP('',(#").unwrap(); + let first: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + let many = format!("#{first},").repeat(MAX_LOOP + 1); + format!("{head}EDGE_LOOP('',({many}#{rest}") + }), + ), + ( + "a degenerate torus", + Box::new(|t: &str| { + let (head, rest) = t.split_once("PLANE('',#").unwrap(); + let a: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + let (_, rest) = rest.split_once(')').unwrap(); + format!("{head}DEGENERATE_TOROIDAL_SURFACE('',#{a},1.,2.,.T.){rest}") + }), + ), + ( + "a non-boolean same_sense", + Box::new(|t: &str| { + let (head, rest) = t.split_once("ADVANCED_FACE(").unwrap(); + format!("{head}ADVANCED_FACE({}", rest.replacen(",.T.);", ",.U.);", 1)) + }), + ), + ("an unsupported loop", Box::new(|t: &str| t.replacen("EDGE_LOOP(", "POLY_LOOP(", 1))), + ("an unsupported solid", Box::new(|t: &str| t.replace("MANIFOLD_SOLID_BREP", "FACETED_BREP"))), + ]; + for (what, edit) in cases { + let r = edited(edit); + assert!(r.solids.is_empty() && r.failures.len() == 1, "{what}: {:?} {}", r.failures, r.solids.len()); + eprintln!("{what}: {}", r.failures[0].message); + } +} + +/// Every solid of every STEP file in `SOLIDCRAFT_STEP_CORPUS` (the tangency-labs/models files) +/// read into schema 4: solids read and refused (by reason), faces, edges, time. Run with +/// `--ignored --nocapture`. +#[test] +#[ignore = "needs SOLIDCRAFT_STEP_CORPUS"] +fn corpus_solids_read_into_schema_4() { + let Ok(dir) = std::env::var("SOLIDCRAFT_STEP_CORPUS") else { return }; + let mut paths: Vec<_> = std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).collect(); + paths.sort(); + for path in paths.iter().filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("step") || e.eq_ignore_ascii_case("stp"))) { + let text = std::fs::read_to_string(path).unwrap_or_default(); + let t0 = std::time::Instant::now(); + let ex = p21::parse(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let parsed = t0.elapsed(); + let r = read_solids(&ex); + let read = t0.elapsed() - parsed; + let faces: usize = r.solids.iter().map(|s| s.brep.faces.len()).sum(); + let edges: usize = r.solids.iter().map(|s| s.brep.edges.len()).sum(); + let sheets = r.solids.iter().filter(|s| s.sheet).count(); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + println!( + "{name}: {} solids read ({sheets} sheets), {} refused; {faces} faces, {edges} edges; parse {:.0} ms, read {:.0} ms", + r.solids.len(), + r.failures.len(), + parsed.as_secs_f64() * 1e3, + read.as_secs_f64() * 1e3 + ); + let mut reasons: std::collections::BTreeMap = Default::default(); + for f in &r.failures { + // Group by the message without entity numbers. + let key: String = f.message.split('#').map(|s| s.trim_start_matches(|c: char| c.is_ascii_digit())).collect::>().join("#"); + *reasons.entry(key).or_default() += 1; + } + for (m, n) in &reasons { + println!(" {n} × {m}"); + } + let mut notes: std::collections::BTreeMap = Default::default(); + for s in &r.solids { + for n in &s.notes { + let key: String = n.chars().skip_while(|c| c.is_ascii_digit()).collect(); + *notes.entry(key).or_default() += 1; + } + } + for (m, n) in ¬es { + println!(" note in {n} solids: …{m}"); + } + } +} diff --git a/crates/step/src/units.rs b/crates/step/src/units.rs index be83d09eb5..55caf4603c 100644 --- a/crates/step/src/units.rs +++ b/crates/step/src/units.rs @@ -7,7 +7,7 @@ use crate::p21::{Exchange, Param}; use crate::read::Units; /// What a representation context says about its numbers. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Context { pub units: Units, /// The context's distance uncertainty in millimetres (the last UNCERTAINTY_MEASURE_WITH_UNIT @@ -15,12 +15,6 @@ pub struct Context { pub uncertainty: Option, } -impl Default for Context { - fn default() -> Self { - Context { units: Units::default(), uncertainty: None } - } -} - fn si_prefix(p: Option<&Param>) -> f64 { match p.and_then(Param::as_enum) { Some("EXA") => 1e18,