Files
OneCAD-Draft/docs/cadcodec-reader-gaps.md
T
Felix RiestraandClaude Sonnet 5 f1813b3ebe Draft cadcodec issues for the DXF reader gaps; fix Spline rational flag
Documents six cadcodec reader/writer gaps with source references, canary
tests and suggested fixes (nothing filed). Verifying them showed the Spline
DXF weight loss was an OCS bug: the writer emits weights only for a rational
spline, so the host now sets flags.rational from the weights. Spline passes
every gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:37:41 +02:00

9.7 KiB

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:

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.

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").

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).