From 3e2155e587ae10b4c989efb954dbef56f1c0c20f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 23:43:22 -0400 Subject: [PATCH] M7: DWG read/write through the acadrust bridge (crates/dwg) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 130 ++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + NOTICE | 6 ++ README.md | 7 ++- crates/dwg/Cargo.toml | 13 +++++ crates/dwg/src/lib.rs | 75 ++++++++++++++++++++++++ crates/io/Cargo.toml | 1 + crates/io/src/lib.rs | 8 ++- xtask/src/layers.rs | 1 + 9 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 crates/dwg/Cargo.toml create mode 100644 crates/dwg/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5f1f105..d28b2d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,29 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acadrust" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db2bd3da02ed787f3f5c8afba8779dd104b4459b78bb1431b1a0211cc3719f0" +dependencies = [ + "anyhow", + "bitflags 2.13.2", + "byteorder", + "encoding_rs", + "flate2", + "foldhash", + "indexmap", + "itoa", + "memmap2", + "nom", + "once_cell", + "rayon", + "ryu", + "thiserror 2.0.21", + "web-time", +] + [[package]] name = "accesskit" version = "0.24.1" @@ -174,6 +197,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arboard" version = "3.6.1" @@ -485,6 +514,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -542,6 +577,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "cadcraft-dwg" +version = "0.1.0" +dependencies = [ + "acadrust", +] + [[package]] name = "cadcraft-dxf" version = "0.1.0" @@ -584,6 +626,7 @@ version = "0.1.0" dependencies = [ "cadcraft-color", "cadcraft-doc", + "cadcraft-dwg", "cadcraft-dxf", "cadcraft-geom", "cadcraft-render", @@ -815,6 +858,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 = "crc32fast" version = "1.5.2" @@ -833,6 +882,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1046,6 +1114,20 @@ dependencies = [ "bytemuck", ] +[[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" @@ -1888,6 +1970,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1937,6 +2025,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" @@ -2011,6 +2105,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2710,6 +2814,26 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -2834,6 +2958,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index 8a71d50..f17f185 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/storytold/cadcraft" # internal cadcraft-geom = { path = "crates/geom" } cadcraft-dxf = { path = "crates/dxf" } +cadcraft-dwg = { path = "crates/dwg" } cadcraft-color = { path = "crates/color" } cadcraft-doc = { path = "crates/doc" } cadcraft-fonts = { path = "crates/fonts" } diff --git a/NOTICE b/NOTICE index a8efce6..22163da 100644 --- a/NOTICE +++ b/NOTICE @@ -9,6 +9,12 @@ docs/brand/LICENSE-brand.txt. Third-party material +Software dependencies are listed in Cargo.lock. One is under a weak-copyleft licence: the acadrust +crate (DWG reading and writing, https://github.com/hakanaktt/acadrust) is licensed under the +Mozilla Public License 2.0 and is used unmodified as a separate library (crates/dwg). Its source +is available from crates.io and the repository above. + + None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md, diff --git a/README.md b/README.md index aa6d4b1..b2b06e2 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,9 @@ line, prompts with clickable `[Keywords]`, AutoComplete, object snaps, polar tracking, ortho, direct distance entry, window and crossing selection, grips, and right-click-to-repeat behave the way decades of drafting habit expect. -- **Open files.** DXF is read and written natively (ASCII and binary, R12 through 2018); DWG is - on the roadmap. Export to SVG and PNG today. +- **Open files.** DXF is read and written natively (ASCII and binary, R12 through 2018), and DWG + files (R13 through 2018) open and save through the open-source acadrust library. Export to SVG + and PNG today. - **Fast and native.** Pure Rust and egui, no Electron, no web view. One binary on macOS (universal), Windows, Linux and FreeBSD, plus a WebAssembly build for the browser. - **Built for agents.** Every menu item, tool and prompt is a command. Agents can type at the @@ -91,7 +92,7 @@ numbers): | Layers & properties | Layers palette and Layer Properties Manager (on/off, freeze, lock, plot, colour, linetype), layer tools (isolate, freeze, off, lock, match, previous), Properties palette with per-object editing, linetypes, lineweights, colour index and true colour | | Annotation | Dimensions (linear, aligned, radius, diameter, angular, ordinate, arc length) rendered from DIMSTYLE settings, our own single-stroke drafting font, `%%d %%p %%c` codes, MTEXT wrapping and attachment | | Hatch & blocks | Pattern and solid hatches with our own pattern library, block references with attributes and nested blocks | -| Files | DXF read/write, SVG and PNG export | +| Files | DXF read/write (R12–2018), DWG read/write (R13–2018, via the acadrust library), SVG and PNG export | | Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) | ## Quick start diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml new file mode 100644 index 0000000..0f1d6aa --- /dev/null +++ b/crates/dwg/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cadcraft-dwg" +description = "CADCraft DWG bridge: converts DWG to and from DXF bytes using the acadrust crate (MPL-2.0, used unmodified)" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE). +acadrust = "0.6.1" diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs new file mode 100644 index 0000000..353658a --- /dev/null +++ b/crates/dwg/src/lib.rs @@ -0,0 +1,75 @@ +//! DWG support through a DXF bridge. +//! +//! DWG files are read with the `acadrust` crate (MPL-2.0, used unmodified as a dependency) and +//! converted to DXF bytes, which CADCraft's own DXF reader maps to its document model; saving +//! goes the other way. Keeping the dependency behind this byte-level API isolates it: nothing +//! else in CADCraft depends on its types. Native targets only (it memory-maps files). +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +/// DWG version codes we can write. +pub const VERSIONS: &[(&str, &str)] = + &[("AC1015", "2000"), ("AC1018", "2004"), ("AC1021", "2007"), ("AC1024", "2010"), ("AC1027", "2013"), ("AC1032", "2018")]; + +/// True when the bytes look like a DWG file (`AC10xx` magic). +pub fn is_dwg(bytes: &[u8]) -> bool { + bytes.len() > 6 && bytes.starts_with(b"AC10") && bytes.get(4..6).is_some_and(|v| v.iter().all(u8::is_ascii_digit)) +} + +/// The DWG version code of a file (e.g. "AC1032"). +pub fn version(bytes: &[u8]) -> Option { + is_dwg(bytes).then(|| String::from_utf8_lossy(bytes.get(0..6).unwrap_or_default()).to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use std::io::Cursor; + + /// Read DWG bytes and return an ASCII DXF rendition. + pub fn dwg_to_dxf(bytes: &[u8]) -> Result, String> { + let data = bytes.to_vec(); + let doc = std::panic::catch_unwind(move || acadrust::DwgReader::from_stream(Cursor::new(data)).read()) + .map_err(|_| "the DWG reader failed on this file".to_string())? + .map_err(|e| format!("DWG: {e}"))?; + acadrust::DxfWriter::new(&doc).write_to_vec().map_err(|e| format!("DWG→DXF: {e}")) + } + + /// Convert DXF bytes into a DWG file. + pub fn dxf_to_dwg(dxf: &[u8]) -> Result, String> { + let data = dxf.to_vec(); + let doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) + .map_err(|_| "the DXF→DWG conversion failed".to_string())? + .map_err(|e| format!("DXF: {e}"))?; + acadrust::DwgWriter::write_to_vec(&doc).map_err(|e| format!("DWG write: {e}")) + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub use native::{dwg_to_dxf, dxf_to_dwg}; + +#[cfg(target_arch = "wasm32")] +pub fn dwg_to_dxf(_bytes: &[u8]) -> Result, String> { + Err("DWG files can't be opened in the web build yet; save as DXF".into()) +} +#[cfg(target_arch = "wasm32")] +pub fn dxf_to_dwg(_dxf: &[u8]) -> Result, String> { + Err("DWG files can't be written in the web build yet; save as DXF".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_magic() { + assert!(is_dwg(b"AC1032\0\0\0\0")); + assert!(!is_dwg(b" 0\r\nSECTION")); + assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018")); + } + + #[test] + fn garbage_is_an_error_not_a_crash() { + assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err()); + assert!(dwg_to_dxf(b"").is_err()); + } +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index f44d536..de71bdd 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -13,5 +13,6 @@ cadcraft-geom = { workspace = true } cadcraft-color = { workspace = true } cadcraft-doc = { workspace = true } cadcraft-dxf = { workspace = true } +cadcraft-dwg = { workspace = true } cadcraft-render = { workspace = true } thiserror = { workspace = true } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 2d6e083..e1a7c84 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -24,8 +24,9 @@ fn ext(name: &str) -> String { /// Read a drawing; the format comes from the content (DXF ASCII/binary) or the name. pub fn read(bytes: &[u8], name: &str) -> Result { - if bytes.starts_with(b"AC10") { - return Err(IoError::Unsupported("DWG (reading .dwg is on the roadmap; save as DXF for now)".into())); + if cadcraft_dwg::is_dwg(bytes) { + let dxf = cadcraft_dwg::dwg_to_dxf(bytes).map_err(IoError::Format)?; + return dxf_read::read(&dxf); } match ext(name).as_str() { "dxf" | "" => dxf_read::read(bytes), @@ -37,7 +38,8 @@ pub fn read(bytes: &[u8], name: &str) -> Result { /// Write a drawing in the format chosen by the name's extension. pub fn write(d: &Drawing, name: &str) -> Result> { match ext(name).as_str() { - "dxf" | "dwg" | "" => Ok(dxf_write::write(d).into_bytes()), + "dxf" | "" => Ok(dxf_write::write(d).into_bytes()), + "dwg" => cadcraft_dwg::dxf_to_dwg(dxf_write::write(d).as_bytes()).map_err(IoError::Format), "svg" => Ok(svg::export(d, &Space::Model).into_bytes()), "png" => png(d, &Space::Model, 2400, 1600), e => Err(IoError::Unsupported(e.to_string())), diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 446b3b8..fb04181 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -35,6 +35,7 @@ impl Class { pub const TABLE: &[(&str, Class)] = &[ ("geom", Class::Layer(0)), ("dxf", Class::Standalone), + ("dwg", Class::Standalone), ("color", Class::Layer(0)), ("doc", Class::Layer(1)), ("fonts", Class::Layer(2)),