Skip to main content

brep_render/engine_state/
model_io.rs

1use super::*;
2
3// ===========================================================================
4// Import / export (STEP + STL) — the file-interchange lane (the ONE platform
5// exception). These marshal geometry to/from the storage trait as STRINGS: a
6// STEP import appends an IMPORT3D feature carrying the raw ISO-10303-21 text;
7// STEP/STL export collects the CURRENT model's resident solids and serializes
8// them. Appended as one self-contained block so it never interleaves with the
9// history / sketch / picking surfaces above.
10// ===========================================================================
11impl EngineState {
12    /// Import a STEP document into the model: append an `IMPORT3D` feature whose
13    /// `inputParams.stepText` is the raw ISO-10303-21 text (the exact headless
14    /// source the kernel importer reads — no `fileToImport` data-URL marshaling
15    /// needed), mint it a persistent-counter id, roll to it, and rebuild. Returns the
16    /// build report JSON (imported bodies + any per-feature error). A non-STEP
17    /// payload is refused up front so a bad upload never leaves a dead feature.
18    pub fn import_step_feature(&mut self, step_text: &str) -> Result<String, String> {
19        if !step_text.contains("ISO-10303-21") {
20            return Err("not a STEP file (missing the ISO-10303-21 header)".into());
21        }
22        let id = self.next_feature_id(&crate::features::feature_short_name("IMPORT3D"));
23        let feature = serde_json::json!({
24            "type": "IMPORT3D",
25            "inputParams": { "id": id, "stepText": step_text },
26            "persistentData": {},
27        });
28        self.add_feature(&feature.to_string())
29    }
30
31    /// Export the CURRENT model's resident solids to an ISO-10303-21 STEP
32    /// document. Collects the resident handles of the rolled-to model (a warm
33    /// re-run of the same prefix the display scene was built from — see
34    /// [`crate::pipeline::resident_solid_handles`]) and hands them to the kernel's
35    /// [`brep_kernel::export_step_handles`], so the exact NURBS topology is
36    /// serialized (never the display mesh). Errs clearly when the model is empty.
37    pub fn export_step_text(&self) -> Result<String, String> {
38        let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
39            .map_err(|e| format!("export STEP: history request: {e}"))?;
40        let handles: Vec<u32> = crate::pipeline::resident_solid_handles(&request)
41            .into_iter()
42            .map(|(_, handle)| handle)
43            .collect();
44        if handles.is_empty() {
45            return Err("nothing to export: the model has no solids".into());
46        }
47        brep_kernel::export_step_handles(&handles, "Part", "MM", "")
48    }
49
50    /// Export the CURRENT display scene to an ASCII STL string (one `solid` with a
51    /// per-triangle geometric normal for every mesh triangle of every displayed
52    /// solid). STL is a triangle-soup format with no multi-body concept, so all
53    /// solids fold into a single `solid brep … endsolid brep`. String-shaped so it
54    /// crosses the same string `ModelStore` seam the STEP lane uses. Errs when the
55    /// scene has no triangles.
56    pub fn export_stl_text(&self) -> Result<String, String> {
57        let mut out = String::from("solid brep\n");
58        let mut triangles = 0usize;
59        for solid in self.scene.solids() {
60            let positions = &solid.mesh.positions;
61            for tri in solid.mesh.indices.chunks_exact(3) {
62                let a = positions[tri[0] as usize];
63                let b = positions[tri[1] as usize];
64                let c = positions[tri[2] as usize];
65                let normal = triangle_normal(a, b, c);
66                out.push_str(&format!(
67                    "  facet normal {} {} {}\n    outer loop\n",
68                    normal[0], normal[1], normal[2]
69                ));
70                for v in [a, b, c] {
71                    out.push_str(&format!("      vertex {} {} {}\n", v[0], v[1], v[2]));
72                }
73                out.push_str("    endloop\n  endfacet\n");
74                triangles += 1;
75            }
76        }
77        out.push_str("endsolid brep\n");
78        if triangles == 0 {
79            return Err("nothing to export: the scene has no triangles".into());
80        }
81        Ok(out)
82    }
83}
84
85/// Unit (or zero, for a degenerate triangle) geometric normal of triangle
86/// `(a, b, c)` — the per-facet normal an ASCII STL record carries.
87fn triangle_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
88    let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
89    let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
90    let n = [
91        u[1] * v[2] - u[2] * v[1],
92        u[2] * v[0] - u[0] * v[2],
93        u[0] * v[1] - u[1] * v[0],
94    ];
95    let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
96    if len > 0.0 {
97        [n[0] / len, n[1] / len, n[2] / len]
98    } else {
99        [0.0, 0.0, 0.0]
100    }
101}
102
103#[cfg(test)]
104mod io_tests {
105    use super::*;
106
107    /// A full history document for a single P.CU cube of side `size` (volume
108    /// `size^3`), fed to [`EngineState::set_history_json`].
109    fn cube_history(id: &str, size: f64) -> String {
110        serde_json::json!({
111            "expressions": "",
112            "configurator": {},
113            "features": [{
114                "type": "P.CU",
115                "inputParams": {
116                    "id": id,
117                    "sizeX": size, "sizeY": size, "sizeZ": size,
118                    "transform": {
119                        "position": [0.0, 0.0, 0.0],
120                        "rotationEuler": [0.0, 0.0, 0.0],
121                        "scale": [1.0, 1.0, 1.0]
122                    },
123                    "boolean": { "targets": [], "operation": "NONE" }
124                },
125                "persistentData": {}
126            }]
127        })
128        .to_string()
129    }
130
131    /// STEP text for an axis-aligned box `sx × sy × sz`, via the kernel exporter.
132    fn box_step(sx: f64, sy: f64, sz: f64) -> String {
133        let solid =
134            brep_kernel::make_box_brep(brep_kernel::Vec3::new(0.0, 0.0, 0.0), sx, sy, sz)
135                .unwrap();
136        brep_kernel::export_step(&[solid], "part", "MM", "").unwrap()
137    }
138
139    /// Volume of the single solid `import_step` recovers from STEP text.
140    fn imported_volume(step_text: &str) -> f64 {
141        let solids = brep_kernel::import_step(step_text).unwrap();
142        assert_eq!(solids.len(), 1, "STEP round-trips to one solid");
143        brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume
144    }
145
146    /// Importing a STEP box appends an IMPORT3D feature that yields the body in
147    /// the model: the scene shows one solid whose bbox matches the box. A
148    /// non-STEP payload is refused up front, leaving no dead feature behind.
149    #[test]
150    fn import_step_feature_adds_the_body_to_the_model() {
151        let step = box_step(4.0, 3.0, 2.0);
152        let mut state = EngineState::new();
153        state.import_step_feature(&step).unwrap();
154        assert_eq!(state.scene.solids().len(), 1, "one imported body");
155        let size = state.scene.solids()[0].bbox.size();
156        assert!(
157            (size[0] - 4.0).abs() < 1e-4
158                && (size[1] - 3.0).abs() < 1e-4
159                && (size[2] - 2.0).abs() < 1e-4,
160            "imported bbox {size:?} != 4x3x2"
161        );
162
163        let mut empty = EngineState::new();
164        assert!(empty.import_step_feature("not a step file").is_err());
165        assert_eq!(empty.history_len(), 0, "a bad import adds no feature");
166    }
167
168    /// Exporting a box model produces STEP text that `import_step` round-trips to
169    /// one solid of the same volume. An empty model has nothing to export.
170    #[test]
171    fn export_step_text_round_trips_a_box_model() {
172        let mut state = EngineState::new();
173        state.set_history_json(&cube_history("Box", 10.0)).unwrap();
174        let step = state.export_step_text().unwrap();
175        assert!(step.contains("ISO-10303-21"), "STEP header present");
176        let volume = imported_volume(&step);
177        assert!((volume - 1000.0).abs() < 1e-3, "exported volume {volume} != 1000");
178
179        let empty = EngineState::new();
180        assert!(empty.export_step_text().is_err(), "empty model errs on export");
181    }
182
183    /// Round trip: import a STEP box into the model, export the model back to
184    /// STEP, re-import — the solid count and volume are preserved.
185    #[test]
186    fn import_export_import_preserves_count_and_volume() {
187        let step_in = box_step(5.0, 4.0, 3.0); // volume 60
188        let mut state = EngineState::new();
189        state.import_step_feature(&step_in).unwrap();
190        assert_eq!(state.scene.solids().len(), 1);
191
192        let step_out = state.export_step_text().unwrap();
193        let solids = brep_kernel::import_step(&step_out).unwrap();
194        assert_eq!(solids.len(), 1, "solid count preserved");
195        let volume = brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume;
196        assert!((volume - 60.0).abs() < 1e-3, "round-trip volume {volume} != 60");
197    }
198
199    /// ASCII STL export of a box model is well-formed (`solid brep … endsolid
200    /// brep`) with the box's 12 triangles / 36 vertices. An empty scene errs.
201    #[test]
202    fn export_stl_text_emits_ascii_facets() {
203        let mut state = EngineState::new();
204        state.set_history_json(&cube_history("Box", 6.0)).unwrap();
205        let stl = state.export_stl_text().unwrap();
206        assert!(stl.starts_with("solid brep"), "STL opens with the solid header");
207        assert!(stl.trim_end().ends_with("endsolid brep"), "STL closes the solid");
208        assert_eq!(
209            stl.matches("facet normal").count(),
210            12,
211            "a box tessellates to 12 triangles"
212        );
213        assert_eq!(stl.matches("vertex").count(), 36, "3 vertices per triangle");
214
215        let empty = EngineState::new();
216        assert!(empty.export_stl_text().is_err(), "empty scene errs on STL export");
217    }
218}
219
220// ===========================================================================
221// Feature dimensions (FD-1) — the ◎ DIMENSION-gizmo mode.
222//
223// When a primitive-solid feature is armed in DIMENSION mode (the ◎'s second
224// cycle state), its key numeric params render as draggable dimension
225// annotations: a leader from world `pointA → pointB` whose length is the param
226// value, editing `fieldKey`. The geometry lives in `crate::feature_dimensions`
227// (ported from the previous feature-dimension annotation builder); THIS block owns the
228// engine surface: reporting the annotations (JSON + the `feature-dim-leaders`
229// overlay), dragging a handle (project the pointer onto the `a → b` world axis →
230// new param value), and value-editing a label (numeric literal OR a live
231// expression via the kernel `eval_expression`). Every mutator re-runs the
232// history (the model updates live) and re-projects the leaders. Kept in ONE
233// appended block so concurrent edits to the primary impl land clean.
234// ===========================================================================
235