forked from 1b-agent/opencadstudio-upstream
The codec and kernel repositories and crates are renamed opencadcodec and opencadkernel. Inside OCS they are depended on under short keys, so code reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node graph engine). File-format and protocol spellings stay as they were: the ACIS product id `acadrust`, the ACADRUST_* switches and the plugin version keys `acadrust_version` / `acadrust_source`. A button left of MCP on the command line opens a translucent node graph over the viewport, with a palette of object, input, math, logic, list, text and geometry nodes added by click or drag. An object node owns drawing entities and its rows are the Properties panel's own rows for them: the single-object page is extracted from refresh_properties as entity_property_sections, values use the control protocol's property_json, and writes go through set_property_value with a property target override, so the panel, MCP and the graph share one read/write path. A list reaching an object row makes one entity per element. Each graph action is one undo step; the canvas pans and zooms. Co-Authored-By: Claude Opus 5.5 <[email protected]>
230 lines
12 KiB
Markdown
230 lines
12 KiB
Markdown
# opencadcodec DXF reader/writer gaps found by the OCS Python host audit
|
|
|
|
Reports on `HakanSeven12/opencadcodec` (the `opencadcodec` crate). Every finding was made against
|
|
**opencadcodec revision `5b682ed`** by saving a document with `DxfWriter` or `DwgWriter`,
|
|
reloading it, and comparing entity fields.
|
|
|
|
**Status (23 September 2026):** the fixes landed in opencadcodec as
|
|
[#48](https://github.com/HakanSeven12/opencadcodec/pull/48) (issues 1-6) and
|
|
[#51](https://github.com/HakanSeven12/opencadcodec/pull/51) (the first three style findings), and
|
|
the block description with commit `dd1d7bf` (opencadcodec 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 opencadcodec 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 opencadcodec.
|
|
|
|
**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/opencadcodec#51](https://github.com/HakanSeven12/opencadcodec/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 opencadcodec 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
|
|
opencadcodec `dd1d7bf` (opencadcodec issue #49).
|
|
|