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 <jacob.oursland@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jackson OurslandandClaude Opus 5.5 authored and GitHub committed 2026-10-15 19:30:03 -04:00
1 parent 2b45635d73
commit 78242d67d1
23 files changed
+1585 -328

No files matched your search

+12
View File
@@ -110,6 +110,8 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
| crates/io/src/native_cad/fixtures/block.3dm | SolidCraft contributors | original 10 × 20 × 30 mm test box written with McNeel's MIT-licensed rhino3dm Python package (`make_block_3dm.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
@@ -151,6 +153,16 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
The glTF test files are built by the tests themselves (crates/io/src/gltf_in_tests.rs,
crates/engine/src/tests/gltf_import.rs); no third-party models are committed.
## FreeCAD and Rhino import library
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
0.6.1 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
`cadmpeg-container`, `cadmpeg-codec-freecad`, `cadmpeg-codec-rhino` and `cadmpeg-codec-step`
in native builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no
default features, so cadmpeg's codecs for commercial CAD formats and its ShapeManager/ACIS
codec are not compiled in. Its licence text is in THIRD-PARTY-LICENSES.txt; no code from it is
copied into SolidCraft.
## Recovered contributions
| SolidCraft file | Original contribution | Licence |
Generated
+329 -20
View File
@@ -131,7 +131,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@@ -489,6 +489,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
@@ -582,6 +591,117 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadmpeg-codec-freecad"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a5257265c2549cf59a8457b3c7aadcc96c95abe20205232553766993a808812"
dependencies = [
"cadmpeg-container",
"cadmpeg-core",
"cadmpeg-ir",
"roxmltree",
"serde",
"serde_json",
"tempfile",
"zip 8.6.0",
]
[[package]]
name = "cadmpeg-codec-rhino"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d88b42fb91c7e5af4c75c0433029771f7e3233a0b72ec3d0b2e5ff2c72bed07"
dependencies = [
"cadmpeg-container",
"cadmpeg-core",
"cadmpeg-ir",
"crc32fast",
"flate2",
"roxmltree",
"serde",
"serde_json",
"sha1 0.11.0",
"sha2 0.11.1",
"tempfile",
]
[[package]]
name = "cadmpeg-codec-step"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83a52258c129a683f77fb360ca30af54ae34339625ad890be12621128a5811c"
dependencies = [
"base64",
"cadmpeg-container",
"cadmpeg-core",
"cadmpeg-ir",
"encoding_rs",
"serde_json",
"thiserror 2.0.21",
"zip 8.6.0",
]
[[package]]
name = "cadmpeg-container"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c110508cd9155e905f52906de505dfd42dc70a0321f5e90b5f68c28ba207686e"
dependencies = [
"cadmpeg-core",
"crc32fast",
"flate2",
"zip 8.6.0",
"zstd 0.14.2",
]
[[package]]
name = "cadmpeg-core"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b1147e51a74e7c52da5b393a0344597238cdc622fac6443eb2fd526759f6fa"
dependencies = [
"flate2",
"memchr",
"roxmltree",
"serde",
"serde_json",
"thiserror 2.0.21",
"zstd 0.14.2",
]
[[package]]
name = "cadmpeg-ir"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71ff19f019f013d97e353bb802c5d2e253c2859aaa5422009aa933dc2f2e7dbc"
dependencies = [
"base64",
"cadmpeg-core",
"serde",
"serde-value",
"serde_json",
"sha2 0.11.1",
"thiserror 2.0.21",
]
[[package]]
name = "cadmpeg-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66046f1a694926707bea8944f861e699964b04c68972708588f97e14ccee3c5d"
dependencies = [
"cadmpeg-codec-freecad",
"cadmpeg-codec-rhino",
"cadmpeg-codec-step",
"cadmpeg-container",
"cadmpeg-core",
"cadmpeg-ir",
"serde",
"thiserror 2.0.21",
"toml",
]
[[package]]
name = "calloop"
version = "0.13.0"
@@ -704,7 +824,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"inout",
]
@@ -756,6 +876,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -812,6 +938,12 @@ dependencies = [
"libc",
]
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -821,6 +953,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -871,6 +1012,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -902,7 +1052,7 @@ dependencies = [
"hkdf",
"num",
"once_cell",
"sha2",
"sha2 0.10.9",
"zeroize",
]
@@ -936,11 +1086,22 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1155,6 +1316,20 @@ dependencies = [
"winreg",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1740,7 +1915,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1759,6 +1934,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -2252,6 +2436,12 @@ dependencies = [
"pxfm",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "naga"
version = "30.0.1"
@@ -2767,6 +2957,15 @@ dependencies = [
"libredox",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-float"
version = "5.5.0"
@@ -3291,6 +3490,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3463,7 +3671,7 @@ dependencies = [
"once_cell",
"rand",
"serde",
"sha2",
"sha2 0.10.9",
"zbus 4.4.0",
]
@@ -3525,6 +3733,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float 2.10.1",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.229"
@@ -3585,8 +3803,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
@@ -3596,8 +3825,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
@@ -3804,7 +4044,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
@@ -3856,16 +4096,19 @@ dependencies = [
name = "solidcraft-io"
version = "0.13.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
"cadmpeg-registry",
"gltf",
"quick-xml",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
"thiserror 2.0.21",
"zip",
"zip 3.0.0",
]
[[package]]
@@ -3903,7 +4146,7 @@ dependencies = [
"png",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-geom",
]
@@ -3931,12 +4174,12 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-engine",
"solidcraft-gpu",
"ttf-parser",
"web-time",
"zip",
"zip 3.0.0",
]
[[package]]
@@ -4370,6 +4613,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4941,7 +5190,7 @@ dependencies = [
"objc2-metal 0.3.2",
"objc2-quartz-core 0.3.2",
"once_cell",
"ordered-float",
"ordered-float 5.5.0",
"parking_lot",
"portable-atomic",
"portable-atomic-util",
@@ -5433,7 +5682,7 @@ name = "xtask"
version = "0.13.1"
dependencies = [
"serde_json",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -5486,7 +5735,7 @@ dependencies = [
"rand",
"serde",
"serde_repr",
"sha1",
"sha1 0.10.7",
"static_assertions",
"tracing",
"uds_windows",
@@ -5735,6 +5984,20 @@ dependencies = [
"zopfli",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zstd 0.13.3",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -5759,6 +6022,52 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe 7.3.0",
]
[[package]]
name = "zstd"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93"
dependencies = [
"zstd-safe 8.1.0",
]
[[package]]
name = "zstd-safe"
version = "7.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-safe"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.1+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+8
View File
@@ -57,6 +57,14 @@ quick-xml = "0.41"
# read by crates/io itself, so no image decoding or file access comes with it.
gltf = { version = "1.4", default-features = false, features = ["names"] }
# FreeCAD and Rhino import (Apache-2.0, pure Rust apart from zstd; native builds only).
# Pinned: its IR and codec APIs change between minor versions. Open formats only: no default
# features, so cadmpeg's codecs for commercial formats (f3d, sldprt, inventor, catia, creo, nx)
# and its ShapeManager/ACIS `sat` codec are not compiled in. `step` is the writer we restate through.
cadmpeg-core = "=0.6.1"
cadmpeg-ir = "=0.6.1"
cadmpeg-registry = { version = "=0.6.1", default-features = false, features = ["fcstd", "rhino", "step"] }
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
+3 -1
View File
@@ -136,7 +136,9 @@ rebuild with no errors, replayed here at 2× speed.
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)); FreeCAD
`.FCStd` and Rhino `.3dm` solids import the same way through
[cadmpeg](https://github.com/cadmpeg/cadmpeg) in the desktop app and CLI.
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
+2 -2
View File
@@ -7,7 +7,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Question | Answer |
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) or FreeCAD-computed (21) expected values, run by CI on every PR. |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
@@ -49,7 +49,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
+778 -287
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -108,7 +108,7 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
let future = open_parent
.clone()
.add_filter(
"Designs, STEP, IGES and meshes",
"Designs, CAD files and meshes",
&[
io::DESIGN_EXT,
io::LEGACY_DESIGN_EXT,
@@ -121,6 +121,10 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
"iges",
"IGS",
"IGES",
"FCStd",
"fcstd",
"3dm",
"3DM",
"3mf",
"3MF",
"stl",
@@ -136,6 +140,7 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("FreeCAD and Rhino", &["FCStd", "fcstd", "3dm", "3DM"])
.add_filter("Mesh (3MF, STL, OBJ, glTF)", &["3mf", "3MF", "stl", "STL", "obj", "OBJ", "glb", "GLB", "gltf", "GLTF"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
+4
View File
@@ -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;
+3 -3
View File
@@ -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)
+2
View File
@@ -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);
}
}
+7 -1
View File
@@ -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
View File
@@ -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()
+156
View File
@@ -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"))
+126
View File
@@ -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());
}
+6 -2
View File
@@ -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 {
+2 -1
View File
@@ -6,6 +6,7 @@
| `.json` (self-contained plain JSON design) | yes | yes |
| STEP (AP203, AP214, AP242) | solids, assemblies, units, names, colours | AP242 with names, colours and the component tree |
| IGES 5.3 | manifold solids, or trimmed surfaces sewn into solids | manifold solids with names and colours |
| FreeCAD `.FCStd`, Rhino `.3dm` | solids, through cadmpeg (desktop app only) | |
| 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 | |
| 3MF with face tags | as 3MF | each triangle tagged with its B-rep face, exact coordinates |
@@ -13,7 +14,7 @@
| DXF, SVG | into a sketch | sketches and flat patterns as DXF |
| PNG, JPEG | canvases and decals | |
Opening a STEP or IGES file creates a design with the file's bodies as a base feature, and
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.
+6
View File
@@ -39,3 +39,9 @@ display on the resulting body.
## IGES
IGES 5.3 files (`.igs`/`.iges`) open, insert and drop the same way: `crates/kernel/src/iges_in.rs` (written from the published specification) restates the file as STEP and the STEP reader reads it, so the feature holds STEP text. Read: manifold solid B-reps (186) with their shells, faces, loops, edge and vertex lists; trimmed (144) and bounded (143) surfaces, and bare B-spline patches, sewn into a solid by their shared edges (an open body when they do not close); lines, circular arcs, ellipses, composite curves, rational B-spline curves; planes (108, 190), cylinders, cones, spheres, tori, rational B-spline surfaces, surfaces of revolution and tabulated cylinders; transformation matrices; units; colours (314 and the predefined numbers); body names (name properties, else labels). Not read yet: ruled and offset surfaces, curves on surfaces given only in parameter space. Export (`file.export` to `.igs`) writes each body as a manifold solid B-rep on the same surfaces a STEP export uses, with its name and colour.
## FreeCAD and Rhino
FreeCAD `.FCStd` and Rhino `.3dm` files open, insert and drop the same way, in the desktop app and the CLI. They are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg) (Apache-2.0, `crates/io/src/native_cad.rs`): cadmpeg detects the format from the bytes, decodes it into its neutral model under resource caps, checks that model and writes it as STEP AP214, which the STEP reader then reads. The feature holds that STEP text, so the design reopens without the original file. The file's solids come in; its feature history, sketches and parameters do not. Losses cadmpeg reports become warnings on the feature, and a file without an importable solid is refused with a message. cadmpeg runs inside the panic guard. How much of a file is read follows cadmpeg's support for that format ([format support](https://github.com/cadmpeg/cadmpeg/blob/main/docs/format-support.md)).
Only codecs for open formats are compiled in: the workspace turns off cadmpeg's default features and enables `fcstd`, `rhino` and `step` (the writer). cadmpeg's codecs for commercial CAD formats are not built, and neither is its `sat` codec, which decodes ShapeManager streams with ACIS as one dialect, so ACIS `.sat`/`.sab` files are not read yet. `crates/io/src/native_cad.rs` is the one place cadmpeg is called; a cadmpeg-based STEP reader could be added there later behind a setting, to compare with the STEP reader above without changing it. The browser app does not read these formats yet: cadmpeg compiles the zstd C library, which the wasm build leaves out.
+2
View File
@@ -30,6 +30,8 @@ const ASSET_EXT: &[&str] = &[
"step",
"stp",
"obj",
"fcstd",
"3dm",
];
fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) -> Result<(), String> {