From ddbb62cf8290b109352d8d4b3b81479bab30d41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Wed, 11 Nov 2026 08:29:46 +0100 Subject: [PATCH 01/12] Add benchup, a CLI to read and edit .bup projects Built on benchup-tools: list tools and schemas, read the scene, run any tool or an all-or-nothing batch as one undo step, undo/redo, and serve the same tools over MCP on stdio (protocol code from #3). Saves are atomic and refuse to overwrite a file changed on disk. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr.yml | 3 + Cargo.lock | 10 ++ Cargo.toml | 1 + README.md | 2 + crates/benchup-cli/Cargo.toml | 19 +++ crates/benchup-cli/README.md | 52 +++++++ crates/benchup-cli/src/main.rs | 237 ++++++++++++++++++++++++++++++ crates/benchup-cli/src/mcp.rs | 88 +++++++++++ crates/benchup-cli/src/project.rs | 171 +++++++++++++++++++++ crates/benchup-cli/tests/cli.rs | 166 +++++++++++++++++++++ 10 files changed, 749 insertions(+) create mode 100644 crates/benchup-cli/Cargo.toml create mode 100644 crates/benchup-cli/README.md create mode 100644 crates/benchup-cli/src/main.rs create mode 100644 crates/benchup-cli/src/mcp.rs create mode 100644 crates/benchup-cli/src/project.rs create mode 100644 crates/benchup-cli/tests/cli.rs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5ed21a1..db04e00 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 + io: name: Test geometry import and export runs-on: ubuntu-24.04 diff --git a/Cargo.lock b/Cargo.lock index 1983b87..71b4c8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,6 +449,16 @@ dependencies = [ "zip", ] +[[package]] +name = "benchup-cli" +version = "0.7.0" +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 9dc2250..1e01284 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,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 its [README](crates/benchup-cli/README.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/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..1fcc371 --- /dev/null +++ b/crates/benchup-cli/README.md @@ -0,0 +1,52 @@ +# benchup (CLI) + +Read and edit BenchUp `.bup` projects from a shell or an AI coding agent (Claude Code, Codex, Cursor…), without opening the app. + +```sh +cargo install --path crates/benchup-cli # installs the `benchup` binary +``` + +The tools come from [`benchup-tools`](../benchup-tools), the same operation layer an MCP server or in-app integration can use. Lengths are millimetres and Z is up. + +## Commands + +```sh +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 undo shelf.bup +benchup redo shelf.bup + +benchup mcp shelf.bup # the same tools over MCP (stdio) +``` + +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]}} +``` + +## 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: `, or `Agent: N changes` for a batch. 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. +- **Shared projects:** local copies of shared projects are refused unless you pass `--allow-shared`, because edits would diverge from the server. +- **Not live:** the CLI edits the file, not a running BenchUp window. Reopen the project to see the changes. Live editing through a local bridge is planned (see #13). + +## Using it from an agent + +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/crates/benchup-cli/src/main.rs b/crates/benchup-cli/src/main.rs new file mode 100644 index 0000000..7d7e987 --- /dev/null +++ b/crates/benchup-cli/src/main.rs @@ -0,0 +1,237 @@ +//! `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 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. + +usage: + benchup new [--name NAME] [--force] create an empty project + 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 + benchup undo undo the last change + benchup redo redo the last undone change + benchup mcp serve the tools over MCP (stdio) + +options: + --allow-shared edit the local copy of a shared project + +Every change is one undo step in BenchUp and the file is saved atomically. +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 { + let batch = calls.len() > 1; + Ok(match project.apply(calls)? { + 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 } => Output { + value: json!({ + "ok": false, + "failed_call": index + 1, + "tool": calls[index].0, + "result": result, + "note": "nothing was changed; fix this call and run the whole batch again", + }), + ok: false, + }, + }) +} + +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}))]) + } + "call" => { + let args = parse_args(argv, &["--allow-shared"], &["--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()), + }; + apply(&mut open(&args)?, &calls) + } + "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 }) + } + "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..047ea47 --- /dev/null +++ b/crates/benchup-cli/src/mcp.rs @@ -0,0 +1,88 @@ +//! 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."; + +/// 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..f95f3e2 --- /dev/null +++ b/crates/benchup-cli/src/project.rs @@ -0,0 +1,171 @@ +//! 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; +use benchup_tools::{Call, ToolEnv, execute, files}; +use serde_json::{Value, json}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; + +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, + pub doc: Document, + env: ToolEnv, + stamp: u64, + counter: u64, +} + +/// 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. + Failed { index: usize, result: Value }, +} + +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 = 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() + )); + } + let env = ToolEnv { + catalog: doc.model.stock_materials.clone(), + selection: doc.selection.clone(), + ..ToolEnv::default() + }; + Ok(Self { path, doc, env, stamp: stamp(&bytes), counter: 0 }) + } + + /// 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::encode_document(&self.doc)?; + files::save_atomic(&self.path, &bytes)?; + self.stamp = stamp(&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 { + let original = self.doc.clone(); + let selection_before = self.env.selection.clone(); + self.env.before = None; + self.env.created.clear(); + let mut results = Vec::with_capacity(calls.len()); + let mut mutated = false; + for (index, (name, arguments)) in calls.iter().enumerate() { + self.counter += 1; + let call = Call::new(self.counter.to_string(), name, arguments.clone()); + let outcome = execute(&mut self.doc.model, &call, &mut self.env); + if !outcome.ok { + self.doc = original; + self.env.selection = selection_before; + return Ok(Applied::Failed { index, result: outcome.result }); + } + mutated |= outcome.mutated; + results.push(outcome.result); + } + if mutated { + let label = match calls { + [(name, _)] => format!("Agent: {name}"), + _ => format!("Agent: {} changes", calls.len()), + }; + self.doc.history.record(label, original.model.clone(), &self.doc.model); + } + self.doc.selection = self.env.selection.clone(); + if (mutated || self.env.selection != selection_before) + && let Err(error) = self.save() + { + self.doc = original; + self.env.selection = selection_before; + return Err(error); + } + Ok(Applied::Ok(results)) + } + + /// 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. +impl crate::mcp::Backend for Project { + fn call(&mut self, _client: &str, name: &str, arguments: Value) -> (Value, bool) { + 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"); + } +} diff --git a/crates/benchup-cli/tests/cli.rs b/crates/benchup-cli/tests/cli.rs new file mode 100644 index 0000000..90e4d33 --- /dev/null +++ b/crates/benchup-cli/tests/cli.rs @@ -0,0 +1,166 @@ +//! 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: create_board"]); + 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: 2 changes"]); + + 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: create_board"})); + 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); +} From 795a9e12c77f8ee0a8e1724b417b67b1baac8dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Thu, 12 Nov 2026 23:23:46 +0100 Subject: [PATCH 02/12] Add benchup open; remove joints with their boards; clearer scope - benchup open hands a .bup to the installed desktop app (bundle id on macOS, file association elsewhere), or to --app / BENCHUP_APP. - erase removes the joints of the boards it deletes (the core keeps them, BenchUp3D/editor#36), refuses joint ids with a pointer to the new remove_joint tool, and the scene flags joints whose board is gone. - Scope errors name every object to open, from the outside in; the help, MCP instructions and enter_object say how long a group stays open. - find_objects: query is optional and its description matches what it does. Co-Authored-By: Claude Opus 5.5 --- crates/benchup-cli/README.md | 8 +- crates/benchup-cli/src/launch.rs | 84 +++++++++++++++++ crates/benchup-cli/src/main.rs | 14 +++ crates/benchup-cli/src/mcp.rs | 3 +- crates/benchup-cli/tests/cli.rs | 124 ++++++++++++++++++++++++++ crates/benchup-tools/src/ops.rs | 94 +++++++++++++++++-- crates/benchup-tools/src/ops_tests.rs | 94 +++++++++++++++++++ crates/benchup-tools/src/scene.rs | 8 +- 8 files changed, 416 insertions(+), 13 deletions(-) create mode 100644 crates/benchup-cli/src/launch.rs diff --git a/crates/benchup-cli/README.md b/crates/benchup-cli/README.md index 1fcc371..682f435 100644 --- a/crates/benchup-cli/README.md +++ b/crates/benchup-cli/README.md @@ -3,7 +3,7 @@ Read and edit BenchUp `.bup` projects from a shell or an AI coding agent (Claude Code, Codex, Cursor…), without opening the app. ```sh -cargo install --path crates/benchup-cli # installs the `benchup` binary +cargo install --locked --path crates/benchup-cli # installs `benchup` in ~/.cargo/bin ``` The tools come from [`benchup-tools`](../benchup-tools), the same operation layer an MCP server or in-app integration can use. Lengths are millimetres and Z is up. @@ -25,6 +25,9 @@ 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: @@ -41,7 +44,8 @@ A batch file has one call per line. Blank lines and lines starting with `#` are - **History:** each `call` is one entry in the project history, labelled `Agent: `, or `Agent: N changes` for a batch. 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. - **Shared projects:** local copies of shared projects are refused unless you pass `--allow-shared`, because edits would diverge from the server. -- **Not live:** the CLI edits the file, not a running BenchUp window. Reopen the project to see the changes. Live editing through a local bridge is planned (see #13). +- **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 (see #13). ## Using it from an agent diff --git a/crates/benchup-cli/src/launch.rs b/crates/benchup-cli/src/launch.rs new file mode 100644 index 0000000..e5dec8e --- /dev/null +++ b/crates/benchup-cli/src/launch.rs @@ -0,0 +1,84 @@ +//! `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. 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(absolute) +} + +/// 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 + } + #[cfg(target_os = "windows")] + { + let mut command = Command::new("cmd"); + command.args(["/C", "start", ""]).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}"))?; + if !output.status.success() { + 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 index 7d7e987..ebc293a 100644 --- a/crates/benchup-cli/src/main.rs +++ b/crates/benchup-cli/src/main.rs @@ -2,6 +2,7 @@ //! //! 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; @@ -26,11 +27,16 @@ usage: 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: --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. @@ -211,6 +217,14 @@ fn run(mut argv: Vec) -> Result { 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)?; diff --git a/crates/benchup-cli/src/mcp.rs b/crates/benchup-cli/src/mcp.rs index 047ea47..b6a07ff 100644 --- a/crates/benchup-cli/src/mcp.rs +++ b/crates/benchup-cli/src/mcp.rs @@ -4,7 +4,8 @@ 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."; +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."; /// Where tool calls go. Returns the tool result and whether it succeeded. pub trait Backend { diff --git a/crates/benchup-cli/tests/cli.rs b/crates/benchup-cli/tests/cli.rs index 90e4d33..5147a55 100644 --- a/crates/benchup-cli/tests/cli.rs +++ b/crates/benchup-cli/tests/cli.rs @@ -164,3 +164,127 @@ fn mcp_serves_tools_over_stdio() { 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 an_open_group_lasts_for_one_command() { + let dir = tempfile::tempdir().unwrap(); + let (path, group, inner) = grouped_project(dir.path()); + let rename = json!({"id": inner, "name": "Inner"}).to_string(); + // Opened in one command, the group is closed again in the next. + assert!(benchup(&["call", &path, "enter_object", &json!({"id": group}).to_string()], None).status.success()); + let output = benchup(&["call", &path, "rename", &rename], None); + assert_eq!(output.status.code(), Some(1)); + let hint = json_out(&output)["hint"].as_str().unwrap().to_owned(); + assert!(hint.contains(&format!("enter_object{{id:{group}}}")) && hint.contains("--batch"), "{hint}"); + // In one batch it works, and the file is left at the top level. + let batch = format!( + "{}\n{}\n", + json!({"tool": "enter_object", "arguments": {"id": group}}), + json!({"tool": "rename", "arguments": {"id": inner, "name": "Inner"}}) + ); + assert!(benchup(&["call", &path, "--batch", "-"], Some(&batch)).status.success()); + let doc = files::decode(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(doc.model.objects[&inner].name, "Inner"); + assert_eq!(doc.model.edit_context, None); +} + diff --git a/crates/benchup-tools/src/ops.rs b/crates/benchup-tools/src/ops.rs index 14a41c5..4193a39 100644 --- a/crates/benchup-tools/src/ops.rs +++ b/crates/benchup-tools/src/ops.rs @@ -116,6 +116,7 @@ const TOOLS: &[(&str, bool)] = &[ ("paint", true), ("erase", true), ("add_joint", true), + ("remove_joint", true), ("add_dimension", true), ("add_text", true), ("select", false), @@ -225,8 +226,8 @@ 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("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, center, area) 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!({}), &[]), @@ -261,7 +262,7 @@ pub fn definitions() -> Vec { 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("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. 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).", json!({"a": id(), "b": id(), "kind": {"type": "string", "enum": joint_kinds}, "params": {"type": "object", "properties": { @@ -272,12 +273,14 @@ pub fn definitions() -> Vec { "width_axis": {"type": "integer", "minimum": 0, "maximum": 2}}, "additionalProperties": false}}), &["a", "b", "kind"]), + def("remove_joint", "Delete wood joints by id (from the joints list in get_scene); the boards stay.", + json!({"ids": ids()}), &["ids"]), 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.", 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 for editing its inner parts. Nested parts need each level opened in turn, 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). push_pull does not need it.", json!({"id": id()}), &["id"]), def("exit_to_root", "Leave any open group and edit the top level again.", json!({}), &[]), ] } @@ -331,6 +334,7 @@ struct Empty {} #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct FindArgs { + #[serde(default)] query: String, limit: Option, } @@ -493,13 +497,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 @@ -817,13 +837,68 @@ 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 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) } fn add_joint(model: &mut Model, a: JointArgs) -> Res { if a.a == a.b { @@ -955,6 +1030,7 @@ 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)?), "add_dimension" => add_dimension(model, parse(arguments)?), "add_text" => add_text(model, parse(arguments)?), "select" => { diff --git a/crates/benchup-tools/src/ops_tests.rs b/crates/benchup-tools/src/ops_tests.rs index fa2b9b6..e743bbc 100644 --- a/crates/benchup-tools/src/ops_tests.rs +++ b/crates/benchup-tools/src/ops_tests.rs @@ -572,3 +572,97 @@ 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(); + let hint = |model: &mut Model, env: &mut ToolEnv| { + run(model, env, "rename", json!({"id": part, "name": "P"})).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); +} diff --git a/crates/benchup-tools/src/scene.rs b/crates/benchup-tools/src/scene.rs index 21b40f9..71c7307 100644 --- a/crates/benchup-tools/src/scene.rs +++ b/crates/benchup-tools/src/scene.rs @@ -121,7 +121,13 @@ fn joint_json(model: &Model, id: u64) -> Option { 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 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. From fd797ec79bae96a4a258cc0058cb434a98bd8ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Thu, 12 Nov 2026 23:38:43 +0100 Subject: [PATCH 03/12] Make the agent tools learnable and checkable; add benchup guide From 12 scenarios run by agents with only the CLI help to go on: - Joints: add_joint picks the woodworking orientation (depth is where a goes into b; tongues and tenons across a's thickness, dovetails and laps across its broad side), validates ranges as the core does, refuses joints that cannot be cut, and get_object shows roles, settings, supported orientations and status. - Faces: outward normals use a ray-parity test instead of "away from the part's centre", which flipped faces of concave parts and made push_pull go the wrong way. Concave faces get a center too. - New tools: create_stock_material, move_to, check_model (unjoined overlaps, uncuttable or orphaned joints, open solids, loose geometry), get_cut_list and get_guide. resize_board takes an anchor. - push_pull and draw_polyline return what they create (face_id), group brings a face's edges along, and the scene lists axes (front is -y), loose geometry ids, dimensions, texts and component definitions. - CLI: "$N.field" references between batch calls, --dry-run, --brief, the project name in scene, benchup guide, and a Claude Code skill. Co-Authored-By: Claude Opus 5.5 --- crates/benchup-cli/README.md | 20 ++ crates/benchup-cli/skill/benchup/SKILL.md | 16 ++ crates/benchup-cli/src/main.rs | 43 +++- crates/benchup-cli/src/mcp.rs | 3 +- crates/benchup-cli/src/project.rs | 92 +++++++- crates/benchup-cli/tests/cli.rs | 55 +++++ crates/benchup-tools/src/guide.md | 80 +++++++ crates/benchup-tools/src/lib.rs | 4 + crates/benchup-tools/src/ops.rs | 255 ++++++++++++++++++++-- crates/benchup-tools/src/ops_tests.rs | 127 +++++++++++ crates/benchup-tools/src/review.rs | 189 ++++++++++++++++ crates/benchup-tools/src/scene.rs | 155 ++++++++++++- 12 files changed, 1001 insertions(+), 38 deletions(-) create mode 100644 crates/benchup-cli/skill/benchup/SKILL.md create mode 100644 crates/benchup-tools/src/guide.md create mode 100644 crates/benchup-tools/src/review.rs diff --git a/crates/benchup-cli/README.md b/crates/benchup-cli/README.md index 682f435..c51532f 100644 --- a/crates/benchup-cli/README.md +++ b/crates/benchup-cli/README.md @@ -11,6 +11,7 @@ The tools come from [`benchup-tools`](../benchup-tools), the same operation laye ## 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 @@ -21,6 +22,9 @@ benchup call shelf.bup create_board '{"name":"Side","origin_mm":[0,0,0],"size_mm 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 @@ -38,6 +42,14 @@ A batch file has one call per line. Blank lines and lines starting with `#` are {"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 inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both: + +```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). @@ -49,6 +61,14 @@ A batch file has one call per line. Blank lines and lines starting with `#` are ## Using it from an agent +Agents should read `benchup guide` first (over MCP, the `get_guide` tool). 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 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/main.rs b/crates/benchup-cli/src/main.rs index ebc293a..7b88578 100644 --- a/crates/benchup-cli/src/main.rs +++ b/crates/benchup-cli/src/main.rs @@ -15,15 +15,18 @@ 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 + 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) @@ -31,6 +34,8 @@ usage: (or in EXECUTABLE, default $BENCHUP_APP) options: + --dry-run (call) report the results without changing the file + --brief (call) leave out object summaries and bounds from results --allow-shared edit the local copy of a shared project Every change is one undo step in BenchUp and the file is saved atomically. @@ -135,8 +140,12 @@ fn parse_batch(text: &str) -> Result, String> { } 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.apply(calls)? { + 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 }, @@ -153,6 +162,21 @@ fn apply(project: &mut Project, calls: &[(String, Value)]) -> Result 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()))), @@ -197,8 +221,12 @@ fn run(mut argv: Vec) -> Result { 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"], &["--batch"])?; + 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!({}))], @@ -208,7 +236,14 @@ fn run(mut argv: Vec) -> Result { } _ => return Err(USAGE.into()), }; - apply(&mut open(&args)?, &calls) + 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"], &[])?; diff --git a/crates/benchup-cli/src/mcp.rs b/crates/benchup-cli/src/mcp.rs index b6a07ff..23153c8 100644 --- a/crates/benchup-cli/src/mcp.rs +++ b/crates/benchup-cli/src/mcp.rs @@ -5,7 +5,8 @@ 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."; +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 { diff --git a/crates/benchup-cli/src/project.rs b/crates/benchup-cli/src/project.rs index f95f3e2..2370f12 100644 --- a/crates/benchup-cli/src/project.rs +++ b/crates/benchup-cli/src/project.rs @@ -80,6 +80,12 @@ impl Project { /// 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 { let original = self.doc.clone(); let selection_before = self.env.selection.clone(); self.env.before = None; @@ -88,7 +94,19 @@ impl Project { let mut mutated = false; for (index, (name, arguments)) in calls.iter().enumerate() { self.counter += 1; - let call = Call::new(self.counter.to_string(), name, arguments.clone()); + let arguments = match resolve(arguments, &results) { + Ok(arguments) => arguments, + Err(error) => { + self.doc = original; + self.env.selection = selection_before; + return Ok(Applied::Failed { + index, + 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.doc = original; @@ -96,7 +114,16 @@ impl Project { return Ok(Applied::Failed { index, result: outcome.result }); } mutated |= outcome.mutated; - results.push(outcome.result); + 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.doc = original; + self.env.selection = selection_before; + return Ok(Applied::Ok(results)); } if mutated { let label = match calls { @@ -116,6 +143,67 @@ impl Project { Ok(Applied::Ok(results)) } +} + +/// Replaces every string of the form `$N.path` (N counts calls from 1, path is +/// `.field` and `[index]` steps, negative indexes count from the end) with that +/// value from an earlier result. +fn resolve(value: &Value, results: &[Value]) -> Result { + match value { + Value::String(text) if text.starts_with('$') && text.len() > 1 && text.as_bytes()[1].is_ascii_digit() => { + reference(text, results) + } + // A reference to a list inside a list is spliced in, so + // "ids": ["$1.created", "$2.created"] joins both lists. + Value::Array(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + let is_reference = matches!(item, Value::String(t) if t.starts_with('$')); + match resolve(item, results)? { + Value::Array(inner) if is_reference => 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, results: &[Value]) -> Result { + let body = &text[1..]; + let split = body.find(['.', '[']).unwrap_or(body.len()); + let index: usize = body[..split].parse().map_err(|_| format!("bad reference {text}"))?; + let mut current = index + .checked_sub(1) + .and_then(|i| results.get(i)) + .ok_or(format!("{text} refers to call {index}, but only {} earlier calls ran", results.len()))?; + let mut rest = &body[split..]; + while !rest.is_empty() { + let (step, next) = if let Some(tail) = rest.strip_prefix('.') { + let end = tail.find(['.', '[']).unwrap_or(tail.len()); + (current.get(&tail[..end]), &tail[end..]) + } else if let Some(tail) = rest.strip_prefix('[') { + let end = tail.find(']').ok_or(format!("bad reference {text}"))?; + let index: i64 = tail[..end].parse().map_err(|_| format!("bad reference {text}"))?; + 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)), &tail[end + 1..]) + } else { + return Err(format!("bad reference {text}")); + }; + current = step.ok_or(format!("{text} does not exist in the result of call {index}"))?; + rest = next; + } + 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() } diff --git a/crates/benchup-cli/tests/cli.rs b/crates/benchup-cli/tests/cli.rs index 5147a55..3dce98a 100644 --- a/crates/benchup-cli/tests/cli.rs +++ b/crates/benchup-cli/tests/cli.rs @@ -288,3 +288,58 @@ fn an_open_group_lasts_for_one_command() { 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: 4 changes", "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!(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() && result["bounds_mm"].is_null()); + assert_eq!(result["created"].as_array().unwrap().len(), 1); +} + +#[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")); +} diff --git a/crates/benchup-tools/src/guide.md b/crates/benchup-tools/src/guide.md new file mode 100644 index 0000000..6056dbe --- /dev/null +++ b/crates/benchup-tools/src/guide.md @@ -0,0 +1,80 @@ +# Building furniture with the BenchUp tools + +## 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. +- `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 (`get_scene`, or `benchup scene`). It lists objects with their sizes, joints, dimensions, texts 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. In a batch, refer to an earlier call's result as `"$N.field"`, counting calls from 1. For example, `"$1.created[0]"` is the board created by call 1, and `"$5.joint_id"` is the joint created by call 5. Ids are not consecutive, so always take them from results. With the CLI, `--dry-run` shows the results without changing the file. +4. Verify: + - `check_model` finds overlaps without a joint, joints that cannot be cut and loose geometry. + - `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. + +## Groups, components and scope + +- Every board is a group. To change a part inside another group (a module, a component), open each level with `enter_object`, from the outside in. With the CLI, the opened group lasts only for that command, so put `enter_object` and the edits in one `--batch` and finish with `exit_to_root`. `push_pull` works on faces inside groups without opening them. +- `group` with several ids makes ONE group, or one component with `component: true`. For identical parts, 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. Avoid editing a board inside a component after it has copies. +- Erasing a board also removes its joints. `remove_joint` deletes a joint alone. + +## 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. + +| 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 the sides | Back (6 mm) enters each side by about half its thickness, flush with the back edge | `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 front and sides by 6 mm | `tongue_groove`, a = bottom, b = front or 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 | + +Several joints in one board must not cross. Two aprons meeting in one leg need shorter tenons, so their overlaps do not touch: `check_model` reports them as an overlap without a joint. A central divider with shelves on both sides at the same height would be cut through by two 9 mm grooves. Let the shelves enter it 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. Shorten everything inside by the back's thickness so it does not hit the back; `check_model` reports it if it does. +- **Even shelves:** with an inner height H, 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. +- **Plinth:** a board 60 to 100 mm high between the sides, set back 20 to 50 mm from the front. +- **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; a top 25 to 40 mm thick resting on the frame with a 20 to 50 mm overhang. +- **Drawer:** inset fronts leave about 2 mm at each side and 3 to 4 mm between drawers. With metal runners, make the box about 12.5 mm narrower on each side, or as the runner specifies. +- **Changing the stock thickness while keeping the outside size:** resize each board's thickness with `resize_board`, using its `anchor` to keep the outer faces in place, and keep each joint's overlap at about half the new thickness. +- **A leaning piece (ladder shelf):** create the board upright, then `rotate` it about its foot. For height H, board width w and angle θ from vertical, the length is (H - w·sin θ)/cos θ and the foot sits L·sin θ from the wall. +- **Materials:** `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'. + +## When to stop and ask + +- A requested size does not fit, such as a shelf longer than its opening. Report the free space 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, and which end of a piece to lengthen. +- In each case, propose an interpretation and say which one you used. + +## Shaped boards (curves, chamfers, angled ends) + +There are no arc, fillet or chamfer tools. 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"}} +``` + +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. + +## Limitations + +- A rotated board keeps square ends. For a level foot or top, build the part from its outline as shown above. +- 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. diff --git a/crates/benchup-tools/src/lib.rs b/crates/benchup-tools/src/lib.rs index bb61765..cea02a1 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!("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 4193a39..f47ccbc 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), @@ -154,6 +159,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"]}) } @@ -231,31 +239,41 @@ pub fn definitions() -> Vec { 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.", 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("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 that overlap without a joint, joints that cannot be cut or lost a board, open solids, loose geometry, boards without material. 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.", 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.", 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_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 ids it 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; see the result.", 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.", @@ -263,7 +281,7 @@ pub fn definitions() -> Vec { 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 (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. 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("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. 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; 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(), @@ -275,9 +293,9 @@ pub fn definitions() -> Vec { &["a", "b", "kind"]), def("remove_joint", "Delete wood joints by id (from the joints list in get_scene); the boards stay.", json!({"ids": ids()}), &["ids"]), - def("add_dimension", "Add a dimension annotation between two points, drawn offset_mm away.", + def("add_dimension", "Add a dimension between two points (they snap to nearby vertices). 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).", 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("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. Nested parts need each level opened in turn, 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). push_pull does not need it.", json!({"id": id()}), &["id"]), @@ -287,6 +305,34 @@ pub fn definitions() -> Vec { // ---------------------------------------------------------------- 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 { @@ -396,6 +442,7 @@ struct ScaleArgs { struct ResizeArgs { id: u64, size_mm: V3, + anchor: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -675,7 +722,14 @@ 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)); + Ok(done) } fn move_objects(model: &mut Model, a: MoveArgs) -> Res { @@ -717,8 +771,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) { @@ -741,6 +843,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. @@ -755,6 +858,7 @@ 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); } @@ -785,7 +889,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) @@ -900,19 +1027,37 @@ fn remove_joint(model: &mut Model, env: &ToolEnv, a: IdsArg) -> Res { 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.")); } let set = objects(model, &[a.a, a.b], true)?; 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() }; @@ -923,10 +1068,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.); @@ -942,15 +1093,60 @@ 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) { + return Err(fail( + format!("the joint cannot be cut: {error}"), + format!("Orientations that fit: {}. Check the overlap and params.", orientations(model, &settings)), + )); + } let mut done = modified(&set); done.joints.push(joint); done.extra.insert("joint_id".into(), json!(joint)); + 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 { @@ -1011,8 +1207,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)?), diff --git a/crates/benchup-tools/src/ops_tests.rs b/crates/benchup-tools/src/ops_tests.rs index e743bbc..bbca2be 100644 --- a/crates/benchup-tools/src/ops_tests.rs +++ b/crates/benchup-tools/src/ops_tests.rs @@ -666,3 +666,130 @@ fn find_objects_lists_everything_without_a_query() { 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")); +} diff --git a/crates/benchup-tools/src/review.rs b/crates/benchup-tools/src/review.rs new file mode 100644 index 0000000..be0038a --- /dev/null +++ b/crates/benchup-tools/src/review.rs @@ -0,0 +1,189 @@ +//! 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, connections::ConnectionOperation}; +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]]) +} + +fn material_name(model: &Model, id: u64) -> Option { + model.object_stock_material(&model.objects[&id]).map(|m| m.name.clone()) +} + +/// Boards grouped by material and blank size, with totals per material. +pub fn cut_list(model: &Model) -> Value { + let mut groups: BTreeMap<(String, [i64; 3]), (usize, BTreeSet, Vec)> = 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_name(model, id).unwrap_or_else(|| "unassigned".into()); + 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 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": 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(|(name, (count, area, volume))| { + let mut entry = json!({"material": name, "pieces": count, + "area_m2": (area * 1000.).round() / 1000., "volume_m3": (volume * 10_000.).round() / 10_000.}); + let stock = model.stock_materials.values().find(|m| &m.name == name); + if let Some(stock) = stock.filter(|s| s.dimensions[1] > 0 && s.dimensions[2] > 0) { + let [_, w, l] = stock.dimensions.map(|v| v as f64 / 10.); + let stock_area = w * l / 1.0e6; + entry["stock_mm"] = json!({"width": w, "length": l}); + entry["minimum_stock_pieces"] = json!((area / stock_area).ceil() as u64); + } + 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; + } + let (name_a, name_b) = (&model.objects[a].name, &model.objects[b].name); + let approximate = if *aligned_a && *aligned_b { "" } else { " (bounding boxes; a board is rotated, so this may be a false alarm)" }; + 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], + ); + } + } + for (id, error) in scene::joint_errors(model) { + 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 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()); + } + let unassigned: Vec = all.iter().copied().filter(|id| model.objects[id].stock_material_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 mut out = json!({"boards": all.len(), "errors": errors, "warnings": warnings, "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.")); + } + if let Some(bounds) = model.bounds() { + out["model_bounds_mm"] = json!({"min": point_mm(bounds.0), "max": point_mm(bounds.1)}); + } + out +} diff --git a/crates/benchup-tools/src/scene.rs b/crates/benchup-tools/src/scene.rs index 71c7307..1b14ccf 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 { @@ -86,6 +88,10 @@ 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); } @@ -115,6 +121,48 @@ 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", + }); + joint["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)], + "width_ratio": s.width_ratio, "height_ratio": s.height_ratio, + "fingers": s.fingers, "dovetails": s.dovetails, "taper_degrees": s.taper_degrees, + "clearance_mm": mm(s.clearance), + }); + 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) +} + fn joint_json(model: &Model, id: u64) -> Option { let connection = model.connections.get(&id)?; let kind = match &connection.operation { @@ -139,19 +187,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(); @@ -183,21 +251,62 @@ 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 +} + 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); @@ -210,9 +319,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); } @@ -245,9 +359,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])) { @@ -258,6 +375,22 @@ 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); + return Some(json!({ + "id": id, "kind": "dimension", "start_mm": point_mm(a), "end_mm": point_mm(b), + "offset_mm": point_mm(dimension.offset), "length_mm": mm(a.distance(b)), + })); + } + 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 { From 517a08b5698dd4c45b4bdd2e70f11d7023defac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Thu, 12 Nov 2026 23:55:23 +0100 Subject: [PATCH 04/12] Share the .bup format between the app and the tools; read PNG previews The app saves projects as a PNG preview with the document in a private buPd chunk, but benchup-tools only read gzip and plain JSON, so the CLI could not open files saved by the desktop app. The format now lives in benchup_tools::files and project_files.rs delegates to it. The CLI keeps a file's container when it saves: a PNG keeps its (now stale) preview with the document chunk replaced. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/benchup-app/Cargo.toml | 1 + crates/benchup-app/src/project_files.rs | 77 ++--------- crates/benchup-cli/src/project.rs | 7 +- crates/benchup-cli/tests/cli.rs | 32 +++++ crates/benchup-tools/src/files.rs | 164 ++++++++++++++++++++++-- 6 files changed, 207 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 71b4c8f..ed783b1 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", 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/src/project.rs b/crates/benchup-cli/src/project.rs index 2370f12..28fc81e 100644 --- a/crates/benchup-cli/src/project.rs +++ b/crates/benchup-cli/src/project.rs @@ -22,6 +22,8 @@ pub struct Project { 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. @@ -49,7 +51,7 @@ impl Project { selection: doc.selection.clone(), ..ToolEnv::default() }; - Ok(Self { path, doc, env, stamp: stamp(&bytes), counter: 0 }) + Ok(Self { path, doc, env, stamp: stamp(&bytes), counter: 0, saved: bytes }) } /// Writes a new, empty project. Refuses to replace an existing file unless `force`. @@ -71,9 +73,10 @@ impl Project { if stamp(¤t) != self.stamp { return Err(CHANGED_ON_DISK.to_owned()); } - let bytes = files::encode_document(&self.doc)?; + let bytes = files::reencode_like(&self.saved, &self.doc)?; files::save_atomic(&self.path, &bytes)?; self.stamp = stamp(&bytes); + self.saved = bytes; Ok(()) } diff --git a/crates/benchup-cli/tests/cli.rs b/crates/benchup-cli/tests/cli.rs index 3dce98a..2bdaec8 100644 --- a/crates/benchup-cli/tests/cli.rs +++ b/crates/benchup-cli/tests/cli.rs @@ -343,3 +343,35 @@ fn the_scene_names_the_project_and_the_guide_prints() { 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-tools/src/files.rs b/crates/benchup-tools/src/files.rs index 0704417..08c8f71 100644 --- a/crates/benchup-tools/src/files.rs +++ b/crates/benchup-tools/src/files.rs @@ -1,25 +1,62 @@ -//! `.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)? { + 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,6 +72,78 @@ 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. +fn chunks(bytes: &[u8]) -> Result, String> { + let mut out = Vec::new(); + let mut position = PNG_SIGNATURE.len(); + 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"))?; + out.push(bytes.get(position..end).ok_or(tr!("project_files.truncated_chunk"))?); + position = end; + } + Ok(out) +} + +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); + } + for chunk in chunks(bytes)? { + 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 @@ -86,6 +195,45 @@ 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()); + } + #[test] fn corrupt_input_is_an_error_not_a_panic() { assert!(decode(&[0x1f, 0x8b, 1, 2, 3]).is_err()); From 42e7ef0f997b3c670dd4c4f841436c67dccad50b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Thu, 12 Nov 2026 23:59:32 +0100 Subject: [PATCH 05/12] Move the agent guide to docs/ and document the CLI there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/agent-guide.md is compiled into the tools (benchup guide, get_guide), so the guide in the binary always matches it, and maintainers review it with the other docs. docs/cli.md is the user documentation for the CLI; the crate README points to both. The guide now reflects the second round of agent tests: joints on one board must keep 1 to 2 mm apart (backs in a groove when shelves are housed, drawer bottoms in the sides only, tenon depth below P - s - t), components cannot be joined to outside boards, dry runs ending with check_model, result fields and nested references, leaning pieces built upright and rotated by -θ, and recipes for cut lists and dimensions. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- crates/benchup-cli/README.md | 77 ++-------------------- crates/benchup-tools/src/guide.md | 80 ----------------------- crates/benchup-tools/src/lib.rs | 2 +- docs/README.md | 2 + docs/agent-guide.md | 104 ++++++++++++++++++++++++++++++ docs/cli.md | 82 +++++++++++++++++++++++ 7 files changed, 194 insertions(+), 155 deletions(-) delete mode 100644 crates/benchup-tools/src/guide.md create mode 100644 docs/agent-guide.md create mode 100644 docs/cli.md diff --git a/README.md b/README.md index 1e01284..ba57f28 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ Verified during implementation: native Linux and browser WASM Cargo checks; core - `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 its [README](crates/benchup-cli/README.md). +- `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/crates/benchup-cli/README.md b/crates/benchup-cli/README.md index c51532f..05a2cd1 100644 --- a/crates/benchup-cli/README.md +++ b/crates/benchup-cli/README.md @@ -1,76 +1,7 @@ # benchup (CLI) -Read and edit BenchUp `.bup` projects from a shell or an AI coding agent (Claude Code, Codex, Cursor…), without opening the app. +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). -```sh -cargo install --locked --path crates/benchup-cli # installs `benchup` in ~/.cargo/bin -``` - -The tools come from [`benchup-tools`](../benchup-tools), the same operation layer an MCP server or in-app integration can use. Lengths are millimetres and Z is up. - -## 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 inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both: - -```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: `, or `Agent: N changes` for a batch. 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. -- **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 (see #13). - -## Using it from an agent - -Agents should read `benchup guide` first (over MCP, the `get_guide` tool). 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 -``` +- 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-tools/src/guide.md b/crates/benchup-tools/src/guide.md deleted file mode 100644 index 6056dbe..0000000 --- a/crates/benchup-tools/src/guide.md +++ /dev/null @@ -1,80 +0,0 @@ -# Building furniture with the BenchUp tools - -## 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. -- `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 (`get_scene`, or `benchup scene`). It lists objects with their sizes, joints, dimensions, texts 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. In a batch, refer to an earlier call's result as `"$N.field"`, counting calls from 1. For example, `"$1.created[0]"` is the board created by call 1, and `"$5.joint_id"` is the joint created by call 5. Ids are not consecutive, so always take them from results. With the CLI, `--dry-run` shows the results without changing the file. -4. Verify: - - `check_model` finds overlaps without a joint, joints that cannot be cut and loose geometry. - - `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. - -## Groups, components and scope - -- Every board is a group. To change a part inside another group (a module, a component), open each level with `enter_object`, from the outside in. With the CLI, the opened group lasts only for that command, so put `enter_object` and the edits in one `--batch` and finish with `exit_to_root`. `push_pull` works on faces inside groups without opening them. -- `group` with several ids makes ONE group, or one component with `component: true`. For identical parts, 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. Avoid editing a board inside a component after it has copies. -- Erasing a board also removes its joints. `remove_joint` deletes a joint alone. - -## 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. - -| 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 the sides | Back (6 mm) enters each side by about half its thickness, flush with the back edge | `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 front and sides by 6 mm | `tongue_groove`, a = bottom, b = front or 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 | - -Several joints in one board must not cross. Two aprons meeting in one leg need shorter tenons, so their overlaps do not touch: `check_model` reports them as an overlap without a joint. A central divider with shelves on both sides at the same height would be cut through by two 9 mm grooves. Let the shelves enter it 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. Shorten everything inside by the back's thickness so it does not hit the back; `check_model` reports it if it does. -- **Even shelves:** with an inner height H, 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. -- **Plinth:** a board 60 to 100 mm high between the sides, set back 20 to 50 mm from the front. -- **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; a top 25 to 40 mm thick resting on the frame with a 20 to 50 mm overhang. -- **Drawer:** inset fronts leave about 2 mm at each side and 3 to 4 mm between drawers. With metal runners, make the box about 12.5 mm narrower on each side, or as the runner specifies. -- **Changing the stock thickness while keeping the outside size:** resize each board's thickness with `resize_board`, using its `anchor` to keep the outer faces in place, and keep each joint's overlap at about half the new thickness. -- **A leaning piece (ladder shelf):** create the board upright, then `rotate` it about its foot. For height H, board width w and angle θ from vertical, the length is (H - w·sin θ)/cos θ and the foot sits L·sin θ from the wall. -- **Materials:** `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'. - -## When to stop and ask - -- A requested size does not fit, such as a shelf longer than its opening. Report the free space 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, and which end of a piece to lengthen. -- In each case, propose an interpretation and say which one you used. - -## Shaped boards (curves, chamfers, angled ends) - -There are no arc, fillet or chamfer tools. 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"}} -``` - -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. - -## Limitations - -- A rotated board keeps square ends. For a level foot or top, build the part from its outline as shown above. -- 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. diff --git a/crates/benchup-tools/src/lib.rs b/crates/benchup-tools/src/lib.rs index cea02a1..1b8c877 100644 --- a/crates/benchup-tools/src/lib.rs +++ b/crates/benchup-tools/src/lib.rs @@ -17,7 +17,7 @@ pub mod review; pub mod scene; /// Conventions, workflow and recipes for agents (`get_guide`, `benchup guide`). -pub const GUIDE: &str = include_str!("guide.md"); +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, 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..6ddc929 --- /dev/null +++ b/docs/agent-guide.md @@ -0,0 +1,104 @@ +# 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 overlaps without a joint, joints that cannot be cut, joints whose roles look reversed and loose geometry. + - `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 to a list inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both. +- 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` (and `get_object` on new joints) to validate the change before applying it.** Read tools inside a batch see the batch's earlier changes. `--brief` leaves out object summaries and bounds. + +## Groups, components and scope + +- Every board is a group. To change a part inside another group (a module, a component), open each level with `enter_object`, from the outside in. With the CLI, the opened group lasts only for that command, so put `enter_object` and the edits in one `--batch` and finish with `exit_to_root`. `push_pull` works on faces inside groups without opening them. +- `group` with several ids makes ONE group, or one component with `component: true`. Grouping keeps existing joints. +- 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, and `create_copy` does not copy joints with boards outside the component. Parts joined to outside boards (shelves housed in sides) should stay separate boards; name and tag them alike instead. +- 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. + +| 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. Only when nothing else is housed in the sides near the back (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 at the back face of the front | `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 | + +### Several joints in one board + +Joints on the same board must not cross, touch, or end in the same plane; the joint is refused with a "closed solid" or "valid solid geometry" error. Keep at least 1 to 2 mm of wood between their regions: + +- **Shelves and a back:** stop shelves, top and bottom 1 to 2 mm short of the back, not flush with it. With shelves, top or bottom housed in the sides, put the back in a groove set in from the back edge rather than a rabbet. +- **Drawer bottom:** groove it into the drawer sides only. Where the sides already enter the front with dovetails, a groove in the front would meet them. +- **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. A divider that reaches the floor splits it in two. +- **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:** inset fronts leave about 2 mm at each side and 3 to 4 mm between drawers. With metal runners, make the box about 12.5 mm narrower on each side, or as the runner specifies. +- **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, so rebuild a shaped board from its new outline instead. +- **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 keep each joint's overlap at about half the new thickness. Create the new material and assign it. +- **A leaning piece (ladder shelf):** build each upright standing vertically, from its outline with the ends already cut at θ (see shaped boards), and set its grain to z. Then `rotate` it by −θ about x, pivoting on the back edge of its foot, so its top leans towards the wall at +Y. For height H, board width w and angle θ, the length is (H - w·sin θ)/cos θ and the foot sits L·sin θ from the wall. Keep shelf edges a few millimetres away from the uprights' sloping faces; a near-coincident edge leaves slivers smaller than the model precision. +- **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, width and depth, and a chain of shelf gaps, in the front plane (y = 0) so they read in the front view. Dimension points snap to nearby vertices, so measure between real corners. `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. `get_scene` lists each dimension with its `length_mm`. Ask whether shelf spacing means clear space or centre to centre. + +## Shaped boards (curves, chamfers, angled ends) + +There are no arc, fillet or chamfer tools. 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, so the outline above extrudes towards +z. 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. + +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. +- In each case, propose an interpretation and say which one you used. + +## 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. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..00b853d --- /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 inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both: + +```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: `, or `Agent: N changes` for a batch. 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. +- **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 +``` From 6ad9ba415fe805d03b7d79a77d9ac1914ee5b07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Thu, 12 Nov 2026 23:59:32 +0100 Subject: [PATCH 06/12] Explain joint conflicts; check real overlaps and reversed joints - add_joint names the joints already on the boards when a new one cannot be cut, refuses boards at different group levels and assemblies with a clear reason, and returns a and b. - check_model confirms box overlaps with the real solids (no alarm for a notched board around a post), warns when a joint's roles look reversed, follows materials set on parent groups, and reports the size of the boards rather than of the annotations around them. - get_cut_list explains how to get minimum_stock_pieces; joint kinds use add_joint's names; push_pull also returns face_id; group explains a group of loose geometry; --brief keeps a scene's objects; descriptions say what tools return and what happens to joints. Co-Authored-By: Claude Opus 5.5 --- crates/benchup-cli/src/main.rs | 4 ++ crates/benchup-cli/tests/cli.rs | 2 + crates/benchup-tools/src/ops.rs | 57 +++++++++++++++++++++------ crates/benchup-tools/src/ops_tests.rs | 53 ++++++++++++++++++++++++- crates/benchup-tools/src/review.rs | 53 ++++++++++++++++++++++--- crates/benchup-tools/src/scene.rs | 21 ++++++++-- 6 files changed, 168 insertions(+), 22 deletions(-) diff --git a/crates/benchup-cli/src/main.rs b/crates/benchup-cli/src/main.rs index 7b88578..2a3b929 100644 --- a/crates/benchup-cli/src/main.rs +++ b/crates/benchup-cli/src/main.rs @@ -165,6 +165,10 @@ fn run_calls(project: &mut Project, calls: &[(String, Value)], save: bool) -> Re /// Drops the bulky parts of tool results, keeping ids, errors and answers. fn brief(value: &mut Value) { if let Some(map) = value.as_object_mut() { + // A scene's object list is the answer, not a summary. + if map.contains_key("object_count") { + return; + } map.remove("objects"); map.remove("bounds_mm"); for key in ["results", "result"] { diff --git a/crates/benchup-cli/tests/cli.rs b/crates/benchup-cli/tests/cli.rs index 2bdaec8..e89ed97 100644 --- a/crates/benchup-cli/tests/cli.rs +++ b/crates/benchup-cli/tests/cli.rs @@ -331,6 +331,8 @@ fn dry_run_and_brief_output() { let result = json_out(&benchup(&["call", &path, "create_board", &args, "--brief"], None)); assert!(result["objects"].is_null() && result["bounds_mm"].is_null()); 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] diff --git a/crates/benchup-tools/src/ops.rs b/crates/benchup-tools/src/ops.rs index f47ccbc..7c3acef 100644 --- a/crates/benchup-tools/src/ops.rs +++ b/crates/benchup-tools/src/ops.rs @@ -246,16 +246,16 @@ pub fn definitions() -> Vec { 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.", + 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", "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 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("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).", @@ -265,7 +265,7 @@ pub fn definitions() -> Vec { 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 ids it created.", + 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.", @@ -273,7 +273,7 @@ pub fn definitions() -> Vec { "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 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; 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.", @@ -281,7 +281,7 @@ pub fn definitions() -> Vec { 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 (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. 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; get_object on a joint shows its settings and supported orientations.", + 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. 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(), @@ -864,6 +864,7 @@ fn push_pull(model: &mut Model, a: PushPullArgs) -> Res { } 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) } @@ -927,7 +928,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) { + "The group holds loose faces and edges; it is created[0]." + } 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 { @@ -1048,7 +1054,22 @@ 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 { @@ -1138,14 +1159,28 @@ fn add_joint(model: &mut Model, a: JointArgs) -> Res { operation: ConnectionOperation::Joinery(settings.clone()), })?; if let Some(error) = scene::joint_errors(model).remove(&joint) { - return Err(fail( - format!("the joint cannot be cut: {error}"), - format!("Orientations that fit: {}. Check the overlap and params.", orientations(model, &settings)), - )); + let others: Vec = model + .connections + .iter() + .filter(|(id, c)| **id != joint && c.members.iter().any(|m| members.contains(m))) + .map(|(id, c)| format!("{id} ({} between {} and {})", scene::joint_kind(c), c.members[0], c.members[1])) + .collect(); + let hint = 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) } diff --git a/crates/benchup-tools/src/ops_tests.rs b/crates/benchup-tools/src/ops_tests.rs index bbca2be..0060fdf 100644 --- a/crates/benchup-tools/src/ops_tests.rs +++ b/crates/benchup-tools/src/ops_tests.rs @@ -280,7 +280,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"})); @@ -793,3 +793,54 @@ fn the_scene_lists_annotations_axes_and_component_definitions() { 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}"); +} diff --git a/crates/benchup-tools/src/review.rs b/crates/benchup-tools/src/review.rs index be0038a..9cf544a 100644 --- a/crates/benchup-tools/src/review.rs +++ b/crates/benchup-tools/src/review.rs @@ -1,7 +1,7 @@ //! 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, connections::ConnectionOperation}; +use benchup_core::{DVec3, Model, connections::ConnectionOperation, joinery::JointSettings}; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; @@ -43,8 +43,26 @@ fn blank(model: &Model, id: u64) -> Option<[f64; 3]> { Some([extent[thickness], extent[width], extent[length]]) } +/// The board's stock material, or the nearest one set on a group around it. fn material_name(model: &Model, id: u64) -> Option { - model.object_stock_material(&model.objects[&id]).map(|m| m.name.clone()) + 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.name.clone()); + } + 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. @@ -87,6 +105,8 @@ pub fn cut_list(model: &Model) -> Value { let stock_area = w * l / 1.0e6; entry["stock_mm"] = json!({"width": w, "length": l}); entry["minimum_stock_pieces"] = json!((area / stock_area).ceil() as u64); + } else if name == "unassigned" { + entry["note"] = json!("Assign a stock material with sizes (create_stock_material, set_material) to get minimum_stock_pieces."); } entry }) @@ -136,8 +156,12 @@ pub fn check(model: &Model) -> Value { 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); - let approximate = if *aligned_a && *aligned_b { "" } else { " (bounding boxes; a board is rotated, so this may be a false alarm)" }; + let approximate = if *aligned_a && *aligned_b { "" } else { " (measured on bounding boxes; a board is rotated)" }; issue( "warning", "overlap_without_joint", @@ -152,6 +176,21 @@ pub fn check(model: &Model) -> Value { 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 (remove_joint, then add_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]); @@ -167,7 +206,7 @@ pub fn check(model: &Model) -> Value { 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()); } - let unassigned: Vec = all.iter().copied().filter(|id| model.objects[id].stock_material_id.is_none()).collect(); + let unassigned: Vec = all.iter().copied().filter(|id| material_name(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()); } @@ -182,8 +221,10 @@ pub fn check(model: &Model) -> Value { if all.len() > MAX_CHECKED_BOARDS { out["note"] = json!(format!("Only the first {MAX_CHECKED_BOARDS} boards were compared for overlaps.")); } - if let Some(bounds) = model.bounds() { - out["model_bounds_mm"] = json!({"min": point_mm(bounds.0), "max": point_mm(bounds.1)}); + // 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 1b14ccf..5f1d3ea 100644 --- a/crates/benchup-tools/src/scene.rs +++ b/crates/benchup-tools/src/scene.rs @@ -163,12 +163,25 @@ pub fn joint_detail(model: &Model, id: u64) -> Option { 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"), - }; + 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() { From 07db72c746da93ef00c991d46ebe7b047b5b473d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Fri, 13 Nov 2026 00:14:40 +0100 Subject: [PATCH 07/12] Reach parts inside groups; flip joints; fuller checks From the third round of agent tests: - Editing tools open the group around their targets for the call when all ids share one, as push_pull already did; enter_object is only needed to create parts inside a group. - flip_joint swaps a joint's a and b, keeping its settings and re-picking the orientation; check_model points to it for reversed joints. - check_model reports boards that cut another in two (passes_through) and boards whose thickness does not match their material, and counts the joints that cut. - add_dimension can measure along an axis and returns length_mm and whether each end attached; its description no longer promises snapping. - The scene shows materials inherited from groups and only the joint settings each kind uses. - CLI: --brief keeps bounds and reading tools' answers; a failed batch shows the earlier results; group explains a part made from loose faces. Co-Authored-By: Claude Opus 5.5 --- crates/benchup-cli/src/main.rs | 17 ++-- crates/benchup-cli/src/project.rs | 6 +- crates/benchup-cli/tests/cli.rs | 27 +++--- crates/benchup-tools/src/ops.rs | 119 ++++++++++++++++++++++++-- crates/benchup-tools/src/ops_tests.rs | 87 ++++++++++++++++++- crates/benchup-tools/src/review.rs | 41 ++++++++- crates/benchup-tools/src/scene.rs | 46 ++++++++-- 7 files changed, 301 insertions(+), 42 deletions(-) diff --git a/crates/benchup-cli/src/main.rs b/crates/benchup-cli/src/main.rs index 2a3b929..f9416ff 100644 --- a/crates/benchup-cli/src/main.rs +++ b/crates/benchup-cli/src/main.rs @@ -35,7 +35,7 @@ usage: options: --dry-run (call) report the results without changing the file - --brief (call) leave out object summaries and bounds from results + --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. @@ -149,28 +149,27 @@ fn run_calls(project: &mut Project, calls: &[(String, Value)], save: bool) -> Re 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 } => Output { + Applied::Failed { index, result, earlier } => Output { value: json!({ "ok": false, "failed_call": index + 1, "tool": calls[index].0, "result": result, - "note": "nothing was changed; fix this call and run the whole batch again", + "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 bulky parts of tool results, keeping ids, errors and answers. +/// 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() { - // A scene's object list is the answer, not a summary. - if map.contains_key("object_count") { - return; + if map.contains_key("created") { + map.remove("objects"); } - map.remove("objects"); - map.remove("bounds_mm"); for key in ["results", "result"] { match map.get_mut(key) { Some(Value::Array(items)) => items.iter_mut().for_each(brief), diff --git a/crates/benchup-cli/src/project.rs b/crates/benchup-cli/src/project.rs index 28fc81e..1785d2c 100644 --- a/crates/benchup-cli/src/project.rs +++ b/crates/benchup-cli/src/project.rs @@ -31,7 +31,8 @@ pub enum Applied { /// Every call succeeded; one result per call. Ok(Vec), /// Call `index` failed; nothing was changed or saved. - Failed { index: usize, result: Value }, + /// Call `index` failed; `earlier` holds the results of the calls before it. + Failed { index: usize, result: Value, earlier: Vec }, } impl Project { @@ -104,6 +105,7 @@ impl Project { self.env.selection = 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\"."}), }); @@ -114,7 +116,7 @@ impl Project { if !outcome.ok { self.doc = original; self.env.selection = selection_before; - return Ok(Applied::Failed { index, result: outcome.result }); + return Ok(Applied::Failed { index, result: outcome.result, earlier: results }); } mutated |= outcome.mutated; let mut result = outcome.result; diff --git a/crates/benchup-cli/tests/cli.rs b/crates/benchup-cli/tests/cli.rs index e89ed97..03bb03d 100644 --- a/crates/benchup-cli/tests/cli.rs +++ b/crates/benchup-cli/tests/cli.rs @@ -266,29 +266,30 @@ fn grouped_project(dir: &Path) -> (String, u64, u64) { } #[test] -fn an_open_group_lasts_for_one_command() { +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(); - // Opened in one command, the group is closed again in the next. + 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 output = benchup(&["call", &path, "rename", &rename], None); - assert_eq!(output.status.code(), Some(1)); - let hint = json_out(&output)["hint"].as_str().unwrap().to_owned(); - assert!(hint.contains(&format!("enter_object{{id:{group}}}")) && hint.contains("--batch"), "{hint}"); - // In one batch it works, and the file is left at the top level. + 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": "rename", "arguments": {"id": inner, "name": "Inner"}}) + json!({"tool": "create_board", "arguments": board("D", [0., 700., 0.], [10., 10., 10.])}) ); - assert!(benchup(&["call", &path, "--batch", "-"], Some(&batch)).status.success()); + let inside = json_out(&benchup(&["call", &path, "--batch", "-"], Some(&batch))); let doc = files::decode(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(doc.model.objects[&inner].name, "Inner"); + 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(); @@ -312,6 +313,7 @@ fn batch_calls_can_use_earlier_results() { 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); } @@ -329,7 +331,8 @@ fn dry_run_and_brief_output() { 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() && result["bounds_mm"].is_null()); + 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"); diff --git a/crates/benchup-tools/src/ops.rs b/crates/benchup-tools/src/ops.rs index 7c3acef..22a5391 100644 --- a/crates/benchup-tools/src/ops.rs +++ b/crates/benchup-tools/src/ops.rs @@ -122,6 +122,7 @@ const TOOLS: &[(&str, bool)] = &[ ("erase", true), ("add_joint", true), ("remove_joint", true), + ("flip_joint", true), ("add_dimension", true), ("add_text", true), ("select", false), @@ -281,7 +282,7 @@ pub fn definitions() -> Vec { 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 (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. 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.", + 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(), @@ -293,12 +294,14 @@ pub fn definitions() -> Vec { &["a", "b", "kind"]), def("remove_joint", "Delete wood joints by id (from the joints list in get_scene); the boards stay.", json!({"ids": ids()}), &["ids"]), - def("add_dimension", "Add a dimension between two points (they snap to nearby vertices). 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).", - json!({"start_mm": vec3(), "end_mm": vec3(), "offset_mm": vec3()}), &["start_mm", "end_mm", "offset_mm"]), + 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. Nested parts need each level opened in turn, 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). push_pull does not need it.", 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!({}), &[]), ] } @@ -513,6 +516,7 @@ struct DimensionArgs { start_mm: V3, end_mm: V3, offset_mm: V3, + axis: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -929,7 +933,7 @@ fn group(model: &mut Model, a: GroupArgs) -> Res { Value::Array(members.iter().filter_map(|id| scene::object_entry(model, *id)).collect()), ); let note = if done.extra["members"].as_array().is_some_and(Vec::is_empty) { - "The group holds loose faces and edges; it is created[0]." + "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." }; @@ -1003,6 +1007,48 @@ fn erase(model: &mut Model, a: IdsArg) -> Res { } 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."))); @@ -1185,8 +1231,15 @@ fn add_joint(model: &mut Model, a: JointArgs) -> Res { 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 { @@ -1281,6 +1334,7 @@ fn run(model: &mut Model, env: &mut ToolEnv, name: &str, arguments: &str) -> Res "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" => { @@ -1339,12 +1393,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())); @@ -1475,7 +1567,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 0060fdf..e264d43 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); @@ -640,8 +643,10 @@ fn scope_errors_name_every_object_to_open() { 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, "rename", json!({"id": part, "name": "P"})).result["hint"].as_str().unwrap().to_owned() + 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}"); @@ -844,3 +849,77 @@ fn cut_list_uses_materials_set_on_groups() { 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 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); +} diff --git a/crates/benchup-tools/src/review.rs b/crates/benchup-tools/src/review.rs index 9cf544a..dd8e620 100644 --- a/crates/benchup-tools/src/review.rs +++ b/crates/benchup-tools/src/review.rs @@ -161,6 +161,27 @@ pub fn check(model: &Model) -> Value { 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", @@ -184,7 +205,7 @@ pub fn check(model: &Model) -> Value { issue( "warning", "joint_roles_reversed", - format!("Joint {id} looks reversed: {} goes into {}, so it should be a and {} b (remove_joint, then add_joint).", + 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], ); @@ -206,6 +227,16 @@ pub fn check(model: &Model) -> Value { 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(name) = material_name(model, *id) else { continue }; + let Some(stock) = model.stock_materials.values().find(|m| m.name == name).filter(|m| m.dimensions[0] > 0) else { continue }; + 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_name(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()); @@ -214,7 +245,13 @@ pub fn check(model: &Model) -> Value { 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 mut out = json!({"boards": all.len(), "errors": errors, "warnings": warnings, "issues": issues}); + let joint_errors = scene::joint_errors(model); + let wood_joints = model.connections.values().filter(|c| matches!(c.operation, ConnectionOperation::Joinery(_))).count(); + let mut out = json!({ + "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); } diff --git a/crates/benchup-tools/src/scene.rs b/crates/benchup-tools/src/scene.rs index 5f1d3ea..274b135 100644 --- a/crates/benchup-tools/src/scene.rs +++ b/crates/benchup-tools/src/scene.rs @@ -70,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!({ @@ -94,6 +106,9 @@ pub fn object_entry(model: &Model, id: u64) -> Option { } 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)); @@ -139,13 +154,28 @@ pub fn joint_detail(model: &Model, id: u64) -> Option { "a": "carries the tenon, tongue, tails, fingers or lap (the board that goes into the other)", "b": "receives the mortise, groove, sockets or lap", }); - joint["params"] = json!({ + 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)], - "width_ratio": s.width_ratio, "height_ratio": s.height_ratio, - "fingers": s.fingers, "dovetails": s.dovetails, "taper_degrees": s.taper_degrees, "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() @@ -392,10 +422,14 @@ pub fn describe(model: &Model, id: u64) -> Option { pub fn annotation_json(model: &Model, id: u64) -> Option { if let Some(dimension) = model.dimensions.get(&id) { let (a, b) = dimension.points(model); - return Some(json!({ + 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(a.distance(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!({ From 7b72c393fc753f9b6ad5058f81126d9c5a76ff46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Fri, 13 Nov 2026 00:14:40 +0100 Subject: [PATCH 08/12] Update the agent guide from the third round of agent tests Editing inside groups without enter_object, flip_joint, settings by kind, what "joints must not meet" means, rabbets with housed shelves, plinth and top joints, drawer proportions, lengthening notched boards with push_pull, tapered ladder uprights, dimensions (straight or along an axis, no snapping), outlines in vertical planes, arc radius, housed parts requested as components, and fixing problems the user did not mention. Co-Authored-By: Claude Opus 5.5 --- docs/agent-guide.md | 46 ++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 6ddc929..62e13fd 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -17,7 +17,7 @@ This guide is written for AI agents that edit BenchUp projects through the [`ben 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 overlaps without a joint, joints that cannot be cut, joints whose roles look reversed and loose geometry. + - `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. - `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. @@ -25,15 +25,19 @@ This guide is written for AI agents that edit BenchUp projects through the [`ben ### 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 to a list inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both. -- 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` (and `get_object` on new joints) to validate the change before applying it.** Read tools inside a batch see the batch's earlier changes. `--brief` leaves out object summaries and bounds. +- 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 inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both. +- 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. To change a part inside another group (a module, a component), open each level with `enter_object`, from the outside in. With the CLI, the opened group lasts only for that command, so put `enter_object` and the edits in one `--batch` and finish with `exit_to_root`. `push_pull` works on faces inside groups without opening them. -- `group` with several ids makes ONE group, or one component with `component: true`. Grouping keeps existing joints. +- 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, and `create_copy` does not copy joints with boards outside the component. Parts joined to outside boards (shelves housed in sides) should stay separate boards; name and tag them alike instead. +- 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, and `create_copy` does not copy joints with boards outside the component. +- **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 @@ -43,23 +47,27 @@ A joint joins two boards that **overlap** where they meet; the overlap is the jo - **`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. Only when nothing else is housed in the sides near the back (see below) | `rabbet`, 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 at the back face of the front | `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 -Joints on the same board must not cross, touch, or end in the same plane; the joint is refused with a "closed solid" or "valid solid geometry" error. Keep at least 1 to 2 mm of wood between their regions: +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. 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. With shelves, top or bottom housed in the sides, put the back in a groove set in from the back edge rather than a rabbet. +- **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. Where the sides already enter the front with dovetails, a groove in the front would meet them. - **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. @@ -68,14 +76,14 @@ Joints on the same board must not cross, touch, or end in the same plane; the jo - **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. A divider that reaches the floor splits it in two. +- **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. - **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:** inset fronts leave about 2 mm at each side and 3 to 4 mm between drawers. With metal runners, make the box about 12.5 mm narrower on each side, or as the runner specifies. -- **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, so rebuild a shaped board from its new outline instead. -- **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 keep each joint's overlap at about half the new thickness. Create the new material and assign it. -- **A leaning piece (ladder shelf):** build each upright standing vertically, from its outline with the ends already cut at θ (see shaped boards), and set its grain to z. Then `rotate` it by −θ about x, pivoting on the back edge of its foot, so its top leans towards the wall at +Y. For height H, board width w and angle θ, the length is (H - w·sin θ)/cos θ and the foot sits L·sin θ from the wall. Keep shelf edges a few millimetres away from the uprights' sloping faces; a near-coincident edge leaves slivers smaller than the model precision. +- **Drawer:** 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. +- **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 gives round 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):** build each upright standing vertically, from its outline with the ends already cut at θ (see shaped boards), and set its grain to z. Then `rotate` it by −θ about x, pivoting on the back edge of its foot, so its top leans towards the wall at +Y. For height H, board width w and angle θ, the length is (H - w·sin θ)/cos θ and the foot sits L·sin θ 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, width and depth, and a chain of shelf gaps, in the front plane (y = 0) so they read in the front view. Dimension points snap to nearby vertices, so measure between real corners. `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. `get_scene` lists each dimension with its `length_mm`. Ask whether shelf spacing means clear space or centre to centre. +- **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) @@ -88,14 +96,18 @@ There are no arc, fillet or chamfer tools. Draw the board's outline as one close {"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, so the outline above extrudes towards +z. 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. +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. +- 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. ## Limitations From dc41d706cdf3563a08150f5948d70743dd41d1ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Fri, 13 Nov 2026 00:29:26 +0100 Subject: [PATCH 09/12] Last-round fixes: passed flag, id alias, clearer joint failures - check_model returns passed (no errors or warnings); ok only says it ran. - Tools taking ids also accept a single "id". - When a joint cannot be cut, only joints whose region touches it are blamed; with none, the hint points at the board's own geometry (curves with many segments, vertices near the joint planes). - draw_polyline lists each new face with its center, so a split face's pieces can be told apart; get_object's description names center_mm. - Guide: odd thickness changes and shelf centres, drawer bottoms stopping short of the front, components keeping existing joints, ladder uprights with level cuts, splitting faces to lengthen notched boards, walls in the example room, backs behind dividers, chamfered tops, curve segment counts, and what to do when there is no one to ask. Co-Authored-By: Claude Opus 5.5 --- crates/benchup-tools/src/ops.rs | 36 ++++++++++++++++++++++++--- crates/benchup-tools/src/ops_tests.rs | 17 +++++++++++++ crates/benchup-tools/src/review.rs | 1 + crates/benchup-tools/src/scene.rs | 5 ++++ docs/agent-guide.md | 25 +++++++++++-------- 5 files changed, 71 insertions(+), 13 deletions(-) diff --git a/crates/benchup-tools/src/ops.rs b/crates/benchup-tools/src/ops.rs index 22a5391..932eaf8 100644 --- a/crates/benchup-tools/src/ops.rs +++ b/crates/benchup-tools/src/ops.rs @@ -237,11 +237,11 @@ pub fn definitions() -> Vec { def("get_scene", "Return the whole scene summary again (objects, selection, stock, joints). Lengths are in mm.", json!({}), &[]), 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, center, area) for push_pull. Face normals point out of the part; a positive push_pull distance moves a face outward.", + 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 (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 that overlap without a joint, joints that cannot be cut or lost a board, open solids, loose geometry, boards without material. Run it after a change.", 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.", @@ -395,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 { @@ -733,6 +747,9 @@ fn draw_polyline(model: &mut Model, a: PolylineArgs) -> Res { 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) } @@ -1205,13 +1222,26 @@ fn add_joint(model: &mut Model, a: JointArgs) -> Res { 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 hint = if others.is_empty() { + 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!( diff --git a/crates/benchup-tools/src/ops_tests.rs b/crates/benchup-tools/src/ops_tests.rs index e264d43..bcc2744 100644 --- a/crates/benchup-tools/src/ops_tests.rs +++ b/crates/benchup-tools/src/ops_tests.rs @@ -923,3 +923,20 @@ fn the_scene_shows_materials_inherited_from_groups() { 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 index dd8e620..aff5db1 100644 --- a/crates/benchup-tools/src/review.rs +++ b/crates/benchup-tools/src/review.rs @@ -248,6 +248,7 @@ pub fn check(model: &Model) -> Value { let joint_errors = scene::joint_errors(model); 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, diff --git a/crates/benchup-tools/src/scene.rs b/crates/benchup-tools/src/scene.rs index 274b135..6b6bb63 100644 --- a/crates/benchup-tools/src/scene.rs +++ b/crates/benchup-tools/src/scene.rs @@ -350,6 +350,11 @@ fn ray_hits(origin: DVec3, direction: DVec3, [a, b, c]: &[DVec3; 3]) -> bool { 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); diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 62e13fd..3c74ab5 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -17,7 +17,7 @@ This guide is written for AI agents that edit BenchUp projects through the [`ben 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. + - `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. @@ -35,7 +35,7 @@ This guide is written for AI agents that edit BenchUp projects through the [`ben - 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, and `create_copy` does not copy joints with boards outside the component. +- 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. @@ -57,7 +57,7 @@ A joint joins two boards that **overlap** where they meet; the overlap is the jo | 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 at the back face of the front | `tongue_groove`, a = bottom, b = side | +| 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 | @@ -65,10 +65,11 @@ A joint joins two boards that **overlap** where they meet; the overlap is the jo ### 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. 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: +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. Where the sides already enter the front with dovetails, a groove in the front would meet them. +- **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. @@ -76,12 +77,15 @@ Two joints cut into the same board must not cross, and must not meet face to fac - **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. +- **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:** 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. -- **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 gives round 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):** build each upright standing vertically, from its outline with the ends already cut at θ (see shaped boards), and set its grain to z. Then `rotate` it by −θ about x, pivoting on the back edge of its foot, so its top leans towards the wall at +Y. For height H, board width w and angle θ, the length is (H - w·sin θ)/cos θ and the foot sits L·sin θ 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. +- **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. @@ -109,6 +113,7 @@ Avoid cutting through a finished board with a negative `push_pull` of its full t - 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 From de81b288c7981e7fda54d6944174348b3805d270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Fri, 13 Nov 2026 00:31:18 +0100 Subject: [PATCH 10/12] Update Cargo.lock for 0.7.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed783b1..94138f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,7 +452,7 @@ dependencies = [ [[package]] name = "benchup-cli" -version = "0.7.0" +version = "0.7.1" dependencies = [ "benchup-core", "benchup-tools", @@ -493,7 +493,7 @@ dependencies = [ [[package]] name = "benchup-tools" -version = "0.7.0" +version = "0.7.1" dependencies = [ "benchup-core", "flate2", From bc4b397d0cd509eae42f7bae5da9f0637b509fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Fri, 13 Nov 2026 00:41:49 +0100 Subject: [PATCH 11/12] Mention the CLI in llms.txt; tell agents which app features the tools lack The guide said there were no chamfer tools; BenchUp has a Router and a Cutting Plan, which the tools do not expose yet. llms.txt now points agents to the benchup command and the agent guide, noting that it is built from source for now. Co-Authored-By: Claude Opus 5.5 --- docs/agent-guide.md | 3 ++- site/llms.txt | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 3c74ab5..0f79c16 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -91,7 +91,7 @@ Two joints cut into the same board must not cross, and must not meet face to fac ## Shaped boards (curves, chamfers, angled ends) -There are no arc, fillet or chamfer tools. 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: +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}} @@ -119,3 +119,4 @@ Avoid cutting through a finished board with a negative `push_pull` of its full t - 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/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). From d6edc766168f711b346490410b97dbf229884c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Fri, 13 Nov 2026 10:13:19 +0100 Subject: [PATCH 12/12] Fix review findings in the CLI, MCP server and agent tools - Resolve "$N" references only in batches, and only text that is exactly a reference; "$12.50 hardware" stays as written. - Splice only referenced lists of ids into lists; a point stays one item. - MCP reloads the project before each call when it changed on disk, so a save in BenchUp no longer blocks the session. - check_model and get_cut_list use the assigned stock material, not the first one with the same name; the cut list shows the stock thickness. - Ignore bytes after IEND when reading or rewriting PNG projects. - New projects get the usual permissions under the umask. - Agent history entries use the translatable "Agent edit" label. - Windows: open projects through explorer.exe with a plain path, so no shell parses "&" or "%". - Read-only calls no longer copy the document; check_model evaluates joints once. Co-Authored-By: Claude Opus 5.5 --- assets/i18n/en.json | 3 +- assets/i18n/pt_BR.json | 3 +- assets/i18n/zh_CN.json | 3 +- crates/benchup-cli/src/launch.rs | 33 +++- crates/benchup-cli/src/project.rs | 240 +++++++++++++++++++------- crates/benchup-cli/tests/cli.rs | 8 +- crates/benchup-core/build.rs | 1 + crates/benchup-tools/src/files.rs | 72 +++++--- crates/benchup-tools/src/ops_tests.rs | 24 +++ crates/benchup-tools/src/review.rs | 55 +++--- docs/agent-guide.md | 2 +- docs/cli.md | 6 +- 12 files changed, 328 insertions(+), 122 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 9647d8a..b8be818 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -2179,5 +2179,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/pt_BR.json b/assets/i18n/pt_BR.json index 0cf32b7..5a9ed36 100644 --- a/assets/i18n/pt_BR.json +++ b/assets/i18n/pt_BR.json @@ -2179,5 +2179,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/zh_CN.json b/assets/i18n/zh_CN.json index 0d1b35c..8c0db78 100644 --- a/assets/i18n/zh_CN.json +++ b/assets/i18n/zh_CN.json @@ -2179,5 +2179,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-cli/src/launch.rs b/crates/benchup-cli/src/launch.rs index e5dec8e..d21ee1a 100644 --- a/crates/benchup-cli/src/launch.rs +++ b/crates/benchup-cli/src/launch.rs @@ -2,7 +2,8 @@ //! //! 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. If the +//! 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}; @@ -25,7 +26,26 @@ fn project_path(path: &str) -> Result { if !absolute.is_file() { return Err(format!("{} is not a file", path.display())); } - Ok(absolute) + 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. @@ -36,10 +56,12 @@ fn system_command(path: &Path) -> Command { 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("cmd"); - command.args(["/C", "start", ""]).arg(path); + let mut command = Command::new("explorer.exe"); + command.arg(path); command } #[cfg(not(any(target_os = "macos", target_os = "windows")))] @@ -73,7 +95,8 @@ pub fn open(path: &str, app: Option<&str>) -> Result { .stdin(Stdio::null()) .output() .map_err(|e| format!("cannot run {program}: {e}. Is BenchUp installed? {DOWNLOAD_URL}"))?; - if !output.status.success() { + // 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}). \ diff --git a/crates/benchup-cli/src/project.rs b/crates/benchup-cli/src/project.rs index 1785d2c..74513c6 100644 --- a/crates/benchup-cli/src/project.rs +++ b/crates/benchup-cli/src/project.rs @@ -1,11 +1,15 @@ //! 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; -use benchup_tools::{Call, ToolEnv, execute, files}; +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."; @@ -18,6 +22,7 @@ fn stamp(bytes: &[u8]) -> u64 { pub struct Project { path: PathBuf, + allow_shared: bool, pub doc: Document, env: ToolEnv, stamp: u64, @@ -30,8 +35,7 @@ pub struct Project { pub enum Applied { /// Every call succeeded; one result per call. Ok(Vec), - /// Call `index` failed; nothing was changed or saved. - /// Call `index` failed; `earlier` holds the results of the calls before it. + /// Call `index` failed; nothing was changed or saved; `earlier` holds the results of the calls before it. Failed { index: usize, result: Value, earlier: Vec }, } @@ -39,20 +43,31 @@ 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 = 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() - )); - } + 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, doc, env, stamp: stamp(&bytes), counter: 0, saved: bytes }) + 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`. @@ -90,19 +105,24 @@ impl Project { /// 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 { - let original = self.doc.clone(); + // 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 = match resolve(arguments, &results) { + let arguments = if batch { resolve(arguments, &results) } else { Ok(arguments.clone()) }; + let arguments = match arguments { Ok(arguments) => arguments, Err(error) => { - self.doc = original; - self.env.selection = selection_before; + self.restore(model_before, context_before, selection_before); return Ok(Applied::Failed { index, earlier: results, @@ -114,8 +134,7 @@ impl Project { 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.doc = original; - self.env.selection = selection_before; + self.restore(model_before, context_before, selection_before); return Ok(Applied::Failed { index, result: outcome.result, earlier: results }); } mutated |= outcome.mutated; @@ -126,46 +145,103 @@ impl Project { results.push(result); } if !save { - self.doc = original; - self.env.selection = selection_before; + self.restore(model_before, context_before, selection_before); return Ok(Applied::Ok(results)); } - if mutated { - let label = match calls { - [(name, _)] => format!("Agent: {name}"), - _ => format!("Agent: {} changes", calls.len()), - }; - self.doc.history.record(label, original.model.clone(), &self.doc.model); + 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 || self.env.selection != selection_before) + if (mutated || selection_changed) && let Err(error) = self.save() { - self.doc = original; + // 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), } -/// Replaces every string of the form `$N.path` (N counts calls from 1, path is -/// `.field` and `[index]` steps, negative indexes count from the end) with that -/// value from an earlier result. +/// `$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) if text.starts_with('$') && text.len() > 1 && text.as_bytes()[1].is_ascii_digit() => { - reference(text, results) - } - // A reference to a list inside a list is spliced in, so - // "ids": ["$1.created", "$2.created"] joins both lists. + 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 t.starts_with('$')); + let is_reference = matches!(item, Value::String(t) if parse_reference(t).is_some()); match resolve(item, results)? { - Value::Array(inner) if is_reference => out.extend(inner), + Value::Array(inner) if is_reference && inner.iter().all(|v| v.is_u64() || v.is_i64()) => { + out.extend(inner) + } other => out.push(other), } } @@ -180,30 +256,21 @@ fn resolve(value: &Value, results: &[Value]) -> Result { } } -fn reference(text: &str, results: &[Value]) -> Result { - let body = &text[1..]; - let split = body.find(['.', '[']).unwrap_or(body.len()); - let index: usize = body[..split].parse().map_err(|_| format!("bad reference {text}"))?; - let mut current = index +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 {index}, but only {} earlier calls ran", results.len()))?; - let mut rest = &body[split..]; - while !rest.is_empty() { - let (step, next) = if let Some(tail) = rest.strip_prefix('.') { - let end = tail.find(['.', '[']).unwrap_or(tail.len()); - (current.get(&tail[..end]), &tail[end..]) - } else if let Some(tail) = rest.strip_prefix('[') { - let end = tail.find(']').ok_or(format!("bad reference {text}"))?; - let index: i64 = tail[..end].parse().map_err(|_| format!("bad reference {text}"))?; - 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)), &tail[end + 1..]) - } else { - return Err(format!("bad reference {text}")); + .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 = step.ok_or(format!("{text} does not exist in the result of call {index}"))?; - rest = next; + current = next.ok_or(format!("{text} does not exist in the result of call {call}"))?; } Ok(current.clone()) } @@ -230,9 +297,13 @@ impl Project { } } -/// MCP: each `tools/call` is applied (and saved) on its own. +/// 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), @@ -261,4 +332,51 @@ mod tests { 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 index 03bb03d..26dacfd 100644 --- a/crates/benchup-cli/tests/cli.rs +++ b/crates/benchup-cli/tests/cli.rs @@ -56,7 +56,7 @@ fn a_call_edits_the_file_as_one_undo_step() { 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: create_board"]); + assert_eq!(doc.history.labels().collect::>(), ["Agent edit"]); assert_eq!(object_count(&path), 1); } @@ -73,7 +73,7 @@ fn a_batch_is_all_or_nothing() { 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: 2 changes"]); + assert_eq!(doc.history.labels().collect::>(), ["Agent edit"]); let before = std::fs::read(&path).unwrap(); let bad = format!( @@ -111,7 +111,7 @@ fn undo_and_redo_step_through_history() { benchup(&["call", &path, "create_board", &args], None); let undo = benchup(&["undo", &path], None); - assert_eq!(json_out(&undo), json!({"ok": true, "undone": "Agent: create_board"})); + 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)); @@ -305,7 +305,7 @@ fn batch_calls_can_use_earlier_results() { 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: 4 changes", "one undo step"); + 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]}})); diff --git a/crates/benchup-core/build.rs b/crates/benchup-core/build.rs index 77f47ca..3c9f0e3 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 08c8f71..0b6b5e8 100644 --- a/crates/benchup-tools/src/files.rs +++ b/crates/benchup-tools/src/files.rs @@ -47,7 +47,8 @@ pub fn reencode_like(original: &[u8], doc: &Document) -> Result, String> } let mut image = Vec::with_capacity(original.len()); image.extend_from_slice(PNG_SIGNATURE); - for chunk in chunks(original)? { + for chunk in chunks(original) { + let chunk = chunk?; if &chunk[4..8] != DOCUMENT_CHUNK { image.extend_from_slice(chunk); } @@ -99,20 +100,30 @@ fn with_document_chunk(png: &[u8], compressed: &[u8]) -> Result, String> Ok(out) } -/// The PNG's chunks (length, type, data and CRC), after the signature. -fn chunks(bytes: &[u8]) -> Result, String> { - let mut out = Vec::new(); +/// 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(); - while let Some(header) = bytes.get(position..position.saturating_add(8)) { + 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 end = position + let chunk = position .checked_add(12) .and_then(|v| v.checked_add(length)) - .ok_or(tr!("project_files.invalid_chunk_size"))?; - out.push(bytes.get(position..end).ok_or(tr!("project_files.truncated_chunk"))?); - position = end; - } - Ok(out) + .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 { @@ -131,7 +142,9 @@ pub fn project_payload(bytes: &[u8]) -> Result<&[u8], String> { if !bytes.starts_with(PNG_SIGNATURE) { return Ok(bytes); } - for chunk in chunks(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()); @@ -147,7 +160,7 @@ pub fn project_payload(bytes: &[u8]) -> Result<&[u8], String> { /// 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(); @@ -156,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()) @@ -164,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)] @@ -232,6 +246,14 @@ mod tests { 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] @@ -265,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/ops_tests.rs b/crates/benchup-tools/src/ops_tests.rs index bcc2744..7525c1a 100644 --- a/crates/benchup-tools/src/ops_tests.rs +++ b/crates/benchup-tools/src/ops_tests.rs @@ -850,6 +850,30 @@ fn cut_list_uses_materials_set_on_groups() { 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()); diff --git a/crates/benchup-tools/src/review.rs b/crates/benchup-tools/src/review.rs index aff5db1..ba669c0 100644 --- a/crates/benchup-tools/src/review.rs +++ b/crates/benchup-tools/src/review.rs @@ -1,7 +1,7 @@ //! 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, connections::ConnectionOperation, joinery::JointSettings}; +use benchup_core::{DVec3, Model, StockMaterial, connections::ConnectionOperation, joinery::JointSettings}; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; @@ -44,11 +44,12 @@ fn blank(model: &Model, id: u64) -> Option<[f64; 3]> { } /// The board's stock material, or the nearest one set on a group around it. -fn material_name(model: &Model, id: u64) -> Option { +/// 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.name.clone()); + return Some(material); } current = model.parent(object.id); } @@ -67,21 +68,27 @@ fn full_axes(board: (DVec3, DVec3), shared: DVec3) -> usize { /// Boards grouped by material and blank size, with totals per material. pub fn cut_list(model: &Model) -> Value { - let mut groups: BTreeMap<(String, [i64; 3]), (usize, BTreeSet, Vec)> = BTreeMap::new(); + // 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_name(model, id).unwrap_or_else(|| "unassigned".into()); + 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 mut totals: BTreeMap = BTreeMap::new(); + 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))| { @@ -90,23 +97,26 @@ pub fn cut_list(model: &Model) -> Value { total.0 += count; total.1 += l * w / 1.0e6 * *count as f64; total.2 += l * w * t / 1.0e9 * *count as f64; - json!({"material": material, "length_mm": l, "width_mm": w, "thickness_mm": t, "quantity": count, + 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(|(name, (count, area, volume))| { - let mut entry = json!({"material": name, "pieces": count, + .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.}); - let stock = model.stock_materials.values().find(|m| &m.name == name); - if let Some(stock) = stock.filter(|s| s.dimensions[1] > 0 && s.dimensions[2] > 0) { - let [_, w, l] = stock.dimensions.map(|v| v as f64 / 10.); - let stock_area = w * l / 1.0e6; - entry["stock_mm"] = json!({"width": w, "length": l}); - entry["minimum_stock_pieces"] = json!((area / stock_area).ceil() as u64); - } else if name == "unassigned" { - entry["note"] = json!("Assign a stock material with sizes (create_stock_material, set_material) to get minimum_stock_pieces."); + 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 }) @@ -192,7 +202,9 @@ pub fn check(model: &Model) -> Value { ); } } - for (id, error) in scene::joint_errors(model) { + 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); } @@ -228,8 +240,8 @@ pub fn check(model: &Model) -> Value { loose.into_iter().take(20).collect()); } for id in &all { - let Some(name) = material_name(model, *id) else { continue }; - let Some(stock) = model.stock_materials.values().find(|m| m.name == name).filter(|m| m.dimensions[0] > 0) else { continue }; + 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 { @@ -237,7 +249,7 @@ pub fn check(model: &Model) -> Value { 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_name(model, *id).is_none()).collect(); + 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()); } @@ -245,7 +257,6 @@ pub fn check(model: &Model) -> Value { 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 joint_errors = scene::joint_errors(model); let wood_joints = model.connections.values().filter(|c| matches!(c.operation, ConnectionOperation::Joinery(_))).count(); let mut out = json!({ "passed": errors == 0 && warnings == 0, diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 0f79c16..06bb269 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -25,7 +25,7 @@ This guide is written for AI agents that edit BenchUp projects through the [`ben ### 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 inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both. +- 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. diff --git a/docs/cli.md b/docs/cli.md index 00b853d..62629f3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -48,7 +48,7 @@ A batch file has one call per line. Blank lines and lines starting with `#` are {"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 inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both: +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]}} @@ -59,8 +59,8 @@ In a batch, `"$N.field"` is a value from the result of call N (counting from 1): ## 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: `, or `Agent: N changes` for a batch. 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. +- **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)).