Files
codecraft/server/src/tools/cad.rs
T
Richard ZinkandClaude Opus 5.5 e8e455b779 Store chat images separately; JPEG for large PNGs; gzip/brotli responses
Images of messages (pasted images, screenshots, generated images) were base64
inside the message JSON, so opening a chat downloaded all of them at once
(66 MB for a chat with 120 screenshots). They now live in a `blobs` table,
addressed by SHA-256; messages keep a reference (blob, size). The UI loads
them from /api/blobs/<hash> lazily with an immutable cache header, the runner
fills the bytes in before model requests, JSON exports stay self-contained.

- Store::add_message moves inline images out; large opaque PNGs become JPEG
  (q85) when smaller. Older chats are converted once at start-up in the
  background, unused images are removed (also after deleting a chat) and the
  database is vacuumed when that frees 32 MB or more.
- Responses are compressed with gzip/brotli (not images, SSE streams or
  binary downloads).

Example (copy of production): chat load 66 MB -> 1.5 MB (370 KB brotli),
database 90 MB -> 27 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:21:24 +01:00

271 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 { 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::<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));
}
}