1use 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
30const 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 Compile(CompileArgs),
49 Examples(ExamplesArgs),
51}
52
53#[derive(clap::Args)]
54struct CompileArgs {
55 program: PathBuf,
57 #[arg(short, long)]
60 output: Option<PathBuf>,
61 #[arg(short, long = "solid", value_name = "NAME")]
64 solids: Vec<String>,
65 #[arg(long)]
67 ascii: bool,
68 #[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 #[arg(short, long, default_value = "examples")]
78 out_dir: PathBuf,
79 #[arg(long)]
81 ascii: bool,
82 #[arg(short, long, default_value_t = DEFAULT_QUALITY, value_parser = clap::value_parser!(u16).range(2..))]
84 quality: u16,
85}
86
87#[derive(Debug)]
89struct Compiled {
90 output: PathBuf,
91 steps: usize,
92 solids: usize,
93 triangles: usize,
94}
95
96fn 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 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
177fn 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 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}