BREP_render 0.1.0

BREP Rust rendering engine: kernel-fed scene store + wgpu renderer (headless artifact, desktop window, and wasm canvas shells).
Documentation
use super::*;

// ===========================================================================
// Import / export (STEP + STL) — the file-interchange lane (the ONE platform
// exception). These marshal geometry to/from the storage trait as STRINGS: a
// STEP import appends an IMPORT3D feature carrying the raw ISO-10303-21 text;
// STEP/STL export collects the CURRENT model's resident solids and serializes
// them. Appended as one self-contained block so it never interleaves with the
// history / sketch / picking surfaces above.
// ===========================================================================
impl EngineState {
    /// Import a STEP document into the model: append an `IMPORT3D` feature whose
    /// `inputParams.stepText` is the raw ISO-10303-21 text (the exact headless
    /// source the kernel importer reads — no `fileToImport` data-URL marshaling
    /// needed), mint it a persistent-counter id, roll to it, and rebuild. Returns the
    /// build report JSON (imported bodies + any per-feature error). A non-STEP
    /// payload is refused up front so a bad upload never leaves a dead feature.
    pub fn import_step_feature(&mut self, step_text: &str) -> Result<String, String> {
        if !step_text.contains("ISO-10303-21") {
            return Err("not a STEP file (missing the ISO-10303-21 header)".into());
        }
        let id = self.next_feature_id(&crate::features::feature_short_name("IMPORT3D"));
        let feature = serde_json::json!({
            "type": "IMPORT3D",
            "inputParams": { "id": id, "stepText": step_text },
            "persistentData": {},
        });
        self.add_feature(&feature.to_string())
    }

    /// Export the CURRENT model's resident solids to an ISO-10303-21 STEP
    /// document. Collects the resident handles of the rolled-to model (a warm
    /// re-run of the same prefix the display scene was built from — see
    /// [`crate::pipeline::resident_solid_handles`]) and hands them to the kernel's
    /// [`brep_kernel::export_step_handles`], so the exact NURBS topology is
    /// serialized (never the display mesh). Errs clearly when the model is empty.
    pub fn export_step_text(&self) -> Result<String, String> {
        let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
            .map_err(|e| format!("export STEP: history request: {e}"))?;
        let handles: Vec<u32> = crate::pipeline::resident_solid_handles(&request)
            .into_iter()
            .map(|(_, handle)| handle)
            .collect();
        if handles.is_empty() {
            return Err("nothing to export: the model has no solids".into());
        }
        brep_kernel::export_step_handles(&handles, "Part", "MM", "")
    }

    /// Export the CURRENT display scene to an ASCII STL string (one `solid` with a
    /// per-triangle geometric normal for every mesh triangle of every displayed
    /// solid). STL is a triangle-soup format with no multi-body concept, so all
    /// solids fold into a single `solid brep … endsolid brep`. String-shaped so it
    /// crosses the same string `ModelStore` seam the STEP lane uses. Errs when the
    /// scene has no triangles.
    pub fn export_stl_text(&self) -> Result<String, String> {
        let mut out = String::from("solid brep\n");
        let mut triangles = 0usize;
        for solid in self.scene.solids() {
            let positions = &solid.mesh.positions;
            for tri in solid.mesh.indices.chunks_exact(3) {
                let a = positions[tri[0] as usize];
                let b = positions[tri[1] as usize];
                let c = positions[tri[2] as usize];
                let normal = triangle_normal(a, b, c);
                out.push_str(&format!(
                    "  facet normal {} {} {}\n    outer loop\n",
                    normal[0], normal[1], normal[2]
                ));
                for v in [a, b, c] {
                    out.push_str(&format!("      vertex {} {} {}\n", v[0], v[1], v[2]));
                }
                out.push_str("    endloop\n  endfacet\n");
                triangles += 1;
            }
        }
        out.push_str("endsolid brep\n");
        if triangles == 0 {
            return Err("nothing to export: the scene has no triangles".into());
        }
        Ok(out)
    }
}

/// Unit (or zero, for a degenerate triangle) geometric normal of triangle
/// `(a, b, c)` — the per-facet normal an ASCII STL record carries.
fn triangle_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
    let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
    let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
    let n = [
        u[1] * v[2] - u[2] * v[1],
        u[2] * v[0] - u[0] * v[2],
        u[0] * v[1] - u[1] * v[0],
    ];
    let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
    if len > 0.0 {
        [n[0] / len, n[1] / len, n[2] / len]
    } else {
        [0.0, 0.0, 0.0]
    }
}

#[cfg(test)]
mod io_tests {
    use super::*;

    /// A full history document for a single P.CU cube of side `size` (volume
    /// `size^3`), fed to [`EngineState::set_history_json`].
    fn cube_history(id: &str, size: f64) -> String {
        serde_json::json!({
            "expressions": "",
            "configurator": {},
            "features": [{
                "type": "P.CU",
                "inputParams": {
                    "id": id,
                    "sizeX": size, "sizeY": size, "sizeZ": size,
                    "transform": {
                        "position": [0.0, 0.0, 0.0],
                        "rotationEuler": [0.0, 0.0, 0.0],
                        "scale": [1.0, 1.0, 1.0]
                    },
                    "boolean": { "targets": [], "operation": "NONE" }
                },
                "persistentData": {}
            }]
        })
        .to_string()
    }

