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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
7a2eb059c4
commit
51ffaf9945
13 files changed
+2615
-31
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<f64> = 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() {
|
||||
|
||||
+135
-23
@@ -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<T> = std::result::Result<T, IoError>;
|
||||
/// 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<StepFeature> {
|
||||
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<StepFeature> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<MeshFeature> {
|
||||
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<solvecraft_kernel::ExportBody> =
|
||||
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<MeshObject> = sel
|
||||
.iter()
|
||||
@@ -333,39 +357,70 @@ pub fn write_design(doc: &Document) -> Vec<u8> {
|
||||
|
||||
/// Parse a binary or ASCII STL (used by tests and round-trip checks): triangles.
|
||||
pub fn read_stl(bytes: &[u8]) -> Result<Vec<[[f32; 3]; 3]>> {
|
||||
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<f32> = 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<f32> = 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<u32>, normals: [f32; 3]) -> Vec<u8> {
|
||||
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<Vec3> = 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();
|
||||
|
||||
@@ -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<MeshObject>, Vec<String>)> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut verts: Vec<Vec3> = 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<String> = 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<f64> = it.take(3).filter_map(|x| x.parse::<f64>().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::<Vec<_>>().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<Vec<u32>> = 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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<String> = 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::<Vec<_>>()
|
||||
.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<char> = 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::<Vec<_>>().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);
|
||||
}
|
||||
@@ -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<V3> {
|
||||
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<String>,
|
||||
xform: i64,
|
||||
color: i64,
|
||||
label: String,
|
||||
dependent: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct W {
|
||||
ents: Vec<Ent>,
|
||||
colors: HashMap<[u32; 3], i64>,
|
||||
}
|
||||
|
||||
impl W {
|
||||
fn add(&mut self, typ: i64, form: i64, params: Vec<String>) -> 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<String> = (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<u64> {
|
||||
p.and_then(Param::as_ref_id).ok_or_else(|| KernelError::Failed("IGES export: bad reference".into()))
|
||||
}
|
||||
fn f(&self, p: Option<&Param>) -> Result<f64> {
|
||||
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<V3> {
|
||||
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<V3> {
|
||||
let d = self.point(id)?;
|
||||
unit(d).ok_or_else(|| KernelError::Failed("IGES export: zero direction".into()))
|
||||
}
|
||||
fn vertex(&self, id: u64) -> Result<V3> {
|
||||
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<usize>,
|
||||
pts: Vec<Vec<V3>>,
|
||||
weights: Option<Vec<Vec<f64>>>,
|
||||
knots: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
fn expand(mults: &[Param], knots: &[Param]) -> Option<Vec<f64>> {
|
||||
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<Spline> {
|
||||
let bad = || KernelError::Failed("IGES export: unreadable B-spline".into());
|
||||
let (base, kn): (Vec<Param>, Vec<Param>) = 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<usize> = (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::<Result<_>>()?;
|
||||
let ctrl = base.get(1 + deg_n).and_then(Param::as_list).ok_or_else(bad)?;
|
||||
let pts: Vec<Vec<V3>> = 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::<Result<Vec<_>>>())
|
||||
.collect::<Result<_>>()?
|
||||
} else {
|
||||
vec![ctrl.iter().map(|c| s.point(c.as_ref_id().ok_or_else(bad)?)).collect::<Result<Vec<_>>>()?]
|
||||
};
|
||||
let knots: Vec<Vec<f64>> = 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::<Result<Vec<_>>>())
|
||||
.collect::<Result<_>>()?
|
||||
} else {
|
||||
vec![w.iter().map(|x| x.as_f64().ok_or_else(bad)).collect::<Result<Vec<_>>>()?]
|
||||
})
|
||||
}
|
||||
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<i64> {
|
||||
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<V3> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
let e = s.e(id)?;
|
||||
let p = e.params();
|
||||
let analytic = |w: &mut W, typ: i64, place: u64, extra: &[f64]| -> Result<i64> {
|
||||
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<f64>, 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<u64, [f32; 3]> {
|
||||
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<u64> =
|
||||
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<u64, [f32; 3]>) -> Result<i64> {
|
||||
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<u64, usize> = HashMap::new();
|
||||
let mut verts: Vec<V3> = Vec::new();
|
||||
let mut eidx: HashMap<u64, usize> = 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<(u64, bool)>> = 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<usize> {
|
||||
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<String> {
|
||||
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<u64> =
|
||||
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<i64> = 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::<Result<_>>()?
|
||||
} 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::<Vec<_>>(), ',', ';', 72) {
|
||||
gseq += 1;
|
||||
line(&mut out, &chunk, 'G', gseq);
|
||||
}
|
||||
// Parameter lines per entity.
|
||||
let mut pdata: Vec<Vec<String>> = 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::<Vec<_>>(), ',', ';', 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<String> {
|
||||
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<char> = 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
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -95,6 +95,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
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<Body> {
|
||||
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<usize>> = 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();
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user