Add web terminal, git credentials, OpenSCAD, review loops, automations
- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay) - Per-user git credentials and commit identity (credential store in the user's home) - OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card - Craft phase inferred from tool activity when the agent does not report it - Configurable review loop per mode (rounds, reviewer model, criteria) - Delete config and Markdown agents from the agent editor - Automations: cron-scheduled agents from Markdown job files, schedule tool, page - change_directory tool (only on the user's request) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0d0e707f2
commit
df1924b0a1
41 files changed
+2809
-34
No files matched your search
@@ -0,0 +1,270 @@
|
||||
//! OpenSCAD: render .scad files to STL/3MF and preview images (for 3D printing).
|
||||
|
||||
use super::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||
use crate::types::ImageData;
|
||||
use anyhow::{Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const VIEWS: &[(&str, &str)] = &[
|
||||
// rot_x, rot_y, rot_z for --camera (translate 0, distance from --viewall)
|
||||
("iso", "55,0,25"),
|
||||
("front", "90,0,0"),
|
||||
("right", "90,0,90"),
|
||||
("top", "0,0,0"),
|
||||
("back", "90,0,180"),
|
||||
("bottom", "180,0,0"),
|
||||
];
|
||||
|
||||
pub struct OpenScad;
|
||||
|
||||
/// Bounding box and triangle count of an ASCII or binary STL.
|
||||
fn stl_stats(data: &[u8]) -> Option<([f32; 3], [f32; 3], usize)> {
|
||||
let mut min = [f32::MAX; 3];
|
||||
let mut max = [f32::MIN; 3];
|
||||
let mut tris = 0;
|
||||
let mut add = |v: [f32; 3]| {
|
||||
for i in 0..3 {
|
||||
min[i] = min[i].min(v[i]);
|
||||
max[i] = max[i].max(v[i]);
|
||||
}
|
||||
};
|
||||
if data.starts_with(b"solid") && data.windows(6).take(2000).any(|w| w == b"vertex") {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
for line in text.lines() {
|
||||
let l = line.trim();
|
||||
if let Some(rest) = l.strip_prefix("vertex") {
|
||||
let v: Vec<f32> = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect();
|
||||
if v.len() == 3 {
|
||||
add([v[0], v[1], v[2]]);
|
||||
}
|
||||
} else if l.starts_with("facet") {
|
||||
tris += 1;
|
||||
}
|
||||
}
|
||||
} else if data.len() >= 84 {
|
||||
let n = u32::from_le_bytes(data[80..84].try_into().ok()?) as usize;
|
||||
for t in 0..n {
|
||||
let base = 84 + t * 50 + 12;
|
||||
if base + 36 > data.len() {
|
||||
break;
|
||||
}
|
||||
for k in 0..3 {
|
||||
let o = base + k * 12;
|
||||
let f = |i: usize| f32::from_le_bytes(data[o + i * 4..o + i * 4 + 4].try_into().unwrap());
|
||||
add([f(0), f(1), f(2)]);
|
||||
}
|
||||
tris += 1;
|
||||
}
|
||||
}
|
||||
(tris > 0).then_some((min, max, tris))
|
||||
}
|
||||
|
||||
fn param_value(v: &Value) -> Option<String> {
|
||||
Some(match v {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
|
||||
Value::Array(a) => format!("[{}]", a.iter().filter_map(param_value).collect::<Vec<_>>().join(",")),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for OpenScad {
|
||||
fn name(&self) -> &str {
|
||||
"openscad"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Render an OpenSCAD file (.scad) for 3D printing: exports STL and/or 3MF next to the file and \
|
||||
returns preview images from several views (iso, front, right, top, back, bottom) plus the \
|
||||
bounding box in mm, the triangle count and OpenSCAD warnings (e.g. non-manifold geometry). \
|
||||
Use `params` to override top-level variables (-D) without editing the file. Look at the \
|
||||
previews and compare them with the requirements or the user's sketch, then refine."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": ".scad file"},
|
||||
"formats": {"type": "array", "items": {"type": "string", "enum": ["stl", "3mf", "png"]}, "description": "Default [\"stl\", \"png\"]"},
|
||||
"views": {"type": "array", "items": {"type": "string", "enum": VIEWS.iter().map(|v| v.0).collect::<Vec<_>>()}, "description": "Preview views, default [\"iso\", \"front\", \"top\"]"},
|
||||
"params": {"type": "object", "description": "Variable overrides, e.g. {\"wall\": 2, \"label\": \"A\"}"},
|
||||
"output": {"type": "string", "description": "Base name for exports (default: same as the .scad file)"}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
fn is_edit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("OpenSCAD {}", input["path"].as_str().unwrap_or(""))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let scad = ctx.resolve(arg_str(&input, "path")?)?;
|
||||
if !scad.is_file() {
|
||||
bail!("{} does not exist – write the .scad file first", scad.display());
|
||||
}
|
||||
let formats: Vec<String> = input["formats"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["stl".into(), "png".into()]);
|
||||
let views: Vec<String> = input["views"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["iso".into(), "front".into(), "top".into()]);
|
||||
let dir = scad.parent().unwrap_or(Path::new(".")).to_path_buf();
|
||||
let stem = input["output"].as_str().map(String::from).unwrap_or_else(|| scad.file_stem().and_then(|s| s.to_str()).unwrap_or("model").to_string());
|
||||
let stem = stem.replace(['/', '\\'], "_");
|
||||
let mut defines: Vec<String> = Vec::new();
|
||||
if let Some(p) = input["params"].as_object() {
|
||||
for (k, v) in p {
|
||||
if !k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
bail!("invalid parameter name '{k}'");
|
||||
}
|
||||
if let Some(val) = param_value(v) {
|
||||
defines.push("-D".into());
|
||||
defines.push(format!("{k}={val}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut report = Vec::new();
|
||||
let mut warnings = String::new();
|
||||
let mut images = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let preview_dir = dir.join(".codecraft").join("cad");
|
||||
|
||||
// Mesh exports.
|
||||
for fmt in formats.iter().filter(|f| *f == "stl" || *f == "3mf") {
|
||||
let out = dir.join(format!("{stem}.{fmt}"));
|
||||
let mut args: Vec<String> = vec!["-o".into(), out.display().to_string()];
|
||||
args.extend(defines.iter().cloned());
|
||||
args.push(scad.display().to_string());
|
||||
let (ok, log) = run_scad(ctx, &dir, &args, false).await?;
|
||||
warnings.push_str(&log);
|
||||
if !ok {
|
||||
return Ok(ToolOutput::error(format!("OpenSCAD failed:\n{}", tail(&log, 4000))));
|
||||
}
|
||||
files.push(out.clone());
|
||||
if fmt == "stl" {
|
||||
if let Some((min, max, tris)) = std::fs::read(&out).ok().and_then(|d| stl_stats(&d)) {
|
||||
report.push(format!(
|
||||
"Bounding box: {:.2} × {:.2} × {:.2} mm (x {:.2}..{:.2}, y {:.2}..{:.2}, z {:.2}..{:.2}), {tris} triangles",
|
||||
max[0] - min[0], max[1] - min[1], max[2] - min[2], min[0], max[0], min[1], max[1], min[2], max[2]
|
||||
));
|
||||
if min[2].abs() > 0.01 {
|
||||
report.push(format!("Note: the model does not sit on z=0 (lowest point z={:.2}) – slicers drop it onto the bed.", min[2]));
|
||||
}
|
||||
} else {
|
||||
report.push("The exported STL is empty – check the geometry (e.g. difference() removed everything).".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview images (rendered, so they show the final geometry).
|
||||
if formats.iter().any(|f| f == "png") {
|
||||
std::fs::create_dir_all(&preview_dir)?;
|
||||
for view in &views {
|
||||
let Some((_, rot)) = VIEWS.iter().find(|v| v.0 == view) else { continue };
|
||||
let out = preview_dir.join(format!("{stem}-{view}.png"));
|
||||
let mut args: Vec<String> = vec![
|
||||
"-o".into(),
|
||||
out.display().to_string(),
|
||||
"--render".into(),
|
||||
"--imgsize=900,700".into(),
|
||||
"--viewall".into(),
|
||||
"--autocenter".into(),
|
||||
"--projection=p".into(),
|
||||
"--colorscheme=Tomorrow".into(),
|
||||
format!("--camera=0,0,0,{rot},0"),
|
||||
];
|
||||
args.extend(defines.iter().cloned());
|
||||
args.push(scad.display().to_string());
|
||||
let (ok, log) = run_scad(ctx, &dir, &args, true).await?;
|
||||
if !ok {
|
||||
warnings.push_str(&log);
|
||||
continue;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(&out) {
|
||||
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
|
||||
files.push(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let warn: Vec<&str> = warnings
|
||||
.lines()
|
||||
.filter(|l| l.contains("WARNING") || l.contains("ERROR") || l.contains("not a valid 2-manifold") || l.contains("Simple: no"))
|
||||
.collect();
|
||||
let rel = |p: &PathBuf| p.strip_prefix(&ctx.cwd).map(|r| r.display().to_string()).unwrap_or_else(|_| p.display().to_string());
|
||||
let mut text = format!("Rendered {}.\nFiles: {}\n", rel(&scad), files.iter().map(rel).collect::<Vec<_>>().join(", "));
|
||||
for r in &report {
|
||||
text.push_str(r);
|
||||
text.push('\n');
|
||||
}
|
||||
if !warn.is_empty() {
|
||||
text.push_str("OpenSCAD messages:\n");
|
||||
for w in warn.iter().take(20) {
|
||||
text.push_str(w.trim());
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
if !images.is_empty() {
|
||||
text.push_str(&format!("Preview images ({}): {}", images.len(), views.join(", ")));
|
||||
}
|
||||
let meta = json!({"files": files.iter().map(rel).collect::<Vec<_>>()});
|
||||
Ok(ToolOutput { content: text, images, meta: Some(meta), is_error: false })
|
||||
}
|
||||
}
|
||||
|
||||
fn tail(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut start = s.len() - max;
|
||||
while !s.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
s[start..].to_string()
|
||||
}
|
||||
|
||||
/// Run openscad (PNG output through a virtual X server), sandboxed for non-admins.
|
||||
async fn run_scad(ctx: &ToolCtx, dir: &Path, args: &[String], display: bool) -> Result<(bool, String)> {
|
||||
let mut full: Vec<&str> = Vec::new();
|
||||
if display {
|
||||
full.extend(["xvfb-run", "-a", "-s", "-screen 0 1280x1024x24", "openscad"]);
|
||||
} else {
|
||||
full.push("openscad");
|
||||
}
|
||||
full.extend(args.iter().map(String::as_str));
|
||||
let mut cmd = match &ctx.sandbox {
|
||||
Some(spec) => {
|
||||
if !crate::sandbox::available() {
|
||||
bail!("OpenSCAD is disabled for this account: the sandbox is not available");
|
||||
}
|
||||
crate::sandbox::command(spec, dir, full[0], &full[1..])
|
||||
}
|
||||
None => {
|
||||
let mut c = tokio::process::Command::new(full[0]);
|
||||
c.args(&full[1..]).current_dir(dir);
|
||||
c
|
||||
}
|
||||
};
|
||||
cmd.kill_on_drop(true);
|
||||
let out = tokio::time::timeout(Duration::from_secs(300), cmd.output())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("OpenSCAD timed out after 5 minutes (simplify the model or lower $fn)"))?
|
||||
.map_err(|e| anyhow::anyhow!("cannot run OpenSCAD ({e}) – is it installed?"))?;
|
||||
let log = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
Ok((out.status.success(), log))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn ascii_stl_bbox() {
|
||||
let stl = b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 10 0 0\nvertex 0 5 2\nendloop\nendfacet\nendsolid x\n";
|
||||
let (min, max, n) = super::stl_stats(stl).unwrap();
|
||||
assert_eq!((min, max, n), ([0.0, 0.0, 0.0], [10.0, 5.0, 2.0], 1));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user