forked from 1b-agent/opencadstudio-upstream
TableOperation gains BlockCreate (copy entities into a definition shifted by the base point, optionally erasing originals), BlockModify, BlockRename (inserts follow) and BlockDelete (refused while referenced; removes members, markers and orphaned draw-order tables). Real-IPC audit with DWG/DXF persistence and undo, IPC and Python unit tests, docs; the DXF loss of the block description is pinned by a canary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
214 lines
11 KiB
Markdown
214 lines
11 KiB
Markdown
# cadcodec DXF reader/writer gaps found by the OCS Python host audit
|
|
|
|
Draft issue reports for `HakanSeven12/cadcodec` (the `acadrust` crate). Nothing
|
|
here has been filed. Every finding was made against **cadcodec revision
|
|
`5b682ed`** (OCS pins `acadrust` to it) by saving a document with
|
|
`DxfWriter` or `DwgWriter`, reloading it, and comparing entity fields.
|
|
|
|
Each report names an executable canary in OCS (`src/app/plugin_host.rs`). The
|
|
canaries assert today's wrong result, so they fail loudly once cadcodec is
|
|
fixed; flip the marked expectation when that happens. To reproduce, from the
|
|
OCS repository:
|
|
|
|
```sh
|
|
STAGE="$PWD/work/bundled-plugins/opencad.python"
|
|
bash plugins/opencad-python/tools/stage-bundled.sh "$STAGE" --debug
|
|
cargo build --bin OpenCADStudio
|
|
OCS_TEST_PYTHON_PLUGIN="$STAGE/libopencad_python.dylib" \
|
|
OCS_PLUGIN_RUNNER_EXE="$PWD/target/debug/OpenCADStudio" \
|
|
cargo test --lib app::plugin_host::tests::<test name> -- --test-threads=1
|
|
```
|
|
|
|
| # | Area | Severity | Effect |
|
|
|---|---|---|---|
|
|
| 1 | `ATTDEF` reader | high | Most attribute-definition properties silently reset to defaults |
|
|
| 2 | Boolean group codes (290-299) | high | Booleans read through `as_i16()` are never applied |
|
|
| 3 | `UNDERLAY` rotation | high | Rotation is not converted back to radians; repeated saves compound it |
|
|
| 4 | `LEADER` | medium | Annotation link and three vectors are lost |
|
|
| 5 | `DRAWINGVIEW` / `SECTIONLINE` | medium | Typed entity not restored from the ENTITIES section |
|
|
| 6 | `TABLE` merged ranges | low | `merged_ranges` is empty after DXF reopen |
|
|
|
|
---
|
|
|
|
## 1. ATTDEF reader handles eight group codes; the writer emits all of them
|
|
|
|
**Where:** `src/io/dxf/reader/section_reader.rs`, `read_attdef` (around line
|
|
15647). The writer, `write_attdef` in `writer/section_writer.rs` (around line
|
|
4837), emits `7, 11, 41, 51, 70, 71, 72, 73, 74, 210` and `280` in addition to
|
|
the groups the reader consumes.
|
|
|
|
**What happens:** the reader has arms for groups `1, 2, 3, 10/20/30, 40, 50,
|
|
280` and `101` only; everything else falls through to `_ =>`. After a DXF round
|
|
trip these fields reset to defaults:
|
|
|
|
- text style name (7)
|
|
- alignment point (11/21/31) and both alignments (72, 74)
|
|
- width factor (41) and oblique angle (51)
|
|
- attribute flags (70): invisible, constant, verify, preset
|
|
- text generation flags (71) and field length (73)
|
|
- normal (210/220/230) and the position-lock state
|
|
|
|
Tag, prompt, default value, insertion point, height and rotation survive.
|
|
Losing the attribute flags is the worst part: an invisible or constant
|
|
attribute becomes visible and editable after a DXF save.
|
|
|
|
**Evidence:** `audit_python_attribute_definition_lifecycle_over_real_ipc`
|
|
compares every writable field through both formats. DWG round-trips the full
|
|
state; the DXF digest is pinned to the defaults (see the `BLOCKER` comment on
|
|
`expect_edited_dxf`).
|
|
|
|
**Suggested fix:** add reader arms for the missing groups, using the same
|
|
conversions the writer applies (rotation and oblique angle are written in
|
|
degrees). `TEXT` and `ATTRIB` already read most of these groups and can share
|
|
the implementation.
|
|
|
|
## 2. Boolean group codes are read with `as_i16()` and never applied
|
|
|
|
**Where:** `src/io/dxf/reader/stream_reader.rs` defines
|
|
`DxfCodePair::as_i16()` as `Some` only for `CodePairValue::Int`. Groups 290-299
|
|
are typed `Bool`, so `as_i16()` returns `None` for them and
|
|
`unwrap_or(0) != 0` yields `false`. The pattern appears in many arms of
|
|
`section_reader.rs`.
|
|
|
|
**Confirmed impact (round-trip tested):**
|
|
|
|
- `HELIX` group 290 (`handedness`): a clockwise helix reopens counter-clockwise.
|
|
The reader arm is `290 => { if let Some(v) = pair.as_i16() { ... } }`
|
|
(around line 12796).
|
|
- `LIGHT` groups 291, 292, 293 (`plot_glyph`, `use_attenuation_limits`,
|
|
`cast_shadows`; around lines 18154-18182), and 290 for the photometric web
|
|
file flag: `cast_shadows` reopens `false`.
|
|
|
|
**Same pattern, not individually tested** (line numbers in `section_reader.rs`):
|
|
13833, 13955-13969 (point-cloud style flags); 18194 (`has_web_file`);
|
|
18431-18438 and 18493-18494 (sweep, loft and revolve option flags); and several
|
|
`if let Some(v) = pair.as_i16()` arms on 290/292 near lines 9815, 10096, 10478
|
|
and 12861. Some other arms already use `as_bool()` correctly (for example lines
|
|
18376 and 18550), so the fix is mechanical.
|
|
|
|
**Evidence:** `audit_python_helix_lifecycle_over_real_ipc` (canary
|
|
`expect_edited_dxf`) and `audit_python_light_lifecycle_over_real_ipc`.
|
|
|
|
**Suggested fix:** read these groups with a helper that accepts either
|
|
representation, for example
|
|
`pair.as_bool().or_else(|| pair.as_i16().map(|v| v != 0))`, and audit every arm
|
|
whose code is in 290-299. Alternatively make `as_i16()` accept `Bool` values.
|
|
|
|
## 3. `UNDERLAY` rotation is written in degrees and read back unconverted
|
|
|
|
**Where:** `write_underlay` writes group 50 with `underlay.rotation.to_degrees()`
|
|
(around line 11038 of `writer/section_writer.rs`). `read_underlay` stores the
|
|
raw value: `50 => { if let Some(v) = pair.as_double() { underlay.rotation = v; } }`
|
|
(`reader/section_reader.rs`, around line 19470), with no `to_radians()`.
|
|
|
|
**What happens:** a rotation of `0.5` rad is written as 28.6479 degrees and
|
|
read back as 28.6479 rad. Because the writer converts again on every save, the
|
|
error compounds: a second save-and-reopen yields 1641.4. This silently corrupts
|
|
existing drawings each time they pass through cadcodec.
|
|
|
|
**Evidence:** `audit_python_underlay_lifecycle_over_real_ipc`; the canary
|
|
`expect_edited_dxf` / `expect_reedited_dxf` records 28.6 then 1641.4.
|
|
|
|
**Suggested fix:** `underlay.rotation = v.to_radians()` in the reader. Check
|
|
that `DGN`/`DWF`/`PDF` underlays share the code path.
|
|
|
|
## 4. `LEADER`: annotation link and vectors are lost in DXF
|
|
|
|
**a. The writer never emits group 340.** `write_leader` writes 3, 71-76, 10, 210,
|
|
211, 212 and 213 but no associated-annotation handle, while the reader has a
|
|
`340 =>` arm that fills `annotation_handle` (around line 16619). A DXF save
|
|
therefore unlinks every leader from its text, tolerance or block annotation.
|
|
|
|
**b. The coordinate mapping does not reassemble 211, 212 and 213.**
|
|
`GroupCodeValueType::coordinate_axis` in `src/io/dxf/group_code_value.rs` maps
|
|
X to `10..=18 | 110..=112 | 210 | 1010..=1013` (and the Y/Z equivalents), so
|
|
`add_coordinate` ignores the 211/221/231, 212/222/232 and 213/223/233 series.
|
|
`read_leader` uses a `PointReader` for `horizontal_direction`, `block_offset`
|
|
and `annotation_offset`, so all three reopen as their defaults even though the
|
|
writer emits them. (`normal`, group 210, works.)
|
|
|
|
**c. `override_color` is not persisted** in DXF or DWG: a leader coloured by
|
|
`override_color` reopens `ByLayer`. Observed, cause not traced.
|
|
|
|
**Evidence:** the canaries in `staged_python_leader_lifecycle_over_real_ipc`
|
|
(search for `BLOCKER`). DWG keeps the annotation link, offset and arrow flag.
|
|
|
|
**Suggested fix:** write group 340 for a non-null `annotation_handle`; extend
|
|
`coordinate_axis`/`coordinate_group` to cover the 211-213 series (or read those
|
|
groups explicitly); decide where the override colour belongs (group 62/420 on
|
|
the leader).
|
|
|
|
## 5. `DRAWINGVIEW` and `SECTIONLINE` are dispatched only inside blocks
|
|
|
|
**Where:** `section_reader.rs` has two entity dispatch tables: one for block
|
|
contents (around lines 3241-3390) and one for the ENTITIES section
|
|
(`read_entities`, from line 3482). `"DRAWINGVIEW"` (line 3386) and
|
|
`"SECTIONLINE"` (line 3382) appear only in the first; the ENTITIES table has no
|
|
arm for them. `write_view_border_dxf` writes `DRAWINGVIEW` correctly.
|
|
|
|
**What happens:** a `ViewBorder` saved to DXF in the drawing's entity list
|
|
reopens as a different, opaque entity kind instead of `ViewBorder`. DWG restores
|
|
the typed record. The same applies to `SectionSymbol`.
|
|
|
|
**Evidence:** `audit_python_view_border_lifecycle_over_real_ipc` (canary
|
|
`expect_edited_dxf: "wrong kind"`) and `audit_python_section_symbol_lifecycle_over_real_ipc`
|
|
(canary `wrong kind Unknown`).
|
|
|
|
**Suggested fix:** add the two arms to the ENTITIES dispatch, calling
|
|
`read_view_border_dxf` and `read_section_symbol_dxf`.
|
|
|
|
## 6. `TABLE` `merged_ranges` is empty after a DXF reopen
|
|
|
|
**What happens:** the writer emits the merged-range list (`AcDbFormattedTableData`,
|
|
group 90 then 91-94 per range; near line 10216 of `writer/section_writer.rs`),
|
|
and `reader/section_reader/table_content.rs` (around lines 627-690) appears to
|
|
parse it, yet `Table::merged_ranges` is empty after a DXF reopen. The merge
|
|
itself survives through the origin cell's `merge_width` and `merge_height`
|
|
(groups 175 and 176), and DWG restores `merged_ranges` but not the per-cell
|
|
dimensions.
|
|
|
|
**Impact:** low, because the merge is preserved in the cell fields; consumers
|
|
that read `merged_ranges` directly see nothing. Cause not traced; it may be that
|
|
the entity path does not call the table-content parser.
|
|
|
|
**Evidence:** `staged_python_table_lifecycle_over_real_ipc` (the DXF branch
|
|
asserts an empty `merged_ranges` and marks it as a blocker).
|
|
|
|
**Suggested fix:** rebuild `merged_ranges` from the cell merge dimensions after
|
|
reading, or make sure the `AcDbFormattedTableData` block is parsed for entities.
|
|
|
|
---
|
|
|
|
## Observed but not filed
|
|
|
|
These are format constraints or OCS-side issues, not reader bugs:
|
|
|
|
- **DWG R2010+ does not store** a leader's `text_height`, `text_width` or
|
|
`hookline_enabled`, or a 3-D polyline's `elevation`; the writer stores them
|
|
only for the older versions that define them.
|
|
- **Entity-level `MultiLeader.text_height`** is not stored by either writer
|
|
(`context.text_height` is), so OCS exposes it read-only.
|
|
- **Legacy `Polyline` reopens as `Polyline3D`** from both formats and a 2-D
|
|
polyline reopens as `LwPolyline` from DXF. These look like deliberate
|
|
normalization; OCS made the legacy kind update-only and records the DXF
|
|
conversion.
|
|
- **Spline weights in DXF** were briefly suspected but are correct: the writer
|
|
emits group 41 only when `flags.rational` is set, so the host must set that
|
|
flag when weights are present (fixed in OCS).
|
|
|
|
### Text and dimension style findings (2026-09-21, not filed)
|
|
|
|
Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a canary.
|
|
|
|
- **`STYLE` generation flags:** the DXF writer hard-codes group 71 to 0, so a text
|
|
style's `flags.backward` and `flags.upside_down` are lost on every DXF save (DWG keeps them).
|
|
- **`STYLE` oblique angle units:** group 50 is written and read as the raw radian value, while DXF
|
|
defines it in degrees; it round-trips inside cadcodec but AutoCAD would read 15 degrees as 0.26.
|
|
DWG stores radians correctly.
|
|
- **`DIMSTYLE` text style name:** the DXF reader keeps only the text-style handle (group 340) and never
|
|
resolves `dimtxsty` from it, so the name reopens as `Standard` (the handle is right; DWG resolves both).
|
|
- **`TextStyle::true_type_font`** is never written or read by either codec, so it lives only in memory.
|
|
- **`BLOCK_RECORD` description:** the DXF codec does not carry `BlockRecord::description`
|
|
(found by `audit_python_blocks_over_real_ipc`, pinned by a canary); DWG keeps it.
|
|
|