From f40da1fca6e8a3d4e06171f75445c027bbcaeb34 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Mon, 26 Oct 2026 14:11:22 -0400 Subject: [PATCH] topology: B-rep writer, part-file reader and the corpus parity harness (S1) - topology::brep_data_write / geometry_write: write B-reps and geometry to the SMLib database format; topology::part_file: read part files. - difftest units topology.part_file and corpus.parity (whole upstream TestFiles corpus, Rust vs C++), with their shims and FFI, plus the corpus example. - Fixes found by the corpus in line, ellipse, plane, offset-surface, B-spline and surface code. - WR-449: one sphere_diff fillet face integrates 0.6 too much area upstream; its area and volume are exempted in corpus.parity, cause under investigation. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PJzQKZ9sjU1DhgS253e8J5 --- crates/difftest/src/main.rs | 11 + crates/difftest/src/units/corpus_parity.rs | 745 ++++++++ .../difftest/src/units/topology_part_file.rs | 202 ++ .../solidcore-ffi/src/units/corpus_parity.rs | 102 + .../src/units/topology_part_file.rs | 92 + crates/solidcore/examples/corpus/main.rs | 1222 ++++++++++++ crates/solidcore/src/curve/ellipse/p1.rs | 148 +- crates/solidcore/src/curve/line.rs | 30 +- .../solidcore/src/nurbs/b_spline_curve/p2.rs | 88 +- .../src/nurbs/b_spline_surface/p1.rs | 65 +- crates/solidcore/src/query/face/p1.rs | 5 +- .../solidcore/src/surface/offset_surface.rs | 55 +- crates/solidcore/src/surface/plane/p2.rs | 35 +- crates/solidcore/src/surface/surface/p1.rs | 98 +- crates/solidcore/src/surface/surface/p2.rs | 202 +- crates/solidcore/src/surface/surface/p8.rs | 7 +- crates/solidcore/src/topology.rs | 3 + .../solidcore/src/topology/brep_data_write.rs | 1668 +++++++++++++++++ crates/solidcore/src/topology/database_io.rs | 275 ++- .../solidcore/src/topology/geometry_write.rs | 323 ++++ crates/solidcore/src/topology/part_file.rs | 928 +++++++++ harness/cpp/shim/corpus_parity.cpp | 213 +++ harness/cpp/shim/topology_part_file.cpp | 110 ++ ...olume-drift-is-upstreams-own-2026-10-23.md | 21 + ...brep-writer-outside-the-plan-2026-10-23.md | 18 + ...ne-fillet-face-integrates-0-6-too-large.md | 22 + 26 files changed, 6560 insertions(+), 128 deletions(-) create mode 100644 crates/difftest/src/units/corpus_parity.rs create mode 100644 crates/difftest/src/units/topology_part_file.rs create mode 100644 crates/solidcore-ffi/src/units/corpus_parity.rs create mode 100644 crates/solidcore-ffi/src/units/topology_part_file.rs create mode 100644 crates/solidcore/examples/corpus/main.rs create mode 100644 crates/solidcore/src/topology/brep_data_write.rs create mode 100644 crates/solidcore/src/topology/geometry_write.rs create mode 100644 crates/solidcore/src/topology/part_file.rs create mode 100644 harness/cpp/shim/corpus_parity.cpp create mode 100644 harness/cpp/shim/topology_part_file.cpp create mode 100644 plan/findings/corpus.parity--ascii-round-trip-volume-drift-is-upstreams-own-2026-10-23.md create mode 100644 plan/findings/topology.part_file--part-reader-and-brep-writer-outside-the-plan-2026-10-23.md create mode 100644 plan/upstream-issues/WR-449--sphere-diff-3cylinders-one-fillet-face-integrates-0-6-too-large.md diff --git a/crates/difftest/src/main.rs b/crates/difftest/src/main.rs index 9b0eb43aed..5a3e2cd5fe 100644 --- a/crates/difftest/src/main.rs +++ b/crates/difftest/src/main.rs @@ -149,6 +149,17 @@ fn main() -> ExitCode { if raw.first().map(String::as_str) == Some("--step-case") { return difftest::step::run_child(raw.get(1..).unwrap_or(&[])); } + // Child of unit `corpus.parity`: one side on one model file + // (see `difftest::units::corpus_parity::run_child`). + if raw.first().map(String::as_str) == Some("--corpus-parity-child") { + #[cfg(feature = "cpp")] + return difftest::units::corpus_parity::run_child(raw.get(1..).unwrap_or(&[])); + #[cfg(not(feature = "cpp"))] + { + eprintln!("difftest: --corpus-parity-child needs feature `cpp`"); + return ExitCode::from(2); + } + } // Child of `--fuzz`: one batch of fuzz cases (see `difftest::fuzz::run_child`). if raw.first().map(String::as_str) == Some("--fuzz-exec") { return fuzz::run_child(raw.get(1..).unwrap_or(&[])); diff --git a/crates/difftest/src/units/corpus_parity.rs b/crates/difftest/src/units/corpus_parity.rs new file mode 100644 index 0000000000..00e1dd29f1 --- /dev/null +++ b/crates/difftest/src/units/corpus_parity.rs @@ -0,0 +1,745 @@ +//! Runner for unit `corpus.parity`: the Rust port is at least as good as upstream on +//! every real model file under `upstream/TestFiles/pt_TestFiles` (188 `.smb` B-rep +//! files and 74 `.smp` part files). C++: `solidcore_ffi::units::corpus_parity`. Rust: +//! the readers (`SmBrep::ReadFromFile`, `SmBrepData::ReadPartFromFile`) and the +//! measures of `crates/solidcore/examples/corpus`. +//! +//! One case per file. Each side reads the file and measures every B-rep: the kind +//! (`IsManifoldSolid`, else `IsSheet`, else other; empty without faces, edges and +//! vertices), the face, edge, vertex and loop counts, the area (`SmApiFaceComputeArea`'s +//! per-face integration without its accuracy clamp) and, for a solid, the volume +//! (`SmApiBrepComputeVolume`), at integration accuracy [`ACCURACY`]. Both sides run in +//! child processes (`difftest --corpus-parity-child `), so a +//! crash or hang on one file fails only that file's side (timeout [`TIMEOUT`]). +//! +//! The file's class, decided on the Rust side (`run_cpp` only records the expected +//! verdict): +//! * `pass`: both succeed, with equal B-rep counts, kinds and counts, and areas and +//! volumes within `10 * ACCURACY` relative (the integration accuracy, not the 1e-9 +//! of the ported units: the two integrations are separate); also when both fail. +//! * `rust-better`: upstream fails (error status, crash or timeout) and the port +//! succeeds. Not a failure: recorded as the diagnostic `rust_better` and printed as +//! `corpus.parity: rust-better `. So is a disagreement on a file listed in +//! [`KNOWN_CPP_DEFECTS`] (upstream's value there is a recorded C++ defect) when every +//! quantity outside the defect agrees; it also gets the diagnostic `cpp_defect`. +//! * `REGRESSION`: upstream succeeds and the port fails or disagrees. The only +//! failing class: the Rust output is the error `REGRESSION: `. +//! * deferred: upstream succeeds and the port stops at an `Unported` callee (the unit +//! is `partial` until the callee is ported). +//! +//! A file whose read passes is then round-tripped on both sides: every B-rep written as +//! an ASCII `.smb` (`SmBrep::WriteToFile`: `SmBrepData::FromBrep`, `WriteToFile`), read +//! back into a new B-rep and measured again, and the read-back measures are compared +//! the same way (children `cpp-roundtrip` / `rust-roundtrip`). Upstream's writer prints +//! fixed-format doubles, so a read-back volume can drift from the read one by more than +//! the integration accuracy; the two sides write the same bytes and must drift alike. + +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use solidcore::geom::attribute::{AttributeStore, ObjectKey}; +use solidcore::geom::object::ObjectRef; +use solidcore::kernel::Kernel; +use solidcore::mass::integrator::properties::{brep_volume, face_properties, PropertyAttributes}; +use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType; +use solidcore::topology::brep::p1 as bl; +use solidcore::topology::brep::p10::TopologyEnv; +use solidcore::topology::brep::p2::{is_manifold_solid, is_sheet}; +use solidcore::topology::brep::p7::read_from_file; +use solidcore::topology::brep_data::BrepData; +use solidcore::topology::brep_data_write::FromBrepEnv; +use solidcore::topology::database_io::FileType; +use solidcore::topology::face::p2::{self as fl, PPF_AREA}; +use solidcore::topology::part_file::{read_part_from_file, PartEnv}; +use solidcore::topology::topology::Topology; +use solidcore::topology::{Brep, EdgeId, EdgeuseId, FaceId}; +use solidcore::{EntityRef, SolidError}; +use solidcore_ffi::units::corpus_parity as ffi; + +use crate::case::{CaseContext, CaseSource}; +use crate::{Case, HarnessError, Output, UnitDiff}; + +/// Integration accuracy of the areas and volumes (relative). +pub const ACCURACY: f64 = 1e-6; +/// Longest a side may take on one file. +pub const TIMEOUT: Duration = Duration::from_secs(300); +/// The child-process flag of `difftest`. +pub const CHILD_FLAG: &str = "--corpus-parity-child"; +/// Deepest directory level searched below the test files. +const MAX_DEPTH: usize = 32; +/// Values per B-rep in a measurement (as [`ffi::PER_BREP`]). +const PER_BREP: usize = ffi::PER_BREP; +/// First identity of the geometry the readers make (topology identities stay below). +const GEOMETRY_KEYS: u64 = 1 << 62; + +/// `corpus.parity` runner. +pub struct Runner; + +#[derive(Serialize, Deserialize)] +struct Inputs { + /// The model file, relative to `upstream/TestFiles/pt_TestFiles`. + file: String, + /// Its absolute path. + path: String, +} + +// ---- the measurements ----------------------------------------------------------------- + +/// What one side made of a file. +#[derive(Debug, Clone, PartialEq)] +enum Measured { + /// `read_status n` then [`PER_BREP`] values per B-rep (`kind faces edges vertices + /// loops status area volume`; volume NaN unless a solid). + Values(Vec), + /// The port stopped at an unported callee. + Unported { upstream: String, unit: String }, + /// The side failed: an error, a crash or a timeout (the reason). + Failed(String), +} + +impl Measured { + /// The B-reps' records when the side read the file and measured every B-rep. + fn breps(&self) -> Option> { + let Self::Values(v) = self else { return None }; + let (&status, rest) = v.split_first()?; + let (&n, rest) = rest.split_first()?; + if status != 0.0 || rest.len() != n as usize * PER_BREP { + return None; + } + let breps: Vec<&[f64]> = rest.chunks(PER_BREP).collect(); + breps + .iter() + .all(|b| b.get(5) == Some(&0.0)) + .then_some(breps) + } + + /// Why the side did not succeed (for the report). + fn why(&self) -> String { + match self { + Self::Values(v) => match v.split_first() { + Some((&s, _)) if s != 0.0 => format!("read status {s}"), + _ => "a B-rep's measurement failed".to_string(), + }, + Self::Unported { upstream, unit } => format!("unported {upstream} ({unit})"), + Self::Failed(why) => why.clone(), + } + } +} + +/// `|a - b| <= 10 * ACCURACY * max(|a|, |b|)` (both NaN: no volume on either side). +fn close(a: f64, b: f64) -> bool { + if a.is_nan() && b.is_nan() { + return true; + } + (a - b).abs() <= 10.0 * ACCURACY * a.abs().max(b.abs()) +} + +/// The first disagreement between two successful measurements, if any. +fn disagreement(cpp: &[&[f64]], rust: &[&[f64]]) -> Option { + disagreement_except(cpp, rust, &[]) +} + +/// [`disagreement`] ignoring the quantities named in `skip`. +fn disagreement_except(cpp: &[&[f64]], rust: &[&[f64]], skip: &[&str]) -> Option { + const NAMES: [&str; 5] = ["kind", "faces", "edges", "vertices", "loops"]; + if cpp.len() != rust.len() { + return Some(format!("{} B-reps, upstream {}", rust.len(), cpp.len())); + } + for (i, (c, r)) in cpp.iter().zip(rust).enumerate() { + for (j, name) in NAMES.iter().enumerate() { + if !skip.contains(name) && c.get(j) != r.get(j) { + return Some(format!( + "B-rep {i}: {name} {:?}, upstream {:?}", + r.get(j), + c.get(j) + )); + } + } + for (j, name) in [(6, "area"), (7, "volume")] { + if skip.contains(&name) { + continue; + } + let (a, b) = (c.get(j).copied(), r.get(j).copied()); + match (a, b) { + (Some(a), Some(b)) if close(a, b) => {} + _ => return Some(format!("B-rep {i}: {name} {b:?}, upstream {a:?}")), + } + } + } + None +} + +/// Files where upstream's result is a known C++ defect and the port's is right: the file, +/// the quantities the defect affects (empty: the whole result) and the evidence. +const KNOWN_CPP_DEFECTS: [(&str, &[&str], &str); 2] = [ + ( + "Fillet/sphere_diff_3cylinders.smb", + &["area", "volume"], + "plan/upstream-issues/WR-449--sphere-diff-3cylinders-one-fillet-face-integrates-0-6-too-large.md: \ + upstream's fillet face 7 integrates 0.60 more area and 2.14 more volume than its five \ + symmetric siblings, which the port matches; the trims differ on edge 30, made on demand \ + (CreateUVTrimCurve / DropCurve / sm_FixUVTrimCurves), the diverging step not yet pinned", + ), + ( + "Solids/fillet_ts.smb", + &[], + "plan/upstream-issues.md ND-257 (UB-254): upstream's B-rep depends on the allocator; \ + without address reuse it is the port's", + ), +]; + +/// The evidence when `file`'s disagreement is a known C++ defect: everything outside the +/// defect's quantities agrees. +fn known_cpp_defect(file: &str, cpp: &[&[f64]], rust: &[&[f64]]) -> Option<&'static str> { + KNOWN_CPP_DEFECTS + .iter() + .find(|(f, _, _)| *f == file) + .and_then(|(_, quantities, why)| { + (quantities.is_empty() || disagreement_except(cpp, rust, quantities).is_none()) + .then_some(*why) + }) +} + +// ---- the Rust side -------------------------------------------------------------------- + +/// A distinct identity for every topology object of B-rep `brep` the readers build +/// (as the corpus example gives them). +fn key_of(brep: usize, e: EntityRef) -> ObjectRef { + let (type_code, kind, i) = match e { + EntityRef::Brep(x) => (16011, 0, x.index()), + EntityRef::Region(x) => (16012, 1, x.index()), + EntityRef::Shell(x) => (16013, 2, x.index()), + EntityRef::Faceuse(x) => (16022, 3, x.index()), + EntityRef::Face(x) => (16002, 4, x.index()), + EntityRef::Loopuse(x) => (16004, 5, x.index()), + EntityRef::Loop(x) => (16003, 6, x.index()), + EntityRef::Edgeuse(x) => (16005, 7, x.index()), + EntityRef::Edge(x) => (16016, 8, x.index()), + EntityRef::Vertexuse(x) => (16006, 9, x.index()), + EntityRef::Vertex(x) => (16021, 10, x.index()), + }; + let brep = (brep as u64 + 1) << 36; + ObjectRef { + type_code, + key: ObjectKey(brep | ((kind + 1) << 32) | u64::from(i)), + } +} + +/// A model file as read: its B-reps and the records and attributes the readers made. +struct Read { + breps: Vec, + attributes: AttributeStore, + records: std::collections::BTreeMap, +} + +/// The B-reps of a model file: one from a B-rep file, any number from a part file. +fn read_breps(k: &mut Kernel, bytes: Vec) -> solidcore::Result { + let mut records = std::collections::BTreeMap::new(); + let mut attributes = AttributeStore::default(); + if bytes.starts_with(b"//[Output Summary]") { + let mut env = PartEnv { + attributes: &mut attributes, + key_of: &key_of, + records: &mut records, + next_key: GEOMETRY_KEYS, + }; + let breps = read_part_from_file(k, bytes, FileType::Ascii, &mut env)?.breps; + return Ok(Read { + breps, + attributes, + records, + }); + } + let mut b = Brep::new_with_infinite_region()?; + let one = |e| key_of(0, e); + let mut env = TopologyEnv { + all_attributes: &[], + attributes: &mut attributes, + key_of: &one, + records: &mut records, + next_key: GEOMETRY_KEYS, + }; + read_from_file(k, &mut b, bytes, FileType::Ascii, false, &mut env)?; + Ok(Read { + breps: vec![b], + attributes, + records, + }) +} + +/// The area at `accuracy`: `SmApiFaceComputeArea`'s per-face integration without its +/// accuracy clamp. +fn integrated_area( + k: &Kernel, + b: &mut Brep, + faces: &[FaceId], + accuracy: f64, +) -> solidcore::Result { + let mut area = 0.0; + for f in faces { + let bbox = solidcore::query::face::p1::calculate_bounding_box(k, &*b, *f, false)?; + let s = bbox.size(); + let d = s.x.max(s.y).max(s.z); + let p = face_properties( + k, + b, + *f, + OrientType::Same, + accuracy, + bbox.mid(), + d * d / 100.0, + 0.0, + PPF_AREA, + )?; + area += p.area.abs(); + } + Ok(area) +} + +/// One B-rep's record (as the shim's `measure`). +fn measure( + k: &mut Kernel, + b: &mut Brep, + accuracy: f64, + out: &mut Vec, +) -> solidcore::Result<()> { + let faces = bl::faces(b, None)?; + let edges = bl::edges(b, None)?; + let vertices = bl::vertices(b, None)?; + let mut loops = 0; + for f in &faces { + loops += fl::loops(b, *f, false, None)?.len(); + } + let kind = if faces.is_empty() && edges.is_empty() && vertices.is_empty() { + 0.0 + } else if is_manifold_solid(b)? { + 1.0 + } else if is_sheet(k, b)? { + 2.0 + } else { + 3.0 + }; + let area = integrated_area(k, b, &faces, accuracy)?; + let mut volume = f64::NAN; + if kind == 1.0 { + let store = AttributeStore::new(); + let key = |_| ObjectKey(0); + let attributes = PropertyAttributes { + store: &store, + key_of: &key, + }; + volume = brep_volume(k, b, accuracy, &attributes)?; + } + out.extend([ + kind, + faces.len() as f64, + edges.len() as f64, + vertices.len() as f64, + loops as f64, + 0.0, + area, + volume, + ]); + Ok(()) +} + +/// The Rust side on one file. +fn measure_rust(path: &Path, accuracy: f64) -> Measured { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => return Measured::Failed(format!("read: {e}")), + }; + let fail = |stage: &str, e: SolidError| match e { + SolidError::Unported { upstream, unit } => Measured::Unported { + upstream: upstream.to_string(), + unit: unit.to_string(), + }, + e => Measured::Failed(format!("{stage}: {e}")), + }; + let mut k = Kernel::default(); + let mut read = match read_breps(&mut k, bytes) { + Ok(r) => r, + Err(e) => return fail("read", e), + }; + let mut out = vec![0.0, read.breps.len() as f64]; + for b in &mut read.breps { + if let Err(e) = measure(&mut k, b, accuracy, &mut out) { + return fail("measure", e); + } + } + Measured::Values(out) +} + +/// The Rust side's round trip of one file, as the shim's `sc_corpus_parity_roundtrip`: +/// every B-rep flattened (`SmBrepData::FromBrep`), written as ASCII (`WriteToFile`, which +/// makes a lazy surface's NURB as upstream's `GetOrCreateGwNurbPointer` does), read back +/// into a new B-rep and measured. +fn measure_rust_roundtrip(path: &Path, accuracy: f64) -> Measured { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => return Measured::Failed(format!("read: {e}")), + }; + let fail = |stage: &str, e: SolidError| match e { + SolidError::Unported { upstream, unit } => Measured::Unported { + upstream: upstream.to_string(), + unit: unit.to_string(), + }, + e => Measured::Failed(format!("{stage}: {e}")), + }; + let mut k = Kernel::default(); + let read = match read_breps(&mut k, bytes) { + Ok(r) => r, + Err(e) => return fail("read", e), + }; + let mut out = vec![0.0, read.breps.len() as f64]; + for (i, b) in read.breps.iter().enumerate() { + let key = |e| key_of(i, e); + let env = FromBrepEnv { + attributes: &read.attributes, + key_of: &key, + records: &read.records, + curve_key: &|_: EdgeId| ObjectKey(0), + uv_curve_key: &|_: EdgeuseId| ObjectKey(0), + surface_key: &|_: FaceId| ObjectKey(0), + }; + let mut all = Vec::new(); + let written = BrepData::from_brep(b, &env, &mut all) + .and_then(|mut data| data.write_to_file_with_nurbs(&k, FileType::Ascii)); + let bytes = match written { + Ok(bytes) => bytes, + Err(e) => return fail("write", e), + }; + let mut back = match read_breps(&mut k, bytes) { + Ok(r) => r, + Err(e) => return fail("read back", e), + }; + let Some(b2) = back.breps.first_mut() else { + return Measured::Failed("read back: no B-rep".to_string()); + }; + if let Err(e) = measure(&mut k, b2, accuracy, &mut out) { + return fail("measure read back", e); + } + } + Measured::Values(out) +} + +// ---- the child processes -------------------------------------------------------------- + +/// `difftest --corpus-parity-child +/// `: measures one file (or its ASCII round trip) on one side and prints one line (`CP `, `CP-UNPORTED ` or +/// `CP-ERROR `, tab separated). +pub fn run_child(args: &[String]) -> ExitCode { + let (Some(side), Some(path), Some(accuracy)) = (args.first(), args.get(1), args.get(2)) else { + eprintln!( + "usage: difftest {CHILD_FLAG} " + ); + return ExitCode::from(2); + }; + let Ok(accuracy) = accuracy.parse::() else { + eprintln!("{CHILD_FLAG}: bad accuracy {accuracy:?}"); + return ExitCode::from(2); + }; + let m = match side.as_str() { + "cpp" => match ffi::measure(path, accuracy) { + Ok(v) => Measured::Values(v), + Err(e) => Measured::Failed(format!("shim: {e}")), + }, + "rust" => measure_rust(Path::new(path), accuracy), + "cpp-roundtrip" => match PrivateDir::new() { + // (the shim writes and reads the file by name: only inside a directory of + // our own, never at a guessable path another user could have planted) + Ok(dir) => { + let tmp = dir.0.join("roundtrip.smb"); + match ffi::roundtrip(path, &tmp.to_string_lossy(), accuracy) { + Ok(v) => Measured::Values(v), + Err(e) => Measured::Failed(format!("shim: {e}")), + } + } + Err(e) => Measured::Failed(format!("private temp dir: {e}")), + }, + "rust-roundtrip" => measure_rust_roundtrip(Path::new(path), accuracy), + _ => { + eprintln!("{CHILD_FLAG}: side must be cpp, rust, cpp-roundtrip or rust-roundtrip"); + return ExitCode::from(2); + } + }; + match m { + Measured::Values(v) => { + let s: Vec = v.iter().map(|x| format!("{x:e}")).collect(); + println!("CP\t{}", s.join("\t")); + } + Measured::Unported { upstream, unit } => println!("CP-UNPORTED\t{upstream}\t{unit}"), + Measured::Failed(why) => println!("CP-ERROR\t{}", why.replace(['\n', '\t'], " ")), + } + ExitCode::SUCCESS +} + +/// A directory only this user can reach (mode 0700 on Unix), made fresh under the +/// system temp directory for the C++ round trip's file and removed with everything in it +/// when dropped (on every return path). `create_dir` fails on any existing entry, +/// a planted symlink included, and the next name is tried. +struct PrivateDir(PathBuf); + +impl PrivateDir { + fn new() -> std::io::Result { + let base = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.subsec_nanos()); + let mut last = None; + for attempt in 0..64_u32 { + let dir = base.join(format!( + "corpus-parity-{}-{nanos:09}-{attempt}", + std::process::id() + )); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&dir) { + Ok(()) => return Ok(Self(dir)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e), + Err(e) => return Err(e), + } + } + Err(last.unwrap_or_else(|| std::io::Error::other("no free name"))) + } +} + +impl Drop for PrivateDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Runs one side on `path` in a child process, with [`TIMEOUT`]. +fn measure_isolated(side: &str, path: &str) -> Measured { + let exe = match std::env::current_exe() { + Ok(e) => e, + Err(e) => return Measured::Failed(format!("current_exe: {e}")), + }; + let child = Command::new(exe) + .arg(CHILD_FLAG) + .arg(side) + .arg(path) + .arg(ACCURACY.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => return Measured::Failed(format!("spawn: {e}")), + }; + let reader = child.stdout.take().map(|mut out| { + std::thread::spawn(move || { + let mut s = String::new(); + let _ = out.read_to_string(&mut s); + s + }) + }); + let start = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(s)) => break Some(s), + Ok(None) if start.elapsed() > TIMEOUT => { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(e) => return Measured::Failed(format!("wait: {e}")), + } + }; + let stdout = reader.and_then(|h| h.join().ok()).unwrap_or_default(); + let Some(status) = status else { + return Measured::Failed(format!("timeout after {}s", TIMEOUT.as_secs())); + }; + if !status.success() { + return Measured::Failed(format!("crash ({status})")); + } + parse_child(&stdout) +} + +/// The measurement a child printed. +fn parse_child(stdout: &str) -> Measured { + for line in stdout.lines() { + let mut fields = line.split('\t'); + match fields.next() { + Some("CP") => { + let v: Result, _> = fields.map(str::parse::).collect(); + return match v { + Ok(v) => Measured::Values(v), + Err(e) => Measured::Failed(format!("bad child output: {e}")), + }; + } + Some("CP-UNPORTED") => { + return Measured::Unported { + upstream: fields.next().unwrap_or_default().to_string(), + unit: fields.next().unwrap_or_default().to_string(), + }; + } + Some("CP-ERROR") => { + return Measured::Failed(fields.next().unwrap_or_default().to_string()); + } + _ => {} + } + } + Measured::Failed("no result from the child".to_string()) +} + +// ---- the cases ------------------------------------------------------------------------ + +/// The `.smb` and `.smp` files below `dir` (relative paths, sorted). +fn model_files(dir: &Path) -> Vec<(PathBuf, String)> { + let mut out = Vec::new(); + let mut stack = vec![(dir.to_path_buf(), 0usize)]; + while let Some((d, depth)) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + if depth < MAX_DEPTH { + stack.push((p, depth + 1)); + } + continue; + } + let ext = p + .extension() + .and_then(|x| x.to_str()) + .map(str::to_ascii_lowercase); + if matches!(ext.as_deref(), Some("smb" | "smp")) { + if let Ok(rel) = p.strip_prefix(dir) { + out.push((p.clone(), rel.to_string_lossy().replace('\\', "/"))); + } + } + } + } + out.sort_by(|a, b| a.1.cmp(&b.1)); + out +} + +/// The round trip's verdict on a file whose read passed (`read`, kept when the round trip +/// passes too): each side writes every B-rep as ASCII and reads it back +/// (`cpp-roundtrip` / `rust-roundtrip` children), and the read-back measures are compared +/// as the read ones are. A disagreement is a `REGRESSION` (upstream's ASCII writer drops +/// digits, so the read-back volume can move from the read one; both sides must move +/// alike), unless it is a known C++ defect of the file. +fn roundtrip_verdict(i: &Inputs, read: Output) -> Output { + let cpp = measure_isolated("cpp-roundtrip", &i.path); + let rust = measure_isolated("rust-roundtrip", &i.path); + match (cpp.breps(), rust.breps()) { + (Some(c), Some(r)) => match disagreement(&c, &r) { + None => read.diagnostic("roundtrip_pass", 1), + Some(why) => match known_cpp_defect(&i.file, &c, &r) { + Some(_) => read.diagnostic("roundtrip_cpp_defect", 1), + None => Output::err(format!("REGRESSION: round trip: {why}")), + }, + }, + (Some(_), None) => match rust { + Measured::Unported { upstream, unit } => Output::deferred(unit, upstream), + _ => Output::err(format!("REGRESSION: round trip: {}", rust.why())), + }, + (None, Some(_)) => { + eprintln!( + "corpus.parity: rust-better {} (upstream round trip: {})", + i.file, + cpp.why() + ); + read.diagnostic("roundtrip_rust_better", 1) + } + (None, None) => read.diagnostic("roundtrip_both_fail", 1), + } +} + +impl UnitDiff for Runner { + fn id(&self) -> &'static str { + "corpus.parity" + } + + fn cases(&self, ctx: &CaseContext) -> Result, HarnessError> { + let dir = ctx.root.join(crate::fixtures::UPSTREAM_TEST_FILES); + let files = model_files(&dir); + if files.is_empty() { + return Err(HarnessError::new(format!( + "no .smb/.smp files under {}", + dir.display() + ))); + } + files + .into_iter() + .map(|(path, file)| { + let name = file.replace('/', "__"); + let inputs = Inputs { + file, + path: path.to_string_lossy().into_owned(), + }; + Ok(Case { + unit: self.id().to_string(), + name, + source: CaseSource::Inline, + inputs: serde_json::to_value(inputs) + .map_err(|e| HarnessError::new(e.to_string()))?, + }) + }) + .collect() + } + + /// The verdict expected of every file: no regression (the measurements run on the + /// Rust side, which classifies the file). + fn run_cpp(&self, _case: &Case) -> Result { + Ok(Output::new().int("regression", 0)) + } + + fn run_rust(&self, case: &Case) -> Result { + let i: Inputs = case.parse()?; + let cpp = measure_isolated("cpp", &i.path); + let rust = measure_isolated("rust", &i.path); + let ok = Output::new().int("regression", 0); + let read = match (cpp.breps(), rust.breps()) { + (Some(c), Some(r)) => match disagreement(&c, &r) { + None => ok.diagnostic("pass", 1), + Some(why) => match known_cpp_defect(&i.file, &c, &r) { + Some(defect) => { + eprintln!( + "corpus.parity: rust-better {} (known C++ defect: {why}; {defect})", + i.file + ); + ok.diagnostic("rust_better", 1).diagnostic("cpp_defect", 1) + } + None => Output::err(format!("REGRESSION: {why}")), + }, + }, + (Some(_), None) => match rust { + Measured::Unported { upstream, unit } => Output::deferred(unit, upstream), + _ => Output::err(format!("REGRESSION: {}", rust.why())), + }, + (None, Some(_)) => { + eprintln!( + "corpus.parity: rust-better {} (upstream: {})", + i.file, + cpp.why() + ); + ok.diagnostic("rust_better", 1) + } + (None, None) => ok.diagnostic("both_fail", 1), + }; + if read.error.is_some() || read.deferred.is_some() { + return Ok(read); + } + Ok(roundtrip_verdict(&i, read)) + } + + fn generate(&self, _rng: &mut crate::Rng) -> Option { + None + } +} diff --git a/crates/difftest/src/units/topology_part_file.rs b/crates/difftest/src/units/topology_part_file.rs new file mode 100644 index 0000000000..23dd549a53 --- /dev/null +++ b/crates/difftest/src/units/topology_part_file.rs @@ -0,0 +1,202 @@ +//! Difftest runner for the part reader (`topology::part_file`, outside the plan: +//! `SmBrepData::ReadPartFromFile` is not a plan unit, see `plan/findings.md`). Run it +//! with `--unit topology.part_file`; `--green` does not include it. +//! +//! One case per upstream part file under `upstream/TestFiles/pt_TestFiles` (the `.smp` +//! files and the `.smb` files that start with `//[Output Summary]`). Both sides read the +//! file and dump, as `out`: the stand-alone curves (type, dimension, natural interval), +//! the surfaces (type, natural UV domain), the Boolean tree nodes and per B-rep its +//! tolerance, manifold-solid flag and region, shell, face, loop, edge and vertex counts +//! (layout in `solidcore_ffi::units::topology_part_file`). A read failure is compared +//! as a status only. The B-reps go through `MakeTopologyFromData`, so a case defers +//! wherever that path still reaches an unported call. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use solidcore::geom::attribute::{AttributeStore, ObjectKey}; +use solidcore::geom::object::ObjectRef; +use solidcore::kernel::Kernel; +use solidcore::topology::brep::p1 as bl; +use solidcore::topology::brep::p2::is_manifold_solid; +use solidcore::topology::database_io::FileType; +use solidcore::topology::face::p2 as fl; +use solidcore::topology::part_file::{read_part_from_file, Part, PartEnv}; +use solidcore::{EntityRef, SolidError}; +use solidcore_ffi::units::topology_part_file as ffi; + +use crate::case::{repo_root, CaseContext}; +use crate::fixtures::UPSTREAM_TEST_FILES; +use crate::{Case, CaseSource, HarnessError, Output, UnitDiff}; + +/// Status output of a failed read (compared on both sides). +const READ_FAILED: &str = "read failed"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Inputs { + /// The part file, relative to `upstream/TestFiles/pt_TestFiles`. + file: String, +} + +/// Differential runner for `SmBrepData::ReadPartFromFile`. +pub struct Runner; + +fn path_of(rel: &str) -> String { + repo_root() + .join(UPSTREAM_TEST_FILES) + .join(rel) + .to_string_lossy() + .into_owned() +} + +/// Every part file under the upstream test files, sorted. +fn part_files() -> Vec { + let root = repo_root().join(UPSTREAM_TEST_FILES); + let mut out = Vec::new(); + let mut stack = vec![root.clone()]; + while let Some(dir) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in rd.flatten() { + let p = entry.path(); + if p.is_dir() { + stack.push(p); + } else if is_part_file(&p) { + if let Ok(rel) = p.strip_prefix(&root) { + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } + } + } + out.sort(); + out +} + +fn is_part_file(p: &Path) -> bool { + let ext = p.extension().and_then(|x| x.to_str()).unwrap_or_default(); + (ext == "smp" || ext == "smb") + && std::fs::read(p).is_ok_and(|b| b.starts_with(b"//[Output Summary]")) +} + +/// The identity of topology entity `e` of B-rep `brep` (geometry keys start at `1 << 62`). +fn key_of(brep: usize, e: EntityRef) -> ObjectRef { + let (type_code, kind, i) = match e { + EntityRef::Brep(x) => (16011, 0, x.index()), + EntityRef::Region(x) => (16012, 1, x.index()), + EntityRef::Shell(x) => (16013, 2, x.index()), + EntityRef::Faceuse(x) => (16022, 3, x.index()), + EntityRef::Face(x) => (16002, 4, x.index()), + EntityRef::Loopuse(x) => (16004, 5, x.index()), + EntityRef::Loop(x) => (16003, 6, x.index()), + EntityRef::Edgeuse(x) => (16005, 7, x.index()), + EntityRef::Edge(x) => (16016, 8, x.index()), + EntityRef::Vertexuse(x) => (16006, 9, x.index()), + EntityRef::Vertex(x) => (16021, 10, x.index()), + }; + ObjectRef { + type_code, + key: ObjectKey(((brep as u64 + 1) << 36) | ((kind + 1) << 32) | u64::from(i)), + } +} + +/// The dump of a part read on the Rust side (the shim's layout). +fn dump(k: &mut Kernel, part: &Part) -> Result, SolidError> { + let mut o = vec![0.0, part.curves.len() as f64]; + for c in &part.curves { + let ivl = c.object.natural_interval(); + let (ty, dim) = (c.object.type_code() as f64, c.object.dim() as f64); + o.extend([ty, dim, ivl.min(), ivl.max()]); + } + o.push(part.surfaces.len() as f64); + for s in &part.surfaces { + let d = s.object.natural_uv_domain(); + let (lo, hi) = (d.min(), d.max()); + o.extend([s.object.type_code() as f64, lo.x, lo.y, hi.x, hi.y]); + } + o.push(part.boolean_tree_nodes.len() as f64); + o.extend(part.boolean_tree_nodes.iter().map(|&n| n as f64)); + o.push(part.breps.len() as f64); + for b in &part.breps { + let faces = bl::faces(b, None)?; + let mut loops = 0usize; + for &f in &faces { + loops += fl::loops(b, f, false, None)?.len(); + } + o.push(b.tolerance()); + o.push(f64::from(u8::from(is_manifold_solid(b)?))); + o.push(bl::regions(b, None)?.len() as f64); + o.push(bl::shells(k, b, None)?.len() as f64); + o.push(faces.len() as f64); + o.push(loops as f64); + o.push(bl::edges(b, None)?.len() as f64); + o.push(bl::vertices(b, None)?.len() as f64); + } + Ok(o) +} + +fn rust(rel: &str) -> Result { + let bytes = + std::fs::read(path_of(rel)).map_err(|e| HarnessError::new(format!("{rel}: {e}")))?; + let mut k = Kernel::default(); + let mut store = AttributeStore::new(); + let mut records = BTreeMap::new(); + let mut env = PartEnv { + attributes: &mut store, + key_of: &key_of, + records: &mut records, + next_key: 1 << 62, + }; + let result = read_part_from_file(&mut k, bytes, FileType::Ascii, &mut env) + .and_then(|part| dump(&mut k, &part)); + Ok(match result { + Ok(v) => Output::new().values("out", v), + Err(e) => Output::unported(&e).unwrap_or_else(|| Output::err(READ_FAILED)), + }) +} + +fn cpp(rel: &str) -> Result { + let v = ffi::run(&path_of(rel)).map_err(|e| HarnessError::new(format!("c++: {e}")))?; + Ok(match v.first() { + Some(&0.0) => Output::new().values("out", v), + st => { + // (the status only goes to stderr: both sides' errors compare as READ_FAILED) + eprintln!( + "topology.part_file: c++ {rel}: status {st:?}, {} values", + v.len() + ); + Output::err(READ_FAILED) + } + }) +} + +impl UnitDiff for Runner { + fn id(&self) -> &'static str { + "topology.part_file" + } + + fn cases(&self, _ctx: &CaseContext) -> Result, HarnessError> { + Ok(part_files() + .into_iter() + .map(|rel| Case { + unit: self.id().to_string(), + name: rel.clone(), + source: CaseSource::Inline, + inputs: serde_json::to_value(Inputs { file: rel }).unwrap_or(Value::Null), + }) + .collect()) + } + + fn run_cpp(&self, case: &Case) -> Result { + let i: Inputs = case.parse()?; + cpp(&i.file) + } + + fn run_rust(&self, case: &Case) -> Result { + let i: Inputs = case.parse()?; + rust(&i.file) + } +} diff --git a/crates/solidcore-ffi/src/units/corpus_parity.rs b/crates/solidcore-ffi/src/units/corpus_parity.rs new file mode 100644 index 0000000000..dc97eb871e --- /dev/null +++ b/crates/solidcore-ffi/src/units/corpus_parity.rs @@ -0,0 +1,102 @@ +//! FFI wrappers for unit `corpus.parity`. +//! +//! Shim: `harness/cpp/shim/corpus_parity.cpp` (`sc_corpus_parity_measure`): upstream's +//! `SmBrep::ReadFromFile` / `SmBrepData::ReadPartFromFile` on one model file, then per +//! B-rep `IsManifoldSolid` / `IsSheet`, the counts, the per-face area integration of +//! `SmApiFaceComputeArea` (without its accuracy clamp) and `SmApiBrepComputeVolume`. +//! +//! | Function | Output | +//! |-------------|--------| +//! | [`measure`] | `read_status n`, then per B-rep `kind faces edges vertices loops status area volume` | +//! | [`roundtrip`] | as [`measure`], each B-rep measured after `SmBrep::WriteToFile` (ASCII) and `ReadFromFile` | +//! +//! `kind` is 0 empty, 1 solid, 2 sheet, 3 other; `status` is 0 or the first failing +//! measurement's `SmStatus`; `volume` is NaN unless the B-rep is a solid. The objects +//! read are leaked: call it from a short-lived process. + +use crate::{call_with_buffer, FfiError}; + +mod sys { + extern "C" { + pub fn sc_corpus_parity_measure( + path: *const std::ffi::c_char, + accuracy: f64, + out: *mut f64, + cap: i64, + out_len: *mut i64, + ) -> i32; + pub fn sc_corpus_parity_roundtrip( + path: *const std::ffi::c_char, + tmp: *const std::ffi::c_char, + accuracy: f64, + out: *mut f64, + cap: i64, + out_len: *mut i64, + ) -> i32; + } +} + +/// Values per B-rep in [`measure`]'s output. +pub const PER_BREP: usize = 8; + +/// Reads model file `path` with upstream's reader and measures every B-rep at +/// integration accuracy `accuracy` (see the module docs for the layout). +/// +/// # Errors +/// +/// [`FfiError::InvalidArgument`] for a path with a NUL byte or a non-positive or +/// non-finite accuracy; [`FfiError::Status`] for a shim failure. +pub fn measure(path: &str, accuracy: f64) -> Result, FfiError> { + if !(accuracy.is_finite() && accuracy > 0.0) { + return Err(FfiError::InvalidArgument("accuracy".into())); + } + let cpath = std::ffi::CString::new(path) + .map_err(|_| FfiError::InvalidArgument("path has a NUL byte".into()))?; + call_with_buffer(64, |buf, len| { + // SAFETY: cpath is a valid NUL-terminated string; buf is valid for buf.len() + // writes and the shim honours the capacity; len is valid. + unsafe { + sys::sc_corpus_parity_measure( + cpath.as_ptr(), + accuracy, + buf.as_mut_ptr(), + buf.len() as i64, + &raw mut *len, + ) + } + }) +} + +/// Reads model file `path` with upstream's reader, then writes every B-rep to the ASCII +/// file `tmp` (`SmBrep::WriteToFile`: `SmBrepData::FromBrep`, `WriteToFile`), reads it +/// back into a new B-rep (`SmBrep::ReadFromFile`) and measures that at integration +/// accuracy `accuracy`; `tmp` is removed afterwards. The layout is [`measure`]'s, a +/// failed write or read back giving its `SmStatus` as the B-rep's `status`. +/// +/// # Errors +/// +/// [`FfiError::InvalidArgument`] for a path with a NUL byte or a non-positive or +/// non-finite accuracy; [`FfiError::Status`] for a shim failure. +pub fn roundtrip(path: &str, tmp: &str, accuracy: f64) -> Result, FfiError> { + if !(accuracy.is_finite() && accuracy > 0.0) { + return Err(FfiError::InvalidArgument("accuracy".into())); + } + let cpath = std::ffi::CString::new(path) + .map_err(|_| FfiError::InvalidArgument("path has a NUL byte".into()))?; + let ctmp = std::ffi::CString::new(tmp) + .map_err(|_| FfiError::InvalidArgument("tmp has a NUL byte".into()))?; + call_with_buffer(64, |buf, len| { + // SAFETY: cpath and ctmp are valid NUL-terminated strings; buf is valid for + // buf.len() writes and the shim honours the capacity; len is valid. + unsafe { + sys::sc_corpus_parity_roundtrip( + cpath.as_ptr(), + ctmp.as_ptr(), + accuracy, + buf.as_mut_ptr(), + buf.len() as i64, + &raw mut *len, + ) + } + }) +} diff --git a/crates/solidcore-ffi/src/units/topology_part_file.rs b/crates/solidcore-ffi/src/units/topology_part_file.rs new file mode 100644 index 0000000000..c629b5ac9e --- /dev/null +++ b/crates/solidcore-ffi/src/units/topology_part_file.rs @@ -0,0 +1,92 @@ +//! FFI wrapper for the part reader (`topology::part_file`, outside the plan). +//! +//! Shim: `harness/cpp/shim/topology_part_file.cpp`. +//! Upstream: `source/SMLib/src/SmBrepData.cpp` (`SmBrepData::ReadPartFromFile`). +//! +//! | Rust | shim | upstream | +//! |---------|------------------------------|---------------------------------------------| +//! | [`run`] | `sc_topology_part_file_run` | `SmBrepData::ReadPartFromFile` and a dump | +//! +//! # Output +//! +//! The read status (`0` = `SM_SUCCESS`), then on success: the curve count and per curve +//! its type, dimension and natural interval (2); the surface count and per surface its +//! type and natural UV domain (`umin vmin umax vmax`); the Boolean tree node count and +//! the nodes; the B-rep count and per B-rep its tolerance, `IsManifoldSolid` flag and +//! region, shell, face, loop (over the faces' `GetLoops`), edge and vertex counts. +//! +//! # Guard +//! +//! Upstream's part reader trusts every count and extraction of the file, so [`run`] +//! accepts only upstream's own test files: a path under +//! `upstream/TestFiles/pt_TestFiles` (after resolving it) whose contents start with +//! `//[Output Summary]`; anything else is [`FfiError::InvalidArgument`]. + +use std::path::{Component, Path}; + +use crate::{call_with_buffer, FfiError}; + +mod sys { + extern "C" { + pub fn sc_topology_part_file_run( + path: *const std::ffi::c_char, + file_type: i32, + out: *mut f64, + cap: i64, + out_len: *mut i64, + ) -> i32; + } +} + +fn bad(msg: &str) -> FfiError { + FfiError::InvalidArgument(msg.to_string()) +} + +/// The guard of [`run`]: `path` names an upstream part test file. +/// +/// # Errors +/// +/// [`FfiError::InvalidArgument`] for any other file. +pub fn validate(path: &str) -> Result<(), FfiError> { + let p = Path::new(path); + if p.components().any(|c| c == Component::ParentDir) { + return Err(bad("path with `..`")); + } + let canon = p.canonicalize().map_err(|e| bad(&format!("{path}: {e}")))?; + let under = canon + .ancestors() + .any(|a| a.ends_with(Path::new("upstream/TestFiles/pt_TestFiles"))); + if !under { + return Err(bad("not an upstream test file")); + } + let bytes = std::fs::read(&canon).map_err(|e| bad(&format!("{path}: {e}")))?; + if !bytes.starts_with(b"//[Output Summary]") { + return Err(bad("not a part file")); + } + Ok(()) +} + +/// `SmBrepData::ReadPartFromFile(ctx, path, ..., SM_ASCII)` and the dump described in +/// the module docs. +/// +/// # Errors +/// +/// Those of [`validate`]; [`FfiError::Status`] for a shim failure. +pub fn run(path: &str) -> Result, FfiError> { + validate(path)?; + let cpath = std::ffi::CString::new(path).map_err(|_| bad("path has a NUL byte"))?; + call_with_buffer(1024, |buf, len| { + // SAFETY: cpath is a valid NUL-terminated string naming a validated upstream + // part file; buf is valid for buf.len() writes and the shim honours the + // capacity; len is valid. + unsafe { + sys::sc_topology_part_file_run( + cpath.as_ptr(), + 0, + buf.as_mut_ptr(), + buf.len() as i64, + &raw mut *len, + ) + } + }) +} diff --git a/crates/solidcore/examples/corpus/main.rs b/crates/solidcore/examples/corpus/main.rs new file mode 100644 index 0000000000..e9b7d32b36 --- /dev/null +++ b/crates/solidcore/examples/corpus/main.rs @@ -0,0 +1,1222 @@ +//! `cargo run --release --example corpus -- [] [--json ]`: reads every real +//! B-rep model under `` (default: upstream's `TestFiles/pt_TestFiles`, 188 `.smb` +//! files and 74 `.smp` part files) with the ported reader (`SmBrep::ReadFromFile`), checks +//! each one in pure Rust and prints `corpus: X/Y pass`. +//! +//! Per model: +//! 1. read: [`read_from_file`] on the file's bytes (ASCII); a part file (`.smp`, or an +//! `.smb` that starts with `//[Output Summary]`) with [`read_part_from_file`] +//! (`SmBrepData::ReadPartFromFile`), which gives any number of B-reps. +//! 2. classify each B-rep: `IsManifoldSolid` gives a solid, otherwise `IsSheet` gives a +//! sheet, otherwise it is `other` (wire, non-manifold or general). The model's kind +//! is its B-reps' kind when they agree, else `mixed` (`empty` without a B-rep). +//! 3. measure each B-rep: face, edge, vertex and loop counts; the face components (faces joined +//! through shared edges); the tight bounding box; the area, integrated per face as the +//! `primitives` example's checks do (`SmApiFaceComputeArea` without its accuracy clamp); +//! and, for a solid, `SmApiBrepComputeVolume`. +//! 4. check: a model has a B-rep, and every B-rep has a face and a positive finite area. +//! A solid also needs an Euler-Poincare characteristic `V - E + 2F - L = 2 (S - G)` +//! that is even and gives a genus `G >= 0` (`S` the face components) and a volume in +//! `(0, bbox volume]`. A check of B-rep `i` of a part names it in its message. +//! +//! 5. round trip (does not change the class): each measured B-rep is flattened +//! (`SmBrepData::FromBrep`), written as an ASCII `.smb` (`WriteToFile`, after making a lazy surface's NURB as upstream's `GetOrCreateGwNurbPointer` does), read back +//! and measured again; it fails when the kind or counts differ. An area or volume +//! that moves by more than ten times the integration accuracy (relative) is +//! reported as `drift (upstream writer): ...` with the values, not as a failure: +//! upstream's writer prints doubles in fixed format (`%.16f`) and its own round +//! trip moves them alike (bit for bit on the corpus; `plan/findings/corpus.parity--ascii-round-trip-volume-drift-is-upstreams-own-2026-10-23.md`, and the +//! `corpus.parity` difftest compares the C++ and Rust round trips). The model's +//! `roundtrip` is `pass`, that drift, or the first B-rep's failure +//! (`unported()` for a writer not ported yet). +//! +//! Each model ends in one class: `pass`, `read-fail` (the reader returned an error), +//! `check-fail` (a measurement errored or a check failed), `unported` (an +//! `Unported { upstream, unit }` stopped it; the symbol is reported) or `crash` (the +//! model's child process panicked, aborted, overflowed its stack or ran past +//! `--timeout`). +//! +//! Every model runs in its own child process (this executable with `--one`), so a crash +//! or hang on one file cannot stop the run; `--no-isolate` runs them in-process. +//! +//! Options: `--json ` writes the results (schema below); `--accuracy ` +//! (default `1e-6`) is the integration accuracy; `--timeout ` (default 300) bounds +//! each model; `--jobs ` (default 1) runs that many models at once; `--filter ` +//! keeps the files whose relative path contains `s`. The exit status is non-zero only +//! for bad arguments or I/O errors, not for failing models. Schema 1: +//! +//! ```text +//! { "schema": 1, "source": "crates/solidcore/examples/corpus", "dir", "accuracy", +//! "roundtrip": "", +//! "summary": { "total", "pass", "read_fail", "check_fail", "unported", "crash", +//! "roundtrip": { "tried", "pass", "drift" }, +//! "kinds": { "": { "total", "pass" } } }, +//! "blockers": [ { "upstream", "unit", "count", "files": [..] } ], (most files first) +//! "models": [ { "file", "class", "kind", "stage": null | "read" | "measure" | "check" +//! | "run", "blocker": null | { "upstream", "unit" }, "error", +//! "breps": [ { "kind", "counts": { "faces", "edges", "vertices", +//! "loops", "components" }, "genus", "volume", "area", +//! "bbox": { "min", "max" } } ], +//! "checks": [ { "name", "pass", "message" } ], +//! "roundtrip": null | "pass" | "drift (upstream writer): ..." | "", +//! "seconds" } ] } +//! ``` + +use solidcore::geom::attribute::{AttributeStore, ObjectKey}; +use solidcore::geom::object::ObjectRef; +use solidcore::kernel::Kernel; +use solidcore::mass::integrator::properties::{brep_volume, face_properties, PropertyAttributes}; +use solidcore::math::Point3; +use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType; +use solidcore::query::brep::{calculate_bounding_box, BoundingBoxOptions}; +use solidcore::topology::brep::p1 as bl; +use solidcore::topology::brep::p10::TopologyEnv; +use solidcore::topology::brep::p2::{is_manifold_solid, is_sheet}; +use solidcore::topology::brep::p7::read_from_file; +use solidcore::topology::brep_data::BrepData; +use solidcore::topology::brep_data_write::FromBrepEnv; +use solidcore::topology::database_io::FileType; +use solidcore::topology::edge as ed; +use solidcore::topology::face::p2::{self as fl, PPF_AREA}; +use solidcore::topology::part_file::{read_part_from_file, PartEnv}; +use solidcore::topology::topology::Topology; +use solidcore::topology::{Brep, EdgeId, EdgeuseId, FaceId}; +use solidcore::{EntityRef, SolidError}; +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +/// Upstream's test models, relative to this crate. +const DEFAULT_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../upstream/TestFiles/pt_TestFiles" +); +/// Largest model file read (bytes). +const MAX_FILE_BYTES: u64 = 256 << 20; +/// Deepest directory level searched below ``. +const MAX_DEPTH: usize = 32; +/// Most model files run. +const MAX_FILES: usize = 100_000; +/// Most bytes of a crashed child's stderr kept for the report. +const STDERR_TAIL: usize = 800; +/// What the round trip does. +const ROUNDTRIP: &str = "FromBrep, WriteToFile (ASCII), ReadFromFile: equal kind and counts; \ + area or volume beyond 10 x accuracy is upstream-writer drift"; + +/// The prefix of a round trip whose area or volume moved (upstream's writer moves it +/// alike). +const DRIFT: &str = "drift (upstream writer)"; + +/// Command-line options. +struct Opts { + dir: PathBuf, + json: Option, + accuracy: f64, + timeout: Duration, + jobs: usize, + isolate: bool, + filter: Option, + /// Child mode: run this one model and print its result. + one: Option, + /// Child mode: the model's path relative to `dir`. + rel: Option, +} + +fn parse_args() -> Result { + let mut o = Opts { + dir: PathBuf::from(DEFAULT_DIR), + json: None, + accuracy: 1e-6, + timeout: Duration::from_secs(300), + jobs: 1, + isolate: true, + filter: None, + one: None, + rel: None, + }; + let usage = "usage: [] [--json ] [--accuracy ] [--timeout ] \ + [--jobs ] [--filter ] [--no-isolate]"; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + let mut value = |name: &str| { + let v = args.next(); + v.ok_or_else(|| format!("{name} needs a value; {usage}")) + }; + match a.as_str() { + "--json" => o.json = Some(PathBuf::from(value("--json")?)), + "--accuracy" => { + let v = value("--accuracy")?; + o.accuracy = v.parse().map_err(|e| format!("--accuracy {v:?}: {e}"))?; + if !(o.accuracy.is_finite() && o.accuracy > 0.0) { + return Err(format!("--accuracy {v:?}: must be positive")); + } + } + "--timeout" => { + let v = value("--timeout")?; + let s: u64 = v.parse().map_err(|e| format!("--timeout {v:?}: {e}"))?; + o.timeout = Duration::from_secs(s.max(1)); + } + "--jobs" => { + let v = value("--jobs")?; + let n: usize = v.parse().map_err(|e| format!("--jobs {v:?}: {e}"))?; + o.jobs = n.clamp(1, 64); + } + "--filter" => o.filter = Some(value("--filter")?), + "--no-isolate" => o.isolate = false, + "--one" => o.one = Some(PathBuf::from(value("--one")?)), + "--rel" => o.rel = Some(value("--rel")?), + s if s.starts_with("--") => return Err(format!("unknown option {s:?}; {usage}")), + _ => o.dir = PathBuf::from(&a), + } + } + Ok(o) +} + +fn main() -> Result<(), String> { + let opts = parse_args()?; + if let Some(file) = &opts.one { + let rel = match &opts.rel { + Some(r) => r.clone(), + None => file.display().to_string(), + }; + let e = Entry::of(&evaluate(file, &rel, opts.accuracy)); + let upstream = e.blocker.as_ref().map_or("", |b| b.0.as_str()); + let unit = e.blocker.as_ref().map_or("", |b| b.1.as_str()); + let rt = match e.roundtrip { + None => "-", + Some(true) if e.roundtrip_drift => "drift", + Some(true) => "pass", + Some(false) => "fail", + }; + let (class, kind) = (&e.class, &e.kind); + println!("CORPUS-CLASS\t{class}\t{kind}\t{upstream}\t{unit}\t{rt}"); + println!("CORPUS-LINE\t{}", e.line.replace(['\n', '\r'], " ")); + println!("CORPUS-JSON\t{}", e.json); + return Ok(()); + } + + let files = model_files(&opts.dir, opts.filter.as_deref()); + if files.is_empty() { + return Err(format!("no .smb/.smp files under {}", opts.dir.display())); + } + let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?; + println!( + "corpus: {} model files under {}", + files.len(), + opts.dir.display() + ); + + let next = AtomicUsize::new(0); + let results: Mutex>> = Mutex::new(files.iter().map(|_| None).collect()); + thread::scope(|s| { + for _ in 0..opts.jobs.min(files.len()) { + s.spawn(|| loop { + let i = next.fetch_add(1, Ordering::SeqCst); + let Some((path, rel)) = files.get(i) else { + break; + }; + let entry = if opts.isolate { + run_isolated(&exe, path, rel, &opts) + } else { + Entry::of(&evaluate(path, rel, opts.accuracy)) + }; + println!("{}", entry.line); + let mut r = results.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(slot) = r.get_mut(i) { + *slot = Some(entry); + } + }); + } + }); + let entries: Vec = results + .into_inner() + .unwrap_or_else(PoisonError::into_inner) + .into_iter() + .flatten() + .collect(); + + let summary = Summary::of(&entries); + summary.print(); + if let Some(path) = &opts.json { + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + let text = report_json(&opts, &summary, &entries); + std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))?; + println!("JSON: {}", path.display()); + } + Ok(()) +} + +// ---- files ---------------------------------------------------------------------------------- + +/// Every `.smb`/`.smp` file under `dir` (bounded depth and count), sorted by relative path, +/// as `(path, relative path)`. +fn model_files(dir: &Path, filter: Option<&str>) -> Vec<(PathBuf, String)> { + let mut out = Vec::new(); + let mut stack = vec![(dir.to_path_buf(), 0usize)]; + while let Some((d, depth)) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for entry in rd.flatten() { + let p = entry.path(); + if p.is_dir() { + if depth < MAX_DEPTH { + stack.push((p, depth + 1)); + } + continue; + } + let ext = p.extension().and_then(|x| x.to_str()).unwrap_or_default(); + if !(ext.eq_ignore_ascii_case("smb") || ext.eq_ignore_ascii_case("smp")) { + continue; + } + let rel = p.strip_prefix(dir).unwrap_or(&p).to_string_lossy(); + let rel = rel.replace('\\', "/"); + if filter.is_some_and(|f| !rel.contains(f)) { + continue; + } + out.push((p, rel)); + } + } + out.sort_by(|a, b| a.1.cmp(&b.1)); + out.truncate(MAX_FILES); + out +} + +// ---- one model ------------------------------------------------------------------------------ + +/// How a model ended. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Class { + Pass, + ReadFail, + CheckFail, + Unported { upstream: String, unit: String }, +} + +impl Class { + fn label(&self) -> &'static str { + match self { + Self::Pass => "pass", + Self::ReadFail => "read-fail", + Self::CheckFail => "check-fail", + Self::Unported { .. } => "unported", + } + } +} + +/// One named check of a model. +struct Check { + name: &'static str, + pass: bool, + message: String, +} + +/// Faces, edges, vertices, loops and face components. +#[derive(Clone, Copy)] +struct Counts { + faces: usize, + edges: usize, + vertices: usize, + loops: usize, + components: usize, +} + +/// What was measured of one B-rep. +struct Body { + kind: &'static str, + counts: Counts, + genus: Option, + volume: Option, + area: f64, + bbox: (Point3, Point3), +} + +/// Everything found out about one model. +struct Outcome { + file: String, + class: Class, + kind: &'static str, + stage: Option<&'static str>, + error: Option, + bodies: Vec, + checks: Vec, + /// `None` when not tried; `pass` or the first failure. + roundtrip: Option, + seconds: f64, +} + +impl Outcome { + /// Stops the model at `stage` on `e`: unported, or a read or check failure. + fn fail(&mut self, stage: &'static str, e: &SolidError) { + self.stage = Some(stage); + self.error = Some(format!("{e:?}")); + self.class = match e { + SolidError::Unported { upstream, unit } => Class::Unported { + upstream: (*upstream).to_string(), + unit: (*unit).to_string(), + }, + _ if stage == "read" => Class::ReadFail, + _ => Class::CheckFail, + }; + } +} + +fn evaluate(path: &Path, rel: &str, accuracy: f64) -> Outcome { + let start = Instant::now(); + let mut o = Outcome { + file: rel.to_string(), + class: Class::Pass, + kind: "unknown", + stage: None, + error: None, + bodies: Vec::new(), + checks: Vec::new(), + roundtrip: None, + seconds: 0.0, + }; + run(path, accuracy, &mut o); + o.seconds = start.elapsed().as_secs_f64(); + o +} + +/// Reads, measures and checks one model into `o`. +fn run(path: &Path, accuracy: f64, o: &mut Outcome) { + let bytes = match read_bytes(path) { + Ok(b) => b, + Err(msg) => { + o.stage = Some("read"); + o.error = Some(msg); + o.class = Class::ReadFail; + return; + } + }; + let mut k = Kernel::default(); + let mut read = match read_breps(&mut k, bytes) { + Ok(r) => r, + Err(e) => return o.fail("read", &e), + }; + let n_breps = read.breps.len(); + + for (i, b) in read.breps.iter_mut().enumerate() { + match measure(&mut k, b, accuracy) { + Ok(body) => o.bodies.push(body), + Err(e) => return o.fail("measure", &e), + } + if let Some(body) = o.bodies.last() { + let name = if n_breps > 1 { + format!("B-rep {i}: ") + } else { + String::new() + }; + check(body, &name, accuracy, &mut o.checks); + } + } + let mut rt = String::from("pass"); + let mut drift = Vec::new(); + for (i, body) in o.bodies.iter().enumerate() { + let name = if n_breps > 1 { + format!("B-rep {i}: ") + } else { + String::new() + }; + match round_trip(&mut k, &read, i, body, accuracy) { + Ok(None) => {} + Ok(Some(moved)) => drift.push(format!("{name}{moved}")), + Err(why) => { + rt = format!("{name}{why}"); + break; + } + } + } + if rt == "pass" && !drift.is_empty() { + rt = format!("{DRIFT}: {}", drift.join("; ")); + } + o.roundtrip = Some(rt); + o.kind = match o.bodies.split_first() { + None => "empty", + Some((first, rest)) if rest.iter().all(|b| b.kind == first.kind) => first.kind, + Some(_) => "mixed", + }; + o.checks.push(Check { + name: "breps", + pass: n_breps != 0, + message: if n_breps == 0 { + "no B-rep in the file".to_string() + } else { + String::new() + }, + }); + if o.checks.iter().any(|c| !c.pass) { + o.stage = Some("check"); + o.class = Class::CheckFail; + } +} + +/// The B-reps of a model file with their objects' records and attributes. +struct Read { + breps: Vec, + attributes: AttributeStore, + records: BTreeMap, +} + +/// The B-reps of a model file: one from a B-rep file (`SmBrep::ReadFromFile`), any +/// number from a part file (`SmBrepData::ReadPartFromFile`). +fn read_breps(k: &mut Kernel, bytes: Vec) -> solidcore::Result { + let mut records = BTreeMap::new(); + let mut attributes = AttributeStore::default(); + if bytes.starts_with(b"//[Output Summary]") { + let mut env = PartEnv { + attributes: &mut attributes, + key_of: &key_of, + records: &mut records, + next_key: GEOMETRY_KEYS, + }; + let breps = read_part_from_file(k, bytes, FileType::Ascii, &mut env)?.breps; + return Ok(Read { + breps, + attributes, + records, + }); + } + // a B-rep as `new SmBrep(ctx)` makes it, read as `SmBrep::ReadFromFile` does + let mut b = Brep::new_with_infinite_region()?; + let one = |e| key_of(0, e); + let mut env = TopologyEnv { + all_attributes: &[], + attributes: &mut attributes, + key_of: &one, + records: &mut records, + next_key: GEOMETRY_KEYS, + }; + read_from_file(k, &mut b, bytes, FileType::Ascii, false, &mut env)?; + Ok(Read { + breps: vec![b], + attributes, + records, + }) +} + +/// Writes B-rep `i` of `read` to an ASCII file and reads it back, then compares its +/// measures with `body`. `Err` is the failure (kind or counts); `Ok(Some(..))` the +/// area or volume drift beyond ten times `accuracy`, which upstream's writer makes too. +fn round_trip( + k: &mut Kernel, + read: &Read, + i: usize, + body: &Body, + accuracy: f64, +) -> Result, String> { + let describe = |e: SolidError| match e { + SolidError::Unported { upstream, .. } => format!("unported({upstream})"), + e => format!("{e:?}"), + }; + let b = read.breps.get(i).ok_or("no such B-rep")?; + let key = |e| key_of(i, e); + let env = FromBrepEnv { + attributes: &read.attributes, + key_of: &key, + records: &read.records, + curve_key: &|_: EdgeId| ObjectKey(0), + uv_curve_key: &|_: EdgeuseId| ObjectKey(0), + surface_key: &|_: FaceId| ObjectKey(0), + }; + let mut all = Vec::new(); + let mut data = BrepData::from_brep(b, &env, &mut all).map_err(describe)?; + let bytes = data + .write_to_file_with_nurbs(k, FileType::Ascii) + .map_err(describe)?; + let mut back = read_breps(k, bytes).map_err(|e| format!("read back: {}", describe(e)))?; + let b2 = back.breps.first_mut().ok_or("read back no B-rep")?; + let again = measure(k, b2, accuracy).map_err(|e| format!("measure: {}", describe(e)))?; + + let (c, d) = (body.counts, again.counts); + let counts = (c.faces, c.edges, c.vertices, c.loops); + let counts2 = (d.faces, d.edges, d.vertices, d.loops); + if body.kind != again.kind || counts != counts2 { + return Err(format!( + "{} F E V L {counts:?} read back as {} {counts2:?}", + body.kind, again.kind + )); + } + let tol = 10.0 * accuracy; + let close = |a: f64, b: f64| (a - b).abs() <= tol * a.abs().max(b.abs()); + let mut moved = Vec::new(); + if !close(body.area, again.area) { + moved.push(format!("area {} read back as {}", body.area, again.area)); + } + if let (Some(v), Some(w)) = (body.volume, again.volume) { + if !close(v, w) { + moved.push(format!("volume {v} read back as {w}")); + } + } + Ok((!moved.is_empty()).then(|| moved.join(", "))) +} + +fn read_bytes(path: &Path) -> Result, String> { + let len = std::fs::metadata(path) + .map_err(|e| format!("metadata: {e}"))? + .len(); + if len > MAX_FILE_BYTES { + return Err(format!( + "file of {len} bytes is over the {MAX_FILE_BYTES}-byte limit" + )); + } + std::fs::read(path).map_err(|e| format!("read: {e}")) +} + +/// First identity of the geometry the readers make (topology identities stay below). +const GEOMETRY_KEYS: u64 = 1 << 62; + +/// A distinct identity for every topology object of B-rep `brep` the readers build, +/// as the `topology.brep_p7` difftest runner gives them, with the B-rep index above. +fn key_of(brep: usize, e: EntityRef) -> ObjectRef { + let (type_code, kind, i) = match e { + EntityRef::Brep(x) => (16011, 0, x.index()), + EntityRef::Region(x) => (16012, 1, x.index()), + EntityRef::Shell(x) => (16013, 2, x.index()), + EntityRef::Faceuse(x) => (16022, 3, x.index()), + EntityRef::Face(x) => (16002, 4, x.index()), + EntityRef::Loopuse(x) => (16004, 5, x.index()), + EntityRef::Loop(x) => (16003, 6, x.index()), + EntityRef::Edgeuse(x) => (16005, 7, x.index()), + EntityRef::Edge(x) => (16016, 8, x.index()), + EntityRef::Vertexuse(x) => (16006, 9, x.index()), + EntityRef::Vertex(x) => (16021, 10, x.index()), + }; + let brep = (brep as u64 + 1) << 36; + ObjectRef { + type_code, + key: ObjectKey(brep | ((kind + 1) << 32) | u64::from(i)), + } +} + +/// Measures one B-rep: counts, kind, bounding box, area and (solids) volume. +fn measure(k: &mut Kernel, b: &mut Brep, accuracy: f64) -> solidcore::Result { + let faces = bl::faces(b, None)?; + let edges = bl::edges(b, None)?; + let vertices = bl::vertices(b, None)?; + let mut loops = 0; + for f in &faces { + loops += fl::loops(b, *f, false, None)?.len(); + } + let counts = Counts { + faces: faces.len(), + edges: edges.len(), + vertices: vertices.len(), + loops, + components: components(b, &faces)?, + }; + + let kind = if is_manifold_solid(b)? { + "solid" + } else if is_sheet(k, b)? { + "sheet" + } else { + "other" + }; + + let opts = BoundingBoxOptions { + approx: false, + tight: true, + expand_by_zone_tol_3d: false, + }; + let ext = calculate_bounding_box(k, &*b, opts)?; + let bbox = ( + Point3::new(ext.min_at(0), ext.min_at(1), ext.min_at(2)), + Point3::new(ext.max_at(0), ext.max_at(1), ext.max_at(2)), + ); + + let area = integrated_area(k, b, &faces, accuracy)?; + + let mut volume = None; + if kind == "solid" { + let store = AttributeStore::new(); + let key = |_| ObjectKey(0); + let attributes = PropertyAttributes { + store: &store, + key_of: &key, + }; + volume = Some(brep_volume(k, b, accuracy, &attributes)?); + } + + // V - E + F - (L - F) = 2 (S - G) + let c = counts; + let chi = c.vertices as i64 - c.edges as i64 + 2 * c.faces as i64 - c.loops as i64; + let genus = if chi.rem_euclid(2) == 0 { + Some(c.components as i64 - chi.div_euclid(2)) + } else { + None + }; + Ok(Body { + kind, + counts, + genus, + volume, + area, + bbox, + }) +} + +/// The surface area at `accuracy`: `SmApiBrepComputeArea`'s per-face integration +/// (`SmApiFaceComputeArea`: face box midpoint as origin, area estimate from the box, area +/// only) without that entry point's `[1e-4, 1e-1]` accuracy clamp, as the `primitives` +/// example's checks integrate it. +fn integrated_area( + k: &Kernel, + b: &mut Brep, + faces: &[FaceId], + accuracy: f64, +) -> solidcore::Result { + let mut area = 0.0; + for f in faces { + let bbox = solidcore::query::face::p1::calculate_bounding_box(k, &*b, *f, false)?; + let s = bbox.size(); + let d = s.x.max(s.y).max(s.z); + let p = face_properties( + k, + b, + *f, + OrientType::Same, + accuracy, + bbox.mid(), + d * d / 100.0, + 0.0, + PPF_AREA, + )?; + area += p.area.abs(); + } + Ok(area) +} + +/// The number of connected components of `faces`, two faces being connected when they +/// share an edge. +fn components(b: &Brep, faces: &[FaceId]) -> solidcore::Result { + let index: BTreeMap = faces.iter().enumerate().map(|(i, f)| (*f, i)).collect(); + let mut parent: Vec = (0..faces.len()).collect(); + for e in bl::edges(b, None)? { + let on = ed::faces(b, e, None)?; + let mut ids = on.iter().filter_map(|f| index.get(f).copied()); + if let Some(first) = ids.next() { + for other in ids { + let (x, y) = (root(&mut parent, first), root(&mut parent, other)); + if let Some(slot) = parent.get_mut(x.max(y)) { + *slot = x.min(y); + } + } + } + } + Ok((0..faces.len()) + .filter(|&i| root(&mut parent, i) == i) + .count()) +} + +/// The union-find root of `i` (path halving). +fn root(parent: &mut [usize], mut i: usize) -> usize { + loop { + let p = parent.get(i).copied().unwrap_or(i); + if p == i { + return i; + } + let gp = parent.get(p).copied().unwrap_or(p); + if let Some(slot) = parent.get_mut(i) { + *slot = gp; + } + i = gp; + } +} + +/// Runs the checks of one measured B-rep (`name` prefixes the messages). +fn check(body: &Body, name: &str, accuracy: f64, checks: &mut Vec) { + let c = body.counts; + checks.push(Check { + name: "faces", + pass: c.faces > 0, + message: if c.faces > 0 { + String::new() + } else { + format!("{name}no faces") + }, + }); + let area = body.area; + let area_ok = area.is_finite() && area > 0.0; + checks.push(Check { + name: "area", + pass: area_ok, + message: if area_ok { + String::new() + } else { + format!("{name}area {area} is not positive and finite") + }, + }); + if body.kind != "solid" { + return; + } + + let euler_ok = body.genus.is_some_and(|g| g >= 0); + checks.push(Check { + name: "euler", + pass: euler_ok, + message: if euler_ok { + String::new() + } else { + let (v, e, f, l, s) = (c.vertices, c.edges, c.faces, c.loops, c.components); + format!("{name}V {v} E {e} F {f} L {l} with {s} components: no genus >= 0") + }, + }); + + let volume = body.volume.unwrap_or(f64::NAN); + let (lo, hi) = body.bbox; + let box_volume = (hi.x - lo.x) * (hi.y - lo.y) * (hi.z - lo.z); + // the integration is accurate to about ten times the requested accuracy + let volume_ok = + volume.is_finite() && volume > 0.0 && volume <= box_volume * (1.0 + 10.0 * accuracy); + checks.push(Check { + name: "volume", + pass: volume_ok, + message: if volume_ok { + String::new() + } else { + format!("{name}volume {volume} is not in (0, bounding-box volume {box_volume}]") + }, + }); +} + +// ---- isolation ------------------------------------------------------------------------------ + +/// A finished model: its class, kind, blocker, report line and JSON object. +struct Entry { + file: String, + class: String, + kind: String, + blocker: Option<(String, String)>, + /// The round trip: `None` when not tried, else whether it passed. + roundtrip: Option, + /// A passed round trip moved the area or volume (upstream-writer drift). + roundtrip_drift: bool, + line: String, + json: String, +} + +impl Entry { + fn of(o: &Outcome) -> Self { + let blocker = match &o.class { + Class::Unported { upstream, unit } => Some((upstream.clone(), unit.clone())), + _ => None, + }; + let mut detail = String::new(); + if o.bodies.len() > 1 { + let _ = write!(detail, " {} B-reps:", o.bodies.len()); + } + for b in &o.bodies { + let c = b.counts; + let (f, e, v, l) = (c.faces, c.edges, c.vertices, c.loops); + if o.bodies.len() > 1 { + let _ = write!(detail, " [{}", b.kind); + } + let _ = write!(detail, " F {f} E {e} V {v} L {l}"); + if let Some(g) = b.genus { + let _ = write!(detail, " genus {g}"); + } + if let Some(v) = b.volume { + let _ = write!(detail, " volume {v:.9e}"); + } + let _ = write!(detail, " area {:.9e}", b.area); + if o.bodies.len() > 1 { + detail.push(']'); + } + } + if let Some((upstream, unit)) = &blocker { + let _ = write!(detail, " {upstream} ({unit})"); + } else if let Some(e) = &o.error { + let _ = write!(detail, " {}", short(e, 160)); + } + for c in o.checks.iter().filter(|c| !c.pass) { + let _ = write!(detail, " [{}: {}]", c.name, c.message); + } + if let Some(rt) = &o.roundtrip { + let _ = write!(detail, " roundtrip: {}", short(rt, 160)); + } + let (class, kind, file, secs) = (o.class.label(), o.kind, &o.file, o.seconds); + Self { + file: o.file.clone(), + class: class.to_string(), + kind: kind.to_string(), + blocker, + roundtrip: o + .roundtrip + .as_ref() + .map(|rt| rt == "pass" || rt.starts_with(DRIFT)), + roundtrip_drift: o.roundtrip.as_ref().is_some_and(|rt| rt.starts_with(DRIFT)), + line: format!("{class:<10} {kind:<7} {file}{detail} ({secs:.2}s)"), + json: outcome_json(o), + } + } + + /// A model whose child process did not report. + fn crash(rel: &str, error: &str, seconds: f64) -> Self { + let line = format!( + "crash unknown {rel} {} ({seconds:.2}s)", + short(error, 300) + ); + let (file, error, seconds) = (string(rel), string(error), num(seconds)); + let json = format!( + "{{\"file\": {file}, \"class\": \"crash\", \"kind\": \"unknown\", \ + \"stage\": \"run\", \"blocker\": null, \"error\": {error}, \"breps\": [], \ + \"checks\": [], \"roundtrip\": null, \"seconds\": {seconds}}}" + ); + Self { + file: rel.to_string(), + class: "crash".to_string(), + kind: "unknown".to_string(), + blocker: None, + roundtrip: None, + roundtrip_drift: false, + line, + json, + } + } +} + +fn short(s: &str, n: usize) -> String { + if s.chars().count() <= n { + s.to_string() + } else { + let mut t: String = s.chars().take(n).collect(); + t.push_str("..."); + t + } +} + +/// Runs one model in a child process (`--one`), bounded by `--timeout`. +fn run_isolated(exe: &Path, path: &Path, rel: &str, opts: &Opts) -> Entry { + let start = Instant::now(); + let child = Command::new(exe) + .arg("--one") + .arg(path) + .arg("--rel") + .arg(rel) + .arg("--accuracy") + .arg(format!("{:e}", opts.accuracy)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => return Entry::crash(rel, &format!("spawn: {e}"), 0.0), + }; + let out = drain(child.stdout.take()); + let err = drain(child.stderr.take()); + let status = loop { + match child.try_wait() { + Ok(Some(st)) => break Ok(st), + Ok(None) if start.elapsed() >= opts.timeout => { + let _ = child.kill(); + let _ = child.wait(); + break Err(format!("timeout after {} s", opts.timeout.as_secs())); + } + Ok(None) => thread::sleep(Duration::from_millis(20)), + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + break Err(format!("wait: {e}")); + } + } + }; + let stdout = out.join().unwrap_or_default(); + let stdout = String::from_utf8_lossy(&stdout); + let stderr = err.join().unwrap_or_default(); + let stderr = String::from_utf8_lossy(&stderr); + let seconds = start.elapsed().as_secs_f64(); + let reported = parse_child(&stdout, rel); + match (status, reported) { + (Ok(st), Some(entry)) if st.success() => entry, + (status, _) => { + let why = match status { + Ok(st) => format!("child exited with {st}"), + Err(e) => e, + }; + let tail: String = { + let t = stderr.trim(); + let skip = t.chars().count().saturating_sub(STDERR_TAIL); + t.chars().skip(skip).collect() + }; + let error = if tail.is_empty() { + why + } else { + format!("{why}: {tail}") + }; + Entry::crash(rel, &error, seconds) + } + } +} + +/// Reads all of a child's pipe on its own thread (so a full pipe cannot stall the child). +fn drain(pipe: Option) -> JoinHandle> { + thread::spawn(move || { + let mut v = Vec::new(); + if let Some(mut p) = pipe { + let _ = p.read_to_end(&mut v); + } + v + }) +} + +/// The entry a `--one` child printed for the model at `rel`. +fn parse_child(stdout: &str, rel: &str) -> Option { + let mut head = None; + let mut line = None; + let mut json = None; + for l in stdout.lines() { + if let Some(rest) = l.strip_prefix("CORPUS-CLASS\t") { + head = Some(rest); + } else if let Some(rest) = l.strip_prefix("CORPUS-LINE\t") { + line = Some(rest); + } else if let Some(rest) = l.strip_prefix("CORPUS-JSON\t") { + json = Some(rest); + } + } + let mut f = head?.split('\t'); + let class = f.next()?.to_string(); + let kind = f.next()?.to_string(); + let upstream = f.next().unwrap_or_default(); + let unit = f.next().unwrap_or_default(); + let rt = f.next(); + let roundtrip = match rt { + Some("pass" | "drift") => Some(true), + Some("fail") => Some(false), + _ => None, + }; + let blocker = (!upstream.is_empty()).then(|| (upstream.to_string(), unit.to_string())); + Some(Entry { + file: rel.to_string(), + class, + kind, + blocker, + roundtrip, + roundtrip_drift: rt == Some("drift"), + line: line?.to_string(), + json: json?.to_string(), + }) +} + +// ---- summary and JSON ----------------------------------------------------------------------- + +/// Totals over all models. +struct Summary { + total: usize, + by_class: BTreeMap, + /// kind -> (total, pass) + kinds: BTreeMap, + /// (upstream, unit) -> files, most files first + blockers: Vec<((String, String), Vec)>, + /// Models whose round trip was tried, passed, and passed with upstream-writer drift. + roundtrip: (usize, usize, usize), +} + +impl Summary { + fn of(entries: &[Entry]) -> Self { + let mut by_class = BTreeMap::new(); + let mut kinds: BTreeMap = BTreeMap::new(); + let mut blockers: BTreeMap<(String, String), Vec> = BTreeMap::new(); + let mut roundtrip = (0, 0, 0); + for e in entries { + *by_class.entry(e.class.clone()).or_insert(0) += 1; + let k = kinds.entry(e.kind.clone()).or_insert((0, 0)); + k.0 += 1; + if e.class == "pass" { + k.1 += 1; + } + if let Some(b) = &e.blocker { + blockers.entry(b.clone()).or_default().push(e.file.clone()); + } + if let Some(pass) = e.roundtrip { + roundtrip.0 += 1; + roundtrip.1 += usize::from(pass); + roundtrip.2 += usize::from(e.roundtrip_drift); + } + } + let mut blockers: Vec<_> = blockers.into_iter().collect(); + blockers.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0))); + Self { + total: entries.len(), + by_class, + kinds, + blockers, + roundtrip, + } + } + + fn count(&self, class: &str) -> usize { + self.by_class.get(class).copied().unwrap_or(0) + } + + fn print(&self) { + if !self.blockers.is_empty() { + println!("blockers (unported symbol, models stopped):"); + for ((upstream, unit), files) in &self.blockers { + println!(" {:>4} {upstream} ({unit})", files.len()); + } + } + let kinds: Vec = self + .kinds + .iter() + .map(|(k, (t, p))| format!("{k} {p}/{t}")) + .collect(); + println!( + "corpus: {}/{} pass ({}; read-fail {}, check-fail {}, unported {}, crash {})", + self.count("pass"), + self.total, + kinds.join(", "), + self.count("read-fail"), + self.count("check-fail"), + self.count("unported"), + self.count("crash") + ); + let (tried, pass, drift) = self.roundtrip; + println!( + "roundtrip: {pass}/{tried} pass, {drift} with upstream-writer drift \ + (FromBrep, WriteToFile, ReadFromFile)" + ); + } +} + +fn num(x: f64) -> String { + if x.is_finite() { + format!("{x:?}") + } else { + "null".to_string() + } +} + +fn string(s: &str) -> String { + let mut o = String::from("\""); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + c if (c as u32) < 0x20 => { + let _ = write!(o, "\\u{:04x}", c as u32); + } + c => o.push(c), + } + } + o.push('"'); + o +} + +fn opt(v: Option, f: impl Fn(T) -> String) -> String { + v.map_or_else(|| "null".to_string(), f) +} + +fn point(p: Point3) -> String { + let (x, y, z) = (num(p.x), num(p.y), num(p.z)); + format!("[{x}, {y}, {z}]") +} + +fn counts_json(c: Counts) -> String { + let (f, e, v, l, s) = (c.faces, c.edges, c.vertices, c.loops, c.components); + format!( + "{{\"faces\": {f}, \"edges\": {e}, \"vertices\": {v}, \"loops\": {l}, \ + \"components\": {s}}}" + ) +} + +fn bbox_json((lo, hi): (Point3, Point3)) -> String { + let (lo, hi) = (point(lo), point(hi)); + format!("{{\"min\": {lo}, \"max\": {hi}}}") +} + +fn check_json(c: &Check) -> String { + let (name, pass, message) = (string(c.name), c.pass, string(&c.message)); + format!("{{\"name\": {name}, \"pass\": {pass}, \"message\": {message}}}") +} + +fn blocker_json(upstream: &str, unit: &str) -> String { + let (upstream, unit) = (string(upstream), string(unit)); + format!("{{\"upstream\": {upstream}, \"unit\": {unit}}}") +} + +fn body_json(b: &Body) -> String { + let kind = string(b.kind); + let counts = counts_json(b.counts); + let genus = opt(b.genus, |g| format!("{g}")); + let volume = opt(b.volume, num); + let area = num(b.area); + let bbox = bbox_json(b.bbox); + format!( + "{{\"kind\": {kind}, \"counts\": {counts}, \"genus\": {genus}, \"volume\": {volume}, \ + \"area\": {area}, \"bbox\": {bbox}}}" + ) +} + +fn outcome_json(o: &Outcome) -> String { + let blocker = match &o.class { + Class::Unported { upstream, unit } => blocker_json(upstream, unit), + _ => "null".to_string(), + }; + let file = string(&o.file); + let class = string(o.class.label()); + let kind = string(o.kind); + let stage = opt(o.stage, string); + let error = opt(o.error.as_deref(), string); + let breps: Vec = o.bodies.iter().map(body_json).collect(); + let breps = breps.join(", "); + let checks: Vec = o.checks.iter().map(check_json).collect(); + let checks = checks.join(", "); + let roundtrip = opt(o.roundtrip.as_deref(), string); + let seconds = num(o.seconds); + format!( + "{{\"file\": {file}, \"class\": {class}, \"kind\": {kind}, \"stage\": {stage}, \ + \"blocker\": {blocker}, \"error\": {error}, \"breps\": [{breps}], \ + \"checks\": [{checks}], \"roundtrip\": {roundtrip}, \"seconds\": {seconds}}}" + ) +} + +fn report_json(opts: &Opts, s: &Summary, entries: &[Entry]) -> String { + let mut kinds = Vec::new(); + for (k, (total, pass)) in &s.kinds { + let k = string(k); + kinds.push(format!("{k}: {{\"total\": {total}, \"pass\": {pass}}}")); + } + let mut blockers = Vec::new(); + for ((upstream, unit), files) in &s.blockers { + let (upstream, unit, count) = (string(upstream), string(unit), files.len()); + let files: Vec = files.iter().map(|f| string(f)).collect(); + let files = files.join(", "); + blockers.push(format!( + "{{\"upstream\": {upstream}, \"unit\": {unit}, \"count\": {count}, \ + \"files\": [{files}]}}" + )); + } + let models: Vec<&str> = entries.iter().map(|e| e.json.as_str()).collect(); + let dir = string(&opts.dir.display().to_string()); + let accuracy = num(opts.accuracy); + let roundtrip = string(ROUNDTRIP); + let total = s.total; + let pass = s.count("pass"); + let read_fail = s.count("read-fail"); + let check_fail = s.count("check-fail"); + let unported = s.count("unported"); + let crash = s.count("crash"); + let (rt_tried, rt_pass, rt_drift) = s.roundtrip; + let kinds = kinds.join(", "); + let blockers = blockers.join(",\n"); + let models = models.join(",\n"); + format!( + "{{\"schema\": 1, \"source\": \"crates/solidcore/examples/corpus\", \"dir\": {dir}, \ + \"accuracy\": {accuracy}, \"roundtrip\": {roundtrip},\n\"summary\": {{\"total\": {total}, \ + \"pass\": {pass}, \"read_fail\": {read_fail}, \"check_fail\": {check_fail}, \ + \"unported\": {unported}, \"crash\": {crash}, \ + \"roundtrip\": {{\"tried\": {rt_tried}, \"pass\": {rt_pass}, \"drift\": {rt_drift}}}, \ + \"kinds\": {{{kinds}}}}},\n\ + \"blockers\": [{blockers}],\n\"models\": [\n{models}\n]}}\n" + ) +} diff --git a/crates/solidcore/src/curve/ellipse/p1.rs b/crates/solidcore/src/curve/ellipse/p1.rs index c6e4e1e59a..7caecaaac7 100644 --- a/crates/solidcore/src/curve/ellipse/p1.rs +++ b/crates/solidcore/src/curve/ellipse/p1.rs @@ -25,6 +25,7 @@ //! | [`Ellipse::create_simple_offset`] | `SmCircle::CreateSimpleOffset` | //! | [`Ellipse::assert_valid`] | `SmEllipse::AssertValid`, `SmCircle::AssertValid` | //! | [`Ellipse::read_from_db`] | `SmEllipse::ReadFromDB`, `SmCircle::ReadFromDB` | +//! | [`Ellipse::write_to_db`] | `SmEllipse::WriteToDB`, `SmCircle::WriteToDB` | //! | [`Ellipse::convert_to_2d`] | `SmEllipse::ConvertTo2D` | //! | [`Ellipse::convert_t_from_nurbs_to_step`] | `SmEllipse::ConvertTFromNURBSToSTEP` | //! | [`Ellipse::adjust_step_interval`] | `SmEllipse::AdjustSTEPInterval` | @@ -40,6 +41,7 @@ //! | [`PolarConversion::is_current`] | `SmPolarConversion::IsCurrent` | //! | [`PolarConversion::assert_valid`] | `SmPolarConversion::AssertValid` | //! | [`PolarConversion::read_from_db`], [`PolarConversion::read_from_db_with_curve`] | `SmPolarConversion::ReadFromDB` (ellipse back pointer; null back pointer, from `SmSurfOfRevolution::ReadFromDB`) | +//! | [`PolarConversion::write_to_db`] | `SmPolarConversion::WriteToDB` | //! //! Deviations: //! - **Circle class.** `SmCircle` derives from `SmEllipse` without data of its own; @@ -116,7 +118,9 @@ use crate::nurbs::crv_basic::ConicType; use crate::nurbs::crv_common::{elliptical_arc_tol, EllipseArc}; use crate::nurbs::index::at; use crate::nurbs::nurbs_crv::to_euclid; -use crate::topology::database_io::{DatabaseReader, FileType, InputStream}; +use crate::topology::database_io::{ + fixed16, DatabaseReader, DatabaseWriter, FileType, InputStream, +}; use super::p2::{Ellipse, EllipseOptions, PolarConversion}; @@ -565,6 +569,101 @@ impl PolarConversion { Ok((pc, curve)) } + /// `WriteToDB`: writes the set-up flag, the knot, angle and exactness counts and + /// whether the table owns a copy of its curve (`owned_curve`; the ellipse's own + /// table borrows it and passes `None`), then the three arrays (ASCII: a line + /// break after the elements with index 5, 10, ... and after a partial last line), + /// then the owned curve (`m_cpCurve`, any curve kind) with its type header, as + /// a curve nested `depth` levels deep. + /// + /// # Errors + /// + /// Those of [`crate::topology::geometry_write::write_curve_at`] for the owned curve. + pub fn write_to_db( + &self, + db: &mut DatabaseWriter, + version: u64, + owned_curve: Option<&Curve>, + depth: usize, + ) -> Result<()> { + let ascii = db.file_type() == FileType::Ascii; + let n_knots = self.natural_knots.len() as u64; + let n_angles = self.angles.len() as u64; + let n_exact = self.exact_conversion.len() as u64; + let has_curve = i32::from(owned_curve.is_some()); + let possible = i32::from(self.possible); + + if ascii { + db.out_i64(possible.into()); + db.out_str( + " SmPolarConversion PolarConversionPossible - TRUE=tables are initialized \n", + ); + db.out_u64(n_knots); + db.out_str(" SmPolarConversion Number of Natural Knots \n"); + db.out_u64(n_angles); + db.out_str(" SmPolarConversion Number of Angles \n"); + db.out_u64(n_exact); + db.out_str(" SmPolarConversion Number of ExactConversion Flags \n"); + db.out_i64(has_curve.into()); + db.out_str( + " SmPolarConversion TRUE = Has Curve for stored conversions, \ + FALSE = back pointer \n", + ); + } else { + db.write_boolean(possible); + db.write_long(n_knots as i64); + db.write_long(n_angles as i64); + db.write_long(n_exact as i64); + db.write_boolean(has_curve); + } + + // Write Arrays + if ascii { + let rows = |db: &mut DatabaseWriter, values: &mut dyn Iterator| { + let mut n = 0usize; + for (ii, v) in values.enumerate() { + db.out_str(&v).out_str(" "); + if ii > 0 && ii % 5 == 0 { + db.out_str("\n"); + } + n += 1; + } + if n > 0 && n % 5 != 0 { + db.out_str("\n"); + } + }; + let exact = &self.exact_conversion; + let mut flags = exact.iter().map(|&b| i32::from(b).to_string()); + rows(db, &mut self.natural_knots.iter().map(|&x| fixed16(x))); + rows(db, &mut self.angles.iter().map(|&x| fixed16(x))); + rows(db, &mut flags); + } else { + if n_knots > 0 { + db.write_doubles(&self.natural_knots); + } + if n_angles > 0 { + db.write_doubles(&self.angles); + } + if n_exact > 0 { + let exact = &self.exact_conversion; + let flags: Vec = exact.iter().map(|&b| i32::from(b)).collect(); + db.write_booleans(&flags); + } + } + + // write out the curve the table owns (the ellipse's own table borrows it) + if let Some(c) = owned_curve { + if ascii { + db.out_str(" SmPolarConversion->CurveBeingConverted \n"); + } + db.write_type(c.type_code(), Some(c.dim())); + crate::topology::geometry_write::write_curve_at(c, db, version, depth)?; + } else if ascii { + db.out_str("Has No Copy of SmPolarConversion->CurveBeingConverted \n"); + } + Ok(()) + } + /// The end of a read whose table has no stored curve: the back pointer /// (`has_back_pointer`: the ellipse being read; else null), then the ASCII line /// break. @@ -1242,6 +1341,53 @@ impl Ellipse { Ok(e) } + /// `WriteToDB` (`SmCircle::WriteToDB` is the same): writes the placement origin + /// and axes, the angular domain (degrees), the two radii and the inside-out flag, + /// then the polar converter (which borrows this ellipse) and the B-spline curve + /// part. + /// + /// # Errors + /// + /// Those of [`PolarConversion::write_to_db`]; `SM_ERR` when the B-spline part has + /// no NURB (upstream ignores the part's status; its virtual + /// `GetOrCreateGwNurbPointer` always has one to write). + pub fn write_to_db(&self, db: &mut DatabaseWriter, version: u64) -> Result<()> { + let o = self.position.origin(); + let x = self.position.x_axis(); + let y = self.position.y_axis(); + let (lo, hi) = (self.anal_domain.min(), self.anal_domain.max()); + let inside_out = i32::from(self.inside_out); + let ascii = db.file_type() == FileType::Ascii; + if ascii { + db.out_reals(&[o.x, o.y, o.z]); + db.out_str(" SmEllipse Axis Origin \n"); + db.out_reals(&[x.x, x.y, x.z]); + db.out_str(" SmEllipse X Axis\n"); + db.out_reals(&[y.x, y.y, y.z]); + db.out_str(" SmEllipse Y Axis\n"); + db.out_reals(&[lo, hi]); + db.out_str(" SmEllipse angular arc domain in degrees \n"); + db.out_f64(self.radius_x); + db.out_str(" SmEllipse X_Axis radius \n"); + db.out_f64(self.radius_y); + db.out_str(" SmEllipse Y_Axis radius \n"); + db.out_i64(inside_out.into()); + db.out_str(" SmEllipse InsideOut Flag \n"); + } else { + db.write_doubles(&[o.x, o.y, o.z, x.x, x.y, x.z, y.x, y.y, y.z]); + db.write_doubles(&[lo, hi, self.radius_x, self.radius_y]); + db.write_boolean(inside_out); + } + + // PolarConverter + if ascii { + db.out_str(" SmEllipse->PolarConverter \n"); + } + self.polar.write_to_db(db, version, None, 0)?; + + self.base.write_to_db(db, version) + } + // ---- conversions ------------------------------------------------------------------ /// Projects the ellipse onto the x-y plane (`ConvertTo2D`): the NURB is made 2D diff --git a/crates/solidcore/src/curve/line.rs b/crates/solidcore/src/curve/line.rs index e8f864d33e..360c855926 100644 --- a/crates/solidcore/src/curve/line.rs +++ b/crates/solidcore/src/curve/line.rs @@ -51,6 +51,7 @@ //! | [`Line::trim`] | `Trim` | //! | [`Line::assert_valid`] | `AssertValid` | //! | [`Line::read_from_db`] | `ReadFromDB` | +//! | [`Line::write_to_db`] | `WriteToDB` | //! | [`Line::is_kind_of`], [`Line::TYPE`], [`Line::type_string`], [`Line::class_string`] | `IsKindOf`, `GetType`, `GetClassType`, `GetTypeString`, `GetClassString` (`SM_COMMON`) | //! | [`Line::step_interval`], [`Line::max_analytic_domain`], [`Line::degree`], [`Line::number_natural_knots`] | `GetSTEPInterval`, `GetMaxAnalyticDomain`, `GetDegree`, `GetNumberNaturalKnots` (inline) | //! | [`Line::line_point`], [`Line::line_vector`], [`Line::line_scale`], [`Line::inside_out`] | `GetLinePoint`, `GetLineVector`, `GetLineScale`, `GetInsideOut` (inline) | @@ -139,7 +140,7 @@ use crate::nurbs::b_spline_curve_n_lib::{InValueType, TrimMode}; use crate::nurbs::b_spline_surface::p1::SurfParamType; use crate::nurbs::math::NOW; use crate::nurbs::nurbs_crv::allocate_nurb_curve; -use crate::topology::database_io::{DatabaseReader, FileType, InputStream}; +use crate::topology::database_io::{DatabaseReader, DatabaseWriter, FileType, InputStream}; const OP_SEGMENT: &str = "curve::line::Line::create_line_segment"; const OP_OFFSET: &str = "curve::line::Line::create_simple_offset"; @@ -2167,6 +2168,33 @@ impl Line { } Ok(line) } + + /// `WriteToDB`: writes the line point, vector, analytic domain, scale and + /// inside-out flag, then the B-spline curve part. + /// + /// # Errors + /// + /// `SM_ERR` when the B-spline part has no NURB (upstream ignores the part's + /// status; its virtual `GetOrCreateGwNurbPointer` always has one to write). + pub fn write_to_db(&self, db: &mut DatabaseWriter, version: u64) -> Result<()> { + let (p, v) = (self.point, self.vector); + let (lo, hi) = (self.anal_domain.min(), self.anal_domain.max()); + let inside_out = i32::from(self.inside_out); + if db.file_type() == FileType::Ascii { + db.out_reals(&[p.x, p.y, p.z]); + db.out_str(" SmLine Line Point \n"); + db.out_reals(&[v.x, v.y, v.z]); + db.out_str(" SmLine Line Vector \n"); + db.out_reals(&[lo, hi]).out_str(" SmLine Line Domain \n"); + db.out_f64(self.scale).out_str(" SmLine Line Scale \n"); + db.out_i64(inside_out.into()); + db.out_str(" SmLine InsideOut Flag \n"); + } else { + db.write_doubles(&[p.x, p.y, p.z, v.x, v.y, v.z, lo, hi, self.scale]); + db.write_boolean(inside_out); + } + self.base.write_to_db(db, version) + } } /// The `SM_ASSERT_VALUE_REPORT` macro of `AssertValid` (an `SM_LEVEL_0` test, so the diff --git a/crates/solidcore/src/nurbs/b_spline_curve/p2.rs b/crates/solidcore/src/nurbs/b_spline_curve/p2.rs index f48977dd26..ceb44e73ef 100644 --- a/crates/solidcore/src/nurbs/b_spline_curve/p2.rs +++ b/crates/solidcore/src/nurbs/b_spline_curve/p2.rs @@ -30,6 +30,7 @@ //! | [`BSplineCurve::set_control_point`] | `SetControlPoint` | //! | [`BSplineCurve::adjust_step_interval`] | `AdjustSTEPInterval` | //! | [`BSplineCurve::read_from_db`] | `ReadFromDB` | +//! | [`BSplineCurve::write_to_db`] | `WriteToDB` | //! //! The other members of the file belong to `nurbs.b_spline_curve_p1`, `_p3`, ... and //! `nurbs.b_spline_curve_n_lib`. @@ -110,7 +111,7 @@ use crate::nurbs::geom::dist_cpt_cpt; use crate::nurbs::knots::Side; use crate::nurbs::math::{CPoint, NOW, NOZ}; use crate::nurbs::nurbs_crv::{allocate_nurb_curve, is_ncrv_degenerate, to_euclid}; -use crate::topology::database_io::{DatabaseReader, FileType, InputStream}; +use crate::topology::database_io::{DatabaseReader, DatabaseWriter, FileType, InputStream}; /// `SmCrvOnSurf_TYPE` (`CURV_BASE_TYPE + 48`). const CRV_ON_SURF_TYPE: i64 = BSplineCurve::TYPE - 33 + 48; @@ -1933,6 +1934,91 @@ impl BSplineCurve { } Ok(new_curve) } + + /// `WriteToDB`: writes the NURB's header (last control point index, degree, + /// rational flag and dimension, then for `version > 32` the knot type and form), + /// its control points (`x y`, `x y z`, `wx wy w` or `wx wy wz w`: Euclidean for a + /// non-rational NURB, weighted for a rational one) and its knots. + /// + /// # Errors + /// + /// `SM_ERR` without NURB (`GetOrCreateGwNurbPointer` cannot make one for a + /// B-spline; the analytic kinds always hold theirs). + pub fn write_to_db(&self, db: &mut DatabaseWriter, version: u64) -> Result<()> { + let cur = self.nurb_ref()?; + let n = cur.n() as i64; + let p = cur.degree() as i16; + let knot_type = self.knot_type as u64; + let form = self.form as u64; + + // set type flag for different types of output + let rat: i16 = i16::from(cur.is_rational()); + let dim: i16 = if cur.is_3d() { 3 } else { 2 }; + + // output header, n, p, rat, and dim + let ascii = db.file_type() == FileType::Ascii; + if ascii { + db.out_i64(n).out_str(" Number of Control Points \n"); + db.out_i64(p.into()).out_str(" Degree \n"); + db.out_i64(rat.into()).out_str(" RationalFlag \n"); + db.out_i64(dim.into()).out_str(" Dimension \n"); + if version > 32 { + db.out_u64(knot_type); + db.out_str( + " Knot Type Enum: 1=2d NonRational, 2=3d NonRational, 3=2d Rational, \ + 4=3d Rational\n", + ); + db.out_u64(form).out_str(" BSplineCurveForm Enum\n"); + } + } else { + db.write_long(n); + db.write_short(p); + db.write_short(rat); + db.write_short(dim); + if version > 32 { + db.write_long(knot_type as i64); + db.write_long(form as i64); + } + } + + // for every control point, by curve type + for pw in cur.cpts() { + let [x, y, z] = cpt_to_xyz(*pw); + let values = match (rat == 1, dim == 3) { + // 2-D non-rational + (false, false) => [x, y, 0.0, 0.0], + // 3-D non-rational + (false, true) => [x, y, z, 0.0], + // 2-D rational + (true, false) => [pw.x, pw.y, pw.w, 0.0], + // 3-D rational + (true, true) => [pw.x, pw.y, pw.z, pw.w], + }; + let count = 2 + usize::from(dim == 3) + usize::from(rat == 1); + db.reals(values.get(..count).unwrap_or_default()); + } + + // for every knot + for &u in cur.knots() { + db.reals(&[u]); + } + Ok(()) + } +} + +/// `N_CPtToXYZ`: the Euclidean coordinates of a control point (`z` of a 2D point is +/// `0`; a weighted point is divided by its weight). +fn cpt_to_xyz(pw: CPoint) -> [f64; 3] { + let mut x = pw.x; + let mut y = pw.y; + let mut z = if pw.z == NOZ { 0.0 } else { pw.z }; + if pw.w != NOW { + let w = pw.w; + x /= w; + y /= w; + z /= w; + } + [x, y, z] } #[cfg(test)] diff --git a/crates/solidcore/src/nurbs/b_spline_surface/p1.rs b/crates/solidcore/src/nurbs/b_spline_surface/p1.rs index 9018d571f3..2b69ef70c6 100644 --- a/crates/solidcore/src/nurbs/b_spline_surface/p1.rs +++ b/crates/solidcore/src/nurbs/b_spline_surface/p1.rs @@ -40,6 +40,7 @@ //! | [`BSplineSurface::transform`] | `Transform` | //! | [`BSplineSurface::is_kind_of`] | `IsKindOf` | //! | [`BSplineSurface::read_from_db`] | `ReadFromDB` | +//! | [`BSplineSurface::write_to_db`] | `WriteToDB` | //! | [`BSplineSurface::TYPE`], [`BSplineSurface::type_code`], [`BSplineSurface::type_string`], [`BSplineSurface::class_string`] | `GetType`, `GetTypeString`, `GetClassString` (`SM_COMMON`) | //! | [`BSplineSurface::out_of_bounds_enabled_mut`] | `GetOutOfBoundsEnabled` | //! | [`BSplineSurface::is_out_of_bounds_enabled`] | `IsOutOfBoundsEnabled` | @@ -155,7 +156,7 @@ use crate::nurbs::nurbs_srf::{allocate_nurb_surface, copy_nurb_surface}; use crate::nurbs::srf_basic::{Direction, Surface}; use crate::nurbs::srf_common::{corner_pts, cyl_cone, extrude_crv, CylCone}; use crate::surface::surface::p1::SurfaceSolveContext; -use crate::topology::database_io::{DatabaseReader, FileType}; +use crate::topology::database_io::{DatabaseReader, DatabaseWriter, FileType}; /// `SURF_BASE_TYPE` (`SmTypes.h`). const SURF_BASE_TYPE: i64 = 15000; @@ -182,6 +183,7 @@ const OP_KNOTS: &str = "nurbs::b_spline_surface::p1::BSplineSurface::knots"; const OP_STEP_INV: &str = "nurbs::b_spline_surface::p1::BSplineSurface::step_inversion"; const OP_XFORM: &str = "nurbs::b_spline_surface::p1::BSplineSurface::transform"; const OP_READ: &str = "nurbs::b_spline_surface::p1::BSplineSurface::read_from_db"; +const OP_WRITE: &str = "nurbs::b_spline_surface::p1::BSplineSurface::write_to_db"; /// `SmBSplineSurfaceForm`: the STEP form of a B-spline surface; discriminants are the /// upstream values. @@ -2245,6 +2247,67 @@ impl BSplineSurface { form, }) } + + /// `WriteToDB`: writes the NURB's last control point indices `n m`, degrees `p q`, + /// rational flag and, for `version > 32`, knot type and form; then the control + /// net row by row (`wx wy wz`, plus `w` when rational: the stored, weighted + /// values) and the u and v knots. + /// + /// # Errors + /// + /// `SM_ERR` without NURB (upstream dereferences `NULL`). + pub fn write_to_db(&self, db: &mut DatabaseWriter, version: u64) -> Result<()> { + let sur = self.nurb_or_err(OP_WRITE)?; + let (n, m) = (sur.n() as i64, sur.m() as i64); + let (p, q) = (sur.p() as i16, sur.q() as i16); + let knot_type = self.knot_type as u64; + let form = self.form as u64; + + // rational flag + let rat: i16 = i16::from(sur.is_rational()); + + if db.file_type() == FileType::Ascii { + db.out_i64(n).out_str(" ").out_i64(m).out_str("\n"); + db.out_i64(p.into()).out_str(" ").out_i64(q.into()); + db.out_str("\n"); + db.out_i64(rat.into()).out_str("\n"); + if version > 32 { + db.out_u64(knot_type); + db.out_str(" BSplineSurface Knot Type Flag \n"); + db.out_u64(form).out_str(" BSplineSurfaceForm Flag \n"); + } + } else { + db.write_long(n); + db.write_long(m); + db.write_short(p); + db.write_short(q); + db.write_short(rat); + if version > 32 { + db.write_long(knot_type as i64); + db.write_long(form as i64); + } + } + + // control points, by rational / non-rational + for row in sur.rows() { + for pw in row { + if rat == 1 { + db.reals(&[pw.x, pw.y, pw.z, pw.w]); + } else { + db.reals(&[pw.x, pw.y, pw.z]); + } + } + } + + // output u knots, then v knots + for &u in sur.u_knots() { + db.reals(&[u]); + } + for &v in sur.v_knots() { + db.reals(&[v]); + } + Ok(()) + } } /// `SmKnotType` with upstream value `code`, if any. diff --git a/crates/solidcore/src/query/face/p1.rs b/crates/solidcore/src/query/face/p1.rs index 3e7c967841..e86670e974 100644 --- a/crates/solidcore/src/query/face/p1.rs +++ b/crates/solidcore/src/query/face/p1.rs @@ -164,8 +164,9 @@ pub fn calculate_bounding_box_in( let surface = fp2::surface(b, f)? .ok_or_else(|| SolidError::invalid_input(OP_FACE_BOX, "face without surface"))?; - let u_deg = surface.degree(SurfParamType::U); - let v_deg = surface.degree(SurfParamType::V); + // (GetDegree makes a missing NURB: a lazy surface of extrusion is still linear in V) + let u_deg = surface.degree_with_nurb(k, SurfParamType::U)?; + let v_deg = surface.degree_with_nurb(k, SurfParamType::V)?; // If one of the directions of the surface is linear we can compute the bounding box // using the edges and vertices. diff --git a/crates/solidcore/src/surface/offset_surface.rs b/crates/solidcore/src/surface/offset_surface.rs index b7e3d10369..f0793c02f3 100644 --- a/crates/solidcore/src/surface/offset_surface.rs +++ b/crates/solidcore/src/surface/offset_surface.rs @@ -24,6 +24,7 @@ //! | [`OffsetSurface::reverse`], [`OffsetSurface::split_at`], [`OffsetSurface::step_inversion`] (and `_extending`), [`OffsetSurface::swap_uv`], [`OffsetSurface::transform`], [`OffsetSurface::trim_with_domain`] | `Reverse`, `SplitAt`, `STEPInversion`, `SwapUV`, `Transform`, `TrimWithDomain` | //! | [`OffsetSurface::create_offset_surface`] | `CreateOffsetSurface` | //! | [`OffsetSurface::read_from_db`] | `ReadFromDB` | +//! | [`OffsetSurface::write_to_db`] | `WriteToDB` | //! | [`OffsetSurface::is_kind_of`], [`OffsetSurface::type_code`], [`OffsetSurface::type_string`], [`OffsetSurface::class_string`] | `IsKindOf`, `GetType`, `GetTypeString`, `GetClassString` (`SM_COMMON`) | //! | [`OffsetSurface::offset_distance`], [`OffsetSurface::base_surface`], [`OffsetSurface::extended_base_surface`], [`OffsetSurface::max_analytic_domain`], [`OffsetSurface::degree`] | `GetOffsetDistance`, `GetBaseSurface`, `GetExtendedBaseSurface`, `GetMaxAnalyticDomain`, `GetDegree` | //! | [`OffsetSurface::set_offset_distance`], [`OffsetSurface::set_allow_extension`], [`OffsetSurface::set_context`] | `SetOffsetDistance`, `SetAllowExtension`, `SetContext` | @@ -131,7 +132,8 @@ use crate::surface::surface::p2::{ }; use crate::surface::surface::p7::base_evaluate_normal; use crate::surface::surface::{Surface, SurfaceKind}; -use crate::topology::database_io::{DatabaseReader, FileType, InputStream}; +use crate::topology::database_io::{DatabaseReader, DatabaseWriter, FileType, InputStream}; +use crate::topology::geometry_write::write_surface_at; const OP: &str = "surface::offset_surface::OffsetSurface"; const OP_NEW: &str = "surface::offset_surface::OffsetSurface::new"; @@ -1865,6 +1867,57 @@ impl OffsetSurface { db.leave_nested(); result } + + /// `WriteToDB`: writes the offset distance, the extension and periodicity flags + /// and whether there is an extended surface, then the base surface and the + /// extended surface (if any), each with its type header. `depth` is this + /// surface's nesting depth (see [`write_surface_at`]). + /// + /// # Errors + /// + /// Those of [`write_surface_at`] for the base and extended surfaces. + pub fn write_to_db(&self, db: &mut DatabaseWriter, version: u64, depth: usize) -> Result<()> { + let ascii = db.file_type() == FileType::Ascii; + let distance = self.offset_distance.get(); + let allow = i32::from(self.allow_extension); + self.with_current_extension(|extended| { + let has_extended = i32::from(extended.is_some()); + if ascii { + db.out_f64(distance); + db.out_str(" SmOffsetSurface Offset Distance \n"); + db.out_i64(allow.into()); + db.out_str(" SmOffsetSurface Allow Extension \n"); + db.out_i64(self.is_periodic_on_u.into()); + db.out_str(" SmOffsetSurface PeriodicOnU \n"); + db.out_i64(self.is_periodic_on_v.into()); + db.out_str(" SmOffsetSurface PeriodicOnV \n"); + db.out_i64(has_extended.into()); + db.out_str(" SmOffsetSurface HasExtendedSurface \n"); + } else { + db.write_double(distance); + db.write_boolean(allow); + db.write_boolean(self.is_periodic_on_u); + db.write_boolean(self.is_periodic_on_v); + db.write_boolean(has_extended); + } + + if ascii { + db.out_str(" OffsetSurface->BaseSurface \n"); + } + db.write_type(self.surface.type_code(), None); + write_surface_at(&self.surface, db, version, depth + 1)?; + if let Some(e) = extended { + if ascii { + db.out_str(" OffsetSurface->ExtendedSurface \n"); + } + db.write_type(e.type_code(), None); + write_surface_at(e, db, version, depth + 1)?; + } else if ascii { + db.out_str("Has No OffsetSurface->ExtendedSurface \n"); + } + Ok(()) + }) + } } /// Maximum wrapper depth accepted by offset construction and restoration. diff --git a/crates/solidcore/src/surface/plane/p2.rs b/crates/solidcore/src/surface/plane/p2.rs index eb58b0dbee..ff35da451b 100644 --- a/crates/solidcore/src/surface/plane/p2.rs +++ b/crates/solidcore/src/surface/plane/p2.rs @@ -15,6 +15,7 @@ //! | [`Plane::transform`] | `Transform` | //! | [`Plane::trim_with_domain`] | `TrimWithDomain` | //! | [`Plane::read_from_db`] | `ReadFromDB` (static) | +//! | [`Plane::write_to_db`] | `WriteToDB` | //! | [`Plane::make_new_plane_line`] | `MakeNewPlaneLine` | //! | [`Plane::make_new_plane_ellipse`] | `MakeNewPlaneEllipse` | //! @@ -87,7 +88,7 @@ use crate::surface::surface::p2::{ }; use crate::surface::surface::p7::base_evaluate_normal; use crate::surface::surface::{Surface, SurfaceKind}; -use crate::topology::database_io::{DatabaseReader, FileType, InputStream}; +use crate::topology::database_io::{DatabaseReader, DatabaseWriter, FileType, InputStream}; use super::p1::{is_grazing_intersection, Plane}; @@ -374,6 +375,38 @@ impl Plane { }) } + /// `WriteToDB`: writes the placement origin and axes, the analytic UV domain + /// corners and the UV scales, then the B-spline surface part with its NURB (made + /// on a copy when missing, as the virtual `GetOrCreateGwNurbPointer` makes it). + /// + /// # Errors + /// + /// Those of [`Plane::base_with_nurb`] (upstream ignores the B-spline part's + /// status, which cannot fail once the NURB exists). + pub fn write_to_db(&self, db: &mut DatabaseWriter, version: u64) -> Result<()> { + let o = self.position.origin(); + let x = self.position.x_axis(); + let y = self.position.y_axis(); + let (lo, hi) = (self.anal_uv_domain.min(), self.anal_uv_domain.max()); + let scale = self.uv_scale; + if db.file_type() == FileType::Ascii { + db.out_reals(&[o.x, o.y, o.z]); + db.out_str(" SmPlane Placement Origin \n"); + db.out_reals(&[x.x, x.y, x.z]); + db.out_str(" SmPlane Placement X Axis \n"); + db.out_reals(&[y.x, y.y, y.z]); + db.out_str(" SmPlane Placement Y Axis \n"); + db.out_reals(&[lo.x, lo.y, hi.x, hi.y]); + db.out_str(" SmPlane AnalUVDomain \n"); + db.out_reals(&[scale.x, scale.y]); + db.out_str(" SmPlane UV Scale \n"); + } else { + db.write_doubles(&[o.x, o.y, o.z, x.x, x.y, x.z, y.x, y.y, y.z]); + db.write_doubles(&[lo.x, lo.y, hi.x, hi.y, scale.x, scale.y]); + } + self.base_with_nurb()?.write_to_db(db, version) + } + // ---- recognition ------------------------------------------------------------------ /// The plane with the parameterisation of `test` when `test` is one diff --git a/crates/solidcore/src/surface/surface/p1.rs b/crates/solidcore/src/surface/surface/p1.rs index 694f450df1..de59a14962 100644 --- a/crates/solidcore/src/surface/surface/p1.rs +++ b/crates/solidcore/src/surface/surface/p1.rs @@ -159,7 +159,7 @@ use crate::kernel::Kernel; use crate::math::EFF_ZERO_DEG; use crate::math::{max2, min2, BIG_DOUBLE, EFF_ZERO, EFF_ZERO_SQ, EFF_ZERO_SQRT, PI}; use crate::nurbs::b_spline_curve_n_lib::TrimMode; -use crate::nurbs::b_spline_surface::SurfParamType; +use crate::nurbs::b_spline_surface::{BSplineSurface, SurfParamType}; use crate::nurbs::b_spline_surface_n_lib::p2::EvaluateFlags; use crate::nurbs::knots::Side; use crate::surface::offset_surface::{OffsetSurface, SS_UNKNOWN}; @@ -1547,40 +1547,13 @@ impl Surface { flags.v_side == Side::Left, flags.only_upper_half, ), - // inherited from SmBSplineSurface - SurfaceKind::Cone(s) => s.rev().base().evaluate_simple( - uv, - highest_u_deriv, - highest_v_deriv, - flags.u_side == Side::Left, - flags.v_side == Side::Left, - flags.only_upper_half, - ), - SurfaceKind::SurfOfRevolution(s) => s.base().evaluate_simple( - uv, - highest_u_deriv, - highest_v_deriv, - flags.u_side == Side::Left, - flags.v_side == Side::Left, - flags.only_upper_half, - ), - SurfaceKind::SurfOfExtrusion(s) => s.base().evaluate_simple( - uv, - highest_u_deriv, - highest_v_deriv, - flags.u_side == Side::Left, - flags.v_side == Side::Left, - flags.only_upper_half, - ), - SurfaceKind::Sphere(s) => s.base().base().evaluate_simple( - uv, - highest_u_deriv, - highest_v_deriv, - flags.u_side == Side::Left, - flags.v_side == Side::Left, - flags.only_upper_half, - ), - SurfaceKind::Torus(s) => s.base().base().evaluate_simple( + // inherited from SmBSplineSurface, which reads m_pNurb: upstream's analytic + // surfaces always hold one (the port makes it on demand, kind_with_nurb) + SurfaceKind::Cone(_) + | SurfaceKind::SurfOfRevolution(_) + | SurfaceKind::SurfOfExtrusion(_) + | SurfaceKind::Sphere(_) + | SurfaceKind::Torus(_) => self.analytic_base_with_nurb(k)?.evaluate_simple( uv, highest_u_deriv, highest_v_deriv, @@ -1641,11 +1614,13 @@ impl Surface { ) -> Result<()> { let base = match self.kind() { SurfaceKind::BSpline(s) => Some(s), - SurfaceKind::Cone(s) => Some(s.rev().base()), - SurfaceKind::SurfOfRevolution(s) => Some(s.base()), - SurfaceKind::SurfOfExtrusion(s) => Some(s.base()), - SurfaceKind::Sphere(s) => Some(s.base().base()), - SurfaceKind::Torus(s) => Some(s.base().base()), + // inherited from SmBSplineSurface, which reads m_pNurb: upstream's analytic + // surfaces always hold one (the port makes it on demand, kind_with_nurb) + SurfaceKind::Cone(_) + | SurfaceKind::SurfOfRevolution(_) + | SurfaceKind::SurfOfExtrusion(_) + | SurfaceKind::Sphere(_) + | SurfaceKind::Torus(_) => Some(self.analytic_base_with_nurb(k)?), SurfaceKind::Plane(_) | SurfaceKind::CurveBounded(_) | SurfaceKind::OffsetSurface(_) @@ -2007,31 +1982,11 @@ impl Surface { }; let r = match self.kind() { SurfaceKind::BSpline(s) => s.calculate_bounding_box(k, uv_domain, &req)?, - SurfaceKind::Cone(s) => { - s.rev() - .base() - .calculate_bounding_box_of(k, uv_domain, &req, Some(self))? - } - SurfaceKind::Sphere(s) => { - s.base() - .base() - .calculate_bounding_box_of(k, uv_domain, &req, Some(self))? - } - SurfaceKind::Torus(s) => { - s.base() - .base() - .calculate_bounding_box_of(k, uv_domain, &req, Some(self))? - } - SurfaceKind::SurfOfRevolution(s) => { - s.base() - .calculate_bounding_box_of(k, uv_domain, &req, Some(self))? - } - SurfaceKind::SurfOfExtrusion(s) => { - s.base() - .calculate_bounding_box_of(k, uv_domain, &req, Some(self))? - } SurfaceKind::Plane(p) => plane_bounding_box(k, p, uv_domain, &req)?, - _ => unreachable_kind()?, + _ => { + let base = self.analytic_base_with_nurb(k)?; + base.calculate_bounding_box_of(k, uv_domain, &req, Some(self))? + } }; Ok(SurfaceBoxes { normal_box: r.normal, @@ -2048,6 +2003,20 @@ impl Surface { } } + /// The B-spline part of an analytic kind with its NURB (`GetOrCreateGwNurbPointer` + /// from a `const` method: [`Surface::kind_with_nurb`], which makes a missing NURB + /// once and keeps it on the surface). + pub(crate) fn analytic_base_with_nurb(&self, k: &Kernel) -> Result<&BSplineSurface> { + Ok(match self.kind_with_nurb(k)? { + SurfaceKind::Cone(s) => s.rev().base(), + SurfaceKind::Sphere(s) => s.base().base(), + SurfaceKind::Torus(s) => s.base().base(), + SurfaceKind::SurfOfRevolution(s) => s.base(), + SurfaceKind::SurfOfExtrusion(s) => s.base(), + _ => return unreachable_kind(), + }) + } + /// The base body of [`Surface::calculate_bounding_box`]. /// /// # Errors @@ -4106,7 +4075,6 @@ impl<'a> SssIntersectEnfo<'a> { #[cfg(test)] mod tests { use super::*; - use crate::nurbs::b_spline_surface::BSplineSurface; /// A bilinear patch over `[0, 1]^2` spanning `(0,0,0)..(2,1,0)` with a twist. fn bilinear() -> Surface { diff --git a/crates/solidcore/src/surface/surface/p2.rs b/crates/solidcore/src/surface/surface/p2.rs index 37f3eab583..bf43ddf863 100644 --- a/crates/solidcore/src/surface/surface/p2.rs +++ b/crates/solidcore/src/surface/surface/p2.rs @@ -353,6 +353,9 @@ pub struct Surface { /// The identity a context issued this surface object for its notifications /// (upstream passes the object itself, known by address); a copy has none. notify_identity: IdentitySlot, + /// The NURB an analytic kind made on first use from a `const` method + /// ([`Surface::kind_with_nurb`], [`NurbMemo`]). + made_nurb: NurbMemo, /// Not upstream: the memo of the natural boundary curves `CoincidenceCheck` makes /// (`BoundaryMemo`); a copy starts an empty one, an edit replaces it. boundary_memo: BoundaryMemo, @@ -447,6 +450,24 @@ impl CacheSlot { } } +/// `GetOrCreateGwNurbPointer` called from a `const` method of an analytic kind that has +/// no NURB yet (cone, sphere, torus, surface of revolution or extrusion): upstream runs +/// the virtual `MakeNurb` and keeps the NURB on the surface, so every later call sees +/// it. The port's `&self` methods cannot fill the kind in place; the kind with its NURB +/// made is kept here instead, once, and dropped whenever the kind is changed +/// ([`Surface::kind_mut`] and the kind's in-place edits). A copy starts empty. +#[derive(Debug, Default)] +struct NurbMemo(std::sync::OnceLock>); + +const OP_KIND_WITH_NURB: &str = "surface::surface::p2::Surface::kind_with_nurb"; + +impl NurbMemo { + /// Forgets the made NURB (the kind changed). + fn reset(&mut self) { + self.0 = std::sync::OnceLock::new(); + } +} + /// The copy constructor: copies the geometry, resets the owner (an owning offset in it /// then re-owns its base, whose chain no longer reaches a face). impl Clone for Surface { @@ -458,6 +479,7 @@ impl Clone for Surface { owner_zone_tol_3d: None, cache_slot: CacheSlot::default(), notify_identity: IdentitySlot::new(), + made_nurb: NurbMemo::default(), boundary_memo: BoundaryMemo::new(), }; s.update_owned_bases(); @@ -699,6 +721,7 @@ impl Surface { owner_zone_tol_3d: self.owner_zone_tol_3d, cache_slot: self.cache_slot.share(), notify_identity: IdentitySlot::new(), + made_nurb: NurbMemo::default(), boundary_memo: BoundaryMemo::new(), }) } @@ -746,6 +769,7 @@ impl Surface { owner_zone_tol_3d: self.owner_zone_tol_3d, cache_slot: self.cache_slot.share(), notify_identity: IdentitySlot::new(), + made_nurb: NurbMemo::default(), boundary_memo: BoundaryMemo::new(), } } @@ -897,6 +921,7 @@ impl Surface { owner_zone_tol_3d: None, cache_slot: CacheSlot::default(), notify_identity: IdentitySlot::new(), + made_nurb: NurbMemo::default(), boundary_memo: BoundaryMemo::new(), } } @@ -1106,13 +1131,57 @@ impl Surface { &self.kind } - /// The concrete kind, mutable. + /// The concrete kind, mutable (forgets a NURB made by [`Surface::kind_with_nurb`]). pub fn kind_mut(&mut self) -> &mut SurfaceKind { + self.made_nurb.reset(); // an edit makes another surface value: start an empty boundary memo self.boundary_memo = BoundaryMemo::new(); &mut self.kind } + /// The concrete kind with its NURB (`GetOrCreateGwNurbPointer` from a `const` + /// method): this surface's kind when it is not analytic or has its NURB, else the + /// kind with the NURB its virtual `MakeNurb` makes (status ignored, as upstream: a + /// NURB that cannot be made stays missing), made once and kept on this surface as + /// upstream keeps it ([`NurbMemo`]). + /// + /// # Errors + /// + /// [`SolidError::Unported`] from `MakeNurb`. + pub fn kind_with_nurb(&self, k: &Kernel) -> Result<&SurfaceKind> { + let missing = match &self.kind { + SurfaceKind::Cone(s) => s.rev().base().gw_nurb().is_none(), + SurfaceKind::Sphere(s) => s.base().base().gw_nurb().is_none(), + SurfaceKind::Torus(s) => s.base().base().gw_nurb().is_none(), + SurfaceKind::SurfOfRevolution(s) => s.base().gw_nurb().is_none(), + SurfaceKind::SurfOfExtrusion(s) => s.base().gw_nurb().is_none(), + _ => false, + }; + if !missing { + return Ok(&self.kind); + } + if let Some(made) = self.made_nurb.0.get() { + return Ok(made); + } + let mut kind = match &self.kind { + // an exact copy, as SurfOfExtrusion::base_with_nurb makes for a query (no + // copy-constructor knot fix-up: upstream makes the NURB on the object itself) + SurfaceKind::SurfOfExtrusion(s) => { + SurfaceKind::SurfOfExtrusion(Box::new(s.clone_for_evaluation(0)?)) + } + _ => self.kind.clone(), + }; + match &mut kind { + SurfaceKind::Cone(s) => ignore_status(s.make_nurb(k))?, + SurfaceKind::Sphere(s) => ignore_status(s.make_nurb(k))?, + SurfaceKind::Torus(s) => ignore_status(s.make_nurb(k))?, + SurfaceKind::SurfOfRevolution(s) => ignore_status(s.make_nurb(k))?, + SurfaceKind::SurfOfExtrusion(s) => ignore_status(s.make_nurb(k))?, + _ => {} + } + Ok(self.made_nurb.0.get_or_init(|| Box::new(kind))) + } + /// The concrete kind, by value. #[must_use] pub fn into_kind(self) -> SurfaceKind { @@ -1464,10 +1533,15 @@ impl Surface { SurfaceKind::BSpline(s) => { s.evaluate_ignoring_status(uv, highest_u_deriv, highest_v_deriv, flags) } - // (not overridden: the SmBSplineSurface evaluation) - SurfaceKind::SurfOfRevolution(s) => { - s.base() - .evaluate_ignoring_status(uv, highest_u_deriv, highest_v_deriv, flags) + // (not overridden: the SmBSplineSurface evaluation, on the m_pNurb upstream's + // analytic surfaces always hold) + SurfaceKind::SurfOfRevolution(_) | SurfaceKind::Sphere(_) | SurfaceKind::Torus(_) => { + self.analytic_base_with_nurb(k)?.evaluate_ignoring_status( + uv, + highest_u_deriv, + highest_v_deriv, + flags, + ) } SurfaceKind::SurfOfExtrusion(s) => s.base_with_nurb(k)?.evaluate_ignoring_status( uv, @@ -1475,18 +1549,6 @@ impl Surface { highest_v_deriv, flags, ), - SurfaceKind::Sphere(s) => s.base().base().evaluate_ignoring_status( - uv, - highest_u_deriv, - highest_v_deriv, - flags, - ), - SurfaceKind::Torus(s) => s.base().base().evaluate_ignoring_status( - uv, - highest_u_deriv, - highest_v_deriv, - flags, - ), // pass the call along SurfaceKind::CurveBounded(s) => { s.basis() @@ -1535,10 +1597,12 @@ impl Surface { SurfaceKind::SrfInVolume(s) => s.evaluate_point(k, uv), SurfaceKind::Plane(s) => Ok(s.evaluate_point(uv)), SurfaceKind::Cone(s) => s.evaluate_point(k, uv), - SurfaceKind::SurfOfRevolution(s) => s.base().evaluate_point(uv), + // (the inherited SmBSplineSurface evaluation, on the m_pNurb upstream's analytic + // surfaces always hold) + SurfaceKind::SurfOfRevolution(_) | SurfaceKind::Sphere(_) | SurfaceKind::Torus(_) => { + self.analytic_base_with_nurb(k)?.evaluate_point(uv) + } SurfaceKind::SurfOfExtrusion(s) => s.base_with_nurb(k)?.evaluate_point(uv), - SurfaceKind::Sphere(s) => s.base().base().evaluate_point(uv), - SurfaceKind::Torus(s) => s.base().base().evaluate_point(uv), SurfaceKind::OffsetSurface(s) => s.evaluate_point(k, uv), } } @@ -1600,6 +1664,38 @@ impl Surface { } } + /// [`Surface::degree`] as upstream's `GetDegree`, which makes a missing NURB + /// (`GetOrCreateGwNurbPointer`): a surface of extrusion (the recogniser's lazy + /// generator) or a cone without one reads the degree of the NURB `MakeNurb` gives + /// it, on a temporary copy, where [`Surface::degree`] would answer `0`. A failed + /// make leaves no NURB, so `0`, as upstream. + /// + /// # Errors + /// + /// [`SolidError::Unported`] from the make. + pub fn degree_with_nurb(&self, k: &Kernel, param: SurfParamType) -> Result { + match &self.kind { + SurfaceKind::SurfOfExtrusion(s) if s.base().gw_nurb().is_none() => { + match s.base_with_nurb(k) { + Ok(base) => Ok(base.degree(param)), + Err(e) if e.is_unported() => Err(e), + Err(_) => Ok(0), // FS + } + } + // (a sphere's or a torus's NURB is quadratic both ways, so its 0 never + // reads as linear either) + SurfaceKind::Cone(s) if s.rev().base().gw_nurb().is_none() => { + let mut copy = s.clone(); + match copy.make_nurb(k) { + Ok(()) => Ok(copy.rev().base().degree(param)), + Err(e) if e.is_unported() => Err(e), + Err(_) => Ok(0), // FS + } + } + _ => Ok(self.degree(param)), + } + } + /// The number of control points in `param`'s direction /// (`GetNumberControlPoints`). #[must_use] @@ -1717,7 +1813,11 @@ impl Surface { SurfaceKind::Cone(s) => s.convert_uv_from_step_to_nurbs(k, step_uv), SurfaceKind::Sphere(s) => s.convert_uv_from_step_to_nurbs(k, step_uv), SurfaceKind::SurfOfRevolution(s) => s.convert_uv_from_step_to_nurbs(k, step_uv), - SurfaceKind::SurfOfExtrusion(s) => s.convert_uv_from_step_to_nurbs(k, step_uv), + // (GetNaturalUVDomain makes a missing NURB) + SurfaceKind::SurfOfExtrusion(_) => match self.kind_with_nurb(k)? { + SurfaceKind::SurfOfExtrusion(s) => s.convert_uv_from_step_to_nurbs(k, step_uv), + _ => Err(SolidError::sm_err(OP_KIND_WITH_NURB)), + }, _ => Ok(base_convert_uv(step_uv)), } } @@ -1734,7 +1834,11 @@ impl Surface { SurfaceKind::Cone(s) => s.convert_uv_from_nurbs_to_step(k, nurbs_uv), SurfaceKind::Sphere(s) => s.convert_uv_from_nurbs_to_step(k, nurbs_uv), SurfaceKind::SurfOfRevolution(s) => s.convert_uv_from_nurbs_to_step(k, nurbs_uv), - SurfaceKind::SurfOfExtrusion(s) => s.convert_uv_from_nurbs_to_step(k, nurbs_uv), + // (GetNaturalUVDomain makes a missing NURB) + SurfaceKind::SurfOfExtrusion(_) => match self.kind_with_nurb(k)? { + SurfaceKind::SurfOfExtrusion(s) => s.convert_uv_from_nurbs_to_step(k, nurbs_uv), + _ => Err(SolidError::sm_err(OP_KIND_WITH_NURB)), + }, _ => Ok(base_convert_uv(nurbs_uv)), } } @@ -1876,7 +1980,9 @@ impl Surface { target_distance: Option, requested: SolutionRequested, ) -> Result> { - match &self.kind { + // (the analytic kinds' DropPointFast reads the NURB through + // GetNaturalUVDomain / GetOrCreateGwNurbPointer, which makes it when missing) + match self.kind_with_nurb(k)? { SurfaceKind::Plane(s) => s.drop_point_fast( uv_domain, operation, @@ -2074,6 +2180,7 @@ impl Surface { k: &Kernel, new_step_uv_domain: &Extent2d, ) -> Result<()> { + self.made_nurb.reset(); self.boundary_memo = BoundaryMemo::new(); match &mut self.kind { SurfaceKind::BSpline(s) => s.adjust_step_uv_domain(new_step_uv_domain), @@ -2098,6 +2205,7 @@ impl Surface { /// /// Those of the kind's override (the base default is `SM_ERR`). pub fn reverse(&mut self, k: &Kernel, param: SurfParamType) -> Result<()> { + self.made_nurb.reset(); self.boundary_memo = BoundaryMemo::new(); match &mut self.kind { SurfaceKind::BSpline(s) => s.reverse(param), @@ -2161,6 +2269,7 @@ impl Surface { /// /// Those of the kind's override (the base default is `SM_ERR`). pub fn reparameterize(&mut self, k: &Kernel, new_domain: &Extent2d) -> Result<()> { + self.made_nurb.reset(); self.boundary_memo = BoundaryMemo::new(); match &mut self.kind { SurfaceKind::BSpline(s) => s.reparameterize(new_domain), @@ -2186,6 +2295,7 @@ impl Surface { /// /// Those of the kind's override (the base default is `SM_ERR`). pub fn swap_uv(&mut self, k: &Kernel) -> Result<()> { + self.made_nurb.reset(); self.boundary_memo = BoundaryMemo::new(); match &mut self.kind { SurfaceKind::BSpline(s) => s.swap_uv(), @@ -2262,6 +2372,7 @@ impl Surface { scale: Option, depth: usize, ) -> Result<()> { + self.made_nurb.reset(); self.boundary_memo = BoundaryMemo::new(); match &mut self.kind { SurfaceKind::Cone(s) => s.transform(k, rotate_n_move, scale), @@ -2302,6 +2413,7 @@ impl Surface { trim_domain: &mut Extent2d, options: TrimOptions, ) -> Result<()> { + self.made_nurb.reset(); self.boundary_memo = BoundaryMemo::new(); match &mut self.kind { SurfaceKind::BSpline(s) => s.trim_with_domain_with(trim_domain, options), @@ -2320,7 +2432,9 @@ impl Surface { // ---- new surfaces --------------------------------------------------------------- /// The two halves of the surface split at `param` in `surf_param`'s direction - /// (`SplitAt`; the kernel is the context the halves are constructed in). + /// (`SplitAt`; the kernel is the context the halves are constructed in). An analytic + /// kind without its NURB yet makes it first and keeps it on the surface + /// ([`Surface::kind_with_nurb`], upstream's `GetOrCreateGwNurbPointer`). /// /// # Errors /// @@ -2345,33 +2459,21 @@ impl Surface { let (l, r) = s.base_for_query().split_at(param, surf_param)?; Ok((Self::from(l), Self::from(r))) } - SurfaceKind::Cone(s) => { - let (l, r) = s.rev().base().split_at(param, surf_param)?; - Ok((Self::from(l), Self::from(r))) - } - SurfaceKind::Sphere(s) => { - let (l, r) = s.base().base().split_at(param, surf_param)?; - Ok((Self::from(l), Self::from(r))) - } - SurfaceKind::Torus(s) => { - let (l, r) = s.base().base().split_at(param, surf_param)?; - Ok((Self::from(l), Self::from(r))) - } - SurfaceKind::SurfOfRevolution(s) => { - let (l, r) = s.base().split_at(param, surf_param)?; - Ok((Self::from(l), Self::from(r))) - } - SurfaceKind::SurfOfExtrusion(s) => { - // GetOrCreateGwNurbPointer: the virtual MakeNurb when the NURB is missing - // (as the extrusion recogniser leaves it), here on a temporary copy; - // its status is not checked upstream, so a failure splits the NURB-less - // base (SM_ERR) - let base = match s.base_with_nurb(k) { - Ok(b) => b, - Err(e) if e.is_unported() => return Err(e), - Err(_) => std::borrow::Cow::Borrowed(s.base()), + SurfaceKind::Cone(_) + | SurfaceKind::Sphere(_) + | SurfaceKind::Torus(_) + | SurfaceKind::SurfOfRevolution(_) + | SurfaceKind::SurfOfExtrusion(_) => { + // SmBSplineSurface::SplitAt: GetOrCreateGwNurbPointer (the analytic's + // MakeNurb when missing, status ignored; the NURB stays on the surface) + let (l, r) = match self.kind_with_nurb(k)? { + SurfaceKind::Cone(s) => s.rev().base().split_at(param, surf_param)?, + SurfaceKind::Sphere(s) => s.base().base().split_at(param, surf_param)?, + SurfaceKind::Torus(s) => s.base().base().split_at(param, surf_param)?, + SurfaceKind::SurfOfRevolution(s) => s.base().split_at(param, surf_param)?, + SurfaceKind::SurfOfExtrusion(s) => s.base().split_at(param, surf_param)?, + _ => return base_split_at(), }; - let (l, r) = base.split_at(param, surf_param)?; Ok((Self::from(l), Self::from(r))) } SurfaceKind::OffsetSurface(s) => s.split_at(k, param, surf_param), diff --git a/crates/solidcore/src/surface/surface/p8.rs b/crates/solidcore/src/surface/surface/p8.rs index e88f425a74..ae8fec207b 100644 --- a/crates/solidcore/src/surface/surface/p8.rs +++ b/crates/solidcore/src/surface/surface/p8.rs @@ -365,9 +365,10 @@ struct BoundaryMemoEntry { /// function of the surface value and the key, so a hit gives what a fresh build gives, /// and the curves carry their own curve cache memos, so their caches are built once /// too. Lent by reference: the check only reads the curves. One entry per live surface, -/// freed with it; no `unsafe`, and the surface stays `Send`. +/// freed with it; no `unsafe`, and the surface stays `Send`. The entry is boxed so the +/// memo adds only a small cell, not the whole entry, to every `Surface` value. #[derive(Default)] -pub(crate) struct BoundaryMemo(OnceLock); +pub(crate) struct BoundaryMemo(OnceLock>); impl core::fmt::Debug for BoundaryMemo { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { @@ -402,7 +403,7 @@ fn boundary_curves<'s>( return trim_curves(k, surface, domain).map(Cow::Owned); } let curves = trim_curves(k, surface, domain)?; - if let Err(entry) = memo.set(BoundaryMemoEntry { key, curves }) { + if let Err(entry) = memo.set(Box::new(BoundaryMemoEntry { key, curves })) { return Ok(Cow::Owned(entry.curves)); } match memo.get() { diff --git a/crates/solidcore/src/topology.rs b/crates/solidcore/src/topology.rs index ea18c8efe2..90af14e20f 100644 --- a/crates/solidcore/src/topology.rs +++ b/crates/solidcore/src/topology.rs @@ -41,11 +41,14 @@ pub mod brep; pub mod brep_cache; pub mod brep_data; +pub mod brep_data_write; pub mod database_io; pub mod edge; pub mod edgeuse; pub mod face; +pub mod geometry_write; pub mod loopuse; +pub mod part_file; pub mod proto_topology; pub mod shell; #[allow(clippy::module_inception)] diff --git a/crates/solidcore/src/topology/brep_data_write.rs b/crates/solidcore/src/topology/brep_data_write.rs new file mode 100644 index 0000000000..b99677827a --- /dev/null +++ b/crates/solidcore/src/topology/brep_data_write.rs @@ -0,0 +1,1668 @@ +//! Writing serialized B-rep data (`SmBrepData` and its record classes) to an SMLib +//! database stream (`.smb`, ASCII or binary): the write side of +//! [`crate::topology::brep_data`]. +//! +//! Upstream: `source/SMLib/src/SmBrepData.cpp`, `source/SMLib/src/SmAttribute.cpp`, +//! `source/SMLib/inc/SmBrepData.h`. +//! +//! | Rust | Upstream | +//! |---------------------------------------|--------------------------------------------------| +//! | [`BrepData::write_to_file`] | `SmBrepData::WriteToFile` (new file) | +//! | [`BrepData::write_to_db`] | `SmBrepData::WriteToDB` | +//! | [`write_curve_to_db`] | `SmBrepData::WriteCurveToDB` | +//! | [`write_surface_to_db`] | `SmBrepData::WriteSurfaceToDB` | +//! | [`TopologyData::write_to_db`] ... [`VertexData::write_to_db`] | `SmTopologyData::WriteToDB` ... `SmVertexData::WriteToDB` | +//! | [`AttributeTable`] | `rAllAttributes` and `rAttrMap` | +//! | [`BrepData::from_brep`], [`FromBrepEnv`] | `SmBrepData::FromBrep` | +//! | `get_map`, `edgeuse_index` (private) | `sm_GetMap`, `sm_GetEdgeuseIndex` | +//! | `extract_attributes` (private) | `sm_ExtractAttributes`, `sm_FindAttribute` | +//! | `write_indexed_attributes` (private) | `SmAttributeData::WriteIndexedAttributesToDB` | +//! | [`DATABASE_VERSION`] | `SM_CURRENT_DATABASE_VERSION` | +//! +//! Deviations: +//! - **Files.** [`BrepData::write_to_file`] returns the file's bytes (no filesystem on +//! wasm32); only a new file (`bNewFile = TRUE`) is supported. +//! - **Native formats only.** `bWriteAsBSplines = TRUE` approximates every non-B-spline +//! curve and surface (`ApproximateCurve`, `ApproximateSurface`); it is not ported, +//! and the writers always write native formats (the upstream default). The kinds +//! with a ported writer are those of [`crate::topology::geometry_write`]. +//! - **Edge use mate flags.** `SmEUData::WriteToDB` writes the edge use's own flags and +//! user indices where `ReadFromDB` reads the mate's (`m_lFlags` twice, never +//! `m_lMateFlags`), so the mate's values do not survive a write and read; the port +//! writes what upstream writes. +//! - **UV curve dimension.** `WriteToDB` turns each UV curve whose control points all +//! have `z == 0` into a 2D NURB in place before writing it, as upstream does (the +//! write therefore takes `&mut self`). +//! - **`FromBrep` identities.** Upstream maps object addresses to record indices. The +//! port maps typed ids, and reads each object's `SmTopology` record and attribute +//! list under the identity [`FromBrepEnv::key_of`] gives it (geometry under the +//! `*_key` closures). An object missing from the maps is `SM_NO_OBJECT`, as +//! `sm_GetMap` returns (it also signals `SM_ERR`, which nothing reads). Upstream +//! borrows the B-rep's curves and surfaces (`m_bIsGeomBorrowed`); the port copies +//! them exactly (no copy-constructor knot fix-up). Each edge owns its curve, so the +//! 3D curve list has one curve per edge (upstream shares a curve between edges +//! only when they share the object). `GetSurfaces` keeps the surfaces owned by +//! their faces, as upstream. Dereferences of null objects (a loop without loop +//! uses, an edge use without mate, next edge use, loop use or loop; an edge without +//! primary edge use or curve) are `SM_ERR_NULL_POINTER`. +//! - **Statuses.** Upstream ignores the status of every record write and of the curve +//! and surface writes; the port's record writes cannot fail, and a curve or surface +//! that cannot be written (no ported writer, no NURB) is an error instead of a +//! silently short file. + +use crate::curve::curve::{Curve, CurveKind}; +use crate::error::{ignore_status, EntityRef, Result, SolidError}; +use crate::geom::attribute::{ids, AttributeHandle, AttributeStore, Behavior, ObjectKey}; +use crate::geom::object::ObjectRef; +use crate::geom::vector3d::Vec3; +use crate::kernel::Kernel; +use crate::nurbs::b_spline_curve::BSplineCurve; +use crate::nurbs::b_spline_surface::BSplineSurface; +use crate::nurbs::b_spline_surface_n_lib::p2::OrientType; +use crate::nurbs::math::NOZ; +use crate::surface::surface::{Surface, SurfaceKind}; +use crate::topology::brep::p1::{self as bl, surface_owners}; +use crate::topology::brep::p10::HEALER_VERSION; +use crate::topology::brep::p2::is_manifold_solid; +use crate::topology::brep_data::{ + BrepData, DataSourceType, EdgeData, EuData, FaceData, FaceuseData, Keyed, LoopData, RegionData, + ShellData, TopologyData, VertexData, NO_OBJECT, UNDEF_ULONG, +}; +use crate::topology::database_io::{DatabaseWriter, FileType}; +use crate::topology::edgeuse::p1 as eu1; +use crate::topology::geometry_write::{write_curve, write_surface}; +use crate::topology::topology::Topology; +use crate::topology::{edge, face, loopuse, Brep, EdgeId, EdgeuseId, FaceId}; +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; + +/// `SM_CURRENT_DATABASE_VERSION`: the version [`BrepData::write_to_file`] writes. +pub const DATABASE_VERSION: u64 = 42; + +const OP_CURVE: &str = "topology::brep_data_write::write_curve_to_db"; +const OP_SURFACE: &str = "topology::brep_data_write::write_surface_to_db"; +const OP_UV: &str = "topology::brep_data_write::BrepData::write_to_db"; +const OP_FROM: &str = "topology::brep_data_write::BrepData::from_brep"; + +/// The attributes a write gathers (upstream `rAllAttributes`, with `rAttrMap`): every +/// attribute written, in index order, read from `store`. +#[derive(Debug)] +pub struct AttributeTable<'a> { + /// The attribute lists of the objects written (by their keys). + pub store: &'a AttributeStore, + /// `rAllAttributes`: the attributes indexed so far. + pub all: &'a mut Vec, + /// `rAttrMap`: the attributes this table indexed (an attribute in `all` but not + /// here, indexed under another map, is appended again, as upstream). + pub map: &'a mut BTreeSet, +} + +/// `sm_ExtractAttributes` (with `sm_FindAttribute`): the indices of the attributes of +/// `key` in `table.all`, appending those the map has not seen; attributes of +/// behaviour [`Behavior::Temp`] are skipped. +fn extract_attributes(table: &mut AttributeTable<'_>, key: ObjectKey) -> Vec { + let mut indices = Vec::new(); + for h in table.store.attributes(key) { + let temp = table + .store + .get(h) + .is_some_and(|a| a.behavior() == Behavior::Temp); + if temp { + continue; + } + // sm_FindAttribute: in the map, then its position in the accumulation list + let found = if table.map.contains(&h) { + table.all.iter().position(|&x| x == h) + } else { + None + }; + let index = if let Some(i) = found { + i + } else { + table.all.push(h); + table.map.insert(h); + table.all.len() - 1 + }; + indices.push(index as u64); + } + indices +} + +/// `SmAttributeData::WriteIndexedAttributesToDB`: the count and the indices. +fn write_indexed_attributes(indices: &[u64], db: &mut DatabaseWriter) { + if db.file_type() == FileType::Ascii { + if indices.is_empty() { + db.out_str(" 0 "); + } else { + // output total number of attributes, then each attribute index + db.out_str(" ").out_u64(indices.len() as u64); + db.out_str(" "); + for &i in indices { + db.out_i64(i as i64).out_str(" "); + } + } + db.out_str("\n"); + } else { + db.write_long(indices.len() as i64); + if !indices.is_empty() { + db.write_longs(indices); + } + } +} + +fn is_ascii(db: &DatabaseWriter) -> bool { + db.file_type() == FileType::Ascii +} + +/// `version > 30 && bPersistAttribs`. +fn with_attributes(version: u64, persist_attribs: bool) -> bool { + version > 30 && persist_attribs +} + +impl TopologyData { + /// `SmTopologyData::WriteToDB`: for `version > 31`, the flags and the two user + /// indices (ASCII: each after a space; the small-topology flag of `version > 43` + /// is `SM_USE_NEWTOL` only). + pub fn write_to_db(&self, version: u64, db: &mut DatabaseWriter) { + if version > 31 { + if is_ascii(db) { + db.out_str(" ").out_u64(self.flags); + db.out_str(" ").out_u64(self.user_index1); + db.out_str(" ").out_u64(self.user_index2); + } else { + db.write_long(self.flags as i64); + db.write_long(self.user_index1 as i64); + db.write_long(self.user_index2 as i64); + } + } + } +} + +impl RegionData { + /// `SmRegionData::WriteToDB`: the topology data, the shell count, first shell and + /// void flag, then the attribute indices. + pub fn write_to_db(&self, version: u64, db: &mut DatabaseWriter, persist_attribs: bool) { + self.topology.write_to_db(version, db); + if is_ascii(db) { + db.out_str(" ").out_u64(self.num_shells); + db.out_str(" ").out_u64(self.start_shell); + db.out_str(" ").out_i64(self.is_void.into()); + db.out_str("\n"); + } else { + db.write_long(self.num_shells as i64); + db.write_long(self.start_shell as i64); + db.write_boolean(self.is_void); + } + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&self.topology.attributes, db); + } + } +} + +impl ShellData { + /// `SmShellData::WriteToDB`: the topology data and the shell by type (face use: + /// the constant orientation `1`, count and first face use; wire edge: the edge; + /// vertex: the vertex; another type writes nothing), then the attribute indices. + pub fn write_to_db(&self, version: u64, db: &mut DatabaseWriter, persist_attribs: bool) { + self.topology.write_to_db(version, db); + // used to remove m_bOrientation - kept to keep the smb file structure constant + let orientation: i32 = 1; + if is_ascii(db) { + match self.shell_type { + 0 => { + db.out_str(" ").out_u64(self.shell_type); + db.out_str(" ").out_i64(orientation.into()); + db.out_str(" ").out_u64(self.num_faceuses); + db.out_str(" ").out_u64(self.faceuse_start); + db.out_str("\n"); + } + 1 => { + db.out_str(" ").out_u64(self.shell_type); + db.out_str(" ").out_u64(self.edge).out_str("\n"); + } + 2 => { + db.out_str(" ").out_u64(self.shell_type); + db.out_str(" ").out_u64(self.vertex).out_str("\n"); + } + _ => {} + } + } else { + match self.shell_type { + 0 => { + db.write_long(self.shell_type as i64); + db.write_long(self.num_faceuses as i64); + db.write_long(self.faceuse_start as i64); + db.write_boolean(orientation); + } + 1 => { + db.write_long(self.shell_type as i64); + db.write_long(self.edge as i64); + } + 2 => { + db.write_long(self.shell_type as i64); + db.write_long(self.vertex as i64); + } + _ => {} + } + } + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&self.topology.attributes, db); + } + } +} + +impl FaceuseData { + /// `SmFaceuseData::WriteToDB`: the topology data, the face and the orientation, + /// then the attribute indices. + pub fn write_to_db(&self, version: u64, db: &mut DatabaseWriter, persist_attribs: bool) { + self.topology.write_to_db(version, db); + if is_ascii(db) { + db.out_str(" ").out_u64(self.face); + db.out_str(" ").out_i64(self.orientation.into()); + db.out_str("\n"); + } else { + db.write_long(self.face as i64); + db.write_boolean(self.orientation); + } + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&self.topology.attributes, db); + } + } +} + +impl FaceData { + /// `SmFaceData::WriteToDB`: the topology data, the zone tolerance, surface, first + /// loop, loop count and rectangular-trim flag, then the UV domain as + /// `umin umax vmin vmax` (binary: the domain first), then the attribute indices. + pub fn write_to_db(&self, version: u64, db: &mut DatabaseWriter, persist_attribs: bool) { + self.topology.write_to_db(version, db); + let (lo, hi) = (self.uv_domain.min(), self.uv_domain.max()); + if is_ascii(db) { + db.out_str(" ").out_f64(self.zone_tol_3d); + db.out_str(" ").out_u64(self.surface); + db.out_str(" ").out_u64(self.start_loop); + db.out_str(" ").out_u64(self.num_loops); + db.out_str(" ").out_i64(self.rectangular_trim.into()); + db.out_str("\n").out_f64(lo.x); + db.out_str(" ").out_f64(hi.x); + db.out_str(" ").out_f64(lo.y); + db.out_str(" ").out_f64(hi.y); + db.out_str("\n"); + } else { + db.write_doubles(&[lo.x, hi.x, lo.y, hi.y, self.zone_tol_3d]); + db.write_long(self.surface as i64); + db.write_long(self.start_loop as i64); + db.write_long(self.num_loops as i64); + db.write_boolean(self.rectangular_trim); + } + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&self.topology.attributes, db); + } + } +} + +impl LoopData { + /// `SmLoopData::WriteToDB`: the topology data and the loop by type (edge uses: + /// count and first edge use; vertex: the vertex; another type writes nothing), + /// then the loop's and its two loop uses' attribute indices. + pub fn write_to_db(&self, version: u64, db: &mut DatabaseWriter, persist_attribs: bool) { + self.topology.write_to_db(version, db); + if is_ascii(db) { + match self.loop_type { + 0 => { + db.out_str(" ").out_u64(self.loop_type); + db.out_str(" ").out_u64(self.num_eu); + db.out_str(" ").out_u64(self.start_eu); + db.out_str("\n"); + } + 1 => { + db.out_str(" ").out_u64(self.loop_type); + db.out_str(" ").out_u64(self.vertex).out_str("\n"); + } + _ => {} + } + } else { + match self.loop_type { + 0 => { + db.write_long(self.loop_type as i64); + db.write_long(self.num_eu as i64); + db.write_long(self.start_eu as i64); + } + 1 => { + db.write_long(self.loop_type as i64); + db.write_long(self.vertex as i64); + } + _ => {} + } + } + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&self.topology.attributes, db); + write_indexed_attributes(&self.lu_upper_attributes, db); + write_indexed_attributes(&self.lu_lower_attributes, db); + } + } +} + +impl EuData { + /// `SmEUData::WriteToDB`: the topology data; for `version > 31` the edge use's + /// flags and user indices again (where the reader expects the mate's: see the + /// module notes); the edge use by type (edge: edge, loop, orientation, UV curve, + /// next and mate's next edge use; vertex and vertex at pole: vertex, loop, + /// orientation, UV curve; wire edge: edge, shell, orientation, UV curve), then its + /// and its mate's attribute indices. + pub fn write_to_db(&self, version: u64, db: &mut DatabaseWriter, persist_attribs: bool) { + let t = &self.topology; + t.write_to_db(version, db); + let ascii = is_ascii(db); + if version > 31 { + if ascii { + db.out_str(" ").out_u64(t.flags); + db.out_str(" ").out_u64(t.user_index1); + db.out_str(" ").out_u64(t.user_index2); + } else { + db.write_long(t.flags as i64); + db.write_long(t.user_index1 as i64); + db.write_long(t.user_index2 as i64); + } + } + let orientation = i64::from(self.orientation); + if ascii { + let (a, b) = match self.eu_type { + 0 | 2 => ( + self.edge, + if self.eu_type == 0 { + self.loop_ + } else { + self.shell + }, + ), + _ => (self.vertex, self.loop_), + }; + if self.eu_type <= 3 { + db.out_str(" ").out_u64(self.eu_type); + db.out_str(" ").out_u64(a); + db.out_str(" ").out_u64(b); + db.out_str(" ").out_i64(orientation); + db.out_str(" ").out_u64(self.uv_curve); + if self.eu_type == 0 { + db.out_str(" ").out_i64(self.next_eu); + db.out_str(" ").out_i64(self.mate_next_eu); + } + db.out_str("\n"); + } + } else { + db.write_long(self.eu_type as i64); + match self.eu_type { + 0 => { + db.write_long(self.edge as i64); + db.write_long(self.loop_ as i64); + } + 1 | 3 => { + db.write_long(self.vertex as i64); + db.write_long(self.loop_ as i64); + } + 2 => { + db.write_long(self.edge as i64); + db.write_long(self.shell as i64); + } + _ => {} + } + db.write_boolean(self.orientation); + db.write_long(self.uv_curve as i64); + if self.eu_type == 0 { + db.write_long(self.next_eu); + db.write_long(self.mate_next_eu); + } + } + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&t.attributes, db); + write_indexed_attributes(&self.mate_attributes, db); + } + } +} + +impl EdgeData { + /// `SmEdgeData::WriteToDB`: the topology data, the zone tolerance, curve, start + /// and end vertex, primary edge use and the interval, then the attribute indices. + pub fn write_to_db(&self, version: u64, db: &mut DatabaseWriter, persist_attribs: bool) { + self.topology.write_to_db(version, db); + let (lo, hi) = (self.interval.min(), self.interval.max()); + if is_ascii(db) { + db.out_str(" ").out_f64(self.zone_tol_3d); + db.out_str(" ").out_u64(self.curve); + db.out_str(" ").out_u64(self.start_vertex); + db.out_str(" ").out_u64(self.end_vertex); + db.out_str(" ").out_i64(self.prim_eu); + db.out_str(" ").out_f64(lo); + db.out_str(" ").out_f64(hi); + db.out_str("\n"); + } else { + db.write_double(self.zone_tol_3d); + db.write_long(self.curve as i64); + db.write_long(self.start_vertex as i64); + db.write_long(self.end_vertex as i64); + db.write_long(self.prim_eu); + db.write_doubles(&[lo, hi]); + } + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&self.topology.attributes, db); + } + } +} + +impl VertexData { + /// `SmVertexData::WriteToDB`: the topology data, the zone tolerance and the point, + /// then the attribute indices. + pub fn write_to_db(&self, version: u64, db: &mut DatabaseWriter, persist_attribs: bool) { + self.topology.write_to_db(version, db); + let p = self.point; + if is_ascii(db) { + db.out_str(" ").out_f64(self.zone_tol_3d); + db.out_str(" ").out_f64(p.x); + db.out_str(" ").out_f64(p.y); + db.out_str(" ").out_f64(p.z); + db.out_str("\n"); + } else { + db.write_doubles(&[self.zone_tol_3d, p.x, p.y, p.z]); + } + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&self.topology.attributes, db); + } + } +} + +/// `SM_CAST_PTR(SmBSplineCurve, pCrv)`: the B-spline part of the kinds derived from +/// `SmBSplineCurve`, `None` for the others. +const fn bspline_curve_part(c: &Curve) -> Option<&BSplineCurve> { + match c.kind() { + CurveKind::BSpline(b) => Some(b), + CurveKind::Line(l) => Some(l.base()), + CurveKind::Ellipse(e) => Some(e.base()), + CurveKind::Hyperbola(h) => Some(h.base()), + CurveKind::Parabola(p) => Some(p.base()), + CurveKind::Hermite(_) + | CurveKind::Projected(_) + | CurveKind::Iso(_) + | CurveKind::Offset(_) + | CurveKind::Conic(_) + | CurveKind::CrvInVolume(_) + | CurveKind::CrvOnSurf(_) + | CurveKind::Composite(_) => None, + } +} + +/// [`bspline_curve_part`], mutable. +fn bspline_curve_part_mut(c: &mut Curve) -> Option<&mut BSplineCurve> { + match c.kind_mut() { + CurveKind::BSpline(b) => Some(b), + CurveKind::Line(l) => Some(&mut l.base), + CurveKind::Ellipse(e) => Some(e.base_mut()), + CurveKind::Hyperbola(h) => Some(h.base_mut()), + CurveKind::Parabola(p) => Some(p.base_mut()), + _ => None, + } +} + +/// `SM_CAST_PTR(SmBSplineSurface, pSrf)`: the B-spline part of the kinds derived from +/// `SmBSplineSurface` (a plane's made with its NURB when missing), `None` for the +/// others. +fn bspline_surface_part(s: &Surface) -> Result>> { + Ok(match s.kind() { + SurfaceKind::BSpline(b) => Some(Cow::Borrowed(b)), + SurfaceKind::Plane(p) => Some(p.base_with_nurb()?), + SurfaceKind::Cone(c) => Some(Cow::Borrowed(c.rev().base())), + SurfaceKind::Sphere(sp) => Some(Cow::Borrowed(sp.base().base())), + SurfaceKind::Torus(t) => Some(Cow::Borrowed(t.base().base())), + SurfaceKind::SurfOfRevolution(r) => Some(Cow::Borrowed(r.base())), + SurfaceKind::SurfOfExtrusion(e) => Some(Cow::Borrowed(e.base())), + SurfaceKind::CurveBounded(_) + | SurfaceKind::Step(_) + | SurfaceKind::OffsetSurface(_) + | SurfaceKind::SrfInVolume(_) => None, + }) +} + +/// `SmBrepData::WriteCurveToDB` (native formats): for `version > 32` the curve's type +/// header and its own format; up to version 32 the B-spline type header and the +/// curve's B-spline part. With `version > 30` and `persist_attribs` the curve's +/// attribute indices follow (its attributes are added to `attrs`). +/// +/// # Errors +/// +/// `SM_ERR_NULL_POINTER` up to version 32 for a curve without B-spline part; those +/// of [`write_curve`]. +pub fn write_curve_to_db( + c: &Keyed, + version: u64, + attrs: &mut AttributeTable<'_>, + db: &mut DatabaseWriter, + persist_attribs: bool, +) -> Result<()> { + let curve = &c.object; + if version > 32 { + // preface Curve Write with Type and Dim data + db.write_type(curve.type_code(), Some(curve.dim())); + write_curve(curve, db, version)?; + } else { + // database versions 32 and before - limited to BSplineCurves + db.write_type(crate::curve::curve::p2::BSPLINE_CURVE_TYPE, None); + let b = bspline_curve_part(curve).ok_or_else(|| SolidError::null_pointer(OP_CURVE))?; + b.write_to_db(db, version)?; + } + + // gather and output curve attributes + if with_attributes(version, persist_attribs) { + let indices = extract_attributes(attrs, c.key); + write_indexed_attributes(&indices, db); + } + Ok(()) +} + +/// `SmBrepData::WriteSurfaceToDB` (native formats): for `version > 32` the surface's +/// type header and its own format; up to version 32 an offset surface's type header, +/// offset distance and (extended) base's B-spline part, or the B-spline type header +/// and the surface's B-spline part. With `version > 30` and `persist_attribs` the +/// surface's attribute indices follow. +/// +/// # Errors +/// +/// `SM_ERR_NULL_POINTER` up to version 32 for a surface (or offset base) without +/// B-spline part; those of [`write_surface`]. +pub fn write_surface_to_db( + s: &Keyed, + version: u64, + attrs: &mut AttributeTable<'_>, + db: &mut DatabaseWriter, + persist_attribs: bool, +) -> Result<()> { + use crate::surface::surface::p2::{B_SPLINE_SURFACE_TYPE, OFFSET_SURFACE_TYPE}; + let surface = &s.object; + if version > 32 { + // preface Surface Write with Type data + db.write_type(surface.type_code(), None); + write_surface(surface, db, version)?; + } else if let SurfaceKind::OffsetSurface(off) = surface.kind() { + db.write_type(OFFSET_SURFACE_TYPE, None); + let distance = off.offset_distance(); + if is_ascii(db) { + db.out_f64(distance).out_str(" Offset Distance \n"); + } else { + db.write_double(distance); + } + let base = off.extended_base_surface().unwrap_or(off.base_surface()); + let b = bspline_surface_part(base)?.ok_or_else(|| SolidError::null_pointer(OP_SURFACE))?; + b.write_to_db(db, version)?; + } else { + db.write_type(B_SPLINE_SURFACE_TYPE, None); + let b = + bspline_surface_part(surface)?.ok_or_else(|| SolidError::null_pointer(OP_SURFACE))?; + b.write_to_db(db, version)?; + } + + // add surface attributes to accumulation list and output attribute indices + if with_attributes(version, persist_attribs) { + let indices = extract_attributes(attrs, s.key); + write_indexed_attributes(&indices, db); + } + Ok(()) +} + +/// The ASCII section header `"\n//\n<count> <what>\n"` or the binary count. +fn section(db: &mut DatabaseWriter, title: &str, count: usize, what: &str) { + if is_ascii(db) { + db.out_str("\n//").out_str(title).out_str("\n"); + db.out_u64(count as u64).out_str(what); + } else { + db.write_long(count as i64); + } +} + +/// An ASCII comment line (nothing in binary). +fn comment(db: &mut DatabaseWriter, text: &str) { + if is_ascii(db) { + db.out_str(text); + } +} + +/// The ASCII record index (nothing in binary). +fn index(db: &mut DatabaseWriter, i: usize) { + if is_ascii(db) { + db.out_u64(i as u64); + } +} + +/// A composite face or edge section: `[count, n0, items.., n1, items.., ...]`. +fn write_composites( + db: &mut DatabaseWriter, + list: &[u64], + title: &str, + what: &str, + columns: (&str, &str), + version: u64, +) { + let count = list.first().copied().unwrap_or(0); + section(db, title, count as usize, what); + if count == 0 { + return; + } + comment(db, if version > 30 { columns.0 } else { columns.1 }); + let mut items = list.iter().skip(1).copied(); + for i in 0..count { + let n = items.next().unwrap_or(0); + if is_ascii(db) { + db.out_u64(i).out_str(" ").out_u64(n); + for _ in 0..n { + db.out_str(" ").out_u64(items.next().unwrap_or(0)); + } + db.out_str("\n"); + } else { + db.write_long(n as i64); + for _ in 0..n { + db.write_long(items.next().unwrap_or(0) as i64); + } + } + } +} + +/// `GetOrCreateGwNurbPointer` of a surface about to be written: an analytic kind +/// without its NURB yet (cone, sphere, torus, surface of revolution or extrusion: the +/// recogniser's lazy surfaces) makes it with its virtual `MakeNurb`, status ignored as +/// upstream (a NURB that cannot be made stays missing and the writer reports it), and +/// a lazy line generator makes its own. The same make a `const` query of the surface runs; +/// here on the B-rep data's own copy. A surface (and generator) that has its NURB is +/// left as it is, so its written bytes do not change. +/// +/// # Errors +/// +/// [`SolidError::Unported`] from `MakeNurb`. +pub fn make_missing_nurb(k: &Kernel, surface: &mut Surface) -> Result<()> { + let missing = match surface.kind() { + SurfaceKind::Cone(s) => s.rev().base().gw_nurb().is_none(), + SurfaceKind::Sphere(s) => s.base().base().gw_nurb().is_none(), + SurfaceKind::Torus(s) => s.base().base().gw_nurb().is_none(), + SurfaceKind::SurfOfRevolution(s) => s.base().gw_nurb().is_none(), + SurfaceKind::SurfOfExtrusion(s) => s.base().gw_nurb().is_none(), + _ => false, + }; + if missing { + match surface.kind_mut() { + SurfaceKind::Cone(s) => ignore_status(s.make_nurb(k))?, + SurfaceKind::Sphere(s) => ignore_status(s.make_nurb(k))?, + SurfaceKind::Torus(s) => ignore_status(s.make_nurb(k))?, + SurfaceKind::SurfOfRevolution(s) => ignore_status(s.make_nurb(k))?, + SurfaceKind::SurfOfExtrusion(s) => ignore_status(s.make_nurb(k))?, + _ => {} + } + } + // the generator is written too: a lazy line generator (SmLine is an + // SmBSplineCurve) makes its NURB the same way + let lazy_line = + |c: &Curve| matches!(c.kind(), CurveKind::Line(l) if l.base().gw_nurb().is_none()); + let has_lazy_line = match surface.kind() { + SurfaceKind::Cone(s) => s.rev.gen_curve.as_ref().is_some_and(lazy_line), + SurfaceKind::Sphere(s) => s.base.gen_curve.as_ref().is_some_and(lazy_line), + SurfaceKind::Torus(s) => s.base.gen_curve.as_ref().is_some_and(lazy_line), + SurfaceKind::SurfOfRevolution(s) => s.gen_curve.as_ref().is_some_and(lazy_line), + SurfaceKind::SurfOfExtrusion(s) => s.gen_curve.as_ref().is_some_and(lazy_line), + _ => false, + }; + if !has_lazy_line { + return Ok(()); + } + let gen = match surface.kind_mut() { + SurfaceKind::Cone(s) => s.rev.gen_curve.as_mut(), + SurfaceKind::Sphere(s) => s.base.gen_curve.as_mut(), + SurfaceKind::Torus(s) => s.base.gen_curve.as_mut(), + SurfaceKind::SurfOfRevolution(s) => s.gen_curve.as_mut(), + SurfaceKind::SurfOfExtrusion(s) => s.gen_curve.as_mut(), + _ => None, + }; + if let Some(CurveKind::Line(l)) = gen.map(Curve::kind_mut) { + ignore_status(l.make_nurb())?; + } + Ok(()) +} + +impl BrepData { + /// [`Self::write_to_file`] after [`make_missing_nurb`] on every surface: upstream's + /// `SmBSplineSurface::WriteToDB` reaches the NURB through + /// `GetOrCreateGwNurbPointer`, so a lazy surface is written with the NURB it makes. + /// + /// # Errors + /// + /// Those of [`make_missing_nurb`] and [`Self::write_to_file`]. + pub fn write_to_file_with_nurbs(&mut self, k: &Kernel, file_type: FileType) -> Result<Vec<u8>> { + for s in &mut self.surfaces { + make_missing_nurb(k, &mut s.object)?; + } + self.write_to_file(file_type) + } + + /// `SmBrepData::WriteToFile(name, eType, TRUE)`: the `Version` and + /// `Healer Version` header ([`DATABASE_VERSION`], `SM_HEALER_VERSION`; binary: + /// `'V'`, a long, `'H'`, a long), then [`Self::write_to_db`] without attributes. + /// Returns the file's contents. + /// + /// # Errors + /// + /// Those of [`Self::write_to_db`]. + pub fn write_to_file(&mut self, file_type: FileType) -> Result<Vec<u8>> { + let mut db = DatabaseWriter::open_for_write(file_type); + + // virtual preWrite for derived classes - base class does nothing + db.begin_writing(); + + // starting with version 38 write out a header for this file + if file_type == FileType::Ascii { + db.out_str("Version ").out_u64(DATABASE_VERSION); + db.out_str("\n"); + db.out_str("Healer Version ").out_u64(HEALER_VERSION); + db.out_str("\n"); + } else { + db.write_char(b'V'); + db.write_long(DATABASE_VERSION as i64); + db.write_char(b'H'); + db.write_long(HEALER_VERSION as i64); + } + + // output BrepData to stream, without attributes + let store = AttributeStore::new(); + let mut all = Vec::new(); + let mut map = BTreeSet::new(); + let mut attrs = AttributeTable { + store: &store, + all: &mut all, + map: &mut map, + }; + self.write_to_db(DATABASE_VERSION, &mut attrs, &mut db, false)?; + + // virtual postWrite for derived classes - base class does nothing + db.end_writing(); + Ok(db.into_bytes()) + } + + /// `SmBrepData::WriteToDB` (native formats): the B-rep properties (attribute + /// indices or colour, zone tolerance, manifold flag, the obsolete composites flag + /// `0`, region count and start region), the region, shell, composite face, face + /// use, face, loop, composite edge, edge use, edge and vertex sections, then the + /// UV curves (each made 2D first when all its control points have `z == 0`), the + /// 3D curves and the surfaces. ASCII streams carry the comment lines the reader + /// skips. + /// + /// # Errors + /// + /// `SM_ERR_NULL_POINTER` for a UV curve without NURB (upstream dereferences it), + /// [`SolidError::InvalidInput`] for one that is not a B-spline kind (upstream + /// casts it); those of [`write_curve_to_db`] and [`write_surface_to_db`]. + pub fn write_to_db( + &mut self, + version: u64, + attrs: &mut AttributeTable<'_>, + db: &mut DatabaseWriter, + persist_attribs: bool, + ) -> Result<()> { + let ascii = is_ascii(db); + let new_tol = version > 43; + + // output brep properties: color, tolerance, manifold flag, composites flag, + // numRegions, startRegion + let make_composites: i32 = 0; + if ascii { + db.out_str("//Brep Starts\n"); + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&self.attributes, db); + } else { + db.out_reals(&[self.color.x, self.color.y, self.color.z]); + db.out_str(" - Color\n"); + } + // (the SM_USE_NEWTOL lines of version > 43 are compiled out of the reference) + if !new_tol { + db.out_f64(self.zone_tol_3d).out_str(" - Brep ZoneTol3d\n"); + } + db.out_i64(self.manifold.into()); + db.out_str(" - is Manifold:[1 = is, 0 = isn't]\n"); + db.out_i64(make_composites.into()); + db.out_str(" - has Composites:[1 = does, 0 = doesn't]\n"); + db.out_str("\n//Region Data\n"); + db.out_u64(self.regions.len() as u64); + db.out_str(" Regions In Brep\n"); + db.out_u64(self.start_region).out_str(" - Start Region\n"); + if version > 30 { + db.out_str( + "//Index, Flags, UserId1, UserId2, Number Of Shells, First Shell, \ + Is void Region\n", + ); + } else { + db.out_str("//Index, Number Of Shells, First Shell, Is void Region\n"); + } + } else { + if with_attributes(version, persist_attribs) { + write_indexed_attributes(&self.attributes, db); + } else { + db.write_doubles(&[self.color.x, self.color.y, self.color.z]); + } + if !new_tol { + db.write_double(self.zone_tol_3d); + } + db.write_boolean(self.manifold); + db.write_boolean(make_composites); + db.write_long(self.num_regions as i64); + db.write_long(self.start_region as i64); + } + + // Region Data + for (i, r) in self.regions.iter().enumerate() { + index(db, i); + r.write_to_db(version, db, persist_attribs); + } + + // Shells + let n = self.shells.len(); + section(db, "Shell Data", n, " Shells In Brep\n"); + if version > 30 { + comment( + db, + "//Index, Flags, UserId1, UserId2, 0(Faceuse), Orientation, \ + Number Of Faceuses, Start Faceuse\n", + ); + comment(db, "//Index, Flags, UserId1, UserId2, 1(WireEdge), Edge\n"); + comment(db, "//Index, Flags, UserId1, UserId2, 2(Vertex), Vertex\n"); + } else { + comment( + db, + "//Index, 0(Faceuse), Orientation, Number Of Faceuses, Start Faceuse\n", + ); + comment(db, "//Index, 1(WireEdge), Edge\n"); + comment(db, "//Index, 2(Vertex), Vertex\n"); + } + for (i, s) in self.shells.iter().enumerate() { + index(db, i); + s.write_to_db(version, db, persist_attribs); + } + + // Composite Faces + write_composites( + db, + &self.cfaces, + "Composite Face Data", + " Composite Faces In Brep\n", + ( + "//Index, Flags, UserId1, UserId2, Face Count, Face1, Face2, ...\n", + "//Index, Face Count, Face1, Face2, ...\n", + ), + version, + ); + + // Faceuse Data + let n = self.faceuses.len(); + section(db, "Faceuse Data", n, " Faceuses In Brep\n"); + if version > 30 { + comment(db, "//Index, Flags, UserId1, UserId2, Face, Orientation\n"); + } else { + comment(db, "//Index, Face, Orientation\n"); + } + for (i, f) in self.faceuses.iter().enumerate() { + index(db, i); + f.write_to_db(version, db, persist_attribs); + } + + // Face Data + let n = self.faces.len(); + section(db, "Face Data", n, " Faces In Brep\n"); + if version > 30 { + comment( + db, + "//Index, Flags, UserId1, UserId2, Tol, Surface, Outer Loop, # of Loops, \ + Naturally Trimmed\n", + ); + } else { + comment( + db, + "//Index, Tol, Surface, Outer Loop, # of Loops, Naturally Trimmed\n", + ); + } + comment(db, "//UMin, UMax, VMin, VMax\n"); + for (i, f) in self.faces.iter().enumerate() { + index(db, i); + f.write_to_db(version, db, persist_attribs); + } + + // Loop Data + let n = self.loops.len(); + section(db, "Loop Data", n, " Loops In Brep\n"); + if version > 30 { + comment( + db, + "//Index, Flags, UserId1, UserId2, 0(Edgeuse), # Of Edgeuses, Start Edgeuse\n", + ); + comment(db, "//Index, Flags, UserId1, UserId2, 1(Vertex), Vertex\n"); + } else { + comment(db, "//Index, 0(Edgeuse), # Of Edgeuses, Start Edgeuse\n"); + comment(db, "//Index, 1(Vertex), Vertex\n"); + } + for (i, l) in self.loops.iter().enumerate() { + index(db, i); + l.write_to_db(version, db, persist_attribs); + } + + // Composite Edges + write_composites( + db, + &self.cedges, + "Composite Edge Data", + " Composite Edges In Brep\n", + ( + "//Index, Flags, UserId1, UserId2, Edge Count, Edge1, Edge2, ...\n", + "//Index, Edge Count, Edge1, Edge2, ...\n", + ), + version, + ); + + // Edgeuse Data + let n = self.edgeuses.len(); + section(db, "Edgeuse Data", n, " Edgeuses In Brep\n"); + if version > 30 { + const HEAD: &str = "//Index, Flags, UserId1, UserId2, MateFlags, MateUserId1, \ + MateUserId2, "; + for tail in [ + "0(Edge), Edge, Loop, Orientation, UV Curve, Next EU, Mate Next EU\n", + "1(Vertex), Vertex, Loop, Orientation, UV Curve\n", + "2(Wire Edge), Edge, Shell, Orientation, UV Curve\n", + "3(Vertex At Pole), Vertex, Loop, Orientation, UV Curve\n", + ] { + comment(db, HEAD); + comment(db, tail); + } + } else { + comment( + db, + "//Index, 0(Edge), Edge, Loop, Orientation, UV Curve, Next EU, Mate Next EU\n", + ); + comment( + db, + "//Index, 1(Vertex), Vertex, Loop, Orientation, UV Curve\n", + ); + comment( + db, + "//Index, 2(Wire Edge), Edge, Shell, Orientation, UV Curve\n", + ); + comment( + db, + "//Index, 3(Vertex At Pole), Vertex, Loop, Orientation, UV Curve\n", + ); + } + for (i, e) in self.edgeuses.iter().enumerate() { + index(db, i); + e.write_to_db(version, db, persist_attribs); + } + + // Edge Data + let n = self.edges.len(); + section(db, "Edge Data", n, " Edges In Brep\n"); + if version > 30 { + comment( + db, + "//Index, Flags, UserId1, UserId2, Tol, Curve, Start Vertex, End Vertex, \ + Primary Edgeuse, Param Min, Param Max\n", + ); + } else { + comment( + db, + "//Index, Tol, Curve, Start Vertex, End Vertex, Primary Edgeuse, Param Min, \ + Param Max\n", + ); + } + for (i, e) in self.edges.iter().enumerate() { + index(db, i); + e.write_to_db(version, db, persist_attribs); + } + + // Vertex Data + let n = self.vertices.len(); + section(db, "Vertex Data", n, " Vertices In Brep\n"); + if version > 30 { + comment(db, "//Index, Flags, UserId1, UserId2, Tol, X, Y, Z\n"); + } else { + comment(db, "//Index, Tol, X, Y, Z\n"); + } + for (i, v) in self.vertices.iter().enumerate() { + index(db, i); + v.write_to_db(version, db, persist_attribs); + } + + // UV-Curve Data + let n = self.uv_curves.len(); + section(db, " UV-Curve Data", n, " UV-Curves In Brep\n"); + for (i, c) in self.uv_curves.iter_mut().enumerate() { + if ascii { + db.out_u64(i as u64).out_str(" - UV-Curve:\n"); + } + // convert all 2d UV curves into x, y, NL_NOZ + make_uv_curve_2d(&mut c.object)?; + write_curve_to_db(c, version, attrs, db, persist_attribs)?; + } + + // 3D-Curve Data + let n = self.curves_3d.len(); + section(db, " 3D-Curve Data", n, " 3D-Curves In Brep\n"); + for (i, c) in self.curves_3d.iter().enumerate() { + if ascii { + db.out_u64(i as u64).out_str(" - 3D-Curve\n"); + } + write_curve_to_db(c, version, attrs, db, persist_attribs)?; + } + + // Surface Data + let n = self.surfaces.len(); + section(db, "Surface Data", n, " Surfaces In Brep\n"); + for (i, s) in self.surfaces.iter().enumerate() { + if ascii { + db.out_u64(i as u64).out_str(" - Surface\n"); + } + write_surface_to_db(s, version, attrs, db, persist_attribs)?; + } + Ok(()) + } +} + +/// The UV curve conversion of `WriteToDB`: a NURB whose control points are 3D with +/// every `z == 0` is made 2D (`z = NL_NOZ`) in place. +fn make_uv_curve_2d(c: &mut Curve) -> Result<()> { + let b = bspline_curve_part_mut(c) + .ok_or_else(|| SolidError::invalid_input(OP_UV, "UV curve is not a B-spline curve"))?; + let cur = b + .nurb + .as_mut() + .ok_or_else(|| SolidError::null_pointer(OP_UV))?; + let cpts = cur.cpts_mut(); + let first_z = cpts.first().map_or(NOZ, |p| p.z); + if first_z != NOZ && cpts.iter().all(|p| p.z == 0.0) { + for p in cpts.iter_mut() { + p.z = NOZ; + } + } + Ok(()) +} + +// ---- FromBrep ----------------------------------------------------------------------- + +/// The identities and records [`BrepData::from_brep`] reads (upstream: the objects' +/// addresses, their `SmTopology` members and their attribute lists). +pub struct FromBrepEnv<'a> { + /// The attribute lists of the B-rep's objects (by identity). + pub attributes: &'a AttributeStore, + /// The identity of each topology entity (the B-rep's own is [`Brep::key`]). + pub key_of: &'a dyn Fn(EntityRef) -> ObjectRef, + /// The `SmTopology` records (flags, user indices) by identity; a missing record is + /// a fresh `SmTopology()`. + pub records: &'a BTreeMap<ObjectKey, Topology>, + /// The identity of an edge's curve. + pub curve_key: &'a dyn Fn(EdgeId) -> ObjectKey, + /// The identity of the UV trim curve held by an edge use. + pub uv_curve_key: &'a dyn Fn(EdgeuseId) -> ObjectKey, + /// The identity of a face's surface. + pub surface_key: &'a dyn Fn(FaceId) -> ObjectKey, +} + +/// `SM_ERR_NULL_POINTER` of [`BrepData::from_brep`]. +fn null() -> SolidError { + SolidError::null_pointer(OP_FROM) +} + +/// `sm_GetMap`: the record index of `e`, `SM_NO_OBJECT` when it is not mapped (or +/// null). +fn get_map(map: &BTreeMap<EntityRef, u64>, e: Option<EntityRef>) -> u64 { + e.and_then(|e| map.get(&e).copied()).unwrap_or(NO_OBJECT) +} + +/// `sm_GetEdgeuseIndex`: `-(index + 1)` for a mate of a recorded edge use, else the +/// edge use's own index (`SM_NO_OBJECT` when unmapped). +fn edgeuse_index( + map: &BTreeMap<EntityRef, u64>, + mate_map: &BTreeMap<EdgeuseId, u64>, + eu: EdgeuseId, +) -> i64 { + match mate_map.get(&eu) { + Some(&v) if v != 0 => -(v as i64), + _ => get_map(map, Some(EntityRef::Edgeuse(eu))) as i64, + } +} + +/// The `SmTopology` record of `e` (a fresh one when there is none). +fn record_of(env: &FromBrepEnv<'_>, e: EntityRef) -> Topology { + let key = (env.key_of)(e).key; + env.records.get(&key).copied().unwrap_or_default() +} + +/// "for backward compatibility: map `SM_UNDEF_ULONG` flag and index values to zero". +const fn defined(v: u64) -> u64 { + if v == UNDEF_ULONG { + 0 + } else { + v + } +} + +/// The topology part of a record: flags and user indices mapped by [`defined`], and +/// the attribute indices. +fn topology_data( + env: &FromBrepEnv<'_>, + table: &mut AttributeTable<'_>, + e: EntityRef, +) -> TopologyData { + let t = record_of(env, e); + let mut data = TopologyData::zeroed(); + data.flags = defined(t.all_flags()); + data.user_index1 = defined(t.user_index1()); + data.user_index2 = defined(t.user_index2()); + data.attributes = extract_attributes(table, (env.key_of)(e).key); + data +} + +fn same(o: OrientType) -> i32 { + i32::from(o == OrientType::Same) +} + +impl BrepData { + /// `SmBrepData::FromBrep`: flattens `b` into records indexed as upstream does: the + /// regions (infinite region first), the vertices, the wire edges' primary edge + /// uses, the shells, the face uses, the faces' surfaces (those owned by their + /// faces), the faces, the loops (with the edge uses of each loop's upper loop use), + /// the edge uses (next and mate's next as `sm_GetEdgeuseIndex` codes; UV trim + /// curves gathered), then the edges and their curves. Attributes are indexed into + /// `all_attributes` in that order (under a map of this call). The colour is the + /// B-rep's `SM_AI_COLOR` vector attribute, red without one. + /// + /// # Errors + /// + /// `SM_ERR_NULL_POINTER` for the null objects upstream dereferences (see the module + /// notes); [`SolidError::InvalidInput`] for a vertex loop use without vertex; those + /// of the topology queries and the geometry copies. + pub fn from_brep( + b: &Brep, + env: &FromBrepEnv<'_>, + all_attributes: &mut Vec<AttributeHandle>, + ) -> Result<Self> { + let mut attr_map = BTreeSet::new(); + let mut table = AttributeTable { + store: env.attributes, + all: all_attributes, + map: &mut attr_map, + }; + // map ObjectPointers to indices; [Edge->primaryEdgeUse->Mate, edgeuseIndex+1] + let mut map: BTreeMap<EntityRef, u64> = BTreeMap::new(); + let mut mate_map: BTreeMap<EdgeuseId, u64> = BTreeMap::new(); + + // init output + let mut bd = Self::new(0, DataSourceType::Unknown); + + // set Brep properties + bd.data_source_type = DataSourceType::SmLib; + bd.manifold = i32::from(is_manifold_solid(b)?); + bd.zone_tol_3d = b.tolerance(); + bd.start_region = 0; + + // Set the brep color if it exists. Otherwise default to red + let color = env + .attributes + .find_attribute(b.key(), ids::COLOR) + .and_then(|h| env.attributes.get(h)) + .and_then(crate::geom::attribute::Attribute::vector3d_value); + bd.color = color.unwrap_or(Vec3::new(1.0, 0.0, 0.0)); + + // get Brep attributes (not attributes of objects in the Brep structure) + bd.attributes = extract_attributes(&mut table, b.key()); + + // Regions: place infinite region at head of list + let mut regions = bl::regions(b, None)?; + let infinite = b.infinite_region(); + if regions.len() > 1 && regions.first().copied() != infinite { + if let Some(i) = regions.iter().skip(1).position(|&r| Some(r) == infinite) { + let r = regions.remove(i + 1); + regions.insert(0, r); + } + } + bd.num_regions = regions.len() as u64; + let mut all_shells = Vec::new(); + for (i, &r) in regions.iter().enumerate() { + map.insert(EntityRef::Region(r), i as u64); + let region = b.region(r)?; + let mut rd = RegionData::zeroed(); + rd.start_shell = all_shells.len() as u64; + rd.num_shells = region.shells.len() as u64; + rd.is_void = region.is_void; + rd.topology = topology_data(env, &mut table, EntityRef::Region(r)); + all_shells.extend(region.shells.iter().copied()); + bd.regions.push(rd); + } + + // Vertices + for (i, v) in bl::vertices(b, None)?.into_iter().enumerate() { + map.insert(EntityRef::Vertex(v), i as u64); + let vertex = b.vertex(v)?; + let mut vd = VertexData::zeroed(); + vd.zone_tol_3d = vertex.tolerance; + vd.point = vertex.point; + vd.topology = topology_data(env, &mut table, EntityRef::Vertex(v)); + bd.vertices.push(vd); + } + + // Edges: the wire edges' primary edge uses come first + let edges = bl::edges(b, None)?; + let mut all_edgeuses: Vec<EdgeuseId> = Vec::new(); + for (i, &e) in edges.iter().enumerate() { + map.insert(EntityRef::Edge(e), i as u64); + if edge::is_wire(b, e)? { + let prim = eu1::primary_edgeuse(b, e)?.ok_or_else(null)?; + let n = all_edgeuses.len() as u64; + map.insert(EntityRef::Edgeuse(prim), n); + if let Some(m) = eu1::mate(b, prim)? { + mate_map.insert(m, n + 1); + } + all_edgeuses.push(prim); + } + } + + // Shells + let mut all_faceuses = Vec::new(); + for (i, &s) in all_shells.iter().enumerate() { + map.insert(EntityRef::Shell(s), i as u64); + let shell = b.shell(s)?; + let mut sd = ShellData::zeroed(); + if shell.is_vertex_shell() { + sd.shell_type = 2; + sd.vertex = get_map(&map, shell.vertex(b)?.map(EntityRef::Vertex)); + } else if shell.is_wire_shell() { + sd.shell_type = 1; + sd.edge = get_map(&map, shell.wire_edge(b)?.map(EntityRef::Edge)); + } else { + // Normal (Faceuse) + let faceuses = shell.get_faceuses()?; + sd.shell_type = 0; + sd.faceuse_start = all_faceuses.len() as u64; + sd.num_faceuses = faceuses.len() as u64; + all_faceuses.extend(faceuses); + } + sd.topology = topology_data(env, &mut table, EntityRef::Shell(s)); + bd.shells.push(sd); + } + + // Faces (mapped before the face uses refer to them) + let faces = bl::faces(b, None)?; + for (i, &f) in faces.iter().enumerate() { + map.insert(EntityRef::Face(f), i as u64); + } + + // Faceuses + for (i, &fu) in all_faceuses.iter().enumerate() { + map.insert(EntityRef::Faceuse(fu), i as u64); + let mut fd = FaceuseData::zeroed(); + fd.orientation = same(eu1::faceuse_orientation(b, fu)?); + fd.face = get_map(&map, eu1::faceuse_face(b, fu)?.map(EntityRef::Face)); + fd.topology = topology_data(env, &mut table, EntityRef::Faceuse(fu)); + bd.faceuses.push(fd); + } + + // Surfaces owned by their faces + let face_ref = |f: FaceId| (env.key_of)(EntityRef::Face(f)); + let mut surface_index: BTreeMap<FaceId, u64> = BTreeMap::new(); + for f in surface_owners(b, None, &face_ref)? { + let surface = b.face(f)?.surface.as_ref().ok_or_else(null)?; + surface_index.insert(f, bd.surfaces.len() as u64); + bd.surfaces.push(Keyed { + key: (env.surface_key)(f), + object: surface.clone_for_evaluation()?, + }); + } + + // Faces + let mut all_loops = Vec::new(); + for &f in &faces { + let face_rec = b.face(f)?; + let loops = face::p2::loops(b, f, false, None)?; + let mut fd = FaceData::zeroed(); + fd.surface = surface_index.get(&f).copied().unwrap_or(NO_OBJECT); + fd.zone_tol_3d = face_rec.tolerance; + fd.rectangular_trim = face_rec.rectangular_trim; + fd.uv_domain = face_rec.uv_domain; + fd.start_loop = all_loops.len() as u64; + fd.num_loops = loops.len() as u64; + fd.topology = topology_data(env, &mut table, EntityRef::Face(f)); + all_loops.extend(loops); + bd.faces.push(fd); + } + + // Loops + for (i, &l) in all_loops.iter().enumerate() { + map.insert(EntityRef::Loop(l), i as u64); + + // set pLUT1 = topFU->loopuse (pLUT2 stays the other loop use) + let (lu1, lu2) = loopuse::loop_loopuses(b, l)?; + let lu2 = lu2.ok_or_else(null)?; + let fu = loopuse::faceuse(b, lu1)?.ok_or_else(null)?; + let lut1 = if eu1::faceuse_orientation(b, fu)? == OrientType::Opposite { + lu2 + } else { + lu1 + }; + + let mut ld = LoopData::zeroed(); + if loopuse::is_vertex_loopuse(b, lut1)? { + let vertices = loopuse::vertices(b, lut1, None)?; + let v = vertices.first().copied().ok_or_else(|| { + SolidError::invalid_input(OP_FROM, "vertex loop use without vertex") + })?; + ld.loop_type = 1; + ld.vertex = get_map(&map, v.map(EntityRef::Vertex)); + } else { + let edgeuses = loopuse::edgeuses(b, lut1, None, None)?; + ld.loop_type = 0; + ld.start_eu = all_edgeuses.len() as u64; + ld.num_eu = edgeuses.len() as u64; + for eu in edgeuses { + let n = all_edgeuses.len() as u64; + map.insert(EntityRef::Edgeuse(eu), n); + if let Some(m) = eu1::mate(b, eu)? { + mate_map.insert(m, n + 1); + } + all_edgeuses.push(eu); + } + } + ld.topology = topology_data(env, &mut table, EntityRef::Loop(l)); + ld.lu_upper_attributes = + extract_attributes(&mut table, (env.key_of)(EntityRef::Loopuse(lut1)).key); + ld.lu_lower_attributes = + extract_attributes(&mut table, (env.key_of)(EntityRef::Loopuse(lu2)).key); + bd.loops.push(ld); + } + + // Edgeuses (top face use edge uses and wire primary edge uses, not mates) + for &eu in &all_edgeuses { + let mut ed = EuData::zeroed(); + let next = eu1::edgeuse_next(b, eu)?.ok_or_else(null)?; + ed.next_eu = edgeuse_index(&map, &mate_map, next); + let mate = eu1::mate(b, eu)?.ok_or_else(null)?; + let mate_next = eu1::edgeuse_next(b, mate)?.ok_or_else(null)?; + ed.mate_next_eu = edgeuse_index(&map, &mate_map, mate_next); + ed.edge = get_map(&map, eu1::edge(b, eu)?.map(EntityRef::Edge)); + + if eu1::is_shell_edgeuse(b, eu)? { + // connects to a wire edge + ed.eu_type = 2; + ed.orientation = same(eu1::orientation(b, eu)?); + ed.shell = get_map(&map, eu1::shell(b, eu)?.map(EntityRef::Shell)); + } else { + // an edge in a loop connecting edges to a face + let lu = eu1::loopuse(b, eu)?.ok_or_else(null)?; + let l = loopuse::loop_(b, lu)?; + ed.eu_type = 0; + ed.loop_ = get_map(&map, l.map(EntityRef::Loop)); + ed.orientation = same(eu1::orientation(b, eu)?); + let (uv, slot) = eu1::uv_trim_curve(b, eu)?; + if let Some(c) = uv { + ed.uv_curve = bd.uv_curves.len() as u64; + bd.uv_curves.push(Keyed { + key: (env.uv_curve_key)(slot.unwrap_or(eu)), + object: c.clone_for_evaluation()?, + }); + } else { + ed.uv_curve = NO_OBJECT; + } + } + + let t = record_of(env, EntityRef::Edgeuse(eu)); + ed.topology.flags = t.all_flags(); + ed.topology.user_index1 = t.user_index1(); + ed.topology.user_index2 = t.user_index2(); + ed.topology.attributes = + extract_attributes(&mut table, (env.key_of)(EntityRef::Edgeuse(eu)).key); + + let mt = record_of(env, EntityRef::Edgeuse(mate)); + ed.mate_flags = mt.all_flags(); + ed.mate_user_index1 = mt.user_index1(); + ed.mate_user_index2 = mt.user_index2(); + ed.mate_attributes = + extract_attributes(&mut table, (env.key_of)(EntityRef::Edgeuse(mate)).key); + bd.edgeuses.push(ed); + } + + // Edges - set EdgeData objects and store Curves + for &e in &edges { + let prim = eu1::primary_edgeuse(b, e)?.ok_or_else(null)?; + let mut ed = EdgeData::zeroed(); + ed.prim_eu = edgeuse_index(&map, &mate_map, prim); + + let (start, end) = if eu1::orientation(b, prim)? == OrientType::Same { + let start = edge::vertex(b, e)?; + (start, edge::other_vertex(b, e, start)?) + } else { + let end = edge::vertex(b, e)?; + (edge::other_vertex(b, e, end)?, end) + }; + ed.start_vertex = get_map(&map, start.map(EntityRef::Vertex)); + ed.end_vertex = get_map(&map, end.map(EntityRef::Vertex)); + + // every edge owns its curve: a new 3D curve each + let edge_rec = b.edge(e)?; + let curve = edge_rec.curve.as_ref().ok_or_else(null)?; + ed.curve = bd.curves_3d.len() as u64; + bd.curves_3d.push(Keyed { + key: (env.curve_key)(e), + object: curve.clone_for_evaluation()?, + }); + + ed.zone_tol_3d = edge_rec.tolerance; + ed.interval = edge_rec.interval; + ed.topology = topology_data(env, &mut table, EntityRef::Edge(e)); + bd.edges.push(ed); + } + + // All curves & surfaces are borrowed from Brep (copied here) + bd.is_geom_borrowed = true; + Ok(bd) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::Kernel; + + /// The ASCII bytes `write_surface` writes for `s`. + fn surface_bytes(s: &Surface) -> Result<Vec<u8>> { + let mut db = DatabaseWriter::open_for_write(FileType::Ascii); + db.begin_writing(); + write_surface(s, &mut db, DATABASE_VERSION)?; + db.end_writing(); + Ok(db.into_bytes()) + } + + /// A lazy surface of extrusion and a lazy cone (no NURB yet) are written with the + /// NURB their `MakeNurb` gives: the bytes equal those of the same surface with its + /// NURB made first (upstream's `GetOrCreateGwNurbPointer`). A surface that has its + /// NURB is written byte for byte as before. + #[test] + fn lazy_surfaces_write_the_nurb_they_make() { + use crate::geom::extent2d::Extent2d; + use crate::nurbs::crv_basic::Curve as NurbCurve; + use crate::nurbs::math::{CPoint, NOW}; + use crate::surface::cone::Cone; + use crate::surface::surf_of_extrusion::SurfOfExtrusion; + let k = Kernel::default(); + + let pw = vec![ + CPoint::new(0.0, 0.0, 0.0, NOW), + CPoint::new(1.0, 1.0, 0.0, NOW), + CPoint::new(2.0, 0.0, 0.0, NOW), + CPoint::new(3.0, 0.5, 0.0, NOW), + ]; + let gen = NurbCurve::new(pw, 3, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).unwrap(); + let gen = Curve::from(BSplineCurve::from_nurb(gen, 3, false)); + let extrusion = SurfOfExtrusion::new( + &k, + gen, + Vec3::new(0.0, 0.0, 0.0), + Vec3::new(0.0, 0.0, 2.0), + Extent2d::from_bounds(0.0, 0.0, 1.0, 1.0), + false, + ) + .unwrap(); + let cone = Cone::new_bounded( + Vec3::new(0.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(0.0, 1.0, 0.0), + 2.0, + 1.0, + 0.0, + 270.0, + 3.0, + false, + false, + false, + ); + for lazy in [Surface::from(extrusion), Surface::from(cone)] { + assert!( + surface_bytes(&lazy).is_err(), + "a lazy surface has no NURB to write" + ); + // the same make on a separate copy: what upstream writes + let mut made = lazy.clone_for_evaluation().unwrap(); + match made.kind_mut() { + SurfaceKind::SurfOfExtrusion(s) => s.make_nurb(&k).unwrap(), + SurfaceKind::Cone(s) => s.make_nurb(&k).unwrap(), + _ => unreachable!(), + } + let want = surface_bytes(&made).unwrap(); + let mut written = lazy.clone_for_evaluation().unwrap(); + make_missing_nurb(&k, &mut written).unwrap(); + assert_eq!(surface_bytes(&written).unwrap(), want); + // a surface with its NURB: unchanged by make_missing_nurb + let mut again = made.clone_for_evaluation().unwrap(); + make_missing_nurb(&k, &mut again).unwrap(); + assert_eq!(surface_bytes(&again).unwrap(), want); + } + } + use crate::topology::database_io::fixed16; + + /// A version 42 file as `WriteToFile` writes it (topology only). + const V42: &str = "Version 42 +Healer Version 10 +//Brep Starts +1.0000000000000000 0.0000000000000000 0.0000000000000000 - Color +0.0000010000000000 - Brep ZoneTol3d +1 - is Manifold:[1 = is, 0 = isn't] +0 - has Composites:[1 = does, 0 = doesn't] + +//Region Data +2 Regions In Brep +0 - Start Region +//Index, Flags, UserId1, UserId2, Number Of Shells, First Shell, Is void Region +0 0 0 0 1 0 0 +1 4 7 0 0 1 1 + +//Shell Data +1 Shells In Brep +//Index, Flags, UserId1, UserId2, 0(Faceuse), Orientation, Number Of Faceuses, Start Faceuse +//Index, Flags, UserId1, UserId2, 1(WireEdge), Edge +//Index, Flags, UserId1, UserId2, 2(Vertex), Vertex +0 0 0 0 0 1 2 0 + +//Composite Face Data +0 Composite Faces In Brep + +//Faceuse Data +2 Faceuses In Brep +//Index, Flags, UserId1, UserId2, Face, Orientation +0 0 0 0 0 1 +1 0 0 0 0 0 + +//Face Data +1 Faces In Brep +//Index, Flags, UserId1, UserId2, Tol, Surface, Outer Loop, # of Loops, Naturally Trimmed +//UMin, UMax, VMin, VMax +0 0 0 0 0.0000010000000000 0 0 1 0 +-5.0000000000000000 5.0000000000000000 -1.5000000000000000 3.5000000000000000 + +//Loop Data +1 Loops In Brep +//Index, Flags, UserId1, UserId2, 0(Edgeuse), # Of Edgeuses, Start Edgeuse +//Index, Flags, UserId1, UserId2, 1(Vertex), Vertex +0 0 0 0 0 2 0 + +//Composite Edge Data +0 Composite Edges In Brep + +//Edgeuse Data +2 Edgeuses In Brep +//Index, Flags, UserId1, UserId2, MateFlags, MateUserId1, MateUserId2, 0(Edge), Edge, Loop, Orientation, UV Curve, Next EU, Mate Next EU +//Index, Flags, UserId1, UserId2, MateFlags, MateUserId1, MateUserId2, 1(Vertex), Vertex, Loop, Orientation, UV Curve +//Index, Flags, UserId1, UserId2, MateFlags, MateUserId1, MateUserId2, 2(Wire Edge), Edge, Shell, Orientation, UV Curve +//Index, Flags, UserId1, UserId2, MateFlags, MateUserId1, MateUserId2, 3(Vertex At Pole), Vertex, Loop, Orientation, UV Curve +0 3 0 0 3 0 0 0 0 0 1 9999999 1 -1 +1 0 0 0 0 0 0 0 0 0 0 9999999 0 -2 + +//Edge Data +1 Edges In Brep +//Index, Flags, UserId1, UserId2, Tol, Curve, Start Vertex, End Vertex, Primary Edgeuse, Param Min, Param Max +0 0 0 0 0.0000010000000000 0 0 0 0 0.0000000000000000 1.0000000000000000 + +//Vertex Data +1 Vertices In Brep +//Index, Flags, UserId1, UserId2, Tol, X, Y, Z +0 0 0 0 0.0000010000000000 5.0000000000000000 0.0000000000000000 -0.2500000000000000 + +// UV-Curve Data +0 UV-Curves In Brep + +// 3D-Curve Data +0 3D-Curves In Brep + +//Surface Data +0 Surfaces In Brep +"; + + #[test] + fn fixed16_matches_percent_16f() { + assert_eq!(fixed16(0.1), "0.1000000000000000"); + assert_eq!(fixed16(-0.0), "-0.0000000000000000"); + assert_eq!(fixed16(1e-17), "0.0000000000000000"); + assert_eq!(fixed16(123_456_789.123), "123456789.1229999959468842"); + assert_eq!(fixed16(f64::INFINITY), "inf"); + assert_eq!(fixed16(f64::NEG_INFINITY), "-inf"); + assert_eq!(fixed16(f64::NAN), "nan"); + } + + #[test] + fn ascii_write_reproduces_the_file() -> Result<()> { + let k = Kernel::default(); + let mut bd = BrepData::read_from_file(&k, V42.as_bytes().to_vec(), FileType::Ascii)?; + assert_eq!(bd.regions.get(1).map(|r| r.topology.user_index1), Some(7)); + let out = bd.write_to_file(FileType::Ascii)?; + assert_eq!(String::from_utf8_lossy(&out), V42); + Ok(()) + } + + #[test] + fn binary_write_reads_back() -> Result<()> { + let k = Kernel::default(); + let mut bd = BrepData::read_from_file(&k, V42.as_bytes().to_vec(), FileType::Ascii)?; + let bin = bd.write_to_file(FileType::Binary)?; + let mut back = BrepData::read_from_file(&k, bin.clone(), FileType::Binary)?; + assert_eq!(back.healer_version(), HEALER_VERSION); + assert_eq!(back.edgeuses.len(), 2); + assert_eq!(back.edgeuses.get(1).map(|e| e.mate_next_eu), Some(-2)); + assert_eq!(back.vertices.first().map(|v| v.point.z), Some(-0.25)); + assert_eq!(back.write_to_file(FileType::Binary)?, bin); + let text = back.write_to_file(FileType::Ascii)?; + assert_eq!(String::from_utf8_lossy(&text), V42); + Ok(()) + } + + #[test] + fn indexed_attributes_line() { + let mut db = DatabaseWriter::open_for_write(FileType::Ascii); + write_indexed_attributes(&[], &mut db); + write_indexed_attributes(&[3, 0], &mut db); + assert_eq!(db.bytes(), b" 0 \n 2 3 0 \n"); + } +} diff --git a/crates/solidcore/src/topology/database_io.rs b/crates/solidcore/src/topology/database_io.rs index 2ca9c1210e..e94ec288ee 100644 --- a/crates/solidcore/src/topology/database_io.rs +++ b/crates/solidcore/src/topology/database_io.rs @@ -24,14 +24,25 @@ //! | [`DatabaseReader::read_booleans`] | `SmDatabaseIO::ReadBooleans` | //! | [`DatabaseReader::read_short`] | `SmDatabaseIO::ReadShort` | //! | [`DatabaseReader::read_char`] | `SmDatabaseIO::ReadChar` | +//! | [`DatabaseReader::read_characters`] | `SmDatabaseIO::ReadCharacters` | //! | [`DatabaseReader::read_type`] | `SmDatabaseIO::ReadType` | //! | [`DatabaseReader::go_to_next_line`] | `SmDatabaseIO::GoToNextLine` | +//! | [`DatabaseWriter`] | `SmDatabaseIO` / `SmDatabaseIOFile` (write side) | +//! | [`DatabaseWriter::open_for_write`] | `SmDatabaseIOFile::OpenFileForWrite` (new file) | +//! | [`DatabaseWriter::write_long`] / [`DatabaseWriter::write_longs`] | `WriteLong` / `WriteLongs` | +//! | [`DatabaseWriter::write_double`] / [`DatabaseWriter::write_doubles`] | `WriteDouble` / `WriteDoubles` | +//! | [`DatabaseWriter::write_boolean`] / [`DatabaseWriter::write_booleans`] | `WriteBoolean` / `WriteBooleans` | +//! | [`DatabaseWriter::write_short`] / [`DatabaseWriter::write_char`] | `WriteShort` / `WriteChar` | +//! | [`DatabaseWriter::write_characters`] | `SmDatabaseIO::WriteCharacters` | +//! | [`DatabaseWriter::write_type`] | `SmDatabaseIO::WriteType` | +//! | [`DatabaseWriter::out_str`] ... [`fixed16`] | `*GetOutStreamPtr() << value` (`fixed`, precision 16) | //! | [`is_big_endian`] | `SmDatabaseIO::IsBigEndian` | //! | [`byte_swap_f64`] | `ByteSwap64(double)`, `ByteSwap128(double)` | //! | [`byte_swap_u64`] | `ByteSwap64(unsigned long)`, `ByteSwap64(unsigned long long)` | //! | [`byte_swap_u32`] | `ByteSwap32(unsigned long)`, `ByteSwap32(uint32_t)` | //! | [`byte_swap_u16`] | `ByteSwap16(unsigned short)` | //! | [`InputStream`] | the `std::istream` behind `m_pStreamIn` (libstdc++ semantics) | +//! | [`InputStream::getline`] | `std::istream::getline(char*, std::streamsize)` | //! | [`FileType`] | `SmFileType` | //! //! Deviations: @@ -58,8 +69,13 @@ //! `__builtin_object_size` when the caller is optimised, as the upstream build is. //! - `ByteSwap32(unsigned short)` is not ported: it is only reached when //! `sizeof(short) != 2` and always returns 0 (its shifts are truncated to 16 bits). -//! - `ReadCharacters` and the write side (`Write*`, `OpenFileForWrite`) belong to -//! other units and are not here. +//! - **Write side.** [`DatabaseWriter`] keeps the stream in memory (no filesystem on +//! wasm32): only a new file (`bNewFile = TRUE`) is supported and appending is the +//! caller's. Upstream's byte-swapped `WriteLong`/`WriteLongs` swap the variable's +//! address instead of its value (`ByteSwap64((unsigned long long) &llLong)`) and so +//! write garbage on big-endian machines or with `SM_BYTESWAP`; the port swaps the +//! value. On the little-endian reference build with `SM_ASCII` or `SM_BINARY` no +//! swap happens and the output agrees. //! - **Nesting bound.** Upstream recurses through nested polymorphic reads (a curve //! holding a curve holding ...) without limit. The port stops at //! [`MAX_READ_NESTING`] = 32 levels ([`DatabaseReader::enter_nested`]); upstream @@ -342,6 +358,42 @@ impl InputStream { } } + /// `istream::getline(buf, n)` (delimiter `'\n'`): the bytes stored in the + /// `n`-byte buffer (without the terminating NUL). Stores at most `n - 1` bytes, + /// stopping before the end of the data (`eofbit`) or at a newline (consumed, not + /// stored); when the buffer fills before a newline, `failbit` is set and the next + /// byte is left in the stream. Nothing extracted sets `failbit`, as does a stream + /// that was not good (nothing is read; the buffer is empty). + pub fn getline(&mut self, n: usize) -> Vec<u8> { + let mut out = Vec::new(); + if !self.sentry(false) { + return out; + } + let mut gcount = 0usize; + while gcount + 1 < n { + match self.peek_byte() { + Some(c) if c != b'\n' => { + out.push(c); + self.pos += 1; + gcount += 1; + } + _ => break, + } + } + match self.peek_byte() { + None => self.set_state(IoState::EOF), + Some(b'\n') => { + gcount += 1; + self.pos += 1; + } + Some(_) => self.set_state(IoState::FAIL), + } + if gcount == 0 { + self.set_state(IoState::FAIL); + } + out + } + /// `istream::get()`: the next byte, or `None` (`eofbit|failbit`) at the end /// (`failbit` if the stream was not good). pub fn get(&mut self) -> Option<u8> { @@ -967,6 +1019,13 @@ impl DatabaseReader { Ok(c) } + /// Reads `n` bytes (`ReadCharacters`; never swapped: `char` is one byte). + pub fn read_characters(&mut self, n: usize) -> Result<Vec<u8>> { + let items = + self.read_raw_items::<1>(n, "topology::database_io::DatabaseReader::read_characters")?; + Ok(items.into_iter().map(|[c]| c).collect()) + } + /// Reads an object type header (`ReadType`): in ASCII the line /// `Object Type <code>` and, when `with_dim`, `Curve Dim <dim>`; in binary a /// long and optionally an unsigned long. @@ -1005,6 +1064,218 @@ impl DatabaseReader { } } +// ---- write side ----------------------------------------------------------------------- + +/// `std::ostream << double` with `fixed` and `precision(16)` (glibc `%.16f`): `inf`, +/// `-inf`, `nan` and `-nan` for the special values. +#[must_use] +pub fn fixed16(x: f64) -> String { + if x.is_nan() { + if x.is_sign_negative() { + "-nan".to_string() + } else { + "nan".to_string() + } + } else if x.is_infinite() { + if x < 0.0 { + "-inf".to_string() + } else { + "inf".to_string() + } + } else { + format!("{x:.16}") + } +} + +/// Writes values to an SMLib database stream (upstream `SmDatabaseIO` write side over +/// the stream `SmDatabaseIOFile::OpenFileForWrite` opens), kept in memory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DatabaseWriter { + file_type: FileType, + byte_swap: bool, + out: Vec<u8>, +} + +impl DatabaseWriter { + /// `SmDatabaseIOFile::OpenFileForWrite(name, eType, TRUE)`: an empty stream of + /// `file_type`, byte-swapped for [`FileType::ByteSwap`] or on a big-endian machine. + /// An ASCII stream writes doubles `fixed` with precision 16 ([`fixed16`]). + #[must_use] + pub fn open_for_write(file_type: FileType) -> Self { + Self { + file_type, + byte_swap: file_type.default_byte_swap(), + out: Vec::new(), + } + } + + /// The file type (`GetFileType`). + #[must_use] + pub fn file_type(&self) -> FileType { + self.file_type + } + + /// Whether binary values are byte-swapped (`m_bByteSwap`). + #[must_use] + pub fn byte_swap(&self) -> bool { + self.byte_swap + } + + /// The bytes written so far (the file's contents). + #[must_use] + pub fn bytes(&self) -> &[u8] { + &self.out + } + + /// The bytes written (the file's contents). + #[must_use] + pub fn into_bytes(self) -> Vec<u8> { + self.out + } + + /// Hook before writing (`BeginWriting`); does nothing in the base writer. + pub fn begin_writing(&mut self) {} + + /// Hook after writing (`EndWriting`); does nothing in the base writer. + pub fn end_writing(&mut self) {} + + /// `*GetOutStreamPtr() << "text"`. + pub fn out_str(&mut self, s: &str) -> &mut Self { + self.out.extend_from_slice(s.as_bytes()); + self + } + + /// `*GetOutStreamPtr() << value` for a signed integer (`long`, `int`, `short`). + pub fn out_i64(&mut self, v: i64) -> &mut Self { + self.out.extend_from_slice(v.to_string().as_bytes()); + self + } + + /// `*GetOutStreamPtr() << value` for an unsigned integer (`ULONG`). + pub fn out_u64(&mut self, v: u64) -> &mut Self { + self.out.extend_from_slice(v.to_string().as_bytes()); + self + } + + /// `*GetOutStreamPtr() << value` for a double ([`fixed16`]). + pub fn out_f64(&mut self, v: f64) -> &mut Self { + self.out.extend_from_slice(fixed16(v).as_bytes()); + self + } + + /// `v0 << " " << v1 << ...`: doubles separated by single spaces (not an upstream + /// function). + pub fn out_reals(&mut self, values: &[f64]) -> &mut Self { + for (i, &v) in values.iter().enumerate() { + if i > 0 { + self.out_str(" "); + } + self.out_f64(v); + } + self + } + + /// One line of doubles, the common shape of the geometry writers (not an upstream + /// function): `v0 << " " << v1 ... << "\n"` in ASCII, `WriteDouble` of each in + /// binary. + pub fn reals(&mut self, values: &[f64]) { + if self.file_type == FileType::Ascii { + self.out_reals(values).out_str("\n"); + } else { + self.write_doubles(values); + } + } + + fn put(&mut self, bytes: &[u8]) { + self.out.extend_from_slice(bytes); + } + + /// Writes a signed 8-byte integer (`WriteLong`). + pub fn write_long(&mut self, v: i64) { + let v = if self.byte_swap { + byte_swap_u64(v as u64) as i64 + } else { + v + }; + self.put(&v.to_ne_bytes()); + } + + /// Writes unsigned 8-byte integers (`WriteLongs`). + pub fn write_longs(&mut self, values: &[u64]) { + for &v in values { + let v = if self.byte_swap { byte_swap_u64(v) } else { v }; + self.put(&v.to_ne_bytes()); + } + } + + /// Writes a double (`WriteDouble`). + pub fn write_double(&mut self, v: f64) { + let v = if self.byte_swap { byte_swap_f64(v) } else { v }; + self.put(&v.to_ne_bytes()); + } + + /// Writes doubles (`WriteDoubles`). + pub fn write_doubles(&mut self, values: &[f64]) { + for &v in values { + self.write_double(v); + } + } + + /// Writes bytes (`WriteCharacters`; never swapped: `char` is one byte). + pub fn write_characters(&mut self, chars: &[u8]) { + self.put(chars); + } + + /// Writes a 4-byte `SmBoolean` (`WriteBoolean`; never swapped, as upstream). + pub fn write_boolean(&mut self, v: i32) { + self.put(&v.to_ne_bytes()); + } + + /// Writes 4-byte `SmBoolean`s (`WriteBooleans`), byte-swapped when enabled. + pub fn write_booleans(&mut self, values: &[i32]) { + for &v in values { + if self.byte_swap { + self.put(&byte_swap_u32(v as u32).to_ne_bytes()); + } else { + self.put(&v.to_ne_bytes()); + } + } + } + + /// Writes a 2-byte `short` (`WriteShort`). + pub fn write_short(&mut self, v: i16) { + let v = if self.byte_swap { + byte_swap_u16(v as u16) as i16 + } else { + v + }; + self.put(&v.to_ne_bytes()); + } + + /// Writes one byte (`WriteChar`; never swapped). + pub fn write_char(&mut self, c: u8) { + self.put(&[c]); + } + + /// Writes an object type header (`WriteType`): in ASCII the line + /// `Object Type <code>` and, with `dim`, `Curve Dim <dim>`; in binary a long and + /// optionally a second long. + pub fn write_type(&mut self, type_code: i64, dim: Option<u64>) { + if self.file_type == FileType::Ascii { + self.out_str("Object Type ").out_i64(type_code); + self.out_str("\n"); + if let Some(d) = dim { + self.out_str("Curve Dim ").out_u64(d).out_str("\n"); + } + } else { + self.write_long(type_code); + if let Some(d) = dim { + self.write_long(d as i64); + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/solidcore/src/topology/geometry_write.rs b/crates/solidcore/src/topology/geometry_write.rs new file mode 100644 index 0000000000..440e9faf40 --- /dev/null +++ b/crates/solidcore/src/topology/geometry_write.rs @@ -0,0 +1,323 @@ +//! Writing curves and surfaces to an SMLib database stream: the virtual `WriteToDB` of +//! `SmCurve` and `SmSurface` dispatched over the concrete kinds, and the writers of the +//! surfaces of revolution and extrusion and their analytic kinds. +//! +//! Upstream: `source/SMLib/src/SmSurfOfRevolution.cpp`, `SmSurfOfExtrusion.cpp`, +//! `SmCone.cpp`, `SmCylinder.cpp`, `SmSphere.cpp`, `SmTorus.cpp` (and the `WriteToDB` +//! overrides of the other curve and surface classes, dispatched to). +//! +//! | Rust | Upstream | +//! |------------------------------|--------------------------------------------------| +//! | [`write_curve`] | `SmCurve::WriteToDB` (virtual) | +//! | [`write_surface`] | `SmSurface::WriteToDB` (virtual) | +//! | `write_surf_of_revolution` (private) | `SmSurfOfRevolution::WriteToDB` | +//! | `write_surf_of_extrusion` (private) | `SmSurfOfExtrusion::WriteToDB` | +//! | `write_cone` (private) | `SmCone::WriteToDB`, `SmCylinder::WriteToDB` | +//! | `write_sphere` (private) | `SmSphere::WriteToDB` | +//! | `write_torus` (private) | `SmTorus::WriteToDB` | +//! +//! Deviations: +//! - **Kinds without a writer.** The write side is not in the plan (see +//! `plan/findings.md`); only the kinds B-rep files hold are ported: B-spline curves, +//! lines, ellipses and circles; B-spline surfaces, planes, cones and cylinders, +//! spheres, tori, surfaces of revolution and extrusion, offset surfaces. Another +//! kind is [`SolidError::Unported`] with its `WriteToDB` symbol and unit +//! `(not in plan)`. +//! - **Missing NURB.** Upstream's analytic writers ignore the status of the B-spline +//! part they end with; it cannot fail there, as the virtual +//! `GetOrCreateGwNurbPointer` makes the NURB. The port cannot make a surface of +//! revolution's NURB without the kernel, so a missing NURB is an error (`SM_ERR`) +//! instead of a silently short record. +//! - **Recursion.** Writers nest through generator curves and offset bases. The depth +//! is counted and bounded by [`MAX_GEOMETRY_NESTING`] (the bound every geometry +//! constructor and the reader keep), past which the write is +//! [`SolidError::InvalidInput`]. + +use crate::curve::curve::{Curve, CurveKind}; +use crate::error::{Result, SolidError}; +use crate::surface::cone::Cone; +use crate::surface::sphere::Sphere; +use crate::surface::surf_of_extrusion::SurfOfExtrusion; +use crate::surface::surf_of_revolution::SurfOfRevolution; +use crate::surface::surface::p2::MAX_GEOMETRY_NESTING; +use crate::surface::surface::{Surface, SurfaceKind}; +use crate::surface::torus::Torus; +use crate::topology::database_io::{DatabaseWriter, FileType}; + +const OP_WRITE: &str = "topology::geometry_write::write_surface"; +const NOT_IN_PLAN: &str = "(not in plan)"; + +/// `pCrv->WriteToDB(rDB, lDBVersionNumber)`: writes `c` (without its type header) in +/// its own format. +/// +/// # Errors +/// +/// [`SolidError::Unported`] for a kind without a ported writer; those of the kind's +/// writer. +pub fn write_curve(c: &Curve, db: &mut DatabaseWriter, version: u64) -> Result<()> { + write_curve_at(c, db, version, 0) +} + +/// [`write_curve`] for a curve nested `depth` levels deep. +/// +/// # Errors +/// +/// Those of [`write_curve`]; [`SolidError::InvalidInput`] past +/// [`MAX_GEOMETRY_NESTING`]. +pub fn write_curve_at( + c: &Curve, + db: &mut DatabaseWriter, + version: u64, + depth: usize, +) -> Result<()> { + check_depth(depth)?; + match c.kind() { + CurveKind::BSpline(b) => b.write_to_db(db, version), + CurveKind::Line(l) => l.write_to_db(db, version), + CurveKind::Ellipse(e) => e.write_to_db(db, version), + CurveKind::Hermite(_) => Err(unported("SmHermiteCurve::WriteToDB")), + CurveKind::Hyperbola(_) => Err(unported("SmHyperbola::WriteToDB")), + CurveKind::Projected(_) => Err(unported("SmProjectedCurve::WriteToDB")), + CurveKind::Iso(_) => Err(unported("SmIsoCurve::WriteToDB")), + CurveKind::Offset(_) => Err(unported("SmOffsetCurve::WriteToDB")), + CurveKind::Conic(_) => Err(unported("SmConic::WriteToDB")), + CurveKind::CrvInVolume(_) => Err(unported("SmCrvInVolume::WriteToDB")), + CurveKind::CrvOnSurf(_) => Err(unported("SmCrvOnSurf::WriteToDB")), + CurveKind::Parabola(_) => Err(unported("SmParabola::WriteToDB")), + CurveKind::Composite(_) => Err(unported("SmCompositeCurve::WriteToDB")), + } +} + +/// `pSrf->WriteToDB(rDB, lDBVersionNumber)`: writes `s` (without its type header) in +/// its own format. +/// +/// # Errors +/// +/// [`SolidError::Unported`] for a kind without a ported writer; those of the kind's +/// writer. +pub fn write_surface(s: &Surface, db: &mut DatabaseWriter, version: u64) -> Result<()> { + write_surface_at(s, db, version, 0) +} + +/// [`write_surface`] for a surface nested `depth` levels deep. +/// +/// # Errors +/// +/// Those of [`write_surface`]; [`SolidError::InvalidInput`] past +/// [`MAX_GEOMETRY_NESTING`]. +pub fn write_surface_at( + s: &Surface, + db: &mut DatabaseWriter, + version: u64, + depth: usize, +) -> Result<()> { + check_depth(depth)?; + match s.kind() { + SurfaceKind::BSpline(b) => b.write_to_db(db, version), + SurfaceKind::Plane(p) => p.write_to_db(db, version), + SurfaceKind::Cone(c) => write_cone(c, db, version, depth), + SurfaceKind::Sphere(sp) => write_sphere(sp, db, version, depth), + SurfaceKind::Torus(t) => write_torus(t, db, version, depth), + SurfaceKind::SurfOfRevolution(r) => write_surf_of_revolution(r, db, version, depth), + SurfaceKind::SurfOfExtrusion(e) => write_surf_of_extrusion(e, db, version, depth), + SurfaceKind::OffsetSurface(o) => o.write_to_db(db, version, depth), + SurfaceKind::CurveBounded(_) => Err(unported("SmCurveBoundedSurface::WriteToDB")), + SurfaceKind::Step(_) => Err(unported("SmSTEPSurface::WriteToDB")), + SurfaceKind::SrfInVolume(_) => Err(unported("SmSrfInVolume::WriteToDB")), + } +} + +fn unported(symbol: &'static str) -> SolidError { + SolidError::unported(symbol, NOT_IN_PLAN) +} + +fn check_depth(depth: usize) -> Result<()> { + if depth > MAX_GEOMETRY_NESTING { + return Err(SolidError::invalid_input( + OP_WRITE, + "geometry nested too deeply", + )); + } + Ok(()) +} + +fn is_ascii(db: &DatabaseWriter) -> bool { + db.file_type() == FileType::Ascii +} + +/// `SmCone::WriteToDB` (`SmCylinder::WriteToDB` passes the call on): the base radius, +/// the semi-angle (degrees) and the inside-out flag, then the surface of revolution. +fn write_cone(c: &Cone, db: &mut DatabaseWriter, version: u64, depth: usize) -> Result<()> { + let inside_out = i32::from(c.inside_out); + if is_ascii(db) { + db.out_f64(c.base_radius); + db.out_str(" SmCone Radius of the cone in the plane passing through origin \n"); + db.out_f64(c.semi_angle_deg); + db.out_str(" SmCone SemiAngleDeg between -90 and 90 \n"); + db.out_i64(inside_out.into()); + db.out_str( + " SmCone InsideOutFlag: FALSE= m_vPosition.ZAxis points from bot to top of Nurb, \ + TRUE=Inverted \n", + ); + } else { + db.write_double(c.base_radius); + db.write_double(c.semi_angle_deg); + db.write_boolean(inside_out); + } + write_surf_of_revolution(&c.rev, db, version, depth) +} + +/// `SmSphere::WriteToDB`: the radius and the inside-out flag, then the surface of +/// revolution. +fn write_sphere(s: &Sphere, db: &mut DatabaseWriter, version: u64, depth: usize) -> Result<()> { + let inside_out = i32::from(s.inside_out); + if is_ascii(db) { + db.out_f64(s.radius).out_str(" SmSphere Sphere Radius \n"); + db.out_i64(inside_out.into()); + db.out_str(" SmSphere Sphere InsideOut: TRUE=outward pointing normal, FALSE=inverted \n"); + } else { + db.write_double(s.radius); + db.write_boolean(inside_out); + } + write_surf_of_revolution(&s.base, db, version, depth) +} + +/// `SmTorus::WriteToDB`: the major and minor radii and the inside-out flag, then the +/// surface of revolution. +fn write_torus(t: &Torus, db: &mut DatabaseWriter, version: u64, depth: usize) -> Result<()> { + let inside_out = i32::from(t.inside_out); + if is_ascii(db) { + db.out_f64(t.major_radius); + db.out_str(" SmTorus Torus Major Radius \n"); + db.out_f64(t.minor_radius); + db.out_str(" SmTorus Torus Minor Radius \n"); + db.out_i64(inside_out.into()); + db.out_str(" SmTorus Torus InsideOut: TRUE=outward pointing normal, FALSE=inverted \n"); + } else { + db.write_double(t.major_radius); + db.write_double(t.minor_radius); + db.write_boolean(inside_out); + } + write_surf_of_revolution(&t.base, db, version, depth) +} + +/// `SmSurfOfRevolution::WriteToDB`: the flags, the placement, the analytic UV domain +/// and the swap flag; then the generator curve with its type header, the polar +/// converter and the B-spline surface part. +fn write_surf_of_revolution( + r: &SurfOfRevolution, + db: &mut DatabaseWriter, + version: u64, + depth: usize, +) -> Result<()> { + let ascii = is_ascii(db); + let o = r.position.origin(); + let x = r.position.x_axis(); + let y = r.position.y_axis(); + let (lo, hi) = (r.anal_uv_domain.min(), r.anal_uv_domain.max()); + let make_nurb_gen_curve = i32::from(r.make_nurb_gen_curve); + let planar_generator = i32::from(r.planar_generator); + let swap_uv = i32::from(r.swap_uv); + if ascii { + db.out_i64(make_nurb_gen_curve.into()); + db.out_str( + " SmSurfOfRevolution Make Nurb GenCurve Flag, FALSE=Unbounded analytic, \ + TRUE=bounded \n", + ); + db.out_i64(planar_generator.into()); + db.out_str(" SmSurfOfRevolution Planar Generator Curve \n"); + db.out_reals(&[o.x, o.y, o.z]); + db.out_str(" SmSurfOfRevolution Placement Origin \n"); + db.out_reals(&[x.x, x.y, x.z]); + db.out_str(" SmSurfOfRevolution Placement X Axis \n"); + db.out_reals(&[y.x, y.y, y.z]); + db.out_str(" SmSurfOfRevolution Placement Y Axis \n"); + db.out_reals(&[lo.x, lo.y, hi.x, hi.y]); + db.out_str(" SmSurfOfRevolution AnalUVDomain \n"); + db.out_i64(swap_uv.into()); + db.out_str(" SmSurfOfRevolution SwapUV Boolean \n"); + } else { + db.write_boolean(make_nurb_gen_curve); + db.write_boolean(planar_generator); + db.write_doubles(&[o.x, o.y, o.z, x.x, x.y, x.z, y.x, y.y, y.z]); + db.write_doubles(&[lo.x, lo.y, hi.x, hi.y]); + db.write_boolean(swap_uv); + } + + // the generator curve (upstream dereferences a null one) + let gen = r + .gen_curve + .as_ref() + .ok_or_else(|| SolidError::null_pointer(OP_WRITE))?; + if ascii { + db.out_str(" SmSurfOfRevolution->GenCurve \n"); + } + db.write_type(gen.type_code(), Some(gen.dim())); + write_curve_at(gen, db, version, depth + 1)?; + + // the polar converter (with its own copy of the curve, if it has one) + if ascii { + db.out_str(" SmSurfOfRevolution->PolarConverter \n"); + } + r.polar + .write_to_db(db, version, r.polar_curve.as_ref(), depth + 1)?; + + r.base.write_to_db(db, version) +} + +/// `SmSurfOfExtrusion::WriteToDB`: the origin, the extrusion vector, the analytic UV +/// domain, the generator's orientation type, the swap flag and the generator's point +/// and vector; then the generator curve with its type header and the B-spline surface +/// part. +fn write_surf_of_extrusion( + e: &SurfOfExtrusion, + db: &mut DatabaseWriter, + version: u64, + depth: usize, +) -> Result<()> { + let ascii = is_ascii(db); + let (o, v) = (e.origin, e.extrusion_vec); + let (lo, hi) = (e.anal_uv_domain.min(), e.anal_uv_domain.max()); + let orientation = e.orientation as i64; + let swap_uv = i32::from(e.swap_uv); + let (cp, cv) = (e.curve_point, e.curve_vec); + if ascii { + db.out_reals(&[o.x, o.y, o.z]); + db.out_str(" SmSurfOfExtrusion Origin \n"); + db.out_reals(&[v.x, v.y, v.z]); + db.out_str(" SmSurfOfExtrusion Extrusion Vector \n"); + db.out_reals(&[lo.x, lo.y, hi.x, hi.y]); + db.out_str(" SmSurfOfExtrusion AnalUVDomain \n"); + db.out_i64(orientation); + db.out_str(" SmSurfOfExtrusion Curve Orientation Type \n"); + db.out_i64(swap_uv.into()); + db.out_str(" SmSurfOfExtrusion SwapUV Boolean \n"); + db.out_reals(&[cp.x, cp.y, cp.z]); + db.out_str( + " SmSurfOfExtrusion When GenCurve is planar or linear - point on plane or line\n", + ); + db.out_reals(&[cv.x, cv.y, cv.z]); + db.out_str( + " SmSurfOfExtrusion When GenCurve is planar or linear - plane normal or line vec \n", + ); + } else { + db.write_doubles(&[o.x, o.y, o.z, v.x, v.y, v.z]); + db.write_doubles(&[lo.x, lo.y, hi.x, hi.y]); + db.write_long(orientation); + db.write_boolean(swap_uv); + db.write_doubles(&[cp.x, cp.y, cp.z, cv.x, cv.y, cv.z]); + } + + // the generator curve (upstream dereferences a null one) + let gen = e + .gen_curve + .as_ref() + .ok_or_else(|| SolidError::null_pointer(OP_WRITE))?; + if ascii { + db.out_str(" SmSurfOfExtrusion->GenCurve \n"); + } + db.write_type(gen.type_code(), Some(gen.dim())); + write_curve_at(gen, db, version, depth + 1)?; + + e.base.write_to_db(db, version) +} diff --git a/crates/solidcore/src/topology/part_file.rs b/crates/solidcore/src/topology/part_file.rs new file mode 100644 index 0000000000..bb0d005994 --- /dev/null +++ b/crates/solidcore/src/topology/part_file.rs @@ -0,0 +1,928 @@ +//! Reading an SMLib part file (`.smp`, ASCII or binary): the stand-alone curves and +//! surfaces, the Boolean tree nodes and every B-rep of a part, with the attributes +//! they share, as `SmBrepData::ReadPartFromFile` does. +//! +//! Upstream: `source/SMLib/src/SmBrepData.cpp`, `source/SMLib/inc/SmBrepData.h`, +//! `source/SMLib/src/SmAttribute.cpp`. +//! +//! | Rust | Upstream | +//! |------------------------------|--------------------------------------------------| +//! | [`read_part_from_file`] | `SmBrepData::ReadPartFromFile` | +//! | [`read_part_from_db`] | `SmBrepData::ReadPartFromDB` | +//! | [`read_attribute_from_db`] | `SmBrepData::ReadAttributeFromDB` | +//! | [`create_attribute`] | `SmAttribute::CreateAttribute` | +//! | [`Part`] | the `r3DCurves`, `rSurfaces`, `rBooleanTreeNodes` and `rBreps` out-params | +//! | [`PartEnv`] | the attribute heap and the new objects' addresses | +//! +//! Deviations: +//! - **Out-params -> return value.** The part is returned as a [`Part`]. On an error +//! nothing is returned; upstream leaves what it read so far in the caller's arrays. +//! - **Files.** [`read_part_from_file`] takes the file's bytes (no filesystem on +//! wasm32); a file that cannot be opened is the caller's error. +//! - **Identities.** Upstream objects are known by address. [`PartEnv::key_of`] names +//! the topology objects of each B-rep (by B-rep index) and [`PartEnv::next_key`] +//! hands out the keys of the geometry read and of the B-reps' geometry copies, which +//! share one [`AttributeStore`] as upstream objects share the heap; the caller keeps +//! the two key ranges apart. +//! - **ASCII lines.** `fin.getline(sBuff, MAXSIZE)` is [`InputStream::getline`] with +//! `MAXSIZE` 256. `SSCANF(sBuff, "<text>%ld<text>", &x)` is `scan_long`, glibc's +//! rules for the part before the conversion (white space in the format matches any +//! white space, other characters must match) and for `%ld` (skips white space, an +//! optional sign, decimal digits, clamped to the `long` range on overflow); the text +//! after the conversion cannot change the value. A failed conversion stores nothing: +//! the counts keep their initial `0`. The `%ld` read into a `ULONG` count wraps a +//! negative value, as upstream. The `[Curve #%ld]`, `[Surface #%ld]` and +//! `[Brep #%ld]` indices are never used and are not scanned. The uninitialised +//! locals `lNodeValue` and `lTotalAttributes` are `0` when nothing is stored. +//! - **Counts (not upstream; upstream trusts them).** A header-declared count is +//! [`SolidError::InvalidInput`] when it is above its sanity cap +//! ([`MAX_PART_ITEMS`] curves, surfaces, tree nodes or attributes, +//! [`MAX_PART_BREPS`] B-reps, [`MAX_ATTRIBUTE_VALUES`] values of one attribute) or +//! when the remaining input cannot hold the smallest record of that kind +//! ([`DatabaseReader::ensure_available`] with the per-kind minimum sizes below). +//! Items are appended as they are read, never preallocated. Every loop over +//! such a count fails closed: before each item after the first the stream must be +//! good, and each item must consume input, else [`SolidError::InvalidInput`] +//! (upstream would go on reading nothing for the rest of the count). +//! - **Attribute table.** The placeholder table `sAttributes` is +//! [`AttributeSink::all`]; an index first seen past its end leaves `NULL` slots +//! below it. `MakeTopologyFromData` gets the table with each `NULL` slot standing +//! for an unused placeholder (no record refers to it: every index read made its +//! own); the placeholder is deleted at the end. The table is kept in step with the +//! slots as B-reps are read (new slots appended; a `NULL` slot a later B-rep fills is +//! updated from that B-rep's records), not rebuilt per B-rep. The attribute section reaching a +//! `NULL` slot (upstream dereferences it) or an index past the table (upstream reads +//! past the array) is [`SolidError::InvalidInput`]. +//! - **`CreateAttribute`.** The kernel registers no attribute callbacks, so none is +//! tried. A colour attribute with fewer than 3 doubles or a tag attribute with fewer +//! than 8 longs (upstream reads its uninitialised stack buffer) and a behaviour +//! outside `SmAttributeBehaviorType` (upstream casts it) are +//! [`SolidError::InvalidInput`]. +//! - **Recursion.** None here; nested curves are bounded by [`read_curve_from_db`]. + +use crate::curve::curve::Curve; +use crate::error::{EntityRef, Result, SolidError}; +use crate::geom::attribute::{ + ids, Attribute, AttributeHandle, AttributeStore, Behavior, ObjectKey, TagData, +}; +use crate::geom::object::ObjectRef; +use crate::kernel::Kernel; +use crate::math::Vec3; +use crate::surface::surface::Surface; +use crate::topology::brep::p10::{make_topology_from_data, MakeTopologyOptions, TopologyEnv}; +use crate::topology::brep_data::{ + read_curve_from_db, read_surface_from_db, AttributeSink, BrepData, Keyed, TopologyData, +}; +use crate::topology::database_io::{DatabaseReader, FileType, InputStream}; +use crate::topology::topology::Topology; +use crate::topology::Brep; +use std::collections::BTreeMap; + +const OP_PART: &str = "topology::part_file::read_part_from_db"; +const OP_ATTRIBUTE: &str = "topology::part_file::read_attribute_from_db"; +const OP_CREATE: &str = "topology::part_file::create_attribute"; + +/// Sanity cap on the stand-alone curves, surfaces, Boolean tree nodes and attributes a +/// part may declare (not upstream; upstream fixtures hold a few dozen). +pub const MAX_PART_ITEMS: u64 = 1_000_000; +/// Sanity cap on the B-reps a part may declare (not upstream; fixtures hold at most 51). +pub const MAX_PART_BREPS: u64 = 100_000; +/// Sanity cap on the longs, doubles or characters of one attribute (not upstream). +pub const MAX_ATTRIBUTE_VALUES: u64 = 1_000_000; + +/// Smallest stand-alone curve or surface record: ASCII, the two separator lines and the +/// `Object Type <n>` header (at least 16 bytes); binary, the type and dimension longs +/// (a surface: the type long and the B-spline indices). +const MIN_GEOMETRY_BYTES: usize = 16; +/// Smallest B-rep record: ASCII, the separator lines, `//Brep Starts` and the section +/// count lines; binary, the colour (or attribute count), tolerance, flags and the +/// twelve section counts (at least 128 bytes either way). +const MIN_BREP_BYTES: usize = 128; +/// Smallest attribute record: ASCII, two separator lines and the five-number header +/// line (12 bytes); binary, five longs (40). +const MIN_ATTRIBUTE_BYTES_ASCII: usize = 12; +const MIN_ATTRIBUTE_BYTES_BINARY: usize = 40; + +/// `MAXSIZE` of `SmBrepData.cpp`: the `getline` buffer size. +const MAXSIZE: usize = 256; + +/// What a part file holds (upstream's out-params). +#[derive(Debug)] +pub struct Part { + /// `r3DCurves`: the stand-alone curves, with their attribute keys. + pub curves: Vec<Keyed<Curve>>, + /// `rSurfaces`: the stand-alone surfaces, with their attribute keys. + pub surfaces: Vec<Keyed<Surface>>, + /// `rBooleanTreeNodes`: the Boolean tree nodes (see `SmMerge::BooleanTreeNodes`). + pub boolean_tree_nodes: Vec<i64>, + /// `rBreps`: the B-rep models. + pub breps: Vec<Brep>, +} + +/// The objects' identities and attributes of a part read (upstream: the heap and +/// the objects' addresses). +pub struct PartEnv<'a> { + /// Owner of every attribute read and of every object's attribute list. + pub attributes: &'a mut AttributeStore, + /// The identity of each topology entity of the B-rep with the given index (in + /// file order). + pub key_of: &'a dyn Fn(usize, EntityRef) -> ObjectRef, + /// The `SmTopology` records of the topology objects built, by identity. + pub records: &'a mut BTreeMap<ObjectKey, Topology>, + /// The next free identity for a geometry object (read or copied); incremented per + /// object. + pub next_key: u64, +} + +/// `*rDB.GetInStreamPtr()` (upstream dereferences `NULL` without a stream). +fn stream(db: &mut DatabaseReader) -> Result<&mut InputStream> { + db.in_stream_mut() + .ok_or_else(|| SolidError::null_pointer(OP_PART)) +} + +/// `fin.getline(sBuff, MAXSIZE)`. +fn line(db: &mut DatabaseReader) -> Result<Vec<u8>> { + Ok(stream(db)?.getline(MAXSIZE)) +} + +/// `std::isspace` in the "C" locale. +fn is_space(c: u8) -> bool { + c == b' ' || (b'\t'..=b'\r').contains(&c) +} + +fn skip_space(s: &[u8]) -> &[u8] { + let n = s.iter().take_while(|&&c| is_space(c)).count(); + s.get(n..).unwrap_or_default() +} + +/// `sscanf(line, "<prefix>%ld...", &x)`: the converted value, or `None` when `line` +/// does not match `prefix` or holds no number where the conversion starts (nothing +/// is stored). +fn scan_long(line: &[u8], prefix: &[u8]) -> Option<i64> { + let mut rest = line; + for &f in prefix { + if is_space(f) { + rest = skip_space(rest); + } else { + let (&c, tail) = rest.split_first()?; + if c != f { + return None; + } + rest = tail; + } + } + // %ld + rest = skip_space(rest); + let (negative, digits) = match rest.split_first() { + Some((b'-', tail)) => (true, tail), + Some((b'+', tail)) => (false, tail), + _ => (false, rest), + }; + let n = digits.iter().take_while(|c| c.is_ascii_digit()).count(); + if n == 0 { + return None; + } + // strtol: LONG_MIN / LONG_MAX on overflow + let clamped = if negative { i64::MIN } else { i64::MAX }; + let mut value: i64 = 0; + for &c in digits.get(..n)? { + let d = i64::from(c - b'0'); + let next = if negative { + value.checked_mul(10).and_then(|v| v.checked_sub(d)) + } else { + value.checked_mul(10).and_then(|v| v.checked_add(d)) + }; + match next { + Some(v) => value = v, + None => return Some(clamped), + } + } + Some(value) +} + +/// A header-declared count as a loop bound: rejected above `cap` or when the remaining +/// input cannot hold `min_bytes` per item. +fn checked_count( + db: &DatabaseReader, + count: u64, + cap: u64, + min_bytes: usize, + op: &'static str, +) -> Result<usize> { + let too_big = SolidError::InvalidInput { + operation: op, + reason: "header-declared size exceeds the remaining input", + }; + if count > cap { + return Err(too_big); + } + let n = usize::try_from(count).map_err(|_| too_big)?; + db.ensure_available(n, min_bytes, op)?; + Ok(n) +} + +/// The read position (`0` without a stream). +fn position(db: &DatabaseReader) -> usize { + db.in_stream().map_or(0, InputStream::position) +} + +/// Fail-closed check of item `i` of a counted loop, before it is read: from the second +/// item on, the stream must still be good (not upstream). +fn before_item(db: &DatabaseReader, i: usize, op: &'static str) -> Result<()> { + if i > 0 && !db.in_stream().is_some_and(InputStream::good) { + return Err(SolidError::invalid_input( + op, + "stream ended before the counted items", + )); + } + Ok(()) +} + +/// Fail-closed check after an item of a counted loop: it must have consumed input +/// (not upstream). +fn after_item(db: &DatabaseReader, start: usize, op: &'static str) -> Result<()> { + if position(db) == start { + return Err(SolidError::invalid_input( + op, + "counted item consumed no input", + )); + } + Ok(()) +} + +/// `SmBrepData::ReadPartFromFile`: reads a part from the contents of a database file +/// of `file_type` (see [`read_part_from_db`]). +/// +/// # Errors +/// +/// Those of [`read_part_from_db`]. +pub fn read_part_from_file( + k: &mut Kernel, + data: Vec<u8>, + file_type: FileType, + env: &mut PartEnv<'_>, +) -> Result<Part> { + // create databaseIO for operation + let mut db = DatabaseReader::new(); + db.open_for_read(data, file_type); + + // pass the call along + read_part_from_db(k, &mut db, env) +} + +/// `SmBrepData::ReadPartFromDB`: reads the part summary (curve, surface, tree node and +/// B-rep counts; the curve count carries the database version and the surface count +/// the healer version, each as millions), then the stand-alone curves (dimension 3) +/// and surfaces, the Boolean tree nodes and the B-reps, each read with attributes and +/// built by `MakeTopologyFromData(pBrepData, sAttributes, FALSE)`. For a version +/// above 30 the attributes follow; each replaces its placeholder on every object +/// using it, and the placeholders are deleted. +/// +/// # Errors +/// +/// Those of [`read_curve_from_db`], [`read_surface_from_db`], +/// [`BrepData::read_from_db`], [`make_topology_from_data`] and +/// [`read_attribute_from_db`]; a short binary stream, a missing stream, a count +/// exceeding the remaining input; [`SolidError::InvalidInput`] for an attribute +/// index past the placeholder table or on a `NULL` slot. +pub fn read_part_from_db( + k: &mut Kernel, + db: &mut DatabaseReader, + env: &mut PartEnv<'_>, +) -> Result<Part> { + let ascii = db.file_type() == FileType::Ascii; + let mut version: u64 = 30; + let mut healer_version: u64 = 0; + + // SmTArray<SmAttribute*> sAttributes; + let mut all: Vec<Option<AttributeHandle>> = Vec::new(); + + db.begin_reading(); + + let mut total_curves: u64 = 0; + let mut total_surfaces: u64 = 0; + let mut total_breps: u64 = 0; + let mut total_tree_nodes: u64 = 0; + + // read counts for stand-alone curves + if ascii { + line(db)?; // //[Output Summary] + if let Some(v) = scan_long(&line(db)?, b"// (1) ") { + total_curves = v as u64; + } + } else { + total_curves = db.read_ulong()?; + } + + // extract version number embedded within the curve count + if total_curves > 1_000_000 { + version = total_curves / 1_000_000; + total_curves %= 1_000_000; + db.set_version(version); + } + // else: WARN "Database being read has no lDBVersionNumber" + + // read counts for stand-alone surfaces + if ascii { + if let Some(v) = scan_long(&line(db)?, b"// (2) ") { + total_surfaces = v as u64; + } + } else { + // version >= 40: ignore(sizeof(long long) - sizeof(long)) skips nothing on LP64 + total_surfaces = db.read_ulong()?; + } + + // extract Healer version number embedded within the Surface count + if total_surfaces > 1_000_000 { + healer_version = total_surfaces / 1_000_000; + total_surfaces %= 1_000_000; + } + // else: WARN "Database being read has no lHealerVersion" + + // read counts for stand-alone boolean trees, and brep Models + if ascii { + if let Some(v) = scan_long(&line(db)?, b"// (3) ") { + total_tree_nodes = v as u64; + } + if let Some(v) = scan_long(&line(db)?, b"// (4) ") { + total_breps = v as u64; + } + } else { + total_tree_nodes = db.read_ulong()?; + total_breps = db.read_ulong()?; + } + + // stand-alone curves - no approximations + let geometry_bytes = MIN_GEOMETRY_BYTES; + let n = checked_count(db, total_curves, MAX_PART_ITEMS, geometry_bytes, OP_PART)?; + let mut curves = Vec::new(); + for i in 0..n { + before_item(db, i, OP_PART)?; + let start = position(db); + if ascii { + line(db)?; + line(db)?; // [Curve #i] + } + let mut attrs = AttributeSink::new(&mut *env.attributes, &mut all, env.next_key); + let c = read_curve_from_db(k, 3, version, &mut attrs, db, true)?; + env.next_key = attrs.next_key(); + curves.push(c); + after_item(db, start, OP_PART)?; + } + + // stand-alone surfaces - no approximations + let n = checked_count(db, total_surfaces, MAX_PART_ITEMS, geometry_bytes, OP_PART)?; + let mut surfaces = Vec::new(); + for j in 0..n { + before_item(db, j, OP_PART)?; + let start = position(db); + if ascii { + line(db)?; + line(db)?; // [Surface #j] + } + let mut attrs = AttributeSink::new(&mut *env.attributes, &mut all, env.next_key); + let s = read_surface_from_db(k, version, &mut attrs, db, true)?; + env.next_key = attrs.next_key(); + surfaces.push(s); + after_item(db, start, OP_PART)?; + } + + // boolean trees + let node_bytes = if ascii { 1 } else { 8 }; + let n = checked_count(db, total_tree_nodes, MAX_PART_ITEMS, node_bytes, OP_PART)?; + if ascii { + // Read Line With title + line(db)?; + line(db)?; + } + let mut boolean_tree_nodes = Vec::new(); + for m in 0..n { + before_item(db, m, OP_PART)?; + let start = position(db); + let node = if ascii { + scan_long(&line(db)?, b"").unwrap_or(0) + } else { + db.read_long()? + }; + boolean_tree_nodes.push(node); + after_item(db, start, OP_PART)?; + } + + // ASCII white space + if ascii { + line(db)?; + line(db)?; + } + + // Brep Models + let n = checked_count(db, total_breps, MAX_PART_BREPS, MIN_BREP_BYTES, OP_PART)?; + let mut breps = Vec::new(); + let mut table = PlaceholderTable::default(); + for i in 0..n { + before_item(db, i, OP_PART)?; + let start = position(db); + if ascii { + line(db)?; + line(db)?; + line(db)?; // [Brep #k] + } + + // allocate an empty and initialized SmBrep object + let mut b = Brep::new_with_infinite_region()?; + + // read the BrepData + let mut attrs = AttributeSink::new(&mut *env.attributes, &mut all, env.next_key); + let mut data = BrepData::read_from_db(k, version, &mut attrs, db, true)?; + env.next_key = attrs.next_key(); + + // Set pBrepData Healer Version + data.set_healer_version(healer_version); + + // Build Brep Model from BrepData + // FALSE: do not force creation of uv trim curves. + table.update(env.attributes, &all, &data)?; + let key_of = env.key_of; + let key = move |e: EntityRef| key_of(i, e); + let mut topology_env = TopologyEnv { + all_attributes: &table.handles, + attributes: &mut *env.attributes, + key_of: &key, + records: &mut *env.records, + next_key: env.next_key, + }; + let options = MakeTopologyOptions { + make_uv_trim_curves: false, + ..MakeTopologyOptions::default() + }; + make_topology_from_data(k, &mut b, &mut data, &mut topology_env, options)?; + env.next_key = topology_env.next_key; + breps.push(b); + after_item(db, start, OP_PART)?; + } + + // Now read in attributes - the placeholders collected in the table are replaced + // by the real attributes, which are in the same order in the file. + if version > 30 { + let replaced = replace_attributes(db, env.attributes, &all); + // SmObjsDelete<SmAttribute *> sCleanAttributes(&sAttributes) + let cleaned = delete_placeholders(env.attributes, all.iter().flatten().copied()); + replaced?; + cleaned?; + } + if let Some(h) = table.unused { + delete_placeholders(env.attributes, [h])?; + } + + db.end_reading(); + + Ok(Part { + curves, + surfaces, + boolean_tree_nodes, + breps, + }) +} + +/// The placeholder table as `MakeTopologyFromData` takes it, kept in step with the +/// slots: each `NULL` slot is `unused` (made once, referenced by no record). +#[derive(Debug, Default)] +struct PlaceholderTable { + handles: Vec<AttributeHandle>, + unused: Option<AttributeHandle>, +} + +impl PlaceholderTable { + /// The handle standing for a `NULL` slot. + fn unused(&mut self, store: &mut AttributeStore) -> Result<AttributeHandle> { + if let Some(h) = self.unused { + return Ok(h); + } + let h = store.insert(Attribute::new(ids::UNKNOWN, Behavior::StandaloneReference))?; + self.unused = Some(h); + Ok(h) + } + + /// Brings the table up to date after B-rep `data` was read into `all`: appends the + /// new slots, and updates the `NULL` slots the B-rep's records filled (a slot is + /// filled only when an index naming it is read, so those records list it). + fn update( + &mut self, + store: &mut AttributeStore, + all: &[Option<AttributeHandle>], + data: &BrepData, + ) -> Result<()> { + // slots filled below the old end: only indices this B-rep's records name + let old_len = self.handles.len(); + if self.unused.is_some() { + for &i in record_indices(data) { + let Ok(i) = usize::try_from(i) else { + continue; + }; + if i >= old_len { + continue; + } + if let (Some(slot), Some(Some(h))) = (self.handles.get_mut(i), all.get(i)) { + *slot = *h; + } + } + } + // new slots + for slot in all.iter().skip(old_len) { + let h = match slot { + Some(h) => *h, + None => self.unused(store)?, + }; + self.handles.push(h); + } + Ok(()) + } +} + +/// The attribute indices of one record. +fn topo(t: &TopologyData) -> core::slice::Iter<'_, u64> { + t.attributes.iter() +} + +/// Every attribute index the records of `data` list (the B-rep's own included). +fn record_indices(data: &BrepData) -> impl Iterator<Item = &u64> { + data.attributes + .iter() + .chain(data.regions.iter().flat_map(|r| topo(&r.topology))) + .chain(data.shells.iter().flat_map(|s| topo(&s.topology))) + .chain(data.faceuses.iter().flat_map(|f| topo(&f.topology))) + .chain(data.faces.iter().flat_map(|f| topo(&f.topology))) + .chain(data.loops.iter().flat_map(|l| { + topo(&l.topology) + .chain(l.lu_upper_attributes.iter()) + .chain(l.lu_lower_attributes.iter()) + })) + .chain( + data.edgeuses + .iter() + .flat_map(|e| topo(&e.topology).chain(e.mate_attributes.iter())), + ) + .chain(data.edges.iter().flat_map(|e| topo(&e.topology))) + .chain(data.vertices.iter().flat_map(|v| topo(&v.topology))) +} + +/// `delete` of each placeholder still alive. +fn delete_placeholders( + store: &mut AttributeStore, + handles: impl IntoIterator<Item = AttributeHandle>, +) -> Result<()> { + for h in handles { + if store.is_live(h) { + store.destroy(h)?; + } + } + Ok(()) +} + +/// The attribute section of [`read_part_from_db`]: the attribute count, then each +/// attribute, which replaces the placeholder of the same index on every object using +/// it (an attribute without users is dropped). +fn replace_attributes( + db: &mut DatabaseReader, + store: &mut AttributeStore, + all: &[Option<AttributeHandle>], +) -> Result<()> { + let ascii = db.file_type() == FileType::Ascii; + + // read total attribute count (uninitialised upstream when nothing is stored) + let mut total: u64 = 0; + if ascii { + line(db)?; + if let Some(v) = scan_long(&line(db)?, b"") { + total = v as u64; + } + } else { + total = db.read_ulong()?; + } + + // read each attribute (bounded as the other counted sections) + let min_bytes = if ascii { + MIN_ATTRIBUTE_BYTES_ASCII + } else { + MIN_ATTRIBUTE_BYTES_BINARY + }; + let total = checked_count(db, total, MAX_PART_ITEMS, min_bytes, OP_PART)?; + for j in 0..total { + before_item(db, j, OP_PART)?; + let start = position(db); + if ascii { + // clear ascii white space + line(db)?; + line(db)?; + } + + // read the attribute + let attribute = read_attribute_from_db(db)?; + after_item(db, start, OP_PART)?; + + // Replace and update attribute in sAttributes array + let slot = all.get(j).ok_or(SolidError::InvalidInput { + operation: OP_PART, + reason: "attribute index past the attribute table", + })?; + let temp = slot.ok_or(SolidError::InvalidInput { + operation: OP_PART, + reason: "attribute table slot is NULL", + })?; + + // get every object using this attribute + let users = store.users(temp)?; + + // User may have been deleted by heal sequence in MakeTopologyFromData + if users.is_empty() { + continue; // delete pAttribute + } + let new = store.insert(attribute)?; + // for every attribute user: substitute attribute + for user in users { + store.remove_attribute(user, temp, false)?; + store.add_attribute(user, new)?; + } + } + Ok(()) +} + +/// `SmBrepData::ReadAttributeFromDB`: reads one attribute (id, behaviour and its +/// longs, doubles and characters) and makes it with [`create_attribute`]. +/// +/// # Errors +/// +/// A short binary stream, a missing stream, a count exceeding the remaining input; +/// those of [`create_attribute`]. +pub fn read_attribute_from_db(db: &mut DatabaseReader) -> Result<Attribute> { + // uninitialised upstream when nothing is stored + let mut id: u64 = 0; + let mut behavior: i64 = 0; + let mut num_long: u64 = 0; + let mut num_double: u64 = 0; + let mut num_char: u64 = 0; + let mut longs = Vec::new(); + let mut doubles = Vec::new(); + let mut chars = Vec::new(); + + if db.file_type() == FileType::Ascii { + let s = stream(db)?; + if let Some(v) = s.extract_ulong() { + id = v; + } + if let Some(v) = s.extract_long() { + behavior = v; + } + if let Some(v) = s.extract_ulong() { + num_long = v; + } + if let Some(v) = s.extract_ulong() { + num_double = v; + } + if let Some(v) = s.extract_ulong() { + num_char = v; + } + db.go_to_next_line(); + + // read the longs ("<n> " each: at least two bytes) + let n = checked_count(db, num_long, MAX_ATTRIBUTE_VALUES, 2, OP_ATTRIBUTE)?; + for i in 0..n { + before_item(db, i, OP_ATTRIBUTE)?; + let start = position(db); + longs.push(stream(db)?.extract_long().unwrap_or(0)); + after_item(db, start, OP_ATTRIBUTE)?; + } + if num_long > 0 { + db.go_to_next_line(); + } + // read the doubles + let n = checked_count(db, num_double, MAX_ATTRIBUTE_VALUES, 2, OP_ATTRIBUTE)?; + for i in 0..n { + before_item(db, i, OP_ATTRIBUTE)?; + let start = position(db); + doubles.push(stream(db)?.extract_double().unwrap_or(0.0)); + after_item(db, start, OP_ATTRIBUTE)?; + } + if num_double > 0 { + db.go_to_next_line(); + } + // read the characters + let n = checked_count(db, num_char, MAX_ATTRIBUTE_VALUES, 1, OP_ATTRIBUTE)?; + for i in 0..n { + before_item(db, i, OP_ATTRIBUTE)?; + let start = position(db); + chars.push(stream(db)?.get().unwrap_or(b'0')); + after_item(db, start, OP_ATTRIBUTE)?; + } + if num_char > 0 { + db.go_to_next_line(); + } + } else { + id = db.read_ulong()?; + behavior = db.read_long()?; + num_long = db.read_ulong()?; + num_double = db.read_ulong()?; + num_char = db.read_ulong()?; + + if num_long > 0 { + let n = checked_count(db, num_long, MAX_ATTRIBUTE_VALUES, 8, OP_ATTRIBUTE)?; + longs = db.read_longs(n)?.into_iter().map(|v| v as i64).collect(); + } + if num_double > 0 { + let n = checked_count(db, num_double, MAX_ATTRIBUTE_VALUES, 8, OP_ATTRIBUTE)?; + doubles = db.read_doubles(n)?; + } + if num_char > 0 { + let n = checked_count(db, num_char, MAX_ATTRIBUTE_VALUES, 1, OP_ATTRIBUTE)?; + chars = db.read_characters(n)?; + } + } + + create_attribute(id, behavior, &longs, &doubles, &chars) +} + +/// `SmAttribute::CreateAttribute`: a colour id ([`ids::COLOR`]) makes a vector +/// attribute from the first three doubles, a tag id ([`ids::TAG`]) a tag attribute +/// from the first eight longs, any other id a generic attribute holding the values. +/// +/// # Errors +/// +/// [`SolidError::InvalidInput`] for a colour with fewer than 3 doubles, a tag with +/// fewer than 8 longs or a behaviour that is not an `SmAttributeBehaviorType` value. +pub fn create_attribute( + attribute_id: u64, + behavior: i64, + longs: &[i64], + doubles: &[f64], + chars: &[u8], +) -> Result<Attribute> { + let behavior = i32::try_from(behavior) + .map_err(|_| SolidError::invalid_input(OP_CREATE, "behaviour out of range")) + .and_then(Behavior::from_code)?; + match attribute_id { + ids::COLOR => match doubles { + [x, y, z, ..] => Ok(Attribute::vector3d( + attribute_id, + Vec3::new(*x, *y, *z), + behavior, + )), + _ => Err(SolidError::invalid_input( + OP_CREATE, + "colour attribute without 3 doubles", + )), + }, + ids::TAG => match longs { + [a, b, c, d, e, f, g, h, ..] => { + let tag = TagData { + primary_id: *a, + primary_ordinal: *b, + secondary_id_type: *c, + secondary_id: *d, + secondary_ordinal: *e, + third_id_type: *f, + third_id: *g, + third_ordinal: *h, + }; + Ok(Attribute::tag(attribute_id, tag, behavior)) + } + _ => Err(SolidError::invalid_input( + OP_CREATE, + "tag attribute without 8 longs", + )), + }, + // Just create a generic attribute + _ => Ok(Attribute::generic( + attribute_id, + behavior, + longs, + doubles, + chars, + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scan(line: &str, prefix: &str) -> Option<i64> { + scan_long(line.as_bytes(), prefix.as_bytes()) + } + + #[test] + fn scan_long_follows_sscanf() { + let p1 = "// (1) "; + assert_eq!(scan("// (1) 32000000 Curves", p1), Some(32_000_000)); + assert_eq!(scan("// (1) -7 x", p1), Some(-7)); + assert_eq!(scan("// (2) 5", p1), None); + assert_eq!(scan(" 12 // Total", ""), Some(12)); + assert_eq!(scan("x", ""), None); + assert_eq!(scan("", ""), None); + assert_eq!(scan("99999999999999999999", ""), Some(i64::MAX)); + assert_eq!(scan("-99999999999999999999", ""), Some(i64::MIN)); + } + + #[test] + fn getline_follows_libstdcxx() { + let mut s = InputStream::new(b"ab\n\ncd".to_vec()); + assert_eq!(s.getline(MAXSIZE), b"ab"); + assert!(s.good()); + assert_eq!(s.getline(MAXSIZE), b""); + assert!(s.good()); + assert_eq!(s.getline(MAXSIZE), b"cd"); + assert!(s.eof()); + assert!(!s.fail()); + assert_eq!(s.getline(MAXSIZE), b""); + assert!(s.fail()); + + // a full buffer before the newline fails and leaves the rest + let mut s = InputStream::new(b"abcdef\n".to_vec()); + assert_eq!(s.getline(4), b"abc"); + assert!(s.fail()); + // exactly n - 1 bytes then the newline is fine + let mut s = InputStream::new(b"abc\nx".to_vec()); + assert_eq!(s.getline(4), b"abc"); + assert!(s.good()); + } + + fn key_of(_: usize, _: EntityRef) -> ObjectRef { + ObjectRef { + type_code: 0, + key: ObjectKey(0), + } + } + + fn read(text: &str) -> Result<Part> { + let mut k = Kernel::default(); + let mut store = AttributeStore::new(); + let mut records = BTreeMap::new(); + let mut env = PartEnv { + attributes: &mut store, + key_of: &key_of, + records: &mut records, + next_key: 1 << 50, + }; + let data = text.as_bytes().to_vec(); + read_part_from_file(&mut k, data, FileType::Ascii, &mut env) + } + + const HEAD: &str = "//[Output Summary]\n// (1) 32000000 Curves\n// (2) 0 Surfaces\n"; + + #[test] + fn empty_part_reads() -> Result<()> { + let text = format!( + "{HEAD}// (3) 2 Boolean Tree Nodes\n// (4) 0 Breps\n\n// Boolean Trees\n\ + 7\n-3\n\n\n\n0 // Total number of attributes\n" + ); + let part = read(&text)?; + assert!(part.curves.is_empty()); + assert!(part.surfaces.is_empty()); + assert!(part.breps.is_empty()); + assert_eq!(part.boolean_tree_nodes, vec![7, -3]); + Ok(()) + } + + #[test] + fn counts_beyond_the_input_are_rejected() { + let text = "//[Output Summary]\n// (1) 32999999 Curves\n"; + assert!(matches!(read(text), Err(SolidError::InvalidInput { .. }))); + let text = format!("{HEAD}// (3) 0 Boolean Tree Nodes\n// (4) 9000000000 Breps\n"); + assert!(matches!(read(&text), Err(SolidError::InvalidInput { .. }))); + } + + #[test] + fn counted_loops_fail_closed() { + // four tree nodes declared, the stream ends after two + let text = format!( + "{HEAD}// (3) 4 Boolean Tree Nodes\n// (4) 0 Breps\n\n// Boolean Trees\n7\n-3" + ); + assert!(matches!(read(&text), Err(SolidError::InvalidInput { .. }))); + // more B-reps than the cap + let text = format!("{HEAD}// (3) 0 Boolean Tree Nodes\n// (4) 100001 Breps\n"); + assert!(matches!(read(&text), Err(SolidError::InvalidInput { .. }))); + } + + #[test] + fn attribute_past_the_table_is_rejected() { + let text = format!( + "{HEAD}// (3) 0 Boolean Tree Nodes\n// (4) 0 Breps\n\n// Boolean Trees\n\n\n\n\ + 1 // Total number of attributes\n\nAttribute #0\n100 0 0 3 0\n1 2 3\n" + ); + assert!(matches!(read(&text), Err(SolidError::InvalidInput { .. }))); + } + + #[test] + fn create_attribute_by_id() -> Result<()> { + let rgb = [0.5, 0.25, 1.0]; + let c = create_attribute(ids::COLOR, 0, &[], &rgb, &[])?; + assert_eq!(c.vector3d_value(), Some(Vec3::new(0.5, 0.25, 1.0))); + assert!(create_attribute(ids::COLOR, 0, &[], &[0.5], &[]).is_err()); + let tag = [1, 2, 3, 4, 5, 6, 7, 8]; + let t = create_attribute(ids::TAG, 1, &tag, &[], &[])?; + assert_eq!(t.behavior(), Behavior::Reference); + assert!(create_attribute(ids::TAG, 0, &[1, 2], &[], &[]).is_err()); + let g = create_attribute(5000, 3, &[9], &[1.5], b"ab")?; + assert_eq!(g.attribute_id(), 5000); + assert!(create_attribute(5000, 9, &[], &[], &[]).is_err()); + Ok(()) + } +} diff --git a/harness/cpp/shim/corpus_parity.cpp b/harness/cpp/shim/corpus_parity.cpp new file mode 100644 index 0000000000..03bea3fac4 --- /dev/null +++ b/harness/cpp/shim/corpus_parity.cpp @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 +// difftest shim for unit `corpus.parity`: reads one upstream test model with +// SmBrep::ReadFromFile (a B-rep file) or SmBrepData::ReadPartFromFile (a part file: one +// that starts with `//[Output Summary]`) and measures every B-rep as the Rust side does +// (crates/difftest/src/units/corpus_parity.rs, after crates/solidcore/examples/corpus): +// the kind (IsManifoldSolid, IsSheet), the face, edge, vertex and loop counts, the area +// (SmApiFaceComputeArea's per-face integration without its accuracy clamp) and, for a +// solid, the volume (SmApiBrepComputeVolume). `sc_corpus_parity_roundtrip` measures each +// B-rep after an ASCII write (SmBrep::WriteToFile) and read back instead. +// +// The objects read are leaked on purpose: the runner calls this in a child process that +// exits right after (a crash or hang on one model must not stop the run). +#include "sc_shim.h" +#include "StdAfx.h" +#include <SmApiQueries.h> +#include <SmBrep.h> +#include <SmBrepData.h> +#include <SmContext.h> +#include <SmCurve.h> +#include <SmEdge.h> +#include <SmExtent3d.h> +#include <SmFace.h> +#include <SmLoop.h> +#include <SmSurface.h> +#include <SmVertex.h> +#include <cmath> +#include <cstdio> +#include <cstring> +#include <limits> +#include <vector> + +namespace { +const SmContext& the_context() +{ + static SmContext* p = new SmContext(); + return *p; +} + +// Whether the file starts with the part file header (as the Rust side tests it). +bool is_part_file(const char* path) +{ + static const char header[] = "//[Output Summary]"; + const size_t n = sizeof(header) - 1; + char buf[sizeof(header)] = {0}; + FILE* f = std::fopen(path, "rb"); + if (f == nullptr) + return false; + size_t got = std::fread(buf, 1, n, f); + std::fclose(f); + return got == n && std::memcmp(buf, header, n) == 0; +} + +// SmApiFaceComputeArea without its [1e-4, 1e-1] accuracy clamp: face box midpoint as +// origin, area estimate (max box side)^2 / 100, area only, made positive. +SmStatus face_area(const SmFace* face, double accuracy, double& area) +{ + SmExtent3d box; + SmStatus st = face->CalculateBoundingBox(box); + if (st != SM_SUCCESS) + return st; + SmVector3d size = box.GetSize(); + double d = smos_Max(size.z, smos_Max(size.x, size.y)); + double dv = 0.0; + SmTArray<SmVector3d> moments; + st = face->ComputePreciseProperties(SM_OT_SAME, accuracy, box.GetMid(), d * d / 100.0, 0.0, + area, dv, moments, SM_PPF_AREA); + if (area < 0.0) + area = -area; + return st; +} + +// One B-rep: kind (0 empty, 1 solid, 2 sheet, 3 other), faces, edges, vertices, loops, +// measure status (0 or the first failing SmStatus), area, volume (NaN unless a solid). +void measure(SmBrep* b, double accuracy, std::vector<double>& o) +{ + SmTArray<SmFace*> faces; + SmTArray<SmEdge*> edges; + SmTArray<SmVertex*> vertices; + b->GetFaces(faces); + b->GetEdges(edges); + b->GetVertices(vertices); + ULONG loops = 0; + for (ULONG i = 0; i < faces.GetSize(); i++) + { + SmTArray<SmLoop*> l; + faces[i]->GetLoops(l); + loops += l.GetSize(); + } + int kind = 3; + if (faces.GetSize() == 0 && edges.GetSize() == 0 && vertices.GetSize() == 0) + kind = 0; + else if (b->IsManifoldSolid()) + kind = 1; + else if (b->IsSheet()) + kind = 2; + + SmStatus status = SM_SUCCESS; + double area = 0.0; + for (ULONG i = 0; i < faces.GetSize() && status == SM_SUCCESS; i++) + { + double a = 0.0; + status = face_area(faces[i], accuracy, a); + area += a; + } + double volume = std::numeric_limits<double>::quiet_NaN(); + if (status == SM_SUCCESS && kind == 1) + { + double v = 0.0; + SmApiStatus vs = SmApiBrepComputeVolume(b, accuracy, v); + if (vs == SM_SUCCESS) + volume = v; + else + status = (SmStatus)vs; + } + o.insert(o.end(), {(double)kind, (double)faces.GetSize(), (double)edges.GetSize(), + (double)vertices.GetSize(), (double)loops, + status == SM_SUCCESS ? 0.0 : (double)status, area, volume}); +} + +// The B-reps of model file `path` (one from a B-rep file, any number from a part file); +// the reader's status. +SmStatus read_model(const SmContext& ctx, const char* path, std::vector<SmBrep*>& breps) +{ + SmStatus st; + if (is_part_file(path)) + { + SmTArray<SmCurve*> curves; + SmTArray<SmSurface*> surfaces; + SmTArray<long> trees; + SmTArray<SmBrep*> read; + st = SmBrepData::ReadPartFromFile(ctx, path, curves, surfaces, trees, read, SM_ASCII); + for (ULONG i = 0; i < read.GetSize(); i++) + if (read[i] != nullptr) + breps.push_back(read[i]); + } + else + { + SmBrep* b = new (ctx) SmBrep(); + st = b->ReadFromFile(ctx, path, SM_ASCII); + breps.push_back(b); + } + return st; +} +} // namespace + +// Output: `read_status n` then 8 values per B-rep (see `measure`); `read_status` is 0 or +// the reader's SmStatus (then `n` is 0). +SC_EXPORT int32_t sc_corpus_parity_measure(const char* path, double accuracy, double* out, + int64_t cap, int64_t* out_len) +{ + if (path == nullptr || !(accuracy > 0.0) || !std::isfinite(accuracy)) + return SC_ERR_ARG; + SC_TRY + std::vector<SmBrep*> breps; + SmStatus st = read_model(the_context(), path, breps); + std::vector<double> o; + if (st != SM_SUCCESS) + { + o.push_back((double)st); + o.push_back(0.0); + } + else + { + o.push_back(0.0); + o.push_back((double)breps.size()); + for (SmBrep* b : breps) + measure(b, accuracy, o); + } + return sc_copy_out(o.data(), (int64_t)o.size(), out, cap, out_len); + SC_CATCH +} + +// The round trip of every B-rep of model file `path`: `SmBrep::WriteToFile` (SM_ASCII, +// native geometry; `SmBrepData::FromBrep` then `WriteToFile`) to `tmp`, a new B-rep +// read back from it with `ReadFromFile`, measured as `sc_corpus_parity_measure` does. +// Output as `sc_corpus_parity_measure`'s, with the read-back B-reps' records; a B-rep +// whose write or read back fails has the failing SmStatus as its record's status. +SC_EXPORT int32_t sc_corpus_parity_roundtrip(const char* path, const char* tmp, + double accuracy, double* out, int64_t cap, + int64_t* out_len) +{ + if (path == nullptr || tmp == nullptr || !(accuracy > 0.0) || !std::isfinite(accuracy)) + return SC_ERR_ARG; + SC_TRY + const SmContext& ctx = the_context(); + std::vector<SmBrep*> breps; + SmStatus st = read_model(ctx, path, breps); + std::vector<double> o; + if (st != SM_SUCCESS) + { + o.push_back((double)st); + o.push_back(0.0); + } + else + { + o.push_back(0.0); + o.push_back((double)breps.size()); + const double nan = std::numeric_limits<double>::quiet_NaN(); + for (SmBrep* b : breps) + { + SmStatus ws = b->WriteToFile(tmp, SM_ASCII, TRUE, FALSE, 0.0); + SmBrep* back = new (ctx) SmBrep(); + SmStatus rs = ws == SM_SUCCESS ? back->ReadFromFile(ctx, tmp, SM_ASCII) : ws; + if (rs == SM_SUCCESS) + measure(back, accuracy, o); + else + o.insert(o.end(), {0.0, 0.0, 0.0, 0.0, 0.0, (double)rs, 0.0, nan}); + } + std::remove(tmp); + } + return sc_copy_out(o.data(), (int64_t)o.size(), out, cap, out_len); + SC_CATCH +} diff --git a/harness/cpp/shim/topology_part_file.cpp b/harness/cpp/shim/topology_part_file.cpp new file mode 100644 index 0000000000..2a2b97a78d --- /dev/null +++ b/harness/cpp/shim/topology_part_file.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +// difftest shim for the part reader (`topology::part_file`, outside the plan): +// SmBrepData::ReadPartFromFile (source/SMLib/src/SmBrepData.cpp) on an upstream test file. +// +// sc_topology_part_file_run reads the part file at `path` and writes a flat list of +// doubles (layout in crates/solidcore-ffi/src/units/topology_part_file.rs): the status, +// then per stand-alone curve its type, dimension and natural interval, per surface its +// type and natural UV domain, the Boolean tree nodes, and per B-rep its tolerance, +// manifold-solid flag and region, shell, face, loop, edge and vertex counts. +#include "sc_shim.h" + +#include "StdAfx.h" +#include <SmBrep.h> +#include <SmBrepData.h> +#include <SmContext.h> +#include <SmCurve.h> +#include <SmExtent1d.h> +#include <SmExtent2d.h> +#include <SmFace.h> +#include <SmSurface.h> +#include <SmTArray.h> + +#include <vector> + +SC_EXPORT int32_t sc_topology_part_file_run(const char* path, int32_t file_type, double* out, + int64_t cap, int64_t* out_len) +{ + if (path == NULL || file_type < 0 || file_type > 1) + return SC_ERR_ARG; + SC_TRY + // one context for the process, never destroyed, as the corpus.parity shim's (whose + // reads of the same part files succeed) + static SmContext* context = new SmContext(); + const SmContext& ctx = *context; + SmTArray<SmCurve*> curves; + SmTArray<SmSurface*> surfaces; + SmTArray<long> trees; + SmTArray<SmBrep*> breps; + SmStatus st = SmBrepData::ReadPartFromFile(ctx, path, curves, surfaces, trees, breps, + file_type == 0 ? SM_ASCII : SM_BINARY); + std::vector<double> o; + // status: 0 for SM_SUCCESS (1000L), else the SmStatus (the layout of the FFI docs) + o.push_back(st == SM_SUCCESS ? 0.0 : (double)st); + if (st == SM_SUCCESS) + { + o.push_back((double)curves.GetSize()); + for (ULONG i = 0; i < curves.GetSize(); i++) + { + SmCurve* c = curves[i]; + SmExtent1d ivl = c->GetNaturalInterval(); + o.push_back((double)c->GetType()); + o.push_back((double)c->GetDim()); + o.push_back(ivl.GetMin()); + o.push_back(ivl.GetMax()); + } + o.push_back((double)surfaces.GetSize()); + for (ULONG i = 0; i < surfaces.GetSize(); i++) + { + SmSurface* s = surfaces[i]; + SmExtent2d d = s->GetNaturalUVDomain(); + o.push_back((double)s->GetType()); + o.push_back(d.GetMin().x); + o.push_back(d.GetMin().y); + o.push_back(d.GetMax().x); + o.push_back(d.GetMax().y); + } + o.push_back((double)trees.GetSize()); + for (ULONG i = 0; i < trees.GetSize(); i++) + o.push_back((double)trees[i]); + o.push_back((double)breps.GetSize()); + for (ULONG i = 0; i < breps.GetSize(); i++) + { + SmBrep* b = breps[i]; + SmTArray<SmRegion*> regions; + SmTArray<SmShell*> shells; + SmTArray<SmFace*> faces; + SmTArray<SmEdge*> edges; + SmTArray<SmVertex*> vertices; + b->GetRegions(regions); + b->GetShells(shells); + b->GetFaces(faces); + b->GetEdges(edges); + b->GetVertices(vertices); + ULONG loops = 0; + for (ULONG f = 0; f < faces.GetSize(); f++) + { + SmTArray<SmLoop*> l; + faces[f]->GetLoops(l); + loops += l.GetSize(); + } + o.push_back((double)b->GetTolerance()); + o.push_back(b->IsManifoldSolid() ? 1.0 : 0.0); + o.push_back((double)regions.GetSize()); + o.push_back((double)shells.GetSize()); + o.push_back((double)faces.GetSize()); + o.push_back((double)loops); + o.push_back((double)edges.GetSize()); + o.push_back((double)vertices.GetSize()); + } + } + // the part's objects are the caller's: release them + for (ULONG i = 0; i < curves.GetSize(); i++) + delete curves[i]; + for (ULONG i = 0; i < surfaces.GetSize(); i++) + delete surfaces[i]; + for (ULONG i = 0; i < breps.GetSize(); i++) + delete breps[i]; + return sc_copy_out(o.data(), (int64_t)o.size(), out, cap, out_len); + SC_CATCH +} diff --git a/plan/findings/corpus.parity--ascii-round-trip-volume-drift-is-upstreams-own-2026-10-23.md b/plan/findings/corpus.parity--ascii-round-trip-volume-drift-is-upstreams-own-2026-10-23.md new file mode 100644 index 0000000000..57afa59bd9 --- /dev/null +++ b/plan/findings/corpus.parity--ascii-round-trip-volume-drift-is-upstreams-own-2026-10-23.md @@ -0,0 +1,21 @@ +# corpus.parity: the ASCII round trip's volume drift is upstream's own (2026-10-23) + + +- **What.** Nine corpus models (Solids/sphere, sphere, ssi_coincident_spheres, + ssi_sph_plane1, blade_hub_1/2/3, cliff_1/2) read back after a write (`SmBrepData::FromBrep`, + `WriteToFile` ASCII, `ReadFromFile`) with a volume 1.2e-5 to 7e-5 relative away from the + read one, area nearly unchanged; the corpus example's round trip reports them. +- **Evidence (Solids/sphere.smb).** C++ wrote the B-rep its own `ReadFromFile` made + (`SmBrep::WriteToFile(path, SM_ASCII, TRUE, FALSE, 0.0)`, called in gdb in the + corpus.parity C++ child); the port's bytes came from the corpus example's + `BrepData::write_to_file`. The two files are byte-identical (13 571 bytes). Read back, + C++ measures volume 523.5923997299045 and area 314.160106233197 (read: 523.5990772283121 + and 314.15938308003854); the port's own round trip and the port reading the C++ file + give the same values bit for bit. +- **Cause.** Upstream's writer prints doubles in fixed format (`%.16f`), which drops + significant digits of small coordinates and weights; the read-back geometry differs + slightly and so does the volume integral. The port writes the same bytes. +- **Now.** corpus.parity round-trips every file whose read passes on both sides + (`sc_corpus_parity_roundtrip`, children `cpp-roundtrip` / `rust-roundtrip`) and compares + the read-back measures at `10 * ACCURACY`: the drift is expected, a difference between + the sides is a regression. diff --git a/plan/findings/topology.part_file--part-reader-and-brep-writer-outside-the-plan-2026-10-23.md b/plan/findings/topology.part_file--part-reader-and-brep-writer-outside-the-plan-2026-10-23.md new file mode 100644 index 0000000000..6b912405ce --- /dev/null +++ b/plan/findings/topology.part_file--part-reader-and-brep-writer-outside-the-plan-2026-10-23.md @@ -0,0 +1,18 @@ +# topology.part_file: SmBrepData part reader and the B-rep writer were outside the plan (2026-10-23) + +## General: `SmBrepData` part reader and the B-rep writer are missing from the plan (2026-10-23) + +`harness/callgraph/units.json` lists only the single-B-rep read side of `SmBrepData` +(`ReadFromFile`, `ReadFromDB`, `ReadCurveFromDB`, `ReadSurfaceFromDB`, `Get/SetHealerVersion`). +Nothing reaches the part functions or the write side from the plan's test entry points, +so `ReadPartFromFile` / `ReadPartFromDB` / `ReadAttributeFromDB` (and +`SmAttribute::CreateAttribute`) were never units. Neither were `WriteToFile` / `WriteToDB` +/ `WritePartToFile` / `WritePartToDB` / `WriteCurveToDB` / `WriteSurfaceToDB` / +`WriteAttributeToDB`, the `SmDatabaseIO` `Write*` side or `FromBrep`. "Every unit green or +partial" therefore does not mean the port can read upstream's 74 `.smp` test parts (plus +`SmallBox.smb` and `Platform.smb`, which are part files too) or write any B-rep back. +`topology::part_file` (fix/part-reader) ports the read side outside the plan: +`read_part_from_file`, `read_part_from_db`, `read_attribute_from_db` and `create_attribute`, +plus `InputStream::getline` and `DatabaseReader::read_characters`. The difftest unit `topology.part_file` (shim `topology_part_file.cpp`) compares it with +upstream's `ReadPartFromFile`; the corpus runner (`examples/corpus`) exercises them on the real +files. The writer (`FromBrep` plus the `Write*` functions) is `topology::brep_data_write` (fix/brep-writer, salvaged onto swarm/b1 2026-10-25). diff --git a/plan/upstream-issues/WR-449--sphere-diff-3cylinders-one-fillet-face-integrates-0-6-too-large.md b/plan/upstream-issues/WR-449--sphere-diff-3cylinders-one-fillet-face-integrates-0-6-too-large.md new file mode 100644 index 0000000000..e7cfbcde14 --- /dev/null +++ b/plan/upstream-issues/WR-449--sphere-diff-3cylinders-one-fillet-face-integrates-0-6-too-large.md @@ -0,0 +1,22 @@ +#### WR-449. `Fillet/sphere_diff_3cylinders.smb`: one of six symmetric fillet faces integrates 0.6 too much area and 2.1 too much volume +- **Severity:** Wrong result +- **Area:** Mass properties +- **Location:** the trim of edge 30 that `SmFace::CreateOrientedTrimBoundary` makes for face 7 when `SmFace::ComputePreciseProperties` (`source/SMLib/src/SmFace.cpp`) integrates it: `SmFace::CreateUVTrimCurves` -> `SmEdgeuse::GetOrCreateUVTrimCurve` -> `SmEdgeuse::CreateUVTrimCurve` (`source/SMLib/src/SmEdgeuse.cpp`, `DropCurve` / `DropAndTrimCurve`), with `sm_FixUVTrimCurves` (`SmFace.cpp`) called from `CreateUVTrimCurves`. Which of these steps yields the single cubic span is not yet pinned. +- **What happens:** the six cone fillet faces (faces 2-7 in read order) are symmetric pairs about the origin. At accuracy 1e-9, with each face's own box midpoint as the area origin and the B-rep box midpoint (the origin) and area estimate of `SmBrep::ComputePreciseProperties` for the volume: + + | Face | Box midpoint | C++ area | Port area | C++ volume term (`SM_OT_SAME`) | Port volume term | + |------|--------------|----------|-----------|--------------------------------|------------------| + | 2 | (-21.328, 0, 0) | 3158.691087366512 | same bits | 34760.371669123495 | same bits | + | 3 | (21.328, 0, 0) | 3158.691158601684 | same bits | 34760.372446088266 | same bits | + | 4 | (0, 0, -21.328) | 3158.695605607531 | same bits | 0 | 0 | + | 5 | (0, 0, 21.328) | 3158.695947937785 | same bits | 0 | 0 | + | 6 | (0, 29.110, 0) | 3158.690617780610 | same bits | 34760.369029003836 | same bits | + | 7 | (0, -29.110, 0) | **3159.303327738010** | 3158.702340530602 | **34762.512347049167** | 34760.371712554756 | + + Faces 0-1 (spheres) and 2-6 agree to the last bit. Face 7 alone differs: C++ is 0.601 larger in area and 2.141 larger in volume term, and both C++ values stand apart from the five symmetric siblings (area 3158.6906-3158.6959, volume term 34760.369-34760.372), which the port's face 7 matches. This one face is the whole of `corpus.parity`'s disagreement on the file: B-rep area 38236.6055 (C++) vs 38236.0045 (port), volume 169497.2899 vs 169499.4301 (1.26e-5 relative). The C++ face 7 value does not move with accuracy (B-rep area 38236.6109 at 1e-6, 38236.6055 at 1e-10). +- **Independent check:** a fine inscribed mesh of the port's B-rep (solidcore-mesh, relative chord 4e-6, 2e-6 and 1e-6, Richardson-extrapolated) gives 3158.6966 for faces 2, 3 and 5, against the integrals 3158.6911-3158.6959, which supports the siblings' value; the mesher did not mesh faces 4, 6 and 7 at that run (since fixed in solidcore-mesh), so face 7 itself is judged by symmetry and the trims, not by the mesh. +- **Cause (input to the integral):** the file carries no UV trim for most edge uses; both sides make them when the face is measured (gdb on the C++ child: no trim is made or changed during `ReadFromFile`; every `CreateUVTrimCurve` call comes from `ComputePreciseProperties`). The trim boundary face 7 is integrated over differs on one curve, curve 2: edge 30's, from (0.37738, 0), on the surface's v-seam, to (0.41329, 0.125) in UV. The port drops a degree-3 trim with 46 control points; C++ makes a degree-3 curve with 4 control points (one cubic span) over the same interval [0.49999995785315, 0.74999998663581] with the same first and last control points. This is a Rust/C++ divergence inside the ported on-demand trim creation (`CreateUVTrimCurve` / `DropCurve` / `sm_FixUVTrimCurves`); the diverging step is not yet pinned. Every other curve of face 7 and every curve of face 6 (a sibling, the control) match exactly (largest control point difference 0). The face integrated over C++'s single span gains 0.60 of area and 2.14 of volume term and stands apart from its siblings; over the port's 46-point drop it matches them. The port runs with the default `Kernel` (upstream's `sm_FixUVTrimCurves` path, `UpstreamFixes::uv_trim_corner_end_points_only` off). +- **Trigger:** read `upstream/TestFiles/pt_TestFiles/Fillet/sphere_diff_3cylinders.smb` (`SmBrep::ReadFromFile`) and compute its area or volume (`corpus.parity`). Per-face probe: `crates/difftest/tests/probe_sphere_diff_faces.rs` (shim `harness/cpp/shim/corpus_faces.cpp`); mesh probe: `crates/solidcore-mesh/tests/probe_sphere_diff_mesh.rs`. +- **How found:** corpus.parity disagreement; per-face values first from gdb on the two children (2026-10-23, recorded as WR-256 in the former shared archive, whose entry did not survive the split; the ID now names another record), then from the per-face probe and the trims probe (2026-10-26). +- **Confirmed:** yes, case corpus.parity `Fillet/sphere_diff_3cylinders.smb` (C++ build, x86-64 Linux, `c9c5979`): C++'s face 7 deviates from its five symmetric siblings, which the port matches; the diverging step is in on-demand trim creation (`CreateUVTrimCurve` / `DropCurve` / `sm_FixUVTrimCurves`), not yet pinned. Evidence: the per-face probe (`probe_sphere_diff_faces`) and the trims probe (`probe_sphere_diff_trims`, shim `sc_corpus_faces_trims`), both in `crates/difftest/tests/probe_sphere_diff_faces.rs`. +- **Rust port:** the same integrator and a port of the same trim creation, which here gives a different trim (the divergence above); its face 7 matches its siblings. `corpus.parity` lists the file in `KNOWN_CPP_DEFECTS` for its area and volume (every count and kind still compared).