    /// STEP text for an axis-aligned box `sx × sy × sz`, via the kernel exporter.
    fn box_step(sx: f64, sy: f64, sz: f64) -> String {
        let solid =
            brep_kernel::make_box_brep(brep_kernel::Vec3::new(0.0, 0.0, 0.0), sx, sy, sz)
                .unwrap();
        brep_kernel::export_step(&[solid], "part", "MM", "").unwrap()
    }

    /// Volume of the single solid `import_step` recovers from STEP text.
    fn imported_volume(step_text: &str) -> f64 {
        let solids = brep_kernel::import_step(step_text).unwrap();
        assert_eq!(solids.len(), 1, "STEP round-trips to one solid");
        brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume
    }

    /// Importing a STEP box appends an IMPORT3D feature that yields the body in
    /// the model: the scene shows one solid whose bbox matches the box. A
    /// non-STEP payload is refused up front, leaving no dead feature behind.
    #[test]
    fn import_step_feature_adds_the_body_to_the_model() {
        let step = box_step(4.0, 3.0, 2.0);
        let mut state = EngineState::new();
        state.import_step_feature(&step).unwrap();
        assert_eq!(state.scene.solids().len(), 1, "one imported body");
        let size = state.scene.solids()[0].bbox.size();
        assert!(
            (size[0] - 4.0).abs() < 1e-4
                && (size[1] - 3.0).abs() < 1e-4
                && (size[2] - 2.0).abs() < 1e-4,
            "imported bbox {size:?} != 4x3x2"
        );

        let mut empty = EngineState::new();
        assert!(empty.import_step_feature("not a step file").is_err());
        assert_eq!(empty.history_len(), 0, "a bad import adds no feature");
    }

    /// Exporting a box model produces STEP text that `import_step` round-trips to
    /// one solid of the same volume. An empty model has nothing to export.
    #[test]
    fn export_step_text_round_trips_a_box_model() {
        let mut state = EngineState::new();
        state.set_history_json(&cube_history("Box", 10.0)).unwrap();
        let step = state.export_step_text().unwrap();
        assert!(step.contains("ISO-10303-21"), "STEP header present");
        let volume = imported_volume(&step);
        assert!((volume - 1000.0).abs() < 1e-3, "exported volume {volume} != 1000");

        let empty = EngineState::new();
        assert!(empty.export_step_text().is_err(), "empty model errs on export");
    }

    /// Round trip: import a STEP box into the model, export the model back to
    /// STEP, re-import — the solid count and volume are preserved.
    #[test]
    fn import_export_import_preserves_count_and_volume() {
        let step_in = box_step(5.0, 4.0, 3.0); // volume 60
        let mut state = EngineState::new();
        state.import_step_feature(&step_in).unwrap();
        assert_eq!(state.scene.solids().len(), 1);

        let step_out = state.export_step_text().unwrap();
        let solids = brep_kernel::import_step(&step_out).unwrap();
        assert_eq!(solids.len(), 1, "solid count preserved");
        let volume = brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume;
        assert!((volume - 60.0).abs() < 1e-3, "round-trip volume {volume} != 60");
    }

    /// ASCII STL export of a box model is well-formed (`solid brep … endsolid
    /// brep`) with the box's 12 triangles / 36 vertices. An empty scene errs.
    #[test]
    fn export_stl_text_emits_ascii_facets() {
        let mut state = EngineState::new();
        state.set_history_json(&cube_history("Box", 6.0)).unwrap();
        let stl = state.export_stl_text().unwrap();
        assert!(stl.starts_with("solid brep"), "STL opens with the solid header");
        assert!(stl.trim_end().ends_with("endsolid brep"), "STL closes the solid");
        assert_eq!(
            stl.matches("facet normal").count(),
            12,
            "a box tessellates to 12 triangles"
        );
        assert_eq!(stl.matches("vertex").count(), 36, "3 vertices per triangle");

        let empty = EngineState::new();
        assert!(empty.export_stl_text().is_err(), "empty scene errs on STL export");
    }
}

// ===========================================================================
// Feature dimensions (FD-1) — the ◎ DIMENSION-gizmo mode.
//
// When a primitive-solid feature is armed in DIMENSION mode (the ◎'s second
// cycle state), its key numeric params render as draggable dimension
// annotations: a leader from world `pointA → pointB` whose length is the param
// value, editing `fieldKey`. The geometry lives in `crate::feature_dimensions`
// (ported from the previous feature-dimension annotation builder); THIS block owns the
// engine surface: reporting the annotations (JSON + the `feature-dim-leaders`
// overlay), dragging a handle (project the pointer onto the `a → b` world axis →
// new param value), and value-editing a label (numeric literal OR a live
// expression via the kernel `eval_expression`). Every mutator re-runs the
// history (the model updates live) and re-projects the leaders. Kept in ONE
// appended block so concurrent edits to the primary impl land clean.
// ===========================================================================