Files
OneCAD-Simulation/apps/fieldcraft/src/main.rs
T
jyvikoandClaude Opus 5.5 c9a765aa02 engine: samples can build their geometry from a SolidCraft script
A sample may carry a SolidCraft script (the corpus recipe form) whose design
opens before its commands run, and its script sees the design's bodies.
fieldcraft-engine now builds fieldcraft-geometry with its scripts feature.
The start screen scrolls its sample list, and a test keeps the file.sample
form's choices in step with the samples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 15:55:22 -04:00

238 lines
9.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! FieldCraft desktop app.
//!
//! Usage: `fieldcraft [--sample [NAME]] [--control PORT] [FILE…]` (files open as if dropped on the
//! window; NAME is a sample of `fieldcraft_engine::sample`, `parts` by default)
//!
//! `--sample` opens a sample model (`fieldcraft_engine::sample`). `--control PORT` (or
//! `FIELDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"engine.execute","params":{"command":"model.info","params":{}}}`. See
//! `fieldcraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod control_server;
use fieldcraft_engine::Session;
use fieldcraft_engine::autosave::{self, Autosaver};
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
const USAGE: &str =
"usage: fieldcraft [--sample [NAME]] [--control PORT] [FILE…] (NAME: a sample, docs/examples.md; FILE: .fieldcraft, .solidcraft or .3mf)";
const DEPTH_BITS: u8 = 24;
/// The window draws its own caption buttons where the OS ones would sit apart from the app bar.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const MSAA: u16 = 4;
/// The reverse-DNS application id: the Wayland app_id / X11 class, matched to the packaged
/// `.desktop` entry (packaging/linux), and the macOS bundle id (packaging/macos/Info.plist.in).
const APP_ID: &str = "io.github.jyviko.fieldcraft";
/// The window and Dock icon: the 256 px export of the application icon (assets/app-icon).
const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/icon.iconset/icon_128x128@2x.png");
/// The application icon for the window; eframe's default icon if the PNG cannot be decoded.
fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(ICON_PNG).unwrap_or_else(|e| {
log::warn!("app icon: {e}");
egui::IconData::default()
})
}
/// How often unsaved changes are autosaved for recovery (seconds).
const AUTOSAVE_SECONDS: f64 = 30.0;
struct App {
app: ModelApp,
/// Autosave for crash recovery (`None` when the recovery folder is unavailable).
autosaver: Option<Autosaver>,
/// App time of the last autosave check.
autosaved_at: f64,
}
impl App {
fn autosave(&mut self) {
if let Some(a) = &mut self.autosaver
&& let Err(e) = a.save(&self.app.session)
{
log::warn!("autosave: {e}");
}
}
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.app.logic(ctx);
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
self.autosaved_at = self.app.now;
self.autosave();
}
if self.autosaver.is_some() {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECONDS));
}
}
fn on_exit(&mut self) {
if let Some(a) = self.autosaver.take()
&& let Err(e) = a.close(&self.app.session)
{
log::warn!("autosave: {e}");
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
}
}
/// Native file pickers and the recent-files file, for the UI.
fn services() -> Services {
let dialog = |filters: &[Filter]| filters.iter().fold(rfd::FileDialog::new(), |d, f| d.add_filter(f.name, f.extensions));
Services {
pick_open: Some(Box::new(move |filters: &[Filter]| dialog(filters).pick_file().map(|p| p.to_string_lossy().to_string()))),
pick_save: Some(Box::new(move |name: &str, filters: &[Filter]| {
dialog(filters).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
})),
// Beside the recovery folder in the per-user data folder.
recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))),
}
}
/// Command-line options.
#[derive(Debug, Default, PartialEq)]
struct Args {
/// The sample to open (`--sample [NAME]`).
sample: Option<String>,
control: Option<u16>,
/// Files to open (models, designs, tagged meshes).
files: Vec<String>,
}
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>) -> Result<Option<Args>, String> {
let mut out = Args { control: env_port.and_then(|p| p.trim().parse().ok()), ..Default::default() };
let mut args = args.into_iter().peekable();
while let Some(a) = args.next() {
match a.as_str() {
"--sample" => {
// An optional sample name follows.
let name = args.peek().filter(|a| fieldcraft_engine::sample::find(a).is_some()).cloned();
if name.is_some() {
args.next();
}
out.sample = Some(name.unwrap_or_else(|| "parts".into()));
}
"--control" => {
let port = args.next().ok_or("--control needs a port")?;
out.control = Some(port.parse().map_err(|_| format!("--control: `{port}` is not a port number"))?);
}
"--version" | "-V" => {
println!("fieldcraft {}", fieldcraft_ui_egui::VERSION);
return Ok(None);
}
"--help" | "-h" => {
println!("{USAGE}");
return Ok(None);
}
other if other.starts_with('-') => return Err(format!("unknown argument `{other}`\n{USAGE}")),
file => out.files.push(file.to_string()),
}
}
Ok(Some(out))
}
fn main() -> eframe::Result {
let args = match parse_args(std::env::args().skip(1), std::env::var("FIELDCRAFT_CONTROL_PORT").ok()) {
Ok(Some(a)) => a,
Ok(None) => return Ok(()),
Err(e) => {
eprintln!("fieldcraft: {e}");
std::process::exit(2);
}
};
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FieldCraft")
.with_app_id(APP_ID)
.with_icon(app_icon())
.with_inner_size([1400.0, 900.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
// The app bar is the title bar (as in the other Craft apps): on macOS under the
// traffic lights, elsewhere with its own caption buttons and resize zones.
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
depth_buffer: DEPTH_BITS,
multisampling: MSAA,
..Default::default()
};
eframe::run_native(
"FieldCraft",
options,
Box::new(move |cc| {
let mut app = ModelApp::new(Session::default()).with_services(services());
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = &cc.wgpu_render_state {
let info = rs.adapter.get_info();
eprintln!("fieldcraft: graphics {:?} {}, MSAA {}x (requested {MSAA}x)", info.backend, info.name, rs.msaa_samples);
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
}
if let Some(port) = args.control {
app = app.with_control(control_server::start(port, cc.egui_ctx.clone()));
}
if let Some(name) = &args.sample {
app.open_sample(name);
}
for f in &args.files {
app.open_path(f);
}
let autosaver = autosave::default_dir().and_then(|dir| {
let listed = app.session.execute("file.recovery.list", &serde_json::json!({"dir": dir}));
let recoverable = listed.ok().and_then(|v| v.as_array().map(Vec::len)).unwrap_or(0);
if recoverable > 0 {
app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false);
}
Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
});
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0 }))
}),
)
}
#[cfg(test)]
mod tests {
#[test]
fn the_app_icon_decodes() {
let icon = super::app_icon();
assert_eq!((icon.width, icon.height), (256, 256));
assert_eq!(icon.rgba.len(), 256 * 256 * 4);
}
use super::*;
fn parse(a: &[&str], env: Option<&str>) -> Result<Option<Args>, String> {
parse_args(a.iter().map(|s| s.to_string()), env.map(str::to_string))
}
#[test]
fn arguments() {
assert_eq!(parse(&[], None), Ok(Some(Args::default())));
assert_eq!(
parse(&["--sample", "--control", "7070"], None),
Ok(Some(Args { sample: Some("parts".into()), control: Some(7070), files: vec![] }))
);
assert_eq!(parse(&[], Some("9000")), Ok(Some(Args { control: Some(9000), ..Default::default() })));
assert_eq!(parse(&[], Some("nope")), Ok(Some(Args::default())));
assert!(parse(&["--control"], None).is_err());
assert!(parse(&["--control", "99999"], None).is_err());
assert!(parse(&["--frobnicate"], None).is_err());
assert_eq!(parse(&["--sample", "impact"], None).map(|a| a.and_then(|a| a.sample)), Ok(Some("impact".into())));
let a = parse(&["--sample", "x.fieldcraft"], None).unwrap().unwrap();
assert_eq!((a.sample.as_deref(), a.files), (Some("parts"), vec!["x.fieldcraft".to_string()]));
assert_eq!(parse(&["a.fieldcraft", "--sample"], None).map(|a| a.map(|a| a.files)), Ok(Some(vec!["a.fieldcraft".to_string()])));
assert_eq!(parse(&["--help"], None), Ok(None));
}
}