Skip to main content

geop/
main.rs

1//! `geop`: the kernel on the command line.
2//!
3//! `geop compile part.program.json` builds a program — the JSON the web
4//! editor saves (see `geop_ops_parts::Program`) — and writes the part it
5//! makes as an STL mesh. The mesh is the one the editor draws (see
6//! `geop_ops_rasterize::stl`), so a compiled file looks exactly like the
7//! part on screen.
8
9use std::{
10    fs::File,
11    io::BufWriter,
12    path::{Path, PathBuf},
13    process::ExitCode,
14};
15
16use clap::{Parser, Subcommand};
17use geop_core_math::{
18    geop_error::{GeopError, GeopResult},
19    scalars::scal_in_f64::ScalInF64,
20};
21use geop_core_part::Part;
22use geop_ops_parts::Program;
23use geop_ops_rasterize::{
24    rasterize_model_tagged,
25    stl::{StlFormat, stl_triangles, write_stl},
26};
27
28type S = ScalInF64;
29
30/// How finely curved faces are meshed unless asked otherwise — what the
31/// web editor draws with.
32const DEFAULT_QUALITY: u16 = 24;
33
34#[derive(Parser)]
35#[command(
36    name = "geop",
37    version,
38    about = "The geop CAD kernel on the command line."
39)]
40struct Cli {
41    #[command(subcommand)]
42    command: Command,
43}
44
45#[derive(Subcommand)]
46enum Command {
47    /// Build a program (the JSON the web editor saves) and write its part as an STL mesh.
48    Compile(CompileArgs),
49    /// Write every built-in example (see `geop_ops_parts::examples`) as a program and an STL mesh.
50    Examples(ExamplesArgs),
51}
52
53#[derive(clap::Args)]
54struct CompileArgs {
55    /// The program to build, e.g. `part.program.json`.
56    program: PathBuf,
57    /// Where to write the mesh. Defaults to the program's path with
58    /// `.program.json` (or `.json`) replaced by `.stl`.
59    #[arg(short, long)]
60    output: Option<PathBuf>,
61    /// Only this solid, by name (e.g. `extrude(hole)`); repeat for several.
62    /// Every solid of the part, if not given.
63    #[arg(short, long = "solid", value_name = "NAME")]
64    solids: Vec<String>,
65    /// Write ASCII STL instead of binary.
66    #[arg(long)]
67    ascii: bool,
68    /// How finely curved faces are meshed: higher is smoother, and bigger.
69    /// Flat faces are meshed exactly whatever this is.
70    #[arg(short, long, default_value_t = DEFAULT_QUALITY, value_parser = clap::value_parser!(u16).range(2..))]
71    quality: u16,
72}
73
74#[derive(clap::Args)]
75struct ExamplesArgs {
76    /// Where to write `<name>.program.json` and `<name>.stl` for each example.
77    #[arg(short, long, default_value = "examples")]
78    out_dir: PathBuf,
79    /// Write ASCII STL instead of binary.
80    #[arg(long)]
81    ascii: bool,
82    /// How finely curved faces are meshed: higher is smoother, and bigger.
83    #[arg(short, long, default_value_t = DEFAULT_QUALITY, value_parser = clap::value_parser!(u16).range(2..))]
84    quality: u16,
85}
86
87/// What a compile wrote, for the report.
88#[derive(Debug)]
89struct Compiled {
90    output: PathBuf,
91    steps: usize,
92    solids: usize,
93    triangles: usize,
94}
95
96/// `program`'s path with its extension replaced by `.stl`: `part.program.json`
97/// becomes `part.stl`.
98fn default_output(program: &Path) -> PathBuf {
99    let name = program
100        .file_name()
101        .and_then(|n| n.to_str())
102        .unwrap_or("part");
103    let stem = name.strip_suffix(".json").unwrap_or(name);
104    let stem = stem.strip_suffix(".program").unwrap_or(stem);
105    program.with_file_name(format!("{stem}.stl"))
106}
107
108fn compile(args: &CompileArgs) -> GeopResult<Compiled> {
109    let io_err = |what: &str, path: &Path| {
110        let what = what.to_string();
111        let path = path.display().to_string();
112        move |e: std::io::Error| GeopError::new(format!("{what} {path}: {e}"))
113    };
114    let json = std::fs::read_to_string(&args.program).map_err(io_err("reading", &args.program))?;
115    let program = Program::from_json(&json)?;
116    let part = program.apply(Part::<S>::new())?;
117    let model = part.topology();
118
119    // The solids to write, in name order so the file does not depend on
120    // how the part stores them.
121    let solids = if args.solids.is_empty() {
122        let mut solids: Vec<_> = model.solids.keys().copied().collect();
123        solids.sort_by(|a, b| part.name_of(*a).cmp(&part.name_of(*b)));
124        solids
125    } else {
126        args.solids
127            .iter()
128            .map(|name| {
129                part.solid_id(name).map_err(|e| {
130                    let mut known: Vec<_> = model
131                        .solids
132                        .keys()
133                        .filter_map(|&s| part.name_of(s))
134                        .collect();
135                    known.sort();
136                    e.with_context(format!("the part's solids are: {}", known.join(", ")))
137                })
138            })
139            .collect::<GeopResult<_>>()?
140    };
141    let mut faces = Vec::new();
142    for &solid in &solids {
143        let mut of_solid = model.solid_faces(solid)?;
144        of_solid.sort_by_key(|f| f.0);
145        faces.extend(of_solid);
146    }
147
148    let raster = rasterize_model_tagged(model, usize::from(args.quality))?;
149    let triangles = stl_triangles(&raster, &faces);
150
151    let output = args
152        .output
153        .clone()
154        .unwrap_or_else(|| default_output(&args.program));
155    let name = output
156        .file_stem()
157        .and_then(|n| n.to_str())
158        .unwrap_or("part");
159    let format = if args.ascii {
160        StlFormat::Ascii
161    } else {
162        StlFormat::Binary
163    };
164    let mut out = BufWriter::new(File::create(&output).map_err(io_err("creating", &output))?);
165    write_stl(&triangles, name, format, &mut out)
166        .and_then(|()| std::io::Write::flush(&mut out))
167        .map_err(io_err("writing", &output))?;
168
169    Ok(Compiled {
170        output,
171        steps: program.steps.len(),
172        solids: solids.len(),
173        triangles: triangles.len(),
174    })
175}
176
177/// Write every built-in example as `<out_dir>/<name>.program.json` and
178/// `<out_dir>/<name>.stl`, via [`compile`] — so an example's mesh is
179/// generated exactly the way any other program's would be.
180fn export_examples(args: &ExamplesArgs) -> GeopResult<Vec<Compiled>> {
181    std::fs::create_dir_all(&args.out_dir)
182        .map_err(|e| GeopError::new(format!("creating {}: {e}", args.out_dir.display())))?;
183    geop_ops_parts::examples::all()
184        .into_iter()
185        .map(|(name, program)| {
186            let json_path = args.out_dir.join(format!("{name}.program.json"));
187            std::fs::write(&json_path, program.to_json()?)
188                .map_err(|e| GeopError::new(format!("writing {}: {e}", json_path.display())))?;
189            compile(&CompileArgs {
190                program: json_path,
191                output: Some(args.out_dir.join(format!("{name}.stl"))),
192                solids: Vec::new(),
193                ascii: args.ascii,
194                quality: args.quality,
195            })
196            .map_err(|e| e.with_context(format!("export_examples(name={name})")))
197        })
198        .collect()
199}
200
201fn main() -> ExitCode {
202    let cli = Cli::parse();
203    let result = match cli.command {
204        Command::Compile(args) => compile(&args).map(|c| {
205            eprintln!(
206                "{} steps, {} solid{}, {} triangles -> {}",
207                c.steps,
208                c.solids,
209                if c.solids == 1 { "" } else { "s" },
210                c.triangles,
211                c.output.display()
212            );
213        }),
214        Command::Examples(args) => export_examples(&args).map(|compiled| {
215            for c in &compiled {
216                eprintln!("{} triangles -> {}", c.triangles, c.output.display());
217            }
218            eprintln!("{} example(s) -> {}", compiled.len(), args.out_dir.display());
219        }),
220    };
221    match result {
222        Ok(()) => ExitCode::SUCCESS,
223        Err(e) => {
224            eprintln!("error: {e}");
225            ExitCode::FAILURE
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use geop_ops_parts::examples;
233
234    use super::*;
235
236    /// A fresh directory for one test's files.
237    fn scratch(test: &str) -> PathBuf {
238        let dir = std::env::temp_dir().join(format!("geop-cli-{test}-{}", std::process::id()));
239        std::fs::create_dir_all(&dir).unwrap();
240        dir
241    }
242
243    fn args(program: PathBuf) -> CompileArgs {
244        CompileArgs {
245            program,
246            output: None,
247            solids: Vec::new(),
248            ascii: false,
249            quality: DEFAULT_QUALITY,
250        }
251    }
252
253    #[test]
254    fn default_output_replaces_the_program_extension() {
255        assert_eq!(
256            default_output(Path::new("a/part.program.json")),
257            Path::new("a/part.stl")
258        );
259        assert_eq!(
260            default_output(Path::new("part.json")),
261            Path::new("part.stl")
262        );
263        assert_eq!(default_output(Path::new("part")), Path::new("part.stl"));
264    }
265
266    #[test]
267    fn compiles_every_example() {
268        let dir = scratch("examples");
269        for (name, program) in examples::all() {
270            let path = dir.join(format!("{name}.program.json"));
271            std::fs::write(&path, program.to_json().unwrap()).unwrap();
272            let compiled = compile(&args(path)).unwrap();
273            assert_eq!(compiled.output, dir.join(format!("{name}.stl")));
274            assert!(compiled.triangles > 0, "{name}: no triangles");
275            let bytes = std::fs::read(&compiled.output).unwrap();
276            assert_eq!(bytes.len(), 84 + 50 * compiled.triangles, "{name}");
277        }
278    }
279
280    #[test]
281    fn export_examples_writes_every_example_as_json_and_stl() {
282        let dir = scratch("export-examples");
283        let compiled = export_examples(&ExamplesArgs {
284            out_dir: dir.clone(),
285            ascii: false,
286            quality: DEFAULT_QUALITY,
287        })
288        .unwrap();
289        assert_eq!(compiled.len(), examples::all().len());
290        for (name, _) in examples::all() {
291            assert!(dir.join(format!("{name}.program.json")).is_file(), "{name}");
292            assert!(dir.join(format!("{name}.stl")).is_file(), "{name}");
293        }
294    }
295
296    #[test]
297    fn an_unknown_solid_names_the_known_ones() {
298        let dir = scratch("unknown-solid");
299        let path = dir.join("box.program.json");
300        std::fs::write(&path, examples::box_with_drill_hole().to_json().unwrap()).unwrap();
301        let err = compile(&CompileArgs {
302            solids: vec!["nothing".into()],
303            ..args(path)
304        })
305        .unwrap_err();
306        assert!(err.to_string().contains("extrude(hole)"), "{err}");
307    }
308}