oxihuman-export 0.1.2

Export pipeline for OxiHuman — glTF, COLLADA, STL, and streaming formats
Documentation
// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
// SPDX-License-Identifier: Apache-2.0
#![allow(dead_code)]

//! OpenSCAD CSG geometry export.

/// An OpenSCAD CSG primitive.
#[derive(Clone, Debug)]
pub enum ScadPrim {
    Cube {
        size: [f32; 3],
    },
    Sphere {
        r: f32,
    },
    Cylinder {
        h: f32,
        r: f32,
    },
    Polyhedron {
        points: Vec<[f32; 3]>,
        faces: Vec<Vec<u32>>,
    },
}

/// An OpenSCAD operation node.
#[derive(Clone, Debug)]
pub enum ScadNode {
    Primitive(ScadPrim),
    Union(Vec<ScadNode>),
    Difference(Vec<ScadNode>),
    Intersection(Vec<ScadNode>),
    Translate { v: [f32; 3], child: Box<ScadNode> },
}

/// An OpenSCAD document.
#[derive(Clone, Debug, Default)]
pub struct OpenScadExport {
    pub nodes: Vec<ScadNode>,
}

/// Create a new empty OpenSCAD document.
pub fn new_openscad_export() -> OpenScadExport {
    OpenScadExport::default()
}

/// Add a node to the document.
pub fn openscad_push_node(doc: &mut OpenScadExport, node: ScadNode) {
    doc.nodes.push(node);
}

/// Return the number of top-level nodes.
pub fn openscad_node_count(doc: &OpenScadExport) -> usize {
    doc.nodes.len()
}

fn render_prim(p: &ScadPrim) -> String {
    match p {
        ScadPrim::Cube { size } => {
            format!("cube([{:.6}, {:.6}, {:.6}]);", size[0], size[1], size[2])
        }
        ScadPrim::Sphere { r } => format!("sphere(r={:.6});", r),
        ScadPrim::Cylinder { h, r } => format!("cylinder(h={:.6}, r={:.6});", h, r),
        ScadPrim::Polyhedron { points, faces } => {
            let pts: Vec<String> = points
                .iter()
                .map(|p| format!("[{:.6},{:.6},{:.6}]", p[0], p[1], p[2]))
                .collect();
            let fcs: Vec<String> = faces
                .iter()
                .map(|f| {
                    format!(
                        "[{}]",
                        f.iter()
                            .map(|i| i.to_string())
                            .collect::<Vec<_>>()
                            .join(",")
                    )
                })
                .collect();
            format!(
                "polyhedron(points=[{}], faces=[{}]);",
                pts.join(","),
                fcs.join(",")
            )
        }
    }
}

fn render_node(node: &ScadNode, indent: usize) -> String {
    let pad = " ".repeat(indent);
    match node {
        ScadNode::Primitive(p) => format!("{}{}", pad, render_prim(p)),
        ScadNode::Union(children) => {
            let body: String = children
                .iter()
                .map(|c| render_node(c, indent + 2))
                .collect::<Vec<_>>()
                .join("\n");
            format!("{}union() {{\n{}\n{}}}", pad, body, pad)
        }
        ScadNode::Difference(children) => {
            let body: String = children
                .iter()
                .map(|c| render_node(c, indent + 2))
                .collect::<Vec<_>>()
                .join("\n");
            format!("{}difference() {{\n{}\n{}}}", pad, body, pad)
        }
        ScadNode::Intersection(children) => {
            let body: String = children
                .iter()
                .map(|c| render_node(c, indent + 2))
                .collect::<Vec<_>>()
                .join("\n");
            format!("{}intersection() {{\n{}\n{}}}", pad, body, pad)
        }
        ScadNode::Translate { v, child } => {
            let child_str = render_node(child, indent + 2);
            format!(
                "{}translate([{:.6},{:.6},{:.6}]) {{\n{}\n{}}}",
                pad, v[0], v[1], v[2], child_str, pad
            )
        }
    }
}

/// Render the document to an OpenSCAD source string.
pub fn render_openscad(doc: &OpenScadExport) -> String {
    let mut out = String::from("// Generated by oxihuman\n");
    for node in &doc.nodes {
        out.push_str(&render_node(node, 0));
        out.push('\n');
    }
    out
}

/// Export a mesh as a polyhedron node.
pub fn export_mesh_as_openscad(positions: &[[f32; 3]], indices: &[u32]) -> OpenScadExport {
    let mut doc = new_openscad_export();
    let points: Vec<[f32; 3]> = positions.to_vec();
    let faces: Vec<Vec<u32>> = indices.chunks(3).map(|t| t.to_vec()).collect();
    openscad_push_node(
        &mut doc,
        ScadNode::Primitive(ScadPrim::Polyhedron { points, faces }),
    );
    doc
}

/// Validate the document (nodes non-empty).
pub fn validate_openscad(doc: &OpenScadExport) -> bool {
    !doc.nodes.is_empty()
}

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

    #[test]
    fn new_doc_empty() {
        let d = new_openscad_export();
        assert_eq!(openscad_node_count(&d), 0);
    }

    #[test]
    fn push_node_increments_count() {
        let mut d = new_openscad_export();
        openscad_push_node(&mut d, ScadNode::Primitive(ScadPrim::Sphere { r: 1.0 }));
        assert_eq!(openscad_node_count(&d), 1);
    }

    #[test]
    fn render_sphere() {
        let mut d = new_openscad_export();
        openscad_push_node(&mut d, ScadNode::Primitive(ScadPrim::Sphere { r: 2.5 }));
        let s = render_openscad(&d);
        assert!(s.contains("sphere"), "got: {s}");
    }

    #[test]
    fn render_cube() {
        let mut d = new_openscad_export();
        openscad_push_node(
            &mut d,
            ScadNode::Primitive(ScadPrim::Cube {
                size: [1.0, 2.0, 3.0],
            }),
        );
        let s = render_openscad(&d);
        assert!(s.contains("cube"));
    }

    #[test]
    fn export_mesh_adds_polyhedron() {
        let pos = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
        let idx = vec![0u32, 1, 2];
        let d = export_mesh_as_openscad(&pos, &idx);
        assert_eq!(openscad_node_count(&d), 1);
        let s = render_openscad(&d);
        assert!(s.contains("polyhedron"));
    }

    #[test]
    fn validate_nonempty_doc() {
        let mut d = new_openscad_export();
        openscad_push_node(&mut d, ScadNode::Primitive(ScadPrim::Sphere { r: 1.0 }));
        assert!(validate_openscad(&d));
    }

    #[test]
    fn validate_empty_doc_fails() {
        let d = new_openscad_export();
        assert!(!validate_openscad(&d));
    }

    #[test]
    fn render_translate_contains_translate() {
        let mut d = new_openscad_export();
        let child = ScadNode::Primitive(ScadPrim::Sphere { r: 1.0 });
        openscad_push_node(
            &mut d,
            ScadNode::Translate {
                v: [1.0, 0.0, 0.0],
                child: Box::new(child),
            },
        );
        let s = render_openscad(&d);
        assert!(s.contains("translate"));
    }

    #[test]
    fn render_union_contains_union() {
        let mut d = new_openscad_export();
        let c1 = ScadNode::Primitive(ScadPrim::Sphere { r: 1.0 });
        let c2 = ScadNode::Primitive(ScadPrim::Cube {
            size: [1.0, 1.0, 1.0],
        });
        openscad_push_node(&mut d, ScadNode::Union(vec![c1, c2]));
        let s = render_openscad(&d);
        assert!(s.contains("union"));
    }
}