Files

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

#files#uploads#printing#calculators

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 error

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.

model3d_print_estimate(path: str, options: any) → anyor an error

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.

model3d_to_stl(path: str, to: str, scale: float) → intor an error

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.

Get it

$ quillo install models_3d               # models_3d.qwer into this folder$ quillo install models_3d --example     # and its example, models_3d.qwe

Or 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.obj

Then, in your program, all of it or only what you call:

main.qwe
use models_3d.qweruse model3d_info, model3d_print_estimate from models_3d      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]

Example

models_3d.qwe
// 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

models_3d.qwer
//! 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)}
Thread
67Useful? Like it, or add to its thread below.

Thread 0 posts

On 3D models: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.