From 182146ab23917d2d1e5fdcb4c2876a91c7200475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Sat, 14 Nov 2026 12:24:05 +0100 Subject: [PATCH 1/5] Add the next agent tools from #42 From the agent tests behind #41, the tools agents needed most: - Router profiles on board edges (add_profile, remove_profile), with edges named by the faces they lie between. - Joint trimming and set_joint; get_object shows a joint's geometry, the part actually cut and whether the cut in b is through, stopped or closed. - Finishes (set_finish) and a time estimate (get_estimate). - Boards from a 2D outline with arcs (create_profile_board). - Face operations (split_face, merge_faces), distribute and change_thickness, which keeps outer faces, joint shares and butting parts. - Scene filters, measure_space and find_surfaces for walls and floors made of plain faces, and check_model {only_new} for a batch's new problems. - A PNG preview without a GPU (benchup render, MCP render_view), with joints cut, dimensions and notes, reporting overlapping labels. - Over MCP, run_batch (with dry runs and $N references) and undo. - benchup open on macOS passes the file as a launch argument when BenchUp is not running (the app cannot take an open event at start-up, #58). The agent guide and CLI docs cover the new tools, MCP and the findings of two agent rounds. The Cutting Plan and the items that need a core decision stay open in #42; the joint precision limit is #61. Co-Authored-By: Claude Opus 5.5 --- crates/benchup-cli/skill/benchup/SKILL.md | 8 +- crates/benchup-cli/src/launch.rs | 42 +- crates/benchup-cli/src/main.rs | 101 +- crates/benchup-cli/src/mcp.rs | 91 +- crates/benchup-cli/src/project.rs | 82 + crates/benchup-cli/tests/cli.rs | 36 + crates/benchup-tools/src/lib.rs | 2 + crates/benchup-tools/src/ops.rs | 302 +++- crates/benchup-tools/src/ops_more.rs | 1701 ++++++++++++++++++++ crates/benchup-tools/src/ops_more_tests.rs | 447 +++++ crates/benchup-tools/src/render.rs | 562 +++++++ crates/benchup-tools/src/review.rs | 122 +- crates/benchup-tools/src/scene.rs | 351 +++- docs/agent-guide.md | 70 +- docs/cli.md | 13 +- 15 files changed, 3740 insertions(+), 190 deletions(-) create mode 100644 crates/benchup-tools/src/ops_more.rs create mode 100644 crates/benchup-tools/src/ops_more_tests.rs create mode 100644 crates/benchup-tools/src/render.rs diff --git a/crates/benchup-cli/skill/benchup/SKILL.md b/crates/benchup-cli/skill/benchup/SKILL.md index a164757..daa2206 100644 --- a/crates/benchup-cli/skill/benchup/SKILL.md +++ b/crates/benchup-cli/skill/benchup/SKILL.md @@ -1,6 +1,6 @@ --- 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). +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, edge profiles, finishes, cut lists). --- # BenchUp projects @@ -8,9 +8,9 @@ description: Design or edit furniture and woodworking projects in BenchUp (.bup 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`. +2. Read the project with `benchup scene file.bup` (on big projects, filter it: `benchup scene file.bup '{"depth":0}'`). 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, ending the batch with `check_model {"only_new": true}`. +4. Verify with `benchup call file.bup check_model`, look at it with `benchup render file.bup --view front`, and for materials and time use `get_cut_list` and `get_estimate`. 5. Show the result with `benchup open file.bup`. If a size does not fit or the request is ambiguous, say so and propose an interpretation instead of forcing it. `benchup tools ` shows any tool's exact arguments. diff --git a/crates/benchup-cli/src/launch.rs b/crates/benchup-cli/src/launch.rs index d21ee1a..81fff6d 100644 --- a/crates/benchup-cli/src/launch.rs +++ b/crates/benchup-cli/src/launch.rs @@ -6,6 +6,12 @@ //! 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. +//! +//! On macOS, a document handed to an app that is still starting arrives +//! before BenchUp can take it, and macOS reports that BenchUp "cannot open +//! files in the BenchUp project format". So when the app is not running yet, +//! the file is passed as a launch argument instead, which the app reads at +//! start-up. use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -48,12 +54,34 @@ fn plain_path(path: PathBuf) -> PathBuf { path } +/// Whether the installed BenchUp is running (macOS lists it by bundle id). +#[cfg(target_os = "macos")] +fn app_running() -> bool { + Command::new("lsappinfo") + .args(["find", &format!("bundleid={BUNDLE_ID}")]) + .stdin(Stdio::null()) + .output() + .is_ok_and(|o| o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty()) +} + +/// `open` arguments: hand the file to a running app, or start the app with +/// the file as its argument. +#[cfg(target_os = "macos")] +fn open_arguments(path: &Path, running: bool) -> Vec { + let mut args: Vec = vec!["-b".into(), BUNDLE_ID.into()]; + if !running { + args.push("--args".into()); + } + args.push(path.into()); + args +} + /// 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.args(open_arguments(path, app_running())); command } // Explorer opens the file with its association without a shell parsing @@ -105,3 +133,15 @@ pub fn open(path: &str, app: Option<&str>) -> Result { } Ok(json!({"ok": true, "opened": opened, "app": "BenchUp"})) } + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + + #[test] + fn a_running_app_gets_the_file_and_a_starting_one_gets_it_as_an_argument() { + let path = Path::new("/tmp/a b&c.bup"); + assert_eq!(open_arguments(path, true), ["-b", BUNDLE_ID, "/tmp/a b&c.bup"]); + assert_eq!(open_arguments(path, false), ["-b", BUNDLE_ID, "--args", "/tmp/a b&c.bup"]); + } +} diff --git a/crates/benchup-cli/src/main.rs b/crates/benchup-cli/src/main.rs index f9416ff..c3578a9 100644 --- a/crates/benchup-cli/src/main.rs +++ b/crates/benchup-cli/src/main.rs @@ -7,7 +7,7 @@ mod mcp; mod project; use benchup_tools::{definitions, is_mutating, mcp_tools}; -use project::{Applied, Project}; +use project::Project; use serde_json::{Value, json}; use std::io::{Read, stdin, stdout}; use std::path::Path; @@ -20,9 +20,15 @@ New here? Run `benchup guide` first: conventions, workflow and woodworking recip 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 tools [NAME] [--json] [--names] list tools (--names: names only), or show one + tool's JSON schema + benchup scene [FILTER] scene summary (objects, stock, joints); FILTER + is get_scene's JSON, e.g. '{\"depth\":0}' benchup get one entity in detail, with its faces + benchup render [-o OUT.png] [--view VIEW] [--size WxH] + a PNG of the model (iso, front, back, left, + right, top, bottom; default iso, 1000x750; + 64 to 2400 pixels a side) benchup call [ARGS|-] run a tool; ARGS is a JSON object (- reads stdin) benchup call --batch run JSON lines {\"tool\": .., \"arguments\": {..}} as one all-or-nothing undo step; \"$N.created[0]\" @@ -140,49 +146,30 @@ fn parse_batch(text: &str) -> Result, String> { } fn apply(project: &mut Project, calls: &[(String, Value)]) -> Result { - run_calls(project, calls, true) + run_calls(project, calls, true, false) } -fn run_calls(project: &mut Project, calls: &[(String, Value)], save: bool) -> Result { - let batch = calls.len() > 1; - Ok(match project.run(calls, save)? { - Applied::Ok(mut results) if !batch => ok(results.pop().unwrap_or(Value::Null)), - Applied::Ok(results) => ok(json!({"ok": true, "results": results})), - Applied::Failed { result, .. } if !batch => Output { value: result, ok: false }, - Applied::Failed { index, result, earlier } => Output { - value: json!({ - "ok": false, - "failed_call": index + 1, - "tool": calls[index].0, - "result": result, - "earlier_results": earlier, - "note": "nothing was changed: earlier_results shows what the calls before it returned, then everything was rolled back; fix this call and run the whole batch again", - }), - ok: false, - }, - }) +fn run_calls(project: &mut Project, calls: &[(String, Value)], save: bool, batch: bool) -> Result { + let (value, ok) = project::report(calls, project.run(calls, save)?, batch); + Ok(Output { value, ok }) } -/// Drops the object summaries that changing tools attach to their results, -/// keeping ids, bounds, errors and the answers of reading tools. -fn brief(value: &mut Value) { - if let Some(map) = value.as_object_mut() { - if map.contains_key("created") { - map.remove("objects"); - } - for key in ["results", "result"] { - match map.get_mut(key) { - Some(Value::Array(items)) => items.iter_mut().for_each(brief), - Some(item @ Value::Object(_)) => brief(item), - _ => {} - } - } - } +/// Pretty JSON with the answer first: `ok`, then the error and hint of a +/// failure, then the rest in name order. +fn pretty(value: &Value) -> String { + const FIRST: [&str; 6] = ["ok", "error", "hint", "failed_call", "tool", "dry_run"]; + let Value::Object(map) = value else { return serde_json::to_string_pretty(value).unwrap_or_default() }; + let keys = FIRST.iter().copied().filter(|k| map.contains_key(*k)).chain(map.keys().map(String::as_str).filter(|k| !FIRST.contains(k))); + let fields: Vec = keys + .map(|key| format!(" {}: {}", json!(key), serde_json::to_string_pretty(&map[key]).unwrap_or_default().replace('\n', "\n "))) + .collect(); + if fields.is_empty() { "{}".into() } else { format!("{{\n{}\n}}", fields.join(",\n")) } } fn tools(args: Args) -> Result { match args.positional.as_slice() { [] if args.flag("--json") => Ok(ok(Value::Array(mcp_tools()))), + [] if args.flag("--names") => Ok(ok(json!(definitions().into_iter().map(|d| d.name).collect::>()))), [] => { let list: Vec = definitions() .into_iter() @@ -213,11 +200,38 @@ fn run(mut argv: Vec) -> Result { 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"], &[])?), + "tools" => tools(parse_args(argv, &["--json", "--names"], &[])?), "scene" => { let args = parse_args(argv, &["--allow-shared"], &[])?; - args.expect(1)?; - apply(&mut open(&args)?, &[("get_scene".into(), json!({}))]) + let filter = match args.positional.len() { + 1 => json!({}), + 2 => parse_object(&args.positional[1], "FILTER")?, + _ => return Err(USAGE.into()), + }; + apply(&mut open(&args)?, &[("get_scene".into(), filter)]) + } + "render" => { + let args = parse_args(argv, &["--allow-shared"], &["-o", "--output", "--view", "--size"])?; + let path = &args.expect(1)?[0]; + let view = args.value("--view").unwrap_or("iso"); + let (width, height) = match args.value("--size") { + None => (1000, 750), + Some(size) => size + .split_once('x') + .and_then(|(w, h)| Some((w.trim().parse().ok()?, h.trim().parse().ok()?))) + .ok_or("--size is WIDTHxHEIGHT in pixels, e.g. 1200x900")?, + }; + let project = open(&args)?; + let rendered = benchup_tools::render::render(&project.doc.model, view, width, height)?; + let output = match args.value("-o").or(args.value("--output")) { + Some(output) => std::path::PathBuf::from(output), + None => Path::new(path).with_extension(format!("{view}.png")), + }; + std::fs::write(&output, &rendered.png).map_err(|e| format!("cannot write {}: {e}", output.display()))?; + let mut value = rendered.info; + value["ok"] = json!(true); + value["file"] = json!(output.display().to_string()); + Ok(ok(value)) } "get" => { let args = parse_args(argv, &["--allow-shared"], &[])?; @@ -239,12 +253,13 @@ fn run(mut argv: Vec) -> Result { } _ => return Err(USAGE.into()), }; - let mut output = run_calls(&mut open(&args)?, &calls, !args.flag("--dry-run"))?; + let batch = args.value("--batch").is_some(); + let mut output = run_calls(&mut open(&args)?, &calls, !args.flag("--dry-run"), batch)?; if args.flag("--dry-run") { output.value["dry_run"] = json!(true); } if args.flag("--brief") { - brief(&mut output.value); + project::brief(&mut output.value); } Ok(output) } @@ -278,7 +293,7 @@ fn run(mut argv: Vec) -> Result { fn main() -> ExitCode { match run(std::env::args().skip(1).collect()) { Ok(output) => { - println!("{}", serde_json::to_string_pretty(&output.value).unwrap_or_default()); + println!("{}", pretty(&output.value)); if output.ok { ExitCode::SUCCESS } else { ExitCode::from(1) } } Err(message) => { diff --git a/crates/benchup-cli/src/mcp.rs b/crates/benchup-cli/src/mcp.rs index 23153c8..6f4c2b9 100644 --- a/crates/benchup-cli/src/mcp.rs +++ b/crates/benchup-cli/src/mcp.rs @@ -6,11 +6,84 @@ const SUPPORTED: [&str; 4] = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11 const INSTRUCTIONS: &str = "Edits a BenchUp 3D woodworking project. Lengths are millimetres, Z is up. \ Call get_scene first to see the model. Each mutating call is one undo step in BenchUp. \ The session starts at the top level; a group opened with enter_object stays open until exit_to_root. \ -Call get_guide once for conventions (front is -y), joint roles and woodworking recipes."; +Call get_guide once for conventions (front is -y), joint roles and woodworking recipes. \ +Use run_batch for several calls as one undo step (with dry_run to try them first) and undo to go back."; /// 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); + /// Several calls as one undo step, all or nothing; `dry_run` changes nothing (`run_batch`). + fn batch(&mut self, calls: Vec<(String, Value)>, dry_run: bool) -> (Value, bool); + /// Undo or redo the last change to the file (`undo`). + fn history(&mut self, redo: bool) -> (Value, bool); + /// A PNG of the model from a view, and what it shows (`render_view`). + fn render(&mut self, view: &str, width: u32, height: u32) -> Result<(Vec, Value), String>; +} + +/// `render_view` is served here rather than by the shared tools: its result +/// is an image, which only MCP clients can take. +fn render_view_tool() -> Value { + json!({ + "name": "render_view", + "description": "A PNG image of the model from a standard view (iso, front, back, left, right, top, bottom), with joints and router profiles cut, edges and dimensions with their values, to check a result without opening BenchUp. The text part reports dimensions whose labels overlap or that point along the view and cannot be read.", + "inputSchema": {"type": "object", "properties": { + "view": {"type": "string", "enum": benchup_tools::render::VIEWS}, + "width": {"type": "integer", "minimum": benchup_tools::render::MIN_SIZE, "maximum": benchup_tools::render::MAX_SIZE}, + "height": {"type": "integer", "minimum": benchup_tools::render::MIN_SIZE, "maximum": benchup_tools::render::MAX_SIZE}}, + "additionalProperties": false}, + }) +} + +/// Tools that only make sense for a file served over MCP: batches with dry +/// runs and `$N` references, and undo. The shared tools act one call at a time. +fn session_tools() -> Vec { + vec![ + json!({ + "name": "run_batch", + "description": "Run several tool calls as ONE undo step, all or nothing, like `benchup call --batch`. In a call's arguments, \"$N.field\" is a value from call N's result (counting from 1), e.g. \"$1.created[0]\" or \"$3.joint_id\"; a reference to a list of ids inside a list is spliced in. With dry_run: true nothing is saved: every result is reported and the file stays as it was (end a dry run with check_model {\"only_new\": true}). When a call fails, nothing is changed and the answer names the failed call, its error and the results of the calls before it.", + "inputSchema": {"type": "object", "properties": { + "calls": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": { + "tool": {"type": "string"}, "arguments": {"type": "object"}}, "required": ["tool"], "additionalProperties": false}}, + "dry_run": {"type": "boolean"}}, "required": ["calls"], "additionalProperties": false}, + }), + json!({ + "name": "undo", + "description": "Undo the last change to the project (one call or one run_batch), or redo it with redo: true. The same history as BenchUp's Edit menu.", + "inputSchema": {"type": "object", "properties": {"redo": {"type": "boolean"}}, "additionalProperties": false}, + }), + ] +} + +fn session_call(backend: &mut dyn Backend, name: &str, arguments: &Value) -> (Value, bool) { + if name == "undo" { + return backend.history(arguments.get("redo").and_then(Value::as_bool).unwrap_or(false)); + } + let Some(list) = arguments.get("calls").and_then(Value::as_array).filter(|l| !l.is_empty()) else { + return (json!({"ok": false, "error": "calls must be a non-empty list of {\"tool\", \"arguments\"}"}), false); + }; + let mut calls = Vec::with_capacity(list.len()); + for (n, call) in list.iter().enumerate() { + let Some(tool) = call.get("tool").and_then(Value::as_str) else { + return (json!({"ok": false, "error": format!("call {} has no \"tool\"", n + 1)}), false); + }; + calls.push((tool.to_owned(), call.get("arguments").cloned().unwrap_or_else(|| json!({})))); + } + backend.batch(calls, arguments.get("dry_run").and_then(Value::as_bool).unwrap_or(false)) +} + +fn render_view(backend: &mut dyn Backend, arguments: &Value) -> Value { + let view = arguments.get("view").and_then(Value::as_str).unwrap_or("iso"); + let size = |key: &str, default: u32| arguments.get(key).and_then(Value::as_u64).map_or(default, |v| v.min(u64::from(u32::MAX)) as u32); + match backend.render(view, size("width", 1000), size("height", 750)) { + Ok((png, info)) => json!({ + "content": [ + {"type": "image", "data": benchup_tools::render::base64(&png), "mimeType": "image/png"}, + {"type": "text", "text": info.to_string()}, + ], + "isError": false, + }), + Err(error) => json!({"content": [{"type": "text", "text": json!({"ok": false, "error": error}).to_string()}], "isError": true}), + } } fn error(id: Value, code: i64, message: &str) -> Value { @@ -48,13 +121,25 @@ fn handle(message: &Value, backend: &mut dyn Backend, client: &mut String) -> Op })) } "ping" => ok(json!({})), - "tools/list" => ok(json!({"tools": benchup_tools::mcp_tools()})), + "tools/list" => { + let mut tools = benchup_tools::mcp_tools(); + tools.push(render_view_tool()); + tools.extend(session_tools()); + ok(json!({"tools": 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); + if name == "render_view" { + return Some(ok(render_view(backend, &arguments))); + } + let (result, success) = if matches!(name, "run_batch" | "undo") { + session_call(backend, name, &arguments) + } else { + backend.call(client, name, arguments) + }; let mut out = json!({ "content": [{"type": "text", "text": result.to_string()}], "isError": !success, diff --git a/crates/benchup-cli/src/project.rs b/crates/benchup-cli/src/project.rs index 74513c6..50f0d08 100644 --- a/crates/benchup-cli/src/project.rs +++ b/crates/benchup-cli/src/project.rs @@ -297,6 +297,53 @@ impl Project { } } +/// The JSON answer for applied calls, as the CLI prints it and MCP returns +/// it: one call's result, or `{"ok", "results"}` for a batch. A failed batch +/// leads with the error and hint of the call that failed; the results of the +/// calls before it are kept brief (ids and bounds, no object summaries). +/// `batch` is true for `--batch` and `run_batch`, which always answer with +/// `results`, even for one call. +pub fn report(calls: &[(String, Value)], applied: Applied, batch: bool) -> (Value, bool) { + match applied { + Applied::Ok(mut results) if !batch => (results.pop().unwrap_or(Value::Null), true), + Applied::Ok(results) => (json!({"ok": true, "results": results}), true), + Applied::Failed { result, .. } if !batch => (result, false), + Applied::Failed { index, result, mut earlier } => { + earlier.iter_mut().for_each(brief); + let mut out = json!({ + "ok": false, + "failed_call": index + 1, + "tool": calls[index].0, + "error": result.get("error").cloned().unwrap_or(Value::Null), + "result": result, + "earlier_results": earlier, + "note": "nothing was changed: earlier_results shows what the calls before it returned, then everything was rolled back; fix this call and run the whole batch again", + }); + if let Some(hint) = out["result"].get("hint").cloned() { + out["hint"] = hint; + } + (out, false) + } + } +} + +/// Drops the object summaries that changing tools attach to their results, +/// keeping ids, bounds, errors and the answers of reading tools. +pub fn brief(value: &mut Value) { + if let Some(map) = value.as_object_mut() { + if map.contains_key("created") { + map.remove("objects"); + } + for key in ["results", "result", "earlier_results"] { + match map.get_mut(key) { + Some(Value::Array(items)) => items.iter_mut().for_each(brief), + Some(item @ Value::Object(_)) => brief(item), + _ => {} + } + } + } +} + /// 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 { @@ -310,6 +357,41 @@ impl crate::mcp::Backend for Project { Err(error) => (json!({"ok": false, "error": error}), false), } } + + fn batch(&mut self, calls: Vec<(String, Value)>, dry_run: bool) -> (Value, bool) { + if let Err(error) = self.refresh() { + return (json!({"ok": false, "error": error}), false); + } + match self.run(&calls, !dry_run) { + Ok(applied) => { + let (mut value, ok) = report(&calls, applied, true); + if dry_run { + value["dry_run"] = json!(true); + } + (value, ok) + } + Err(error) => (json!({"ok": false, "error": error}), false), + } + } + + fn history(&mut self, redo: bool) -> (Value, bool) { + if let Err(error) = self.refresh() { + return (json!({"ok": false, "error": error}), false); + } + match self.step_history(redo) { + Ok(value) => { + let ok = value["ok"] == true; + (value, ok) + } + Err(error) => (json!({"ok": false, "error": error}), false), + } + } + + fn render(&mut self, view: &str, width: u32, height: u32) -> Result<(Vec, Value), String> { + self.refresh()?; + let rendered = benchup_tools::render::render(&self.doc.model, view, width, height)?; + Ok((rendered.png, rendered.info)) + } } #[cfg(test)] diff --git a/crates/benchup-cli/tests/cli.rs b/crates/benchup-cli/tests/cli.rs index 26dacfd..ad26665 100644 --- a/crates/benchup-cli/tests/cli.rs +++ b/crates/benchup-cli/tests/cli.rs @@ -60,6 +60,42 @@ fn a_call_edits_the_file_as_one_undo_step() { assert_eq!(object_count(&path), 1); } +#[test] +fn mcp_runs_batches_with_dry_runs_undoes_them_and_renders() { + let dir = tempfile::tempdir().unwrap(); + let path = new_project(dir.path()); + let calls = json!([ + {"tool": "create_board", "arguments": board("Side", [0., 0., 0.], [18., 300., 900.])}, + {"tool": "create_board", "arguments": board("Shelf", [9., 0., 400.], [500., 290., 18.])}, + {"tool": "add_joint", "arguments": {"a": "$2.created[0]", "b": "$1.created[0]", "kind": "tongue_groove"}}, + ]); + let call = |id: u64, name: &str, arguments: Value| json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": arguments}}); + let messages = [ + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}), + call(2, "run_batch", json!({"calls": calls, "dry_run": true})), + call(3, "run_batch", json!({"calls": calls})), + call(4, "run_batch", json!({"calls": [{"tool": "create_board", "arguments": board("Top", [0., 0., 900.], [500., 300., 18.])}, + {"tool": "move", "arguments": {"ids": [999_999], "delta_mm": [1, 0, 0]}}]})), + call(5, "render_view", json!({"view": "front", "width": 200, "height": 150})), + call(6, "undo", json!({})), + ]; + let input: String = messages.iter().map(|m| format!("{m}\n")).collect(); + let output = benchup(&["mcp", &path], Some(&input)); + let replies: Vec = + String::from_utf8(output.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let content = |n: usize| &replies[n]["result"]["structuredContent"]; + assert_eq!(content(1)["dry_run"], true); + assert_eq!(content(1)["results"][2]["ok"], true, "references resolve in a dry run"); + assert_eq!(content(2)["results"].as_array().unwrap().len(), 3); + assert_eq!(replies[3]["result"]["isError"], true); + assert_eq!(content(3)["failed_call"], 2); + assert_eq!(content(3)["error"], "unknown id 999999"); + assert_eq!(replies[4]["result"]["content"][0]["mimeType"], "image/png"); + assert_eq!(content(5)["ok"], true); + // The dry run and the failed batch changed nothing; undo took back the whole real batch. + assert_eq!(object_count(&path), 0); +} + #[test] fn a_batch_is_all_or_nothing() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/benchup-tools/src/lib.rs b/crates/benchup-tools/src/lib.rs index 1b8c877..39b6f1b 100644 --- a/crates/benchup-tools/src/lib.rs +++ b/crates/benchup-tools/src/lib.rs @@ -9,10 +9,12 @@ //! * [`scene`] describes the model compactly (summary, references, per-object //! detail) for an agent to read. //! * [`files`] reads and writes `.bup` project files (gzip-compressed JSON). +//! * [`render`] draws a PNG preview of a standard view without a GPU. //! //! The crate has no network or UI dependencies and builds for `wasm32`. pub mod files; pub mod ops; +pub mod render; pub mod review; pub mod scene; diff --git a/crates/benchup-tools/src/ops.rs b/crates/benchup-tools/src/ops.rs index 932eaf8..479981a 100644 --- a/crates/benchup-tools/src/ops.rs +++ b/crates/benchup-tools/src/ops.rs @@ -7,7 +7,7 @@ use benchup_core::{ Axis, DVec3, MaterialId, Model, StockMaterial, connections::{Connection, ConnectionOperation}, inference::AxisConstraint, - joinery::{JointKind, JointSettings}, + joinery::{JointKind, JointSettings, KeepEnd, TrimBoard}, }; use serde::{Deserialize, de::DeserializeOwned}; use serde_json::{Map, Value, json}; @@ -102,15 +102,23 @@ const TOOLS: &[(&str, bool)] = &[ ("check_model", false), ("get_cut_list", false), ("get_guide", false), + ("get_estimate", false), + ("measure_space", false), + ("find_surfaces", false), ("create_stock_material", true), ("create_board", true), ("create_copy", true), + ("create_profile_board", true), ("draw_polyline", true), + ("split_face", true), + ("merge_faces", true), ("move", true), ("move_to", true), ("rotate", true), ("scale", true), ("resize_board", true), + ("change_thickness", true), + ("distribute", true), ("push_pull", true), ("rename", true), ("set_material", true), @@ -123,6 +131,10 @@ const TOOLS: &[(&str, bool)] = &[ ("add_joint", true), ("remove_joint", true), ("flip_joint", true), + ("set_joint", true), + ("add_profile", true), + ("remove_profile", true), + ("set_finish", true), ("add_dimension", true), ("add_text", true), ("select", false), @@ -233,15 +245,18 @@ pub fn mcp_tools() -> Vec { 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!({}), &[]), + let mut list = vec![ + def("get_scene", "Return the scene summary (objects, selection, stock, joints, dimensions, texts). Lengths are in mm. Filters keep it small on big projects: ids lists only those objects and what is inside them; depth limits nesting (0 = top level only, or only the given ids); fields picks object fields (id is always included); sections picks the parts of the summary.", + json!({"ids": ids(), "depth": {"type": "integer", "minimum": 0, "maximum": 64}, + "fields": {"type": "array", "items": {"type": "string", "enum": scene::OBJECT_FIELDS}}, + "sections": {"type": "array", "items": {"type": "string", "enum": scene::SECTIONS}}}), &[]), def("find_objects", "Search objects (groups and components) when the scene summary was truncated. query matches part of a name, tag or material name, a kind (\"group\" or \"component\") or an exact id; omit it or pass \"\" to list every object. Dimensions and texts are not objects.", json!({"query": text(), "limit": {"type": "integer", "minimum": 1, "maximum": 100}}), &[]), def("get_object", "Details of one entity, including its faces (id, normal, facing, center_mm, area_mm2) for push_pull. Face normals point out of the part; a positive push_pull distance moves a face outward.", json!({"id": id()}), &["id"]), def("get_selection", "Entities currently selected in the editor.", json!({}), &[]), def("list_stock_materials", "Stock materials that can be assigned to boards (ids for material_id). A new project has none: add them with create_stock_material.", json!({}), &[]), - def("check_model", "Look for problems before building: boards passing through others or overlapping without a joint, joints that cannot be cut, look reversed or lost a board, thickness that does not match the material, open solids, loose geometry, boards without material. passed is true when there are no errors or warnings (ok only says the check ran). Run it after a change.", json!({}), &[]), + def("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. With only_new, it reports only the problems that the earlier calls of this batch (or session turn) introduced, so a dry-run batch ending with check_model{only_new:true} ignores problems that were already there.", json!({"only_new": {"type": "boolean"}}), &[]), 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.", @@ -272,24 +287,27 @@ pub fn definitions() -> Vec { def("set_material", "Assign a stock material to objects, or null to clear.", 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("set_grain", "Set the grain direction of objects along a world axis, or null to clear (\"id\" for one object is accepted too).", + json!({"ids": ids(), "axis": {"type": ["string", "null"], "enum": ["x", "y", "z", null]}}), &["ids", "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: 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("ungroup", "Dissolve groups/components one level. The released objects keep their ids and are listed in members.", json!({"ids": ids()}), &["ids"]), def("tag", "Assign a tag by name (created if missing), or null to clear.", json!({"ids": ids(), "tag": tag_name(true)}), &["ids", "tag"]), def("paint", "Paint objects or faces with an RGB color.", json!({"ids": ids(), "rgb": {"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": 255}, "minItems": 3, "maxItems": 3}}), &["ids", "rgb"]), def("erase", "Delete entities (objects, faces, edges, dimensions, texts). Joints that use an erased board are removed with it; use remove_joint to delete a joint alone.", json!({"ids": ids()}), &["ids"]), - def("add_joint", "Add a wood joint between two boards that already overlap where they meet; the overlap is the joint's depth. a carries the tenon, tongue, tails, fingers or lap: the board that goes into the other (shelf, apron, drawer side). b receives the mortise, groove or sockets (side, leg, drawer front). params.depth_axis is the direction a goes into b; width_axis is the axis across which the tongue, tenon or fingers are spread (width_ratio of it); the third axis is the height (height_ratio, mortise_tenon only). Axes are 0=x, 1=y, 2=z (a's own axes if it is rotated). Omit them and the first orientation that can be cut is chosen. Params by kind: mortise_tenon uses width_ratio and height_ratio; tongue_groove and rabbet use width_ratio; dovetail uses dovetails, width_ratio (share of the width taken by tails) and taper_degrees; finger uses fingers; all use clearance_mm. Ranges: ratios 0.1-0.9 (0.5), fingers 2-16 (4), dovetails 1-16 (1), taper_degrees 0-30 (8), clearance_mm 0-5 (0.1). The joint must be cuttable or the call fails; joints on one board must not cross, touch or end in the same plane. Both boards must be at the same group level. Returns joint_id and the orientation used; get_object on a joint shows its settings and supported orientations.", + 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. Trimming: trim \"a\" or \"b\" cuts that board's excess flush with the other board's outer face (keep_end lower or upper says which end of the board stays, along trim_axis, default its longest axis). Returns joint_id and the orientation used; get_object on a joint shows its settings, geometry and supported orientations. set_joint changes an existing joint.", json!({"a": id(), "b": id(), "kind": {"type": "string", "enum": joint_kinds}, "params": {"type": "object", "properties": { "width_ratio": number(), "height_ratio": number(), "fingers": {"type": "integer"}, "dovetails": {"type": "integer"}, "taper_degrees": number(), "clearance_mm": number(), "depth_axis": {"type": "integer", "minimum": 0, "maximum": 2}, - "width_axis": {"type": "integer", "minimum": 0, "maximum": 2}}, + "width_axis": {"type": "integer", "minimum": 0, "maximum": 2}, + "trim": {"type": "string", "enum": ["none", "a", "b"]}, + "trim_axis": {"type": "integer", "minimum": 0, "maximum": 2}, + "keep_end": {"type": "string", "enum": ["lower", "upper"]}}, "additionalProperties": false}}), &["a", "b", "kind"]), def("remove_joint", "Delete wood joints by id (from the joints list in get_scene); the boards stay.", @@ -303,7 +321,9 @@ pub fn definitions() -> Vec { def("select", "Select entities in the editor so the designer sees them.", json!({"ids": ids()}), &["ids"]), 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!({}), &[]), - ] + ]; + list.extend(more::definitions()); + list } // ---------------------------------------------------------------- arguments @@ -482,7 +502,9 @@ struct MaterialArgs { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct GrainArgs { - id: u64, + /// `"id": 5` is accepted too, like the other tools that take several objects. + #[serde(alias = "id", deserialize_with = "one_or_many")] + ids: Vec, axis: Option, } #[derive(Deserialize)] @@ -515,6 +537,22 @@ struct JointParams { clearance_mm: Option, depth_axis: Option, width_axis: Option, + trim: Option, + trim_axis: Option, + keep_end: Option, +} +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum TrimArg { + None, + A, + B, +} +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum KeepEndArg { + Lower, + Upper, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -775,11 +813,21 @@ fn scale(model: &mut Model, a: ScaleArgs) -> Res { } fn resize_board(model: &mut Model, a: ResizeArgs) -> Res { let id = one_object(model, a.id)?; - let set = BTreeSet::from([id]); let target = point(a.size_mm)?; if target.min_element() < 1.0e-4 { return Err(fail("size_mm out of range", "Each extent must be at least 0.1 mm.")); } + let anchors = match a.anchor { + None => [Anchor::Min; 3], + Some(AnchorArg::All(anchor)) => [anchor; 3], + Some(AnchorArg::PerAxis(anchors)) => anchors, + }; + resize_object(model, id, target, anchors)?; + Ok(modified(&BTreeSet::from([id]))) +} +/// Stretch an object to `target` (world extents), keeping the `anchors` side of each axis. +fn resize_object(model: &mut Model, id: u64, target: DVec3, anchors: [Anchor; 3]) -> Result<(), Fail> { + let set = BTreeSet::from([id]); let (lo, hi) = model.selection_bounds(&set).ok_or("the object has no geometry")?; let current = hi - lo; for axis in [Axis::X, Axis::Y, Axis::Z] { @@ -792,11 +840,6 @@ 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., @@ -806,7 +849,7 @@ fn resize_board(model: &mut Model, a: ResizeArgs) -> Res { if shift.length() > 1.0e-12 { model.move_selection(&set, shift)?; } - Ok(modified(&set)) + Ok(()) } fn move_to(model: &mut Model, a: MoveToArgs) -> Res { let id = one_object(model, a.id)?; @@ -906,9 +949,12 @@ fn set_material(model: &mut Model, env: &ToolEnv, a: MaterialArgs) -> Res { Ok(modified(&set)) } fn set_grain(model: &mut Model, a: GrainArgs) -> Res { - let id = one_object(model, a.id)?; - model.set_grain_direction(id, a.axis.map(|axis| axis.axis().vector()))?; - Ok(modified(&BTreeSet::from([id]))) + let set = objects(model, &a.ids, true)?; + let direction = a.axis.map(|axis| axis.axis().vector()); + for id in &set { + model.set_grain_direction(*id, direction)?; + } + Ok(modified(&set)) } fn group(model: &mut Model, a: GroupArgs) -> Res { let mut set = entities(model, &a.ids, true)?; @@ -960,11 +1006,14 @@ fn group(model: &mut Model, a: GroupArgs) -> Res { fn ungroup(model: &mut Model, a: IdsArg) -> Res { let set = objects(model, &a.ids, true)?; let released = model.dissolve_objects(&set, false); - Ok(Done { + let mut done = Done { modified: released.into_iter().filter(|id| model.contains_entity(id)).collect(), erased: set.iter().copied().filter(|id| !model.contains_entity(id)).collect(), ..Default::default() - }) + }; + // Ungrouping keeps the members' ids (grouping does not). + done.extra.insert("members".into(), Value::Array(done.modified.iter().filter_map(|id| scene::object_entry(model, *id)).collect())); + Ok(done) } fn tag(model: &mut Model, a: TagArgs) -> Res { let set = entities(model, &a.ids, true)?; @@ -1113,6 +1162,74 @@ fn preferred_axes(model: &Model, members: [u64; 2], settings: &JointSettings) -> }; Some((depth, width)) } +/// Apply the optional joint settings an agent passed; true when it chose the axes. +fn apply_joint_params(settings: &mut JointSettings, p: JointParams) -> Result { + let ratio = |value: Option, what: &str| -> Result, Fail> { + value + .map(|v| { + (v.is_finite() && (0.1..=0.9).contains(&v)) + .then_some(v) + .ok_or_else(|| Fail::from(format!("{what} must be between 0.1 and 0.9"))) + }) + .transpose() + }; + if let Some(v) = ratio(p.width_ratio, "width_ratio")? { + settings.width_ratio = v; + } + if let Some(v) = ratio(p.height_ratio, "height_ratio")? { + settings.height_ratio = v; + } + if let Some(v) = p.fingers { + if !(2..=16).contains(&v) { + return Err("fingers must be between 2 and 16".into()); + } + settings.fingers = v; + } + if let Some(v) = p.dovetails { + 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.); + } + if let Some(v) = p.clearance_mm { + settings.clearance = from_mm(finite(v, "clearance_mm")?.clamp(0., 5.)); + } + for (axis, slot) in [(p.depth_axis, &mut settings.depth_axis), (p.width_axis, &mut settings.width_axis)] { + if let Some(axis) = axis { + if axis > 2 { + return Err("axis indexes are 0, 1 or 2".into()); + } + *slot = axis; + } + } + if let Some(trim) = p.trim { + settings.trim = match trim { + TrimArg::None => TrimBoard::None, + TrimArg::A => TrimBoard::First, + TrimArg::B => TrimBoard::Second, + }; + } + if let Some(axis) = p.trim_axis { + if axis > 2 { + return Err("trim_axis is 0, 1 or 2".into()); + } + settings.trim_axis = Some(axis); + } + if let Some(end) = p.keep_end { + settings.keep_end = match end { + KeepEndArg::Lower => KeepEnd::Lower, + KeepEndArg::Upper => KeepEnd::Upper, + }; + } + Ok(p.depth_axis.is_some() || p.width_axis.is_some()) +} +struct JointPair { + a: u64, + b: u64, +} 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.")); @@ -1127,59 +1244,29 @@ fn add_joint(model: &mut Model, a: JointArgs) -> Res { 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 hint = if matches!(model.objects[&id].kind, benchup_core::ObjectKind::Component { .. }) { + "It is a component instance. The board inside it (get_object lists it) can only be joined to boards inside the same instance, \ + not to boards outside it; make the joints before making the component." + } else { + "Join the boards themselves; get_object on a group lists its children." + }; + return Err(fail(format!("{id} is not a single board: {error}"), hint)); } } 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() && (0.1..=0.9).contains(&v)) - .then_some(v) - .ok_or_else(|| Fail::from(format!("{what} must be between 0.1 and 0.9"))) - }) - .transpose() - }; - if let Some(v) = ratio(p.width_ratio, "width_ratio")? { - settings.width_ratio = v; - } - if let Some(v) = ratio(p.height_ratio, "height_ratio")? { - settings.height_ratio = v; - } - if let Some(v) = p.fingers { - if !(2..=16).contains(&v) { - return Err("fingers must be between 2 and 16".into()); - } - settings.fingers = v; - } - if let Some(v) = p.dovetails { - 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.); - } - if let Some(v) = p.clearance_mm { - settings.clearance = from_mm(finite(v, "clearance_mm")?.clamp(0., 5.)); - } - for (axis, slot) in [(p.depth_axis, &mut settings.depth_axis), (p.width_axis, &mut settings.width_axis)] { - if let Some(axis) = axis { - if axis > 2 { - return Err("axis indexes are 0, 1 or 2".into()); - } - *slot = axis; - } - } - chosen_axes = p.depth_axis.is_some() || p.width_axis.is_some(); - } - let members = [a.a, a.b]; + let chosen_axes = match a.params { + Some(p) => apply_joint_params(&mut settings, p)?, + None => false, + }; + let mut done = commit_joint(model, [a.a, a.b], settings, chosen_axes, None)?; + done.modified = set.into_iter().collect(); + Ok(done) +} + +/// Pick the orientation, then add the joint (or replace joint `replace`) and +/// make sure it cuts; the error names the joints it collides with. +fn commit_joint(model: &mut Model, members: [u64; 2], mut settings: JointSettings, chosen_axes: bool, replace: Option) -> Res { + let a = JointPair { a: members[0], b: members[1] }; 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(", ") } @@ -1216,11 +1303,14 @@ fn add_joint(model: &mut Model, a: JointArgs) -> Res { } } } - let joint = model.add_connection(Connection { - members: [a.a, a.b], - entry_face: None, - operation: ConnectionOperation::Joinery(settings.clone()), - })?; + let connection = Connection { members: [a.a, a.b], entry_face: None, operation: ConnectionOperation::Joinery(settings.clone()) }; + let joint = match replace { + Some(id) => { + model.connections.insert(id, connection); + id + } + None => model.add_connection(connection)?, + }; 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)> { @@ -1241,6 +1331,27 @@ fn add_joint(model: &mut Model, a: JointArgs) -> Res { "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() && (error.contains("closed solid") || error.contains("precision")) { + // Nothing touches this joint and the boards are plain boxes: a + // precision limit of the cutter, which small changes get round. + let mut hint = "No other joint touches this one and the boards are plain boxes, so this is a precision limit of BenchUp's \ + joint cutter, not a clash. A small change usually gets round it: height_ratio or width_ratio a little different \ + (0.6 → 0.65), or one board moved by 0.5 to 1 mm. Try it in a dry run." + .to_owned(); + let earlier: Vec = model + .connections + .iter() + .filter(|(id, c)| **id != joint && c.members.contains(&members[0]) && matches!(c.operation, ConnectionOperation::Joinery(_))) + .map(|(id, _)| id.to_string()) + .collect(); + if !earlier.is_empty() { + hint.push_str(&format!( + " Board {} already has joint {} elsewhere; adding this joint first and that one after also gets round it.", + members[0], + earlier.join(", ") + )); + } + hint } else if others.is_empty() { format!("Orientations that fit: {}. Check the overlap and params.", orientations(model, &settings)) } else { @@ -1252,7 +1363,7 @@ fn add_joint(model: &mut Model, a: JointArgs) -> Res { }; return Err(fail(format!("the joint cannot be cut: {error}"), hint)); } - let mut done = modified(&set); + let mut done = modified(&BTreeSet::from(members)); done.joints.push(joint); done.extra.insert("joint_id".into(), json!(joint)); done.extra.insert("a".into(), json!(a.a)); @@ -1303,9 +1414,9 @@ fn find_objects(model: &Model, a: FindArgs) -> Res { fn run(model: &mut Model, env: &mut ToolEnv, name: &str, arguments: &str) -> Res { match name { "get_scene" => { - parse::(arguments)?; + let filter = more::scene_filter(model, parse(arguments)?)?; let shared = if env.hide_selection { BTreeSet::new() } else { env.selection.clone() }; - read(scene::summary(model, &shared, &env.catalog, scene::SCENE_BUDGET)) + read(scene::summary_with(model, &shared, &env.catalog, scene::SCENE_BUDGET, &filter)) } "find_objects" => find_objects(model, parse(arguments)?), "get_object" => { @@ -1333,7 +1444,10 @@ fn run(model: &mut Model, env: &mut ToolEnv, name: &str, arguments: &str) -> Res read(out) } "check_model" => { - parse::(arguments)?; + let a: more::CheckArgs = parse(arguments)?; + if a.only_new.unwrap_or(false) { + return read(crate::review::check_new(model, env.before.as_ref())); + } read(crate::review::check(model)) } "get_cut_list" => { @@ -1367,6 +1481,18 @@ fn run(model: &mut Model, env: &mut ToolEnv, name: &str, arguments: &str) -> Res "flip_joint" => flip_joint(model, env, parse(arguments)?), "add_dimension" => add_dimension(model, parse(arguments)?), "add_text" => add_text(model, parse(arguments)?), + "get_estimate" => more::get_estimate(model, parse(arguments)?), + "measure_space" => more::measure_space(model, parse(arguments)?), + "find_surfaces" => more::find_surfaces(model, parse(arguments)?), + "create_profile_board" => more::create_profile_board(model, env, parse(arguments)?), + "split_face" => more::split_face(model, parse(arguments)?), + "merge_faces" => more::merge_faces(model, parse(arguments)?), + "change_thickness" => more::change_thickness(model, env, parse(arguments)?), + "distribute" => more::distribute(model, parse(arguments)?), + "set_joint" => more::set_joint(model, env, parse(arguments)?), + "add_profile" => more::add_profile(model, parse(arguments)?), + "remove_profile" => more::remove_profile(model, parse(arguments)?), + "set_finish" => more::set_finish(model, parse(arguments)?), "select" => { let a: IdsArg = parse(arguments)?; let chosen = entities(model, &a.ids, false)?; @@ -1422,11 +1548,12 @@ 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"]; +const CREATING: &[&str] = &["create_board", "create_copy", "create_profile_board", "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", + "group", "ungroup", "tag", "paint", "erase", "add_joint", "create_copy", "change_thickness", "distribute", + "add_profile", "remove_profile", "set_finish", "merge_faces", ]; /// The group to open so that all `ids` are editable, if they share one that is @@ -1678,6 +1805,13 @@ pub fn execute(model: &mut Model, call: &Call, env: &mut ToolEnv) -> ToolOutcome } } +#[path = "ops_more.rs"] +mod more; + #[cfg(test)] #[path = "ops_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "ops_more_tests.rs"] +mod more_tests; diff --git a/crates/benchup-tools/src/ops_more.rs b/crates/benchup-tools/src/ops_more.rs new file mode 100644 index 0000000..554af94 --- /dev/null +++ b/crates/benchup-tools/src/ops_more.rs @@ -0,0 +1,1701 @@ +//! Tools from the second round of agent testing (BenchUp3D/benchup#42): +//! router profiles, joint settings and trimming, finishes and estimates, +//! boards from an outline with arcs, face operations, layout helpers and +//! queries that spare agents probing the model one entity at a time. +use super::*; +use benchup_core::{ + connections::ConnectionOperation as Operation, + faces, + router::{Profile, Settings as RouteSettings}, + surface_treatment::{self, Sheen, Step}, +}; +use std::f64::consts::FRAC_PI_2; + +const MAX_ARC_SEGMENTS: u32 = 96; +const MAX_LISTED: usize = 100; +const AXES: [&str; 3] = ["x", "y", "z"]; + +// ---------------------------------------------------------------- schemas + +fn face_ref() -> Value { + json!({"oneOf": [{"type": "integer"}, {"type": "string", "enum": ["+x", "-x", "+y", "-y", "+z", "-z", "*"]}]}) +} +fn step_schema() -> Value { + json!({"type": "object", "properties": { + "enabled": {"type": "boolean"}, + "coats": {"type": "integer", "minimum": 1, "maximum": 100}, + "minutes_per_m2": {"type": "number", "minimum": 0}, + "drying_hours": {"type": "number", "minimum": 0}, + "final_drying_hours": {"type": "number", "minimum": 0}, + "tint_rgb": {"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": 255}, "minItems": 3, "maxItems": 3}, + "strength": {"type": "integer", "minimum": 0, "maximum": 100}, + "sheen": {"type": "string", "enum": ["matte", "satin", "gloss"]}}, + "additionalProperties": false}) +} + +pub(super) fn definitions() -> Vec { + let outline_point = json!({"oneOf": [ + {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + {"type": "object", "properties": { + "at": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "bulge": {"type": "number"}, "sagitta_mm": {"type": "number"}, + "segments": {"type": "integer", "minimum": 2, "maximum": MAX_ARC_SEGMENTS}}, + "required": ["at"], "additionalProperties": false}]}); + let joint_params = json!({"type": "object", "properties": { + "width_ratio": number(), "height_ratio": number(), + "fingers": {"type": "integer"}, "dovetails": {"type": "integer"}, + "taper_degrees": number(), "clearance_mm": number(), + "depth_axis": {"type": "integer", "minimum": 0, "maximum": 2}, + "width_axis": {"type": "integer", "minimum": 0, "maximum": 2}, + "trim": {"type": "string", "enum": ["none", "a", "b"]}, + "trim_axis": {"type": "integer", "minimum": 0, "maximum": 2}, + "keep_end": {"type": "string", "enum": ["lower", "upper"]}}, + "additionalProperties": false}); + vec![ + def("create_profile_board", "Create a shaped board in one call: a closed 2D outline (straight segments and arcs) on a plane, extruded to thickness_mm, grouped, named and optionally given a grain, material and tag. plane xy draws (u, v) = (x, y) and extrudes along +z; xz draws (x, z) and extrudes along +y (into the depth, from the front); yz draws (y, z) and extrudes along +x. origin_mm is where (0, 0) of the outline is; a negative thickness extrudes the other way. Points are [u, v] in mm, or {\"at\": [u, v], \"bulge\": b} or {\"at\": [u, v], \"sagitta_mm\": s} to make the segment from this point to the next an arc. bulge = tan(angle/4) (1 is a half circle); sagitta is how far the arc's middle is from the chord. Positive values bulge to the right of the direction of travel, which is outward on a counter-clockwise outline. Arcs get 12 segments per quarter turn (24 for a half circle) unless segments says otherwise. Returns the board in created[0].", + json!({"name": text(), "plane": {"type": "string", "enum": ["xy", "xz", "yz"]}, "origin_mm": vec3(), + "points": {"type": "array", "items": outline_point, "minItems": 3}, + "thickness_mm": number(), "grain_axis": axis_enum(), + "material_id": {"type": "string", "format": "uuid"}, "tag": tag_name(false)}), + &["name", "plane", "origin_mm", "points", "thickness_mm"]), + def("split_face", "Split a face of a part in two along a line or a plane, without enter_object. line_mm is two points on the face; plane cuts the face where the plane crosses it (point_mm on the plane, normal an axis name or a vector). Returns the pieces in faces (id, center_mm, area) so the right one can be pushed or pulled, ordered from the plane's negative side to its positive side (for a line, along normal × line direction): with a plane normal of y, faces[0] is the front piece.", + json!({"face_id": id(), "line_mm": {"type": "array", "items": vec3(), "minItems": 2, "maxItems": 2}, + "plane": {"type": "object", "properties": {"point_mm": vec3(), + "normal": {"oneOf": [axis_enum(), vec3()]}}, "required": ["point_mm", "normal"], "additionalProperties": false}}), + &["face_id"]), + def("merge_faces", "Merge flat faces of a board that lie in one plane and share an edge, removing the seam edges between them (erase would remove the faces too). Faces that surround an opening, such as the mouth of a mortise, become one face with a hole, and edges that meet another face's edge partway (T-junctions) are handled. Pass the board's id to merge every such pair, or ids of faces to merge only those. Not available inside components.", + json!({"id": id(), "ids": ids()}), &[]), + def("change_thickness", "Change the thickness of boards (their thinnest side) to thickness_mm, keeping the piece's outside size: with anchor auto (default), a board on the outside of the given boards keeps its outer face and the others keep their centre. Boards that go into a resized board with a joint (whichever is a or b) are lengthened or shortened so the joint keeps the same share of the new thickness, and boards that only butt against a resized board's face follow that face (joint_adjustments, butt_adjustments). material_id optionally assigns a material. Boards must be square to the axes. anchor applies to every board of the call: call it twice for different anchors.", + json!({"ids": ids(), "thickness_mm": number(), "anchor": {"type": "string", "enum": ["auto", "min", "center", "max"]}, + "material_id": {"type": "string", "format": "uuid"}}), &["ids", "thickness_mm"]), + def("distribute", "Spread objects evenly along an axis between from_mm and to_mm (shelves between a bottom and a top). mode gaps (default) makes the free gaps equal; centers spaces their centres equally. ends gap (default) leaves a gap at each limit too; flush puts the first object at from_mm and the last against to_mm. Objects keep their order along the axis; joints follow them.", + json!({"ids": ids(), "axis": axis_enum(), "from_mm": number(), "to_mm": number(), + "mode": {"type": "string", "enum": ["gaps", "centers"]}, "ends": {"type": "string", "enum": ["gap", "flush"]}}), + &["ids", "axis", "from_mm", "to_mm"]), + def("set_joint", "Change an existing joint: its kind and any params of add_joint, including trimming (trim a or b, keep_end, trim_axis). Unchanged params keep their values; the orientation is picked again when the kind changes and no axes are given. The joint must still cut.", + json!({"id": id(), "kind": {"type": "string", "enum": ["mortise_tenon", "half_lap", "tongue_groove", "rabbet", "dovetail", "finger"]}, + "params": joint_params}), &["id"]), + def("add_profile", "Rout a profile along edges of a board with BenchUp's Router: chamfer, bevel, roundover, cove, rabbet, groove, ogee or bead. Choose edges with between: pairs of faces the edge lies between, each a direction (+x, -y...), a face id from get_object, or * for any face; [[\"+z\", \"-y\"]] is the top front edge and [[\"+z\", \"*\"]] every edge of the top. Or pass edge ids in edges. width_mm is the width (chamfer, bevel, rabbet, groove) or radius (roundover, cove, ogee, bead), default 5; depth_mm (rabbet, groove; default 3); offset_mm (groove setback, bead shoulder; default 2); angle_degrees (bevel, 1-89; default 30). For a rabbet or groove, which of the two faces gets width_mm and which depth_mm depends on the edge: check it (check_model reports a part that should sit in the rabbet as an overlap if it went the wrong way) and pass reverse: true to swap them. Profiles other than chamfer and bevel need square edges. The profile is cut when BenchUp evaluates the model; the call fails if it cannot be cut. get_object lists a board's profiles.", + json!({"id": id(), "between": {"type": "array", "items": {"type": "array", "items": face_ref(), "minItems": 2, "maxItems": 2}}, + "edges": ids(), "profile": {"type": "string", "enum": ["chamfer", "bevel", "roundover", "cove", "rabbet", "groove", "ogee", "bead"]}, + "width_mm": number(), "depth_mm": number(), "offset_mm": number(), "angle_degrees": number(), "reverse": {"type": "boolean"}}), + &["id", "profile"]), + def("remove_profile", "Remove router profiles from a board: the given edges, or all of the board's profiles when edges is omitted.", + json!({"id": id(), "edges": ids()}), &["id"]), + def("set_finish", "Set the sanding and finishing of objects (boards; a group sets every board in it), used by get_estimate and BenchUp's Time & Cost Estimates. sanding_grits lists the grits to sand with (e.g. [120, 180, 240]); pre_stain, stain and varnish switch a stage on with its coats, minutes_per_m2, drying hours and look (tint_rgb, strength 0-100, sheen). Omitted values keep their current or default values; the defaults are sanding 16 min/m² per grit; pre_stain 5 min/m², 1 coat, 0.25 h drying; stain 5 min/m², 1 coat, 8 h; varnish 6 min/m² per coat, 3 coats, 6 h between coats and 24 h after the last (final_drying_hours). The area is every face of each board. clear: true goes back to the project defaults.", + json!({"ids": ids(), "clear": {"type": "boolean"}, + "sanding_grits": {"type": "array", "items": {"type": "integer", "minimum": 24, "maximum": 3000}}, + "pre_stain": step_schema(), "stain": step_schema(), "varnish": step_schema()}), &["ids"]), + def("get_estimate", "Time estimate for building the boards (all, or those in ids): finishing time per stage from each board's surface area and finish (set_finish), joint cutting by kind and router profiles by length, with BenchUp's default allowances; and the wood by material (area, volume, pieces). Rates and prices edited in the app are not stored in the project, so costs are not included.", + json!({"ids": ids()}), &[]), + def("measure_space", "Measure the free space around a point: along each axis, the distance to the nearest face in both directions, what it belongs to, and the free gap between them (e.g. the clear height between two shelves, or the room left to a wall). Walls and floors made of plain faces count too. inside lists boards that contain the point.", + json!({"point_mm": vec3(), "axes": {"type": "array", "items": axis_enum()}}), &["point_mm"]), + def("find_surfaces", "List large flat faces that are not part of a closed board, such as the walls and floor of a room drawn as plain faces inside groups, with their direction, plane position, bounds and owner. Largest first. include_solids also lists faces of closed parts (a solid floor or a table top).", + json!({"min_area_mm2": number(), "include_solids": {"type": "boolean"}, "facing": {"type": "string", "enum": ["+x", "-x", "+y", "-y", "+z", "-z"]}, + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_LISTED}}), &[]), + ] +} + +// ---------------------------------------------------------------- scene filters + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SceneArgs { + ids: Option>, + depth: Option, + fields: Option>, + sections: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct CheckArgs { + pub only_new: Option, +} + +fn object_depth(model: &Model, id: u64) -> usize { + let mut depth = 0; + let mut current = model.parent(id); + while let Some(parent) = current.filter(|p| model.objects.contains_key(p)) { + depth += 1; + if depth > 256 { + break; + } + current = model.parent(parent); + } + depth +} + +pub(super) fn scene_filter(model: &Model, a: SceneArgs) -> Result { + let check = |list: &Option>, allowed: &[&str], what: &str| -> Result>, Fail> { + let Some(list) = list else { return Ok(None) }; + if let Some(bad) = list.iter().find(|f| !allowed.contains(&f.as_str())) { + return Err(fail(format!("unknown {what} {bad}"), format!("Choose from: {}.", allowed.join(", ")))); + } + Ok(Some(list.iter().cloned().collect())) + }; + let fields = check(&a.fields, scene::OBJECT_FIELDS, "field")?; + // The output names the stock section stock_catalog; accept either name. + let sections = a.sections.map(|list| list.into_iter().map(|s| if s == "stock_catalog" { "stock".to_owned() } else { s }).collect()); + let sections = check(§ions, scene::SECTIONS, "section")?; + let objects = match (a.ids, a.depth) { + (None, None) => None, + (None, Some(depth)) => Some(model.objects.keys().copied().filter(|id| object_depth(model, *id) <= depth).collect()), + (Some(ids), depth) => { + if ids.len() > MAX_IDS { + return Err(fail("too many ids", format!("Pass at most {MAX_IDS} ids per call."))); + } + if let Some(id) = ids.iter().find(|id| !model.objects.contains_key(id)) { + return Err(fail(format!("{id} is not a group or component"), "Use object ids from get_scene or find_objects.")); + } + let mut list = Vec::new(); + let mut seen = BTreeSet::new(); + for id in ids { + let base = object_depth(model, id); + let inside = model.expand_entities(&BTreeSet::from([id])); + for object in std::iter::once(id).chain(inside.into_iter().filter(|e| model.objects.contains_key(e) && *e != id)) { + let relative = object_depth(model, object).saturating_sub(base); + if depth.is_none_or(|d| relative <= d) && seen.insert(object) { + list.push(object); + } + } + } + Some(list) + } + }; + Ok(scene::Filter { objects, fields, sections }) +} + +// ---------------------------------------------------------------- shaped boards + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum PlaneArg { + Xy, + Xz, + Yz, +} +#[derive(Deserialize)] +#[serde(untagged)] +enum OutlinePoint { + Plain([f64; 2]), + Arc(ArcPoint), +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ArcPoint { + at: [f64; 2], + bulge: Option, + sagitta_mm: Option, + segments: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ProfileBoardArgs { + name: String, + plane: PlaneArg, + origin_mm: V3, + points: Vec, + thickness_mm: f64, + grain_axis: Option, + material_id: Option, + tag: Option, +} + +/// Points between `p` and `q` (both excluded) on the arc whose middle is +/// `sagitta` to the right of the chord. Millimetres, in the outline's plane. +fn arc_points(p: [f64; 2], q: [f64; 2], sagitta: f64, segments: Option) -> Result, Fail> { + let (dx, dy) = (q[0] - p[0], q[1] - p[1]); + let chord = dx.hypot(dy); + if chord < 1.0e-6 { + return Err(fail("an arc joins two points at the same place", "Give each arc a start and an end that differ.")); + } + let half = chord / 2.; + if sagitta.abs() < 1.0e-9 { + return Ok(Vec::new()); + } + // The right of the travel direction (dx, dy) is (dy, -dx). + let right = [dy / chord, -dx / chord]; + let mid = [(p[0] + q[0]) / 2., (p[1] + q[1]) / 2.]; + let radius = (half * half + sagitta * sagitta) / (2. * sagitta.abs()); + let apex = [mid[0] + right[0] * sagitta, mid[1] + right[1] * sagitta]; + let side = sagitta.signum(); + let center = [apex[0] - right[0] * side * radius, apex[1] - right[1] * side * radius]; + let sweep = 4. * (sagitta / half).atan(); + let count = match segments { + Some(n) => n, + // An even count puts a vertex at the arc's middle, where it reaches furthest. + None => (((sweep.abs() / FRAC_PI_2 * 6.).round() as u32) * 2).max(2), + }; + if !(2..=MAX_ARC_SEGMENTS).contains(&count) { + return Err(fail("too many arc segments", format!("Use between 2 and {MAX_ARC_SEGMENTS} segments per arc."))); + } + let start = (p[1] - center[1]).atan2(p[0] - center[0]); + Ok((1..count) + .map(|k| { + let angle = start + sweep * f64::from(k) / f64::from(count); + [center[0] + radius * angle.cos(), center[1] + radius * angle.sin()] + }) + .collect()) +} + +/// How an outline segment curves: by bulge (relative to the chord) or sagitta (mm). +#[derive(Clone, Copy)] +enum Curve { + Bulge(f64), + Sagitta(f64), +} + +/// The closed outline in the plane, arcs expanded, and how many arcs it has. +fn outline(points: &[OutlinePoint]) -> Result<(Vec<[f64; 2]>, usize), Fail> { + let corners: Vec<([f64; 2], Option, Option)> = points + .iter() + .map(|point| match point { + OutlinePoint::Plain(at) => Ok((*at, None, None)), + OutlinePoint::Arc(arc) => { + let curve = match (arc.bulge, arc.sagitta_mm) { + (Some(_), Some(_)) => return Err(fail("give either bulge or sagitta_mm", "An arc is set by one of them.")), + (Some(bulge), None) => Some(Curve::Bulge(bulge)), + (None, Some(sagitta)) => Some(Curve::Sagitta(sagitta)), + (None, None) => None, + }; + Ok((arc.at, curve, arc.segments)) + } + }) + .collect::>()?; + let mut out = Vec::new(); + let mut arcs = 0; + for (i, (at, curve, segments)) in corners.iter().enumerate() { + if at.iter().any(|v| !v.is_finite() || v.abs() > MAX_COORDINATE_MM) { + return Err(fail("coordinate out of range", "Use finite values within 100000 mm.")); + } + out.push(*at); + let Some(curve) = curve else { continue }; + let next = corners[(i + 1) % corners.len()].0; + let half = (next[0] - at[0]).hypot(next[1] - at[1]) / 2.; + let sagitta = match *curve { + Curve::Bulge(bulge) => { + if !bulge.is_finite() || bulge.abs() > 10. { + return Err(fail("bulge out of range", "Use a bulge between -10 and 10 (1 is a half circle).")); + } + bulge * half + } + Curve::Sagitta(sagitta) => finite(sagitta, "sagitta_mm")?, + }; + let points = arc_points(*at, next, sagitta, *segments)?; + arcs += usize::from(!points.is_empty()); + out.extend(points); + } + Ok((out, arcs)) +} + +pub(super) fn create_profile_board(model: &mut Model, env: &ToolEnv, a: ProfileBoardArgs) -> Res { + let name = checked_name(&a.name)?; + if a.points.len() < 3 { + return Err(fail("points needs at least 3 corners", "Give the outline's corners in order.")); + } + if a.points.len() > MAX_POINTS { + return Err(fail("too many points", format!("Pass at most {MAX_POINTS} points."))); + } + let thickness = finite(a.thickness_mm, "thickness_mm")?; + if !(0.1..=MAX_COORDINATE_MM).contains(&thickness.abs()) { + return Err(fail("thickness_mm out of range", "Use a thickness between 0.1 mm and 100000 mm (negative extrudes the other way).")); + } + if let Some(id) = a.material_id { + find_material(model, env, id)?; + } + let (flat, arcs) = outline(&a.points)?; + if flat.len() > MAX_POINTS * 4 { + return Err(fail("the outline has too many points", "Use fewer arcs or fewer segments per arc.")); + } + // Drop repeated points; reject outlines with no area. + let mut ring: Vec<[f64; 2]> = Vec::with_capacity(flat.len()); + for p in flat { + if ring.last().is_none_or(|q: &[f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]) > 1.0e-4) { + ring.push(p); + } + } + while ring.len() > 1 && (ring[0][0] - ring[ring.len() - 1][0]).hypot(ring[0][1] - ring[ring.len() - 1][1]) <= 1.0e-4 { + ring.pop(); + } + let area: f64 = (0..ring.len()).map(|i| { + let (p, q) = (ring[i], ring[(i + 1) % ring.len()]); + p[0] * q[1] - q[0] * p[1] + }).sum::() / 2.; + if ring.len() < 3 || area.abs() < 1.0 { + return Err(fail("the outline has no area", "Give at least three corners that do not lie on one line.")); + } + let origin = point(a.origin_mm)?; + let (u, v, normal) = match a.plane { + PlaneArg::Xy => (DVec3::X, DVec3::Y, DVec3::Z), + PlaneArg::Xz => (DVec3::X, DVec3::Z, DVec3::Y), + PlaneArg::Yz => (DVec3::Y, DVec3::Z, DVec3::X), + }; + let mut corners: Vec = ring.iter().map(|p| origin + u * from_mm(p[0]) + v * from_mm(p[1])).collect(); + if corners.iter().any(|p| p.abs().max_element() > MAX_RESULT_M) { + return Err(fail("coordinate out of range", "Keep the outline within 100 m of the origin.")); + } + corners.push(corners[0]); + let mut drawing = Model::default(); + drawing.draw_polyline(&corners, false).map_err(|e| fail(format!("the outline could not be drawn: {e}"), "Check that the outline does not cross itself."))?; + let faces: Vec = drawing.faces.keys().copied().collect(); + let [face] = faces[..] else { + return Err(fail("the outline does not make one face", "Check that the outline is closed and does not cross or touch itself.")); + }; + drawing.push_pull_along(face, normal * from_mm(thickness))?; + let roots = model.paste_entities(&drawing, DVec3::ZERO); + let object = model + .make_object(&roots, false, &name) + .ok_or_else(|| fail("the board could not be created", "The current editing scope may be locked; try exit_to_root."))?; + if let Err(reason) = model.solid_volume(object) { + return Err(fail(format!("the outline did not make a closed board: {reason}"), "Check that the outline does not cross itself, and use fewer, longer segments.")); + } + if a.material_id.is_some() { + assign_material(model, env, object, a.material_id)?; + } + if let Some(axis) = a.grain_axis { + model.set_grain_direction(object, Some(axis.axis().vector()))?; + } + if let Some(tag) = a.tag.as_deref().filter(|t| !t.trim().is_empty()) { + let tag = tag_named(model, tag)?; + model.assign_tag(&BTreeSet::from([object]), Some(tag))?; + } + let mut done = Done { created: vec![object], ..Default::default() }; + done.extra.insert("outline_points".into(), json!(ring.len())); + done.extra.insert("arcs".into(), json!(arcs)); + done.extra.insert("area_mm2".into(), json!(area.abs().round())); + Ok(done) +} + +// ---------------------------------------------------------------- faces + +/// Checks that a face may be edited and returns the object that owns it. +fn editable_face(model: &Model, face: u64) -> Result, Fail> { + if !model.faces.contains_key(&face) { + return Err(fail(format!("{face} is not a face"), "Use a face id from get_object.")); + } + if model.is_hidden(face) || model.is_locked(face) { + return Err(fail(format!("face {face} is hidden or locked"), "Pick another face.")); + } + let top = model + .entity_in_context(face) + .ok_or_else(|| fail(format!("face {face} is not editable in the current scope"), unreachable_hint(model, face)))?; + entities(model, &[top], true)?; + let mut ancestor = model.parent(face); + while let Some(owner) = ancestor { + if model.is_hidden(owner) || model.is_locked(owner) { + return Err(fail(format!("face {face} is inside hidden or locked object {owner}"), "Locked entities cannot be changed; tell the designer to unlock it.")); + } + ancestor = model.parent(owner); + } + Ok(model.parent(face).filter(|o| model.objects.contains_key(o))) +} + +/// Run `edit` with the face owner's scope open, as the designer would. +fn in_scope(model: &mut Model, owner: Option, edit: impl FnOnce(&mut Model) -> T) -> T { + let saved = model.edit_context; + if owner.is_some() { + model.edit_context = owner; + } + let result = edit(model); + model.edit_context = saved; + result +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum NormalArg { + Axis(AxisArg), + Vector(V3), +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PlaneCut { + point_mm: V3, + normal: NormalArg, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SplitArgs { + face_id: u64, + line_mm: Option<[V3; 2]>, + plane: Option, +} + +/// Segments where a plane crosses a face, inside it. +fn plane_segments(model: &Model, face: u64, origin: DVec3, normal: DVec3) -> Vec<(DVec3, DVec3)> { + let Some(f) = model.faces.get(&face) else { return Vec::new() }; + let Some(face_plane) = faces::plane(model, &f.vertices) else { return Vec::new() }; + let direction = normal.cross(face_plane.normal); + if direction.length() < 1.0e-9 { + return Vec::new(); + } + let direction = direction.normalize(); + let eps = 1.0e-9; + let mut hits: Vec = Vec::new(); + for ring in std::iter::once(&f.vertices).chain(&f.holes) { + for (i, a) in ring.iter().enumerate() { + let (pa, pb) = (model.vertices[a].position, model.vertices[&ring[(i + 1) % ring.len()]].position); + let (da, db) = (normal.dot(pa - origin), normal.dot(pb - origin)); + if da.abs() <= eps { + hits.push(pa); + } else if (da < -eps && db > eps) || (da > eps && db < -eps) { + hits.push(pa + (pb - pa) * (da / (da - db))); + } + } + } + hits.sort_by(|a, b| direction.dot(*a).total_cmp(&direction.dot(*b))); + hits.dedup_by(|a, b| a.distance(*b) < 1.0e-7); + hits.windows(2) + .filter(|pair| pair[0].distance(pair[1]) > 1.0e-6 && model.face_contains(f, (pair[0] + pair[1]) / 2., false)) + .map(|pair| (pair[0], pair[1])) + .collect() +} + +pub(super) fn split_face(model: &mut Model, a: SplitArgs) -> Res { + let owner = editable_face(model, a.face_id)?; + let face_normal = scene::outward_normal(model, a.face_id).unwrap_or(DVec3::Z); + let order_axis; + let segments = match (a.line_mm, a.plane) { + (Some([p, q]), None) => { + let (p, q) = (point(p)?, point(q)?); + order_axis = face_normal.cross(q - p).normalize_or_zero(); + vec![(p, q)] + } + (None, Some(cut)) => { + let normal = match cut.normal { + NormalArg::Axis(axis) => axis.axis().vector(), + NormalArg::Vector(v) => { + let v = DVec3::from_array(v); + if !v.is_finite() || v.length() < 1.0e-9 { + return Err(fail("normal must be a non-zero vector", "Use an axis name or a direction such as [0, 1, 0].")); + } + v.normalize() + } + }; + order_axis = normal; + let segments = plane_segments(model, a.face_id, point(cut.point_mm)?, normal); + if segments.is_empty() { + return Err(fail("the plane does not cross the face", "Check the plane's position with the face's center_mm and area in get_object.")); + } + segments + } + _ => return Err(fail("give either line_mm or plane", "line_mm is two points on the face; plane is {point_mm, normal}.")), + }; + let before: BTreeSet = model.faces.keys().chain(model.edges.keys()).copied().collect(); + in_scope(model, owner, |model| -> Result<(), Fail> { + for (p, q) in &segments { + model.draw_polyline(&[*p, *q], true)?; + } + Ok(()) + })?; + let created: Vec = model.faces.keys().chain(model.edges.keys()).copied().filter(|id| !before.contains(id)).collect(); + let new_faces: Vec = created.iter().copied().filter(|id| model.faces.contains_key(id)).collect(); + if new_faces.is_empty() { + return Err(fail("the face was not split", "The line must cross the face from edge to edge, lying on it.")); + } + // The original face keeps its id when it survives as one of the pieces. + let mut pieces: Vec = std::iter::once(a.face_id).filter(|f| model.faces.contains_key(f)).chain(new_faces.iter().copied()).collect(); + // From the negative to the positive side of the plane (or of the line, + // turning from the face's normal towards the line's direction). + let centre = |f: &u64| model.faces.get(f).and_then(|face| model.face_center(face)).map_or(0., |c| c.dot(order_axis)); + pieces.sort_by(|x, y| centre(x).total_cmp(¢re(y))); + let mut done = Done { created, ..Default::default() }; + done.modified.extend(owner); + done.extra.insert("face_ids".into(), json!(pieces)); + done.extra.insert("faces".into(), Value::Array(pieces.iter().filter_map(|f| scene::face_entry(model, *f)).collect())); + Ok(done) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct MergeArgs { + id: Option, + ids: Option>, +} + +fn ring_edges(ring: &[u64]) -> impl Iterator + '_ { + (0..ring.len()).map(move |i| (ring[i], ring[(i + 1) % ring.len()])) +} + +/// The boundary of two faces that share edges: the outer loop, the loops +/// left inside it (holes, such as the mouth of a mortise the two faces +/// surround), and the vertex pairs of the seam edges removed. `None` when +/// they share no edge or the boundary branches (they touch at a point). +struct Merged { + outer: Vec, + holes: Vec>, + shared: Vec<(u64, u64)>, +} +fn merged_ring(first: &[u64], second: &[u64], position: &dyn Fn(u64) -> DVec3, normal: DVec3) -> Option { + let mut directed: Vec<(u64, u64)> = ring_edges(first).collect(); + let mut shared = Vec::new(); + for (a, b) in ring_edges(second) { + if let Some(i) = directed.iter().position(|e| *e == (b, a)) { + directed.remove(i); + shared.push((a.min(b), a.max(b))); + } else { + directed.push((a, b)); + } + } + if shared.is_empty() || directed.is_empty() { + return None; + } + let mut next: BTreeMap = BTreeMap::new(); + for (a, b) in &directed { + if next.insert(*a, *b).is_some() { + return None; + } + } + let mut left: BTreeSet = next.keys().copied().collect(); + let mut loops = Vec::new(); + while let Some(start) = left.pop_first() { + let mut ring = vec![start]; + let mut current = *next.get(&start)?; + while current != start { + if !left.remove(¤t) { + return None; + } + ring.push(current); + current = *next.get(¤t)?; + } + loops.push(ring); + } + let area = |ring: &Vec| -> f64 { + (0..ring.len()).map(|i| position(ring[i]).cross(position(ring[(i + 1) % ring.len()])).dot(normal)).sum::().abs() + }; + let outer = (0..loops.len()).max_by(|x, y| area(&loops[*x]).total_cmp(&area(&loops[*y])))?; + let outer = loops.remove(outer); + Some(Merged { outer, holes: loops, shared }) +} + +/// `ring` with the vertices of `other` that lie inside its edges added, so +/// that two faces meeting at a T-junction (one edge against two) share +/// edges vertex for vertex. +fn with_t_vertices(ring: &[u64], other: &[u64], position: &dyn Fn(u64) -> DVec3) -> Vec { + let mut out = Vec::with_capacity(ring.len()); + for (a, b) in ring_edges(ring) { + out.push(a); + let (pa, pb) = (position(a), position(b)); + let delta = pb - pa; + let length = delta.length_squared(); + if length < 1.0e-18 { + continue; + } + let mut inside: Vec<(f64, u64)> = other + .iter() + .copied() + .filter(|v| *v != a && *v != b && !ring.contains(v)) + .filter_map(|v| { + let t = (position(v) - pa).dot(delta) / length; + ((1.0e-9..1. - 1.0e-9).contains(&t) && (pa + delta * t).distance(position(v)) < 1.0e-8).then_some((t, v)) + }) + .collect(); + inside.sort_by(|x, y| x.0.total_cmp(&y.0)); + inside.dedup_by_key(|(_, v)| *v); + out.extend(inside.into_iter().map(|(_, v)| v)); + } + out +} + +pub(super) fn merge_faces(model: &mut Model, a: MergeArgs) -> Res { + // One object in ids means that board, as id would. + let (id, ids) = match (a.id, a.ids) { + (None, Some(ids)) if ids.len() == 1 && model.objects.contains_key(&ids[0]) => (Some(ids[0]), None), + other => other, + }; + let (board, candidates): (u64, BTreeSet) = match (id, ids) { + (Some(id), None) => { + let board = one_object(model, id)?; + let faces = model.objects[&board].entities.iter().copied().filter(|e| model.faces.contains_key(e)).collect(); + (board, faces) + } + (None, Some(ids)) => { + if ids.len() < 2 || ids.len() > MAX_IDS { + return Err(fail("pass at least two face ids", "Or pass the board's id to merge all its flat seams.")); + } + let mut owner = None; + for face in &ids { + let parent = editable_face(model, *face)?; + if owner.is_some() && owner != parent { + return Err(fail("the faces belong to different parts", "Merge faces of one board at a time.")); + } + owner = parent; + } + let board = owner.ok_or_else(|| fail("the faces are loose geometry", "Merge faces of a board (group them first)."))?; + (board, ids.into_iter().collect()) + } + _ => return Err(fail("give either id or ids", "id is a board; ids are faces of one board.")), + }; + let mut path = Some(board); + while let Some(object) = path { + if matches!(model.objects.get(&object).map(|o| &o.kind), Some(benchup_core::ObjectKind::Component { .. })) { + return Err(fail("faces inside components cannot be merged by this tool", "Merge the faces in BenchUp, or explode the component first.")); + } + if model.is_locked(object) { + return Err(fail(format!("object {object} is locked"), "Locked entities cannot be changed; tell the designer to unlock it.")); + } + path = model.parent(object); + } + let plane_of = |model: &Model, face: u64| model.faces.get(&face).and_then(|f| faces::plane(model, &f.vertices)); + let mut faces_left: BTreeSet = candidates; + let mut removed_faces = Vec::new(); + let mut removed_edges = BTreeSet::new(); + let mut merged = Vec::new(); + 'search: loop { + let list: Vec = faces_left.iter().copied().collect(); + for (i, f1) in list.iter().enumerate() { + for f2 in &list[i + 1..] { + let (Some(p1), Some(p2)) = (plane_of(model, *f1), plane_of(model, *f2)) else { continue }; + if p1.normal.dot(p2.normal).abs() < 1. - 1.0e-9 || p1.normal.dot(p2.origin - p1.origin).abs() > 1.0e-7 { + continue; + } + let (first, mut second) = (model.faces[f1].vertices.clone(), model.faces[f2].vertices.clone()); + if p1.normal.dot(p2.normal) < 0. { + second.reverse(); + } + let position = |v: u64| model.vertices.get(&v).map_or(DVec3::ZERO, |v| v.position); + let (first_t, second_t) = (with_t_vertices(&first, &second, &position), with_t_vertices(&second, &first, &position)); + let Some(Merged { outer: ring, holes: inner, mut shared }) = merged_ring(&first_t, &second_t, &position, p1.normal) else { continue }; + // Edges that only the faces' old outlines used (an edge split at a T-junction) go too. + shared.extend(ring_edges(&first).chain(ring_edges(&second)).map(|(a, b)| (a.min(b), a.max(b)))); + shared.sort_unstable(); + shared.dedup(); + let holes: Vec> = model.faces[f1].holes.iter().chain(&model.faces[f2].holes).cloned().chain(inner).collect(); + let color = model.face_colors.get(f1).or_else(|| model.face_colors.get(f2)).copied(); + let target = model.faces.get_mut(f1).ok_or("missing face")?; + target.vertices = ring; + target.holes = holes; + if let Some(color) = color { + model.face_colors.insert(*f1, color); + } + model.faces.remove(f2); + model.face_colors.remove(f2); + model.wood_glue.remove(f2); + model.entity_tags.remove(f2); + for object in model.objects.values_mut() { + object.entities.remove(f2); + } + faces_left.remove(f2); + removed_faces.push(*f2); + merged.push(json!([f1, f2])); + // The seam edges no face uses any more. + for (a, b) in shared { + let still_used = model.faces.values().any(|f| { + std::iter::once(&f.vertices).chain(&f.holes).any(|r| ring_edges(r).any(|(x, y)| (x.min(y), x.max(y)) == (a, b))) + }); + if still_used { + continue; + } + let edge = model.edges.values().find(|e| (e.a.min(e.b), e.a.max(e.b)) == (a, b)).map(|e| e.id); + if let Some(edge) = edge { + model.edges.remove(&edge); + model.routes.remove(&edge); + model.entity_tags.remove(&edge); + for object in model.objects.values_mut() { + object.entities.remove(&edge); + } + removed_edges.insert(edge); + } + } + continue 'search; + } + } + break; + } + if merged.is_empty() { + return Err(fail("no faces to merge", "Only flat faces in one plane that share an edge can be merged.")); + } + // Vertices no edge uses any more, and straight-run vertices left on the merged outline. + let used: BTreeSet = model.edges.values().flat_map(|e| [e.a, e.b]).collect(); + model.vertices.retain(|id, _| used.contains(id)); + let _ = in_scope(model, Some(board), |model| { + let contents = model.objects[&board].entities.clone(); + model.clean_collinear_vertices(&contents) + }); + let mut done = modified(&BTreeSet::from([board])); + done.erased = removed_faces.into_iter().chain(removed_edges).collect(); + done.extra.insert("merged".into(), json!(merged)); + done.extra.insert( + "faces".into(), + Value::Array(faces_left.iter().filter(|f| model.faces.contains_key(f)).take(MAX_LISTED).filter_map(|f| scene::face_entry(model, *f)).collect()), + ); + Ok(done) +} + +// ---------------------------------------------------------------- layout + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum SpacingMode { + Gaps, + Centers, +} +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum EndsArg { + Gap, + Flush, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct DistributeArgs { + ids: Vec, + axis: AxisArg, + from_mm: f64, + to_mm: f64, + mode: Option, + ends: Option, +} + +pub(super) fn distribute(model: &mut Model, a: DistributeArgs) -> Res { + let set = entities(model, &a.ids, true)?; + let axis = a.axis.axis().vector(); + let (from, to) = (from_mm(finite(a.from_mm, "from_mm")?), from_mm(finite(a.to_mm, "to_mm")?)); + if from.abs() > MAX_RESULT_M || to.abs() > MAX_RESULT_M { + return Err(fail("coordinate out of range", "Use limits within 100000 mm.")); + } + let (from, to) = (from.min(to), from.max(to)); + let mut items: Vec<(u64, f64, f64)> = set + .iter() + .map(|id| { + let (lo, hi) = model.selection_bounds(&BTreeSet::from([*id])).ok_or_else(|| Fail::from(format!("{id} has no geometry")))?; + Ok((*id, lo.dot(axis), (hi - lo).dot(axis))) + }) + .collect::>()?; + items.sort_by(|x, y| x.1.total_cmp(&y.1).then(x.0.cmp(&y.0))); + let n = items.len() as f64; + let total: f64 = items.iter().map(|(_, _, size)| size).sum(); + let length = to - from; + let flush = matches!(a.ends, Some(EndsArg::Flush)); + if flush && items.len() < 2 { + return Err(fail("ends flush needs at least two objects", "Use ends gap to centre one object.")); + } + let mut starts = Vec::with_capacity(items.len()); + let mut gap = None; + match a.mode.unwrap_or(SpacingMode::Gaps) { + SpacingMode::Gaps => { + let g = (length - total) / if flush { n - 1. } else { n + 1. }; + if g < -1.0e-9 { + return Err(fail( + format!("the objects do not fit: they take {} mm of the {} mm between the limits", scene::mm(total), scene::mm(length)), + "Widen the limits or use fewer or thinner objects.", + )); + } + let mut cursor = from + if flush { 0. } else { g }; + for (_, _, size) in &items { + starts.push(cursor); + cursor += size + g; + } + gap = Some(g); + } + SpacingMode::Centers => { + let (first, last) = if flush { + (from + items[0].2 / 2., to - items[items.len() - 1].2 / 2.) + } else { + (from + length / (n + 1.), to - length / (n + 1.)) + }; + let step = if items.len() > 1 { (last - first) / (n - 1.) } else { 0. }; + for (k, (_, _, size)) in items.iter().enumerate() { + let center = if items.len() > 1 { first + step * k as f64 } else { (from + to) / 2. }; + starts.push(center - size / 2.); + } + } + } + let mut positions = Vec::new(); + for ((id, lo, size), start) in items.iter().zip(&starts) { + let delta = start - lo; + if delta.abs() > 1.0e-12 { + model.move_selection(&BTreeSet::from([*id]), axis * delta)?; + } + positions.push(json!({"id": id, "min_mm": scene::mm(*start), "max_mm": scene::mm(start + size)})); + } + let mut done = modified(&set); + if let Some(g) = gap { + done.extra.insert("gap_mm".into(), json!(scene::mm(g))); + } + done.extra.insert("positions".into(), json!(positions)); + Ok(done) +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum ThicknessAnchor { + Auto, + Min, + Center, + Max, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ThicknessArgs { + ids: Vec, + thickness_mm: f64, + anchor: Option, + material_id: Option, +} + +fn bounds_of(model: &Model, id: u64) -> Result<(DVec3, DVec3), Fail> { + model.selection_bounds(&BTreeSet::from([id])).ok_or_else(|| Fail::from(format!("{id} has no geometry"))) +} + +pub(super) fn change_thickness(model: &mut Model, env: &ToolEnv, a: ThicknessArgs) -> Res { + let set = objects(model, &a.ids, true)?; + let thickness = finite(a.thickness_mm, "thickness_mm")?; + if !(0.1..=10_000.).contains(&thickness) { + return Err(fail("thickness_mm out of range", "Use a thickness between 0.1 mm and 10000 mm.")); + } + let thickness = from_mm(thickness); + if let Some(id) = a.material_id { + find_material(model, env, id)?; + } + for id in &set { + let aligned = model.joinery_frame(*id).is_ok_and(|f| f.is_project_aligned()); + if !aligned { + return Err(fail(format!("{id} is not a board square to the axes"), "Resize rotated boards with resize_board, or rotate them square first.")); + } + } + let before: BTreeMap = set.iter().map(|id| Ok((*id, bounds_of(model, *id)?))).collect::>()?; + let outer = before.values().fold((DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY)), |acc, b| (acc.0.min(b.0), acc.1.max(b.1))); + // Joints into the resized boards, measured before resizing: the board that + // goes in, the axis, which side it comes from and the overlap. + struct Entry { + joint: u64, + board: u64, + receiver: u64, + axis: usize, + from_high: bool, + share: f64, + } + // Which board goes into which is read from the geometry, not from the + // joint's a and b, so reversed joints are adjusted too. + let thin_axis = |b: &(DVec3, DVec3)| { + let size = b.1 - b.0; + (0..3).min_by(|x, y| size[*x].total_cmp(&size[*y])).unwrap_or(0) + }; + let mut entries = Vec::new(); + for (joint, connection) in &model.connections { + if !matches!(connection.operation, Operation::Joinery(_)) { + continue; + } + for (inner, receiver) in [(connection.members[0], connection.members[1]), (connection.members[1], connection.members[0])] { + let (Some(rb), Ok(ib)) = (before.get(&receiver), bounds_of(model, inner)) else { continue }; + let axis = thin_axis(rb); + let (lo, hi) = (ib.0[axis].max(rb.0[axis]), ib.1[axis].min(rb.1[axis])); + if hi - lo <= 1.0e-9 { + continue; + } + let from_high = ib.1[axis] > rb.1[axis] + 1.0e-9 && ib.0[axis] > rb.0[axis] + 1.0e-9; + let from_low = ib.0[axis] < rb.0[axis] - 1.0e-9 && ib.1[axis] < rb.1[axis] - 1.0e-9; + if from_high == from_low { + continue; + } + entries.push(Entry { joint: *joint, board: inner, receiver, axis, from_high, share: (hi - lo) / (rb.1[axis] - rb.0[axis]) }); + } + } + // Boards that only butt against a resized board's face, without a joint: + // they follow that face so they neither overlap it nor leave a gap. + struct Butt { + board: u64, + against: u64, + axis: usize, + on_high_side: bool, + } + 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 mut butts = Vec::new(); + for (id, rb) in &before { + let axis = thin_axis(rb); + for other in model.objects.keys().copied().filter(|o| !before.contains_key(o) && !joined.contains(&[(*id).min(*o), (*id).max(*o)])) { + let object = &model.objects[&other]; + if model.is_hidden(other) || object.entities.iter().any(|e| model.objects.contains_key(e)) || model.parent(other) != model.parent(*id) { + continue; + } + let Ok(ob) = bounds_of(model, other) else { continue }; + let across = (0..3).filter(|i| *i != axis).all(|i| ob.1[i].min(rb.1[i]) - ob.0[i].max(rb.0[i]) > 1.0e-6); + if !across { + continue; + } + if (ob.0[axis] - rb.1[axis]).abs() < 1.0e-7 { + butts.push(Butt { board: other, against: *id, axis, on_high_side: true }); + } else if (ob.1[axis] - rb.0[axis]).abs() < 1.0e-7 { + butts.push(Butt { board: other, against: *id, axis, on_high_side: false }); + } + } + } + let mut resized = Vec::new(); + for (id, (lo, hi)) in &before { + let size = *hi - *lo; + let axis = (0..3).min_by(|x, y| size[*x].total_cmp(&size[*y])).unwrap_or(0); + let anchor = match a.anchor.as_ref().unwrap_or(&ThicknessAnchor::Auto) { + ThicknessAnchor::Min => Anchor::Min, + ThicknessAnchor::Center => Anchor::Center, + ThicknessAnchor::Max => Anchor::Max, + ThicknessAnchor::Auto => { + let (at_min, at_max) = (lo[axis] <= outer.0[axis] + 1.0e-7, hi[axis] >= outer.1[axis] - 1.0e-7); + match (at_min, at_max) { + (true, false) => Anchor::Min, + (false, true) => Anchor::Max, + _ => Anchor::Center, + } + } + }; + let mut target = size; + target[axis] = thickness; + let mut anchors = [Anchor::Min; 3]; + anchors[axis] = anchor; + resize_object(model, *id, target, anchors)?; + if a.material_id.is_some() { + assign_material(model, env, *id, a.material_id)?; + } + let name = match anchor { + Anchor::Min => "min", + Anchor::Center => "center", + Anchor::Max => "max", + }; + resized.push(json!({"id": id, "axis": AXES[axis], "anchor": name})); + } + let mut adjusted = Vec::new(); + let mut touched: BTreeSet = set.clone(); + for entry in &entries { + let rb = bounds_of(model, entry.receiver)?; + let ib = bounds_of(model, entry.board)?; + let axis = entry.axis; + let overlap = entry.share * (rb.1[axis] - rb.0[axis]); + // The end inside the receiver moves; the other end stays. + let (new_lo, new_hi) = if entry.from_high { (rb.1[axis] - overlap, ib.1[axis]) } else { (ib.0[axis], rb.0[axis] + overlap) }; + if (new_lo - ib.0[axis]).abs() < 1.0e-9 && (new_hi - ib.1[axis]).abs() < 1.0e-9 { + continue; + } + if new_hi - new_lo < 1.0e-4 { + return Err(fail(format!("board {} would become too short", entry.board), "Check the joints of the resized boards.")); + } + let mut target = ib.1 - ib.0; + target[axis] = new_hi - new_lo; + let mut anchors = [Anchor::Min; 3]; + anchors[axis] = if entry.from_high { Anchor::Max } else { Anchor::Min }; + resize_object(model, entry.board, target, anchors)?; + touched.insert(entry.board); + adjusted.push(json!({"joint": entry.joint, "board": entry.board, "axis": AXES[axis], + "overlap_mm": scene::mm(overlap), "length_mm": scene::mm(new_hi - new_lo)})); + } + let mut butted = Vec::new(); + for butt in &butts { + let rb = bounds_of(model, butt.against)?; + let ob = bounds_of(model, butt.board)?; + let axis = butt.axis; + let (new_lo, new_hi) = if butt.on_high_side { (rb.1[axis], ob.1[axis]) } else { (ob.0[axis], rb.0[axis]) }; + if (new_lo - ob.0[axis]).abs() < 1.0e-9 && (new_hi - ob.1[axis]).abs() < 1.0e-9 { + continue; + } + if new_hi - new_lo < 1.0e-4 { + return Err(fail(format!("board {} would become too short", butt.board), "Move it away from the resized board first.")); + } + let mut target = ob.1 - ob.0; + target[axis] = new_hi - new_lo; + let mut anchors = [Anchor::Min; 3]; + anchors[axis] = if butt.on_high_side { Anchor::Max } else { Anchor::Min }; + resize_object(model, butt.board, target, anchors)?; + touched.insert(butt.board); + butted.push(json!({"board": butt.board, "against": butt.against, "axis": AXES[axis], "length_mm": scene::mm(new_hi - new_lo)})); + } + let mut done = modified(&touched); + done.extra.insert("resized".into(), json!(resized)); + done.extra.insert("joint_adjustments".into(), json!(adjusted)); + if !butted.is_empty() { + done.extra.insert("butt_adjustments".into(), json!(butted)); + } + Ok(done) +} + +// ---------------------------------------------------------------- joints + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SetJointArgs { + id: u64, + kind: Option, + params: Option, +} + +pub(super) fn set_joint(model: &mut Model, env: &ToolEnv, a: SetJointArgs) -> Res { + let Some(connection) = model.connections.get(&a.id).cloned() else { + return Err(fail(format!("{} is not a joint", a.id), "Use ids from the joints list in get_scene.")); + }; + let Operation::Joinery(mut settings) = connection.operation else { + return Err(fail(format!("{} is not a wood joint", a.id), "Only wood joints can be changed.")); + }; + for board in connection.members { + if !model.contains_entity(&board) { + return Err(fail(format!("board {board} of joint {} was erased", a.id), "Remove the joint with remove_joint.")); + } + if model.is_locked(board) { + return Err(fail(format!("board {board} of joint {} is locked", a.id), "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")); + } + } + let changed_kind = a.kind.as_ref().is_some_and(|k| k.kind() != settings.kind); + if let Some(kind) = &a.kind { + settings.kind = kind.kind(); + } + let chosen_axes = match a.params { + Some(p) => apply_joint_params(&mut settings, p)?, + None => false, + }; + // Keep the current orientation unless the kind changed and no axes were given. + commit_joint(model, connection.members, settings, chosen_axes || !changed_kind, Some(a.id)).map_err(|failure| { + if failure.error == "this orientation cannot cut these boards" && !chosen_axes { + fail("the joint's current orientation cannot cut it with these settings", failure.hint.unwrap_or_default()) + } else { + failure + } + }) +} + +// ---------------------------------------------------------------- router + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ProfileArg { + Chamfer, + Bevel, + Roundover, + Cove, + Rabbet, + Groove, + Ogee, + Bead, +} +impl ProfileArg { + fn profile(self) -> Profile { + match self { + Self::Chamfer => Profile::Chamfer, + Self::Bevel => Profile::Bevel, + Self::Roundover => Profile::Roundover, + Self::Cove => Profile::Cove, + Self::Rabbet => Profile::Rabbet, + Self::Groove => Profile::Groove, + Self::Ogee => Profile::Ogee, + Self::Bead => Profile::Bead, + } + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct AddProfileArgs { + id: u64, + #[serde(default)] + between: Vec<[Value; 2]>, + #[serde(default)] + edges: Vec, + profile: ProfileArg, + width_mm: Option, + depth_mm: Option, + offset_mm: Option, + angle_degrees: Option, + reverse: Option, +} + +enum FaceSel { + Id(u64), + Facing(DVec3), + Any, +} +fn face_selector(value: &Value) -> Result { + if let Some(id) = value.as_u64() { + return Ok(FaceSel::Id(id)); + } + let hint = "Name a face by the direction it faces (+x, -x, +y, -y, +z, -z), by its id from get_object, or * for any face."; + let text = value.as_str().ok_or_else(|| fail(format!("{value} is not a face"), hint))?; + if text == "*" { + return Ok(FaceSel::Any); + } + let (sign, axis) = match text.as_bytes() { + [b'+', axis] => (1., *axis), + [b'-', axis] => (-1., *axis), + _ => return Err(fail(format!("{text} is not a face direction"), hint)), + }; + let axis = match axis { + b'x' => DVec3::X, + b'y' => DVec3::Y, + b'z' => DVec3::Z, + _ => return Err(fail(format!("{text} is not a face direction"), hint)), + }; + Ok(FaceSel::Facing(axis * sign)) +} + +fn board_only(model: &Model, id: u64) -> Result { + let board = one_object(model, id)?; + if model.objects[&board].entities.iter().any(|e| model.objects.contains_key(e)) { + return Err(fail(format!("{board} is a group of parts"), "Profile one board: get_object lists the group's children.")); + } + Ok(board) +} + +fn mm_setting(value: Option, default: f64, what: &str) -> Result { + let Some(value) = value else { return Ok(default) }; + let value = from_mm(finite(value, what)?); + if !RouteSettings::SIZE.contains(&value) { + return Err(fail(format!("{what} out of range"), "Use a size between 0.1 mm and 100000 mm.")); + } + Ok(value) +} + +pub(super) fn add_profile(model: &mut Model, a: AddProfileArgs) -> Res { + let board = board_only(model, a.id)?; + let adjacency = scene::edge_faces(model, board); + let mut chosen: BTreeSet = BTreeSet::new(); + for edge in &a.edges { + if !adjacency.contains_key(edge) { + return Err(fail(format!("{edge} is not an edge of board {board}"), "Choose edges with between, e.g. [[\"+z\", \"-y\"]] for the top front edge.")); + } + chosen.insert(*edge); + } + let normals: BTreeMap> = adjacency.values().flatten().map(|f| (*f, scene::outward_normal(model, *f))).collect(); + let matches = |face: u64, selector: &FaceSel| match selector { + FaceSel::Any => true, + FaceSel::Id(id) => face == *id, + FaceSel::Facing(direction) => normals.get(&face).copied().flatten().is_some_and(|n| n.dot(*direction) > 0.999), + }; + for [x, y] in &a.between { + let (sx, sy) = (face_selector(x)?, face_selector(y)?); + let found: Vec = adjacency + .iter() + .filter(|(_, faces)| { + let [f, g] = faces[..] else { return false }; + (matches(f, &sx) && matches(g, &sy)) || (matches(f, &sy) && matches(g, &sx)) + }) + .map(|(edge, _)| *edge) + .collect(); + if found.is_empty() { + return Err(fail( + format!("no edge of board {board} lies between {x} and {y}"), + "Faces are named by the direction they face (+x, -y...), by id from get_object, or * for any face. get_object lists the board's faces.", + )); + } + chosen.extend(found); + } + if chosen.is_empty() { + return Err(fail("no edges chosen", "Pass between, e.g. [[\"+z\", \"-y\"]] for the top front edge, or edge ids in edges.")); + } + if chosen.len() > MAX_IDS { + return Err(fail("too many edges", format!("Profile at most {MAX_IDS} edges per call."))); + } + let defaults = RouteSettings::default(); + let angle = finite(a.angle_degrees.unwrap_or(defaults.angle_degrees), "angle_degrees")?; + if !RouteSettings::ANGLE.contains(&angle) { + return Err(fail("angle_degrees out of range", "Use an angle between 1 and 89 degrees.")); + } + let settings = RouteSettings { + profile: a.profile.profile(), + width: mm_setting(a.width_mm, defaults.width, "width_mm")?, + depth: mm_setting(a.depth_mm, defaults.depth, "depth_mm")?, + offset: mm_setting(a.offset_mm, defaults.offset, "offset_mm")?, + angle_degrees: angle, + reverse: a.reverse.unwrap_or(false), + }; + for edge in &chosen { + model.routes.insert(*edge, settings); + } + let errors = scene::joint_errors(model); + if let Some((edge, error)) = chosen.iter().find_map(|e| errors.get(e).map(|error| (*e, error))) { + return Err(fail( + format!("the profile cannot be cut on edge {edge}: {error}"), + "Use a smaller width or depth, a chamfer or bevel on angled edges, or other edges. Profiles must not overlap joints or each other.", + )); + } + let length: f64 = chosen.iter().filter_map(|e| model.edges.get(e)).map(|e| model.edge_length(e)).sum(); + let mut done = modified(&BTreeSet::from([board])); + done.extra.insert("profile".into(), json!(scene::profile_name(settings.profile))); + done.extra.insert("edges".into(), json!(chosen)); + done.extra.insert("length_mm".into(), json!(scene::mm(length))); + Ok(done) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RemoveProfileArgs { + id: u64, + edges: Option>, +} + +pub(super) fn remove_profile(model: &mut Model, a: RemoveProfileArgs) -> Res { + let board = board_only(model, a.id)?; + let mine: BTreeSet = model.objects[&board].entities.iter().copied().filter(|e| model.routes.contains_key(e)).collect(); + let targets: Vec = match a.edges { + None => mine.iter().copied().collect(), + Some(edges) => { + if let Some(edge) = edges.iter().find(|e| !mine.contains(e)) { + return Err(fail(format!("edge {edge} of board {board} has no profile"), "get_object on the board lists its profiles.")); + } + edges + } + }; + if targets.is_empty() { + return Err(fail(format!("board {board} has no profiles"), "get_object on the board lists its profiles.")); + } + for edge in &targets { + model.routes.remove(edge); + } + let mut done = modified(&BTreeSet::from([board])); + done.extra.insert("removed_profiles".into(), json!(targets)); + Ok(done) +} + +// ---------------------------------------------------------------- finishes + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum SheenArg { + Matte, + Satin, + Gloss, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StepArgs { + enabled: Option, + coats: Option, + minutes_per_m2: Option, + drying_hours: Option, + final_drying_hours: Option, + tint_rgb: Option<[u8; 3]>, + strength: Option, + sheen: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct FinishArgs { + ids: Vec, + #[serde(default)] + clear: bool, + sanding_grits: Option>, + pre_stain: Option, + stain: Option, + varnish: Option, +} + +fn apply_step(step: &mut Step, a: &StepArgs, what: &str) -> Result<(), Fail> { + step.enabled = a.enabled.unwrap_or(true); + if let Some(coats) = a.coats { + if !(1..=100).contains(&coats) { + return Err(fail(format!("{what}.coats out of range"), "Use 1 to 100 coats.")); + } + step.coats = coats; + } + for (value, slot, name) in [ + (a.minutes_per_m2, &mut step.minutes_per_m2, "minutes_per_m2"), + (a.drying_hours, &mut step.drying_hours, "drying_hours"), + ] { + if let Some(value) = value { + if !value.is_finite() || !(0. ..=10_000.).contains(&value) { + return Err(fail(format!("{what}.{name} out of range"), "Use a value between 0 and 10000.")); + } + *slot = value; + } + } + if let Some(hours) = a.final_drying_hours { + if !hours.is_finite() || !(0. ..=10_000.).contains(&hours) { + return Err(fail(format!("{what}.final_drying_hours out of range"), "Use a value between 0 and 10000.")); + } + step.final_drying_hours = Some(hours); + } + if let Some(tint) = a.tint_rgb { + step.appearance.tint = Some(tint); + } + if let Some(strength) = a.strength { + step.appearance.strength = Some(strength.min(100)); + } + if let Some(sheen) = &a.sheen { + step.appearance.sheen = Some(match sheen { + SheenArg::Matte => Sheen::Matte, + SheenArg::Satin => Sheen::Satin, + SheenArg::Gloss => Sheen::Gloss, + }); + } + Ok(()) +} + +pub(super) fn set_finish(model: &mut Model, a: FinishArgs) -> Res { + let set = objects(model, &a.ids, true)?; + if !a.clear && a.sanding_grits.is_none() && a.pre_stain.is_none() && a.stain.is_none() && a.varnish.is_none() { + return Err(fail("nothing to set", "Pass sanding_grits, pre_stain, stain or varnish, or clear: true.")); + } + if let Some(grits) = &a.sanding_grits + && (grits.len() > 20 || grits.iter().any(|g| !(24..=3000).contains(g))) + { + return Err(fail("sanding_grits out of range", "List up to 20 grits between 24 and 3000.")); + } + // A group sets every object inside it; finishing is counted per board. + let mut targets: BTreeSet = model.expand_entities(&set).into_iter().filter(|e| model.objects.contains_key(e)).collect(); + targets.extend(&set); + if let Some(id) = targets.iter().find(|id| model.is_locked(**id)) { + return Err(fail(format!("entity {id} is locked"), "Locked entities cannot be changed; tell the designer to unlock it.")); + } + for id in &targets { + let object = model.objects.get_mut(id).ok_or("missing object")?; + if a.clear { + object.surface_treatment = surface_treatment::Settings::default(); + object.surface_treatment_customized = false; + continue; + } + let mut settings = object.surface_treatment.clone(); + if let Some(grits) = &a.sanding_grits { + let mut all: BTreeSet = settings.sanding_grits.iter().copied().collect(); + all.extend(grits); + let old: BTreeMap = settings.sanding_grits.iter().copied().zip(settings.sanding.iter().cloned()).collect(); + let after: BTreeMap = settings.sanding_grits.iter().copied().zip(settings.sanding_after_varnish.iter().copied()).collect(); + settings.sanding_grits = all.iter().copied().collect(); + settings.sanding = all + .iter() + .map(|grit| Step { enabled: grits.contains(grit), ..old.get(grit).cloned().unwrap_or_default() }) + .collect(); + settings.sanding_after_varnish = all.iter().map(|grit| after.get(grit).copied().unwrap_or(false)).collect(); + } + for (args, step, what) in [(&a.pre_stain, &mut settings.pre_stain, "pre_stain"), (&a.stain, &mut settings.stain, "stain"), (&a.varnish, &mut settings.varnish, "varnish")] { + if let Some(args) = args { + apply_step(step, args, what)?; + } + } + settings.sanitize(); + object.surface_treatment = settings; + object.surface_treatment_customized = true; + } + let mut done = modified(&targets); + if let Some(first) = set.first().and_then(|id| scene::finish_json(model, *id)) { + done.extra.insert("finish".into(), first); + } + done.extra.insert("objects_set".into(), json!(targets.len())); + Ok(done) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct EstimateArgs { + ids: Option>, +} + +/// BenchUp's default planning allowance per joint, in minutes. +fn joint_minutes(kind: JointKind) -> f64 { + match kind { + JointKind::MortiseTenon => 15., + JointKind::HalfLap => 8., + JointKind::TongueGroove => 8., + JointKind::Rabbet => 5., + JointKind::Dovetail => 25., + JointKind::Finger => 15., + } +} +const ROUTER_SETUP_MINUTES: f64 = 5.; +const ROUTER_MINUTES_PER_METRE: f64 = 1.; + +fn round1(value: f64) -> f64 { + (value * 10.).round() / 10. +} + +pub(super) fn get_estimate(model: &Model, a: EstimateArgs) -> Res { + let scope: Option> = match &a.ids { + Some(ids) => Some(model.expand_entities(&objects(model, ids, false)?)), + None => None, + }; + let in_scope = |id: u64| scope.as_ref().is_none_or(|s| s.contains(&id)); + // Each face counts once, for the object that holds it. + let mut areas: BTreeMap = BTreeMap::new(); + for object in model.objects.values() { + if model.is_hidden(object.id) { + continue; + } + for face in object.entities.iter().filter(|e| in_scope(**e)).filter_map(|e| model.faces.get(e)) { + let area = model.face_area(face); + if area.is_finite() && area > 0. && !model.is_hidden(face.id) { + *areas.entry(object.id).or_default() += area; + } + } + } + let defaults = surface_treatment::Settings::default(); + let mut tasks: BTreeMap = BTreeMap::new(); + let mut finished = 0; + for (id, area) in &areas { + let mut settings = model.objects[id].surface_treatment_override().unwrap_or(&defaults).clone(); + settings.sanitize(); + if settings.any_enabled() { + finished += 1; + } + for (task, duration) in settings.tasks(*area) { + let entry = tasks.entry(task.label()).or_default(); + entry.0 += duration.work; + // Boards dry together after each coat; wait for the slowest one. + entry.1 = entry.1.max(duration.wait); + entry.2 += 1; + } + } + let finishing: Vec = tasks + .iter() + .map(|(task, (work, wait, count))| json!({"task": task, "work_minutes": round1(*work), "wait_minutes": round1(*wait), "boards": count})) + .collect(); + let mut joints: BTreeMap<&'static str, (usize, f64)> = BTreeMap::new(); + for connection in model.connections.values() { + let Operation::Joinery(settings) = &connection.operation else { continue }; + if !connection.members.iter().all(|m| in_scope(*m)) { + continue; + } + let entry = joints.entry(scene::joint_kind(connection)).or_default(); + entry.0 += 1; + entry.1 += joint_minutes(settings.kind); + } + let mut router: BTreeMap<&'static str, (usize, f64)> = BTreeMap::new(); + for (edge, settings) in &model.routes { + let Some(e) = model.edges.get(edge) else { continue }; + if settings.profile == Profile::None || !in_scope(*edge) { + continue; + } + let entry = router.entry(scene::profile_name(settings.profile)).or_default(); + entry.0 += 1; + entry.1 += model.edge_length(e); + } + let finishing_work: f64 = tasks.values().map(|t| t.0).sum(); + let finishing_wait: f64 = tasks.values().map(|t| t.1).sum(); + let joint_work: f64 = joints.values().map(|j| j.1).sum(); + let router_rows: Vec = router + .iter() + .map(|(profile, (count, metres))| { + json!({"profile": profile, "edges": count, "length_mm": scene::mm(*metres), + "work_minutes": round1(ROUTER_SETUP_MINUTES + metres * ROUTER_MINUTES_PER_METRE)}) + }) + .collect(); + let router_work: f64 = router.values().map(|(_, metres)| ROUTER_SETUP_MINUTES + metres * ROUTER_MINUTES_PER_METRE).sum(); + let work = finishing_work + joint_work + router_work; + let total_area: f64 = areas.values().sum(); + let mut out = json!({ + "boards": areas.len(), + "finished_boards": finished, + "surface_area_m2": (total_area * 1000.).round() / 1000., + "finishing": finishing, + "joinery": joints.iter().map(|(kind, (count, minutes))| json!({"kind": kind, "count": count, "work_minutes": round1(*minutes)})).collect::>(), + "router": router_rows, + "total": {"work_minutes": round1(work), "wait_minutes": round1(finishing_wait), "work_hours": round1(work / 60.), + "elapsed_hours": round1((work + finishing_wait) / 60.), + "by_category": {"finishing_work_minutes": round1(finishing_work), "finishing_wait_minutes": round1(finishing_wait), + "joinery_work_minutes": round1(joint_work), "router_work_minutes": round1(router_work)}}, + "wood": crate::review::cut_list(model)["materials"].clone(), + "note": "BenchUp's default allowances: joints by kind, router 5 min setup per profile plus 1 min per metre, finishing per m² and coat. \ + The last coat of a stage waits its final drying time. elapsed_hours adds work and drying, as if done one after the other. \ + Boards without set_finish use the defaults, which sand and finish nothing. Rates and prices edited in the app are not stored in the project.", + }); + if finished == 0 { + out["hint"] = json!("No board has a finish yet: set one with set_finish to estimate finishing time."); + } + read(out) +} + +// ---------------------------------------------------------------- queries + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SpaceArgs { + point_mm: V3, + axes: Option>, +} + +/// Distance along the ray to the triangle, when it is ahead. +fn ray_triangle(origin: DVec3, direction: DVec3, [a, b, c]: &[DVec3; 3]) -> Option { + let (e1, e2) = (*b - *a, *c - *a); + let p = direction.cross(e2); + let det = e1.dot(p); + if det.abs() < 1e-14 { + return None; + } + let t0 = origin - *a; + let u = t0.dot(p) / det; + if !(-1e-9..=1. + 1e-9).contains(&u) { + return None; + } + let q = t0.cross(e1); + let v = direction.dot(q) / det; + if v < -1e-9 || u + v > 1. + 1e-9 { + return None; + } + let t = e2.dot(q) / det; + (t > 1e-9).then_some(t) +} + +fn owner_info(model: &Model, entity: u64) -> Value { + let Some(owner) = model.parent(entity).filter(|o| model.objects.contains_key(o)) else { + return json!({"object": null}); + }; + let mut path = Vec::new(); + let mut current = Some(owner); + while let Some(object) = current.and_then(|c| model.objects.get(&c)) { + path.push(object.name.clone()); + current = model.parent(object.id); + if path.len() > 64 { + break; + } + } + path.reverse(); + json!({"object": owner, "name": model.objects[&owner].name, "path": path}) +} + +pub(super) fn measure_space(model: &Model, a: SpaceArgs) -> Res { + let origin = point(a.point_mm)?; + let axes: Vec = a.axes.unwrap_or_else(|| vec![AxisArg::X, AxisArg::Y, AxisArg::Z]); + let triangles: Vec<(u64, Vec<[DVec3; 3]>)> = model + .faces + .values() + .filter(|f| !model.is_hidden(f.id) && model.parent(f.id).is_none_or(|p| !model.is_hidden(p))) + .map(|f| (f.id, model.face_triangles(f))) + .collect(); + let cast = |direction: DVec3| -> Option<(u64, f64)> { + triangles + .iter() + .filter_map(|(id, tris)| tris.iter().filter_map(|t| ray_triangle(origin, direction, t)).min_by(f64::total_cmp).map(|t| (*id, t))) + .min_by(|x, y| x.1.total_cmp(&y.1)) + }; + let mut out = serde_json::Map::new(); + for axis in axes { + let vector = axis.axis().vector(); + let name = ["x", "y", "z"][match axis { + AxisArg::X => 0, + AxisArg::Y => 1, + AxisArg::Z => 2, + }]; + let mut entry = json!({}); + let mut gap = Some(0.); + for (sign, key) in [(-1., "minus"), (1., "plus")] { + match cast(vector * sign) { + Some((face, t)) => { + let mut hit = owner_info(model, face); + hit["face"] = json!(face); + hit["distance_mm"] = json!(scene::mm(t)); + hit["at_mm"] = scene::point_mm(origin + vector * sign * t); + entry[key] = hit; + gap = gap.map(|g| g + t); + } + None => { + entry[key] = Value::Null; + gap = None; + } + } + } + entry["free_gap_mm"] = gap.map_or(Value::Null, |g| json!(scene::mm(g))); + out.insert(name.into(), entry); + } + let inside: Vec = model + .objects + .keys() + .filter(|id| !model.is_hidden(**id) && model.objects[*id].entities.iter().all(|e| !model.objects.contains_key(e))) + .filter(|id| model.selection_bounds(&BTreeSet::from([**id])).is_some_and(|(lo, hi)| (origin - lo).min_element() > 1e-9 && (hi - origin).min_element() > 1e-9)) + .take(20) + .map(|id| json!({"id": id, "name": model.objects[id].name})) + .collect(); + let mut result = json!({"point_mm": scene::point_mm(origin), "axes": Value::Object(out)}); + if result["axes"].as_object().is_some_and(|axes| axes.values().any(|a| a["minus"].is_null() || a["plus"].is_null())) { + result["null_means"] = json!("nothing in that direction, so the gap along that axis is open (free_gap_mm null)"); + } + if !inside.is_empty() { + result["inside"] = json!(inside); + result["note"] = json!("The point is inside the bounding box of these boards; distances are measured from inside them."); + } + read(result) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SurfacesArgs { + min_area_mm2: Option, + facing: Option, + limit: Option, + include_solids: Option, +} + +pub(super) fn find_surfaces(model: &Model, a: SurfacesArgs) -> Res { + let min_area = finite(a.min_area_mm2.unwrap_or(100_000.), "min_area_mm2")?.max(0.) / 1.0e6; + let limit = a.limit.unwrap_or(40).clamp(1, MAX_LISTED); + let wanted = match &a.facing { + Some(text) => match face_selector(&json!(text))? { + FaceSel::Facing(direction) => Some(direction), + _ => return Err(fail("facing must be a direction", "Use +x, -x, +y, -y, +z or -z.")), + }, + None => None, + }; + let mut solid: BTreeMap = BTreeMap::new(); + let mut found: Vec<(f64, Value)> = Vec::new(); + for face in model.faces.values() { + if model.is_hidden(face.id) { + continue; + } + let area = model.face_area(face); + if area.is_nan() || area < min_area { + continue; + } + let owner = model.parent(face.id).filter(|o| model.objects.contains_key(o)); + if owner.is_some_and(|o| model.is_hidden(o)) { + continue; + } + let closed = owner.is_some_and(|o| *solid.entry(o).or_insert_with(|| model.solid_volume(o).is_ok())); + if closed && !a.include_solids.unwrap_or(false) { + continue; + } + let Some(plane) = faces::plane(model, &face.vertices) else { continue }; + // Faces of closed parts point outward; a loose face's normal is only + // its drawing order, so either side matches a direction. + let normal = if closed { plane.normal * scene::outward_sign(model, face.id) } else { plane.normal }; + if let Some(direction) = wanted { + let along = normal.dot(direction); + if !(along > 0.999 || (!closed && along < -0.999)) { + continue; + } + } + let mut entry = owner_info(model, face.id); + entry["id"] = json!(face.id); + entry["area_mm2"] = json!((area * 1.0e6).round()); + if let Some(facing) = scene::facing_name(normal) { + entry["facing"] = json!(facing); + } + entry["normal"] = json!([round1(normal.x * 100.) / 100., round1(normal.y * 100.) / 100., round1(normal.z * 100.) / 100.]); + if let Some(axis) = (0..3).find(|i| normal[*i].abs() > 0.999) { + entry["plane"] = json!(format!("{} = {}", ["x", "y", "z"][axis], scene::mm(plane.origin[axis]))); + } + if let Some(bounds) = scene::bounds_json(model, &BTreeSet::from([face.id])) { + entry["bounds_mm"] = json!({"min": bounds["min"], "max": bounds["max"]}); + } + if let Some(center) = model.face_center(face) { + entry["center_mm"] = scene::point_mm(center); + } + found.push((area, entry)); + } + found.sort_by(|x, y| y.0.total_cmp(&x.0)); + let total = found.len(); + let mut out = json!({"total": total, "surfaces": found.into_iter().take(limit).map(|(_, e)| e).collect::>()}); + if total == 0 { + out["note"] = json!("No open surfaces of that size: everything is a closed board. Lower min_area_mm2 to see smaller faces."); + } + read(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn faces_meeting_at_a_t_junction_merge() { + // Two unit squares side by side; the left one's shared edge is whole, + // the right one's is split at its middle (vertex 7). + let points: BTreeMap = [ + (1, [0., 0.]), (2, [1., 0.]), (3, [1., 1.]), (4, [0., 1.]), + (5, [2., 0.]), (6, [2., 1.]), (7, [1., 0.5]), + ] + .into_iter() + .map(|(id, [x, y])| (id, DVec3::new(x, y, 0.))) + .collect(); + let position = |v: u64| points[&v]; + let (left, right) = (vec![1, 2, 3, 4], vec![2, 5, 6, 3, 7]); + assert!(merged_ring(&left, &right, &position, DVec3::Z).is_none(), "without the T-junction vertex they share no edge"); + let (l, r) = (with_t_vertices(&left, &right, &position), with_t_vertices(&right, &left, &position)); + assert_eq!(l, vec![1, 2, 7, 3, 4]); + let merged = merged_ring(&l, &r, &position, DVec3::Z).unwrap(); + assert_eq!(merged.outer.len(), 6, "{:?}", merged.outer); + assert!(merged.holes.is_empty()); + } +} diff --git a/crates/benchup-tools/src/ops_more_tests.rs b/crates/benchup-tools/src/ops_more_tests.rs new file mode 100644 index 0000000..aae6fcd --- /dev/null +++ b/crates/benchup-tools/src/ops_more_tests.rs @@ -0,0 +1,447 @@ +//! Tests for the tools in `ops_more.rs` (BenchUp3D/benchup#42). +use super::*; +use benchup_core::router::Profile; +use serde_json::{Value, json}; + +fn run(model: &mut Model, env: &mut ToolEnv, name: &str, args: Value) -> ToolOutcome { + execute(model, &Call::new("t", name, args), env) +} +fn ok(model: &mut Model, env: &mut ToolEnv, name: &str, args: Value) -> Value { + let outcome = run(model, env, name, args); + assert!(outcome.ok, "{name} failed: {}", outcome.result); + outcome.result +} +fn err(model: &mut Model, env: &mut ToolEnv, name: &str, args: Value) -> Value { + let before = model.clone(); + let outcome = run(model, env, name, args); + assert!(!outcome.ok, "{name} unexpectedly succeeded: {}", outcome.result); + assert_eq!(*model, before, "{name} must leave the model untouched on error"); + outcome.result +} +fn board(model: &mut Model, env: &mut ToolEnv, name: &str, origin: [f64; 3], size: [f64; 3]) -> u64 { + ok(model, env, "create_board", json!({"name": name, "origin_mm": origin, "size_mm": size}))["created"][0].as_u64().unwrap() +} +fn bounds_mm(model: &Model, id: u64) -> ([f64; 3], [f64; 3]) { + let (lo, hi) = model.selection_bounds(&BTreeSet::from([id])).unwrap(); + let r = |v: DVec3| [scene::mm(v.x), scene::mm(v.y), scene::mm(v.z)]; + (r(lo), r(hi)) +} +/// Two sides 18 mm thick and a shelf housed 9 mm into each, with joints. +fn carcass(model: &mut Model, env: &mut ToolEnv) -> (u64, u64, u64, u64, u64) { + let left = board(model, env, "Left", [0., 0., 0.], [18., 300., 600.]); + let right = board(model, env, "Right", [482., 0., 0.], [18., 300., 600.]); + let shelf = board(model, env, "Shelf", [9., 0., 300.], [482., 290., 18.]); + let j1 = ok(model, env, "add_joint", json!({"a": shelf, "b": left, "kind": "tongue_groove"}))["joint_id"].as_u64().unwrap(); + let j2 = ok(model, env, "add_joint", json!({"a": shelf, "b": right, "kind": "tongue_groove"}))["joint_id"].as_u64().unwrap(); + (left, right, shelf, j1, j2) +} + +#[test] +fn every_new_tool_is_listed_with_a_schema() { + let names: Vec = definitions().into_iter().map(|d| d.name).collect(); + for (name, _) in TOOLS { + assert!(names.iter().any(|n| n == name), "{name} has no definition"); + } + assert_eq!(names.len(), TOOLS.len(), "every definition is a known tool"); +} + +#[test] +fn get_scene_filters_by_ids_depth_fields_and_sections() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let a = board(&mut model, &mut env, "A", [0., 0., 0.], [10., 10., 10.]); + let b = board(&mut model, &mut env, "B", [20., 0., 0.], [10., 10., 10.]); + let group = ok(&mut model, &mut env, "group", json!({"ids": [a, b], "name": "Pair", "component": false}))["created"][0].as_u64().unwrap(); + let c = board(&mut model, &mut env, "C", [40., 0., 0.], [10., 10., 10.]); + + let top = ok(&mut model, &mut env, "get_scene", json!({"depth": 0, "fields": ["name"], "sections": ["objects"]})); + let listed: Vec = top["objects"].as_array().unwrap().iter().map(|o| o["id"].as_u64().unwrap()).collect(); + assert_eq!(listed.iter().copied().collect::>(), BTreeSet::from([group, c])); + assert_eq!(top["objects"][0].as_object().unwrap().len(), 2, "only id and name: {}", top["objects"][0]); + assert!(top.get("joints").is_none() && top.get("dimensions").is_none() && top.get("stock_catalog").is_none()); + + let inside = ok(&mut model, &mut env, "get_scene", json!({"ids": [group], "depth": 1, "sections": ["objects"]})); + assert_eq!(inside["objects"].as_array().unwrap().len(), 3, "the group and its two boards"); + assert_eq!(inside["listed_count"], 3); + let only = ok(&mut model, &mut env, "get_scene", json!({"ids": [group], "depth": 0})); + assert_eq!(only["objects"].as_array().unwrap().len(), 1); + + let bad = err(&mut model, &mut env, "get_scene", json!({"fields": ["colour"]})); + assert!(bad["hint"].as_str().unwrap().contains("size_mm")); + // No filter: as before. + let all = ok(&mut model, &mut env, "get_scene", json!({})); + assert_eq!(all["objects"].as_array().unwrap().len(), 4); + assert!(all.get("joints").is_some()); +} + +#[test] +fn check_model_only_new_reports_problems_added_by_the_batch() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + // An existing problem: two boards overlapping without a joint. + board(&mut model, &mut env, "A", [0., 0., 0.], [100., 100., 18.]); + board(&mut model, &mut env, "B", [50., 0., 0.], [100., 100., 18.]); + env.before = None; // a new batch starts + let none_yet = ok(&mut model, &mut env, "check_model", json!({"only_new": true})); + assert_eq!(none_yet["issues"], json!([])); + assert_eq!(none_yet["passed"], true); + board(&mut model, &mut env, "C", [500., 0., 0.], [100., 100., 18.]); + let clean = ok(&mut model, &mut env, "check_model", json!({"only_new": true})); + let kinds: Vec<&str> = clean["issues"].as_array().unwrap().iter().map(|i| i["kind"].as_str().unwrap()).collect(); + assert!(!kinds.contains(&"overlap_without_joint"), "{clean}"); + assert!(clean["existing_issues"].as_u64().unwrap() >= 1); + assert_eq!(clean["passed"], true); + board(&mut model, &mut env, "D", [550., 0., 0.], [100., 100., 18.]); + let new = ok(&mut model, &mut env, "check_model", json!({"only_new": true})); + assert_eq!(new["passed"], false); + assert_eq!(new["warnings"], 1, "{new}"); + // Without only_new both overlaps are reported. + assert_eq!(ok(&mut model, &mut env, "check_model", json!({}))["warnings"], 2); +} + +#[test] +fn distribute_makes_equal_gaps_or_centres() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let shelves: Vec = (0..3).map(|i| board(&mut model, &mut env, "Shelf", [0., 0., 10. * i as f64], [500., 300., 18.])).collect(); + let result = ok(&mut model, &mut env, "distribute", json!({"ids": shelves, "axis": "z", "from_mm": 18, "to_mm": 882})); + // (864 - 3 * 18) / 4 = 202.5 + assert_eq!(result["gap_mm"], 202.5); + assert_eq!(bounds_mm(&model, shelves[0]).0[2], 220.5); + assert_eq!(bounds_mm(&model, shelves[2]).1[2], 882. - 202.5); + ok(&mut model, &mut env, "distribute", json!({"ids": shelves, "axis": "z", "from_mm": 0, "to_mm": 900, "ends": "flush"})); + assert_eq!(bounds_mm(&model, shelves[0]).0[2], 0.); + assert_eq!(bounds_mm(&model, shelves[2]).1[2], 900.); + ok(&mut model, &mut env, "distribute", json!({"ids": shelves, "axis": "z", "from_mm": 0, "to_mm": 800, "mode": "centers"})); + assert_eq!(bounds_mm(&model, shelves[1]).0[2], 400. - 9.); + let too_tight = err(&mut model, &mut env, "distribute", json!({"ids": shelves, "axis": "z", "from_mm": 0, "to_mm": 40})); + assert!(too_tight["error"].as_str().unwrap().contains("do not fit")); +} + +#[test] +fn add_profile_routes_edges_between_named_faces() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let top = board(&mut model, &mut env, "Top", [0., 0., 0.], [600., 300., 20.]); + let result = ok(&mut model, &mut env, "add_profile", json!({"id": top, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 4})); + assert_eq!(result["edges"].as_array().unwrap().len(), 1); + assert_eq!(result["length_mm"], 600.); + let edge = result["edges"][0].as_u64().unwrap(); + assert_eq!(model.routes[&edge].profile, Profile::Chamfer); + assert!((model.routes[&edge].width - 0.004).abs() < 1e-12); + let detail = ok(&mut model, &mut env, "get_object", json!({"id": top})); + assert_eq!(detail["profiles"][0]["profile"], "chamfer"); + let between: BTreeSet = detail["profiles"][0]["between"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_owned()).collect(); + assert_eq!(between, BTreeSet::from(["+z".to_owned(), "-y".to_owned()])); + assert_eq!(ok(&mut model, &mut env, "get_scene", json!({}))["profile_count"], 1); + // The whole top perimeter, rounded over. + let all = ok(&mut model, &mut env, "add_profile", json!({"id": top, "between": [["+z", "*"]], "profile": "roundover", "width_mm": 3})); + assert_eq!(all["edges"].as_array().unwrap().len(), 4); + let evaluated = model.evaluate_joinery(); + assert!(evaluated.errors.is_empty(), "{:?}", evaluated.errors); + assert!(evaluated.model.solid_volume(top).unwrap() < model.solid_volume(top).unwrap()); + let check = ok(&mut model, &mut env, "check_model", json!({})); + assert_eq!(check["profiles"]["total"], 4); + // Too big to cut: refused, model unchanged. + let refused = err(&mut model, &mut env, "add_profile", json!({"id": top, "between": [["-z", "+y"]], "profile": "roundover", "width_mm": 50})); + assert!(refused["error"].as_str().unwrap().contains("cannot be cut"), "{refused}"); + let missing = err(&mut model, &mut env, "add_profile", json!({"id": top, "between": [["+z", "-z"]], "profile": "chamfer"})); + assert!(missing["error"].as_str().unwrap().contains("no edge")); + // Remove them all. + let removed = ok(&mut model, &mut env, "remove_profile", json!({"id": top})); + assert_eq!(removed["removed_profiles"].as_array().unwrap().len(), 4); + assert!(model.routes.is_empty()); + err(&mut model, &mut env, "remove_profile", json!({"id": top})); +} + +#[test] +fn joints_can_be_trimmed_and_changed_and_report_their_geometry() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let (left, _right, shelf, j1, _j2) = carcass(&mut model, &mut env); + let detail = ok(&mut model, &mut env, "get_object", json!({"id": j1})); + let geometry = &detail["geometry"]; + assert_eq!(geometry["depth_mm"], 9.); + assert_eq!(geometry["depth_direction"], "-x", "the shelf goes into the left side towards -x: {geometry}"); + assert_eq!(geometry["region_mm"]["min"], json!([9.0, 0.0, 300.0])); + assert_eq!(geometry["tongue_thickness_mm"], 9.); + // The shelf is 290 deep on a 300 deep side: the groove is open at the front only. + assert_eq!(geometry["cut_in_b"]["y"], "stopped", "{geometry}"); + assert_eq!(geometry["cut_in_b"]["z"], "closed"); + assert_eq!(geometry["through_b"], false); + + let changed = ok(&mut model, &mut env, "set_joint", json!({"id": j1, "params": {"width_ratio": 0.33}})); + assert_eq!(changed["joint_id"], j1); + let ConnectionOperation::Joinery(settings) = &model.connections[&j1].operation else { panic!() }; + assert!((settings.width_ratio - 0.33).abs() < 1e-12); + assert_eq!(settings.kind, JointKind::TongueGroove); + ok(&mut model, &mut env, "set_joint", json!({"id": j1, "kind": "mortise_tenon"})); + let ConnectionOperation::Joinery(settings) = &model.connections[&j1].operation else { panic!() }; + assert_eq!(settings.kind, JointKind::MortiseTenon); + assert_eq!(model.connections[&j1].members, [shelf, left]); + err(&mut model, &mut env, "set_joint", json!({"id": j1, "params": {"width_ratio": 2}})); + err(&mut model, &mut env, "set_joint", json!({"id": 999_999})); + + // A rail crossing a leg, trimmed flush with the leg's outer face. + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let leg = board(&mut model, &mut env, "Leg", [0., 0., 0.], [50., 50., 700.]); + let rail = board(&mut model, &mut env, "Rail", [-30., 10., 600.], [400., 22., 80.]); + let joint = ok(&mut model, &mut env, "add_joint", json!({"a": rail, "b": leg, "kind": "mortise_tenon", + "params": {"trim": "a", "keep_end": "upper"}}))["joint_id"].as_u64().unwrap(); + let detail = ok(&mut model, &mut env, "get_object", json!({"id": joint})); + assert_eq!(detail["trim"]["board"], "a"); + assert_eq!(detail["trim"]["keep_end"], "upper"); + let evaluated = model.evaluate_joinery(); + assert!(evaluated.errors.is_empty(), "{:?}", evaluated.errors); + let (lo, _) = evaluated.model.selection_bounds(&BTreeSet::from([rail])).unwrap(); + assert!(lo.x > -1e-6, "the rail's excess past the leg is trimmed: {lo:?}"); +} + +#[test] +fn create_profile_board_extrudes_an_outline_with_arcs() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + // A 400 x 300 top with a half-round front edge... drawn counter-clockwise + // from +z, the front (-y) arc bulging outward. + let result = ok(&mut model, &mut env, "create_profile_board", json!({ + "name": "Top", "plane": "xy", "origin_mm": [0, 0, 700], "thickness_mm": 20, "grain_axis": "x", + "points": [{"at": [0, 0], "sagitta_mm": 50}, [400, 0], [400, 300], [0, 300]]})); + let top = result["created"][0].as_u64().unwrap(); + assert_eq!(result["arcs"], 1); + let (lo, hi) = bounds_mm(&model, top); + assert_eq!(lo, [0., -50., 700.], "the arc bulges 50 mm to the front"); + assert_eq!(hi, [400., 300., 720.]); + assert!(model.solid_volume(top).is_ok()); + assert_eq!(model.objects[&top].name, "Top"); + // The arc's sagitta from a bulge: a half circle of radius 200 on the back. + let half = ok(&mut model, &mut env, "create_profile_board", json!({ + "name": "Half", "plane": "xy", "origin_mm": [0, 1000, 0], "thickness_mm": -18, + "points": [[0, 0], [400, 0], {"at": [400, 100], "bulge": 1}, [0, 100]]})); + let id = half["created"][0].as_u64().unwrap(); + let (lo, hi) = bounds_mm(&model, id); + assert_eq!(lo[2], -18., "a negative thickness extrudes downward"); + assert_eq!(hi[2], 0.); + assert!((hi[1] - 1300.).abs() < 0.2, "a half circle on the 400 mm chord reaches 200 mm further: {hi:?}"); + // A side panel in the yz plane. + let side = ok(&mut model, &mut env, "create_profile_board", json!({ + "name": "Side", "plane": "yz", "origin_mm": [500, 0, 0], "thickness_mm": 18, + "points": [[0, 0], [300, 0], [300, 800], [0, 800]]}))["created"][0].as_u64().unwrap(); + assert_eq!(bounds_mm(&model, side), ([500., 0., 0.], [518., 300., 800.])); + // Joints work with the shaped board. + let check = ok(&mut model, &mut env, "check_model", json!({})); + assert!(check["issues"].as_array().unwrap().iter().all(|i| i["kind"] != "open_solid"), "{check}"); + err(&mut model, &mut env, "create_profile_board", json!({ + "name": "Flat", "plane": "xy", "origin_mm": [0, 0, 0], "thickness_mm": 18, "points": [[0, 0], [100, 0], [200, 0]]})); +} + +#[test] +fn split_face_by_plane_then_push_one_piece_and_merge_back() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let top = board(&mut model, &mut env, "Top", [0., 0., 0.], [600., 300., 20.]); + let faces = ok(&mut model, &mut env, "get_object", json!({"id": top}))["faces"].clone(); + let front = faces.as_array().unwrap().iter().find(|f| f["facing"] == "-y").unwrap()["id"].as_u64().unwrap(); + let split = ok(&mut model, &mut env, "split_face", json!({"face_id": front, "plane": {"point_mm": [200, 0, 0], "normal": "x"}})); + let pieces = split["faces"].as_array().unwrap(); + assert_eq!(pieces.len(), 2, "{split}"); + let areas: BTreeSet = pieces.iter().map(|f| f["area_mm2"].as_f64().unwrap() as u64).collect(); + assert_eq!(areas, BTreeSet::from([200 * 20, 400 * 20])); + let small = pieces.iter().find(|f| f["area_mm2"] == 4000.0).unwrap()["id"].as_u64().unwrap(); + ok(&mut model, &mut env, "push_pull", json!({"face_id": small, "distance_mm": 30})); + assert_eq!(bounds_mm(&model, top).0[1], -30.); + assert!(model.solid_volume(top).is_ok()); + + // Another board: split its top, then merge the seam away. + let panel = board(&mut model, &mut env, "Panel", [1000., 0., 0.], [400., 200., 18.]); + let faces = ok(&mut model, &mut env, "get_object", json!({"id": panel}))["faces"].clone(); + let up = faces.as_array().unwrap().iter().find(|f| f["facing"] == "+z").unwrap()["id"].as_u64().unwrap(); + let count = |model: &Model| model.expand_entities(&BTreeSet::from([panel])).iter().filter(|e| model.faces.contains_key(e)).count(); + ok(&mut model, &mut env, "split_face", json!({"face_id": up, "line_mm": [[1100, 0, 18], [1100, 200, 18]]})); + assert_eq!(count(&model), 7); + let merged = ok(&mut model, &mut env, "merge_faces", json!({"id": panel})); + assert!(!merged["merged"].as_array().unwrap().is_empty()); + assert_eq!(count(&model), 6, "the seam is gone and the faces are kept"); + assert!(model.solid_volume(panel).is_ok()); + let volume = model.solid_volume(panel).unwrap(); + assert!((volume - 0.4 * 0.2 * 0.018).abs() < 1e-9); + model.validate().unwrap(); + err(&mut model, &mut env, "merge_faces", json!({"id": panel})); + err(&mut model, &mut env, "split_face", json!({"face_id": up, "plane": {"point_mm": [5000, 0, 0], "normal": "x"}})); +} + +#[test] +fn change_thickness_keeps_outer_faces_and_joint_shares() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let (left, right, shelf, _j1, _j2) = carcass(&mut model, &mut env); + let result = ok(&mut model, &mut env, "change_thickness", json!({"ids": [left, right], "thickness_mm": 16})); + assert_eq!(bounds_mm(&model, left), ([0., 0., 0.], [16., 300., 600.]), "the left side keeps its outer face"); + assert_eq!(bounds_mm(&model, right), ([484., 0., 0.], [500., 300., 600.]), "the right side keeps its outer face"); + // The shelf now enters each side by half of 16 mm. + assert_eq!(bounds_mm(&model, shelf).0[0], 8., "{result}"); + assert_eq!(bounds_mm(&model, shelf).1[0], 492.); + assert_eq!(result["joint_adjustments"].as_array().unwrap().len(), 2); + let check = ok(&mut model, &mut env, "check_model", json!({})); + assert_eq!(check["joints"]["failing"], json!([]), "{check}"); + // A shelf on its own keeps its centre. + ok(&mut model, &mut env, "change_thickness", json!({"ids": [shelf], "thickness_mm": 20, "anchor": "center"})); + assert_eq!(bounds_mm(&model, shelf).0[2], 299.); + err(&mut model, &mut env, "change_thickness", json!({"ids": [shelf], "thickness_mm": 0})); +} + +#[test] +fn set_finish_and_get_estimate() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let (left, right, shelf, _, _) = carcass(&mut model, &mut env); + let group = ok(&mut model, &mut env, "group", json!({"ids": [left, right, shelf], "name": "Case", "component": false}))["created"][0].as_u64().unwrap(); + let shelf = model.objects.values().find(|o| o.name == "Shelf").unwrap().id; + let empty = ok(&mut model, &mut env, "get_estimate", json!({})); + assert_eq!(empty["finished_boards"], 0); + assert!(empty["hint"].is_string()); + assert_eq!(empty["joinery"][0]["kind"], "tongue_groove"); + assert_eq!(empty["joinery"][0]["count"], 2); + assert_eq!(empty["joinery"][0]["work_minutes"], 16.); + + let set = ok(&mut model, &mut env, "set_finish", json!({"ids": [group], "sanding_grits": [120, 180], + "varnish": {"coats": 2, "sheen": "satin"}})); + assert_eq!(set["objects_set"], 4, "the group and its three boards"); + let detail = ok(&mut model, &mut env, "get_object", json!({"id": shelf})); + assert_eq!(detail["finish"]["sanding_grits"], json!([120, 180])); + assert_eq!(detail["finish"]["varnish"]["coats"], 2); + assert_eq!(detail["finish"]["varnish"]["sheen"], "satin"); + assert_eq!(detail["finish"]["stain"], false); + let estimate = ok(&mut model, &mut env, "get_estimate", json!({})); + assert_eq!(estimate["finished_boards"], 3); + assert!(estimate["total"]["work_minutes"].as_f64().unwrap() > 16.); + assert!(estimate["finishing"].as_array().unwrap().len() >= 3, "{estimate}"); + let one = ok(&mut model, &mut env, "get_estimate", json!({"ids": [shelf]})); + assert_eq!(one["boards"], 1); + ok(&mut model, &mut env, "set_finish", json!({"ids": [group], "clear": true})); + assert!(model.objects[&shelf].surface_treatment_override().is_none()); + err(&mut model, &mut env, "set_finish", json!({"ids": [shelf]})); +} + +#[test] +fn measure_space_and_find_surfaces_see_walls_made_of_faces() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let (_, _, shelf, _, _) = carcass(&mut model, &mut env); + let bottom = board(&mut model, &mut env, "Bottom", [18., 0., 0.], [464., 290., 18.]); + // A wall behind the piece at y = 400, a plain face inside a group. + let wall = ok(&mut model, &mut env, "draw_polyline", json!({"points_mm": [[-500, 400, 0], [1000, 400, 0], [1000, 400, 2400], [-500, 400, 2400]], "close": true})); + let room = ok(&mut model, &mut env, "group", json!({"ids": wall["created"].clone(), "name": "Room", "component": false}))["created"][0].as_u64().unwrap(); + let space = ok(&mut model, &mut env, "measure_space", json!({"point_mm": [250, 150, 100]})); + let z = &space["axes"]["z"]; + assert_eq!(z["minus"]["object"], bottom); + assert_eq!(z["plus"]["object"], shelf); + assert_eq!(z["free_gap_mm"], 300. - 18., "{z}"); + assert_eq!(space["axes"]["y"]["plus"]["name"], "Room"); + assert_eq!(space["axes"]["y"]["plus"]["distance_mm"], 250.); + assert_eq!(space["axes"]["x"]["free_gap_mm"], 464.); + let surfaces = ok(&mut model, &mut env, "find_surfaces", json!({})); + assert_eq!(surfaces["total"], 1, "boards are closed solids and are not listed: {surfaces}"); + assert_eq!(surfaces["surfaces"][0]["object"], room); + assert_eq!(surfaces["surfaces"][0]["plane"], "y = 400"); + let up = ok(&mut model, &mut env, "find_surfaces", json!({"facing": "+z"})); + assert_eq!(up["total"], 0); +} + +#[test] +fn new_mutating_tools_reach_boards_inside_groups_and_respect_leases() { + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let a = board(&mut model, &mut env, "A", [0., 0., 0.], [500., 300., 18.]); + let b = board(&mut model, &mut env, "B", [0., 0., 100.], [500., 300., 18.]); + ok(&mut model, &mut env, "group", json!({"ids": [a, b], "name": "Pair", "component": false})); + let ids: Vec = model.objects.values().filter(|o| o.name != "Pair").map(|o| o.id).collect(); + ok(&mut model, &mut env, "add_profile", json!({"id": ids[0], "between": [["+z", "-y"]], "profile": "chamfer"})); + ok(&mut model, &mut env, "distribute", json!({"ids": ids, "axis": "z", "from_mm": 0, "to_mm": 300})); + assert_eq!(model.edit_context, None, "the group opened for the call is closed again"); + // Shared project: boards outside the lease are refused. + env.lease = Some(BTreeSet::new()); + env.created.clear(); + let refused = err(&mut model, &mut env, "add_profile", json!({"id": ids[1], "between": [["+z", "-y"]], "profile": "chamfer"})); + assert_eq!(refused["error"], "not-leased"); +} + +#[test] +fn round_five_fixes() { + // merge_faces reaches a board inside a group by itself. + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let a = board(&mut model, &mut env, "A", [0., 0., 0.], [400., 200., 18.]); + let b = board(&mut model, &mut env, "B", [0., 0., 100.], [400., 200., 18.]); + ok(&mut model, &mut env, "group", json!({"ids": [a, b], "name": "Pair", "component": false})); + let inner = model.objects.values().find(|o| o.name == "A").unwrap().id; + let up = ok(&mut model, &mut env, "get_object", json!({"id": inner}))["faces"].as_array().unwrap().iter().find(|f| f["facing"] == "+z").unwrap()["id"].as_u64().unwrap(); + let split = ok(&mut model, &mut env, "split_face", json!({"face_id": up, "plane": {"point_mm": [100, 0, 0], "normal": "x"}})); + let centres: Vec = split["faces"].as_array().unwrap().iter().map(|f| f["center_mm"][0].as_f64().unwrap()).collect(); + assert!(centres[0] < centres[1], "pieces go from the plane's negative side to its positive side: {centres:?}"); + ok(&mut model, &mut env, "merge_faces", json!({"id": inner})); + assert_eq!(model.edit_context, None); + + // set_grain takes several ids, or one id. + let ids: Vec = model.objects.values().filter(|o| o.name != "Pair").map(|o| o.id).collect(); + ok(&mut model, &mut env, "set_grain", json!({"ids": ids, "axis": "x"})); + ok(&mut model, &mut env, "set_grain", json!({"id": ids[0], "axis": "y"})); + + // change_thickness follows the geometry when joints are reversed, and + // boards that only butt against a thickened board follow its face. + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let left = board(&mut model, &mut env, "Left", [0., 0., 0.], [18., 300., 600.]); + let divider = board(&mut model, &mut env, "Divider", [491., 0., 0.], [18., 300., 600.]); + let shelf = board(&mut model, &mut env, "Shelf", [9., 0., 300.], [482., 300., 18.]); + // Reversed on purpose: the side is a, the shelf b. + model.add_connection(benchup_core::connections::Connection { + members: [left, shelf], + entry_face: None, + operation: ConnectionOperation::Joinery(JointSettings { kind: JointKind::TongueGroove, depth_axis: 0, width_axis: 2, ..Default::default() }), + }).unwrap(); + let right = board(&mut model, &mut env, "Right", [982., 0., 0.], [18., 300., 600.]); + let result = ok(&mut model, &mut env, "change_thickness", json!({"ids": [left, divider, right], "thickness_mm": 19})); + assert_eq!(result["joint_adjustments"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(result["butt_adjustments"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(bounds_mm(&model, shelf).0[0], 9.5, "the shelf enters half of 19 mm"); + assert_eq!(bounds_mm(&model, shelf).1[0], 490.5, "and stops at the thickened divider"); + + // only_new does not report existing issues again after grouping renews ids. + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let a = board(&mut model, &mut env, "A", [0., 0., 0.], [100., 100., 18.]); + let b = board(&mut model, &mut env, "B", [50., 0., 0.], [100., 100., 18.]); + env.before = None; + ok(&mut model, &mut env, "group", json!({"ids": [a, b], "name": "G", "component": false})); + let check = ok(&mut model, &mut env, "check_model", json!({"only_new": true})); + assert_eq!(check["issues"], json!([]), "{check}"); + assert_eq!(check["existing_by_kind"]["overlap_without_joint"], 1); + + // A mortise closed inside the leg reads closed, even with the rail flush with the leg's face. + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let leg = board(&mut model, &mut env, "Leg", [0., 0., 0.], [50., 50., 425.]); + let rail = board(&mut model, &mut env, "Rail", [0., 0., 300.], [600., 22., 70.]); + let joint = ok(&mut model, &mut env, "add_joint", json!({"a": rail, "b": leg, "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}))["joint_id"].as_u64().unwrap(); + let geometry = ok(&mut model, &mut env, "get_object", json!({"id": joint}))["geometry"].clone(); + assert_eq!(geometry["cut_in_b"]["y"], "closed", "{geometry}"); + assert_eq!(geometry["cut_in_b"]["z"], "closed", "{geometry}"); + assert_eq!(geometry["through_b"], true); + + // A back in a rabbet routed into the side is not an overlap. + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let side = board(&mut model, &mut env, "Side", [0., 0., 0.], [18., 300., 900.]); + ok(&mut model, &mut env, "add_profile", json!({"id": side, "between": [["+y", "+x"]], "profile": "rabbet", "width_mm": 6, "depth_mm": 9})); + board(&mut model, &mut env, "Back", [9., 294., 0.], [500., 6., 900.]); + let check = ok(&mut model, &mut env, "check_model", json!({})); + assert!(check["issues"].as_array().unwrap().iter().all(|i| i["kind"] != "overlap_without_joint"), "{check}"); +} + +#[test] +fn merge_faces_leaves_one_face_with_the_mortise_mouth_as_a_hole() { + // A mortise made by splitting a leg's face and pushing the mouth in, as an agent would. + let (mut model, mut env) = (Model::default(), ToolEnv::default()); + let leg = board(&mut model, &mut env, "Leg", [0., 0., 0.], [70., 70., 700.]); + let front = ok(&mut model, &mut env, "get_object", json!({"id": leg}))["faces"].as_array().unwrap().iter() + .find(|f| f["facing"] == "-y").unwrap()["id"].as_u64().unwrap(); + let split = |model: &mut Model, env: &mut ToolEnv, face: u64, at: [f64; 3], normal: &str, piece: usize| -> u64 { + ok(model, env, "split_face", json!({"face_id": face, "plane": {"point_mm": at, "normal": normal}}))["faces"][piece]["id"].as_u64().unwrap() + }; + let upper = split(&mut model, &mut env, front, [0., 0., 550.], "z", 1); + let band = split(&mut model, &mut env, upper, [0., 0., 630.], "z", 0); + let right = split(&mut model, &mut env, band, [24., 0., 0.], "x", 1); + let mouth = split(&mut model, &mut env, right, [46., 0., 0.], "x", 0); + ok(&mut model, &mut env, "push_pull", json!({"face_id": mouth, "distance_mm": -30})); + ok(&mut model, &mut env, "merge_faces", json!({"id": leg})); + let faces = ok(&mut model, &mut env, "get_object", json!({"id": leg}))["faces"].clone(); + let fronts: Vec = faces.as_array().unwrap().iter().filter(|f| f["facing"] == "-y").map(|f| f["area_mm2"].as_f64().unwrap()).collect(); + assert_eq!(faces.as_array().unwrap().len(), 11, "6 faces of the leg and 5 of the mortise: {faces}"); + assert!(fronts.contains(&(70. * 700. - 22. * 80.)), "one front face around the mouth: {fronts:?}"); + assert!(model.solid_volume(leg).is_ok()); + model.validate().unwrap(); +} diff --git a/crates/benchup-tools/src/render.rs b/crates/benchup-tools/src/render.rs new file mode 100644 index 0000000..3f9968d --- /dev/null +++ b/crates/benchup-tools/src/render.rs @@ -0,0 +1,562 @@ +//! A PNG preview of the model from a standard view, so an agent can check a +//! result without opening BenchUp (`benchup render`, MCP `render_view`). +//! +//! This is an offline export for command-line agents, not the interactive +//! viewport (which renders on the GPU): a small software rasterizer with a +//! depth buffer, flat shading, visible edges and dimensions, so it runs +//! anywhere the CLI does, without a display or GPU. Joints and router +//! profiles are shown cut, as BenchUp evaluates them. +use crate::scene; +use benchup_core::{DVec3, Model}; +use flate2::{Compression, write::ZlibEncoder}; +use serde_json::{Value, json}; +use std::collections::BTreeSet; +use std::io::Write; + +pub const VIEWS: &[&str] = &["iso", "front", "back", "left", "right", "top", "bottom"]; +pub const MAX_SIZE: u32 = 2400; +pub const MIN_SIZE: u32 = 64; +/// Supersampling factor per axis, for smooth edges. +const SAMPLES: usize = 2; +const BACKGROUND: [u8; 3] = [246, 246, 244]; +const WOOD: [f64; 3] = [222., 196., 158.]; +const EDGE: [f64; 3] = [40., 40., 40.]; +const DIMENSION: [f64; 3] = [30., 90., 200.]; +const TEXT_MARK: [f64; 3] = [200., 80., 30.]; +/// Faces meeting at less than 20° are drawn without the edge between them. +const SMOOTH_COS: f64 = 0.94; +const GLYPH_W: usize = 5; +const GLYPH_H: usize = 7; + +pub struct Rendered { + pub png: Vec, + /// The view, image size and what the image shows about dimensions. + pub info: Value, +} + +/// Camera axes for a named view: (forward, right, up). +fn view_axes(view: &str) -> Option<(DVec3, DVec3, DVec3)> { + let forward = match view { + "iso" => DVec3::new(1., 1., -1.).normalize(), + "front" => DVec3::Y, + "back" => -DVec3::Y, + "left" => DVec3::X, + "right" => -DVec3::X, + "top" => -DVec3::Z, + "bottom" => DVec3::Z, + _ => return None, + }; + let up_hint = if forward.z.abs() > 0.99 { DVec3::Y * -forward.z.signum() } else { DVec3::Z }; + let right = forward.cross(up_hint).normalize(); + let up = right.cross(forward).normalize(); + Some((forward, right, up)) +} + +struct Canvas { + width: usize, + height: usize, + color: Vec<[f64; 3]>, + depth: Vec, +} +impl Canvas { + fn new(width: usize, height: usize) -> Self { + let background = BACKGROUND.map(f64::from); + Self { width, height, color: vec![background; width * height], depth: vec![f64::INFINITY; width * height] } + } + fn triangle(&mut self, p: [[f64; 3]; 3], color: [f64; 3]) { + let min_x = p.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min).floor().max(0.) as usize; + let max_x = p.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max).ceil().min(self.width as f64 - 1.); + let min_y = p.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min).floor().max(0.) as usize; + let max_y = p.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max).ceil().min(self.height as f64 - 1.); + if max_x < 0. || max_y < 0. { + return; + } + let area = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]); + if area.abs() < 1e-12 { + return; + } + for y in min_y..=max_y as usize { + for x in min_x..=max_x as usize { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let w0 = ((p[1][0] - px) * (p[2][1] - py) - (p[2][0] - px) * (p[1][1] - py)) / area; + let w1 = ((p[2][0] - px) * (p[0][1] - py) - (p[0][0] - px) * (p[2][1] - py)) / area; + let w2 = 1. - w0 - w1; + if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 { + continue; + } + let z = w0 * p[0][2] + w1 * p[1][2] + w2 * p[2][2]; + let i = y * self.width + x; + if z < self.depth[i] { + self.depth[i] = z; + self.color[i] = color; + } + } + } + } + /// A line, hidden where something is in front of it by more than `bias` + /// (`None` draws it on top of everything). + fn line(&mut self, a: [f64; 3], b: [f64; 3], color: [f64; 3], thickness: f64, bias: Option) { + let length = (b[0] - a[0]).hypot(b[1] - a[1]); + let steps = (length * 1.5).ceil().max(1.) as usize; + let radius = (thickness / 2.).max(0.5); + let reach = radius.ceil() as i64; + for k in 0..=steps { + let t = k as f64 / steps as f64; + let (x, y, z) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t); + for dy in -reach..=reach { + for dx in -reach..=reach { + if ((dx * dx + dy * dy) as f64).sqrt() > radius { + continue; + } + let (px, py) = (x.floor() as i64 + dx, y.floor() as i64 + dy); + if px < 0 || py < 0 || px >= self.width as i64 || py >= self.height as i64 { + continue; + } + let i = py as usize * self.width + px as usize; + if bias.is_none_or(|bias| z <= self.depth[i] + bias) { + self.color[i] = color; + } + } + } + } + } + fn text(&mut self, text: &str, center: [f64; 2], scale: f64, color: [f64; 3]) { + let glyphs: Vec<&[u8; GLYPH_H]> = text.chars().filter_map(glyph).collect(); + let advance = (GLYPH_W + 1) as f64 * scale; + let width = advance * glyphs.len() as f64 - scale; + let (left, top) = (center[0] - width / 2., center[1] - GLYPH_H as f64 * scale / 2.); + // A light plate behind the label keeps it readable over the model. + let pad = scale * 1.5; + let plate = BACKGROUND.map(f64::from); + for y in (top - pad).max(0.) as usize..((top + GLYPH_H as f64 * scale + pad).max(0.) as usize).min(self.height) { + for x in (left - pad).max(0.) as usize..((left + width + pad).max(0.) as usize).min(self.width) { + self.color[y * self.width + x] = plate; + } + } + for (n, rows) in glyphs.iter().enumerate() { + for (row, bits) in rows.iter().enumerate() { + for column in 0..GLYPH_W { + if bits & (1 << (GLYPH_W - 1 - column)) == 0 { + continue; + } + let x0 = left + n as f64 * advance + column as f64 * scale; + let y0 = top + row as f64 * scale; + for y in y0.max(0.) as usize..((y0 + scale).max(0.) as usize).min(self.height) { + for x in x0.max(0.) as usize..((x0 + scale).max(0.) as usize).min(self.width) { + self.color[y * self.width + x] = color; + } + } + } + } + } + } + /// Average each SAMPLES x SAMPLES block into one pixel. + fn downsample(&self) -> Vec { + let (w, h) = (self.width / SAMPLES, self.height / SAMPLES); + let mut out = Vec::with_capacity(w * h * 3); + for y in 0..h { + for x in 0..w { + let mut sum = [0.; 3]; + for sy in 0..SAMPLES { + for sx in 0..SAMPLES { + let c = self.color[(y * SAMPLES + sy) * self.width + x * SAMPLES + sx]; + for k in 0..3 { + sum[k] += c[k]; + } + } + } + let n = (SAMPLES * SAMPLES) as f64; + out.extend(sum.map(|v| (v / n).round().clamp(0., 255.) as u8)); + } + } + out + } +} + +fn glyph(c: char) -> Option<&'static [u8; GLYPH_H]> { + const DIGITS: [[u8; GLYPH_H]; 10] = [ + [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], + [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], + [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111], + [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110], + [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], + [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], + [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], + [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], + [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], + [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], + ]; + const LETTERS: [[u8; GLYPH_H]; 26] = [ + [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], + [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110], + [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110], + [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110], + [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111], + [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000], + [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111], + [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], + [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], + [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100], + [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001], + [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111], + [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001], + [0b10001, 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001], + [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], + [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000], + [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101], + [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001], + [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110], + [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100], + [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], + [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100], + [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b10101, 0b01010], + [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001], + [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100], + [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111], + ]; + const DOT: [u8; GLYPH_H] = [0, 0, 0, 0, 0, 0b01100, 0b01100]; + const MINUS: [u8; GLYPH_H] = [0, 0, 0, 0b11111, 0, 0, 0]; + const SPACE: [u8; GLYPH_H] = [0; GLYPH_H]; + const COMMA: [u8; GLYPH_H] = [0, 0, 0, 0, 0b00110, 0b00100, 0b01000]; + const COLON: [u8; GLYPH_H] = [0, 0b01100, 0b01100, 0, 0b01100, 0b01100, 0]; + const SLASH: [u8; GLYPH_H] = [0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000]; + const OPEN: [u8; GLYPH_H] = [0b00010, 0b00100, 0b01000, 0b01000, 0b01000, 0b00100, 0b00010]; + const CLOSE: [u8; GLYPH_H] = [0b01000, 0b00100, 0b00010, 0b00010, 0b00010, 0b00100, 0b01000]; + const PLUS: [u8; GLYPH_H] = [0, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0]; + const EQUALS: [u8; GLYPH_H] = [0, 0, 0b11111, 0, 0b11111, 0, 0]; + const QUESTION: [u8; GLYPH_H] = [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0, 0b00100]; + const BANG: [u8; GLYPH_H] = [0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0, 0b00100]; + const QUOTE: [u8; GLYPH_H] = [0b01010, 0b01010, 0, 0, 0, 0, 0]; + const APOSTROPHE: [u8; GLYPH_H] = [0b00100, 0b00100, 0, 0, 0, 0, 0]; + const DEGREE: [u8; GLYPH_H] = [0b01100, 0b10010, 0b10010, 0b01100, 0, 0, 0]; + const HASH: [u8; GLYPH_H] = [0b01010, 0b01010, 0b11111, 0b01010, 0b11111, 0b01010, 0b01010]; + match c { + '0'..='9' => Some(&DIGITS[c as usize - '0' as usize]), + 'A'..='Z' => Some(&LETTERS[c as usize - 'A' as usize]), + '.' => Some(&DOT), + '-' | '_' => Some(&MINUS), + ' ' => Some(&SPACE), + ',' | ';' => Some(&COMMA), + ':' => Some(&COLON), + '/' => Some(&SLASH), + '(' | '[' => Some(&OPEN), + ')' | ']' => Some(&CLOSE), + '+' => Some(&PLUS), + '=' => Some(&EQUALS), + '!' => Some(&BANG), + '"' => Some("E), + '\'' => Some(&APOSTROPHE), + '°' => Some(&DEGREE), + '#' => Some(&HASH), + _ => Some(&QUESTION), + } +} + +/// Upper case without accents, as the preview font has them: "Mesilla ñ" → "MESILLA N". +fn fold_text(text: &str) -> String { + text.chars() + .flat_map(char::to_uppercase) + .map(|c| match c { + 'Á' | 'À' | 'Â' | 'Ä' | 'Ã' | 'Å' => 'A', + 'É' | 'È' | 'Ê' | 'Ë' => 'E', + 'Í' | 'Ì' | 'Î' | 'Ï' => 'I', + 'Ó' | 'Ò' | 'Ô' | 'Ö' | 'Õ' => 'O', + 'Ú' | 'Ù' | 'Û' | 'Ü' => 'U', + 'Ñ' => 'N', + 'Ç' => 'C', + '×' => 'X', + '«' | '»' | '“' | '”' => '"', + '‘' | '’' => '\'', + '–' | '—' => '-', + other => other, + }) + .take(80) + .collect() +} + +fn png(width: u32, height: u32, rgb: &[u8]) -> Result, String> { + let mut raw = Vec::with_capacity(rgb.len() + height as usize); + for row in rgb.chunks(width as usize * 3) { + raw.push(0); + raw.extend_from_slice(row); + } + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&raw).map_err(|e| e.to_string())?; + let data = encoder.finish().map_err(|e| e.to_string())?; + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut chunk = |kind: &[u8; 4], body: &[u8]| { + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + let mut typed = kind.to_vec(); + typed.extend_from_slice(body); + out.extend_from_slice(&typed); + out.extend_from_slice(&crate::files::png_crc(&typed).to_be_bytes()); + }; + let mut header = Vec::new(); + header.extend_from_slice(&width.to_be_bytes()); + header.extend_from_slice(&height.to_be_bytes()); + header.extend_from_slice(&[8, 2, 0, 0, 0]); + chunk(b"IHDR", &header); + chunk(b"IDAT", &data); + chunk(b"IEND", &[]); + Ok(out) +} + +fn label(length_mm: f64) -> String { + let rounded = (length_mm * 10.).round() / 10.; + if (rounded - rounded.round()).abs() < 1e-9 { format!("{}", rounded.round() as i64) } else { format!("{rounded:.1}") } +} + +/// Render `model` from `view` at `width` x `height` pixels. +pub fn render(model: &Model, view: &str, width: u32, height: u32) -> Result { + let (forward, right, up) = view_axes(view).ok_or_else(|| format!("unknown view {view}; use one of {}", VIEWS.join(", ")))?; + if !(MIN_SIZE..=MAX_SIZE).contains(&width) || !(MIN_SIZE..=MAX_SIZE).contains(&height) { + return Err(format!("the image must be between {MIN_SIZE} and {MAX_SIZE} pixels on each side")); + } + // Show the boards as they will be built: joints and profiles cut. + let evaluated = if model.has_machining() { model.evaluate_joinery().model } else { model.clone() }; + let shown = |m: &Model, id: u64| !m.is_hidden(id); + let faces: Vec<(u64, Vec<[DVec3; 3]>)> = evaluated + .faces + .values() + .filter(|f| shown(&evaluated, f.id)) + .map(|f| (f.id, evaluated.face_triangles(f))) + .collect(); + // An edge between two faces that meet almost flat is a facet of a curve, + // not a corner: leave it out so curves read smooth. + let mut normals: std::collections::BTreeMap<(u64, u64), Vec> = std::collections::BTreeMap::new(); + for face in evaluated.faces.values().filter(|f| shown(&evaluated, f.id)) { + let Some(plane) = benchup_core::faces::plane(&evaluated, &face.vertices) else { continue }; + for ring in std::iter::once(&face.vertices).chain(&face.holes) { + for (i, a) in ring.iter().enumerate() { + let b = ring[(i + 1) % ring.len()]; + normals.entry(((*a).min(b), (*a).max(b))).or_default().push(plane.normal); + } + } + } + let facet = |a: u64, b: u64| matches!(normals.get(&(a.min(b), a.max(b))).map(Vec::as_slice), Some([n, m]) if n.dot(*m).abs() > SMOOTH_COS); + let edges: Vec<(DVec3, DVec3)> = evaluated + .edges + .values() + .filter(|e| shown(&evaluated, e.id) && !facet(e.a, e.b)) + .map(|e| evaluated.points(e)) + .collect(); + // As BenchUp draws them: extension lines and the dimension line (along + // its axis when it has one), with the value at the label position. + struct Shown { + id: u64, + segments: Vec<(DVec3, DVec3)>, + line: (DVec3, DVec3), + label: DVec3, + length: f64, + } + let dimensions: Vec = model + .dimensions + .iter() + .filter(|(id, _)| shown(model, **id)) + .map(|(id, d)| { + let [_, _, c, e] = d.layout(model); + Shown { id: *id, segments: d.segments(model), line: (c, e), label: d.label_position(model), length: d.length(model) } + }) + .collect(); + let texts: Vec<(u64, DVec3, DVec3, String)> = model + .texts + .iter() + .filter(|(id, _)| shown(model, **id)) + .map(|(id, t)| (*id, t.anchor.position(model), t.offset, t.text.clone())) + .collect(); + // Fit everything drawn, with a margin. + let mut points: Vec = faces.iter().flat_map(|(_, t)| t.iter().flatten().copied()).collect(); + points.extend(edges.iter().flat_map(|(a, b)| [*a, *b])); + for d in &dimensions { + points.extend(d.segments.iter().flat_map(|(a, b)| [*a, *b])); + } + for (_, anchor, offset, _) in &texts { + points.extend([*anchor, *anchor + *offset]); + } + let (w, h) = (width as usize * SAMPLES, height as usize * SAMPLES); + let mut canvas = Canvas::new(w, h); + let empty = points.is_empty(); + let project_2d = |p: DVec3| (p.dot(right), p.dot(up)); + let (mut lo, mut hi) = ((f64::INFINITY, f64::INFINITY), (f64::NEG_INFINITY, f64::NEG_INFINITY)); + let mut depth_range = (f64::INFINITY, f64::NEG_INFINITY); + for p in &points { + let (x, y) = project_2d(*p); + lo = (lo.0.min(x), lo.1.min(y)); + hi = (hi.0.max(x), hi.1.max(y)); + let d = p.dot(forward); + depth_range = (depth_range.0.min(d), depth_range.1.max(d)); + } + if empty { + lo = (-0.5, -0.5); + hi = (0.5, 0.5); + depth_range = (0., 1.); + } + let margin = 0.06; + let span = ((hi.0 - lo.0) / (1. - 2. * margin) / w as f64).max((hi.1 - lo.1) / (1. - 2. * margin) / h as f64).max(1e-9); + let center = ((lo.0 + hi.0) / 2., (lo.1 + hi.1) / 2.); + let project = |p: DVec3| -> [f64; 3] { + let (x, y) = project_2d(p); + [w as f64 / 2. + (x - center.0) / span, h as f64 / 2. - (y - center.1) / span, p.dot(forward)] + }; + let light = (-forward + up * 0.6 - right * 0.35).normalize(); + for (id, triangles) in &faces { + let base = evaluated.face_colors.get(id).map_or(WOOD, |c| c.map(f64::from)); + for t in triangles { + let mut normal = (t[1] - t[0]).cross(t[2] - t[0]); + if normal.length() < 1e-18 { + continue; + } + normal = normal.normalize(); + if normal.dot(forward) > 0. { + normal = -normal; + } + let shade = 0.45 + 0.55 * normal.dot(light).max(0.); + canvas.triangle([project(t[0]), project(t[1]), project(t[2])], base.map(|c| c * shade)); + } + } + let bias = (depth_range.1 - depth_range.0).max(1e-3) * 0.004 + span * 2.; + for (a, b) in &edges { + canvas.line(project(*a), project(*b), EDGE, SAMPLES as f64, Some(bias)); + } + // Dimensions on top, with their value; overlapping labels are reported. + let scale = SAMPLES as f64 * 2.; + let mut labels: Vec<(u64, [f64; 4], [f64; 4])> = Vec::new(); + let mut end_on = Vec::new(); + for d in &dimensions { + for (a, b) in &d.segments { + canvas.line(project(*a), project(*b), DIMENSION, 1., None); + } + let (pa, pb) = (project(d.line.0), project(d.line.1)); + canvas.line(pa, pb, DIMENSION, SAMPLES as f64, None); + let id = &d.id; + if (pb[0] - pa[0]).hypot(pb[1] - pa[1]) < 6. * SAMPLES as f64 { + end_on.push(*id); + continue; + } + let text = label(scene::mm(d.length)); + let at = project(d.label); + let mid = [at[0], at[1]]; + canvas.text(&text, mid, scale, DIMENSION); + let half_w = ((GLYPH_W + 1) * text.len()) as f64 * scale / 2. + scale; + let half_h = GLYPH_H as f64 * scale / 2. + scale; + let rect = [mid[0] - half_w, mid[1] - half_h, mid[0] + half_w, mid[1] + half_h]; + labels.push((*id, rect, [pa[0], pa[1], pb[0], pb[1]])); + } + let mut notes = Vec::new(); + for (id, anchor, offset, text) in &texts { + let (p, q) = (project(*anchor), project(*anchor + *offset)); + canvas.line(p, q, TEXT_MARK, 1., None); + // One line per line of the note, centred on the label point. + let lines: Vec = text.lines().map(fold_text).filter(|l| !l.trim().is_empty()).collect(); + let step = (GLYPH_H as f64 + 3.) * scale; + let first = q[1] - step * (lines.len().max(1) - 1) as f64 / 2.; + for (n, line) in lines.iter().enumerate() { + canvas.text(line, [q[0], first + step * n as f64], scale, TEXT_MARK); + } + notes.push(json!({"id": id, "text": text, "at_px": [(q[0] / SAMPLES as f64).round(), (q[1] / SAMPLES as f64).round()]})); + } + let overlaps_rect = |r: &[f64; 4], s: &[f64; 4]| r[0] < s[2] && s[0] < r[2] && r[1] < s[3] && s[1] < r[3]; + let line_hits_rect = |l: &[f64; 4], r: &[f64; 4]| { + (0..=32).any(|k| { + let t = k as f64 / 32.; + let (x, y) = (l[0] + (l[2] - l[0]) * t, l[1] + (l[3] - l[1]) * t); + x > r[0] && x < r[2] && y > r[1] && y < r[3] + }) + }; + let mut overlaps = Vec::new(); + for (i, (id, rect, line)) in labels.iter().enumerate() { + for (other, other_rect, other_line) in &labels[i + 1..] { + if overlaps_rect(rect, other_rect) { + overlaps.push(json!({"dimensions": [id, other], "problem": "labels overlap"})); + } else if line_hits_rect(line, other_rect) || line_hits_rect(other_line, rect) { + overlaps.push(json!({"dimensions": [id, other], "problem": "a dimension line crosses the other's label"})); + } + } + } + let rgb = canvas.downsample(); + let png = png(width, height, &rgb)?; + let mut info = json!({ + "view": view, "width": width, "height": height, + "mm_per_pixel": (span * SAMPLES as f64 * 10_000.).round() / 10., + "dimensions_shown": labels.len(), + "dimension_overlaps": overlaps, + "texts": notes, + }); + if !end_on.is_empty() { + info["dimensions_end_on"] = json!(end_on); + info["note"] = json!("These dimensions point along the view direction and cannot be read in this view."); + } + if empty { + info["note"] = json!("The model is empty."); + } + let hidden: BTreeSet = model.hidden.iter().copied().collect(); + if !hidden.is_empty() { + info["hidden_entities"] = json!(hidden.len()); + } + Ok(Rendered { png, info }) +} + +/// Standard base64, for embedding the image in an MCP result. +pub fn base64(bytes: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let n = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0)); + out.push(TABLE[(n >> 18) as usize & 63] as char); + out.push(TABLE[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { TABLE[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { TABLE[n as usize & 63] as char } else { '=' }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Call, ToolEnv, execute}; + + fn shelf_with_dimensions() -> Model { + let mut model = Model::default(); + let mut env = ToolEnv::default(); + for (name, args) in [ + ("create_board", json!({"name": "Side", "origin_mm": [0, 0, 0], "size_mm": [18, 300, 900]})), + ("create_board", json!({"name": "Shelf", "origin_mm": [9, 0, 400], "size_mm": [500, 290, 18]})), + ("add_dimension", json!({"start_mm": [0, 0, 0], "end_mm": [0, 0, 900], "offset_mm": [-100, 0, 0], "axis": "z"})), + ("add_dimension", json!({"start_mm": [0, 0, 0], "end_mm": [0, 0, 900], "offset_mm": [-110, 0, 0], "axis": "z"})), + ] { + assert!(execute(&mut model, &Call::new("t", name, args), &mut env).ok); + } + model + } + + #[test] + fn renders_a_png_of_the_requested_size_for_every_view() { + let model = shelf_with_dimensions(); + for view in VIEWS { + let rendered = render(&model, view, 200, 150).unwrap(); + assert!(rendered.png.starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(&rendered.png[16..24], &[0, 0, 0, 200, 0, 0, 0, 150]); + assert_eq!(rendered.info["view"], *view); + } + assert!(render(&model, "sideways", 200, 150).is_err()); + assert!(render(&model, "front", 10, 150).is_err()); + } + + #[test] + fn the_model_is_drawn_and_overlapping_dimensions_are_reported() { + let model = shelf_with_dimensions(); + let rendered = render(&model, "front", 400, 400).unwrap(); + let overlaps = rendered.info["dimension_overlaps"].as_array().unwrap(); + assert_eq!(overlaps.len(), 1, "{}", rendered.info); + // Something other than the background was drawn. + let mut decoder = flate2::read::ZlibDecoder::new(&rendered.png[41..rendered.png.len() - 16]); + let mut raw = Vec::new(); + std::io::Read::read_to_end(&mut decoder, &mut raw).unwrap(); + assert!(raw.chunks(3).any(|p| p.len() == 3 && p != BACKGROUND), "the image is blank"); + // Seen from the side, the vertical dimensions read; from the top they are end-on. + let top = render(&model, "top", 200, 200).unwrap(); + assert_eq!(top.info["dimensions_end_on"].as_array().unwrap().len(), 2); + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Ma"), "TWE="); + } +} diff --git a/crates/benchup-tools/src/review.rs b/crates/benchup-tools/src/review.rs index ba669c0..5740804 100644 --- a/crates/benchup-tools/src/review.rs +++ b/crates/benchup-tools/src/review.rs @@ -142,6 +142,86 @@ fn overlap(a: (DVec3, DVec3), b: (DVec3, DVec3)) -> Option { /// 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 { + summarize(model, collect(model), None) +} + +/// Like [`check`], listing only the problems that are not in `baseline` (the +/// model before this batch). An issue is the same when its kind and ids match. +pub fn check_new(model: &Model, baseline: Option<&Model>) -> Value { + let found = collect(model); + let Some(baseline) = baseline else { + let existing = found.issues.len(); + let mut out = summarize(model, Found { issues: Vec::new(), ..found }, Some(existing)); + out["note"] = json!("Nothing has changed yet in this batch, so there are no new problems. Run check_model without only_new to see all."); + return out; + }; + let key = |issue: &Value| -> (String, Vec) { + let mut ids: Vec = issue["ids"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + ids.sort_unstable(); + (issue["kind"].as_str().unwrap_or_default().to_owned(), ids) + }; + // Grouping gives boards new ids, so an issue also counts as existing when + // its message (names, joint ids, sizes) is unchanged. + let message = |issue: &Value| (issue["kind"].as_str().unwrap_or_default().to_owned(), issue["message"].as_str().unwrap_or_default().to_owned()); + let earlier = collect(baseline).issues; + let old: BTreeSet<(String, Vec)> = earlier.iter().map(key).collect(); + let old_messages: BTreeSet<(String, String)> = earlier.iter().map(message).collect(); + let total = found.issues.len(); + let (existing, issues): (Vec, Vec) = + found.issues.into_iter().partition(|issue| old.contains(&key(issue)) || old_messages.contains(&message(issue))); + let mut kinds: BTreeMap = BTreeMap::new(); + for issue in &existing { + *kinds.entry(issue["kind"].as_str().unwrap_or_default().to_owned()).or_default() += 1; + } + debug_assert_eq!(total, issues.len() + existing.len()); + let mut out = summarize(model, Found { issues, ..found }, Some(existing.len())); + out["existing_by_kind"] = json!(kinds); + out +} + +struct Found { + issues: Vec, + joint_errors: BTreeMap, + boards: Vec, +} + +fn summarize(model: &Model, found: Found, existing: Option) -> Value { + let Found { mut issues, joint_errors, boards: all } = found; + let total = issues.len(); + let errors = issues.iter().filter(|i| i["severity"] == "error").count(); + let warnings = issues.iter().filter(|i| i["severity"] == "warning").count(); + issues.truncate(MAX_LISTED_ISSUES); + let wood_joints = model.connections.values().filter(|c| matches!(c.operation, ConnectionOperation::Joinery(_))).count(); + let failing: Vec = joint_errors.keys().copied().filter(|id| model.connections.contains_key(id)).collect(); + let mut out = json!({ + "passed": errors == 0 && warnings == 0, + "boards": all.len(), "errors": errors, "warnings": warnings, + "joints": {"total": wood_joints, "ok": wood_joints - failing.len().min(wood_joints), "failing": failing}, + "issues": issues, + }); + if !model.routes.is_empty() { + let failing: Vec = joint_errors.keys().copied().filter(|id| model.routes.contains_key(id)).collect(); + out["profiles"] = json!({"total": model.routes.len(), "failing": failing}); + } + if let Some(existing) = existing { + out["only_new"] = json!(true); + out["existing_issues"] = json!(existing); + } + if total > MAX_LISTED_ISSUES { + out["issues_omitted"] = json!(total - MAX_LISTED_ISSUES); + } + if all.len() > MAX_CHECKED_BOARDS { + out["note"] = json!(format!("Only the first {MAX_CHECKED_BOARDS} boards were compared for overlaps.")); + } + // The furniture's size: boards only, not dimensions or texts drawn around them. + let extents = all.iter().filter_map(|id| board_bounds(model, *id)).reduce(|x, y| (x.0.min(y.0), x.1.max(y.1))); + if let Some((lo, hi)) = extents { + out["model_bounds_mm"] = json!({"min": point_mm(lo), "max": point_mm(hi), "size": point_mm(hi - lo)}); + } + out +} + +fn collect(model: &Model) -> Found { 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})); @@ -160,6 +240,7 @@ pub fn check(model: &Model) -> Value { Some((*id, model.selection_bounds(&BTreeSet::from([*id]))?, aligned)) }) .collect(); + let mut machined: Option = None; 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 }; @@ -170,6 +251,16 @@ pub fn check(model: &Model) -> Value { if model.joinery_options([*a, *b], &JointSettings::default()).0.is_none() { continue; } + // Nor do they when a router profile (a rabbet, a groove) removed the shared wood. + let routed = |id: u64| model.objects[&id].entities.iter().any(|e| model.routes.contains_key(e)); + if routed(*a) || routed(*b) { + let evaluated = machined.get_or_insert_with(|| model.evaluate_joinery().model); + if evaluated.objects.contains_key(a) && evaluated.objects.contains_key(b) + && evaluated.joinery_options([*a, *b], &JointSettings::default()).0.is_none() + { + continue; + } + } let (name_a, name_b) = (&model.objects[a].name, &model.objects[b].name); // One board crossing the other completely, cutting it in two: the // shared region spans the other on two axes and it continues on @@ -205,6 +296,13 @@ pub fn check(model: &Model) -> Value { let joint_errors = scene::joint_errors(model); for (id, error) in &joint_errors { let id = *id; + if model.routes.contains_key(&id) { + let board = model.route_board(id); + let name = board.and_then(|b| model.objects.get(&b)).map_or("loose geometry", |o| o.name.as_str()); + issue("error", "profile_not_cut", format!("The router profile on edge {id} of {name} cannot be cut: {error}"), + board.into_iter().chain([id]).collect()); + continue; + } 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); } @@ -253,27 +351,5 @@ pub fn check(model: &Model) -> Value { if !unassigned.is_empty() { issue("info", "no_material", format!("{} boards have no stock material.", unassigned.len()), unassigned.into_iter().take(20).collect()); } - let total = issues.len(); - let errors = issues.iter().filter(|i| i["severity"] == "error").count(); - let warnings = issues.iter().filter(|i| i["severity"] == "warning").count(); - issues.truncate(MAX_LISTED_ISSUES); - let wood_joints = model.connections.values().filter(|c| matches!(c.operation, ConnectionOperation::Joinery(_))).count(); - let mut out = json!({ - "passed": errors == 0 && warnings == 0, - "boards": all.len(), "errors": errors, "warnings": warnings, - "joints": {"total": wood_joints, "ok": wood_joints - joint_errors.len().min(wood_joints), "failing": joint_errors.keys().collect::>()}, - "issues": issues, - }); - if total > MAX_LISTED_ISSUES { - out["issues_omitted"] = json!(total - MAX_LISTED_ISSUES); - } - if all.len() > MAX_CHECKED_BOARDS { - out["note"] = json!(format!("Only the first {MAX_CHECKED_BOARDS} boards were compared for overlaps.")); - } - // The furniture's size: boards only, not dimensions or texts drawn around them. - let extents = all.iter().filter_map(|id| board_bounds(model, *id)).reduce(|x, y| (x.0.min(y.0), x.1.max(y.1))); - if let Some((lo, hi)) = extents { - out["model_bounds_mm"] = json!({"min": point_mm(lo), "max": point_mm(hi), "size": point_mm(hi - lo)}); - } - out + Found { issues, joint_errors, boards: all } } diff --git a/crates/benchup-tools/src/scene.rs b/crates/benchup-tools/src/scene.rs index 6b6bb63..05b81e4 100644 --- a/crates/benchup-tools/src/scene.rs +++ b/crates/benchup-tools/src/scene.rs @@ -119,9 +119,194 @@ pub fn object_entry(model: &Model, id: u64) -> Option { if model.is_locked(id) { map.insert("locked".into(), json!(true)); } + let profiles = object.entities.iter().filter(|e| model.routes.contains_key(e)).count(); + if profiles > 0 { + map.insert("profiles".into(), json!(profiles)); + } Some(entry) } +/// Fields of an object entry that `get_scene{fields}` may pick. +pub const OBJECT_FIELDS: &[&str] = &[ + "name", "kind", "parent", "tag", "bounds_mm", "size_mm", "definition", "material", "grain", "hidden", "locked", "profiles", +]; +/// Parts of the scene summary that `get_scene{sections}` may pick. +pub const SECTIONS: &[&str] = &["objects", "joints", "dimensions", "texts", "tags", "stock", "loose_geometry", "selection"]; + +/// What `get_scene` lists. The default lists everything. +#[derive(Default)] +pub struct Filter { + /// Objects to list, in order; `None` lists every object. + pub objects: Option>, + /// Object fields to keep besides `id`; `None` keeps all. + pub fields: Option>, + /// Sections to include; `None` includes all. + pub sections: Option>, +} +impl Filter { + fn wants(&self, section: &str) -> bool { + self.sections.as_ref().is_none_or(|s| s.contains(section)) + } + fn trim(&self, mut entry: Value) -> Value { + if let (Some(fields), Some(map)) = (&self.fields, entry.as_object_mut()) { + map.retain(|key, _| key == "id" || fields.contains(key)); + } + entry + } +} + +/// Router profiles on a board's edges: the edge, the profile and its sizes, +/// and the directions of the two faces the edge lies between. +pub fn profiles_json(model: &Model, board: u64) -> Vec { + use benchup_core::router::Profile; + let Some(object) = model.objects.get(&board) else { return Vec::new() }; + if !object.entities.iter().any(|e| model.routes.contains_key(e)) { + return Vec::new(); + } + let mut adjacency = edge_faces(model, board); + object + .entities + .iter() + .filter_map(|edge| Some((*edge, model.routes.get(edge)?, model.edges.get(edge)?))) + .map(|(id, settings, edge)| { + let mut entry = json!({"edge": id, "profile": profile_name(settings.profile)}); + match settings.profile { + Profile::Chamfer => entry["width_mm"] = json!(mm(settings.width)), + Profile::Roundover | Profile::Cove | Profile::Ogee => entry["radius_mm"] = json!(mm(settings.width)), + Profile::Bevel => { + entry["width_mm"] = json!(mm(settings.width)); + entry["angle_degrees"] = json!(settings.angle_degrees); + } + Profile::Rabbet => { + entry["width_mm"] = json!(mm(settings.width)); + entry["depth_mm"] = json!(mm(settings.depth)); + } + Profile::Groove => { + entry["width_mm"] = json!(mm(settings.width)); + entry["depth_mm"] = json!(mm(settings.depth)); + entry["offset_mm"] = json!(mm(settings.offset)); + } + Profile::Bead => { + entry["radius_mm"] = json!(mm(settings.width)); + entry["offset_mm"] = json!(mm(settings.offset)); + } + Profile::None => {} + } + if settings.reverse { + entry["reverse"] = json!(true); + } + let (a, b) = model.points(edge); + entry["from_mm"] = point_mm(a); + entry["to_mm"] = point_mm(b); + let between: Vec = adjacency + .remove(&id) + .unwrap_or_default() + .into_iter() + .map(|face| facing(model, face).map_or(json!(face), Value::from)) + .collect(); + entry["between"] = json!(between); + entry + }) + .collect() +} + +/// The name add_profile uses for a router profile. +pub fn profile_name(profile: benchup_core::router::Profile) -> &'static str { + use benchup_core::router::Profile; + match profile { + Profile::Chamfer => "chamfer", + Profile::Bevel => "bevel", + Profile::Roundover => "roundover", + Profile::Cove => "cove", + Profile::Rabbet => "rabbet", + Profile::Groove => "groove", + Profile::Ogee => "ogee", + Profile::Bead => "bead", + Profile::None => "none", + } +} + +/// Each edge of a board with the faces of the board that use it. +pub fn edge_faces(model: &Model, board: u64) -> BTreeMap> { + let Some(object) = model.objects.get(&board) else { return BTreeMap::new() }; + let mut by_ends: BTreeMap<(u64, u64), u64> = BTreeMap::new(); + for id in &object.entities { + if let Some(edge) = model.edges.get(id) { + by_ends.insert((edge.a.min(edge.b), edge.a.max(edge.b)), *id); + } + } + let mut out: BTreeMap> = by_ends.values().map(|id| (*id, Vec::new())).collect(); + for id in &object.entities { + let Some(face) = model.faces.get(id) else { continue }; + for ring in std::iter::once(&face.vertices).chain(&face.holes) { + for (i, a) in ring.iter().enumerate() { + let b = ring[(i + 1) % ring.len()]; + if let Some(edge) = by_ends.get(&((*a).min(b), (*a).max(b))) { + let faces = out.entry(*edge).or_default(); + if !faces.contains(id) { + faces.push(*id); + } + } + } + } + } + out +} + +/// The outward normal of a face of a part. +pub fn outward_normal(model: &Model, face: u64) -> Option { + let f = model.faces.get(&face)?; + Some(faces::plane(model, &f.vertices)?.normal * outward_sign(model, face)) +} + +/// "+x", "-z"... for a face square to an axis. +pub fn facing(model: &Model, face: u64) -> Option { + facing_name(outward_normal(model, face)?) +} +pub fn facing_name(n: DVec3) -> Option { + [(DVec3::X, "x"), (DVec3::Y, "y"), (DVec3::Z, "z")] + .into_iter() + .find(|(axis, _)| n.dot(*axis).abs() > 0.999) + .map(|(axis, name)| format!("{}{name}", if n.dot(axis) > 0. { '+' } else { '-' })) +} + +/// A board's finishing settings, when it has its own. +pub fn finish_json(model: &Model, id: u64) -> Option { + let settings = model.objects.get(&id)?.surface_treatment_override()?; + let grits: Vec = settings + .sanding_grits + .iter() + .zip(&settings.sanding) + .filter(|(_, step)| step.enabled) + .map(|(grit, _)| *grit) + .collect(); + let step = |s: &benchup_core::surface_treatment::Step| -> Value { + if !s.enabled { + return json!(false); + } + let mut out = json!({"coats": s.coats, "minutes_per_m2": s.minutes_per_m2, "drying_hours": s.drying_hours}); + if let Some(hours) = s.final_drying_hours { + out["final_drying_hours"] = json!(hours); + } + if let Some(tint) = s.appearance.tint { + out["tint_rgb"] = json!(tint); + } + if let Some(strength) = s.appearance.strength { + out["strength"] = json!(strength); + } + if let Some(sheen) = s.appearance.sheen { + out["sheen"] = json!(format!("{sheen:?}").to_lowercase()); + } + out + }; + Some(json!({ + "sanding_grits": grits, + "pre_stain": step(&settings.pre_stain), + "stain": step(&settings.stain), + "varnish": step(&settings.varnish), + })) +} + pub fn stock_list( model: &Model, catalog: &BTreeMap, @@ -137,8 +322,9 @@ pub fn stock_list( } /// Joints the core cannot cut, keyed by joint id, with its reason. +/// Router profiles that cannot be cut are keyed by their edge id. pub fn joint_errors(model: &Model) -> BTreeMap { - if !model.connections.values().any(|c| matches!(c.operation, ConnectionOperation::Joinery(_))) { + if !model.has_machining() { return BTreeMap::new(); } model.evaluate_joinery().errors.into_iter().collect() @@ -184,6 +370,17 @@ pub fn joint_detail(model: &Model, id: u64) -> Option { 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); + if let Some(geometry) = joint_geometry(model, connection.members, s, &overlap) { + joint["geometry"] = geometry; + } + } + if let Some(index) = s.trim.member() { + joint["trim"] = json!({ + "board": if index == 0 { "a" } else { "b" }, + "board_id": connection.members[index], + "keep_end": match s.keep_end { benchup_core::joinery::KeepEnd::Lower => "lower", benchup_core::joinery::KeepEnd::Upper => "upper" }, + "trim_axis": s.trim_axis, + }); } } match joint_errors(model).get(&id) { @@ -193,6 +390,95 @@ pub fn joint_detail(model: &Model, id: u64) -> Option { Some(joint) } +/// Where a joint is, in world millimetres: the shared region (`region_mm`), +/// the tenon, tongue or lap actually cut in it (`cut_mm`), how deep a goes +/// into b and which way, and whether the cut in b runs through it, stops at +/// one face or is closed inside it. +fn joint_geometry( + model: &Model, + members: [u64; 2], + s: &benchup_core::joinery::JointSettings, + overlap: &benchup_core::joinery::JoineryOverlap, +) -> Option { + use benchup_core::joinery::JointKind; + let frame = model.joinery_frame(members[0]).ok()?; + let (lo, hi) = overlap.bounds; + let corners: Vec = (0..8) + .map(|i| frame.world(DVec3::new(if i & 1 == 0 { lo.x } else { hi.x }, if i & 2 == 0 { lo.y } else { hi.y }, if i & 4 == 0 { lo.z } else { hi.z }))) + .collect(); + let (wlo, whi) = corners.iter().fold((corners[0], corners[0]), |(a, b), p| (a.min(*p), b.max(*p))); + let size = hi - lo; + let axis_vector = |i: usize| -> DVec3 { + let mut v = DVec3::ZERO; + v[i] = 1.; + (frame.world(v) - frame.world(DVec3::ZERO)).normalize_or_zero() + }; + let depth = s.depth_axis.min(2); + let width = s.width_axis.min(2); + let height = 3 - depth - width; + // a goes into b towards b's centre along the depth axis. + let (a_box, b_box) = (frame.bounds(model, members[0])?, frame.bounds(model, members[1])?); + let towards = if (b_box.0[depth] + b_box.1[depth]) >= (a_box.0[depth] + a_box.1[depth]) { 1. } else { -1. }; + let direction = axis_vector(depth) * towards; + let mut out = json!({ + "region_mm": {"min": point_mm(wlo), "max": point_mm(whi)}, + "depth_mm": mm(size[depth]), + "depth_direction": facing_name(direction).map_or_else(|| unit(direction), Value::from), + }); + match s.kind { + JointKind::TongueGroove | JointKind::Rabbet => out["tongue_thickness_mm"] = json!(mm(size[width] * s.width_ratio)), + JointKind::MortiseTenon => { + out["tenon_thickness_mm"] = json!(mm(size[width] * s.width_ratio)); + out["tenon_height_mm"] = json!(mm(size[height] * s.height_ratio)); + } + _ => {} + } + // The part of the overlap that is cut, as the core lays it out: a tongue + // or tenon centred across the width (and height for a tenon), a rabbet's + // tongue at the low end of the width, half the height for a lap. Fingers + // and dovetails use the whole overlap. + let (mut cl, mut ch) = (lo, hi); + match s.kind { + JointKind::TongueGroove | JointKind::MortiseTenon => { + let margin = size[width] * (1. - s.width_ratio) * 0.5; + cl[width] += margin; + ch[width] -= margin; + if s.kind == JointKind::MortiseTenon { + let margin = size[height] * (1. - s.height_ratio) * 0.5; + cl[height] += margin; + ch[height] -= margin; + } + } + JointKind::Rabbet => ch[width] = lo[width] + size[width] * s.width_ratio, + JointKind::HalfLap => ch[height] = lo[height] + size[height] * 0.5, + JointKind::Dovetail | JointKind::Finger => {} + } + let cut: Vec = (0..8) + .map(|i| frame.world(DVec3::new(if i & 1 == 0 { cl.x } else { ch.x }, if i & 2 == 0 { cl.y } else { ch.y }, if i & 4 == 0 { cl.z } else { ch.z }))) + .collect(); + let (clo, chi) = cut.iter().fold((cut[0], cut[0]), |(a, b), p| (a.min(*p), b.max(*p))); + out["cut_mm"] = json!({"min": point_mm(clo), "max": point_mm(chi)}); + // The mortise, groove or socket in b along the axes across the depth: + // open at both of b's faces (through), at one (stopped) or at none (closed). + let eps = 1.0e-5; + let mut ends = serde_json::Map::new(); + for axis in [width, height] { + let open_low = cl[axis] <= b_box.0[axis] + eps; + let open_high = ch[axis] >= b_box.1[axis] - eps; + let kind = match (open_low, open_high) { + (true, true) => "through", + (false, false) => "closed", + _ => "stopped", + }; + let name = ["x", "y", "z"][axis]; + let key = if frame.is_project_aligned() { name.to_owned() } else { format!("{name} (a's axes)") }; + ends.insert(key, json!(kind)); + } + out["cut_in_b"] = Value::Object(ends); + out["through_b"] = json!(lo[depth] <= b_box.0[depth] + eps && hi[depth] >= b_box.1[depth] - eps); + Some(out) +} + /// 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; @@ -228,6 +514,17 @@ pub fn summary( selection: &BTreeSet, catalog: &BTreeMap, budget: usize, +) -> Value { + summary_with(model, selection, catalog, budget, &Filter::default()) +} + +/// The summary, keeping only what `filter` asks for. +pub fn summary_with( + model: &Model, + selection: &BTreeSet, + catalog: &BTreeMap, + budget: usize, + filter: &Filter, ) -> Value { let stock = stock_list(model, catalog); let errors = joint_errors(model); @@ -265,15 +562,48 @@ pub fn summary( "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(); - let mut order: Vec = Vec::with_capacity(model.objects.len()); - order.extend(selection.iter().filter(|id| model.objects.contains_key(id))); - order.extend(model.objects.keys().filter(|id| in_scope.contains(id) && !selection.contains(id))); - order.extend(model.objects.keys().filter(|id| !in_scope.contains(id) && !selection.contains(id))); + let profiles = model.routes.len(); + if profiles > 0 { + out["profile_count"] = json!(profiles); + } + if let Some(map) = out.as_object_mut() { + for (section, keys) in [ + ("joints", &["joints"][..]), + ("dimensions", &["dimensions"]), + ("texts", &["texts"]), + ("tags", &["tags"]), + ("stock", &["stock_catalog"]), + ("loose_geometry", &["loose_geometry"]), + ("selection", &["selection"]), + ] { + if !filter.wants(section) { + for key in keys { + map.remove(*key); + } + } + } + } + if !filter.wants("objects") { + return out; + } + let order: Vec = match &filter.objects { + Some(list) => list.clone(), + None => { + let in_scope: BTreeSet = model.selectable_entities(); + let mut order: Vec = Vec::with_capacity(model.objects.len()); + order.extend(selection.iter().filter(|id| model.objects.contains_key(id))); + order.extend(model.objects.keys().filter(|id| in_scope.contains(id) && !selection.contains(id))); + order.extend(model.objects.keys().filter(|id| !in_scope.contains(id) && !selection.contains(id))); + order + } + }; + if filter.objects.is_some() { + out["listed_count"] = json!(order.len()); + } let mut used = out.to_string().len() + 160; let mut objects = Vec::new(); for id in &order { - let Some(entry) = object_entry(model, *id) else { continue }; + let Some(entry) = object_entry(model, *id).map(|e| filter.trim(e)) else { continue }; let cost = entry.to_string().len() + 1; if used + cost > budget { break; @@ -397,6 +727,13 @@ pub fn describe(model: &Model, id: u64) -> Option { if total > MAX_FACES { entry["faces_omitted"] = json!(total - MAX_FACES); } + let profiles = profiles_json(model, id); + if !profiles.is_empty() { + entry["profiles"] = json!(profiles); + } + if let Some(finish) = finish_json(model, id) { + entry["finish"] = finish; + } return Some(entry); } if let Some(face) = face_json(model, id) { diff --git a/docs/agent-guide.md b/docs/agent-guide.md index bbd8941..7384f3e 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -2,7 +2,7 @@ [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. +This guide is written for AI agents that edit BenchUp projects through the [`benchup` command line](cli.md) or its MCP server (see [Over MCP](#over-mcp) for the differences). Agents read it with `benchup guide` (over MCP, the `get_guide` tool), and it always matches the installed version. ## Conventions @@ -13,22 +13,33 @@ This guide is written for AI agents that edit BenchUp projects through the [`ben ## 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. +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. On a big project, filter it: `depth` 0 lists only top-level objects, `ids` lists those objects and what is inside them, `fields` keeps only some object fields (`["name", "size_mm"]`), and `sections` keeps only some parts (`["objects"]`). 2. Plan the whole change. For anything with repeated parts, write a small script that computes the positions and prints a batch file. 3. Apply it as **one batch**: one undo step, and all-or-nothing. 4. Verify: - `check_model` finds boards that pass through others, overlaps without a joint, joints that cannot be cut or look reversed, boards thicker or thinner than their material, and loose geometry. It also counts the joints that cut. Read `passed` (no errors or warnings); `ok` only says the check ran. - `get_object` on a joint shows its settings and status. - - `get_cut_list` lists the boards to cut. + - `get_cut_list` lists the boards to cut; `get_estimate` gives the time to build them. + - `benchup render file.bup --view front` (over MCP, `render_view`) draws the model as a PNG, joints and profiles cut, with dimensions and text notes (in capitals, without accents). Its output lists dimensions whose labels overlap and the notes with their position. It shows the whole model; details of a few millimetres need `--size 2400x1800`. 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. +- Every call that changes the model returns `created`, `modified` and `erased` ids. Some return more: `joint_id` (`add_joint`, `set_joint`), `material_id` (`create_stock_material`), `face_id` (`draw_polyline` and `push_pull`), `faces` (`split_face`), `edges` (`add_profile`) and `members` (`group`). `benchup tools ` describes each tool. - Ids are not consecutive, so always take them from results. In a batch, `"$N.field"` is a value from call N's result, counting from 1. Any field works, including nested ones: `"$1.created[0]"`, `"$3.joint_id"`, `"$2.material_id"`, `"$4.members[0].id"`, `"$5.created[-1]"`. A reference may stand in for any argument, even where the schema says integer or uuid. A reference to a list of ids inside a list is spliced in, so `"ids": ["$1.created", "$2.created"]` joins both; other lists, such as a point in `points_mm`, stay one item. Text that is not exactly a reference, such as `"$12.50 hardware"`, is kept as written. -- With the CLI, `--dry-run` reports every result without changing the file; ids made in a dry run differ from the real run. **End a dry-run batch with `check_model` to validate the change before applying it.** Read tools inside a batch see the batch's earlier changes. `--brief` leaves out the object summaries of changing calls and keeps ids and bounds. +- 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.** `check_model` with `"only_new": true` reports only the problems the batch introduced, leaving out those that were already in the project. 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. +### Over MCP + +The tools are the same. What the CLI does with flags, MCP does with three extra tools: + +- `run_batch {calls, dry_run}` runs several calls as one undo step, all or nothing, with the same `"$N.field"` references as a batch file. `dry_run: true` reports every result and saves nothing; end it with `check_model {"only_new": true}`. +- `undo` takes back the last call or batch (`redo: true` to redo it). +- `render_view` returns the image of a view. + +Each other call is saved on its own, so prefer `run_batch` for any change of more than one call. The schemas are in the server's tool list; there is no `--brief`. + ## Groups, components and scope - Every board is a group. Editing tools (`move`, `resize_board`, `rename`, `tag`, `erase`, `add_joint`, `push_pull`…) reach parts inside groups and components by themselves when all the ids in a call share one group. Ids from different groups need separate calls. @@ -48,6 +59,9 @@ A joint joins two boards that **overlap** where they meet; the overlap is the jo - **`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. +- `get_object` on a joint shows its `geometry`: the region cut (`region_mm`), the depth and the direction `a` goes into `b`, the tenon or tongue thickness, and `cut_in_b`, whether the groove or mortise runs `through` `b`, is `stopped` at one end or `closed` inside it, along each axis across the depth. +- `set_joint` changes an existing joint's kind or params, keeping the rest. The joint must still cut. +- **Trimming excess:** where a board runs past the other (a rail through a leg, a side past a top), `"trim": "a"` (or `"b"`) in the params cuts that board flush with the other board's outer face; `keep_end` (`lower` or `upper`, along `trim_axis`, default the board's longest axis) says which end of it stays. - 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 | @@ -55,9 +69,10 @@ A joint joins two boards that **overlap** where they meet; the overlap is the jo | Shelf housed in a side | Shelf enters the side by about half the side's thickness (9 mm into 18) | `tongue_groove`, a = shelf, b = side | | Apron into a leg | Apron enters the leg 25 to 35 mm, set back 5 to 10 mm from the leg's outer face | `mortise_tenon`, a = apron, b = leg, `width_ratio` about 0.33 (tenon one third of the apron's thickness), `height_ratio` about 0.75 | | Back panel in a groove | Back (6 mm) enters each side by about half the side's thickness, 6 to 10 mm in from the back edge | `tongue_groove`, a = back, b = side | -| Back panel in a rabbet | Back enters each side by about half the side's thickness, flush with the back edge. Stop housed shelves 2 mm short of it (see below) | `rabbet`, a = back, b = side | +| 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. This cuts both parts: a tongue on the back's edge (its front `width_ratio` share) in a slot in the side, leaving a lip of the side behind the back. For a classic open rabbet with the back left whole, rout it instead: `add_profile` `rabbet` on each side's back inner edge (`[["+y", "+x"]]` on the left side), as wide as the back is thick and half the side deep, with the back sitting in it and no joint | | Drawer side into the front (half-blind dovetail) | Side enters the front from behind, leaving 4 to 6 mm of the front | `dovetail`, a = drawer side, b = front, `dovetails` 3 to 5 | | Drawer bottom | Bottom (6 mm) enters the drawer sides by 6 mm and stops 1 to 2 mm behind the front, clear of the dovetails | `tongue_groove`, a = bottom, b = side | +| Drawer back | Back (12 to 15 mm) enters the drawer sides by half their thickness, standing on the bottom | `tongue_groove` (a dado), a = back, b = side. Sitting on the bottom is fine: its dado and the bottom's groove only touch along the bottom's face. If the joint is refused, raise the back 1 mm | | 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 | @@ -69,40 +84,53 @@ Two joints cut into the same board must not cross, and must not meet face to fac - **Shelves and a back:** stop shelves, top and bottom 1 to 2 mm short of the back, not flush with it. Then a rabbet or a groove for the back both work. - **Drawer bottom:** groove it into the drawer sides only, and stop it 1 to 2 mm behind the front. Where the sides already enter the front with dovetails, a groove in the front would meet them. -- **The same error with no other joint nearby** comes from the board's own geometry: a shaped board with many small faces, or vertices very close to the joint's planes. Change the number of segments of a curve (24 and 48 are known to work) or move the joint a few millimetres, and test in a `--dry-run`. +- **The same error with no other joint nearby** is a precision limit of the joint cutter, not a clash. On a shaped board it comes from 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). On plain boards it can appear on the second joint of a board, for example a rail with a tenon at each end: change `height_ratio` or `width_ratio` slightly (0.6 → 0.65), move one board by 0.5 to 1 mm, or add the joints in the other order (they are cut in the order they were added). Test in a `--dry-run`. - **Two aprons meeting in one leg:** their tenons must not meet inside the leg. With leg width P, apron setback s from the leg's outer face and apron thickness t, keep the tenon depth below P − s − t (70 − 10 − 22 = 38 mm, so use 30). - **A central divider:** shelves on both sides at the same height would cut it through with two 9 mm grooves. Let them enter by a third of its thickness each, stagger the heights, or butt them against it without a joint. ## Recipes - **Carcass (cabinet, bookcase):** two sides, a top and a bottom (between the sides, or the top over them), shelves, and a back in a groove. Stop everything inside 1 to 2 mm short of the back. -- **Even shelves:** with an inner height H (between the bottom and the top, or the full side height if there are none), n shelves of thickness t and equal gaps above, below and between, the gap is (H - n·t)/(n + 1). Shelf k (counting from 1) has its bottom at k·gap + (k - 1)·t above the inner bottom. To change the number of shelves, erase or copy shelves, then `move` them to the new heights; their joints follow. +- **Even shelves:** `distribute` the shelves along z between the inner bottom and the inner top (`from_mm`, `to_mm`; without a bottom or top, the floor and the top of the sides, as the original spacing shows); the gaps above, below and between are equal, (H - n·t)/(n + 1), and the result reports `gap_mm`. Use `"mode": "centers"` for equal centre spacing. To change the number of shelves, erase or copy shelves, then `distribute` them again; their joints follow. With several columns, distribute each column in its own call. Check the clear height between two shelves with `measure_space` at a point between them. - **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. +- **Edge profiles:** `add_profile` routs a chamfer, bevel, roundover, cove, rabbet, groove, ogee or bead along a board's edges with BenchUp's Router. Name an edge by the two faces it lies between: `[["+z", "-y"]]` is the top front edge, `[["+z", "*"]]` every edge of the top face, `[["-y", "-x"]]` the front vertical edge on the left (a corner seen from above); face ids from `get_object` work too. `width_mm` is the width or radius. For a rabbet or groove, which face gets the width and which the depth depends on the edge: check it in a dry run (the part that should sit in the rabbet shows as an overlap in `check_model` if it went the wrong way) and pass `reverse: true` to swap them. The call fails when the profile cannot be cut (too big, or an edge next to a notch); try a smaller size or other edges. `get_object` lists a board's profiles; `remove_profile` takes them off. - **A top with chamfered or rounded front corners over the sides:** let the top overhang the sides at the front by at least the chamfer, or set the sides back, so their corners do not show under the cut. - **Table:** legs 60 to 80 mm square; aprons 80 to 120 mm high and 20 to 25 mm thick, their tops flush with the legs, tenons as above; a top 25 to 40 mm thick resting on the frame with a 20 to 50 mm overhang. Ask whether the size given is the top or the frame, and whether the height includes the top. - **Drawer:** build one drawer as a component (front, sides, back, bottom and their joints), then `create_copy` it up by the front's height plus the gap. Inset fronts leave about 2 mm at each side and 3 to 4 mm between drawers. The box (sides, back and bottom) has sides 12 to 15 mm thick and 30 to 50 mm lower than the front, and is 10 to 20 mm shorter than the carcass's inner depth. The bottom is 6 mm, grooved into the sides about 10 mm above their lower edge, and the back sits on the bottom. With metal runners, make the box about 12.5 mm narrower on each side, or as the runner specifies; with wooden runners, about 2 mm. -- **Lengthening a piece:** `resize_board` the boards that run in that direction, choosing the end with `anchor`, and `move` the parts at that end. `resize_board` stretches the whole board, notches included. For a board with notches or a shape, `push_pull` its end face by the extra length instead; that keeps the notches where they are. Faces in one plane merge after a push. To push only part of a face, split it first with a `draw_polyline` line across it (inside the board, with `enter_object`); the result lists each new face with its `center_mm`. The seam edges left in a flat face are harmless. Do not `erase` them: erasing an edge removes the faces on it. -- **Before choosing which end to lengthen,** look for walls and other parts in the way. The room in an example project can be a group of plain faces (a "Floor" with walls), whose faces `get_object` lists with their positions. -- **Changing the stock thickness while keeping the outside size:** resize each board's thickness with `resize_board`, using `anchor` to keep the outer faces in place (`["max", "min", "min"]` for a right side, `"center"` for dividers and shelves), and keep each joint's overlap at about half the new thickness. Keeping the shelves' centres keeps the gaps symmetric, but a change of an odd number of millimetres gives half millimetres; anchor the shelves at `"min"` for whole numbers. Recompute equal gaps only if asked. Create the new material and assign it; `check_model` reports boards whose thickness does not match their material. +- **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. To push only part of a face, `split_face` it first, by a `line_mm` across it or where a `plane` crosses it; the result lists the pieces with their `center_mm` and area. Afterwards, `merge_faces` on the board removes seams between flat faces in one plane, also around an opening such as a pocket's mouth. Do not `erase` seam edges: erasing an edge removes the faces on it. A pocket or mortise cut by hand in the middle of a face is cleaner drawn than split: `enter_object` the board, draw the mouth as a closed `draw_polyline` inside the face (not touching its edges), and `push_pull` that face in; `split_face` planes cross the whole face and leave seams to merge. A joint with `add_joint` is better still when another board goes into it. +- **Before choosing which end to lengthen,** look for walls and other parts in the way. `measure_space` at a point gives the free distance along each axis to the nearest face and what it belongs to, walls made of plain faces included. `find_surfaces` lists large flat faces that are not boards, such as a room's walls and floor drawn as faces inside groups, with their plane (`y = 400`); `include_solids` adds faces of closed parts, such as a solid floor. +- **Changing the stock thickness while keeping the outside size:** `change_thickness` the boards in one call, with the new material in `material_id`. Boards on the outside of the given set keep their outer face, the others their centre; boards that go into a resized board with a joint are lengthened or shortened so the joint keeps the same share of the new thickness (half stays half), and boards that only butt against a resized board (shelves against a divider) follow its face. `anchor` applies to every board of the call: call it twice for different anchors. Pass `"anchor": "min"` to keep whole millimetres on shelves when an odd change would give half millimetres. Recompute equal gaps only if asked (`distribute`). `check_model` reports boards whose thickness does not match their material. For rotated boards, use `resize_board` with `anchor` instead. - **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. +- **Finishes and time:** `set_finish` on boards or a whole group: `sanding_grits` ([120, 180, 240]) and the `pre_stain`, `stain` and `varnish` stages with their coats, drying hours and look (`tint_rgb`, `sheen`). `get_estimate` (all boards, or `ids`) adds up finishing time per stage from each board's area, joint cutting by kind and router profiles by length, with BenchUp's default allowances, and lists the wood by material. Rates and prices edited in the app are not in the project, so it gives no costs. - **Dimensions for the workshop:** put overall height and width, and a chain of shelf gaps, in the front plane (y = 0) so they read in the front view; put the depth in the side plane (x = the width), offset upwards. A dimension measures the straight distance between its points. Pass `axis` to measure only along x, y or z, so a slightly misplaced point cannot tilt it. Points exactly on a corner, edge or face attach to it and follow it. Nothing snaps, so use real corners: where a shelf enters a side, its visible corner is on the side's inner face. `offset_mm` pushes the line away from the piece: chain dimensions about 100 mm out, overall ones about 250 mm out, so they do not overlap. `add_dimension` returns `length_mm`, and `get_scene` lists every dimension. Put the piece's name in a text above it. Ask whether shelf spacing means clear space or centre to centre. ## Shaped boards (curves, chamfers, angled ends) -The tools have no arc tool and do not expose BenchUp's Router yet (edge chamfers, bevels, roundovers, coves, rabbets, grooves and other profiles). For a profile along an edge, say that the user can apply it with the Router in BenchUp, or cut the shape into the outline as below. Draw the board's outline as one closed `draw_polyline`, using many short segments for a curve, on the plane where the board starts. Then `push_pull` its face to the thickness and `group` everything the two calls created. Finish with `set_grain` and a name. In one batch: +For a chamfer or a rounded edge along a board, use `add_profile` (see the recipes); it keeps the board a simple box with a profile. For a board whose outline is shaped (a curved front, angled ends, a cut-out corner), use `create_profile_board`: the outline, its plane, the thickness, grain, name, material and tag in one call. + +- `origin_mm` is where the outline's (0, 0) is. A negative `thickness_mm` extrudes the other way. Each plane, seen from where u points right and v up: + + | `plane` | u | v | seen from | extrudes along | typical parts | + |---|---|---|---|---|---| + | `xy` | x | y | above | +z | tops, shelves, bottoms | + | `xz` | x | z | the front (-y) | +y, away from you | fronts, backs, rails | + | `yz` | y | z | the right (+x) | +x, towards you | sides | + + "Counter-clockwise" and "to the right of travel" are as seen from there. A side's front edge (y = 0) curving inward, towards +y, is an arc with a negative sagitta when the outline runs counter-clockwise in `yz`. +- Points are `[u, v]` in mm. A point written `{"at": [u, v], "sagitta_mm": s}` or `{"at": [u, v], "bulge": b}` makes the segment from it to the next point an arc. The sagitta is how far the arc's middle is from the straight line between the two points; bulge = tan(angle/4), so 1 is a half circle and 0.414 a quarter. Positive values bulge to the right of the direction of travel, which is outward on a counter-clockwise outline (seen as in the table above). +- Arcs get 12 segments per quarter turn, 24 for a half circle, unless `segments` says otherwise. 24 and 48 segments are known to work with joints; some other counts (32, 40) can make joints impossible to cut. ```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"}} +{"tool": "create_profile_board", "arguments": {"name": "Top", "plane": "xy", "origin_mm": [0, 0, 700], "thickness_mm": 20, "grain_axis": "x", "points": [{"at": [0, 0], "sagitta_mm": 40}, [800, 0], [800, 400], [0, 400]]}} ``` -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. +This top is 800 × 400 with a front edge curving 40 mm forward (to y = -40), counter-clockwise from above. For a curved edge with chord c and depth s, the radius is R = (c²/4 + s²)/(2s). Parts behind a curved front (shelves, a bottom) must be set back far enough to stay behind the curve at their height. + +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. Check the result's `bounds_mm`; an outline that crosses itself or does not make a closed board is refused. -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. +Drawing by hand still works when needed: one closed `draw_polyline` on the plane where the board starts, `push_pull` its face to the thickness, `group` everything the two calls created, then `set_grain`. A loose face's normal follows the order of its points: counter-clockwise seen from the side it faces. 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. @@ -111,7 +139,7 @@ Avoid cutting through a finished board with a negative `push_pull` of its full t - A requested size does not fit, such as a shelf longer than its opening. Measure the free space from `bounds_mm`: from one board's inner face to the next. A housed shelf is that gap plus both overlaps. Try the change in a `--dry-run` batch ending with `check_model`, report what you measured, and ask instead of forcing it. - Ambiguous terms. Ask whether "two columns" means two bays, and whether shelf spacing is clear space or centre to centre. Ask whether "1000 mm from the floor" is the top or the bottom face. Ask whether a front is inset or overlaid, which end of a piece to lengthen, and whether a size is the top or the frame. "The sides" of a bay can include a divider. - A request that conflicts with how the piece is built, such as housed shelves as one component. Keep the construction, do what can be done, and offer the alternative. -- Problems already in the model that the user did not mention, such as joints `check_model` reports as reversed. Report them and offer to fix them. If the change touches those joints anyway, fixing them is reasonable; do it as a separate batch so it can be undone on its own. +- 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, also when you cannot ask: do it as a separate batch so it can be undone on its own; that does not count as departing from the request. Otherwise only report them. - 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. @@ -119,5 +147,5 @@ 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. -- The tools cannot read, set or render views and cameras, so you cannot look at the model. Check it with `get_scene`, `get_object` and `check_model`, and ask the user to look in BenchUp when the result must be seen. Saved views are left as they were. -- 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. +- The tools cannot read or set BenchUp's saved views and cameras; saved views are left as they were. To look at the model, `benchup render` (over MCP, `render_view`) draws one of the standard views (iso, front, back, left, right, top, bottom), not a saved camera; ask the user to look in BenchUp when a particular view matters. +- Some BenchUp features are not available through the tools yet: the Cutting Plan (stock layouts with saw kerf and grain), Workshop Labels and the PDF exports. `get_cut_list` gives an area lower bound; for a real layout, the user can export the Cutting Plan from BenchUp's Tools menu. `get_estimate` covers time with default allowances, not costs. diff --git a/docs/cli.md b/docs/cli.md index ec5f04f..b21d2df 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -22,7 +22,9 @@ 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 scene shelf.bup '{"depth":0,"fields":["name","size_mm"]}' # a filtered summary (get_scene's arguments) +benchup get shelf.bup 79 # one entity in detail, with its faces, profiles and finish +benchup render shelf.bup --view front # a PNG of the model: shelf.front.png (-o OUT.png, --size 1200x900) 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 - @@ -31,10 +33,11 @@ benchup call shelf.bup --batch ops.jsonl # several calls as ONE undo step, al 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 call shelf.bup get_estimate # finishing, joinery and router time; wood by material benchup undo shelf.bup benchup redo shelf.bup -benchup mcp shelf.bup # the same tools over MCP (stdio) +benchup mcp shelf.bup # the same tools over MCP (stdio), plus run_batch, undo and render_view 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 @@ -59,11 +62,13 @@ 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). +- **MCP:** each tool call is applied and saved on its own. `run_batch {calls, dry_run}` runs several calls as one undo step with `"$N.field"` references, like `--batch` (with `dry_run: true`, like `--dry-run`); `undo` (`redo: true`) steps through the project history; `render_view` returns an image. +- **Previews:** `benchup render` draws the model from a standard view (`iso`, `front`, `back`, `left`, `right`, `top`, `bottom`) with joints and router profiles cut, edges, and dimensions with their values, without a display or GPU. Its JSON output lists dimensions whose labels overlap and dimensions that point along the view. Over MCP, the `render_view` tool returns the same image. - **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. -- **Views and cameras:** the CLI keeps the camera of each view saved in the file, so editing a project does not move the user's views. It cannot list, change or render views yet; use BenchUp's [views](controls.md#views-and-cameras) and Tools → Screenshot for that. +- **Views and cameras:** the CLI keeps the camera of each view saved in the file, so editing a project does not move the user's views. It cannot list or change saved views yet, and `benchup render` draws the standard views (iso, front, back, left, right, top, bottom), not saved cameras; use BenchUp's [views](controls.md#views-and-cameras) and Tools → Screenshot for those. - **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). +- **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). On macOS, when BenchUp is not running yet, it starts the app with the file as a launch argument. `--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 From 0e2537a6f0f62c5c6b795164cb379161a3666398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Sat, 14 Nov 2026 12:24:05 +0100 Subject: [PATCH 2/5] Add agent evaluation cases, a regression test and CI for the tools crates/benchup-cli/evals holds the 17 cases used to test the tools with agents (12 from #41 and 5 for the #42 tools), their seed projects, reference solutions, the prompt every agent gets, a script that prepares a round and the results of rounds 1 to 6. tests/evals.rs replays the reference solutions with the real binary and checks them with check_model, so a change that breaks what agents did fails without running agents. CI now runs the benchup-tools and benchup-cli tests, which it did not run before. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 5 + crates/benchup-cli/evals/README.md | 28 ++ crates/benchup-cli/evals/cases.json | 366 ++++++++++++++++++ .../benchup-cli/evals/fixtures/estanteria.bup | Bin 0 -> 10369 bytes crates/benchup-cli/evals/mcp_call.py | 52 +++ crates/benchup-cli/evals/prompt.md | 21 + .../reference/01-example-desk/step1.jsonl | 2 + .../reference/01-example-desk/step2.jsonl | 6 + .../reference/02-four-shelves/batch.jsonl | 6 + .../reference/02-four-shelves/flip.jsonl | 2 + .../evals/reference/03-table/mesa.jsonl | 21 + .../evals/reference/04-drawers/build.jsonl | 29 ++ .../reference/05-back-rabbet/change.jsonl | 19 + .../evals/reference/05-back-rabbet/flip.jsonl | 2 + .../evals/reference/06-oak/flip.jsonl | 2 + .../evals/reference/06-oak/oak.jsonl | 17 + .../reference/07-curved-side/build.jsonl | 25 ++ .../reference/08-ladder-shelf/batch.jsonl | 24 ++ .../evals/reference/09-dimensions/batch.jsonl | 17 + .../reference/10-organize/organize.jsonl | 15 + .../reference/11-cut-material/batch.jsonl | 4 + .../evals/reference/12-impossible/alt.jsonl | 4 + .../reference/12-impossible/literal.jsonl | 3 + .../evals/reference/13-router/profiles.jsonl | 13 + .../evals/reference/14-trim/bench.jsonl | 21 + .../evals/reference/15-finish/finish.jsonl | 2 + crates/benchup-cli/evals/results/round-1-4.md | 21 + crates/benchup-cli/evals/results/round-5.md | 69 ++++ crates/benchup-cli/evals/results/round-6.md | 30 ++ crates/benchup-cli/evals/setup.py | 67 ++++ crates/benchup-cli/tests/evals.rs | 73 ++++ 31 files changed, 966 insertions(+) create mode 100644 crates/benchup-cli/evals/README.md create mode 100644 crates/benchup-cli/evals/cases.json create mode 100644 crates/benchup-cli/evals/fixtures/estanteria.bup create mode 100755 crates/benchup-cli/evals/mcp_call.py create mode 100644 crates/benchup-cli/evals/prompt.md create mode 100644 crates/benchup-cli/evals/reference/01-example-desk/step1.jsonl create mode 100644 crates/benchup-cli/evals/reference/01-example-desk/step2.jsonl create mode 100644 crates/benchup-cli/evals/reference/02-four-shelves/batch.jsonl create mode 100644 crates/benchup-cli/evals/reference/02-four-shelves/flip.jsonl create mode 100644 crates/benchup-cli/evals/reference/03-table/mesa.jsonl create mode 100644 crates/benchup-cli/evals/reference/04-drawers/build.jsonl create mode 100644 crates/benchup-cli/evals/reference/05-back-rabbet/change.jsonl create mode 100644 crates/benchup-cli/evals/reference/05-back-rabbet/flip.jsonl create mode 100644 crates/benchup-cli/evals/reference/06-oak/flip.jsonl create mode 100644 crates/benchup-cli/evals/reference/06-oak/oak.jsonl create mode 100644 crates/benchup-cli/evals/reference/07-curved-side/build.jsonl create mode 100644 crates/benchup-cli/evals/reference/08-ladder-shelf/batch.jsonl create mode 100644 crates/benchup-cli/evals/reference/09-dimensions/batch.jsonl create mode 100644 crates/benchup-cli/evals/reference/10-organize/organize.jsonl create mode 100644 crates/benchup-cli/evals/reference/11-cut-material/batch.jsonl create mode 100644 crates/benchup-cli/evals/reference/12-impossible/alt.jsonl create mode 100644 crates/benchup-cli/evals/reference/12-impossible/literal.jsonl create mode 100644 crates/benchup-cli/evals/reference/13-router/profiles.jsonl create mode 100644 crates/benchup-cli/evals/reference/14-trim/bench.jsonl create mode 100644 crates/benchup-cli/evals/reference/15-finish/finish.jsonl create mode 100644 crates/benchup-cli/evals/results/round-1-4.md create mode 100644 crates/benchup-cli/evals/results/round-5.md create mode 100644 crates/benchup-cli/evals/results/round-6.md create mode 100755 crates/benchup-cli/evals/setup.py create mode 100644 crates/benchup-cli/tests/evals.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e14245d..e550045 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -235,6 +235,11 @@ jobs: - name: Test native import and export fixtures run: bash scripts/ci_cargo.sh test -p benchup-io -p benchup-skp --profile ci-test --locked --target "${{ matrix.target }}" + # Includes the replay of the agent evaluation's reference solutions + # (crates/benchup-cli/tests/evals.rs); the agent rounds themselves are run on demand. + - name: Test the agent tools and command line + run: bash scripts/ci_cargo.sh test -p benchup-tools -p benchup-cli --profile ci-test --locked --target "${{ matrix.target }}" + wasm: name: Build WebAssembly needs: validate diff --git a/crates/benchup-cli/evals/README.md b/crates/benchup-cli/evals/README.md new file mode 100644 index 0000000..8a76d7b --- /dev/null +++ b/crates/benchup-cli/evals/README.md @@ -0,0 +1,28 @@ +# Agent evaluation of the `benchup` tools + +The CLI and MCP tools are meant to be used by coding agents that have never seen them. These cases check that: realistic requests from a woodworker (in Spanish, as users write them), each solved by an agent that knows only what the tool tells it (`benchup --help`, `benchup guide`, the tool schemas and its errors). + +There are two layers: + +1. **Regression, on every change** — `cargo test -p benchup-cli --test evals`. Replays the reference solution of each case (`reference//*.jsonl`, batches that agents or maintainers got right) with the real binary and checks the result with `check_model`. It needs no agent and takes a few seconds. When a tool changes on purpose, update the reference batch. +2. **Agent rounds, on demand** — before a release, or after changing tools, descriptions or the [agent guide](../../../docs/agent-guide.md). One agent per case, run in parallel; each one reports what it did, how many commands it needed and where it got stuck. Not in CI: it costs tokens and the results vary from run to run. To run one: + + ```sh + cargo build --release -p benchup-cli + python3 crates/benchup-cli/evals/setup.py --round 5 # or --cases 01,13 + # give each agent the prompt in /tmp/benchup-eval5/prompts/.md, verbatim + ``` + +## Files + +- `cases.json` — the cases: request, seed project, mode (`cli`, or `mcp` through `mcp_call.py`), the tools it is expected to exercise, and the reference solution with what `check_model` must say about it. +- `prompt.md` — the rules every agent gets, and the report it must return. +- `setup.py` — prepares a round in `/tmp/benchup-eval/` (binary, one folder per case with its seed, prompts). It never builds. +- `mcp_call.py` — a minimal MCP client over stdio, for the MCP cases. +- `fixtures/` — seed projects. `estanteria.bup` is a 1000 × 1800 mm bookcase with two columns of five housed shelves whose ten joints are reversed on purpose (agents should notice and offer `flip_joint`). +- `reference/` — reference batches, per case. +- `results/` — one file per round: the table of commands (errors) per case compared with earlier rounds, and the findings, sorted into tool bugs, guide gaps, core limits and missing features. + +## Adding a case + +Add it to `cases.json` with a request a real user would write, without naming tools. Give it a reference batch when the result can be checked with `check_model`; keep the request achievable with the current core (a case that hits a core limit belongs in an issue). Cases that only make sense with an agent (judgement, visual checks, a mode such as MCP) can go without a reference. diff --git a/crates/benchup-cli/evals/cases.json b/crates/benchup-cli/evals/cases.json new file mode 100644 index 0000000..5259b9e --- /dev/null +++ b/crates/benchup-cli/evals/cases.json @@ -0,0 +1,366 @@ +{ + "seeds": { + "estanteria": "crates/benchup-cli/evals/fixtures/estanteria.bup", + "example": "assets/examples/Example.bup", + "new": null + }, + "cases": [ + { + "id": "01-example-desk", + "title": "Lengthen the desk", + "mode": "cli", + "seed": "example", + "file": "Example.bup", + "request": "En Example.bup, haz el escritorio (Desk) 200 mm más largo.", + "focus": [ + "get_scene filters", + "split_face", + "merge_faces", + "measure_space", + "find_surfaces", + "push_pull" + ], + "reference": { + "batches": [ + "step1.jsonl", + "step2.jsonl" + ], + "expect": { + "errors": 0, + "allowed_kinds": [ + "no_material" + ] + } + } + }, + { + "id": "02-four-shelves", + "title": "Five shelves to four", + "mode": "cli", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "En estanteria.bup, ahora quiero que tenga 4 baldas en cada columna en vez de 5, repartidas por igual, manteniendo los ensambles.", + "focus": [ + "distribute", + "flip_joint" + ], + "reference": { + "batches": [ + "batch.jsonl", + "flip.jsonl" + ], + "expect": { + "passed": true + } + } + }, + { + "id": "03-table", + "title": "Dining table", + "mode": "cli", + "seed": "new", + "file": "mesa.bup", + "request": "Créame mesa.bup: una mesa de comedor de 1400×800 mm y 750 mm de alto, con 4 patas de 70×70 mm, faldones unidos a las patas con caja y espiga, y un tablero de 30 mm encima.", + "focus": [ + "create_board", + "add_joint" + ], + "reference": { + "batches": [ + "mesa.jsonl" + ], + "expect": { + "passed": true + } + } + }, + { + "id": "04-drawers", + "title": "Chest of drawers", + "mode": "cli", + "seed": "new", + "file": "cajonera.bup", + "request": "Créame cajonera.bup: una cajonera de 500 mm de ancho, 450 de fondo y 700 de alto, con 3 cajones iguales. Los frentes de cajón unidos a los laterales del cajón con colas de milano. Los tres cajones deben ser el mismo componente.", + "focus": [ + "group", + "create_copy", + "add_joint" + ], + "reference": { + "batches": [ + "build.jsonl" + ], + "expect": { + "passed": true + } + } + }, + { + "id": "05-back-rabbet", + "title": "Back in a rabbet and plinth", + "mode": "cli", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "En estanteria.bup, añade una trasera de 6 mm metida en un galce en los laterales, y un zócalo de 80 mm de alto retranqueado 20 mm por delante.", + "focus": [ + "add_joint", + "check_model only_new" + ], + "reference": { + "batches": [ + "flip.jsonl", + "change.jsonl" + ], + "expect": { + "passed": true + } + } + }, + { + "id": "06-oak", + "title": "Solid oak 19 mm", + "mode": "cli", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "En estanteria.bup, hazla en roble macizo de 19 mm en vez de tablero de 18.", + "focus": [ + "change_thickness", + "create_stock_material" + ], + "reference": { + "batches": [ + "flip.jsonl", + "oak.jsonl" + ], + "expect": { + "passed": true + } + } + }, + { + "id": "07-curved-side", + "title": "Bedside table with curved sides", + "mode": "cli", + "seed": "new", + "file": "mesilla.bup", + "request": "Créame mesilla.bup: una mesilla de noche de 450 mm de ancho, 350 de fondo y 550 de alto. Los laterales tienen el borde delantero curvo (un arco que entra 40 mm en el centro) y la tapa tiene un chaflán de 10 mm en las esquinas delanteras.", + "focus": [ + "create_profile_board", + "add_profile" + ], + "reference": { + "batches": [ + "build.jsonl" + ], + "expect": { + "passed": true + } + } + }, + { + "id": "08-ladder-shelf", + "title": "Leaning ladder shelf", + "mode": "cli", + "seed": "new", + "file": "escalera.bup", + "request": "Créame escalera.bup: una estantería tipo escalera apoyada en la pared, de 1800 mm de alto, con dos largueros inclinados 15° respecto a la vertical y 4 baldas horizontales de 600 mm de ancho, cada una menos profunda que la de abajo, encajadas en los largueros.", + "focus": [ + "create_profile_board", + "rotate" + ], + "reference": { + "batches": [ + "batch.jsonl" + ], + "expect": { + "passed": true + } + } + }, + { + "id": "09-dimensions", + "title": "Workshop dimensions", + "mode": "cli", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "En estanteria.bup, acota el alto, el ancho y el fondo totales, y la distancia entre baldas, y pon un texto con el nombre del mueble, para llevarlo al taller.", + "focus": [ + "add_dimension", + "render_view" + ], + "reference": { + "batches": [ + "batch.jsonl" + ], + "expect": { + "errors": 0, + "allowed_kinds": [ + "joint_roles_reversed", + "no_material" + ], + "min_dimensions": 4, + "min_texts": 1 + } + } + }, + { + "id": "10-organize", + "title": "Modules, components and tags", + "mode": "cli", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "Organiza estanteria.bup: agrupa cada columna como un módulo (Módulo izquierdo, Módulo derecho), haz que todas las baldas sean el mismo componente, y pon la etiqueta «Baldas» a las baldas y «Estructura» a laterales y separador.", + "focus": [ + "group", + "tag" + ], + "reference": { + "batches": [ + "organize.jsonl" + ], + "expect": { + "errors": 0, + "allowed_kinds": [ + "joint_roles_reversed", + "no_material" + ] + } + } + }, + { + "id": "11-cut-material", + "title": "Cut list and sheets", + "mode": "cli", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "Para estanteria.bup, ¿cuánto tablero necesito? Dame la lista de despiece (pieza, medidas, cantidad) y cuántos tableros de 2440×1220 mm de 18 mm tengo que comprar.", + "focus": [ + "get_cut_list", + "get_estimate" + ], + "reference": { + "batches": [ + "batch.jsonl" + ], + "expect": { + "errors": 0, + "allowed_kinds": [ + "joint_roles_reversed", + "no_material" + ] + } + } + }, + { + "id": "12-impossible", + "title": "A shelf that does not fit", + "mode": "cli", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "En estanteria.bup, añade una balda de 1200 mm de largo en la columna izquierda, a 1000 mm del suelo, encajada en los laterales.", + "focus": [ + "check_model", + "measure_space" + ], + "reference": { + "batches": [ + "literal.jsonl" + ], + "expect": { + "required_kinds": [ + "passes_through" + ] + } + } + }, + { + "id": "13-router", + "title": "Edge profiles", + "mode": "cli", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "En estanteria.bup, achaflana 2 mm el canto delantero superior de todas las baldas y redondea con radio 3 mm los dos cantos delanteros del separador central.", + "focus": [ + "add_profile" + ], + "reference": { + "batches": [ + "profiles.jsonl" + ], + "expect": { + "passed": true + } + } + }, + { + "id": "14-trim", + "title": "Bench with trimmed through tenons", + "mode": "cli", + "seed": "new", + "file": "banco.bup", + "request": "Créame banco.bup: un banco de 1200×350 mm y 450 mm de alto, con 4 patas de 50×50 mm y un asiento de 25 mm. Los travesaños (70×22 mm) atraviesan las patas con caja y espiga pasante y deben quedar enrasados por fuera de las patas.", + "focus": [ + "add_joint trim", + "set_joint" + ], + "reference": { + "batches": [ + "bench.jsonl" + ], + "expect": { + "passed": true + } + } + }, + { + "id": "15-finish", + "title": "Finish and time", + "mode": "cli", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "En estanteria.bup, quiero lijarla a grano 120 y 180 y darle dos manos de barniz satinado. ¿Cuánto tiempo me llevará terminarla, contando el secado?", + "focus": [ + "set_finish", + "get_estimate" + ], + "reference": { + "batches": [ + "finish.jsonl" + ], + "expect": { + "errors": 0, + "allowed_kinds": [ + "joint_roles_reversed", + "no_material" + ] + } + } + }, + { + "id": "16-big-project", + "title": "Shelf above the desk, checked visually", + "mode": "cli", + "seed": "example", + "file": "Example.bup", + "request": "En Example.bup, pon una balda de 18 mm y 250 mm de fondo encima del escritorio, a 400 mm por encima del tablero y del mismo largo, sin chocar con nada. Comprueba el resultado con una imagen.", + "focus": [ + "get_scene filters", + "measure_space", + "render" + ] + }, + { + "id": "17-mcp-shelves", + "title": "Five shelves to four, over MCP", + "mode": "mcp", + "seed": "estanteria", + "file": "estanteria.bup", + "request": "En estanteria.bup, ahora quiero que tenga 4 baldas en cada columna en vez de 5, repartidas por igual, manteniendo los ensambles.", + "focus": [ + "MCP", + "distribute", + "render_view" + ] + } + ] +} diff --git a/crates/benchup-cli/evals/fixtures/estanteria.bup b/crates/benchup-cli/evals/fixtures/estanteria.bup new file mode 100644 index 0000000000000000000000000000000000000000..f5477be10e015eab6e3a58674c6e2f81b2a36044 GIT binary patch literal 10369 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 XPn;4jl#YpQ-mq})RH^(H#l`u5)lQ53 literal 0 HcmV?d00001 diff --git a/crates/benchup-cli/evals/mcp_call.py b/crates/benchup-cli/evals/mcp_call.py new file mode 100755 index 0000000..876a91b --- /dev/null +++ b/crates/benchup-cli/evals/mcp_call.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Minimal MCP client for the evaluation: talks JSON-RPC over stdio to +`benchup mcp FILE`, the way an MCP-capable agent would. + + mcp_call.py FILE list tools with their descriptions and schemas + mcp_call.py FILE call TOOL '{"json": 1}' call a tool, print its result + mcp_call.py FILE render VIEW OUT.png render_view: save the image, print what it reports +""" +import base64, json, subprocess, sys + + +def session(path, requests): + lines = [{"jsonrpc": "2.0", "id": 0, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "clientInfo": {"name": "eval"}}}] + lines += [{"jsonrpc": "2.0", "id": i + 1, **r} for i, r in enumerate(requests)] + text = "".join(json.dumps(line) + "\n" for line in lines) + out = subprocess.run(["benchup", "mcp", path], input=text, capture_output=True, text=True) + replies = {m["id"]: m for m in map(json.loads, out.stdout.splitlines()) if "id" in m} + return [replies.get(i + 1) for i in range(len(requests))] + + +def main(argv): + if len(argv) < 3: + sys.exit(__doc__) + path, command = argv[1], argv[2] + if command == "list": + [reply] = session(path, [{"method": "tools/list"}]) + print(json.dumps(reply["result"]["tools"], indent=1, ensure_ascii=False)) + return 0 + if command == "call" and len(argv) in (4, 5): + arguments = json.loads(argv[4]) if len(argv) == 5 else {} + [reply] = session(path, [{"method": "tools/call", "params": {"name": argv[3], "arguments": arguments}}]) + if "error" in reply: + print(json.dumps(reply["error"], indent=1)) + return 2 + result = reply["result"] + print(json.dumps(result.get("structuredContent") or result["content"][0]["text"], indent=1, ensure_ascii=False)) + return 1 if result.get("isError") else 0 + if command == "render" and len(argv) == 5: + [reply] = session(path, [{"method": "tools/call", "params": {"name": "render_view", "arguments": {"view": argv[3]}}}]) + result = reply["result"] + for part in result["content"]: + if part["type"] == "image": + open(argv[4], "wb").write(base64.b64decode(part["data"])) + else: + print(part["text"]) + return 1 if result.get("isError") else 0 + sys.exit(__doc__) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/crates/benchup-cli/evals/prompt.md b/crates/benchup-cli/evals/prompt.md new file mode 100644 index 0000000..123e5d8 --- /dev/null +++ b/crates/benchup-cli/evals/prompt.md @@ -0,0 +1,21 @@ +You are a coding agent being evaluated on how well you can use a tool you have never seen, the way a real user's agent would. A user asked you (in Spanish) the request below. + +USER REQUEST: +"{request}" + +SETUP AND RULES +- Your working folder is {dir} . Work only there. Start EVERY shell command with: export PATH={bin}:$PATH && cd {dir} && +{access} +- Do NOT read any source code, README, docs or file of the BenchUp repository, ~/.cargo, or other evaluation folders ({root}), or anywhere else about benchup. No web searches. A real user would not have them. +- Do NOT run `benchup open` (several evaluations run at once and would fight over the desktop app). Everything else is allowed. +- You cannot ask the user questions. If you would normally ask something, write down the question and the assumption you made, then proceed (or stop if proceeding makes no sense). +- Scripts (python3 etc.) to generate inputs are fine; keep them inside your working folder. +- You can look at PNG images you produce (for example with your file-reading tool) to check the result. + +WHEN DONE, reply with a report (in Spanish) with exactly these sections: +1. Resultado: what you built/changed, and whether you believe it fully satisfies the request (be honest; verify with the tool's own checks). +2. Métricas: total number of {unit} run, number that returned an error, number of scratch/trial runs (including dry runs). +3. Tropiezos: every point where you were stuck, confused, had to guess, needed a workaround, or the tool gave an unhelpful error. Quote the error text. +4. Información que faltaba: what you needed that the help, the guide and the tool schemas did not tell you. +5. Decisiones y supuestos: design or woodworking decisions you made on your own; questions you would have asked the user. +6. Qué habría ayudado: concrete suggestions, split into (a) changes to the tools and (b) guidance the guide should contain or correct (say if anything in the guide was wrong or misleading). diff --git a/crates/benchup-cli/evals/reference/01-example-desk/step1.jsonl b/crates/benchup-cli/evals/reference/01-example-desk/step1.jsonl new file mode 100644 index 0000000..72981e3 --- /dev/null +++ b/crates/benchup-cli/evals/reference/01-example-desk/step1.jsonl @@ -0,0 +1,2 @@ +{"tool": "push_pull", "arguments": {"face_id": 5119, "distance_mm": 200}} +{"tool": "get_object", "arguments": {"id": 5427}} diff --git a/crates/benchup-cli/evals/reference/01-example-desk/step2.jsonl b/crates/benchup-cli/evals/reference/01-example-desk/step2.jsonl new file mode 100644 index 0000000..9dbd0a9 --- /dev/null +++ b/crates/benchup-cli/evals/reference/01-example-desk/step2.jsonl @@ -0,0 +1,6 @@ +{"tool": "push_pull", "arguments": {"face_id": 5119, "distance_mm": 200}} +{"tool": "enter_object", "arguments": {"id": 5430}} +{"tool": "enter_object", "arguments": {"id": 5427}} +{"tool": "draw_polyline", "arguments": {"points_mm": [[649,960,892],[649,960,928]]}} +{"tool": "exit_to_root", "arguments": {}} +{"tool": "get_object", "arguments": {"id": 5427}} diff --git a/crates/benchup-cli/evals/reference/02-four-shelves/batch.jsonl b/crates/benchup-cli/evals/reference/02-four-shelves/batch.jsonl new file mode 100644 index 0000000..0418024 --- /dev/null +++ b/crates/benchup-cli/evals/reference/02-four-shelves/batch.jsonl @@ -0,0 +1,6 @@ +{"tool": "erase", "arguments": {"ids": [450, 715]}} +{"tool": "move", "arguments": {"ids": [238, 503], "delta_mm": [0, 0, 60.6]}} +{"tool": "move", "arguments": {"ids": [291, 556], "delta_mm": [0, 0, 121.2]}} +{"tool": "move", "arguments": {"ids": [344, 609], "delta_mm": [0, 0, 181.8]}} +{"tool": "move", "arguments": {"ids": [397, 662], "delta_mm": [0, 0, 242.4]}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/02-four-shelves/flip.jsonl b/crates/benchup-cli/evals/reference/02-four-shelves/flip.jsonl new file mode 100644 index 0000000..d560b51 --- /dev/null +++ b/crates/benchup-cli/evals/reference/02-four-shelves/flip.jsonl @@ -0,0 +1,2 @@ +{"tool":"flip_joint","arguments":{"ids":[716,717,718,719,720,721,722,723]}} +{"tool":"check_model","arguments":{}} diff --git a/crates/benchup-cli/evals/reference/03-table/mesa.jsonl b/crates/benchup-cli/evals/reference/03-table/mesa.jsonl new file mode 100644 index 0000000..669fb4f --- /dev/null +++ b/crates/benchup-cli/evals/reference/03-table/mesa.jsonl @@ -0,0 +1,21 @@ +{"tool": "create_stock_material", "arguments": {"name": "Listón patas 70x70", "thickness_mm": 70, "width_mm": 70, "length_mm": 2000}} +{"tool": "create_stock_material", "arguments": {"name": "Tabla faldones 22 mm", "thickness_mm": 22, "width_mm": 150, "length_mm": 2500}} +{"tool": "create_stock_material", "arguments": {"name": "Tablero 30 mm", "thickness_mm": 30, "width_mm": 900, "length_mm": 1600}} +{"tool": "create_board", "arguments": {"name": "Pata delantera izquierda", "origin_mm": [30, 30, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Patas"}} +{"tool": "create_board", "arguments": {"name": "Pata delantera derecha", "origin_mm": [1300, 30, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Patas"}} +{"tool": "create_board", "arguments": {"name": "Pata trasera izquierda", "origin_mm": [30, 700, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Patas"}} +{"tool": "create_board", "arguments": {"name": "Pata trasera derecha", "origin_mm": [1300, 700, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Patas"}} +{"tool": "create_board", "arguments": {"name": "Faldón delantero", "origin_mm": [70, 40, 620], "size_mm": [1260, 22, 100], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Faldones"}} +{"tool": "create_board", "arguments": {"name": "Faldón trasero", "origin_mm": [70, 738, 620], "size_mm": [1260, 22, 100], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Faldones"}} +{"tool": "create_board", "arguments": {"name": "Faldón lateral izquierdo", "origin_mm": [40, 70, 620], "size_mm": [22, 660, 100], "grain_axis": "y", "material_id": "$2.material_id", "tag": "Faldones"}} +{"tool": "create_board", "arguments": {"name": "Faldón lateral derecho", "origin_mm": [1338, 70, 620], "size_mm": [22, 660, 100], "grain_axis": "y", "material_id": "$2.material_id", "tag": "Faldones"}} +{"tool": "create_board", "arguments": {"name": "Tablero", "origin_mm": [0, 0, 720], "size_mm": [1400, 800, 30], "grain_axis": "x", "material_id": "$3.material_id", "tag": "Tablero"}} +{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$4.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$5.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$6.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$7.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$10.created[0]", "b": "$4.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$10.created[0]", "b": "$6.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$11.created[0]", "b": "$5.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$11.created[0]", "b": "$7.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/04-drawers/build.jsonl b/crates/benchup-cli/evals/reference/04-drawers/build.jsonl new file mode 100644 index 0000000..85acbd9 --- /dev/null +++ b/crates/benchup-cli/evals/reference/04-drawers/build.jsonl @@ -0,0 +1,29 @@ +{"tool": "create_stock_material", "arguments": {"name": "Tablero 18 mm", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440}} +{"tool": "create_stock_material", "arguments": {"name": "Tablero 12 mm", "thickness_mm": 12, "width_mm": 1220, "length_mm": 2440}} +{"tool": "create_stock_material", "arguments": {"name": "Contrachapado 6 mm", "thickness_mm": 6, "width_mm": 1220, "length_mm": 2440}} +{"tool": "create_board", "arguments": {"name": "Lateral izquierdo", "origin_mm": [0, 0, 0], "size_mm": [18, 450, 700], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Lateral derecho", "origin_mm": [482, 0, 0], "size_mm": [18, 450, 700], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Tapa", "origin_mm": [9, 0, 682], "size_mm": [482, 434, 18], "grain_axis": "x", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Base", "origin_mm": [9, 0, 0], "size_mm": [482, 434, 18], "grain_axis": "x", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Trasera", "origin_mm": [9, 436, 0], "size_mm": [482, 6, 700], "grain_axis": "z", "material_id": "$3.material_id"}} +{"tool": "add_joint", "arguments": {"a": "$6.created[0]", "b": "$4.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$6.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$7.created[0]", "b": "$4.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$7.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$4.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} +{"tool": "create_board", "arguments": {"name": "Frente de cajón", "origin_mm": [20, 0, 20], "size_mm": [460, 18, 218], "grain_axis": "x", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Lateral de cajón izquierdo", "origin_mm": [20, 5, 30], "size_mm": [12, 415, 178], "grain_axis": "y", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Lateral de cajón derecho", "origin_mm": [468, 5, 30], "size_mm": [12, 415, 178], "grain_axis": "y", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Trasera de cajón", "origin_mm": [26, 400, 46], "size_mm": [448, 12, 162], "grain_axis": "x", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Fondo de cajón", "origin_mm": [26, 20, 40], "size_mm": [448, 392, 6], "grain_axis": "x", "material_id": "$3.material_id"}} +{"tool": "add_joint", "arguments": {"a": "$16.created[0]", "b": "$15.created[0]", "kind": "dovetail", "params": {"dovetails": 4}}} +{"tool": "add_joint", "arguments": {"a": "$17.created[0]", "b": "$15.created[0]", "kind": "dovetail", "params": {"dovetails": 4}}} +{"tool": "add_joint", "arguments": {"a": "$18.created[0]", "b": "$16.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$18.created[0]", "b": "$17.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$19.created[0]", "b": "$16.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$19.created[0]", "b": "$17.created[0]", "kind": "tongue_groove"}} +{"tool": "group", "arguments": {"ids": ["$15.created[0]", "$16.created[0]", "$17.created[0]", "$18.created[0]", "$19.created[0]"], "name": "Cajón", "component": true}} +{"tool": "create_copy", "arguments": {"id": "$26.created[0]", "offset_mm": [0, 0, 221]}} +{"tool": "create_copy", "arguments": {"id": "$26.created[0]", "offset_mm": [0, 0, 442]}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/05-back-rabbet/change.jsonl b/crates/benchup-cli/evals/reference/05-back-rabbet/change.jsonl new file mode 100644 index 0000000..2357785 --- /dev/null +++ b/crates/benchup-cli/evals/reference/05-back-rabbet/change.jsonl @@ -0,0 +1,19 @@ +{"tool": "resize_board", "arguments": {"id": 238, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 291, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 344, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 397, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 450, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 503, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 556, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 609, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 662, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 715, "size_mm": [482, 292, 18], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 185, "size_mm": [18, 294, 1800], "anchor": "min"}} +{"tool": "create_board", "arguments": {"name": "Trasera", "origin_mm": [9, 294, 0], "size_mm": [982, 6, 1800], "grain_axis": "z"}} +{"tool": "add_joint", "arguments": {"a": "$12.created[0]", "b": 79, "kind": "rabbet"}} +{"tool": "add_joint", "arguments": {"a": "$12.created[0]", "b": 132, "kind": "rabbet"}} +{"tool": "create_board", "arguments": {"name": "Zócalo izquierdo", "origin_mm": [9, 20, 0], "size_mm": [482, 18, 80], "grain_axis": "x"}} +{"tool": "create_board", "arguments": {"name": "Zócalo derecho", "origin_mm": [509, 20, 0], "size_mm": [482, 18, 80], "grain_axis": "x"}} +{"tool": "add_joint", "arguments": {"a": "$15.created[0]", "b": 79, "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$16.created[0]", "b": 132, "kind": "tongue_groove"}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/05-back-rabbet/flip.jsonl b/crates/benchup-cli/evals/reference/05-back-rabbet/flip.jsonl new file mode 100644 index 0000000..2c9cfcc --- /dev/null +++ b/crates/benchup-cli/evals/reference/05-back-rabbet/flip.jsonl @@ -0,0 +1,2 @@ +{"tool":"flip_joint","arguments":{"ids":[716,717,718,719,720,721,722,723,724,725]}} +{"tool":"check_model","arguments":{}} diff --git a/crates/benchup-cli/evals/reference/06-oak/flip.jsonl b/crates/benchup-cli/evals/reference/06-oak/flip.jsonl new file mode 100644 index 0000000..affd611 --- /dev/null +++ b/crates/benchup-cli/evals/reference/06-oak/flip.jsonl @@ -0,0 +1,2 @@ +{"tool": "flip_joint", "arguments": {"ids": [716,717,718,719,720,721,722,723,724,725]}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/06-oak/oak.jsonl b/crates/benchup-cli/evals/reference/06-oak/oak.jsonl new file mode 100644 index 0000000..cc12486 --- /dev/null +++ b/crates/benchup-cli/evals/reference/06-oak/oak.jsonl @@ -0,0 +1,17 @@ +{"tool": "create_stock_material", "arguments": {"name": "Roble macizo 19 mm", "thickness_mm": 19, "width_mm": 300, "length_mm": 2500}} +{"tool": "resize_board", "arguments": {"id": 79, "size_mm": [19, 300, 1800], "anchor": "min"}} +{"tool": "resize_board", "arguments": {"id": 132, "size_mm": [19, 300, 1800], "anchor": ["max", "min", "min"]}} +{"tool": "resize_board", "arguments": {"id": 185, "size_mm": [19, 300, 1800], "anchor": ["center", "min", "min"]}} +{"tool": "resize_board", "arguments": {"id": 238, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "resize_board", "arguments": {"id": 291, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "resize_board", "arguments": {"id": 344, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "resize_board", "arguments": {"id": 397, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "resize_board", "arguments": {"id": 450, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "resize_board", "arguments": {"id": 503, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "resize_board", "arguments": {"id": 556, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "resize_board", "arguments": {"id": 609, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "resize_board", "arguments": {"id": 662, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "resize_board", "arguments": {"id": 715, "size_mm": [481, 300, 19], "anchor": ["center", "min", "center"]}} +{"tool": "set_material", "arguments": {"ids": [79, 132, 185, 238, 291, 344, 397, 450, 503, 556, 609, 662, 715], "material_id": "$1.material_id"}} +{"tool": "check_model", "arguments": {}} +{"tool": "get_cut_list", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/07-curved-side/build.jsonl b/crates/benchup-cli/evals/reference/07-curved-side/build.jsonl new file mode 100644 index 0000000..0aa6dfe --- /dev/null +++ b/crates/benchup-cli/evals/reference/07-curved-side/build.jsonl @@ -0,0 +1,25 @@ +{"tool": "create_stock_material", "arguments": {"name": "Tablero 18 mm", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440}} +{"tool": "create_stock_material", "arguments": {"name": "Tablero 6 mm (trasera)", "thickness_mm": 6, "width_mm": 1220, "length_mm": 2440}} +{"tool": "draw_polyline", "arguments": {"points_mm": [[10.0, 0, 532.0], [440.0, 0, 532.0], [450.0, 10.0, 532.0], [450.0, 350.0, 532.0], [0, 350.0, 532.0], [0, 10.0, 532.0]], "close": true}} +{"tool": "push_pull", "arguments": {"face_id": "$3.face_id", "distance_mm": 18.0}} +{"tool": "group", "arguments": {"ids": ["$3.created", "$4.created"], "name": "Tapa", "component": false}} +{"tool": "set_grain", "arguments": {"id": "$5.created[0]", "axis": "x"}} +{"tool": "draw_polyline", "arguments": {"points_mm": [[0.0, 20.0, 0], [0.0, 350.0, 0], [0.0, 350.0, 541.0], [0.0, 20.0, 541.0], [0.0, 20.0, 532.0], [0.0, 23.333, 520.917], [0.0, 26.512, 509.833], [0.0, 29.539, 498.75], [0.0, 32.416, 487.667], [0.0, 35.143, 476.583], [0.0, 37.723, 465.5], [0.0, 40.157, 454.417], [0.0, 42.445, 443.333], [0.0, 44.589, 432.25], [0.0, 46.59, 421.167], [0.0, 48.45, 410.083], [0.0, 50.168, 399.0], [0.0, 51.745, 387.917], [0.0, 53.183, 376.833], [0.0, 54.483, 365.75], [0.0, 55.643, 354.667], [0.0, 56.666, 343.583], [0.0, 57.552, 332.5], [0.0, 58.301, 321.417], [0.0, 58.913, 310.333], [0.0, 59.389, 299.25], [0.0, 59.728, 288.167], [0.0, 59.932, 277.083], [0.0, 60.0, 266.0], [0.0, 59.932, 254.917], [0.0, 59.728, 243.833], [0.0, 59.389, 232.75], [0.0, 58.913, 221.667], [0.0, 58.301, 210.583], [0.0, 57.552, 199.5], [0.0, 56.666, 188.417], [0.0, 55.643, 177.333], [0.0, 54.483, 166.25], [0.0, 53.183, 155.167], [0.0, 51.745, 144.083], [0.0, 50.168, 133.0], [0.0, 48.45, 121.917], [0.0, 46.59, 110.833], [0.0, 44.589, 99.75], [0.0, 42.445, 88.667], [0.0, 40.157, 77.583], [0.0, 37.723, 66.5], [0.0, 35.143, 55.417], [0.0, 32.416, 44.333], [0.0, 29.539, 33.25], [0.0, 26.512, 22.167], [0.0, 23.333, 11.083]], "close": true}} +{"tool": "push_pull", "arguments": {"face_id": "$7.face_id", "distance_mm": 18.0}} +{"tool": "group", "arguments": {"ids": ["$7.created", "$8.created"], "name": "Lateral izquierdo", "component": false}} +{"tool": "set_grain", "arguments": {"id": "$9.created[0]", "axis": "z"}} +{"tool": "create_copy", "arguments": {"id": "$9.created[0]", "offset_mm": [432.0, 0, 0]}} +{"tool": "rename", "arguments": {"id": "$11.created[0]", "name": "Lateral derecho"}} +{"tool": "create_board", "arguments": {"name": "Fondo", "origin_mm": [9.0, 44, 50.0], "size_mm": [432.0, 298.0, 18.0], "grain_axis": "x", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Balda", "origin_mm": [9.0, 65, 291.0], "size_mm": [432.0, 277.0, 18.0], "grain_axis": "x", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Trasera", "origin_mm": [9.0, 344.0, 0], "size_mm": [432.0, 6.0, 530.0], "grain_axis": "z", "material_id": "$2.material_id"}} +{"tool": "set_material", "arguments": {"ids": ["$5.created[0]", "$9.created[0]", "$11.created[0]"], "material_id": "$1.material_id"}} +{"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$11.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$13.created[0]", "b": "$9.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$13.created[0]", "b": "$11.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$14.created[0]", "b": "$9.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$14.created[0]", "b": "$11.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$15.created[0]", "b": "$9.created[0]", "kind": "rabbet"}} +{"tool": "add_joint", "arguments": {"a": "$15.created[0]", "b": "$11.created[0]", "kind": "rabbet"}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/08-ladder-shelf/batch.jsonl b/crates/benchup-cli/evals/reference/08-ladder-shelf/batch.jsonl new file mode 100644 index 0000000..4989d16 --- /dev/null +++ b/crates/benchup-cli/evals/reference/08-ladder-shelf/batch.jsonl @@ -0,0 +1,24 @@ +{"tool": "create_stock_material", "arguments": {"name": "Madera maciza 30 mm (largueros)", "thickness_mm": 30, "width_mm": 400, "length_mm": 2200}} +{"tool": "create_stock_material", "arguments": {"name": "Tablero 18 mm (baldas)", "thickness_mm": 18, "width_mm": 400, "length_mm": 2400}} +{"tool": "draw_polyline", "arguments": {"points_mm": [[0.0, -367.0518, -98.3512], [0.0, 0.0, 0.0], [0.0, 0.0, 1863.4971], [0.0, -149.7185, 1823.3802]], "close": true}} +{"tool": "push_pull", "arguments": {"face_id": "$3.face_id", "distance_mm": 30.0}} +{"tool": "group", "arguments": {"ids": ["$3.created", "$4.created"], "name": "Larguero izquierdo", "component": false}} +{"tool": "set_grain", "arguments": {"id": "$5.created[0]", "axis": "z"}} +{"tool": "set_material", "arguments": {"ids": ["$5.created[0]"], "material_id": "$1.material_id"}} +{"tool": "rotate", "arguments": {"ids": ["$5.created[0]"], "pivot_mm": [0, 0, 0], "axis": "x", "degrees": -15}} +{"tool": "create_copy", "arguments": {"id": "$5.created[0]", "offset_mm": [600.0, 0, 0]}} +{"tool": "rename", "arguments": {"id": "$9.created[0]", "name": "Larguero derecho"}} +{"tool": "tag", "arguments": {"ids": ["$5.created[0]", "$9.created[0]"], "tag": "Largueros"}} +{"tool": "create_board", "arguments": {"name": "Balda 1", "origin_mm": [15.0, -305.81, 150], "size_mm": [600.0, 340, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Baldas"}} +{"tool": "create_board", "arguments": {"name": "Balda 2", "origin_mm": [15.0, -117.19, 630], "size_mm": [600.0, 280, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Baldas"}} +{"tool": "create_board", "arguments": {"name": "Balda 3", "origin_mm": [15.0, 71.42, 1110], "size_mm": [600.0, 220, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Baldas"}} +{"tool": "create_board", "arguments": {"name": "Balda 4", "origin_mm": [15.0, 260.04, 1590], "size_mm": [600.0, 160, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Baldas"}} +{"tool": "add_joint", "arguments": {"a": "$12.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$12.created[0]", "b": "$9.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$13.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$13.created[0]", "b": "$9.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$14.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$14.created[0]", "b": "$9.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$15.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$15.created[0]", "b": "$9.created[0]", "kind": "tongue_groove"}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/09-dimensions/batch.jsonl b/crates/benchup-cli/evals/reference/09-dimensions/batch.jsonl new file mode 100644 index 0000000..d771c47 --- /dev/null +++ b/crates/benchup-cli/evals/reference/09-dimensions/batch.jsonl @@ -0,0 +1,17 @@ +{"tool": "add_dimension", "arguments": {"start_mm": [0, 0, 0], "end_mm": [0, 0, 1800], "axis": "z", "offset_mm": [-250, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [0, 0, 1800], "end_mm": [1000, 0, 1800], "axis": "x", "offset_mm": [0, 0, 250]}} +{"tool": "add_dimension", "arguments": {"start_mm": [1000, 0, 1800], "end_mm": [1000, 300, 1800], "axis": "y", "offset_mm": [0, 0, 150]}} +{"tool": "add_dimension", "arguments": {"start_mm": [18, 0, 0], "end_mm": [18, 0, 285], "axis": "z", "offset_mm": [-100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [18, 0, 303], "end_mm": [18, 0, 588], "axis": "z", "offset_mm": [-100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [18, 0, 606], "end_mm": [18, 0, 891], "axis": "z", "offset_mm": [-100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [18, 0, 909], "end_mm": [18, 0, 1194], "axis": "z", "offset_mm": [-100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [18, 0, 1212], "end_mm": [18, 0, 1497], "axis": "z", "offset_mm": [-100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [18, 0, 1515], "end_mm": [18, 0, 1800], "axis": "z", "offset_mm": [-100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [982, 0, 0], "end_mm": [982, 0, 285], "axis": "z", "offset_mm": [100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [982, 0, 303], "end_mm": [982, 0, 588], "axis": "z", "offset_mm": [100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [982, 0, 606], "end_mm": [982, 0, 891], "axis": "z", "offset_mm": [100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [982, 0, 909], "end_mm": [982, 0, 1194], "axis": "z", "offset_mm": [100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [982, 0, 1212], "end_mm": [982, 0, 1497], "axis": "z", "offset_mm": [100, 0, 0]}} +{"tool": "add_dimension", "arguments": {"start_mm": [982, 0, 1515], "end_mm": [982, 0, 1800], "axis": "z", "offset_mm": [100, 0, 0]}} +{"tool": "add_text", "arguments": {"point_mm": [500.0, 0, 1800], "offset_mm": [0, 0, 500], "text": "Estantería"}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/10-organize/organize.jsonl b/crates/benchup-cli/evals/reference/10-organize/organize.jsonl new file mode 100644 index 0000000..ffc5c0c --- /dev/null +++ b/crates/benchup-cli/evals/reference/10-organize/organize.jsonl @@ -0,0 +1,15 @@ +{"tool": "rename", "arguments": {"id": 238, "name": "Balda"}} +{"tool": "rename", "arguments": {"id": 291, "name": "Balda"}} +{"tool": "rename", "arguments": {"id": 344, "name": "Balda"}} +{"tool": "rename", "arguments": {"id": 397, "name": "Balda"}} +{"tool": "rename", "arguments": {"id": 450, "name": "Balda"}} +{"tool": "rename", "arguments": {"id": 503, "name": "Balda"}} +{"tool": "rename", "arguments": {"id": 556, "name": "Balda"}} +{"tool": "rename", "arguments": {"id": 609, "name": "Balda"}} +{"tool": "rename", "arguments": {"id": 662, "name": "Balda"}} +{"tool": "rename", "arguments": {"id": 715, "name": "Balda"}} +{"tool": "tag", "arguments": {"ids": [238, 291, 344, 397, 450, 503, 556, 609, 662, 715], "tag": "Baldas"}} +{"tool": "tag", "arguments": {"ids": [79, 132, 185], "tag": "Estructura"}} +{"tool": "group", "arguments": {"ids": [79, 238, 291, 344, 397, 450], "name": "Módulo izquierdo", "component": false}} +{"tool": "group", "arguments": {"ids": [132, 503, 556, 609, 662, 715], "name": "Módulo derecho", "component": false}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/11-cut-material/batch.jsonl b/crates/benchup-cli/evals/reference/11-cut-material/batch.jsonl new file mode 100644 index 0000000..0635e3c --- /dev/null +++ b/crates/benchup-cli/evals/reference/11-cut-material/batch.jsonl @@ -0,0 +1,4 @@ +{"tool": "create_stock_material", "arguments": {"name": "Tablero 18 mm 2440x1220", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440}} +{"tool": "set_material", "arguments": {"ids": [79,132,185,238,291,344,397,450,503,556,609,662,715], "material_id": "$1.material_id"}} +{"tool": "get_cut_list", "arguments": {}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/12-impossible/alt.jsonl b/crates/benchup-cli/evals/reference/12-impossible/alt.jsonl new file mode 100644 index 0000000..e34f9f5 --- /dev/null +++ b/crates/benchup-cli/evals/reference/12-impossible/alt.jsonl @@ -0,0 +1,4 @@ +{"tool": "create_board", "arguments": {"origin_mm": [9, 0, 1000], "size_mm": [491, 300, 18], "name": "Balda izquierda 1000", "grain_axis": "x"}} +{"tool": "add_joint", "arguments": {"a": "$1.created[0]", "b": 79, "kind": "tongue_groove"}} +{"tool": "add_joint", "arguments": {"a": "$1.created[0]", "b": 185, "kind": "tongue_groove"}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/12-impossible/literal.jsonl b/crates/benchup-cli/evals/reference/12-impossible/literal.jsonl new file mode 100644 index 0000000..cd5c8e4 --- /dev/null +++ b/crates/benchup-cli/evals/reference/12-impossible/literal.jsonl @@ -0,0 +1,3 @@ +{"tool": "create_board", "arguments": {"origin_mm": [9, 0, 1000], "size_mm": [1200, 300, 18], "name": "Balda izquierda 1000"}} +{"tool": "set_grain", "arguments": {"id": "$1.created[0]", "axis": "x"}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/13-router/profiles.jsonl b/crates/benchup-cli/evals/reference/13-router/profiles.jsonl new file mode 100644 index 0000000..0884f17 --- /dev/null +++ b/crates/benchup-cli/evals/reference/13-router/profiles.jsonl @@ -0,0 +1,13 @@ +{"tool": "flip_joint", "arguments": {"ids": [716, 717, 718, 719, 720, 721, 722, 723, 724, 725]}} +{"tool": "add_profile", "arguments": {"id": 238, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 291, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 344, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 397, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 450, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 503, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 556, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 609, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 662, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 715, "between": [["+z", "-y"]], "profile": "chamfer", "width_mm": 2}} +{"tool": "add_profile", "arguments": {"id": 185, "between": [["-y", "-x"], ["-y", "+x"]], "profile": "roundover", "width_mm": 3}} +{"tool": "check_model", "arguments": {"only_new": true}} diff --git a/crates/benchup-cli/evals/reference/14-trim/bench.jsonl b/crates/benchup-cli/evals/reference/14-trim/bench.jsonl new file mode 100644 index 0000000..7b0252d --- /dev/null +++ b/crates/benchup-cli/evals/reference/14-trim/bench.jsonl @@ -0,0 +1,21 @@ +{"tool": "create_stock_material", "arguments": {"name": "Haya 50x50", "thickness_mm": 50, "width_mm": 50, "length_mm": 2000}} +{"tool": "create_stock_material", "arguments": {"name": "Haya 22 mm", "thickness_mm": 22, "width_mm": 200, "length_mm": 2500}} +{"tool": "create_board", "arguments": {"name": "Pata delantera izquierda", "origin_mm": [0, 0, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Pata delantera derecha", "origin_mm": [1150, 0, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Pata trasera izquierda", "origin_mm": [0, 300, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Pata trasera derecha", "origin_mm": [1150, 300, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Travesaño delantero", "origin_mm": [-30, 14, 300], "size_mm": [1260, 22, 70], "grain_axis": "x", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Travesaño trasero", "origin_mm": [-30, 314, 300], "size_mm": [1260, 22, 70], "grain_axis": "x", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Travesaño lateral izquierdo", "origin_mm": [14, -30, 200], "size_mm": [22, 410, 70], "grain_axis": "y", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Travesaño lateral derecho", "origin_mm": [1164, -30, 200], "size_mm": [22, 410, 70], "grain_axis": "y", "material_id": "$2.material_id"}} +{"tool": "create_stock_material", "arguments": {"name": "Haya 25 mm", "thickness_mm": 25, "width_mm": 400, "length_mm": 2500}} +{"tool": "create_board", "arguments": {"name": "Asiento", "origin_mm": [0, 0, 425], "size_mm": [1200, 350, 25], "grain_axis": "x", "material_id": "$11.material_id"}} +{"tool": "add_joint", "arguments": {"a": "$7.created[0]", "b": "$3.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "upper"}}} +{"tool": "add_joint", "arguments": {"a": "$7.created[0]", "b": "$4.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "lower"}}} +{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$5.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "upper"}}} +{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$6.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "lower"}}} +{"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$3.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "upper"}}} +{"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$5.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "lower"}}} +{"tool": "add_joint", "arguments": {"a": "$10.created[0]", "b": "$4.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "upper"}}} +{"tool": "add_joint", "arguments": {"a": "$10.created[0]", "b": "$6.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "lower"}}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/15-finish/finish.jsonl b/crates/benchup-cli/evals/reference/15-finish/finish.jsonl new file mode 100644 index 0000000..16bc97e --- /dev/null +++ b/crates/benchup-cli/evals/reference/15-finish/finish.jsonl @@ -0,0 +1,2 @@ +{"tool": "set_finish", "arguments": {"ids": [79, 132, 185, 238, 291, 344, 397, 450, 503, 556, 609, 662, 715], "sanding_grits": [120, 180], "varnish": {"coats": 2, "sheen": "satin"}}} +{"tool": "get_estimate", "arguments": {}} diff --git a/crates/benchup-cli/evals/results/round-1-4.md b/crates/benchup-cli/evals/results/round-1-4.md new file mode 100644 index 0000000..d5a832f --- /dev/null +++ b/crates/benchup-cli/evals/results/round-1-4.md @@ -0,0 +1,21 @@ +# Rounds 1–4 (12 November 2026) + +Before the tools of [#42](https://github.com/BenchUp3D/benchup/issues/42). Each cell is the number of `benchup` commands the agent ran, with the commands that returned an error in brackets. Rounds 1–4 used the 12 original cases; the fixes between rounds are in the history of PR #41. + +| # | Case | R1 | R2 | R3 | R4 | Result in R4 | +|---|---|---|---|---|---|---| +| 01 | Lengthen the desk | 43 (3) | 27 (0) | 19 (1) | ~61 (0) | ✅, a more ambitious solution; 36 commands spent finding the walls one by one | +| 02 | Five shelves to four | 21 (3) | 22 (0) | 16 (0) | 14 (0) | ✅, with `flip_joint` | +| 03 | Dining table | 22 (0) | 18 (0) | 15 (0) | 15 (0) | ✅ | +| 04 | Chest of drawers | 46 (3) | 32 (9) | 19 (0) | 14 (0) | ✅, one `--dry-run` | +| 05 | Back in a rabbet and plinth | 14 (0) | 27 (5) | 17 (0) | 18 (0) | ✅ | +| 06 | Solid oak 19 mm | 24 (3) | 25 (0) | 17 (0) | 17 (1) | ✅ | +| 07 | Bedside table with curved sides | ~261 (35) | 26 (1) | 24 (0) | 36 (4) | ✅, hit the core's precision limit (32 or 40 arc segments) | +| 08 | Leaning ladder shelf | ~91 (16) | 45 (1) | 32 (0) | 22 (0) | ✅, first try | +| 09 | Workshop dimensions | 21 (1) | 13 (0) | 12 (0) | 11 (0) | ✅, with `axis` | +| 10 | Modules, components and tags | ~142 (8) | 31 (5) | 27 (2) | 19 (~2) | ⚠️ partial on purpose (housed shelves cannot be one component) | +| 11 | Cut list and sheets | 8 (0) | 12 (0) | 8 (0) | 8 (0) | ✅ | +| 12 | A shelf that does not fit | 8 (0) | 7 (0) | 12 (0) | 10 (0) | ✅, with `passes_through` | +| | **Total** | **~701 (72)** | **285 (21)** | **218 (3)** | **~245 (~7)** | 11 ✅, 1 ⚠️ | + +What remained after round 4 became [#42](https://github.com/BenchUp3D/benchup/issues/42). diff --git a/crates/benchup-cli/evals/results/round-5.md b/crates/benchup-cli/evals/results/round-5.md new file mode 100644 index 0000000..fe3c588 --- /dev/null +++ b/crates/benchup-cli/evals/results/round-5.md @@ -0,0 +1,69 @@ +# Round 5 (14 November 2026) + +First round with the tools of [#42](https://github.com/BenchUp3D/benchup/issues/42) (router profiles, joint trimming and `set_joint`, finishes and `get_estimate`, `create_profile_board`, `split_face`/`merge_faces`, `distribute`, `change_thickness`, scene filters, `measure_space`/`find_surfaces`, joint geometry, `check_model only_new`, `render`/`render_view`) and with five new cases (13–17). Each cell is the number of commands the agent ran (MCP calls for case 17), with those that returned an error in brackets. Results were checked independently with `check_model` and an iso render of each final file. + +| # | Case | R4 | R5 | Result in R5 | `focus` tools used | +|---|---|---|---|---|---| +| 01 | Lengthen the desk | ~61 (0) | **23 (1)** | ✅ lengthened at the front, notches kept | `split_face`, `push_pull`, `merge_faces`, `measure_space` | +| 02 | Five shelves to four | 14 (0) | 17 (0) | ✅ equal gaps of 345.6 mm, joints flipped in a separate batch | `distribute`, `flip_joint` | +| 03 | Dining table | 15 (0) | 18 (0) | ✅ 8/8 joints | `create_board`, `add_joint`, `render` | +| 04 | Chest of drawers | 14 (0) | 20 (0) | ✅ 24/24 joints, one component | `group`, `create_copy`, `render` | +| 05 | Back in a rabbet and plinth | 18 (0) | 43 (2) | ⚠️ builds and checks, but the `rabbet` joint is not the rabbet the user means (see findings) | `add_joint`, `set_joint`, `render` | +| 06 | Solid oak 19 mm | 17 (1) | 25 (3) | ✅ after working around two `change_thickness` bugs | `change_thickness` | +| 07 | Bedside table with curved sides | 36 (4) | **16 (0)** | ✅ first try, 6/6 joints, 2 profiles | `create_profile_board`, `add_profile` | +| 08 | Leaning ladder shelf | 22 (0) | 27 (1) | ✅ | `create_profile_board`, `rotate`, `render` | +| 09 | Workshop dimensions | 11 (0) | 18 (0) | ✅ no overlapping labels in the front view | `add_dimension`, `render` | +| 10 | Modules, components and tags | 19 (~2) | 25 (2) | ⚠️ partial on purpose, as the guide says (housed shelves cannot be one component); alternative saved | `group`, `tag` | +| 11 | Cut list and sheets | 8 (0) | 12 (0) | ✅ 2 sheets, checked by hand layout | `get_cut_list` | +| 12 | A shelf that does not fit | 10 (0) | 14 (0) | ✅ refused, 491 mm alternative saved | `measure_space`, `check_model only_new` | +| | **Total 01–12** | **~245 (~7)** | **258 (9)** | 10 ✅, 2 ⚠️ | | +| 13 | Edge profiles | — | 13 (0) | ✅ 12 profiles | `add_profile` | +| 14 | Bench with through tenons | — | 15 (0) | ✅ 8/8 joints; sized the rails flush instead of trimming, so `trim` was not exercised | `add_joint` | +| 15 | Finish and time | — | 13 (0) | ✅ ~4 h 50 min work, 30 h drying | `set_finish`, `get_estimate` | +| 16 | Shelf above the desk, checked visually | — | 20 (0) | ✅ notched around the posts, no clash, 4 renders | `get_scene` filters, `measure_space`, `render` | +| 17 | Five shelves to four, over MCP | — | 16 (0) | ✅ same result as 02 | `distribute`, `render_view` | + +The total for 01–12 is flat, but it hides two opposite moves. The cases the new tools were made for dropped sharply: 01 (61 → 23, no more probing walls one by one) and 07 (36 → 16, first try). Elsewhere agents now spend 2–5 commands on `render` to look at their result, which they could not do before, and two cases grew because of real bugs or a misleading guide row: 05 (rabbet geometry, 43) and 06 (`change_thickness`, 25). 13–17 all succeeded with no tool errors. + +## Findings + +### Tool bugs (benchup-tools) +1. **`merge_faces` does not open the board's group** like the other editing tools; it fails with "not editable in the current scope" unless wrapped in `enter_object` (01). +2. **`change_thickness`** decides which board goes into which by the joint's `a`/`b` roles: with reversed joints it silently adjusts nothing. It also ignores boards that only butt against a thickened board, which then overlap it (06). +3. **`check_model only_new`** compares issues by ids; `group` changes member ids, so existing issues are reported as new (10). +4. **`cut_in_b`** in joint geometry describes the overlap region, not the mortise or groove actually cut, so a closed mortise reads as `stopped` (14). +5. **`render`**: dimensions with `axis` are drawn diagonally between their points instead of along the axis (08); text notes are drawn as a marker without their content, and the output does not say so (03, 08, 09); arc facets are drawn as edges and look toothed (07). +6. **Failed batch output** buries the error after a long `earlier_results`, and `--brief` does not trim `earlier_results` (01, 06). +7. **`set_grain`** takes only `id`, unlike `set_material` and `tag` (08). +8. **`split_face`** returns its pieces in an undocumented order (01). +9. **`check_model`** ignores router profiles when looking for overlaps (05). + +### Tool improvements asked for +- `get_estimate`: totals per category (finishing, joinery, router) and elapsed time (work + wait); the step defaults in the `set_finish` schema (15). +- `measure_space`: say what `null` means (nothing in that direction) (02, 16). +- `render`: zoom/region, hide or isolate objects, several views per call (04, 05, 13); document the 2400 px limit (05). +- `distribute`: a `round_mm` option; changing the number of shelves in one call (02, 17). +- `get_object` on a board: whether it is shaped or notched (16). +- `check_model only_new`: summarise existing issues by kind (09). + +### MCP +- **No batch, dry run or undo over MCP** (17). Each call writes the file; the agent made backup copies by hand. The guide is written for the CLI (`--batch`, `--dry-run`, `$N.field`, `benchup tools`) and is misleading over MCP. Proposal: MCP tools `run_batch {calls, dry_run}` and `undo`, and a short MCP section in the guide. + +### Guide +- **"Back panel in a rabbet" is misleading** (05): the `rabbet` joint cuts both parts and leaves a 3 mm lip behind the back, not an open rabbet in the side. Explain it, or recommend a router `rabbet` profile on the sides' back edges with the back left whole. +- A table per plane of `create_profile_board`: which side it is seen from and where u and v point (07). +- `distribute` limits when there is no top or bottom; distribute each column separately (02, 17). +- The tension between "fixing pre-existing problems is reasonable" and "do not depart from the request" when no one can be asked (02, 05, 13). +- Drawer back joint, and whether it may sit on the bottom when both joints meet in one plane (04). +- `change_thickness` needs correct joint roles; butting parts need a check (06, until fixed). +- `render` does not draw text content (until fixed). + +### Core limits +- **A router profile on a side with five through grooves fails** ("Each body must be a closed solid"), while one groove works. Found while writing the reference for case 13; needs a minimal reproduction before an issue. +- A classic rabbet joint (only `b` cut, `a` whole) does not exist; the `rabbet` kind cuts both parts (05). + +### Missing features +- Cutting plan from the CLI (11; still out of scope, see #42). +- A dry-run render (render the result of a batch without applying it) (12). +- Moving an object between groups (`reparent`) (10). +- Reading the guide by section; it is about 38 KB with the tool list (11). diff --git a/crates/benchup-cli/evals/results/round-6.md b/crates/benchup-cli/evals/results/round-6.md new file mode 100644 index 0000000..4a7781f --- /dev/null +++ b/crates/benchup-cli/evals/results/round-6.md @@ -0,0 +1,30 @@ +# Round 6 (14 November 2026) + +A short round to confirm the fixes made after [round 5](round-5.md), on the six cases where those findings came from. Each cell is the number of commands (MCP calls for 17), with errors in brackets; results checked independently with `check_model` and an iso render. + +| # | Case | R5 | R6 | Result in R6 | +|---|---|---|---|---| +| 01 | Lengthen the desk | 23 (1) | 32 (1) | ✅ `merge_faces` worked inside the group without `enter_object`; the agent also filled the front corner | +| 05 | Back in a rabbet and plinth | 43 (2) ⚠️ | **23 (2)** | ✅ routed rabbet with the back whole, as the guide now says; the errors were a deliberate `reverse` test | +| 06 | Solid oak 19 mm | 25 (3) | **20 (1)** | ✅ `change_thickness` adjusted the shelves and the butting divider without workarounds | +| 08 | Leaning ladder shelf | 27 (1) | 24 (0) | ✅ dimensions along their axis and text readable in the render | +| 10 | Modules, components and tags | 25 (2) ⚠️ | 25 (1) ⚠️ | partial on purpose, as in every round; `only_new` no longer reports existing issues after grouping | +| 17 | Five shelves to four, over MCP | 16 (0) | **11 (0)** | ✅ used `run_batch` with a dry run and a separate batch for the joint fix, no manual backups | +| | **Total** | **159 (9)** | **135 (5)** | 5 ✅, 1 ⚠️ | + +## Fixed after this round + +- A one-call `--batch` answered with the bare result instead of `results`; `--batch` and `run_batch` now always answer with `results` (05). +- `get_scene{sections}` accepts `stock_catalog`, the name the output uses, as well as `stock` (06). +- `merge_faces` accepts a board's id inside `ids` (01). +- `add_joint` on a component instance explains that the board inside can only be joined within the instance (10). +- `ungroup` lists the released objects in `members` (10). +- `benchup tools --names` lists the tool names only; the full list is cut off in agent consoles (08). + +## Still open + +- `add_profile` does not say which face got `width_mm` and which `depth_mm` (05). +- A router profile that cannot be cut does not name the joint or profile it collides with (05). +- Blind (stopped) tongue and groove joints are not available in the core (17). +- `distribute` with a `round_mm` option; changing the number of shelves in one call (17). +- `render`: sections or hidden edges to see joints inside parts (08). diff --git a/crates/benchup-cli/evals/setup.py b/crates/benchup-cli/evals/setup.py new file mode 100755 index 0000000..9f71d7d --- /dev/null +++ b/crates/benchup-cli/evals/setup.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Prepare a round of the agent evaluation (see README.md). + + python3 crates/benchup-cli/evals/setup.py --round 5 [--cases 01,13] [--binary target/release/benchup] + +Creates /tmp/benchup-eval/ with the binary in bin/, one folder per +case with its seed project, and prompts/.md with the exact prompt to +give each agent. Prints a JSON list of {id, dir, prompt}. Run it from the +repository root; it never builds anything. +""" +import argparse, json, os, shutil, sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +CLI_ACCESS = ("- The `benchup` CLI is on that PATH. Learn it ONLY from `benchup --help`, what the help points you to " + "(e.g. `benchup guide`, `benchup tools`, `benchup tools `) and the outputs/errors it gives you.") +MCP_ACCESS = ("- You reach the project ONLY through BenchUp's MCP server, as an MCP client would. Use the client " + "`python3 {bin}/mcp_call.py FILE list` (tools and schemas), `... FILE call TOOL 'JSON'` and " + "`... FILE render VIEW OUT.png`. Start with the server's get_guide tool. Do NOT run the `benchup` " + "command directly.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--round", required=True, type=int) + parser.add_argument("--cases", help="comma-separated case number prefixes, e.g. 01,13 (default: all)") + parser.add_argument("--binary", default=str(ROOT / "target/release/benchup")) + parser.add_argument("--out", help="default /tmp/benchup-eval") + args = parser.parse_args() + binary = Path(args.binary) + if not binary.is_file(): + sys.exit(f"no benchup binary at {binary}; build it with `cargo build --release -p benchup-cli` first") + manifest = json.loads((HERE / "cases.json").read_text()) + wanted = args.cases.split(",") if args.cases else None + cases = [c for c in manifest["cases"] if not wanted or any(c["id"].startswith(w) for w in wanted)] + out = Path(args.out or f"/tmp/benchup-eval{args.round}") + if out.exists(): + shutil.rmtree(out) + (out / "bin").mkdir(parents=True) + (out / "prompts").mkdir() + shutil.copy2(binary, out / "bin/benchup") + shutil.copy2(HERE / "mcp_call.py", out / "bin/mcp_call.py") + template = (HERE / "prompt.md").read_text() + listing = [] + for case in cases: + folder = out / case["id"] + folder.mkdir() + seed = manifest["seeds"][case["seed"]] + if seed: + shutil.copy2(ROOT / seed, folder / case["file"]) + mcp = case["mode"] == "mcp" + prompt = template.format( + request=case["request"], dir=folder, bin=out / "bin", root=out.parent / "benchup-eval*", + access=(MCP_ACCESS if mcp else CLI_ACCESS).format(bin=out / "bin"), + unit="MCP tool calls" if mcp else "benchup commands", + ) + if not seed: + prompt = prompt.replace("Work only there.", "Work only there. The project does not exist yet; create it there.") + path = out / "prompts" / f"{case['id']}.md" + path.write_text(prompt) + listing.append({"id": case["id"], "title": case["title"], "dir": str(folder), "prompt": str(path)}) + print(json.dumps(listing, indent=1, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/crates/benchup-cli/tests/evals.rs b/crates/benchup-cli/tests/evals.rs new file mode 100644 index 0000000..30b0594 --- /dev/null +++ b/crates/benchup-cli/tests/evals.rs @@ -0,0 +1,73 @@ +//! Replays the reference solutions of the agent evaluation cases +//! (`evals/cases.json`) with the real binary, so a change to the tools that +//! breaks what agents did successfully fails here, without running agents. +//! See `evals/README.md`. +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn benchup(args: &[&str]) -> (Value, bool) { + let output = Command::new(env!("CARGO_BIN_EXE_benchup")).args(args).output().expect("run benchup"); + let value = serde_json::from_slice(&output.stdout).unwrap_or_else(|_| { + panic!("benchup {args:?}: stdout is not JSON: {}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)) + }); + (value, output.status.success()) +} + +fn check(case: &str, file: &str, expect: &Value) { + let (report, _) = benchup(&["call", file, "check_model"]); + let kinds: Vec<&str> = report["issues"].as_array().unwrap().iter().map(|i| i["kind"].as_str().unwrap()).collect(); + let context = || format!("{case}: {}", serde_json::to_string_pretty(&report["issues"]).unwrap()); + if let Some(passed) = expect.get("passed") { + assert_eq!(&report["passed"], passed, "{}", context()); + } + if let Some(errors) = expect.get("errors") { + assert_eq!(&report["errors"], errors, "{}", context()); + } + if let Some(allowed) = expect.get("allowed_kinds").and_then(Value::as_array) { + let allowed: Vec<&str> = allowed.iter().filter_map(Value::as_str).collect(); + assert!(kinds.iter().all(|k| allowed.contains(k)), "{}", context()); + } + if let Some(required) = expect.get("required_kinds").and_then(Value::as_array) { + assert!(required.iter().filter_map(Value::as_str).all(|k| kinds.contains(&k)), "{}", context()); + } + assert_eq!(report["joints"]["failing"], json!([]), "{}", context()); + let (scene, _) = benchup(&["scene", file, r#"{"sections":["dimensions","texts"]}"#]); + for (key, section) in [("min_dimensions", "dimensions"), ("min_texts", "texts")] { + if let Some(min) = expect.get(key).and_then(Value::as_u64) { + assert!(scene[section].as_array().unwrap().len() as u64 >= min, "{case}: fewer {section} than {min}"); + } + } +} + +#[test] +fn reference_solutions_of_the_agent_cases_still_apply() { + let root = root(); + let manifest: Value = serde_json::from_str(&std::fs::read_to_string(root.join("crates/benchup-cli/evals/cases.json")).unwrap()).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let mut replayed = 0; + for case in manifest["cases"].as_array().unwrap() { + let Some(reference) = case.get("reference") else { continue }; + let id = case["id"].as_str().unwrap(); + let file = dir.path().join(format!("{id}-{}", case["file"].as_str().unwrap())); + let file = file.to_str().unwrap(); + match manifest["seeds"][case["seed"].as_str().unwrap()].as_str() { + Some(seed) => { + std::fs::copy(root.join(seed), file).unwrap(); + } + None => assert!(benchup(&["new", file, "--name", id]).1, "{id}: benchup new failed"), + } + for batch in reference["batches"].as_array().unwrap() { + let path = root.join("crates/benchup-cli/evals/reference").join(id).join(batch.as_str().unwrap()); + let (result, ok) = benchup(&["call", file, "--batch", path.to_str().unwrap(), "--brief"]); + assert!(ok, "{id}/{batch}: {}", serde_json::to_string_pretty(&result).unwrap()); + } + check(id, file, &reference["expect"]); + replayed += 1; + } + assert!(replayed >= 15, "only {replayed} cases have a reference solution"); +} From 3128165da473bdec2cced3a55c21b893713ab578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Sat, 14 Nov 2026 13:21:30 +0100 Subject: [PATCH 3/5] Note that BenchUp reloads projects the CLI changes Since #46 the desktop app reloads an open project about a second after its file changes on disk, and asks first when it has unsaved changes. The CLI docs and the agent guide said to run `benchup open` again; they now describe the reload, and keep that advice for BenchUp 0.7.1 and older. Co-Authored-By: Claude Opus 5.5 --- docs/agent-guide.md | 2 +- docs/cli.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 7384f3e..b6d8c96 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -21,7 +21,7 @@ This guide is written for AI agents that edit BenchUp projects through the [`ben - `get_object` on a joint shows its settings and status. - `get_cut_list` lists the boards to cut; `get_estimate` gives the time to build them. - `benchup render file.bup --view front` (over MCP, `render_view`) draws the model as a PNG, joints and profiles cut, with dimensions and text notes (in capitals, without accents). Its output lists dimensions whose labels overlap and the notes with their position. It shows the whole model; details of a few millimetres need `--size 2400x1800`. -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. +5. With the CLI, `benchup open file.bup` shows the result in the desktop app. The CLI edits the file, not the open window, but BenchUp versions after 0.7.1 reload the file by themselves about a second after each change (with older versions, run `open` again). If the project has unsaved changes in the app, BenchUp asks the user first: tell them to choose "Reload from disk" to see your edit, because keeping their changes and saving overwrites it. ### Batches, results and dry runs diff --git a/docs/cli.md b/docs/cli.md index b21d2df..f41835a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -69,7 +69,7 @@ In a batch, `"$N.field"` is a value from the result of call N (counting from 1): - **Views and cameras:** the CLI keeps the camera of each view saved in the file, so editing a project does not move the user's views. It cannot list or change saved views yet, and `benchup render` draws the standard views (iso, front, back, left, right, top, bottom), not saved cameras; use BenchUp's [views](controls.md#views-and-cameras) and Tools → Screenshot for those. - **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). On macOS, when BenchUp is not running yet, it starts the app with the file as a launch argument. `--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)). +- **Not live, but reloaded:** the CLI edits the file, not a running BenchUp window. BenchUp versions after 0.7.1 that have the project open reload it about a second after the CLI saves, keeping the camera. If the project has unsaved changes in the app, BenchUp asks whether to keep them or reload from disk; keeping them and saving overwrites the CLI's edits. With BenchUp 0.7.1 or older, run `benchup open` again after a change to reload it, and 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 From 321ff1e8adb5b4f4249629a609c59ddc360f7434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Sat, 14 Nov 2026 13:35:31 +0100 Subject: [PATCH 4/5] Write the agent evaluation cases in English The requests, the prompt and its report sections, the seed project (estanteria.bup is now bookcase.bup, with English part names and no history) and the names in the reference batches are now in English, like the rest of the repository. Part ids are unchanged, so the reference solutions still apply. Co-Authored-By: Claude Opus 5.5 --- crates/benchup-cli/evals/README.md | 4 +- crates/benchup-cli/evals/cases.json | 88 +++++++++--------- .../benchup-cli/evals/fixtures/bookcase.bup | Bin 0 -> 5814 bytes .../benchup-cli/evals/fixtures/estanteria.bup | Bin 10369 -> 0 bytes crates/benchup-cli/evals/prompt.md | 16 ++-- .../reference/01-example-desk/step2.jsonl | 2 +- .../reference/02-four-shelves/flip.jsonl | 4 +- .../evals/reference/03-table/mesa.jsonl | 21 ----- .../evals/reference/03-table/table.jsonl | 21 +++++ .../evals/reference/04-drawers/build.jsonl | 26 +++--- .../reference/05-back-rabbet/change.jsonl | 6 +- .../evals/reference/05-back-rabbet/flip.jsonl | 4 +- .../evals/reference/06-oak/flip.jsonl | 2 +- .../evals/reference/06-oak/oak.jsonl | 2 +- .../reference/07-curved-side/build.jsonl | 16 ++-- .../reference/08-ladder-shelf/batch.jsonl | 18 ++-- .../evals/reference/09-dimensions/batch.jsonl | 2 +- .../reference/10-organize/organize.jsonl | 28 +++--- .../reference/11-cut-material/batch.jsonl | 4 +- .../evals/reference/12-impossible/alt.jsonl | 2 +- .../reference/12-impossible/literal.jsonl | 2 +- .../evals/reference/14-trim/bench.jsonl | 24 ++--- 22 files changed, 146 insertions(+), 146 deletions(-) create mode 100644 crates/benchup-cli/evals/fixtures/bookcase.bup delete mode 100644 crates/benchup-cli/evals/fixtures/estanteria.bup delete mode 100644 crates/benchup-cli/evals/reference/03-table/mesa.jsonl create mode 100644 crates/benchup-cli/evals/reference/03-table/table.jsonl diff --git a/crates/benchup-cli/evals/README.md b/crates/benchup-cli/evals/README.md index 8a76d7b..309e81b 100644 --- a/crates/benchup-cli/evals/README.md +++ b/crates/benchup-cli/evals/README.md @@ -1,6 +1,6 @@ # Agent evaluation of the `benchup` tools -The CLI and MCP tools are meant to be used by coding agents that have never seen them. These cases check that: realistic requests from a woodworker (in Spanish, as users write them), each solved by an agent that knows only what the tool tells it (`benchup --help`, `benchup guide`, the tool schemas and its errors). +The CLI and MCP tools are meant to be used by coding agents that have never seen them. These cases check that: realistic requests from a woodworker, worded as a user would, each solved by an agent that knows only what the tool tells it (`benchup --help`, `benchup guide`, the tool schemas and its errors). There are two layers: @@ -19,7 +19,7 @@ There are two layers: - `prompt.md` — the rules every agent gets, and the report it must return. - `setup.py` — prepares a round in `/tmp/benchup-eval/` (binary, one folder per case with its seed, prompts). It never builds. - `mcp_call.py` — a minimal MCP client over stdio, for the MCP cases. -- `fixtures/` — seed projects. `estanteria.bup` is a 1000 × 1800 mm bookcase with two columns of five housed shelves whose ten joints are reversed on purpose (agents should notice and offer `flip_joint`). +- `fixtures/` — seed projects. `bookcase.bup` is a 1000 × 1800 mm bookcase with two columns of five housed shelves whose ten joints are reversed on purpose (agents should notice and offer `flip_joint`). - `reference/` — reference batches, per case. - `results/` — one file per round: the table of commands (errors) per case compared with earlier rounds, and the findings, sorted into tool bugs, guide gaps, core limits and missing features. diff --git a/crates/benchup-cli/evals/cases.json b/crates/benchup-cli/evals/cases.json index 5259b9e..b33f522 100644 --- a/crates/benchup-cli/evals/cases.json +++ b/crates/benchup-cli/evals/cases.json @@ -1,6 +1,6 @@ { "seeds": { - "estanteria": "crates/benchup-cli/evals/fixtures/estanteria.bup", + "bookcase": "crates/benchup-cli/evals/fixtures/bookcase.bup", "example": "assets/examples/Example.bup", "new": null }, @@ -11,7 +11,7 @@ "mode": "cli", "seed": "example", "file": "Example.bup", - "request": "En Example.bup, haz el escritorio (Desk) 200 mm más largo.", + "request": "In Example.bup, make the desk 200 mm longer.", "focus": [ "get_scene filters", "split_face", @@ -37,9 +37,9 @@ "id": "02-four-shelves", "title": "Five shelves to four", "mode": "cli", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "En estanteria.bup, ahora quiero que tenga 4 baldas en cada columna en vez de 5, repartidas por igual, manteniendo los ensambles.", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "In bookcase.bup, I now want 4 shelves in each column instead of 5, evenly spaced, keeping the joints.", "focus": [ "distribute", "flip_joint" @@ -59,15 +59,15 @@ "title": "Dining table", "mode": "cli", "seed": "new", - "file": "mesa.bup", - "request": "Créame mesa.bup: una mesa de comedor de 1400×800 mm y 750 mm de alto, con 4 patas de 70×70 mm, faldones unidos a las patas con caja y espiga, y un tablero de 30 mm encima.", + "file": "table.bup", + "request": "Create table.bup: a 1400×800 mm dining table, 750 mm high, with 4 legs of 70×70 mm, aprons joined to the legs with mortise and tenon, and a 30 mm top.", "focus": [ "create_board", "add_joint" ], "reference": { "batches": [ - "mesa.jsonl" + "table.jsonl" ], "expect": { "passed": true @@ -79,8 +79,8 @@ "title": "Chest of drawers", "mode": "cli", "seed": "new", - "file": "cajonera.bup", - "request": "Créame cajonera.bup: una cajonera de 500 mm de ancho, 450 de fondo y 700 de alto, con 3 cajones iguales. Los frentes de cajón unidos a los laterales del cajón con colas de milano. Los tres cajones deben ser el mismo componente.", + "file": "drawers.bup", + "request": "Create drawers.bup: a chest of drawers 500 mm wide, 450 deep and 700 high, with 3 equal drawers. The drawer fronts are joined to the drawer sides with dovetails. The three drawers must be the same component.", "focus": [ "group", "create_copy", @@ -99,9 +99,9 @@ "id": "05-back-rabbet", "title": "Back in a rabbet and plinth", "mode": "cli", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "En estanteria.bup, añade una trasera de 6 mm metida en un galce en los laterales, y un zócalo de 80 mm de alto retranqueado 20 mm por delante.", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "In bookcase.bup, add a 6 mm back set in a rabbet in the sides, and an 80 mm high plinth set back 20 mm from the front.", "focus": [ "add_joint", "check_model only_new" @@ -120,9 +120,9 @@ "id": "06-oak", "title": "Solid oak 19 mm", "mode": "cli", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "En estanteria.bup, hazla en roble macizo de 19 mm en vez de tablero de 18.", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "In bookcase.bup, make it in 19 mm solid oak instead of 18 mm board.", "focus": [ "change_thickness", "create_stock_material" @@ -142,8 +142,8 @@ "title": "Bedside table with curved sides", "mode": "cli", "seed": "new", - "file": "mesilla.bup", - "request": "Créame mesilla.bup: una mesilla de noche de 450 mm de ancho, 350 de fondo y 550 de alto. Los laterales tienen el borde delantero curvo (un arco que entra 40 mm en el centro) y la tapa tiene un chaflán de 10 mm en las esquinas delanteras.", + "file": "nightstand.bup", + "request": "Create nightstand.bup: a nightstand 450 mm wide, 350 deep and 550 high. The sides have a curved front edge (an arc that goes in 40 mm at the middle) and the top has a 10 mm chamfer on its front corners.", "focus": [ "create_profile_board", "add_profile" @@ -162,8 +162,8 @@ "title": "Leaning ladder shelf", "mode": "cli", "seed": "new", - "file": "escalera.bup", - "request": "Créame escalera.bup: una estantería tipo escalera apoyada en la pared, de 1800 mm de alto, con dos largueros inclinados 15° respecto a la vertical y 4 baldas horizontales de 600 mm de ancho, cada una menos profunda que la de abajo, encajadas en los largueros.", + "file": "ladder.bup", + "request": "Create ladder.bup: a ladder shelf leaning against the wall, 1800 mm high, with two uprights leaning 15° from vertical and 4 horizontal shelves 600 mm wide, each shallower than the one below, housed in the uprights.", "focus": [ "create_profile_board", "rotate" @@ -181,9 +181,9 @@ "id": "09-dimensions", "title": "Workshop dimensions", "mode": "cli", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "En estanteria.bup, acota el alto, el ancho y el fondo totales, y la distancia entre baldas, y pon un texto con el nombre del mueble, para llevarlo al taller.", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "In bookcase.bup, dimension the overall height, width and depth, and the spacing between shelves, and add a text with the name of the piece, to take it to the workshop.", "focus": [ "add_dimension", "render_view" @@ -207,9 +207,9 @@ "id": "10-organize", "title": "Modules, components and tags", "mode": "cli", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "Organiza estanteria.bup: agrupa cada columna como un módulo (Módulo izquierdo, Módulo derecho), haz que todas las baldas sean el mismo componente, y pon la etiqueta «Baldas» a las baldas y «Estructura» a laterales y separador.", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "Organize bookcase.bup: group each column as a module (Left module, Right module), make all the shelves the same component, and tag the shelves \"Shelves\" and the sides and divider \"Carcass\".", "focus": [ "group", "tag" @@ -231,9 +231,9 @@ "id": "11-cut-material", "title": "Cut list and sheets", "mode": "cli", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "Para estanteria.bup, ¿cuánto tablero necesito? Dame la lista de despiece (pieza, medidas, cantidad) y cuántos tableros de 2440×1220 mm de 18 mm tengo que comprar.", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "For bookcase.bup, how much board do I need? Give me the cut list (part, sizes, quantity) and how many 2440×1220 mm sheets of 18 mm I have to buy.", "focus": [ "get_cut_list", "get_estimate" @@ -255,9 +255,9 @@ "id": "12-impossible", "title": "A shelf that does not fit", "mode": "cli", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "En estanteria.bup, añade una balda de 1200 mm de largo en la columna izquierda, a 1000 mm del suelo, encajada en los laterales.", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "In bookcase.bup, add a 1200 mm long shelf in the left column, 1000 mm from the floor, housed in the sides.", "focus": [ "check_model", "measure_space" @@ -277,9 +277,9 @@ "id": "13-router", "title": "Edge profiles", "mode": "cli", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "En estanteria.bup, achaflana 2 mm el canto delantero superior de todas las baldas y redondea con radio 3 mm los dos cantos delanteros del separador central.", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "In bookcase.bup, chamfer the top front edge of every shelf by 2 mm and round over the two front edges of the center divider with a 3 mm radius.", "focus": [ "add_profile" ], @@ -297,8 +297,8 @@ "title": "Bench with trimmed through tenons", "mode": "cli", "seed": "new", - "file": "banco.bup", - "request": "Créame banco.bup: un banco de 1200×350 mm y 450 mm de alto, con 4 patas de 50×50 mm y un asiento de 25 mm. Los travesaños (70×22 mm) atraviesan las patas con caja y espiga pasante y deben quedar enrasados por fuera de las patas.", + "file": "bench.bup", + "request": "Create bench.bup: a bench 1200×350 mm and 450 mm high, with 4 legs of 50×50 mm and a 25 mm seat. The rails (70×22 mm) go through the legs with through mortise and tenon joints and must end flush with the outside of the legs.", "focus": [ "add_joint trim", "set_joint" @@ -316,9 +316,9 @@ "id": "15-finish", "title": "Finish and time", "mode": "cli", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "En estanteria.bup, quiero lijarla a grano 120 y 180 y darle dos manos de barniz satinado. ¿Cuánto tiempo me llevará terminarla, contando el secado?", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "In bookcase.bup, I want to sand it at 120 and 180 grit and give it two coats of satin varnish. How long will it take me to finish it, including drying?", "focus": [ "set_finish", "get_estimate" @@ -342,7 +342,7 @@ "mode": "cli", "seed": "example", "file": "Example.bup", - "request": "En Example.bup, pon una balda de 18 mm y 250 mm de fondo encima del escritorio, a 400 mm por encima del tablero y del mismo largo, sin chocar con nada. Comprueba el resultado con una imagen.", + "request": "In Example.bup, put an 18 mm shelf, 250 mm deep, above the desk, 400 mm above the desktop and as long as it, without hitting anything. Check the result with an image.", "focus": [ "get_scene filters", "measure_space", @@ -353,9 +353,9 @@ "id": "17-mcp-shelves", "title": "Five shelves to four, over MCP", "mode": "mcp", - "seed": "estanteria", - "file": "estanteria.bup", - "request": "En estanteria.bup, ahora quiero que tenga 4 baldas en cada columna en vez de 5, repartidas por igual, manteniendo los ensambles.", + "seed": "bookcase", + "file": "bookcase.bup", + "request": "In bookcase.bup, I now want 4 shelves in each column instead of 5, evenly spaced, keeping the joints.", "focus": [ "MCP", "distribute", diff --git a/crates/benchup-cli/evals/fixtures/bookcase.bup b/crates/benchup-cli/evals/fixtures/bookcase.bup new file mode 100644 index 0000000000000000000000000000000000000000..99b8d72a70ebd7655851b2fc1d82310befbf8845 GIT binary patch literal 5814 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 XPn;4jl#YpQ-mq})RH^(H#l`u5)lQ53 diff --git a/crates/benchup-cli/evals/prompt.md b/crates/benchup-cli/evals/prompt.md index 123e5d8..207e6c5 100644 --- a/crates/benchup-cli/evals/prompt.md +++ b/crates/benchup-cli/evals/prompt.md @@ -1,4 +1,4 @@ -You are a coding agent being evaluated on how well you can use a tool you have never seen, the way a real user's agent would. A user asked you (in Spanish) the request below. +You are a coding agent being evaluated on how well you can use a tool you have never seen, the way a real user's agent would. A user asked you the request below. USER REQUEST: "{request}" @@ -12,10 +12,10 @@ SETUP AND RULES - Scripts (python3 etc.) to generate inputs are fine; keep them inside your working folder. - You can look at PNG images you produce (for example with your file-reading tool) to check the result. -WHEN DONE, reply with a report (in Spanish) with exactly these sections: -1. Resultado: what you built/changed, and whether you believe it fully satisfies the request (be honest; verify with the tool's own checks). -2. Métricas: total number of {unit} run, number that returned an error, number of scratch/trial runs (including dry runs). -3. Tropiezos: every point where you were stuck, confused, had to guess, needed a workaround, or the tool gave an unhelpful error. Quote the error text. -4. Información que faltaba: what you needed that the help, the guide and the tool schemas did not tell you. -5. Decisiones y supuestos: design or woodworking decisions you made on your own; questions you would have asked the user. -6. Qué habría ayudado: concrete suggestions, split into (a) changes to the tools and (b) guidance the guide should contain or correct (say if anything in the guide was wrong or misleading). +WHEN DONE, reply with a report with exactly these sections: +1. Result: what you built/changed, and whether you believe it fully satisfies the request (be honest; verify with the tool's own checks). +2. Metrics: total number of {unit} run, number that returned an error, number of scratch/trial runs (including dry runs). +3. Stumbling points: every point where you were stuck, confused, had to guess, needed a workaround, or the tool gave an unhelpful error. Quote the error text. +4. Missing information: what you needed that the help, the guide and the tool schemas did not tell you. +5. Decisions and assumptions: design or woodworking decisions you made on your own; questions you would have asked the user. +6. What would have helped: concrete suggestions, split into (a) changes to the tools and (b) guidance the guide should contain or correct (say if anything in the guide was wrong or misleading). diff --git a/crates/benchup-cli/evals/reference/01-example-desk/step2.jsonl b/crates/benchup-cli/evals/reference/01-example-desk/step2.jsonl index 9dbd0a9..ba682d1 100644 --- a/crates/benchup-cli/evals/reference/01-example-desk/step2.jsonl +++ b/crates/benchup-cli/evals/reference/01-example-desk/step2.jsonl @@ -1,6 +1,6 @@ {"tool": "push_pull", "arguments": {"face_id": 5119, "distance_mm": 200}} {"tool": "enter_object", "arguments": {"id": 5430}} {"tool": "enter_object", "arguments": {"id": 5427}} -{"tool": "draw_polyline", "arguments": {"points_mm": [[649,960,892],[649,960,928]]}} +{"tool": "draw_polyline", "arguments": {"points_mm": [[649, 960, 892], [649, 960, 928]]}} {"tool": "exit_to_root", "arguments": {}} {"tool": "get_object", "arguments": {"id": 5427}} diff --git a/crates/benchup-cli/evals/reference/02-four-shelves/flip.jsonl b/crates/benchup-cli/evals/reference/02-four-shelves/flip.jsonl index d560b51..64b989d 100644 --- a/crates/benchup-cli/evals/reference/02-four-shelves/flip.jsonl +++ b/crates/benchup-cli/evals/reference/02-four-shelves/flip.jsonl @@ -1,2 +1,2 @@ -{"tool":"flip_joint","arguments":{"ids":[716,717,718,719,720,721,722,723]}} -{"tool":"check_model","arguments":{}} +{"tool": "flip_joint", "arguments": {"ids": [716, 717, 718, 719, 720, 721, 722, 723]}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/03-table/mesa.jsonl b/crates/benchup-cli/evals/reference/03-table/mesa.jsonl deleted file mode 100644 index 669fb4f..0000000 --- a/crates/benchup-cli/evals/reference/03-table/mesa.jsonl +++ /dev/null @@ -1,21 +0,0 @@ -{"tool": "create_stock_material", "arguments": {"name": "Listón patas 70x70", "thickness_mm": 70, "width_mm": 70, "length_mm": 2000}} -{"tool": "create_stock_material", "arguments": {"name": "Tabla faldones 22 mm", "thickness_mm": 22, "width_mm": 150, "length_mm": 2500}} -{"tool": "create_stock_material", "arguments": {"name": "Tablero 30 mm", "thickness_mm": 30, "width_mm": 900, "length_mm": 1600}} -{"tool": "create_board", "arguments": {"name": "Pata delantera izquierda", "origin_mm": [30, 30, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Patas"}} -{"tool": "create_board", "arguments": {"name": "Pata delantera derecha", "origin_mm": [1300, 30, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Patas"}} -{"tool": "create_board", "arguments": {"name": "Pata trasera izquierda", "origin_mm": [30, 700, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Patas"}} -{"tool": "create_board", "arguments": {"name": "Pata trasera derecha", "origin_mm": [1300, 700, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Patas"}} -{"tool": "create_board", "arguments": {"name": "Faldón delantero", "origin_mm": [70, 40, 620], "size_mm": [1260, 22, 100], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Faldones"}} -{"tool": "create_board", "arguments": {"name": "Faldón trasero", "origin_mm": [70, 738, 620], "size_mm": [1260, 22, 100], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Faldones"}} -{"tool": "create_board", "arguments": {"name": "Faldón lateral izquierdo", "origin_mm": [40, 70, 620], "size_mm": [22, 660, 100], "grain_axis": "y", "material_id": "$2.material_id", "tag": "Faldones"}} -{"tool": "create_board", "arguments": {"name": "Faldón lateral derecho", "origin_mm": [1338, 70, 620], "size_mm": [22, 660, 100], "grain_axis": "y", "material_id": "$2.material_id", "tag": "Faldones"}} -{"tool": "create_board", "arguments": {"name": "Tablero", "origin_mm": [0, 0, 720], "size_mm": [1400, 800, 30], "grain_axis": "x", "material_id": "$3.material_id", "tag": "Tablero"}} -{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$4.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} -{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$5.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} -{"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$6.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} -{"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$7.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} -{"tool": "add_joint", "arguments": {"a": "$10.created[0]", "b": "$4.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} -{"tool": "add_joint", "arguments": {"a": "$10.created[0]", "b": "$6.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} -{"tool": "add_joint", "arguments": {"a": "$11.created[0]", "b": "$5.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} -{"tool": "add_joint", "arguments": {"a": "$11.created[0]", "b": "$7.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} -{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/03-table/table.jsonl b/crates/benchup-cli/evals/reference/03-table/table.jsonl new file mode 100644 index 0000000..23f6591 --- /dev/null +++ b/crates/benchup-cli/evals/reference/03-table/table.jsonl @@ -0,0 +1,21 @@ +{"tool": "create_stock_material", "arguments": {"name": "Leg stock 70x70", "thickness_mm": 70, "width_mm": 70, "length_mm": 2000}} +{"tool": "create_stock_material", "arguments": {"name": "Apron board 22 mm", "thickness_mm": 22, "width_mm": 150, "length_mm": 2500}} +{"tool": "create_stock_material", "arguments": {"name": "Panel 30 mm", "thickness_mm": 30, "width_mm": 900, "length_mm": 1600}} +{"tool": "create_board", "arguments": {"name": "Front left leg", "origin_mm": [30, 30, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Legs"}} +{"tool": "create_board", "arguments": {"name": "Front right leg", "origin_mm": [1300, 30, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Legs"}} +{"tool": "create_board", "arguments": {"name": "Back left leg", "origin_mm": [30, 700, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Legs"}} +{"tool": "create_board", "arguments": {"name": "Back right leg", "origin_mm": [1300, 700, 0], "size_mm": [70, 70, 720], "grain_axis": "z", "material_id": "$1.material_id", "tag": "Legs"}} +{"tool": "create_board", "arguments": {"name": "Front apron", "origin_mm": [70, 40, 620], "size_mm": [1260, 22, 100], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Aprons"}} +{"tool": "create_board", "arguments": {"name": "Back apron", "origin_mm": [70, 738, 620], "size_mm": [1260, 22, 100], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Aprons"}} +{"tool": "create_board", "arguments": {"name": "Left apron", "origin_mm": [40, 70, 620], "size_mm": [22, 660, 100], "grain_axis": "y", "material_id": "$2.material_id", "tag": "Aprons"}} +{"tool": "create_board", "arguments": {"name": "Right apron", "origin_mm": [1338, 70, 620], "size_mm": [22, 660, 100], "grain_axis": "y", "material_id": "$2.material_id", "tag": "Aprons"}} +{"tool": "create_board", "arguments": {"name": "Top", "origin_mm": [0, 0, 720], "size_mm": [1400, 800, 30], "grain_axis": "x", "material_id": "$3.material_id", "tag": "Top"}} +{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$4.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$5.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$6.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$7.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$10.created[0]", "b": "$4.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$10.created[0]", "b": "$6.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$11.created[0]", "b": "$5.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "add_joint", "arguments": {"a": "$11.created[0]", "b": "$7.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75}}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/04-drawers/build.jsonl b/crates/benchup-cli/evals/reference/04-drawers/build.jsonl index 85acbd9..18536b6 100644 --- a/crates/benchup-cli/evals/reference/04-drawers/build.jsonl +++ b/crates/benchup-cli/evals/reference/04-drawers/build.jsonl @@ -1,29 +1,29 @@ -{"tool": "create_stock_material", "arguments": {"name": "Tablero 18 mm", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440}} -{"tool": "create_stock_material", "arguments": {"name": "Tablero 12 mm", "thickness_mm": 12, "width_mm": 1220, "length_mm": 2440}} -{"tool": "create_stock_material", "arguments": {"name": "Contrachapado 6 mm", "thickness_mm": 6, "width_mm": 1220, "length_mm": 2440}} -{"tool": "create_board", "arguments": {"name": "Lateral izquierdo", "origin_mm": [0, 0, 0], "size_mm": [18, 450, 700], "grain_axis": "z", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Lateral derecho", "origin_mm": [482, 0, 0], "size_mm": [18, 450, 700], "grain_axis": "z", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Tapa", "origin_mm": [9, 0, 682], "size_mm": [482, 434, 18], "grain_axis": "x", "material_id": "$1.material_id"}} +{"tool": "create_stock_material", "arguments": {"name": "Panel 18 mm", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440}} +{"tool": "create_stock_material", "arguments": {"name": "Panel 12 mm", "thickness_mm": 12, "width_mm": 1220, "length_mm": 2440}} +{"tool": "create_stock_material", "arguments": {"name": "Plywood 6 mm", "thickness_mm": 6, "width_mm": 1220, "length_mm": 2440}} +{"tool": "create_board", "arguments": {"name": "Left side", "origin_mm": [0, 0, 0], "size_mm": [18, 450, 700], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Right side", "origin_mm": [482, 0, 0], "size_mm": [18, 450, 700], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Top", "origin_mm": [9, 0, 682], "size_mm": [482, 434, 18], "grain_axis": "x", "material_id": "$1.material_id"}} {"tool": "create_board", "arguments": {"name": "Base", "origin_mm": [9, 0, 0], "size_mm": [482, 434, 18], "grain_axis": "x", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Trasera", "origin_mm": [9, 436, 0], "size_mm": [482, 6, 700], "grain_axis": "z", "material_id": "$3.material_id"}} +{"tool": "create_board", "arguments": {"name": "Back", "origin_mm": [9, 436, 0], "size_mm": [482, 6, 700], "grain_axis": "z", "material_id": "$3.material_id"}} {"tool": "add_joint", "arguments": {"a": "$6.created[0]", "b": "$4.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$6.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$7.created[0]", "b": "$4.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$7.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$4.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} -{"tool": "create_board", "arguments": {"name": "Frente de cajón", "origin_mm": [20, 0, 20], "size_mm": [460, 18, 218], "grain_axis": "x", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Lateral de cajón izquierdo", "origin_mm": [20, 5, 30], "size_mm": [12, 415, 178], "grain_axis": "y", "material_id": "$2.material_id"}} -{"tool": "create_board", "arguments": {"name": "Lateral de cajón derecho", "origin_mm": [468, 5, 30], "size_mm": [12, 415, 178], "grain_axis": "y", "material_id": "$2.material_id"}} -{"tool": "create_board", "arguments": {"name": "Trasera de cajón", "origin_mm": [26, 400, 46], "size_mm": [448, 12, 162], "grain_axis": "x", "material_id": "$2.material_id"}} -{"tool": "create_board", "arguments": {"name": "Fondo de cajón", "origin_mm": [26, 20, 40], "size_mm": [448, 392, 6], "grain_axis": "x", "material_id": "$3.material_id"}} +{"tool": "create_board", "arguments": {"name": "Drawer front", "origin_mm": [20, 0, 20], "size_mm": [460, 18, 218], "grain_axis": "x", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Drawer left side", "origin_mm": [20, 5, 30], "size_mm": [12, 415, 178], "grain_axis": "y", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Drawer right side", "origin_mm": [468, 5, 30], "size_mm": [12, 415, 178], "grain_axis": "y", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Drawer back", "origin_mm": [26, 400, 46], "size_mm": [448, 12, 162], "grain_axis": "x", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Drawer bottom", "origin_mm": [26, 20, 40], "size_mm": [448, 392, 6], "grain_axis": "x", "material_id": "$3.material_id"}} {"tool": "add_joint", "arguments": {"a": "$16.created[0]", "b": "$15.created[0]", "kind": "dovetail", "params": {"dovetails": 4}}} {"tool": "add_joint", "arguments": {"a": "$17.created[0]", "b": "$15.created[0]", "kind": "dovetail", "params": {"dovetails": 4}}} {"tool": "add_joint", "arguments": {"a": "$18.created[0]", "b": "$16.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$18.created[0]", "b": "$17.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$19.created[0]", "b": "$16.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$19.created[0]", "b": "$17.created[0]", "kind": "tongue_groove"}} -{"tool": "group", "arguments": {"ids": ["$15.created[0]", "$16.created[0]", "$17.created[0]", "$18.created[0]", "$19.created[0]"], "name": "Cajón", "component": true}} +{"tool": "group", "arguments": {"ids": ["$15.created[0]", "$16.created[0]", "$17.created[0]", "$18.created[0]", "$19.created[0]"], "name": "Drawer", "component": true}} {"tool": "create_copy", "arguments": {"id": "$26.created[0]", "offset_mm": [0, 0, 221]}} {"tool": "create_copy", "arguments": {"id": "$26.created[0]", "offset_mm": [0, 0, 442]}} {"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/05-back-rabbet/change.jsonl b/crates/benchup-cli/evals/reference/05-back-rabbet/change.jsonl index 2357785..42e06fd 100644 --- a/crates/benchup-cli/evals/reference/05-back-rabbet/change.jsonl +++ b/crates/benchup-cli/evals/reference/05-back-rabbet/change.jsonl @@ -9,11 +9,11 @@ {"tool": "resize_board", "arguments": {"id": 662, "size_mm": [482, 292, 18], "anchor": "min"}} {"tool": "resize_board", "arguments": {"id": 715, "size_mm": [482, 292, 18], "anchor": "min"}} {"tool": "resize_board", "arguments": {"id": 185, "size_mm": [18, 294, 1800], "anchor": "min"}} -{"tool": "create_board", "arguments": {"name": "Trasera", "origin_mm": [9, 294, 0], "size_mm": [982, 6, 1800], "grain_axis": "z"}} +{"tool": "create_board", "arguments": {"name": "Back", "origin_mm": [9, 294, 0], "size_mm": [982, 6, 1800], "grain_axis": "z"}} {"tool": "add_joint", "arguments": {"a": "$12.created[0]", "b": 79, "kind": "rabbet"}} {"tool": "add_joint", "arguments": {"a": "$12.created[0]", "b": 132, "kind": "rabbet"}} -{"tool": "create_board", "arguments": {"name": "Zócalo izquierdo", "origin_mm": [9, 20, 0], "size_mm": [482, 18, 80], "grain_axis": "x"}} -{"tool": "create_board", "arguments": {"name": "Zócalo derecho", "origin_mm": [509, 20, 0], "size_mm": [482, 18, 80], "grain_axis": "x"}} +{"tool": "create_board", "arguments": {"name": "Left plinth", "origin_mm": [9, 20, 0], "size_mm": [482, 18, 80], "grain_axis": "x"}} +{"tool": "create_board", "arguments": {"name": "Right plinth", "origin_mm": [509, 20, 0], "size_mm": [482, 18, 80], "grain_axis": "x"}} {"tool": "add_joint", "arguments": {"a": "$15.created[0]", "b": 79, "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$16.created[0]", "b": 132, "kind": "tongue_groove"}} {"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/05-back-rabbet/flip.jsonl b/crates/benchup-cli/evals/reference/05-back-rabbet/flip.jsonl index 2c9cfcc..8dfd34c 100644 --- a/crates/benchup-cli/evals/reference/05-back-rabbet/flip.jsonl +++ b/crates/benchup-cli/evals/reference/05-back-rabbet/flip.jsonl @@ -1,2 +1,2 @@ -{"tool":"flip_joint","arguments":{"ids":[716,717,718,719,720,721,722,723,724,725]}} -{"tool":"check_model","arguments":{}} +{"tool": "flip_joint", "arguments": {"ids": [716, 717, 718, 719, 720, 721, 722, 723, 724, 725]}} +{"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/06-oak/flip.jsonl b/crates/benchup-cli/evals/reference/06-oak/flip.jsonl index affd611..8dfd34c 100644 --- a/crates/benchup-cli/evals/reference/06-oak/flip.jsonl +++ b/crates/benchup-cli/evals/reference/06-oak/flip.jsonl @@ -1,2 +1,2 @@ -{"tool": "flip_joint", "arguments": {"ids": [716,717,718,719,720,721,722,723,724,725]}} +{"tool": "flip_joint", "arguments": {"ids": [716, 717, 718, 719, 720, 721, 722, 723, 724, 725]}} {"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/06-oak/oak.jsonl b/crates/benchup-cli/evals/reference/06-oak/oak.jsonl index cc12486..3b6d20c 100644 --- a/crates/benchup-cli/evals/reference/06-oak/oak.jsonl +++ b/crates/benchup-cli/evals/reference/06-oak/oak.jsonl @@ -1,4 +1,4 @@ -{"tool": "create_stock_material", "arguments": {"name": "Roble macizo 19 mm", "thickness_mm": 19, "width_mm": 300, "length_mm": 2500}} +{"tool": "create_stock_material", "arguments": {"name": "Solid oak 19 mm", "thickness_mm": 19, "width_mm": 300, "length_mm": 2500}} {"tool": "resize_board", "arguments": {"id": 79, "size_mm": [19, 300, 1800], "anchor": "min"}} {"tool": "resize_board", "arguments": {"id": 132, "size_mm": [19, 300, 1800], "anchor": ["max", "min", "min"]}} {"tool": "resize_board", "arguments": {"id": 185, "size_mm": [19, 300, 1800], "anchor": ["center", "min", "min"]}} diff --git a/crates/benchup-cli/evals/reference/07-curved-side/build.jsonl b/crates/benchup-cli/evals/reference/07-curved-side/build.jsonl index 0aa6dfe..bdce01b 100644 --- a/crates/benchup-cli/evals/reference/07-curved-side/build.jsonl +++ b/crates/benchup-cli/evals/reference/07-curved-side/build.jsonl @@ -1,18 +1,18 @@ -{"tool": "create_stock_material", "arguments": {"name": "Tablero 18 mm", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440}} -{"tool": "create_stock_material", "arguments": {"name": "Tablero 6 mm (trasera)", "thickness_mm": 6, "width_mm": 1220, "length_mm": 2440}} +{"tool": "create_stock_material", "arguments": {"name": "Panel 18 mm", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440}} +{"tool": "create_stock_material", "arguments": {"name": "Panel 6 mm (back)", "thickness_mm": 6, "width_mm": 1220, "length_mm": 2440}} {"tool": "draw_polyline", "arguments": {"points_mm": [[10.0, 0, 532.0], [440.0, 0, 532.0], [450.0, 10.0, 532.0], [450.0, 350.0, 532.0], [0, 350.0, 532.0], [0, 10.0, 532.0]], "close": true}} {"tool": "push_pull", "arguments": {"face_id": "$3.face_id", "distance_mm": 18.0}} -{"tool": "group", "arguments": {"ids": ["$3.created", "$4.created"], "name": "Tapa", "component": false}} +{"tool": "group", "arguments": {"ids": ["$3.created", "$4.created"], "name": "Top", "component": false}} {"tool": "set_grain", "arguments": {"id": "$5.created[0]", "axis": "x"}} {"tool": "draw_polyline", "arguments": {"points_mm": [[0.0, 20.0, 0], [0.0, 350.0, 0], [0.0, 350.0, 541.0], [0.0, 20.0, 541.0], [0.0, 20.0, 532.0], [0.0, 23.333, 520.917], [0.0, 26.512, 509.833], [0.0, 29.539, 498.75], [0.0, 32.416, 487.667], [0.0, 35.143, 476.583], [0.0, 37.723, 465.5], [0.0, 40.157, 454.417], [0.0, 42.445, 443.333], [0.0, 44.589, 432.25], [0.0, 46.59, 421.167], [0.0, 48.45, 410.083], [0.0, 50.168, 399.0], [0.0, 51.745, 387.917], [0.0, 53.183, 376.833], [0.0, 54.483, 365.75], [0.0, 55.643, 354.667], [0.0, 56.666, 343.583], [0.0, 57.552, 332.5], [0.0, 58.301, 321.417], [0.0, 58.913, 310.333], [0.0, 59.389, 299.25], [0.0, 59.728, 288.167], [0.0, 59.932, 277.083], [0.0, 60.0, 266.0], [0.0, 59.932, 254.917], [0.0, 59.728, 243.833], [0.0, 59.389, 232.75], [0.0, 58.913, 221.667], [0.0, 58.301, 210.583], [0.0, 57.552, 199.5], [0.0, 56.666, 188.417], [0.0, 55.643, 177.333], [0.0, 54.483, 166.25], [0.0, 53.183, 155.167], [0.0, 51.745, 144.083], [0.0, 50.168, 133.0], [0.0, 48.45, 121.917], [0.0, 46.59, 110.833], [0.0, 44.589, 99.75], [0.0, 42.445, 88.667], [0.0, 40.157, 77.583], [0.0, 37.723, 66.5], [0.0, 35.143, 55.417], [0.0, 32.416, 44.333], [0.0, 29.539, 33.25], [0.0, 26.512, 22.167], [0.0, 23.333, 11.083]], "close": true}} {"tool": "push_pull", "arguments": {"face_id": "$7.face_id", "distance_mm": 18.0}} -{"tool": "group", "arguments": {"ids": ["$7.created", "$8.created"], "name": "Lateral izquierdo", "component": false}} +{"tool": "group", "arguments": {"ids": ["$7.created", "$8.created"], "name": "Left side", "component": false}} {"tool": "set_grain", "arguments": {"id": "$9.created[0]", "axis": "z"}} {"tool": "create_copy", "arguments": {"id": "$9.created[0]", "offset_mm": [432.0, 0, 0]}} -{"tool": "rename", "arguments": {"id": "$11.created[0]", "name": "Lateral derecho"}} -{"tool": "create_board", "arguments": {"name": "Fondo", "origin_mm": [9.0, 44, 50.0], "size_mm": [432.0, 298.0, 18.0], "grain_axis": "x", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Balda", "origin_mm": [9.0, 65, 291.0], "size_mm": [432.0, 277.0, 18.0], "grain_axis": "x", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Trasera", "origin_mm": [9.0, 344.0, 0], "size_mm": [432.0, 6.0, 530.0], "grain_axis": "z", "material_id": "$2.material_id"}} +{"tool": "rename", "arguments": {"id": "$11.created[0]", "name": "Right side"}} +{"tool": "create_board", "arguments": {"name": "Bottom", "origin_mm": [9.0, 44, 50.0], "size_mm": [432.0, 298.0, 18.0], "grain_axis": "x", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Shelf", "origin_mm": [9.0, 65, 291.0], "size_mm": [432.0, 277.0, 18.0], "grain_axis": "x", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Back", "origin_mm": [9.0, 344.0, 0], "size_mm": [432.0, 6.0, 530.0], "grain_axis": "z", "material_id": "$2.material_id"}} {"tool": "set_material", "arguments": {"ids": ["$5.created[0]", "$9.created[0]", "$11.created[0]"], "material_id": "$1.material_id"}} {"tool": "add_joint", "arguments": {"a": "$9.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$11.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} diff --git a/crates/benchup-cli/evals/reference/08-ladder-shelf/batch.jsonl b/crates/benchup-cli/evals/reference/08-ladder-shelf/batch.jsonl index 4989d16..435f566 100644 --- a/crates/benchup-cli/evals/reference/08-ladder-shelf/batch.jsonl +++ b/crates/benchup-cli/evals/reference/08-ladder-shelf/batch.jsonl @@ -1,18 +1,18 @@ -{"tool": "create_stock_material", "arguments": {"name": "Madera maciza 30 mm (largueros)", "thickness_mm": 30, "width_mm": 400, "length_mm": 2200}} -{"tool": "create_stock_material", "arguments": {"name": "Tablero 18 mm (baldas)", "thickness_mm": 18, "width_mm": 400, "length_mm": 2400}} +{"tool": "create_stock_material", "arguments": {"name": "Solid wood 30 mm (uprights)", "thickness_mm": 30, "width_mm": 400, "length_mm": 2200}} +{"tool": "create_stock_material", "arguments": {"name": "Panel 18 mm (shelves)", "thickness_mm": 18, "width_mm": 400, "length_mm": 2400}} {"tool": "draw_polyline", "arguments": {"points_mm": [[0.0, -367.0518, -98.3512], [0.0, 0.0, 0.0], [0.0, 0.0, 1863.4971], [0.0, -149.7185, 1823.3802]], "close": true}} {"tool": "push_pull", "arguments": {"face_id": "$3.face_id", "distance_mm": 30.0}} -{"tool": "group", "arguments": {"ids": ["$3.created", "$4.created"], "name": "Larguero izquierdo", "component": false}} +{"tool": "group", "arguments": {"ids": ["$3.created", "$4.created"], "name": "Left upright", "component": false}} {"tool": "set_grain", "arguments": {"id": "$5.created[0]", "axis": "z"}} {"tool": "set_material", "arguments": {"ids": ["$5.created[0]"], "material_id": "$1.material_id"}} {"tool": "rotate", "arguments": {"ids": ["$5.created[0]"], "pivot_mm": [0, 0, 0], "axis": "x", "degrees": -15}} {"tool": "create_copy", "arguments": {"id": "$5.created[0]", "offset_mm": [600.0, 0, 0]}} -{"tool": "rename", "arguments": {"id": "$9.created[0]", "name": "Larguero derecho"}} -{"tool": "tag", "arguments": {"ids": ["$5.created[0]", "$9.created[0]"], "tag": "Largueros"}} -{"tool": "create_board", "arguments": {"name": "Balda 1", "origin_mm": [15.0, -305.81, 150], "size_mm": [600.0, 340, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Baldas"}} -{"tool": "create_board", "arguments": {"name": "Balda 2", "origin_mm": [15.0, -117.19, 630], "size_mm": [600.0, 280, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Baldas"}} -{"tool": "create_board", "arguments": {"name": "Balda 3", "origin_mm": [15.0, 71.42, 1110], "size_mm": [600.0, 220, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Baldas"}} -{"tool": "create_board", "arguments": {"name": "Balda 4", "origin_mm": [15.0, 260.04, 1590], "size_mm": [600.0, 160, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Baldas"}} +{"tool": "rename", "arguments": {"id": "$9.created[0]", "name": "Right upright"}} +{"tool": "tag", "arguments": {"ids": ["$5.created[0]", "$9.created[0]"], "tag": "Uprights"}} +{"tool": "create_board", "arguments": {"name": "Shelf 1", "origin_mm": [15.0, -305.81, 150], "size_mm": [600.0, 340, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Shelves"}} +{"tool": "create_board", "arguments": {"name": "Shelf 2", "origin_mm": [15.0, -117.19, 630], "size_mm": [600.0, 280, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Shelves"}} +{"tool": "create_board", "arguments": {"name": "Shelf 3", "origin_mm": [15.0, 71.42, 1110], "size_mm": [600.0, 220, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Shelves"}} +{"tool": "create_board", "arguments": {"name": "Shelf 4", "origin_mm": [15.0, 260.04, 1590], "size_mm": [600.0, 160, 18.0], "grain_axis": "x", "material_id": "$2.material_id", "tag": "Shelves"}} {"tool": "add_joint", "arguments": {"a": "$12.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$12.created[0]", "b": "$9.created[0]", "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$13.created[0]", "b": "$5.created[0]", "kind": "tongue_groove"}} diff --git a/crates/benchup-cli/evals/reference/09-dimensions/batch.jsonl b/crates/benchup-cli/evals/reference/09-dimensions/batch.jsonl index d771c47..5779ac7 100644 --- a/crates/benchup-cli/evals/reference/09-dimensions/batch.jsonl +++ b/crates/benchup-cli/evals/reference/09-dimensions/batch.jsonl @@ -13,5 +13,5 @@ {"tool": "add_dimension", "arguments": {"start_mm": [982, 0, 909], "end_mm": [982, 0, 1194], "axis": "z", "offset_mm": [100, 0, 0]}} {"tool": "add_dimension", "arguments": {"start_mm": [982, 0, 1212], "end_mm": [982, 0, 1497], "axis": "z", "offset_mm": [100, 0, 0]}} {"tool": "add_dimension", "arguments": {"start_mm": [982, 0, 1515], "end_mm": [982, 0, 1800], "axis": "z", "offset_mm": [100, 0, 0]}} -{"tool": "add_text", "arguments": {"point_mm": [500.0, 0, 1800], "offset_mm": [0, 0, 500], "text": "Estantería"}} +{"tool": "add_text", "arguments": {"point_mm": [500.0, 0, 1800], "offset_mm": [0, 0, 500], "text": "Bookcase"}} {"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/10-organize/organize.jsonl b/crates/benchup-cli/evals/reference/10-organize/organize.jsonl index ffc5c0c..d712259 100644 --- a/crates/benchup-cli/evals/reference/10-organize/organize.jsonl +++ b/crates/benchup-cli/evals/reference/10-organize/organize.jsonl @@ -1,15 +1,15 @@ -{"tool": "rename", "arguments": {"id": 238, "name": "Balda"}} -{"tool": "rename", "arguments": {"id": 291, "name": "Balda"}} -{"tool": "rename", "arguments": {"id": 344, "name": "Balda"}} -{"tool": "rename", "arguments": {"id": 397, "name": "Balda"}} -{"tool": "rename", "arguments": {"id": 450, "name": "Balda"}} -{"tool": "rename", "arguments": {"id": 503, "name": "Balda"}} -{"tool": "rename", "arguments": {"id": 556, "name": "Balda"}} -{"tool": "rename", "arguments": {"id": 609, "name": "Balda"}} -{"tool": "rename", "arguments": {"id": 662, "name": "Balda"}} -{"tool": "rename", "arguments": {"id": 715, "name": "Balda"}} -{"tool": "tag", "arguments": {"ids": [238, 291, 344, 397, 450, 503, 556, 609, 662, 715], "tag": "Baldas"}} -{"tool": "tag", "arguments": {"ids": [79, 132, 185], "tag": "Estructura"}} -{"tool": "group", "arguments": {"ids": [79, 238, 291, 344, 397, 450], "name": "Módulo izquierdo", "component": false}} -{"tool": "group", "arguments": {"ids": [132, 503, 556, 609, 662, 715], "name": "Módulo derecho", "component": false}} +{"tool": "rename", "arguments": {"id": 238, "name": "Shelf"}} +{"tool": "rename", "arguments": {"id": 291, "name": "Shelf"}} +{"tool": "rename", "arguments": {"id": 344, "name": "Shelf"}} +{"tool": "rename", "arguments": {"id": 397, "name": "Shelf"}} +{"tool": "rename", "arguments": {"id": 450, "name": "Shelf"}} +{"tool": "rename", "arguments": {"id": 503, "name": "Shelf"}} +{"tool": "rename", "arguments": {"id": 556, "name": "Shelf"}} +{"tool": "rename", "arguments": {"id": 609, "name": "Shelf"}} +{"tool": "rename", "arguments": {"id": 662, "name": "Shelf"}} +{"tool": "rename", "arguments": {"id": 715, "name": "Shelf"}} +{"tool": "tag", "arguments": {"ids": [238, 291, 344, 397, 450, 503, 556, 609, 662, 715], "tag": "Shelves"}} +{"tool": "tag", "arguments": {"ids": [79, 132, 185], "tag": "Carcass"}} +{"tool": "group", "arguments": {"ids": [79, 238, 291, 344, 397, 450], "name": "Left module", "component": false}} +{"tool": "group", "arguments": {"ids": [132, 503, 556, 609, 662, 715], "name": "Right module", "component": false}} {"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/11-cut-material/batch.jsonl b/crates/benchup-cli/evals/reference/11-cut-material/batch.jsonl index 0635e3c..69a5272 100644 --- a/crates/benchup-cli/evals/reference/11-cut-material/batch.jsonl +++ b/crates/benchup-cli/evals/reference/11-cut-material/batch.jsonl @@ -1,4 +1,4 @@ -{"tool": "create_stock_material", "arguments": {"name": "Tablero 18 mm 2440x1220", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440}} -{"tool": "set_material", "arguments": {"ids": [79,132,185,238,291,344,397,450,503,556,609,662,715], "material_id": "$1.material_id"}} +{"tool": "create_stock_material", "arguments": {"name": "Panel 18 mm 2440x1220", "thickness_mm": 18, "width_mm": 1220, "length_mm": 2440}} +{"tool": "set_material", "arguments": {"ids": [79, 132, 185, 238, 291, 344, 397, 450, 503, 556, 609, 662, 715], "material_id": "$1.material_id"}} {"tool": "get_cut_list", "arguments": {}} {"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/12-impossible/alt.jsonl b/crates/benchup-cli/evals/reference/12-impossible/alt.jsonl index e34f9f5..f38a4c5 100644 --- a/crates/benchup-cli/evals/reference/12-impossible/alt.jsonl +++ b/crates/benchup-cli/evals/reference/12-impossible/alt.jsonl @@ -1,4 +1,4 @@ -{"tool": "create_board", "arguments": {"origin_mm": [9, 0, 1000], "size_mm": [491, 300, 18], "name": "Balda izquierda 1000", "grain_axis": "x"}} +{"tool": "create_board", "arguments": {"origin_mm": [9, 0, 1000], "size_mm": [491, 300, 18], "name": "Left shelf at 1000", "grain_axis": "x"}} {"tool": "add_joint", "arguments": {"a": "$1.created[0]", "b": 79, "kind": "tongue_groove"}} {"tool": "add_joint", "arguments": {"a": "$1.created[0]", "b": 185, "kind": "tongue_groove"}} {"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/12-impossible/literal.jsonl b/crates/benchup-cli/evals/reference/12-impossible/literal.jsonl index cd5c8e4..a43891a 100644 --- a/crates/benchup-cli/evals/reference/12-impossible/literal.jsonl +++ b/crates/benchup-cli/evals/reference/12-impossible/literal.jsonl @@ -1,3 +1,3 @@ -{"tool": "create_board", "arguments": {"origin_mm": [9, 0, 1000], "size_mm": [1200, 300, 18], "name": "Balda izquierda 1000"}} +{"tool": "create_board", "arguments": {"origin_mm": [9, 0, 1000], "size_mm": [1200, 300, 18], "name": "Left shelf at 1000"}} {"tool": "set_grain", "arguments": {"id": "$1.created[0]", "axis": "x"}} {"tool": "check_model", "arguments": {}} diff --git a/crates/benchup-cli/evals/reference/14-trim/bench.jsonl b/crates/benchup-cli/evals/reference/14-trim/bench.jsonl index 7b0252d..48242eb 100644 --- a/crates/benchup-cli/evals/reference/14-trim/bench.jsonl +++ b/crates/benchup-cli/evals/reference/14-trim/bench.jsonl @@ -1,15 +1,15 @@ -{"tool": "create_stock_material", "arguments": {"name": "Haya 50x50", "thickness_mm": 50, "width_mm": 50, "length_mm": 2000}} -{"tool": "create_stock_material", "arguments": {"name": "Haya 22 mm", "thickness_mm": 22, "width_mm": 200, "length_mm": 2500}} -{"tool": "create_board", "arguments": {"name": "Pata delantera izquierda", "origin_mm": [0, 0, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Pata delantera derecha", "origin_mm": [1150, 0, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Pata trasera izquierda", "origin_mm": [0, 300, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Pata trasera derecha", "origin_mm": [1150, 300, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} -{"tool": "create_board", "arguments": {"name": "Travesaño delantero", "origin_mm": [-30, 14, 300], "size_mm": [1260, 22, 70], "grain_axis": "x", "material_id": "$2.material_id"}} -{"tool": "create_board", "arguments": {"name": "Travesaño trasero", "origin_mm": [-30, 314, 300], "size_mm": [1260, 22, 70], "grain_axis": "x", "material_id": "$2.material_id"}} -{"tool": "create_board", "arguments": {"name": "Travesaño lateral izquierdo", "origin_mm": [14, -30, 200], "size_mm": [22, 410, 70], "grain_axis": "y", "material_id": "$2.material_id"}} -{"tool": "create_board", "arguments": {"name": "Travesaño lateral derecho", "origin_mm": [1164, -30, 200], "size_mm": [22, 410, 70], "grain_axis": "y", "material_id": "$2.material_id"}} -{"tool": "create_stock_material", "arguments": {"name": "Haya 25 mm", "thickness_mm": 25, "width_mm": 400, "length_mm": 2500}} -{"tool": "create_board", "arguments": {"name": "Asiento", "origin_mm": [0, 0, 425], "size_mm": [1200, 350, 25], "grain_axis": "x", "material_id": "$11.material_id"}} +{"tool": "create_stock_material", "arguments": {"name": "Beech 50x50", "thickness_mm": 50, "width_mm": 50, "length_mm": 2000}} +{"tool": "create_stock_material", "arguments": {"name": "Beech 22 mm", "thickness_mm": 22, "width_mm": 200, "length_mm": 2500}} +{"tool": "create_board", "arguments": {"name": "Front left leg", "origin_mm": [0, 0, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Front right leg", "origin_mm": [1150, 0, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Back left leg", "origin_mm": [0, 300, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Back right leg", "origin_mm": [1150, 300, 0], "size_mm": [50, 50, 425], "grain_axis": "z", "material_id": "$1.material_id"}} +{"tool": "create_board", "arguments": {"name": "Front rail", "origin_mm": [-30, 14, 300], "size_mm": [1260, 22, 70], "grain_axis": "x", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Back rail", "origin_mm": [-30, 314, 300], "size_mm": [1260, 22, 70], "grain_axis": "x", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Left rail", "origin_mm": [14, -30, 200], "size_mm": [22, 410, 70], "grain_axis": "y", "material_id": "$2.material_id"}} +{"tool": "create_board", "arguments": {"name": "Right rail", "origin_mm": [1164, -30, 200], "size_mm": [22, 410, 70], "grain_axis": "y", "material_id": "$2.material_id"}} +{"tool": "create_stock_material", "arguments": {"name": "Beech 25 mm", "thickness_mm": 25, "width_mm": 400, "length_mm": 2500}} +{"tool": "create_board", "arguments": {"name": "Seat", "origin_mm": [0, 0, 425], "size_mm": [1200, 350, 25], "grain_axis": "x", "material_id": "$11.material_id"}} {"tool": "add_joint", "arguments": {"a": "$7.created[0]", "b": "$3.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "upper"}}} {"tool": "add_joint", "arguments": {"a": "$7.created[0]", "b": "$4.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "lower"}}} {"tool": "add_joint", "arguments": {"a": "$8.created[0]", "b": "$5.created[0]", "kind": "mortise_tenon", "params": {"width_ratio": 0.33, "height_ratio": 0.75, "trim": "a", "keep_end": "upper"}}} From 937e871ab153e0ee7f70737a4bb5e2bbd7767918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Ortiz?= Date: Sat, 14 Nov 2026 12:27:30 +0100 Subject: [PATCH 5/5] Add a skill that runs an agent evaluation round .agents/skills/benchup-agent-eval tells Claude Code or Codex how to run a round of crates/benchup-cli/evals: the regression test first, one subagent per case with its prompt verbatim, an independent check of every result with check_model and a render, and results/round-N.md with the findings sorted into tool bugs, guide gaps, core limits and missing features. .claude/skills links to .agents/skills so Claude Code finds it. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/benchup-agent-eval/SKILL.md | 20 ++++++++++++++++++++ .claude/skills | 1 + crates/benchup-cli/evals/README.md | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/benchup-agent-eval/SKILL.md create mode 120000 .claude/skills diff --git a/.agents/skills/benchup-agent-eval/SKILL.md b/.agents/skills/benchup-agent-eval/SKILL.md new file mode 100644 index 0000000..2ef4947 --- /dev/null +++ b/.agents/skills/benchup-agent-eval/SKILL.md @@ -0,0 +1,20 @@ +--- +name: benchup-agent-eval +description: Run a round of the agent evaluation of the benchup CLI and MCP tools (crates/benchup-cli/evals) — one subagent per case, a results table compared with earlier rounds, and findings sorted into tool bugs, guide gaps and core limits. Use when asked to evaluate, test or "run the cases/battery/round" for the benchup CLI, MCP or agent guide, or after changing agent tools, their descriptions or docs/agent-guide.md. +--- + +# Agent evaluation round + +The cases, rules and earlier results are in `crates/benchup-cli/evals/` (read its README.md first). A round spends many tokens: run it only when the user asked for it, and say how many agents it will start. + +1. **Regression first.** `cargo test -p benchup-cli --test evals`. If it fails, stop and report: a reference solution no longer applies, which is a regression or a reference to update. +2. **Build and prepare.** `cargo build --release -p benchup-cli`, then `python3 crates/benchup-cli/evals/setup.py --round N` (N = the last round in `results/` plus one; `--cases 01,13` for a subset). It prints each case's folder and prompt file. +3. **Launch one subagent per case, in parallel, in the background.** Give each the content of its prompt file verbatim, with nothing added: no hints, no knowledge of the code, no expectations. The agents must learn the tools only from what the tools say. +4. **Check the results yourself; do not trust the reports alone.** For each case, run `benchup call check_model` and `benchup render --view iso` (look at the image) on the file the agent left, and compare with the request. +5. **Write `crates/benchup-cli/evals/results/round-N.md`:** + - a table per case: commands (errors), result ✅ / ⚠️ / ❌ checked in step 4, and the same columns from the previous round; + - which of each case's `focus` tools the agent actually used, and where it fell back to a workaround; + - the findings, sorted into: tool bugs and unclear errors or descriptions (fix in `benchup-tools`); guide gaps or mistakes (`docs/agent-guide.md`); core limits (an issue, with a minimal reproduction); missing features. +6. **Report to the user** with the table and the proposed fixes, and wait before changing code. After fixing, update reference batches if a tool changed on purpose, and add a reference for any new case an agent solved well. + +Keep the cases comparable across rounds: do not edit a case's request or seed; add a new case instead. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/crates/benchup-cli/evals/README.md b/crates/benchup-cli/evals/README.md index 309e81b..48b8161 100644 --- a/crates/benchup-cli/evals/README.md +++ b/crates/benchup-cli/evals/README.md @@ -5,7 +5,7 @@ The CLI and MCP tools are meant to be used by coding agents that have never seen There are two layers: 1. **Regression, on every change** — `cargo test -p benchup-cli --test evals`. Replays the reference solution of each case (`reference//*.jsonl`, batches that agents or maintainers got right) with the real binary and checks the result with `check_model`. It needs no agent and takes a few seconds. When a tool changes on purpose, update the reference batch. -2. **Agent rounds, on demand** — before a release, or after changing tools, descriptions or the [agent guide](../../../docs/agent-guide.md). One agent per case, run in parallel; each one reports what it did, how many commands it needed and where it got stuck. Not in CI: it costs tokens and the results vary from run to run. To run one: +2. **Agent rounds, on demand** — before a release, or after changing tools, descriptions or the [agent guide](../../../docs/agent-guide.md). One agent per case, run in parallel; each one reports what it did, how many commands it needed and where it got stuck. Not in CI: it costs tokens and the results vary from run to run. With Claude Code or Codex, the `benchup-agent-eval` skill in [`.agents/skills`](../../../.agents/skills/benchup-agent-eval/SKILL.md) runs a round (`.claude/skills` links to it); by hand: ```sh cargo build --release -p benchup-cli