From 2e310438cce4fa07eee0637ac8f18bf69f3cfcd8 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 13:36:04 +0900 Subject: [PATCH 01/54] Name libclang, and how to install it, when bindgen cannot find it Without libclang the build died inside bindgen's Builder::generate() with "Unable to find libclang: couldn't find any valid shared libraries matching ...", which names neither this crate, nor LLVM, nor the command that fixes it -- and someone running `cargo install uncad-cli` never reads README.md's "Platform" section. build.rs now probes libclang first, through the same bindgen::clang_version() call generate() would make, and fails with the install command per platform, and with the LIBCLANG_PATH it searched when that is set. bindgen already runs before the C compile here, so the failure came within seconds before too; the difference is the message. Ported-from: 6ea8a3c Co-Authored-By: Claude Opus 5.5 --- crates/libredwg-sys/build.rs | 64 ++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/libredwg-sys/build.rs b/crates/libredwg-sys/build.rs index 01eed56..fa2b15a 100644 --- a/crates/libredwg-sys/build.rs +++ b/crates/libredwg-sys/build.rs @@ -61,6 +61,12 @@ fn main() { and unsafe to silently use on a {pointer_width}-bit target. See docs/CAVEATS.md." ); + // The other prerequisite, and the one a crates.io consumer is actually + // likely to be missing. Checked before anything else so that its absence + // is reported by name, with the command that installs it, rather than as + // bindgen's own message further down. + check_libclang(); + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); // Build from the vendored copy inside the crate (vendor/libredwg), not @@ -351,6 +357,64 @@ fn main() { ); } +/// Fails early, and by name, when libclang is missing. +/// +/// bindgen needs libclang at build time. Without it the build dies inside +/// `Builder::generate()` below, under a screenful of `cc` environment dump, +/// with `Unable to find libclang: "couldn't find any valid shared libraries +/// matching: ['clang.dll', 'libclang.dll'] ..."` -- which names neither this +/// crate, nor LLVM, nor the command that fixes it. README.md's "Platform" +/// section documents the prerequisite, and someone running +/// `cargo install uncad-cli` never reads README.md. (bindgen runs before the +/// C compile, so that failure already comes within seconds; what this adds is +/// a message that says what to install.) +/// +/// `bindgen::clang_version()` makes the same `ensure_libclang_is_loaded()` +/// call `generate()` does, so it fails exactly where `generate()` would -- +/// by panicking rather than returning an error, which is why this probes it +/// through `catch_unwind` instead of matching a `Result`. Cargo always +/// builds build scripts with `panic = "unwind"` (a profile's `panic` setting +/// does not reach them), so the unwind is caught rather than aborting. +fn check_libclang() { + let previous_hook = std::panic::take_hook(); + // Swallow bindgen's own message; the panic below replaces it. + std::panic::set_hook(Box::new(|_| {})); + let found = std::panic::catch_unwind(bindgen::clang_version).is_ok(); + std::panic::set_hook(previous_hook); + if found { + return; + } + + // A wrong LIBCLANG_PATH is its own failure mode, and bindgen's message + // reports it as an empty candidate list. + let stale_path = match std::env::var("LIBCLANG_PATH") { + Ok(path) => { + format!("LIBCLANG_PATH is set to '{path}', and no clang library was found there.\n") + } + Err(_) => String::new(), + }; + panic!( + " +libredwg-sys generates its FFI bindings with bindgen, which needs libclang, +and no libclang could be found. +{stale_path} +Install LLVM/Clang, then build again: + Windows winget install LLVM.LLVM (or: choco install llvm) + if it is still not found afterwards, set + LIBCLANG_PATH to the directory holding libclang.dll + (a default install puts it in LLVM/bin under + Program Files) + Debian/Ubuntu sudo apt install libclang-dev + Fedora/RHEL sudo dnf install clang-devel + Alpine apk add clang-dev + macOS xcode-select --install, or: brew install llvm + and set LIBCLANG_PATH=$(brew --prefix llvm)/lib + +LLVM is needed to build this crate, not to run what it builds. +" + ); +} + /// `-isystem` arguments for the MSVC and Windows SDK header directories the /// `cc` crate located, so libclang can resolve the C standard headers the /// same way `cl.exe` does. From 1918e0c20da1b6a95f4d8ee628f7e296cf49a87a Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 13:42:02 +0900 Subject: [PATCH 02/54] Read a drawing from memory, and convert its strings by code page, in the shim The shim gains the entry points the parser needs to read a drawing whose path LibreDWG cannot open, and to turn the file's bytes into UTF-8: - uncad_dwg_read_bytes / uncad_dxf_read_bytes decode from a buffer. The file readers fopen() a `const char *`, which the MSVC runtime interprets in the ANSI code page, so a non-ASCII path fails with DWG_ERR_IOERROR. Both mirror their file-based counterparts, down to dwg_read_file()'s VIEWPORT id fix-up; on four corpus files (DWG R2000/R2018, DXF R2000 x2) they return the same error code and object count as the file readers. - uncad_dwg_version, uncad_dwg_from_version, uncad_dwg_from_dxf and uncad_codepage_name expose the header fields Dwg_Data hides behind its opaque binding. - uncad_tv_to_utf8, uncad_bytes_to_utf8, their entity-pointer twins, uncad_dwg_string_to_utf8 and uncad_free_string convert a string by the drawing's code page, with the DOS-era double-byte rules LibreDWG's tables get wrong (BIG5 and GB2312 pair only bytes >= 0x80, CP932 is double-byte, GB2312's EUC bytes are masked to the table's 7-bit index), and tell an R2007+ DXF's UTF-16 fields from its 8-bit ones. - build.rs binds them, plus dwg_version_type and dwg_next_object. Where both sides of the port defined the same thing there is one definition. uncad_dwg_codepage returns uint16_t: LibreDWG's header.codepage is a BITCODE_RS, and a second `unsigned int` definition would not compile; uncad_codepage_name takes the same type. The feature branch's uncad_dwg_is_tu is the same test as uncad_dwg_is_wide_string, and no Rust code calls it, so only uncad_dwg_is_wide_string remains, now written as the library's own IS_FROM_TU_DWG macro (bits.h is on the shim's include path) instead of a copy of it. Nothing calls the new entry points yet; the parser moves onto them when it gains parse_bytes and the code-page rules. Ported-from: 2b2c750, 78cfe4b, a37b7ba, c045d6a, 1f08658, efd43cc Co-Authored-By: Claude Opus 5.5 --- crates/libredwg-sys/build.rs | 24 ++ crates/libredwg-sys/shim/uncad_shim.c | 446 +++++++++++++++++++++++++- crates/libredwg-sys/shim/uncad_shim.h | 141 +++++++- docs/ARCHITECTURE.md | 8 +- 4 files changed, 606 insertions(+), 13 deletions(-) diff --git a/crates/libredwg-sys/build.rs b/crates/libredwg-sys/build.rs index fa2b15a..7752b19 100644 --- a/crates/libredwg-sys/build.rs +++ b/crates/libredwg-sys/build.rs @@ -190,6 +190,11 @@ fn main() { .allowlist_function("dwg_object_polyline_2d_get_points") .allowlist_function("dwg_object_polyline_3d_get_numpoints") .allowlist_function("dwg_object_polyline_3d_get_points") + // The next object in file order. Pre-R13 files fill neither a + // POLYLINE's `first_vertex` nor its `vertex[]`, so its vertices are + // reachable only by object order there -- which is what LibreDWG's own + // pre-R13 branch walks. + .allowlist_function("dwg_next_object") // Named explicitly because src/lib.rs's hand-written // Dwg_MLINE_vertex refers to it, not only because the polyline // accessors above return it. @@ -208,6 +213,25 @@ fn main() { .allowlist_function("uncad_dwg_is_r2013_or_later") .allowlist_function("uncad_dwg_codepage") .allowlist_function("uncad_dwg_is_wide_string") + // Reading from memory (Unicode paths on Windows, in-memory inputs), + // the file-header accessors, and the code-page string conversion -- + // see shim/uncad_shim.h for why each exists. + .allowlist_function("uncad_dwg_read_bytes") + .allowlist_function("uncad_dxf_read_bytes") + .allowlist_function("uncad_dwg_version") + .allowlist_function("uncad_dwg_from_version") + .allowlist_function("uncad_dwg_from_dxf") + .allowlist_function("uncad_dwg_string_to_utf8") + .allowlist_function("uncad_tv_to_utf8") + .allowlist_function("uncad_entity_tv_to_utf8") + // The strings in_dxf.c stores 8-bit whatever the version (HEADER + // variables, MTEXT.text) -- never to be run through bit_convert_TU. + .allowlist_function("uncad_bytes_to_utf8") + .allowlist_function("uncad_entity_bytes_to_utf8") + .allowlist_function("uncad_free_string") + .allowlist_function("uncad_codepage_name") + // Version enum -> "r2004"-style name, for the parsed header. + .allowlist_function("dwg_version_type") // The codepage tables (src/codepages.h): what decodes a pre-R2007 // string's bytes, since the library's text accessors return them // undecoded. Predicates plus the byte -> code point lookups. diff --git a/crates/libredwg-sys/shim/uncad_shim.c b/crates/libredwg-sys/shim/uncad_shim.c index 9dc498c..a68fa00 100644 --- a/crates/libredwg-sys/shim/uncad_shim.c +++ b/crates/libredwg-sys/shim/uncad_shim.c @@ -1,9 +1,21 @@ +#include +#include #include #include #include #include "dwg.h" #include "dwg_api.h" +/* Private LibreDWG headers (vendor/libredwg/src, on the include path in + build.rs): bits.h for Bit_Chain, IS_FROM_TU_DWG and bit_TV_to_utf8; + decode.h for dwg_decode; in_dxf.h for dwg_read_dxf/dwg_read_dxfb; + codepages.h for the Dwg_Codepage enum; logging.h for the `loglevel` + global the file-based readers set from dwg->opts. */ +#include "bits.h" +#include "codepages.h" +#include "decode.h" +#include "logging.h" /* must precede in_dxf.h (logging.h enforces it) */ +#include "in_dxf.h" #include "uncad_shim.h" void * @@ -35,6 +47,424 @@ uncad_object_object_ptr (Dwg_Object *obj) return (void *)o->tio.UNKNOWN_OBJ; } +/* --- reading from memory ------------------------------------------------ */ + +/* Copy of the file-static dwg_fixup_viewport_ids() in src/dwg.c, which + dwg_read_file() runs after a successful decode and which this shim's + memory-based reader must therefore run too: it gives every paper-space + VIEWPORT its on_off/id the way AutoCAD numbers them, with the layout's + own overall viewport (entmode 0) as id 0 / off. */ +static void +fixup_viewport_ids (Dwg_Data *restrict dwg) +{ + BITCODE_BL i; + BITCODE_RS last_id = 0; + for (i = 0; i < dwg->num_objects; i++) + { + Dwg_Object *obj = &dwg->object[i]; + if (obj->supertype == DWG_SUPERTYPE_ENTITY + && obj->fixedtype == DWG_TYPE_VIEWPORT) + { + Dwg_Entity_VIEWPORT *_obj = obj->tio.entity->tio.VIEWPORT; + if (obj->tio.entity->entmode == 0) + { + _obj->on_off = 0; + _obj->id = 0; + last_id = 0; + } + else + { + _obj->on_off = 1; + last_id++; + _obj->id = last_id; + } + } + } +} + +int +uncad_dwg_read_bytes (const unsigned char *buf, size_t len, Dwg_Data *dwg) +{ + Bit_Chain dat; + int error; + unsigned int opts; + + if (!dwg) + return DWG_ERR_INVALIDDWG; + /* Same reset as dwg_read_file(): everything but the log-level bits. */ + opts = dwg->opts & DWG_OPTS_LOGLEVEL; + loglevel = opts; + memset (dwg, 0, sizeof (Dwg_Data)); + dwg->opts = opts; + + if (!buf || len < 6) + return DWG_ERR_INVALIDDWG; + + /* dat_read_file() leaves the chain NUL-terminated (size + 1 bytes); a + little extra zeroed slack costs nothing and protects the bit reader's + look-ahead on a truncated file. */ + memset (&dat, 0, sizeof (Bit_Chain)); + dat.chain = (unsigned char *)calloc (1, len + 16); + if (!dat.chain) + return DWG_ERR_OUTOFMEM; + memcpy (dat.chain, buf, len); + dat.size = len; + dat.opts = dwg->opts; + + error = dwg_decode (&dat, dwg); + free (dat.chain); + if (error >= DWG_ERR_CRITICAL) + return error; + + fixup_viewport_ids (dwg); + return error; +} + +int +uncad_dxf_read_bytes (const unsigned char *buf, size_t len, Dwg_Data *dwg) +{ + Bit_Chain dat; + int error; + unsigned int opts; + Dwg_Version_Type version; + + if (!dwg) + return DWG_ERR_INVALIDDWG; + /* Same reset as dxf_read_file(): keep the log level and a caller-preset + target version. */ + opts = dwg->opts & DWG_OPTS_LOGLEVEL; + loglevel = opts; + version = dwg->header.version; + memset (dwg, 0, sizeof (Dwg_Data)); + dwg->opts = opts | DWG_OPTS_INDXF; + dwg->header.version = version; + + /* "0\nSECTION\n2\nENTITIES\n0\nENDSEC\n" is the smallest DXF the reader + accepts; dxf_read_file() rejects anything shorter the same way. */ + if (!buf || len < 31) + return DWG_ERR_IOERROR; + + memset (&dat, 0, sizeof (Bit_Chain)); + dat.chain = (unsigned char *)calloc (1, len + 2); + if (!dat.chain) + return DWG_ERR_OUTOFMEM; + memcpy (dat.chain, buf, len); + dat.size = len; + dat.from_version = dwg->header.from_version; + dat.version = dwg->header.version; + dat.opts = dwg->opts; + + /* Terminate the buffer for the strtol()/sscanf() readers -- reproduced + verbatim from dxf_read_file(), including its quirk of writing the NUL + over the newline it just appended (the calloc'd slack keeps the result + NUL-terminated either way). */ + if (dat.chain[len - 1] != '\n') + { + dat.chain[len] = '\n'; + dat.size++; + } + dat.chain[len] = '\0'; + + /* Fail on DWG */ + if (!memcmp (dat.chain, "AC10", 4) || !memcmp (dat.chain, "AC1.", 4) + || !memcmp (dat.chain, "AC2.10", 4) || !memcmp (dat.chain, "MC0.0", 4)) + { + free (dat.chain); + return DWG_ERR_INVALIDDWG; + } + /* See if binary or ascii */ + if (!memcmp (dat.chain, "AutoCAD Binary DXF", + sizeof ("AutoCAD Binary DXF") - 1)) + { + dat.byte = 22; + error = dwg_read_dxfb (&dat, dwg); + } + else + error = dwg_read_dxf (&dat, dwg); + + dwg->opts |= (DWG_OPTS_INDXF | opts); + free (dat.chain); + if (error >= DWG_ERR_CRITICAL) + return error; + return 0; +} + +/* --- file header ---------------------------------------------------------- */ + +int +uncad_dwg_version (const Dwg_Data *dwg) +{ + return dwg ? (int)dwg->header.version : 0; +} + +int +uncad_dwg_from_version (const Dwg_Data *dwg) +{ + return dwg ? (int)dwg->header.from_version : 0; +} + +int +uncad_dwg_from_dxf (const Dwg_Data *dwg) +{ + return (dwg && (dwg->opts & DWG_OPTS_INDXF)) ? 1 : 0; +} + +char * +uncad_dwg_string_to_utf8 (const Dwg_Data *dwg, const char *s) +{ + if (!s) + return NULL; + if (dwg && IS_FROM_TU_DWG (dwg)) + return bit_convert_TU ((BITCODE_TU)s); + return uncad_tv_to_utf8 (dwg, s); +} + +const char * +uncad_codepage_name (uint16_t codepage) +{ + return dwg_codepage_dxfstr ((Dwg_Codepage)codepage); +} + +/* --- strings -------------------------------------------------------------- */ + +static char * +dup_string (const char *s) +{ + size_t n = strlen (s); + char *d = (char *)malloc (n + 1); + if (d) + memcpy (d, s, n + 1); + return d; +} + +/* Appends the UTF-8 encoding of one BMP code point (at most 3 bytes) and + returns how many bytes it wrote. Lone surrogates and anything outside the + BMP -- which no 8-bit code-page table produces -- become U+FFFD so the + 3-bytes-per-input-byte bound below always holds. */ +static size_t +put_utf8 (char *out, uint32_t wc) +{ + if (wc < 0x80) + { + out[0] = (char)wc; + return 1; + } + if (wc < 0x800) + { + out[0] = (char)(0xC0 | (wc >> 6)); + out[1] = (char)(0x80 | (wc & 0x3F)); + return 2; + } + if ((wc >= 0xD800 && wc <= 0xDFFF) || wc > 0xFFFF) + wc = 0xFFFD; + out[0] = (char)(0xE0 | (wc >> 12)); + out[1] = (char)(0x80 | ((wc >> 6) & 0x3F)); + out[2] = (char)(0x80 | (wc & 0x3F)); + return 3; +} + +/* Whether `c` opens a two-byte sequence in the double-byte code page `cp`. + + LibreDWG's dwg_codepage_is_twobyte answers "always" for the DOS-era + BIG5 (24) and GB2312 (31) pages, ASCII included, which paired every + table name up ("*Model_Space" -> "*M", "od", ...) and turned such a + drawing into one with no *Model_Space and no entities. Both are + EUC-style encodings whose lead and trail bytes are all >= 0x80, so the + high bit is the rule there. */ +static bool +opens_double_byte (Dwg_Codepage cp, unsigned char c) +{ + if (cp == CP_GB2312 || cp == CP_BIG5) + return c >= 0x80; + return dwg_codepage_is_twobyte (cp, c); +} + +/* Transcodes an 8-bit code-page string to UTF-8 with LibreDWG's own + code-page tables (dwg_codepage_uc / dwg_codepage_uwc / is_twobyte) and + then expands \U+XXXX / \M+nXXXX escapes the way bit_TV_to_utf8 does. + + This is a re-implementation of bit_TV_to_utf8_codepage (src/bits.c) + rather than a call to it, for two reasons found in review: that function + sizes its output at 1.5x the input for single-byte code pages and stops + *reading* once the output is full, so a CP1251/CP1252 string that is + mostly non-ASCII loses its tail ("Стена" -> "Стен"); and it writes a NUL + for a character the table cannot map, truncating the rest of the string. + Here the buffer is 3 bytes per input byte (the true bound) and an + unmappable character becomes U+FFFD. Four more table quirks are + corrected on the way (bits.c shares them): the DOS-era BIG5/GB2312 + pages pair only bytes >= 0x80 (see opens_double_byte); CP932 (22, DOS + Shift-JIS) is treated as double-byte although dwg_codepage_isasian + leaves it out, so its kanji do not go through the single-byte table one + byte at a time; cptbl_gb2312 is indexed by the 7-bit ISO-2022 form + (0x2121..0x777E) while the file holds EUC-CN bytes (0xA1A1..0xFEFE), so + the pair is masked to 7 bits before the lookup; and ASCII is never + looked up (see the loop), so 0x5C stays the backslash the text codes + need. `cp` must be a value dwg_codepage_dxfstr knows (the caller + checks). Returns NULL only when out of memory. */ +static char * +convert_codepage (const char *src, Dwg_Codepage cp) +{ + const bool is_asian = dwg_codepage_isasian (cp) || cp == CP_CP932; + const size_t srclen = strlen (src); + const unsigned char *p = (const unsigned char *)src; + const unsigned char *end = p + srclen; + char *out = (char *)malloc (srclen * 3 + 1); + char *expanded; + size_t o = 0; + + if (!out) + return NULL; + while (p < end) + { + uint32_t wc; + unsigned int c = *p++; + if (c < 0x80) + /* ASCII stays ASCII (no double-byte page has a lead byte below + 0x80). dwg_codepage_uwc maps 0x5C to the yen sign for CP932 and + to the won sign for JOHAB (their JIS X 0201 / KS X 1003 half), + but in a drawing 0x5C is the backslash of MTEXT's \P and of the + \U+XXXX escapes whatever the code page, and the tables' other + sub-0x80 rows are the identity. */ + wc = c; + else if (is_asian) + { + uint16_t cc = (uint16_t)c; + if (opens_double_byte (cp, (unsigned char)c) && p < end) + { + cc = (uint16_t)((cc << 8) | *p++); + if (cp == CP_GB2312) + cc &= 0x7F7F; + } + wc = (uint32_t)dwg_codepage_uwc (cp, cc); + if (wc == 0) + wc = 0xFFFD; + } + else + { + wc = (uint32_t)dwg_codepage_uc (cp, (unsigned char)c); + if (wc == 0) + wc = 0xFFFD; + } + o += put_utf8 (out + o, wc); + } + out[o] = '\0'; + + /* The escape expansion: with CP_UTF8, bit_TV_to_utf8 only rewrites the + \U+XXXX / \M+nXXXX sequences (in place, or in a fresh copy) and leaves + every other byte alone. */ + expanded = bit_TV_to_utf8 (out, (BITCODE_RS)CP_UTF8); + if (expanded && expanded != out) + { + free (out); + return expanded; + } + return out; +} + +/* The 8-bit half of uncad_tv_to_utf8: `s` holds bytes in the file's own + text encoding -- UTF-8 for an R2007+ DXF (what in_dxf.c's own TU + conversion, bit_utf8_to_TU, assumes of the file too), the header code + page for everything else -- and is never a UTF-16 buffer. */ +static char * +bytes_to_utf8 (const Dwg_Data *dwg, const char *s) +{ + unsigned int cp = (unsigned int)dwg->header.codepage; + char *converted; + + if ((dwg->opts & DWG_OPTS_IN) && dwg->header.from_version >= R_2007) + cp = CP_UTF8; + if (cp == CP_UTF8) + { + /* Only the \U+XXXX / \M+nXXXX escapes are rewritten; bit_TV_to_utf8 + returns NULL on error, its *input* when there was nothing to + convert, or a fresh buffer. Normalise all three to "a buffer the + caller owns". */ + converted = bit_TV_to_utf8 (s, (BITCODE_RS)CP_UTF8); + if (!converted || converted == s) + return dup_string (s); + return converted; + } + /* The code page is a raw RS from the file header (header.spec) and + LibreDWG indexes its tables with it unchecked -- a corrupt or unknown + value would read past cp_fntbl[]. CP_UNDEFINED ("mostly R11") and + CP_UTF16 (never valid for an 8-bit string) fall back to ANSI_1252, + LibreDWG's own default for an undeclared code page. */ + if (cp > CP_ANSI_1258 || cp == CP_UTF16 || !dwg_codepage_dxfstr ((Dwg_Codepage)cp)) + cp = CP_ANSI_1252; + + converted = convert_codepage (s, (Dwg_Codepage)cp); + return converted ? converted : dup_string (s); +} + +char * +uncad_tv_to_utf8 (const Dwg_Data *dwg, const char *s) +{ + if (!s) + return NULL; + /* R2007+ DWG: dynapi already handed out UTF-8 (bit_convert_TU). */ + if (!dwg || IS_FROM_TU_DWG (dwg)) + return dup_string (s); + + /* R2007+ DXF: in_dxf stores its T fields as UTF-16 (TU) as well, but + IS_FROM_TU_DWG is false for DXF input, so dynapi hands the UTF-16 + buffer out as if it were an 8-bit string (truncated at its first NUL, + which is why "*Model_Space" used to come back as "*"). Convert it the + way dynapi does for a DWG. The fields in_dxf.c stores 8-bit anyway + (see uncad_bytes_to_utf8) must not come through here: bit_convert_TU + scans for a 16-bit NUL and would read past their allocation. */ + if ((dwg->opts & DWG_OPTS_IN) && dwg->header.version >= R_2007) + { + char *converted = bit_convert_TU ((BITCODE_TU)(uintptr_t)s); + return converted ? converted : dup_string (""); + } + + return bytes_to_utf8 (dwg, s); +} + +char * +uncad_bytes_to_utf8 (const Dwg_Data *dwg, const char *s) +{ + if (!s) + return NULL; + if (!dwg || IS_FROM_TU_DWG (dwg)) + return dup_string (s); + return bytes_to_utf8 (dwg, s); +} + +/* The Dwg_Data owning an entity/object struct pointer (what + uncad_object_entity_ptr / uncad_object_object_ptr returned), or NULL + when dwg_obj_generic_to_object cannot walk back to the Dwg_Object. */ +static const Dwg_Data * +owning_dwg (const void *entity) +{ + int error = 0; + const Dwg_Object *obj; + if (!entity) + return NULL; + obj = dwg_obj_generic_to_object (entity, &error); + return (obj && !error) ? obj->parent : NULL; +} + +char * +uncad_entity_tv_to_utf8 (const void *entity, const char *s) +{ + return uncad_tv_to_utf8 (owning_dwg (entity), s); +} + +char * +uncad_entity_bytes_to_utf8 (const void *entity, const char *s) +{ + return uncad_bytes_to_utf8 (owning_dwg (entity), s); +} + +void +uncad_free_string (char *s) +{ + free (s); +} + +/* --- MULTILEADER ------------------------------------------------------------ */ + unsigned int uncad_multileader_get_lines (void *entity, uncad_multileader_line_t **out_lines) { @@ -103,6 +533,8 @@ uncad_multileader_free_lines (uncad_multileader_line_t *lines, unsigned int num_ free (lines); } +/* --- 3DSOLID ---------------------------------------------------------------- */ + char * uncad_3dsolid_sab_to_sat_text (const void *entity, size_t *out_len) { @@ -168,6 +600,8 @@ uncad_free_sat_text (char *text) free (text); } +/* --- version bands, codepage and string width ---------------------------- */ + int uncad_dwg_is_pre_r13 (const Dwg_Data *dwg) { @@ -220,12 +654,8 @@ uncad_dwg_codepage (const Dwg_Data *dwg) int uncad_dwg_is_wide_string (const Dwg_Data *dwg) { - if (!dwg) - return 0; - /* The same test as the library's IS_FROM_TU_DWG (bits.h, an internal - * header): read from an R2007+ DWG, and not through an importer - * (DWG_OPTS_IN -- DXF/JSON input), whose strings are stored as given. */ - return (dwg->header.from_version >= R_2007 && !(dwg->opts & DWG_OPTS_IN)) - ? 1 - : 0; + /* The library's own IS_FROM_TU_DWG (bits.h, an internal header on this + * file's include path): read from an R2007+ DWG, and not through an + * importer (DWG_OPTS_IN -- DXF/JSON input). */ + return (dwg && IS_FROM_TU_DWG (dwg)) ? 1 : 0; } diff --git a/crates/libredwg-sys/shim/uncad_shim.h b/crates/libredwg-sys/shim/uncad_shim.h index 8391a72..9d76a9b 100644 --- a/crates/libredwg-sys/shim/uncad_shim.h +++ b/crates/libredwg-sys/shim/uncad_shim.h @@ -37,6 +37,137 @@ void *uncad_object_entity_ptr(Dwg_Object *obj); */ void *uncad_object_object_ptr(Dwg_Object *obj); +/* --- reading from memory ------------------------------------------------ + * + * dwg_read_file()/dxf_read_file() take a `const char *filename` and open it + * with fopen(). On Windows the MSVC C runtime interprets that byte string in + * the process's ANSI code page, so a UTF-8 path with non-ASCII characters + * (e.g. a Korean directory name) fails with DWG_ERR_IOERROR even though the + * file exists. Reading the bytes in Rust (std::fs::read handles Unicode + * paths on every platform) and decoding from memory sidesteps that, and is + * also what a server that already holds the file in memory wants. + * + * Both functions mirror the body of their file-based LibreDWG counterpart + * (src/dwg.c): `dwg` is cleared except for the log-level bits of its `opts` + * (and, for DXF, its `header.version`), the buffer is copied into a + * Bit_Chain LibreDWG owns for the duration of the decode, and the return + * value has the same meaning (0 or a DWG_ERROR bit set; >= DWG_ERR_CRITICAL + * means the decode failed). `buf` is only read, never retained. + */ +int uncad_dwg_read_bytes(const unsigned char *buf, size_t len, Dwg_Data *dwg); +int uncad_dxf_read_bytes(const unsigned char *buf, size_t len, Dwg_Data *dwg); + +/* --- file header ---------------------------------------------------------- + * + * Dwg_Data is opaque on the Rust side (see build.rs), and LibreDWG has no + * public accessor for these two header fields, which together with the + * codepage (uncad_dwg_codepage, below) drive how strings must be decoded + * (see uncad_tv_to_utf8): `version` is the version the drawing is held as, + * `from_version` the one it was read from, both Dwg_Version_Type enum values + * (dwg.h). Both return 0 for a NULL `dwg`. + */ +int uncad_dwg_version(const Dwg_Data *dwg); +int uncad_dwg_from_version(const Dwg_Data *dwg); + +/* 1 when the data came from DXF text (LibreDWG's DWG_OPTS_INDXF flag on + * `dwg->opts`), 0 for a DWG or a NULL `dwg`. Some fields mean something + * else on that path (a LAYER's plot flag, for one). */ +int uncad_dwg_from_dxf(const Dwg_Data *dwg); + +/* The DXF name of a Dwg_Codepage value -- as uncad_dwg_codepage returns it + * -- ("ANSI_1252", "ANSI_949", "UTF-8", ...) from LibreDWG's own table + * (dwg_codepage_dxfstr in src/codepages.h, which is not a public header), or + * NULL for a value it has no name for. The string is static; do not free it. + */ +const char *uncad_codepage_name(uint16_t codepage); + +/* --- strings --------------------------------------------------------------- + * + * Converts one string as returned by dwg_dynapi_*_utf8text() / + * dwg_dynapi_handle_name() with `isnew == 0` (i.e. a raw pointer into the + * parsed Dwg_Data, in the file's own code page) to UTF-8. + * + * This exists because LibreDWG's dynapi only transcodes the R2007+ UTF-16 + * path; for older files it returns the code-page bytes unchanged, and the + * Rust side used to run those through a lossy UTF-8 decode, turning every + * non-ASCII character (Korean text in R2000/R2004 drawings, the degree and + * plus-minus signs in dimension text) into U+FFFD. LibreDWG does ship the + * converter -- bit_TV_to_utf8() in src/bits.c, with built-in CP949/CP936/ + * CP1252/... tables, used only by its own DXF writer -- so this wraps it: + * + * - when IS_FROM_TU_DWG(dwg) the input is already UTF-8 and is copied; + * - for an R2007+ DXF (DWG_OPTS_IN set, version >= R_2007) the string in + * memory is UTF-16 -- in_dxf.c stores its T fields as TU, but + * IS_FROM_TU_DWG excludes DXF input so dynapi never converts them -- + * and it goes through bit_convert_TU() like a DWG's would. This is the + * one rule that is not true of every field: see uncad_bytes_to_utf8 + * for the strings in_dxf.c keeps 8-bit whatever the version; + * - an R2007+ DXF's 8-bit strings are UTF-8 (the DXF's own encoding from + * AC1021 on, and what in_dxf.c's TU conversion assumes) and only have + * their `\U+XXXX` / `\M+nXXXX` escapes expanded (bit_TV_to_utf8), as + * does a CP_UTF8 code page; + * - CP_UNDEFINED, CP_UTF16 and any value outside LibreDWG's tables fall + * back to ANSI_1252, LibreDWG's own default (an unchecked value would + * index past its tables); + * - otherwise the string is transcoded with LibreDWG's code-page tables, + * an unmappable character becoming U+FFFD, and the escapes expanded. + * The double-byte pages follow the file's bytes rather than every + * quirk of LibreDWG's tables: the DOS-era BIG5 (24) and GB2312 (31) + * pair only bytes >= 0x80 (dwg_codepage_is_twobyte pairs ASCII too, + * which garbled every table name and emptied the drawing), CP932 (22) + * is decoded as the double-byte Shift-JIS it is, and GB2312's EUC-CN + * bytes are masked to the 7-bit form its table is indexed by. + * + * The result is always a fresh heap buffer (or NULL only for a NULL `s` or + * out of memory) -- bit_TV_to_utf8() may return its input pointer unchanged, + * which is copied here so the caller never has to guess who owns what. + * Free with uncad_free_string. + */ +char *uncad_tv_to_utf8(const Dwg_Data *dwg, const char *s); + +/* uncad_tv_to_utf8 for a string that is 8-bit in memory whatever the file's + * version: the bytes are decoded as the file's own text encoding (UTF-8 for + * an R2007+ DXF, the header code page otherwise) with the escapes expanded, + * and bit_convert_TU() is never run over them. + * + * This exists because LibreDWG's DXF reader does not store every string the + * way the "R2007+ means UTF-16" rule says. dxf_header_read() runs while + * `header.version` is still R_INVALID (dxf_fixup_header sets it afterwards, + * and the $ACADVER branch only sets it for R13..R2000), so every HEADER text + * variable ($DIMPOST, ...) is a plain malloc'd copy of the file's bytes; + * and MTEXT's group 1/3 text chunks are strdup'd/realloc'd together with no + * version branch at all (in_dxf.c, "MTEXT text > 250 chars"). Handing + * either to bit_convert_TU() walks 16-bit units past the end of the + * allocation until a zero pair happens to occur, so the header's DIMPOST + * and every MTEXT of an R2007+ DXF came back as CJK-looking garbage with + * neighbouring heap bytes in it. Same result type and ownership as + * uncad_tv_to_utf8; a `dwg` that IS_FROM_TU_DWG (only a TF field can get + * here from one) is copied unchanged. + */ +char *uncad_bytes_to_utf8(const Dwg_Data *dwg, const char *s); + +/* Same as uncad_tv_to_utf8, but finds the owning Dwg_Data through the + * entity/object struct pointer (what uncad_object_entity_ptr / + * uncad_object_object_ptr returned -- the same pointer dynapi takes), via + * dwg_obj_generic_to_object(). If that lookup fails the string is copied + * without conversion. + */ +char *uncad_entity_tv_to_utf8(const void *entity, const char *s); + +/* uncad_bytes_to_utf8 with the same entity-pointer lookup as + * uncad_entity_tv_to_utf8: for the fields in_dxf.c stores 8-bit whatever + * the version (MTEXT.text). */ +char *uncad_entity_bytes_to_utf8(const void *entity, const char *s); + +/* Any string pointer read straight out of `dwg`'s structures (a T/TV field + * of an embedded struct dynapi cannot reach through utf8text, say): the + * UTF-16 a R2007+ DWG stores is converted with bit_convert_TU, anything + * else goes through uncad_tv_to_utf8. NULL in, NULL out. Free with + * uncad_free_string. */ +char *uncad_dwg_string_to_utf8(const Dwg_Data *dwg, const char *s); + +void uncad_free_string(char *s); + /* One leader-line's vertices from a MULTILEADER, flattened into a single * malloc'd (x,y,z) array -- see uncad_multileader_get_lines. */ typedef struct uncad_multileader_line @@ -168,10 +299,14 @@ uint16_t uncad_dwg_codepage(const Dwg_Data *dwg); /* 1 when the drawing's strings are wide (UTF-16, R2007 and later), which the * library converts to UTF-8 in its text accessors, 0 otherwise or when `dwg` - * is NULL. The same test as the library's own IS_FROM_TU_DWG (an internal + * is NULL. This is the library's own IS_FROM_TU_DWG (src/bits.h, an internal * macro): `from_version` (the source's version) is R2007 or later and the - * drawing did not come through an importer (DXF/JSON input keeps strings - * as given). + * drawing did not come through an importer (DXF/JSON input). For a DWG, 0 + * means the strings are the file's raw 8-bit code-page bytes. For a DXF it + * is 0 whatever the version, although an R2007+ DXF holds most of its T + * fields as UTF-16 in memory, which the accessors then hand out unconverted + * -- uncad_tv_to_utf8 and uncad_bytes_to_utf8 above say which strings are + * which. */ int uncad_dwg_is_wide_string(const Dwg_Data *dwg); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 595dbcb..52ed829 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,8 +13,12 @@ crates/ bindings with bindgen. shim/ uncad_shim.c -- accessors that reach entity pointers behind opaque types, a walker that flattens nested structs dynapi - cannot reach (MULTILEADER leader lines), and a 3DSOLID SAB->SAT - conversion that runs on a copy rather than the original. + cannot reach (MULTILEADER leader lines), a 3DSOLID SAB->SAT + conversion that runs on a copy rather than the original, + readers that decode a DWG or DXF from a memory buffer rather + than a path, and the header fields (version, codepage, + string width) and string conversions that decoding a + drawing's text needs. vendor/libredwg/ the upstream C sources actually compiled (see "Build") vendor-config/ config.h -- hand-written, standing in for autotools' output examples/ smoke.rs -- manual check of the raw FFI (see "Test layout") From b00adb764873ca1c8bdc88e4d16cdb4bab987e44 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 13:45:44 +0900 Subject: [PATCH 03/54] Patch five defects in the vendored LibreDWG, and say so everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored copy stops being byte-for-byte upstream: five files carry an `uncad local patch` block, each dated and saying what it changes and why (GPLv3 §5(a)). - src/dwg.c: dwg_find_tablehandle(), dwg_find_dicthandle_objname() and dwg_handle_name() decode a table record's name before comparing it. For an R2007+ DXF the importer stores names as UTF-16 but read them as 8-bit, so every layer, block, style and dimstyle lookup it makes while building the drawing stopped at the first character. - src/common.c: cvt_TIMEBLL() zeroes its struct tm and clamps every field into what strftime() accepts. One changed byte of the corpus Helix.dwg otherwise ends the process with 0xC0000409 inside the C library, where no Rust guard reaches. - src/in_dxf.c and src/dynapi.c: AcDbPolygonMeshVertex, the marker on a polygon mesh's vertices, is known. Without it one polygon mesh is a critical error (2048) for the whole DXF. - src/common_entity_data.spec: an R2004+ entity colour reads its RGB before its transparency, as LibreDWG's own bit_read_ENC() does; with both present the two were swapped. The five files are the feature branch's, byte for byte (same blob hashes). With them, and nothing else changed, all 92 existing tests pass unchanged: golden, corpus_sweep's pinned counts, determinism, second_reader and dxf_pipeline included. The R2007+ DXF refusal stays -- this crate's half of reading those files is not ported yet. tests/vendored_patches.rs pins the two patches parse() can observe today; both tests fail against the unpatched sources (the first by ending the test binary with 0xC0000409, the second with Critical(2048)). The dwg.c patch is not observable while R2007+ DXF is refused, and the colour-order one not while an entity's true colour is read only under the TRUECOLOR method; the documents say so. The documents that called the vendored copy unmodified now say what it carries: the crate description, lib.rs, vendor-config/config.h, README, docs/ARCHITECTURE.md and docs/THIRD_PARTY_NOTICES.md. NOTICE.md at the crate root lists the five changes inside the tarball, where docs/ does not reach; docs/CAVEATS.md gains "Local patches to the vendored LibreDWG" and notes, in the R2007+ DXF section, that the library's half of that fix is now vendored. scripts/sync-libredwg-vendor.sh warns that a re-vendor deletes the patches. Ported-from: 722d568, 596e208, 598fc09, 25cd9e7, 75b0461 Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- crates/libredwg-sys/Cargo.toml | 2 +- crates/libredwg-sys/NOTICE.md | 63 ++++++ crates/libredwg-sys/src/lib.rs | 5 +- crates/libredwg-sys/vendor-config/config.h | 5 +- .../libredwg-sys/vendor/libredwg/src/common.c | 43 ++++ .../libredwg/src/common_entity_data.spec | 48 ++-- crates/libredwg-sys/vendor/libredwg/src/dwg.c | 80 ++++++- .../libredwg-sys/vendor/libredwg/src/dynapi.c | 7 +- .../libredwg-sys/vendor/libredwg/src/in_dxf.c | 26 +++ crates/uncad/tests/vendored_patches.rs | 207 ++++++++++++++++++ docs/ARCHITECTURE.md | 29 ++- docs/CAVEATS.md | 86 ++++++++ docs/THIRD_PARTY_NOTICES.md | 6 +- scripts/sync-libredwg-vendor.sh | 6 + 15 files changed, 582 insertions(+), 45 deletions(-) create mode 100644 crates/libredwg-sys/NOTICE.md create mode 100644 crates/uncad/tests/vendored_patches.rs diff --git a/README.md b/README.md index e375ed6..492a6ca 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,11 @@ well as Windows. ## License **GPLv3-or-later**. LibreDWG (GPLv3+) is the only third-party component linked -in, and its license carries over. Copyright and license details for third-party -components are in [`docs/THIRD_PARTY_NOTICES.md`](./docs/THIRD_PARTY_NOTICES.md). +in, and its license carries over. The vendored copy carries five local patches; +`crates/libredwg-sys/NOTICE.md` is their modification notice, inside the crate +so that it reaches the published tarball. Copyright and license details for +third-party components are in +[`docs/THIRD_PARTY_NOTICES.md`](./docs/THIRD_PARTY_NOTICES.md). ## Repository layout @@ -91,9 +94,10 @@ lib/libredwg/ LibreDWG upstream, as a git submodule. Not used by the it is the source vendor/ is regenerated from, and where the real-file test fixtures (test/test-data/) come from crates/ - libredwg-sys/ raw FFI (cc + bindgen). vendor/libredwg/ holds the unmodified - subset of C sources actually compiled (for publishing to - crates.io); shim/ holds the C accessors for opaque types, and + libredwg-sys/ raw FFI (cc + bindgen). vendor/libredwg/ holds the subset of C + sources actually compiled (for publishing to crates.io), with + five local patches marked "uncad local patch" and listed in + NOTICE.md; shim/ holds the C accessors for opaque types, and vendor-config/config.h stands in for autotools uncad/ the safe API: parse() -> uncad_model::CadDatabase uncad-cli/ the CLI binary (uncad) diff --git a/crates/libredwg-sys/Cargo.toml b/crates/libredwg-sys/Cargo.toml index f4a17f2..7e36423 100644 --- a/crates/libredwg-sys/Cargo.toml +++ b/crates/libredwg-sys/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true repository.workspace = true homepage.workspace = true readme.workspace = true -description = "Raw FFI bindings to the LibreDWG C sources (vendored, unmodified upstream), built natively via cc + bindgen (no Emscripten/WASM)." +description = "Raw FFI bindings to the LibreDWG C sources (a vendored subset of upstream carrying five local patches, see NOTICE.md), built natively via cc + bindgen (no Emscripten/WASM)." keywords = ["dwg", "dxf", "ffi", "libredwg", "cad"] categories = ["external-ffi-bindings"] links = "libredwg" diff --git a/crates/libredwg-sys/NOTICE.md b/crates/libredwg-sys/NOTICE.md new file mode 100644 index 0000000..9e09535 --- /dev/null +++ b/crates/libredwg-sys/NOTICE.md @@ -0,0 +1,63 @@ +# Notices for `libredwg-sys` + +`libredwg-sys` is GPL-3.0-or-later. The licence text is `LICENSE` beside this +file; it is the same text as LibreDWG's own `vendor/libredwg/COPYING`. + +## LibreDWG — vendored, and **modified** + +- **Upstream**: https://github.com/LibreDWG/libredwg +- **Copyright**: Free Software Foundation, Inc. +- **Licence**: GNU General Public License v3.0 or later; upstream's own copy + travels in this tarball as `vendor/libredwg/COPYING`. +- **What is here**: not all of LibreDWG. `vendor/libredwg/` holds only the + files `build.rs` actually compiles or `#include`s — 24 `.c` files plus the + headers, `.spec`, `.inc` and code-page tables they reach, traced from the + real include graph. Autotools' generated `config.h` is stood in for by this + crate's own `vendor-config/config.h`, a separate file rather than a patch to + LibreDWG's sources. +- **Modified**: **yes**, in five files. Each change carries an + `uncad local patch` comment in the source saying what changed and why + (GPLv3 §5(a)); `grep -rn "uncad local patch" vendor/libredwg/` finds every + marker. + +### The five changes + +The markers in `dwg.c` and `common.c` were written when those two were the +only changes, and cite this list as "The two changes"; it is this one. + +1. **`vendor/libredwg/src/dwg.c`**, 2026-09-23 — `dwg_find_tablehandle()`, + `dwg_find_dicthandle_objname()` and `dwg_handle_name()` read a table + record's name through a new `uncad_record_name_utf8()` helper instead of + `dwg_dynapi_entity_utf8text()`, so an R2007+ DXF's UTF-16 record names are + decoded before they are compared. Upstream compares the raw UTF-16 bytes + and so never matches such a name. +2. **`vendor/libredwg/src/common.c`**, 2026-09-23 — `cvt_TIMEBLL()` zeroes its + `struct tm` and clamps every field into the range `strftime()` accepts, so + a corrupt date in a hostile file cannot abort the process from inside a + library call. +3. **`vendor/libredwg/src/in_dxf.c`**, 2026-09-23 — the VERTEX_2D upgrade + chain knows `AcDbPolygonMeshVertex`, the subclass marker AutoCAD writes on + a polygon mesh's vertices. Without it the object stayed a VERTEX_2D whose + subclass list does not hold that name, which is a *critical* error: one + polygon mesh made the whole DXF unreadable. +4. **`vendor/libredwg/src/dynapi.c`**, 2026-09-23 — the same name added to + VERTEX_MESH's row in `dwg_name_subclasses[]`, which is the table the check + above consults. +5. **`vendor/libredwg/src/common_entity_data.spec`**, 2026-09-23 — the R2004+ + entity colour reads its RGB before its transparency, the order LibreDWG's + own `bit_read_ENC()` uses. With the two the other way round, an entity + carrying both had its colour and its transparency swapped. + +None of the changes alters LibreDWG's file formats or its API. Each changes +what the library reads only where upstream misreads a drawing, refuses it, or +ends the process on it. + +The full reasoning, with the reproduction for each, is in the project's +`docs/CAVEATS.md` under "Local patches to the vendored LibreDWG" — that file +is *not* in this tarball; it is at https://github.com/iyulab/uncad. + +## Everything else + +`libredwg-sys` bundles no other third-party source. The project-wide notice +file, covering the Rust dependency licences, is `docs/THIRD_PARTY_NOTICES.md` +in the repository above. diff --git a/crates/libredwg-sys/src/lib.rs b/crates/libredwg-sys/src/lib.rs index b1b0183..adba450 100644 --- a/crates/libredwg-sys/src/lib.rs +++ b/crates/libredwg-sys/src/lib.rs @@ -1,5 +1,6 @@ -//! Raw FFI bindings to the LibreDWG C sources (vendored, unmodified, from -//! the real upstream project at `lib/libredwg`, GPLv3+; see +//! Raw FFI bindings to the LibreDWG C sources (a vendored subset of the +//! real upstream project at `lib/libredwg`, GPLv3+, carrying five local +//! patches marked `uncad local patch`; see `NOTICE.md` in this crate and //! docs/THIRD_PARTY_NOTICES.md). //! Generated by `build.rs` via `bindgen` against the public //! `dwg.h`/`dwg_api.h` headers plus this crate's own `uncad_*` C shims diff --git a/crates/libredwg-sys/vendor-config/config.h b/crates/libredwg-sys/vendor-config/config.h index 412d297..59c30df 100644 --- a/crates/libredwg-sys/vendor-config/config.h +++ b/crates/libredwg-sys/vendor-config/config.h @@ -1,6 +1,7 @@ /* Hand-authored config.h for a native build of the vendored LibreDWG C - sources (crates/libredwg-sys/vendor/libredwg/, an unmodified subset of - the lib/libredwg submodule), used in place of the autotools-generated + sources (crates/libredwg-sys/vendor/libredwg/, a subset of the + lib/libredwg submodule carrying the local patches NOTICE.md lists -- none + of them in this file), used in place of the autotools-generated src/config.h.in output. See docs/ARCHITECTURE.md's "Build" section for why this project builds LibreDWG via a direct `cc`-crate compile instead of autotools/configure (Windows autotools friction). diff --git a/crates/libredwg-sys/vendor/libredwg/src/common.c b/crates/libredwg-sys/vendor/libredwg/src/common.c index 722e195..aba74eb 100644 --- a/crates/libredwg-sys/vendor/libredwg/src/common.c +++ b/crates/libredwg-sys/vendor/libredwg/src/common.c @@ -525,6 +525,38 @@ find_hv (BITCODE_H *entries, BITCODE_BS num_entries, BITCODE_RLL handle_value) return -1; // not found } +/* --- uncad local patch, 2026-09-23 (NOTICE.md at the root of this crate, + "The two changes"; the full reasoning is in the project's docs/CAVEATS.md, + "Local patches to the vendored LibreDWG", which is not in the published + tarball) --------------------------------------------------------------- + + Every caller of cvt_TIMEBLL() hands the struct tm straight to strftime() + (dec_macros.h's FIELD_TIMEBLL/FIELD_TIMERLL and the DECODER block in + header_variables.spec, which runs at any log level). Microsoft's UCRT + strftime() *validates* its struct tm: measured against ucrtbase.dll, a + tm_year outside [-1900, 8099], tm_mon outside [0, 11], tm_mday outside + [1, 31], tm_hour outside [0, 23], tm_min outside [0, 59] or tm_sec + outside [0, 60] trips the invalid-parameter handler, which fail-fasts the + whole process with 0xC0000409 -- reported as STATUS_STACK_BUFFER_OVERRUN + although nothing overran. That is below the FFI boundary, so no Rust + guard can catch it. + + A TIMEBLL is two raw BLs off the bit stream (BITCODE_BL, unsigned 32-bit + days and milliseconds), so a corrupt or truncated DWG -- or any mutation + that shifts the header bit stream before TDUCREATE/TDUUPDATE -- reaches + here with a date the Julian-day arithmetic below turns into an + out-of-range tm_year/tm_mon/tm_hour. Upstream additionally leaves + tm_wday/tm_yday/tm_isdst at whatever the caller's stack held. + + So: zero the struct first, and clamp every field into the range strftime + accepts before returning. A real drawing's date already satisfies every + bound, so no valid file's parse changes. The one visible difference is + the debug string for a >24h TDINDWG/TDUSRTIMER *duration*, whose hour is + now capped at 23 -- a LOG_TRACE line only (this crate never raises + loglevel), and strftime cannot print an hour above 23 anyway. */ +#define UNCAD_TM_CLAMP(v, lo, hi) \ + ((v) < (lo) ? (lo) : ((v) > (hi) ? (hi) : (v))) + /* from my dwg11.c, 1995 - rurban */ struct tm * cvt_TIMEBLL (struct tm *tm, BITCODE_TIMEBLL date) @@ -534,6 +566,7 @@ cvt_TIMEBLL (struct tm *tm, BITCODE_TIMEBLL date) #define TRUNC(n) (long)floor (n) + memset (tm, 0, sizeof (struct tm)); /* uncad local patch */ t = 0.864 * date.ms / 1000.0; /*t=1000000 = 1 day, means 86400 in seconds */ if (date.days > 2299161) { @@ -578,6 +611,16 @@ cvt_TIMEBLL (struct tm *tm, BITCODE_TIMEBLL date) ss = t - (tm->tm_min * 60.0); tm->tm_sec = (int)ss; // sprintf (s, "%02d.%02d.%4d %02d:%02d:%05.2f", d, m, y, hh, mm, ss); + /* uncad local patch: keep the result inside strftime's accepted ranges */ + tm->tm_sec = UNCAD_TM_CLAMP (tm->tm_sec, 0, 60); + tm->tm_min = UNCAD_TM_CLAMP (tm->tm_min, 0, 59); + tm->tm_hour = UNCAD_TM_CLAMP (tm->tm_hour, 0, 23); + tm->tm_mday = UNCAD_TM_CLAMP (tm->tm_mday, 1, 31); + tm->tm_mon = UNCAD_TM_CLAMP (tm->tm_mon, 0, 11); + tm->tm_year = UNCAD_TM_CLAMP (tm->tm_year, -1900, 8099); + tm->tm_wday = UNCAD_TM_CLAMP (tm->tm_wday, 0, 6); + tm->tm_yday = UNCAD_TM_CLAMP (tm->tm_yday, 0, 365); + tm->tm_isdst = 0; return tm; } diff --git a/crates/libredwg-sys/vendor/libredwg/src/common_entity_data.spec b/crates/libredwg-sys/vendor/libredwg/src/common_entity_data.spec index f8518b0..d6c40c4 100644 --- a/crates/libredwg-sys/vendor/libredwg/src/common_entity_data.spec +++ b/crates/libredwg-sys/vendor/libredwg/src/common_entity_data.spec @@ -430,6 +430,39 @@ LOG_TRACE (" color.flag: 0x%x\n", _ent->color.flag); #endif flags = _ent->color.flag; + /* --- uncad local patch (see docs/CAVEATS.md, "Local patches to the + vendored LibreDWG") ------------------------------------------------ + + The rgb block below used to come *after* the alpha block, so an + entity whose flag has both 0x80 (an rgb follows) and 0x20 (a + transparency follows) had its two BLs swapped: the colour landed in + alpha_raw and the transparency in rgb. LibreDWG's own standalone + bit_read_ENC() (bits.c) already reads rgb first; only this spec (and + the encoder that shares it) had the two the other way round. + + Measured on test-data/2004/HatchG.dwg, whose HATCH 29F (flag 0xa0) + sits inside LWPOLYLINE 28D (flag 0x80, one BL, unambiguous). With + alpha first the HATCH read alpha_raw = 0xc21ae464 -- byte for byte + the LWPOLYLINE's rgb -- and rgb = 0x020000e5, which has exactly the + alpha_type<<24 | alpha shape every flag-0x20 entity in the corpus + shows (0x0200003a, 0x02000056, ...). Reading rgb first gives the + HATCH its boundary's colour and leaves a well-formed transparency. + Only the 0xa0 combination changes: with one of the two bits set + there is a single BL and the order cannot matter. */ + if (flags & 0x40) + { +#ifndef IS_ENCODER + FIELD_HANDLE (color.handle, 0, 430); // DBCOLOR 1E9F74 => 1F05B9 lgtm[cpp/use-after-free] codeql[cpp/use-after-free] +#endif + } + else if (flags & 0x80) // and not a reference + { + DXF { + VALUE_BL (_ent->color.rgb & 0x00ffffff, 420); + } else { + FIELD_BLx (color.rgb, 420); // ODA bug, documented as BS + } + } if (flags & 0x20) { #ifdef IS_ENCODER @@ -452,20 +485,7 @@ FIELD_RC (color.alpha, 0); } } - if (flags & 0x40) - { -#ifndef IS_ENCODER - FIELD_HANDLE (color.handle, 0, 430); // DBCOLOR 1E9F74 => 1F05B9 lgtm[cpp/use-after-free] codeql[cpp/use-after-free] -#endif - } - else if (flags & 0x80) // and not a reference - { - DXF { - VALUE_BL (_ent->color.rgb & 0x00ffffff, 420); - } else { - FIELD_BLx (color.rgb, 420); // ODA bug, documented as BS - } - } + /* --- end uncad local patch ---------------------------------------- */ if ((flags & 0x41) == 0x41) { FIELD_TV (color.name, 430); diff --git a/crates/libredwg-sys/vendor/libredwg/src/dwg.c b/crates/libredwg-sys/vendor/libredwg/src/dwg.c index 1ea92f7..bcabcca 100644 --- a/crates/libredwg-sys/vendor/libredwg/src/dwg.c +++ b/crates/libredwg-sys/vendor/libredwg/src/dwg.c @@ -2328,6 +2328,74 @@ dwg_find_table_control (Dwg_Data *restrict dwg, const char *restrict table) return NULL; } +/* --- uncad local patch, 2026-09-23 (NOTICE.md at the root of this crate, + "The two changes"; the full reasoning is in the project's docs/CAVEATS.md, + "Local patches to the vendored LibreDWG", which is not in the published + tarball) --------------------------------------------------------------- + + LibreDWG stores a T (text) field as UTF-16 whenever the *writer* side of + dynapi decided to: dynapi_set_helper() widens as soon as + dwg->header.version >= R_2007, with no look at where the data came from. + The *reader* side (dwg_dynapi_entity_utf8text, dwg_dynapi_handle_name) + converts back only under IS_FROM_TU_DWG(), which additionally demands + !(dwg->opts & DWG_OPTS_IN) -- i.e. it is false for DXF and JSON input. + bits.h admits the gap in its own comment on IS_FROM_TU: + "only if from r2007+ DWG. not JSON, DXF (FIXME TABLE.name)". + + So for an R2007+ DXF a table record's "name" came out as the raw UTF-16 + buffer and was read as an 8-bit C string, which stops at the first NUL: + "Tavolo 3" compares as "T". Every name->handle lookup the DXF reader + makes (in_dxf.c resolves the group 8 layer, the group 2 block name, the + linetype, text style, dimstyle, UCS, VPORT and APPID names through + dwg_find_tablehandle and dwg_find_dicthandle_objname below) therefore + failed for any name longer than one character, leaving entity->layer and + INSERT->block_header NULL. + + UNCAD_IS_TU_DWG() is the predicate that storage actually follows, and + matches what this crate's own shim uses (uncad_tv_to_utf8 in + shim/uncad_shim.c). uncad_record_name_utf8() is the single place that + reads a record's "name" through dynapi and decodes it when needed. + + Scope: *only* fields dynapi itself wrote. The strings in_dxf.c stores + through dwg_add_u8_input() -- DICTIONARY.texts, LTYPE.dashes[].text -- + follow IS_FROM_TU_DWG and really are 8-bit for DXF input, so + dwg_find_dictionary() and dwg_find_dicthandle() below keep the original + predicate; widening it there would make bit_convert_TU/bit_eq_TU read + past an 8-bit allocation. The one table record in_dxf.c creates with + dwg_add_u8_input, the initial *Model_Space BLOCK_HEADER, is re-encoded to + TU by in_dxf.c itself as soon as $ACADVER turns out to be R2007+. */ +#define UNCAD_IS_TU_DWG(dwg) \ + (IS_FROM_TU_DWG (dwg) \ + || (((dwg)->opts & DWG_OPTS_IN) && (dwg)->header.version >= R_2007)) + +/* dwg_dynapi_entity_utf8text (_o, objname, "name", ...) plus the decode + IS_FROM_TU_DWG misses. On success *isnew tells the caller whether the + returned buffer must be freed, exactly as dynapi's own does. */ +static bool +uncad_record_name_utf8 (const Dwg_Data *restrict dwg, void *restrict _o, + const char *restrict objname, char **restrict out, + int *restrict isnew) +{ + Dwg_DYNAPI_field fp; + char *utf8; + + *isnew = 0; + if (!dwg_dynapi_entity_utf8text (_o, objname, "name", out, isnew, &fp)) + return false; + /* Already decoded by dynapi, a NULL name, or a fixed-length TF field + (which dynapi_set_helper never widens): nothing to do. */ + if (*isnew || !*out || strEQc (fp.type, "TF") || !UNCAD_IS_TU_DWG (dwg)) + return true; + utf8 = bit_convert_TU ((BITCODE_TU) (void *)*out); + if (utf8) + { + *out = utf8; + *isnew = 1; + } + return true; +} +/* --- end uncad local patch --------------------------------------------- */ + // Searching for a named dictionary entry, name is utf8. // Returning a hardpointer ref (5) to it, as stored in header_vars. // Usually another dictionary. @@ -2461,8 +2529,8 @@ dwg_find_dicthandle_objname (Dwg_Data *restrict dwg, BITCODE_H dict, || !hobj->name) continue; _o = hobj->tio.object->tio.APPID; - ok = dwg_dynapi_entity_utf8text (_o, hobj->name, "name", &hdlname, - &isnew, NULL); + /* uncad local patch: was dwg_dynapi_entity_utf8text () */ + ok = uncad_record_name_utf8 (dwg, _o, hobj->name, &hdlname, &isnew); LOG_HANDLE (" %s.%s[%d] => %s.name: %s\n", obj->name, "entries", i, hobj->name, hdlname ? hdlname : "NULL"); if (ok && hdlname @@ -2777,8 +2845,8 @@ dwg_find_tablehandle (Dwg_Data *restrict dwg, const char *restrict name, if (!hobj || !hobj->tio.object || !hobj->tio.object->tio.APPID) continue; _o = hobj->tio.object->tio.APPID; - ok = dwg_dynapi_entity_utf8text (_o, hobj->name, "name", &hdlname, - &isnew, NULL); + /* uncad local patch: was dwg_dynapi_entity_utf8text () */ + ok = uncad_record_name_utf8 (dwg, _o, hobj->name, &hdlname, &isnew); LOG_HANDLE (" %s.%s[%d] => %s.name: %s\n", obj->name, "entries", i, hobj->name, hdlname ? hdlname : "NULL"); if (ok && hdlname @@ -2999,8 +3067,8 @@ dwg_handle_name (Dwg_Data *restrict dwg, const char *restrict table, name = bo->name; } } - ok = dwg_dynapi_entity_utf8text (_o, name, "name", &hdlname, &isnew, - NULL); + /* uncad local patch: was dwg_dynapi_entity_utf8text () */ + ok = uncad_record_name_utf8 (dwg, _o, name, &hdlname, &isnew); LOG_HANDLE (" %s.%s[%d] => %s.name: %s\n", obj->name, "entries", i, hobj->name, hdlname ? hdlname : "NULL"); if (ok) diff --git a/crates/libredwg-sys/vendor/libredwg/src/dynapi.c b/crates/libredwg-sys/vendor/libredwg/src/dynapi.c index 7c55c38..013ab06 100644 --- a/crates/libredwg-sys/vendor/libredwg/src/dynapi.c +++ b/crates/libredwg-sys/vendor/libredwg/src/dynapi.c @@ -14744,7 +14744,12 @@ static const struct _name_subclasses dwg_name_subclasses[] = { { "VBA_PROJECT", {"AcDbObject", "AcDbVbaProject", NULL, NULL, NULL, NULL, NULL, NULL} }, { "VERTEX_2D", {"AcDbEntity", "AcDbVertex", "AcDb2dVertex", NULL, NULL, NULL, NULL, NULL} }, { "VERTEX_3D", {"AcDbEntity", "AcDbVertex", "AcDb3dPolylineVertex", NULL, NULL, NULL, NULL, NULL} }, - { "VERTEX_MESH", {"AcDbEntity", "AcDbVertex", "AcDbPolyFaceMeshVertex", NULL, NULL, NULL, NULL, NULL} }, + /* uncad local patch: "AcDbPolygonMeshVertex" added -- see in_dxf.c's + VERTEX_2D upgrade chain and docs/CAVEATS.md. It is the subclass marker + AutoCAD writes on a polygon mesh's vertices, i.e. exactly what a + VERTEX_MESH is; without it dwg_has_subclass() rejects the object and the + DXF reader fails the whole file. */ + { "VERTEX_MESH", {"AcDbEntity", "AcDbVertex", "AcDbPolyFaceMeshVertex", "AcDbPolygonMeshVertex", NULL, NULL, NULL, NULL} }, { "VERTEX_PFACE", {"AcDbEntity", "AcDbVertex", "AcDbPolyFaceMeshVertex", NULL, NULL, NULL, NULL, NULL} }, { "VERTEX_PFACE_FACE", {"AcDbEntity", "AcDbFaceRecord", NULL, NULL, NULL, NULL, NULL, NULL} }, { "VIEW", {"AcDbSymbolTableRecord", "AcDbViewTableRecord", NULL, NULL, NULL, NULL, NULL, NULL} }, diff --git a/crates/libredwg-sys/vendor/libredwg/src/in_dxf.c b/crates/libredwg-sys/vendor/libredwg/src/in_dxf.c index db846a7..6eab077 100644 --- a/crates/libredwg-sys/vendor/libredwg/src/in_dxf.c +++ b/crates/libredwg-sys/vendor/libredwg/src/in_dxf.c @@ -10524,6 +10524,32 @@ static __nonnull ((1, 2, 3, 4)) Dxf_Pair *new_object ( UPGRADE_ENTITY (VERTEX_2D, VERTEX_MESH) } } + /* --- uncad local patch (see docs/CAVEATS.md, "Local + patches to the vendored LibreDWG") ------------------ + + A polygon mesh's vertices carry the subclass marker + AcDbPolygonMeshVertex, which appeared nowhere in + LibreDWG: the chain above knows AcDb3dPolylineVertex, + AcDbPolyFaceMeshVertex and AcDbFaceRecord only. The + object therefore stayed a VERTEX_2D, whose subclass list + (dynapi.c) does not contain that name either, so the + "Illegal subclass ... in object VERTEX_2D" check below + took `goto invalid_dxf` -- DWG_ERR_INVALIDDWG, a + *critical* error, which makes the whole file + unreadable. One POLYLINE written by REVSURF, RULESURF, + EDGESURF, ezdxf's add_polymesh() or any terrain tool + cost every other entity in the DXF. + + VERTEX_MESH is what the else-branch above already + upgrades a polygon mesh's vertices to, so this only + adds the spelling AutoCAD actually writes. Its entry in + dwg_name_subclasses[] (dynapi.c) gains the same name, + so the subclass check passes afterwards. */ + else if (strEQc (subclass, "AcDbPolygonMeshVertex")) + { + UPGRADE_ENTITY (VERTEX_2D, VERTEX_MESH) + } + /* --- end uncad local patch ------------------------- */ else if (strEQc (subclass, "AcDbFaceRecord")) { UPGRADE_ENTITY (VERTEX_2D, VERTEX_PFACE_FACE) diff --git a/crates/uncad/tests/vendored_patches.rs b/crates/uncad/tests/vendored_patches.rs new file mode 100644 index 0000000..6b230b4 --- /dev/null +++ b/crates/uncad/tests/vendored_patches.rs @@ -0,0 +1,207 @@ +//! What the local patches to the vendored LibreDWG change, pinned from the +//! outside. +//! +//! `crates/libredwg-sys/vendor/libredwg/` carries five `uncad local patch` +//! blocks (listed in `crates/libredwg-sys/NOTICE.md`, reasoned in +//! `docs/CAVEATS.md`, "Local patches to the vendored LibreDWG"). A re-vendor +//! that drops one is caught by `build.rs`, which counts the markers; these +//! tests catch a patch that is still marked but no longer does its job. Each +//! input below was checked against a build without the patches: the corrupt +//! header date ends that build's process with 0xC0000409, and the polygon +//! mesh makes it refuse the whole file with critical error 2048. +//! +//! Two of the five are not observable through `parse()` yet, so they have no +//! test here. The `dwg.c` one decodes an R2007+ DXF's table names, and this +//! crate still refuses such a DXF before LibreDWG reads it. The +//! `common_entity_data.spec` one puts an entity's true colour and its +//! transparency back in their own fields, and the entity it was measured on +//! (HATCH 29F in `test-data/2004/HatchG.dwg`) comes out of this crate without +//! a true colour with the patch as without it: the colour is reported only +//! when its method says TRUECOLOR, and that entity's does not. + +use std::fs; +use std::path::{Path, PathBuf}; + +const HELIX: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/2000/Helix.dwg" +); + +/// The same corpus R2000 DXF the other DXF tests read. +const CORPUS_DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/2000/entities-2d.dxf" +); + +/// Removes its file on drop, so a failing assertion leaves nothing behind. +struct TempFile(PathBuf); + +impl Drop for TempFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +impl TempFile { + fn with_contents(name: &str, contents: &[u8]) -> Self { + let path = std::env::temp_dir().join(format!("uncad-{}-{}", std::process::id(), name)); + fs::write(&path, contents).expect("the temp dir should be writable"); + TempFile(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +// --- src/common.c: cvt_TIMEBLL --------------------------------------------- + +/// The single byte that turned this R2000 drawing into a process killer, +/// minimised from a fuzzed corpus file by bisecting over 89 mutated offsets. +/// It sits in the header-variables bit stream just before `TDUUPDATE`, so +/// flipping it gives that date a wild value, which `cvt_TIMEBLL` used to turn +/// into a `struct tm` that Microsoft's `strftime` refuses by ending the +/// process. +const HEADER_DATE_OFFSET: usize = 27644; +const HEADER_DATE_ORIGINAL: u8 = 0x25; +const HEADER_DATE_CORRUPT: u8 = 0x80; + +#[test] +fn a_corrupt_header_date_does_not_end_the_process() { + let mut bytes = fs::read(HELIX).expect("the corpus DWG is readable"); + assert_eq!( + bytes.get(HEADER_DATE_OFFSET).copied(), + Some(HEADER_DATE_ORIGINAL), + "the corpus file changed: byte {HEADER_DATE_OFFSET} is no longer the one this \ + regression was minimised against" + ); + + // The file as it stands must still read, so what follows is about the + // corruption and not about Helix.dwg. + let clean = uncad::parse(HELIX).expect("the unmodified drawing parses"); + assert!(!clean.entities.is_empty()); + + bytes[HEADER_DATE_OFFSET] = HEADER_DATE_CORRUPT; + let corrupt = TempFile::with_contents("corrupt-header-date.dwg", &bytes); + // Reaching the next line at all is the assertion: without the patch this + // call ends the test binary with 0xC0000409 on Windows, printing nothing. + // Either outcome is acceptable -- LibreDWG may still decode the rest of + // the file or give up on it. + match uncad::parse(corrupt.path()) { + Ok(_) | Err(_) => {} + } +} + +// --- src/in_dxf.c and src/dynapi.c: AcDbPolygonMeshVertex ------------------ + +/// `CORPUS_DXF` with one polygon mesh added to its ENTITIES section: a 2 x 2 +/// POLYLINE (flag 16) whose VERTEX records carry the subclass marker AutoCAD +/// writes on a polygon mesh's vertices, `AcDbPolygonMeshVertex`. The handles +/// are above everything the file already uses. The file's own line ending is +/// kept, whatever the checkout turned it into. +fn corpus_dxf_with_a_polygon_mesh() -> String { + let text = fs::read_to_string(CORPUS_DXF).expect("the corpus DXF should be readable text"); + let nl = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let pair = |code: u16, value: &str| format!("{code:>3}{nl}{value}{nl}"); + + let mut mesh = String::new(); + for (code, value) in [ + (0, "POLYLINE"), + (5, "F0"), + (330, "1F"), + (100, "AcDbEntity"), + (8, "0"), + (100, "AcDbPolygonMesh"), + (66, "1"), + (10, "0.0"), + (20, "0.0"), + (30, "0.0"), + (70, "16"), + (71, "2"), + (72, "2"), + ] { + mesh.push_str(&pair(code, value)); + } + for (handle, (x, y)) in [ + ("F1", (0, 0)), + ("F2", (0, 5)), + ("F3", (10, 0)), + ("F4", (10, 5)), + ] { + for (code, value) in [ + (0, "VERTEX".to_string()), + (5, handle.to_string()), + (330, "F0".to_string()), + (100, "AcDbEntity".to_string()), + (8, "0".to_string()), + (100, "AcDbVertex".to_string()), + (100, "AcDbPolygonMeshVertex".to_string()), + (10, format!("{x}.0")), + (20, format!("{y}.0")), + (30, "0.0".to_string()), + (70, "64".to_string()), + ] { + mesh.push_str(&pair(code, &value)); + } + } + for (code, value) in [ + (0, "SEQEND"), + (5, "F5"), + (330, "F0"), + (100, "AcDbEntity"), + (8, "0"), + ] { + mesh.push_str(&pair(code, value)); + } + + let entities = text + .find(&format!("{nl}ENTITIES{nl}")) + .expect("the corpus DXF has an ENTITIES section"); + let end = entities + + text[entities..] + .find(&format!(" 0{nl}ENDSEC{nl}")) + .expect("the ENTITIES section ends"); + format!("{}{mesh}{}", &text[..end], &text[end..]) +} + +/// How many entities of each type name a drawing holds. +fn type_counts(db: &uncad::CadDatabase) -> std::collections::BTreeMap { + let mut counts = std::collections::BTreeMap::new(); + for entity in &db.entities { + *counts.entry(entity.type_name().to_string()).or_insert(0) += 1; + } + counts +} + +#[test] +fn a_polygon_mesh_does_not_cost_the_whole_dxf() { + let reference = uncad::parse(CORPUS_DXF).expect("the untouched corpus DXF should parse"); + let with_mesh = TempFile::with_contents( + "polygon-mesh.dxf", + corpus_dxf_with_a_polygon_mesh().as_bytes(), + ); + + // Without the patch this is Err(Critical(2048)): the VERTEX stays a + // VERTEX_2D, fails the subclass check and the importer gives up on the + // file. + let db = uncad::parse(with_mesh.path()) + .unwrap_or_else(|e| panic!("a DXF holding a polygon mesh should parse: {e}")); + + // Everything the file held before is still there, and the mesh is one + // entity more -- whatever type this crate gives it, and with its + // vertices not counted as entities of their own. + let before = type_counts(&reference); + let after = type_counts(&db); + for (name, count) in &before { + assert_eq!( + after.get(name), + Some(count), + "{name}: the entities of the untouched file should all survive the mesh" + ); + } + assert_eq!( + db.entities.len(), + reference.entities.len() + 1, + "the polygon mesh should add exactly one entity: {after:?}" + ); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 52ed829..4c6824a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,8 @@ lib/libredwg/ git submodule pointing at LibreDWG upstream (github.com/LibreDWG/libredwg). Not used by the build -- it is the source vendor/ is copied from, and the origin of the - real-file test fixtures (test/test-data/). Left unmodified. + real-file test fixtures (test/test-data/). Left unmodified: + the local patches are in the vendored copy, not here. crates/ libredwg-sys/ raw FFI: build.rs compiles vendor/libredwg/src/*.c (see "Build") directly through the cc crate, no autotools, and generates the @@ -19,7 +20,8 @@ crates/ than a path, and the header fields (version, codepage, string width) and string conversions that decoding a drawing's text needs. - vendor/libredwg/ the upstream C sources actually compiled (see "Build") + vendor/libredwg/ the upstream C sources actually compiled (see "Build"), five + of them carrying a local patch (NOTICE.md) vendor-config/ config.h -- hand-written, standing in for autotools' output examples/ smoke.rs -- manual check of the raw FFI (see "Test layout") uncad/ the safe API, layered: dynapi.rs (reflection helpers) -> @@ -50,10 +52,13 @@ hand-written stand-in for what autotools would generate. and no submodule at all. A `build.rs` that referenced `repo_root/lib/libredwg` would work only inside this workspace and fail for every published consumer -- a failure mode confirmed with `cargo publish --dry-run`. So `crates/libredwg-sys/vendor/libredwg/` holds -a byte-for-byte copy of exactly the files this crate compiles: 24 `.c` files plus every -header, `.spec`, `.inc` and codepage table they `#include`, 112 files in total -(`git ls-files crates/libredwg-sys/vendor | wc -l`), unmodified (see -`docs/THIRD_PARTY_NOTICES.md`) but a subset rather than the whole submodule. The +a copy of exactly the files this crate compiles: 24 `.c` files plus every header, +`.spec`, `.inc` and codepage table they `#include`, 112 files in total +(`git ls-files crates/libredwg-sys/vendor | wc -l`), a subset rather than the whole +submodule. Five of those files carry a local patch each, marked in the source with a +dated `uncad local patch` comment and listed in `crates/libredwg-sys/NOTICE.md` (see +`docs/CAVEATS.md`, "Local patches to the vendored LibreDWG"); everything else is +byte for byte what the submodule holds. The submodule itself stays: it is the diff target when upstream moves, and the real-file tests (`png.rs`, `tests/dxf_pipeline.rs`, `tests/acis_sab.rs` in `uncad`, `tests/documented_invocations.rs` in `uncad-cli`) read fixtures from @@ -62,11 +67,13 @@ tests (`png.rs`, `tests/dxf_pipeline.rs`, `tests/acis_sab.rs` in `uncad`, **Updating the submodule**: after moving the `lib/libredwg` pointer (e.g. with `git submodule update --remote`), run `scripts/sync-libredwg-vendor.sh` to regenerate the -vendored copy -- it re-traces the real `#include` graph and rebuilds the file list. Then -run `cargo build --workspace`: a newly required `.c` or header shows up immediately as a -compile error. `build.rs` registers the whole `vendor/libredwg/` directory with -`cargo:rerun-if-changed`, so an incremental build really does recompile the C sources and -regenerate the bindings after a re-vendor -- no `cargo clean` needed. +vendored copy -- it re-traces the real `#include` graph and rebuilds the file list. It +deletes and recopies the whole directory, so the local patches are gone afterwards and +have to be re-applied (or dropped, with `NOTICE.md` and `docs/CAVEATS.md` updated to +match). Then run `cargo build --workspace`: a newly required `.c` or header shows up +immediately as a compile error. `build.rs` registers the whole `vendor/libredwg/` +directory with `cargo:rerun-if-changed`, so an incremental build really does recompile the +C sources and regenerate the bindings after a re-vendor -- no `cargo clean` needed. `build.rs` also carries two drift detectors: diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 4d37046..5f28d01 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -148,6 +148,12 @@ bytes with `$ACADVER` rewritten to `AC1018` (R2004) read as 14 -- an ATTDEF and appear that the R2000 pass drops without a message. Nothing on this side can tell that they were dropped. +Two DXF-only defects that *were* patched are recorded under "Local patches to the vendored +LibreDWG" below: a polygon mesh made the reader refuse the whole file, and the importer +compared an R2007+ DXF's table-record names in the wrong width. The per-entity +degradation this section describes -- a type dropping out, or arriving as +`Entity::Unknown` -- is what a DXF should cost; a whole-file refusal was not. + ### DXF saved as R2007 or later is refused with an explicit error `parse()` returns `Err(ParseError::UnsupportedDxfVersion)` for a DXF whose `$ACADVER` @@ -174,6 +180,12 @@ drawing that looks read and is quietly missing that much is worse than an obviou one, and an obviously empty one is worse than an error. The fix belongs in LibreDWG's accessor, or in reading DXF without LibreDWG. +The library's half of that fix is now in the vendored copy: a local patch to `dwg.c` (see +"Local patches to the vendored LibreDWG" below) makes the importer decode a record's name +before it compares it, so its own layer and block lookups no longer stop at the first +character. This crate's half -- reading each of an R2007+ DXF's strings in the width the +importer stored it in -- is not done yet, so the refusal stays. + What to do: save the drawing as R2004 DXF or as DWG (any version). A test walks every DXF in the LibreDWG corpus and checks that exactly the R2007+ files are refused; a second one feeds the same minimal drawing with two `$ACADVER` values and requires one refusal and one @@ -618,6 +630,80 @@ returns only the text, so `parse()` never touches the `Dwg_Data` at all. `crates/uncad/tests/acis_sab.rs` guards this with the same file, checking that both walks extract the same wireframe for every solid. +## Local patches to the vendored LibreDWG + +`crates/libredwg-sys/vendor/libredwg/` is a copy of the submodule sources (see +`docs/ARCHITECTURE.md`, "Build"), and it carries five local patches. Each is marked in the +source with a dated `uncad local patch` comment saying why, and each is listed again in +`crates/libredwg-sys/NOTICE.md` -- inside the crate, because that is what a crates.io +consumer receives and this file is not in the tarball (GPLv3 §5(a)). +**`scripts/sync-libredwg-vendor.sh` deletes and recopies that directory, so re-applying +these patches is part of any submodule update.** None of them has been sent to LibreDWG +yet. + +- **`src/dwg.c`** -- `dwg_find_tablehandle()`, `dwg_find_dicthandle_objname()` and + `dwg_handle_name()` read a table record's `name` with `IS_FROM_TU_DWG()`, which is false + for DXF and JSON input even when the record's name is stored as UTF-16 -- which it is for + an R2007+ DXF (see "DXF saved as R2007 or later is refused with an explicit error" + above). So "Tavolo 3" compared as "T", and every name the importer resolves while it + builds the drawing (the group 8 layer, the group 2 block name, the linetype, text + style, dimstyle, UCS, VPORT and APPID names) failed for any name longer than one + character. The three functions now share one helper, `uncad_record_name_utf8()`, whose + predicate `UNCAD_IS_TU_DWG()` is the one the storage actually follows, and the one the + shim's `uncad_tv_to_utf8` uses. The patch deliberately stops there: the strings + `in_dxf.c` stores through `dwg_add_u8_input()` (`DICTIONARY.texts`, + `LTYPE.dashes[].text`) really are 8-bit for DXF input, so `dwg_find_dictionary()` and + `dwg_find_dicthandle()` keep the original predicate. LibreDWG has the same gap; it is not + reported there yet. +- **`src/common.c`** -- `cvt_TIMEBLL()` left `tm_wday`/`tm_yday`/`tm_isdst` uninitialized + and let a corrupt date drive `tm_year`, `tm_mon` and `tm_hour` far out of range. Every + caller passes the result straight to `strftime()` (`dec_macros.h`'s `FIELD_TIMEBLL` and + the `DECODER` block in `header_variables.spec`, which runs at any log level), and + Microsoft's UCRT `strftime` *validates* its `struct tm`: measured against + `ucrtbase.dll`, a `tm_year` outside [-1900, 8099], `tm_mon` outside [0, 11], `tm_mday` + outside [1, 31], `tm_hour` outside [0, 23], `tm_min` outside [0, 59] or `tm_sec` outside + [0, 60] calls the invalid-parameter handler, which fail-fasts the process with + 0xC0000409. Windows reports that code as STATUS_STACK_BUFFER_OVERRUN even though nothing + overran, which is why it looked like a stack smash, and it happens below the FFI + boundary, where no Rust guard can catch it. The patch zeroes the `struct tm` and clamps + every field into those ranges. A real drawing's date already satisfies them, so no valid + file's parse changes; the only visible difference is that the debug string for a + TDINDWG/TDUSRTIMER *duration* longer than a day now caps its hour at 23 (a `LOG_TRACE` + line this crate never enables, and `strftime` cannot print a larger hour anyway). + `crates/uncad/tests/vendored_patches.rs` is the regression: one changed byte of the + corpus `2000/Helix.dwg` (offset 27644), which ends the process without the patch. That + is one abort fixed, not a guarantee: the decoder is ~100 000 lines of C over + attacker-controlled offsets and lengths, so a service that parses untrusted drawings + should still do it in a process it can afford to lose. +- **`src/in_dxf.c` and `src/dynapi.c`** -- a polygon mesh's vertices carry the subclass + marker `AcDbPolygonMeshVertex`, which appeared nowhere in LibreDWG: the VERTEX_2D + upgrade chain in `in_dxf.c` knows `AcDb3dPolylineVertex`, `AcDbPolyFaceMeshVertex` and + `AcDbFaceRecord` only, and `dwg_name_subclasses[]` does not list it either. The object + stayed a VERTEX_2D, failed the "is this subclass allowed in this object" check and took + `goto invalid_dxf` -- `DWG_ERR_INVALIDDWG`, a *critical* error, so **the whole file was + refused**. One POLYLINE written by REVSURF, RULESURF, EDGESURF or `ezdxf.add_polymesh()` + cost every other entity in the DXF. The patch adds the missing spelling to the upgrade + chain (to VERTEX_MESH, which is where the neighbouring branch already sends a polygon + mesh's vertices) and to VERTEX_MESH's subclass list. + `crates/uncad/tests/vendored_patches.rs` is the regression: the corpus + `2000/entities-2d.dxf` with one 2 x 2 polygon mesh added reads with every one of its own + entities, where it was critical error 2048 without the patch. +- **`src/common_entity_data.spec`** -- for an R2004+ entity whose ENC flag has both `0x80` + (an inline RGB follows) and `0x20` (a transparency follows), the spec read the two BLs + in the wrong order, so the colour landed in `alpha_raw` and the transparency in `rgb`. + LibreDWG's own standalone `bit_read_ENC` (`src/bits.c`) already reads rgb first; only + this spec, and the encoder that shares it, had them the other way round. Measured on + `test-data/2004/HatchG.dwg`, whose HATCH `29F` (flag `0xa0`) sits inside LWPOLYLINE + `28D` (flag `0x80`, one BL, unambiguous): with alpha first the HATCH read + `alpha_raw = 0xc21ae464` -- byte for byte the LWPOLYLINE's `rgb` -- and + `rgb = 0x020000e5`, which has exactly the `alpha_type << 24 | alpha` shape every + flag-`0x20` entity in the corpus shows. Only the `0xa0` combination changes: with one of + the two bits set there is a single BL and the order cannot matter. This crate does not + show the difference yet: it reports an entity's true colour only when the colour's + method says TRUECOLOR, and on that drawing neither entity's does, so both come out + without a true colour with the patch as without it. There is no regression test for it + here for that reason. + ## No DWG/DXF writing 0.1.0's `CadDatabase::write_dwg`/`write_dxf`, `uncad::dwg_to_dxf`, `WriteError` and the diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md index b339551..f5d4a97 100644 --- a/docs/THIRD_PARTY_NOTICES.md +++ b/docs/THIRD_PARTY_NOTICES.md @@ -4,12 +4,12 @@ This project bundles or builds against the following third-party components. ## LibreDWG -- **Source**: https://github.com/LibreDWG/libredwg (the real upstream project, tracked as a **git submodule** at `lib/libredwg`, plus an unmodified subset of it vendored directly into `crates/libredwg-sys/vendor/libredwg/` — see below) +- **Source**: https://github.com/LibreDWG/libredwg (the real upstream project, tracked as a **git submodule** at `lib/libredwg`, plus a subset of it, carrying the five local patches described below, vendored directly into `crates/libredwg-sys/vendor/libredwg/`) - **Copyright**: Free Software Foundation, Inc. - **License**: GNU General Public License v3.0 or later (GPLv3+), original `COPYING` file included at `crates/libredwg-sys/vendor/libredwg/COPYING` - **Used for**: DWG and DXF parsing (core engine, compiled as a native static library, linked via Rust FFI — `crates/libredwg-sys`) -- **Modified**: no — built directly from unmodified upstream sources (autotools' generated `config.h` is stood in for by this project's own `crates/libredwg-sys/vendor-config/config.h`, a separate file, not a patch to LibreDWG's own sources). -- **Vendored subset**: `crates/libredwg-sys` is published to crates.io as a standalone, self-contained crate, and crates.io only packages files inside a crate's own directory (no git submodules for downstream consumers). So `crates/libredwg-sys/vendor/libredwg/` holds an unmodified, byte-for-byte copy of just the 112 upstream files this crate's `build.rs` actually compiles/includes (24 `.c` files plus every header/`.spec`/`.inc`/codepage table they `#include`, traced from the real include graph — not a hand-picked subset) — not the full `lib/libredwg` submodule (which also has tests, docs, examples, and program sources this crate never builds). See `docs/ARCHITECTURE.md`'s "Build" section and `scripts/sync-libredwg-vendor.sh` for how it's kept in sync with the submodule. +- **Modified**: yes, in five files, each change dated 2026-09-23 and marked in the source with an `uncad local patch` comment naming what changed and why (GPLv3 §5(a)). The same notice travels inside the published crate as `crates/libredwg-sys/NOTICE.md`, because `docs/` is not part of that tarball. `src/dwg.c`: `dwg_find_tablehandle()`, `dwg_find_dicthandle_objname()` and `dwg_handle_name()` read a table record's name through a new `uncad_record_name_utf8()` helper, so an R2007+ DXF's UTF-16 record names are compared correctly. `src/common.c`: `cvt_TIMEBLL()` zeroes its `struct tm` and clamps every field into the ranges `strftime()` accepts, so a corrupt date cannot fail-fast the process. `src/in_dxf.c` and `src/dynapi.c`: a polygon mesh's vertex marker `AcDbPolygonMeshVertex` is known, so one polygon mesh no longer makes the DXF reader refuse the whole file. `src/common_entity_data.spec`: an R2004+ entity colour reads its RGB before its transparency, as LibreDWG's own `bit_read_ENC()` does, so an entity carrying both no longer has the two swapped. `docs/CAVEATS.md`, "Local patches to the vendored LibreDWG", has the full reasoning. Everything else is upstream as written; autotools' generated `config.h` is stood in for by this project's own `crates/libredwg-sys/vendor-config/config.h`, a separate file, not a patch to LibreDWG's own sources. +- **Vendored subset**: `crates/libredwg-sys` is published to crates.io as a standalone, self-contained crate, and crates.io only packages files inside a crate's own directory (no git submodules for downstream consumers). So `crates/libredwg-sys/vendor/libredwg/` holds a copy of just the 112 upstream files this crate's `build.rs` actually compiles/includes (24 `.c` files plus every header/`.spec`/`.inc`/codepage table they `#include`, traced from the real include graph — not a hand-picked subset), with the five patches above, rather than the full `lib/libredwg` submodule (which also has tests, docs, examples, and program sources this crate never builds). See `docs/ARCHITECTURE.md`'s "Build" section and `scripts/sync-libredwg-vendor.sh` for how it's kept in sync with the submodule. ## acadrust diff --git a/scripts/sync-libredwg-vendor.sh b/scripts/sync-libredwg-vendor.sh index aca51a5..db72cf4 100644 --- a/scripts/sync-libredwg-vendor.sh +++ b/scripts/sync-libredwg-vendor.sh @@ -20,6 +20,11 @@ # the whole vendor/libredwg directory with cargo:rerun-if-changed, so that # next build really does recompile from the refreshed copy (no `cargo # clean` needed). +# +# WARNING: the vendored copy carries local patches (grep it for +# "uncad local patch"; they are listed in docs/CAVEATS.md under "Local +# patches to the vendored LibreDWG"). This script deletes and recopies the +# whole directory, so re-apply or re-check them after every run. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -96,3 +101,4 @@ cp "$REPO_ROOT/lib/libredwg/COPYING" "$VENDOR_DIR/COPYING" echo "Synced ${#VISITED[@]} files into $VENDOR_DIR" echo "Next: cargo build -p libredwg-sys, and if LIBREDWG_SOURCES in build.rs needs updating, update it now." +echo "Also: re-apply the local patches listed in docs/CAVEATS.md, \"Local patches to the vendored LibreDWG\" (grep for 'uncad local patch')." From 53b77cd24368b555a8951986760c0505faf68495 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 13:46:16 +0900 Subject: [PATCH 04/54] Ship the GPLv3 text inside every crate `cargo package` never includes a file from outside the crate directory, and no crate here sets `license-file` or an `include` list, so the repository root's LICENSE reached none of the three tarballs. Measured on the published 0.2.0: libredwg-sys carried only LibreDWG's own vendor/libredwg/COPYING, uncad only the OFL text of the font it bundled then, and uncad-cli no licence file at all. Each crate now has its own copy, byte-identical to the root LICENSE. (`license-file.workspace` would avoid the copies, but cargo then warns "only one of `license` or `license-file` is necessary" for every member on every build.) Ported-from: 25cd9e7 Co-Authored-By: Claude Opus 5.5 --- crates/libredwg-sys/LICENSE | 674 ++++++++++++++++++++++++++++++++++++ crates/uncad-cli/LICENSE | 674 ++++++++++++++++++++++++++++++++++++ crates/uncad/LICENSE | 674 ++++++++++++++++++++++++++++++++++++ docs/THIRD_PARTY_NOTICES.md | 6 + 4 files changed, 2028 insertions(+) create mode 100644 crates/libredwg-sys/LICENSE create mode 100644 crates/uncad-cli/LICENSE create mode 100644 crates/uncad/LICENSE diff --git a/crates/libredwg-sys/LICENSE b/crates/libredwg-sys/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/crates/libredwg-sys/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/crates/uncad-cli/LICENSE b/crates/uncad-cli/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/crates/uncad-cli/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/crates/uncad/LICENSE b/crates/uncad/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/crates/uncad/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md index f5d4a97..a1bdd73 100644 --- a/docs/THIRD_PARTY_NOTICES.md +++ b/docs/THIRD_PARTY_NOTICES.md @@ -21,6 +21,12 @@ This project bundles or builds against the following third-party components. --- +Each published crate carries the full GPLv3 text as a `LICENSE` file in its own directory +(`crates/libredwg-sys/`, `crates/uncad/`, `crates/uncad-cli/`), byte-identical to the +repository root's. They are copies rather than one shared file because `cargo package` +never reaches outside a crate directory, and GPLv3 §4 asks for the licence to be conveyed +with the source that is conveyed — a crates.io tarball is exactly that. + This project is distributed under **GPLv3-or-later**, matching LibreDWG's own license (the only third-party component bundled). See [`LICENSE`](../LICENSE). DWF/DWFx support (previously provided by a vendored subset of From 6c6d0e63cecab6446b0057638b64a8db1f0921d8 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 13:52:31 +0900 Subject: [PATCH 05/54] Refuse to build when a vendored LibreDWG patch has gone missing The drift detector in build.rs counts .c files, and a re-vendor through scripts/sync-libredwg-vendor.sh replaces the five patched files with upstream's without changing that count: the build then compiles upstream's code, and polygon-mesh DXFs, R2007+ DXF names, the colour order and the strftime abort come back without a word. build.rs now also counts the `uncad local patch` markers in every file under vendor/libredwg and compares them, file by file, with the table it carries (LOCAL_PATCHES). A difference in either direction panics with both lists and says what to update. Checked by replacing dynapi.c with upstream's: the build stops, naming the file that lost its marker. Markers are counted as bytes, so a CRLF checkout counts the same. ARCHITECTURE's list of drift detectors, CAVEATS, NOTICE.md and the sync script's warning say so. Co-Authored-By: Claude Opus 5.5 --- crates/libredwg-sys/NOTICE.md | 3 +- crates/libredwg-sys/build.rs | 79 ++++++++++++++++++++++++++++++++- docs/ARCHITECTURE.md | 8 +++- docs/CAVEATS.md | 11 +++-- scripts/sync-libredwg-vendor.sh | 3 +- 5 files changed, 95 insertions(+), 9 deletions(-) diff --git a/crates/libredwg-sys/NOTICE.md b/crates/libredwg-sys/NOTICE.md index 9e09535..1a4453e 100644 --- a/crates/libredwg-sys/NOTICE.md +++ b/crates/libredwg-sys/NOTICE.md @@ -18,7 +18,8 @@ file; it is the same text as LibreDWG's own `vendor/libredwg/COPYING`. - **Modified**: **yes**, in five files. Each change carries an `uncad local patch` comment in the source saying what changed and why (GPLv3 §5(a)); `grep -rn "uncad local patch" vendor/libredwg/` finds every - marker. + marker, and `build.rs` refuses to build when the markers per file differ + from the list it carries. ### The five changes diff --git a/crates/libredwg-sys/build.rs b/crates/libredwg-sys/build.rs index 7752b19..5d58906 100644 --- a/crates/libredwg-sys/build.rs +++ b/crates/libredwg-sys/build.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; // Core LibreDWG sources actually needed for DWG/DXF *reading*, mirroring the // former WASM build's `emmake make -C src` scope (which itself excludes @@ -42,6 +42,24 @@ const LIBREDWG_SOURCES: &[&str] = &[ "reedsolomon.c", ]; +/// The comment every local change to the vendored LibreDWG carries. +const LOCAL_PATCH_MARKER: &[u8] = b"uncad local patch"; + +// The local patches the vendored copy carries, as (path under +// vendor/libredwg, times LOCAL_PATCH_MARKER occurs in that file). NOTICE.md +// lists the same five changes and docs/CAVEATS.md, "Local patches to the +// vendored LibreDWG", says why each exists. scripts/sync-libredwg-vendor.sh +// deletes and recopies the whole directory, so a re-vendor silently drops +// every one of them; main() compares the tree against this table so that it +// cannot. +const LOCAL_PATCHES: &[(&str, usize)] = &[ + ("src/common.c", 3), + ("src/common_entity_data.spec", 2), + ("src/dwg.c", 5), + ("src/dynapi.c", 1), + ("src/in_dxf.c", 2), +]; + fn main() { // vendor-config/config.h hardcodes SIZEOF_SIZE_T to 8 with no platform // branch (unlike its neighboring SIZEOF_WCHAR_T, which does branch on @@ -105,6 +123,28 @@ fn main() { ); } + // Patch detector: the file count above does not change when a re-vendor + // replaces a patched file with upstream's, so the markers are counted too. + // A file that lost (or gained) a marker fails the build by name. + let vendor_root = libredwg_src + .parent() + .expect("vendor/libredwg/src always has a parent directory"); + let found_patches = local_patch_markers(vendor_root); + let expected_patches: Vec<(String, usize)> = LOCAL_PATCHES + .iter() + .map(|&(path, count)| (path.to_string(), count)) + .collect(); + if found_patches != expected_patches { + panic!( + "vendor/libredwg carries these `uncad local patch` markers (file, count): \ + {found_patches:?}, but build.rs expects {expected_patches:?}. \ + scripts/sync-libredwg-vendor.sh deletes the local patches on every run: re-apply \ + them (docs/CAVEATS.md, \"Local patches to the vendored LibreDWG\"), or, if one \ + was dropped or added on purpose, update LOCAL_PATCHES in build.rs, NOTICE.md and \ + docs/CAVEATS.md together." + ); + } + // Dev-only cross-check: when the lib/libredwg submodule is also checked // out (local dev / CI clone with submodules, not a published-crate // build), warn if its .c file set has drifted from the vendored copy -- @@ -381,6 +421,43 @@ fn main() { ); } +/// Every file under `root` that contains [`LOCAL_PATCH_MARKER`], as its +/// `/`-separated path relative to `root` and the number of times the marker +/// occurs in it, sorted by path. +fn local_patch_markers(root: &Path) -> Vec<(String, usize)> { + fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, usize)>) { + let entries = + std::fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display())); + for entry in entries { + let path = entry + .unwrap_or_else(|e| panic!("cannot read an entry of {}: {e}", dir.display())) + .path(); + if path.is_dir() { + walk(&path, root, out); + continue; + } + let bytes = std::fs::read(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + let count = bytes + .windows(LOCAL_PATCH_MARKER.len()) + .filter(|window| *window == LOCAL_PATCH_MARKER) + .count(); + if count > 0 { + let relative = path + .strip_prefix(root) + .expect("the walk stays under its root") + .to_string_lossy() + .replace('\\', "/"); + out.push((relative, count)); + } + } + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out.sort(); + out +} + /// Fails early, and by name, when libclang is missing. /// /// bindgen needs libclang at build time. Without it the build dies inside diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4c6824a..f42d1a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,11 +75,15 @@ immediately as a compile error. `build.rs` registers the whole `vendor/libredwg/ directory with `cargo:rerun-if-changed`, so an incremental build really does recompile the C sources and regenerate the bindings after a re-vendor -- no `cargo clean` needed. -`build.rs` also carries two drift detectors: +`build.rs` also carries three drift detectors: 1. It compares the `.c` file count in `vendor/libredwg/src` against `LIBREDWG_SOURCES`. A mismatch means the vendored copy is damaged or out of step, and it `panic!`s. -2. When the `lib/libredwg` submodule is checked out (local development and CI only, never +2. It counts the `uncad local patch` markers in every file under `vendor/libredwg` and + compares them, file by file, against `LOCAL_PATCHES`. A re-vendor replaces a patched + file with upstream's without changing any file count, so this is what notices that the + local patches are gone; it `panic!`s, naming the files. +3. When the `lib/libredwg` submodule is checked out (local development and CI only, never for a published-crate consumer), it cross-checks that submodule's `.c` file count against the vendored copy and emits a `cargo:warning` if they have diverged, without failing the build. diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 5f28d01..5a8aab0 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -638,8 +638,10 @@ source with a dated `uncad local patch` comment saying why, and each is listed a `crates/libredwg-sys/NOTICE.md` -- inside the crate, because that is what a crates.io consumer receives and this file is not in the tarball (GPLv3 §5(a)). **`scripts/sync-libredwg-vendor.sh` deletes and recopies that directory, so re-applying -these patches is part of any submodule update.** None of them has been sent to LibreDWG -yet. +these patches is part of any submodule update.** `build.rs` counts the markers per file +against the list it carries (`LOCAL_PATCHES`) and refuses to build when they differ, so +a re-vendor that drops a patch fails by name instead of compiling upstream's code. None of +the patches has been sent to LibreDWG yet. - **`src/dwg.c`** -- `dwg_find_tablehandle()`, `dwg_find_dicthandle_objname()` and `dwg_handle_name()` read a table record's `name` with `IS_FROM_TU_DWG()`, which is false @@ -783,8 +785,9 @@ exceptions, plus one weak-copyleft test-only dependency granted by name. Develop dependencies are license-checked as well (`include-dev`): nothing distributed contains them, but they are still dependencies this repository chose. The gate reads `Cargo.lock` too, so the same submodule caveat applies -- what -watches the vendored C side is the source-file drift detector in -`crates/libredwg-sys/build.rs`. The +watches the vendored C side is the pair of drift detectors in +`crates/libredwg-sys/build.rs`: the source-file count, and the count of local-patch +markers per file. The bindgen-generated `bindings.rs` in `libredwg-sys` is regenerated on every build rather than written by hand, and the handful of harmless lints it raises (`useless_transmute`, `missing_safety_doc`, `ptr_offset_with_cast`, `unsafe_op_in_unsafe_fn`, `manual_div_ceil`) are allowed at diff --git a/scripts/sync-libredwg-vendor.sh b/scripts/sync-libredwg-vendor.sh index db72cf4..7324f04 100644 --- a/scripts/sync-libredwg-vendor.sh +++ b/scripts/sync-libredwg-vendor.sh @@ -24,7 +24,8 @@ # WARNING: the vendored copy carries local patches (grep it for # "uncad local patch"; they are listed in docs/CAVEATS.md under "Local # patches to the vendored LibreDWG"). This script deletes and recopies the -# whole directory, so re-apply or re-check them after every run. +# whole directory, so re-apply or re-check them after every run: build.rs +# counts the markers per file and fails the build until they are back. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" From d8a0f3080097c032e65caf80cab18c491330e2d5 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 13:57:17 +0900 Subject: [PATCH 06/54] Pin the licence files and the vendored-patch notice a release has to carry crates/uncad-cli/tests/release_invariants.rs checks, against the tree: - every published crate has its own LICENSE, byte-identical to the root one (cargo package never reaches outside the crate directory); - LibreDWG's own COPYING stays beside the vendored sources; - while any vendored file carries an `uncad local patch` marker (found by walking the tree), nothing calls the vendored LibreDWG unmodified: not the crates.io description, the rustdoc or config.h's header (which must say "patch"), and none of the phrases README, ARCHITECTURE and THIRD_PARTY_NOTICES used before this port (each of which must say "local patch"); and NOTICE.md and CAVEATS name every patched file. The feature branch's version also checked `uncad --version`, the test counts the documents state and the CI triggers; those claims do not hold on this tree yet and come with the ports that make them true. Ported-from: 192d7fd Co-Authored-By: Claude Opus 5.5 --- crates/uncad-cli/tests/release_invariants.rs | 206 +++++++++++++++++++ docs/ARCHITECTURE.md | 3 +- 2 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 crates/uncad-cli/tests/release_invariants.rs diff --git a/crates/uncad-cli/tests/release_invariants.rs b/crates/uncad-cli/tests/release_invariants.rs new file mode 100644 index 0000000..0a23205 --- /dev/null +++ b/crates/uncad-cli/tests/release_invariants.rs @@ -0,0 +1,206 @@ +//! What has to be true of the repository before a `cargo publish`. +//! +//! **Licence files reach the tarball.** `cargo package` never includes a file +//! from outside the crate directory, so the repository root's `LICENSE` is +//! not enough: every published crate needs its own copy (GPLv3 §4), and the +//! modified vendored LibreDWG needs its modification notice inside the crate +//! too (GPLv3 §5(a)). Both were missing from 0.2.0 -- `uncad-cli-0.2.0.crate` +//! shipped no licence text at all -- and were verified only by someone +//! remembering. +//! +//! Like `documented_invocations.rs`, this reads the repository it is checked +//! into: run it from the workspace, not from an unpacked crate tarball. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// The three published crates, in workspace order. +const MEMBERS: [&str; 3] = ["libredwg-sys", "uncad", "uncad-cli"]; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("the workspace root is two levels above this crate") +} + +fn read(path: impl AsRef) -> String { + let path = path.as_ref(); + fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) +} + +/// Every crate a `cargo publish` uploads carries the GPLv3 text itself. +/// +/// `cargo package --list` is the real check, but it needs the crates.io index +/// and a clean VCS state, so it cannot run here. The property it would prove +/// is this one: the file is inside the crate directory, which is exactly what +/// decides whether the tarball has it. +#[test] +fn every_published_crate_ships_the_gpl_text() { + let root = repo_root(); + let canonical = read(root.join("LICENSE")); + assert!( + canonical.contains("GNU GENERAL PUBLIC LICENSE") + && canonical.contains("Version 3, 29 June 2007"), + "the repository's LICENSE should be the GPLv3 text" + ); + + for member in MEMBERS { + let path = root.join("crates").join(member).join("LICENSE"); + assert!( + path.exists(), + "crates/{member}/LICENSE is missing: `cargo package` only includes files under \ + the crate directory, so this crate would be published with no licence text" + ); + assert_eq!( + read(&path), + canonical, + "crates/{member}/LICENSE has drifted from the repository's LICENSE" + ); + } +} + +/// The bundled third-party source keeps its own licence beside it: LibreDWG's +/// `COPYING` in `libredwg-sys`. +#[test] +fn the_vendored_libredwg_ships_its_own_licence() { + let copying = read(repo_root().join("crates/libredwg-sys/vendor/libredwg/COPYING")); + assert!( + copying.contains("GNU GENERAL PUBLIC LICENSE"), + "LibreDWG's own COPYING should stay beside the vendored sources" + ); +} + +/// Every vendored LibreDWG file carrying an `uncad local patch` marker, as +/// its `/`-separated path under `vendor/libredwg`, found by walking the tree +/// rather than trusting a list. +fn patched_vendor_files(root: &Path) -> Vec { + fn walk(dir: &Path, vendor: &Path, out: &mut Vec) { + for entry in fs::read_dir(dir).expect("the vendored tree is readable") { + let path = entry.expect("a readable directory entry").path(); + if path.is_dir() { + walk(&path, vendor, out); + } else if fs::read(&path) + .expect("a readable vendored file") + .windows(b"uncad local patch".len()) + .any(|w| w == b"uncad local patch") + { + out.push( + path.strip_prefix(vendor) + .expect("inside the vendored tree") + .to_string_lossy() + .replace('\\', "/"), + ); + } + } + } + let vendor = root.join("crates/libredwg-sys/vendor/libredwg"); + let mut out = Vec::new(); + walk(&vendor, &vendor, &mut out); + out.sort(); + out +} + +/// The comment a source file opens with -- its `//!` lines, or its first +/// `/* ... */` block: what a reader of that file is told it is. +fn leading_comment(text: &str) -> String { + if text.starts_with("//!") { + return text + .lines() + .take_while(|line| line.starts_with("//!")) + .collect::>() + .join("\n"); + } + assert!( + text.starts_with("/*"), + "the file should open with a comment" + ); + let end = text.find("*/").expect("the opening comment is closed"); + text[..end].to_string() +} + +/// Nothing may call the vendored LibreDWG unmodified while it carries local +/// patches -- not the crate description crates.io shows, not the crate's own +/// documentation, and not the project's documents. GPLv3 §5(a) asks for the +/// opposite, and every one of these used to say it. +#[test] +fn nothing_describes_the_patched_libredwg_as_unmodified() { + let root = repo_root(); + let patched = patched_vendor_files(&root); + assert!( + !patched.is_empty(), + "expected the vendored LibreDWG to carry `uncad local patch` markers; if the \ + patches were dropped, the claims this test guards have to be revisited too" + ); + + // What the crate says about itself: the description crates.io shows, the + // top of the rustdoc, and the config.h header that sits beside the tree. + let manifest = read(root.join("crates/libredwg-sys/Cargo.toml")); + let description = manifest + .lines() + .find(|line| line.starts_with("description =")) + .expect("libredwg-sys declares a description"); + let rustdoc = leading_comment(&read(root.join("crates/libredwg-sys/src/lib.rs"))); + let config_h = leading_comment(&read( + root.join("crates/libredwg-sys/vendor-config/config.h"), + )); + for (what, text) in [ + ("the crates.io description", description.to_string()), + ("src/lib.rs's crate documentation", rustdoc), + ("vendor-config/config.h's header", config_h), + ] { + assert!( + !text.contains("unmodified"), + "{what} calls the vendored LibreDWG unmodified, but {patched:?} carry local \ + patches:\n{text}" + ); + assert!( + text.contains("patch"), + "{what} should say the vendored LibreDWG carries local patches:\n{text}" + ); + } + + // The project's documents: each once described the vendored copy with one + // of these phrases. + for (doc, wrong) in [ + ("README.md", "holds the unmodified"), + ("docs/ARCHITECTURE.md", "unmodified (see"), + ("docs/THIRD_PARTY_NOTICES.md", "unmodified subset"), + ( + "docs/THIRD_PARTY_NOTICES.md", + "an unmodified, byte-for-byte copy", + ), + ( + "docs/THIRD_PARTY_NOTICES.md", + "**Modified**: no — built directly", + ), + ] { + let text = read(root.join(doc)); + assert!( + !text.contains(wrong), + "{doc} says \"{wrong}\" of the vendored LibreDWG while {patched:?} carry local \ + patches" + ); + assert!( + text.contains("local patch"), + "{doc} should say the vendored LibreDWG carries local patches" + ); + } + + // The markers point readers at NOTICE.md, which is inside the tarball + // (docs/ is not), and at docs/CAVEATS.md for the reasoning: both have to + // name every patched file. + let notice = read(root.join("crates/libredwg-sys/NOTICE.md")); + let caveats = read(root.join("docs/CAVEATS.md")); + for file in &patched { + assert!( + notice.contains(&format!("vendor/libredwg/{file}")), + "crates/libredwg-sys/NOTICE.md should name the patched vendor/libredwg/{file}" + ); + assert!( + caveats.contains(&format!("`{file}`")), + "docs/CAVEATS.md's \"Local patches to the vendored LibreDWG\" should name the \ + patched {file}" + ); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f42d1a1..2121c3c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,7 +37,8 @@ crates/ examples/ dump.rs / blocks.rs -- manual checks uncad-cli/ the CLI binary (uncad) tests/ documented_invocations.rs -- every call README and --help - advertise + advertise; release_invariants.rs -- the licence text and the + vendored-patch notice every published crate has to carry ``` ## Build: the `cc` crate instead of autotools, a vendored copy instead of the submodule From 3142ba3461efbc0c16814cd73fb96a77898a5727 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 13:59:27 +0900 Subject: [PATCH 07/54] Record the native-layer changes in the changelog The shim's new entry points, the per-crate licence files and NOTICE.md, the libclang message, and the five vendored patches with the build check that keeps them. Measured for the last entry: the JSON output of all 208 corpus drawings (172 parsed, the same 36 refused or failed) is byte-identical between this branch and da93fa7, so the patches change no value this crate reports today; what they change is shown by tests/vendored_patches.rs. Ported-from: 25cd9e7, 598fc09 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7a4c7..5a53e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ Notable changes to this project are recorded here. The format follows ### Added +- `libredwg-sys` reads a drawing from memory (`uncad_dwg_read_bytes`, + `uncad_dxf_read_bytes`) -- LibreDWG's own file readers `fopen()` a byte string the + MSVC runtime reads in the ANSI code page, so a non-ASCII path fails on Windows -- and + exposes what decoding a drawing's text needs: `uncad_dwg_version`, + `uncad_dwg_from_version`, `uncad_dwg_from_dxf`, `uncad_codepage_name`, and the + code-page string conversions `uncad_tv_to_utf8`, `uncad_bytes_to_utf8`, their + `uncad_entity_*` twins, `uncad_dwg_string_to_utf8` and `uncad_free_string`, with + bindings for `dwg_version_type` and `dwg_next_object`. `uncad` does not call them yet. +- Every crate carries the GPLv3 text as its own `LICENSE`; `cargo package` never + reaches the repository root's, so of the 0.2.0 tarballs only `libredwg-sys` had the + text (as LibreDWG's own `COPYING`) and `uncad-cli` had no licence file at all. + `libredwg-sys` also carries `NOTICE.md`, the modification notice for its vendored + LibreDWG. `uncad-cli`'s `tests/release_invariants.rs` keeps both true. +- `libredwg-sys`'s build names libclang, and the command that installs it, when bindgen + cannot find it, instead of bindgen's own message. - A SPLINE carries what defines its curve: `degree`, `knots`, `weights` (empty when the file gives none -- every weight is 1), and the `closed` / `periodic` bits as `Option`. A spline stored by its fit points has no periodic bit, and no @@ -63,6 +78,21 @@ Notable changes to this project are recorded here. The format follows ### Fixed +- **The vendored LibreDWG carries five local patches**, each marked `uncad local patch` + in the source and listed in `crates/libredwg-sys/NOTICE.md` and `docs/CAVEATS.md`, + "Local patches to the vendored LibreDWG". A DXF holding a polygon mesh is read instead + of refused as a whole (critical error 2048: the importer did not know the mesh + vertices' `AcDbPolygonMeshVertex` marker). A corrupt header date no longer ends the + process from inside the C library (0xC0000409 on Windows, from `strftime`). Two more + are not visible in this crate's output yet: an R2004+ entity carrying both a true + colour and a transparency no longer has the two swapped in the library's fields (this + crate reads a true colour only under the TRUECOLOR method, which the entities measured + do not have), and the importer compares an R2007+ DXF's table-record names decoded, so + its layer and block lookups no longer stop at the first character (such a DXF is still + refused). On the 208 corpus drawings the JSON output is byte-identical with and without + the patches. `build.rs` refuses to build when a patch's marker has gone missing, + which a re-vendor through `scripts/sync-libredwg-vendor.sh` would otherwise do in + silence. - `MTextEntity::rotation` is the direction of the text's X axis instead of a constant `0`, which reported rotated multi-line text as horizontal. - A two-line angular dimension's `definition_point` (DXF 10) is read instead of reported as From 61383518ee5eaa4464ffa110026b813db35acc37 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 14:01:14 +0900 Subject: [PATCH 08/54] Say what the submodule has of the vendored patches, not whether they were sent upstream CAVEATS said none of the five patches had been sent to LibreDWG, which nothing here can check. What can be checked replaces it: the lib/libredwg submodule (a8ce248) and the vendored copy, compared file by file with line endings aside, differ in exactly the five patched files. Co-Authored-By: Claude Opus 5.5 --- docs/CAVEATS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 5a8aab0..46b65c8 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -640,8 +640,9 @@ consumer receives and this file is not in the tarball (GPLv3 §5(a)). **`scripts/sync-libredwg-vendor.sh` deletes and recopies that directory, so re-applying these patches is part of any submodule update.** `build.rs` counts the markers per file against the list it carries (`LOCAL_PATCHES`) and refuses to build when they differ, so -a re-vendor that drops a patch fails by name instead of compiling upstream's code. None of -the patches has been sent to LibreDWG yet. +a re-vendor that drops a patch fails by name instead of compiling upstream's code. The +`lib/libredwg` submodule the copy is taken from has none of them: compared file by file +(line endings aside), the two trees differ in exactly these five files. - **`src/dwg.c`** -- `dwg_find_tablehandle()`, `dwg_find_dicthandle_objname()` and `dwg_handle_name()` read a table record's `name` with `IS_FROM_TU_DWG()`, which is false From 95be3d4daf0ce2b9361c705f593b8c684cf05552 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 14:28:51 +0900 Subject: [PATCH 09/54] Build against the model's new fields and the renderer's PNG size uncad-model gained extrusion, justification, polyline, viewport, layer state, dimension-style and layout fields and a POLYLINE_MESH variant; iron-render-cad replaced ToPngOptions::scale with a PngSize. Give every literal the value the model documents for a file that states nothing (the z axis, None, an empty list, an absent reference) and count the mesh with the polylines in the second-reader comparison. The parser does not read the new fields yet; the commits that port each feature do. Co-Authored-By: Claude Opus 5.5 --- crates/uncad-cli/src/main.rs | 5 +- crates/uncad/src/convert.rs | 93 +++++++++++++++++++++++++++++ crates/uncad/src/lib.rs | 13 ++++ crates/uncad/src/table_convert.rs | 19 +++++- crates/uncad/tests/second_reader.rs | 6 +- 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/crates/uncad-cli/src/main.rs b/crates/uncad-cli/src/main.rs index fe4c6fb..0c958c3 100644 --- a/crates/uncad-cli/src/main.rs +++ b/crates/uncad-cli/src/main.rs @@ -4,7 +4,7 @@ //! the parsed model as JSON or a rendering of it as SVG/PNG. There is no //! DWG/DXF output. -use iron_render_cad::{to_png, to_svg, Space, ToPngOptions, ToSvgOptions}; +use iron_render_cad::{to_png, to_svg, PngSize, Space, ToPngOptions, ToSvgOptions}; use std::collections::BTreeMap; use std::path::Path; use std::process::ExitCode; @@ -157,7 +157,8 @@ fn run(args: &Args) -> Result<(), String> { &db, ToPngOptions { svg: svg_options(args)?, - scale: parse_scale(&args.scale)?, + size: PngSize::Scale(parse_scale(&args.scale)?), + ..ToPngOptions::default() }, ) .map_err(|e| e.to_string())?; diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index e5155dc..fd7b741 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -540,6 +540,11 @@ unsafe fn convert_entity( common, center, radius, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_TEXT => { @@ -555,6 +560,18 @@ unsafe fn convert_entity( text_height, text, rotation, + horizontal_justification: Default::default(), + vertical_justification: Default::default(), + alignment_point: None, + width_factor: 1.0, + oblique_angle: 0.0, + style_name: uncad_model::Ref::Absent, + elevation: 0.0, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_LWPOLYLINE => { @@ -565,6 +582,15 @@ unsafe fn convert_entity( common, vertices, closed: flag & LWPOLYLINE_CLOSED_FLAG != 0, + bulges: Vec::new(), + widths: Vec::new(), + const_width: 0.0, + elevation: 0.0, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ARC => { @@ -578,6 +604,11 @@ unsafe fn convert_entity( radius, start_angle, end_angle, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ELLIPSE => { @@ -623,6 +654,12 @@ unsafe fn convert_entity( corner2, corner3, corner4, + elevation: 0.0, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_TRACE => { @@ -638,6 +675,12 @@ unsafe fn convert_entity( corner2, corner3, corner4, + elevation: 0.0, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_RAY => { @@ -677,6 +720,19 @@ unsafe fn convert_entity( tag, text, rotation, + flags: Default::default(), + horizontal_justification: Default::default(), + vertical_justification: Default::default(), + alignment_point: None, + width_factor: 1.0, + oblique_angle: 0.0, + style_name: uncad_model::Ref::Absent, + elevation: 0.0, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_INSERT => { @@ -728,6 +784,11 @@ unsafe fn convert_entity( scale, rotation, attribs, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ATTDEF => { @@ -745,6 +806,19 @@ unsafe fn convert_entity( tag, default_value, rotation, + flags: Default::default(), + horizontal_justification: Default::default(), + vertical_justification: Default::default(), + alignment_point: None, + width_factor: 1.0, + oblique_angle: 0.0, + style_name: uncad_model::Ref::Absent, + elevation: 0.0, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VIEWPORT => { @@ -756,6 +830,10 @@ unsafe fn convert_entity( center, width, height, + view: None, + on: None, + viewport_id: None, + frozen_layers: Vec::new(), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE__3DFACE => { @@ -867,6 +945,10 @@ unsafe fn convert_entity( rotation, line_spacing_factor, attachment, + rect_width: 0.0, + extents_width: None, + extents_height: None, + style_name: uncad_model::Ref::Absent, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_POLYLINE_3D => { @@ -904,6 +986,15 @@ unsafe fn convert_entity( common, vertices, closed: flag & POLYLINE_CLOSED_FLAG != 0, + bulges: Vec::new(), + widths: Vec::new(), + const_width: 0.0, + elevation: 0.0, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_DIMENSION_ORDINATE @@ -992,6 +1083,7 @@ unsafe fn convert_entity( c"DIMSTYLE", |handle_ptr| text.handle_name(dwg, handle_ptr), ), + ordinate_axis: None, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_TABLE => { @@ -1477,6 +1569,7 @@ fn convert_mline( vertices, closed: flags & MLINE_CLOSED_FLAG != 0, mlinestyle_name, + scale: None, } } diff --git a/crates/uncad/src/lib.rs b/crates/uncad/src/lib.rs index 94a267e..d98a682 100644 --- a/crates/uncad/src/lib.rs +++ b/crates/uncad/src/lib.rs @@ -312,6 +312,19 @@ mod missing_required_groups_tests { tag: String::new(), text: "X".to_string(), rotation: 0.0, + flags: Default::default(), + horizontal_justification: Default::default(), + vertical_justification: Default::default(), + alignment_point: None, + width_factor: 1.0, + oblique_angle: 0.0, + style_name: uncad_model::Ref::Absent, + elevation: 0.0, + extrusion: uncad_model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, }); assert_eq!( missing_required_groups(&[untagged]), diff --git a/crates/uncad/src/table_convert.rs b/crates/uncad/src/table_convert.rs index b060ba1..9ff1e50 100644 --- a/crates/uncad/src/table_convert.rs +++ b/crates/uncad/src/table_convert.rs @@ -74,6 +74,7 @@ pub(crate) unsafe fn convert_tables( dim_styles, block_records, mlinestyles, + layouts: Default::default(), } } @@ -100,6 +101,13 @@ fn convert_dim_style(text: &TextDecoder, object_ptr: *mut c_void) -> Option(object_ptr, "DIMSTYLE", "DIMTDEC") .map(i32::from), text_height: number("DIMTXT"), + arrow_size: None, + linear_unit_format: None, + zero_suppression: None, + rounding: None, + angular_unit_format: None, + angular_decimal_places: None, + fraction_format: None, }) } @@ -189,7 +197,16 @@ fn convert_layer(text: &TextDecoder, object_ptr: *mut c_void) -> Option(object_ptr, "LAYER", "color")?; let color_index = resolve_layer_color_index(color.index, color.method, color.rgb); - Some(LayerRecord { name, color_index }) + Some(LayerRecord { + name, + color_index, + off: false, + frozen: false, + locked: false, + plot: None, + lineweight: None, + linetype: uncad_model::Ref::Absent, + }) } /// Recovers a usable ACI index from a LAYER's raw `Dwg_Color` when LibreDWG diff --git a/crates/uncad/tests/second_reader.rs b/crates/uncad/tests/second_reader.rs index 215e315..9198459 100644 --- a/crates/uncad/tests/second_reader.rs +++ b/crates/uncad/tests/second_reader.rs @@ -333,7 +333,11 @@ fn ours_kind(entity: &uncad::Entity) -> &'static str { E::Wipeout(_) => "wipeout", E::Light(_) => "light", E::Attrib(_) | E::Attdef(_) => "attrib", - E::LwPolyline(_) | E::Polyline2D(_) | E::Polyline3D(_) | E::PolylinePFace(_) => "polyline", + E::LwPolyline(_) + | E::Polyline2D(_) + | E::Polyline3D(_) + | E::PolylinePFace(_) + | E::PolylineMesh(_) => "polyline", E::Unknown { .. } => "unknown", } } From 1e10f2285c032b09b6d8a9b5868d5787d7776ede Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 14:46:59 +0900 Subject: [PATCH 10/54] Bring the hand-written DXF fixtures, and read every one of them Eighteen R2000 DXFs written from group codes by make_fixtures.py (which reproduces them byte for byte): code-page strings, mirrored object coordinate systems and bulges, dimensions of every kind, viewports and layouts with plot settings, layer states, polyline vertices, meshes, colours, a nested attribute and a sheet whose every string is on paper. Their README states what each file says and what LibreDWG holds after reading it; the renderer and export assertions the feature branch made on them are left to those crates. tests/fixtures.rs reads each one with clean diagnostics and through a JSON round trip, and pins what the model already carries: the CP949 strings, the bound dimension block, the paper-space viewport, the construction lines, block children kept on layer 0, the strings on the sheet. tests/polyline_closed.rs checks every LWPOLYLINE's closed bit in example_2000.dwg and .dxf against the DXF's own group 70. The DXFs are kept out of line-ending normalisation. Ported-from: 2b2c750, be3ff34, a7f9ea5, f80bdfa, 2415979, 47d0b00, 7d4ce8b, 7ff5c70, 72cedb9, bd10f50, b4918ab Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 + crates/uncad/tests/fixtures.rs | 313 +++++ crates/uncad/tests/fixtures/README.md | 573 +++++++++ .../tests/fixtures/angular_ordinate_r2000.dxf | 244 ++++ .../tests/fixtures/block_layer0_r2000.dxf | 232 ++++ crates/uncad/tests/fixtures/cp949_r2000.dxf | 148 +++ .../uncad/tests/fixtures/dimlfac12_r2000.dxf | 222 ++++ .../tests/fixtures/entity_truecolor_r2000.dxf | 144 +++ .../tests/fixtures/hatched_viewport_r2000.dxf | 568 ++++++++ .../tests/fixtures/hidden_layers_r2000.dxf | 326 +++++ .../tests/fixtures/infinite_lines_r2000.dxf | 120 ++ crates/uncad/tests/fixtures/make_fixtures.py | 1139 +++++++++++++++++ .../tests/fixtures/mirrored_bulge_r2000.dxf | 80 ++ .../tests/fixtures/mirrored_ocs_r2000.dxf | 168 +++ .../tests/fixtures/nested_attrib_r2000.dxf | 378 ++++++ .../tests/fixtures/plot_origin_r2000.dxf | 424 ++++++ .../tests/fixtures/polyface_mesh_r2000.dxf | 634 +++++++++ .../fixtures/polyline_vertices_r2000.dxf | 384 ++++++ crates/uncad/tests/fixtures/radial_r2000.dxf | 238 ++++ .../tests/fixtures/title_block_r2000.dxf | 510 ++++++++ .../tests/fixtures/twisted_viewport_r2000.dxf | 390 ++++++ .../tests/fixtures/viewport_states_r2000.dxf | 780 +++++++++++ crates/uncad/tests/polyline_closed.rs | 98 ++ 23 files changed, 8116 insertions(+) create mode 100644 crates/uncad/tests/fixtures.rs create mode 100644 crates/uncad/tests/fixtures/README.md create mode 100644 crates/uncad/tests/fixtures/angular_ordinate_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/block_layer0_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/cp949_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/dimlfac12_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/entity_truecolor_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/hatched_viewport_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/hidden_layers_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/infinite_lines_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/make_fixtures.py create mode 100644 crates/uncad/tests/fixtures/mirrored_bulge_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/mirrored_ocs_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/nested_attrib_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/plot_origin_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/polyface_mesh_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/polyline_vertices_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/radial_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/title_block_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/twisted_viewport_r2000.dxf create mode 100644 crates/uncad/tests/fixtures/viewport_states_r2000.dxf create mode 100644 crates/uncad/tests/polyline_closed.rs diff --git a/.gitattributes b/.gitattributes index f7e2def..2507352 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ * text=auto eol=lf *.sh text eol=lf +# Hand-written DXF fixtures: keep their bytes (CRLF, and a code page +# that is not UTF-8) exactly as the generator wrote them +crates/uncad/tests/fixtures/*.dxf -text diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs new file mode 100644 index 0000000..ba6fc27 --- /dev/null +++ b/crates/uncad/tests/fixtures.rs @@ -0,0 +1,313 @@ +//! The hand-authored DXF fixtures in `tests/fixtures/`, read through the +//! public API. +//! +//! Unlike the corpus-based tests, these files were written by this project +//! from group codes (`tests/fixtures/make_fixtures.py`; what each one holds, +//! and what LibreDWG itself reads back from it, is in +//! `tests/fixtures/README.md`), so the values below are the ones the files +//! state -- worked out from the definitions, not copied out of this crate's +//! own output. +//! +//! Only what the *model* carries is asserted here. How a renderer draws a +//! fixture, or what an export writes for it, is a question for those crates. + +use std::collections::BTreeMap; + +use uncad::model::Ref; +use uncad::{CadDatabase, Entity}; + +macro_rules! fixture { + ($name:literal) => { + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/", $name) + }; +} + +const CP949: &str = fixture!("cp949_r2000.dxf"); +const MIRRORED: &str = fixture!("mirrored_ocs_r2000.dxf"); +const DIMLFAC12: &str = fixture!("dimlfac12_r2000.dxf"); +const TWISTED_VIEWPORT: &str = fixture!("twisted_viewport_r2000.dxf"); +const MIRRORED_BULGE: &str = fixture!("mirrored_bulge_r2000.dxf"); +const HATCHED_VIEWPORT: &str = fixture!("hatched_viewport_r2000.dxf"); +const NESTED_ATTRIB: &str = fixture!("nested_attrib_r2000.dxf"); +const HIDDEN_LAYERS: &str = fixture!("hidden_layers_r2000.dxf"); +const PLOT_ORIGIN: &str = fixture!("plot_origin_r2000.dxf"); +const ANGULAR_ORDINATE: &str = fixture!("angular_ordinate_r2000.dxf"); +const VIEWPORT_STATES: &str = fixture!("viewport_states_r2000.dxf"); +const RADIAL: &str = fixture!("radial_r2000.dxf"); +const INFINITE_LINES: &str = fixture!("infinite_lines_r2000.dxf"); +const POLYLINE_VERTICES: &str = fixture!("polyline_vertices_r2000.dxf"); +const ENTITY_TRUECOLOR: &str = fixture!("entity_truecolor_r2000.dxf"); +const POLYFACE_MESH: &str = fixture!("polyface_mesh_r2000.dxf"); +const BLOCK_LAYER0: &str = fixture!("block_layer0_r2000.dxf"); +const TITLE_BLOCK: &str = fixture!("title_block_r2000.dxf"); + +/// Every shipped fixture, so the checks that must hold for all of them +/// (parsing, the JSON round trip) cover each new file from the day it +/// lands. +const ALL: [&str; 18] = [ + CP949, + MIRRORED, + DIMLFAC12, + TWISTED_VIEWPORT, + MIRRORED_BULGE, + HATCHED_VIEWPORT, + NESTED_ATTRIB, + HIDDEN_LAYERS, + PLOT_ORIGIN, + ANGULAR_ORDINATE, + VIEWPORT_STATES, + RADIAL, + INFINITE_LINES, + POLYLINE_VERTICES, + ENTITY_TRUECOLOR, + POLYFACE_MESH, + BLOCK_LAYER0, + TITLE_BLOCK, +]; + +fn parse(path: &str) -> CadDatabase { + uncad::parse(path).unwrap_or_else(|e| panic!("{path} should parse: {e}")) +} + +fn type_counts(db: &CadDatabase) -> BTreeMap { + let mut counts = BTreeMap::new(); + for entity in &db.entities { + *counts.entry(entity.type_name().to_string()).or_insert(0) += 1; + } + counts +} + +fn expected(pairs: &[(&str, usize)]) -> BTreeMap { + pairs + .iter() + .map(|(name, n)| (name.to_string(), *n)) + .collect() +} + +fn resolved(name: &str) -> Ref { + Ref::Resolved(name.to_string()) +} + +/// The handle the file gave an entity, as the model records it. +fn handle(entity: &Entity) -> &str { + match &entity.common().source_handle { + Ref::Resolved(h) => h, + other => panic!("every fixture entity has a handle: {other:?}"), + } +} + +#[test] +fn every_fixture_parses_and_survives_a_json_round_trip() { + for path in ALL { + let db = parse(path); + assert!(!db.entities.is_empty(), "{path} should have entities"); + assert!( + db.read_diagnostics.is_clean(), + "{path}: {:?}", + db.read_diagnostics.warnings + ); + let json = db + .to_json(uncad::ToJsonOptions::default()) + .expect("serializing the model should succeed"); + let back: CadDatabase = + serde_json::from_str(&json).expect("what this crate wrote, it should read back"); + assert_eq!(back, db, "{path} changed across a JSON round trip"); + } +} + +// ---------------------------------------------------------------- cp949 + +#[test] +fn cp949_fixture_has_four_texts_one_mtext_and_two_layers() { + let db = parse(CP949); + assert_eq!(type_counts(&db), expected(&[("TEXT", 4), ("MTEXT", 1)])); + assert_eq!( + db.tables.layers.keys().collect::>(), + ["0", "\u{BCBD}\u{CCB4}"], + "layer 0 plus the Korean-named layer" + ); + assert_eq!(db.tables.layers["0"].color_index, 7); + assert_eq!(db.tables.layers["\u{BCBD}\u{CCB4}"].color_index, 1); +} + +#[test] +fn cp949_strings_decode_through_the_file_code_page() { + let db = parse(CP949); + let texts: Vec<(&str, &str)> = db + .entities + .iter() + .filter_map(|e| match e { + Entity::Text(t) => Some((handle(e), t.text.as_str())), + _ => None, + }) + .collect(); + // The bytes are B5B5 B8E9 / A1BE 33 / 3332 2E35 A7B3 (README): "±" is a + // two-byte KS X 1001 character, the case a single-byte reading gets + // wrong. + assert_eq!( + texts, + [ + ("23", "\u{B3C4}\u{BA74}"), + ("24", "\u{B1}3"), + ("25", "32.5\u{33A1}"), + ("27", "PLAIN"), + ] + ); + // The MTEXT keeps its format code `\P` verbatim. + let mtext = db + .entities + .iter() + .find_map(|e| match e { + Entity::MText(m) => Some(m.text.as_str()), + _ => None, + }) + .expect("MTEXT"); + assert_eq!(mtext, "\u{BC29} 101\\P\u{BA74}\u{C801} 32.5\u{33A1}"); + + // The TEXT on the Korean layer names it the way the table does. + assert_eq!( + db.entities[0].common().layer, + resolved("\u{BCBD}\u{CCB4}"), + "{:?}", + db.entities[0] + ); +} + +// ------------------------------------------------------------ dimlfac12 + +#[test] +fn dimlfac12_fixture_has_a_line_and_a_dimension_bound_to_its_cached_block() { + let db = parse(DIMLFAC12); + assert_eq!(type_counts(&db), expected(&[("DIMENSION", 1), ("LINE", 1)])); + + let dim = db + .entities + .iter() + .find_map(|e| match e { + Entity::Dimension(d) => Some(d), + _ => None, + }) + .expect("DIMENSION"); + assert_eq!(dim.common.source_handle, resolved("45")); + // `2 *D1`, bound through the BLOCK_RECORD table, and `42 = 10.0`. + assert_eq!(dim.block_name, resolved("*D1")); + assert_eq!(dim.measurement, Some(10.0)); + assert_eq!(dim.style_name, resolved("STANDARD")); + assert_eq!(db.tables.block_records["*Model_Space"].entities.len(), 2); + // The cached label is the block's one TEXT: 12 x 10 under DIMLFAC 12. + let cached = &db.tables.block_records["*D1"].entities; + assert_eq!(cached.len(), 1, "{cached:?}"); + match &cached[0] { + Entity::Text(t) => assert_eq!(t.text, "120"), + other => panic!("expected the cached dimension label, got {other:?}"), + } +} + +// ------------------------------------------------------- twisted viewport + +#[test] +fn twisted_viewport_fixture_has_a_model_line_and_a_paper_viewport() { + let db = parse(TWISTED_VIEWPORT); + assert_eq!(type_counts(&db), expected(&[("LINE", 1), ("VIEWPORT", 1)])); + let vp = db + .entities + .iter() + .find_map(|e| match e { + Entity::Viewport(v) => Some(v), + _ => None, + }) + .expect("VIEWPORT"); + assert_eq!(vp.common.source_handle, resolved("2A")); + assert_eq!((vp.center.x, vp.center.y, vp.center.z), (150.0, 100.0, 0.0)); + assert_eq!((vp.width, vp.height), (200.0, 120.0)); + // `67 = 1` and `330 = 1C`: the VIEWPORT is paper space's, the LINE the + // model's. + assert_eq!(db.tables.block_records["*Model_Space"].entities.len(), 1); + let paper = &db.tables.block_records["*Paper_Space"].entities; + assert_eq!(paper.len(), 1, "{paper:?}"); + assert!(matches!(paper[0], Entity::Viewport(_)), "{paper:?}"); +} + +// ------------------------------------------------------- infinite lines + +#[test] +fn infinite_lines_fixture_has_a_line_a_text_an_xline_and_a_ray() { + let db = parse(INFINITE_LINES); + assert_eq!( + type_counts(&db), + expected(&[("LINE", 1), ("RAY", 1), ("TEXT", 1), ("XLINE", 1)]) + ); + let (mut xline, mut ray) = (None, None); + for e in &db.entities { + match e { + Entity::XLine(x) => xline = Some(x), + Entity::Ray(r) => ray = Some(r), + _ => {} + } + } + let (xline, ray) = (xline.expect("XLINE"), ray.expect("RAY")); + assert_eq!(xline.common.source_handle, resolved("32")); + assert_eq!((xline.point.x, xline.point.y), (0.001, 0.001)); + assert_eq!((xline.vector.x, xline.vector.y), (1.0, 0.0)); + assert_eq!(ray.common.source_handle, resolved("33")); + assert_eq!((ray.point.x, ray.point.y), (0.001, 0.001)); + assert_eq!((ray.vector.x, ray.vector.y), (0.0, 1.0)); +} + +// ------------------------------------------------------- block on layer 0 + +/// AutoCAD's rule that a block's layer-0 geometry takes the layer of the +/// reference is a property of the reference, not of the block: the model +/// keeps what the file stores, and the children stay on layer 0. +#[test] +fn block_geometry_on_layer_0_stays_on_layer_0_in_the_model() { + let db = parse(BLOCK_LAYER0); + let insert = match &db.entities[0] { + Entity::Insert(i) => i, + other => panic!("expected the INSERT, got {other:?}"), + }; + assert_eq!(insert.common.layer, resolved("RED")); + assert_eq!(insert.block_name, resolved("SYM")); + let children: Vec<&Ref> = db.tables.block_records["SYM"] + .entities + .iter() + .map(|e| &e.common().layer) + .collect(); + assert_eq!( + children, + [ + &resolved("0"), + &resolved("0"), + &resolved("BLUE"), + &resolved("0") + ] + ); +} + +// ------------------------------------------------------------ title_block + +#[test] +fn the_title_block_fixture_keeps_every_string_in_paper_space() { + // The model holds one LINE and nothing else, while the sheet carries + // the drawing's name: make_fixtures.py's `title_block()`. + let db = parse(TITLE_BLOCK); + assert_eq!( + type_counts(&db), + expected(&[("INSERT", 1), ("LINE", 1), ("TEXT", 1), ("VIEWPORT", 1)]) + ); + let model = &db.tables.block_records["*Model_Space"]; + assert_eq!(model.entities.len(), 1); + assert!(matches!(model.entities[0], Entity::Line(_))); + let texts = |block: &str| -> Vec { + db.tables.block_records[block] + .entities + .iter() + .filter_map(|e| match e { + Entity::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect() + }; + assert_eq!(texts("*Paper_Space"), ["GARDEN PAVILION"]); + assert_eq!(texts("TITLEBLOCK"), ["SHEET 1 OF 2"]); +} diff --git a/crates/uncad/tests/fixtures/README.md b/crates/uncad/tests/fixtures/README.md new file mode 100644 index 0000000..3148c7d --- /dev/null +++ b/crates/uncad/tests/fixtures/README.md @@ -0,0 +1,573 @@ +# Test fixtures + +Hand-authored DXF files that pin what a reader must get out of a drawing: the +pre-R2007 code-page path, mirrored object coordinate systems and polyline bulges, +dimension points and values, viewports and layouts with their plot settings, layer +states, polyline vertices, meshes and colours. `tests/fixtures.rs` asserts what this +crate's model carries for each of them. + +Every file is written from scratch by `make_fixtures.py` in this directory -- no +third-party drawing was copied, so they are redistributable under the repository's +GPL-3.0-or-later. They were authored on 2026-09-21..23 for the feature branch that is +being ported onto this repository (`archive/feat-0.3-readable`), where the renderer and +the export package of that time made assertions on them too; those assertions belong to +the renderer (`iron-render-cad`) and to the export crate now, and are not repeated here. + +All are R2000 (`$ACADVER AC1015`) text DXF with CRLF line endings, between 470 and 4387 +bytes, above the 256-byte minimum LibreDWG's `dwg_read_dxf` enforces. `.gitattributes` +keeps their bytes (`-text`): the CP949 fixture is not UTF-8 by design, and none of them +may be normalised to LF. + +Ground truth below is what each file states, worked out from its group codes, and -- +where it says "probe" -- what LibreDWG itself holds after reading it (a throw-away program +linked against `libredwg-sys` that read `Dwg_Data.header` and every field through +`dwg_dynapi_*`). + +| File | Bytes | Objects read | Purpose | +|---|---|---|---| +| `cp949_r2000.dxf` | 855 | 9 | CP949 (`ANSI_949`) strings in TEXT, MTEXT and a LAYER name | +| `mirrored_ocs_r2000.dxf` | 925 | 7 | Closed LWPOLYLINE with extrusion (0,0,-1), an open one with a bulge, mirrored CIRCLE/ARC/TEXT | +| `mirrored_bulge_r2000.dxf` | 470 | 3 | The bulged outline of the mirrored fixture in an OCS with extrusion (0,0,-1), next to an ARC tracing the same arc | +| `dimlfac12_r2000.dxf` | 1304 | 12 | `$DIMLFAC 12.0` (header and STANDARD style) with a rotated DIMENSION whose `act_measurement` is 10.0, its `*D1` block bound | +| `twisted_viewport_r2000.dxf` | 2253 | 16 | A paper-space VIEWPORT with `VIEWTWIST` 30 degrees and every AcDbViewport view field, plus a LAYOUT `Layout1` (A4 landscape, embedded plot settings) bound to `*Paper_Space` and to the VIEWPORT | +| `hidden_layers_r2000.dxf` | 1925 | 9 entities, 7 layers, 2 linetypes | One LINE per layer state (on, off, frozen, non-plotting, `Defpoints`, locked), an invisible LINE and a 0.50 mm DASHED one | +| `plot_origin_r2000.dxf` | 2430 | 17 | A LAYOUT `Layout1` in inches (ANSI B landscape, rotation 0) with asymmetric margins and a non-zero plot origin (DXF 46/47); a paper-space border LWPOLYLINE and a 1:5 plan VIEWPORT over a model LINE | +| `angular_ordinate_r2000.dxf` | 1491 | 10 | A 2-line angular DIMENSION (60 degrees) and an X- and a Y-type ordinate DIMENSION (30 and 50): the two kinds whose definition points LibreDWG's DXF reader lays out differently from its DWG decoder | +| `viewport_states_r2000.dxf` | 4387 | 5 entities, 2 layers, 2 layouts | One VIEWPORT per state a sheet distinguishes (on; on a frozen layer; off; non-plan) plus two page setups: plot rotation 2 with asymmetric margins in mm, and plot rotation 3 in inches | +| `radial_r2000.dxf` | 1425 | 6 entities, 1 DIMSTYLE | A RADIUS, a DIAMETER and a 3-point angular DIMENSION -- the three kinds no corpus DXF carries -- each with its circle or arc, no cached `*D` block | +| `hatched_viewport_r2000.dxf` | 3117 | 18 | The twisted-viewport fixture plus a pattern HATCH in model space (under the viewport) and one in paper space (outside its frame) | +| `nested_attrib_r2000.dxf` | 2174 | 3 model-space entities, 2 blocks (6 entities) | A block with an ATTDEF inserted inside another block, its ATTRIB value owned by the block record: the attribute of a *nested* block reference | +| `infinite_lines_r2000.dxf` | 673 | 4 | An XLINE and a RAY through the middle of a drawing 0.002 units across: the two entities with no end, at a scale where drawing them a fixed 1e6 units long panicked a rasterizer | +| `polyline_vertices_r2000.dxf` | 2322 | 3 | A closed 4-vertex `POLYLINE_2D`, a 2-vertex one with bulge 1.0 and a 5-vertex `POLYLINE_3D`: the last vertex of each is what LibreDWG's own point accessors drop on any pre-R2004 file | +| `entity_truecolor_r2000.dxf` | 795 | 4 | One LINE per way a DXF can state a colour (420 alone, 62 alone, 62 then 420, neither) on a layer whose own ACI is 3 | +| `polyface_mesh_r2000.dxf` | 3921 | 3 | A `POLYLINE_PFACE` and a `POLYLINE_MESH` whose VERTEX records name the block record as their owner, next to a LINE: the shape that made an unpatched LibreDWG refuse the whole file | +| `block_layer0_r2000.dxf` | 1295 | 1 entity, 2 blocks (4 entities), 3 layers | A block drawn on layer 0 inserted on a coloured layer: AutoCAD's "layer 0 in a block means the layer of the reference" rule | +| `title_block_r2000.dxf` | 2939 | 4 entities, 3 block records, 1 layout | A drawing whose every string is in *paper* space: a title TEXT and a title-block INSERT on an A4 sheet over a model with no text at all | + +## cp949_r2000.dxf + +HEADER: `$DWGCODEPAGE ANSI_949`, `$INSUNITS 4`, `$MEASUREMENT 1`, `$LUNITS 2`, +`$DIMLFAC 1.0`. TABLES: a LAYER table with `0` and a layer whose name is the CP949 bytes +of "벽체". ENTITIES: four TEXT and one MTEXT, all at height 2.5. + +Probe: `dxf_read_file` rc 0, `Dwg_Data.header.version` 25 (`R_2000`), +`Dwg_Data.header.codepage` **40** (`CP_ANSI_949`, `src/codepages.h`). LibreDWG does no +code-page conversion on DXF input: `dwg_dynapi_entity_utf8text` hands back the file's +bytes unchanged. + +| Handle | Entity | Layer | Raw bytes (CP949) | Meaning (UTF-8) | +|---|---|---|---|---| +| 23 | TEXT at (0,0) | 벽체 | `B5 B5 B8 E9` | `도면` | +| 24 | TEXT at (0,5) | 0 | `A1 BE 33` | `±3` | +| 25 | TEXT at (0,10) | 0 | `33 32 2E 35 A7 B3` | `32.5㎡` | +| 26 | MTEXT at (0,20), width 50 | 0 | `B9 E6 20 31 30 31 5C 50 B8 E9 C0 FB 20 33 32 2E 35 A7 B3` | `방 101\P면적 32.5㎡` (`\P` literal) | +| 27 | TEXT at (0,15) | 0 | `50 4C 41 49 4E` | `PLAIN` | +| 22 | LAYER (color 1) | | `BA AE C3 BC` | `벽체` | + +Notes: + +- `±` is `A1 BE` in CP949 (KS X 1001 row 1, PLUS-MINUS SIGN); `A1 B1` would be the right + double quotation mark. Python's `cp949` codec is the authority and the file carries + `A1 BE`. Read as CP1252 the pair is `¡¾`, read as UTF-8 it is two U+FFFD. +- The layer name is the worst case for a lossy UTF-8 reading: `C3 BC` happens to be + valid UTF-8 for U+00FC, so such a reading invents a wrong character (`��ü`) rather + than only replacing bytes. + +## mirrored_ocs_r2000.dxf + +HEADER: `$INSUNITS 4` only, so `header.codepage` is LibreDWG's DXF default 30 +(`ANSI_1252`). No TABLES section: no entity's layer resolves. + +| Handle | Entity | DXF groups | LibreDWG in-memory (probe) | +|---|---|---|---| +| 20 | LWPOLYLINE | `70 = 1`, 4 vertices (0,0) (100,0) (100,50) (0,50), `210/220/230 = 0,0,-1` | `flag = 513` (512 closed + 1 has-extrusion), `num_bulges = 0`, `extrusion = (0,0,-1)` | +| 21 | LWPOLYLINE | `70 = 0`, same vertices, `42 = 0.41421356` after the second vertex, `210/220/230 = 0,0,1` | `flag = 16`, `num_bulges = 4`, `bulges = [0, 0.41421356, 0, 0]`, `extrusion = (0,0,1)` | +| 22 | CIRCLE | centre (10,10) r 5, `210/220/230 = 0,0,-1` | `extrusion = (0,0,-1)` | +| 23 | ARC | centre (0,0) r 20, 0 to 90 degrees, `210/220/230 = 0,0,-1` | angles 0 and 1.5707963267948966 rad, `extrusion = (0,0,-1)` | +| 24 | TEXT | "MIRROR" at (10,10) h 2.5, `210/220/230 = 0,0,-1` | `extrusion = (0,0,-1)` | +| 25 | LINE | (-5,-5) to (5,5), no extrusion | | + +The coordinates are stated in each entity's object coordinate system. With extrusion +(0,0,-1) that system's x axis points the other way, so in the world the circle's centre +is (-10,10), the arc runs clockwise from 90 to 180 degrees and the text starts at +(-10,10); taking them there is the consumer's step, the model keeps what the file states. + +The closed flag: `flag & 1` returns `true` for handle 20 only because the reader sets +bit 1 for "has extrusion"; LWPOLYLINE's closed bit is 512 in LibreDWG's memory. Handle +21 (`flag = 16`) is the one that proves bit 1 is not "closed". A closed polyline +*without* an extrusion (in-memory `flag = 512` exactly) is not in this file; +`tests/polyline_closed.rs` covers that case with the corpus's `example_2000.dwg`/`.dxf` +pair. + +## mirrored_bulge_r2000.dxf + +HEADER: `$INSUNITS 4` only; no TABLES section. The OCS-to-world map of extrusion +(0,0,-1) is the reflection `x -> -x`, which reverses every arc's turning direction: a +consumer that takes the polyline to the world negates its bulges together with the x of +its vertices, and the ARC next to it is the same arc stated the way an ARC states it. + +| Handle | Entity | DXF groups | +|---|---|---| +| 20 | LWPOLYLINE | `70 = 0`, vertices (0,0) (100,0) (100,50) (0,50), `42 = 0.41421356` after the second vertex, `210/220/230 = 0,0,-1` | +| 21 | ARC | centre (75,25) r 35.35533906, 315 to 45 degrees, `210/220/230 = 0,0,-1` | + +In the world both are the clockwise 90-degree arc from (-100,0) to (-100,50) about +(-75,25), whose apex is (-110.355,25). + +## hidden_layers_r2000.dxf + +HEADER: `$INSUNITS 4`. TABLES: an LTYPE table (`Continuous`, `DASHED`) and a LAYER table: + +| Layer | DXF 62 | DXF 70 | DXF 290 | State | +|---|---|---|---|---| +| `0` | 7 | 0 | omitted | on | +| `VISIBLE` | 1 | 0 | omitted | on | +| `OFF` | -3 | 0 | omitted | off (negative colour) | +| `FROZEN` | 4 | 1 | omitted | frozen | +| `NOPLOT` | 5 | 0 | 0 | not plotted -- but LibreDWG's DXF reader cannot tell an omitted 290 from a 0 | +| `Defpoints` | 7 | 0 | 0 | not plotted, as above | +| `LOCKED` | 6 | 4 | omitted | locked, still drawn | + +ENTITIES: one LINE per layer, in table order, from `(0, 10 i)` to `(100, 10 i)`; then on +`VISIBLE` a LINE with `60 = 1` (invisible) at y = 100 and a LINE with `6 DASHED`, +`370 = 50` (0.50 mm) and `48 = 2.0` at y = 110. + +LibreDWG's DXF reader applies the binary format's bit layout to a LAYER's group 70 +(bit 2 -> off, bit 4 -> frozen in new viewports, bit 8 -> locked), so the DXF meaning +(1 frozen, 2 frozen in new viewports, 4 locked) has to be read off the raw flag. + +## dimlfac12_r2000.dxf + +HEADER: `$INSUNITS 4`, `$DIMLFAC 12.0`, `$DIMDEC 2`, `$DIMLUNIT 2`, `$DIMSCALE 1.0`. +TABLES: a BLOCK_RECORD table with `*Model_Space` (the reader's pre-created handle `1F`) +and `*D1` (handle `40`), plus DIMSTYLE `STANDARD` with `144 = 12.0` (DIMLFAC). BLOCKS: a +BLOCK `*D1` (flag 1, anonymous, `330 = 40`) containing a TEXT "120" at (5,6), height +0.18 -- the label 12 x 10 under DIMLFAC 12. ENTITIES: a LINE (0,0)-(10,0) and a DIMENSION +with `100 AcDbDimension`, `2 *D1`, `10 = (10,5)` dimension-line point, `11 = (5,6)` text +midpoint, `70 = 32`, `1` empty, `42 = 10.0`, `3 STANDARD`, `100 AcDbAlignedDimension`, +`13 = (0,0)`, `14 = (10,0)`, `50 = 0.0`, `100 AcDbRotatedDimension`. + +| Item | LibreDWG in-memory (probe) | +|---|---| +| `$DIMLFAC` / DIMSTYLE `DIMLFAC` | 12.0 / 12.0 | +| `$DIMDEC` / `$DIMLUNIT` / `$DIMSCALE` | 2 / 2 / 1.0 | +| DIMENSION type | `DIMENSION_LINEAR` (upgraded on `AcDbRotatedDimension`) | +| `act_measurement` (group 42) | **10.0** | +| `user_text` | `""` | +| `flag` / `flag1` | 32 / 35 | +| `def_pt` / `text_midpt` | (10,5,0) / (5,6) | +| `xline1_pt` / `xline2_pt` / `dim_rotation` | (0,0,0) / (10,0,0) / 0.0 | +| `block` (group 2) / `dimstyle` (group 3) | BLOCK_RECORD `40` (name from its BLOCK: `*D1`) / `STANDARD` | +| Objects | BLOCK_HEADER `*Model_Space`, BLOCK_RECORD `*D1`, BLOCK, TEXT "120", ENDBLK, LINE, DIMENSION_LINEAR | + +Why the file carries a BLOCK_RECORD table: without it (the earlier ENTITIES-only draft, +`make_fixtures.py . dimlfac-minimal`) the `*D1` BLOCK, its TEXT and ENDBLK are read but +no BLOCK_HEADER named `*D1` exists, so the DIMENSION's `2 *D1` cannot resolve. Reason +(`src/in_dxf.c`, `dxf_blocks_read`): for R13+ input a BLOCK is bound to its BLOCK_HEADER +only through a `330` owner handle that names a BLOCK_RECORD table entry; the "find or +create the BLOCK_HEADER by name" path exists only for R11/R12 files. + +## twisted_viewport_r2000.dxf + +HEADER: `$INSUNITS 4`. TABLES: a BLOCK_RECORD table with `*Model_Space` (`1F`) and +`*Paper_Space` (`1C`, with `340 = 2B` naming its LAYOUT). BLOCKS: both blocks, empty. +ENTITIES: a LINE (0,0)-(100,50) owned by `*Model_Space` and a VIEWPORT owned by +`*Paper_Space` (`330 = 1C`) with handle `5 = 2A`, `67 = 1`, `100 AcDbViewport`, +`10 = (150,100,0)`, `40 = 200`, `41 = 120`, `68 = 1`, `69 = 2`, `12 = (50,25)`, +`13 = (0,0)`, `14 = (10,10)`, `15 = (10,10)`, `16 = (0,0,1)`, `17 = (0,0,0)`, `42 = 50`, +`43 = 0`, `44 = 0`, `45 = 60`, `50 = 0`, `51 = 30.0`, `72 = 100`, `90 = 32864`. OBJECTS: +the named object dictionary (`5 = C`, `330 = 0`, `281 = 1`, one entry `3 ACAD_LAYOUT` / +`350 1A`), the `ACAD_LAYOUT` dictionary (`5 = 1A`, `330 = C`, entry `3 Layout1` / +`350 2B`) and one LAYOUT (`5 = 2B`, `330 = 1A`): + +- `100 AcDbPlotSettings`: `1` empty (page setup name), `2 none_device` (printer), + `4 ISO_A4_(210.00_x_297.00_MM)` (canonical media name), `40`-`43 = 6.35` (margins, + mm), `44 = 210.0`, `45 = 297.0` (the unrotated sheet, mm), `46`-`49`, `140`, + `141 = 0.0` (plot origin and window), `142 = 1.0`, `143 = 1.0` (paper : drawing + units), `70 = 688` (plot flags), `72 = 1` (mm), `73 = 1` (rotated 90 degrees + counter-clockwise: landscape), `74 = 5` (plot the layout), `7` empty (style sheet), + `75 = 16` (1:1), `147 = 1.0`, `148`, `149 = 0.0` (paper image origin). +- `100 AcDbLayout`: `1 Layout1`, `70 = 1`, `71 = 1` (tab order), `10 = (-6.35,-6.35)`, + `11 = (290.65,203.65)` (LIMMIN/LIMMAX: the printable area of the rotated sheet), + `12 = (0,0,0)`, `14 = (1e20,1e20,1e20)`, `15 = (-1e20,-1e20,-1e20)` (the "never + computed" extents sentinels AutoCAD writes for a layout that has not been plotted or + zoomed), `146 = 0.0`, `13 = (0,0,0)`, `16 = (1,0,0)`, `17 = (0,1,0)`, `76 = 0`, + `330 = 1C` (block record), `331 = 2A` (active viewport). + +Deliberately absent: a `Model` LAYOUT, a plot view (`6`), reactors and extension +dictionaries, `345`/`346` UCS handles, the R2004+ shade-plot groups. + +| Field | LibreDWG in-memory (probe) | +|---|---| +| `center` / `width` / `height` | (150,100,0) / 200 / 120 | +| `on_off` (68) / `id` (69) | 1 / 2 | +| `VIEWCTR` (12) | (50,25) | +| `VIEWSIZE` (45) | 60.0: 60 drawing units in a 120-unit frame, scale 2 | +| `VIEWTWIST` (51) | **0.5235987755982988 rad** (the reader converts DXF degrees to radians) | +| `VIEWDIR` (16) / `view_target` (17) | (0,0,1) / (0,0,0) | +| `LENSLENGTH` (42) / `status_flag` (90) | 50.0 / 32864 | +| `entmode` | **1 (paper space)** | +| Extra objects | `VX_CONTROL` (handle 1), `VX_TABLE_RECORD` (handle 2): LibreDWG's own R2000 artefact, a VIEWPORT with a `5` handle gets a VX record (`in_dxf.c`, "special-case VIEWPORT -> VX") | + +Why the file carries BLOCKS: `67 = 1` on its own does not move an entity to paper space. +`dxf_entities_read` assigns `entmode 1` only when the entity's `330` owner handle equals +`BLOCK_RECORD_PSPACE`, and that header handle is only set after a BLOCKS section defines +`*Paper_Space`. The earlier ENTITIES-only draft (`make_fixtures.py . viewport-minimal`) +therefore put the VIEWPORT in model space. + +The LAYOUT as LibreDWG hands it back (the AcDbLayout fields through +`dwg_dynapi_entity_value(obj, "LAYOUT", ...)`, the embedded plot settings at +`dwg_dynapi_entity_field("LAYOUT", "plotsettings")->offset` through +`dwg_dynapi_subclass_value(sub, "Dwg_Object_PLOTSETTINGS", ...)` -- +`dwg_dynapi_subclass_value(sub, "PLOTSETTINGS", ...)` returns false): + +| Field (DXF group) | LibreDWG in-memory (probe) | +|---|---| +| object | `LAYOUT`, handle `2B`, `ownerhandle` `1A` (the `ACAD_LAYOUT` DICTIONARY) | +| `layout_name` (1) / `tab_order` (71) / `layout_flags` (70) | `"Layout1"` / 1 / 1 | +| `block_header` (330) | `1C` -> BLOCK_RECORD `*Paper_Space`; and `BLOCK_HEADER 1C.layout` -> `2B` from the table's `340` | +| `active_viewport` (331) | `2A` -> VIEWPORT | +| `LIMMIN` / `LIMMAX` (10/11) | (-6.35,-6.35) / (290.65,203.65) | +| `EXTMIN` / `EXTMAX` (14/15) | (1e20,1e20,1e20) / (-1e20,-1e20,-1e20), the sentinels exactly as written | +| `INSBASE` (12) / `UCSORG` (13) / `UCSXDIR` (16) / `UCSYDIR` (17) / `ucs_elevation` (146) / `UCSORTHOVIEW` (76) | (0,0,0) / (0,0,0) / (1,0,0) / (0,1,0) / 0.0 / 0 | +| `num_viewports` / `viewports` | 0 / empty: a DWG-only (R2004+) list, DXF has no group for it | +| `plotsettings.printer_cfg_file` (1) / `paper_size` (2) / `canonical_media_name` (4) | `""` (set, not NULL) / `"none_device"` / **`"ISO_A4_(210.00_x_297.00_MM)"`** | +| `plotsettings.plotview_name` (6) / `plotview` / `stylesheet` (7) | NULL (no `6` written) / null ref / `""` | +| `left/bottom/right/top_margin` (40-43) | 6.35 each | +| `paper_width` / `paper_height` (44/45) | **210.0 / 297.0** (mm, unrotated) | +| `plot_origin` (46) / `plot_window_ll` (48) / `plot_window_ur` (140) / `paper_image_origin` (148) | (0,0) each | +| `plot_paper_unit` (72) / `plot_rotation_mode` (73) / `plot_type` (74) | 1 (mm) / **1** (90 degrees counter-clockwise) / 5 (layout) | +| `std_scale_type` (75) / `std_scale_factor` (147) / `paper_units` (142) / `drawing_units` (143) | 16 (1:1) / 1.0 / 1.0 / 1.0 | +| `plot_flags` (70 under `AcDbPlotSettings`) | 688 (0x2b0) | +| `shadeplot_type` / `shadeplot_reslevel` / `shadeplot_customdpi` | 0 / 0 / 0 (R2004+ groups, not written) | +| `HEADER.DICTIONARY_NAMED_OBJECT` / `DICTIONARY_LAYOUT` / `DICTIONARY_PLOTSETTINGS` | `C` / `1A` / null | +| Objects | 16: `BLOCK_HEADER`, `LAYER_CONTROL`, `LAYER`, `BLOCK_CONTROL`, `BLOCK_RECORD`, 2 x `BLOCK`/`ENDBLK`, `LINE`, `VIEWPORT`, `VX_CONTROL`, `VX_TABLE_RECORD`, `DICTIONARY` `C`, `DICTIONARY` `1A`, `LAYOUT` `2B` | + +Why the file carries an OBJECTS section, and what the reader needs from it +(`src/in_dxf.c`, read at trace level so every group's landing is visible): + +- `dxf_objects_read` turns any `0 LAYOUT` into a LAYOUT object by itself; no dictionary + is needed for the object to exist. Every group above landed in the field named + (`set LAYOUT.plotsettings. [.. ]` and `LAYOUT. = ... [.. ]` + in the trace); nothing written was ignored or altered. Values that equal a default + cannot be told from the default by value alone: the zero points, and `paper_units` + (142), which the reader forces to 1.0 for every LAYOUT before it reads the groups. +- The two `330`s matter. The first (before the `100` markers) becomes the object's + `ownerhandle` through the generic path; the second, inside `AcDbLayout`, is taken as + `block_header` only because an owner is already set (the LAYOUT branch guards on + `ownerhandle`). With a single `330` the block record would become the owner and + `block_header` would stay null. `331` is stored as an absolute reference and resolves + to the VIEWPORT. +- The named object dictionary (which must be the file's first DICTIONARY) and its + `ACAD_LAYOUT` entry are what `CHECK_DICTIONARY_HDR(LAYOUT)` needs to set + `HEADER.DICTIONARY_LAYOUT` (it tries the entry names `LAYOUT`, then `ACAD_LAYOUT`). + Without any DICTIONARY the reader makes an empty NOD of its own and that header handle + stays null; the LAYOUT is unaffected. +- `340` on the BLOCK_RECORD goes through the generic handle path (codes above 300 are hex + handles) into `BLOCK_HEADER.layout`, so the block and the layout point at each other. +- A `6` plot-view name is only taken when non-empty (and then looked up as a table handle + in `dxf_postprocess_LAYOUT`), so it is omitted; `plotview_name` comes back NULL rather + than `""`. +- At warning level the reader prints three "Duplicate handle 20/21/22" errors: its own + LAYER_CONTROL, LAYER and BLOCK_CONTROL take 20-22 before the BLOCK/ENDBLK entities with + those explicit handles arrive. Harmless (nothing refers to an ENDBLK), but new handles + in this file must avoid 1, 2, 20-23 as well as C, 1A, 1C, 1F, 24, 2A and 2B. + +## plot_origin_r2000.dxf + +The same skeleton as the viewport fixture (`*Model_Space` 1F, `*Paper_Space` 1C with +`340 = 2B`, BLOCK/ENDBLK 20-23, the named object dictionary `C`, `ACAD_LAYOUT` `1A`, +LAYOUT `2B`, VIEWPORT `2A`), `$INSUNITS 1` (inches), and the page setup AutoCAD-written +drawings usually carry: `72 = 0` (inch paper units), `73 = 0` (no rotation), ANSI B +`44/45 = 431.8 x 279.4` mm (17 x 11 in), margins `40..43 = 6.35 / 19.05 / 6.35 / 19.05` +mm (0.25 / 0.75 / 0.25 / 0.75 in) and a plot origin `46/47 = -6.35 / -12.7` mm (-0.25 / +-0.5 in). AutoCAD places the layout origin at the printable corner moved by the plot +origin, so the paper runs from `-(margin + origin)` = `(-(0.25 - 0.25), -(0.75 - 0.5))` = +`(0, -0.25)` to `(17, 10.75)` in, and that is what the file's `LIMMIN/LIMMAX` (10/11) +say. The rule is ezdxf 1.4.4's `reset_paper_limits`; it was checked against the seven +AutoCAD-written sample layouts (`-(margin + origin)` equals the stored limits within +0.005 in on the six with rotation 0; the rotated one keeps limits matching neither +formula, which is why a consumer should trust the stored limits first). + +ENTITIES: a model LINE `24` (0,0) -> (100,50) owned by 1F; on paper (owner 1C, `67 = 1`) +a closed border LWPOLYLINE `25` (0.5, 0.25) .. (16.5, 10.5) -- its top edge lies above the +10.25 in a margins-only sheet would end at -- and the VIEWPORT `2A`: centre (8.5, 5.5), +12 x 8 in, `VIEWCTR` (50, 25), `VIEWSIZE` 40 (scale 8 / 40 = 1:5), no twist, `68 = 1`, +`69 = 2`, `90 = 32864`. + +Probe (17 objects): `LIMMIN (0, -0.25)`, `LIMMAX (17, 10.75)`, `plotsettings` margins +6.35/19.05/6.35/19.05, paper 431.8 x 279.4, `plot_origin (-6.35, -12.7)`, +`plot_paper_unit 0`, `plot_rotation_mode 0`, VIEWPORT `entmode 1`. + +## angular_ordinate_r2000.dxf + +HEADER: `$INSUNITS 4`, `$DIMDEC 2`, `$DIMADEC 0`, `$DIMLUNIT 2`. TABLES: a LAYER table +and DIMSTYLE `STANDARD` (handle 30). ENTITIES: two LINEs (0,0) -> (10,0) and (0,0) -> +(5, 8.660254), then three DIMENSIONs with no cached `*D` blocks, every number derived by +hand: + +| Handle | Subclass | 70 | 10 | 13 | 14 | 15 | 16 | 42 | Value | +|---|---|---|---|---|---|---|---|---|---| +| 33 | `AcDb2LineAngularDimension` | 34 | (5, 8.660254) = line 2's end | (0,0) | (10,0) | (0,0) | (4.330127, 2.5) = arc point, 30 degrees along r = 5 | pi/3 | 60 degrees | +| 34 | `AcDbOrdinateDimension` | 102 (bit 64: X type) | (100, 200) datum | (130, 250) feature | (130, 270) leader | | | 30.0 | 130 - 100 = 30 | +| 35 | `AcDbOrdinateDimension` | 38 (Y type) | (100, 200) | (130, 250) | (150, 250) | | | 50.0 | 250 - 200 = 50 | + +What the file pins: LibreDWG's DXF reader maps a 2-line angular dimension's groups by +code (`def_pt` = 10, `xline2end_pt` = 16), its DWG decoder by stream order (the leading +`def_pt` 2RD is the arc point = group 16, `xline2end_pt` the last point = group 10), so a +reader has to swap the two for DXF input. Read swapped, the arc point would sit at 60 +degrees between rays at 30 and 180 degrees and the dimension would measure 150. The +ordinate's type is bit 64 of group 70 in a DXF (`flag`) and bit 1 of the stream-only +`flag2` in a DWG; read from the other field both ordinates would be Y type. + +## hatched_viewport_r2000.dxf + +`twisted_viewport_r2000.dxf` in its full form (the same HEADER, TABLES, BLOCKS, LINE +`24`, VIEWPORT `2A` and OBJECTS section, so everything that section says holds here) plus +one pattern HATCH per space, written by `make_fixtures.py`'s `hatch` helper: a +user-defined pattern (`76 = 0`, `2 USER`) of one definition line (`78 = 1`) with no +dashes, one closed polyline boundary (`92 = 3`, `73 = 1`), style outermost (`75 = 1`), +and a seed point one unit inside the first vertex. + +| Handle | Entity | Space | Boundary | Definition line (53 / 45,46) | +|---|---|---|---|---| +| 30 | HATCH | model (`330 = 1F`) | (20,10) (60,10) (60,30) (20,30) | 90 degrees, offset (-2, 0): vertical lines 2 units apart | +| 31 | HATCH | paper (`330 = 1C`, `67 = 1`) | (10,10) (40,10) (40,30) (10,30) | 0 degrees, offset (0, 4): horizontal lines 4 units apart | + +The paper hatch lies outside the viewport's 200 x 120 frame (x 50..250, y 40..160 on the +sheet): a sheet that composites the model through the viewport has to keep the two +patterns apart. Objects read: the 16 of the viewport fixture plus the two HATCHes. + +## nested_attrib_r2000.dxf + +The tag-inside-assembly pattern: an attributed block inside another block. HEADER: +`$INSUNITS 4`. TABLES: BLOCK_RECORDs `1F` (`*Model_Space`), `40` (`TAG`) and `50` +(`DOOR`). BLOCKS: `TAG` = LINE `42` (0,0)-(10,0) and ATTDEF `43` (tag `NUM`, default +`D-000`, height 2.5); `DOOR` = LINEs `52` and `53` plus INSERT `55` of `TAG` at (20, 20) +(`66 = 1`) followed by its ATTRIB `56` (`NUM` = `D-101`, at (21, 21)) and a SEQEND -- +the ATTRIB owned by the **block record** (`330 = 50`), the shape ezdxf- and +AutoCAD-written DXFs use. ENTITIES: INSERT `60` of `DOOR` at (100, 100) and INSERT `61` +of `TAG` at (0, 0) with its own ATTRIB `62` (`NUM` = `D-TOP`). + +LibreDWG's DXF importer fills an INSERT's `attribs[]` array for the INSERT in ENTITIES +(`61`), not for the one inside the block definition (`55`): that one's attribute is only +reachable as the entity that follows it in the block's chain. An R2000 DXF gives only +this shape; a DWG stores the value on the INSERT itself. + +## viewport_states_r2000.dxf + +The viewport fixture's skeleton with a second paper layout and four viewports, one per +state a sheet distinguishes. TABLES: LAYERs `0` (colour 7) and `VPFROZEN` (colour 4, +`70 = 1`: frozen, the usual way to hide a viewport's border); BLOCK_RECORDs +`*Model_Space` (`1F`), `*Paper_Space` (`1C`, `340 = 2B`) and `*Paper_Space0` (`1D`, +`340 = 2C`). BLOCKS: all three (`20`/`21`, `22`/`23`, `26`/`27`), empty. ENTITIES: the +model LINE `24` (0,0) -> (100,50) of the other viewport fixtures, then four VIEWPORTs +owned by `1C` (`67 = 1`), each a 60 x 40 frame showing the model window 30 x 20 about +(50,25) -- `12 = (50,25)`, `45 = 20`, so the scale is 40 / 20 = 2 -- with `69` counting +up from 2 so none is the overall frame: + +| Handle | `10` centre | Layer | `68` | `90` | `16` VIEWDIR | +|---|---|---|---|---|---| +| `2A` | (50, 50) | `0` | 1 | 32864 | (0,0,1) | +| `2D` | (50, 120) | `VPFROZEN` | 1 | 32864 | (0,0,1) | +| `2E` | (50, 190) | `0` | **0** | **163936** (`32864 \| 0x20000`) | (0,0,1) | +| `2F` | (140, 50) | `0` | 1 | 32864 | **(1,1,1)** | + +A frozen layer hides `2D`'s border and not the window; `2E` is switched off; `2F` is not +a plan view. + +OBJECTS: the named object dictionary (`C`), the `ACAD_LAYOUT` dictionary (`1A`) with +both entries, and two LAYOUTs: + +- `2B` `Layout1`, tab 1, block `1C`, active viewport `2A`: ISO A4 `44/45 = 210 x 297` mm, + `73 = 2` (upside down, so the sheet keeps its portrait size), `72 = 1` (mm), margins + `40..43 = 10 / 20 / 5 / 15` mm, no plot origin. The sheet therefore runs from + `-(left, bottom)` = (-10, -20) to (210 - 10, 297 - 20) = (200, 277) mm, which is what + `10`/`11` (LIMMIN/LIMMAX) say. +- `2C` `Layout2`, tab 2, block `1D` (empty), no active viewport: ANSI B + `44/45 = 431.8 x 279.4` mm, `73 = 3` (90 degrees clockwise, so the landscape sheet + becomes portrait 279.4 x 431.8), `72 = 0` (**inches**), margins 6.35 mm all round, no + plot origin: (-0.25, -0.25) to (273.05 / 25.4, 425.45 / 25.4) = (10.75, 16.75) in. + +Handles avoid the ones LibreDWG's reader takes for itself (1, 2, 20-23) as well as `C`, +`1A`, `1C`, `1D`, `1F`, `24`, `26`, `27`, `2A`-`2F`; each VIEWPORT with a `5` handle still +gets its own `VX_TABLE_RECORD` artefact, as the viewport fixture's section describes. + +## radial_r2000.dxf + +The three dimension kinds no corpus DXF carries. `2000/TS1.dwg` has all three, but every +TS1 DXF in the corpus fails LibreDWG's reader with a critical error, so the DXF side +needs a fixture of its own. + +HEADER: `$INSUNITS 4`, `$DIMDEC 2`, `$DIMADEC 0`, `$DIMLUNIT 2`. TABLES: a LAYER table +and DIMSTYLE `STANDARD` (handle 30). ENTITIES: the circle or arc each dimension +annotates, then the three DIMENSIONs with no cached `*D` blocks, every number derived by +hand: + +| Handle | `70` | Subclass | `10` | `13` | `14` | `15` | `42` | Value | +|---|---|---|---|---|---|---|---|---| +| 34 | 36 (4 radius) | `AcDbRadialDimension` | (0,0) centre | | | (3,4) on the circle | 5.0 | 3-4-5: radius 5 | +| 35 | 35 (3 diameter) | `AcDbDiametricDimension` | (20,0) chord start | | | (20,10) chord end | 10.0 | the vertical diameter of the r = 5 circle at (20,5) | +| 36 | 37 (5 angular 3-point) | `AcDb3PointAngularDimension` | (44.330127, 2.5) arc point | (50,0) | (45, 8.660254) | (40,0) centre | pi/3 | the 60-degree sector | + +The arc point of the angular dimension lies at 30 degrees along a radius of 5 -- inside +the 60-degree sector between the rays at 0 and 60 degrees, not the 300-degree one on the +other side. LibreDWG's DXF reader maps `def_pt` to group 10 and `first_arc_pt` / +`center_pt` to group 15. + +## infinite_lines_r2000.dxf + +The two entities that have no end, at a scale that makes the difference between "long" +and "as far as the picture goes" fatal to a renderer. HEADER: `$INSUNITS 4`. TABLES: a +LAYER table with `0` only. ENTITIES, all in model space: + +| Handle | Entity | Groups | +|---|---|---| +| `30` | LINE | `10` (0,0), `11` (0.002, 0.002): the only entity with a size | +| `31` | TEXT | `10` (0.0013, 0.0002), `40` 0.0006, `1` `X` | +| `32` | XLINE (`AcDbXline`) | `10` (0.001, 0.001), `11` (1, 0): a horizontal line through the middle | +| `33` | RAY (`AcDbRay`) | `10` (0.001, 0.001), `11` (0, 1): a vertical line from the middle upwards, nothing below | + +At 1568 px across, this drawing is about 750 thousand pixels per drawing unit, so a +construction line drawn as a segment 1e6 units long ends 7e11 px off the canvas -- which +tiny-skia's scan converter does not survive at every size. + +## title_block_r2000.dxf + +The drawing whose name is not in the drawing. HEADER: `$INSUNITS 4`. TABLES: a LAYER +table with `0`, and BLOCK_RECORDs `1F` `*Model_Space`, `1C` `*Paper_Space` (LAYOUT `2B`) +and `40` `TITLEBLOCK`. BLOCKS: the two spaces plus `TITLEBLOCK`. OBJECTS: the named +object dictionary, `ACAD_LAYOUT` and one LAYOUT `Layout1`, A4 landscape +(`ISO_A4_(210.00_x_297.00_MM)`, rotation 1, 6.35 mm margins, no plot origin). + +| Handle | Space | Entity | Groups | +|---|---|---|---| +| `24` | model | LINE | `10` (0,0), `11` (100,50): the whole model, no text anywhere in it | +| `25` | paper | TEXT | `10` (150,20), `40` 8, `1` `GARDEN PAVILION`: the drawing's title | +| `26` | paper | INSERT `TITLEBLOCK` | `10` (200,10) | +| `42` | in `TITLEBLOCK` | TEXT | `10` (10,10), `40` 5, `1` `SHEET 1 OF 2` | +| `43` | in `TITLEBLOCK` | LINE | `10` (0,5), `11` (90,5) | +| `2A` | paper | VIEWPORT | `10` (150,120), `40/41` 200 x 120, `12` (50,25), `45` 60: the model at 2 paper units per model unit | + +## polyline_vertices_r2000.dxf + +HEADER: `$ACADVER AC1015`, `$INSUNITS 4`. TABLES: the `0` layer. ENTITIES: three +old-style POLYLINEs, each with its own VERTEX chain and SEQEND, with the VERTEX records +naming the POLYLINE in group 330 (the shape AutoCAD writes). + +| handle | entity | group 70 | vertices | +|---|---|---|---| +| `30` | `POLYLINE_2D` (`AcDb2dPolyline`) | 1, closed | (0,0) (100,0) (100,100) (0,100) | +| `35` | `POLYLINE_2D` | 0 | (0,1000) with bulge (group 42) 1.0, (100,1000) | +| `39` | `POLYLINE_3D` (`AcDb3dPolyline`) | 8 | (0,0,0) (10,0,0) (10,10,0) (0,10,5) (0,0,5) | + +Ground truth, all of it a property of the geometry above rather than of any reading of +it: the square's perimeter is 400 and its area 10000; a bulge of 1.0 is a half turn, so +handle `35` is a semicircle over a 100-unit chord, radius 50, length `pi * 50` = +157.0796327; handle `39`'s last vertex is the only one with both y = 0 and z = 5. + +LibreDWG's `dwg_object_polyline_{2,3}d_get_points` return each of these one vertex short +(3, 1 and 4): for any file older than R2004 they end their `first_vertex .. last_vertex` +walk before the body reaches `last_vertex`. + +## entity_truecolor_r2000.dxf + +HEADER: `$ACADVER AC1015`, `$INSUNITS 4`. TABLES: layers `0` (ACI 7) and `GREEN` (ACI 3, +`00ff00`). ENTITIES: four LINEs on `GREEN`, differing only in their colour groups, +written in DXF order (62 before 420). + +| handle | groups | colour index | true colour | +|---|---|---|---| +| `30` | `420 65407` | 256 (BYLAYER) | `0x00ff7f` | +| `31` | `62 1` | 1 | none | +| `32` | `62 1`, `420 255` | 1 | `0x0000ff` | +| `33` | neither | 256 (BYLAYER) | none | + +65407 is `0x00ff7f` and 255 is `0x0000ff`, both plain 24-bit values with no method byte, +which is what a DXF carries. LibreDWG's DXF reader answers a plain group 62 with an RGB of +its own, taken from its copy of the ACI palette (`dxf_set_CMC_index`), so handle `31` +holds an `rgb` the file never wrote. + +## polyface_mesh_r2000.dxf + +HEADER: `$ACADVER AC1015`, `$INSUNITS 4`. TABLES: the `0` layer. ENTITIES: a LINE, a +polyface mesh and a polygon mesh. Every VERTEX record names the `*Model_Space` block +record (`330 1F`) rather than its POLYLINE -- what ezdxf and several exporters write, and +what `ezdxf.audit()` passes with 0 errors. + +| handle | entity | contents | +|---|---|---| +| `30` | `LINE` | (0,0) to (1000,0) -- the control: it must survive whatever the meshes do | +| `31` | `POLYLINE_PFACE` (`AcDbPolyFaceMesh`, 70 = 64, 71 = 8, 72 = 2) | 8 `AcDbPolyFaceMeshVertex` records (a 10 x 10 square at z 0 and the same at z 10) and 2 `AcDbFaceRecord` records, `1 2 3 4` and `5 6 7 8` | +| `50` | `POLYLINE_MESH` (`AcDbPolygonMesh`, 70 = 16, 71 = 3, 72 = 4) | 12 `AcDbPolygonMeshVertex` records at `(i*10, j*5, 0)`, row-major over i in 0..3, j in 0..4 | + +Ground truth from the definitions: two quad faces are 2 * 4 = 8 wireframe edges; an open +`m` by `n` grid is `n * (m - 1)` column edges plus `m * (n - 1)` row edges, so +4 * 2 + 3 * 3 = 17. + +Without the vendored `in_dxf.c`/`dynapi.c` patches (`crates/libredwg-sys/NOTICE.md`) this +file does not parse at all: `AcDbPolygonMeshVertex` matches no known subclass, which is +`DWG_ERR_INVALIDDWG` and so a whole-file refusal, taking the LINE with it. The polyface's +vertices come back typed `VERTEX_MESH`, not `VERTEX_PFACE`: the reader picks between the +two by looking the VERTEX's own group 330 up and asking whether it is a POLYLINE_PFACE. + +## block_layer0_r2000.dxf + +HEADER: `$ACADVER AC1015`, `$INSUNITS 4`. TABLES: layers `0` (ACI 7), `RED` (ACI 1) and +`BLUE` (ACI 5), and BLOCK_RECORDs `*Model_Space` (`1F`) and `SYM` (`40`). BLOCKS: block +`SYM`. ENTITIES: one INSERT of `SYM` at (0,0) on layer `RED`, colour BYLAYER. + +| handle | child of `SYM` | layer | group 62 | colour it is drawn in | +|---|---|---|---|---| +| `42` | `LINE` (0,0)-(10,0) | 0 | 256 BYLAYER | red, the INSERT's layer | +| `43` | `LINE` (0,2)-(10,2) | 0 | 0 BYBLOCK | red, the INSERT's own resolved colour | +| `44` | `LINE` (0,4)-(10,4) | BLUE | 256 BYLAYER | blue -- a named layer inside a block is used as stored | +| `45` | `TEXT` "L0" at (0,6) | 0 | BYLAYER | red | + +Layer 0's own ACI 7 is white (drawn black on a white page), so a child that resolved +against layer 0 instead of the reference is unmistakable. The rule is a property of the +reference: the file, and the model, keep the children on layer 0. + +## What did not work + +1. **`cp949_r2000.dwg` via LibreDWG's add/write API**: `dwg_add_Document`, + `dwg_add_TEXT`, `dwg_add_MTEXT`, `dwg_add_LINE`, `dwg_add_LWPOLYLINE`, `dwg_add_LAYER` + and `dwg_write_file` are compiled into the static library (`config.h` defines + `USE_WRITE`; only the bindgen allowlist omits them). A scratch program that sets + `header.version = R_2000` and `header.codepage = 40` before `dwg_add_Document`, adds + the same strings as the DXF fixture and calls `dwg_write_file` does produce an R2000 + DWG that reads back with `codepage = 40` -- but LibreDWG's UTF-8 -> code-page encoder + mangles every non-ASCII string on the way in (the layer came back as `;`, "±3" as + `1B 33`, "32.5㎡" as `32.5{`, with `Warning: utf-8: BAD_CONTINUATION_BYTE` at write + time), and setting `$DWGCODEPAGE` through `dwg_dynapi_header_set_value` crashed. So no + DWG is shipped: the code-page fixture is DXF-only. An AutoCAD-written Korean R2000 DWG + is still wanted as a fixture. +2. **A closed LWPOLYLINE without extrusion** in the mirrored fixture (the case where + `flag & 1` and `flag & 512` disagree directly) is not in the file; + `tests/polyline_closed.rs` covers it with the corpus's `example_2000.dwg`/`.dxf` pair + instead. +3. **`±` as `A1 B1`**: not a valid encoding of the character; the file uses `A1 BE`. +4. **A `Model` LAYOUT, a plot view (`6`) and a page-setup name** are not in the viewport + fixture: the reader needs none of them, and the `num_viewports` list cannot come from + DXF at all. +5. **A rotated sheet with a plot origin** is not in the plot-origin fixture: how AutoCAD + folds the offset into a rotated layout's limits is not known from the one rotated + sample (its limits match neither the margins-only nor the `-(margin + origin)` + placement). + +## Regenerating + +``` +python crates/uncad/tests/fixtures/make_fixtures.py # the shipped files (a name such as `mirrored-bulge`, `hatched-viewport` or `nested-attrib` writes only that one) +python crates/uncad/tests/fixtures/make_fixtures.py . dimlfac-minimal # the ENTITIES-only draft (unbound *D1) +python crates/uncad/tests/fixtures/make_fixtures.py . viewport-minimal # the ENTITIES-only draft (model-space VIEWPORT) +python crates/uncad/tests/fixtures/make_fixtures.py . plot-origin # one file (also angular-ordinate, radial, viewport-states, infinite-lines, title-block, cp949, mirrored, dimlfac, viewport, hidden) +``` + +The default mode reproduces the shipped bytes exactly; check with `git status` after +running it. Verify any regenerated file the same way the shipped ones were: read it +through `libredwg-sys` (`dxf_read_file`, then `dwg_dynapi_entity_value` / +`dwg_dynapi_entity_utf8text` per entity and the `codepage` field of `Dwg_Data.header`; +for the viewport fixture also the LAYOUT and its embedded `plotsettings`, see that +section) and through `uncad -o out.json --pretty`, and update the tables above and +`tests/fixtures.rs`. diff --git a/crates/uncad/tests/fixtures/angular_ordinate_r2000.dxf b/crates/uncad/tests/fixtures/angular_ordinate_r2000.dxf new file mode 100644 index 0000000..70a192a --- /dev/null +++ b/crates/uncad/tests/fixtures/angular_ordinate_r2000.dxf @@ -0,0 +1,244 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 9 +$DIMDEC + 70 +2 + 9 +$DIMADEC + 70 +0 + 9 +$DIMLUNIT + 70 +2 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 70 +1 + 0 +DIMSTYLE +105 +30 + 2 +STANDARD + 70 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +10.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +5.0 + 21 +8.660254037844386 + 31 +0.0 + 0 +DIMENSION + 8 +0 +100 +AcDbDimension + 2 +*D1 + 10 +5.0 + 20 +8.660254037844386 + 30 +0.0 + 11 +6.0 + 21 +3.5 + 31 +0.0 + 70 +34 + 1 + + 42 +1.0471975511965976 + 3 +STANDARD +100 +AcDb2LineAngularDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +10.0 + 24 +0.0 + 34 +0.0 + 15 +0.0 + 25 +0.0 + 35 +0.0 + 16 +4.330127018922194 + 26 +2.5 + 36 +0.0 + 0 +DIMENSION + 8 +0 +100 +AcDbDimension + 2 +*D2 + 10 +100.0 + 20 +200.0 + 30 +0.0 + 11 +130.0 + 21 +270.0 + 31 +0.0 + 70 +102 + 1 + + 42 +30.0 + 3 +STANDARD +100 +AcDbOrdinateDimension + 13 +130.0 + 23 +250.0 + 33 +0.0 + 14 +130.0 + 24 +270.0 + 34 +0.0 + 0 +DIMENSION + 8 +0 +100 +AcDbDimension + 2 +*D3 + 10 +100.0 + 20 +200.0 + 30 +0.0 + 11 +150.0 + 21 +250.0 + 31 +0.0 + 70 +38 + 1 + + 42 +50.0 + 3 +STANDARD +100 +AcDbOrdinateDimension + 13 +130.0 + 23 +250.0 + 33 +0.0 + 14 +150.0 + 24 +250.0 + 34 +0.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/block_layer0_r2000.dxf b/crates/uncad/tests/fixtures/block_layer0_r2000.dxf new file mode 100644 index 0000000..0fe92f6 --- /dev/null +++ b/crates/uncad/tests/fixtures/block_layer0_r2000.dxf @@ -0,0 +1,232 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +3 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +LAYER + 2 +RED + 70 +0 + 62 +1 + 6 +Continuous + 0 +LAYER + 2 +BLUE + 70 +0 + 62 +5 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 70 +2 + 0 +BLOCK_RECORD + 5 +1F + 2 +*Model_Space + 70 +0 + 0 +BLOCK_RECORD + 5 +40 + 2 +SYM + 70 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +41 +330 +40 + 8 +0 +100 +AcDbBlockBegin + 2 +SYM + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SYM + 0 +LINE + 5 +42 +330 +40 + 8 +0 + 62 +256 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +10.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +43 +330 +40 + 8 +0 + 62 +0 + 10 +0.0 + 20 +2.0 + 30 +0.0 + 11 +10.0 + 21 +2.0 + 31 +0.0 + 0 +LINE + 5 +44 +330 +40 + 8 +BLUE + 62 +256 + 10 +0.0 + 20 +4.0 + 30 +0.0 + 11 +10.0 + 21 +4.0 + 31 +0.0 + 0 +TEXT + 5 +45 +330 +40 + 8 +0 + 10 +0.0 + 20 +6.0 + 30 +0.0 + 40 +2.5 + 1 +L0 + 0 +ENDBLK + 5 +46 +330 +40 + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +INSERT + 5 +50 +330 +1F + 8 +RED +100 +AcDbEntity +100 +AcDbBlockReference + 2 +SYM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/cp949_r2000.dxf b/crates/uncad/tests/fixtures/cp949_r2000.dxf new file mode 100644 index 0000000..6180775 --- /dev/null +++ b/crates/uncad/tests/fixtures/cp949_r2000.dxf @@ -0,0 +1,148 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$DWGCODEPAGE + 3 +ANSI_949 + 9 +$INSUNITS + 70 +4 + 9 +$MEASUREMENT + 70 +1 + 9 +$LUNITS + 70 +2 + 9 +$DIMLFAC + 40 +1.0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +2 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +LAYER + 2 +º®ü + 70 +0 + 62 +1 + 6 +Continuous + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +TEXT + 8 +º®ü + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +2.5 + 1 +µµ¸é + 0 +TEXT + 8 +0 + 10 +0.0 + 20 +5.0 + 30 +0.0 + 40 +2.5 + 1 +¡¾3 + 0 +TEXT + 8 +0 + 10 +0.0 + 20 +10.0 + 30 +0.0 + 40 +2.5 + 1 +32.5§³ + 0 +MTEXT + 8 +0 +100 +AcDbMText + 10 +0.0 + 20 +20.0 + 30 +0.0 + 40 +2.5 + 41 +50.0 + 71 +1 + 1 +¹æ 101\P¸éÀû 32.5§³ + 0 +TEXT + 8 +0 + 10 +0.0 + 20 +15.0 + 30 +0.0 + 40 +2.5 + 1 +PLAIN + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/dimlfac12_r2000.dxf b/crates/uncad/tests/fixtures/dimlfac12_r2000.dxf new file mode 100644 index 0000000..f46bbbb --- /dev/null +++ b/crates/uncad/tests/fixtures/dimlfac12_r2000.dxf @@ -0,0 +1,222 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 9 +$DIMLFAC + 40 +12.0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMSCALE + 40 +1.0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 70 +1 + 0 +DIMSTYLE +105 +30 + 2 +STANDARD + 70 +0 +144 +12.0 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 70 +2 + 0 +BLOCK_RECORD + 5 +1F + 2 +*Model_Space + 70 +0 + 0 +BLOCK_RECORD + 5 +40 + 2 +*D1 + 70 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +41 +330 +40 + 8 +0 +100 +AcDbBlockBegin + 2 +*D1 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D1 + 0 +TEXT + 5 +42 +330 +40 + 8 +0 + 10 +5.0 + 20 +6.0 + 30 +0.0 + 40 +0.18 + 1 +120 + 0 +ENDBLK + 5 +43 +330 +40 + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +10.0 + 21 +0.0 + 31 +0.0 + 0 +DIMENSION + 8 +0 +100 +AcDbDimension + 2 +*D1 + 10 +10.0 + 20 +5.0 + 30 +0.0 + 11 +5.0 + 21 +6.0 + 31 +0.0 + 70 +32 + 1 + + 42 +10.0 + 3 +STANDARD +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +10.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/entity_truecolor_r2000.dxf b/crates/uncad/tests/fixtures/entity_truecolor_r2000.dxf new file mode 100644 index 0000000..52783d5 --- /dev/null +++ b/crates/uncad/tests/fixtures/entity_truecolor_r2000.dxf @@ -0,0 +1,144 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +2 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +LAYER + 2 +GREEN + 70 +0 + 62 +3 + 6 +Continuous + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +30 +330 +1F + 8 +GREEN +420 +65407 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +100.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +31 +330 +1F + 8 +GREEN + 62 +1 + 10 +0.0 + 20 +10.0 + 30 +0.0 + 11 +100.0 + 21 +10.0 + 31 +0.0 + 0 +LINE + 5 +32 +330 +1F + 8 +GREEN + 62 +1 +420 +255 + 10 +0.0 + 20 +20.0 + 30 +0.0 + 11 +100.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 5 +33 +330 +1F + 8 +GREEN + 10 +0.0 + 20 +30.0 + 30 +0.0 + 11 +100.0 + 21 +30.0 + 31 +0.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/hatched_viewport_r2000.dxf b/crates/uncad/tests/fixtures/hatched_viewport_r2000.dxf new file mode 100644 index 0000000..f5a4f2e --- /dev/null +++ b/crates/uncad/tests/fixtures/hatched_viewport_r2000.dxf @@ -0,0 +1,568 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 70 +2 + 0 +BLOCK_RECORD + 5 +1F + 2 +*Model_Space + 70 +0 + 0 +BLOCK_RECORD + 5 +1C + 2 +*Paper_Space + 70 +0 +340 +2B + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +20 +330 +1F + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 0 +ENDBLK + 5 +21 +330 +1F + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +22 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 0 +ENDBLK + 5 +23 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +24 +330 +1F + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +100.0 + 21 +50.0 + 31 +0.0 + 0 +HATCH + 5 +30 +330 +1F + 8 +0 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +USER + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +20.0 + 20 +10.0 + 10 +60.0 + 20 +10.0 + 10 +60.0 + 20 +30.0 + 10 +20.0 + 20 +30.0 + 97 +0 + 75 +1 + 76 +0 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +1 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-2.0 + 46 +0.0 + 79 +0 + 47 +1.0 + 98 +1 + 10 +21.0 + 20 +11.0 + 0 +VIEWPORT + 5 +2A +330 +1C + 8 +0 + 67 +1 +100 +AcDbViewport + 10 +150.0 + 20 +100.0 + 30 +0.0 + 40 +200.0 + 41 +120.0 + 68 +1 + 69 +2 + 12 +50.0 + 22 +25.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +60.0 + 50 +0.0 + 51 +30.0 + 72 +100 + 90 +32864 + 0 +HATCH + 5 +31 +330 +1C + 8 +0 + 67 +1 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +USER + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +10.0 + 20 +10.0 + 10 +40.0 + 20 +10.0 + 10 +40.0 + 20 +30.0 + 10 +10.0 + 20 +30.0 + 97 +0 + 75 +1 + 76 +0 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +1 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +4.0 + 79 +0 + 47 +1.0 + 98 +1 + 10 +11.0 + 20 +11.0 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +C +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_LAYOUT +350 +1A + 0 +DICTIONARY + 5 +1A +330 +C +100 +AcDbDictionary +281 +1 + 3 +Layout1 +350 +2B + 0 +LAYOUT + 5 +2B +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ISO_A4_(210.00_x_297.00_MM) + 40 +6.35 + 41 +6.35 + 42 +6.35 + 43 +6.35 + 44 +210.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +1 + 74 +5 + 7 + + 75 +16 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +-6.35 + 20 +-6.35 + 11 +290.65 + 21 +203.65 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +0 +330 +1C +331 +2A + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/hidden_layers_r2000.dxf b/crates/uncad/tests/fixtures/hidden_layers_r2000.dxf new file mode 100644 index 0000000..93bd11e --- /dev/null +++ b/crates/uncad/tests/fixtures/hidden_layers_r2000.dxf @@ -0,0 +1,326 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LTYPE + 70 +2 + 0 +LTYPE + 5 +14 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 +Solid line + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +15 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ + 72 +65 + 73 +2 + 40 +0.75 + 49 +0.5 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 70 +7 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +LAYER + 2 +VISIBLE + 70 +0 + 62 +1 + 6 +Continuous + 0 +LAYER + 2 +OFF + 70 +0 + 62 +-3 + 6 +Continuous + 0 +LAYER + 2 +FROZEN + 70 +1 + 62 +4 + 6 +Continuous + 0 +LAYER + 2 +NOPLOT + 70 +0 + 62 +5 + 6 +Continuous +290 +0 + 0 +LAYER + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 + 0 +LAYER + 2 +LOCKED + 70 +4 + 62 +6 + 6 +Continuous + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +100.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 8 +VISIBLE + 10 +0.0 + 20 +10.0 + 30 +0.0 + 11 +100.0 + 21 +10.0 + 31 +0.0 + 0 +LINE + 8 +OFF + 10 +0.0 + 20 +20.0 + 30 +0.0 + 11 +100.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 8 +FROZEN + 10 +0.0 + 20 +30.0 + 30 +0.0 + 11 +100.0 + 21 +30.0 + 31 +0.0 + 0 +LINE + 8 +NOPLOT + 10 +0.0 + 20 +40.0 + 30 +0.0 + 11 +100.0 + 21 +40.0 + 31 +0.0 + 0 +LINE + 8 +Defpoints + 10 +0.0 + 20 +50.0 + 30 +0.0 + 11 +100.0 + 21 +50.0 + 31 +0.0 + 0 +LINE + 8 +LOCKED + 10 +0.0 + 20 +60.0 + 30 +0.0 + 11 +100.0 + 21 +60.0 + 31 +0.0 + 0 +LINE + 8 +VISIBLE +100 +AcDbEntity + 60 +1 +100 +AcDbLine + 10 +0.0 + 20 +100.0 + 30 +0.0 + 11 +100.0 + 21 +100.0 + 31 +0.0 + 0 +LINE + 8 +VISIBLE +100 +AcDbEntity + 6 +DASHED +370 +50 + 48 +2.0 +100 +AcDbLine + 10 +0.0 + 20 +110.0 + 30 +0.0 + 11 +100.0 + 21 +110.0 + 31 +0.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/infinite_lines_r2000.dxf b/crates/uncad/tests/fixtures/infinite_lines_r2000.dxf new file mode 100644 index 0000000..cc07047 --- /dev/null +++ b/crates/uncad/tests/fixtures/infinite_lines_r2000.dxf @@ -0,0 +1,120 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +30 + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.002 + 21 +0.002 + 31 +0.0 + 0 +TEXT + 5 +31 + 8 +0 + 10 +0.0013 + 20 +0.0002 + 30 +0.0 + 40 +0.0006 + 1 +X + 0 +XLINE + 5 +32 + 8 +0 +100 +AcDbXline + 10 +0.001 + 20 +0.001 + 30 +0.0 + 11 +1.0 + 21 +0.0 + 31 +0.0 + 0 +RAY + 5 +33 + 8 +0 +100 +AcDbRay + 10 +0.001 + 20 +0.001 + 30 +0.0 + 11 +0.0 + 21 +1.0 + 31 +0.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/make_fixtures.py b/crates/uncad/tests/fixtures/make_fixtures.py new file mode 100644 index 0000000..d9ef43b --- /dev/null +++ b/crates/uncad/tests/fixtures/make_fixtures.py @@ -0,0 +1,1139 @@ +"""Writes the DXF test fixtures (see README.md next to this file). + +Every file is authored here from scratch (no third-party drawing is copied). +Text is written as raw bytes so the CP949 fixture carries real code-page +bytes, not UTF-8; line endings are CRLF. + + python make_fixtures.py [out_dir] [which] + +`which` defaults to `all` (the shipped DXF files, in their verified "full" +form: the dimension fixture carries a BLOCK_RECORD table so its *D1 block +binds, the viewport fixture a *Paper_Space block so the VIEWPORT lands in +paper space plus a LAYOUT object with plot settings for that block). +`dimlfac-minimal` and `viewport-minimal` write the earlier ENTITIES-only +variants README.md describes, kept for comparison. +""" +import os +import sys + +OUT = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] else os.path.dirname(os.path.abspath(__file__)) + + +def pair(code, value): + """One DXF group: right-aligned 3-char code, value, each on its own line.""" + if isinstance(value, bytes): + v = value + elif isinstance(value, float): + v = repr(value).encode("ascii") + else: + v = str(value).encode("ascii") + return b"%3d\r\n" % code + v + b"\r\n" + + +def pairs(*items): + return b"".join(pair(c, v) for c, v in items) + + +def section(name, body): + return pairs((0, "SECTION"), (2, name)) + body + pairs((0, "ENDSEC")) + + +def header(acadver="AC1015", **vars_): + """HEADER section. vars_ maps NAME (without $) to a (code, value) pair.""" + body = pairs((9, "$ACADVER"), (1, acadver)) + for name, spec in vars_.items(): + body += pair(9, "$" + name) + if isinstance(spec, list): + body += pairs(*spec) + else: + body += pair(*spec) + return section("HEADER", body) + + +def entity(kind, layer=b"0", *groups, handle=None, owner=None, paper=False): + out = pair(0, kind) + if handle is not None: + out += pair(5, handle) + if owner is not None: + out += pair(330, owner) + out += pair(8, layer) + if paper: + out += pair(67, 1) + out += pairs(*groups) + return out + + +def text(value, x, y, height=2.5, layer=b"0", extrusion=None, handle=None, owner=None, + paper=False): + g = [(10, float(x)), (20, float(y)), (30, 0.0), (40, float(height)), (1, value)] + if extrusion is not None: + g += [(210, float(extrusion[0])), (220, float(extrusion[1])), (230, float(extrusion[2]))] + return entity("TEXT", layer, *g, handle=handle, owner=owner, paper=paper) + + +def line(x1, y1, x2, y2, layer=b"0", handle=None, owner=None, paper=False, + color=None, true_color=None): + """A LINE. `color` is the DXF 62 ACI index (0 BYBLOCK, 256 BYLAYER) and + `true_color` the DXF 420 packed RGB, written in that order -- which is + the order AutoCAD writes them and the one LibreDWG's reader needs, since + a later 62 overwrites the colour a 420 stored.""" + color_groups = [] + if color is not None: + color_groups.append((62, color)) + if true_color is not None: + color_groups.append((420, true_color)) + return entity( + "LINE", layer, *color_groups, + (10, float(x1)), (20, float(y1)), (30, 0.0), + (11, float(x2)), (21, float(y2)), (31, 0.0), + handle=handle, owner=owner, paper=paper, + ) + + +def polyline(subclass, vertex_subclass, vertices, groups=(), vertex_flag=None, + layer=b"0", handle=None, owner=None, vertex_handles=(), seqend=None, + vertex_owner=None, faces=()): + """An old-style POLYLINE with its VERTEX chain and SEQEND -- the shape + every DXF uses for a 2D/3D polyline, a polyface mesh and a polygon mesh + (they differ only in the two subclass markers and the group 70 flags). + + `vertices` are (x, y, z) or (x, y, z, bulge); `faces` are extra VERTEX + records holding 1-based vertex indices (`AcDbFaceRecord`, group 71-74). + `vertex_owner` is what the VERTEX records name in group 330: AutoCAD + writes the POLYLINE's own handle there, ezdxf writes the block record, + and LibreDWG's DXF reader picks a polyface vertex's *type* from it.""" + g = [(100, "AcDbEntity"), (100, subclass), (66, 1), + (10, 0.0), (20, 0.0), (30, 0.0)] + g += list(groups) + out = entity("POLYLINE", layer, *g, handle=handle, owner=owner) + vowner = vertex_owner if vertex_owner is not None else handle + for i, v in enumerate(vertices): + vg = [(100, "AcDbEntity"), (100, "AcDbVertex"), (100, vertex_subclass), + (10, float(v[0])), (20, float(v[1])), (30, float(v[2]))] + if len(v) > 3: + vg.append((42, float(v[3]))) + if vertex_flag is not None: + vg.append((70, vertex_flag)) + out += entity("VERTEX", layer, *vg, + handle=vertex_handles[i] if vertex_handles else None, + owner=vowner) + for i, face in enumerate(faces): + fg = [(100, "AcDbEntity"), (100, "AcDbFaceRecord"), + (10, 0.0), (20, 0.0), (30, 0.0), (70, 128)] + fg += [(71 + j, int(idx)) for j, idx in enumerate(face)] + out += entity("VERTEX", layer, *fg, + handle=vertex_handles[len(vertices) + i] if vertex_handles else None, + owner=vowner) + out += entity("SEQEND", layer, (100, "AcDbEntity"), handle=seqend, owner=handle) + return out + + +def lwpolyline(points, closed, extrusion=None, bulges=None, layer=b"0", + handle=None, owner=None, paper=False): + g = [(100, "AcDbPolyline"), (90, len(points)), (70, 1 if closed else 0), (43, 0.0)] + if extrusion is not None: + g += [(210, float(extrusion[0])), (220, float(extrusion[1])), (230, float(extrusion[2]))] + for i, (x, y) in enumerate(points): + g += [(10, float(x)), (20, float(y))] + if bulges and bulges.get(i): + g += [(42, float(bulges[i]))] + return entity("LWPOLYLINE", layer, *g, handle=handle, owner=owner, paper=paper) + + +def construction(kind, x, y, dx, dy, layer=b"0", handle=None, owner=None): + """A RAY or an XLINE: base point (10) and unit direction (11).""" + return entity( + kind, layer, + (100, "AcDbXline" if kind == "XLINE" else "AcDbRay"), + (10, float(x)), (20, float(y)), (30, 0.0), + (11, float(dx)), (21, float(dy)), (31, 0.0), + handle=handle, owner=owner, + ) + + +def circle(cx, cy, r, extrusion=None, layer=b"0"): + g = [(10, float(cx)), (20, float(cy)), (30, 0.0), (40, float(r))] + if extrusion is not None: + g += [(210, float(extrusion[0])), (220, float(extrusion[1])), (230, float(extrusion[2]))] + return entity("CIRCLE", layer, *g) + + +def arc(cx, cy, r, a0, a1, extrusion=None, layer=b"0"): + g = [(10, float(cx)), (20, float(cy)), (30, 0.0), (40, float(r)), (50, float(a0)), (51, float(a1))] + if extrusion is not None: + g += [(210, float(extrusion[0])), (220, float(extrusion[1])), (230, float(extrusion[2]))] + return entity("ARC", layer, *g) + + +def viewport(handle, owner, center, size, view_center, view_size, twist_deg=0.0, + vp_id=2, on=1, status=32864, view_dir=(0.0, 0.0, 1.0), layer=b"0", + lens=50.0): + """A paper-space VIEWPORT (`AcDbViewport`). `center`/`size` are the + frame on the sheet in paper units, `view_center` (DXF 12, VIEWCTR) and + `view_size` (45, VIEWSIZE) the model window it shows, so the scale is + `size[1] / view_size`; `twist_deg` is DXF 51 (the reader converts it to + radians). `on` is DXF 68 (0 switches the viewport off in a DXF) and + `status` DXF 90, whose 0x20000 bit is a DWG's own "off"; `vp_id` is + DXF 69 (1 marks the sheet's overall viewport). `view_dir` is DXF 16, + VIEWDIR -- anything but (0,0,1) is a non-plan view.""" + cx, cy = center + w, h = size + vx, vy = view_center + dx, dy, dz = view_dir + return entity( + "VIEWPORT", layer, + (100, "AcDbViewport"), + (10, float(cx)), (20, float(cy)), (30, 0.0), + (40, float(w)), (41, float(h)), + (68, on), (69, vp_id), + (12, float(vx)), (22, float(vy)), + (13, 0.0), (23, 0.0), + (14, 10.0), (24, 10.0), + (15, 10.0), (25, 10.0), + (16, float(dx)), (26, float(dy)), (36, float(dz)), + (17, 0.0), (27, 0.0), (37, 0.0), + (42, float(lens)), (43, 0.0), (44, 0.0), + (45, float(view_size)), + (50, 0.0), + (51, float(twist_deg)), + (72, 100), + (90, status), + handle=handle, owner=owner, paper=True, + ) + + +def hatch(points, angle_deg, spacing, handle=None, owner=None, paper=False, layer=b"0"): + """A pattern HATCH (not solid) over the closed polyline `points`: one + user-defined family of lines at `angle_deg`, `spacing` apart, no dashes. + The definition line's offset (45/46) is the perpendicular to the line + direction scaled to `spacing`; the seed point sits one unit inside the + first vertex.""" + import math + a = math.radians(angle_deg) + # Rounded so a right angle writes 0.0, not 1.2e-16 or -0.0. + ox, oy = round(-math.sin(a) * spacing, 12) + 0.0, round(math.cos(a) * spacing, 12) + 0.0 + g = [ + (100, "AcDbHatch"), + (10, 0.0), (20, 0.0), (30, 0.0), # elevation point + (210, 0.0), (220, 0.0), (230, 1.0), # extrusion + (2, "USER"), # pattern name + (70, 0), # pattern fill, not solid + (71, 0), # not associative + (91, 1), # one boundary path + (92, 3), # external + polyline + (72, 0), # no bulges + (73, 1), # closed + (93, len(points)), + ] + for x, y in points: + g += [(10, float(x)), (20, float(y))] + g += [ + (97, 0), # no source boundary objects + (75, 1), # hatch style: outermost + (76, 0), # user-defined pattern + (52, 0.0), # pattern angle + (41, 1.0), # pattern scale + (77, 0), # not double + (78, 1), # one definition line + (53, float(angle_deg)), + (43, 0.0), (44, 0.0), # base point + (45, ox), (46, oy), # offset + (79, 0), # no dash items + (47, 1.0), # pixel size + (98, 1), # one seed point + (10, float(points[0][0]) + 1.0), (20, float(points[0][1]) + 1.0), + ] + return entity("HATCH", layer, *g, handle=handle, owner=owner, paper=paper) + + +def tables(layers=((b"0", 7),), dimstyle=False, block_records=(), dimlfac=None): + """TABLES section: a LAYER table, optionally DIMSTYLE STANDARD (handle + 30, with DIMLFAC group 144 when `dimlfac` is given -- a dimension uses + its style's factor, not the header's) and a BLOCK_RECORD table whose + entries carry explicit handles so BLOCK/entity `330` owner codes can + refer to them; a third element on a block record is the handle of its + LAYOUT object (`340`). A layer is `(name, colour)` or + `(name, colour, flag)` with the DXF 70 flag (1 frozen, 4 locked); a + negative colour means the layer is off.""" + body = pairs((0, "TABLE"), (2, "LAYER"), (70, len(layers))) + for name, color, *rest in layers: + flag = rest[0] if rest else 0 + body += pairs((0, "LAYER"), (2, name), (70, flag), (62, color), (6, "Continuous")) + body += pair(0, "ENDTAB") + if dimstyle: + body += pairs( + (0, "TABLE"), (2, "DIMSTYLE"), (70, 1), + (0, "DIMSTYLE"), (105, "30"), (2, "STANDARD"), (70, 0), + *(((144, dimlfac),) if dimlfac is not None else ()), + (0, "ENDTAB"), + ) + if block_records: + body += pairs((0, "TABLE"), (2, "BLOCK_RECORD"), (70, len(block_records))) + for handle, name, *layout in block_records: + body += pairs((0, "BLOCK_RECORD"), (5, handle), (2, name), (70, 0)) + if layout: + body += pair(340, layout[0]) + body += pair(0, "ENDTAB") + return section("TABLES", body) + + +def block(name, owner, handles, body=b"", paper=False, flag=0): + """A BLOCK ... ENDBLK pair, owned by BLOCK_RECORD `owner` when given.""" + h_begin, h_end = handles + begin = entity( + "BLOCK", b"0", + (100, "AcDbBlockBegin"), (2, name), (70, flag), + (10, 0.0), (20, 0.0), (30, 0.0), (3, name), + handle=h_begin, owner=owner, paper=paper, + ) + end = entity("ENDBLK", b"0", (100, "AcDbBlockEnd"), handle=h_end, owner=owner, paper=paper) + return begin + body + end + + +def dictionary(handle, owner, items): + """A DICTIONARY object (OBJECTS section) whose `items` are (name, handle) + soft-owner entries (`3` / `350`).""" + body = pairs((0, "DICTIONARY"), (5, handle), (330, owner), (100, "AcDbDictionary"), (281, 1)) + for name, target in items: + body += pairs((3, name), (350, target)) + return body + + +def layout(handle, owner, name, tab_order, block_record, viewport, paper, + printer="none_device", margin=6.35, rotation=1, margins=None, + plot_origin=(0.0, 0.0), paper_units=1): + """A LAYOUT object: the embedded AcDbPlotSettings (page setup, all + lengths in mm) followed by AcDbLayout. `paper` is (canonical media + name, width, height) of the unrotated sheet; `rotation` 1 is 90 degrees + counter-clockwise (landscape). `margins` is (left, bottom, right, top) + in mm (default: `margin` on every side), `plot_origin` the DXF 46/47 + offset in mm and `paper_units` DXF 72 (1 mm, 0 inches: the layout's own + unit, which LIMMIN/LIMMAX are written in). LIMMIN/LIMMAX follow + AutoCAD's placement of the sheet: the layout origin is the printable + corner moved by the plot origin, so the paper runs from + -(margin + origin) to that plus the rotated size (ezdxf's + `reset_paper_limits`; verified against seven AutoCAD-written layouts on + 2026-09-22). EXTMIN/EXTMAX carry the 1e20 / -1e20 "never computed" + sentinels AutoCAD writes for a layout that has not been plotted or + zoomed. `owner` is the ACAD_LAYOUT dictionary; the AcDbLayout `330` is + the block record and `331` the active viewport.""" + media, w, h = paper + if rotation in (1, 3): + w, h = h, w + left, bottom, right, top = margins if margins is not None else (margin,) * 4 + ox, oy = plot_origin + k = 1.0 / 25.4 if paper_units == 0 else 1.0 + shift_x, shift_y = left + ox, bottom + oy + r = lambda v: round(v, 9) + 0.0 # noqa: E731 -- no float noise, no -0.0 + plot = [ + (100, "AcDbPlotSettings"), + (1, b""), # page setup name + (2, printer), # printer / plot configuration + (4, media), # canonical media name + (40, float(left)), (41, float(bottom)), (42, float(right)), (43, float(top)), + (44, float(paper[1])), (45, float(paper[2])), + (46, float(ox)), (47, float(oy)), # plot origin + (48, 0.0), (49, 0.0), # plot window lower-left + (140, 0.0), (141, 0.0), # plot window upper-right + (142, 1.0), (143, 1.0), # paper units : drawing units + (70, 688), # plot flags + (72, paper_units), # plot paper unit: 1 mm, 0 in + (73, rotation), # plot rotation + (74, 5), # plot type: layout + (7, b""), # style sheet + (75, 16), # standard scale: 1:1 + (147, 1.0), # standard scale factor + (148, 0.0), (149, 0.0), # paper image origin + ] + lay = [ + (100, "AcDbLayout"), + (1, name), + (70, 1), # layout flags: PSLTSCALE + (71, tab_order), + (10, r(-shift_x * k)), (20, r(-shift_y * k)), # LIMMIN + (11, r((w - shift_x) * k)), (21, r((h - shift_y) * k)), # LIMMAX + (12, 0.0), (22, 0.0), (32, 0.0), # INSBASE + (14, 1e20), (24, 1e20), (34, 1e20), # EXTMIN + (15, -1e20), (25, -1e20), (35, -1e20), # EXTMAX + (146, 0.0), # elevation + (13, 0.0), (23, 0.0), (33, 0.0), # UCSORG + (16, 1.0), (26, 0.0), (36, 0.0), # UCSXDIR + (17, 0.0), (27, 1.0), (37, 0.0), # UCSYDIR + (76, 0), # UCSORTHOVIEW + (330, block_record), + (331, viewport), + ] + return pairs((0, "LAYOUT"), (5, handle), (330, owner), *plot, *lay) + + +def write(name, data): + os.makedirs(OUT, exist_ok=True) + path = os.path.join(OUT, name) + with open(path, "wb") as fh: + fh.write(data) + print(f"{name}: {len(data)} bytes") + + +# ---------------------------------------------------------------- fixture 1 +def cp949(): + k = lambda s: s.encode("cp949") # noqa: E731 + # KS X 1001 row 1: A1BE is PLUS-MINUS SIGN (A1B1 is the right double + # quotation mark); the CP949 codec is the authority here. + assert k("±") == b"\xa1\xbe", k("±") + hdr = header( + DWGCODEPAGE=(3, "ANSI_949"), + INSUNITS=(70, 4), + MEASUREMENT=(70, 1), + LUNITS=(70, 2), + DIMLFAC=(40, 1.0), + ) + tbl = tables(layers=((b"0", 7), (k("벽체"), 1))) + ents = b"".join([ + text(k("도면"), 0, 0, layer=k("벽체")), + text(k("±3"), 0, 5), + text(k("32.5㎡"), 0, 10), + entity( + "MTEXT", b"0", + (100, "AcDbMText"), + (10, 0.0), (20, 20.0), (30, 0.0), + (40, 2.5), (41, 50.0), (71, 1), + (1, k("방 101\\P면적 32.5㎡")), + ), + text(b"PLAIN", 0, 15), + ]) + return hdr + tbl + section("ENTITIES", ents) + pair(0, "EOF") + + +# ---------------------------------------------------------------- fixture 2 +def mirrored(): + hdr = header(INSUNITS=(70, 4)) + rect = [(0, 0), (100, 0), (100, 50), (0, 50)] + ents = b"".join([ + lwpolyline(rect, closed=True, extrusion=(0, 0, -1)), + lwpolyline(rect, closed=False, extrusion=(0, 0, 1), bulges={1: 0.41421356}), + circle(10, 10, 5, extrusion=(0, 0, -1)), + arc(0, 0, 20, 0, 90, extrusion=(0, 0, -1)), + text(b"MIRROR", 10, 10, height=2.5, extrusion=(0, 0, -1)), + line(-5, -5, 5, 5), + ]) + return hdr + section("ENTITIES", ents) + pair(0, "EOF") + + +# ---------------------------------------------------------------- fixture 2b +def mirrored_bulge(): + """The bulged outline of the mirrored fixture (handle 21 there) drawn in + a mirrored OCS, next to an ARC tracing the same arc in the same OCS: the + 90-degree arc from OCS (100,0) to (100,50) has its centre at (75,25) and + its apex at (110.355,25), so in the world both curves run from (-100,0) + to (-100,50) around (-75,25) with the apex at (-110.355,25).""" + hdr = header(INSUNITS=(70, 4)) + rect = [(0, 0), (100, 0), (100, 50), (0, 50)] + ents = b"".join([ + lwpolyline(rect, closed=False, extrusion=(0, 0, -1), bulges={1: 0.41421356}), + arc(75, 25, 35.35533906, 315, 45, extrusion=(0, 0, -1)), + ]) + return hdr + section("ENTITIES", ents) + pair(0, "EOF") + + +# ---------------------------------------------------------------- fixture 3 +def dimlfac12(variant="full"): + hdr = header( + INSUNITS=(70, 4), + DIMLFAC=(40, 12.0), + DIMDEC=(70, 2), + DIMLUNIT=(70, 2), + DIMSCALE=(40, 1.0), + ) + if variant == "full": + # Verified 2026-09-21: *Model_Space keeps the reader's pre-created handle 1F, + # *D1 gets 40, and BLOCK/TEXT/ENDBLK point at it through 330. + pre = tables(dimstyle=True, block_records=(("1F", "*Model_Space"), ("40", "*D1")), dimlfac=12.0) + pre += section("BLOCKS", block( + "*D1", "40", ("41", "43"), flag=1, + body=text(b"120", 5, 6, height=0.18, handle="42", owner="40"), + )) + else: + # Shipped: the BLOCK is read but binds to no BLOCK_HEADER (README). + pre = section("BLOCKS", block( + "*D1", None, (None, None), flag=1, + body=text(b"120", 5, 6, height=0.18), + )) + dim = entity( + "DIMENSION", b"0", + (100, "AcDbDimension"), + (2, "*D1"), + (10, 10.0), (20, 5.0), (30, 0.0), + (11, 5.0), (21, 6.0), (31, 0.0), + (70, 32), + (1, b""), + (42, 10.0), + (3, "STANDARD"), + (100, "AcDbAlignedDimension"), + (13, 0.0), (23, 0.0), (33, 0.0), + (14, 10.0), (24, 0.0), (34, 0.0), + (50, 0.0), + (100, "AcDbRotatedDimension"), + ) + ents = line(0, 0, 10, 0) + dim + return hdr + pre + section("ENTITIES", ents) + pair(0, "EOF") + + +# ---------------------------------------------------------------- fixture 4 +def twisted_viewport(variant="full"): + hdr = header(INSUNITS=(70, 4)) + pre = b"" + post = b"" + owner = None + line_owner = None + if variant == "full": + # Verified 2026-09-21: a *Paper_Space BLOCK_RECORD/BLOCK so that the VIEWPORT's + # 330 owner equals BLOCK_RECORD_PSPACE and it gets entmode 1. + pre = tables(block_records=(("1F", "*Model_Space"), ("1C", "*Paper_Space", "2B"))) + pre += section("BLOCKS", + block("*Model_Space", "1F", ("20", "21")) + + block("*Paper_Space", "1C", ("22", "23"), paper=True)) + owner = "1C" + line_owner = "1F" + # Verified 2026-09-22: an OBJECTS section with the named object + # dictionary (C), its ACAD_LAYOUT dictionary (1A) and one LAYOUT (2B) + # for *Paper_Space, an A4 sheet in landscape. LibreDWG builds the + # LAYOUT from the object alone; the dictionaries are what lets it set + # HEADER.DICTIONARY_LAYOUT (README.md). No Model layout. + post = section("OBJECTS", + dictionary("C", "0", (("ACAD_LAYOUT", "1A"),)) + + dictionary("1A", "C", (("Layout1", "2B"),)) + + layout("2B", "1A", "Layout1", 1, block_record="1C", viewport="2A", + paper=("ISO_A4_(210.00_x_297.00_MM)", 210.0, 297.0))) + vp = viewport("2A", owner, (150.0, 100.0), (200.0, 120.0), (50.0, 25.0), 60.0, + twist_deg=30.0) + ents = line(0, 0, 100, 50, handle="24" if line_owner else None, owner=line_owner) + vp + return hdr + pre + section("ENTITIES", ents) + post + pair(0, "EOF") + + +def hatched_viewport(): + """The twisted-viewport fixture (its full form: tables, blocks, the + LAYOUT) with a pattern HATCH in each space: vertical lines 2 units apart + over model (20,10)-(60,30), under the viewport, and horizontal lines 4 + units apart over paper (10,10)-(40,30), outside the viewport's frame + (x 50..250, y 40..160). Both hatches are the first pattern of their + render, so a composited sheet that did not namespace its would + define `hp0` twice.""" + hdr = header(INSUNITS=(70, 4)) + pre = tables(block_records=(("1F", "*Model_Space"), ("1C", "*Paper_Space", "2B"))) + pre += section("BLOCKS", + block("*Model_Space", "1F", ("20", "21")) + + block("*Paper_Space", "1C", ("22", "23"), paper=True)) + post = section("OBJECTS", + dictionary("C", "0", (("ACAD_LAYOUT", "1A"),)) + + dictionary("1A", "C", (("Layout1", "2B"),)) + + layout("2B", "1A", "Layout1", 1, block_record="1C", viewport="2A", + paper=("ISO_A4_(210.00_x_297.00_MM)", 210.0, 297.0))) + vp = viewport("2A", "1C", (150.0, 100.0), (200.0, 120.0), (50.0, 25.0), 60.0, + twist_deg=30.0) + ents = (line(0, 0, 100, 50, handle="24", owner="1F") + + hatch([(20, 10), (60, 10), (60, 30), (20, 30)], 90.0, 2.0, handle="30", owner="1F") + + vp + + hatch([(10, 10), (40, 10), (40, 30), (10, 30)], 0.0, 4.0, handle="31", owner="1C", paper=True)) + return hdr + pre + section("ENTITIES", ents) + post + pair(0, "EOF") + + +def hidden_layers(): + """Every way an entity can be hidden: a LINE on each of the layers below + (one per 10 units of y), plus an invisible LINE (DXF 60 = 1) and a + 0.50 mm DASHED one (370 = 50, 6 = DASHED, 48 = 2.0) on VISIBLE.""" + hdr = header(INSUNITS=(70, 4)) + # (name, colour, DXF 70 flag, DXF 290 plot flag or None to omit) + layers = [ + (b"0", 7, 0, None), + (b"VISIBLE", 1, 0, None), + (b"OFF", -3, 0, None), # negative colour: the layer is off + (b"FROZEN", 4, 1, None), # 70 bit 1: frozen + (b"NOPLOT", 5, 0, 0), # 290 = 0: not plotted + (b"Defpoints", 7, 0, 0), # AutoCAD's own non-plotting layer + (b"LOCKED", 6, 4, None), # 70 bit 4: locked (still drawn) + ] + body = pairs((0, "TABLE"), (2, "LTYPE"), (70, 2)) + body += pairs( + (0, "LTYPE"), (5, "14"), (100, "AcDbSymbolTableRecord"), (100, "AcDbLinetypeTableRecord"), + (2, "Continuous"), (70, 0), (3, "Solid line"), (72, 65), (73, 0), (40, 0.0), + ) + body += pairs( + (0, "LTYPE"), (5, "15"), (100, "AcDbSymbolTableRecord"), (100, "AcDbLinetypeTableRecord"), + (2, "DASHED"), (70, 0), (3, "Dashed __ __ __"), (72, 65), (73, 2), (40, 0.75), + (49, 0.5), (74, 0), (49, -0.25), (74, 0), + ) + body += pair(0, "ENDTAB") + body += pairs((0, "TABLE"), (2, "LAYER"), (70, len(layers))) + for name, color, flag, plot in layers: + body += pairs((0, "LAYER"), (2, name), (70, flag), (62, color), (6, "Continuous")) + if plot is not None: + body += pair(290, plot) + body += pair(0, "ENDTAB") + tbl = section("TABLES", body) + ents = b"" + for i, (name, _color, _flag, _plot) in enumerate(layers): + ents += line(0, 10 * i, 100, 10 * i, layer=name) + ents += entity( + "LINE", b"VISIBLE", (100, "AcDbEntity"), (60, 1), (100, "AcDbLine"), + (10, 0.0), (20, 100.0), (30, 0.0), (11, 100.0), (21, 100.0), (31, 0.0), + ) + ents += entity( + "LINE", b"VISIBLE", (100, "AcDbEntity"), (6, "DASHED"), (370, 50), (48, 2.0), (100, "AcDbLine"), + (10, 0.0), (20, 110.0), (30, 0.0), (11, 100.0), (21, 110.0), (31, 0.0), + ) + return hdr + tbl + section("ENTITIES", ents) + pair(0, "EOF") + + +# ---------------------------------------------------------------- fixture 8 +def attdef(tag, prompt, default, x, y, height, handle, owner): + """An ATTDEF: the attribute *template* a block definition carries.""" + return entity( + "ATTDEF", b"0", + (100, "AcDbEntity"), (100, "AcDbText"), + (10, float(x)), (20, float(y)), (30, 0.0), (40, float(height)), (1, default), + (100, "AcDbAttributeDefinition"), (3, prompt), (2, tag), (70, 0), + handle=handle, owner=owner, + ) + + +def attrib(tag, value, x, y, height, handle, owner): + """An ATTRIB: one INSERT's value for a tag. `owner` (DXF 330) decides the + shape LibreDWG builds -- the owning INSERT links it into that INSERT's + attribute chain, the block record makes it a child of the block.""" + return entity( + "ATTRIB", b"0", + (100, "AcDbEntity"), (100, "AcDbText"), + (10, float(x)), (20, float(y)), (30, 0.0), (40, float(height)), (1, value), + (100, "AcDbAttribute"), (2, tag), (70, 0), + handle=handle, owner=owner, + ) + + +def insert(name, x, y, handle, owner, attribs=None, seqend=None, layer=b"0", paper=False): + """An INSERT, with its ATTRIB chain (DXF 66 = 1) and SEQEND when given.""" + groups = [(100, "AcDbEntity"), (100, "AcDbBlockReference")] + if attribs: + groups.append((66, 1)) + groups += [(2, name), (10, float(x)), (20, float(y)), (30, 0.0)] + out = entity("INSERT", layer, *groups, handle=handle, owner=owner, paper=paper) + if attribs: + out += attribs + out += entity("SEQEND", layer, (100, "AcDbEntity"), handle=seqend, + owner=owner, paper=paper) + return out + + +def nested_attrib(): + """An attributed block inside another block -- the tag-inside-assembly + pattern -- so the export has to find a nested INSERT's attribute value. + + Block TAG is a LINE and an ATTDEF `NUM`; block DOOR is two LINEs and an + INSERT of TAG at (20, 20) whose ATTRIB `NUM = D-101` is owned (DXF 330) + by the *block record*, the shape ezdxf- and AutoCAD-written DXFs use and + the one LibreDWG turns into a child of DOOR. Model space holds INSERT + `60` of DOOR at (100, 100) -- so the nested value is drawn at + (100+21, 100+21) and belongs to the text id `60/56` -- and INSERT `61` + of TAG at (0, 0) with its own top-level ATTRIB `NUM = D-TOP`, the + control that always worked.""" + hdr = header(INSUNITS=(70, 4)) + pre = tables(block_records=(("1F", "*Model_Space"), ("40", "TAG"), ("50", "DOOR"))) + tag_body = (line(0, 0, 10, 0, handle="42", owner="40") + + attdef("NUM", "Number", b"D-000", 1, 1, 2.5, "43", "40")) + door_body = (line(0, 0, 40, 0, handle="52", owner="50") + + line(0, 0, 0, 40, handle="53", owner="50") + + insert("TAG", 20, 20, "55", "50", + attribs=attrib("NUM", b"D-101", 21, 21, 2.5, "56", "50"), + seqend="57")) + pre += section("BLOCKS", + block("TAG", "40", ("41", "44"), body=tag_body) + + block("DOOR", "50", ("51", "54"), body=door_body)) + ents = insert("DOOR", 100, 100, "60", "1F") + ents += insert("TAG", 0, 0, "61", "1F", + attribs=attrib("NUM", b"D-TOP", 1, 1, 2.5, "62", "1F"), + seqend="63") + return hdr + pre + section("ENTITIES", ents) + pair(0, "EOF") + + +# ---------------------------------------------------------------- fixture 6 +def plot_origin(): + """A paper layout with the page setup AutoCAD-written drawings usually + carry: inch units, asymmetric margins and a plot origin that is not + zero, so the sheet is not at `(-left, -bottom)`. ANSI B landscape + (431.8 x 279.4 mm = 17 x 11 in) unrotated, margins (0.25, 0.75, 0.25, + 0.75) in, plot origin (-0.25, -0.5) in: the layout origin is the + printable corner moved by the origin, so the sheet runs from + (-(0.25 - 0.25), -(0.75 - 0.5)) = (0, -0.25) to (17, 10.75), which is + what LIMMIN/LIMMAX say. On paper: a border rectangle (0.5, 0.25) .. + (16.5, 10.5) -- its top edge lies above the 10.25 a margins-only sheet + would end at -- and a 12 x 8 in plan viewport at (8.5, 5.5) showing the + model at 1:5 (VIEWSIZE 40 for a height of 8) centred on (50, 25). The + model holds one LINE (0,0) -> (100,50). Same block/dictionary layout and + handles as the twisted-viewport fixture.""" + hdr = header(INSUNITS=(70, 1)) + pre = tables(block_records=(("1F", "*Model_Space"), ("1C", "*Paper_Space", "2B"))) + pre += section("BLOCKS", + block("*Model_Space", "1F", ("20", "21")) + + block("*Paper_Space", "1C", ("22", "23"), paper=True)) + post = section("OBJECTS", + dictionary("C", "0", (("ACAD_LAYOUT", "1A"),)) + + dictionary("1A", "C", (("Layout1", "2B"),)) + + layout("2B", "1A", "Layout1", 1, block_record="1C", viewport="2A", + paper=("ANSI_B_(17.00_x_11.00_Inches)", 431.8, 279.4), + rotation=0, margins=(6.35, 19.05, 6.35, 19.05), + plot_origin=(-6.35, -12.7), paper_units=0)) + vp = viewport("2A", "1C", (8.5, 5.5), (12.0, 8.0), (50.0, 25.0), 40.0) + border = lwpolyline([(0.5, 0.25), (16.5, 0.25), (16.5, 10.5), (0.5, 10.5)], closed=True, + handle="25", owner="1C", paper=True) + ents = line(0, 0, 100, 50, handle="24", owner="1F") + border + vp + return hdr + pre + section("ENTITIES", ents) + post + pair(0, "EOF") + + +# ---------------------------------------------------------------- fixture 7 +def angular_ordinate(): + """The two dimension kinds whose definition points a DXF lays out + differently from a DWG (LibreDWG maps DXF groups by code, its DWG + decoder by stream order): a 2-line angular dimension and an ordinate + dimension of each type. Every value below is derived by hand. + + The angular dimension (AcDb2LineAngularDimension, 70 = 2 | 32): line 1 + from 13 = (0,0) to 14 = (10,0), line 2 from 15 = (0,0) to 10 = (5, + 8.660254) -- for this kind group 10 is the second line's end point -- + and the arc point 16 = (4.330127, 2.5), 30 degrees along a radius of + 5, inside the 60-degree sector; 42 = pi/3. Read with 10 and 16 swapped + the probe would sit at 60 degrees between rays at 30 and 180 degrees, + i.e. 150. + + The ordinates (AcDbOrdinateDimension) share the datum origin 10 = + (100, 200) and the feature 13 = (130, 250); 14 is the leader end. The + first has bit 64 of 70 set (70 = 6 | 32 | 64 = 102): an X ordinate, + 130 - 100 = 30 (42 = 30.0). The second has 70 = 38: a Y ordinate, + 250 - 200 = 50 (42 = 50.0). No cached *D blocks, so the labels are + formatted from the values: DIMADEC 0 and DIMDEC 2 from the header.""" + hdr = header(INSUNITS=(70, 4), DIMDEC=(70, 2), DIMADEC=(70, 0), DIMLUNIT=(70, 2)) + tbl = tables(dimstyle=True) + common = lambda block: [(100, "AcDbDimension"), (2, block)] # noqa: E731 + angular = entity( + "DIMENSION", b"0", + *common("*D1"), + (10, 5.0), (20, 8.660254037844386), (30, 0.0), + (11, 6.0), (21, 3.5), (31, 0.0), + (70, 34), + (1, b""), + (42, 1.0471975511965976), + (3, "STANDARD"), + (100, "AcDb2LineAngularDimension"), + (13, 0.0), (23, 0.0), (33, 0.0), + (14, 10.0), (24, 0.0), (34, 0.0), + (15, 0.0), (25, 0.0), (35, 0.0), + (16, 4.330127018922194), (26, 2.5), (36, 0.0), + ) + + def ordinate(block, flag, value, leader): + return entity( + "DIMENSION", b"0", + *common(block), + (10, 100.0), (20, 200.0), (30, 0.0), + (11, float(leader[0])), (21, float(leader[1])), (31, 0.0), + (70, flag), + (1, b""), + (42, float(value)), + (3, "STANDARD"), + (100, "AcDbOrdinateDimension"), + (13, 130.0), (23, 250.0), (33, 0.0), + (14, float(leader[0])), (24, float(leader[1])), (34, 0.0), + ) + + ents = (line(0, 0, 10, 0) + line(0, 0, 5, 8.660254037844386) + angular + + ordinate("*D2", 102, 30.0, (130, 270)) + + ordinate("*D3", 38, 50.0, (150, 250))) + return hdr + tbl + section("ENTITIES", ents) + pair(0, "EOF") + + +# ---------------------------------------------------------------- fixture 8 +def viewport_states(): + """Every viewport state the sheet compositing rules distinguish, and two + page setups the other layout fixtures do not carry. + + `Layout1` (`*Paper_Space`, tab 1): A4 210 x 297 mm with plot rotation 2 + (upside down, so the sheet keeps its portrait size), asymmetric margins + (10, 20, 5, 15) mm and no plot origin, so the sheet runs from + (-10, -20) to (200, 277) mm. On it, four 60 x 40 viewports, each + showing the model window 30 x 20 around (50, 25) at scale 40 / 20 = 2: + + - `2A` at (50, 50): on, plan, layer 0 -- composited, border drawn. + - `2D` at (50, 120): the same, but on the frozen layer `VPFROZEN`, the + usual way to hide a viewport's border -- composited, no border. + - `2E` at (50, 190): switched off (DXF 68 = 0 and the status flag's + 0x20000 bit, 32864 | 0x20000 = 163936) -- not composited. + - `2F` at (140, 50): VIEWDIR (1,1,1), an isometric view -- not + composited (the compositing transform is a plan-view mapping). + + `Layout2` (`*Paper_Space0`, tab 2, empty): ANSI B 431.8 x 279.4 mm with + plot rotation 3 (90 degrees clockwise: the landscape sheet is turned + portrait) in inch paper units and uniform 6.35 mm margins, so the sheet + is (-0.25, -0.25) to (273.05 / 25.4, 425.45 / 25.4) = (10.75, 16.75) + in. It pins the rotation swap and the 1/25.4 conversion in + `PlotSettings::sheet_rect` against the limits AutoCAD would store. + + The model holds the LINE (0,0) -> (100,50) of the viewport fixtures, so + each composited window shows it running corner to corner. + """ + hdr = header(INSUNITS=(70, 4)) + pre = tables(layers=((b"0", 7), (b"VPFROZEN", 4, 1)), + block_records=(("1F", "*Model_Space"), + ("1C", "*Paper_Space", "2B"), + ("1D", "*Paper_Space0", "2C"))) + pre += section("BLOCKS", + block("*Model_Space", "1F", ("20", "21")) + + block("*Paper_Space", "1C", ("22", "23"), paper=True) + + block("*Paper_Space0", "1D", ("26", "27"), paper=True)) + post = section("OBJECTS", + dictionary("C", "0", (("ACAD_LAYOUT", "1A"),)) + + dictionary("1A", "C", (("Layout1", "2B"), ("Layout2", "2C"))) + + layout("2B", "1A", "Layout1", 1, block_record="1C", viewport="2A", + paper=("ISO_A4_(210.00_x_297.00_MM)", 210.0, 297.0), + rotation=2, margins=(10.0, 20.0, 5.0, 15.0)) + + layout("2C", "1A", "Layout2", 2, block_record="1D", viewport="0", + paper=("ANSI_B_(17.00_x_11.00_Inches)", 431.8, 279.4), + rotation=3, paper_units=0)) + window = dict(view_center=(50.0, 25.0), view_size=20.0) + ents = (line(0, 0, 100, 50, handle="24", owner="1F") + + viewport("2A", "1C", (50.0, 50.0), (60.0, 40.0), vp_id=2, **window) + + viewport("2D", "1C", (50.0, 120.0), (60.0, 40.0), vp_id=3, + layer=b"VPFROZEN", **window) + + viewport("2E", "1C", (50.0, 190.0), (60.0, 40.0), vp_id=4, + on=0, status=32864 | 0x20000, **window) + + viewport("2F", "1C", (140.0, 50.0), (60.0, 40.0), vp_id=5, + view_dir=(1.0, 1.0, 1.0), **window)) + return hdr + pre + section("ENTITIES", ents) + post + pair(0, "EOF") + + +# ---------------------------------------------------------------- fixture 9 +def radial(): + """The three dimension kinds no corpus DXF carries (`2000/TS1.dwg` has + all three, but every TS1 DXF fails LibreDWG's reader): a radius, a + diameter and a 3-point angular dimension, with no cached `*D` blocks so + the labels are formatted from the values. Every number is derived by + hand, and group 42 (`act_measurement`) is written as the value a CAD + program would have measured, so `measurement` and + `measurement_from_points` must agree. + + | 70 | subclass | 10 | 13 | 14 | 15 | 42 | value | + |---|---|---|---|---|---|---|---| + | 36 (4 radius) | `AcDbRadialDimension` | (0,0) centre | | | (3,4) on the circle | 5.0 | 3-4-5: radius 5 | + | 35 (3 diameter) | `AcDbDiametricDimension` | (20,0) chord start | | | (20,10) chord end | 10.0 | the vertical diameter of the circle at (20,5), r = 5 | + | 37 (5 angular 3-point) | `AcDb3PointAngularDimension` | (44.330127, 2.5) arc point, 30 degrees along r = 5 | (50,0) | (45, 8.660254) | (40,0) centre | pi/3 | the 60-degree sector between the rays at 0 and 60 degrees | + + The arc point picks the sector: it lies at 30 degrees, inside the + 60-degree one, so the 300-degree sector on the other side is not the + one dimensioned. + """ + hdr = header(INSUNITS=(70, 4), DIMDEC=(70, 2), DIMADEC=(70, 0), DIMLUNIT=(70, 2)) + tbl = tables(dimstyle=True) + radius = entity( + "DIMENSION", b"0", + (100, "AcDbDimension"), (2, "*D1"), + (10, 0.0), (20, 0.0), (30, 0.0), + (11, 1.5), (21, 2.0), (31, 0.0), + (70, 36), + (1, b""), + (42, 5.0), + (3, "STANDARD"), + (100, "AcDbRadialDimension"), + (15, 3.0), (25, 4.0), (35, 0.0), + (40, 0.0), + ) + diameter = entity( + "DIMENSION", b"0", + (100, "AcDbDimension"), (2, "*D2"), + (10, 20.0), (20, 0.0), (30, 0.0), + (11, 20.0), (21, 5.0), (31, 0.0), + (70, 35), + (1, b""), + (42, 10.0), + (3, "STANDARD"), + (100, "AcDbDiametricDimension"), + (15, 20.0), (25, 10.0), (35, 0.0), + (40, 0.0), + ) + angular = entity( + "DIMENSION", b"0", + (100, "AcDbDimension"), (2, "*D3"), + (10, 44.33012701892219), (20, 2.5), (30, 0.0), + (11, 46.0), (21, 3.0), (31, 0.0), + (70, 37), + (1, b""), + (42, 1.0471975511965976), + (3, "STANDARD"), + (100, "AcDb3PointAngularDimension"), + (13, 50.0), (23, 0.0), (33, 0.0), + (14, 45.0), (24, 8.660254037844386), (34, 0.0), + (15, 40.0), (25, 0.0), (35, 0.0), + ) + ents = (circle(0, 0, 5) + circle(20, 5, 5) + arc(40, 0, 10, 0, 60) + + radius + diameter + angular) + return hdr + tbl + section("ENTITIES", ents) + pair(0, "EOF") + + +# --------------------------------------------------------------- fixture 13 +def infinite_lines(): + """The two entities that have no end, in a drawing 0.002 units across. + + A RAY and an XLINE are mathematically infinite, so the renderer has to + decide where to stop drawing them. Drawing them a fixed 1e6 units long + (what uncad did before 0.3.0) is a coordinate, not a length: this + drawing's crop is 0.002 units wide, so a 1568 px image is around 750 + thousand pixels per unit and that segment lands 7e11 px off the canvas, + where tiny-skia's fixed-point scan converter asserts and takes the + process with it. The right answer is the edge of the picture. + + | handle | entity | where | what it must look like | + |---|---|---|---| + | 30 | `LINE` | (0,0) to (0.002, 0.002) | the diagonal, and the entity the crop comes from | + | 31 | `TEXT` "X" | (0.0013, 0.0002), height 0.0006 | one text class, so the export can build a tile pyramid at all | + | 32 | `XLINE` | (0.001, 0.001), direction (1, 0) | a horizontal line across the whole image, through the middle | + | 33 | `RAY` | (0.001, 0.001), direction (0, 1) | a vertical line from the middle to the *top* edge, with nothing below it | + + The base point of both construction lines is inside the LINE's box, so + the crop of this drawing is the crop of its finite entities -- an + infinite line must never enlarge it. The text sits in the bottom-right + quarter, clear of the middle row and column, so a test can sample both + construction lines (and the empty half below the ray) at pixels nothing + else draws on. + """ + hdr = header(INSUNITS=(70, 4)) + tbl = tables() + ents = ( + line(0.0, 0.0, 0.002, 0.002, handle="30") + + text("X", 0.0013, 0.0002, height=0.0006, handle="31") + + construction("XLINE", 0.001, 0.001, 1.0, 0.0, handle="32") + + construction("RAY", 0.001, 0.001, 0.0, 1.0, handle="33") + ) + return hdr + tbl + section("ENTITIES", ents) + pair(0, "EOF") + + + +# --------------------------------------------------------------- fixture 14 +def polyline_vertices(): + """The three old-style POLYLINEs whose last vertex LibreDWG's own + `dwg_object_polyline_{2,3}d_get_points` drops on every R13/R14/R2000 + file (its `first_vertex..last_vertex` loop ends before `last_vertex`). + + | handle | entity | vertices | what it pins | + |---|---|---|---| + | 30 | closed `POLYLINE_2D` | (0,0) (100,0) (100,100) (0,100) | a 100 x 100 square: area 10000, perimeter 400 -- a triangle if the 4th vertex is lost | + | 35 | `POLYLINE_2D` | (0,1000) bulge 1.0, (100,1000) | one semicircular segment: length pi * 50, gone entirely if either the vertex or the bulge list is lost | + | 39 | `POLYLINE_3D` | (0,0,0) (10,0,0) (10,10,0) (0,10,5) (0,0,5) | 5 vertices ending above the start, so the last one is the only vertex with both y = 0 and z = 5 | + + The VERTEX records name the POLYLINE in group 330, as AutoCAD writes + them (the polyface fixture uses the other shape).""" + hdr = header(INSUNITS=(70, 4)) + tbl = tables() + square = polyline( + "AcDb2dPolyline", "AcDb2dVertex", + [(0, 0, 0), (100, 0, 0), (100, 100, 0), (0, 100, 0)], + groups=[(70, 1)], vertex_flag=0, + handle="30", owner="1F", vertex_handles=("31", "32", "33", "34"), seqend="3A", + ) + arc = polyline( + "AcDb2dPolyline", "AcDb2dVertex", + [(0, 1000, 0, 1.0), (100, 1000, 0)], + vertex_flag=0, + handle="35", owner="1F", vertex_handles=("36", "37"), seqend="38", + ) + p3d = polyline( + "AcDb3dPolyline", "AcDb3dPolylineVertex", + [(0, 0, 0), (10, 0, 0), (10, 10, 0), (0, 10, 5), (0, 0, 5)], + groups=[(70, 8)], vertex_flag=32, + handle="39", owner="1F", + vertex_handles=("3B", "3C", "3D", "3E", "3F"), seqend="40", + ) + return hdr + tbl + section("ENTITIES", square + arc + p3d) + pair(0, "EOF") + + +# --------------------------------------------------------------- fixture 15 +def entity_truecolor(): + """The four ways a DXF entity can state its colour, on one layer whose + own ACI is 3 (`00ff00`), so each entity's rendered colour says which + field was read. + + | handle | groups | `color_index` | `true_color` | + |---|---|---|---| + | 30 | 420 only | 256 | `0x00ff7f` | + | 31 | 62 only | 1 | none -- an index is not an RGB | + | 32 | 62 then 420 | 1 | `0x0000ff` | + | 33 | neither | 256 | none | + + 65407 is `0x00ff7f` and 255 is `0x0000ff`; both are written as the plain + 24-bit decimal a DXF carries, with no method byte.""" + hdr = header(INSUNITS=(70, 4)) + tbl = tables(layers=((b"0", 7), (b"GREEN", 3))) + ents = ( + line(0, 0, 100, 0, layer=b"GREEN", handle="30", owner="1F", true_color=65407) + + line(0, 10, 100, 10, layer=b"GREEN", handle="31", owner="1F", color=1) + + line(0, 20, 100, 20, layer=b"GREEN", handle="32", owner="1F", color=1, + true_color=255) + + line(0, 30, 100, 30, layer=b"GREEN", handle="33", owner="1F") + ) + return hdr + tbl + section("ENTITIES", ents) + pair(0, "EOF") + + +# --------------------------------------------------------------- fixture 16 +def polyface_mesh(): + """A polyface mesh and a polygon mesh next to an ordinary LINE, written + the way ezdxf and several exporters do: the VERTEX records name the + *block record* in group 330, not the POLYLINE. + + That shape is what made LibreDWG reject the whole file (a polygon mesh's + `AcDbPolygonMeshVertex` marker matched no known subclass, which is a + critical error) and what makes it type a polyface's vertices VERTEX_MESH + rather than VERTEX_PFACE. The LINE is the control: it must survive. + + | handle | entity | what it pins | + |---|---|---| + | 30 | `LINE` (0,0)-(1000,0) | the rest of the file survives whatever the meshes do | + | 31 | `POLYLINE_PFACE` | 8 vertices (two 10 x 10 squares, at z 0 and z 10), 2 quad faces -> 8 wireframe edges | + | 50 | `POLYLINE_MESH` | an open 3 (M) by 4 (N) grid at (i*10, j*5, 0) -> 4*(3-1) + 3*(4-1) = 17 edges | + + Neither mesh's VERTEX records may appear in the entity list.""" + hdr = header(INSUNITS=(70, 4)) + tbl = tables() + pface_vertices = [(0, 0, 0), (10, 0, 0), (10, 10, 0), (0, 10, 0), + (0, 0, 10), (10, 0, 10), (10, 10, 10), (0, 10, 10)] + pface = polyline( + "AcDbPolyFaceMesh", "AcDbPolyFaceMeshVertex", pface_vertices, + groups=[(70, 64), (71, 8), (72, 2)], vertex_flag=192, + handle="31", owner="1F", vertex_owner="1F", + vertex_handles=("32", "33", "34", "35", "36", "37", "38", "39", "3A", "3B"), + seqend="3C", faces=((1, 2, 3, 4), (5, 6, 7, 8)), + ) + grid = [(i * 10, j * 5, 0) for i in range(3) for j in range(4)] + mesh = polyline( + "AcDbPolygonMesh", "AcDbPolygonMeshVertex", grid, + groups=[(70, 16), (71, 3), (72, 4)], vertex_flag=64, + handle="50", owner="1F", vertex_owner="1F", + vertex_handles=tuple("%X" % h for h in range(0x51, 0x51 + len(grid))), + seqend="60", + ) + ents = line(0, 0, 1000, 0, handle="30", owner="1F") + pface + mesh + return hdr + tbl + section("ENTITIES", ents) + pair(0, "EOF") + + +# --------------------------------------------------------------- fixture 17 +def block_layer0(): + """AutoCAD's layer-0-in-a-block rule: geometry drawn on layer 0 inside a + block definition is placed on the layer of the reference that inserts it. + + Block `SYM` is inserted once, on layer `RED` (ACI 1), with its own + colour BYLAYER. Layer `BLUE` is ACI 5 and layer `0` is ACI 7 (black on + a white page), so every child's rendered colour names the layer it + resolved against. + + | handle | child | layer | colour | drawn | + |---|---|---|---|---| + | 42 | `LINE` (0,0)-(10,0) | 0 | BYLAYER | `#ff0000` -- the INSERT's layer, not layer 0's black | + | 43 | `LINE` (0,2)-(10,2) | 0 | BYBLOCK | `#ff0000` -- the INSERT's own resolved colour | + | 44 | `LINE` (0,4)-(10,4) | BLUE | BYLAYER | `#0000ff` -- a named layer inside a block is used as stored | + | 45 | `TEXT` "L0" | 0 | BYLAYER | its text record's layer is `RED` | + """ + hdr = header(INSUNITS=(70, 4)) + pre = tables(layers=((b"0", 7), (b"RED", 1), (b"BLUE", 5)), + block_records=(("1F", "*Model_Space"), ("40", "SYM"))) + body = (line(0, 0, 10, 0, handle="42", owner="40", color=256) + + line(0, 2, 10, 2, handle="43", owner="40", color=0) + + line(0, 4, 10, 4, layer=b"BLUE", handle="44", owner="40", color=256) + + text("L0", 0, 6, height=2.5, handle="45", owner="40")) + pre += section("BLOCKS", block("SYM", "40", ("41", "46"), body=body)) + ents = insert("SYM", 0, 0, "50", "1F", layer=b"RED") + return hdr + pre + section("ENTITIES", ents) + pair(0, "EOF") + + +# --------------------------------------------------------------- fixture 18 +def title_block(): + """A sheet that says what the drawing is -- the question a package of a + real set is asked first -- with every string of it in paper space. + + Model space holds one LINE (0,0) -> (100,50) and no text at all, the + shape of an AutoCAD sheet set: the title, the sheet number and the + office name live on the paper, drawn once, while the model holds the + geometry. A4 landscape (297 x 210 mm, 6.35 mm margins on every side, no + plot origin, so LIMMIN/LIMMAX put the 297 x 210 sheet at + (-6.35, -6.35) .. (290.65, 203.65), the printable corner at the origin) + with: + + | handle | entity | where (paper units) | string | + |---|---|---|---| + | 25 | `TEXT` | (150, 20), height 8 | `GARDEN PAVILION` | + | 26 | `INSERT` of `TITLEBLOCK` | (200, 10) | -- | + | 42 | `TEXT` inside `TITLEBLOCK` | (10, 10) + the insert, height 5 | `SHEET 1 OF 2` | + | 43 | `LINE` inside `TITLEBLOCK` | (0, 5) -> (90, 5) | the rule under it | + | 2A | `VIEWPORT` | frame (150, 120) x (200, 120), VIEWCTR (50, 25), VIEWSIZE 60 | the model at 2:1 | + + The nested text's record id is `26/42`, and its anchor on the sheet is + (200 + 10, 10 + 10) = (210, 20). The viewport's scale is 120 / 60 = 2 + paper units per model unit, and its model window is 100 x 60 model + units centred on (50, 25), i.e. (0, -5) .. (100, 55).""" + hdr = header(INSUNITS=(70, 4)) + pre = tables(block_records=(("1F", "*Model_Space"), ("1C", "*Paper_Space", "2B"), + ("40", "TITLEBLOCK"))) + title_body = (line(0, 5, 90, 5, handle="43", owner="40") + + text(b"SHEET 1 OF 2", 10, 10, height=5, handle="42", owner="40")) + pre += section("BLOCKS", + block("*Model_Space", "1F", ("20", "21")) + + block("*Paper_Space", "1C", ("22", "23"), paper=True) + + block("TITLEBLOCK", "40", ("41", "44"), body=title_body)) + post = section("OBJECTS", + dictionary("C", "0", (("ACAD_LAYOUT", "1A"),)) + + dictionary("1A", "C", (("Layout1", "2B"),)) + + layout("2B", "1A", "Layout1", 1, block_record="1C", viewport="2A", + paper=("ISO_A4_(210.00_x_297.00_MM)", 210.0, 297.0))) + vp = viewport("2A", "1C", (150.0, 120.0), (200.0, 120.0), (50.0, 25.0), 60.0) + ents = (line(0, 0, 100, 50, handle="24", owner="1F") + + text(b"GARDEN PAVILION", 150, 20, height=8, handle="25", owner="1C", paper=True) + + insert("TITLEBLOCK", 200, 10, "26", "1C", paper=True) + + vp) + return hdr + pre + section("ENTITIES", ents) + post + pair(0, "EOF") + + +if __name__ == "__main__": + which = sys.argv[2] if len(sys.argv) > 2 else "all" + if which in ("all", "cp949"): + write("cp949_r2000.dxf", cp949()) + if which in ("all", "mirrored"): + write("mirrored_ocs_r2000.dxf", mirrored()) + if which in ("all", "mirrored-bulge"): + write("mirrored_bulge_r2000.dxf", mirrored_bulge()) + if which in ("all", "dimlfac"): + write("dimlfac12_r2000.dxf", dimlfac12("full")) + if which in ("all", "viewport"): + write("twisted_viewport_r2000.dxf", twisted_viewport("full")) + if which in ("all", "hidden"): + write("hidden_layers_r2000.dxf", hidden_layers()) + if which in ("all", "plot-origin"): + write("plot_origin_r2000.dxf", plot_origin()) + if which in ("all", "angular-ordinate"): + write("angular_ordinate_r2000.dxf", angular_ordinate()) + if which in ("all", "radial"): + write("radial_r2000.dxf", radial()) + if which in ("all", "viewport-states"): + write("viewport_states_r2000.dxf", viewport_states()) + if which in ("all", "hatched-viewport"): + write("hatched_viewport_r2000.dxf", hatched_viewport()) + if which in ("all", "nested-attrib"): + write("nested_attrib_r2000.dxf", nested_attrib()) + if which in ("all", "infinite-lines"): + write("infinite_lines_r2000.dxf", infinite_lines()) + if which in ("all", "polyline-vertices"): + write("polyline_vertices_r2000.dxf", polyline_vertices()) + if which in ("all", "entity-truecolor"): + write("entity_truecolor_r2000.dxf", entity_truecolor()) + if which in ("all", "polyface-mesh"): + write("polyface_mesh_r2000.dxf", polyface_mesh()) + if which in ("all", "block-layer0"): + write("block_layer0_r2000.dxf", block_layer0()) + if which in ("all", "title-block"): + write("title_block_r2000.dxf", title_block()) + if which == "dimlfac-minimal": + write("dimlfac12_r2000.dxf", dimlfac12("minimal")) + if which == "viewport-minimal": + write("twisted_viewport_r2000.dxf", twisted_viewport("minimal")) diff --git a/crates/uncad/tests/fixtures/mirrored_bulge_r2000.dxf b/crates/uncad/tests/fixtures/mirrored_bulge_r2000.dxf new file mode 100644 index 0000000..71011b8 --- /dev/null +++ b/crates/uncad/tests/fixtures/mirrored_bulge_r2000.dxf @@ -0,0 +1,80 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LWPOLYLINE + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +0 + 43 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 + 10 +0.0 + 20 +0.0 + 10 +100.0 + 20 +0.0 + 42 +0.41421356 + 10 +100.0 + 20 +50.0 + 10 +0.0 + 20 +50.0 + 0 +ARC + 8 +0 + 10 +75.0 + 20 +25.0 + 30 +0.0 + 40 +35.35533906 + 50 +315.0 + 51 +45.0 +210 +0.0 +220 +0.0 +230 +-1.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/mirrored_ocs_r2000.dxf b/crates/uncad/tests/fixtures/mirrored_ocs_r2000.dxf new file mode 100644 index 0000000..e089ebf --- /dev/null +++ b/crates/uncad/tests/fixtures/mirrored_ocs_r2000.dxf @@ -0,0 +1,168 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LWPOLYLINE + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 43 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 + 10 +0.0 + 20 +0.0 + 10 +100.0 + 20 +0.0 + 10 +100.0 + 20 +50.0 + 10 +0.0 + 20 +50.0 + 0 +LWPOLYLINE + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +0 + 43 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 10 +0.0 + 20 +0.0 + 10 +100.0 + 20 +0.0 + 42 +0.41421356 + 10 +100.0 + 20 +50.0 + 10 +0.0 + 20 +50.0 + 0 +CIRCLE + 8 +0 + 10 +10.0 + 20 +10.0 + 30 +0.0 + 40 +5.0 +210 +0.0 +220 +0.0 +230 +-1.0 + 0 +ARC + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +20.0 + 50 +0.0 + 51 +90.0 +210 +0.0 +220 +0.0 +230 +-1.0 + 0 +TEXT + 8 +0 + 10 +10.0 + 20 +10.0 + 30 +0.0 + 40 +2.5 + 1 +MIRROR +210 +0.0 +220 +0.0 +230 +-1.0 + 0 +LINE + 8 +0 + 10 +-5.0 + 20 +-5.0 + 30 +0.0 + 11 +5.0 + 21 +5.0 + 31 +0.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/nested_attrib_r2000.dxf b/crates/uncad/tests/fixtures/nested_attrib_r2000.dxf new file mode 100644 index 0000000..0c3dd36 --- /dev/null +++ b/crates/uncad/tests/fixtures/nested_attrib_r2000.dxf @@ -0,0 +1,378 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 70 +3 + 0 +BLOCK_RECORD + 5 +1F + 2 +*Model_Space + 70 +0 + 0 +BLOCK_RECORD + 5 +40 + 2 +TAG + 70 +0 + 0 +BLOCK_RECORD + 5 +50 + 2 +DOOR + 70 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +41 +330 +40 + 8 +0 +100 +AcDbBlockBegin + 2 +TAG + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +TAG + 0 +LINE + 5 +42 +330 +40 + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +10.0 + 21 +0.0 + 31 +0.0 + 0 +ATTDEF + 5 +43 +330 +40 + 8 +0 +100 +AcDbEntity +100 +AcDbText + 10 +1.0 + 20 +1.0 + 30 +0.0 + 40 +2.5 + 1 +D-000 +100 +AcDbAttributeDefinition + 3 +Number + 2 +NUM + 70 +0 + 0 +ENDBLK + 5 +44 +330 +40 + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +51 +330 +50 + 8 +0 +100 +AcDbBlockBegin + 2 +DOOR + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +DOOR + 0 +LINE + 5 +52 +330 +50 + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +40.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +53 +330 +50 + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +40.0 + 31 +0.0 + 0 +INSERT + 5 +55 +330 +50 + 8 +0 +100 +AcDbEntity +100 +AcDbBlockReference + 66 +1 + 2 +TAG + 10 +20.0 + 20 +20.0 + 30 +0.0 + 0 +ATTRIB + 5 +56 +330 +50 + 8 +0 +100 +AcDbEntity +100 +AcDbText + 10 +21.0 + 20 +21.0 + 30 +0.0 + 40 +2.5 + 1 +D-101 +100 +AcDbAttribute + 2 +NUM + 70 +0 + 0 +SEQEND + 5 +57 +330 +50 + 8 +0 +100 +AcDbEntity + 0 +ENDBLK + 5 +54 +330 +50 + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +INSERT + 5 +60 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbBlockReference + 2 +DOOR + 10 +100.0 + 20 +100.0 + 30 +0.0 + 0 +INSERT + 5 +61 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbBlockReference + 66 +1 + 2 +TAG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +ATTRIB + 5 +62 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbText + 10 +1.0 + 20 +1.0 + 30 +0.0 + 40 +2.5 + 1 +D-TOP +100 +AcDbAttribute + 2 +NUM + 70 +0 + 0 +SEQEND + 5 +63 +330 +1F + 8 +0 +100 +AcDbEntity + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/plot_origin_r2000.dxf b/crates/uncad/tests/fixtures/plot_origin_r2000.dxf new file mode 100644 index 0000000..cb33c3a --- /dev/null +++ b/crates/uncad/tests/fixtures/plot_origin_r2000.dxf @@ -0,0 +1,424 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +1 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 70 +2 + 0 +BLOCK_RECORD + 5 +1F + 2 +*Model_Space + 70 +0 + 0 +BLOCK_RECORD + 5 +1C + 2 +*Paper_Space + 70 +0 +340 +2B + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +20 +330 +1F + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 0 +ENDBLK + 5 +21 +330 +1F + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +22 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 0 +ENDBLK + 5 +23 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +24 +330 +1F + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +100.0 + 21 +50.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +25 +330 +1C + 8 +0 + 67 +1 +100 +AcDbPolyline + 90 +4 + 70 +1 + 43 +0.0 + 10 +0.5 + 20 +0.25 + 10 +16.5 + 20 +0.25 + 10 +16.5 + 20 +10.5 + 10 +0.5 + 20 +10.5 + 0 +VIEWPORT + 5 +2A +330 +1C + 8 +0 + 67 +1 +100 +AcDbViewport + 10 +8.5 + 20 +5.5 + 30 +0.0 + 40 +12.0 + 41 +8.0 + 68 +1 + 69 +2 + 12 +50.0 + 22 +25.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +40.0 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +32864 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +C +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_LAYOUT +350 +1A + 0 +DICTIONARY + 5 +1A +330 +C +100 +AcDbDictionary +281 +1 + 3 +Layout1 +350 +2B + 0 +LAYOUT + 5 +2B +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ANSI_B_(17.00_x_11.00_Inches) + 40 +6.35 + 41 +19.05 + 42 +6.35 + 43 +19.05 + 44 +431.8 + 45 +279.4 + 46 +-6.35 + 47 +-12.7 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +0 + 73 +0 + 74 +5 + 7 + + 75 +16 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +-0.25 + 11 +17.0 + 21 +10.75 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +0 +330 +1C +331 +2A + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/polyface_mesh_r2000.dxf b/crates/uncad/tests/fixtures/polyface_mesh_r2000.dxf new file mode 100644 index 0000000..ca0f133 --- /dev/null +++ b/crates/uncad/tests/fixtures/polyface_mesh_r2000.dxf @@ -0,0 +1,634 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +30 +330 +1F + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +1000.0 + 21 +0.0 + 31 +0.0 + 0 +POLYLINE + 5 +31 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbPolyFaceMesh + 66 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +64 + 71 +8 + 72 +2 + 0 +VERTEX + 5 +32 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolyFaceMeshVertex + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +192 + 0 +VERTEX + 5 +33 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolyFaceMeshVertex + 10 +10.0 + 20 +0.0 + 30 +0.0 + 70 +192 + 0 +VERTEX + 5 +34 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolyFaceMeshVertex + 10 +10.0 + 20 +10.0 + 30 +0.0 + 70 +192 + 0 +VERTEX + 5 +35 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolyFaceMeshVertex + 10 +0.0 + 20 +10.0 + 30 +0.0 + 70 +192 + 0 +VERTEX + 5 +36 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolyFaceMeshVertex + 10 +0.0 + 20 +0.0 + 30 +10.0 + 70 +192 + 0 +VERTEX + 5 +37 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolyFaceMeshVertex + 10 +10.0 + 20 +0.0 + 30 +10.0 + 70 +192 + 0 +VERTEX + 5 +38 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolyFaceMeshVertex + 10 +10.0 + 20 +10.0 + 30 +10.0 + 70 +192 + 0 +VERTEX + 5 +39 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolyFaceMeshVertex + 10 +0.0 + 20 +10.0 + 30 +10.0 + 70 +192 + 0 +VERTEX + 5 +3A +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbFaceRecord + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +128 + 71 +1 + 72 +2 + 73 +3 + 74 +4 + 0 +VERTEX + 5 +3B +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbFaceRecord + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +128 + 71 +5 + 72 +6 + 73 +7 + 74 +8 + 0 +SEQEND + 5 +3C +330 +31 + 8 +0 +100 +AcDbEntity + 0 +POLYLINE + 5 +50 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbPolygonMesh + 66 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +16 + 71 +3 + 72 +4 + 0 +VERTEX + 5 +51 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +52 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +0.0 + 20 +5.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +53 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +0.0 + 20 +10.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +54 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +0.0 + 20 +15.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +55 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +10.0 + 20 +0.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +56 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +10.0 + 20 +5.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +57 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +10.0 + 20 +10.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +58 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +10.0 + 20 +15.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +59 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +20.0 + 20 +0.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +5A +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +20.0 + 20 +5.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +5B +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +20.0 + 20 +10.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +5C +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +20.0 + 20 +15.0 + 30 +0.0 + 70 +64 + 0 +SEQEND + 5 +60 +330 +50 + 8 +0 +100 +AcDbEntity + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/polyline_vertices_r2000.dxf b/crates/uncad/tests/fixtures/polyline_vertices_r2000.dxf new file mode 100644 index 0000000..aedfbac --- /dev/null +++ b/crates/uncad/tests/fixtures/polyline_vertices_r2000.dxf @@ -0,0 +1,384 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +POLYLINE + 5 +30 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDb2dPolyline + 66 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +1 + 0 +VERTEX + 5 +31 +330 +30 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb2dVertex + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +32 +330 +30 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb2dVertex + 10 +100.0 + 20 +0.0 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +33 +330 +30 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb2dVertex + 10 +100.0 + 20 +100.0 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +34 +330 +30 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb2dVertex + 10 +0.0 + 20 +100.0 + 30 +0.0 + 70 +0 + 0 +SEQEND + 5 +3A +330 +30 + 8 +0 +100 +AcDbEntity + 0 +POLYLINE + 5 +35 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDb2dPolyline + 66 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +VERTEX + 5 +36 +330 +35 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb2dVertex + 10 +0.0 + 20 +1000.0 + 30 +0.0 + 42 +1.0 + 70 +0 + 0 +VERTEX + 5 +37 +330 +35 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb2dVertex + 10 +100.0 + 20 +1000.0 + 30 +0.0 + 70 +0 + 0 +SEQEND + 5 +38 +330 +35 + 8 +0 +100 +AcDbEntity + 0 +POLYLINE + 5 +39 +330 +1F + 8 +0 +100 +AcDbEntity +100 +AcDb3dPolyline + 66 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +8 + 0 +VERTEX + 5 +3B +330 +39 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb3dPolylineVertex + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +32 + 0 +VERTEX + 5 +3C +330 +39 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb3dPolylineVertex + 10 +10.0 + 20 +0.0 + 30 +0.0 + 70 +32 + 0 +VERTEX + 5 +3D +330 +39 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb3dPolylineVertex + 10 +10.0 + 20 +10.0 + 30 +0.0 + 70 +32 + 0 +VERTEX + 5 +3E +330 +39 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb3dPolylineVertex + 10 +0.0 + 20 +10.0 + 30 +5.0 + 70 +32 + 0 +VERTEX + 5 +3F +330 +39 + 8 +0 +100 +AcDbEntity +100 +AcDbVertex +100 +AcDb3dPolylineVertex + 10 +0.0 + 20 +0.0 + 30 +5.0 + 70 +32 + 0 +SEQEND + 5 +40 +330 +39 + 8 +0 +100 +AcDbEntity + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/radial_r2000.dxf b/crates/uncad/tests/fixtures/radial_r2000.dxf new file mode 100644 index 0000000..25ed315 --- /dev/null +++ b/crates/uncad/tests/fixtures/radial_r2000.dxf @@ -0,0 +1,238 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 9 +$DIMDEC + 70 +2 + 9 +$DIMADEC + 70 +0 + 9 +$DIMLUNIT + 70 +2 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 70 +1 + 0 +DIMSTYLE +105 +30 + 2 +STANDARD + 70 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +CIRCLE + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +5.0 + 0 +CIRCLE + 8 +0 + 10 +20.0 + 20 +5.0 + 30 +0.0 + 40 +5.0 + 0 +ARC + 8 +0 + 10 +40.0 + 20 +0.0 + 30 +0.0 + 40 +10.0 + 50 +0.0 + 51 +60.0 + 0 +DIMENSION + 8 +0 +100 +AcDbDimension + 2 +*D1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +1.5 + 21 +2.0 + 31 +0.0 + 70 +36 + 1 + + 42 +5.0 + 3 +STANDARD +100 +AcDbRadialDimension + 15 +3.0 + 25 +4.0 + 35 +0.0 + 40 +0.0 + 0 +DIMENSION + 8 +0 +100 +AcDbDimension + 2 +*D2 + 10 +20.0 + 20 +0.0 + 30 +0.0 + 11 +20.0 + 21 +5.0 + 31 +0.0 + 70 +35 + 1 + + 42 +10.0 + 3 +STANDARD +100 +AcDbDiametricDimension + 15 +20.0 + 25 +10.0 + 35 +0.0 + 40 +0.0 + 0 +DIMENSION + 8 +0 +100 +AcDbDimension + 2 +*D3 + 10 +44.33012701892219 + 20 +2.5 + 30 +0.0 + 11 +46.0 + 21 +3.0 + 31 +0.0 + 70 +37 + 1 + + 42 +1.0471975511965976 + 3 +STANDARD +100 +AcDb3PointAngularDimension + 13 +50.0 + 23 +0.0 + 33 +0.0 + 14 +45.0 + 24 +8.660254037844386 + 34 +0.0 + 15 +40.0 + 25 +0.0 + 35 +0.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/title_block_r2000.dxf b/crates/uncad/tests/fixtures/title_block_r2000.dxf new file mode 100644 index 0000000..169b223 --- /dev/null +++ b/crates/uncad/tests/fixtures/title_block_r2000.dxf @@ -0,0 +1,510 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 70 +3 + 0 +BLOCK_RECORD + 5 +1F + 2 +*Model_Space + 70 +0 + 0 +BLOCK_RECORD + 5 +1C + 2 +*Paper_Space + 70 +0 +340 +2B + 0 +BLOCK_RECORD + 5 +40 + 2 +TITLEBLOCK + 70 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +20 +330 +1F + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 0 +ENDBLK + 5 +21 +330 +1F + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +22 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 0 +ENDBLK + 5 +23 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +41 +330 +40 + 8 +0 +100 +AcDbBlockBegin + 2 +TITLEBLOCK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +TITLEBLOCK + 0 +LINE + 5 +43 +330 +40 + 8 +0 + 10 +0.0 + 20 +5.0 + 30 +0.0 + 11 +90.0 + 21 +5.0 + 31 +0.0 + 0 +TEXT + 5 +42 +330 +40 + 8 +0 + 10 +10.0 + 20 +10.0 + 30 +0.0 + 40 +5.0 + 1 +SHEET 1 OF 2 + 0 +ENDBLK + 5 +44 +330 +40 + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +24 +330 +1F + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +100.0 + 21 +50.0 + 31 +0.0 + 0 +TEXT + 5 +25 +330 +1C + 8 +0 + 67 +1 + 10 +150.0 + 20 +20.0 + 30 +0.0 + 40 +8.0 + 1 +GARDEN PAVILION + 0 +INSERT + 5 +26 +330 +1C + 8 +0 + 67 +1 +100 +AcDbEntity +100 +AcDbBlockReference + 2 +TITLEBLOCK + 10 +200.0 + 20 +10.0 + 30 +0.0 + 0 +VIEWPORT + 5 +2A +330 +1C + 8 +0 + 67 +1 +100 +AcDbViewport + 10 +150.0 + 20 +120.0 + 30 +0.0 + 40 +200.0 + 41 +120.0 + 68 +1 + 69 +2 + 12 +50.0 + 22 +25.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +60.0 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +32864 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +C +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_LAYOUT +350 +1A + 0 +DICTIONARY + 5 +1A +330 +C +100 +AcDbDictionary +281 +1 + 3 +Layout1 +350 +2B + 0 +LAYOUT + 5 +2B +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ISO_A4_(210.00_x_297.00_MM) + 40 +6.35 + 41 +6.35 + 42 +6.35 + 43 +6.35 + 44 +210.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +1 + 74 +5 + 7 + + 75 +16 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +-6.35 + 20 +-6.35 + 11 +290.65 + 21 +203.65 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +0 +330 +1C +331 +2A + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/twisted_viewport_r2000.dxf b/crates/uncad/tests/fixtures/twisted_viewport_r2000.dxf new file mode 100644 index 0000000..efce653 --- /dev/null +++ b/crates/uncad/tests/fixtures/twisted_viewport_r2000.dxf @@ -0,0 +1,390 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +1 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 70 +2 + 0 +BLOCK_RECORD + 5 +1F + 2 +*Model_Space + 70 +0 + 0 +BLOCK_RECORD + 5 +1C + 2 +*Paper_Space + 70 +0 +340 +2B + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +20 +330 +1F + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 0 +ENDBLK + 5 +21 +330 +1F + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +22 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 0 +ENDBLK + 5 +23 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +24 +330 +1F + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +100.0 + 21 +50.0 + 31 +0.0 + 0 +VIEWPORT + 5 +2A +330 +1C + 8 +0 + 67 +1 +100 +AcDbViewport + 10 +150.0 + 20 +100.0 + 30 +0.0 + 40 +200.0 + 41 +120.0 + 68 +1 + 69 +2 + 12 +50.0 + 22 +25.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +60.0 + 50 +0.0 + 51 +30.0 + 72 +100 + 90 +32864 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +C +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_LAYOUT +350 +1A + 0 +DICTIONARY + 5 +1A +330 +C +100 +AcDbDictionary +281 +1 + 3 +Layout1 +350 +2B + 0 +LAYOUT + 5 +2B +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ISO_A4_(210.00_x_297.00_MM) + 40 +6.35 + 41 +6.35 + 42 +6.35 + 43 +6.35 + 44 +210.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +1 + 74 +5 + 7 + + 75 +16 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +-6.35 + 20 +-6.35 + 11 +290.65 + 21 +203.65 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +0 +330 +1C +331 +2A + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/fixtures/viewport_states_r2000.dxf b/crates/uncad/tests/fixtures/viewport_states_r2000.dxf new file mode 100644 index 0000000..d80e61a --- /dev/null +++ b/crates/uncad/tests/fixtures/viewport_states_r2000.dxf @@ -0,0 +1,780 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$INSUNITS + 70 +4 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LAYER + 70 +2 + 0 +LAYER + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +LAYER + 2 +VPFROZEN + 70 +1 + 62 +4 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 70 +3 + 0 +BLOCK_RECORD + 5 +1F + 2 +*Model_Space + 70 +0 + 0 +BLOCK_RECORD + 5 +1C + 2 +*Paper_Space + 70 +0 +340 +2B + 0 +BLOCK_RECORD + 5 +1D + 2 +*Paper_Space0 + 70 +0 +340 +2C + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +20 +330 +1F + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 0 +ENDBLK + 5 +21 +330 +1F + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +22 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 0 +ENDBLK + 5 +23 +330 +1C + 8 +0 + 67 +1 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +26 +330 +1D + 8 +0 + 67 +1 +100 +AcDbBlockBegin + 2 +*Paper_Space0 + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space0 + 0 +ENDBLK + 5 +27 +330 +1D + 8 +0 + 67 +1 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +24 +330 +1F + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +100.0 + 21 +50.0 + 31 +0.0 + 0 +VIEWPORT + 5 +2A +330 +1C + 8 +0 + 67 +1 +100 +AcDbViewport + 10 +50.0 + 20 +50.0 + 30 +0.0 + 40 +60.0 + 41 +40.0 + 68 +1 + 69 +2 + 12 +50.0 + 22 +25.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +20.0 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +32864 + 0 +VIEWPORT + 5 +2D +330 +1C + 8 +VPFROZEN + 67 +1 +100 +AcDbViewport + 10 +50.0 + 20 +120.0 + 30 +0.0 + 40 +60.0 + 41 +40.0 + 68 +1 + 69 +3 + 12 +50.0 + 22 +25.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +20.0 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +32864 + 0 +VIEWPORT + 5 +2E +330 +1C + 8 +0 + 67 +1 +100 +AcDbViewport + 10 +50.0 + 20 +190.0 + 30 +0.0 + 40 +60.0 + 41 +40.0 + 68 +0 + 69 +4 + 12 +50.0 + 22 +25.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +20.0 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +163936 + 0 +VIEWPORT + 5 +2F +330 +1C + 8 +0 + 67 +1 +100 +AcDbViewport + 10 +140.0 + 20 +50.0 + 30 +0.0 + 40 +60.0 + 41 +40.0 + 68 +1 + 69 +5 + 12 +50.0 + 22 +25.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +1.0 + 26 +1.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +20.0 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +32864 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +C +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_LAYOUT +350 +1A + 0 +DICTIONARY + 5 +1A +330 +C +100 +AcDbDictionary +281 +1 + 3 +Layout1 +350 +2B + 3 +Layout2 +350 +2C + 0 +LAYOUT + 5 +2B +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ISO_A4_(210.00_x_297.00_MM) + 40 +10.0 + 41 +20.0 + 42 +5.0 + 43 +15.0 + 44 +210.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +2 + 74 +5 + 7 + + 75 +16 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +-10.0 + 20 +-20.0 + 11 +200.0 + 21 +277.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +0 +330 +1C +331 +2A + 0 +LAYOUT + 5 +2C +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ANSI_B_(17.00_x_11.00_Inches) + 40 +6.35 + 41 +6.35 + 42 +6.35 + 43 +6.35 + 44 +431.8 + 45 +279.4 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +0 + 73 +3 + 74 +5 + 7 + + 75 +16 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout2 + 70 +1 + 71 +2 + 10 +-0.25 + 20 +-0.25 + 11 +10.75 + 21 +16.75 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +0 +330 +1D +331 +0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/polyline_closed.rs b/crates/uncad/tests/polyline_closed.rs new file mode 100644 index 0000000..e1362f8 --- /dev/null +++ b/crates/uncad/tests/polyline_closed.rs @@ -0,0 +1,98 @@ +//! LWPOLYLINE's `closed` flag against the one file in the corpus that has a +//! text twin: `example_2000.dxf` states each polyline's closed bit in group +//! code 70, and `example_2000.dwg` is the same drawing. The DXF is the +//! reference, so the numbers here (11 polylines, 10 closed) come from +//! AutoCAD's own writer, not from this project's output. + +use uncad::model::Ref; +use uncad::Entity; + +const EXAMPLE_2000_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dwg" +); +const EXAMPLE_2000_DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dxf" +); + +/// `(handle, closed)` for every LWPOLYLINE in the drawing, top level and +/// block definitions alike, so the DWG and the DXF can be compared entity by +/// entity. +fn lwpolylines(db: &uncad::CadDatabase) -> Vec<(String, bool)> { + let mut out: Vec<(String, bool)> = db + .tables + .block_records + .values() + .flat_map(|record| record.entities.iter()) + .filter_map(|e| match e { + Entity::LwPolyline(p) => match &p.common.source_handle { + Ref::Resolved(handle) => Some((handle.clone(), p.closed)), + other => panic!("an R2000 entity has a handle: {other:?}"), + }, + _ => None, + }) + .collect(); + out.sort(); + out.dedup(); + out +} + +/// Reads group code 70 of every LWPOLYLINE straight out of the DXF text, the +/// way the DXF reference defines it (bit 1 = closed): the ground truth. +fn closed_bits_from_dxf_text() -> Vec<(String, bool)> { + // Bytes, not read_to_string: the file's text is CP1252 (it carries a + // degree sign), and only the ASCII group codes matter here. + let bytes = std::fs::read(EXAMPLE_2000_DXF).expect("corpus DXF is readable"); + let text = String::from_utf8_lossy(&bytes); + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let mut out = Vec::new(); + let mut i = 0; + while i + 1 < lines.len() { + if lines[i] == "0" && lines[i + 1] == "LWPOLYLINE" { + let (mut handle, mut flags) = (None, None); + let mut j = i + 2; + while j + 1 < lines.len() && lines[j] != "0" { + match lines[j] { + "5" => handle = Some(lines[j + 1].to_string()), + "70" => flags = lines[j + 1].parse::().ok(), + _ => {} + } + j += 2; + } + if let (Some(h), Some(f)) = (handle, flags) { + out.push((h, f & 1 != 0)); + } + i = j; + } else { + i += 1; + } + } + out.sort(); + out +} + +#[test] +fn closed_bits_read_from_the_dwg_match_the_dxf_group_70() { + let truth = closed_bits_from_dxf_text(); + assert_eq!( + truth.len(), + 11, + "example_2000.dxf holds 11 LWPOLYLINEs: {truth:?}" + ); + assert_eq!(truth.iter().filter(|(_, closed)| *closed).count(), 10); + + let dwg = uncad::parse(EXAMPLE_2000_DWG).expect("corpus file must parse"); + assert_eq!( + lwpolylines(&dwg), + truth, + "DWG closed flags vs the DXF's group 70" + ); +} + +#[test] +fn the_dxf_input_path_agrees_with_the_dwg() { + let dwg = uncad::parse(EXAMPLE_2000_DWG).expect("corpus file must parse"); + let dxf = uncad::parse(EXAMPLE_2000_DXF).expect("corpus file must parse"); + assert_eq!(lwpolylines(&dxf), lwpolylines(&dwg)); +} From 2cf2e315eda7f5712fa588a38f8f64bafe724acd Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 14:51:12 +0900 Subject: [PATCH 11/54] Bound the walks over an entity's owned subentities An INSERT converts its own ATTRIBs, the one place the conversion recurses, and every attribute source it reads is data from the file: a damaged handle can point the chain back at the INSERT (endless recursion), make it a ring (an endless loop) or point it at anything at all. The attribs[] array was converted whatever each reference named, and the R2004+ owned-subentity walk and the polyface walk had no bound. Now the conversion carries its depth and stops past two levels (the format has one), each walk converts only ATTRIBs and stops at the first object that is not one, and every walk hands back at most 100,000 subentities. tests/corrupt_dwg.rs pins the three bytes of example_2000.dwg that sent the feature branch's walk around the INSERT until the stack ran out; this crate's chained walk already survived them, and the test keeps it that way. No output changes: the JSON of all 199 drawings the CLI reads from the corpus, the fixtures and the samples is byte-identical before and after. Ported-from: e7d9780 Co-Authored-By: Claude Opus 5.5 --- crates/uncad/src/convert.rs | 119 ++++++++++++++++++++++++------ crates/uncad/tests/corrupt_dwg.rs | 82 ++++++++++++++++++++ 2 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 crates/uncad/tests/corrupt_dwg.rs diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index fd7b741..26f4e35 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -43,6 +43,25 @@ const LWPOLYLINE_CLOSED_FLAG: u16 = 512; /// `MLINE_FLAGS_CLOSED` (dwg.h). const MLINE_CLOSED_FLAG: u16 = 2; +/// How deep the conversion of an entity's owned *sub*entities may recurse. +/// +/// Only one kind of nesting is real: an INSERT owns its ATTRIBs, which are +/// entities themselves, so converting an INSERT converts them too (depth 1; +/// an entity a block owns is depth 0). A file whose handles are damaged can +/// point an INSERT's attribute chain back at the INSERT, or around a cycle +/// of them, and the conversion then recurses until the stack runs out -- a +/// 512 MB stack did not survive one byte-flipped `example_2000.dwg`. One +/// level is what the format has; two is the cap. +const MAX_SUBENTITY_DEPTH: u32 = 2; + +/// How many subentities one owned-subentity walk may hand back. +/// +/// The same damaged handles can make a chain a *ring* instead of a list, +/// which is not recursion but a loop that never ends. No real entity owns +/// anything like this many: the largest polyline in the corpus has a few +/// thousand vertices. +const MAX_OWNED_SUBENTITIES: usize = 100_000; + fn is_model_space(name: &str) -> bool { name.to_uppercase() == "*MODEL_SPACE" } @@ -139,7 +158,7 @@ pub(crate) unsafe fn owned_entities( let mut entities = Vec::new(); let mut owned = unsafe { libredwg_sys::get_first_owned_entity(block_obj) }; while !owned.is_null() { - if let Some(entity) = unsafe { convert_entity(dwg, text, owned) } { + if let Some(entity) = unsafe { convert_entity(dwg, text, owned, 0) } { entities.push(entity); } owned = unsafe { libredwg_sys::get_next_owned_entity(block_obj, owned) }; @@ -229,7 +248,7 @@ unsafe fn chained_block_entities( let fixedtype = unsafe { libredwg_sys::dwg_object_get_fixedtype(obj) } as libredwg_sys::DWG_OBJECT_TYPE; if !is_sub_entity(fixedtype) { - if let Some(entity) = unsafe { convert_entity(dwg, text, obj) } { + if let Some(entity) = unsafe { convert_entity(dwg, text, obj, 0) } { entities.push(entity); } } @@ -252,13 +271,20 @@ unsafe fn chained_block_entities( /// every attribute of an imported INSERT was lost. A drawing decoded from /// DWG carries the chain and no array, so the chain is the fallback. /// +/// Either source is data from the file, so both are bounded: only ATTRIB +/// objects are converted (an INSERT owns nothing else, and converting what +/// a damaged reference points at instead -- the INSERT itself, say -- is +/// endless recursion), at `depth + 1`, and neither yields more than +/// [`MAX_OWNED_SUBENTITIES`]. +/// /// # Safety /// `dwg` must be live and `entity_ptr` the type-specific struct pointer of a -/// valid INSERT object of it. +/// valid INSERT object of it, converted at `depth`. unsafe fn chained_insert_attribs( dwg: *mut libredwg_sys::Dwg_Data, text: &TextDecoder, entity_ptr: *mut std::ffi::c_void, + depth: u32, ) -> Vec { let mut attribs = Vec::new(); let owned = get_array_field::( @@ -268,12 +294,14 @@ unsafe fn chained_insert_attribs( "attribs", ); if !owned.is_empty() { - for reference in owned { + for reference in owned.into_iter().take(MAX_OWNED_SUBENTITIES) { let sub = unsafe { referenced_object(dwg, reference) }; - if sub.is_null() { + if sub.is_null() || !unsafe { is_attrib(sub) } { continue; } - if let Some(Entity::Attrib(attrib)) = unsafe { convert_entity(dwg, text, sub) } { + if let Some(Entity::Attrib(attrib)) = + unsafe { convert_entity(dwg, text, sub, depth + 1) } + { attribs.push(attrib); } } @@ -288,14 +316,12 @@ unsafe fn chained_insert_attribs( let mut sub = unsafe { referenced_object(dwg, first) }; let max_steps = unsafe { libredwg_sys::dwg_get_num_objects(dwg) }; let mut steps = 0; - while !sub.is_null() && steps <= max_steps { + while !sub.is_null() && steps <= max_steps && attribs.len() < MAX_OWNED_SUBENTITIES { steps += 1; - let fixedtype = - unsafe { libredwg_sys::dwg_object_get_fixedtype(sub) } as libredwg_sys::DWG_OBJECT_TYPE; - if fixedtype != libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ATTRIB { + if !unsafe { is_attrib(sub) } { break; } - if let Some(Entity::Attrib(attrib)) = unsafe { convert_entity(dwg, text, sub) } { + if let Some(Entity::Attrib(attrib)) = unsafe { convert_entity(dwg, text, sub, depth + 1) } { attribs.push(attrib); } if sub == last_obj { @@ -306,6 +332,51 @@ unsafe fn chained_insert_attribs( attribs } +/// An INSERT's attributes from R2004 on, where the library keeps them as an +/// owned array its own subentity walker resolves correctly -- walked from +/// the INSERT's own `Dwg_Object`, not from its type-specific struct. +/// +/// Bounded three ways, because damaged handles can turn the chain into a +/// ring (an endless walk), point it back at the INSERT (endless recursion +/// through [`convert_entity`]) or run it off the end of the object list: an +/// INSERT owns ATTRIBs and nothing else, so the walk stops the moment the +/// chain says otherwise; it converts at `depth + 1`; and it hands back at +/// most [`MAX_OWNED_SUBENTITIES`]. +/// +/// # Safety +/// `dwg` must be live and `obj` a valid INSERT object of it, converted at +/// `depth`. +unsafe fn owned_insert_attribs( + dwg: *mut libredwg_sys::Dwg_Data, + text: &TextDecoder, + obj: *mut libredwg_sys::Dwg_Object, + depth: u32, +) -> Vec { + let mut attribs = Vec::new(); + let mut walked = 0usize; + let mut sub = unsafe { libredwg_sys::get_first_owned_subentity(obj) }; + while !sub.is_null() && walked < MAX_OWNED_SUBENTITIES { + walked += 1; + if !unsafe { is_attrib(sub) } { + break; + } + if let Some(Entity::Attrib(attrib)) = unsafe { convert_entity(dwg, text, sub, depth + 1) } { + attribs.push(attrib); + } + sub = unsafe { libredwg_sys::get_next_owned_subentity(obj, sub) }; + } + attribs +} + +/// # Safety +/// `obj` must be a valid, non-null `Dwg_Object`. +unsafe fn is_attrib(obj: *mut libredwg_sys::Dwg_Object) -> bool { + // Cast for cross-platform bindgen enum-width consistency -- see + // convert_entities(). + (unsafe { libredwg_sys::dwg_object_get_fixedtype(obj) } as libredwg_sys::DWG_OBJECT_TYPE) + == libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ATTRIB +} + /// Copies the points LibreDWG's own `dwg_object_polyline_{2,3}d_get_points` /// returns into an owned `Vec`, then frees its buffer. /// @@ -354,8 +425,11 @@ unsafe fn polyline_pface_wireframe(obj: *mut libredwg_sys::Dwg_Object) -> Vec<[P let mut positions = Vec::new(); let mut faces: Vec<[i16; 4]> = Vec::new(); + // Bounded against a chain damaged handles turned into a ring. + let mut walked = 0usize; let mut sub = unsafe { libredwg_sys::get_first_owned_subentity(obj) }; - while !sub.is_null() { + while !sub.is_null() && walked < MAX_OWNED_SUBENTITIES { + walked += 1; let sub_fixedtype = unsafe { libredwg_sys::dwg_object_get_fixedtype(sub) } as libredwg_sys::DWG_OBJECT_TYPE; let sub_entity_ptr = unsafe { libredwg_sys::uncad_object_entity_ptr(sub) }; @@ -464,6 +538,10 @@ fn wipeout_boundary(entity_ptr: *mut std::ffi::c_void) -> Vec { .collect() } +/// `depth` is how many owned-*sub*entity steps were taken to reach `obj` (0 +/// for an entity a block owns); past [`MAX_SUBENTITY_DEPTH`] nothing is +/// converted. +/// /// # Safety /// `dwg` must be the live `Dwg_Data` `obj` was obtained from; `obj` must be a /// valid pointer from `dwg_get_object` on that same `Dwg_Data`. @@ -471,7 +549,11 @@ unsafe fn convert_entity( dwg: *mut libredwg_sys::Dwg_Data, text: &TextDecoder, obj: *mut libredwg_sys::Dwg_Object, + depth: u32, ) -> Option { + if depth > MAX_SUBENTITY_DEPTH { + return None; + } // Cast for cross-platform bindgen enum-width consistency -- see the // comment on the same call in convert_entities() above. let fixedtype = @@ -763,18 +845,9 @@ unsafe fn convert_entity( // from the INSERT's own Dwg_Object rather than from entity_ptr // (the type-specific struct dynapi needs, a different pointer). let attribs = if unsafe { libredwg_sys::uncad_dwg_is_r13_to_r2000(dwg) } != 0 { - unsafe { chained_insert_attribs(dwg, text, entity_ptr) } + unsafe { chained_insert_attribs(dwg, text, entity_ptr, depth) } } else { - let mut attribs = Vec::new(); - let mut sub = unsafe { libredwg_sys::get_first_owned_subentity(obj) }; - while !sub.is_null() { - if let Some(Entity::Attrib(attrib)) = unsafe { convert_entity(dwg, text, sub) } - { - attribs.push(attrib); - } - sub = unsafe { libredwg_sys::get_next_owned_subentity(obj, sub) }; - } - attribs + unsafe { owned_insert_attribs(dwg, text, obj, depth) } }; Entity::Insert(InsertEntity { diff --git a/crates/uncad/tests/corrupt_dwg.rs b/crates/uncad/tests/corrupt_dwg.rs new file mode 100644 index 0000000..e97d75e --- /dev/null +++ b/crates/uncad/tests/corrupt_dwg.rs @@ -0,0 +1,82 @@ +//! A corrupt DWG must come back as a value or a `ParseError`, never as a +//! dead process -- here for the one place this crate's own conversion +//! recurses. (The corruptions that kill the process inside LibreDWG itself +//! are pinned with the vendored patches that fix them, in +//! `tests/vendored_patches.rs`.) + +use std::fs; +use std::path::PathBuf; + +const EXAMPLE_2000: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dwg" +); + +/// Removes its file on drop, so a failing assertion leaves nothing behind. +struct TempFile(PathBuf); + +impl Drop for TempFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +/// Three bytes of `example_2000.dwg`, bisected out of a fuzzed file's 1 155 +/// mutated offsets, that damage an INSERT's attribute chain. +/// +/// An INSERT owns its ATTRIBs, so converting one converts them too -- the +/// single place the conversion recurses. With these bytes the chain leads +/// back to the INSERT, and a walk that converted whatever the chain pointed +/// at recursed until the stack ran out: not deep recursion but endless, since +/// a 512 MB stack did not survive it either (STATUS_STACK_OVERFLOW, +/// 0xC00000FD). The decoder itself is untouched by this: reading the same +/// bytes without converting them returns normally. +/// +/// The walk converts only ATTRIBs, stops at the first object that is not +/// one, converts them one level down with a cap on the depth, and hands back +/// at most `MAX_OWNED_SUBENTITIES` (100,000) of them. +const ATTRIB_CHAIN_FLIPS: [(usize, u8, u8); 3] = [ + (581_356, 0x32, 0xC4), + (581_784, 0xC6, 0x6A), + (582_336, 0x34, 0x30), +]; + +#[test] +fn an_inserts_attribute_chain_leading_back_to_itself_does_not_kill_the_process() { + let mut bytes = fs::read(EXAMPLE_2000).expect("the corpus DWG is readable"); + for (offset, original, corrupt) in ATTRIB_CHAIN_FLIPS { + assert_eq!( + bytes.get(offset).copied(), + Some(original), + "the corpus file changed: byte {offset} is no longer the one this \ + regression was minimised against" + ); + bytes[offset] = corrupt; + } + let path = std::env::temp_dir().join(format!("uncad-{}-attrib-chain.dwg", std::process::id())); + fs::write(&path, &bytes).expect("the temp dir should be writable"); + let file = TempFile(path); + + // Reaching the next line at all is the assertion. + let drawing = uncad::parse(&file.0).expect("the corrupted file still parses"); + + // The damage is reported, not hidden: LibreDWG met handles it could not + // resolve. + assert!( + !drawing.read_diagnostics.is_clean(), + "a damaged drawing read with nothing to say" + ); + // And the walk terminated with a sane answer rather than by running out + // of some other resource: a ring would have yielded the cap. + let attribs: usize = drawing + .tables + .block_records + .values() + .flat_map(|b| b.entities.iter()) + .filter_map(|e| match e { + uncad::Entity::Insert(i) => Some(i.attribs.len()), + _ => None, + }) + .sum(); + assert!(attribs < 100_000, "{attribs} attributes were collected"); +} From f64350a63fdd9cb1201577ee4e2c3f83b15715ea Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:00:06 +0900 Subject: [PATCH 12/54] Read every vertex of a POLYLINE, and no VERTEX as an entity LibreDWG's dwg_object_polyline_{2,3}d_get_points walk first_vertex .. last_vertex with a loop whose condition ends before its body sees last_vertex, so on every file older than R2004 the last vertex was dropped: a closed square came back a triangle, a two-vertex arc a single point. The vertices now come from the polyline's own owned-subentity chain, which stops at last_vertex and, from R2004 on, indexes the same vertex[] array; a drawing older than R13 fills neither, and there the VERTEX records that follow the polyline in the object list are read, as LibreDWG's own pre-R13 branch does. The four accessor allowlist entries in libredwg-sys go with them. The walk that lists a block's entities for versions other than R13..R2000 skips the five VERTEX kinds, as the R13..R2000 chain walk already did: the DXF importer puts every object between a BLOCK and its ENDBLK into that list. Measured against the files themselves: all 22 POLYLINEs whose VERTEX records can be counted in a corpus DXF or fixture now have exactly that many vertices (the accessors gave 20 of them one too few), the DWG twins agree with their DXFs, and all 12,250 POLYLINE_2D/3D vertex lists (entities and block records over the 235 oracle inputs) equal the feature branch's; the 69 that differ from upstream's are each one vertex longer. corpus_sweep's layer-reference total goes from 64,733 to 64,671: the 62 VERTEX_* Unknown entities seven pre-R13 DXFs listed as block content (r2.6, r2.10, r9, r10, both r11, r12 Leader) are gone, and nothing else changed in the sweep. Ported-from: efd43cc Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ crates/libredwg-sys/build.rs | 7 +- crates/uncad/src/convert.rs | 162 ++++++++++++++++++++--------- crates/uncad/tests/corpus_sweep.rs | 8 +- crates/uncad/tests/fixtures.rs | 69 ++++++++++++ docs/CAVEATS.md | 38 +++++-- 6 files changed, 226 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a53e8b..6e26943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,14 @@ Notable changes to this project are recorded here. The format follows the patches. `build.rs` refuses to build when a patch's marker has gone missing, which a re-vendor through `scripts/sync-libredwg-vendor.sh` would otherwise do in silence. +- **Every vertex of a POLYLINE_2D/3D is read.** LibreDWG's point accessors end their walk + one vertex early on every file older than R2004, so the last vertex was dropped: a closed + square came back a triangle, a two-vertex arc a single point. The vertices now come from + the polyline's own subentity chain; all 22 POLYLINEs of the corpus DXFs and the + fixtures whose vertices can be counted in the file match that count (the accessors had + 20 of them one short). A polyline's VERTEX records are no longer reported as entities of + their own either -- 62 `Unknown` VERTEX entities in seven pre-R13 DXFs. See + `docs/CAVEATS.md`, "Polyline vertices come from the polyline's own chain". - `MTextEntity::rotation` is the direction of the text's X axis instead of a constant `0`, which reported rotated multi-line text as horizontal. - A two-line angular dimension's `definition_point` (DXF 10) is read instead of reported as diff --git a/crates/libredwg-sys/build.rs b/crates/libredwg-sys/build.rs index 5d58906..77c9e3b 100644 --- a/crates/libredwg-sys/build.rs +++ b/crates/libredwg-sys/build.rs @@ -226,18 +226,13 @@ fn main() { .allowlist_function("get_next_owned_entity") .allowlist_function("get_first_owned_subentity") .allowlist_function("get_next_owned_subentity") - .allowlist_function("dwg_object_polyline_2d_get_numpoints") - .allowlist_function("dwg_object_polyline_2d_get_points") - .allowlist_function("dwg_object_polyline_3d_get_numpoints") - .allowlist_function("dwg_object_polyline_3d_get_points") // The next object in file order. Pre-R13 files fill neither a // POLYLINE's `first_vertex` nor its `vertex[]`, so its vertices are // reachable only by object order there -- which is what LibreDWG's own // pre-R13 branch walks. .allowlist_function("dwg_next_object") // Named explicitly because src/lib.rs's hand-written - // Dwg_MLINE_vertex refers to it, not only because the polyline - // accessors above return it. + // Dwg_MLINE_vertex refers to it. .allowlist_type("dwg_point_3d") .allowlist_function("uncad_object_entity_ptr") .allowlist_function("uncad_object_object_ptr") diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 26f4e35..7d6128f 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -144,6 +144,16 @@ pub unsafe fn convert_entities( /// and by every block's own entry in [`crate::tables::Tables::block_records`], /// so the two stay in sync as entity types are added. /// +/// A polyline's VERTEX records are skipped: they belong to the POLYLINE that +/// owns them and are read from *its* chain (see [`polyline_subentities`]), +/// not drawing content of the block. That is the contract LibreDWG +/// documents for `get_next_owned_entity` ("Not subentities: ATTRIB, +/// VERTEX") and what the R13..R2000 walk here implements -- but the +/// library's walker for other versions hands back whatever its list holds, +/// and the DXF importer fills that list with every object between a BLOCK +/// and its ENDBLK. Each polyline's vertices came back as entities of their +/// own: in a drawing older than R13, one `VERTEX_2D` "entity" per vertex. +/// /// # Safety /// `dwg` must be the live `Dwg_Data` `block_obj` was obtained from; /// `block_obj` must be a valid, non-null `BLOCK_HEADER` object. @@ -158,6 +168,12 @@ pub(crate) unsafe fn owned_entities( let mut entities = Vec::new(); let mut owned = unsafe { libredwg_sys::get_first_owned_entity(block_obj) }; while !owned.is_null() { + let fixedtype = unsafe { libredwg_sys::dwg_object_get_fixedtype(owned) } + as libredwg_sys::DWG_OBJECT_TYPE; + if is_polyline_vertex(fixedtype) { + owned = unsafe { libredwg_sys::get_next_owned_entity(block_obj, owned) }; + continue; + } if let Some(entity) = unsafe { convert_entity(dwg, text, owned, 0) } { entities.push(entity); } @@ -199,15 +215,22 @@ unsafe fn referenced_object( /// so a chain walk has to step over them. ATTDEF is *not* one of these: an /// attribute definition is an ordinary block-owned entity. fn is_sub_entity(fixedtype: libredwg_sys::DWG_OBJECT_TYPE) -> bool { + fixedtype == libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ATTRIB + || fixedtype == libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_SEQEND + || is_polyline_vertex(fixedtype) +} + +/// `true` for the five VERTEX kinds an old-style POLYLINE owns -- the list +/// LibreDWG's own `get_next_owned_entity` skips ("Not subentities: ATTRIB, +/// VERTEX", dwg.c). +fn is_polyline_vertex(fixedtype: libredwg_sys::DWG_OBJECT_TYPE) -> bool { matches!( fixedtype, - libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ATTRIB - | libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_2D + libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_2D | libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_3D | libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_MESH | libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_PFACE | libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_PFACE_FACE - | libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_SEQEND ) } @@ -377,35 +400,79 @@ unsafe fn is_attrib(obj: *mut libredwg_sys::Dwg_Object) -> bool { == libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ATTRIB } -/// Copies the points LibreDWG's own `dwg_object_polyline_{2,3}d_get_points` -/// returns into an owned `Vec`, then frees its buffer. +/// Every subentity an old-style POLYLINE owns, in order. +/// +/// Walks the owned-subentity chain (`get_first`/`get_next_owned_subentity`, +/// dwg.c) rather than calling LibreDWG's own +/// `dwg_object_polyline_{2,3}d_get_points`, because those are short by one on +/// every R13/R14/R2000 file: for `version < R_2004` both the point and the +/// count accessor walk `first_vertex .. last_vertex` as +/// `do { ... } while ((vobj = dwg_next_object (vobj)) && vobj != vlast);` +/// (dwg_api.c), whose condition ends the loop *before* the body sees +/// `vlast`. They returned N-1 points, and the last vertex of every such +/// polyline was dropped -- a closed square came back a triangle, a +/// two-vertex arc a single point. `get_next_owned_subentity` stops *at* +/// `last_vertex` and yields all N; from R2004 on it indexes the `vertex[]` +/// array by `num_owned`, the same source the accessors use there. /// -/// Those dedicated C functions are used rather than a generic owned-subentity -/// walk because their traversal is version-dependent (pre-R2004 files chain -/// `first_vertex..last_vertex` through the raw object list). A generic walk was -/// tried and over-collected a vertex on a real file. +/// Files older than R13 fill neither `first_vertex` nor `vertex[]`, so that +/// chain is empty for them, and the fallback is the scan LibreDWG's own +/// pre-R13 branch uses: forward through the object list, which is where such +/// a polyline's vertices physically are, stopping at the first object that +/// is not a VERTEX (the SEQEND, in a well-formed file). +/// +/// Both walks are bounded against a chain damaged handles turned into a ring +/// ([`MAX_OWNED_SUBENTITIES`]). /// /// # Safety -/// `obj` must be a valid `POLYLINE_2D`/`POLYLINE_3D` object matching the -/// accessors passed in, and `T` must have the same layout as the -/// `dwg_point_2d`/`dwg_point_3d` they return. -unsafe fn read_polyline_points( +/// `obj` must be a valid, non-null `POLYLINE_2D`/`POLYLINE_3D`/ +/// `POLYLINE_PFACE`/`POLYLINE_MESH` `Dwg_Object`. +unsafe fn polyline_subentities( obj: *mut libredwg_sys::Dwg_Object, - get_points: unsafe extern "C" fn(*const libredwg_sys::Dwg_Object, *mut i32) -> *mut P, - get_num_points: unsafe extern "C" fn(*const libredwg_sys::Dwg_Object, *mut i32) -> u32, -) -> Vec { - let mut error = 0i32; - let points_ptr = unsafe { get_points(obj, &mut error) }; - let num_points = unsafe { get_num_points(obj, &mut error) }; - if points_ptr.is_null() || num_points == 0 { - return Vec::new(); +) -> Vec<*mut libredwg_sys::Dwg_Object> { + let mut subs = Vec::new(); + let mut sub = unsafe { libredwg_sys::get_first_owned_subentity(obj) }; + while !sub.is_null() && subs.len() < MAX_OWNED_SUBENTITIES { + subs.push(sub); + sub = unsafe { libredwg_sys::get_next_owned_subentity(obj, sub) }; + } + if !subs.is_empty() { + return subs; + } + let mut next = unsafe { libredwg_sys::dwg_next_object(obj) }; + while !next.is_null() && subs.len() < MAX_OWNED_SUBENTITIES { + let fixedtype = unsafe { libredwg_sys::dwg_object_get_fixedtype(next) } + as libredwg_sys::DWG_OBJECT_TYPE; + if !is_polyline_vertex(fixedtype) { + break; + } + subs.push(next); + next = unsafe { libredwg_sys::dwg_next_object(next) }; } - // SAFETY: on success the accessor calloc's exactly num_points entries of - // the layout T mirrors; copied out here before the buffer is freed. - let points = - unsafe { std::slice::from_raw_parts(points_ptr.cast::(), num_points as usize) }.to_vec(); - unsafe { libc::free(points_ptr.cast()) }; - points + subs +} + +/// The type-specific struct pointers of the vertices of `vertex_type` an +/// old-style POLYLINE owns, in order -- what each vertex's fields are read +/// through. +/// +/// # Safety +/// `obj` must be a valid, non-null POLYLINE `Dwg_Object` per +/// [`polyline_subentities`]. +unsafe fn polyline_vertices( + obj: *mut libredwg_sys::Dwg_Object, + vertex_type: libredwg_sys::DWG_OBJECT_TYPE, +) -> Vec<*mut std::ffi::c_void> { + unsafe { polyline_subentities(obj) } + .into_iter() + .filter(|&sub| { + (unsafe { libredwg_sys::dwg_object_get_fixedtype(sub) } + as libredwg_sys::DWG_OBJECT_TYPE) + == vertex_type + }) + .map(|sub| unsafe { libredwg_sys::uncad_object_entity_ptr(sub) }) + .filter(|ptr| !ptr.is_null()) + .collect() } /// Resolves a POLYLINE_PFACE's mesh into wireframe edges by walking its owned @@ -425,11 +492,7 @@ unsafe fn polyline_pface_wireframe(obj: *mut libredwg_sys::Dwg_Object) -> Vec<[P let mut positions = Vec::new(); let mut faces: Vec<[i16; 4]> = Vec::new(); - // Bounded against a chain damaged handles turned into a ring. - let mut walked = 0usize; - let mut sub = unsafe { libredwg_sys::get_first_owned_subentity(obj) }; - while !sub.is_null() && walked < MAX_OWNED_SUBENTITIES { - walked += 1; + for sub in unsafe { polyline_subentities(obj) } { let sub_fixedtype = unsafe { libredwg_sys::dwg_object_get_fixedtype(sub) } as libredwg_sys::DWG_OBJECT_TYPE; let sub_entity_ptr = unsafe { libredwg_sys::uncad_object_entity_ptr(sub) }; @@ -446,7 +509,6 @@ unsafe fn polyline_pface_wireframe(obj: *mut libredwg_sys::Dwg_Object) -> Vec<[P } } } - sub = unsafe { libredwg_sys::get_next_owned_subentity(obj, sub) }; } let mut edges = Vec::new(); @@ -1025,15 +1087,13 @@ unsafe fn convert_entity( }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_POLYLINE_3D => { - // SAFETY: obj is a POLYLINE_3D per fixedtype, and Point3D mirrors - // dwg_point_3d's layout. - let vertices: Vec = unsafe { - read_polyline_points( - obj, - libredwg_sys::dwg_object_polyline_3d_get_points, - libredwg_sys::dwg_object_polyline_3d_get_numpoints, - ) - }; + // SAFETY: obj is a POLYLINE_3D per fixedtype; the vertices it + // owns are VERTEX_3D. + let vertices: Vec = + unsafe { polyline_vertices(obj, libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_3D) } + .into_iter() + .filter_map(|vertex| get_point3d(vertex, "VERTEX_3D", "point")) + .collect(); // POLYLINE_3D.flag is BITCODE_RC (1 byte), unlike LWPOLYLINE's // BITCODE_BS (2 bytes) -- same closed-bit convention, different // underlying C width. @@ -1045,15 +1105,15 @@ unsafe fn convert_entity( }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_POLYLINE_2D => { - // SAFETY: obj is a POLYLINE_2D per fixedtype, and Point2D mirrors - // dwg_point_2d's layout. - let vertices: Vec = unsafe { - read_polyline_points( - obj, - libredwg_sys::dwg_object_polyline_2d_get_points, - libredwg_sys::dwg_object_polyline_2d_get_numpoints, - ) - }; + // SAFETY: obj is a POLYLINE_2D per fixedtype; the vertices it + // owns are VERTEX_2D. A VERTEX_2D's stored point is 3D, but its z + // is the polyline's own elevation repeated. + let vertices: Vec = + unsafe { polyline_vertices(obj, libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_2D) } + .into_iter() + .filter_map(|vertex| get_point3d(vertex, "VERTEX_2D", "point")) + .map(|p| Point2D { x: p.x, y: p.y }) + .collect(); let flag = get_field::(entity_ptr, "POLYLINE_2D", "flag").unwrap_or(0); Entity::Polyline2D(LwPolylineEntity { common, diff --git a/crates/uncad/tests/corpus_sweep.rs b/crates/uncad/tests/corpus_sweep.rs index ef6adc5..8697287 100644 --- a/crates/uncad/tests/corpus_sweep.rs +++ b/crates/uncad/tests/corpus_sweep.rs @@ -233,8 +233,12 @@ fn the_corpus_distribution_is_what_it_was_when_last_measured() { // 64,697 before the attribute-chain fix: 36 more entities (all ATTDEFs in // blocks with several of them, in the R2000 and R13/R14 files) are read // now that this crate walks the R13..R2000 block chain itself instead of - // through the library's walker, which skipped them. - assert_eq!(layers, 64_733); + // through the library's walker, which skipped them. 64,733 then, and 62 + // fewer since a polyline's VERTEX records are no longer entities of the + // block that holds the polyline: seven pre-R13 DXFs (r2.6, r2.10, r9, + // r10, both r11 ones and r12's Leader) listed each polyline's vertices + // again as `Unknown` entities of their own. + assert_eq!(layers, 64_671); // --- reference IDs: the handle-derived scheme yields no duplicate in any // file, and the index fallback is measured, not assumed --- diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index ba6fc27..3955bb9 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -311,3 +311,72 @@ fn the_title_block_fixture_keeps_every_string_in_paper_space() { assert_eq!(texts("*Paper_Space"), ["GARDEN PAVILION"]); assert_eq!(texts("TITLEBLOCK"), ["SHEET 1 OF 2"]); } + +// --------------------------------------------------- polyline vertices + +/// Regression for the last vertex LibreDWG's own +/// `dwg_object_polyline_{2,3}d_get_points` drop on every R13/R14/R2000 file. +/// The vertices are the ones `make_fixtures.py` writes: a 100 by 100 closed +/// square, a two-vertex polyline whose one bulge makes it a semicircle, and a +/// five-point 3D polyline. Read through the library's accessors each came +/// back one vertex short: the square a right triangle, the semicircle a +/// single point, the 3D polyline ending at (0, 10, 5). +#[test] +fn every_polyline_vertex_survives_the_r2000_subentity_chain() { + let db = parse(POLYLINE_VERTICES); + assert_eq!( + type_counts(&db), + expected(&[("POLYLINE_2D", 2), ("POLYLINE_3D", 1)]) + ); + + let square = match &db.entities[0] { + Entity::Polyline2D(p) => p, + other => panic!("expected the square first, got {other:?}"), + }; + assert_eq!(square.common.source_handle, resolved("30")); + assert!(square.closed); + assert_eq!( + square + .vertices + .iter() + .map(|v| (v.x, v.y)) + .collect::>(), + [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)] + ); + + let arc = match &db.entities[1] { + Entity::Polyline2D(p) => p, + other => panic!("expected the arc polyline second, got {other:?}"), + }; + assert_eq!(arc.common.source_handle, resolved("35")); + assert!(!arc.closed); + assert_eq!( + arc.vertices.iter().map(|v| (v.x, v.y)).collect::>(), + [(0.0, 1000.0), (100.0, 1000.0)] + ); + + let p3d = match &db.entities[2] { + Entity::Polyline3D(p) => p, + other => panic!("expected the 3D polyline third, got {other:?}"), + }; + assert_eq!(p3d.common.source_handle, resolved("39")); + assert!( + !p3d.closed, + "group 70 = 8 is a 3D polyline, not a closed one" + ); + assert_eq!( + p3d.vertices + .iter() + .map(|v| (v.x, v.y, v.z)) + .collect::>(), + [ + (0.0, 0.0, 0.0), + (10.0, 0.0, 0.0), + (10.0, 10.0, 0.0), + (0.0, 10.0, 5.0), + (0.0, 0.0, 5.0), + ] + ); + // And no VERTEX record is an entity of the block that holds them. + assert_eq!(db.tables.block_records["*Model_Space"].entities.len(), 3); +} diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 46b65c8..8fbec1c 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -91,13 +91,14 @@ against the object's actual dxfname and silently fails on a mismatch, so `"3DSOL cannot be hardcoded and `extract_wireframe()` takes the name as an argument. Rendering shares the isometric wireframe path. -**POLYLINE_PFACE** ("polyface mesh") could not reuse a dedicated C function the way -POLYLINE_3D does: LibreDWG's own `dwg_ent_polyline_pface_get_points` is marked -`/* not implemented. use the dynapi instead */` in `dwg_api.h`. Instead the -`VERTEX_PFACE` (vertex positions) and `VERTEX_PFACE_FACE` (up to 4 vertex indices per -face) subentity chain is walked with `get_first_owned_subentity`, and each face's indices -become wireframe edges -- rendered through the same isometric path as REGION, a polyface -mesh being just as inherently 3D as an ACIS solid's wireframe. +**POLYLINE_PFACE** ("polyface mesh") has no working accessor in LibreDWG: its own +`dwg_ent_polyline_pface_get_points` is marked `/* not implemented. use the dynapi +instead */` in `dwg_api.h`. Instead the `VERTEX_PFACE` (vertex positions) and +`VERTEX_PFACE_FACE` (up to 4 vertex indices per face) subentities are read from the +polyline's own chain -- the one POLYLINE_2D/3D vertices come from, see "Polyline vertices +come from the polyline's own chain" -- and each face's indices become wireframe edges, +carried like a REGION's: a polyface mesh is just as inherently 3D as an ACIS solid's +wireframe. **TOLERANCE** renders exactly like ATTRIB/TEXT (position plus text), except that `text_value` still carries GD&T feature-control-frame codes (`%%v` and similar), which @@ -403,6 +404,29 @@ corpus showed that not one of its 1,137 LWPOLYLINEs had ever been reported close constants in `crates/uncad/src/convert.rs` (`POLYLINE_CLOSED_FLAG`, `LWPOLYLINE_CLOSED_FLAG`) carry the distinction. +## Polyline vertices come from the polyline's own chain + +An old-style POLYLINE (2D, 3D, polyface, polygon mesh) owns its vertices as VERTEX +records, closed by a SEQEND. They are read here by walking the polyline's own +owned-subentity chain (`get_first_owned_subentity` / `get_next_owned_subentity`), not +through LibreDWG's `dwg_object_polyline_{2,3}d_get_points`: for every file older than +R2004 those walk `first_vertex .. last_vertex` with a loop whose condition ends *before* +its body sees `last_vertex`, and returned one vertex short -- a closed square came back a +triangle, a two-vertex arc a single point. Measured on the DXFs of the corpus +(`example_2000`, `example_2004`, `example_r13`, `example_r14`, `2000/PolyLine2D`, +`2000/PolyLine3D`, `r12/Leader`) and the `polyline_vertices_r2000.dxf` fixture: every one +of the 22 POLYLINEs whose VERTEX records can be counted in the file now has exactly that +many vertices, where the accessors had given 20 of them one too few; the DWG twins give +the same vertices as their DXFs. Files older than R13 do not fill that chain, so there +the vertices are the VERTEX records that follow the polyline in the object list, as in +LibreDWG's own pre-R13 branch. + +The VERTEX records themselves are not entities of the block that holds the polyline. The +R13..R2000 block walk here skips them, and so does the walk for every other version, whose +list the DXF importer fills with every object between a BLOCK and its ENDBLK: seven +pre-R13 DXFs in the corpus reported each polyline's vertices a second time, as 62 +`Unknown` entities. + ## Polyline bulges are not carried A polyline vertex can carry a bulge: the segment to the next vertex is then an arc, not a From 29547b1fc25f02716dad650e879af61ae6f903bb Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:07:46 +0900 Subject: [PATCH 13/54] Read a layer's state, lineweight and linetype LayerRecord's off, frozen, locked, plot, lineweight and linetype were the defaults for a file that states nothing. They are read now: - off/frozen/locked from the bits LibreDWG's DWG decoder fills; for a DXF from the raw group 70 (1 frozen, 4 locked) and the colour's sign, because the importer applies the binary layout to group 70 (2 off, 4 frozen in new viewports, 8 locked). A negative colour is "off" for either reader, as the model documents. - plot (290) and lineweight (370) from R2000 on. A DWG states both; the DXF importer leaves an absent group at 0, so a DXF's 290 = 0 and its silence are both None, and a lineweight code of 0 is None. Codes map through the lineweight table to hundredths of a millimetre, 31 to -3; BYLAYER/BYBLOCK and the unused codes are not a layer's weight. - linetype through the same reference resolver as every other table reference, so a pre-R13 drawing's index resolves too. The regenerated golden expectations for G1-G10 (they only add the model's new keys) now pass whole: their layers name CONTINUOUS. tests/layer_state.rs checks the hidden-layers fixture and the example_2000 DWG/DXF twins. A/B over the 717 layers the feature branch also read (corpus, fixtures, samples): off, frozen and locked agree everywhere; plot agrees for every R2000+ DWG layer (557 plotted, 25 not) and is None where the feature branch said "plots" without the file saying so (90 DXF, 45 pre-R2000 DWG layers); lineweights agree (the feature's "none" is the model's -3); the linetype differs on 28 layers of pre-R13 DWGs, which now resolve to CONTINUOUS through the table index where the feature branch had an empty name. Ported-from: c045d6a Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + crates/uncad/src/convert.rs | 2 +- crates/uncad/src/dynapi.rs | 29 ++ crates/uncad/src/table_convert.rs | 98 +++- crates/uncad/tests/golden/g1.expected.json | 513 ++++++++++++++++++-- crates/uncad/tests/golden/g10.expected.json | 28 +- crates/uncad/tests/golden/g2.expected.json | 42 +- crates/uncad/tests/golden/g5.expected.json | 188 ++++++- crates/uncad/tests/golden/g6.expected.json | 14 +- crates/uncad/tests/golden/g7.expected.json | 239 ++++++++- crates/uncad/tests/golden/g8.expected.json | 379 ++++++++++++++- crates/uncad/tests/golden/g9.expected.json | 262 +++++++++- crates/uncad/tests/layer_state.rs | 116 +++++ docs/ARCHITECTURE.md | 6 +- docs/CAVEATS.md | 16 + 15 files changed, 1787 insertions(+), 150 deletions(-) create mode 100644 crates/uncad/tests/layer_state.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e26943..7a06f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ Notable changes to this project are recorded here. The format follows hidden visibility states are the common case (5,499 of the test corpus's entities). - An ELLIPSE carries its `extrusion` (DXF 210), the normal of its plane: a mirrored ellipse's parameters run the other way. +- A layer's state is read: `off`, `frozen`, `locked`, `plot` (DXF 290), `lineweight` + (DXF 370, hundredths of a millimetre or -3 for the default) and the `linetype` it names. + A DXF's plot flag and lineweight are `None` where its importer cannot tell a stated 0 + from an absent group, and a drawing older than R2000 states neither. See + `docs/CAVEATS.md`, "Layer state: what a DXF cannot say". ### Changed - A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 7d6128f..165bc84 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -1829,7 +1829,7 @@ fn entity_reference( /// `Unresolved("idx:")` -- the index is kept the way a handle would be. /// - From R13 on, a handle whose value is zero is a reference the file does /// not carry (a DIMENSION without a block, for instance): `Absent`. -fn reference( +pub(crate) fn reference( dwg: *mut libredwg_sys::Dwg_Data, text: &TextDecoder, handle_ptr: Option<*mut libredwg_sys::Dwg_Object_Ref>, diff --git a/crates/uncad/src/dynapi.rs b/crates/uncad/src/dynapi.rs index e6c517f..c4b82d6 100644 --- a/crates/uncad/src/dynapi.rs +++ b/crates/uncad/src/dynapi.rs @@ -365,6 +365,35 @@ pub fn is_pre_r13(dwg: *mut libredwg_sys::Dwg_Data) -> bool { unsafe { libredwg_sys::uncad_dwg_is_pre_r13(dwg) != 0 } } +/// `true` when the drawing was read from a DXF rather than a DWG. The two +/// readers leave some fields in different states -- the DXF importer +/// applies the binary format's bit layout to a LAYER's group 70, and fills +/// a two-line angular dimension's points by group code rather than in +/// stream order -- so a field's meaning can depend on which of them read it. +pub fn is_from_dxf(dwg: *mut libredwg_sys::Dwg_Data) -> bool { + if dwg.is_null() { + return false; + } + // SAFETY: dwg is a live Dwg_Data (caller contract, same as the rest of + // this crate's conversion pass); the shim null-checks it again itself. + unsafe { libredwg_sys::uncad_dwg_from_dxf(dwg) != 0 } +} + +/// `true` when the drawing is R2000 or later: the first version whose +/// LAYER records carry a plot flag and a lineweight. +pub fn is_r2000_or_later(dwg: *mut libredwg_sys::Dwg_Data) -> bool { + if dwg.is_null() { + return false; + } + // SAFETY: as `is_from_dxf`. + let version = unsafe { libredwg_sys::uncad_dwg_version(dwg) }; + // The enum constant's width is whatever bindgen inferred for the target + // (see convert.rs on DWG_OBJECT_TYPE); the shim returns a plain int. + #[allow(clippy::unnecessary_cast)] + let r2000 = libredwg_sys::DWG_VERSION_TYPE_R_2000 as i32; + version >= r2000 +} + /// `true` when the drawing was read from an R2010-or-later DWG. The /// library's LEADER record layout loses its place in such a file after the /// annotation offset, so the fields it reads past that point -- the diff --git a/crates/uncad/src/table_convert.rs b/crates/uncad/src/table_convert.rs index 9ff1e50..94debd5 100644 --- a/crates/uncad/src/table_convert.rs +++ b/crates/uncad/src/table_convert.rs @@ -2,8 +2,8 @@ //! against: LAYER, BLOCK_RECORD and MLINESTYLE -- from LibreDWG's structures //! into the model's [`Tables`]. -use crate::convert::owned_entities; -use crate::dynapi::{get_array_field, get_field}; +use crate::convert::{owned_entities, reference}; +use crate::dynapi::{get_array_field, get_field, is_from_dxf, is_r2000_or_later}; use crate::text::TextDecoder; use std::collections::BTreeMap; use std::ffi::c_void; @@ -40,7 +40,7 @@ pub(crate) unsafe fn convert_tables( if fixedtype == libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_LAYER { let object_ptr = unsafe { libredwg_sys::uncad_object_object_ptr(obj) }; if !object_ptr.is_null() { - if let Some(record) = convert_layer(text, object_ptr) { + if let Some(record) = convert_layer(dwg, text, object_ptr) { layers.insert(record.name.clone(), record); } } @@ -193,22 +193,89 @@ fn convert_mlinestyle(text: &TextDecoder, object_ptr: *mut c_void) -> Option<(St Some((name, offsets)) } -fn convert_layer(text: &TextDecoder, object_ptr: *mut c_void) -> Option { +/// Reads a LAYER table entry: its colour, its state and the linetype it +/// names. +/// +/// Which fields state the layer's state depends on who read the file. The +/// binary format's decoder fills the `off`, `frozen` and `locked` bits (for +/// a drawing older than R13 it derives them from group 70 and the colour's +/// sign itself). LibreDWG's DXF importer instead applies the binary layout +/// to group 70 -- bit 2 becomes "off", 4 "frozen in new viewports", 8 +/// "locked" -- where a DXF means 1 frozen, 2 frozen in new viewports, 4 +/// locked, and says "off" with a negative colour; so for a DXF the raw +/// group 70 and the colour's sign are read instead. A negative colour +/// means "off" whoever read it, as [`LayerRecord::color_index`] documents. +/// +/// The plot flag (DXF 290) and the lineweight (DXF 370) exist from R2000 +/// on. A DWG states both. The DXF importer leaves a flag the file left out +/// at 0, so a DXF's stated "do not plot" (290 = 0) and its silence read the +/// same -- `None`, not `Some(false)` -- and likewise a lineweight code of 0 +/// (0.00 mm, or absent). +fn convert_layer( + dwg: *mut libredwg_sys::Dwg_Data, + text: &TextDecoder, + object_ptr: *mut c_void, +) -> Option { let name = text.field(object_ptr, "LAYER", "name")?; let color = get_field::(object_ptr, "LAYER", "color")?; let color_index = resolve_layer_color_index(color.index, color.method, color.rgb); + let bit = |field: &str| get_field::(object_ptr, "LAYER", field).is_some_and(|b| b != 0); + let from_dxf = is_from_dxf(dwg); + let (off, frozen, locked) = if from_dxf { + let flag = get_field::(object_ptr, "LAYER", "flag").unwrap_or(0); + (color.index < 0, flag & 1 != 0, flag & 4 != 0) + } else { + (bit("off") || color.index < 0, bit("frozen"), bit("locked")) + }; + let r2000 = is_r2000_or_later(dwg); + let plot = if !r2000 { + None + } else if from_dxf { + bit("plotflag").then_some(true) + } else { + Some(bit("plotflag")) + }; + let lineweight = get_field::(object_ptr, "LAYER", "linewt") + .filter(|&code| r2000 && !(from_dxf && code == 0)) + .and_then(layer_lineweight); + let linetype = reference( + dwg, + text, + get_field::<*mut libredwg_sys::Dwg_Object_Ref>(object_ptr, "LAYER", "ltype"), + c"LTYPE", + |handle_ptr| text.handle_name(dwg, handle_ptr), + ); Some(LayerRecord { name, color_index, - off: false, - frozen: false, - locked: false, - plot: None, - lineweight: None, - linetype: uncad_model::Ref::Absent, + off, + frozen, + locked, + plot, + lineweight, + linetype, }) } +/// The standard lineweights in hundredths of a millimetre, indexed by the +/// code the library stores in `linewt` (`lweights[]` in its `dwg.c` and +/// `in_dxf.c`). +const LINEWEIGHTS: [i16; 24] = [ + 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, + 211, +]; + +/// A layer's lineweight for the library's `linewt` code: codes 0 to 23 are +/// the standard weights, 31 is "the default" (DXF -3). 29 and 30 are BYLAYER +/// and BYBLOCK, which a layer cannot be, and 24 to 28 are unused: none of +/// those is a lineweight a layer states. +fn layer_lineweight(code: u8) -> Option { + match code { + 31 => Some(-3), + code => LINEWEIGHTS.get(usize::from(code)).copied(), + } +} + /// Recovers a usable ACI index from a LAYER's raw `Dwg_Color` when LibreDWG /// could not. /// @@ -240,6 +307,17 @@ fn resolve_layer_color_index(index: i16, method: libredwg_sys::Dwg_Color_Method, mod tests { use super::*; + #[test] + fn a_layer_lineweight_is_the_standard_weight_or_the_default() { + assert_eq!(layer_lineweight(0), Some(0)); + assert_eq!(layer_lineweight(11), Some(50), "0.50 mm"); + assert_eq!(layer_lineweight(23), Some(211)); + assert_eq!(layer_lineweight(31), Some(-3), "the default"); + for not_a_layer_weight in [24, 28, 29, 30, 32, 255] { + assert_eq!(layer_lineweight(not_a_layer_weight), None); + } + } + #[test] fn truecolor_with_no_palette_match_falls_back_to_rgbs_low_byte() { let resolved = resolve_layer_color_index( diff --git a/crates/uncad/tests/golden/g1.expected.json b/crates/uncad/tests/golden/g1.expected.json index bd6539a..af775cc 100644 --- a/crates/uncad/tests/golden/g1.expected.json +++ b/crates/uncad/tests/golden/g1.expected.json @@ -36,7 +36,16 @@ "y": 100.0 } ], - "closed": true + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "CIRCLE", @@ -61,7 +70,12 @@ "y": 20.0, "z": 0.0 }, - "radius": 5.0 + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "CIRCLE", @@ -86,7 +100,12 @@ "y": 20.0, "z": 0.0 }, - "radius": 5.0 + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "CIRCLE", @@ -111,7 +130,12 @@ "y": 80.0, "z": 0.0 }, - "radius": 5.0 + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "CIRCLE", @@ -136,7 +160,12 @@ "y": 80.0, "z": 0.0 }, - "radius": 5.0 + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "DIMENSION", @@ -193,7 +222,8 @@ "text_rotation": 0.0, "style_name": { "type": "ABSENT" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -250,7 +280,8 @@ "text_rotation": 0.0, "style_name": { "type": "ABSENT" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -307,7 +338,8 @@ "text_rotation": 0.0, "style_name": { "type": "ABSENT" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -360,7 +392,8 @@ "text_rotation": 0.0, "style_name": { "type": "ABSENT" - } + }, + "ordinate_axis": null }, { "type": "INSERT", @@ -419,8 +452,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "common": { @@ -445,8 +498,28 @@ }, "text_height": 3.5, "tag": "REV", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "B", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "common": { @@ -471,10 +544,35 @@ }, "text_height": 3.5, "tag": "MATERIAL", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "SS400", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } - ] + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTRIB", @@ -500,8 +598,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTRIB", @@ -527,8 +645,28 @@ }, "text_height": 3.5, "tag": "REV", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "B", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTRIB", @@ -554,31 +692,96 @@ }, "text_height": 3.5, "tag": "MATERIAL", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "SS400", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ], "tables": { "layers": { "0": { "name": "0", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } }, "DIMS": { "name": "DIMS", - "color_index": 3 + "color_index": 3, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } }, "HOLES": { "name": "HOLES", - "color_index": 1 + "color_index": 1, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } }, "OUTLINE": { "name": "OUTLINE", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } }, "TITLE": { "name": "TITLE", - "color_index": 2 + "color_index": 2, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } } }, "block_records": { @@ -696,7 +899,21 @@ }, "text_height": 2.5, "text": "200", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -814,7 +1031,21 @@ }, "text_height": 2.5, "text": "100", - "rotation": 1.5707963267948966 + "rotation": 1.5707963267948966, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -932,7 +1163,21 @@ }, "text_height": 2.5, "text": "160", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -992,7 +1237,21 @@ }, "text_height": 2.5, "text": "%%C10", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -1035,7 +1294,16 @@ "y": 100.0 } ], - "closed": true + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "CIRCLE", @@ -1060,7 +1328,12 @@ "y": 20.0, "z": 0.0 }, - "radius": 5.0 + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "CIRCLE", @@ -1085,7 +1358,12 @@ "y": 20.0, "z": 0.0 }, - "radius": 5.0 + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "CIRCLE", @@ -1110,7 +1388,12 @@ "y": 80.0, "z": 0.0 }, - "radius": 5.0 + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "CIRCLE", @@ -1135,7 +1418,12 @@ "y": 80.0, "z": 0.0 }, - "radius": 5.0 + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "DIMENSION", @@ -1192,7 +1480,8 @@ "text_rotation": 0.0, "style_name": { "type": "ABSENT" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -1249,7 +1538,8 @@ "text_rotation": 0.0, "style_name": { "type": "ABSENT" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -1306,7 +1596,8 @@ "text_rotation": 0.0, "style_name": { "type": "ABSENT" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -1359,7 +1650,8 @@ "text_rotation": 0.0, "style_name": { "type": "ABSENT" - } + }, + "ordinate_axis": null }, { "type": "INSERT", @@ -1418,8 +1710,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "common": { @@ -1444,8 +1756,28 @@ }, "text_height": 3.5, "tag": "REV", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "B", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "common": { @@ -1470,10 +1802,35 @@ }, "text_height": 3.5, "tag": "MATERIAL", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "SS400", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } - ] + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -1520,7 +1877,16 @@ "y": 20.0 } ], - "closed": true + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTDEF", @@ -1546,8 +1912,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "default_value": "-", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTDEF", @@ -1573,8 +1959,28 @@ }, "text_height": 3.5, "tag": "REV", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "default_value": "-", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTDEF", @@ -1600,14 +2006,35 @@ }, "text_height": 3.5, "tag": "MATERIAL", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "default_value": "-", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] } }, "dim_styles": {}, - "mlinestyles": {} + "mlinestyles": {}, + "layouts": {} }, "read_diagnostics": { "warnings": [] diff --git a/crates/uncad/tests/golden/g10.expected.json b/crates/uncad/tests/golden/g10.expected.json index 7e5dd90..8b3001a 100644 --- a/crates/uncad/tests/golden/g10.expected.json +++ b/crates/uncad/tests/golden/g10.expected.json @@ -62,14 +62,28 @@ "z": 1.0 }, "rotation": 0.0, - "attribs": [] + "attribs": [], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ], "tables": { "layers": { "0": { "name": "0", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } } }, "block_records": { @@ -138,7 +152,12 @@ "z": 1.0 }, "rotation": 0.0, - "attribs": [] + "attribs": [], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -148,7 +167,8 @@ } }, "dim_styles": {}, - "mlinestyles": {} + "mlinestyles": {}, + "layouts": {} }, "read_diagnostics": { "warnings": [] diff --git a/crates/uncad/tests/golden/g2.expected.json b/crates/uncad/tests/golden/g2.expected.json index 7945c88..6bc9574 100644 --- a/crates/uncad/tests/golden/g2.expected.json +++ b/crates/uncad/tests/golden/g2.expected.json @@ -33,14 +33,28 @@ "z": 1.0 }, "rotation": 0.0, - "attribs": [] + "attribs": [], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ], "tables": { "layers": { "0": { "name": "0", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } } }, "block_records": { @@ -80,7 +94,12 @@ "z": 1.0 }, "rotation": 0.0, - "attribs": [] + "attribs": [], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -124,7 +143,12 @@ "z": 2.0 }, "rotation": 0.0, - "attribs": [] + "attribs": [], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -164,7 +188,12 @@ "z": 1.0 }, "rotation": 1.5707963267948966, - "attribs": [] + "attribs": [], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -204,7 +233,8 @@ } }, "dim_styles": {}, - "mlinestyles": {} + "mlinestyles": {}, + "layouts": {} }, "read_diagnostics": { "warnings": [] diff --git a/crates/uncad/tests/golden/g5.expected.json b/crates/uncad/tests/golden/g5.expected.json index 7ab98eb..3179b70 100644 --- a/crates/uncad/tests/golden/g5.expected.json +++ b/crates/uncad/tests/golden/g5.expected.json @@ -55,7 +55,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -112,7 +113,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -169,7 +171,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -227,7 +230,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -284,7 +288,8 @@ "style_name": { "type": "UNRESOLVED", "data": "NOT-DECLARED" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -338,7 +343,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -399,18 +405,37 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null } ], "tables": { "layers": { "0": { "name": "0", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } }, "DIMS": { "name": "DIMS", - "color_index": 3 + "color_index": 3, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } } }, "block_records": { @@ -528,7 +553,21 @@ }, "text_height": 2.5, "text": "<>", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -646,7 +685,21 @@ }, "text_height": 2.5, "text": "", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -764,7 +817,21 @@ }, "text_height": 2.5, "text": " ", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -882,7 +949,21 @@ }, "text_height": 2.5, "text": "125", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -1000,7 +1081,21 @@ }, "text_height": 2.5, "text": "<>", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -1060,7 +1155,21 @@ }, "text_height": 2.5, "text": "%%C20", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -1120,7 +1229,21 @@ }, "text_height": 2.5, "text": "<>", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -1182,7 +1305,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -1239,7 +1363,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -1296,7 +1421,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -1354,7 +1480,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -1411,7 +1538,8 @@ "style_name": { "type": "UNRESOLVED", "data": "NOT-DECLARED" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -1465,7 +1593,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null }, { "type": "DIMENSION", @@ -1526,7 +1655,8 @@ "style_name": { "type": "RESOLVED", "data": "ISO-25" - } + }, + "ordinate_axis": null } ] }, @@ -1547,10 +1677,18 @@ "tolerance_lower": null, "decimal_places": 2, "tolerance_decimal_places": null, - "text_height": 2.5 + "text_height": 2.5, + "arrow_size": null, + "linear_unit_format": null, + "zero_suppression": null, + "rounding": null, + "angular_unit_format": null, + "angular_decimal_places": null, + "fraction_format": null } }, - "mlinestyles": {} + "mlinestyles": {}, + "layouts": {} }, "read_diagnostics": { "warnings": [] diff --git a/crates/uncad/tests/golden/g6.expected.json b/crates/uncad/tests/golden/g6.expected.json index 85a63cb..7b59851 100644 --- a/crates/uncad/tests/golden/g6.expected.json +++ b/crates/uncad/tests/golden/g6.expected.json @@ -63,7 +63,16 @@ "layers": { "0": { "name": "0", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } } }, "block_records": { @@ -136,7 +145,8 @@ } }, "dim_styles": {}, - "mlinestyles": {} + "mlinestyles": {}, + "layouts": {} }, "read_diagnostics": { "warnings": [] diff --git a/crates/uncad/tests/golden/g7.expected.json b/crates/uncad/tests/golden/g7.expected.json index f768dc7..a4e988c 100644 --- a/crates/uncad/tests/golden/g7.expected.json +++ b/crates/uncad/tests/golden/g7.expected.json @@ -36,7 +36,16 @@ "y": -40.0 } ], - "closed": true + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -62,7 +71,21 @@ }, "text_height": 3.5, "text": "DWG NO", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -88,7 +111,21 @@ }, "text_height": 3.5, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -114,7 +151,21 @@ }, "text_height": 3.5, "text": "REV", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -140,7 +191,21 @@ }, "text_height": 3.5, "text": "B", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -166,7 +231,21 @@ }, "text_height": 3.5, "text": "MATERIAL", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -192,18 +271,50 @@ }, "text_height": 3.5, "text": "SS400", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ], "tables": { "layers": { "0": { "name": "0", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } }, "TITLE": { "name": "TITLE", - "color_index": 2 + "color_index": 2, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } } }, "block_records": { @@ -246,7 +357,16 @@ "y": -40.0 } ], - "closed": true + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -272,7 +392,21 @@ }, "text_height": 3.5, "text": "DWG NO", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -298,7 +432,21 @@ }, "text_height": 3.5, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -324,7 +472,21 @@ }, "text_height": 3.5, "text": "REV", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -350,7 +512,21 @@ }, "text_height": 3.5, "text": "B", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -376,7 +552,21 @@ }, "text_height": 3.5, "text": "MATERIAL", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -402,7 +592,21 @@ }, "text_height": 3.5, "text": "SS400", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -412,7 +616,8 @@ } }, "dim_styles": {}, - "mlinestyles": {} + "mlinestyles": {}, + "layouts": {} }, "read_diagnostics": { "warnings": [] diff --git a/crates/uncad/tests/golden/g8.expected.json b/crates/uncad/tests/golden/g8.expected.json index 7676c26..3b97f1b 100644 --- a/crates/uncad/tests/golden/g8.expected.json +++ b/crates/uncad/tests/golden/g8.expected.json @@ -36,7 +36,16 @@ "y": 100.0 } ], - "closed": true + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "INSERT", @@ -95,8 +104,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "common": { @@ -121,8 +150,28 @@ }, "text_height": 3.5, "tag": "MATERIAL", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "SS400 일반구조용 압연강재", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "common": { @@ -147,10 +196,35 @@ }, "text_height": 3.5, "tag": "DRAWN", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "홍길동", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } - ] + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTRIB", @@ -176,8 +250,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTRIB", @@ -203,8 +297,28 @@ }, "text_height": 3.5, "tag": "MATERIAL", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "SS400 일반구조용 압연강재", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTRIB", @@ -230,8 +344,28 @@ }, "text_height": 3.5, "tag": "DRAWN", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "홍길동", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -257,22 +391,63 @@ }, "text_height": 3.5, "text": "축척 1:1", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ], "tables": { "layers": { "0": { "name": "0", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } }, "외형선": { "name": "외형선", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } }, "표제란": { "name": "표제란", - "color_index": 2 + "color_index": 2, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } } }, "block_records": { @@ -315,7 +490,16 @@ "y": 100.0 } ], - "closed": true + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "INSERT", @@ -374,8 +558,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "common": { @@ -400,8 +604,28 @@ }, "text_height": 3.5, "tag": "MATERIAL", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "SS400 일반구조용 압연강재", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "common": { @@ -426,10 +650,35 @@ }, "text_height": 3.5, "tag": "DRAWN", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "홍길동", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } - ] + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "TEXT", @@ -455,7 +704,21 @@ }, "text_height": 3.5, "text": "축척 1:1", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -502,7 +765,16 @@ "y": 20.0 } ], - "closed": true + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTDEF", @@ -528,8 +800,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "default_value": "", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTDEF", @@ -555,8 +847,28 @@ }, "text_height": 3.5, "tag": "MATERIAL", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "default_value": "SS400", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTDEF", @@ -582,14 +894,35 @@ }, "text_height": 3.5, "tag": "DRAWN", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "default_value": "미정", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] } }, "dim_styles": {}, - "mlinestyles": {} + "mlinestyles": {}, + "layouts": {} }, "read_diagnostics": { "warnings": [] diff --git a/crates/uncad/tests/golden/g9.expected.json b/crates/uncad/tests/golden/g9.expected.json index c7b5a85..263feb2 100644 --- a/crates/uncad/tests/golden/g9.expected.json +++ b/crates/uncad/tests/golden/g9.expected.json @@ -57,10 +57,35 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } - ] + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTRIB", @@ -86,8 +111,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "INSERT", @@ -146,10 +191,35 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-2077", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } - ] + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTRIB", @@ -175,19 +245,57 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-2077", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ], "tables": { "layers": { "0": { "name": "0", - "color_index": 7 + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } }, "TITLE": { "name": "TITLE", - "color_index": 2 + "color_index": 2, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } } }, "block_records": { @@ -251,10 +359,35 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-1042", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } - ] + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "INSERT", @@ -313,10 +446,35 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "text": "BP-2077", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } - ] + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] }, @@ -363,7 +521,16 @@ "y": 20.0 } ], - "closed": true + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTDEF", @@ -389,8 +556,28 @@ }, "text_height": 3.5, "tag": "DWGNO", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "default_value": "-", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTDEF", @@ -416,8 +603,28 @@ }, "text_height": 3.5, "tag": "REV", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "default_value": "-", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } }, { "type": "ATTDEF", @@ -443,14 +650,35 @@ }, "text_height": 3.5, "tag": "MATERIAL", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, "default_value": "-", - "rotation": 0.0 + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } } ] } }, "dim_styles": {}, - "mlinestyles": {} + "mlinestyles": {}, + "layouts": {} }, "read_diagnostics": { "warnings": [] diff --git a/crates/uncad/tests/layer_state.rs b/crates/uncad/tests/layer_state.rs new file mode 100644 index 0000000..5b708d2 --- /dev/null +++ b/crates/uncad/tests/layer_state.rs @@ -0,0 +1,116 @@ +//! A layer's state: off, frozen, locked, plotted, its lineweight and the +//! linetype it names. Two sources: the project's `hidden_layers_r2000.dxf` +//! (one layer per state, the group codes in `tests/fixtures/README.md`) and +//! the corpus's `example_2000.dwg` with its text twin, whose `Defpoints` +//! layer is non-plotting and whose `ADSK_SYSTEM_LIGHTS` layer is frozen and +//! locked (DXF group 70 = 5 in the twin). + +use uncad::model::Ref; +use uncad::tables::LayerRecord; + +const HIDDEN: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/hidden_layers_r2000.dxf" +); +const EXAMPLE_2000_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dwg" +); +const EXAMPLE_2000_DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dxf" +); + +/// `(off, frozen, locked)`. +fn state(layer: &LayerRecord) -> (bool, bool, bool) { + (layer.off, layer.frozen, layer.locked) +} + +#[test] +fn the_fixture_layers_carry_the_state_their_groups_state() { + let db = uncad::parse(HIDDEN).expect("fixture must parse"); + let l = &db.tables.layers; + assert_eq!(l.len(), 7, "{:?}", l.keys().collect::>()); + for name in ["0", "VISIBLE", "NOPLOT", "Defpoints"] { + assert_eq!(state(&l[name]), (false, false, false), "{name}"); + } + // Off is a negative colour in a DXF; the index keeps its sign. + assert_eq!(state(&l["OFF"]), (true, false, false)); + assert_eq!(l["OFF"].color_index, -3); + // Group 70 bit 1; bit 4. The importer read both into its own bit + // layout, where they would mean "off" and "frozen in new viewports". + assert_eq!(state(&l["FROZEN"]), (false, true, false)); + assert_eq!(state(&l["LOCKED"]), (false, false, true)); + // The importer leaves an absent 290 at 0, so a stated 0 cannot be told + // from silence: not plotting is not something this DXF can be read to + // say, and every plot flag is unknown rather than a guess. + for layer in l.values() { + assert_eq!(layer.plot, None, "{}", layer.name); + assert_eq!(layer.lineweight, None, "{}: no 370", layer.name); + assert_eq!( + layer.linetype, + Ref::Resolved("Continuous".to_string()), + "{}", + layer.name + ); + } +} + +#[test] +fn autocad_written_layer_state_reads_the_same_from_dwg_and_dxf() { + for (path, from_dwg) in [(EXAMPLE_2000_DWG, true), (EXAMPLE_2000_DXF, false)] { + let db = uncad::parse(path).expect("corpus file must parse"); + let l = &db.tables.layers; + assert_eq!(state(&l["0"]), (false, false, false), "{path}"); + assert_eq!( + state(&l["ADSK_SYSTEM_LIGHTS"]), + (false, true, true), + "{path}" + ); + for layer in l.values() { + assert_eq!( + layer.linetype, + Ref::Resolved("Continuous".to_string()), + "{path}: {}", + layer.name + ); + // 370 = -3 on every layer of the twin: the default weight. + assert_eq!(layer.lineweight, Some(-3), "{path}: {}", layer.name); + } + // The R2000 DWG states the plot flag of every layer; the DXF states + // only Defpoints' 290 = 0, which its importer cannot tell from an + // absent group. + let plot: Vec<(&str, Option)> = + l.values().map(|l| (l.name.as_str(), l.plot)).collect(); + let expected: Vec<(&str, Option)> = if from_dwg { + vec![ + ("0", Some(true)), + ("ADSK_SYSTEM_LIGHTS", Some(true)), + ("Defpoints", Some(false)), + ("Tavolo 2", Some(true)), + ("Tavolo 3", Some(true)), + ] + } else { + l.keys().map(|name| (name.as_str(), None)).collect() + }; + assert_eq!(plot, expected, "{path}"); + } +} + +#[test] +fn a_drawing_older_than_r2000_states_no_plot_flag_and_no_lineweight() { + let db = uncad::parse(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_r14.dwg" + )) + .expect("corpus file must parse"); + assert!(!db.tables.layers.is_empty()); + for layer in db.tables.layers.values() { + assert_eq!( + (layer.plot, layer.lineweight), + (None, None), + "{}", + layer.name + ); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2121c3c..7735f85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -203,8 +203,10 @@ is pinned to `dwg.h`'s) and converts them into the model's plain `Point2D`/`Poin read C memory by accident -- the compiler refuses it. The model is deliberately lossy: it keeps what consumers of the drawing's content need and -nothing else -- no linetypes, lineweights, layer on/off state, text styles, object -dictionaries or header variables. It cannot be used to write a DWG/DXF back out, and this +nothing else. A layer carries its state (off, frozen, locked, plotted), its lineweight and +the name of its linetype, but the linetype and text style definitions those names resolve +in, object dictionaries and header variables are not carried. It cannot be used to write a +DWG/DXF back out, and this project offers no writing (0.1.0's `write_dwg`/`write_dxf`/`dwg_to_dxf` were removed; see `CHANGELOG.md`). diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 8fbec1c..cfdea50 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -550,6 +550,22 @@ other color bugs, this one was verified by plausibility rather than by reference inherits `bit_downconvert_CMC`'s own limitation: a genuine arbitrary truecolor that happens not to match the palette is misread as a small ACI index. +## Layer state: what a DXF cannot say + +A layer's `off`, `frozen` and `locked` are read from the fields LibreDWG's DWG decoder +fills. Its DXF importer applies the binary format's bit layout to a LAYER's group 70 +instead (bit 2 becomes "off", 4 "frozen in new viewports", 8 "locked"), where a DXF means +1 frozen, 2 frozen in new viewports and 4 locked, and says "off" with a negative colour -- +so for a DXF this crate reads the raw group 70 and the colour's sign. `tests/layer_state.rs` +checks both readers against `hidden_layers_r2000.dxf` and the `example_2000` twins. + +Two more fields exist from R2000 on and are read only where the file can be heard: the +plot flag (DXF 290) and the lineweight (DXF 370). An R2000-or-later DWG states both. The +DXF importer leaves a 290 or a 370 the file left out at 0, so a stated "do not plot" and +silence read the same, as do a stated 0.00 mm and silence: `plot` is `Some(true)` or +`None` for a DXF, never `Some(false)`, and a lineweight code of 0 is `None`. A drawing +older than R2000 has neither (`None`). + ## Fixed: SPLINE control points read at the wrong stride Rendering 9 real files to SVG and reviewing the screenshots turned up nonsensical From a7ff344d98afbef9e6057c66d8d1f66e616c538e Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:12:13 +0900 Subject: [PATCH 14/54] Read POLYLINE_MESH as its grid's wireframe A polygon mesh arrived as Entity::Unknown. It is now Entity::PolylineMesh: the M by N VERTEX_MESH records of its own subentity chain, joined as the model states -- first each row to the next, column by column, then along each row -- with the closing edges where group 70 says the mesh is closed in M (bit 1) or N (bit 32). A mesh whose vertex count is not M times N (a smoothed surface stores its control points beside the approximated ones) keeps no edges and counts every edge its grid implies as skipped, so it reads as not read rather than as empty. A polyface mesh accepts VERTEX_MESH positions too: LibreDWG's DXF importer types a polyface vertex VERTEX_MESH when the vertex names the block record rather than the POLYLINE as its owner (what ezdxf writes), and such a polyface found no positions. Golden case G15 (a 3 by 4 mesh closed in N) reads back exactly. A/B: the four POLYLINE_MESHes (2000/TS1.dwg, the polyface fixture) and the 25 POLYLINE_PFACEs of the oracle inputs have exactly the feature branch's edges; against upstream, the polyface 4E4 of example_2000.dxf, example_r13.dxf and example_r14.dxf now has the six edges its DWG twin already had, and TS1.dwg's two meshes are no longer Unknown. corpus_sweep's pinned numbers do not move. Ported-from: efd43cc, 598fc09, bd10f50 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + crates/uncad/src/convert.rs | 100 +++- crates/uncad/tests/fixtures.rs | 44 ++ crates/uncad/tests/golden.rs | 7 +- crates/uncad/tests/golden/README.md | 5 +- crates/uncad/tests/golden/g15.dxf | 528 ++++++++++++++++++ crates/uncad/tests/golden/g15.expected.json | 575 ++++++++++++++++++++ docs/CAVEATS.md | 20 +- 8 files changed, 1268 insertions(+), 17 deletions(-) create mode 100644 crates/uncad/tests/golden/g15.dxf create mode 100644 crates/uncad/tests/golden/g15.expected.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a06f9c..2f27392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,12 @@ Notable changes to this project are recorded here. The format follows A DXF's plot flag and lineweight are `None` where its importer cannot tell a stated 0 from an absent group, and a drawing older than R2000 states neither. See `docs/CAVEATS.md`, "Layer state: what a DXF cannot say". +- POLYLINE_MESH (a polygon mesh) is read, as `Entity::PolylineMesh`: the wireframe of its + M by N grid, closed in either direction where the file says so. It used to arrive as + `Entity::Unknown`. A polyface mesh whose vertices the DXF importer types `VERTEX_MESH` + (they name the block record as their owner, the shape ezdxf writes) finds its vertex + positions: `example_2000.dxf`'s and `example_r13.dxf`'s polyface had no edges where + their DWG twins have six. ### Changed - A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 165bc84..66a472f 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -40,6 +40,13 @@ const POLYLINE_CLOSED_FLAG: u16 = 1; /// whose spec said "closed" and whose outline came back as an open polyline. const LWPOLYLINE_CLOSED_FLAG: u16 = 512; +/// POLYLINE_MESH's `flag` bit 1: the grid wraps in M ("closed polygon mesh +/// in the M direction", DXF group 70). +const POLYLINE_MESH_CLOSED_M_FLAG: u16 = 1; + +/// POLYLINE_MESH's `flag` bit 32: the grid wraps in N. +const POLYLINE_MESH_CLOSED_N_FLAG: u16 = 32; + /// `MLINE_FLAGS_CLOSED` (dwg.h). const MLINE_CLOSED_FLAG: u16 = 2; @@ -476,11 +483,19 @@ unsafe fn polyline_vertices( } /// Resolves a POLYLINE_PFACE's mesh into wireframe edges by walking its owned -/// `VERTEX_PFACE` (vertex positions, in order) and `VERTEX_PFACE_FACE` (up to +/// subentities -- vertex positions, in order, then `VERTEX_PFACE_FACE` (up to /// 4 vertex indices per face, 1-based, negative meaning "invisible edge" -- -/// the sign carries no other meaning, so it is just dropped) subentities -/// directly; LibreDWG's own accessor for this type is documented as not -/// implemented. +/// the sign carries no other meaning, so it is just dropped) -- directly; +/// LibreDWG's own accessor for this type is documented as not implemented. +/// +/// A position vertex is a `VERTEX_PFACE` *or* a `VERTEX_MESH`. Both mean the +/// same thing inside a POLYLINE_PFACE's own chain, and the DXF importer hands +/// back the second one for a polyface whose `AcDbPolyFaceMeshVertex` records +/// name the block record as their owner rather than the POLYLINE: `in_dxf.c` +/// picks between the two types by looking the VERTEX's own group 330 up and +/// asking whether it is a POLYLINE_PFACE, and falls back to VERTEX_MESH when +/// it is not. ezdxf writes exactly that shape (and its `audit()` passes it), +/// and such a polyface found no positions at all. /// /// Face records may be interleaved with vertex records, so indices are only /// resolved once the whole chain has been walked. Faces referencing an @@ -497,8 +512,13 @@ unsafe fn polyline_pface_wireframe(obj: *mut libredwg_sys::Dwg_Object) -> Vec<[P unsafe { libredwg_sys::dwg_object_get_fixedtype(sub) } as libredwg_sys::DWG_OBJECT_TYPE; let sub_entity_ptr = unsafe { libredwg_sys::uncad_object_entity_ptr(sub) }; if !sub_entity_ptr.is_null() { - if sub_fixedtype == libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_PFACE { - if let Some(p) = get_point3d(sub_entity_ptr, "VERTEX_PFACE", "point") { + let position = match sub_fixedtype { + libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_PFACE => Some("VERTEX_PFACE"), + libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_MESH => Some("VERTEX_MESH"), + _ => None, + }; + if let Some(dxfname) = position { + if let Some(p) = get_point3d(sub_entity_ptr, dxfname, "point") { positions.push(p); } } else if sub_fixedtype == libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_PFACE_FACE { @@ -533,6 +553,59 @@ unsafe fn polyline_pface_wireframe(obj: *mut libredwg_sys::Dwg_Object) -> Vec<[P edges } +/// Resolves a POLYLINE_MESH ("polygon mesh") into the wireframe of its grid, +/// in the order the model states for [`Entity::PolylineMesh`]: `m` rows of +/// `n` VERTEX_MESH subentities, stored row by row (vertex `i * n + j` is row +/// `i`, column `j`); first the edges `(i, j)-(i + 1, j)` row by row, then +/// `(i, j)-(i, j + 1)` row by row. `flag` bit 1 wraps the grid in M and bit +/// 32 in N (`dwg.spec`'s POLYLINE_MESH, DXF group 70), which adds the +/// closing edges. +/// +/// Returns the edges and how many the grid's definition promises but could +/// not be drawn: unless exactly `m * n` vertices were found, no grid shape +/// is guessed -- a smooth-surface mesh stores spline control points beside +/// the approximated ones -- and every edge the definition implies is +/// reported as skipped, so the mesh reads as "not read" rather than as an +/// empty one. +/// +/// # Safety +/// `obj` must be a valid, non-null `POLYLINE_MESH` `Dwg_Object`. +unsafe fn polyline_mesh_wireframe( + obj: *mut libredwg_sys::Dwg_Object, + m: usize, + n: usize, + flag: u16, +) -> (Vec<[Point3D; 2]>, usize) { + let closed_m = flag & POLYLINE_MESH_CLOSED_M_FLAG != 0; + let closed_n = flag & POLYLINE_MESH_CLOSED_N_FLAG != 0; + let rows_joined = if closed_m { m } else { m.saturating_sub(1) }; + let columns_joined = if closed_n { n } else { n.saturating_sub(1) }; + let edge_count = rows_joined + .saturating_mul(n) + .saturating_add(m.saturating_mul(columns_joined)); + let positions: Vec = + unsafe { polyline_vertices(obj, libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_MESH) } + .into_iter() + .filter_map(|vertex| get_point3d(vertex, "VERTEX_MESH", "point")) + .collect(); + if positions.is_empty() || positions.len() != m.saturating_mul(n) { + return (Vec::new(), edge_count); + } + let at = |i: usize, j: usize| positions[i * n + j]; + let mut edges = Vec::with_capacity(edge_count); + for i in 0..rows_joined { + for j in 0..n { + edges.push([at(i, j), at((i + 1) % m, j)]); + } + } + for i in 0..m { + for j in 0..columns_joined { + edges.push([at(i, j), at(i, (j + 1) % n)]); + } + } + (edges, 0) +} + /// Resolves a WIPEOUT's clip boundary to local 2D points -- see /// [`crate::model::WipeoutEntity`] for the risk this carries. /// @@ -1329,6 +1402,21 @@ unsafe fn convert_entity( skipped_edges: 0, }) } + libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_POLYLINE_MESH => { + let m = get_field::(entity_ptr, "POLYLINE_MESH", "num_m_verts").unwrap_or(0); + let n = get_field::(entity_ptr, "POLYLINE_MESH", "num_n_verts").unwrap_or(0); + let flag = get_field::(entity_ptr, "POLYLINE_MESH", "flag").unwrap_or(0); + // SAFETY: obj is a valid, non-null POLYLINE_MESH Dwg_Object* + // (matching fixedtype); the helper only walks its owned-subentity + // chain. + let (wireframe_edges, skipped_edges) = + unsafe { polyline_mesh_wireframe(obj, usize::from(m), usize::from(n), flag) }; + Entity::PolylineMesh(Solid3DEntity { + common, + wireframe_edges, + skipped_edges, + }) + } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_TOLERANCE => { let insertion_point = get_point3d(entity_ptr, "TOLERANCE", "ins_pt")?; // Same as the dimension's measurement: this library has no "the diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index 3955bb9..75fa295 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -380,3 +380,47 @@ fn every_polyline_vertex_survives_the_r2000_subentity_chain() { // And no VERTEX record is an entity of the block that holds them. assert_eq!(db.tables.block_records["*Model_Space"].entities.len(), 3); } + +// --------------------------------------------------------- mesh polylines + +/// A DXF whose one polygon mesh made an unpatched LibreDWG refuse the whole +/// file, and whose polyface mesh found no vertex positions: the importer +/// types its vertices VERTEX_MESH, because they name the block record as +/// their owner. +/// +/// The edge counts are the grids `make_fixtures.py` writes, worked out from +/// the mesh definitions: the polyface has two quad faces, so 2 * 4 = 8 +/// edges; the polygon mesh is an open 3 by 4 grid, so 4 * (3 - 1) edges +/// between the rows plus 3 * (4 - 1) along them = 17. +#[test] +fn a_dxf_with_mesh_polylines_reads_and_both_meshes_have_their_wireframe() { + let db = parse(POLYFACE_MESH); + assert_eq!( + type_counts(&db), + expected(&[("LINE", 1), ("POLYLINE_MESH", 1), ("POLYLINE_PFACE", 1)]), + "no VERTEX record may be reported as an entity" + ); + let model = &db.tables.block_records["*Model_Space"].entities; + assert_eq!(model.len(), 3, "{model:?}"); + + let Entity::PolylinePFace(pface) = &db.entities[1] else { + panic!("expected the polyface second: {:?}", db.entities[1]); + }; + assert_eq!(pface.common.source_handle, resolved("31")); + assert_eq!((pface.wireframe_edges.len(), pface.skipped_edges), (8, 0)); + + let Entity::PolylineMesh(mesh) = &db.entities[2] else { + panic!("expected the polygon mesh third: {:?}", db.entities[2]); + }; + assert_eq!(mesh.common.source_handle, resolved("50")); + assert_eq!((mesh.wireframe_edges.len(), mesh.skipped_edges), (17, 0)); + // Vertex i*4 + j sits at (i*10, j*5, 0): the first edge joins row 0 to + // row 1 in column 0, the first edge along a row is its column 0 to 1. + let point = |i: f64, j: f64| uncad::model::Point3D { + x: i * 10.0, + y: j * 5.0, + z: 0.0, + }; + assert_eq!(mesh.wireframe_edges[0], [point(0.0, 0.0), point(1.0, 0.0)]); + assert_eq!(mesh.wireframe_edges[8], [point(0.0, 0.0), point(0.0, 1.0)]); +} diff --git a/crates/uncad/tests/golden.rs b/crates/uncad/tests/golden.rs index 5b81ef0..00f3637 100644 --- a/crates/uncad/tests/golden.rs +++ b/crates/uncad/tests/golden.rs @@ -20,7 +20,7 @@ use uncad::model::Ref; use uncad::{CadDatabase, Entity}; /// Every case, as (name, DXF bytes, expected model JSON). -const CASES: [(&str, &[u8], &str); 8] = [ +const CASES: [(&str, &[u8], &str); 9] = [ ( "g1", include_bytes!("golden/g1.dxf"), @@ -61,6 +61,11 @@ const CASES: [(&str, &[u8], &str); 8] = [ include_bytes!("golden/g10.dxf"), include_str!("golden/g10.expected.json"), ), + ( + "g15", + include_bytes!("golden/g15.dxf"), + include_str!("golden/g15.expected.json"), + ), ]; /// Removes its file on drop, so a failing assertion leaves nothing behind. diff --git a/crates/uncad/tests/golden/README.md b/crates/uncad/tests/golden/README.md index 86f8e82..b3ce3a3 100644 --- a/crates/uncad/tests/golden/README.md +++ b/crates/uncad/tests/golden/README.md @@ -7,8 +7,9 @@ Copies of what the `uncad-model` repository's golden writer produces, one pair p | `.dxf` | The synthetic drawing (R2000 ASCII DXF) written from that case's spec | | `.expected.json` | The model that spec says a reader must produce from it | -Cases here: `g1`, `g2`, `g6`, `g7`, `g8`, `g9`, `g10`. `tests/golden.rs` parses each DXF and -requires the model to match exactly. +Cases here: `g1`, `g2`, `g5`, `g6`, `g7`, `g8`, `g9`, `g10`, `g15`. `tests/golden.rs` parses +each DXF and requires the model to match exactly, apart from the known deviations it +applies to the expectation (each with a test that fails the day it is no longer needed). `g8.dxf` is not UTF-8 by design: it declares `$DWGCODEPAGE = ANSI_949` and stores its Korean text as CP949 bytes, which is how an R2000 DXF carries such text. An editor shows diff --git a/crates/uncad/tests/golden/g15.dxf b/crates/uncad/tests/golden/g15.dxf new file mode 100644 index 0000000..60735c9 --- /dev/null +++ b/crates/uncad/tests/golden/g15.dxf @@ -0,0 +1,528 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$HANDSEED + 5 +112 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LTYPE + 5 +1 +100 +AcDbSymbolTable + 70 +1 + 0 +LTYPE + 5 +2 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CONTINUOUS + 70 +0 + 3 +Solid line + 72 +65 + 73 +0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +3 +100 +AcDbSymbolTable + 70 +2 + 0 +LAYER + 5 +4 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +CONTINUOUS + 0 +LAYER + 5 +5 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +MESH + 70 +0 + 62 +5 + 6 +CONTINUOUS + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +6 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +7 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space + 0 +BLOCK_RECORD + 5 +8 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +100 +330 +7 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +101 +330 +7 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +102 +330 +8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +103 +330 +8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +POLYLINE + 5 +104 +330 +7 +100 +AcDbEntity + 8 +MESH +100 +AcDbPolygonMesh + 66 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +48 + 71 +3 + 72 +4 + 0 +VERTEX + 5 +105 +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +64 + 0 +VERTEX + 5 +106 +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +10.0 + 20 +0.0 + 30 +1.0 + 70 +64 + 0 +VERTEX + 5 +107 +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +20.0 + 20 +0.0 + 30 +2.0 + 70 +64 + 0 +VERTEX + 5 +108 +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +30.0 + 20 +0.0 + 30 +1.0 + 70 +64 + 0 +VERTEX + 5 +109 +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +0.0 + 20 +10.0 + 30 +0.5 + 70 +64 + 0 +VERTEX + 5 +10A +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +10.0 + 20 +10.0 + 30 +1.5 + 70 +64 + 0 +VERTEX + 5 +10B +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +20.0 + 20 +10.0 + 30 +2.5 + 70 +64 + 0 +VERTEX + 5 +10C +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +30.0 + 20 +10.0 + 30 +1.5 + 70 +64 + 0 +VERTEX + 5 +10D +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +0.0 + 20 +20.0 + 30 +1.0 + 70 +64 + 0 +VERTEX + 5 +10E +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +10.0 + 20 +20.0 + 30 +2.0 + 70 +64 + 0 +VERTEX + 5 +10F +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +20.0 + 20 +20.0 + 30 +3.0 + 70 +64 + 0 +VERTEX + 5 +110 +330 +104 +100 +AcDbEntity + 8 +MESH +100 +AcDbVertex +100 +AcDbPolygonMeshVertex + 10 +30.0 + 20 +20.0 + 30 +2.0 + 70 +64 + 0 +SEQEND + 5 +111 +330 +104 +100 +AcDbEntity + 8 +MESH + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/golden/g15.expected.json b/crates/uncad/tests/golden/g15.expected.json new file mode 100644 index 0000000..b2193ef --- /dev/null +++ b/crates/uncad/tests/golden/g15.expected.json @@ -0,0 +1,575 @@ +{ + "entities": [ + { + "type": "POLYLINE_MESH", + "common": { + "id": 260, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "104" + }, + "layer": { + "type": "RESOLVED", + "data": "MESH" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "wireframe_edges": [ + [ + { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + { + "x": 0.0, + "y": 10.0, + "z": 0.5 + } + ], + [ + { + "x": 10.0, + "y": 0.0, + "z": 1.0 + }, + { + "x": 10.0, + "y": 10.0, + "z": 1.5 + } + ], + [ + { + "x": 20.0, + "y": 0.0, + "z": 2.0 + }, + { + "x": 20.0, + "y": 10.0, + "z": 2.5 + } + ], + [ + { + "x": 30.0, + "y": 0.0, + "z": 1.0 + }, + { + "x": 30.0, + "y": 10.0, + "z": 1.5 + } + ], + [ + { + "x": 0.0, + "y": 10.0, + "z": 0.5 + }, + { + "x": 0.0, + "y": 20.0, + "z": 1.0 + } + ], + [ + { + "x": 10.0, + "y": 10.0, + "z": 1.5 + }, + { + "x": 10.0, + "y": 20.0, + "z": 2.0 + } + ], + [ + { + "x": 20.0, + "y": 10.0, + "z": 2.5 + }, + { + "x": 20.0, + "y": 20.0, + "z": 3.0 + } + ], + [ + { + "x": 30.0, + "y": 10.0, + "z": 1.5 + }, + { + "x": 30.0, + "y": 20.0, + "z": 2.0 + } + ], + [ + { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + { + "x": 10.0, + "y": 0.0, + "z": 1.0 + } + ], + [ + { + "x": 10.0, + "y": 0.0, + "z": 1.0 + }, + { + "x": 20.0, + "y": 0.0, + "z": 2.0 + } + ], + [ + { + "x": 20.0, + "y": 0.0, + "z": 2.0 + }, + { + "x": 30.0, + "y": 0.0, + "z": 1.0 + } + ], + [ + { + "x": 30.0, + "y": 0.0, + "z": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + ], + [ + { + "x": 0.0, + "y": 10.0, + "z": 0.5 + }, + { + "x": 10.0, + "y": 10.0, + "z": 1.5 + } + ], + [ + { + "x": 10.0, + "y": 10.0, + "z": 1.5 + }, + { + "x": 20.0, + "y": 10.0, + "z": 2.5 + } + ], + [ + { + "x": 20.0, + "y": 10.0, + "z": 2.5 + }, + { + "x": 30.0, + "y": 10.0, + "z": 1.5 + } + ], + [ + { + "x": 30.0, + "y": 10.0, + "z": 1.5 + }, + { + "x": 0.0, + "y": 10.0, + "z": 0.5 + } + ], + [ + { + "x": 0.0, + "y": 20.0, + "z": 1.0 + }, + { + "x": 10.0, + "y": 20.0, + "z": 2.0 + } + ], + [ + { + "x": 10.0, + "y": 20.0, + "z": 2.0 + }, + { + "x": 20.0, + "y": 20.0, + "z": 3.0 + } + ], + [ + { + "x": 20.0, + "y": 20.0, + "z": 3.0 + }, + { + "x": 30.0, + "y": 20.0, + "z": 2.0 + } + ], + [ + { + "x": 30.0, + "y": 20.0, + "z": 2.0 + }, + { + "x": 0.0, + "y": 20.0, + "z": 1.0 + } + ] + ], + "skipped_edges": 0 + } + ], + "tables": { + "layers": { + "0": { + "name": "0", + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "MESH": { + "name": "MESH", + "color_index": 5, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + } + }, + "block_records": { + "*Model_Space": { + "name": "*Model_Space", + "entities": [ + { + "type": "POLYLINE_MESH", + "common": { + "id": 260, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "104" + }, + "layer": { + "type": "RESOLVED", + "data": "MESH" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "wireframe_edges": [ + [ + { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + { + "x": 0.0, + "y": 10.0, + "z": 0.5 + } + ], + [ + { + "x": 10.0, + "y": 0.0, + "z": 1.0 + }, + { + "x": 10.0, + "y": 10.0, + "z": 1.5 + } + ], + [ + { + "x": 20.0, + "y": 0.0, + "z": 2.0 + }, + { + "x": 20.0, + "y": 10.0, + "z": 2.5 + } + ], + [ + { + "x": 30.0, + "y": 0.0, + "z": 1.0 + }, + { + "x": 30.0, + "y": 10.0, + "z": 1.5 + } + ], + [ + { + "x": 0.0, + "y": 10.0, + "z": 0.5 + }, + { + "x": 0.0, + "y": 20.0, + "z": 1.0 + } + ], + [ + { + "x": 10.0, + "y": 10.0, + "z": 1.5 + }, + { + "x": 10.0, + "y": 20.0, + "z": 2.0 + } + ], + [ + { + "x": 20.0, + "y": 10.0, + "z": 2.5 + }, + { + "x": 20.0, + "y": 20.0, + "z": 3.0 + } + ], + [ + { + "x": 30.0, + "y": 10.0, + "z": 1.5 + }, + { + "x": 30.0, + "y": 20.0, + "z": 2.0 + } + ], + [ + { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + { + "x": 10.0, + "y": 0.0, + "z": 1.0 + } + ], + [ + { + "x": 10.0, + "y": 0.0, + "z": 1.0 + }, + { + "x": 20.0, + "y": 0.0, + "z": 2.0 + } + ], + [ + { + "x": 20.0, + "y": 0.0, + "z": 2.0 + }, + { + "x": 30.0, + "y": 0.0, + "z": 1.0 + } + ], + [ + { + "x": 30.0, + "y": 0.0, + "z": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + ], + [ + { + "x": 0.0, + "y": 10.0, + "z": 0.5 + }, + { + "x": 10.0, + "y": 10.0, + "z": 1.5 + } + ], + [ + { + "x": 10.0, + "y": 10.0, + "z": 1.5 + }, + { + "x": 20.0, + "y": 10.0, + "z": 2.5 + } + ], + [ + { + "x": 20.0, + "y": 10.0, + "z": 2.5 + }, + { + "x": 30.0, + "y": 10.0, + "z": 1.5 + } + ], + [ + { + "x": 30.0, + "y": 10.0, + "z": 1.5 + }, + { + "x": 0.0, + "y": 10.0, + "z": 0.5 + } + ], + [ + { + "x": 0.0, + "y": 20.0, + "z": 1.0 + }, + { + "x": 10.0, + "y": 20.0, + "z": 2.0 + } + ], + [ + { + "x": 10.0, + "y": 20.0, + "z": 2.0 + }, + { + "x": 20.0, + "y": 20.0, + "z": 3.0 + } + ], + [ + { + "x": 20.0, + "y": 20.0, + "z": 3.0 + }, + { + "x": 30.0, + "y": 20.0, + "z": 2.0 + } + ], + [ + { + "x": 30.0, + "y": 20.0, + "z": 2.0 + }, + { + "x": 0.0, + "y": 20.0, + "z": 1.0 + } + ] + ], + "skipped_edges": 0 + } + ] + }, + "*Paper_Space": { + "name": "*Paper_Space", + "entities": [] + } + }, + "dim_styles": {}, + "mlinestyles": {}, + "layouts": {} + }, + "read_diagnostics": { + "warnings": [] + } +} \ No newline at end of file diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index cfdea50..aa9942b 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -5,8 +5,8 @@ `parse()`/`to_svg()` support: LINE, CIRCLE, ARC, ELLIPSE, LWPOLYLINE, TEXT, POINT, SOLID, TRACE, RAY, XLINE, INSERT (including recursive block-reference rendering), ATTRIB, ATTDEF, VIEWPORT, 3DFACE, SPLINE, MTEXT, POLYLINE_3D, POLYLINE_2D, DIMENSION, HATCH, 3DSOLID, -LEADER, MULTILEADER, MLINE, REGION, POLYLINE_PFACE, TOLERANCE, ACAD_TABLE, WIPEOUT and -LIGHT. Details worth knowing: +LEADER, MULTILEADER, MLINE, REGION, POLYLINE_PFACE, POLYLINE_MESH, TOLERANCE, ACAD_TABLE, +WIPEOUT and LIGHT. Details worth knowing: - **DIMENSION** folds all 7 subtypes (ALIGNED, ANG2LN, ANG3PT, DIAMETER, LINEAR, ORDINATE, ARC_DIMENSION) into one type. They share the `DIMENSION_COMMON` layout, including the @@ -23,9 +23,13 @@ LIGHT. Details worth knowing: is filled the same way. - **POLYLINE_2D** reuses `LwPolylineEntity` and renders through exactly the same code path as LWPOLYLINE, the same way `Entity::XLine` reuses `RayEntity`. -- **3DSOLID**, **REGION** and **POLYLINE_PFACE** render as isometric wireframes, which is - an approximation, not a reading of the B-rep. A solid whose ACIS data cannot be read or - converted is reported as unsupported. +- **3DSOLID**, **REGION**, **POLYLINE_PFACE** and **POLYLINE_MESH** render as isometric + wireframes, which is an approximation, not a reading of the B-rep. A solid whose ACIS + data cannot be read or converted is reported as unsupported. A polygon mesh is carried + as the lines of its M by N grid, in the order `Entity::PolylineMesh` documents; one + whose vertex count is not M times N (a smoothed surface stores its control points beside + the approximated ones) is carried with no edges and every edge its grid implies counted + in `skipped_edges` -- not read, rather than empty. - **MULTILEADER, MLINE, REGION, POLYLINE_PFACE, TOLERANCE, ACAD_TABLE, WIPEOUT, LIGHT** are all **experimental** -- see the next section. @@ -36,9 +40,9 @@ it through `unsupported_types` (sorted by name, so the report is the same on eve A listed type can end up there too when a particular entity gives the renderer nothing to draw, e.g. a DIMENSION without its cached-geometry block. -Being off the list says nothing about whether the type has geometry. POLYLINE_MESH, IMAGE -and HELIX, for instance, all have a shape and none of them is covered; that is simply work -that has not been done. Do not read the list as "everything with a shape". +Being off the list says nothing about whether the type has geometry. IMAGE and HELIX, for +instance, both have a shape and neither is covered; that is simply work that has not been +done. Do not read the list as "everything with a shape". **ACAD_PROXY_ENTITY is the one type that will stay unsupported.** It is the proxy representation of a custom entity from another program, so it has no fixed geometry to From 07dbd78f905c42ac130699f45b8ba470e2e7df63 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:15:46 +0900 Subject: [PATCH 15/54] Carry the object coordinate system of every OCS entity CIRCLE, ARC, LWPOLYLINE, POLYLINE_2D, TEXT, ATTRIB, ATTDEF, INSERT, SOLID and TRACE state their coordinates in an object coordinate system whose normal they carry (DXF 210), the planar ones at an elevation. The model's extrusion and elevation fields were the defaults; they are read now, as the file states them: the coordinates stay in the OCS (owner decision: the transform is the consumer's; Affine2::from_insert does it for a block reference), the normal is not normalised, and only a zero vector reads as the default (0, 0, 1). An LWPOLYLINE's normal is read only when its flag says one is stored, as the decoder itself does. A/B: all 55,635 normals the feature branch also carried (it normalised them) agree; every coordinate of these kinds is upstream's, apart from the 52 POLYLINE_2D vertex lists the last-vertex fix lengthened -- the feature branch moved the 401 mirrored entities of the samples (240 SOLIDs, 95 LWPOLYLINEs, 66 INSERTs) to world coordinates, which the model deliberately does not. tests/fixtures.rs pins the mirrored fixture: stated coordinates, stated angles, the (0,0,-1) normal. Ported-from: fae3b14, 89d77c5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ crates/uncad/src/convert.rs | 100 ++++++++++++++----------------- crates/uncad/tests/fixtures.rs | 106 +++++++++++++++++++++++++++++++++ docs/CAVEATS.md | 16 +++++ 4 files changed, 171 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f27392..9b3ce7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,11 @@ Notable changes to this project are recorded here. The format follows (they name the block record as their owner, the shape ezdxf writes) finds its vertex positions: `example_2000.dxf`'s and `example_r13.dxf`'s polyface had no edges where their DWG twins have six. +- The object coordinate system of CIRCLE, ARC, LWPOLYLINE, POLYLINE_2D, TEXT, ATTRIB, + ATTDEF, INSERT, SOLID and TRACE is read: each carries its `extrusion` (DXF 210) and the + planar ones their `elevation`, with the coordinates as the file states them. A + mirrored entity used to be indistinguishable from an upright one. See + `docs/CAVEATS.md`, "Object coordinate systems". ### Changed - A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 66a472f..59e79b8 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -47,6 +47,19 @@ const POLYLINE_MESH_CLOSED_M_FLAG: u16 = 1; /// POLYLINE_MESH's `flag` bit 32: the grid wraps in N. const POLYLINE_MESH_CLOSED_N_FLAG: u16 = 32; +/// LWPOLYLINE's `flag` bit 1 in the library's layout: an extrusion is +/// stored. The decoder reads the field only then; otherwise it is the +/// default normal. +const LWPOLYLINE_EXTRUSION_FLAG: u16 = 1; + +/// The default normal of an object coordinate system (DXF 210): the world's +/// z axis, which makes the OCS the world's own axes. +const Z_AXIS: Point3D = Point3D { + x: 0.0, + y: 0.0, + z: 1.0, +}; + /// `MLINE_FLAGS_CLOSED` (dwg.h). const MLINE_CLOSED_FLAG: u16 = 2; @@ -606,6 +619,17 @@ unsafe fn polyline_mesh_wireframe( (edges, 0) } +/// An OCS entity's normal (`extrusion`, DXF 210), as the file states it. +/// A zero vector is not a direction, and the format's default is the z +/// axis, so an unreadable or zero normal is the z axis. Not normalised: +/// the model carries what the file states, and taking the entity's +/// coordinates to the world through it is the consumer's step. +fn extrusion(entity_ptr: *mut std::ffi::c_void, dxfname: &str) -> Point3D { + get_point3d(entity_ptr, dxfname, "extrusion") + .filter(|e| e.x != 0.0 || e.y != 0.0 || e.z != 0.0) + .unwrap_or(Z_AXIS) +} + /// Resolves a WIPEOUT's clip boundary to local 2D points -- see /// [`crate::model::WipeoutEntity`] for the risk this carries. /// @@ -757,11 +781,7 @@ unsafe fn convert_entity( common, center, radius, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, + extrusion: extrusion(entity_ptr, "CIRCLE"), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_TEXT => { @@ -783,12 +803,8 @@ unsafe fn convert_entity( width_factor: 1.0, oblique_angle: 0.0, style_name: uncad_model::Ref::Absent, - elevation: 0.0, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, + elevation: get_field::(entity_ptr, "TEXT", "elevation").unwrap_or(0.0), + extrusion: extrusion(entity_ptr, "TEXT"), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_LWPOLYLINE => { @@ -802,11 +818,11 @@ unsafe fn convert_entity( bulges: Vec::new(), widths: Vec::new(), const_width: 0.0, - elevation: 0.0, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, + elevation: get_field::(entity_ptr, "LWPOLYLINE", "elevation").unwrap_or(0.0), + extrusion: if flag & LWPOLYLINE_EXTRUSION_FLAG != 0 { + extrusion(entity_ptr, "LWPOLYLINE") + } else { + Z_AXIS }, }) } @@ -821,11 +837,7 @@ unsafe fn convert_entity( radius, start_angle, end_angle, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, + extrusion: extrusion(entity_ptr, "ARC"), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ELLIPSE => { @@ -871,12 +883,8 @@ unsafe fn convert_entity( corner2, corner3, corner4, - elevation: 0.0, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, + elevation: get_field::(entity_ptr, "SOLID", "elevation").unwrap_or(0.0), + extrusion: extrusion(entity_ptr, "SOLID"), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_TRACE => { @@ -892,12 +900,8 @@ unsafe fn convert_entity( corner2, corner3, corner4, - elevation: 0.0, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, + elevation: get_field::(entity_ptr, "TRACE", "elevation").unwrap_or(0.0), + extrusion: extrusion(entity_ptr, "TRACE"), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_RAY => { @@ -944,12 +948,8 @@ unsafe fn convert_entity( width_factor: 1.0, oblique_angle: 0.0, style_name: uncad_model::Ref::Absent, - elevation: 0.0, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, + elevation: get_field::(entity_ptr, "ATTRIB", "elevation").unwrap_or(0.0), + extrusion: extrusion(entity_ptr, "ATTRIB"), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_INSERT => { @@ -992,11 +992,7 @@ unsafe fn convert_entity( scale, rotation, attribs, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, + extrusion: extrusion(entity_ptr, "INSERT"), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_ATTDEF => { @@ -1021,12 +1017,8 @@ unsafe fn convert_entity( width_factor: 1.0, oblique_angle: 0.0, style_name: uncad_model::Ref::Absent, - elevation: 0.0, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, + elevation: get_field::(entity_ptr, "ATTDEF", "elevation").unwrap_or(0.0), + extrusion: extrusion(entity_ptr, "ATTDEF"), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VIEWPORT => { @@ -1195,12 +1187,8 @@ unsafe fn convert_entity( bulges: Vec::new(), widths: Vec::new(), const_width: 0.0, - elevation: 0.0, - extrusion: uncad_model::Point3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, + elevation: get_field::(entity_ptr, "POLYLINE_2D", "elevation").unwrap_or(0.0), + extrusion: extrusion(entity_ptr, "POLYLINE_2D"), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_DIMENSION_ORDINATE diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index 75fa295..8a605d3 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -424,3 +424,109 @@ fn a_dxf_with_mesh_polylines_reads_and_both_meshes_have_their_wireframe() { assert_eq!(mesh.wireframe_edges[0], [point(0.0, 0.0), point(1.0, 0.0)]); assert_eq!(mesh.wireframe_edges[8], [point(0.0, 0.0), point(0.0, 1.0)]); } + +// ------------------------------------------------------------- mirrored + +const DOWN: uncad::model::Point3D = uncad::model::Point3D { + x: 0.0, + y: 0.0, + z: -1.0, +}; +const UP: uncad::model::Point3D = uncad::model::Point3D { + x: 0.0, + y: 0.0, + z: 1.0, +}; + +#[test] +fn mirrored_ocs_fixture_has_the_expected_entity_mix() { + let db = parse(MIRRORED); + assert_eq!( + type_counts(&db), + expected(&[ + ("ARC", 1), + ("CIRCLE", 1), + ("LINE", 1), + ("LWPOLYLINE", 2), + ("TEXT", 1) + ]) + ); + // No TABLES section in this file: no layer resolves. + assert!(db + .entities + .iter() + .all(|e| !matches!(e.common().layer, Ref::Resolved(_)))); +} + +/// Coordinates are carried as the file states them, in the entity's object +/// coordinate system, beside the normal (DXF 210) that defines it: taking +/// them to the world is the consumer's step. The feature branch moved them +/// to world coordinates while reading; the model does not. +#[test] +fn mirrored_ocs_entities_keep_their_stated_coordinates_and_carry_their_normal() { + let db = parse(MIRRORED); + let polylines: Vec<&uncad::model::LwPolylineEntity> = db + .entities + .iter() + .filter_map(|e| match e { + Entity::LwPolyline(p) => Some(p), + _ => None, + }) + .collect(); + assert_eq!(polylines.len(), 2); + let xy = |p: &uncad::model::LwPolylineEntity| -> Vec<(f64, f64)> { + p.vertices.iter().map(|v| (v.x, v.y)).collect() + }; + let outline = [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)]; + + // Handle 20: DXF 70 = 1 with extrusion (0,0,-1); LibreDWG stores + // flag = 513 (512 closed | 1 has-extrusion). + let mirrored = polylines[0]; + assert_eq!(mirrored.common.source_handle, resolved("20")); + assert!(mirrored.closed); + assert_eq!(mirrored.extrusion, DOWN); + assert_eq!(xy(mirrored), outline); + assert_eq!(mirrored.elevation, 0.0); + + // Handle 21: DXF 70 = 0 (flag = 16: neither bit set), extrusion + // (0,0,1) stated. + let upright = polylines[1]; + assert_eq!(upright.common.source_handle, resolved("21")); + assert!(!upright.closed); + assert_eq!(upright.extrusion, UP); + assert_eq!(xy(upright), outline); + + let mut seen = 0; + for e in &db.entities { + match e { + Entity::Circle(c) => { + assert_eq!((c.center.x, c.center.y, c.center.z), (10.0, 10.0, 0.0)); + assert_eq!((c.radius, c.extrusion), (5.0, DOWN)); + seen += 1; + } + Entity::Arc(a) => { + // Stated 0 to 90 degrees; in the world the arc runs + // clockwise from 90 to 180, which is not the model's to say. + assert_eq!((a.center.x, a.center.y, a.center.z), (0.0, 0.0, 0.0)); + assert_eq!((a.radius, a.extrusion), (20.0, DOWN)); + assert_eq!(a.start_angle, 0.0); + assert_eq!(a.end_angle, std::f64::consts::FRAC_PI_2); + seen += 1; + } + Entity::Text(t) => { + assert_eq!(t.text, "MIRROR"); + assert_eq!((t.start_point.x, t.start_point.y), (10.0, 10.0)); + assert_eq!((t.extrusion, t.elevation), (DOWN, 0.0)); + seen += 1; + } + Entity::Line(l) => { + // A LINE is stated in world coordinates and has no OCS. + assert_eq!((l.start_point.x, l.start_point.y), (-5.0, -5.0)); + assert_eq!((l.end_point.x, l.end_point.y), (5.0, 5.0)); + seen += 1; + } + _ => {} + } + } + assert_eq!(seen, 4); +} diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index aa9942b..017e482 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -397,6 +397,22 @@ An arc-length dimension is a separate entity in the format rather than a value o the group says 5 on such a dimension, which would read as a three-point angular one. It is filed by the entity it is. +## Object coordinate systems: coordinates as stated, with their normal + +CIRCLE, ARC, LWPOLYLINE, POLYLINE_2D, TEXT, ATTRIB, ATTDEF, INSERT, SOLID and TRACE state +their coordinates in an object coordinate system whose normal the entity carries (DXF 210, +`extrusion`), at an elevation (DXF 30/38) for the planar ones. The model carries both as +the file states them and moves nothing to the world: a mirrored circle stated at (10, 10) +with the normal (0, 0, -1) is carried at (10, 10), where its world centre is (-10, 10), +and a mirrored arc keeps its stated angles although it runs clockwise in the world. Taking +the coordinates to the world (the DXF reference's arbitrary axis algorithm) is a +consumer's step; `Affine2::from_insert` in the model does it for a block reference. The +normal is not normalised either; only a zero vector, which is no direction, reads as the +default (0, 0, 1). An LWPOLYLINE's normal is read only when its flag says one is stored +(bit 1 in LibreDWG's layout), as the decoder itself does. Of the corpus and the nine local +sample drawings, three samples carry such entities -- 240 SOLIDs, 95 LWPOLYLINEs and 66 +INSERTs, every one with the normal (0, 0, -1) that AutoCAD's MIRROR leaves behind. + ## The polyline "closed" flag Two layouts, one field name. POLYLINE_2D/3D keep DXF's convention (bit 1 of `flag` is From 3700221214fc21cc4df2185fa09af848dbdd6d41 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:23:38 +0900 Subject: [PATCH 16/54] Carry a polyline's bulges, widths and constant width LWPOLYLINE and POLYLINE_2D kept only their vertices, so every arc segment arrived as its chord and every width as none. They now carry: - bulges: LWPOLYLINE's bulges[], a POLYLINE_2D's VERTEX bulges, one per vertex with the sign the file wrote (a mirrored OCS does not change it; the feature branch negated them while moving the vertices to the world, which the model no longer does); - widths: LWPOLYLINE's widths[], a POLYLINE_2D's VERTEX start/end widths; and const_width (DXF 43; a POLYLINE_2D states none, 0). Both lists are empty when every bulge is 0 or every width is the constant one, so a file stating those values and one stating nothing are the same polyline (the model's rule, which G12's last polyline tests). One reading goes beyond the field map: a DXF writes a POLYLINE's default widths once, as its groups 40/41, and omits them from every VERTEX that has them, and the importer reads an absent vertex width as 0 -- so a DXF vertex whose widths read 0 takes the default. Without it the DXF of 2000/PolyLine2D gave its 0.15-wide arrow ticks no width while the DWG twin carried 0.15 on every vertex. Golden cases G11 (a mirrored part) and G12 (curved and wide polylines) read back exactly. tests/polyline_fields.rs holds the DWG/DXF twins of example_2000, example_2004, example_r13 and 2000/PolyLine2D to agree on every polyline field. A/B against the feature branch over the oracle inputs: all 20,195 polyline bulge lists agree once its mirrored-OCS negation is undone, all 7,986 LWPOLYLINE width lists and constant widths agree (the empty-list rule aside). Of the 52 POLYLINE_2Ds it gave a constant width from the polyline's start width, 50 now carry that width on their vertices; the other two (one polyline of AutoCADSamples5.dwg, counted as entity and block content) state 0 on all 553 vertices, and carry no width, as the vertices say. Every twin polyline agrees between DWG and DXF (80). Ported-from: fae3b14, 89d77c5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + crates/uncad/src/convert.rs | 125 +++- crates/uncad/src/dynapi.rs | 12 + crates/uncad/tests/fixtures.rs | 58 ++ crates/uncad/tests/golden.rs | 12 +- crates/uncad/tests/golden/g11.dxf | 556 +++++++++++++++++ crates/uncad/tests/golden/g11.expected.json | 645 ++++++++++++++++++++ crates/uncad/tests/golden/g12.dxf | 412 +++++++++++++ crates/uncad/tests/golden/g12.expected.json | 523 ++++++++++++++++ crates/uncad/tests/polyline_fields.rs | 129 ++++ docs/CAVEATS.md | 29 +- 11 files changed, 2480 insertions(+), 26 deletions(-) create mode 100644 crates/uncad/tests/golden/g11.dxf create mode 100644 crates/uncad/tests/golden/g11.expected.json create mode 100644 crates/uncad/tests/golden/g12.dxf create mode 100644 crates/uncad/tests/golden/g12.expected.json create mode 100644 crates/uncad/tests/polyline_fields.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3ce7a..ea6ff5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,11 @@ Notable changes to this project are recorded here. The format follows planar ones their `elevation`, with the coordinates as the file states them. A mirrored entity used to be indistinguishable from an upright one. See `docs/CAVEATS.md`, "Object coordinate systems". +- LWPOLYLINE and POLYLINE_2D carry what runs between their vertices: `bulges` (with the + sign the file wrote), per-vertex `widths` and `const_width`, empty lists when every + segment is straight and every width the constant one. A DXF POLYLINE's default widths + (its groups 40/41) are the widths of the vertices that state none, so a DXF and its DWG + twin agree. See `docs/CAVEATS.md`, "Polyline bulges and widths are carried as stated". ### Changed - A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 59e79b8..87c190a 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -12,7 +12,8 @@ use crate::dynapi::{ get_array_field, get_common_field, get_field, get_point2d, get_point2d_array, get_point3d, - get_point3d_array, is_pre_r13, is_r2010_or_later, is_r2013_or_later, SplineControlPoint, + get_point3d_array, is_from_dxf, is_pre_r13, is_r2010_or_later, is_r2013_or_later, + RawSegmentWidth, SplineControlPoint, }; use crate::text::TextDecoder; use std::ffi::CStr; @@ -22,8 +23,8 @@ use uncad_model::model::{ Face3DEntity, HatchBoundaryPath, HatchEdge, HatchEntity, HatchGradient, HatchPatternLine, InsertEntity, LeaderAnnotation, LeaderEntity, LeaderPath, LightEntity, LightType, LineEntity, LwPolylineEntity, MLineEntity, MLineVertex, MTextAttachment, MTextEntity, MultiLeaderEntity, - Origin, PointEntity, PolylineEntity, RayEntity, Ref, Solid3DEntity, SolidEntity, SplineEntity, - TextEntity, TextOverride, ToleranceEntity, ViewportEntity, WipeoutEntity, + Origin, PointEntity, PolylineEntity, RayEntity, Ref, SegmentWidth, Solid3DEntity, SolidEntity, + SplineEntity, TextEntity, TextOverride, ToleranceEntity, ViewportEntity, WipeoutEntity, }; use uncad_model::model::{Point2D, Point3D}; @@ -630,6 +631,34 @@ fn extrusion(entity_ptr: *mut std::ffi::c_void, dxfname: &str) -> Point3D { .unwrap_or(Z_AXIS) } +/// A polyline's bulges as the model carries them: as stated, one per vertex +/// -- a negative bulge is an arc that turns clockwise in the OCS, and a +/// mirrored OCS does not change that sign -- or none at all when every +/// segment is straight, so that a file stating only zeros and one stating +/// nothing are the same polyline. +fn stated_bulges(bulges: Vec) -> Vec { + if bulges.iter().all(|b| *b == 0.0) { + Vec::new() + } else { + bulges + } +} + +/// A polyline's per-vertex widths as the model carries them: as stated, or +/// none at all when every segment is `const_width` wide at both ends -- the +/// same polyline whether the file states that width for every vertex or +/// states none. +fn stated_widths(widths: Vec, const_width: f64) -> Vec { + if widths + .iter() + .all(|w| w.start == const_width && w.end == const_width) + { + Vec::new() + } else { + widths + } +} + /// Resolves a WIPEOUT's clip boundary to local 2D points -- see /// [`crate::model::WipeoutEntity`] for the risk this carries. /// @@ -811,13 +840,36 @@ unsafe fn convert_entity( let vertices: Vec = get_point2d_array::(entity_ptr, "LWPOLYLINE", "num_points", "points"); let flag = get_field::(entity_ptr, "LWPOLYLINE", "flag").unwrap_or(0); + let const_width = + get_field::(entity_ptr, "LWPOLYLINE", "const_width").unwrap_or(0.0); + let bulges = stated_bulges(get_array_field::( + entity_ptr, + "LWPOLYLINE", + "num_bulges", + "bulges", + )); + let widths = stated_widths( + get_array_field::( + entity_ptr, + "LWPOLYLINE", + "num_widths", + "widths", + ) + .into_iter() + .map(|w| SegmentWidth { + start: w.start, + end: w.end, + }) + .collect(), + const_width, + ); Entity::LwPolyline(LwPolylineEntity { common, vertices, closed: flag & LWPOLYLINE_CLOSED_FLAG != 0, - bulges: Vec::new(), - widths: Vec::new(), - const_width: 0.0, + bulges, + widths, + const_width, elevation: get_field::(entity_ptr, "LWPOLYLINE", "elevation").unwrap_or(0.0), extrusion: if flag & LWPOLYLINE_EXTRUSION_FLAG != 0 { extrusion(entity_ptr, "LWPOLYLINE") @@ -1171,22 +1223,61 @@ unsafe fn convert_entity( } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_POLYLINE_2D => { // SAFETY: obj is a POLYLINE_2D per fixedtype; the vertices it - // owns are VERTEX_2D. A VERTEX_2D's stored point is 3D, but its z - // is the polyline's own elevation repeated. - let vertices: Vec = - unsafe { polyline_vertices(obj, libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_2D) } - .into_iter() - .filter_map(|vertex| get_point3d(vertex, "VERTEX_2D", "point")) - .map(|p| Point2D { x: p.x, y: p.y }) - .collect(); + // owns are VERTEX_2D, each with its own bulge and widths. A + // VERTEX_2D's stored point is 3D, but its z is the polyline's + // own elevation repeated. + let owned = + unsafe { polyline_vertices(obj, libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_VERTEX_2D) }; + // A DXF states the polyline's default widths once (its own + // groups 40/41) and leaves them out of every VERTEX that has + // them, and the importer reads an absent vertex width as 0: so + // there a vertex that states no width has the default. A DWG + // stores every vertex's widths on the vertex (the DWG twin of + // 2000/PolyLine2D.dxf has 0.15 on each vertex where the DXF has + // it once, on the POLYLINE). + let default_width = if is_from_dxf(dwg) { + ( + get_field::(entity_ptr, "POLYLINE_2D", "start_width").unwrap_or(0.0), + get_field::(entity_ptr, "POLYLINE_2D", "end_width").unwrap_or(0.0), + ) + } else { + (0.0, 0.0) + }; + let mut vertices = Vec::with_capacity(owned.len()); + let mut bulges = Vec::with_capacity(owned.len()); + let mut widths = Vec::with_capacity(owned.len()); + for vertex in owned { + let Some(point) = get_point3d(vertex, "VERTEX_2D", "point") else { + continue; + }; + let value = |field: &str| get_field::(vertex, "VERTEX_2D", field); + vertices.push(Point2D { + x: point.x, + y: point.y, + }); + bulges.push(value("bulge").unwrap_or(0.0)); + let stated = ( + value("start_width").unwrap_or(0.0), + value("end_width").unwrap_or(0.0), + ); + let (start, end) = if stated == (0.0, 0.0) { + default_width + } else { + stated + }; + widths.push(SegmentWidth { start, end }); + } let flag = get_field::(entity_ptr, "POLYLINE_2D", "flag").unwrap_or(0); + // A POLYLINE_2D states no constant width: its widths are its + // vertices' own (see LwPolylineEntity::const_width). + let const_width = 0.0; Entity::Polyline2D(LwPolylineEntity { common, vertices, closed: flag & POLYLINE_CLOSED_FLAG != 0, - bulges: Vec::new(), - widths: Vec::new(), - const_width: 0.0, + bulges: stated_bulges(bulges), + widths: stated_widths(widths, const_width), + const_width, elevation: get_field::(entity_ptr, "POLYLINE_2D", "elevation").unwrap_or(0.0), extrusion: extrusion(entity_ptr, "POLYLINE_2D"), }) diff --git a/crates/uncad/src/dynapi.rs b/crates/uncad/src/dynapi.rs index c4b82d6..12cc729 100644 --- a/crates/uncad/src/dynapi.rs +++ b/crates/uncad/src/dynapi.rs @@ -84,6 +84,18 @@ impl From for Point2D { } } +/// `Dwg_LWPOLYLINE_width`: one segment's width where it starts and where it +/// ends, two doubles (dwg.h). +#[repr(C)] +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct RawSegmentWidth { + pub start: f64, + pub end: f64, +} + +// SAFETY: `#[repr(C)]`, two `double`s, matching Dwg_LWPOLYLINE_width. +unsafe impl DwgRaw for RawSegmentWidth {} + /// [`get_field`] for a 3D point field, handed back as the model's type. pub fn get_point3d(entity: *mut c_void, dxfname: &str, field: &str) -> Option { get_field::(entity, dxfname, field).map(Point3D::from) diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index 8a605d3..c005123 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -530,3 +530,61 @@ fn mirrored_ocs_entities_keep_their_stated_coordinates_and_carry_their_normal() } assert_eq!(seen, 4); } + +// ------------------------------------------------------------- bulges + +/// A bulge is carried as stated -- one per vertex, the sign the file wrote +/// -- and a polyline whose segments are all straight carries none. +#[test] +fn polyline_bulges_are_carried_as_the_file_states_them() { + let bulged_polyline = |path: &str| -> Vec<(String, Vec, uncad::model::Point3D)> { + parse(path) + .entities + .iter() + .filter_map(|e| match e { + Entity::LwPolyline(p) | Entity::Polyline2D(p) => { + Some((handle(e).to_string(), p.bulges.clone(), p.extrusion)) + } + _ => None, + }) + .collect() + }; + // The mirrored fixture: handle 20 states no bulge, handle 21 one of + // tan(22.5 degrees) after its second vertex -- a quarter circle. + assert_eq!( + bulged_polyline(MIRRORED), + [ + ("20".to_string(), vec![], DOWN), + ("21".to_string(), vec![0.0, 0.41421356, 0.0, 0.0], UP), + ] + ); + // The same outline in a mirrored OCS keeps the sign the file wrote: + // the arc turns counter-clockwise in its OCS, and clockwise in the + // world only once a consumer takes it there. + assert_eq!( + bulged_polyline(MIRRORED_BULGE), + [("20".to_string(), vec![0.0, 0.41421356, 0.0, 0.0], DOWN)] + ); + // A POLYLINE_2D's bulges are its VERTEX records' (group 42): the + // semicircle's one bulge of 1.0, and the square's none. + assert_eq!( + bulged_polyline(POLYLINE_VERTICES), + [ + ("30".to_string(), vec![], UP), + ("35".to_string(), vec![1.0, 0.0], UP), + ] + ); + // The mirrored fixture's ARC is the same arc, stated as an ARC states + // it: 315 to 45 degrees in the mirrored OCS. + let arc = parse(MIRRORED_BULGE) + .entities + .iter() + .find_map(|e| match e { + Entity::Arc(a) => Some(a.clone()), + _ => None, + }) + .expect("the ARC"); + assert_eq!(arc.extrusion, DOWN); + assert!((arc.start_angle - 315f64.to_radians()).abs() < 1e-9); + assert!((arc.end_angle - 45f64.to_radians()).abs() < 1e-9); +} diff --git a/crates/uncad/tests/golden.rs b/crates/uncad/tests/golden.rs index 00f3637..01f8af5 100644 --- a/crates/uncad/tests/golden.rs +++ b/crates/uncad/tests/golden.rs @@ -20,7 +20,7 @@ use uncad::model::Ref; use uncad::{CadDatabase, Entity}; /// Every case, as (name, DXF bytes, expected model JSON). -const CASES: [(&str, &[u8], &str); 9] = [ +const CASES: [(&str, &[u8], &str); 11] = [ ( "g1", include_bytes!("golden/g1.dxf"), @@ -61,6 +61,16 @@ const CASES: [(&str, &[u8], &str); 9] = [ include_bytes!("golden/g10.dxf"), include_str!("golden/g10.expected.json"), ), + ( + "g11", + include_bytes!("golden/g11.dxf"), + include_str!("golden/g11.expected.json"), + ), + ( + "g12", + include_bytes!("golden/g12.dxf"), + include_str!("golden/g12.expected.json"), + ), ( "g15", include_bytes!("golden/g15.dxf"), diff --git a/crates/uncad/tests/golden/g11.dxf b/crates/uncad/tests/golden/g11.dxf new file mode 100644 index 0000000..01bae3c --- /dev/null +++ b/crates/uncad/tests/golden/g11.dxf @@ -0,0 +1,556 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$HANDSEED + 5 +10F + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LTYPE + 5 +1 +100 +AcDbSymbolTable + 70 +1 + 0 +LTYPE + 5 +2 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CONTINUOUS + 70 +0 + 3 +Solid line + 72 +65 + 73 +0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +3 +100 +AcDbSymbolTable + 70 +2 + 0 +LAYER + 5 +4 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +CONTINUOUS + 0 +LAYER + 5 +5 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +MIRROR + 70 +0 + 62 +4 + 6 +CONTINUOUS + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +6 +100 +AcDbSymbolTable + 70 +3 + 0 +BLOCK_RECORD + 5 +7 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space + 0 +BLOCK_RECORD + 5 +8 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space + 0 +BLOCK_RECORD + 5 +9 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +MARK + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +100 +330 +7 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +101 +330 +7 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +102 +330 +8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +103 +330 +8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +104 +330 +9 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +MARK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +MARK + 1 + + 0 +LINE + 5 +105 +330 +9 +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +10.0 + 21 +0.0 + 31 +0.0 + 0 +CIRCLE + 5 +106 +330 +9 +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +10.0 + 20 +0.0 + 30 +0.0 + 40 +2.0 + 0 +ENDBLK + 5 +107 +330 +9 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +CIRCLE + 5 +108 +330 +7 +100 +AcDbEntity + 8 +MIRROR +100 +AcDbCircle + 10 +30.0 + 20 +20.0 + 30 +0.0 + 40 +5.0 + 0 +CIRCLE + 5 +109 +330 +7 +100 +AcDbEntity + 8 +MIRROR +100 +AcDbCircle + 10 +30.0 + 20 +20.0 + 30 +0.0 + 40 +5.0 +210 +0.0 +220 +0.0 +230 +-1.0 + 0 +ARC + 5 +10A +330 +7 +100 +AcDbEntity + 8 +MIRROR +100 +AcDbCircle + 10 +30.0 + 20 +50.0 + 30 +0.0 + 40 +10.0 +210 +0.0 +220 +0.0 +230 +-1.0 +100 +AcDbArc + 50 +0.0 + 51 +90.0 + 0 +LWPOLYLINE + 5 +10B +330 +7 +100 +AcDbEntity + 8 +MIRROR +100 +AcDbPolyline + 90 +4 + 70 +1 + 38 +2.5 + 10 +20.0 + 20 +0.0 + 42 +0.0 + 10 +40.0 + 20 +0.0 + 42 +1.0 + 10 +40.0 + 20 +10.0 + 42 +0.0 + 10 +20.0 + 20 +10.0 + 42 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 + 0 +TEXT + 5 +10C +330 +7 +100 +AcDbEntity + 8 +MIRROR +100 +AcDbText + 10 +20.0 + 20 +-10.0 + 30 +0.0 + 40 +2.5 + 1 +MIRRORED + 50 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 +100 +AcDbText + 0 +SOLID + 5 +10D +330 +7 +100 +AcDbEntity + 8 +MIRROR +100 +AcDbTrace + 10 +20.0 + 20 +-30.0 + 30 +0.0 + 11 +30.0 + 21 +-30.0 + 31 +0.0 + 12 +20.0 + 22 +-20.0 + 32 +0.0 + 13 +30.0 + 23 +-20.0 + 33 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 + 0 +INSERT + 5 +10E +330 +7 +100 +AcDbEntity + 8 +MIRROR +100 +AcDbBlockReference + 2 +MARK + 10 +50.0 + 20 +0.0 + 30 +0.0 + 41 +1.0 + 42 +1.0 + 43 +1.0 + 50 +30.0 +210 +0.0 +220 +0.0 +230 +-1.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/golden/g11.expected.json b/crates/uncad/tests/golden/g11.expected.json new file mode 100644 index 0000000..15b0af6 --- /dev/null +++ b/crates/uncad/tests/golden/g11.expected.json @@ -0,0 +1,645 @@ +{ + "entities": [ + { + "type": "CIRCLE", + "common": { + "id": 264, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "108" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "CIRCLE", + "common": { + "id": 265, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "109" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "ARC", + "common": { + "id": 266, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10A" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 30.0, + "y": 50.0, + "z": 0.0 + }, + "radius": 10.0, + "start_angle": 0.0, + "end_angle": 1.5707963267948966, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 267, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10B" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 20.0, + "y": 0.0 + }, + { + "x": 40.0, + "y": 0.0 + }, + { + "x": 40.0, + "y": 10.0 + }, + { + "x": 20.0, + "y": 10.0 + } + ], + "closed": true, + "bulges": [ + 0.0, + 1.0, + 0.0, + 0.0 + ], + "widths": [], + "const_width": 0.0, + "elevation": 2.5, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 268, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10C" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 20.0, + "y": -10.0 + }, + "text_height": 2.5, + "text": "MIRRORED", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "SOLID", + "common": { + "id": 269, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10D" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "corner1": { + "x": 20.0, + "y": -30.0 + }, + "corner2": { + "x": 30.0, + "y": -30.0 + }, + "corner3": { + "x": 20.0, + "y": -20.0 + }, + "corner4": { + "x": 30.0, + "y": -20.0 + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "INSERT", + "common": { + "id": 270, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10E" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "MARK" + }, + "insertion_point": { + "x": 50.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + }, + "rotation": 0.5235987755982988, + "attribs": [], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + } + ], + "tables": { + "layers": { + "0": { + "name": "0", + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "MIRROR": { + "name": "MIRROR", + "color_index": 4, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + } + }, + "block_records": { + "*Model_Space": { + "name": "*Model_Space", + "entities": [ + { + "type": "CIRCLE", + "common": { + "id": 264, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "108" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "CIRCLE", + "common": { + "id": 265, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "109" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "radius": 5.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "ARC", + "common": { + "id": 266, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10A" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 30.0, + "y": 50.0, + "z": 0.0 + }, + "radius": 10.0, + "start_angle": 0.0, + "end_angle": 1.5707963267948966, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 267, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10B" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 20.0, + "y": 0.0 + }, + { + "x": 40.0, + "y": 0.0 + }, + { + "x": 40.0, + "y": 10.0 + }, + { + "x": 20.0, + "y": 10.0 + } + ], + "closed": true, + "bulges": [ + 0.0, + 1.0, + 0.0, + 0.0 + ], + "widths": [], + "const_width": 0.0, + "elevation": 2.5, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 268, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10C" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 20.0, + "y": -10.0 + }, + "text_height": 2.5, + "text": "MIRRORED", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "SOLID", + "common": { + "id": 269, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10D" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "corner1": { + "x": 20.0, + "y": -30.0 + }, + "corner2": { + "x": 30.0, + "y": -30.0 + }, + "corner3": { + "x": 20.0, + "y": -20.0 + }, + "corner4": { + "x": 30.0, + "y": -20.0 + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + }, + { + "type": "INSERT", + "common": { + "id": 270, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10E" + }, + "layer": { + "type": "RESOLVED", + "data": "MIRROR" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "MARK" + }, + "insertion_point": { + "x": 50.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + }, + "rotation": 0.5235987755982988, + "attribs": [], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": -1.0 + } + } + ] + }, + "*Paper_Space": { + "name": "*Paper_Space", + "entities": [] + }, + "MARK": { + "name": "MARK", + "entities": [ + { + "type": "LINE", + "common": { + "id": 261, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "105" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "end_point": { + "x": 10.0, + "y": 0.0, + "z": 0.0 + } + }, + { + "type": "CIRCLE", + "common": { + "id": 262, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "106" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 10.0, + "y": 0.0, + "z": 0.0 + }, + "radius": 2.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ] + } + }, + "dim_styles": {}, + "mlinestyles": {}, + "layouts": {} + }, + "read_diagnostics": { + "warnings": [] + } +} \ No newline at end of file diff --git a/crates/uncad/tests/golden/g12.dxf b/crates/uncad/tests/golden/g12.dxf new file mode 100644 index 0000000..c30aa48 --- /dev/null +++ b/crates/uncad/tests/golden/g12.dxf @@ -0,0 +1,412 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$HANDSEED + 5 +109 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LTYPE + 5 +1 +100 +AcDbSymbolTable + 70 +1 + 0 +LTYPE + 5 +2 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CONTINUOUS + 70 +0 + 3 +Solid line + 72 +65 + 73 +0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +3 +100 +AcDbSymbolTable + 70 +2 + 0 +LAYER + 5 +4 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +CONTINUOUS + 0 +LAYER + 5 +5 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +OUTLINE + 70 +0 + 62 +7 + 6 +CONTINUOUS + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +6 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +7 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space + 0 +BLOCK_RECORD + 5 +8 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +100 +330 +7 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +101 +330 +7 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +102 +330 +8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +103 +330 +8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LWPOLYLINE + 5 +104 +330 +7 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 42 +0.0 + 10 +40.0 + 20 +0.0 + 42 +1.0 + 10 +40.0 + 20 +10.0 + 42 +0.0 + 10 +0.0 + 20 +10.0 + 42 +1.0 + 0 +LWPOLYLINE + 5 +105 +330 +7 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbPolyline + 90 +3 + 70 +0 + 10 +0.0 + 20 +30.0 + 40 +2.0 + 41 +2.0 + 10 +30.0 + 20 +30.0 + 40 +4.0 + 41 +0.0 + 10 +40.0 + 20 +30.0 + 40 +0.0 + 41 +0.0 + 0 +LWPOLYLINE + 5 +106 +330 +7 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbPolyline + 90 +3 + 70 +0 + 43 +1.5 + 10 +0.0 + 20 +50.0 + 42 +0.0 + 10 +40.0 + 20 +50.0 + 42 +-0.41421356237309503 + 10 +50.0 + 20 +60.0 + 42 +0.0 + 0 +LWPOLYLINE + 5 +107 +330 +7 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbPolyline + 90 +2 + 70 +1 + 43 +2.0 + 10 +60.0 + 20 +20.0 + 42 +1.0 + 10 +70.0 + 20 +20.0 + 42 +1.0 + 0 +LWPOLYLINE + 5 +108 +330 +7 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbPolyline + 90 +2 + 70 +0 + 43 +1.0 + 10 +0.0 + 20 +90.0 + 40 +1.0 + 41 +1.0 + 42 +0.0 + 10 +40.0 + 20 +90.0 + 40 +1.0 + 41 +1.0 + 42 +0.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/golden/g12.expected.json b/crates/uncad/tests/golden/g12.expected.json new file mode 100644 index 0000000..151d880 --- /dev/null +++ b/crates/uncad/tests/golden/g12.expected.json @@ -0,0 +1,523 @@ +{ + "entities": [ + { + "type": "LWPOLYLINE", + "common": { + "id": 260, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "104" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 40.0, + "y": 0.0 + }, + { + "x": 40.0, + "y": 10.0 + }, + { + "x": 0.0, + "y": 10.0 + } + ], + "closed": true, + "bulges": [ + 0.0, + 1.0, + 0.0, + 1.0 + ], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 261, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "105" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 30.0 + }, + { + "x": 30.0, + "y": 30.0 + }, + { + "x": 40.0, + "y": 30.0 + } + ], + "closed": false, + "bulges": [], + "widths": [ + { + "start": 2.0, + "end": 2.0 + }, + { + "start": 4.0, + "end": 0.0 + }, + { + "start": 0.0, + "end": 0.0 + } + ], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 262, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "106" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 50.0 + }, + { + "x": 40.0, + "y": 50.0 + }, + { + "x": 50.0, + "y": 60.0 + } + ], + "closed": false, + "bulges": [ + 0.0, + -0.41421356237309503, + 0.0 + ], + "widths": [], + "const_width": 1.5, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 263, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "107" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 60.0, + "y": 20.0 + }, + { + "x": 70.0, + "y": 20.0 + } + ], + "closed": true, + "bulges": [ + 1.0, + 1.0 + ], + "widths": [], + "const_width": 2.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 264, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "108" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 90.0 + }, + { + "x": 40.0, + "y": 90.0 + } + ], + "closed": false, + "bulges": [], + "widths": [], + "const_width": 1.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ], + "tables": { + "layers": { + "0": { + "name": "0", + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "OUTLINE": { + "name": "OUTLINE", + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + } + }, + "block_records": { + "*Model_Space": { + "name": "*Model_Space", + "entities": [ + { + "type": "LWPOLYLINE", + "common": { + "id": 260, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "104" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 40.0, + "y": 0.0 + }, + { + "x": 40.0, + "y": 10.0 + }, + { + "x": 0.0, + "y": 10.0 + } + ], + "closed": true, + "bulges": [ + 0.0, + 1.0, + 0.0, + 1.0 + ], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 261, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "105" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 30.0 + }, + { + "x": 30.0, + "y": 30.0 + }, + { + "x": 40.0, + "y": 30.0 + } + ], + "closed": false, + "bulges": [], + "widths": [ + { + "start": 2.0, + "end": 2.0 + }, + { + "start": 4.0, + "end": 0.0 + }, + { + "start": 0.0, + "end": 0.0 + } + ], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 262, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "106" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 50.0 + }, + { + "x": 40.0, + "y": 50.0 + }, + { + "x": 50.0, + "y": 60.0 + } + ], + "closed": false, + "bulges": [ + 0.0, + -0.41421356237309503, + 0.0 + ], + "widths": [], + "const_width": 1.5, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 263, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "107" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 60.0, + "y": 20.0 + }, + { + "x": 70.0, + "y": 20.0 + } + ], + "closed": true, + "bulges": [ + 1.0, + 1.0 + ], + "widths": [], + "const_width": 2.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 264, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "108" + }, + "layer": { + "type": "RESOLVED", + "data": "OUTLINE" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 90.0 + }, + { + "x": 40.0, + "y": 90.0 + } + ], + "closed": false, + "bulges": [], + "widths": [], + "const_width": 1.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ] + }, + "*Paper_Space": { + "name": "*Paper_Space", + "entities": [] + } + }, + "dim_styles": {}, + "mlinestyles": {}, + "layouts": {} + }, + "read_diagnostics": { + "warnings": [] + } +} \ No newline at end of file diff --git a/crates/uncad/tests/polyline_fields.rs b/crates/uncad/tests/polyline_fields.rs new file mode 100644 index 0000000..d7a1a9a --- /dev/null +++ b/crates/uncad/tests/polyline_fields.rs @@ -0,0 +1,129 @@ +//! What runs between a polyline's vertices -- its bulges, its widths, its +//! constant width -- and its elevation and normal, read from corpus drawings +//! in both of their formats: the DXF twin states each of them as a group, +//! so the two readers have to agree. + +use std::collections::BTreeMap; + +use uncad::model::{LwPolylineEntity, SegmentWidth}; +use uncad::Entity; + +const CORPUS: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/" +); + +/// The corpus drawings that have both formats and polylines in them. +const TWINS: [&str; 4] = [ + "example_2000", + "example_2004", + "example_r13", + "2000/PolyLine2D", +]; + +fn parse(twin: &str, extension: &str) -> uncad::CadDatabase { + let path = format!("{CORPUS}{twin}.{extension}"); + uncad::parse(&path).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +/// Every LWPOLYLINE and POLYLINE_2D the drawing's blocks own, by handle. +fn polylines(db: &uncad::CadDatabase) -> BTreeMap { + let mut out = BTreeMap::new(); + for record in db.tables.block_records.values() { + for e in &record.entities { + if let Entity::LwPolyline(p) | Entity::Polyline2D(p) = e { + let uncad::model::Ref::Resolved(handle) = &p.common.source_handle else { + panic!("an R2000 entity has a handle"); + }; + out.insert(handle.clone(), p.clone()); + } + } + } + out +} + +#[test] +fn the_dwg_and_its_dxf_twin_agree_on_what_runs_between_the_vertices() { + let mut compared = 0; + let mut bulged = 0; + for twin in TWINS { + let dwg = polylines(&parse(twin, "dwg")); + let dxf = polylines(&parse(twin, "dxf")); + assert_eq!( + dwg.keys().collect::>(), + dxf.keys().collect::>(), + "{twin}" + ); + for (handle, a) in &dwg { + let b = &dxf[handle]; + let what = format!("{twin}: {handle}"); + // The DXF writes each number with at most 16 significant + // digits, so the two agree to that, not to the last bit. + let near = |x: &[f64], y: &[f64]| { + x.len() == y.len() + && x.iter() + .zip(y) + .all(|(p, q)| (p - q).abs() <= 1e-12 * p.abs().max(q.abs()).max(1.0)) + }; + let xy = |p: &LwPolylineEntity| -> Vec { + p.vertices.iter().flat_map(|v| [v.x, v.y]).collect() + }; + let widths = |p: &LwPolylineEntity| -> Vec { + p.widths.iter().flat_map(|w| [w.start, w.end]).collect() + }; + assert!( + near(&xy(a), &xy(b)), + "{what}: {:?} vs {:?}", + a.vertices, + b.vertices + ); + assert!( + near(&a.bulges, &b.bulges), + "{what}: {:?} vs {:?}", + a.bulges, + b.bulges + ); + assert!( + near(&widths(a), &widths(b)), + "{what}: {:?} vs {:?}", + a.widths, + b.widths + ); + assert!( + near(&[a.const_width, a.elevation], &[b.const_width, b.elevation]), + "{what}" + ); + assert_eq!(a.extrusion, b.extrusion, "{what}"); + compared += 1; + bulged += usize::from(!a.bulges.is_empty()); + } + } + assert!(compared >= 30, "{compared} polylines compared"); + assert!(bulged > 0, "no bulged polyline among the {compared}"); +} + +/// The dimension-arrow block `_ARCHTICK` draws a 0.15-wide tick. The DWG +/// stores that width on each of the tick's two vertices; the DXF writes it +/// once, as the POLYLINE's own default width (groups 40/41), and leaves it +/// out of both VERTEX records. Read as the vertices' own widths only, the +/// DXF's tick had no width at all. +#[test] +fn a_dxf_polyline_s_default_width_is_the_width_of_the_vertices_that_state_none() { + for extension in ["dwg", "dxf"] { + let db = parse("2000/PolyLine2D", extension); + let tick = db.tables.block_records["_ARCHTICK"] + .entities + .iter() + .find_map(|e| match e { + Entity::Polyline2D(p) => Some(p), + _ => None, + }) + .expect("the tick is a POLYLINE_2D"); + let width = SegmentWidth { + start: 0.15, + end: 0.15, + }; + assert_eq!(tick.widths, [width, width], "{extension}"); + assert_eq!(tick.const_width, 0.0, "{extension}"); + } +} diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 017e482..df8bd31 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -447,16 +447,29 @@ list the DXF importer fills with every object between a BLOCK and its ENDBLK: se pre-R13 DXFs in the corpus reported each polyline's vertices a second time, as 62 `Unknown` entities. -## Polyline bulges are not carried +## Polyline bulges and widths are carried as stated -- except a HATCH boundary's A polyline vertex can carry a bulge: the segment to the next vertex is then an arc, not a -straight line. The model has no field for it, so this crate reads the vertices and drops the -bulges -- an arc segment arrives as its chord, with no error and no diagnostic. This applies -to LWPOLYLINE, to 2D POLYLINE, and to the polyline boundaries of a HATCH. It is not rare: in -the test corpus, 677 of the 4,955 LWPOLYLINE vertices another reader finds carry a non-zero -bulge, as do 67 of the 122 HATCH polyline-boundary vertices. The library reads the bulges -(`bulges[]` on LWPOLYLINE, `bulge` on each 2D vertex); carrying them is a change to the -model, not to this reader. +straight line. LWPOLYLINE and POLYLINE_2D carry their `bulges` (one per vertex, with the +sign the file wrote -- a mirrored object coordinate system does not change it), their +per-vertex `widths`, their `const_width` and their `elevation`. A polyline whose file +states only zero bulges, or widths all equal to its constant width, carries the empty list, +the same as one that states none. In the test corpus, 231 LWPOLYLINEs and 10 POLYLINE_2Ds +(entities and block contents counted apiece) have a bulge. + +A POLYLINE_2D states no constant width of its own: its widths are its vertices'. A DXF, +though, writes the polyline's default widths once, as the POLYLINE's groups 40/41, and +leaves them out of every VERTEX that has them -- the DWG of `2000/PolyLine2D` stores 0.15 on +each vertex of its `_ARCHTICK` tick where the DXF twin states it once -- and LibreDWG's +importer reads an absent vertex width as 0. So a DXF vertex whose widths read as 0 takes +the polyline's default widths. A DXF vertex that states 0 explicitly under a non-zero +default cannot be told apart and reads as the default too. + +A HATCH's polyline boundary paths still arrive as their vertices alone: the model's +`HatchBoundaryPath::Polyline` has no bulges, so an arc segment of a boundary arrives as its +chord, with no error and no diagnostic. In the test corpus, 67 of the 122 HATCH +polyline-boundary vertices another reader finds carry a non-zero bulge. The library reads +them; carrying them is a change to the model, not to this reader. ## A fit-point spline's closed and periodic bits From 9120c1e31f102e67d5f886224bbf26bb7220ecf1 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:37:32 +0900 Subject: [PATCH 17/54] Read the file in Rust and decode it from memory, so a non-ASCII path opens parse() handed LibreDWG a C path, which dwg_read_file/dxf_read_file open with fopen(); the MSVC runtime reads that byte string in the process's ANSI code page, so a drawing under a Korean directory name failed with critical error 4096 although the file was there. parse() now reads the bytes with std::fs::read and decodes them with the shim's memory readers (uncad_dwg_read_bytes / uncad_dxf_read_bytes, ported in the native layer), and the same decode is public as parse_bytes(bytes, Format) for callers that never have a path. - Format { Dwg, Dxf } with Format::from_path: ".dxf" in any case is DXF, anything else DWG -- what parse() always did. - ParseError::Io(std::io::Error) for a file that cannot be read, with the cause as its source(). ParseError::InvalidPath is removed: nothing can produce it any more (a path is no longer converted for C), and a variant that is never returned would be a wrong sentence in the API. - The R2007+ DXF refusal is unchanged; its $ACADVER scan reads the bytes already in memory instead of opening the file a second time. tests/read_paths.rs (ported, adapted): a DWG and a DXF under a Hangul path, parse vs parse_bytes equality, Format::from_path, a missing file as Io, garbage bytes as Critical. The CLI's parse_input comment now says what parse() reports. Ported-from: 2b2c750 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++- README.md | 9 ++- crates/uncad-cli/src/main.rs | 9 ++- crates/uncad/src/lib.rs | 132 +++++++++++++++++++++---------- crates/uncad/tests/read_paths.rs | 99 +++++++++++++++++++++++ docs/ARCHITECTURE.md | 9 ++- 6 files changed, 216 insertions(+), 53 deletions(-) create mode 100644 crates/uncad/tests/read_paths.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a53e8b..faa40e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,14 @@ Notable changes to this project are recorded here. The format follows `uncad_dwg_from_version`, `uncad_dwg_from_dxf`, `uncad_codepage_name`, and the code-page string conversions `uncad_tv_to_utf8`, `uncad_bytes_to_utf8`, their `uncad_entity_*` twins, `uncad_dwg_string_to_utf8` and `uncad_free_string`, with - bindings for `dwg_version_type` and `dwg_next_object`. `uncad` does not call them yet. + bindings for `dwg_version_type` and `dwg_next_object`. `uncad` reads every drawing + through the two memory readers; it does not call the rest yet. +- `uncad::parse_bytes(bytes, Format)` parses a drawing already in memory, and + `uncad::Format` (`Dwg`, `Dxf`, with `Format::from_path`) says which it is. `parse()` + now reads the file itself and decodes it from memory, so a path LibreDWG could not + open -- any non-ASCII one on Windows, such as a Korean directory name, which failed + with critical error 4096 -- parses; a file that cannot be read is the new + `ParseError::Io`. `tests/read_paths.rs`. - Every crate carries the GPLv3 text as its own `LICENSE`; `cargo package` never reaches the repository root's, so of the 0.2.0 tarballs only `libredwg-sys` had the text (as LibreDWG's own `COPYING`) and `uncad-cli` had no licence file at all. @@ -227,6 +234,8 @@ Notable changes to this project are recorded here. The format follows -- that is the point. `ParseError` is `#[non_exhaustive]`, so the new variant is not a breaking change to matches. Two tests pin it: a walk of the corpus that requires exactly the R2007+ files to be refused, and one drawing under two `$ACADVER` values. +- `ParseError::InvalidPath` is gone: `parse()` no longer hands LibreDWG a C path, and a + file it cannot read is `ParseError::Io`. Breaking for code that names the variant. - No `std` hash collections anywhere in the workspace: `clippy.toml` disallows `HashMap` and `HashSet`, and the `iter_over_hash_type` lint is on. Both earlier ordering bugs went through `into_iter()`/`into_values()`, which no lint on `for` loops would have seen. diff --git a/README.md b/README.md index 492a6ca..86ef976 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ cargo test --workspace let db = uncad::parse("drawing.dwg")?; // same model for DWG and DXF (entities + tables) println!("{} entities", db.entities.len()); +// Bytes already in memory (from the network, an archive, ...): +let bytes = std::fs::read("drawing.dwg")?; +let db = uncad::parse_bytes(&bytes, uncad::Format::Dwg)?; + let json = db.to_json(uncad::ToJsonOptions { pretty: true })?; // the model, serialized as-is std::fs::write("drawing.json", json)?; @@ -44,7 +48,8 @@ cargo run -p uncad-cli -- drawing.dwg -o all.svg --space all # every space i 1. **DWG** — read through [LibreDWG](https://www.gnu.org/software/libredwg/) (GPLv3+), bound directly via Rust FFI (`bindgen`). All versions. -2. **DXF** — read through the same LibreDWG engine, chosen by file extension. +2. **DXF** — read through the same LibreDWG engine, chosen by file extension + (`parse`) or by the caller (`parse_bytes`). **DXF saved as R2007 or later is refused** with `ParseError::UnsupportedDxfVersion` rather than read incompletely — see `docs/CAVEATS.md`, "DXF reading". R2000/R2004 DXF and every DWG version are unaffected. @@ -99,7 +104,7 @@ crates/ five local patches marked "uncad local patch" and listed in NOTICE.md; shim/ holds the C accessors for opaque types, and vendor-config/config.h stands in for autotools - uncad/ the safe API: parse() -> uncad_model::CadDatabase + uncad/ the safe API: parse() / parse_bytes() -> uncad_model::CadDatabase uncad-cli/ the CLI binary (uncad) crates/*/tests/ integration tests against the public API. crates/*/examples/ are manual-check tools, and #[cfg(test)] blocks inside src/*.rs are diff --git a/crates/uncad-cli/src/main.rs b/crates/uncad-cli/src/main.rs index 0c958c3..8f9e051 100644 --- a/crates/uncad-cli/src/main.rs +++ b/crates/uncad-cli/src/main.rs @@ -188,10 +188,11 @@ fn run(args: &Args) -> Result<(), String> { Ok(()) } -/// `uncad::parse()` reports a nonexistent path or a wrong extension as a bare -/// LibreDWG error code, which is accurate but not something a user can act on -/// without cross-referencing dwg.h. The obvious cases are checked here first so -/// the message says what is actually wrong. +/// `uncad::parse()` reports a path it cannot read as `ParseError::Io`, and a +/// file that is not a drawing as a bare LibreDWG error code, which is accurate +/// but not something a user can act on without cross-referencing dwg.h. The +/// obvious cases are checked here first so the message says what is actually +/// wrong. fn parse_input(input: &str) -> Result { match std::fs::metadata(input) { Ok(meta) if meta.is_dir() => { diff --git a/crates/uncad/src/lib.rs b/crates/uncad/src/lib.rs index d98a682..72849c8 100644 --- a/crates/uncad/src/lib.rs +++ b/crates/uncad/src/lib.rs @@ -13,7 +13,6 @@ mod dynapi; mod table_convert; mod text; -use std::ffi::CString; use std::mem::MaybeUninit; use std::path::Path; use std::sync::Mutex; @@ -30,9 +29,10 @@ pub use uncad_model::{CadDatabase, Entity, JsonError, ReadDiagnostics, Tables, T /// cycles overlapped. Sequential reuse across many calls is fine; concurrent /// calls are what this lock closes off. /// -/// [`parse`] is the only entry point that touches the FFI boundary and it -/// holds this lock for its whole duration, so callers never need to know -/// `libredwg-sys` exists, let alone serialize around it themselves. +/// [`parse_bytes`] (which [`parse`] calls) is the only entry point that +/// touches the FFI boundary and it holds this lock for its whole duration, so +/// callers never need to know `libredwg-sys` exists, let alone serialize +/// around it themselves. static LIBREDWG_LOCK: Mutex<()> = Mutex::new(()); /// Names for the non-critical `DWG_ERROR` bits, in bit order (dwg.h). @@ -71,10 +71,12 @@ pub fn read_diagnostics_from_libredwg_bits(bits: i32) -> ReadDiagnostics { #[derive(Debug)] #[non_exhaustive] pub enum ParseError { - /// `dwg_read_file`/`dxf_read_file` returned a critical LibreDWG error - /// code (>= DWG_ERR_CRITICAL, see dwg.h). + /// LibreDWG's decoder returned a critical error code (>= DWG_ERR_CRITICAL, + /// see dwg.h) for the bytes it was given. Critical(i32), - InvalidPath, + /// The file could not be read from disk ([`parse`] only; [`parse_bytes`] + /// never produces it). + Io(std::io::Error), /// The DXF declares `$ACADVER` R2007 or later (`AC1021` and up). LibreDWG's /// DXF importer reads these files without reporting an error but loses /// every layer and block name on the way, so this crate refuses them @@ -88,7 +90,7 @@ impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ParseError::Critical(code) => write!(f, "LibreDWG critical read error (code {code})"), - ParseError::InvalidPath => write!(f, "path is not valid UTF-8 / contains a NUL byte"), + ParseError::Io(e) => write!(f, "cannot read the file: {e}"), ParseError::UnsupportedDxfVersion(v) => write!( f, "DXF version {v} (R2007 or later) is not supported: LibreDWG's DXF importer would return a silently incomplete drawing -- save it as R2004 DXF or as DWG" @@ -96,7 +98,41 @@ impl std::fmt::Display for ParseError { } } } -impl std::error::Error for ParseError {} +impl std::error::Error for ParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ParseError::Io(e) => Some(e), + ParseError::Critical(_) | ParseError::UnsupportedDxfVersion(_) => None, + } + } +} + +/// The on-disk format of a drawing. [`parse`] picks it from the file +/// extension; [`parse_bytes`] takes it explicitly because bytes carry no +/// name. A binary DXF is [`Format::Dxf`] too: the reader tells ASCII from +/// binary by the bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Format { + Dwg, + Dxf, +} + +impl Format { + /// `.dxf` (any case) is [`Format::Dxf`]; anything else is read as DWG, + /// which is what [`parse`] has always done. + pub fn from_path(path: impl AsRef) -> Format { + let is_dxf = path + .as_ref() + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("dxf")); + if is_dxf { + Format::Dxf + } else { + Format::Dwg + } + } +} /// Groups an entity cannot be understood without, where this crate filled a /// value instead of finding one. @@ -141,30 +177,37 @@ fn missing_required_groups(entities: &[Entity]) -> Vec { /// Parses a DWG or DXF file at `path` into a [`CadDatabase`] -- the same /// model either way, with the format inferred from the `.dwg`/`.dxf` -/// extension. +/// extension (see [`Format::from_path`]). /// -/// How complete DXF reading is depends on the entity type: `dxf_read_file()` -/// is LibreDWG's own function and its documentation describes DXF reading as -/// working "for most objects" rather than being feature-complete the way DWG -/// reading is. A DXF whose `$ACADVER` is R2007 or later is refused up front -/// with [`ParseError::UnsupportedDxfVersion`] rather than handed to LibreDWG, -/// because the importer would return it as a drawing with no entities and no -/// error. See `docs/CAVEATS.md`. +/// The file is read into memory here and decoded by [`parse_bytes`] rather +/// than handed to LibreDWG as a path: LibreDWG opens paths with `fopen()`, +/// which on Windows interprets the bytes in the process's ANSI code page and +/// so cannot open a path with, say, a Korean directory name. `std::fs::read` +/// has no such limit, and a file that cannot be read is [`ParseError::Io`]. +/// +/// How complete DXF reading is depends on the entity type: LibreDWG's own +/// DXF reader is documented as working "for most objects" rather than being +/// feature-complete the way DWG reading is. A DXF whose `$ACADVER` is R2007 +/// or later is refused up front with [`ParseError::UnsupportedDxfVersion`] +/// rather than handed to LibreDWG, because the importer would return it as a +/// drawing with no entities and no error. See `docs/CAVEATS.md`. pub fn parse(path: impl AsRef) -> Result { - let path_str = path.as_ref().to_str().ok_or(ParseError::InvalidPath)?; - let c_path = CString::new(path_str).map_err(|_| ParseError::InvalidPath)?; - let is_dxf = path - .as_ref() - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("dxf")); + let path = path.as_ref(); + let format = Format::from_path(path); + let bytes = std::fs::read(path).map_err(ParseError::Io)?; + parse_bytes(&bytes, format) +} +/// Parses a drawing already held in memory. This is what [`parse`] calls +/// after reading the file; it is public for callers that receive drawings +/// over the network or from an archive and never have a path. +pub fn parse_bytes(bytes: &[u8], format: Format) -> Result { // Decided from the file's own header, before LibreDWG sees it: the R2007+ // failure is silent on the C side (error code 0, zero entities), so the // only place it can be turned into an error is here. No lock needed -- - // this is plain file I/O. - if is_dxf { - if let Some(acadver) = dxf_acadver(path.as_ref()) { + // this is a scan of the bytes. + if format == Format::Dxf { + if let Some(acadver) = dxf_acadver(bytes) { if dxf_version_number(&acadver).is_some_and(|n| n >= DXF_VERSION_R2007) { return Err(ParseError::UnsupportedDxfVersion(acadver)); } @@ -182,8 +225,8 @@ pub fn parse(path: impl AsRef) -> Result { .unwrap_or_else(|poisoned| poisoned.into_inner()); // SAFETY: Dwg_Data is bound as an opaque, correctly-sized byte blob (see - // libredwg-sys's build.rs); dwg_read_file/dxf_read_file expect a - // zero-initialized instance -- an uninitialized one aborts with + // libredwg-sys's build.rs); the readers expect a zero-initialized + // instance -- an uninitialized one aborts with // STATUS_STACK_BUFFER_OVERRUN (garbage in dwg.opts feeding the runtime // loglevel global). Boxed so the C side fills it in place at a stable // heap address; it lives only until the two conversion walks below have @@ -191,10 +234,16 @@ pub fn parse(path: impl AsRef) -> Result { let mut dwg: Box = Box::new(unsafe { MaybeUninit::zeroed().assume_init() }); - let error = if is_dxf { - unsafe { libredwg_sys::dxf_read_file(c_path.as_ptr(), dwg.as_mut()) } - } else { - unsafe { libredwg_sys::dwg_read_file(c_path.as_ptr(), dwg.as_mut()) } + // SAFETY: `bytes` is a live slice for the duration of the call and the + // shims only read it (they copy it into their own Bit_Chain); `dwg` is a + // valid zeroed Dwg_Data they fill in place. + let error = match format { + Format::Dwg => unsafe { + libredwg_sys::uncad_dwg_read_bytes(bytes.as_ptr(), bytes.len(), dwg.as_mut()) + }, + Format::Dxf => unsafe { + libredwg_sys::uncad_dxf_read_bytes(bytes.as_ptr(), bytes.len(), dwg.as_mut()) + }, }; // Cast the constant, not `error`: the DWG_ERROR enum constants' width is // whatever bindgen inferred for the target (u32 on Linux, i32 on MSVC -- @@ -216,7 +265,7 @@ pub fn parse(path: impl AsRef) -> Result { // Every string the two walks below read goes through this decoder: the // library returns a pre-R2007 string as the codepage bytes the file // holds, and what could not be decoded is reported, never dropped. - let text = unsafe { text::TextDecoder::new(dwg.as_mut(), is_dxf) }; + let text = unsafe { text::TextDecoder::new(dwg.as_mut(), format == Format::Dxf) }; // Two walks over the live C structure, neither of which mutates it. // Everything the returned value exposes is an owned Rust copy by the end. @@ -254,20 +303,19 @@ fn dxf_version_number(acadver: &str) -> Option { } /// Reads `$ACADVER` out of an ASCII DXF's HEADER section, or `None` if the -/// file has no such variable (pre-R10 files), is not readable, or is not laid -/// out as (group code, value) line pairs. Binary DXF is not handled here and -/// falls through to LibreDWG untouched. +/// file has no such variable (pre-R10 files) or is not laid out as (group +/// code, value) line pairs. Binary DXF is not handled here and falls through +/// to LibreDWG untouched. /// /// The scan is bounded: `$ACADVER` is by convention the first header variable, /// and the HEADER section ends at the first `ENDSEC`, so a file that reaches /// either bound without it is treated as not declaring a version. -fn dxf_acadver(path: &Path) -> Option { - use std::io::{BufRead, BufReader}; - +fn dxf_acadver(bytes: &[u8]) -> Option { const MAX_PAIRS_SCANNED: usize = 4096; - let file = std::fs::File::open(path).ok()?; - let mut lines = BufReader::new(file).lines().map_while(Result::ok); + let mut lines = bytes + .split(|&b| b == b'\n') + .map(|line| String::from_utf8_lossy(line).into_owned()); for _ in 0..MAX_PAIRS_SCANNED { let (code, value) = (lines.next()?, lines.next()?); match (code.trim(), value.trim()) { diff --git a/crates/uncad/tests/read_paths.rs b/crates/uncad/tests/read_paths.rs new file mode 100644 index 0000000..ddedf6e --- /dev/null +++ b/crates/uncad/tests/read_paths.rs @@ -0,0 +1,99 @@ +//! `parse()` reads the file itself and decodes from memory (`parse_bytes`), +//! so a path with non-ASCII characters works on Windows too -- LibreDWG's +//! own `dwg_read_file` opens paths with `fopen()`, which the MSVC runtime +//! interprets in the ANSI code page, and a Korean directory name failed with +//! `DWG_ERR_IOERROR` (critical error 4096). The fixtures are the LibreDWG +//! corpus's one-entity `circle.dwg` and the R2000 DXF the other tests read; +//! the assertions stay reference-free (a count, and parse-vs-parse_bytes +//! equality). + +use std::path::PathBuf; + +const CIRCLE_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/2000/circle.dwg" +); +const ENTITIES_DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/2000/entities-2d.dxf" +); + +/// A per-test directory under the OS temp dir, removed on drop. +struct TempDir(PathBuf); + +impl TempDir { + fn new(name: &str) -> Self { + let mut path = std::env::temp_dir(); + path.push(format!("uncad-{name}-{}", std::process::id())); + std::fs::create_dir_all(&path).expect("temp dir should be writable"); + TempDir(path) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn parse_opens_a_dwg_under_a_korean_path() { + let dir = TempDir::new("한글경로"); + let target = dir.0.join("도면.dwg"); + std::fs::copy(CIRCLE_DWG, &target).expect("copying the fixture should succeed"); + + let db = uncad::parse(&target).expect("a path with Hangul in it must parse"); + assert_eq!(db.entities.len(), 1, "circle.dwg holds exactly one entity"); +} + +#[test] +fn parse_opens_a_dxf_under_a_korean_path() { + let dir = TempDir::new("한글경로-dxf"); + let target = dir.0.join("도면.dxf"); + std::fs::copy(ENTITIES_DXF, &target).expect("copying the fixture should succeed"); + + let from_korean_path = uncad::parse(&target).expect("a path with Hangul in it must parse"); + let from_ascii_path = uncad::parse(ENTITIES_DXF).expect("the corpus path must parse"); + assert_eq!(from_korean_path, from_ascii_path); +} + +#[test] +fn parse_bytes_gives_the_same_model_as_parse() { + for (path, format) in [ + (CIRCLE_DWG, uncad::Format::Dwg), + (ENTITIES_DXF, uncad::Format::Dxf), + ] { + let from_path = uncad::parse(path).expect("the corpus file must parse"); + let bytes = std::fs::read(path).expect("the corpus file must be readable"); + let from_bytes = uncad::parse_bytes(&bytes, format).expect("the same bytes must parse"); + assert_eq!(from_path, from_bytes, "{path}"); + } +} + +#[test] +fn format_from_path_only_treats_dxf_as_dxf() { + assert_eq!(uncad::Format::from_path("a/b.DXF"), uncad::Format::Dxf); + assert_eq!(uncad::Format::from_path("a/b.dwg"), uncad::Format::Dwg); + assert_eq!(uncad::Format::from_path("no-extension"), uncad::Format::Dwg); +} + +#[test] +fn a_missing_file_is_an_io_error() { + let err = uncad::parse("this-file-does-not-exist.dwg").expect_err("must fail"); + assert!(matches!(err, uncad::ParseError::Io(_)), "{err:?}"); + // The cause travels with it, and the message says what failed. + assert!(std::error::Error::source(&err).is_some()); + assert!(err.to_string().starts_with("cannot read the file"), "{err}"); +} + +#[test] +fn garbage_bytes_are_a_critical_decode_error() { + for format in [uncad::Format::Dwg, uncad::Format::Dxf] { + let err = uncad::parse_bytes(b"this is not a drawing, in any format", format) + .expect_err("must fail"); + assert!( + matches!(err, uncad::ParseError::Critical(_)), + "{format:?}: {err:?}" + ); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2121c3c..5c3dc98 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -189,10 +189,11 @@ the [`uncad-model`](https://github.com/iyulab/uncad-model) crate (MIT, pure data this crate depends on by version and re-exports as `uncad::model` / `uncad::tables` / `uncad::json`. `CadDatabase` is a plain Rust value holding `entities` (what the model and paper spaces own), `tables` (LAYER, every BLOCK_RECORD, MLINESTYLE) and `read_diagnostics` -(the reader's non-fatal warnings). The `Dwg_Data` that LibreDWG filled in through -`dwg_read_file`/`dxf_read_file` is walked twice inside `parse()` (`convert_entities`, then -`convert_tables`), freed with `dwg_free` immediately afterwards, and never reaches the -return value. The hub of "DWG/DXF -> one model -> several outputs" is therefore the model +(the reader's non-fatal warnings). `parse()` reads the file into memory itself (LibreDWG's +own readers `fopen()` a path, which on Windows cannot open a non-ASCII one), and the +`Dwg_Data` that the shim's `uncad_dwg_read_bytes`/`uncad_dxf_read_bytes` fill from those +bytes is walked twice inside `parse()` (`convert_entities`, then `convert_tables`), freed +with `dwg_free` immediately afterwards, and never reaches the return value. The hub of "DWG/DXF -> one model -> several outputs" is therefore the model crate's value, and the outputs are `CadDatabase::to_json()` (serde, in `uncad-model`) and, in the `iron-render-cad` crate, `to_svg(&db, ..)` and `to_png(&db, ..)`. From 5f2a90448a1e85dc901c51383ce52e98217f4991 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:42:05 +0900 Subject: [PATCH 18/54] Return the drawing's header variables beside the model, as uncad's own type A consumer cannot read the model's numbers without the header: the unit a coordinate is in ($INSUNITS), the extents the file claims, the dimension variables a DIMENSION falls back on. The model carries no header variables by design, so the header is this crate's public type, uncad::Header, read in the same pass and returned beside the database: parse_with_header(path) -> Result<(CadDatabase, Header), _> parse_bytes_with_header(bytes) -> Result<(CadDatabase, Header), _> parse() and parse_bytes() keep their signatures and call these. A separate header read was the alternative and was not taken: it would decode the drawing twice, and a DXF decode is the expensive part (more than quadratic in LibreDWG's importer). A tuple rather than a wrapper struct keeps the database the model's own type with nothing to unwrap. Header: format, acadver (the DWG magic or $ACADVER, as the file states it), version (LibreDWG's release name), codepage/codepage_name (the one strings were decoded with), $INSUNITS (Header::units() -> Units { name, to_mm }, the DXF reference's table), $MEASUREMENT, $LUNITS/$LUPREC/$AUNITS/$AUPREC, $EXTMIN/$EXTMAX/$LIMMIN/$LIMMAX and the paper-space four, the $DIM* variables (DIMSCALE, DIMLFAC, DIMDEC, DIMLUNIT, DIMPOST, DIMRND, DIMZIN, DIMFRAC, DIMAUNIT, DIMADEC, DIMTXT, DIMASZ), $LTSCALE, $TEXTSIZE and $CLAYER as a Ref. Serialize/Deserialize, #[non_exhaustive]. Unknown is None, never a default. LibreDWG's struct holds every variable whether the file states it or not, so a variable is Some only when the file is known to: for a DWG by its version's header layout (header_variables.spec / header_variables_r11.spec: INSUNITS, DIMADEC, DIMFRAC, DIMLUNIT from R2000; DIMDEC, DIMAUNIT from R13; a pre-R13 header stops where its numheader_vars says; MEASUREMENT only when the optional Template section was read), for an ASCII DXF by the variables its HEADER section names (a scan of the bytes, bounded by the section), and not at all for a binary DXF, which is not scanned. The feature branch filled all of these with zero or LibreDWG's value. The same scan now supplies the R2007+ refusal's $ACADVER. Two shim accessors make that decidable (new C API, in the shim this crate owns): uncad_dwg_numheader_vars and uncad_dwg_template_read (whether Template.description was allocated, which bit_read_T16/TU16 do for any length). TextDecoder gains header_text, and codepage_name returns None for a codepage LibreDWG has no name for. tests/header.rs (ported, adapted to Option): example_2000.dwg against the values the file states, the DXF twin agreeing, R14 stating no R2000-only variable, pre-R13 headers cut where their count says, the Chinese R2013 file, $DIMPOST round trips, a DXF stating only what its HEADER names, the serde shape, parse vs parse_bytes. Unit tests for the INSUNITS table, the scan and the DWG layout. Ported-from: 2b2c750, 1f08658 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 +- README.md | 7 +- crates/libredwg-sys/build.rs | 2 + crates/libredwg-sys/shim/uncad_shim.c | 16 + crates/libredwg-sys/shim/uncad_shim.h | 14 + crates/uncad/src/dynapi.rs | 72 ++++ crates/uncad/src/header.rs | 531 ++++++++++++++++++++++++++ crates/uncad/src/lib.rs | 114 +++--- crates/uncad/src/text.rs | 49 ++- crates/uncad/tests/header.rs | 281 ++++++++++++++ docs/ARCHITECTURE.md | 17 +- 11 files changed, 1052 insertions(+), 71 deletions(-) create mode 100644 crates/uncad/src/header.rs create mode 100644 crates/uncad/tests/header.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index faa40e7..990426f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,18 +11,32 @@ Notable changes to this project are recorded here. The format follows - `libredwg-sys` reads a drawing from memory (`uncad_dwg_read_bytes`, `uncad_dxf_read_bytes`) -- LibreDWG's own file readers `fopen()` a byte string the MSVC runtime reads in the ANSI code page, so a non-ASCII path fails on Windows -- and - exposes what decoding a drawing's text needs: `uncad_dwg_version`, - `uncad_dwg_from_version`, `uncad_dwg_from_dxf`, `uncad_codepage_name`, and the + exposes what decoding a drawing's text and header needs: `uncad_dwg_version`, + `uncad_dwg_from_version`, `uncad_dwg_from_dxf`, `uncad_dwg_numheader_vars` (how long a + pre-R13 header is), `uncad_dwg_template_read` (whether the section holding + `$MEASUREMENT` was read), `uncad_codepage_name`, and the code-page string conversions `uncad_tv_to_utf8`, `uncad_bytes_to_utf8`, their `uncad_entity_*` twins, `uncad_dwg_string_to_utf8` and `uncad_free_string`, with bindings for `dwg_version_type` and `dwg_next_object`. `uncad` reads every drawing - through the two memory readers; it does not call the rest yet. + through the two memory readers and its header through the file-header accessors; it + does not call the string conversions. - `uncad::parse_bytes(bytes, Format)` parses a drawing already in memory, and `uncad::Format` (`Dwg`, `Dxf`, with `Format::from_path`) says which it is. `parse()` now reads the file itself and decodes it from memory, so a path LibreDWG could not open -- any non-ASCII one on Windows, such as a Korean directory name, which failed with critical error 4096 -- parses; a file that cannot be read is the new `ParseError::Io`. `tests/read_paths.rs`. +- `uncad::parse_with_header` / `uncad::parse_bytes_with_header` return the drawing's + `uncad::Header` beside the model: the format, `$ACADVER` and LibreDWG's release name, + the codepage strings were decoded with, `$INSUNITS` (`Header::units()` gives the unit + and its millimetre factor), `$MEASUREMENT`, `$LUNITS`/`$LUPREC`/`$AUNITS`/`$AUPREC`, + the model and paper extents and limits, the `$DIM*` variables a dimension falls back + on, `$LTSCALE`, `$TEXTSIZE` and `$CLAYER`. A variable the file does not state is + `None` -- for a DWG by its version's header layout, for an ASCII DXF by what its HEADER + section names -- never the zero or default LibreDWG's struct holds for it. The header + is this crate's type, not the model's, which carries no header variables by design; + `parse()` and `parse_bytes()` are unchanged and return the database alone. + `tests/header.rs`. - Every crate carries the GPLv3 text as its own `LICENSE`; `cargo package` never reaches the repository root's, so of the 0.2.0 tarballs only `libredwg-sys` had the text (as LibreDWG's own `COPYING`) and `uncad-cli` had no licence file at all. diff --git a/README.md b/README.md index 86ef976..c6b6906 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ cargo test --workspace let db = uncad::parse("drawing.dwg")?; // same model for DWG and DXF (entities + tables) println!("{} entities", db.entities.len()); +// The header variables that give the numbers a meaning come beside the model: +let (db, header) = uncad::parse_with_header("drawing.dxf")?; +println!("units: {:?}", header.units()); // $INSUNITS; None when the file does not say + // Bytes already in memory (from the network, an archive, ...): let bytes = std::fs::read("drawing.dwg")?; let db = uncad::parse_bytes(&bytes, uncad::Format::Dwg)?; @@ -104,7 +108,8 @@ crates/ five local patches marked "uncad local patch" and listed in NOTICE.md; shim/ holds the C accessors for opaque types, and vendor-config/config.h stands in for autotools - uncad/ the safe API: parse() / parse_bytes() -> uncad_model::CadDatabase + uncad/ the safe API: parse() / parse_bytes() -> uncad_model::CadDatabase, + and the drawing's Header beside it from parse_with_header() uncad-cli/ the CLI binary (uncad) crates/*/tests/ integration tests against the public API. crates/*/examples/ are manual-check tools, and #[cfg(test)] blocks inside src/*.rs are diff --git a/crates/libredwg-sys/build.rs b/crates/libredwg-sys/build.rs index 5d58906..ad725f2 100644 --- a/crates/libredwg-sys/build.rs +++ b/crates/libredwg-sys/build.rs @@ -261,6 +261,8 @@ fn main() { .allowlist_function("uncad_dwg_version") .allowlist_function("uncad_dwg_from_version") .allowlist_function("uncad_dwg_from_dxf") + .allowlist_function("uncad_dwg_numheader_vars") + .allowlist_function("uncad_dwg_template_read") .allowlist_function("uncad_dwg_string_to_utf8") .allowlist_function("uncad_tv_to_utf8") .allowlist_function("uncad_entity_tv_to_utf8") diff --git a/crates/libredwg-sys/shim/uncad_shim.c b/crates/libredwg-sys/shim/uncad_shim.c index a68fa00..116ba55 100644 --- a/crates/libredwg-sys/shim/uncad_shim.c +++ b/crates/libredwg-sys/shim/uncad_shim.c @@ -209,6 +209,22 @@ uncad_dwg_from_dxf (const Dwg_Data *dwg) return (dwg && (dwg->opts & DWG_OPTS_INDXF)) ? 1 : 0; } +uint16_t +uncad_dwg_numheader_vars (const Dwg_Data *dwg) +{ + return dwg ? (uint16_t)dwg->header.numheader_vars : 0; +} + +int +uncad_dwg_template_read (const Dwg_Data *dwg) +{ + /* template.spec reads `description` first, and bit_read_T16/TU16 allocate + it for every length, zero included: non-NULL exactly when the decoder + found and read the section. The DXF importer copies $MEASUREMENT into + the Template without it. */ + return (dwg && dwg->Template.description) ? 1 : 0; +} + char * uncad_dwg_string_to_utf8 (const Dwg_Data *dwg, const char *s) { diff --git a/crates/libredwg-sys/shim/uncad_shim.h b/crates/libredwg-sys/shim/uncad_shim.h index 9d76a9b..f5b124c 100644 --- a/crates/libredwg-sys/shim/uncad_shim.h +++ b/crates/libredwg-sys/shim/uncad_shim.h @@ -74,6 +74,20 @@ int uncad_dwg_from_version(const Dwg_Data *dwg); * else on that path (a LAYER's plot flag, for one). */ int uncad_dwg_from_dxf(const Dwg_Data *dwg); +/* How many header variables a pre-R13 DWG's file header says its header + * section holds (`dwg->header.numheader_vars`: 74, 83, ... 205 across the + * releases), which decides where LibreDWG's pre-R13 header layout stops + * reading (header_variables_r11.spec). 0 for R13 and later, whose header + * layout is fixed per version, and for a NULL `dwg`. */ +uint16_t uncad_dwg_numheader_vars(const Dwg_Data *dwg); + +/* 1 when the decoder read a DWG's Template section -- the one that holds + * $MEASUREMENT, optional before R2007 and skipped without an error bit when + * it is missing -- 0 otherwise, for any DXF input and for a NULL `dwg`. + * Without this a $MEASUREMENT of 0 ("English") cannot be told from one the + * file never stated. */ +int uncad_dwg_template_read(const Dwg_Data *dwg); + /* The DXF name of a Dwg_Codepage value -- as uncad_dwg_codepage returns it * -- ("ANSI_1252", "ANSI_949", "UTF-8", ...) from LibreDWG's own table * (dwg_codepage_dxfstr in src/codepages.h, which is not a public header), or diff --git a/crates/uncad/src/dynapi.rs b/crates/uncad/src/dynapi.rs index e6c517f..489f3c4 100644 --- a/crates/uncad/src/dynapi.rs +++ b/crates/uncad/src/dynapi.rs @@ -470,6 +470,78 @@ pub fn get_text_bytes(entity: *mut c_void, dxfname: &str, field: &str) -> Option Some(owned) } +/// Reads a plain-old-data header variable (`INSUNITS`, `EXTMIN`, +/// `DIMSCALE`, ...) via `dwg_dynapi_header_value`, with the same up-front +/// size check as [`get_field`] (through `dwg_dynapi_header_field`). Returns +/// `None` for an unknown variable name or a `T` of the wrong size -- never +/// for a variable the file did not state, which reads as whatever the +/// reader left there (see `crate::header`). +pub fn get_header_field(dwg: *const libredwg_sys::Dwg_Data, name: &str) -> Option { + if dwg.is_null() { + return None; + } + let c_name = CString::new(name).expect("variable name has no interior NUL"); + // SAFETY: pure name -> descriptor lookup, no write through any pointer. + let field_desc = unsafe { libredwg_sys::dwg_dynapi_header_field(c_name.as_ptr()) }; + if field_desc.is_null() { + return None; + } + if !field_write_size_matches::(unsafe { &*field_desc }, "
", name) { + return None; + } + let mut out = MaybeUninit::::uninit(); + let mut fp: libredwg_sys::Dwg_DYNAPI_field = Default::default(); + // SAFETY: dwg is live (caller contract); out is sized for T and the size + // check above confirms dynapi writes exactly size_of::() bytes. + let ok = unsafe { + libredwg_sys::dwg_dynapi_header_value( + dwg, + c_name.as_ptr(), + out.as_mut_ptr().cast::(), + &mut fp, + ) + }; + if !ok { + return None; + } + // SAFETY: dynapi reported success and wrote size_of::() bytes. + Some(unsafe { out.assume_init() }) +} + +/// Reads a text header variable (`DIMPOST`, ...) as the bytes LibreDWG holds +/// for it, via `dwg_dynapi_header_utf8text` -- the same contract as +/// [`get_text_bytes`]. Returns `None` for an unknown name or a null string. +pub fn get_header_text_bytes(dwg: *const libredwg_sys::Dwg_Data, name: &str) -> Option> { + if dwg.is_null() { + return None; + } + let c_name = CString::new(name).expect("variable name has no interior NUL"); + let mut text_ptr: *mut std::os::raw::c_char = std::ptr::null_mut(); + let mut is_new: std::os::raw::c_int = 0; + // SAFETY: dwg is live (caller contract); text_ptr/is_new are valid + // out-params for the duration of the call. + let ok = unsafe { + libredwg_sys::dwg_dynapi_header_utf8text( + dwg, + c_name.as_ptr(), + &mut text_ptr, + &mut is_new, + std::ptr::null_mut(), + ) + }; + if !ok || text_ptr.is_null() { + return None; + } + // SAFETY: text_ptr is a valid, NUL-terminated C string per dynapi's + // contract (checked non-null above). + let owned = unsafe { CStr::from_ptr(text_ptr) }.to_bytes().to_vec(); + if is_new != 0 { + // SAFETY: as in get_text_bytes -- a malloc'd copy that is ours. + unsafe { libc::free(text_ptr.cast()) }; + } + Some(owned) +} + /// Reads a `(count_field, array_field)` pair -- e.g. LWPOLYLINE's /// `num_points`/`points` -- as an owned `Vec`. The array field is a raw /// pointer into memory LibreDWG itself owns (freed by `dwg_free`, not by diff --git a/crates/uncad/src/header.rs b/crates/uncad/src/header.rs new file mode 100644 index 0000000..e224264 --- /dev/null +++ b/crates/uncad/src/header.rs @@ -0,0 +1,531 @@ +//! The drawing's header variables (`$INSUNITS`, `$EXTMIN`, `$DIMSCALE`, ...) +//! and the file-level facts that give the model's numbers their meaning -- +//! version, code page, format -- as a type of this crate, outside the model. +//! +//! [`uncad_model::CadDatabase`] deliberately carries no header variables: the +//! model is format-shaped and states what the drawing *draws*. What a +//! consumer needs to read those numbers -- the unit a coordinate is in, the +//! extents the file claims, the dimension variables a DIMENSION falls back +//! on -- is here instead, read in the same pass and returned beside the +//! database by [`crate::parse_with_header`] / [`crate::parse_bytes_with_header`]. +//! +//! # Unknown is `None` +//! +//! LibreDWG's header struct holds every variable whether or not the file +//! states it: a variable the file leaves out reads as whatever the reader put +//! there -- zero, or the importer's default -- and cannot be told from a +//! stated one by its value. So a variable is `Some` only when the file is +//! known to state it: +//! +//! - **DWG:** the header section has a fixed layout per version, and every +//! variable in that layout is stated. `$INSUNITS`, `$DIMADEC`, `$DIMFRAC` +//! and `$DIMLUNIT` exist from R2000, `$DIMDEC` and `$DIMAUNIT` from R13, +//! and a pre-R13 header ends after however many variables its own count +//! says (`numheader_vars`), which cuts off `$DIMZIN`/`$DIMRND`, +//! `$DIMPOST`, `$DIMLFAC` and the paper-space extents and limits in the +//! older releases. `$MEASUREMENT` is not in the header section at all but +//! in the optional Template section, and is stated only when that was read. +//! - **ASCII DXF:** exactly the variables its HEADER section names, found by +//! a scan of the file's own text (bounded by that section). +//! - **Binary DXF:** not scanned, so nothing can be told apart from a +//! default and every variable is `None`. +//! +//! What is stated is kept as stated: `$EXTMIN`/`$EXTMAX` may be stale or +//! AutoCAD's `1e20` "never set" sentinel, and are not corrected here. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use uncad_model::{Point2D, Point3D, Ref}; + +use crate::dynapi::{self, RawPoint2D, RawPoint3D}; +use crate::text::{codepage_name, TextDecoder}; +use crate::Format; + +/// The drawing unit a `$INSUNITS` code names, with its conversion to +/// millimetres when the code is a length. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Units { + /// Short name: `"mm"`, `"in"`, `"ft"`, `"m"`, ... -- or `"du"` ("drawing + /// units") for code 0 (unitless) and for a code the DXF reference does + /// not define. + pub name: String, + /// Millimetres per drawing unit; `None` when unitless or unknown. + pub to_mm: Option, +} + +impl Units { + /// The DXF reference's `$INSUNITS` table (codes 0..=24). LibreDWG has no + /// such table of its own -- it only reads and writes the code. + pub fn from_insunits(code: u16) -> Units { + let (name, to_mm): (&str, Option) = match code { + 1 => ("in", Some(25.4)), + 2 => ("ft", Some(304.8)), + 3 => ("mi", Some(1_609_344.0)), + 4 => ("mm", Some(1.0)), + 5 => ("cm", Some(10.0)), + 6 => ("m", Some(1000.0)), + 7 => ("km", Some(1_000_000.0)), + 8 => ("uin", Some(2.54e-5)), + 9 => ("mil", Some(0.0254)), + 10 => ("yd", Some(914.4)), + 11 => ("angstrom", Some(1e-7)), + 12 => ("nm", Some(1e-6)), + 13 => ("um", Some(1e-3)), + 14 => ("dm", Some(100.0)), + 15 => ("dam", Some(10_000.0)), + 16 => ("hm", Some(100_000.0)), + 17 => ("Gm", Some(1e12)), + 18 => ("au", Some(1.495_978_707e14)), + 19 => ("ly", Some(9.460_730_472_580_8e18)), + 20 => ("pc", Some(3.085_677_581_491_367e19)), + // US survey units: 1200/3937 m to the foot. + 21 => ("us-ft", Some(304.800_609_601_219_2)), + 22 => ("us-in", Some(25.400_050_800_101_6)), + 23 => ("us-yd", Some(914.401_828_803_657_7)), + 24 => ("us-mi", Some(1_609_347.218_694_437_2)), + _ => ("du", None), + }; + Units { + name: name.to_string(), + to_mm, + } + } +} + +/// File-level facts and the header variables a consumer needs to give the +/// model's numbers a meaning. Variable fields are named after the DXF +/// `$VARIABLE` in lower case, and are `None` when the file does not state +/// them (see the module doc). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Header { + /// What the bytes were. + pub format: Format, + /// The version code as the file states it: the magic of a DWG + /// (`"AC1015"`), `$ACADVER` of a DXF. `None` for a DXF without + /// `$ACADVER` (pre-R10) or a binary one. + pub acadver: Option, + /// LibreDWG's release name for that version (`"r2000"`, `"r2018"`, ...); + /// `None` exactly when [`acadver`](Self::acadver) is, or when LibreDWG + /// has no name for it. + pub version: Option, + /// The codepage this read decoded the drawing's 8-bit strings with, as + /// LibreDWG's `Dwg_Codepage` number (30 `ANSI_1252`, 40 `ANSI_949`, ...): + /// the DWG file header's (R13 and later), a DXF's `$DWGCODEPAGE`, and + /// LibreDWG's own default where a file names none -- which is why it is + /// a fact of the read, not a variable. An R2007+ drawing's strings are + /// Unicode and do not depend on it. + pub codepage: u16, + /// LibreDWG's name for [`codepage`](Self::codepage) (`"ANSI_1252"`); + /// `None` for a value it has no table for -- such a codepage is reported + /// in `read_diagnostics` and its strings read as UTF-8. + pub codepage_name: Option, + /// `$INSUNITS`: the drawing unit, as its code; see [`Header::units`]. + pub insunits: Option, + /// `$MEASUREMENT`: 0 English, 1 metric -- the default for dimensioning + /// and hatch patterns, not the drawing unit. + pub measurement: Option, + /// `$LUNITS`: 1 scientific, 2 decimal, 3 engineering, 4 architectural, + /// 5 fractional. + pub lunits: Option, + /// `$LUPREC`: decimal places (or fraction precision) of linear units. + pub luprec: Option, + /// `$AUNITS`: 0 degrees, 1 deg/min/sec, 2 grads, 3 radians, 4 surveyor. + pub aunits: Option, + /// `$AUPREC`: decimal places of angular units. + pub auprec: Option, + /// `$EXTMIN`/`$EXTMAX`: the model-space extents the file stores. May be + /// stale, or AutoCAD's `+/-1e20` sentinel for "never set". + pub extmin: Option, + pub extmax: Option, + /// `$LIMMIN`/`$LIMMAX`: the model-space drawing limits. + pub limmin: Option, + pub limmax: Option, + /// `$PEXTMIN`/`$PEXTMAX`/`$PLIMMIN`/`$PLIMMAX`: the same for paper space. + pub pextmin: Option, + pub pextmax: Option, + pub plimmin: Option, + pub plimmax: Option, + /// `$DIMSCALE`: overall dimension scale of the current dimension style. + pub dimscale: Option, + /// `$DIMLFAC`: linear measurement factor of the current dimension style. + pub dimlfac: Option, + /// `$DIMDEC`: decimal places of a linear dimension. + pub dimdec: Option, + /// `$DIMLUNIT`: linear unit format of a dimension (as `$LUNITS`, plus 6 + /// Windows desktop). + pub dimlunit: Option, + /// `$DIMPOST`: prefix/suffix of a dimension's text (`"<> mm"`, where + /// `<>` stands for the measurement); `Some("")` when stated empty. + pub dimpost: Option, + /// `$DIMRND`: rounding of a linear dimension. + pub dimrnd: Option, + /// `$DIMZIN`: zero suppression of a linear dimension. + pub dimzin: Option, + /// `$DIMFRAC`: fraction format of a dimension. + pub dimfrac: Option, + /// `$DIMAUNIT`: angle format of an angular dimension. + pub dimaunit: Option, + /// `$DIMADEC`: decimal places of an angular dimension. + pub dimadec: Option, + /// `$DIMTXT`: dimension text height. + pub dimtxt: Option, + /// `$DIMASZ`: dimension arrow size. + pub dimasz: Option, + /// `$LTSCALE`: global linetype scale. + pub ltscale: Option, + /// `$TEXTSIZE`: default text height. + pub textsize: Option, + /// `$CLAYER`: the current layer. `Absent` when not stated; `Unresolved` + /// (the handle, hex) when it names a layer the table does not have. + pub clayer: Ref, +} + +impl Header { + /// `$INSUNITS` as a unit name and a millimetre factor; `None` when the + /// file does not state `$INSUNITS`. + pub fn units(&self) -> Option { + self.insunits.map(Units::from_insunits) + } +} + +/// What the scan of an ASCII DXF's HEADER section found. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct DxfHeaderScan { + /// `$ACADVER`'s value, trimmed. + pub acadver: Option, + /// Every variable the section names, without the `$`. + pub variables: BTreeSet, +} + +/// Reads the variable names out of an ASCII DXF's HEADER section, as +/// (group code, value) line pairs, up to the section's `ENDSEC`. A file +/// whose first section is not HEADER states no variables. `None` for a +/// binary DXF, which is not scanned. +pub(crate) fn scan_dxf_header(bytes: &[u8]) -> Option { + if bytes.starts_with(b"AutoCAD Binary DXF") { + return None; + } + fn trim(line: &[u8]) -> &[u8] { + line.trim_ascii() + } + let mut scan = DxfHeaderScan::default(); + let mut lines = bytes.split(|&b| b == b'\n').map(trim); + let mut in_header = false; + let mut current: Option> = None; + while let (Some(code), Some(value)) = (lines.next(), lines.next()) { + match (code, value) { + (b"0", b"SECTION") => continue, + (b"0", b"ENDSEC") | (b"0", b"EOF") => break, + (b"2", b"HEADER") if !in_header => in_header = true, + // The first section is some other one: no HEADER to read. + (b"2", _) if !in_header => break, + (b"9", name) if in_header => { + let name = name.strip_prefix(b"$").unwrap_or(name); + scan.variables + .insert(String::from_utf8_lossy(name).into_owned()); + current = Some(name.to_vec()); + } + (b"1", value) if in_header && current.as_deref() == Some(b"ACADVER") => { + scan.acadver = Some(String::from_utf8_lossy(value).into_owned()); + } + _ => {} + } + } + Some(scan) +} + +/// Which header variables the file states -- see the module doc. +pub(crate) enum Stated { + /// A DWG, by its version's header layout. + Dwg { + version: i32, + numheader_vars: u16, + template_read: bool, + }, + /// An ASCII DXF, by the variables its HEADER section names. + Dxf(BTreeSet), + /// A binary DXF: nothing known. + Unknown, +} + +impl Stated { + fn has(&self, name: &str) -> bool { + match self { + Stated::Dxf(variables) => variables.contains(name), + Stated::Unknown => false, + Stated::Dwg { + version, + numheader_vars, + template_read, + } => dwg_layout_has(name, *version, *numheader_vars, *template_read), + } + } +} + +/// Whether a DWG of `version` carries `name` in its header, per LibreDWG's +/// own layout (`header_variables.spec`, `header_variables_r11.spec`) for the +/// variables this module reads. +#[allow(clippy::unnecessary_cast)] // the enum's width differs by target +fn dwg_layout_has(name: &str, version: i32, numheader_vars: u16, template_read: bool) -> bool { + use libredwg_sys as sys; + let at_least = |v: sys::Dwg_Version_Type| version >= v as i32; + if name == "MEASUREMENT" { + // In the Template section, which is optional before R2007 and which + // the decoder skips silently when it is missing. + return template_read; + } + if at_least(sys::DWG_VERSION_TYPE_R_13b1) { + return match name { + "INSUNITS" | "DIMADEC" | "DIMFRAC" | "DIMLUNIT" => { + at_least(sys::DWG_VERSION_TYPE_R_2000b) + } + _ => true, + }; + } + // Before R13 the header is one run of variables whose length the file + // header states; the early releases stop sooner still. + let r1_3 = version == sys::DWG_VERSION_TYPE_R_1_3 as i32; + let r2_0 = at_least(sys::DWG_VERSION_TYPE_R_2_0); + match name { + "EXTMIN" | "EXTMAX" | "LIMMIN" | "LIMMAX" | "TEXTSIZE" | "CLAYER" => true, + "LUNITS" | "LUPREC" => r1_3 || at_least(sys::DWG_VERSION_TYPE_R_1_4), + "AUNITS" | "AUPREC" | "DIMSCALE" | "DIMASZ" | "DIMTXT" => { + r1_3 || at_least(sys::DWG_VERSION_TYPE_R_2_0b) + } + "LTSCALE" => r2_0, + "DIMZIN" | "DIMRND" => r2_0 && numheader_vars > 83, + "DIMPOST" => r2_0 && numheader_vars > 114, + "DIMLFAC" => r2_0 && numheader_vars > 120, + "PEXTMIN" | "PEXTMAX" | "PLIMMIN" | "PLIMMAX" => r2_0 && numheader_vars > 160, + // INSUNITS, DIMDEC, DIMAUNIT, DIMADEC, DIMFRAC, DIMLUNIT: R13 and later. + _ => false, + } +} + +/// The version code a DWG's first six bytes spell (`AC1015`). +pub(crate) fn dwg_magic(bytes: &[u8]) -> Option { + let magic = bytes.get(..6)?; + magic + .iter() + .all(|b| b.is_ascii_graphic()) + .then(|| String::from_utf8_lossy(magic).into_owned()) +} + +/// LibreDWG's release name of a `Dwg_Version_Type` (`r2000`), or `None` for +/// `R_INVALID`. +pub(crate) fn release_name(version: i32) -> Option { + #[allow(clippy::unnecessary_cast)] + if version <= libredwg_sys::DWG_VERSION_TYPE_R_INVALID as i32 + || version >= libredwg_sys::DWG_VERSION_TYPE_R_AFTER as i32 + { + return None; + } + // SAFETY: an in-range enum value; the function returns a static string + // from LibreDWG's version table. + let ptr = unsafe { libredwg_sys::dwg_version_type(version as libredwg_sys::Dwg_Version_Type) }; + if ptr.is_null() { + return None; + } + let name = unsafe { std::ffi::CStr::from_ptr(ptr) }.to_string_lossy(); + Some(name.into_owned()) +} + +/// Reads the header out of a live `Dwg_Data`. `acadver` is the version code +/// the file states (see [`Header::acadver`]), `stated` which variables it +/// states. +/// +/// # Safety +/// `dwg` must point at a `Dwg_Data` a successful read filled in and that +/// has not been freed; `text` must be the decoder made for it. +pub(crate) unsafe fn read_header( + dwg: *mut libredwg_sys::Dwg_Data, + text: &TextDecoder, + format: Format, + acadver: Option, + stated: &Stated, +) -> Header { + // SAFETY: the shim reads one header field of a live Dwg_Data. + let from_version = unsafe { libredwg_sys::uncad_dwg_from_version(dwg) }; + let version = acadver.as_ref().and_then(|_| release_name(from_version)); + let codepage = text.codepage(); + + let u16_var = |name: &str| { + stated + .has(name) + .then(|| dynapi::get_header_field::(dwg, name)) + .flatten() + }; + let f64_var = |name: &str| { + stated + .has(name) + .then(|| dynapi::get_header_field::(dwg, name)) + .flatten() + }; + let p3_var = |name: &str| { + stated + .has(name) + .then(|| dynapi::get_header_field::(dwg, name).map(Point3D::from)) + .flatten() + }; + let p2_var = |name: &str| { + stated + .has(name) + .then(|| dynapi::get_header_field::(dwg, name).map(Point2D::from)) + .flatten() + }; + let dimpost = stated + .has("DIMPOST") + .then(|| text.header_text(dwg, "DIMPOST").unwrap_or_default()); + let clayer = if stated.has("CLAYER") { + let handle = dynapi::get_header_field::<*mut libredwg_sys::Dwg_Object_Ref>(dwg, "CLAYER"); + layer_reference(dwg, text, handle.unwrap_or(std::ptr::null_mut())) + } else { + Ref::Absent + }; + + Header { + format, + acadver, + version, + codepage, + codepage_name: codepage_name(codepage), + insunits: u16_var("INSUNITS"), + measurement: u16_var("MEASUREMENT"), + lunits: u16_var("LUNITS"), + luprec: u16_var("LUPREC"), + aunits: u16_var("AUNITS"), + auprec: u16_var("AUPREC"), + extmin: p3_var("EXTMIN"), + extmax: p3_var("EXTMAX"), + limmin: p2_var("LIMMIN"), + limmax: p2_var("LIMMAX"), + pextmin: p3_var("PEXTMIN"), + pextmax: p3_var("PEXTMAX"), + plimmin: p2_var("PLIMMIN"), + plimmax: p2_var("PLIMMAX"), + dimscale: f64_var("DIMSCALE"), + dimlfac: f64_var("DIMLFAC"), + dimdec: u16_var("DIMDEC"), + dimlunit: u16_var("DIMLUNIT"), + dimpost, + dimrnd: f64_var("DIMRND"), + dimzin: u16_var("DIMZIN"), + dimfrac: u16_var("DIMFRAC"), + dimaunit: u16_var("DIMAUNIT"), + dimadec: u16_var("DIMADEC"), + dimtxt: f64_var("DIMTXT"), + dimasz: f64_var("DIMASZ"), + ltscale: f64_var("LTSCALE"), + textsize: f64_var("TEXTSIZE"), + clayer, + } +} + +/// `$CLAYER` as a reference: the layer's name when the table resolves it +/// (by handle, or by index before R13), the handle when it does not, and +/// `Absent` for a null one -- the same three states the model gives an +/// entity's layer. +fn layer_reference( + dwg: *mut libredwg_sys::Dwg_Data, + text: &TextDecoder, + handle: *mut libredwg_sys::Dwg_Object_Ref, +) -> Ref { + if handle.is_null() { + return Ref::Absent; + } + if let Some(name) = text.table_entry_name(dwg, handle, c"LAYER") { + return Ref::Resolved(name); + } + // SAFETY: a non-null Dwg_Object_Ref the live Dwg_Data owns. + let (absolute_ref, r11_idx) = unsafe { ((*handle).absolute_ref, (*handle).r11_idx) }; + if dynapi::is_pre_r13(dwg) { + return Ref::Unresolved(format!("idx:{r11_idx}")); + } + if absolute_ref != 0 { + return Ref::Unresolved(format!("{absolute_ref:X}")); + } + Ref::Absent +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insunits_table_names_the_common_units() { + assert_eq!( + Units::from_insunits(4), + Units { + name: "mm".into(), + to_mm: Some(1.0) + } + ); + assert_eq!( + Units::from_insunits(1), + Units { + name: "in".into(), + to_mm: Some(25.4) + } + ); + assert_eq!(Units::from_insunits(6).to_mm, Some(1000.0)); + assert_eq!(Units::from_insunits(2).to_mm, Some(304.8)); + } + + #[test] + fn insunits_zero_and_unknown_codes_are_unitless() { + for code in [0, 25, 99, u16::MAX] { + let units = Units::from_insunits(code); + assert_eq!(units.name, "du", "code {code}"); + assert_eq!(units.to_mm, None, "code {code}"); + } + } + + #[test] + fn the_scan_reads_the_header_section_only() { + let dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015 \r\n 9\r\n$INSUNITS\r\n 70\r\n 4\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 9\r\n$DIMPOST\r\n 0\r\nENDSEC\r\n"; + let scan = scan_dxf_header(dxf).expect("ASCII DXF"); + assert_eq!(scan.acadver.as_deref(), Some("AC1015")); + assert_eq!( + scan.variables + .iter() + .map(String::as_str) + .collect::>(), + ["ACADVER", "INSUNITS"] + ); + // No HEADER section: nothing stated. + let scan = scan_dxf_header(b" 0\nSECTION\n 2\nENTITIES\n 9\n$ACADVER\n 1\nAC1015\n") + .expect("ASCII DXF"); + assert_eq!(scan, DxfHeaderScan::default()); + assert_eq!(scan_dxf_header(b"AutoCAD Binary DXF\r\n\x1a\0"), None); + } + + #[test] + #[allow(clippy::unnecessary_cast)] // the enum's width differs by target + fn a_dwg_states_what_its_versions_header_layout_carries() { + use libredwg_sys as sys; + let (r14, r2000) = ( + sys::DWG_VERSION_TYPE_R_14 as i32, + sys::DWG_VERSION_TYPE_R_2000 as i32, + ); + for name in ["INSUNITS", "DIMADEC", "DIMFRAC", "DIMLUNIT"] { + assert!(!dwg_layout_has(name, r14, 0, true), "{name}"); + assert!(dwg_layout_has(name, r2000, 0, true), "{name}"); + } + assert!(dwg_layout_has("DIMDEC", r14, 0, false)); + assert!(!dwg_layout_has("MEASUREMENT", r2000, 0, false)); + assert!(dwg_layout_has("MEASUREMENT", r2000, 0, true)); + // R11 (numheader_vars 204/205) carries DIMLFAC and the paper-space + // extents; an R2.10 header (83 variables) stops before DIMZIN. + let (r11, r2_10) = ( + sys::DWG_VERSION_TYPE_R_11 as i32, + sys::DWG_VERSION_TYPE_R_2_10 as i32, + ); + assert!(dwg_layout_has("DIMLFAC", r11, 205, false)); + assert!(dwg_layout_has("PEXTMIN", r11, 205, false)); + assert!(!dwg_layout_has("INSUNITS", r11, 205, false)); + assert!(dwg_layout_has("EXTMIN", r2_10, 83, false)); + assert!(!dwg_layout_has("DIMZIN", r2_10, 83, false)); + } +} diff --git a/crates/uncad/src/lib.rs b/crates/uncad/src/lib.rs index 72849c8..cbcd93d 100644 --- a/crates/uncad/src/lib.rs +++ b/crates/uncad/src/lib.rs @@ -10,6 +10,7 @@ mod acis; mod convert; mod dynapi; +pub mod header; mod table_convert; mod text; @@ -22,6 +23,8 @@ use std::sync::Mutex; pub use uncad_model::{json, model, tables}; pub use uncad_model::{CadDatabase, Entity, JsonError, ReadDiagnostics, Tables, ToJsonOptions}; +pub use header::{Header, Units}; + /// LibreDWG's C code has non-reentrant global state (the `loglevel` global /// read and written throughout `decode.c`/`bits.c`, and likely more). /// Calling into it concurrently reliably produced `STATUS_HEAP_CORRUPTION` @@ -29,10 +32,10 @@ pub use uncad_model::{CadDatabase, Entity, JsonError, ReadDiagnostics, Tables, T /// cycles overlapped. Sequential reuse across many calls is fine; concurrent /// calls are what this lock closes off. /// -/// [`parse_bytes`] (which [`parse`] calls) is the only entry point that -/// touches the FFI boundary and it holds this lock for its whole duration, so -/// callers never need to know `libredwg-sys` exists, let alone serialize -/// around it themselves. +/// [`parse_bytes_with_header`] (which every other entry point calls) is the +/// only one that touches the FFI boundary and it holds this lock for its +/// whole duration, so callers never need to know `libredwg-sys` exists, let +/// alone serialize around it themselves. static LIBREDWG_LOCK: Mutex<()> = Mutex::new(()); /// Names for the non-critical `DWG_ERROR` bits, in bit order (dwg.h). @@ -111,7 +114,8 @@ impl std::error::Error for ParseError { /// extension; [`parse_bytes`] takes it explicitly because bytes carry no /// name. A binary DXF is [`Format::Dxf`] too: the reader tells ASCII from /// binary by the bytes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Format { Dwg, Dxf, @@ -177,7 +181,8 @@ fn missing_required_groups(entities: &[Entity]) -> Vec { /// Parses a DWG or DXF file at `path` into a [`CadDatabase`] -- the same /// model either way, with the format inferred from the `.dwg`/`.dxf` -/// extension (see [`Format::from_path`]). +/// extension (see [`Format::from_path`]). [`parse_with_header`] returns the +/// drawing's header variables beside it. /// /// The file is read into memory here and decoded by [`parse_bytes`] rather /// than handed to LibreDWG as a path: LibreDWG opens paths with `fopen()`, @@ -192,25 +197,49 @@ fn missing_required_groups(entities: &[Entity]) -> Vec { /// rather than handed to LibreDWG, because the importer would return it as a /// drawing with no entities and no error. See `docs/CAVEATS.md`. pub fn parse(path: impl AsRef) -> Result { + parse_with_header(path).map(|(db, _)| db) +} + +/// [`parse`], returning the drawing's [`Header`] -- version, codepage, +/// units, extents, the dimension variables -- beside the database. +/// +/// The header is a type of this crate rather than part of the model, which +/// deliberately carries no header variables (see [`header`]); it is read in +/// the same pass, under the same lock, from the same decoded drawing, so +/// asking for it costs no second decode -- which a separate header read +/// would, and a DXF decode is the expensive part. +pub fn parse_with_header(path: impl AsRef) -> Result<(CadDatabase, Header), ParseError> { let path = path.as_ref(); let format = Format::from_path(path); let bytes = std::fs::read(path).map_err(ParseError::Io)?; - parse_bytes(&bytes, format) + parse_bytes_with_header(&bytes, format) } /// Parses a drawing already held in memory. This is what [`parse`] calls /// after reading the file; it is public for callers that receive drawings /// over the network or from an archive and never have a path. pub fn parse_bytes(bytes: &[u8], format: Format) -> Result { + parse_bytes_with_header(bytes, format).map(|(db, _)| db) +} + +/// [`parse_bytes`], returning the drawing's [`Header`] beside the database +/// -- see [`parse_with_header`]. +pub fn parse_bytes_with_header( + bytes: &[u8], + format: Format, +) -> Result<(CadDatabase, Header), ParseError> { + // A DXF's HEADER section, scanned from the bytes: which variables it + // states, and its $ACADVER. No lock needed -- this is plain text. + let scan = match format { + Format::Dwg => None, + Format::Dxf => header::scan_dxf_header(bytes), + }; // Decided from the file's own header, before LibreDWG sees it: the R2007+ // failure is silent on the C side (error code 0, zero entities), so the - // only place it can be turned into an error is here. No lock needed -- - // this is a scan of the bytes. - if format == Format::Dxf { - if let Some(acadver) = dxf_acadver(bytes) { - if dxf_version_number(&acadver).is_some_and(|n| n >= DXF_VERSION_R2007) { - return Err(ParseError::UnsupportedDxfVersion(acadver)); - } + // only place it can be turned into an error is here. + if let Some(acadver) = scan.as_ref().and_then(|scan| scan.acadver.as_ref()) { + if dxf_version_number(acadver).is_some_and(|n| n >= DXF_VERSION_R2007) { + return Err(ParseError::UnsupportedDxfVersion(acadver.clone())); } } @@ -271,6 +300,20 @@ pub fn parse_bytes(bytes: &[u8], format: Format) -> Result ( + header::dwg_magic(bytes), + // SAFETY: the shims read file-header fields of a live Dwg_Data. + header::Stated::Dwg { + version: unsafe { libredwg_sys::uncad_dwg_from_version(dwg.as_mut()) }, + numheader_vars: unsafe { libredwg_sys::uncad_dwg_numheader_vars(dwg.as_mut()) }, + template_read: unsafe { libredwg_sys::uncad_dwg_template_read(dwg.as_mut()) } != 0, + }, + ), + (Format::Dxf, Some(scan)) => (scan.acadver, header::Stated::Dxf(scan.variables)), + (Format::Dxf, None) => (None, header::Stated::Unknown), + }; + let header = unsafe { header::read_header(dwg.as_mut(), &text, format, acadver, &stated) }; read_diagnostics.warnings.extend(text.into_warnings()); read_diagnostics .warnings @@ -282,11 +325,14 @@ pub fn parse_bytes(bytes: &[u8], format: Format) -> Result Option { acadver.strip_prefix("AC")?.parse().ok() } -/// Reads `$ACADVER` out of an ASCII DXF's HEADER section, or `None` if the -/// file has no such variable (pre-R10 files) or is not laid out as (group -/// code, value) line pairs. Binary DXF is not handled here and falls through -/// to LibreDWG untouched. -/// -/// The scan is bounded: `$ACADVER` is by convention the first header variable, -/// and the HEADER section ends at the first `ENDSEC`, so a file that reaches -/// either bound without it is treated as not declaring a version. -fn dxf_acadver(bytes: &[u8]) -> Option { - const MAX_PAIRS_SCANNED: usize = 4096; - - let mut lines = bytes - .split(|&b| b == b'\n') - .map(|line| String::from_utf8_lossy(line).into_owned()); - for _ in 0..MAX_PAIRS_SCANNED { - let (code, value) = (lines.next()?, lines.next()?); - match (code.trim(), value.trim()) { - ("9", "$ACADVER") => { - let (code, value) = (lines.next()?, lines.next()?); - return (code.trim() == "1").then(|| value.trim().to_string()); - } - // End of the HEADER section (or of a headerless file's first - // section): $ACADVER cannot appear after this point. - ("0", "ENDSEC") | ("0", "EOF") => return None, - _ => {} - } - } - None -} - #[cfg(test)] mod missing_required_groups_tests { use super::missing_required_groups; diff --git a/crates/uncad/src/text.rs b/crates/uncad/src/text.rs index a9ef976..212ed4f 100644 --- a/crates/uncad/src/text.rs +++ b/crates/uncad/src/text.rs @@ -33,19 +33,26 @@ fn has_codepage_tables(codepage: u16) -> bool { (1..=CP_LAST).contains(&codepage) && codepage != CP_UTF16 } -/// The `$DWGCODEPAGE`-style name of a codepage, for a diagnostic. -fn codepage_name(codepage: u16) -> String { - // SAFETY: dwg_codepage_dxfstr returns a static string for every value, - // "undefined"/"" for the ones it does not know. +/// LibreDWG's `$DWGCODEPAGE`-style name of a codepage (`ANSI_1252`), or +/// `None` when it has none -- a value outside its table, `CP_UNDEFINED`. +pub fn codepage_name(codepage: u16) -> Option { + // SAFETY: dwg_codepage_dxfstr returns a static string or NULL for every + // value; it bounds-checks the number itself. let ptr = unsafe { libredwg_sys::dwg_codepage_dxfstr(codepage as libredwg_sys::Dwg_Codepage) }; if ptr.is_null() { - return format!("{codepage}"); + return None; } let name = unsafe { CStr::from_ptr(ptr) }.to_string_lossy(); - if name.is_empty() { - format!("{codepage}") - } else { - format!("{name} ({codepage})") + (!name.is_empty() && name != "undefined").then(|| name.into_owned()) +} + +/// The name a diagnostic gives a codepage: `ANSI_1252 (30)`, or the bare +/// number when the library has no name for it. +fn codepage_label(codepage: u16) -> String { + match codepage_name(codepage) { + Some(name) => format!("{name} ({codepage})"), + None if codepage == 0xFF => format!("undefined ({codepage})"), + None => format!("{codepage}"), } } @@ -146,6 +153,11 @@ impl TextDecoder { } } + /// The codepage 8-bit strings are decoded with (`header.codepage`). + pub fn codepage(&self) -> u16 { + self.codepage + } + /// The warnings, in the order the strings were met, each once -- the /// entities of a block are converted twice (once for the entity list, /// once for the block record), and a warning is about a string, not @@ -209,6 +221,13 @@ impl TextDecoder { })) } + /// A text header variable (`DIMPOST`, ...), decoded -- see + /// `dynapi::get_header_text_bytes`. + pub fn header_text(&self, dwg: *const libredwg_sys::Dwg_Data, name: &str) -> Option { + let bytes = dynapi::get_header_text_bytes(dwg, name)?; + Some(self.decode(&bytes, || format!("header variable ${name}"))) + } + /// The decoding rule. `what` names the string for a diagnostic and is /// only evaluated when one is written. pub fn decode(&self, bytes: &[u8], what: impl FnOnce() -> String) -> String { @@ -242,7 +261,7 @@ impl TextDecoder { "TEXT_ENCODING: {}: codepage {} cannot be decoded and the bytes are \ not UTF-8; invalid sequences replaced with U+FFFD", what(), - codepage_name(self.codepage) + codepage_label(self.codepage) )); String::from_utf8_lossy(bytes).into_owned() } @@ -254,7 +273,7 @@ impl TextDecoder { "TEXT_ENCODING: {}: {unmapped} byte(s) have no character in codepage {}; \ replaced with U+FFFD", what(), - codepage_name(self.codepage) + codepage_label(self.codepage) )); } text @@ -360,6 +379,14 @@ mod tests { assert!(warnings[0].contains("undefined"), "{}", warnings[0]); } + #[test] + fn codepage_names_are_the_librarys_and_unknown_ones_have_none() { + assert_eq!(codepage_name(ANSI_1252).as_deref(), Some("ANSI_1252")); + assert_eq!(codepage_name(ANSI_949).as_deref(), Some("ANSI_949")); + assert_eq!(codepage_name(100), None); + assert_eq!(codepage_name(0xFF), None); + } + #[test] fn a_wide_string_drawing_is_utf8_whatever_its_codepage_says() { let d = decoder(ANSI_949, true, false); diff --git a/crates/uncad/tests/header.rs b/crates/uncad/tests/header.rs new file mode 100644 index 0000000..236eab1 --- /dev/null +++ b/crates/uncad/tests/header.rs @@ -0,0 +1,281 @@ +//! The drawing's `Header` on real corpus files: version, code page, units +//! and the header variables a consumer needs to read the model's numbers. +//! The expected values are the ones the files state (cross-checked against +//! the DXF twin's `$` variables where one exists), so the drawing is its own +//! reference; nothing here is a number copied from this crate's output. +//! And what a file does not state is `None`, never the value LibreDWG's +//! struct happens to hold for it. + +use uncad::model::{Point2D, Ref}; +use uncad::{Format, Header, Units}; + +const EXAMPLE_2000_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dwg" +); +const EXAMPLE_2000_DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dxf" +); +const EXAMPLE_R14_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_r14.dwg" +); +const GH109_2013_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/2013/gh109_1.dwg" +); +const TEST_DATA: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/" +); + +fn header_of(path: &str) -> Header { + uncad::parse_with_header(path) + .unwrap_or_else(|e| panic!("{path} must parse: {e}")) + .1 +} + +fn close(a: Option, b: f64) -> bool { + a.is_some_and(|a| (a - b).abs() < 1e-6) +} + +#[test] +fn example_2000_dwg_header_reads_the_dxf_variables() { + let h = header_of(EXAMPLE_2000_DWG); + + assert_eq!(h.format, Format::Dwg); + assert_eq!(h.acadver.as_deref(), Some("AC1015")); + assert_eq!(h.version.as_deref(), Some("r2000")); + assert_eq!( + (h.codepage, h.codepage_name.as_deref()), + (30, Some("ANSI_1252")) + ); + + // $INSUNITS 4 = millimetres, $MEASUREMENT 1 = metric. + assert_eq!(h.insunits, Some(4)); + assert_eq!( + h.units(), + Some(Units { + name: "mm".into(), + to_mm: Some(1.0) + }) + ); + assert_eq!(h.measurement, Some(1)); + // $LUNITS 2 decimal, $DIMDEC 2, $DIMLFAC 1.0 -- what the file declares. + assert_eq!((h.lunits, h.dimdec), (Some(2), Some(2))); + assert!(close(h.dimlfac, 1.0)); + // $LIMMAX is the A3 sheet in mm. + assert_eq!(h.limmax, Some(Point2D { x: 420.0, y: 297.0 })); + // The stored model extents include a 3256x-scaled INSERT far off the + // sheet: kept verbatim, not "corrected". + let (extmin, extmax) = (h.extmin.expect("stated"), h.extmax.expect("stated")); + assert!( + extmin.x < -2_000_000.0 && extmax.x > 800_000.0, + "{extmin:?}..{extmax:?}" + ); + // Paper-space extents were never set: AutoCAD's +/-1e20 sentinel, verbatim. + let (pextmin, pextmax) = (h.pextmin.expect("stated"), h.pextmax.expect("stated")); + assert!( + pextmin.x > 1e19 && pextmax.x < -1e19, + "{pextmin:?}..{pextmax:?}" + ); + assert_eq!(h.clayer, Ref::Resolved("Tavolo 3".to_string())); +} + +#[test] +fn the_dxf_twin_declares_the_same_units_and_dimension_variables() { + let (a, b) = (header_of(EXAMPLE_2000_DWG), header_of(EXAMPLE_2000_DXF)); + + assert_eq!(b.format, Format::Dxf); + assert_eq!((&a.acadver, &a.version), (&b.acadver, &b.version)); + assert_eq!(a.insunits, b.insunits); + assert_eq!(a.measurement, b.measurement); + assert_eq!( + (a.lunits, a.luprec, a.aunits, a.auprec), + (b.lunits, b.luprec, b.aunits, b.auprec) + ); + assert_eq!( + (a.dimdec, a.dimlunit, a.dimzin, a.dimfrac, a.dimaunit, a.dimadec), + (b.dimdec, b.dimlunit, b.dimzin, b.dimfrac, b.dimaunit, b.dimadec) + ); + for (dwg, dxf, name) in [ + (a.dimlfac, b.dimlfac, "DIMLFAC"), + (a.dimscale, b.dimscale, "DIMSCALE"), + (a.dimtxt, b.dimtxt, "DIMTXT"), + (a.dimasz, b.dimasz, "DIMASZ"), + (a.ltscale, b.ltscale, "LTSCALE"), + (a.textsize, b.textsize, "TEXTSIZE"), + ] { + assert!( + close(dxf, dwg.expect("stated")), + "{name}: {dwg:?} vs {dxf:?}" + ); + } + assert_eq!(a.limmax, b.limmax); + assert_eq!(a.dimpost, b.dimpost); + assert_eq!(a.clayer, b.clayer); + // A DXF names its code page in $DWGCODEPAGE, which the reader records. + assert_eq!(b.codepage_name.as_deref(), Some("ANSI_1252")); +} + +#[test] +fn an_r14_dwg_states_no_variable_its_header_does_not_carry() { + // $INSUNITS, $DIMADEC, $DIMFRAC and $DIMLUNIT entered the DWG header in + // R2000: an R14 file cannot state them, whatever LibreDWG's struct holds. + let h = header_of(EXAMPLE_R14_DWG); + assert_eq!(h.version.as_deref(), Some("r14")); + assert_eq!( + (h.insunits, h.dimadec, h.dimfrac, h.dimlunit), + (None, None, None, None) + ); + assert_eq!(h.units(), None); + // What an R14 header does carry is stated. + assert!(h.lunits.is_some() && h.dimdec.is_some() && h.dimscale.is_some()); + assert!(h.extmin.is_some() && h.limmax.is_some()); + assert!(matches!(h.clayer, Ref::Resolved(_)), "{:?}", h.clayer); +} + +#[test] +fn a_pre_r13_dwg_states_what_its_header_count_covers() { + // A pre-R13 header is one run of variables, as long as the file header + // says (header.spec: AC2.10 holds 83, AC1009 204/205). R2.10 stops + // before $DIMZIN, $DIMPOST and $DIMLFAC; R11 carries them and the + // paper-space extents. R1.4 stops sooner still, after $LUPREC. + let r2_10 = header_of(&format!("{TEST_DATA}r2.10/entities.dwg")); + assert_eq!(r2_10.version.as_deref(), Some("r2.10")); + assert!(r2_10.dimscale.is_some() && r2_10.dimtxt.is_some()); + assert_eq!((r2_10.dimzin, r2_10.dimlfac), (None, None)); + assert_eq!((r2_10.dimpost, r2_10.pextmin), (None, None)); + + let r11 = header_of(&format!("{TEST_DATA}r11/entities-2d.dwg")); + assert_eq!(r11.version.as_deref(), Some("r11")); + assert!(close(r11.dimlfac, 1.0)); + assert_eq!(r11.dimpost.as_deref(), Some("")); + assert!(r11.pextmin.is_some()); + // Never in a pre-R13 header, whatever its length. + assert_eq!( + (r11.insunits, r11.dimdec, r11.measurement), + (None, None, None) + ); + + let r1_4 = header_of(&format!("{TEST_DATA}r1.4/entities.dwg")); + assert_eq!(r1_4.version.as_deref(), Some("r1.4")); + assert!(r1_4.lunits.is_some() && r1_4.limmax.is_some()); + assert_eq!( + (r1_4.aunits, r1_4.dimscale, r1_4.ltscale), + (None, None, None) + ); +} + +#[test] +fn a_chinese_r2013_file_reports_its_code_page_and_unitless_units() { + let (db, h) = uncad::parse_with_header(GH109_2013_DWG).expect("corpus file must parse"); + assert_eq!(h.version.as_deref(), Some("r2013")); + assert_eq!( + (h.codepage, h.codepage_name.as_deref()), + (39, Some("ANSI_936")) + ); + // $INSUNITS 0 is stated -- "unitless" -- which is not the same as absent. + assert_eq!(h.insunits, Some(0)); + assert_eq!( + h.units(), + Some(Units { + name: "du".into(), + to_mm: None + }) + ); + // R2007+ strings arrive as UTF-8 from LibreDWG itself; the code page is + // recorded but not applied. The file's layer table has a Chinese name. + assert!( + db.tables + .layers + .keys() + .any(|name| name.chars().any(|c| c as u32 > 0x2E80)), + "expected a CJK layer name, got {:?}", + db.tables.layers.keys().collect::>() + ); +} + +/// A DXF of version `acadver` whose only header variables are `$ACADVER` +/// and `$DIMPOST` (`dimpost` written as the raw bytes), padded with LINEs +/// past LibreDWG's 256-byte minimum. +fn dxf_with_dimpost(acadver: &str, dimpost: &[u8]) -> Vec { + let mut dxf = Vec::new(); + dxf.extend_from_slice(b" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\n"); + dxf.extend_from_slice(acadver.as_bytes()); + dxf.extend_from_slice(b"\n 9\n$DIMPOST\n 1\n"); + dxf.extend_from_slice(dimpost); + dxf.extend_from_slice(b"\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n"); + for i in 0..8 { + dxf.extend_from_slice( + format!(" 0\nLINE\n 8\n0\n 10\n{i}.0\n 20\n0.0\n 11\n{i}.0\n 21\n1.0\n").as_bytes(), + ); + } + dxf.extend_from_slice(b" 0\nENDSEC\n 0\nEOF\n"); + dxf +} + +#[test] +fn a_dimpost_suffix_round_trips() { + // "<> mm" is the DXF reference's own example of $DIMPOST (the "<>" is + // where the measurement goes). The non-ASCII variant is CP1252 (B5 = + // U+00B5; with no $DWGCODEPAGE, LibreDWG assumes ANSI_1252). + let cases: [(&str, &[u8], &str); 2] = [ + ("AC1015", b"<> mm", "<> mm"), + ("AC1015", b"<> \xB5m", "<> \u{B5}m"), + ]; + for (acadver, raw, expected) in cases { + let (_, h) = uncad::parse_bytes_with_header(&dxf_with_dimpost(acadver, raw), Format::Dxf) + .expect("the DXF must parse"); + assert_eq!(h.dimpost.as_deref(), Some(expected), "{acadver} {raw:?}"); + } +} + +#[test] +fn a_dxf_states_only_the_variables_its_header_section_names() { + // LibreDWG fills the rest of its header struct with zeros or its own + // defaults; none of those may come out as if the file had said them. + let (_, h) = uncad::parse_bytes_with_header(&dxf_with_dimpost("AC1015", b"<>"), Format::Dxf) + .expect("the DXF must parse"); + assert_eq!(h.acadver.as_deref(), Some("AC1015")); + assert_eq!(h.dimpost.as_deref(), Some("<>")); + assert_eq!( + (h.insunits, h.measurement, h.lunits, h.dimdec, h.dimzin), + (None, None, None, None, None) + ); + assert_eq!((h.extmin, h.limmax), (None, None)); + assert_eq!((h.dimscale, h.dimlfac, h.ltscale), (None, None, None)); + assert_eq!(h.clayer, Ref::Absent); + + // A DXF without $ACADVER (pre-R10) states no version either: LibreDWG + // assumes R11 for it, which is a default, not what the file says. + let text = String::from_utf8(dxf_with_dimpost("AC1015", b"<>")).expect("ASCII"); + let headerless = text.replace(" 9\n$ACADVER\n 1\nAC1015\n", ""); + let (_, h) = uncad::parse_bytes_with_header(headerless.as_bytes(), Format::Dxf) + .expect("the DXF must parse"); + assert_eq!((h.acadver, h.version), (None, None)); +} + +#[test] +fn the_header_serializes_with_unstated_variables_as_null() { + let h = header_of(EXAMPLE_R14_DWG); + let json = serde_json::to_value(&h).expect("serializes"); + assert_eq!(json["format"], "dwg"); + assert_eq!(json["acadver"], "AC1014"); + assert!(json["insunits"].is_null()); + assert_eq!(json["clayer"]["type"], "RESOLVED"); + let back: Header = serde_json::from_value(json).expect("deserializes"); + assert_eq!(back, h); +} + +#[test] +fn parse_bytes_reads_the_same_header() { + let (_, from_path) = uncad::parse_with_header(EXAMPLE_2000_DWG).expect("parses"); + let bytes = std::fs::read(EXAMPLE_2000_DWG).expect("readable"); + let (_, from_bytes) = uncad::parse_bytes_with_header(&bytes, Format::Dwg).expect("parses"); + assert_eq!(from_path, from_bytes); + // And the database beside it is the one parse() returns. + let (db, _) = uncad::parse_with_header(EXAMPLE_2000_DWG).expect("parses"); + assert_eq!(db, uncad::parse(EXAMPLE_2000_DWG).expect("parses")); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5c3dc98..85513e4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,9 +17,10 @@ crates/ cannot reach (MULTILEADER leader lines), a 3DSOLID SAB->SAT conversion that runs on a copy rather than the original, readers that decode a DWG or DXF from a memory buffer rather - than a path, and the header fields (version, codepage, - string width) and string conversions that decoding a - drawing's text needs. + than a path, the file-header fields (version, codepage, + string width, a pre-R13 header's length, whether the Template + section was read) that decoding a drawing's text and header + need, and string conversions. vendor/libredwg/ the upstream C sources actually compiled (see "Build"), five of them carrying a local patch (NOTICE.md) vendor-config/ config.h -- hand-written, standing in for autotools' output @@ -145,7 +146,7 @@ That suits the design rather than fighting it: entity fields were always going t through `dwg_dynapi_entity_value`/`dwg_dynapi_common_value`, LibreDWG's own reflection API keyed by string field name, with runtime type and range checks. `uncad::dynapi` wraps it in the generic helpers `get_field::`, `get_common_field::`, -`get_text_bytes` and `get_array_field::`, comparing the field size dynapi reports +`get_header_field::`, `get_text_bytes` and `get_array_field::`, comparing the field size dynapi reports against the requested Rust type's size so a wrong type mapping fails loudly instead of quietly corrupting data. Text fields come back as bytes on purpose: for a pre-R2007 drawing they are codepage bytes, not UTF-8, and `uncad::text::TextDecoder` (one per @@ -192,8 +193,8 @@ paper spaces own), `tables` (LAYER, every BLOCK_RECORD, MLINESTYLE) and `read_di (the reader's non-fatal warnings). `parse()` reads the file into memory itself (LibreDWG's own readers `fopen()` a path, which on Windows cannot open a non-ASCII one), and the `Dwg_Data` that the shim's `uncad_dwg_read_bytes`/`uncad_dxf_read_bytes` fill from those -bytes is walked twice inside `parse()` (`convert_entities`, then `convert_tables`), freed -with `dwg_free` immediately afterwards, and never reaches the return value. The hub of "DWG/DXF -> one model -> several outputs" is therefore the model +bytes is walked inside `parse()` (`convert_entities`, `convert_tables`, then the header +read), freed with `dwg_free` immediately afterwards, and never reaches the return value. The hub of "DWG/DXF -> one model -> several outputs" is therefore the model crate's value, and the outputs are `CadDatabase::to_json()` (serde, in `uncad-model`) and, in the `iron-render-cad` crate, `to_svg(&db, ..)` and `to_png(&db, ..)`. @@ -205,7 +206,9 @@ read C memory by accident -- the compiler refuses it. The model is deliberately lossy: it keeps what consumers of the drawing's content need and nothing else -- no linetypes, lineweights, layer on/off state, text styles, object -dictionaries or header variables. It cannot be used to write a DWG/DXF back out, and this +dictionaries or header variables (a consumer that needs the header variables gets them +beside the model, as this crate's own `uncad::Header`, from `parse_with_header`). It +cannot be used to write a DWG/DXF back out, and this project offers no writing (0.1.0's `write_dwg`/`write_dxf`/`dwg_to_dxf` were removed; see `CHANGELOG.md`). From ccabb07f378a38f4e8af2728f865e45ba6f7dc71 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:43:36 +0900 Subject: [PATCH 19/54] Decode the DOS-era Big5, GB2312 and CP932 code pages by their bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextDecoder walked a double-byte code page the way LibreDWG's own converter does, and inherited three of its table quirks: - dwg_codepage_is_twobyte answers "always" for Big5 (24) and GB2312 (31), ASCII included, so "*Model_Space" was consumed as "*M", "od", ...: a drawing declaring either lost its model space and every entity, and '中国 AB' decoded as four U+FFFD. Both are EUC-style encodings whose lead and trail bytes all have the high bit set; only such a byte opens a pair. - The GB2312 table is indexed by the 7-bit ISO-2022 form of a pair (0x2121..0x777E) while the file holds EUC-CN bytes; the pair is masked to 7 bits before the lookup. - CP932 (DOS Shift-JIS, 22) is left out of dwg_codepage_isasian and went through the single-byte table one byte at a time; it is double-byte. And ASCII is never looked up in any code page's table now. The wide lookup used to be applied to every byte of a double-byte page, which turns 0x5C into a yen sign (CP932) or a won sign (JOHAB) -- in a drawing it is the backslash of \P and of every \U+XXXX escape. Upstream's rules stay as they were: a DXF string that is valid UTF-8 is UTF-8 first, a byte the declared page has no character for is U+FFFD and a TEXT_ENCODING warning, and a code page LibreDWG has no table for is reported and read as UTF-8 -- not guessed as ANSI_1252, which the feature branch's C conversion did in silence. \U+XXXX escapes are passed through as written (disposition #8: expansion is a consumer's). A/B on generated DXFs (bytes from Python's encoders), text value as read: GB2312 '中国 AB' upstream '����' + TEXT_ENCODING, feature and this '中国 AB' BIG5 '中文 AB' upstream '中文��' + TEXT_ENCODING, feature and this '中文 AB' CP932 '日本 AB' upstream '���{ AB' + TEXT_ENCODING, feature and this '日本 AB' ANSI_1252 + UTF-8 'café': upstream and this 'café', feature 'café' unknown $DWGCODEPAGE + 'caf\xE9': upstream and this 'caf�' + TEXT_ENCODING, feature 'café' with no warning On the 198 corpus/fixture/sample inputs both read, every entity string, layer and block name and every TEXT_ENCODING diagnostic is the same as upstream's. tests/codepage.rs (ported, adapted): single-byte pages keeping every character, an unmapped byte reported, the DOS-era pages not pairing ASCII (example_2000.dwg with its declared code page patched) and decoding their pairs against the Windows twins, and a corrupt code page reported rather than guessed. Unit tests in text.rs for the same rules at byte level. Ported-from: a37b7ba Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ crates/uncad/src/text.rs | 126 ++++++++++++++++-- crates/uncad/tests/codepage.rs | 228 +++++++++++++++++++++++++++++++++ docs/CAVEATS.md | 27 +++- 4 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 crates/uncad/tests/codepage.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 990426f..77dfcbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,14 @@ Notable changes to this project are recorded here. The format follows tables, and a byte the declared codepage has no character for is now U+FFFD *and* a `TEXT_ENCODING: ...` warning in `read_diagnostics` naming the entity and field. See `docs/CAVEATS.md`, "Text before R2007 is decoded here". +- **The DOS-era double-byte codepages decode.** LibreDWG's tables pair every byte of a + Big5 or GB2312 string, ASCII included, so a drawing declaring either lost its + `*Model_Space` (read as `*M`, `od`, ...) and every entity, and `中国 AB` read as four + U+FFFD; CP932 (DOS Shift-JIS) was read one byte at a time. Only bytes >= 0x80 open a + pair now, a GB2312 pair is looked up in the 7-bit form the library's table is indexed + by, and CP932 is double-byte. ASCII is never looked up in any codepage's table, so a + 0x5C stays the backslash of `\P` and `\U+XXXX` where CP932's and JOHAB's tables say yen + and won. `tests/codepage.rs`. - **Closed LWPOLYLINEs are closed.** The `closed` field read bit 1 of the entity's `flag`, which in the library's LWPOLYLINE layout means "has extrusion"; closed is bit 512. Not one of the corpus's 1,137 LWPOLYLINEs had ever been reported closed, so every closed outline diff --git a/crates/uncad/src/text.rs b/crates/uncad/src/text.rs index 212ed4f..f70d0ec 100644 --- a/crates/uncad/src/text.rs +++ b/crates/uncad/src/text.rs @@ -22,6 +22,13 @@ use crate::dynapi; /// `Dwg_Codepage` values this module treats specially; the numbers are the /// `header.codepage` values LibreDWG's `codepages.h` fixes them to. const CP_UTF8: u16 = 0; +/// DOS Shift-JIS: double-byte, although `dwg_codepage_isasian` leaves it out. +const CP_CP932: u16 = 22; +/// DOS Big5: an EUC-style encoding whose lead and trail bytes are all >= 0x80. +const CP_BIG5: u16 = 24; +/// EUC-CN: like Big5, and the library's table for it is indexed by the 7-bit +/// ISO-2022 form of a pair (`0x2121..0x777E`), not by the bytes in the file. +const CP_GB2312: u16 = 31; const CP_UTF16: u16 = 43; /// The last codepage the library has a table for (`CP_ANSI_1258`). const CP_LAST: u16 = 44; @@ -56,38 +63,73 @@ fn codepage_label(codepage: u16) -> String { } } +/// Whether `lead` (a byte >= 0x80) opens a two-byte sequence in the +/// double-byte `codepage`. +/// +/// The library's `dwg_codepage_is_twobyte` answers "always" for the DOS-era +/// Big5 and GB2312 pages, ASCII included. Both are EUC-style encodings whose +/// lead and trail bytes are all >= 0x80, so the high bit is the rule there; +/// ASCII never reaches this function at all (see [`decode_codepage`]). +fn opens_pair(codepage: u16, lead: u8) -> bool { + if codepage == CP_GB2312 || codepage == CP_BIG5 { + return true; + } + // SAFETY: a pure table predicate on a codepage number with tables. + unsafe { libredwg_sys::dwg_codepage_is_twobyte(codepage as libredwg_sys::Dwg_Codepage, lead) } +} + /// Decodes `bytes` in `codepage` through the library's tables: one byte or, -/// in an East Asian codepage, a lead byte plus its trail byte per +/// in a double-byte codepage, a lead byte plus its trail byte per /// character. Returns the text and how many input bytes had no mapping; /// each of those became U+FFFD in the text. /// /// The library's own converter (`bit_TV_to_utf8_codepage`) is not used /// because it writes a NUL for an unmapped character -- which cuts the /// string short at that point -- and returns its input aliased rather than -/// copied in some cases. The loop here is the same walk, with the unmapped -/// count kept. +/// copied in some cases. The loop here is the same walk with the unmapped +/// count kept, and with three corrections to how the library reads the +/// DOS-era double-byte pages: +/// +/// - **ASCII is never looked up.** No double-byte page has a lead byte below +/// 0x80, and the tables' only differences there -- 0x5C as a yen sign in +/// CP932 and a won sign in JOHAB -- would turn the backslash of MTEXT's +/// `\P` and of every `\U+XXXX` escape into a currency sign. So a byte +/// below 0x80 is itself, in every codepage. +/// - **Big5 and GB2312 pair only bytes >= 0x80** (see [`opens_pair`]). The +/// library pairs every byte there, which consumed `*Model_Space` as `*M`, +/// `od`, ... and left such a drawing with no model space and no entities. +/// - **CP932 (DOS Shift-JIS) is double-byte**, although +/// `dwg_codepage_isasian` leaves it out, so its kanji are not looked up +/// one byte at a time in a single-byte table. And a GB2312 pair is +/// masked to its 7-bit form before the lookup, because that is how the +/// library's table is indexed while the file holds EUC-CN bytes. pub fn decode_codepage(bytes: &[u8], codepage: u16) -> (String, usize) { debug_assert!(has_codepage_tables(codepage)); let cp = codepage as libredwg_sys::Dwg_Codepage; // SAFETY: pure table predicates on a codepage number within the range // has_codepage_tables admits. - let two_byte_codepage = unsafe { libredwg_sys::dwg_codepage_isasian(cp) }; + let two_byte_codepage = + unsafe { libredwg_sys::dwg_codepage_isasian(cp) } || codepage == CP_CP932; let mut text = String::with_capacity(bytes.len()); let mut unmapped = 0; let mut i = 0; while i < bytes.len() { let lead = bytes[i]; i += 1; + if lead < 0x80 { + text.push(char::from(lead)); + continue; + } let code = if two_byte_codepage { - // The wide lookup is applied to every byte, as the library's own - // walk does: a few single bytes map differently in these - // codepages (0x5C is a yen sign in CP932, a won sign in JOHAB). let mut c = u16::from(lead); - if unsafe { libredwg_sys::dwg_codepage_is_twobyte(cp, lead) } { + if opens_pair(codepage, lead) { match bytes.get(i) { Some(&trail) => { c = (c << 8) | u16::from(trail); i += 1; + if codepage == CP_GB2312 { + c &= 0x7F7F; + } } None => { // A lead byte at the very end: nothing to pair it with. @@ -97,10 +139,10 @@ pub fn decode_codepage(bytes: &[u8], codepage: u16) -> (String, usize) { } } } + // SAFETY: a pure table lookup (see above). unsafe { libredwg_sys::dwg_codepage_uwc(cp, c) as u32 } - } else if lead < 0x80 { - u32::from(lead) } else { + // SAFETY: a pure table lookup (see above). unsafe { libredwg_sys::dwg_codepage_uc(cp, lead) as u32 } }; // The tables answer 0 for a code they do not contain. @@ -286,6 +328,9 @@ mod tests { const ANSI_949: u16 = 40; const ANSI_1252: u16 = 30; + const ANSI_936: u16 = 39; + const ANSI_950: u16 = 41; + const ANSI_932: u16 = 38; fn decoder(codepage: u16, wide: bool, utf8_first: bool) -> TextDecoder { TextDecoder { @@ -312,6 +357,21 @@ mod tests { assert_eq!(unmapped, 0); } + #[test] + fn single_byte_codepages_keep_every_character_of_a_non_ascii_string() { + // CP1251 "Стена" (all five bytes non-ASCII) and CP1252 "€€€" (three + // bytes of UTF-8 each): the library's own converter sizes its output + // at 1.5x the input and stops reading when it is full. + assert_eq!( + decode_codepage(b"\xD1\xF2\xE5\xED\xE0", 29), + ("Стена".to_string(), 0) + ); + assert_eq!( + decode_codepage(b"\x80\x80\x80", ANSI_1252), + ("€€€".to_string(), 0) + ); + } + #[test] fn an_unmapped_byte_is_replaced_and_counted() { // 0x81 is unassigned in Windows-1252. @@ -324,6 +384,52 @@ mod tests { assert_eq!(unmapped, 1); } + #[test] + fn the_dos_era_double_byte_codepages_pair_only_high_bytes() { + // Byte sequences from Python: '中国 AB'.encode('gb2312') (EUC-CN), + // '中文 AB'.encode('big5'), '日本 AB'.encode('shift_jis'). The + // Windows twins 936/950/932 read the same bytes and are the control. + // Before these rules GB2312 gave four U+FFFD (the ASCII paired up and + // lost), Big5 '中文' plus two U+FFFD, CP932 U+FFFD for each kanji. + let cases: [(u16, &[u8], &str); 6] = [ + (CP_GB2312, b"\xD6\xD0\xB9\xFA AB", "中国 AB"), + (ANSI_936, b"\xD6\xD0\xB9\xFA AB", "中国 AB"), + (CP_BIG5, b"\xA4\xA4\xA4\xE5 AB", "中文 AB"), + (ANSI_950, b"\xA4\xA4\xA4\xE5 AB", "中文 AB"), + (CP_CP932, b"\x93\xFA\x96\x7B AB", "日本 AB"), + (ANSI_932, b"\x93\xFA\x96\x7B AB", "日本 AB"), + ]; + for (codepage, bytes, expected) in cases { + assert_eq!( + decode_codepage(bytes, codepage), + (expected.to_string(), 0), + "codepage {codepage}" + ); + } + // ASCII is never paired: a block name survives whole. + for codepage in [CP_GB2312, CP_BIG5, CP_CP932] { + assert_eq!( + decode_codepage(b"*Model_Space", codepage), + ("*Model_Space".to_string(), 0) + ); + } + } + + #[test] + fn ascii_is_never_looked_up_so_a_backslash_stays_a_backslash() { + // The library's CP932 and JOHAB tables map 0x5C to a currency sign; + // in a drawing it is the backslash of \P and \U+XXXX. + for codepage in [CP_CP932, ANSI_932] { + assert_eq!( + decode_codepage(b"\x93\xFA\\P\\U+00B1", codepage), + ("日\\P\\U+00B1".to_string(), 0) + ); + } + const JOHAB: u16 = 26; + let (text, _) = decode_codepage(b"\xC8\\P", JOHAB); + assert!(text.ends_with("\\P"), "{text}"); + } + #[test] fn the_decoder_reports_what_it_could_not_decode_once() { let d = decoder(ANSI_1252, false, false); diff --git a/crates/uncad/tests/codepage.rs b/crates/uncad/tests/codepage.rs new file mode 100644 index 0000000..7baaad2 --- /dev/null +++ b/crates/uncad/tests/codepage.rs @@ -0,0 +1,228 @@ +//! How a drawing's 8-bit strings come out, end to end: single-byte code +//! pages keep every character, a byte the declared code page has no +//! character for is U+FFFD *and* a `TEXT_ENCODING` warning, the DOS-era +//! double-byte pages (Big5, GB2312, CP932) pair only the bytes that can +//! pair, and a code page LibreDWG has no table for is reported and read as +//! UTF-8. The 8-bit fixtures are written by the +//! tests themselves from group codes, so the expected strings are the ones +//! the test encoded; `text.rs` has the byte-level cases. + +use std::path::PathBuf; + +use uncad::{CadDatabase, Entity, Format}; + +const EXAMPLE_2000_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dwg" +); + +struct TempDir(PathBuf); + +impl TempDir { + fn new(name: &str) -> Self { + let mut path = std::env::temp_dir(); + path.push(format!("uncad-{name}-{}", std::process::id())); + std::fs::create_dir_all(&path).expect("temp dir should be writable"); + TempDir(path) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// An AC1015 DXF declaring `codepage` in `$DWGCODEPAGE`, with one TEXT per +/// entry of `texts` (already encoded in that code page). +fn dxf_with_texts(codepage: &str, texts: &[&[u8]]) -> Vec { + let mut dxf = Vec::new(); + dxf.extend_from_slice(b" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1015\n"); + dxf.extend_from_slice(b" 9\n$DWGCODEPAGE\n 3\n"); + dxf.extend_from_slice(codepage.as_bytes()); + dxf.extend_from_slice(b"\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n"); + for (i, text) in texts.iter().enumerate() { + dxf.extend_from_slice(b" 0\nTEXT\n 8\n0\n 10\n0.0\n 20\n"); + dxf.extend_from_slice(format!("{}.0\n", i * 5).as_bytes()); + dxf.extend_from_slice(b" 40\n2.5\n 1\n"); + dxf.extend_from_slice(text); + dxf.extend_from_slice(b"\n"); + } + // LibreDWG's DXF reader rejects files under 256 bytes; pad with LINEs, + // which the assertions never look at. + for i in 0..8 { + dxf.extend_from_slice( + format!(" 0\nLINE\n 8\n0\n 10\n{i}.0\n 20\n0.0\n 11\n{i}.0\n 21\n1.0\n").as_bytes(), + ); + } + dxf.extend_from_slice(b" 0\nENDSEC\n 0\nEOF\n"); + dxf +} + +fn text_values(db: &CadDatabase) -> Vec { + db.entities + .iter() + .filter_map(|e| match e { + Entity::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect() +} + +fn text_encoding_warnings(db: &CadDatabase) -> Vec<&String> { + db.read_diagnostics + .warnings + .iter() + .filter(|w| w.starts_with("TEXT_ENCODING")) + .collect() +} + +#[test] +fn single_byte_code_pages_keep_every_character() { + let dir = TempDir::new("codepage-8bit"); + // CP1251 "Стена" (five Cyrillic letters, all non-ASCII) and CP1252 "€€€" + // (three bytes of UTF-8 each): the cases where the library's own + // converter, with its 1.5x output buffer, loses the tail. + let cases: [(&str, &[u8], &str); 3] = [ + ("ANSI_1251", b"\xD1\xF2\xE5\xED\xE0", "Стена"), + ("ANSI_1252", b"\x80\x80\x80", "€€€"), + ("ANSI_1252", b"\xB1\xB0\xF8 100", "±°ø 100"), + ]; + for (i, (codepage, bytes, expected)) in cases.iter().enumerate() { + let path = dir.0.join(format!("case{i}.dxf")); + std::fs::write(&path, dxf_with_texts(codepage, &[bytes])).expect("writable"); + let (db, header) = uncad::parse_with_header(&path).expect("the DXF must parse"); + assert_eq!(header.codepage_name.as_deref(), Some(*codepage)); + assert_eq!(text_values(&db), vec![expected.to_string()], "{codepage}"); + assert!(text_encoding_warnings(&db).is_empty(), "{codepage}"); + } +} + +#[test] +fn an_unmappable_byte_becomes_a_reported_replacement_character_not_a_truncation() { + // 0x81 is unassigned in CP1252; the text after it must survive, and the + // gap must be reported rather than papered over. + let bytes = dxf_with_texts("ANSI_1252", &[b"A\x81B"]); + let db = uncad::parse_bytes(&bytes, Format::Dxf).expect("the DXF must parse"); + assert_eq!(text_values(&db), vec!["A\u{FFFD}B".to_string()]); + let warnings = text_encoding_warnings(&db); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0].contains("TEXT.text_value") && warnings[0].contains("ANSI_1252 (30)"), + "{}", + warnings[0] + ); +} + +#[test] +fn the_dos_era_double_byte_code_pages_do_not_pair_ascii() { + // The library says every byte of Big5 (24) and GB2312 (31) opens a pair, + // so "*Model_Space" was consumed as "*M", "od", ... and no entity was + // ever selected. Patch the declared code page (byte 0x13 of an R2000 + // file header) to the two real values and to CP932, which is decoded as + // double-byte too: the ASCII names must survive and the drawing keep the + // entities its declared ANSI_1252 gives. + let reference = uncad::parse(EXAMPLE_2000_DWG).expect("corpus file is readable"); + assert!(!reference.entities.is_empty()); + let bytes = std::fs::read(EXAMPLE_2000_DWG).expect("corpus file is readable"); + assert_eq!(bytes[0x13], 30, "example_2000.dwg declares ANSI_1252 (30)"); + for (cp, name) in [(24u8, "BIG5"), (31, "GB2312"), (22, "CP932")] { + let mut patched = bytes.clone(); + patched[0x13] = cp; + let (db, header) = + uncad::parse_bytes_with_header(&patched, Format::Dwg).expect("still a valid DWG"); + assert_eq!(header.codepage_name.as_deref(), Some(name)); + assert_eq!(db.entities.len(), reference.entities.len(), "{name}"); + assert!( + db.tables.layers.contains_key("Tavolo 3"), + "{name}: {:?}", + db.tables.layers.keys().collect::>() + ); + assert!( + db.tables.block_records.contains_key("*Model_Space"), + "{name}" + ); + } +} + +#[test] +fn the_dos_era_double_byte_code_pages_decode_their_cjk_pairs() { + // Byte sequences from Python: '中国 AB'.encode('gb2312') (EUC-CN), + // '中文 AB'.encode('big5'), '日本 AB'.encode('shift_jis') (CP932). The + // Windows twins 936/950/932 of the same bytes are the control group. + // The last CP932 case keeps 0x5C a backslash (LibreDWG's table says + // yen), so the \U+ escape it opens and a \P stay what they are; an + // escape is text as far as the model is concerned, and is not expanded. + let cases: [(&str, &[u8], &str); 7] = [ + ("GB2312", b"\xD6\xD0\xB9\xFA AB", "中国 AB"), + ("ANSI_936", b"\xD6\xD0\xB9\xFA AB", "中国 AB"), + ("BIG5", b"\xA4\xA4\xA4\xE5 AB", "中文 AB"), + ("ANSI_950", b"\xA4\xA4\xA4\xE5 AB", "中文 AB"), + ("CP932", b"\x93\xFA\x96\x7B AB", "日本 AB"), + ("ANSI_932", b"\x93\xFA\x96\x7B AB", "日本 AB"), + ("CP932", b"\x93\xFA\\P\\U+00B1", "日\\P\\U+00B1"), + ]; + for (codepage, bytes, expected) in cases { + let (db, header) = + uncad::parse_bytes_with_header(&dxf_with_texts(codepage, &[bytes]), Format::Dxf) + .expect("the DXF must parse"); + assert_eq!(header.codepage_name.as_deref(), Some(codepage)); + assert_eq!(text_values(&db), vec![expected.to_string()], "{codepage}"); + assert!(text_encoding_warnings(&db).is_empty(), "{codepage}"); + } +} + +#[test] +fn a_corrupt_code_page_in_the_file_header_is_reported_not_guessed() { + // header.spec reads the code page as a raw 16-bit value at byte 0x13 of + // an R2000 file; LibreDWG indexes its tables with it unchecked. 100 is + // outside every table, so it must never reach them. + let mut bytes = std::fs::read(EXAMPLE_2000_DWG).expect("corpus file is readable"); + assert_eq!(bytes[0x13], 30, "example_2000.dwg declares ANSI_1252 (30)"); + bytes[0x13] = 100; + let (db, header) = + uncad::parse_bytes_with_header(&bytes, Format::Dwg).expect("still a valid DWG"); + assert_eq!( + header.codepage, 100, + "the header reports what the file says" + ); + assert_eq!(header.codepage_name, None, "LibreDWG has no name for it"); + // ASCII reads the same in every code page and is never looked up: the + // names survive. The one string with CP1252 bytes in it (an MTEXT) is + // not decoded as 1252 by guesswork -- it is read as UTF-8, which it is + // not, and said so. + assert!(db.tables.layers.contains_key("Tavolo 3")); + let warnings = text_encoding_warnings(&db); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0].contains("MTEXT.text") + && warnings[0].contains("codepage 100 cannot be decoded"), + "{}", + warnings[0] + ); + + // A non-ASCII string under an unknown code page is read as UTF-8 when it + // is UTF-8, and reported when it is not -- never decoded as 1252. + let dxf = String::from_utf8(dxf_with_texts("ANSI_1252", &[b"X"])).expect("ASCII"); + for (text, expected, warned) in [ + ("caf\u{E9}".as_bytes(), "caf\u{E9}", false), + (b"caf\xE9".as_slice(), "caf\u{FFFD}", true), + ] { + let mut bytes = dxf.replace("ANSI_1252", "BOGUS_CP").into_bytes(); + let at = bytes + .windows(3) + .position(|w| w == b"\nX\n") + .expect("the text value") + + 1; + bytes.splice(at..at + 1, text.iter().copied()); + let (db, header) = + uncad::parse_bytes_with_header(&bytes, Format::Dxf).expect("the DXF must parse"); + assert_eq!(header.codepage_name, None, "{:?}", header.codepage); + assert_eq!(text_values(&db), vec![expected.to_string()]); + assert_eq!( + !text_encoding_warnings(&db).is_empty(), + warned, + "{expected}" + ); + } +} diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 46b65c8..e484493 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -219,8 +219,28 @@ Asian one, and reports what it could not decode: and reported otherwise. The library's own converter for this (`bit_TV_to_utf8_codepage`) is not used: it writes a -NUL for an unmapped character, which cuts the string short at that point, and in some -cases returns its input aliased rather than copied. +NUL for an unmapped character, which cuts the string short at that point, sizes its output +at 1.5 times the input for a single-byte codepage and stops reading when that is full (a +CP1251 `Стена` came back as `Стен`), and in some cases returns its input aliased rather +than copied. + +**The DOS-era double-byte codepages are read by their bytes.** LibreDWG's tables treat +every byte of a Big5 (24) or GB2312 (31) string as the first of a pair, ASCII included: +`*Model_Space` paired up into `*M`, `od`, ..., so a drawing declaring either lost its model +space and every entity, and `中国 AB` came back as four U+FFFD. Both are EUC-style +encodings whose lead and trail bytes all have the high bit set, so only such a byte opens +a pair here; a GB2312 pair is masked to the 7-bit ISO-2022 form the library's table is +indexed by, since the file holds EUC-CN bytes; and CP932 (22, DOS Shift-JIS), which +`dwg_codepage_isasian` leaves out, is read as the double-byte encoding it is rather than +one byte at a time through a single-byte table. **ASCII is never looked up**, in any +codepage: the CP932 and JOHAB tables map 0x5C to a yen and a won sign, but in a drawing it +is the backslash of `\P` and `\U+XXXX`. `tests/codepage.rs` writes each case with bytes +from Python's own encoders, with the Windows twins (936, 950, 932) of the same bytes as +the control. + +**Escapes are text.** `\U+XXXX` and `\M+nXXXX` in a string are passed through as the file +wrote them; expanding them is a consumer's decision, as the rest of MTEXT's inline codes +are. **What is trusted, and what cannot be told:** @@ -240,6 +260,9 @@ cases returns its input aliased rather than copied. there. - R2007 and later: the library converts from UTF-16 itself and the codepage is moot; the result is checked to be UTF-8 and reported if it is not. +- A codepage LibreDWG has no table for -- `CP_UNDEFINED`, or a corrupt value in a DWG + header, which the library would index its tables with unchecked -- is not guessed at + (as `ANSI_1252`, say): its strings are read as UTF-8 and reported where they are not. The golden case G8 (a title block with Korean layer and block names, attribute values and defaults, and a text, written as CP949 with `$DWGCODEPAGE = ANSI_949`) reads back exactly, From f82164788203866260c082efdc469ea69630f0e7 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:45:57 +0900 Subject: [PATCH 20/54] Read an R2007+ DXF's strings in the width they were stored in, and stop refusing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LibreDWG's DXF importer stores every string it sets through its field setter as UTF-16 once the version is R2007 or later, exactly as for a DWG, but its text accessors convert UTF-16 only for a DWG (IS_FROM_TU_DWG) and hand an R2007+ DXF's strings out as 8-bit ones, which stop at the first NUL byte: "*Model_Space" read as "*", no block record matched, and the file came back empty with no error -- which is why it was refused. The exceptions are the strings the importer copies byte for byte: MTEXT's text chunks (groups 1/3) and the HEADER variables, parsed before it knows the version. Those are the file's UTF-8 and must never be read as UTF-16, which scans for a 16-bit NUL past their allocation. TextDecoder now knows which of three storages a drawing is (R2007+ DWG, R2007+ DXF by the same header.version >= R_2007 rule the library's setter uses, or 8-bit before R2007) and reads each stored string in its width: dynapi hands back a RawText (Converted UTF-8 from the library, stored Bytes, or stored UTF-16 units read in Rust up to their 16-bit NUL), never decoded there. The 8-bit strings of an R2007+ DXF are checked as UTF-8 and reported when they are not. stored_field decodes a string field reached by raw pointer (an embedded struct such as LAYOUT's plotsettings) by the same rule. The importer's own layer/block lookups are the vendored dwg.c patch's (ported in the native layer); this pins it. The refusal is gone. ParseError::UnsupportedDxfVersion stays, for the failure this used to be: an R2007+ DXF whose entities LibreDWG placed in model or paper space (entmode, set from each entity's owner handle, no name lookup of ours involved) while none reached the model is an error, not an empty drawing. An error rather than a read_diagnostics warning, because upstream's own ordering (CAVEATS) is that an obviously empty drawing is worse than an error, and a warning would still return the empty drawing as Ok. Scoped to R2007+ DXF: the same test on every input would also refuse two pre-R13 DWGs (r11/ACEB10.dwg, r2.10/block.dwg) that read today with an empty model space. No corpus file trips it; with the width fix disabled, exactly the 27 readable R2007+ DXFs do. Measured: - corpus_sweep (dxf parsed, refused, critical) (31, 32, 4) -> (58, 0, 9): 27 of the 32 R2007+ DXFs read; 2013/gh109_1, 2018/Constraints, Dynblocks, LiveSection1 and TS1 fail inside LibreDWG with 2048. Layer references 64,733 -> 66,064: the 1,331 entities of those 27 files, all resolved but the 21 of 2010/gh209_1.dxf, whose entities the importer leaves without a layer handle (absent). Every other pinned number holds. - 22 of the 24 readable R2007+ DXFs with a DWG twin state the same entity count, layers and INSERT blocks; the two that do not are pinned as known deviations with a tripwire (gh209_1 above; 2018/Leader.dxf's own text puts one entity on a layer "0 @ 1" the DWG does not have). - 2000/entities-2d.dxf restamped AC1015..AC1032 reads to the same model. - Against the oracle: all 235 inputs parse or fail exactly as the feature branch's CLI; on the 28 it reads that upstream refused, every string, layer and block name matches the feature branch but four MTEXT texts, where the feature branch expanded "108\U+00B0" to "108°" and this keeps the escape as written (disposition #8). - Without the dwg.c patch: r2007_dxf_handles.rs fails all 3 tests (example_2018.dxf {"0": 7, : 65}), dxf_pipeline's two R2007+ tests fail. Tests: tests/r2007_dxf_handles.rs (ported: DWG-twin agreement on layers, blocks and the layer+invisible tally -- the hidden count is a consumer's rule now, so what the parser owns is compared), codepage.rs's R2007+ cases (ported; escapes now kept as written), header.rs's $DIMPOST across all 27 files and at AC1032, dxf_pipeline's two refusal tests turned into twin-agreement tests, lib unit tests for the guard. vendored_patches.rs's module doc, CAVEATS (the refused section, the text section, the dwg.c bullet, the corpus tally, the stale R2000/R2004 entity-count paragraph), README scope and CHANGELOG say what is true now. Ported-from: 2b2c750, 78cfe4b, 722d568 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 47 ++-- README.md | 12 +- crates/uncad/src/dynapi.rs | 176 ++++++++---- crates/uncad/src/lib.rs | 197 +++++++++---- crates/uncad/src/text.rs | 358 ++++++++++++++++++------ crates/uncad/tests/codepage.rs | 139 ++++++++- crates/uncad/tests/corpus_sweep.rs | 14 +- crates/uncad/tests/dxf_pipeline.rs | 190 ++++++++----- crates/uncad/tests/header.rs | 74 ++++- crates/uncad/tests/r2007_dxf_handles.rs | 203 ++++++++++++++ crates/uncad/tests/vendored_patches.rs | 9 +- docs/ARCHITECTURE.md | 17 +- docs/CAVEATS.md | 129 +++++---- 13 files changed, 1199 insertions(+), 366 deletions(-) create mode 100644 crates/uncad/tests/r2007_dxf_handles.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 77dfcbd..b0a73c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,14 +104,15 @@ Notable changes to this project are recorded here. The format follows "Local patches to the vendored LibreDWG". A DXF holding a polygon mesh is read instead of refused as a whole (critical error 2048: the importer did not know the mesh vertices' `AcDbPolygonMeshVertex` marker). A corrupt header date no longer ends the - process from inside the C library (0xC0000409 on Windows, from `strftime`). Two more - are not visible in this crate's output yet: an R2004+ entity carrying both a true - colour and a transparency no longer has the two swapped in the library's fields (this - crate reads a true colour only under the TRUECOLOR method, which the entities measured - do not have), and the importer compares an R2007+ DXF's table-record names decoded, so - its layer and block lookups no longer stop at the first character (such a DXF is still - refused). On the 208 corpus drawings the JSON output is byte-identical with and without - the patches. `build.rs` refuses to build when a patch's marker has gone missing, + process from inside the C library (0xC0000409 on Windows, from `strftime`). The + importer compares an R2007+ DXF's table-record names decoded, so its layer and block + lookups no longer stop at the first character: without that patch `example_2018.dxf` + reads with 65 of its 72 entities on no layer (`tests/r2007_dxf_handles.rs`). One more is + not visible in this crate's output yet: an R2004+ entity carrying both a true colour and + a transparency no longer has the two swapped in the library's fields (this crate reads a + true colour only under the TRUECOLOR method, which the entities measured do not have). + Before R2007+ DXF was read, the 208 corpus drawings gave byte-identical JSON with and + without the patches. `build.rs` refuses to build when a patch's marker has gone missing, which a re-vendor through `scripts/sync-libredwg-vendor.sh` would otherwise do in silence. - `MTextEntity::rotation` is the direction of the text's X axis instead of a constant `0`, @@ -125,9 +126,9 @@ Notable changes to this project are recorded here. The format follows which those files still carry), so the value it returned for the flag was a bit of the offset's encoding -- "no arrowhead" whenever the offset's z was zero -- not the file's flag. Earlier versions are unaffected and still read the flag. -- Two user-facing messages carried a run of spaces in mid-sentence: the refusal of an - R2007+ DXF (`ParseError::UnsupportedDxfVersion`'s `Display`) and the missing-tag - diagnostic. Both now read as one sentence, pinned by tests. +- Two user-facing messages carried a run of spaces in mid-sentence: the R2007+ DXF error + (`ParseError::UnsupportedDxfVersion`'s `Display`) and the missing-tag diagnostic. Both + now read as one sentence, pinned by tests. - **Text before R2007 is decoded through the drawing's codepage** (`header.codepage`, DXF `$DWGCODEPAGE`). LibreDWG returns such strings as the 8-bit bytes the file holds; they were read as UTF-8, so every non-ASCII character of a CP949 or CP1252 @@ -189,8 +190,8 @@ Notable changes to this project are recorded here. The format follows directories `cc` located. Bindings are also generated *before* the C compile, so a libclang problem fails in seconds rather than after the whole LibreDWG compile. - A DXF saved as R2007 or later used to read as an empty drawing without any error; it - is now refused (see "Changed"). `docs/CAVEATS.md` explains the cause (string width in - LibreDWG's DXF importer) and why reading it partially was rejected. + is now read (see "Changed"). `docs/CAVEATS.md` explains the cause (string width, both in + LibreDWG's DXF importer and in how this crate read what it stored). - `docs/CAVEATS.md` claimed every entity type with geometry was handled. It is not -- several types that have a shape still arrive as `Unknown`. The section now states the supported list as the contract. @@ -248,14 +249,18 @@ Notable changes to this project are recorded here. The format follows shape** of every entity (`common.layer`) and is the reason the next release is a 0.x minor. `Ref::name()` gives the resolved name or `""` for consumers that only need a lookup key. -- A DXF saved as R2007 or later (`$ACADVER` `AC1021` and up) is now refused with - `ParseError::UnsupportedDxfVersion` instead of being returned as a drawing with no - entities and no error. The decision is made from the file's HEADER section before - LibreDWG reads it; R2000/R2004 DXF, files without `$ACADVER`, binary DXF and every DWG - are unaffected. Callers that treated the empty result as success will now see an error - -- that is the point. `ParseError` is `#[non_exhaustive]`, so the new variant is not a - breaking change to matches. Two tests pin it: a walk of the corpus that requires exactly - the R2007+ files to be refused, and one drawing under two `$ACADVER` values. +- **A DXF saved as R2007 or later (`$ACADVER` `AC1021` and up) is read**, instead of being + returned as a drawing with no entities and no error. LibreDWG's importer holds such a + file's strings in two widths -- UTF-16 for everything it sets through its field setter, + the file's own UTF-8 for MTEXT text and the HEADER variables -- and hands both out as if + they were 8-bit; `TextDecoder` now reads each in its width, and the vendored `dwg.c` + patch does the same for the importer's own layer and block lookups. Measured on the + corpus: 27 of the 32 R2007+ DXFs read (the other 5 fail inside LibreDWG with critical + error 2048), and 22 of the 24 with a DWG twin state the same layers and INSERT blocks + as it; `tests/dxf_pipeline.rs`, `tests/r2007_dxf_handles.rs` and `tests/codepage.rs` + pin it. `ParseError::UnsupportedDxfVersion` stays, for the case this used to be: an + R2007+ DXF whose entities LibreDWG placed in model or paper space and none of which + reached the model is an error, not an empty drawing (no corpus file is). - `ParseError::InvalidPath` is gone: `parse()` no longer hands LibreDWG a C path, and a file it cannot read is `ParseError::Io`. Breaking for code that names the variant. - No `std` hash collections anywhere in the workspace: `clippy.toml` disallows `HashMap` diff --git a/README.md b/README.md index c6b6906..aa4fde0 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,12 @@ cargo run -p uncad-cli -- drawing.dwg -o all.svg --space all # every space i 1. **DWG** — read through [LibreDWG](https://www.gnu.org/software/libredwg/) (GPLv3+), bound directly via Rust FFI (`bindgen`). All versions. 2. **DXF** — read through the same LibreDWG engine, chosen by file extension - (`parse`) or by the caller (`parse_bytes`). - **DXF saved as R2007 or later is refused** with `ParseError::UnsupportedDxfVersion` - rather than read incompletely — see `docs/CAVEATS.md`, "DXF reading". R2000/R2004 - DXF and every DWG version are unaffected. - LibreDWG's own DXF importer is documented as working "for most objects", so - it is less complete than its DWG reading ([`docs/CAVEATS.md`](./docs/CAVEATS.md)). + (`parse`) or by the caller (`parse_bytes`). Every version, R2007 and later + included: such a file holds its strings in two widths in LibreDWG's memory, + and each is read in its own — see `docs/CAVEATS.md`, "DXF saved as R2007 or + later is read". LibreDWG's own DXF importer is documented as working "for most + objects", so it is less complete than its DWG reading + ([`docs/CAVEATS.md`](./docs/CAVEATS.md)). 3. **Output** — the parsed model as JSON (`CadDatabase::to_json`, from `uncad-model`). SVG and PNG come from the [`iron-render-cad`](https://github.com/iyulab/iron-render-cad) crate (MIT), which diff --git a/crates/uncad/src/dynapi.rs b/crates/uncad/src/dynapi.rs index 489f3c4..c9e5ef0 100644 --- a/crates/uncad/src/dynapi.rs +++ b/crates/uncad/src/dynapi.rs @@ -324,15 +324,95 @@ pub fn get_common_field(entity: *mut c_void, field: &str) -> Option), + /// The string as stored, read as 8-bit bytes up to their NUL. + Bytes(Vec), + /// The string as stored, read as UTF-16 code units up to their 16-bit + /// NUL. + Wide(Vec), +} + +/// How a string the library hands out *unconverted* -- a pointer straight +/// into the parsed `Dwg_Data` -- is laid out in memory. The library cannot +/// be asked: the text accessors hand out such a pointer both for 8-bit +/// strings and, in an R2007+ DXF, for the UTF-16 ones its DXF importer +/// wrote, so the caller ([`crate::text::TextDecoder`]) says which it is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StoredWidth { + /// 8-bit bytes, NUL-terminated. + Bytes, + /// UTF-16 code units, terminated by a 16-bit NUL. + Wide, +} + +/// Copies an unconverted string out of the parsed `Dwg_Data`, in the width +/// the caller says it is stored in. +/// +/// # Safety +/// `ptr` must be non-null and point at a NUL-terminated string of that +/// width which stays alive for the duration of the call. Reading an 8-bit +/// string as [`StoredWidth::Wide`] would look for a 16-bit NUL past its +/// allocation, which is why the width is never guessed here. +pub unsafe fn read_stored(ptr: *const std::os::raw::c_char, width: StoredWidth) -> RawText { + match width { + // SAFETY: a NUL-terminated 8-bit string (caller contract). + StoredWidth::Bytes => RawText::Bytes(unsafe { CStr::from_ptr(ptr) }.to_bytes().to_vec()), + StoredWidth::Wide => { + let units = ptr.cast::(); + let mut out = Vec::new(); + loop { + // SAFETY: a string of 16-bit units ending in a 16-bit NUL + // (caller contract); every unit read is at or before it. + // Unaligned reads, because nothing in the contract promises + // the pointer's alignment. + let unit = unsafe { units.add(out.len()).read_unaligned() }; + if unit == 0 { + break; + } + out.push(unit); + } + RawText::Wide(out) + } + } +} + +/// Takes what a LibreDWG text accessor handed out: a buffer it converted and +/// allocated (`is_new`, freed here once copied), or a pointer into the +/// `Dwg_Data`, read in `stored` width. +/// +/// # Safety +/// `ptr` must be non-null and valid per the accessor's contract. +unsafe fn take_text(ptr: *mut std::os::raw::c_char, is_new: bool, stored: StoredWidth) -> RawText { + if is_new { + // SAFETY: a NUL-terminated UTF-8 buffer the accessor malloc'd for + // its caller (documented in dwg_api.h); ours to free once copied, + // and nothing else holds a reference to it. + let owned = unsafe { CStr::from_ptr(ptr) }.to_bytes().to_vec(); + unsafe { libc::free(ptr.cast()) }; + RawText::Converted(owned) + } else { + // SAFETY: a stored string of the width the caller named. + unsafe { read_stored(ptr, stored) } + } +} + /// Resolves a `BITCODE_H` handle reference (e.g. an entity's `layer` -/// field) to the name bytes of the object it points at (undecoded -- see -/// [`get_text_bytes`]), via -/// `dwg_dynapi_handle_name`. Returns `None` for a null handle or an object -/// with no name field (not every handle target has one). -pub fn handle_name_bytes( +/// field) to the name of the object it points at, as the library hands it +/// out (see [`RawText`]), via `dwg_dynapi_handle_name`. `stored` is the +/// width of that name when the library does not convert it. Returns `None` +/// for a null handle or an object with no name field (not every handle +/// target has one). +pub fn handle_name( dwg: *mut libredwg_sys::Dwg_Data, handle: *mut libredwg_sys::Dwg_Object_Ref, -) -> Option> { + stored: StoredWidth, +) -> Option { if handle.is_null() { return None; } @@ -343,14 +423,9 @@ pub fn handle_name_bytes( if name_ptr.is_null() { return None; } - // SAFETY: name_ptr is a valid NUL-terminated C string per dynapi's contract. - let owned = unsafe { CStr::from_ptr(name_ptr) }.to_bytes().to_vec(); - if alloced != 0 { - // SAFETY: alloced != 0 means dwg_dynapi_handle_name malloc'd this - // buffer itself (documented in dwg_api.h); ours to free. - unsafe { libc::free(name_ptr.cast()) }; - } - Some(owned) + // SAFETY: alloced != 0 means dwg_dynapi_handle_name malloc'd a + // converted copy; otherwise name_ptr is the record's own stored name. + Some(unsafe { take_text(name_ptr, alloced != 0, stored) }) } /// `true` when the drawing was read from a pre-R13 source (DWG R1.4 .. R12, @@ -394,7 +469,9 @@ pub fn is_r2013_or_later(dwg: *mut libredwg_sys::Dwg_Data) -> bool { /// pre-R13 drawing the library matches the reference's `r11_idx` against the /// table's entry order, since such references carry no handle; from R13 on it /// matches the handle. Returns `None` when there is no such table or entry. -/// The library always hands back a copy, freed here once it has been read. +/// The library always hands back a copy, freed here once it has been read: +/// UTF-8 it converted for an R2007+ drawing (a DXF one too, through the +/// vendored `dwg.c` patch), the stored 8-bit bytes before R2007. pub fn table_entry_name_bytes( dwg: *mut libredwg_sys::Dwg_Data, handle: *mut libredwg_sys::Dwg_Object_Ref, @@ -417,20 +494,24 @@ pub fn table_entry_name_bytes( Some(owned) } -/// Reads a text field (`BITCODE_T`/`TV`/`TU`) as the bytes LibreDWG holds -/// for it, via `dwg_dynapi_entity_utf8text`. For an R2007+ drawing those -/// bytes are UTF-8 (the library converts its UTF-16 strings); for an older -/// one they are the file's own 8-bit bytes in the drawing's codepage, which -/// the function does not decode despite its name. [`crate::text::TextDecoder`] -/// is what turns either into a `String`; nothing else reads text fields. -/// Returns `None` if the field doesn't exist or is a null string. +/// Reads a text field (`BITCODE_T`/`TV`/`TU`) as LibreDWG hands it out, via +/// `dwg_dynapi_entity_utf8text`. [`crate::text::TextDecoder`] is what turns +/// it into a `String`; nothing else reads text fields. Returns `None` if the +/// field doesn't exist or is a null string. /// -/// The C function may return a freshly `malloc`'d buffer (r2007+ conversion -/// path) or a pointer straight into the parsed `Dwg_Data` (older formats) -- -/// `isnew` tells us which. We always copy before returning, and `free()` the -/// malloc'd buffer ourselves in the `isnew` case so this doesn't leak one -/// string per TEXT/MTEXT/... field read for the lifetime of the process. -pub fn get_text_bytes(entity: *mut c_void, dxfname: &str, field: &str) -> Option> { +/// Despite its name the C function converts only an R2007+ *DWG*'s UTF-16 +/// strings, into a freshly `malloc`'d UTF-8 buffer (`isnew`, freed here once +/// copied so no string leaks per field read). For every other drawing it +/// returns a pointer straight into the parsed `Dwg_Data`: the file's own +/// 8-bit bytes before R2007, and in an R2007+ DXF the UTF-16 its importer +/// stored -- or, for the few fields that importer keeps 8-bit, the file's +/// bytes. `stored` says which width that pointer is read in. +pub fn get_text( + entity: *mut c_void, + dxfname: &str, + field: &str, + stored: StoredWidth, +) -> Option { if entity.is_null() { return None; } @@ -455,19 +536,9 @@ pub fn get_text_bytes(entity: *mut c_void, dxfname: &str, field: &str) -> Option if !ok || text_ptr.is_null() { return None; } - - // SAFETY: text_ptr is a valid, NUL-terminated C string per dynapi's - // contract (checked non-null above). - let owned = unsafe { CStr::from_ptr(text_ptr) }.to_bytes().to_vec(); - - if is_new != 0 { - // SAFETY: is_new != 0 means dwg_dynapi_entity_utf8text malloc'd - // this buffer itself (documented in dwg_api.h); it's ours to free - // and nothing else holds a reference to it. - unsafe { libc::free(text_ptr.cast()) }; - } - - Some(owned) + // SAFETY: text_ptr is non-null and, per dynapi's contract, either a + // converted copy (is_new) or the field's stored string. + Some(unsafe { take_text(text_ptr, is_new != 0, stored) }) } /// Reads a plain-old-data header variable (`INSUNITS`, `EXTMIN`, @@ -508,10 +579,15 @@ pub fn get_header_field(dwg: *const libredwg_sys::Dwg_Data, name: &st Some(unsafe { out.assume_init() }) } -/// Reads a text header variable (`DIMPOST`, ...) as the bytes LibreDWG holds -/// for it, via `dwg_dynapi_header_utf8text` -- the same contract as -/// [`get_text_bytes`]. Returns `None` for an unknown name or a null string. -pub fn get_header_text_bytes(dwg: *const libredwg_sys::Dwg_Data, name: &str) -> Option> { +/// Reads a text header variable (`DIMPOST`, ...) as LibreDWG hands it out, +/// via `dwg_dynapi_header_utf8text` -- the same contract as [`get_text`], +/// with `stored` the width of an unconverted string. Returns `None` for an +/// unknown name or a null string. +pub fn get_header_text( + dwg: *const libredwg_sys::Dwg_Data, + name: &str, + stored: StoredWidth, +) -> Option { if dwg.is_null() { return None; } @@ -532,14 +608,8 @@ pub fn get_header_text_bytes(dwg: *const libredwg_sys::Dwg_Data, name: &str) -> if !ok || text_ptr.is_null() { return None; } - // SAFETY: text_ptr is a valid, NUL-terminated C string per dynapi's - // contract (checked non-null above). - let owned = unsafe { CStr::from_ptr(text_ptr) }.to_bytes().to_vec(); - if is_new != 0 { - // SAFETY: as in get_text_bytes -- a malloc'd copy that is ours. - unsafe { libc::free(text_ptr.cast()) }; - } - Some(owned) + // SAFETY: as in get_text. + Some(unsafe { take_text(text_ptr, is_new != 0, stored) }) } /// Reads a `(count_field, array_field)` pair -- e.g. LWPOLYLINE's diff --git a/crates/uncad/src/lib.rs b/crates/uncad/src/lib.rs index cbcd93d..22878c3 100644 --- a/crates/uncad/src/lib.rs +++ b/crates/uncad/src/lib.rs @@ -80,12 +80,13 @@ pub enum ParseError { /// The file could not be read from disk ([`parse`] only; [`parse_bytes`] /// never produces it). Io(std::io::Error), - /// The DXF declares `$ACADVER` R2007 or later (`AC1021` and up). LibreDWG's - /// DXF importer reads these files without reporting an error but loses - /// every layer and block name on the way, so this crate refuses them - /// instead of returning a drawing that is silently incomplete. The value - /// is the `$ACADVER` string as written in the file. See `docs/CAVEATS.md`, - /// "DXF reading". + /// A DXF saved as R2007 or later (`$ACADVER` `AC1021` and up) that this + /// crate could not read: LibreDWG's importer placed entities in its model + /// or paper space, and none of them reached the model -- the way every + /// such file used to come out, as a drawing with no entities and no + /// error. The value is the `$ACADVER` string as written in the file (or + /// LibreDWG's name for the version, for a binary DXF). See + /// `docs/CAVEATS.md`, "DXF saved as R2007 or later". UnsupportedDxfVersion(String), } @@ -96,7 +97,7 @@ impl std::fmt::Display for ParseError { ParseError::Io(e) => write!(f, "cannot read the file: {e}"), ParseError::UnsupportedDxfVersion(v) => write!( f, - "DXF version {v} (R2007 or later) is not supported: LibreDWG's DXF importer would return a silently incomplete drawing -- save it as R2004 DXF or as DWG" + "DXF version {v} (R2007 or later) could not be read: none of the entities LibreDWG's DXF importer placed in model or paper space reached the drawing -- save it as R2004 DXF or as DWG" ), } } @@ -192,10 +193,11 @@ fn missing_required_groups(entities: &[Entity]) -> Vec { /// /// How complete DXF reading is depends on the entity type: LibreDWG's own /// DXF reader is documented as working "for most objects" rather than being -/// feature-complete the way DWG reading is. A DXF whose `$ACADVER` is R2007 -/// or later is refused up front with [`ParseError::UnsupportedDxfVersion`] -/// rather than handed to LibreDWG, because the importer would return it as a -/// drawing with no entities and no error. See `docs/CAVEATS.md`. +/// feature-complete the way DWG reading is. A DXF saved as R2007 or later is +/// read like any other; one whose +/// entities nonetheless reach no model or paper space is +/// [`ParseError::UnsupportedDxfVersion`] rather than an empty drawing. See +/// `docs/CAVEATS.md`. pub fn parse(path: impl AsRef) -> Result { parse_with_header(path).map(|(db, _)| db) } @@ -228,21 +230,6 @@ pub fn parse_bytes_with_header( bytes: &[u8], format: Format, ) -> Result<(CadDatabase, Header), ParseError> { - // A DXF's HEADER section, scanned from the bytes: which variables it - // states, and its $ACADVER. No lock needed -- this is plain text. - let scan = match format { - Format::Dwg => None, - Format::Dxf => header::scan_dxf_header(bytes), - }; - // Decided from the file's own header, before LibreDWG sees it: the R2007+ - // failure is silent on the C side (error code 0, zero entities), so the - // only place it can be turned into an error is here. - if let Some(acadver) = scan.as_ref().and_then(|scan| scan.acadver.as_ref()) { - if dxf_version_number(acadver).is_some_and(|n| n >= DXF_VERSION_R2007) { - return Err(ParseError::UnsupportedDxfVersion(acadver.clone())); - } - } - // See LIBREDWG_LOCK: the whole read/convert/free cycle must run without // another thread's LibreDWG call interleaved. Recovering from a poisoned // lock rather than propagating the poison is deliberate -- a panic here @@ -258,7 +245,7 @@ pub fn parse_bytes_with_header( // instance -- an uninitialized one aborts with // STATUS_STACK_BUFFER_OVERRUN (garbage in dwg.opts feeding the runtime // loglevel global). Boxed so the C side fills it in place at a stable - // heap address; it lives only until the two conversion walks below have + // heap address; it lives only until the conversion walks below have // copied out everything this crate exposes. let mut dwg: Box = Box::new(unsafe { MaybeUninit::zeroed().assume_init() }); @@ -291,17 +278,17 @@ pub fn parse_bytes_with_header( // result instead of being dropped here. let mut read_diagnostics = read_diagnostics_from_libredwg_bits(error); - // Every string the two walks below read goes through this decoder: the - // library returns a pre-R2007 string as the codepage bytes the file - // holds, and what could not be decoded is reported, never dropped. - let text = unsafe { text::TextDecoder::new(dwg.as_mut(), format == Format::Dxf) }; + // Every string the walks below read goes through this decoder: it knows + // how this drawing's strings sit in LibreDWG's memory, and what could + // not be decoded is reported, never dropped. + let text = unsafe { text::TextDecoder::new(dwg.as_mut()) }; - // Two walks over the live C structure, neither of which mutates it. + // Walks over the live C structure, none of which mutates it. // Everything the returned value exposes is an owned Rust copy by the end. let entities = unsafe { convert::convert_entities(dwg.as_mut(), &text) }; let tables = unsafe { table_convert::convert_tables(dwg.as_mut(), &text) }; - let (acadver, stated) = match (format, scan) { - (Format::Dwg, _) => ( + let (acadver, stated) = match format { + Format::Dwg => ( header::dwg_magic(bytes), // SAFETY: the shims read file-header fields of a live Dwg_Data. header::Stated::Dwg { @@ -310,10 +297,27 @@ pub fn parse_bytes_with_header( template_read: unsafe { libredwg_sys::uncad_dwg_template_read(dwg.as_mut()) } != 0, }, ), - (Format::Dxf, Some(scan)) => (scan.acadver, header::Stated::Dxf(scan.variables)), - (Format::Dxf, None) => (None, header::Stated::Unknown), + Format::Dxf => match header::scan_dxf_header(bytes) { + Some(scan) => (scan.acadver, header::Stated::Dxf(scan.variables)), + None => (None, header::Stated::Unknown), + }, }; let header = unsafe { header::read_header(dwg.as_mut(), &text, format, acadver, &stated) }; + + // The one way an R2007+ DXF used to fail was silently: the importer + // stored its strings in a width the lookups did not expect, no block + // record was found by name, and the result was a drawing with no + // entities and no error. The width is handled now (text.rs, and the + // vendored dwg.c patch for the importer's own lookups); should a file + // still come out that way, it is an error, not an empty drawing. + let unplaced = entities_went_missing( + format, + is_r2007_or_later(dwg.as_mut()), + entities.len(), + // SAFETY: the Dwg_Data is live until the dwg_free below. + || unsafe { entities_placed_in_a_space(dwg.as_mut()) }, + ); + read_diagnostics.warnings.extend(text.into_warnings()); read_diagnostics .warnings @@ -325,6 +329,15 @@ pub fn parse_bytes_with_header( // Drop, no C memory, Send + Sync by construction). unsafe { libredwg_sys::dwg_free(dwg.as_mut()) }; + if unplaced { + let version = header + .acadver + .clone() + .or_else(|| header.version.clone()) + .unwrap_or_default(); + return Err(ParseError::UnsupportedDxfVersion(version)); + } + Ok(( CadDatabase { entities, @@ -335,17 +348,54 @@ pub fn parse_bytes_with_header( )) } -/// `$ACADVER` value of the first DXF release LibreDWG's importer reads back -/// incompletely: `AC1021` = R2007, the release that switched DXF strings to -/// UTF-16 in the importer's storage. Every later code (`AC1024`, `AC1027`, -/// `AC1032`, ...) is numerically above it. -const DXF_VERSION_R2007: u32 = 1021; - -/// The numeric part of an `$ACADVER` code (`AC1021` -> `1021`), or `None` for -/// anything that is not shaped like one. An unrecognised value never rejects a -/// file: the decision falls back to LibreDWG, as it did before this check. -fn dxf_version_number(acadver: &str) -> Option { - acadver.strip_prefix("AC")?.parse().ok() +/// The guard against the silent R2007+ DXF failure: a DXF saved as R2007 or +/// later whose model holds no entity although LibreDWG placed some in model +/// or paper space. Scoped to R2007+ DXF because that is where the failure +/// lived: measured on the corpus, the same test applied to every input would +/// also refuse two pre-R13 DWGs (`r11/ACEB10.dwg`, `r2.10/block.dwg`) that +/// read today with an empty model space -- a different question, and not an +/// error this change is entitled to introduce. +fn entities_went_missing( + format: Format, + r2007_or_later: bool, + converted: usize, + placed_in_a_space: impl FnOnce() -> usize, +) -> bool { + format == Format::Dxf && r2007_or_later && converted == 0 && placed_in_a_space() > 0 +} + +/// Whether the drawing was read from an R2007-or-later source. +fn is_r2007_or_later(dwg: *mut libredwg_sys::Dwg_Data) -> bool { + // SAFETY: the shim reads one header field of a live Dwg_Data. + let version = unsafe { libredwg_sys::uncad_dwg_from_version(dwg) }; + #[allow(clippy::unnecessary_cast)] + let r2007 = libredwg_sys::DWG_VERSION_TYPE_R_2007 as i32; + version >= r2007 +} + +/// How many entities LibreDWG itself placed in model or paper space +/// (`entmode` 2 or 1) -- found without any name lookup, so a drawing whose +/// block records failed to resolve by name still counts what it holds. +/// +/// # Safety +/// `dwg` must be a live, successfully read `Dwg_Data`. +unsafe fn entities_placed_in_a_space(dwg: *mut libredwg_sys::Dwg_Data) -> usize { + let num_objects = unsafe { libredwg_sys::dwg_get_num_objects(dwg) }; + (0..num_objects) + .filter(|&i| { + // SAFETY: i is below the object count of the live Dwg_Data; the + // shim answers NULL for an object that is not an entity. + let obj = unsafe { libredwg_sys::dwg_get_object(dwg, i) }; + if obj.is_null() { + return false; + } + let entity = unsafe { libredwg_sys::uncad_object_entity_ptr(obj) }; + matches!( + dynapi::get_common_field::(entity, "entmode"), + Some(1 | 2) + ) + }) + .count() } #[cfg(test)] @@ -404,3 +454,56 @@ mod missing_required_groups_tests { assert!(!message.contains(" "), "{message}"); } } + +#[cfg(test)] +mod unplaced_entities_tests { + use super::*; + + #[test] + fn only_an_r2007_plus_dxf_whose_placed_entities_all_went_missing_is_refused() { + assert!(entities_went_missing(Format::Dxf, true, 0, || 72)); + // Something reached the model, or nothing was there to reach it. + assert!(!entities_went_missing(Format::Dxf, true, 1, || 72)); + assert!(!entities_went_missing(Format::Dxf, true, 0, || 0)); + // Not the case the guard is for; the count is not even taken. + assert!(!entities_went_missing( + Format::Dxf, + false, + 0, + || unreachable!() + )); + assert!(!entities_went_missing( + Format::Dwg, + true, + 0, + || unreachable!() + )); + } + + /// The count the guard compares against comes from LibreDWG's own + /// placement of each entity, not from any name lookup: it is there for + /// an R2007+ DXF whatever its strings did. + #[test] + fn libredwg_places_an_r2018_dxfs_entities_without_a_name_lookup() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2018.dxf" + ); + let bytes = std::fs::read(path).expect("the corpus DXF is readable"); + let _guard = LIBREDWG_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut dwg: Box = + Box::new(unsafe { MaybeUninit::zeroed().assume_init() }); + let error = unsafe { + libredwg_sys::uncad_dxf_read_bytes(bytes.as_ptr(), bytes.len(), dwg.as_mut()) + }; + assert_eq!(error, 0); + let placed = unsafe { entities_placed_in_a_space(dwg.as_mut()) }; + let r2007 = is_r2007_or_later(dwg.as_mut()); + unsafe { libredwg_sys::dwg_free(dwg.as_mut()) }; + assert!(r2007); + // 69 on this file when measured; any count at all is the point. + assert!(placed > 0, "{placed}"); + } +} diff --git a/crates/uncad/src/text.rs b/crates/uncad/src/text.rs index f70d0ec..8d0d33d 100644 --- a/crates/uncad/src/text.rs +++ b/crates/uncad/src/text.rs @@ -1,23 +1,41 @@ -//! Text fields as UTF-8 `String`s, decoded here from the bytes LibreDWG holds. +//! Text fields as UTF-8 `String`s, decoded here from what LibreDWG holds. //! -//! A pre-R2007 drawing stores its strings (`TV`) as 8-bit bytes in the -//! drawing's codepage -- `header.codepage`, read from the DWG header or from -//! DXF `$DWGCODEPAGE` -- and LibreDWG keeps them that way. Its `utf8text` and -//! `handle_name` accessors convert only the UTF-16 strings (`TU`) of R2007 -//! and later; for an older drawing they hand back the codepage bytes -//! unchanged. Read as UTF-8, every non-ASCII character of a CP949 or CP1252 -//! drawing came out as mojibake or U+FFFD, with clean diagnostics. +//! How a drawing's strings sit in LibreDWG's memory depends on where they +//! came from, and the library's text accessors (`dwg_dynapi_*_utf8text`, +//! `dwg_dynapi_handle_name`) convert only one of the cases: //! -//! [`TextDecoder`] is the one place bytes become `String`s. It decodes -//! through the library's own codepage tables, and whatever it could not -//! decode is reported in `read_diagnostics` (`TEXT_ENCODING: ...`) rather -//! than replaced in silence: a string that is wrong is never presented as -//! one that is right. +//! - **R2007 and later, from DWG:** stored as UTF-16 (`TU`); the accessors +//! convert them to UTF-8 themselves. +//! - **Before R2007:** stored as the file's own 8-bit bytes (`TV`) in the +//! drawing's codepage -- `header.codepage`, read from the DWG header or +//! from DXF `$DWGCODEPAGE` -- and handed out unchanged, whatever the +//! `utf8` in the accessor's name says. Read as UTF-8, every non-ASCII +//! character of a CP949 or CP1252 drawing came out as mojibake or U+FFFD, +//! with clean diagnostics. +//! - **R2007 and later, from DXF:** the DXF importer writes every string it +//! sets through the library's field setter as UTF-16, exactly as for a DWG +//! -- but the accessors convert only for a DWG, so they hand the UTF-16 +//! out as if it were an 8-bit string, which stops at its first NUL byte +//! (`*Model_Space` read as `*`). The exceptions are the strings the +//! importer copies byte for byte: MTEXT's text (groups 1 and 3) and the +//! HEADER variables, parsed before the importer knows the version. Those +//! are the file's own bytes -- UTF-8, since an R2007+ DXF is UTF-8 -- and +//! must never be read as UTF-16, which would look for a 16-bit NUL past +//! their allocation. +//! +//! [`TextDecoder`] is the one place any of this becomes a `String`. It knows +//! which case the drawing is, reads each stored string in the width it was +//! stored in (`crate::dynapi::StoredWidth`), decodes 8-bit bytes through the +//! library's own codepage tables, and reports whatever it could not decode +//! in `read_diagnostics` (`TEXT_ENCODING: ...`) rather than replacing it in +//! silence: a string that is wrong is never presented as one that is right. +//! Escape sequences in the text (`\U+XXXX`, `\M+nXXXX`) are text as far as +//! the model is concerned and are passed through as written. use std::cell::RefCell; use std::ffi::{c_void, CStr}; -use crate::dynapi; +use crate::dynapi::{self, RawText, StoredWidth}; /// `Dwg_Codepage` values this module treats specially; the numbers are the /// `header.codepage` values LibreDWG's `codepages.h` fixes them to. @@ -157,40 +175,78 @@ pub fn decode_codepage(bytes: &[u8], codepage: u16) -> (String, usize) { (text, unmapped) } +/// How one drawing's strings sit in LibreDWG's memory -- see the module doc. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Storage { + /// Read from an R2007+ DWG: the accessors convert from UTF-16 themselves. + WideDwg, + /// Read from an R2007+ DXF: the strings the importer set are UTF-16 the + /// accessors do not convert; those it copied are the file's UTF-8. + WideDxf, + /// Before R2007: 8-bit bytes in the drawing's codepage. `utf8_first` for + /// a DXF, whose importer keeps the file's bytes and assumes UTF-8 -- + /// LibreDWG's own DXF writer emits UTF-8 text whatever `$DWGCODEPAGE` it + /// declares -- so bytes that are valid UTF-8 are taken as UTF-8 first and + /// the codepage is tried only for the rest. A DWG never holds UTF-8 in a + /// codepage string, so there the codepage always applies. + Narrow { utf8_first: bool }, +} + +/// The fields LibreDWG's DXF importer copies from the file byte for byte +/// whatever its version, so that in an R2007+ DXF they are 8-bit while every +/// other string is UTF-16: `in_dxf.c` special-cases MTEXT's group 1/3 text +/// chunks (`strdup`, then `realloc` + `memcpy`, no `bit_utf8_to_TU`). +/// Everything else this crate reads goes through the library's field +/// setter, which widens once `header.version` is R2007 or later. (The HEADER +/// variables are the other byte-for-byte case; they are read through +/// [`TextDecoder::header_text`].) +fn stored_8bit_in_dxf(dxfname: &str, field: &str) -> bool { + matches!((dxfname, field), ("MTEXT", "text")) +} + /// Decodes every string of one drawing and collects what it could not /// decode. One per `parse()`, created once the drawing is read (its codepage /// and version are known then) and consumed after the last string. pub struct TextDecoder { codepage: u16, - /// R2007 and later: the library has already converted every string from - /// UTF-16 to UTF-8, so the bytes are UTF-8 and the codepage is moot. - wide: bool, - /// Read from DXF: the importer keeps the file's bytes as they are and - /// assumes UTF-8 -- LibreDWG's own DXF writer emits UTF-8 text whatever - /// `$DWGCODEPAGE` it declares -- so bytes that are valid UTF-8 are taken - /// as UTF-8 first and the codepage is tried only for the rest. A DWG - /// never holds UTF-8 in a codepage string, so there the codepage always - /// applies. - utf8_first: bool, + storage: Storage, warnings: RefCell>, } impl TextDecoder { /// # Safety /// `dwg` must be a live, successfully read `Dwg_Data`. - pub unsafe fn new(dwg: *mut libredwg_sys::Dwg_Data, from_dxf: bool) -> Self { - // SAFETY: the shim reads two header fields of a live Dwg_Data - // (caller contract) and null-checks it itself. - let (codepage, wide) = unsafe { + pub unsafe fn new(dwg: *mut libredwg_sys::Dwg_Data) -> Self { + // SAFETY: the shim reads header fields of a live Dwg_Data (caller + // contract) and null-checks it itself. + let (codepage, wide_dwg, from_dxf, version) = unsafe { ( libredwg_sys::uncad_dwg_codepage(dwg), libredwg_sys::uncad_dwg_is_wide_string(dwg) != 0, + libredwg_sys::uncad_dwg_from_dxf(dwg) != 0, + libredwg_sys::uncad_dwg_version(dwg), ) }; + // The library's own rule for when its field setter widens a string: + // the drawing's version (`header.version`, which the DXF importer + // sets from `$ACADVER` once the HEADER section is read), not where + // the data came from. Cast the constant, not the version: see + // convert.rs's DWG_OBJECT_TYPE comment for why the enum's width + // differs between targets. + #[allow(clippy::unnecessary_cast)] + let r2007_or_later = version >= libredwg_sys::DWG_VERSION_TYPE_R_2007 as i32; + let storage = if wide_dwg { + Storage::WideDwg + } else if from_dxf && r2007_or_later { + Storage::WideDxf + } else { + Storage::Narrow { + utf8_first: from_dxf, + } + }; TextDecoder { codepage, - wide, - utf8_first: from_dxf, + storage, warnings: RefCell::new(Vec::new()), } } @@ -215,30 +271,74 @@ impl TextDecoder { } } + /// The width of a `dxfname.field` string the library hands out + /// unconverted. For an R2007+ DWG that happens only when its accessor + /// could not find the object the pointer belongs to (a struct embedded + /// in another object), and the string is then the stored UTF-16. + fn stored_width(&self, dxfname: &str, field: &str) -> StoredWidth { + match self.storage { + Storage::WideDwg => StoredWidth::Wide, + Storage::WideDxf if !stored_8bit_in_dxf(dxfname, field) => StoredWidth::Wide, + Storage::WideDxf | Storage::Narrow { .. } => StoredWidth::Bytes, + } + } + /// A text field (`BITCODE_T`/`TV`/`TU`) of an entity or object, decoded. /// `None` only when the field does not exist or is a null string; a /// string that is present always comes back, decoded as far as it can be. pub fn field(&self, entity: *mut c_void, dxfname: &str, field: &str) -> Option { - let bytes = dynapi::get_text_bytes(entity, dxfname, field)?; - Some(self.decode(&bytes, || { + let raw = dynapi::get_text(entity, dxfname, field, self.stored_width(dxfname, field))?; + Some(self.decode_raw(raw, || { // SAFETY: entity is the type-specific struct pointer of a live - // object (get_text_bytes just read a field through it); the - // accessor answers 0 when it cannot find the owning object. + // object (get_text just read a field through it); the accessor + // answers 0 when it cannot find the owning object. let handle = unsafe { libredwg_sys::dwg_obj_generic_handlevalue(entity) }; format!("{dxfname}.{field} (handle {handle:X})") })) } + /// A text field read as a raw pointer rather than through the library's + /// text accessor -- a field of a struct embedded in another object + /// (LAYOUT's `plotsettings`), which the accessor cannot place -- + /// decoded by the same rules as [`field`](Self::field). `None` for a null + /// pointer. + /// + /// # Safety + /// `ptr` must be null or the value of the `dxfname.field` string field + /// of an object of the drawing this decoder was made for, still live. + #[allow( + dead_code, + reason = "the reader for embedded structs (LAYOUT's plot settings) is ported separately" + )] + pub unsafe fn stored_field( + &self, + ptr: *const std::os::raw::c_char, + dxfname: &str, + field: &str, + ) -> Option { + if ptr.is_null() { + return None; + } + // Nothing has converted this one: an R2007+ DWG's string is its + // stored UTF-16 here, which stored_width already answers. + let width = self.stored_width(dxfname, field); + // SAFETY: a live string field of this drawing (caller contract), + // read in the width this drawing stores it in. + let raw = unsafe { dynapi::read_stored(ptr, width) }; + Some(self.decode_raw(raw, || format!("{dxfname}.{field}"))) + } + /// The name of whatever a handle reference points at, decoded -- see - /// `dynapi::handle_name_bytes`. + /// `dynapi::handle_name`. Such a name is a table record's or an + /// object's, which the importer always sets through the field setter. pub fn handle_name( &self, dwg: *mut libredwg_sys::Dwg_Data, handle: *mut libredwg_sys::Dwg_Object_Ref, ) -> Option { - let bytes = dynapi::handle_name_bytes(dwg, handle)?; - Some(self.decode(&bytes, || { - // SAFETY: handle is non-null (handle_name_bytes returned) and a + let raw = dynapi::handle_name(dwg, handle, self.stored_width("", "name"))?; + Some(self.decode_raw(raw, || { + // SAFETY: handle is non-null (handle_name returned) and a // Dwg_Object_Ref the live Dwg_Data owns. let value = unsafe { (*handle).absolute_ref }; format!("name of handle {value:X}") @@ -246,7 +346,9 @@ impl TextDecoder { } /// The name of the table entry a reference points at, decoded -- see - /// `dynapi::table_entry_name_bytes`. + /// `dynapi::table_entry_name_bytes`. The library converts the name itself + /// for every R2007+ drawing (for a DXF through the vendored `dwg.c` + /// patch), so only a pre-R2007 one is left as codepage bytes. pub fn table_entry_name( &self, dwg: *mut libredwg_sys::Dwg_Data, @@ -254,7 +356,11 @@ impl TextDecoder { table: &CStr, ) -> Option { let bytes = dynapi::table_entry_name_bytes(dwg, handle, table)?; - Some(self.decode(&bytes, || { + let raw = match self.storage { + Storage::WideDwg | Storage::WideDxf => RawText::Converted(bytes), + Storage::Narrow { .. } => RawText::Bytes(bytes), + }; + Some(self.decode_raw(raw, || { let value = unsafe { (*handle).absolute_ref }; format!( "{} entry of handle {value:X}", @@ -263,34 +369,67 @@ impl TextDecoder { })) } - /// A text header variable (`DIMPOST`, ...), decoded -- see - /// `dynapi::get_header_text_bytes`. + /// A text header variable (`DIMPOST`, ...), decoded. The DXF importer + /// stores these 8-bit whatever the version: it reads the HEADER section + /// before `$ACADVER` has told it to widen anything. pub fn header_text(&self, dwg: *const libredwg_sys::Dwg_Data, name: &str) -> Option { - let bytes = dynapi::get_header_text_bytes(dwg, name)?; - Some(self.decode(&bytes, || format!("header variable ${name}"))) + let raw = dynapi::get_header_text(dwg, name, StoredWidth::Bytes)?; + Some(self.decode_raw(raw, || format!("header variable ${name}"))) } - /// The decoding rule. `what` names the string for a diagnostic and is - /// only evaluated when one is written. - pub fn decode(&self, bytes: &[u8], what: impl FnOnce() -> String) -> String { - if bytes.is_ascii() { - // ASCII is the same in every codepage this crate meets. - return String::from_utf8_lossy(bytes).into_owned(); - } - if self.wide || self.codepage == CP_UTF8 { - return match std::str::from_utf8(bytes) { - Ok(text) => text.to_string(), + /// Turns what the library handed out into a `String` by this drawing's + /// rule; `what` names the string for a diagnostic. + fn decode_raw(&self, raw: RawText, what: impl FnOnce() -> String) -> String { + match raw { + RawText::Converted(bytes) => self.decode_utf8(&bytes, what), + RawText::Wide(units) => match String::from_utf16(&units) { + Ok(text) => text, Err(_) => { self.warn(format!( - "TEXT_ENCODING: {}: not valid UTF-8 although the drawing's strings are \ - UTF-8; invalid sequences replaced with U+FFFD", + "TEXT_ENCODING: {}: not valid UTF-16; unpaired surrogates replaced \ + with U+FFFD", what() )); - String::from_utf8_lossy(bytes).into_owned() + String::from_utf16_lossy(&units) } - }; + }, + RawText::Bytes(bytes) => self.decode(&bytes, what), + } + } + + /// Bytes that are UTF-8 by the drawing's own account, checked. + fn decode_utf8(&self, bytes: &[u8], what: impl FnOnce() -> String) -> String { + match std::str::from_utf8(bytes) { + Ok(text) => text.to_string(), + Err(_) => { + self.warn(format!( + "TEXT_ENCODING: {}: not valid UTF-8 although the drawing's strings are \ + UTF-8; invalid sequences replaced with U+FFFD", + what() + )); + String::from_utf8_lossy(bytes).into_owned() + } + } + } + + /// The decoding rule for an 8-bit string. `what` names the string for a + /// diagnostic and is only evaluated when one is written. + pub fn decode(&self, bytes: &[u8], what: impl FnOnce() -> String) -> String { + if bytes.is_ascii() { + // ASCII is the same in every codepage this crate meets. + return String::from_utf8_lossy(bytes).into_owned(); } - if self.utf8_first { + let utf8_first = match self.storage { + // An R2007+ drawing's 8-bit strings are UTF-8: the converted + // ones by construction, the ones an R2007+ DXF keeps as they + // are because such a file is UTF-8. + Storage::WideDwg | Storage::WideDxf => return self.decode_utf8(bytes, what), + Storage::Narrow { .. } if self.codepage == CP_UTF8 => { + return self.decode_utf8(bytes, what) + } + Storage::Narrow { utf8_first } => utf8_first, + }; + if utf8_first { if let Ok(text) = std::str::from_utf8(bytes) { return text.to_string(); } @@ -332,15 +471,18 @@ mod tests { const ANSI_950: u16 = 41; const ANSI_932: u16 = 38; - fn decoder(codepage: u16, wide: bool, utf8_first: bool) -> TextDecoder { + fn decoder(codepage: u16, storage: Storage) -> TextDecoder { TextDecoder { codepage, - wide, - utf8_first, + storage, warnings: RefCell::new(Vec::new()), } } + fn narrow(codepage: u16, utf8_first: bool) -> TextDecoder { + decoder(codepage, Storage::Narrow { utf8_first }) + } + #[test] fn cp949_hangul_decodes_to_the_unicode_syllables() { // "drawing number" in Korean: four syllables, two bytes each. @@ -432,7 +574,7 @@ mod tests { #[test] fn the_decoder_reports_what_it_could_not_decode_once() { - let d = decoder(ANSI_1252, false, false); + let d = narrow(ANSI_1252, false); assert_eq!(d.decode(b"plain", || unreachable!()), "plain"); assert_eq!(d.decode(b"caf\xE9", || unreachable!()), "caf\u{E9}"); assert_eq!( @@ -450,7 +592,7 @@ mod tests { "{}", warnings[0] ); - assert!(warnings[0].contains("ANSI_1252"), "{}", warnings[0]); + assert!(warnings[0].contains("ANSI_1252 (30)"), "{}", warnings[0]); } #[test] @@ -458,48 +600,84 @@ mod tests { // The same bytes are CP949 in a DWG and UTF-8 in a DXF import. let utf8 = "\u{B3C4}\u{BA74}".as_bytes(); assert_eq!( - decoder(ANSI_949, false, true).decode(utf8, || unreachable!()), + narrow(ANSI_949, true).decode(utf8, || unreachable!()), "\u{B3C4}\u{BA74}" ); let (as_cp949, _) = decode_codepage(utf8, ANSI_949); assert_eq!( - decoder(ANSI_949, false, false).decode(utf8, || "x".into()), + narrow(ANSI_949, false).decode(utf8, || "x".into()), as_cp949 ); assert_ne!(as_cp949, "\u{B3C4}\u{BA74}"); } #[test] - fn an_undefined_codepage_never_reaches_the_tables() { - let d = decoder(0xFF, false, false); - assert_eq!( - d.decode("caf\u{E9}".as_bytes(), || unreachable!()), - "caf\u{E9}" - ); - assert_eq!( - d.decode(b"caf\xE9", || "LAYER.name (handle 2)".into()), - "caf\u{FFFD}" - ); - let warnings = d.into_warnings(); - assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("undefined"), "{}", warnings[0]); + fn an_undefined_or_corrupt_codepage_never_reaches_the_tables() { + for (codepage, label) in [(0xFF, "undefined (255)"), (100, "codepage 100 ")] { + let d = narrow(codepage, false); + assert_eq!( + d.decode("caf\u{E9}".as_bytes(), || unreachable!()), + "caf\u{E9}" + ); + assert_eq!( + d.decode(b"caf\xE9", || "LAYER.name (handle 2)".into()), + "caf\u{FFFD}" + ); + let warnings = d.into_warnings(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains(label), "{}", warnings[0]); + } + } + + #[test] + fn a_wide_string_drawing_is_utf8_whatever_its_codepage_says() { + for storage in [Storage::WideDwg, Storage::WideDxf] { + let d = decoder(ANSI_949, storage); + assert_eq!( + d.decode("\u{B3C4}".as_bytes(), || unreachable!()), + "\u{B3C4}" + ); + assert!(d.into_warnings().is_empty()); + // Bytes that are not UTF-8 are reported, never guessed at. + let d = decoder(ANSI_949, storage); + assert_eq!( + d.decode(b"\xB5\xB5", || "MTEXT.text".into()), + "\u{FFFD}\u{FFFD}" + ); + assert_eq!(d.into_warnings().len(), 1); + } + } + + #[test] + fn an_r2007_dxf_reads_its_stored_strings_in_the_width_the_importer_wrote() { + let d = decoder(CP_UTF16, Storage::WideDxf); + assert_eq!(d.stored_width("LAYER", "name"), StoredWidth::Wide); + assert_eq!(d.stored_width("TEXT", "text_value"), StoredWidth::Wide); + assert_eq!(d.stored_width("MTEXT", "text"), StoredWidth::Bytes); + let d = narrow(ANSI_1252, true); + assert_eq!(d.stored_width("LAYER", "name"), StoredWidth::Bytes); + } + + #[test] + fn a_stored_utf16_string_is_read_to_its_16_bit_nul() { + // "가A" then a 16-bit NUL: the first byte of U+AC00 in memory is 0x00, + // where an 8-bit read would have stopped with nothing. + let units: [u16; 3] = [0xAC00, 0x0041, 0]; + let d = decoder(CP_UTF16, Storage::WideDxf); + let got = unsafe { d.stored_field(units.as_ptr().cast(), "LAYOUT", "layout_name") }; + assert_eq!(got.as_deref(), Some("\u{AC00}A")); + // An unpaired surrogate is replaced and reported. + let units: [u16; 2] = [0xD800, 0]; + let got = unsafe { d.stored_field(units.as_ptr().cast(), "LAYOUT", "layout_name") }; + assert_eq!(got.as_deref(), Some("\u{FFFD}")); + assert_eq!(d.into_warnings().len(), 1); } #[test] fn codepage_names_are_the_librarys_and_unknown_ones_have_none() { assert_eq!(codepage_name(ANSI_1252).as_deref(), Some("ANSI_1252")); - assert_eq!(codepage_name(ANSI_949).as_deref(), Some("ANSI_949")); + assert_eq!(codepage_name(CP_GB2312).as_deref(), Some("GB2312")); assert_eq!(codepage_name(100), None); assert_eq!(codepage_name(0xFF), None); } - - #[test] - fn a_wide_string_drawing_is_utf8_whatever_its_codepage_says() { - let d = decoder(ANSI_949, true, false); - assert_eq!( - d.decode("\u{B3C4}".as_bytes(), || unreachable!()), - "\u{B3C4}" - ); - assert!(d.into_warnings().is_empty()); - } } diff --git a/crates/uncad/tests/codepage.rs b/crates/uncad/tests/codepage.rs index 7baaad2..c4829ac 100644 --- a/crates/uncad/tests/codepage.rs +++ b/crates/uncad/tests/codepage.rs @@ -2,8 +2,9 @@ //! pages keep every character, a byte the declared code page has no //! character for is U+FFFD *and* a `TEXT_ENCODING` warning, the DOS-era //! double-byte pages (Big5, GB2312, CP932) pair only the bytes that can -//! pair, and a code page LibreDWG has no table for is reported and read as -//! UTF-8. The 8-bit fixtures are written by the +//! pair, a code page LibreDWG has no table for is reported and read as +//! UTF-8, and an R2007+ DXF's strings are read in the width LibreDWG's DXF +//! importer stored each of them in. The 8-bit fixtures are written by the //! tests themselves from group codes, so the expected strings are the ones //! the test encoded; `text.rs` has the byte-level cases. @@ -226,3 +227,137 @@ fn a_corrupt_code_page_in_the_file_header_is_reported_not_guessed() { ); } } + +// --- R2007 and later: strings in the width the DXF importer stored them --- + +const EXAMPLE_2018_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2018.dwg" +); +const EXAMPLE_2007_DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2007.dxf" +); +const EXAMPLE_2018_DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2018.dxf" +); +const TEXT_2007_DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/2007/Text.dxf" +); +const LEADER_2018_DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/2018/Leader.dxf" +); + +/// A DXF of version `acadver` (no `$DWGCODEPAGE`) with one TEXT per entry +/// of `texts` and one MTEXT per entry of `mtexts`, the string bytes written +/// as they are. +fn dxf_with_strings(acadver: &str, texts: &[&[u8]], mtexts: &[&[u8]]) -> Vec { + let mut dxf = Vec::new(); + dxf.extend_from_slice(b" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\n"); + dxf.extend_from_slice(acadver.as_bytes()); + dxf.extend_from_slice(b"\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n"); + for (i, text) in texts.iter().enumerate() { + dxf.extend_from_slice(b" 0\nTEXT\n 8\n0\n 10\n0.0\n 20\n"); + dxf.extend_from_slice(format!("{}.0\n", i * 5).as_bytes()); + dxf.extend_from_slice(b" 40\n2.5\n 1\n"); + dxf.extend_from_slice(text); + dxf.extend_from_slice(b"\n"); + } + for (i, text) in mtexts.iter().enumerate() { + dxf.extend_from_slice(b" 0\nMTEXT\n 8\n0\n100\nAcDbMText\n 10\n50.0\n 20\n"); + dxf.extend_from_slice(format!("{}.0\n", i * 5).as_bytes()); + dxf.extend_from_slice(b" 40\n2.5\n 41\n50.0\n 71\n1\n 1\n"); + dxf.extend_from_slice(text); + dxf.extend_from_slice(b"\n"); + } + for i in 0..8 { + dxf.extend_from_slice( + format!(" 0\nLINE\n 8\n0\n 10\n{i}.0\n 20\n0.0\n 11\n{i}.0\n 21\n1.0\n").as_bytes(), + ); + } + dxf.extend_from_slice(b" 0\nENDSEC\n 0\nEOF\n"); + dxf +} + +fn mtext_values(db: &CadDatabase) -> Vec { + db.entities + .iter() + .filter_map(|e| match e { + Entity::MText(m) => Some(m.text.clone()), + _ => None, + }) + .collect() +} + +#[test] +fn r2007_and_later_dxf_input_decodes_its_utf16_strings() { + for (path, version) in [(TEXT_2007_DXF, "r2007"), (LEADER_2018_DXF, "r2018")] { + let (db, header) = uncad::parse_with_header(path).expect("corpus file must parse"); + assert_eq!(header.version.as_deref(), Some(version), "{path}"); + // Read 8-bit, every block name stopped at the first NUL byte of its + // UTF-16 storage: "*Model_Space" read as "*", nothing was selected. + assert!( + db.tables.block_records.contains_key("*Model_Space"), + "{path}: {:?}", + db.tables.block_records.keys().collect::>() + ); + assert!(!db.entities.is_empty(), "{path} should project to entities"); + assert!(text_encoding_warnings(&db).is_empty(), "{path}"); + } +} + +#[test] +fn r2007_and_later_dxf_mtext_is_read_as_the_utf8_it_is_stored_as() { + // in_dxf.c stores MTEXT's group 1/3 text 8-bit (strdup) with no R2007 + // branch, unlike every other string, which becomes UTF-16. Reading it as + // UTF-16 gave "\u{6554}\u{736b}..." ("Te", "ks" as one unit each) plus + // whatever heap bytes followed the NUL. The reference is the same + // drawing as a DWG, which LibreDWG converts itself. + let dwg = uncad::parse(EXAMPLE_2018_DWG).expect("corpus file must parse"); + let mut expected = mtext_values(&dwg); + expected.sort(); + assert_eq!( + expected, + vec!["Teksto granda nur por testi.\\PAlia linio.\\PAlia pli.".to_string()], + "the reference drawing's MTEXT, as the DXF twin spells it too" + ); + for path in [EXAMPLE_2007_DXF, EXAMPLE_2018_DXF] { + let db = uncad::parse(path).expect("corpus file must parse"); + let mut got = mtext_values(&db); + got.sort(); + assert_eq!(got, expected, "{path}"); + } +} + +#[test] +fn an_r2018_dxf_carries_its_non_ascii_text_as_utf8_and_its_escapes_as_written() { + // An AC1021+ DXF is UTF-8 by definition. "가나" is U+AC00 U+B098, the + // bytes EA B0 80 EB 82 98. TEXT goes through the importer's UTF-16 + // storage, MTEXT stays 8-bit: both must come back the same. The escaped + // spelling older writers use is text too, kept as written in both. + let hangul: &[u8] = b"\xEA\xB0\x80\xEB\x82\x98 AB"; + let escaped: &[u8] = b"\\U+AC00\\U+B098 AB"; + let bytes = dxf_with_strings("AC1032", &[hangul, escaped], &[hangul, escaped]); + let (db, header) = + uncad::parse_bytes_with_header(&bytes, Format::Dxf).expect("the DXF must parse"); + assert_eq!(header.version.as_deref(), Some("r2018")); + let expected = vec!["가나 AB".to_string(), "\\U+AC00\\U+B098 AB".to_string()]; + assert_eq!(mtext_values(&db), expected); + assert_eq!(text_values(&db), expected); + assert!(text_encoding_warnings(&db).is_empty()); +} + +#[test] +fn an_r2018_dxf_mtext_that_is_not_utf8_is_reported() { + // CP949 bytes in a file that must be UTF-8: kept as U+FFFD and said so, + // not guessed at through a code page the file does not use. + let bytes = dxf_with_strings("AC1032", &[], &[b"\xB5\xB5\xB8\xE9"]); + let db = uncad::parse_bytes(&bytes, Format::Dxf).expect("the DXF must parse"); + assert_eq!(mtext_values(&db), vec!["\u{FFFD}".repeat(4)]); + let warnings = text_encoding_warnings(&db); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("MTEXT.text"), "{}", warnings[0]); +} diff --git a/crates/uncad/tests/corpus_sweep.rs b/crates/uncad/tests/corpus_sweep.rs index ef6adc5..1398f98 100644 --- a/crates/uncad/tests/corpus_sweep.rs +++ b/crates/uncad/tests/corpus_sweep.rs @@ -203,8 +203,13 @@ fn the_corpus_distribution_is_what_it_was_when_last_measured() { } // --- how the files read --- + // (31, 32, 4) while every R2007+ DXF was refused. Read now: 27 of the 32 + // parse, and the other 5 (2013/gh109_1, 2018/Constraints, Dynblocks, + // LiveSection1, TS1) fail inside LibreDWG's importer with critical + // error 2048. None trips the guard against an R2007+ DXF whose entities + // all go missing (`dxf_refused`). assert_eq!((s.files, s.dwg_parsed), (208, 141)); - assert_eq!((s.dxf_parsed, s.dxf_refused, s.critical), (31, 32, 4)); + assert_eq!((s.dxf_parsed, s.dxf_refused, s.critical), (58, 0, 9)); // --- LibreDWG's non-fatal error bits, which used to be discarded --- assert_eq!(s.clean_dwg, 100); @@ -233,8 +238,11 @@ fn the_corpus_distribution_is_what_it_was_when_last_measured() { // 64,697 before the attribute-chain fix: 36 more entities (all ATTDEFs in // blocks with several of them, in the R2000 and R13/R14 files) are read // now that this crate walks the R13..R2000 block chain itself instead of - // through the library's walker, which skipped them. - assert_eq!(layers, 64_733); + // through the library's walker, which skipped them. 64,733 then; 1,331 + // more since the 27 readable R2007+ DXFs are read instead of refused -- + // all resolved but the 21 of 2010/gh209_1.dxf, whose entities LibreDWG's + // importer leaves without a layer handle (absent, as the model says). + assert_eq!(layers, 66_064); // --- reference IDs: the handle-derived scheme yields no duplicate in any // file, and the index fallback is measured, not assumed --- diff --git a/crates/uncad/tests/dxf_pipeline.rs b/crates/uncad/tests/dxf_pipeline.rs index 39962e6..18d5c39 100644 --- a/crates/uncad/tests/dxf_pipeline.rs +++ b/crates/uncad/tests/dxf_pipeline.rs @@ -17,6 +17,7 @@ //! stay at the level of properties a drawing file has by construction, rather //! than counts copied out of this project's own output. +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -201,14 +202,15 @@ fn no_corpus_example_makes_the_parser_panic() { assert!(seen >= 5, "expected the corpus example DXFs, found {seen}"); } -// --- R2007+ DXF is refused, and only R2007+ ------------------------------- +// --- R2007+ DXF reads, and reads like its DWG twin ------------------------ // -// LibreDWG reads a DXF saved as R2007 or later without any error and hands -// back a drawing with no entities (`docs/CAVEATS.md`, "DXF reading"). The -// crate now decides from `$ACADVER` before LibreDWG sees the file. Two tests -// pin that decision from both sides: an input that must be refused, and one -// that must keep reading -- a check that only looked at the refusal could not -// tell "R2007+ is rejected" from "every DXF is rejected". +// LibreDWG's DXF importer stores an R2007+ file's strings in two widths +// (`docs/CAVEATS.md`, "DXF saved as R2007 or later"), and until both were +// read in the right one such a file came back as a drawing with no entities +// and no error -- which is why it used to be refused. Two tests pin the +// reading from both sides the refusal was pinned from: every corpus DXF of +// that age, against the DWG of the same drawing where the corpus has one; +// and one drawing under several `$ACADVER` stamps, against itself. /// Root of the LibreDWG corpus (submodule); every `.dxf` under it is walked. const CORPUS_ROOT: &str = concat!( @@ -243,58 +245,114 @@ fn dxf_files_under(dir: &Path, out: &mut Vec) { } } +/// A reference as one comparable string: the name, or its state. +fn ref_name(r: &uncad::model::Ref) -> String { + match r { + uncad::model::Ref::Resolved(name) => name.clone(), + uncad::model::Ref::Absent => "".to_string(), + uncad::model::Ref::Unresolved(handle) => format!(""), + } +} + +/// What the parser states about a drawing that two decoders of the same +/// drawing must agree on: how many entities, on which layers, and which +/// block record each INSERT names. +fn names(db: &uncad::CadDatabase) -> (usize, BTreeMap, BTreeMap) { + let mut layers = BTreeMap::new(); + let mut blocks = BTreeMap::new(); + for entity in &db.entities { + *layers.entry(ref_name(&entity.common().layer)).or_insert(0) += 1; + if let uncad::Entity::Insert(insert) = entity { + *blocks.entry(ref_name(&insert.block_name)).or_insert(0) += 1; + } + } + (db.entities.len(), layers, blocks) +} + #[test] -fn refuses_exactly_the_corpus_dxfs_saved_as_r2007_or_later() { +fn every_r2007_plus_corpus_dxf_reads_like_its_dwg_twin_or_fails_in_libredwg() { let mut files = Vec::new(); dxf_files_under(Path::new(CORPUS_ROOT), &mut files); files.sort(); - assert!(!files.is_empty(), "the corpus should contain DXF files"); - let (mut refused, mut accepted) = (0usize, 0usize); + // Known deviations, each pinned with a tripwire: if the twins ever + // agree, the entry is stale and must go. + // - 2010/gh209_1: LibreDWG's importer leaves every entity without a + // layer handle (the DWG puts them on five layers); the model says so + // with `Absent`, which is what the parser owes. + // - 2018/Leader: the DXF itself puts one LEADER on a layer "0 @ 1" that + // the DWG does not have (groups 8 at lines 1836 and 2606 of the file). + let deviations = ["2010/gh209_1.dxf", "2018/Leader.dxf"]; + + let (mut read, mut failed, mut twins) = (Vec::new(), Vec::new(), 0usize); for path in &files { - let declared = acadver_by_search(path); - let is_r2007_plus = declared + let is_r2007_plus = acadver_by_search(path) .as_deref() .and_then(|v| v.strip_prefix("AC")?.parse::().ok()) .is_some_and(|n| n >= 1021); - - match uncad::parse(path) { - Err(uncad::ParseError::UnsupportedDxfVersion(v)) => { - assert!( - is_r2007_plus, - "{} was refused as {v} but declares {declared:?}", - path.display() - ); - assert_eq!(Some(v.as_str()), declared.as_deref(), "{}", path.display()); - refused += 1; - } - other => { - assert!( - !is_r2007_plus, - "{} declares {declared:?} (R2007+) but was not refused: {:?}", - path.display(), - other.map(|db| db.entities.len()) - ); - accepted += 1; + if !is_r2007_plus { + continue; + } + let rel = path + .strip_prefix(CORPUS_ROOT) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let db = match uncad::parse(path) { + Ok(db) => db, + // LibreDWG's own reader gives up on these, as it does on the + // older DXFs that fail: an error, never an empty drawing. + Err(uncad::ParseError::Critical(_)) => { + failed.push(rel); + continue; } + Err(e) => panic!("{rel}: {e}"), + }; + assert!(!db.entities.is_empty(), "{rel} read as an empty drawing"); + read.push(rel.clone()); + + let twin = path.with_extension("dwg"); + if !twin.exists() { + continue; } + twins += 1; + let dwg = uncad::parse(&twin).expect("the DWG twin parses"); + let agree = names(&db) == names(&dwg); + let deviates = deviations.contains(&rel.as_str()); + assert!( + agree != deviates, + "{rel}: {}\n dxf {:?}\n dwg {:?}", + if deviates { + "now agrees with its DWG twin -- drop it from `deviations`" + } else { + "must state what its DWG twin states" + }, + names(&db), + names(&dwg) + ); } - // Both sides must be populated for the test to have discriminated at all. - assert!( - refused > 0, - "no corpus DXF was refused -- the version check is not running" + eprintln!( + "R2007+ corpus DXF: {} read ({twins} against a DWG twin), {} failed in LibreDWG: {failed:?}", + read.len(), + failed.len() ); - assert!( - accepted > 0, - "every corpus DXF was refused -- the version check is too broad" + assert_eq!((read.len(), twins), (27, 24)); + assert_eq!( + failed, + [ + "2013/gh109_1.dxf", + "2018/Constraints.dxf", + "2018/Dynblocks.dxf", + "2018/LiveSection1.dxf", + "2018/TS1.dxf" + ] ); - eprintln!("corpus DXF: {refused} refused (R2007+), {accepted} handed to LibreDWG"); } /// The corpus R2000 drawing with its `$ACADVER` value line rewritten -- the -/// same file otherwise, so the two sides of the control differ in nothing but -/// the version stamp. (A synthetic HEADER holding only `$ACADVER` is not -/// enough for LibreDWG's importer, which is why the control is not built +/// same file otherwise, so every reading of it differs in nothing but the +/// version stamp. (A synthetic HEADER holding only `$ACADVER` is not +/// enough for LibreDWG's importer, which is why the drawing is not built /// from scratch.) fn corpus_dxf_stamped(acadver: &str) -> String { let text = fs::read_to_string(CORPUS_DXF).expect("the corpus DXF should be readable text"); @@ -319,39 +377,19 @@ fn corpus_dxf_stamped(acadver: &str) -> String { } #[test] -fn the_same_drawing_is_refused_at_r2007_and_read_at_r2004() { - let r2007 = TempFile::new("acadver-r2007.dxf"); - fs::write(r2007.path(), corpus_dxf_stamped("AC1021")).expect("temp dir writable"); - match uncad::parse(r2007.path()) { - Err(uncad::ParseError::UnsupportedDxfVersion(v)) => assert_eq!(v, "AC1021"), - other => panic!( - "an R2007 DXF must be refused, got {:?}", - other.map(|db| db.entities.len()) - ), - } - - // Restamping with the file's own version is the null control: it proves - // the rewrite itself changes nothing before the other stamps are judged. +fn the_same_drawing_reads_the_same_whatever_release_it_is_stamped() { + // The drawing is its own reference: stamped R2007 and later, the + // importer stores every string it sets as UTF-16 instead of 8-bit, and + // nothing of what comes out may change -- not a name, not a text, not a + // reference. Restamping with the file's own version is the null control. let reference = uncad::parse(CORPUS_DXF).expect("the untouched corpus DXF should parse"); - let same = TempFile::new("acadver-r2000.dxf"); - fs::write(same.path(), corpus_dxf_stamped("AC1015")).expect("temp dir writable"); - let db = uncad::parse(same.path()).expect("the R2000 restamp must still be read"); - assert_eq!( - db.entities.len(), - reference.entities.len(), - "restamping with the same version must not change what is read" - ); - - // One step below the threshold: handed to LibreDWG and read. Only the - // *decision* is asserted here -- how many entities LibreDWG's importer - // produces for an R2004 stamp of R2000 content is its business (it does - // differ: 14 against 12 for this file), and pinning it would test the - // importer, not this crate's version check. - let r2004 = TempFile::new("acadver-r2004.dxf"); - fs::write(r2004.path(), corpus_dxf_stamped("AC1018")).expect("temp dir writable"); - let db = uncad::parse(r2004.path()).expect("an R2004 DXF must still be read"); - assert!( - !db.entities.is_empty(), - "the R2004 control should read entities" - ); + assert!(!reference.entities.is_empty()); + for acadver in ["AC1015", "AC1018", "AC1021", "AC1024", "AC1027", "AC1032"] { + let stamped = TempFile::new(&format!("acadver-{acadver}.dxf")); + fs::write(stamped.path(), corpus_dxf_stamped(acadver)).expect("temp dir writable"); + let (db, header) = uncad::parse_with_header(stamped.path()) + .unwrap_or_else(|e| panic!("the {acadver} stamp must be read: {e}")); + assert_eq!(header.acadver.as_deref(), Some(acadver)); + assert_eq!(db, reference, "the {acadver} stamp read differently"); + } } diff --git a/crates/uncad/tests/header.rs b/crates/uncad/tests/header.rs index 236eab1..3996593 100644 --- a/crates/uncad/tests/header.rs +++ b/crates/uncad/tests/header.rs @@ -30,6 +30,40 @@ const TEST_DATA: &str = concat!( "/../../lib/libredwg/test/test-data/" ); +/// Every R2007+ DXF in the corpus that LibreDWG reads (`$ACADVER` AC1021 or +/// later; `2013/gh109_1`, `2018/Constraints`, `Dynblocks`, `LiveSection1` +/// and `TS1` fail in LibreDWG itself). All of them declare an empty +/// `$DIMPOST` (`grep -A1 '^\$DIMPOST'` over the files). +const R2007_PLUS_DXFS: &[&str] = &[ + "example_2007.dxf", + "example_2010.dxf", + "example_2013.dxf", + "example_2018.dxf", + "sample_2007.dxf", + "sample_2010.dxf", + "sample_2013.dxf", + "sample_2018.dxf", + "2007/Arc.dxf", + "2007/circle.dxf", + "2007/Constraints.dxf", + "2007/ConstructionLine.dxf", + "2007/Donut.dxf", + "2007/Ellipse.dxf", + "2007/Helix.dxf", + "2007/Leader.dxf", + "2007/Line.dxf", + "2007/Polygon.dxf", + "2007/Polyline.dxf", + "2007/RAY.dxf", + "2007/Text.dxf", + "2010/Constraints.dxf", + "2010/gh209_1.dxf", + "2010/Leader.dxf", + "2013/Constraints.dxf", + "2013/Leader.dxf", + "2018/Leader.dxf", +]; + fn header_of(path: &str) -> Header { uncad::parse_with_header(path) .unwrap_or_else(|e| panic!("{path} must parse: {e}")) @@ -197,6 +231,34 @@ fn a_chinese_r2013_file_reports_its_code_page_and_unitless_units() { ); } +#[test] +fn r2007_and_later_dxf_header_text_variables_are_not_read_as_utf16() { + // LibreDWG parses the HEADER section before it sets header.version (only + // R13..R2000 get it from $ACADVER; dxf_fixup_header runs afterwards), so + // every $ text variable is a plain 8-bit copy of the file's bytes even + // in an R2007+ DXF. Read as UTF-16, the 1-byte allocation of an empty + // $DIMPOST was overrun into "DIMSE..."-style heap garbage on every one of + // these files. + for name in R2007_PLUS_DXFS { + let (db, h) = uncad::parse_with_header(format!("{TEST_DATA}{name}")) + .unwrap_or_else(|e| panic!("{name}: {e}")); + assert!( + ["r2007", "r2010", "r2013", "r2018"].contains(&h.version.as_deref().unwrap_or("")), + "{name}: {:?}", + h.version + ); + assert_eq!(h.dimpost.as_deref(), Some(""), "{name}"); + assert!( + !db.read_diagnostics + .warnings + .iter() + .any(|w| w.starts_with("TEXT_ENCODING")), + "{name}: {:?}", + db.read_diagnostics.warnings + ); + } +} + /// A DXF of version `acadver` whose only header variables are `$ACADVER` /// and `$DIMPOST` (`dimpost` written as the raw bytes), padded with LINEs /// past LibreDWG's 256-byte minimum. @@ -217,12 +279,16 @@ fn dxf_with_dimpost(acadver: &str, dimpost: &[u8]) -> Vec { } #[test] -fn a_dimpost_suffix_round_trips() { +fn a_dimpost_suffix_round_trips_through_every_dxf_version() { // "<> mm" is the DXF reference's own example of $DIMPOST (the "<>" is - // where the measurement goes). The non-ASCII variant is CP1252 (B5 = - // U+00B5; with no $DWGCODEPAGE, LibreDWG assumes ANSI_1252). - let cases: [(&str, &[u8], &str); 2] = [ + // where the measurement goes); a 6-byte allocation that used to come + // back as 13 UTF-16 units of heap. The R2018 non-ASCII variant is UTF-8 + // in the file (AC1021+ DXF), the R2000 one CP1252 (B5 = U+00B5; with no + // $DWGCODEPAGE, LibreDWG assumes ANSI_1252). + let cases: [(&str, &[u8], &str); 4] = [ + ("AC1032", b"<> mm", "<> mm"), ("AC1015", b"<> mm", "<> mm"), + ("AC1032", b"<> \xC2\xB5m", "<> \u{B5}m"), ("AC1015", b"<> \xB5m", "<> \u{B5}m"), ]; for (acadver, raw, expected) in cases { diff --git a/crates/uncad/tests/r2007_dxf_handles.rs b/crates/uncad/tests/r2007_dxf_handles.rs new file mode 100644 index 0000000..707bb82 --- /dev/null +++ b/crates/uncad/tests/r2007_dxf_handles.rs @@ -0,0 +1,203 @@ +//! An R2007+ DXF must resolve its entities' *handle-borne* names -- the +//! layer every entity points at and the block record every INSERT points at +//! -- exactly as the DWG of the same drawing does. +//! +//! LibreDWG's DXF importer stores table-record names as UTF-16 as soon as +//! `$ACADVER` is AC1021 or later (its field setter widens every string once +//! `header.version >= R_2007`), but its own name->handle lookups read those +//! names back as 8-bit C strings, which stop at the first NUL. So every name +//! longer than one character compared as just its first letter, +//! `entity->layer` and `INSERT->block_header` were left NULL, and the only +//! entities with a layer were those on layer `0`. The fix is the +//! `uncad local patch` block in `crates/libredwg-sys/vendor/libredwg/src/dwg.c` +//! (see `docs/CAVEATS.md`, "Local patches to the vendored LibreDWG"): these +//! tests are what pins it. This crate's half -- reading each string in the +//! width it was stored in -- is `crates/uncad/src/text.rs`. +//! +//! The corpus carries the same drawing in both formats, which makes the DWG +//! an independent reference for the DXF: two different decoders, one +//! drawing. The absolute numbers below were derived a third way -- by +//! walking the DXF's own text -- and each assertion says how. + +use std::collections::BTreeMap; + +use uncad::model::{Entity, Ref}; +use uncad::CadDatabase; + +const DXF: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2018.dxf" +); +const DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2018.dwg" +); + +/// A reference as one comparable string: the name, or its state. +fn name(r: &Ref) -> String { + match r { + Ref::Resolved(name) => name.clone(), + Ref::Absent => "".to_string(), + Ref::Unresolved(handle) => format!(""), + } +} + +/// How many model- and paper-space entities sit on each layer. +fn layers(db: &CadDatabase) -> BTreeMap { + let mut out = BTreeMap::new(); + for entity in &db.entities { + *out.entry(name(&entity.common().layer)).or_insert(0) += 1; + } + out +} + +/// How many INSERTs name each block record. +fn block_names(db: &CadDatabase) -> BTreeMap { + let mut out = BTreeMap::new(); + for entity in &db.entities { + if let Entity::Insert(insert) = entity { + *out.entry(name(&insert.block_name)).or_insert(0) += 1; + } + } + out +} + +/// Every entity the drawing holds: model and paper space plus each *named* +/// block definition. `*Model_Space`/`*Paper_Space*` are skipped because +/// their contents are already in `db.entities`. +fn all_entities(db: &CadDatabase) -> Vec<&Entity> { + let mut out: Vec<&Entity> = db.entities.iter().collect(); + for (name, record) in &db.tables.block_records { + if name.starts_with("*Model_Space") || name.starts_with("*Paper_Space") { + continue; + } + out.extend(record.entities.iter()); + } + out +} + +/// What a consumer decides an entity's visibility from, as far as the +/// parser states it: the entity's layer and its own invisible flag, counted +/// over every entity of the drawing. (Which of these hide an entity -- a +/// `Defpoints` layer, an invisible flag, a layer that is off or frozen -- is +/// the consumer's rule, not the reader's.) +fn layer_and_visibility(db: &CadDatabase) -> BTreeMap<(String, bool), usize> { + let mut out = BTreeMap::new(); + for entity in all_entities(db) { + let common = entity.common(); + *out.entry((name(&common.layer), common.invisible)) + .or_insert(0) += 1; + } + out +} + +#[test] +fn an_r2018_dxf_resolves_the_same_layers_blocks_and_visibility_as_its_dwg_twin() { + let dxf = uncad::parse(DXF).expect("the corpus DXF parses"); + let dwg = uncad::parse(DWG).expect("the corpus DWG parses"); + + // The two formats hold the same drawing, so these must agree whatever + // the numbers are. Before the fix the DXF side was {"": 65, "0": 7}. + assert_eq!( + layers(&dxf), + layers(&dwg), + "DXF and DWG must put the same entities on the same layers" + ); + assert_eq!( + block_names(&dxf), + block_names(&dwg), + "DXF and DWG must resolve the same block records" + ); + assert_eq!( + layer_and_visibility(&dxf), + layer_and_visibility(&dwg), + "DXF and DWG must state the same layer and invisible flag for every entity" + ); + let resolved = |db: &CadDatabase| { + db.entities + .iter() + .all(|e| matches!(e.common().layer, Ref::Resolved(_))) + }; + assert!(resolved(&dxf), "no entity may be left without a layer name"); +} + +#[test] +fn the_r2018_dxf_layers_and_block_names_match_the_dxf_text() { + let dxf = uncad::parse(DXF).expect("the corpus DXF parses"); + + // Derived independently of this crate, by walking example_2018.dxf's own + // group codes: for every record in its ENTITIES section (skipping the + // VERTEX and SEQEND sub-records, which LibreDWG folds into their owning + // POLYLINE/INSERT), the group 8 value. That walk yields + // {"Tavolo 3": 57, "Tavolo 2": 7, "0": 6, "*ADSK_SYSTEM_LIGHTS": 1} over + // 71 records. The seventh layer-`0` entity is the second paper-space + // VIEWPORT: the ENTITIES section spells out one VIEWPORT record, and + // LibreDWG materialises the layout's own viewport alongside it (the DWG + // decoder produces the same pair, which is why both formats report 2). + let expected: BTreeMap = [ + ("*ADSK_SYSTEM_LIGHTS", 1), + ("0", 7), + ("Tavolo 2", 7), + ("Tavolo 3", 57), + ] + .into_iter() + .map(|(name, count)| (name.to_string(), count)) + .collect(); + assert_eq!(layers(&dxf), expected); + + // Same walk, group 2 of each INSERT record in the ENTITIES section. + let expected: BTreeMap = [("CIRKLO_PUNKTOJ", 8), ("bloko", 2)] + .into_iter() + .map(|(name, count)| (name.to_string(), count)) + .collect(); + assert_eq!(block_names(&dxf), expected); + + // And the same walk over ENTITIES *and* BLOCKS: 34 records carry group + // 8 "Defpoints" (all of them inside the anonymous `*D` dimension + // blocks) and exactly one record carries group 60 = 1 -- a LINE that is + // itself one of those 34. + let tally = layer_and_visibility(&dxf); + let on_defpoints: usize = tally + .iter() + .filter(|((layer, _), _)| layer == "Defpoints") + .map(|(_, n)| n) + .sum(); + assert_eq!(on_defpoints, 34); + let invisible: Vec<_> = tally + .iter() + .filter(|((_, invisible), _)| *invisible) + .collect(); + assert_eq!(invisible, [(&("Defpoints".to_string(), true), &1)]); +} + +#[test] +fn every_readable_r2007_plus_corpus_dxf_resolves_its_entity_layers() { + // The same failure hit every R2007+ DXF in the corpus, not just the one + // above; sample_2007/sample_2010 came back as {"": 4, "0": 2}. A layer + // that is not resolved means the entity's layer handle did not match + // its table row. (2010/gh209_1.dxf is not in this list: LibreDWG's + // importer leaves every one of its entities without a layer handle, + // with and without the patch -- an absent reference, reported as such.) + for name in [ + "example_2007.dxf", + "example_2010.dxf", + "example_2013.dxf", + "example_2018.dxf", + "sample_2007.dxf", + "sample_2010.dxf", + ] { + let path = format!( + "{}/../../lib/libredwg/test/test-data/{name}", + env!("CARGO_MANIFEST_DIR") + ); + let db = uncad::parse(&path).unwrap_or_else(|e| panic!("{name} parses: {e}")); + assert!(!db.entities.is_empty(), "{name} has entities"); + assert!( + db.entities + .iter() + .all(|e| matches!(e.common().layer, Ref::Resolved(_))), + "{name}: every entity must carry a layer name, got {:?}", + layers(&db) + ); + } +} diff --git a/crates/uncad/tests/vendored_patches.rs b/crates/uncad/tests/vendored_patches.rs index 6b230b4..f4d3b4b 100644 --- a/crates/uncad/tests/vendored_patches.rs +++ b/crates/uncad/tests/vendored_patches.rs @@ -10,9 +10,12 @@ //! header date ends that build's process with 0xC0000409, and the polygon //! mesh makes it refuse the whole file with critical error 2048. //! -//! Two of the five are not observable through `parse()` yet, so they have no -//! test here. The `dwg.c` one decodes an R2007+ DXF's table names, and this -//! crate still refuses such a DXF before LibreDWG reads it. The +//! The `dwg.c` one, which makes the DXF importer decode an R2007+ file's +//! table-record names before it compares them, is pinned in +//! `tests/r2007_dxf_handles.rs`: an R2018 DXF must resolve the same layers +//! and block records as its DWG twin, which without the patch it does not +//! (every name longer than one character missed its lookup). One of the +//! five is not observable through `parse()` yet, so it has no test: the //! `common_entity_data.spec` one puts an entity's true colour and its //! transparency back in their own fields, and the entity it was measured on //! (HATCH 29F in `test-data/2004/HatchG.dwg`) comes out of this crate without diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 85513e4..950e406 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,7 +17,7 @@ crates/ cannot reach (MULTILEADER leader lines), a 3DSOLID SAB->SAT conversion that runs on a copy rather than the original, readers that decode a DWG or DXF from a memory buffer rather - than a path, the file-header fields (version, codepage, + than a path, and the file-header fields (version, codepage, string width, a pre-R13 header's length, whether the Template section was read) that decoding a drawing's text and header need, and string conversions. @@ -146,12 +146,15 @@ That suits the design rather than fighting it: entity fields were always going t through `dwg_dynapi_entity_value`/`dwg_dynapi_common_value`, LibreDWG's own reflection API keyed by string field name, with runtime type and range checks. `uncad::dynapi` wraps it in the generic helpers `get_field::`, `get_common_field::`, -`get_header_field::`, `get_text_bytes` and `get_array_field::`, comparing the field size dynapi reports -against the requested Rust type's size so a wrong type mapping fails loudly instead of -quietly corrupting data. Text fields come back as bytes on purpose: for a pre-R2007 -drawing they are codepage bytes, not UTF-8, and `uncad::text::TextDecoder` (one per -`parse()`) is the only place they become `String`s -- through LibreDWG's codepage tables, -with what could not be decoded reported in `read_diagnostics` (see `docs/CAVEATS.md`). +`get_header_field::`, `get_text` and `get_array_field::`, comparing the field size +dynapi reports against the requested Rust type's size so a wrong type mapping fails loudly +instead of quietly corrupting data. Text fields come back undecoded on purpose, as LibreDWG +hands them out: a UTF-8 copy it converted (R2007+ DWG), or the stored string -- codepage +bytes before R2007, and in an R2007+ DXF the UTF-16 its importer wrote or, for the few +fields it copies byte for byte, the file's UTF-8. `uncad::text::TextDecoder` (one per +`parse()`) says which width a stored string is read in and is the only place any of them +become `String`s -- through LibreDWG's codepage tables where a codepage applies, with what +could not be decoded reported in `read_diagnostics` (see `docs/CAVEATS.md`). **The same bindgen failure recurs for individual types.** Even with the whole `tio` union opaque, allowlisting a nested struct on its own (`Dwg_HATCH_Path`, `Dwg_HATCH_PathSeg`, diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index e484493..c90061a 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -142,11 +142,12 @@ A sharper example: taking `lib/libredwg/test/test-data/2007/ATMOS-DC22S.dwg` (60 entities), writing it out as R2007 DXF with LibreDWG's own DXF writer, and reading that back with `dxf_read_file` returns exactly 1 entity. -Which entities the importer keeps also depends on the declared version, not only on the -content: the corpus R2000 file `2000/entities-2d.dxf` reads as 12 entities, and the same -bytes with `$ACADVER` rewritten to `AC1018` (R2004) read as 14 -- an ATTDEF and an ATTRIB -appear that the R2000 pass drops without a message. Nothing on this side can tell that -they were dropped. +The declared version, by contrast, does not change what is read: the corpus R2000 file +`2000/entities-2d.dxf` gives the same model under every `$ACADVER` from `AC1015` to +`AC1032` (`tests/dxf_pipeline.rs`), although from `AC1021` on the importer stores its +strings as UTF-16. (It used to read 12 entities at R2000 and 14 at R2004; the two that +went missing, an ATTDEF and an ATTRIB, are read since this crate walks the R13..R2000 +attribute chains itself -- see "Attributes".) Two DXF-only defects that *were* patched are recorded under "Local patches to the vendored LibreDWG" below: a polygon mesh made the reader refuse the whole file, and the importer @@ -154,42 +155,56 @@ compared an R2007+ DXF's table-record names in the wrong width. The per-entity degradation this section describes -- a type dropping out, or arriving as `Entity::Unknown` -- is what a DXF should cost; a whole-file refusal was not. -### DXF saved as R2007 or later is refused with an explicit error - -`parse()` returns `Err(ParseError::UnsupportedDxfVersion)` for a DXF whose `$ACADVER` -is `AC1021` (R2007) or later -- R2007, R2010, R2013 and R2018 files, which is what -current CAD software writes by default. The decision is made from the file's own HEADER -section before LibreDWG sees it. R2000/R2004 DXF (`AC1015`/`AC1018`) and older, DXF files -without a `$ACADVER` (pre-R10), and every DWG version are unaffected. Binary DXF is not -inspected and goes to LibreDWG as before. - -Why refuse rather than read: LibreDWG's DXF importer stores strings as UTF-16 for R2007+ -input, but its own text accessor treats imported data as 8-bit. Every name therefore comes -back cut off after its first character -- `*Model_Space` arrives as `*` -- and this crate -finds the entities by looking up the model- and paper-space block records by name. The -entities are in memory (the block record that arrives as `*` has them); they are just -never matched, and LibreDWG reports no error. Left alone, that surfaced as `Ok` with zero -entities, indistinguishable from an empty drawing. - -Correcting the width on this side is not enough either. The importer resolves layer and -block names through that same accessor while it builds the drawing, so those lookups have -already failed by the time the data gets here: with the width corrected, the corpus files -do yield their 72 entities, but 65 of them without a layer, every INSERT and DIMENSION -without its block, and MTEXT content garbled (the importer stores that one field 8-bit). A -drawing that looks read and is quietly missing that much is worse than an obviously empty -one, and an obviously empty one is worse than an error. The fix belongs in LibreDWG's -accessor, or in reading DXF without LibreDWG. - -The library's half of that fix is now in the vendored copy: a local patch to `dwg.c` (see -"Local patches to the vendored LibreDWG" below) makes the importer decode a record's name -before it compares it, so its own layer and block lookups no longer stop at the first -character. This crate's half -- reading each of an R2007+ DXF's strings in the width the -importer stored it in -- is not done yet, so the refusal stays. - -What to do: save the drawing as R2004 DXF or as DWG (any version). A test walks every DXF -in the LibreDWG corpus and checks that exactly the R2007+ files are refused; a second one -feeds the same minimal drawing with two `$ACADVER` values and requires one refusal and one -successful read. +### DXF saved as R2007 or later is read, each string in the width it was stored in + +A DXF whose `$ACADVER` is `AC1021` (R2007) or later -- R2007, R2010, R2013 and R2018 +files, which is what current CAD software writes by default -- is read like any other. +Until 0.3.0 such a file came back from LibreDWG as a drawing with no entities and no +error, and this crate refused it with `ParseError::UnsupportedDxfVersion` instead. The +cause was string width, in two places, and both are fixed: + +- **In this crate.** LibreDWG's DXF importer stores every string it sets through its + field setter as UTF-16 once the version is R2007 or later, exactly as for a DWG -- but + its text accessors convert UTF-16 back only for a DWG, and hand an R2007+ DXF's strings + out as if they were 8-bit, which stops at the first NUL byte: `*Model_Space` arrived as + `*`, and this crate, which finds the entities by looking the model- and paper-space + block records up by name, found none. `uncad::text::TextDecoder` now knows which kind + of drawing it is reading and reads each stored string in its width. The importer's + exceptions are read 8-bit: MTEXT's text (groups 1 and 3, copied byte for byte) and the + HEADER variables (parsed before the importer knows the version). Those are the file's + own bytes, UTF-8 in an R2007+ DXF, and are checked as UTF-8 and reported in + `read_diagnostics` when they are not; reading them as UTF-16 would look for a 16-bit + NUL past their allocation. +- **In LibreDWG's importer.** It resolves layer and block names through the same 8-bit + reading while it builds the drawing, so those lookups had already failed by the time + the data got here: with this crate's half alone, `example_2018.dxf` yields its 72 + entities with 65 of them on no layer and every INSERT without its block. A local patch + to the vendored `dwg.c` (see "Local patches to the vendored LibreDWG" below) makes the + importer decode a record's name before it compares it. + +Measured on the corpus: 27 of its 32 R2007+ DXFs read; 24 of those have the same drawing +as a DWG beside them, and 22 of the 24 state the same entities on the same layers with +the same INSERT blocks as their twin. The two that do not are recorded, not hidden: +`2010/gh209_1.dxf`, whose entities LibreDWG's importer leaves without a layer handle +(the model says `Absent`), and `2018/Leader.dxf`, whose DXF text itself puts one entity +on a layer `0 @ 1` the DWG does not have. The other five (`2013/gh109_1`, +`2018/Constraints`, `Dynblocks`, `LiveSection1`, `TS1`) fail inside LibreDWG's importer +with critical error 2048, as a few older DXFs do. `tests/dxf_pipeline.rs` pins all of +that, and also reads one drawing restamped `AC1015`, `AC1018`, `AC1021`, `AC1024`, +`AC1027` and `AC1032` and requires the same model from every stamp; +`tests/r2007_dxf_handles.rs` compares `example_2018.dxf` with its DWG twin and with its +own text, and `tests/codepage.rs` covers the two widths. + +**The silent case is guarded.** Should an R2007+ DXF still come out with no entity in +the model while LibreDWG itself placed entities in its model or paper space (the +`entmode` the importer sets from each entity's owner handle, which no name +lookup of this crate's is involved in), `parse()` returns +`ParseError::UnsupportedDxfVersion` rather than an empty drawing: an obviously empty +drawing is worse than an error, and one that looks read but is empty is worse still. No +corpus file trips it; with this crate's half of the width fix disabled, exactly the 27 +readable files do. The guard is limited to R2007+ DXF on purpose: applied to every input +it would also refuse two pre-R13 DWGs (`r11/ACEB10.dwg`, `r2.10/block.dwg`) that read +today with an empty model space, which is a different question. ## Text before R2007 is decoded here, through the drawing's codepage @@ -239,8 +254,8 @@ from Python's own encoders, with the Windows twins (936, 950, 932) of the same b the control. **Escapes are text.** `\U+XXXX` and `\M+nXXXX` in a string are passed through as the file -wrote them; expanding them is a consumer's decision, as the rest of MTEXT's inline codes -are. +wrote them, whatever its version or format; expanding them is a consumer's decision, as +the rest of MTEXT's inline codes are. **What is trusted, and what cannot be told:** @@ -258,8 +273,10 @@ are. reads as U+0223). Title-block strings of more than one or two syllables are never valid UTF-8 as a whole, and a DWG never holds UTF-8 in a codepage string, so the codepage always applies there. -- R2007 and later: the library converts from UTF-16 itself and the codepage is moot; the - result is checked to be UTF-8 and reported if it is not. +- R2007 and later: the codepage is moot. From a DWG the library converts its UTF-16 + itself; from a DXF this crate reads the UTF-16 the importer stored, and the strings the + importer keeps 8-bit as the UTF-8 such a file is (see "DXF saved as R2007 or later is + read" above). Whatever is not valid UTF-16 or UTF-8 is reported. - A codepage LibreDWG has no table for -- `CP_UNDEFINED`, or a corrupt value in a DWG header, which the library would index its tables with unchecked -- is not guessed at (as `ANSI_1252`, say): its strings are read as UTF-8 and reported where they are not. @@ -277,8 +294,8 @@ the CLI prints them as a warning. Every example DWG in the LibreDWG corpus comes `UNHANDLEDCLASS` set, and `example_2018.dwg` with `UNHANDLEDCLASS | VALUEOUTOFBOUNDS`; the R2000 DXF from the same corpus comes back clean. Across the whole corpus (208 files: 141 DWG, 67 DXF), 100 DWG read clean, 17 with `UNHANDLEDCLASS`, 31 with `VALUEOUTOFBOUNDS` (some with -both); every DXF that LibreDWG read at all (31) read clean, 32 were refused as R2007+ and 4 -failed critically (3 `INVALIDDWG`, 1 `IOERROR`). What the bits mean for the result is +both); every DXF that LibreDWG read at all (58) read clean, and 9 failed critically (8 +`INVALIDDWG`, 5 of them R2007+ files, and 1 `IOERROR`). What the bits mean for the result is LibreDWG's to say -- `UNHANDLEDCLASS` in particular means objects of a class it did not know were skipped, and nothing else in the model shows that they existed. @@ -315,8 +332,10 @@ measured with hand-written files: an INSERT naming a block the BLOCKS section do reads as *absent* (the importer stores no handle), a defined one resolves, and a LINE on a layer no LAYER table declares is not readable at all (`IOERROR`) -- so an unresolved *layer* comes from DWG files with broken handles, not from anything one can write into a DXF by hand. -The R2007+ DXF case that this crate now refuses was the large-scale version of "unresolved": -65 of 72 entities with a layer handle that no longer matched its table row. +The R2007+ DXF case was the large-scale version of "unresolved" before the importer's +lookups were patched: 65 of `example_2018.dxf`'s 72 entities without a layer handle, where +its DWG twin names four layers. One corpus R2007+ DXF, `2010/gh209_1.dxf`, still reads that +way -- all of its entities are absent-layered, which is what LibreDWG's importer leaves. Rendering treats absent and unresolved alike (no layer color to look up, no block to draw); the model still says which it was. @@ -670,17 +689,19 @@ a re-vendor that drops a patch fails by name instead of compiling upstream's cod - **`src/dwg.c`** -- `dwg_find_tablehandle()`, `dwg_find_dicthandle_objname()` and `dwg_handle_name()` read a table record's `name` with `IS_FROM_TU_DWG()`, which is false for DXF and JSON input even when the record's name is stored as UTF-16 -- which it is for - an R2007+ DXF (see "DXF saved as R2007 or later is refused with an explicit error" - above). So "Tavolo 3" compared as "T", and every name the importer resolves while it + an R2007+ DXF (see "DXF saved as R2007 or later is read, each string in the width it was + stored in" above). So "Tavolo 3" compared as "T", and every name the importer resolves while it builds the drawing (the group 8 layer, the group 2 block name, the linetype, text style, dimstyle, UCS, VPORT and APPID names) failed for any name longer than one character. The three functions now share one helper, `uncad_record_name_utf8()`, whose - predicate `UNCAD_IS_TU_DWG()` is the one the storage actually follows, and the one the - shim's `uncad_tv_to_utf8` uses. The patch deliberately stops there: the strings + predicate `UNCAD_IS_TU_DWG()` is the one the storage actually follows, and the one + `crates/uncad/src/text.rs` reads the strings by. The patch deliberately stops there: the strings `in_dxf.c` stores through `dwg_add_u8_input()` (`DICTIONARY.texts`, `LTYPE.dashes[].text`) really are 8-bit for DXF input, so `dwg_find_dictionary()` and `dwg_find_dicthandle()` keep the original predicate. LibreDWG has the same gap; it is not - reported there yet. + reported there yet. `crates/uncad/tests/r2007_dxf_handles.rs` is the regression: without + the patch its three tests fail, `example_2018.dxf` putting 65 of its 72 entities on no + layer, and so do `tests/dxf_pipeline.rs`'s two R2007+ twin tests. - **`src/common.c`** -- `cvt_TIMEBLL()` left `tm_wday`/`tm_yday`/`tm_isdst` uninitialized and let a corrupt date drive `tm_year`, `tm_mon` and `tm_hour` far out of range. Every caller passes the result straight to `strftime()` (`dec_macros.h`'s `FIELD_TIMEBLL` and From 73743e2d957c17a1035f719c829e22a1ea087659 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:28:45 +0900 Subject: [PATCH 21/54] Read how a text is justified, stretched, slanted and styled TEXT, ATTRIB and ATTDEF carried only their start point, height, rotation and string, so a centred or right-aligned text was placed as if it started where its alignment point is not, and its style was not named. They now carry the horizontal and vertical justification (DXF 72 and 73, 74 on the attribute kinds; out-of-range values read as the format's left/baseline default), the alignment point (DXF 11, only when a justification is not the default -- otherwise the field holds nothing the file stated), the width factor (DXF 41; a 0, which a hand-written DXF that omits the group reads as, is the default 1), the oblique angle (DXF 51, radians from both readers) and the text style (DXF 7) as a reference; LibreDWG's DXF importer already points an entity with no group 7 at the STANDARD entry when the drawing declares one. An MTEXT carries its reference width (41), its extents (42/43, a zero measures no text and is None) and its style. tests/text_fields.rs writes a DXF from group codes (a centre/middle TEXT at (50, 50) with width 0.8 and a 15-degree slant, a plain TEXT, a rotated MTEXT) and checks example_2000.dwg's attributes. A/B against the feature branch: justification, alignment point, width factor, oblique angle and style agree on all 4,256 TEXTs and ATTRIBs it read (two mirrored texts aside, whose alignment point it moved to the world), and rect width, extents and style on all 3,152 MTEXTs; the goldens G1-G12 still read back exactly. Ported-from: 96abb6d Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + crates/uncad/src/convert.rs | 139 +++++++++++++++++++---- crates/uncad/tests/text_fields.rs | 178 ++++++++++++++++++++++++++++++ 3 files changed, 300 insertions(+), 23 deletions(-) create mode 100644 crates/uncad/tests/text_fields.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6ff5a..8a39bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,12 @@ Notable changes to this project are recorded here. The format follows segment is straight and every width the constant one. A DXF POLYLINE's default widths (its groups 40/41) are the widths of the vertices that state none, so a DXF and its DWG twin agree. See `docs/CAVEATS.md`, "Polyline bulges and widths are carried as stated". +- TEXT, ATTRIB and ATTDEF carry how they are placed beyond their start point: the + horizontal and vertical justification (DXF 72, 73/74), the alignment point (DXF 11, + only for a justified text), the width factor (DXF 41), the oblique angle (DXF 51, + radians) and the text style they name (DXF 7). An MTEXT carries its reference width + (DXF 41), the extents its writer measured (DXF 42/43, `None` when not stated) and its + text style. ### Changed - A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 87c190a..0b97e8e 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -26,7 +26,7 @@ use uncad_model::model::{ Origin, PointEntity, PolylineEntity, RayEntity, Ref, SegmentWidth, Solid3DEntity, SolidEntity, SplineEntity, TextEntity, TextOverride, ToleranceEntity, ViewportEntity, WipeoutEntity, }; -use uncad_model::model::{Point2D, Point3D}; +use uncad_model::model::{HorizontalJustification, Point2D, Point3D, VerticalJustification}; /// The `flag` bit that means "closed" on POLYLINE_2D and POLYLINE_3D: bit 1, /// as in DXF group 70 and as `dwg.h` documents for `Dwg_Entity_POLYLINE_2D`. @@ -659,6 +659,88 @@ fn stated_widths(widths: Vec, const_width: f64) -> Vec, + width_factor: f64, + oblique_angle: f64, + style_name: Ref, +} + +/// Reads a TEXT's, ATTRIB's or ATTDEF's [`TextPlacement`] off +/// `entity_ptr`, the type-specific struct pointer of a live `dxfname` +/// entity of `dwg`. +/// +/// - The justifications are DXF 72 and 73 (74 on ATTRIB/ATTDEF), 0 to 5 and +/// 0 to 3; a value outside the format's range reads as the format's +/// default, left and baseline, which is also what an absent group means. +/// - The alignment point (DXF 11) exists only for a justified text: +/// dwg.h's `alignment_pt` is "optional, when dataflags & 2, i.e. 72/73 != +/// 0", and for left/baseline text the field holds whatever the decoder +/// left in it, which is not a point the file stated. +/// - The width factor (DXF 41) is a ratio whose default is 1; a 0 is no +/// width at all, and it is what a hand-written DXF that leaves the group +/// out reads as, so it is 1 too. +/// - The oblique angle (DXF 51) arrives in radians from both readers. +fn text_placement( + dwg: *mut libredwg_sys::Dwg_Data, + text: &TextDecoder, + entity_ptr: *mut std::ffi::c_void, + dxfname: &str, +) -> TextPlacement { + let horizontal = match get_field::(entity_ptr, dxfname, "horiz_alignment") { + Some(1) => HorizontalJustification::Center, + Some(2) => HorizontalJustification::Right, + Some(3) => HorizontalJustification::Aligned, + Some(4) => HorizontalJustification::Middle, + Some(5) => HorizontalJustification::Fit, + _ => HorizontalJustification::Left, + }; + let vertical = match get_field::(entity_ptr, dxfname, "vert_alignment") { + Some(1) => VerticalJustification::Bottom, + Some(2) => VerticalJustification::Middle, + Some(3) => VerticalJustification::Top, + _ => VerticalJustification::Baseline, + }; + let justified = + horizontal != HorizontalJustification::Left || vertical != VerticalJustification::Baseline; + TextPlacement { + horizontal, + vertical, + alignment_point: justified + .then(|| get_point2d(entity_ptr, dxfname, "alignment_pt")) + .flatten(), + width_factor: get_field::(entity_ptr, dxfname, "width_factor") + .filter(|w| *w != 0.0) + .unwrap_or(1.0), + oblique_angle: get_field::(entity_ptr, dxfname, "oblique_angle").unwrap_or(0.0), + style_name: text_style(dwg, text, entity_ptr, dxfname), + } +} + +/// The text style (DXF 7) a TEXT, ATTRIB, ATTDEF or MTEXT names, as a +/// reference like a layer. The DXF reference's default for an absent group +/// is the style named STANDARD, and LibreDWG's DXF importer already points +/// such an entity at that entry when the drawing declares it; a drawing +/// that declares none leaves the reference null, which is `Absent`. +fn text_style( + dwg: *mut libredwg_sys::Dwg_Data, + text: &TextDecoder, + entity_ptr: *mut std::ffi::c_void, + dxfname: &str, +) -> Ref { + reference( + dwg, + text, + get_field::<*mut libredwg_sys::Dwg_Object_Ref>(entity_ptr, dxfname, "style"), + c"STYLE", + |handle_ptr| text.handle_name(dwg, handle_ptr), + ) +} + /// Resolves a WIPEOUT's clip boundary to local 2D points -- see /// [`crate::model::WipeoutEntity`] for the risk this carries. /// @@ -816,6 +898,7 @@ unsafe fn convert_entity( libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_TEXT => { let start_point = get_point2d(entity_ptr, "TEXT", "ins_pt")?; let text_height = get_field::(entity_ptr, "TEXT", "height")?; + let placement = text_placement(dwg, text, entity_ptr, "TEXT"); let text = text .field(entity_ptr, "TEXT", "text_value") .unwrap_or_default(); @@ -826,12 +909,12 @@ unsafe fn convert_entity( text_height, text, rotation, - horizontal_justification: Default::default(), - vertical_justification: Default::default(), - alignment_point: None, - width_factor: 1.0, - oblique_angle: 0.0, - style_name: uncad_model::Ref::Absent, + horizontal_justification: placement.horizontal, + vertical_justification: placement.vertical, + alignment_point: placement.alignment_point, + width_factor: placement.width_factor, + oblique_angle: placement.oblique_angle, + style_name: placement.style_name, elevation: get_field::(entity_ptr, "TEXT", "elevation").unwrap_or(0.0), extrusion: extrusion(entity_ptr, "TEXT"), }) @@ -982,6 +1065,7 @@ unsafe fn convert_entity( let text_height = get_field::(entity_ptr, "ATTRIB", "height")?; // Read before `text` is shadowed by the value below. let tag = text.field(entity_ptr, "ATTRIB", "tag").unwrap_or_default(); + let placement = text_placement(dwg, text, entity_ptr, "ATTRIB"); let text = text .field(entity_ptr, "ATTRIB", "text_value") .unwrap_or_default(); @@ -994,12 +1078,12 @@ unsafe fn convert_entity( text, rotation, flags: Default::default(), - horizontal_justification: Default::default(), - vertical_justification: Default::default(), - alignment_point: None, - width_factor: 1.0, - oblique_angle: 0.0, - style_name: uncad_model::Ref::Absent, + horizontal_justification: placement.horizontal, + vertical_justification: placement.vertical, + alignment_point: placement.alignment_point, + width_factor: placement.width_factor, + oblique_angle: placement.oblique_angle, + style_name: placement.style_name, elevation: get_field::(entity_ptr, "ATTRIB", "elevation").unwrap_or(0.0), extrusion: extrusion(entity_ptr, "ATTRIB"), }) @@ -1055,6 +1139,7 @@ unsafe fn convert_entity( .unwrap_or_default(); let rotation = get_field::(entity_ptr, "ATTDEF", "rotation").unwrap_or(0.0); let tag = text.field(entity_ptr, "ATTDEF", "tag").unwrap_or_default(); + let placement = text_placement(dwg, text, entity_ptr, "ATTDEF"); Entity::Attdef(AttdefEntity { common, start_point, @@ -1063,12 +1148,12 @@ unsafe fn convert_entity( default_value, rotation, flags: Default::default(), - horizontal_justification: Default::default(), - vertical_justification: Default::default(), - alignment_point: None, - width_factor: 1.0, - oblique_angle: 0.0, - style_name: uncad_model::Ref::Absent, + horizontal_justification: placement.horizontal, + vertical_justification: placement.vertical, + alignment_point: placement.alignment_point, + width_factor: placement.width_factor, + oblique_angle: placement.oblique_angle, + style_name: placement.style_name, elevation: get_field::(entity_ptr, "ATTDEF", "elevation").unwrap_or(0.0), extrusion: extrusion(entity_ptr, "ATTDEF"), }) @@ -1159,6 +1244,14 @@ unsafe fn convert_entity( } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_MTEXT => { let insertion_point = get_point3d(entity_ptr, "MTEXT", "ins_pt")?; + let style_name = text_style(dwg, text, entity_ptr, "MTEXT"); + // DXF 41: 0 is a text that is not wrapped, which is what an absent + // group means too. + let rect_width = get_field::(entity_ptr, "MTEXT", "rect_width").unwrap_or(0.0); + // DXF 42/43, a measurement of the laid-out text: zero measures no + // text, so it is "not stated" rather than a size. + let extent = + |field: &str| get_field::(entity_ptr, "MTEXT", field).filter(|v| *v != 0.0); let text = text.field(entity_ptr, "MTEXT", "text").unwrap_or_default(); let text_height = get_field::(entity_ptr, "MTEXT", "text_height").unwrap_or(1.0); // The file states the rotation as the text's X-axis direction @@ -1197,10 +1290,10 @@ unsafe fn convert_entity( rotation, line_spacing_factor, attachment, - rect_width: 0.0, - extents_width: None, - extents_height: None, - style_name: uncad_model::Ref::Absent, + rect_width, + extents_width: extent("extents_width"), + extents_height: extent("extents_height"), + style_name, }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_POLYLINE_3D => { diff --git a/crates/uncad/tests/text_fields.rs b/crates/uncad/tests/text_fields.rs new file mode 100644 index 0000000..b3fba15 --- /dev/null +++ b/crates/uncad/tests/text_fields.rs @@ -0,0 +1,178 @@ +//! How TEXT, ATTRIB and MTEXT are placed beyond their insertion point: +//! justification and the alignment point, width factor, oblique angle and +//! text style on TEXT; the attachment point, wrap width, extents and +//! rotation on MTEXT; the tag on ATTRIB. The single-line and multi-line +//! cases come from a DXF this test writes from group codes (so the expected +//! values are the ones it wrote); the ATTRIB case from the corpus. + +use std::f64::consts::FRAC_PI_2; +use std::path::PathBuf; + +use uncad::model::{HorizontalJustification, MTextAttachment, Point2D, Ref, VerticalJustification}; +use uncad::Entity; + +const EXAMPLE_2000_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dwg" +); + +struct TempDir(PathBuf); + +impl TempDir { + fn new(name: &str) -> Self { + let mut path = std::env::temp_dir(); + path.push(format!("uncad-{name}-{}", std::process::id())); + std::fs::create_dir_all(&path).expect("temp dir should be writable"); + TempDir(path) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// An AC1015 DXF with: a center/middle-justified TEXT anchored at (50,50) +/// with a width factor of 0.8 and a 15-degree slant; a plain left/baseline +/// TEXT; and a middle-center MTEXT rotated 90 degrees (direction vector +/// (0,1,0)) with a wrap width and a paragraph break. Padded past LibreDWG's +/// 256-byte minimum with LINEs. +fn text_dxf() -> String { + let mut dxf = String::from( + " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1015\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n", + ); + dxf.push_str( + " 0\nTEXT\n 8\n0\n 10\n0.0\n 20\n0.0\n 40\n5.0\n 1\nCENTER\n 41\n0.8\n 51\n15.0\n 72\n1\n 11\n50.0\n 21\n50.0\n 73\n2\n", + ); + dxf.push_str(" 0\nTEXT\n 8\n0\n 10\n0.0\n 20\n20.0\n 40\n5.0\n 1\n%%c50\n"); + dxf.push_str( + " 0\nMTEXT\n 8\n0\n 10\n100.0\n 20\n100.0\n 30\n0.0\n 40\n2.5\n 41\n80.0\n 71\n5\n 1\nA\\PB\n 11\n0.0\n 21\n1.0\n 31\n0.0\n", + ); + for i in 0..8 { + dxf.push_str(&format!( + " 0\nLINE\n 8\n0\n 10\n{i}.0\n 20\n0.0\n 11\n{i}.0\n 21\n1.0\n" + )); + } + dxf.push_str(" 0\nENDSEC\n 0\nEOF\n"); + dxf +} + +/// Writes the DXF into a directory of its own (`name` keeps the tests +/// apart: they run on parallel threads of one process) and parses it. +fn parse_text_dxf(name: &str) -> uncad::CadDatabase { + let dir = TempDir::new(&format!("text-fields-{name}")); + let path = dir.0.join("text.dxf"); + std::fs::write(&path, text_dxf()).expect("writable"); + uncad::parse(&path).expect("the DXF must parse") +} + +#[test] +fn text_justification_and_alignment_point_are_read() { + let db = parse_text_dxf("justification"); + let texts: Vec<&uncad::model::TextEntity> = db + .entities + .iter() + .filter_map(|e| match e { + Entity::Text(t) => Some(t), + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 2, "{texts:?}"); + + let centered = texts.iter().find(|t| t.text == "CENTER").expect("CENTER"); + assert_eq!( + ( + centered.horizontal_justification, + centered.vertical_justification + ), + ( + HorizontalJustification::Center, + VerticalJustification::Middle + ) + ); + assert_eq!( + centered.alignment_point, + Some(Point2D { x: 50.0, y: 50.0 }), + "DXF 11 is the anchor" + ); + assert_eq!(centered.width_factor, 0.8); + assert!((centered.oblique_angle - 15f64.to_radians()).abs() < 1e-9); + + // The text keeps its `%%c` code verbatim; decoding it is a consumer's. + let plain = texts.iter().find(|t| t.text == "%%c50").expect("%%c50"); + assert_eq!( + (plain.horizontal_justification, plain.vertical_justification), + ( + HorizontalJustification::Left, + VerticalJustification::Baseline + ) + ); + assert_eq!( + plain.alignment_point, None, + "left/baseline states no DXF 11" + ); + assert_eq!(plain.width_factor, 1.0, "an absent width factor reads as 1"); + assert_eq!(plain.oblique_angle, 0.0); + // The file has no STYLE table, so there is no STANDARD entry to stand + // in for the absent group 7. + assert_eq!(plain.style_name, Ref::Absent); +} + +#[test] +fn mtext_reads_attachment_width_and_rotation_from_its_direction_vector() { + let db = parse_text_dxf("mtext"); + let mtext = db + .entities + .iter() + .find_map(|e| match e { + Entity::MText(m) => Some(m), + _ => None, + }) + .expect("MTEXT"); + assert_eq!(mtext.text, "A\\PB"); + assert_eq!(mtext.attachment, Some(MTextAttachment::MiddleCenter)); + assert_eq!(mtext.rect_width, 80.0); + assert_eq!( + (mtext.extents_width, mtext.extents_height), + (None, None), + "the file states no extents" + ); + assert!( + (mtext.rotation - FRAC_PI_2).abs() < 1e-12, + "atan2(1, 0): {}", + mtext.rotation + ); +} + +#[test] +fn autocad_written_attributes_carry_their_tag_and_style() { + let db = uncad::parse(EXAMPLE_2000_DWG).expect("corpus file must parse"); + let attribs: Vec<&uncad::model::AttribEntity> = db + .entities + .iter() + .filter_map(|e| match e { + Entity::Attrib(a) => Some(a), + _ => None, + }) + .collect(); + assert!(!attribs.is_empty(), "example_2000.dwg has ATTRIBs"); + for attrib in &attribs { + assert!(!attrib.tag.is_empty(), "{attrib:?}"); + assert!( + matches!(attrib.style_name, Ref::Resolved(_)), + "an AutoCAD-written attribute names its style: {attrib:?}" + ); + } + // And the INSERT's own copy agrees. + let inserts_attribs: Vec<&uncad::model::AttribEntity> = db + .entities + .iter() + .filter_map(|e| match e { + Entity::Insert(i) => Some(i.attribs.iter()), + _ => None, + }) + .flatten() + .collect(); + assert_eq!(inserts_attribs, attribs); +} From b1aeacc0252a71087c12d4c831f7a2e5b99e9779 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:32:03 +0900 Subject: [PATCH 22/54] Read an attribute's flags ATTRIB and ATTDEF carry AttributeFlags from DXF 70 -- bit 1 invisible, 2 constant, 4 verify, 8 preset -- instead of the no-flag default. An invisible attribute (a title block's hidden field) was indistinguishable from a shown one. Golden case G13 (justified text, text styles and an invisible attribute next to a visible one) now reads back exactly, with one known deviation applied to the expectation and pinned by the existing tripwire test: a TEXT naming the text style GOST, which the file never declares, is pointed by LibreDWG's DXF importer at STANDARD -- the entry an absent group 7 means -- and the name it wrote is lost, the same cause as the undeclared block and dimension-style names G10 and G5 already pin. tests/fixtures.rs pins the nested-attribute fixture from the parser's side of disposition #12: the INSERT inside DOOR carries D-101 as its own attribute and DOOR's entity list does not repeat it; the top level lists the model-space INSERT's D-TOP once after it. A/B: no attribute of the corpus or the samples sets any of the four bits, and all 1,401 ATTRIBs agree with the feature branch's invisible flag. Ported-from: c045d6a, 47d0b00 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + crates/uncad/src/convert.rs | 23 +- crates/uncad/tests/fixtures.rs | 62 + crates/uncad/tests/golden.rs | 53 +- crates/uncad/tests/golden/g13.dxf | 746 ++++++++++++ crates/uncad/tests/golden/g13.expected.json | 1128 +++++++++++++++++++ 6 files changed, 2010 insertions(+), 5 deletions(-) create mode 100644 crates/uncad/tests/golden/g13.dxf create mode 100644 crates/uncad/tests/golden/g13.expected.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a39bda..be9bb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ Notable changes to this project are recorded here. The format follows radians) and the text style they name (DXF 7). An MTEXT carries its reference width (DXF 41), the extents its writer measured (DXF 42/43, `None` when not stated) and its text style. +- ATTRIB and ATTDEF carry their `flags` (DXF 70): invisible, constant, verify, preset. An + invisible attribute (a title block's hidden field, say) is no longer indistinguishable + from a shown one. ### Changed - A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 0b97e8e..a280b34 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -26,7 +26,9 @@ use uncad_model::model::{ Origin, PointEntity, PolylineEntity, RayEntity, Ref, SegmentWidth, Solid3DEntity, SolidEntity, SplineEntity, TextEntity, TextOverride, ToleranceEntity, ViewportEntity, WipeoutEntity, }; -use uncad_model::model::{HorizontalJustification, Point2D, Point3D, VerticalJustification}; +use uncad_model::model::{ + AttributeFlags, HorizontalJustification, Point2D, Point3D, VerticalJustification, +}; /// The `flag` bit that means "closed" on POLYLINE_2D and POLYLINE_3D: bit 1, /// as in DXF group 70 and as `dwg.h` documents for `Dwg_Entity_POLYLINE_2D`. @@ -721,6 +723,19 @@ fn text_placement( } } +/// An ATTRIB's or ATTDEF's flags (DXF 70), one per bit as the reference +/// names them: 1 invisible, 2 constant, 4 verify, 8 preset. An absent group +/// is no flag set. +fn attribute_flags(entity_ptr: *mut std::ffi::c_void, dxfname: &str) -> AttributeFlags { + let flags = get_field::(entity_ptr, dxfname, "flags").unwrap_or(0); + AttributeFlags { + invisible: flags & 1 != 0, + constant: flags & 2 != 0, + verify: flags & 4 != 0, + preset: flags & 8 != 0, + } +} + /// The text style (DXF 7) a TEXT, ATTRIB, ATTDEF or MTEXT names, as a /// reference like a layer. The DXF reference's default for an absent group /// is the style named STANDARD, and LibreDWG's DXF importer already points @@ -1066,6 +1081,7 @@ unsafe fn convert_entity( // Read before `text` is shadowed by the value below. let tag = text.field(entity_ptr, "ATTRIB", "tag").unwrap_or_default(); let placement = text_placement(dwg, text, entity_ptr, "ATTRIB"); + let flags = attribute_flags(entity_ptr, "ATTRIB"); let text = text .field(entity_ptr, "ATTRIB", "text_value") .unwrap_or_default(); @@ -1077,7 +1093,7 @@ unsafe fn convert_entity( tag, text, rotation, - flags: Default::default(), + flags, horizontal_justification: placement.horizontal, vertical_justification: placement.vertical, alignment_point: placement.alignment_point, @@ -1140,6 +1156,7 @@ unsafe fn convert_entity( let rotation = get_field::(entity_ptr, "ATTDEF", "rotation").unwrap_or(0.0); let tag = text.field(entity_ptr, "ATTDEF", "tag").unwrap_or_default(); let placement = text_placement(dwg, text, entity_ptr, "ATTDEF"); + let flags = attribute_flags(entity_ptr, "ATTDEF"); Entity::Attdef(AttdefEntity { common, start_point, @@ -1147,7 +1164,7 @@ unsafe fn convert_entity( tag, default_value, rotation, - flags: Default::default(), + flags, horizontal_justification: placement.horizontal, vertical_justification: placement.vertical, alignment_point: placement.alignment_point, diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index c005123..897350b 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -588,3 +588,65 @@ fn polyline_bulges_are_carried_as_the_file_states_them() { assert!((arc.start_angle - 315f64.to_radians()).abs() < 1e-9); assert!((arc.end_angle - 45f64.to_radians()).abs() < 1e-9); } + +// -------------------------------------------------------- nested attrib + +/// The attribute of a block reference nested in another block. The inner +/// INSERT (`55`, inside `DOOR`) carries its value as its own attribute, and +/// the block's entity list does not carry that ATTRIB a second time; the +/// model-space INSERT (`61`) carries its value and the top level lists it +/// once more after it, as every drawn INSERT's attributes are. +#[test] +fn a_nested_block_reference_carries_its_attribute_once() { + let db = parse(NESTED_ATTRIB); + let attribs = |e: &Entity| -> Vec<(String, String, String)> { + match e { + Entity::Insert(i) => i + .attribs + .iter() + .map(|a| { + let Ref::Resolved(h) = &a.common.source_handle else { + panic!("{a:?}"); + }; + (h.clone(), a.tag.clone(), a.text.clone()) + }) + .collect(), + _ => Vec::new(), + } + }; + let entry = |h: &str, tag: &str, text: &str| (h.to_string(), tag.to_string(), text.to_string()); + + let door: Vec<(&str, &str)> = db.tables.block_records["DOOR"] + .entities + .iter() + .map(|e| (e.type_name(), handle(e))) + .collect(); + assert_eq!(door, [("LINE", "52"), ("LINE", "53"), ("INSERT", "55")]); + assert_eq!( + attribs(&db.tables.block_records["DOOR"].entities[2]), + [entry("56", "NUM", "D-101")] + ); + // The definition keeps its ATTDEF, with no flag set. + let tag = db.tables.block_records["TAG"] + .entities + .iter() + .find_map(|e| match e { + Entity::Attdef(a) => Some(a), + _ => None, + }) + .expect("TAG's ATTDEF"); + assert_eq!( + (tag.tag.as_str(), tag.default_value.as_str()), + ("NUM", "D-000") + ); + assert_eq!(tag.flags, uncad::model::AttributeFlags::default()); + + let top: Vec<(&str, &str)> = db + .entities + .iter() + .map(|e| (e.type_name(), handle(e))) + .collect(); + assert_eq!(top, [("INSERT", "60"), ("INSERT", "61"), ("ATTRIB", "62")]); + assert_eq!(attribs(&db.entities[0]), []); + assert_eq!(attribs(&db.entities[1]), [entry("62", "NUM", "D-TOP")]); +} diff --git a/crates/uncad/tests/golden.rs b/crates/uncad/tests/golden.rs index 01f8af5..af3b8d2 100644 --- a/crates/uncad/tests/golden.rs +++ b/crates/uncad/tests/golden.rs @@ -20,7 +20,7 @@ use uncad::model::Ref; use uncad::{CadDatabase, Entity}; /// Every case, as (name, DXF bytes, expected model JSON). -const CASES: [(&str, &[u8], &str); 11] = [ +const CASES: [(&str, &[u8], &str); 12] = [ ( "g1", include_bytes!("golden/g1.dxf"), @@ -71,6 +71,11 @@ const CASES: [(&str, &[u8], &str); 11] = [ include_bytes!("golden/g12.dxf"), include_str!("golden/g12.expected.json"), ), + ( + "g13", + include_bytes!("golden/g13.dxf"), + include_str!("golden/g13.expected.json"), + ), ( "g15", include_bytes!("golden/g15.dxf"), @@ -111,6 +116,12 @@ impl Drop for Fixture { /// It is applied to the expectation rather than by weakening the /// comparison, so every other value in those cases stays pinned exactly. /// +/// A text style takes a different turn through the same importer: a TEXT +/// naming a style the file never declares (G13's `GOST`) is pointed at the +/// STANDARD entry instead, the entry an absent group 7 means. The name the +/// file wrote is lost the same way; what arrives is a resolved reference +/// to a style the entity did not name. +/// /// The style table carries a second, smaller one. A DIMSTYLE writes a /// variable only when it differs from the value the application starts from, /// and this library holds a style as a struct with no "the group was not @@ -163,6 +174,19 @@ fn apply_known_deviations(actual: &CadDatabase, expected: &mut CadDatabase) { } fn lower(entity: &mut Entity) { + let text_style = match entity { + Entity::Text(text) => Some(&mut text.style_name), + Entity::Attrib(attrib) => Some(&mut attrib.style_name), + Entity::Attdef(attdef) => Some(&mut attdef.style_name), + Entity::MText(mtext) => Some(&mut mtext.style_name), + _ => None, + }; + if let Some(style) = text_style { + if matches!(style, Ref::Unresolved(_)) { + *style = Ref::Resolved("STANDARD".to_string()); + } + return; + } let reference = match entity { Entity::Insert(insert) => &mut insert.block_name, Entity::Dimension(dimension) => &mut dimension.style_name, @@ -220,7 +244,7 @@ fn assert_reads_back_exactly(name: &str, dxf: &[u8], expected_json: &str) { } /// The tripwire for the deviation above: it asserts the defect is still -/// there, in both the places the golden cases put it. The day this reader +/// there, in all the places the golden cases put it. The day this reader /// returns the name the file wrote, this test fails -- which is the signal /// to delete both it and `apply_known_deviations`. #[test] @@ -257,6 +281,31 @@ fn the_dxf_importer_still_drops_an_undeclared_name() { &Ref::Absent, "the importer kept the name of an undeclared style -- remove the known deviation" ); + + let fixture = Fixture::write("golden-g13-deviation.dxf", include_bytes!("golden/g13.dxf")); + let db = uncad::parse(&fixture.0).expect("g13 should parse"); + let expected: CadDatabase = + serde_json::from_str(include_str!("golden/g13.expected.json")).unwrap(); + let styles = |db: &CadDatabase| -> Vec> { + db.entities + .iter() + .filter_map(|e| match e { + Entity::Text(t) => Some(t.style_name.clone()), + _ => None, + }) + .collect() + }; + let (read, stated) = (styles(&db), styles(&expected)); + let undeclared = stated + .iter() + .position(|s| matches!(s, Ref::Unresolved(_))) + .expect("g13 has a text naming a style the file never declares"); + assert_eq!(stated[undeclared], Ref::Unresolved("GOST".to_string())); + assert_eq!( + read[undeclared], + Ref::Resolved("STANDARD".to_string()), + "the importer kept the name of an undeclared text style -- remove the known deviation" + ); } #[test] diff --git a/crates/uncad/tests/golden/g13.dxf b/crates/uncad/tests/golden/g13.dxf new file mode 100644 index 0000000..091fb3b --- /dev/null +++ b/crates/uncad/tests/golden/g13.dxf @@ -0,0 +1,746 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$HANDSEED + 5 +113 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LTYPE + 5 +1 +100 +AcDbSymbolTable + 70 +1 + 0 +LTYPE + 5 +2 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CONTINUOUS + 70 +0 + 3 +Solid line + 72 +65 + 73 +0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +3 +100 +AcDbSymbolTable + 70 +2 + 0 +LAYER + 5 +4 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +CONTINUOUS + 0 +LAYER + 5 +5 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TEXT + 70 +0 + 62 +2 + 6 +CONTINUOUS + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +6 +100 +AcDbSymbolTable + 70 +2 + 0 +STYLE + 5 +7 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +STANDARD + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +8 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +ROMANS + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +100 +AcDbSymbolTable + 70 +3 + 0 +BLOCK_RECORD + 5 +A +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space + 0 +BLOCK_RECORD + 5 +B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space + 0 +BLOCK_RECORD + 5 +C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +TAG + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +100 +330 +A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +101 +330 +A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +102 +330 +B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +103 +330 +B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +104 +330 +C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +TAG + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +TAG + 1 + + 0 +ATTDEF + 5 +105 +330 +C +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +2.5 + 1 +- +100 +AcDbAttributeDefinition + 3 +Number + 2 +NUMBER + 70 +0 + 0 +ATTDEF + 5 +106 +330 +C +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +0.0 + 20 +-4.0 + 30 +0.0 + 40 +2.5 + 1 +- +100 +AcDbAttributeDefinition + 3 +Note + 2 +NOTE + 70 +0 + 0 +ENDBLK + 5 +107 +330 +C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +TEXT + 5 +108 +330 +A +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +2.5 + 1 +LEFT + 50 +0.0 +100 +AcDbText + 0 +TEXT + 5 +109 +330 +A +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +43.0 + 20 +0.0 + 30 +0.0 + 40 +2.5 + 1 +CENTER + 50 +0.0 + 7 +ROMANS + 72 +1 + 11 +50.0 + 21 +0.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +10A +330 +A +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +88.0 + 20 +-2.5 + 30 +0.0 + 40 +2.5 + 1 +RIGHT TOP + 50 +0.0 + 72 +2 + 11 +100.0 + 21 +0.0 + 31 +0.0 +100 +AcDbText + 73 +3 + 0 +TEXT + 5 +10B +330 +A +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +44.0 + 20 +18.75 + 30 +0.0 + 40 +2.5 + 1 +MIDDLE + 50 +0.0 + 41 +0.8 + 7 +ROMANS + 72 +4 + 11 +50.0 + 21 +20.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +10C +330 +A +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +40.0 + 30 +0.0 + 40 +2.5 + 1 +ALIGNED + 50 +0.0 + 51 +15.0 + 72 +3 + 11 +60.0 + 21 +40.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +10D +330 +A +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +60.0 + 30 +0.0 + 40 +2.5 + 1 +FIT + 50 +0.0 + 41 +2.5 + 72 +5 + 11 +60.0 + 21 +60.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +10E +330 +A +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +79.0 + 30 +0.0 + 40 +2.5 + 1 +LEFT BOTTOM + 50 +0.0 + 7 +GOST + 11 +0.0 + 21 +80.0 + 31 +0.0 +100 +AcDbText + 73 +1 + 0 +INSERT + 5 +10F +330 +A +100 +AcDbEntity + 8 +TEXT + 66 +1 +100 +AcDbBlockReference + 2 +TAG + 10 +120.0 + 20 +0.0 + 30 +0.0 + 41 +1.0 + 42 +1.0 + 43 +1.0 + 50 +0.0 + 0 +ATTRIB + 5 +110 +330 +10F +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +120.0 + 20 +0.0 + 30 +0.0 + 40 +2.5 + 1 +D-101 +100 +AcDbAttribute + 2 +NUMBER + 70 +0 + 0 +ATTRIB + 5 +111 +330 +10F +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +120.0 + 20 +-4.0 + 30 +0.0 + 40 +2.5 + 1 +FIRE RATED +100 +AcDbAttribute + 2 +NOTE + 70 +1 + 0 +SEQEND + 5 +112 +330 +10F +100 +AcDbEntity + 8 +TEXT + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/golden/g13.expected.json b/crates/uncad/tests/golden/g13.expected.json new file mode 100644 index 0000000..cf1d87d --- /dev/null +++ b/crates/uncad/tests/golden/g13.expected.json @@ -0,0 +1,1128 @@ +{ + "entities": [ + { + "type": "TEXT", + "common": { + "id": 264, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "108" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 0.0 + }, + "text_height": 2.5, + "text": "LEFT", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 265, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "109" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 43.0, + "y": 0.0 + }, + "text_height": 2.5, + "text": "CENTER", + "rotation": 0.0, + "horizontal_justification": "CENTER", + "vertical_justification": "BASELINE", + "alignment_point": { + "x": 50.0, + "y": 0.0 + }, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ROMANS" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 266, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10A" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 88.0, + "y": -2.5 + }, + "text_height": 2.5, + "text": "RIGHT TOP", + "rotation": 0.0, + "horizontal_justification": "RIGHT", + "vertical_justification": "TOP", + "alignment_point": { + "x": 100.0, + "y": 0.0 + }, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 267, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10B" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 44.0, + "y": 18.75 + }, + "text_height": 2.5, + "text": "MIDDLE", + "rotation": 0.0, + "horizontal_justification": "MIDDLE", + "vertical_justification": "BASELINE", + "alignment_point": { + "x": 50.0, + "y": 20.0 + }, + "width_factor": 0.8, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ROMANS" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 268, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10C" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 40.0 + }, + "text_height": 2.5, + "text": "ALIGNED", + "rotation": 0.0, + "horizontal_justification": "ALIGNED", + "vertical_justification": "BASELINE", + "alignment_point": { + "x": 60.0, + "y": 40.0 + }, + "width_factor": 1.0, + "oblique_angle": 0.2617993877991494, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 269, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10D" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 60.0 + }, + "text_height": 2.5, + "text": "FIT", + "rotation": 0.0, + "horizontal_justification": "FIT", + "vertical_justification": "BASELINE", + "alignment_point": { + "x": 60.0, + "y": 60.0 + }, + "width_factor": 2.5, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 270, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10E" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 79.0 + }, + "text_height": 2.5, + "text": "LEFT BOTTOM", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BOTTOM", + "alignment_point": { + "x": 0.0, + "y": 80.0 + }, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "UNRESOLVED", + "data": "GOST" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "INSERT", + "common": { + "id": 271, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10F" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "TAG" + }, + "insertion_point": { + "x": 120.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + }, + "rotation": 0.0, + "attribs": [ + { + "common": { + "id": 272, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "110" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 120.0, + "y": 0.0 + }, + "text_height": 2.5, + "tag": "NUMBER", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, + "text": "D-101", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "common": { + "id": 273, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "111" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 120.0, + "y": -4.0 + }, + "text_height": 2.5, + "tag": "NOTE", + "flags": { + "invisible": true, + "constant": false, + "verify": false, + "preset": false + }, + "text": "FIRE RATED", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "ATTRIB", + "common": { + "id": 272, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "110" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 120.0, + "y": 0.0 + }, + "text_height": 2.5, + "tag": "NUMBER", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, + "text": "D-101", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "ATTRIB", + "common": { + "id": 273, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "111" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 120.0, + "y": -4.0 + }, + "text_height": 2.5, + "tag": "NOTE", + "flags": { + "invisible": true, + "constant": false, + "verify": false, + "preset": false + }, + "text": "FIRE RATED", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ], + "tables": { + "layers": { + "0": { + "name": "0", + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "TEXT": { + "name": "TEXT", + "color_index": 2, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + } + }, + "block_records": { + "*Model_Space": { + "name": "*Model_Space", + "entities": [ + { + "type": "TEXT", + "common": { + "id": 264, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "108" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 0.0 + }, + "text_height": 2.5, + "text": "LEFT", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 265, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "109" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 43.0, + "y": 0.0 + }, + "text_height": 2.5, + "text": "CENTER", + "rotation": 0.0, + "horizontal_justification": "CENTER", + "vertical_justification": "BASELINE", + "alignment_point": { + "x": 50.0, + "y": 0.0 + }, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ROMANS" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 266, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10A" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 88.0, + "y": -2.5 + }, + "text_height": 2.5, + "text": "RIGHT TOP", + "rotation": 0.0, + "horizontal_justification": "RIGHT", + "vertical_justification": "TOP", + "alignment_point": { + "x": 100.0, + "y": 0.0 + }, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 267, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10B" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 44.0, + "y": 18.75 + }, + "text_height": 2.5, + "text": "MIDDLE", + "rotation": 0.0, + "horizontal_justification": "MIDDLE", + "vertical_justification": "BASELINE", + "alignment_point": { + "x": 50.0, + "y": 20.0 + }, + "width_factor": 0.8, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ROMANS" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 268, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10C" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 40.0 + }, + "text_height": 2.5, + "text": "ALIGNED", + "rotation": 0.0, + "horizontal_justification": "ALIGNED", + "vertical_justification": "BASELINE", + "alignment_point": { + "x": 60.0, + "y": 40.0 + }, + "width_factor": 1.0, + "oblique_angle": 0.2617993877991494, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 269, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10D" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 60.0 + }, + "text_height": 2.5, + "text": "FIT", + "rotation": 0.0, + "horizontal_justification": "FIT", + "vertical_justification": "BASELINE", + "alignment_point": { + "x": 60.0, + "y": 60.0 + }, + "width_factor": 2.5, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 270, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10E" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 79.0 + }, + "text_height": 2.5, + "text": "LEFT BOTTOM", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BOTTOM", + "alignment_point": { + "x": 0.0, + "y": 80.0 + }, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "UNRESOLVED", + "data": "GOST" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "INSERT", + "common": { + "id": 271, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10F" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "TAG" + }, + "insertion_point": { + "x": 120.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + }, + "rotation": 0.0, + "attribs": [ + { + "common": { + "id": 272, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "110" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 120.0, + "y": 0.0 + }, + "text_height": 2.5, + "tag": "NUMBER", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, + "text": "D-101", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "common": { + "id": 273, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "111" + }, + "layer": { + "type": "RESOLVED", + "data": "TEXT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 120.0, + "y": -4.0 + }, + "text_height": 2.5, + "tag": "NOTE", + "flags": { + "invisible": true, + "constant": false, + "verify": false, + "preset": false + }, + "text": "FIRE RATED", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ], + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ] + }, + "*Paper_Space": { + "name": "*Paper_Space", + "entities": [] + }, + "TAG": { + "name": "TAG", + "entities": [ + { + "type": "ATTDEF", + "common": { + "id": 261, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "105" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 0.0 + }, + "text_height": 2.5, + "tag": "NUMBER", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, + "default_value": "-", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "ATTDEF", + "common": { + "id": 262, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "106" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": -4.0 + }, + "text_height": 2.5, + "tag": "NOTE", + "flags": { + "invisible": false, + "constant": false, + "verify": false, + "preset": false + }, + "default_value": "-", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "STANDARD" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ] + } + }, + "dim_styles": {}, + "mlinestyles": {}, + "layouts": {} + }, + "read_diagnostics": { + "warnings": [] + } +} \ No newline at end of file From c01f1e3e7079ae624d5942fa138236ca2ce4f3cf Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:49:04 +0900 Subject: [PATCH 23/54] Retire the shim's code-page conversion, now that TextDecoder does it The native layer carried the feature branch's C string conversion into the shim (uncad_tv_to_utf8, uncad_bytes_to_utf8, their uncad_entity_* twins, uncad_dwg_string_to_utf8, uncad_free_string, uncad_codepage_name and the static convert_codepage/bytes_to_utf8 behind them). No Rust code calls any of them: TextDecoder decodes every string itself, with the DOS-era rules and the R2007+ DXF width split that were the reason for the C version, and unlike it reports what it cannot decode instead of falling back to ANSI_1252 in silence. Keeping a second implementation of the same rules would let the two drift, so they go, with their bindgen allowlist entries. What R2007+ DXF still needs from C is kept: the file-header accessors (version, from_version, from_dxf) and uncad_dwg_is_wide_string, which TextDecoder reads to tell the three storages apart. UTF-16 is read in Rust (dynapi::read_stored), so bit_convert_TU is not needed either; codepages.h is no longer included by the shim (wrapper.h still gives bindgen the table functions TextDecoder calls). The comment in the vendored dwg.c patch named uncad_tv_to_utf8 as the rule its predicate matches; it names crates/uncad/src/text.rs now (a comment only, the marker count is unchanged). The CHANGELOG entry for the shim and ARCHITECTURE's shim line no longer list the conversions. No output changes: the whole workspace suite passes unchanged. Ported-from: a37b7ba, 78cfe4b Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +- crates/libredwg-sys/build.rs | 15 +- crates/libredwg-sys/shim/uncad_shim.c | 262 +----------------- crates/libredwg-sys/shim/uncad_shim.h | 113 +------- crates/libredwg-sys/vendor/libredwg/src/dwg.c | 4 +- docs/ARCHITECTURE.md | 2 +- 6 files changed, 26 insertions(+), 385 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0a73c2..c1a75a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,15 +11,12 @@ Notable changes to this project are recorded here. The format follows - `libredwg-sys` reads a drawing from memory (`uncad_dwg_read_bytes`, `uncad_dxf_read_bytes`) -- LibreDWG's own file readers `fopen()` a byte string the MSVC runtime reads in the ANSI code page, so a non-ASCII path fails on Windows -- and - exposes what decoding a drawing's text and header needs: `uncad_dwg_version`, - `uncad_dwg_from_version`, `uncad_dwg_from_dxf`, `uncad_dwg_numheader_vars` (how long a - pre-R13 header is), `uncad_dwg_template_read` (whether the section holding - `$MEASUREMENT` was read), `uncad_codepage_name`, and the - code-page string conversions `uncad_tv_to_utf8`, `uncad_bytes_to_utf8`, their - `uncad_entity_*` twins, `uncad_dwg_string_to_utf8` and `uncad_free_string`, with - bindings for `dwg_version_type` and `dwg_next_object`. `uncad` reads every drawing - through the two memory readers and its header through the file-header accessors; it - does not call the string conversions. + exposes the file-header facts decoding a drawing's text and header needs: + `uncad_dwg_version`, `uncad_dwg_from_version`, `uncad_dwg_from_dxf`, + `uncad_dwg_numheader_vars` (how long a pre-R13 header is) and `uncad_dwg_template_read` + (whether the section holding `$MEASUREMENT` was read), with bindings for + `dwg_version_type` and `dwg_next_object`. Strings are not converted in C: `uncad`'s + `TextDecoder` does that, and reports what it cannot. - `uncad::parse_bytes(bytes, Format)` parses a drawing already in memory, and `uncad::Format` (`Dwg`, `Dxf`, with `Format::from_path`) says which it is. `parse()` now reads the file itself and decodes it from memory, so a path LibreDWG could not diff --git a/crates/libredwg-sys/build.rs b/crates/libredwg-sys/build.rs index ad725f2..ccdedf9 100644 --- a/crates/libredwg-sys/build.rs +++ b/crates/libredwg-sys/build.rs @@ -253,9 +253,9 @@ fn main() { .allowlist_function("uncad_dwg_is_r2013_or_later") .allowlist_function("uncad_dwg_codepage") .allowlist_function("uncad_dwg_is_wide_string") - // Reading from memory (Unicode paths on Windows, in-memory inputs), - // the file-header accessors, and the code-page string conversion -- - // see shim/uncad_shim.h for why each exists. + // Reading from memory (Unicode paths on Windows, in-memory inputs) + // and the file-header accessors -- see shim/uncad_shim.h for why + // each exists. .allowlist_function("uncad_dwg_read_bytes") .allowlist_function("uncad_dxf_read_bytes") .allowlist_function("uncad_dwg_version") @@ -263,15 +263,6 @@ fn main() { .allowlist_function("uncad_dwg_from_dxf") .allowlist_function("uncad_dwg_numheader_vars") .allowlist_function("uncad_dwg_template_read") - .allowlist_function("uncad_dwg_string_to_utf8") - .allowlist_function("uncad_tv_to_utf8") - .allowlist_function("uncad_entity_tv_to_utf8") - // The strings in_dxf.c stores 8-bit whatever the version (HEADER - // variables, MTEXT.text) -- never to be run through bit_convert_TU. - .allowlist_function("uncad_bytes_to_utf8") - .allowlist_function("uncad_entity_bytes_to_utf8") - .allowlist_function("uncad_free_string") - .allowlist_function("uncad_codepage_name") // Version enum -> "r2004"-style name, for the parsed header. .allowlist_function("dwg_version_type") // The codepage tables (src/codepages.h): what decodes a pre-R2007 diff --git a/crates/libredwg-sys/shim/uncad_shim.c b/crates/libredwg-sys/shim/uncad_shim.c index 116ba55..87e1354 100644 --- a/crates/libredwg-sys/shim/uncad_shim.c +++ b/crates/libredwg-sys/shim/uncad_shim.c @@ -7,12 +7,10 @@ #include "dwg.h" #include "dwg_api.h" /* Private LibreDWG headers (vendor/libredwg/src, on the include path in - build.rs): bits.h for Bit_Chain, IS_FROM_TU_DWG and bit_TV_to_utf8; - decode.h for dwg_decode; in_dxf.h for dwg_read_dxf/dwg_read_dxfb; - codepages.h for the Dwg_Codepage enum; logging.h for the `loglevel` - global the file-based readers set from dwg->opts. */ + build.rs): bits.h for Bit_Chain and IS_FROM_TU_DWG; decode.h for + dwg_decode; in_dxf.h for dwg_read_dxf/dwg_read_dxfb; logging.h for the + `loglevel` global the file-based readers set from dwg->opts. */ #include "bits.h" -#include "codepages.h" #include "decode.h" #include "logging.h" /* must precede in_dxf.h (logging.h enforces it) */ #include "in_dxf.h" @@ -225,260 +223,6 @@ uncad_dwg_template_read (const Dwg_Data *dwg) return (dwg && dwg->Template.description) ? 1 : 0; } -char * -uncad_dwg_string_to_utf8 (const Dwg_Data *dwg, const char *s) -{ - if (!s) - return NULL; - if (dwg && IS_FROM_TU_DWG (dwg)) - return bit_convert_TU ((BITCODE_TU)s); - return uncad_tv_to_utf8 (dwg, s); -} - -const char * -uncad_codepage_name (uint16_t codepage) -{ - return dwg_codepage_dxfstr ((Dwg_Codepage)codepage); -} - -/* --- strings -------------------------------------------------------------- */ - -static char * -dup_string (const char *s) -{ - size_t n = strlen (s); - char *d = (char *)malloc (n + 1); - if (d) - memcpy (d, s, n + 1); - return d; -} - -/* Appends the UTF-8 encoding of one BMP code point (at most 3 bytes) and - returns how many bytes it wrote. Lone surrogates and anything outside the - BMP -- which no 8-bit code-page table produces -- become U+FFFD so the - 3-bytes-per-input-byte bound below always holds. */ -static size_t -put_utf8 (char *out, uint32_t wc) -{ - if (wc < 0x80) - { - out[0] = (char)wc; - return 1; - } - if (wc < 0x800) - { - out[0] = (char)(0xC0 | (wc >> 6)); - out[1] = (char)(0x80 | (wc & 0x3F)); - return 2; - } - if ((wc >= 0xD800 && wc <= 0xDFFF) || wc > 0xFFFF) - wc = 0xFFFD; - out[0] = (char)(0xE0 | (wc >> 12)); - out[1] = (char)(0x80 | ((wc >> 6) & 0x3F)); - out[2] = (char)(0x80 | (wc & 0x3F)); - return 3; -} - -/* Whether `c` opens a two-byte sequence in the double-byte code page `cp`. - - LibreDWG's dwg_codepage_is_twobyte answers "always" for the DOS-era - BIG5 (24) and GB2312 (31) pages, ASCII included, which paired every - table name up ("*Model_Space" -> "*M", "od", ...) and turned such a - drawing into one with no *Model_Space and no entities. Both are - EUC-style encodings whose lead and trail bytes are all >= 0x80, so the - high bit is the rule there. */ -static bool -opens_double_byte (Dwg_Codepage cp, unsigned char c) -{ - if (cp == CP_GB2312 || cp == CP_BIG5) - return c >= 0x80; - return dwg_codepage_is_twobyte (cp, c); -} - -/* Transcodes an 8-bit code-page string to UTF-8 with LibreDWG's own - code-page tables (dwg_codepage_uc / dwg_codepage_uwc / is_twobyte) and - then expands \U+XXXX / \M+nXXXX escapes the way bit_TV_to_utf8 does. - - This is a re-implementation of bit_TV_to_utf8_codepage (src/bits.c) - rather than a call to it, for two reasons found in review: that function - sizes its output at 1.5x the input for single-byte code pages and stops - *reading* once the output is full, so a CP1251/CP1252 string that is - mostly non-ASCII loses its tail ("Стена" -> "Стен"); and it writes a NUL - for a character the table cannot map, truncating the rest of the string. - Here the buffer is 3 bytes per input byte (the true bound) and an - unmappable character becomes U+FFFD. Four more table quirks are - corrected on the way (bits.c shares them): the DOS-era BIG5/GB2312 - pages pair only bytes >= 0x80 (see opens_double_byte); CP932 (22, DOS - Shift-JIS) is treated as double-byte although dwg_codepage_isasian - leaves it out, so its kanji do not go through the single-byte table one - byte at a time; cptbl_gb2312 is indexed by the 7-bit ISO-2022 form - (0x2121..0x777E) while the file holds EUC-CN bytes (0xA1A1..0xFEFE), so - the pair is masked to 7 bits before the lookup; and ASCII is never - looked up (see the loop), so 0x5C stays the backslash the text codes - need. `cp` must be a value dwg_codepage_dxfstr knows (the caller - checks). Returns NULL only when out of memory. */ -static char * -convert_codepage (const char *src, Dwg_Codepage cp) -{ - const bool is_asian = dwg_codepage_isasian (cp) || cp == CP_CP932; - const size_t srclen = strlen (src); - const unsigned char *p = (const unsigned char *)src; - const unsigned char *end = p + srclen; - char *out = (char *)malloc (srclen * 3 + 1); - char *expanded; - size_t o = 0; - - if (!out) - return NULL; - while (p < end) - { - uint32_t wc; - unsigned int c = *p++; - if (c < 0x80) - /* ASCII stays ASCII (no double-byte page has a lead byte below - 0x80). dwg_codepage_uwc maps 0x5C to the yen sign for CP932 and - to the won sign for JOHAB (their JIS X 0201 / KS X 1003 half), - but in a drawing 0x5C is the backslash of MTEXT's \P and of the - \U+XXXX escapes whatever the code page, and the tables' other - sub-0x80 rows are the identity. */ - wc = c; - else if (is_asian) - { - uint16_t cc = (uint16_t)c; - if (opens_double_byte (cp, (unsigned char)c) && p < end) - { - cc = (uint16_t)((cc << 8) | *p++); - if (cp == CP_GB2312) - cc &= 0x7F7F; - } - wc = (uint32_t)dwg_codepage_uwc (cp, cc); - if (wc == 0) - wc = 0xFFFD; - } - else - { - wc = (uint32_t)dwg_codepage_uc (cp, (unsigned char)c); - if (wc == 0) - wc = 0xFFFD; - } - o += put_utf8 (out + o, wc); - } - out[o] = '\0'; - - /* The escape expansion: with CP_UTF8, bit_TV_to_utf8 only rewrites the - \U+XXXX / \M+nXXXX sequences (in place, or in a fresh copy) and leaves - every other byte alone. */ - expanded = bit_TV_to_utf8 (out, (BITCODE_RS)CP_UTF8); - if (expanded && expanded != out) - { - free (out); - return expanded; - } - return out; -} - -/* The 8-bit half of uncad_tv_to_utf8: `s` holds bytes in the file's own - text encoding -- UTF-8 for an R2007+ DXF (what in_dxf.c's own TU - conversion, bit_utf8_to_TU, assumes of the file too), the header code - page for everything else -- and is never a UTF-16 buffer. */ -static char * -bytes_to_utf8 (const Dwg_Data *dwg, const char *s) -{ - unsigned int cp = (unsigned int)dwg->header.codepage; - char *converted; - - if ((dwg->opts & DWG_OPTS_IN) && dwg->header.from_version >= R_2007) - cp = CP_UTF8; - if (cp == CP_UTF8) - { - /* Only the \U+XXXX / \M+nXXXX escapes are rewritten; bit_TV_to_utf8 - returns NULL on error, its *input* when there was nothing to - convert, or a fresh buffer. Normalise all three to "a buffer the - caller owns". */ - converted = bit_TV_to_utf8 (s, (BITCODE_RS)CP_UTF8); - if (!converted || converted == s) - return dup_string (s); - return converted; - } - /* The code page is a raw RS from the file header (header.spec) and - LibreDWG indexes its tables with it unchecked -- a corrupt or unknown - value would read past cp_fntbl[]. CP_UNDEFINED ("mostly R11") and - CP_UTF16 (never valid for an 8-bit string) fall back to ANSI_1252, - LibreDWG's own default for an undeclared code page. */ - if (cp > CP_ANSI_1258 || cp == CP_UTF16 || !dwg_codepage_dxfstr ((Dwg_Codepage)cp)) - cp = CP_ANSI_1252; - - converted = convert_codepage (s, (Dwg_Codepage)cp); - return converted ? converted : dup_string (s); -} - -char * -uncad_tv_to_utf8 (const Dwg_Data *dwg, const char *s) -{ - if (!s) - return NULL; - /* R2007+ DWG: dynapi already handed out UTF-8 (bit_convert_TU). */ - if (!dwg || IS_FROM_TU_DWG (dwg)) - return dup_string (s); - - /* R2007+ DXF: in_dxf stores its T fields as UTF-16 (TU) as well, but - IS_FROM_TU_DWG is false for DXF input, so dynapi hands the UTF-16 - buffer out as if it were an 8-bit string (truncated at its first NUL, - which is why "*Model_Space" used to come back as "*"). Convert it the - way dynapi does for a DWG. The fields in_dxf.c stores 8-bit anyway - (see uncad_bytes_to_utf8) must not come through here: bit_convert_TU - scans for a 16-bit NUL and would read past their allocation. */ - if ((dwg->opts & DWG_OPTS_IN) && dwg->header.version >= R_2007) - { - char *converted = bit_convert_TU ((BITCODE_TU)(uintptr_t)s); - return converted ? converted : dup_string (""); - } - - return bytes_to_utf8 (dwg, s); -} - -char * -uncad_bytes_to_utf8 (const Dwg_Data *dwg, const char *s) -{ - if (!s) - return NULL; - if (!dwg || IS_FROM_TU_DWG (dwg)) - return dup_string (s); - return bytes_to_utf8 (dwg, s); -} - -/* The Dwg_Data owning an entity/object struct pointer (what - uncad_object_entity_ptr / uncad_object_object_ptr returned), or NULL - when dwg_obj_generic_to_object cannot walk back to the Dwg_Object. */ -static const Dwg_Data * -owning_dwg (const void *entity) -{ - int error = 0; - const Dwg_Object *obj; - if (!entity) - return NULL; - obj = dwg_obj_generic_to_object (entity, &error); - return (obj && !error) ? obj->parent : NULL; -} - -char * -uncad_entity_tv_to_utf8 (const void *entity, const char *s) -{ - return uncad_tv_to_utf8 (owning_dwg (entity), s); -} - -char * -uncad_entity_bytes_to_utf8 (const void *entity, const char *s) -{ - return uncad_bytes_to_utf8 (owning_dwg (entity), s); -} - -void -uncad_free_string (char *s) -{ - free (s); -} - /* --- MULTILEADER ------------------------------------------------------------ */ unsigned int diff --git a/crates/libredwg-sys/shim/uncad_shim.h b/crates/libredwg-sys/shim/uncad_shim.h index f5b124c..94e2aa2 100644 --- a/crates/libredwg-sys/shim/uncad_shim.h +++ b/crates/libredwg-sys/shim/uncad_shim.h @@ -60,18 +60,21 @@ int uncad_dxf_read_bytes(const unsigned char *buf, size_t len, Dwg_Data *dwg); /* --- file header ---------------------------------------------------------- * * Dwg_Data is opaque on the Rust side (see build.rs), and LibreDWG has no - * public accessor for these two header fields, which together with the - * codepage (uncad_dwg_codepage, below) drive how strings must be decoded - * (see uncad_tv_to_utf8): `version` is the version the drawing is held as, - * `from_version` the one it was read from, both Dwg_Version_Type enum values - * (dwg.h). Both return 0 for a NULL `dwg`. + * public accessor for these header fields, which together with the + * codepage (uncad_dwg_codepage, below) decide how the drawing's strings sit + * in memory and which header variables the file states: `version` is the + * version the drawing is held as, `from_version` the one it was read from, + * both Dwg_Version_Type enum values (dwg.h). Both return 0 for a NULL + * `dwg`. */ int uncad_dwg_version(const Dwg_Data *dwg); int uncad_dwg_from_version(const Dwg_Data *dwg); /* 1 when the data came from DXF text (LibreDWG's DWG_OPTS_INDXF flag on * `dwg->opts`), 0 for a DWG or a NULL `dwg`. Some fields mean something - * else on that path (a LAYER's plot flag, for one). */ + * else on that path (a LAYER's plot flag, for one), and an R2007+ DXF holds + * its strings differently from an R2007+ DWG (see uncad_dwg_is_wide_string). + */ int uncad_dwg_from_dxf(const Dwg_Data *dwg); /* How many header variables a pre-R13 DWG's file header says its header @@ -88,100 +91,6 @@ uint16_t uncad_dwg_numheader_vars(const Dwg_Data *dwg); * file never stated. */ int uncad_dwg_template_read(const Dwg_Data *dwg); -/* The DXF name of a Dwg_Codepage value -- as uncad_dwg_codepage returns it - * -- ("ANSI_1252", "ANSI_949", "UTF-8", ...) from LibreDWG's own table - * (dwg_codepage_dxfstr in src/codepages.h, which is not a public header), or - * NULL for a value it has no name for. The string is static; do not free it. - */ -const char *uncad_codepage_name(uint16_t codepage); - -/* --- strings --------------------------------------------------------------- - * - * Converts one string as returned by dwg_dynapi_*_utf8text() / - * dwg_dynapi_handle_name() with `isnew == 0` (i.e. a raw pointer into the - * parsed Dwg_Data, in the file's own code page) to UTF-8. - * - * This exists because LibreDWG's dynapi only transcodes the R2007+ UTF-16 - * path; for older files it returns the code-page bytes unchanged, and the - * Rust side used to run those through a lossy UTF-8 decode, turning every - * non-ASCII character (Korean text in R2000/R2004 drawings, the degree and - * plus-minus signs in dimension text) into U+FFFD. LibreDWG does ship the - * converter -- bit_TV_to_utf8() in src/bits.c, with built-in CP949/CP936/ - * CP1252/... tables, used only by its own DXF writer -- so this wraps it: - * - * - when IS_FROM_TU_DWG(dwg) the input is already UTF-8 and is copied; - * - for an R2007+ DXF (DWG_OPTS_IN set, version >= R_2007) the string in - * memory is UTF-16 -- in_dxf.c stores its T fields as TU, but - * IS_FROM_TU_DWG excludes DXF input so dynapi never converts them -- - * and it goes through bit_convert_TU() like a DWG's would. This is the - * one rule that is not true of every field: see uncad_bytes_to_utf8 - * for the strings in_dxf.c keeps 8-bit whatever the version; - * - an R2007+ DXF's 8-bit strings are UTF-8 (the DXF's own encoding from - * AC1021 on, and what in_dxf.c's TU conversion assumes) and only have - * their `\U+XXXX` / `\M+nXXXX` escapes expanded (bit_TV_to_utf8), as - * does a CP_UTF8 code page; - * - CP_UNDEFINED, CP_UTF16 and any value outside LibreDWG's tables fall - * back to ANSI_1252, LibreDWG's own default (an unchecked value would - * index past its tables); - * - otherwise the string is transcoded with LibreDWG's code-page tables, - * an unmappable character becoming U+FFFD, and the escapes expanded. - * The double-byte pages follow the file's bytes rather than every - * quirk of LibreDWG's tables: the DOS-era BIG5 (24) and GB2312 (31) - * pair only bytes >= 0x80 (dwg_codepage_is_twobyte pairs ASCII too, - * which garbled every table name and emptied the drawing), CP932 (22) - * is decoded as the double-byte Shift-JIS it is, and GB2312's EUC-CN - * bytes are masked to the 7-bit form its table is indexed by. - * - * The result is always a fresh heap buffer (or NULL only for a NULL `s` or - * out of memory) -- bit_TV_to_utf8() may return its input pointer unchanged, - * which is copied here so the caller never has to guess who owns what. - * Free with uncad_free_string. - */ -char *uncad_tv_to_utf8(const Dwg_Data *dwg, const char *s); - -/* uncad_tv_to_utf8 for a string that is 8-bit in memory whatever the file's - * version: the bytes are decoded as the file's own text encoding (UTF-8 for - * an R2007+ DXF, the header code page otherwise) with the escapes expanded, - * and bit_convert_TU() is never run over them. - * - * This exists because LibreDWG's DXF reader does not store every string the - * way the "R2007+ means UTF-16" rule says. dxf_header_read() runs while - * `header.version` is still R_INVALID (dxf_fixup_header sets it afterwards, - * and the $ACADVER branch only sets it for R13..R2000), so every HEADER text - * variable ($DIMPOST, ...) is a plain malloc'd copy of the file's bytes; - * and MTEXT's group 1/3 text chunks are strdup'd/realloc'd together with no - * version branch at all (in_dxf.c, "MTEXT text > 250 chars"). Handing - * either to bit_convert_TU() walks 16-bit units past the end of the - * allocation until a zero pair happens to occur, so the header's DIMPOST - * and every MTEXT of an R2007+ DXF came back as CJK-looking garbage with - * neighbouring heap bytes in it. Same result type and ownership as - * uncad_tv_to_utf8; a `dwg` that IS_FROM_TU_DWG (only a TF field can get - * here from one) is copied unchanged. - */ -char *uncad_bytes_to_utf8(const Dwg_Data *dwg, const char *s); - -/* Same as uncad_tv_to_utf8, but finds the owning Dwg_Data through the - * entity/object struct pointer (what uncad_object_entity_ptr / - * uncad_object_object_ptr returned -- the same pointer dynapi takes), via - * dwg_obj_generic_to_object(). If that lookup fails the string is copied - * without conversion. - */ -char *uncad_entity_tv_to_utf8(const void *entity, const char *s); - -/* uncad_bytes_to_utf8 with the same entity-pointer lookup as - * uncad_entity_tv_to_utf8: for the fields in_dxf.c stores 8-bit whatever - * the version (MTEXT.text). */ -char *uncad_entity_bytes_to_utf8(const void *entity, const char *s); - -/* Any string pointer read straight out of `dwg`'s structures (a T/TV field - * of an embedded struct dynapi cannot reach through utf8text, say): the - * UTF-16 a R2007+ DWG stores is converted with bit_convert_TU, anything - * else goes through uncad_tv_to_utf8. NULL in, NULL out. Free with - * uncad_free_string. */ -char *uncad_dwg_string_to_utf8(const Dwg_Data *dwg, const char *s); - -void uncad_free_string(char *s); - /* One leader-line's vertices from a MULTILEADER, flattened into a single * malloc'd (x,y,z) array -- see uncad_multileader_get_lines. */ typedef struct uncad_multileader_line @@ -319,8 +228,8 @@ uint16_t uncad_dwg_codepage(const Dwg_Data *dwg); * means the strings are the file's raw 8-bit code-page bytes. For a DXF it * is 0 whatever the version, although an R2007+ DXF holds most of its T * fields as UTF-16 in memory, which the accessors then hand out unconverted - * -- uncad_tv_to_utf8 and uncad_bytes_to_utf8 above say which strings are - * which. + * -- the Rust side (crates/uncad/src/text.rs) says which strings are which + * and reads each in its width. */ int uncad_dwg_is_wide_string(const Dwg_Data *dwg); diff --git a/crates/libredwg-sys/vendor/libredwg/src/dwg.c b/crates/libredwg-sys/vendor/libredwg/src/dwg.c index bcabcca..ca37ec0 100644 --- a/crates/libredwg-sys/vendor/libredwg/src/dwg.c +++ b/crates/libredwg-sys/vendor/libredwg/src/dwg.c @@ -2352,8 +2352,8 @@ dwg_find_table_control (Dwg_Data *restrict dwg, const char *restrict table) INSERT->block_header NULL. UNCAD_IS_TU_DWG() is the predicate that storage actually follows, and - matches what this crate's own shim uses (uncad_tv_to_utf8 in - shim/uncad_shim.c). uncad_record_name_utf8() is the single place that + matches the rule the uncad crate reads these strings by + (crates/uncad/src/text.rs). uncad_record_name_utf8() is the single place that reads a record's "name" through dynapi and decodes it when needed. Scope: *only* fields dynapi itself wrote. The strings in_dxf.c stores diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 950e406..f6db54f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,7 @@ crates/ than a path, and the file-header fields (version, codepage, string width, a pre-R13 header's length, whether the Template section was read) that decoding a drawing's text and header - need, and string conversions. + need. Strings themselves are decoded on the Rust side. vendor/libredwg/ the upstream C sources actually compiled (see "Build"), five of them carrying a local patch (NOTICE.md) vendor-config/ config.h -- hand-written, standing in for autotools' output From 892721df6c4e5b202cec74e691050ff3d35c43cf Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:51:31 +0900 Subject: [PATCH 24/54] Say what a large DXF costs to read, measured on this branch A DXF's decode grows faster than the square of its entity count inside LibreDWG's importer, so a drawing of a few megabytes takes a minute. The feature branch recorded this in CAVEATS (75b0461) with numbers from its own machine and generator; they did not reproduce here, so the paragraph carries this branch's measurement instead (release CLI, LINE-only DXFs with a handle and subclass markers per entity, summary only): 25 000 entities, 2.3 MB: 2.8 s 50 000 entities, 4.6 MB: 16.4 s 100 000 entities, 9.2 MB: 72.0 s The 0.2 CLI, whose conversion code differs, takes the same on the same files (3.0 s, 14.0 s), which is what places the cost in the importer. The feature branch's instrumentation of dwg_read_dxf (nearly all of it in dxf_entities_read, re-resolving the object-reference vector on every pool reallocation) is cited as its measurement, not repeated as this one. Its claim that a DWG of the same drawing parses in linear time was not measured here and is not carried over. parse()'s rustdoc and README's DXF scope say the same in a clause. Ported-from: 75b0461 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- crates/uncad/src/lib.rs | 5 +++-- docs/CAVEATS.md | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index aa4fde0..3addba6 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,8 @@ cargo run -p uncad-cli -- drawing.dwg -o all.svg --space all # every space i included: such a file holds its strings in two widths in LibreDWG's memory, and each is read in its own — see `docs/CAVEATS.md`, "DXF saved as R2007 or later is read". LibreDWG's own DXF importer is documented as working "for most - objects", so it is less complete than its DWG reading - ([`docs/CAVEATS.md`](./docs/CAVEATS.md)). + objects", so it is less complete than its DWG reading, and slower than linear + in the entity count ([`docs/CAVEATS.md`](./docs/CAVEATS.md)). 3. **Output** — the parsed model as JSON (`CadDatabase::to_json`, from `uncad-model`). SVG and PNG come from the [`iron-render-cad`](https://github.com/iyulab/iron-render-cad) crate (MIT), which diff --git a/crates/uncad/src/lib.rs b/crates/uncad/src/lib.rs index 22878c3..415fdb0 100644 --- a/crates/uncad/src/lib.rs +++ b/crates/uncad/src/lib.rs @@ -193,8 +193,9 @@ fn missing_required_groups(entities: &[Entity]) -> Vec { /// /// How complete DXF reading is depends on the entity type: LibreDWG's own /// DXF reader is documented as working "for most objects" rather than being -/// feature-complete the way DWG reading is. A DXF saved as R2007 or later is -/// read like any other; one whose +/// feature-complete the way DWG reading is, and it costs more than the +/// square of the entity count, so a DXF of ten megabytes takes over a +/// minute. A DXF saved as R2007 or later is read like any other; one whose /// entities nonetheless reach no model or paper space is /// [`ParseError::UnsupportedDxfVersion`] rather than an empty drawing. See /// `docs/CAVEATS.md`. diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index c90061a..66c53e5 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -155,6 +155,27 @@ compared an R2007+ DXF's table-record names in the wrong width. The per-entity degradation this section describes -- a type dropping out, or arriving as `Entity::Unknown` -- is what a DXF should cost; a whole-file refusal was not. +**DXF parse time grows faster than the file does.** Reading a DXF costs more than the +square of its entity count inside LibreDWG's importer, so sizes that are unremarkable for +a real drawing take minutes. Measured on DXFs of nothing but LINEs, each with its handle +and subclass markers (release build, wall clock around `uncad `, summary only, no +rendering, one Windows machine): + +| Entities | Bytes | Time | +|---|---|---| +| 25 000 | 2.3 MB | 2.8 s | +| 50 000 | 4.6 MB | 16.4 s | +| 100 000 | 9.2 MB | 72.0 s | + +The time is the importer's, not this crate's: the 0.2 CLI, whose conversion code is a +different one, takes the same on the same files (3.0 s and 14.0 s for the first two). When +the importer was instrumented on the 0.3 feature branch (commit `75b0461`), nearly all of +it was inside `dxf_entities_read`, where LibreDWG re-resolves its whole object-reference +vector every time `dwg_add_object` reallocates the object pool. There is nothing to fix on +this side of the FFI boundary, and a DWG does not go through the importer at all. A caller +that must accept large DXFs should bound the work itself (a size or entity-count limit +before calling `parse`, and a timeout). + ### DXF saved as R2007 or later is read, each string in the width it was stored in A DXF whose `$ACADVER` is `AC1021` (R2007) or later -- R2007, R2010, R2013 and R2018 From 941b6b8f01e498aa1eda963102b4d9e02d871f85 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:38:04 +0900 Subject: [PATCH 25/54] Read an entity's true colour where the file states one, and only there An entity's true colour was read when its colour's method said TRUECOLOR, which is wrong for both readers: an R2004+ DWG states the RGB under the colour's ENC flag 0x80 and never sets the method, so no DWG entity reported its true colour; the DXF importer answers a plain group 62 with the TRUECOLOR method and an RGB synthesised from its own ACI palette, so a DXF entity with only an index reported an RGB the file never wrote (example_2000.dxf's LINE 513, ACI 8, came back 0x414141). split_entity_color reads the flag first -- 0x80 an inline RGB, 0x40 a colour-book reference, which is not converted -- and for the DXF shapes drops an RGB that is the one the library synthesises for the entity's index. That RGB comes from the library itself: libredwg-sys now binds dwg_rgb_palette_index, so LibreDWG's table stays private to the parser and the model's display palette (upstream's pinned table, which differs on 222 of 256 indices) is untouched (owner decision on the palette). This makes the vendored common_entity_data.spec patch visible, and tests/vendored_patches.rs now pins it: HatchG.dwg's HATCH 29F (RGB and transparency) and the LWPOLYLINE 28D around it (RGB alone) both carry 0x1ae464, where the HATCH's colour read 0x0000e5 without the patch. Its module doc, the CAVEATS bullet and the CHANGELOG line that said the patch was not visible yet are rewritten. tests/fixtures.rs pins the four DXF spellings of entity_truecolor_r2000.dxf. A/B: the true colour of all 206,652 entities compared agrees with the feature branch; against upstream, 23 differ -- the example_*/sample_* DXFs' synthesised ACI RGBs are gone (10), HatchG.dwg and 2013/gh44-error.dwg report the colours they state (7), and the fixture (6). Ported-from: efd43cc, 598fc09, 0c07b95 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 28 +++-- crates/libredwg-sys/build.rs | 4 + crates/uncad/src/convert.rs | 165 ++++++++++++++++++++++++- crates/uncad/tests/fixtures.rs | 25 ++++ crates/uncad/tests/vendored_patches.rs | 45 +++++-- docs/CAVEATS.md | 32 ++++- 6 files changed, 272 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be9bb91..da234b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,16 +113,16 @@ Notable changes to this project are recorded here. The format follows "Local patches to the vendored LibreDWG". A DXF holding a polygon mesh is read instead of refused as a whole (critical error 2048: the importer did not know the mesh vertices' `AcDbPolygonMeshVertex` marker). A corrupt header date no longer ends the - process from inside the C library (0xC0000409 on Windows, from `strftime`). Two more - are not visible in this crate's output yet: an R2004+ entity carrying both a true - colour and a transparency no longer has the two swapped in the library's fields (this - crate reads a true colour only under the TRUECOLOR method, which the entities measured - do not have), and the importer compares an R2007+ DXF's table-record names decoded, so - its layer and block lookups no longer stop at the first character (such a DXF is still - refused). On the 208 corpus drawings the JSON output is byte-identical with and without - the patches. `build.rs` refuses to build when a patch's marker has gone missing, - which a re-vendor through `scripts/sync-libredwg-vendor.sh` would otherwise do in - silence. + process from inside the C library (0xC0000409 on Windows, from `strftime`). An R2004+ + entity carrying both a true colour and a transparency no longer has the two swapped in + the library's fields (`2004/HatchG.dwg`'s HATCH 29F: `0x1ae464`, not `0x0000e5`). One + more is not visible in this crate's output yet: the importer compares an R2007+ DXF's + table-record names decoded, so its layer and block lookups no longer stop at the first + character (such a DXF is still refused). When the patches landed, the JSON output of + the 208 corpus drawings was byte-identical with and without them; the colour-order one + shows since true colours are read as the file states them (below). `build.rs` refuses + to build when a patch's marker has gone missing, which a re-vendor through + `scripts/sync-libredwg-vendor.sh` would otherwise do in silence. - **Every vertex of a POLYLINE_2D/3D is read.** LibreDWG's point accessors end their walk one vertex early on every file older than R2004, so the last vertex was dropped: a closed square came back a triangle, a two-vertex arc a single point. The vertices now come from @@ -131,6 +131,14 @@ Notable changes to this project are recorded here. The format follows 20 of them one short). A polyline's VERTEX records are no longer reported as entities of their own either -- 62 `Unknown` VERTEX entities in seven pre-R13 DXFs. See `docs/CAVEATS.md`, "Polyline vertices come from the polyline's own chain". +- **An entity's true colour is the one the file states.** It was read only when the + colour's method said TRUECOLOR: an R2004+ DWG never sets the method (the RGB comes under + the colour's `0x80` flag), so no DWG entity reported its true colour, while the DXF + importer sets it for a plain group 62 with an RGB taken from its own palette, so a DXF + entity with only an ACI index reported an RGB the file never wrote. The flag is read + first, and a DXF RGB that is the one the library synthesises for the entity's index is + not a true colour. See `docs/CAVEATS.md`, "An entity's true colour is what the file + states". - `MTextEntity::rotation` is the direction of the text's X axis instead of a constant `0`, which reported rotated multi-line text as horizontal. - A two-line angular dimension's `definition_point` (DXF 10) is read instead of reported as diff --git a/crates/libredwg-sys/build.rs b/crates/libredwg-sys/build.rs index 77c9e3b..2ed478b 100644 --- a/crates/libredwg-sys/build.rs +++ b/crates/libredwg-sys/build.rs @@ -276,6 +276,10 @@ fn main() { .allowlist_function("dwg_codepage_uc") .allowlist_function("dwg_codepage_uwc") .allowlist_function("dwg_codepage_dxfstr") + // The RGB the library's DXF importer synthesises for a plain ACI + // index (dxf_set_CMC_index): how an entity's stated true colour is + // told from one the importer made up. + .allowlist_function("dwg_rgb_palette_index") // The handle of the object a type-specific struct pointer belongs // to, for naming a string in a diagnostic. .allowlist_function("dwg_obj_generic_handlevalue") diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index a280b34..298af4c 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -2000,15 +2000,82 @@ fn dimension_dxfname(fixedtype: libredwg_sys::DWG_OBJECT_TYPE) -> &'static str { } } +/// `Dwg_Color.flag` bit: an inline 24-bit RGB follows in `rgb`. The *only* +/// statement an R2004+ DWG entity makes about a true colour -- `bit_read_ENC` +/// (bits.c), which `common_entity_data.spec` uses from R2004 on, reads `rgb` +/// under this bit and never assigns `method` at all. +const COLOR_FLAG_INLINE_RGB: u16 = 0x80; + +/// `Dwg_Color.flag` bit: a DBCOLOR object handle follows *instead of* an +/// inline RGB. That object is not converted, so there is no colour to report +/// and whatever `rgb` holds is not it. +const COLOR_FLAG_COLOR_HANDLE: u16 = 0x40; + /// Reads the common `color` (`Dwg_Color`) field and splits it into /// `(color_index, true_color)` per [`EntityCommon`]. fn entity_color(entity_ptr: *mut std::ffi::c_void) -> (i16, Option) { let Some(color) = get_common_field::(entity_ptr, "color") else { return (256, None); // no color field at all -- BYLAYER default }; - let true_color = (color.method == libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_TRUECOLOR) - .then_some(color.rgb & 0xff_ffff); - (color.index, true_color) + split_entity_color(color.index, color.flag, color.method, color.rgb, |index| { + // SAFETY: a pure lookup in the library's static palette; any index + // is accepted (256 and above answer 0). + unsafe { libredwg_sys::dwg_rgb_palette_index(index) } + }) +} + +/// Decides whether a `Dwg_Color` read off an *entity* really states a direct +/// RGB, from the three fields the two readers fill differently. Testing +/// `method == TRUECOLOR` alone is wrong in both directions: +/// +/// * **DWG, R2004+** -- `bit_read_ENC` puts the 420 value in `rgb` under +/// `flag & 0x80` and leaves `method` at 0, so a real true colour was +/// dropped: no entity of any corpus DWG reported one. +/// * **DXF** -- `dxf_set_CMC_index` (in_dxf.c) answers a plain group 62 with +/// `method = 0xc3` and an `rgb` *synthesised* from LibreDWG's own ACI +/// palette, so an entity that states only an index was reported as +/// carrying an RGB the file never wrote. A real group 420 instead takes the +/// `color.method = value >> 24` path, which is 0 for a plain 24-bit value. +/// +/// `palette` is the RGB the library synthesises for an ACI index -- its own +/// table (`dwg_rgb_palette_index`), which is not the display palette the +/// model publishes; the two disagree on most indices, and only the +/// library's tells a synthesised RGB from a stated one. +/// +/// Two cases stay indistinguishable from the fields available and are +/// reported as "no true colour", which renders identically either way: a +/// group 420 of pure black (`rgb` 0 is also what an untouched field holds), +/// and a group 420 that repeats the library's RGB for the entity's own ACI +/// index exactly. +fn split_entity_color( + index: i16, + flag: u16, + method: libredwg_sys::Dwg_Color_Method, + rgb: u32, + palette: impl Fn(u16) -> u32, +) -> (i16, Option) { + let rgb24 = rgb & 0xff_ffff; + if flag & COLOR_FLAG_COLOR_HANDLE != 0 { + return (index, None); + } + if flag & COLOR_FLAG_INLINE_RGB != 0 { + return (index, Some(rgb24)); + } + // The DXF reader's method byte. VOID (0) is a plain 24-bit group 420; + // ACI (0xc2) and TRUECOLOR (0xc3) are a pre-tagged one -- except that + // 0xc2 with no RGB is how it spells BYLAYER and 0xc3 with the palette's + // own entry is how it spells a plain group 62. + let tagged_rgb = matches!( + method, + libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_VOID + | libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_ACI + | libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_TRUECOLOR + ); + let from_palette = u16::try_from(index).is_ok_and(|i| palette(i) & 0xff_ffff == rgb24); + ( + index, + (tagged_rgb && rgb24 != 0 && !from_palette).then_some(rgb24), + ) } /// # Safety @@ -2253,6 +2320,98 @@ fn dimension_text_override(user_text: Option<&str>) -> TextOverride { mod tests { use super::*; + const VOID: libredwg_sys::Dwg_Color_Method = + libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_VOID; + const BYLAYER: libredwg_sys::Dwg_Color_Method = + libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_BYLAYER; + const BYBLOCK: libredwg_sys::Dwg_Color_Method = + libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_BYBLOCK; + const ACI: libredwg_sys::Dwg_Color_Method = libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_ACI; + const TRUECOLOR: libredwg_sys::Dwg_Color_Method = + libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_TRUECOLOR; + + /// The library's own palette, through the same lookup the conversion + /// uses. + fn split( + index: i16, + flag: u16, + method: libredwg_sys::Dwg_Color_Method, + rgb: u32, + ) -> (i16, Option) { + split_entity_color(index, flag, method, rgb, |i| unsafe { + libredwg_sys::dwg_rgb_palette_index(i) + }) + } + + /// Each case below is the `(index, flag, method, rgb)` LibreDWG leaves in + /// `Dwg_Color` for one way of writing a colour, read off the reader that + /// writes it: `bit_read_ENC` / `common_entity_data.spec` for the DWG + /// rows and `dxf_set_CMC_index` / the group-420 arm of the common-entity + /// loop in `in_dxf.c` for the DXF ones. + #[test] + fn split_entity_color_follows_what_each_reader_actually_stores() { + // --- R2004+ DWG (bit_read_ENC): flag 0x80 says an RGB follows, and + // nothing ever sets `method` on this path. + assert_eq!(split(256, 0x80, VOID, 0x00_ff7f), (256, Some(0x00_ff7f))); + // The stored BL may carry a method byte of its own; only the low 24 + // bits are the colour (which is what the DXF writer emits for 420). + assert_eq!(split(256, 0x80, VOID, 0xc200_ff7f), (256, Some(0x00_ff7f))); + // flag 0x40 is a DBCOLOR handle *instead of* an inline RGB (the spec + // reads one or the other), and that object is not converted. + assert_eq!(split(256, 0xc0, VOID, 0x00_ff7f), (256, None)); + // Plain BYLAYER: no flag bits, rgb zeroed by the decoder. + assert_eq!(split(256, 0, VOID, 0), (256, None)); + + // --- DXF group 62 only (dxf_set_CMC_index): method 0xc3 with `rgb` + // synthesised from LibreDWG's ACI palette. ACI 1 is 0xff0000 there, + // and ACI 8 -- where the library's table and the model's display + // palette disagree -- is 0x414141. + assert_eq!( + split(1, 0, TRUECOLOR, 0xc300_0000 | 0xff_0000), + (1, None), + "an index-only entity states no RGB" + ); + assert_eq!(split(8, 0, TRUECOLOR, 0xc341_4141), (8, None)); + // ... and the same for BYLAYER / BYBLOCK / none, which that function + // spells with an empty rgb. + assert_eq!( + split(256, 0, ACI, 0xc200_0000), + (256, None), + "0xc2 with no RGB is how the DXF reader spells BYLAYER" + ); + assert_eq!(split(0, 0, BYBLOCK, 0xc100_0000), (0, None)); + assert_eq!(split(256, 0, BYLAYER, 0xc000_0000), (256, None)); + + // --- DXF group 420. The common-entity arm stores the value verbatim + // and takes the method from its top byte, so a plain 24-bit RGB + // arrives with method 0 and a pre-tagged one with 0xc2 or 0xc3. + assert_eq!(split(256, 0, VOID, 65407), (256, Some(0x00_ff7f))); + assert_eq!( + split(3, 0, ACI, 0xc200_ff7f), + (3, Some(0x00_ff7f)), + "a 420 override wins over the entity's own group 62" + ); + assert_eq!(split(3, 0, TRUECOLOR, 0xc300_ff7f), (3, Some(0x00_ff7f))); + // Group 420 of 257 is the reader's "none": method 0xc8, rgb 0. + assert_eq!( + split( + 256, + 0, + libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_NONE, + 0xc800_0000 + ), + (256, None) + ); + } + + #[test] + fn the_librarys_palette_is_its_own_not_the_models() { + // The reason the lookup goes through the library: its ACI 8 is + // 0x414141, the model's display palette says 0x808080. + assert_eq!(unsafe { libredwg_sys::dwg_rgb_palette_index(8) }, 0x41_4141); + assert_eq!(uncad_model::color::aci_to_rgb(8), Some(0x80_8080)); + } + fn aci_stop(shift_value: f64, index: i16) -> libredwg_sys::Dwg_HATCH_Color { libredwg_sys::Dwg_HATCH_Color { parent: std::ptr::null_mut(), diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index 897350b..60d49c2 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -650,3 +650,28 @@ fn a_nested_block_reference_carries_its_attribute_once() { assert_eq!(attribs(&db.entities[0]), []); assert_eq!(attribs(&db.entities[1]), [entry("62", "NUM", "D-TOP")]); } + +// ------------------------------------------------------ entity truecolor + +/// The four ways a DXF entity can state its colour, on a layer whose own ACI +/// is 3. 65407 is `0x00ff7f` and 255 is `0x0000ff`, the file's own group 420 +/// values; a plain group 62 states no RGB, although LibreDWG's importer +/// synthesises one for it from its own palette. +#[test] +fn a_dxf_entity_carries_the_true_colour_it_states_and_no_other() { + let db = parse(ENTITY_TRUECOLOR); + let colors: Vec<(&str, i16, Option)> = db + .entities + .iter() + .map(|e| (handle(e), e.common().color_index, e.common().true_color)) + .collect(); + assert_eq!( + colors, + [ + ("30", 256, Some(0x00_ff7f)), + ("31", 1, None), + ("32", 1, Some(0x00_00ff)), + ("33", 256, None), + ] + ); +} diff --git a/crates/uncad/tests/vendored_patches.rs b/crates/uncad/tests/vendored_patches.rs index 6b230b4..0efd52e 100644 --- a/crates/uncad/tests/vendored_patches.rs +++ b/crates/uncad/tests/vendored_patches.rs @@ -10,14 +10,9 @@ //! header date ends that build's process with 0xC0000409, and the polygon //! mesh makes it refuse the whole file with critical error 2048. //! -//! Two of the five are not observable through `parse()` yet, so they have no -//! test here. The `dwg.c` one decodes an R2007+ DXF's table names, and this -//! crate still refuses such a DXF before LibreDWG reads it. The -//! `common_entity_data.spec` one puts an entity's true colour and its -//! transparency back in their own fields, and the entity it was measured on -//! (HATCH 29F in `test-data/2004/HatchG.dwg`) comes out of this crate without -//! a true colour with the patch as without it: the colour is reported only -//! when its method says TRUECOLOR, and that entity's does not. +//! One of the five is not observable through `parse()` yet, so it has no +//! test here: the `dwg.c` one decodes an R2007+ DXF's table names, and this +//! crate still refuses such a DXF before LibreDWG reads it. use std::fs; use std::path::{Path, PathBuf}; @@ -27,6 +22,12 @@ const HELIX: &str = concat!( "/../../lib/libredwg/test/test-data/2000/Helix.dwg" ); +/// The R2004 drawing the colour-order patch was measured on. +const HATCH_G: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/2004/HatchG.dwg" +); + /// The same corpus R2000 DXF the other DXF tests read. const CORPUS_DXF: &str = concat!( env!("CARGO_MANIFEST_DIR"), @@ -205,3 +206,31 @@ fn a_polygon_mesh_does_not_cost_the_whole_dxf() { "the polygon mesh should add exactly one entity: {after:?}" ); } + +// --- src/common_entity_data.spec: an entity's RGB before its transparency -- + +/// HATCH `29F` of `2004/HatchG.dwg` carries both an inline RGB and a +/// transparency (its colour's ENC flag is `0xa0`); it lies inside LWPOLYLINE +/// `28D`, whose flag is `0x80` alone -- one BL, which no reading order can +/// get wrong -- and the file draws the two in the same colour. Without the +/// patch the spec read the HATCH's two BLs the other way round, so its +/// colour was the transparency word `0x020000e5` (a true colour of +/// `0x0000e5`) and the RGB went to the transparency field. +#[test] +fn an_entity_with_a_transparency_keeps_its_true_colour() { + let db = uncad::parse(HATCH_G).expect("the corpus DWG parses"); + let colour = |handle: &str| { + db.entities + .iter() + .find(|e| e.common().source_handle == uncad::model::Ref::Resolved(handle.to_string())) + .unwrap_or_else(|| panic!("HatchG.dwg has an entity {handle}")) + .common() + .true_color + }; + assert_eq!(colour("28D"), Some(0x1a_e464), "the LWPOLYLINE, one BL"); + assert_eq!( + colour("29F"), + Some(0x1a_e464), + "the HATCH, RGB and transparency" + ); +} diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index df8bd31..3c7f61c 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -527,7 +527,7 @@ confirmation, which put rotated text on its side without saying so.) ## There is no single ACI colour table -`ACI_PALETTE` in `crates/uncad/src/color.rs` maps colour index 1-255 to RGB, and which RGB +`ACI_PALETTE` in `uncad-model`'s `color.rs` maps colour index 1-255 to RGB, and which RGB values are "right" has no one answer. AutoCAD's *displayed* colours depend on the drawing-area background, so a table captured from a dark model space and one captured from a white sheet disagree with each other; a third-party reader may carry a table that matches @@ -547,6 +547,27 @@ Two entries are not colours: index 0 is a placeholder and index 256 is the BYLAY Both are `0`, which is what lets `aci_to_hex` stay total over `0..=256` without a branch -- and what makes the unresolved-layer case below come out black rather than panicking. +## An entity's true colour is what the file states + +`common.true_color` is the 24-bit RGB an entity states (DXF 420), beside its ACI index. +The two readers leave it in different shapes, and the colour's method byte alone tells +neither apart. An R2004+ DWG entity states it under its colour's ENC flag `0x80` and +never sets the method, so a test on the method dropped every true colour of every DWG. +The DXF importer, for its part, answers a plain group 62 with the TRUECOLOR method and an +RGB it *synthesises* from its own copy of the ACI palette, so an entity that states only +an index looked as if it carried an RGB. `split_entity_color` in `convert.rs` reads the +flag first (`0x80` an inline RGB, `0x40` a colour-book reference, which is not converted), +and for a DXF compares the RGB with the one the library synthesises for the entity's +index -- through the library itself (`dwg_rgb_palette_index`), because its table is not +the display palette the model publishes (the two differ on 222 of 256 indices, see "There +is no single ACI colour table"): an RGB that is the synthesised one is not a stated true +colour. Two cases stay indistinguishable and read as no true colour, which draws the same +either way: a stated 420 of pure black, and a stated 420 that repeats the library's RGB +for the entity's own index. `tests/fixtures.rs` pins the four DXF spellings +(`entity_truecolor_r2000.dxf`). Over the corpus, the DXF readings of `example_*` and +`sample_*` no longer report a true colour for their entities of plain ACI 8 or 3, and the +R2004+ DWGs `2004/HatchG` and `2013/gh44-error` now report the ones they state. + ## Layer colors: `Dwg_Color.rgb` is untrustworthy, and `color_index` needs a fallback `Tables` does not expose a layer's `Dwg_Color.rgb`. On an older LibreDWG that field was @@ -774,11 +795,10 @@ a re-vendor that drops a patch fails by name instead of compiling upstream's cod `alpha_raw = 0xc21ae464` -- byte for byte the LWPOLYLINE's `rgb` -- and `rgb = 0x020000e5`, which has exactly the `alpha_type << 24 | alpha` shape every flag-`0x20` entity in the corpus shows. Only the `0xa0` combination changes: with one of - the two bits set there is a single BL and the order cannot matter. This crate does not - show the difference yet: it reports an entity's true colour only when the colour's - method says TRUECOLOR, and on that drawing neither entity's does, so both come out - without a true colour with the patch as without it. There is no regression test for it - here for that reason. + the two bits set there is a single BL and the order cannot matter. + `crates/uncad/tests/vendored_patches.rs` is the regression: both entities carry the true + colour `0x1ae464`, where the HATCH's was `0x0000e5` without the patch (see "An entity's + true colour is what the file states" for how the colour is read). ## No DWG/DXF writing From 2eea5dd02d65d33a14b8deeb7584de2039da2cb4 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:40:05 +0900 Subject: [PATCH 26/54] Read the dimension-style variables the displayed text depends on DimStyleRecord's arrow size (DIMASZ), linear unit format (DIMLUNIT), zero suppression (DIMZIN), rounding (DIMRND), angular unit format (DIMAUNIT), angular decimal places (DIMADEC) and fraction format (DIMFRAC) were None. They are read like the style's other variables: every number Some, an enumeration only for a value the format defines (DIMLUNIT 1..6, DIMAUNIT 0..4, DIMFRAC 0..2), the 16-bit ones read signed so DIMADEC's -1 survives. The rules for a style that states next to nothing -- the feature branch fell back to the header and capped DIMDEC -- are the export's, not the parser's. The golden test's DIMSTYLE deviation, which takes the library's starting value for every variable a DXF case does not state (the library cannot tell an omitted group from a written one), now covers the seven new fields too; every stated variable is still compared exactly. A/B: all eleven compared variables agree with the feature branch on all 335 styles of the oracle inputs. Ported-from: 74bb835, 837fb7d Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ crates/uncad/src/table_convert.rs | 53 ++++++++++++++++++++++++------- crates/uncad/tests/golden.rs | 16 ++++++++++ 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da234b6..c51da5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,11 @@ Notable changes to this project are recorded here. The format follows - ATTRIB and ATTDEF carry their `flags` (DXF 70): invisible, constant, verify, preset. An invisible attribute (a title block's hidden field, say) is no longer indistinguishable from a shown one. +- A DIMSTYLE carries the rest of what a dimension's displayed text depends on, each as an + `Option` like the others: `arrow_size` (DIMASZ), `linear_unit_format` (DIMLUNIT), + `zero_suppression` (DIMZIN), `rounding` (DIMRND), `angular_unit_format` (DIMAUNIT), + `angular_decimal_places` (DIMADEC) and `fraction_format` (DIMFRAC). What to use where a + style states nothing is the consumer's decision. ### Changed - A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an diff --git a/crates/uncad/src/table_convert.rs b/crates/uncad/src/table_convert.rs index 94debd5..d1b50f6 100644 --- a/crates/uncad/src/table_convert.rs +++ b/crates/uncad/src/table_convert.rs @@ -7,7 +7,10 @@ use crate::dynapi::{get_array_field, get_field, is_from_dxf, is_r2000_or_later}; use crate::text::TextDecoder; use std::collections::BTreeMap; use std::ffi::c_void; -use uncad_model::tables::{BlockRecord, DimStyleRecord, LayerRecord, Tables}; +use uncad_model::tables::{ + AngularUnitFormat, BlockRecord, DimStyleRecord, FractionFormat, LayerRecord, LinearUnitFormat, + Tables, +}; /// # Safety /// `dwg` must be a successfully-`dwg_read_file`'d, not-yet-`dwg_free`'d @@ -81,13 +84,19 @@ pub(crate) unsafe fn convert_tables( /// Reads a DIMSTYLE table entry -- the settings a dimension names rather than /// carries (see [`DimStyleRecord`]). /// -/// Every value comes back `Some`. This library holds a style as a struct with -/// no "the file did not write this group", so which of these the file stated -/// and which are the values it starts from cannot be told apart here; the -/// other reader of this format, which sees the groups themselves, can. +/// Every number comes back `Some` (an enumeration only when its value is one +/// the format defines). This library holds a style as a struct with no "the +/// file did not write this group", so which of these the file stated and +/// which are the values it starts from cannot be told apart here; the other +/// reader of this format, which sees the groups themselves, can. What to do +/// with a husk of a style -- one that states next to nothing -- is a +/// consumer's decision, like the displayed text itself. fn convert_dim_style(text: &TextDecoder, object_ptr: *mut c_void) -> Option { let name = text.field(object_ptr, "DIMSTYLE", "name")?; let number = |field: &str| get_field::(object_ptr, "DIMSTYLE", field); + // The 16-bit variables (BITCODE_BS). Read signed: DIMADEC's -1 ("as + // DIMDEC") is a value the format uses. + let small = |field: &str| get_field::(object_ptr, "DIMSTYLE", field).map(i32::from); Some(DimStyleRecord { name, post: text.field(object_ptr, "DIMSTYLE", "DIMPOST"), @@ -101,13 +110,33 @@ fn convert_dim_style(text: &TextDecoder, object_ptr: *mut c_void) -> Option(object_ptr, "DIMSTYLE", "DIMTDEC") .map(i32::from), text_height: number("DIMTXT"), - arrow_size: None, - linear_unit_format: None, - zero_suppression: None, - rounding: None, - angular_unit_format: None, - angular_decimal_places: None, - fraction_format: None, + arrow_size: number("DIMASZ"), + linear_unit_format: small("DIMLUNIT").and_then(|v| match v { + 1 => Some(LinearUnitFormat::Scientific), + 2 => Some(LinearUnitFormat::Decimal), + 3 => Some(LinearUnitFormat::Engineering), + 4 => Some(LinearUnitFormat::Architectural), + 5 => Some(LinearUnitFormat::Fractional), + 6 => Some(LinearUnitFormat::WindowsDesktop), + _ => None, + }), + zero_suppression: small("DIMZIN"), + rounding: number("DIMRND"), + angular_unit_format: small("DIMAUNIT").and_then(|v| match v { + 0 => Some(AngularUnitFormat::DecimalDegrees), + 1 => Some(AngularUnitFormat::DegreesMinutesSeconds), + 2 => Some(AngularUnitFormat::Gradians), + 3 => Some(AngularUnitFormat::Radians), + 4 => Some(AngularUnitFormat::SurveyorsUnits), + _ => None, + }), + angular_decimal_places: small("DIMADEC"), + fraction_format: small("DIMFRAC").and_then(|v| match v { + 0 => Some(FractionFormat::Horizontal), + 1 => Some(FractionFormat::Diagonal), + 2 => Some(FractionFormat::NotStacked), + _ => None, + }), }) } diff --git a/crates/uncad/tests/golden.rs b/crates/uncad/tests/golden.rs index af3b8d2..1a64131 100644 --- a/crates/uncad/tests/golden.rs +++ b/crates/uncad/tests/golden.rs @@ -144,6 +144,8 @@ fn apply_known_deviations(actual: &CadDatabase, expected: &mut CadDatabase) { (&mut style.tolerance_upper, read.tolerance_upper), (&mut style.tolerance_lower, read.tolerance_lower), (&mut style.text_height, read.text_height), + (&mut style.arrow_size, read.arrow_size), + (&mut style.rounding, read.rounding), ] { if want.is_none() { *want = got; @@ -163,11 +165,25 @@ fn apply_known_deviations(actual: &CadDatabase, expected: &mut CadDatabase) { &mut style.tolerance_decimal_places, read.tolerance_decimal_places, ), + (&mut style.zero_suppression, read.zero_suppression), + ( + &mut style.angular_decimal_places, + read.angular_decimal_places, + ), ] { if want.is_none() { *want = got; } } + if style.linear_unit_format.is_none() { + style.linear_unit_format = read.linear_unit_format; + } + if style.angular_unit_format.is_none() { + style.angular_unit_format = read.angular_unit_format; + } + if style.fraction_format.is_none() { + style.fraction_format = read.fraction_format; + } if style.post.is_none() { style.post = read.post.clone(); } From 925b46143b2a49c8219326e9adbfba320e3a6caa Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:43:38 +0900 Subject: [PATCH 27/54] Read an ordinate dimension's axis, and a DXF angular dimension's groups 10 and 16 Two parts of disposition #19, checked against upstream's latest mapping (9039b63) on the corpus and the fixtures: - ordinate_axis: bit 64 of group 70 says whether an ordinate measures its feature's x or y distance from the datum. A DXF, and a drawing older than R13, state it in `flag`; from R13 on a DWG states it as bit 1 of the stream-only `flag2`, and the decoder rebuilds `flag` from it wrongly (it sets bit 128 and clears bit 64), so `flag2` is read there. None on every other kind. - 9039b63 maps a two-line angular dimension's group 10 to xline2end_pt and group 16 to def_pt, which is right for a DWG only: the DXF importer fills those two fields by group code. Read from a DXF, every such dimension had its definition point and arc point exchanged -- example_2000.dxf's 43B gave (3.54, 4128.87) for group 10, which the file states as (490.62, 4118.24). The mapping now depends on the reader; the rest of 9039b63's mapping was already right and is kept. corpus_sweep's angular test pins all five points for example_2000.dxf too. Golden case G16 (ordinate dimensions, a style stating every variable and one stating only its unit formats) reads back exactly; the fixtures angular_ordinate and radial pin every point by its group. A/B: the axis of all 75 ordinates and all five points of all 33 two-line angular dimensions of the oracle inputs agree with the feature branch; against upstream, only the 13 two-line angular dimensions of DXF inputs differ, each in exactly those two points. Ported-from: a7f9ea5, 74bb835 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- crates/uncad/src/convert.rs | 69 +- crates/uncad/tests/corpus_sweep.rs | 30 +- crates/uncad/tests/fixtures.rs | 125 ++ crates/uncad/tests/golden.rs | 7 +- crates/uncad/tests/golden/g16.dxf | 1056 +++++++++++++++++ crates/uncad/tests/golden/g16.expected.json | 1138 +++++++++++++++++++ docs/CAVEATS.md | 19 +- 8 files changed, 2415 insertions(+), 36 deletions(-) create mode 100644 crates/uncad/tests/golden/g16.dxf create mode 100644 crates/uncad/tests/golden/g16.expected.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c51da5d..db227f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ Notable changes to this project are recorded here. The format follows `zero_suppression` (DIMZIN), `rounding` (DIMRND), `angular_unit_format` (DIMAUNIT), `angular_decimal_places` (DIMADEC) and `fraction_format` (DIMFRAC). What to use where a style states nothing is the consumer's decision. +- An ordinate DIMENSION carries its `ordinate_axis` (DXF 70, bit 64): whether it measures + its feature's x or y distance from the datum. ### Changed - A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an @@ -147,7 +149,10 @@ Notable changes to this project are recorded here. The format follows - `MTextEntity::rotation` is the direction of the text's X axis instead of a constant `0`, which reported rotated multi-line text as horizontal. - A two-line angular dimension's `definition_point` (DXF 10) is read instead of reported as - not stated: the library keeps it under the field name `xline2end_pt`. + not stated: the library keeps it under the field name `xline2end_pt`. Read from a DXF, + the same dimension had its groups 10 and 16 (`definition_point` and `points.arc`) + exchanged: LibreDWG's DXF importer fills those two fields by group code, its DWG decoder + in stream order. - An MTEXT from a drawing older than R2000 reports a line spacing factor of `1` (the format has no such field there) instead of `0`, which is outside the factor's valid range. - A LEADER's `has_arrowhead` read from an R2010-or-later DWG is `None`. The vendored diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 298af4c..ec90268 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -23,8 +23,9 @@ use uncad_model::model::{ Face3DEntity, HatchBoundaryPath, HatchEdge, HatchEntity, HatchGradient, HatchPatternLine, InsertEntity, LeaderAnnotation, LeaderEntity, LeaderPath, LightEntity, LightType, LineEntity, LwPolylineEntity, MLineEntity, MLineVertex, MTextAttachment, MTextEntity, MultiLeaderEntity, - Origin, PointEntity, PolylineEntity, RayEntity, Ref, SegmentWidth, Solid3DEntity, SolidEntity, - SplineEntity, TextEntity, TextOverride, ToleranceEntity, ViewportEntity, WipeoutEntity, + OrdinateAxis, Origin, PointEntity, PolylineEntity, RayEntity, Ref, SegmentWidth, Solid3DEntity, + SolidEntity, SplineEntity, TextEntity, TextOverride, ToleranceEntity, ViewportEntity, + WipeoutEntity, }; use uncad_model::model::{ AttributeFlags, HorizontalJustification, Point2D, Point3D, VerticalJustification, @@ -1415,7 +1416,7 @@ unsafe fn convert_entity( // field name than every other subtype does. The mapping is written out // per subtype rather than passing this backend's field names // through, so one model field never holds two different points. - let (p13, p14, p15, p16) = dimension_point_fields(fixedtype); + let (p13, p14, p15, p16) = dimension_point_fields(fixedtype, is_from_dxf(dwg)); let point = |field: Option<&'static str>| { field.and_then(|f| get_point3d(entity_ptr, dxfname, f)) }; @@ -1437,10 +1438,13 @@ unsafe fn convert_entity( text_override: dimension_text_override( text.field(entity_ptr, dxfname, "user_text").as_deref(), ), - // A two-line angular dimension keeps group 10 in the record's - // last point, which this library names `xline2end_pt` (its - // `def_pt` holds group 16 -- see `dimension_point_fields`). - definition_point: if kind == Some(DimensionKind::Angular2Line) { + // A two-line angular dimension decoded from a DWG keeps group + // 10 in the record's last point, which this library names + // `xline2end_pt` (its `def_pt` holds group 16); the DXF + // importer fills the two by group code instead -- see + // `dimension_point_fields`. + definition_point: if kind == Some(DimensionKind::Angular2Line) && !is_from_dxf(dwg) + { get_point3d(entity_ptr, dxfname, "xline2end_pt") } else { get_point3d(entity_ptr, dxfname, "def_pt") @@ -1478,7 +1482,8 @@ unsafe fn convert_entity( c"DIMSTYLE", |handle_ptr| text.handle_name(dwg, handle_ptr), ), - ordinate_axis: None, + ordinate_axis: (kind == Some(DimensionKind::Ordinate)) + .then(|| ordinate_axis(dwg, entity_ptr, dxfname)), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_TABLE => { @@ -2228,14 +2233,44 @@ unsafe fn dxfname(obj: *mut libredwg_sys::Dwg_Object) -> String { .into_owned() } +/// Which coordinate an ordinate dimension measures (DXF 70, bit 64: X when +/// set). A DXF, and a drawing older than R13, state it in `flag`, the group +/// 70 the file wrote. From R13 on a DWG states it as bit 1 of the +/// stream-only `flag2` byte, and the decoder rebuilds `flag` from it wrongly +/// -- it sets bit 128 and clears bit 64 (`dwg.spec`'s DIMENSION_ORDINATE) -- +/// so there `flag2` is read. The DXF importer, for its part, never fills +/// `flag2`. +fn ordinate_axis( + dwg: *mut libredwg_sys::Dwg_Data, + entity_ptr: *mut std::ffi::c_void, + dxfname: &str, +) -> OrdinateAxis { + let x = if is_from_dxf(dwg) || is_pre_r13(dwg) { + get_field::(entity_ptr, dxfname, "flag").is_some_and(|f| f & 0x40 != 0) + } else { + get_field::(entity_ptr, dxfname, "flag2").is_some_and(|f| f & 1 != 0) + }; + if x { + OrdinateAxis::X + } else { + OrdinateAxis::Y + } +} + /// Which of this backend's point fields carries DXF group 13, 14, 15 and 16, /// per dimension subtype. `None` is a group the subtype does not write. /// /// The library's own names are not a mapping: `xline1_pt` is group 13 for a /// linear dimension, while a two-line angular dimension calls its group 13 -/// `xline1start_pt` and its group 16 `xline2end_pt`. +/// `xline1start_pt` and its group 16 `xline2end_pt` -- when the DXF importer +/// filled the record, that is. The two readers disagree on a two-line +/// angular dimension's last two points: the DWG decoder fills the record in +/// stream order (the leading 2RD, `def_pt`, is the arc point, group 16, and +/// `xline2end_pt` the last point, group 10), the DXF importer by group code +/// (`def_pt` 10, `xline2end_pt` 16). `from_dxf` says which reader filled it. fn dimension_point_fields( fixedtype: libredwg_sys::Dwg_Object_Type, + from_dxf: bool, ) -> ( Option<&'static str>, Option<&'static str>, @@ -2253,17 +2288,19 @@ fn dimension_point_fields( Some("center_pt"), None, ), - // Measured against the same drawing in both formats: this - // library's `def_pt` holds group 16 here, and `xline2end_pt` holds - // group 10 (the dimension's definition point) rather than 16. The - // mapping follows the measurement, not the field names. (An earlier - // measurement read `xline2end_pt` as group 13's point; that drawing - // has groups 10 and 13 at the same place, so it could not tell.) + // Measured against the same drawing in both formats: decoded from + // the DWG, this library's `def_pt` holds group 16 here, and + // `xline2end_pt` holds group 10 (the dimension's definition point) + // rather than 16; read from the DXF, each holds the group its name + // says. The mapping follows the measurement, not the field names. + // (An earlier measurement read `xline2end_pt` as group 13's point; + // that drawing has groups 10 and 13 at the same place, so it could + // not tell.) libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_DIMENSION_ANG2LN => ( Some("xline1start_pt"), Some("xline1end_pt"), Some("xline2start_pt"), - Some("def_pt"), + Some(if from_dxf { "xline2end_pt" } else { "def_pt" }), ), libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_DIMENSION_RADIUS | libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_DIMENSION_DIAMETER => { diff --git a/crates/uncad/tests/corpus_sweep.rs b/crates/uncad/tests/corpus_sweep.rs index 8697287..9881de3 100644 --- a/crates/uncad/tests/corpus_sweep.rs +++ b/crates/uncad/tests/corpus_sweep.rs @@ -255,25 +255,29 @@ fn the_corpus_distribution_is_what_it_was_when_last_measured() { } /// Which of this library's point fields is which DXF group cannot be read off -/// the field names for a two-line angular dimension: `def_pt` holds group 16 -/// there, and `xline2end_pt` holds group 10. The mapping in the reader follows -/// a measurement against the same drawing in both formats, so the measurement -/// is pinned here -- if the library's field layout changes, this fails rather -/// than the model quietly holding the wrong point. +/// the field names for a two-line angular dimension: decoded from a DWG, +/// `def_pt` holds group 16 there, and `xline2end_pt` holds group 10; the DXF +/// importer fills the same two fields the other way round. The mapping in the +/// reader follows a measurement against the same drawing in both formats, so +/// the measurement is pinned here, for both readers -- if the library's field +/// layout changes, this fails rather than the model quietly holding the wrong +/// point. /// /// The second drawing is what settles group 10: in the first, groups 10 and /// 13 are the same point, so a reading of either looks like the other. #[test] fn a_two_line_angular_dimensions_groups_are_the_ones_the_dxf_twin_states() { // Two unrelated drawings, so the mapping is not one file's accident. - angular_groups_match_the_twin( - "example_2000.dwg", - (490.6216519543077, 4118.24274338716), - (490.6216519543077, 4118.24274338716), - (-276.8548009664508, 4701.847034571434), - (172.7442546208081, 3207.985617767696), - (3.542714605046057, 4128.871501442696), - ); + for drawing in ["example_2000.dwg", "example_2000.dxf"] { + angular_groups_match_the_twin( + drawing, + (490.6216519543077, 4118.24274338716), + (490.6216519543077, 4118.24274338716), + (-276.8548009664508, 4701.847034571434), + (172.7442546208081, 3207.985617767696), + (3.542714605046057, 4128.871501442696), + ); + } angular_groups_match_the_twin( "2000/TS1.dwg", (28.3894217039915, 46.63480213521191), diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index 60d49c2..8dd5db2 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -675,3 +675,128 @@ fn a_dxf_entity_carries_the_true_colour_it_states_and_no_other() { ] ); } + +// --------------------------------------------------- dimension points + +fn dimensions(db: &CadDatabase) -> Vec<&uncad::model::DimensionEntity> { + db.entities + .iter() + .filter_map(|e| match e { + Entity::Dimension(d) => Some(d), + _ => None, + }) + .collect() +} + +fn p3(x: f64, y: f64) -> Option { + Some(uncad::model::Point3D { x, y, z: 0.0 }) +} + +/// Rounds a point to the fixture's own precision, so a hand-derived +/// coordinate (8.660254 for 10 sin 60) compares with the stored one. +fn rounded(p: Option) -> Option<(i64, i64)> { + p.map(|p| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64)) +} + +/// The two kinds whose points LibreDWG's DXF importer lays out differently +/// from its DWG decoder, every value derived by hand in the fixture's +/// README: a 2-line angular dimension between (0,0)-(10,0) and +/// (0,0)-(5, 8.660254) with its arc point 30 degrees along a radius of 5, +/// and an X- and a Y-type ordinate sharing the datum (100, 200) and the +/// feature (130, 250). Each point lands in the field of the group the file +/// wrote it in. +#[test] +fn the_angular_ordinate_fixture_reads_every_point_by_its_group() { + use uncad::model::{DimensionKind, OrdinateAxis}; + let db = parse(ANGULAR_ORDINATE); + let dims = dimensions(&db); + assert_eq!(dims.len(), 3); + + let angular = dims[0]; + assert_eq!(angular.kind, Some(DimensionKind::Angular2Line)); + assert_eq!( + rounded(angular.definition_point), + rounded(p3(5.0, 8.660254)), + "group 10" + ); + assert_eq!(angular.points.extension1, p3(0.0, 0.0), "group 13"); + assert_eq!(angular.points.extension2, p3(10.0, 0.0), "group 14"); + assert_eq!(angular.points.radial, p3(0.0, 0.0), "group 15"); + assert_eq!( + rounded(angular.points.arc), + rounded(p3(4.330127, 2.5)), + "group 16" + ); + assert_eq!(angular.ordinate_axis, None); + // Group 42 is pi/3, in radians as the file states it. + let measured = angular.measurement.expect("group 42"); + assert!( + (measured - std::f64::consts::FRAC_PI_3).abs() < 1e-12, + "{measured}" + ); + + for (d, axis, leader, value) in [ + (dims[1], OrdinateAxis::X, (130.0, 270.0), 30.0), + (dims[2], OrdinateAxis::Y, (150.0, 250.0), 50.0), + ] { + assert_eq!(d.kind, Some(DimensionKind::Ordinate)); + assert_eq!(d.ordinate_axis, Some(axis), "{:?}", d.common.source_handle); + assert_eq!(d.definition_point, p3(100.0, 200.0), "the datum, group 10"); + assert_eq!( + d.points.extension1, + p3(130.0, 250.0), + "the feature, group 13" + ); + assert_eq!( + d.points.extension2, + p3(leader.0, leader.1), + "the leader, group 14" + ); + assert_eq!(d.measurement, Some(value)); + } +} + +/// The three dimension kinds no corpus DXF carries, each point in the field +/// of the group the file wrote it in (the fixture's README). +#[test] +fn the_radial_fixture_reads_its_points_by_their_groups() { + use uncad::model::DimensionKind; + let db = parse(RADIAL); + let dims = dimensions(&db); + let kinds: Vec> = dims.iter().map(|d| d.kind).collect(); + assert_eq!( + kinds, + [ + Some(DimensionKind::Radius), + Some(DimensionKind::Diameter), + Some(DimensionKind::Angular3Point) + ] + ); + let (radius, diameter, angular) = (dims[0], dims[1], dims[2]); + assert_eq!( + radius.definition_point, + p3(0.0, 0.0), + "the centre, group 10" + ); + assert_eq!( + radius.points.radial, + p3(3.0, 4.0), + "on the circle, group 15" + ); + assert_eq!(radius.measurement, Some(5.0)); + assert_eq!(diameter.definition_point, p3(20.0, 0.0), "group 10"); + assert_eq!(diameter.points.radial, p3(20.0, 10.0), "group 15"); + assert_eq!(diameter.measurement, Some(10.0)); + assert_eq!( + rounded(angular.definition_point), + rounded(p3(44.330127, 2.5)), + "group 10" + ); + assert_eq!(angular.points.extension1, p3(50.0, 0.0), "group 13"); + assert_eq!( + rounded(angular.points.extension2), + rounded(p3(45.0, 8.660254)), + "group 14" + ); + assert_eq!(angular.points.radial, p3(40.0, 0.0), "the centre, group 15"); +} diff --git a/crates/uncad/tests/golden.rs b/crates/uncad/tests/golden.rs index 1a64131..d12bd6f 100644 --- a/crates/uncad/tests/golden.rs +++ b/crates/uncad/tests/golden.rs @@ -20,7 +20,7 @@ use uncad::model::Ref; use uncad::{CadDatabase, Entity}; /// Every case, as (name, DXF bytes, expected model JSON). -const CASES: [(&str, &[u8], &str); 12] = [ +const CASES: [(&str, &[u8], &str); 13] = [ ( "g1", include_bytes!("golden/g1.dxf"), @@ -81,6 +81,11 @@ const CASES: [(&str, &[u8], &str); 12] = [ include_bytes!("golden/g15.dxf"), include_str!("golden/g15.expected.json"), ), + ( + "g16", + include_bytes!("golden/g16.dxf"), + include_str!("golden/g16.expected.json"), + ), ]; /// Removes its file on drop, so a failing assertion leaves nothing behind. diff --git a/crates/uncad/tests/golden/g16.dxf b/crates/uncad/tests/golden/g16.dxf new file mode 100644 index 0000000..20b76bd --- /dev/null +++ b/crates/uncad/tests/golden/g16.dxf @@ -0,0 +1,1056 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$HANDSEED + 5 +11E + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LTYPE + 5 +1 +100 +AcDbSymbolTable + 70 +1 + 0 +LTYPE + 5 +2 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CONTINUOUS + 70 +0 + 3 +Solid line + 72 +65 + 73 +0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +3 +100 +AcDbSymbolTable + 70 +2 + 0 +LAYER + 5 +4 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +CONTINUOUS + 0 +LAYER + 5 +5 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +DIMS + 70 +0 + 62 +3 + 6 +CONTINUOUS + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +6 +100 +AcDbSymbolTable + 70 +2 + 0 +DIMSTYLE +105 +7 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +ORD + 70 +0 + 3 +<> + 41 +2.5 + 45 +0.5 + 78 +8 +140 +2.5 +179 +1 +271 +2 +275 +1 +276 +2 +277 +2 + 0 +DIMSTYLE +105 +8 +330 +6 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +ARCH + 70 +0 +276 +1 +277 +4 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +100 +AcDbSymbolTable + 70 +7 + 0 +BLOCK_RECORD + 5 +A +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space + 0 +BLOCK_RECORD + 5 +B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space + 0 +BLOCK_RECORD + 5 +C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D1 + 0 +BLOCK_RECORD + 5 +D +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D2 + 0 +BLOCK_RECORD + 5 +E +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D3 + 0 +BLOCK_RECORD + 5 +F +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D4 + 0 +BLOCK_RECORD + 5 +10 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D5 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +100 +330 +A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +101 +330 +A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +102 +330 +B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +103 +330 +B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +104 +330 +C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D1 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D1 + 1 + + 0 +LINE + 5 +105 +330 +C +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +-15.0 + 31 +0.0 + 0 +TEXT + 5 +106 +330 +C +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +1.0 + 20 +-15.0 + 30 +0.0 + 40 +2.5 + 1 +<> + 50 +0.0 +100 +AcDbText + 0 +ENDBLK + 5 +107 +330 +C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +108 +330 +D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D2 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D2 + 1 + + 0 +LINE + 5 +109 +330 +D +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +30.0 + 20 +20.0 + 30 +0.0 + 11 +30.0 + 21 +-15.0 + 31 +0.0 + 0 +TEXT + 5 +10A +330 +D +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +31.0 + 20 +-15.0 + 30 +0.0 + 40 +2.5 + 1 + + 50 +0.0 +100 +AcDbText + 0 +ENDBLK + 5 +10B +330 +D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +10C +330 +E +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D3 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D3 + 1 + + 0 +LINE + 5 +10D +330 +E +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +120.0 + 20 +0.0 + 30 +0.0 + 11 +120.0 + 21 +-15.0 + 31 +0.0 + 0 +TEXT + 5 +10E +330 +E +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +121.0 + 20 +-15.0 + 30 +0.0 + 40 +2.5 + 1 +120.00 + 50 +0.0 +100 +AcDbText + 0 +ENDBLK + 5 +10F +330 +E +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +110 +330 +F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D4 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D4 + 1 + + 0 +LINE + 5 +111 +330 +F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +30.0 + 20 +20.0 + 30 +0.0 + 11 +-15.0 + 21 +20.0 + 31 +0.0 + 0 +TEXT + 5 +112 +330 +F +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +-14.0 + 20 +20.0 + 30 +0.0 + 40 +2.5 + 1 +<> + 50 +0.0 +100 +AcDbText + 0 +ENDBLK + 5 +113 +330 +F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +114 +330 +10 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D5 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D5 + 1 + + 0 +LINE + 5 +115 +330 +10 +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +0.0 + 20 +60.0 + 30 +0.0 + 11 +-15.0 + 21 +60.0 + 31 +0.0 + 0 +TEXT + 5 +116 +330 +10 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +-14.0 + 20 +60.0 + 30 +0.0 + 40 +2.5 + 1 +<> + 50 +0.0 +100 +AcDbText + 0 +ENDBLK + 5 +117 +330 +10 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LWPOLYLINE + 5 +118 +330 +A +100 +AcDbEntity + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +120.0 + 20 +0.0 + 10 +120.0 + 20 +60.0 + 10 +0.0 + 20 +60.0 + 0 +DIMENSION + 5 +119 +330 +A +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension + 2 +*D1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +-15.0 + 31 +0.0 + 70 +102 + 1 +<> + 3 +ORD +100 +AcDbOrdinateDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +0.0 + 24 +-15.0 + 34 +0.0 + 0 +DIMENSION + 5 +11A +330 +A +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension + 2 +*D2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +30.0 + 21 +-15.0 + 31 +0.0 + 70 +102 + 1 + + 42 +30.0 + 3 +ORD +100 +AcDbOrdinateDimension + 13 +30.0 + 23 +20.0 + 33 +0.0 + 14 +30.0 + 24 +-15.0 + 34 +0.0 + 0 +DIMENSION + 5 +11B +330 +A +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension + 2 +*D3 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +120.0 + 21 +-15.0 + 31 +0.0 + 70 +102 + 1 +120.00 + 3 +ARCH +100 +AcDbOrdinateDimension + 13 +120.0 + 23 +0.0 + 33 +0.0 + 14 +120.0 + 24 +-15.0 + 34 +0.0 + 0 +DIMENSION + 5 +11C +330 +A +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension + 2 +*D4 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +-15.0 + 21 +20.0 + 31 +0.0 + 70 +38 + 1 +<> + 3 +ORD +100 +AcDbOrdinateDimension + 13 +30.0 + 23 +20.0 + 33 +0.0 + 14 +-15.0 + 24 +20.0 + 34 +0.0 + 0 +DIMENSION + 5 +11D +330 +A +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension + 2 +*D5 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +-15.0 + 21 +60.0 + 31 +0.0 + 70 +38 + 1 +<> + 3 +ORD +100 +AcDbOrdinateDimension + 13 +0.0 + 23 +60.0 + 33 +0.0 + 14 +-15.0 + 24 +60.0 + 34 +0.0 + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/golden/g16.expected.json b/crates/uncad/tests/golden/g16.expected.json new file mode 100644 index 0000000..da8e6c1 --- /dev/null +++ b/crates/uncad/tests/golden/g16.expected.json @@ -0,0 +1,1138 @@ +{ + "entities": [ + { + "type": "LWPOLYLINE", + "common": { + "id": 280, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "118" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 120.0, + "y": 0.0 + }, + { + "x": 120.0, + "y": 60.0 + }, + { + "x": 0.0, + "y": 60.0 + } + ], + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "DIMENSION", + "common": { + "id": 281, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "119" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D1" + }, + "kind": "ORDINATE", + "measurement": null, + "text_override": { + "type": "MEASURED" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": 0.0, + "y": -15.0 + }, + "points": { + "extension1": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "extension2": { + "x": 0.0, + "y": -15.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ORD" + }, + "ordinate_axis": "X" + }, + { + "type": "DIMENSION", + "common": { + "id": 282, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "11A" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D2" + }, + "kind": "ORDINATE", + "measurement": 30.0, + "text_override": { + "type": "MEASURED" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": 30.0, + "y": -15.0 + }, + "points": { + "extension1": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "extension2": { + "x": 30.0, + "y": -15.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ORD" + }, + "ordinate_axis": "X" + }, + { + "type": "DIMENSION", + "common": { + "id": 283, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "11B" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D3" + }, + "kind": "ORDINATE", + "measurement": null, + "text_override": { + "type": "LITERAL", + "data": "120.00" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": 120.0, + "y": -15.0 + }, + "points": { + "extension1": { + "x": 120.0, + "y": 0.0, + "z": 0.0 + }, + "extension2": { + "x": 120.0, + "y": -15.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ARCH" + }, + "ordinate_axis": "X" + }, + { + "type": "DIMENSION", + "common": { + "id": 284, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "11C" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D4" + }, + "kind": "ORDINATE", + "measurement": null, + "text_override": { + "type": "MEASURED" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": -15.0, + "y": 20.0 + }, + "points": { + "extension1": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "extension2": { + "x": -15.0, + "y": 20.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ORD" + }, + "ordinate_axis": "Y" + }, + { + "type": "DIMENSION", + "common": { + "id": 285, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "11D" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D5" + }, + "kind": "ORDINATE", + "measurement": null, + "text_override": { + "type": "MEASURED" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": -15.0, + "y": 60.0 + }, + "points": { + "extension1": { + "x": 0.0, + "y": 60.0, + "z": 0.0 + }, + "extension2": { + "x": -15.0, + "y": 60.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ORD" + }, + "ordinate_axis": "Y" + } + ], + "tables": { + "layers": { + "0": { + "name": "0", + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "DIMS": { + "name": "DIMS", + "color_index": 3, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + } + }, + "block_records": { + "*D1": { + "name": "*D1", + "entities": [ + { + "type": "LINE", + "common": { + "id": 261, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "105" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "end_point": { + "x": 0.0, + "y": -15.0, + "z": 0.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 262, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "106" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 1.0, + "y": -15.0 + }, + "text_height": 2.5, + "text": "<>", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ] + }, + "*D2": { + "name": "*D2", + "entities": [ + { + "type": "LINE", + "common": { + "id": 265, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "109" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "end_point": { + "x": 30.0, + "y": -15.0, + "z": 0.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 266, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10A" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 31.0, + "y": -15.0 + }, + "text_height": 2.5, + "text": "", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ] + }, + "*D3": { + "name": "*D3", + "entities": [ + { + "type": "LINE", + "common": { + "id": 269, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10D" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 120.0, + "y": 0.0, + "z": 0.0 + }, + "end_point": { + "x": 120.0, + "y": -15.0, + "z": 0.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 270, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10E" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 121.0, + "y": -15.0 + }, + "text_height": 2.5, + "text": "120.00", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ] + }, + "*D4": { + "name": "*D4", + "entities": [ + { + "type": "LINE", + "common": { + "id": 273, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "111" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "end_point": { + "x": -15.0, + "y": 20.0, + "z": 0.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 274, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "112" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": -14.0, + "y": 20.0 + }, + "text_height": 2.5, + "text": "<>", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ] + }, + "*D5": { + "name": "*D5", + "entities": [ + { + "type": "LINE", + "common": { + "id": 277, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "115" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 60.0, + "z": 0.0 + }, + "end_point": { + "x": -15.0, + "y": 60.0, + "z": 0.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 278, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "116" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": -14.0, + "y": 60.0 + }, + "text_height": 2.5, + "text": "<>", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + } + ] + }, + "*Model_Space": { + "name": "*Model_Space", + "entities": [ + { + "type": "LWPOLYLINE", + "common": { + "id": 280, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "118" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 120.0, + "y": 0.0 + }, + { + "x": 120.0, + "y": 60.0 + }, + { + "x": 0.0, + "y": 60.0 + } + ], + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "DIMENSION", + "common": { + "id": 281, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "119" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D1" + }, + "kind": "ORDINATE", + "measurement": null, + "text_override": { + "type": "MEASURED" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": 0.0, + "y": -15.0 + }, + "points": { + "extension1": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "extension2": { + "x": 0.0, + "y": -15.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ORD" + }, + "ordinate_axis": "X" + }, + { + "type": "DIMENSION", + "common": { + "id": 282, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "11A" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D2" + }, + "kind": "ORDINATE", + "measurement": 30.0, + "text_override": { + "type": "MEASURED" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": 30.0, + "y": -15.0 + }, + "points": { + "extension1": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "extension2": { + "x": 30.0, + "y": -15.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ORD" + }, + "ordinate_axis": "X" + }, + { + "type": "DIMENSION", + "common": { + "id": 283, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "11B" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D3" + }, + "kind": "ORDINATE", + "measurement": null, + "text_override": { + "type": "LITERAL", + "data": "120.00" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": 120.0, + "y": -15.0 + }, + "points": { + "extension1": { + "x": 120.0, + "y": 0.0, + "z": 0.0 + }, + "extension2": { + "x": 120.0, + "y": -15.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ARCH" + }, + "ordinate_axis": "X" + }, + { + "type": "DIMENSION", + "common": { + "id": 284, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "11C" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D4" + }, + "kind": "ORDINATE", + "measurement": null, + "text_override": { + "type": "MEASURED" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": -15.0, + "y": 20.0 + }, + "points": { + "extension1": { + "x": 30.0, + "y": 20.0, + "z": 0.0 + }, + "extension2": { + "x": -15.0, + "y": 20.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ORD" + }, + "ordinate_axis": "Y" + }, + { + "type": "DIMENSION", + "common": { + "id": 285, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "11D" + }, + "layer": { + "type": "RESOLVED", + "data": "DIMS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "block_name": { + "type": "RESOLVED", + "data": "*D5" + }, + "kind": "ORDINATE", + "measurement": null, + "text_override": { + "type": "MEASURED" + }, + "definition_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "text_midpoint": { + "x": -15.0, + "y": 60.0 + }, + "points": { + "extension1": { + "x": 0.0, + "y": 60.0, + "z": 0.0 + }, + "extension2": { + "x": -15.0, + "y": 60.0, + "z": 0.0 + }, + "radial": null, + "arc": null + }, + "rotation": 0.0, + "text_rotation": 0.0, + "style_name": { + "type": "RESOLVED", + "data": "ORD" + }, + "ordinate_axis": "Y" + } + ] + }, + "*Paper_Space": { + "name": "*Paper_Space", + "entities": [] + } + }, + "dim_styles": { + "ARCH": { + "name": "ARCH", + "post": null, + "scale": null, + "length_factor": null, + "tolerances": null, + "limits": null, + "tolerance_upper": null, + "tolerance_lower": null, + "decimal_places": null, + "tolerance_decimal_places": null, + "text_height": null, + "arrow_size": null, + "linear_unit_format": "ARCHITECTURAL", + "zero_suppression": null, + "rounding": null, + "angular_unit_format": null, + "angular_decimal_places": null, + "fraction_format": "DIAGONAL" + }, + "ORD": { + "name": "ORD", + "post": "<>", + "scale": null, + "length_factor": null, + "tolerances": null, + "limits": null, + "tolerance_upper": null, + "tolerance_lower": null, + "decimal_places": 2, + "tolerance_decimal_places": null, + "text_height": 2.5, + "arrow_size": 2.5, + "linear_unit_format": "DECIMAL", + "zero_suppression": 8, + "rounding": 0.5, + "angular_unit_format": "DEGREES_MINUTES_SECONDS", + "angular_decimal_places": 1, + "fraction_format": "NOT_STACKED" + } + }, + "mlinestyles": {}, + "layouts": {} + }, + "read_diagnostics": { + "warnings": [] + } +} \ No newline at end of file diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 3c7f61c..0657b74 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -385,11 +385,20 @@ DXF reading no longer goes through this importer. **The field names of a two-line angular dimension are not the mapping.** Measured against the same drawing in both formats: `xline1start_pt`, `xline1end_pt` and `xline2start_pt` are -groups 13, 14 and 15 as their names suggest, `def_pt` is group 16, and `xline2end_pt` is -group 10 -- the definition point every other subtype keeps in `def_pt`. The reader follows -the measurement, and `tests/corpus_sweep.rs` pins all five points against the values the DXF -twin writes for two drawings, so a change in the library's field layout fails the build -instead of quietly putting the wrong point in the model. (The second drawing matters: in the +groups 13, 14 and 15 as their names suggest; decoded from a DWG, `def_pt` is group 16 and +`xline2end_pt` is group 10 -- the definition point every other subtype keeps in `def_pt` -- +while LibreDWG's DXF importer fills the two by group code, `def_pt` 10 and `xline2end_pt` +16. The reader follows the measurement for each, and `tests/corpus_sweep.rs` pins all five +points against the values the DXF twin writes for two drawings, and for the DXF itself, so +a change in the library's field layout fails the build instead of quietly putting the wrong +point in the model. Before the DXF half, every two-line angular dimension read from a DXF +had its groups 10 and 16 exchanged. + +**An ordinate dimension's axis.** Bit 64 of group 70 says whether an ordinate dimension +measures its feature's x (set) or y distance from the datum (`ordinate_axis`). A DXF, and a +drawing older than R13, state it in the flag the model reads it from; from R13 on a DWG +states it as bit 1 of a separate byte (`flag2`), from which the decoder rebuilds group 70 +wrongly (it sets bit 128 and clears bit 64), so that byte is read there instead. (The second drawing matters: in the first, groups 10 and 13 are the same point, and an earlier reading of it took `xline2end_pt` for group 13 and reported group 10 as not stated.) From e2c30c15090e805c61f420c24acc0f6ecfe9126d Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:45:33 +0900 Subject: [PATCH 28/54] Report a dimension measurement of -1 as not stated Group 42 of -1 is what writers leave for a dimension they did not measure; no length or angle is negative. Like 0 before it, it is now None instead of -1.0. Every other value stays the file's, radians for an angular dimension, whether or not it agrees with the dimension's points: the feature branch dropped a stored value more than 1% away from the one its points give and converted angles to degrees, and both are a consumer's steps now (owner decision on disposition #20; G5's 133.5 keeps reading back). A/B over the top-level dimensions of the oracle inputs: against upstream, exactly the 63 sentinels change from -1.0 to None (AutoCADSamples9.dwg 58, AutoCADSamples8.dwg 5); against the feature branch, all 1,120 agree once its degrees are taken back to radians, apart from four in 2018/Dynblocks.dwg (3.048 and 3048.0) that it had dropped for disagreeing with their points and the model carries. Ported-from: 74bb835 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ crates/uncad/src/convert.rs | 6 +++++- docs/CAVEATS.md | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db227f4..045d8bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,8 @@ Notable changes to this project are recorded here. The format follows its feature's x or y distance from the datum. ### Changed +- A dimension whose group 42 is `-1`, the value writers leave for a dimension they did not + measure, reports `measurement: None` rather than `-1.0`, the way a `0` already did. - A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an entity of the drawing, `Unresolved` keeps the handle the file wrote (hex) when no entity answers to it, `Absent` is a leader that names nothing. The `Option` it diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index ec90268..509d9f3 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -1433,8 +1433,12 @@ unsafe fn convert_entity( // Drawings older than R2000 routinely omit the group, and // reporting 0.0 for them would put a false difference // between a drawing and its own twin in the other format. + // -1 is the other "not measured" the format's writers leave + // behind (no length or angle is negative); any other value + // is the file's, whether or not it agrees with the points -- + // judging that is a consumer's step. measurement: get_field::(entity_ptr, dxfname, "act_measurement") - .filter(|m| *m != 0.0), + .filter(|m| *m != 0.0 && *m != -1.0), text_override: dimension_text_override( text.field(entity_ptr, dxfname, "user_text").as_deref(), ), diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 0657b74..1e5ab26 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -373,6 +373,12 @@ The rule is worth only what the DXF path costs. The binary format always stores once this crate's DXF reading no longer goes through this importer the rule buys nothing and only loses genuine zeros: remove it then, together with the deviation in `tests/golden.rs`. +**The measurement, when it is -1.** Writers leave `-1` in group 42 for a dimension they did +not measure -- 58 of the dimensions of one AutoCAD-written sample drawing and 5 of another +carry it -- and no length or angle is negative, so `-1` is "not stated" too. Every other +value is carried as the file states it, radians for an angular dimension, even where it +disagrees with the dimension's own points: whether to trust it is a consumer's judgement. + **Every variable of a dimension style.** The DIMSTYLE table states what a dimension names rather than carries, and the format writes a style variable only when it differs from the value the application starts from. This library holds a style as a struct with no "the file did not From f80db836912117f23cb005b9f390ff0843613159 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:47:02 +0900 Subject: [PATCH 29/54] Read an MLINE's scale MLineEntity::scale was None. It is DXF 40, which the format requires: the factor the MLINESTYLE's offsets are drawn at, carried as the file states it. The feature branch turned a 0 or a missing value into 1.0; that fallback is a consumer's (the model's own doc says so). A/B: the 37 MLINEs of the oracle inputs (20.0 on 23, 1.0 on 14) agree with the feature branch. Ported-from: 00414f4 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ crates/uncad/src/convert.rs | 5 ++++- docs/CAVEATS.md | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 045d8bf..1f4c020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ Notable changes to this project are recorded here. The format follows `zero_suppression` (DIMZIN), `rounding` (DIMRND), `angular_unit_format` (DIMAUNIT), `angular_decimal_places` (DIMADEC) and `fraction_format` (DIMFRAC). What to use where a style states nothing is the consumer's decision. +- An MLINE carries its `scale` (DXF 40), the factor its style's offsets are drawn at: a + wall drawn 20 units thick in a style of unit offsets was drawn 1 unit thick. - An ordinate DIMENSION carries its `ordinate_axis` (DXF 70, bit 64): whether it measures its feature's x or y distance from the datum. ### Changed diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 509d9f3..9ed00a3 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -1988,7 +1988,10 @@ fn convert_mline( vertices, closed: flags & MLINE_CLOSED_FLAG != 0, mlinestyle_name, - scale: None, + // DXF 40, which the format requires: the factor the style's + // offsets are drawn at. Carried as stated -- a 0 collapses every + // element onto the centreline, which is what the file says. + scale: get_field::(entity_ptr, "MLINE", "scale"), } } diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 1e5ab26..13e7aff 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -710,7 +710,9 @@ vector LibreDWG has already computed with the miter angle applied, so this is on multiply, no trigonometry. When the style cannot be found (empty handle, failed resolution) it is treated as a single `offset = 0.0`, which reproduces the old centerline rendering exactly through the same `mline_offset_points` function, with no -separate branch. +separate branch. The offsets are in the style's units: the MLINE's own `scale` (DXF 40, +which the model carries as the file states it) is what turns them into drawing units -- +the corpus's MLINEs state 20 and 1. **bindgen**: `Dwg_MLINESTYLE_line` (`offset`/`color`/`lt_index`/`lt_ltype`) hits the same cascade through `parent: struct _dwg_object_MLINESTYLE *`, handled the same way (hand From f46a7beec4723d8b23dd8e95cab8faab1ac6042c Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:53:14 +0900 Subject: [PATCH 30/54] Read what a viewport shows: its view, state, number and frozen layers ViewportEntity carried only its frame. It now carries: - view (DXF 12, 45, 17, 16, 51, 42): the view's centre and height in its own coordinates, target, direction (a zero one reads as the plan view (0,0,1)), twist (radians from both readers) and lens length -- from R2000 on; an older viewport keeps its view in extended data, which is not read, so it is None (the feature branch reported zeros there); - on: a DXF's group 68; a DWG does not store that group (LibreDWG makes one up) and says "off" as status-flag bit 0x20000, which exists from R2000 on; - viewport_id: a DXF's group 69 only, for the same reason; - frozen_layers: every layer frozen in this viewport alone, in file order, through the reference resolver (an unresolved one keeps its handle; the feature branch dropped those). tests/fixtures.rs pins the twisted viewport's stated view and the four states of the viewport-states fixture. A/B over the 168 viewports of the oracle inputs: every view, state, number and frozen-layer list present on both sides agrees with the feature branch; the model says None for the 12 pre-R2000 views, the 8 pre-R2000 DWG on-states and the 118 DWG numbers it made up values for. Ported-from: 1f08658 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ crates/uncad/src/convert.rs | 78 +++++++++++++++++++++++++--- crates/uncad/tests/fixtures.rs | 93 ++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4c020..c89984d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,10 @@ Notable changes to this project are recorded here. The format follows style states nothing is the consumer's decision. - An MLINE carries its `scale` (DXF 40), the factor its style's offsets are drawn at: a wall drawn 20 units thick in a style of unit offsets was drawn 1 unit thick. +- A VIEWPORT carries what it shows of the model: its `view` (centre and height in the + view's own coordinates, target, direction, twist and lens length; `None` before R2000, + whose viewports keep it in extended data), whether it is `on`, its `viewport_id` (a + DXF's; the binary format stores none) and the layers frozen in it alone. - An ordinate DIMENSION carries its `ordinate_axis` (DXF 70, bit 64): whether it measures its feature's x or y distance from the datum. ### Changed diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index 9ed00a3..d75532b 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -12,8 +12,8 @@ use crate::dynapi::{ get_array_field, get_common_field, get_field, get_point2d, get_point2d_array, get_point3d, - get_point3d_array, is_from_dxf, is_pre_r13, is_r2010_or_later, is_r2013_or_later, - RawSegmentWidth, SplineControlPoint, + get_point3d_array, is_from_dxf, is_pre_r13, is_r2000_or_later, is_r2010_or_later, + is_r2013_or_later, RawSegmentWidth, SplineControlPoint, }; use crate::text::TextDecoder; use std::ffi::CStr; @@ -25,7 +25,7 @@ use uncad_model::model::{ LwPolylineEntity, MLineEntity, MLineVertex, MTextAttachment, MTextEntity, MultiLeaderEntity, OrdinateAxis, Origin, PointEntity, PolylineEntity, RayEntity, Ref, SegmentWidth, Solid3DEntity, SolidEntity, SplineEntity, TextEntity, TextOverride, ToleranceEntity, ViewportEntity, - WipeoutEntity, + ViewportView, WipeoutEntity, }; use uncad_model::model::{ AttributeFlags, HorizontalJustification, Point2D, Point3D, VerticalJustification, @@ -64,6 +64,9 @@ const Z_AXIS: Point3D = Point3D { z: 1.0, }; +/// VIEWPORT's status-flag bit (DXF 90) that says the viewport is off. +const VIEWPORT_OFF_FLAG: u32 = 0x20000; + /// `MLINE_FLAGS_CLOSED` (dwg.h). const MLINE_CLOSED_FLAG: u16 = 2; @@ -724,6 +727,48 @@ fn text_placement( } } +/// What a paper-space viewport shows of the model (DXF 12, 45, 17, 16, 51, +/// 42). The record carries the view from R2000 on; an older viewport keeps +/// it in extended data, which is not read, so there is none to report. A +/// zero view direction is not a direction and reads as a plan view, +/// (0, 0, 1); the twist arrives in radians from both readers. +fn viewport_view( + dwg: *mut libredwg_sys::Dwg_Data, + entity_ptr: *mut std::ffi::c_void, +) -> Option { + if !is_r2000_or_later(dwg) { + return None; + } + let number = |field: &str| get_field::(entity_ptr, "VIEWPORT", field); + Some(ViewportView { + center: get_point2d(entity_ptr, "VIEWPORT", "VIEWCTR")?, + height: number("VIEWSIZE")?, + target: get_point3d(entity_ptr, "VIEWPORT", "view_target")?, + direction: get_point3d(entity_ptr, "VIEWPORT", "VIEWDIR") + .filter(|d| d.x != 0.0 || d.y != 0.0 || d.z != 0.0) + .unwrap_or(Z_AXIS), + twist: number("VIEWTWIST")?, + lens_length: number("LENSLENGTH")?, + }) +} + +/// Whether a viewport is on. A DXF states it in group 68 (0 off; -1 or a +/// positive number on); the binary format does not store that group -- +/// LibreDWG makes one up for a DWG, in block order -- and states "off" as +/// bit 0x20000 of the status flags (DXF 90), which exist from R2000 on. +fn viewport_on( + dwg: *mut libredwg_sys::Dwg_Data, + entity_ptr: *mut std::ffi::c_void, +) -> Option { + if is_from_dxf(dwg) { + get_field::(entity_ptr, "VIEWPORT", "on_off").map(|on| on != 0) + } else if is_r2000_or_later(dwg) { + get_field::(entity_ptr, "VIEWPORT", "status_flag").map(|f| f & VIEWPORT_OFF_FLAG == 0) + } else { + None + } +} + /// An ATTRIB's or ATTDEF's flags (DXF 70), one per bit as the reference /// names them: 1 invisible, 2 constant, 4 verify, 8 preset. An absent group /// is no flag set. @@ -1185,10 +1230,29 @@ unsafe fn convert_entity( center, width, height, - view: None, - on: None, - viewport_id: None, - frozen_layers: Vec::new(), + view: viewport_view(dwg, entity_ptr), + on: viewport_on(dwg, entity_ptr), + // DXF 69. The binary format stores no such number: LibreDWG + // makes one up for a DWG's viewports, in block order, so + // only a DXF's is the file's. + viewport_id: is_from_dxf(dwg) + .then(|| get_field::(entity_ptr, "VIEWPORT", "id")) + .flatten() + .map(i32::from), + frozen_layers: get_array_field::( + entity_ptr, + "VIEWPORT", + "num_frozen_layers", + "frozen_layers", + ) + .into_iter() + .filter(|h| !h.is_null()) + .map(|h| { + reference(dwg, text, Some(h), c"LAYER", |handle_ptr| { + text.handle_name(dwg, handle_ptr) + }) + }) + .collect(), }) } libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE__3DFACE => { diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index 8dd5db2..09be343 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -800,3 +800,96 @@ fn the_radial_fixture_reads_its_points_by_their_groups() { ); assert_eq!(angular.points.radial, p3(40.0, 0.0), "the centre, group 15"); } + +// ------------------------------------------------------------ viewports + +fn viewports(db: &CadDatabase) -> Vec<&uncad::model::ViewportEntity> { + db.entities + .iter() + .filter_map(|e| match e { + Entity::Viewport(v) => Some(v), + _ => None, + }) + .collect() +} + +/// The twisted viewport's view, as its AcDbViewport groups state it: centre +/// (50, 25) and height 60 in the view's own coordinates (so the 120-high +/// frame shows the model at scale 2), looking down (0, 0, 1) at (0, 0, 0), +/// turned 30 degrees, lens 50; on (68 = 1), number 2 (69), nothing frozen. +#[test] +fn the_twisted_viewport_carries_its_view_as_stated() { + use uncad::model::{Point2D, Point3D, ViewportView}; + let db = parse(TWISTED_VIEWPORT); + let vp = viewports(&db)[0]; + let view = vp.view.expect("an R2000 viewport carries its view"); + assert_eq!( + view, + ViewportView { + center: Point2D { x: 50.0, y: 25.0 }, + height: 60.0, + target: Point3D { + x: 0.0, + y: 0.0, + z: 0.0 + }, + direction: UP, + twist: view.twist, + lens_length: 50.0, + } + ); + assert!( + (view.twist - 30f64.to_radians()).abs() < 1e-12, + "{}", + view.twist + ); + assert_eq!((vp.on, vp.viewport_id), (Some(true), Some(2))); + assert!(vp.frozen_layers.is_empty()); +} + +/// One viewport per state a sheet tells apart: on; on, on a frozen layer +/// (the frame hidden, not the window); off (68 = 0, status bit 0x20000); +/// not a plan view (VIEWDIR (1,1,1)). Numbers 2 to 5. +#[test] +fn the_viewport_states_fixture_carries_each_state() { + use uncad::model::Point3D; + let db = parse(VIEWPORT_STATES); + /// (handle, layer, on, number, view direction) + type State<'a> = (&'a str, Ref, Option, Option, Point3D); + let states: Vec = viewports(&db) + .iter() + .map(|v| { + let Ref::Resolved(h) = &v.common.source_handle else { + panic!("{v:?}"); + }; + ( + h.as_str(), + v.common.layer.clone(), + v.on, + v.viewport_id, + v.view.expect("R2000").direction, + ) + }) + .collect(); + let diagonal = Point3D { + x: 1.0, + y: 1.0, + z: 1.0, + }; + assert_eq!( + states, + [ + ("2A", resolved("0"), Some(true), Some(2), UP), + ("2D", resolved("VPFROZEN"), Some(true), Some(3), UP), + ("2E", resolved("0"), Some(false), Some(4), UP), + ("2F", resolved("0"), Some(true), Some(5), diagonal), + ] + ); + for vp in viewports(&db) { + let view = vp.view.expect("R2000"); + assert_eq!( + (view.center.x, view.center.y, view.height), + (50.0, 25.0, 20.0) + ); + } +} From 67bafad0beb8f425aa60028e6a3c2f1ff8ff6739 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 15:59:47 +0900 Subject: [PATCH 31/54] Read the drawing's layouts and their plot settings Tables::layouts was empty. Every LAYOUT object is now read: its name, tab order, the block it shows (block_header, resolved like any block reference, so it is a key of block_records), its limits, and the plot settings embedded in it -- paper name, size, margins, plot origin, paper unit, rotation and the custom scale's two sides, as the object holds them. The plot settings are a Dwg_Object_PLOTSETTINGS inside the LAYOUT, which dynapi's entity accessors cannot see through. dynapi.rs gains the feature branch's get_sub_field -- the embedded struct's offset from the parent's field table, then dwg_dynapi_subclass_value under the "Dwg_Object_PLOTSETTINGS" spelling, with the same size check as every other read -- and get_sub_text_bytes for its strings: the stored 8-bit bytes, or for an R2007+ drawing the UTF-16 string converted to UTF-8, the conversion dwg_dynapi_entity_utf8text does for a parent's own fields. text.rs gains one method, TextDecoder::sub_field, so those bytes are decoded and reported like every other string. Golden case G14 (layers in every state, two layouts with plot settings, an overall viewport, a twisted half-scale detail with a layer frozen in it, a viewport that is off) reads back exactly, with one known deviation applied to the expectation and pinned by the tripwire test: the file's 290 = 0 on layer NOPLOT, which the DXF importer cannot tell from an absent group, reads None. tests/layouts.rs pins example_2000's layouts in both formats, the three layout fixtures' page setups, and example_r14.dwg, which keeps the layouts a later AutoCAD saved into the R14 format. A/B: all 461 layouts of the oracle inputs agree with the feature branch on every field it read, R2007+ paper names included. Ported-from: 1f08658, be3ff34, 7ff5c70 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + crates/uncad/src/dynapi.rs | 108 ++ crates/uncad/src/table_convert.rs | 115 +- crates/uncad/src/text.rs | 22 + crates/uncad/tests/fixtures.rs | 6 + crates/uncad/tests/golden.rs | 29 +- crates/uncad/tests/golden/README.md | 2 +- crates/uncad/tests/golden/g14.dxf | 1374 +++++++++++++++++++ crates/uncad/tests/golden/g14.expected.json | 1130 +++++++++++++++ crates/uncad/tests/layouts.rs | 255 ++++ docs/CAVEATS.md | 3 +- 11 files changed, 3038 insertions(+), 10 deletions(-) create mode 100644 crates/uncad/tests/golden/g14.dxf create mode 100644 crates/uncad/tests/golden/g14.expected.json create mode 100644 crates/uncad/tests/layouts.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c89984d..2884f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,10 @@ Notable changes to this project are recorded here. The format follows style states nothing is the consumer's decision. - An MLINE carries its `scale` (DXF 40), the factor its style's offsets are drawn at: a wall drawn 20 units thick in a style of unit offsets was drawn 1 unit thick. +- The drawing's layouts are read into `Tables::layouts`: every LAYOUT object -- a tab, the + block it shows (`block_name`), its tab order and limits -- with the plot settings + embedded in it (paper name and size, margins, plot origin, paper unit, rotation and the + custom scale), read through LibreDWG's dynapi into the embedded `PLOTSETTINGS` struct. - A VIEWPORT carries what it shows of the model: its `view` (centre and height in the view's own coordinates, target, direction, twist and lens length; `None` before R2000, whose viewports keep it in extended data), whether it is `on`, its `viewport_id` (a diff --git a/crates/uncad/src/dynapi.rs b/crates/uncad/src/dynapi.rs index 12cc729..2e76814 100644 --- a/crates/uncad/src/dynapi.rs +++ b/crates/uncad/src/dynapi.rs @@ -511,6 +511,114 @@ pub fn get_text_bytes(entity: *mut c_void, dxfname: &str, field: &str) -> Option Some(owned) } +/// Reads a field of a struct embedded in an object -- LAYOUT's +/// `plotsettings` (a `Dwg_Object_PLOTSETTINGS`), say -- by adding the +/// embedded struct's offset from the parent's dynapi table and reading the +/// field through the embedded type's own table +/// (`dwg_dynapi_subclass_value`, a plain copy of the field's bytes), with +/// the same size check as [`get_field`]. +/// +/// `object` must be a live pointer to a `dxfname` object; `sub_field` is the +/// parent's field holding the struct, `sub_dxfname` the struct's dynapi name +/// (`"PLOTSETTINGS"`), `field` the field inside it. +pub fn get_sub_field( + object: *mut c_void, + dxfname: &str, + sub_field: &str, + sub_dxfname: &str, + field: &str, +) -> Option { + if object.is_null() { + return None; + } + let c_dxfname = CString::new(dxfname).expect("dxfname has no interior NUL"); + let c_sub_field = CString::new(sub_field).expect("field name has no interior NUL"); + // SAFETY: pure name -> descriptor lookups, no write through any pointer. + let sub_desc = + unsafe { libredwg_sys::dwg_dynapi_entity_field(c_dxfname.as_ptr(), c_sub_field.as_ptr()) }; + if sub_desc.is_null() { + return None; + } + let offset = unsafe { (*sub_desc).offset } as usize; + let c_sub_dxfname = CString::new(sub_dxfname).expect("dxfname has no interior NUL"); + let c_field = CString::new(field).expect("field name has no interior NUL"); + let field_desc = + unsafe { libredwg_sys::dwg_dynapi_entity_field(c_sub_dxfname.as_ptr(), c_field.as_ptr()) }; + if field_desc.is_null() { + return None; + } + if !field_write_size_matches::(unsafe { &*field_desc }, sub_dxfname, field) { + return None; + } + // dwg_dynapi_subclass_value wants the "Dwg_Object_" spelling (it + // strips the prefix and falls back to the entity table); the plain + // "PLOTSETTINGS" is refused. + let c_subclass = + CString::new(format!("Dwg_Object_{sub_dxfname}")).expect("dxfname has no interior NUL"); + let mut out = MaybeUninit::::uninit(); + let mut fp: libredwg_sys::Dwg_DYNAPI_field = Default::default(); + // SAFETY: `object` is a live `dxfname` object (caller contract), so + // `object + offset` is its embedded struct, and the size check above + // confirms dynapi writes exactly `size_of::()` bytes into `out`. + let ok = unsafe { + libredwg_sys::dwg_dynapi_subclass_value( + object.cast::().add(offset).cast::(), + c_subclass.as_ptr(), + c_field.as_ptr(), + out.as_mut_ptr().cast::(), + &mut fp, + ) + }; + if !ok { + return None; + } + // SAFETY: dynapi reported success and wrote size_of::() bytes. + Some(unsafe { out.assume_init() }) +} + +/// The longest string [`get_sub_text_bytes`] reads, in characters: far past +/// any name a plot setting holds, and a bound on a pointer into damaged data. +const MAX_SUB_TEXT: usize = 1 << 16; + +/// [`get_sub_field`] for a text field, as the bytes a text field of the +/// parent itself comes back as through [`get_text_bytes`]: the stored +/// 8-bit bytes, or -- `wide`, a drawing whose strings are UTF-16 (R2007 and +/// later) -- that string converted to UTF-8. `dwg_dynapi_entity_utf8text`, +/// which does the conversion for a parent's own fields, cannot see through +/// the embedded struct. `None` for a missing field or a null string. +pub fn get_sub_text_bytes( + object: *mut c_void, + dxfname: &str, + sub_field: &str, + sub_dxfname: &str, + field: &str, + wide: bool, +) -> Option> { + let raw = get_sub_field::<*const u8>(object, dxfname, sub_field, sub_dxfname, field)?; + if raw.is_null() { + return None; + } + if wide { + let units: Vec = (0..MAX_SUB_TEXT) + // SAFETY: a TU string the live Dwg_Data owns, NUL-terminated + // (caller contract, as for any text field); read up to its + // terminator or the bound, whichever comes first. + .map(|i| unsafe { raw.cast::().add(i).read_unaligned() }) + .take_while(|&unit| unit != 0) + .collect(); + return Some(String::from_utf16_lossy(&units).into_bytes()); + } + // SAFETY: an 8-bit NUL-terminated string the live Dwg_Data owns. + Some( + unsafe { CStr::from_ptr(raw.cast()) } + .to_bytes() + .iter() + .take(MAX_SUB_TEXT) + .copied() + .collect(), + ) +} + /// Reads a `(count_field, array_field)` pair -- e.g. LWPOLYLINE's /// `num_points`/`points` -- as an owned `Vec`. The array field is a raw /// pointer into memory LibreDWG itself owns (freed by `dwg_free`, not by diff --git a/crates/uncad/src/table_convert.rs b/crates/uncad/src/table_convert.rs index d1b50f6..114c018 100644 --- a/crates/uncad/src/table_convert.rs +++ b/crates/uncad/src/table_convert.rs @@ -1,15 +1,26 @@ -//! Conversion of the non-entity OBJECT-supertype tables an entity resolves -//! against: LAYER, BLOCK_RECORD and MLINESTYLE -- from LibreDWG's structures -//! into the model's [`Tables`]. +//! Conversion of the non-entity OBJECT-supertype records an entity or a +//! sheet resolves against -- LAYER, BLOCK_RECORD, DIMSTYLE, MLINESTYLE, and +//! LAYOUT with its embedded plot settings -- from LibreDWG's structures into +//! the model's [`Tables`]. +//! +//! All of them are collected by one pass over the objects, dispatching on +//! `dwg_object_get_fixedtype`. The named object dictionary is never walked: +//! a file with no LAYOUT object -- a DXF without an OBJECTS section, or a +//! drawing older than R2000 that no application with layouts saved -- has +//! no layouts. use crate::convert::{owned_entities, reference}; -use crate::dynapi::{get_array_field, get_field, is_from_dxf, is_r2000_or_later}; +use crate::dynapi::{ + get_array_field, get_field, get_point2d, get_sub_field, is_from_dxf, is_r2000_or_later, + RawPoint2D, +}; use crate::text::TextDecoder; use std::collections::BTreeMap; use std::ffi::c_void; +use uncad_model::model::Point2D; use uncad_model::tables::{ - AngularUnitFormat, BlockRecord, DimStyleRecord, FractionFormat, LayerRecord, LinearUnitFormat, - Tables, + AngularUnitFormat, BlockRecord, DimStyleRecord, FractionFormat, LayerRecord, LayoutRecord, + LinearUnitFormat, PlotPaperUnits, PlotRotation, PlotSettings, Tables, }; /// # Safety @@ -29,6 +40,7 @@ pub(crate) unsafe fn convert_tables( let mut block_records = BTreeMap::new(); let mut mlinestyles = BTreeMap::new(); let mut dim_styles = BTreeMap::new(); + let mut layouts = BTreeMap::new(); for i in 0..num_objects { let obj = unsafe { libredwg_sys::dwg_get_object(dwg, i) }; @@ -62,6 +74,13 @@ pub(crate) unsafe fn convert_tables( dim_styles.insert(record.name.clone(), record); } } + } else if fixedtype == libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_LAYOUT { + let object_ptr = unsafe { libredwg_sys::uncad_object_object_ptr(obj) }; + if !object_ptr.is_null() { + if let Some(record) = convert_layout(dwg, text, object_ptr) { + layouts.insert(record.name.clone(), record); + } + } } else if fixedtype == libredwg_sys::DWG_OBJECT_TYPE_DWG_TYPE_MLINESTYLE { let object_ptr = unsafe { libredwg_sys::uncad_object_object_ptr(obj) }; if !object_ptr.is_null() { @@ -77,10 +96,92 @@ pub(crate) unsafe fn convert_tables( dim_styles, block_records, mlinestyles, - layouts: Default::default(), + layouts, } } +/// Reads a LAYOUT object -- a tab of the drawing, the block it shows -- and +/// the plot settings embedded in it (`plotsettings`, a +/// `Dwg_Object_PLOTSETTINGS` read through its own dynapi table; see +/// [`get_sub_field`]). +/// +/// Every value is what the object holds. Two of them a DXF may leave out +/// and the importer fills in, so they cannot be told from a stated value: +/// the zero points, and the paper-units side of the custom scale (DXF 142), +/// which the importer sets to 1 on every LAYOUT before it reads the groups. +/// A plot unit or rotation outside the format's range is `None`. +fn convert_layout( + dwg: *mut libredwg_sys::Dwg_Data, + text: &TextDecoder, + object_ptr: *mut c_void, +) -> Option { + let name = text.field(object_ptr, "LAYOUT", "layout_name")?; + let block_name = reference( + dwg, + text, + get_field::<*mut libredwg_sys::Dwg_Object_Ref>(object_ptr, "LAYOUT", "block_header"), + c"BLOCK", + |handle_ptr| resolve_block_name(text, handle_ptr), + ); + let point = |field: &str| get_point2d(object_ptr, "LAYOUT", field).unwrap_or_default(); + let plot = |field: &str| { + get_sub_field::(object_ptr, "LAYOUT", "plotsettings", "PLOTSETTINGS", field) + .unwrap_or(0.0) + }; + let plot_code = |field: &str| { + get_sub_field::(object_ptr, "LAYOUT", "plotsettings", "PLOTSETTINGS", field) + }; + let plot_settings = PlotSettings { + paper_name: text + .sub_field( + object_ptr, + "LAYOUT", + "plotsettings", + "PLOTSETTINGS", + "canonical_media_name", + ) + .unwrap_or_default(), + paper_width: plot("paper_width"), + paper_height: plot("paper_height"), + margin_left: plot("left_margin"), + margin_bottom: plot("bottom_margin"), + margin_right: plot("right_margin"), + margin_top: plot("top_margin"), + plot_origin: get_sub_field::( + object_ptr, + "LAYOUT", + "plotsettings", + "PLOTSETTINGS", + "plot_origin", + ) + .map(Point2D::from) + .unwrap_or_default(), + paper_units: plot_code("plot_paper_unit").and_then(|v| match v { + 0 => Some(PlotPaperUnits::Inches), + 1 => Some(PlotPaperUnits::Millimeters), + 2 => Some(PlotPaperUnits::Pixels), + _ => None, + }), + rotation: plot_code("plot_rotation_mode").and_then(|v| match v { + 0 => Some(PlotRotation::Unrotated), + 1 => Some(PlotRotation::Counterclockwise90), + 2 => Some(PlotRotation::UpsideDown), + 3 => Some(PlotRotation::Clockwise90), + _ => None, + }), + scale_numerator: plot("paper_units"), + scale_denominator: plot("drawing_units"), + }; + Some(LayoutRecord { + name, + tab_order: get_field::(object_ptr, "LAYOUT", "tab_order").map_or(0, i32::from), + block_name, + limits_min: point("LIMMIN"), + limits_max: point("LIMMAX"), + plot_settings, + }) +} + /// Reads a DIMSTYLE table entry -- the settings a dimension names rather than /// carries (see [`DimStyleRecord`]). /// diff --git a/crates/uncad/src/text.rs b/crates/uncad/src/text.rs index a9ef976..100502c 100644 --- a/crates/uncad/src/text.rs +++ b/crates/uncad/src/text.rs @@ -175,6 +175,28 @@ impl TextDecoder { })) } + /// A text field of a struct embedded in an object (a LAYOUT's plot + /// settings), decoded like [`Self::field`] -- see + /// `dynapi::get_sub_text_bytes`. + pub fn sub_field( + &self, + object: *mut c_void, + dxfname: &str, + sub_field: &str, + sub_dxfname: &str, + field: &str, + ) -> Option { + let bytes = + dynapi::get_sub_text_bytes(object, dxfname, sub_field, sub_dxfname, field, self.wide)?; + Some(self.decode(&bytes, || { + // SAFETY: object is the type-specific struct pointer of a live + // object (get_sub_text_bytes just read through it); the accessor + // answers 0 when it cannot find the owning object. + let handle = unsafe { libredwg_sys::dwg_obj_generic_handlevalue(object) }; + format!("{dxfname}.{sub_field}.{field} (handle {handle:X})") + })) + } + /// The name of whatever a handle reference points at, decoded -- see /// `dynapi::handle_name_bytes`. pub fn handle_name( diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index 09be343..1820b2a 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -310,6 +310,12 @@ fn the_title_block_fixture_keeps_every_string_in_paper_space() { }; assert_eq!(texts("*Paper_Space"), ["GARDEN PAVILION"]); assert_eq!(texts("TITLEBLOCK"), ["SHEET 1 OF 2"]); + // One A4 sheet, the layout that shows paper space. + assert_eq!(db.tables.layouts.keys().collect::>(), ["Layout1"]); + assert_eq!( + db.tables.layouts["Layout1"].block_name, + resolved("*Paper_Space") + ); } // --------------------------------------------------- polyline vertices diff --git a/crates/uncad/tests/golden.rs b/crates/uncad/tests/golden.rs index d12bd6f..78b8a49 100644 --- a/crates/uncad/tests/golden.rs +++ b/crates/uncad/tests/golden.rs @@ -20,7 +20,7 @@ use uncad::model::Ref; use uncad::{CadDatabase, Entity}; /// Every case, as (name, DXF bytes, expected model JSON). -const CASES: [(&str, &[u8], &str); 13] = [ +const CASES: [(&str, &[u8], &str); 14] = [ ( "g1", include_bytes!("golden/g1.dxf"), @@ -76,6 +76,11 @@ const CASES: [(&str, &[u8], &str); 13] = [ include_bytes!("golden/g13.dxf"), include_str!("golden/g13.expected.json"), ), + ( + "g14", + include_bytes!("golden/g14.dxf"), + include_str!("golden/g14.expected.json"), + ), ( "g15", include_bytes!("golden/g15.dxf"), @@ -127,6 +132,12 @@ impl Drop for Fixture { /// file wrote is lost the same way; what arrives is a resolved reference /// to a style the entity did not name. /// +/// A layer's plot flag goes the same way from the other side. The importer +/// leaves an absent group 290 at 0, so a layer that states `290 = 0` (G14's +/// NOPLOT) cannot be told from one that states nothing, and this reader +/// reports both as "not stated" rather than guess: `None` where the spec +/// says `Some(false)`. +/// /// The style table carries a second, smaller one. A DIMSTYLE writes a /// variable only when it differs from the value the application starts from, /// and this library holds a style as a struct with no "the group was not @@ -194,6 +205,12 @@ fn apply_known_deviations(actual: &CadDatabase, expected: &mut CadDatabase) { } } + for layer in expected.tables.layers.values_mut() { + if layer.plot == Some(false) { + layer.plot = None; + } + } + fn lower(entity: &mut Entity) { let text_style = match entity { Entity::Text(text) => Some(&mut text.style_name), @@ -303,6 +320,16 @@ fn the_dxf_importer_still_drops_an_undeclared_name() { "the importer kept the name of an undeclared style -- remove the known deviation" ); + let fixture = Fixture::write("golden-g14-deviation.dxf", include_bytes!("golden/g14.dxf")); + let db = uncad::parse(&fixture.0).expect("g14 should parse"); + let expected: CadDatabase = + serde_json::from_str(include_str!("golden/g14.expected.json")).unwrap(); + assert_eq!(expected.tables.layers["NOPLOT"].plot, Some(false)); + assert_eq!( + db.tables.layers["NOPLOT"].plot, None, + "the importer told a stated 290 = 0 from an absent one -- remove the known deviation" + ); + let fixture = Fixture::write("golden-g13-deviation.dxf", include_bytes!("golden/g13.dxf")); let db = uncad::parse(&fixture.0).expect("g13 should parse"); let expected: CadDatabase = diff --git a/crates/uncad/tests/golden/README.md b/crates/uncad/tests/golden/README.md index b3ce3a3..a8f6c21 100644 --- a/crates/uncad/tests/golden/README.md +++ b/crates/uncad/tests/golden/README.md @@ -7,7 +7,7 @@ Copies of what the `uncad-model` repository's golden writer produces, one pair p | `.dxf` | The synthetic drawing (R2000 ASCII DXF) written from that case's spec | | `.expected.json` | The model that spec says a reader must produce from it | -Cases here: `g1`, `g2`, `g5`, `g6`, `g7`, `g8`, `g9`, `g10`, `g15`. `tests/golden.rs` parses +Cases here: `g1`, `g2`, `g5` to `g16`. `tests/golden.rs` parses each DXF and requires the model to match exactly, apart from the known deviations it applies to the expectation (each with a test that fails the day it is no longer needed). diff --git a/crates/uncad/tests/golden/g14.dxf b/crates/uncad/tests/golden/g14.dxf new file mode 100644 index 0000000..92c544e --- /dev/null +++ b/crates/uncad/tests/golden/g14.dxf @@ -0,0 +1,1374 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$HANDSEED + 5 +117 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +LTYPE + 5 +1 +100 +AcDbSymbolTable + 70 +1 + 0 +LTYPE + 5 +2 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CONTINUOUS + 70 +0 + 3 +Solid line + 72 +65 + 73 +0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +3 +100 +AcDbSymbolTable + 70 +8 + 0 +LAYER + 5 +4 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +CONTINUOUS + 0 +LAYER + 5 +5 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +WALLS + 70 +0 + 62 +7 + 6 +CONTINUOUS + 0 +LAYER + 5 +6 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +HIDDEN + 70 +0 + 62 +-1 + 6 +CONTINUOUS + 0 +LAYER + 5 +7 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +FROZEN + 70 +1 + 62 +3 + 6 +CONTINUOUS + 0 +LAYER + 5 +8 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LOCKED + 70 +4 + 62 +4 + 6 +CONTINUOUS + 0 +LAYER + 5 +9 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +NOPLOT + 70 +0 + 62 +5 + 6 +CONTINUOUS +290 +0 + 0 +LAYER + 5 +A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PLOT + 70 +0 + 62 +6 + 6 +CONTINUOUS +290 +1 +370 +50 + 0 +LAYER + 5 +B +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +DEFAULTWT + 70 +0 + 62 +8 + 6 +CONTINUOUS +370 +-3 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +C +100 +AcDbSymbolTable + 70 +3 + 0 +BLOCK_RECORD + 5 +D +330 +C +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space + 0 +BLOCK_RECORD + 5 +E +330 +C +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space + 0 +BLOCK_RECORD + 5 +F +330 +C +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +100 +330 +D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +101 +330 +D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +102 +330 +E +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +103 +330 +E +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +104 +330 +F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space0 + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space0 + 1 + + 0 +ENDBLK + 5 +105 +330 +F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +106 +330 +D +100 +AcDbEntity + 8 +WALLS +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +200.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +107 +330 +D +100 +AcDbEntity + 8 +HIDDEN +100 +AcDbLine + 10 +0.0 + 20 +10.0 + 30 +0.0 + 11 +200.0 + 21 +10.0 + 31 +0.0 + 0 +LINE + 5 +108 +330 +D +100 +AcDbEntity + 8 +FROZEN +100 +AcDbLine + 10 +0.0 + 20 +20.0 + 30 +0.0 + 11 +200.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 5 +109 +330 +D +100 +AcDbEntity + 8 +LOCKED +100 +AcDbLine + 10 +0.0 + 20 +30.0 + 30 +0.0 + 11 +200.0 + 21 +30.0 + 31 +0.0 + 0 +LINE + 5 +10A +330 +D +100 +AcDbEntity + 8 +NOPLOT +100 +AcDbLine + 10 +0.0 + 20 +40.0 + 30 +0.0 + 11 +200.0 + 21 +40.0 + 31 +0.0 + 0 +LINE + 5 +10B +330 +D +100 +AcDbEntity + 8 +PLOT +100 +AcDbLine + 10 +0.0 + 20 +50.0 + 30 +0.0 + 11 +200.0 + 21 +50.0 + 31 +0.0 + 0 +LINE + 5 +10C +330 +D +100 +AcDbEntity + 8 +DEFAULTWT +100 +AcDbLine + 10 +0.0 + 20 +60.0 + 30 +0.0 + 11 +200.0 + 21 +60.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +10D +330 +E +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +420.0 + 20 +0.0 + 10 +420.0 + 20 +297.0 + 10 +0.0 + 20 +297.0 + 0 +TEXT + 5 +10E +330 +E +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbText + 10 +320.0 + 20 +20.0 + 30 +0.0 + 40 +5.0 + 1 +SHEET 1 + 50 +0.0 +100 +AcDbText + 0 +VIEWPORT + 5 +10F +330 +E +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbViewport + 10 +210.0 + 20 +148.5 + 30 +0.0 + 40 +420.0 + 41 +297.0 + 68 +1 + 69 +1 + 12 +210.0 + 22 +148.5 + 32 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +10.0 + 24 +10.0 + 34 +0.0 + 15 +10.0 + 25 +10.0 + 35 +0.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +297.0 + 50 +0.0 + 51 +0.0 + 72 +1000 + 90 +32864 + 1 + +281 +0 + 71 +1 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +VIEWPORT + 5 +110 +330 +E +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbViewport + 10 +150.0 + 20 +150.0 + 30 +0.0 + 40 +200.0 + 41 +150.0 + 68 +2 + 69 +2 + 12 +100.0 + 22 +50.0 + 32 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +10.0 + 24 +10.0 + 34 +0.0 + 15 +10.0 + 25 +10.0 + 35 +0.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +10.0 + 27 +5.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +300.0 + 50 +0.0 + 51 +30.0 + 72 +1000 +341 +5 + 90 +32864 + 1 + +281 +0 + 71 +1 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +VIEWPORT + 5 +111 +330 +E +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbViewport + 10 +350.0 + 20 +60.0 + 30 +0.0 + 40 +100.0 + 41 +80.0 + 68 +0 + 69 +3 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +10.0 + 24 +10.0 + 34 +0.0 + 15 +10.0 + 25 +10.0 + 35 +0.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +80.0 + 50 +0.0 + 51 +0.0 + 72 +1000 +341 +7 +341 +9 + 90 +163936 + 1 + +281 +0 + 71 +1 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +112 +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_LAYOUT +350 +113 + 0 +DICTIONARY + 5 +113 +330 +112 +100 +AcDbDictionary +281 +1 + 3 +Model +350 +114 + 3 +Layout1 +350 +115 + 3 +Layout2 +350 +116 + 0 +LAYOUT + 5 +114 +330 +113 +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 + + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +420.0 + 21 +297.0 + 31 +0.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +420.0 + 25 +297.0 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +0 +330 +D + 0 +LAYOUT + 5 +115 +330 +113 +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ISO_A3_(420.00_x_297.00_MM) + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +297.0 + 45 +420.0 + 46 +-7.5 + 47 +-20.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +1 + 74 +5 + 7 + + 75 +16 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +420.0 + 21 +297.0 + 31 +0.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +420.0 + 25 +297.0 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +0 +330 +E + 0 +LAYOUT + 5 +116 +330 +113 +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ANSI_A_(8.50_x_11.00_Inches) + 6 + + 40 +6.35 + 41 +19.05 + 42 +6.35 + 43 +19.05 + 44 +215.9 + 45 +279.4 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +0 + 73 +3 + 74 +5 + 7 + + 75 +16 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout2 + 70 +1 + 71 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +11.0 + 21 +8.5 + 31 +0.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +11.0 + 25 +8.5 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +0 +330 +F + 0 +ENDSEC + 0 +EOF diff --git a/crates/uncad/tests/golden/g14.expected.json b/crates/uncad/tests/golden/g14.expected.json new file mode 100644 index 0000000..5d8ebc5 --- /dev/null +++ b/crates/uncad/tests/golden/g14.expected.json @@ -0,0 +1,1130 @@ +{ + "entities": [ + { + "type": "LINE", + "common": { + "id": 262, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "106" + }, + "layer": { + "type": "RESOLVED", + "data": "WALLS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 0.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 263, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "107" + }, + "layer": { + "type": "RESOLVED", + "data": "HIDDEN" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 10.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 10.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 264, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "108" + }, + "layer": { + "type": "RESOLVED", + "data": "FROZEN" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 20.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 20.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 265, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "109" + }, + "layer": { + "type": "RESOLVED", + "data": "LOCKED" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 30.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 30.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 266, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10A" + }, + "layer": { + "type": "RESOLVED", + "data": "NOPLOT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 40.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 40.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 267, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10B" + }, + "layer": { + "type": "RESOLVED", + "data": "PLOT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 50.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 50.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 268, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10C" + }, + "layer": { + "type": "RESOLVED", + "data": "DEFAULTWT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 60.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 60.0, + "z": 0.0 + } + }, + { + "type": "LWPOLYLINE", + "common": { + "id": 269, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10D" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 420.0, + "y": 0.0 + }, + { + "x": 420.0, + "y": 297.0 + }, + { + "x": 0.0, + "y": 297.0 + } + ], + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 270, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10E" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 320.0, + "y": 20.0 + }, + "text_height": 5.0, + "text": "SHEET 1", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "VIEWPORT", + "common": { + "id": 271, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10F" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 210.0, + "y": 148.5, + "z": 0.0 + }, + "width": 420.0, + "height": 297.0, + "view": { + "center": { + "x": 210.0, + "y": 148.5 + }, + "height": 297.0, + "target": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "direction": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + }, + "twist": 0.0, + "lens_length": 50.0 + }, + "on": true, + "viewport_id": 1, + "frozen_layers": [] + }, + { + "type": "VIEWPORT", + "common": { + "id": 272, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "110" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 150.0, + "y": 150.0, + "z": 0.0 + }, + "width": 200.0, + "height": 150.0, + "view": { + "center": { + "x": 100.0, + "y": 50.0 + }, + "height": 300.0, + "target": { + "x": 10.0, + "y": 5.0, + "z": 0.0 + }, + "direction": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + }, + "twist": 0.5235987755982988, + "lens_length": 50.0 + }, + "on": true, + "viewport_id": 2, + "frozen_layers": [ + { + "type": "RESOLVED", + "data": "WALLS" + } + ] + }, + { + "type": "VIEWPORT", + "common": { + "id": 273, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "111" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 350.0, + "y": 60.0, + "z": 0.0 + }, + "width": 100.0, + "height": 80.0, + "view": { + "center": { + "x": 0.0, + "y": 0.0 + }, + "height": 80.0, + "target": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "direction": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + }, + "twist": 0.0, + "lens_length": 50.0 + }, + "on": false, + "viewport_id": 3, + "frozen_layers": [ + { + "type": "RESOLVED", + "data": "FROZEN" + }, + { + "type": "RESOLVED", + "data": "NOPLOT" + } + ] + } + ], + "tables": { + "layers": { + "0": { + "name": "0", + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "DEFAULTWT": { + "name": "DEFAULTWT", + "color_index": 8, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": -3, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "FROZEN": { + "name": "FROZEN", + "color_index": 3, + "off": false, + "frozen": true, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "HIDDEN": { + "name": "HIDDEN", + "color_index": -1, + "off": true, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "LOCKED": { + "name": "LOCKED", + "color_index": 4, + "off": false, + "frozen": false, + "locked": true, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "NOPLOT": { + "name": "NOPLOT", + "color_index": 5, + "off": false, + "frozen": false, + "locked": false, + "plot": false, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "PLOT": { + "name": "PLOT", + "color_index": 6, + "off": false, + "frozen": false, + "locked": false, + "plot": true, + "lineweight": 50, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + }, + "WALLS": { + "name": "WALLS", + "color_index": 7, + "off": false, + "frozen": false, + "locked": false, + "plot": null, + "lineweight": null, + "linetype": { + "type": "RESOLVED", + "data": "CONTINUOUS" + } + } + }, + "block_records": { + "*Model_Space": { + "name": "*Model_Space", + "entities": [ + { + "type": "LINE", + "common": { + "id": 262, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "106" + }, + "layer": { + "type": "RESOLVED", + "data": "WALLS" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 0.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 263, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "107" + }, + "layer": { + "type": "RESOLVED", + "data": "HIDDEN" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 10.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 10.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 264, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "108" + }, + "layer": { + "type": "RESOLVED", + "data": "FROZEN" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 20.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 20.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 265, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "109" + }, + "layer": { + "type": "RESOLVED", + "data": "LOCKED" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 30.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 30.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 266, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10A" + }, + "layer": { + "type": "RESOLVED", + "data": "NOPLOT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 40.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 40.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 267, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10B" + }, + "layer": { + "type": "RESOLVED", + "data": "PLOT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 50.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 50.0, + "z": 0.0 + } + }, + { + "type": "LINE", + "common": { + "id": 268, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10C" + }, + "layer": { + "type": "RESOLVED", + "data": "DEFAULTWT" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 0.0, + "y": 60.0, + "z": 0.0 + }, + "end_point": { + "x": 200.0, + "y": 60.0, + "z": 0.0 + } + } + ] + }, + "*Paper_Space": { + "name": "*Paper_Space", + "entities": [ + { + "type": "LWPOLYLINE", + "common": { + "id": 269, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10D" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "vertices": [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 420.0, + "y": 0.0 + }, + { + "x": 420.0, + "y": 297.0 + }, + { + "x": 0.0, + "y": 297.0 + } + ], + "closed": true, + "bulges": [], + "widths": [], + "const_width": 0.0, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "TEXT", + "common": { + "id": 270, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10E" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "start_point": { + "x": 320.0, + "y": 20.0 + }, + "text_height": 5.0, + "text": "SHEET 1", + "rotation": 0.0, + "horizontal_justification": "LEFT", + "vertical_justification": "BASELINE", + "alignment_point": null, + "width_factor": 1.0, + "oblique_angle": 0.0, + "style_name": { + "type": "ABSENT" + }, + "elevation": 0.0, + "extrusion": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + } + }, + { + "type": "VIEWPORT", + "common": { + "id": 271, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "10F" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 210.0, + "y": 148.5, + "z": 0.0 + }, + "width": 420.0, + "height": 297.0, + "view": { + "center": { + "x": 210.0, + "y": 148.5 + }, + "height": 297.0, + "target": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "direction": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + }, + "twist": 0.0, + "lens_length": 50.0 + }, + "on": true, + "viewport_id": 1, + "frozen_layers": [] + }, + { + "type": "VIEWPORT", + "common": { + "id": 272, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "110" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 150.0, + "y": 150.0, + "z": 0.0 + }, + "width": 200.0, + "height": 150.0, + "view": { + "center": { + "x": 100.0, + "y": 50.0 + }, + "height": 300.0, + "target": { + "x": 10.0, + "y": 5.0, + "z": 0.0 + }, + "direction": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + }, + "twist": 0.5235987755982988, + "lens_length": 50.0 + }, + "on": true, + "viewport_id": 2, + "frozen_layers": [ + { + "type": "RESOLVED", + "data": "WALLS" + } + ] + }, + { + "type": "VIEWPORT", + "common": { + "id": 273, + "origin": "VECTOR", + "confidence": "HIGH", + "source_handle": { + "type": "RESOLVED", + "data": "111" + }, + "layer": { + "type": "RESOLVED", + "data": "0" + }, + "color_index": 256, + "true_color": null, + "invisible": false + }, + "center": { + "x": 350.0, + "y": 60.0, + "z": 0.0 + }, + "width": 100.0, + "height": 80.0, + "view": { + "center": { + "x": 0.0, + "y": 0.0 + }, + "height": 80.0, + "target": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "direction": { + "x": 0.0, + "y": 0.0, + "z": 1.0 + }, + "twist": 0.0, + "lens_length": 50.0 + }, + "on": false, + "viewport_id": 3, + "frozen_layers": [ + { + "type": "RESOLVED", + "data": "FROZEN" + }, + { + "type": "RESOLVED", + "data": "NOPLOT" + } + ] + } + ] + }, + "*Paper_Space0": { + "name": "*Paper_Space0", + "entities": [] + } + }, + "dim_styles": {}, + "mlinestyles": {}, + "layouts": { + "Layout1": { + "name": "Layout1", + "tab_order": 1, + "block_name": { + "type": "RESOLVED", + "data": "*Paper_Space" + }, + "limits_min": { + "x": 0.0, + "y": 0.0 + }, + "limits_max": { + "x": 420.0, + "y": 297.0 + }, + "plot_settings": { + "paper_name": "ISO_A3_(420.00_x_297.00_MM)", + "paper_width": 297.0, + "paper_height": 420.0, + "margin_left": 7.5, + "margin_bottom": 20.0, + "margin_right": 7.5, + "margin_top": 20.0, + "plot_origin": { + "x": -7.5, + "y": -20.0 + }, + "paper_units": "MILLIMETERS", + "rotation": "COUNTERCLOCKWISE90", + "scale_numerator": 1.0, + "scale_denominator": 1.0 + } + }, + "Layout2": { + "name": "Layout2", + "tab_order": 2, + "block_name": { + "type": "RESOLVED", + "data": "*Paper_Space0" + }, + "limits_min": { + "x": 0.0, + "y": 0.0 + }, + "limits_max": { + "x": 11.0, + "y": 8.5 + }, + "plot_settings": { + "paper_name": "ANSI_A_(8.50_x_11.00_Inches)", + "paper_width": 215.9, + "paper_height": 279.4, + "margin_left": 6.35, + "margin_bottom": 19.05, + "margin_right": 6.35, + "margin_top": 19.05, + "plot_origin": { + "x": 0.0, + "y": 0.0 + }, + "paper_units": "INCHES", + "rotation": "CLOCKWISE90", + "scale_numerator": 1.0, + "scale_denominator": 1.0 + } + }, + "Model": { + "name": "Model", + "tab_order": 0, + "block_name": { + "type": "RESOLVED", + "data": "*Model_Space" + }, + "limits_min": { + "x": 0.0, + "y": 0.0 + }, + "limits_max": { + "x": 420.0, + "y": 297.0 + }, + "plot_settings": { + "paper_name": "", + "paper_width": 0.0, + "paper_height": 0.0, + "margin_left": 0.0, + "margin_bottom": 0.0, + "margin_right": 0.0, + "margin_top": 0.0, + "plot_origin": { + "x": 0.0, + "y": 0.0 + }, + "paper_units": "MILLIMETERS", + "rotation": "UNROTATED", + "scale_numerator": 1.0, + "scale_denominator": 1.0 + } + } + } + }, + "read_diagnostics": { + "warnings": [] + } +} \ No newline at end of file diff --git a/crates/uncad/tests/layouts.rs b/crates/uncad/tests/layouts.rs new file mode 100644 index 0000000..64fa231 --- /dev/null +++ b/crates/uncad/tests/layouts.rs @@ -0,0 +1,255 @@ +//! Paper layouts: the LAYOUT objects with their plot settings, and the +//! viewports on their sheets. Sources: the corpus's `example_2000.dwg` and +//! its text twin (two Letter layouts, each with only its overall viewport, +//! and the model tab set up in inches), and this project's +//! `twisted_viewport_r2000.dxf`, `plot_origin_r2000.dxf` and +//! `viewport_states_r2000.dxf`, whose page setups `tests/fixtures/README.md` +//! lists group by group. Where a sheet lies on its paper, and what a +//! viewport maps where, are consumers' derivations and not asserted here. + +use uncad::model::{Entity, Point2D, Ref}; +use uncad::tables::{LayoutRecord, PlotPaperUnits, PlotRotation}; + +const EXAMPLE_2000: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000" +); + +macro_rules! fixture { + ($name:literal) => { + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/", $name) + }; +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-4 +} + +fn point(x: f64, y: f64) -> Point2D { + Point2D { x, y } +} + +fn near_point(a: Point2D, b: Point2D) -> bool { + near(a.x, b.x) && near(a.y, b.y) +} + +/// `[left, bottom, right, top]`. +fn margins(l: &LayoutRecord) -> [f64; 4] { + let p = &l.plot_settings; + [p.margin_left, p.margin_bottom, p.margin_right, p.margin_top] +} + +#[test] +fn autocad_layouts_carry_their_plot_settings_in_both_formats() { + for extension in ["dwg", "dxf"] { + let path = format!("{EXAMPLE_2000}.{extension}"); + let db = uncad::parse(&path).expect("corpus file must parse"); + let layouts = &db.tables.layouts; + assert_eq!( + layouts.keys().cloned().collect::>(), + ["Layout1", "Layout2", "Model"], + "{path}" + ); + + let model = &layouts["Model"]; + assert_eq!(model.tab_order, 0); + assert_eq!(model.block_name, Ref::Resolved("*Model_Space".to_string())); + let m = &model.plot_settings; + assert_eq!(m.paper_name, "ANSI_A_(8.50_x_11.00_Inches)"); + assert_eq!(m.paper_units, Some(PlotPaperUnits::Inches), "{path}"); + // A custom scale of 1 : 78.436... + assert!( + near(m.scale_numerator / m.scale_denominator, 0.012749), + "{path}" + ); + + for (name, tab, block) in [ + ("Layout1", 1, "*Paper_Space0"), + ("Layout2", 2, "*Paper_Space"), + ] { + let l = &layouts[name]; + assert_eq!(l.tab_order, tab, "{path} {name}"); + assert_eq!(l.block_name, Ref::Resolved(block.to_string()), "{path}"); + let p = &l.plot_settings; + assert_eq!(p.paper_name, "Letter_(8.50_x_11.00_Inches)", "{path}"); + assert!(near(p.paper_width, 215.9) && near(p.paper_height, 279.4)); + assert!(margins(l).iter().all(|m| near(*m, 6.35)), "{path}"); + assert_eq!( + (p.paper_units, p.rotation), + ( + Some(PlotPaperUnits::Millimeters), + Some(PlotRotation::Counterclockwise90) + ), + "{path}" + ); + assert_eq!((p.scale_numerator, p.scale_denominator), (1.0, 1.0)); + // Landscape Letter with the printable corner at the origin: the + // limits AutoCAD stored for the layout. + assert!(near_point(l.limits_min, point(-6.35, -6.35)), "{path}"); + assert!(near_point(l.limits_max, point(273.05, 209.55)), "{path}"); + } + + // The two layouts' overall viewports, in plan. The DWG says both are + // on and states no number (the binary format stores none); the DXF + // AutoCAD wrote states 68 = 0 and 69 = 0 for the one of the layout + // that is not current, and 68 = 1, 69 = 1 for the other -- what each + // file states. + let mut viewports: Vec<(String, Option, Option)> = db + .entities + .iter() + .filter_map(|e| match e { + Entity::Viewport(v) => { + let view = v.view.expect("an R2000 viewport states its view"); + assert_eq!(view.direction.z, 1.0, "{path}"); + let Ref::Resolved(h) = &v.common.source_handle else { + panic!("{v:?}"); + }; + Some((h.clone(), v.on, v.viewport_id)) + } + _ => None, + }) + .collect(); + viewports.sort(); + let expected = if extension == "dwg" { + [ + ("84".to_string(), Some(true), None), + ("88".to_string(), Some(true), None), + ] + } else { + [ + ("84".to_string(), Some(false), Some(0)), + ("88".to_string(), Some(true), Some(1)), + ] + }; + assert_eq!(viewports, expected, "{path}"); + } +} + +#[test] +fn the_twisted_viewport_fixture_s_layout_is_an_a4_sheet_turned_landscape() { + let db = uncad::parse(fixture!("twisted_viewport_r2000.dxf")).expect("fixture parses"); + assert_eq!(db.tables.layouts.len(), 1); + let l = &db.tables.layouts["Layout1"]; + assert_eq!(l.tab_order, 1); + assert_eq!(l.block_name, Ref::Resolved("*Paper_Space".to_string())); + let p = &l.plot_settings; + assert_eq!(p.paper_name, "ISO_A4_(210.00_x_297.00_MM)"); + assert_eq!((p.paper_width, p.paper_height), (210.0, 297.0)); + assert_eq!(margins(l), [6.35; 4]); + assert_eq!(p.plot_origin, point(0.0, 0.0)); + assert_eq!( + (p.paper_units, p.rotation), + ( + Some(PlotPaperUnits::Millimeters), + Some(PlotRotation::Counterclockwise90) + ) + ); + assert_eq!( + (l.limits_min, l.limits_max), + (point(-6.35, -6.35), point(290.65, 203.65)) + ); +} + +/// An inch layout with asymmetric margins and a plot origin (DXF 46/47): +/// every value as the file states it, millimetres whatever the paper unit. +#[test] +fn the_plot_origin_fixture_s_page_setup_reads_as_stated() { + let db = uncad::parse(fixture!("plot_origin_r2000.dxf")).expect("fixture parses"); + let l = &db.tables.layouts["Layout1"]; + let p = &l.plot_settings; + assert_eq!(p.paper_name, "ANSI_B_(17.00_x_11.00_Inches)"); + assert_eq!((p.paper_width, p.paper_height), (431.8, 279.4)); + assert_eq!(margins(l), [6.35, 19.05, 6.35, 19.05]); + assert_eq!(p.plot_origin, point(-6.35, -12.7)); + assert_eq!( + (p.paper_units, p.rotation), + (Some(PlotPaperUnits::Inches), Some(PlotRotation::Unrotated)) + ); + // AutoCAD's own placement of the sheet, which the file stores. + assert_eq!( + (l.limits_min, l.limits_max), + (point(0.0, -0.25), point(17.0, 10.75)) + ); +} + +/// Two page setups no other fixture has: upside down in millimetres with +/// asymmetric margins, and a quarter turn clockwise in inches. +#[test] +fn the_viewport_states_fixture_s_two_layouts_read_as_stated() { + let db = uncad::parse(fixture!("viewport_states_r2000.dxf")).expect("fixture parses"); + let layouts = &db.tables.layouts; + assert_eq!(layouts.len(), 2); + + let first = &layouts["Layout1"]; + assert_eq!(first.block_name, Ref::Resolved("*Paper_Space".to_string())); + assert_eq!(margins(first), [10.0, 20.0, 5.0, 15.0]); + assert_eq!( + ( + first.plot_settings.paper_units, + first.plot_settings.rotation + ), + ( + Some(PlotPaperUnits::Millimeters), + Some(PlotRotation::UpsideDown) + ) + ); + assert_eq!( + (first.limits_min, first.limits_max), + (point(-10.0, -20.0), point(200.0, 277.0)) + ); + + let second = &layouts["Layout2"]; + assert_eq!(second.tab_order, 2); + assert_eq!( + second.block_name, + Ref::Resolved("*Paper_Space0".to_string()) + ); + assert_eq!( + ( + second.plot_settings.paper_width, + second.plot_settings.paper_height + ), + (431.8, 279.4) + ); + assert_eq!( + ( + second.plot_settings.paper_units, + second.plot_settings.rotation + ), + ( + Some(PlotPaperUnits::Inches), + Some(PlotRotation::Clockwise90) + ) + ); + assert_eq!( + (second.limits_min, second.limits_max), + (point(-0.25, -0.25), point(10.75, 16.75)) + ); +} + +/// R13 and R14 have no layouts, but a drawing an application with layouts +/// saved in that format keeps its LAYOUT objects, and they are read like any +/// other: the corpus's `example_r14.dwg` has the same three tabs as its +/// R2000 sibling. +#[test] +fn an_r14_drawing_saved_by_a_later_application_keeps_its_layouts() { + let db = uncad::parse(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_r14.dwg" + )) + .expect("corpus file must parse"); + let tabs: Vec<(&str, i32, Ref)> = db + .tables + .layouts + .values() + .map(|l| (l.name.as_str(), l.tab_order, l.block_name.clone())) + .collect(); + assert_eq!( + tabs, + [ + ("Layout1", 1, Ref::Resolved("*PAPER_SPACE0".to_string())), + ("Layout2", 2, Ref::Resolved("*PAPER_SPACE".to_string())), + ("Model", 0, Ref::Resolved("*MODEL_SPACE".to_string())), + ] + ); +} diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 13e7aff..bddcddc 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -633,7 +633,8 @@ plot flag (DXF 290) and the lineweight (DXF 370). An R2000-or-later DWG states b DXF importer leaves a 290 or a 370 the file left out at 0, so a stated "do not plot" and silence read the same, as do a stated 0.00 mm and silence: `plot` is `Some(true)` or `None` for a DXF, never `Some(false)`, and a lineweight code of 0 is `None`. A drawing -older than R2000 has neither (`None`). +older than R2000 has neither (`None`). The golden case G14 states `290 = 0` on one layer; +`tests/golden.rs` pins the difference as a known deviation. ## Fixed: SPLINE control points read at the wrong stride From 4fed21a762c96c66cf5a5d001ec826cecd083a60 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 16:02:23 +0900 Subject: [PATCH 32/54] Pin what a dimension carries against its twins and the corpus tests/dimensions.rs brings the parser side of the feature branch's dimension tests onto the model's shape (kind, points by DXF group, measurement as stated, radians for angles): - the dimlfac12 fixture's kind, points, measurement, style and its style's DIMLFAC; - example_2000.dwg against its DXF twin, every dimension: kind, all five points, ordinate axis and measurement agree between LibreDWG's two readers -- the check the angular DXF mapping fix made pass -- with 43B at 108 degrees in radians and 430 an X ordinate; - 2000/TS1.dwg's radius, diameter and 3-point angle, each stored measurement equal to what its points give and to AutoCAD's cached label; - the example_2007 / R13 / R14 family: the R13/R14 files' unstated (zero) measurements are None, and 2007's stated one is carried to the last bit; - a TOLERANCE without a height names the style its height comes from. The display-text, recomputed-measurement and label assertions of the original tests belong to the export crate. Ported-from: 74bb835, a7f9ea5, f80bdfa Co-Authored-By: Claude Opus 5.5 --- crates/uncad/tests/dimensions.rs | 268 +++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 crates/uncad/tests/dimensions.rs diff --git a/crates/uncad/tests/dimensions.rs b/crates/uncad/tests/dimensions.rs new file mode 100644 index 0000000..ce52874 --- /dev/null +++ b/crates/uncad/tests/dimensions.rs @@ -0,0 +1,268 @@ +//! What a DIMENSION carries: its kind, the points it was built from by DXF +//! group, the measurement the file stored, its text and style. Sources: the +//! `dimlfac12_r2000.dxf` fixture (ground truth in tests/fixtures/README.md), +//! the corpus's `example_2000.dwg` and its text twin -- the same drawing +//! through LibreDWG's DWG decoder and its DXF importer, which lay two kinds' +//! points out differently -- `2000/TS1.dwg` for the radial and 3-point +//! kinds `example_2000` lacks, and the `example_2007` / R13 / R14 family, +//! the same drawing with and without a stored measurement. +//! +//! How a measurement is displayed, and whether it agrees with the points, +//! are consumers' questions: the model carries what the file states. + +use uncad::model::{DimensionEntity, DimensionKind, OrdinateAxis, Point3D, Ref, TextOverride}; +use uncad::Entity; + +const CORPUS: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/" +); +const DIMLFAC12: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/dimlfac12_r2000.dxf" +); + +fn parse(path: &str) -> uncad::CadDatabase { + uncad::parse(path).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +fn corpus(name: &str) -> uncad::CadDatabase { + parse(&format!("{CORPUS}{name}")) +} + +fn dimensions(db: &uncad::CadDatabase) -> Vec<&DimensionEntity> { + db.entities + .iter() + .filter_map(|e| match e { + Entity::Dimension(d) => Some(d), + _ => None, + }) + .collect() +} + +fn handle(d: &DimensionEntity) -> &str { + match &d.common.source_handle { + Ref::Resolved(h) => h, + other => panic!("{other:?}"), + } +} + +fn same(a: Option, b: Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => { + (a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6 && (a.z - b.z).abs() < 1e-6 + } + _ => false, + } +} + +fn p3(x: f64, y: f64) -> Option { + Some(Point3D { x, y, z: 0.0 }) +} + +#[test] +fn the_fixture_dimension_carries_its_value_points_and_style() { + let db = parse(DIMLFAC12); + let dims = dimensions(&db); + assert_eq!(dims.len(), 1); + let dim = dims[0]; + assert_eq!(dim.kind, Some(DimensionKind::Rotated)); + assert_eq!(dim.measurement, Some(10.0), "DXF 42 as written"); + assert_eq!(dim.definition_point, p3(10.0, 5.0), "group 10"); + assert_eq!((dim.text_midpoint.x, dim.text_midpoint.y), (5.0, 6.0)); + assert_eq!(dim.points.extension1, p3(0.0, 0.0), "group 13"); + assert_eq!(dim.points.extension2, p3(10.0, 0.0), "group 14"); + assert_eq!((dim.points.radial, dim.points.arc), (None, None)); + assert_eq!(dim.rotation, 0.0); + assert_eq!( + dim.text_override, + TextOverride::Measured, + "an empty group 1" + ); + assert_eq!(dim.style_name, Ref::Resolved("STANDARD".to_string())); + assert_eq!(dim.ordinate_axis, None); + // The style's own DIMLFAC (144), which is what the displayed 120 is 12 + // times the measurement by. + assert_eq!(db.tables.dim_styles["STANDARD"].length_factor, Some(12.0)); +} + +/// `example_2000.dxf` is AutoCAD's DXF of `example_2000.dwg`, so every +/// dimension must come out the same whichever of LibreDWG's readers read +/// it -- in particular the 2-line angular (43B, 108 degrees) and the +/// X-type ordinate (430, 4630.52), whose points and axis the DXF importer +/// stores in different fields from the DWG decoder. +#[test] +fn the_dwg_and_dxf_readers_agree_on_every_dimension() { + let (dwg, dxf) = (corpus("example_2000.dwg"), corpus("example_2000.dxf")); + let (from_dwg, from_dxf) = (dimensions(&dwg), dimensions(&dxf)); + assert_eq!(from_dwg.len(), from_dxf.len()); + assert!(from_dwg.len() >= 10, "{}", from_dwg.len()); + for a in &from_dwg { + let b = from_dxf + .iter() + .find(|d| handle(d) == handle(a)) + .unwrap_or_else(|| panic!("{} missing from the DXF", handle(a))); + let what = handle(a); + assert_eq!(a.kind, b.kind, "{what}"); + assert!( + same(a.definition_point, b.definition_point), + "{what}: {a:?} vs {b:?}" + ); + for (x, y) in [ + (a.points.extension1, b.points.extension1), + (a.points.extension2, b.points.extension2), + (a.points.radial, b.points.radial), + (a.points.arc, b.points.arc), + ] { + assert!(same(x, y), "{what}: {:?} vs {:?}", a.points, b.points); + } + assert_eq!(a.ordinate_axis, b.ordinate_axis, "{what}"); + let (ma, mb) = (a.measurement.unwrap(), b.measurement.unwrap()); + assert!((ma - mb).abs() < 1e-6 * ma.abs().max(1.0), "{what}"); + } + + let two_line = from_dxf.iter().find(|d| handle(d) == "43B").expect("43B"); + assert_eq!(two_line.kind, Some(DimensionKind::Angular2Line)); + let radians = two_line.measurement.unwrap(); + assert!((radians - 108f64.to_radians()).abs() < 1e-9, "{radians}"); + let ordinate = from_dxf.iter().find(|d| handle(d) == "430").expect("430"); + assert_eq!(ordinate.ordinate_axis, Some(OrdinateAxis::X)); + let x = ordinate.measurement.unwrap(); + assert!((x - 4630.519359).abs() < 1e-5, "{x}"); + // The feature 430 measures sits at x = 4630.52 from the datum at the + // origin: group 13's x is the measurement. + assert!((ordinate.points.extension1.unwrap().x - x).abs() < 1e-6); +} + +/// The one dimension of `kind` in `dims`. +fn of_kind<'a>(dims: &[&'a DimensionEntity], kind: DimensionKind) -> &'a DimensionEntity { + let mut found = dims.iter().filter(|d| d.kind == Some(kind)); + let dim = *found + .next() + .unwrap_or_else(|| panic!("a {kind:?} dimension")); + assert!(found.next().is_none(), "one {kind:?} dimension only"); + dim +} + +/// `2000/TS1.dwg` is a sampler AutoCAD wrote (its TS1.txt lists a 3-point +/// angular, a diametric and a radial dimension); its cached labels +/// `R1.1897`, `2.3794` and `45` degrees are AutoCAD's own. Each stored +/// measurement agrees with the points it was built from, which is what +/// shows the points are in the right fields: the centre in group 10 and the +/// point on the arc in 15 for a radius, the chord's ends for a diameter, +/// the vertex in 15 and the two rays' points in 13 and 14 for an angle. +#[test] +fn the_radial_and_three_point_kinds_read_from_an_autocad_dwg() { + let db = corpus("2000/TS1.dwg"); + let dims = dimensions(&db); + let distance = |a: Option, b: Option| { + let (a, b) = (a.expect("a point"), b.expect("a point")); + (b.x - a.x).hypot(b.y - a.y) + }; + + let radius = of_kind(&dims, DimensionKind::Radius); + let by_points = distance(radius.definition_point, radius.points.radial); + let stored = radius.measurement.unwrap(); + assert!((stored - by_points).abs() < 1e-9, "{stored} vs {by_points}"); + assert!( + (stored - 1.1897).abs() < 5e-5, + "the label says R1.1897: {stored}" + ); + + let diameter = of_kind(&dims, DimensionKind::Diameter); + let by_points = distance(diameter.definition_point, diameter.points.radial); + let stored = diameter.measurement.unwrap(); + assert!((stored - by_points).abs() < 1e-9, "{stored} vs {by_points}"); + assert!( + (stored - 2.3794).abs() < 5e-5, + "the label says 2.3794: {stored}" + ); + + let angular = of_kind(&dims, DimensionKind::Angular3Point); + let centre = angular.points.radial.expect("group 15"); + let ray = |p: Option| { + let p = p.expect("a point"); + (p.y - centre.y).atan2(p.x - centre.x) + }; + let by_points = (ray(angular.points.extension2) - ray(angular.points.extension1)).abs(); + let stored = angular.measurement.unwrap(); + assert!((stored - by_points).abs() < 1e-9, "{stored} vs {by_points}"); + assert!( + (stored - 45f64.to_radians()).abs() < 1e-9, + "the label says 45 degrees; the file states radians: {stored}" + ); + + for dim in [radius, diameter, angular] { + assert_eq!(dim.style_name, Ref::Resolved("Standard".to_string())); + assert!(matches!(dim.block_name, Ref::Resolved(_)), "{dim:?}"); + } +} + +/// The same drawing in four files that differ in whether they carry +/// `act_measurement`: `example_2007.dwg` writes it, and the R13 and R14 +/// files leave it at 0.0 -- which reads as "not stated", not as a zero +/// measurement. +#[test] +fn a_stored_measurement_of_zero_is_not_a_measurement() { + let reference = corpus("example_2007.dwg"); + let reference: Vec<(String, f64)> = dimensions(&reference) + .iter() + .map(|d| { + let m = d + .measurement + .unwrap_or_else(|| panic!("2007 writes act_measurement for {}", handle(d))); + (handle(d).to_string(), m) + }) + .collect(); + assert_eq!(reference.len(), 10, "the drawing has ten dimensions"); + + for name in ["example_r14.dwg", "example_r13.dwg", "example_r13.dxf"] { + let db = corpus(name); + let dims = dimensions(&db); + let mut seen = 0; + for (h, _) in &reference { + // The R13 DXF drops the ARC_LENGTH dimension entirely. + let Some(d) = dims.iter().find(|d| handle(d) == h) else { + continue; + }; + assert_eq!(d.measurement, None, "{name} {h}"); + seen += 1; + } + assert!(seen >= 9, "{name}: {seen} of the ten dimensions"); + } +} + +/// Where the file carries a measurement, it is carried to the last bit: +/// `example_2007.dwg`'s ALIGNED 37E states 1504.6794770244742, the value the +/// R13 and R14 saves of the same drawing leave out. +#[test] +fn a_stored_measurement_is_carried_as_stated() { + let db = corpus("example_2007.dwg"); + let dims = dimensions(&db); + let aligned = dims.iter().find(|d| handle(d) == "37E").expect("37E"); + assert_eq!(aligned.kind, Some(DimensionKind::Aligned)); + assert_eq!(aligned.measurement, Some(1504.6794770244742)); +} + +/// An R2000+ TOLERANCE stores no height of its own (LibreDWG decodes +/// `height` for R13/R14 only), so the model says none and names the style +/// its height comes from: `example_2000.dwg`'s 4F1 draws at ISO-25's +/// DIMTXT, 2.5 -- a consumer's lookup. +#[test] +fn a_tolerance_names_the_style_its_height_comes_from() { + let db = corpus("example_2000.dwg"); + let tolerance = db + .entities + .iter() + .find_map(|e| match e { + Entity::Tolerance(t) if t.common.source_handle == Ref::Resolved("4F1".to_string()) => { + Some(t) + } + _ => None, + }) + .expect("TOLERANCE 4F1"); + assert_eq!(tolerance.text_height, None); + assert_eq!(tolerance.style_name, Ref::Resolved("ISO-25".to_string())); + assert_eq!(db.tables.dim_styles["ISO-25"].text_height, Some(2.5)); +} From 00ccb9fee2ae8678258bded88a99348a46030562 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 16:03:16 +0900 Subject: [PATCH 33/54] Pin the hidden-layers and hatched-viewport fixtures from the parser's side The two fixtures whose feature-branch assertions were all about drawing and exporting get the parser-side facts those rested on: every LINE of hidden_layers_r2000.dxf keeps the layer it names whatever that layer's state, and only the one with group 60 = 1 is invisible; each of hatched_viewport_r2000.dxf's pattern HATCHes is owned by its own space's block and carries the definition line its groups state. With this every fixture has a parser-side test; what the renderer and the export do with them is left to those crates. Ported-from: c045d6a, 72cedb9 Co-Authored-By: Claude Opus 5.5 --- crates/uncad/tests/fixtures.rs | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/uncad/tests/fixtures.rs b/crates/uncad/tests/fixtures.rs index 1820b2a..3bd938a 100644 --- a/crates/uncad/tests/fixtures.rs +++ b/crates/uncad/tests/fixtures.rs @@ -899,3 +899,61 @@ fn the_viewport_states_fixture_carries_each_state() { ); } } + +// ------------------------------------------------ hidden layers, hatches + +/// One LINE per layer, in table order, then an invisible LINE (`60 = 1`) and +/// a visible one on `VISIBLE`: the entity's own invisible flag is carried, +/// and every line keeps the layer it names whatever that layer's state. +#[test] +fn the_hidden_layers_fixture_s_entities_keep_their_layers_and_their_flag() { + let db = parse(HIDDEN_LAYERS); + let lines: Vec<(Ref, bool)> = db + .entities + .iter() + .map(|e| (e.common().layer.clone(), e.common().invisible)) + .collect(); + assert_eq!( + lines, + [ + (resolved("0"), false), + (resolved("VISIBLE"), false), + (resolved("OFF"), false), + (resolved("FROZEN"), false), + (resolved("NOPLOT"), false), + (resolved("Defpoints"), false), + (resolved("LOCKED"), false), + (resolved("VISIBLE"), true), + (resolved("VISIBLE"), false), + ] + ); +} + +/// One pattern HATCH per space: the model one under the viewport (vertical +/// lines 2 apart, `53 = 90`), the paper one beside it (horizontal lines 4 +/// apart), each owned by its own space's block. +#[test] +fn the_hatched_viewport_fixture_keeps_each_hatch_in_its_space() { + let db = parse(HATCHED_VIEWPORT); + let pattern = |block: &str| -> Vec<(f64, f64, f64)> { + db.tables.block_records[block] + .entities + .iter() + .filter_map(|e| match e { + Entity::Hatch(h) => Some( + h.pattern_lines + .iter() + .map(|l| (l.angle, l.offset.x, l.offset.y)) + .collect::>(), + ), + _ => None, + }) + .flatten() + .collect() + }; + assert_eq!( + pattern("*Model_Space"), + [(std::f64::consts::FRAC_PI_2, -2.0, 0.0)] + ); + assert_eq!(pattern("*Paper_Space"), [(0.0, 0.0, 4.0)]); +} From bb1570a3ae99d8cd20749a12b40c1a7e322519b4 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 16:03:17 +0900 Subject: [PATCH 34/54] Say what the table conversion reads now ARCHITECTURE listed LAYER, BLOCK_RECORD and MLINESTYLE as the tables table_convert.rs reads and the model's `tables` carries. It also reads every DIMSTYLE, and the LAYOUTs with their embedded plot settings. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7735f85..c59d7c2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,7 +26,8 @@ crates/ examples/ smoke.rs -- manual check of the raw FFI (see "Test layout") uncad/ the safe API, layered: dynapi.rs (reflection helpers) -> convert.rs (raw Dwg_Data* -> uncad_model's Entity) -> - table_convert.rs (LAYER/BLOCK_RECORD/MLINESTYLE), with acis.rs + table_convert.rs (LAYER/BLOCK_RECORD/DIMSTYLE/MLINESTYLE and + LAYOUT with its plot settings), with acis.rs for 3DSOLID wireframes and hatch_color.rs for the gradient stop colors the model carries. The model and its JSON form are the uncad-model crate's; SVG/PNG rendering is the @@ -188,7 +189,8 @@ The model is not this crate's: `CadDatabase`, `Entity`, `Tables` and their JSON the [`uncad-model`](https://github.com/iyulab/uncad-model) crate (MIT, pure data), which this crate depends on by version and re-exports as `uncad::model` / `uncad::tables` / `uncad::json`. `CadDatabase` is a plain Rust value holding `entities` (what the model and -paper spaces own), `tables` (LAYER, every BLOCK_RECORD, MLINESTYLE) and `read_diagnostics` +paper spaces own), `tables` (LAYER, every BLOCK_RECORD, DIMSTYLE, MLINESTYLE, and the +LAYOUTs with their plot settings) and `read_diagnostics` (the reader's non-fatal warnings). The `Dwg_Data` that LibreDWG filled in through `dwg_read_file`/`dxf_read_file` is walked twice inside `parse()` (`convert_entities`, then `convert_tables`), freed with `dwg_free` immediately afterwards, and never reaches the From d52a84de3e86584743e7774d1b5da1c1919cd719 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 17:39:37 +0900 Subject: [PATCH 35/54] Pin what the parser reads on top of upstream's polyline and OCS reads Three cases the merge kept on top of upstream's reading code, each now shown by a test that fails without it: - tests/extrusion.rs: a normal the record does not store is the world Z axis. LibreDWG leaves the field zero then -- a DWG LWPOLYLINE whose flag bit 1 is clear, a pre-R13 entity whose options leave it out -- and reading it as it stands gives (0, 0, 0) for every LWPOLYLINE of example_2000.dwg and every CIRCLE, ARC, SOLID, TRACE and 2D POLYLINE of r11/entities-2d.dwg and r10/entities.dwg, where their DXF twins (and the model's own default for an absent group) say (0, 0, 1). - convert.rs unit tests for with_widths: an LWPOLYLINE's width array goes on its vertices one to one; widths that all repeat const_width are no widths of the vertices; an array of another length is reported as POLYLINE_WIDTH and not used, the rule upstream gave the bulge array. TextDecoder gains a test-only constructor for them. - polyline_fields.rs: the corpus's R2000 3D polyline has six vertices in both of its formats, the last one included. Upstream's vertex walk keeps it as this branch's did; the library's point accessors gave the DWG five. Co-Authored-By: Claude Opus 5.5 --- crates/uncad/src/convert.rs | 77 ++++++++++++++++++++++++ crates/uncad/src/text.rs | 11 ++++ crates/uncad/tests/extrusion.rs | 84 +++++++++++++++++++++++++++ crates/uncad/tests/polyline_fields.rs | 31 ++++++++++ 4 files changed, 203 insertions(+) create mode 100644 crates/uncad/tests/extrusion.rs diff --git a/crates/uncad/src/convert.rs b/crates/uncad/src/convert.rs index e3238f2..c734fd4 100644 --- a/crates/uncad/src/convert.rs +++ b/crates/uncad/src/convert.rs @@ -2499,6 +2499,83 @@ fn dimension_text_override(user_text: Option<&str>) -> TextOverride { mod tests { use super::*; + fn straight(x: f64) -> PolylineVertex { + PolylineVertex::straight(Point2D { x, y: 0.0 }) + } + + fn width(start: f64, end: f64) -> RawSegmentWidth { + RawSegmentWidth { start, end } + } + + fn widths_of(vertices: &[PolylineVertex]) -> Vec<(f64, f64)> { + vertices + .iter() + .map(|v| (v.start_width, v.end_width)) + .collect() + } + + /// An LWPOLYLINE's width array pairs with its points one to one, the + /// way its bulge array does; the widths go on the vertices. + #[test] + fn an_lwpolyline_s_widths_go_on_its_vertices_one_to_one() { + let text = TextDecoder::for_tests(); + let vertices = with_widths( + &text, + "LWPOLYLINE", + vec![straight(0.0), straight(1.0), straight(2.0)], + vec![width(0.0, 2.0), width(2.0, 2.0), width(1.0, 0.0)], + 0.0, + ); + assert_eq!(widths_of(&vertices), [(0.0, 2.0), (2.0, 2.0), (1.0, 0.0)]); + assert!(text.into_warnings().is_empty()); + } + + /// A file that states the constant width again on every vertex, at both + /// ends, is the same polyline as one that states it once: its vertices + /// have no width of their own. + #[test] + fn widths_that_all_repeat_the_constant_width_are_no_widths_of_the_vertices() { + let text = TextDecoder::for_tests(); + let vertices = with_widths( + &text, + "LWPOLYLINE", + vec![straight(0.0), straight(1.0)], + vec![width(0.5, 0.5), width(0.5, 0.5)], + 0.5, + ); + assert_eq!(widths_of(&vertices), [(0.0, 0.0), (0.0, 0.0)]); + // One vertex that differs keeps them all. + let vertices = with_widths( + &text, + "LWPOLYLINE", + vec![straight(0.0), straight(1.0)], + vec![width(0.5, 0.5), width(0.5, 0.0)], + 0.5, + ); + assert_eq!(widths_of(&vertices), [(0.5, 0.5), (0.5, 0.0)]); + assert!(text.into_warnings().is_empty()); + } + + /// A width array of another length than the points' does not say which + /// width is whose: none is used, and the read says so -- the rule the + /// bulge array has (`POLYLINE_BULGE`). + #[test] + fn a_width_array_that_does_not_match_the_points_is_reported_and_not_used() { + let text = TextDecoder::for_tests(); + let vertices = with_widths( + &text, + "LWPOLYLINE", + vec![straight(0.0), straight(1.0), straight(2.0)], + vec![width(1.0, 1.0), width(1.0, 1.0)], + 0.0, + ); + assert_eq!(widths_of(&vertices), [(0.0, 0.0); 3]); + assert_eq!( + text.into_warnings(), + ["POLYLINE_WIDTH: a LWPOLYLINE stores 2 widths for 3 vertices; its vertices are read with no width of their own"] + ); + } + const VOID: libredwg_sys::Dwg_Color_Method = libredwg_sys::DWG_COLOR_METHOD_DWG_COLOR_METHOD_VOID; const BYLAYER: libredwg_sys::Dwg_Color_Method = diff --git a/crates/uncad/src/text.rs b/crates/uncad/src/text.rs index 4714a78..828937a 100644 --- a/crates/uncad/src/text.rs +++ b/crates/uncad/src/text.rs @@ -271,6 +271,17 @@ impl TextDecoder { } } + /// A decoder for no drawing, for the unit tests of the conversion + /// helpers that decode nothing but report through it. + #[cfg(test)] + pub(crate) fn for_tests() -> Self { + TextDecoder { + codepage: 0, + storage: Storage::Narrow { utf8_first: false }, + warnings: RefCell::new(Vec::new()), + } + } + /// The width of a `dxfname.field` string the library hands out /// unconverted. For an R2007+ DWG that happens only when its accessor /// could not find the object the pointer belongs to (a struct embedded diff --git a/crates/uncad/tests/extrusion.rs b/crates/uncad/tests/extrusion.rs new file mode 100644 index 0000000..5b5429b --- /dev/null +++ b/crates/uncad/tests/extrusion.rs @@ -0,0 +1,84 @@ +//! The normal (DXF 210, `extrusion`) of an entity whose record stores none. +//! +//! LibreDWG leaves the field zero when the record does not store it -- an +//! LWPOLYLINE unless its flag says so, a pre-R13 entity unless its options +//! do. A zero vector is no direction: it is the absent group, whose default +//! is the world Z axis. The DXF twins state no normal either, and the +//! library's DXF importer fills in (0, 0, 1) itself, so the two formats of +//! each drawing have to agree. + +use std::collections::BTreeMap; + +use uncad::model::Point3D; +use uncad::Entity; + +const CORPUS: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/" +); + +const Z_AXIS: Point3D = Point3D { + x: 0.0, + y: 0.0, + z: 1.0, +}; + +/// Every normal the drawing's blocks carry, by entity type. +fn normals(path: &str) -> BTreeMap> { + let db = uncad::parse(path).unwrap_or_else(|e| panic!("{path}: {e}")); + let mut out: BTreeMap> = BTreeMap::new(); + for record in db.tables.block_records.values() { + for e in &record.entities { + let normal = match e { + Entity::Circle(c) => c.extrusion, + Entity::Arc(a) => a.extrusion, + Entity::LwPolyline(p) | Entity::Polyline2D(p) => p.extrusion, + Entity::Solid(s) | Entity::Trace(s) => s.extrusion, + Entity::Text(t) => t.extrusion, + Entity::Attrib(a) => a.extrusion, + Entity::Attdef(a) => a.extrusion, + Entity::Insert(i) => i.extrusion, + _ => continue, + }; + out.entry(e.type_name().to_string()) + .or_default() + .push(normal); + } + } + out +} + +#[test] +fn a_normal_the_record_does_not_store_is_the_world_z_axis() { + // (drawing, the kinds it must have been met with) + let drawings: [(&str, &[&str]); 3] = [ + // Before R13 every one of these stores its normal only as an option. + ( + "r11/entities-2d", + &["ARC", "CIRCLE", "POLYLINE_2D", "SOLID", "TRACE"], + ), + ( + "r10/entities", + &["ARC", "CIRCLE", "POLYLINE_2D", "SOLID", "TRACE"], + ), + // From R13 an LWPOLYLINE stores one only when its flag bit 1 says so; + // none of this drawing's eleven does. + ("example_2000", &["LWPOLYLINE"]), + ]; + for (twin, kinds) in drawings { + for extension in ["dwg", "dxf"] { + let path = format!("{CORPUS}{twin}.{extension}"); + let found = normals(&path); + for kind in kinds { + let met = found.get(*kind).map_or(0, Vec::len); + assert!(met > 0, "{path}: no {kind}"); + } + for (kind, normals) in &found { + assert!( + normals.iter().all(|n| *n == Z_AXIS), + "{path}: {kind} {normals:?}" + ); + } + } + } +} diff --git a/crates/uncad/tests/polyline_fields.rs b/crates/uncad/tests/polyline_fields.rs index a2f91c7..75592f3 100644 --- a/crates/uncad/tests/polyline_fields.rs +++ b/crates/uncad/tests/polyline_fields.rs @@ -138,3 +138,34 @@ fn a_dxf_polyline_s_default_width_is_the_width_of_the_vertices_that_state_none() assert_eq!(tick.const_width, 0.0, "{extension}"); } } + +/// The R2000 3D polyline of the corpus, in both of its formats: six +/// vertices, the last one included. LibreDWG's point accessors stop one +/// record short of `last_vertex` from R13 to R2000 and gave the DWG five. +#[test] +fn an_r2000_3d_polyline_keeps_its_last_vertex_in_both_formats() { + let vertices = |extension: &str| -> Vec<(f64, f64, f64)> { + let db = parse("2000/PolyLine3D", extension); + let polylines: Vec> = db + .entities + .iter() + .filter_map(|e| match e { + Entity::Polyline3D(p) => Some(p.vertices.iter().map(|v| (v.x, v.y, v.z)).collect()), + _ => None, + }) + .collect(); + assert_eq!(polylines.len(), 1, "{extension}"); + polylines.into_iter().next().unwrap() + }; + let dwg = vertices("dwg"); + assert_eq!(dwg.len(), 6); + let dxf = vertices("dxf"); + assert_eq!(dxf.len(), 6); + for (a, b) in dwg.iter().zip(&dxf) { + let near = |p: f64, q: f64| (p - q).abs() <= 1e-9 * p.abs().max(1.0); + assert!( + near(a.0, b.0) && near(a.1, b.1) && near(a.2, b.2), + "{a:?} vs {b:?}" + ); + } +} From 0c93c991cf9827dae01380c271b3ddef64131c01 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 17:40:09 +0900 Subject: [PATCH 36/54] Changelog: say the local-patches entry once The entry carried two versions of itself, one after the other, from an earlier merge: the second began mid-sentence and each said the other's patch was "not visible yet". Both are visible now -- the name-lookup patch since R2007+ DXF is read, the colour-order one since true colours are read as stated -- and the entry says so once. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4490eb..2275351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -178,23 +178,13 @@ Notable changes to this project are recorded here. The format follows process from inside the C library (0xC0000409 on Windows, from `strftime`). The importer compares an R2007+ DXF's table-record names decoded, so its layer and block lookups no longer stop at the first character: without that patch `example_2018.dxf` - reads with 65 of its 72 entities on no layer (`tests/r2007_dxf_handles.rs`). One more is - not visible in this crate's output yet: an R2004+ entity carrying both a true colour and - a transparency no longer has the two swapped in the library's fields (this crate reads a - true colour only under the TRUECOLOR method, which the entities measured do not have). - Before R2007+ DXF was read, the 208 corpus drawings gave byte-identical JSON with and - without the patches. `build.rs` refuses to build when a patch's marker has gone missing, - which a re-vendor through `scripts/sync-libredwg-vendor.sh` would otherwise do in - silence. - process from inside the C library (0xC0000409 on Windows, from `strftime`). An R2004+ + reads with 65 of its 72 entities on no layer (`tests/r2007_dxf_handles.rs`). An R2004+ entity carrying both a true colour and a transparency no longer has the two swapped in - the library's fields (`2004/HatchG.dwg`'s HATCH 29F: `0x1ae464`, not `0x0000e5`). One - more is not visible in this crate's output yet: the importer compares an R2007+ DXF's - table-record names decoded, so its layer and block lookups no longer stop at the first - character (such a DXF is still refused). When the patches landed, the JSON output of - the 208 corpus drawings was byte-identical with and without them; the colour-order one - shows since true colours are read as the file states them (below). `build.rs` refuses - to build when a patch's marker has gone missing, which a re-vendor through + the library's fields (`2004/HatchG.dwg`'s HATCH 29F: `0x1ae464`, not `0x0000e5`). When + the patches landed, the JSON output of the 208 corpus drawings was byte-identical with + and without them; the colour-order one shows since true colours are read as the file + states them (below), and the name-lookup one since R2007+ DXF is read. `build.rs` + refuses to build when a patch's marker has gone missing, which a re-vendor through `scripts/sync-libredwg-vendor.sh` would otherwise do in silence. - A 2D or 3D POLYLINE from an R13 to R2000 drawing no longer loses its last vertex. The library's point accessors stop one record early in that range; the vertex records are From a91ba4692d0d9a967e999942568b5b334aa8ca28 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 18:59:02 +0900 Subject: [PATCH 37/54] Frame the CLI's picture with the renderer's Crop option iron-render-cad's render-once/assemble-many proposal (port/export-api 6e33818) replaces ToSvgOptions::outlier_trim with a Crop choice. The CLI keeps its behaviour: the default stays the cluster trim (Crop::Cluster), and --no-trim keeps everything (Crop::Everything). This is the only caller of the option in the workspace. Co-Authored-By: Claude Opus 5.5 --- crates/uncad-cli/src/main.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/uncad-cli/src/main.rs b/crates/uncad-cli/src/main.rs index 8f9e051..93a7346 100644 --- a/crates/uncad-cli/src/main.rs +++ b/crates/uncad-cli/src/main.rs @@ -4,7 +4,7 @@ //! the parsed model as JSON or a rendering of it as SVG/PNG. There is no //! DWG/DXF output. -use iron_render_cad::{to_png, to_svg, PngSize, Space, ToPngOptions, ToSvgOptions}; +use iron_render_cad::{to_png, to_svg, Crop, PngSize, Space, ToPngOptions, ToSvgOptions}; use std::collections::BTreeMap; use std::path::Path; use std::process::ExitCode; @@ -212,7 +212,11 @@ fn write_output(path: &str, bytes: &[u8]) -> Result<(), String> { fn svg_options(args: &Args) -> Result { Ok(ToSvgOptions { space: parse_space(&args.space)?, - outlier_trim: args.outlier_trim, + crop: if args.outlier_trim { + Crop::Cluster + } else { + Crop::Everything + }, ..Default::default() }) } From daf30f96bd11dec3588815cc02cee8f495ed2384 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 19:14:28 +0900 Subject: [PATCH 38/54] Add uncad-export, the crate the LLM/VLM package lives in, with its font The package the feature branch wrote from inside `uncad` has no place in the split: `uncad` parses and draws nothing, and the renderer knows nothing of who reads its pictures and bundles no font. It becomes a crate of its own in this workspace, `uncad-export` 0.1.0, depending on `uncad` (path and version, like `uncad-cli`) and on `uncad-model` and `iron-render-cad` by version only. This first step brings the font the package draws and measures text with: `Uncad Sans`, the feature branch's Noto Sans KR subset (Latin, Greek, the 2350 KS X 1001 Hangul syllables, the CAD symbols), byte for byte, with its OFL text, its reproduction recipe and the two scripts that produce it. `fonts::bundled()` hands it to the renderer as `Fonts::Custom`, built once per process, and `fonts::UNCAD_SANS_CAP_HEIGHT` (0.733, OS/2 sCapHeight over unitsPerEm, checked against the embedded bytes) is the value the renderer needs as `ToSvgOptions::cap_height` for a CAD height to be the capitals' height. The README says what the renderer cannot do that the feature branch did: there is no "bundled face first, then the host's" choice, and the renderer's `%%c` (U+2300) is outside the subset. Licences: the crate is `GPL-3.0-or-later AND OFL-1.1`; `deny.toml` grants both to this crate by name (the fourth copyleft grant, which the file says is a decision), `docs/THIRD_PARTY_NOTICES.md` names the font, and `uncad-cli`'s release invariants now cover four crates' `LICENSE` and the font's licence beside the font and in the manifest. Ported-from: 2572d99, ea24fdc, 815ffda, 26bdd8d Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +- Cargo.toml | 4 +- crates/uncad-cli/tests/release_invariants.rs | 36 +- crates/uncad-export/Cargo.toml | 29 + crates/uncad-export/LICENSE | 674 +++++ crates/uncad-export/README.md | 18 + crates/uncad-export/fonts/OFL-NotoSansKR.txt | 93 + crates/uncad-export/fonts/README.md | 75 + .../uncad-export/fonts/UncadSans-Regular.otf | Bin 0 -> 370536 bytes .../uncad-export/fonts/tools/gen_unicodes.py | 22 + crates/uncad-export/fonts/tools/rename.py | 20 + crates/uncad-export/fonts/tools/unicodes.txt | 2365 +++++++++++++++++ crates/uncad-export/src/fonts.rs | 124 + crates/uncad-export/src/lib.rs | 17 + deny.toml | 10 +- docs/THIRD_PARTY_NOTICES.md | 13 +- 16 files changed, 3502 insertions(+), 8 deletions(-) create mode 100644 crates/uncad-export/Cargo.toml create mode 100644 crates/uncad-export/LICENSE create mode 100644 crates/uncad-export/README.md create mode 100644 crates/uncad-export/fonts/OFL-NotoSansKR.txt create mode 100644 crates/uncad-export/fonts/README.md create mode 100644 crates/uncad-export/fonts/UncadSans-Regular.otf create mode 100644 crates/uncad-export/fonts/tools/gen_unicodes.py create mode 100644 crates/uncad-export/fonts/tools/rename.py create mode 100644 crates/uncad-export/fonts/tools/unicodes.txt create mode 100644 crates/uncad-export/src/fonts.rs create mode 100644 crates/uncad-export/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2275351..2e881a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,9 +133,17 @@ Notable changes to this project are recorded here. The format follows here was not read, not empty; the edges used to be skipped in silence. - TRACE is read and drawn: `Entity::Trace`, which reuses `SolidEntity` the way `XLine` reuses `RayEntity`. It used to arrive as `Entity::Unknown`. -- All three crates declare `rust-version = "1.88"`. Until now the minimum was whatever +- Every crate declares `rust-version = "1.88"`. Until now the minimum was whatever happened to build. The value is measured (1.87 fails, 1.88 passes) and CI has an `msrv` job that checks the workspace with exactly the declared toolchain. +- `uncad-export` 0.1.0, a new crate in this workspace: the LLM/VLM package, which does not + belong in `uncad` (a parser; it draws nothing) or in the renderer (which knows nothing of + who reads its pictures). It bundles the font its images are drawn and measured with, + `Uncad Sans`, a Noto Sans KR subset under the SIL Open Font License 1.1, handed to the + renderer as `fonts::bundled()` with the face's capital height (`fonts::UNCAD_SANS_CAP_HEIGHT`, + 0.733). Its licence expression is `GPL-3.0-or-later AND OFL-1.1`; `deny.toml` grants the + crate both by name, and `uncad-cli`'s `tests/release_invariants.rs` checks its `LICENSE`, + the font's licence text beside the font and the expression. ### Changed diff --git a/Cargo.toml b/Cargo.toml index 2674470..9597abb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] resolver = "2" -members = ["crates/libredwg-sys", "crates/uncad", "crates/uncad-cli"] +members = ["crates/libredwg-sys", "crates/uncad", "crates/uncad-export", "crates/uncad-cli"] [workspace.package] edition = "2021" -# Minimum supported Rust version, for all three crates. Measured, not guessed: +# Minimum supported Rust version, for all four crates. Measured, not guessed: # 1.87 fails (`slice::as_chunks`, stable since 1.88, in crates/uncad) and 1.88 # passes. CI's `msrv` job reads this value and checks the workspace with # exactly that toolchain -- raise the two together, never this line alone. diff --git a/crates/uncad-cli/tests/release_invariants.rs b/crates/uncad-cli/tests/release_invariants.rs index 0a23205..1c42563 100644 --- a/crates/uncad-cli/tests/release_invariants.rs +++ b/crates/uncad-cli/tests/release_invariants.rs @@ -14,8 +14,8 @@ use std::fs; use std::path::{Path, PathBuf}; -/// The three published crates, in workspace order. -const MEMBERS: [&str; 3] = ["libredwg-sys", "uncad", "uncad-cli"]; +/// The four published crates, in workspace order. +const MEMBERS: [&str; 4] = ["libredwg-sys", "uncad", "uncad-export", "uncad-cli"]; fn repo_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -71,6 +71,38 @@ fn the_vendored_libredwg_ships_its_own_licence() { ); } +/// The font `uncad-export` bundles is not GPL but OFL-1.1, and cargo-deny +/// never sees a font: the licence has to travel beside the font inside the +/// crate (the OFL asks for it with every copy), and the crate's licence +/// expression has to say so, or crates.io would describe the tarball as GPL +/// alone. +#[test] +fn the_bundled_font_ships_its_own_licence_and_the_manifest_names_it() { + let root = repo_root(); + let crate_dir = root.join("crates/uncad-export"); + assert!( + crate_dir.join("fonts/UncadSans-Regular.otf").exists(), + "the bundled font" + ); + let ofl = read(crate_dir.join("fonts/OFL-NotoSansKR.txt")); + assert!( + ofl.contains("SIL OPEN FONT LICENSE Version 1.1"), + "the bundled font's OFL text should travel with the font" + ); + let manifest = read(crate_dir.join("Cargo.toml")); + assert!( + manifest + .lines() + .any(|line| line.trim() == r#"license = "GPL-3.0-or-later AND OFL-1.1""#), + "crates/uncad-export/Cargo.toml should declare both licences" + ); + let notices = read(root.join("docs/THIRD_PARTY_NOTICES.md")); + assert!( + notices.contains("crates/uncad-export/fonts/OFL-NotoSansKR.txt"), + "docs/THIRD_PARTY_NOTICES.md should name the font's licence" + ); +} + /// Every vendored LibreDWG file carrying an `uncad local patch` marker, as /// its `/`-separated path under `vendor/libredwg`, found by walking the tree /// rather than trusting a list. diff --git a/crates/uncad-export/Cargo.toml b/crates/uncad-export/Cargo.toml new file mode 100644 index 0000000..b46bed9 --- /dev/null +++ b/crates/uncad-export/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "uncad-export" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +# The crate is GPL like the rest of this workspace (it links `uncad`, and so +# LibreDWG); the font it bundles is data under the SIL Open Font License. +# cargo-deny reads this expression as one license decision per crate -- +# see deny.toml -- and never looks at the font file itself, so the OFL is +# stated here and in docs/THIRD_PARTY_NOTICES.md. +license = "GPL-3.0-or-later AND OFL-1.1" +repository.workspace = true +homepage.workspace = true +readme = "README.md" +description = "An LLM/VLM-readable package of a DWG/DXF drawing: sized images, a tile pyramid, sheets per paper layout and exact JSON records, built on uncad and iron-render-cad." +keywords = ["cad", "dwg", "dxf", "llm", "vision"] +categories = ["graphics", "multimedia::images"] + +[lints] +workspace = true + +[dependencies] +# The parser, and the drawing's header (units, extents, the dimension +# variables), which the model does not carry. +uncad = { path = "../uncad", version = "0.2.0" } +# The model and the renderer. Version dependencies, never paths: this crate +# must build from a standalone checkout. +uncad-model = "0.1.0" +iron-render-cad = "0.1.0" diff --git a/crates/uncad-export/LICENSE b/crates/uncad-export/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/crates/uncad-export/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/crates/uncad-export/README.md b/crates/uncad-export/README.md new file mode 100644 index 0000000..5e9c783 --- /dev/null +++ b/crates/uncad-export/README.md @@ -0,0 +1,18 @@ +# uncad-export + +A DWG/DXF drawing as a package an LLM or a vision model can read: an +overview image sized to the model's patch budget, a pyramid of overlapping +tiles with JSON sidecars saying what is on each, one image per paper layout, +and JSON records with the exact numbers a picture cannot give -- lengths, +areas, dimension values, texts -- each pointing at the pixels it is drawn in. + +It is built on [`uncad`](https://crates.io/crates/uncad), which parses the +drawing, and [`iron-render-cad`](https://crates.io/crates/iron-render-cad), +which draws it. See the crate documentation for the package's layout. + +## Licence + +GPL-3.0-or-later (see `LICENSE`), like the rest of this repository. The +bundled font `fonts/UncadSans-Regular.otf` is a Noto Sans KR subset under the +SIL Open Font License 1.1 (see `fonts/OFL-NotoSansKR.txt` and +`fonts/README.md`). diff --git a/crates/uncad-export/fonts/OFL-NotoSansKR.txt b/crates/uncad-export/fonts/OFL-NotoSansKR.txt new file mode 100644 index 0000000..1c9f432 --- /dev/null +++ b/crates/uncad-export/fonts/OFL-NotoSansKR.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/uncad-export/fonts/README.md b/crates/uncad-export/fonts/README.md new file mode 100644 index 0000000..9c553b0 --- /dev/null +++ b/crates/uncad-export/fonts/README.md @@ -0,0 +1,75 @@ +# Bundled font: Uncad Sans + +`UncadSans-Regular.otf` (370 KB) is a subset of **Noto Sans KR Regular +v2.004** (Adobe / Google, SIL Open Font License 1.1 -- `OFL-NotoSansKR.txt` +is the licence with its copyright notice). It is what the package's images +and its text measurements are drawn with (`uncad_export::fonts::bundled()`, +the default `ExportOptions::fonts`), so a drawing renders the same on every +machine and a Hangul label never depends on the host's fonts. The renderer +this crate draws with, `iron-render-cad`, bundles no font of its own: the +bytes are handed to it as `Fonts::Custom`, with the face's capital height +(`uncad_export::fonts::UNCAD_SANS_CAP_HEIGHT`, OS/2 `sCapHeight` 733 over +`unitsPerEm` 1000) as `ToSvgOptions::cap_height`. + +Coverage (2755 glyphs): Basic Latin, Latin-1 Supplement, the part of Latin +Extended-A Noto Sans KR has, Greek, General Punctuation, Hangul Compatibility +Jamo, the 2350 KS X 1001 Hangul syllables, and the CAD symbols `∅ ° ± ² ³ × +Ø ㎡ ㎜ ㎥ ㎝ ㎞ ← ↑ → ↓ φ Δ Ω`. Hanja, the other 8822 syllables and `⌀` +(U+2300, absent from Noto Sans KR itself) are not included: such a character +is drawn as a crossed `.notdef` box (the subset keeps the notdef outline, see +below) and counted in the package's `unshaped_glyphs` / `UnshapedGlyphs` +warning. The renderer draws AutoCAD's `%%c` as U+2300, so a diameter written +that way is one such box in the images; the package's own text records read +it as `∅` (U+2205), which the subset has. + +There is no "this font first, then the host's" choice: `iron-render-cad` +draws with the fonts it is given or with the host's, not both. A caller that +wants the host's fonts passes `Fonts::System` and gives up the same-pixels +guarantee. + +## Why this font + +Only Noto Sans KR and Pretendard cover every block above (NanumGothic, IBM +Plex Sans KR and Spoqa Han Sans lack `Ø`, `∅`, `㎡` or Greek; Gowun Dodum +subsets to 1.2 MB). Noto is the canonical, most widely mirrored face and +its OFL Reserved Font Name is `Source`, which the subset's name does not +use. The subset is renamed to `Uncad Sans` anyway: the OFL asks a Modified +Version not to pass as the original, and a unique family name keeps a +font database from picking a host-installed Noto Sans KR over the bundled +face. + +## Reproducing the file + +Source: https://github.com/notofonts/noto-cjk/raw/main/Sans/SubsetOTF/KR/NotoSansKR-Regular.otf +(4,644,748 bytes). Tools: Python 3 with `fonttools` and `brotli` +(`uv venv venv && uv pip install --python venv/Scripts/python.exe fonttools brotli`). + +```text +python tools/gen_unicodes.py # rewrites tools/unicodes.txt (2365 lines) +set PYTHONUTF8=1 +pyftsubset NotoSansKR-Regular.otf --unicodes-file=tools/unicodes.txt \ + --output-file=NotoSansKR-Regular-sub.otf --name-IDs='*' --notdef-outline --layout-features='' +python tools/rename.py NotoSansKR-Regular-sub.otf UncadSans-Regular.otf "Uncad Sans" UncadSans-Regular +``` + +`gen_unicodes.py` writes next to itself, whatever directory it is run +from, so it rewrites the `tools/unicodes.txt` checked in here -- the file +`pyftsubset` reads -- and the rewrite is byte for byte the same file. + +`--layout-features=''` drops kerning and the other GSUB/GPOS tables (16 KB +of kerning would keep glyph spacing identical to full Noto; the text +measurement lays out whatever is bundled, so records and pixels agree either +way). `--name-IDs='*'` keeps the copyright and licence name records; the +rename script changes the family, full and PostScript names and appends +`uncad subset` to the version string, as the OFL asks of a Modified Version. + +## Licence + +The font is OFL 1.1 (not GPL like the rest of this repository), which is +why this crate's licence expression is `GPL-3.0-or-later AND OFL-1.1`. +Bundling it in GPL software is permitted by the OFL; it may not be sold by +itself, and the licence text must travel with it -- `cargo package` +includes this directory, and every binary linking `uncad-export` embeds the +font bytes. cargo-deny does not see a font, so the repository's +`docs/THIRD_PARTY_NOTICES.md` names it, and `uncad-cli`'s +`tests/release_invariants.rs` checks that the licence text is beside it. diff --git a/crates/uncad-export/fonts/UncadSans-Regular.otf b/crates/uncad-export/fonts/UncadSans-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..17022a294b6c28449a29304b7bd91cc8dead6271 GIT binary patch literal 370536 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

x>WCB(>B9o7lqs!?HzqHHxY68c>bfc{f0f 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

AXvLtPa*7P9e%v`xB2ftLTpMIN@^W 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%0Aoza-{P*bD|IySPQy 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 a3=Da)9W6#+n)G)kaEME!o%la=EB^", dst, "| family:", n.getDebugName(1), "| full:", n.getDebugName(4), "| ps:", n.getDebugName(6), "| id3:", n.getDebugName(3), "| ver:", n.getDebugName(5)) +if 'CFF ' in g: print("CFF fontName:", g['CFF '].cff.fontNames[0], "FamilyName:", g['CFF '].cff.topDictIndex[0].FamilyName) diff --git a/crates/uncad-export/fonts/tools/unicodes.txt b/crates/uncad-export/fonts/tools/unicodes.txt new file mode 100644 index 0000000..6fd2dcc --- /dev/null +++ b/crates/uncad-export/fonts/tools/unicodes.txt @@ -0,0 +1,2365 @@ +# Basic Latin +U+0020-007E +# Latin-1 Supplement (degree B0, plus-minus B1, sup2 B2, sup3 B3, times D7, Oslash D8, oslash F8) +U+00A0-00FF +# Latin Extended-A +U+0100-017F +# Greek and Coptic (phi, delta, omega, ...) +U+0370-03FF +# General Punctuation (primes, dashes, bullets) +U+2000-206F +# Hangul Compatibility Jamo +U+3130-318F +# CAD symbols: empty set (diameter), diameter sign, arrows, ㎡ ㎜ ㎥ ㎝ ㎞ +U+2205 U+2300 U+2190-2193 U+33A1 U+339C U+33A5 U+339D U+339E +# KS X 1001 Hangul syllables (2350) +U+AC00 +U+AC01 +U+AC04 +U+AC07 +U+AC08 +U+AC09 +U+AC0A +U+AC10 +U+AC11 +U+AC12 +U+AC13 +U+AC14 +U+AC15 +U+AC16 +U+AC17 +U+AC19 +U+AC1A +U+AC1B +U+AC1C +U+AC1D +U+AC20 +U+AC24 +U+AC2C +U+AC2D +U+AC2F +U+AC30 +U+AC31 +U+AC38 +U+AC39 +U+AC3C +U+AC40 +U+AC4B +U+AC4D +U+AC54 +U+AC58 +U+AC5C +U+AC70 +U+AC71 +U+AC74 +U+AC77 +U+AC78 +U+AC7A +U+AC80 +U+AC81 +U+AC83 +U+AC84 +U+AC85 +U+AC86 +U+AC89 +U+AC8A +U+AC8B +U+AC8C +U+AC90 +U+AC94 +U+AC9C +U+AC9D +U+AC9F +U+ACA0 +U+ACA1 +U+ACA8 +U+ACA9 +U+ACAA +U+ACAC +U+ACAF +U+ACB0 +U+ACB8 +U+ACB9 +U+ACBB +U+ACBC +U+ACBD +U+ACC1 +U+ACC4 +U+ACC8 +U+ACCC +U+ACD5 +U+ACD7 +U+ACE0 +U+ACE1 +U+ACE4 +U+ACE7 +U+ACE8 +U+ACEA +U+ACEC +U+ACEF +U+ACF0 +U+ACF1 +U+ACF3 +U+ACF5 +U+ACF6 +U+ACFC +U+ACFD +U+AD00 +U+AD04 +U+AD06 +U+AD0C +U+AD0D +U+AD0F +U+AD11 +U+AD18 +U+AD1C +U+AD20 +U+AD29 +U+AD2C +U+AD2D +U+AD34 +U+AD35 +U+AD38 +U+AD3C +U+AD44 +U+AD45 +U+AD47 +U+AD49 +U+AD50 +U+AD54 +U+AD58 +U+AD61 +U+AD63 +U+AD6C +U+AD6D +U+AD70 +U+AD73 +U+AD74 +U+AD75 +U+AD76 +U+AD7B +U+AD7C +U+AD7D +U+AD7F +U+AD81 +U+AD82 +U+AD88 +U+AD89 +U+AD8C +U+AD90 +U+AD9C +U+AD9D +U+ADA4 +U+ADB7 +U+ADC0 +U+ADC1 +U+ADC4 +U+ADC8 +U+ADD0 +U+ADD1 +U+ADD3 +U+ADDC +U+ADE0 +U+ADE4 +U+ADF8 +U+ADF9 +U+ADFC +U+ADFF +U+AE00 +U+AE01 +U+AE08 +U+AE09 +U+AE0B +U+AE0D +U+AE14 +U+AE30 +U+AE31 +U+AE34 +U+AE37 +U+AE38 +U+AE3A +U+AE40 +U+AE41 +U+AE43 +U+AE45 +U+AE46 +U+AE4A +U+AE4C +U+AE4D +U+AE4E +U+AE50 +U+AE54 +U+AE56 +U+AE5C +U+AE5D +U+AE5F +U+AE60 +U+AE61 +U+AE65 +U+AE68 +U+AE69 +U+AE6C +U+AE70 +U+AE78 +U+AE79 +U+AE7B +U+AE7C +U+AE7D +U+AE84 +U+AE85 +U+AE8C +U+AEBC +U+AEBD +U+AEBE +U+AEC0 +U+AEC4 +U+AECC +U+AECD +U+AECF +U+AED0 +U+AED1 +U+AED8 +U+AED9 +U+AEDC +U+AEE8 +U+AEEB +U+AEED +U+AEF4 +U+AEF8 +U+AEFC +U+AF07 +U+AF08 +U+AF0D +U+AF10 +U+AF2C +U+AF2D +U+AF30 +U+AF32 +U+AF34 +U+AF3C +U+AF3D +U+AF3F +U+AF41 +U+AF42 +U+AF43 +U+AF48 +U+AF49 +U+AF50 +U+AF5C +U+AF5D +U+AF64 +U+AF65 +U+AF79 +U+AF80 +U+AF84 +U+AF88 +U+AF90 +U+AF91 +U+AF95 +U+AF9C +U+AFB8 +U+AFB9 +U+AFBC +U+AFC0 +U+AFC7 +U+AFC8 +U+AFC9 +U+AFCB +U+AFCD +U+AFCE +U+AFD4 +U+AFDC +U+AFE8 +U+AFE9 +U+AFF0 +U+AFF1 +U+AFF4 +U+AFF8 +U+B000 +U+B001 +U+B004 +U+B00C +U+B010 +U+B014 +U+B01C +U+B01D +U+B028 +U+B044 +U+B045 +U+B048 +U+B04A +U+B04C +U+B04E +U+B053 +U+B054 +U+B055 +U+B057 +U+B059 +U+B05D +U+B07C +U+B07D +U+B080 +U+B084 +U+B08C +U+B08D +U+B08F +U+B091 +U+B098 +U+B099 +U+B09A +U+B09C +U+B09F +U+B0A0 +U+B0A1 +U+B0A2 +U+B0A8 +U+B0A9 +U+B0AB +U+B0AC +U+B0AD +U+B0AE +U+B0AF +U+B0B1 +U+B0B3 +U+B0B4 +U+B0B5 +U+B0B8 +U+B0BC +U+B0C4 +U+B0C5 +U+B0C7 +U+B0C8 +U+B0C9 +U+B0D0 +U+B0D1 +U+B0D4 +U+B0D8 +U+B0E0 +U+B0E5 +U+B108 +U+B109 +U+B10B +U+B10C +U+B110 +U+B112 +U+B113 +U+B118 +U+B119 +U+B11B +U+B11C +U+B11D +U+B123 +U+B124 +U+B125 +U+B128 +U+B12C +U+B134 +U+B135 +U+B137 +U+B138 +U+B139 +U+B140 +U+B141 +U+B144 +U+B148 +U+B150 +U+B151 +U+B154 +U+B155 +U+B158 +U+B15C +U+B160 +U+B178 +U+B179 +U+B17C +U+B180 +U+B182 +U+B188 +U+B189 +U+B18B +U+B18D +U+B192 +U+B193 +U+B194 +U+B198 +U+B19C +U+B1A8 +U+B1CC +U+B1D0 +U+B1D4 +U+B1DC +U+B1DD +U+B1DF +U+B1E8 +U+B1E9 +U+B1EC +U+B1F0 +U+B1F9 +U+B1FB +U+B1FD +U+B204 +U+B205 +U+B208 +U+B20B +U+B20C +U+B214 +U+B215 +U+B217 +U+B219 +U+B220 +U+B234 +U+B23C +U+B258 +U+B25C +U+B260 +U+B268 +U+B269 +U+B274 +U+B275 +U+B27C +U+B284 +U+B285 +U+B289 +U+B290 +U+B291 +U+B294 +U+B298 +U+B299 +U+B29A +U+B2A0 +U+B2A1 +U+B2A3 +U+B2A5 +U+B2A6 +U+B2AA +U+B2AC +U+B2B0 +U+B2B4 +U+B2C8 +U+B2C9 +U+B2CC +U+B2D0 +U+B2D2 +U+B2D8 +U+B2D9 +U+B2DB +U+B2DD +U+B2E2 +U+B2E4 +U+B2E5 +U+B2E6 +U+B2E8 +U+B2EB +U+B2EC +U+B2ED +U+B2EE +U+B2EF +U+B2F3 +U+B2F4 +U+B2F5 +U+B2F7 +U+B2F8 +U+B2F9 +U+B2FA +U+B2FB +U+B2FF +U+B300 +U+B301 +U+B304 +U+B308 +U+B310 +U+B311 +U+B313 +U+B314 +U+B315 +U+B31C +U+B354 +U+B355 +U+B356 +U+B358 +U+B35B +U+B35C +U+B35E +U+B35F +U+B364 +U+B365 +U+B367 +U+B369 +U+B36B +U+B36E +U+B370 +U+B371 +U+B374 +U+B378 +U+B380 +U+B381 +U+B383 +U+B384 +U+B385 +U+B38C +U+B390 +U+B394 +U+B3A0 +U+B3A1 +U+B3A8 +U+B3AC +U+B3C4 +U+B3C5 +U+B3C8 +U+B3CB +U+B3CC +U+B3CE +U+B3D0 +U+B3D4 +U+B3D5 +U+B3D7 +U+B3D9 +U+B3DB +U+B3DD +U+B3E0 +U+B3E4 +U+B3E8 +U+B3FC +U+B410 +U+B418 +U+B41C +U+B420 +U+B428 +U+B429 +U+B42B +U+B434 +U+B450 +U+B451 +U+B454 +U+B458 +U+B460 +U+B461 +U+B463 +U+B465 +U+B46C +U+B480 +U+B488 +U+B49D +U+B4A4 +U+B4A8 +U+B4AC +U+B4B5 +U+B4B7 +U+B4B9 +U+B4C0 +U+B4C4 +U+B4C8 +U+B4D0 +U+B4D5 +U+B4DC +U+B4DD +U+B4E0 +U+B4E3 +U+B4E4 +U+B4E6 +U+B4EC +U+B4ED +U+B4EF +U+B4F1 +U+B4F8 +U+B514 +U+B515 +U+B518 +U+B51B +U+B51C +U+B524 +U+B525 +U+B527 +U+B528 +U+B529 +U+B52A +U+B530 +U+B531 +U+B534 +U+B538 +U+B540 +U+B541 +U+B543 +U+B544 +U+B545 +U+B54B +U+B54C +U+B54D +U+B550 +U+B554 +U+B55C +U+B55D +U+B55F +U+B560 +U+B561 +U+B5A0 +U+B5A1 +U+B5A4 +U+B5A8 +U+B5AA +U+B5AB +U+B5B0 +U+B5B1 +U+B5B3 +U+B5B4 +U+B5B5 +U+B5BB +U+B5BC +U+B5BD +U+B5C0 +U+B5C4 +U+B5CC +U+B5CD +U+B5CF +U+B5D0 +U+B5D1 +U+B5D8 +U+B5EC +U+B610 +U+B611 +U+B614 +U+B618 +U+B625 +U+B62C +U+B634 +U+B648 +U+B664 +U+B668 +U+B69C +U+B69D +U+B6A0 +U+B6A4 +U+B6AB +U+B6AC +U+B6B1 +U+B6D4 +U+B6F0 +U+B6F4 +U+B6F8 +U+B700 +U+B701 +U+B705 +U+B728 +U+B729 +U+B72C +U+B72F +U+B730 +U+B738 +U+B739 +U+B73B +U+B744 +U+B748 +U+B74C +U+B754 +U+B755 +U+B760 +U+B764 +U+B768 +U+B770 +U+B771 +U+B773 +U+B775 +U+B77C +U+B77D +U+B780 +U+B784 +U+B78C +U+B78D +U+B78F +U+B790 +U+B791 +U+B792 +U+B796 +U+B797 +U+B798 +U+B799 +U+B79C +U+B7A0 +U+B7A8 +U+B7A9 +U+B7AB +U+B7AC +U+B7AD +U+B7B4 +U+B7B5 +U+B7B8 +U+B7C7 +U+B7C9 +U+B7EC +U+B7ED +U+B7F0 +U+B7F4 +U+B7FC +U+B7FD +U+B7FF +U+B800 +U+B801 +U+B807 +U+B808 +U+B809 +U+B80C +U+B810 +U+B818 +U+B819 +U+B81B +U+B81D +U+B824 +U+B825 +U+B828 +U+B82C +U+B834 +U+B835 +U+B837 +U+B838 +U+B839 +U+B840 +U+B844 +U+B851 +U+B853 +U+B85C +U+B85D +U+B860 +U+B864 +U+B86C +U+B86D +U+B86F +U+B871 +U+B878 +U+B87C +U+B88D +U+B8A8 +U+B8B0 +U+B8B4 +U+B8B8 +U+B8C0 +U+B8C1 +U+B8C3 +U+B8C5 +U+B8CC +U+B8D0 +U+B8D4 +U+B8DD +U+B8DF +U+B8E1 +U+B8E8 +U+B8E9 +U+B8EC +U+B8F0 +U+B8F8 +U+B8F9 +U+B8FB +U+B8FD +U+B904 +U+B918 +U+B920 +U+B93C +U+B93D +U+B940 +U+B944 +U+B94C +U+B94F +U+B951 +U+B958 +U+B959 +U+B95C +U+B960 +U+B968 +U+B969 +U+B96B +U+B96D +U+B974 +U+B975 +U+B978 +U+B97C +U+B984 +U+B985 +U+B987 +U+B989 +U+B98A +U+B98D +U+B98E +U+B9AC +U+B9AD +U+B9B0 +U+B9B4 +U+B9BC +U+B9BD +U+B9BF +U+B9C1 +U+B9C8 +U+B9C9 +U+B9CC +U+B9CE +U+B9CF +U+B9D0 +U+B9D1 +U+B9D2 +U+B9D8 +U+B9D9 +U+B9DB +U+B9DD +U+B9DE +U+B9E1 +U+B9E3 +U+B9E4 +U+B9E5 +U+B9E8 +U+B9EC +U+B9F4 +U+B9F5 +U+B9F7 +U+B9F8 +U+B9F9 +U+B9FA +U+BA00 +U+BA01 +U+BA08 +U+BA15 +U+BA38 +U+BA39 +U+BA3C +U+BA40 +U+BA42 +U+BA48 +U+BA49 +U+BA4B +U+BA4D +U+BA4E +U+BA53 +U+BA54 +U+BA55 +U+BA58 +U+BA5C +U+BA64 +U+BA65 +U+BA67 +U+BA68 +U+BA69 +U+BA70 +U+BA71 +U+BA74 +U+BA78 +U+BA83 +U+BA84 +U+BA85 +U+BA87 +U+BA8C +U+BAA8 +U+BAA9 +U+BAAB +U+BAAC +U+BAB0 +U+BAB2 +U+BAB8 +U+BAB9 +U+BABB +U+BABD +U+BAC4 +U+BAC8 +U+BAD8 +U+BAD9 +U+BAFC +U+BB00 +U+BB04 +U+BB0D +U+BB0F +U+BB11 +U+BB18 +U+BB1C +U+BB20 +U+BB29 +U+BB2B +U+BB34 +U+BB35 +U+BB36 +U+BB38 +U+BB3B +U+BB3C +U+BB3D +U+BB3E +U+BB44 +U+BB45 +U+BB47 +U+BB49 +U+BB4D +U+BB4F +U+BB50 +U+BB54 +U+BB58 +U+BB61 +U+BB63 +U+BB6C +U+BB88 +U+BB8C +U+BB90 +U+BBA4 +U+BBA8 +U+BBAC +U+BBB4 +U+BBB7 +U+BBC0 +U+BBC4 +U+BBC8 +U+BBD0 +U+BBD3 +U+BBF8 +U+BBF9 +U+BBFC +U+BBFF +U+BC00 +U+BC02 +U+BC08 +U+BC09 +U+BC0B +U+BC0C +U+BC0D +U+BC0F +U+BC11 +U+BC14 +U+BC15 +U+BC16 +U+BC17 +U+BC18 +U+BC1B +U+BC1C +U+BC1D +U+BC1E +U+BC1F +U+BC24 +U+BC25 +U+BC27 +U+BC29 +U+BC2D +U+BC30 +U+BC31 +U+BC34 +U+BC38 +U+BC40 +U+BC41 +U+BC43 +U+BC44 +U+BC45 +U+BC49 +U+BC4C +U+BC4D +U+BC50 +U+BC5D +U+BC84 +U+BC85 +U+BC88 +U+BC8B +U+BC8C +U+BC8E +U+BC94 +U+BC95 +U+BC97 +U+BC99 +U+BC9A +U+BCA0 +U+BCA1 +U+BCA4 +U+BCA7 +U+BCA8 +U+BCB0 +U+BCB1 +U+BCB3 +U+BCB4 +U+BCB5 +U+BCBC +U+BCBD +U+BCC0 +U+BCC4 +U+BCCD +U+BCCF +U+BCD0 +U+BCD1 +U+BCD5 +U+BCD8 +U+BCDC +U+BCF4 +U+BCF5 +U+BCF6 +U+BCF8 +U+BCFC +U+BD04 +U+BD05 +U+BD07 +U+BD09 +U+BD10 +U+BD14 +U+BD24 +U+BD2C +U+BD40 +U+BD48 +U+BD49 +U+BD4C +U+BD50 +U+BD58 +U+BD59 +U+BD64 +U+BD68 +U+BD80 +U+BD81 +U+BD84 +U+BD87 +U+BD88 +U+BD89 +U+BD8A +U+BD90 +U+BD91 +U+BD93 +U+BD95 +U+BD99 +U+BD9A +U+BD9C +U+BDA4 +U+BDB0 +U+BDB8 +U+BDD4 +U+BDD5 +U+BDD8 +U+BDDC +U+BDE9 +U+BDF0 +U+BDF4 +U+BDF8 +U+BE00 +U+BE03 +U+BE05 +U+BE0C +U+BE0D +U+BE10 +U+BE14 +U+BE1C +U+BE1D +U+BE1F +U+BE44 +U+BE45 +U+BE48 +U+BE4C +U+BE4E +U+BE54 +U+BE55 +U+BE57 +U+BE59 +U+BE5A +U+BE5B +U+BE60 +U+BE61 +U+BE64 +U+BE68 +U+BE6A +U+BE70 +U+BE71 +U+BE73 +U+BE74 +U+BE75 +U+BE7B +U+BE7C +U+BE7D +U+BE80 +U+BE84 +U+BE8C +U+BE8D +U+BE8F +U+BE90 +U+BE91 +U+BE98 +U+BE99 +U+BEA8 +U+BED0 +U+BED1 +U+BED4 +U+BED7 +U+BED8 +U+BEE0 +U+BEE3 +U+BEE4 +U+BEE5 +U+BEEC +U+BF01 +U+BF08 +U+BF09 +U+BF18 +U+BF19 +U+BF1B +U+BF1C +U+BF1D +U+BF40 +U+BF41 +U+BF44 +U+BF48 +U+BF50 +U+BF51 +U+BF55 +U+BF94 +U+BFB0 +U+BFC5 +U+BFCC +U+BFCD +U+BFD0 +U+BFD4 +U+BFDC +U+BFDF +U+BFE1 +U+C03C +U+C051 +U+C058 +U+C05C +U+C060 +U+C068 +U+C069 +U+C090 +U+C091 +U+C094 +U+C098 +U+C0A0 +U+C0A1 +U+C0A3 +U+C0A5 +U+C0AC +U+C0AD +U+C0AF +U+C0B0 +U+C0B3 +U+C0B4 +U+C0B5 +U+C0B6 +U+C0BC +U+C0BD +U+C0BF +U+C0C0 +U+C0C1 +U+C0C5 +U+C0C8 +U+C0C9 +U+C0CC +U+C0D0 +U+C0D8 +U+C0D9 +U+C0DB +U+C0DC +U+C0DD +U+C0E4 +U+C0E5 +U+C0E8 +U+C0EC +U+C0F4 +U+C0F5 +U+C0F7 +U+C0F9 +U+C100 +U+C104 +U+C108 +U+C110 +U+C115 +U+C11C +U+C11D +U+C11E +U+C11F +U+C120 +U+C123 +U+C124 +U+C126 +U+C127 +U+C12C +U+C12D +U+C12F +U+C130 +U+C131 +U+C136 +U+C138 +U+C139 +U+C13C +U+C140 +U+C148 +U+C149 +U+C14B +U+C14C +U+C14D +U+C154 +U+C155 +U+C158 +U+C15C +U+C164 +U+C165 +U+C167 +U+C168 +U+C169 +U+C170 +U+C174 +U+C178 +U+C185 +U+C18C +U+C18D +U+C18E +U+C190 +U+C194 +U+C196 +U+C19C +U+C19D +U+C19F +U+C1A1 +U+C1A5 +U+C1A8 +U+C1A9 +U+C1AC +U+C1B0 +U+C1BD +U+C1C4 +U+C1C8 +U+C1CC +U+C1D4 +U+C1D7 +U+C1D8 +U+C1E0 +U+C1E4 +U+C1E8 +U+C1F0 +U+C1F1 +U+C1F3 +U+C1FC +U+C1FD +U+C200 +U+C204 +U+C20C +U+C20D +U+C20F +U+C211 +U+C218 +U+C219 +U+C21C +U+C21F +U+C220 +U+C228 +U+C229 +U+C22B +U+C22D +U+C22F +U+C231 +U+C232 +U+C234 +U+C248 +U+C250 +U+C251 +U+C254 +U+C258 +U+C260 +U+C265 +U+C26C +U+C26D +U+C270 +U+C274 +U+C27C +U+C27D +U+C27F +U+C281 +U+C288 +U+C289 +U+C290 +U+C298 +U+C29B +U+C29D +U+C2A4 +U+C2A5 +U+C2A8 +U+C2AC +U+C2AD +U+C2B4 +U+C2B5 +U+C2B7 +U+C2B9 +U+C2DC +U+C2DD +U+C2E0 +U+C2E3 +U+C2E4 +U+C2EB +U+C2EC +U+C2ED +U+C2EF +U+C2F1 +U+C2F6 +U+C2F8 +U+C2F9 +U+C2FB +U+C2FC +U+C300 +U+C308 +U+C309 +U+C30C +U+C30D +U+C313 +U+C314 +U+C315 +U+C318 +U+C31C +U+C324 +U+C325 +U+C328 +U+C329 +U+C345 +U+C368 +U+C369 +U+C36C +U+C370 +U+C372 +U+C378 +U+C379 +U+C37C +U+C37D +U+C384 +U+C388 +U+C38C +U+C3C0 +U+C3D8 +U+C3D9 +U+C3DC +U+C3DF +U+C3E0 +U+C3E2 +U+C3E8 +U+C3E9 +U+C3ED +U+C3F4 +U+C3F5 +U+C3F8 +U+C408 +U+C410 +U+C424 +U+C42C +U+C430 +U+C434 +U+C43C +U+C43D +U+C448 +U+C464 +U+C465 +U+C468 +U+C46C +U+C474 +U+C475 +U+C479 +U+C480 +U+C494 +U+C49C +U+C4B8 +U+C4BC +U+C4E9 +U+C4F0 +U+C4F1 +U+C4F4 +U+C4F8 +U+C4FA +U+C4FF +U+C500 +U+C501 +U+C50C +U+C510 +U+C514 +U+C51C +U+C528 +U+C529 +U+C52C +U+C530 +U+C538 +U+C539 +U+C53B +U+C53D +U+C544 +U+C545 +U+C548 +U+C549 +U+C54A +U+C54C +U+C54D +U+C54E +U+C553 +U+C554 +U+C555 +U+C557 +U+C558 +U+C559 +U+C55D +U+C55E +U+C560 +U+C561 +U+C564 +U+C568 +U+C570 +U+C571 +U+C573 +U+C574 +U+C575 +U+C57C +U+C57D +U+C580 +U+C584 +U+C587 +U+C58C +U+C58D +U+C58F +U+C591 +U+C595 +U+C597 +U+C598 +U+C59C +U+C5A0 +U+C5A9 +U+C5B4 +U+C5B5 +U+C5B8 +U+C5B9 +U+C5BB +U+C5BC +U+C5BD +U+C5BE +U+C5C4 +U+C5C5 +U+C5C6 +U+C5C7 +U+C5C8 +U+C5C9 +U+C5CA +U+C5CC +U+C5CE +U+C5D0 +U+C5D1 +U+C5D4 +U+C5D8 +U+C5E0 +U+C5E1 +U+C5E3 +U+C5E5 +U+C5EC +U+C5ED +U+C5EE +U+C5F0 +U+C5F4 +U+C5F6 +U+C5F7 +U+C5FC +U+C5FD +U+C5FE +U+C5FF +U+C600 +U+C601 +U+C605 +U+C606 +U+C607 +U+C608 +U+C60C +U+C610 +U+C618 +U+C619 +U+C61B +U+C61C +U+C624 +U+C625 +U+C628 +U+C62C +U+C62D +U+C62E +U+C630 +U+C633 +U+C634 +U+C635 +U+C637 +U+C639 +U+C63B +U+C640 +U+C641 +U+C644 +U+C648 +U+C650 +U+C651 +U+C653 +U+C654 +U+C655 +U+C65C +U+C65D +U+C660 +U+C66C +U+C66F +U+C671 +U+C678 +U+C679 +U+C67C +U+C680 +U+C688 +U+C689 +U+C68B +U+C68D +U+C694 +U+C695 +U+C698 +U+C69C +U+C6A4 +U+C6A5 +U+C6A7 +U+C6A9 +U+C6B0 +U+C6B1 +U+C6B4 +U+C6B8 +U+C6B9 +U+C6BA +U+C6C0 +U+C6C1 +U+C6C3 +U+C6C5 +U+C6CC +U+C6CD +U+C6D0 +U+C6D4 +U+C6DC +U+C6DD +U+C6E0 +U+C6E1 +U+C6E8 +U+C6E9 +U+C6EC +U+C6F0 +U+C6F8 +U+C6F9 +U+C6FD +U+C704 +U+C705 +U+C708 +U+C70C +U+C714 +U+C715 +U+C717 +U+C719 +U+C720 +U+C721 +U+C724 +U+C728 +U+C730 +U+C731 +U+C733 +U+C735 +U+C737 +U+C73C +U+C73D +U+C740 +U+C744 +U+C74A +U+C74C +U+C74D +U+C74F +U+C751 +U+C752 +U+C753 +U+C754 +U+C755 +U+C756 +U+C757 +U+C758 +U+C75C +U+C760 +U+C768 +U+C76B +U+C774 +U+C775 +U+C778 +U+C77C +U+C77D +U+C77E +U+C783 +U+C784 +U+C785 +U+C787 +U+C788 +U+C789 +U+C78A +U+C78E +U+C790 +U+C791 +U+C794 +U+C796 +U+C797 +U+C798 +U+C79A +U+C7A0 +U+C7A1 +U+C7A3 +U+C7A4 +U+C7A5 +U+C7A6 +U+C7AC +U+C7AD +U+C7B0 +U+C7B4 +U+C7BC +U+C7BD +U+C7BF +U+C7C0 +U+C7C1 +U+C7C8 +U+C7C9 +U+C7CC +U+C7CE +U+C7D0 +U+C7D8 +U+C7DD +U+C7E4 +U+C7E8 +U+C7EC +U+C800 +U+C801 +U+C804 +U+C808 +U+C80A +U+C810 +U+C811 +U+C813 +U+C815 +U+C816 +U+C81C +U+C81D +U+C820 +U+C824 +U+C82C +U+C82D +U+C82F +U+C831 +U+C838 +U+C83C +U+C840 +U+C848 +U+C849 +U+C84C +U+C84D +U+C854 +U+C870 +U+C871 +U+C874 +U+C878 +U+C87A +U+C880 +U+C881 +U+C883 +U+C885 +U+C886 +U+C887 +U+C88B +U+C88C +U+C88D +U+C894 +U+C89D +U+C89F +U+C8A1 +U+C8A8 +U+C8BC +U+C8BD +U+C8C4 +U+C8C8 +U+C8CC +U+C8D4 +U+C8D5 +U+C8D7 +U+C8D9 +U+C8E0 +U+C8E1 +U+C8E4 +U+C8F5 +U+C8FC +U+C8FD +U+C900 +U+C904 +U+C905 +U+C906 +U+C90C +U+C90D +U+C90F +U+C911 +U+C918 +U+C92C +U+C934 +U+C950 +U+C951 +U+C954 +U+C958 +U+C960 +U+C961 +U+C963 +U+C96C +U+C970 +U+C974 +U+C97C +U+C988 +U+C989 +U+C98C +U+C990 +U+C998 +U+C999 +U+C99B +U+C99D +U+C9C0 +U+C9C1 +U+C9C4 +U+C9C7 +U+C9C8 +U+C9CA +U+C9D0 +U+C9D1 +U+C9D3 +U+C9D5 +U+C9D6 +U+C9D9 +U+C9DA +U+C9DC +U+C9DD +U+C9E0 +U+C9E2 +U+C9E4 +U+C9E7 +U+C9EC +U+C9ED +U+C9EF +U+C9F0 +U+C9F1 +U+C9F8 +U+C9F9 +U+C9FC +U+CA00 +U+CA08 +U+CA09 +U+CA0B +U+CA0C +U+CA0D +U+CA14 +U+CA18 +U+CA29 +U+CA4C +U+CA4D +U+CA50 +U+CA54 +U+CA5C +U+CA5D +U+CA5F +U+CA60 +U+CA61 +U+CA68 +U+CA7D +U+CA84 +U+CA98 +U+CABC +U+CABD +U+CAC0 +U+CAC4 +U+CACC +U+CACD +U+CACF +U+CAD1 +U+CAD3 +U+CAD8 +U+CAD9 +U+CAE0 +U+CAEC +U+CAF4 +U+CB08 +U+CB10 +U+CB14 +U+CB18 +U+CB20 +U+CB21 +U+CB41 +U+CB48 +U+CB49 +U+CB4C +U+CB50 +U+CB58 +U+CB59 +U+CB5D +U+CB64 +U+CB78 +U+CB79 +U+CB9C +U+CBB8 +U+CBD4 +U+CBE4 +U+CBE7 +U+CBE9 +U+CC0C +U+CC0D +U+CC10 +U+CC14 +U+CC1C +U+CC1D +U+CC21 +U+CC22 +U+CC27 +U+CC28 +U+CC29 +U+CC2C +U+CC2E +U+CC30 +U+CC38 +U+CC39 +U+CC3B +U+CC3C +U+CC3D +U+CC3E +U+CC44 +U+CC45 +U+CC48 +U+CC4C +U+CC54 +U+CC55 +U+CC57 +U+CC58 +U+CC59 +U+CC60 +U+CC64 +U+CC66 +U+CC68 +U+CC70 +U+CC75 +U+CC98 +U+CC99 +U+CC9C +U+CCA0 +U+CCA8 +U+CCA9 +U+CCAB +U+CCAC +U+CCAD +U+CCB4 +U+CCB5 +U+CCB8 +U+CCBC +U+CCC4 +U+CCC5 +U+CCC7 +U+CCC9 +U+CCD0 +U+CCD4 +U+CCE4 +U+CCEC +U+CCF0 +U+CD01 +U+CD08 +U+CD09 +U+CD0C +U+CD10 +U+CD18 +U+CD19 +U+CD1B +U+CD1D +U+CD24 +U+CD28 +U+CD2C +U+CD39 +U+CD5C +U+CD60 +U+CD64 +U+CD6C +U+CD6D +U+CD6F +U+CD71 +U+CD78 +U+CD88 +U+CD94 +U+CD95 +U+CD98 +U+CD9C +U+CDA4 +U+CDA5 +U+CDA7 +U+CDA9 +U+CDB0 +U+CDC4 +U+CDCC +U+CDD0 +U+CDE8 +U+CDEC +U+CDF0 +U+CDF8 +U+CDF9 +U+CDFB +U+CDFD +U+CE04 +U+CE08 +U+CE0C +U+CE14 +U+CE19 +U+CE20 +U+CE21 +U+CE24 +U+CE28 +U+CE30 +U+CE31 +U+CE33 +U+CE35 +U+CE58 +U+CE59 +U+CE5C +U+CE5F +U+CE60 +U+CE61 +U+CE68 +U+CE69 +U+CE6B +U+CE6D +U+CE74 +U+CE75 +U+CE78 +U+CE7C +U+CE84 +U+CE85 +U+CE87 +U+CE89 +U+CE90 +U+CE91 +U+CE94 +U+CE98 +U+CEA0 +U+CEA1 +U+CEA3 +U+CEA4 +U+CEA5 +U+CEAC +U+CEAD +U+CEC1 +U+CEE4 +U+CEE5 +U+CEE8 +U+CEEB +U+CEEC +U+CEF4 +U+CEF5 +U+CEF7 +U+CEF8 +U+CEF9 +U+CF00 +U+CF01 +U+CF04 +U+CF08 +U+CF10 +U+CF11 +U+CF13 +U+CF15 +U+CF1C +U+CF20 +U+CF24 +U+CF2C +U+CF2D +U+CF2F +U+CF30 +U+CF31 +U+CF38 +U+CF54 +U+CF55 +U+CF58 +U+CF5C +U+CF64 +U+CF65 +U+CF67 +U+CF69 +U+CF70 +U+CF71 +U+CF74 +U+CF78 +U+CF80 +U+CF85 +U+CF8C +U+CFA1 +U+CFA8 +U+CFB0 +U+CFC4 +U+CFE0 +U+CFE1 +U+CFE4 +U+CFE8 +U+CFF0 +U+CFF1 +U+CFF3 +U+CFF5 +U+CFFC +U+D000 +U+D004 +U+D011 +U+D018 +U+D02D +U+D034 +U+D035 +U+D038 +U+D03C +U+D044 +U+D045 +U+D047 +U+D049 +U+D050 +U+D054 +U+D058 +U+D060 +U+D06C +U+D06D +U+D070 +U+D074 +U+D07C +U+D07D +U+D081 +U+D0A4 +U+D0A5 +U+D0A8 +U+D0AC +U+D0B4 +U+D0B5 +U+D0B7 +U+D0B9 +U+D0C0 +U+D0C1 +U+D0C4 +U+D0C8 +U+D0C9 +U+D0D0 +U+D0D1 +U+D0D3 +U+D0D4 +U+D0D5 +U+D0DC +U+D0DD +U+D0E0 +U+D0E4 +U+D0EC +U+D0ED +U+D0EF +U+D0F0 +U+D0F1 +U+D0F8 +U+D10D +U+D130 +U+D131 +U+D134 +U+D138 +U+D13A +U+D140 +U+D141 +U+D143 +U+D144 +U+D145 +U+D14C +U+D14D +U+D150 +U+D154 +U+D15C +U+D15D +U+D15F +U+D161 +U+D168 +U+D16C +U+D17C +U+D184 +U+D188 +U+D1A0 +U+D1A1 +U+D1A4 +U+D1A8 +U+D1B0 +U+D1B1 +U+D1B3 +U+D1B5 +U+D1BA +U+D1BC +U+D1C0 +U+D1D8 +U+D1F4 +U+D1F8 +U+D207 +U+D209 +U+D210 +U+D22C +U+D22D +U+D230 +U+D234 +U+D23C +U+D23D +U+D23F +U+D241 +U+D248 +U+D25C +U+D264 +U+D280 +U+D281 +U+D284 +U+D288 +U+D290 +U+D291 +U+D295 +U+D29C +U+D2A0 +U+D2A4 +U+D2AC +U+D2B1 +U+D2B8 +U+D2B9 +U+D2BC +U+D2BF +U+D2C0 +U+D2C2 +U+D2C8 +U+D2C9 +U+D2CB +U+D2D4 +U+D2D8 +U+D2DC +U+D2E4 +U+D2E5 +U+D2F0 +U+D2F1 +U+D2F4 +U+D2F8 +U+D300 +U+D301 +U+D303 +U+D305 +U+D30C +U+D30D +U+D30E +U+D310 +U+D314 +U+D316 +U+D31C +U+D31D +U+D31F +U+D320 +U+D321 +U+D325 +U+D328 +U+D329 +U+D32C +U+D330 +U+D338 +U+D339 +U+D33B +U+D33C +U+D33D +U+D344 +U+D345 +U+D37C +U+D37D +U+D380 +U+D384 +U+D38C +U+D38D +U+D38F +U+D390 +U+D391 +U+D398 +U+D399 +U+D39C +U+D3A0 +U+D3A8 +U+D3A9 +U+D3AB +U+D3AD +U+D3B4 +U+D3B8 +U+D3BC +U+D3C4 +U+D3C5 +U+D3C8 +U+D3C9 +U+D3D0 +U+D3D8 +U+D3E1 +U+D3E3 +U+D3EC +U+D3ED +U+D3F0 +U+D3F4 +U+D3FC +U+D3FD +U+D3FF +U+D401 +U+D408 +U+D41D +U+D440 +U+D444 +U+D45C +U+D460 +U+D464 +U+D46D +U+D46F +U+D478 +U+D479 +U+D47C +U+D47F +U+D480 +U+D482 +U+D488 +U+D489 +U+D48B +U+D48D +U+D494 +U+D4A9 +U+D4CC +U+D4D0 +U+D4D4 +U+D4DC +U+D4DF +U+D4E8 +U+D4EC +U+D4F0 +U+D4F8 +U+D4FB +U+D4FD +U+D504 +U+D508 +U+D50C +U+D514 +U+D515 +U+D517 +U+D53C +U+D53D +U+D540 +U+D544 +U+D54C +U+D54D +U+D54F +U+D551 +U+D558 +U+D559 +U+D55C +U+D560 +U+D565 +U+D568 +U+D569 +U+D56B +U+D56D +U+D574 +U+D575 +U+D578 +U+D57C +U+D584 +U+D585 +U+D587 +U+D588 +U+D589 +U+D590 +U+D5A5 +U+D5C8 +U+D5C9 +U+D5CC +U+D5D0 +U+D5D2 +U+D5D8 +U+D5D9 +U+D5DB +U+D5DD +U+D5E4 +U+D5E5 +U+D5E8 +U+D5EC +U+D5F4 +U+D5F5 +U+D5F7 +U+D5F9 +U+D600 +U+D601 +U+D604 +U+D608 +U+D610 +U+D611 +U+D613 +U+D614 +U+D615 +U+D61C +U+D620 +U+D624 +U+D62D +U+D638 +U+D639 +U+D63C +U+D640 +U+D645 +U+D648 +U+D649 +U+D64B +U+D64D +U+D651 +U+D654 +U+D655 +U+D658 +U+D65C +U+D667 +U+D669 +U+D670 +U+D671 +U+D674 +U+D683 +U+D685 +U+D68C +U+D68D +U+D690 +U+D694 +U+D69D +U+D69F +U+D6A1 +U+D6A8 +U+D6AC +U+D6B0 +U+D6B9 +U+D6BB +U+D6C4 +U+D6C5 +U+D6C8 +U+D6CC +U+D6D1 +U+D6D4 +U+D6D7 +U+D6D9 +U+D6E0 +U+D6E4 +U+D6E8 +U+D6F0 +U+D6F5 +U+D6FC +U+D6FD +U+D700 +U+D704 +U+D711 +U+D718 +U+D719 +U+D71C +U+D720 +U+D728 +U+D729 +U+D72B +U+D72D +U+D734 +U+D735 +U+D738 +U+D73C +U+D744 +U+D747 +U+D749 +U+D750 +U+D751 +U+D754 +U+D756 +U+D757 +U+D758 +U+D759 +U+D760 +U+D761 +U+D763 +U+D765 +U+D769 +U+D76C +U+D770 +U+D774 +U+D77C +U+D77D +U+D781 +U+D788 +U+D789 +U+D78C +U+D790 +U+D798 +U+D799 +U+D79B +U+D79D diff --git a/crates/uncad-export/src/fonts.rs b/crates/uncad-export/src/fonts.rs new file mode 100644 index 0000000..7c7b46b --- /dev/null +++ b/crates/uncad-export/src/fonts.rs @@ -0,0 +1,124 @@ +//! The font the package draws and measures text with: `Uncad Sans`, a +//! Noto Sans KR subset bundled in this crate (see `fonts/README.md` for its +//! coverage, the reason for it and how to rebuild it, and +//! `fonts/OFL-NotoSansKR.txt` for its licence). +//! +//! The renderer bundles no font: it draws text with whatever +//! [`Fonts`](iron_render_cad::Fonts) it is handed, and needs to be told +//! the face's capital height, since a CAD text height is the height of the +//! capitals rather than the em. [`bundled`] and [`UNCAD_SANS_CAP_HEIGHT`] are +//! those two things for the bundled face. + +use std::sync::{Arc, OnceLock}; + +use iron_render_cad::Fonts; + +/// The bundled face, as the OpenType file it is. +pub static UNCAD_SANS_OTF: &[u8] = include_bytes!("../fonts/UncadSans-Regular.otf"); + +/// The family name the bundled face is registered under. +pub const UNCAD_SANS_FAMILY: &str = "Uncad Sans"; + +/// The bundled face's capital height as a fraction of its em: OS/2 +/// `sCapHeight` 733 over `head.unitsPerEm` 1000, read from the font's own +/// tables (a unit test checks the value against the embedded bytes). +/// +/// Handed to the renderer as `ToSvgOptions::cap_height`, it makes a text of +/// CAD height `h` draw its capitals `h` tall, and it scales the renderer's +/// 0.6-em character advance and 0.2-em descender to what the text box +/// estimates of this crate's earlier form assumed. +pub const UNCAD_SANS_CAP_HEIGHT: f64 = 0.733; + +/// [`Fonts::Custom`] holding the bundled face and nothing else: the same +/// pixels and the same text boxes on every machine. Built once per process; +/// the value is cheap to clone (the bytes are shared). +pub fn bundled() -> Fonts { + static BUNDLED: OnceLock = OnceLock::new(); + BUNDLED + .get_or_init(|| Fonts::Custom(vec![Arc::from(UNCAD_SANS_OTF)])) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One table of an OpenType font: its bytes, found through the table + /// directory at the start of the file (a 12-byte header, then 16-byte + /// records of tag, checksum, offset, length). + fn otf_table<'a>(font: &'a [u8], tag: &[u8; 4]) -> &'a [u8] { + let count = usize::from(u16::from_be_bytes([font[4], font[5]])); + (0..count) + .map(|i| &font[12 + 16 * i..12 + 16 * (i + 1)]) + .find(|record| &record[..4] == tag) + .map(|record| { + let offset = u32::from_be_bytes(record[8..12].try_into().unwrap()) as usize; + let length = u32::from_be_bytes(record[12..16].try_into().unwrap()) as usize; + &font[offset..offset + length] + }) + .unwrap_or_else(|| panic!("the font has no {} table", String::from_utf8_lossy(tag))) + } + + #[test] + fn the_cap_height_is_the_one_the_font_states() { + // head.unitsPerEm is at byte 18; OS/2 sCapHeight (version 2+) at 88. + let head = otf_table(UNCAD_SANS_OTF, b"head"); + let units_per_em = u16::from_be_bytes([head[18], head[19]]); + let os2 = otf_table(UNCAD_SANS_OTF, b"OS/2"); + assert!(u16::from_be_bytes([os2[0], os2[1]]) >= 2, "OS/2 v2+"); + let cap_height = i16::from_be_bytes([os2[88], os2[89]]); + assert_eq!((cap_height, units_per_em), (733, 1000)); + assert_eq!( + UNCAD_SANS_CAP_HEIGHT, + f64::from(cap_height) / f64::from(units_per_em) + ); + } + + #[test] + fn the_face_is_registered_under_its_own_family_name() { + // The name table's family records (name ID 1 and 16) say "Uncad + // Sans", never the Noto name the OFL asks a Modified Version not to + // pass as: a font database keyed by family must not pick a host's + // Noto Sans KR over this face, or the other way round. + let name = otf_table(UNCAD_SANS_OTF, b"name"); + let count = usize::from(u16::from_be_bytes([name[2], name[3]])); + let storage = usize::from(u16::from_be_bytes([name[4], name[5]])); + let mut families = Vec::new(); + for i in 0..count { + let record = &name[6 + 12 * i..6 + 12 * (i + 1)]; + let field = |at: usize| u16::from_be_bytes([record[at], record[at + 1]]); + let (platform, name_id, length, offset) = (field(0), field(6), field(8), field(10)); + if !(name_id == 1 || name_id == 16) { + continue; + } + let bytes = &name[storage + usize::from(offset)..][..usize::from(length)]; + // Windows records are UTF-16BE; Macintosh ones are single-byte. + let text = if platform == 3 { + let units: Vec = bytes + .chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + String::from_utf16_lossy(&units) + } else { + String::from_utf8_lossy(bytes).into_owned() + }; + families.push(text); + } + assert!(!families.is_empty()); + assert!( + families.iter().all(|f| f == UNCAD_SANS_FAMILY), + "{families:?}" + ); + } + + #[test] + fn the_bundled_fonts_are_built_once_and_share_their_bytes() { + let (a, b) = (bundled(), bundled()); + assert_eq!(a, b); + let (Fonts::Custom(a), Fonts::Custom(b)) = (a, b) else { + panic!("the bundled face is a custom font"); + }; + assert!(Arc::ptr_eq(&a[0], &b[0]), "one copy of the bytes"); + assert_eq!(&*a[0], UNCAD_SANS_OTF); + } +} diff --git a/crates/uncad-export/src/lib.rs b/crates/uncad-export/src/lib.rs new file mode 100644 index 0000000..5119ea7 --- /dev/null +++ b/crates/uncad-export/src/lib.rs @@ -0,0 +1,17 @@ +//! A DWG/DXF drawing as a package an LLM or a vision model can read. +//! +//! [`uncad`] parses a drawing into the neutral [`uncad_model`] entity model +//! and draws nothing; `iron-render-cad` draws that model and knows nothing +//! of who looks at the picture. This crate is the consumer between them that +//! writes for one kind of reader: images sized to a model's patch budget, +//! and the exact numbers a picture cannot give. +//! +//! What is here so far: [`fonts`], the face the package draws and measures +//! text with. +//! +//! # Licence +//! +//! GPL-3.0-or-later, like the rest of the workspace, and OFL-1.1 for the +//! bundled font (`fonts/OFL-NotoSansKR.txt`). + +pub mod fonts; diff --git a/deny.toml b/deny.toml index aa272c6..085292f 100644 --- a/deny.toml +++ b/deny.toml @@ -60,12 +60,20 @@ allow = [ # below. # Copyleft is granted per crate, never by license name. The GPL lives on the -# three crates this workspace publishes and nowhere else -- a fourth crate +# four crates this workspace publishes and nowhere else -- a fifth crate # named here means the boundary moved, which is a decision, not a dependency # bump. exceptions = [ { crate = "libredwg-sys", allow = ["GPL-3.0-or-later"] }, { crate = "uncad", allow = ["GPL-3.0-or-later"] }, + # The fourth, by decision: the LLM/VLM package moved out of `uncad` (which + # renders nothing) into a crate of its own. Its expression is + # `GPL-3.0-or-later AND OFL-1.1` because it bundles a font (a Noto Sans + # KR subset) under the SIL Open Font License. cargo-deny sees only that + # expression, never the font file, so the OFL is granted here for this + # crate alone and named in `docs/THIRD_PARTY_NOTICES.md`; a font arriving + # through any other crate is still refused. + { crate = "uncad-export", allow = ["GPL-3.0-or-later", "OFL-1.1"] }, { crate = "uncad-cli", allow = ["GPL-3.0-or-later"] }, # Weak, file-scope copyleft, consumed unmodified and only by this # workspace's tests; where its source can be obtained is recorded in diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md index a1bdd73..d89cd2f 100644 --- a/docs/THIRD_PARTY_NOTICES.md +++ b/docs/THIRD_PARTY_NOTICES.md @@ -19,15 +19,24 @@ This project bundles or builds against the following third-party components. - **Modified**: no — consumed as published. - **Why it is named here**: MPL-2.0 § 3.2 asks anyone distributing an Executable Form built from Covered Software to tell recipients how to obtain the Source Code Form, and it asks that whether or not the Covered Software was modified. This entry is that notice. Its § 3.3 is why the rest of this workspace keeps its own terms. +## Noto Sans KR (bundled font subset) + +- **Source**: https://github.com/notofonts/noto-cjk (`Sans/SubsetOTF/KR/NotoSansKR-Regular.otf`, v2.004) +- **Copyright**: Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' +- **License**: SIL Open Font License, Version 1.1 (OFL-1.1), licence text at `crates/uncad-export/fonts/OFL-NotoSansKR.txt`, inside the crate so it travels with every copy of the font +- **Used for**: drawing and measuring text in the images of the LLM/VLM package `crates/uncad-export` writes (Latin, Greek, the 2350 common Hangul syllables and the CAD symbols), so they are the same on every machine +- **Modified**: yes -- `crates/uncad-export/fonts/UncadSans-Regular.otf` is a subset (2755 glyphs, no layout tables) renamed to "Uncad Sans", as the OFL asks of a Modified Version; `crates/uncad-export/fonts/README.md` records the exact steps and the tools that produce it from the source above +- **Why it is named here**: a font is data, not a Rust dependency, so cargo-deny never sees it. The crate states it in its licence expression (`GPL-3.0-or-later AND OFL-1.1`), `deny.toml` grants the OFL to that crate alone, and every binary linking `uncad-export` embeds the font bytes. + --- Each published crate carries the full GPLv3 text as a `LICENSE` file in its own directory -(`crates/libredwg-sys/`, `crates/uncad/`, `crates/uncad-cli/`), byte-identical to the +(`crates/libredwg-sys/`, `crates/uncad/`, `crates/uncad-export/`, `crates/uncad-cli/`), byte-identical to the repository root's. They are copies rather than one shared file because `cargo package` never reaches outside a crate directory, and GPLv3 §4 asks for the licence to be conveyed with the source that is conveyed — a crates.io tarball is exactly that. -This project is distributed under **GPLv3-or-later**, matching LibreDWG's own license (the only third-party component bundled). See [`LICENSE`](../LICENSE). +This project is distributed under **GPLv3-or-later**, matching LibreDWG's own license (the only third-party *code* bundled; the second bundled component, the Noto Sans KR subset above, is data under the SIL Open Font License 1.1, which permits bundling in GPL software, and is why `uncad-export`'s licence expression names both). See [`LICENSE`](../LICENSE). DWF/DWFx support (previously provided by a vendored subset of [dwf-viewer](https://github.com/flyfish-dev/dwf-viewer), AGPL-3.0-only) was From 35950f01f88076e16f220764613bfd0b3b155d72 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 19:16:18 +0900 Subject: [PATCH 39/54] Give the package its own polyline arithmetic The model carries a polyline's vertices with their bulges and computes nothing but a block reference's placement; a length, an area or whether an outline crosses itself is a consumer's derivation. These are the feature branch's, on the model's `PolylineVertex` (a vertex's bulge is the segment leaving it) instead of parallel point and bulge lists: - `bulge_arc` and `polyline_segments`, `polyline_length`, `polyline_signed_area` and `polyline_area`: arcs included; an open polyline's area closes with a straight segment, so the bulge AutoCAD leaves on its last vertex bends nothing; a closed pair of arcs (a DONUT) encloses its real area; a repeated closing vertex adds no segment. `polyline_area` is `None` where the vertices cannot enclose anything. - `is_simple` (quadratic; the caller bounds it), `polygon_centroid`, `point_in_polygon`. - New for the split: `plane_to_world(extrusion, elevation)`. The model now keeps a planar entity's coordinates in its own plane and leaves the map to the world to its consumers; the package takes it from the model's own `Affine2::from_insert` for a unit reference at the plane's origin, so it and the renderer run the one arbitrary axis algorithm the model owns. `map_polyline` takes vertices through a similarity -- a mirror turns a bulge's sign -- and refuses a tilted plane, where an arc is no longer one. The feature's unit tests come along on the new shapes, and the revision cloud of example_2000.dwg is measured against an independent sum of its arcs (tests/geometry.rs). Ported-from: fae3b14, c5ccaf0, 7210517, f34985c Co-Authored-By: Claude Opus 5.5 --- crates/uncad-export/src/geom.rs | 630 ++++++++++++++++++++++++++ crates/uncad-export/src/lib.rs | 5 +- crates/uncad-export/tests/geometry.rs | 73 +++ 3 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 crates/uncad-export/src/geom.rs create mode 100644 crates/uncad-export/tests/geometry.rs diff --git a/crates/uncad-export/src/geom.rs b/crates/uncad-export/src/geom.rs new file mode 100644 index 0000000..0c8e01c --- /dev/null +++ b/crates/uncad-export/src/geom.rs @@ -0,0 +1,630 @@ +//! The arithmetic the package's records carry and the model does not: a +//! polyline's length and area with its bulge arcs, whether an outline +//! crosses itself, a polygon's centroid, and the map from an entity's own +//! plane to the world. +//! +//! The model states a drawing and computes nothing but a block reference's +//! placement ([`uncad_model::Affine2`]); lengths and areas are this +//! consumer's derivations, and every one of them is exact arithmetic on +//! what the file states, not an estimate. + +use uncad_model::model::{ + Confidence, EntityCommon, EntityId, InsertEntity, Origin, Point2D, Point3D, PolylineVertex, Ref, +}; +use uncad_model::Affine2; + +/// The circular arc a bulge describes between two vertices. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BulgeArc { + pub center: Point2D, + pub radius: f64, + /// Radians, of the arc's first point about the centre. + pub start_angle: f64, + /// Radians, of the arc's second point about the centre. + pub end_angle: f64, + /// The signed included angle in radians: positive counter-clockwise. + pub sweep: f64, +} + +impl BulgeArc { + pub fn length(&self) -> f64 { + self.radius * self.sweep.abs() + } + + /// The area between the arc and its chord (the circular segment), + /// always positive. + pub fn segment_area(&self) -> f64 { + let theta = self.sweep.abs(); + self.radius * self.radius / 2.0 * (theta - theta.sin()) + } +} + +/// The arc from `from` to `to` with the given bulge -- the tangent of a +/// quarter of its included angle, positive counter-clockwise -- or `None` +/// for a straight segment (bulge 0 or not a number, or coincident points). +pub fn bulge_arc(from: Point2D, to: Point2D, bulge: f64) -> Option { + if bulge == 0.0 || !bulge.is_finite() { + return None; + } + let (dx, dy) = (to.x - from.x, to.y - from.y); + let chord = dx.hypot(dy); + if chord < 1e-12 { + return None; + } + let sweep = 4.0 * bulge.atan(); + let radius = chord * (1.0 + bulge * bulge) / (4.0 * bulge.abs()); + let sagitta = chord * bulge.abs() / 2.0; + // The centre sits on the chord's perpendicular bisector, to the left of + // travel for a counter-clockwise arc, `radius - sagitta` away (negative + // past a semicircle, i.e. on the bulge's own side). + let mid = Point2D { + x: (from.x + to.x) / 2.0, + y: (from.y + to.y) / 2.0, + }; + let left = Point2D { + x: -dy / chord, + y: dx / chord, + }; + let offset = (radius - sagitta) * bulge.signum(); + let center = Point2D { + x: mid.x + left.x * offset, + y: mid.y + left.y * offset, + }; + Some(BulgeArc { + center, + radius, + start_angle: (from.y - center.y).atan2(from.x - center.x), + end_angle: (to.y - center.y).atan2(to.x - center.x), + sweep, + }) +} + +/// One segment of a polyline. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Segment { + Line { + from: Point2D, + to: Point2D, + }, + Arc { + from: Point2D, + to: Point2D, + bulge: f64, + arc: BulgeArc, + }, +} + +impl Segment { + pub fn length(&self) -> f64 { + match self { + Segment::Line { from, to } => (to.x - from.x).hypot(to.y - from.y), + Segment::Arc { arc, .. } => arc.length(), + } + } +} + +/// The segments of a polyline: a vertex's bulge applies to the segment +/// leaving it, and a closed polyline gets the segment from its last vertex +/// back to its first. A closing vertex that repeats the first one is +/// dropped first, so it does not add a zero-length segment. +pub fn polyline_segments(vertices: &[PolylineVertex], closed: bool) -> Vec { + let vertices = without_repeated_close(vertices, closed); + let n = vertices.len(); + if n < 2 { + return Vec::new(); + } + let count = if closed { n } else { n - 1 }; + (0..count) + .map(|i| { + let (from, to) = (vertices[i].point, vertices[(i + 1) % n].point); + let bulge = vertices[i].bulge; + match bulge_arc(from, to, bulge) { + Some(arc) => Segment::Arc { + from, + to, + bulge, + arc, + }, + None => Segment::Line { from, to }, + } + }) + .collect() +} + +fn without_repeated_close(vertices: &[PolylineVertex], closed: bool) -> &[PolylineVertex] { + if closed && vertices.len() > 2 { + let (first, last) = (vertices[0].point, vertices[vertices.len() - 1].point); + if (first.x - last.x).abs() < 1e-9 && (first.y - last.y).abs() < 1e-9 { + return &vertices[..vertices.len() - 1]; + } + } + vertices +} + +/// The polyline's length (its perimeter when closed), arcs included. +pub fn polyline_length(vertices: &[PolylineVertex], closed: bool) -> f64 { + polyline_segments(vertices, closed) + .iter() + .map(Segment::length) + .sum() +} + +/// The signed area a polyline encloses, arcs included: the shoelace area of +/// its vertices plus each arc's circular segment, added when the arc bulges +/// outward and subtracted when it bulges inward. Positive for a +/// counter-clockwise outline. An open polyline is closed by a straight +/// segment from its last vertex back to its first for this purpose: the +/// bulge stored on the last vertex (AutoCAD keeps one there, e.g. after +/// BREAK or TRIM) applies to no segment, exactly as in +/// [`polyline_segments`]. Self-intersecting outlines give a value with no +/// geometric meaning; see [`is_simple`]. +pub fn polyline_signed_area(vertices: &[PolylineVertex], closed: bool) -> f64 { + let mut segments = polyline_segments(vertices, closed); + if !closed && vertices.len() >= 2 { + segments.push(Segment::Line { + from: vertices[vertices.len() - 1].point, + to: vertices[0].point, + }); + } + // Three straight segments are the fewest that can enclose anything -- + // but two *arcs* can. A closed two-vertex polyline with bulges is + // exactly what AutoCAD's DONUT command writes, and two bulges of 1 + // describe a full circle. The shoelace terms of such a shape cancel to + // zero, so what is left is the two circular-segment terms, which are + // its area. Fewer than three segments with no arc among them still + // encloses nothing. + if segments.len() < 3 && !segments.iter().any(|s| matches!(s, Segment::Arc { .. })) { + return 0.0; + } + let mut area = 0.0; + for segment in &segments { + match *segment { + Segment::Line { from, to } => area += (from.x * to.y - to.x * from.y) / 2.0, + Segment::Arc { + from, + to, + bulge, + arc, + } => { + area += (from.x * to.y - to.x * from.y) / 2.0; + // A counter-clockwise arc bulges to the right of travel, + // which is outward for a counter-clockwise outline and + // inward for a clockwise one -- so adding a signed term does + // the right thing for both orientations. + area += arc.segment_area() * bulge.signum(); + } + } + } + area +} + +/// The enclosed area, unsigned, when the polyline can enclose one at all: +/// three or more vertices, or a closed pair (two arcs, AutoCAD's DONUT). +/// `None` otherwise. +pub fn polyline_area(vertices: &[PolylineVertex], closed: bool) -> Option { + let enclosing = vertices.len() >= 3 || (closed && vertices.len() == 2); + enclosing.then(|| polyline_signed_area(vertices, closed).abs()) +} + +/// Whether the straight-segment outline through `vertices` (closed) has no +/// two non-adjacent segments crossing. Quadratic in the vertex count -- +/// callers bound it (the package tests at most 2000 vertices); arcs are +/// treated as their chords. +pub fn is_simple(vertices: &[Point2D]) -> bool { + let vertices = if vertices.len() > 2 && vertices[0] == vertices[vertices.len() - 1] { + &vertices[..vertices.len() - 1] + } else { + vertices + }; + let n = vertices.len(); + if n < 4 { + return true; + } + for i in 0..n { + for j in i + 1..n { + if j == i + 1 || (i == 0 && j == n - 1) { + continue; + } + let (a, b) = (vertices[i], vertices[(i + 1) % n]); + let (c, d) = (vertices[j], vertices[(j + 1) % n]); + if segments_cross(a, b, c, d) { + return false; + } + } + } + true +} + +fn segments_cross(a: Point2D, b: Point2D, c: Point2D, d: Point2D) -> bool { + let orient = + |p: Point2D, q: Point2D, r: Point2D| (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x); + let (o1, o2, o3, o4) = ( + orient(a, b, c), + orient(a, b, d), + orient(c, d, a), + orient(c, d, b), + ); + (o1 > 0.0) != (o2 > 0.0) + && (o3 > 0.0) != (o4 > 0.0) + && o1 != 0.0 + && o2 != 0.0 + && o3 != 0.0 + && o4 != 0.0 +} + +/// Area-weighted centroid of a polygon's vertices (straight segments); the +/// vertices' mean when they enclose no area. +pub fn polygon_centroid(vertices: &[Point2D]) -> Point2D { + let n = vertices.len(); + if n == 0 { + return Point2D::default(); + } + let (mut cx, mut cy, mut area) = (0.0, 0.0, 0.0); + for i in 0..n { + let (a, b) = (vertices[i], vertices[(i + 1) % n]); + let cross = a.x * b.y - b.x * a.y; + cx += (a.x + b.x) * cross; + cy += (a.y + b.y) * cross; + area += cross; + } + if area.abs() < 1e-12 { + let sx: f64 = vertices.iter().map(|p| p.x).sum(); + let sy: f64 = vertices.iter().map(|p| p.y).sum(); + return Point2D { + x: sx / n as f64, + y: sy / n as f64, + }; + } + Point2D { + x: cx / (3.0 * area), + y: cy / (3.0 * area), + } +} + +/// Whether `p` is inside the polygon `vertices` (even-odd rule). +pub fn point_in_polygon(p: Point2D, vertices: &[Point2D]) -> bool { + let n = vertices.len(); + if n == 0 { + return false; + } + let mut inside = false; + let mut j = n - 1; + for i in 0..n { + let (a, b) = (vertices[i], vertices[j]); + if (a.y > p.y) != (b.y > p.y) { + let x = (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x; + if p.x < x { + inside = !inside; + } + } + j = i; + } + inside +} + +/// The map from an entity's own plane -- the object coordinate system whose +/// Z axis is `extrusion`, at height `elevation` in it -- to the world's (x, +/// y): the DXF reference's arbitrary axis algorithm, seen from above. The +/// identity for the usual normal (0, 0, 1), a mirror across the y axis for +/// (0, 0, -1). +/// +/// Computed by the model's own [`Affine2::from_insert`] -- which applies +/// exactly this map to an INSERT's placement -- for an unscaled, unrotated +/// reference at the plane's origin, so the package and every other +/// consumer of the model agree on the algorithm to the bit. A normal that +/// is not a direction places the entity as (0, 0, 1) would, as it does +/// there. +pub fn plane_to_world(extrusion: Point3D, elevation: f64) -> Affine2 { + let unit = InsertEntity { + common: EntityCommon { + id: EntityId::new(0), + origin: Origin::Derived, + confidence: Confidence::High, + source_handle: Ref::Absent, + layer: Ref::Absent, + color_index: 256, + true_color: None, + invisible: false, + }, + block_name: Ref::Absent, + insertion_point: Point3D { + x: 0.0, + y: 0.0, + z: elevation, + }, + scale: Point3D { + x: 1.0, + y: 1.0, + z: 1.0, + }, + rotation: 0.0, + attribs: Vec::new(), + extrusion, + }; + Affine2::from_insert(&unit) +} + +/// Whether `m` keeps shapes: a rotation, a uniform scale and possibly one +/// mirror. A circle stays a circle through it, a bulge stays a bulge (its +/// sign turned by a mirror), and lengths scale by `sqrt(|det|)`. +pub fn is_similarity(m: &Affine2) -> bool { + let la = m.a.hypot(m.b); + let lb = m.c.hypot(m.d); + let dot = m.a * m.c + m.b * m.d; + la > 0.0 && lb > 0.0 && dot.abs() <= 1e-9 * la * lb && (la - lb).abs() <= 1e-9 * la.max(lb) +} + +/// `vertices` taken through `m`, when `m` is a similarity: the points +/// moved, a bulge's sign turned by a mirror (an arc that ran +/// counter-clockwise runs clockwise in a mirrored plane), the widths scaled. +/// `None` for a map that does not keep an arc an arc. +pub fn map_polyline(vertices: &[PolylineVertex], m: &Affine2) -> Option> { + if !is_similarity(m) { + return None; + } + let flip = if m.determinant() < 0.0 { -1.0 } else { 1.0 }; + let scale = m.determinant().abs().sqrt(); + Some( + vertices + .iter() + .map(|v| PolylineVertex { + point: m.apply(v.point), + bulge: v.bulge * flip, + start_width: v.start_width * scale, + end_width: v.end_width * scale, + }) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(x: f64, y: f64) -> Point2D { + Point2D { x, y } + } + + fn v(x: f64, y: f64, bulge: f64) -> PolylineVertex { + PolylineVertex { + point: p(x, y), + bulge, + start_width: 0.0, + end_width: 0.0, + } + } + + fn vs(points: &[(f64, f64)], bulges: &[f64]) -> Vec { + points + .iter() + .enumerate() + .map(|(i, (x, y))| v(*x, *y, bulges.get(i).copied().unwrap_or(0.0))) + .collect() + } + + fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 + } + + #[test] + fn the_design_documents_worked_example() { + // A 100 x 50 rectangle whose right edge is a 90-degree arc. + let vertices = vs( + &[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)], + &[0.0, 0.41421356, 0.0, 0.0], + ); + let segments = polyline_segments(&vertices, true); + assert_eq!(segments.len(), 4); + let Segment::Arc { arc, .. } = segments[1] else { + panic!("second segment is the arc"); + }; + assert!( + close(arc.center.x, 75.0) && close(arc.center.y, 25.0), + "{arc:?}" + ); + assert!(close(arc.radius, 35.355339), "{}", arc.radius); + assert!(close(arc.sweep.to_degrees(), 90.0), "{}", arc.sweep); + assert!(close(arc.length(), 55.536037), "{}", arc.length()); + assert!( + close(arc.segment_area(), 356.747702), + "{}", + arc.segment_area() + ); + assert!(close(polyline_length(&vertices, true), 305.536037)); + assert!(close(polyline_area(&vertices, true).unwrap(), 5356.747702)); + // The same outline clockwise: same area, negative sign. Reversed, + // the arc is the segment leaving vertex 1 ((100,50) down to + // (100,0)) and must bulge to the left of travel, i.e. negatively. + let cw = vs( + &[(0.0, 50.0), (100.0, 50.0), (100.0, 0.0), (0.0, 0.0)], + &[0.0, -0.41421356, 0.0, 0.0], + ); + assert!(close(polyline_signed_area(&cw, true), -5356.747702)); + let points: Vec = vertices.iter().map(|v| v.point).collect(); + assert!(is_simple(&points)); + } + + #[test] + fn bulges_beyond_a_semicircle_put_the_centre_on_the_bulge_side() { + // A 270-degree arc from (0,0) to (10,0): bulge = tan(67.5 deg). + let bulge = (270f64 / 4.0).to_radians().tan(); + let arc = bulge_arc(p(0.0, 0.0), p(10.0, 0.0), bulge).unwrap(); + assert!(close(arc.sweep.to_degrees(), 270.0)); + // Centre below the chord (the arc bulges to the right of travel, + // i.e. downward, and past a semicircle the centre is on that side): + // (5, -5), radius 5 sqrt(2). + assert!( + close(arc.center.x, 5.0) && close(arc.center.y, -5.0), + "{arc:?}" + ); + assert!(close(arc.radius, 5.0 * 2f64.sqrt()), "{}", arc.radius); + } + + #[test] + fn open_polylines_and_degenerate_input() { + let vertices = vs(&[(0.0, 0.0), (3.0, 4.0)], &[]); + assert!(close(polyline_length(&vertices, false), 5.0)); + assert_eq!(polyline_segments(&vertices, true).len(), 2); + assert_eq!(polyline_segments(&vs(&[(1.0, 1.0)], &[]), true).len(), 0); + assert_eq!(polyline_area(&vertices, false), None, "two open vertices"); + assert!(bulge_arc(p(0.0, 0.0), p(0.0, 0.0), 1.0).is_none()); + assert!(bulge_arc(p(0.0, 0.0), p(1.0, 0.0), f64::NAN).is_none()); + // A repeated closing vertex does not add a zero-length segment. + let repeated = vs(&[(0.0, 0.0), (4.0, 0.0), (4.0, 3.0), (0.0, 0.0)], &[]); + assert_eq!(polyline_segments(&repeated, true).len(), 3); + assert!(close(polyline_area(&repeated, true).unwrap(), 6.0)); + assert!(close(polyline_length(&repeated, true), 12.0)); + } + + #[test] + fn an_open_polylines_last_bulge_does_not_bend_the_closing_segment() { + // The right triangle (0,0) -> (10,0) -> (10,10), open, with a bulge + // left on its last vertex. Closed by a straight segment the area is + // 10 * 10 / 2 = 50, and the last bulge applies to nothing. Bent, the + // closing chord (10,10) -> (0,0) of length 10 sqrt(2) would carry a + // semicircle of radius 5 sqrt(2), adding or taking pi * 50 / 2 = + // 78.539816. + for bulge in [1.0, -1.0, 669.19] { + let vertices = vs(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], &[0.0, 0.0, bulge]); + assert!( + close(polyline_signed_area(&vertices, false), 50.0), + "bulge {bulge}: {}", + polyline_signed_area(&vertices, false) + ); + // Length is unaffected either way: two straight sides. + assert!(close(polyline_length(&vertices, false), 20.0)); + } + // Flagged closed, the same bulge is the real closing segment. + let vertices = vs(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], &[0.0, 0.0, 1.0]); + assert!(close( + polyline_signed_area(&vertices, true), + 50.0 + std::f64::consts::PI * 50.0 / 2.0 + )); + // An open polyline drawn back to its first vertex: the bulge on the + // vertex before the repeat is a real segment, the repeat's own is + // not. The third segment (10,10) -> (0,0) with bulge -1 is a + // semicircle inside the triangle, so 50 - 78.539816; the 5.0 on the + // repeated vertex must count for nothing. + let back_home = vs( + &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 0.0)], + &[0.0, 0.0, -1.0, 5.0], + ); + assert!(close( + polyline_signed_area(&back_home, false), + 50.0 - std::f64::consts::PI * 50.0 / 2.0 + )); + } + + #[test] + fn self_intersection_is_detected() { + let bowtie = [p(0.0, 0.0), p(10.0, 10.0), p(10.0, 0.0), p(0.0, 10.0)]; + assert!(!is_simple(&bowtie)); + let square = [p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0), p(0.0, 10.0)]; + assert!(is_simple(&square)); + // The same square with its closing vertex repeated. + let repeated = [ + p(0.0, 0.0), + p(10.0, 0.0), + p(10.0, 10.0), + p(0.0, 10.0), + p(0.0, 0.0), + ]; + assert!(is_simple(&repeated)); + } + + #[test] + fn a_closed_two_vertex_bulged_polyline_encloses_its_real_area() { + // AutoCAD's DONUT: two vertices 100 apart with bulges of 1 each is + // a circle of radius 50, so the area is pi * 50^2 = 7853.981634 and + // the perimeter is 2 * pi * 50 = 314.159265. Both numbers come from + // the circle, not from this crate. + let donut = vs(&[(0.0, 0.0), (100.0, 0.0)], &[1.0, 1.0]); + let area = polyline_area(&donut, true).unwrap(); + assert!(close(area, std::f64::consts::PI * 2500.0), "{area}"); + assert!(close( + polyline_length(&donut, true), + std::f64::consts::TAU * 50.0 + )); + // Orientation still reads: two negative bulges trace the same + // circle clockwise. + assert!(polyline_signed_area(&donut, true) > 0.0); + let clockwise = vs(&[(0.0, 0.0), (100.0, 0.0)], &[-1.0, -1.0]); + assert!(polyline_signed_area(&clockwise, true) < 0.0); + // Two *straight* segments still enclose nothing -- there and back + // along the same line. + let straight = vs(&[(0.0, 0.0), (100.0, 0.0)], &[0.0, 0.0]); + assert!(close(polyline_area(&straight, true).unwrap(), 0.0)); + // One bulge of 1, one of 0: a semicircle closed by its diameter, + // pi * 50^2 / 2. + let half = vs(&[(0.0, 0.0), (100.0, 0.0)], &[1.0, 0.0]); + assert!(close( + polyline_area(&half, true).unwrap(), + std::f64::consts::PI * 1250.0 + )); + } + + #[test] + fn centroids_and_containment() { + let square = [p(0.0, 0.0), p(4.0, 0.0), p(4.0, 2.0), p(0.0, 2.0)]; + let c = polygon_centroid(&square); + assert!(close(c.x, 2.0) && close(c.y, 1.0), "{c:?}"); + assert!(point_in_polygon(p(1.0, 1.0), &square)); + assert!(!point_in_polygon(p(5.0, 1.0), &square)); + // No area: the mean of the vertices. + let line = [p(0.0, 0.0), p(10.0, 0.0)]; + let c = polygon_centroid(&line); + assert!(close(c.x, 5.0) && close(c.y, 0.0), "{c:?}"); + } + + #[test] + fn a_mirrored_plane_turns_x_and_the_bulges_sign() { + // (0, 0, -1): the arbitrary axis algorithm takes the OCS x axis to + // the world's -x (Wy x N) and leaves y alone. + let mirror = plane_to_world( + Point3D { + x: 0.0, + y: 0.0, + z: -1.0, + }, + 0.0, + ); + let q = mirror.apply(p(3.0, 4.0)); + assert!(close(q.x, -3.0) && close(q.y, 4.0), "{q:?}"); + assert!(is_similarity(&mirror) && mirror.determinant() < 0.0); + // A counter-clockwise half circle in the mirrored plane runs + // clockwise in the world: same length and area, orientation turned. + let ocs = vs(&[(0.0, 0.0), (10.0, 0.0)], &[1.0, 1.0]); + let world = map_polyline(&ocs, &mirror).unwrap(); + assert!(world.iter().all(|v| v.bulge == -1.0)); + assert!(close( + polyline_length(&world, true), + polyline_length(&ocs, true) + )); + assert!(close( + polyline_signed_area(&world, true), + -polyline_signed_area(&ocs, true) + )); + // The usual normal is the identity, whatever the elevation. + let flat = plane_to_world( + Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + }, + 7.0, + ); + assert_eq!(flat, Affine2::IDENTITY); + // A tilted plane seen from above is no similarity: an arc in it is + // an ellipse in the plan. + let tilted = plane_to_world( + Point3D { + x: 1.0, + y: 0.0, + z: 1.0, + }, + 0.0, + ); + assert!(!is_similarity(&tilted)); + assert!(map_polyline(&ocs, &tilted).is_none()); + } +} diff --git a/crates/uncad-export/src/lib.rs b/crates/uncad-export/src/lib.rs index 5119ea7..6bbc08d 100644 --- a/crates/uncad-export/src/lib.rs +++ b/crates/uncad-export/src/lib.rs @@ -7,7 +7,9 @@ //! and the exact numbers a picture cannot give. //! //! What is here so far: [`fonts`], the face the package draws and measures -//! text with. +//! text with, and [`geom`], the arithmetic its records carry -- a polyline's +//! length and area with its bulge arcs, whether an outline crosses itself, +//! the map from an entity's own plane to the world. //! //! # Licence //! @@ -15,3 +17,4 @@ //! bundled font (`fonts/OFL-NotoSansKR.txt`). pub mod fonts; +pub mod geom; diff --git a/crates/uncad-export/tests/geometry.rs b/crates/uncad-export/tests/geometry.rs new file mode 100644 index 0000000..142ba11 --- /dev/null +++ b/crates/uncad-export/tests/geometry.rs @@ -0,0 +1,73 @@ +//! The package's polyline arithmetic against a real file: the corpus's +//! `example_2000.dwg` has a revision cloud -- an LWPOLYLINE whose every +//! segment is a 110-degree arc -- whose length and area are checked here +//! against a computation that does not go through this crate. + +use uncad::Entity; +use uncad_export::geom; +use uncad_model::model::{LwPolylineEntity, PolylineVertex}; + +const EXAMPLE_2000_DWG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dwg" +); + +/// The small revision cloud in model space (the other one has 3635 +/// vertices), by the handle the file gives it. +const REVCLOUD: &str = "156"; + +fn lwpolyline<'a>(db: &'a uncad::CadDatabase, handle: &str) -> &'a LwPolylineEntity { + db.entities + .iter() + .find_map(|e| match e { + Entity::LwPolyline(p) + if p.common.source_handle.resolved().map(String::as_str) == Some(handle) => + { + Some(p) + } + _ => None, + }) + .unwrap_or_else(|| panic!("LWPOLYLINE {handle} is in the drawing")) +} + +#[test] +fn the_revision_clouds_length_is_the_sum_of_its_arcs() { + let db = uncad::parse(EXAMPLE_2000_DWG).expect("corpus file must parse"); + let cloud = lwpolyline(&db, REVCLOUD); + assert_eq!(cloud.vertices.len(), 25); + assert!(cloud.closed); + // Independently: chord c and bulge b give r = c (1 + b^2) / (4 |b|) and + // the arc length r * |4 atan b|, summed over the 25 closing segments. + let n = cloud.vertices.len(); + let mut expected = 0.0; + for i in 0..n { + let (a, b) = (cloud.vertices[i].point, cloud.vertices[(i + 1) % n].point); + let chord = (b.x - a.x).hypot(b.y - a.y); + let bulge = cloud.vertices[i].bulge.abs(); + let radius = chord * (1.0 + bulge * bulge) / (4.0 * bulge); + expected += radius * 4.0 * bulge.atan(); + } + let length = geom::polyline_length(&cloud.vertices, true); + assert!( + (length - expected).abs() < 1e-6 * expected, + "{length} vs {expected}" + ); + // Every segment is a 110-degree arc, so the ratio of arc length to + // chord is the same for all of them: theta / (2 sin(theta / 2)). + let chords: Vec = cloud + .vertices + .iter() + .map(|v| PolylineVertex::straight(v.point)) + .collect(); + let chord_length = geom::polyline_length(&chords, true); + let theta = 110f64.to_radians(); + let ratio = theta / (2.0 * (theta / 2.0).sin()); + assert!( + (length / chord_length - ratio).abs() < 1e-9, + "{length} / {chord_length} vs {ratio}" + ); + // A cloud bulging outward encloses more than its vertex polygon. + let area = geom::polyline_area(&cloud.vertices, true).expect("closed outline"); + let polygon = geom::polyline_area(&chords, true).expect("closed outline"); + assert!(area > polygon, "{area} vs polygon {polygon}"); +} From bc0c030e61bf234bcb73862117a8f7b2b8da15b3 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 19:17:19 +0900 Subject: [PATCH 40/54] Read a text's codes into the string the package's records carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model keeps every string as the file states it -- MTEXT format codes, `\S` stacks, `%%` symbols, `\U+` escapes -- and what a reader sees is a consumer's derivation, so the feature branch's decoder comes to the export as `text::decode_mtext` / `text::decode_text` (the feature's `text_plain`): paragraphs as newlines, a stack as `a/b` with a space after a digit so `3 1/2"` cannot read as thirty-one halves (`a^b` for a tolerance stack), `%%c` as `∅`, `%%nnn` by code, decorations and fractions recorded, and a character XML forbids replaced by U+FFFD. `\U+XXXX` is expanded here, which is where an R2007+ DXF's strings, which the model keeps raw, get their characters. The renderer has a decoder of its own for what it draws, and does a few things differently (`%%c` as U+2300, which the bundled font lacks; a tolerance stack with `/`). The records keep this one -- the package's own string for its string index and its dimension labels -- and the module says where the two part. The feature's text-box estimates are not ported: the renderer estimates and measures every text it draws now (`Scene::text_boxes`). Ported-from: 96abb6d, 888caf6, b182912 Co-Authored-By: Claude Opus 5.5 --- crates/uncad-export/Cargo.toml | 1 + crates/uncad-export/src/lib.rs | 6 +- crates/uncad-export/src/text.rs | 413 ++++++++++++++++++++++++++++++++ 3 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 crates/uncad-export/src/text.rs diff --git a/crates/uncad-export/Cargo.toml b/crates/uncad-export/Cargo.toml index b46bed9..7c530bc 100644 --- a/crates/uncad-export/Cargo.toml +++ b/crates/uncad-export/Cargo.toml @@ -27,3 +27,4 @@ uncad = { path = "../uncad", version = "0.2.0" } # must build from a standalone checkout. uncad-model = "0.1.0" iron-render-cad = "0.1.0" +serde = { version = "1.0.229", features = ["derive"] } diff --git a/crates/uncad-export/src/lib.rs b/crates/uncad-export/src/lib.rs index 6bbc08d..bda7ca4 100644 --- a/crates/uncad-export/src/lib.rs +++ b/crates/uncad-export/src/lib.rs @@ -7,9 +7,10 @@ //! and the exact numbers a picture cannot give. //! //! What is here so far: [`fonts`], the face the package draws and measures -//! text with, and [`geom`], the arithmetic its records carry -- a polyline's +//! text with; [`geom`], the arithmetic its records carry -- a polyline's //! length and area with its bulge arcs, whether an outline crosses itself, -//! the map from an entity's own plane to the world. +//! the map from an entity's own plane to the world; and [`text`], the +//! string a reader sees for a text the file stores with its codes. //! //! # Licence //! @@ -18,3 +19,4 @@ pub mod fonts; pub mod geom; +pub mod text; diff --git a/crates/uncad-export/src/text.rs b/crates/uncad-export/src/text.rs new file mode 100644 index 0000000..7b1f3ea --- /dev/null +++ b/crates/uncad-export/src/text.rs @@ -0,0 +1,413 @@ +//! The text a record carries: a TEXT, MTEXT, ATTRIB or TOLERANCE string, +//! or a dimension's label, with AutoCAD's inline codes read -- MTEXT's `\P`, +//! `\S` stacked fractions, `{...}` groups and `\A`/`\H`/`\f`-style format +//! codes, the `%%c` / `%%d` / `%%p` / `%%u` symbol and toggle codes TEXT, +//! ATTRIB and dimension labels use, and the `\U+XXXX` escapes both use. +//! +//! The model keeps every string as the file states it, codes and all; what +//! a reader sees is this consumer's derivation, so the package's records +//! carry both (`text` and, when it differs, `raw`), and its string index +//! keys on this one. Getting it right matters for numbers: +//! `3{\H0.7x;\S1#2;}"` is three and a half inches, and stripping the codes +//! turns it into `31/2"`. +//! +//! The renderer reads the same codes for what it draws, with a few choices +//! of its own: `%%c` is U+2300 in the picture (a glyph the bundled font does +//! not have) and the `∅` (U+2205) the font has here, and a tolerance stack +//! keeps its `^` here. The record is the reader's string; the picture is the +//! renderer's. +//! +//! Not attempted: `\M+nXXXX` multibyte escapes (LibreDWG expands them for +//! pre-R2007 files; in an R2007+ file they are left as written), `\p...;` +//! paragraph properties beyond dropping them, and any visual formatting. + +use serde::{Deserialize, Serialize}; + +/// A text decoration one of the toggle codes switched on somewhere in the +/// string (`\L`/`\l`, `%%u` underline; `\O`/`\o`, `%%o` overline; `\K`/`\k` +/// strike-through). Recorded per string, not per character. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Decoration { + Underline, + Overline, + Strikethrough, +} + +/// Which separator a `\S` stack used, i.e. how AutoCAD draws it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StackKind { + /// `\S1/2;` -- a horizontal fraction bar (`DIMFRAC` 0). + Horizontal, + /// `\S1#2;` -- a diagonal bar (`DIMFRAC` 1). + Diagonal, + /// `\S+0.1^-0.2;` -- one value over the other with no bar: a tolerance + /// stack, or a super/subscript when one side is empty. + Tolerance, +} + +/// One `\S` stack found in an MTEXT string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Fraction { + pub numerator: String, + pub denominator: String, + pub kind: StackKind, +} + +/// What a raw text string decodes to. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct DecodedText { + /// The readable text. Paragraph breaks (`\P`, `\X`, `\N`) are `\n`; + /// a stack `\S a#b;` reads as `a/b` (`a^b` for a tolerance stack), with a + /// space inserted when a digit precedes it so `3 1/2` cannot read as + /// thirty-one halves. + pub plain: String, + pub decorations: Vec, + pub fractions: Vec, +} + +/// Decodes an MTEXT string (also what a dimension's cached label holds). +pub fn decode_mtext(raw: &str) -> DecodedText { + decode(raw, true) +} + +/// Decodes a TEXT / ATTRIB / TOLERANCE string: only the `%%` codes and +/// `\U+XXXX` escapes apply, a backslash is otherwise a backslash. +pub fn decode_text(raw: &str) -> DecodedText { + decode(raw, false) +} + +fn decode(raw: &str, mtext: bool) -> DecodedText { + let chars: Vec = raw.chars().collect(); + let mut out = DecodedText::default(); + let mut plain = String::with_capacity(raw.len()); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == '\\' && i + 1 < chars.len() { + let code = chars[i + 1]; + // \U+XXXX is the one backslash escape TEXT shares with MTEXT. + if let Some((ch, len)) = unicode_escape(&chars[i..]) { + plain.push(ch); + i += len; + continue; + } + if !mtext { + plain.push(c); + i += 1; + continue; + } + match code { + 'P' | 'X' | 'N' => plain.push('\n'), + '~' => plain.push(' '), + '\\' | '{' | '}' => plain.push(code), + 'L' => add_decoration(&mut out, Decoration::Underline), + 'O' => add_decoration(&mut out, Decoration::Overline), + 'K' => add_decoration(&mut out, Decoration::Strikethrough), + 'l' | 'o' | 'k' => {} + 'S' => { + let end = chars[i + 2..] + .iter() + .position(|&ch| ch == ';') + .map(|p| i + 2 + p); + match end { + Some(end) => { + let body: String = chars[i + 2..end].iter().collect(); + push_stack(&mut out, &mut plain, &body); + i = end + 1; + continue; + } + // Unterminated: nothing sensible to do but keep it. + None => plain.push_str("\\S"), + } + } + // Format codes with a `;` terminator: alignment, colour, + // font, height, slant, tracking, width, paragraph properties. + 'A' | 'C' | 'c' | 'f' | 'F' | 'H' | 'Q' | 'T' | 'W' | 'p' => { + // No terminator: drop just the code letter. + if let Some(p) = chars[i + 2..].iter().position(|&ch| ch == ';') { + i += 2 + p + 1; + continue; + } + } + // Anything else: an unknown code, or a stray backslash in + // text that was never escaped. Keep the character. + other => plain.push(other), + } + i += 2; + continue; + } + if mtext && (c == '{' || c == '}') { + i += 1; + continue; + } + if c == '%' && i + 2 < chars.len() && chars[i + 1] == '%' { + if let Some((replacement, len)) = percent_code(&mut out, &chars[i..]) { + plain.push_str(&replacement); + i += len; + continue; + } + } + plain.push(c); + i += 1; + } + // A stray control byte in a label (or a `\U+0001` escape) must not + // reach the SVG: roxmltree rejects the whole document, and the PNG and + // the export package with it. U+FFFD marks where it was. + if plain.chars().any(is_xml_illegal) { + plain = plain + .chars() + .map(|c| if is_xml_illegal(c) { '\u{FFFD}' } else { c }) + .collect(); + } + out.plain = plain; + out +} + +/// Whether XML 1.0 forbids `c` in a document altogether: the C0 controls +/// other than tab, LF and CR, and the non-characters U+FFFE / U+FFFF. (A +/// lone surrogate cannot occur in a Rust `char`.) The renderer's SVG is +/// parsed by roxmltree, which refuses a document containing one. +pub(crate) fn is_xml_illegal(c: char) -> bool { + matches!( + c, + '\u{0}'..='\u{8}' | '\u{B}' | '\u{C}' | '\u{E}'..='\u{1F}' | '\u{FFFE}' | '\u{FFFF}' + ) +} + +fn add_decoration(out: &mut DecodedText, decoration: Decoration) { + if !out.decorations.contains(&decoration) { + out.decorations.push(decoration); + } +} + +/// `\U+XXXX` (four hex digits) at the start of `chars`, as the character and +/// the number of chars consumed. +fn unicode_escape(chars: &[char]) -> Option<(char, usize)> { + if chars.len() < 7 || chars[0] != '\\' || chars[1] != 'U' || chars[2] != '+' { + return None; + } + let hex: String = chars[3..7].iter().collect(); + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let code = u32::from_str_radix(&hex, 16).ok()?; + char::from_u32(code).map(|ch| (ch, 7)) +} + +/// The `%%` codes: `%%c` diameter, `%%d` degree, `%%p` plus-minus, `%%%` a +/// percent sign, `%%u`/`%%o` underline/overline toggles (recorded, nothing +/// emitted), `%%nnn` a character by code (Latin-1 for the upper half, which +/// matches the Western code pages these codes were written for; a control +/// code below 32 other than tab, LF and CR is not a code, since it could +/// only produce a character XML forbids). Returns the replacement and the +/// number of chars consumed, or `None` when the `%%` is not a code. +fn percent_code(out: &mut DecodedText, chars: &[char]) -> Option<(String, usize)> { + let code = chars.get(2)?; + match code.to_ascii_lowercase() { + 'c' => Some(("\u{2205}".to_string(), 3)), + 'd' => Some(("\u{00B0}".to_string(), 3)), + 'p' => Some(("\u{00B1}".to_string(), 3)), + '%' => Some(("%".to_string(), 3)), + 'u' => { + add_decoration(out, Decoration::Underline); + Some((String::new(), 3)) + } + 'o' => { + add_decoration(out, Decoration::Overline); + Some((String::new(), 3)) + } + _ if code.is_ascii_digit() => { + let digits: String = chars[2..].iter().take(3).collect(); + if digits.len() == 3 && digits.chars().all(|c| c.is_ascii_digit()) { + let n: u32 = digits.parse().ok()?; + let ch = if n < 256 { + let ch = char::from_u32(n)?; + if is_xml_illegal(ch) { + // `%%001`: left as written, like any other non-code. + return None; + } + ch + } else { + '\u{FFFD}' + }; + Some((ch.to_string(), 5)) + } else { + None + } + } + _ => None, + } +} + +/// Emits the plain form of a `\S` stack body (`1#2`, `1/2`, `+0.1^-0.2`, +/// `2^` ...) and records it. +fn push_stack(out: &mut DecodedText, plain: &mut String, body: &str) { + let (kind, sep) = if let Some(p) = body.find('^') { + (StackKind::Tolerance, p) + } else if let Some(p) = body.find('#') { + (StackKind::Diagonal, p) + } else if let Some(p) = body.find('/') { + (StackKind::Horizontal, p) + } else { + // No separator: AutoCAD shows the text unstacked. + plain.push_str(body); + return; + }; + let numerator = body[..sep].trim().to_string(); + let denominator = body[sep + 1..].trim().to_string(); + + // "3" immediately followed by "1/2" would read as thirty-one halves. + if plain.chars().last().is_some_and(|c| c.is_ascii_digit()) { + plain.push(' '); + } + match (numerator.is_empty(), denominator.is_empty()) { + (true, true) => {} + (false, true) => plain.push_str(&numerator), + (true, false) => plain.push_str(&denominator), + (false, false) => { + plain.push_str(&numerator); + plain.push(if kind == StackKind::Tolerance { + '^' + } else { + '/' + }); + plain.push_str(&denominator); + } + } + out.fractions.push(Fraction { + numerator, + denominator, + kind, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plain_mtext(raw: &str) -> String { + decode_mtext(raw).plain + } + + #[test] + fn dimension_labels_keep_their_numbers_readable() { + // The cases measured on the sample drawings. + assert_eq!(plain_mtext(r#"\A1;7'-4""#), r#"7'-4""#); + assert_eq!(plain_mtext(r#"3{\H0.7x;\S1#2;}""#), r#"3 1/2""#); + assert_eq!(plain_mtext(r#"\A1;{\H0.750000x;\S1#2;}""#), r#"1/2""#); + assert_eq!( + plain_mtext(r#"\A1;1'-1{\H0.750000x;\S1#8;}""#), + r#"1'-1 1/8""# + ); + assert_eq!(plain_mtext(r#"\A1;2"%%C"#), "2\"\u{2205}"); + assert_eq!(plain_mtext(r#"\A1;R10'-0""#), r#"R10'-0""#); + } + + #[test] + fn every_stack_separator_is_understood() { + let d = decode_mtext(r"\S1/2;"); + assert_eq!(d.plain, "1/2"); + assert_eq!(d.fractions[0].kind, StackKind::Horizontal); + let d = decode_mtext(r"\S1#2;"); + assert_eq!(d.fractions[0].kind, StackKind::Diagonal); + let d = decode_mtext(r"\S+0.1^-0.2;"); + assert_eq!(d.plain, "+0.1^-0.2"); + assert_eq!( + d.fractions, + vec![Fraction { + numerator: "+0.1".into(), + denominator: "-0.2".into(), + kind: StackKind::Tolerance + }] + ); + // Super/subscripts are one-sided stacks. + assert_eq!(plain_mtext(r"m\S2^;"), "m2"); + assert_eq!(plain_mtext(r"H\S^2;"), "H2"); + assert_eq!(plain_mtext(r"\Sabc;"), "abc"); + } + + #[test] + fn paragraphs_groups_and_format_codes() { + assert_eq!(plain_mtext(r"A\PB\XC\ND"), "A\nB\nC\nD"); + assert_eq!(plain_mtext(r"\~x"), " x"); + assert_eq!( + plain_mtext(r"\pi102.25;{\f@Arial Unicode MS|b1|i0|c0|p34;A T M O S}"), + "A T M O S" + ); + assert_eq!(plain_mtext(r"\C1;red\C256;bylayer"), "redbylayer"); + assert_eq!(plain_mtext(r"\fArial|b0|i0;\W0.8;\Q10;\T1.2;\H2.5;x"), "x"); + assert_eq!(plain_mtext(r"a\\b\{c\}"), r"a\b{c}"); + assert_eq!(plain_mtext(r"\Lunder\l plain"), "under plain"); + let d = decode_mtext(r"\Lunder\l \Oover\o \Kstrike\k"); + assert_eq!(d.plain, "under over strike"); + assert_eq!( + d.decorations, + vec![ + Decoration::Underline, + Decoration::Overline, + Decoration::Strikethrough + ] + ); + // An unterminated format code drops only its letter. + assert_eq!(plain_mtext(r"\Hoops"), "oops"); + } + + #[test] + fn percent_codes_and_unicode_escapes() { + assert_eq!(decode_text("%%c50").plain, "\u{2205}50"); + assert_eq!(decode_text("108%%d").plain, "108\u{00B0}"); + assert_eq!(decode_text("%%P0.5").plain, "\u{00B1}0.5"); + assert_eq!(decode_text("50%%%").plain, "50%"); + let d = decode_text("%%UBOOK RETURN%%U"); + assert_eq!(d.plain, "BOOK RETURN"); + assert_eq!(d.decorations, vec![Decoration::Underline]); + assert_eq!(decode_text("%%176").plain, "\u{00B0}"); + assert_eq!(decode_text("%%065").plain, "A"); + assert_eq!(decode_text(r"\U+00B1 3").plain, "\u{00B1} 3"); + assert_eq!(decode_mtext(r"\U+2205 50").plain, "\u{2205} 50"); + // Not codes: a lone percent, a bad escape. + assert_eq!(decode_text("50%").plain, "50%"); + assert_eq!(decode_text("a%%zb").plain, "a%%zb"); + assert_eq!(decode_text(r"\U+ZZZZ").plain, r"\U+ZZZZ"); + } + + #[test] + fn text_strings_keep_backslashes_and_braces() { + assert_eq!(decode_text(r"C:\Temp\{x}").plain, r"C:\Temp\{x}"); + assert_eq!( + decode_text(r"\P is not a paragraph here").plain, + r"\P is not a paragraph here" + ); + assert_eq!(decode_text("").plain, ""); + assert_eq!(decode_mtext("trailing\\").plain, "trailing\\"); + } + + #[test] + fn xml_illegal_characters_never_survive_decoding() { + // A raw control byte in the string, and one written as a \U+ escape, + // each become one U+FFFD; tab, LF and CR (the C0 controls XML 1.0 + // allows) are kept. + assert_eq!(decode_text("ZE\u{1}\u{B}RO").plain, "ZE\u{FFFD}\u{FFFD}RO"); + assert_eq!(decode_mtext(r"A\U+0001B").plain, "A\u{FFFD}B"); + assert_eq!(decode_text("a\tb\nc\r").plain, "a\tb\nc\r"); + assert_eq!(decode_text("\u{FFFE}\u{FFFF}").plain, "\u{FFFD}\u{FFFD}"); + // %%nnn below 32 is not a code (it could only yield a forbidden + // character), so it stays as written -- while %%009 (tab) and the + // printable %%065 still decode. + assert_eq!(decode_text("ZE%%001RO").plain, "ZE%%001RO"); + assert_eq!(decode_text("%%031").plain, "%%031"); + assert_eq!(decode_text("a%%009b").plain, "a\tb"); + assert_eq!(decode_text("%%065").plain, "A"); + assert!(!decode_text("ZE%%001RO").plain.chars().any(is_xml_illegal)); + } + + #[test] + fn korean_text_passes_through() { + assert_eq!( + plain_mtext("방 101\\P면적 32.5\u{33A1}"), + "방 101\n면적 32.5\u{33A1}" + ); + } +} From ff2d330b6c08bad4b3ad06f2d298a40b3bed997b Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 19:20:37 +0900 Subject: [PATCH 41/54] Derive a dimension's value, its trust and its label in the export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model keeps what a DIMENSION's file states -- DXF 42 as written (radians for an angle; 0 and -1 read as not stated), the points by DXF group (10, 13-16), the text override folded to one value per meaning, the style's name -- and says nothing about which number to believe or what the label reads. That is the package's, and the feature branch's rules come here as `dimension`: - `measurement_from_points` on the model's points, per kind: a rotated dimension 13-14 along DXF 50, aligned 13-14, a three-point angle the sector at 15 between 13 and 14 holding 10, a two-line angle between 13-14 and 15-10 holding 16, radius 10-15, diameter 10-15, an ordinate 13's offset from 10 along its stated axis, an arc length about 15. Angles in degrees. - `usable_stored_measurement`: the file's value unless it is not finite, -1, impossible for the kind (<= 0 except an ordinate) or more than 1 % from the points (the corpus's worst honest disagreement is 2.3e-7), in degrees for an angle -- the model keeps the file's value (owner decision #20); this decides the package's confidence in it. - `EffectiveStyle::resolve` over the model's all-`Option` DIMSTYLE: a stated value is taken at its word (DIMDEC 0 included), DIMLUNIT and DIMLFAC keep their > 0 guard, the husk a file leaves when it only names a style (no text height and no arrow size) says nothing, the header's `$DIM*` (`DimDefaults::from_header`, since `uncad::Header` is outside the model) stand in, then the format's documented defaults; DIMDEC/DIMADEC are capped at 8, and DIMADEC -1 means "as DIMDEC". - `format_measurement`, `display_text` and `cached_label`: suppressed, override (`<>` replaced), the cached *D block's texts, then formatted. New over the feature (#21): DIMPOST (prefix/suffix, `<>` for the value), DIMRND rounding and DIMZIN's leading-zero bit, which the model now carries. - `tolerance_text_height`: the renderer's rule (stated, the style's text height, 1), so a TOLERANCE record's height is the one it is drawn at. Tests: the feature's unit tests on the model's shapes, and on real files example_2000.dwg's ten dimensions against their points and AutoCAD's labels ("1504,68", "108°"), the R13/R14 twins recomputed to example_2007's stored values, 37E's believable disagreement kept, and dimlfac12's "120". Ported-from: 74bb835, 9c05987, 837fb7d, 80009b4 Co-Authored-By: Claude Opus 5.5 --- crates/uncad-export/src/dimension.rs | 1041 +++++++++++++++++++++++ crates/uncad-export/src/lib.rs | 7 +- crates/uncad-export/tests/dimensions.rs | 195 +++++ 3 files changed, 1241 insertions(+), 2 deletions(-) create mode 100644 crates/uncad-export/src/dimension.rs create mode 100644 crates/uncad-export/tests/dimensions.rs diff --git a/crates/uncad-export/src/dimension.rs b/crates/uncad-export/src/dimension.rs new file mode 100644 index 0000000..eecb7b7 --- /dev/null +++ b/crates/uncad-export/src/dimension.rs @@ -0,0 +1,1041 @@ +//! Dimension values: the measurement a DIMENSION stores, the same quantity +//! recomputed from its definition points, whether the stored one can be +//! believed, and the text the drawing shows for it. +//! +//! The model carries what the file states: `measurement` (DXF 42 as +//! written, radians for an angular kind; `None` for the 0 and -1 writers +//! leave when they did not measure), the definition points by DXF group, +//! the text override, the style's name, and the anonymous `*D` block the +//! drawn dimension is cached in. Everything a reader wants on top of that is +//! derived here, in this order of trust for the label: a text override that +//! is only whitespace means "no text"; an override wins over the cache +//! (`<>` in it standing for the measurement); the cache over a value this +//! crate formats itself. +//! +//! The formatter is deliberately basic -- decimal, architectural and +//! fractional units at the style's precision, `DIMRND` rounding, `DIMZIN`'s +//! leading- and trailing-zero suppression, `DIMPOST`'s prefix and suffix, +//! and decimal degrees for angles; the cached label is the value of record +//! whenever the file has one. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use uncad_model::model::{ + DimensionEntity, DimensionKind, Entity, OrdinateAxis, Point3D, TextOverride, ToleranceEntity, +}; +use uncad_model::tables::{BlockRecord, DimStyleRecord, LinearUnitFormat}; +use uncad_model::Tables; + +use crate::text::decode_mtext; + +/// The header's dimension variables, as far as the file states them: what a +/// dimension falls back on when its own DIMSTYLE says nothing (see +/// [`EffectiveStyle::resolve`]). `None` is "not stated". +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DimDefaults { + pub dimlfac: Option, + pub dimdec: Option, + pub dimlunit: Option, + pub dimzin: Option, + pub dimadec: Option, + pub dimrnd: Option, + pub dimpost: Option, +} + +impl DimDefaults { + /// The variables `header` states. + pub fn from_header(header: &uncad::Header) -> DimDefaults { + DimDefaults { + dimlfac: header.dimlfac, + dimdec: header.dimdec, + dimlunit: header.dimlunit, + dimzin: header.dimzin, + dimadec: header.dimadec, + dimrnd: header.dimrnd, + dimpost: header.dimpost.clone(), + } + } +} + +/// The dimension-style variables the label depends on, resolved for one +/// dimension: its own DIMSTYLE's when the file wrote one, the header's where +/// it did not, and the format's documented default where neither says +/// anything (`DIMDEC` 4, `DIMLUNIT` 2 decimal, `DIMZIN` 0, `DIMADEC` 0, +/// `DIMLFAC` 1, no rounding, no `DIMPOST`). +#[derive(Debug, Clone, PartialEq)] +pub struct EffectiveStyle { + pub dimlfac: f64, + /// Decimal places, at most 8 (see [`EffectiveStyle::resolve`]). + pub dimdec: u16, + /// 1 scientific, 2 decimal, 3 engineering, 4 architectural, 5 + /// fractional, 6 Windows desktop. + pub dimlunit: u16, + pub dimzin: u16, + /// Decimal places of an angle, at most 8. + pub dimadec: u16, + /// Round every linear value to a multiple of this; 0 is no rounding. + pub dimrnd: f64, + /// The prefix and suffix around a linear value: `<>` stands for the + /// value; without one, the whole string is a suffix. + pub dimpost: String, +} + +/// The most decimal places a label is written with. Both `DIMDEC` and +/// `DIMADEC` hold 0..=8 per the DXF reference, but the fields are integers +/// a corrupt record can fill with anything: `format!` would honour 65535 +/// literally, once per dimension in the drawing. +const MAX_DECIMALS: u16 = 8; + +/// Whether a DIMSTYLE record carries the style's own settings, or is the +/// husk a file leaves behind when it names a style without writing its +/// body. +/// +/// The distinction is what tells "the file said nothing" from "the file +/// said zero", and nothing in the record's numbers says it directly: +/// LibreDWG's DXF reader creates the record, presets the few fields it has +/// defaults for (`DIMSCALE = DIMLFAC = DIMTFAC = 1`, `DIMLUNIT = 2`) and +/// leaves every other field at zero, exactly as if the file had written 0. +/// The tell is the sizing pair: no usable style has both a text height and +/// an arrow size of zero, since a zero-height dimension text draws nothing. +/// `sample_2000.dxf` is the proof -- it names the same `Standard` style +/// that `sample_2000.dwg` writes in full (DIMDEC 4, DIMTXT 0.18, +/// DIMASZ 0.18) and leaves all three at 0 -- and every one of the 40-odd +/// real styles in the corpus has DIMTXT and DIMASZ above zero. +fn carries_a_body(style: &DimStyleRecord) -> bool { + style.text_height.is_some_and(|h| h > 0.0) || style.arrow_size.is_some_and(|a| a > 0.0) +} + +/// A decimal-place count from a file's integer field: negative is not a +/// count (DIMADEC's -1 is "use DIMDEC"; the caller handles that), and more +/// than [`MAX_DECIMALS`] is capped. +fn decimals(v: i64) -> Option { + (v >= 0).then(|| v.min(i64::from(MAX_DECIMALS)) as u16) +} + +fn unit_code(format: LinearUnitFormat) -> u16 { + match format { + LinearUnitFormat::Scientific => 1, + LinearUnitFormat::Decimal => 2, + LinearUnitFormat::Engineering => 3, + LinearUnitFormat::Architectural => 4, + LinearUnitFormat::Fractional => 5, + LinearUnitFormat::WindowsDesktop => 6, + } +} + +impl EffectiveStyle { + /// The values that govern a dimension's label: its own DIMSTYLE's where + /// the file wrote one, the header's otherwise. + /// + /// A style the file wrote is taken at its word, 0 included: DIMDEC 0 + /// (whole millimetres) and DIMADEC 0 (whole degrees) are the ordinary + /// metric settings, and treating them as "unset" gave a style that asks + /// for "50" the header's precision instead -- "50.0000" against a + /// picture, a cached label and a `strings.json` key all reading "50". + /// DIMLUNIT and DIMLFAC keep a `> 0` guard because 0 is not a value + /// either can hold: the unit codes run 1..=6, and a linear factor of 0 + /// would zero every label. A style that is only the husk a file leaves + /// when it names a style without writing it (see `carries_a_body`) + /// says nothing, and the header stands in. DIMADEC -1 is AutoCAD's "as + /// DIMDEC". + pub fn resolve(style: Option<&DimStyleRecord>, header: &DimDefaults) -> EffectiveStyle { + let style = style.filter(|s| carries_a_body(s)); + let dimlfac = style + .and_then(|s| s.length_factor) + .filter(|v| *v > 0.0 && v.is_finite()) + .or(header.dimlfac.filter(|v| *v > 0.0 && v.is_finite())) + .unwrap_or(1.0); + let dimdec = match style.and_then(|s| s.decimal_places) { + Some(v) => decimals(i64::from(v)), + None => header.dimdec.map(|v| v.min(MAX_DECIMALS)), + } + .unwrap_or(4); + let dimlunit = style + .and_then(|s| s.linear_unit_format) + .map(unit_code) + .or(header.dimlunit.filter(|v| *v > 0)) + .unwrap_or(2); + let dimzin = match style.and_then(|s| s.zero_suppression) { + Some(v) => u16::try_from(v).unwrap_or(0), + None => header.dimzin.unwrap_or(0), + }; + let dimadec = match style.and_then(|s| s.angular_decimal_places) { + Some(v) if v < 0 => Some(dimdec), + Some(v) => decimals(i64::from(v)), + None => header.dimadec.map(|v| v.min(MAX_DECIMALS)), + } + .unwrap_or(0); + let dimrnd = style + .and_then(|s| s.rounding) + .or(header.dimrnd) + .filter(|v| *v > 0.0 && v.is_finite()) + .unwrap_or(0.0); + let dimpost = style + .and_then(|s| s.post.clone()) + .or_else(|| header.dimpost.clone()) + .unwrap_or_default(); + EffectiveStyle { + dimlfac, + dimdec, + dimlunit, + dimzin, + dimadec, + dimrnd, + dimpost, + } + } +} + +/// Whether a dimension of this kind measures an angle. +pub fn is_angular(kind: Option) -> bool { + matches!( + kind, + Some(DimensionKind::Angular2Line | DimensionKind::Angular3Point) + ) +} + +/// How far a stored measurement may sit from the one the definition points +/// give before it is treated as not a measurement of this dimension at all. +/// +/// Derived from the corpus, not chosen: across all 26 `example_*` / +/// `sample_*` files and the nine `AutoCADSamples*.dwg`, every DIMENSION +/// whose file wrote a real `act_measurement` agrees with +/// [`measurement_from_points`] to better than 2.3e-7 relative (one +/// dimension in `example_20xx` differs at all; the rest are exact). The +/// values this rejects are off by 100 %. 1 % leaves four orders of +/// magnitude of headroom over the worst honest disagreement. +const MEASUREMENT_DISAGREEMENT: f64 = 0.01; + +/// The measurement a DIMENSION stores when it can be believed, in the unit +/// the package reports: degrees for an angular kind, drawing units (before +/// `DIMLFAC`) otherwise. `None` means "ask the definition points instead". +/// +/// The model already reads the writers' "not measured" values -- 0, and +/// -1 -- as not stated. What is left is the file's value, which is refused +/// here when +/// - it is not finite, or is exactly -1 (a model built another way may +/// still carry it); +/// - it is negative or zero and the kind's measurement cannot be (every +/// kind but ORDINATE, which is a signed offset from a datum); +/// - it disagrees with the definition points by more than +/// [`MEASUREMENT_DISAGREEMENT`]. Magnitudes are compared, so a sign +/// convention reconstructed differently (ORDINATE's datum axis) does not +/// throw a good value away, and a computed value of 0 is no evidence +/// against a stored one. +pub fn usable_stored_measurement( + stored: Option, + kind: Option, + from_points: Option, +) -> Option { + let stored = stored.filter(|v| v.is_finite())?; + if stored == -1.0 { + return None; + } + let signed = kind == Some(DimensionKind::Ordinate); + let value = if is_angular(kind) { + stored.to_degrees() + } else { + stored + }; + if !signed && value <= 0.0 { + return None; + } + if let Some(computed) = from_points { + let (a, b) = (value.abs(), computed.abs()); + if b != 0.0 && (a - b).abs() > MEASUREMENT_DISAGREEMENT * a.max(b) { + return None; + } + } + Some(value) +} + +/// The quantity a dimension's definition points measure, with the meaning +/// [`usable_stored_measurement`] gives a stored one: drawing units for +/// linear kinds (before `DIMLFAC`), degrees for angular kinds, the arc +/// length for an arc-length dimension, a signed offset for an ordinate. +/// `None` for an unknown kind, or when a point the kind needs is missing. +/// +/// The points are the model's, by DXF group: 13 and 14 (`extension1`, +/// `extension2`), 15 (`radial`), 16 (`arc`) and 10 (`definition_point`). +/// Per kind: a rotated dimension measures 13-14 along its rotation (DXF +/// 50); an aligned one the distance 13-14; a three-point angle the sector +/// at 15 between 13 and 14 that holds 10; a two-line angle the sector +/// between the line 13-14 and the line 15-10 that holds 16; a radius the +/// distance 10-15 (the centre to the curve); a diameter the chord 10-15; an +/// ordinate the feature 13's x or y offset from the datum 10; an arc length +/// the arc about 15 from 13 to 14 through 10. +pub fn measurement_from_points(d: &DimensionEntity) -> Option { + let p = &d.points; + let def = d.definition_point; + match d.kind? { + DimensionKind::Rotated => { + let (a, b) = (p.extension1?, p.extension2?); + let (dx, dy) = (b.x - a.x, b.y - a.y); + Some((dx * d.rotation.cos() + dy * d.rotation.sin()).abs()) + } + DimensionKind::Aligned => Some(distance(&p.extension1?, &p.extension2?)), + DimensionKind::Angular3Point => { + let (center, a, b) = (p.radial?, p.extension1?, p.extension2?); + Some(sector_degrees( + ¢er, + &[angle_of(¢er, &a), angle_of(¢er, &b)], + &def?, + )) + } + DimensionKind::Angular2Line => { + let (l1s, l1e, l2s, l2e) = (p.extension1?, p.extension2?, p.radial?, def?); + let vertex = intersection(&l1s, &l1e, &l2s, &l2e)?; + let a1 = (l1e.y - l1s.y).atan2(l1e.x - l1s.x); + let a2 = (l2e.y - l2s.y).atan2(l2e.x - l2s.x); + // Each line contributes two rays from the vertex; the arc point + // says which of the four sectors was dimensioned. + let pi = std::f64::consts::PI; + Some(sector_degrees( + &vertex, + &[a1, a1 + pi, a2, a2 + pi], + &p.arc?, + )) + } + DimensionKind::Radius => Some(distance(&def?, &p.radial?)), + DimensionKind::Diameter => Some(distance(&def?, &p.radial?)), + DimensionKind::Ordinate => { + let (feature, datum) = (p.extension1?, def?); + Some(match d.ordinate_axis? { + OrdinateAxis::X => feature.x - datum.x, + OrdinateAxis::Y => feature.y - datum.y, + }) + } + DimensionKind::ArcLength => { + let (center, a, b) = (p.radial?, p.extension1?, p.extension2?); + let radius = distance(¢er, &a); + let sweep = sector_degrees( + ¢er, + &[angle_of(¢er, &a), angle_of(¢er, &b)], + &def?, + ); + Some(radius * sweep.to_radians()) + } + } +} + +fn distance(a: &Point3D, b: &Point3D) -> f64 { + ((b.x - a.x).powi(2) + (b.y - a.y).powi(2) + (b.z - a.z).powi(2)).sqrt() +} + +fn angle_of(from: &Point3D, to: &Point3D) -> f64 { + (to.y - from.y).atan2(to.x - from.x) +} + +/// Where two lines (each given by two points) cross, in the XY plane. +fn intersection(p1: &Point3D, p2: &Point3D, p3: &Point3D, p4: &Point3D) -> Option { + let (d1x, d1y) = (p2.x - p1.x, p2.y - p1.y); + let (d2x, d2y) = (p4.x - p3.x, p4.y - p3.y); + let cross = d1x * d2y - d1y * d2x; + if cross.abs() < 1e-12 { + return None; + } + let t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / cross; + Some(Point3D { + x: p1.x + t * d1x, + y: p1.y + t * d1y, + z: p1.z, + }) +} + +/// The angular size, in degrees, of the sector between two consecutive rays +/// (angles in radians, from `vertex`) that contains the direction of +/// `probe` -- the arc point a dimension was drawn through, which is how +/// AutoCAD records which of the possible sectors was measured. +fn sector_degrees(vertex: &Point3D, rays: &[f64], probe: &Point3D) -> f64 { + let tau = std::f64::consts::TAU; + let norm = |a: f64| a.rem_euclid(tau); + let mut sorted: Vec = rays.iter().map(|&a| norm(a)).collect(); + sorted.sort_by(f64::total_cmp); + sorted.dedup_by(|a, b| (*a - *b).abs() < 1e-12); + if sorted.len() < 2 { + return 0.0; + } + let target = norm(angle_of(vertex, probe)); + for i in 0..sorted.len() { + let start = sorted[i]; + let end = if i + 1 < sorted.len() { + sorted[i + 1] + } else { + sorted[0] + tau + }; + let t = if target < start { target + tau } else { target }; + if t >= start && t <= end { + return (end - start).to_degrees(); + } + } + // Unreachable in exact arithmetic; the last sector wraps around. + (sorted[0] + tau - sorted[sorted.len() - 1]).to_degrees() +} + +/// Formats a measurement the way a basic dimension style shows it: a linear +/// value times `DIMLFAC`, rounded to `DIMRND`, in decimal (`DIMLUNIT` 2 and +/// every code this does not handle), architectural feet-inches (4) or +/// fractional inches (5) at `DIMDEC` precision, `DIMZIN`'s leading (bit 4) +/// and trailing (bit 8) zeros dropped from a decimal, and `DIMPOST` +/// around it; an angle as decimal degrees at `DIMADEC` precision with a +/// degree sign. +pub fn format_measurement(value: f64, angular: bool, style: &EffectiveStyle) -> String { + if angular { + return format!("{:.*}\u{00B0}", usize::from(style.dimadec), value); + } + let mut scaled = value * style.dimlfac; + if style.dimrnd > 0.0 { + scaled = (scaled / style.dimrnd).round() * style.dimrnd; + } + let text = match style.dimlunit { + 4 => format_architectural(scaled, style.dimdec), + 5 => format_fractional(scaled, style.dimdec), + _ => { + let mut text = format!("{:.*}", usize::from(style.dimdec), scaled); + if style.dimzin & 8 != 0 && text.contains('.') { + text = text.trim_end_matches('0').trim_end_matches('.').to_string(); + } + if style.dimzin & 4 != 0 { + if let Some(rest) = text.strip_prefix("0.") { + text = format!(".{rest}"); + } else if let Some(rest) = text.strip_prefix("-0.") { + text = format!("-.{rest}"); + } + } + text + } + }; + with_post(&text, &style.dimpost) +} + +/// `DIMPOST` around a formatted value: `<>` in it stands for the value; +/// without one, the whole string follows the value. +fn with_post(value: &str, post: &str) -> String { + if post.is_empty() { + value.to_string() + } else if post.contains("<>") { + post.replacen("<>", value, 1) + } else { + format!("{value}{post}") + } +} + +/// `7'-4 1/2"`: whole feet, whole inches, and a fraction to the nearest +/// `1/2^DIMDEC` inch (for architectural units `DIMDEC` is that exponent). +fn format_architectural(inches: f64, dimdec: u16) -> String { + let sign = if inches < 0.0 { "-" } else { "" }; + let (whole, fraction) = split_fraction(inches.abs(), dimdec); + let feet = whole / 12; + let rest = whole % 12; + let mut text = format!("{sign}{feet}'-{rest}"); + if let Some(fraction) = fraction { + text.push(' '); + text.push_str(&fraction); + } + text.push('"'); + text +} + +fn format_fractional(value: f64, dimdec: u16) -> String { + let sign = if value < 0.0 { "-" } else { "" }; + let (whole, fraction) = split_fraction(value.abs(), dimdec); + match fraction { + Some(fraction) if whole == 0 => format!("{sign}{fraction}"), + Some(fraction) => format!("{sign}{whole} {fraction}"), + None => format!("{sign}{whole}"), + } +} + +/// Rounds `value` to the nearest `1/2^dimdec` and splits it into a whole +/// part and a reduced fraction (`None` when there is none). +fn split_fraction(value: f64, dimdec: u16) -> (u64, Option) { + let denominator: u64 = 1 << dimdec.min(MAX_DECIMALS); + let total = (value * denominator as f64).round() as u64; + let whole = total / denominator; + let mut numerator = total % denominator; + if numerator == 0 { + return (whole, None); + } + let mut den = denominator; + while numerator.is_multiple_of(2) { + numerator /= 2; + den /= 2; + } + (whole, Some(format!("{numerator}/{den}"))) +} + +/// Where a dimension's label came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DisplaySource { + /// Nothing to show: no override, no cached label, no measurement. + None, + /// The text override (DXF 1), with `<>` replaced by the formatted + /// measurement. + UserText, + /// The TEXT/MTEXT inside the cached `*D` block: what the drawing shows. + CachedBlock, + /// This crate formatted the measurement itself (basic rules only). + Formatted, + /// The override is whitespace: AutoCAD's "no text" convention. + Suppressed, +} + +/// A dimension's label, as a reader sees it and as the file wrote it. +#[derive(Debug, Clone, PartialEq)] +pub struct DisplayText { + /// With the codes read (see [`crate::text`]). + pub text: String, + /// As the file wrote it: the override, or the cached block's strings; + /// empty for a label this crate formatted. + pub raw: String, + pub source: DisplaySource, +} + +/// The text inside a dimension's cached block: `(raw, plain)`, the block's +/// TEXT and MTEXT entities in order, joined by a space; `None` when it has +/// none, or only whitespace. +pub fn cached_label(block: &BlockRecord) -> Option<(String, String)> { + let mut raw = Vec::new(); + let mut plain = Vec::new(); + for entity in &block.entities { + match entity { + Entity::Text(t) => { + raw.push(t.text.clone()); + plain.push(crate::text::decode_text(&t.text).plain); + } + Entity::MText(m) => { + raw.push(m.text.clone()); + plain.push(decode_mtext(&m.text).plain); + } + _ => {} + } + } + let plain = plain.join(" "); + (!plain.trim().is_empty()).then(|| (raw.join(" "), plain)) +} + +/// The label of `d`, by the precedence in the module docs. `value` is the +/// measurement to format (see [`usable_stored_measurement`] and +/// [`measurement_from_points`]), `cached` its block's label. +pub fn display_text( + d: &DimensionEntity, + style: &EffectiveStyle, + value: Option, + cached: Option<&(String, String)>, +) -> DisplayText { + let formatted = value.map(|v| format_measurement(v, is_angular(d.kind), style)); + match &d.text_override { + TextOverride::Suppressed => DisplayText { + text: String::new(), + raw: " ".to_string(), + source: DisplaySource::Suppressed, + }, + TextOverride::Literal(s) if s.trim().is_empty() => DisplayText { + text: String::new(), + raw: s.clone(), + source: DisplaySource::Suppressed, + }, + TextOverride::Literal(s) => DisplayText { + text: decode_mtext(&s.replace("<>", formatted.as_deref().unwrap_or(""))).plain, + raw: s.clone(), + source: DisplaySource::UserText, + }, + TextOverride::Measured => match (cached, formatted) { + (Some((raw, plain)), _) => DisplayText { + text: plain.clone(), + raw: raw.clone(), + source: DisplaySource::CachedBlock, + }, + (None, Some(text)) => DisplayText { + text, + raw: String::new(), + source: DisplaySource::Formatted, + }, + (None, None) => DisplayText { + text: String::new(), + raw: String::new(), + source: DisplaySource::None, + }, + }, + } +} + +/// The cached label of every block that has one, by block name -- what +/// [`display_text`] looks a dimension's `block_name` up in. +pub fn cached_labels(tables: &Tables) -> BTreeMap { + tables + .block_records + .iter() + .filter_map(|(name, block)| cached_label(block).map(|label| (name.clone(), label))) + .collect() +} + +/// The height a TOLERANCE's text is drawn at: the height the entity states +/// when positive (LibreDWG decodes one for R13/R14 files only), else its +/// DIMSTYLE's text height, else 1 -- the renderer's rule, so the record and +/// the picture agree. +pub fn tolerance_text_height(t: &ToleranceEntity, tables: &Tables) -> f64 { + let positive = |h: f64| (h.is_finite() && h > 0.0).then_some(h); + t.text_height + .and_then(positive) + .or_else(|| { + t.style_name + .resolved() + .and_then(|name| tables.dim_styles.get(name)) + .and_then(|style| style.text_height) + .and_then(positive) + }) + .unwrap_or(1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use uncad_model::model::{Confidence, DimensionPoints, EntityCommon, EntityId, Origin, Ref}; + use uncad_model::Point2D; + + fn p(x: f64, y: f64) -> Point3D { + Point3D { x, y, z: 0.0 } + } + + fn style() -> EffectiveStyle { + EffectiveStyle { + dimlfac: 1.0, + dimdec: 2, + dimlunit: 2, + dimzin: 0, + dimadec: 0, + dimrnd: 0.0, + dimpost: String::new(), + } + } + + fn dim(kind: DimensionKind, def: Option, points: DimensionPoints) -> DimensionEntity { + DimensionEntity { + common: EntityCommon { + id: EntityId::new(1), + origin: Origin::Vector, + confidence: Confidence::High, + source_handle: Ref::Resolved("1".into()), + layer: Ref::Resolved("0".into()), + color_index: 256, + true_color: None, + invisible: false, + }, + block_name: Ref::Resolved("*D1".into()), + kind: Some(kind), + measurement: None, + text_override: TextOverride::Measured, + definition_point: def, + text_midpoint: Point2D { x: 5.0, y: 2.0 }, + points, + rotation: 0.0, + text_rotation: 0.0, + style_name: Ref::Resolved("STANDARD".into()), + ordinate_axis: None, + } + } + + fn pts( + e1: Option, + e2: Option, + radial: Option, + arc: Option, + ) -> DimensionPoints { + DimensionPoints { + extension1: e1, + extension2: e2, + radial, + arc, + } + } + + #[test] + fn a_rotated_dimension_projects_on_its_direction_and_an_aligned_one_does_not() { + let mut d = dim( + DimensionKind::Rotated, + Some(p(0.0, 0.0)), + pts(Some(p(0.0, 0.0)), Some(p(3.0, 4.0)), None, None), + ); + assert_eq!(measurement_from_points(&d), Some(3.0)); + d.rotation = std::f64::consts::FRAC_PI_2; + assert!((measurement_from_points(&d).unwrap() - 4.0).abs() < 1e-12); + d.kind = Some(DimensionKind::Aligned); + assert_eq!(measurement_from_points(&d), Some(5.0)); + // A point the kind needs is missing: nothing to compute. + d.points.extension2 = None; + assert_eq!(measurement_from_points(&d), None); + d.kind = None; + assert_eq!(measurement_from_points(&d), None); + } + + #[test] + fn radius_and_diameter_are_the_chord_distances() { + // RADIUS: group 10 is the centre, 15 the point on the curve. + let radius = dim( + DimensionKind::Radius, + Some(p(0.0, 0.0)), + pts(None, None, Some(p(0.0, 7.0)), None), + ); + assert_eq!(measurement_from_points(&radius), Some(7.0)); + // DIAMETER: 10 and 15 are the two ends of the chord. + let diameter = dim( + DimensionKind::Diameter, + Some(p(-5.0, 0.0)), + pts(None, None, Some(p(5.0, 0.0)), None), + ); + assert_eq!(measurement_from_points(&diameter), Some(10.0)); + } + + #[test] + fn angular_sectors_follow_the_arc_point() { + // Three-point: vertex 15, ends 13 and 14, the arc through 10. + let mut three_point = dim( + DimensionKind::Angular3Point, + Some(p(5.0, 5.0)), + pts( + Some(p(10.0, 0.0)), + Some(p(0.0, 10.0)), + Some(p(0.0, 0.0)), + None, + ), + ); + let inside = measurement_from_points(&three_point).unwrap(); + assert!((inside - 90.0).abs() < 1e-9, "{inside}"); + three_point.definition_point = Some(p(-5.0, -5.0)); + let reflex = measurement_from_points(&three_point).unwrap(); + assert!((reflex - 270.0).abs() < 1e-9, "{reflex}"); + + // Two lines crossing at 108 degrees (a 72-degree acute pair): the + // first line 13-14, the second 15-10, the arc point 16. + let a = 108f64.to_radians(); + let two_line = |probe: Point3D| { + dim( + DimensionKind::Angular2Line, + Some(p(10.0 * a.cos(), 10.0 * a.sin())), + pts( + Some(p(0.0, 0.0)), + Some(p(10.0, 0.0)), + Some(p(0.0, 0.0)), + Some(probe), + ), + ) + }; + let obtuse = + measurement_from_points(&two_line(p((a / 2.0).cos() * 5.0, (a / 2.0).sin() * 5.0))) + .unwrap(); + assert!((obtuse - 108.0).abs() < 1e-9, "{obtuse}"); + let acute = measurement_from_points(&two_line(p(-1.0, 3.0))).unwrap(); + assert!((acute - 72.0).abs() < 1e-9, "{acute}"); + } + + #[test] + fn ordinate_and_arc_length() { + let mut ordinate = dim( + DimensionKind::Ordinate, + Some(p(10.0, 10.0)), + pts(Some(p(25.0, 40.0)), Some(p(25.0, 60.0)), None, None), + ); + ordinate.ordinate_axis = Some(OrdinateAxis::X); + assert_eq!(measurement_from_points(&ordinate), Some(15.0)); + ordinate.ordinate_axis = Some(OrdinateAxis::Y); + assert_eq!(measurement_from_points(&ordinate), Some(30.0)); + ordinate.ordinate_axis = None; + assert_eq!(measurement_from_points(&ordinate), None); + let arc = dim( + DimensionKind::ArcLength, + Some(p(1.0, 1.0)), + pts( + Some(p(2.0, 0.0)), + Some(p(0.0, 2.0)), + Some(p(0.0, 0.0)), + None, + ), + ); + let length = measurement_from_points(&arc).unwrap(); + assert!((length - std::f64::consts::PI).abs() < 1e-9, "{length}"); + } + + #[test] + fn formatting_follows_the_style() { + let mut s = style(); + assert_eq!(format_measurement(10.0, false, &s), "10.00"); + s.dimlfac = 12.0; + assert_eq!(format_measurement(10.0, false, &s), "120.00"); + s.dimzin = 8; + assert_eq!(format_measurement(10.0, false, &s), "120"); + assert_eq!(format_measurement(10.55, false, &s), "126.6"); + s.dimlfac = 1.0; + s.dimlunit = 4; + s.dimdec = 4; + assert_eq!(format_measurement(88.0, false, &s), "7'-4\""); + assert_eq!(format_measurement(92.375, false, &s), "7'-8 3/8\""); + assert_eq!(format_measurement(2.5, false, &s), "0'-2 1/2\""); + assert_eq!(format_measurement(12.0, false, &s), "1'-0\""); + s.dimlunit = 5; + assert_eq!(format_measurement(2.5, false, &s), "2 1/2"); + assert_eq!(format_measurement(0.75, false, &s), "3/4"); + assert_eq!(format_measurement(3.0, false, &s), "3"); + s.dimadec = 1; + assert_eq!(format_measurement(108.0, true, &s), "108.0\u{00B0}"); + } + + #[test] + fn rounding_leading_zeros_and_the_post_string() { + let mut s = style(); + // DIMRND 0.25: 10.1 is 10.00, 10.2 is 10.25. + s.dimrnd = 0.25; + assert_eq!(format_measurement(10.1, false, &s), "10.00"); + assert_eq!(format_measurement(10.2, false, &s), "10.25"); + s.dimrnd = 0.0; + // DIMZIN bit 4 drops a leading zero. + s.dimzin = 4; + assert_eq!(format_measurement(0.5, false, &s), ".50"); + assert_eq!(format_measurement(-0.5, false, &s), "-.50"); + assert_eq!(format_measurement(1.5, false, &s), "1.50"); + s.dimzin = 12; + assert_eq!(format_measurement(0.5, false, &s), ".5"); + s.dimzin = 0; + // DIMPOST: a suffix, or a template around `<>`. Not for angles. + s.dimpost = " mm".into(); + assert_eq!(format_measurement(2.0, false, &s), "2.00 mm"); + s.dimpost = "\u{2205}<> TYP".into(); + assert_eq!(format_measurement(2.0, false, &s), "\u{2205}2.00 TYP"); + assert_eq!(format_measurement(90.0, true, &s), "90\u{00B0}"); + } + + #[test] + fn display_precedence_suppressed_override_cache_formatted() { + let base = dim( + DimensionKind::Aligned, + Some(p(0.0, 0.0)), + pts(Some(p(0.0, 0.0)), Some(p(10.0, 0.0)), None, None), + ); + let cached = ("\\A1;120".to_string(), "120".to_string()); + + let mut d = base.clone(); + d.text_override = TextOverride::Suppressed; + let shown = display_text(&d, &style(), Some(10.0), Some(&cached)); + assert_eq!( + (shown.text.as_str(), shown.source), + ("", DisplaySource::Suppressed) + ); + d.text_override = TextOverride::Literal(" ".into()); + let shown = display_text(&d, &style(), Some(10.0), Some(&cached)); + assert_eq!(shown.source, DisplaySource::Suppressed); + + d.text_override = TextOverride::Literal("<> TYP.".into()); + let shown = display_text(&d, &style(), Some(10.0), Some(&cached)); + assert_eq!(shown.text, "10.00 TYP."); + assert_eq!(shown.raw, "<> TYP."); + assert_eq!(shown.source, DisplaySource::UserText); + + let shown = display_text(&base, &style(), Some(10.0), Some(&cached)); + assert_eq!( + (shown.text.as_str(), shown.source), + ("120", DisplaySource::CachedBlock) + ); + assert_eq!(shown.raw, "\\A1;120"); + + let shown = display_text(&base, &style(), Some(10.0), None); + assert_eq!( + (shown.text.as_str(), shown.source), + ("10.00", DisplaySource::Formatted) + ); + let shown = display_text(&base, &style(), None, None); + assert_eq!(shown.source, DisplaySource::None); + } + + #[test] + fn a_stored_measurement_that_cannot_be_this_dimensions_is_refused() { + // Expected values come from the geometry, not from the function: + // the ALIGNED pair (0,0)-(3,4) is 5 units apart (3-4-5), and the + // ORDINATE offset is 4 - 1. + let aligned = Some(DimensionKind::Aligned); + let ordinate = Some(DimensionKind::Ordinate); + let take = usable_stored_measurement; + + assert_eq!(take(Some(0.0), aligned, Some(5.0)), None); + assert_eq!(take(Some(0.0), aligned, None), None); + assert_eq!(take(Some(-1.0), aligned, Some(5.0)), None); + assert_eq!(take(Some(-5.0), aligned, Some(5.0)), None); + assert_eq!(take(Some(f64::NAN), aligned, Some(5.0)), None); + assert_eq!(take(None, aligned, Some(5.0)), None); + // A value that measures something else entirely. + assert_eq!(take(Some(500.0), aligned, Some(5.0)), None); + // A believable one survives, disagreement inside the tolerance + // included (5.02 is 0.4 % off 5.0). + assert_eq!(take(Some(5.0), aligned, Some(5.0)), Some(5.0)); + assert_eq!(take(Some(5.02), aligned, Some(5.0)), Some(5.02)); + assert_eq!(take(Some(5.0), aligned, None), Some(5.0)); + + // An ORDINATE is a signed offset from a datum, so 0 and a negative + // value are both measurements it can have ... + assert_eq!(take(Some(0.0), ordinate, Some(0.0)), Some(0.0)); + assert_eq!(take(Some(-3.0), ordinate, Some(3.0)), Some(-3.0)); + assert_eq!(take(Some(3.0), ordinate, Some(-3.0)), Some(3.0)); + // ... but not one that disagrees with the points by 100 %. + assert_eq!(take(Some(0.0), ordinate, Some(3.0)), None); + + // Angular kinds store radians and are reported in degrees. + let angular = Some(DimensionKind::Angular3Point); + let quarter = std::f64::consts::FRAC_PI_2; + let got = take(Some(quarter), angular, Some(90.0)).expect("a right angle"); + assert!((got - 90.0).abs() < 1e-9, "{got}"); + assert_eq!(take(Some(0.0), angular, Some(90.0)), None); + } + + fn header(dimdec: u16, dimadec: u16, dimzin: u16) -> DimDefaults { + DimDefaults { + dimdec: Some(dimdec), + dimadec: Some(dimadec), + dimzin: Some(dimzin), + dimlunit: Some(2), + dimlfac: Some(1.0), + ..Default::default() + } + } + + fn metric() -> DimStyleRecord { + DimStyleRecord { + name: "ISO-0".into(), + decimal_places: Some(0), + angular_decimal_places: Some(0), + zero_suppression: Some(0), + linear_unit_format: Some(LinearUnitFormat::Decimal), + length_factor: Some(1.0), + // What says the file wrote this style's body at all. + text_height: Some(2.5), + arrow_size: Some(2.5), + ..Default::default() + } + } + + #[test] + fn a_style_the_file_wrote_is_taken_at_its_word_zero_included() { + // DIMDEC 0 ("50", whole millimetres) and DIMADEC 0 are ordinary + // metric settings, and were read as "unset": the header's DIMDEC 4 + // stood in and every label came out as "50.0000". + let header = header(4, 3, 8); + let resolved = EffectiveStyle::resolve(Some(&metric()), &header); + assert_eq!( + (resolved.dimdec, resolved.dimadec, resolved.dimzin), + (0, 0, 0) + ); + assert_eq!(format_measurement(50.0, false, &resolved), "50"); + assert_eq!(format_measurement(108.0, true, &resolved), "108\u{00B0}"); + + // A style the file wrote in full is still honoured when it asks for + // decimals. + let three = DimStyleRecord { + decimal_places: Some(3), + ..metric() + }; + assert_eq!( + format_measurement(50.0, false, &EffectiveStyle::resolve(Some(&three), &header)), + "50.000" + ); + + // The husk a file leaves when it names a style without writing its + // body reads back as zeros (LibreDWG's DXF reader presets only + // DIMSCALE/DIMLFAC/DIMTFAC = 1 and DIMLUNIT = 2), and says nothing: + // the header's values stand in. + let husk = DimStyleRecord { + name: "STANDARD".into(), + length_factor: Some(1.0), + linear_unit_format: Some(LinearUnitFormat::Decimal), + decimal_places: Some(0), + angular_decimal_places: Some(0), + zero_suppression: Some(0), + text_height: Some(0.0), + arrow_size: Some(0.0), + ..Default::default() + }; + let resolved = EffectiveStyle::resolve(Some(&husk), &header); + assert_eq!( + (resolved.dimdec, resolved.dimadec, resolved.dimzin), + (4, 3, 8) + ); + assert_eq!( + EffectiveStyle::resolve(None, &header), + EffectiveStyle::resolve(Some(&husk), &header) + ); + + // DIMLUNIT and DIMLFAC keep their guard: 0 is not a value either + // can hold, so a style carrying one takes the header's. + let zeroed = DimStyleRecord { + linear_unit_format: None, + length_factor: Some(0.0), + ..metric() + }; + let header = DimDefaults { + dimlunit: Some(4), + dimlfac: Some(12.0), + ..header + }; + let resolved = EffectiveStyle::resolve(Some(&zeroed), &header); + assert_eq!((resolved.dimlunit, resolved.dimlfac), (4, 12.0)); + + // Nothing stated anywhere: the format's defaults. + let resolved = EffectiveStyle::resolve(None, &DimDefaults::default()); + assert_eq!( + ( + resolved.dimdec, + resolved.dimlunit, + resolved.dimzin, + resolved.dimadec, + resolved.dimlfac + ), + (4, 2, 0, 0, 1.0) + ); + // DIMADEC -1 is "as DIMDEC". + let as_dimdec = DimStyleRecord { + decimal_places: Some(2), + angular_decimal_places: Some(-1), + ..metric() + }; + assert_eq!( + EffectiveStyle::resolve(Some(&as_dimdec), &DimDefaults::default()).dimadec, + 2 + ); + // The post string comes from the style, else the header. + let posted = DimStyleRecord { + post: Some("<> mm".into()), + ..metric() + }; + let header = DimDefaults { + dimpost: Some(" in".into()), + ..DimDefaults::default() + }; + assert_eq!( + EffectiveStyle::resolve(Some(&posted), &header).dimpost, + "<> mm" + ); + assert_eq!(EffectiveStyle::resolve(None, &header).dimpost, " in"); + } + + #[test] + fn a_precision_outside_the_dxf_range_does_not_build_a_65_kb_label() { + // A 16-bit DIMDEC of 65535 (a corrupt record) would have `format!` + // build a 65 KB label for every dimension in the drawing. + let wild = DimStyleRecord { + decimal_places: Some(65_535), + angular_decimal_places: Some(i32::MAX), + ..metric() + }; + let resolved = EffectiveStyle::resolve(Some(&wild), &DimDefaults::default()); + // 8 decimals is the DXF reference's maximum for both fields. + assert_eq!(format_measurement(1.5, false, &resolved), "1.50000000"); + assert_eq!( + format_measurement(90.0, true, &resolved), + "90.00000000\u{00B0}" + ); + let header = DimDefaults { + dimdec: Some(u16::MAX), + ..DimDefaults::default() + }; + assert_eq!(EffectiveStyle::resolve(None, &header).dimdec, 8); + } +} diff --git a/crates/uncad-export/src/lib.rs b/crates/uncad-export/src/lib.rs index bda7ca4..117e478 100644 --- a/crates/uncad-export/src/lib.rs +++ b/crates/uncad-export/src/lib.rs @@ -9,14 +9,17 @@ //! What is here so far: [`fonts`], the face the package draws and measures //! text with; [`geom`], the arithmetic its records carry -- a polyline's //! length and area with its bulge arcs, whether an outline crosses itself, -//! the map from an entity's own plane to the world; and [`text`], the -//! string a reader sees for a text the file stores with its codes. +//! the map from an entity's own plane to the world; [`text`], the string a +//! reader sees for a text the file stores with its codes; and +//! [`dimension`], a dimension's value, whether its stored measurement can be +//! believed, and its label. //! //! # Licence //! //! GPL-3.0-or-later, like the rest of the workspace, and OFL-1.1 for the //! bundled font (`fonts/OFL-NotoSansKR.txt`). +pub mod dimension; pub mod fonts; pub mod geom; pub mod text; diff --git a/crates/uncad-export/tests/dimensions.rs b/crates/uncad-export/tests/dimensions.rs new file mode 100644 index 0000000..9c6c407 --- /dev/null +++ b/crates/uncad-export/tests/dimensions.rs @@ -0,0 +1,195 @@ +//! Dimension values against real files: the stored measurement, the one +//! recomputed from the definition points, and the label the drawing shows. +//! `example_2000.dwg`'s labels were written by AutoCAD and are the reference +//! for the values; `example_2007.dwg` is the same drawing in a format that +//! stores `act_measurement`, and the R13/R14 files are the same drawing in +//! formats that do not; `dimlfac12_r2000.dxf` is this project's own fixture +//! (ground truth in crates/uncad/tests/fixtures/README.md). + +use uncad::Entity; +use uncad_export::dimension::{ + cached_labels, display_text, is_angular, measurement_from_points, usable_stored_measurement, + DimDefaults, DisplaySource, EffectiveStyle, +}; +use uncad_model::model::{DimensionEntity, DimensionKind}; + +const TEST_DATA: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data" +); +const DIMLFAC12: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../uncad/tests/fixtures/dimlfac12_r2000.dxf" +); + +fn dimensions(db: &uncad::CadDatabase) -> Vec<&DimensionEntity> { + db.entities + .iter() + .filter_map(|e| match e { + Entity::Dimension(d) => Some(d), + _ => None, + }) + .collect() +} + +fn handle(d: &DimensionEntity) -> &str { + d.common.source_handle.name() +} + +/// The measurement the package reports: the stored one when it can be +/// believed, the definition points' otherwise. +fn value(d: &DimensionEntity) -> Option { + let from_points = measurement_from_points(d); + usable_stored_measurement(d.measurement, d.kind, from_points).or(from_points) +} + +fn label( + db: &uncad::CadDatabase, + header: &uncad::Header, + d: &DimensionEntity, +) -> (String, DisplaySource) { + let style = EffectiveStyle::resolve( + d.style_name + .resolved() + .and_then(|name| db.tables.dim_styles.get(name)), + &DimDefaults::from_header(header), + ); + let labels = cached_labels(&db.tables); + let shown = display_text(d, &style, value(d), labels.get(d.block_name.name())); + (shown.text, shown.source) +} + +#[test] +fn autocad_written_dimensions_agree_with_their_definition_points_and_labels() { + let (db, header) = uncad::parse_with_header(format!("{TEST_DATA}/example_2000.dwg")) + .expect("corpus file must parse"); + let dims = dimensions(&db); + assert_eq!(dims.len(), 10, "example_2000.dwg has ten dimensions"); + for d in &dims { + let stored = usable_stored_measurement(d.measurement, d.kind, measurement_from_points(d)) + .unwrap_or_else(|| panic!("{} stores a believable measurement", handle(d))); + let recomputed = measurement_from_points(d) + .unwrap_or_else(|| panic!("{} {:?} has its points", handle(d), d.kind)); + assert!( + (stored - recomputed).abs() <= 1e-6 * stored.abs().max(1.0), + "{} {:?}: stored {stored} vs from points {recomputed}", + handle(d), + d.kind + ); + let (_, source) = label(&db, &header, d); + assert_ne!(source, DisplaySource::None, "{} has no label", handle(d)); + } + + // An ALIGNED dimension whose cached label AutoCAD wrote with a decimal + // comma (DIMDSEP) and two decimals. + let aligned = dims + .iter() + .find(|d| d.kind == Some(DimensionKind::Aligned)) + .expect("an ALIGNED dimension"); + assert!((value(aligned).unwrap() - 1504.6795).abs() < 1e-3); + assert_eq!( + label(&db, &header, aligned), + ("1504,68".to_string(), DisplaySource::CachedBlock) + ); + + // A two-line angular dimension: 108 degrees, stored in radians, + // reported in degrees and labelled with a degree sign. + let angular = dims + .iter() + .find(|d| d.kind == Some(DimensionKind::Angular2Line)) + .expect("an ANGULAR_2LINE dimension"); + assert!(is_angular(angular.kind)); + let stored = angular.measurement.expect("R2000 stores it"); + assert!((stored.to_degrees() - 108.0).abs() < 1e-3, "{stored} rad"); + assert!((value(angular).unwrap() - 108.0).abs() < 1e-3); + assert_eq!(label(&db, &header, angular).0, "108\u{00B0}"); +} + +#[test] +fn a_file_that_stores_no_measurement_gets_the_definition_points_value() { + // The expected values are the `act_measurement`s AutoCAD itself wrote + // into `example_2007.dwg`, the same drawing saved in a newer format + // (identical handles, identical definition points). Each old file's + // value has to match its 2007 twin's stored one. + let reference = uncad::parse(format!("{TEST_DATA}/example_2007.dwg")).expect("parses"); + let reference: Vec<(String, f64)> = dimensions(&reference) + .iter() + .map(|d| { + let stored = + usable_stored_measurement(d.measurement, d.kind, measurement_from_points(d)) + .unwrap_or_else(|| panic!("2007 writes act_measurement for {}", handle(d))); + (handle(d).to_string(), stored) + }) + .collect(); + assert_eq!(reference.len(), 10, "the drawing has ten dimensions"); + + for name in ["example_r14.dwg", "example_r13.dwg", "example_r13.dxf"] { + let db = uncad::parse(format!("{TEST_DATA}/{name}")).expect("corpus file must parse"); + let dims = dimensions(&db); + for (h, want) in &reference { + let Some(d) = dims.iter().find(|d| handle(d) == h) else { + // The R13 DXF drops the ARC_LENGTH dimension entirely. + continue; + }; + // Nothing believable is stored, so the definition points are + // the source ... + assert_eq!( + usable_stored_measurement(d.measurement, d.kind, measurement_from_points(d)), + None, + "{name} {h} stored" + ); + // ... and they give the value the 2007 file measured, to the + // tolerance the two files' own float storage needs (the worst + // pair in the drawing differs by 2.3e-7 relative). + let got = measurement_from_points(d) + .unwrap_or_else(|| panic!("{name} {h} has definition points")); + assert!( + (got - want).abs() <= 1e-6 * want.abs(), + "{name} {h}: {got} vs the 2007 file's {want}" + ); + } + } +} + +#[test] +fn a_believable_stored_measurement_is_still_preferred() { + // `example_2007.dwg`'s ALIGNED 37E stores 1504.6794770244742 while its + // definition points give 1504.6798093211207 -- a real 3.3e-4 + // disagreement in the file itself, which the stored value must survive. + let db = uncad::parse(format!("{TEST_DATA}/example_2007.dwg")).expect("parses"); + let dims = dimensions(&db); + let aligned = dims + .iter() + .find(|d| handle(d) == "37E") + .expect("handle 37E"); + let computed = measurement_from_points(aligned).expect("it has points"); + let stored = usable_stored_measurement(aligned.measurement, aligned.kind, Some(computed)) + .expect("2007 stores a believable measurement"); + assert!(stored != computed, "the file's two values differ"); + assert!((stored - 1504.679477024474).abs() < 1e-9, "{stored}"); +} + +#[test] +fn the_fixture_dimension_reads_its_style_and_its_cached_label() { + // One LINEAR dimension 10 units long under $DIMLFAC 12 (the STANDARD + // style says 12 too): the cached label AutoCAD would show is "120", and + // a label formatted here from the same style says the same. + let (db, header) = uncad::parse_with_header(DIMLFAC12).expect("fixture must parse"); + let dims = dimensions(&db); + assert_eq!(dims.len(), 1); + let d = dims[0]; + assert_eq!(d.kind, Some(DimensionKind::Rotated)); + assert_eq!(measurement_from_points(d), Some(10.0)); + assert_eq!(value(d), Some(10.0)); + assert_eq!( + label(&db, &header, d), + ("120".to_string(), DisplaySource::CachedBlock) + ); + let style = EffectiveStyle::resolve( + db.tables.dim_styles.get("STANDARD"), + &DimDefaults::from_header(&header), + ); + assert_eq!(style.dimlfac, 12.0); + let formatted = uncad_export::dimension::format_measurement(10.0, false, &style); + assert!(formatted.starts_with("120"), "{formatted}"); +} From ffcaebb71222677e5e604872e540ca84027ec802 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 19:22:14 +0900 Subject: [PATCH 42/54] Frame the package's pictures: crop mode, padding, lattice and groups The feature branch decided its crop itself. On the split the renderer does (its proposal's `Crop` and `CropReport`), and the export keeps only what a reader counting patches needs, as `frame`: - `CropMode` (Auto, Raw, Header, Fixed) and `renderer_crop`: Auto is `Crop::Guarded` with the header's `$EXTMIN/$EXTMAX` as the stated extent the renderer may take; Raw is `Crop::Everything`; Header is a `Crop::Window` of the header's extents when they are sane, else Auto; Fixed a `Crop::Window`. `header_extents` reads them from `uncad::Header` (the model carries no header), and `crop_source` names the rule the manifest reports from the renderer's `stated_taken` and content. - `auto_padding` (2 % of the longer side, 24 px at least, half a unit for a point) and `snap_to_lattice` (the pixel size up to the patch lattice, the world rectangle grown to match), unchanged. - `detached_groups`, the grouping that makes a detail beside the plan a frame of its own, on a `BTreeMap` of grid cells instead of a `HashMap` (the workspace forbids std's hash collections: same input, same bytes). The grid is the crop's diagonal times `frame_gap`, so it holds a few hundred cells; the corner-only rule for a runaway extent stays. - `Rect`, the package's rectangle, still serialized as `[x0, y0, x1, y1]` and read from the old object form, converting to and from the renderer's. The feature's guard, header-candidate and cluster code is not ported: it is the renderer's now. Ported-from: 89ac6de, 583882e, 6395009, 0194c0e Co-Authored-By: Claude Opus 5.5 --- crates/uncad-export/Cargo.toml | 1 + crates/uncad-export/src/frame.rs | 546 +++++++++++++++++++++++++++++++ crates/uncad-export/src/lib.rs | 5 +- 3 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 crates/uncad-export/src/frame.rs diff --git a/crates/uncad-export/Cargo.toml b/crates/uncad-export/Cargo.toml index 7c530bc..476fe74 100644 --- a/crates/uncad-export/Cargo.toml +++ b/crates/uncad-export/Cargo.toml @@ -28,3 +28,4 @@ uncad = { path = "../uncad", version = "0.2.0" } uncad-model = "0.1.0" iron-render-cad = "0.1.0" serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" diff --git a/crates/uncad-export/src/frame.rs b/crates/uncad-export/src/frame.rs new file mode 100644 index 0000000..9722cc7 --- /dev/null +++ b/crates/uncad-export/src/frame.rs @@ -0,0 +1,546 @@ +//! Where the package's pictures stop: the crop a caller asks for, as the +//! renderer's [`Crop`]; the padding around it; the snap of an image's size +//! to the model's patch lattice; and the detached groups of a drawing that +//! become frames of their own. +//! +//! The renderer decides the crop -- which entities a picture is framed on +//! and which few outliers it leaves out, and says so in its `CropReport`. +//! What is left here is what only a reader counting patches cares about: +//! +//! 1. **Crop mode** ([`CropMode`]): `Auto` is the renderer's guard, handed +//! the header's `$EXTMIN/$EXTMAX` as the stated extent it may take +//! instead; `Raw` frames everything; `Header` frames the header's +//! extents when they are a sane rectangle; `Fixed` a caller's rectangle. +//! 2. **Padding** ([`auto_padding`]): 2 % of the longer side, or 24 output +//! pixels when that is more. +//! 3. **Lattice snap** ([`snap_to_lattice`]): the pixel size is rounded up +//! to a multiple of the patch size (28 px for Claude) and the world +//! rectangle grows on the right and bottom so that pixels and units stay +//! in exact proportion -- the affine the package publishes beside every +//! image then round-trips. +//! 4. **Frames** ([`detached_groups`]): entities within about a cell of one +//! another are one group; a detail drawn beside the plan is another. + +use std::collections::BTreeMap; + +use iron_render_cad::Crop; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// An axis-aligned rectangle in world (drawing) units -- or, on a sheet, in +/// the layout's paper units. +/// +/// JSON form: the `[x0, y0, x1, y1]` array the package documents for every +/// box, so a rectangle reads the same in `manifest.json`, `sheets.json`, +/// `report.json` and the records. Reading also accepts the `{min_x, min_y, +/// max_x, max_y}` object an earlier form of the package wrote. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub min_x: f64, + pub min_y: f64, + pub max_x: f64, + pub max_y: f64, +} + +impl Serialize for Rect { + fn serialize(&self, serializer: S) -> Result { + [self.min_x, self.min_y, self.max_x, self.max_y].serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Rect { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Form { + Array([f64; 4]), + Object { + min_x: f64, + min_y: f64, + max_x: f64, + max_y: f64, + }, + } + Ok(match Form::deserialize(deserializer)? { + Form::Array([min_x, min_y, max_x, max_y]) => Rect::new(min_x, min_y, max_x, max_y), + Form::Object { + min_x, + min_y, + max_x, + max_y, + } => Rect::new(min_x, min_y, max_x, max_y), + }) + } +} + +impl From for Rect { + fn from(r: iron_render_cad::Rect) -> Rect { + Rect::new(r.min_x, r.min_y, r.max_x, r.max_y) + } +} + +impl From for iron_render_cad::Rect { + fn from(r: Rect) -> iron_render_cad::Rect { + iron_render_cad::Rect::new(r.min_x, r.min_y, r.max_x, r.max_y) + } +} + +impl Rect { + pub const fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Rect { + Rect { + min_x, + min_y, + max_x, + max_y, + } + } + + pub fn width(&self) -> f64 { + self.max_x - self.min_x + } + + pub fn height(&self) -> f64 { + self.max_y - self.min_y + } + + pub fn longer_side(&self) -> f64 { + self.width().max(self.height()) + } + + pub fn diagonal(&self) -> f64 { + self.width().hypot(self.height()) + } + + /// Finite, no coordinate at or beyond 1e15 (the runaway values some + /// headers hold), and a positive area -- the renderer's test for a + /// stated extent it may take. + pub fn is_sane(&self) -> bool { + [self.min_x, self.min_y, self.max_x, self.max_y] + .iter() + .all(|v| v.is_finite() && v.abs() < 1e15) + && self.width() > 0.0 + && self.height() > 0.0 + } + + /// Whether the two overlap or touch. + pub fn intersects(&self, other: &Rect) -> bool { + other.min_x <= self.max_x + && other.max_x >= self.min_x + && other.min_y <= self.max_y + && other.max_y >= self.min_y + } + + /// Whether `other` lies entirely inside (edges included). + pub fn contains(&self, other: &Rect) -> bool { + other.min_x >= self.min_x + && other.max_x <= self.max_x + && other.min_y >= self.min_y + && other.max_y <= self.max_y + } + + pub fn union(&self, other: &Rect) -> Rect { + Rect { + min_x: self.min_x.min(other.min_x), + min_y: self.min_y.min(other.min_y), + max_x: self.max_x.max(other.max_x), + max_y: self.max_y.max(other.max_y), + } + } + + /// Grown by `pad` on every side. + pub fn padded(&self, pad: f64) -> Rect { + Rect { + min_x: self.min_x - pad, + min_y: self.min_y - pad, + max_x: self.max_x + pad, + max_y: self.max_y + pad, + } + } + + /// The bounding rectangle of several; `None` for none. + pub fn bounding<'a>(rects: impl IntoIterator) -> Option { + rects.into_iter().fold(None, |acc: Option, r| { + Some(acc.map_or(*r, |a| a.union(r))) + }) + } + + /// A rectangle of one point. + pub fn at(x: f64, y: f64) -> Rect { + Rect::new(x, y, x, y) + } +} + +/// The canvas a package of an empty drawing gets. +pub const EMPTY_RECT: Rect = Rect::new(0.0, 0.0, 10.0, 10.0); + +/// What the package's pictures show. `Auto` is the default. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum CropMode { + /// Every entity but the few outliers the renderer's guard sets aside + /// (a block reference scaled thousands of times, a stray point a + /// million units out), or the header's `$EXTMIN/$EXTMAX` when those + /// pass the renderer's test for a stated extent and cover more. + #[default] + Auto, + /// Every entity, outliers included: the raw bounds. + Raw, + /// `$EXTMIN/$EXTMAX` as stored, when they are a sane rectangle; `Auto` + /// otherwise. + Header, + /// Exactly this world rectangle. + Fixed(Rect), +} + +/// Which rule produced the crop, as the manifest names it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CropSource { + /// The entities' extents, outliers set aside. + Content, + /// The header's `$EXTMIN/$EXTMAX`. + Header, + /// Every entity's extent. + Raw, + /// A rectangle the caller gave. + Fixed, + /// Nothing measured: a 10 x 10 canvas at the origin ([`EMPTY_RECT`]). + Empty, +} + +/// The header's `$EXTMIN/$EXTMAX` as a rectangle, when both are stated and +/// they make a sane one (see [`Rect::is_sane`]); AutoCAD writes `1e20` +/// when it never computed them. +pub fn header_extents(header: &uncad::Header) -> Option { + let (min, max) = (header.extmin?, header.extmax?); + let rect = Rect::new(min.x, min.y, max.x, max.y); + rect.is_sane().then_some(rect) +} + +/// A crop mode as the renderer's [`Crop`], with the header's extents (see +/// [`header_extents`]) as the stated extent `Auto` may take. +pub fn renderer_crop(mode: CropMode, header: Option) -> Crop { + let stated = header.map(iron_render_cad::Rect::from); + match mode { + CropMode::Auto => Crop::Guarded { stated }, + CropMode::Raw => Crop::Everything, + CropMode::Header => match stated { + Some(rect) => Crop::Window(rect), + None => Crop::Guarded { stated: None }, + }, + CropMode::Fixed(rect) => Crop::Window(rect.into()), + } +} + +/// Which rule a render under `mode` framed its picture by: `stated_taken` +/// and `content` are the renderer's report of it. +pub fn crop_source( + mode: CropMode, + header: Option, + stated_taken: bool, + content: Option<&Rect>, +) -> CropSource { + if content.is_none() { + return CropSource::Empty; + } + match mode { + CropMode::Raw => CropSource::Raw, + CropMode::Fixed(_) => CropSource::Fixed, + CropMode::Header if header.is_some() => CropSource::Header, + CropMode::Header | CropMode::Auto if stated_taken => CropSource::Header, + CropMode::Header | CropMode::Auto => CropSource::Content, + } +} + +/// Rule 2: the padding for `rect`, in drawing units: 2 % of its longer +/// side, or 24 pixels at `px_per_unit` when that is more. A degenerate +/// (zero-size) rectangle gets half a unit, or those 24 pixels when they are +/// more -- never less, however large the scale: a scale seeded from a +/// zero-size rectangle is itself enormous, and 24 pixels of it came to +/// 1e-11 units, a blank window at 1e13 px/unit. +pub fn auto_padding(rect: &Rect, px_per_unit: Option) -> f64 { + let l = rect.longer_side(); + let by_pixels = px_per_unit + .filter(|s| s.is_finite() && *s > 0.0) + .map(|s| 24.0 / s) + .unwrap_or(0.0); + if l.is_nan() || l <= 0.0 { + return by_pixels.max(0.5); + } + (0.02 * l).max(by_pixels) +} + +/// Rule 3: the pixel size of `padded` at `px_per_unit`, rounded up to a +/// multiple of `lattice` (0 = plain rounding), and the world rectangle +/// grown on the right and bottom so that `width / px_per_unit` and +/// `height / px_per_unit` equal its size exactly. Returns `(rect, width_px, +/// height_px)`; a size beyond `u32::MAX` is clamped (the renderer refuses +/// anything past its own edge limit anyway). +pub fn snap_to_lattice(padded: &Rect, px_per_unit: f64, lattice: u32) -> (Rect, u32, u32) { + let to_px = |units: f64| -> f64 { + let raw = units * px_per_unit; + let px = if lattice > 0 { + let l = f64::from(lattice); + (raw / l - 1e-9).ceil() * l + } else { + raw.round() + }; + if px.is_finite() { + px.max(1.0).min(f64::from(u32::MAX)) + } else { + f64::from(u32::MAX) + } + }; + let w = to_px(padded.width()); + let h = to_px(padded.height()); + let rect = Rect { + min_x: padded.min_x, + min_y: padded.max_y - h / px_per_unit, + max_x: padded.min_x + w / px_per_unit, + max_y: padded.max_y, + }; + (rect, w as u32, h as u32) +} + +/// Rule 4: groups the extents that lie within about `cell` of one another +/// (rectangles are painted onto a grid of `cell`-sized squares; entities +/// sharing a square or neighbouring squares are one group). Groups come +/// back largest first (then by position), each with its indices in order. +/// The package turns detached groups into frames. +pub fn detached_groups(extents: &[Rect], cell: f64) -> Vec> { + let n = extents.len(); + if n == 0 { + return Vec::new(); + } + let cell = if cell.is_finite() && cell > 0.0 { + cell + } else { + 1.0 + }; + let mut parent: Vec = (0..n).collect(); + fn find(parent: &mut [usize], mut x: usize) -> usize { + while parent[x] != x { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + x + } + fn union(parent: &mut [usize], a: usize, b: usize) { + let (ra, rb) = (find(parent, a), find(parent, b)); + if ra != rb { + parent[ra.max(rb)] = ra.min(rb); + } + } + // The first entity to touch a square owns it; later ones join it. + let mut owner: BTreeMap<(i64, i64), usize> = BTreeMap::new(); + let key = |v: f64| (v / cell).floor() as i64; + for (i, r) in extents.iter().enumerate() { + if !(r.min_x.is_finite() + && r.max_x.is_finite() + && r.min_y.is_finite() + && r.max_y.is_finite()) + { + continue; + } + let (x0, x1, y0, y1) = (key(r.min_x), key(r.max_x), key(r.min_y), key(r.max_y)); + // A rectangle spanning an absurd number of squares (a runaway + // extent that slipped through) only paints its four corners. + let too_many = (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > 1 << 20; + let mut paint = |x: i64, y: i64| match owner.get(&(x, y)) { + Some(&o) => union(&mut parent, i, o), + None => { + owner.insert((x, y), i); + } + }; + if too_many { + for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { + paint(x, y); + } + } else { + for x in x0..=x1 { + for y in y0..=y1 { + paint(x, y); + } + } + } + } + // Neighbouring squares join too (8-connectivity), so the effective gap + // is between one and two cells. + for (&(x, y), &i) in &owner { + for dx in -1..=1i64 { + for dy in -1..=1i64 { + if let Some(&j) = owner.get(&(x + dx, y + dy)) { + union(&mut parent, i, j); + } + } + } + } + let mut groups: BTreeMap> = BTreeMap::new(); + for i in 0..n { + let r = find(&mut parent, i); + groups.entry(r).or_default().push(i); + } + let mut out: Vec> = groups.into_values().collect(); + let rect_of = |g: &[usize]| Rect::bounding(g.iter().map(|i| &extents[*i])).expect("non-empty"); + out.sort_by(|a, b| { + b.len() + .cmp(&a.len()) + .then_with(|| rect_of(a).min_x.total_cmp(&rect_of(b).min_x)) + .then_with(|| rect_of(a).min_y.total_cmp(&rect_of(b).min_y)) + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detached_groups_split_a_plan_from_its_details() { + // A 100 x 50 "plan" of four lines, a detail of three lines 30 units + // away, and a lone dot far off. With 5-unit cells the plan and the + // detail are separate; with 40-unit cells they merge. + let mut extents = vec![ + Rect::new(0.0, 0.0, 100.0, 0.0), + Rect::new(100.0, 0.0, 100.0, 50.0), + Rect::new(0.0, 50.0, 100.0, 50.0), + Rect::new(0.0, 0.0, 0.0, 50.0), + Rect::new(130.0, 0.0, 150.0, 0.0), + Rect::new(150.0, 0.0, 150.0, 20.0), + Rect::new(130.0, 20.0, 150.0, 20.0), + Rect::new(500.0, 500.0, 500.0, 500.0), + ]; + let groups = detached_groups(&extents, 5.0); + assert_eq!(groups, vec![vec![0, 1, 2, 3], vec![4, 5, 6], vec![7]]); + let merged = detached_groups(&extents, 40.0); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].len(), 7); + // The empty interior of the plan is no gap: opposite walls meet at + // the corners. + extents.truncate(4); + assert_eq!(detached_groups(&extents, 1.0).len(), 1); + assert!(detached_groups(&[], 1.0).is_empty()); + // A runaway extent paints only its corners, and still groups. + let runaway = [ + Rect::new(0.0, 0.0, 1e12, 1e12), + Rect::new(0.0, 0.0, 1.0, 1.0), + ]; + assert_eq!(detached_groups(&runaway, 1.0).len(), 1); + } + + #[test] + fn padding_and_lattice() { + let rect = Rect::new(0.0, 0.0, 1000.0, 500.0); + assert_eq!(auto_padding(&rect, None), 20.0); + // 24 px at 0.5 px/unit is 48 units, more than 2 %. + assert_eq!(auto_padding(&rect, Some(0.5)), 48.0); + assert_eq!(auto_padding(&rect, Some(10.0)), 20.0); + let point = Rect::at(3.0, 3.0); + assert_eq!(auto_padding(&point, None), 0.5); + assert_eq!(auto_padding(&point, Some(2.0)), 12.0); + // A scale seeded from the same zero-size rectangle is ~1e12, which + // would make 24 px a 2e-11-unit window: half a unit is the floor. + assert_eq!(auto_padding(&point, Some(1.05e12)), 0.5); + assert_eq!(auto_padding(&point, Some(f64::INFINITY)), 0.5); + assert_eq!(auto_padding(&point, Some(0.0)), 0.5, "no scale at all"); + + // 1040 x 540 units at 1.5 px/unit = 1560 x 810 px, snapped up to + // 1568 x 812; the world rectangle grows to 1045.333.. x 541.333.. + let padded = rect.padded(20.0); + let (snapped, w, h) = snap_to_lattice(&padded, 1.5, 28); + assert_eq!((w, h), (1568, 812)); + assert!((snapped.width() * 1.5 - 1568.0).abs() < 1e-9); + assert!((snapped.height() * 1.5 - 812.0).abs() < 1e-9); + assert_eq!((snapped.min_x, snapped.max_y), (-20.0, 520.0)); + assert!(snapped.max_x > padded.max_x && snapped.min_y < padded.min_y); + // Without a lattice: plain rounding, still exact. + let (plain, w, h) = snap_to_lattice(&padded, 1.5, 0); + assert_eq!((w, h), (1560, 810)); + assert_eq!(plain, padded); + // An exact multiple is not bumped up. + let (_, w, _) = snap_to_lattice(&Rect::new(0.0, 0.0, 28.0, 28.0), 1.0, 28); + assert_eq!(w, 28); + } + + #[test] + fn every_crop_mode_is_a_renderer_crop_and_names_its_source() { + let header = Some(Rect::new(-1.0, -1.0, 11.0, 11.0)); + let content = Rect::new(0.0, 0.0, 10.0, 10.0); + let stated = iron_render_cad::Rect::new(-1.0, -1.0, 11.0, 11.0); + assert_eq!( + renderer_crop(CropMode::Auto, header), + Crop::Guarded { + stated: Some(stated) + } + ); + assert_eq!( + renderer_crop(CropMode::Auto, None), + Crop::Guarded { stated: None } + ); + assert_eq!(renderer_crop(CropMode::Raw, header), Crop::Everything); + assert_eq!( + renderer_crop(CropMode::Header, header), + Crop::Window(stated) + ); + // No usable header: `Header` is `Auto`. + assert_eq!( + renderer_crop(CropMode::Header, None), + Crop::Guarded { stated: None } + ); + let fixed = Rect::new(0.0, 0.0, 5.0, 5.0); + assert_eq!( + renderer_crop(CropMode::Fixed(fixed), header), + Crop::Window(fixed.into()) + ); + + let source = |mode, header, taken, content| crop_source(mode, header, taken, content); + assert_eq!( + source(CropMode::Auto, header, false, Some(&content)), + CropSource::Content + ); + assert_eq!( + source(CropMode::Auto, header, true, Some(&content)), + CropSource::Header + ); + assert_eq!( + source(CropMode::Header, header, false, Some(&content)), + CropSource::Header + ); + assert_eq!( + source(CropMode::Header, None, false, Some(&content)), + CropSource::Content + ); + assert_eq!( + source(CropMode::Raw, header, false, Some(&content)), + CropSource::Raw + ); + assert_eq!( + source(CropMode::Fixed(fixed), header, false, Some(&content)), + CropSource::Fixed + ); + assert_eq!( + source(CropMode::Auto, header, false, None), + CropSource::Empty + ); + } + + #[test] + fn a_rect_is_the_documented_array_and_reads_both_forms() { + let rect = Rect::new(-6.35, -6.35, 273.05, 209.55); + assert_eq!( + serde_json::to_string(&rect).unwrap(), + "[-6.35,-6.35,273.05,209.55]" + ); + assert_eq!( + serde_json::from_str::("[-6.35,-6.35,273.05,209.55]").unwrap(), + rect + ); + let object = r#"{"min_x":-6.35,"min_y":-6.35,"max_x":273.05,"max_y":209.55}"#; + assert_eq!(serde_json::from_str::(object).unwrap(), rect); + // And the renderer's rectangle converts both ways unchanged. + let theirs: iron_render_cad::Rect = rect.into(); + assert_eq!(Rect::from(theirs), rect); + // Sanity is the renderer's test for a stated extent. + assert!(rect.is_sane()); + assert!(!Rect::new(1e20, 0.0, 1e21, 1.0).is_sane()); + assert!(!Rect::at(5.0, 5.0).is_sane()); + } +} diff --git a/crates/uncad-export/src/lib.rs b/crates/uncad-export/src/lib.rs index 117e478..d2a929a 100644 --- a/crates/uncad-export/src/lib.rs +++ b/crates/uncad-export/src/lib.rs @@ -12,7 +12,9 @@ //! the map from an entity's own plane to the world; [`text`], the string a //! reader sees for a text the file stores with its codes; and //! [`dimension`], a dimension's value, whether its stored measurement can be -//! believed, and its label. +//! believed, and its label; and [`frame`], where the package's pictures +//! stop -- the crop mode as the renderer's `Crop`, the padding, the snap to +//! the model's patch lattice, and the detached groups that become frames. //! //! # Licence //! @@ -21,5 +23,6 @@ pub mod dimension; pub mod fonts; +pub mod frame; pub mod geom; pub mod text; From 82dd555eeac45e8ec8c90322cd14335b9bf74ad3 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 19:38:33 +0900 Subject: [PATCH 43/54] Write the package: overview, records, string index, report and manifest The feature branch's export.rs, on the renderer's render-once surface and the model's shapes, as `export_package(db, header, dir, options)` and `export_file(path, dir, options)` (which parses with the header and names the source). This first part writes everything but the tile pyramid and the sheets: - One walk: `Scene::new` with padding 0, the crop mode as `Crop`, the bundled font's cap height 0.733. The overview is `fit_overview` (the profile's edge and patch budget, 2 %/24 px padding, the lattice snap) of the renderer's `crop.content`, drawn as `Scene::png` of exactly that View, every part but the ones the crop left out, re-encoded 8-bit RGB. On example_2000.dwg it is the feature's rectangle to the bit. - Records of what the picture shows (not hidden, not left out). Texts come from `Scene::text_boxes`: every text the renderer drew, found back along its path of IDs (top-level entity, block references, a nested reference's attribute) to its entity, placed through the model's `Affine2::from_insert` and its own plane (`plane_to_world`), with the renderer's measured glyph box -- a text inside a block, a table cell and a TOLERANCE included, a hidden one and a dimension's label not. Dimensions, geometry (a mirrored arc, circle, polyline or solid is reported where the picture draws it), regions with labels, block instances; ids are the model's reference IDs (`293`, `293/270`), with the file's `handle` beside them, and a record's confidence never above the model's. - strings.json (NFKC), shards with `first_key`/`last_key` on the ids' numbers, drawing.json (the `uncad::Header`, layers with their state, block definitions, the reader's diagnostics), drawing.svg as a window of the same scene, entities.json, report.json (left out -- `scale_outlier`, `far_outlier`, `outside_view` -- plus every renderer bound with its cap, hidden counts and ids, unsupported types, empty blocks, unresolved references, the limits), README.txt and the manifest, written last. - Same input, same bytes: no hash collection anywhere, and report.json carries no timings (the feature's `timings_ms` is gone, and the determinism test now compares every file). Tests: the feature's package tests on this API, on example_2000, the fixtures and built drawings, plus a mirrored polyline and a mirrored block placed where the picture draws them. Ported-from: 1d17ff8, d4cddd6, 2572d99, 8d73ab4, 47d0b00, c6f9539, f0bdaae, 775b514, 00414f4, 7c9cb91, 643765e, 2b0f92f Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 + crates/uncad-export/Cargo.toml | 5 + crates/uncad-export/src/dimension.rs | 9 +- crates/uncad-export/src/fonts.rs | 7 +- crates/uncad-export/src/lib.rs | 62 +- crates/uncad-export/src/package/mod.rs | 1871 ++++++++++++++++++++ crates/uncad-export/src/package/output.rs | 255 +++ crates/uncad-export/src/package/records.rs | 633 +++++++ crates/uncad-export/tests/common/mod.rs | 266 +++ crates/uncad-export/tests/export.rs | 1189 +++++++++++++ 10 files changed, 4288 insertions(+), 19 deletions(-) create mode 100644 crates/uncad-export/src/package/mod.rs create mode 100644 crates/uncad-export/src/package/output.rs create mode 100644 crates/uncad-export/src/package/records.rs create mode 100644 crates/uncad-export/tests/common/mod.rs create mode 100644 crates/uncad-export/tests/export.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e881a0..d900626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,16 @@ Notable changes to this project are recorded here. The format follows 0.733). Its licence expression is `GPL-3.0-or-later AND OFL-1.1`; `deny.toml` grants the crate both by name, and `uncad-cli`'s `tests/release_invariants.rs` checks its `LICENSE`, the font's licence text beside the font and the expression. + `export_package(db, header, dir, options)` (and `export_file`) writes the package: an + overview image fitted to a model's patch budget (`Profile`: Claude, Claude hi-res, + OpenAI), drawn once through the renderer's `Scene`; JSON records -- texts with the box + their glyph outlines fill, dimensions with their value, where it came from and their + label, geometry with lengths and areas, closed regions with their labels, block + instances -- whose ids are the model's reference IDs, with the file's handle beside + them; an NFKC string index; record shards with an index a consumer can search; + `drawing.json`, `report.json` (what was left out, hidden and bounded, and why) and a + manifest saying what the package can answer. The same input gives the same bytes, + `report.json` included. ### Changed diff --git a/crates/uncad-export/Cargo.toml b/crates/uncad-export/Cargo.toml index 476fe74..4309a56 100644 --- a/crates/uncad-export/Cargo.toml +++ b/crates/uncad-export/Cargo.toml @@ -29,3 +29,8 @@ uncad-model = "0.1.0" iron-render-cad = "0.1.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +# The package's images are 8-bit RGB; the renderer's encoder writes RGBA. +# Already in the renderer's own tree (tiny-skia encodes through it). +png = "0.18" +# NFKC for the package's string index (㎡ -> m2, full-width digits, ...). +unicode-normalization = "0.1" diff --git a/crates/uncad-export/src/dimension.rs b/crates/uncad-export/src/dimension.rs index eecb7b7..d76db55 100644 --- a/crates/uncad-export/src/dimension.rs +++ b/crates/uncad-export/src/dimension.rs @@ -218,11 +218,10 @@ const MEASUREMENT_DISAGREEMENT: f64 = 0.01; /// still carry it); /// - it is negative or zero and the kind's measurement cannot be (every /// kind but ORDINATE, which is a signed offset from a datum); -/// - it disagrees with the definition points by more than -/// [`MEASUREMENT_DISAGREEMENT`]. Magnitudes are compared, so a sign -/// convention reconstructed differently (ORDINATE's datum axis) does not -/// throw a good value away, and a computed value of 0 is no evidence -/// against a stored one. +/// - it disagrees with the definition points by more than 1 %. Magnitudes +/// are compared, so a sign convention reconstructed differently +/// (ORDINATE's datum axis) does not throw a good value away, and a +/// computed value of 0 is no evidence against a stored one. pub fn usable_stored_measurement( stored: Option, kind: Option, diff --git a/crates/uncad-export/src/fonts.rs b/crates/uncad-export/src/fonts.rs index 7c7b46b..ffd5ef0 100644 --- a/crates/uncad-export/src/fonts.rs +++ b/crates/uncad-export/src/fonts.rs @@ -3,10 +3,9 @@ //! coverage, the reason for it and how to rebuild it, and //! `fonts/OFL-NotoSansKR.txt` for its licence). //! -//! The renderer bundles no font: it draws text with whatever -//! [`Fonts`](iron_render_cad::Fonts) it is handed, and needs to be told -//! the face's capital height, since a CAD text height is the height of the -//! capitals rather than the em. [`bundled`] and [`UNCAD_SANS_CAP_HEIGHT`] are +//! The renderer bundles no font: it draws text with whatever [`Fonts`] it +//! is handed, and needs to be told the face's capital height, since a CAD +//! text height is the height of the capitals rather than the em. [`bundled`] and [`UNCAD_SANS_CAP_HEIGHT`] are //! those two things for the bundled face. use std::sync::{Arc, OnceLock}; diff --git a/crates/uncad-export/src/lib.rs b/crates/uncad-export/src/lib.rs index d2a929a..8b960ed 100644 --- a/crates/uncad-export/src/lib.rs +++ b/crates/uncad-export/src/lib.rs @@ -4,17 +4,50 @@ //! and draws nothing; `iron-render-cad` draws that model and knows nothing //! of who looks at the picture. This crate is the consumer between them that //! writes for one kind of reader: images sized to a model's patch budget, -//! and the exact numbers a picture cannot give. +//! and JSON records with the exact numbers a picture cannot give -- +//! lengths, areas, dimension values, texts -- each pointing at the pixels it +//! is drawn in. //! -//! What is here so far: [`fonts`], the face the package draws and measures -//! text with; [`geom`], the arithmetic its records carry -- a polyline's -//! length and area with its bulge arcs, whether an outline crosses itself, -//! the map from an entity's own plane to the world; [`text`], the string a -//! reader sees for a text the file stores with its codes; and -//! [`dimension`], a dimension's value, whether its stored measurement can be -//! believed, and its label; and [`frame`], where the package's pictures -//! stop -- the crop mode as the renderer's `Crop`, the padding, the snap to -//! the model's patch lattice, and the detached groups that become frames. +//! [`export_package`] writes the package of a parsed drawing (with its +//! [`uncad::Header`], which the model does not carry), [`export_file`] of a +//! file: +//! +//! ```text +//! dir/ +//! README.txt reading order +//! manifest.json source, units, profile, crop, overview, frames, legibility, +//! capabilities, counts, warnings, files, shard_index, legend, guidance +//! drawing.json header, units, layers with their state, the block definitions, counts +//! overview.png the whole crop, fitted to the profile (Claude: <= 1568 px edge, +//! <= 1568 patches) +//! texts.json TEXT/MTEXT/ATTRIB/TOLERANCE, block contents included, with the +//! world box of their glyph outlines +//! dimensions.json measured value and where it came from, display string, points +//! geometry.json every other visible entity: key points, length, area, bbox +//! regions.json closed polylines: area, perimeter, centroid, the texts inside +//! blocks.json the INSERT instances with their attributes +//! strings.json NFKC-normalised string -> record ids +//! report.json excluded and hidden entities with reasons, unsupported types, +//! the renderer's robustness limits +//! drawing.svg with `svg: true`; entities.json with `full: true` +//! ``` +//! +//! Every JSON file carries `"$schema": "uncad-package/1"` and a `units` +//! block; record files above `shard_kb` are split into `name.NNN.json` and +//! listed in the manifest's `shard_index`. The package is the same bytes for +//! the same input and options, `report.json` included. Re-exporting into a +//! directory first clears what the previous `manifest.json` listed, and +//! leaves anything else there alone. +//! +//! The drawing is walked once (the renderer's `Scene`), and every image is +//! a window of that walk. A text record's box is measured from the glyph +//! outlines of the bundled font ([`fonts`]) as the renderer laid them out; +//! a record's id is the model's reference ID (a path of them for a text +//! inside a block), and the file's handle is beside it. What the package +//! derives -- a text's readable string ([`text`]), a dimension's value, the +//! trust in its stored measurement and its label ([`dimension`]), lengths, +//! areas and outlines ([`geom`]), the frame of each picture ([`frame`]) -- +//! is computed here, not by the model or the renderer. //! //! # Licence //! @@ -26,3 +59,12 @@ pub mod fonts; pub mod frame; pub mod geom; pub mod text; + +mod package; + +pub use frame::{CropMode, CropSource, Rect}; +pub use package::{ + compact_string, export_file, export_package, normalize_string, Counts, CropReport, + ExcludeReason, Excluded, ExportError, ExportOptions, ExportReport, FrameReport, HeightClass, + ImageInfo, LevelInfo, Profile, WrittenFile, SCHEMA, +}; diff --git a/crates/uncad-export/src/package/mod.rs b/crates/uncad-export/src/package/mod.rs new file mode 100644 index 0000000..40f34d1 --- /dev/null +++ b/crates/uncad-export/src/package/mod.rs @@ -0,0 +1,1871 @@ +//! The package itself: [`export_package`] writes a directory an agent can +//! read. See the crate documentation for what it holds. + +mod output; +mod records; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use iron_render_cad::{ + limits::Cap, Background, Fonts, Hidden, LeftOutReason, Part, PngError, Scene, Space, + ToSvgOptions, View, +}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use uncad_model::model::{Entity, EntityId}; +use uncad_model::{CadDatabase, ToJsonOptions}; + +use crate::dimension::{ + cached_labels, display_text, is_angular, measurement_from_points, usable_stored_measurement, + DimDefaults, EffectiveStyle, +}; +use crate::fonts; +use crate::frame::{ + auto_padding, crop_source, header_extents, renderer_crop, snap_to_lattice, CropMode, + CropSource, Rect, EMPTY_RECT, +}; +use crate::geom; +use crate::text::decode_text; + +pub use output::WrittenFile; +use output::{clear_previous_package, to_rgb, Writer}; +use records::{ + capped_confidence, coord_decimals, handle_of, id_key, layer_name, placed_texts, text_record, + PlacedText, Record, Rounder, +}; + +/// The `$schema` value every JSON file in the package carries. +pub const SCHEMA: &str = "uncad-package/1"; + +/// The stroke width of every image, in pixels. +const STROKE_PX: f64 = 1.25; + +/// The image budget of one model family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Profile { + pub name: &'static str, + /// The overview's longest edge, in pixels. + pub overview_edge: u32, + /// The most patches (lattice x lattice squares) the overview may hold. + pub overview_patches: u32, + /// Tile edge, in pixels. + pub tile: u32, + /// Overlap between neighbouring tiles, in pixels. + pub overlap: u32, + /// The patch size every image size is rounded up to. + pub lattice: u32, +} + +impl Profile { + /// Claude's standard tier: 1568 px edge, 1568 patches of 28 px. + pub const CLAUDE: Profile = Profile { + name: "claude", + overview_edge: 1568, + overview_patches: 1568, + tile: 1092, + overlap: 224, + lattice: 28, + }; + /// Claude's high-resolution tier. + pub const CLAUDE_HIRES: Profile = Profile { + name: "claude-hires", + overview_edge: 2576, + overview_patches: 4784, + tile: 1932, + overlap: 392, + lattice: 28, + }; + /// OpenAI's patch-based models (gpt-5.x): 32 px patches. + pub const OPENAI_PATCH: Profile = Profile { + name: "openai-patch", + overview_edge: 2048, + overview_patches: 4096, + tile: 1600, + overlap: 320, + lattice: 32, + }; + + /// The profiles by name: `claude`, `claude-hires`, `openai-patch`. + pub fn by_name(name: &str) -> Option { + [ + Profile::CLAUDE, + Profile::CLAUDE_HIRES, + Profile::OPENAI_PATCH, + ] + .into_iter() + .find(|p| p.name == name) + } +} + +impl Default for Profile { + fn default() -> Self { + Profile::CLAUDE + } +} + +/// What [`export_package`] writes, and how. +#[derive(Debug, Clone, PartialEq)] +pub struct ExportOptions { + pub profile: Profile, + /// The deepest zoom level written (levels are `z1..=z_max`, each 2x the + /// previous). Default 5. + pub max_levels: u32, + /// The most tiles written across all levels; deeper levels are dropped + /// whole when this would be exceeded. Default 400. + pub max_tiles: usize, + /// The pixel height the dominant text class should reach at the deepest + /// level. Default 14. + pub target_text_px: f64, + pub crop: CropMode, + /// The padding around the crop, in drawing units, for the overview and + /// each frame's window. `None` (the default) is + /// [`crate::frame::auto_padding`]: 2 % of the longer side, at least 24 + /// output pixels. The sheets keep their own zero padding -- a sheet is + /// the paper exactly. + pub padding: Option, + /// Draw hidden entities faded (they never enter the records or the + /// crop). Default `false`. + pub include_hidden: bool, + /// Record files larger than this many KB are sharded. Default 96. + pub shard_kb: usize, + /// Also write `drawing.svg`. Default `false`. + pub svg: bool, + /// Also write `entities.json`, the whole model. Default `false`. + pub full: bool, + /// What the manifest records as the source's name (a file name, say). + pub source_name: Option, + /// Entities closer than this fraction of the crop's diagonal belong to + /// the same group; a detached group becomes its own frame. Default 0.05. + pub frame_gap: f64, + /// A detached group needs this many entities, or one text, to become a + /// frame. A group that does not stays in the overview only and is + /// listed in `frames_dropped` with the reason `below_min_entities`. + /// Default 20. + pub min_frame_entities: usize, + /// The most frames written (the primary one included); further groups + /// stay in the overview only and are listed in `frames_dropped` with + /// the reason `max_frames`. Default 8. + pub max_frames: usize, + /// The fonts text is drawn and measured with. Default + /// [`fonts::bundled()`], the same pixels and text boxes on every + /// machine; with any other the capital height is the renderer's default + /// (0.7 of the em), since this crate knows only the bundled face's. + pub fonts: Fonts, + /// Write `sheets.json` and one image per paper layout. Default `true`. + pub sheets: bool, +} + +impl Default for ExportOptions { + fn default() -> Self { + ExportOptions { + profile: Profile::CLAUDE, + max_levels: 5, + max_tiles: 400, + target_text_px: 14.0, + crop: CropMode::Auto, + padding: None, + include_hidden: false, + shard_kb: 96, + svg: false, + full: false, + source_name: None, + frame_gap: 0.05, + min_frame_entities: 20, + max_frames: 8, + fonts: fonts::bundled(), + sheets: true, + } + } +} + +/// Why [`export_package`] or [`export_file`] wrote no package. +#[derive(Debug)] +#[non_exhaustive] +pub enum ExportError { + /// A file or directory could not be written. + Io { + path: PathBuf, + source: std::io::Error, + }, + /// The renderer could not draw an image. + Render(PngError), + /// An image could not be re-encoded as 8-bit RGB. + Encode(String), + Json(serde_json::Error), + /// The whole-model `entities.json` could not be serialized. + Model(uncad_model::JsonError), + /// [`export_file`] could not read the drawing. + Parse(uncad::ParseError), +} + +impl std::fmt::Display for ExportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExportError::Io { path, source } => { + write!(f, "cannot write {}: {source}", path.display()) + } + ExportError::Render(e) => write!(f, "rendering failed: {e}"), + ExportError::Encode(e) => write!(f, "PNG encoding failed: {e}"), + ExportError::Json(e) => write!(f, "JSON serialization failed: {e}"), + ExportError::Model(e) => write!(f, "entities.json failed: {e}"), + ExportError::Parse(e) => write!(f, "cannot read the drawing: {e}"), + } + } +} + +impl std::error::Error for ExportError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ExportError::Io { source, .. } => Some(source), + ExportError::Render(e) => Some(e), + ExportError::Json(e) => Some(e), + ExportError::Model(e) => Some(e), + ExportError::Parse(e) => Some(e), + ExportError::Encode(_) => None, + } + } +} + +impl From for ExportError { + fn from(e: PngError) -> Self { + ExportError::Render(e) + } +} + +impl From for ExportError { + fn from(e: serde_json::Error) -> Self { + ExportError::Json(e) + } +} + +impl From for ExportError { + fn from(e: uncad_model::JsonError) -> Self { + ExportError::Model(e) + } +} + +/// One image of the package: where it is and how its pixels map to the +/// drawing. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ImageInfo { + pub id: String, + pub png: String, + /// `[width, height]` in pixels. + pub px: [u32; 2], + /// The world rectangle the image shows. + pub world: Rect, + /// Pixels per drawing unit. + pub ppu: f64, + /// Row-major `[a, b, c, d, e, f]` with `px = a x + b y + c` and + /// `py = d x + e y + f`: `[s, 0, -x0 s, 0, -s, y1 s]`. + pub world_to_px: [f64; 6], + pub px_to_world: [f64; 6], +} + +impl ImageInfo { + fn new(id: &str, png: &str, world: Rect, ppu: f64, width: u32, height: u32) -> ImageInfo { + ImageInfo { + id: id.to_string(), + png: png.to_string(), + px: [width, height], + world, + ppu, + world_to_px: [ppu, 0.0, -world.min_x * ppu, 0.0, -ppu, world.max_y * ppu], + px_to_world: [1.0 / ppu, 0.0, world.min_x, 0.0, -1.0 / ppu, world.max_y], + } + } + + /// The renderer's view of these pixels: the image's top-left corner at + /// the world rectangle's, `ppu` pixels to the unit -- exactly the map + /// `world_to_px` publishes. + fn view(&self) -> View { + View { + left: self.world.min_x, + top: self.world.max_y, + px_per_unit: self.ppu, + width: self.px[0], + height: self.px[1], + } + } + + /// A world rectangle in this image's pixels, `[x0, y0, x1, y1]` with y + /// down, rounded to whole pixels and **clipped to the image**. + /// + /// A record is listed on an image whenever its world box merely meets + /// it, so the unclipped box of a record that continues past the edge + /// named pixels the image does not have. The record's full extent is + /// its world `bbox`, and the rest of it is on the other images it + /// lists. + fn px_box(&self, r: &Rect) -> [i64; 4] { + let x0 = ((r.min_x - self.world.min_x) * self.ppu).floor(); + let x1 = ((r.max_x - self.world.min_x) * self.ppu).ceil(); + let y0 = ((self.world.max_y - r.max_y) * self.ppu).floor(); + let y1 = ((self.world.max_y - r.min_y) * self.ppu).ceil(); + let (w, h) = (i64::from(self.px[0]), i64::from(self.px[1])); + let clamp = |v: f64, hi: i64| -> i64 { + if v.is_nan() { + 0 + } else { + (v.clamp(-9e15, 9e15) as i64).clamp(0, hi) + } + }; + [ + clamp(x0, w), + clamp(y0, h), + clamp(x1, w).max(clamp(x0, w)), + clamp(y1, h).max(clamp(y0, h)), + ] + } +} + +/// One zoom level of a frame's tile pyramid. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct LevelInfo { + pub z: u32, + pub ppu: f64, + pub canvas_px: [u32; 2], + pub cols: u32, + pub rows: u32, + pub tile_px: u32, + pub overlap_px: u32, + pub step_px: u32, + pub tiles_written: usize, + pub tiles_empty: usize, +} + +/// One text height class of a frame and how legible it is at the deepest +/// level. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct HeightClass { + pub height: f64, + pub count: usize, + pub px_at_zmax: f64, + pub legible: bool, +} + +/// A frame: a region of the drawing with its own overview and tile pyramid +/// under `frames//`. `f0` is the primary frame (the largest connected +/// group of entities); detached groups -- a detail drawn beside the plan -- +/// get `f1`, `f2`, ... +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct FrameReport { + pub id: String, + /// `primary` or `detached`. + pub kind: String, + /// The tight bounds of the frame's entities. + pub content: Rect, + pub entities: usize, + pub texts: usize, + pub overview: ImageInfo, + pub levels: Vec, + pub z_max: u32, + /// `false` when the tile budget cut the pyramid short. + pub reached: bool, + pub height_classes: Vec, +} + +/// Why the picture leaves an entity out. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ExcludeReason { + /// Far larger than the rest of the drawing (the renderer's guard, 20x): + /// not drawn. + ScaleOutlier, + /// Far away from the rest of the drawing (20 of its diagonals): not + /// drawn. + FarOutlier, + /// Outside the crop's window. + OutsideView, +} + +/// A top-level entity the picture does not show. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Excluded { + /// Its reference ID in the model. + pub id: u64, + /// The file's handle for it, when it came from a file. + pub handle: Option, + pub type_name: String, + /// The world box it measured. + pub rect: Rect, + pub reason: ExcludeReason, +} + +/// What the overview shows and what it leaves out. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CropReport { + pub source: CropSource, + /// The world rectangle the overview shows, padding and lattice growth + /// included. + pub rect: Rect, + /// The tight bounds of the entities inside the crop; `None` when + /// nothing is. + pub content: Option, + /// The padding added on each side, in drawing units (the lattice snap + /// may add a little more on the right and bottom). + pub padding_units: f64, + /// The header's `$EXTMIN/$EXTMAX`, when stated and sane. + pub header_extents: Option, + /// Entities outside the picture, in drawing order. + pub excluded: Vec, +} + +/// How many of each thing the package holds. +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +pub struct Counts { + /// Top-level entities of model space. + pub entities: usize, + /// Every text record, model space and paper space together. + pub texts: usize, + /// The part of `texts` that is paper-space text (a sheet's title + /// block): records with `space: "paper"` and a `sheet`. + pub texts_paper: usize, + pub dimensions: usize, + pub geometry: usize, + pub regions: usize, + pub blocks: usize, + /// Every hidden entity the renderer met, those inside block definitions + /// included -- the number `report.json` prints as `hidden.count`. + pub hidden: usize, + pub excluded: usize, + pub tiles: usize, + pub frames: usize, + pub sheets: usize, +} + +/// What [`export_package`] wrote. +#[derive(Debug, Clone, PartialEq)] +pub struct ExportReport { + pub dir: PathBuf, + pub files: Vec, + /// The whole crop in one image. + pub overview: ImageInfo, + /// The frames, `f0` first. + pub frames: Vec, + pub crop: CropReport, + pub counts: Counts, + pub warnings: Vec, +} + +/// The most entities `report.json`'s `hidden` names; the file says so with +/// `handles_limit` and `handles_truncated`. +const MAX_HIDDEN_NAMED: usize = 100; + +/// The most vertices an outline may have before its self-intersection test +/// is skipped. [`geom::is_simple`] compares every pair of non-adjacent +/// segments, so the test costs O(n^2): a single closed polyline of 64 000 +/// vertices (a surveyed contour, routine in a GIS import) held the export +/// for 175 s for one boolean on one record. No outline in the corpus comes +/// near this cap: the largest region in `AutoCADSamples5.dwg` has 378 +/// vertices. +const MAX_SIMPLE_TEST_VERTICES: usize = 2_000; + +/// What a record says instead of claiming an untested outline is simple. +const UNTESTED_OUTLINE: &str = + "outline too large to test for self-intersection; the area assumes it does not cross itself"; + +/// Whether `vertices` outline a non-self-intersecting polygon, or `None` +/// when there are too many of them to ask. Unknown is reported as `null`, +/// never as `true`: a crossing outline's area is meaningless, and a reader +/// must be able to tell "checked, fine" from "not checked". +fn simple_outline(vertices: &[uncad_model::Point2D]) -> Option { + (vertices.len() <= MAX_SIMPLE_TEST_VERTICES).then(|| geom::is_simple(vertices)) +} + +/// The most pixels one drawing unit may become (see [`fit_overview`]). +const MAX_PPU: f64 = 1e9; + +/// The smallest world window an image of degenerate content gets, in +/// drawing units. Content with no size at all -- a lone POINT, coincident +/// entities, the base points of RAY/XLINE, which is all those entities +/// contribute -- gives the fit nothing to scale to. Such content gets a +/// window of its own, sized so the picture still reads as a drawing: at ten +/// units the scale stays in the hundreds of pixels per unit. +const MIN_CONTENT_EXTENT: f64 = 10.0; + +/// An image fitted to the profile: the pixel size within both the edge and +/// the patch budget, the world rectangle (padded and lattice-snapped) and +/// the scale. +struct OverviewFit { + rect: Rect, + width: u32, + height: u32, + ppu: f64, + padding: f64, +} + +fn fit_overview(content: &Rect, profile: &Profile, padding: Option) -> OverviewFit { + // Degenerate content first: a window around its centre, so the rest of + // the fit works on a rectangle with a size. + let grown; + let content = if content.longer_side().is_finite() && content.longer_side() > 0.0 { + content + } else { + let half = MIN_CONTENT_EXTENT / 2.0; + let (cx, cy) = if content.min_x.is_finite() && content.min_y.is_finite() { + (content.min_x, content.min_y) + } else { + (0.0, 0.0) + }; + grown = Rect::new(cx - half, cy - half, cx + half, cy + half); + &grown + }; + let lattice = f64::from(profile.lattice.max(1)); + let edge_patches = (f64::from(profile.overview_edge) / lattice) + .floor() + .max(1.0); + let (w, h) = (content.width().max(1e-9), content.height().max(1e-9)); + let aspect = w / h; + let budget = f64::from(profile.overview_patches); + let pw = edge_patches.min((budget * aspect).sqrt().floor()).max(1.0); + let ph = edge_patches + .min((budget / pw).floor()) + .min((pw / aspect).ceil()) + .max(1.0); + let seed_ppu = (pw * lattice / (1.04 * w)).min(ph * lattice / (1.04 * h)); + let padding = padding.unwrap_or_else(|| auto_padding(content, Some(seed_ppu))); + let padded = content.padded(padding); + let ppu = (pw * lattice / padded.width()).min(ph * lattice / padded.height()); + // A zero-size window (a caller that forced zero padding on degenerate + // content) makes `1568 / 0` infinite; the cap also keeps the scale of a + // drawing a millionth of a unit across representable. + let ppu = if ppu.is_finite() && ppu > 0.0 { + ppu.min(MAX_PPU) + } else { + 1.0 + }; + let (rect, width, height) = snap_to_lattice(&padded, ppu, profile.lattice); + OverviewFit { + rect, + width, + height, + ppu, + padding, + } +} + +/// The name the package's JSON gives a reason the drawing hides an entity. +fn hidden_name(h: Hidden) -> &'static str { + match h { + Hidden::Invisible => "invisible", + Hidden::Defpoints => "defpoints", + Hidden::LayerOff => "layer_off", + Hidden::LayerFrozen => "layer_frozen", + Hidden::FrozenInViewport => "frozen_in_viewport", + Hidden::LayerNoPlot => "layer_no_plot", + _ => "hidden", + } +} + +/// The name the package's JSON gives a renderer bound. +fn cap_name(c: Cap) -> &'static str { + match c { + Cap::EntityPoints => "entity_points", + Cap::DocumentBytes => "document_bytes", + Cap::EntityBytes => "entity_bytes", + Cap::BlockRefs => "block_refs", + Cap::HatchTile => "hatch_tile", + Cap::NotANumber => "not_a_number", + Cap::OutOfRange => "out_of_range", + _ => "other", + } +} + +fn area_unit(unit: &str) -> String { + format!("{unit}2") +} + +/// The normalisation `strings.json` keys use: Unicode NFKC (so `㎡` is +/// `m2`, `²` is `2`, full-width digits are ASCII), the fraction slash +/// U+2044 as `/`, case folded, whitespace collapsed to single spaces. +pub fn normalize_string(s: &str) -> String { + use unicode_normalization::UnicodeNormalization; + let folded: String = s.nfkc().collect::().replace('\u{2044}', "/"); + folded + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +/// [`normalize_string`] with every space removed as well, the second key +/// each string is indexed under (so `32.5 m2` and `32.5m2` meet). +pub fn compact_string(s: &str) -> String { + normalize_string(s).replace(' ', "") +} + +/// Parses the drawing at `input` with its header and writes its package +/// into `dir` -- [`export_package`] for a file. When `options` names no +/// source, the manifest records the file's name. +pub fn export_file( + input: &Path, + dir: &Path, + options: &ExportOptions, +) -> Result { + let (db, header) = uncad::parse_with_header(input).map_err(ExportError::Parse)?; + let named; + let options = if options.source_name.is_none() { + named = ExportOptions { + source_name: input.file_name().map(|n| n.to_string_lossy().into_owned()), + ..options.clone() + }; + &named + } else { + options + }; + export_package(&db, Some(&header), dir, options) +} + +/// Writes the package for `db` into `dir` (created if needed). `header` is +/// the drawing's header as [`uncad::parse_with_header`] returns it -- the +/// units, the extents the `Auto` crop may take, `$LUPREC` and the dimension +/// variables a style falls back on; without one the package says its units +/// are drawing units (`du`) and uses the format's defaults. +/// +/// A previous uncad package in `dir` is cleared first -- every file its +/// `manifest.json` listed, and the directories under `frames/` and +/// `sheets/` that empties -- so a re-export with other options leaves no +/// stale shards or tiles behind; nothing a manifest did not list is +/// touched. `manifest.json` is written last: a directory holding one is a +/// finished package. +pub fn export_package( + db: &CadDatabase, + header: Option<&uncad::Header>, + dir: &Path, + options: &ExportOptions, +) -> Result { + let profile = options.profile; + std::fs::create_dir_all(dir).map_err(|source| ExportError::Io { + path: dir.to_path_buf(), + source, + })?; + clear_previous_package(dir); + let mut warnings: Vec = Vec::new(); + let fonts = &options.fonts; + let cap_height = if *fonts == fonts::bundled() { + fonts::UNCAD_SANS_CAP_HEIGHT + } else { + iron_render_cad::DEFAULT_CAP_HEIGHT + }; + + // --- render once, and read what the renderer framed ------------------- + let header_rect = header.and_then(header_extents); + let svg_options = ToSvgOptions { + space: Space::Model, + padding: 0.0, + crop: renderer_crop(options.crop, header_rect), + include_hidden: options.include_hidden, + cap_height, + ..ToSvgOptions::default() + }; + let scene = Scene::new(db, svg_options); + let parts = scene.parts(); + let top: BTreeMap = db.entities.iter().map(|e| (e.common().id, e)).collect(); + // The picture's frame, before padding: the window of a window crop, + // the entities' bounds otherwise. + let framed: Option = scene.crop.content.map(Rect::from); + let window_crop = matches!(options.crop, CropMode::Fixed(_)) + || (options.crop == CropMode::Header && header_rect.is_some()); + // What the crop shows. Records cover what the picture shows: not the + // hidden entities and not the ones the crop left out (they are listed + // in report.json). + let shown: Vec<(&Part, &Entity)> = parts + .iter() + .filter(|p| p.hidden.is_none() && p.left_out.is_none()) + .filter_map(|p| top.get(&p.id).map(|e| (p, *e))) + .collect(); + let shown_ids: BTreeSet = shown.iter().map(|(p, _)| p.id).collect(); + let content: Option = if window_crop { + Rect::bounding( + shown + .iter() + .filter_map(|(p, _)| p.extent.map(Rect::from)) + .collect::>() + .iter(), + ) + } else { + framed + }; + let crop_rect = framed.unwrap_or(EMPTY_RECT); + let source = crop_source( + options.crop, + header_rect, + scene.crop.stated_taken, + framed.as_ref(), + ); + + // --- the overview: the whole crop, sized to the profile ---------------- + let fit = fit_overview(&crop_rect, &profile, options.padding); + // The short edge: `fit_overview` pins the long edge at the patch + // budget, so the aspect ratio can only squeeze the other one. + if fit.width.min(fit.height) < 200 { + warnings.push(format!( + "TinyOverview: the overview is only {} x {} px; the drawing's aspect leaves little of the patch budget", + fit.width, fit.height + )); + } + let overview = ImageInfo::new( + "ov", + "overview.png", + fit.rect, + fit.ppu, + fit.width, + fit.height, + ); + // What the crop leaves out is not drawn either: a 3256x INSERT clipped + // by the viewBox would still cross the whole picture. + let overview_png = + to_rgb( + &scene.png(&overview.view(), STROKE_PX, fonts, Background::White, |p| { + p.left_out.is_none() + })?, + )?; + + // --- units and rounding ----------------------------------------------- + let units = header.and_then(uncad::Header::units); + let unit = units.as_ref().map_or("du".to_string(), |u| u.name.clone()); + let levels = i32::try_from(options.max_levels) + .unwrap_or(i32::MAX) + .min(64); + let coords = coord_decimals( + header.and_then(|h| h.luprec).unwrap_or(4), + fit.ppu * 2f64.powi(levels), + ); + let rounder = Rounder { + coords, + derived: coords + 2, + }; + + // --- texts, measured by the renderer with the package's fonts ---------- + let boxes = scene.text_boxes(fonts)?; + let dimension_ids: BTreeSet = shown + .iter() + .filter(|(_, e)| matches!(e, Entity::Dimension(_))) + .map(|(p, _)| p.id) + .collect(); + // A dimension's label is drawn from its cached block, and is carried by + // its own record's `display`. + let texts = placed_texts(&db.tables, &top, &boxes, |id| { + shown_ids.contains(&id) && !dimension_ids.contains(&id) + }); + let unshaped_texts = texts.iter().filter(|t| t.unshaped > 0).count(); + if unshaped_texts > 0 { + warnings.push(format!( + "UnshapedGlyphs: {unshaped_texts} texts hold characters the fonts lack (drawn as boxes)" + )); + } + let measured_why = if *fonts == fonts::bundled() { + "glyph outlines as the renderer laid them out, bundled font" + } else { + "glyph outlines as the renderer laid them out" + }; + + // --- records ----------------------------------------------------------- + let extent_of: BTreeMap = shown + .iter() + .filter_map(|(p, _)| p.extent.map(|r| (p.id, Rect::from(r)))) + .collect(); + let px_map = |bbox: &Rect| -> Value { + let mut m = Map::new(); + m.insert("ov".to_string(), json!(overview.px_box(bbox))); + Value::Object(m) + }; + let tiles_for = |_: &Rect| -> Vec { Vec::new() }; + let with_images = |mut r: Record| -> Record { + r.value.insert("tiles".into(), json!(tiles_for(&r.bbox))); + r.value.insert("px".into(), px_map(&r.bbox)); + r + }; + + let mut text_records: Vec = texts + .iter() + .map(|t| with_images(text_record(t, &rounder, "model", None, measured_why))) + .collect(); + text_records.sort_by_cached_key(|r| id_key(&r.id)); + + let defaults = header.map(DimDefaults::from_header).unwrap_or_default(); + let dim_records = dimension_records(db, &shown, &extent_of, &defaults, &unit, &rounder)? + .into_iter() + .map(&with_images) + .collect::>(); + let (geo_records, region_records) = + geometry_records(&shown, &extent_of, &texts, units.as_ref(), &unit, &rounder); + let geo_records: Vec = geo_records.into_iter().map(&with_images).collect(); + let region_records: Vec = region_records.into_iter().map(&with_images).collect(); + let (block_records, instances) = block_records(&shown, &extent_of, &rounder); + let block_records: Vec = block_records.into_iter().map(&with_images).collect(); + + // --- strings ------------------------------------------------------------- + let mut strings: BTreeMap> = BTreeMap::new(); + let mut index = |text: &str, id: &str| { + let key = normalize_string(text); + if key.is_empty() { + return; + } + let compact = compact_string(text); + if compact != key { + strings.entry(compact).or_default().insert(id.to_string()); + } + strings.entry(key).or_default().insert(id.to_string()); + }; + for t in &texts { + index(&t.text, &t.id); + } + for r in &dim_records { + if let Some(Value::String(d)) = r.value.get("display") { + index(d, &r.id); + } + } + for r in &block_records { + if let Some(Value::Object(attribs)) = r.value.get("attribs") { + for value in attribs.values() { + if let Value::String(s) = value { + index(s, &r.id); + } + } + } + } + + // --- the frames ------------------------------------------------------------ + let frame = FrameReport { + id: "f0".into(), + kind: "primary".into(), + content: crop_rect, + entities: extent_of.len(), + texts: texts + .iter() + .filter(|t| crop_rect.intersects(&t.bbox)) + .count(), + overview: overview.clone(), + levels: Vec::new(), + z_max: 0, + reached: true, + height_classes: Vec::new(), + }; + let frame_reports = vec![frame]; + + // --- write ----------------------------------------------------------------- + let mut writer = Writer { + dir, + files: Vec::new(), + shard_index: Vec::new(), + units: json!({ + "name": unit, + "insunits": header.and_then(|h| h.insunits), + "to_mm": units.as_ref().and_then(|u| u.to_mm), + "source": if units.is_some() { "header" } else { "none" }, + }), + shard_kb: options.shard_kb, + }; + writer.write_bytes("overview.png", &overview_png, "image")?; + writer.write_records("texts", "text", &text_records)?; + writer.write_records("dimensions", "dimension", &dim_records)?; + writer.write_records("geometry", "geometry", &geo_records)?; + writer.write_records("regions", "region", ®ion_records)?; + // The INSERT instances are records like any other kind: they go through + // the same shard rule and land in `shard_index`. The definitions -- a + // table, not a per-entity record -- are in drawing.json. + writer.write_records("blocks", "blocks", &block_records)?; + writer.write_json( + "strings.json", + &json!({ + "$schema": SCHEMA, + "normalization": "NFKC, fraction slash to '/', case fold, whitespace collapsed; each string also under its key with spaces removed", + "ids": "record ids: the drawing's entity reference ID as a decimal number, or `/` for a text drawn inside a block. The kind is not in the id -- resolve it with manifest.shard_index, whose `first_key`/`last_key` bracket each file's ids by their first number (`int(id.split('/')[0])`). One id can be in two kinds at once: a closed polyline is both a geometry and a region record.", + "strings": strings, + }), + "strings", + )?; + writer.write_json( + "drawing.json", + &drawing_json(db, header, parts, &top, &instances, &writer.units)?, + "drawing", + )?; + + // optional whole-model files + let mut svg_origin: Option<[f64; 2]> = None; + if options.svg { + let window = crop_rect.padded(auto_padding(&crop_rect, None)); + let stroke = (window.diagonal() / 6000.0).max(0.01); + let svg = scene.svg(window.into(), stroke, |p| p.left_out.is_none()); + writer.write_bytes("drawing.svg", svg.as_bytes(), "svg")?; + svg_origin = Some([scene.origin.x, scene.origin.y]); + } + if options.full { + let text = db.to_json(ToJsonOptions { pretty: false })?; + writer.write_bytes("entities.json", text.as_bytes(), "entities")?; + } + + // --- report.json ------------------------------------------------------- + let mut hidden_by_reason: BTreeMap<&str, usize> = BTreeMap::new(); + let mut hidden_named: Vec<(u64, Value)> = Vec::new(); + for p in parts { + if let Some(reason) = p.hidden { + *hidden_by_reason.entry(hidden_name(reason)).or_default() += 1; + if hidden_named.len() < MAX_HIDDEN_NAMED { + let handle = top + .get(&p.id) + .map_or(Value::Null, |e| handle_of(e.common())); + hidden_named.push((p.id.value(), handle)); + } + } + } + let limits = &scene.limits; + if limits.engaged() { + warnings.push(format!( + "the drawing hit the renderer's robustness limits: {}", + limits_summary(limits) + )); + } + let excluded: Vec = scene + .crop + .left_out + .iter() + .map(|l| Excluded { + id: l.id.value(), + handle: top + .get(&l.id) + .and_then(|e| e.common().source_handle.resolved().cloned()), + type_name: l.type_name.clone(), + rect: Rect::from(l.extent), + reason: match l.reason { + LeftOutReason::ScaleOutlier => ExcludeReason::ScaleOutlier, + LeftOutReason::FarOutlier => ExcludeReason::FarOutlier, + _ => ExcludeReason::OutsideView, + }, + }) + .collect(); + let crop_report = CropReport { + source, + rect: fit.rect, + content, + padding_units: fit.padding, + header_extents: header_rect, + excluded, + }; + // "What is missing from the picture, and why" is one list: the crop's + // own exclusions plus every entity a robustness bound acted on, each + // naming the bound under `reason`. + let excluded_json: Vec = crop_report + .excluded + .iter() + .map(|e| json!(e)) + .chain(limits.dropped.iter().map(|d| { + json!({ + "id": d.id.value(), + "handle": top.get(&d.id).map_or(Value::Null, |e| handle_of(e.common())), + "type_name": d.type_name, + "reason": cap_name(d.cap), + }) + })) + .collect(); + let written_texts = &text_records; + let counts = Counts { + entities: parts.len(), + texts: written_texts.len(), + texts_paper: 0, + dimensions: dim_records.len(), + geometry: geo_records.len(), + regions: region_records.len(), + blocks: block_records.len(), + hidden: scene.hidden, + excluded: excluded_json.len(), + tiles: 0, + frames: frame_reports.len(), + sheets: 0, + }; + let hidden_top_level: usize = hidden_by_reason.values().sum(); + // `count` is the number `manifest.counts.hidden` prints, every hidden + // entity the renderer met; the split says how many of them the + // top-level reasons cover. + let report_value = json!({ + "$schema": SCHEMA, + "excluded": excluded_json, + "hidden": { + "count": scene.hidden, + "top_level": hidden_top_level, + "inside_blocks": scene.hidden.saturating_sub(hidden_top_level), + "covers": "top_level", + "by_reason": hidden_by_reason, + "ids": hidden_named.iter().map(|(id, _)| *id).collect::>(), + "handles": hidden_named.iter().map(|(_, h)| h.clone()).collect::>(), + "handles_limit": MAX_HIDDEN_NAMED, + "handles_truncated": hidden_top_level > hidden_named.len(), + }, + "unsupported_types": scene.unsupported_types, + "empty_blocks": scene.empty_blocks, + "unresolved_block_refs": scene.unresolved_block_refs.iter().map(|id| id.value()).collect::>(), + "limits": limits_json(limits, &top), + "warnings": warnings, + }); + writer.files.push(WrittenFile { + path: "report.json".into(), + bytes: None, + kind: "report".into(), + }); + + // --- manifest.json and README.txt, last (they list the files) ------------ + let manifest = manifest_json(ManifestInput { + header, + options, + writer: &mut writer, + crop: &crop_report, + svg_origin, + overview: &overview, + frames: &frame_reports, + counts: &counts, + texts: written_texts, + dims: &dim_records, + regions: ®ion_records, + warnings: &warnings, + }); + let manifest_text = serde_json::to_string_pretty(&manifest)?; + let readme = readme(options); + std::fs::write(dir.join("README.txt"), readme.as_bytes()).map_err(|source| { + ExportError::Io { + path: dir.join("README.txt"), + source, + } + })?; + let report_text = serde_json::to_string_pretty(&report_value)?; + std::fs::write(dir.join("report.json"), &report_text).map_err(|source| ExportError::Io { + path: dir.join("report.json"), + source, + })?; + // manifest.json is written last, after every file it names: a directory + // that holds one is a finished package, and one that does not is + // nothing a reader should trust. (It is also what the next run's + // `clear_previous_package` reads.) + std::fs::write(dir.join("manifest.json"), &manifest_text).map_err(|source| { + ExportError::Io { + path: dir.join("manifest.json"), + source, + } + })?; + + Ok(ExportReport { + dir: dir.to_path_buf(), + files: writer.files, + overview, + frames: frame_reports, + crop: crop_report, + counts, + warnings, + }) +} + +/// The robustness bounds a render engaged, in a sentence. +fn limits_summary(l: &iron_render_cad::limits::LimitReport) -> String { + let mut parts = Vec::new(); + for (n, what) in [ + (l.oversized_entities, "entities with too many points"), + ( + l.block_refs_dropped, + "block references past the depth or expansion budget", + ), + (l.hatch_patterns_dropped, "hatch patterns too fine to draw"), + (l.entities_dropped, "entities past the document budget"), + (l.truncated_parts, "entities drawn only in part"), + ( + l.unreadable_entities, + "entities with coordinates that are not numbers", + ), + (l.out_of_range_entities, "entities too far from the origin"), + ] { + if n > 0 { + parts.push(format!("{n} {what}")); + } + } + parts.join(", ") +} + +fn limits_json( + l: &iron_render_cad::limits::LimitReport, + top: &BTreeMap, +) -> Value { + json!({ + "oversized_entities": l.oversized_entities, + "block_refs_dropped": l.block_refs_dropped, + "hatch_patterns_dropped": l.hatch_patterns_dropped, + "entities_dropped": l.entities_dropped, + "truncated_parts": l.truncated_parts, + "unreadable_entities": l.unreadable_entities, + "out_of_range_entities": l.out_of_range_entities, + "dropped": l.dropped.iter().map(|d| json!({ + "id": d.id.value(), + "handle": top.get(&d.id).map_or(Value::Null, |e| handle_of(e.common())), + "type_name": d.type_name, + "cap": cap_name(d.cap), + })).collect::>(), + }) +} + +/// The dimension records: every shown DIMENSION with its value, where the +/// value came from, the value its points give, the label and its source. +fn dimension_records( + db: &CadDatabase, + shown: &[(&Part, &Entity)], + extent_of: &BTreeMap, + defaults: &DimDefaults, + unit: &str, + rounder: &Rounder, +) -> Result, ExportError> { + let labels = cached_labels(&db.tables); + let mut out = Vec::new(); + for (part, e) in shown { + let Entity::Dimension(d) = e else { continue }; + let bbox = extent_of.get(&part.id).copied().unwrap_or_else(|| { + let p = d.definition_point.unwrap_or_default(); + Rect::at(p.x, p.y) + }); + let angular = is_angular(d.kind); + let from_points = measurement_from_points(d); + let stored = usable_stored_measurement(d.measurement, d.kind, from_points); + // `confidence` follows the source, not merely "there is a number": a + // value the definition points gave is exact arithmetic on what the + // file stores, but it is not what the drawing was measured at, and + // a reader deciding whether to trust the number over the label + // needs the two kept apart. + let (measurement, source, confidence) = match (stored, from_points) { + (Some(m), _) => (Some(m), "act_measurement", "stored"), + (None, Some(p)) => (Some(p), "from_points", "exact"), + (None, None) => (None, "none", "unavailable"), + }; + let delta = match (stored, from_points) { + (Some(m), Some(p)) => Some(rounder.derived(m - p)), + _ => None, + }; + let style = EffectiveStyle::resolve( + d.style_name + .resolved() + .and_then(|name| db.tables.dim_styles.get(name)), + defaults, + ); + let shown_text = display_text(d, &style, measurement, labels.get(d.block_name.name())); + let (confidence, why) = capped_confidence(confidence, d.common.confidence); + let point = |p: Option| p.map_or(Value::Null, |p| rounder.pt3(p)); + let mut v = Map::new(); + v.insert("id".into(), json!(d.common.id.value().to_string())); + v.insert("handle".into(), handle_of(&d.common)); + v.insert("kind".into(), serde_json::to_value(d.kind)?); + v.insert("layer".into(), json!(layer_name(&d.common.layer))); + v.insert("dimstyle".into(), json!(d.style_name.name())); + v.insert( + "measurement".into(), + json!(measurement.map(|m| rounder.derived(m))), + ); + v.insert("measurement_source".into(), json!(source)); + v.insert( + "measurement_from_points".into(), + json!(from_points.map(|m| rounder.derived(m))), + ); + v.insert("delta".into(), json!(delta)); + v.insert("unit".into(), json!(if angular { "deg" } else { unit })); + v.insert("dimlfac".into(), json!(style.dimlfac)); + v.insert("display".into(), json!(shown_text.text)); + if shown_text.raw != shown_text.text && !shown_text.raw.is_empty() { + v.insert("display_raw".into(), json!(shown_text.raw)); + } + v.insert( + "display_source".into(), + serde_json::to_value(shown_text.source)?, + ); + if let uncad_model::model::TextOverride::Literal(s) = &d.text_override { + v.insert("user_text".into(), json!(s)); + } + v.insert( + "points".into(), + json!({ + "definition": point(d.definition_point), + "extension1": point(d.points.extension1), + "extension2": point(d.points.extension2), + "radial": point(d.points.radial), + "arc": point(d.points.arc), + }), + ); + if d.kind == Some(uncad_model::model::DimensionKind::Rotated) { + v.insert( + "rotation_deg".into(), + json!(rounder.derived(d.rotation.to_degrees())), + ); + } + if let Some(axis) = d.ordinate_axis { + v.insert("ordinate_axis".into(), serde_json::to_value(axis)?); + } + v.insert("definition_point".into(), point(d.definition_point)); + v.insert("text_at".into(), rounder.pt2(d.text_midpoint)); + v.insert("confidence".into(), json!(confidence)); + if let Some(why) = why { + v.insert("why".into(), json!(why)); + } + v.insert("bbox".into(), rounder.rect(&bbox)); + out.push(Record { + id: d.common.id.value().to_string(), + bbox, + value: v, + }); + } + out.sort_by_cached_key(|r| id_key(&r.id)); + Ok(out) +} + +/// The geometry records (every shown entity that is not a text, a +/// dimension or a block instance, and measured an extent) and the region +/// records (the closed polylines among them that enclose an area). +fn geometry_records( + shown: &[(&Part, &Entity)], + extent_of: &BTreeMap, + texts: &[PlacedText], + units: Option<&uncad::Units>, + unit: &str, + rounder: &Rounder, +) -> (Vec, Vec) { + use std::f64::consts::{PI, TAU}; + let mut geometry: Vec = Vec::new(); + let mut regions: Vec = Vec::new(); + // The outlines of the regions, world coordinates, for their labels. + let mut outlines: BTreeMap> = BTreeMap::new(); + for (part, e) in shown { + if matches!( + e, + Entity::Text(_) + | Entity::MText(_) + | Entity::Attrib(_) + | Entity::Attdef(_) + | Entity::Dimension(_) + | Entity::Insert(_) + ) { + continue; + } + let Some(bbox) = extent_of.get(&part.id).copied() else { + continue; + }; + let id = part.id.value().to_string(); + let mut v = Map::new(); + v.insert("id".into(), json!(id)); + v.insert("handle".into(), handle_of(e.common())); + v.insert("type".into(), json!(e.type_name())); + v.insert("layer".into(), json!(layer_name(&e.common().layer))); + let mut confidence = "exact"; + let mut why: Option<&str> = None; + match e { + Entity::Line(l) => { + v.insert("from".into(), rounder.pt3(l.start_point)); + v.insert("to".into(), rounder.pt3(l.end_point)); + // `length` is the length the file holds: the 3D distance. + // `length_plan` and `dz` are added only when the line leaves + // the plane, the only signal in an otherwise planar record + // that it does. + let (dx, dy, dz) = ( + l.end_point.x - l.start_point.x, + l.end_point.y - l.start_point.y, + l.end_point.z - l.start_point.z, + ); + let plan = dx.hypot(dy); + v.insert("length".into(), json!(rounder.derived(plan.hypot(dz)))); + if rounder.derived(dz) != 0.0 { + v.insert("length_plan".into(), json!(rounder.derived(plan))); + v.insert("dz".into(), json!(rounder.derived(dz))); + } + } + Entity::Arc(a) => { + // Its own plane to the world: a mirrored arc (normal + // (0,0,-1)) runs clockwise there, so its world arc runs + // counter-clockwise from the image of its end to the image + // of its start. + let plane = geom::plane_to_world(a.extrusion, a.center.z); + let center = plane.apply(uncad_model::Point2D { + x: a.center.x, + y: a.center.y, + }); + let (start, end) = if geom::is_similarity(&plane) { + let turn = |angle: f64| { + let (s, c) = angle.sin_cos(); + (plane.b * c + plane.d * s).atan2(plane.a * c + plane.c * s) + }; + if plane.determinant() < 0.0 { + (turn(a.end_angle), turn(a.start_angle)) + } else { + (turn(a.start_angle), turn(a.end_angle)) + } + } else { + confidence = "estimated"; + why = Some("drawn in a tilted plane: the centre is its plan projection, the angles are the arc's own"); + (a.start_angle, a.end_angle) + }; + let mut sweep = end - start; + if sweep <= 0.0 { + sweep += TAU; + } + v.insert("center".into(), rounder.pt2(center)); + v.insert("r".into(), json!(rounder.derived(a.radius))); + v.insert( + "start_deg".into(), + json!(rounder.derived(start.to_degrees())), + ); + v.insert("end_deg".into(), json!(rounder.derived(end.to_degrees()))); + v.insert( + "sweep_deg".into(), + json!(rounder.derived(sweep.to_degrees())), + ); + v.insert("length".into(), json!(rounder.derived(a.radius * sweep))); + } + Entity::Circle(c) => { + let plane = geom::plane_to_world(c.extrusion, c.center.z); + let center = plane.apply(uncad_model::Point2D { + x: c.center.x, + y: c.center.y, + }); + if !geom::is_similarity(&plane) { + why = Some("drawn in a tilted plane: the centre is its plan projection"); + } + v.insert("center".into(), rounder.pt2(center)); + v.insert("r".into(), json!(rounder.derived(c.radius))); + v.insert("length".into(), json!(rounder.derived(TAU * c.radius))); + v.insert( + "area".into(), + json!(rounder.derived(PI * c.radius * c.radius)), + ); + } + Entity::Ellipse(el) => { + let a = el.major_axis_endpoint.x.hypot(el.major_axis_endpoint.y); + v.insert("center".into(), rounder.pt3(el.center)); + v.insert("major_axis".into(), rounder.pt3(el.major_axis_endpoint)); + v.insert("ratio".into(), json!(rounder.derived(el.axis_ratio))); + let full = (el.end_angle - el.start_angle - TAU).abs() < 1e-9 + || (el.start_angle == 0.0 && el.end_angle == 0.0); + if full { + v.insert( + "area".into(), + json!(rounder.derived(PI * a * a * el.axis_ratio)), + ); + } else { + confidence = "unavailable"; + why = Some("elliptical arc length is not computed"); + } + } + Entity::LwPolyline(p) | Entity::Polyline2D(p) => { + // The vertices in the world, when the plane maps arcs to + // arcs; a tilted plane's are projected, and its lengths and + // area are measured in its own plane instead. + let plane = geom::plane_to_world(p.extrusion, p.elevation); + let world = geom::map_polyline(&p.vertices, &plane); + let tilted = world.is_none(); + let world_vertices = world.unwrap_or_else(|| { + p.vertices + .iter() + .map(|v| uncad_model::PolylineVertex { + point: plane.apply(v.point), + ..*v + }) + .collect() + }); + let measured_on = if tilted { &p.vertices } else { &world_vertices }; + let bulged = world_vertices.iter().any(|v| v.bulge != 0.0); + v.insert("closed".into(), json!(p.closed)); + v.insert( + "vfmt".into(), + json!(if bulged { "[x,y,bulge]" } else { "[x,y]" }), + ); + let vertices: Vec = world_vertices + .iter() + .map(|vx| { + if bulged { + json!([ + rounder.coord(vx.point.x), + rounder.coord(vx.point.y), + vx.bulge + ]) + } else { + rounder.pt2(vx.point) + } + }) + .collect(); + v.insert("vertices".into(), json!(vertices)); + let length = geom::polyline_length(measured_on, p.closed); + v.insert( + if p.closed { "perimeter" } else { "length" }.into(), + json!(rounder.derived(length)), + ); + if tilted { + confidence = "estimated"; + why = Some("drawn in a tilted plane: the vertices are their plan projection, the length and area are measured in the entity's own plane"); + } + if let Some(area) = geom::polyline_area(measured_on, p.closed) { + let signed = geom::polyline_signed_area(&world_vertices, p.closed); + let points: Vec = + world_vertices.iter().map(|v| v.point).collect(); + let simple = simple_outline(&points); + v.insert("area".into(), json!(rounder.derived(area))); + v.insert( + "orientation".into(), + json!(if signed >= 0.0 { "ccw" } else { "cw" }), + ); + v.insert("simple".into(), json!(simple)); + let outline_why = match simple { + Some(true) => None, + Some(false) => { + confidence = "unavailable"; + Some("self-intersecting outline: the area has no meaning") + } + None => { + if confidence == "exact" { + confidence = "estimated"; + } + Some(UNTESTED_OUTLINE) + } + }; + why = outline_why.or(why); + // Whatever the area could be measured of gets a region + // record: a closed two-vertex bulged polyline (AutoCAD's + // DONUT) is as much a closed shape as a triangle. + if p.closed && p.vertices.len() >= 2 { + let (region_confidence, capped) = + capped_confidence(confidence, e.common().confidence); + let mut r = Map::new(); + r.insert("id".into(), json!(id)); + r.insert("handle".into(), handle_of(e.common())); + r.insert("src".into(), json!(e.type_name())); + r.insert("layer".into(), json!(layer_name(&e.common().layer))); + r.insert("area".into(), json!(rounder.derived(area))); + r.insert("area_unit".into(), json!(area_unit(unit))); + r.insert( + "area_si".into(), + json!(units + .and_then(|u| u.to_mm) + .map(|mm| rounder.derived(area * mm * mm / 1e6))), + ); + r.insert("perimeter".into(), json!(rounder.derived(length))); + r.insert( + "centroid".into(), + rounder.pt2(geom::polygon_centroid(&points)), + ); + r.insert("vertex_count".into(), json!(p.vertices.len())); + r.insert("simple".into(), json!(simple)); + // The same `confidence` *and the same `why`* as the + // geometry record this region is built from. + r.insert("confidence".into(), json!(region_confidence)); + if let Some(w) = capped.or(why) { + r.insert("why".into(), json!(w)); + } + r.insert("bbox".into(), rounder.rect(&bbox)); + regions.push(Record { + id: id.clone(), + bbox, + value: r, + }); + if points.len() >= 3 { + outlines.insert(id.clone(), points); + } + } + } + } + Entity::Polyline3D(p) => { + v.insert("closed".into(), json!(p.closed)); + v.insert("vertex_count".into(), json!(p.vertices.len())); + let len: f64 = p + .vertices + .windows(2) + .map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y)) + .sum(); + v.insert("length_plan".into(), json!(rounder.derived(len))); + } + Entity::Spline(s) => { + v.insert("control_points".into(), json!(s.control_points.len())); + v.insert("fit_points".into(), json!(s.fit_points.len())); + confidence = "estimated"; + why = Some("spline length is not evaluated"); + } + Entity::Point(p) => { + v.insert("at".into(), rounder.pt3(p.position)); + } + Entity::Solid(s) | Entity::Trace(s) => { + let plane = geom::plane_to_world(s.extrusion, s.elevation); + v.insert( + "corners".into(), + json!([ + rounder.pt2(plane.apply(s.corner1)), + rounder.pt2(plane.apply(s.corner2)), + rounder.pt2(plane.apply(s.corner3)), + rounder.pt2(plane.apply(s.corner4)) + ]), + ); + } + Entity::Hatch(h) => { + v.insert("paths".into(), json!(h.boundary_paths.len())); + v.insert("solid_fill".into(), json!(h.solid_fill)); + confidence = "estimated"; + } + Entity::Leader(l) => { + v.insert("vertex_count".into(), json!(l.vertices.len())); + v.insert("arrowhead".into(), json!(l.has_arrowhead)); + } + Entity::Ray(r) | Entity::XLine(r) => { + v.insert("point".into(), rounder.pt3(r.point)); + v.insert("vector".into(), rounder.pt3(r.vector)); + } + _ => { + confidence = "estimated"; + } + } + let (confidence, capped) = capped_confidence(confidence, e.common().confidence); + v.insert("unit".into(), json!(unit)); + v.insert("confidence".into(), json!(confidence)); + if let Some(w) = capped.or(why) { + v.insert("why".into(), json!(w)); + } + v.insert("bbox".into(), rounder.rect(&bbox)); + geometry.push(Record { id, bbox, value: v }); + } + geometry.sort_by_cached_key(|r| id_key(&r.id)); + regions.sort_by_cached_key(|r| id_key(&r.id)); + label_regions(&outlines, texts, &mut regions); + (geometry, regions) +} + +/// Gives every region the ids of the texts whose anchor lies inside it and +/// in no smaller region. +fn label_regions( + outlines: &BTreeMap>, + texts: &[PlacedText], + regions: &mut [Record], +) { + let mut labels: BTreeMap> = BTreeMap::new(); + for t in texts { + let mut best: Option<(f64, &str)> = None; + for r in regions.iter() { + if !r.bbox.intersects(&Rect::at(t.anchor.x, t.anchor.y)) { + continue; + } + let Some(vertices) = outlines.get(&r.id) else { + continue; + }; + if geom::point_in_polygon(t.anchor, vertices) { + let area = r + .value + .get("area") + .and_then(Value::as_f64) + .unwrap_or(f64::INFINITY); + if best.is_none_or(|(a, _)| area < a) { + best = Some((area, r.id.as_str())); + } + } + } + if let Some((_, id)) = best { + labels.entry(id.to_string()).or_default().push(t.id.clone()); + } + } + for r in regions.iter_mut() { + let ids = labels.remove(&r.id).unwrap_or_default(); + r.value.insert("labels".into(), json!(ids)); + } +} + +/// The block instance records -- every shown INSERT that measured an +/// extent, with its attributes -- and the instances of each block, by name. +fn block_records( + shown: &[(&Part, &Entity)], + extent_of: &BTreeMap, + rounder: &Rounder, +) -> (Vec, BTreeMap>) { + let mut instances: BTreeMap> = BTreeMap::new(); + let mut out = Vec::new(); + for (part, e) in shown { + let Entity::Insert(i) = e else { continue }; + let Some(bbox) = extent_of.get(&part.id).copied() else { + continue; + }; + let id = part.id.value().to_string(); + instances + .entry(i.block_name.name().to_string()) + .or_default() + .push(id.clone()); + let attribs: Map = i + .attribs + .iter() + .filter(|a| !a.tag.is_empty()) + .map(|a| (a.tag.clone(), json!(decode_text(&a.text).plain))) + .collect(); + // Where the block's origin lands: the placement the model computes, + // its own plane included. + let placement = uncad_model::Affine2::from_insert(i); + let mut v = Map::new(); + v.insert("id".into(), json!(id)); + v.insert("handle".into(), handle_of(&i.common)); + v.insert("block".into(), json!(i.block_name.name())); + v.insert("layer".into(), json!(layer_name(&i.common.layer))); + v.insert( + "at".into(), + rounder.pt2(uncad_model::Point2D { + x: placement.e, + y: placement.f, + }), + ); + v.insert( + "rotation_deg".into(), + json!(rounder.derived(i.rotation.to_degrees())), + ); + v.insert("scale".into(), json!([i.scale.x, i.scale.y, i.scale.z])); + v.insert("mirrored".into(), json!(placement.determinant() < 0.0)); + v.insert("attribs".into(), Value::Object(attribs)); + v.insert("bbox".into(), rounder.rect(&bbox)); + out.push(Record { id, bbox, value: v }); + } + out.sort_by_cached_key(|r| id_key(&r.id)); + (out, instances) +} + +/// `drawing.json`: the header, the units, the layers with their state and +/// how many top-level entities each holds, the block definitions with their +/// instances, and the entity counts. +fn drawing_json( + db: &CadDatabase, + header: Option<&uncad::Header>, + parts: &[Part], + top: &BTreeMap, + instances: &BTreeMap>, + units: &Value, +) -> Result { + let mut layer_counts: BTreeMap<&str, usize> = BTreeMap::new(); + let mut type_counts: BTreeMap<&str, usize> = BTreeMap::new(); + for p in parts { + *type_counts.entry(p.type_name.as_str()).or_default() += 1; + if let Some(e) = top.get(&p.id) { + *layer_counts + .entry(layer_name(&e.common().layer)) + .or_default() += 1; + } + } + let layers: Vec = db + .tables + .layers + .values() + .map(|l| { + json!({ + "name": l.name, + "color_index": l.color_index, + "on": !l.off, + "frozen": l.frozen, + "locked": l.locked, + "plot": l.plot, + // Hundredths of a millimetre in the model; -3 is "the + // default", which is no width of its own. + "lineweight_mm": l.lineweight.filter(|w| *w >= 0).map(|w| f64::from(w) / 100.0), + "linetype": l.linetype.name(), + "entity_count": layer_counts.get(l.name.as_str()).copied().unwrap_or(0), + }) + }) + .collect(); + let definitions: Vec = db + .tables + .block_records + .values() + .filter(|b| { + let upper = b.name.to_uppercase(); + !upper.starts_with("*MODEL_SPACE") && !upper.starts_with("*PAPER_SPACE") + }) + .map(|b| { + let mut by_layer: BTreeMap<&str, usize> = BTreeMap::new(); + let mut tags: BTreeSet<&str> = BTreeSet::new(); + for e in &b.entities { + *by_layer.entry(layer_name(&e.common().layer)).or_default() += 1; + if let Entity::Attdef(a) = e { + tags.insert(a.tag.as_str()); + } + } + let ids = instances.get(&b.name).cloned().unwrap_or_default(); + json!({ + "name": b.name, + "entity_count": b.entities.len(), + "count_by_layer": by_layer, + "attrib_tags": tags, + "anonymous": b.name.starts_with('*'), + "instances": ids.len(), + "instance_ids": ids, + }) + }) + .collect(); + Ok(json!({ + "$schema": SCHEMA, + "units": units, + "header": match header { + Some(h) => serde_json::to_value(h)?, + None => Value::Null, + }, + "read_diagnostics": db.read_diagnostics.warnings, + "layers": layers, + "blocks": definitions, + "counts": { "model_space": parts.len(), "by_type": type_counts }, + })) +} + +struct ManifestInput<'a, 'w> { + header: Option<&'a uncad::Header>, + options: &'a ExportOptions, + writer: &'a mut Writer<'w>, + crop: &'a CropReport, + svg_origin: Option<[f64; 2]>, + overview: &'a ImageInfo, + frames: &'a [FrameReport], + counts: &'a Counts, + texts: &'a [Record], + dims: &'a [Record], + regions: &'a [Record], + warnings: &'a [String], +} + +fn manifest_json(m: ManifestInput<'_, '_>) -> Value { + let profile = m.options.profile; + // "exact" is reserved for values the file itself measured: a package of + // an R13/R14 drawing (no act_measurement anywhere) carries values this + // crate recomputed from the definition points. + let dim_source = |want: &str| { + m.dims + .iter() + .any(|r| r.value.get("measurement_source").is_some_and(|s| s == want)) + }; + // `areas` says what the region records say, the way `dimension_values` + // does. + let area_confidence = |want: &str| { + m.regions + .iter() + .filter(|r| r.value.get("confidence").is_some_and(|c| c == want)) + .count() + }; + let (areas_exact, areas_estimated, areas_unavailable) = ( + area_confidence("exact"), + area_confidence("estimated"), + area_confidence("unavailable"), + ); + let measured_total = m + .texts + .iter() + .filter(|r| { + r.value + .get("bbox_confidence") + .is_some_and(|c| c == "measured") + }) + .count(); + let fonts_name = if m.options.fonts == fonts::bundled() { + "bundled" + } else if m.options.fonts == Fonts::System { + "system" + } else { + "custom" + }; + let capabilities = json!({ + "dimension_values": if m.dims.is_empty() { "none" } else if dim_source("act_measurement") { "exact" } else if dim_source("from_points") { "computed" } else { "text_only" }, + "areas": if m.regions.is_empty() { "none" } else if areas_exact == m.regions.len() { "exact" } else if areas_exact > 0 { "mixed" } else if areas_unavailable > 0 && areas_estimated == 0 { "unavailable" } else { "estimated" }, + "areas_by_confidence": { "exact": areas_exact, "estimated": areas_estimated, "unavailable": areas_unavailable }, + "text_boxes": if m.texts.is_empty() { "none" } else if measured_total == m.texts.len() { "measured" } else if measured_total == 0 { "estimated" } else { "mixed" }, + "fonts": fonts_name, + "frames": m.frames.len(), + }); + let legend = json!({ + "confidence": { + "carried_by": ["geometry", "region", "dimension"], + "exact": "computed from the file's own coordinates", + "stored": "the value the file stores (a dimension's act_measurement)", + "estimated": "approximated, or a check that was skipped; `why` says which", + "unavailable": "no meaningful value; `why` says why, and any number beside it is not to be used", + "none": "there was nothing to compute (used by capabilities, not by records)", + }, + "text_records": "texts carry `bbox_confidence` (`measured` from the glyph outlines as the renderer laid them out, or `estimated` at 0.6 em per character) and no `confidence`; block instances carry neither.", + "ids": "a record's `id` is the drawing's entity reference ID in decimal (a path of them for a text inside a block); its `handle` is the file's own handle for the entity, in hexadecimal, when it came from a file", + "measurement_source": ["act_measurement", "from_points", "none"], + "display_source": ["user_text", "cached_block", "formatted", "suppressed", "none"], + "region_labels": "`labels` holds the ids of the text records whose anchor falls inside the region", + "px_boxes": "`px` maps an image id to [x0, y0, x1, y1] in that image's pixels, y down, clipped to the image; the record's full extent is its world `bbox`, and the rest of it is on the other images it lists", + "shard_lookup": "manifest.shard_index resolves a record id to its file: the entry whose [first_key, last_key] contains int(id.split('/')[0]). `first_id`/`last_id` are the same bounds as strings and do not compare as numbers", + }); + let guidance = "Read manifest.json first. Numbers (lengths, areas, dimension values, text) come from the JSON records, never from pixels; `legend` says what `confidence` and the package's other vocabularies mean and which records carry them. To find something: look its text up in strings.json (normalised: trimmed, lower-case, single spaces), resolve the id through shard_index -- the entry whose [first_key, last_key] contains int(id.split('/')[0]), since the id does not say its kind and one id can be in two. overview.png shows the whole crop. report.json lists what was left out and why.".to_string(); + m.writer.files.push(WrittenFile { + path: "manifest.json".into(), + bytes: None, + kind: "manifest".into(), + }); + m.writer.files.push(WrittenFile { + path: "README.txt".into(), + bytes: None, + kind: "readme".into(), + }); + json!({ + "$schema": SCHEMA, + "generator": { "name": "uncad-export", "version": env!("CARGO_PKG_VERSION") }, + "profile": profile.name, + "source": { + "name": m.options.source_name, + "format": m.header.map(|h| h.format), + "acadver": m.header.and_then(|h| h.acadver.clone()), + "version": m.header.and_then(|h| h.version.clone()), + "codepage": m.header.and_then(|h| h.codepage_name.clone()), + }, + "units": m.writer.units, + "crop": m.crop, + // drawing.svg's user units are world minus this (null without it). + "svg_origin": m.svg_origin, + "overview": m.overview, + "frames": m.frames, + "legibility": { "target_px": m.options.target_text_px, "per_frame": m.frames.iter().map(|f| json!({"frame": f.id, "z_max": f.z_max, "target_met": f.height_classes.iter().all(|c| c.legible), "pyramid_complete": f.reached, "height_classes": f.height_classes})).collect::>() }, + "counts": m.counts, + "capabilities": capabilities, + "legend": legend, + "guidance": guidance, + "files": m.writer.files, + "shard_index": m.writer.shard_index, + "warnings": m.warnings, + }) +} + +fn readme(options: &ExportOptions) -> String { + format!( + "uncad package ({SCHEMA})\n\nReading order:\n 1. manifest.json what is here, the crop, the images and their affines; `legend` explains the record vocabularies, `guidance` how to look something up\n 2. strings.json find a text or a number, get record ids; shard_index turns an id into a file (compare int(id.split('/')[0]) against first_key/last_key, not the strings)\n 3. texts.json / dimensions.json / geometry.json / regions.json / blocks.json the records (sharded above {} KB, see shard_index); blocks.json holds the INSERT instances, drawing.json the block definitions\n 4. overview.png the whole drawing\n 5. report.json what was left out and why\n\ndrawing.json holds the header, units, layer states and block definitions; entities.json and drawing.svg (when present) are tool inputs, not for reading.\n", + options.shard_kb + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strings_are_normalized_for_lookup() { + assert_eq!(normalize_string(" Room 101 \n"), "room 101"); + assert_eq!(normalize_string("32.5㎡"), "32.5m2"); + assert_eq!(normalize_string("½\" Ø"), "1/2\" ø"); + assert_eq!(normalize_string("A1"), "a1"); + assert_eq!(compact_string(" 32.5 m 2 "), "32.5m2"); + assert_eq!(area_unit("mm"), "mm2"); + assert_eq!(area_unit("du"), "du2"); + } + + #[test] + fn profiles_by_name() { + assert_eq!(Profile::by_name("claude"), Some(Profile::CLAUDE)); + assert_eq!( + Profile::by_name("claude-hires"), + Some(Profile::CLAUDE_HIRES) + ); + assert_eq!( + Profile::by_name("openai-patch"), + Some(Profile::OPENAI_PATCH) + ); + assert_eq!(Profile::by_name("gemini"), None); + } + + #[test] + fn an_overview_uses_its_budget_and_a_point_gets_a_window() { + // A 1000 x 500 drawing on Claude's budget: 56 patches wide is the + // edge limit, and 28 of them tall keeps 1568 patches. + let fit = fit_overview(&Rect::new(0.0, 0.0, 1000.0, 500.0), &Profile::CLAUDE, None); + assert_eq!(fit.width % 28, 0); + assert_eq!(fit.height % 28, 0); + assert!(fit.width <= 1568 && (fit.width / 28) * (fit.height / 28) <= 1568); + assert!((fit.rect.width() * fit.ppu - f64::from(fit.width)).abs() < 1e-6); + // A single point: a ten-unit window, not a 1e-11-unit one. + let fit = fit_overview(&Rect::at(3.0, 3.0), &Profile::CLAUDE, None); + assert!((10.0..=12.0).contains(&fit.rect.width()), "{:?}", fit.rect); + assert!(fit.ppu.is_finite() && fit.ppu < 1000.0); + } +} diff --git a/crates/uncad-export/src/package/output.rs b/crates/uncad-export/src/package/output.rs new file mode 100644 index 0000000..2e65d9f --- /dev/null +++ b/crates/uncad-export/src/package/output.rs @@ -0,0 +1,255 @@ +//! Writing the package: files and their listing, record shards and their +//! index, the 8-bit RGB images, and clearing what a previous package in the +//! same directory left. + +use std::path::Path; + +use serde::Serialize; +use serde_json::{json, Value}; + +use super::records::{id_key, Record}; +use super::{ExportError, SCHEMA}; + +/// One file the package holds. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct WrittenFile { + /// Relative to the package directory, `/`-separated. + pub path: String, + /// `None` for the three files written after the listing was made + /// (`manifest.json`, `README.txt`, `report.json`). + pub bytes: Option, + pub kind: String, +} + +pub(crate) struct Writer<'a> { + pub(crate) dir: &'a Path, + pub(crate) files: Vec, + pub(crate) shard_index: Vec, + pub(crate) units: Value, + pub(crate) shard_kb: usize, +} + +impl Writer<'_> { + pub(crate) fn write_bytes( + &mut self, + rel: &str, + bytes: &[u8], + kind: &str, + ) -> Result<(), ExportError> { + let path = self.dir.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|source| ExportError::Io { + path: parent.to_path_buf(), + source, + })?; + } + std::fs::write(&path, bytes).map_err(|source| ExportError::Io { path, source })?; + self.files.push(WrittenFile { + path: rel.to_string(), + bytes: Some(bytes.len() as u64), + kind: kind.to_string(), + }); + Ok(()) + } + + pub(crate) fn write_json( + &mut self, + rel: &str, + value: &Value, + kind: &str, + ) -> Result<(), ExportError> { + let text = serde_json::to_string_pretty(value)?; + self.write_bytes(rel, text.as_bytes(), kind) + } + + /// Writes `records` (already in id order) as `name.json`, or as + /// `name.NNN.json` shards above `shard_kb`, and indexes them. + pub(crate) fn write_records( + &mut self, + name: &str, + kind: &str, + records: &[Record], + ) -> Result<(), ExportError> { + let limit = self.shard_kb.max(1) * 1024; + let mut shards: Vec> = vec![Vec::new()]; + let mut bytes = 0usize; + for r in records { + let size = serde_json::to_string(&r.value)?.len() + 8; + if bytes + size > limit && !shards.last().is_none_or(Vec::is_empty) { + shards.push(Vec::new()); + bytes = 0; + } + shards.last_mut().expect("one shard").push(r); + bytes += size; + } + let single = shards.len() == 1; + for (n, shard) in shards.iter().enumerate() { + let file = if single { + format!("{name}.json") + } else { + format!("{name}.{:03}.json", n + 1) + }; + let value = json!({ + "$schema": SCHEMA, + "kind": kind, + "units": self.units, + "count": shard.len(), + "records": shard.iter().map(|r| Value::Object(r.value.clone())).collect::>(), + }); + let text = serde_json::to_string(&value)?; + self.write_bytes(&file, text.as_bytes(), kind)?; + // `first_id`/`last_id` alone are no index: ids are paths of + // numbers written as strings, ordered by their numbers, so the + // string comparison a consumer would reach for picks the wrong + // shard ("10" sorts before "9"). `first_key`/`last_key` publish + // the order itself: the id's first number. + let key = |r: Option<&&Record>| r.map(|r| id_key(&r.id)[0]); + self.shard_index.push(json!({ + "file": file, + "kind": kind, + "first_id": shard.first().map(|r| r.id.as_str()), + "last_id": shard.last().map(|r| r.id.as_str()), + "first_key": key(shard.first()), + "last_key": key(shard.last()), + "count": shard.len(), + "bytes": text.len(), + })); + } + Ok(()) + } +} + +/// Removes what a previous uncad package in `dir` left behind, so a second +/// export with other options does not leave stale shards, tile PNGs and +/// sidecars beside the new ones: they look valid (same schema, same record +/// ids) and a consumer that walks the tree would mix two exports. +/// +/// Only the files the previous `manifest.json` lists are removed, and only +/// when it is an uncad manifest: a directory holding anything else is left +/// alone, and a listed path that is not a plain relative path inside `dir` +/// is ignored. Directories under `frames/` and `sheets/` go when they are +/// left empty. Every failure is ignored -- the write that follows reports +/// what actually matters. +pub(crate) fn clear_previous_package(dir: &Path) { + let Ok(text) = std::fs::read_to_string(dir.join("manifest.json")) else { + return; + }; + let Ok(manifest) = serde_json::from_str::(&text) else { + return; + }; + let ours = manifest + .get("$schema") + .and_then(Value::as_str) + .is_some_and(|s| s.starts_with("uncad-package/")); + if !ours { + return; + } + let files = manifest.get("files").and_then(Value::as_array); + for file in files.into_iter().flatten() { + let Some(rel) = file.get("path").and_then(Value::as_str) else { + continue; + }; + let inside = !rel.is_empty() + && Path::new(rel) + .components() + .all(|c| matches!(c, std::path::Component::Normal(_))); + if inside { + let _ = std::fs::remove_file(dir.join(rel)); + } + } + for sub in ["frames", "sheets"] { + remove_empty_dirs(&dir.join(sub)); + } +} + +/// Removes `dir` and every directory under it that is empty once its own +/// empty children are gone; a directory still holding a file stays (with +/// everything above it). +fn remove_empty_dirs(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|t| t.is_dir()) { + remove_empty_dirs(&entry.path()); + } + } + // Fails, harmlessly, when anything is left in it. + let _ = std::fs::remove_dir(dir); +} + +/// The renderer's PNG (RGBA, on an opaque white background) as 8-bit RGB: +/// a quarter fewer bytes to decode for an image that has no transparency +/// to carry, and the form the package has always written. +pub(crate) fn to_rgb(rgba_png: &[u8]) -> Result, ExportError> { + let encode = |e: &dyn std::fmt::Display| ExportError::Encode(e.to_string()); + let mut decoder = png::Decoder::new(std::io::Cursor::new(rgba_png)); + decoder.set_transformations(png::Transformations::EXPAND); + let mut reader = decoder.read_info().map_err(|e| encode(&e))?; + let size = reader + .output_buffer_size() + .ok_or_else(|| ExportError::Encode("the image is too large to decode".into()))?; + let mut buf = vec![0; size]; + let info = reader.next_frame(&mut buf).map_err(|e| encode(&e))?; + let samples = info.color_type.samples(); + let data = &buf[..info.buffer_size()]; + let rgb: Vec = match samples { + 4 => data + .as_chunks::<4>() + .0 + .iter() + .flat_map(|px| { + // Straight colour over white: the renderer fills the + // background, so alpha is 255 and this is the identity. + let a = u32::from(px[3]); + let over = |c: u8| ((u32::from(c) * a + 255 * (255 - a) + 127) / 255) as u8; + [over(px[0]), over(px[1]), over(px[2])] + }) + .collect(), + 3 => data.to_vec(), + _ => { + return Err(ExportError::Encode(format!( + "unexpected PNG colour type {:?}", + info.color_type + ))) + } + }; + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, info.width, info.height); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().map_err(|e| encode(&e))?; + writer.write_image_data(&rgb).map_err(|e| encode(&e))?; + writer.finish().map_err(|e| encode(&e))?; + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_rgba_png_on_white_becomes_the_same_picture_in_rgb() { + // A 2 x 1 RGBA image: one opaque red pixel, one half-transparent + // black one, which over white is mid-grey. + let mut rgba = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut rgba, 2, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer + .write_image_data(&[255, 0, 0, 255, 0, 0, 0, 128]) + .unwrap(); + } + let rgb = to_rgb(&rgba).expect("re-encodes"); + let decoder = png::Decoder::new(std::io::Cursor::new(&rgb)); + let mut reader = decoder.read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut buf).unwrap(); + assert_eq!(info.color_type, png::ColorType::Rgb); + assert_eq!(&buf[..6], &[255, 0, 0, 127, 127, 127]); + } +} diff --git a/crates/uncad-export/src/package/records.rs b/crates/uncad-export/src/package/records.rs new file mode 100644 index 0000000..648f1cf --- /dev/null +++ b/crates/uncad-export/src/package/records.rs @@ -0,0 +1,633 @@ +//! The package's records: one per text, dimension, geometric entity, +//! closed region and block instance, each with its exact numbers, its world +//! box and -- filled in by the caller -- the images it is on. +//! +//! A record's id is the model's reference ID ([`EntityId`]) as a decimal +//! string, or the path of IDs `/` for a text drawn inside a +//! block reference: the same path the renderer names that text's `` +//! element with (`t.`). The file's own handle, where the entity +//! came from a file, is the record's `handle`. + +use std::collections::{BTreeMap, BTreeSet}; + +use iron_render_cad::TextBox; +use serde_json::{json, Map, Value}; +use uncad_model::model::{ + AttribEntity, Confidence, Entity, EntityCommon, EntityId, HorizontalJustification, Point2D, + Point3D, Ref, VerticalJustification, +}; +use uncad_model::tables::BlockRecord; +use uncad_model::{Affine2, Tables}; + +use crate::dimension::tolerance_text_height; +use crate::frame::Rect; +use crate::geom::plane_to_world; +use crate::text::{decode_mtext, decode_text}; + +/// One entity's record before its images are known. +#[derive(Clone)] +pub(crate) struct Record { + pub(crate) id: String, + pub(crate) bbox: Rect, + pub(crate) value: Map, +} + +/// A record id: the path's IDs, outermost first, joined by `/`. +pub(crate) fn record_id(path: &[EntityId]) -> String { + let mut id = String::new(); + for (i, e) in path.iter().enumerate() { + if i > 0 { + id.push('/'); + } + id.push_str(&e.value().to_string()); + } + id +} + +/// The order records are written in, and what a shard's `first_key` and +/// `last_key` publish: the id's IDs as numbers. A malformed segment sorts +/// last. +pub(crate) fn id_key(id: &str) -> Vec { + id.split('/') + .map(|s| s.parse::().unwrap_or(u64::MAX)) + .collect() +} + +/// The file's handle for an entity, when it came from a file. +pub(crate) fn handle_of(common: &EntityCommon) -> Value { + match &common.source_handle { + Ref::Resolved(h) => json!(h), + Ref::Absent | Ref::Unresolved(_) => Value::Null, + } +} + +/// A layer reference as a record names it: the name it resolved to; the +/// name or handle the file wrote when nothing answers to it; `""` when the +/// file names none. +pub(crate) fn layer_name(layer: &Ref) -> &str { + match layer { + Ref::Resolved(name) | Ref::Unresolved(name) => name, + Ref::Absent => "", + } +} + +// ------------------------------------------------------------- rounding + +pub(crate) fn round_to(v: f64, decimals: u16) -> f64 { + let f = 10f64.powi(i32::from(decimals)); + let r = (v * f).round() / f; + if r == 0.0 { + 0.0 + } else { + r + } +} + +pub(crate) struct Rounder { + pub(crate) coords: u16, + pub(crate) derived: u16, +} + +impl Rounder { + pub(crate) fn coord(&self, v: f64) -> f64 { + round_to(v, self.coords) + } + + pub(crate) fn derived(&self, v: f64) -> f64 { + round_to(v, self.derived) + } + + pub(crate) fn pt2(&self, p: Point2D) -> Value { + json!([self.coord(p.x), self.coord(p.y)]) + } + + pub(crate) fn pt3(&self, p: Point3D) -> Value { + json!([self.coord(p.x), self.coord(p.y)]) + } + + pub(crate) fn rect(&self, r: &Rect) -> Value { + json!([ + self.coord(r.min_x), + self.coord(r.min_y), + self.coord(r.max_x), + self.coord(r.max_y) + ]) + } +} + +/// The decimals a world coordinate is written with: `$LUPREC` (clamped to +/// 3..=12), but never fewer than the package's own scale needs. +/// +/// `$LUPREC` says how precisely the drawing's units are *displayed*; it +/// says nothing about how far the package zooms into them. A drawing a few +/// thousandths of a unit across is drawn at hundreds of thousands of pixels +/// per unit, where LUPREC's three or four decimals put every tile rectangle +/// of a frame on the same numbers: the `world` a consumer reads is then a +/// different rectangle from the one the tile shows, and the affine beside +/// it maps to the wrong pixels. +/// +/// The floor is the scale's. `deepest_ppu` is the finest scale the package +/// may reach -- the overview's, doubled once per zoom level -- and the +/// coordinate carries three decimals beyond one pixel there, so one unit in +/// the last place is a thousandth of a pixel. +pub(crate) fn coord_decimals(luprec: u16, deepest_ppu: f64) -> u16 { + let from_scale = if deepest_ppu.is_finite() && deepest_ppu > 0.0 { + (deepest_ppu.log10().ceil() + 3.0).clamp(3.0, 12.0) as u16 + } else { + 3 + }; + luprec.clamp(3, 12).max(from_scale) +} + +// ----------------------------------------------------------- visibility + +/// The layer an entity is drawn on: its own, except that an entity on +/// layer 0 inside a block reference takes the layer of the reference that +/// places it -- the rule the renderer draws by. +pub(crate) fn effective_layer<'a>(layer: &'a str, reference_layer: Option<&'a str>) -> &'a str { + match reference_layer { + Some(reference) if layer == "0" => reference, + _ => layer, + } +} + +/// Whether the drawing hides an entity with `common` drawn under +/// `reference_layer` -- the renderer's rule: its own invisible flag (or an +/// attribute's), AutoCAD's `DEFPOINTS` layer, or a layer that is off, +/// frozen or stated not to plot. A layer the tables do not hold is shown. +pub(crate) fn is_hidden( + common: &EntityCommon, + invisible_attribute: bool, + tables: &Tables, + reference_layer: Option<&str>, +) -> bool { + if common.invisible || invisible_attribute { + return true; + } + let layer = effective_layer(common.layer.name(), reference_layer); + if layer.eq_ignore_ascii_case("DEFPOINTS") { + return true; + } + tables + .layers + .get(layer) + .is_some_and(|l| l.off || l.frozen || l.plot == Some(false)) +} + +fn entity_hidden(e: &Entity, tables: &Tables, reference_layer: Option<&str>) -> bool { + let invisible_attribute = matches!(e, Entity::Attrib(a) if a.flags.invisible); + is_hidden(e.common(), invisible_attribute, tables, reference_layer) +} + +// ---------------------------------------------------------------- texts + +/// A text placed in the world (or on a sheet's paper). +pub(crate) struct PlacedText { + pub(crate) id: String, + pub(crate) handle: Value, + pub(crate) kind: &'static str, + pub(crate) layer: String, + pub(crate) text: String, + pub(crate) raw: String, + pub(crate) height: f64, + pub(crate) rotation: f64, + pub(crate) anchor: Point2D, + /// The glyph outlines' box when the fonts laid the text out, the + /// renderer's estimate otherwise. + pub(crate) bbox: Rect, + pub(crate) measured: bool, + /// Glyphs the fonts could not shape (drawn as `.notdef` boxes). + pub(crate) unshaped: usize, + pub(crate) style: String, + pub(crate) tag: Option, +} + +/// An entity a text path steps through: a drawing's entity, or an +/// attribute that hangs off a nested block reference and nothing else. +#[derive(Clone, Copy)] +enum Located<'a> { + Entity(&'a Entity), + NestedAttribute(&'a AttribEntity), +} + +/// Finds the entities along a text's path. The first ID is a top-level +/// entity (`top`); each later one is an entity of the block the previous +/// one references, or an attribute of a block reference among them -- the +/// renderer draws a nested reference's attributes beside it, in the +/// enclosing block's frame. Blocks are indexed once, on first use. +struct PathIndex<'a> { + tables: &'a Tables, + top: &'a BTreeMap, + blocks: BTreeMap<&'a str, BTreeMap>>, +} + +impl<'a> PathIndex<'a> { + fn block(&mut self, name: &'a str) -> Option<&BTreeMap>> { + if !self.blocks.contains_key(name) { + let block: &'a BlockRecord = self.tables.block_records.get(name)?; + let mut index = BTreeMap::new(); + for e in &block.entities { + if let Entity::Insert(i) = e { + for a in &i.attribs { + index + .entry(a.common.id) + .or_insert(Located::NestedAttribute(a)); + } + } + } + for e in &block.entities { + index.insert(e.common().id, Located::Entity(e)); + } + self.blocks.insert(name, index); + } + self.blocks.get(name) + } +} + +/// The world rotation of a text drawn with `rotation` in the frame `m`: the +/// angle of its transformed *up* axis less 90 degrees, i.e. how the glyphs +/// are oriented on the page. Through the up axis rather than the baseline +/// so that a frame mirrored about the vertical axis (the usual MIRROR of a +/// block) reports an upright, mirror-written text as 0 rather than 180. +fn text_rotation(m: &Affine2, rotation: f64) -> f64 { + let (ux, uy) = (-rotation.sin(), rotation.cos()); + let (wx, wy) = (m.a * ux + m.c * uy, m.b * ux + m.d * uy); + wy.atan2(wx) - std::f64::consts::FRAC_PI_2 +} + +/// The length scale of a frame, `sqrt(|det|)`: exact for a uniform scale, +/// the geometric mean of the axis scales otherwise. +fn length_scale(m: &Affine2) -> f64 { + m.determinant().abs().sqrt() +} + +/// The point a TEXT or ATTRIB is placed by: its alignment point when it is +/// justified at all (the file states one then), its start point otherwise. +fn text_base( + start: Point2D, + alignment: Option, + h: HorizontalJustification, + v: VerticalJustification, +) -> Point2D { + if h != HorizontalJustification::Left || v != VerticalJustification::Baseline { + alignment.unwrap_or(start) + } else { + start + } +} + +/// Every text the renderer drew that a reader should find, as the package +/// places it: `boxes` are the scene's texts in drawing order, `top` its +/// top-level entities by ID, and `keep` says which top-level entities' +/// texts count (the shown ones -- not hidden, not left out of the picture, +/// not a dimension, whose label is its own record). A text the drawing +/// hides (drawn only faded, when hidden entities are included) or that +/// shows nothing but spaces is not a record. +pub(crate) fn placed_texts( + tables: &Tables, + top: &BTreeMap, + boxes: &[TextBox], + keep: impl Fn(EntityId) -> bool, +) -> Vec { + let mut index = PathIndex { + tables, + top, + blocks: BTreeMap::new(), + }; + let mut seen: BTreeSet = BTreeSet::new(); + let mut out = Vec::new(); + for b in boxes { + let Some(&first) = b.path.first() else { + continue; + }; + if !keep(first) { + continue; + } + let Some(placed) = place(&mut index, b) else { + continue; + }; + if seen.insert(placed.id.clone()) { + out.push(placed); + } + } + out +} + +fn place(index: &mut PathIndex<'_>, b: &TextBox) -> Option { + let tables = index.tables; + let mut current = Located::Entity(*index.top.get(b.path.first()?)?); + let mut affine = Affine2::IDENTITY; + let mut reference_layer: Option = None; + for id in &b.path[1..] { + let Located::Entity(owner) = current else { + return None; + }; + if entity_hidden(owner, tables, reference_layer.as_deref()) { + return None; + } + let (block, placement) = match owner { + Entity::Insert(i) => (&i.block_name, Affine2::from_insert(i)), + Entity::AcadTable(t) => ( + &t.block_name, + Affine2::placement( + Point2D { + x: t.insertion_point.x, + y: t.insertion_point.y, + }, + t.scale.x, + t.scale.y, + t.rotation, + ), + ), + Entity::Dimension(d) => (&d.block_name, Affine2::IDENTITY), + _ => return None, + }; + affine = placement.then(&affine); + reference_layer = Some( + effective_layer(owner.common().layer.name(), reference_layer.as_deref()).to_string(), + ); + let children = index.block(block.resolved()?)?; + current = *children.get(id)?; + } + let reference = reference_layer.as_deref(); + let (common, hidden) = match current { + Located::Entity(e) => (e.common(), entity_hidden(e, tables, reference)), + Located::NestedAttribute(a) => ( + &a.common, + is_hidden(&a.common, a.flags.invisible, tables, reference), + ), + }; + if hidden { + return None; + } + struct Leaf<'b> { + kind: &'static str, + raw: &'b str, + text: String, + base: Point2D, + plane: Affine2, + height: f64, + rotation: f64, + style: &'b Ref, + tag: Option<&'b str>, + } + let attribute = |a: &'_ AttribEntity| -> (Point2D, Affine2) { + ( + text_base( + a.start_point, + a.alignment_point, + a.horizontal_justification, + a.vertical_justification, + ), + plane_to_world(a.extrusion, a.elevation), + ) + }; + let no_style = Ref::Absent; + let leaf = match current { + Located::Entity(Entity::Text(t)) => Leaf { + kind: "TEXT", + raw: &t.text, + text: decode_text(&t.text).plain, + base: text_base( + t.start_point, + t.alignment_point, + t.horizontal_justification, + t.vertical_justification, + ), + plane: plane_to_world(t.extrusion, t.elevation), + height: t.text_height, + rotation: t.rotation, + style: &t.style_name, + tag: None, + }, + Located::Entity(Entity::Attrib(a)) | Located::NestedAttribute(a) => { + let (base, plane) = attribute(a); + Leaf { + kind: "ATTRIB", + raw: &a.text, + text: decode_text(&a.text).plain, + base, + plane, + height: a.text_height, + rotation: a.rotation, + style: &a.style_name, + tag: Some(&a.tag), + } + } + Located::Entity(Entity::MText(m)) => Leaf { + kind: "MTEXT", + raw: &m.text, + text: decode_mtext(&m.text).plain, + base: Point2D { + x: m.insertion_point.x, + y: m.insertion_point.y, + }, + plane: Affine2::IDENTITY, + height: m.text_height, + rotation: m.rotation, + style: &m.style_name, + tag: None, + }, + Located::Entity(Entity::Tolerance(t)) => Leaf { + kind: "TOLERANCE", + raw: &t.text_value, + text: decode_text(&t.text_value).plain, + base: Point2D { + x: t.insertion_point.x, + y: t.insertion_point.y, + }, + plane: Affine2::IDENTITY, + height: tolerance_text_height(t, tables), + rotation: 0.0, + style: &no_style, + tag: None, + }, + _ => return None, + }; + if leaf.text.trim().is_empty() { + return None; + } + let frame = leaf.plane.then(&affine); + let anchor = frame.apply(leaf.base); + let bbox = b + .measured + .or(b.estimate) + .map(Rect::from) + .unwrap_or(Rect::at(anchor.x, anchor.y)); + Some(PlacedText { + id: record_id(&b.path), + handle: handle_of(common), + kind: leaf.kind, + layer: effective_layer(common.layer.name(), reference).to_string(), + raw: leaf.raw.to_string(), + text: leaf.text, + height: leaf.height * length_scale(&frame), + rotation: text_rotation(&frame, leaf.rotation), + anchor, + bbox, + measured: b.measured.is_some(), + unshaped: b.missing_glyphs, + style: leaf.style.name().to_string(), + tag: leaf.tag.map(str::to_string), + }) +} + +/// One text's record, for `texts.json`. +/// +/// `space` is `model` or `paper`; a paper-space text also names the +/// `sheet` it is on and carries that sheet image's pixel box instead of +/// tiles, since the model tile pyramid does not cover the paper. Its +/// `bbox` and `anchor` are in the layout's paper units, not world units -- +/// the only records in the package that are. +pub(crate) fn text_record( + t: &PlacedText, + rounder: &Rounder, + space: &str, + sheet: Option<&str>, + measured_why: &str, +) -> Record { + let mut v = Map::new(); + v.insert("id".into(), json!(t.id)); + v.insert("handle".into(), t.handle.clone()); + v.insert("kind".into(), json!(t.kind)); + v.insert("text".into(), json!(t.text)); + if t.raw != t.text { + v.insert("raw".into(), json!(t.raw)); + } + if let Some(tag) = &t.tag { + v.insert("tag".into(), json!(tag)); + } + v.insert("space".into(), json!(space)); + if let Some(name) = sheet { + v.insert("sheet".into(), json!(name)); + } + v.insert("layer".into(), json!(t.layer)); + v.insert("height".into(), json!(rounder.derived(t.height))); + v.insert( + "rotation_deg".into(), + json!(rounder.derived(t.rotation.to_degrees())), + ); + v.insert("anchor".into(), rounder.pt2(t.anchor)); + if !t.style.is_empty() { + v.insert("style".into(), json!(t.style)); + } + v.insert("bbox".into(), rounder.rect(&t.bbox)); + v.insert( + "bbox_confidence".into(), + json!(if t.measured { "measured" } else { "estimated" }), + ); + v.insert( + "why".into(), + json!(if t.measured { + measured_why + } else { + "the renderer's estimate: 0.6 em per character from the anchor" + }), + ); + v.insert("font_ok".into(), json!(t.unshaped == 0)); + if t.unshaped > 0 { + v.insert("unshaped_glyphs".into(), json!(t.unshaped)); + } + Record { + id: t.id.clone(), + bbox: t.bbox, + value: v, + } +} + +// ------------------------------------------------------------ confidence + +/// A record's `confidence`, never above the model's for the entity it is +/// made from (the model's invariant: a value that entered low never leaves +/// high). The model's `HIGH` changes nothing; below it, a value the package +/// would call `exact` or `stored` is `estimated`, with the reason. +pub(crate) fn capped_confidence( + confidence: &'static str, + model: Confidence, +) -> (&'static str, Option<&'static str>) { + match (model, confidence) { + (Confidence::High, _) => (confidence, None), + (_, "exact" | "stored") => ( + "estimated", + Some("the reader that produced the model trusts this entity's values less than fully"), + ), + _ => (confidence, None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_sort_by_their_numbers() { + let path = [EntityId::new(0x125), EntityId::new(270)]; + assert_eq!(record_id(&path), "293/270"); + assert_eq!(record_id(&path[..1]), "293"); + assert_eq!(id_key("293/270"), vec![293, 270]); + assert!(id_key("31") < id_key("293"), "numbers, not strings"); + assert!(id_key("293") < id_key("293/1")); + assert_eq!(id_key("x"), vec![u64::MAX]); + } + + #[test] + fn rounding() { + assert_eq!(round_to(1.23456789, 3), 1.235); + assert_eq!(round_to(-0.0001, 3), 0.0); + } + + #[test] + fn coordinate_decimals_never_fall_below_the_scale() { + // An ordinary drawing: a 10 000-unit plan on a 1568 px overview is + // about 0.15 px/unit, 4.8 at the deepest of five levels, so four + // decimals put one ulp at 5e-4 px. + assert_eq!(coord_decimals(0, 4.8), 4); + assert_eq!(coord_decimals(4, 4.8), 4); + assert_eq!(coord_decimals(8, 4.8), 8, "a precise drawing keeps its 8"); + // A drawing 0.004 units across: 8.7e6 px/unit at z5. Ten decimals + // keep one ulp at a thousandth of a pixel. + assert_eq!(coord_decimals(4, 8.7e6), 10); + assert!(10f64.powi(-10) * 8.7e6 < 1e-3); + // Clamped at both ends; a scale that is not a number falls back to + // the LUPREC floor. + assert_eq!(coord_decimals(99, 4.8), 12); + assert_eq!(coord_decimals(4, 1e30), 12); + assert_eq!(coord_decimals(4, f64::NAN), 4); + assert_eq!(coord_decimals(4, 0.0), 4); + } + + #[test] + fn a_frame_reports_the_text_orientation_a_reader_sees() { + let quarter = std::f64::consts::FRAC_PI_2; + // A 90-degree child under a parent mirrored about the vertical + // axis: a text along the child's x axis runs up the page with its + // glyph tops to +x, which reads as -90 degrees. + let child = Affine2::placement(Point2D { x: 0.0, y: 0.0 }, 1.0, 1.0, quarter); + let mirror = Affine2::placement(Point2D { x: 100.0, y: 100.0 }, -1.0, 1.0, 0.0); + let both = child.then(&mirror); + assert!((text_rotation(&both, 0.0) + quarter).abs() < 1e-12); + // The top-level mirror alone keeps a horizontal text at 0 (the + // glyphs are mirrored, not turned). + assert!(text_rotation(&mirror, 0.0).abs() < 1e-12); + assert!((length_scale(&both) - 1.0).abs() < 1e-12); + let stretch = Affine2::placement(Point2D { x: 0.0, y: 0.0 }, 2.0, 1.0, 0.0); + assert!((length_scale(&stretch) - 2f64.sqrt()).abs() < 1e-12); + } + + #[test] + fn a_record_never_claims_more_than_the_model() { + assert_eq!( + capped_confidence("exact", Confidence::High), + ("exact", None) + ); + assert_eq!(capped_confidence("exact", Confidence::Low).0, "estimated"); + assert_eq!( + capped_confidence("stored", Confidence::Unknown).0, + "estimated" + ); + assert_eq!( + capped_confidence("unavailable", Confidence::Low), + ("unavailable", None) + ); + } +} diff --git a/crates/uncad-export/tests/common/mod.rs b/crates/uncad-export/tests/common/mod.rs new file mode 100644 index 0000000..7fdaf67 --- /dev/null +++ b/crates/uncad-export/tests/common/mod.rs @@ -0,0 +1,266 @@ +//! What the package tests share: a scratch directory, JSON reading, record +//! collection across shards, and small drawings built on the model's own +//! types -- model space is what the renderer draws, so an entity has to be +//! both at the top level and in the `*Model_Space` block record. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use uncad_export::{export_package, ExportError, ExportOptions, ExportReport}; +use uncad_model::model::{ + Confidence, Entity, EntityCommon, EntityId, LineEntity, LwPolylineEntity, MTextAttachment, + MTextEntity, Origin, Point2D, Point3D, PolylineVertex, Ref, TextEntity, +}; +use uncad_model::tables::BlockRecord; +use uncad_model::{CadDatabase, Tables}; + +pub const TEST_DATA: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data" +); +pub const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../uncad/tests/fixtures"); + +/// A corpus file under `lib/libredwg/test/test-data`. +pub fn corpus(name: &str) -> String { + format!("{TEST_DATA}/{name}") +} + +/// One of `crates/uncad/tests/fixtures`. +pub fn fixture(name: &str) -> String { + format!("{FIXTURES}/{name}") +} + +/// A fresh directory under the target dir, removed when dropped. +pub struct TempDir(pub PathBuf); + +impl TempDir { + pub fn new(name: &str) -> TempDir { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join(format!("export_{name}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + TempDir(dir) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub fn read_json(path: &Path) -> Value { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display())) +} + +/// Every record of `name` (`texts`, `geometry`, ...), whether written whole +/// or in shards. +pub fn records(dir: &Path, name: &str) -> Vec { + let mut out = Vec::new(); + let single = dir.join(format!("{name}.json")); + if single.exists() { + out.extend(read_json(&single)["records"].as_array().unwrap().clone()); + return out; + } + let mut shards: Vec = std::fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(&format!("{name}.")) && n.ends_with(".json")) + }) + .collect(); + shards.sort(); + assert!(!shards.is_empty(), "{name}.json or its shards"); + for shard in shards { + out.extend(read_json(&shard)["records"].as_array().unwrap().clone()); + } + out +} + +/// The record of `records` whose `handle` is `handle`. +pub fn by_handle<'a>(records: &'a [Value], handle: &str) -> &'a Value { + records + .iter() + .find(|r| r["handle"] == handle) + .unwrap_or_else(|| panic!("a record with handle {handle}")) +} + +/// Parses a file with its header and exports it. +pub fn export_path( + path: &str, + dir: &Path, + options: &ExportOptions, +) -> Result { + let (db, header) = uncad::parse_with_header(path).expect("the drawing must parse"); + export_package(&db, Some(&header), dir, options) +} + +/// Exports a drawing built here, which has no header. +pub fn export_db( + db: &CadDatabase, + dir: &Path, + options: &ExportOptions, +) -> Result { + export_package(db, None, dir, options) +} + +/// Dark (< 128) pixels of an 8-bit RGB PNG, inside `[x0, y0, x1, y1]` when +/// given. +pub fn dark_pixels_in(png: &[u8], area: Option<[i64; 4]>) -> usize { + let decoder = png::Decoder::new(std::io::Cursor::new(png)); + let mut reader = decoder.read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().expect("a frame size")]; + let info = reader.next_frame(&mut buf).unwrap(); + assert_eq!( + info.color_type, + png::ColorType::Rgb, + "the package writes RGB" + ); + let (w, h) = (i64::from(info.width), i64::from(info.height)); + let [x0, y0, x1, y1] = area.unwrap_or([0, 0, w, h]); + let mut count = 0; + for y in y0.max(0)..y1.min(h) { + for x in x0.max(0)..x1.min(w) { + if buf[((y * w + x) * 3) as usize] < 128 { + count += 1; + } + } + } + count +} + +pub fn dark_pixels(png: &[u8]) -> usize { + dark_pixels_in(png, None) +} + +// ------------------------------------------------------------ drawings + +/// An entity's common fields: its ID `id`, its handle the ID in hex, on +/// layer 0, by layer, as a reader of a file would state them. +pub fn common(id: u64) -> EntityCommon { + EntityCommon { + id: EntityId::new(id), + origin: Origin::Vector, + confidence: Confidence::High, + source_handle: Ref::Resolved(format!("{id:X}")), + layer: Ref::Resolved("0".into()), + color_index: 256, + true_color: None, + invisible: false, + } +} + +pub fn on_layer(mut common: EntityCommon, layer: &str) -> EntityCommon { + common.layer = Ref::Resolved(layer.into()); + common +} + +pub fn p3(x: f64, y: f64) -> Point3D { + Point3D { x, y, z: 0.0 } +} + +pub fn z_axis() -> Point3D { + Point3D { + x: 0.0, + y: 0.0, + z: 1.0, + } +} + +pub fn line(id: u64, x0: f64, y0: f64, x1: f64, y1: f64) -> Entity { + Entity::Line(LineEntity { + common: common(id), + start_point: p3(x0, y0), + end_point: p3(x1, y1), + }) +} + +pub fn text(id: u64, x: f64, y: f64, height: f64, s: &str) -> Entity { + Entity::Text(TextEntity { + common: common(id), + start_point: Point2D { x, y }, + text_height: height, + text: s.into(), + rotation: 0.0, + horizontal_justification: Default::default(), + vertical_justification: Default::default(), + alignment_point: None, + width_factor: 1.0, + oblique_angle: 0.0, + style_name: Ref::Absent, + elevation: 0.0, + extrusion: z_axis(), + }) +} + +pub fn mtext(id: u64, x: f64, y: f64, height: f64, s: &str) -> Entity { + Entity::MText(MTextEntity { + common: common(id), + insertion_point: p3(x, y), + text: s.into(), + text_height: height, + rotation: 0.0, + line_spacing_factor: 1.0, + attachment: Some(MTextAttachment::TopLeft), + rect_width: 0.0, + extents_width: None, + extents_height: None, + style_name: Ref::Absent, + }) +} + +pub fn lwpolyline(id: u64, points: &[(f64, f64)], bulges: &[f64], closed: bool) -> Entity { + Entity::LwPolyline(LwPolylineEntity { + common: common(id), + vertices: points + .iter() + .enumerate() + .map(|(i, (x, y))| PolylineVertex { + point: Point2D { x: *x, y: *y }, + bulge: bulges.get(i).copied().unwrap_or(0.0), + start_width: 0.0, + end_width: 0.0, + }) + .collect(), + closed, + const_width: 0.0, + elevation: 0.0, + extrusion: z_axis(), + }) +} + +/// A drawing whose model space is `entities`, wired up the way a parsed +/// file has it. +pub fn model_space(entities: Vec) -> CadDatabase { + with_blocks(entities, Vec::new()) +} + +/// [`model_space`] with block definitions beside it. +pub fn with_blocks(entities: Vec, blocks: Vec<(&str, Vec)>) -> CadDatabase { + let mut tables = Tables::default(); + tables.block_records.insert( + "*Model_Space".into(), + BlockRecord { + name: "*Model_Space".into(), + entities: entities.clone(), + }, + ); + for (name, block) in blocks { + tables.block_records.insert( + name.into(), + BlockRecord { + name: name.into(), + entities: block, + }, + ); + } + CadDatabase { + entities, + tables, + read_diagnostics: Default::default(), + } +} diff --git a/crates/uncad-export/tests/export.rs b/crates/uncad-export/tests/export.rs new file mode 100644 index 0000000..722d7e2 --- /dev/null +++ b/crates/uncad-export/tests/export.rs @@ -0,0 +1,1189 @@ +//! The package on real and built drawings: every file it lists, an +//! overview sized to the profile, records with the exact numbers that point +//! at what the picture shows, and byte-identical output on a second run. + +mod common; + +use std::collections::BTreeSet; + +use common::*; +use serde_json::Value; +use uncad_export::{ExportError, ExportOptions, Profile}; +use uncad_model::model::{ + AttribEntity, Entity, InsertEntity, LwPolylineEntity, Point2D, PolylineVertex, Ref, +}; + +fn example_2000() -> String { + corpus("example_2000.dwg") +} + +#[test] +fn the_package_has_every_file_and_a_profile_sized_overview() { + let tmp = TempDir::new("files"); + let report = export_path(&example_2000(), &tmp.0, &ExportOptions::default()).expect("exports"); + + for name in [ + "README.txt", + "manifest.json", + "drawing.json", + "overview.png", + "blocks.json", + "strings.json", + "report.json", + ] { + assert!(tmp.0.join(name).exists(), "{name} is written"); + } + // Record files may be sharded (`name.001.json`, ...). + for name in ["texts", "dimensions", "geometry", "regions"] { + assert!( + tmp.0.join(format!("{name}.json")).exists() + || tmp.0.join(format!("{name}.001.json")).exists(), + "{name} is written" + ); + } + assert!(!tmp.0.join("drawing.svg").exists()); + assert!(!tmp.0.join("entities.json").exists()); + + // The overview fits Claude's budget: edge <= 1568, patches <= 1568. + let [w, h] = report.overview.px; + assert!(w <= 1568 && h <= 1568, "{w}x{h}"); + assert!((w / 28) * (h / 28) <= 1568, "{w}x{h}"); + assert_eq!(w % 28, 0); + assert_eq!(h % 28, 0); + // Either the edge or the patch budget is the binding limit. + assert!( + w.max(h) >= 1568 - 28 || (w / 28) * (h / 28) >= 1568 - 56, + "the budget is used: {w}x{h}" + ); + let png = std::fs::read(tmp.0.join("overview.png")).unwrap(); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + assert!(dark_pixels(&png) > 1000, "the overview shows the drawing"); + + let manifest = read_json(&tmp.0.join("manifest.json")); + assert_eq!(manifest["$schema"], "uncad-package/1"); + assert_eq!(manifest["profile"], "claude"); + assert_eq!(manifest["source"]["name"], Value::Null); + assert_eq!(manifest["source"]["format"], "dwg"); + assert_eq!(manifest["svg_origin"], Value::Null, "no drawing.svg"); + assert_eq!(manifest["units"]["name"], "mm"); + assert_eq!(manifest["crop"]["source"], "content"); + // The 3256x INSERT, which the renderer's guard sets aside, and the + // second outlier the corpus's copy of this drawing carries. + let excluded = manifest["crop"]["excluded"].as_array().unwrap(); + assert!( + excluded + .iter() + .any(|e| e["handle"] == "756" && e["reason"] == "scale_outlier"), + "{excluded:?}" + ); + assert!(manifest["guidance"] + .as_str() + .unwrap() + .contains("strings.json")); + assert!(manifest["files"].as_array().unwrap().len() == report.files.len()); + assert_eq!(manifest["counts"]["dimensions"], 10); + assert_eq!(manifest["capabilities"]["dimension_values"], "exact"); + assert_eq!(manifest["capabilities"]["fonts"], "bundled"); + + // Every listed file exists with the listed size (manifest, README and + // report are listed without one: they are written last). + for file in manifest["files"].as_array().unwrap() { + let path = tmp.0.join(file["path"].as_str().unwrap()); + let meta = std::fs::metadata(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + if let Some(bytes) = file["bytes"].as_u64() { + assert_eq!(meta.len(), bytes, "{}", path.display()); + } else { + assert!( + ["manifest.json", "README.txt", "report.json"] + .contains(&file["path"].as_str().unwrap()), + "{file}" + ); + } + } +} + +#[test] +fn records_carry_exact_numbers() { + let tmp = TempDir::new("records"); + export_path(&example_2000(), &tmp.0, &ExportOptions::default()).expect("exports"); + + let dims = records(&tmp.0, "dimensions"); + assert_eq!(dims.len(), 10); + let aligned = dims + .iter() + .find(|d| d["kind"] == "ALIGNED") + .expect("an ALIGNED dimension"); + assert_eq!(aligned["display"], "1504,68"); + assert_eq!(aligned["measurement_source"], "act_measurement"); + assert!((aligned["measurement"].as_f64().unwrap() - 1504.6795).abs() < 1e-3); + assert_eq!(aligned["confidence"], "stored"); + let angular = dims + .iter() + .find(|d| d["kind"] == "ANGULAR2_LINE") + .unwrap_or_else(|| panic!("a two-line angular dimension: {dims:?}")); + assert_eq!(angular["unit"], "deg"); + assert!((angular["measurement"].as_f64().unwrap() - 108.0).abs() < 1e-3); + + let geometry = records(&tmp.0, "geometry"); + let cloud = by_handle(&geometry, "156"); + // The id is the model's reference ID; this reader mints it from the + // handle's value. + assert_eq!(cloud["id"], (0x156u64).to_string()); + assert_eq!(cloud["type"], "LWPOLYLINE"); + assert_eq!(cloud["vfmt"], "[x,y,bulge]"); + assert_eq!(cloud["closed"], true); + assert!(cloud["perimeter"].as_f64().unwrap() > 1400.0); + assert!(cloud["area"].as_f64().unwrap() > 0.0); + assert_eq!(cloud["confidence"], "exact"); + // The 3256x INSERT is not a record: it is outside the crop. + let instances = records(&tmp.0, "blocks"); + assert!( + instances.iter().all(|i| i["handle"] != "756"), + "{instances:?}" + ); + assert!(instances.iter().any(|i| i["block"] == "CIRKLO_PUNKTOJ")); + + let regions = records(&tmp.0, "regions"); + assert!(regions.iter().any(|r| r["handle"] == "156")); + let texts = records(&tmp.0, "texts"); + assert!(!texts.is_empty()); + assert!(texts + .iter() + .all(|t| t["bbox_confidence"] == "measured" && t["font_ok"] == true)); + // Every record has an overview pixel box. + for record in dims.iter().chain(&geometry).chain(®ions).chain(&texts) { + assert!(record["px"]["ov"].is_array(), "{record}"); + } + + // strings.json finds the dimension label. + let strings = read_json(&tmp.0.join("strings.json")); + let ids = strings["strings"]["1504,68"] + .as_array() + .expect("the label is indexed"); + assert!(ids.iter().any(|i| i == &aligned["id"])); + + // One top-level entity on the frozen layer; the dimension blocks hold + // the definition points on Defpoints. `count` is every hidden entity, + // the same number the manifest prints under the same word. + let report = read_json(&tmp.0.join("report.json")); + let manifest = read_json(&tmp.0.join("manifest.json")); + assert_eq!( + report["excluded"].as_array().unwrap().len(), + manifest["counts"]["excluded"].as_u64().unwrap() as usize + ); + assert_eq!( + report["hidden"]["count"].as_u64().unwrap(), + manifest["counts"]["hidden"].as_u64().unwrap() + ); + assert_eq!(report["hidden"]["top_level"], 1); + assert_eq!(report["hidden"]["covers"], "top_level"); + assert_eq!(report["hidden"]["handles"].as_array().unwrap().len(), 1); + assert_eq!(report["hidden"]["handles_truncated"], false); + assert_eq!(report["hidden"]["by_reason"]["layer_frozen"], 1); + assert!(report["hidden"]["inside_blocks"].as_u64().unwrap() > 1); +} + +#[test] +fn the_export_is_deterministic_and_options_are_honoured() { + let (db, header) = uncad::parse_with_header(example_2000()).expect("parses"); + let options = ExportOptions { + max_levels: 1, + svg: true, + full: true, + shard_kb: 4, + source_name: Some("example_2000.dwg".into()), + ..Default::default() + }; + let a = TempDir::new("det_a"); + let b = TempDir::new("det_b"); + let ra = uncad_export::export_package(&db, Some(&header), &a.0, &options).expect("exports"); + let rb = uncad_export::export_package(&db, Some(&header), &b.0, &options).expect("exports"); + assert!(a.0.join("drawing.svg").exists() && a.0.join("entities.json").exists()); + // A 4 KB shard size splits the geometry records. + assert!(a.0.join("geometry.001.json").exists(), "{:?}", ra.files); + let manifest = read_json(&a.0.join("manifest.json")); + assert_eq!(manifest["source"]["name"], "example_2000.dwg"); + assert!(manifest["shard_index"].as_array().unwrap().len() > 5); + // drawing.svg reads in world units for a drawing near the origin. + assert_eq!(manifest["svg_origin"], serde_json::json!([0.0, 0.0])); + + // Same input, same bytes -- every file, report.json included: it holds + // no clock. + for file in &ra.files { + let fa = std::fs::read(a.0.join(&file.path)).unwrap(); + let fb = std::fs::read(b.0.join(&file.path)).unwrap(); + assert!(fa == fb, "{} differs between runs", file.path); + } + assert_eq!(ra.counts, rb.counts); + + // Another profile changes the lattice. + let c = TempDir::new("openai"); + let rc = export_path( + &example_2000(), + &c.0, + &ExportOptions { + profile: Profile::OPENAI_PATCH, + max_levels: 0, + ..Default::default() + }, + ) + .expect("exports"); + assert_eq!(rc.overview.px[0] % 32, 0); +} + +#[test] +fn export_file_parses_and_names_its_source() { + let tmp = TempDir::new("export_file"); + let report = uncad_export::export_file( + std::path::Path::new(&fixture("hidden_layers_r2000.dxf")), + &tmp.0, + &ExportOptions { + max_levels: 0, + ..Default::default() + }, + ) + .expect("exports"); + assert!(report.counts.geometry > 0); + let manifest = read_json(&tmp.0.join("manifest.json")); + assert_eq!(manifest["source"]["name"], "hidden_layers_r2000.dxf"); + assert_eq!(manifest["source"]["format"], "dxf"); + // A file that does not parse is the parser's error, not a half-written + // package. + let missing = TempDir::new("export_missing"); + let err = uncad_export::export_file( + std::path::Path::new(&fixture("no_such_file.dxf")), + &missing.0, + &ExportOptions::default(), + ) + .expect_err("no such file"); + assert!(matches!(err, ExportError::Parse(_)), "{err:?}"); + assert!(!missing.0.join("manifest.json").exists()); +} + +#[test] +fn hidden_entities_stay_out_of_the_records() { + let tmp = TempDir::new("hidden"); + let report = export_path( + &fixture("hidden_layers_r2000.dxf"), + &tmp.0, + &ExportOptions::default(), + ) + .expect("exports"); + assert_eq!(report.counts.hidden, 4); + let geometry = records(&tmp.0, "geometry"); + assert_eq!(geometry.len(), 5, "{geometry:?}"); + let layers: BTreeSet<&str> = geometry + .iter() + .map(|g| g["layer"].as_str().unwrap()) + .collect(); + assert!( + !layers.contains("OFF") && !layers.contains("FROZEN") && !layers.contains("Defpoints"), + "{layers:?}" + ); + let dashed = geometry.iter().find(|g| g["layer"] == "VISIBLE").unwrap(); + assert_eq!(dashed["length"], 100.0); + assert_eq!(dashed["unit"], "mm"); + assert!(records(&tmp.0, "texts").is_empty()); + let report_json = read_json(&tmp.0.join("report.json")); + assert_eq!(report_json["hidden"]["by_reason"]["layer_off"], 1); + assert_eq!(report_json["hidden"]["by_reason"]["invisible"], 1); +} + +#[test] +fn hidden_entities_are_counted_the_same_way_in_both_files() { + let tmp = TempDir::new("hidden_agree"); + export_path( + &fixture("hidden_layers_r2000.dxf"), + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let manifest = read_json(&tmp.0.join("manifest.json")); + let report = read_json(&tmp.0.join("report.json")); + let hidden = &report["hidden"]; + assert_eq!( + hidden["count"].as_u64().unwrap(), + manifest["counts"]["hidden"].as_u64().unwrap() + ); + assert_eq!( + hidden["top_level"].as_u64().unwrap() + hidden["inside_blocks"].as_u64().unwrap(), + hidden["count"].as_u64().unwrap() + ); + let by_reason: u64 = hidden["by_reason"] + .as_object() + .unwrap() + .values() + .map(|v| v.as_u64().unwrap()) + .sum(); + assert_eq!(by_reason, hidden["top_level"].as_u64().unwrap()); + assert!(by_reason > 0); + assert_eq!(hidden["covers"], "top_level"); + assert_eq!( + hidden["handles"].as_array().unwrap().len(), + hidden["top_level"].as_u64().unwrap() as usize + ); + assert_eq!( + hidden["ids"].as_array().unwrap().len(), + hidden["top_level"].as_u64().unwrap() as usize + ); + assert_eq!(hidden["handles_truncated"], false); + assert_eq!(hidden["handles_limit"], 100); +} + +/// One line and one TEXT saying `s`, in model space. +fn drawing_with_text(s: &str) -> uncad::CadDatabase { + model_space(vec![ + line(0x10, 0.0, 0.0, 100.0, 0.0), + text(0x11, 10.0, 10.0, 5.0, s), + ]) +} + +#[test] +fn text_boxes_are_measured_with_the_bundled_font_and_gaps_are_reported() { + // Hangul, CP949-decoded, shaped by the bundled Noto Sans KR subset. + let tmp = TempDir::new("hangul"); + let report = export_path( + &fixture("cp949_r2000.dxf"), + &tmp.0, + &ExportOptions { + max_levels: 0, + ..Default::default() + }, + ) + .expect("exports"); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + let texts = records(&tmp.0, "texts"); + assert_eq!(texts.len(), 5); + assert!(texts + .iter() + .all(|t| t["bbox_confidence"] == "measured" && t["font_ok"] == true)); + // "도면" at height 2.5 is drawn at font-size 2.5 / 0.733 = 3.41 (the CAD + // height is the cap height): two syllables of roughly 0.85 em each, so + // about 5.8 units wide, and a Hangul glyph is about 0.88 em tall, so + // about 3 units -- taller than the 2.5 of a capital. + let domyeon = by_handle(&texts, "23"); + assert_eq!(domyeon["text"], "\u{b3c4}\u{ba74}"); + let b = domyeon["bbox"].as_array().unwrap(); + let (w, h) = ( + b[2].as_f64().unwrap() - b[0].as_f64().unwrap(), + b[3].as_f64().unwrap() - b[1].as_f64().unwrap(), + ); + assert!( + (4.8..7.5).contains(&w) && (2.5..3.8).contains(&h), + "{w} x {h}" + ); + let manifest = read_json(&tmp.0.join("manifest.json")); + assert_eq!(manifest["capabilities"]["text_boxes"], "measured"); + + // Hanja is outside the subset: the box is measured (as .notdef boxes), + // the record says the font failed, and the manifest warns. + let tmp = TempDir::new("hanja"); + let report = export_db( + &drawing_with_text("\u{6f22}\u{5b57} A"), + &tmp.0, + &ExportOptions { + max_levels: 0, + ..Default::default() + }, + ) + .expect("exports"); + assert!( + report + .warnings + .iter() + .any(|w| w.starts_with("UnshapedGlyphs")), + "{:?}", + report.warnings + ); + let texts = records(&tmp.0, "texts"); + let t = by_handle(&texts, "11"); + assert_eq!(t["id"], "17"); + assert_eq!(t["font_ok"], false); + assert_eq!(t["unshaped_glyphs"], 2); + assert_eq!(t["bbox_confidence"], "measured"); +} + +#[test] +fn a_very_wide_drawing_raises_the_tiny_overview_warning() { + // Two 1000-unit lines 2 units apart with a label between them: a 500:1 + // drawing. The fit for 1000 x 2 content is 56 patches wide and one + // patch (28 px) tall: far under 200 on the short edge. + let db = model_space(vec![ + line(0x10, 0.0, 0.0, 1000.0, 0.0), + line(0x11, 0.0, 2.0, 1000.0, 2.0), + text(0x12, 10.0, 0.5, 1.0, "WIDE"), + ]); + let tmp = TempDir::new("wide"); + let report = export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 0, + ..Default::default() + }, + ) + .expect("exports"); + let [w, h] = report.overview.px; + assert_eq!(h, 28, "{w}x{h}"); + assert!(w >= 200, "{w}x{h}"); + assert!( + report + .warnings + .iter() + .any(|w| w.starts_with("TinyOverview")), + "{:?}", + report.warnings + ); +} + +#[test] +fn capitals_are_drawn_as_tall_as_the_cad_text_height() { + // A capital-only TEXT of height 2 and a three-line capital-only MTEXT + // of height 2.5 attached at its top-left corner, over a line. + let db = model_space(vec![ + line(0x10, 0.0, 0.0, 100.0, 0.0), + text(0x11, 10.0, 10.0, 2.0, "HIH"), + mtext(0x12, 10.0, 40.0, 2.5, r"H\PH\PH"), + ]); + let tmp = TempDir::new("capitals"); + export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 0, + ..Default::default() + }, + ) + .expect("exports"); + let texts = records(&tmp.0, "texts"); + let bbox = |handle: &str| -> [f64; 4] { + let t = by_handle(&texts, handle); + assert_eq!(t["bbox_confidence"], "measured"); + let b = t["bbox"].as_array().unwrap(); + [ + b[0].as_f64().unwrap(), + b[1].as_f64().unwrap(), + b[2].as_f64().unwrap(), + b[3].as_f64().unwrap(), + ] + }; + // The CAD height is the height of the capitals: "HIH" at height 2 + // measures 2 units from baseline to cap top, sitting on its baseline at + // y = 10. + let t = bbox("11"); + assert!((t[3] - t[1] - 2.0).abs() < 0.05, "{t:?}"); + assert!((t[1] - 10.0).abs() < 0.05, "{t:?}"); + assert_eq!(by_handle(&texts, "11")["height"], 2.0); + // Three lines of height 2.5 at AutoCAD's 5/3 spacing: the first cap top + // at the insertion point (top attachment), baselines 4.1667 apart, so + // the block runs from y = 40 down to the last baseline at 29.1667. + let m = bbox("12"); + assert!((m[3] - 40.0).abs() < 0.05, "{m:?}"); + assert!( + (m[3] - m[1] - 2.5 * (1.0 + 2.0 * 5.0 / 3.0)).abs() < 0.05, + "{m:?}" + ); + assert_eq!(by_handle(&texts, "12")["text"], "H\nH\nH"); + assert_eq!(by_handle(&texts, "12")["raw"], r"H\PH\PH"); +} + +#[test] +fn exporting_onto_an_existing_file_reports_the_path_it_could_not_write() { + let tmp = TempDir::new("io"); + std::fs::create_dir_all(&tmp.0).expect("a writable directory"); + let file = tmp.0.join("not-a-directory"); + std::fs::write(&file, b"in the way").expect("writable"); + let err = export_db(&drawing_with_text("A"), &file, &ExportOptions::default()) + .expect_err("a file is not a directory"); + match &err { + ExportError::Io { path, source } => { + assert_eq!(path, &file); + assert!( + matches!( + source.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::NotADirectory + ), + "{source:?}" + ); + } + other => panic!("expected an Io error, got {other:?}"), + } + let message = err.to_string(); + assert!( + message.starts_with(&format!("cannot write {}", file.display())), + "{message}" + ); + assert!(file.is_file()); +} + +#[test] +fn a_package_says_when_its_dimension_values_were_recomputed() { + // An R14 file stores no measurement: the values come from the + // definition points, and every place that says where a number came + // from says so. + let tmp = TempDir::new("r14"); + export_path( + &corpus("example_r14.dwg"), + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let dims = records(&tmp.0, "dimensions"); + assert_eq!(dims.len(), 10); + for d in &dims { + assert_eq!(d["measurement_source"], "from_points", "{}", d["id"]); + assert_eq!(d["confidence"], "exact", "{}", d["id"]); + assert_eq!( + d["measurement"], d["measurement_from_points"], + "{}", + d["id"] + ); + assert!( + d["measurement"].as_f64().is_some_and(|m| m.abs() > 1.0), + "{} measures {}", + d["id"], + d["measurement"] + ); + assert!(d["delta"].is_null(), "{}", d["id"]); + } + let manifest = read_json(&tmp.0.join("manifest.json")); + assert_eq!(manifest["capabilities"]["dimension_values"], "computed"); +} + +#[test] +fn an_outline_too_big_to_test_says_so_instead_of_claiming_it_is_simple() { + // Above the cap the answer is `null` -- not the `true` that would let a + // reader trust an area that may be meaningless. + for (n, want_simple) in [(64usize, true), (8000, false)] { + let points: Vec<(f64, f64)> = (0..n) + .map(|i| { + let a = std::f64::consts::TAU * i as f64 / n as f64; + (100.0 * a.cos(), 100.0 * a.sin()) + }) + .collect(); + let db = model_space(vec![lwpolyline(0x10, &points, &[], true)]); + let tmp = TempDir::new(&format!("ring{n}")); + let started = std::time::Instant::now(); + export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let elapsed = started.elapsed(); + let regions = records(&tmp.0, "regions"); + assert_eq!(regions.len(), 1, "one closed polyline, one region"); + let region = ®ions[0]; + assert_eq!(region["vertex_count"], n); + if want_simple { + assert_eq!(region["simple"], true); + assert_eq!(region["confidence"], "exact"); + } else { + assert!(region["simple"].is_null(), "{}", region["simple"]); + assert_eq!(region["confidence"], "estimated"); + assert!( + region["why"] + .as_str() + .is_some_and(|w| w.contains("self-intersection")), + "{}", + region["why"] + ); + assert!( + elapsed < std::time::Duration::from_secs(60), + "{elapsed:?} for one polyline" + ); + } + assert!(region["area"].as_f64().is_some_and(|a| a > 0.0)); + } +} + +#[test] +fn every_string_the_picture_draws_is_a_text_record_and_a_strings_key() { + // The renderer draws a TOLERANCE's own and an ACAD_TABLE's + // cached block too, and the manifest's guidance tells the reader to + // find things by looking them up in strings.json. The expected set is + // every id the renderer put a element under, read back out of + // the SVG the same run wrote; the dimension labels are the documented + // exception -- they are carried by dimensions.json's `display`. + let tmp = TempDir::new("drawn_texts"); + export_path( + &corpus("example_2000.dxf"), + &tmp.0, + &ExportOptions { + max_levels: 1, + svg: true, + ..Default::default() + }, + ) + .expect("exports"); + let svg = std::fs::read_to_string(tmp.0.join("drawing.svg")).expect("drawing.svg"); + let drawn: BTreeSet = svg + .match_indices(" = records(&tmp.0, "dimensions") + .iter() + .map(|r| r["id"].as_str().unwrap().to_string()) + .collect(); + let expected: BTreeSet = drawn + .iter() + .filter(|id| !dimension_ids.contains(id.split('/').next().unwrap_or_default())) + .cloned() + .collect(); + assert!( + expected.len() >= 11, + "the drawing draws more than the three top-level texts: {expected:?}" + ); + let indexed: BTreeSet = records(&tmp.0, "texts") + .iter() + .map(|r| r["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + expected.difference(&indexed).collect::>(), + Vec::<&String>::new(), + "drawn but not indexed" + ); + let strings = read_json(&tmp.0.join("strings.json")); + let ids = strings["strings"]["test"] + .as_array() + .expect("a table cell reading \"test\""); + assert!( + ids.iter() + .any(|v| v.as_str().is_some_and(|s| s.contains('/'))), + "{ids:?} should name the cells inside the table" + ); + let texts = records(&tmp.0, "texts"); + let kinds: BTreeSet<&str> = texts.iter().filter_map(|r| r["kind"].as_str()).collect(); + assert!(kinds.contains("TOLERANCE"), "{kinds:?}"); +} + +#[test] +fn record_building_does_not_grow_with_the_square_of_the_entity_count() { + // Four times the entities may cost at most `LIMIT` times the time: a + // linear pass over every extent per record made it quadratic. + const LIMIT: f64 = 5.5; + let time_of = |count: usize| -> f64 { + let entities: Vec = (0..count) + .map(|i| { + let (x, y) = ((i % 500) as f64 * 2.0, (i / 500) as f64 * 2.0); + line(0x1000 + i as u64, x, y, x + 1.0, y) + }) + .collect(); + let db = model_space(entities); + let tmp = TempDir::new(&format!("scale{count}")); + let started = std::time::Instant::now(); + export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + started.elapsed().as_secs_f64() + }; + time_of(500); + let small = time_of(12_500); + let big = time_of(50_000); + assert!( + big <= small * LIMIT, + "50 000 entities took {big:.2}s against {small:.2}s for 12 500 ({:.1}x)", + big / small + ); +} + +#[test] +fn a_lines_length_is_its_length_in_three_dimensions() { + // The expected numbers are 3-4-5 triangles: (0,50,0)->(300,50,400) is + // 300 across and 400 up, so 500; the vertical one is 0 across and 100 + // up; the flat one is 400 across and 0 up, and has no `dz` at all. + let mut vertical = line(0xA, 0.0, 0.0, 0.0, 0.0); + let mut rafter = line(0xB, 0.0, 50.0, 300.0, 50.0); + if let Entity::Line(l) = &mut vertical { + l.end_point.z = 100.0; + } + if let Entity::Line(l) = &mut rafter { + l.end_point.z = 400.0; + } + let db = model_space(vec![vertical, rafter, line(0xC, 0.0, 100.0, 400.0, 100.0)]); + let tmp = TempDir::new("line3d"); + export_db(&db, &tmp.0, &ExportOptions::default()).expect("exports"); + let geometry = records(&tmp.0, "geometry"); + + let vertical = by_handle(&geometry, "A"); + assert_eq!(vertical["length"], 100.0); + assert_eq!(vertical["length_plan"], 0.0); + assert_eq!(vertical["dz"], 100.0); + assert_eq!(vertical["confidence"], "exact"); + let rafter = by_handle(&geometry, "B"); + assert_eq!(rafter["length"], 500.0); + assert_eq!(rafter["length_plan"], 300.0); + assert_eq!(rafter["dz"], 400.0); + let flat = by_handle(&geometry, "C"); + assert_eq!(flat["length"], 400.0); + assert!(flat.get("length_plan").is_none(), "{flat}"); + assert!(flat.get("dz").is_none(), "{flat}"); +} + +#[test] +fn a_donut_reports_its_area_and_gets_a_region_record() { + // Two bulges of 1 over a 100-unit chord are a circle of radius 50, so + // the area is pi * 50^2 = 7853.98163397 and the perimeter is + // 2 * pi * 50 = 314.15926536: both from the circle, not from uncad. + let db = model_space(vec![lwpolyline( + 0xD, + &[(0.0, 0.0), (100.0, 0.0)], + &[1.0, 1.0], + true, + )]); + let tmp = TempDir::new("donut"); + let report = export_db(&db, &tmp.0, &ExportOptions::default()).expect("exports"); + let geometry = records(&tmp.0, "geometry"); + let g = &geometry[0]; + assert_eq!(g["area"], 7853.98163397, "{g}"); + assert_eq!(g["perimeter"], 314.15926536, "{g}"); + assert_eq!(g["orientation"], "ccw", "{g}"); + assert_eq!(g["confidence"], "exact", "{g}"); + assert_eq!(report.counts.regions, 1); + let regions = records(&tmp.0, "regions"); + assert_eq!(regions[0]["area"], 7853.98163397, "{:?}", regions[0]); + assert_eq!(regions[0]["centroid"], serde_json::json!([50.0, 0.0])); + assert_eq!(regions[0]["vertex_count"], 2); +} + +#[test] +fn a_mirrored_polyline_is_reported_where_the_picture_draws_it() { + // The model keeps a planar entity in its own plane: this closed + // polyline states (10,0)-(20,0)-(20,10) with the normal (0,0,-1), which + // puts it at x -10..-20 in the world and runs it clockwise there. The + // record is the world's -- the box the picture draws it in -- with its + // arc bulge turned by the mirror and the same area. + let mut mirrored = lwpolyline( + 0xE, + &[(10.0, 0.0), (20.0, 0.0), (20.0, 10.0)], + &[0.0, 0.5, 0.0], + true, + ); + if let Entity::LwPolyline(p) = &mut mirrored { + p.extrusion.z = -1.0; + } + let plain = lwpolyline( + 0xF, + &[(10.0, 0.0), (20.0, 0.0), (20.0, 10.0)], + &[0.0, 0.5, 0.0], + true, + ); + let db = model_space(vec![mirrored, plain]); + let tmp = TempDir::new("mirrored"); + export_db(&db, &tmp.0, &ExportOptions::default()).expect("exports"); + let geometry = records(&tmp.0, "geometry"); + let m = by_handle(&geometry, "E"); + let p = by_handle(&geometry, "F"); + assert_eq!( + m["vertices"], + serde_json::json!([[-10.0, 0.0, 0.0], [-20.0, 0.0, -0.5], [-20.0, 10.0, 0.0]]) + ); + assert_eq!(m["area"], p["area"]); + assert_eq!(m["perimeter"], p["perimeter"]); + assert_eq!( + (m["orientation"].as_str(), p["orientation"].as_str()), + (Some("cw"), Some("ccw")) + ); + let b = m["bbox"].as_array().unwrap(); + assert!( + b[0].as_f64().unwrap() <= -20.0 && b[2].as_f64().unwrap() <= -10.0, + "{b:?}" + ); +} + +#[test] +fn a_region_says_why_its_area_is_unavailable_and_the_manifest_says_how_many() { + // Rectangle (0,0) (100,0) (100,50) (0,50): 5000. Bowtie (0,0) (100,0) + // (20,60) (80,80), whose edges cross: the shoelace half-sum is 1400. + let db = model_space(vec![ + lwpolyline( + 0x10, + &[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)], + &[], + true, + ), + lwpolyline( + 0x11, + &[(0.0, 0.0), (100.0, 0.0), (20.0, 60.0), (80.0, 80.0)], + &[], + true, + ), + ]); + let tmp = TempDir::new("areas"); + export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let regions = records(&tmp.0, "regions"); + assert_eq!(regions.len(), 2); + let good = by_handle(®ions, "10"); + assert_eq!(good["confidence"], "exact"); + assert_eq!(good["area"].as_f64().unwrap(), 5000.0); + assert!(good["why"].is_null()); + let bad = by_handle(®ions, "11"); + assert_eq!(bad["confidence"], "unavailable"); + assert_eq!(bad["simple"], false); + assert_eq!(bad["area"].as_f64().unwrap(), 1400.0); + assert!( + bad["why"] + .as_str() + .is_some_and(|w| w.contains("self-intersecting")), + "{bad}" + ); + let geometry = records(&tmp.0, "geometry"); + assert_eq!(by_handle(&geometry, "11")["why"], bad["why"]); + let manifest = read_json(&tmp.0.join("manifest.json")); + assert_eq!(manifest["capabilities"]["areas"], "mixed"); + assert_eq!( + manifest["capabilities"]["areas_by_confidence"], + serde_json::json!({"exact": 1, "estimated": 0, "unavailable": 1}) + ); +} + +/// `levels` blocks nested one inside the next, the innermost holding a +/// TEXT, with one INSERT of the outermost in model space. +fn nested_blocks(levels: usize) -> uncad::CadDatabase { + let insert = |id: u64, block: String| { + Entity::Insert(InsertEntity { + common: common(id), + block_name: Ref::Resolved(block), + insertion_point: p3(0.0, 0.0), + scale: uncad_model::Point3D { + x: 1.0, + y: 1.0, + z: 1.0, + }, + rotation: 0.0, + attribs: Vec::new(), + extrusion: z_axis(), + }) + }; + let mut blocks = Vec::new(); + let names: Vec = (0..levels).map(|i| format!("L{i}")).collect(); + for (i, name) in names.iter().enumerate() { + let child = if i + 1 == levels { + text(0x200 + i as u64, 1.0, 1.0, 2.5, "DEEPTEXT") + } else { + insert(0x200 + i as u64, format!("L{}", i + 1)) + }; + blocks.push((name.as_str(), vec![child])); + } + with_blocks( + vec![ + insert(0x100, "L0".into()), + line(0x101, 0.0, 0.0, 40.0, 20.0), + ], + blocks, + ) +} + +#[test] +fn a_text_twelve_blocks_deep_is_indexed_by_the_renderers_rule() { + // The renderer follows block references 20 deep, and the records hold + // every text it drew: twelve levels is a bound XREF of an assembly of + // assemblies, deeper than the feature branch's first cap of 8. + let db = nested_blocks(12); + let tmp = TempDir::new("deep_blocks"); + export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let texts = records(&tmp.0, "texts"); + assert_eq!(texts.len(), 1, "{texts:?}"); + assert_eq!(texts[0]["text"], "DEEPTEXT"); + // The id is the chain of INSERT IDs -- the top-level one and the + // eleven nested ones -- ending in the text's own. + let id = texts[0]["id"].as_str().unwrap(); + assert_eq!(id.split('/').count(), 13, "{id}"); + assert!(id.starts_with("256/512/"), "{id}"); + let strings = read_json(&tmp.0.join("strings.json")); + assert_eq!(strings["strings"]["deeptext"][0], id); +} + +#[test] +fn every_record_id_resolves_to_its_file_through_the_shard_index() { + // `first_key` and `last_key` publish the order of the ids, which are + // strings a consumer would otherwise compare as strings; block + // instances are in the index like every other kind. + let tmp = TempDir::new("shard_lookup"); + export_path( + &example_2000(), + &tmp.0, + &ExportOptions { + shard_kb: 1, + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let manifest = read_json(&tmp.0.join("manifest.json")); + let index = manifest["shard_index"].as_array().unwrap(); + assert!( + index.iter().filter(|s| s["kind"] == "blocks").count() > 1, + "1 KB shards split nine INSERT records" + ); + let mut checked = 0; + for (kind, name) in [ + ("text", "texts"), + ("dimension", "dimensions"), + ("geometry", "geometry"), + ("region", "regions"), + ("blocks", "blocks"), + ] { + for record in records(&tmp.0, name) { + let id = record["id"].as_str().unwrap(); + let key: u64 = id.split('/').next().unwrap().parse().unwrap(); + let found: Vec<&str> = index + .iter() + .filter(|s| { + s["kind"] == kind + && s["first_key"].as_u64().unwrap() <= key + && key <= s["last_key"].as_u64().unwrap() + }) + .map(|s| s["file"].as_str().unwrap()) + .collect(); + assert_eq!(found.len(), 1, "{id} of kind {kind} resolved to {found:?}"); + let shard = read_json(&tmp.0.join(found[0])); + assert!( + shard["records"] + .as_array() + .unwrap() + .iter() + .any(|r| r["id"] == id), + "{id} is not in {}", + found[0] + ); + checked += 1; + } + } + assert!(checked > 50, "{checked} records"); +} + +#[test] +fn a_nested_block_references_attribute_is_indexed_once() { + // The tag-inside-assembly pattern: block DOOR holds an INSERT of block + // TAG whose ATTRIB says NUM = D-101. DOOR is inserted at (100, 100) + // and the ATTRIB sits at (21, 21) in DOOR's own frame, so the value is + // drawn at (121, 121) at height 2.5; its id is the INSERT's and the + // ATTRIB's, the path the renderer names the with. + let tmp = TempDir::new("nested_attrib"); + export_path( + &fixture("nested_attrib_r2000.dxf"), + &tmp.0, + &ExportOptions { + max_levels: 1, + svg: true, + ..Default::default() + }, + ) + .expect("exports"); + let texts = records(&tmp.0, "texts"); + let nested = texts + .iter() + .find(|t| t["text"] == "D-101") + .unwrap_or_else(|| panic!("the nested attribute: {texts:?}")); + assert_eq!(nested["id"], format!("{}/{}", 0x60, 0x56)); + assert_eq!(nested["kind"], "ATTRIB"); + assert_eq!(nested["tag"], "NUM"); + let b: Vec = nested["bbox"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + assert!( + (120.5..122.5).contains(&b[0]) && (120.5..122.5).contains(&b[1]), + "{b:?}" + ); + assert!((b[3] - b[1] - 2.5).abs() < 0.3, "cap height 2.5: {b:?}"); + assert_eq!(texts.iter().filter(|t| t["text"] == "D-TOP").count(), 1); + assert_eq!(texts.iter().filter(|t| t["text"] == "D-101").count(), 1); + let strings = read_json(&tmp.0.join("strings.json")); + assert_eq!( + strings["strings"]["d-101"], + serde_json::json!([nested["id"]]), + "{}", + strings["strings"] + ); + let svg = std::fs::read_to_string(tmp.0.join("drawing.svg")).unwrap(); + assert_eq!(svg.matches("D-101").count(), 1, "drawn once"); + assert!(svg.contains(&format!("id=\"t{}.{}\"", 0x60, 0x56))); +} + +#[test] +fn a_nested_attribute_stored_on_the_insert_is_indexed_too() { + // The same nesting in the shape a DWG (and an R2004+ DXF) gives: the + // nested INSERT carries its ATTRIB in `attribs` and the block has no + // ATTRIB child at all. + let insert = |id: u64, block: &str, x: f64, y: f64, attribs: Vec| { + Entity::Insert(InsertEntity { + common: common(id), + block_name: Ref::Resolved(block.into()), + insertion_point: p3(x, y), + scale: uncad_model::Point3D { + x: 1.0, + y: 1.0, + z: 1.0, + }, + rotation: 0.0, + attribs, + extrusion: z_axis(), + }) + }; + let value = AttribEntity { + common: common(0x56), + start_point: Point2D { x: 21.0, y: 21.0 }, + text_height: 2.5, + tag: "NUM".into(), + flags: Default::default(), + text: "D-101".into(), + rotation: 0.0, + horizontal_justification: Default::default(), + vertical_justification: Default::default(), + alignment_point: None, + width_factor: 1.0, + oblique_angle: 0.0, + style_name: Ref::Absent, + elevation: 0.0, + extrusion: z_axis(), + }; + let db = with_blocks( + vec![insert(0x60, "DOOR", 100.0, 100.0, Vec::new())], + vec![ + ("TAG", vec![line(0x42, 0.0, 0.0, 10.0, 0.0)]), + ( + "DOOR", + vec![ + line(0x52, 0.0, 0.0, 40.0, 0.0), + line(0x53, 0.0, 0.0, 0.0, 40.0), + insert(0x55, "TAG", 20.0, 20.0, vec![value]), + ], + ), + ], + ); + let tmp = TempDir::new("nested_attrib_dwg"); + export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 0, + svg: true, + ..Default::default() + }, + ) + .expect("exports"); + let texts = records(&tmp.0, "texts"); + assert_eq!(texts.len(), 1, "{texts:?}"); + assert_eq!(texts[0]["id"], format!("{}/{}", 0x60, 0x56)); + assert_eq!(texts[0]["text"], "D-101"); + let b: Vec = texts[0]["bbox"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + assert!( + (120.5..122.5).contains(&b[0]) && (120.5..122.5).contains(&b[1]), + "{b:?}" + ); + let svg = std::fs::read_to_string(tmp.0.join("drawing.svg")).unwrap(); + assert_eq!(svg.matches("D-101").count(), 1, "drawn once: {svg}"); +} + +#[test] +fn a_mirrored_block_places_its_text_where_the_picture_draws_it() { + // A block holding one text at (10, 0), inserted at (100, 100) with the + // normal (0, 0, -1): the block is mirrored across the world's y axis, + // so its text lands at x = -100 - 10 = -110 and reads as mirror + // writing, not turned: rotation 0. + let mut mirrored = Entity::Insert(InsertEntity { + common: common(0x70), + block_name: Ref::Resolved("B".into()), + insertion_point: p3(100.0, 100.0), + scale: uncad_model::Point3D { + x: 1.0, + y: 1.0, + z: 1.0, + }, + rotation: 0.0, + attribs: Vec::new(), + extrusion: z_axis(), + }); + if let Entity::Insert(i) = &mut mirrored { + i.extrusion.z = -1.0; + } + let db = with_blocks( + vec![mirrored, line(0x71, -200.0, 0.0, 0.0, 200.0)], + vec![("B", vec![text(0x72, 10.0, 0.0, 2.0, "MIRROR")])], + ); + let tmp = TempDir::new("mirrored_block"); + export_db(&db, &tmp.0, &ExportOptions::default()).expect("exports"); + let texts = records(&tmp.0, "texts"); + assert_eq!(texts.len(), 1, "{texts:?}"); + let t = &texts[0]; + assert_eq!(t["anchor"], serde_json::json!([-110.0, 100.0])); + assert_eq!(t["rotation_deg"], 0.0); + assert_eq!(t["height"], 2.0); + let blocks = records(&tmp.0, "blocks"); + assert_eq!(blocks[0]["mirrored"], true); + assert_eq!(blocks[0]["at"], serde_json::json!([-100.0, 100.0])); + // Its box is on the mirrored side, and the picture's. + let b: Vec = t["bbox"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + assert!(b[2] <= -109.0 && b[0] < -115.0, "{b:?}"); +} + +#[test] +fn a_record_is_built_from_a_polyline_whose_first_vertex_repeats() { + // A closed outline whose file repeats the first vertex: four segments, + // not five, and the area of the square. + let db = model_space(vec![Entity::LwPolyline(LwPolylineEntity { + common: common(0x30), + vertices: [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0)] + .iter() + .map(|(x, y)| PolylineVertex::straight(Point2D { x: *x, y: *y })) + .collect(), + closed: true, + const_width: 0.0, + elevation: 0.0, + extrusion: z_axis(), + })]); + let tmp = TempDir::new("repeat"); + export_db(&db, &tmp.0, &ExportOptions::default()).expect("exports"); + let regions = records(&tmp.0, "regions"); + assert_eq!(regions[0]["area"], 16.0); + assert_eq!(regions[0]["perimeter"], 16.0); + assert_eq!(regions[0]["simple"], true); +} From 2a7b8f06c17a468a7b93e229bcc5aab6eefc22b0 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 19:47:19 +0900 Subject: [PATCH 44/54] Split the package into frames with a tile pyramid each The feature branch's frames and tiles, on the scene the package already walks once: - Frames: the drawn extents -- each part's grown by the measured boxes of the texts it draws, so a long Hangul note is culled by its glyphs and not by the 0.6-em estimate -- grouped by `frame::detached_groups` at `frame_gap` of the crop's diagonal. `f0` is the largest group; a detached one becomes `f1`, `f2`, ... when it holds `min_frame_entities` entities or a text, up to `max_frames`; every other group is in `frames_dropped` with its reason and a warning. One frame reuses the overview; several get `frames/fN/overview.png` each. - The pyramid: levels 2x apart until the dominant text class reaches `target_text_px`, within `max_levels` and the `max_tiles` budget (a level that would overrun it is dropped whole, with a warning, and the frame's `pyramid_complete` says so); SAHI-style tiles with the profile's overlap, the last row and column shifted inward; a tile nothing visible reaches is planned but not written, and `tiles.json` gives its reason. - Drawing: every frame overview and tile is `Scene::png` of its exact View with only the parts that reach it (plus construction lines), on up to 16 threads sharing the scene and the fonts; a thread that panics is an error, not a dead process. Records now list their tiles and carry a clipped pixel box on every image they are on. - Sidecars: position, affines, written neighbours, parent and children, records as positional rows with their `columns`, exact `counts` and `geometry_by_kind`, every layer on the tile; cut to 32 KB compact (geometry first, then the other rows, then the layers, each with its flag). `tiles.json` lists every tile with its bytes and SHA-256. The manifest gains `frames_dropped`, the legibility legend and a guidance that quotes the profile's own tile and overlap. Tests: the feature's tile, frame, sidecar and acceptance tests, including the pixel checks (the far- from-origin grid rasterizing alike, a Hangul note's second half on its tile, a nested attribute's ink on the tile its record names). Ported-from: 583882e, d4cddd6, 8d73ab4, 2afef3a, 14eaeae, 1f49e4d, b28c8a2, 0d8d299, 30c02d3, 0194c0e Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- crates/uncad-export/src/lib.rs | 25 +- crates/uncad-export/src/package/mod.rs | 191 +++- crates/uncad-export/src/package/output.rs | 125 +- crates/uncad-export/src/package/records.rs | 3 + crates/uncad-export/src/package/tiles.rs | 745 ++++++++++++ crates/uncad-export/tests/acceptance.rs | 179 +++ crates/uncad-export/tests/tiles.rs | 1193 ++++++++++++++++++++ 8 files changed, 2433 insertions(+), 35 deletions(-) create mode 100644 crates/uncad-export/src/package/tiles.rs create mode 100644 crates/uncad-export/tests/acceptance.rs create mode 100644 crates/uncad-export/tests/tiles.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d900626..909dcf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,8 +152,11 @@ Notable changes to this project are recorded here. The format follows instances -- whose ids are the model's reference IDs, with the file's handle beside them; an NFKC string index; record shards with an index a consumer can search; `drawing.json`, `report.json` (what was left out, hidden and bounded, and why) and a - manifest saying what the package can answer. The same input gives the same bytes, - `report.json` included. + manifest saying what the package can answer. The drawing splits into frames (a detail + beside the plan is its own), each with a pyramid of overlapping tiles as deep as its + text needs, culled to the tiles something visible reaches -- by the measured text boxes, + not the estimate -- and hashed in `tiles.json`; every tile has a sidecar under 32 KB + saying what is on it. The same input gives the same bytes, `report.json` included. ### Changed diff --git a/crates/uncad-export/src/lib.rs b/crates/uncad-export/src/lib.rs index 8b960ed..b89a865 100644 --- a/crates/uncad-export/src/lib.rs +++ b/crates/uncad-export/src/lib.rs @@ -15,13 +15,19 @@ //! ```text //! dir/ //! README.txt reading order -//! manifest.json source, units, profile, crop, overview, frames, legibility, -//! capabilities, counts, warnings, files, shard_index, legend, guidance +//! manifest.json source, units, profile, crop, overview, frames, frames_dropped, +//! legibility, capabilities, counts, warnings, files, shard_index, +//! legend, guidance //! drawing.json header, units, layers with their state, the block definitions, counts //! overview.png the whole crop, fitted to the profile (Claude: <= 1568 px edge, //! <= 1568 patches) +//! frames/fN/tiles/z{z}/r{rr}_c{cc}.png + .json tiles (Claude: 1092 px, 224 px +//! overlap) and their sidecars +//! frames/fN/overview.png one per frame when the drawing splits into several +//! tiles.json every tile of every level and frame: written (with its bytes and +//! sha256) or empty (with a reason) //! texts.json TEXT/MTEXT/ATTRIB/TOLERANCE, block contents included, with the -//! world box of their glyph outlines +//! world box of their glyph outlines and the tiles they are on //! dimensions.json measured value and where it came from, display string, points //! geometry.json every other visible entity: key points, length, area, bbox //! regions.json closed polylines: area, perimeter, centroid, the texts inside @@ -40,9 +46,16 @@ //! leaves anything else there alone. //! //! The drawing is walked once (the renderer's `Scene`), and every image is -//! a window of that walk. A text record's box is measured from the glyph -//! outlines of the bundled font ([`fonts`]) as the renderer laid them out; -//! a record's id is the model's reference ID (a path of them for a text +//! a window of that walk: the overview, one per frame (a detail drawn +//! beside the plan is a frame of its own), and a pyramid of overlapping +//! tiles per frame, 2x per level, as deep as the frame's dominant text needs +//! to reach the target pixel height and the tile budget allows. A tile is +//! drawn from the parts that reach it, on as many threads as there are. +//! +//! A text record's box is measured from the glyph outlines of the bundled +//! font ([`fonts`]) as the renderer laid them out, and those boxes widen the +//! drawn extents before the frames are grouped and the tiles culled; a +//! record's id is the model's reference ID (a path of them for a text //! inside a block), and the file's handle is beside it. What the package //! derives -- a text's readable string ([`text`]), a dimension's value, the //! trust in its stored measurement and its label ([`dimension`]), lengths, diff --git a/crates/uncad-export/src/package/mod.rs b/crates/uncad-export/src/package/mod.rs index 40f34d1..a28ceb1 100644 --- a/crates/uncad-export/src/package/mod.rs +++ b/crates/uncad-export/src/package/mod.rs @@ -3,6 +3,7 @@ mod output; mod records; +mod tiles; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -29,7 +30,7 @@ use crate::geom; use crate::text::decode_text; pub use output::WrittenFile; -use output::{clear_previous_package, to_rgb, Writer}; +use output::{clear_previous_package, sha256_hex, to_rgb, Writer}; use records::{ capped_confidence, coord_decimals, handle_of, id_key, layer_name, placed_texts, text_record, PlacedText, Record, Rounder, @@ -761,17 +762,95 @@ pub fn export_package( "glyph outlines as the renderer laid them out" }; - // --- records ----------------------------------------------------------- - let extent_of: BTreeMap = shown + // --- the drawn extents, widened by the measured texts ------------------ + // The renderer's extents count a text by its 0.6-em estimate; the text + // boxes above are its glyphs. A Hangul syllable advances about 0.92 + // heights, so a long Korean note runs some 12 % past its estimate, while + // `texts.json` lists a text's tiles from its measured box: the frames, + // the frame overviews and the tiles are culled by these extents, so each + // drawn part grows by the measured boxes of the texts it draws (the part + // is the first ID of a text's path). An estimate that was generous is + // never shrunk. + let mut extent_of: BTreeMap = shown .iter() .filter_map(|(p, _)| p.extent.map(|r| (p.id, Rect::from(r)))) .collect(); + for t in texts.iter().filter(|t| t.measured) { + if let Some(e) = extent_of.get_mut(&t.part) { + *e = e.union(&t.bbox); + } + } + // In drawing order: the frames are the groups of these. + let extents: Vec = shown + .iter() + .filter_map(|(p, _)| extent_of.get(&p.id).copied()) + .collect(); + + // --- frames: the primary group and each detached group ---------------- + let plan = tiles::plan_frames( + &extents, + crop_rect, + &texts, + &overview, + options, + &rounder, + &mut warnings, + ); + let frame_overviews: Vec<&ImageInfo> = plan + .frames + .iter() + .map(|f| &f.report.overview) + .filter(|ov| ov.id != "ov") + .collect(); + let mut tile_images: Vec = Vec::new(); + for build in &plan.frames { + for level in &build.report.levels { + for tile in build.tiles.iter().filter(|t| t.z == level.z && !t.empty) { + let png = format!( + "frames/{}/tiles/z{}/r{:02}_c{:02}.png", + tile.frame, tile.z, tile.row, tile.col + ); + tile_images.push(ImageInfo::new( + &tile.id, + &png, + tile.world, + level.ppu, + tile.width, + tile.height, + )); + } + } + } + // Every image but the overview, drawn on as many threads as there are: + // each is a window of the one walk, holding only the parts that reach + // it. + let to_draw: Vec<&ImageInfo> = frame_overviews + .iter() + .copied() + .chain(tile_images.iter()) + .collect(); + let drawn = tiles::render_images(&scene, fonts, &extent_of, &to_draw)?; + + // --- records with their images ------------------------------------------- + let images_for = |bbox: &Rect| -> Vec<&ImageInfo> { + tile_images + .iter() + .filter(|img| img.world.intersects(bbox)) + .collect() + }; let px_map = |bbox: &Rect| -> Value { let mut m = Map::new(); m.insert("ov".to_string(), json!(overview.px_box(bbox))); + for ov in frame_overviews.iter().filter(|o| o.world.intersects(bbox)) { + m.insert(ov.id.clone(), json!(ov.px_box(bbox))); + } + for img in images_for(bbox) { + m.insert(img.id.clone(), json!(img.px_box(bbox))); + } Value::Object(m) }; - let tiles_for = |_: &Rect| -> Vec { Vec::new() }; + let tiles_for = + |bbox: &Rect| -> Vec { images_for(bbox).iter().map(|i| i.id.clone()).collect() }; let with_images = |mut r: Record| -> Record { r.value.insert("tiles".into(), json!(tiles_for(&r.bbox))); r.value.insert("px".into(), px_map(&r.bbox)); @@ -826,24 +905,12 @@ pub fn export_package( } } } - - // --- the frames ------------------------------------------------------------ - let frame = FrameReport { - id: "f0".into(), - kind: "primary".into(), - content: crop_rect, - entities: extent_of.len(), - texts: texts - .iter() - .filter(|t| crop_rect.intersects(&t.bbox)) - .count(), - overview: overview.clone(), - levels: Vec::new(), - z_max: 0, - reached: true, - height_classes: Vec::new(), - }; - let frame_reports = vec![frame]; + let frame_reports: Vec = plan.frames.iter().map(|b| b.report.clone()).collect(); + let written_total: usize = frame_reports + .iter() + .flat_map(|f| f.levels.iter()) + .map(|l| l.tiles_written) + .sum(); // --- write ----------------------------------------------------------------- let mut writer = Writer { @@ -859,6 +926,15 @@ pub fn export_package( shard_kb: options.shard_kb, }; writer.write_bytes("overview.png", &overview_png, "image")?; + // Tile id -> its PNG's byte count and SHA-256, for `tiles.json`. + let mut tile_png: BTreeMap<&str, (u64, String)> = BTreeMap::new(); + for (image, bytes) in to_draw.iter().zip(&drawn) { + let is_tile = !frame_overviews.iter().any(|ov| ov.id == image.id); + if is_tile { + tile_png.insert(&image.id, (bytes.len() as u64, sha256_hex(bytes))); + } + writer.write_bytes(&image.png, bytes, if is_tile { "tile" } else { "image" })?; + } writer.write_records("texts", "text", &text_records)?; writer.write_records("dimensions", "dimension", &dim_records)?; writer.write_records("geometry", "geometry", &geo_records)?; @@ -877,6 +953,58 @@ pub fn export_package( }), "strings", )?; + + // --- sidecars and tiles.json ------------------------------------------------- + let on_tiles = tiles::OnTiles { + texts: &text_records, + dims: &dim_records, + blocks: &block_records, + regions: ®ion_records, + geometry: &geo_records, + }; + let mut tiles_json: Vec = Vec::new(); + for build in &plan.frames { + for tile in &build.tiles { + let mut entry = json!({ + "id": tile.id, + "frame": tile.frame, + "z": tile.z, + "row": tile.row, + "col": tile.col, + "px": [tile.width, tile.height], + "world": rounder.rect(&tile.world), + "empty": tile.empty, + }); + if tile.empty { + // One reason a planned tile is not written: nothing visible + // reaches it. A reader following `children` or a record's + // `tiles` must know an absent file was meant to be absent. + entry["reason"] = json!("no_visible_entity_on_tile"); + } + if let Some(img) = tile_images.iter().find(|i| i.id == tile.id) { + entry["png"] = json!(img.png); + if let Some((bytes, sha)) = tile_png.get(img.id.as_str()) { + entry["bytes"] = json!(bytes); + entry["sha256"] = json!(sha); + } + let sidecar = + tiles::sidecar(img, tile, &build.tiles, &profile, &on_tiles, &rounder); + let sidecar_path = img.png.replace(".png", ".json"); + writer.write_json_compact(&sidecar_path, &sidecar, "sidecar")?; + entry["sidecar"] = json!(sidecar_path); + } + tiles_json.push(entry); + } + } + writer.write_json( + "tiles.json", + &json!({ + "$schema": SCHEMA, + "frames": frame_reports.iter().map(|f| json!({ "id": f.id, "kind": f.kind, "content": rounder.rect(&f.content), "levels": f.levels })).collect::>(), + "tiles": tiles_json, + }), + "tiles", + )?; writer.write_json( "drawing.json", &drawing_json(db, header, parts, &top, &instances, &writer.units)?, @@ -971,7 +1099,7 @@ pub fn export_package( blocks: block_records.len(), hidden: scene.hidden, excluded: excluded_json.len(), - tiles: 0, + tiles: written_total, frames: frame_reports.len(), sheets: 0, }; @@ -1014,6 +1142,8 @@ pub fn export_package( svg_origin, overview: &overview, frames: &frame_reports, + frames_dropped: &plan.dropped, + frames_dropped_total: plan.dropped_total, counts: &counts, texts: written_texts, dims: &dim_records, @@ -1708,6 +1838,8 @@ struct ManifestInput<'a, 'w> { svg_origin: Option<[f64; 2]>, overview: &'a ImageInfo, frames: &'a [FrameReport], + frames_dropped: &'a [Value], + frames_dropped_total: usize, counts: &'a Counts, texts: &'a [Record], dims: &'a [Record], @@ -1777,9 +1909,16 @@ fn manifest_json(m: ManifestInput<'_, '_>) -> Value { "display_source": ["user_text", "cached_block", "formatted", "suppressed", "none"], "region_labels": "`labels` holds the ids of the text records whose anchor falls inside the region", "px_boxes": "`px` maps an image id to [x0, y0, x1, y1] in that image's pixels, y down, clipped to the image; the record's full extent is its world `bbox`, and the rest of it is on the other images it lists", + "tile_sidecar": "`records` holds positional rows described by the sidecar's own `columns`; `counts` is the true number of records of each kind on the tile, which `records_truncated` does not affect, and `geometry_by_kind` summarises the geometry whether or not its rows fit", "shard_lookup": "manifest.shard_index resolves a record id to its file: the entry whose [first_key, last_key] contains int(id.split('/')[0]). `first_id`/`last_id` are the same bounds as strings and do not compare as numbers", + "legibility": "`target_met` is whether every text height class reaches `target_px` in the deepest image of that frame; `pyramid_complete` is whether the tile budget let the pyramid reach the depth the text asked for", }); - let guidance = "Read manifest.json first. Numbers (lengths, areas, dimension values, text) come from the JSON records, never from pixels; `legend` says what `confidence` and the package's other vocabularies mean and which records carry them. To find something: look its text up in strings.json (normalised: trimmed, lower-case, single spaces), resolve the id through shard_index -- the entry whose [first_key, last_key] contains int(id.split('/')[0]), since the id does not say its kind and one id can be in two. overview.png shows the whole crop. report.json lists what was left out and why.".to_string(); + // The tile and overlap numbers come from the profile in use, not from + // the prose: claude-hires writes 1932 px tiles with 392 px of overlap. + let guidance = format!( + "Read manifest.json first. Numbers (lengths, areas, dimension values, text) come from the JSON records, never from pixels; `legend` says what `confidence` and the package's other vocabularies mean and which records carry them. To find something: look its text up in strings.json (normalised: trimmed, lower-case, single spaces), resolve the id through shard_index -- the entry whose [first_key, last_key] contains int(id.split('/')[0]), since the id does not say its kind and one id can be in two -- then open the tile(s) in its `tiles` list; every tile's .json sidecar lists what is on it with pixel boxes, its own `columns` legend and a `counts` object that stays exact when rows are cut. Pixel boxes are clipped to the image they are quoted in; the record's world `bbox` is its full extent. overview.png shows the whole crop; each frame in `frames` (f0 the main drawing, f1.. details drawn beside it) has its own overview and tiles z1..zN, {} px with {} px overlap (2x zooms), row 0 at the top; a group too small to be framed is in `frames_dropped` and its records carry `tiles: []`. report.json lists what was left out and why.", + profile.tile, profile.overlap + ); m.writer.files.push(WrittenFile { path: "manifest.json".into(), bytes: None, @@ -1807,6 +1946,8 @@ fn manifest_json(m: ManifestInput<'_, '_>) -> Value { "svg_origin": m.svg_origin, "overview": m.overview, "frames": m.frames, + "frames_dropped": m.frames_dropped, + "frames_dropped_total": m.frames_dropped_total, "legibility": { "target_px": m.options.target_text_px, "per_frame": m.frames.iter().map(|f| json!({"frame": f.id, "z_max": f.z_max, "target_met": f.height_classes.iter().all(|c| c.legible), "pyramid_complete": f.reached, "height_classes": f.height_classes})).collect::>() }, "counts": m.counts, "capabilities": capabilities, @@ -1820,7 +1961,7 @@ fn manifest_json(m: ManifestInput<'_, '_>) -> Value { fn readme(options: &ExportOptions) -> String { format!( - "uncad package ({SCHEMA})\n\nReading order:\n 1. manifest.json what is here, the crop, the images and their affines; `legend` explains the record vocabularies, `guidance` how to look something up\n 2. strings.json find a text or a number, get record ids; shard_index turns an id into a file (compare int(id.split('/')[0]) against first_key/last_key, not the strings)\n 3. texts.json / dimensions.json / geometry.json / regions.json / blocks.json the records (sharded above {} KB, see shard_index); blocks.json holds the INSERT instances, drawing.json the block definitions\n 4. overview.png the whole drawing\n 5. report.json what was left out and why\n\ndrawing.json holds the header, units, layer states and block definitions; entities.json and drawing.svg (when present) are tool inputs, not for reading.\n", + "uncad package ({SCHEMA})\n\nReading order:\n 1. manifest.json what is here, the crop, the images and their affines; `legend` explains the record vocabularies, `guidance` how to look something up\n 2. strings.json find a text or a number, get record ids; shard_index turns an id into a file (compare int(id.split('/')[0]) against first_key/last_key, not the strings)\n 3. texts.json / dimensions.json / geometry.json / regions.json / blocks.json the records (sharded above {} KB, see shard_index); blocks.json holds the INSERT instances, drawing.json the block definitions\n 4. overview.png the whole drawing; frames/f*/overview.png and frames/f*/tiles/z*/ zoomed tiles with .json sidecars; tiles.json lists every tile, written or empty with a reason, with its size and sha256\n 5. report.json what was left out and why\n\ndrawing.json holds the header, units, layer states and block definitions; entities.json and drawing.svg (when present) are tool inputs, not for reading.\n", options.shard_kb ) } diff --git a/crates/uncad-export/src/package/output.rs b/crates/uncad-export/src/package/output.rs index 2e65d9f..b87e2a2 100644 --- a/crates/uncad-export/src/package/output.rs +++ b/crates/uncad-export/src/package/output.rs @@ -1,6 +1,6 @@ //! Writing the package: files and their listing, record shards and their -//! index, the 8-bit RGB images, and clearing what a previous package in the -//! same directory left. +//! index, the 8-bit RGB images, the tile hashes, and clearing what a +//! previous package in the same directory left. use std::path::Path; @@ -62,6 +62,21 @@ impl Writer<'_> { self.write_bytes(rel, text.as_bytes(), kind) } + /// [`Writer::write_json`] without the indentation, for a file whose own + /// size is the thing being kept: a sidecar drops record rows until its + /// compact form fits its cap, so pretty-printing it afterwards would put + /// a file three times the measured size on disk. Record shards are + /// written compact for the same reason. + pub(crate) fn write_json_compact( + &mut self, + rel: &str, + value: &Value, + kind: &str, + ) -> Result<(), ExportError> { + let text = serde_json::to_string(value)?; + self.write_bytes(rel, text.as_bytes(), kind) + } + /// Writes `records` (already in id order) as `name.json`, or as /// `name.NNN.json` shards above `shard_kb`, and indexes them. pub(crate) fn write_records( @@ -226,10 +241,116 @@ pub(crate) fn to_rgb(rgba_png: &[u8]) -> Result, ExportError> { Ok(out) } +/// SHA-256 of `data` as lower-case hex (FIPS 180-4), for the per-tile +/// hashes `tiles.json` publishes: a package verifier can check a tile PNG +/// against the manifest without re-running the export. Written out here +/// rather than pulled in: 40 lines of a fully specified function, pinned by +/// the standard test vectors below. +pub(crate) fn sha256_hex(data: &[u8]) -> String { + const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + let mut h: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ]; + let bit_len = (data.len() as u64).wrapping_mul(8); + let mut padded = Vec::with_capacity(data.len() + 72); + padded.extend_from_slice(data); + padded.push(0x80); + while padded.len() % 64 != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_len.to_be_bytes()); + let (blocks, _) = padded.as_chunks::<64>(); + for block in blocks { + let mut w = [0u32; 64]; + let (words, _) = block.as_chunks::<4>(); + for (word, bytes) in w.iter_mut().zip(words) { + *word = u32::from_be_bytes(*bytes); + } + for i in 16..64 { + let (a, b) = (w[i - 15], w[i - 2]); + let s0 = a.rotate_right(7) ^ a.rotate_right(18) ^ (a >> 3); + let s1 = b.rotate_right(17) ^ b.rotate_right(19) ^ (b >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h; + for (k, wi) in K.iter().zip(w.iter()) { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ (!e & g); + let t1 = hh + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(*k) + .wrapping_add(*wi); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(maj); + hh = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (x, y) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) { + *x = x.wrapping_add(y); + } + } + h.iter().map(|v| format!("{v:08x}")).collect() +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn sha256_matches_the_published_vectors() { + // FIPS 180-4's own examples, plus the one-block/two-block boundary + // (55, 56 and 64 bytes, where the padding runs into a second + // block). + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + assert_eq!( + sha256_hex(&[b'a'; 1000]), + "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3" + ); + for len in [55, 56, 63, 64, 65] { + let hash = sha256_hex(&vec![0u8; len]); + assert_eq!(hash.len(), 64, "{len} bytes"); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + assert_eq!( + sha256_hex(&[b'a'; 56]), + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a" + ); + } + #[test] fn an_rgba_png_on_white_becomes_the_same_picture_in_rgb() { // A 2 x 1 RGBA image: one opaque red pixel, one half-transparent diff --git a/crates/uncad-export/src/package/records.rs b/crates/uncad-export/src/package/records.rs index 648f1cf..85b5146 100644 --- a/crates/uncad-export/src/package/records.rs +++ b/crates/uncad-export/src/package/records.rs @@ -184,6 +184,8 @@ fn entity_hidden(e: &Entity, tables: &Tables, reference_layer: Option<&str>) -> /// A text placed in the world (or on a sheet's paper). pub(crate) struct PlacedText { pub(crate) id: String, + /// The top-level entity whose part draws the text: its path's first ID. + pub(crate) part: EntityId, pub(crate) handle: Value, pub(crate) kind: &'static str, pub(crate) layer: String, @@ -456,6 +458,7 @@ fn place(index: &mut PathIndex<'_>, b: &TextBox) -> Option { .unwrap_or(Rect::at(anchor.x, anchor.y)); Some(PlacedText { id: record_id(&b.path), + part: b.path[0], handle: handle_of(common), kind: leaf.kind, layer: effective_layer(common.layer.name(), reference).to_string(), diff --git a/crates/uncad-export/src/package/tiles.rs b/crates/uncad-export/src/package/tiles.rs new file mode 100644 index 0000000..d6fa125 --- /dev/null +++ b/crates/uncad-export/src/package/tiles.rs @@ -0,0 +1,745 @@ +//! Frames and their tile pyramids: the drawing's detached groups, each with +//! its own overview when there is more than one; zoom levels deep enough for +//! the frame's text; overlapping tiles of every level, culled to the ones +//! something visible reaches; and the sidecar beside each tile saying what +//! is on it. + +use std::collections::{BTreeMap, BTreeSet}; + +use iron_render_cad::{Background, Fonts, Part, PngError, Scene}; +use serde_json::{json, Value}; +use uncad_model::model::{EntityId, Point2D}; + +use super::output::to_rgb; +use super::records::{PlacedText, Record, Rounder}; +use super::{ + fit_overview, ExportError, ExportOptions, FrameReport, HeightClass, ImageInfo, LevelInfo, + Profile, SCHEMA, STROKE_PX, +}; +use crate::frame::{detached_groups, Rect}; + +/// The most entries `manifest.frames_dropped` lists (the count is always +/// exact in `frames_dropped_total`). +const MAX_DROPPED_FRAMES: usize = 100; + +pub(crate) struct Tile { + pub(crate) id: String, + pub(crate) frame: String, + pub(crate) z: u32, + pub(crate) row: u32, + pub(crate) col: u32, + /// Pixel origin on the level canvas. + pub(crate) origin_px: (u32, u32), + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) world: Rect, + pub(crate) empty: bool, +} + +pub(crate) struct FrameBuild { + pub(crate) report: FrameReport, + pub(crate) tiles: Vec, +} + +/// The frames a drawing is split into, and the groups that were not made +/// frames. +pub(crate) struct FramePlan { + pub(crate) frames: Vec, + /// `manifest.frames_dropped`: at most [`MAX_DROPPED_FRAMES`] entries. + pub(crate) dropped: Vec, + pub(crate) dropped_total: usize, +} + +/// Splits the drawing into frames -- `f0` the largest connected group of +/// entities, a detached group (a detail drawn beside the plan) its own when +/// it holds `min_frame_entities` entities or a text -- and plans each +/// frame's tile pyramid within the tile budget. +/// +/// `extents` are the drawn entities' boxes, in drawing order; `crop` the +/// whole crop (the one frame of a drawing that does not split); `overview` +/// the whole crop's image, which a one-frame drawing reuses. +#[allow(clippy::too_many_arguments)] +pub(crate) fn plan_frames( + extents: &[Rect], + crop: Rect, + texts: &[PlacedText], + overview: &ImageInfo, + options: &ExportOptions, + rounder: &Rounder, + warnings: &mut Vec, +) -> FramePlan { + let groups = detached_groups(extents, options.frame_gap * crop.diagonal()); + let group_rect = + |g: &[usize]| Rect::bounding(g.iter().map(|i| &extents[*i])).expect("non-empty"); + let group_texts = |r: &Rect| texts.iter().filter(|t| r.intersects(&t.bbox)).count(); + let mut specs: Vec<(String, &str, Rect, usize)> = Vec::new(); + // Every group that did not become a frame, with the reason: a reader + // seeing a record with `tiles: []` needs to know whether that part of + // the drawing was left out on purpose. The list is capped (a drawing + // of scattered symbols can have thousands of groups) and + // `frames_dropped_total` says how many there were. + let mut dropped: Vec = Vec::new(); + let mut dropped_total = 0usize; + let (mut dropped_small, mut dropped_over_max) = (0usize, 0usize); + if groups.len() <= 1 { + specs.push(("f0".to_string(), "primary", crop, extents.len())); + } else { + for (n, g) in groups.iter().enumerate() { + let r = group_rect(g); + let qualifies = n == 0 || g.len() >= options.min_frame_entities || group_texts(&r) > 0; + let reason = if !qualifies { + dropped_small += 1; + "below_min_entities" + } else if specs.len() >= options.max_frames.max(1) { + dropped_over_max += 1; + "max_frames" + } else { + let id = format!("f{}", specs.len()); + specs.push((id, if n == 0 { "primary" } else { "detached" }, r, g.len())); + continue; + }; + dropped_total += 1; + if dropped.len() < MAX_DROPPED_FRAMES { + dropped.push(json!({ + "content": rounder.rect(&r), + "entities": g.len(), + "texts": group_texts(&r), + "reason": reason, + })); + } + } + if dropped_over_max > 0 { + warnings.push(format!( + "MaxFrames: {dropped_over_max} detached groups beyond the {} frames written stay in the overview only (frames_dropped)", + options.max_frames + )); + } + if dropped_small > 0 { + warnings.push(format!( + "SmallGroups: {dropped_small} detached groups hold fewer than {} entities and no text; they stay in the overview only, so their records carry `tiles: []` (frames_dropped)", + options.min_frame_entities + )); + } + } + let mut frames = Vec::new(); + let mut tile_budget = options.max_tiles; + let single = specs.len() == 1; + for (id, kind, content, entities) in &specs { + let reuse = single.then(|| overview.clone()); + frames.push(build_frame( + id, + kind, + *content, + *entities, + texts, + extents, + options, + reuse, + &mut tile_budget, + warnings, + )); + } + FramePlan { + frames, + dropped, + dropped_total, + } +} + +/// Plans one frame: its overview (or the whole-crop one when `reuse` is +/// given), its depth from the texts inside it, and its tiles within the +/// remaining `tile_budget`. +#[allow(clippy::too_many_arguments)] +fn build_frame( + id: &str, + kind: &str, + content: Rect, + entities: usize, + texts: &[PlacedText], + extents: &[Rect], + options: &ExportOptions, + reuse: Option, + tile_budget: &mut usize, + warnings: &mut Vec, +) -> FrameBuild { + let profile = &options.profile; + let overview = reuse.unwrap_or_else(|| { + let fit = fit_overview(&content, profile, options.padding); + ImageInfo::new( + &format!("{id}/ov"), + &format!("frames/{id}/overview.png"), + fit.rect, + fit.ppu, + fit.width, + fit.height, + ) + }); + let (rect0, w0, h0, ppu_0) = (overview.world, overview.px[0], overview.px[1], overview.ppu); + let inside: Vec<&PlacedText> = texts + .iter() + .filter(|t| content.intersects(&t.bbox)) + .collect(); + let heights = height_classes(&inside); + let z_max = depth_for(&heights, ppu_0, options); + let mut levels: Vec = Vec::new(); + let mut tiles: Vec = Vec::new(); + let mut reached = true; + for z in 1..=z_max { + let ppu = ppu_0 * 2f64.powi(z as i32); + let (cw, ch) = (w0 * 2u32.pow(z), h0 * 2u32.pow(z)); + let plan = plan_tiles(id, z, cw, ch, &rect0, ppu, profile, extents); + let written = plan.iter().filter(|t| !t.empty).count(); + if written > *tile_budget { + reached = false; + warnings.push(format!( + "MaxTiles: frame {id} level z{z} would need {written} tiles with {} left of {}; stopping at z{}", + *tile_budget, + options.max_tiles, + z - 1 + )); + break; + } + *tile_budget -= written; + let (cols, rows) = grid(cw, ch, profile); + levels.push(LevelInfo { + z, + ppu, + canvas_px: [cw, ch], + cols, + rows, + tile_px: profile.tile, + overlap_px: profile.overlap, + step_px: profile.tile - profile.overlap, + tiles_written: written, + tiles_empty: plan.len() - written, + }); + tiles.extend(plan); + } + let z_reached = levels.last().map_or(0, |l| l.z); + let height_classes = heights + .iter() + .map(|(h, n)| { + let px = h * ppu_0 * 2f64.powi(z_reached as i32); + HeightClass { + height: super::records::round_to(*h, 6), + count: *n, + px_at_zmax: super::records::round_to(px, 3), + legible: px >= options.target_text_px, + } + }) + .collect(); + FrameBuild { + report: FrameReport { + id: id.to_string(), + kind: kind.to_string(), + content, + entities, + texts: inside.len(), + overview, + levels, + z_max: z_reached, + reached, + height_classes, + }, + tiles, + } +} + +/// Count-weighted text height classes (heights rounded to 3 decimals), +/// most common first. +fn height_classes(texts: &[&PlacedText]) -> Vec<(f64, usize)> { + let mut classes: BTreeMap = BTreeMap::new(); + for t in texts { + if t.height > 0.0 && t.height.is_finite() { + *classes + .entry((t.height * 1000.0).round() as i64) + .or_default() += 1; + } + } + let mut out: Vec<(f64, usize)> = classes + .into_iter() + .map(|(k, n)| (k as f64 / 1000.0, n)) + .collect(); + out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.total_cmp(&b.0))); + out +} + +/// The deepest level: where the dominant text class (the count-weighted +/// median height) reaches the target pixel height; one level without text. +fn depth_for(heights: &[(f64, usize)], ppu_0: f64, options: &ExportOptions) -> u32 { + if options.max_levels == 0 { + return 0; + } + let total: usize = heights.iter().map(|(_, n)| n).sum(); + if total == 0 { + return 1; + } + let mut sorted: Vec<(f64, usize)> = heights.to_vec(); + sorted.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut seen = 0; + let mut median = sorted[0].0; + for (h, n) in &sorted { + seen += n; + if seen * 2 >= total { + median = *h; + break; + } + } + let px_now = median * ppu_0; + if px_now <= 0.0 { + return 1; + } + let z = (options.target_text_px / px_now).log2().ceil(); + if z.is_finite() { + (z.max(1.0) as u32).min(options.max_levels) + } else { + 1 + } +} + +fn grid(canvas_w: u32, canvas_h: u32, profile: &Profile) -> (u32, u32) { + let step = profile.tile - profile.overlap; + let count = |extent: u32| -> u32 { + if extent <= profile.tile { + 1 + } else { + (extent - profile.tile).div_ceil(step) + 1 + } + }; + (count(canvas_w), count(canvas_h)) +} + +/// The tiles of one level: SAHI-style, the last row and column shifted +/// inward so every tile is the full size (or the whole canvas when that is +/// smaller); a tile is empty when no drawn extent touches it. +#[allow(clippy::too_many_arguments)] +fn plan_tiles( + frame: &str, + z: u32, + canvas_w: u32, + canvas_h: u32, + rect0: &Rect, + ppu: f64, + profile: &Profile, + extents: &[Rect], +) -> Vec { + let (cols, rows) = grid(canvas_w, canvas_h, profile); + let step = profile.tile - profile.overlap; + let origin = |index: u32, extent: u32| -> (u32, u32) { + if extent <= profile.tile { + (0, extent) + } else { + let o = (index * step).min(extent - profile.tile); + (o, profile.tile) + } + }; + let mut tiles = Vec::new(); + for row in 0..rows { + let (oy, th) = origin(row, canvas_h); + for col in 0..cols { + let (ox, tw) = origin(col, canvas_w); + let x0 = rect0.min_x + f64::from(ox) / ppu; + let y1 = rect0.max_y - f64::from(oy) / ppu; + let world = Rect::new(x0, y1 - f64::from(th) / ppu, x0 + f64::from(tw) / ppu, y1); + let empty = !extents.iter().any(|e| e.intersects(&world)); + tiles.push(Tile { + id: format!("{frame}/z{z}/r{row:02}_c{col:02}"), + frame: frame.to_string(), + z, + row, + col, + origin_px: (ox, oy), + width: tw, + height: th, + world, + empty, + }); + } + } + tiles +} + +/// Which parts an image of `window` draws: every part the crop did not +/// leave out whose extent reaches the window grown by `margin` (for strokes +/// and text overhang), every construction line, and every part that +/// measured no extent -- so rasterizing a tile costs what is on it, not the +/// whole drawing. +pub(crate) fn keep_for<'a>( + window: Rect, + margin: f64, + extent_of: &'a BTreeMap, +) -> impl Fn(&Part) -> bool + 'a { + let grown = window.padded(margin); + move |p: &Part| { + p.left_out.is_none() + && (p.unbounded + || extent_of + .get(&p.id) + .is_none_or(|rect| rect.intersects(&grown))) + } +} + +/// Renders `images` on as many threads as the machine offers (at most one +/// per image), returning the 8-bit RGB PNG bytes in their order. A thread +/// that panics ends the export with [`PngError::RenderPanic`], not with the +/// process. +pub(crate) fn render_images( + scene: &Scene, + fonts: &Fonts, + extent_of: &BTreeMap, + images: &[&ImageInfo], +) -> Result>, ExportError> { + if images.is_empty() { + return Ok(Vec::new()); + } + let threads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) + .clamp(1, 16) + .min(images.len()); + let chunk = images.len().div_ceil(threads); + let render_one = |image: &ImageInfo| -> Result, ExportError> { + let keep = keep_for(image.world, 16.0 / image.ppu, extent_of); + let rgba = scene.png(&image.view(), STROKE_PX, fonts, Background::White, keep)?; + to_rgb(&rgba) + }; + let results: Vec>, ExportError>> = std::thread::scope(|scope| { + let handles: Vec<_> = images + .chunks(chunk) + .map(|group| { + let render_one = &render_one; + scope.spawn(move || { + group + .iter() + .map(|image| render_one(image)) + .collect::, _>>() + }) + }) + .collect(); + handles + .into_iter() + .map(|h| { + h.join().unwrap_or_else(|_| { + Err(ExportError::Render(PngError::RenderPanic( + "a tile rendering thread panicked".to_string(), + ))) + }) + }) + .collect() + }); + let mut out = Vec::with_capacity(images.len()); + for group in results { + out.extend(group?); + } + Ok(out) +} + +/// A sidecar's cap: its compact JSON never exceeds this. +const SIDECAR_LIMIT: usize = 32 * 1024; + +/// The most rows of one kind a sidecar builds before the shrink loop even +/// looks at it. The shortest row a group can write is about 25 bytes, so +/// nothing beyond this could ever fit in [`SIDECAR_LIMIT`]; cutting here is +/// the same cut the loop would make, reported the same way. +const MAX_SIDECAR_ROWS: usize = SIDECAR_LIMIT / 20; + +/// The records a sidecar looks at, by kind. +pub(crate) struct OnTiles<'a> { + pub(crate) texts: &'a [Record], + pub(crate) dims: &'a [Record], + pub(crate) blocks: &'a [Record], + pub(crate) regions: &'a [Record], + pub(crate) geometry: &'a [Record], +} + +/// The sidecar of one written tile: where it is, how its pixels map to the +/// world, its written neighbours, parent and children, and the records on +/// it as positional rows (their columns named beside them), cut to fit +/// [`SIDECAR_LIMIT`] with `counts` and `geometry_by_kind` staying exact. +pub(crate) fn sidecar( + img: &ImageInfo, + tile: &Tile, + tiles: &[Tile], + profile: &Profile, + records: &OnTiles<'_>, + rounder: &Rounder, +) -> Value { + // Only tiles that were *written*: an empty tile gets no .png and no + // .json, so a reader panning by `neighbors` must not be sent to one. + // tiles.json still lists the empty ones, with their reason. + let find = |z: u32, row: i64, col: i64| -> Option { + if row < 0 || col < 0 { + return None; + } + tiles + .iter() + .find(|t| t.z == z && i64::from(t.row) == row && i64::from(t.col) == col && !t.empty) + .map(|t| t.id.clone()) + }; + let (r, c) = (i64::from(tile.row), i64::from(tile.col)); + let neighbors = json!({ + "n": find(tile.z, r - 1, c), + "s": find(tile.z, r + 1, c), + "w": find(tile.z, r, c - 1), + "e": find(tile.z, r, c + 1), + }); + let centre = Point2D { + x: (tile.world.min_x + tile.world.max_x) / 2.0, + y: (tile.world.min_y + tile.world.max_y) / 2.0, + }; + let parent = tiles + .iter() + .find(|t| { + t.z + 1 == tile.z + && !t.empty + && t.world.min_x <= centre.x + && centre.x <= t.world.max_x + && t.world.min_y <= centre.y + && centre.y <= t.world.max_y + }) + .map(|t| t.id.clone()); + let children: Vec = tiles + .iter() + .filter(|t| t.z == tile.z + 1 && t.world.intersects(&tile.world) && !t.empty) + .map(|t| t.id.clone()) + .collect(); + fn on_tile<'a>(records: &'a [Record], world: &Rect) -> Vec<&'a Record> { + records + .iter() + .filter(|rec| rec.bbox.intersects(world)) + .collect() + } + let truncate = |s: &str| -> String { + if s.chars().count() > 24 { + let cut: String = s.chars().take(24).collect(); + format!("{cut}...") + } else { + s.to_string() + } + }; + let (on_texts, on_dims, on_blocks, on_regions, on_geometry) = ( + on_tile(records.texts, &tile.world), + on_tile(records.dims, &tile.world), + on_tile(records.blocks, &tile.world), + on_tile(records.regions, &tile.world), + on_tile(records.geometry, &tile.world), + ); + let rows = |on: &[&Record], third: &dyn Fn(&Record) -> Vec| -> Vec { + on.iter() + .take(MAX_SIDECAR_ROWS) + .map(|rec| { + let mut row = vec![json!(rec.id), json!(img.px_box(&rec.bbox))]; + row.extend(third(rec)); + Value::Array(row) + }) + .collect() + }; + let field = |rec: &Record, key: &str| rec.value.get(key).cloned().unwrap_or(Value::Null); + let mut text_rows = rows(&on_texts, &|rec| { + vec![json!(truncate( + rec.value.get("text").and_then(Value::as_str).unwrap_or("") + ))] + }); + let mut dim_rows = rows(&on_dims, &|rec| { + vec![ + json!(truncate( + rec.value + .get("display") + .and_then(Value::as_str) + .unwrap_or("") + )), + field(rec, "measurement"), + ] + }); + let mut block_rows = rows(&on_blocks, &|rec| vec![field(rec, "block")]); + let mut region_rows = rows(&on_regions, &|rec| vec![field(rec, "area")]); + // The geometry -- the bulk of every tile. It is cut first when the file + // has to shrink (a text or a dimension is what a reader came for), so + // `counts`, which is never truncated, and `geometry_by_kind` are what + // say how much is really there. + let mut geometry_rows = rows(&on_geometry, &|rec| vec![field(rec, "type")]); + let mut geometry_by_kind: BTreeMap = BTreeMap::new(); + for rec in &on_geometry { + if let Some(Value::String(t)) = rec.value.get("type") { + *geometry_by_kind.entry(t.clone()).or_default() += 1; + } + } + let counts = json!({ + "texts": on_texts.len(), + "dims": on_dims.len(), + "blocks": on_blocks.len(), + "regions": on_regions.len(), + "geometry": on_geometry.len(), + }); + // Every record on the tile, geometry included, computed before the + // shrink loop so the layer set stays complete even when rows are cut. + let mut layer_set: BTreeSet = BTreeSet::new(); + for rec in on_texts + .iter() + .chain(on_dims.iter()) + .chain(on_blocks.iter()) + .chain(on_regions.iter()) + .chain(on_geometry.iter()) + { + if let Some(Value::String(l)) = rec.value.get("layer") { + layer_set.insert(l.clone()); + } + } + let layers_total = layer_set.len(); + let mut layers: Vec = layer_set.into_iter().collect(); + let build = |text_rows: &[Value], + dim_rows: &[Value], + block_rows: &[Value], + region_rows: &[Value], + geometry_rows: &[Value], + layers: &[String], + truncated: bool| { + let mut value = json!({ + "$schema": SCHEMA, + "id": img.id, + "png": img.png, + "z": tile.z, + "row": tile.row, + "col": tile.col, + "px": img.px, + "canvas_origin_px": [tile.origin_px.0, tile.origin_px.1], + "world": rounder.rect(&img.world), + "ppu": img.ppu, + "world_to_px": img.world_to_px, + "px_to_world": img.px_to_world, + "overlap_px": profile.overlap, + "neighbors": neighbors, + "parent": parent, + "children": children, + "empty": tile.empty, + "layers_present": layers, + "layers_truncated": layers.len() < layers_total, + // What each positional row holds, beside the rows themselves. + "columns": { + "texts": ["id", "px_box", "text"], + "dims": ["id", "px_box", "display", "measurement"], + "blocks": ["id", "px_box", "block"], + "regions": ["id", "px_box", "area"], + "geometry": ["id", "px_box", "type"], + }, + "counts": counts, + "geometry_by_kind": geometry_by_kind, + "records": { "texts": text_rows, "dims": dim_rows, "blocks": block_rows, "regions": region_rows, "geometry": geometry_rows }, + "records_truncated": truncated, + }); + if layers.len() < layers_total { + value["layers_total"] = json!(layers_total); + } + value + }; + let mut truncated = [ + (text_rows.len(), on_texts.len()), + (dim_rows.len(), on_dims.len()), + (block_rows.len(), on_blocks.len()), + (region_rows.len(), on_regions.len()), + (geometry_rows.len(), on_geometry.len()), + ] + .iter() + .any(|(rows, all)| rows < all); + let mut value = build( + &text_rows, + &dim_rows, + &block_rows, + ®ion_rows, + &geometry_rows, + &layers, + truncated, + ); + // Geometry rows are cut first (halved), then the other rows (by a + // quarter), then the layer list: each list has its own flag, so the + // file always says which one the reader is missing. + while serde_json::to_string(&value).map_or(0, |s| s.len()) > SIDECAR_LIMIT { + let rows_left = [ + text_rows.len(), + dim_rows.len(), + block_rows.len(), + region_rows.len(), + ] + .into_iter() + .max() + .unwrap_or(0); + if !geometry_rows.is_empty() { + truncated = true; + let keep = geometry_rows.len() / 2; + geometry_rows.truncate(keep); + } else if rows_left > 0 { + truncated = true; + for rows in [ + &mut text_rows, + &mut dim_rows, + &mut block_rows, + &mut region_rows, + ] { + let keep = rows.len() * 3 / 4; + rows.truncate(keep); + } + } else if !layers.is_empty() { + let keep = layers.len() * 3 / 4; + layers.truncate(keep); + } else { + break; + } + value = build( + &text_rows, + &dim_rows, + &block_rows, + ®ion_rows, + &geometry_rows, + &layers, + truncated, + ); + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grid_and_tile_plan_match_the_design() { + let profile = Profile::CLAUDE; + assert_eq!(grid(1000, 500, &profile), (1, 1)); + // 2688 px: (2688 - 1092) / 868 = 1.84 -> 2 + 1 = 3 columns. + assert_eq!(grid(2688, 1792, &profile), (3, 2)); + let rect0 = Rect::new(0.0, 0.0, 2688.0, 1792.0); + let extents = [Rect::new(2600.0, 100.0, 2650.0, 150.0)]; + let tiles = plan_tiles("f0", 1, 2688, 1792, &rect0, 1.0, &profile, &extents); + assert_eq!(tiles.len(), 6); + assert!(tiles.iter().all(|t| t.width == 1092 && t.height == 1092)); + // The last column is shifted inward to end at the canvas edge. + let last = tiles.iter().find(|t| t.row == 0 && t.col == 2).unwrap(); + assert_eq!(last.origin_px, (2688 - 1092, 0)); + assert_eq!(last.id, "f0/z1/r00_c02"); + // Only the tiles touching the line at the top right are non-empty: + // rows are y-down, so row 0 holds y in [700, 1792] and the line at + // y 100..150 lies in row 1. + let non_empty: Vec<&str> = tiles + .iter() + .filter(|t| !t.empty) + .map(|t| t.id.as_str()) + .collect(); + assert_eq!(non_empty, ["f0/z1/r01_c02"]); + } + + #[test] + fn depth_reaches_the_target_text_height() { + let options = ExportOptions::default(); + // 2.5-unit text at 0.5 px/unit is 1.25 px: 14 / 1.25 = 11.2 -> 2^4. + assert_eq!(depth_for(&[(2.5, 10)], 0.5, &options), 4); + // Already legible: still one level. + assert_eq!(depth_for(&[(50.0, 10)], 1.0, &options), 1); + // No text: one level; max_levels caps. + assert_eq!(depth_for(&[], 1.0, &options), 1); + let capped = ExportOptions { + max_levels: 2, + ..Default::default() + }; + assert_eq!(depth_for(&[(0.01, 1)], 0.1, &capped), 2); + } +} diff --git a/crates/uncad-export/tests/acceptance.rs b/crates/uncad-export/tests/acceptance.rs new file mode 100644 index 0000000..bab651c --- /dev/null +++ b/crates/uncad-export/tests/acceptance.rs @@ -0,0 +1,179 @@ +//! Acceptance questions: the package of `example_2000.dwg` must let an +//! agent answer these without looking at a pixel, following only the +//! manifest's guidance -- strings.json for the lookup, the record for the +//! value, the sidecar for the place. One export serves every question, so +//! this file is a single test. + +mod common; + +use std::path::Path; + +use common::*; +use serde_json::Value; +use uncad_export::ExportOptions; + +/// The records of a kind, following the manifest's shard index the way an +/// agent would. +fn records_of(dir: &Path, manifest: &Value, kind: &str) -> Vec { + let mut out = Vec::new(); + for shard in manifest["shard_index"].as_array().unwrap() { + if shard["kind"] == kind { + let file = read_json(&dir.join(shard["file"].as_str().unwrap())); + out.extend(file["records"].as_array().unwrap().clone()); + } + } + out +} + +fn find<'a>(records: &'a [Value], id: &str) -> &'a Value { + records + .iter() + .find(|r| r["id"] == id) + .unwrap_or_else(|| panic!("record {id}")) +} + +#[test] +fn the_five_questions_are_answerable_from_the_package_alone() { + let dir = TempDir::new("acceptance"); + export_path( + &corpus("example_2000.dwg"), + &dir.0, + &ExportOptions::default(), + ) + .expect("exports"); + let manifest = read_json(&dir.0.join("manifest.json")); + assert!(manifest["guidance"] + .as_str() + .unwrap() + .starts_with("Read manifest.json first.")); + assert_eq!(manifest["units"]["name"], "mm"); + + // Q1: "What does the aligned dimension read, and is the drawing + // consistent?" -> dimensions.json: the cached label, the stored value, + // the value recomputed from the definition points, their difference. + let dims = records_of(&dir.0, &manifest, "dimension"); + let aligned = dims + .iter() + .find(|d| d["kind"] == "ALIGNED") + .expect("an ALIGNED dimension"); + assert_eq!(aligned["display"], "1504,68"); + assert_eq!(aligned["display_source"], "cached_block"); + assert_eq!(aligned["unit"], "mm"); + assert!(aligned["delta"].as_f64().unwrap().abs() < 0.001); + assert_eq!(aligned["measurement_source"], "act_measurement"); + assert_eq!(dims.len(), 10); + assert!(dims + .iter() + .all(|d| !d["display"].as_str().unwrap().is_empty())); + assert!(dims.iter().all(|d| d["measurement"].is_number())); + + // Q2: "How many CIRKLO_PUNKTOJ blocks are placed, and where?" -> the + // block table in drawing.json, and the INSERT instances. + let drawing = read_json(&dir.0.join("drawing.json")); + let definition = drawing["blocks"] + .as_array() + .unwrap() + .iter() + .find(|b| b["name"] == "CIRKLO_PUNKTOJ") + .expect("the block definition"); + assert_eq!(definition["instances"], 8); + let instances = records_of(&dir.0, &manifest, "blocks"); + let placed: Vec<&Value> = instances + .iter() + .filter(|i| i["block"] == "CIRKLO_PUNKTOJ") + .collect(); + assert_eq!(placed.len(), 8); + assert!(placed + .iter() + .all(|i| i["at"].as_array().unwrap().len() == 2)); + assert!(placed + .iter() + .all(|i| !i["tiles"].as_array().unwrap().is_empty())); + let bloko = instances + .iter() + .find(|i| i["block"] == "bloko") + .expect("the bloko instance inside the crop"); + assert_eq!(bloko["attribs"]["ETIKEDO"], "valoro de la teksto en bloko"); + + // Q3: "Where is the text 'teksto simpla'?" -> strings.json gives the + // id, texts.json the record, its tiles list an image, and that image's + // sidecar places it in pixels inside the tile. + let strings = read_json(&dir.0.join("strings.json")); + let ids = strings["strings"]["teksto simpla"] + .as_array() + .expect("indexed"); + let id = ids[0].as_str().unwrap(); + let texts = records_of(&dir.0, &manifest, "text"); + let text = find(&texts, id); + assert_eq!(text["kind"], "TEXT"); + assert_eq!(text["height"], 100.0); + let tile_id = text["tiles"][0].as_str().expect("on a tile"); + let tiles = read_json(&dir.0.join("tiles.json")); + let tile = tiles["tiles"] + .as_array() + .unwrap() + .iter() + .find(|t| t["id"] == tile_id) + .expect("the tile is listed"); + let sidecar = read_json(&dir.0.join(tile["sidecar"].as_str().unwrap())); + let row = sidecar["records"]["texts"] + .as_array() + .unwrap() + .iter() + .find(|r| r[0] == id) + .expect("the sidecar lists the text"); + let px = row[1].as_array().unwrap(); + let (w, h) = ( + sidecar["px"][0].as_i64().unwrap(), + sidecar["px"][1].as_i64().unwrap(), + ); + assert!(px[2].as_i64().unwrap() > 0 && px[0].as_i64().unwrap() < w); + assert!(px[3].as_i64().unwrap() > 0 && px[1].as_i64().unwrap() < h); + assert_eq!(row[2], "teksto simpla"); + assert_eq!(text["px"][tile_id], row[1]); + + // Q4: "What is the largest closed area, and what is written inside it?" + // -> regions.json, agreeing with geometry.json for the same entity. + let regions = records_of(&dir.0, &manifest, "region"); + let largest = regions + .iter() + .max_by(|a, b| { + a["area"] + .as_f64() + .unwrap() + .total_cmp(&b["area"].as_f64().unwrap()) + }) + .expect("a region"); + assert_eq!(largest["area_unit"], "mm2"); + assert!(largest["area_si"].as_f64().unwrap() > 0.0, "square metres"); + let geometry = records_of(&dir.0, &manifest, "geometry"); + let same = find(&geometry, largest["id"].as_str().unwrap()); + assert_eq!(same["area"], largest["area"]); + assert_eq!(same["perimeter"], largest["perimeter"]); + assert!(largest["labels"].is_array()); + assert_eq!(largest["confidence"], "exact"); + + // Q5: "What is not in the picture?" -> report.json and the manifest + // agree on the entities the crop left out and why. + let report = read_json(&dir.0.join("report.json")); + let excluded = report["excluded"].as_array().unwrap(); + assert_eq!( + excluded.len(), + manifest["counts"]["excluded"].as_u64().unwrap() as usize + ); + let insert = excluded + .iter() + .find(|e| e["type_name"] == "INSERT") + .expect("the 3256x INSERT"); + assert_eq!(insert["reason"], "scale_outlier"); + assert_eq!(insert["handle"], "756"); + assert_eq!(insert["id"], 0x756); + assert!(geometry.iter().all(|g| g["handle"] != "756")); + assert!(instances.iter().all(|i| i["handle"] != "756")); + assert_eq!(report["hidden"]["by_reason"]["layer_frozen"], 1); + assert_eq!( + report["hidden"]["count"].as_u64().unwrap(), + manifest["counts"]["hidden"].as_u64().unwrap() + ); + assert_eq!(report["hidden"]["top_level"], 1); +} diff --git a/crates/uncad-export/tests/tiles.rs b/crates/uncad-export/tests/tiles.rs new file mode 100644 index 0000000..b6c0078 --- /dev/null +++ b/crates/uncad-export/tests/tiles.rs @@ -0,0 +1,1193 @@ +//! Frames and the tile pyramid: the levels a frame's text asks for, tiles +//! that overlap, are culled to what is on them and hashed, sidecars whose +//! affines round-trip and whose every pointer leads to a file, and the +//! pixels of a tile showing what its records say is there. + +mod common; + +use std::collections::{BTreeMap, BTreeSet}; + +use common::*; +use serde_json::Value; +use uncad_export::{ExportOptions, Profile}; +use uncad_model::model::Entity; + +fn example_2000() -> String { + corpus("example_2000.dwg") +} + +fn written_tiles(dir: &std::path::Path) -> BTreeSet { + read_json(&dir.join("tiles.json"))["tiles"] + .as_array() + .unwrap() + .iter() + .filter(|t| t["png"].is_string()) + .map(|t| t["id"].as_str().unwrap().to_string()) + .collect() +} + +#[test] +fn tiles_cover_the_levels_and_their_sidecars_round_trip() { + let tmp = TempDir::new("tiles"); + let report = export_path(&example_2000(), &tmp.0, &ExportOptions::default()).expect("exports"); + assert_eq!(report.frames.len(), 1, "one connected drawing, one frame"); + let frame = &report.frames[0]; + assert_eq!(frame.id, "f0"); + assert_eq!( + frame.overview.id, "ov", + "a single frame reuses the overview" + ); + assert!( + !frame.levels.is_empty(), + "the drawing has text, so it has zoom levels" + ); + + let tiles = read_json(&tmp.0.join("tiles.json")); + let entries = tiles["tiles"].as_array().unwrap(); + let mut written = 0; + for level in &frame.levels { + let step = level.tile_px - level.overlap_px; + let expect = |extent: u32| -> u32 { + if extent <= level.tile_px { + 1 + } else { + (extent - level.tile_px).div_ceil(step) + 1 + } + }; + assert_eq!(level.cols, expect(level.canvas_px[0]), "z{}", level.z); + assert_eq!(level.rows, expect(level.canvas_px[1]), "z{}", level.z); + let on_level: Vec<&Value> = entries.iter().filter(|t| t["z"] == level.z).collect(); + assert_eq!(on_level.len() as u32, level.cols * level.rows); + written += level.tiles_written; + } + assert_eq!(written, report.counts.tiles); + assert!(written >= 1); + + for entry in entries { + let Some(png) = entry["png"].as_str() else { + assert_eq!(entry["empty"], true); + continue; + }; + assert!(tmp.0.join(png).exists(), "{png}"); + let sidecar = read_json(&tmp.0.join(entry["sidecar"].as_str().unwrap())); + assert_eq!(sidecar["id"], entry["id"]); + let ppu = sidecar["ppu"].as_f64().unwrap(); + // Row-major [a, b, c, d, e, f]: px = a x + b y + c, py = d x + e y + f. + let p2w = sidecar["px_to_world"].as_array().unwrap(); + let w2p = sidecar["world_to_px"].as_array().unwrap(); + let (a, c, e, f) = ( + w2p[0].as_f64().unwrap(), + w2p[2].as_f64().unwrap(), + w2p[4].as_f64().unwrap(), + w2p[5].as_f64().unwrap(), + ); + assert!((a - ppu).abs() < 1e-12 && (e + ppu).abs() < 1e-12); + // px -> world -> px on the tile's far corner. + let px = sidecar["px"].as_array().unwrap(); + let (pw, ph) = (px[0].as_f64().unwrap(), px[1].as_f64().unwrap()); + let wx = p2w[0].as_f64().unwrap() * pw + p2w[2].as_f64().unwrap(); + let wy = p2w[4].as_f64().unwrap() * ph + p2w[5].as_f64().unwrap(); + let bx = a * wx + c; + let by = e * wy + f; + assert!( + (bx - pw).abs() < 1e-6 && (by - ph).abs() < 1e-6, + "{bx} {by}" + ); + assert_eq!(sidecar["overlap_px"], 224); + assert!(sidecar["records"]["texts"].is_array()); + assert!( + std::fs::metadata(tmp.0.join(entry["sidecar"].as_str().unwrap())) + .unwrap() + .len() + <= 32 * 1024 + ); + // The PNG is exactly the size the sidecar says, in 8-bit RGB. + let bytes = std::fs::read(tmp.0.join(png)).unwrap(); + let decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); + let info = decoder.read_info().unwrap(); + let header = info.info(); + assert_eq!( + (f64::from(header.width), f64::from(header.height)), + (pw, ph) + ); + assert_eq!(header.color_type, png::ColorType::Rgb); + } +} + +#[test] +fn every_record_points_at_tiles_that_exist() { + let tmp = TempDir::new("records_tiles"); + export_path(&example_2000(), &tmp.0, &ExportOptions::default()).expect("exports"); + let written = written_tiles(&tmp.0); + for kind in ["texts", "dimensions", "geometry", "regions", "blocks"] { + for record in records(&tmp.0, kind) { + for tile in record["tiles"].as_array().unwrap() { + assert!(written.contains(tile.as_str().unwrap()), "{record}"); + assert!(record["px"][tile.as_str().unwrap()].is_array(), "{record}"); + } + assert!(record["px"]["ov"].is_array(), "{record}"); + } + } +} + +/// Two 100 x 100 squares of 25 lines each, 1000 units apart, the second +/// with a label: two frames. +fn two_islands() -> uncad::CadDatabase { + let mut entities = Vec::new(); + let mut id = 0x100u64; + for dx in [0.0, 1000.0] { + for i in 0..25 { + let y = f64::from(i) * 4.0; + entities.push(line(id, dx, y, dx + 100.0, y)); + id += 1; + } + } + entities.push(text(0x7000, 1010.0, 110.0, 5.0, "DETAIL A")); + model_space(entities) +} + +#[test] +fn a_detached_group_becomes_its_own_frame() { + let db = two_islands(); + let tmp = TempDir::new("frames"); + let report = export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + assert_eq!(report.frames.len(), 2, "{:?}", report.frames); + let (f0, f1) = (&report.frames[0], &report.frames[1]); + assert_eq!((f0.id.as_str(), f0.kind.as_str()), ("f0", "primary")); + assert_eq!((f1.id.as_str(), f1.kind.as_str()), ("f1", "detached")); + // The primary frame is the group with the most entities: the labelled + // island (25 lines and the text). + assert_eq!((f0.entities, f1.entities), (26, 25)); + assert_eq!((f0.texts, f1.texts), (1, 0)); + assert_eq!(f0.overview.png, "frames/f0/overview.png"); + assert!(tmp.0.join("frames/f1/overview.png").exists()); + assert!(tmp.0.join("overview.png").exists()); + assert!(f0.content.min_x >= 999.0 && f1.content.max_x <= 101.0); + // Each frame overview shows its own island. + for f in [f0, f1] { + let png = std::fs::read(tmp.0.join(&f.overview.png)).unwrap(); + assert!(dark_pixels(&png) > 500, "{} shows its lines", f.id); + } + let tiles = read_json(&tmp.0.join("tiles.json")); + let ids: Vec<&str> = tiles["tiles"] + .as_array() + .unwrap() + .iter() + .map(|t| t["id"].as_str().unwrap()) + .collect(); + assert!( + ids.iter().any(|i| i.starts_with("f0/z1/")) && ids.iter().any(|i| i.starts_with("f1/z1/")), + "{ids:?}" + ); + // The label's record shows where it is on the whole overview, on its + // frame's overview and on its tiles -- not on the other frame's images. + let texts = records(&tmp.0, "texts"); + let label = by_handle(&texts, "7000"); + let px = label["px"].as_object().unwrap(); + assert!(px.contains_key("ov") && px.contains_key("f0/ov"), "{px:?}"); + assert!(!px.contains_key("f1/ov")); + assert!(label["tiles"] + .as_array() + .unwrap() + .iter() + .all(|t| t.as_str().unwrap().starts_with("f0/"))); + let manifest = read_json(&tmp.0.join("manifest.json")); + assert_eq!(manifest["frames"].as_array().unwrap().len(), 2); + assert_eq!(manifest["capabilities"]["frames"], 2); + // A gap of 100 % of the diagonal merges everything into one frame. + let merged = export_db( + &db, + &TempDir::new("frames_merged").0, + &ExportOptions { + max_levels: 0, + frame_gap: 1.0, + ..Default::default() + }, + ) + .expect("exports"); + assert_eq!(merged.frames.len(), 1); + assert_eq!(merged.frames[0].overview.id, "ov"); +} + +#[test] +fn padding_sets_the_window_of_the_overview_and_every_frame() { + let db = two_islands(); + let run = |name: &str, padding: Option| { + let tmp = TempDir::new(name); + let report = export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + frame_gap: 0.05, + sheets: false, + padding, + ..Default::default() + }, + ) + .expect("exports"); + (tmp, report) + }; + let (_auto, automatic) = run("pad_auto", None); + let (_tight, tight) = run("pad_0", Some(0.0)); + let (_roomy, roomy) = run("pad_200", Some(200.0)); + assert!(automatic.frames.len() > 1, "two islands, two frames"); + let content = tight.crop.content.expect("the drawing has content"); + assert!( + tight.overview.world.width() < content.width() * 1.05, + "no padding: {:?} vs {:?}", + tight.overview.world, + content + ); + assert!( + roomy.overview.world.width() > tight.overview.world.width() + 390.0, + "200 units a side: {:?} vs {:?}", + roomy.overview.world, + tight.overview.world + ); + assert!( + automatic.overview.world.width() > tight.overview.world.width() + && automatic.overview.world.width() < roomy.overview.world.width() + ); + assert_eq!(tight.crop.padding_units, 0.0); + assert_eq!(roomy.crop.padding_units, 200.0); + for (a, b) in tight.frames.iter().zip(&roomy.frames) { + assert_eq!(a.content, b.content, "the same group either way"); + assert!( + b.overview.world.width() > a.overview.world.width() + 390.0, + "frame {}: {:?} vs {:?}", + a.id, + b.overview.world, + a.overview.world + ); + } +} + +/// An 11 x 11 grid of 1000-unit lines with a 3-unit label in every cell +/// (small enough to need two zoom levels), shifted by `offset` on both +/// axes. +fn labelled_grid(offset: f64) -> uncad::CadDatabase { + let mut entities = Vec::new(); + let mut id = 0x100u64; + for i in 0..=10 { + let at = offset + f64::from(i) * 100.0; + entities.push(line(id, offset, at, offset + 1000.0, at)); + entities.push(line(id + 1, at, offset, at, offset + 1000.0)); + id += 2; + } + for row in 0..10 { + for col in 0..10 { + entities.push(text( + id, + offset + f64::from(col) * 100.0 + 20.0, + offset + f64::from(row) * 100.0 + 40.0, + 3.0, + &format!("R{row}{col}"), + )); + id += 1; + } + } + model_space(entities) +} + +fn small() -> Profile { + Profile { + name: "claude-small", + overview_edge: 784, + overview_patches: 784, + ..Profile::CLAUDE + } +} + +#[test] +fn a_drawing_far_from_the_origin_rasterizes_like_one_at_the_origin() { + // usvg and tiny-skia keep path points in f32. The renderer writes its + // coordinates relative to the drawing's own origin whenever that + // exceeds 32768, and every tile is a window of that same document, so + // the same grid gives the same picture wherever it sits: the dark + // pixel counts of each tile level agree within a few percent. + let mut levels: Vec> = Vec::new(); + for (name, offset) in [("0", 0.0), ("1e7", 1.0e7), ("2.5e8", 2.5e8)] { + let db = labelled_grid(offset); + let tmp = TempDir::new(&format!("far_{name}")); + let report = export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 2, + profile: small(), + ..Default::default() + }, + ) + .expect("exports"); + assert_eq!(report.frames[0].levels.len(), 2, "{name}: two levels"); + let tiles = read_json(&tmp.0.join("tiles.json")); + let mut per_level = vec![0usize; 3]; + for tile in tiles["tiles"].as_array().unwrap() { + let Some(path) = tile["png"].as_str() else { + continue; + }; + let z = tile["z"].as_u64().unwrap() as usize; + per_level[z] += dark_pixels(&std::fs::read(tmp.0.join(path)).unwrap()); + } + levels.push(per_level); + assert!(report.warnings.is_empty(), "{name}: {:?}", report.warnings); + } + let within = |a: usize, b: usize| (a as f64 - b as f64).abs() / a as f64 <= 0.03; + assert!(levels[0][1] > 20_000, "the grid and its labels: {levels:?}"); + for i in 1..3 { + for z in 1..=2 { + assert!( + within(levels[0][z], levels[i][z]), + "z{z}: {:?} vs {:?}", + levels[0], + levels[i] + ); + } + } +} + +#[test] +fn a_sub_millimetre_drawing_keeps_its_tile_rectangles_apart() { + // A drawing 0.004 x 0.003 units across. The world rectangles are + // rounded to at least the decimals the deepest scale needs, so no two + // tiles print the same rectangle and the affine beside each maps the + // rounded corners onto the image's corners. + let (w, h) = (0.004, 0.003); + let mut entities = vec![ + line(0x100, 0.0, 0.0, w, 0.0), + line(0x101, w, 0.0, w, h), + line(0x102, w, h, 0.0, h), + line(0x103, 0.0, h, 0.0, 0.0), + ]; + for i in 1..12 { + let y = h * f64::from(i) / 12.0; + entities.push(line(0x110 + i as u64, 0.0, y, w, y)); + } + entities.push(text(0x200, w * 0.1, h * 0.5, 1e-5, "0.004")); + let db = model_space(entities); + let tmp = TempDir::new("tiny"); + let report = export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + sheets: false, + ..Default::default() + }, + ) + .expect("exports"); + assert!( + report.overview.world.width() < 0.01, + "{:?}", + report.overview.world + ); + let deepest = report.frames[0].z_max; + assert_eq!(deepest, 1); + let tiles = read_json(&tmp.0.join("tiles.json")); + let mut seen: BTreeSet = BTreeSet::new(); + let mut checked = 0; + for entry in tiles["tiles"].as_array().unwrap() { + let Some(sidecar_path) = entry["sidecar"].as_str() else { + continue; + }; + assert!( + seen.insert(entry["world"].to_string()), + "two tiles share the rectangle {}: {}", + entry["world"], + entry["id"] + ); + let sidecar = read_json(&tmp.0.join(sidecar_path)); + assert_eq!(sidecar["world"], entry["world"], "{}", entry["id"]); + let world: Vec = sidecar["world"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + let w2p: Vec = sidecar["world_to_px"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + let px = sidecar["px"].as_array().unwrap(); + let (pw, ph) = (px[0].as_f64().unwrap(), px[1].as_f64().unwrap()); + for (got, want) in [ + (w2p[0] * world[0] + w2p[2], 0.0), + (w2p[0] * world[2] + w2p[2], pw), + (w2p[4] * world[3] + w2p[5], 0.0), + (w2p[4] * world[1] + w2p[5], ph), + ] { + assert!( + (got - want).abs() < 0.01, + "{}: {got} vs {want}", + entry["id"] + ); + } + checked += 1; + } + assert!(checked > 1, "the deepest level holds more than one tile"); +} + +#[test] +fn a_dense_drawing_keeps_every_sidecar_under_the_32_kb_cap() { + // 6000 small closed squares: 6000 geometry and 6000 region records, + // enough on one tile to push its sidecar past its 32 KB budget, which + // the compact form on disk must still meet. + let count = 6000usize; + let side = (count as f64).sqrt().ceil() as usize; + let entities: Vec = (0..count) + .map(|i| { + let (x, y) = ((i % side) as f64 * 10.0, (i / side) as f64 * 10.0); + lwpolyline( + 0x1000 + i as u64, + &[(x, y), (x + 6.0, y), (x + 6.0, y + 6.0), (x, y + 6.0)], + &[], + true, + ) + }) + .collect(); + let tmp = TempDir::new("dense"); + let report = export_db( + &model_space(entities), + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + assert_eq!(report.counts.regions, 6000); + let tiles = read_json(&tmp.0.join("tiles.json")); + let (mut checked, mut truncated) = (0, 0); + for entry in tiles["tiles"].as_array().unwrap() { + let Some(path) = entry["sidecar"].as_str() else { + continue; + }; + let file = tmp.0.join(path); + let bytes = std::fs::metadata(&file).unwrap().len(); + assert!(bytes <= 32 * 1024, "{path} is {bytes} bytes"); + let sidecar = read_json(&file); + assert!(sidecar["records"]["regions"].is_array()); + if sidecar["records_truncated"] == true { + truncated += 1; + } + checked += 1; + } + assert!(checked >= 2, "{checked} sidecars"); + assert!(truncated >= 1, "6000 regions must overflow a sidecar"); +} + +#[test] +fn a_tile_on_hundreds_of_layers_keeps_its_sidecar_under_the_cap() { + // 900 layers with 45-character names, one line each, all over the same + // patch of the drawing: the layer list alone cannot fit, so it is cut + // after the rows, and said so. + let entities: Vec = (0..900usize) + .map(|i| { + let (x, y) = (20.0 + (i % 17) as f64, 20.0 + (i % 13) as f64); + let mut e = line(0x1000 + i as u64, x, y, x + 8.0, y + 6.0); + let layer = format!("A-WALL-FULL-DIMS-ANNO-TEXT-IDENTITY-PATT-{i:04}"); + *e.common_mut() = on_layer(e.common().clone(), &layer); + e + }) + .collect(); + let tmp = TempDir::new("layers_everywhere"); + export_db( + &model_space(entities), + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let tiles = read_json(&tmp.0.join("tiles.json")); + let (mut checked, mut trimmed) = (0, 0); + for entry in tiles["tiles"].as_array().unwrap() { + let Some(path) = entry["sidecar"].as_str() else { + continue; + }; + let file = tmp.0.join(path); + let bytes = std::fs::metadata(&file).unwrap().len(); + assert!(bytes <= 32 * 1024, "{path} is {bytes} bytes"); + let sidecar = read_json(&file); + let present = sidecar["layers_present"].as_array().unwrap().len(); + if sidecar["layers_truncated"] == true { + let total = sidecar["layers_total"].as_u64().unwrap() as usize; + assert!( + total > present && total <= 900, + "{path}: {present} of {total}" + ); + assert_eq!(sidecar["records_truncated"], true, "{path}"); + trimmed += 1; + } else { + assert!(sidecar["layers_total"].is_null()); + } + assert!( + sidecar["records"]["geometry"].as_array().unwrap().len() + <= sidecar["counts"]["geometry"].as_u64().unwrap() as usize + ); + checked += 1; + } + assert!(checked >= 2, "{checked} sidecars"); + assert!(trimmed >= 1, "900 layers on one tile must overflow it"); +} + +#[test] +fn a_sidecar_lists_the_layers_and_the_geometry_of_everything_on_its_tile() { + let tmp = TempDir::new("layers"); + export_path( + &example_2000(), + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + // What each tile shows, derived from the records' own `tiles` lists. + let mut expected: BTreeMap> = BTreeMap::new(); + let mut geometry_on: BTreeMap = BTreeMap::new(); + let mut geometry_layers: BTreeSet = BTreeSet::new(); + for kind in ["texts", "dimensions", "geometry", "regions", "blocks"] { + for record in records(&tmp.0, kind) { + let layer = record["layer"].as_str().unwrap().to_string(); + if kind == "geometry" { + geometry_layers.insert(layer.clone()); + } + for tile in record["tiles"].as_array().unwrap() { + let tile = tile.as_str().unwrap().to_string(); + if kind == "geometry" { + *geometry_on.entry(tile.clone()).or_default() += 1; + } + expected.entry(tile).or_default().insert(layer.clone()); + } + } + } + assert!(geometry_layers.len() > 1, "{geometry_layers:?}"); + assert!(geometry_on.values().any(|n| *n > 3)); + let tiles = read_json(&tmp.0.join("tiles.json")); + let mut seen: BTreeSet = BTreeSet::new(); + let mut checked = 0; + for entry in tiles["tiles"].as_array().unwrap() { + let Some(path) = entry["sidecar"].as_str() else { + continue; + }; + let id = entry["id"].as_str().unwrap(); + let sidecar = read_json(&tmp.0.join(path)); + let listed: BTreeSet = sidecar["layers_present"] + .as_array() + .unwrap() + .iter() + .map(|l| l.as_str().unwrap().to_string()) + .collect(); + assert_eq!( + listed, + *expected.get(id).unwrap_or(&BTreeSet::new()), + "{id}" + ); + let want = geometry_on.get(id).copied().unwrap_or(0); + assert_eq!( + sidecar["counts"]["geometry"].as_u64().unwrap() as usize, + want + ); + let by_kind: usize = sidecar["geometry_by_kind"] + .as_object() + .unwrap() + .values() + .map(|v| v.as_u64().unwrap() as usize) + .sum(); + assert_eq!(by_kind, want, "{id}"); + seen.extend(listed); + checked += 1; + } + assert!(checked >= 2, "{checked} sidecars"); + for layer in &geometry_layers { + assert!(seen.contains(layer), "{layer} is on no sidecar"); + } +} + +#[test] +fn the_guidance_quotes_the_profile_in_use() { + let (db, header) = uncad::parse_with_header(example_2000()).expect("parses"); + for profile in [ + Profile::CLAUDE, + Profile::CLAUDE_HIRES, + Profile::OPENAI_PATCH, + ] { + let tmp = TempDir::new(&format!("guidance_{}", profile.name)); + let report = uncad_export::export_package( + &db, + Some(&header), + &tmp.0, + &ExportOptions { + profile, + max_levels: u32::from(profile == Profile::CLAUDE), + ..Default::default() + }, + ) + .expect("exports"); + let manifest = read_json(&tmp.0.join("manifest.json")); + let guidance = manifest["guidance"].as_str().unwrap(); + let sentence = format!("{} px with {} px overlap", profile.tile, profile.overlap); + assert!(guidance.contains(&sentence), "{}: {guidance}", profile.name); + for level in &report.frames[0].levels { + assert_eq!(level.overlap_px, profile.overlap); + assert_eq!(level.tile_px, profile.tile); + } + } +} + +#[test] +fn re_exporting_clears_the_previous_package_but_nothing_else() { + let db = labelled_grid(0.0); + let tmp = TempDir::new("reexport"); + let first = export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 2, + shard_kb: 4, + profile: small(), + ..Default::default() + }, + ) + .expect("exports"); + assert!(first.frames[0].levels.len() == 2, "{:?}", first.frames); + assert!(tmp.0.join("frames/f0/tiles/z2").exists()); + assert!(tmp.0.join("texts.001.json").exists(), "sharded"); + + // Something the export did not write: not ours to remove. + let sentinel = tmp.0.join("notes.txt"); + std::fs::write(&sentinel, b"mine").unwrap(); + let kept_tile = tmp.0.join("frames/f0/tiles/z2/keep.txt"); + std::fs::write(&kept_tile, b"mine too").unwrap(); + + let second = export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + profile: small(), + ..Default::default() + }, + ) + .expect("exports again"); + assert_eq!(second.frames[0].levels.len(), 1); + let mut on_disk: BTreeSet = BTreeSet::new(); + let mut stack = vec![tmp.0.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + on_disk.insert( + path.strip_prefix(&tmp.0) + .unwrap() + .to_string_lossy() + .replace('\\', "/"), + ); + } + } + } + let listed: BTreeSet = second.files.iter().map(|f| f.path.clone()).collect(); + let extra: Vec<&String> = on_disk.difference(&listed).collect(); + assert_eq!( + extra, + [ + &"frames/f0/tiles/z2/keep.txt".to_string(), + &"notes.txt".to_string() + ], + "stale files: {extra:?}" + ); + assert!(listed.iter().all(|p| on_disk.contains(p)), "{listed:?}"); + assert!(!on_disk.iter().any(|p| p.starts_with("texts.0"))); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"mine"); + assert!(tmp.0.join("frames/f0/tiles/z1").exists()); + assert!( + tmp.0.join("frames/f0/tiles/z2").exists(), + "keep.txt is in it" + ); + std::fs::remove_file(&kept_tile).unwrap(); + export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + profile: small(), + ..Default::default() + }, + ) + .expect("exports a third time"); + assert!(!tmp.0.join("frames/f0/tiles/z2").exists(), "now empty"); + + // A directory that is not an uncad package is never touched. + let foreign = TempDir::new("foreign"); + std::fs::create_dir_all(&foreign.0).unwrap(); + std::fs::write(foreign.0.join("manifest.json"), br#"{"schema":"other"}"#).unwrap(); + std::fs::write(foreign.0.join("important.bin"), b"keep").unwrap(); + export_db( + &db, + &foreign.0, + &ExportOptions { + max_levels: 0, + profile: small(), + ..Default::default() + }, + ) + .expect("exports"); + assert_eq!( + std::fs::read(foreign.0.join("important.bin")).unwrap(), + b"keep" + ); +} + +#[test] +fn a_drawing_that_is_one_point_gets_a_window_it_can_be_seen_in() { + // Content with no size at all -- the corpus's Point.dwg, RAY.dwg and + // ConstructionLine.dwg, whose entities record a single base point -- + // gets a ten-unit window, and the picture shows the entity. + for name in ["Point", "RAY", "ConstructionLine"] { + let tmp = TempDir::new(&format!("degenerate_{name}")); + let report = export_path( + &corpus(&format!("2000/{name}.dwg")), + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let world = report.overview.world; + assert!( + (10.0..=12.0).contains(&world.width()) && (10.0..=12.0).contains(&world.height()), + "{name}: {world:?}" + ); + assert!( + report.overview.ppu.is_finite() && (50.0..=200.0).contains(&report.overview.ppu), + "{name}: {} px/unit", + report.overview.ppu + ); + // A RAY and an XLINE cross the whole window; a POINT is drawn as a + // cross sized in pixels, about a dozen dark pixels. + let png = std::fs::read(tmp.0.join("overview.png")).unwrap(); + let floor = if name == "Point" { 8 } else { 100 }; + assert!(dark_pixels(&png) > floor, "{name}: {}", dark_pixels(&png)); + let tiles = read_json(&tmp.0.join("tiles.json")); + let mut checked = 0; + for entry in tiles["tiles"].as_array().unwrap() { + let Some(sidecar_path) = entry["sidecar"].as_str() else { + continue; + }; + let sidecar = read_json(&tmp.0.join(sidecar_path)); + let w: Vec = sidecar["world"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + assert!(w[2] > w[0] && w[3] > w[1], "{name}: {w:?}"); + let a = sidecar["world_to_px"].as_array().unwrap(); + let (sx, cx) = (a[0].as_f64().unwrap(), a[2].as_f64().unwrap()); + let (sy, cy) = (a[4].as_f64().unwrap(), a[5].as_f64().unwrap()); + let (px, py) = (sx * w[0] + cx, sy * w[3] + cy); + assert!(px.abs() < 1.0 && py.abs() < 1.0, "{name}: ({px}, {py})"); + checked += 1; + } + assert!(checked >= 1, "{name}: no tile was written"); + } +} + +#[test] +fn a_tile_keeps_the_half_of_a_long_hangul_text_that_reaches_it() { + // A 3000 x 1000 frame with a centre line and one note of 100 Hangul + // syllables at (40, 510), height 20. Each advances about 0.92 of the + // text height where the renderer's estimate allows 0.8186 (0.6 em over + // the bundled font's 0.733 cap height), so the string runs some 200 + // units past the estimate -- and the tiles are culled by the measured + // box, so the tile past the estimate still draws the text. + let note = "\u{ac00}\u{b098}\u{b2e4}\u{b77c}".repeat(25); + let db = model_space(vec![ + line(0x10, 0.0, 0.0, 3000.0, 0.0), + line(0x11, 3000.0, 0.0, 3000.0, 1000.0), + line(0x12, 3000.0, 1000.0, 0.0, 1000.0), + line(0x13, 0.0, 1000.0, 0.0, 0.0), + line(0x14, 0.0, 500.0, 3000.0, 500.0), + text(0x20, 40.0, 510.0, 20.0, ¬e), + ]); + let tmp = TempDir::new("hangul_tile"); + export_db( + &db, + &tmp.0, + &ExportOptions { + max_levels: 1, + frame_gap: 1.0, + ..Default::default() + }, + ) + .expect("exports"); + let texts = records(&tmp.0, "texts"); + let note = by_handle(&texts, "20"); + assert_eq!(note["bbox_confidence"], "measured"); + let b: Vec = note["bbox"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + let estimate_end = 40.0 + 100.0 * (0.6 / 0.733) * 20.0; + assert!(b[2] > estimate_end + 100.0, "{b:?}"); + let tiles = read_json(&tmp.0.join("tiles.json")); + let listed: BTreeSet<&str> = note["tiles"] + .as_array() + .unwrap() + .iter() + .map(|t| t.as_str().unwrap()) + .collect(); + let mut checked = 0; + for entry in tiles["tiles"].as_array().unwrap() { + let world: Vec = entry["world"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + let beyond_the_estimate = world[0] > estimate_end + 20.0; + let inside_the_text = world[0] < b[2] && world[1] < b[3] && world[3] > b[1]; + if !(beyond_the_estimate && inside_the_text) { + continue; + } + let id = entry["id"].as_str().unwrap(); + assert!(listed.contains(id), "texts.json lists {id}: {listed:?}"); + let png_path = entry["png"] + .as_str() + .unwrap_or_else(|| panic!("{id} empty")); + let png = std::fs::read(tmp.0.join(png_path)).unwrap(); + let area: Vec = note["px"][id] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_i64().unwrap()) + .collect(); + let ink = dark_pixels_in(&png, Some([area[0], area[1], area[2], area[3]])); + assert!(ink > 200, "{id}: {ink} dark pixels in the text band"); + checked += 1; + } + assert!(checked >= 1, "no tile starts past the estimate's end"); +} + +#[test] +fn a_nested_attribute_is_on_the_tile_its_record_names() { + let tmp = TempDir::new("nested_attrib_tile"); + export_path( + &fixture("nested_attrib_r2000.dxf"), + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let texts = records(&tmp.0, "texts"); + let nested = texts.iter().find(|t| t["text"] == "D-101").expect("D-101"); + let tile = nested["tiles"].as_array().unwrap()[0].as_str().unwrap(); + let tiles = read_json(&tmp.0.join("tiles.json")); + let entry = tiles["tiles"] + .as_array() + .unwrap() + .iter() + .find(|t| t["id"] == tile) + .expect("the tile"); + let png = std::fs::read(tmp.0.join(entry["png"].as_str().unwrap())).unwrap(); + let area: Vec = nested["px"][tile] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_i64().unwrap()) + .collect(); + assert!( + dark_pixels_in(&png, Some([area[0], area[1], area[2], area[3]])) > 20, + "{tile} shows no attribute text" + ); +} + +#[test] +fn every_pointer_in_a_sidecar_leads_somewhere_and_every_box_is_on_the_image() { + let tmp = TempDir::new("pointers"); + export_path(&example_2000(), &tmp.0, &ExportOptions::default()).expect("exports"); + let tiles = read_json(&tmp.0.join("tiles.json")); + let written: BTreeSet = tiles["tiles"] + .as_array() + .unwrap() + .iter() + .filter(|t| t["empty"] == false) + .map(|t| t["id"].as_str().unwrap().to_string()) + .collect(); + let empty = tiles["tiles"].as_array().unwrap().len() - written.len(); + assert!(empty > 0, "the test needs a package with culled tiles"); + let mut boxes = 0; + for entry in tiles["tiles"].as_array().unwrap() { + let Some(path) = entry["sidecar"].as_str() else { + continue; + }; + let sidecar = read_json(&tmp.0.join(path)); + let mut pointers: Vec<&Value> = ["n", "s", "e", "w"] + .iter() + .map(|d| &sidecar["neighbors"][*d]) + .collect(); + pointers.push(&sidecar["parent"]); + pointers.extend(sidecar["children"].as_array().unwrap()); + for pointer in pointers { + let Some(id) = pointer.as_str() else { continue }; + assert!(written.contains(id), "{path} points at {id}, never written"); + } + let (w, h) = ( + sidecar["px"][0].as_i64().unwrap(), + sidecar["px"][1].as_i64().unwrap(), + ); + for group in ["texts", "dims", "blocks", "regions", "geometry"] { + for row in sidecar["records"][group].as_array().unwrap() { + let v: Vec = row[1] + .as_array() + .unwrap() + .iter() + .map(|n| n.as_i64().unwrap()) + .collect(); + assert!( + 0 <= v[0] + && v[0] <= v[2] + && v[2] <= w + && 0 <= v[1] + && v[1] <= v[3] + && v[3] <= h, + "{path}: {group} {row} on a {w}x{h} image" + ); + boxes += 1; + } + } + } + assert!(boxes > 20, "{boxes} boxes"); + let mut spanning = 0; + for record in records(&tmp.0, "geometry") { + if record["tiles"].as_array().unwrap().len() < 2 { + continue; + } + spanning += 1; + for (id, value) in record["px"].as_object().unwrap() { + if !written.contains(id.as_str()) { + continue; + } + let v: Vec = value + .as_array() + .unwrap() + .iter() + .map(|n| n.as_i64().unwrap()) + .collect(); + assert!( + v[0] >= 0 && v[1] >= 0 && v[2] <= 1092 && v[3] <= 1092, + "{record} on {id}" + ); + } + } + assert!(spanning > 0, "no record spans two tiles"); +} + +#[test] +fn the_package_explains_its_own_compact_forms() { + let tmp = TempDir::new("legend"); + export_path( + &example_2000(), + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let manifest = read_json(&tmp.0.join("manifest.json")); + let legend = &manifest["legend"]; + for key in [ + "confidence", + "text_records", + "ids", + "measurement_source", + "display_source", + "region_labels", + "px_boxes", + "tile_sidecar", + "shard_lookup", + "legibility", + ] { + assert!(!legend[key].is_null(), "the legend is missing {key}"); + } + let confidence = legend["confidence"].as_object().unwrap(); + for record in records(&tmp.0, "geometry") + .iter() + .chain(records(&tmp.0, "regions").iter()) + .chain(records(&tmp.0, "dimensions").iter()) + { + let value = record["confidence"].as_str().expect("a confidence"); + assert!( + confidence.contains_key(value), + "{value} is not in the legend" + ); + } + let sources = legend["display_source"].as_array().unwrap(); + for record in records(&tmp.0, "dimensions") { + assert!(sources.contains(&record["display_source"]), "{record}"); + } + assert!(manifest["guidance"].as_str().unwrap().contains("legend")); + for record in records(&tmp.0, "blocks") { + assert!(record["confidence"].is_null(), "{record}"); + } + let tiles = read_json(&tmp.0.join("tiles.json")); + let mut checked = 0; + for entry in tiles["tiles"].as_array().unwrap() { + let Some(path) = entry["sidecar"].as_str() else { + continue; + }; + let sidecar = read_json(&tmp.0.join(path)); + let columns = sidecar["columns"].as_object().expect("a columns legend"); + assert_eq!(columns.len(), 5, "{path}"); + for (group, names) in columns { + let arity = names.as_array().unwrap().len(); + assert_eq!(names[0], "id"); + assert_eq!(names[1], "px_box"); + for row in sidecar["records"][group].as_array().unwrap() { + assert_eq!(row.as_array().unwrap().len(), arity, "{path}: {group}"); + checked += 1; + } + } + } + assert!(checked > 20, "{checked} rows"); +} + +#[test] +fn a_group_too_small_to_frame_is_listed_as_dropped() { + // sample_2000.dwg has two groups below `min_frame_entities` and without + // text: a circle and a 100 x 140 rectangle, both in the overview. + let tmp = TempDir::new("small_groups"); + export_path( + &corpus("sample_2000.dwg"), + &tmp.0, + &ExportOptions::default(), + ) + .expect("exports"); + let manifest = read_json(&tmp.0.join("manifest.json")); + let dropped = manifest["frames_dropped"].as_array().unwrap(); + assert_eq!(dropped.len(), 2, "{dropped:?}"); + assert_eq!(manifest["frames_dropped_total"], 2); + assert!(dropped.iter().all(|d| d["reason"] == "below_min_entities")); + assert!(manifest["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w.as_str().unwrap().starts_with("SmallGroups:"))); + // Every record with no tile is inside one of the dropped rectangles. + let mut without_tiles = 0; + for record in records(&tmp.0, "geometry") { + if !record["tiles"].as_array().unwrap().is_empty() { + continue; + } + without_tiles += 1; + let b: Vec = record["bbox"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + assert!( + dropped.iter().any(|d| { + let r: Vec = d["content"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + b[0] >= r[0] - 1e-6 + && b[1] >= r[1] - 1e-6 + && b[2] <= r[2] + 1e-6 + && b[3] <= r[3] + 1e-6 + }), + "{record} is in none of {dropped:?}" + ); + } + assert_eq!(without_tiles, 2); +} + +#[test] +fn legibility_says_whether_the_target_was_met_not_whether_the_budget_held() { + let tmp = TempDir::new("legibility"); + export_path( + &example_2000(), + &tmp.0, + &ExportOptions { + max_levels: 1, + target_text_px: 1000.0, + ..Default::default() + }, + ) + .expect("exports"); + let manifest = read_json(&tmp.0.join("manifest.json")); + let legibility = &manifest["legibility"]; + assert_eq!(legibility["target_px"], 1000.0); + let frame = &legibility["per_frame"][0]; + assert!(frame["reached"].is_null(), "the ambiguous name is gone"); + assert_eq!(frame["pyramid_complete"], true); + assert_eq!(frame["target_met"], false); + let classes = frame["height_classes"].as_array().unwrap(); + assert!(!classes.is_empty()); + assert!(classes + .iter() + .all(|c| c["legible"] == false && c["px_at_zmax"].as_f64().unwrap() < 1000.0)); + assert_eq!( + frame["target_met"].as_bool().unwrap(), + classes.iter().all(|c| c["legible"] == true) + ); +} + +#[test] +fn tiles_json_says_how_big_each_tile_is_and_why_an_empty_one_is_not_there() { + let tmp = TempDir::new("tile_hashes"); + export_path(&example_2000(), &tmp.0, &ExportOptions::default()).expect("exports"); + let tiles = read_json(&tmp.0.join("tiles.json")); + let mut hashes: BTreeMap, String> = BTreeMap::new(); + let (mut written, mut empty) = (0, 0); + for entry in tiles["tiles"].as_array().unwrap() { + if entry["empty"] == true { + empty += 1; + assert_eq!(entry["reason"], "no_visible_entity_on_tile"); + assert!(entry["png"].is_null()); + continue; + } + written += 1; + let path = tmp.0.join(entry["png"].as_str().unwrap()); + let content = std::fs::read(&path).unwrap(); + assert_eq!(entry["bytes"].as_u64().unwrap(), content.len() as u64); + let sha = entry["sha256"].as_str().expect("a hash").to_string(); + assert_eq!(sha.len(), 64); + assert!(sha + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + if let Some(other) = hashes.get(&content) { + assert_eq!(*other, sha, "{path:?}"); + } + assert!( + hashes.iter().all(|(k, v)| k == &content || v != &sha), + "two different tiles share a hash" + ); + hashes.insert(content, sha); + } + assert!(written > 5 && empty > 0, "{written} written, {empty} empty"); +} From 895b8e251a24861412e83e8ee10890a69ec3ffb8 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 19:53:57 +0900 Subject: [PATCH 45/54] Draw every paper layout as a sheet, with its own texts as records The feature branch composited the model through each viewport itself; on the split that is the renderer's (`Scene::layout`, its layout proposal), and the package writes what a reader of sheets needs: - One sheet per LAYOUT with a tab order above 0 whose block the model holds, in tab order, drawn with `Crop::Everything` and no padding, and imaged whole: the renderer's sheet (`SheetSource::Limits` -> `layout_limits`, `PlotSettings` -> `paper_size`), else the paper entities' bounds (`entities`), fitted to the profile's lattice. A file with no layout (R13/R14, a DXF without OBJECTS) has every non-empty `*Paper_Space*` block drawn as a sheet through a layout stated on a copy of the model, one stating no sheet, so it is framed on its entities. A sheet with no usable rectangle, or one the renderer refuses, is a warning, not a failed export. - `sheets.json` / `SheetReport`: every VIEWPORT of the sheet with its ID and handle, the file's number, on, overall, composited, frame, scale and twist -- from the map the renderer drew the model through when composited, from the model's view otherwise -- the model window, and the map itself as `model_to_paper` [a, b, c, d, e, f] in the package's own row-major form, which the file's prose now states. - The sheets' own texts -- the title, the title block through its INSERT -- are text records with `space: "paper"`, their sheet, and a pixel box on that sheet's image: the renderer's texts whose path does not start at a viewport, placed on the paper. They are in texts.json, the string index, `counts.texts_paper` and `capabilities.paper_text`. - Each sheet render's bounds are summed into the report's limits. - Sheet directories: the layout's name sanitised, cut to 100 characters, suffixed when two collide. Tests: the feature's sheet tests on this API -- example_2000's two Letter layouts, colliding Hangul names, the twisted viewport's map and its ink, the plot origin, a paper hatch keeping its own pattern, one viewport per state, construction lines staying inside their viewport, the title block's texts on their sheet -- and a paper block without a layout. Ported-from: 1f08658, f9af522, 7ff5c70, 72cedb9, a4a137a, 69b2edf, 7761c75 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- crates/uncad-export/src/lib.rs | 16 +- crates/uncad-export/src/package/mod.rs | 103 ++- crates/uncad-export/src/package/sheets.rs | 427 ++++++++++++ crates/uncad-export/tests/sheets.rs | 813 ++++++++++++++++++++++ 5 files changed, 1350 insertions(+), 15 deletions(-) create mode 100644 crates/uncad-export/src/package/sheets.rs create mode 100644 crates/uncad-export/tests/sheets.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 909dcf2..a064e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,7 +156,11 @@ Notable changes to this project are recorded here. The format follows beside the plan is its own), each with a pyramid of overlapping tiles as deep as its text needs, culled to the tiles something visible reaches -- by the measured text boxes, not the estimate -- and hashed in `tiles.json`; every tile has a sidecar under 32 KB - saying what is on it. The same input gives the same bytes, `report.json` included. + saying what is on it. Each paper layout is a sheet image, drawn by the renderer's layout + rendering with the model composited through its viewports, and `sheets.json` states each + composited viewport's model-to-paper map in its own fields; the sheets' own texts (the + title, the title block) are text records on their sheet. The same input gives the same + bytes, `report.json` included. ### Changed diff --git a/crates/uncad-export/src/lib.rs b/crates/uncad-export/src/lib.rs index b89a865..6adc81f 100644 --- a/crates/uncad-export/src/lib.rs +++ b/crates/uncad-export/src/lib.rs @@ -16,8 +16,8 @@ //! dir/ //! README.txt reading order //! manifest.json source, units, profile, crop, overview, frames, frames_dropped, -//! legibility, capabilities, counts, warnings, files, shard_index, -//! legend, guidance +//! sheets, legibility, capabilities, counts, warnings, files, +//! shard_index, legend, guidance //! drawing.json header, units, layers with their state, the block definitions, counts //! overview.png the whole crop, fitted to the profile (Claude: <= 1568 px edge, //! <= 1568 patches) @@ -26,8 +26,14 @@ //! frames/fN/overview.png one per frame when the drawing splits into several //! tiles.json every tile of every level and frame: written (with its bytes and //! sha256) or empty (with a reason) +//! sheets.json the paper layouts: sheet rectangle and its source, plot settings, +//! viewports, the model-to-paper map of each composited one +//! sheets//overview.png each layout, the model composited through its +//! viewports //! texts.json TEXT/MTEXT/ATTRIB/TOLERANCE, block contents included, with the -//! world box of their glyph outlines and the tiles they are on +//! world box of their glyph outlines and the tiles they are on; the +//! paper layouts' own texts with `space: "paper"`, their sheet and +//! that sheet's pixel box //! dimensions.json measured value and where it came from, display string, points //! geometry.json every other visible entity: key points, length, area, bbox //! regions.json closed polylines: area, perimeter, centroid, the texts inside @@ -51,6 +57,8 @@ //! tiles per frame, 2x per level, as deep as the frame's dominant text needs //! to reach the target pixel height and the tile budget allows. A tile is //! drawn from the parts that reach it, on as many threads as there are. +//! Each paper layout is walked once more, as its sheet (the renderer's +//! `Scene::layout`), and drawn whole. //! //! A text record's box is measured from the glyph outlines of the bundled //! font ([`fonts`]) as the renderer laid them out, and those boxes widen the @@ -79,5 +87,5 @@ pub use frame::{CropMode, CropSource, Rect}; pub use package::{ compact_string, export_file, export_package, normalize_string, Counts, CropReport, ExcludeReason, Excluded, ExportError, ExportOptions, ExportReport, FrameReport, HeightClass, - ImageInfo, LevelInfo, Profile, WrittenFile, SCHEMA, + ImageInfo, LevelInfo, Profile, SheetReport, SheetViewport, WrittenFile, SCHEMA, }; diff --git a/crates/uncad-export/src/package/mod.rs b/crates/uncad-export/src/package/mod.rs index a28ceb1..1df00ca 100644 --- a/crates/uncad-export/src/package/mod.rs +++ b/crates/uncad-export/src/package/mod.rs @@ -3,6 +3,7 @@ mod output; mod records; +mod sheets; mod tiles; use std::collections::{BTreeMap, BTreeSet}; @@ -35,6 +36,7 @@ use records::{ capped_confidence, coord_decimals, handle_of, id_key, layer_name, placed_texts, text_record, PlacedText, Record, Rounder, }; +pub use sheets::{SheetReport, SheetViewport}; /// The `$schema` value every JSON file in the package carries. pub const SCHEMA: &str = "uncad-package/1"; @@ -444,6 +446,8 @@ pub struct ExportReport { pub overview: ImageInfo, /// The frames, `f0` first. pub frames: Vec, + /// The paper layouts, in tab order. + pub sheets: Vec, pub crop: CropReport, pub counts: Counts, pub warnings: Vec, @@ -831,6 +835,13 @@ pub fn export_package( .collect(); let drawn = tiles::render_images(&scene, fonts, &extent_of, &to_draw)?; + // --- sheets: one image per paper layout ------------------------------------ + let sheets = if options.sheets { + sheets::export_sheets(db, &unit, options, cap_height, &rounder, &mut warnings)? + } else { + sheets::Sheets::default() + }; + // --- records with their images ------------------------------------------- let images_for = |bbox: &Rect| -> Vec<&ImageInfo> { tile_images @@ -862,6 +873,35 @@ pub fn export_package( .map(|t| with_images(text_record(t, &rounder, "model", None, measured_why))) .collect(); text_records.sort_by_cached_key(|r| id_key(&r.id)); + // The paper-space texts, each with its sheet and that sheet image's + // pixel box -- the only route in the package from a record to a sheet + // pixel. They are kept out of `text_records` proper because that list + // is what the tile sidecars are built from, and a paper box in paper + // units would otherwise be matched against model tiles. + let paper_text_records: Vec = sheets + .texts + .iter() + .map(|(t, sheet)| { + let report = &sheets.reports[*sheet]; + let mut r = text_record(t, &rounder, "paper", Some(&report.name), measured_why); + let mut px = Map::new(); + px.insert( + report.overview.id.clone(), + json!(report.overview.px_box(&t.bbox)), + ); + r.value.insert("tiles".into(), json!([])); + r.value.insert("px".into(), Value::Object(px)); + r + }) + .collect(); + // Model and paper texts in one record file, ordered by id like every + // other kind, so `shard_index` resolves either of them. + let mut written_texts: Vec = text_records + .iter() + .chain(paper_text_records.iter()) + .cloned() + .collect(); + written_texts.sort_by_cached_key(|r| id_key(&r.id)); let defaults = header.map(DimDefaults::from_header).unwrap_or_default(); let dim_records = dimension_records(db, &shown, &extent_of, &defaults, &unit, &rounder)? @@ -888,7 +928,7 @@ pub fn export_package( } strings.entry(key).or_default().insert(id.to_string()); }; - for t in &texts { + for t in texts.iter().chain(sheets.texts.iter().map(|(t, _)| t)) { index(&t.text, &t.id); } for r in &dim_records { @@ -935,7 +975,10 @@ pub fn export_package( } writer.write_bytes(&image.png, bytes, if is_tile { "tile" } else { "image" })?; } - writer.write_records("texts", "text", &text_records)?; + for (path, bytes) in &sheets.images { + writer.write_bytes(path, bytes, "sheet")?; + } + writer.write_records("texts", "text", &written_texts)?; writer.write_records("dimensions", "dimension", &dim_records)?; writer.write_records("geometry", "geometry", &geo_records)?; writer.write_records("regions", "region", ®ion_records)?; @@ -954,6 +997,20 @@ pub fn export_package( "strings", )?; + if options.sheets { + writer.write_json( + "sheets.json", + &json!({ + "$schema": SCHEMA, + // Stated in the fields this file publishes. + "model_to_paper": "Per composited viewport, `model_to_paper` is the map the model was drawn through: row-major [a, b, c, d, e, f] with paper_x = a x + b y + c and paper_y = d x + e y + f for a model point (x, y) -- a rotation by `twist_deg` and a scale of `scale` paper units per model unit, which sends the four `model_window` corners (lower-left, lower-right, upper-right, upper-left as they sit on the sheet) onto the four corners of its `frame`. With twist_deg 0 that is paper = [frame[0] + (x - model_window[0][0]) * scale, frame[1] + (y - model_window[0][1]) * scale]. Then apply this sheet's overview `world_to_px` to get pixels in sheets//overview.png.", + "twist_convention": "`twist_deg` is the viewport's VIEWTWIST in degrees, positive turning the model counter-clockwise on the sheet (ezdxf's rule; not checked against a plotted sheet).", + "sheets": sheets.reports, + }), + "sheets", + )?; + } + // --- sidecars and tiles.json ------------------------------------------------- let on_tiles = tiles::OnTiles { texts: &text_records, @@ -1039,7 +1096,24 @@ pub fn export_package( } } } - let limits = &scene.limits; + // Every bound the model render and each sheet render engaged, summed + // field by field: what a malformed file cost the package. + let mut limits = scene.limits.clone(); + for l in &sheets.limits { + limits.oversized_entities += l.oversized_entities; + limits.block_refs_dropped += l.block_refs_dropped; + limits.hatch_patterns_dropped += l.hatch_patterns_dropped; + limits.entities_dropped += l.entities_dropped; + limits.truncated_parts += l.truncated_parts; + limits.unreadable_entities += l.unreadable_entities; + limits.out_of_range_entities += l.out_of_range_entities; + for d in &l.dropped { + if limits.dropped.len() < MAX_HIDDEN_NAMED && !limits.dropped.contains(d) { + limits.dropped.push(d.clone()); + } + } + } + let limits = &limits; if limits.engaged() { warnings.push(format!( "the drawing hit the renderer's robustness limits: {}", @@ -1088,11 +1162,10 @@ pub fn export_package( }) })) .collect(); - let written_texts = &text_records; let counts = Counts { entities: parts.len(), texts: written_texts.len(), - texts_paper: 0, + texts_paper: paper_text_records.len(), dimensions: dim_records.len(), geometry: geo_records.len(), regions: region_records.len(), @@ -1101,7 +1174,7 @@ pub fn export_package( excluded: excluded_json.len(), tiles: written_total, frames: frame_reports.len(), - sheets: 0, + sheets: sheets.reports.len(), }; let hidden_top_level: usize = hidden_by_reason.values().sum(); // `count` is the number `manifest.counts.hidden` prints, every hidden @@ -1144,8 +1217,11 @@ pub fn export_package( frames: &frame_reports, frames_dropped: &plan.dropped, frames_dropped_total: plan.dropped_total, + rounder: &rounder, counts: &counts, - texts: written_texts, + texts: &written_texts, + paper_texts: &paper_text_records, + sheets: &sheets.reports, dims: &dim_records, regions: ®ion_records, warnings: &warnings, @@ -1179,6 +1255,7 @@ pub fn export_package( files: writer.files, overview, frames: frame_reports, + sheets: sheets.reports, crop: crop_report, counts, warnings, @@ -1840,8 +1917,11 @@ struct ManifestInput<'a, 'w> { frames: &'a [FrameReport], frames_dropped: &'a [Value], frames_dropped_total: usize, + rounder: &'a Rounder, counts: &'a Counts, texts: &'a [Record], + paper_texts: &'a [Record], + sheets: &'a [SheetReport], dims: &'a [Record], regions: &'a [Record], warnings: &'a [String], @@ -1892,6 +1972,8 @@ fn manifest_json(m: ManifestInput<'_, '_>) -> Value { "areas_by_confidence": { "exact": areas_exact, "estimated": areas_estimated, "unavailable": areas_unavailable }, "text_boxes": if m.texts.is_empty() { "none" } else if measured_total == m.texts.len() { "measured" } else if measured_total == 0 { "estimated" } else { "mixed" }, "fonts": fonts_name, + "paper_layouts": if m.sheets.is_empty() { "none" } else { "composited" }, + "paper_text": if m.sheets.is_empty() { "none" } else if m.paper_texts.is_empty() { "empty" } else { "indexed" }, "frames": m.frames.len(), }); let legend = json!({ @@ -1903,7 +1985,7 @@ fn manifest_json(m: ManifestInput<'_, '_>) -> Value { "unavailable": "no meaningful value; `why` says why, and any number beside it is not to be used", "none": "there was nothing to compute (used by capabilities, not by records)", }, - "text_records": "texts carry `bbox_confidence` (`measured` from the glyph outlines as the renderer laid them out, or `estimated` at 0.6 em per character) and no `confidence`; block instances carry neither.", + "text_records": "texts carry `bbox_confidence` (`measured` from the glyph outlines as the renderer laid them out, or `estimated` at 0.6 em per character) and no `confidence`; block instances carry neither. `space` is `model` or `paper`; a paper text names its `sheet` and is measured in that layout's paper units.", "ids": "a record's `id` is the drawing's entity reference ID in decimal (a path of them for a text inside a block); its `handle` is the file's own handle for the entity, in hexadecimal, when it came from a file", "measurement_source": ["act_measurement", "from_points", "none"], "display_source": ["user_text", "cached_block", "formatted", "suppressed", "none"], @@ -1916,7 +1998,7 @@ fn manifest_json(m: ManifestInput<'_, '_>) -> Value { // The tile and overlap numbers come from the profile in use, not from // the prose: claude-hires writes 1932 px tiles with 392 px of overlap. let guidance = format!( - "Read manifest.json first. Numbers (lengths, areas, dimension values, text) come from the JSON records, never from pixels; `legend` says what `confidence` and the package's other vocabularies mean and which records carry them. To find something: look its text up in strings.json (normalised: trimmed, lower-case, single spaces), resolve the id through shard_index -- the entry whose [first_key, last_key] contains int(id.split('/')[0]), since the id does not say its kind and one id can be in two -- then open the tile(s) in its `tiles` list; every tile's .json sidecar lists what is on it with pixel boxes, its own `columns` legend and a `counts` object that stays exact when rows are cut. Pixel boxes are clipped to the image they are quoted in; the record's world `bbox` is its full extent. overview.png shows the whole crop; each frame in `frames` (f0 the main drawing, f1.. details drawn beside it) has its own overview and tiles z1..zN, {} px with {} px overlap (2x zooms), row 0 at the top; a group too small to be framed is in `frames_dropped` and its records carry `tiles: []`. report.json lists what was left out and why.", + "Read manifest.json first. Numbers (lengths, areas, dimension values, text) come from the JSON records, never from pixels; `legend` says what `confidence` and the package's other vocabularies mean and which records carry them. To find something: look its text up in strings.json (normalised: trimmed, lower-case, single spaces), resolve the id through shard_index -- the entry whose [first_key, last_key] contains int(id.split('/')[0]), since the id does not say its kind and one id can be in two -- then open the tile(s) in its `tiles` list; every tile's .json sidecar lists what is on it with pixel boxes, its own `columns` legend and a `counts` object that stays exact when rows are cut. Pixel boxes are clipped to the image they are quoted in; the record's world `bbox` is its full extent. overview.png shows the whole crop; each frame in `frames` (f0 the main drawing, f1.. details drawn beside it) has its own overview and tiles z1..zN, {} px with {} px overlap (2x zooms), row 0 at the top; a group too small to be framed is in `frames_dropped` and its records carry `tiles: []`. The drawing's title and title block are paper-space text: those records carry `space: \"paper\"` and a `sheet`, and their pixel box is on that sheet's image, not on a tile. report.json lists what was left out and why.", profile.tile, profile.overlap ); m.writer.files.push(WrittenFile { @@ -1948,6 +2030,7 @@ fn manifest_json(m: ManifestInput<'_, '_>) -> Value { "frames": m.frames, "frames_dropped": m.frames_dropped, "frames_dropped_total": m.frames_dropped_total, + "sheets": m.sheets.iter().map(|s| json!({"name": s.name, "tab_order": s.tab_order, "png": s.overview.png, "px": s.overview.px, "rect": m.rounder.rect(&s.rect), "units": s.units, "viewports": s.viewports.len(), "texts": m.paper_texts.iter().filter(|r| r.value.get("sheet").is_some_and(|n| n == s.name.as_str())).count()})).collect::>(), "legibility": { "target_px": m.options.target_text_px, "per_frame": m.frames.iter().map(|f| json!({"frame": f.id, "z_max": f.z_max, "target_met": f.height_classes.iter().all(|c| c.legible), "pyramid_complete": f.reached, "height_classes": f.height_classes})).collect::>() }, "counts": m.counts, "capabilities": capabilities, @@ -1961,7 +2044,7 @@ fn manifest_json(m: ManifestInput<'_, '_>) -> Value { fn readme(options: &ExportOptions) -> String { format!( - "uncad package ({SCHEMA})\n\nReading order:\n 1. manifest.json what is here, the crop, the images and their affines; `legend` explains the record vocabularies, `guidance` how to look something up\n 2. strings.json find a text or a number, get record ids; shard_index turns an id into a file (compare int(id.split('/')[0]) against first_key/last_key, not the strings)\n 3. texts.json / dimensions.json / geometry.json / regions.json / blocks.json the records (sharded above {} KB, see shard_index); blocks.json holds the INSERT instances, drawing.json the block definitions\n 4. overview.png the whole drawing; frames/f*/overview.png and frames/f*/tiles/z*/ zoomed tiles with .json sidecars; tiles.json lists every tile, written or empty with a reason, with its size and sha256\n 5. report.json what was left out and why\n\ndrawing.json holds the header, units, layer states and block definitions; entities.json and drawing.svg (when present) are tool inputs, not for reading.\n", + "uncad package ({SCHEMA})\n\nReading order:\n 1. manifest.json what is here, the crop, the images and their affines; `legend` explains the record vocabularies, `guidance` how to look something up\n 2. strings.json find a text or a number, get record ids; shard_index turns an id into a file (compare int(id.split('/')[0]) against first_key/last_key, not the strings)\n 3. texts.json / dimensions.json / geometry.json / regions.json / blocks.json the records (sharded above {} KB, see shard_index); blocks.json holds the INSERT instances, drawing.json the block definitions\n 4. overview.png the whole drawing; frames/f*/overview.png and frames/f*/tiles/z*/ zoomed tiles with .json sidecars; tiles.json lists every tile, written or empty with a reason, with its size and sha256\n 5. sheets.json paper layouts: sheet size, viewports with their scale and model window, and how a model point maps onto the sheet; sheets//overview.png. The title and title block are paper-space texts in texts.json (`space: \"paper\"`, with a `sheet` and that sheet's pixel box)\n 6. report.json what was left out and why\n\nAll other records are model space. drawing.json holds the header, units, layer states and block definitions; entities.json and drawing.svg (when present) are tool inputs, not for reading.\n", options.shard_kb ) } diff --git a/crates/uncad-export/src/package/sheets.rs b/crates/uncad-export/src/package/sheets.rs new file mode 100644 index 0000000..b033ff3 --- /dev/null +++ b/crates/uncad-export/src/package/sheets.rs @@ -0,0 +1,427 @@ +//! Sheets: one image per paper layout, the model composited through its +//! viewports by the renderer's layout rendering, with what each viewport +//! shows, how a model point maps onto the sheet, and the sheet's own texts +//! -- the title and the title block -- as records on it. + +use std::collections::{BTreeMap, BTreeSet}; + +use iron_render_cad::{limits::LimitReport, Background, Scene, SheetSource, ToSvgOptions}; +use serde::Serialize; +use uncad_model::model::{Entity, EntityId, Point2D, Ref, ViewportEntity}; +use uncad_model::tables::{LayoutRecord, PlotPaperUnits, PlotSettings}; +use uncad_model::CadDatabase; + +use super::output::to_rgb; +use super::records::{placed_texts, PlacedText, Rounder}; +use super::{fit_overview, ExportError, ExportOptions, ImageInfo, STROKE_PX}; +use crate::frame::{Rect, EMPTY_RECT}; + +/// A viewport on a sheet, as `sheets.json` lists it. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SheetViewport { + /// The VIEWPORT's reference ID in the model. + pub id: u64, + /// The file's handle for it, when it came from a file. + pub handle: Option, + /// The number the file gives it (DXF 69); a DWG states none. + pub viewport_id: Option, + /// Whether it is on: a viewport the file does not say is off is on. + pub on: bool, + /// The sheet's own frame (the paper), never composited. + pub overall: bool, + /// Whether the model was drawn through it. + pub composited: bool, + /// The frame on the sheet, paper units. + pub frame: Rect, + /// Paper units per model unit. + pub scale: Option, + pub twist_deg: f64, + /// World corners of the model window (lower-left, lower-right, + /// upper-right, upper-left on the sheet), when composited. + pub model_window: Option<[[f64; 2]; 4]>, + /// Row-major `[a, b, c, d, e, f]` with `paper_x = a x + b y + c` and + /// `paper_y = d x + e y + f` for a model point `(x, y)`, when + /// composited: the map the renderer drew the model through. + pub model_to_paper: Option<[f64; 6]>, + pub frozen_layers: Vec, +} + +/// One paper layout: its sheet, its viewports and its image. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SheetReport { + pub name: String, + pub tab_order: i32, + pub block: String, + /// `mm`, `in` or `px` -- the layout's paper unit. + pub units: String, + /// The plot settings, when the file has a LAYOUT for this sheet. + pub plot: Option, + /// The sheet rectangle in paper units and where it came from, in order + /// of preference: `layout_limits` (the LAYOUT's LIMMIN/LIMMAX, AutoCAD's + /// own placement of the paper), `paper_size` (the paper its plot + /// settings describe), `entities` (the paper entities' extents) or + /// `empty`. + pub rect: Rect, + pub rect_source: String, + pub overview: ImageInfo, + pub viewports: Vec, + /// The sheet's own entities the drawing shows (viewport frames + /// included). + pub entities: usize, +} + +/// A paper layout to export. +struct SheetSpec { + /// The key of `tables.layouts` the renderer knows it by. + name: String, + tab_order: i32, + block: String, + units: String, + plot: Option, +} + +/// The sheets written, their paper texts (each with the index of its +/// sheet), and every bound their renders engaged. +#[derive(Default)] +pub(crate) struct Sheets { + pub(crate) reports: Vec, + pub(crate) texts: Vec<(PlacedText, usize)>, + pub(crate) images: Vec<(String, Vec)>, + pub(crate) limits: Vec, +} + +/// The most characters of a layout name a sheet directory keeps: well under +/// every per-component limit even after the `_99` dedup suffix, and under +/// what is left of Windows' 260-character path budget once the package +/// directory and `sheets//overview.png` are counted. +const MAX_SHEET_DIR: usize = 100; + +/// The directory one sheet's image goes in, under `sheets/`: the layout's +/// name with every character outside `[A-Za-z0-9_-]` replaced by `_` (so +/// the path is portable), cut to [`MAX_SHEET_DIR`] characters, `sheet` when +/// nothing is left of it, and `_2`, `_3`, ... in tab order when an earlier +/// layout already took the name -- every Hangul syllable sanitises to `_`, +/// so two three-syllable Korean names both become `___`. +fn sheet_dir(name: &str, used: &mut BTreeSet) -> String { + let safe: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + // Sanitising leaves pure ASCII, so this cuts characters and bytes + // alike and can never split one. + .take(MAX_SHEET_DIR) + .collect(); + let base = if safe.is_empty() { + "sheet".to_string() + } else { + safe + }; + let mut candidate = base.clone(); + let mut n = 2; + while !used.insert(candidate.clone()) { + candidate = format!("{base}_{n}"); + n += 1; + } + candidate +} + +/// The unit a layout's paper is set up in. +fn paper_units(plot: &PlotSettings) -> &'static str { + match plot.paper_units { + Some(PlotPaperUnits::Inches) => "in", + Some(PlotPaperUnits::Pixels) => "px", + _ => "mm", + } +} + +/// The paper layouts of `db`, in tab order: the LAYOUT objects whose block +/// the model holds -- or, for a file without them (R13/R14, a DXF without +/// an OBJECTS section), every paper-space block with entities, which the +/// renderer can only draw as a layout: `db` is then returned with a +/// layout stated for each, one that states no sheet, so it is framed on its +/// paper entities. +fn sheet_specs(db: &CadDatabase, unit: &str) -> (Vec, Option) { + let mut specs: Vec = db + .tables + .layouts + .iter() + .filter(|(_, l)| { + l.tab_order > 0 + && l.block_name + .resolved() + .is_some_and(|b| db.tables.block_records.contains_key(b)) + }) + .map(|(key, l)| SheetSpec { + name: key.clone(), + tab_order: l.tab_order, + block: l.block_name.name().to_string(), + units: paper_units(&l.plot_settings).to_string(), + plot: Some(l.plot_settings.clone()), + }) + .collect(); + let mut synthetic = None; + if specs.is_empty() { + let mut tab = 1; + for (name, block) in &db.tables.block_records { + if name.to_uppercase().starts_with("*PAPER_SPACE") && !block.entities.is_empty() { + specs.push(SheetSpec { + name: name.trim_start_matches('*').to_string(), + tab_order: tab, + block: name.clone(), + units: unit.to_string(), + plot: None, + }); + tab += 1; + } + } + if !specs.is_empty() { + let mut stated = db.clone(); + for spec in &specs { + stated.tables.layouts.insert( + spec.name.clone(), + LayoutRecord { + name: spec.name.clone(), + tab_order: spec.tab_order, + block_name: Ref::Resolved(spec.block.clone()), + limits_min: Point2D::default(), + limits_max: Point2D::default(), + plot_settings: PlotSettings { + paper_name: String::new(), + paper_width: 0.0, + paper_height: 0.0, + margin_left: 0.0, + margin_bottom: 0.0, + margin_right: 0.0, + margin_top: 0.0, + plot_origin: Point2D::default(), + paper_units: None, + rotation: None, + scale_numerator: 0.0, + scale_denominator: 0.0, + }, + }, + ); + } + synthetic = Some(stated); + } + } + specs.sort_by(|a, b| a.tab_order.cmp(&b.tab_order).then(a.name.cmp(&b.name))); + (specs, synthetic) +} + +/// Draws every paper layout of `db` as its sheet and collects its texts. +pub(crate) fn export_sheets( + db: &CadDatabase, + unit: &str, + options: &ExportOptions, + cap_height: f64, + rounder: &Rounder, + warnings: &mut Vec, +) -> Result { + let (specs, synthetic) = sheet_specs(db, unit); + let db = synthetic.as_ref().unwrap_or(db); + let fonts = &options.fonts; + let mut out = Sheets { + reports: Vec::new(), + texts: Vec::new(), + images: Vec::new(), + limits: Vec::new(), + }; + let mut dirs: BTreeSet = BTreeSet::new(); + // Two LAYOUTs of a malformed file can name one paper block; a record + // file must not hold an id twice. + let mut text_ids: BTreeSet = BTreeSet::new(); + for spec in specs { + let sheet = match Scene::layout( + db, + &spec.name, + ToSvgOptions { + crop: iron_render_cad::Crop::Everything, + padding: 0.0, + include_hidden: options.include_hidden, + cap_height, + ..ToSvgOptions::default() + }, + ) { + Ok(sheet) => sheet, + Err(e) => { + warnings.push(format!( + "UnusableSheet: layout {} ({}) cannot be drawn: {e}", + spec.name, spec.block + )); + continue; + } + }; + let (rect, rect_source) = match (sheet.sheet, sheet.crop.content) { + (Some(SheetSource::Limits), _) => (Rect::from(sheet.view_box), "layout_limits"), + (Some(SheetSource::PlotSettings), _) => (Rect::from(sheet.view_box), "paper_size"), + (Some(_), _) => (Rect::from(sheet.view_box), "stated"), + (None, Some(content)) => (Rect::from(content), "entities"), + (None, None) => (EMPTY_RECT, "empty"), + }; + // No paper size, no limits and nothing but point-like content (a + // lone POINT, a zero-length LINE): there is no rectangle to fit, + // and one unusable sheet is worth a warning, not the whole export. + let usable = [rect.min_x, rect.min_y, rect.max_x, rect.max_y] + .iter() + .all(|v| v.is_finite() && v.abs() < 1e15) + && (rect.width() > 0.0 || rect.height() > 0.0); + if !usable { + warnings.push(format!( + "UnusableSheet: layout {} ({}) has no paper size, no limits and no usable content ({rect_source}); its sheet is skipped", + spec.name, spec.block + )); + continue; + } + let fit = fit_overview(&rect, &options.profile, Some(0.0)); + let png_path = format!("sheets/{}/overview.png", sheet_dir(&spec.name, &mut dirs)); + let overview = ImageInfo::new( + &format!("sheet:{}", spec.name), + &png_path, + fit.rect, + fit.ppu, + fit.width, + fit.height, + ); + let bytes = to_rgb(&sheet.png( + &overview.view(), + STROKE_PX, + fonts, + Background::White, + |_| true, + )?)?; + out.images.push((png_path, bytes)); + + let block_entities: &[Entity] = db + .tables + .block_records + .get(&spec.block) + .map_or(&[], |b| b.entities.as_slice()); + let viewport_entities: BTreeMap = block_entities + .iter() + .filter_map(|e| match e { + Entity::Viewport(v) => Some((v.common.id, v)), + _ => None, + }) + .collect(); + let viewports: Vec = sheet + .viewports + .iter() + .map(|r| { + let vp = viewport_entities.get(&r.id); + let view = vp.and_then(|v| v.view); + let m = r.model_to_paper; + SheetViewport { + id: r.id.value(), + handle: vp.and_then(|v| v.common.source_handle.resolved().cloned()), + viewport_id: vp.and_then(|v| v.viewport_id), + on: vp.is_none_or(|v| v.on != Some(false)), + overall: r.overall, + composited: m.is_some(), + frame: Rect::from(r.frame), + scale: match (m, vp, view) { + (Some(m), _, _) => Some(m.determinant().abs().sqrt()), + (None, Some(v), Some(view)) if view.height > 0.0 && v.height > 0.0 => { + Some(v.height / view.height) + } + _ => None, + }, + twist_deg: match (m, view) { + (Some(m), _) => m.b.atan2(m.a).to_degrees(), + (None, Some(view)) => view.twist.to_degrees(), + (None, None) => 0.0, + }, + model_window: r + .model_window + .map(|w| w.map(|p| [rounder.coord(p.x), rounder.coord(p.y)])), + model_to_paper: m.map(|m| [m.a, m.c, m.e, m.b, m.d, m.f]), + frozen_layers: vp.map_or(Vec::new(), |v| { + v.frozen_layers + .iter() + .map(|l| l.name().to_string()) + .collect() + }), + } + }) + .collect(); + let entities = sheet + .parts() + .iter() + .filter(|p| !p.through_viewport && p.hidden.is_none()) + .count(); + + // The sheet's own texts: the ones whose path does not start at a + // viewport (the model drawn through one starts there), placed on + // the paper, measured through the same fonts the sheet image got. + let viewport_ids: BTreeSet = sheet.viewports.iter().map(|v| v.id).collect(); + let boxes = sheet.text_boxes(fonts)?; + let mut top: BTreeMap = + db.entities.iter().map(|e| (e.common().id, e)).collect(); + for e in block_entities { + top.insert(e.common().id, e); + } + let sheet_index = out.reports.len(); + let texts = placed_texts(&db.tables, &top, &boxes, |id| !viewport_ids.contains(&id)); + out.texts.extend( + texts + .into_iter() + .filter(|t| text_ids.insert(t.id.clone())) + .map(|t| (t, sheet_index)), + ); + out.limits.push(sheet.limits.clone()); + out.reports.push(SheetReport { + name: spec.name, + tab_order: spec.tab_order, + block: spec.block, + units: spec.units, + plot: spec.plot, + rect, + rect_source: rect_source.to_string(), + overview, + viewports, + entities, + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sheet_directories_are_unique() { + let mut used = BTreeSet::new(); + assert_eq!(sheet_dir("\u{d3c9}\u{ba74}\u{b3c4}", &mut used), "___"); + assert_eq!(sheet_dir("\u{c785}\u{ba74}\u{b3c4}", &mut used), "____2"); + assert_eq!(sheet_dir("Layout 1", &mut used), "Layout_1"); + assert_eq!(sheet_dir("Layout_1", &mut used), "Layout_1_2"); + assert_eq!(sheet_dir("Layout-1", &mut used), "Layout-1"); + assert_eq!(sheet_dir("", &mut used), "sheet"); + assert_eq!(sheet_dir("", &mut used), "sheet_2"); + assert_eq!(used.len(), 7, "every name got its own directory"); + } + + #[test] + fn a_sheet_directory_is_short_enough_for_the_filesystem() { + // A layout name longer than the filesystem's 255-byte component + // limit made the first sheet write fail, and the export with it. + let mut used = BTreeSet::new(); + let long = "L".repeat(300); + let first = sheet_dir(&long, &mut used); + assert_eq!(first, "L".repeat(MAX_SHEET_DIR)); + let second = sheet_dir(&format!("{long}-other"), &mut used); + assert_eq!(second, format!("{}_2", "L".repeat(MAX_SHEET_DIR))); + for dir in [&first, &second] { + assert!(dir.len() < 255, "{} bytes", dir.len()); + assert!(dir.is_ascii()); + } + let exact = "N".repeat(MAX_SHEET_DIR); + assert_eq!(sheet_dir(&exact, &mut used), exact); + } +} diff --git a/crates/uncad-export/tests/sheets.rs b/crates/uncad-export/tests/sheets.rs new file mode 100644 index 0000000..b20d09e --- /dev/null +++ b/crates/uncad-export/tests/sheets.rs @@ -0,0 +1,813 @@ +//! Sheets: one image per paper layout, the model composited through its +//! viewports by the renderer, the sheet placed by the layout's limits or +//! its page setup, what each viewport shows and how a model point maps onto +//! the paper, and the sheet's own texts as records on it. Sources: the +//! corpus's `example_2000.dwg` (two Letter layouts, each with only its +//! overall viewport) and this project's fixtures (see +//! crates/uncad/tests/fixtures/README.md): a viewport twisted 30 degrees at +//! scale 2, a page setup with a plot origin, a hatch on each side of a +//! viewport, one viewport per state, a title block. + +mod common; + +use std::path::Path; + +use common::*; +use serde_json::Value; +use uncad_export::{ExportOptions, SheetReport}; +use uncad_model::model::{Entity, PointEntity}; +use uncad_model::tables::{BlockRecord, LayoutRecord, PlotSettings}; +use uncad_model::{Point2D, Ref}; + +fn one_level() -> ExportOptions { + ExportOptions { + max_levels: 0, + ..Default::default() + } +} + +/// The sheet image as a "is this pixel inked?" function in paper units. +struct Sheet { + dark: Vec, + width: usize, + height: usize, + min_x: f64, + max_y: f64, + ppu: f64, +} + +impl Sheet { + fn read(dir: &Path, sheet: &SheetReport, threshold: u8) -> Sheet { + let bytes = std::fs::read(dir.join(&sheet.overview.png)).expect("the sheet image"); + let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + let mut reader = decoder.read_info().expect("a PNG"); + let mut buf = vec![0; reader.output_buffer_size().expect("a frame size")]; + let info = reader.next_frame(&mut buf).expect("a frame"); + assert_eq!(info.color_type, png::ColorType::Rgb); + Sheet { + dark: buf[..info.buffer_size()] + .chunks_exact(3) + .map(|p| p[0] < threshold) + .collect(), + width: info.width as usize, + height: info.height as usize, + min_x: sheet.overview.world.min_x, + max_y: sheet.overview.world.max_y, + ppu: sheet.overview.ppu, + } + } + + fn at(&self, x: f64, y: f64) -> (usize, usize) { + let px = ((x - self.min_x) * self.ppu).round().max(0.0) as usize; + let py = ((self.max_y - y) * self.ppu).round().max(0.0) as usize; + (px.min(self.width - 1), py.min(self.height - 1)) + } + + fn dark_at(&self, x: usize, y: usize) -> bool { + self.dark[y * self.width + x] + } + + /// Whether anything is inked within `r` pixels of the paper point. + fn inked_near(&self, x: f64, y: f64, r: usize) -> bool { + let (px, py) = self.at(x, y); + (px.saturating_sub(r)..=(px + r).min(self.width - 1)).any(|x| { + (py.saturating_sub(r)..=(py + r).min(self.height - 1)).any(|y| self.dark_at(x, y)) + }) + } + + /// Inked pixels along the horizontal paper segment `y`, `x0..x1`. + fn inked_along(&self, y: f64, x0: f64, x1: f64) -> usize { + let (px0, py) = self.at(x0, y); + let (px1, _) = self.at(x1, y); + (px0..=px1) + .filter(|x| { + (py.saturating_sub(1)..=(py + 1).min(self.height - 1)).any(|y| self.dark_at(*x, y)) + }) + .count() + } +} + +#[test] +fn the_export_writes_one_sheet_per_paper_layout() { + let tmp = TempDir::new("letter"); + let report = export_path(&corpus("example_2000.dwg"), &tmp.0, &one_level()).expect("exports"); + assert_eq!(report.sheets.len(), 2); + assert_eq!(report.counts.sheets, 2); + let s1 = &report.sheets[0]; + assert_eq!( + (s1.name.as_str(), s1.tab_order, s1.units.as_str()), + ("Layout1", 1, "mm") + ); + // The layout's own limits are taken first: landscape Letter with the + // printable corner at the origin. + assert_eq!(s1.rect_source, "layout_limits"); + for (got, want) in [ + (s1.rect.min_x, -6.35), + (s1.rect.min_y, -6.35), + (s1.rect.max_x, 273.05), + (s1.rect.max_y, 209.55), + ] { + assert!((got - want).abs() < 1e-3, "{got} vs {want}"); + } + assert_eq!(s1.overview.png, "sheets/Layout1/overview.png"); + assert!(tmp.0.join(&s1.overview.png).exists()); + assert_eq!(s1.overview.px[0] % 28, 0); + // The whole sheet, no padding: the image's world rectangle is the sheet + // (grown only by the lattice snap). + assert!((s1.overview.world.min_x - s1.rect.min_x).abs() < 1e-9); + assert!((s1.overview.world.max_y - s1.rect.max_y).abs() < 1e-9); + assert_eq!(s1.viewports.len(), 1); + assert!(s1.viewports[0].overall && !s1.viewports[0].composited); + assert_eq!(s1.viewports[0].scale, Some(1.0)); + assert_eq!(s1.viewports[0].model_to_paper, None); + + let sheets = read_json(&tmp.0.join("sheets.json")); + assert_eq!(sheets["sheets"].as_array().unwrap().len(), 2); + assert!(sheets["twist_convention"] + .as_str() + .unwrap() + .contains("counter-clockwise")); + let manifest = read_json(&tmp.0.join("manifest.json")); + assert_eq!(manifest["sheets"].as_array().unwrap().len(), 2); + assert_eq!(manifest["capabilities"]["paper_layouts"], "composited"); + assert_eq!(manifest["counts"]["sheets"], 2); + + // Opting out. + let quiet = export_path( + &corpus("example_2000.dwg"), + &TempDir::new("nosheets").0, + &ExportOptions { + max_levels: 0, + sheets: false, + ..Default::default() + }, + ) + .expect("exports"); + assert!(quiet.sheets.is_empty()); + assert!(!quiet.files.iter().any(|f| f.path.starts_with("sheets"))); +} + +#[test] +fn every_world_box_in_the_package_is_the_documented_array() { + // "boxes are [x0, y0, x1, y1] in world units": the manifest's overview, + // frames and crop, sheets.json, tiles.json and report.json alike. + let tmp = TempDir::new("boxes"); + let report = export_path( + &corpus("example_2000.dwg"), + &tmp.0, + &ExportOptions { + max_levels: 1, + ..Default::default() + }, + ) + .expect("exports"); + let manifest = read_json(&tmp.0.join("manifest.json")); + let sheets = read_json(&tmp.0.join("sheets.json")); + let tiles = read_json(&tmp.0.join("tiles.json")); + let report_json = read_json(&tmp.0.join("report.json")); + let box_of = |value: &Value, what: &str| -> [f64; 4] { + let array = value + .as_array() + .unwrap_or_else(|| panic!("{what} is an array, got {value}")); + assert_eq!(array.len(), 4, "{what}: {value}"); + let mut out = [0.0; 4]; + for (slot, v) in out.iter_mut().zip(array) { + *slot = v.as_f64().unwrap_or_else(|| panic!("{what}: {value}")); + } + out + }; + let overview = box_of(&manifest["overview"]["world"], "manifest.overview.world"); + let world = report.overview.world; + for (got, want) in overview + .iter() + .zip([world.min_x, world.min_y, world.max_x, world.max_y]) + { + assert!((got - want).abs() < 1e-9, "{got} vs {want}"); + } + box_of(&manifest["crop"]["rect"], "manifest.crop.rect"); + box_of(&manifest["crop"]["content"], "manifest.crop.content"); + box_of( + &manifest["crop"]["header_extents"], + "manifest.crop.header_extents", + ); + for excluded in manifest["crop"]["excluded"].as_array().unwrap() { + box_of(&excluded["rect"], "manifest.crop.excluded[].rect"); + } + for excluded in report_json["excluded"].as_array().unwrap() { + if !excluded["rect"].is_null() { + box_of(&excluded["rect"], "report.excluded[].rect"); + } + } + for frame in manifest["frames"].as_array().unwrap() { + box_of(&frame["content"], "manifest.frames[].content"); + box_of( + &frame["overview"]["world"], + "manifest.frames[].overview.world", + ); + } + let content = box_of(&manifest["frames"][0]["content"], "frames[0].content"); + let rounded = box_of(&tiles["frames"][0]["content"], "tiles.frames[0].content"); + for (a, b) in content.iter().zip(rounded) { + assert!((a - b).abs() < 1e-3, "{a} vs {b}"); + } + for sheet in sheets["sheets"].as_array().unwrap() { + box_of(&sheet["rect"], "sheets.json rect"); + box_of(&sheet["overview"]["world"], "sheets.json overview.world"); + for viewport in sheet["viewports"].as_array().unwrap() { + box_of(&viewport["frame"], "sheets.json viewports[].frame"); + } + } + let from_manifest = box_of(&manifest["sheets"][0]["rect"], "manifest.sheets[0].rect"); + let from_sheets = box_of(&sheets["sheets"][0]["rect"], "sheets.json sheets[0].rect"); + for (a, b) in from_manifest.iter().zip(from_sheets) { + assert!((a - b).abs() < 1e-3, "{a} vs {b}"); + } +} + +/// Three paper layouts whose names all sanitise to the same directory +/// string: two three-syllable Hangul names and an empty one. Each has its +/// own paper block holding one line of its own length, and its own limits. +fn colliding_layout_names() -> uncad::CadDatabase { + let mut db = model_space(vec![line(0x10, 10.0, 10.0, 100.0, 50.0)]); + for (n, name) in ["\u{d3c9}\u{ba74}\u{b3c4}", "\u{c785}\u{ba74}\u{b3c4}", ""] + .into_iter() + .enumerate() + { + let block = if n == 0 { + "*Paper_Space".to_string() + } else { + format!("*Paper_Space{}", n - 1) + }; + let width = 100.0 + 40.0 * n as f64; + let paper = vec![line(0x20 + n as u64, 10.0, 10.0, width - 10.0, 60.0)]; + db.entities.extend(paper.iter().cloned()); + db.tables.block_records.insert( + block.clone(), + BlockRecord { + name: block.clone(), + entities: paper, + }, + ); + db.tables.layouts.insert( + name.to_string(), + LayoutRecord { + name: name.to_string(), + tab_order: n as i32 + 1, + block_name: Ref::Resolved(block), + limits_min: Point2D { x: 0.0, y: 0.0 }, + limits_max: Point2D { x: width, y: 80.0 }, + plot_settings: PlotSettings { + paper_name: String::new(), + paper_width: 0.0, + paper_height: 0.0, + margin_left: 0.0, + margin_bottom: 0.0, + margin_right: 0.0, + margin_top: 0.0, + plot_origin: Point2D::default(), + paper_units: None, + rotation: None, + scale_numerator: 0.0, + scale_denominator: 0.0, + }, + }, + ); + } + db +} + +#[test] +fn layouts_whose_names_collide_get_a_sheet_image_each() { + let tmp = TempDir::new("collide"); + let report = export_db(&colliding_layout_names(), &tmp.0, &one_level()).expect("exports"); + assert_eq!(report.sheets.len(), 3); + let paths: Vec<&str> = report + .sheets + .iter() + .map(|s| s.overview.png.as_str()) + .collect(); + assert_eq!( + paths, + [ + "sheets/___/overview.png", + "sheets/____2/overview.png", + "sheets/sheet/overview.png" + ], + "the suffix goes to the later tab, the empty name to the placeholder" + ); + let manifest = read_json(&tmp.0.join("manifest.json")); + let files = manifest["files"].as_array().unwrap(); + let listed: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect(); + let unique: std::collections::BTreeSet<&str> = listed.iter().copied().collect(); + assert_eq!(listed.len(), unique.len(), "{listed:?}"); + let mut images: Vec> = Vec::new(); + for sheet in &report.sheets { + let bytes = std::fs::read(tmp.0.join(&sheet.overview.png)).expect("the sheet image"); + assert!(!images.contains(&bytes), "{} repeats", sheet.overview.png); + images.push(bytes); + } + let sheets = read_json(&tmp.0.join("sheets.json")); + let from_json: Vec<&str> = sheets["sheets"] + .as_array() + .unwrap() + .iter() + .map(|s| s["overview"]["png"].as_str().unwrap()) + .collect(); + assert_eq!(from_json, paths); +} + +/// A composited viewport's published map, applied to a model point. +fn to_paper(m: &[f64; 6], x: f64, y: f64) -> [f64; 2] { + [m[0] * x + m[1] * y + m[2], m[3] * x + m[4] * y + m[5]] +} + +#[test] +fn the_model_is_composited_through_a_twisted_viewport() { + let tmp = TempDir::new("twisted"); + let report = + export_path(&fixture("twisted_viewport_r2000.dxf"), &tmp.0, &one_level()).expect("exports"); + assert_eq!(report.sheets.len(), 1, "{:?}", report.sheets); + let sheet = &report.sheets[0]; + assert_eq!(sheet.viewports.len(), 1); + let vp = &sheet.viewports[0]; + assert!(vp.composited && !vp.overall && vp.on); + assert!((vp.scale.unwrap() - 2.0).abs() < 1e-9); + assert!((vp.twist_deg - 30.0).abs() < 1e-9); + // The frame's corners in the model (ezdxf's convention): the view + // centre un-rotated by the twist, the window 100 x 60 model units. + let window = vp.model_window.expect("a stored view"); + for (got, (x, y)) in window.iter().zip([ + (-2.5, -4.330), + (84.103, -54.330), + (114.103, -2.369), + (27.5, 47.631), + ]) { + assert!( + (got[0] - x).abs() < 1e-3 && (got[1] - y).abs() < 1e-3, + "{got:?} vs ({x}, {y})" + ); + } + let m = vp.model_to_paper.expect("the map it was drawn through"); + // The model origin lands at (50, 50) on the paper, and (100, 50) at + // (173.205, 236.603): scale 2, turned 30 degrees. + let o = to_paper(&m, 0.0, 0.0); + assert!( + (o[0] - 50.0).abs() < 1e-9 && (o[1] - 50.0).abs() < 1e-9, + "{o:?}" + ); + let e = to_paper(&m, 100.0, 50.0); + assert!( + (e[0] - 173.205).abs() < 1e-3 && (e[1] - 236.603).abs() < 1e-3, + "{e:?}" + ); + // And the image shows it: ink on the composited line, none in the + // frame's lower-right quarter. + let image = Sheet::read(&tmp.0, sheet, 128); + let on_line = to_paper(&m, 40.0, 20.0); + assert!( + image.inked_near(on_line[0], on_line[1], 1), + "no ink at the composited line {on_line:?}" + ); + let (bx, by) = image.at(220.0, 70.0); + assert!(!image.dark_at(bx, by), "unexpected ink at ({bx}, {by})"); +} + +#[test] +fn a_plot_origin_moves_the_sheet_and_the_limits_are_taken_first() { + // ANSI B 17 x 11 in unrotated, margins (0.25, 0.75, 0.25, 0.75) in, + // plot origin (-0.25, -0.5) in: the sheet runs from (0, -0.25) to + // (17, 10.75), which the file's LIMMIN/LIMMAX say too. + let tmp = TempDir::new("plot_origin"); + let report = + export_path(&fixture("plot_origin_r2000.dxf"), &tmp.0, &one_level()).expect("exports"); + assert_eq!(report.sheets.len(), 1); + let sheet = &report.sheets[0]; + assert_eq!( + (sheet.name.as_str(), sheet.units.as_str()), + ("Layout1", "in") + ); + assert_eq!(sheet.rect_source, "layout_limits"); + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + assert!( + close(sheet.rect.min_x, 0.0) + && close(sheet.rect.min_y, -0.25) + && close(sheet.rect.max_x, 17.0) + && close(sheet.rect.max_y, 10.75), + "{:?}", + sheet.rect + ); + assert_eq!(sheet.viewports.len(), 1); + assert!(sheet.viewports[0].composited); + assert!((sheet.viewports[0].scale.unwrap() - 0.2).abs() < 1e-9); + // The border's top edge (y = 10.5) is drawn, and the blank strip + // between it and the paper's top edge stays blank. + let image = Sheet::read(&tmp.0, sheet, 128); + let (tx, ty) = image.at(4.0, 10.5); + let hit = (0..3).any(|dy| image.dark_at(tx, (ty + dy).saturating_sub(1).min(image.height - 1))); + assert!(hit, "no ink on the border's top edge at ({tx}, {ty})"); + let (gx, gy) = image.at(4.0, 10.65); + assert!( + !image.dark_at(gx, gy), + "ink above the border at ({gx}, {gy})" + ); +} + +#[test] +fn a_paper_hatch_keeps_its_own_pattern_on_the_composited_sheet() { + // Horizontal lines 4 units apart over paper (10,10)-(40,30), vertical + // lines 2 units apart in the model: the paper hatch must be filled with + // its own pattern, not the model's. + let tmp = TempDir::new("hatched"); + let report = + export_path(&fixture("hatched_viewport_r2000.dxf"), &tmp.0, &one_level()).expect("exports"); + let sheet = &report.sheets[0]; + assert!(sheet.viewports[0].composited); + let image = Sheet::read(&tmp.0, sheet, 160); + let (x0, y_top) = image.at(12.0, 28.0); + let (x1, y_bottom) = image.at(38.0, 12.0); + let fractions: Vec = (y_top..y_bottom) + .map(|y| (x0..x1).filter(|&x| image.dark_at(x, y)).count() as f64 / (x1 - x0) as f64) + .collect(); + let fullest = fractions.iter().cloned().fold(0.0, f64::max); + let emptiest = fractions.iter().cloned().fold(1.0, f64::min); + assert!(fullest > 0.9, "no horizontal pattern line: {fractions:?}"); + assert!( + emptiest < 0.05, + "no empty row between the lines: {fractions:?}" + ); + let full_rows = fractions.iter().filter(|f| **f > 0.9).count(); + assert!((3..=10).contains(&full_rows), "{full_rows} full rows"); +} + +/// Paper space as a parsed file has it: the entities at the top level +/// beside model space's, and in the `*Paper_Space` block record. +fn add_paper_space(db: &mut uncad::CadDatabase, entities: Vec) { + db.entities.extend(entities.iter().cloned()); + db.tables.block_records.insert( + "*Paper_Space".into(), + BlockRecord { + name: "*Paper_Space".into(), + entities, + }, + ); +} + +#[test] +fn a_paper_layout_with_no_usable_rectangle_is_skipped_not_fatal() { + // A paper space holding nothing but one POINT, and no LAYOUT objects at + // all -- the R13/R14 / OBJECTS-less DXF shape: the paper block is drawn + // as a sheet of its own, framed on its entities, and a single point is + // no rectangle. + let mut db = model_space(vec![line(0x24, 0.0, 0.0, 100.0, 50.0)]); + add_paper_space( + &mut db, + vec![Entity::Point(PointEntity { + common: common(0x2A), + position: p3(100.0, 100.0), + })], + ); + let tmp = TempDir::new("paper_point"); + let report = export_db(&db, &tmp.0, &one_level()).expect("a degenerate sheet is not fatal"); + assert!(report.sheets.is_empty(), "{:?}", report.sheets); + assert_eq!(report.counts.sheets, 0); + let warning = report + .warnings + .iter() + .find(|w| w.starts_with("UnusableSheet")) + .unwrap_or_else(|| panic!("{:?}", report.warnings)); + // The fallback names the layout after its block, without the star. + assert!( + warning.contains("Paper_Space") && warning.contains("entities"), + "{warning}" + ); + assert!(tmp.0.join("manifest.json").exists()); + assert!(!tmp.0.join("sheets").exists()); + let report_json = read_json(&tmp.0.join("report.json")); + assert!(report_json["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w.as_str().unwrap().starts_with("UnusableSheet"))); +} + +#[test] +fn a_paper_block_without_a_layout_is_still_a_sheet() { + // The same shape with a paper border instead of a point: the sheet is + // the border's extent, and the model is not composited (there is no + // viewport to show it through). + let mut db = model_space(vec![line(0x24, 0.0, 0.0, 100.0, 50.0)]); + add_paper_space( + &mut db, + vec![lwpolyline( + 0x30, + &[(0.0, 0.0), (420.0, 0.0), (420.0, 297.0), (0.0, 297.0)], + &[], + true, + )], + ); + let tmp = TempDir::new("paper_border"); + let report = export_db(&db, &tmp.0, &one_level()).expect("exports"); + assert_eq!(report.sheets.len(), 1, "{:?}", report.warnings); + let sheet = &report.sheets[0]; + assert_eq!(sheet.name, "Paper_Space"); + assert_eq!(sheet.rect_source, "entities"); + assert_eq!(sheet.entities, 1); + assert!(sheet.viewports.is_empty() && sheet.plot.is_none()); + assert!((sheet.rect.max_x - 420.0).abs() < 1e-9 && (sheet.rect.max_y - 297.0).abs() < 1e-9); +} + +#[test] +fn only_on_plan_viewports_composite_and_a_hidden_border_still_does() { + let tmp = TempDir::new("states"); + let report = + export_path(&fixture("viewport_states_r2000.dxf"), &tmp.0, &one_level()).expect("exports"); + assert_eq!(report.sheets.len(), 2, "{:?}", report.sheets); + let sheet = &report.sheets[0]; + assert_eq!((sheet.name.as_str(), sheet.tab_order), ("Layout1", 1)); + let states: Vec<(Option<&str>, Option, bool, bool)> = sheet + .viewports + .iter() + .map(|v| (v.handle.as_deref(), v.viewport_id, v.on, v.composited)) + .collect(); + assert_eq!( + states, + [ + // on, plan, layer 0 + (Some("2A"), Some(2), true, true), + // on, plan, on the frozen layer VPFROZEN: still composited + (Some("2D"), Some(3), true, true), + // off + (Some("2E"), Some(4), false, false), + // VIEWDIR (1,1,1): not a plan view + (Some("2F"), Some(5), true, false), + ] + ); + for v in &sheet.viewports { + assert!(!v.overall); + assert!((v.scale.unwrap() - 2.0).abs() < 1e-9, "{:?}", v.handle); + } + // Three of the four borders are drawn: 2D's is on a frozen layer. + assert_eq!(sheet.entities, 3); + let image = Sheet::read(&tmp.0, sheet, 128); + for (handle, cx, cy, composited) in [ + ("2A", 50.0, 50.0, true), + ("2D", 50.0, 120.0, true), + ("2E", 50.0, 190.0, false), + ("2F", 140.0, 50.0, false), + ] { + assert_eq!( + image.inked_near(cx, cy, 2), + composited, + "{handle}: ink at the frame centre ({cx}, {cy})" + ); + } + for (handle, cx, cy, border) in [ + ("2A", 50.0, 70.0, true), + ("2D", 50.0, 140.0, false), + ("2E", 50.0, 210.0, true), + ("2F", 140.0, 70.0, true), + ] { + let inked = image.inked_along(cy, cx - 25.0, cx + 25.0); + assert_eq!( + inked > 0, + border, + "{handle}: {inked} inked pixels at y = {cy}" + ); + } + // The sheet rectangles follow the rotation and the paper unit: A4 + // upside down in mm, and ANSI B turned a quarter in inches -- both from + // the layouts' limits -- and the second layout's empty paper block is a + // sheet all the same. + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + for (sheet, unit, want) in [ + (&report.sheets[0], "mm", (-10.0, -20.0, 200.0, 277.0)), + (&report.sheets[1], "in", (-0.25, -0.25, 10.75, 16.75)), + ] { + assert_eq!(sheet.units, unit); + assert_eq!(sheet.rect_source, "layout_limits"); + assert!( + close(sheet.rect.min_x, want.0) + && close(sheet.rect.min_y, want.1) + && close(sheet.rect.max_x, want.2) + && close(sheet.rect.max_y, want.3), + "{}: {:?}", + sheet.name, + sheet.rect + ); + } + assert_eq!( + (report.sheets[1].entities, report.sheets[1].viewports.len()), + (0, 0) + ); +} + +#[test] +fn an_infinite_line_in_model_space_is_drawn_inside_the_viewport_and_nowhere_else() { + // A RAY and an XLINE through the middle of the model window reach the + // viewport's whole frame and nothing outside it. + use uncad_model::model::RayEntity; + let ink = |db: &uncad::CadDatabase, header: &uncad::Header, name: &str| -> (usize, usize) { + let tmp = TempDir::new(name); + let report = + uncad_export::export_package(db, Some(header), &tmp.0, &one_level()).expect("exports"); + let sheet = Sheet::read(&tmp.0, report.sheets.first().expect("one sheet"), 128); + // The viewport's frame is 200 x 120 paper units about (150, 100); + // its border, and the model line that starts exactly on it, live in + // a two-unit band that counts as neither side. + (0..sheet.height) + .flat_map(|y| (0..sheet.width).map(move |x| (x, y))) + .filter(|&(x, y)| sheet.dark_at(x, y)) + .fold((0, 0), |(inside, outside), (x, y)| { + let wx = sheet.min_x + x as f64 / sheet.ppu; + let wy = sheet.max_y - y as f64 / sheet.ppu; + if (52.0..=248.0).contains(&wx) && (42.0..=158.0).contains(&wy) { + (inside + 1, outside) + } else if (48.0..=252.0).contains(&wx) && (38.0..=162.0).contains(&wy) { + (inside, outside) + } else { + (inside, outside + 1) + } + }) + }; + let (plain, header) = + uncad::parse_with_header(fixture("twisted_viewport_r2000.dxf")).expect("parses"); + let mut with_lines = plain.clone(); + for (id, vector, ray) in [ + ( + 0x2F00u64, + uncad_model::Point3D { + x: 1.0, + y: 0.5, + z: 0.0, + }, + false, + ), + ( + 0x3F00, + uncad_model::Point3D { + x: 0.0, + y: 1.0, + z: 0.0, + }, + true, + ), + ] { + let line = RayEntity { + common: common(id), + point: p3(50.0, 25.0), + vector, + }; + let entity = if ray { + Entity::Ray(line) + } else { + Entity::XLine(line) + }; + with_lines + .tables + .block_records + .get_mut("*Model_Space") + .expect("the model block") + .entities + .push(entity.clone()); + with_lines.entities.push(entity); + } + let (inside_plain, outside_plain) = ink(&plain, &header, "no_construction_lines"); + let (inside_lines, outside_lines) = ink(&with_lines, &header, "construction_lines"); + assert!( + inside_lines > inside_plain + 100, + "{inside_plain} -> {inside_lines} pixels inside" + ); + assert_eq!(outside_plain, outside_lines, "a line escaped the frame"); +} + +#[test] +fn a_sheet_states_the_model_to_paper_mapping_in_the_fields_it_publishes() { + // The fixture's viewport: a frame 200 x 120 paper units centred at + // (150, 120), showing a model window of VIEWSIZE 60 centred at (50, + // 25): scale 2, window (0, -5) .. (100, 55). + let tmp = TempDir::new("model_to_paper"); + let report = + export_path(&fixture("title_block_r2000.dxf"), &tmp.0, &one_level()).expect("exports"); + let sheets = read_json(&tmp.0.join("sheets.json")); + let stated = sheets["model_to_paper"].as_str().expect("the mapping"); + for field in [ + "model_to_paper", + "model_window", + "frame", + "scale", + "twist_deg", + "world_to_px", + ] { + assert!(stated.contains(field), "{stated}"); + } + let sheet = &report.sheets[0]; + let vp = sheet + .viewports + .iter() + .find(|v| v.composited) + .expect("a composited viewport"); + assert!((vp.scale.unwrap() - 2.0).abs() < 1e-9); + assert_eq!(vp.twist_deg, 0.0); + let frame = [ + vp.frame.min_x, + vp.frame.min_y, + vp.frame.max_x, + vp.frame.max_y, + ]; + assert_eq!(frame, [50.0, 60.0, 250.0, 180.0]); + let window = vp.model_window.expect("a model window"); + assert_eq!( + window, + [[0.0, -5.0], [100.0, -5.0], [100.0, 55.0], [0.0, 55.0]] + ); + // Both statements of the mapping put the window's corners on the + // frame's, in the order the file names them. + let m = vp.model_to_paper.expect("the map"); + let by_prose = |p: [f64; 2]| { + [ + frame[0] + (p[0] - window[0][0]) * 2.0, + frame[1] + (p[1] - window[0][1]) * 2.0, + ] + }; + for (corner, want) in window.iter().zip([ + [frame[0], frame[1]], + [frame[2], frame[1]], + [frame[2], frame[3]], + [frame[0], frame[3]], + ]) { + for got in [by_prose(*corner), to_paper(&m, corner[0], corner[1])] { + assert!( + (got[0] - want[0]).abs() < 1e-9 && (got[1] - want[1]).abs() < 1e-9, + "{corner:?} -> {got:?}, not {want:?}" + ); + } + } + // And it lands on the ink: the model LINE runs (0,0) -> (100,50). + let image = Sheet::read(&tmp.0, sheet, 128); + for t in [0.25, 0.5, 0.75] { + let paper = to_paper(&m, 100.0 * t, 50.0 * t); + assert!( + image.inked_near(paper[0], paper[1], 2), + "nothing at {paper:?}" + ); + } + let off = to_paper(&m, 10.0, 45.0); + assert!(!image.inked_near(off[0], off[1], 2), "ink at {off:?}"); +} + +#[test] +fn the_title_and_the_title_block_are_records_on_their_sheet() { + // The fixture's model space holds one LINE and no text; its title is + // on the paper, and so is a title block INSERT holding the sheet + // number. + let tmp = TempDir::new("paper_text"); + export_path( + &fixture("title_block_r2000.dxf"), + &tmp.0, + &ExportOptions::default(), + ) + .expect("exports"); + let texts = records(&tmp.0, "texts"); + assert_eq!(texts.len(), 2, "{texts:?}"); + let title = by_handle(&texts, "25"); + assert_eq!(title["id"], "37"); + assert_eq!(title["text"], "GARDEN PAVILION"); + assert_eq!(title["space"], "paper"); + assert_eq!(title["sheet"], "Layout1"); + assert!(title["tiles"].as_array().unwrap().is_empty()); + // The text inside the title block, through the INSERT: id + // `/`. + let sheet_no = by_handle(&texts, "42"); + assert_eq!(sheet_no["id"], format!("{}/{}", 0x26, 0x42)); + assert_eq!(sheet_no["text"], "SHEET 1 OF 2"); + assert_eq!(sheet_no["space"], "paper"); + let strings = read_json(&tmp.0.join("strings.json")); + assert_eq!(strings["strings"]["garden pavilion"][0], "37"); + assert_eq!(strings["strings"]["sheet 1 of 2"][0], sheet_no["id"]); + // The pixel box is on the sheet image and nowhere else, where the + // fixture's own numbers put it: the title anchored at (150, 20) in + // paper units, 8 units tall. + let sheets = read_json(&tmp.0.join("sheets.json")); + let image = &sheets["sheets"][0]["overview"]; + let ppu = image["ppu"].as_f64().unwrap(); + let min_x = image["world"][0].as_f64().unwrap(); + let max_y = image["world"][3].as_f64().unwrap(); + let px = title["px"]["sheet:Layout1"].as_array().unwrap(); + let (x0, y0, x1, y1) = ( + px[0].as_f64().unwrap(), + px[1].as_f64().unwrap(), + px[2].as_f64().unwrap(), + px[3].as_f64().unwrap(), + ); + assert!((x0 - (150.0 - min_x) * ppu).abs() <= 4.0, "{x0}"); + assert!((y1 - (max_y - 20.0) * ppu).abs() <= 4.0, "{y1}"); + let (w, h) = (x1 - x0, y1 - y0); + assert!(h > 0.4 * 8.0 * ppu && h < 1.5 * 8.0 * ppu, "{h}"); + assert!(w > h, "{w}x{h}"); + assert_eq!(title["px"].as_object().unwrap().len(), 1); + let manifest = read_json(&tmp.0.join("manifest.json")); + assert_eq!(manifest["counts"]["texts"], 2); + assert_eq!(manifest["counts"]["texts_paper"], 2); + assert_eq!(manifest["capabilities"]["paper_text"], "indexed"); + assert_eq!(manifest["sheets"][0]["texts"], 2); +} From bf0c43726609e1282aa2d76e07fb9b2a19485fbd Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 20:02:42 +0900 Subject: [PATCH 46/54] Write the LLM/VLM package from the command line: uncad export `uncad export -o

` parses the drawing with its header and hands it to uncad-export, with the profile, the pyramid depth and tile budget, the sheet images and the SVG copy as options. An option the subcommand does not know is refused by name instead of ignored. Ported-from: 22d6677, 44b131e Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + crates/uncad-cli/Cargo.toml | 2 + crates/uncad-cli/src/main.rs | 93 +++++++++++++++++++ .../uncad-cli/tests/documented_invocations.rs | 34 +++++++ 4 files changed, 133 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a064e88..ec5d226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Notable changes to this project are recorded here. The format follows ### Added +- `uncad export -o `: the CLI writes the LLM/VLM package through the new + `uncad-export` crate (`--profile`, `--max-levels`, `--max-tiles`, `--no-sheets`, + `--svg`); an option the subcommand does not know is an error. + - `libredwg-sys` reads a drawing from memory (`uncad_dwg_read_bytes`, `uncad_dxf_read_bytes`) -- LibreDWG's own file readers `fopen()` a byte string the MSVC runtime reads in the ANSI code page, so a non-ASCII path fails on Windows -- and diff --git a/crates/uncad-cli/Cargo.toml b/crates/uncad-cli/Cargo.toml index 5ea79de..55a222a 100644 --- a/crates/uncad-cli/Cargo.toml +++ b/crates/uncad-cli/Cargo.toml @@ -22,6 +22,8 @@ path = "src/main.rs" uncad = { path = "../uncad", version = "0.2.0" } # SVG/PNG output. A version dependency, never a path. iron-render-cad = "0.1.0" +# The LLM/VLM package behind `uncad export`. +uncad-export = { path = "../uncad-export", version = "0.1.0" } [dev-dependencies] serde_json = "1.0.151" diff --git a/crates/uncad-cli/src/main.rs b/crates/uncad-cli/src/main.rs index 93a7346..498c700 100644 --- a/crates/uncad-cli/src/main.rs +++ b/crates/uncad-cli/src/main.rs @@ -18,6 +18,7 @@ Usage: uncad -o export the parsed model (entities + tables) uncad -o render to SVG uncad -o render to PNG (rasterized from the SVG) + uncad export -o write the LLM/VLM package (images + JSON) JSON options: --pretty indented, multi-line JSON (default: one line) @@ -34,6 +35,13 @@ PNG options: --scale multiplies the SVG viewBox size (default: 1.0, e.g. 2.0 for twice the resolution) +Export options (uncad export): + --profile claude (default), claude-hires, openai-patch + --max-levels deepest tile level (default 5; 0 = overview only) + --max-tiles tile budget (default 400) + --no-sheets skip the paper-layout sheet images + --svg also write drawing.svg + Examples: uncad drawing.dwg uncad drawing.dwg -o drawing.json --pretty @@ -93,6 +101,15 @@ fn parse_args(argv: &[String]) -> Args { fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); + if argv.first().map(String::as_str) == Some("export") { + return match run_export(&argv[1..]) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("error: {message}"); + ExitCode::FAILURE + } + }; + } let args = parse_args(&argv); if args.help || args.input.is_none() { @@ -256,3 +273,79 @@ fn print_summary(input: &str, db: &CadDatabase) { println!(" {type_name}: {count}"); } } + +/// `uncad export -o [options]`: the LLM/VLM package through +/// `uncad-export`. An option this subcommand does not know is an error, not +/// something silently ignored. +fn run_export(argv: &[String]) -> Result<(), String> { + let mut input: Option<&str> = None; + let mut output: Option<&str> = None; + let mut options = uncad_export::ExportOptions::default(); + let mut i = 0; + let value = |i: usize, flag: &str| -> Result<&str, String> { + argv.get(i + 1) + .map(String::as_str) + .ok_or_else(|| format!("{flag} needs a value")) + }; + let count = |v: &str, flag: &str| -> Result { + v.parse::() + .map_err(|_| format!("{flag} takes a whole number, not '{v}'")) + }; + while i < argv.len() { + match argv[i].as_str() { + "-o" | "--output" => { + output = Some(value(i, "-o")?); + i += 1; + } + "--profile" => { + let name = value(i, "--profile")?; + options.profile = uncad_export::Profile::by_name(name) + .ok_or_else(|| format!("unknown profile '{name}'"))?; + i += 1; + } + "--max-levels" => { + options.max_levels = count(value(i, "--max-levels")?, "--max-levels")? as u32; + i += 1; + } + "--max-tiles" => { + options.max_tiles = count(value(i, "--max-tiles")?, "--max-tiles")?; + i += 1; + } + "--no-sheets" => options.sheets = false, + "--svg" => options.svg = true, + "-h" | "--help" => { + eprintln!("{USAGE}"); + return Ok(()); + } + flag if flag.starts_with('-') => { + return Err(format!("unknown option '{flag}' for uncad export")); + } + positional => { + if let Some(first) = input { + return Err(format!( + "unexpected argument '{positional}': the input is already '{first}'" + )); + } + input = Some(positional); + } + } + i += 1; + } + let input = input.ok_or("uncad export needs an input drawing")?; + let output = output.ok_or("uncad export needs -o ")?; + let report = uncad_export::export_file(Path::new(input), Path::new(output), &options) + .map_err(|e| e.to_string())?; + for warning in &report.warnings { + eprintln!("warning: {warning}"); + } + println!( + "wrote: {} ({} files; overview {}x{} px; {} frames; {} sheets)", + report.dir.display(), + report.files.len(), + report.overview.px[0], + report.overview.px[1], + report.frames.len(), + report.sheets.len() + ); + Ok(()) +} diff --git a/crates/uncad-cli/tests/documented_invocations.rs b/crates/uncad-cli/tests/documented_invocations.rs index 7c271e3..e31440c 100644 --- a/crates/uncad-cli/tests/documented_invocations.rs +++ b/crates/uncad-cli/tests/documented_invocations.rs @@ -427,3 +427,37 @@ fn no_trim_keeps_the_outlier_inside_the_viewbox() { "--no-trim should keep the outlier inside the viewBox: {vb_untrimmed}" ); } + +/// `uncad export -o ` writes a finished package (its +/// manifest is the last file written) and refuses an option it does not +/// know rather than ignoring it. +#[test] +fn export_writes_a_package_and_refuses_unknown_options() { + let input = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../lib/libredwg/test/test-data/example_2000.dwg" + ); + let dir = std::env::temp_dir().join(format!("uncad-cli-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let out = std::process::Command::new(EXE) + .args(["export", input, "-o"]) + .arg(&dir) + .args(["--max-levels", "0"]) + .output() + .expect("the binary runs"); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(dir.join("manifest.json").is_file()); + assert!(dir.join("overview.png").is_file()); + let _ = std::fs::remove_dir_all(&dir); + + let bad = std::process::Command::new(EXE) + .args(["export", input, "--bogus"]) + .output() + .expect("the binary runs"); + assert!(!bad.status.success()); + assert!(String::from_utf8_lossy(&bad.stderr).contains("unknown option '--bogus'")); +} From b3376e8a0ac070a4be7611fe8103baa949f3e3e0 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 20:08:14 +0900 Subject: [PATCH 47/54] Answer --version, add --include-hidden, and refuse what the CLI does not know The plain command used to skip an unknown option, a second input and an option missing its value in silence; each is now an error that names it. `--version` answers on both commands, and `--include-hidden` passes the renderer's include_hidden through. Ported-from: 22d6677, 44b131e Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ crates/uncad-cli/src/main.rs | 64 +++++++++++++------ .../uncad-cli/tests/documented_invocations.rs | 62 ++++++++++++++++++ 3 files changed, 113 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec5d226..becf18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Notable changes to this project are recorded here. The format follows ### Added +- `uncad --version` / `-V` (both commands) and `--include-hidden` (draw what the layer + rules and the invisible flag leave out). The plain command now refuses an option it + does not know, a second input and an option without its value, naming what was wrong, + where it used to ignore them. + - `uncad export -o `: the CLI writes the LLM/VLM package through the new `uncad-export` crate (`--profile`, `--max-levels`, `--max-tiles`, `--no-sheets`, `--svg`); an option the subcommand does not know is an error. diff --git a/crates/uncad-cli/src/main.rs b/crates/uncad-cli/src/main.rs index 498c700..d1a57aa 100644 --- a/crates/uncad-cli/src/main.rs +++ b/crates/uncad-cli/src/main.rs @@ -24,6 +24,8 @@ JSON options: --pretty indented, multi-line JSON (default: one line) SVG/PNG options: + --include-hidden draw entities hidden by their layer or flag (off, + frozen, non-plotting, DEFPOINTS, invisible) --space which space to render (default: model) model = the drawing itself paper = sheet borders and title blocks @@ -42,6 +44,10 @@ Export options (uncad export): --no-sheets skip the paper-layout sheet images --svg also write drawing.svg +Both commands: + -h, --help this text + -V, --version print the version + Examples: uncad drawing.dwg uncad drawing.dwg -o drawing.json --pretty @@ -56,10 +62,11 @@ struct Args { outlier_trim: bool, scale: String, pretty: bool, + include_hidden: bool, help: bool, } -fn parse_args(argv: &[String]) -> Args { +fn parse_args(argv: &[String]) -> Result { let mut args = Args { input: None, output: None, @@ -67,40 +74,54 @@ fn parse_args(argv: &[String]) -> Args { outlier_trim: true, scale: "1".to_string(), pretty: false, + include_hidden: false, help: false, }; let mut i = 0; while i < argv.len() { match argv[i].as_str() { - "-o" | "--output" => { + "-o" | "--output" | "--space" | "--scale" => { + let flag = argv[i].as_str(); i += 1; - args.output = argv.get(i).cloned(); - } - "--space" => { - i += 1; - if let Some(v) = argv.get(i) { - args.space = v.clone(); + let value = argv + .get(i) + .cloned() + .ok_or_else(|| format!("{flag} needs a value"))?; + match flag { + "--space" => args.space = value, + "--scale" => args.scale = value, + _ => args.output = Some(value), } } "--no-trim" => args.outlier_trim = false, - "--scale" => { - i += 1; - if let Some(v) = argv.get(i) { - args.scale = v.clone(); - } - } "--pretty" => args.pretty = true, + "--include-hidden" => args.include_hidden = true, "-h" | "--help" => args.help = true, - other if args.input.is_none() => args.input = Some(other.to_string()), - _ => {} + // Answered by `main` before any parsing. + "-V" | "--version" => {} + flag if flag.starts_with('-') && flag.len() > 1 => { + return Err(format!("unknown option '{flag}' (see --help)")); + } + positional => { + if let Some(first) = &args.input { + return Err(format!( + "unexpected argument '{positional}': the input is already '{first}'" + )); + } + args.input = Some(positional.to_string()); + } } i += 1; } - args + Ok(args) } fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); + if argv.iter().any(|a| a == "-V" || a == "--version") { + println!("uncad {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } if argv.first().map(String::as_str) == Some("export") { return match run_export(&argv[1..]) { Ok(()) => ExitCode::SUCCESS, @@ -110,7 +131,13 @@ fn main() -> ExitCode { } }; } - let args = parse_args(&argv); + let args = match parse_args(&argv) { + Ok(args) => args, + Err(message) => { + eprintln!("error: {message}"); + return ExitCode::FAILURE; + } + }; if args.help || args.input.is_none() { eprintln!("{USAGE}"); @@ -234,6 +261,7 @@ fn svg_options(args: &Args) -> Result { } else { Crop::Everything }, + include_hidden: args.include_hidden, ..Default::default() }) } diff --git a/crates/uncad-cli/tests/documented_invocations.rs b/crates/uncad-cli/tests/documented_invocations.rs index e31440c..d15b93b 100644 --- a/crates/uncad-cli/tests/documented_invocations.rs +++ b/crates/uncad-cli/tests/documented_invocations.rs @@ -461,3 +461,65 @@ fn export_writes_a_package_and_refuses_unknown_options() { assert!(!bad.status.success()); assert!(String::from_utf8_lossy(&bad.stderr).contains("unknown option '--bogus'")); } + +/// `--version` answers on both commands; an option the plain command does +/// not know, a missing value and a second input are errors, not ignored. +#[test] +fn version_and_the_parsers_refusals() { + let run = |args: &[&str]| { + std::process::Command::new(EXE) + .args(args) + .output() + .expect("the binary runs") + }; + let v = run(&["--version"]); + assert!(v.status.success()); + assert_eq!( + String::from_utf8_lossy(&v.stdout).trim(), + format!("uncad {}", env!("CARGO_PKG_VERSION")) + ); + assert!(run(&["export", "--version"]).status.success()); + for (args, needle) in [ + (&["x.dwg", "--bogus"][..], "unknown option '--bogus'"), + (&["--bogus", "x.dwg"][..], "unknown option '--bogus'"), + (&["x.dwg", "y.dwg"][..], "unexpected argument 'y.dwg'"), + (&["x.dwg", "-o"][..], "-o needs a value"), + ] { + let out = run(args); + assert!(!out.status.success(), "{args:?} should fail"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains(needle), "{args:?}: {err}"); + } +} + +/// `--include-hidden` draws what the layer rules leave out, so the SVG of a +/// drawing with hidden entities grows. +#[test] +fn include_hidden_draws_more() { + let input = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../uncad/tests/fixtures/hidden_layers_r2000.dxf" + ); + let dir = std::env::temp_dir().join(format!("uncad-cli-hidden-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let render = |name: &str, extra: &[&str]| { + let path = dir.join(name); + let out = std::process::Command::new(EXE) + .arg(input) + .arg("-o") + .arg(&path) + .args(extra) + .output() + .expect("the binary runs"); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + std::fs::read_to_string(&path).unwrap() + }; + let plain = render("plain.svg", &[]); + let all = render("all.svg", &["--include-hidden"]); + assert!(all.len() > plain.len(), "{} vs {}", all.len(), plain.len()); + let _ = std::fs::remove_dir_all(&dir); +} From 9e3396391287245ceb390d895138aa7a9c2bea41 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 20:09:04 +0900 Subject: [PATCH 48/54] docs: say what uncad-export and `uncad export` are, and where they sit README: the package as a third output, the export entry point beside the parser and the renderer, the export subcommand, --include-hidden and --version, and the crate in the repository layout. ARCHITECTURE: the parser's current modules (text.rs, header.rs; hatch_color.rs is gone), the tests the parser now carries, and the export crate as the consumer that holds the derived values the model does not. Co-Authored-By: Claude Opus 5.5 --- README.md | 25 +++++++++++++++++++++++-- docs/ARCHITECTURE.md | 33 ++++++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3addba6..4b6dfc9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) An open-source Rust library that parses CAD files (DWG/DXF) into a model and -exports that model as JSON, SVG or PNG. Read-only: it does not write DWG or DXF, +exports that model as JSON, SVG or PNG -- and, through the `uncad-export` crate, +as a directory an LLM or VLM can read (overview and tile images, sheet images, +and JSON records that point into them). Read-only: it does not write DWG or DXF, and does not convert between them. ## Quick start @@ -33,6 +35,14 @@ std::fs::write("drawing.json", json)?; // Rendering is the iron-render-cad crate's (uncad-cli uses it): let result = iron_render_cad::to_svg(&db, iron_render_cad::ToSvgOptions::default()); std::fs::write("drawing.svg", result.svg)?; + +// The LLM/VLM package (the uncad-export crate; uncad-cli's `uncad export`): +let report = uncad_export::export_file( + std::path::Path::new("drawing.dwg"), + std::path::Path::new("drawing.pkg"), + &uncad_export::ExportOptions::default(), +)?; +println!("{} files, {} sheets", report.files.len(), report.sheets.len()); ``` ## CLI @@ -46,8 +56,16 @@ cargo run -p uncad-cli -- drawing.dwg -o drawing.png --scale 2 # rasterize at cargo run -p uncad-cli -- drawing.dwg -o drawing.svg --no-trim # keep outlying coordinates cargo run -p uncad-cli -- drawing.dwg -o sheet.svg --space paper # sheet borders / title blocks cargo run -p uncad-cli -- drawing.dwg -o all.svg --space all # every space in one document +cargo run -p uncad-cli -- drawing.dwg -o all.svg --include-hidden # also what layers hide +cargo run -p uncad-cli -- export drawing.dwg -o drawing.pkg # the LLM/VLM package +cargo run -p uncad-cli -- export drawing.dwg -o drawing.pkg --profile openai-patch --max-levels 2 +cargo run -p uncad-cli -- --version ``` +An option the CLI does not know is an error that names it, on both commands. +`uncad export` writes `manifest.json` last; start reading a package there (its +`README.txt` says the same in prose). + ## Scope 1. **DWG** — read through [LibreDWG](https://www.gnu.org/software/libredwg/) @@ -110,7 +128,10 @@ crates/ vendor-config/config.h stands in for autotools uncad/ the safe API: parse() / parse_bytes() -> uncad_model::CadDatabase, and the drawing's Header beside it from parse_with_header() - uncad-cli/ the CLI binary (uncad) + uncad-export/ the LLM/VLM package: export_file() / export_package() write a + directory of images and JSON records; bundles a Noto Sans KR + subset (OFL-1.1) so Korean text renders anywhere + uncad-cli/ the CLI binary (uncad), including `uncad export` crates/*/tests/ integration tests against the public API. crates/*/examples/ are manual-check tools, and #[cfg(test)] blocks inside src/*.rs are unit tests -- docs/ARCHITECTURE.md's "Test layout" says which diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c95092c..073013d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,16 +28,31 @@ crates/ uncad/ the safe API, layered: dynapi.rs (reflection helpers) -> convert.rs (raw Dwg_Data* -> uncad_model's Entity) -> table_convert.rs (LAYER/BLOCK_RECORD/DIMSTYLE/MLINESTYLE and - LAYOUT with its plot settings), with acis.rs - for 3DSOLID wireframes and hatch_color.rs for the gradient - stop colors the model carries. The model and its JSON form - are the uncad-model crate's; SVG/PNG rendering is the - iron-render-cad crate's (uncad-cli and this crate's tests use - it). Read-only: there is no DWG/DXF write path. - tests/ integration tests against the public API (dxf_pipeline.rs, - acis_sab.rs) + LAYOUT with its plot settings), with text.rs (TextDecoder: + code pages, R2007+ widths, diagnostics), header.rs (the + drawing's Header, returned beside the model by + parse_with_header) and acis.rs for 3DSOLID wireframes. The + model and its JSON form are the uncad-model crate's; SVG/PNG + rendering is the iron-render-cad crate's (uncad-cli, + uncad-export and this crate's tests use it). Read-only: there + is no DWG/DXF write path. + tests/ integration tests against the public API: the golden cases, + corpus sweep and second-reader comparison, R2007+ DXF twins, + code pages, header, layouts, layer state, polylines, + dimensions, the vendored patches, the fixtures in + tests/fixtures (authored by make_fixtures.py) examples/ dump.rs / blocks.rs -- manual checks - uncad-cli/ the CLI binary (uncad) + uncad-export/ the LLM/VLM package, a consumer of the parser and the + renderer: frame.rs (crop, padding, lattice, detached groups), + package/ (overview, tile pyramid, sheets, records, strings + index, shards, manifest, report), dimension.rs (display + strings and how far a value is trusted), geom.rs (lengths, + areas), text.rs (plain strings for records), fonts.rs (the + bundled Noto Sans KR subset, OFL-1.1, under fonts/). Derived + values live here because the model carries none. + tests/ the package end to end, tiles, sheets, dimensions, geometry, + and acceptance questions answered from a package alone + uncad-cli/ the CLI binary (uncad), including `uncad export` tests/ documented_invocations.rs -- every call README and --help advertise; release_invariants.rs -- the licence text and the vendored-patch notice every published crate has to carry From 1796a3ad0275b03259a7143e95f997a5b258c7c3 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 20:10:43 +0900 Subject: [PATCH 49/54] docs: bring over the LLM/VLM package design The package format uncad-export writes is specified in the design the feature work followed. Bring it over with a note on where each part now lives (uncad-export, the model, the renderer) and that its measured numbers were taken on the earlier monolithic layout. Ported-from: 85e66d1 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + docs/VLM_EXPORT_DESIGN.md | 806 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 808 insertions(+) create mode 100644 docs/VLM_EXPORT_DESIGN.md diff --git a/README.md b/README.md index 4b6dfc9..09f6074 100644 --- a/README.md +++ b/README.md @@ -150,3 +150,5 @@ Further reading: test layout, the FFI/bindgen boundary, thread safety, the entity model - [`docs/CAVEATS.md`](./docs/CAVEATS.md) — entity type coverage, known limitations and bugs, cross-platform notes +- [`docs/VLM_EXPORT_DESIGN.md`](./docs/VLM_EXPORT_DESIGN.md) — the LLM/VLM + package `uncad export` writes: its files, coordinates, tiling and crop rules diff --git a/docs/VLM_EXPORT_DESIGN.md b/docs/VLM_EXPORT_DESIGN.md new file mode 100644 index 0000000..1f4650d --- /dev/null +++ b/docs/VLM_EXPORT_DESIGN.md @@ -0,0 +1,806 @@ +# Design proposal: the LLM export package + +> **Where this lives now (2026-09-23).** This design was written and first +> implemented in the monolithic uncad of the `feat/0.3-readable` work (tag +> `archive/feat-0.3-readable`). The package it describes is now written by +> the `crates/uncad-export` crate on top of the three-repository split: +> the parser here (`uncad`), the model (`uncad-model`) and the renderer +> (`iron-render-cad`). The package format is the same; paths to modules in +> the text below (`crates/uncad/src/export.rs`, `crop.rs`, `svg.rs`, `png.rs`) +> refer to that earlier layout -- the export logic is in +> `crates/uncad-export/src/`, the crop rules in its `frame.rs`, and the +> rendering in `iron-render-cad`. Record ids now come from the model's +> entity ids. Section 10's status notes and measured numbers were taken on the +> earlier layout and have not been re-measured here. + + +Status: **proposal, implementation in progress** (2026-09-21; section 10 +records what has landed). It answers the question +investigated in [`VLM_INVESTIGATION.md`](./VLM_INVESTIGATION.md): how uncad +should change so that a drawing becomes a directory of PNG images plus JSON that +a vision-language model or an LLM can read, with exact numbers, overlapping +tiles and a defined crop boundary. Numbers quoted here trace to that document. + +The proposal went through three independent drafts, three scored reviews, a +synthesis, and three adversarial reviews plus a completeness check; the +corrections from that last round are folded in. + +## 1. Answers to the five questions + +**Package API and structure.** Delivery becomes a directory written by +`uncad export in.dwg --out dir/` (library: `CadDatabase::export`), tiered so an +agent opens the smallest file that answers its question: `manifest.json` -> +`drawing.json` -> one kind-specific shard (`geometry`, `texts`, `dimensions`, +`regions`, `blocks`, `strings`) -> one tile PNG with its JSON sidecar. Every +record carries an id (the entity handle), a world bounding box and its pixel box +in each image that shows it, so the agent moves between "what" (JSON) and +"where" (PNG) by id. `parse()` gains a `header`, and a parse-once `Renderer` +sits between the model and the images. The 0.2.0 API survives with additive +fields; two things break (section 8). + +**Image versus structured text.** "Image for where, JSON for what." VLMs read +text well but symbols badly, cannot count reliably and misread 12-20 % of +dimensions from pixels, so every number an agent may ask for -- lengths, areas, +dimension values, counts, attribute values, units -- is computed once in Rust +from DWG fields and written with an explicit unit and a confidence tag. Images +carry spatial context. SVG is a rendering intermediate and never an LLM input. +Volume is `unavailable`: a DWG stores only the ACIS stream. + +**Tiling.** Yes. One `usvg::Tree` per zoom level is rendered into 1092 x 1092 +tiles (39 x 39 patches of 28 px = 1521 Claude tokens, the largest square the +standard tier accepts untouched) with 224 px overlap (20.5 %), in parallel, +one root transform per tile. An overview plus a pyramid (z1, z2 complete; z3+ +only where text is still small) replaces the unbounded canvas; on-demand +windows cover the rest. Overlap duplicates are reconciled by record id, never by +box IoU. + +**Crop boundary.** A deterministic, reported rule (section 4): the overview +always covers the true extents of every visible entity (no cluster trim); +clustering only splits the drawing into "frames" that each get their own tile +pyramid; validated header `EXTMIN/EXTMAX` is an automatic candidate; every +image writes its world rectangle and both affines; everything excluded is +listed with a reason. Paper layouts crop to the PLOTSETTINGS sheet, one image +set per layout. + +**In (dwg|dwf) -> Out (directory).** Feasible now for DWG and DXF. `.dwf` is +refused with an explanation (no reader exists; the format has no CAD semantics +to extract); `.dwfx` could later yield a paper-only package under the same +schema from a separate crate. DXF input is flagged `input_fidelity: +"dxf-partial"` because LibreDWG's DXF reader is documented as incomplete. + +## 2. Output package layout + +> **This is the proposal, not a description of the output.** Lines tagged +> *(proposal)* below describe something `uncad export` does not write; section +> 10's "Where the package differs from this document" is the full list, +> measured against a real export. Everything untagged was checked against the +> `example_2000.dwg` package and is what the exporter writes today. + +``` +dir/ + README.txt reading order; entities.json and drawing.svg are tool inputs, not LLM inputs + manifest.json source{version,codepage,name}, units, profile, crop, frames[] (each with its own + levels[] and legibility), frames_dropped[], overview{png,px,world,both affines}, + legibility{target_px, per_frame[{frame, z_max, target_met, pyramid_complete, height_classes}]}, + counts, capabilities (every entry derived from the records), sheets[], svg_origin, generator, + legend, guidance, shard_index, warnings[], files[] (per-tile hashes live in tiles.json). + 17 KB to 90 KB on the nine drawings docs/EVAL.md measures, not the 12 KB this + proposal budgeted: files[] holds one entry per written file with its byte count + (89 for example_2000.dwg, 771 for example_2018.dxf) and is most of the weight + drawing.json header variables, units, layers[] (state flags, hex + rendered_hex, entity counts), + blocks[]: the block definitions {name, entity_count, count_by_layer, attrib_tags, anonymous, + instances, instance_ids} -- the table; the instances themselves are records in blocks.json + legibility{height_classes}, counts, capabilities, sheets[], svg_origin, generator, + guidance, shard_index, warnings[], files[] + (proposal: "per-tile hashes live in tiles.json" -- no hash of any file + is written anywhere in the package). + 17 KB to 90 KB on the nine drawings docs/EVAL.md measures, not the 12 KB this + proposal budgeted: files[] holds one entry per written file with its byte count + (89 for example_2000.dwg, 771 for example_2018.dxf) and is most of the weight + drawing.json header variables, units, counts, layers[] (state flags, entity counts) and + block definitions {name, entity_count} + (proposal: layers' hex + rendered_hex, blocks' xref/dynamic flags, and a + layouts[] array -- the paper layouts are in sheets.json instead) + overview.png fitted to the profile budget (claude: <= 1568 px edge and <= 1568 patches), opaque white + frames/f0/ primary frame: overview.png, tiles/z1..zN/rRR_cCC.png + .json sidecars, tiles.json + frames/f1/ ... secondary frames (detached clusters, scale groups), same layout + geometry.json every visible non-text entity as a summary record (type, layer, key points, length, bbox, tiles); + sharded per layer above the shard size + texts.json TEXT/MTEXT/ATTRIB/dimension labels: plain + raw, world bbox, tiles, px boxes. + `space` is model or paper; a paper-space text (the drawing's title, the title block) names its + `sheet`, is measured in that layout's paper units and carries that sheet image's pixel box + dimensions.json measured value, displayed string, unit, definition points, agreement + regions.json closed polygons: area, perimeter, centroid, labels; vertex lists only under --full (geometry ref otherwise) + blocks.json the INSERT instances {id, block, at, rot, scale, mirrored, attribs key/value}, records under + the same shard rule as the other kinds (the definitions are drawing.json's blocks[]) + strings.json normalized string -> [id] inverted index. An id is resolved through manifest.shard_index + (`first_key`/`last_key`), not through a map here: a map of every id would be a second copy + of the record files + tiles.json every frame/level/tile: the written ones with their png, sidecar, byte count and sha256, + the empty ones with a `reason` + sharded above the shard size by id range, not per layer as this proposal says + (geometry.001.json, geometry.002.json, ... each a contiguous run of ids) + texts.json TEXT/MTEXT/ATTRIB/dimension labels: plain + raw, world bbox, tiles, px boxes + dimensions.json measured value and its source, displayed string and its source, unit, the + definition point and a geometry object per kind + (proposal: agreement, and the rest of the DIMENSION record in section 3) + regions.json closed polygons: area, perimeter, centroid, labels, and `src`, the id of the + geometry record holding the vertices + (proposal: vertex lists under --full -- --full adds entities.json, not + region vertices) + blocks.json definitions {name, entity_count, instances, instance_ids, count_by_layer, + attrib_tags, anonymous} and instances {id, block, layer, at, rotation_deg, + scale, mirrored, attribs key/value, bbox, tiles, px} + (proposal: the definitions' dynamic and xref flags, and an array field) + strings.json normalized string -> [id] inverted index (id -> shard file is manifest.shard_index) + tiles.json every frame/level/tile {id, frame, z, row, col, png, sidecar, px, world, empty}, + including empty ones + (proposal: a reason per empty tile, and each tile's sha256 and byte count -- + manifest.files[] carries byte counts for written files, nothing carries hashes) + sheets.json + sheets//... 0.3.0: paper size, scale, viewport windows; 0.4.0: model composited per viewport + report.json excluded entities with reasons, unshaped characters, code-page fallbacks, dimension paths, timings + entities.json --full only: the whole model without the 0.2.0 duplication (block_records definition-only), + sharded by (layer, 2 MiB) + drawing.svg --svg only +``` + +Every JSON file follows one `--shard-kb` rule (default 96) and sorts records by +id, so `manifest.shard_index {file, kind, first_id, last_id, first_key, last_key, +count, bytes}` resolves any id to one file: ids are hex handles ordered by their +numeric value, so the bounds a consumer compares against are the numbers +`first_key`/`last_key`, not the strings (`"109B3"` sorts above a shard whose +`first_id` is `"3D2D"` while its handle is far below it). The id does not carry +its kind and one id can be in two (a closed polyline is a geometry record and a +region record), so the lookup runs over every kind. A typical question costs the manifest + +`strings.json` lookup + one shard + one sidecar + one tile (1521 tokens). The +manifest is the largest of the JSON reads and the one this proposal sized +wrongly: 16 728 bytes on `example_2000.dwg` and 89 532 on `example_2018.dxf`, +against the ~3.5k tokens estimated here. Token counts per file are still +estimated; measuring them with the vendor tokenizer is open (`docs/EVAL.md`, +section 4). + +## 3. JSON conventions and example records + +> **The example records below are the proposal, not the output.** Every one of +> them shows fields `uncad export` does not write; the tags mark which. The +> conventions above them hold, with the two exceptions noted. Section 10 lists +> the differences in one place, and a package's own files are the contract. + +- Every file starts with `"$schema": "uncad-package/1"`. A `units` block + `{name, insunits, to_mm | null, source, guess?}` follows it in + `manifest.json`, `drawing.json`, `texts.json`, `dimensions.json`, + `regions.json` and `blocks.json` -- but **not** in `strings.json`, + `tiles.json`, `sheets.json` or `report.json`, which hold no measured + quantity. Read units from `manifest.json`, which always has them. +- ids: upper-case hex handle; `"/"` through INSERTs; + `"/T"` for a dimension's cached text; `"#r3c4"` for MINSERT + cells. Image ids: `ov`, `f0/z2/r03_c05`, `sheet:Layout1`, `w:<8hex>`. +- Points are `[x, y]` arrays rounded to `max(LUPREC, 3)` decimals, derived + values to two more. One exception in 0.3.0: a DIMENSION's `geometry` object + carries its definition points as `{"x":..,"y":..,"z":..}` at full `f64` + precision, not as rounded `[x, y]` pairs. `LUPREC` is a display setting, so the decimals also have + a floor from the package's own scale: enough that one unit in the last place + is a thousandth of a pixel at the deepest level the package can reach + (0.3.0; without it every tile rectangle of a drawing a few thousandths of a + unit across printed the same numbers). Angles are in degrees; pixels are + integers; boxes are `[x0, y0, x1, y1]` in world units. +- `confidence` in `{exact, stored, estimated, unavailable}` with an optional + `why`, on the geometry, region and dimension records. A text record carries + `bbox_confidence` (`measured` / `estimated`) instead and a block instance + carries neither; `manifest.legend` says so, and enumerates this vocabulary, + `measurement_source`, `display_source`, what a region's `labels` are and how a + pixel box relates to its image. +- Every record lists `tiles: [...]` and `px: {"ov": [...], "f0/z1/r02_c00": [...]}`. + +LINE. `from`/`to` are the plan projection (every box and every pixel map in the +package is planar), but `length` is the length the *file* holds -- the 3D +distance -- so a line that rises does not publish its plan length as `exact`. +When it does rise, `length_plan` (the key POLYLINE_3D uses for the same +quantity) and `dz` are written beside it, and they are the only thing in an +otherwise planar record that says so; a line in the plane carries neither. +LINE. *(proposal: `space` -- a geometry record carries no space field; model +and paper are separate packages of images, and `drawing.json.counts` carries +`model_space`.)* + +```json +{"id":"2F3A","type":"LINE","layer":"A-WALL","space":"model","from":[120.5,48.0],"to":[240.5,48.0], + "length":120.0,"unit":"mm","confidence":"exact","bbox":[120.5,48.0,240.5,48.0],"tiles":["f0/z1/r02_c00"]} +``` + +```json +{"id":"2F3B","type":"LINE","layer":"A-ROOF","from":[0.0,50.0],"to":[300.0,50.0], + "length":500.0,"length_plan":300.0,"dz":400.0,"unit":"mm","confidence":"exact"} +``` + +Closed LWPOLYLINE with one 90-degree arc. The bulge on vertex i applies to the +Closed LWPOLYLINE with one 90-degree arc. *(proposal: `space`, `area_unit`, +`segments`, `centroid` and `region`. The record written today is `{id, type, +layer, closed, vfmt, vertices, perimeter, area, orientation, simple, +confidence, why, bbox, tiles, px}`; the per-segment breakdown, the centroid +and the back-reference to a region are not in it -- regions point at the +geometry, not the other way round.)* The bulge on vertex i applies to the +segment i -> i+1 (including the closing segment); bulge = tan(theta/4), positive += counter-clockwise. theta = 4 atan(b), r = c (1 + b^2) / (4 b), circular +segment area r^2/2 (theta - sin theta), signed by orientation. A polyline closed +by repeating its first vertex is deduplicated and marked `closed_by_repeat`. + +```json +{"id":"3B0","type":"LWPOLYLINE","layer":"A-FLOR","space":"model","closed":true,"vfmt":"[x,y,bulge]", + "vertices":[[0,0,0],[100,0,0.41421356],[100,50,0],[0,50,0]], + "perimeter":305.536,"area":5356.748,"area_unit":"mm2","orientation":"ccw","simple":true,"confidence":"exact", + "why":"shoelace 5000 + circular segment r^2/2(theta - sin theta) = 356.748", + "segments":[{"k":"line","len":100},{"k":"arc","len":55.536,"r":35.355,"center":[75,25],"sweep_deg":90}, + {"k":"line","len":100},{"k":"line","len":50}], + "centroid":[53.61,25.0],"bbox":[0,0,110.355,50],"region":"3B0","tiles":["f0/z1/r00_c00"]} +``` + +DIMENSION. *(proposal: `display_value`, `display_raw`, `agreement`, +`display_tolerance`, `style_used`, `user_text`, `from`/`to`, `dir_deg` and +`text_id`. What 0.3.0 writes instead is `{id, kind, layer, dimstyle, +measurement, measurement_source, measurement_from_points, delta, dimlfac, +unit, display, display_source, confidence, definition_point, geometry, +text_at, bbox, tiles, px}` -- `geometry` is a per-kind object (`{kind, +xline1, xline2}` for LINEAR/ALIGNED, and so on) that replaces `from`/`to`, +and `delta` replaces `agreement`: it is the stored measurement minus the one +recomputed from the definition points. The prose below about +`measurement_source`, `capabilities.dimension_values`, angular kinds and +`display_source` is accurate; the paragraphs on `display_tolerance` and +`agreement` describe fields that do not exist yet.)* + +```json +{"id":"3A1","kind":"LINEAR","layer":"A-DIMS","dimstyle":"ARCH-48", + "measurement":2.5,"measurement_source":"act_measurement","measurement_from_points":2.5,"delta":0.0,"unit":"in", + "dimlfac":1.0,"display_value":2.5,"display":"2 1/2\"","display_raw":"\\A1;2{\\H0.750000x;\\S1#2;}\"", + "display_source":"cached_block","agreement":"exact","display_tolerance":0.03125,"user_text":"", + "style_used":{"DIMLUNIT":4,"DIMDEC":4,"DIMZIN":0,"DIMPOST":"","DIMDSEP":".","DIMRND":0,"overrides_xdata":"not_decoded"}, + "from":[10,20],"to":[12.5,20],"dir_deg":0,"text_at":[11.25,24.4],"text_id":"3A1/T", + "bbox":[10,20,12.5,24.8],"tiles":["f0/z1/r01_c03"],"px":{"ov":[420,910,470,930],"f0/z1/r01_c03":[88,310,420,362]}} +``` + +- `measurement_source` in `{act_measurement, from_points, none}`, with + `confidence` following it: `stored` for the file's own `act_measurement`, + `exact` for a value recomputed from the definition points, `unavailable` for + neither. A stored value is refused -- and the definition points used instead + -- when it is not a measurement this dimension could have: `-1.0` exactly + (R13/R14's "not computed"), `0.0` on any kind but ORDINATE (how an R13/R14 + DXF, and any DXF without a group 42, leaves the field), a negative value on + any kind but ORDINATE, or one disagreeing with the definition points by more + than 1 % (the corpus's worst honest disagreement is 2.3e-7 relative). + ORDINATE keeps its sign, and may legitimately measure 0. +- `capabilities.dimension_values` in `{exact, computed, text_only, none}`: + `exact` when at least one dimension carries the file's own measurement, + `computed` when every value was recomputed from the definition points (an + R13/R14 drawing), `text_only` when there are dimensions but no values. +- Angular kinds (ANG3PT, ANG2LN): `act_measurement` is radians and is exported + in degrees with `unit: "deg"`; DIMLFAC does not apply; the sector is the one + containing `def_pt`; display follows DIMAUNIT/DIMADEC. +- `display_tolerance`: `0.5 x 10^-DIMDEC` for DIMLUNIT 1/2/3/6; for DIMLUNIT + 4/5 (architectural, fractional) DIMDEC is a fraction-precision index, so the + tolerance is `0.5 / 2^DIMDEC` inches. All nine samples are DIMLUNIT 4. +- `display_source` in `{user_text, cached_block, formatted_basic, formatted, + suppressed}`; `agreement` in `{exact, whitespace, mismatch, no_cache, + override}`. A whitespace-only `user_text` is `suppressed`, without a warning. + +TEXT. *(proposal: `decorations`, `align`, `visible` and `space`. A text +record is `{id, kind, text, raw, layer, height, rotation_deg, anchor, style, +tag, font_ok, bbox, bbox_confidence, why, tiles, px}`: the `%%u` underline is +decoded out of `text` but not reported, the justification is applied to +`anchor` rather than published, and only visible texts are exported at all.)* + +```json +{"id":"4B2","kind":"TEXT","text":"BOOK RETURN","raw":"%%UBOOK RETURN","decorations":["underline"],"layer":"A-ANNO", + "height":4.5,"rotation_deg":0,"anchor":[1500.25,820.5],"align":"MC","style":"ROMANS","font_ok":true,"visible":true, + "bbox":[1484.9,818.3,1515.6,824.7],"bbox_confidence":"stored","why":"usvg glyph box, bundled font", + "tiles":["f0/z1/r00_c02"],"px":{"ov":[610,402,624,405],"f0/z1/r00_c02":[212,640,571,693]}} +``` + +Other records: **region** `{id, src, layer, area, area_unit, area_si | null, +perimeter, centroid, bbox, vertex_count, simple, labels, confidence, tiles, +px}` -- *(proposal: `holes` and `hatch`)* -- where labels are the texts whose anchor lies inside the +polygon and in no smaller region, and `simple` is `null` (with `confidence: +"estimated"`) for an outline of more than 2 000 vertices, whose pairwise +self-intersection test is skipped rather than run at O(n^2); **tile sidecar** `{id, png, z, row, col, px, +world, ppu, world_to_px, px_to_world, overlap_px, neighbors, parent, children, +empty, layers_present, layers_truncated, layers_total (only when trimmed), +fits_profile, expected_encoded_px, resize_factor, norm_to_px, columns, counts, +geometry_by_kind, records: {texts: [[id, px, t]], dims: [[id, px, s, v]], blocks, +regions, geometry: [[id, px, type]]}}`, capped at 32 KB with +text truncated to 24 characters: the geometry rows are cut first, then the other +record rows (`records_truncated`), then the layer list (`layers_truncated`, with +self-intersection test is skipped rather than run at O(n^2); **tile sidecar** `{$schema, id, png, z, row, col, px, +world, canvas_origin_px, ppu, world_to_px, px_to_world, overlap_px, neighbors, +parent, children, empty, layers_present, layers_truncated, layers_total (only +when trimmed), records: {texts: [[id, px, t]], dims: [[id, px, s, v]], blocks, +regions}, records_truncated}` -- *(proposal: `fits_profile`, +`expected_encoded_px`, `resize_factor` and `norm_to_px`; every tile is already +within the profile's budget, so nothing is resized, and pixel coordinates are +absolute throughout -- a model returning 0-1000 coordinates has to be scaled by +the reader against `px`)* -- capped at 32 KB with +text truncated to 24 characters: the record rows are cut first +(`records_truncated`), then the layer list (`layers_truncated`, with +`layers_total` saying how many names there were), so the file always says which +of the three a reader is missing. `counts` is the true number of records of each +kind on the tile whatever was cut, `geometry_by_kind` the same for the geometry, +and `columns` names the fields of each positional row. Every pixel box is +clipped to the image it is quoted in. Sidecars are +authoritative for "what is on this image"; shards hold the full record. +`strings.json` normalisation is NFKC + case fold + whitespace collapse + a +canonical fraction form + unit-suffix handling (m2, mm, ㎡, ㎜). + +## 4. Crop boundary rule + +Model space, one crop per frame; every step is deterministic and reported. + +1. **Visible set V**: model-space entities minus layers that are off, frozen or + non-plotting, DEFPOINTS, `invisible` entities, invisible ATTRIBs, RAY/XLINE, + ACAD_PROXY_ENTITY/Unknown, empty text. `--include-hidden` draws hidden + layers at 50 % and never affects the crop. +2. **True WCS extents** per entity after OCS -> WCS: LINE endpoints; CIRCLE + c +/- r; ARC endpoints plus the axis crossings inside the sweep; ELLIPSE + parametric extremes clipped to the range; LWPOLYLINE/POLYLINE_2D with + bulge-arc extremes and widths; SPLINE by evaluation; TEXT/MTEXT/ATTRIB from a + **metrics pre-pass** (one parse of a world-unit SVG with the bundled font, + an id -> node index built once) with the 0.62-em estimate only as a seed + before that pass; INSERT/DIMENSION/MINSERT nested through the composed + transform; HATCH loops; 3D entities as plan (x, y). Computed once, reused for + record boxes. +3. **Scale-outlier guard**: an entity whose diagonal exceeds 20x the seed + cluster's and whose cluster holds at most `max(3, 1 %)` entities is excluded + with reason `scale_outlier`; if the majority rule fails and the raw diagonal + exceeds 100x the seed's, the seed is forced (`cluster_forced`). The 3256x + INSERT of `example_2018.dwg` becomes a listed exclusion, not a blank page. +4. **Candidates and automatic choice**: the computed content extents E versus + the header `EXTMIN/EXTMAX` accepted only if finite, `|v| < 1e15`, area > 0, + containing >= 90 % of visible boxes and at most 4x E's area. `--crop auto` + picks whichever covers more visible entities and records it in `crop.mode`. + Priority: `--crop x0,y0,x1,y1` > `--crop header` > `auto` > `raw`. An empty + drawing yields `[0, 0, 10, 10]`, mode `empty`. +5. **The overview is never cluster-trimmed.** Clustering (today's union-find, + made deterministic by sorting clusters by score, min_x, min_y, count; absorb + threshold `--trim-absorb`, default 0.30 until a corpus sweep says otherwise) + only splits V into **frames**: the primary frame f0 and one secondary frame + per excluded cluster holding at least one text or N entities. Text-height + clusters (scale groups) are framed the same way, which covers model spaces + that hold several drawings or details side by side. `crop.excluded_text_count + > 0` raises `CROP_EXCLUDES_TEXT`. +6. **Padding**, solved once: L = longer side, s = fit_px / (1.04 L), + pad = max(0.02 L, 24 / s) on all sides; `padding_units` written. +7. **Lattice snap**: W = ceil(w_pad s / 28) x 28, H likewise; the world rect + grows on the right and bottom so W / s and H / s equal the world size + exactly (`--lattice 32` for OpenAI-first use). +8. **Affine per image**: `px = (x - x0) s`, `py = (y1 - y) s`; + `world_to_px = [s, 0, -x0 s, 0, -s, y1 s]`, `px_to_world = [1/s, 0, x0, 0, + -1/s, y1]`, `units_per_px = 1/s`; absolute pixels, never normalised; round + trip exact to 1e-9 by unit test. *(Proposal, not implemented: an image above + the profile cap carrying `fits_profile: false`, `expected_encoded_px` and + `resize_factor`, and a `norm_to_px` in every sidecar for models that return + 0-1000 coordinates. Images are fitted to the profile's budget when they are + rendered, so none is above the cap, and no sidecar carries a normalised + affine.)* +9. **Reporting**: `manifest.crop {mode, rect, content_rect, padding_units, + header_extents, excluded {count, by_type, rect, first 100 handles}}`; + `report.json` lists every excluded id with a reason in `{excluded_by_trim, + hidden_layer, invisible, scale_outlier, unsupported_type}`; excluded records + carry `tiles: []`. +10. **Paper layouts**: crop = the full sheet from PLOTSETTINGS + `paper_width/height` via `plot_paper_unit`, rotated by + `plot_rotation_mode`, margins applied, no padding, no trim; LAYOUT + `LIMMIN/LIMMAX` then layout extents as fallbacks; one image set per layout, + never merged. Viewport scale = `height / VIEWSIZE`, model window = + `VIEWCTR +/- (VIEWSIZE w/h / 2, VIEWSIZE / 2)` when `VIEWDIR = (0,0,1)`. + The overall viewport (`entmode 0`) is skipped; content of a viewport whose + own layer is off is still drawn, only its frame is hidden; the sign of + `VIEWTWIST` is fixed by a fixture before `twist_convention` is recorded. + +## 5. Tiling rule + +> The geometry in this section is what `export.rs` implements. The **flag +> names and the last bullet are the proposal**: `--min-text-px` is spelled +> `--text-px`, `--sparse-from`, `--dense` and `--mono` do not exist, and +> neither does the `uncad render` subcommand -- see section 10's CLI +> paragraph. `uncad --help` is the current list. + +| Profile | Overview cap | Tile T | Overlap O | Step | Cost per tile | +|---|---|---|---|---|---| +| claude (default) | 1568 px edge and 1568 patches | 1092 | 224 (20.5 %) | 868 | 1521 tokens | +| claude-hires | 2576 px / 4784 patches | 1932 | 392 (20.3 %) | 1540 | 4761 tokens | +| openai-patch (gpt-5.x) | 2048 | 1600 | 320 (20 %) | 1280 | 2500 patches | +| openai-tile (gpt-4o/4.1/5.1) | 768 short side | 768 | 160 | 608 | 4 x 512 tiles = 765 tokens | +| gemini (2.x cost model) | 3072 | 1536 | 308 | 1228 | 1032 tokens (Gemini 3: fixed `media_resolution`) | + +- **Overview**: for aspect a = w/h, pw = min(56, floor(sqrt(1568 a))), + ph = min(56, floor(1568 / pw), ceil(pw / a)); image (pw x 28) x (ph x 28) + after the lattice snap; ppu_0 = min(pw 28 / w, ph 28 / h). Sample 6 + (2886 x 1959 units): 48 x 32 patches = 1344 x 896 px, 1536 tokens, ppu 0.457. + A short edge under 200 px raises `TinyOverview`. +- **Levels**: ppu_z = ppu_0 x 2^z; no canvas above 8000 px is ever + materialised. Grid: cols = 1 if W_z <= T else ceil((W_z - T) / step) + 1, + rows likewise; the last row and column are shifted inward (SAHI) so every + tile is exactly T x T; row 0 is north; ids `f0/z{z}/r{rr}_c{cc}`. Sample 6: + z1 3 x 2, z2 6 x 4, z3 12 x 8 candidates. +- **Depth**: from the **dominant text-height class** (count-weighted median of + visible text heights, not the 5th percentile, which the small title-block + text would drag to a depth that blows the tile budget): z_max = ceil(log2 + (target_px / (h ppu_0))), default target 14 px (floor-plan text measured + legible at 9-12 px), `--min-text-px 20` for detail work, `--max-levels 5`. + `manifest.legibility.per_frame[].height_classes = [{height, count, + px_at_zmax, legible}]` tells the agent which classes still need a window (the + classes are per frame, one level deeper than this proposal put them; each + `manifest.frames[]` entry repeats its own `height_classes`, `z_max` and + `reached`). z1 and z2 are complete; + from `--sparse-from 3` a tile is written only if it holds text of a class + that first crosses the target at that level. Tiles that intersect no visible + box are listed `empty: true` and not written, with the reason. `--max-tiles + 400` counts written tiles; when exceeded z_max drops and + `legibility.per_frame[].pyramid_complete = false` points to windows -- + separately from `target_met`, which is whether every height class reaches + `target_px` in the deepest image that frame got. The two used to be one field + named `reached`, which read as the second and meant the first. + box are listed `empty: true` and not written. `--max-tiles 400` counts written + tiles; when exceeded z_max drops and the frame's `reached: false` (in + `manifest.frames[]`) points to windows. +- **Implementation**: one SVG string and one `usvg::Tree` per level (numbers + rounded to 0.01 px at the level's ppu, which cuts the SVG ~40 %); `fontdb` + built once per process (`OnceLock>`); tiles rendered in + `std::thread::scope` with `resvg::render(&tree, + Transform::from_scale(s, s).post_translate(-x0, -y0), &mut pixmap)`. + `resvg::render_node` is not used (it drops axis-aligned lines). Tile culling + is at tile level through the entity-box index. Low levels (overview, z1) may + render one canvas and crop. PNG: 8-bit RGB (Gray8 under `--mono`) through the + `png` crate, `Compression::Fast`. +- **On demand** *(proposal: none of this exists in 0.3.0 -- there is no + `render` subcommand and no `windows/` output)*: `uncad render --around + --margin 2.0 --fit 1092`, + `--window x0,y0,x1,y1 --ppu 8`, `--layers`/`--exclude-layers` for + de-cluttered views, `--ruler` for a world-unit scale overlay, `--batch + windows.json` for many windows per parse; output `windows/w_<8hex>.png` + + sidecar; 8000 px cap per edge. + +## 6. Numeric exactness strategy + +| Class | What | Tag | +|---|---|---| +| Exact (closed form on dynapi fields) | LINE length; ARC r x sweep; CIRCLE 2 pi r, pi r^2; polyline length and area with bulges (signed); full ELLIPSE pi a b; counts by type/layer/block/attribute; MINSERT rows x cols; `uncad measure` (distance between ids, area from an id or point list, per-layer/block/hatch-pattern sums) | `exact` | +| Stored (copied) | `act_measurement` (R2000+, not `-1.0`); MTEXT extents; usvg glyph boxes | `stored` | +| Numeric (quadrature, `tol` given) | ELLIPSE arc length (adaptive Gauss-Kronrod); SPLINE length by de Boor on knots/weights (1.0; chords in 0.3.0) | `numeric` | +| Cached | Dimension display string from the `*D` block, decoded | `cached` | +| Estimated | Pre-render text boxes (0.62 em); SHX-styled widths from the bundled face; fit-point-only splines | `estimated` + `why` | +| Unavailable | Volume/mass of 3DSOLID/REGION/BODY; area of a self-intersecting polygon (`simple: false`); non-planar REGION wires | `unavailable` + `why` | + +- **Units**: INSUNITS -> the DXF reference table (0 unitless, 1 in 25.4, + 2 ft 304.8, 4 mm 1, 5 cm 10, 6 m 1000, ... 21-24 US survey), hardcoded + because LibreDWG has no such table. INSUNITS 0 gives `unit: "du"`, + `to_mm: null` and `units.guess {name, reason}` from MEASUREMENT / DIMLUNIT / + extent span; a guess is never written into `unit`. `--units mm` is the user + override, recorded as `unit_source: "user"`. `area_si`/`length_si` are + `null` when `to_mm` is null. `BLOCK_HEADER.insert_units` is exported, never + re-applied. +- **Dimensions**: `measurement` = `act_measurement` in drawing units, or the + definition-point recomputation (LINEAR projected on `dim_rotation`; ALIGNED + `|x2 - x1|`; RADIUS/DIAMETER `|first_arc_pt - def_pt|`; ORDINATE the signed + x or y offset per `flag2`; ARC r x |end - start|); `measurement_from_points` + and `delta` always exported. Effective `dimlfac`: XDATA DSTYLE override (1.0; + until then `overrides_xdata: "not_decoded"`) > DIMSTYLE (read in 0.3.0 via + `dwg_resolve_handle`) > header. `display_value = measurement x dimlfac`. + Display text: suppressed > `user_text` with `<>` substituted > cached `*D` + text > `formatted_basic` (LUPREC/DIMDEC decimal, 0.3.0) > `formatted` (full + DIMLUNIT 1-6, DIMRND, DIMZIN, DIMDSEP, DIMPOST, DIMALT, DIMAUNIT/DIMADEC, 1.0). + Caveat: every probed file has DIMLFAC = 1.0, so "act_measurement is + pre-DIMLFAC" (the DXF reference reading) is untested; a DIMLFAC = 12 fixture + is required before that claim is tagged `exact`. +- **OCS**: a per-entity table of which points to transform (LINE, POINT, MTEXT, + 3DFACE: none; TEXT/ATTRIB: `ins_pt` and `alignment_pt`; INSERT: `ins_pt`; + ARC/CIRCLE: centre, plus start/end swap when the normal's z < 0; LWPOLYLINE: + all vertices at `elevation`; HATCH paths; SOLID corners; DIMENSION: only + groups 11, 12 and 16 are OCS, groups 10/13/14/15 are WCS). Presence is decided + by the entity's flag bit, since the DWG decoder leaves (0,0,0) otherwise. +- **Text decoding** (one tokenizer for JSON and SVG): `\P`, `\~`, `\{ \} \\`, + groups, stacked fractions with all three separators (`\S a/b;`, `\S a#b;`, + `\S a^b;` per DIMFRAC) with a space inserted after a leading digit + (`3{\H0.7x;\S1#2;}"` -> `3 1/2"`), `\U+XXXX`, `\M+nXXXX` MIF codes, other + format codes dropped, `%%c` -> U+2205 (U+2300 fallback), `%%d` -> degree, + `%%p` -> plus-minus, `%%u`/`%%o` -> decorations, `%%nnn` through the file + code page. Raw and plain are both kept. +- **Pre-R2007 strings**: a shim `uncad_tv_to_utf8(Dwg_Data*, const char*)` + applies LibreDWG's own `IS_FROM_TU_DWG` rule (version < R2007, or any DXF + input) and `header.codepage`, calls `bit_TV_to_utf8` (vendored CP949/CP936 + tables) and returns a fresh buffer. U+FFFD counts go to `report.json`. +- **Determinism**: `BTreeMap` everywhere, sorted clusters, bundled font by + default; identical input + options + version -> byte-identical JSON; PNG + goldens compared with a 2-level per-channel tolerance. + +## 7. Rendering changes + +| Area | 0.3.0 | 0.4.0 / 1.0 | +|---|---|---| +| Background / PNG | opaque white rect; RGB8 or Gray8 via `png`; `--bg white\|black` | -- | +| Palette | ACI -> RGB with luminance > 0.45 darkened (yellow -> #8f8f00, cyan -> #007a7a, ACI 7 -> black); `hex` + `rendered_hex` in the layer legend; `--mono` | `--weights color-map:` (ACI -> px, the CTB convention) | +| Strokes | pixel-constant through the existing `@@SW@@` placeholder: default 1.25 px, lineweight classes 1.0-3.5 px, floor 1.0; POINT 5 px cross; arrowheads DIMASZ x DIMSCALE but >= 6 px; dashes 6,4 px | LTYPE dashes x LTSCALE x `ltype_scale`, solid below a 6 px period | +| Fonts | `bundled-fonts` feature (default on): Noto Sans Latin + symbols (degree, plus-minus, U+2205, U+2300, ㎡, ㎜) and a Noto Sans KR KS X 1001 subset (target ~1 MB; 4.4 MB unsubsetted), registered as family `uncad-sans` via `push_face_info`; `font-family` on every ``; unshaped characters counted; `--fonts bundled+system` | SHX look-alike face; `--shx-dir` | +| Text | `text-anchor` from `horiz/vert_alignment` with `alignment_pt` as an `Option` set only when they are non-zero; `id=""` on `` so `Node::abs_bounding_box` gives exact boxes | `width_factor`, `oblique`, generation flags, MTEXT attachment / wrap / rotation from `x_axis_dir` | +| Visibility | hidden layers, DEFPOINTS, invisible entities not drawn; `--include-hidden` at 50 % | per-viewport frozen layers | +| Geometry | OCS applied; RAY/XLINE clipped to the crop; all 3D in plan (`--view iso` opt-in); bulges as arc paths; ELLIPSE arcs from the stored parameter range, with the arc's own extent | NURBS, HATCH spline edges | +| Sizing | `PngSize::{FitLongEdge, FitTokens, PxPerUnit, Scale}`, default `FitLongEdge(1568)`, hard cap 8000 px -> `TooLarge` | -- | +| Sheets | LAYOUT/PLOTSETTINGS read; `layouts[]` with paper size and per-viewport scale; frames-only sheet overview | model composited per VIEWPORT inside a `clipPath`; `sheets.json` complete | +| Input | bytes read in Rust, decoded through a `dwg_decode`-from-memory shim (fixes Hangul paths on Windows, enables `parse_bytes`) | -- | + +## 8. Rust API + +```rust +pub struct CadDatabase { pub entities: Vec, pub tables: Tables, pub header: Header } // + CadDatabase::new(..) +pub fn parse(path: impl AsRef) -> Result; // + ParseError::UnsupportedFormat +pub fn parse_bytes(bytes: &[u8], format: Format) -> Result; +pub fn parse_with(path, opts: &ParseOptions) -> Result; // { decode_codepage, codepage_override } +impl CadDatabase { + pub fn to_json(&self, o: ToJsonOptions) -> Result; // unchanged; output gains header + fields + pub fn to_svg(&self, o: ToSvgOptions) -> ToSvgResult; // unchanged; + crop, background, hidden, decimals + pub fn to_png(&self, o: ToPngOptions) -> Result; // ToPngOptions { svg, size: PngSize, max_edge, fonts } + pub fn renderer(&self, o: RenderOptions) -> Result; // parse-once handle, Send + Sync + pub fn export(&self, out: impl AsRef, o: &ExportOptions) -> Result; + pub fn measure(&self, q: MeasureQuery) -> Measure; +} +// header.rs Header { dwg_version, from_version, codepage, insunits: InsUnits, units, measurement, lunits, luprec, aunits, auprec, +// extmin/extmax, limmin/limmax, pextmin/pextmax, plimmin/plimmax, dimscale, dimlfac, dimdec, dimlunit, dimpost, dimrnd, +// dimzin, dimfrac, ltscale, textsize, clayer } +// geom.rs Box2D; Affine { sx, sy, tx, ty } + to_px/to_world/inverse; Confidence; Measure { value, unit, confidence, why }; +// ocs_to_wcs(p, normal); bulge_arc(p1, p2, b); nurbs_eval / nurbs_length (1.0) +// measure.rs bbox(&Entity, &Tables, &dyn FontMetrics) -> Option; length / area(&Entity) -> Option; +// polyline_segments(&[LwVertex], closed) -> Vec; dimension_text(&DimensionEntity, &CadDatabase) -> DimensionText +// text.rs decode(raw) -> DecodedText { plain, lines, decorations, fractions }; dimfmt.rs (1.0) +// crop.rs CropSpec { Auto, Raw, Header, Rect(Box2D) }; compute_crop(&CadDatabase, Space, CropSpec, &TrimOptions) -> Crop { frames, excluded, .. } +// render.rs Renderer { crop(), render_fit(max_edge, max_tokens), render_window(Box2D, max_edge), tile_plan(&TileOptions), +// render_tile(&TileSpec), render_tiles_parallel(plan, jobs, sink), text_boxes() }; Image { png, width, height, world, affine } +// export.rs ExportOptions { target, tile, overlap, min_text_px: 14.0, max_levels: 5, max_tiles: 400, sparse_from: 3, crop, padding, +// spaces, layers, render, full, svg, shard_bytes: 98_304, jobs, overwrite }; ExportReport { manifest, files, warnings, timing } +// Model and table additions, all #[serde(default)]: +// EntityCommon += space (from entmode), visible, invisible, lineweight_mm, linetype, ltype_scale, extrusion +// LwPolyline += bulges, widths, const_width, elevation (closed = flag & 512) +// Text/Attrib += text_plain, decorations, horiz/vert alignment, alignment_pt: Option, width_factor, oblique_angle, style, tag +// MText += text_plain, lines, attachment, rect_width, extents, x_axis_dir (rotation becomes real) +// Dimension += subtype, measurement: Option, user_text, display_text(_raw), display_source, def_pt, text_midpt, dimstyle, points +// Viewport += view_center, view_size, twist, status_flag, entmode, id, frozen_layers +// Spline += degree, knots, weights, closed, periodic, rational; Hatch += pattern_name, angle, scale_spacing, associative (+ bulges) +// LayerRecord += on, frozen, locked, plot, lineweight, linetype; Tables += linetypes, text_styles, dimstyles, layouts +// Entity::MInsert (0.4.0); block_records become definition-only (no model/paper-space duplication) +``` + +| 0.2.0 | New | +|---|---| +| `ToPngOptions { scale }` | `ToPngOptions { size: PngSize::Scale(s), ..Default::default() }` (**break**) | +| `CadDatabase { entities, tables }` literal | add `header`, or `CadDatabase::new` (**break**) | +| `to_png` default: units x scale, transparent | `PngSize::FitLongEdge(1568)`, opaque white RGB | +| `ToSvgOptions { padding, stroke_width, space, outlier_trim }` | kept; `..Default::default()` covers the new fields | +| `svg_to_png(svg, scale)` | kept with the 8000 px cap; deprecated in 0.4.0, removed in 1.0 | +| `block_records` duplicating model/paper-space entities in JSON | definition-only (the one intentional shape change) | +| 1.0 | option/result structs `#[non_exhaustive]` with builders; one `uncad::Error` | + +## 9. CLI + +``` +uncad -o out.{json|svg|png} [--space model|paper|all] [--no-trim] [--scale f] [--pretty] legacy form kept; png fits 1568 px +uncad export --out [--target claude|claude-hires|openai-patch|openai-tile|gemini|custom] [--tile 1092] [--overlap 224] + [--min-text-px 14] [--max-levels 5] [--max-tiles 400] [--sparse-from 3|--dense] [--crop auto|raw|header|x0,y0,x1,y1] + [--pad 0.02] [--pad-min-px 24] [--trim-absorb 0.30] [--lattice 28|32] [--space model|paper|all] [--layout ] + [--layers ] [--exclude-layers ] [--stroke 1.25] [--bg white|black] [--mono] [--include-hidden] [--view plan|iso] + [--fonts bundled|bundled+system] [--weights color-map:] [--units mm] [--full] [--svg] [--shard-kb 96] [--jobs N] + [--force] [--dry-run] [--json-report] [-q] +uncad info [--json] header, units, header vs computed extents, counts, layers, layouts +uncad render -o out.png (--window x0,y0,x1,y1 | --around [--margin 2.0]) [--fit 1092|--ppu f] [--layers ..] [--ruler] + [--batch windows.json] +uncad dims [--json] per dimension: measurement / from_points / display / source / agreement +uncad measure --between A B | --area A,B,C | --sum-length --layer +``` + +Exit codes: 0 (warnings in `manifest.warnings`), 1 (error; `export` writes to +`.tmp-` and renames on success), 2 (unsupported input, with distinct +messages for `.dwf` and `.dwfx`). `--dry-run` writes only the manifest, crop and +tile plan with token estimates. `uncad info` is not a fast path: the C decode +dominates parse time. + +## 10. Roadmap + +Effort in person-days is the review panel's estimate. + +Implementation status (2026-09-23, see `CHANGELOG.md` "Unreleased"): **P-1 done** +except the AutoCAD-written Korean DWG fixture (LibreDWG's own writer mangles +CP949 text, so `tests/fixtures/` ships DXF fixtures only); **P0 done** +(`CadDatabase::header`); **P1 mostly done** (`PngSize` fit-to-pixels with the +8000 px cap, white RGB background, pixel strokes, contrast-normalized palette, +fonts loaded once, `ViewBox` + `px_per_unit` in every result, RAY/XLINE clipped +to each document's viewBox instead of a fixed 1e6-unit segment, and the bundled +`Uncad Sans` with `font-family` on every `` -- shipped as a plain +`include_bytes!`, not behind the `bundled-fonts` cargo feature the table in +section 7 proposes) -- POINT now draws the table's 5 px cross, sized through the +same `@@SW@@` placeholder as the strokes, so it is the same size whatever the +drawing's scale; still open in P1: the other pixel-sized symbols (arrowheads a +fixed 2.5 units, dashes `4,2` user units); +**P2 done** (`uncad::text` +decoder with all three stack separators and the `%%` codes, `text_plain` on +every text type, TEXT/ATTRIB justification fields and ATTRIB `tag`, MTEXT +attachment/width/extents/`x_axis_dir` with the rotation derived, renderer +anchoring) except MTEXT word-wrapping at `rect_width`; **P3 done** in its +0.3.0 form (`DimensionGeometry` per subtype, stored and recomputed +measurements, `user_text`/cached-label/formatted display text with its +source, DIMSTYLE table and effective DIMLFAC, basic formatter; the full +DIMSTYLE formatter with DIMPOST/DIMRND/DIMDSEP and XDATA overrides stays in +1.0); **P4** is in (bit 512, bulges and widths, the VERTEX_2D walk, `geom.rs` +with OCS -> WCS, segments, length, signed area, arc-aware bounds and a +self-intersection check; the OCS applied to CIRCLE, ARC, LWPOLYLINE, +POLYLINE_2D, TEXT, ATTRIB, INSERT and SOLID -- HATCH boundaries and the +DIMENSION text point are still stored-frame); **P5** is in (`visibility.rs` +with `hidden_reason` and the lineweight table, layer on/frozen/locked/plot/ +lineweight/linetype, entity invisible/lineweight/linetype/ltype_scale, the +renderer skipping hidden entities with `include_hidden` fading them, hidden +counts in the results and the CLI); **P6** is in its 0.3.0 form (`crop.rs`: +scale/far outlier guard (largest-entities and median-centre rules -- the +design's cluster-based wording did not survive contact with real files, +whose corner-connected clusters are too fragmented for a majority rule), +header candidate, +`CropMode::{Auto, Raw, Header, Fixed}`, automatic padding, lattice snap with +exact pixel/unit proportion, `CropReport` in both results, CLI `--crop`, +`--padding`, `--lattice`; the frame split landed with P7, and the text metrics +pre-pass exists but does not feed the crop -- `measure_texts` runs on the +already-rendered drawing, so the crop is chosen from the 0.6-em estimate and +only the frames and the tile culling see the measured boxes); +**P7** is in its 0.3.0 form (`export.rs`: the overview fitted +to the profile's edge and patch budget, the tile pyramid with 224 px overlap +and inward-shifted edge tiles, sidecars with both affines and per-tile record +lists, texts/dimensions/geometry/regions/blocks/strings/report/drawing JSON +with sharding and a manifest, CLI `uncad export`; frames for detached +groups, NFKC string keys, per-tile culling and parallel tiles landed after; +the bundled `Uncad Sans`, usvg-measured text boxes and the paper layouts +(`sheets.json`, composited sheet images) landed too -- no P7 feature of this +section's 0.3.0 scope is open, but the package diverges from section 2 in +four places: `manifest.json` is 17 KB to 90 KB rather than the 12 KB budgeted +there, because it carries a `files[]` array the design never listed (one entry +per written file, 89 of them for `example_2000.dwg` and 771 for +`example_2018.dxf`); `source` is `{codepage, name, version}` -- there is no +`producer`, and no `input_fidelity: "dxf-partial"` for DXF input as section 1 +promises; `header.format` and the source file's extension are what say the +input was a DXF; and `strings.json` has no `id -> shard file` map, because a +map of every id would be a second copy of the record files -- the ids resolve +through `manifest.shard_index`'s `first_key`/`last_key` instead, which +section 2 now describes; and the block definitions carry no `dynamic` or +`xref` flag -- the block table in `drawing.json` has `anonymous`, the +per-layer counts, the attribute tags and the instance ids instead. (The +sha256, the byte count and the reason on each empty tile that section 2 asks +of `tiles.json` were a fifth divergence until this round, and the paper +layouts' texts a sixth: both are in the package now.)); **P8** (README, ARCHITECTURE, CAVEATS, `--help`) and **P9** +section's 0.3.0 scope is open, but what it writes is not the package sections +2 and 3 describe: "Where the package differs from this document" below is the +measured list); **P8** (README, ARCHITECTURE, CAVEATS, `--help`) and **P9** +(`tests/corpus_sweep.rs` over the 208 corpus files, `tests/acceptance.rs` +with five package questions, determinism test, `docs/EVAL.md`) are in -- +goldens (byte-exact reference packages) are not: with the bundled font the +tile PNGs should be identical across machines running the same resvg version, +but no reference package is checked in, so the determinism test compares two +runs on one machine. + +Four items the tables below place in 0.4.0 landed in 0.3.0 instead: the frame +split (`frames/fN/`), viewport compositing with `sheets.json`, the bundled +font, and per-viewport frozen layers (honoured by the sheet compositor only, +`docs/CAVEATS.md`). The section 7 "Rendering changes" table and the section 9 +CLI sketch are the proposal as written, not a description of the binary. +`uncad export` takes `-o`/`--output`, not `--out`; `--target` is spelled +`--profile` and `--min-text-px` is `--text-px`; the tile and padding geometry +comes from the profile and the crop rule, so `--tile`, `--overlap`, +`--sparse-from`, `--dense`, `--pad`, `--pad-min-px` and `--trim-absorb` do +not exist; and neither do `--layers`, `--exclude-layers`, `--layout`, +`--units`, `--weights`, `--view`, `--mono`, `--jobs`, `--force`, +`--dry-run`, `--json-report` or `-q`. Only `--crop`, `--no-trim`, +`--include-hidden`, `--padding` and `--fonts` are shared with the plain +command; the other render flags (`--space`, `--stroke`, `--fit`, `--lattice`, +...) are refused by name on an `export` line -- the package's patch size is +the profile's, not a flag. The `info`, `render`, `dims` and `measure` +subcommands do not exist. `uncad --help` is the current list. + +The same goes for what section 9 says about exit and failure handling, and for +section 11's DWF paragraph. The CLI exits 0 or 1, never 2; `export` writes into +the target directory itself (clearing what the previous `manifest.json` listed +and writing the new manifest last) rather than into `.tmp-` and +renaming; and there is no `.dwf`/`.dwfx` branch at all -- `Format::from_path` +reads a `.dxf` extension as DXF and everything else as DWG, so a `.dwf` is +handed to the DWG decoder and comes back as `ParseError::Critical`, not as an +`UnsupportedFormat` with an explanation. `ParseError` has two variants, +`Critical(i32)` and `Io`. + +### Which sections describe the code, and which are still the proposal + +| Section | Status | +| --- | --- | +| 1 Answers, 6 Numeric exactness | description, with `input_fidelity` the one unkept promise (see the table below) | +| 2 Package layout, 3 JSON conventions and records | **mixed** -- the file list and the conventions hold, the record shapes do not. Every divergence is tagged *(proposal)* in place and listed below | +| 4 Crop rule | description, except rule 8's `fits_profile`/`norm_to_px` sentence | +| 5 Tiling rule | the geometry is description; the flag names and the "on demand" bullet are the proposal | +| 7 Rendering changes, 9 CLI, 11 DWF | the proposal as written -- see the paragraphs above | +| 8 Rust API | `ExportOptions`/`ExportReport` exist; read `cargo doc` for the current shape | +| 10 Roadmap | this section | +| 12 Risks | open questions, unchanged | + +### Where the package differs from this document + +Measured on `uncad export lib/libredwg/test/test-data/example_2000.dwg` (89 +files) at this commit, by taking the key union of every record in every file. +A package's own files are the contract; this table is the delta from the +proposal, and each row is tagged *(proposal)* at its source too. + +| Where | The document promises | The package has | +| --- | --- | --- | +| §1 `manifest.source` | `input_fidelity: "dxf-partial"` for DXF input (§1, "Answers") | `{codepage, name, version}` only. `drawing.json`'s `header.format` and the source file's extension are what say the input was a DXF | +| §2 `manifest.json` | "per-tile hashes live in tiles.json" | no hash of anything, anywhere: `sha256` does not occur in `export.rs`. `files[]` gives each written file's byte count, which is the only integrity aid in the package | +| §2 `drawing.json` | `layers[]` with `hex` + `rendered_hex`; block definitions with xref/dynamic flags; `layouts[]` | `layers[]` = `{name, on, frozen, locked, plot, color_index, linetype, lineweight_mm, entity_count}`; `blocks[]` = `{name, entity_count}`; no `layouts[]` -- paper layouts are `sheets.json` and `sheets//` | +| §2 `geometry.json` | sharded per layer above the shard size | sharded by id range (`geometry.001.json` first_id `8B`, last_id `75A`) | +| §2 `blocks.json` | definitions `{name, count, count_by_layer, attrib_tags, dynamic, xref}` | `{name, entity_count, instances, instance_ids, count_by_layer, attrib_tags, anonymous}` | +| §2 `tiles.json` | every tile with `sha256`, `bytes`, and a reason for each empty one | `{id, frame, z, row, col, png, sidecar, px, world, empty}` | +| §2 `regions.json` | vertex lists under `--full` | never: `src` names the geometry record that holds them. `--full` adds `entities.json`, nothing else | +| §3 conventions | every file carries a `units` block | `strings.json`, `tiles.json`, `sheets.json` and `report.json` do not. `manifest.json` always does | +| §3 conventions | points are `[x, y]` rounded to `max(LUPREC, 3)` | true except a DIMENSION's `geometry`, whose points are `{x, y, z}` at full `f64` precision | +| §3 LINE, LWPOLYLINE, TEXT | `space` on every record | not written; a package is one space | +| §3 LWPOLYLINE | `area_unit`, `segments`, `centroid`, `region` | not written | +| §3 DIMENSION | `display_value`, `display_raw`, `agreement`, `display_tolerance`, `style_used`, `user_text`, `from`/`to`, `dir_deg`, `text_id` | `geometry` (a per-kind object) replaces `from`/`to`, `delta` (stored minus recomputed) replaces `agreement`, and `definition_point` is written; the others are absent | +| §3 TEXT | `decorations`, `align`, `visible` | not written: `%%u` is decoded out of `text` without being reported, justification is applied to `anchor`, and only visible texts are exported | +| §3 region | `holes`, `hatch` | not written | +| §3, §4 rule 8 tile sidecar | `fits_profile`, `expected_encoded_px`, `resize_factor`, `norm_to_px` | none of them. Images are fitted to the profile when rendered, so none exceeds the cap; pixels are absolute everywhere. The sidecar also carries `$schema` and `canvas_origin_px`, which the proposal does not list | +| §5 legibility | `manifest.legibility.height_classes`, `legibility.reached` | `manifest.legibility.per_frame[].height_classes`, and `reached` on each `manifest.frames[]` entry | + +None of these is a bug report: they are the design as written against the +package as built. Changing the package to match the document is a 0.4.0 +decision (`sha256` per tile and `layouts[]` in `drawing.json` are the two a +reader is most likely to miss); until then the document is the one that +moves. + +| Release | Phase | Files | +|---|---|---| +| **0.3.0 "Readable"** (~42 pd, ~50 with contingency) | P-1 fixtures and spikes (3): a Korean R2000 CP949 DWG (redistributable, e.g. written with LibreDWG's own tools), a mirrored-OCS fixture in the style of sample 9, a DIMLFAC = 12 fixture, a twisted-viewport layout, a Hangul-path test, the `uncad_tv_to_utf8` and decode-from-memory shims compiling against the vendored `bits.h`/`decode.h` | `libredwg-sys/shim`, `tests/` | +| | P0 header, units, version, code page (2) | `dynapi.rs`, `header.rs`, `lib.rs`, shim | +| | P1 render hygiene (4): `PngSize`, fit, 8000 cap, white background, px strokes, RAY clip, contrast, bundled fonts + `OnceLock`, RGB/Gray8 | `png.rs`, `svg.rs`, `color.rs`, `fonts/` | +| | P2 text (4): decoder (three stack separators, MIF, `%%`), `text_plain`, alignment fields + `text-anchor`, CP949 wiring | `text.rs`, `convert.rs`, `svg.rs` | +| | P3 dimensions (4.5): all subtypes, `*D` harvest, suppressed/override paths, from-points, angular kinds, DIMSTYLE scalars (`dwg_resolve_handle` allowlisted), basic formatter, `-1.0` sentinel | `convert.rs`, `model.rs`, `measure.rs`, `build.rs` | +| | P4 polylines, areas, OCS (4): bit 512, bulges, VERTEX_2D walk, segments/length/area, self-intersection check, the OCS table | `convert.rs`, `geom.rs` | +| | P5 visibility (1.5) | `tables.rs`, `convert.rs`, `svg.rs` | +| | P6 crop (5): true extents with the metrics pre-pass, outlier guard, header candidate, frame split, padding, lattice snap, reporting | `crop.rs`, `geom.rs`, `svg/bounds.rs` | +| | P7 renderer and export (8): `Renderer`, parallel tiles, pyramid, sidecars, all shards, manifest, sharding, LAYOUT read + `layouts[]`, tmp-rename, schema, README | `render.rs`, `export.rs`, `schema/` | +| | P8 CLI and docs (2); P9 tests (4.5): corpus sweep over `lib/libredwg/test/test-data` (141 DWG, 67 DXF), goldens, determinism, the five example questions as acceptance tests, `docs/EVAL.md` | `uncad-cli`, `docs/`, `tests/` | +| **0.4.0 "Faithful"** (~30 pd) | text placement (4); MINSERT/BODY/IMAGE/LARGE_RADIAL_DIMENSION (2); lineweight, linetype, CTB colour map (3); viewport compositing + `sheets.json` (6); scale-group frames (2); tables, leaders, dynamic blocks (`*U` -> effective name) (4); xref reporting (1); batch/serve ergonomics (2); display parser (2); CI (4) | | +| **1.0 "Contract"** (~22 pd) | full DIMSTYLE formatter + XDATA overrides (4); NURBS and ellipse arcs (3); schema freeze, `non_exhaustive`, `Error` (2); docs (3); evaluation harness measuring VLM accuracy against glyph height (3); dynapi offset cache (2); EED/GROUP (2); release engineering (3) | | + +Each release is independently useful: 0.3.0 already answers "area of room 101", +"where is 1250", "how many D2 doors" and dimension questions from JSON, with +legible tiles. + +## 11. DWF + +`.dwf` returns `ParseError::UnsupportedFormat("DWF")` and the CLI exits 2: +"DWF is a publishing format without CAD semantics (no DIMENSION objects, +act_measurement, DIMSTYLE, layer state or INSUNITS); ask for the DWG/DXF or +convert upstream." DWFx (OPC zip + XPS FixedPage XML) could be read by a +separate `uncad-dwfx` crate (15-20 pd) producing a paper-only package under the +same schema with `capabilities.dimension_values: "text_only"`; scheduled only if +real DWFx inputs appear after 1.0. + +## 12. Risks and open questions + +- SHX glyph widths are unknowable without the `.shx` files; boxes from the + bundled face may differ 10-20 % (`estimated`). +- The DIMSTYLE formatter is reimplemented from the DXF reference; the cached + `*D` text stays the value of record and every mismatch is reported. +- OCS sign errors mirror geometry silently; gated on the mirrored fixture. + `VIEWTWIST` sign and non-plan viewports fall back to frames-only until the + fixture exists. +- Bundled font: an OFL notice inside a GPL crate; the subset size is a target. +- The CP949 path is traced in source only (no Korean file in the corpus); the + fixture is a prerequisite and CI needs a redistributable one. +- The absorb threshold and the frame-split rule are decided by the corpus + sweep; both are exposed as options. +- Dynapi reads grow ~5x per entity; if sample 5's 1.4 s parse doubles, the + offset cache moves forward. +- Rooms drawn as lines rather than closed polylines (common in older Korean + plans) yield no region; guidance tells the agent to fall back to the image. + Models with floors stacked in z collapse into one plan; a z histogram warning + is the mitigation. +- Vendor token rules drift; profiles are data and the manifest records the one + used. +- GPL-3 propagates to embedders; the practical pattern for proprietary hosts + is a subprocess plus a directory. +- Nothing stops an agent from reading numbers off pixels; the guidance string, + values in every sidecar and the evaluation harness are mitigations. From 3941fc7530cafd3d2357b753a37d9f5eab8e7d7d Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 20:13:46 +0900 Subject: [PATCH 50/54] docs: state what the LLM/VLM package does not do yet A CAVEATS section for uncad-export: the crop decided before text is laid out, glyphs outside the bundled subset, tile cost, the sheet compositing rule, record ids from the model, and how little of the corpus the port has been compared on. Co-Authored-By: Claude Opus 5.5 --- docs/CAVEATS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 4865099..a7054f3 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -907,6 +907,36 @@ a re-vendor that drops a patch fails by name instead of compiling upstream's cod colour `0x1ae464`, where the HATCH's was `0x0000e5` without the patch (see "An entity's true colour is what the file states" for how the colour is read). +## The LLM/VLM package (`uncad-export`, `uncad export`) + +`crates/uncad-export` writes the directory `docs/VLM_EXPORT_DESIGN.md` +describes. It is a consumer: it parses with `uncad::parse_with_header`, +renders through iron-render-cad's scene API, and computes every derived value +the model does not carry (display strings, lengths, areas, how far a +dimension's stored value is trusted). Known limits: + +- **The crop is decided before text is laid out.** Text boxes in `texts.json` + are the measured glyph outlines of the bundled font, but the crop that + frames the overview still uses the renderer's estimate. The frames and the + tile culling use the measured boxes. +- **A character the bundled subset lacks is drawn as a box.** The subset holds + Latin, Greek, the 2350 common Hangul syllables and the CAD symbols; the + record says `font_ok: false` and the manifest warns. +- **A tile costs what touches it.** Each tile draws only the entities whose + extent meets it, so a drawing whose every entity crosses every tile costs + tiles x content. `--max-tiles` (400) and `--max-levels` (5) bound the total. +- **Sheets are plan views composited by rule.** A viewport that is off, not a + plan view or the layout's overall frame is not composited; `sheets.json` + says which, per viewport. +- **Record ids are the model's entity ids.** They differ from the handle + strings the earlier monolithic export used; `source_handle` keeps the + file's handle. +- **Its numbers were measured against the earlier implementation on five + drawings only** (the corpus's `example_2000.dwg` and `example_2018.dxf` and + three local samples): every manifest count matched except the ATTDEF and + ATTRIB records the attribute walk now reads. A full comparison over the + corpus has not been run on this layout. + ## No DWG/DXF writing 0.1.0's `CadDatabase::write_dwg`/`write_dxf`, `uncad::dwg_to_dxf`, `WriteError` and the From 055ef9b1ff89623e63d8be0cf910724761b6fb6d Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 20:15:01 +0900 Subject: [PATCH 51/54] docs: record that the ported export is slower, with the measurement Release build, four drawings, the new export against the earlier monolithic one: 17 to 41 per cent slower. Not profiled yet. Co-Authored-By: Claude Opus 5.5 --- docs/CAVEATS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index a7054f3..4eff61d 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -936,6 +936,12 @@ dimension's stored value is trusted). Known limits: three local samples): every manifest count matched except the ATTDEF and ATTRIB records the attribute walk now reads. A full comparison over the corpus has not been run on this layout. +- **It is slower than the earlier implementation.** Release build, wall + clock around `uncad export`, measured on 2026-09-23 (Windows 11, Ryzen 5 + 6600H): `example_2000.dwg` 0.93 s against 0.80 s, `AutoCADSamples1.dwg` + 2.28 s against 1.61 s, `AutoCADSamples3.dwg` 2.46 s against 1.83 s, + `AutoCADSamples5.dwg` 4.73 s against 3.83 s. Where the extra time goes has + not been profiled. ## No DWG/DXF writing From 70826b958d373bfdb6897a78c943d98b7d2ee752 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 20:27:31 +0900 Subject: [PATCH 52/54] docs: record the whole-corpus comparison of the ported export Co-Authored-By: Claude Opus 5.5 --- docs/CAVEATS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 4eff61d..23842dd 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -936,6 +936,13 @@ dimension's stored value is trusted). Known limits: three local samples): every manifest count matched except the ATTDEF and ATTRIB records the attribute walk now reads. A full comparison over the corpus has not been run on this layout. +- **Whole-corpus comparison (2026-09-23).** All 208 corpus files through both + exports at `--max-levels 1`: 199 read by both, none read by only one. Manifest + counts differ on few files: entities on 9, texts on 5, geometry on 18, frames + on 1, paper texts on 1, hidden on 1 (listed in + `target/corpus_cmp/results.json` when rerun with `target/port/corpus_cmp.py`). + The differences seen are the ATTDEF/ATTRIB records the attribute walk now + reads; the geometry ones have not been classified one by one. - **It is slower than the earlier implementation.** Release build, wall clock around `uncad export`, measured on 2026-09-23 (Windows 11, Ryzen 5 6600H): `example_2000.dwg` 0.93 s against 0.80 s, `AutoCADSamples1.dwg` From bfcc2973f3e81bee38796b529b32a6f29a015123 Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 20:30:40 +0900 Subject: [PATCH 53/54] docs: hand the port over -- status, setup, what remains, PR drafts PORT_STATUS.md says what the port is, how to build the three repositories together without committing the local patch, what is done and what remains in order. UPSTREAM_PR_DRAFTS.md carries the three pull request descriptions; the corpus comparison script comes along. Co-Authored-By: Claude Opus 5.5 --- docs/PORT_STATUS.md | 90 ++++++++++++++++++ docs/UPSTREAM_PR_DRAFTS.md | 123 +++++++++++++++++++++++++ scripts/compare-export-with-archive.py | 44 +++++++++ 3 files changed, 257 insertions(+) create mode 100644 docs/PORT_STATUS.md create mode 100644 docs/UPSTREAM_PR_DRAFTS.md create mode 100644 scripts/compare-export-with-archive.py diff --git a/docs/PORT_STATUS.md b/docs/PORT_STATUS.md new file mode 100644 index 0000000..04cd5d8 --- /dev/null +++ b/docs/PORT_STATUS.md @@ -0,0 +1,90 @@ +# Port status: feat/0.3-readable onto the three-repository split + +Handoff for whoever continues this work (a person or an agent). Written 2026-09-23. + +## What this is + +The 0.3 "readable" work (LLM/VLM export, ~120 review fixes) was done in the old +monolithic uncad, preserved as the tag `archive/feat-0.3-readable` (c0213bb, pushed). +Upstream then split the project into three repositories: `uncad` (parser, this repo), +`uncad-model` (entity model, MIT) and `iron-render-cad` (renderer, MIT). The work is +being re-expressed on that split, feature by feature, on branch `integrate/0.3` in +all three repositories (pushed; each contains its repository's current `main`). + +Rules the owner set: do not publish crates (upstream releases them); do not push to +`main`; after each step, merge upstream `main` into `integrate/0.3` (where both sides +did the same thing, adopt upstream's shape), run the gates, push `integrate/0.3`. +Upstream's own rules (MSRV 1.88, no std HashMap/HashSet, cargo-deny, "a version +dependency, never a path", golden tests exact, "propose first" for new public API) +apply to everything. + +## Local setup to build the three together + +Clone `uncad`, `uncad-model` and `iron-render-cad` as siblings and check out +`integrate/0.3` in each. The unpublished model and renderer are wired in by an +UNCOMMITTED `.cargo/config.toml` in each consumer (add `.cargo/` to +`.git/info/exclude`; never commit it, and never commit a `Cargo.lock` that records +path sources): + +```toml +# uncad/.cargo/config.toml +[patch.crates-io] +uncad-model = { path = "/uncad-model" } +iron-render-cad = { path = "/iron-render-cad" } +``` + +```toml +# iron-render-cad/.cargo/config.toml +[patch.crates-io] +uncad-model = { path = "/uncad-model" } +``` + +Gates per repository: `cargo fmt --all --check`, +`cargo clippy --workspace --all-targets -- -D warnings`, +`cargo test --workspace --no-fail-fast`. At 2026-09-23: uncad 304, iron-render-cad +265, uncad-model 59, all passing. + +## Done + +- Native layer: five vendored LibreDWG patches (see `crates/libredwg-sys/NOTICE.md`), + shim, build.rs patch-marker check, per-crate LICENSE. +- Model fields (uncad-model), parser reading them (uncad), renderer phases A and B + (iron-render-cad), a sync with upstream's own OCS/bulge/vertex work. +- R2007+ DXF read, DOS-era code pages, `parse_bytes`, `uncad::Header`. +- iron-render-cad's render-once/assemble-many Scene API (a proposal for upstream). +- `crates/uncad-export` and `uncad export`; `--version`, `--include-hidden`, a strict + CLI parser. +- Docs: README, ARCHITECTURE, CAVEATS ("The LLM/VLM package"), VLM_EXPORT_DESIGN. + +## Remaining, in suggested order + +1. **CLI options not yet ported** (the library already has each): `--fit`, `--ppu`, + `--max-edge`, `--stroke`, `--bg`, `--crop`, `--padding`, `--lattice`, `--fonts`, + `--text-px`, `--shard-kb`, `--frame-gap`, `--min-frame-entities`, + `--max-frames`, `--full`. Source: `crates/uncad-cli/src/main.rs` and its tests at + the archive tag. +2. **Export parity not checked item by item.** The agent porting `export.rs` was + stopped after 9 commits (up to 895b8e2). Walk the archive tag's + `crates/uncad/src/export.rs`, `crop.rs`, `dimension.rs` and their tests and + confirm every behaviour exists in `crates/uncad-export`. +3. **Corpus differences to classify.** `scripts/compare-export-with-archive.py` + (needs the archive tag's release binary and this branch's) over all 208 corpus + files: 199 read by both, none by only one; manifest counts differ on 9 files + (entities), 5 (texts), 18 (geometry), 1 each (frames, paper texts, hidden). + Entities/texts are ATTDEF/ATTRIB records upstream's attribute walk now reads + (intended). The 18 geometry differences are unclassified. +4. **Speed.** Release build, the port is 17-41 % slower than the archive + (CAVEATS has the numbers). A rough stage split on AutoCADSamples5 showed parse, + JSON, SVG, PNG and export all 20-30 % slower, i.e. a shared path, but it was + measured under load; re-measure alone, then profile. +5. **Adversarial review** of all three `integrate/0.3` branches (none has been run + on the ported code). +6. **Deferred, needs a model decision:** HATCH extrusion (mirrored hatches drawn as + stated), EntityCommon lineweight/linetype/ltype_scale, LayoutRecord flags/extents/ + active viewport and more PlotSettings fields, the renderer's light-colour + darkening on white (a proposal). +7. **docs/EVAL.md** from the archive tag, re-measured on this layout. +8. **Upstream review.** `docs/UPSTREAM_PR_DRAFTS.md` has the three PR descriptions, + including the three decisions that reverse upstream's documents and the new public + API that is "propose first". Opening the PRs and releasing are the owner's and + upstream's calls. diff --git a/docs/UPSTREAM_PR_DRAFTS.md b/docs/UPSTREAM_PR_DRAFTS.md new file mode 100644 index 0000000..864c139 --- /dev/null +++ b/docs/UPSTREAM_PR_DRAFTS.md @@ -0,0 +1,123 @@ +# Pull request drafts: integrate/0.3 -> main + +Three pull requests, one per repository, all from the pushed branch `integrate/0.3`. +Each branch already contains its repository's current `main`, so every merge is a +fast-forward. They depend on each other: merge uncad-model first, then +iron-render-cad, then uncad. Nothing here publishes a crate; releases stay with the +maintainer. + +--- + +## uncad-model: carry what the files state for OCS, polylines, text, viewports, layouts + +**What this adds.** Fields the parser now reads and the renderer and the LLM/VLM +export consume. Every addition follows the model's principles: `Option` for what a +file may leave unstated, `Ref` for every reference by name or handle, a doc sentence +per field, and `serde(default)` so a 0.1.0 document still loads, except a 0.1.0 +polyline. That last case is pinned by a tripwire test. + +- Extrusion on TEXT, ATTRIB, ATTDEF and INSERT, alongside the extrusion upstream + added for CIRCLE, ARC, polylines, SOLID and TRACE. `Affine2::from_insert` applies + an INSERT's extrusion. +- Per-vertex `start_width` and `end_width` on upstream's `PolylineVertex`, and + `const_width` on the entity. +- Text placement: horizontal and vertical justification, the alignment point, + width factor, oblique angle and the style name. Attribute flags. +- MTEXT: `rect_width`, the extents and the style name. +- VIEWPORT: the stated view, `on`, `viewport_id` and the frozen layers. +- DIMENSION: the ordinate axis. MLINE: its scale. The new variant `Entity::PolylineMesh`. +- LayerRecord: off, frozen, locked, plot, lineweight and linetype. +- DimStyleRecord: the rest of the display variables, as `Option`. +- `Tables.layouts`: a LayoutRecord with its plot settings. +- Golden cases g11 to g16 pin all of the above. + +**Needs your decision.** These are "propose first" under the principles. +- The CadDatabase doc said the model was deliberately without linetypes, + lineweights and styles. Layers now carry state, lineweight and a linetype name, + and texts name their style. The linetype and style definitions, dictionaries and + header variables are still left out. +- A 0.1.0 polyline document no longer loads, because of your `PolylineVertex` + change combined with ours. This is a breaking 0.2.0. + +**Tests.** 59 pass. The fields were checked against a DXF twin of every drawing +where one exists. + +--- + +## iron-render-cad: robustness, hidden entities, sheets, and a render-once API + +**What this adds.** +- Robustness: a PNG size cap with an error, where `example_2000.dwg` used to abort + on a 36 TB allocation. Limits on block expansion and SVG size are reported in a + `LimitReport`. A rasterizer panic is caught, NaN is screened, and XML-illegal + characters are dropped. +- Correctness: a render origin for drawings far from 0, where f32 in + usvg/tiny-skia lost lines. RAY and XLINE are clipped to the picture. POINT is + drawn as a pixel-sized cross, and MTEXT keeps its blank lines. Text is drawn at + cap height. Layer-0 geometry inside a block inherits the reference's layer. + Text justification and text boxes, attribute invisibility, and MLINE scale. +- Hidden-entity rules for layers that are off, frozen, non-plotting or DEFPOINTS, + with `include_hidden` and a hidden count. +- `layout_to_svg` and `layout_to_png`: a paper layout with its viewports composited. +- A render-once, assemble-many surface: a Scene of per-entity parts, a window + assembly, crop modes with a report, text-box measurement and sheet reports. The + LLM/VLM export builds on it. + +**Needs your decision.** Everything below is a new public type or function, so it +is "propose first" under section 9. Fields added to returned results need no +discussion under your newest principle. +- `PngSize`, `Fonts` (with `Custom(bytes)`, used to inject a font; nothing is + bundled here), `Background`, `DEFAULT_MAX_EDGE`, `DEFAULT_CAP_HEIGHT`. +- `limits::{LimitReport, Dropped, Cap}`. +- `layout_to_svg`, `layout_to_png` and `LayoutError`. +- The Scene API: each item's reason is in commit 6e33818. + +**Behaviour taken from your side.** The single-pass text code reader, OCS drawing +for circles, arcs, polylines, SOLID and TRACE, bulge arcs and 3DFACE hidden edges +all replaced our versions. The remaining differences are listed in the merge commit +message: %%c, %%nnn range, and the tolerance-stack spelling. + +**Tests.** 265 pass. No test compares rendered images. + +--- + +## uncad: R2007+ DXF, code pages, header, every new field, and uncad-export + +**What this adds.** +- Five local patches to the vendored LibreDWG, listed in `crates/libredwg-sys/NOTICE.md`. + `build.rs` counts their markers so a re-vendor cannot drop one silently: + - R2007+ DXF table names are compared decoded. + - A corrupt date no longer aborts the process in strftime. + - A polygon-mesh DXF is no longer refused whole (two patches). + - An entity's true colour and transparency are no longer swapped. +- R2007+ DXF is read instead of refused. A guard turns an R2007+ DXF that would + come out empty into an error. The 27 readable corpus files match their DWG twins. +- TextDecoder handles the DOS-era Big5, GB2312 and CP932 code pages. + `parse_bytes`, `Format` and `ParseError::Io` are added, and a non-ASCII path + now opens. +- `uncad::Header`, returned beside the model by `parse_with_header`. Unstated + values are `None`. +- Every new model field is read. VERTEX records are no longer entities, and + POLYLINE_MESH, true colour, layer state, layouts and viewports are read. + Subentity walks are bounded. +- `crates/uncad-export` is new. It writes the LLM/VLM package: overview, tile + pyramid, sheets, records, strings index, manifest. It bundles a Noto Sans KR + subset under OFL-1.1, granted in `deny.toml`. +- The CLI adds `uncad export`, `--version` and `--include-hidden`. Unknown options + are now errors. + +**Needs your decision.** Three reverse documented positions. +- CAVEATS said the vendored copy is unmodified. It now carries five patches, each + measured and pinned by a test. +- CAVEATS said R2007+ DXF "cannot be fixed on this side". It is now read, with the + twin comparison as evidence. +- Header variables are returned beside the model rather than inside it. + +**Tests.** 304 in the workspace, the CLI's 23 included. The corpus sweep pins were +re-measured, with the reasons in the commit messages. On five drawings the export's +counts match the earlier monolithic implementation. The two differences come from +your attribute walk reading more ATTDEF/ATTRIB records, which is intended. + +**Release order when you are ready.** uncad-model 0.2.0, then iron-render-cad 0.2.0, +then libredwg-sys 0.3.0, uncad 0.3.0, uncad-export 0.1.0 and uncad-cli 0.3.0. Publish +from a clean checkout without any local `[patch]`. diff --git a/scripts/compare-export-with-archive.py b/scripts/compare-export-with-archive.py new file mode 100644 index 0000000..f5fb775 --- /dev/null +++ b/scripts/compare-export-with-archive.py @@ -0,0 +1,44 @@ +"""Compare `uncad export` of this branch with the archived monolithic implementation. + +Build both release binaries first: this branch's into target/release/uncad.exe, and the +tag archive/feat-0.3-readable's (in a worktree, its own target dir) copied to +target/ab/uncad-feat.exe. Runs both over every DWG/DXF under lib/libredwg/test/test-data +at --max-levels 1 and compares the manifest counts. Paths below are the ones used on +2026-09-23; adjust them for your checkout. +""" +import os, json, subprocess, shutil +from concurrent.futures import ThreadPoolExecutor +NEW=r"C:\data\uncad\target\release\uncad.exe"; OLD=r"C:\data\uncad\target\ab\uncad-feat.exe" +BASE=r"C:\data\uncad\lib\libredwg\test\test-data"; OUT=r"C:\data\uncad\target\corpus_cmp" +shutil.rmtree(OUT, ignore_errors=True); os.makedirs(OUT) +files=[os.path.join(dp,f) for dp,_,fs in os.walk(BASE) for f in fs if f.lower().endswith(('.dwg','.dxf'))] +KEYS=["entities","texts","texts_paper","dimensions","geometry","regions","block_instances","hidden","sheets","frames"] +def one(f): + rel=os.path.relpath(f,BASE).replace("\\","_") + res={} + for side,exe in (("old",OLD),("new",NEW)): + d=os.path.join(OUT,side,rel) + try: + r=subprocess.run([exe,"export",f,"-o",d,"--max-levels","1"],capture_output=True,timeout=120) + rc=r.returncode + except subprocess.TimeoutExpired: rc="timeout" + c={} + try: + m=json.load(open(os.path.join(d,"manifest.json"),encoding="utf-8")); c={k:m.get("counts",{}).get(k) for k in KEYS} + except Exception: pass + res[side]=(rc,c) + return rel,res +with ThreadPoolExecutor(10) as ex: results=list(ex.map(one, files)) +json.dump(results, open(os.path.join(OUT,"results.json"),"w"), indent=0) +both=[r for r in results if r[1]["old"][0]==0 and r[1]["new"][0]==0] +only_old=[r[0] for r in results if r[1]["old"][0]==0 and r[1]["new"][0]!=0] +only_new=[r[0] for r in results if r[1]["new"][0]==0 and r[1]["old"][0]!=0] +diff={} +for rel,res in both: + for k in KEYS: + a=res["old"][1].get(k); b=res["new"][1].get(k) + if a!=b: diff.setdefault(k,[]).append((rel,a,b)) +print(f"files {len(files)}: both ok {len(both)}, only old ok {len(only_old)}, only new ok {len(only_new)}") +print("only old:", only_old[:10]); print("only new:", only_new[:10]) +for k,v in diff.items(): print(f"{k}: {len(v)} files differ, e.g. {v[:4]}") +if not diff: print("all manifest counts equal on files both read") From e2aaec2c68a40806adcf68eeead3086c3cff2acf Mon Sep 17 00:00:00 2001 From: iujunhyung Date: Wed, 23 Sep 2026 20:31:12 +0900 Subject: [PATCH 54/54] docs: address the handoff to upstream Co-Authored-By: Claude Opus 5.5 --- docs/PORT_STATUS.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/PORT_STATUS.md b/docs/PORT_STATUS.md index 04cd5d8..ff52904 100644 --- a/docs/PORT_STATUS.md +++ b/docs/PORT_STATUS.md @@ -1,6 +1,6 @@ # Port status: feat/0.3-readable onto the three-repository split -Handoff for whoever continues this work (a person or an agent). Written 2026-09-23. +Handoff to the upstream maintainer and their agent. Written 2026-09-23. ## What this is @@ -11,12 +11,12 @@ Upstream then split the project into three repositories: `uncad` (parser, this r being re-expressed on that split, feature by feature, on branch `integrate/0.3` in all three repositories (pushed; each contains its repository's current `main`). -Rules the owner set: do not publish crates (upstream releases them); do not push to -`main`; after each step, merge upstream `main` into `integrate/0.3` (where both sides -did the same thing, adopt upstream's shape), run the gates, push `integrate/0.3`. -Upstream's own rules (MSRV 1.88, no std HashMap/HashSet, cargo-deny, "a version -dependency, never a path", golden tests exact, "propose first" for new public API) -apply to everything. +**Handed over to upstream.** The iujunhyung side has stopped working on this; the +branches are now upstream's to review, change, merge into `main` or drop. Nothing has +been merged into any `main` and nothing has been published. While it was in progress +the branches followed upstream's rules (MSRV 1.88, no std HashMap/HashSet, +cargo-deny, "a version dependency, never a path", golden tests exact) and treated +new public API as "propose first" -- the proposals are in the PR drafts below. ## Local setup to build the three together @@ -84,7 +84,8 @@ Gates per repository: `cargo fmt --all --check`, active viewport and more PlotSettings fields, the renderer's light-colour darkening on white (a proposal). 7. **docs/EVAL.md** from the archive tag, re-measured on this layout. -8. **Upstream review.** `docs/UPSTREAM_PR_DRAFTS.md` has the three PR descriptions, - including the three decisions that reverse upstream's documents and the new public - API that is "propose first". Opening the PRs and releasing are the owner's and - upstream's calls. +8. **Upstream review.** `docs/UPSTREAM_PR_DRAFTS.md` describes each repository's + changes, the three decisions that reverse upstream's own documents (vendored + LibreDWG patched, R2007+ DXF read instead of refused, the header beside the + model) and the new public API. Whether to take them, merge order + (uncad-model, then iron-render-cad, then uncad) and releases are upstream's.