Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ jobs:
- name: Test core
run: cargo test --locked -p benchup-core

- name: Test agent tools and CLI
run: cargo test --locked -p benchup-tools -p benchup-cli

app:
name: Test settings and estimate PDF export
runs-on: ubuntu-24.04
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = [
"crates/benchup-io",
"crates/benchup-skp",
"crates/benchup-tools",
"crates/benchup-cli",
"crates/benchup-app",
]
default-members = ["crates/benchup-app"]
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@ Verified during implementation: native Linux and browser WASM Cargo checks; core
## Layout

- `crates/benchup-core`: double-precision document geometry, planar face discovery/triangulation, inference, picking, camera, tool definitions, and bounded history. It has no GUI or GPU dependencies.
- `crates/benchup-tools`: the operations an agent may call (schemas, validation, scene summaries) and `.bup` encode/decode. Depends only on `benchup-core`.
- `crates/benchup-cli`: the `benchup` command-line tool for reading and editing `.bup` files from a shell or an AI agent, including an MCP server over stdio. See [docs/cli.md](docs/cli.md).
- `crates/benchup-app`: egui workspace, gesture routing, local persistence, native/browser launchers, and the wgpu/WGSL edge renderer. Model coordinates remain independent of GPU resources.
- `index.html` and `Trunk.toml`: static browser host and WASM development configuration.

Expand Down
3 changes: 2 additions & 1 deletion assets/i18n/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -2181,5 +2181,6 @@
"app.measurements_unavailable": "Measurements unavailable.",
"app.calculating_measurements": "Calculating measurements…",
"sidebar.move_wireframe": "Wireframe other objects while moving",
"sidebar.selected_edge_lengths": "Selected Edge Lengths"
"sidebar.selected_edge_lengths": "Selected Edge Lengths",
"cli.agent_edit": "Agent edit"
}
3 changes: 2 additions & 1 deletion assets/i18n/es_419.json
Original file line number Diff line number Diff line change
Expand Up @@ -2181,5 +2181,6 @@
"app.measurements_unavailable": "Mediciones no disponibles.",
"app.calculating_measurements": "Calculando mediciones…",
"sidebar.move_wireframe": "Estructura alámbrica de otros objetos al mover",
"sidebar.selected_edge_lengths": "Longitudes de aristas seleccionadas"
"sidebar.selected_edge_lengths": "Longitudes de aristas seleccionadas",
"cli.agent_edit": "Edición del agente"
}
3 changes: 2 additions & 1 deletion assets/i18n/pt_BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -2181,5 +2181,6 @@
"app.measurements_unavailable": "Medições indisponíveis.",
"app.calculating_measurements": "Calculando medições…",
"sidebar.move_wireframe": "Exibir outros objetos em aramado ao mover",
"sidebar.selected_edge_lengths": "Comprimentos das arestas selecionadas"
"sidebar.selected_edge_lengths": "Comprimentos das arestas selecionadas",
"cli.agent_edit": "Edição do agente"
}
3 changes: 2 additions & 1 deletion assets/i18n/vi_VN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2181,5 +2181,6 @@
"app.measurements_unavailable": "Không có phép đo.",
"app.calculating_measurements": "Tính số đo…",
"sidebar.move_wireframe": "Hiển thị khung dây cho đối tượng khác khi di chuyển",
"sidebar.selected_edge_lengths": "Chiều dài các cạnh đã chọn"
"sidebar.selected_edge_lengths": "Chiều dài các cạnh đã chọn",
"cli.agent_edit": "Chỉnh sửa của tác nhân"
}
3 changes: 2 additions & 1 deletion assets/i18n/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2181,5 +2181,6 @@
"app.measurements_unavailable": "无法获取测量数据。",
"app.calculating_measurements": "正在计算测量数据…",
"sidebar.move_wireframe": "移动时以线框形式显示其他对象",
"sidebar.selected_edge_lengths": "所选边长"
"sidebar.selected_edge_lengths": "所选边长",
"cli.agent_edit": "智能体编辑"
}
1 change: 1 addition & 0 deletions crates/benchup-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ rust-version.workspace = true

[dependencies]
benchup-core = { path = "../benchup-core" }
benchup-tools = { path = "../benchup-tools" }
benchup-io = { path = "../benchup-io" }
serde.workspace = true
serde_json.workspace = true
Expand Down
77 changes: 12 additions & 65 deletions crates/benchup-app/src/project_files.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
//! Portable project files are separate from the local recovery/autosave document.
use benchup_core::tr;
use crate::cutting_plan::{CutReport, CuttingPlan};
use flate2::{Compression, read::MultiGzDecoder, write::GzEncoder};
use benchup_core::Document;
use std::{
io::{Read, Write},
path::PathBuf,
sync::mpsc,
};
use std::{path::PathBuf, sync::mpsc};
// The tests build and inspect project bytes by hand.
#[cfg(test)]
use flate2::{Compression, read::MultiGzDecoder, write::GzEncoder};
#[cfg(test)]
use std::io::{Read, Write};

