Files
OneCAD-Draft/docs/cadcodec-reader-gaps.md
T
Hakan SevenandClaude Opus 5.5 6d9365ea88 fix(xclip): follow-ups to the XCLIP merge
- An inverted clip's roundtrip record goes in the filter's extension
  dictionary through the codec's own API (ensure_extension_dictionary /
  xrecord / extension_dictionary_handle) instead of a scan of every
  object, which the renderer ran for each clipped reference.
- Pin cadcodec 1c3be0c (the DXF writer now links a spatial filter's
  extension dictionary, so that record is not left unreferenced) and
  cadkernel deb529b; plugin crates and the Python plugin lock follow.
- The XCLIP boundary preview stays f64.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 23:46:50 +03:00

230 lines
12 KiB
Markdown

# cadcodec DXF reader/writer gaps found by the OCS Python host audit
Reports on `HakanSeven12/cadcodec` (the `acadrust` crate). Every finding was made against
**cadcodec revision `5b682ed`** by saving a document with `DxfWriter` or `DwgWriter`,
reloading it, and comparing entity fields.
**Status (23 September 2026):** the fixes landed in cadcodec as
[#48](https://github.com/HakanSeven12/cadcodec/pull/48) (issues 1-6) and
[#51](https://github.com/HakanSeven12/cadcodec/pull/51) (the first three style findings), and
the block description with commit `dd1d7bf` (cadcodec issue #49). OCS pins `1c3be0c`, which
includes it; the canaries below were flipped against `dd1d7bf`. What is still open:
- **ATTDEF `lock_position`** (issue 1): the reader is complete, but the DXF writer emits no
group 280 at all, neither the version byte nor the lock flag the reader expects after it.
- **LEADER true-colour `override_color`** (issue 4): DXF group 77 holds an ACI index only, so
a true colour cannot be written there, and the DWG writer does not store the field.
- **`TextStyle::true_type_font`** (style findings): still neither written nor read.
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)
Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a canary. The first
three are fixed in [HakanSeven12/cadcodec#51](https://github.com/HakanSeven12/cadcodec/pull/51)
(independent of #48); the last two are not filed, because fixing them needs the XDATA layout
AutoCAD expects and that cannot be checked here.
- **`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 did not carry `BlockRecord::description`
(found by `audit_python_blocks_over_real_ipc`). It needs no XDATA: it is BLOCK group 4, which
the reader parsed onto the discarded BLOCK marker and the writer never emitted. Fixed in
cadcodec `dd1d7bf` (cadcodec issue #49).