diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c756c52..3e66a6a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -38,6 +38,9 @@ jobs: - name: Test core run: cargo test --locked -p benchup-core + - name: Test agent tools and CLI + run: cargo test --locked -p benchup-tools -p benchup-cli + app: name: Test settings and estimate PDF export runs-on: ubuntu-24.04 diff --git a/Cargo.lock b/Cargo.lock index 741144f..94138f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -421,6 +421,7 @@ version = "0.7.1" dependencies = [ "benchup-core", "benchup-io", + "benchup-tools", "bytemuck", "directories", "eframe", @@ -449,6 +450,16 @@ dependencies = [ "zip", ] +[[package]] +name = "benchup-cli" +version = "0.7.1" +dependencies = [ + "benchup-core", + "benchup-tools", + "serde_json", + "tempfile", +] + [[package]] name = "benchup-core" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index 2e7c487..bfa1977 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/benchup-io", "crates/benchup-skp", "crates/benchup-tools", + "crates/benchup-cli", "crates/benchup-app", ] default-members = ["crates/benchup-app"] diff --git a/README.md b/README.md index 6d71dd8..b88fc0f 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,8 @@ Verified during implementation: native Linux and browser WASM Cargo checks; core ## Layout - `crates/benchup-core`: double-precision document geometry, planar face discovery/triangulation, inference, picking, camera, tool definitions, and bounded history. It has no GUI or GPU dependencies. +- `crates/benchup-tools`: the operations an agent may call (schemas, validation, scene summaries) and `.bup` encode/decode. Depends only on `benchup-core`. +- `crates/benchup-cli`: the `benchup` command-line tool for reading and editing `.bup` files from a shell or an AI agent, including an MCP server over stdio. See [docs/cli.md](docs/cli.md). - `crates/benchup-app`: egui workspace, gesture routing, local persistence, native/browser launchers, and the wgpu/WGSL edge renderer. Model coordinates remain independent of GPU resources. - `index.html` and `Trunk.toml`: static browser host and WASM development configuration. diff --git a/assets/i18n/en_US.json b/assets/i18n/en_US.json index 85619e2..996f579 100644 --- a/assets/i18n/en_US.json +++ b/assets/i18n/en_US.json @@ -2181,5 +2181,6 @@ "app.measurements_unavailable": "Measurements unavailable.", "app.calculating_measurements": "Calculating measurements…", "sidebar.move_wireframe": "Wireframe other objects while moving", - "sidebar.selected_edge_lengths": "Selected Edge Lengths" + "sidebar.selected_edge_lengths": "Selected Edge Lengths", + "cli.agent_edit": "Agent edit" } diff --git a/assets/i18n/es_419.json b/assets/i18n/es_419.json index cd3e14d..ee5d550 100644 --- a/assets/i18n/es_419.json +++ b/assets/i18n/es_419.json @@ -2181,5 +2181,6 @@ "app.measurements_unavailable": "Mediciones no disponibles.", "app.calculating_measurements": "Calculando mediciones…", "sidebar.move_wireframe": "Estructura alámbrica de otros objetos al mover", - "sidebar.selected_edge_lengths": "Longitudes de aristas seleccionadas" + "sidebar.selected_edge_lengths": "Longitudes de aristas seleccionadas", + "cli.agent_edit": "Edición del agente" } diff --git a/assets/i18n/pt_BR.json b/assets/i18n/pt_BR.json index c47e62d..9f33f37 100644 --- a/assets/i18n/pt_BR.json +++ b/assets/i18n/pt_BR.json @@ -2181,5 +2181,6 @@ "app.measurements_unavailable": "Medições indisponíveis.", "app.calculating_measurements": "Calculando medições…", "sidebar.move_wireframe": "Exibir outros objetos em aramado ao mover", - "sidebar.selected_edge_lengths": "Comprimentos das arestas selecionadas" + "sidebar.selected_edge_lengths": "Comprimentos das arestas selecionadas", + "cli.agent_edit": "Edição do agente" } diff --git a/assets/i18n/vi_VN.json b/assets/i18n/vi_VN.json index 61c80a6..7e8bc79 100644 --- a/assets/i18n/vi_VN.json +++ b/assets/i18n/vi_VN.json @@ -2181,5 +2181,6 @@ "app.measurements_unavailable": "Không có phép đo.", "app.calculating_measurements": "Tính số đo…", "sidebar.move_wireframe": "Hiển thị khung dây cho đối tượng khác khi di chuyển", - "sidebar.selected_edge_lengths": "Chiều dài các cạnh đã chọn" + "sidebar.selected_edge_lengths": "Chiều dài các cạnh đã chọn", + "cli.agent_edit": "Chỉnh sửa của tác nhân" } diff --git a/assets/i18n/zh_CN.json b/assets/i18n/zh_CN.json index a98d99d..5d4a522 100644 --- a/assets/i18n/zh_CN.json +++ b/assets/i18n/zh_CN.json @@ -2181,5 +2181,6 @@ "app.measurements_unavailable": "无法获取测量数据。", "app.calculating_measurements": "正在计算测量数据…", "sidebar.move_wireframe": "移动时以线框形式显示其他对象", - "sidebar.selected_edge_lengths": "所选边长" + "sidebar.selected_edge_lengths": "所选边长", + "cli.agent_edit": "智能体编辑" } diff --git a/crates/benchup-app/Cargo.toml b/crates/benchup-app/Cargo.toml index 6613b08..e4e6623 100644 --- a/crates/benchup-app/Cargo.toml +++ b/crates/benchup-app/Cargo.toml @@ -7,6 +7,7 @@ rust-version.workspace = true [dependencies] benchup-core = { path = "../benchup-core" } +benchup-tools = { path = "../benchup-tools" } benchup-io = { path = "../benchup-io" } serde.workspace = true serde_json.workspace = true diff --git a/crates/benchup-app/src/project_files.rs b/crates/benchup-app/src/project_files.rs index b8478f8..f0c241e 100644 --- a/crates/benchup-app/src/project_files.rs +++ b/crates/benchup-app/src/project_files.rs @@ -1,13 +1,13 @@ //! Portable project files are separate from the local recovery/autosave document. use benchup_core::tr; use crate::cutting_plan::{CutReport, CuttingPlan}; -use flate2::{Compression, read::MultiGzDecoder, write::GzEncoder}; use benchup_core::Document; -use std::{ - io::{Read, Write}, - path::PathBuf, - sync::mpsc, -}; +use std::{path::PathBuf, sync::mpsc}; +// The tests build and inspect project bytes by hand. +#[cfg(test)] +use flate2::{Compression, read::MultiGzDecoder, write::GzEncoder}; +#[cfg(test)] +use std::io::{Read, Write}; #[cfg(all(test, not(target_arch = "wasm32")))] #[path = "project_file_tests.rs"] @@ -94,45 +94,17 @@ fn exchange_name(name: &str, extension: &str) -> String { } /// Portable previews use PNG with a private gzip document chunk. Headless saves -/// retain gzip JSON; recovery and save snapshots stay JSON. +/// retain gzip JSON; recovery and save snapshots stay JSON. The format lives in +/// `benchup_tools::files`, shared with the command-line tools. pub(crate) fn encode(json: &str) -> Result, String> { - let mut value: serde_json::Value = serde_json::from_str(json).map_err(|e| e.to_string())?; - let preview = value.as_object_mut().and_then(|v| v.remove("viewport_preview_png")); - let json = if preview.is_some() { serde_json::to_string(&value).map_err(|e| e.to_string())? } else { json.to_owned() }; - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(json.as_bytes()).map_err(|e| e.to_string())?; - let compressed = encoder.finish().map_err(|e| e.to_string())?; - if let Some(preview) = preview { - let mut png: Vec = serde_json::from_value(preview).map_err(|e| e.to_string())?; - // Insert a private ancillary chunk immediately before IEND. Standard PNG - // readers ignore it and display the saved GPU viewport thumbnail. - let end = png.len().checked_sub(12).ok_or(tr!("project_files.invalid_preview"))?; - let mut chunk = Vec::with_capacity(compressed.len() + 12); - chunk.extend_from_slice(&(u32::try_from(compressed.len()).map_err(|e| e.to_string())?).to_be_bytes()); - chunk.extend_from_slice(b"buPd"); - chunk.extend_from_slice(&compressed); - chunk.extend_from_slice(&png_crc(&chunk[4..]).to_be_bytes()); - png.splice(end..end, chunk); - Ok(png) - } else { Ok(compressed) } + benchup_tools::files::encode(json) } pub fn decode(bytes: &[u8]) -> Result { - let bytes = project_payload(bytes)?; - // Detect the content rather than the extension so all project extensions - // accept compressed files and older, plain JSON files unchanged. - let mut decompressed = Vec::new(); - let bytes: &[u8] = if bytes.starts_with(&[0x1f, 0x8b]) { - MultiGzDecoder::new(bytes) - .read_to_end(&mut decompressed) - .map_err(|error| tr!("project_files.could_not_decompress_project", error = error))?; - &decompressed - } else { - bytes - }; - let json = std::str::from_utf8(bytes).map_err(|_| tr!("project_files.the_project_is_not_utf_8_text"))?; - Document::from_json(json) + benchup_tools::files::decode(bytes) } +#[cfg(test)] +use benchup_tools::files::project_payload; #[cfg(not(target_arch = "wasm32"))] pub(crate) fn run( @@ -783,28 +755,3 @@ pub fn extract_preview(path: &std::path::Path, output: &std::path::Path) -> Resu image.save_with_format(output, image::ImageFormat::Png).map_err(|e| e.to_string()) } -fn png_crc(bytes: &[u8]) -> u32 { - let mut crc = !0u32; - for &byte in bytes { - crc ^= byte as u32; - for _ in 0..8 { crc = (crc >> 1) ^ (0xedb88320u32 & 0u32.wrapping_sub(crc & 1)); } - } - !crc -} - -fn project_payload(bytes: &[u8]) -> Result<&[u8], String> { - if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Ok(bytes); } - let mut position = 8usize; - while let Some(header) = bytes.get(position..position.saturating_add(8)) { - let length = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize; - let end = position.checked_add(12).and_then(|v| v.checked_add(length)).ok_or(tr!("project_files.invalid_chunk_size"))?; - let chunk = bytes.get(position..end).ok_or(tr!("project_files.truncated_chunk"))?; - if &header[4..8] == b"buPd" { - let checksum = u32::from_be_bytes(chunk[8 + length..].try_into().unwrap()); - if png_crc(&chunk[4..8 + length]) != checksum { return Err(tr!("project_files.invalid_checksum").into()); } - return Ok(&chunk[8..8 + length]); - } - position = end; - } - Err(tr!("project_files.png_has_no_project").into()) -} diff --git a/crates/benchup-cli/Cargo.toml b/crates/benchup-cli/Cargo.toml new file mode 100644 index 0000000..955af8a --- /dev/null +++ b/crates/benchup-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "benchup-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "Command-line access to BenchUp project files, for shells and AI agents" + +[[bin]] +name = "benchup" +path = "src/main.rs" + +[dependencies] +benchup-core = { path = "../benchup-core" } +benchup-tools = { path = "../benchup-tools" } +serde_json.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/benchup-cli/README.md b/crates/benchup-cli/README.md new file mode 100644 index 0000000..05a2cd1 --- /dev/null +++ b/crates/benchup-cli/README.md @@ -0,0 +1,7 @@ +# benchup (CLI) + +The `benchup` command reads and edits BenchUp `.bup` projects from a shell or an AI agent, and serves the same tools over MCP. It is built on [`benchup-tools`](../benchup-tools). + +- User documentation: [docs/cli.md](../../docs/cli.md) +- Guide for agents (also `benchup guide`): [docs/agent-guide.md](../../docs/agent-guide.md) +- Claude Code skill: [skill/benchup](skill/benchup/SKILL.md) diff --git a/crates/benchup-cli/skill/benchup/SKILL.md b/crates/benchup-cli/skill/benchup/SKILL.md new file mode 100644 index 0000000..a164757 --- /dev/null +++ b/crates/benchup-cli/skill/benchup/SKILL.md @@ -0,0 +1,16 @@ +--- +name: benchup +description: Design or edit furniture and woodworking projects in BenchUp (.bup files) with the `benchup` command-line tool. Use when the user mentions BenchUp, a .bup file, or asks to model, change, dimension or cost a piece of furniture (shelves, cabinets, tables, drawers, joints, cut lists). +--- + +# BenchUp projects + +Use the `benchup` CLI to read and edit `.bup` projects. It edits the file, not an open BenchUp window. + +1. Run `benchup guide` once and follow it. It explains the axes (the front faces -Y), joint roles, the batch workflow and recipes. +2. Read the project with `benchup scene file.bup`. Create a new one with `benchup new file.bup --name "..."`. +3. Make each change as one `benchup call file.bup --batch -` (one undo step), generating the JSON lines with a script for repeated parts. Use `--dry-run` to try first. +4. Verify with `benchup call file.bup check_model` and, for materials, `get_cut_list`. +5. Show the result with `benchup open file.bup`. + +If a size does not fit or the request is ambiguous, say so and propose an interpretation instead of forcing it. `benchup tools ` shows any tool's exact arguments. diff --git a/crates/benchup-cli/src/launch.rs b/crates/benchup-cli/src/launch.rs new file mode 100644 index 0000000..d21ee1a --- /dev/null +++ b/crates/benchup-cli/src/launch.rs @@ -0,0 +1,107 @@ +//! `benchup open`: hand a project to the BenchUp desktop app. +//! +//! The CLI does not link the app. It asks the operating system to open the +//! file with the installed app (macOS by bundle id, Linux and Windows through +//! the `.bup` file association), or starts a given executable directly. No +//! shell parses the path, so names with `&`, `%` or `^` open as they are. If the +//! app is already running, macOS hands it the file; the app reads the file +//! from disk again, asking first if the open project has unsaved changes. +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +pub const DOWNLOAD_URL: &str = "https://github.com/BenchUp3D/editor/releases/latest"; + +#[cfg(target_os = "macos")] +const BUNDLE_ID: &str = "io.github.BenchUp3D.BenchUp"; + +/// An absolute path to an existing `.bup` file, which is the only kind of +/// argument the app opens. +fn project_path(path: &str) -> Result { + let path = Path::new(path); + if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("bup")) { + return Err(format!("{} is not a .bup project; BenchUp only opens .bup files", path.display())); + } + let absolute = path.canonicalize().map_err(|e| format!("cannot open {}: {e}", path.display()))?; + if !absolute.is_file() { + return Err(format!("{} is not a file", path.display())); + } + Ok(plain_path(absolute)) +} + +/// Windows `canonicalize` gives a verbatim `\\?\C:\...` path, which file +/// associations and many apps do not accept; this is the usual form of it. +#[cfg(windows)] +fn plain_path(path: PathBuf) -> PathBuf { + let text = path.to_string_lossy().into_owned(); + if let Some(share) = text.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{share}")) + } else if let Some(local) = text.strip_prefix(r"\\?\").filter(|rest| rest.as_bytes().get(1) == Some(&b':')) { + PathBuf::from(local) + } else { + path + } +} + +#[cfg(not(windows))] +fn plain_path(path: PathBuf) -> PathBuf { + path +} + +/// The command the operating system uses to open `path` in the installed app. +fn system_command(path: &Path) -> Command { + #[cfg(target_os = "macos")] + { + let mut command = Command::new("open"); + command.arg("-b").arg(BUNDLE_ID).arg(path); + command + } + // Explorer opens the file with its association without a shell parsing + // the path (`cmd /C start` would split it at `&` and expand `%NAME%`). + #[cfg(target_os = "windows")] + { + let mut command = Command::new("explorer.exe"); + command.arg(path); + command + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let mut command = Command::new("xdg-open"); + command.arg(path); + command + } +} + +/// Opens `path` in BenchUp: in `app` (an executable) if given, otherwise in +/// the installed app. +pub fn open(path: &str, app: Option<&str>) -> Result { + let path = project_path(path)?; + let opened = path.display().to_string(); + if let Some(app) = app { + // Started detached: the app keeps running after this command exits, + // and its output does not mix with this command's JSON. + Command::new(app) + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("cannot start {app}: {e}"))?; + return Ok(json!({"ok": true, "opened": opened, "app": app})); + } + let mut command = system_command(&path); + let program = command.get_program().to_string_lossy().into_owned(); + let output = command + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("cannot run {program}: {e}. Is BenchUp installed? {DOWNLOAD_URL}"))?; + // Explorer exits with 1 even when it opened the file. + if !output.status.success() && !cfg!(target_os = "windows") { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(format!( + "could not open {opened} in BenchUp ({program}: {detail}). \ + Install the desktop app from {DOWNLOAD_URL}, or pass --app " + )); + } + Ok(json!({"ok": true, "opened": opened, "app": "BenchUp"})) +} diff --git a/crates/benchup-cli/src/main.rs b/crates/benchup-cli/src/main.rs new file mode 100644 index 0000000..f9416ff --- /dev/null +++ b/crates/benchup-cli/src/main.rs @@ -0,0 +1,289 @@ +//! `benchup`: read and edit BenchUp project files from a shell or an AI agent. +//! +//! Every command prints JSON on stdout. Exit status: 0 success, 1 the tool +//! reported an error (the file is unchanged), 2 usage or I/O error. +mod launch; +mod mcp; +mod project; + +use benchup_tools::{definitions, is_mutating, mcp_tools}; +use project::{Applied, Project}; +use serde_json::{Value, json}; +use std::io::{Read, stdin, stdout}; +use std::path::Path; +use std::process::ExitCode; + +const USAGE: &str = "\ +benchup: read and edit BenchUp (.bup) projects. Lengths are millimetres, Z is up. +New here? Run `benchup guide` first: conventions, workflow and woodworking recipes. + +usage: + benchup new [--name NAME] [--force] create an empty project + benchup guide how to model furniture with these tools + benchup tools [NAME] [--json] list tools, or show one tool's JSON schema + benchup scene scene summary (objects, stock, joints) + benchup get one entity in detail, with its faces + benchup call [ARGS|-] run a tool; ARGS is a JSON object (- reads stdin) + benchup call --batch run JSON lines {\"tool\": .., \"arguments\": {..}} + as one all-or-nothing undo step; \"$N.created[0]\" + in arguments is a value from call N (from 1) + benchup undo undo the last change + benchup redo redo the last undone change + benchup mcp serve the tools over MCP (stdio) + benchup open [--app EXECUTABLE] open the project in the BenchUp desktop app + (or in EXECUTABLE, default $BENCHUP_APP) + +options: + --dry-run (call) report the results without changing the file + --brief (call) leave out the object summaries of changing calls + --allow-shared edit the local copy of a shared project + +Every change is one undo step in BenchUp and the file is saved atomically. +Each command starts at the top level of the model: a group opened with +enter_object stays open only until the end of that command, so put enter_object +and the calls that need it in one --batch. +Output is JSON. Exit status: 0 ok, 1 tool error (file unchanged), 2 usage or I/O error."; + +/// What a command produced: JSON to print and whether it succeeded. +struct Output { + value: Value, + ok: bool, +} + +fn ok(value: Value) -> Output { + Output { value, ok: true } +} + +/// Splits `args` into positionals and the flags this command accepts. +struct Args { + positional: Vec, + flags: Vec, + values: Vec<(String, String)>, +} + +fn parse_args(args: Vec, flags: &[&str], valued: &[&str]) -> Result { + let mut out = Args { positional: Vec::new(), flags: Vec::new(), values: Vec::new() }; + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + if flags.contains(&arg.as_str()) { + out.flags.push(arg); + } else if valued.contains(&arg.as_str()) { + let value = it.next().ok_or(format!("{arg} needs a value"))?; + out.values.push((arg, value)); + } else if arg.starts_with("--") { + return Err(format!("unknown option {arg}")); + } else { + out.positional.push(arg); + } + } + Ok(out) +} + +impl Args { + fn flag(&self, name: &str) -> bool { + self.flags.iter().any(|f| f == name) + } + fn value(&self, name: &str) -> Option<&str> { + self.values.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str()) + } + /// Exactly `n` positionals, or a usage error. + fn expect(&self, n: usize) -> Result<&[String], String> { + if self.positional.len() == n { Ok(&self.positional) } else { Err(USAGE.into()) } + } +} + +fn read_input(source: &str) -> Result { + if source == "-" { + let mut text = String::new(); + stdin().read_to_string(&mut text).map_err(|e| format!("cannot read stdin: {e}"))?; + Ok(text) + } else { + std::fs::read_to_string(source).map_err(|e| format!("cannot read {source}: {e}")) + } +} + +fn parse_object(text: &str, what: &str) -> Result { + if text.trim().is_empty() { + return Ok(json!({})); + } + match serde_json::from_str::(text) { + Ok(value @ Value::Object(_)) => Ok(value), + Ok(_) => Err(format!("{what} must be a JSON object")), + Err(e) => Err(format!("{what} is not valid JSON: {e}")), + } +} + +/// One batch line: `{"tool": "move", "arguments": {...}}` (`name` is accepted for `tool`). +fn parse_batch(text: &str) -> Result, String> { + let mut calls = Vec::new(); + for (number, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let entry = parse_object(line, &format!("batch line {}", number + 1))?; + let name = entry + .get("tool") + .or_else(|| entry.get("name")) + .and_then(Value::as_str) + .ok_or(format!("batch line {}: missing \"tool\"", number + 1))?; + let arguments = entry.get("arguments").cloned().unwrap_or_else(|| json!({})); + if !arguments.is_object() { + return Err(format!("batch line {}: \"arguments\" must be an object", number + 1)); + } + calls.push((name.to_owned(), arguments)); + } + if calls.is_empty() { + return Err("the batch has no calls".into()); + } + Ok(calls) +} + +fn apply(project: &mut Project, calls: &[(String, Value)]) -> Result { + run_calls(project, calls, true) +} + +fn run_calls(project: &mut Project, calls: &[(String, Value)], save: bool) -> Result { + let batch = calls.len() > 1; + Ok(match project.run(calls, save)? { + Applied::Ok(mut results) if !batch => ok(results.pop().unwrap_or(Value::Null)), + Applied::Ok(results) => ok(json!({"ok": true, "results": results})), + Applied::Failed { result, .. } if !batch => Output { value: result, ok: false }, + Applied::Failed { index, result, earlier } => Output { + value: json!({ + "ok": false, + "failed_call": index + 1, + "tool": calls[index].0, + "result": result, + "earlier_results": earlier, + "note": "nothing was changed: earlier_results shows what the calls before it returned, then everything was rolled back; fix this call and run the whole batch again", + }), + ok: false, + }, + }) +} + +/// Drops the object summaries that changing tools attach to their results, +/// keeping ids, bounds, errors and the answers of reading tools. +fn brief(value: &mut Value) { + if let Some(map) = value.as_object_mut() { + if map.contains_key("created") { + map.remove("objects"); + } + for key in ["results", "result"] { + match map.get_mut(key) { + Some(Value::Array(items)) => items.iter_mut().for_each(brief), + Some(item @ Value::Object(_)) => brief(item), + _ => {} + } + } + } +} + +fn tools(args: Args) -> Result { + match args.positional.as_slice() { + [] if args.flag("--json") => Ok(ok(Value::Array(mcp_tools()))), + [] => { + let list: Vec = definitions() + .into_iter() + .map(|d| json!({"name": d.name, "changes_model": is_mutating(&d.name), "description": d.description})) + .collect(); + Ok(ok(Value::Array(list))) + } + [name] => definitions() + .into_iter() + .find(|d| &d.name == name) + .map(|d| ok(json!({"name": d.name, "description": d.description, "inputSchema": d.input_schema}))) + .ok_or(format!("unknown tool {name}; run `benchup tools` for the list")), + _ => Err(USAGE.into()), + } +} + +fn run(mut argv: Vec) -> Result { + if argv.is_empty() || matches!(argv[0].as_str(), "-h" | "--help" | "help") { + println!("{USAGE}"); + std::process::exit(if argv.is_empty() { 2 } else { 0 }); + } + let command = argv.remove(0); + let open = |args: &Args| Project::open(&args.positional[0], args.flag("--allow-shared")); + match command.as_str() { + "new" => { + let args = parse_args(argv, &["--force"], &["--name"])?; + let path = Path::new(&args.expect(1)?[0]); + let doc = Project::create(path, args.value("--name"), args.flag("--force"))?; + Ok(ok(json!({"ok": true, "created": path.display().to_string(), "name": doc.name}))) + } + "tools" => tools(parse_args(argv, &["--json"], &[])?), + "scene" => { + let args = parse_args(argv, &["--allow-shared"], &[])?; + args.expect(1)?; + apply(&mut open(&args)?, &[("get_scene".into(), json!({}))]) + } + "get" => { + let args = parse_args(argv, &["--allow-shared"], &[])?; + let id: u64 = args.expect(2)?[1].parse().map_err(|_| "the id must be a number".to_owned())?; + apply(&mut open(&args)?, &[("get_object".into(), json!({"id": id}))]) + } + "guide" => { + println!("{}", benchup_tools::GUIDE); + std::process::exit(0); + } + "call" => { + let args = parse_args(argv, &["--allow-shared", "--dry-run", "--brief"], &["--batch"])?; + let calls = match (args.value("--batch"), args.positional.as_slice()) { + (Some(source), [_]) => parse_batch(&read_input(source)?)?, + (None, [_, tool]) => vec![(tool.clone(), json!({}))], + (None, [_, tool, arguments]) => { + let text = if arguments == "-" { read_input("-")? } else { arguments.clone() }; + vec![(tool.clone(), parse_object(&text, "ARGS")?)] + } + _ => return Err(USAGE.into()), + }; + let mut output = run_calls(&mut open(&args)?, &calls, !args.flag("--dry-run"))?; + if args.flag("--dry-run") { + output.value["dry_run"] = json!(true); + } + if args.flag("--brief") { + brief(&mut output.value); + } + Ok(output) + } + "undo" | "redo" => { + let args = parse_args(argv, &["--allow-shared"], &[])?; + args.expect(1)?; + let value = open(&args)?.step_history(command == "redo")?; + let ok = value["ok"] == true; + Ok(Output { value, ok }) + } + "open" => { + let args = parse_args(argv, &[], &["--app"])?; + let path = &args.expect(1)?[0]; + let app = args.value("--app").map(str::to_owned).or_else(|| { + std::env::var("BENCHUP_APP").ok().filter(|app| !app.is_empty()) + }); + Ok(ok(launch::open(path, app.as_deref())?)) + } + "mcp" => { + let args = parse_args(argv, &["--allow-shared"], &[])?; + args.expect(1)?; + let mut project = open(&args)?; + eprintln!("benchup: serving {} over MCP on stdio", project.doc.name); + mcp::serve(stdin().lock(), stdout().lock(), &mut project).map_err(|e| e.to_string())?; + std::process::exit(0); + } + _ => Err(format!("unknown command {command}\n\n{USAGE}")), + } +} + +fn main() -> ExitCode { + match run(std::env::args().skip(1).collect()) { + Ok(output) => { + println!("{}", serde_json::to_string_pretty(&output.value).unwrap_or_default()); + if output.ok { ExitCode::SUCCESS } else { ExitCode::from(1) } + } + Err(message) => { + eprintln!("benchup: {message}"); + ExitCode::from(2) + } + } +} diff --git a/crates/benchup-cli/src/mcp.rs b/crates/benchup-cli/src/mcp.rs new file mode 100644 index 0000000..23153c8 --- /dev/null +++ b/crates/benchup-cli/src/mcp.rs @@ -0,0 +1,90 @@ +//! Newline-delimited JSON-RPC 2.0 for MCP over stdio. +use serde_json::{Value, json}; +use std::io::{BufRead, Write}; + +const SUPPORTED: [&str; 4] = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]; +const INSTRUCTIONS: &str = "Edits a BenchUp 3D woodworking project. Lengths are millimetres, Z is up. \ +Call get_scene first to see the model. Each mutating call is one undo step in BenchUp. \ +The session starts at the top level; a group opened with enter_object stays open until exit_to_root. \ +Call get_guide once for conventions (front is -y), joint roles and woodworking recipes."; + +/// Where tool calls go. Returns the tool result and whether it succeeded. +pub trait Backend { + fn call(&mut self, client: &str, name: &str, arguments: Value) -> (Value, bool); +} + +fn error(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} + +/// Handle one parsed message; `None` means no reply (notification). +fn handle(message: &Value, backend: &mut dyn Backend, client: &mut String) -> Option { + if !message.is_object() { + return Some(error(Value::Null, -32600, "invalid request")); + } + let id = message.get("id").cloned(); + let method = message.get("method").and_then(Value::as_str); + let Some(method) = method else { + // A response or malformed message: only malformed ones with an id get an error. + return id + .filter(|_| message.get("result").is_none() && message.get("error").is_none()) + .map(|id| error(id, -32600, "invalid request")); + }; + let id = id?; // notifications never get replies + let params = message.get("params").cloned().unwrap_or(Value::Null); + let ok = |result: Value| json!({"jsonrpc": "2.0", "id": id, "result": result}); + Some(match method { + "initialize" => { + let requested = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(""); + let version = SUPPORTED.iter().find(|v| **v == requested).unwrap_or(&SUPPORTED[0]); + if let Some(name) = params.pointer("/clientInfo/name").and_then(Value::as_str) { + *client = name.to_owned(); + } + ok(json!({ + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": false}}, + "serverInfo": {"name": "benchup", "version": env!("CARGO_PKG_VERSION")}, + "instructions": INSTRUCTIONS, + })) + } + "ping" => ok(json!({})), + "tools/list" => ok(json!({"tools": benchup_tools::mcp_tools()})), + "tools/call" => { + let Some(name) = params.get("name").and_then(Value::as_str) else { + return Some(error(id, -32602, "tools/call needs a tool name")); + }; + let arguments = params.get("arguments").cloned().unwrap_or_else(|| json!({})); + let (result, success) = backend.call(client, name, arguments); + let mut out = json!({ + "content": [{"type": "text", "text": result.to_string()}], + "isError": !success, + }); + if result.is_object() { + out["structuredContent"] = result; + } + ok(out) + } + _ => error(id, -32601, &format!("method not found: {method}")), + }) +} + +/// Run the stdio loop until the reader ends. +pub fn serve(reader: R, mut writer: W, backend: &mut dyn Backend) -> std::io::Result<()> { + let mut client = String::from("agent"); + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Err(_) => Some(error(Value::Null, -32700, "parse error")), + Ok(Value::Array(_)) => Some(error(Value::Null, -32600, "batch requests are not supported")), + Ok(message) => handle(&message, backend, &mut client), + }; + if let Some(reply) = reply { + writeln!(writer, "{reply}")?; + writer.flush()?; + } + } + Ok(()) +} diff --git a/crates/benchup-cli/src/project.rs b/crates/benchup-cli/src/project.rs new file mode 100644 index 0000000..74513c6 --- /dev/null +++ b/crates/benchup-cli/src/project.rs @@ -0,0 +1,382 @@ +//! A `.bup` file opened for tool calls. Every change is recorded in the +//! document history (so BenchUp can undo it) and saved atomically. +use benchup_core::{Document, Model}; +use benchup_tools::{Call, ToolEnv, execute, files, is_mutating}; +use serde_json::{Value, json}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; + +/// History label of agent changes, the English source of the `cli.agent_edit` +/// message; the app shows it in the user's language, as it does its own labels. +pub const HISTORY_LABEL: &str = "Agent edit"; + +pub const CHANGED_ON_DISK: &str = + "The project file changed on disk since it was opened (was it saved in BenchUp?). Run the command again."; + +/// Content fingerprint, to refuse overwriting a file somebody else changed. +fn stamp(bytes: &[u8]) -> u64 { + let mut hasher = DefaultHasher::new(); + bytes.hash(&mut hasher); + hasher.finish() +} + +pub struct Project { + path: PathBuf, + allow_shared: bool, + pub doc: Document, + env: ToolEnv, + stamp: u64, + counter: u64, + /// The file as last read or written; a PNG preview is kept when saving. + saved: Vec, +} + +/// Result of applying one or more calls. +pub enum Applied { + /// Every call succeeded; one result per call. + Ok(Vec), + /// Call `index` failed; nothing was changed or saved; `earlier` holds the results of the calls before it. + Failed { index: usize, result: Value, earlier: Vec }, +} + +impl Project { + pub fn open(path: impl Into, allow_shared: bool) -> Result { + let path = path.into(); + let bytes = std::fs::read(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let doc = load(&path, &bytes, allow_shared)?; + let env = ToolEnv { + catalog: doc.model.stock_materials.clone(), + selection: doc.selection.clone(), + ..ToolEnv::default() + }; + Ok(Self { path, allow_shared, doc, env, stamp: stamp(&bytes), counter: 0, saved: bytes }) + } + + /// Reads the file again if it changed on disk since it was last read or + /// written (saved in BenchUp while an MCP session has it open). The open + /// group stays open when it still exists. + pub fn refresh(&mut self) -> Result<(), String> { + let bytes = std::fs::read(&self.path).map_err(|e| format!("cannot read {}: {e}", self.path.display()))?; + if stamp(&bytes) == self.stamp { + return Ok(()); + } + let mut doc = load(&self.path, &bytes, self.allow_shared)?; + doc.model.edit_context = self.doc.model.edit_context.filter(|id| doc.model.objects.contains_key(id)); + self.env.catalog = doc.model.stock_materials.clone(); + self.env.selection = doc.selection.clone(); + self.doc = doc; + self.stamp = stamp(&bytes); + self.saved = bytes; + Ok(()) + } + + /// Writes a new, empty project. Refuses to replace an existing file unless `force`. + pub fn create(path: &Path, name: Option<&str>, force: bool) -> Result { + if path.exists() && !force { + return Err(format!("{} already exists (pass --force to replace it)", path.display())); + } + let mut doc = Document::default(); + doc.name = match name { + Some(name) => name.to_owned(), + None => path.file_stem().map_or(doc.name.clone(), |s| s.to_string_lossy().into_owned()), + }; + files::save_atomic(path, &files::encode_document(&doc)?)?; + Ok(doc) + } + + pub fn save(&mut self) -> Result<(), String> { + let current = std::fs::read(&self.path).map_err(|_| CHANGED_ON_DISK.to_owned())?; + if stamp(¤t) != self.stamp { + return Err(CHANGED_ON_DISK.to_owned()); + } + let bytes = files::reencode_like(&self.saved, &self.doc)?; + files::save_atomic(&self.path, &bytes)?; + self.stamp = stamp(&bytes); + self.saved = bytes; + Ok(()) + } + + /// Applies `calls` in order as one all-or-nothing step: if any call fails, + /// the document is left as it was. Changes become a single undo entry. + pub fn apply(&mut self, calls: &[(String, Value)]) -> Result { + self.run(calls, true) + } + + /// Like `apply`, but `save == false` leaves the file and the document as + /// they were (a dry run) while still reporting every result. + pub fn run(&mut self, calls: &[(String, Value)], save: bool) -> Result { + // Only changing tools touch more of the model than the open group, so + // reading calls skip the copy needed to roll back. + let model_before = calls.iter().any(|(name, _)| is_mutating(name)).then(|| self.doc.model.clone()); + let context_before = self.doc.model.edit_context; + let selection_before = self.env.selection.clone(); + self.env.before = None; + self.env.created.clear(); + // `$N` references only mean something in a batch; a single call's text is taken as written. + let batch = calls.len() > 1; + let mut results = Vec::with_capacity(calls.len()); + let mut mutated = false; + for (index, (name, arguments)) in calls.iter().enumerate() { + self.counter += 1; + let arguments = if batch { resolve(arguments, &results) } else { Ok(arguments.clone()) }; + let arguments = match arguments { + Ok(arguments) => arguments, + Err(error) => { + self.restore(model_before, context_before, selection_before); + return Ok(Applied::Failed { + index, + earlier: results, + result: json!({"ok": false, "error": error, + "hint": "Refer to an earlier call as \"$N.field\" (N counts calls from 1), e.g. \"$1.created[0]\" or \"$3.joint_id\"."}), + }); + } + }; + let call = Call::new(self.counter.to_string(), name, arguments); + let outcome = execute(&mut self.doc.model, &call, &mut self.env); + if !outcome.ok { + self.restore(model_before, context_before, selection_before); + return Ok(Applied::Failed { index, result: outcome.result, earlier: results }); + } + mutated |= outcome.mutated; + let mut result = outcome.result; + if name == "get_scene" { + result["project"] = json!({"name": self.doc.name, "file": self.path.display().to_string()}); + } + results.push(result); + } + if !save { + self.restore(model_before, context_before, selection_before); + return Ok(Applied::Ok(results)); + } + if let Some(before) = model_before.filter(|_| mutated) { + self.doc.history.record(HISTORY_LABEL, before, &self.doc.model); + } + let selection_changed = self.env.selection != selection_before; + self.doc.selection = self.env.selection.clone(); + if (mutated || selection_changed) + && let Err(error) = self.save() + { + // Back to the file as last read or written, which is this + // document before the calls (history included). + self.doc = files::decode(&self.saved)?; + self.doc.model.edit_context = context_before; + self.env.selection = selection_before; + return Err(error); + } + Ok(Applied::Ok(results)) + } + + /// Undoes the calls of a failed or dry run. + fn restore(&mut self, model: Option, context: Option, selection: std::collections::BTreeSet) { + if let Some(model) = model { + self.doc.model = model; + } + self.doc.model.edit_context = context; + self.env.selection = selection; + } +} + +/// The document stored in `bytes`, refusing a shared project unless allowed. +fn load(path: &Path, bytes: &[u8], allow_shared: bool) -> Result { + let doc = files::decode(bytes).map_err(|e| format!("cannot load {}: {e}", path.display()))?; + if doc.shared_project.is_some() && !allow_shared { + return Err(format!( + "{} is a shared project; edits made here would diverge from the server. \ + Pass --allow-shared to edit the local copy anyway.", + path.display() + )); + } + Ok(doc) +} + +/// One step of a reference after `$N`. +enum Step<'a> { + Field(&'a str), + Index(i64), +} + +/// `$N` followed by `.field` and `[index]` steps (N counts calls from 1, +/// negative indexes count from the end), or `None` when `text` is ordinary +/// text such as "$12.50 hardware" or "$5 shelf". +fn parse_reference(text: &str) -> Option<(usize, Vec>)> { + let body = text.strip_prefix('$')?; + let digits = body.find(|c: char| !c.is_ascii_digit()).unwrap_or(body.len()); + let call = body[..digits].parse().ok()?; + let mut rest = &body[digits..]; + let mut steps = Vec::new(); + while !rest.is_empty() { + if let Some(tail) = rest.strip_prefix('.') { + let end = tail.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).unwrap_or(tail.len()); + let field = &tail[..end]; + if !field.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + return None; + } + steps.push(Step::Field(field)); + rest = &tail[end..]; + } else if let Some(tail) = rest.strip_prefix('[') { + let end = tail.find(']')?; + steps.push(Step::Index(tail[..end].parse().ok()?)); + rest = &tail[end + 1..]; + } else { + return None; + } + } + Some((call, steps)) +} + +/// Replaces every `$N.path` reference with that value from an earlier result. +fn resolve(value: &Value, results: &[Value]) -> Result { + match value { + Value::String(text) => match parse_reference(text) { + Some((call, steps)) => reference(text, call, &steps, results), + None => Ok(value.clone()), + }, + // A reference to a list of ids inside a list is spliced in, so + // "ids": ["$1.created", "$2.created"] joins both lists. Other lists, + // such as a point, stay one item. + Value::Array(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + let is_reference = matches!(item, Value::String(t) if parse_reference(t).is_some()); + match resolve(item, results)? { + Value::Array(inner) if is_reference && inner.iter().all(|v| v.is_u64() || v.is_i64()) => { + out.extend(inner) + } + other => out.push(other), + } + } + Ok(Value::Array(out)) + } + Value::Object(map) => map + .iter() + .map(|(k, v)| resolve(v, results).map(|v| (k.clone(), v))) + .collect::>() + .map(Value::Object), + other => Ok(other.clone()), + } +} + +fn reference(text: &str, call: usize, steps: &[Step], results: &[Value]) -> Result { + let mut current = call + .checked_sub(1) + .and_then(|i| results.get(i)) + .ok_or(format!("{text} refers to call {call}, but only {} earlier calls ran", results.len()))?; + for step in steps { + let next = match step { + Step::Field(field) => current.get(field), + Step::Index(index) => { + let items = current.as_array().ok_or(format!("{text}: not a list"))?; + let index = if *index < 0 { items.len() as i64 + index } else { *index }; + usize::try_from(index).ok().and_then(|i| items.get(i)) + } + }; + current = next.ok_or(format!("{text} does not exist in the result of call {call}"))?; + } + Ok(current.clone()) +} + +impl Project { + /// Undo (`redo == false`) or redo the last history entry and save. + pub fn step_history(&mut self, redo: bool) -> Result { + let label = if redo { self.doc.history.redo_label() } else { self.doc.history.undo_label() } + .map(str::to_owned); + let changed = + if redo { self.doc.history.redo(&mut self.doc.model) } else { self.doc.history.undo(&mut self.doc.model) }; + if !changed { + let what = if redo { "redo" } else { "undo" }; + return Ok(json!({"ok": false, "error": format!("nothing to {what}")})); + } + // Undo may remove entities the saved selection still points at. + self.doc.selection.retain(|id| self.doc.model.contains_entity(id)); + self.env.selection = self.doc.selection.clone(); + self.save()?; + let key = if redo { "redone" } else { "undone" }; + let mut result = json!({"ok": true}); + result[key] = json!(label); + Ok(result) + } +} + +/// MCP: each `tools/call` is applied (and saved) on its own, on the file as +/// it is now on disk. +impl crate::mcp::Backend for Project { + fn call(&mut self, _client: &str, name: &str, arguments: Value) -> (Value, bool) { + if let Err(error) = self.refresh() { + return (json!({"ok": false, "error": error}), false); + } + match self.apply(&[(name.to_owned(), arguments)]) { + Ok(Applied::Ok(mut results)) => (results.pop().unwrap_or(Value::Null), true), + Ok(Applied::Failed { result, .. }) => (result, false), + Err(error) => (json!({"ok": false, "error": error}), false), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refuses_to_overwrite_a_file_changed_on_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p.bup"); + Project::create(&path, Some("P"), false).unwrap(); + let mut project = Project::open(&path, false).unwrap(); + // BenchUp saves the project while this session has it open. + let other = Document { name: "Saved elsewhere".into(), ..Document::default() }; + files::save_atomic(&path, &files::encode_document(&other).unwrap()).unwrap(); + + let board = json!({"name": "B", "origin_mm": [0, 0, 0], "size_mm": [10, 10, 10]}); + let error = project.apply(&[("create_board".into(), board)]).err().unwrap(); + assert_eq!(error, CHANGED_ON_DISK); + assert_eq!(files::decode(&std::fs::read(&path).unwrap()).unwrap().name, "Saved elsewhere"); + assert!(project.doc.model.root_entities().is_empty(), "the in-memory change is rolled back"); + } + + #[test] + fn mcp_calls_work_on_the_file_as_saved_elsewhere() { + use crate::mcp::Backend; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p.bup"); + Project::create(&path, Some("P"), false).unwrap(); + let mut project = Project::open(&path, false).unwrap(); + let other = Document { name: "Saved elsewhere".into(), ..Document::default() }; + files::save_atomic(&path, &files::encode_document(&other).unwrap()).unwrap(); + + let board = json!({"name": "B", "origin_mm": [0, 0, 0], "size_mm": [10, 10, 10]}); + let (result, ok) = project.call("agent", "create_board", board); + assert!(ok, "{result}"); + let saved = files::decode(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.name, "Saved elsewhere", "the outside save is kept"); + assert_eq!(saved.model.root_entities().len(), 1); + let (scene, _) = project.call("agent", "get_scene", json!({})); + assert_eq!(scene["project"]["name"], "Saved elsewhere"); + } + + #[test] + fn references_are_strict_and_only_lists_of_ids_are_spliced() { + let results = [json!({"created": [7, 8], "center_mm": [1.5, 2.0, 3.0]})]; + let text = json!({"text": "$12.50 hardware", "name": "$5 shelf", "note": "$1.", "cost": "$3.99"}); + assert_eq!(resolve(&text, &results).unwrap(), text); + let points = json!({"points_mm": ["$1.center_mm", [0, 0, 0]], "ids": ["$1.created", 9]}); + assert_eq!( + resolve(&points, &results).unwrap(), + json!({"points_mm": [[1.5, 2.0, 3.0], [0, 0, 0]], "ids": [7, 8, 9]}) + ); + assert_eq!(resolve(&json!("$1.created[-1]"), &results).unwrap(), json!(8)); + assert!(resolve(&json!("$2.created"), &results).is_err(), "a reference to a missing call is an error"); + } + + #[test] + fn a_single_call_takes_dollar_text_as_written() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p.bup"); + Project::create(&path, Some("P"), false).unwrap(); + let mut project = Project::open(&path, false).unwrap(); + let board = json!({"name": "$1", "origin_mm": [0, 0, 0], "size_mm": [10, 10, 10]}); + assert!(matches!(project.apply(&[("create_board".into(), board)]).unwrap(), Applied::Ok(_))); + let doc = files::decode(&std::fs::read(&path).unwrap()).unwrap(); + assert!(doc.model.objects.values().any(|o| o.name == "$1")); + assert_eq!(doc.history.labels().collect::>(), [HISTORY_LABEL]); + } +} diff --git a/crates/benchup-cli/tests/cli.rs b/crates/benchup-cli/tests/cli.rs new file mode 100644 index 0000000..26dacfd --- /dev/null +++ b/crates/benchup-cli/tests/cli.rs @@ -0,0 +1,382 @@ +//! End-to-end tests of the `benchup` binary against real project files. +use benchup_tools::files; +use serde_json::{Value, json}; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Output, Stdio}; + +fn benchup(args: &[&str], stdin: Option<&str>) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_benchup")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("run benchup"); + let mut input = child.stdin.take().unwrap(); + if let Some(text) = stdin { + input.write_all(text.as_bytes()).unwrap(); + } + drop(input); + child.wait_with_output().unwrap() +} + +fn json_out(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|_| { + panic!("stdout is not JSON: {}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)) + }) +} + +fn new_project(dir: &Path) -> String { + let path = dir.join("shelf.bup").display().to_string(); + let output = benchup(&["new", &path, "--name", "Shelf"], None); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + path +} + +fn board(name: &str, origin: [f64; 3], size: [f64; 3]) -> Value { + json!({"name": name, "origin_mm": origin, "size_mm": size}) +} + +fn object_count(path: &str) -> u64 { + json_out(&benchup(&["scene", path], None))["object_count"].as_u64().unwrap() +} + +#[test] +fn a_call_edits_the_file_as_one_undo_step() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let args = board("Side", [0., 0., 0.], [18., 300., 900.]).to_string(); + let output = benchup(&["call", &path, "create_board", &args], None); + assert!(output.status.success()); + let result = json_out(&output); + assert_eq!(result["ok"], true); + assert_eq!(result["objects"][0]["size_mm"], json!([18., 300., 900.])); + + let doc = files::decode(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(doc.name, "Shelf"); + doc.model.validate().unwrap(); + assert_eq!(doc.history.labels().collect::>(), ["Agent edit"]); + assert_eq!(object_count(&path), 1); +} + +#[test] +fn a_batch_is_all_or_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let good = format!( + "{}\n# comment\n\n{}\n", + json!({"tool": "create_board", "arguments": board("Left", [0., 0., 0.], [18., 300., 900.])}), + json!({"tool": "create_board", "arguments": board("Right", [782., 0., 0.], [18., 300., 900.])}), + ); + let output = benchup(&["call", &path, "--batch", "-"], Some(&good)); + assert!(output.status.success()); + assert_eq!(json_out(&output)["results"].as_array().unwrap().len(), 2); + let doc = files::decode(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(doc.history.labels().collect::>(), ["Agent edit"]); + + let before = std::fs::read(&path).unwrap(); + let bad = format!( + "{}\n{}\n", + json!({"tool": "create_board", "arguments": board("Top", [0., 0., 900.], [800., 300., 18.])}), + json!({"tool": "move", "arguments": {"ids": [999_999], "delta_mm": [0, 0, 1]}}), + ); + let output = benchup(&["call", &path, "--batch", "-"], Some(&bad)); + assert_eq!(output.status.code(), Some(1)); + let result = json_out(&output); + assert_eq!(result["failed_call"], 2); + assert_eq!(result["tool"], "move"); + assert_eq!(std::fs::read(&path).unwrap(), before, "a failed batch must not touch the file"); +} + +#[test] +fn tool_errors_exit_1_and_leave_the_file_alone() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let before = std::fs::read(&path).unwrap(); + let output = benchup(&["call", &path, "create_board", r#"{"name": "No size"}"#], None); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(json_out(&output)["ok"], false); + assert_eq!(std::fs::read(&path).unwrap(), before); + + let output = benchup(&["call", &path, "create_board", "{not json"], None); + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn undo_and_redo_step_through_history() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let args = board("Side", [0., 0., 0.], [18., 300., 900.]).to_string(); + benchup(&["call", &path, "create_board", &args], None); + + let undo = benchup(&["undo", &path], None); + assert_eq!(json_out(&undo), json!({"ok": true, "undone": "Agent edit"})); + assert_eq!(object_count(&path), 0); + assert_eq!(benchup(&["undo", &path], None).status.code(), Some(1)); + + let redo = benchup(&["redo", &path], None); + assert_eq!(json_out(&redo)["ok"], true); + assert_eq!(object_count(&path), 1); +} + +#[test] +fn new_refuses_to_replace_a_project() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + assert_eq!(benchup(&["new", &path], None).status.code(), Some(2)); + assert!(benchup(&["new", &path, "--force"], None).status.success()); +} + +#[test] +fn tools_lists_schemas() { + let list = json_out(&benchup(&["tools"], None)); + let names: Vec<_> = list.as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"create_board") && names.contains(&"get_scene")); + let one = json_out(&benchup(&["tools", "create_board"], None)); + assert_eq!(one["inputSchema"]["required"], json!(["name", "origin_mm", "size_mm"])); + assert_eq!(benchup(&["tools", "nope"], None).status.code(), Some(2)); +} + +#[test] +fn mcp_serves_tools_over_stdio() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let messages = [ + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "clientInfo": {"name": "test"}}}), + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "create_board", "arguments": board("Side", [0., 0., 0.], [18., 300., 900.])}}), + json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", + "params": {"name": "move", "arguments": {"ids": [999_999], "delta_mm": [1, 0, 0]}}}), + ]; + let input: String = messages.iter().map(|m| format!("{m}\n")).collect(); + let output = benchup(&["mcp", &path], Some(&input)); + assert!(output.status.success()); + let replies: Vec = + String::from_utf8(output.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 4, "notifications get no reply"); + assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18"); + assert!(replies[1]["result"]["tools"].as_array().unwrap().len() > 20); + assert_eq!(replies[2]["result"]["isError"], false); + assert_eq!(replies[3]["result"]["isError"], true); + assert_eq!(object_count(&path), 1); +} + +/// A stand-in for the desktop app that records the path it was asked to open. +#[cfg(unix)] +fn fake_app(dir: &Path) -> (String, std::path::PathBuf) { + use std::os::unix::fs::PermissionsExt; + let record = dir.join("opened.txt"); + let app = dir.join("fake-benchup-app"); + std::fs::write(&app, format!("#!/bin/sh\nprintf '%s' \"$1\" > '{}.tmp'\nmv '{0}.tmp' '{0}'\n", record.display())) + .unwrap(); + std::fs::set_permissions(&app, std::fs::Permissions::from_mode(0o755)).unwrap(); + (app.display().to_string(), record) +} + +/// The app is started detached, so wait for it to record its argument. +#[cfg(unix)] +fn opened_by(record: &Path) -> String { + for _ in 0..100 { + if let Ok(text) = std::fs::read_to_string(record) { + return text; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + panic!("the app was not started"); +} + +#[cfg(unix)] +#[test] +fn open_starts_the_app_with_the_absolute_project_path() { + let dir = tempfile::tempdir().unwrap(); + new_project(dir.path()); + let (app, record) = fake_app(dir.path()); + // A relative path, as typed in a shell, reaches the app as an absolute one. + let output = Command::new(env!("CARGO_BIN_EXE_benchup")) + .args(["open", "shelf.bup", "--app", &app]) + .current_dir(dir.path()) + .env_remove("BENCHUP_APP") + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let expected = dir.path().canonicalize().unwrap().join("shelf.bup").display().to_string(); + let result = json_out(&output); + assert_eq!(result["ok"], true); + assert_eq!(result["opened"], expected); + assert_eq!(opened_by(&record), expected); +} + +#[cfg(unix)] +#[test] +fn open_uses_the_benchup_app_variable() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let (app, record) = fake_app(dir.path()); + let output = Command::new(env!("CARGO_BIN_EXE_benchup")).args(["open", &path]).env("BENCHUP_APP", &app).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(json_out(&output)["app"], app); + assert!(opened_by(&record).ends_with("shelf.bup")); +} + +#[test] +fn open_refuses_missing_files_and_other_extensions() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing.bup").display().to_string(); + let output = benchup(&["open", &missing, "--app", "/nonexistent/app"], None); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("cannot open")); + + let text = dir.path().join("notes.txt"); + std::fs::write(&text, "not a project").unwrap(); + let output = benchup(&["open", &text.display().to_string(), "--app", "/nonexistent/app"], None); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("not a .bup project")); + + let output = benchup(&["open"], None); + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn open_reports_an_app_that_cannot_start() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let output = benchup(&["open", &path, "--app", "/nonexistent/benchup-app"], None); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("cannot start /nonexistent/benchup-app")); +} + +/// A project with one group "Pair" holding two boards; returns (group, inner board). +fn grouped_project(dir: &Path) -> (String, u64, u64) { + let path = new_project(dir); + let batch = [ + json!({"tool": "create_board", "arguments": board("A", [0., 0., 0.], [100., 100., 20.])}), + json!({"tool": "create_board", "arguments": board("B", [200., 0., 0.], [100., 100., 20.])}), + ]; + let input: String = batch.iter().map(|c| format!("{c}\n")).collect(); + let created = json_out(&benchup(&["call", &path, "--batch", "-"], Some(&input))); + let ids: Vec = created["results"].as_array().unwrap().iter().map(|r| r["created"][0].as_u64().unwrap()).collect(); + let grouped = json_out(&benchup(&["call", &path, "group", &json!({"ids": ids, "name": "Pair", "component": false}).to_string()], None)); + let group = grouped["created"][0].as_u64().unwrap(); + let inner = grouped["members"][0]["id"].as_u64().unwrap(); + (path, group, inner) +} + +#[test] +fn parts_inside_groups_are_edited_directly_and_an_open_group_lasts_one_command() { + let dir = tempfile::tempdir().unwrap(); + let (path, group, inner) = grouped_project(dir.path()); + // Editing a part inside a group needs no enter_object. + let rename = json!({"id": inner, "name": "Inner"}).to_string(); + assert!(benchup(&["call", &path, "rename", &rename], None).status.success()); + let doc = files::decode(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(doc.model.objects[&inner].name, "Inner"); + // Creating inside a group needs it open, in the same command. + assert!(benchup(&["call", &path, "enter_object", &json!({"id": group}).to_string()], None).status.success()); + let alone = json_out(&benchup(&["call", &path, "create_board", &board("C", [0., 500., 0.], [10., 10., 10.]).to_string()], None)); + let doc = files::decode(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(doc.model.parent(alone["created"][0].as_u64().unwrap()), None, "the group closed with its command"); + let batch = format!( + "{}\n{}\n", + json!({"tool": "enter_object", "arguments": {"id": group}}), + json!({"tool": "create_board", "arguments": board("D", [0., 700., 0.], [10., 10., 10.])}) + ); + let inside = json_out(&benchup(&["call", &path, "--batch", "-"], Some(&batch))); + let doc = files::decode(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(doc.model.parent(inside["results"][1]["created"][0].as_u64().unwrap()), Some(group)); + assert_eq!(doc.model.edit_context, None); +} + +#[test] +fn batch_calls_can_use_earlier_results() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let batch = [ + json!({"tool": "create_board", "arguments": board("Side", [0., 0., 0.], [18., 300., 900.])}), + json!({"tool": "create_board", "arguments": board("Shelf", [9., 0., 400.], [500., 300., 18.])}), + json!({"tool": "add_joint", "arguments": {"a": "$2.created[0]", "b": "$1.created[0]", "kind": "tongue_groove"}}), + json!({"tool": "group", "arguments": {"ids": ["$1.created", "$2.created"], "name": "Pair", "component": false}}), + ]; + let input: String = batch.iter().map(|c| format!("{c}\n")).collect(); + let output = benchup(&["call", &path, "--batch", "-"], Some(&input)); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stdout)); + let doc = files::decode(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(doc.model.connections.len(), 1); + assert_eq!(doc.history.labels().last().unwrap(), "Agent edit", "one undo step"); + + // A reference to a call that has not run fails the batch and changes nothing. + let bad = format!("{}\n", json!({"tool": "move", "arguments": {"ids": ["$3.created[0]"], "delta_mm": [1, 0, 0]}})); + let before = std::fs::read(&path).unwrap(); + let output = benchup(&["call", &path, "--batch", "-"], Some(&format!("{}\n{bad}", batch[0]))); + assert_eq!(output.status.code(), Some(1)); + assert!(json_out(&output)["result"]["error"].as_str().unwrap().contains("only 1 earlier")); + assert_eq!(json_out(&output)["earlier_results"].as_array().unwrap().len(), 1, "what ran before the failure"); + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[test] +fn dry_run_and_brief_output() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let before = std::fs::read(&path).unwrap(); + let args = board("Side", [0., 0., 0.], [18., 300., 900.]).to_string(); + let output = benchup(&["call", &path, "create_board", &args, "--dry-run"], None); + assert!(output.status.success()); + let result = json_out(&output); + assert_eq!(result["dry_run"], true); + assert!(result["objects"].is_array(), "a dry run reports the full result"); + assert_eq!(std::fs::read(&path).unwrap(), before, "and leaves the file alone"); + + let result = json_out(&benchup(&["call", &path, "create_board", &args, "--brief"], None)); + assert!(result["objects"].is_null(), "no object summaries"); + assert!(result["bounds_mm"].is_object(), "bounds stay"); + assert_eq!(result["created"].as_array().unwrap().len(), 1); + let scene = json_out(&benchup(&["call", &path, "get_scene", "--brief"], None)); + assert_eq!(scene["objects"].as_array().unwrap().len(), 1, "a brief scene keeps its objects"); +} + +#[test] +fn the_scene_names_the_project_and_the_guide_prints() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let scene = json_out(&benchup(&["scene", &path], None)); + assert_eq!(scene["project"]["name"], "Shelf"); + let guide = benchup(&["guide"], None); + assert!(guide.status.success()); + assert!(String::from_utf8_lossy(&guide.stdout).contains("## Joints")); +} + +/// What BenchUp writes when it has a GPU preview: a PNG with the project inside. +#[test] +fn projects_saved_with_a_preview_can_be_edited_and_keep_it() { + let chunk = |kind: &[u8; 4], data: &[u8]| -> Vec { + let mut c = (data.len() as u32).to_be_bytes().to_vec(); + c.extend_from_slice(kind); + c.extend_from_slice(data); + let crc = files::png_crc(&c[4..]); + c.extend_from_slice(&crc.to_be_bytes()); + c + }; + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(chunk(b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0])); + png.extend(chunk(b"IDAT", &[120, 156, 99, 96, 0, 0, 0, 2, 0, 1])); + png.extend(chunk(b"IEND", &[])); + let mut doc = benchup_core::Document::default(); + doc.name = "Preview".into(); + let mut value = serde_json::to_value(&doc).unwrap(); + value[files::PREVIEW_FIELD] = serde_json::to_value(&png).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("preview.bup"); + std::fs::write(&path, files::encode(&value.to_string()).unwrap()).unwrap(); + let path = path.display().to_string(); + + assert_eq!(json_out(&benchup(&["scene", &path], None))["project"]["name"], "Preview"); + let args = board("Side", [0., 0., 0.], [18., 300., 900.]).to_string(); + assert!(benchup(&["call", &path, "create_board", &args], None).status.success()); + let bytes = std::fs::read(&path).unwrap(); + assert!(files::has_preview(&bytes), "the file is still a PNG preview"); + assert_eq!(files::decode(&bytes).unwrap().model.objects.len(), 1); +} diff --git a/crates/benchup-core/build.rs b/crates/benchup-core/build.rs index ccd881a..47f896b 100644 --- a/crates/benchup-core/build.rs +++ b/crates/benchup-core/build.rs @@ -25,6 +25,7 @@ const HISTORY_LABEL_KEYS: &[&str] = &[ "app.paint_face", "app.paint_group", "app.paste", + "cli.agent_edit", "construction_guides.delete_guides", "context_menu.clean_aligned_vertices", "context_menu.duplicate", diff --git a/crates/benchup-tools/src/files.rs b/crates/benchup-tools/src/files.rs index 0704417..0b6b5e8 100644 --- a/crates/benchup-tools/src/files.rs +++ b/crates/benchup-tools/src/files.rs @@ -1,25 +1,63 @@ -//! `.bup` project files: gzip-compressed JSON of a [`Document`]. Plain JSON -//! (older files, recovery snapshots) is accepted when reading. +//! `.bup` project files, shared by the app and the agent tools. A project is +//! JSON of a [`Document`], stored in one of three ways, detected by content: +//! +//! * a PNG viewport preview with the gzip-compressed JSON in a private `buPd` +//! chunk before `IEND` (what the app saves when it has a GPU preview); +//! * gzip-compressed JSON (headless saves, the CLI); +//! * plain JSON (older files, recovery snapshots). use benchup_core::{Document, tr}; use flate2::{Compression, read::MultiGzDecoder, write::GzEncoder}; use std::io::{Read, Write}; +const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; +const DOCUMENT_CHUNK: &[u8; 4] = b"buPd"; +/// Temporary JSON field the app uses to hand its preview image to [`encode`]. +pub const PREVIEW_FIELD: &str = "viewport_preview_png"; + /// Serialize `doc` as the app does when saving, then compress it. pub fn encode_document(doc: &Document) -> Result, String> { let json = serde_json::to_string(doc).map_err(|error| error.to_string())?; encode(&json) } -/// Project files use gzip-compressed JSON; recovery and save snapshots stay JSON. +/// Gzip-compressed JSON; a PNG with the document chunk when the JSON carries a +/// preview image in [`PREVIEW_FIELD`] (the field itself is not stored). pub fn encode(json: &str) -> Result, String> { - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder - .write_all(json.as_bytes()) - .and_then(|_| encoder.finish()) - .map_err(|error| tr!("project_files.could_not_compress_project", error = error)) + let mut value: serde_json::Value = serde_json::from_str(json).map_err(|e| e.to_string())?; + let preview = value.as_object_mut().and_then(|v| v.remove(PREVIEW_FIELD)); + let json = if preview.is_some() { serde_json::to_string(&value).map_err(|e| e.to_string())? } else { json.to_owned() }; + let compressed = gzip(&json)?; + match preview { + Some(preview) => { + let png: Vec = serde_json::from_value(preview).map_err(|e| e.to_string())?; + with_document_chunk(&png, &compressed) + } + None => Ok(compressed), + } +} + +/// Encode `doc` in the same container as `original`: a PNG keeps its preview +/// image (now stale) with the document chunk replaced; anything else becomes +/// gzip-compressed JSON. Lets a tool edit a file without dropping its preview. +pub fn reencode_like(original: &[u8], doc: &Document) -> Result, String> { + let json = serde_json::to_string(doc).map_err(|error| error.to_string())?; + let compressed = gzip(&json)?; + if !original.starts_with(PNG_SIGNATURE) { + return Ok(compressed); + } + let mut image = Vec::with_capacity(original.len()); + image.extend_from_slice(PNG_SIGNATURE); + for chunk in chunks(original) { + let chunk = chunk?; + if &chunk[4..8] != DOCUMENT_CHUNK { + image.extend_from_slice(chunk); + } + } + with_document_chunk(&image, &compressed) } pub fn decode(bytes: &[u8]) -> Result { + let bytes = project_payload(bytes)?; // Detect the content rather than the extension so all project extensions // accept compressed files and older, plain JSON files unchanged. let mut decompressed = Vec::new(); @@ -35,10 +73,94 @@ pub fn decode(bytes: &[u8]) -> Result { Document::from_json(json) } +/// Whether `bytes` is a PNG preview file (it may still lack a project). +pub fn has_preview(bytes: &[u8]) -> bool { + bytes.starts_with(PNG_SIGNATURE) +} + +fn gzip(json: &str) -> Result, String> { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(json.as_bytes()) + .and_then(|_| encoder.finish()) + .map_err(|error| tr!("project_files.could_not_compress_project", error = error)) +} + +/// Insert the document as a private ancillary chunk immediately before IEND. +/// Standard PNG readers ignore it and display the saved viewport thumbnail. +fn with_document_chunk(png: &[u8], compressed: &[u8]) -> Result, String> { + let end = png.len().checked_sub(12).ok_or(tr!("project_files.invalid_preview"))?; + let mut chunk = Vec::with_capacity(compressed.len() + 12); + chunk.extend_from_slice(&(u32::try_from(compressed.len()).map_err(|e| e.to_string())?).to_be_bytes()); + chunk.extend_from_slice(DOCUMENT_CHUNK); + chunk.extend_from_slice(compressed); + chunk.extend_from_slice(&png_crc(&chunk[4..]).to_be_bytes()); + let mut out = png.to_vec(); + out.splice(end..end, chunk); + Ok(out) +} + +/// The PNG's chunks (length, type, data and CRC), after the signature, up to +/// and including `IEND`. Bytes after `IEND` are not part of the image (tools +/// sometimes append some) and are ignored. +fn chunks(bytes: &[u8]) -> impl Iterator> { + let mut position = PNG_SIGNATURE.len(); + let mut done = false; + std::iter::from_fn(move || { + if done { + return None; + } + let header = bytes.get(position..position.saturating_add(8))?; + let length = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize; + let chunk = position + .checked_add(12) + .and_then(|v| v.checked_add(length)) + .ok_or_else(|| tr!("project_files.invalid_chunk_size").to_string()) + .and_then(|end| { + let chunk = bytes.get(position..end).ok_or_else(|| tr!("project_files.truncated_chunk").to_string())?; + position = end; + Ok(chunk) + }); + done = chunk.is_err() || &header[4..8] == b"IEND"; + Some(chunk) + }) +} + +pub fn png_crc(bytes: &[u8]) -> u32 { + let mut crc = !0u32; + for &byte in bytes { + crc ^= byte as u32; + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb88320u32 & 0u32.wrapping_sub(crc & 1)); + } + } + !crc +} + +/// The stored document: the `buPd` chunk of a PNG, or the bytes themselves. +pub fn project_payload(bytes: &[u8]) -> Result<&[u8], String> { + if !bytes.starts_with(PNG_SIGNATURE) { + return Ok(bytes); + } + // Stops at the document chunk, so nothing after it needs to be readable. + for chunk in chunks(bytes) { + let chunk = chunk?; + if &chunk[4..8] == DOCUMENT_CHUNK { + let length = chunk.len() - 12; + let checksum = u32::from_be_bytes(chunk[8 + length..].try_into().unwrap()); + if png_crc(&chunk[4..8 + length]) != checksum { + return Err(tr!("project_files.invalid_checksum").into()); + } + return Ok(&chunk[8..8 + length]); + } + } + Err(tr!("project_files.png_has_no_project").into()) +} + /// Write `bytes` to `path` through a temporary file next to the real file and /// a rename, so a crash never leaves a half-written project. A symlink is /// followed (the link stays, its target is replaced) and an existing file's -/// permissions are kept. +/// permissions are kept; a new file gets the usual ones (0o666 less the umask). #[cfg(not(target_arch = "wasm32"))] pub fn save_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { let existed = path.exists(); @@ -47,7 +169,15 @@ pub fn save_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { .parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or(std::path::Path::new(".")); - let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + let mut builder = tempfile::Builder::new(); + #[cfg(unix)] + if !existed { + use std::os::unix::fs::PermissionsExt; + // The temporary file is created with these, less the umask, as + // `std::fs::write` would create the project. + builder.permissions(std::fs::Permissions::from_mode(0o666)); + } + let mut temporary = builder.tempfile_in(parent).map_err(|e| e.to_string())?; temporary .write_all(bytes) .and_then(|_| temporary.as_file().sync_all()) @@ -55,13 +185,6 @@ pub fn save_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { if existed { let permissions = std::fs::metadata(&real).map_err(|e| e.to_string())?.permissions(); std::fs::set_permissions(temporary.path(), permissions).map_err(|e| e.to_string())?; - } else { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o644)) - .map_err(|e| e.to_string())?; - } } temporary.persist(&real).map_err(|e| e.to_string())?; #[cfg(unix)] @@ -86,6 +209,53 @@ mod tests { assert_eq!(decode(&plain).unwrap().name, "Bench"); } + /// A minimal valid PNG: signature, IHDR, IDAT and IEND. + fn tiny_png() -> Vec { + let chunk = |kind: &[u8; 4], data: &[u8]| -> Vec { + let mut c = (data.len() as u32).to_be_bytes().to_vec(); + c.extend_from_slice(kind); + c.extend_from_slice(data); + let crc = png_crc(&c[4..]); + c.extend_from_slice(&crc.to_be_bytes()); + c + }; + let mut png = PNG_SIGNATURE.to_vec(); + png.extend(chunk(b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0])); + png.extend(chunk(b"IDAT", &[120, 156, 99, 96, 0, 0, 0, 2, 0, 1])); + png.extend(chunk(b"IEND", &[])); + png + } + + #[test] + fn png_previews_carry_the_project_and_survive_an_edit() { + let mut doc = Document::default(); + doc.name = "Bench".into(); + let mut value = serde_json::to_value(&doc).unwrap(); + value[PREVIEW_FIELD] = serde_json::to_value(tiny_png()).unwrap(); + let saved = encode(&value.to_string()).unwrap(); + assert!(has_preview(&saved)); + assert!(saved.ends_with(&tiny_png()[tiny_png().len() - 12..]), "IEND stays last"); + assert_eq!(decode(&saved).unwrap().name, "Bench"); + + doc.name = "Edited".into(); + let edited = reencode_like(&saved, &doc).unwrap(); + assert!(has_preview(&edited), "the preview image is kept"); + assert_eq!(decode(&edited).unwrap().name, "Edited"); + assert_eq!(edited.windows(4).filter(|w| *w == DOCUMENT_CHUNK).count(), 1, "one document chunk"); + // Files without a preview stay gzip JSON. + assert!(reencode_like(&encode_document(&doc).unwrap(), &doc).unwrap().starts_with(&[0x1f, 0x8b])); + // A preview without a project is reported, not misread. + assert!(decode(&tiny_png()).is_err()); + + // Bytes appended after IEND are ignored when reading and when editing. + let mut padded = edited.clone(); + padded.extend_from_slice(b"trailing metadata bytes"); + assert_eq!(decode(&padded).unwrap().name, "Edited"); + let reedited = reencode_like(&padded, &doc).unwrap(); + assert_eq!(decode(&reedited).unwrap().name, "Edited"); + assert!(reedited.ends_with(&tiny_png()[tiny_png().len() - 12..]), "IEND stays last"); + } + #[test] fn corrupt_input_is_an_error_not_a_panic() { assert!(decode(&[0x1f, 0x8b, 1, 2, 3]).is_err()); @@ -117,9 +287,13 @@ mod tests { assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); assert_eq!(std::fs::read(&real).unwrap(), b"new"); assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o640); + // A new file gets the mode std::fs::write gives it under this umask. let fresh = dir.path().join("fresh.bup"); save_atomic(&fresh, b"x").unwrap(); - assert_eq!(std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777, 0o644); - assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 3); + let usual = dir.path().join("usual"); + std::fs::write(&usual, b"x").unwrap(); + let mode = |path: &std::path::Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&fresh), mode(&usual)); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 4); } } diff --git a/crates/benchup-tools/src/lib.rs b/crates/benchup-tools/src/lib.rs index bb61765..1b8c877 100644 --- a/crates/benchup-tools/src/lib.rs +++ b/crates/benchup-tools/src/lib.rs @@ -13,8 +13,12 @@ //! The crate has no network or UI dependencies and builds for `wasm32`. pub mod files; pub mod ops; +pub mod review; pub mod scene; +/// Conventions, workflow and recipes for agents (`get_guide`, `benchup guide`). +pub const GUIDE: &str = include_str!("../../../docs/agent-guide.md"); + pub use ops::{ Call, ToolDefinition, ToolEnv, ToolOutcome, definitions, execute, is_known, is_mutating, mcp_tools, openai_tools, diff --git a/crates/benchup-tools/src/ops.rs b/crates/benchup-tools/src/ops.rs index 14a41c5..932eaf8 100644 --- a/crates/benchup-tools/src/ops.rs +++ b/crates/benchup-tools/src/ops.rs @@ -99,10 +99,15 @@ const TOOLS: &[(&str, bool)] = &[ ("get_object", false), ("get_selection", false), ("list_stock_materials", false), + ("check_model", false), + ("get_cut_list", false), + ("get_guide", false), + ("create_stock_material", true), ("create_board", true), ("create_copy", true), ("draw_polyline", true), ("move", true), + ("move_to", true), ("rotate", true), ("scale", true), ("resize_board", true), @@ -116,6 +121,8 @@ const TOOLS: &[(&str, bool)] = &[ ("paint", true), ("erase", true), ("add_joint", true), + ("remove_joint", true), + ("flip_joint", true), ("add_dimension", true), ("add_text", true), ("select", false), @@ -153,6 +160,9 @@ fn tag_name(nullable: bool) -> Value { json!({"type": kind, "maxLength": MAX_TAG_BYTES, "description": format!("At most {MAX_TAG_BYTES} bytes (UTF-8); not \"Untagged\".")}) } +fn anchor_enum() -> Value { + json!({"type": "string", "enum": ["min", "center", "max"]}) +} fn axis_enum() -> Value { json!({"type": "string", "enum": ["x", "y", "z"]}) } @@ -225,44 +235,54 @@ pub fn definitions() -> Vec { let joint_kinds = json!(["mortise_tenon", "half_lap", "tongue_groove", "rabbet", "dovetail", "finger"]); vec![ def("get_scene", "Return the whole scene summary again (objects, selection, stock, joints). Lengths are in mm.", json!({}), &[]), - def("find_objects", "Search objects by name, tag, material, kind (group/component) or id. Use when the scene summary was truncated.", - json!({"query": text(), "limit": {"type": "integer", "minimum": 1, "maximum": 100}}), &["query"]), - def("get_object", "Details of one entity, including its faces (id, normal, center, area) for push_pull. Face normals point out of the part; a positive push_pull distance moves a face outward.", + def("find_objects", "Search objects (groups and components) when the scene summary was truncated. query matches part of a name, tag or material name, a kind (\"group\" or \"component\") or an exact id; omit it or pass \"\" to list every object. Dimensions and texts are not objects.", + json!({"query": text(), "limit": {"type": "integer", "minimum": 1, "maximum": 100}}), &[]), + def("get_object", "Details of one entity, including its faces (id, normal, facing, center_mm, area_mm2) for push_pull. Face normals point out of the part; a positive push_pull distance moves a face outward.", json!({"id": id()}), &["id"]), def("get_selection", "Entities currently selected in the editor.", json!({}), &[]), - def("list_stock_materials", "Stock materials that can be assigned to boards.", json!({}), &[]), - def("create_board", "Create one rectangular board as a named group. origin_mm is the minimum corner; size_mm is [x, y, z] extents. Prefer this over drawing.", + def("list_stock_materials", "Stock materials that can be assigned to boards (ids for material_id). A new project has none: add them with create_stock_material.", json!({}), &[]), + def("check_model", "Look for problems before building: boards passing through others or overlapping without a joint, joints that cannot be cut, look reversed or lost a board, thickness that does not match the material, open solids, loose geometry, boards without material. passed is true when there are no errors or warnings (ok only says the check ran). Run it after a change.", json!({}), &[]), + def("get_cut_list", "Cut list: boards grouped by material and blank size (length along the grain x width x thickness, mm) with quantities, and area totals per material.", json!({}), &[]), + def("get_guide", "Conventions, workflow and woodworking recipes for building furniture with these tools. Read it before the first change.", json!({}), &[]), + def("create_stock_material", "Add a stock material (sheet or board) to this project and return its id for material_id. Sizes in mm; length runs along the grain.", + json!({"name": text(), "thickness_mm": number(), "width_mm": number(), "length_mm": number()}), + &["name", "thickness_mm", "width_mm", "length_mm"]), + def("create_board", "Create one rectangular board as a named group. origin_mm is the minimum corner; size_mm is [x, y, z] extents. Prefer this over drawing. Returns the board id in created[0].", json!({"name": text(), "origin_mm": vec3(), "size_mm": vec3(), - "material_id": {"type": "string", "description": "id from list_stock_materials"}, + "material_id": {"type": "string", "format": "uuid", "description": "id from list_stock_materials or create_stock_material"}, "grain_axis": axis_enum(), "tag": tag_name(false)}), &["name", "origin_mm", "size_mm"]), - def("create_copy", "Copy an object or group, shifted by offset_mm. Returns the new ids.", + def("create_copy", "Copy an object or group, shifted by offset_mm; a copy of a component is a linked instance. Returns the copy in created[0]. Joints inside a copied object come along; joints with boards outside it do not.", json!({"id": id(), "offset_mm": vec3()}), &["id", "offset_mm"]), - def("draw_polyline", "Draw loose connected edges through points; a closed loop makes a face. Not grouped.", + def("draw_polyline", "Draw connected edges through points in the current scope; a closed flat loop makes a face (face_id in the result). Drawn on a board's face (inside it, via enter_object), a line splits that face. To make a shaped board, draw its closed outline, push_pull the face to the thickness, then group created ids.", json!({"points_mm": {"type": "array", "items": vec3(), "minItems": 2}, "close": {"type": "boolean"}}), &["points_mm"]), - def("move", "Translate objects by delta_mm.", json!({"ids": ids(), "delta_mm": vec3()}), &["ids", "delta_mm"]), - def("rotate", "Rotate objects about an axis through pivot_mm.", + def("move", "Translate objects by delta_mm. Joints follow the boards.", json!({"ids": ids(), "delta_mm": vec3()}), &["ids", "delta_mm"]), + def("move_to", "Move an object so the minimum corner of its bounding box lands on origin_mm.", + json!({"id": id(), "origin_mm": vec3()}), &["id", "origin_mm"]), + def("rotate", "Rotate objects about an axis through pivot_mm. Positive degrees follow the right-hand rule: looking from the positive end of the axis, counter-clockwise (about x, +y turns towards +z; about z, +x turns towards +y).", json!({"ids": ids(), "pivot_mm": vec3(), "axis": axis_enum(), "degrees": number()}), &["ids", "pivot_mm", "axis", "degrees"]), def("scale", "Uniformly scale objects about center_mm.", json!({"ids": ids(), "center_mm": vec3(), "factor": {"type": "number", "minimum": MIN_SCALE, "maximum": MAX_SCALE}}), &["ids", "center_mm", "factor"]), - def("resize_board", "Resize a board to size_mm (world-axis extents), keeping its minimum corner fixed.", - json!({"id": id(), "size_mm": vec3()}), &["id", "size_mm"]), - def("push_pull", "Extrude a face outward by distance_mm (see get_object for face ids). Negative cuts into the part.", + def("resize_board", "Resize a board to size_mm (world-axis extents). anchor says which side stays put on each axis: min (default), center or max; one value for all axes or [x, y, z]. Joints follow the board.", + json!({"id": id(), "size_mm": vec3(), "anchor": {"oneOf": [anchor_enum(), + {"type": "array", "items": anchor_enum(), "minItems": 3, "maxItems": 3}]}}), &["id", "size_mm"]), + def("push_pull", "Extrude a face outward by distance_mm (see get_object for face ids). Negative cuts into the part; cutting by the full thickness removes that material. Works on faces inside groups without enter_object. Returns the moved face as face_id and everything it made in created.", json!({"face_id": id(), "distance_mm": number()}), &["face_id", "distance_mm"]), def("rename", "Rename an object.", json!({"id": id(), "name": text()}), &["id", "name"]), def("set_material", "Assign a stock material to objects, or null to clear.", - json!({"ids": ids(), "material_id": {"type": ["string", "null"]}}), &["ids", "material_id"]), + json!({"ids": ids(), "material_id": {"type": ["string", "null"], "format": "uuid", + "description": "id from list_stock_materials or create_stock_material"}}), &["ids", "material_id"]), def("set_grain", "Set the grain direction of an object along a world axis, or null to clear.", json!({"id": id(), "axis": {"type": ["string", "null"], "enum": ["x", "y", "z", null]}}), &["id", "axis"]), - def("group", "Group objects, or make them a component (linked copies). Member ids change; see the result.", + def("group", "Group entities into one object, or make that object a component. Several ids become ONE group or component; for linked copies, make one component and use create_copy. Faces bring their edges along. Member ids change: the group is created[0] and its objects are listed in members.", json!({"ids": ids(), "name": text(), "component": {"type": "boolean"}}), &["ids", "name", "component"]), def("ungroup", "Dissolve groups/components one level.", json!({"ids": ids()}), &["ids"]), def("tag", "Assign a tag by name (created if missing), or null to clear.", json!({"ids": ids(), "tag": tag_name(true)}), &["ids", "tag"]), def("paint", "Paint objects or faces with an RGB color.", json!({"ids": ids(), "rgb": {"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": 255}, "minItems": 3, "maxItems": 3}}), &["ids", "rgb"]), - def("erase", "Delete entities.", json!({"ids": ids()}), &["ids"]), - def("add_joint", "Add a wood joint between two boards. The boards must already overlap where they meet (the overlap sets the joint depth). Optional params tune the joint (ratios 0-1, clearance in mm).", + def("erase", "Delete entities (objects, faces, edges, dimensions, texts). Joints that use an erased board are removed with it; use remove_joint to delete a joint alone.", json!({"ids": ids()}), &["ids"]), + def("add_joint", "Add a wood joint between two boards that already overlap where they meet; the overlap is the joint's depth. a carries the tenon, tongue, tails, fingers or lap: the board that goes into the other (shelf, apron, drawer side). b receives the mortise, groove or sockets (side, leg, drawer front). params.depth_axis is the direction a goes into b; width_axis is the axis across which the tongue, tenon or fingers are spread (width_ratio of it); the third axis is the height (height_ratio, mortise_tenon only). Axes are 0=x, 1=y, 2=z (a's own axes if it is rotated). Omit them and the first orientation that can be cut is chosen. Params by kind: mortise_tenon uses width_ratio and height_ratio; tongue_groove and rabbet use width_ratio; dovetail uses dovetails, width_ratio (share of the width taken by tails) and taper_degrees; finger uses fingers; all use clearance_mm. Ranges: ratios 0.1-0.9 (0.5), fingers 2-16 (4), dovetails 1-16 (1), taper_degrees 0-30 (8), clearance_mm 0-5 (0.1). The joint must be cuttable or the call fails; joints on one board must not cross, touch or end in the same plane. Both boards must be at the same group level. Returns joint_id and the orientation used; get_object on a joint shows its settings and supported orientations.", json!({"a": id(), "b": id(), "kind": {"type": "string", "enum": joint_kinds}, "params": {"type": "object", "properties": { "width_ratio": number(), "height_ratio": number(), @@ -272,18 +292,50 @@ pub fn definitions() -> Vec { "width_axis": {"type": "integer", "minimum": 0, "maximum": 2}}, "additionalProperties": false}}), &["a", "b", "kind"]), - def("add_dimension", "Add a dimension annotation between two points, drawn offset_mm away.", - json!({"start_mm": vec3(), "end_mm": vec3(), "offset_mm": vec3()}), &["start_mm", "end_mm", "offset_mm"]), - def("add_text", "Add a text note anchored at point_mm.", + def("remove_joint", "Delete wood joints by id (from the joints list in get_scene); the boards stay.", + json!({"ids": ids()}), &["ids"]), + def("flip_joint", "Swap a joint's a and b (the board that carries the tenon or tongue and the one that receives it), keeping its kind and settings and re-picking the orientation. Use it when check_model reports joint_roles_reversed.", + json!({"ids": ids()}), &["ids"]), + def("add_dimension", "Add a dimension between two points. It measures the straight distance between them, or only along axis (x, y or z) when given. A point exactly on a vertex, edge or face attaches to it and follows it when the part moves; there is no snapping to nearby points. offset_mm moves the dimension line away from the measured points, perpendicular to them (e.g. [0, -150, 0] draws it 150 mm in front). Returns length_mm and whether each end attached.", + json!({"start_mm": vec3(), "end_mm": vec3(), "offset_mm": vec3(), "axis": axis_enum()}), &["start_mm", "end_mm", "offset_mm"]), + def("add_text", "Add a text note anchored at point_mm; offset_mm places the label away from the anchor with a leader line ([0, 0, 0] puts it on the point).", json!({"point_mm": vec3(), "offset_mm": vec3(), "text": text()}), &["point_mm", "offset_mm", "text"]), def("select", "Select entities in the editor so the designer sees them.", json!({"ids": ids()}), &["ids"]), - def("enter_object", "Open a group/component for editing its inner parts.", json!({"id": id()}), &["id"]), + def("enter_object", "Open a group/component so that creating tools (create_board, draw_polyline, add_dimension...) put new parts inside it. Editing tools reach parts inside groups by themselves when all the ids share one group. Nested groups open from the outside in. The group stays open until exit_to_root or the end of the session (with the benchup CLI, one command or --batch).", json!({"id": id()}), &["id"]), def("exit_to_root", "Leave any open group and edit the top level again.", json!({}), &[]), ] } // ---------------------------------------------------------------- arguments +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum Anchor { + Min, + Center, + Max, +} +#[derive(Deserialize)] +#[serde(untagged)] +enum AnchorArg { + All(Anchor), + PerAxis([Anchor; 3]), +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MoveToArgs { + id: u64, + origin_mm: V3, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StockArgs { + name: String, + thickness_mm: f64, + width_mm: f64, + length_mm: f64, +} + #[derive(Deserialize)] #[serde(rename_all = "lowercase")] enum AxisArg { @@ -331,6 +383,7 @@ struct Empty {} #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct FindArgs { + #[serde(default)] query: String, limit: Option, } @@ -342,8 +395,22 @@ struct IdArg { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct IdsArg { + /// `"id": 5` is accepted too: agents guess the singular for one id. + #[serde(alias = "id", deserialize_with = "one_or_many")] ids: Vec, } +fn one_or_many<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(u64), + Many(Vec), + } + Ok(match OneOrMany::deserialize(deserializer)? { + OneOrMany::One(id) => vec![id], + OneOrMany::Many(ids) => ids, + }) +} #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CreateBoardArgs { @@ -392,6 +459,7 @@ struct ScaleArgs { struct ResizeArgs { id: u64, size_mm: V3, + anchor: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -462,6 +530,7 @@ struct DimensionArgs { start_mm: V3, end_mm: V3, offset_mm: V3, + axis: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -493,13 +562,29 @@ fn finite(value: f64, what: &str) -> Result { // ---------------------------------------------------------------- id checks +/// How to reach `id` from the current editing scope: the objects to open, from +/// the outside in, after leaving the current scope if `id` is not inside it. fn unreachable_hint(model: &Model, id: u64) -> String { - match model.parent(id) { - Some(parent) if model.objects.contains_key(&parent) => { - format!("{id} is inside object {parent}: call enter_object{{id:{parent}}} first, or exit_to_root to edit the top level.") + let mut chain = Vec::new(); + let mut current = model.parent(id).filter(|o| model.objects.contains_key(o)); + while let Some(object) = current { + if Some(object) == model.edit_context || chain.len() > 256 { + break; } - _ => "Call exit_to_root, then retry.".into(), + chain.push(object); + current = model.parent(object).filter(|o| model.objects.contains_key(o)); } + let Some(&parent) = chain.first() else { + return "Call exit_to_root, then retry.".into(); + }; + chain.reverse(); + let leave = if model.edit_context.is_some() && current != model.edit_context { "exit_to_root, then " } else { "" }; + let steps: Vec = chain.iter().map(|o| format!("enter_object{{id:{o}}}")).collect(); + format!( + "{id} is inside object {parent}: call {leave}{} first, in the same session \ + (with the benchup CLI, in the same --batch).", + steps.join(", then ") + ) } /// Resolve ids that must exist and be visible; mutating use also needs them @@ -655,7 +740,17 @@ fn draw_polyline(model: &mut Model, a: PolylineArgs) -> Res { let before = known(model); model.draw_polyline(&points, false)?; let created: Vec = known(model).difference(&before).copied().collect(); - Ok(Done { created, ..Default::default() }) + let faces: Vec = created.iter().copied().filter(|id| model.faces.contains_key(id)).collect(); + let mut done = Done { created, ..Default::default() }; + // A closed loop makes a face; name it so push_pull can follow in a batch. + if let [face] = faces[..] { + done.extra.insert("face_id".into(), json!(face)); + } + done.extra.insert("face_ids".into(), json!(faces)); + // Where each new face is, so a split face's pieces can be told apart. + let detail: Vec = faces.iter().filter_map(|f| scene::face_entry(model, *f)).collect(); + done.extra.insert("faces".into(), Value::Array(detail)); + Ok(done) } fn move_objects(model: &mut Model, a: MoveArgs) -> Res { @@ -697,8 +792,56 @@ fn resize_board(model: &mut Model, a: ResizeArgs) -> Res { } model.scale_selection_constrained(&set, lo, new / old, Some(AxisConstraint::Axis(axis)))?; } + let anchors = match a.anchor { + None => [Anchor::Min; 3], + Some(AnchorArg::All(anchor)) => [anchor; 3], + Some(AnchorArg::PerAxis(anchors)) => anchors, + }; + let grown = target - current; + let shift = DVec3::from_array(std::array::from_fn(|i| match anchors[i] { + Anchor::Min => 0., + Anchor::Center => -grown[i] / 2., + Anchor::Max => -grown[i], + })); + if shift.length() > 1.0e-12 { + model.move_selection(&set, shift)?; + } Ok(modified(&set)) } +fn move_to(model: &mut Model, a: MoveToArgs) -> Res { + let id = one_object(model, a.id)?; + let set = BTreeSet::from([id]); + let (lo, _) = model.selection_bounds(&set).ok_or("the object has no geometry")?; + let delta = point(a.origin_mm)? - lo; + if delta.length() > 1.0e-12 { + model.move_selection(&set, delta)?; + } + Ok(modified(&set)) +} +fn create_stock_material(model: &mut Model, env: &mut ToolEnv, a: StockArgs) -> Res { + let name = checked_name(&a.name)?; + let mut tenths = [0_u64; 3]; + for (slot, (value, what)) in tenths.iter_mut().zip([(a.thickness_mm, "thickness_mm"), (a.width_mm, "width_mm"), (a.length_mm, "length_mm")]) { + let value = finite(value, what)?; + if !(0.1..=100_000.).contains(&value) { + return Err(fail(format!("{what} out of range"), "Use a size between 0.1 mm and 100 m.")); + } + *slot = (value * 10.).round() as u64; + } + if let Some((id, _)) = model.stock_materials.iter().chain(env.catalog.iter()).find(|(_, m)| m.name == name && m.dimensions == tenths) { + let mut done = Done::default(); + done.extra.insert("material_id".into(), json!(id)); + done.extra.insert("note".into(), json!("A material with this name and size already exists; reusing it.")); + return Ok(done); + } + let id = MaterialId::new_v4(); + let material = StockMaterial { name, dimensions: tenths }; + model.stock_materials.insert(id, material.clone()); + env.catalog.insert(id, material); + let mut done = Done::default(); + done.extra.insert("material_id".into(), json!(id)); + Ok(done) +} fn push_pull(model: &mut Model, a: PushPullArgs) -> Res { let face = a.face_id; if !model.faces.contains_key(&face) { @@ -721,6 +864,7 @@ fn push_pull(model: &mut Model, a: PushPullArgs) -> Res { ancestor = model.parent(owner); } let distance = from_mm(finite(a.distance_mm, "distance_mm")?) * scene::outward_sign(model, face); + let existing: BTreeSet = model.faces.keys().chain(model.edges.keys()).copied().collect(); let owner = model.parent(face); let saved = model.edit_context; // Faces inside an object are edited through that object's scope. @@ -735,11 +879,13 @@ fn push_pull(model: &mut Model, a: PushPullArgs) -> Res { }; let new_face = result?; let mut done = Done::default(); + done.created = model.faces.keys().chain(model.edges.keys()).copied().filter(|id| !existing.contains(id)).collect(); if let Some(owner) = owner.filter(|o| model.objects.contains_key(o)) { done.modified.push(owner); } if model.faces.contains_key(&new_face) { done.extra.insert("new_face_id".into(), json!(new_face)); + done.extra.insert("face_id".into(), json!(new_face)); } Ok(done) } @@ -765,7 +911,30 @@ fn set_grain(model: &mut Model, a: GrainArgs) -> Res { Ok(modified(&BTreeSet::from([id]))) } fn group(model: &mut Model, a: GroupArgs) -> Res { - let set = entities(model, &a.ids, true)?; + let mut set = entities(model, &a.ids, true)?; + // A face without its edges would leave them loose and duplicate them in the group. + let rings: Vec> = set + .iter() + .filter_map(|id| model.faces.get(id)) + .flat_map(|face| std::iter::once(face.vertices.clone()).chain(face.holes.clone())) + .collect(); + if !rings.is_empty() { + let mut sides = BTreeSet::new(); + for ring in &rings { + for (i, a) in ring.iter().enumerate() { + let b = ring[(i + 1) % ring.len()]; + sides.insert(((*a).min(b), (*a).max(b))); + } + } + set.extend( + model + .edges + .values() + .filter(|e| sides.contains(&(e.a.min(e.b), e.a.max(e.b)))) + .map(|e| e.id) + .filter(|id| model.entity_in_context(*id) == Some(*id) && !model.is_locked(*id)), + ); + } let name = checked_name(&a.name)?; let group = model .make_object(&set, a.component, &name) @@ -780,7 +949,12 @@ fn group(model: &mut Model, a: GroupArgs) -> Res { "members".into(), Value::Array(members.iter().filter_map(|id| scene::object_entry(model, *id)).collect()), ); - done.extra.insert("note".into(), json!("Member ids may have changed; use the ids in members.")); + let note = if done.extra["members"].as_array().is_some_and(Vec::is_empty) { + "Made a new part from the faces and edges: it is created[0]. check_model tells whether it is a closed solid." + } else { + "Member ids may have changed; use the ids in members." + }; + done.extra.insert("note".into(), json!(note)); Ok(done) } fn ungroup(model: &mut Model, a: IdsArg) -> Res { @@ -817,27 +991,157 @@ fn paint(model: &mut Model, a: PaintArgs) -> Res { Ok(modified(&set)) } fn erase(model: &mut Model, a: IdsArg) -> Res { + if let Some(joint) = a.ids.iter().find(|id| model.connections.contains_key(id)) { + return Err(fail( + format!("{joint} is a joint, not an entity"), + "Use remove_joint to delete a joint. Erasing a board also removes its joints.", + )); + } let set = entities(model, &a.ids, true)?; + let intact: Vec = model + .connections + .iter() + .filter(|(_, c)| c.members.iter().all(|m| model.contains_entity(m))) + .map(|(id, _)| *id) + .collect(); model.erase(&set); let erased: Vec = set.iter().copied().filter(|id| !model.contains_entity(id)).collect(); if erased.is_empty() { return Err("nothing was erased".into()); } - Ok(Done { erased, ..Default::default() }) + // The core keeps joints whose boards are gone (BenchUp3D/editor#36); + // remove the ones this call left without a board. + let orphaned: Vec = intact + .into_iter() + .filter(|id| model.connections[id].members.iter().any(|m| !model.contains_entity(m))) + .collect(); + for id in &orphaned { + model.connections.remove(id); + } + let mut done = Done { erased, ..Default::default() }; + if !orphaned.is_empty() { + done.extra.insert("removed_joints".into(), json!(orphaned)); + } + Ok(done) +} +fn flip_joint(model: &mut Model, env: &ToolEnv, a: IdsArg) -> Res { + if a.ids.is_empty() || a.ids.len() > MAX_IDS { + return Err(fail("bad ids", format!("Pass between 1 and {MAX_IDS} joint ids."))); + } + let mut boards = BTreeSet::new(); + let mut orientations = Vec::new(); + for id in &a.ids { + let Some(connection) = model.connections.get(id).cloned() else { + return Err(fail(format!("{id} is not a joint"), "Use ids from the joints list in get_scene.")); + }; + let ConnectionOperation::Joinery(mut settings) = connection.operation else { + return Err(fail(format!("{id} is not a wood joint"), "Only wood joints can be flipped.")); + }; + let members = [connection.members[1], connection.members[0]]; + for board in members { + if !model.contains_entity(&board) { + return Err(fail(format!("board {board} of joint {id} was erased"), "Remove the joint with remove_joint.")); + } + if model.is_locked(board) { + return Err(fail(format!("board {board} of joint {id} is locked"), "Locked entities cannot be changed; tell the designer to unlock it.")); + } + if !leased(model, env, board) { + return Err(fail("not-leased", "select it first so you hold the edit lease")); + } + boards.insert(board); + } + let supported = model.supported_joinery_axes(members, &settings); + if let Some((d, w)) = preferred_axes(model, members, &settings).filter(|axes| supported.contains(axes)).or(supported.first().copied()) { + (settings.depth_axis, settings.width_axis) = (d, w); + } + orientations.push(json!({"joint": id, "a": members[0], "b": members[1], "depth_axis": settings.depth_axis, "width_axis": settings.width_axis})); + // The entry face belonged to the old first board. + model.connections.insert(*id, Connection { members, entry_face: None, operation: ConnectionOperation::Joinery(settings) }); + } + let errors = scene::joint_errors(model); + if let Some((id, error)) = a.ids.iter().find_map(|id| errors.get(id).map(|e| (id, e))) { + return Err(fail(format!("joint {id} cannot be cut once flipped: {error}"), "Check the overlap, or remove the joint and add it again with other params.")); + } + let mut done = modified(&boards); + done.extra.insert("flipped".into(), json!(orientations)); + Ok(done) +} +fn remove_joint(model: &mut Model, env: &ToolEnv, a: IdsArg) -> Res { + if a.ids.is_empty() || a.ids.len() > MAX_IDS { + return Err(fail("bad ids", format!("Pass between 1 and {MAX_IDS} joint ids."))); + } + let mut boards = BTreeSet::new(); + for id in &a.ids { + let Some(connection) = model.connections.get(id) else { + return Err(fail(format!("{id} is not a joint"), "Use ids from the joints list in get_scene.")); + }; + if !matches!(connection.operation, ConnectionOperation::Joinery(_)) { + return Err(fail(format!("{id} is not a wood joint"), "Only wood joints can be removed with this tool.")); + } + // A joint whose board was erased has nothing left to protect. + for board in connection.members.iter().filter(|b| model.contains_entity(b)) { + if model.is_locked(*board) { + return Err(fail(format!("board {board} of joint {id} is locked"), "Locked entities cannot be changed; tell the designer to unlock it.")); + } + if !leased(model, env, *board) { + return Err(fail("not-leased", "select it first so you hold the edit lease")); + } + boards.insert(*board); + } + } + for id in &a.ids { + model.connections.remove(id); + } + let mut done = modified(&boards); + done.extra.insert("removed_joints".into(), json!(a.ids)); + Ok(done) +} +/// The orientation a woodworker would pick, in the first board's axes. Depth +/// is the axis along which `a` goes into `b` (where the overlap is shortest +/// compared with `a`). Tongues, tenons and rabbets are spread across `a`'s +/// thickness; dovetails, fingers and laps across its broad side. +fn preferred_axes(model: &Model, members: [u64; 2], settings: &JointSettings) -> Option<(usize, usize)> { + let overlap = model.joinery_options(members, settings).0?; + let (lo, hi) = model.joinery_frame(members[0]).ok()?.bounds(model, members[0])?; + let (board, shared) = (hi - lo, overlap.bounds.1 - overlap.bounds.0); + let depth = (0..3).min_by(|x, y| (shared[*x] / board[*x]).total_cmp(&(shared[*y] / board[*y])))?; + let (p, q) = ((depth + 1) % 3, (depth + 2) % 3); + let (thin, broad) = if board[p] <= board[q] { (p, q) } else { (q, p) }; + let width = match settings.kind { + JointKind::TongueGroove | JointKind::MortiseTenon | JointKind::Rabbet => thin, + JointKind::Dovetail | JointKind::Finger | JointKind::HalfLap => broad, + }; + Some((depth, width)) } fn add_joint(model: &mut Model, a: JointArgs) -> Res { if a.a == a.b { return Err(fail("a and b must differ", "A joint connects two different boards.")); } + if model.contains_entity(&a.a) && model.contains_entity(&a.b) && model.parent(a.a) != model.parent(a.b) { + return Err(fail( + format!("{} and {} are in different groups", a.a, a.b), + "A joint connects boards at the same level: a board inside a group or component instance cannot be joined \ + to one outside it. Join them before grouping (grouping keeps joints), or keep them as separate boards.", + )); + } let set = objects(model, &[a.a, a.b], true)?; + for id in [a.a, a.b] { + if let Err(error) = model.joinery_frame(id) { + return Err(fail( + format!("{id} is not a single board: {error}"), + "Join the boards themselves; get_object on a group lists its children.", + )); + } + } let mut settings = JointSettings { kind: a.kind.kind(), ..Default::default() }; + let mut chosen_axes = false; if let Some(p) = a.params { let ratio = |value: Option, what: &str| -> Result, Fail> { value .map(|v| { - (v.is_finite() && v > 0. && v <= 1.) + (v.is_finite() && (0.1..=0.9).contains(&v)) .then_some(v) - .ok_or_else(|| Fail::from(format!("{what} must be between 0 and 1"))) + .ok_or_else(|| Fail::from(format!("{what} must be between 0.1 and 0.9"))) }) .transpose() }; @@ -848,10 +1152,16 @@ fn add_joint(model: &mut Model, a: JointArgs) -> Res { settings.height_ratio = v; } if let Some(v) = p.fingers { - settings.fingers = v.clamp(1, 64); + if !(2..=16).contains(&v) { + return Err("fingers must be between 2 and 16".into()); + } + settings.fingers = v; } if let Some(v) = p.dovetails { - settings.dovetails = v.clamp(1, 64); + if !(1..=16).contains(&v) { + return Err("dovetails must be between 1 and 16".into()); + } + settings.dovetails = v; } if let Some(v) = p.taper_degrees { settings.taper_degrees = finite(v, "taper_degrees")?.clamp(0., 30.); @@ -867,20 +1177,99 @@ fn add_joint(model: &mut Model, a: JointArgs) -> Res { *slot = axis; } } + chosen_axes = p.depth_axis.is_some() || p.width_axis.is_some(); + } + let members = [a.a, a.b]; + let orientations = |model: &Model, s: &JointSettings| -> String { + let list: Vec = model.supported_joinery_axes(members, s).iter().map(|(d, w)| format!("{{depth_axis:{d}, width_axis:{w}}}")).collect(); + if list.is_empty() { "none".into() } else { list.join(", ") } + }; + if settings.depth_axis == settings.width_axis { + return Err(fail("depth_axis and width_axis must differ", format!("Orientations that fit these boards: {}.", orientations(model, &settings)))); + } + if model.joinery_options([a.a, a.b], &settings).0.is_none() { + return Err(fail( + "these boards do not overlap", + "Make a enter b where they meet (e.g. a shelf 9 mm into a side); the overlap is the joint's depth.", + )); + } + let supported = model.supported_joinery_axes([a.a, a.b], &settings); + if !chosen_axes { + if let Some(axes) = preferred_axes(model, members, &settings).filter(|axes| supported.contains(axes)) { + (settings.depth_axis, settings.width_axis) = axes; + } + } + if !supported.contains(&(settings.depth_axis, settings.width_axis)) { + match supported.first() { + Some((d, w)) if !chosen_axes => (settings.depth_axis, settings.width_axis) = (*d, *w), + Some(_) => { + return Err(fail( + "this orientation cannot cut these boards", + format!("Orientations that fit: {}.", orientations(model, &settings)), + )); + } + None => { + return Err(fail( + "no orientation of this joint fits these boards", + "Check the overlap and the params (a smaller clearance, fewer dovetails), or choose another kind.", + )); + } + } } let joint = model.add_connection(Connection { members: [a.a, a.b], entry_face: None, - operation: ConnectionOperation::Joinery(settings), + operation: ConnectionOperation::Joinery(settings.clone()), })?; + if let Some(error) = scene::joint_errors(model).remove(&joint) { + // Only joints whose shared region touches this one's can be the cause. + let region = |pair: [u64; 2]| -> Option<(DVec3, DVec3)> { + let (a, b) = (model.selection_bounds(&BTreeSet::from([pair[0]]))?, model.selection_bounds(&BTreeSet::from([pair[1]]))?); + Some((a.0.max(b.0), a.1.min(b.1))) + }; + let near = |x: (DVec3, DVec3), y: (DVec3, DVec3)| (y.0 - x.1).max_element() <= 2.0e-3 && (x.0 - y.1).max_element() <= 2.0e-3; + let mine = region(members); + let others: Vec = model + .connections + .iter() + .filter(|(id, c)| **id != joint && c.members.iter().any(|m| members.contains(m))) + .filter(|(_, c)| matches!((mine, region(c.members)), (Some(x), Some(y)) if near(x, y))) + .map(|(id, c)| format!("{id} ({} between {} and {})", scene::joint_kind(c), c.members[0], c.members[1])) + .collect(); + let shaped = members.iter().any(|m| model.expand_entities(&BTreeSet::from([*m])).iter().filter(|e| model.faces.contains_key(e)).count() > 6); + let hint = if others.is_empty() && shaped { + "No other joint touches this one; the board's own geometry is the problem: a shaped board with many small faces, \ + or vertices very close to the joint's planes. Change the number of segments of a curve (try 24 or 48), or move the joint a few millimetres." + .to_owned() + } else if others.is_empty() { + format!("Orientations that fit: {}. Check the overlap and params.", orientations(model, &settings)) + } else { + format!( + "These boards already have joints {}. Joints on one board must not cross, touch or end in the same plane: \ + keep 1 to 2 mm of wood between them (for example, stop a shelf short of the back).", + others.join(", ") + ) + }; + return Err(fail(format!("the joint cannot be cut: {error}"), hint)); + } let mut done = modified(&set); done.joints.push(joint); done.extra.insert("joint_id".into(), json!(joint)); + done.extra.insert("a".into(), json!(a.a)); + done.extra.insert("b".into(), json!(a.b)); + done.extra.insert("orientation".into(), json!({"depth_axis": settings.depth_axis, "width_axis": settings.width_axis})); Ok(done) } fn add_dimension(model: &mut Model, a: DimensionArgs) -> Res { - let id = model.add_dimension(point(a.start_mm)?, point(a.end_mm)?, point(a.offset_mm)?)?; - Ok(Done { created: vec![id], ..Default::default() }) + let axis = a.axis.as_ref().map(AxisArg::axis); + let id = model.add_dimension_with_axis(point(a.start_mm)?, point(a.end_mm)?, point(a.offset_mm)?, axis)?; + let mut done = Done { created: vec![id], ..Default::default() }; + if let Some(dimension) = model.dimensions.get(&id) { + done.extra.insert("length_mm".into(), json!(scene::mm(dimension.length(model)))); + done.extra.insert("start_attached".into(), json!(!dimension.start.weights.is_empty())); + done.extra.insert("end_attached".into(), json!(!dimension.end.weights.is_empty())); + } + Ok(done) } fn add_text(model: &mut Model, a: TextArgs) -> Res { if a.text.chars().count() > MAX_TEXT_CHARS { @@ -936,8 +1325,27 @@ fn run(model: &mut Model, env: &mut ToolEnv, name: &str, arguments: &str) -> Res } "list_stock_materials" => { parse::(arguments)?; - read(json!({"materials": scene::stock_list(model, &env.catalog)})) + let materials = scene::stock_list(model, &env.catalog); + let mut out = json!({"materials": materials}); + if out["materials"].as_array().is_some_and(Vec::is_empty) { + out["note"] = json!("No stock materials yet; add one with create_stock_material."); + } + read(out) } + "check_model" => { + parse::(arguments)?; + read(crate::review::check(model)) + } + "get_cut_list" => { + parse::(arguments)?; + read(crate::review::cut_list(model)) + } + "get_guide" => { + parse::(arguments)?; + read(json!({"guide": crate::GUIDE})) + } + "create_stock_material" => create_stock_material(model, env, parse(arguments)?), + "move_to" => move_to(model, parse(arguments)?), "create_board" => create_board(model, env, parse(arguments)?), "create_copy" => create_copy(model, parse(arguments)?), "draw_polyline" => draw_polyline(model, parse(arguments)?), @@ -955,6 +1363,8 @@ fn run(model: &mut Model, env: &mut ToolEnv, name: &str, arguments: &str) -> Res "paint" => paint(model, parse(arguments)?), "erase" => erase(model, parse(arguments)?), "add_joint" => add_joint(model, parse(arguments)?), + "remove_joint" => remove_joint(model, env, parse(arguments)?), + "flip_joint" => flip_joint(model, env, parse(arguments)?), "add_dimension" => add_dimension(model, parse(arguments)?), "add_text" => add_text(model, parse(arguments)?), "select" => { @@ -1013,12 +1423,50 @@ fn error_outcome(error: String, hint: Option) -> ToolOutcome { /// Tools that only add new entities: nothing existing is touched, so there is /// nothing to lease. (`create_copy` reads its source without changing it.) const CREATING: &[&str] = &["create_board", "create_copy", "draw_polyline", "add_dimension", "add_text"]; +/// Editing tools that open the group around their targets by themselves. +const AUTO_SCOPE: &[&str] = &[ + "move", "move_to", "rotate", "scale", "resize_board", "rename", "set_material", "set_grain", + "group", "ungroup", "tag", "paint", "erase", "add_joint", "create_copy", +]; + +/// The group to open so that all `ids` are editable, if they share one that is +/// reachable from the current scope through unlocked, visible groups. +fn auto_scope(model: &Model, ids: &[u64]) -> Option { + if ids.is_empty() || !ids.iter().all(|id| model.contains_entity(id)) { + return None; + } + if ids.iter().all(|id| model.entity_in_context(*id) == Some(*id)) { + return None; + } + let owner = model.parent(ids[0]).filter(|o| model.objects.contains_key(o))?; + if ids.iter().any(|id| model.parent(*id) != Some(owner)) { + return None; + } + let top = model.entity_in_context(owner)?; + let mut current = Some(owner); + for _ in 0..256 { + let object = current?; + if model.is_hidden(object) || model.is_locked(object) { + return None; + } + if object == top { + return Some(owner); + } + current = model.parent(object); + } + None +} /// Existing entities a mutating call names, read from the raw arguments. fn target_ids(name: &str, arguments: &str) -> Vec { if CREATING.contains(&name) { return Vec::new(); } + named_ids(arguments) +} + +/// Every id the raw arguments name (ids, id, face_id, a, b). +fn named_ids(arguments: &str) -> Vec { let Ok(Value::Object(map)) = serde_json::from_str::(arguments) else { return Vec::new() }; let mut ids: Vec = map.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect(); ids.extend(["id", "face_id", "a", "b"].iter().filter_map(|key| map.get(*key)?.as_u64())); @@ -1149,7 +1597,20 @@ pub fn execute(model: &mut Model, call: &Call, env: &mut ToolEnv) -> ToolOutcome // `catch_unwind` cannot help on wasm32, where a panic aborts. Every tool // therefore validates its arguments (counts, ranges, lengths) before it // touches the model, and the result is range-checked below. + // Editing parts inside a group: open that group for this call only, as the + // designer would, when every id shares it and the way there is editable. + let saved_context = model.edit_context; + let opened = mutating && AUTO_SCOPE.contains(&name) && { + let owner = auto_scope(model, &named_ids(arguments)); + if let Some(owner) = owner { + model.edit_context = Some(owner); + } + owner.is_some() + }; let attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(model, env, name, arguments))); + if opened { + model.edit_context = saved_context; + } let result = match attempt { Ok(result) => result, Err(_) => Err(Fail::from("internal error while applying the tool")), diff --git a/crates/benchup-tools/src/ops_tests.rs b/crates/benchup-tools/src/ops_tests.rs index fa2b9b6..7525c1a 100644 --- a/crates/benchup-tools/src/ops_tests.rs +++ b/crates/benchup-tools/src/ops_tests.rs @@ -226,13 +226,16 @@ fn copy_group_ungroup_and_selection_scope() { let group = grouped.created[0]; assert_eq!(model.objects[&group].name, "Pair"); assert_eq!(grouped.result["members"].as_array().unwrap().len(), 2); - // Parts are now inside the group: editing one needs its scope open. + // Parts are now inside the group: editing tools open it for the call. let inner = grouped.result["members"][0]["id"].as_u64().unwrap(); - let message = err(&mut model, &mut env, "rename", json!({"id": inner, "name": "Inner"})); + ok(&mut model, &mut env, "rename", json!({"id": inner, "name": "Inner"})); + assert_eq!(model.edit_context, None, "the group closes again after the call"); + // Ids in different groups are not opened together. + let outside = board(&mut model, &mut env, "Outside", [0., 900., 0.], [10., 10., 10.]); + let message = err(&mut model, &mut env, "move", json!({"ids": [inner, outside], "delta_mm": [1, 0, 0]})); assert!(message.contains("not editable"), "{message}"); ok(&mut model, &mut env, "enter_object", json!({"id": group})); assert_eq!(model.edit_context, Some(group)); - ok(&mut model, &mut env, "rename", json!({"id": inner, "name": "Inner"})); assert!(err(&mut model, &mut env, "enter_object", json!({"id": 99999})).contains("cannot open")); ok(&mut model, &mut env, "exit_to_root", json!({})); assert_eq!(model.edit_context, None); @@ -280,7 +283,7 @@ fn joints_connect_two_boards() { other => panic!("{other:?}"), } let scene = ok(&mut model, &mut env, "get_scene", json!({})).result; - assert_eq!(scene["joints"][0]["kind"], "MortiseTenon"); + assert_eq!(scene["joints"][0]["kind"], "mortise_tenon"); // A second joint between the same boards is refused by the core. err(&mut model, &mut env, "add_joint", json!({"a": leg, "b": apron, "kind": "half_lap"})); err(&mut model, &mut env, "add_joint", json!({"a": leg, "b": leg, "kind": "half_lap"})); @@ -572,3 +575,392 @@ fn lease_rolls_back_calls_that_alter_foreign_loose_geometry() { // Geometry that touches nothing existing is fine. ok(&mut model, &mut env, "draw_polyline", json!({"points_mm": [[0, 5000, 0], [100, 5000, 0]]})); } + +/// A side and a shelf entering it by 9 mm, joined with a tongue and groove. +fn joined_pair(model: &mut Model, env: &mut ToolEnv) -> (u64, u64, u64) { + let side = board(model, env, "Side", [0., 0., 0.], [18., 300., 900.]); + let shelf = board(model, env, "Shelf", [9., 0., 400.], [500., 300., 18.]); + let joint = ok(model, env, "add_joint", json!({"a": side, "b": shelf, "kind": "tongue_groove"})).joints[0]; + (side, shelf, joint) +} + +#[test] +fn erasing_a_board_removes_its_joints() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let (side, shelf, joint) = joined_pair(&mut model, &mut env); + let other = board(&mut model, &mut env, "Other", [0., 500., 0.], [18., 300., 900.]); + let second = board(&mut model, &mut env, "Second", [9., 500., 400.], [500., 300., 18.]); + let kept = ok(&mut model, &mut env, "add_joint", json!({"a": other, "b": second, "kind": "tongue_groove"})).joints[0]; + let outcome = ok(&mut model, &mut env, "erase", json!({"ids": [shelf]})); + assert_eq!(outcome.result["removed_joints"], json!([joint])); + assert!(!model.connections.contains_key(&joint)); + assert!(model.connections.contains_key(&kept), "joints of other boards stay"); + assert!(model.contains_entity(&side)); + // Erasing a group removes the joints of the boards inside it. + let group = ok(&mut model, &mut env, "group", json!({"ids": [other, second], "name": "Pair", "component": false})).created[0]; + assert_eq!(model.connections.len(), 1); + let outcome = ok(&mut model, &mut env, "erase", json!({"ids": [group]})); + assert_eq!(outcome.result["removed_joints"].as_array().unwrap().len(), 1); + assert!(model.connections.is_empty()); +} + +#[test] +fn joints_are_removed_with_remove_joint_not_erase() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let (side, shelf, joint) = joined_pair(&mut model, &mut env); + let message = err(&mut model, &mut env, "erase", json!({"ids": [joint]})); + assert!(message.contains("is a joint"), "{message}"); + let hint = run(&mut model, &mut env, "erase", json!({"ids": [joint]})).result["hint"].clone(); + assert!(hint.as_str().unwrap().contains("remove_joint")); + assert!(err(&mut model, &mut env, "remove_joint", json!({"ids": [side]})).contains("not a joint")); + let outcome = ok(&mut model, &mut env, "remove_joint", json!({"ids": [joint]})); + assert!(outcome.mutated, "removing a joint is an undoable change"); + assert_eq!(outcome.result["removed_joints"], json!([joint])); + assert!(model.connections.is_empty()); + assert!(model.contains_entity(&side) && model.contains_entity(&shelf), "the boards stay"); +} + +#[test] +fn the_scene_flags_joints_whose_board_is_gone() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let (_, shelf, joint) = joined_pair(&mut model, &mut env); + // What the app leaves behind today (BenchUp3D/editor#36). + model.erase(&BTreeSet::from([shelf])); + let scene = ok(&mut model, &mut env, "get_scene", json!({})).result; + assert_eq!(scene["joints"][0]["missing_boards"], json!([shelf])); + // Such a joint can still be removed. + ok(&mut model, &mut env, "remove_joint", json!({"ids": [joint]})); + assert!(model.connections.is_empty()); +} + +#[test] +fn scope_errors_name_every_object_to_open() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let a = board(&mut model, &mut env, "A", [0., 0., 0.], [100., 100., 20.]); + let b = board(&mut model, &mut env, "B", [200., 0., 0.], [100., 100., 20.]); + let inner = ok(&mut model, &mut env, "group", json!({"ids": [a, b], "name": "Inner", "component": false})).created[0]; + let other = board(&mut model, &mut env, "C", [0., 300., 0.], [100., 100., 20.]); + let outer = ok(&mut model, &mut env, "group", json!({"ids": [inner, other], "name": "Outer", "component": false})).created[0]; + let inner = model.objects[&outer].entities.iter().copied().find(|id| model.objects.get(id).is_some_and(|o| o.name == "Inner")).unwrap(); + let part = *model.objects[&inner].entities.iter().find(|id| model.objects.contains_key(id)).unwrap(); + // Mixed with a top-level board, the nested part is not opened automatically. + let loose = board(&mut model, &mut env, "Loose", [0., 900., 0.], [10., 10., 10.]); + let hint = |model: &mut Model, env: &mut ToolEnv| { + run(model, env, "move", json!({"ids": [part, loose], "delta_mm": [1, 0, 0]})).result["hint"].as_str().unwrap().to_owned() + }; + let from_root = hint(&mut model, &mut env); + assert!(from_root.contains(&format!("enter_object{{id:{outer}}}, then enter_object{{id:{inner}}}")), "{from_root}"); + ok(&mut model, &mut env, "enter_object", json!({"id": outer})); + let from_outer = hint(&mut model, &mut env); + assert!(from_outer.contains(&format!("call enter_object{{id:{inner}}} first")), "{from_outer}"); + assert!(!from_outer.contains("exit_to_root"), "{from_outer}"); + // From an unrelated group, leave it first. + ok(&mut model, &mut env, "exit_to_root", json!({})); + let lone = board(&mut model, &mut env, "D", [0., 600., 0.], [100., 100., 20.]); + let side_group = ok(&mut model, &mut env, "group", json!({"ids": [lone], "name": "Side", "component": false})).created[0]; + ok(&mut model, &mut env, "enter_object", json!({"id": side_group})); + assert!(hint(&mut model, &mut env).contains("exit_to_root, then enter_object")); +} + +#[test] +fn find_objects_lists_everything_without_a_query() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + board(&mut model, &mut env, "Side", [0., 0., 0.], [18., 300., 900.]); + board(&mut model, &mut env, "Shelf", [18., 0., 400.], [500., 300., 18.]); + assert_eq!(ok(&mut model, &mut env, "find_objects", json!({})).result["total"], 2); + assert_eq!(ok(&mut model, &mut env, "find_objects", json!({"query": "shel"})).result["total"], 1); + assert_eq!(ok(&mut model, &mut env, "find_objects", json!({"query": "group"})).result["total"], 2); +} + +fn axes_of(outcome: &ToolOutcome) -> (u64, u64) { + let o = &outcome.result["orientation"]; + (o["depth_axis"].as_u64().unwrap(), o["width_axis"].as_u64().unwrap()) +} + +#[test] +fn joints_pick_the_woodworking_orientation_and_report_status() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let side = board(&mut model, &mut env, "Side", [0., 0., 0.], [18., 300., 900.]); + let shelf = board(&mut model, &mut env, "Shelf", [9., 0., 400.], [500., 300., 18.]); + // The tongue runs across the shelf's thickness, along its whole depth. + let joint = ok(&mut model, &mut env, "add_joint", json!({"a": shelf, "b": side, "kind": "tongue_groove"})); + assert_eq!(axes_of(&joint), (0, 2)); + let detail = ok(&mut model, &mut env, "get_object", json!({"id": joint.joints[0]})).result; + assert_eq!(detail["status"], "ok"); + assert_eq!(detail["params"]["width_axis_name"], "z"); + assert!(detail["roles"]["a"].as_str().unwrap().contains("tenon")); + assert!(!detail["supported_orientations"].as_array().unwrap().is_empty()); + // A drawer side enters the front along y; its tails spread over its height. + let front = board(&mut model, &mut env, "Front", [1000., 0., 0.], [460., 20., 216.]); + let drawer_side = board(&mut model, &mut env, "Drawer side", [1002., 6., 8.], [12., 420., 200.]); + let dovetail = ok(&mut model, &mut env, "add_joint", json!({"a": drawer_side, "b": front, "kind": "dovetail", "params": {"dovetails": 4}})); + assert_eq!(axes_of(&dovetail), (1, 2)); + // Clear errors instead of silent clamping or a later failure. + let leg = board(&mut model, &mut env, "Leg", [2000., 0., 0.], [70., 70., 720.]); + let apron = board(&mut model, &mut env, "Apron", [2040., 24., 620.], [600., 22., 100.]); + assert!(err(&mut model, &mut env, "add_joint", json!({"a": apron, "b": leg, "kind": "mortise_tenon", "params": {"width_ratio": 0.95}})).contains("0.1 and 0.9")); + assert!(err(&mut model, &mut env, "add_joint", json!({"a": apron, "b": leg, "kind": "finger", "params": {"fingers": 40}})).contains("between 2 and 16")); + assert!(err(&mut model, &mut env, "add_joint", json!({"a": apron, "b": leg, "kind": "mortise_tenon", "params": {"depth_axis": 1, "width_axis": 1}})).contains("must differ")); + let apart = board(&mut model, &mut env, "Apart", [5000., 0., 0.], [100., 100., 100.]); + assert!(err(&mut model, &mut env, "add_joint", json!({"a": apart, "b": leg, "kind": "half_lap"})).contains("do not overlap")); +} + +#[test] +fn stock_materials_can_be_created_and_assigned() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let listed = ok(&mut model, &mut env, "list_stock_materials", json!({})).result; + assert!(listed["note"].as_str().unwrap().contains("create_stock_material")); + let created = ok(&mut model, &mut env, "create_stock_material", json!({"name": "Oak 19", "thickness_mm": 19, "width_mm": 1220, "length_mm": 2440})); + assert!(created.mutated, "a new material is saved with the project"); + let id = created.result["material_id"].as_str().unwrap().to_owned(); + assert_eq!(model.stock_materials.values().next().unwrap().dimensions, [190, 12200, 24400]); + let again = ok(&mut model, &mut env, "create_stock_material", json!({"name": "Oak 19", "thickness_mm": 19, "width_mm": 1220, "length_mm": 2440})); + assert_eq!(again.result["material_id"], id.as_str(), "the same material is reused"); + let shelf = board(&mut model, &mut env, "Shelf", [0., 0., 0.], [500., 300., 19.]); + ok(&mut model, &mut env, "set_material", json!({"ids": [shelf], "material_id": id})); + assert!(model.objects[&shelf].stock_material_id.is_some()); + let cut = ok(&mut model, &mut env, "get_cut_list", json!({})).result; + assert_eq!(cut["pieces"][0]["material"], "Oak 19"); + assert_eq!(cut["pieces"][0]["length_mm"], 500.0); + assert_eq!(cut["materials"][0]["minimum_stock_pieces"], 1); +} + +#[test] +fn shaped_boards_from_an_outline_report_outward_faces() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + // An L-shaped part: concave, so "away from the centre" is not outward everywhere. + let drawn = ok(&mut model, &mut env, "draw_polyline", json!({ + "points_mm": [[0, 0, 0], [400, 0, 0], [400, 100, 0], [100, 100, 0], [100, 400, 0], [0, 400, 0]], "close": true})); + let face = drawn.result["face_id"].as_u64().unwrap(); + let pushed = ok(&mut model, &mut env, "push_pull", json!({"face_id": face, "distance_mm": 18})); + assert!(!pushed.created.is_empty(), "push_pull lists what it created"); + let mut ids: Vec = drawn.created.clone(); + ids.extend(pushed.created.iter().copied().filter(|id| model.contains_entity(id))); + let part = ok(&mut model, &mut env, "group", json!({"ids": ids, "name": "L", "component": false})).created[0]; + let scene = ok(&mut model, &mut env, "get_scene", json!({})).result; + assert_eq!(scene["loose_geometry"]["edges"], 0, "faces bring their edges into the group"); + let detail = ok(&mut model, &mut env, "get_object", json!({"id": part})).result; + // The inner corner faces point into the notch, not away from the part's centre. + let facing = |x: f64, y: f64| -> String { + detail["faces"].as_array().unwrap().iter() + .find(|f| (f["center_mm"][0].as_f64().unwrap() - x).abs() < 1. && (f["center_mm"][1].as_f64().unwrap() - y).abs() < 1.) + .map(|f| f["facing"].as_str().unwrap().to_owned()).unwrap() + }; + assert_eq!(facing(250., 100.), "+y"); + assert_eq!(facing(100., 250.), "+x"); + assert_eq!(facing(200., 0.), "-y"); + assert!(detail["faces"].as_array().unwrap().iter().all(|f| f["center_mm"].is_array()), "concave faces get a center too"); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert!(check["issues"].as_array().unwrap().iter().all(|i| i["kind"] != "open_solid"), "{check}"); +} + +#[test] +fn resize_anchor_and_move_to() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let divider = board(&mut model, &mut env, "Divider", [491., 0., 0.], [18., 300., 1800.]); + ok(&mut model, &mut env, "resize_board", json!({"id": divider, "size_mm": [19, 300, 1800], "anchor": "center"})); + near(bounds(&model, divider).0, [0.4905, 0., 0.]); + ok(&mut model, &mut env, "resize_board", json!({"id": divider, "size_mm": [19, 294, 1800], "anchor": ["min", "max", "min"]})); + near(bounds(&model, divider).0, [0.4905, 0.006, 0.]); + ok(&mut model, &mut env, "move_to", json!({"id": divider, "origin_mm": [0, 0, 100]})); + near(bounds(&model, divider).0, [0., 0., 0.1]); +} + +#[test] +fn check_model_finds_unjoined_overlaps() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let side = board(&mut model, &mut env, "Side", [0., 0., 0.], [18., 300., 900.]); + let shelf = board(&mut model, &mut env, "Shelf", [9., 0., 400.], [500., 300., 18.]); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert_eq!(check["warnings"], 1); + assert_eq!(check["issues"][0]["kind"], "overlap_without_joint"); + ok(&mut model, &mut env, "add_joint", json!({"a": shelf, "b": side, "kind": "tongue_groove"})); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert_eq!(check["warnings"], 0, "{check}"); + assert_eq!(check["errors"], 0, "{check}"); +} + +#[test] +fn the_scene_lists_annotations_axes_and_component_definitions() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let a = board(&mut model, &mut env, "Shelf", [0., 0., 0.], [500., 300., 18.]); + let component = ok(&mut model, &mut env, "group", json!({"ids": [a], "name": "Shelf", "component": true})).created[0]; + let copy = ok(&mut model, &mut env, "create_copy", json!({"id": component, "offset_mm": [0, 0, 300]})).created[0]; + ok(&mut model, &mut env, "add_dimension", json!({"start_mm": [0, 0, 0], "end_mm": [500, 0, 0], "offset_mm": [0, -100, 0]})); + ok(&mut model, &mut env, "add_text", json!({"point_mm": [0, 0, 18], "offset_mm": [0, 0, 0], "text": "Bookcase"})); + let scene = ok(&mut model, &mut env, "get_scene", json!({})).result; + assert!(scene["axes"]["y"].as_str().unwrap().contains("-y")); + assert_eq!(scene["dimensions"][0]["length_mm"], 500.0); + assert_eq!(scene["texts"][0]["text"], "Bookcase"); + let definition = |id: u64| scene["objects"].as_array().unwrap().iter().find(|o| o["id"] == id).unwrap()["definition"].clone(); + assert!(definition(component).is_string()); + assert_eq!(definition(component), definition(copy)); + let guide = ok(&mut model, &mut env, "get_guide", json!({})).result; + assert!(guide["guide"].as_str().unwrap().contains("front of a piece faces -Y")); +} + +#[test] +fn check_model_flags_reversed_roles_and_trusts_real_geometry() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let side = board(&mut model, &mut env, "Side", [0., 0., 0.], [18., 300., 900.]); + let shelf = board(&mut model, &mut env, "Shelf", [9., 0., 400.], [500., 300., 18.]); + // Side as a: the side would carry the tongue. + ok(&mut model, &mut env, "add_joint", json!({"a": side, "b": shelf, "kind": "tongue_groove"})); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert_eq!(check["issues"][0]["kind"], "joint_roles_reversed", "{check}"); + // A notched board around a post: the boxes overlap, the solids do not. + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + board(&mut model, &mut env, "Post", [0., 0., 0.], [50., 50., 800.]); + let outline = ok(&mut model, &mut env, "draw_polyline", json!({ + "points_mm": [[50, 0, 700], [400, 0, 700], [400, 300, 700], [0, 300, 700], [0, 50, 700], [50, 50, 700]], "close": true})); + let face = outline.result["face_id"].as_u64().unwrap(); + let pushed = ok(&mut model, &mut env, "push_pull", json!({"face_id": face, "distance_mm": 30})); + assert_eq!(pushed.result["face_id"], pushed.result["new_face_id"]); + let mut ids = outline.created.clone(); + ids.extend(pushed.created.iter().copied().filter(|id| model.contains_entity(id))); + ok(&mut model, &mut env, "group", json!({"ids": ids, "name": "Top", "component": false})); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert_eq!(check["warnings"], 0, "{check}"); + assert_eq!(check["model_bounds_mm"]["size"], json!([400.0, 300.0, 800.0])); +} + +#[test] +fn joints_explain_level_and_assembly_mistakes() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let side = board(&mut model, &mut env, "Side", [0., 0., 0.], [18., 300., 900.]); + let shelf = board(&mut model, &mut env, "Shelf", [9., 0., 400.], [500., 300., 18.]); + let module = ok(&mut model, &mut env, "group", json!({"ids": [shelf], "name": "Module", "component": false})).created[0]; + let inner = model.objects[&module].entities.iter().copied().find(|id| model.objects.contains_key(id)).unwrap(); + assert!(err(&mut model, &mut env, "add_joint", json!({"a": inner, "b": side, "kind": "tongue_groove"})).contains("different groups")); + assert!(err(&mut model, &mut env, "add_joint", json!({"a": module, "b": side, "kind": "tongue_groove"})).contains("not a single board")); +} + +#[test] +fn cut_list_uses_materials_set_on_groups() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let shelf = board(&mut model, &mut env, "Shelf", [0., 0., 0.], [500., 300., 18.]); + let unassigned = ok(&mut model, &mut env, "get_cut_list", json!({})).result; + assert!(unassigned["materials"][0]["note"].as_str().unwrap().contains("create_stock_material")); + let id = ok(&mut model, &mut env, "create_stock_material", json!({"name": "Birch 18", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440})).result["material_id"].clone(); + let group = ok(&mut model, &mut env, "group", json!({"ids": [shelf], "name": "Unit", "component": false})).created[0]; + ok(&mut model, &mut env, "set_material", json!({"ids": [group], "material_id": id})); + let cut = ok(&mut model, &mut env, "get_cut_list", json!({})).result; + assert_eq!(cut["pieces"][0]["material"], "Birch 18"); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert!(check["issues"].as_array().unwrap().iter().all(|i| i["kind"] != "no_material"), "{check}"); +} + +#[test] +fn same_named_materials_are_told_apart_by_assignment() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let material = |model: &mut Model, env: &mut ToolEnv, thickness: u64, width: u64| { + ok(model, env, "create_stock_material", json!({"name": "Plywood", "thickness_mm": thickness, "width_mm": width, "length_mm": 2440})) + .result["material_id"].clone() + }; + let thick = material(&mut model, &mut env, 18, 1220); + let thin = material(&mut model, &mut env, 12, 610); + assert_ne!(thick, thin); + let top = board(&mut model, &mut env, "Top", [0., 0., 500.], [600., 300., 18.]); + let back = board(&mut model, &mut env, "Back", [0., 400., 0.], [600., 12., 480.]); + ok(&mut model, &mut env, "set_material", json!({"ids": [top], "material_id": thick})); + ok(&mut model, &mut env, "set_material", json!({"ids": [back], "material_id": thin})); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert!(check["issues"].as_array().unwrap().iter().all(|i| i["kind"] != "thickness_mismatch"), "{check}"); + let cut = ok(&mut model, &mut env, "get_cut_list", json!({})).result; + let materials = cut["materials"].as_array().unwrap(); + assert_eq!(materials.len(), 2, "{cut}"); + let mut stock: Vec<_> = materials.iter().map(|m| (m["material"].clone(), m["stock_mm"]["thickness"].clone(), m["stock_mm"]["width"].clone())).collect(); + stock.sort_by_key(|(_, t, _)| t.as_f64().unwrap() as i64); + assert_eq!(stock, [(json!("Plywood"), json!(12.0), json!(610.0)), (json!("Plywood"), json!(18.0), json!(1220.0))]); +} + +#[test] +fn flip_joint_swaps_roles_and_keeps_settings() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let side = board(&mut model, &mut env, "Side", [0., 0., 0.], [18., 300., 900.]); + let shelf = board(&mut model, &mut env, "Shelf", [9., 0., 400.], [500., 300., 18.]); + let joint = ok(&mut model, &mut env, "add_joint", json!({"a": side, "b": shelf, "kind": "tongue_groove", "params": {"width_ratio": 0.4}})).joints[0]; + let flipped = ok(&mut model, &mut env, "flip_joint", json!({"ids": [joint]})); + assert!(flipped.mutated); + assert_eq!(model.connections[&joint].members, [shelf, side]); + let detail = ok(&mut model, &mut env, "get_object", json!({"id": joint})).result; + assert_eq!(detail["params"]["width_ratio"], 0.4, "settings are kept"); + assert_eq!(detail["params"]["width_axis_name"], "z", "the orientation is re-picked for the new a"); + assert!(detail["params"].get("fingers").is_none(), "only this kind's settings"); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert_eq!(check["warnings"], 0, "{check}"); + assert_eq!(check["joints"]["ok"], 1); +} + +#[test] +fn editing_tools_reach_parts_inside_groups() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let a = board(&mut model, &mut env, "A", [0., 0., 0.], [100., 100., 20.]); + let b = board(&mut model, &mut env, "B", [200., 0., 0.], [100., 100., 20.]); + let group = ok(&mut model, &mut env, "group", json!({"ids": [a, b], "name": "Pair", "component": false})).created[0]; + let inner: Vec = model.objects[&group].entities.iter().copied().filter(|id| model.objects.contains_key(id)).collect(); + ok(&mut model, &mut env, "move", json!({"ids": [inner[0]], "delta_mm": [0, 0, 50]})); + near(bounds(&model, inner[0]).0, [bounds(&model, inner[0]).0.x, 0., 0.05]); + ok(&mut model, &mut env, "resize_board", json!({"id": inner[1], "size_mm": [100, 100, 30]})); + assert_eq!(model.edit_context, None); + assert_eq!(model.parent(inner[0]), Some(group), "the part stays in its group"); +} + +#[test] +fn check_model_reports_boards_passing_through_and_thickness() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + board(&mut model, &mut env, "Divider", [491., 0., 0.], [18., 300., 1800.]); + let shelf = board(&mut model, &mut env, "Long shelf", [9., 0., 1000.], [1200., 300., 18.]); + let id = ok(&mut model, &mut env, "create_stock_material", json!({"name": "Board 16", "thickness_mm": 16, "width_mm": 1220, "length_mm": 2440})).result["material_id"].clone(); + ok(&mut model, &mut env, "set_material", json!({"ids": [shelf], "material_id": id})); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + let kinds: Vec<&str> = check["issues"].as_array().unwrap().iter().map(|i| i["kind"].as_str().unwrap()).collect(); + assert!(kinds.contains(&"passes_through"), "{check}"); + assert!(kinds.contains(&"thickness_mismatch"), "{check}"); + assert_eq!(check["errors"], 1); +} + +#[test] +fn dimensions_measure_along_an_axis_and_report_attachment() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + board(&mut model, &mut env, "Side", [0., 0., 0.], [18., 300., 900.]); + let straight = ok(&mut model, &mut env, "add_dimension", json!({"start_mm": [0, 0, 0], "end_mm": [300, 0, 900], "offset_mm": [-100, 0, 0]})); + assert!(straight.result["length_mm"].as_f64().unwrap() > 900.); + assert_eq!(straight.result["start_attached"], true, "a corner of the board"); + let along = ok(&mut model, &mut env, "add_dimension", json!({"start_mm": [0, 0, 0], "end_mm": [300, 0, 900], "offset_mm": [-150, 0, 0], "axis": "z"})); + assert_eq!(along.result["length_mm"], 900.0); + let free = ok(&mut model, &mut env, "add_dimension", json!({"start_mm": [-50, -50, 0], "end_mm": [-50, -50, 100], "offset_mm": [-10, 0, 0]})); + assert_eq!(free.result["start_attached"], false, "no snapping to nearby corners"); + let scene = ok(&mut model, &mut env, "get_scene", json!({})).result; + assert_eq!(scene["dimensions"][1]["axis"], "z"); +} + +#[test] +fn the_scene_shows_materials_inherited_from_groups() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let shelf = board(&mut model, &mut env, "Shelf", [0., 0., 0.], [500., 300., 18.]); + let id = ok(&mut model, &mut env, "create_stock_material", json!({"name": "MDF", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440})).result["material_id"].clone(); + let group = ok(&mut model, &mut env, "group", json!({"ids": [shelf], "name": "Unit", "component": false})).created[0]; + ok(&mut model, &mut env, "set_material", json!({"ids": [group], "material_id": id})); + let inner = *model.objects[&group].entities.iter().find(|id| model.objects.contains_key(id)).unwrap(); + let entry = ok(&mut model, &mut env, "get_object", json!({"id": inner})).result; + assert_eq!(entry["material"]["name"], "MDF"); + assert_eq!(entry["material"]["inherited_from"], group); +} + +#[test] +fn small_conveniences_from_the_last_round() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let side = board(&mut model, &mut env, "Side", [0., 0., 0.], [18., 300., 900.]); + let shelf = board(&mut model, &mut env, "Shelf", [9., 0., 400.], [500., 300., 18.]); + let joint = ok(&mut model, &mut env, "add_joint", json!({"a": side, "b": shelf, "kind": "tongue_groove"})).joints[0]; + // A single "id" works where a list of ids is expected. + ok(&mut model, &mut env, "flip_joint", json!({"id": joint})); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert_eq!(check["passed"], true, "{check}"); + // A drawn face comes back with where it is. + let drawn = ok(&mut model, &mut env, "draw_polyline", json!({"points_mm": [[0, 600, 0], [100, 600, 0], [100, 700, 0], [0, 700, 0]], "close": true})); + assert_eq!(drawn.result["faces"][0]["center_mm"], json!([50.0, 650.0, 0.0])); + let check = ok(&mut model, &mut env, "check_model", json!({})).result; + assert_eq!(check["passed"], true, "loose geometry is only info: {check}"); +} diff --git a/crates/benchup-tools/src/review.rs b/crates/benchup-tools/src/review.rs new file mode 100644 index 0000000..ba669c0 --- /dev/null +++ b/crates/benchup-tools/src/review.rs @@ -0,0 +1,279 @@ +//! Read-only reviews an agent cannot do by looking at the model: a cut list and +//! a check for problems such as overlapping boards without a joint. +use crate::scene::{self, mm, point_mm}; +use benchup_core::{DVec3, Model, StockMaterial, connections::ConnectionOperation, joinery::JointSettings}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Most boards compared pairwise by `check`. +const MAX_CHECKED_BOARDS: usize = 600; +const MAX_LISTED_ISSUES: usize = 60; + +/// Objects that hold board geometry only (faces and edges, no nested objects). +fn boards(model: &Model) -> Vec { + model + .objects + .iter() + .filter(|(id, object)| { + !model.is_hidden(**id) + && !object.entities.is_empty() + && object.entities.iter().all(|e| model.faces.contains_key(e) || model.edges.contains_key(e)) + && object.entities.iter().any(|e| model.faces.contains_key(e)) + }) + .map(|(id, _)| *id) + .collect() +} + +/// A board's blank in its own axes: thickness, width and length (along the +/// grain when it is set, otherwise the longest side), in metres. +fn blank(model: &Model, id: u64) -> Option<[f64; 3]> { + let frame = model.joinery_frame(id).ok()?; + let (lo, hi) = frame.bounds(model, id)?; + let extent = hi - lo; + let axes: [DVec3; 3] = [DVec3::X, DVec3::Y, DVec3::Z].map(|a| (frame.world(a) - frame.world(DVec3::ZERO)).normalize_or_zero()); + let mut order = [0, 1, 2]; + order.sort_by(|a, b| extent[*a].total_cmp(&extent[*b])); + let thickness = order[0]; + let (mut width, mut length) = (order[1], order[2]); + if let Some(grain) = model.objects[&id].world_grain_axis() { + if axes[width].dot(grain).abs() > axes[length].dot(grain).abs() { + std::mem::swap(&mut width, &mut length); + } + } + Some([extent[thickness], extent[width], extent[length]]) +} + +/// The board's stock material, or the nearest one set on a group around it. +/// Found through the assignment, not the name: two materials may share one. +fn material(model: &Model, id: u64) -> Option<&StockMaterial> { + let mut current = Some(id); + while let Some(object) = current.and_then(|c| model.objects.get(&c)) { + if let Some(material) = model.object_stock_material(object) { + return Some(material); + } + current = model.parent(object.id); + } + None +} + +fn board_bounds(model: &Model, id: u64) -> Option<(DVec3, DVec3)> { + model.selection_bounds(&BTreeSet::from([id])) +} + +/// Axes along which the shared region spans the whole board. +fn full_axes(board: (DVec3, DVec3), shared: DVec3) -> usize { + let size = board.1 - board.0; + (0..3).filter(|i| shared[*i] >= size[*i] - 1.0e-4).count() +} + +/// Boards grouped by material and blank size, with totals per material. +pub fn cut_list(model: &Model) -> Value { + // A material is its name and stock size, so same-named materials of + // different sizes stay apart; `None` is unassigned. + type Material = Option<(String, [u64; 3])>; + /// Count, names and ids of the boards of one material and blank size. + type Group = (usize, BTreeSet, Vec); + let mut groups: BTreeMap<(Material, [i64; 3]), Group> = BTreeMap::new(); + let mut skipped = Vec::new(); + for id in boards(model) { + let Some(size) = blank(model, id) else { + skipped.push(id); + continue; + }; + let material = material(model, id).map(|m| (m.name.clone(), m.dimensions)); + let key = (material, size.map(|m| (m * 10_000.).round() as i64)); + let entry = groups.entry(key).or_default(); + entry.0 += 1; + entry.1.insert(model.objects[&id].name.clone()); + entry.2.push(id); + } + let name = |material: &Material| material.as_ref().map_or("unassigned", |(name, _)| name.as_str()).to_owned(); + let mut totals: BTreeMap = BTreeMap::new(); + let pieces: Vec = groups + .iter() + .map(|((material, tenths), (count, names, ids))| { + let [t, w, l] = tenths.map(|v| v as f64 / 10.); + let total = totals.entry(material.clone()).or_default(); + total.0 += count; + total.1 += l * w / 1.0e6 * *count as f64; + total.2 += l * w * t / 1.0e9 * *count as f64; + json!({"material": name(material), "length_mm": l, "width_mm": w, "thickness_mm": t, "quantity": count, + "names": names, "ids": ids.iter().take(20).collect::>()}) + }) + .collect(); + let materials: Vec = totals + .iter() + .map(|(material, (count, area, volume))| { + let mut entry = json!({"material": name(material), "pieces": count, + "area_m2": (area * 1000.).round() / 1000., "volume_m3": (volume * 10_000.).round() / 10_000.}); + match material { + Some((_, dimensions)) if dimensions[1] > 0 && dimensions[2] > 0 => { + let [t, w, l] = dimensions.map(|v| v as f64 / 10.); + let stock_area = w * l / 1.0e6; + entry["stock_mm"] = json!({"thickness": t, "width": w, "length": l}); + entry["minimum_stock_pieces"] = json!((area / stock_area).ceil() as u64); + } + Some(_) => {} + None => { + entry["note"] = json!("Assign a stock material with sizes (create_stock_material, set_material) to get minimum_stock_pieces."); + } + } + entry + }) + .collect(); + let mut out = json!({ + "pieces": pieces, + "materials": materials, + "note": "Blank sizes include tenons and tongues (joints are cut from the overlap). \ + minimum_stock_pieces is an area lower bound with no saw kerf, trimming or nesting; \ + add a margin, or plan the layout in BenchUp.", + }); + if !skipped.is_empty() { + out["skipped"] = json!(skipped); + } + out +} + +fn overlap(a: (DVec3, DVec3), b: (DVec3, DVec3)) -> Option { + let size = a.1.min(b.1) - a.0.max(b.0); + (size.min_element() > 1.0e-6).then_some(size) +} + +/// Problems worth fixing before building: overlapping boards without a joint, +/// joints that cannot be cut or lost a board, open solids and loose geometry. +pub fn check(model: &Model) -> Value { + let mut issues = Vec::new(); + let mut issue = |severity: &str, kind: &str, message: String, ids: Vec| { + issues.push(json!({"severity": severity, "kind": kind, "message": message, "ids": ids})); + }; + let joined: BTreeSet<[u64; 2]> = model + .connections + .values() + .map(|c| [c.members[0].min(c.members[1]), c.members[0].max(c.members[1])]) + .collect(); + let all = boards(model); + let checked = &all[..all.len().min(MAX_CHECKED_BOARDS)]; + let bounds: Vec<(u64, (DVec3, DVec3), bool)> = checked + .iter() + .filter_map(|id| { + let aligned = model.joinery_frame(*id).is_ok_and(|f| f.is_project_aligned()); + Some((*id, model.selection_bounds(&BTreeSet::from([*id]))?, aligned)) + }) + .collect(); + for (i, (a, box_a, aligned_a)) in bounds.iter().enumerate() { + for (b, box_b, aligned_b) in &bounds[i + 1..] { + let Some(size) = overlap(*box_a, *box_b) else { continue }; + if joined.contains(&[(*a).min(*b), (*a).max(*b)]) { + continue; + } + // Bounding boxes overlap; a notch around the other board means the solids do not. + if model.joinery_options([*a, *b], &JointSettings::default()).0.is_none() { + continue; + } + let (name_a, name_b) = (&model.objects[a].name, &model.objects[b].name); + // One board crossing the other completely, cutting it in two: the + // shared region spans the other on two axes and it continues on + // both sides along the third. A housed end only notches the board. + let shared = (box_a.0.max(box_b.0), box_a.1.min(box_b.1)); + let splits = |board: (DVec3, DVec3)| { + let full: Vec = (0..3).filter(|i| shared.1[*i] - shared.0[*i] >= board.1[*i] - board.0[*i] - 1.0e-4).collect(); + full.len() == 2 && { + let i = 3 - full[0] - full[1]; + board.0[i] < shared.0[i] - 1.0e-4 && board.1[i] > shared.1[i] + 1.0e-4 + } + }; + if splits(*box_a) || splits(*box_b) { + let (through, pierced) = if splits(*box_b) { (name_a, name_b) } else { (name_b, name_a) }; + issue( + "error", + "passes_through", + format!("{through} passes right through {pierced}, cutting it in two; a joint cannot fix this. Shorten or move it."), + vec![*a, *b], + ); + continue; + } + let approximate = if *aligned_a && *aligned_b { "" } else { " (measured on bounding boxes; a board is rotated)" }; + issue( + "warning", + "overlap_without_joint", + format!("{name_a} and {name_b} overlap by {:?} mm without a joint{approximate}. Add a joint, or move one so they only touch.", + [mm(size.x), mm(size.y), mm(size.z)]), + vec![*a, *b], + ); + } + } + let joint_errors = scene::joint_errors(model); + for (id, error) in &joint_errors { + let id = *id; + let members = model.connections.get(&id).map_or(vec![id], |c| vec![id, c.members[0], c.members[1]]); + issue("error", "joint_not_cut", format!("Joint {id} cannot be cut: {error}"), members); + } + for (id, connection) in &model.connections { + let [a, b] = connection.members; + if let (ConnectionOperation::Joinery(_), Some(box_a), Some(box_b)) = (&connection.operation, board_bounds(model, a), board_bounds(model, b)) { + // The board that goes into the other is spanned by the shared region across its section. + if let Some(shared) = overlap(box_a, box_b) { + if full_axes(box_b, shared) > full_axes(box_a, shared) { + issue( + "warning", + "joint_roles_reversed", + format!("Joint {id} looks reversed: {} goes into {}, so it should be a and {} b (flip_joint).", + model.objects[&b].name, model.objects[&a].name, model.objects[&a].name), + vec![*id, a, b], + ); + } + } + } + let missing: Vec = connection.members.iter().copied().filter(|m| !model.contains_entity(m)).collect(); + if !missing.is_empty() && matches!(connection.operation, ConnectionOperation::Joinery(_)) { + issue("error", "joint_without_board", format!("Joint {id} refers to erased boards {missing:?}; remove it with remove_joint."), vec![*id]); + } + } + for id in &all { + if let Err(reason) = model.solid_volume(*id) { + issue("warning", "open_solid", format!("{} is not a closed solid: {reason}", model.objects[id].name), vec![*id]); + } + } + let loose: Vec = model.root_entities().into_iter().filter(|id| model.edges.contains_key(id) || model.faces.contains_key(id)).collect(); + if !loose.is_empty() { + issue("info", "loose_geometry", format!("{} loose edges and faces at the top level; group them into a part or erase them.", loose.len()), + loose.into_iter().take(20).collect()); + } + for id in &all { + let Some(stock) = material(model, *id).filter(|m| m.dimensions[0] > 0) else { continue }; + let name = &stock.name; + let Some([thickness, ..]) = blank(model, *id) else { continue }; + let expected = stock.dimensions[0] as f64 / 10.; + if (mm(thickness) - expected).abs() > 0.5 { + issue("warning", "thickness_mismatch", + format!("{} is {} mm thick but its material {name} is {expected} mm.", model.objects[id].name, mm(thickness)), vec![*id]); + } + } + let unassigned: Vec = all.iter().copied().filter(|id| material(model, *id).is_none()).collect(); + if !unassigned.is_empty() { + issue("info", "no_material", format!("{} boards have no stock material.", unassigned.len()), unassigned.into_iter().take(20).collect()); + } + let total = issues.len(); + let errors = issues.iter().filter(|i| i["severity"] == "error").count(); + let warnings = issues.iter().filter(|i| i["severity"] == "warning").count(); + issues.truncate(MAX_LISTED_ISSUES); + let wood_joints = model.connections.values().filter(|c| matches!(c.operation, ConnectionOperation::Joinery(_))).count(); + let mut out = json!({ + "passed": errors == 0 && warnings == 0, + "boards": all.len(), "errors": errors, "warnings": warnings, + "joints": {"total": wood_joints, "ok": wood_joints - joint_errors.len().min(wood_joints), "failing": joint_errors.keys().collect::>()}, + "issues": issues, + }); + if total > MAX_LISTED_ISSUES { + out["issues_omitted"] = json!(total - MAX_LISTED_ISSUES); + } + if all.len() > MAX_CHECKED_BOARDS { + out["note"] = json!(format!("Only the first {MAX_CHECKED_BOARDS} boards were compared for overlaps.")); + } + // The furniture's size: boards only, not dimensions or texts drawn around them. + let extents = all.iter().filter_map(|id| board_bounds(model, *id)).reduce(|x, y| (x.0.min(y.0), x.1.max(y.1))); + if let Some((lo, hi)) = extents { + out["model_bounds_mm"] = json!({"min": point_mm(lo), "max": point_mm(hi), "size": point_mm(hi - lo)}); + } + out +} diff --git a/crates/benchup-tools/src/scene.rs b/crates/benchup-tools/src/scene.rs index 21b40f9..6b6bb63 100644 --- a/crates/benchup-tools/src/scene.rs +++ b/crates/benchup-tools/src/scene.rs @@ -10,6 +10,8 @@ pub const SCENE_BUDGET: usize = 24_000; const MAX_FACES: usize = 80; const MAX_LISTED_SELECTION: usize = 200; const MAX_LISTED_TAGS: usize = 100; +const MAX_LISTED_LOOSE: usize = 100; +const MAX_LISTED_ANNOTATIONS: usize = 50; /// Metres to millimetres, rounded to 0.1 mm. pub fn mm(metres: f64) -> f64 { @@ -68,6 +70,18 @@ fn material_json(model: &Model, object: &benchup_core::Object) -> Option Some(json!({"id": object.stock_material_id, "name": stock.name})) } +/// The stock material of the nearest group around `id` that has one. +fn inherited_material(model: &Model, id: u64) -> Option<(u64, Value)> { + let mut current = model.parent(id); + while let Some(object) = current.and_then(|c| model.objects.get(&c)) { + if let Some(material) = material_json(model, object) { + return Some((object.id, material)); + } + current = model.parent(object.id); + } + None +} + pub fn object_entry(model: &Model, id: u64) -> Option { let object = model.objects.get(&id)?; let mut entry = json!({ @@ -86,8 +100,15 @@ pub fn object_entry(model: &Model, id: u64) -> Option { map.insert("bounds_mm".into(), json!({"min": bounds["min"], "max": bounds["max"]})); map.insert("size_mm".into(), bounds["size"].clone()); } + if let ObjectKind::Component { definition } = object.kind { + // Instances of one component share this id. + map.insert("definition".into(), json!(definition.to_string()[..8])); + } if let Some(material) = material_json(model, object) { map.insert("material".into(), material); + } else if let Some((owner, mut material)) = inherited_material(model, id) { + material["inherited_from"] = json!(owner); + map.insert("material".into(), material); } if let Some(grain) = object.world_grain_axis() { map.insert("grain".into(), axis_name(grain)); @@ -115,13 +136,89 @@ pub fn stock_list( .collect() } +/// Joints the core cannot cut, keyed by joint id, with its reason. +pub fn joint_errors(model: &Model) -> BTreeMap { + if !model.connections.values().any(|c| matches!(c.operation, ConnectionOperation::Joinery(_))) { + return BTreeMap::new(); + } + model.evaluate_joinery().errors.into_iter().collect() +} + +/// A joint in full: which board carries which part, its settings and whether it cuts. +pub fn joint_detail(model: &Model, id: u64) -> Option { + let mut joint = joint_json(model, id)?; + let connection = &model.connections[&id]; + if let ConnectionOperation::Joinery(s) = &connection.operation { + let axes = ["x", "y", "z"]; + joint["roles"] = json!({ + "a": "carries the tenon, tongue, tails, fingers or lap (the board that goes into the other)", + "b": "receives the mortise, groove, sockets or lap", + }); + use benchup_core::joinery::JointKind; + let mut params = json!({ + "depth_axis": s.depth_axis, "width_axis": s.width_axis, + "depth_axis_name": axes[s.depth_axis.min(2)], "width_axis_name": axes[s.width_axis.min(2)], + "clearance_mm": mm(s.clearance), + }); + // Only the settings this kind of joint uses. + match s.kind { + JointKind::MortiseTenon => { + params["width_ratio"] = json!(s.width_ratio); + params["height_ratio"] = json!(s.height_ratio); + } + JointKind::TongueGroove | JointKind::Rabbet => params["width_ratio"] = json!(s.width_ratio), + JointKind::Dovetail => { + params["dovetails"] = json!(s.dovetails); + params["width_ratio"] = json!(s.width_ratio); + params["taper_degrees"] = json!(s.taper_degrees); + } + JointKind::Finger => params["fingers"] = json!(s.fingers), + JointKind::HalfLap => {} + } + joint["params"] = params; + let supported: Vec = model + .supported_joinery_axes(connection.members, s) + .into_iter() + .map(|(d, w)| json!({"depth_axis": d, "width_axis": w})) + .collect(); + joint["supported_orientations"] = json!(supported); + if let Some(overlap) = model.joinery_options(connection.members, s).0 { + joint["overlap_mm"] = point_mm(overlap.bounds.1 - overlap.bounds.0); + } + } + match joint_errors(model).get(&id) { + Some(error) => joint["status"] = json!(error), + None => joint["status"] = json!("ok"), + } + Some(joint) +} + +/// The joint kind as add_joint names it (mortise_tenon, tongue_groove, ...). +pub fn joint_kind(connection: &benchup_core::connections::Connection) -> &'static str { + use benchup_core::joinery::JointKind; + match &connection.operation { + ConnectionOperation::Joinery(settings) => match settings.kind { + JointKind::MortiseTenon => "mortise_tenon", + JointKind::HalfLap => "half_lap", + JointKind::TongueGroove => "tongue_groove", + JointKind::Rabbet => "rabbet", + JointKind::Dovetail => "dovetail", + JointKind::Finger => "finger", + }, + ConnectionOperation::Fasteners(_) => "fasteners", + } +} + fn joint_json(model: &Model, id: u64) -> Option { let connection = model.connections.get(&id)?; - let kind = match &connection.operation { - ConnectionOperation::Joinery(settings) => json!(format!("{:?}", settings.kind)), - ConnectionOperation::Fasteners(_) => json!("Fasteners"), - }; - Some(json!({"id": id, "a": connection.members[0], "b": connection.members[1], "kind": kind})) + let kind = json!(joint_kind(connection)); + let mut joint = json!({"id": id, "a": connection.members[0], "b": connection.members[1], "kind": kind}); + let missing: Vec = connection.members.iter().copied().filter(|m| !model.contains_entity(m)).collect(); + if !missing.is_empty() { + // Left behind when a board was erased (BenchUp3D/editor#36). + joint["missing_boards"] = json!(missing); + } + Some(joint) } /// Everything the model needs to plan an edit, within `budget` characters. @@ -133,19 +230,39 @@ pub fn summary( budget: usize, ) -> Value { let stock = stock_list(model, catalog); - let joints: Vec<_> = model.connections.keys().take(100).filter_map(|id| joint_json(model, *id)).collect(); + let errors = joint_errors(model); + let joints: Vec<_> = model + .connections + .keys() + .take(100) + .filter_map(|id| { + let mut joint = joint_json(model, *id)?; + if let Some(error) = errors.get(id) { + joint["status"] = json!(error); + } + Some(joint) + }) + .collect(); + let root = model.root_entities(); + let loose = |pick: &dyn Fn(&u64) -> bool| -> Vec { root.iter().copied().filter(|id| pick(id)).collect() }; + let (loose_edges, loose_faces) = (loose(&|id| model.edges.contains_key(id)), loose(&|id| model.faces.contains_key(id))); let mut out = json!({ "units": "mm", "up_axis": "z", + "axes": {"x": "width, left to right", "y": "depth; the front of a piece faces -y", "z": "height, up"}, "edit_path": model.edit_path(), "selection": selection.iter().take(MAX_LISTED_SELECTION).collect::>(), "tags": model.tags.values().take(MAX_LISTED_TAGS).map(|t| json!({"id": t.id, "name": t.name, "visible": t.visible})).collect::>(), "stock_catalog": stock.iter().take(40).collect::>(), "joints": joints, "loose_geometry": { - "edges": model.root_entities().iter().filter(|id| model.edges.contains_key(id)).count(), - "faces": model.root_entities().iter().filter(|id| model.faces.contains_key(id)).count(), + "edges": loose_edges.len(), + "faces": loose_faces.len(), + "edge_ids": loose_edges.iter().take(MAX_LISTED_LOOSE).collect::>(), + "face_ids": loose_faces.iter().take(MAX_LISTED_LOOSE).collect::>(), }, + "dimensions": model.dimensions.keys().take(MAX_LISTED_ANNOTATIONS).filter_map(|id| annotation_json(model, *id)).collect::>(), + "texts": model.texts.keys().take(MAX_LISTED_ANNOTATIONS).filter_map(|id| annotation_json(model, *id)).collect::>(), "object_count": model.objects.len(), }); let in_scope: BTreeSet = model.selectable_entities(); @@ -177,21 +294,67 @@ pub fn summary( } /// Face normals follow drawing winding, so a closed part can have inward ones. -/// Report and extrude along the outward direction: away from the owning -/// part's center. Returns the factor (1 or -1) that maps the stored normal to -/// the outward one. +/// Report and extrude along the outward direction. A ray from inside the face +/// along its stored normal crosses the part's other faces an odd number of +/// times when that normal points into the part. Returns the factor (1 or -1) +/// that maps the stored normal to the outward one. Open shapes and loose faces +/// keep their stored normal. pub fn outward_sign(model: &Model, face_id: u64) -> f64 { let outward = || -> Option { let face = model.faces.get(&face_id)?; let normal = faces::plane(model, &face.vertices)?.normal; let owner = model.parent(face_id)?; - let center = model.selection_center(&BTreeSet::from([owner]))?; - let toward_face = model.face_center(face)? - center; - Some(if toward_face.dot(normal) < -1e-9 { -1. } else { 1. }) + // Start inside the face (its largest triangle), not at a concave face's centroid. + let start = model + .face_triangles(face) + .into_iter() + .max_by(|a, b| triangle_area(a).total_cmp(&triangle_area(b))) + .map(|[a, b, c]| (a + b + c) / 3.)?; + // A slight skew keeps the ray off edges and vertices of axis-aligned parts. + let direction = (normal + DVec3::new(0.000_137, 0.000_291, 0.000_173)).normalize(); + let crossings: usize = model + .expand_entities(&BTreeSet::from([owner])) + .iter() + .filter(|id| **id != face_id) + .filter_map(|id| model.faces.get(id)) + .map(|other| model.face_triangles(other).iter().filter(|t| ray_hits(start, direction, t)).count()) + .sum(); + Some(if crossings % 2 == 1 { -1. } else { 1. }) }; outward().unwrap_or(1.) } +fn triangle_area([a, b, c]: &[DVec3; 3]) -> f64 { + (*b - *a).cross(*c - *a).length() +} + +/// Möller–Trumbore: does the ray from `origin` along `direction` cross the triangle ahead of it? +fn ray_hits(origin: DVec3, direction: DVec3, [a, b, c]: &[DVec3; 3]) -> bool { + const EPS: f64 = 1e-12; + let (e1, e2) = (*b - *a, *c - *a); + let p = direction.cross(e2); + let det = e1.dot(p); + if det.abs() < EPS { + return false; + } + let t0 = origin - *a; + let u = t0.dot(p) / det; + if !(0. ..=1.).contains(&u) { + return false; + } + let q = t0.cross(e1); + let v = direction.dot(q) / det; + if v < 0. || u + v > 1. { + return false; + } + e2.dot(q) / det > 1e-9 +} + +/// A face as listed by get_object: id, normal, facing, center and area. +pub fn face_entry(model: &Model, id: u64) -> Option { + face_json(model, id) +} + fn face_json(model: &Model, id: u64) -> Option { let face = model.faces.get(&id)?; let n = faces::plane(model, &face.vertices)?.normal * outward_sign(model, id); @@ -204,9 +367,14 @@ fn face_json(model: &Model, id: u64) -> Option { "normal": unit(n), "area_mm2": (model.face_area(face) * 1.0e6).round(), }); - if let Some(center) = model.face_center(face) { + // The core has no centroid for some concave faces; any interior point will do. + let center = model.face_center(face).or_else(|| { + model.face_triangles(face).into_iter().max_by(|a, b| triangle_area(a).total_cmp(&triangle_area(b))).map(|[a, b, c]| (a + b + c) / 3.) + }); + if let Some(center) = center { entry["center_mm"] = point_mm(center); } + entry["facing"] = Value::Null; if let Some(facing) = facing { entry["facing"] = json!(facing); } @@ -239,9 +407,12 @@ pub fn describe(model: &Model, id: u64) -> Option { } return Some(face); } - if let Some(joint) = joint_json(model, id) { + if let Some(joint) = joint_detail(model, id) { return Some(joint); } + if let Some(annotation) = annotation_json(model, id) { + return Some(annotation); + } if model.contains_entity(&id) { let mut entry = json!({"id": id, "kind": kind_of(model, id)}); if let Some(bounds) = bounds_json(model, &BTreeSet::from([id])) { @@ -252,6 +423,26 @@ pub fn describe(model: &Model, id: u64) -> Option { None } +/// A dimension (its points and measured length) or a text note (its anchor and text). +pub fn annotation_json(model: &Model, id: u64) -> Option { + if let Some(dimension) = model.dimensions.get(&id) { + let (a, b) = dimension.points(model); + let mut entry = json!({ + "id": id, "kind": "dimension", "start_mm": point_mm(a), "end_mm": point_mm(b), + "offset_mm": point_mm(dimension.offset), "length_mm": mm(dimension.length(model)), + }); + if let Some(axis) = dimension.axis { + entry["axis"] = json!(axis_name(axis.vector())); + } + return Some(entry); + } + let text = model.texts.get(&id)?; + Some(json!({ + "id": id, "kind": "text", "point_mm": point_mm(text.anchor.position(model)), + "offset_mm": point_mm(text.offset), "text": text.text, + })) +} + /// Details for entities the prompt names, keyed by id, within `budget` /// characters; the rest are counted under `omitted` (use get_object for them). pub fn references(model: &Model, ids: &BTreeSet, budget: usize) -> Value { diff --git a/docs/README.md b/docs/README.md index e396028..477c188 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,5 +6,7 @@ Guides for using the desktop and browser editor. - [Controls and modeling](controls.md): keyboard shortcuts, navigation, drawing, measurements, objects, and solid tools. - [Right sidebar and preferences](sidebar.md): Outliner, Components, Tags, Display, and remembered settings. - [Projects, import, export, and recovery](projects.md): saving projects, SketchUp import, OBJ export, autosave, and undo history. +- [Command line and AI agents](cli.md): the `benchup` command for reading and editing projects from a shell, an AI coding agent or MCP. +- [Agent guide](agent-guide.md): conventions, workflow and woodworking recipes for agents (`benchup guide`). For development setup, building, testing, and deployment, see the [developer README](../README.md). diff --git a/docs/agent-guide.md b/docs/agent-guide.md new file mode 100644 index 0000000..06bb269 --- /dev/null +++ b/docs/agent-guide.md @@ -0,0 +1,122 @@ +# Building furniture with the BenchUp tools + +[Documentation](README.md) · [Command line](cli.md) + +This guide is written for AI agents that edit BenchUp projects through the [`benchup` command line](cli.md) or its MCP server. Agents read it with `benchup guide` (over MCP, the `get_guide` tool), and it always matches the installed version. + +## Conventions + +- Lengths are millimetres. Z is up. X runs left to right (width), Y is depth, and **the front of a piece faces -Y**: the BenchUp front view looks from -Y, so a wall behind a piece is on its +Y side. +- `create_board` takes the minimum corner (`origin_mm`) and the size along X, Y and Z (`size_mm`). Prefer it to drawing. +- Set the grain along a board's longest side: Z for sides and legs, X for shelves, tops and rails. +- Common thicknesses: 16, 18 or 19 mm for panels, 12 to 15 mm for drawer sides, 6 mm (3 to 9 mm) for backs and drawer bottoms. Legs are 40 to 80 mm square. + +## Workflow + +1. Read the model first with `get_scene` (`benchup scene`). It lists the project name, objects with their sizes, joints, dimensions, texts, loose geometry and stock materials. +2. Plan the whole change. For anything with repeated parts, write a small script that computes the positions and prints a batch file. +3. Apply it as **one batch**: one undo step, and all-or-nothing. +4. Verify: + - `check_model` finds boards that pass through others, overlaps without a joint, joints that cannot be cut or look reversed, boards thicker or thinner than their material, and loose geometry. It also counts the joints that cut. Read `passed` (no errors or warnings); `ok` only says the check ran. + - `get_object` on a joint shows its settings and status. + - `get_cut_list` lists the boards to cut. +5. With the CLI, `benchup open file.bup` shows the result in the desktop app. The CLI edits the file, not an open window: run `open` again to reload, and save or discard changes in the app before editing the file. + +### Batches, results and dry runs + +- Every call that changes the model returns `created`, `modified` and `erased` ids. Some return more: `joint_id` (`add_joint`), `material_id` (`create_stock_material`), `face_id` (`draw_polyline` and `push_pull`), and `members` (`group`). `benchup tools ` describes each tool. +- Ids are not consecutive, so always take them from results. In a batch, `"$N.field"` is a value from call N's result, counting from 1. Any field works, including nested ones: `"$1.created[0]"`, `"$3.joint_id"`, `"$2.material_id"`, `"$4.members[0].id"`, `"$5.created[-1]"`. A reference may stand in for any argument, even where the schema says integer or uuid. A reference to a list of ids inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both; other lists, such as a point in `points_mm`, stay one item. Text that is not exactly a reference, such as `"$12.50 hardware"`, is kept as written. +- With the CLI, `--dry-run` reports every result without changing the file; ids made in a dry run differ from the real run. **End a dry-run batch with `check_model` to validate the change before applying it.** Read tools inside a batch see the batch's earlier changes. `--brief` leaves out the object summaries of changing calls and keeps ids and bounds. +- When a call in a batch fails, nothing is changed. The output names the failed call and shows `earlier_results`, what the calls before it returned. + +## Groups, components and scope + +- Every board is a group. Editing tools (`move`, `resize_board`, `rename`, `tag`, `erase`, `add_joint`, `push_pull`…) reach parts inside groups and components by themselves when all the ids in a call share one group. Ids from different groups need separate calls. +- To **create** a part inside a group, open it with `enter_object` (nested groups from the outside in). With the CLI, the opened group lasts only for that command, so put `enter_object` and the creating calls in one `--batch` and finish with `exit_to_root`. +- `group` with several ids makes ONE group, or one component with `component: true`. With one board and `component: true`, it wraps that board in a new component. Grouping keeps existing joints. Two components with the same name are still different definitions; only `create_copy` makes linked instances. +- Components suit identical parts whose joints stay inside them, such as a drawer box. Build one completely, including its joints, make it a component, then `create_copy` it to each position. Instances of one component share a `definition` id in the scene. +- A joint can only be added between boards at the same level. A board inside a component instance cannot be joined to a board outside it. Making a board that already has joints into a component keeps those joints, but `create_copy` does not copy joints with boards outside the component, and they cannot be added to the copies. +- **When the user asks for housed parts to be one component** (shelves in grooves in the sides), it cannot keep the grooves. By default keep them as separate identical boards, with the same name and tag, and say why. Offer the alternative: one component of shelves resting on pins or cleats, without joints to the sides. +- A tag on a group applies to the boards inside it (the scene shows the inherited tag). Tag boards directly when parts of one group need different tags. +- Erasing a board also removes its joints. `remove_joint` deletes a joint alone. Moving, resizing or rotating a board keeps its joints. + +## Joints + +A joint joins two boards that **overlap** where they meet; the overlap is the joint's depth. Make the overlap first by placing one board partly inside the other, then call `add_joint`. Joints are cut when BenchUp evaluates the model, so the scene still shows plain boxes. `get_object` on the joint reports `"status": "ok"` or the reason it cannot be cut, and `add_joint` refuses joints that cannot be cut. + +- **`a` carries the tenon, tongue, tails, fingers or lap**: the board that goes into the other (shelf, apron, drawer side, back panel). +- **`b` receives** the mortise, groove or sockets (side, leg, drawer front). +- Leave `depth_axis`/`width_axis` out. BenchUp picks the woodworking orientation: depth is the direction `a` goes into `b`. Tongues, tenons and rabbets are spread across `a`'s thickness; dovetails, fingers and laps across its broad side. The result reports the chosen orientation. +- A joint with `a` and `b` reversed would cut the tongue into the side and the groove into the shelf. `check_model` reports it, and `flip_joint` swaps the roles while keeping the joint's settings. +- Settings by kind: `mortise_tenon` uses `width_ratio` (tenon thickness as a share of `a`'s thickness) and `height_ratio`; `tongue_groove` and `rabbet` use `width_ratio`; `dovetail` uses `dovetails`, `width_ratio` (share of the width taken by tails) and `taper_degrees`; `finger` uses `fingers`. All use `clearance_mm`. + +| Situation | Overlap | Joint | +|---|---|---| +| Shelf housed in a side | Shelf enters the side by about half the side's thickness (9 mm into 18) | `tongue_groove`, a = shelf, b = side | +| Apron into a leg | Apron enters the leg 25 to 35 mm, set back 5 to 10 mm from the leg's outer face | `mortise_tenon`, a = apron, b = leg, `width_ratio` about 0.33 (tenon one third of the apron's thickness), `height_ratio` about 0.75 | +| Back panel in a groove | Back (6 mm) enters each side by about half the side's thickness, 6 to 10 mm in from the back edge | `tongue_groove`, a = back, b = side | +| Back panel in a rabbet | Back enters each side by about half the side's thickness, flush with the back edge. Stop housed shelves 2 mm short of it (see below) | `rabbet`, a = back, b = side | +| Drawer side into the front (half-blind dovetail) | Side enters the front from behind, leaving 4 to 6 mm of the front | `dovetail`, a = drawer side, b = front, `dovetails` 3 to 5 | +| Drawer bottom | Bottom (6 mm) enters the drawer sides by 6 mm and stops 1 to 2 mm behind the front, clear of the dovetails | `tongue_groove`, a = bottom, b = side | +| Frame corners | Rails and stiles cross by their full width | `half_lap`, a = rail, b = stile | +| Box corners | Two panels overlap by the other's thickness | `finger`, a and b either way | +| Plinth between the sides | Plinth enters each side by about half the side's thickness | `tongue_groove`, a = plinth, b = side; or butt it without a joint | +| Top on the sides | Sides enter the top from below; the groove shows at the top's edges unless the sides stop short of them | `tongue_groove`, a = side, b = top; or rest the top on the sides and note the fixing | + +### Several joints in one board + +Two joints cut into the same board must not cross, and must not meet face to face inside it. For example, a shelf's groove that ends exactly where the back's rabbet begins leaves a sliver of zero thickness. The joint is then refused with a "closed solid" or "valid solid geometry" error, and the error names the other joints on those boards that touch it. Joints that only share the board's outer face are fine, such as a back and a plinth both reaching the floor. Keep at least 1 to 2 mm of wood between joint regions inside a board: + +- **Shelves and a back:** stop shelves, top and bottom 1 to 2 mm short of the back, not flush with it. Then a rabbet or a groove for the back both work. +- **Drawer bottom:** groove it into the drawer sides only, and stop it 1 to 2 mm behind the front. Where the sides already enter the front with dovetails, a groove in the front would meet them. +- **The same error with no other joint nearby** comes from the board's own geometry: a shaped board with many small faces, or vertices very close to the joint's planes. Change the number of segments of a curve (24 and 48 are known to work) or move the joint a few millimetres, and test in a `--dry-run`. +- **Two aprons meeting in one leg:** their tenons must not meet inside the leg. With leg width P, apron setback s from the leg's outer face and apron thickness t, keep the tenon depth below P − s − t (70 − 10 − 22 = 38 mm, so use 30). +- **A central divider:** shelves on both sides at the same height would cut it through with two 9 mm grooves. Let them enter by a third of its thickness each, stagger the heights, or butt them against it without a joint. + +## Recipes + +- **Carcass (cabinet, bookcase):** two sides, a top and a bottom (between the sides, or the top over them), shelves, and a back in a groove. Stop everything inside 1 to 2 mm short of the back. +- **Even shelves:** with an inner height H (between the bottom and the top, or the full side height if there are none), n shelves of thickness t and equal gaps above, below and between, the gap is (H - n·t)/(n + 1). Shelf k (counting from 1) has its bottom at k·gap + (k - 1)·t above the inner bottom. To change the number of shelves, erase or copy shelves, then `move` them to the new heights; their joints follow. +- **Plinth:** a board 60 to 100 mm high between the sides, set back 20 to 50 mm from the front, usually under a bottom panel resting on it. A divider that reaches the floor splits it in two; butt each half against the divider. +- **A back behind a central divider:** stop the divider at the back's front face so one back runs behind both bays. +- **A top with chamfered or rounded front corners over the sides:** let the top overhang the sides at the front by at least the chamfer, or set the sides back, so their corners do not show under the cut. +- **Table:** legs 60 to 80 mm square; aprons 80 to 120 mm high and 20 to 25 mm thick, their tops flush with the legs, tenons as above; a top 25 to 40 mm thick resting on the frame with a 20 to 50 mm overhang. Ask whether the size given is the top or the frame, and whether the height includes the top. +- **Drawer:** build one drawer as a component (front, sides, back, bottom and their joints), then `create_copy` it up by the front's height plus the gap. Inset fronts leave about 2 mm at each side and 3 to 4 mm between drawers. The box (sides, back and bottom) has sides 12 to 15 mm thick and 30 to 50 mm lower than the front, and is 10 to 20 mm shorter than the carcass's inner depth. The bottom is 6 mm, grooved into the sides about 10 mm above their lower edge, and the back sits on the bottom. With metal runners, make the box about 12.5 mm narrower on each side, or as the runner specifies; with wooden runners, about 2 mm. +- **Lengthening a piece:** `resize_board` the boards that run in that direction, choosing the end with `anchor`, and `move` the parts at that end. `resize_board` stretches the whole board, notches included. For a board with notches or a shape, `push_pull` its end face by the extra length instead; that keeps the notches where they are. Faces in one plane merge after a push. To push only part of a face, split it first with a `draw_polyline` line across it (inside the board, with `enter_object`); the result lists each new face with its `center_mm`. The seam edges left in a flat face are harmless. Do not `erase` them: erasing an edge removes the faces on it. +- **Before choosing which end to lengthen,** look for walls and other parts in the way. The room in an example project can be a group of plain faces (a "Floor" with walls), whose faces `get_object` lists with their positions. +- **Changing the stock thickness while keeping the outside size:** resize each board's thickness with `resize_board`, using `anchor` to keep the outer faces in place (`["max", "min", "min"]` for a right side, `"center"` for dividers and shelves), and keep each joint's overlap at about half the new thickness. Keeping the shelves' centres keeps the gaps symmetric, but a change of an odd number of millimetres gives half millimetres; anchor the shelves at `"min"` for whole numbers. Recompute equal gaps only if asked. Create the new material and assign it; `check_model` reports boards whose thickness does not match their material. +- **A leaning piece (ladder shelf):** work out each upright's outline as it will stand: back edge leaning θ from vertical and touching the wall at the top, foot and top cut level. Rotate that outline by +θ about x around the foot of its back edge, which stands it upright, and draw it there (see shaped boards). Set its grain to z, then `rotate` it by −θ about x around the same point, so its top leans towards the wall at +Y. An upright of constant width w (measured square to its edges) and height H has edges H/cos θ long and level cuts w/cos θ long, and the foot of its back edge sits H·tan θ from the wall. Uprights of constant width give every shelf the same depth. For shelves that get shallower towards the top, taper the uprights (wider at the foot), or let the shelves stick out in front of narrow uprights. Keep shelf edges a few millimetres away from the uprights' sloping faces; a near-coincident edge leaves slivers smaller than the model precision. In the scene, a rotated board's grain shows as a vector, and the cut list measures it unrotated. +- **Materials and cut list:** `create_stock_material` (name, thickness, width, length; the length along the grain), then `set_material` on the boards. The material's thickness should match the boards'. `get_cut_list` groups boards by material and size; `minimum_stock_pieces`, an area lower bound, appears once boards have a material with sizes. To answer "how many sheets do I need" without changing the project, create and assign the material and call `get_cut_list` in one `--dry-run` batch. +- **Dimensions for the workshop:** put overall height and width, and a chain of shelf gaps, in the front plane (y = 0) so they read in the front view; put the depth in the side plane (x = the width), offset upwards. A dimension measures the straight distance between its points. Pass `axis` to measure only along x, y or z, so a slightly misplaced point cannot tilt it. Points exactly on a corner, edge or face attach to it and follow it. Nothing snaps, so use real corners: where a shelf enters a side, its visible corner is on the side's inner face. `offset_mm` pushes the line away from the piece: chain dimensions about 100 mm out, overall ones about 250 mm out, so they do not overlap. `add_dimension` returns `length_mm`, and `get_scene` lists every dimension. Put the piece's name in a text above it. Ask whether shelf spacing means clear space or centre to centre. + +## Shaped boards (curves, chamfers, angled ends) + +The tools have no arc tool and do not expose BenchUp's Router yet (edge chamfers, bevels, roundovers, coves, rabbets, grooves and other profiles). For a profile along an edge, say that the user can apply it with the Router in BenchUp, or cut the shape into the outline as below. Draw the board's outline as one closed `draw_polyline`, using many short segments for a curve, on the plane where the board starts. Then `push_pull` its face to the thickness and `group` everything the two calls created. Finish with `set_grain` and a name. In one batch: + +```jsonl +{"tool": "draw_polyline", "arguments": {"points_mm": [[0,0,0],[440,0,0],[450,10,0],[450,350,0],[0,350,0]], "close": true}} +{"tool": "push_pull", "arguments": {"face_id": "$1.face_id", "distance_mm": 18}} +{"tool": "group", "arguments": {"ids": ["$1.created", "$2.created"], "name": "Top", "component": false}} +{"tool": "set_grain", "arguments": {"id": "$3.created[0]", "axis": "x"}} +``` + +A loose face's normal follows the order of its points: counter-clockwise seen from the side it faces. The outline above extrudes towards +z. A side panel drawn in the plane x = 0 extrudes towards +x when its points run counter-clockwise seen from +x, that is with y and z as the axes on the page: [[0,0,0],[0,350,0],[0,350,532],[0,0,532]]. Check the direction in the result's `bounds_mm` with `--dry-run`. The cut list measures a board along its grain, so draw a part in its natural orientation, set the grain, then rotate it into place. Joints work on shaped boards too. + +For a curved edge with chord c and depth s (how far the curve goes in at its middle), the radius is R = (c²/4 + s²)/(2s). Generate 24 to 48 points along the arc in a script. Parts behind a curved front (shelves, a bottom) must be set back far enough to stay behind the curve at their height. + +Avoid cutting through a finished board with a negative `push_pull` of its full thickness. It currently leaves stray faces and edges, and `check_model` reports an open solid. Rebuild the part from its outline instead. + +## When to stop and ask + +- A requested size does not fit, such as a shelf longer than its opening. Measure the free space from `bounds_mm`: from one board's inner face to the next. A housed shelf is that gap plus both overlaps. Try the change in a `--dry-run` batch ending with `check_model`, report what you measured, and ask instead of forcing it. +- Ambiguous terms. Ask whether "two columns" means two bays, and whether shelf spacing is clear space or centre to centre. Ask whether "1000 mm from the floor" is the top or the bottom face. Ask whether a front is inset or overlaid, which end of a piece to lengthen, and whether a size is the top or the frame. "The sides" of a bay can include a divider. +- A request that conflicts with how the piece is built, such as housed shelves as one component. Keep the construction, do what can be done, and offer the alternative. +- Problems already in the model that the user did not mention, such as joints `check_model` reports as reversed. Report them and offer to fix them. If the change touches those joints anyway, fixing them is reasonable; do it as a separate batch so it can be undone on its own. +- In each case, propose an interpretation and say which one you used. +- When you cannot ask (no one to answer), do not apply a change that departs from the request. Leave the project as it was, save the tested alternative as a batch file, and report both. + +## Limitations + +- Joints are cut from the overlap when BenchUp evaluates the model. The tools show the boards before cutting; use the joint's `status` to confirm. +- There is no fixing hardware (screws, dowels, metal runners). Leave a top resting on its frame, and note the fixing with `add_text` if it matters. +- Some BenchUp features are not available through the tools yet: the Router's edge profiles, the Cutting Plan (stock layouts with saw kerf and grain), Workshop Labels and the Time & Cost Estimates PDFs, trimming excess material at joints, and wood finishes. `get_cut_list` gives an area lower bound; for a real layout, the user can export the Cutting Plan from BenchUp's Tools menu. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..62629f3 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,82 @@ +# Command line and AI agents + +[Documentation](README.md) · [Agent guide](agent-guide.md) · [Developer README](../README.md) + +`benchup` reads and edits BenchUp `.bup` projects from a shell or an AI coding agent (Claude Code, Codex, Cursor…), without opening the app. It reads every project format the app saves, including files with a viewport preview. Lengths are millimetres and Z is up. + +## Installation + +The command is built from this repository with Rust 1.95 or newer: + +```sh +cargo install --locked --path crates/benchup-cli # installs `benchup` in ~/.cargo/bin +``` + +Make sure `~/.cargo/bin` is on your `PATH`. The installed command does not update itself: run the same command again after pulling changes. The desktop app is installed separately. + +## Commands + +```sh +benchup guide # conventions, workflow and woodworking recipes +benchup new shelf.bup --name "Shelf" # create an empty project +benchup tools # list the tools +benchup tools create_board # one tool's JSON schema +benchup scene shelf.bup # scene summary: objects, sizes, grain, stock, joints +benchup get shelf.bup 79 # one entity in detail, with its faces + +benchup call shelf.bup create_board '{"name":"Side","origin_mm":[0,0,0],"size_mm":[18,300,900],"grain_axis":"z"}' +echo '{"ids":[79],"delta_mm":[0,0,10]}' | benchup call shelf.bup move - + +benchup call shelf.bup --batch ops.jsonl # several calls as ONE undo step, all-or-nothing +benchup call shelf.bup --batch ops.jsonl --dry-run --brief # try it without saving, compact output +benchup call shelf.bup check_model # overlaps without joints, joints that cannot be cut, ... +benchup call shelf.bup get_cut_list # boards by material and size +benchup undo shelf.bup +benchup redo shelf.bup + +benchup mcp shelf.bup # the same tools over MCP (stdio) + +benchup open shelf.bup # open it in the BenchUp desktop app +benchup open shelf.bup --app target/debug/benchup-app # or in a specific executable +``` + +A batch file has one call per line. Blank lines and lines starting with `#` are skipped: + +```jsonl +{"tool": "create_board", "arguments": {"name": "Left", "origin_mm": [0, 0, 0], "size_mm": [18, 300, 900]}} +{"tool": "create_board", "arguments": {"name": "Right", "origin_mm": [782, 0, 0], "size_mm": [18, 300, 900]}} +{"tool": "create_board", "arguments": {"name": "Shelf", "origin_mm": [18, 0, 400], "size_mm": [764, 300, 18]}} +``` + +In a batch, `"$N.field"` is a value from the result of call N (counting from 1): `"$1.created[0]"`, `"$3.joint_id"`, `"$2.face_id"`. A reference to a list of ids inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both; other lists, such as a point, stay one item. Text that is not exactly a reference, such as `"$12.50 hardware"`, is kept as written, and a single call never resolves references: + +```jsonl +{"tool": "create_board", "arguments": {"name": "Side", "origin_mm": [0, 0, 0], "size_mm": [18, 300, 900]}} +{"tool": "create_board", "arguments": {"name": "Shelf", "origin_mm": [9, 0, 400], "size_mm": [500, 300, 18]}} +{"tool": "add_joint", "arguments": {"a": "$2.created[0]", "b": "$1.created[0]", "kind": "tongue_groove"}} +``` + +## Behaviour + +- **Output:** JSON on stdout. Exit status is `0` on success, `1` when a tool reported an error (`{"ok": false, "error", "hint"}`, file unchanged), and `2` for a usage or I/O error (message on stderr). +- **History:** each `call` is one entry in the project history, labelled “Agent edit” (in the app's language); a batch is one entry. You can undo it in BenchUp or with `benchup undo`. +- **Atomic saves:** the file is written through a temporary file and a rename. If the file changed on disk since it was read (for example, BenchUp saved it), the command refuses to overwrite it. `benchup mcp` reads the file again before each call, so it carries on from what BenchUp saved. +- **Shared projects:** local copies of shared projects are refused unless you pass `--allow-shared`, because edits would diverge from the server. +- **Opening in the app:** `benchup open` hands the file to the installed desktop app (by bundle id on macOS, through the `.bup` file association on Linux and Windows). `--app `, or the `BENCHUP_APP` environment variable, starts that executable instead, which is handy for a development build. The app must be installed separately: [download it](https://github.com/BenchUp3D/editor/releases/latest). +- **Not live:** the CLI edits the file, not a running BenchUp window. Run `benchup open` again after a change to reload it; if the project has unsaved changes in the app, BenchUp asks first. Saving in BenchUp overwrites the file without checking for outside changes, so save or discard your app edits before editing with the CLI. Live editing through a local bridge is planned ([#13](https://github.com/BenchUp3D/editor/issues/13)). + +## Using it from an agent + +Agents should read `benchup guide` first (over MCP, the `get_guide` tool); it is the [agent guide](agent-guide.md). It explains the conventions (the front faces -Y), which board is `a` in a joint, the batch workflow, woodworking recipes and current limitations. + +To make Claude Code reach for the CLI on its own, install the skill: + +```sh +cp -r crates/benchup-cli/skill/benchup ~/.claude/skills/ +``` + +With Claude Code, either let it call the CLI directly ("use `benchup` to add a shelf to shelf.bup"), or register the MCP server: + +```sh +claude mcp add benchup -- benchup mcp /path/to/shelf.bup +``` diff --git a/site/llms.txt b/site/llms.txt index cafa186..7553ea5 100644 --- a/site/llms.txt +++ b/site/llms.txt @@ -21,6 +21,7 @@ BenchUp helps woodworkers, furniture makers, and DIY builders model shelves, tab - Workshop Labels PDF: one label per piece with a part mark matching the cutting plan, group or component name, dimensions, and dotted cut borders. Cutting plans and labels can cover selected parts or all visible parts; assign stock materials first and verify dimensions before cutting. - Storage: local autosave and portable .bup project files (default), with support for legacy .bup files. No account required. Download projects to keep backups; clearing browser data can remove browser autosaves. - Interchange: import SketchUp (.skp) geometry; export Wavefront OBJ (.obj) geometry. SketchUp textures, scenes, and some features are not supported; OBJ export does not include materials. +- Command line and AI agents: the `benchup` command, built from the source repository for now (it is not in the desktop releases yet), reads and edits .bup projects from a shell or an AI coding agent, and serves the same tools over MCP. Agents should run `benchup guide` first; changes are undoable steps in the project history, and the command can open the result in the desktop app. - Intended use: furniture layout, exploring proportions, dimensioned 3D woodworking models, wood joinery, grain orientation, and workshop planning. Mobile modeling and cloud storage are outside this version. ## Official resources @@ -29,5 +30,7 @@ BenchUp helps woodworkers, furniture makers, and DIY builders model shelves, tab - [Browser editor](https://benchup3d.github.io/editor/play/): try the application. - [Source repository](https://github.com/BenchUp3D/editor): implementation and developer documentation. - [User guide](https://github.com/BenchUp3D/editor/tree/main/docs): modeling instructions and workflows. +- [Command line and AI agents](https://github.com/BenchUp3D/editor/blob/main/docs/cli.md): installing and using the `benchup` command and its MCP server. +- [Agent guide](https://github.com/BenchUp3D/editor/blob/main/docs/agent-guide.md): conventions, workflow and woodworking recipes for agents editing projects. - [Latest desktop release](https://github.com/BenchUp3D/editor/releases/latest): downloads and release notes. - [MIT license](https://github.com/BenchUp3D/editor/blob/main/LICENSE).