#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "project_file_tests.rs"]
Expand Down Expand Up @@ -94,45 +94,17 @@ fn exchange_name(name: &str, extension: &str) -> String {
}

/// Portable previews use PNG with a private gzip document chunk. Headless saves
/// retain gzip JSON; recovery and save snapshots stay JSON.
/// retain gzip JSON; recovery and save snapshots stay JSON. The format lives in
/// `benchup_tools::files`, shared with the command-line tools.
pub(crate) fn encode(json: &str) -> Result<Vec<u8>, String> {
let mut value: serde_json::Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
let preview = value.as_object_mut().and_then(|v| v.remove("viewport_preview_png"));
let json = if preview.is_some() { serde_json::to_string(&value).map_err(|e| e.to_string())? } else { json.to_owned() };
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(json.as_bytes()).map_err(|e| e.to_string())?;
let compressed = encoder.finish().map_err(|e| e.to_string())?;
if let Some(preview) = preview {
let mut png: Vec<u8> = serde_json::from_value(preview).map_err(|e| e.to_string())?;
// Insert a private ancillary chunk immediately before IEND. Standard PNG
// readers ignore it and display the saved GPU viewport thumbnail.
let end = png.len().checked_sub(12).ok_or(tr!("project_files.invalid_preview"))?;
let mut chunk = Vec::with_capacity(compressed.len() + 12);
chunk.extend_from_slice(&(u32::try_from(compressed.len()).map_err(|e| e.to_string())?).to_be_bytes());
chunk.extend_from_slice(b"buPd");
chunk.extend_from_slice(&compressed);
chunk.extend_from_slice(&png_crc(&chunk[4..]).to_be_bytes());
png.splice(end..end, chunk);
Ok(png)
} else { Ok(compressed) }
benchup_tools::files::encode(json)
}

pub fn decode(bytes: &[u8]) -> Result<Document, String> {
let bytes = project_payload(bytes)?;
// Detect the content rather than the extension so all project extensions
// accept compressed files and older, plain JSON files unchanged.
let mut decompressed = Vec::new();
let bytes: &[u8] = if bytes.starts_with(&[0x1f, 0x8b]) {
MultiGzDecoder::new(bytes)
.read_to_end(&mut decompressed)
.map_err(|error| tr!("project_files.could_not_decompress_project", error = error))?;
&decompressed
} else {
bytes
};
let json = std::str::from_utf8(bytes).map_err(|_| tr!("project_files.the_project_is_not_utf_8_text"))?;
Document::from_json(json)
benchup_tools::files::decode(bytes)
}
#[cfg(test)]
use benchup_tools::files::project_payload;

#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn run(
Expand Down Expand Up @@ -783,28 +755,3 @@ pub fn extract_preview(path: &std::path::Path, output: &std::path::Path) -> Resu
image.save_with_format(output, image::ImageFormat::Png).map_err(|e| e.to_string())
}

fn png_crc(bytes: &[u8]) -> u32 {
let mut crc = !0u32;
for &byte in bytes {
crc ^= byte as u32;
for _ in 0..8 { crc = (crc >> 1) ^ (0xedb88320u32 & 0u32.wrapping_sub(crc & 1)); }
}
!crc
}

fn project_payload(bytes: &[u8]) -> Result<&[u8], String> {
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Ok(bytes); }
let mut position = 8usize;
while let Some(header) = bytes.get(position..position.saturating_add(8)) {
let length = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize;
let end = position.checked_add(12).and_then(|v| v.checked_add(length)).ok_or(tr!("project_files.invalid_chunk_size"))?;
let chunk = bytes.get(position..end).ok_or(tr!("project_files.truncated_chunk"))?;
if &header[4..8] == b"buPd" {
let checksum = u32::from_be_bytes(chunk[8 + length..].try_into().unwrap());
if png_crc(&chunk[4..8 + length]) != checksum { return Err(tr!("project_files.invalid_checksum").into()); }
return Ok(&chunk[8..8 + length]);
}
position = end;
}
Err(tr!("project_files.png_has_no_project").into())
}
19 changes: 19 additions & 0 deletions crates/benchup-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "benchup-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Command-line access to BenchUp project files, for shells and AI agents"

[[bin]]
name = "benchup"
path = "src/main.rs"

[dependencies]
benchup-core = { path = "../benchup-core" }
benchup-tools = { path = "../benchup-tools" }
serde_json.workspace = true

[dev-dependencies]
tempfile = "3"
7 changes: 7 additions & 0 deletions crates/benchup-cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# benchup (CLI)

The `benchup` command reads and edits BenchUp `.bup` projects from a shell or an AI agent, and serves the same tools over MCP. It is built on [`benchup-tools`](../benchup-tools).

