From 51ffaf9945c8ee054c58a0afcb1c6bfd59915997 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 14:55:39 +0000 Subject: [PATCH] IGES 5.3 import and export; OBJ import; STL reading hardened - IGES import (kernel iges_in, from the published specification). The file is restated as STEP and read by the STEP reader, so an IGES file becomes the same Import base feature. - Manifold solid B-reps (186/514/510/508/504/502). - Trimmed (144), bounded (143) and bare B-spline surfaces, sewn into a solid by shared edges, with face orientation made consistent. - Curves: lines, arcs, ellipses, composite and rational B-spline curves. - Surfaces: planes, analytic surfaces 190-198, B-spline surfaces, surfaces of revolution and tabulated cylinders. - Transformations, units, colours, names (406 form 15 or labels). Open, insert and drop .igs/.iges like STEP. - IGES export (kernel iges_out): each body as a manifold solid B-rep on the surfaces a STEP export uses, with name properties and colours. ExportCommand to .igs. - OBJ import as mesh bodies: objects/groups, polygons fanned, negative indices, bad lines reported. - STL: binary files whose header starts with "solid" and those with a zero triangle count read as binary; non-finite vertices dropped; facet normals ignored; ASCII with non-UTF-8 names. Mesh bodies get consistent winding (stray inverted facets turned over). - Tests: primitives and features round-trip through IGES (volume, area, name, colour). Six trimmed patches sew into a closed box. Hostile IGES, OBJ and STL inputs never panic. STL quirks. The engine exports a design to IGES and opens it again. All 76 oracle parts round-trip through IGES with matching volume (local check). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- README.md | 14 +- apps/solvecraft/src/main.rs | 6 +- crates/engine/src/tests.rs | 23 + crates/io/src/lib.rs | 158 +++- crates/io/src/obj.rs | 150 +++ crates/kernel/src/iges_in.rs | 1377 ++++++++++++++++++++++++++++ crates/kernel/src/iges_in/tests.rs | 143 +++ crates/kernel/src/iges_out.rs | 711 ++++++++++++++ crates/kernel/src/lib.rs | 4 + crates/kernel/src/meshbody.rs | 43 + crates/ui-egui/src/context_menu.rs | 3 +- docs/step-import.md | 4 + xtask/src/main.rs | 10 + 13 files changed, 2615 insertions(+), 31 deletions(-) create mode 100644 crates/io/src/obj.rs create mode 100644 crates/kernel/src/iges_in.rs create mode 100644 crates/kernel/src/iges_in/tests.rs create mode 100644 crates/kernel/src/iges_out.rs diff --git a/README.md b/README.md index 4feef3da..79d9d1a1 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,14 @@ storytold "craft" family. deleted. - **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`). - **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and - components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), 3MF, STL (binary/ASCII) and OBJ; - 3MF and STL import as mesh bodies (they render, measure, move and export; solid features need - B-rep bodies); - STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) as a base feature - that later features build on — open a `.step`/`.stp` file, insert one into a design, or drop it - on the window ([docs/step-import.md](docs/step-import.md)). + components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), + IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ; + 3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features + need B-rep bodies); + STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import + (manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature + that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a + design, or drop it on the window ([docs/step-import.md](docs/step-import.md)). - **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines control channel so agents and tests can drive it. diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 0354712d..b3f806df 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -47,9 +47,13 @@ fn services() -> Services { Services { pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Designs, STEP and meshes", &["solvecraft", "step", "stp", "STEP", "STP", "3mf", "3MF", "stl", "STL"]) + .add_filter( + "Designs, STEP, IGES and meshes", + &["solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"], + ) .add_filter("SolveCraft design", &["solvecraft"]) .add_filter("STEP", &["step", "stp", "STEP", "STP"]) + .add_filter("IGES", &["igs", "iges", "IGS", "IGES"]) .add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"]) .add_filter("All files", &["*"]) .pick_file() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 526f2909..fb4ba8ee 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1016,6 +1016,29 @@ fn face_colours_and_opacity_export() { assert_eq!(looks[0].1.color, [255, 0, 0]); } +/// IGES: a design exports as IGES and the file opens again as an Import feature with the same +/// bodies, names and colours. +#[test] +fn iges_export_and_open() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 5, "body_name": "Plate"})); + run(&mut s, "PrimitiveCylinder", json!({"radius": 4, "height": 20, "base": [10, 10, 5], "body_name": "Pin"})); + run(&mut s, "AppearanceCommand", json!({"bodies": ["Pin"], "color": "#ff0000"})); + let want: Vec = s.world_state().bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).collect(); + let p = std::env::temp_dir().join(format!("solvecraft-iges-{}.igs", std::process::id())); + run(&mut s, "ExportCommand", json!({"path": p.to_string_lossy()})); + let mut s2 = Session::default(); + run(&mut s2, "doc.open", json!({"path": p.to_string_lossy()})); + let _ = std::fs::remove_file(&p); + let st = s2.world_state(); + let names: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["Plate", "Pin"]); + for (b, v) in st.bodies.iter().zip(&want) { + assert!(rel(solvecraft_kernel::measure(&b.body).unwrap().volume, *v) < 1e-4); + } + assert_eq!(st.bodies[1].body.color(), Some([1.0, 0.0, 0.0])); +} + /// Components export as a STEP assembly: products, occurrences, placements. #[test] fn components_export_as_step_assembly() { diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index e69e02f8..77c63f20 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -15,6 +15,8 @@ pub use dxf::{read_dxf, write_dxf}; pub use safe_write::{backup_path, write_atomic}; pub use sketch2d::{Geom2, MAX_DRAWING_BYTES}; pub use svg::read_svg; +mod obj; +pub use obj::read_obj; pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, read_3mf, weld, write_3mf}; use solvecraft_doc::{Document, ModelState}; @@ -44,9 +46,18 @@ pub type Result = std::result::Result; /// Largest STEP file we read. pub const MAX_STEP_BYTES: usize = 512 << 20; -/// Is this a STEP file name (`.step` / `.stp`, any case)? +/// Is this a CAD exchange file read as an Import base feature: STEP (`.step` / `.stp`) or IGES +/// (`.igs` / `.iges`), any case? pub fn is_step_path(path: &str) -> bool { - std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("step") || e.eq_ignore_ascii_case("stp")) + std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| ["step", "stp", "igs", "iges"].iter().any(|x| e.eq_ignore_ascii_case(x))) +} + +/// Is this an IGES file name (`.igs` / `.iges`, any case)? +pub fn is_iges_path(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("igs") || e.eq_ignore_ascii_case("iges")) } /// A STEP file read as an import feature. @@ -65,8 +76,11 @@ pub fn step_import_feature(bytes: &[u8], file: &str) -> Result { if bytes.len() > MAX_STEP_BYTES { return Err(IoError::Invalid(format!("STEP file too large ({} MB, limit {} MB)", bytes.len() >> 20, MAX_STEP_BYTES >> 20))); } - // STEP is 7-bit text with escapes; tolerate stray 8-bit bytes. + // STEP is 7-bit text with escapes; tolerate stray 8-bit bytes. IGES is restated as STEP + // (the feature keeps that STEP text). let text = String::from_utf8_lossy(bytes).into_owned(); + let (text, iges_warnings) = + if is_iges_path(file) { solvecraft_kernel::iges_to_step(&text).map_err(IoError::Invalid)? } else { (text, Vec::new()) }; let imp = solvecraft_kernel::step_import_shared(&text)?; let stem = std::path::Path::new(file) .file_stem() @@ -85,16 +99,16 @@ pub fn step_import_feature(bytes: &[u8], file: &str) -> Result { name, body_names: imp.bodies.iter().map(|b| b.name.clone()).collect(), kind: solvecraft_doc::FeatureKind::Import { file: file_name, step: text, components: imp.tree.clone() }, - warnings: imp.warnings.clone(), + warnings: iges_warnings.into_iter().chain(imp.warnings.iter().cloned()).collect(), }) } -/// Is this a mesh file we import (`.3mf`, `.stl`, any case)? +/// Is this a mesh file we import (`.3mf`, `.stl`, `.obj`, any case)? pub fn is_mesh_path(path: &str) -> bool { - std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("3mf") || e.eq_ignore_ascii_case("stl")) + std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| ["3mf", "stl", "obj"].iter().any(|x| e.eq_ignore_ascii_case(x))) } -/// A 3MF or STL file read as a mesh import feature. +/// A 3MF, STL or OBJ file read as a mesh import feature. pub struct MeshFeature { /// Timeline name: the only mesh's name, else the file name. pub name: String, @@ -103,15 +117,17 @@ pub struct MeshFeature { pub warnings: Vec, } -/// Read 3MF or STL bytes (the format from `file`'s extension) into a MeshImport feature. +/// Read 3MF, STL or OBJ bytes (the format from `file`'s extension) into a MeshImport feature. pub fn mesh_import_feature(bytes: &[u8], file: &str) -> Result { if bytes.len() > MAX_3MF_BYTES { return Err(IoError::Invalid(format!("mesh file too large ({} MB)", bytes.len() >> 20))); } let path = std::path::Path::new(file); let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "Mesh".into()); - let is_stl = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("stl")); - let (objects, warnings) = if is_stl { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + let (objects, warnings) = if ext == "obj" { + obj::read_obj(bytes, &stem)? + } else if ext == "stl" { let tris = read_stl(bytes)?; if tris.is_empty() { return Err(IoError::Invalid("STL has no triangles".into())); @@ -154,6 +170,7 @@ pub enum Format { StlAscii, Obj, Step, + Iges, ThreeMf, Design, } @@ -167,6 +184,7 @@ impl Format { "stla" | "stl-ascii" => Format::StlAscii, "obj" => Format::Obj, "step" | "stp" => Format::Step, + "igs" | "iges" => Format::Iges, "3mf" => Format::ThreeMf, "solvecraft" | "json" => Format::Design, _ => return Err(IoError::Format(ext)), @@ -263,6 +281,12 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str) let header = solvecraft_kernel::StepHeader { file_name: format!("{name}.step"), ..Default::default() }; solvecraft_kernel::step_export_bodies(&bs, &header)?.into_bytes() } + Format::Iges => { + let bs: Vec = + sel.iter().map(|b| solvecraft_kernel::ExportBody { name: b.name.clone(), body: &b.body, color: b.body.color() }).collect(); + let header = solvecraft_kernel::StepHeader { file_name: format!("{name}.igs"), ..Default::default() }; + solvecraft_kernel::iges_export_bodies(&bs, &header)?.into_bytes() + } Format::ThreeMf => { let objs: Vec = sel .iter() @@ -333,39 +357,70 @@ pub fn write_design(doc: &Document) -> Vec { /// Parse a binary or ASCII STL (used by tests and round-trip checks): triangles. pub fn read_stl(bytes: &[u8]) -> Result> { - let ascii = bytes.starts_with(b"solid") && std::str::from_utf8(bytes).is_ok_and(|s| s.contains("facet")); + // Binary when the size matches the triangle count in the header (some binary files start + // with "solid" too); ASCII when the text has facets. + let count = bytes.get(80..84).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize); + let binary_size = count.is_some_and(|n| bytes.len() == 84 + n.saturating_mul(50)); + let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(512)).unwrap_or_default()).to_string(); + let ascii = !binary_size && head.trim_start().starts_with("solid") && String::from_utf8_lossy(bytes).contains("facet"); let mut tris = Vec::new(); + let ok = |v: &[f32; 3]| v.iter().all(|x| x.is_finite() && x.abs() < 1e9); if ascii { - let s = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("bad STL".into()))?; + let s = String::from_utf8_lossy(bytes); let mut cur: Vec<[f32; 3]> = Vec::new(); for l in s.lines() { let mut it = l.split_whitespace(); - if it.next() == Some("vertex") { - let v: Vec = it.filter_map(|x| x.parse().ok()).collect(); - if let [x, y, z] = v[..] { - cur.push([x, y, z]); + match it.next() { + Some("vertex") => { + let v: Vec = it.filter_map(|x| x.parse().ok()).collect(); + if let [x, y, z] = v[..] { + cur.push([x, y, z]); + } } - if let [a, b, c] = cur[..] { - tris.push([a, b, c]); + // Each facet takes exactly its three vertices (a bad vertex drops the facet). + Some("endfacet") => { + if let [a, b, c] = cur[..] + && [a, b, c].iter().all(ok) + { + tris.push([a, b, c]); + } cur.clear(); } + _ => {} + } + if tris.len() > MAX_STL_TRIANGLES { + return Err(IoError::Invalid(format!("STL with more than {MAX_STL_TRIANGLES} triangles"))); } } return Ok(tris); } - let n = bytes.get(80..84).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize).ok_or_else(|| IoError::Invalid("short STL".into()))?; - if bytes.len() < 84 + n.saturating_mul(50) { - return Err(IoError::Invalid("truncated STL".into())); + let n = count.ok_or_else(|| IoError::Invalid("short STL".into()))?; + // A wrong count in the header (some writers leave 0): use the triangles that are there. + let room = bytes.len().saturating_sub(84) / 50; + let n = if n == 0 || n > room { room } else { n }; + if n == 0 { + return Err(IoError::Invalid("STL has no triangles".into())); } - let f = |o: usize| bytes.get(o..o + 4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(0.0); + if n > MAX_STL_TRIANGLES { + return Err(IoError::Invalid(format!("STL with {n} triangles (limit {MAX_STL_TRIANGLES})"))); + } + let f = |o: usize| bytes.get(o..o + 4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(f32::NAN); + tris.reserve(n); for i in 0..n { + // The facet normal (first 12 bytes) is ignored: winding gives the orientation. let o = 84 + i * 50 + 12; let v = |k: usize| [f(o + k * 12), f(o + k * 12 + 4), f(o + k * 12 + 8)]; - tris.push([v(0), v(1), v(2)]); + let t = [v(0), v(1), v(2)]; + if t.iter().all(ok) { + tris.push(t); + } } Ok(tris) } +/// Most triangles we read from one STL file. +pub const MAX_STL_TRIANGLES: usize = 20_000_000; + #[cfg(test)] mod tests { use super::*; @@ -384,6 +439,63 @@ mod tests { (doc, m) } + /// A binary STL of a cube's triangles. + fn cube_stl(flip: &[usize], header: &[u8], count: Option, normals: [f32; 3]) -> Vec { + let c = |i: usize| [(i & 1) as f32 * 10.0, ((i >> 1) & 1) as f32 * 10.0, ((i >> 2) & 1) as f32 * 10.0]; + let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]]; + let mut tris = Vec::new(); + for q in quads { + tris.push([c(q[0]), c(q[1]), c(q[2])]); + tris.push([c(q[0]), c(q[2]), c(q[3])]); + } + for &k in flip { + tris[k].swap(1, 2); + } + let mut b = header.to_vec(); + b.resize(80, b' '); + b.extend(count.unwrap_or(tris.len() as u32).to_le_bytes()); + for t in &tris { + for x in normals.iter().chain(t.iter().flatten()) { + b.extend(x.to_le_bytes()); + } + b.extend([0, 0]); + } + b + } + + fn mesh_volume(bytes: &[u8]) -> f64 { + let f = mesh_import_feature(bytes, "x.stl").unwrap(); + let solvecraft_doc::FeatureKind::MeshImport { meshes, .. } = f.kind else { panic!() }; + let m = &meshes[0]; + let pos: Vec = m.positions.chunks(3).map(|c| Vec3::new(c[0], c[1], c[2])).collect(); + let tris: Vec<[u32; 3]> = m.triangles.chunks(3).map(|c| [c[0], c[1], c[2]]).collect(); + solvecraft_kernel::measure(&solvecraft_kernel::mesh_body(&pos, &tris).unwrap()).unwrap().volume + } + + /// STL quirks: a binary header starting with "solid", a zero triangle count, garbage + /// normals, stray inverted facets and non-finite vertices. + #[test] + fn stl_quirks_read_cleanly() { + let v = mesh_volume(&cube_stl(&[], b"solid exported by something", None, [0.0; 3])); + assert!((v - 1000.0).abs() < 1e-6, "{v}"); + let v = mesh_volume(&cube_stl(&[], b"x", Some(0), [f32::NAN, 9.0, -7.0])); + assert!((v - 1000.0).abs() < 1e-6, "{v}"); + let v = mesh_volume(&cube_stl(&[1, 4, 7], b"x", None, [1.0, 0.0, 0.0])); + assert!((v - 1000.0).abs() < 1e-6, "flipped facets: {v}"); + let mut bad = cube_stl(&[], b"x", None, [0.0; 3]); + bad[84 + 12..84 + 16].copy_from_slice(&f32::INFINITY.to_le_bytes()); + assert_eq!(read_stl(&bad).unwrap().len(), 11); + // ASCII with a non-UTF-8 name and a facet with a bad vertex. + let mut ascii = b"solid caf\xe9\n".to_vec(); + ascii.extend(b"facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n"); + ascii.extend(b"facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex nan 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n"); + assert_eq!(read_stl(&ascii).unwrap().len(), 1); + for b in [&b""[..], b"solid", &[0u8; 83], &[0xff; 200], &cube_stl(&[], b"x", Some(u32::MAX), [0.0; 3])[..]] { + let r = std::panic::catch_unwind(|| read_stl(b).map(|t| t.len())); + assert!(r.is_ok()); + } + } + #[test] fn stl_round_trip_volume() { let (_, m) = model(); diff --git a/crates/io/src/obj.rs b/crates/io/src/obj.rs new file mode 100644 index 00000000..03cad1dc --- /dev/null +++ b/crates/io/src/obj.rs @@ -0,0 +1,150 @@ +//! Wavefront OBJ import: polygon meshes (`v`, `f` with any of `v`, `v/vt`, `v//vn`, `v/vt/vn`, +//! negative indices counting back), one mesh body per object (`o`, else group `g`). Polygons +//! are split into fans. Texture coordinates, normals and materials are ignored. The file is +//! untrusted: counts are capped and bad lines become warnings. + +use solvecraft_geom::Vec3; + +use crate::threemf::MeshObject; +use crate::{IoError, Result}; + +/// Most vertices and triangles we read from one file. +const MAX_VERTICES: usize = 20_000_000; +const MAX_TRIANGLES: usize = 20_000_000; + +/// Meshes of an OBJ file and what could not be read. +pub fn read_obj(bytes: &[u8], default_name: &str) -> Result<(Vec, Vec)> { + let text = String::from_utf8_lossy(bytes); + let mut verts: Vec = Vec::new(); + // Objects: name and polygons as vertex indices into `verts`. + let mut objects: Vec<(String, Vec<[u32; 3]>)> = vec![(default_name.to_string(), Vec::new())]; + let mut warnings: Vec = Vec::new(); + let mut bad_lines = 0usize; + let mut triangles = 0usize; + let mut named_by_o = false; + for line in text.lines() { + let line = line.split('#').next().unwrap_or("").trim(); + let mut it = line.split_whitespace(); + match it.next() { + Some("v") => { + let c: Vec = it.take(3).filter_map(|x| x.parse::().ok()).collect(); + match c[..] { + [x, y, z] if x.is_finite() && y.is_finite() && z.is_finite() => verts.push(Vec3::new(x, y, z)), + _ => { + // Keep the numbering: later faces count vertices by position. + verts.push(Vec3::new(f64::NAN, f64::NAN, f64::NAN)); + bad_lines += 1; + } + } + if verts.len() > MAX_VERTICES { + return Err(IoError::Invalid(format!("OBJ with more than {MAX_VERTICES} vertices"))); + } + } + Some("o") | Some("g") => { + let is_o = line.starts_with('o'); + let name = it.collect::>().join(" "); + if name.is_empty() || (!is_o && named_by_o) { + continue; + } + named_by_o |= is_o; + match objects.last_mut() { + // An empty object takes the name instead of starting another. + Some((n, tris)) if tris.is_empty() => *n = name, + _ => objects.push((name, Vec::new())), + } + } + Some("f") => { + let n = verts.len() as i64; + let idx: Option> = it + .map(|tok| { + let i: i64 = tok.split('/').next()?.parse().ok()?; + let i = if i < 0 { n + i } else { i - 1 }; + (0..n).contains(&i).then(|| u32::try_from(i).ok()).flatten() + }) + .collect(); + match idx { + Some(p) if p.len() >= 3 && p.iter().all(|i| verts.get(*i as usize).is_some_and(|v| v.is_finite())) => { + if let Some((_, tris)) = objects.last_mut() { + for k in 1..p.len() - 1 { + tris.push([p[0], p[k], p[k + 1]]); + } + triangles += p.len() - 2; + } + } + _ => bad_lines += 1, + } + if triangles > MAX_TRIANGLES { + return Err(IoError::Invalid(format!("OBJ with more than {MAX_TRIANGLES} triangles"))); + } + } + _ => {} + } + } + if bad_lines > 0 { + warnings.push(format!("{bad_lines} vertex or face line(s) could not be read and were left out")); + } + let mut out = Vec::new(); + for (name, tris) in objects { + if tris.is_empty() { + continue; + } + // Each object keeps only its own vertices. + let mut map = std::collections::HashMap::new(); + let mut positions = Vec::new(); + let mut triangles = Vec::with_capacity(tris.len()); + for t in tris { + let mut nt = [0u32; 3]; + for (k, i) in t.iter().enumerate() { + let next = u32::try_from(positions.len()).map_err(|_| IoError::Invalid("OBJ too large".into()))?; + nt[k] = *map.entry(*i).or_insert_with(|| { + positions.push(verts.get(*i as usize).copied().unwrap_or(Vec3::ZERO)); + next + }); + } + triangles.push(nt); + } + out.push(MeshObject { name, positions, triangles, ..Default::default() }); + } + if out.is_empty() { + return Err(IoError::Invalid("OBJ has no faces".into())); + } + Ok((out, warnings)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn objects_quads_and_negative_indices() { + let src = "# cube\no Cube\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nv 0 0 1\nv 1 0 1\nv 1 1 1\nv 0 1 1\n\ + f 1 4 3 2\nf 5 6 7 8\nf 1 2 6 5\nf 2/1 3/2 7/3 6/4\nf 3//1 4//1 8//1 7//1\nf -4 -8 -5 -1\n\ + o Tri\nv 5 5 5\nv 6 5 5\nv 5 6 5\nf -3 -2 -1\n"; + let (objs, warnings) = read_obj(src.as_bytes(), "x").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(objs.len(), 2); + assert_eq!(objs[0].name, "Cube"); + assert_eq!(objs[0].triangles.len(), 12); + assert_eq!(objs[0].positions.len(), 8); + assert_eq!(objs[1].name, "Tri"); + assert_eq!(objs[1].triangles.len(), 1); + } + + #[test] + fn hostile_obj_never_panics() { + for src in [ + "", + "f 1 2 3", + "v 1 2\nf 1 1 1", + "v nan 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 99999999999999999999", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nf -1 -2 -9", + "o\ng\nf", + "\u{0}\u{ff}v 1 1 1", + ] { + let r = std::panic::catch_unwind(|| read_obj(src.as_bytes(), "x").map(|(o, _)| o.len())); + assert!(r.is_ok(), "{src:?}"); + } + assert!(read_obj(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\nf 1 2 x", "x").unwrap().1.len() == 1); + } +} diff --git a/crates/kernel/src/iges_in.rs b/crates/kernel/src/iges_in.rs new file mode 100644 index 00000000..661c1d7f --- /dev/null +++ b/crates/kernel/src/iges_in.rs @@ -0,0 +1,1377 @@ +//! IGES 5.3 import, written from the published specification. The file's manifold solid B-reps +//! (186) and trimmed or bounded surfaces (144, 143, sewn into shells by their shared edges) are +//! restated as an equivalent STEP exchange and read by the STEP reader, so both formats share +//! one geometry and topology path. +//! +//! Files are untrusted: sizes, entity counts, references and recursion are bounded; anything +//! that cannot be read becomes a warning. + +use std::collections::HashMap; +use std::fmt::Write as _; + +/// Largest IGES file we read. +pub const MAX_IGES_BYTES: usize = 512 << 20; +const MAX_ENTITIES: usize = 2_000_000; +const MAX_DEPTH: usize = 32; +const MAX_LIST: usize = 1_000_000; + +type R = Result; +type V3 = [f64; 3]; + +fn sub(a: V3, b: V3) -> V3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn add(a: V3, b: V3) -> V3 { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn mul(a: V3, k: f64) -> V3 { + [a[0] * k, a[1] * k, a[2] * k] +} +fn dot(a: V3, b: V3) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn norm(a: V3) -> f64 { + dot(a, a).sqrt() +} +fn unit(a: V3) -> Option { + let n = norm(a); + (n > 1e-300 && n.is_finite()).then(|| mul(a, 1.0 / n)) +} + +/// A rigid (or general affine) map: rows of a 3×4 matrix. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Xf([[f64; 4]; 3]); + +impl Xf { + const ID: Xf = Xf([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]); + fn point(&self, p: V3) -> V3 { + let m = &self.0; + [0, 1, 2].map(|i| m[i][0] * p[0] + m[i][1] * p[1] + m[i][2] * p[2] + m[i][3]) + } + fn dir(&self, d: V3) -> V3 { + let m = &self.0; + [0, 1, 2].map(|i| m[i][0] * d[0] + m[i][1] * d[1] + m[i][2] * d[2]) + } + /// `self` after `inner` (inner applied first). + fn then(&self, inner: &Xf) -> Xf { + let (a, b) = (&self.0, &inner.0); + let mut m = [[0.0; 4]; 3]; + for i in 0..3 { + for j in 0..4 { + m[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum::() + if j == 3 { a[i][3] } else { 0.0 }; + } + } + Xf(m) + } + /// Scale of lengths (cube root of the determinant's size). + fn scale(&self) -> f64 { + let m = &self.0; + let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + det.abs().cbrt() + } +} + +/// One directory entry. +#[derive(Clone, Debug, Default)] +struct De { + typ: i64, + pd: usize, + xform: i64, + color: i64, + lines: usize, + form: i64, + label: String, +} + +/// The parsed file: directory entries (by DE sequence number) and their parameter tokens. +struct Iges { + des: HashMap, + params: HashMap>, + /// Millimetres per model unit. + unit: f64, + /// Minimum resolution (model units). + resolution: f64, +} + +/// Split parameter text into fields at `delim`, stopping at `rdelim`; Hollerith strings (`nH…`) +/// may contain either. +fn tokens(s: &str, delim: char, rdelim: char) -> Vec { + let chars: Vec = s.chars().collect(); + let mut out = Vec::new(); + let mut cur = String::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == delim || c == rdelim { + out.push(cur.trim().to_string()); + cur.clear(); + if c == rdelim { + return out; + } + i += 1; + continue; + } + if (c == 'H' || c == 'h') && !cur.trim().is_empty() && cur.trim().chars().all(|d| d.is_ascii_digit()) { + let n: usize = cur.trim().parse().unwrap_or(0).min(chars.len()); + let start = i + 1; + let end = (start + n).min(chars.len()); + cur = chars.get(start..end).map(|x| x.iter().collect::()).unwrap_or_default(); + out.push(cur.clone()); + cur.clear(); + i = end; + // Skip to the next delimiter. + while i < chars.len() && chars[i] != delim && chars[i] != rdelim { + i += 1; + } + if i < chars.len() { + if chars[i] == rdelim { + return out; + } + i += 1; + } + continue; + } + cur.push(c); + i += 1; + } + if !cur.trim().is_empty() { + out.push(cur.trim().to_string()); + } + out +} + +fn num(s: &str) -> Option { + let t = s.trim().replace(['D', 'd'], "E"); + if t.is_empty() { + return None; + } + t.parse::().ok().filter(|x| x.is_finite()) +} + +fn int(s: &str) -> Option { + let t = s.trim(); + if t.is_empty() { + return Some(0); + } + t.parse::().ok().or_else(|| num(t).filter(|x| x.fract() == 0.0 && x.abs() < 1e15).map(|x| x as i64)) +} + +fn parse(text: &str) -> R { + let mut g = String::new(); + let mut d_lines: Vec = Vec::new(); + let mut p_lines: HashMap = HashMap::new(); + for raw in text.lines() { + let line = raw.trim_end_matches(['\r', '\n']); + if line.trim().is_empty() { + continue; + } + let chars: Vec = line.chars().collect(); + if chars.len() < 73 { + continue; + } + let sect = chars[72]; + let body = |a: usize, b: usize| chars.get(a..b.min(chars.len())).map(|x| x.iter().collect::()).unwrap_or_default(); + match sect { + 'S' => {} + 'G' => g.push_str(&body(0, 72)), + 'D' => d_lines.push(body(0, 72)), + 'P' => { + let seq: usize = body(73, 80).trim().parse().unwrap_or(0); + p_lines.insert(seq, body(0, 64)); + } + 'T' => break, + 'C' => return Err("compressed IGES is not supported".into()), + 'B' => return Err("binary IGES is not supported".into()), + _ => {} + } + if d_lines.len() > 2 * MAX_ENTITIES { + return Err("too many entities".into()); + } + } + // Global section: parameter and record delimiters, then fields from 3 on. + let gc: Vec = g.chars().collect(); + let (mut delim, mut rdelim, mut pos) = (',', ';', 0usize); + if gc.len() >= 3 && gc[0] == '1' && (gc[1] == 'H' || gc[1] == 'h') { + delim = gc[2]; + pos = 3; + } + if gc.get(pos) == Some(&delim) { + pos += 1; + } + if gc.len() >= pos + 3 && gc[pos] == '1' && (gc[pos + 1] == 'H' || gc[pos + 1] == 'h') { + rdelim = gc[pos + 2]; + pos += 3; + } + if gc.get(pos) == Some(&delim) { + pos += 1; + } + let rest: String = gc.get(pos..).map(|x| x.iter().collect()).unwrap_or_default(); + let gf = tokens(&rest, delim, rdelim); + // Field k (1-based) is gf[k - 3]. + let field = |k: usize| gf.get(k.wrapping_sub(3)).map(String::as_str).unwrap_or(""); + let unit = match int(field(14)).unwrap_or(2) { + 1 => 25.4, + 2 => 1.0, + 3 => match field(15).trim().to_ascii_uppercase().as_str() { + "IN" | "INCH" => 25.4, + "FT" => 304.8, + "M" => 1000.0, + "CM" => 10.0, + "UM" => 1e-3, + _ => 1.0, + }, + 4 => 304.8, + 5 => 1_609_344.0, + 6 => 1000.0, + 7 => 1e6, + 8 => 0.0254, + 9 => 1e-3, + 10 => 10.0, + 11 => 2.54e-5, + _ => 1.0, + }; + let resolution = num(field(19)).filter(|x| *x > 0.0).unwrap_or(1e-6); + // Directory: two lines per entity, nine 8-column fields each. + let mut des = HashMap::new(); + for (k, pair) in d_lines.chunks(2).enumerate() { + let [a, b] = pair else { break }; + let f = |s: &str, i: usize| s.chars().skip(i * 8).take(8).collect::(); + let de = De { + typ: int(&f(a, 0)).unwrap_or(0), + pd: int(&f(a, 1)).unwrap_or(0).max(0) as usize, + xform: int(&f(a, 6)).unwrap_or(0), + color: int(&f(b, 2)).unwrap_or(0), + lines: int(&f(b, 3)).unwrap_or(0).clamp(0, 1_000_000) as usize, + form: int(&f(b, 4)).unwrap_or(0), + label: f(b, 7).trim().to_string(), + }; + des.insert(2 * k + 1, de); + } + let mut params = HashMap::new(); + for (seq, de) in &des { + let mut s = String::new(); + for l in de.pd..de.pd.saturating_add(de.lines.max(1)) { + // Columns 1–64 run on (a long string may continue on the next line). + match p_lines.get(&l) { + Some(x) => s.push_str(&format!("{x:<64}")), + None => break, + } + } + let t = tokens(&s, delim, rdelim); + params.insert(*seq, t); + } + if des.is_empty() { + return Err("the file has no entities".into()); + } + Ok(Iges { des, params, unit, resolution }) +} + +/// A model curve segment ready for an edge: STEP curve id, its end points, points a quarter and +/// three quarters of the way along and one at the middle (for matching shared edges), and +/// whether the curve runs from `b` to `a`. +#[derive(Clone, Copy, Debug)] +struct Seg { + curve: u64, + a: V3, + b: V3, + q: V3, + q3: V3, + mid: V3, + rev: bool, +} + +impl Seg { + fn along(curve: u64, f: impl Fn(f64) -> V3) -> Seg { + Seg { curve, a: f(0.0), b: f(1.0), q: f(0.25), q3: f(0.75), mid: f(0.5), rev: false } + } + fn reversed(self) -> Seg { + Seg { a: self.b, b: self.a, q: self.q3, q3: self.q, rev: !self.rev, ..self } + } +} + +/// The STEP exchange being written. +struct Out { + lines: Vec, +} + +impl Out { + fn add(&mut self, s: impl Into) -> u64 { + self.lines.push(s.into()); + self.lines.len() as u64 + } + fn point(&mut self, p: V3) -> u64 { + self.add(format!("CARTESIAN_POINT('',({},{},{}))", r(p[0]), r(p[1]), r(p[2]))) + } + fn dir(&mut self, d: V3) -> u64 { + self.add(format!("DIRECTION('',({},{},{}))", r(d[0]), r(d[1]), r(d[2]))) + } + fn placement(&mut self, o: V3, z: V3, x: V3) -> u64 { + let (a, b, c) = (self.point(o), self.dir(z), self.dir(x)); + self.add(format!("AXIS2_PLACEMENT_3D('',#{a},#{b},#{c})")) + } +} + +fn r(x: f64) -> String { + let s = format!("{x:?}"); + if s.contains('.') || s.contains('e') || s.contains('E') { s.replace('e', "E") } else { format!("{s}.") } +} + +/// Any direction square to `z`. +fn perp(z: V3) -> V3 { + let a = if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }; + unit(sub(a, mul(z, dot(a, z)))).unwrap_or([1.0, 0.0, 0.0]) +} + +/// A rational B-spline curve evaluated by de Boor's algorithm. +struct Nurbs { + deg: usize, + knots: Vec, + pts: Vec<[f64; 4]>, +} + +impl Nurbs { + fn eval(&self, t: f64) -> Option { + let p = self.deg; + let n = self.pts.len(); + if n == 0 || self.knots.len() != n + p + 1 { + return None; + } + let (lo, hi) = (self.knots[p], self.knots[n]); + let t = t.clamp(lo, hi); + let mut k = p; + while k + 1 < n && self.knots[k + 1] <= t { + k += 1; + } + let mut d: Vec<[f64; 4]> = (0..=p).map(|j| self.pts[(k + j).saturating_sub(p).min(n - 1)]).collect(); + for rr in 1..=p { + for j in (rr..=p).rev() { + let i = k + j - p; + let (a0, a1) = (self.knots[i], self.knots[(i + p + 1 - rr).min(self.knots.len() - 1)]); + let alpha = if a1 > a0 { (t - a0) / (a1 - a0) } else { 0.0 }; + let (x, y) = (d[j - 1], d[j]); + d[j] = [0, 1, 2, 3].map(|c| (1.0 - alpha) * x[c] + alpha * y[c]); + } + } + let h = d[p]; + (h[3].abs() > 1e-300).then(|| [h[0] / h[3], h[1] / h[3], h[2] / h[3]]) + } + fn range(&self) -> (f64, f64) { + (self.knots.get(self.deg).copied().unwrap_or(0.0), self.knots.get(self.pts.len()).copied().unwrap_or(0.0)) + } +} + +struct Tr<'a> { + f: &'a Iges, + o: Out, + warnings: Vec, + /// Sewing tolerance (mm). + tol: f64, + surf_cache: HashMap<(usize, [u64; 12]), u64>, +} + +fn xf_key(x: &Xf) -> [u64; 12] { + let mut k = [0u64; 12]; + for i in 0..3 { + for j in 0..4 { + k[i * 4 + j] = x.0[i][j].to_bits(); + } + } + k +} + +impl<'a> Tr<'a> { + fn warn(&mut self, s: impl Into) { + let s = s.into(); + if !self.warnings.contains(&s) && self.warnings.len() < 200 { + self.warnings.push(s); + } + } + fn de(&self, seq: i64) -> R<&'a De> { + let f = self.f; + usize::try_from(seq).ok().and_then(|s| f.des.get(&s)).ok_or_else(|| format!("missing entity {seq}")) + } + fn p(&self, seq: i64) -> R<&'a [String]> { + let f = self.f; + usize::try_from(seq).ok().and_then(|s| f.params.get(&s)).map(Vec::as_slice).ok_or_else(|| format!("missing parameters of entity {seq}")) + } + /// Field `i` of an entity (field 0 is the entity type). + fn n(&self, seq: i64, i: usize) -> R { + self.p(seq)?.get(i).and_then(|s| num(s)).ok_or_else(|| format!("entity {seq}: bad number in field {i}")) + } + fn i(&self, seq: i64, i: usize) -> R { + self.p(seq)?.get(i).and_then(|s| int(s)).ok_or_else(|| format!("entity {seq}: bad integer in field {i}")) + } + /// The entity's own transformation (its chain), in millimetres. + fn own_xf(&self, seq: i64, depth: usize) -> R { + let de = self.de(seq)?; + if de.xform <= 0 || depth > MAX_DEPTH { + return Ok(Xf::ID); + } + let t = de.xform; + let tde = self.de(t)?; + if tde.typ != 124 { + return Ok(Xf::ID); + } + let mut m = [[0.0; 4]; 3]; + for (row, r) in m.iter_mut().enumerate() { + for (col, x) in r.iter_mut().enumerate() { + *x = self.n(t, 1 + row * 4 + col)?; + } + r[3] *= self.f.unit; + } + let parent = self.own_xf(t, depth + 1)?; + Ok(parent.then(&Xf(m))) + } + fn pt(&self, seq: i64, i: usize) -> R { + let u = self.f.unit; + Ok([self.n(seq, i)? * u, self.n(seq, i + 1)? * u, self.n(seq, i + 2)? * u]) + } + /// A point entity (116) or direction (123), placed. + fn point_entity(&self, seq: i64, xf: &Xf) -> R { + let x = xf.then(&self.own_xf(seq, 0)?); + Ok(x.point(self.pt(seq, 1)?)) + } + fn dir_entity(&self, seq: i64, xf: &Xf) -> R { + let x = xf.then(&self.own_xf(seq, 0)?); + unit(x.dir([self.n(seq, 1)?, self.n(seq, 2)?, self.n(seq, 3)?])).ok_or_else(|| format!("entity {seq}: zero direction")) + } + + /// Curve segments of a model-space curve entity (composite curves give several). + fn curve(&mut self, seq: i64, xf: &Xf, depth: usize) -> R> { + if depth > MAX_DEPTH { + return Err("curves nested too deeply".into()); + } + let de = self.de(seq)?; + let x = xf.then(&self.own_xf(seq, 0)?); + match de.typ { + 110 => { + let (a, b) = (x.point(self.pt(seq, 1)?), x.point(self.pt(seq, 4)?)); + if !(norm(sub(b, a)) > 0.0) { + return Ok(Vec::new()); + } + let (pa, d) = (self.o.point(a), unit(sub(b, a)).ok_or("zero-length line")?); + let dd = self.o.dir(d); + let v = self.o.add(format!("VECTOR('',#{dd},{})", r(norm(sub(b, a))))); + let c = self.o.add(format!("LINE('',#{pa},#{v})")); + Ok(vec![Seg::along(c, |t| add(a, mul(sub(b, a), t)))]) + } + 100 => { + let u = self.f.unit; + let zt = self.n(seq, 1)? * u; + let c = [self.n(seq, 2)? * u, self.n(seq, 3)? * u, zt]; + let s = [self.n(seq, 4)? * u, self.n(seq, 5)? * u, zt]; + let e = [self.n(seq, 6)? * u, self.n(seq, 7)? * u, zt]; + let rad = norm(sub(s, c)); + if !(rad > 0.0) { + return Err(format!("entity {seq}: arc of zero radius")); + } + let a0 = (s[1] - c[1]).atan2(s[0] - c[0]); + let mut a1 = (e[1] - c[1]).atan2(e[0] - c[0]); + while a1 <= a0 + 1e-12 { + a1 += std::f64::consts::TAU; + } + let at = |t: f64| x.point([c[0] + rad * t.cos(), c[1] + rad * t.sin(), zt]); + let z = unit(x.dir([0.0, 0.0, 1.0])).ok_or("degenerate arc frame")?; + let xa = unit(x.dir([1.0, 0.0, 0.0])).ok_or("degenerate arc frame")?; + let ax = self.o.placement(x.point(c), z, xa); + let cid = self.o.add(format!("CIRCLE('',#{ax},{})", r(rad * x.scale()))); + Ok(vec![Seg::along(cid, |t| at(a0 + (a1 - a0) * t))]) + } + 102 => { + let n = self.i(seq, 1)?.clamp(0, MAX_LIST as i64) as usize; + let mut out = Vec::new(); + for k in 0..n { + let s = self.i(seq, 2 + k)?; + out.extend(self.curve(s, &x, depth + 1)?); + } + Ok(out) + } + 126 => { + let nb = self.nurbs_curve(seq, &x)?; + let (t0, t1) = nb.range(); + for k in 0..=4 { + nb.eval(t0 + (t1 - t0) * k as f64 / 4.0).ok_or_else(|| format!("entity {seq}: B-spline cannot be evaluated"))?; + } + let c = self.write_nurbs_curve(&nb); + Ok(vec![Seg::along(c, |t| nb.eval(t0 + (t1 - t0) * t).unwrap_or([0.0; 3]))]) + } + 104 => { + // Ellipse (form 1) in canonical position: A x² + C y² + F = 0. + let u = self.f.unit; + let (a, b, c, d, e, f) = (self.n(seq, 1)?, self.n(seq, 2)?, self.n(seq, 3)?, self.n(seq, 4)?, self.n(seq, 5)?, self.n(seq, 6)?); + let zt = self.n(seq, 7)? * u; + if de.form != 1 || b.abs() > 1e-12 || d.abs() > 1e-12 || e.abs() > 1e-12 || !(a * f < 0.0 && c * f < 0.0) { + return Err(format!("entity {seq}: only canonical ellipses are read")); + } + let (ra, rb) = ((-f / a).sqrt() * u, (-f / c).sqrt() * u); + let s = [self.n(seq, 8)? * u, self.n(seq, 9)? * u]; + let en = [self.n(seq, 10)? * u, self.n(seq, 11)? * u]; + let t0 = (s[1] / rb).atan2(s[0] / ra); + let mut t1 = (en[1] / rb).atan2(en[0] / ra); + while t1 <= t0 + 1e-12 { + t1 += std::f64::consts::TAU; + } + let at = |t: f64| x.point([ra * t.cos(), rb * t.sin(), zt]); + let z = unit(x.dir([0.0, 0.0, 1.0])).ok_or("degenerate ellipse frame")?; + let xa = unit(x.dir([1.0, 0.0, 0.0])).ok_or("degenerate ellipse frame")?; + let ax = self.o.placement(x.point([0.0, 0.0, zt]), z, xa); + let k = x.scale(); + let cid = if (ra - rb).abs() < 1e-12 * ra { + self.o.add(format!("CIRCLE('',#{ax},{})", r(ra * k))) + } else { + self.o.add(format!("ELLIPSE('',#{ax},{},{})", r(ra * k), r(rb * k))) + }; + Ok(vec![Seg::along(cid, |t| at(t0 + (t1 - t0) * t))]) + } + 130 => Err(format!("entity {seq}: offset curves are not read")), + t => Err(format!("curve type {t} is not read")), + } + } + + fn nurbs_curve(&self, seq: i64, x: &Xf) -> R { + let k = self.i(seq, 1)?; + let m = self.i(seq, 2)?; + if !(0..=MAX_LIST as i64).contains(&k) || !(1..=32).contains(&m) || m > k.max(1) + 1 { + return Err(format!("entity {seq}: bad B-spline size")); + } + let (k, m) = (k as usize, m as usize); + let nk = k + m + 2; + let mut knots = Vec::with_capacity(nk); + for j in 0..nk { + knots.push(self.n(seq, 7 + j)?); + } + let base_w = 7 + nk; + let base_p = base_w + k + 1; + let mut pts = Vec::with_capacity(k + 1); + for j in 0..=k { + let w = self.n(seq, base_w + j)?; + if !(w > 0.0) { + return Err(format!("entity {seq}: non-positive weight")); + } + let p = x.point(self.pt(seq, base_p + 3 * j)?); + pts.push([p[0] * w, p[1] * w, p[2] * w, w]); + } + if knots.windows(2).any(|w| w[1] < w[0]) { + return Err(format!("entity {seq}: knots out of order")); + } + let nb = Nurbs { deg: m, knots, pts }; + let (a, b) = nb.range(); + if !(b > a) { + return Err(format!("entity {seq}: empty parameter range")); + } + Ok(nb) + } + + fn knot_text(knots: &[f64]) -> (String, String) { + let mut ks: Vec = Vec::new(); + let mut ms: Vec = Vec::new(); + for &k in knots { + match ks.last() { + Some(&l) if (k - l).abs() <= 1e-14 * (1.0 + k.abs()) => { + if let Some(m) = ms.last_mut() { + *m += 1; + } + } + _ => { + ks.push(k); + ms.push(1); + } + } + } + (ms.iter().map(|m| m.to_string()).collect::>().join(","), ks.iter().map(|k| r(*k)).collect::>().join(",")) + } + + fn write_nurbs_curve(&mut self, nb: &Nurbs) -> u64 { + let ids: Vec = nb.pts.iter().map(|h| format!("#{}", self.o.point([h[0] / h[3], h[1] / h[3], h[2] / h[3]]))).collect(); + let (ms, ks) = Self::knot_text(&nb.knots); + let rational = nb.pts.iter().any(|h| (h[3] - 1.0).abs() > 1e-12); + if rational { + let ws: Vec = nb.pts.iter().map(|h| r(h[3])).collect(); + self.o.add(format!( + "(BOUNDED_CURVE()B_SPLINE_CURVE({},({}),.UNSPECIFIED.,.U.,.U.)B_SPLINE_CURVE_WITH_KNOTS(({ms}),({ks}),.UNSPECIFIED.)CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE(({}))REPRESENTATION_ITEM(''))", + nb.deg, + ids.join(","), + ws.join(",") + )) + } else { + self.o.add(format!("B_SPLINE_CURVE_WITH_KNOTS('',{},({}),.UNSPECIFIED.,.U.,.U.,({ms}),({ks}),.UNSPECIFIED.)", nb.deg, ids.join(","))) + } + } + + /// A surface entity as a STEP surface id. + fn surface(&mut self, seq: i64, xf: &Xf) -> R { + let key = (usize::try_from(seq).unwrap_or(0), xf_key(xf)); + if let Some(id) = self.surf_cache.get(&key) { + return Ok(*id); + } + let id = self.surface_new(seq, xf)?; + self.surf_cache.insert(key, id); + Ok(id) + } + + fn surface_new(&mut self, seq: i64, xf: &Xf) -> R { + let de = self.de(seq)?; + let x = xf.then(&self.own_xf(seq, 0)?); + let u = self.f.unit; + let k = x.scale(); + match de.typ { + 128 => { + let (k1, k2, m1, m2) = (self.i(seq, 1)?, self.i(seq, 2)?, self.i(seq, 3)?, self.i(seq, 4)?); + if !(0..=4096).contains(&k1) || !(0..=4096).contains(&k2) || !(1..=32).contains(&m1) || !(1..=32).contains(&m2) { + return Err(format!("entity {seq}: bad B-spline surface size")); + } + let (k1, k2, m1, m2) = (k1 as usize, k2 as usize, m1 as usize, m2 as usize); + if (k1 + 1) * (k2 + 1) > MAX_LIST { + return Err(format!("entity {seq}: B-spline surface too large")); + } + let (n1, n2) = (k1 + m1 + 2, k2 + m2 + 2); + let mut s_knots = Vec::new(); + for j in 0..n1 { + s_knots.push(self.n(seq, 10 + j)?); + } + let mut t_knots = Vec::new(); + for j in 0..n2 { + t_knots.push(self.n(seq, 10 + n1 + j)?); + } + let base_w = 10 + n1 + n2; + let cnt = (k1 + 1) * (k2 + 1); + let base_p = base_w + cnt; + // IGES runs the first index fastest: entry (i, j) is at j * (k1 + 1) + i. + let mut rows = Vec::new(); + let mut wrows = Vec::new(); + let mut rational = false; + for i in 0..=k1 { + let mut row = Vec::new(); + let mut wrow = Vec::new(); + for j in 0..=k2 { + let idx = j * (k1 + 1) + i; + let w = self.n(seq, base_w + idx)?; + if !(w > 0.0) { + return Err(format!("entity {seq}: non-positive weight")); + } + rational |= (w - 1.0).abs() > 1e-12; + let p = x.point(self.pt(seq, base_p + 3 * idx)?); + row.push(format!("#{}", self.o.point(p))); + wrow.push(r(w)); + } + rows.push(format!("({})", row.join(","))); + wrows.push(format!("({})", wrow.join(","))); + } + let (ms, ks) = Self::knot_text(&s_knots); + let (mt_, kt) = Self::knot_text(&t_knots); + Ok(if rational { + self.o.add(format!( + "(BOUNDED_SURFACE()B_SPLINE_SURFACE({m1},{m2},({}),.UNSPECIFIED.,.U.,.U.,.U.)B_SPLINE_SURFACE_WITH_KNOTS(({ms}),({mt_}),({ks}),({kt}),.UNSPECIFIED.)GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE(({}))REPRESENTATION_ITEM('')SURFACE())", + rows.join(","), + wrows.join(",") + )) + } else { + self.o.add(format!( + "B_SPLINE_SURFACE_WITH_KNOTS('',{m1},{m2},({}),.UNSPECIFIED.,.U.,.U.,.U.,({ms}),({mt_}),({ks}),({kt}),.UNSPECIFIED.)", + rows.join(",") + )) + }) + } + 108 => { + let n = [self.n(seq, 1)?, self.n(seq, 2)?, self.n(seq, 3)?]; + let d = self.n(seq, 4)? * u; + let nn = unit(n).ok_or_else(|| format!("entity {seq}: plane without a normal"))?; + let o = mul(nn, d / norm(n)); + let (o, z) = (x.point(o), unit(x.dir(nn)).ok_or("degenerate plane")?); + let ax = self.o.placement(o, z, perp(z)); + Ok(self.o.add(format!("PLANE('',#{ax})"))) + } + 190 => { + let o = self.point_entity(self.i(seq, 1)?, &x)?; + let z = self.dir_entity(self.i(seq, 2)?, &x)?; + let xd = match self.i(seq, 3) { + Ok(p) if p > 0 && de.form == 1 => self.dir_entity(p, &x).unwrap_or_else(|_| perp(z)), + _ => perp(z), + }; + let ax = self.o.placement(o, z, xd); + Ok(self.o.add(format!("PLANE('',#{ax})"))) + } + 192 | 194 => { + let o = self.point_entity(self.i(seq, 1)?, &x)?; + let z = self.dir_entity(self.i(seq, 2)?, &x)?; + let rad = self.n(seq, 3)? * u * k; + let (angle, ref_field) = if de.typ == 194 { (self.n(seq, 4)?.to_radians(), 5) } else { (0.0, 4) }; + let xd = match self.i(seq, ref_field) { + Ok(p) if p > 0 && de.form == 1 => self.dir_entity(p, &x).unwrap_or_else(|_| perp(z)), + _ => perp(z), + }; + let ax = self.o.placement(o, z, xd); + Ok(if de.typ == 192 { + self.o.add(format!("CYLINDRICAL_SURFACE('',#{ax},{})", r(rad))) + } else { + self.o.add(format!("CONICAL_SURFACE('',#{ax},{},{})", r(rad), r(angle))) + }) + } + 196 => { + let o = self.point_entity(self.i(seq, 1)?, &x)?; + let rad = self.n(seq, 2)? * u * k; + let (z, xd) = match (self.i(seq, 3), self.i(seq, 4)) { + (Ok(a), Ok(b)) if a > 0 && b > 0 && de.form == 1 => (self.dir_entity(a, &x)?, self.dir_entity(b, &x)?), + _ => ( + unit(x.dir([0.0, 0.0, 1.0])).ok_or("degenerate sphere frame")?, + unit(x.dir([1.0, 0.0, 0.0])).ok_or("degenerate sphere frame")?, + ), + }; + let ax = self.o.placement(o, z, xd); + Ok(self.o.add(format!("SPHERICAL_SURFACE('',#{ax},{})", r(rad)))) + } + 198 => { + let o = self.point_entity(self.i(seq, 1)?, &x)?; + let z = self.dir_entity(self.i(seq, 2)?, &x)?; + let (big, small) = (self.n(seq, 3)? * u * k, self.n(seq, 4)? * u * k); + let xd = match self.i(seq, 5) { + Ok(p) if p > 0 && de.form == 1 => self.dir_entity(p, &x).unwrap_or_else(|_| perp(z)), + _ => perp(z), + }; + let ax = self.o.placement(o, z, xd); + Ok(self.o.add(format!("TOROIDAL_SURFACE('',#{ax},{},{})", r(big), r(small)))) + } + 120 => { + // Axis line, generatrix, start and end angles (the face's trim bounds the turn). + let axis = self.i(seq, 1)?; + let generatrix = self.i(seq, 2)?; + if self.de(axis)?.typ != 110 { + return Err(format!("entity {seq}: revolution axis is not a line")); + } + let ax_x = x.then(&self.own_xf(axis, 0)?); + let (a, b) = (ax_x.point(self.pt(axis, 1)?), ax_x.point(self.pt(axis, 4)?)); + let d = unit(sub(b, a)).ok_or("zero-length revolution axis")?; + let segs = self.curve(generatrix, &x, 1)?; + let [seg] = segs.as_slice() else { return Err(format!("entity {seq}: composite generatrix is not read")) }; + let (pa, dd) = (self.o.point(a), self.o.dir(d)); + let a1 = self.o.add(format!("AXIS1_PLACEMENT('',#{pa},#{dd})")); + Ok(self.o.add(format!("SURFACE_OF_REVOLUTION('',#{},#{a1})", seg.curve))) + } + 122 => { + let segs = self.curve(self.i(seq, 1)?, &x, 1)?; + let [seg] = segs.as_slice() else { return Err(format!("entity {seq}: composite directrix is not read")) }; + let end = x.point(self.pt(seq, 2)?); + let v = sub(end, seg.a); + let d = unit(v).ok_or("zero-length extrusion")?; + let dd = self.o.dir(d); + let vec = self.o.add(format!("VECTOR('',#{dd},{})", r(norm(v)))); + Ok(self.o.add(format!("SURFACE_OF_LINEAR_EXTRUSION('',#{},#{vec})", seg.curve))) + } + 118 => Err(format!("entity {seq}: ruled surfaces are not read yet")), + 140 => Err(format!("entity {seq}: offset surfaces are not read")), + t => Err(format!("surface type {t} is not read")), + } + } + + /// The natural boundary of a B-spline surface (its four edge curves), for trimmed surfaces + /// with no outer curve. + fn natural_boundary(&mut self, seq: i64, xf: &Xf) -> R> { + let de = self.de(seq)?; + if de.typ != 128 { + return Err(format!("entity {seq}: an untrimmed {} surface has no boundary", de.typ)); + } + let x = xf.then(&self.own_xf(seq, 0)?); + let (k1, k2, m1, m2) = (self.i(seq, 1)? as usize, self.i(seq, 2)? as usize, self.i(seq, 3)? as usize, self.i(seq, 4)? as usize); + let (n1, n2) = (k1 + m1 + 2, k2 + m2 + 2); + let s_knots: Vec = (0..n1).map(|j| self.n(seq, 10 + j)).collect::>()?; + let t_knots: Vec = (0..n2).map(|j| self.n(seq, 10 + n1 + j)).collect::>()?; + let base_w = 10 + n1 + n2; + let base_p = base_w + (k1 + 1) * (k2 + 1); + let h = |i: usize, j: usize| -> R<[f64; 4]> { + let idx = j * (k1 + 1) + i; + let w = self.n(seq, base_w + idx)?; + let p = x.point(self.pt(seq, base_p + 3 * idx)?); + Ok([p[0] * w, p[1] * w, p[2] * w, w]) + }; + // Clamped knot vectors: the boundary rows are the boundary curves. + let row = |j: usize| -> R { Ok(Nurbs { deg: m1, knots: s_knots.clone(), pts: (0..=k1).map(|i| h(i, j)).collect::>()? }) }; + let col = |i: usize| -> R { Ok(Nurbs { deg: m2, knots: t_knots.clone(), pts: (0..=k2).map(|j| h(i, j)).collect::>()? }) }; + // Counter-clockwise in parameter space: v = v0 forward, u = u1 forward, v = v1 back, u = u0 back. + let curves = [(row(0)?, false), (col(k1)?, false), (row(k2)?, true), (col(0)?, true)]; + let mut out = Vec::new(); + for (nb, rev) in curves { + let (t0, t1) = nb.range(); + let ev = |t: f64| nb.eval(t0 + (t1 - t0) * t); + let (Some(a), Some(b), Some(mid)) = (ev(0.0), ev(1.0), ev(0.5)) else { return Err("boundary cannot be evaluated".into()) }; + if norm(sub(a, b)) <= self.tol && norm(sub(a, mid)) <= self.tol { + continue; // a pole + } + let c = self.write_nurbs_curve(&nb); + let seg = Seg::along(c, |t| ev(t).unwrap_or([0.0; 3])); + out.push(if rev { seg.reversed() } else { seg }); + } + Ok(out) + } +} + +/// An edge after sewing: STEP curve, end vertices, sample points (curve direction). +struct Edge { + curve: u64, + /// The curve runs from `v[1]` to `v[0]`. + rev: bool, + v: [usize; 2], + q: V3, + mid: V3, +} + +/// A face being assembled: surface, sense, loops of (edge, forward), colour. +struct FaceB { + surface: u64, + sense: bool, + loops: Vec>, + color: Option<[f32; 3]>, +} + +struct Sewer { + tol: f64, + verts: Vec, + grid: HashMap<[i64; 3], Vec>, + edges: Vec, + by_ends: HashMap<(usize, usize), Vec>, +} + +impl Sewer { + fn vertex(&mut self, p: V3) -> usize { + let c = |x: f64| (x / self.tol).floor() as i64; + let key = [c(p[0]), c(p[1]), c(p[2])]; + for dx in -1..=1 { + for dy in -1..=1 { + for dz in -1..=1 { + if let Some(list) = self.grid.get(&[key[0] + dx, key[1] + dy, key[2] + dz]) { + for &i in list { + if self.verts.get(i).is_some_and(|q| norm(sub(*q, p)) <= self.tol) { + return i; + } + } + } + } + } + } + self.verts.push(p); + self.grid.entry(key).or_default().push(self.verts.len() - 1); + self.verts.len() - 1 + } + /// The edge for a segment (an existing one when another face already has it) and whether + /// the segment runs along it. + fn edge(&mut self, s: &Seg) -> (usize, bool) { + let (a, b) = (self.vertex(s.a), self.vertex(s.b)); + let key = (a.min(b), a.max(b)); + let close = |x: V3, y: V3| norm(sub(x, y)) <= self.tol * 10.0 + 1e-9; + if let Some(list) = self.by_ends.get(&key) { + for &e in list { + let Some(ed) = self.edges.get(e) else { continue }; + if !close(ed.mid, s.mid) { + continue; + } + let fwd = if a != b { + ed.v[0] == a + } else { + // A closed edge: compare a point a quarter of the way along. + close(ed.q, s.q) + }; + return (e, fwd); + } + } + self.edges.push(Edge { curve: s.curve, rev: s.rev, v: [a, b], q: s.q, mid: s.mid }); + let e = self.edges.len() - 1; + self.by_ends.entry(key).or_default().push(e); + (e, true) + } +} + +/// Faces flipped so that every shared edge is used once each way (the trimmed surfaces of a +/// shell need not agree on their normals). +fn orient(faces: &mut [FaceB]) { + let mut uses: HashMap> = HashMap::new(); + for (fi, f) in faces.iter().enumerate() { + for &(e, d) in f.loops.iter().flatten() { + uses.entry(e).or_default().push((fi, d)); + } + } + let mut flip: Vec> = vec![None; faces.len()]; + for start in 0..faces.len() { + if flip[start].is_some() { + continue; + } + flip[start] = Some(false); + let mut stack = vec![start]; + while let Some(fi) = stack.pop() { + let fl = flip[fi].unwrap_or(false); + let Some(f) = faces.get(fi) else { continue }; + for &(e, d) in f.loops.iter().flatten() { + let d = d != fl; + for &(g, dg) in uses.get(&e).into_iter().flatten() { + if g == fi || flip[g].is_some() { + continue; + } + // Neighbour must run the edge the other way. + flip[g] = Some(dg == d); + stack.push(g); + } + } + } + } + for (f, fl) in faces.iter_mut().zip(flip) { + if fl == Some(true) { + f.sense = !f.sense; + for l in &mut f.loops { + l.reverse(); + for x in l.iter_mut() { + x.1 = !x.1; + } + } + } + } +} + +fn color_of(t: &Tr, seq: i64) -> Option<[f32; 3]> { + let de = t.de(seq).ok()?; + match de.color { + c if c < 0 => { + let p = -c; + if t.de(p).ok()?.typ != 314 { + return None; + } + let ch = |i: usize| t.n(p, i).ok().map(|x| (x / 100.0).clamp(0.0, 1.0) as f32); + Some([ch(1)?, ch(2)?, ch(3)?]) + } + 1 => Some([0.0, 0.0, 0.0]), + 2 => Some([1.0, 0.0, 0.0]), + 3 => Some([0.0, 1.0, 0.0]), + 4 => Some([0.0, 0.0, 1.0]), + 5 => Some([1.0, 1.0, 0.0]), + 6 => Some([1.0, 0.0, 1.0]), + 7 => Some([0.0, 1.0, 1.0]), + 8 => Some([1.0, 1.0, 1.0]), + _ => None, + } +} + +/// Write faces sewn by `sw` as a shell; the shell's id and whether it closes. +fn write_shell(o: &mut Out, sw: &Sewer, faces: &[FaceB], styles: &mut Vec<(u64, [f32; 3])>) -> (u64, bool) { + let vids: Vec = sw + .verts + .iter() + .map(|p| { + let c = o.point(*p); + o.add(format!("VERTEX_POINT('',#{c})")) + }) + .collect(); + let mut eids: HashMap = HashMap::new(); + let mut count: HashMap = HashMap::new(); + for &(e, d) in faces.iter().flat_map(|f| f.loops.iter().flatten()) { + let c = count.entry(e).or_insert((0, 0)); + if d { + c.0 += 1; + } else { + c.1 += 1; + } + } + let mut fids = Vec::new(); + for f in faces { + let mut bounds = Vec::new(); + for (k, l) in f.loops.iter().enumerate() { + let mut oes = Vec::new(); + for &(e, d) in l { + let Some(ed) = sw.edges.get(e) else { continue }; + let id = match eids.get(&e) { + Some(id) => *id, + None => { + let (Some(a), Some(b)) = (vids.get(ed.v[0]), vids.get(ed.v[1])) else { continue }; + let id = o.add(format!("EDGE_CURVE('',#{a},#{b},#{},{})", ed.curve, if ed.rev { ".F." } else { ".T." })); + eids.insert(e, id); + id + } + }; + oes.push(format!("#{}", o.add(format!("ORIENTED_EDGE('',*,*,#{id},{})", if d { ".T." } else { ".F." })))); + } + if oes.is_empty() { + continue; + } + let lp = o.add(format!("EDGE_LOOP('',({}))", oes.join(","))); + let kind = if k == 0 { "FACE_OUTER_BOUND" } else { "FACE_BOUND" }; + bounds.push(format!("#{}", o.add(format!("{kind}('',#{lp},.T.)")))); + } + if bounds.is_empty() { + continue; + } + let id = o.add(format!("ADVANCED_FACE('',({}),#{},{})", bounds.join(","), f.surface, if f.sense { ".T." } else { ".F." })); + if let Some(c) = f.color { + styles.push((id, c)); + } + fids.push(format!("#{id}")); + } + let closed = !count.is_empty() && count.values().all(|c| *c == (1, 1)); + let kind = if closed { "CLOSED_SHELL" } else { "OPEN_SHELL" }; + (o.add(format!("{kind}('',({}))", fids.join(","))), closed) +} + +/// IGES text restated as a STEP exchange (millimetres), with what could not be read. +pub fn iges_to_step(text: &str) -> std::result::Result<(String, Vec), String> { + if text.len() > MAX_IGES_BYTES { + return Err("IGES file too large".into()); + } + let f = parse(text)?; + let tol = (f.resolution * f.unit).clamp(1e-6, 0.05).max(1e-5); + let mut t = Tr { f: &f, o: Out { lines: Vec::new() }, warnings: Vec::new(), tol, surf_cache: HashMap::new() }; + // Header: units and context. + let mm = t.o.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))"); + let rad = t.o.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))"); + let sr = t.o.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())"); + let unc = t.o.add(format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol))); + let ctx = t.o.add(format!( + "(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{mm},#{rad},#{sr}))REPRESENTATION_CONTEXT('',''))" + )); + let mut seqs: Vec = f.des.keys().copied().collect(); + seqs.sort(); + // Entities used by others (a B-rep's faces, a trimmed surface's curves) are not roots; + // physically dependent entities have status digits 01 in columns 3–4, but files are lax: + // collect references instead. + let mut solids: Vec<(u64, String)> = Vec::new(); + let mut styles: Vec<(u64, [f32; 3])> = Vec::new(); + let mut n = 0usize; + for &s in &seqs { + let Some(de) = f.des.get(&s) else { continue }; + if de.typ != 186 { + continue; + } + n += 1; + // A name property (406 form 15) among the solid's trailing pointers, else the label. + let named = f.params.get(&s).and_then(|p| { + p.iter().skip(4).filter_map(|x| int(x)).filter(|x| *x > 0).find_map(|ptr| { + let d = f.des.get(&usize::try_from(ptr).ok()?)?; + (d.typ == 406 && d.form == 15).then(|| f.params.get(&usize::try_from(ptr).ok()?)?.get(2).cloned()).flatten() + }) + }); + let name = match named.filter(|x| !x.trim().is_empty()) { + Some(x) => x.trim().to_string(), + None if de.label.is_empty() => format!("Body{n}"), + None => de.label.clone(), + }; + match brep(&mut t, s as i64, &mut styles) { + Ok((id, color)) => { + let named = { + // Re-label the solid with its name. + let line = t.o.lines.get(id as usize - 1).cloned().unwrap_or_default(); + line.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1) + }; + if let Some(l) = t.o.lines.get_mut(id as usize - 1) { + *l = named; + } + if let Some(c) = color { + styles.push((id, c)); + } + solids.push((id, name)); + } + Err(e) => t.warn(format!("solid {name}: not read: {e}")), + } + } + // Trimmed and bounded surfaces not used by anything else: sewn into shells. + let mut faces = Vec::new(); + let mut sw = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() }; + if solids.is_empty() { + for &s in &seqs { + let Some(de) = f.des.get(&s) else { continue }; + if de.typ != 144 && de.typ != 143 && !(de.typ == 128 && is_root(&f, s)) { + continue; + } + match trimmed(&mut t, &mut sw, s as i64) { + Ok(fb) => faces.push(fb), + Err(e) => t.warn(format!("surface {s}: not read: {e}")), + } + } + if !faces.is_empty() { + orient(&mut faces); + let (shell, closed) = write_shell(&mut t.o, &sw, &faces, &mut styles); + let item = if closed { + t.o.add(format!("MANIFOLD_SOLID_BREP('Body1',#{shell})")) + } else { + t.warn("the surfaces do not close into a solid: imported as an open body"); + t.o.add(format!("SHELL_BASED_SURFACE_MODEL('Body1',(#{shell}))")) + }; + solids.push((item, "Body1".into())); + } + } + if solids.is_empty() { + let why = t.warnings.first().cloned().unwrap_or_else(|| "no solids or trimmed surfaces".into()); + return Err(format!("IGES import: nothing to read ({why})")); + } + let items = solids.iter().map(|(i, _)| format!("#{i}")).collect::>().join(","); + t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({items}),#{ctx})")); + for (item, c) in styles { + let rgb = t.o.add(format!("COLOUR_RGB('',{},{},{})", r(c[0] as f64), r(c[1] as f64), r(c[2] as f64))); + let fasc = t.o.add(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})")); + let fas = t.o.add(format!("FILL_AREA_STYLE('',(#{fasc}))")); + let ssfa = t.o.add(format!("SURFACE_STYLE_FILL_AREA(#{fas})")); + let sss = t.o.add(format!("SURFACE_SIDE_STYLE('',(#{ssfa}))")); + let ssu = t.o.add(format!("SURFACE_STYLE_USAGE(.BOTH.,#{sss})")); + let psa = t.o.add(format!("PRESENTATION_STYLE_ASSIGNMENT((#{ssu}))")); + t.o.add(format!("STYLED_ITEM('color',(#{psa}),#{item})")); + } + let mut s = String::from( + "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('IGES import'),'2;1');\nFILE_NAME('','',(''),(''),'SolveCraft IGES','','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\n", + ); + for (i, l) in t.o.lines.iter().enumerate() { + let _ = writeln!(s, "#{}={l};", i + 1); + } + s.push_str("ENDSEC;\nEND-ISO-10303-21;\n"); + Ok((s, t.warnings)) +} + +/// A surface entity nobody refers to (a bare B-spline patch is a face of its own). +fn is_root(f: &Iges, seq: usize) -> bool { + let target = seq.to_string(); + !f.params + .iter() + .any(|(s, p)| *s != seq && f.des.get(s).is_some_and(|d| d.typ != 406 && d.typ != 402) && p.iter().skip(1).any(|x| x.trim() == target)) +} + +/// A trimmed (144) or bounded (143) surface, or a bare B-spline surface, as a face. +fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R { + let de = t.de(seq)?; + let xf = t.own_xf(seq, 0)?; + let color = color_of(t, seq); + let loop_of = |_t: &mut Tr, sw: &mut Sewer, segs: Vec| -> Vec<(usize, bool)> { + let tol = sw.tol; + segs.iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).map(|s| sw.edge(s)).collect() + }; + match de.typ { + 144 => { + let surf_seq = t.i(seq, 1)?; + let surface = t.surface(surf_seq, &xf)?; + let n1 = t.i(seq, 2)?; + let n2 = t.i(seq, 3)?.clamp(0, MAX_LIST as i64) as usize; + let mut loops = Vec::new(); + let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf)? }; + loops.push(loop_of(t, sw, outer)); + for k in 0..n2 { + let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf)?; + loops.push(loop_of(t, sw, segs)); + } + loops.retain(|l| !l.is_empty()); + if loops.is_empty() { + return Err("no boundary".into()); + } + Ok(FaceB { surface, sense: true, loops, color }) + } + 143 => { + let surf_seq = t.i(seq, 2)?; + let surface = t.surface(surf_seq, &xf)?; + let n = t.i(seq, 3)?.clamp(0, MAX_LIST as i64) as usize; + let mut loops = Vec::new(); + for k in 0..n { + let b = t.i(seq, 4 + k)?; + let segs = boundary_141(t, b, &xf)?; + loops.push(loop_of(t, sw, segs)); + } + loops.retain(|l| !l.is_empty()); + if loops.is_empty() { + return Err("no boundary".into()); + } + Ok(FaceB { surface, sense: true, loops, color }) + } + _ => { + let surface = t.surface(seq, &Xf::ID)?; + let outer = t.natural_boundary(seq, &Xf::ID)?; + let l = loop_of(t, sw, outer); + Ok(FaceB { surface, sense: true, loops: vec![l], color }) + } + } +} + +/// The model-space curve of a curve on a surface (142). +fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf) -> R> { + let de = t.de(seq)?; + if de.typ != 142 { + return t.curve(seq, xf, 0); + } + let x = xf.then(&t.own_xf(seq, 0)?); + let c = t.i(seq, 4)?; + if c <= 0 { + return Err("a curve on a surface without its model-space curve".into()); + } + t.curve(c, &x, 0) +} + +/// The model-space curves of a boundary (141), each in its sense. +fn boundary_141(t: &mut Tr, seq: i64, xf: &Xf) -> R> { + let de = t.de(seq)?; + if de.typ != 141 { + return t.curve(seq, xf, 0); + } + let x = xf.then(&t.own_xf(seq, 0)?); + let n = t.i(seq, 4)?.clamp(0, MAX_LIST as i64) as usize; + let mut out = Vec::new(); + let mut idx = 5; + for _ in 0..n { + let c = t.i(seq, idx)?; + let sense = t.i(seq, idx + 1)?; + let k = t.i(seq, idx + 2)?.clamp(0, 1024) as usize; + idx += 3 + k; + let mut segs = t.curve(c, &x, 0)?; + if sense == 2 { + segs = segs.into_iter().rev().map(Seg::reversed).collect(); + } + out.extend(segs); + } + Ok(out) +} + +/// A manifold solid B-rep (186): its solid id and colour. +fn brep(t: &mut Tr, seq: i64, face_styles: &mut Vec<(u64, [f32; 3])>) -> R<(u64, Option<[f32; 3]>)> { + let xf = t.own_xf(seq, 0)?; + let shell = t.i(seq, 1)?; + let sof = t.i(seq, 2)? != 0; + let nv = t.i(seq, 3)?.clamp(0, 10_000) as usize; + let mut shells = Vec::new(); + let (s0, closed) = brep_shell(t, shell, sof, &xf, face_styles)?; + if !closed { + t.warn("a solid's shell does not close"); + } + shells.push(s0); + for k in 0..nv { + let v = t.i(seq, 4 + 2 * k)?; + let vof = t.i(seq, 5 + 2 * k)? != 0; + match brep_shell(t, v, vof, &xf, face_styles) { + Ok((s, _)) => shells.push(s), + Err(e) => t.warn(format!("void shell not read: {e}")), + } + } + let id = if shells.len() == 1 { + t.o.add(format!("MANIFOLD_SOLID_BREP('',#{s0})")) + } else { + let voids: Vec = shells.iter().skip(1).map(|s| format!("#{s}")).collect(); + t.o.add(format!("BREP_WITH_VOIDS('',#{s0},({}))", voids.join(","))) + }; + Ok((id, color_of(t, seq))) +} + +/// A B-rep shell (514) of vertex lists (502), edge lists (504), loops (508) and faces (510). +fn brep_shell(t: &mut Tr, seq: i64, outward: bool, xf: &Xf, styles: &mut Vec<(u64, [f32; 3])>) -> R<(u64, bool)> { + if t.de(seq)?.typ != 514 { + return Err(format!("entity {seq} is not a shell")); + } + let nf = t.i(seq, 1)?.clamp(0, MAX_LIST as i64) as usize; + let mut sw = Sewer { tol: t.tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() }; + // Edge-list edges already turned into sewn edges. + let mut done: HashMap<(i64, i64), (usize, bool)> = HashMap::new(); + let mut faces = Vec::new(); + for k in 0..nf { + let fseq = t.i(seq, 2 + 2 * k)?; + let of = t.i(seq, 3 + 2 * k)? != 0; + match brep_face(t, &mut sw, &mut done, fseq, xf) { + Ok(mut fb) => { + // The face's normal: its surface's, flipped when the face or shell says so. Loops + // run counter-clockwise about the surface normal, so a flipped face reverses them. + fb.sense = of == outward; + if !fb.sense { + for l in &mut fb.loops { + l.reverse(); + for x in l.iter_mut() { + x.1 = !x.1; + } + } + } + faces.push(fb); + } + Err(e) => t.warn(format!("face {fseq}: not read: {e}")), + } + } + if faces.is_empty() { + return Err("no face could be read".into()); + } + orient(&mut faces); + Ok(write_shell(&mut t.o, &sw, &faces, styles)) +} + +fn brep_face(t: &mut Tr, sw: &mut Sewer, done: &mut HashMap<(i64, i64), (usize, bool)>, seq: i64, xf: &Xf) -> R { + if t.de(seq)?.typ != 510 { + return Err(format!("entity {seq} is not a face")); + } + let surface = t.surface(t.i(seq, 1)?, xf)?; + let nl = t.i(seq, 2)?.clamp(0, MAX_LIST as i64) as usize; + let mut loops = Vec::new(); + for k in 0..nl { + let lseq = t.i(seq, 4 + k)?; + if t.de(lseq)?.typ != 508 { + continue; + } + let ne = t.i(lseq, 1)?.clamp(0, MAX_LIST as i64) as usize; + let mut idx = 2; + let mut l = Vec::new(); + for _ in 0..ne { + let typ = t.i(lseq, idx)?; + let list = t.i(lseq, idx + 1)?; + let ndx = t.i(lseq, idx + 2)?; + let of = t.i(lseq, idx + 3)? != 0; + let kp = t.i(lseq, idx + 4)?.clamp(0, 1024) as usize; + idx += 5 + 2 * kp; + if typ != 0 { + continue; // a vertex (a pole): no edge + } + let (e, fwd) = match done.get(&(list, ndx)) { + Some(x) => *x, + None => { + let x = brep_edge(t, sw, list, ndx, xf)?; + done.insert((list, ndx), x); + x + } + }; + l.push((e, fwd == of)); + } + if !l.is_empty() { + loops.push(l); + } + } + if loops.is_empty() { + return Err("no loops".into()); + } + Ok(FaceB { surface, sense: true, loops, color: color_of(t, seq) }) +} + +/// Edge `ndx` (1-based) of an edge list (504) as a sewn edge. +fn brep_edge(t: &mut Tr, sw: &mut Sewer, list: i64, ndx: i64, xf: &Xf) -> R<(usize, bool)> { + if t.de(list)?.typ != 504 { + return Err(format!("entity {list} is not an edge list")); + } + let n = t.i(list, 1)?; + if ndx < 1 || ndx > n { + return Err(format!("edge {ndx} of a list of {n}")); + } + let base = 2 + 5 * (ndx as usize - 1); + let curve = t.i(list, base)?; + let vertex = |t: &Tr, vl: i64, vi: i64| -> R { + if t.de(vl)?.typ != 502 { + return Err(format!("entity {vl} is not a vertex list")); + } + let nv = t.i(vl, 1)?; + if vi < 1 || vi > nv { + return Err(format!("vertex {vi} of a list of {nv}")); + } + let x = xf.then(&t.own_xf(vl, 0)?); + Ok(x.point(t.pt(vl, 2 + 3 * (vi as usize - 1))?)) + }; + let a = vertex(t, t.i(list, base + 1)?, t.i(list, base + 2)?)?; + let b = vertex(t, t.i(list, base + 3)?, t.i(list, base + 4)?)?; + let segs = t.curve(curve, xf, 0)?; + let [s] = segs.as_slice() else { return Err("composite edge curves are not read".into()) }; + // The edge runs from its start vertex to its end vertex; the curve may run the other way. + let along = norm(sub(s.a, a)) + norm(sub(s.b, b)) <= norm(sub(s.a, b)) + norm(sub(s.b, a)); + let mut s = if along { *s } else { s.reversed() }; + s.a = a; + s.b = b; + Ok(sw.edge(&s)) +} + +/// Read IGES text: its solids, or its surfaces sewn into shells, as the STEP reader gives them. +pub fn iges_import(text: &str) -> crate::Result { + let (step, warnings) = iges_to_step(text).map_err(crate::KernelError::Invalid)?; + let mut imp = crate::step_import(&step)?; + imp.warnings.splice(0..0, warnings); + imp.schema = "IGES".into(); + Ok(imp) +} + +#[cfg(test)] +mod tests; diff --git a/crates/kernel/src/iges_in/tests.rs b/crates/kernel/src/iges_in/tests.rs new file mode 100644 index 00000000..03edfc81 --- /dev/null +++ b/crates/kernel/src/iges_in/tests.rs @@ -0,0 +1,143 @@ +use super::*; +use crate::{Body, BoolOp, ExportBody, StepHeader, boolean, box_solid, cylinder, fillet, iges_export_bodies, measure, sphere, torus}; +use solvecraft_geom::Vec3; + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-9) +} + +/// Export a body to IGES and read it back: same volume and area, its name and colour. +fn round_trip(b: &Body) -> crate::StepImport { + let body = ExportBody { name: "Bracket left 1".into(), body: b, color: Some([1.0, 0.5, 0.0]) }; + let text = iges_export_bodies(&[body], &StepHeader { file_name: "t.igs".into(), ..Default::default() }).unwrap(); + for l in text.lines() { + assert_eq!(l.chars().count(), 80, "{l}"); + } + let imp = iges_import(&text).unwrap(); + assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); + assert_eq!(imp.bodies.len(), 1); + let (m0, m1) = (measure(b).unwrap(), measure(&imp.bodies[0].body).unwrap()); + assert!(rel(m1.volume, m0.volume) < 1e-3, "volume {} vs {}", m1.volume, m0.volume); + assert!(rel(m1.area, m0.area) < 1e-3, "area {} vs {}", m1.area, m0.area); + assert!(imp.bodies[0].closed); + assert_eq!(imp.bodies[0].name, "Bracket left 1"); + assert_eq!(imp.bodies[0].color, Some([1.0, 0.5, 0.0])); + imp +} + +#[test] +fn primitives_round_trip_through_iges() { + round_trip(&box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap()); + round_trip(&cylinder(Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.0, 1.0, 1.0), 5.0, 12.0).unwrap()); + round_trip(&sphere(Vec3::new(1.0, 2.0, 3.0), 10.0).unwrap()); + round_trip(&torus(Vec3::ZERO, 20.0, 4.0).unwrap()); +} + +#[test] +fn features_round_trip_through_iges() { + let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap(); + let pin = cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap(); + let holed = boolean(&plate, &pin, BoolOp::Cut).unwrap().unwrap(); + round_trip(&holed); + round_trip(&fillet(&plate, &[Vec3::new(20.0, 0.0, 10.0)], 3.0).unwrap()); +} + +/// Hostile files fail cleanly: garbage, truncation, missing and circular references, huge +/// counts. +#[test] +fn hostile_iges_never_panics() { + let good = iges_export_bodies( + &[ExportBody { name: "B".into(), body: &box_solid(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)).unwrap(), color: None }], + &StepHeader::default(), + ) + .unwrap(); + let mut cases: Vec = vec![String::new(), "garbage".into(), "\u{0}\u{ff}".into(), good[..good.len() / 2].to_string()]; + // Every parameter line with its numbers bumped, scrambled or blown up. + for (k, f) in [|s: &str| s.replace('1', "9"), |s: &str| s.replace(',', ";"), |s: &str| s.replace("0,", "999999999,")].iter().enumerate() { + let t: String = good + .lines() + .map(|l| if l.as_bytes().get(72) == Some(&b'P') && l.len() >= 64 { format!("{}{}", f(&l[..64]), &l[64..]) } else { l.to_string() }) + .collect::>() + .join("\n"); + let _ = k; + cases.push(t); + } + // A transformation and a composite curve that refer to themselves. + cases.push( + [ + format!("{:<72}S{:>7}", "", 1), + format!("{:<72}G{:>7}", "1H,,1H;;", 1), + format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 124, 1, 0, 0, 0, 0, 1, 0, "00000000", 1), + format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 124, 0, 0, 1, 0, 0, 0, "", 0, 2), + format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 102, 2, 0, 0, 0, 0, 1, 0, "00000000", 3), + format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 102, 0, 0, 1, 0, 0, 0, "", 0, 4), + format!("{:<64}{:>8}P{:>7}", "124,1.,0.,0.,0.,0.,1.,0.,0.,0.,0.,1.,0.;", 1, 1), + format!("{:<64}{:>8}P{:>7}", "102,1,3;", 3, 2), + format!("{:<72}T{:>7}", "S 1G 1D 4P 2", 1), + ] + .join("\n"), + ); + for c in cases { + let r = std::panic::catch_unwind(|| iges_import(&c).map(|i| i.bodies.len())); + assert!(r.is_ok(), "panicked on a hostile file"); + } +} + +/// An IGES file from (type, form, parameters) entities, in order (pointers are 2k + 1). +fn iges_file(ents: &[(i64, i64, String)]) -> String { + let mut out = vec![format!("{:<72}S{:>7}", "test", 1), format!("{:<72}G{:>7}", "1H,,1H;,,,,,,,,,,,1.,2,2HMM,,,,1.E-6;", 1)]; + let mut p_lines: Vec<(usize, String)> = Vec::new(); + let mut starts = Vec::new(); + for (k, (t, _, p)) in ents.iter().enumerate() { + starts.push(p_lines.len() + 1); + let text = format!("{t},{p};"); + let chars: Vec = text.chars().collect(); + for c in chars.chunks(64) { + p_lines.push((2 * k + 1, c.iter().collect())); + } + } + for (k, (t, form, _)) in ents.iter().enumerate() { + let count = p_lines.iter().filter(|(d, _)| *d == 2 * k + 1).count(); + out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, starts[k], 0, 0, 0, 0, 0, 0, "00000000", 2 * k + 1)); + out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, 0, 0, count, form, 0, 0, "", 0, 2 * k + 2)); + } + for (i, (d, l)) in p_lines.iter().enumerate() { + out.push(format!("{l:<64}{d:>8}P{:>7}", i + 1)); + } + out.push(format!("{:<72}T{:>7}", "", 1)); + out.join("\n") +} + +/// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose +/// boundaries are lines: sewn into a closed solid. +#[test] +fn trimmed_surfaces_sew_into_a_solid() { + let c = + |i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] }; + // Faces as corner quads (counter-clockwise seen from outside). + let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]]; + let mut ents: Vec<(i64, i64, String)> = Vec::new(); + let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]); + for q in quads { + let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3])); + // Patch: u from a to b, v from a to d (normal (b - a) × (d - a) outward). + let surf = format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(cc)); + ents.push((128, 0, surf)); + let s_de = 2 * ents.len() - 1; + let mut lines = Vec::new(); + for (p0, p1) in [(a, b), (b, cc), (cc, d), (d, a)] { + ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1)))); + lines.push(2 * ents.len() - 1); + } + ents.push((102, 0, format!("4,{}", lines.iter().map(|x| x.to_string()).collect::>().join(",")))); + let comp = 2 * ents.len() - 1; + ents.push((142, 0, format!("0,{s_de},0,{comp},2"))); + let cos = 2 * ents.len() - 1; + ents.push((144, 0, format!("{s_de},1,0,{cos}"))); + } + let imp = iges_import(&iges_file(&ents)).unwrap(); + assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); + assert!(imp.bodies[0].closed); + let m = measure(&imp.bodies[0].body).unwrap(); + assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area); +} diff --git a/crates/kernel/src/iges_out.rs b/crates/kernel/src/iges_out.rs new file mode 100644 index 00000000..37a0cd2c --- /dev/null +++ b/crates/kernel/src/iges_out.rs @@ -0,0 +1,711 @@ +//! IGES 5.3 export, written from the published specification: each body as a manifold solid +//! B-rep (186) with its shell, faces, loops, edge and vertex lists, on analytic surfaces +//! (planes, cylinders, cones, spheres, tori), surfaces of revolution and extrusion, and +//! rational B-splines, with body names (406 name properties) and colours (314). The bodies are +//! first written as STEP (so faces are merged and surfaces recognised exactly as in a STEP +//! export) and restated entity by entity. + +use std::collections::HashMap; +use std::fmt::Write as _; + +use crate::step_in::p21::{self, Exchange, Param}; +use crate::{ExportBody, KernelError, Result, StepHeader}; + +type V3 = [f64; 3]; + +fn sub(a: V3, b: V3) -> V3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn dot(a: V3, b: V3) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn cross(a: V3, b: V3) -> V3 { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} +fn norm(a: V3) -> f64 { + dot(a, a).sqrt() +} +fn unit(a: V3) -> Option { + let n = norm(a); + (n > 1e-300 && n.is_finite()).then(|| a.map(|x| x / n)) +} + +/// A real in IGES free format (always with a decimal point). +fn real(x: f64) -> String { + let x = if x.is_finite() { x } else { 0.0 }; + let s = format!("{x:?}"); + let s = s.replace('e', "E"); + if s.contains('.') { + s + } else if let Some((m, e)) = s.split_once('E') { + format!("{m}.E{e}") + } else { + format!("{s}.") + } +} + +/// A Hollerith string. +fn holl(s: &str) -> String { + let clean: String = s.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c } else { '_' }).collect(); + format!("{}H{clean}", clean.len()) +} + +struct Ent { + typ: i64, + form: i64, + params: Vec, + xform: i64, + color: i64, + label: String, + dependent: bool, +} + +#[derive(Default)] +struct W { + ents: Vec, + colors: HashMap<[u32; 3], i64>, +} + +impl W { + fn add(&mut self, typ: i64, form: i64, params: Vec) -> i64 { + self.ents.push(Ent { typ, form, params, xform: 0, color: 0, label: String::new(), dependent: true }); + 2 * self.ents.len() as i64 - 1 + } + fn get_mut(&mut self, de: i64) -> Option<&mut Ent> { + usize::try_from((de - 1) / 2).ok().and_then(|i| self.ents.get_mut(i)) + } + fn point(&mut self, p: V3) -> i64 { + self.add(116, 0, vec![real(p[0]), real(p[1]), real(p[2])]) + } + fn direction(&mut self, d: V3) -> i64 { + self.add(123, 0, vec![real(d[0]), real(d[1]), real(d[2])]) + } + /// Transformation (124) taking a frame's local coordinates to the model. + fn frame(&mut self, o: V3, x: V3, z: V3) -> i64 { + let y = cross(z, x); + let rows: Vec = (0..3).flat_map(|i| [real(x[i]), real(y[i]), real(z[i]), real(o[i])]).collect(); + self.add(124, 0, rows) + } + fn color(&mut self, c: [f32; 3]) -> i64 { + let key = c.map(f32::to_bits); + if let Some(d) = self.colors.get(&key) { + return *d; + } + let d = self.add(314, 0, vec![real(c[0] as f64 * 100.0), real(c[1] as f64 * 100.0), real(c[2] as f64 * 100.0), holl("")]); + if let Some(e) = self.get_mut(d) { + e.dependent = false; + } + self.colors.insert(key, d); + d + } +} + +/// Reading the STEP exchange. +struct S<'a> { + ex: &'a Exchange, +} + +impl<'a> S<'a> { + fn e(&self, id: u64) -> Result<&'a p21::Entity> { + self.ex.get(id).ok_or_else(|| KernelError::Failed(format!("IGES export: missing #{id}"))) + } + fn r(&self, p: Option<&Param>) -> Result { + p.and_then(Param::as_ref_id).ok_or_else(|| KernelError::Failed("IGES export: bad reference".into())) + } + fn f(&self, p: Option<&Param>) -> Result { + p.and_then(Param::as_f64).ok_or_else(|| KernelError::Failed("IGES export: bad number".into())) + } + fn list(&self, p: Option<&'a Param>) -> Result<&'a [Param]> { + p.and_then(Param::as_list).ok_or_else(|| KernelError::Failed("IGES export: bad list".into())) + } + fn point(&self, id: u64) -> Result { + let e = self.e(id)?; + let c = self.list(e.params().get(1))?; + Ok([self.f(c.first())?, self.f(c.get(1))?, self.f(c.get(2))?]) + } + fn dir(&self, id: u64) -> Result { + let d = self.point(id)?; + unit(d).ok_or_else(|| KernelError::Failed("IGES export: zero direction".into())) + } + fn vertex(&self, id: u64) -> Result { + self.point(self.r(self.e(id)?.params().get(1))?) + } + /// AXIS2_PLACEMENT_3D: origin, z, x. + fn placement(&self, id: u64) -> Result<(V3, V3, V3)> { + let p = self.e(id)?.params(); + let o = self.point(self.r(p.get(1))?)?; + let z = match p.get(2).and_then(Param::as_ref_id) { + Some(d) => self.dir(d)?, + None => [0.0, 0.0, 1.0], + }; + let x0 = match p.get(3).and_then(Param::as_ref_id) { + Some(d) => self.dir(d)?, + None => [1.0, 0.0, 0.0], + }; + let x = unit(sub(x0, z.map(|c| c * dot(x0, z)))).unwrap_or_else(|| { + let a = if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }; + unit(sub(a, z.map(|c| c * dot(a, z)))).unwrap_or([1.0, 0.0, 0.0]) + }); + Ok((o, z, x)) + } +} + +/// B-spline data of a STEP curve or surface record set: degrees, control rows, weights, knots. +struct Spline { + deg: Vec, + pts: Vec>, + weights: Option>>, + knots: Vec>, +} + +fn expand(mults: &[Param], knots: &[Param]) -> Option> { + let mut out = Vec::new(); + for (m, k) in mults.iter().zip(knots) { + let (m, k) = (m.as_i64()?, k.as_f64()?); + if !(1..=64).contains(&m) { + return None; + } + for _ in 0..m { + out.push(k); + } + } + Some(out) +} + +fn spline(s: &S, e: &p21::Entity, surface: bool) -> Result { + let bad = || KernelError::Failed("IGES export: unreadable B-spline".into()); + let (base, kn): (Vec, Vec) = if e.is_complex() { + let b = e.record(if surface { "B_SPLINE_SURFACE" } else { "B_SPLINE_CURVE" }).ok_or_else(bad)?; + let k = e.record(if surface { "B_SPLINE_SURFACE_WITH_KNOTS" } else { "B_SPLINE_CURVE_WITH_KNOTS" }).ok_or_else(bad)?; + let mut v = vec![Param::Str(String::new())]; + v.extend(b.iter().cloned()); + (v, k.to_vec()) + } else { + let p = e.params(); + let skip = if surface { 8 } else { 6 }; + (p.to_vec(), p.get(skip..).map(<[Param]>::to_vec).unwrap_or_default()) + }; + let deg_n = if surface { 2 } else { 1 }; + let deg: Vec = (0..deg_n) + .map(|i| base.get(1 + i).and_then(Param::as_i64).filter(|d| (1..=32).contains(d)).map(|d| d as usize).ok_or_else(bad)) + .collect::>()?; + let ctrl = base.get(1 + deg_n).and_then(Param::as_list).ok_or_else(bad)?; + let pts: Vec> = if surface { + ctrl.iter() + .map(|row| row.as_list().ok_or_else(bad)?.iter().map(|c| s.point(c.as_ref_id().ok_or_else(bad)?)).collect::>>()) + .collect::>()? + } else { + vec![ctrl.iter().map(|c| s.point(c.as_ref_id().ok_or_else(bad)?)).collect::>>()?] + }; + let knots: Vec> = if surface { + let (mu, mv, ku, kv) = (kn.first(), kn.get(1), kn.get(2), kn.get(3)); + vec![ + expand(mu.and_then(Param::as_list).ok_or_else(bad)?, ku.and_then(Param::as_list).ok_or_else(bad)?).ok_or_else(bad)?, + expand(mv.and_then(Param::as_list).ok_or_else(bad)?, kv.and_then(Param::as_list).ok_or_else(bad)?).ok_or_else(bad)?, + ] + } else { + vec![expand(kn.first().and_then(Param::as_list).ok_or_else(bad)?, kn.get(1).and_then(Param::as_list).ok_or_else(bad)?).ok_or_else(bad)?] + }; + let weights = match e.record(if surface { "RATIONAL_B_SPLINE_SURFACE" } else { "RATIONAL_B_SPLINE_CURVE" }) { + Some(r) => { + let w = r.first().and_then(Param::as_list).ok_or_else(bad)?; + Some(if surface { + w.iter() + .map(|row| row.as_list().ok_or_else(bad)?.iter().map(|x| x.as_f64().ok_or_else(bad)).collect::>>()) + .collect::>()? + } else { + vec![w.iter().map(|x| x.as_f64().ok_or_else(bad)).collect::>>()?] + }) + } + None => None, + }; + Ok(Spline { deg, pts, weights, knots }) +} + +/// A curve as an IGES entity running from `a` to `b` (its direction in the STEP file), the DE +/// pointer. +fn curve(w: &mut W, s: &S, id: u64, a: V3, b: V3, closed: bool) -> Result { + let e = s.e(id)?; + match e.name() { + "LINE" => Ok(w.add(110, 0, [a, b].iter().flatten().map(|x| real(*x)).collect())), + "CIRCLE" | "ELLIPSE" => { + let p = e.params(); + let (o, z, x) = s.placement(s.r(p.get(1))?)?; + let y = cross(z, x); + let local = |q: V3| { + let v = sub(q, o); + [dot(v, x), dot(v, y)] + }; + let (la, lb) = (local(a), if closed { local(a) } else { local(b) }); + let tr = w.frame(o, x, z); + let de = if e.name() == "CIRCLE" { + w.add(100, 0, vec![real(0.0), real(0.0), real(0.0), real(la[0]), real(la[1]), real(lb[0]), real(lb[1])]) + } else { + let (ra, rb) = (s.f(p.get(2))?, s.f(p.get(3))?); + // A x² + C y² + F = 0 with F = -1: A = 1/ra², C = 1/rb². + w.add( + 104, + 1, + vec![ + real(1.0 / (ra * ra)), + real(0.0), + real(1.0 / (rb * rb)), + real(0.0), + real(0.0), + real(-1.0), + real(0.0), + real(la[0]), + real(la[1]), + real(lb[0]), + real(lb[1]), + ], + ) + }; + if let Some(ent) = w.get_mut(de) { + ent.xform = tr; + } + Ok(de) + } + _ if e.has("B_SPLINE_CURVE") || e.name() == "B_SPLINE_CURVE_WITH_KNOTS" => { + let sp = spline(s, e, false)?; + let (Some(pts), Some(knots), Some(°)) = (sp.pts.first(), sp.knots.first(), sp.deg.first()) else { + return Err(KernelError::Failed("IGES export: empty B-spline".into())); + }; + let k = pts.len().saturating_sub(1); + let ws = sp.weights.as_ref().and_then(|w| w.first().cloned()).unwrap_or_else(|| vec![1.0; pts.len()]); + let rational = ws.iter().any(|w| (w - 1.0).abs() > 1e-12); + let (t0, t1) = (knots.get(deg).copied().unwrap_or(0.0), knots.get(pts.len()).copied().unwrap_or(1.0)); + let mut p = vec![k.to_string(), deg.to_string(), "0".into(), "0".into(), if rational { "0" } else { "1" }.into(), "0".into()]; + p.extend(knots.iter().map(|x| real(*x))); + p.extend(ws.iter().map(|x| real(*x))); + p.extend(pts.iter().flatten().map(|x| real(*x))); + p.extend([real(t0), real(t1), real(0.0), real(0.0), real(0.0)]); + Ok(w.add(126, 0, p)) + } + n => Err(KernelError::Failed(format!("IGES export: curve {n} is not written yet"))), + } +} + +/// Start point of a STEP curve (for extrusion directrices). +fn curve_start(s: &S, id: u64) -> Result { + let e = s.e(id)?; + match e.name() { + "LINE" => s.point(s.r(e.params().get(1))?), + "CIRCLE" => { + let (o, _, x) = s.placement(s.r(e.params().get(1))?)?; + let r = s.f(e.params().get(2))?; + Ok([o[0] + r * x[0], o[1] + r * x[1], o[2] + r * x[2]]) + } + _ => { + let sp = spline(s, e, false)?; + sp.pts.first().and_then(|r| r.first()).copied().ok_or_else(|| KernelError::Failed("IGES export: empty curve".into())) + } + } +} + +/// A curve written whole (a revolution's generatrix, an extrusion's directrix). +fn whole_curve(w: &mut W, s: &S, id: u64) -> Result { + let e = s.e(id)?; + match e.name() { + "LINE" => { + let p = e.params(); + let o = s.point(s.r(p.get(1))?)?; + let v = s.e(s.r(p.get(2))?)?; + let d = s.dir(s.r(v.params().get(1))?)?; + let m = s.f(v.params().get(2))?; + curve(w, s, id, o, [o[0] + d[0] * m, o[1] + d[1] * m, o[2] + d[2] * m], false) + } + "CIRCLE" | "ELLIPSE" => { + let a = curve_start(s, id)?; + curve(w, s, id, a, a, true) + } + _ => curve(w, s, id, [0.0; 3], [0.0; 3], false), + } +} + +fn surface(w: &mut W, s: &S, id: u64) -> Result { + let e = s.e(id)?; + let p = e.params(); + let analytic = |w: &mut W, typ: i64, place: u64, extra: &[f64]| -> Result { + let (o, z, x) = s.placement(place)?; + let (po, pz, px) = (w.point(o), w.direction(z), w.direction(x)); + let mut v = vec![po.to_string()]; + match typ { + 190 => v.extend([pz.to_string(), px.to_string()]), + 192 => v.extend([pz.to_string(), real(extra.first().copied().unwrap_or(0.0)), px.to_string()]), + 194 => v.extend([ + pz.to_string(), + real(extra.first().copied().unwrap_or(0.0)), + real(extra.get(1).copied().unwrap_or(0.0).to_degrees()), + px.to_string(), + ]), + 196 => v.extend([real(extra.first().copied().unwrap_or(0.0)), pz.to_string(), px.to_string()]), + _ => v.extend([pz.to_string(), real(extra.first().copied().unwrap_or(0.0)), real(extra.get(1).copied().unwrap_or(0.0)), px.to_string()]), + } + Ok(w.add(typ, 1, v)) + }; + match e.name() { + "PLANE" => analytic(w, 190, s.r(p.get(1))?, &[]), + "CYLINDRICAL_SURFACE" => analytic(w, 192, s.r(p.get(1))?, &[s.f(p.get(2))?]), + "CONICAL_SURFACE" => analytic(w, 194, s.r(p.get(1))?, &[s.f(p.get(2))?, s.f(p.get(3))?]), + "SPHERICAL_SURFACE" => analytic(w, 196, s.r(p.get(1))?, &[s.f(p.get(2))?]), + "TOROIDAL_SURFACE" => analytic(w, 198, s.r(p.get(1))?, &[s.f(p.get(2))?, s.f(p.get(3))?]), + "SURFACE_OF_REVOLUTION" => { + let gen_curve = whole_curve(w, s, s.r(p.get(1))?)?; + let ax = s.e(s.r(p.get(2))?)?; + let o = s.point(s.r(ax.params().get(1))?)?; + let d = s.dir(s.r(ax.params().get(2))?)?; + let axis = w.add(110, 0, [o, [o[0] + d[0], o[1] + d[1], o[2] + d[2]]].iter().flatten().map(|x| real(*x)).collect()); + Ok(w.add(120, 0, vec![axis.to_string(), gen_curve.to_string(), real(0.0), real(std::f64::consts::TAU)])) + } + "SURFACE_OF_LINEAR_EXTRUSION" => { + let c = s.r(p.get(1))?; + let dir = whole_curve(w, s, c)?; + let start = curve_start(s, c)?; + let v = s.e(s.r(p.get(2))?)?; + let d = s.dir(s.r(v.params().get(1))?)?; + let m = s.f(v.params().get(2))?; + Ok(w.add(122, 0, vec![dir.to_string(), real(start[0] + d[0] * m), real(start[1] + d[1] * m), real(start[2] + d[2] * m)])) + } + _ if e.has("B_SPLINE_SURFACE") || e.name() == "B_SPLINE_SURFACE_WITH_KNOTS" => { + let sp = spline(s, e, true)?; + let (Some(&du), Some(&dv), Some(ku), Some(kv)) = (sp.deg.first(), sp.deg.get(1), sp.knots.first(), sp.knots.get(1)) else { + return Err(KernelError::Failed("IGES export: bad B-spline surface".into())); + }; + let nu = sp.pts.len(); + let nv = sp.pts.first().map(Vec::len).unwrap_or(0); + if nu == 0 || nv == 0 || sp.pts.iter().any(|r| r.len() != nv) { + return Err(KernelError::Failed("IGES export: ragged B-spline surface".into())); + } + let wt = |i: usize, j: usize| sp.weights.as_ref().and_then(|w| w.get(i)?.get(j).copied()).unwrap_or(1.0); + let rational = (0..nu).any(|i| (0..nv).any(|j| (wt(i, j) - 1.0).abs() > 1e-12)); + let mut v = vec![ + (nu - 1).to_string(), + (nv - 1).to_string(), + du.to_string(), + dv.to_string(), + "0".into(), + "0".into(), + if rational { "0" } else { "1" }.into(), + "0".into(), + "0".into(), + ]; + v.extend(ku.iter().map(|x| real(*x))); + v.extend(kv.iter().map(|x| real(*x))); + // The first index runs fastest. + for j in 0..nv { + for i in 0..nu { + v.push(real(wt(i, j))); + } + } + for j in 0..nv { + for i in 0..nu { + let q = sp.pts.get(i).and_then(|r| r.get(j)).copied().unwrap_or([0.0; 3]); + v.extend(q.map(real)); + } + } + let range = |k: &Vec, d: usize, n: usize| (k.get(d).copied().unwrap_or(0.0), k.get(n).copied().unwrap_or(1.0)); + let ((u0, u1), (v0, v1)) = (range(ku, du, nu), range(kv, dv, nv)); + v.extend([real(u0), real(u1), real(v0), real(v1)]); + Ok(w.add(128, 0, v)) + } + n => Err(KernelError::Failed(format!("IGES export: surface {n} is not written yet"))), + } +} + +/// Colours of styled items (solids and faces). +fn colours(ex: &Exchange) -> HashMap { + let mut out = HashMap::new(); + for e in ex.entities.values() { + if e.name() != "STYLED_ITEM" { + continue; + } + let Some(item) = e.params().get(2).and_then(Param::as_ref_id) else { continue }; + let mut queue: Vec = + e.params().get(1).and_then(Param::as_list).map(|l| l.iter().filter_map(Param::as_ref_id).collect()).unwrap_or_default(); + let mut seen = 0; + while let Some(id) = queue.pop() { + seen += 1; + if seen > 64 { + break; + } + let Some(s) = ex.get(id) else { continue }; + if s.name() == "COLOUR_RGB" { + let c = |i: usize| s.params().get(i).and_then(Param::as_f64).map(|x| x.clamp(0.0, 1.0) as f32); + if let (Some(r), Some(g), Some(b)) = (c(1), c(2), c(3)) { + out.insert(item, [r, g, b]); + } + break; + } + for r in &s.records { + for p in &r.params { + match p { + Param::Ref(x) => queue.push(*x), + Param::List(v) => queue.extend(v.iter().filter_map(Param::as_ref_id)), + _ => {} + } + } + } + } + } + out +} + +/// A shell (514) with its vertex list, edge list, loops and faces. +fn shell(w: &mut W, s: &S, id: u64, colors: &HashMap) -> Result { + let sh = s.e(id)?; + let faces = s.list(sh.params().get(1))?; + // Vertices and edges of the shell, in first-use order. + let mut vidx: HashMap = HashMap::new(); + let mut verts: Vec = Vec::new(); + let mut eidx: HashMap = HashMap::new(); + let mut edges: Vec<(u64, bool, usize, usize)> = Vec::new(); // curve, same sense, start, end + let mut face_data = Vec::new(); + for f in faces { + let fid = s.r(Some(f))?; + let fe = s.e(fid)?; + let sense = fe.params().get(3).and_then(Param::as_bool).unwrap_or(true); + let mut loops: Vec> = Vec::new(); + let bounds = s.list(fe.params().get(1))?; + // The outer bound first. + let mut ordered: Vec<&Param> = + bounds.iter().filter(|b| b.as_ref_id().and_then(|x| s.ex.get(x)).is_some_and(|e| e.name() == "FACE_OUTER_BOUND")).collect(); + ordered.extend(bounds.iter().filter(|b| b.as_ref_id().and_then(|x| s.ex.get(x)).is_some_and(|e| e.name() != "FACE_OUTER_BOUND"))); + for b in ordered { + let be = s.e(s.r(Some(b))?)?; + let orient = be.params().get(2).and_then(Param::as_bool).unwrap_or(true); + let lp = s.e(s.r(be.params().get(1))?)?; + if lp.name() != "EDGE_LOOP" { + continue; + } + let mut l = Vec::new(); + for oe in s.list(lp.params().get(1))? { + let oe = s.e(s.r(Some(oe))?)?; + let ec = s.r(oe.params().get(3))?; + let d = oe.params().get(4).and_then(Param::as_bool).unwrap_or(true); + #[allow(clippy::map_entry)] // the edge's vertices are numbered before it + if !eidx.contains_key(&ec) { + let ee = s.e(ec)?; + let (v0, v1) = (s.r(ee.params().get(1))?, s.r(ee.params().get(2))?); + let mut vi = |v: u64| -> Result { + if let Some(i) = vidx.get(&v) { + return Ok(*i); + } + verts.push(s.vertex(v)?); + vidx.insert(v, verts.len() - 1); + Ok(verts.len() - 1) + }; + let (a, bb) = (vi(v0)?, vi(v1)?); + let same = ee.params().get(4).and_then(Param::as_bool).unwrap_or(true); + eidx.insert(ec, edges.len()); + edges.push((s.r(ee.params().get(3))?, same, a, bb)); + } + l.push((ec, d)); + } + // IGES loops run counter-clockwise about the surface normal. + if orient != sense { + l.reverse(); + for x in &mut l { + x.1 = !x.1; + } + } + loops.push(l); + } + face_data.push((fid, s.r(fe.params().get(2))?, sense, loops)); + } + let mut vp = vec![verts.len().to_string()]; + vp.extend(verts.iter().flatten().map(|x| real(*x))); + let vlist = w.add(502, 1, vp); + // Edges: the IGES edge runs along its curve (from the STEP edge's start when same sense). + let mut ep = vec![edges.len().to_string()]; + for &(c, same, a, b) in &edges { + let (sv, tv) = if same { (a, b) } else { (b, a) }; + let (pa, pb) = (verts.get(sv).copied().unwrap_or([0.0; 3]), verts.get(tv).copied().unwrap_or([0.0; 3])); + let cd = curve(w, s, c, pa, pb, sv == tv)?; + ep.extend([cd.to_string(), vlist.to_string(), (sv + 1).to_string(), vlist.to_string(), (tv + 1).to_string()]); + } + let elist = w.add(504, 1, ep); + let mut sp = vec![face_data.len().to_string()]; + for (fid, surf, sense, loops) in face_data { + let sd = surface(w, s, surf)?; + let mut lids = Vec::new(); + for l in loops { + let mut lp = vec![l.len().to_string()]; + for (ec, d) in l { + let k = eidx.get(&ec).copied().unwrap_or(0); + let same = edges.get(k).is_none_or(|e| e.1); + lp.extend(["0".into(), elist.to_string(), (k + 1).to_string(), if d == same { "1" } else { "0" }.into(), "0".into()]); + } + lids.push(w.add(508, 1, lp).to_string()); + } + let mut fp = vec![sd.to_string(), lids.len().to_string(), "1".into()]; + fp.extend(lids); + let fd = w.add(510, 1, fp); + if let Some(c) = colors.get(&fid) { + let cd = w.color(*c); + if let Some(e) = w.get_mut(fd) { + e.color = -cd; + } + } + sp.extend([fd.to_string(), if sense { "1" } else { "0" }.into()]); + } + Ok(w.add(514, 1, sp)) +} + +/// IGES text (5.3, millimetres) for bodies. +pub fn iges_export_bodies(bodies: &[ExportBody], header: &StepHeader) -> Result { + let step = crate::step_export_bodies(bodies, header)?; + let ex = p21::parse(&step).map_err(|e| KernelError::Failed(format!("IGES export: {e}")))?; + let s = S { ex: &ex }; + let colors = colours(&ex); + let mut w = W::default(); + let mut solids: Vec = + ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect(); + solids.sort(); + let mut max_coord: f64 = 1.0; + for p in ex.entities.values().filter(|e| e.name() == "CARTESIAN_POINT") { + if let Some(c) = p.params().get(1).and_then(Param::as_list) { + for x in c.iter().filter_map(Param::as_f64) { + max_coord = max_coord.max(x.abs()); + } + } + } + for id in solids { + let e = s.e(id)?; + let name = e.params().first().and_then(Param::as_str).unwrap_or("").to_string(); + let outer = shell(&mut w, &s, s.r(e.params().get(1))?, &colors)?; + let mut p = vec![outer.to_string(), "1".into()]; + let voids: Vec = if e.name() == "BREP_WITH_VOIDS" { + s.list(e.params().get(2))?.iter().map(|v| shell(&mut w, &s, s.r(Some(v))?, &colors)).collect::>()? + } else { + Vec::new() + }; + p.push(voids.len().to_string()); + for v in voids { + p.extend([v.to_string(), "1".into()]); + } + // The body's name as a name property (406 form 15). + let np = w.add(406, 15, vec!["1".into(), holl(&name)]); + p.extend(["0".into(), "1".into(), np.to_string()]); + let d = w.add(186, 0, p); + let c = colors.get(&id).map(|c| w.color(*c)); + if let Some(ent) = w.get_mut(d) { + ent.dependent = false; + ent.label = name.chars().filter(|c| c.is_ascii_alphanumeric()).take(8).collect(); + if let Some(c) = c { + ent.color = -c; + } + } + } + if w.ents.is_empty() { + return Err(KernelError::Invalid("nothing to export".into())); + } + Ok(layout(&w, header, max_coord)) +} + +/// The file: start, global, directory and parameter sections and terminator. +fn layout(w: &W, header: &StepHeader, max_coord: f64) -> String { + let mut out = String::new(); + let line = |out: &mut String, data: &str, sect: char, seq: usize| { + let _ = writeln!(out, "{data:<72}{sect}{seq:>7}"); + }; + line(&mut out, "SolveCraft IGES export", 'S', 1); + let stamp = { + let t = header.time_stamp.replace(['-', ':', 'T'], ""); + let t: String = t.chars().filter(char::is_ascii_digit).collect(); + if t.len() >= 14 { format!("{}.{}", &t[..8], &t[8..14]) } else { "20000101.000000".to_string() } + }; + let g = [ + "1H,".to_string(), + "1H;".to_string(), + holl("SolveCraft"), + holl(&header.file_name), + holl("SolveCraft"), + holl("SolveCraft IGES 5.3"), + "32".into(), + "38".into(), + "6".into(), + "308".into(), + "15".into(), + holl(&header.file_name), + real(1.0), + "2".into(), + holl("MM"), + "1".into(), + real(1.0), + holl(&stamp), + real(1e-6), + real(max_coord), + holl(&header.author), + holl(&header.organization), + "11".into(), + "0".into(), + holl(&stamp), + ]; + let mut gseq = 0; + for chunk in wrap(&g.iter().map(String::as_str).collect::>(), ',', ';', 72) { + gseq += 1; + line(&mut out, &chunk, 'G', gseq); + } + // Parameter lines per entity. + let mut pdata: Vec> = Vec::with_capacity(w.ents.len()); + for e in &w.ents { + let mut fields = vec![e.typ.to_string()]; + fields.extend(e.params.iter().cloned()); + pdata.push(wrap(&fields.iter().map(String::as_str).collect::>(), ',', ';', 64)); + } + let mut pstart = Vec::with_capacity(w.ents.len()); + let mut next = 1; + for p in &pdata { + pstart.push(next); + next += p.len(); + } + let f8 = |x: i64| format!("{x:>8}"); + for (k, e) in w.ents.iter().enumerate() { + let status = if e.dependent { "00010000" } else { "00000000" }; + let a = format!("{}{}{}{}{}{}{}{}{status}", f8(e.typ), f8(pstart[k] as i64), f8(0), f8(0), f8(0), f8(0), f8(e.xform), f8(0)); + let b = format!("{}{}{}{}{}{}{}{:>8}{}", f8(e.typ), f8(0), f8(e.color), f8(pdata[k].len() as i64), f8(e.form), f8(0), f8(0), e.label, f8(0)); + line(&mut out, &a, 'D', 2 * k + 1); + line(&mut out, &b, 'D', 2 * k + 2); + } + let mut pseq = 0; + for (k, p) in pdata.iter().enumerate() { + for l in p { + pseq += 1; + line(&mut out, &format!("{l:<64}{:>8}", 2 * k + 1), 'P', pseq); + } + } + line(&mut out, &format!("S{:>7}G{:>7}D{:>7}P{:>7}", 1, gseq, 2 * w.ents.len(), pseq), 'T', 1); + out +} + +/// Fields joined by `delim`, ended by `end`, broken into lines of at most `width` columns +/// between fields (a longer field is split, as strings may be). +fn wrap(fields: &[&str], delim: char, end: char, width: usize) -> Vec { + let mut lines = Vec::new(); + let mut cur = String::new(); + for (i, f) in fields.iter().enumerate() { + let sep = if i + 1 == fields.len() { end } else { delim }; + let item = format!("{f}{sep}"); + if cur.len() + item.len() > width && !cur.is_empty() { + lines.push(std::mem::take(&mut cur)); + } + if item.len() > width { + let chars: Vec = item.chars().collect(); + for c in chars.chunks(width) { + let piece: String = c.iter().collect(); + if piece.len() == width { + lines.push(piece); + } else { + cur = piece; + } + } + continue; + } + cur.push_str(&item); + } + if !cur.is_empty() { + lines.push(cur); + } + lines +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index cfecb0c8..010ec3f0 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -20,6 +20,8 @@ mod curveblend; mod freeform; mod heal; mod helix; +mod iges_in; +mod iges_out; mod loopblend; mod measure; mod meshbody; @@ -39,6 +41,8 @@ pub use blend::{chamfer, fillet}; pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint}; pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; pub use helix::{loft_to_point, sweep_helix}; +pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; +pub use iges_out::iges_export_bodies; pub use measure::{BodyMeasure, measure}; pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix}; diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs index 65d1ad0d..b275adb6 100644 --- a/crates/kernel/src/meshbody.rs +++ b/crates/kernel/src/meshbody.rs @@ -95,6 +95,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result { if out.triangles.is_empty() { return Err(KernelError::Invalid("mesh has no triangles".into())); } + unify_winding(&mut out); if out.volume6() < 0.0 && is_closed(&out) { for t in &mut out.triangles { t.swap(1, 2); @@ -103,6 +104,48 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result { Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None }) } +/// Wind every connected piece one way: a triangle that runs a side in the same direction as +/// its neighbour is turned over (files with stray inverted facets). Sides shared by more than +/// two triangles are left alone. +fn unify_winding(m: &mut TriMesh) { + let mut sides: HashMap<(u32, u32), Vec> = HashMap::new(); + for (i, t) in m.triangles.iter().enumerate() { + for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] { + sides.entry((a.min(b), a.max(b))).or_default().push(i); + } + } + let runs = |t: &[u32; 3], a: u32, b: u32| [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])].contains(&(a, b)); + let mut done = vec![false; m.triangles.len()]; + for start in 0..m.triangles.len() { + if done[start] { + continue; + } + done[start] = true; + let mut stack = vec![start]; + while let Some(i) = stack.pop() { + let Some(t) = m.triangles.get(i).copied() else { continue }; + for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] { + let Some(list) = sides.get(&(a.min(b), a.max(b))) else { continue }; + if list.len() != 2 { + continue; + } + for &j in list { + if j == i || done[j] { + continue; + } + done[j] = true; + if let Some(u) = m.triangles.get_mut(j) + && runs(u, a, b) + { + u.swap(1, 2); + } + stack.push(j); + } + } + } + } +} + /// Every triangle side is shared by exactly two triangles in opposite directions. fn is_closed(m: &TriMesh) -> bool { let mut count: HashMap<(u32, u32), i32> = HashMap::new(); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 9e51141c..dc243127 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -706,7 +706,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { }, "ui.export" => { let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone()); - if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.step"), &["step", "stp", "stl", "3mf", "obj"])) { + if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"])) + { let _ = app.run("ExportCommand", json!({ "path": path, "bodies": bodies })); } } diff --git a/docs/step-import.md b/docs/step-import.md index 2a232d23..50c9245e 100644 --- a/docs/step-import.md +++ b/docs/step-import.md @@ -8,6 +8,10 @@ SolveCraft reads STEP (ISO 10303-21; AP203, AP214 and AP242 geometry) with its o Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature for the components milestone. Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges. +## IGES + +IGES 5.3 files (`.igs`/`.iges`) open, insert and drop the same way: `crates/kernel/src/iges_in.rs` (written from the published specification) restates the file as STEP and the STEP reader reads it, so the feature holds STEP text. Read: manifold solid B-reps (186) with their shells, faces, loops, edge and vertex lists; trimmed (144) and bounded (143) surfaces, and bare B-spline patches, sewn into a solid by their shared edges (an open body when they do not close); lines, circular arcs, ellipses, composite curves, rational B-spline curves; planes (108, 190), cylinders, cones, spheres, tori, rational B-spline surfaces, surfaces of revolution and tabulated cylinders; transformation matrices; units; colours (314 and the predefined numbers); body names (name properties, else labels). Not read yet: ruled and offset surfaces, curves on surfaces given only in parameter space. Export (`ExportCommand` to `.igs`) writes each body as a manifold solid B-rep on the same surfaces a STEP export uses, with its name and colour. + ## Fusion corpus Generated by `cargo xtask step-corpus`. Each case is a part exported from Fusion as STEP (AP214; kept locally in `plan/fusion/oracle/`, never committed). SolveCraft opens `part.step` exactly as File → Open does (an Import base feature) and compares with Fusion's own measurements: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index added81c..d74070e1 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -249,6 +249,16 @@ solid's name, else the product's); surface colours; assemblies (next-assembly-us transformations, and mapped items) — the product tree is kept in the feature for the components milestone. Files are \ untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a \ warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges.\n\n\ +## IGES\n\n\ +IGES 5.3 files (`.igs`/`.iges`) open, insert and drop the same way: `crates/kernel/src/iges_in.rs` (written from the \ +published specification) restates the file as STEP and the STEP reader reads it, so the feature holds STEP text. Read: \ +manifold solid B-reps (186) with their shells, faces, loops, edge and vertex lists; trimmed (144) and bounded (143) \ +surfaces, and bare B-spline patches, sewn into a solid by their shared edges (an open body when they do not close); \ +lines, circular arcs, ellipses, composite curves, rational B-spline curves; planes (108, 190), cylinders, cones, \ +spheres, tori, rational B-spline surfaces, surfaces of revolution and tabulated cylinders; transformation matrices; \ +units; colours (314 and the predefined numbers); body names (name properties, else labels). Not read yet: ruled and \ +offset surfaces, curves on surfaces given only in parameter space. Export (`ExportCommand` to `.igs`) writes each body as \ +a manifold solid B-rep on the same surfaces a STEP export uses, with its name and colour.\n\n\ ## Fusion corpus\n\nGenerated by `cargo xtask step-corpus`. Each case is a part exported from Fusion as STEP (AP214; kept locally in `plan/fusion/oracle/`, never committed). SolveCraft opens `part.step` exactly as File → Open does (an Import base feature) and compares with Fusion's own measurements: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file.\n\n**{pass} / {} files pass.**\n\n| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes |\n|---|---|---|---|---|---|---|---|\n", list.len() );