3D models
STL and OBJ models read: triangles, size, volume, surface area and whether the mesh is closed, a 3D-print quote by weight, and OBJ turned into binary STL.
Version 1.0.0 · 3 functions · 0 crates
Functions
What use models_3d.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use model3d_info, model3d_print_estimate from models_3d (Quillo 0.6).
model3d_info(path: str) → anyor an errorWhat the model at path (.stl, binary or text, or .obj) is: { format, name, triangles, vertices, min, max, size, center, volume, area, closed, open_edges, inside_out }. Lengths in the file's own unit (millimetres for most 3D printing): volume in that unit cubed, area squared. closed is true when every edge joins exactly two triangles (a watertight mesh, whose volume means something); inside_out when its faces point inwards.
model3d_print_estimate(path: str, options: any) → anyor an errorA 3D-print quote for the model at path, in millimetres: options has any of density (g/cm³; 1.24, PLA), infill (0 to 1; 0.2), wall (the shell's thickness in mm; 1.2), price_per_kg (25.0), bed (the printer's build volume [x, y, z] in mm; [220, 220, 250]). Gives { volume_cm3, grams, cost, fits, size }: the shell printed solid, the inside at infill; fits when the model goes on the bed in some upright turn.
model3d_to_stl(path: str, to: str, scale: float) → intor an errorThe model at path (OBJ, or STL in text) written to to as a binary STL, the form slicers and printing services take, scaled by scale (1.0 keeps it; 25.4 turns inches into millimetres): the number of triangles written.
Get it
$ quillo install models_3d # models_3d.qwer into this folder$ quillo install models_3d --example # and its example, models_3d.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/models_3d.qwer$ curl -O https://quillolang.org/plugins/files/models_3d.qwe$ curl -O https://quillolang.org/plugins/files/models_3d_sample.stl$ curl -O https://quillolang.org/plugins/files/models_3d_sample.objThen, in your program, all of it or only what you call:
use models_3d.qweruse model3d_info, model3d_print_estimate from models_3d // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// A 3D-printing shop's quote: what a customer's model measures, what it weighs and costs in PLA or PETG,// and their OBJ file turned into the STL the slicer takes.//// quillo run models_3d.qwe then open http://localhost:8080/model and http://localhost:8080/quoteuse "models_3d.qwer"route GET /model = { box: model3d_info("models_3d_sample.stl")?, pyramid: model3d_info("models_3d_sample.obj")?,}route GET /quote { let pla = model3d_print_estimate("models_3d_sample.stl", { infill: 0.2, price_per_kg: 25.0 })? let petg = model3d_print_estimate("models_3d_sample.stl", { density: 1.27, infill: 1.0, price_per_kg: 30.0, bed: [30.0, 30.0, 30.0] })? { pla: pla, petg_solid: petg }}route GET /convert { let n = model3d_to_stl("models_3d_sample.obj", "data/pyramid.stl", 1.0)? model3d_to_stl("models_3d_sample.obj", "data/pyramid-x2.stl", 2.0)? { triangles: n, original: model3d_info("data/pyramid.stl")?.volume, doubled: model3d_info("data/pyramid-x2.stl")?.volume }}$ quillo run models_3d.qwe$ curl 'localhost:8080/model'$ curl 'localhost:8080/quote'$ curl 'localhost:8080/convert'Source
//! 3D models read from STL (binary and text) and OBJ files: triangles, size, volume, surface area and whether the mesh is closed, a 3D-print estimate of weight and cost, and OBJ turned into STL.//! version: 1.0.0//!//! [dependencies]use serde_json::{json, Value};use std::collections::HashMap;const MAX_TRIANGLES: usize = 20_000_000;type V3 = [f64; 3];fn sub(a: V3, b: V3) -> V3 { [a[0] - b[0], a[1] - b[1], a[2] - b[2]]}fn cross(a: V3, b: V3) -> V3 { [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]}fn dot(a: V3, b: V3) -> f64 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2]}fn norm(a: V3) -> f64 { dot(a, a).sqrt()}struct Mesh { format: &'static str, name: Option<String>, tris: Vec<[V3; 3]>,}fn num(s: Option<&str>, what: &str, line: usize) -> Result<f64, String> { s.and_then(|t| t.parse::<f64>().ok()).filter(|x| x.is_finite()).ok_or_else(|| format!("line {}: a {} that is not a number", line, what))}fn read_stl_binary(d: &[u8]) -> Result<Mesh, String> { let n = u32::from_le_bytes([d[80], d[81], d[82], d[83]]) as usize; if n > MAX_TRIANGLES { return Err(format!("{} triangles: more than 20 million", n)); } let mut tris = Vec::with_capacity(n); for i in 0..n { let at = 84 + i * 50 + 12; let f = |k: usize| f32::from_le_bytes([d[at + k * 4], d[at + k * 4 + 1], d[at + k * 4 + 2], d[at + k * 4 + 3]]) as f64; tris.push([[f(0), f(1), f(2)], [f(3), f(4), f(5)], [f(6), f(7), f(8)]]); } let header = String::from_utf8_lossy(&d[..80]).trim_end_matches(['\0', ' ']).trim().to_string(); Ok(Mesh { format: "stl", name: if header.is_empty() { None } else { Some(header) }, tris })}fn read_stl_text(t: &str) -> Result<Mesh, String> { let mut tris = Vec::new(); let mut cur: Vec<V3> = Vec::new(); let mut name = None; for (i, line) in t.lines().enumerate() { let mut w = line.split_whitespace(); match w.next() { Some("solid") if name.is_none() => { let rest = line.trim_start()[5..].trim(); if !rest.is_empty() { name = Some(rest.to_string()); } } Some("vertex") => { cur.push([num(w.next(), "coordinate", i + 1)?, num(w.next(), "coordinate", i + 1)?, num(w.next(), "coordinate", i + 1)?]); } Some("endloop") => { // A facet is a triangle; a polygon of more, which some writers make, is cut into a fan. for k in 1..cur.len().saturating_sub(1) { tris.push([cur[0], cur[k], cur[k + 1]]); } cur.clear(); if tris.len() > MAX_TRIANGLES { return Err("more than 20 million triangles".into()); } } _ => {} } } Ok(Mesh { format: "stl", name, tris })}fn read_obj(t: &str) -> Result<Mesh, String> { let mut verts: Vec<V3> = Vec::new(); let mut tris = Vec::new(); let mut name = None; for (i, line) in t.lines().enumerate() { let line = line.split('#').next().unwrap_or(""); let mut w = line.split_whitespace(); match w.next() { Some("v") => verts.push([num(w.next(), "coordinate", i + 1)?, num(w.next(), "coordinate", i + 1)?, num(w.next(), "coordinate", i + 1)?]), Some("o") | Some("g") if name.is_none() => name = w.next().map(String::from), Some("f") => { let mut idx = Vec::new(); for p in w { let first = p.split('/').next().unwrap_or(""); let k: i64 = first.parse().map_err(|_| format!("line {}: the face index `{}` is not a number", i + 1, p))?; // OBJ counts from 1; a negative index counts back from the last vertex so far. let at = if k > 0 { k - 1 } else { verts.len() as i64 + k }; if at < 0 || at as usize >= verts.len() { return Err(format!("line {}: the face uses vertex {}, of {} so far", i + 1, k, verts.len())); } idx.push(at as usize); } for k in 1..idx.len().saturating_sub(1) { tris.push([verts[idx[0]], verts[idx[k]], verts[idx[k + 1]]]); } if tris.len() > MAX_TRIANGLES { return Err("more than 20 million triangles".into()); } } _ => {} } } Ok(Mesh { format: "obj", name, tris })}fn load(path: &str) -> Result<Mesh, String> { let d = std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?; let lower = path.to_lowercase(); let m = if lower.ends_with(".obj") { read_obj(&String::from_utf8_lossy(&d))? } else if d.len() >= 84 && d.len() == 84 + 50 * u32::from_le_bytes([d[80], d[81], d[82], d[83]]) as usize { // Binary STL: the size says so, even when its header starts with "solid", as some exporters write it. read_stl_binary(&d)? } else if d.trim_ascii_start().starts_with(b"solid") { read_stl_text(&String::from_utf8_lossy(&d))? } else if lower.ends_with(".stl") { return Err(format!("{}: not an STL file (a binary one's size does not match its triangle count)", path)); } else { return Err(format!("{}: not an STL or OBJ file", path)); }; if m.tris.is_empty() { return Err(format!("{}: no triangles", path)); } Ok(m)}fn round(x: f64, places: i32) -> f64 { let p = 10f64.powi(places); (x * p).round() / p}struct Measure { min: V3, max: V3, signed_volume: f64, area: f64, vertices: usize, open_edges: usize,}fn measure(m: &Mesh) -> Measure { let mut min = [f64::INFINITY; 3]; let mut max = [f64::NEG_INFINITY; 3]; let mut vol = 0.0; let mut area = 0.0; let mut ids: HashMap<[u64; 3], usize> = HashMap::new(); let mut edges: HashMap<(usize, usize), u32> = HashMap::new(); for t in &m.tris { for p in t { for k in 0..3 { min[k] = min[k].min(p[k]); max[k] = max[k].max(p[k]); } } vol += dot(t[0], cross(t[1], t[2])) / 6.0; area += norm(cross(sub(t[1], t[0]), sub(t[2], t[0]))) / 2.0; // The same point is the same vertex: STL repeats it in every triangle, rounded to a micrometre-ish grid. let id: Vec<usize> = t .iter() .map(|p| { let key = [(p[0] * 1e5).round() as i64 as u64, (p[1] * 1e5).round() as i64 as u64, (p[2] * 1e5).round() as i64 as u64]; let n = ids.len(); *ids.entry(key).or_insert(n) }) .collect(); for (a, b) in [(id[0], id[1]), (id[1], id[2]), (id[2], id[0])] { if a != b { *edges.entry((a.min(b), a.max(b))).or_insert(0) += 1; } } } let open_edges = edges.values().filter(|&&c| c != 2).count(); Measure { min, max, signed_volume: vol, area, vertices: ids.len(), open_edges }}/// What the model at `path` (.stl, binary or text, or .obj) is: `{ format, name, triangles, vertices, min, max, size, center, volume, area, closed, open_edges, inside_out }`./// Lengths in the file's own unit (millimetres for most 3D printing): `volume` in that unit cubed, `area` squared. `closed` is true when every edge/// joins exactly two triangles (a watertight mesh, whose volume means something); `inside_out` when its faces point inwards.pub fn model3d_info(path: &str) -> Result<Value, String> { let m = load(path)?; let s = measure(&m); let size: Vec<f64> = (0..3).map(|k| round(s.max[k] - s.min[k], 4)).collect(); Ok(json!({ "format": m.format, "name": m.name, "triangles": m.tris.len(), "vertices": s.vertices, "min": s.min.iter().map(|x| round(*x, 4)).collect::<Vec<_>>(), "max": s.max.iter().map(|x| round(*x, 4)).collect::<Vec<_>>(), "size": size, "center": (0..3).map(|k| round((s.min[k] + s.max[k]) / 2.0, 4)).collect::<Vec<_>>(), "volume": round(s.signed_volume.abs(), 3), "area": round(s.area, 3), "closed": s.open_edges == 0, "open_edges": s.open_edges, "inside_out": s.signed_volume < 0.0 && s.open_edges == 0, }))}/// A 3D-print quote for the model at `path`, in millimetres: `options` has any of `density` (g/cm³; 1.24, PLA), `infill` (0 to 1; 0.2),/// `wall` (the shell's thickness in mm; 1.2), `price_per_kg` (25.0), `bed` (the printer's build volume `[x, y, z]` in mm; [220, 220, 250])./// Gives `{ volume_cm3, grams, cost, fits, size }`: the shell printed solid, the inside at `infill`; `fits` when the model goes on the bed in some upright turn.pub fn model3d_print_estimate(path: &str, options: serde_json::Value) -> Result<Value, String> { let m = load(path)?; let s = measure(&m); let opt = |k: &str, d: f64| options.get(k).and_then(|v| v.as_f64()).unwrap_or(d); let density = opt("density", 1.24); let infill = opt("infill", 0.2).clamp(0.0, 1.0); let wall = opt("wall", 1.2).max(0.0); let price = opt("price_per_kg", 25.0); let bed: Vec<f64> = options.get("bed").and_then(|b| b.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or(vec![220.0, 220.0, 250.0]); if bed.len() != 3 { return Err("`bed` is three numbers, [x, y, z] in mm".into()); } let volume = s.signed_volume.abs(); let shell = (s.area * wall).min(volume); let printed_mm3 = shell + (volume - shell) * infill; let grams = printed_mm3 / 1000.0 * density; let size: Vec<f64> = (0..3).map(|k| s.max[k] - s.min[k]).collect(); // Turned about the vertical axis only (z stays up): x and y may swap. let fits = size[2] <= bed[2] && ((size[0] <= bed[0] && size[1] <= bed[1]) || (size[1] <= bed[0] && size[0] <= bed[1])); Ok(json!({ "volume_cm3": round(volume / 1000.0, 3), "grams": round(grams, 1), "cost": round(grams / 1000.0 * price, 2), "fits": fits, "size": size.iter().map(|x| round(*x, 2)).collect::<Vec<_>>(), "closed": s.open_edges == 0, }))}/// The model at `path` (OBJ, or STL in text) written to `to` as a binary STL, the form slicers and printing services take, scaled by `scale`/// (1.0 keeps it; 25.4 turns inches into millimetres): the number of triangles written.pub fn model3d_to_stl(path: &str, to: &str, scale: f64) -> Result<i64, String> { let m = load(path)?; if !(scale.is_finite() && scale > 0.0) { return Err("`scale` is a number above 0".into()); } let mut out = Vec::with_capacity(84 + m.tris.len() * 50); let mut header = format!("{} from {} by Quillo", m.name.clone().unwrap_or_else(|| "model".into()), m.format).into_bytes(); header.resize(80, b' '); out.extend_from_slice(&header); out.extend_from_slice(&(m.tris.len() as u32).to_le_bytes()); for t in &m.tris { let t: Vec<V3> = t.iter().map(|p| [p[0] * scale, p[1] * scale, p[2] * scale]).collect(); let n = cross(sub(t[1], t[0]), sub(t[2], t[0])); let l = norm(n); let n = if l > 0.0 { [n[0] / l, n[1] / l, n[2] / l] } else { [0.0; 3] }; for x in n.iter().chain(t.iter().flatten()) { out.extend_from_slice(&(*x as f32).to_le_bytes()); } out.extend_from_slice(&[0, 0]); } if let Some(dir) = std::path::Path::new(to).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?; } std::fs::write(to, out).map_err(|e| format!("{}: {}", to, e))?; Ok(m.tris.len() as i64)}Related
disk_space.qwer · 1.0.0Free and total space of the disk a path is on, for health checks, refusing uploads before the disk fills, and admin pages.8 views · 8 downloadsFile type file_type.qwer · 1.0.0What a file really is from its first bytes, not its name: MIME type, extension and kind, for upload checks.7 views · 9 downloadsFonts fonts.qwer · 1.0.0A font file's names, weight and style, glyph count, which characters it covers, and how wide a text is set in it.5 views · 7 downloadsMIME types mime.qwer · 1.0.0MIME types from file names and extensions, the extensions of a type, and Content-Type headers with a charset for text.8 views · 7 downloadsZip files archive.qwer · 1.0.0Zip files made from several files and opened safely: nothing written outside the folder, no zip bombs.6 views · 5 downloads7-Zip sevenz.qwer · 1.0.07-Zip archives listed, one file read, opened safely into a folder, and made from a folder.7 views · 8 downloadsThread 0 posts
On 3D models: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.