forked from 1b-agent/fieldcraft-upstream
Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family, docs and licences. No compatibility shim: no files of the earlier name exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
9bc4e1b182
commit
d5bd74570f
268 files changed
+1666
-1666
No files matched your search
@@ -0,0 +1,161 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line. Agents and
|
||||
//! tests drive the app through it; the UI thread answers each request between frames
|
||||
//! (`fieldcraft_ui_egui::control`).
|
||||
//!
|
||||
//! Adapted from SolidCraft's `apps/solidcraft/src/control_server.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use fieldcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest request line (bytes).
|
||||
const MAX_LINE: usize = 16 * 1024 * 1024;
|
||||
/// Connections served at once. The channel has no authentication beyond being bound to
|
||||
/// loopback, so a flood must not be able to spawn threads without end.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
/// How long a request waits for the UI thread.
|
||||
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("fieldcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("fieldcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, tx, ctx);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
/// gigabytes with no newline would otherwise make this thread allocate until the process is
|
||||
/// killed, so the line is refused as soon as it passes `max`.
|
||||
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let (done, used) = {
|
||||
let available = r.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok((!line.is_empty()).then_some(line));
|
||||
}
|
||||
match available.iter().position(|b| *b == b'\n') {
|
||||
Some(i) => {
|
||||
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
|
||||
(true, i + 1)
|
||||
}
|
||||
None => {
|
||||
line.extend_from_slice(available);
|
||||
(false, available.len())
|
||||
}
|
||||
}
|
||||
};
|
||||
r.consume(used);
|
||||
if line.len() > max {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
|
||||
}
|
||||
if done {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut bad = 0usize;
|
||||
loop {
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(REPLY_TIMEOUT).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
bad += 1;
|
||||
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
||||
}
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() || bad >= MAX_BAD_LINES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A request longer than the cap is refused instead of allocated.
|
||||
#[test]
|
||||
fn lines_past_the_cap_are_refused() {
|
||||
let flood = vec![b'A'; MAX_LINE + 2];
|
||||
let mut r = BufReader::new(flood.as_slice());
|
||||
let e = read_line(&mut r, MAX_LINE).unwrap_err();
|
||||
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
/// Lines split on newlines, blank lines kept, no trailing newline needed; a line spanning
|
||||
/// several reads arrives whole.
|
||||
#[test]
|
||||
fn lines_split_on_newlines() {
|
||||
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
|
||||
let mut r = BufReader::with_capacity(3, text.as_slice());
|
||||
let mut got = Vec::new();
|
||||
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
|
||||
got.push(String::from_utf8(line).unwrap());
|
||||
}
|
||||
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
|
||||
let mut r = BufReader::with_capacity(4, b"0123456789\n".as_slice());
|
||||
assert!(read_line(&mut r, 5).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! 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 [parts|cantilever|impact]] [--control PORT] [FILE…] (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 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_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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user