- User documentation: [docs/cli.md](../../docs/cli.md)
- Guide for agents (also `benchup guide`): [docs/agent-guide.md](../../docs/agent-guide.md)
- Claude Code skill: [skill/benchup](skill/benchup/SKILL.md)
16 changes: 16 additions & 0 deletions crates/benchup-cli/skill/benchup/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
name: benchup
description: Design or edit furniture and woodworking projects in BenchUp (.bup files) with the `benchup` command-line tool. Use when the user mentions BenchUp, a .bup file, or asks to model, change, dimension or cost a piece of furniture (shelves, cabinets, tables, drawers, joints, cut lists).
---

# BenchUp projects

Use the `benchup` CLI to read and edit `.bup` projects. It edits the file, not an open BenchUp window.

1. Run `benchup guide` once and follow it. It explains the axes (the front faces -Y), joint roles, the batch workflow and recipes.
2. Read the project with `benchup scene file.bup`. Create a new one with `benchup new file.bup --name "..."`.
3. Make each change as one `benchup call file.bup --batch -` (one undo step), generating the JSON lines with a script for repeated parts. Use `--dry-run` to try first.
4. Verify with `benchup call file.bup check_model` and, for materials, `get_cut_list`.
5. Show the result with `benchup open file.bup`.

If a size does not fit or the request is ambiguous, say so and propose an interpretation instead of forcing it. `benchup tools <name>` shows any tool's exact arguments.
107 changes: 107 additions & 0 deletions crates/benchup-cli/src/launch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! `benchup open`: hand a project to the BenchUp desktop app.
//!
//! The CLI does not link the app. It asks the operating system to open the
//! file with the installed app (macOS by bundle id, Linux and Windows through
//! the `.bup` file association), or starts a given executable directly. No
//! shell parses the path, so names with `&`, `%` or `^` open as they are. If the
//! app is already running, macOS hands it the file; the app reads the file
//! from disk again, asking first if the open project has unsaved changes.
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

pub const DOWNLOAD_URL: &str = "https://github.com/BenchUp3D/editor/releases/latest";

#[cfg(target_os = "macos")]
const BUNDLE_ID: &str = "io.github.BenchUp3D.BenchUp";

/// An absolute path to an existing `.bup` file, which is the only kind of
/// argument the app opens.
fn project_path(path: &str) -> Result<PathBuf, String> {
let path = Path::new(path);
if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("bup")) {
return Err(format!("{} is not a .bup project; BenchUp only opens .bup files", path.display()));
}
let absolute = path.canonicalize().map_err(|e| format!("cannot open {}: {e}", path.display()))?;
if !absolute.is_file() {
return Err(format!("{} is not a file", path.display()));
}
Ok(plain_path(absolute))
}

/// Windows `canonicalize` gives a verbatim `\\?\C:\...` path, which file
/// associations and many apps do not accept; this is the usual form of it.
#[cfg(windows)]
fn plain_path(path: PathBuf) -> PathBuf {
let text = path.to_string_lossy().into_owned();
if let Some(share) = text.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{share}"))
} else if let Some(local) = text.strip_prefix(r"\\?\").filter(|rest| rest.as_bytes().get(1) == Some(&b':')) {
PathBuf::from(local)
} else {
path
}
}

#[cfg(not(windows))]
fn plain_path(path: PathBuf) -> PathBuf {
path
}

/// The command the operating system uses to open `path` in the installed app.
fn system_command(path: &Path) -> Command {
#[cfg(target_os = "macos")]
{
let mut command = Command::new("open");
command.arg("-b").arg(BUNDLE_ID).arg(path);
command
}
// Explorer opens the file with its association without a shell parsing
// the path (`cmd /C start` would split it at `&` and expand `%NAME%`).
#[cfg(target_os = "windows")]
{
let mut command = Command::new("explorer.exe");
command.arg(path);
command
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let mut command = Command::new("xdg-open");
command.arg(path);
command
}
}

/// Opens `path` in BenchUp: in `app` (an executable) if given, otherwise in
/// the installed app.
pub fn open(path: &str, app: Option<&str>) -> Result<Value, String> {
let path = project_path(path)?;
let opened = path.display().to_string();
if let Some(app) = app {
// Started detached: the app keeps running after this command exits,
// and its output does not mix with this command's JSON.
Command::new(app)
.arg(&path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("cannot start {app}: {e}"))?;
return Ok(json!({"ok": true, "opened": opened, "app": app}));
}
let mut command = system_command(&path);
let program = command.get_program().to_string_lossy().into_owned();
let output = command
.stdin(Stdio::null())
.output()
.map_err(|e| format!("cannot run {program}: {e}. Is BenchUp installed? {DOWNLOAD_URL}"))?;
// Explorer exits with 1 even when it opened the file.
if !output.status.success() && !cfg!(target_os = "windows") {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
return Err(format!(
"could not open {opened} in BenchUp ({program}: {detail}). \
Install the desktop app from {DOWNLOAD_URL}, or pass --app <executable>"
));
}
Ok(json!({"ok": true, "opened": opened, "app": "BenchUp"}))
}
Loading
Loading