mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-11 19:22:01 +00:00
* claim: #1553 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * File export: save bodies as a GLB file (#1553) File > Export GLB..., the Home export tiles, the CLI (--out part.glb), MCP and file.export now write one binary glTF 2.0 file. Each body is a node named after it, placed where the design places it; one root node turns millimetres and Z up into the metres and Y up glTF uses. Looks become pbrMetallicRoughness materials (colour decoded to linear, metallic, roughness), resolved as the Render workspace resolves them, with KHR_materials_ior only when the IOR is not 1.5. A transparent body or face, or a body a render setup gives an image texture or optics, is refused with a message naming the body, and the file is written atomically, so a refusal leaves an existing file as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GLB export: refuse an oversized model as each body is meshed The 1 GiB size limit was checked only after every selected body had been meshed and kept, so a very large assembly could exhaust memory before the refusal. The running total is now checked after each body's mesh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GLB export: document the remaining helpers in glb.rs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * File menu: scroll the rows when the menu is taller than the window At 1280x720 the File menu ran past the window's bottom, hiding Preferences, Keyboard Shortcuts and Quit (main already hid the last two; the Export GLB row pushed one more off). The rows now scroll inside a ScrollArea capped to the room below the File button, as the panel drop-downs do, and shrink to their content in a taller window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -21,7 +21,7 @@ scripts and AI agents can drive the same commands you use.
|
||||
- Sheet metal with flat patterns and DXF export, and a plastic-enclosure subset.
|
||||
- A Render workspace with GPU rendering, studio lighting, materials and image export
|
||||
(first release in 0.22.0).
|
||||
- Import and export of STEP, IGES, 3MF, STL, OBJ and DXF, and FreeCAD and Rhino files.
|
||||
- Import and export of STEP, IGES, 3MF, STL, OBJ and DXF, GLB export for viewers and renderers, and FreeCAD and Rhino files.
|
||||
- Autosave and crash recovery, and the browser build of the same app.
|
||||
|
||||
The full list of commands is in [docs/features.md](docs/features.md). Release notes for each
|
||||
|
||||
@@ -280,12 +280,26 @@ fn results_still_save_and_export_the_script_once() {
|
||||
let saved = dir.0.join("box.json");
|
||||
let stl = dir.0.join("box.stl");
|
||||
let obj = dir.0.join("box.obj");
|
||||
let glb = dir.0.join("box.glb");
|
||||
let result = json_output(
|
||||
cli().arg("run").arg(dir.script()).arg("--results").arg("--save").arg(&saved).arg("--out").arg(&stl).arg("--out").arg(&obj).output().unwrap(),
|
||||
cli()
|
||||
.arg("run")
|
||||
.arg(dir.script())
|
||||
.arg("--results")
|
||||
.arg("--save")
|
||||
.arg(&saved)
|
||||
.arg("--out")
|
||||
.arg(&stl)
|
||||
.arg("--out")
|
||||
.arg(&obj)
|
||||
.arg("--out")
|
||||
.arg(&glb)
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(result.as_array().unwrap().len(), 1);
|
||||
assert_eq!(body_count(&saved), 1);
|
||||
for exported in [&stl, &obj] {
|
||||
for exported in [&stl, &obj, &glb] {
|
||||
assert!(std::fs::metadata(exported).unwrap().len() > 0);
|
||||
assert_eq!(body_count(exported), 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"en": { "ui.export_glb": "Export GLB" },
|
||||
"ar": { "ui.export_glb": "تصدير GLB" },
|
||||
"de": { "ui.export_glb": "Als GLB exportieren" },
|
||||
"he": { "ui.export_glb": "ייצוא GLB" },
|
||||
"ja": { "ui.export_glb": "GLB をエクスポート" },
|
||||
"ko": { "ui.export_glb": "GLB 내보내기" },
|
||||
"vi": { "ui.export_glb": "Xuất tệp GLB" },
|
||||
"zh": { "ui.export_glb": "导出 GLB" }
|
||||
}
|
||||
@@ -98,7 +98,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&EXPORT_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
|
||||
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
|
||||
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ, 3MF and GLB: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
|
||||
// One command per format, so each File › Export item can have a key of its own; started
|
||||
// from a key or command search, the app opens the save picker (the mesh dialog for STL and
|
||||
// 3MF). Each takes `file.export`'s parameters for its format, without `format`.
|
||||
@@ -1175,6 +1175,9 @@ fn export_to(
|
||||
// The colours of the bodies go in a material library beside the mesh file.
|
||||
return export_obj(s, path, bodies, quality);
|
||||
}
|
||||
if format == Format::Glb {
|
||||
return export_glb(s, cmd, path, bodies, quality);
|
||||
}
|
||||
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
|
||||
// A design with components exports as a STEP assembly; otherwise bodies as placed.
|
||||
let bytes = if format == Format::Design {
|
||||
@@ -1308,6 +1311,31 @@ fn export_obj(s: &Session, path: &str, bodies: &[String], quality: solidcraft_io
|
||||
Ok(json!({"path": path, "bytes": files.obj.len(), "format": format!("{:?}", Format::Obj), "files": written}))
|
||||
}
|
||||
|
||||
/// The GLB export (#1553): the bodies as placed, with the looks the design shows them in.
|
||||
/// Written atomically, so a refusal or a failed write leaves a file already there as it was. A
|
||||
/// body a render setup gives an image texture or optics is refused: the file has no place for
|
||||
/// them, and writing the body without them would show it wrongly.
|
||||
fn export_glb(s: &Session, cmd: &str, path: &str, bodies: &[String], quality: solidcraft_io::MeshQuality) -> Result<Value> {
|
||||
solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
|
||||
let world = s.world_state()?;
|
||||
let bound = super::render::bound_bodies(s, cmd)?;
|
||||
for b in world.bodies.iter().filter(|b| bodies.is_empty() || bodies.contains(&b.name)) {
|
||||
if let Some((setup, what)) = bound.get(&b.name) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"{}: render setup `{setup}` gives it {what}, which GLB export does not write yet; remove it from the setup, or leave the body out with `bodies`",
|
||||
b.name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
let bytes = solidcraft_io::export_glb(&s.doc, &world, bodies, &name, quality)?;
|
||||
solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, false).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{:?}", Format::Glb)}))
|
||||
}
|
||||
|
||||
/// The assembly as a URDF and its meshes next to it (docs/urdf.md). Every path is checked before
|
||||
/// anything is written; the meshes go first and the `.urdf` last.
|
||||
fn export_urdf(s: &Session, cmd: &str, path: &str, p: &Value) -> Result<Value> {
|
||||
@@ -1650,6 +1678,131 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The JSON chunk of a GLB file, after checking its header and chunk lengths.
|
||||
fn glb_json(bytes: &[u8]) -> Value {
|
||||
let u32_at = |i: usize| u32::from_le_bytes(bytes[i..i + 4].try_into().unwrap()) as usize;
|
||||
assert_eq!(&bytes[..4], b"glTF");
|
||||
assert_eq!((u32_at(4), u32_at(8)), (2, bytes.len()));
|
||||
let json_len = u32_at(12);
|
||||
assert_eq!(&bytes[16..20], b"JSON");
|
||||
assert_eq!(json_len % 4, 0);
|
||||
let bin = 20 + json_len;
|
||||
assert_eq!((&bytes[bin + 4..bin + 8], u32_at(bin) + bin + 8), (&b"BIN\0"[..], bytes.len()));
|
||||
serde_json::from_slice(&bytes[20..bin]).unwrap()
|
||||
}
|
||||
|
||||
/// Each body node of a GLB with its mesh's bounds in millimetres (the POSITION accessor's),
|
||||
/// after checking the root node turns millimetres and Z up into metres and Y up.
|
||||
fn glb_bodies(g: &Value) -> Vec<(String, [f64; 3], [f64; 3])> {
|
||||
let root = &g["nodes"][0];
|
||||
assert_eq!(g["scenes"][0]["nodes"], json!([0]));
|
||||
assert_eq!(root["scale"], json!([0.001, 0.001, 0.001]));
|
||||
let half = std::f64::consts::FRAC_1_SQRT_2;
|
||||
assert_eq!(root["rotation"], json!([-half, 0.0, 0.0, half]));
|
||||
let v3 = |v: &Value| -> [f64; 3] { [0, 1, 2].map(|i| v[i].as_f64().unwrap()) };
|
||||
let children = root["children"].as_array().unwrap();
|
||||
children
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let node = &g["nodes"][c.as_u64().unwrap() as usize];
|
||||
let mesh = &g["meshes"][node["mesh"].as_u64().unwrap() as usize];
|
||||
let accessor = &g["accessors"][mesh["primitives"][0]["attributes"]["POSITION"].as_u64().unwrap() as usize];
|
||||
(node["name"].as_str().unwrap().to_string(), v3(&accessor["min"]), v3(&accessor["max"]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #1553: a GLB export holds every body where the assembly places it, each a node named after
|
||||
/// the body, under one root that makes millimetres metres and Z up Y up. A component placed
|
||||
/// twice is written twice, each copy where it stands, and `bodies` exports only those named.
|
||||
#[test]
|
||||
fn a_glb_export_holds_each_body_where_the_assembly_places_it() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-glb-placed-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [-30, 0, 0], "body_name": "Loose"})).unwrap();
|
||||
s.execute("component.create", &json!({"name": "Bracket"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"})).unwrap();
|
||||
s.execute("solid.cylinder", &json!({"radius": 5, "height": 10, "base": [10, 10, 10], "operation": "new", "body_name": "Boss"})).unwrap();
|
||||
s.execute("component.activate", &json!({"component": "root"})).unwrap();
|
||||
s.execute("occurrence.copy", &json!({"component": "Bracket", "translate": [0, 40, 0]})).unwrap();
|
||||
|
||||
let out = s.execute("file.export", &json!({"path": path("assembly.glb")})).unwrap();
|
||||
assert_eq!(out["format"], "Glb");
|
||||
let bodies = glb_bodies(&glb_json(&std::fs::read(path("assembly.glb")).unwrap()));
|
||||
let near = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3);
|
||||
let mut found: Vec<([f64; 3], [f64; 3])> = bodies.iter().map(|(_, lo, hi)| (*lo, *hi)).collect();
|
||||
found.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let mut want = vec![
|
||||
([-30.0, 0.0, 0.0], [-20.0, 10.0, 10.0]),
|
||||
([0.0, 0.0, 0.0], [20.0, 20.0, 10.0]),
|
||||
([0.0, 40.0, 0.0], [20.0, 60.0, 10.0]),
|
||||
([5.0, 5.0, 10.0], [15.0, 15.0, 20.0]),
|
||||
([5.0, 45.0, 10.0], [15.0, 55.0, 20.0]),
|
||||
];
|
||||
want.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
assert_eq!(found.len(), want.len(), "{bodies:?}");
|
||||
for (f, w) in found.iter().zip(&want) {
|
||||
assert!(near(f.0, w.0) && near(f.1, w.1), "{f:?} is not {w:?}: {bodies:?}");
|
||||
}
|
||||
let loose = bodies.iter().find(|(n, _, _)| n == "Loose").unwrap();
|
||||
assert!(near(loose.1, [-30.0, 0.0, 0.0]), "{loose:?}");
|
||||
|
||||
s.execute("file.export", &json!({"path": path("loose.glb"), "bodies": ["Loose"]})).unwrap();
|
||||
let one = glb_bodies(&glb_json(&std::fs::read(path("loose.glb")).unwrap()));
|
||||
assert_eq!(one.iter().map(|(n, _, _)| n.as_str()).collect::<Vec<_>>(), ["Loose"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1553: a GLB material has the look's colour (linear, as glTF's factors are), metallic and
|
||||
/// roughness, and a face's own look wins over its body's. A transparent body, or a body a
|
||||
/// render setup gives optics, is refused, and the file already at the path stays as it was.
|
||||
#[test]
|
||||
fn a_glb_export_writes_opaque_looks_and_refuses_what_it_cannot_hold() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-glb-looks-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("block.glb").to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"})).unwrap();
|
||||
let pbr = json!({"metallic": 1.0, "roughness": 0.25, "dielectric_ior": 1.5});
|
||||
s.execute("appearance.assign", &json!({"bodies": ["Block"], "name": "Red metal", "color": [255, 0, 0], "opacity": 1.0, "pbr": pbr})).unwrap();
|
||||
s.execute("appearance.assign", &json!({"body": "Block", "faces": [[5, 10, 30]], "color": "#00ff00"})).unwrap();
|
||||
s.execute("file.export", &json!({"path": path})).unwrap();
|
||||
let written = std::fs::read(&path).unwrap();
|
||||
let g = glb_json(&written);
|
||||
let materials = g["materials"].as_array().unwrap();
|
||||
let factor = |name: &str| materials.iter().find(|m| m["name"] == name).map(|m| m["pbrMetallicRoughness"].clone());
|
||||
let red = factor("Red metal").unwrap_or_else(|| panic!("{materials:?}"));
|
||||
assert_eq!(red, json!({"baseColorFactor": [1.0, 0.0, 0.0, 1.0], "metallicFactor": 1.0, "roughnessFactor": 0.25}));
|
||||
let green = materials.iter().find(|m| m["pbrMetallicRoughness"]["baseColorFactor"] == json!([0.0, 1.0, 0.0, 1.0]));
|
||||
assert!(green.is_some_and(|m| m["pbrMetallicRoughness"]["metallicFactor"] == 0.0), "{materials:?}");
|
||||
assert_eq!(g["meshes"][0]["primitives"].as_array().map(Vec::len), Some(2), "the top face is a primitive of its own");
|
||||
assert!(g.get("extensionsUsed").is_none(), "IOR 1.5 is glTF's own: {g}");
|
||||
|
||||
// Refused: the file written above stays as it was.
|
||||
s.execute("appearance.assign", &json!({"bodies": ["Block"], "color": "#2040c0", "opacity": 0.5})).unwrap();
|
||||
let e = s.execute("file.export", &json!({"path": path})).unwrap_err().to_string();
|
||||
assert!(e.contains("Block") && e.contains("transparent"), "{e}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), written);
|
||||
s.execute("appearance.assign", &json!({"bodies": ["Block"], "color": "#2040c0", "opacity": 1.0})).unwrap();
|
||||
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 4, "corner": [40, 0, 0], "body_name": "Diffuser"})).unwrap();
|
||||
s.execute("render.settings.set", &json!({"name": "Studio", "set": {"job": {"method": "gpu_path"}}})).unwrap();
|
||||
let index = crate::product_scene::extract(&s).unwrap().meshes.iter().position(|m| m.name == "Diffuser").unwrap();
|
||||
s.execute(
|
||||
"render.optics.bind",
|
||||
&json!({"name": "Studio", "body_index": index, "body_name": "Diffuser", "expected_revision": s.revision, "optics": {"ior": 1.4}}),
|
||||
)
|
||||
.unwrap();
|
||||
let e = s.execute("file.export", &json!({"path": path})).unwrap_err().to_string();
|
||||
assert!(e.contains("Diffuser") && e.contains("render setup `Studio`") && e.contains("optics"), "{e}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), written);
|
||||
s.execute("file.export", &json!({"path": path, "bodies": ["Block"]})).unwrap();
|
||||
assert_ne!(std::fs::read(&path).unwrap(), written, "the body without optics exports");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Each format's own export command (one key each in Keyboard Shortcuts) writes that format
|
||||
/// whatever the path's extension, as `file.export` does with `format`, and says so.
|
||||
#[test]
|
||||
|
||||
@@ -416,6 +416,27 @@ fn backends(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
"cpu_resource_profiles": solidcraft_render::job::CpuBudget::ALL.map(|budget| json!({"id":budget,"limits":budget.limits()}))}))
|
||||
}
|
||||
|
||||
/// The placed bodies (by world name) that a saved render setup gives an image texture or optics,
|
||||
/// each with the first such setup's name and what it gives: what a file without render setups
|
||||
/// cannot hold (GLB export, #1553). A binding whose body is gone binds nothing.
|
||||
pub(super) fn bound_bodies(s: &Session, cmd: &str) -> Result<std::collections::BTreeMap<String, (String, &'static str)>> {
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
let setups = &s.doc.render.setups;
|
||||
if setups.values().all(|x| x.surfaces.is_empty() && x.optics.is_empty()) {
|
||||
return Ok(out);
|
||||
}
|
||||
let names = surface_bindings::targets(s, cmd)?;
|
||||
for (setup, x) in setups {
|
||||
let bound = x.surfaces.iter().map(|b| (&b.target, "an image texture")).chain(x.optics.iter().map(|b| (&b.target, "optics")));
|
||||
for (target, what) in bound {
|
||||
if let Some(name) = names.get(target) {
|
||||
out.entry(name.clone()).or_insert_with(|| (setup.clone(), what));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -115,7 +115,7 @@ pub struct ExportParams {
|
||||
/// The file to write.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// The format: stl, stla, obj, step, 3mf, 3mf-tagged, urdf or json, or a file name whose
|
||||
/// The format: stl, stla, obj, step, 3mf, 3mf-tagged, glb, urdf or json, or a file name whose
|
||||
/// extension names it. Default: from the path's extension.
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
@@ -123,7 +123,7 @@ pub struct ExportParams {
|
||||
/// complete design, so they refuse a list.
|
||||
#[serde(default)]
|
||||
pub bodies: Option<StringList>,
|
||||
/// How closely the mesh follows curved faces (STL, OBJ and 3MF). Default: standard. Not read
|
||||
/// How closely the mesh follows curved faces (STL, OBJ, 3MF and GLB). Default: standard. Not read
|
||||
/// for URDF.
|
||||
#[serde(default)]
|
||||
pub quality: Option<MeshQualityParam>,
|
||||
@@ -242,7 +242,7 @@ pub struct ExportResult {
|
||||
/// Its size in bytes.
|
||||
pub bytes: usize,
|
||||
/// The format written: Step, StlBinary, ThreeMf or Obj (and for `file.export` and
|
||||
/// `file.save_mesh` also StlAscii or ThreeMfTagged, and for `file.export` also Design).
|
||||
/// `file.save_mesh` also StlAscii or ThreeMfTagged, and for `file.export` also Design and Glb).
|
||||
pub format: String,
|
||||
/// OBJ only: every file written, the material library first and the mesh file last.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -730,7 +730,7 @@ pub struct RecoveryDiscardResult {
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum FileExportResult {
|
||||
/// STL, OBJ, STEP, 3MF or design JSON.
|
||||
/// STL, OBJ, STEP, 3MF, GLB or design JSON.
|
||||
File(ExportResult),
|
||||
/// URDF.
|
||||
Urdf(UrdfExportResult),
|
||||
|
||||
@@ -257,6 +257,16 @@ pub static FORMATS: &[FormatSpec] = &[
|
||||
import: Some(ImportSpec { target: ImportTarget::Mesh, options: &[UP_Y], native_only: false }),
|
||||
export: None,
|
||||
},
|
||||
// Writing is GLB only (one file); the glTF row above reads both, and stays the row a
|
||||
// `.glb` path finds first.
|
||||
FormatSpec {
|
||||
id: "glb",
|
||||
label: "GLB (glTF binary)",
|
||||
extensions: &["glb"],
|
||||
role: Role::Artifact,
|
||||
import: None,
|
||||
export: Some(ExportSpec { command: "file.export", format: None, options: &[], scope: ExportScope::Bodies, lossless: false }),
|
||||
},
|
||||
FormatSpec {
|
||||
id: "ply",
|
||||
label: "PLY point cloud",
|
||||
@@ -486,6 +496,10 @@ mod tests {
|
||||
assert_eq!(for_path("a.3mf").map(|f| f.id), Some("3mf"));
|
||||
assert_eq!(for_path("a.dxf").map(|f| f.id), Some("dxf-sketch"));
|
||||
assert_eq!(import_for_path("a.glb").map(|f| f.id), Some("gltf"));
|
||||
assert_eq!(for_path("a.glb").map(|f| f.id), Some("gltf"));
|
||||
let writes = |ext: &str| exportable().filter(|f| f.extensions.contains(&ext)).map(|f| f.id).collect::<Vec<_>>();
|
||||
assert_eq!(writes("glb"), vec!["glb"]);
|
||||
assert!(writes("gltf").is_empty(), "a .gltf file is never written");
|
||||
assert!(for_path("a.png").is_none());
|
||||
assert!(for_path("nodot").is_none());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! GLB export (#1553): the bodies of a model as one binary glTF 2.0 file, for web viewers,
|
||||
//! renderers and game engines. Each body is a node with its mesh, placed where the model has it;
|
||||
//! its colours, metallic and roughness are glTF materials. One root node turns millimetres and
|
||||
//! Z-up into the metres and Y-up glTF uses, so nothing is converted twice.
|
||||
//!
|
||||
//! Only opaque looks are written. A transparent body or face is refused with a message that names
|
||||
//! it rather than written as an opaque colour; image textures are refused by the engine, which
|
||||
//! knows the render setups that bind them.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Document, ModelState};
|
||||
use solidcraft_geom::Mesh;
|
||||
|
||||
use crate::{IoError, MeshQuality, Result, export_mesh_at, selected};
|
||||
|
||||
/// Largest GLB file written: a quarter of what the format's 32-bit lengths allow, so a model
|
||||
/// meshed far too finely is refused before its arrays are built.
|
||||
pub const MAX_GLB_BYTES: usize = 1 << 30;
|
||||
|
||||
/// The colour of a body with no colour of its own (sRGB): the render workspace's.
|
||||
pub const DEFAULT_COLOR: [u8; 3] = [176, 186, 198];
|
||||
|
||||
/// The metallic and roughness of a look without physical (PBR) parameters: those a new PBR look
|
||||
/// starts with.
|
||||
const PLAIN_METALLIC: f64 = 0.0;
|
||||
const PLAIN_ROUGHNESS: f64 = 0.5;
|
||||
|
||||
/// The index of refraction glTF assumes when a material does not give one.
|
||||
const DEFAULT_IOR: f64 = 1.5;
|
||||
|
||||
/// glTF's buffer view targets and component types.
|
||||
const ARRAY_BUFFER: u32 = 34962;
|
||||
const ELEMENT_ARRAY_BUFFER: u32 = 34963;
|
||||
const FLOAT: u32 = 5126;
|
||||
const UNSIGNED_INT: u32 = 5125;
|
||||
|
||||
/// A glTF material: a look's name, sRGB colour, metallic, roughness and index of refraction.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct GlbMaterial {
|
||||
pub name: String,
|
||||
pub color: [u8; 3],
|
||||
pub metallic: f64,
|
||||
pub roughness: f64,
|
||||
pub ior: f64,
|
||||
}
|
||||
|
||||
impl GlbMaterial {
|
||||
/// The material of an opaque look; `None` for a transparent one.
|
||||
fn of_look(look: &solidcraft_doc::appearance::Look) -> Option<GlbMaterial> {
|
||||
if !(look.opacity >= 1.0) {
|
||||
return None;
|
||||
}
|
||||
let (metallic, roughness, ior) =
|
||||
look.pbr.map_or((PLAIN_METALLIC, PLAIN_ROUGHNESS, DEFAULT_IOR), |p| (p.metallic(), p.roughness(), p.dielectric_ior()));
|
||||
Some(GlbMaterial { name: look.name.clone(), color: look.color, metallic, roughness, ior })
|
||||
}
|
||||
|
||||
/// Two materials are one when every value glTF writes is the same.
|
||||
fn key(&self) -> (String, [u8; 3], u64, u64, u64) {
|
||||
(self.name.clone(), self.color, self.metallic.to_bits(), self.roughness.to_bits(), self.ior.to_bits())
|
||||
}
|
||||
|
||||
/// The glTF material: linear base colour, metallic and roughness, and IOR when it is written.
|
||||
fn json(&self) -> Value {
|
||||
let [r, g, b] = self.color.map(solidcraft_color::srgb::decode8);
|
||||
let mut m = json!({
|
||||
"name": self.name,
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorFactor": [r, g, b, 1.0],
|
||||
"metallicFactor": unit(self.metallic),
|
||||
"roughnessFactor": unit(self.roughness),
|
||||
},
|
||||
});
|
||||
if self.writes_ior() {
|
||||
m["extensions"] = json!({"KHR_materials_ior": {"ior": self.ior}});
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// glTF's default IOR is left unwritten, and so is one the extension cannot hold.
|
||||
fn writes_ior(&self) -> bool {
|
||||
self.ior.is_finite() && self.ior >= 1.0 && self.ior != DEFAULT_IOR
|
||||
}
|
||||
}
|
||||
|
||||
/// A fraction between 0 and 1 (0 when it is not a number).
|
||||
fn unit(x: f64) -> f64 {
|
||||
if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A body to write: its name, its mesh in millimetres and Z-up, its own material and the
|
||||
/// materials of faces that have one of their own (by B-rep face index).
|
||||
pub(crate) struct GlbBody {
|
||||
pub name: String,
|
||||
pub mesh: Mesh,
|
||||
pub material: usize,
|
||||
pub faces: BTreeMap<u32, usize>,
|
||||
}
|
||||
|
||||
/// The GLB of the given bodies of a world-placed model (all when `bodies` is empty): each with
|
||||
/// the colour, metallic and roughness the design shows it with, as the render workspace resolves
|
||||
/// them (a face's look over its body's, a body's over its component's and material's, else the
|
||||
/// colour it was imported with). `name` names the scene. A transparent body or face is refused.
|
||||
pub fn export_glb(doc: &Document, state: &ModelState, bodies: &[String], name: &str, quality: MeshQuality) -> Result<Vec<u8>> {
|
||||
let _span = solidcraft_profile::span("io", "export_glb");
|
||||
let sel = selected(state, bodies)?;
|
||||
let mut materials: Vec<GlbMaterial> = Vec::new();
|
||||
let mut index: BTreeMap<(String, [u8; 3], u64, u64, u64), usize> = BTreeMap::new();
|
||||
let mut add = |m: GlbMaterial| {
|
||||
*index.entry(m.key()).or_insert_with(|| {
|
||||
materials.push(m);
|
||||
materials.len() - 1
|
||||
})
|
||||
};
|
||||
let mut out = Vec::with_capacity(sel.len());
|
||||
let mut total = 0;
|
||||
for b in sel {
|
||||
let opacity = doc.model_body_opacity(b);
|
||||
if !(opacity >= 1.0) {
|
||||
return Err(transparent(&b.name, None));
|
||||
}
|
||||
let material = match doc.model_body_look(b) {
|
||||
Some(look) => GlbMaterial::of_look(&look).ok_or_else(|| transparent(&b.name, None))?,
|
||||
None => {
|
||||
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let color = doc.body_color(b).map_or(DEFAULT_COLOR, |c| c.map(to8));
|
||||
GlbMaterial { name: b.name.clone(), color, metallic: PLAIN_METALLIC, roughness: PLAIN_ROUGHNESS, ior: DEFAULT_IOR }
|
||||
}
|
||||
};
|
||||
let material = add(material);
|
||||
let mut faces = BTreeMap::new();
|
||||
for (face, look) in doc.face_colors(b) {
|
||||
let m = GlbMaterial::of_look(&look).ok_or_else(|| transparent(&b.name, Some(face)))?;
|
||||
let face = u32::try_from(face).map_err(|_| IoError::Invalid(format!("{}: face {face} is out of range for GLB", b.name)))?;
|
||||
faces.insert(face, add(m));
|
||||
}
|
||||
let body = GlbBody { name: b.name.clone(), mesh: export_mesh_at(&b.body, quality)?, material, faces };
|
||||
// Refused as soon as the meshes kept so far are too large, not after meshing them all.
|
||||
total = add_to_budget(total, &body)?;
|
||||
out.push(body);
|
||||
}
|
||||
glb(name, &out, &materials)
|
||||
}
|
||||
|
||||
/// Why a transparent body or face is not written.
|
||||
fn transparent(body: &str, face: Option<usize>) -> IoError {
|
||||
let what = face.map_or_else(|| String::from("the body"), |f| format!("face {f}"));
|
||||
IoError::Invalid(format!(
|
||||
"{body}: {what} is transparent, and GLB export writes opaque bodies only; make it opaque, or leave the body out with `bodies`"
|
||||
))
|
||||
}
|
||||
|
||||
/// A body's arrays as they go in the binary chunk, checked: positions (mm, f32), unit normals
|
||||
/// (left out when the mesh has none, or one is not a direction), and its triangles grouped by
|
||||
/// material.
|
||||
struct Arrays {
|
||||
positions: Vec<[f32; 3]>,
|
||||
min: [f32; 3],
|
||||
max: [f32; 3],
|
||||
normals: Option<Vec<[f32; 3]>>,
|
||||
groups: BTreeMap<usize, Vec<[u32; 3]>>,
|
||||
}
|
||||
|
||||
/// `b`'s mesh as [`Arrays`], refused when a point is not finite, a triangle uses a point the mesh
|
||||
/// does not have, or a material index is not one of the `materials`.
|
||||
fn arrays(b: &GlbBody, materials: usize) -> Result<Arrays> {
|
||||
let m = &b.mesh;
|
||||
let count = u32::try_from(m.positions.len()).ok().filter(|n| *n < u32::MAX).ok_or_else(|| too_large(&b.name))?;
|
||||
let mut positions = Vec::new();
|
||||
positions.try_reserve_exact(m.positions.len()).map_err(|_| too_large(&b.name))?;
|
||||
let (mut min, mut max) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
|
||||
for p in &m.positions {
|
||||
let v = [p.x as f32, p.y as f32, p.z as f32];
|
||||
if !v.iter().all(|x| x.is_finite()) {
|
||||
return Err(IoError::Invalid(format!("{}: the mesh has a point that is not a finite number", b.name)));
|
||||
}
|
||||
for i in 0..3 {
|
||||
min[i] = min[i].min(v[i]);
|
||||
max[i] = max[i].max(v[i]);
|
||||
}
|
||||
positions.push(v);
|
||||
}
|
||||
let normals = (m.normals.len() == m.positions.len())
|
||||
.then(|| {
|
||||
m.normals
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let v = [n.x as f32, n.y as f32, n.z as f32];
|
||||
let len = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
(len.is_finite() && len > 0.0).then(|| v.map(|x| x / len))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.flatten();
|
||||
let check =
|
||||
|i: usize| -> Result<usize> { if i < materials { Ok(i) } else { Err(IoError::Invalid(format!("{}: material {i} does not exist", b.name))) } };
|
||||
let base = check(b.material)?;
|
||||
let mut groups: BTreeMap<usize, Vec<[u32; 3]>> = BTreeMap::new();
|
||||
for (i, t) in m.triangles.iter().enumerate() {
|
||||
if t.iter().any(|v| *v >= count) {
|
||||
return Err(IoError::Invalid(format!("{}: triangle {i} uses a point the mesh does not have", b.name)));
|
||||
}
|
||||
// A triangle with a repeated corner has no area and nothing to draw.
|
||||
if t[0] == t[1] || t[1] == t[2] || t[0] == t[2] {
|
||||
continue;
|
||||
}
|
||||
let face = m.tri_face.get(i).copied();
|
||||
let material = match face.and_then(|f| b.faces.get(&f)) {
|
||||
Some(&f) => check(f)?,
|
||||
None => base,
|
||||
};
|
||||
groups.entry(material).or_default().push(*t);
|
||||
}
|
||||
Ok(Arrays { positions, min, max, normals, groups })
|
||||
}
|
||||
|
||||
/// Why `body` is not written: the file would pass [`MAX_GLB_BYTES`].
|
||||
fn too_large(body: &str) -> IoError {
|
||||
IoError::Invalid(format!(
|
||||
"{body}: the mesh is too large for a GLB file (at most {} MiB); export fewer bodies or use standard quality",
|
||||
MAX_GLB_BYTES >> 20
|
||||
))
|
||||
}
|
||||
|
||||
/// The size of each body's arrays in the binary chunk, refused when the sum is larger than
|
||||
/// [`MAX_GLB_BYTES`], before anything is built.
|
||||
fn check_budget(bodies: &[GlbBody]) -> Result<()> {
|
||||
bodies.iter().try_fold(0, add_to_budget).map(|_| ())
|
||||
}
|
||||
|
||||
/// `total` with `b`'s arrays added, refused when that is larger than [`MAX_GLB_BYTES`].
|
||||
fn add_to_budget(total: usize, b: &GlbBody) -> Result<usize> {
|
||||
let vertices = b.mesh.positions.len().checked_mul(24);
|
||||
let triangles = b.mesh.triangles.len().checked_mul(12);
|
||||
vertices
|
||||
.zip(triangles)
|
||||
.and_then(|(v, t)| v.checked_add(t))
|
||||
.and_then(|n| total.checked_add(n))
|
||||
.filter(|n| *n <= MAX_GLB_BYTES)
|
||||
.ok_or_else(|| too_large(&b.name))
|
||||
}
|
||||
|
||||
/// The binary chunk being built, with the glTF buffer views and accessors that describe it.
|
||||
#[derive(Default)]
|
||||
struct Chunk {
|
||||
bin: Vec<u8>,
|
||||
views: Vec<Value>,
|
||||
accessors: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
/// Append `data` as a buffer view for `target` and return the view's index.
|
||||
fn view(&mut self, data: impl Iterator<Item = [u8; 4]>, target: u32) -> Result<usize> {
|
||||
let offset = self.bin.len();
|
||||
for d in data {
|
||||
self.bin.extend_from_slice(&d);
|
||||
}
|
||||
let length = self.bin.len() - offset;
|
||||
if self.bin.len() > MAX_GLB_BYTES {
|
||||
return Err(IoError::Invalid(format!("the model is too large for a GLB file (at most {} MiB)", MAX_GLB_BYTES >> 20)));
|
||||
}
|
||||
self.views.push(json!({"buffer": 0, "byteOffset": offset, "byteLength": length, "target": target}));
|
||||
Ok(self.views.len() - 1)
|
||||
}
|
||||
|
||||
/// Add accessor `a` and return its index.
|
||||
fn accessor(&mut self, a: Value) -> usize {
|
||||
self.accessors.push(a);
|
||||
self.accessors.len() - 1
|
||||
}
|
||||
|
||||
/// Append `v` as a vertex-attribute buffer view and return the view's index.
|
||||
fn vec3s(&mut self, v: &[[f32; 3]]) -> Result<usize> {
|
||||
self.view(v.iter().flatten().map(|x| x.to_le_bytes()), ARRAY_BUFFER)
|
||||
}
|
||||
}
|
||||
|
||||
/// The GLB of `bodies` with `materials`, its scene named `name`.
|
||||
pub(crate) fn glb(name: &str, bodies: &[GlbBody], materials: &[GlbMaterial]) -> Result<Vec<u8>> {
|
||||
check_budget(bodies)?;
|
||||
let mut chunk = Chunk::default();
|
||||
let (mut meshes, mut nodes, mut children) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for b in bodies {
|
||||
let a = arrays(b, materials.len())?;
|
||||
if a.groups.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let view = chunk.vec3s(&a.positions)?;
|
||||
let position = chunk.accessor(json!({
|
||||
"bufferView": view, "componentType": FLOAT, "count": a.positions.len(), "type": "VEC3",
|
||||
"min": a.min.map(f64::from), "max": a.max.map(f64::from),
|
||||
}));
|
||||
let normal = match &a.normals {
|
||||
Some(n) => {
|
||||
let view = chunk.vec3s(n)?;
|
||||
Some(chunk.accessor(json!({"bufferView": view, "componentType": FLOAT, "count": n.len(), "type": "VEC3"})))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut primitives = Vec::new();
|
||||
for (material, tris) in &a.groups {
|
||||
let view = chunk.view(tris.iter().flatten().map(|i| i.to_le_bytes()), ELEMENT_ARRAY_BUFFER)?;
|
||||
let count = tris.len().checked_mul(3).ok_or_else(|| too_large(&b.name))?;
|
||||
let indices = chunk.accessor(json!({"bufferView": view, "componentType": UNSIGNED_INT, "count": count, "type": "SCALAR"}));
|
||||
let mut attributes = json!({"POSITION": position});
|
||||
if let Some(n) = normal {
|
||||
attributes["NORMAL"] = json!(n);
|
||||
}
|
||||
primitives.push(json!({"attributes": attributes, "indices": indices, "material": material}));
|
||||
}
|
||||
meshes.push(json!({"name": b.name, "primitives": primitives}));
|
||||
nodes.push(json!({"name": b.name, "mesh": meshes.len() - 1}));
|
||||
children.push(nodes.len());
|
||||
}
|
||||
if meshes.is_empty() {
|
||||
return Err(IoError::Empty);
|
||||
}
|
||||
// The root: millimetres to metres, and Z up to Y up (a quarter turn back about X), so
|
||||
// (x, y, z) in the model is 0.001 (x, z, -y) in the file.
|
||||
let half = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let root = json!({"name": name, "children": children, "rotation": [-half, 0.0, 0.0, half], "scale": [0.001, 0.001, 0.001]});
|
||||
nodes.insert(0, root);
|
||||
let mut gltf = json!({
|
||||
"asset": {"version": "2.0", "generator": concat!("SolidCraft ", env!("CARGO_PKG_VERSION"))},
|
||||
"scene": 0,
|
||||
"scenes": [{"name": name, "nodes": [0]}],
|
||||
"nodes": nodes,
|
||||
"meshes": meshes,
|
||||
"materials": materials.iter().map(GlbMaterial::json).collect::<Vec<_>>(),
|
||||
"accessors": chunk.accessors,
|
||||
"bufferViews": chunk.views,
|
||||
"buffers": [{"byteLength": chunk.bin.len()}],
|
||||
});
|
||||
if materials.iter().any(GlbMaterial::writes_ior) {
|
||||
gltf["extensionsUsed"] = json!(["KHR_materials_ior"]);
|
||||
}
|
||||
let json = serde_json::to_vec(&gltf).map_err(|e| IoError::Invalid(e.to_string()))?;
|
||||
container(&json, &chunk.bin)
|
||||
}
|
||||
|
||||
/// A GLB container of `json` and `bin`, each chunk padded to four bytes (the JSON with spaces,
|
||||
/// the binary with zeros), with every length checked to fit the format's 32 bits.
|
||||
fn container(json: &[u8], bin: &[u8]) -> Result<Vec<u8>> {
|
||||
let pad = |n: usize| (4 - n % 4) % 4;
|
||||
let json_len = json.len() + pad(json.len());
|
||||
let bin_len = bin.len() + pad(bin.len());
|
||||
let total = 12usize
|
||||
.checked_add(8 + json_len)
|
||||
.and_then(|n| n.checked_add(if bin.is_empty() { 0 } else { 8 + bin_len }))
|
||||
.filter(|n| *n <= MAX_GLB_BYTES)
|
||||
.ok_or_else(|| IoError::Invalid(format!("the model is too large for a GLB file (at most {} MiB)", MAX_GLB_BYTES >> 20)))?;
|
||||
let u32_of = |n: usize| u32::try_from(n).map_err(|_| IoError::Invalid("the model is too large for a GLB file".into()));
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(total).map_err(|_| IoError::Invalid("not enough memory for the GLB file".into()))?;
|
||||
out.extend_from_slice(b"glTF");
|
||||
out.extend_from_slice(&2u32.to_le_bytes());
|
||||
out.extend_from_slice(&u32_of(total)?.to_le_bytes());
|
||||
out.extend_from_slice(&u32_of(json_len)?.to_le_bytes());
|
||||
out.extend_from_slice(b"JSON");
|
||||
out.extend_from_slice(json);
|
||||
out.extend(std::iter::repeat_n(b' ', pad(json.len())));
|
||||
if !bin.is_empty() {
|
||||
out.extend_from_slice(&u32_of(bin_len)?.to_le_bytes());
|
||||
out.extend_from_slice(b"BIN\0");
|
||||
out.extend_from_slice(bin);
|
||||
out.extend(std::iter::repeat_n(0u8, pad(bin.len())));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "glb_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,543 @@
|
||||
//! GLB export tests derived from glTF 2.0 and analytically known box geometry.
|
||||
|
||||
use super::*;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_color::srgb::decode8;
|
||||
use solidcraft_doc::appearance::{FaceLook, Look};
|
||||
use solidcraft_doc::pbr_appearance::PbrParameters;
|
||||
use solidcraft_doc::{FeatureKind, Model, Operation, point_expr};
|
||||
use solidcraft_geom::Vec3Geom;
|
||||
|
||||
fn model(body_count: usize) -> (Document, Model) {
|
||||
let mut doc = Document::new("GLB box");
|
||||
for i in 0..body_count {
|
||||
doc.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(i as f64 * 40.0, 0.0, 0.0)),
|
||||
length: "10".into(),
|
||||
width: "20".into(),
|
||||
height: "30".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.state().bodies.len(), body_count);
|
||||
(doc, model)
|
||||
}
|
||||
|
||||
fn export(doc: &Document, model: &Model) -> Vec<u8> {
|
||||
export_glb(doc, &model.state(), &[], "GLB box", MeshQuality::Standard).unwrap()
|
||||
}
|
||||
|
||||
/// Validate references with gltf, retaining raw JSON for untyped extensions.
|
||||
fn parse(bytes: &[u8]) -> (Value, Vec<u8>) {
|
||||
let parsed = gltf::Gltf::from_slice(bytes).unwrap();
|
||||
assert_eq!(parsed.document.meshes().count(), json(bytes)["meshes"].as_array().unwrap().len());
|
||||
(json(bytes), parsed.blob.unwrap())
|
||||
}
|
||||
|
||||
fn json(bytes: &[u8]) -> Value {
|
||||
let container = gltf::binary::Glb::from_slice(bytes).unwrap();
|
||||
serde_json::from_slice(&container.json).unwrap()
|
||||
}
|
||||
|
||||
fn word(bytes: &[u8], offset: usize) -> u32 {
|
||||
u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// Read the tightly packed accessors the exporter promises, without gltf's optional utils.
|
||||
fn accessor_bytes<'a>(doc: &Value, bin: &'a [u8], accessor: &Value, width: usize) -> &'a [u8] {
|
||||
let view = &doc["bufferViews"][accessor["bufferView"].as_u64().unwrap() as usize];
|
||||
assert!(view.get("byteStride").is_none());
|
||||
let start = view["byteOffset"].as_u64().unwrap_or(0) as usize + accessor["byteOffset"].as_u64().unwrap_or(0) as usize;
|
||||
let size = accessor["count"].as_u64().unwrap() as usize * width;
|
||||
assert!(size <= view["byteLength"].as_u64().unwrap() as usize);
|
||||
&bin[start..start + size]
|
||||
}
|
||||
|
||||
fn vectors(doc: &Value, bin: &[u8], index: &Value) -> Vec<Vec3> {
|
||||
let a = &doc["accessors"][index.as_u64().unwrap() as usize];
|
||||
assert_eq!(a["componentType"], FLOAT);
|
||||
assert_eq!(a["type"], "VEC3");
|
||||
accessor_bytes(doc, bin, a, 12)
|
||||
.as_chunks::<12>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| Vec3::new(f32::from_bits(word(p, 0)) as f64, f32::from_bits(word(p, 4)) as f64, f32::from_bits(word(p, 8)) as f64))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn indices(doc: &Value, bin: &[u8], primitive: &Value) -> Vec<usize> {
|
||||
let a = &doc["accessors"][primitive["indices"].as_u64().unwrap() as usize];
|
||||
assert_eq!(a["componentType"], UNSIGNED_INT);
|
||||
assert_eq!(a["type"], "SCALAR");
|
||||
accessor_bytes(doc, bin, a, 4).as_chunks::<4>().0.iter().map(|v| word(v, 0) as usize).collect()
|
||||
}
|
||||
|
||||
fn bounds(points: impl IntoIterator<Item = Vec3>) -> ([f64; 3], [f64; 3]) {
|
||||
let (mut min, mut max) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in points {
|
||||
for (i, x) in [p.x, p.y, p.z].into_iter().enumerate() {
|
||||
min[i] = min[i].min(x);
|
||||
max[i] = max[i].max(x);
|
||||
}
|
||||
}
|
||||
(min, max)
|
||||
}
|
||||
|
||||
fn near(actual: f64, expected: f64) {
|
||||
assert!((actual - expected).abs() < 1e-7, "{actual} != {expected}");
|
||||
}
|
||||
|
||||
fn face(doc: &mut Document, body: &str, look: Look) {
|
||||
doc.appearances.faces.push(FaceLook {
|
||||
body: body.into(),
|
||||
point: Vec3::new(5.0, 10.0, 30.0),
|
||||
look,
|
||||
name: None,
|
||||
strict_name: false,
|
||||
certificate: None,
|
||||
});
|
||||
}
|
||||
|
||||
fn material() -> GlbMaterial {
|
||||
GlbMaterial { name: "test".into(), color: [128; 3], metallic: 0.0, roughness: 0.5, ior: 1.5 }
|
||||
}
|
||||
|
||||
fn triangle() -> GlbBody {
|
||||
GlbBody {
|
||||
name: "triangle".into(),
|
||||
mesh: Mesh {
|
||||
positions: vec![Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0)],
|
||||
normals: vec![Vec3::new(0.0, 0.0, 2.0); 3],
|
||||
triangles: vec![[0, 1, 2]],
|
||||
tri_face: vec![7],
|
||||
..Default::default()
|
||||
},
|
||||
material: 0,
|
||||
faces: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn direct(body: GlbBody) -> Result<Vec<u8>> {
|
||||
glb("hostile", &[body], &[material()])
|
||||
}
|
||||
|
||||
fn assert_invalid(result: Result<Vec<u8>>, words: &[&str]) {
|
||||
let Err(IoError::Invalid(message)) = result else { panic!("expected IoError::Invalid, got {result:?}") };
|
||||
for word in words {
|
||||
assert!(message.contains(word), "{message:?} does not contain {word:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// GLB headers and chunks describe exactly the emitted bytes.
|
||||
#[test]
|
||||
fn container_has_version_two_and_aligned_json_then_bin() {
|
||||
let (doc, model) = model(1);
|
||||
let bytes = export(&doc, &model);
|
||||
parse(&bytes);
|
||||
assert_eq!(&bytes[..4], b"glTF");
|
||||
assert_eq!(word(&bytes, 4), 2);
|
||||
assert_eq!(word(&bytes, 8) as usize, bytes.len());
|
||||
let json_len = word(&bytes, 12) as usize;
|
||||
assert_eq!(json_len % 4, 0);
|
||||
assert_eq!(&bytes[16..20], b"JSON");
|
||||
let bin_header = 20 + json_len;
|
||||
let bin_len = word(&bytes, bin_header) as usize;
|
||||
assert_eq!(bin_len % 4, 0);
|
||||
assert_eq!(&bytes[bin_header + 4..bin_header + 8], b"BIN\0");
|
||||
assert_eq!(bin_header + 8 + bin_len, bytes.len());
|
||||
}
|
||||
|
||||
/// Every possible padding length uses JSON spaces and binary zeros.
|
||||
#[test]
|
||||
fn container_pads_json_with_spaces_and_bin_with_zeros() {
|
||||
for padding in 0..4 {
|
||||
let mut raw_json = br#"{"asset":{"version":"2.0"}}"#.to_vec();
|
||||
raw_json.extend(std::iter::repeat_n(b' ', padding));
|
||||
let raw_bin = vec![0xab; padding + 1];
|
||||
let bytes = container(&raw_json, &raw_bin).unwrap();
|
||||
let jlen = word(&bytes, 12) as usize;
|
||||
assert_eq!(&bytes[20..20 + raw_json.len()], raw_json);
|
||||
assert!(bytes[20 + raw_json.len()..20 + jlen].iter().all(|b| *b == b' '));
|
||||
let start = 20 + jlen + 8;
|
||||
assert_eq!(&bytes[start..start + raw_bin.len()], raw_bin);
|
||||
assert!(bytes[start + raw_bin.len()..].iter().all(|b| *b == 0));
|
||||
gltf::binary::Glb::from_slice(&bytes).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repeated exports of one evaluated design are byte-for-byte deterministic.
|
||||
#[test]
|
||||
fn exporting_twice_produces_identical_bytes() {
|
||||
let (doc, model) = model(1);
|
||||
assert_eq!(export(&doc, &model), export(&doc, &model));
|
||||
}
|
||||
|
||||
/// Box accessor extrema match the actual f32 positions in millimetres.
|
||||
#[test]
|
||||
fn box_position_bounds_are_exact_millimetres() {
|
||||
let (doc, model) = model(1);
|
||||
let (doc, bin) = parse(&export(&doc, &model));
|
||||
for primitive in doc["meshes"][0]["primitives"].as_array().unwrap() {
|
||||
let index = &primitive["attributes"]["POSITION"];
|
||||
let a = &doc["accessors"][index.as_u64().unwrap() as usize];
|
||||
assert_eq!(a["min"], json!([0.0, 0.0, 0.0]));
|
||||
assert_eq!(a["max"], json!([10.0, 20.0, 30.0]));
|
||||
assert_eq!(bounds(vectors(&doc, &bin, index)), ([0.0; 3], [10.0, 20.0, 30.0]));
|
||||
}
|
||||
}
|
||||
|
||||
/// All triangle corners reference a position in the emitted accessor.
|
||||
#[test]
|
||||
fn box_indices_are_within_position_count() {
|
||||
let (doc, model) = model(1);
|
||||
let (doc, bin) = parse(&export(&doc, &model));
|
||||
for p in doc["meshes"][0]["primitives"].as_array().unwrap() {
|
||||
let count = vectors(&doc, &bin, &p["attributes"]["POSITION"]).len();
|
||||
let indices = indices(&doc, &bin, p);
|
||||
assert!(!indices.is_empty());
|
||||
assert_eq!(indices.len() % 3, 0);
|
||||
assert!(indices.into_iter().all(|i| i < count));
|
||||
}
|
||||
}
|
||||
|
||||
/// Every exported box vertex has a unit-length normal.
|
||||
#[test]
|
||||
fn box_normals_are_present_and_unit_length() {
|
||||
let (doc, model) = model(1);
|
||||
let (doc, bin) = parse(&export(&doc, &model));
|
||||
for p in doc["meshes"][0]["primitives"].as_array().unwrap() {
|
||||
let normals = vectors(&doc, &bin, &p["attributes"]["NORMAL"]);
|
||||
assert_eq!(normals.len(), vectors(&doc, &bin, &p["attributes"]["POSITION"]).len());
|
||||
assert!(normals.iter().all(|n| (n.dot(*n).sqrt() - 1.0).abs() < 1e-5));
|
||||
}
|
||||
}
|
||||
|
||||
/// A sole root owns the unit/axis conversion and named body mesh children.
|
||||
#[test]
|
||||
fn scene_has_one_conversion_root_and_named_body_children() {
|
||||
let (doc, model) = model(2);
|
||||
let (doc, _) = parse(&export(&doc, &model));
|
||||
assert_eq!(doc["scene"], 0);
|
||||
assert_eq!(doc["scenes"][0]["nodes"], json!([0]));
|
||||
let root = &doc["nodes"][0];
|
||||
assert!(root.get("mesh").is_none());
|
||||
assert_eq!(root["scale"], json!([0.001, 0.001, 0.001]));
|
||||
let half = std::f64::consts::FRAC_1_SQRT_2;
|
||||
assert_eq!(root["rotation"], json!([-half, 0.0, 0.0, half]));
|
||||
let children = root["children"].as_array().unwrap();
|
||||
assert_eq!(children.len(), 2);
|
||||
assert_eq!(doc["nodes"].as_array().unwrap().len(), 3);
|
||||
for (child, body) in children.iter().zip(&model.state().bodies) {
|
||||
let node = &doc["nodes"][child.as_u64().unwrap() as usize];
|
||||
assert_eq!(node["name"], body.name);
|
||||
let mesh = &doc["meshes"][node["mesh"].as_u64().unwrap() as usize];
|
||||
assert_eq!(mesh["name"], body.name);
|
||||
assert!(node.get("scale").is_none() && node.get("rotation").is_none() && node.get("translation").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Applying the emitted transform turns the box into metres with Y up.
|
||||
#[test]
|
||||
fn root_transform_maps_box_corners_to_metre_scene_bounds() {
|
||||
let (doc, model) = model(1);
|
||||
let bytes = export(&doc, &model);
|
||||
let gltf = gltf::Gltf::from_slice(&bytes).unwrap();
|
||||
let matrix = gltf.document.nodes().next().unwrap().transform().matrix();
|
||||
let corners = (0..8).map(|i| {
|
||||
let p = [if i & 1 == 0 { 0.0 } else { 10.0 }, if i & 2 == 0 { 0.0 } else { 20.0 }, if i & 4 == 0 { 0.0 } else { 30.0 }];
|
||||
let q: [f64; 3] = std::array::from_fn(|row| (0..3).map(|col| matrix[col][row] as f64 * p[col]).sum::<f64>() + matrix[3][row] as f64);
|
||||
Vec3::new(q[0], q[1], q[2])
|
||||
});
|
||||
let (min, max) = bounds(corners);
|
||||
for i in 0..3 {
|
||||
near(min[i], [0.0, 0.0, -0.02][i]);
|
||||
near(max[i], [0.01, 0.03, 0.0][i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every triangle is counter-clockwise when seen along its outward normals.
|
||||
#[test]
|
||||
fn box_triangle_winding_agrees_with_vertex_normals() {
|
||||
let (doc, model) = model(1);
|
||||
let (doc, bin) = parse(&export(&doc, &model));
|
||||
for p in doc["meshes"][0]["primitives"].as_array().unwrap() {
|
||||
let positions = vectors(&doc, &bin, &p["attributes"]["POSITION"]);
|
||||
let normals = vectors(&doc, &bin, &p["attributes"]["NORMAL"]);
|
||||
for t in indices(&doc, &bin, p).as_chunks::<3>().0 {
|
||||
let [a, b, c] = [positions[t[0]], positions[t[1]], positions[t[2]]];
|
||||
let average = (normals[t[0]] + normals[t[1]] + normals[t[2]]) / 3.0;
|
||||
assert!((b - a).cross(c - a).dot(average) > 0.0);
|
||||
let centroid = (a + b + c) / 3.0;
|
||||
assert!(average.dot(centroid - Vec3::new(5.0, 10.0, 15.0)) > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An unpainted body uses the linear workspace colour and plain PBR defaults.
|
||||
#[test]
|
||||
fn unassigned_body_uses_linear_default_color_and_plain_pbr() {
|
||||
let (doc, model) = model(1);
|
||||
let doc = json(&export(&doc, &model));
|
||||
let pbr = &doc["materials"][0]["pbrMetallicRoughness"];
|
||||
let [r, g, b] = DEFAULT_COLOR.map(decode8);
|
||||
assert_eq!(pbr["baseColorFactor"], json!([r, g, b, 1.0]));
|
||||
assert_eq!(pbr["metallicFactor"], 0.0);
|
||||
assert_eq!(pbr["roughnessFactor"], 0.5);
|
||||
}
|
||||
|
||||
/// A saturated red body look remains opaque red in linear glTF factors.
|
||||
#[test]
|
||||
fn custom_red_body_look_sets_base_color() {
|
||||
let (mut doc, model) = model(1);
|
||||
doc.appearances.bodies.insert(model.state().bodies[0].name.clone(), Look::custom([255, 0, 0], 1.0));
|
||||
let (doc, _) = parse(&export(&doc, &model));
|
||||
assert_eq!(doc["materials"][0]["pbrMetallicRoughness"]["baseColorFactor"], json!([1.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// Mid-grey sRGB codes are decoded rather than copied as linear fractions.
|
||||
#[test]
|
||||
fn custom_mid_grey_is_decoded_to_linear_color() {
|
||||
let (mut doc, model) = model(1);
|
||||
doc.appearances.bodies.insert(model.state().bodies[0].name.clone(), Look::custom([128; 3], 1.0));
|
||||
let (doc, _) = parse(&export(&doc, &model));
|
||||
let factor = &doc["materials"][0]["pbrMetallicRoughness"]["baseColorFactor"];
|
||||
assert_eq!(*factor, json!([decode8(128), decode8(128), decode8(128), 1.0]));
|
||||
near(factor[0].as_f64().unwrap(), 0.2158605);
|
||||
}
|
||||
|
||||
fn pbr_export(ior: f64) -> Value {
|
||||
let (mut doc, model) = model(1);
|
||||
let mut look = Look::custom([128; 3], 1.0);
|
||||
look.pbr = Some(PbrParameters::new(1.0, 0.2, ior).unwrap());
|
||||
doc.appearances.bodies.insert(model.state().bodies[0].name.clone(), look);
|
||||
parse(&export(&doc, &model)).0
|
||||
}
|
||||
|
||||
/// The default IOR needs no extension while authored metallic and roughness survive.
|
||||
#[test]
|
||||
fn pbr_default_ior_omits_extensions() {
|
||||
let doc = pbr_export(1.5);
|
||||
let m = &doc["materials"][0];
|
||||
assert_eq!(m["pbrMetallicRoughness"]["metallicFactor"], 1.0);
|
||||
assert_eq!(m["pbrMetallicRoughness"]["roughnessFactor"], 0.2);
|
||||
assert!(m.get("extensions").is_none());
|
||||
assert!(doc.get("extensionsUsed").is_none());
|
||||
}
|
||||
|
||||
/// A nondefault IOR declares and populates KHR_materials_ior.
|
||||
#[test]
|
||||
fn pbr_nondefault_ior_writes_declared_extension() {
|
||||
let doc = pbr_export(2.0);
|
||||
assert_eq!(doc["materials"][0]["extensions"]["KHR_materials_ior"]["ior"], 2.0);
|
||||
assert!(doc["extensionsUsed"].as_array().unwrap().contains(&json!("KHR_materials_ior")));
|
||||
}
|
||||
|
||||
/// Only triangles on the authored top face receive its override material.
|
||||
#[test]
|
||||
fn face_look_overrides_body_only_on_top_face() {
|
||||
let (mut doc, model) = model(1);
|
||||
let name = model.state().bodies[0].name.clone();
|
||||
doc.appearances.bodies.insert(name.clone(), Look::custom([255, 0, 0], 1.0));
|
||||
face(&mut doc, &name, Look::custom([0, 255, 0], 1.0));
|
||||
let (doc, bin) = parse(&export(&doc, &model));
|
||||
assert_eq!(doc["materials"].as_array().unwrap().len(), 2);
|
||||
let primitives = doc["meshes"][0]["primitives"].as_array().unwrap();
|
||||
assert_eq!(primitives.len(), 2);
|
||||
let mut counts = [0; 2];
|
||||
for p in primitives {
|
||||
let material = p["material"].as_u64().unwrap() as usize;
|
||||
let factor = &doc["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"];
|
||||
let is_face = *factor == json!([0.0, 1.0, 0.0, 1.0]);
|
||||
if !is_face {
|
||||
assert_eq!(*factor, json!([1.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
let positions = vectors(&doc, &bin, &p["attributes"]["POSITION"]);
|
||||
for t in indices(&doc, &bin, p).as_chunks::<3>().0 {
|
||||
let on_top = t.iter().all(|i| positions[*i].z == 30.0);
|
||||
assert_eq!(on_top, is_face);
|
||||
counts[usize::from(is_face)] += 1;
|
||||
}
|
||||
}
|
||||
assert!(counts.iter().all(|count| *count > 0));
|
||||
}
|
||||
|
||||
/// Identical authored body and face looks share one glTF material.
|
||||
#[test]
|
||||
fn identical_body_and_face_looks_are_deduplicated() {
|
||||
let (mut doc, model) = model(2);
|
||||
let look = Look::custom([12, 34, 56], 1.0);
|
||||
for b in &model.state().bodies {
|
||||
doc.appearances.bodies.insert(b.name.clone(), look.clone());
|
||||
}
|
||||
face(&mut doc, &model.state().bodies[0].name, look);
|
||||
let (doc, _) = parse(&export(&doc, &model));
|
||||
assert_eq!(doc["materials"].as_array().unwrap().len(), 1);
|
||||
for mesh in doc["meshes"].as_array().unwrap() {
|
||||
assert_eq!(mesh["primitives"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(mesh["primitives"][0]["material"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparent body refusal identifies the body and unsupported transparency.
|
||||
#[test]
|
||||
fn transparent_body_is_refused_with_its_name() {
|
||||
let (mut doc, model) = model(1);
|
||||
let name = model.state().bodies[0].name.clone();
|
||||
doc.appearances.bodies.insert(name.clone(), Look::custom([255, 0, 0], 0.5));
|
||||
assert_invalid(export_glb(&doc, &model.state(), &[], "test", MeshQuality::Standard), &[&name, "transparent"]);
|
||||
}
|
||||
|
||||
/// Transparent authored faces cannot silently become opaque.
|
||||
#[test]
|
||||
fn transparent_face_is_refused_with_face_context() {
|
||||
let (mut doc, model) = model(1);
|
||||
let name = model.state().bodies[0].name.clone();
|
||||
face(&mut doc, &name, Look::custom([0, 255, 0], 0.5));
|
||||
assert_invalid(export_glb(&doc, &model.state(), &[], "test", MeshQuality::Standard), &[&name, "face", "transparent"]);
|
||||
}
|
||||
|
||||
/// A filter matching no body reports an empty export.
|
||||
#[test]
|
||||
fn nonexistent_body_filter_returns_empty() {
|
||||
let (doc, model) = model(1);
|
||||
assert!(matches!(export_glb(&doc, &model.state(), &["nope".into()], "test", MeshQuality::Standard), Err(IoError::Empty)));
|
||||
}
|
||||
|
||||
/// A body filter selects exactly its named mesh from a two-body design.
|
||||
#[test]
|
||||
fn named_body_filter_exports_only_selected_mesh() {
|
||||
let (doc, model) = model(2);
|
||||
let name = model.state().bodies[1].name.clone();
|
||||
let bytes = export_glb(&doc, &model.state(), std::slice::from_ref(&name), "test", MeshQuality::Standard).unwrap();
|
||||
let (doc, _) = parse(&bytes);
|
||||
assert_eq!(doc["meshes"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(doc["meshes"][0]["name"], name);
|
||||
}
|
||||
|
||||
/// Out-of-range triangle indices return an error rather than indexing positions.
|
||||
#[test]
|
||||
fn hostile_triangle_index_is_refused() {
|
||||
let mut body = triangle();
|
||||
body.mesh.triangles[0][2] = 3;
|
||||
assert_invalid(direct(body), &["triangle"]);
|
||||
}
|
||||
|
||||
/// NaN coordinates cannot enter a GLB position buffer.
|
||||
#[test]
|
||||
fn hostile_nan_position_is_refused() {
|
||||
let mut body = triangle();
|
||||
body.mesh.positions[0].x = f64::NAN;
|
||||
assert_invalid(direct(body), &["finite"]);
|
||||
}
|
||||
|
||||
/// A finite f64 that overflows f32 is rejected before serialization.
|
||||
#[test]
|
||||
fn hostile_position_overflowing_f32_is_refused() {
|
||||
let mut body = triangle();
|
||||
body.mesh.positions[0].x = 1e39;
|
||||
assert_invalid(direct(body), &["finite"]);
|
||||
}
|
||||
|
||||
/// Missing body materials are refused even when the mesh is otherwise valid.
|
||||
#[test]
|
||||
fn hostile_body_material_index_is_refused() {
|
||||
let mut body = triangle();
|
||||
body.material = 1;
|
||||
assert_invalid(direct(body), &["material"]);
|
||||
}
|
||||
|
||||
/// A face override cannot reference a nonexistent material.
|
||||
#[test]
|
||||
fn hostile_face_material_index_is_refused() {
|
||||
let mut body = triangle();
|
||||
body.faces.insert(7, 1);
|
||||
assert_invalid(direct(body), &["material"]);
|
||||
}
|
||||
|
||||
/// Mismatched normal counts leave the entire NORMAL attribute unwritten.
|
||||
#[test]
|
||||
fn mismatched_normal_count_omits_normal_attribute() {
|
||||
let mut body = triangle();
|
||||
body.mesh.normals.pop();
|
||||
let (doc, _) = parse(&direct(body).unwrap());
|
||||
assert!(doc["meshes"][0]["primitives"][0]["attributes"].get("NORMAL").is_none());
|
||||
}
|
||||
|
||||
/// A single zero direction leaves the entire NORMAL attribute unwritten.
|
||||
#[test]
|
||||
fn zero_normal_omits_normal_attribute() {
|
||||
let mut body = triangle();
|
||||
body.mesh.normals[1] = Vec3::ZERO;
|
||||
let (doc, _) = parse(&direct(body).unwrap());
|
||||
assert!(doc["meshes"][0]["primitives"][0]["attributes"].get("NORMAL").is_none());
|
||||
}
|
||||
|
||||
/// Repeated-index triangles have no drawable area and yield an empty export.
|
||||
#[test]
|
||||
fn all_repeated_index_triangles_return_empty() {
|
||||
let mut body = triangle();
|
||||
body.mesh.triangles = vec![[0, 0, 1], [0, 1, 1], [0, 1, 0]];
|
||||
assert!(matches!(direct(body), Err(IoError::Empty)));
|
||||
}
|
||||
|
||||
/// An absent body list cannot create an empty glTF scene.
|
||||
#[test]
|
||||
fn no_bodies_returns_empty() {
|
||||
assert!(matches!(glb("empty", &[], &[material()]), Err(IoError::Empty)));
|
||||
}
|
||||
|
||||
/// Invalid IOR values omit both the extension payload and its declaration.
|
||||
#[test]
|
||||
fn hostile_ior_is_not_written_as_extension() {
|
||||
for ior in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.9, -1.0] {
|
||||
let mut material = material();
|
||||
material.ior = ior;
|
||||
let (doc, _) = parse(&glb("hostile", &[triangle()], &[material]).unwrap());
|
||||
assert!(doc["materials"][0].get("extensions").is_none(), "ior={ior}");
|
||||
assert!(doc.get("extensionsUsed").is_none(), "ior={ior}");
|
||||
}
|
||||
}
|
||||
|
||||
/// NaN metallic and roughness values are sanitized to finite zero factors.
|
||||
#[test]
|
||||
fn hostile_nan_metallic_and_roughness_are_written_as_zero() {
|
||||
let mut material = material();
|
||||
material.metallic = f64::NAN;
|
||||
material.roughness = f64::NAN;
|
||||
let (doc, _) = parse(&glb("hostile", &[triangle()], &[material]).unwrap());
|
||||
let pbr = &doc["materials"][0]["pbrMetallicRoughness"];
|
||||
assert_eq!(pbr["metallicFactor"], 0.0);
|
||||
assert_eq!(pbr["roughnessFactor"], 0.0);
|
||||
}
|
||||
|
||||
/// Import restores millimetres and keeps the export's Y-up geometry.
|
||||
#[test]
|
||||
fn round_trip_preserves_box_bounds_in_importer_frame() {
|
||||
let (doc, model) = model(1);
|
||||
let bytes = export(&doc, &model);
|
||||
let mut imported = crate::read_gltf(&bytes, "box.glb", &mut |uri| panic!("unexpected external buffer: {uri}")).unwrap();
|
||||
imported.flatten();
|
||||
let FeatureKind::MeshImport { meshes, .. } = imported.feature.kind else { panic!("expected imported mesh geometry") };
|
||||
let points = meshes.iter().flat_map(|m| m.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])));
|
||||
let (min, max) = bounds(points);
|
||||
for i in 0..3 {
|
||||
// The gltf API reads the 0.001 scale as f32 before import converts to mm.
|
||||
assert!((min[i] - [0.0, 0.0, -20.0][i]).abs() < 1e-5, "min={min:?}");
|
||||
assert!((max[i] - [10.0, 30.0, 0.0][i]).abs() < 1e-5, "max={max:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The size budget is refused once the bodies kept so far pass it, before more are meshed.
|
||||
#[test]
|
||||
fn a_body_past_the_size_budget_is_refused_naming_it() {
|
||||
let body = triangle();
|
||||
assert_eq!(add_to_budget(0, &body).unwrap(), 3 * 24 + 12);
|
||||
assert_invalid(add_to_budget(MAX_GLB_BYTES - 10, &body).map(|_| Vec::new()), &["triangle", "too large"]);
|
||||
assert_invalid(add_to_budget(usize::MAX, &body).map(|_| Vec::new()), &["too large"]);
|
||||
}
|
||||
@@ -14,12 +14,14 @@ mod design_metadata;
|
||||
mod dxf;
|
||||
mod dxf_out;
|
||||
pub mod formats;
|
||||
mod glb;
|
||||
pub use design::{
|
||||
DesignCompression, DesignContainerReader, DesignPreview, FileKind, THUMBNAIL_SIZE, file_kind, is_design_container, read_design_file,
|
||||
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, read_drawing, read_drawing_file,
|
||||
write_design_container, write_design_container_with_cache, write_design_container_with_compression, write_design_container_with_preview,
|
||||
write_design_container_with_thumbnail, write_drawing_container,
|
||||
};
|
||||
pub use glb::{DEFAULT_COLOR as GLB_DEFAULT_COLOR, MAX_GLB_BYTES, export_glb};
|
||||
mod legacy_dir;
|
||||
pub mod limits;
|
||||
#[cfg(test)]
|
||||
@@ -98,7 +100,7 @@ pub const MAX_DESIGN_BYTES: usize = 256 << 20;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IoError {
|
||||
#[error("unsupported format `{0}` (use stl, obj, step, 3mf, urdf or sc)")]
|
||||
#[error("unsupported format `{0}` (use stl, obj, step, 3mf, glb, urdf or sc)")]
|
||||
Format(String),
|
||||
#[error("nothing to export: the design has no bodies")]
|
||||
Empty,
|
||||
@@ -400,6 +402,9 @@ pub enum Format {
|
||||
/// A robot description of the assembly with its mesh files ([`urdf()`]); written by the
|
||||
/// engine, since it is several files.
|
||||
Urdf,
|
||||
/// Binary glTF with the bodies' looks ([`export_glb`]); written from the document, which
|
||||
/// holds the looks.
|
||||
Glb,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
@@ -415,6 +420,7 @@ impl Format {
|
||||
"3mf" => Format::ThreeMf,
|
||||
"3mf-tagged" => Format::ThreeMfTagged,
|
||||
"urdf" => Format::Urdf,
|
||||
"glb" => Format::Glb,
|
||||
e if e == "json" || is_design_ext(e) => Format::Design,
|
||||
_ => return Err(IoError::Format(ext)),
|
||||
})
|
||||
@@ -832,6 +838,7 @@ pub fn export_with_parts(
|
||||
}
|
||||
Format::Design => return Err(IoError::Format("sc (save the document instead)".into())),
|
||||
Format::Urdf => return Err(IoError::Format("urdf (an assembly package; use file.export)".into())),
|
||||
Format::Glb => return Err(IoError::Format("glb (its looks come from the design; use file.export)".into())),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -316,14 +316,15 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export bodies to STEP (.step/.stp), IGES (.iges/.igs), 3MF (.3mf), binary STL (.stl), ASCII STL (format stla) or OBJ. The format \
|
||||
"Export bodies to STEP (.step/.stp), IGES (.iges/.igs), 3MF (.3mf), binary STL (.stl), ASCII STL (format stla), OBJ or GLB (.glb: binary glTF \
|
||||
in metres with Y up, with each body's colours, metallic and roughness; transparent bodies are refused). The format \
|
||||
comes from the extension unless given. URDF (.urdf) exports the whole assembly for robot simulators: a link per \
|
||||
component placement with mass and inertia, joints from the assembly's joints, and STL meshes in metres written \
|
||||
next to it (docs/urdf.md); RViz needs mesh_prefix such as package://robot/meshes/.",
|
||||
object(
|
||||
json!({
|
||||
"path": string("Output file"),
|
||||
"format": {"type": "string", "enum": ["step", "stp", "iges", "igs", "3mf", "3mf-tagged", "stl", "stla", "obj", "urdf"]},
|
||||
"format": {"type": "string", "enum": ["step", "stp", "iges", "igs", "3mf", "3mf-tagged", "stl", "stla", "obj", "glb", "urdf"]},
|
||||
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Body names (default: all; not with urdf)"},
|
||||
"mesh_prefix": string("urdf: put before each mesh file name (default: none, so relative to the .urdf)"),
|
||||
"effort": {"type": "number", "description": "urdf: joint effort limit, N or N m (default 1000)"},
|
||||
|
||||
@@ -1672,7 +1672,12 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
"ui.export" => {
|
||||
let body = bodies.first().map(String::as_str);
|
||||
let name = crate::export_name::offered(app, body, body.unwrap_or(&app.session.doc.name));
|
||||
app.pick_save_command(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"], "file.export", json!({"bodies": bodies}));
|
||||
app.pick_save_command(
|
||||
&format!("{name}.step"),
|
||||
&["step", "stp", "igs", "stl", "3mf", "obj", "glb"],
|
||||
"file.export",
|
||||
json!({"bodies": bodies}),
|
||||
);
|
||||
}
|
||||
"ui.findBrowser" => {
|
||||
if let Some(b) = p.get("body").and_then(Value::as_str) {
|
||||
|
||||
@@ -975,6 +975,7 @@ fn export_tiles(app: &SolveApp) -> start_page::Tiles<'_, FileAction> {
|
||||
("mesh", "Mesh", Some("home.export_mesh_hint"), FileAction::Mesh),
|
||||
("view_dxf", "DXF", None, FileAction::ViewDxf),
|
||||
("obj", "OBJ", None, FileAction::Export("obj")),
|
||||
("glb", "GLB", None, FileAction::Export("glb")),
|
||||
("urdf", "URDF", None, FileAction::Export("urdf")),
|
||||
("json", "JSON", Some("home.export_json_hint"), FileAction::Export("json")),
|
||||
];
|
||||
@@ -1532,6 +1533,14 @@ mod tests {
|
||||
assert!(!h.app.home.open);
|
||||
assert!(h.dir.join(format!("{}.json", h.app.session.doc.name)).is_file());
|
||||
assert_eq!(h.app.session.doc.to_json(), original, "export leaves the design unchanged");
|
||||
// The GLB tile (#1553) writes a binary glTF file of the bodies.
|
||||
h.app.home.show(0);
|
||||
click_file(&mut h, "home:export");
|
||||
click_file(&mut h, "home:export:glb");
|
||||
h.settle();
|
||||
let glb = std::fs::read(h.dir.join(format!("{}.glb", h.app.session.doc.name))).unwrap_or_default();
|
||||
assert!(glb.starts_with(b"glTF"), "{} bytes", glb.len());
|
||||
assert_eq!(h.app.session.doc.to_json(), original, "export leaves the design unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -484,6 +484,7 @@ fn file_rows(app: &SolveApp) -> Vec<app_bar::FileRow> {
|
||||
rows.push(row("file.export_step", app.tr_label("Export STEP…"), &key("file.export_step"), "export", shown));
|
||||
rows.push(row("file.export_view_dxf", app.tr_label("Export Current View as DXF…"), "", "export", shown));
|
||||
rows.push(row("file.export_obj", app.tr_label("Export OBJ…"), &key("file.export_obj"), "export", shown));
|
||||
rows.push(row("export_glb", &format!("{}…", app.tr("ui.export_glb")), "", "export", shown));
|
||||
rows.push(row("file.export_urdf", app.tr_label("Export URDF…"), &key("file.export_urdf"), "export", shown));
|
||||
rows.push(row("export_json", app.tr_label("Export Design as JSON…"), "", "export", true));
|
||||
rows.push(FileRow::Separator);
|
||||
@@ -531,6 +532,7 @@ fn run_file_row(app: &mut SolveApp, id: &str) {
|
||||
show();
|
||||
}
|
||||
}
|
||||
"export_glb" => FileAction::Export("glb").run(app),
|
||||
"export_json" => FileAction::Export("json").run(app),
|
||||
"install" => {
|
||||
if let Some(offer) = crate::install::offered(app) {
|
||||
|
||||
@@ -32,6 +32,10 @@ const HOME_MIN: f32 = 128.0;
|
||||
const NAME_GAP: f32 = 6.0;
|
||||
/// A bar narrower than this shows no product name, no Undo and Redo, and narrower file actions.
|
||||
const COMPACT: f32 = 640.0;
|
||||
/// Space the File menu keeps below its rows for its frame and a gap above the window's bottom.
|
||||
const FILE_MENU_BOTTOM_GAP: f32 = 24.0;
|
||||
/// The least height the File menu's rows get, about three rows, however short the window.
|
||||
const FILE_MENU_MIN_ROOM: f32 = 100.0;
|
||||
|
||||
/// Where the bar publishes its parts in the widget registry.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -917,17 +921,24 @@ fn file_rows(ui: &mut egui::Ui, rows: &[FileRow], chosen: &mut Option<String>, i
|
||||
}
|
||||
|
||||
/// Draws the File menu's `rows` at `at` (under the File button) in the area `id`. `just_opened`:
|
||||
/// the click that opened it this pass is not a click elsewhere.
|
||||
/// the click that opened it this pass is not a click elsewhere. The rows scroll when the menu would
|
||||
/// run past the window's bottom, so every row stays reachable in a short window.
|
||||
pub fn file_menu(ctx: &egui::Context, rows: &[FileRow], at: Pos2, id: egui::Id, just_opened: bool) -> FileMenuOut {
|
||||
use crate::{Container, Surface};
|
||||
let mut chosen = None;
|
||||
// Room below `at` for the rows, less the menu frame's margins; never under three rows.
|
||||
let room = (ctx.content_rect().bottom() - at.y - FILE_MENU_BOTTOM_GAP).max(FILE_MENU_MIN_ROOM);
|
||||
let resp = Container::new(ctx, Surface::Popup).area(id).fixed_pos(at).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
crate::Menu::show_frame(ui, crate::Menu::frame(), |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
ui.visuals_mut().widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
|
||||
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE;
|
||||
ui.visuals_mut().widgets.hovered.weak_bg_fill = Tokens::get().hover;
|
||||
file_rows(ui, rows, &mut chosen, false);
|
||||
// `min_scrolled_height` lets the rows grow past the size the area kept from an earlier,
|
||||
// shorter list, as the panel drop-downs do (#874); they still shrink to fit their content.
|
||||
egui::ScrollArea::vertical().id_salt("file_menu_rows").max_height(room).min_scrolled_height(room).show(ui, |ui| {
|
||||
file_rows(ui, rows, &mut chosen, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
let close = chosen.is_some() || (!just_opened && resp.response.clicked_elsewhere());
|
||||
|
||||
@@ -242,8 +242,13 @@ fn file_item(id: &str, enabled: bool) -> FileItem {
|
||||
/// The File menu drawn for a frame with `events`: what it returned, and where each text was.
|
||||
fn file_menu_frame(ctx: &egui::Context, time: f64, events: Vec<egui::Event>) -> (FileMenuOut, Vec<(String, Pos2)>) {
|
||||
let rows = vec![FileRow::Item(file_item("new", true)), FileRow::Item(file_item("save", false)), FileRow::Separator];
|
||||
file_menu_frame_of(ctx, &rows, 600.0, time, events)
|
||||
}
|
||||
|
||||
/// The File menu with `rows` in an 800 by `height` window, drawn for a frame with `events`.
|
||||
fn file_menu_frame_of(ctx: &egui::Context, rows: &[FileRow], height: f32, time: f64, events: Vec<egui::Event>) -> (FileMenuOut, Vec<(String, Pos2)>) {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, height))),
|
||||
time: Some(time),
|
||||
events,
|
||||
focused: true,
|
||||
@@ -251,12 +256,39 @@ fn file_menu_frame(ctx: &egui::Context, time: f64, events: Vec<egui::Event>) ->
|
||||
};
|
||||
let mut out = FileMenuOut::default();
|
||||
let output = crate::test_support::raw_frame(ctx, input, |ui| {
|
||||
out = file_menu(ui.ctx(), &rows, pos2(100.0, HEIGHT), egui::Id::unique("test_file_menu"), false);
|
||||
out = file_menu(ui.ctx(), rows, pos2(100.0, HEIGHT), egui::Id::unique("test_file_menu"), false);
|
||||
});
|
||||
let texts = crate::test_support::text_shapes(&output).map(|s| (s.galley.text().to_string(), s.visual_bounding_rect().center())).collect();
|
||||
(out, texts)
|
||||
}
|
||||
|
||||
/// A File menu taller than the window stays inside it, and its last row scrolls into view.
|
||||
#[test]
|
||||
fn a_file_menu_taller_than_the_window_scrolls_its_rows() {
|
||||
let ctx = crate::test_support::context();
|
||||
let rows: Vec<FileRow> = (0..40).map(|i| FileRow::Item(file_item(&format!("r{i}"), true))).collect();
|
||||
let height = 360.0;
|
||||
file_menu_frame_of(&ctx, &rows, height, 0.0, Vec::new());
|
||||
file_menu_frame_of(&ctx, &rows, height, 0.1, Vec::new());
|
||||
let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("test_file_menu"))).expect("the menu is laid out");
|
||||
assert!(menu.bottom() <= height, "the menu ends at {} inside a {height} high window", menu.bottom());
|
||||
let shown = |texts: &[(String, Pos2)], text: &str| texts.iter().any(|(t, p)| t == text && menu.contains(*p));
|
||||
let over = menu.center();
|
||||
let mut texts = Vec::new();
|
||||
for i in 0..20 {
|
||||
let wheel = egui::Event::MouseWheel {
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, -400.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
};
|
||||
(_, texts) = file_menu_frame_of(&ctx, &rows, height, 0.2 + f64::from(i) * 0.1, vec![egui::Event::PointerMoved(over), wheel]);
|
||||
}
|
||||
assert!(shown(&texts, "Item r39"), "scrolling shows the last row");
|
||||
assert!(!shown(&texts, "Item r0"), "and the first has scrolled out");
|
||||
}
|
||||
|
||||
/// A row returns its id and closes the menu; a disabled one returns nothing and keeps it open;
|
||||
/// a click elsewhere closes it.
|
||||
#[test]
|
||||
|
||||
@@ -17,12 +17,20 @@ design · **3** Save · **4** Export: meshes, STEP, a DXF of the view, URDF, JSO
|
||||
| IGES 5.3 | manifold solids, or trimmed surfaces sewn into solids | manifold solids with names and colours |
|
||||
| FreeCAD `.FCStd`, Rhino `.3dm` | solids, through cadmpeg | |
|
||||
| 3MF, STL, OBJ | mesh bodies | yes |
|
||||
| glTF 2.0 (`.glb`, `.gltf` with its `.bin` files) | mesh bodies in the node tree's components, materials as appearances | |
|
||||
| glTF 2.0 (`.glb`, `.gltf` with its `.bin` files) | mesh bodies in the node tree's components, materials as appearances | `.glb`: placed bodies with their colours, metallic and roughness, in metres with Y up |
|
||||
| 3MF with face tags | as 3MF | each triangle tagged with its B-rep face, exact coordinates |
|
||||
| URDF | | the assembly as a robot: links with meshes, mass and inertia, joints from the assembly's joints |
|
||||
| DXF, SVG | into a sketch | sketches and flat patterns as DXF |
|
||||
| PNG, JPEG | canvases and decals | |
|
||||
|
||||
**File → Export GLB…** (`file.export` with a `.glb` path) writes one binary glTF file for web viewers,
|
||||
renderers such as Blender, and game engines. Each body is a node named after it, where the design places
|
||||
it. Its colour, metallic and roughness become a glTF material, and a face with a look of its own gets that
|
||||
look. A body with no colour gets the Render workspace's grey. The file is in metres with Y up, as glTF
|
||||
requires, through one root node. A transparent body or face, and a body that a render setup gives an
|
||||
image texture or optics, are refused with a message that names the body, since the file would show it
|
||||
wrongly; leave such a body out with `bodies`. A refused export leaves a file already at the path as it was.
|
||||
|
||||
Opening a STEP, IGES, FreeCAD or Rhino file creates a design with the file's bodies as a base feature, and
|
||||
inserting one adds them to the current design. Later features build on them. Mesh bodies render,
|
||||
measure, move and export, but solid features need B-rep bodies.
|
||||
|
||||
@@ -59,7 +59,8 @@ and help is welcome: see [CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
provisional picture from warm geometry caches while rebuilding before editing; caches never
|
||||
replace the parametric design. Export to STEP (AP242 with body names, colours and
|
||||
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII), OBJ and GLB (placed bodies with their opaque
|
||||
colours, metallic and roughness, in metres with Y up), and
|
||||
assemblies with joints as URDF for robot simulators (urdf.md);
|
||||
3MF, STL, OBJ and glTF 2.0 (`.glb`/`.gltf`, its node tree as components and its materials as
|
||||
appearances) import as mesh bodies (they render, measure, move and export; solid features
|
||||
|
||||
+1
-1
@@ -354,7 +354,7 @@ builds it.
|
||||
| `check_parameters` | `set: {name: value}` (a number or an expression each, as `set_parameter`'s `value`), `report?` (`measure`, `cylinders`, `errors`; default all three), `bodies?` (a list of body names: only those are measured and their cylinders listed; default all), `summary?` (`measure` as the `measure` tool's `summary: true`; default false) | Try parameter values without keeping them: one `edit.checkpoint`, every value set in one `parameters.change {changes}` (one rebuild; an app without `changes` gets one change per name), the reports read, then `edit.restore_checkpoint`, so the design, its parameters and its undo history are exactly as before. The reply has `values` (each parameter's value with the tried expressions in), `recomputed`, `measure` (volume, bbox, bodies and faces per body, as the `measure` tool reports them), `errors` and `warnings` (the features that fail or warn: `{feature, name, error}`) with `parameter_errors` (the parameters that don't evaluate), `cylinders` (every cylindrical face of every body as `{body, radius, origin, axis, faces, name, area}`; the halves a kernel splits one cylinder into are one entry, `origin` is the axis point closest to the world origin and `axis` points so that its first non-zero component is positive) and `restored: true`. On a large design, `bodies` with the bodies a change touches and `summary: true` keep the reply small. A name the design has no parameter of, a value that is not a number or a string, an expression that does not evaluate, an unknown report item or a `bodies` that is not a list of names is an error, and nothing changes; a body name the design does not have after the change is an error after the design is put back. If the checkpoint cannot be restored the call is an error with `restored: false`, `restore_error` and a note that the tried values are still in the design: set them back before saving. Replaces the loop `set_parameter`, `measure`, `body_topology`, `set_parameter` back, which cost every agent session three to five calls and up to 16 s of rebuilds. Not a `batch` step. |
|
||||
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: a render with the viewport's pipelines on a headless GPU device (see `solidcraft-cli gpu`; with no adapter, the CPU rasterizer, without analysis colours), fitted to the model, or from the named view or camera; the reply's `renderer` says which (`gpu`, `gpu-software`, `cpu`, with a `warning` for `cpu`). Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. A window that is minimised or on another macOS Space presents no frame: with no `source` the reply is a model render and says so (`fallback`); `source: "window"` is an error saying why, at once. Agents that don't need the app's chrome can ask for `source: "model"`. |
|
||||
| `compare_picture` | `reference`, `view?`, `camera?`, `bodies?`, `crop_px?`, `crop_fraction?`, `part_fills_crop?`, `fit?` (width, height, both; default width), `rows?` | Compare the model's outline with the part in a reference picture (a PNG or JPEG inside the server's folders, read through the same path rules as `screenshot`). The compare is outline only: holes are not compared — both the picture's and the model's holes are filled. The model's outline is `inspect.silhouette` seen from `view` (default front) or `camera`; the picture's part is found by growing the background in from the border through small colour steps, filling holes and keeping the largest piece, so a backdrop's shading stays background and labels on a board drop out; a part touching the border, a white part on a light grey backdrop, and a part on a transparent background all separate. `crop_px` (`[x, y, w, h]` in pixels) or `crop_fraction` (the same as fractions 0..1) takes one view off a board; with `part_fills_crop: true` the crop box is the part itself and no background search runs, for a crop that tightly frames the part; `fit` names the dimension the one uniform scale comes from, never stretched per axis (`both` scales by width and reports `aspect_mismatch`). Returns an image content block (the overlay PNG: model red, picture blue) and text: `overlap` (0..1) and the model's top, bottom, left and right edges against the picture's in mm at nine stations, positive where the model reaches past the picture, null where a station misses one of the two. As a `batch` step it runs `inspect.compare_picture` without the overlay. |
|
||||
| `export` | `path`, `format?` (step, stp, iges, igs, 3mf, 3mf-tagged, stl, stla, obj, urdf), `bodies?`, `mesh_prefix?`, `effort?`, `velocity?` | STEP, IGES, 3MF or mesh export; URDF writes the whole assembly with its meshes next to it (`docs/urdf.md`). |
|
||||
| `export` | `path`, `format?` (step, stp, iges, igs, 3mf, 3mf-tagged, stl, stla, obj, glb, urdf), `bodies?`, `mesh_prefix?`, `effort?`, `velocity?` | STEP, IGES, 3MF or mesh export; GLB writes binary glTF in metres with Y up, with each body's colours, metallic and roughness; URDF writes the whole assembly with its meshes next to it (`docs/urdf.md`). |
|
||||
| `undo`, `redo` | — | Undo / redo one change. |
|
||||
| `pending` | — | Bridged: the result of a request that was still running when its call returned (an error starting `still running:`), waiting up to 60 s for it; `done: false` while it runs (call again), `pending: null` when nothing runs. See [Long-running requests](#long-running-requests). |
|
||||
| `crash_reports` | `id?`, `limit?` | The crash reports on this computer, newest first; with `id`, the whole report (backtrace, command, recent commands, `design_path`/`replay_path`). See [crash-reports.md](crash-reports.md). |
|
||||
|
||||
Reference in New Issue
Block a user