forked from 1b-agent/opencadstudio-upstream
STLOUT/STEPOUT wrote via std::fs and IMPORTOBJ read via std::fs, all
of which fail on wasm32. The export pickers now mirror the PDF path
(pick_*_owned returns a name-only PathBuf; the browser download names
the file) and the write tail swaps fs::write for sys::download_bytes.
OBJ import reads the picked file's bytes directly, like the web open
picker.
Opening by drag-and-drop was dead on the web build: winit never emits
FileDropped there. A wasm-gated io::web_drop module installs DOM
dragover/drop listeners, filters .dwg/.dxf, reads the File bytes
async, and queues them; the existing 50 ms PollWebControl tick drains
the queue into the same open path the picker uses (progress overlay,
recovery, OPFS recent cache).
Also floors the model-space plot sheet: extents smaller than 10 mm
(or the 0x0 paper a fresh unsaved drawing carries) now fall back to
the standard sheet instead of producing a ~3 pt page.
(cherry picked from commit ad60be11ed)
191 lines
6.0 KiB
Rust
191 lines
6.0 KiB
Rust
// STL binary export — converts all tessellated MeshModels in the scene to a
|
||
// single binary STL file.
|
||
//
|
||
// Binary STL format:
|
||
// 80-byte header
|
||
// 4-byte triangle count (u32 LE)
|
||
// Per triangle (50 bytes):
|
||
// 3 × f32 normal
|
||
// 3 × 3 × f32 vertices
|
||
// 2-byte attribute (0)
|
||
|
||
use std::io::Write;
|
||
|
||
use crate::scene::model::mesh_model::MeshModel;
|
||
|
||
/// Build a binary STL byte buffer from a slice of mesh models.
|
||
/// Returns `None` if there are no triangles to export.
|
||
pub fn build_stl(meshes: &[&MeshModel]) -> Option<Vec<u8>> {
|
||
// Collect all triangles.
|
||
struct Tri {
|
||
normal: [f32; 3],
|
||
v: [[f32; 3]; 3],
|
||
}
|
||
|
||
let mut tris: Vec<Tri> = Vec::new();
|
||
|
||
for mesh in meshes {
|
||
let verts = &mesh.verts;
|
||
let idx = &mesh.indices;
|
||
let n_tri = idx.len() / 3;
|
||
for t in 0..n_tri {
|
||
let i0 = idx[t * 3] as usize;
|
||
let i1 = idx[t * 3 + 1] as usize;
|
||
let i2 = idx[t * 3 + 2] as usize;
|
||
if i0 >= verts.len() || i1 >= verts.len() || i2 >= verts.len() {
|
||
continue;
|
||
}
|
||
let a = verts[i0];
|
||
let b = verts[i1];
|
||
let c = verts[i2];
|
||
|
||
// The facet normal is the triangle's own. A smoothed vertex normal
|
||
// leans off it, so only a triangle whose cross product is zero
|
||
// falls back to the normal the mesh gives it. Small facets have a
|
||
// tiny cross product but still a normal of their own.
|
||
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||
let nx = ab[1] * ac[2] - ab[2] * ac[1];
|
||
let ny = ab[2] * ac[0] - ab[0] * ac[2];
|
||
let nz = ab[0] * ac[1] - ab[1] * ac[0];
|
||
let len = nx.hypot(ny).hypot(nz);
|
||
let normal = if len == 0.0 && i0 < mesh.normals.len() {
|
||
mesh.normals[i0]
|
||
} else {
|
||
let len = len.max(f32::MIN_POSITIVE);
|
||
[nx / len, ny / len, nz / len]
|
||
};
|
||
|
||
tris.push(Tri {
|
||
normal,
|
||
v: [a, b, c],
|
||
});
|
||
}
|
||
}
|
||
|
||
if tris.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mut buf: Vec<u8> = Vec::with_capacity(84 + tris.len() * 50);
|
||
|
||
// 80-byte header.
|
||
let mut header = [0u8; 80];
|
||
let title = b"Open CAD Studio STL export";
|
||
header[..title.len()].copy_from_slice(title);
|
||
buf.extend_from_slice(&header);
|
||
|
||
// Triangle count.
|
||
buf.extend_from_slice(&(tris.len() as u32).to_le_bytes());
|
||
|
||
for tri in &tris {
|
||
// Normal.
|
||
for &f in &tri.normal {
|
||
buf.write_all(&f.to_le_bytes()).ok()?;
|
||
}
|
||
// Vertices.
|
||
for v in &tri.v {
|
||
for &f in v {
|
||
buf.write_all(&f.to_le_bytes()).ok()?;
|
||
}
|
||
}
|
||
// Attribute byte count = 0.
|
||
buf.extend_from_slice(&0u16.to_le_bytes());
|
||
}
|
||
|
||
Some(buf)
|
||
}
|
||
|
||
/// Web: no save dialog — the export continues with a virtual filename and
|
||
/// the generated bytes are delivered as a browser download.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub async fn pick_stl_path_owned(stem: String) -> Option<std::path::PathBuf> {
|
||
Some(std::path::PathBuf::from(format!("{stem}.stl")))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::build_stl;
|
||
use crate::scene::model::mesh_model::MeshModel;
|
||
|
||
fn triangle(verts: Vec<[f32; 3]>, normals: Vec<[f32; 3]>) -> MeshModel {
|
||
MeshModel {
|
||
name: String::new(),
|
||
verts,
|
||
verts_low: Vec::new(),
|
||
normals,
|
||
indices: vec![0, 1, 2],
|
||
triangle_material_handles: Vec::new(),
|
||
triangle_colors: Vec::new(),
|
||
color: [1.0; 4],
|
||
selected: false,
|
||
}
|
||
}
|
||
|
||
/// The first facet's normal: the 12 bytes after the 80-byte header and
|
||
/// the 4-byte triangle count.
|
||
fn first_facet_normal(stl: &[u8]) -> [f32; 3] {
|
||
[0, 1, 2].map(|k| {
|
||
let at = 84 + 4 * k;
|
||
f32::from_le_bytes(stl[at..at + 4].try_into().expect("4 bytes"))
|
||
})
|
||
}
|
||
|
||
/// A curved surface shares smoothed normals across its facets, so a
|
||
/// vertex normal leans away from the triangle. STL stores the facet's
|
||
/// own normal.
|
||
#[test]
|
||
fn the_facet_normal_is_the_triangles_own() {
|
||
let leaning = [
|
||
std::f32::consts::FRAC_1_SQRT_2,
|
||
0.0,
|
||
std::f32::consts::FRAC_1_SQRT_2,
|
||
];
|
||
let mesh = triangle(
|
||
vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
|
||
vec![leaning; 3],
|
||
);
|
||
let stl = build_stl(&[&mesh]).expect("stl");
|
||
assert_eq!(first_facet_normal(&stl), [0.0, 0.0, 1.0]);
|
||
}
|
||
|
||
/// A small facet still has a normal of its own. Its cross product is tiny
|
||
/// (1e-8 here), far below f32::EPSILON, but it is not zero.
|
||
#[test]
|
||
fn a_small_facet_keeps_its_own_normal() {
|
||
let leaning = [
|
||
std::f32::consts::FRAC_1_SQRT_2,
|
||
0.0,
|
||
std::f32::consts::FRAC_1_SQRT_2,
|
||
];
|
||
let mesh = triangle(
|
||
vec![[0.0, 0.0, 0.0], [1e-4, 0.0, 0.0], [0.0, 1e-4, 0.0]],
|
||
vec![leaning; 3],
|
||
);
|
||
let stl = build_stl(&[&mesh]).expect("stl");
|
||
assert_eq!(first_facet_normal(&stl), [0.0, 0.0, 1.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_facet_whose_squared_cross_product_underflows_keeps_its_own_normal() {
|
||
let mesh = triangle(
|
||
vec![[0.0, 0.0, 0.0], [1.0e-12, 0.0, 0.0], [0.0, 1.0e-12, 0.0]],
|
||
vec![[0.0, 1.0, 0.0]; 3],
|
||
);
|
||
let stl = build_stl(&[&mesh]).expect("stl");
|
||
assert_eq!(first_facet_normal(&stl), [0.0, 0.0, 1.0]);
|
||
}
|
||
|
||
/// A facet with no area has no normal of its own, so it keeps the one
|
||
/// the mesh gives it rather than a zero vector.
|
||
#[test]
|
||
fn a_degenerate_facet_keeps_its_vertex_normal() {
|
||
let mesh = triangle(
|
||
vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
|
||
vec![[0.0, 1.0, 0.0]; 3],
|
||
);
|
||
let stl = build_stl(&[&mesh]).expect("stl");
|
||
assert_eq!(first_facet_normal(&stl), [0.0, 1.0, 0.0]);
|
||
}
|
||
}
|