//! 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 = 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 { 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::>().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::>()}, "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 { 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 = 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 = 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 = 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 = 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 = 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 { blob: None, size: None, 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::>().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::>()}); 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)); } }