Import FreeCAD and Rhino files through cadmpeg (#1435)
FreeCAD .FCStd and Rhino .3dm files open, insert and drop like STEP and IGES: cadmpeg (Apache-2.0, pinned =0.6.1, native builds only) detects the format, decodes it under its service resource caps, validates the model and writes it as STEP AP214, which becomes an Import feature in a new component named after the file, with the up and names options. Only the fcstd, rhino and step codecs are compiled in (no default features). ACIS is left out: cadmpeg's sat codec is a ShapeManager stream decoder. native_cad.rs is the single importer seam. Self-made FreeCAD and Rhino box fixtures, hostile-input, ZIP-bomb and cap tests, engine tests for the component path; the browser build reports these formats as not supported. A note under design format 23 says an Import's file can name these formats. Co-authored-by: Jacob Oursland <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2b45635d73
commit
78242d67d1
23 files changed
+1585
-328
No files matched your search
@@ -229,6 +229,10 @@
|
||||
//! transformation: a link without `coupling` is a plain motion link, as it always was; 0.13.0
|
||||
//! (which reads up to 22) asks for an update instead of keeping a gear's ratio when its radius
|
||||
//! changes.
|
||||
//! Also under 23, an Import's `file` can name a FreeCAD `.FCStd` or Rhino `.3dm` file (#1435,
|
||||
//! read through cadmpeg); its `step` is still the STEP text the file was restated as, so the
|
||||
//! feature's fields are unchanged and version 22 designs need no transformation; 0.13.x asks
|
||||
//! for an update before it meets these imports.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .solidcraft design, or a .step/.stp/.igs/.3mf/.stl/.obj/.glb/.gltf file (opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"),
|
||||
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .step/.stp file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"),
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"),
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -215,7 +215,7 @@ fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.insert_step";
|
||||
let path = path_arg(p, cmd)?;
|
||||
if !solidcraft_io::is_step_path(path) {
|
||||
return Err(bad(cmd, "only STEP files (.step, .stp) can be inserted"));
|
||||
return Err(bad(cmd, "only STEP, IGES, FreeCAD and Rhino files (.step, .stp, .igs, .iges, .fcstd, .3dm) can be inserted"));
|
||||
}
|
||||
let options = step_options(p, cmd)?;
|
||||
add_step(s, path, str_(p, "name"), &options)
|
||||
|
||||
@@ -314,6 +314,8 @@ mod loft_cut;
|
||||
mod loft_ring;
|
||||
mod loft_spacing;
|
||||
mod mirror_planes;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_cad_import;
|
||||
mod path_scope;
|
||||
mod primitive_points;
|
||||
mod rim_cut;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! FreeCAD and Rhino files (read through cadmpeg) go the same way as STEP files: a new rigid
|
||||
//! component named after the file (#1426), with the `up` and `names` options (#1312).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::run;
|
||||
use crate::*;
|
||||
|
||||
const BLOCK_FCSTD: &[u8] = include_bytes!("../../../io/src/native_cad/fixtures/block.FCStd");
|
||||
const BLOCK_3DM: &[u8] = include_bytes!("../../../io/src/native_cad/fixtures/block.3dm");
|
||||
|
||||
/// `bytes` saved as `name` in a fresh temp directory.
|
||||
fn file(tag: &str, name: &str, bytes: &[u8]) -> (std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-native-cad-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
(dir, path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn extent(s: &Session) -> ([f64; 3], [f64; 3]) {
|
||||
let b = crate::view::bounds(s);
|
||||
([b.min.x, b.min.y, b.min.z], [b.max.x, b.max.y, b.max.z])
|
||||
}
|
||||
|
||||
fn near(a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
fn body_names(s: &Session) -> Vec<String> {
|
||||
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freecad_and_rhino_files_come_in_as_a_component_named_after_the_file() {
|
||||
for (tag, name, bytes) in [("fc", "block.FCStd", BLOCK_FCSTD), ("rh", "block.3dm", BLOCK_3DM)] {
|
||||
let (dir, path) = file(tag, name, bytes);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0]}));
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
assert_eq!(r["component_name"], "block", "{name}: {r}");
|
||||
let feature = r["feature"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.feature(feature).unwrap().kind.type_name(), "BaseFeature", "{name}");
|
||||
assert_eq!(s.doc.feature(feature).unwrap().component, r["component"].as_u64().unwrap(), "{name}");
|
||||
assert!((volume(&mut s) - (6000.0 + 125.0)).abs() < 1e-6, "{name}");
|
||||
// Rhino keeps the object's name; cadmpeg leaves FreeCAD's label out, so it is the file's.
|
||||
assert!(body_names(&s).iter().any(|n| n == "Block" || n == "block"), "{name}: {:?}", body_names(&s));
|
||||
assert_eq!(s.active_component, 0, "{name}: the active component is unchanged");
|
||||
|
||||
// As written the block spans 10 × 20 × 30; up: y stands it up, (x, y, z) → (x, -z, y).
|
||||
let mut d = Session::default();
|
||||
let r = run(&mut d, "doc.open", json!({"path": path}));
|
||||
assert_eq!((r["features"].clone(), r["component_name"].clone()), (json!(1), json!("block")), "{name}: {r}");
|
||||
assert!(near(extent(&d).1, [10.0, 20.0, 30.0]), "{name}: {:?}", extent(&d));
|
||||
let mut d = Session::default();
|
||||
let r = run(&mut d, "file.insert_step", json!({"path": path, "up": "y"}));
|
||||
assert_eq!(r["stand_up"], r["occurrence"], "{name}: {r}");
|
||||
let (lo, hi) = extent(&d);
|
||||
assert!(near(lo, [0.0, -30.0, 0.0]) && near(hi, [10.0, 0.0, 20.0]), "{name}: stood up: {lo:?} {hi:?}");
|
||||
|
||||
// Every names choice is taken, and the body is named something.
|
||||
for names in ["solid", "product", "instance"] {
|
||||
let mut d = Session::default();
|
||||
run(&mut d, "file.insert_step", json!({"path": path, "names": names}));
|
||||
let got = body_names(&d);
|
||||
assert!(got.len() == 1 && !got[0].is_empty(), "{name} names={names}: {got:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// A broken file is refused with a message naming it, and the design is left as it was.
|
||||
#[test]
|
||||
fn a_broken_freecad_or_rhino_file_is_refused_cleanly() {
|
||||
let mut cut = BLOCK_FCSTD[..BLOCK_FCSTD.len() / 2].to_vec();
|
||||
cut.extend_from_slice(b"garbage");
|
||||
for (tag, name, bytes) in
|
||||
[("bad-fc", "broken.FCStd", cut.as_slice()), ("bad-rh", "broken.3dm", &BLOCK_3DM[..200]), ("acis", "cube.sat", b"700 0 1 0\n")]
|
||||
{
|
||||
let (dir, path) = file(tag, name, bytes);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
|
||||
let before = s.doc.clone();
|
||||
let e: Result<Value> = s.execute("file.insert_step", &json!({"path": path}));
|
||||
let e = e.expect_err(name).to_string();
|
||||
assert!(!e.contains("internal"), "{name}: a clean error, not a caught panic: {e}");
|
||||
assert_eq!(s.doc, before, "{name}: the design is unchanged");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solidcraft-io"
|
||||
description = "SolidCraft file formats: designs (JSON), STL, OBJ, STEP, 3MF and glTF"
|
||||
description = "SolidCraft file formats: designs (JSON), STL, OBJ, STEP, IGES, 3MF and glTF, and FreeCAD/Rhino import"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -19,3 +19,9 @@ gltf = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
# FreeCAD and Rhino import. Not in the browser build: it compiles the zstd C library.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
cadmpeg-core = { workspace = true }
|
||||
cadmpeg-ir = { workspace = true }
|
||||
cadmpeg-registry = { workspace = true }
|
||||
+16
-10
@@ -1,8 +1,8 @@
|
||||
//! File formats. Designs use JSON or versioned containers (`.solidcraft`); the authoritative
|
||||
//! document rebuilds on load and optional display caches remain provisional. Bodies export
|
||||
//! as STL (binary or ASCII), OBJ, STEP and 3MF, and assemblies as URDF ([`urdf()`]); STEP
|
||||
//! files import as a base feature (see [`step_import_feature`]), 3MF and STL files as mesh bodies
|
||||
//! (see [`mesh_import_feature`]).
|
||||
//! as STL (binary or ASCII), OBJ, STEP and 3MF, and assemblies as URDF ([`urdf()`]); STEP, IGES,
|
||||
//! FreeCAD and Rhino files import as a base feature (see [`step_import_feature`]), 3MF and
|
||||
//! STL files as mesh bodies (see [`mesh_import_feature`]).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -18,6 +18,8 @@ pub use design::{
|
||||
write_design_container_with_thumbnail,
|
||||
};
|
||||
mod legacy_dir;
|
||||
mod native_cad;
|
||||
pub use native_cad::{NATIVE_CAD_EXTS, is_native_cad_path, native_cad_to_step};
|
||||
pub mod safe_write;
|
||||
mod sketch2d;
|
||||
mod svg;
|
||||
@@ -78,13 +80,14 @@ pub type Result<T> = std::result::Result<T, IoError>;
|
||||
/// Largest STEP file we read.
|
||||
pub const MAX_STEP_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Is this a CAD exchange file read as an Import base feature: STEP (`.step` / `.stp`) or IGES
|
||||
/// (`.igs` / `.iges`), any case?
|
||||
/// Is this a CAD file read as an Import base feature: STEP (`.step` / `.stp`), IGES
|
||||
/// (`.igs` / `.iges`) or a format read through cadmpeg ([`is_native_cad_path`]), any case?
|
||||
pub fn is_step_path(path: &str) -> bool {
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| ["step", "stp", "igs", "iges"].iter().any(|x| e.eq_ignore_ascii_case(x)))
|
||||
|| is_native_cad_path(path)
|
||||
}
|
||||
|
||||
/// Is this an IGES file name (`.igs` / `.iges`, any case)?
|
||||
@@ -162,11 +165,14 @@ pub fn step_import_feature_named(bytes: &[u8], file: &str, names: StepNames) ->
|
||||
if bytes.len() > MAX_STEP_BYTES {
|
||||
return Err(IoError::Invalid(format!("STEP file too large ({} MB, limit {} MB)", bytes.len() >> 20, MAX_STEP_BYTES >> 20)));
|
||||
}
|
||||
// STEP is 7-bit text with escapes; tolerate stray 8-bit bytes. IGES is restated as STEP
|
||||
// (the feature keeps that STEP text).
|
||||
let text = String::from_utf8_lossy(bytes).into_owned();
|
||||
let (text, iges_warnings) =
|
||||
if is_iges_path(file) { solidcraft_kernel::iges_to_step(&text).map_err(IoError::Invalid)? } else { (text, Vec::new()) };
|
||||
// STEP is 7-bit text with escapes; tolerate stray 8-bit bytes. IGES and the cadmpeg formats
|
||||
// are restated as STEP (the feature keeps that STEP text).
|
||||
let (text, iges_warnings) = if is_native_cad_path(file) {
|
||||
native_cad_to_step(bytes, file).map_err(IoError::Invalid)?
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(bytes).into_owned();
|
||||
if is_iges_path(file) { solidcraft_kernel::iges_to_step(&text).map_err(IoError::Invalid)? } else { (text, Vec::new()) }
|
||||
};
|
||||
let imp = solidcraft_kernel::step_import_shared(&text)?;
|
||||
let stem = std::path::Path::new(file)
|
||||
.file_stem()
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Other CAD formats read through [cadmpeg](https://github.com/cadmpeg/cadmpeg): FreeCAD
|
||||
//! `.FCStd` and Rhino `.3dm`. cadmpeg decodes the file into its neutral model and writes that as
|
||||
//! STEP, which the Import feature then reads and keeps like any STEP file.
|
||||
//!
|
||||
//! Only codecs for open formats are compiled in (the workspace enables cadmpeg-registry's
|
||||
//! `fcstd`, `rhino` and `step` features alone). cadmpeg's codecs for commercial formats, and its
|
||||
//! `sat` codec (a ShapeManager stream decoder that reads ACIS as one dialect), are not built.
|
||||
//!
|
||||
//! This module is the importer seam: the one place cadmpeg is called, returning STEP text plus
|
||||
//! warnings. A cadmpeg-based STEP reader could be added here later and chosen by a setting, to
|
||||
//! compare with our own STEP reader, without changing that reader.
|
||||
//!
|
||||
//! cadmpeg is not built for the browser (it compiles the zstd C library); there these formats
|
||||
//! return "not supported in the browser yet".
|
||||
|
||||
/// File extensions read through cadmpeg (lower case).
|
||||
pub const NATIVE_CAD_EXTS: [&str; 2] = ["fcstd", "3dm"];
|
||||
|
||||
/// Is this a FreeCAD or Rhino file name (any case)?
|
||||
pub fn is_native_cad_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| NATIVE_CAD_EXTS.iter().any(|x| e.eq_ignore_ascii_case(x)))
|
||||
}
|
||||
|
||||
/// Restate a FreeCAD or Rhino file (`file` is its name) as STEP text, with the losses
|
||||
/// and warnings cadmpeg reported. Panics inside cadmpeg become errors.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn native_cad_to_step(bytes: &[u8], file: &str) -> Result<(String, Vec<String>), String> {
|
||||
let name = std::path::Path::new(file).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file.to_string());
|
||||
solidcraft_kernel::guard("cadmpeg import", || convert::to_step(&name, bytes).map_err(solidcraft_kernel::KernelError::Invalid)).map_err(
|
||||
|e| match e {
|
||||
solidcraft_kernel::KernelError::Invalid(m) => m,
|
||||
e => e.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Restate a FreeCAD or Rhino file as STEP: not available in the browser yet.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn native_cad_to_step(_bytes: &[u8], file: &str) -> Result<(String, Vec<String>), String> {
|
||||
Err(format!("{file}: not supported in the browser yet (FreeCAD and Rhino files open in the desktop app)"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod convert {
|
||||
use std::io::Cursor;
|
||||
|
||||
use cadmpeg_core::decode::{DecodeArena, DecodeContext, DecodeMode, DecodePolicy, ResourceLimits, View};
|
||||
use cadmpeg_ir::codec::write::{EncodeInput, target::TargetRequest};
|
||||
use cadmpeg_ir::codec::{DecodeFailure, DecodeOptions, DecodeResult};
|
||||
use cadmpeg_ir::validate::validate_neutral_for_decode;
|
||||
use cadmpeg_registry::{DETECTION_PREFIX_LEN, Format, InputCatalog, ResolveSourceError, ResolvedSource, build_encoder, forced_input};
|
||||
|
||||
/// The codecs we accept (cadmpeg codec ids), whatever else the registry recognises.
|
||||
const ACCEPTED: [&str; 2] = ["fcstd", "rhino"];
|
||||
|
||||
/// Caps for hostile input: cadmpeg's tight `service` profile, with our STEP size limit.
|
||||
pub(super) fn policy() -> DecodePolicy {
|
||||
DecodePolicy {
|
||||
mode: DecodeMode::Salvage,
|
||||
limits: ResourceLimits { max_input_bytes: crate::MAX_STEP_BYTES as u64, ..ResourceLimits::service() },
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode `bytes` (a file called `name`, extension `ext`) with the codec cadmpeg detects from
|
||||
/// the bytes, else the one the extension names, if it is one of `accepted`. Returns the codec id
|
||||
/// and the decoded model.
|
||||
pub(super) fn decode(name: &str, ext: &str, bytes: &[u8], accepted: &[&str]) -> Result<(String, DecodeResult), String> {
|
||||
let policy = policy();
|
||||
let catalog = InputCatalog::with_builtins();
|
||||
let codec = {
|
||||
let arena = DecodeArena::new();
|
||||
let (ctx, _) = DecodeContext::from_root_bytes(&[], &arena, &policy).map_err(|e| e.to_string())?;
|
||||
let prefix = bytes.get(..DETECTION_PREFIX_LEN).unwrap_or(bytes);
|
||||
let resolved = catalog.resolve_seekable_source(&ctx, View::over_retained(prefix), &mut Cursor::new(bytes), None);
|
||||
let resolved = match resolved {
|
||||
Ok(ResolvedSource::Unrecognized | ResolvedSource::Cadir) | Err(ResolveSourceError::Ambiguous(_)) => {
|
||||
match forced_input(ext).map_err(|e| e.to_string())? {
|
||||
Some(forced) => catalog.resolve_source(&ctx, View::over_retained(prefix), Some(forced)),
|
||||
None => Ok(ResolvedSource::Unrecognized),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
ctx.finish_session().map_err(|e| e.to_string())?;
|
||||
match resolved.map_err(|e| format!("{name}: {e}"))? {
|
||||
ResolvedSource::Native { codec, .. } => codec,
|
||||
_ => return Err(format!("{name}: unrecognised file contents")),
|
||||
}
|
||||
};
|
||||
let format = codec.id().as_str().to_string();
|
||||
if !accepted.contains(&format.as_str()) {
|
||||
return Err(format!("{name}: {format} files are not supported (FreeCAD .FCStd and Rhino .3dm only)"));
|
||||
}
|
||||
|
||||
// Decode in memory under the caps.
|
||||
let options = DecodeOptions { container_only: false, policy };
|
||||
let decoded = codec.decode(&mut Cursor::new(bytes), &options).map_err(|e| match e {
|
||||
DecodeFailure::Codec(e) => format!("{name}: could not read this {format} file: {e}"),
|
||||
other => format!("{name}: {other}"),
|
||||
})?;
|
||||
Ok((format, decoded))
|
||||
}
|
||||
|
||||
pub(super) fn to_step(name: &str, bytes: &[u8]) -> Result<(String, Vec<String>), String> {
|
||||
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
|
||||
if !super::NATIVE_CAD_EXTS.contains(&ext.as_str()) {
|
||||
return Err(format!("{name}: not supported (FreeCAD .FCStd and Rhino .3dm only)"));
|
||||
}
|
||||
let (format, decoded) = decode(name, &ext, bytes, &ACCEPTED)?;
|
||||
let policy = policy();
|
||||
let (ir, report, fidelity) = decoded.into_parts();
|
||||
if !report.geometry_transferred() {
|
||||
return Err(format!("{name}: no geometry could be read from this {format} file"));
|
||||
}
|
||||
let mut warnings: Vec<String> = report.losses.iter().map(|l| format!("{}: {}", l.code, l.message)).collect();
|
||||
|
||||
// Validate the neutral model, as cadmpeg's own conversion does.
|
||||
let arena = DecodeArena::new();
|
||||
let ctx = DecodeContext::for_loaded_input(&arena, &policy, bytes.len() as u64).map_err(|e| e.to_string())?;
|
||||
let validation = validate_neutral_for_decode(&ctx, &ir, Vec::new()).map_err(|e| e.to_string())?;
|
||||
ctx.finish_session().map_err(|e| e.to_string())?;
|
||||
warnings.extend(validation.findings.iter().map(|f| format!("{}: {}", f.severity, f.message)));
|
||||
if !validation.is_ok() {
|
||||
return Err(format!("{name}: the {format} model is invalid ({} errors)", validation.error_count()));
|
||||
}
|
||||
|
||||
// Write it as STEP AP214.
|
||||
let plan = build_encoder(Format::Step)
|
||||
.plan(EncodeInput::new(&ir, Some(&fidelity)), TargetRequest::Explicit("ap214"))
|
||||
.map_err(|e| format!("{name}: could not restate as STEP: {e}"))?;
|
||||
let losses = &plan.report().losses;
|
||||
if losses.iter().any(|l| l.code.to_string() == "step/export.no-exportable-solids") {
|
||||
return Err(format!("{name}: this {format} file has no solids that can be imported"));
|
||||
}
|
||||
warnings.extend(losses.iter().map(|l| format!("{}: {}", l.code, l.message)));
|
||||
let mut out = Vec::new();
|
||||
plan.write_to(&mut out).map_err(|e| e.to_string())?;
|
||||
let step = String::from_utf8(out).map_err(|e| e.to_string())?;
|
||||
Ok((super::name_unnamed_products(step, name), warnings))
|
||||
}
|
||||
}
|
||||
|
||||
/// cadmpeg's STEP writer calls a product it has no name for `cadmpeg_model`; name it after the
|
||||
/// file instead (as plain STEP text: letters, digits, spaces, `-`, `_` and `.` only).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn name_unnamed_products(step: String, file: &str) -> String {
|
||||
const PLACEHOLDER: &str = "'cadmpeg_model'";
|
||||
let stem: String = std::path::Path::new(file)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.')).collect())
|
||||
.unwrap_or_default();
|
||||
if stem.trim().is_empty() || !step.contains(PLACEHOLDER) { step } else { step.replace(PLACEHOLDER, &format!("'{stem}'")) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
# Writes block.3dm: a 10 x 20 x 30 mm box called "Block", as a Rhino 7 file, with McNeel's MIT
|
||||
# licensed rhino3dm package. Run from this directory: `uv run --with rhino3dm python make_block_3dm.py`.
|
||||
import rhino3dm
|
||||
|
||||
model = rhino3dm.File3dm()
|
||||
model.Settings.ModelUnitSystem = rhino3dm.UnitSystem.Millimeters
|
||||
brep = rhino3dm.Brep.CreateFromBox(rhino3dm.Box(rhino3dm.BoundingBox(0, 0, 0, 10, 20, 30)))
|
||||
attributes = rhino3dm.ObjectAttributes()
|
||||
attributes.Name = "Block"
|
||||
model.Objects.AddBrep(brep, attributes)
|
||||
model.Write("block.3dm", 7)
|
||||
@@ -0,0 +1,12 @@
|
||||
# Writes block.FCStd: a 10 x 20 x 30 mm box called "Block". Run inside FreeCAD as an external
|
||||
# program, from this directory: `freecadcmd make_block_fcstd.py` (made with FreeCAD 1.1.4).
|
||||
import os
|
||||
|
||||
import FreeCAD
|
||||
import Part # noqa: F401 (loads the Part workbench)
|
||||
|
||||
doc = FreeCAD.newDocument("Block")
|
||||
box = doc.addObject("Part::Box", "Block")
|
||||
box.Length, box.Width, box.Height = 10, 20, 30
|
||||
doc.recompute()
|
||||
doc.saveAs(os.path.join(os.getcwd(), "block.FCStd"))
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::*;
|
||||
|
||||
/// A 10 × 20 × 30 mm box called "Block", saved by FreeCAD 1.1.4 (`fixtures/make_block_fcstd.py`).
|
||||
const BLOCK_FCSTD: &[u8] = include_bytes!("fixtures/block.FCStd");
|
||||
|
||||
/// The same box as a Rhino 7 file, written with McNeel's MIT-licensed rhino3dm package
|
||||
/// (`fixtures/make_block_3dm.py`).
|
||||
const BLOCK_3DM: &[u8] = include_bytes!("fixtures/block.3dm");
|
||||
|
||||
/// STEP text, which these importers refuse.
|
||||
const STEP_TEXT: &str = "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n";
|
||||
|
||||
/// The file imports as one solid with the box's volume, through the same Import feature as STEP.
|
||||
fn imports_the_block(bytes: &[u8], file: &str) {
|
||||
let step = native_cad_to_step(bytes, file).unwrap().0;
|
||||
assert!(step.contains("MANIFOLD_SOLID_BREP"), "{file}: no solid in the restated STEP");
|
||||
let sf = crate::step_import_feature(bytes, &format!("dir/{file}")).unwrap();
|
||||
assert_eq!(sf.import.bodies.len(), 1, "{file}");
|
||||
let solidcraft_doc::FeatureKind::Import { file: held, step, .. } = &sf.kind else { panic!("not an Import feature") };
|
||||
assert_eq!(held, file);
|
||||
assert!(step.contains("MANIFOLD_SOLID_BREP"), "the feature keeps the restated STEP");
|
||||
let v = solidcraft_kernel::measure(&sf.import.bodies[0].body).unwrap().volume;
|
||||
assert!((v - 6000.0).abs() < 1e-6, "{file}: volume {v}");
|
||||
// FreeCAD's object label does not come through cadmpeg; the product is then named after the file.
|
||||
let stem = file.rsplit_once('.').unwrap().0;
|
||||
assert!(["Block", stem].contains(&sf.body_names[0].as_str()), "{file}: {:?}", sf.body_names);
|
||||
assert!(!step.contains("cadmpeg_model"), "{file}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extensions() {
|
||||
assert!(is_native_cad_path("a/Part.FCStd") && is_native_cad_path("x.3DM") && is_native_cad_path("y.fcstd"));
|
||||
for other in ["c.sat", "c.sab", "c.smt", "c.smb", "c.f3d", "c.sldprt", "c.step", "fcstd"] {
|
||||
assert!(!is_native_cad_path(other), "{other}");
|
||||
}
|
||||
assert!(crate::is_step_path("c.FCStd") && crate::is_step_path("c.3dm"), "read as an Import feature like STEP and IGES");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freecad_box_imports_as_a_solid() {
|
||||
let bytes = BLOCK_FCSTD;
|
||||
assert!(bytes.starts_with(b"PK"), "an FCStd file is a ZIP archive");
|
||||
imports_the_block(bytes, "block.FCStd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rhino_box_imports_as_a_solid() {
|
||||
let bytes = BLOCK_3DM;
|
||||
assert!(bytes.starts_with(b"3D Geometry File Format"), "a 3dm file starts with its signature");
|
||||
imports_the_block(bytes, "block.3dm");
|
||||
}
|
||||
|
||||
/// Only FreeCAD and Rhino contents are read: STEP text, garbage, a bad ZIP and empty input are
|
||||
/// refused with a message, as are ACIS, ShapeManager and commercial formats whatever their bytes.
|
||||
#[test]
|
||||
fn other_contents_are_refused() {
|
||||
let step = STEP_TEXT;
|
||||
let fcstd = BLOCK_FCSTD;
|
||||
let cases: [(&[u8], &str); 9] = [
|
||||
(step.as_bytes(), "x.3dm"),
|
||||
(step.as_bytes(), "x.fcstd"),
|
||||
(b"\0\0garbage", "x.3dm"),
|
||||
(b"PK\x03\x04junk", "x.fcstd"),
|
||||
(b"", "x.3dm"),
|
||||
(b"700 0 1 0\n", "x.sat"),
|
||||
(b"ASM BinaryFile4", "x.smb"),
|
||||
(fcstd, "x.f3d"),
|
||||
(fcstd, "x.sldprt"),
|
||||
];
|
||||
for (bytes, name) in cases {
|
||||
let e = native_cad_to_step(bytes, name).expect_err(name);
|
||||
assert!(e.starts_with(name), "{name}: the message names the file: {e}");
|
||||
}
|
||||
// A Rhino file named .FCStd is still read as what it is.
|
||||
imports_the_block(BLOCK_3DM, "renamed.FCStd");
|
||||
}
|
||||
|
||||
/// Truncated, bit-flipped and garbage-tailed variants of valid FreeCAD and Rhino files give an
|
||||
/// error or a solid, never a panic.
|
||||
#[test]
|
||||
fn hostile_native_cad_never_panics() {
|
||||
for (good, name) in [(BLOCK_FCSTD, "hostile.FCStd"), (BLOCK_3DM, "hostile.3dm")] {
|
||||
let mut cases: Vec<Vec<u8>> = (0..good.len()).step_by((good.len() / 60).max(1)).map(|n| good[..n].to_vec()).collect();
|
||||
for i in (0..good.len()).step_by((good.len() / 80).max(1)) {
|
||||
let mut b = good.to_vec();
|
||||
b[i] ^= 0x5a;
|
||||
cases.push(b);
|
||||
let mut b = good.to_vec();
|
||||
b[i] = 0xff;
|
||||
cases.push(b);
|
||||
}
|
||||
let mut tail = good.to_vec();
|
||||
tail.extend((0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8));
|
||||
cases.push(tail);
|
||||
cases.push(good.iter().rev().copied().collect());
|
||||
for (i, bytes) in cases.iter().enumerate() {
|
||||
let r = std::panic::catch_unwind(|| crate::step_import_feature(bytes, name));
|
||||
assert!(r.is_ok(), "{name}: case {i} panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// cadmpeg decodes under its tight `service` caps, with our STEP size limit on the input.
|
||||
#[test]
|
||||
fn decoding_is_capped() {
|
||||
let limits = convert::policy().limits;
|
||||
let service = cadmpeg_core::decode::ResourceLimits::service();
|
||||
assert_eq!(limits.max_input_bytes, crate::MAX_STEP_BYTES as u64);
|
||||
assert_eq!(cadmpeg_core::decode::ResourceLimits { max_input_bytes: service.max_input_bytes, ..limits }, service);
|
||||
}
|
||||
|
||||
/// A FreeCAD archive whose Document.xml inflates past the per-entry cap (a ZIP bomb, 300 MB of
|
||||
/// zeros in a few hundred KB) is refused.
|
||||
#[test]
|
||||
fn a_zip_bomb_is_refused() {
|
||||
use std::io::Write;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
zip.start_file("Document.xml", zip::write::SimpleFileOptions::default().large_file(true)).unwrap();
|
||||
let zeros = vec![0u8; 1 << 20];
|
||||
for _ in 0..300 {
|
||||
zip.write_all(&zeros).unwrap();
|
||||
}
|
||||
let bytes = zip.finish().unwrap().into_inner();
|
||||
assert!(bytes.len() < 2 << 20, "{} bytes", bytes.len());
|
||||
assert!(native_cad_to_step(&bytes, "bomb.FCStd").is_err());
|
||||
}
|
||||
@@ -125,11 +125,15 @@ const STAGED_THUMBNAIL_TRIANGLES: usize = 1_000_000;
|
||||
/// How long a frame spends on a staged STEP import.
|
||||
const STAGED_FRAME_MS: f64 = 30.0;
|
||||
|
||||
/// Files read in stages: STEP (not IGES, which is first restated as STEP) to insert or open.
|
||||
/// Files read in stages: STEP (not IGES, FreeCAD or Rhino, which are first restated as STEP) to
|
||||
/// insert or open.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn staged_kind(request: &Request) -> bool {
|
||||
let p = &request.path;
|
||||
matches!(request.kind, Kind::Insert | Kind::Open) && solidcraft_engine::io::is_step_path(p) && !solidcraft_engine::io::is_iges_path(p)
|
||||
matches!(request.kind, Kind::Insert | Kind::Open)
|
||||
&& solidcraft_engine::io::is_step_path(p)
|
||||
&& !solidcraft_engine::io::is_iges_path(p)
|
||||
&& !solidcraft_engine::io::is_native_cad_path(p)
|
||||
}
|
||||
|
||||
impl Running {
|
||||
|
||||
Reference in new issue
Block a user