Skip to main content

teamcraft/mesh/
step.rs

1//! STEP (ISO 10303-21: AP203, AP214, AP242) for the 3D preview.
2//!
3//! Two sources of triangles, tried in this order:
4//!
5//! 1. **B-rep** (`CLOSED_SHELL` / `OPEN_SHELL` with `ADVANCED_FACE`s),
6//!    tessellated with the `truck` kernel. The file is first scanned by the
7//!    own reader in `p21.rs`; only the geometry and topology entities the
8//!    kernel knows are handed to it, re-written in the compact form its
9//!    parser accepts (every other instance becomes a stub, so references
10//!    still resolve). PMI, presentation and product data – where real
11//!    AP242 files use syntax the kernel's parser rejects – never reach it.
12//!    Each shell is tessellated on its own; when the kernel panics on a
13//!    shell, its faces are tessellated one by one, so one bad face costs
14//!    that face, not the model.
15//! 2. **Tessellated geometry** of AP242 (`TRIANGULATED_FACE`,
16//!    `COMPLEX_TRIANGULATED_FACE`, `…_SURFACE_SET`, see `tessellated.rs`)
17//!    when there is no B-rep or it is incomplete.
18//!
19//! A preview that shows fewer faces than the file has is marked
20//! ([`Mesh::faces`]); the viewer says "partial preview: N of M faces".
21//! Units and assembly placements are not applied (the kernel reads every
22//! shell in its own coordinates and the file's length unit).
23
24use std::collections::HashMap;
25use std::io::Write;
26use std::panic::{catch_unwind, AssertUnwindSafe};
27
28use anyhow::{anyhow, bail, Context, Result};
29
30use super::p21::{self, Instance};
31use super::{check_triangles, fan, tessellated, Mesh, MAX_TRIANGLES};
32
33/// Larger STEP files are not previewed (the kernel needs several times the
34/// file size in memory and would not finish within the time limit).
35pub const MAX_STEP_BYTES: usize = 256 * 1024 * 1024;
36
37/// Simple entities the B-rep reader of `truck-stepio` understands.
38const BREP_ENTITIES: &[&str] = &[
39    "CARTESIAN_POINT",
40    "DIRECTION",
41    "VECTOR",
42    "PLACEMENT",
43    "AXIS1_PLACEMENT",
44    "AXIS2_PLACEMENT_2D",
45    "AXIS2_PLACEMENT_3D",
46    "LINE",
47    "POLYLINE",
48    "B_SPLINE_CURVE_WITH_KNOTS",
49    "BEZIER_CURVE",
50    "QUASI_UNIFORM_CURVE",
51    "UNIFORM_CURVE",
52    "CIRCLE",
53    "ELLIPSE",
54    "HYPERBOLA",
55    "PARABOLA",
56    "PCURVE",
57    "SURFACE_CURVE",
58    "SEAM_CURVE",
59    "PLANE",
60    "SPHERICAL_SURFACE",
61    "CYLINDRICAL_SURFACE",
62    "TOROIDAL_SURFACE",
63    "CONICAL_SURFACE",
64    "B_SPLINE_SURFACE_WITH_KNOTS",
65    "UNIFORM_SURFACE",
66    "QUASI_UNIFORM_SURFACE",
67    "BEZIER_SURFACE",
68    "SURFACE_OF_LINEAR_EXTRUSION",
69    "SURFACE_OF_REVOLUTION",
70    "VERTEX_POINT",
71    "EDGE_CURVE",
72    "ORIENTED_EDGE",
73    "EDGE_LOOP",
74    "FACE_BOUND",
75    "FACE_OUTER_BOUND",
76    "FACE_SURFACE",
77    "ADVANCED_FACE",
78    "ORIENTED_FACE",
79    "OPEN_SHELL",
80    "CLOSED_SHELL",
81    "ORIENTED_OPEN_SHELL",
82    "ORIENTED_CLOSED_SHELL",
83    "DEFINITIONAL_REPRESENTATION",
84];
85
86/// Records that make a complex instance a (rational) B-spline the kernel reads.
87const BREP_COMPLEX: [&str; 2] = ["B_SPLINE_CURVE", "B_SPLINE_SURFACE"];
88
89const SHELLS: [&str; 4] = [
90    "OPEN_SHELL",
91    "CLOSED_SHELL",
92    "ORIENTED_OPEN_SHELL",
93    "ORIENTED_CLOSED_SHELL",
94];
95
96/// The minimal header the kernel's parser expects.
97const HEADER: &[u8] = b"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n\
98FILE_NAME('','',(''),(''),'','','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\n";
99
100/// Triangles of one source and how many of the file's faces they show.
101struct Found {
102    mesh: Mesh,
103    shown: usize,
104    total: usize,
105}
106
107impl Found {
108    fn into_mesh(mut self) -> Mesh {
109        if self.shown < self.total {
110            self.mesh.faces = Some((self.shown as u32, self.total as u32));
111        }
112        self.mesh
113    }
114}
115
116pub fn read(data: &[u8]) -> Result<Mesh> {
117    if data.len() > MAX_STEP_BYTES {
118        bail!(
119            "The STEP file is too large to preview ({} MB, at most {} MB) – download it instead.",
120            data.len() / (1024 * 1024),
121            MAX_STEP_BYTES / (1024 * 1024)
122        );
123    }
124    let start = data.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(data);
125    let start = start.trim_ascii_start();
126    if !start.starts_with(b"ISO-10303-21") {
127        bail!("Not a STEP file (ISO-10303-21 header missing).");
128    }
129    let insts = p21::scan(data)?;
130    let by_id: HashMap<u64, usize> = insts.iter().enumerate().map(|(i, x)| (x.id, i)).collect();
131
132    let mut partial = None;
133    let mut brep_error = None;
134    if insts.iter().any(|i| SHELLS.iter().any(|s| i.is(s))) {
135        match brep(data, &insts) {
136            Ok(found) if found.shown >= found.total => return Ok(found.into_mesh()),
137            Ok(found) => partial = Some(found),
138            Err(e) => brep_error = Some(e),
139        }
140    }
141    let faces = tessellated::shape_faces(data, &insts, &by_id)?;
142    if !faces.is_empty() {
143        let (mesh, shown) = match tessellated::read(data, &insts, &by_id, &faces) {
144            Ok(read) => read,
145            // too many triangles: the partial B-rep is still worth showing
146            Err(_) if partial.is_some() => (Mesh::default(), 0),
147            Err(e) => return Err(e),
148        };
149        let found = Found {
150            mesh,
151            shown,
152            total: faces.len(),
153        };
154        let better = partial
155            .as_ref()
156            .is_none_or(|b| found.shown * b.total >= b.shown * found.total);
157        if !found.mesh.indices.is_empty() && better {
158            return Ok(found.into_mesh());
159        }
160    }
161    if let Some(found) = partial {
162        return Ok(found.into_mesh());
163    }
164    Err(brep_error.unwrap_or_else(|| {
165        anyhow!("The STEP file contains no solids, surfaces or tessellated geometry the preview can show.")
166    }))
167}
168
169/// Whether the B-rep reader gets the instance.
170fn for_brep(data: &[u8], inst: &Instance<'_>) -> Result<bool> {
171    if !inst.complex {
172        return Ok(BREP_ENTITIES.iter().any(|e| inst.is(e)));
173    }
174    Ok(p21::record_names(data, inst)?.iter().any(|n| {
175        BREP_COMPLEX
176            .iter()
177            .any(|e| n.eq_ignore_ascii_case(e.as_bytes()))
178    }))
179}
180
181/// The B-rep geometry as the kernel's parser reads it.
182fn brep_text(data: &[u8], insts: &[Instance<'_>]) -> Result<String> {
183    let mut text = Vec::with_capacity(data.len() / 2 + HEADER.len() + 32);
184    text.extend_from_slice(HEADER);
185    for inst in insts {
186        if for_brep(data, inst)? {
187            p21::write_normalized(data, inst, &mut text)?;
188        } else {
189            writeln!(text, "#{}=X();", inst.id)?;
190        }
191    }
192    text.extend_from_slice(b"ENDSEC;\nEND-ISO-10303-21;\n");
193    String::from_utf8(text).context("Internal error: STEP text not ASCII")
194}
195
196fn brep(data: &[u8], insts: &[Instance<'_>]) -> Result<Found> {
197    use truck_stepio::r#in::Table;
198
199    let text = brep_text(data, insts)?;
200    let table = catch_unwind(|| Table::from_step(&text))
201        .ok()
202        .flatten()
203        .context("The STEP file could not be parsed by the B-rep reader.")?;
204    drop(text);
205    let mut shells: Vec<_> = table.shell.iter().collect();
206    shells.sort_by_key(|(id, _)| **id);
207    let mut out = Mesh::default();
208    let (mut shown, mut total) = (0usize, 0usize);
209    for (_, holder) in shells {
210        let faces = holder.cfs_faces.len();
211        total += faces;
212        // the kernel may panic on geometry it does not support
213        match catch_unwind(AssertUnwindSafe(|| shell_mesh(&table, holder))) {
214            Ok(Ok((part, n))) if !part.indices.is_empty() => {
215                shown += n.min(faces);
216                out.append(part);
217                check_triangles(out.triangle_count())?;
218            }
219            Ok(Err(e)) if e.is::<TooManyTriangles>() => return Err(e),
220            _ => {}
221        }
222    }
223    if out.indices.is_empty() {
224        bail!("The B-rep reader could not tessellate the {total} face(s) of this STEP file (unsupported surface types?).");
225    }
226    Ok(Found {
227        mesh: out,
228        shown,
229        total,
230    })
231}
232
233/// One shell as a mesh, and how many of its faces it shows.
234fn shell_mesh(
235    table: &truck_stepio::r#in::Table,
236    holder: &truck_stepio::r#in::ShellHolder,
237) -> Result<(Mesh, usize)> {
238    use truck_meshalgo::prelude::*;
239
240    // the whole shell at once (the kernel tessellates its faces in parallel)
241    let whole = catch_unwind(AssertUnwindSafe(|| -> Result<Vec<PolygonMesh>> {
242        let shell = table
243            .to_compressed_shell(holder)
244            .map_err(|e| anyhow!("{e}"))?;
245        let tolerance = tolerance([shell.vertices.as_slice()])?;
246        let meshed = shell.robust_triangulation(tolerance);
247        Ok(meshed
248            .faces
249            .into_iter()
250            .filter_map(|f| oriented(f.surface, f.orientation))
251            .collect())
252    }));
253    let polygons = match whole {
254        Ok(Ok(polygons)) => polygons,
255        _ => {
256            // the kernel panicked on one of the faces: face by face, so that
257            // only the faces it cannot handle are missing
258            #[cfg(test)]
259            tests::FALLBACKS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
260            let mut single = holder.clone();
261            let faces = std::mem::take(&mut single.cfs_faces);
262            let shells: Vec<_> = faces
263                .into_iter()
264                .filter_map(|face| {
265                    single.cfs_faces = vec![face];
266                    catch_unwind(AssertUnwindSafe(|| table.to_compressed_shell(&single)))
267                        .ok()?
268                        .ok()
269                })
270                .collect();
271            let tolerance = tolerance(shells.iter().map(|s| s.vertices.as_slice()))?;
272            let mut polygons = Vec::new();
273            for shell in &shells {
274                if let Ok(meshed) =
275                    catch_unwind(AssertUnwindSafe(|| shell.robust_triangulation(tolerance)))
276                {
277                    polygons.extend(
278                        meshed
279                            .faces
280                            .into_iter()
281                            .filter_map(|f| oriented(f.surface, f.orientation)),
282                    );
283                }
284            }
285            polygons
286        }
287    };
288    let shown = polygons.len();
289    let triangles = polygons
290        .iter()
291        .flat_map(|p| p.face_iter())
292        .map(|f| f.len().saturating_sub(2))
293        .sum();
294    if triangles > MAX_TRIANGLES {
295        return Err(TooManyTriangles(triangles).into());
296    }
297    let mut polygon = PolygonMesh::default();
298    for p in polygons {
299        polygon.merge(p);
300    }
301    polygon
302        .put_together_same_attrs(TOLERANCE * 50.0)
303        .remove_degenerate_faces()
304        .remove_unused_attrs();
305    let mut part = Mesh {
306        parts: 1,
307        ..Mesh::default()
308    };
309    for p in polygon.positions() {
310        part.vertex([p[0], p[1], p[2]]);
311    }
312    let n = part.vertex_count();
313    let mut corners = Vec::new();
314    for face in polygon.face_iter() {
315        corners.clear();
316        for v in face {
317            if v.pos >= n {
318                bail!("Internal error: tessellation index out of range.");
319            }
320            corners.push(v.pos as u32);
321        }
322        fan(&mut part, &corners);
323    }
324    Ok((part, shown))
325}
326
327/// Tessellation tolerance: 0.2 % of the size of the shell.
328fn tolerance<'a>(
329    vertices: impl IntoIterator<Item = &'a [truck_meshalgo::prelude::Point3]>,
330) -> Result<f64> {
331    let mut min = [f64::MAX; 3];
332    let mut max = [f64::MIN; 3];
333    let mut any = false;
334    for v in vertices.into_iter().flatten() {
335        any = true;
336        for ((lo, hi), c) in min.iter_mut().zip(max.iter_mut()).zip([v.x, v.y, v.z]) {
337            *lo = lo.min(c);
338            *hi = hi.max(c);
339        }
340    }
341    let diameter = if any {
342        ((max[0] - min[0]).powi(2) + (max[1] - min[1]).powi(2) + (max[2] - min[2]).powi(2)).sqrt()
343    } else {
344        1.0
345    };
346    let tolerance = (diameter * 0.002).max(truck_meshalgo::prelude::TOLERANCE * 10.0);
347    if !tolerance.is_finite() {
348        bail!("invalid coordinates");
349    }
350    Ok(tolerance)
351}
352
353/// The triangles of one tessellated face in its orientation – `None` when
354/// the kernel gave up on the face or left only degenerate triangles.
355fn oriented(
356    surface: Option<truck_meshalgo::prelude::PolygonMesh>,
357    orientation: bool,
358) -> Option<truck_meshalgo::prelude::PolygonMesh> {
359    use truck_meshalgo::prelude::*;
360    let p = surface?;
361    let pos = p.positions();
362    let area = p.face_iter().any(|f| {
363        (2..f.len()).any(|k| {
364            let at = |i: usize| f.get(i).and_then(|v| <[Point3]>::get(pos, v.pos)).copied();
365            let (Some(a), Some(b), Some(c)) = (at(0), at(k - 1), at(k)) else {
366                return false;
367            };
368            let n = (b - a).cross(c - a).magnitude2();
369            n.is_finite() && n > 0.0
370        })
371    });
372    area.then(|| if orientation { p } else { p.inverse() })
373}
374
375/// A shell beyond [`MAX_TRIANGLES`] – the whole preview fails, not the shell.
376#[derive(Debug)]
377struct TooManyTriangles(usize);
378
379impl std::fmt::Display for TooManyTriangles {
380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        write!(
382            f,
383            "The model has {} triangles – more than the preview shows ({MAX_TRIANGLES}).",
384            self.0
385        )
386    }
387}
388
389impl std::error::Error for TooManyTriangles {}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    const CUBE: &[u8] = include_bytes!("../../tests/fixtures/cube.step");
396
397    /// How often a shell was tessellated face by face.
398    pub(super) static FALLBACKS: std::sync::atomic::AtomicUsize =
399        std::sync::atomic::AtomicUsize::new(0);
400
401    fn fallbacks() -> usize {
402        FALLBACKS.load(std::sync::atomic::Ordering::Relaxed)
403    }
404
405    #[test]
406    fn noise_around_the_brep_does_not_matter() {
407        // AP242 style: PMI with escaped quotes, empty lists, binary values,
408        // comments and complex instances over several lines – none of it
409        // is understood by the kernel's parser, all of it is skipped.
410        let text = String::from_utf8_lossy(CUBE).replace(
411            "DATA;\n",
412            "DATA;\n/* exported by a CAD program */\n\
413             #9001=DRAUGHTING_MODEL('it''s',(),#9002);\n\
414             #9002=(\n  GEOMETRIC_REPRESENTATION_CONTEXT(3)\n  GLOBAL_UNIT_ASSIGNED_CONTEXT((#9003))\n  REPRESENTATION_CONTEXT('','')\n);\n\
415             #9003=(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.));\n\
416             #9004=PROPERTY('Geh\u{e4}use',\"0F\",@1);\n",
417        );
418        let mesh = read(text.as_bytes()).unwrap();
419        assert_eq!(mesh.faces, None, "complete");
420        assert!(mesh.triangle_count() >= 12);
421        let b = mesh.bounds();
422        assert!((b[3] - b[0] - 10.0).abs() < 1e-3, "{b:?}");
423    }
424
425    #[test]
426    fn a_bad_face_costs_that_face_not_the_model() {
427        // the bottom face of the cube gets a surface the kernel cannot
428        // tessellate: the other five faces are shown
429        let cube = String::from_utf8_lossy(CUBE).into_owned();
430        let plane = "#108=PLANE('',#107);";
431        let knots = |degree: u8, mult: &str, knots: &str| {
432            format!("#108=B_SPLINE_SURFACE_WITH_KNOTS('',{degree},{degree},((#1,#2),(#3,#4)),.UNSPECIFIED.,.F.,.F.,.F.,{mult},{mult},{knots},{knots},.UNSPECIFIED.);")
433        };
434        for (from, to) in [
435            (plane, "#108=UNKNOWN_SURFACE('',#107);".to_string()),
436            (plane, "#108=CYLINDRICAL_SURFACE('',#107,0.);".into()),
437            (plane, "#108=SPHERICAL_SURFACE('',#107,-5.);".into()),
438            (plane, "#108=CONICAL_SURFACE('',#107,0.,0.);".into()),
439            (
440                plane,
441                "#108=SURFACE_OF_LINEAR_EXTRUSION('',#41,#31);".into(),
442            ),
443            (plane, knots(3, "(2,2)", "(0.,1.)")),
444            (plane, knots(1, "(1,1)", "(0.,0.)")),
445            (
446                "#107=AXIS2_PLACEMENT_3D('',#1,#26,#21);",
447                "#107=AXIS2_PLACEMENT_3D('',#1,#999,#21);\n#999=DIRECTION('',(0.,0.,0.));".into(),
448            ),
449            (
450                "#109=ADVANCED_FACE('bottom',(#106),#108,.T.);",
451                "#109=ADVANCED_FACE('bottom',(),#108,.T.);".into(),
452            ),
453        ] {
454            let mesh =
455                read(cube.replace(from, &to).as_bytes()).unwrap_or_else(|e| panic!("{to}: {e}"));
456            assert_eq!(mesh.faces, Some((5, 6)), "{to}");
457            assert_eq!(mesh.triangle_count(), 10, "{to}");
458            mesh.check().unwrap();
459        }
460        // unsorted knots make the kernel panic while it reads the shell:
461        // the faces are then read and tessellated one by one
462        let before = fallbacks();
463        let mesh = read(
464            cube.replace(plane, &knots(1, "(2,2)", "(1.,0.)"))
465                .as_bytes(),
466        )
467        .unwrap();
468        assert!(fallbacks() > before, "face by face");
469        assert_eq!(mesh.faces, Some((5, 6)));
470        assert_eq!(mesh.triangle_count(), 10);
471    }
472
473    #[test]
474    fn tessellated_geometry_without_brep() {
475        let text = b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n\
476            #1=COORDINATES_LIST('',4,((0.,0.,0.),(1.,0.,0.),(1.,1.,0.),(0.,1.,0.)));\n\
477            #2=TRIANGULATED_FACE('',#1,4,(),$,(),((1,2,3),(1,3,4)));\n\
478            #3=TESSELLATED_SHELL('',(#2),$);\n\
479            ENDSEC;\nEND-ISO-10303-21;\n";
480        let mesh = read(text).unwrap();
481        assert_eq!(mesh.triangle_count(), 2);
482        assert_eq!(mesh.faces, None);
483    }
484
485    #[test]
486    fn hostile_files_are_errors_not_panics() {
487        let cube = String::from_utf8_lossy(CUBE).into_owned();
488        let inputs: Vec<Vec<u8>> = vec![
489            b"not a step file".to_vec(),
490            b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n".to_vec(),
491            // a huge integer (the kernel's parser would panic on it)
492            cube.replacen("(0.,0.,0.)", "(99999999999999999999,0.,0.)", 1).into_bytes(),
493            // deep nesting (the kernel's parser would overflow the stack)
494            cube.replacen(
495                "(0.,0.,0.)",
496                &format!("{}0.{}", "(".repeat(10_000), ")".repeat(10_000)),
497                1,
498            )
499            .into_bytes(),
500            // references to nothing, wrong types, cycles
501            cube.replace("VERTEX_POINT('',#", "VERTEX_POINT('',#7").into_bytes(),
502            cube.replace("#1=CARTESIAN_POINT", "#1=EDGE_LOOP").into_bytes(),
503            // truncated in the middle
504            CUBE[..CUBE.len() / 2].to_vec(),
505            // a shell with no faces, a face list that is no list
506            b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n#1=CLOSED_SHELL('',());\n#2=CLOSED_SHELL('',#1);\nENDSEC;\nEND-ISO-10303-21;\n".to_vec(),
507            // coordinates beyond any tolerance, broken edges and loops
508            cube.replacen("(10.,0.,0.)", "(1.E308,1.E308,-1.E308)", 1).into_bytes(),
509            cube.replace("#41=LINE('',#1,#31);", "#41=CIRCLE('',#107,0.);").into_bytes(),
510            cube.replace("#41=LINE('',#1,#31);", "#41=ELLIPSE('',#107,0.,0.);").into_bytes(),
511            cube.replace("#31=VECTOR('',#21,1.);", "#31=VECTOR('',#21,0.);").into_bytes(),
512            cube.replace("(#101,#102,#103,#104)", "(#101,#101,#113,#104)").into_bytes(),
513            cube.replace("(#101,#102,#103,#104)", "(#101)").into_bytes(),
514            cube.replace("('bottom',(#106)", "('bottom',(#106,#106,#116)").into_bytes(),
515            cube.replace("#1=CARTESIAN_POINT('',(0.,0.,0.))", "#1=CARTESIAN_POINT('',(0.,0.))").into_bytes(),
516        ];
517        for input in inputs {
518            let result = std::panic::catch_unwind(|| read(&input));
519            assert!(
520                result.is_ok(),
521                "panicked: {}",
522                String::from_utf8_lossy(&input[..input.len().min(200)])
523            );
524            if let Ok(Ok(mesh)) = result {
525                mesh.check().unwrap();
526            }
527        }
528    }
529}