1#![cfg_attr(
23 all(feature = "desktop", not(debug_assertions)),
24 windows_subsystem = "windows"
25)]
26
27mod app;
28mod mcp;
29mod open;
30mod release;
31mod session;
32mod vscode;
33
34use clap::{Parser, Subcommand};
35use geop_cad_base::{BuiltPart, disk::DiskCache, outputs, workspace_with};
36use geop_core_editor::editor::Rerun;
37use geop_core_editor::output::Output;
38use geop_core_math::geop_error::{GeopError, GeopResult};
39use geop_core_program::{program::Program, runner::Counts, workspace::FileStore};
40use geop_ops_assembly::solving::{check as check_mates, solve};
41use session::Session;
42use std::{
43 io::{BufRead, Write},
44 path::{Path, PathBuf},
45 process::ExitCode,
46};
47
48#[derive(Parser)]
49#[command(
50 name = "geop",
51 version,
52 about = "The geop CAD kernel on the command line."
53)]
54struct Cli {
55 #[command(subcommand)]
56 command: Command,
57}
58
59#[derive(Subcommand)]
60enum Command {
61 Compile(CompileArgs),
63 Examples(ExamplesArgs),
65 Prebuild(PrebuildArgs),
67 Serve(ServeArgs),
71 Mcp(McpArgs),
76 Open(open::OpenArgs),
80 App(app::AppArgs),
84 Vscode(vscode::VscodeArgs),
87}
88
89#[derive(clap::Args)]
90struct ServeArgs {
91 #[arg(long, value_name = "DIR")]
96 cache_root: Option<PathBuf>,
97}
98
99#[derive(clap::Args)]
100struct McpArgs {}
101
102#[derive(clap::Args)]
103struct PrebuildArgs {
104 programs: Vec<PathBuf>,
106 #[arg(short, long)]
108 jobs: Option<usize>,
109}
110
111#[derive(clap::Args)]
112struct CompileArgs {
113 program: PathBuf,
115 #[arg(short, long)]
119 output: Option<PathBuf>,
120 #[arg(short = 'O', long = "option", value_name = "KEY=VALUE")]
124 options: Vec<String>,
125}
126
127#[derive(clap::Args)]
128struct ExamplesArgs {
129 #[arg(short, long, default_value = "examples")]
131 out_dir: PathBuf,
132 #[arg(long = "only", value_name = "NAME")]
135 only: Vec<String>,
136 #[arg(short = 'O', long = "option", value_name = "KEY=VALUE")]
138 options: Vec<String>,
139}
140
141fn options(pairs: &[String]) -> GeopResult<serde_json::Value> {
144 let options = (pairs.iter())
145 .map(|pair| {
146 let (key, value) = pair
147 .split_once('=')
148 .ok_or_else(|| GeopError::new(format!("the option {pair:?} is not `key=value`")))?;
149 let value = serde_json::from_str(value)
150 .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
151 Ok((key.to_string(), value))
152 })
153 .collect::<GeopResult<serde_json::Map<_, _>>>()?;
154 Ok(serde_json::Value::Object(options))
155}
156
157fn today() -> String {
159 let days = std::time::SystemTime::now()
160 .duration_since(std::time::UNIX_EPOCH)
161 .map(|d| (d.as_secs() / 86_400) as i64)
162 .unwrap_or(0);
163 let z = days + 719_468;
165 let era = z.div_euclid(146_097);
166 let doe = z.rem_euclid(146_097);
167 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
168 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
169 let mp = (5 * doy + 2) / 153;
170 let day = doy - (153 * mp + 2) / 5 + 1;
171 let month = if mp < 10 { mp + 3 } else { mp - 9 };
172 let year = yoe + era * 400 + i64::from(month <= 2);
173 format!("{year:04}-{month:02}-{day:02}")
174}
175
176#[derive(Debug)]
178struct Compiled {
179 output: PathBuf,
180 steps: usize,
181 bytes: usize,
183 solved_for: Vec<String>,
186}
187
188fn default_output(program: &Path) -> GeopResult<PathBuf> {
191 let outputs = outputs()?;
192 let format = (outputs.exporters().first())
193 .and_then(|exporter| exporter.formats.first())
194 .ok_or_else(|| GeopError::new("the editor offers no exporter"))?;
195 Ok(program.with_extension(format))
196}
197
198struct Disk;
200
201impl FileStore for Disk {
202 fn read(&self, path: &str) -> GeopResult<String> {
206 std::fs::read(path)
207 .map(geop_core_program::workspace::content)
208 .map_err(|e| GeopError::new(format!("reading {path}: {e}")))
209 }
210
211 fn list(&self) -> Vec<String> {
213 Vec::new()
214 }
215}
216
217fn build(path: &Path) -> GeopResult<(Program, BuiltPart, Vec<String>)> {
222 let path = path.to_string_lossy();
223 let workspace = workspace_with(Disk, DiskCache::cache(DiskCache::of(PathBuf::new())));
225 let mut program = Program::from_json(&workspace.files().read(&path)?)?;
226 let mut built = geop_cad_base::build(&program, &path, workspace.clone())?;
227 let report = check_mates(&built.part.current())?;
228 if !report.converged {
229 let mut rerun = Rerun {
230 runner: &built.runner,
231 part: &mut built.part,
232 program: &program,
233 };
234 let (moved, _) = solve(&mut rerun, &program.state, &[], None, true, None)?;
235 program.state.extend(moved);
236 program.run(&built.runner, &mut built.part)?;
237 }
238 if let Some(cache) = geop_core_program::workspace::Workspace::cache(&*workspace) {
240 cache.flush();
241 }
242 Ok((program, built, report.failed))
243}
244
245fn prebuild(args: &PrebuildArgs) -> Vec<(PathBuf, GeopResult<Counts>)> {
251 let jobs = args.jobs.unwrap_or_else(|| {
252 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
253 });
254 let next = std::sync::atomic::AtomicUsize::new(0);
255 let built = std::sync::Mutex::new(Vec::new());
256 std::thread::scope(|scope| {
257 for _ in 0..jobs.clamp(1, args.programs.len().max(1)) {
258 scope.spawn(|| {
259 loop {
260 let at = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
261 let Some(path) = args.programs.get(at) else {
262 break;
263 };
264 let counts = build(path).map(|(_, part, _)| part.runner.counts());
265 if let Ok(mut built) = built.lock() {
266 built.push((path.clone(), counts));
267 }
268 }
269 });
270 }
271 });
272 let mut built = built.into_inner().unwrap_or_default();
273 built.sort_by(|a, b| a.0.cmp(&b.0));
274 built
275}
276
277fn compile(args: &CompileArgs) -> GeopResult<Compiled> {
278 let io_err = |what: &str, path: &Path| {
279 let what = what.to_string();
280 let path = path.display().to_string();
281 move |e: std::io::Error| GeopError::new(format!("{what} {path}: {e}"))
282 };
283 let (program, mut built_part, solved_for) = build(&args.program)?;
284
285 let output = match &args.output {
286 Some(output) => output.clone(),
287 None => default_output(&args.program)?,
288 };
289 let format = (output.extension())
292 .and_then(|e| e.to_str())
293 .ok_or_else(|| GeopError::new(format!("{} names no format", output.display())))?;
294 let outputs = outputs()?;
295 let exporter = outputs.exporter_for(format)?;
296 let options = options(&args.options)?;
297 let today = today();
298 let mut built = Output {
299 runner: &built_part.runner,
300 part: &mut built_part.part,
301 file: &program,
302 editing: None,
303 date: &today,
304 };
305 let export = exporter.export(&mut built, Some(format), &options)?;
306 std::fs::write(&output, export.bytes()).map_err(io_err("writing", &output))?;
307
308 Ok(Compiled {
309 output,
310 steps: program.steps.len(),
311 bytes: export.bytes().len(),
312 solved_for,
313 })
314}
315
316fn export_examples(args: &ExamplesArgs) -> GeopResult<Vec<Compiled>> {
322 let dir_err = |dir: &Path| {
323 let dir = dir.display().to_string();
324 move |e: std::io::Error| GeopError::new(format!("creating {dir}: {e}"))
325 };
326 std::fs::create_dir_all(&args.out_dir).map_err(dir_err(&args.out_dir))?;
327 let offered = geop_cad_base::registry()?;
328 let singles: Vec<&str> = offered.example_names().collect();
329 let workspaces: Vec<&str> = offered.workspace_example_names().collect();
330 let known = || -> Vec<&str> { singles.iter().chain(&workspaces).copied().collect() };
331 if let Some(unknown) = args.only.iter().find(|n| !known().contains(&n.as_str())) {
332 return Err(GeopError::new(format!(
333 "no example named {unknown:?}; the built-in examples are: {}",
334 known().join(", ")
335 )));
336 }
337 let chosen = |name: &str| args.only.is_empty() || args.only.iter().any(|n| n == name);
338 let write = |path: &Path, program: &Program| {
339 std::fs::write(path, program.to_json()?)
340 .map_err(|e| GeopError::new(format!("writing {}: {e}", path.display())))
341 };
342 let compile_to = |program: PathBuf, output: PathBuf| {
343 let output = default_output(&output)?;
344 compile(&CompileArgs {
345 program,
346 output: Some(output),
347 options: args.options.clone(),
348 })
349 };
350 let mut compiled = Vec::new();
351 for name in singles.iter().filter(|n| chosen(n)) {
352 let path = args.out_dir.join(format!("{name}.geop"));
353 write(&path, &offered.example(name)?)?;
354 let output = args.out_dir.join(name);
355 compiled.push(
356 compile_to(path, output)
357 .map_err(|e| e.with_context(format!("export_examples(name={name})")))?,
358 );
359 }
360 for name in workspaces.iter().filter(|n| chosen(n)) {
361 let dir = args.out_dir.join(name);
362 std::fs::create_dir_all(&dir).map_err(dir_err(&dir))?;
363 let files = offered.workspace_example(name)?;
364 for (file, program) in &files {
365 write(&dir.join(file), program)?;
366 }
367 let (main, _) = files.first().expect("an example has files");
368 let output = args.out_dir.join(name);
369 compiled.push(
370 compile_to(dir.join(main), output)
371 .map_err(|e| e.with_context(format!("export_examples(name={name})")))?,
372 );
373 }
374 Ok(compiled)
375}
376
377fn serve(args: &ServeArgs) -> GeopResult<()> {
383 let io_err = |e: std::io::Error| GeopError::new(format!("serving: {e}"));
384 let mut session = Session::new(args.cache_root.clone());
385 let stdin = std::io::stdin().lock();
386 let mut stdout = std::io::stdout().lock();
387 for line in stdin.lines() {
388 let line = line.map_err(io_err)?;
389 if line.trim().is_empty() {
390 continue;
391 }
392 let answer = session.answer(&line);
393 writeln!(stdout, "{}", answer.reply)
394 .and_then(|()| stdout.flush())
395 .map_err(io_err)?;
396 if answer.crashed {
397 return Err(GeopError::new("the kernel panicked"));
398 }
399 }
400 Ok(())
401}
402
403fn main() -> ExitCode {
404 #[cfg(not(feature = "desktop"))]
405 let cli = Cli::parse();
406 #[cfg(feature = "desktop")]
407 let cli = Cli::parse_from(app::desktop_args(std::env::args_os()));
408 let result = match cli.command {
409 Command::Serve(args) => serve(&args),
410 Command::Mcp(McpArgs {}) => mcp::serve(),
411 Command::Open(args) => open::open(&args),
412 Command::App(args) => app::run(&args),
413 Command::Vscode(args) => vscode::install(&args),
414 Command::Compile(args) => compile(&args).map(|c| {
415 if !c.solved_for.is_empty() {
416 eprintln!(
417 "warning: the program's parts are not where its mates hold them ({}); compiled where they do",
418 c.solved_for.join(", ")
419 );
420 }
421 eprintln!(
422 "{} steps, {} bytes -> {}",
423 c.steps,
424 c.bytes,
425 c.output.display()
426 );
427 }),
428 Command::Prebuild(args) => {
429 let built = prebuild(&args);
430 let failed = built.iter().filter(|(_, b)| b.is_err()).count();
431 for (path, counts) in &built {
432 match counts {
433 Ok(c) => eprintln!(
434 "{}: {} made ({} of them kept), {} read back",
435 path.display(),
436 c.makes,
437 c.kept_makes,
438 c.restored
439 ),
440 Err(e) => eprintln!("{}: {e}", path.display()),
441 }
442 }
443 match failed {
444 0 => Ok(()),
445 n => Err(GeopError::new(format!("{n} program(s) did not build"))),
446 }
447 }
448 Command::Examples(args) => export_examples(&args).map(|compiled| {
449 for c in &compiled {
450 eprintln!("{} bytes -> {}", c.bytes, c.output.display());
451 }
452 eprintln!(
453 "{} example(s) -> {}",
454 compiled.len(),
455 args.out_dir.display()
456 );
457 }),
458 };
459 match result {
460 Ok(()) => ExitCode::SUCCESS,
461 Err(e) => {
462 eprintln!("error: {e}");
463 #[cfg(feature = "desktop")]
465 rfd::MessageDialog::new()
466 .set_level(rfd::MessageLevel::Error)
467 .set_title("Geop could not start")
468 .set_description(e.root_message())
469 .show();
470 ExitCode::FAILURE
471 }
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
482 fn programs_prebuilt_on_threads_are_kept() {
483 let dir = scratch("prebuild");
484 let registry = geop_cad_base::registry().unwrap();
485 let programs: Vec<PathBuf> = ["box_with_drill_hole", "parametric_plate", "pin"]
486 .iter()
487 .map(|name| {
488 let path = dir.join(format!("{name}.geop"));
489 let text = registry.example(name).unwrap().to_json().unwrap();
490 std::fs::write(&path, text).unwrap();
491 path
492 })
493 .collect();
494 let args = PrebuildArgs {
495 programs: programs.clone(),
496 jobs: Some(2),
497 };
498 for (path, counts) in prebuild(&args) {
499 let counts = counts.unwrap();
500 assert!(counts.kept_makes > 0, "{}: {counts:?}", path.display());
501 }
502 for path in &programs {
503 let (_, part, _) = build(path).unwrap();
504 let counts = part.runner.counts();
505 assert_eq!(counts.kept_makes, 0, "{}: {counts:?}", path.display());
506 assert!(counts.restored > 0);
507 }
508 std::fs::remove_dir_all(&dir).unwrap();
509 }
510
511 fn scratch(test: &str) -> PathBuf {
513 let dir = std::env::temp_dir().join(format!("geop-cli-{test}-{}", std::process::id()));
514 std::fs::create_dir_all(&dir).unwrap();
515 dir
516 }
517
518 fn triangles(compiled: &Compiled) -> usize {
521 let bytes = std::fs::read(&compiled.output).unwrap();
522 let count = u32::from_le_bytes(bytes[80..84].try_into().unwrap()) as usize;
523 assert_eq!(
524 bytes.len(),
525 84 + 50 * count,
526 "{}",
527 compiled.output.display()
528 );
529 count
530 }
531
532 fn args(program: PathBuf) -> CompileArgs {
533 CompileArgs {
534 program,
535 output: None,
536 options: Vec::new(),
537 }
538 }
539
540 #[test]
541 fn default_output_replaces_the_program_extension() {
542 let output = |path: &str| default_output(Path::new(path)).unwrap();
543 assert_eq!(output("a/part.geop"), Path::new("a/part.stl"));
544 assert_eq!(output("part.json"), Path::new("part.stl"));
545 assert_eq!(output("part"), Path::new("part.stl"));
546 }
547
548 #[test]
549 fn options_are_json_or_else_text() {
550 let pairs = ["quality=8", r#"solids=["a(b)"]"#, "name=x=y"].map(String::from);
551 assert_eq!(
552 options(&pairs).unwrap(),
553 serde_json::json!({"quality": 8, "solids": ["a(b)"], "name": "x=y"})
554 );
555 assert!(options(&["quality".into()]).is_err());
556 }
557
558 #[test]
561 fn serve_answers_every_command_with_one_line() {
562 let mut editor = geop_cad_base::editor();
563 let shown = editor.handle_json(r#"{"command": "show"}"#).unwrap();
564 let shown: serde_json::Value = serde_json::from_str(&shown).unwrap();
565 assert!(shown["error"].is_null(), "{shown}");
566 assert!(
567 shown["program"]["examples"]
568 .as_array()
569 .is_some_and(|e| !e.is_empty())
570 );
571 assert!(editor.handle_json("not json").is_err());
572 }
573
574 #[test]
575 #[ignore = "slow: 7 s — run with `cargo test -- --ignored`"]
576 fn compiles_every_example() {
577 let dir = scratch("examples");
578 let offered = geop_cad_base::registry().unwrap();
579 for name in offered.example_names() {
580 let path = dir.join(format!("{name}.geop"));
581 let program = offered.example(name).unwrap();
582 std::fs::write(&path, program.to_json().unwrap()).unwrap();
583 let compiled = compile(&args(path)).unwrap();
584 assert_eq!(compiled.output, dir.join(format!("{name}.stl")));
585 assert!(triangles(&compiled) > 0, "{name}: no triangles");
586 }
587 }
588
589 #[test]
590 #[ignore = "slow: 10 s — run with `cargo test -- --ignored`"]
591 fn export_examples_writes_every_example_as_json_and_stl() {
592 let dir = scratch("export-examples");
593 let compiled = export_examples(&ExamplesArgs {
594 out_dir: dir.clone(),
595 only: Vec::new(),
596 options: Vec::new(),
597 })
598 .unwrap();
599 let offered = geop_cad_base::registry().unwrap();
600 assert_eq!(
601 compiled.len(),
602 offered.example_names().count() + offered.workspace_example_names().count()
603 );
604 for name in offered.example_names() {
605 assert!(dir.join(format!("{name}.geop")).is_file(), "{name}");
606 assert!(dir.join(format!("{name}.stl")).is_file(), "{name}");
607 }
608 for name in offered.workspace_example_names() {
609 for (file, _) in offered.workspace_example(name).unwrap() {
610 assert!(dir.join(name).join(file).is_file(), "{name}/{file}");
611 }
612 assert!(dir.join(format!("{name}.stl")).is_file(), "{name}");
613 }
614 }
615
616 #[test]
617 fn export_examples_only_writes_the_named_ones() {
618 let dir = scratch("export-examples-only");
619 let compiled = export_examples(&ExamplesArgs {
620 out_dir: dir.clone(),
621 only: vec!["cross_drilled_shaft".into(), "luggage_tag".into()],
622 options: Vec::new(),
623 })
624 .unwrap();
625 assert_eq!(compiled.len(), 2);
626 assert!(dir.join("cross_drilled_shaft.stl").is_file());
627 assert!(dir.join("luggage_tag.stl").is_file());
628 assert!(!dir.join("box_with_drill_hole.stl").exists());
629 }
630
631 #[test]
632 fn export_examples_only_names_the_known_ones_when_unknown() {
633 let dir = scratch("export-examples-unknown");
634 let err = export_examples(&ExamplesArgs {
635 out_dir: dir,
636 only: vec!["not_a_real_example".into()],
637 options: Vec::new(),
638 })
639 .unwrap_err();
640 assert!(err.to_string().contains("luggage_tag"), "{err}");
641 }
642
643 #[test]
644 fn open_app_and_vscode_take_their_arguments() {
645 let parse = |args: &[&str]| Cli::try_parse_from([&["geop"], args].concat());
646 let Command::Open(open) = parse(&["open"]).unwrap().command else {
647 panic!("not open")
648 };
649 assert_eq!(
650 (open.path, open.port, open.no_browser),
651 (PathBuf::from("."), None, false)
652 );
653 let Command::Open(open) = parse(&["open", "proj", "--port", "8080", "--no-browser"])
654 .unwrap()
655 .command
656 else {
657 panic!("not open")
658 };
659 assert_eq!(
660 (open.path, open.port, open.no_browser),
661 (PathBuf::from("proj"), Some(8080), true)
662 );
663 assert!(parse(&["open", "--port", "70000"]).is_err());
664 let Command::App(app) = parse(&["app"]).unwrap().command else {
665 panic!("not app")
666 };
667 assert_eq!(app.path, None);
668 let Command::App(app) = parse(&["app", "proj/part.geop"]).unwrap().command else {
669 panic!("not app")
670 };
671 assert_eq!(app.path, Some(PathBuf::from("proj/part.geop")));
672 assert!(parse(&["app", "a", "b"]).is_err());
673 let Command::Vscode(vscode) = parse(&["vscode"]).unwrap().command else {
674 panic!("not vscode")
675 };
676 assert_eq!(vscode.editor, None);
677 let Command::Vscode(vscode) = parse(&["vscode", "--editor", "cursor"]).unwrap().command
678 else {
679 panic!("not vscode")
680 };
681 assert_eq!(vscode.editor.as_deref(), Some("cursor"));
682 }
683
684 #[test]
685 fn today_is_a_date() {
686 let today = today();
687 assert_eq!(today.len(), 10, "{today}");
688 assert_eq!(today.matches('-').count(), 2, "{today}");
689 }
690}