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>
271 lines
12 KiB
Rust
271 lines
12 KiB
Rust
//! 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));
|
||
}
|
||
}
|