forked from 1b-agent/opencadstudio-upstream
Complete Table host model coverage
Maps rows, columns, merged ranges, style and placement through the generic generator (now with tuple and usize support), adds grid/merge/reference validation, and keeps DXF cell merge dimensions in step with merged_ranges. staged_python_table_lifecycle_over_real_ipc passes the full gate including DWG and DXF reopen. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
b3f5388208
commit
0206c7c30b
12 files changed
+428
-12
No files matched your search
@@ -786,6 +786,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
|
||||
| "annotation_offset"
|
||||
)
|
||||
| ("MultiLeader", _)
|
||||
| ("Table", _)
|
||||
| (
|
||||
"MLine",
|
||||
"flags"
|
||||
|
||||
@@ -331,6 +331,19 @@
|
||||
"arrowhead_handle",
|
||||
"line_type_handle",
|
||||
"block_content_handle"
|
||||
],
|
||||
"Table": [
|
||||
"layer",
|
||||
"insertion_point",
|
||||
"horizontal_direction",
|
||||
"normal",
|
||||
"table_style_handle",
|
||||
"rows",
|
||||
"columns",
|
||||
"merged_ranges",
|
||||
"break_spacing",
|
||||
"break_flow_direction",
|
||||
"break_options"
|
||||
]
|
||||
},
|
||||
"readable": {
|
||||
@@ -363,6 +376,23 @@
|
||||
"property_override_flags",
|
||||
"text_align_in_ipe",
|
||||
"text_height"
|
||||
],
|
||||
"Table": [
|
||||
"block_name",
|
||||
"block_record_handle",
|
||||
"data_version",
|
||||
"value_flags",
|
||||
"override_flag",
|
||||
"override_border_color",
|
||||
"override_border_line_weight",
|
||||
"override_border_visibility",
|
||||
"legacy_style_override",
|
||||
"name",
|
||||
"description",
|
||||
"field_handles",
|
||||
"base_style",
|
||||
"break_data",
|
||||
"break_ranges"
|
||||
]
|
||||
},
|
||||
"aliases": {
|
||||
|
||||
@@ -548,6 +548,80 @@ fn validate_multileader(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Field checks shared by Table creation and mutation.
|
||||
#[cfg(feature = "host")]
|
||||
fn validate_table(
|
||||
old: Option<&crate::host::acadrust::entities::Table>,
|
||||
new: &crate::host::acadrust::entities::Table,
|
||||
) -> Result<(), String> {
|
||||
if old == Some(new) {
|
||||
return Ok(());
|
||||
}
|
||||
finite_vector("Table.insertion_point", &new.insertion_point)?;
|
||||
solid_normal("Table.normal", &new.normal)?;
|
||||
solid_normal("Table.horizontal_direction", &new.horizontal_direction)?;
|
||||
if !new.break_spacing.is_finite() || new.break_spacing < 0.0 {
|
||||
return Err("Table.break_spacing must be finite and non-negative".into());
|
||||
}
|
||||
if new.rows.is_empty() || new.columns.is_empty() {
|
||||
return Err("Table requires at least one row and one column".into());
|
||||
}
|
||||
for (ci, column) in new.columns.iter().enumerate() {
|
||||
if !column.width.is_finite() || column.width <= 0.0 {
|
||||
return Err(format!("Table.columns[{ci}].width must be finite and greater than zero"));
|
||||
}
|
||||
}
|
||||
for (ri, row) in new.rows.iter().enumerate() {
|
||||
if !row.height.is_finite() || row.height <= 0.0 {
|
||||
return Err(format!("Table.rows[{ri}].height must be finite and greater than zero"));
|
||||
}
|
||||
if row.cells.len() != new.columns.len() {
|
||||
return Err(format!(
|
||||
"Table.rows[{ri}] has {} cells but the table has {} columns",
|
||||
row.cells.len(),
|
||||
new.columns.len()
|
||||
));
|
||||
}
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
if !cell.rotation.is_finite() || !cell.geometry_scale.is_finite() || !cell.block_scale.is_finite() {
|
||||
return Err(format!("Table.rows[{ri}].cells[{ci}] has non-finite rotation or scale"));
|
||||
}
|
||||
for (ki, content) in cell.contents.iter().enumerate() {
|
||||
if !content.rotation.is_finite() || !content.scale.is_finite() || !content.text_height.is_finite() || content.text_height < 0.0 {
|
||||
return Err(format!(
|
||||
"Table.rows[{ri}].cells[{ci}].contents[{ki}] has invalid rotation, scale or text height"
|
||||
));
|
||||
}
|
||||
if !content.value.numeric_value.is_finite() {
|
||||
return Err(format!(
|
||||
"Table.rows[{ri}].cells[{ci}].contents[{ki}].value.numeric_value must be finite"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (rows, columns) = (new.rows.len(), new.columns.len());
|
||||
for (mi, range) in new.merged_ranges.iter().enumerate() {
|
||||
if range.top_row > range.bottom_row
|
||||
|| range.left_col > range.right_col
|
||||
|| range.bottom_row >= rows
|
||||
|| range.right_col >= columns
|
||||
{
|
||||
return Err(format!("Table.merged_ranges[{mi}] lies outside the {rows}x{columns} grid"));
|
||||
}
|
||||
for (mj, other) in new.merged_ranges.iter().enumerate().skip(mi + 1) {
|
||||
let separate = range.bottom_row < other.top_row
|
||||
|| other.bottom_row < range.top_row
|
||||
|| range.right_col < other.left_col
|
||||
|| other.right_col < range.left_col;
|
||||
if !separate {
|
||||
return Err(format!("Table.merged_ranges[{mi}] overlaps merged_ranges[{mj}]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate newly created geometry for the kinds whose mapped fields have
|
||||
/// host checks. Called by the Python add path before an entity enters the document.
|
||||
#[cfg(feature = "host")]
|
||||
@@ -643,6 +717,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<()
|
||||
EntityType::MLine(value) => validate_mline(None, value)?,
|
||||
EntityType::Dimension(value) => validate_dimension(None, value)?,
|
||||
EntityType::MultiLeader(value) => validate_multileader(None, value)?,
|
||||
EntityType::Table(value) => validate_table(None, value)?,
|
||||
EntityType::AttributeDefinition(value) => {
|
||||
finite_vector(
|
||||
"AttributeDefinition.insertion_point",
|
||||
@@ -999,6 +1074,7 @@ pub fn validate_entity_mutation(
|
||||
}
|
||||
(EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?,
|
||||
(EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => validate_multileader(Some(old), new)?,
|
||||
(EntityType::Table(old), EntityType::Table(new)) => validate_table(Some(old), new)?,
|
||||
(EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?,
|
||||
(EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?,
|
||||
(EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => {
|
||||
@@ -1262,6 +1338,32 @@ pub fn validate_canvas_entity_references(
|
||||
return Err(format!("Shape text style {:?} has no SHX file", style.name));
|
||||
}
|
||||
}
|
||||
if let EntityType::Table(value) = entity {
|
||||
if let Some(handle) = value.table_style_handle.filter(|handle| !handle.is_null()) {
|
||||
if !matches!(
|
||||
document.objects.get(&handle),
|
||||
Some(crate::host::acadrust::objects::ObjectType::TableStyle(_))
|
||||
) {
|
||||
return Err(format!("Table style handle {handle:?} does not exist"));
|
||||
}
|
||||
}
|
||||
for (ri, row) in value.rows.iter().enumerate() {
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
for content in &cell.contents {
|
||||
if let Some(handle) = content.text_style_handle.filter(|handle| !handle.is_null()) {
|
||||
if !document.text_styles.iter().any(|style| style.handle == handle) {
|
||||
return Err(format!("Table.rows[{ri}].cells[{ci}] text style handle {handle:?} does not exist"));
|
||||
}
|
||||
}
|
||||
if let Some(handle) = content.block_handle.filter(|handle| !handle.is_null()) {
|
||||
if !document.block_records.iter().any(|record| record.handle == handle) {
|
||||
return Err(format!("Table.rows[{ri}].cells[{ci}] block handle {handle:?} does not exist"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let EntityType::MultiLeader(value) = entity {
|
||||
use crate::host::acadrust::objects::ObjectType;
|
||||
let handle_exists = |handle: Option<crate::host::acadrust::Handle>, what: &str, ok: &dyn Fn(crate::host::acadrust::Handle) -> bool| {
|
||||
@@ -1646,6 +1748,7 @@ mod tests {
|
||||
| "MLine"
|
||||
| "Dimension"
|
||||
| "MultiLeader"
|
||||
| "Table"
|
||||
)
|
||||
{
|
||||
assert!(
|
||||
@@ -1867,6 +1970,16 @@ mod tests {
|
||||
"MultiLeader.arrowhead_handle".to_owned(),
|
||||
"MultiLeader.line_type_handle".to_owned(),
|
||||
"MultiLeader.block_content_handle".to_owned(),
|
||||
"Table.insertion_point".to_owned(),
|
||||
"Table.horizontal_direction".to_owned(),
|
||||
"Table.normal".to_owned(),
|
||||
"Table.table_style_handle".to_owned(),
|
||||
"Table.rows".to_owned(),
|
||||
"Table.columns".to_owned(),
|
||||
"Table.merged_ranges".to_owned(),
|
||||
"Table.break_spacing".to_owned(),
|
||||
"Table.break_flow_direction".to_owned(),
|
||||
"Table.break_options".to_owned(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas
|
||||
As of 20 September 2026, the working branch is `plugin/host-model-api` at
|
||||
`a8dd3640` plus the Leader increment, published as draft PR
|
||||
[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is
|
||||
v7. The Python feature has explicit writes for **26 of 43** canvas kinds: 25
|
||||
are creatable and `Insert` remains update-only. This is **not** 26 completed
|
||||
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension and MultiLeader pass every completion gate.
|
||||
v7. The Python feature has explicit writes for **27 of 43** canvas kinds: 26
|
||||
are creatable and `Insert` remains update-only. This is **not** 27 completed
|
||||
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader and Table pass every completion gate.
|
||||
AttributeDefinition passes its host and real IPC lifecycle but remains short of
|
||||
`Complete` because the previously pinned CAD codec dropped optional ATTDEF
|
||||
fields during DXF reads; revalidate that blocker against the current
|
||||
`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 17 canvas kinds have only `layer`
|
||||
`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 16 canvas kinds have only `layer`
|
||||
writes through the document model. All 43 have raw typed snapshots. There are
|
||||
three internal records and two opaque fallbacks outside the 43 canvas kinds.
|
||||
|
||||
@@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver
|
||||
|
||||
## Handoff start point
|
||||
|
||||
Continue with **Table** (queue item 5). Leader is done except for its W
|
||||
Continue with **PolygonMesh** (queue item 6). Leader is done except for its W
|
||||
gate; when the engine writes DXF group 340, reads group 213 and the DWG
|
||||
R2010+ text-size question is decided, flip the canary assertions in
|
||||
`staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`.
|
||||
@@ -161,3 +161,4 @@ exact queue above and update statuses with evidence after each increment.
|
||||
- Completed `MLine`. Host validation and style binding live in `ocs_plugin_api`; derived vertex geometry and element parameters are recomputed by the host (`normalize_scripted_mline`) on scripted create/edit, so scripts supply positions only. The adapter maps `flags` as an integer through a manifest override because the generator cannot classify acadrust's serde-newtype `MLineFlags`. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen. The queue now continues with `Dimension`.
|
||||
- Completed `Dimension` for all nine subtypes. Because the payload is an enum, the adapter generator gained a `manual_kinds` hook (hand-written `<kind>_to_dict/_from_dict/_apply`) and the host catalog a `synthetic` policy section. The host derives `actual_measurement` and the base definition point on every scripted create/edit. Python ints are now accepted wherever a coordinate or angle is expected. `staged_python_dimension_lifecycle_over_real_ipc` records each subtype separately and all nine pass C/R/E/D/U/I/W/V/P. The queue now continues with `MultiLeader`.
|
||||
- Completed `MultiLeader`. The adapter generator now converts `Color`, `LineWeight`, mixed unit/payload enums, fixed float arrays and bitflags-2 newtypes, and a `keep_types` override lets a hand-written setter reuse generated converters (used for the merge-style `context` patch). `Leader.override_color` became writable through the same `Color` form; neither file format stores it. `staged_python_multileader_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `Table`.
|
||||
- Completed `Table`. The generator now handles tuples and `usize`; the host keeps DXF cell merge dimensions in step with `merged_ranges`. `staged_python_table_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `PolygonMesh`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# OCS canvas kind coverage ledger
|
||||
|
||||
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **26 of 43 canvas kinds have explicit geometry writes** (25 creatable, plus update-only `Insert`), **7 kinds are complete**, and 17 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`.
|
||||
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **27 of 43 canvas kinds have explicit geometry writes** (26 creatable, plus update-only `Insert`), **8 kinds are complete**, and 16 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`.
|
||||
|
||||
Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture.
|
||||
|
||||
@@ -32,7 +32,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea
|
||||
| MLine | **Complete** | Writable: `vertices` (raw vertex dicts; a script may pass only `position`), `flags` (integer, unknown bits rejected), `justification`, `normal`, `scale_factor`, `style_name`. Read-only: `start_point`, `style_handle`, `style_element_count`. Host validation requires ≥2 finite non-coincident vertices, ≥3 for a closed multiline, a finite nonzero scale and normal, an existing non-empty `MLineStyle`, and per-vertex segments that are either empty or match the style element count. Vertex `direction`/`miter`, segment layout and per-element miter offsets (justification and scale included) are host-derived by `normalize_scripted_mline` on every scripted create or edit, reusing the drawing command's `rebuild_mline_geometry`/`sync_mline_element_parameters`; dash and fill cuts survive an edit unless a vertex moves. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: create against the Standard style, a three-vertex/scale/justification transaction edit with untouched-field preservation, corner miter widening and element offsets checked, real `mline_lines` linework before and after DWG and DXF reopen, nine single-edit rejections plus a cross-entity atomic rollback, re-edit after reopen in both formats, deletion, and three-step undo/redo. Known limit: switching to a style with a different element count is rejected until the script also clears the vertices' `segments`; only the rejection is covered by the integration test. |
|
||||
| Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the acadrust convenience constructors. |
|
||||
| MultiLeader | **Complete** | Mapped through the generic generator, which gained support for `Color`, `LineWeight`, mixed unit/payload enums, fixed `[f64; N]` arrays, bitflags-2 newtypes and per-type module paths for this kind. Writable: `content_type`, `path_type`, `line_color`, `line_weight`, `enable_landing`, `enable_dogleg`, `dogleg_length`, `arrowhead_size`, `text_color`, `text_frame`, the four text attachments, attachment direction/point, `text_alignment`, `text_angle_type`, `text_direction_negative`, `scale_factor`, `enable_annotation_scale`, `extend_leader_to_text`, block content fields, the reference handles (`style_handle`, `text_style_handle`, `arrowhead_handle`, `line_type_handle`, `block_content_handle`) and `context`, which merges as a patch so a script can change `text_string` without resetting leader roots. Read-only: `dwg_version`, `property_override_flags`, `text_align_in_ipe` and the entity-level `text_height` (neither writer stores it; set `context.text_height`). Excluded but preserved: `arrowhead_overrides`, `block_attributes`. Host validation checks finite/positive scale, height, dogleg and arrow sizes, finite context geometry and transform, non-empty leader lines, content-type/context consistency, and that every referenced handle exists as the right object kind. `staged_python_multileader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: MText creation with a three-point leader root, a partial-context edit that replaces the path (four points), text, text location, dogleg and colour while preserving scale/content/common fields, canvas wires, eleven rejection cases plus a duplicate-handle atomic rollback, DWG and DXF reopen (text, path, dogleg, `context.text_height`, colour) with post-reopen re-edit, and three-step undo/redo. Not covered: block-content MultiLeaders (no block fixture yet), tolerance content, and style-defined defaults. |
|
||||
| Table | Layer-only | Requires table style and cell fixture. |
|
||||
| Table | **Complete** | Mapped through the generic generator (now also handling tuples and `usize`, with the generic `LegacyBorderOverrides<T>` fields excluded and preserved). Writable: `insertion_point`, `horizontal_direction`, `normal`, `table_style_handle`, `rows`, `columns`, `merged_ranges`, `break_spacing`, `break_flow_direction`, `break_options`. Read-only: `block_name`, `block_record_handle`, `data_version`, `value_flags`, the override flags, `name`, `description`, `field_handles`, `base_style`, `break_data`, `break_ranges`, `legacy_style_override`. Creation yields the engine default 3x3 grid; scripts read `rows`/`columns`, modify or extend them and assign them back (rows and columns replace wholesale, so copy an existing row dict when adding one). Host validation requires a non-empty grid, positive finite row heights and column widths, every row to have exactly one cell per column, finite cell and content geometry, merged ranges inside the grid and non-overlapping, and existing table-style, cell text-style and cell block handles. `normalize_scripted_table` mirrors `merged_ranges` onto the origin cell's `merge_width/height` on every scripted change to the ranges. `staged_python_table_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: default-grid creation, a row append, column-width edit, a text cell, a three-column merge and break spacing in one transaction with untouched rows/columns/style/common fields preserved, canvas wires, ten rejection cases plus an atomic rollback, DWG and DXF reopen (row count, width, cell text, insertion) with re-edit, and three-step undo/redo. Format notes: DWG restores `merged_ranges` but not per-cell merge dimensions; DXF restores the cell dimensions but its reader does not rebuild `merged_ranges` (recorded by a canary assertion); both keep the merge. Not covered: the anonymous table block is not regenerated, cell styles and formats beyond text, linked data and field content. |
|
||||
| PolygonMesh | Layer-only | Requires consistent grid/count fixture. |
|
||||
| PolyfaceMesh | Layer-only | Requires valid face-index fixture. |
|
||||
| Mesh | Layer-only | Requires valid topology fixture. |
|
||||
|
||||
@@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet
|
||||
catalog. All 43 canvas kinds also support nonempty `layer` changes through
|
||||
the same undoable transaction; internal and opaque variants remain read-only
|
||||
through the document model. The adapter accepts only `layer` patches for
|
||||
canvas kinds outside its 26 geometry converters. Other writable properties
|
||||
canvas kinds outside its 27 geometry converters. Other writable properties
|
||||
still report `type_conversion_only`; broader CAD range and cross-property
|
||||
checks remain outstanding.
|
||||
The host's `entity_snapshot` helper serializes any typed entity as a detached
|
||||
@@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a
|
||||
token from another tab returns no result and does not consume it.
|
||||
|
||||
Python `doc.entities[handle]` returns a descriptor for every entity. For kinds
|
||||
outside the 26-kind generated schema it contains only handle, kind, and layer;
|
||||
outside the 27-kind generated schema it contains only handle, kind, and layer;
|
||||
`layer` is editable on canvas kinds. `doc.coverage()` enumerates the
|
||||
catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For
|
||||
covered kinds, the coverage object lists actual readable and editable
|
||||
|
||||
@@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every
|
||||
traced field, including fields marked `unmapped`. Query it with
|
||||
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
|
||||
type, access, and current validation status. Geometry edits remain limited to
|
||||
the 26 mapped kinds.
|
||||
the 27 mapped kinds.
|
||||
For any entity, `entity.snapshot` returns a detached dictionary of its raw
|
||||
CAD fields, including `snapshot["common"]` and kind-specific nested values.
|
||||
It can inspect an unmapped kind; editing the dictionary does
|
||||
|
||||
@@ -201,6 +201,10 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec<String> {
|
||||
}
|
||||
|
||||
while let Some(name) = queue.pop() {
|
||||
if let Some(parts) = tuple_parts(&name) {
|
||||
queue.extend(parts.into_iter().map(str::to_owned));
|
||||
continue;
|
||||
}
|
||||
if BUILTIN.contains(&name.as_str())
|
||||
|| SPECIAL.contains(&name.as_str())
|
||||
|| f64_array_len(&name).is_some()
|
||||
@@ -300,6 +304,26 @@ fn is_native(type_id: &str) -> bool {
|
||||
BUILTIN.contains(&type_id)
|
||||
}
|
||||
|
||||
/// Split a tuple type id such as `(u32,CellBorder)` into its element types.
|
||||
fn tuple_parts(type_id: &str) -> Option<Vec<&str>> {
|
||||
let inner = type_id.strip_prefix('(')?.strip_suffix(')')?;
|
||||
let mut parts = Vec::new();
|
||||
let (mut depth, mut start) = (0i32, 0usize);
|
||||
for (i, ch) in inner.char_indices() {
|
||||
match ch {
|
||||
'(' | '[' | '<' => depth += 1,
|
||||
')' | ']' | '>' => depth -= 1,
|
||||
',' if depth == 0 => {
|
||||
parts.push(inner[start..i].trim());
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
parts.push(inner[start..].trim());
|
||||
(parts.len() >= 2).then_some(parts)
|
||||
}
|
||||
|
||||
/// `[f64; N]` fixed arrays (e.g. a 4x4 transform) convert as flat float lists.
|
||||
fn f64_array_len(type_id: &str) -> Option<usize> {
|
||||
type_id
|
||||
@@ -313,6 +337,10 @@ fn default_expr(type_id: &str) -> String {
|
||||
if let Some(n) = f64_array_len(type_id) {
|
||||
return format!("[0.0f64; {n}]");
|
||||
}
|
||||
if let Some(parts) = tuple_parts(type_id) {
|
||||
let items: Vec<String> = parts.iter().map(|part| default_expr(part)).collect();
|
||||
return format!("({})", items.join(", "));
|
||||
}
|
||||
match type_id {
|
||||
"f64" | "f32" => "0.0".to_string(),
|
||||
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => "0".to_string(),
|
||||
@@ -339,6 +367,14 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String
|
||||
"Ok(vm.ctx.new_list(({item_expr}).iter().map(|item| vm.new_pyobj(*item)).collect()).into())"
|
||||
);
|
||||
}
|
||||
if let Some(parts) = tuple_parts(type_id) {
|
||||
let items: Vec<String> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, part)| format!("{}?", leaf_to_py(registry, part, &format!("&({item_expr}).{i}"))))
|
||||
.collect();
|
||||
return format!("Ok(vm.ctx.new_list(vec![{}]).into())", items.join(", "));
|
||||
}
|
||||
match type_id {
|
||||
"f64" | "f32" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "bool" => {
|
||||
format!("Ok(vm.new_pyobj(*{parenthesized}))")
|
||||
@@ -365,12 +401,28 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String
|
||||
|
||||
/// `value_expr` must already be an owned `PyObjectRef` expression. Result: a `PyResult<T>` expression.
|
||||
fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> String {
|
||||
if type_id == "u64" {
|
||||
// serde reports Rust `usize` as `u64`; the inferred cast serves both.
|
||||
return format!("{value_expr}.try_into_value::<u64>(vm).map(|n| n as _)");
|
||||
}
|
||||
if is_native(type_id) {
|
||||
return format!("{value_expr}.try_into_value::<{}>(vm)", native_rust_type(type_id));
|
||||
}
|
||||
if let Some(n) = f64_array_len(type_id) {
|
||||
return format!("py_to_f64_array::<{n}>({value_expr}, vm)");
|
||||
}
|
||||
if let Some(parts) = tuple_parts(type_id) {
|
||||
let n = parts.len();
|
||||
let items: Vec<String> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, part)| format!("{}?", leaf_from_py(registry, part, &format!("items.remove(0 * {i})"))))
|
||||
.collect();
|
||||
return format!(
|
||||
"(|| -> PyResult<_> {{ let mut items: Vec<PyObjectRef> = ({value_expr}).try_into_value(vm)?; if items.len() != {n} {{ return Err(vm.new_value_error(\"expected a {n}-item list\".to_owned())); }} Ok(({})) }})()",
|
||||
items.join(", ")
|
||||
);
|
||||
}
|
||||
match type_id {
|
||||
"Vector3" => format!("py_to_vector3_dict({value_expr}, vm)"),
|
||||
"Vector2" => format!("py_to_vector2({value_expr}, vm)"),
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"Leader",
|
||||
"MLine",
|
||||
"Dimension",
|
||||
"MultiLeader"
|
||||
"MultiLeader",
|
||||
"Table"
|
||||
],
|
||||
"manual_kinds": ["Dimension"],
|
||||
"base_fields": ["handle", "layer", "owner_handle"],
|
||||
@@ -280,6 +281,35 @@
|
||||
"rust_setter": "if let Some(v) = dict.get_item_opt(\"context\", vm)? { VAR.context = merge_multileader_context(&VAR.context, v, vm)?; }"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Table": {
|
||||
"fields": {
|
||||
"insertion_point": { "required": true },
|
||||
"block_name": { "read_only": true },
|
||||
"block_record_handle": { "read_only": true },
|
||||
"data_version": { "read_only": true },
|
||||
"value_flags": { "read_only": true },
|
||||
"override_flag": { "read_only": true },
|
||||
"override_border_color": { "read_only": true },
|
||||
"override_border_line_weight": { "read_only": true },
|
||||
"override_border_visibility": { "read_only": true },
|
||||
"legacy_style_override": { "read_only": true },
|
||||
"name": { "read_only": true },
|
||||
"description": { "read_only": true },
|
||||
"field_handles": { "read_only": true },
|
||||
"base_style": { "read_only": true },
|
||||
"break_data": { "read_only": true },
|
||||
"break_ranges": { "read_only": true },
|
||||
"dwg_unknown_byte": { "exclude": true },
|
||||
"dwg_unknown_handle": { "exclude": true },
|
||||
"dwg_unknown_long1": { "exclude": true },
|
||||
"dwg_r2010_unknown_bit": { "exclude": true },
|
||||
"dwg_unknown_long2": { "exclude": true },
|
||||
"dwg_unknown_short": { "exclude": true },
|
||||
"legacy_border_colors": { "exclude": true, "_comment": "Generic LegacyBorderOverrides<T> is not expressible by the generator; legacy R13-R2000 border overrides are preserved untouched." },
|
||||
"legacy_border_line_weights": { "exclude": true },
|
||||
"legacy_border_visibility": { "exclude": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4756,7 +4756,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(emitted.len(), 26);
|
||||
assert_eq!(emitted.len(), 27);
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
|
||||
@@ -158,6 +158,13 @@ impl<'a> HostSession<'a> {
|
||||
if let EntityType::Dimension(dimension) = entity {
|
||||
crate::entities::dimension::normalize_scripted_dimension(dimension);
|
||||
}
|
||||
if let EntityType::Table(new) = entity {
|
||||
let old = match old {
|
||||
Some(EntityType::Table(old)) => Some(old),
|
||||
_ => None,
|
||||
};
|
||||
crate::entities::table::normalize_scripted_table(old, new);
|
||||
}
|
||||
if let EntityType::MLine(new) = entity {
|
||||
let old = match old {
|
||||
Some(EntityType::MLine(old)) => Some(old),
|
||||
@@ -2937,6 +2944,162 @@ mod tests {
|
||||
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_python_table_lifecycle_over_real_ipc() {
|
||||
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
|
||||
return;
|
||||
};
|
||||
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
|
||||
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
|
||||
assert!(std::path::Path::new(&runner_path).is_file());
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let process = ocs_plugin_api::process::PluginProcess::spawn(
|
||||
std::path::Path::new(&plugin_path),
|
||||
&mut host,
|
||||
crate::plugin::v4_support::notification_handler(),
|
||||
)
|
||||
.unwrap();
|
||||
let dispatch = |host: &mut HostSession<'_>, command: &str| {
|
||||
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
|
||||
};
|
||||
let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
|
||||
|
||||
dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Table', insertion_point={'x':5.0,'y':5.0,'z':0.0}).handle");
|
||||
let handle = host.document().entities().find_map(|entity| match entity {
|
||||
EntityType::Table(value) => Some(value.common.handle),
|
||||
_ => None,
|
||||
}).unwrap_or_else(|| panic!("Python did not create Table: {}", last_output(&host)));
|
||||
let created = host.document().get_entity(handle).unwrap().clone();
|
||||
let EntityType::Table(created_table) = &created else { unreachable!() };
|
||||
assert_eq!((created_table.rows.len(), created_table.columns.len()), (3, 3));
|
||||
assert_eq!(created_table.insertion_point, acadrust::types::Vector3::new(5.0, 5.0, 0.0));
|
||||
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry");
|
||||
|
||||
let script = std::env::temp_dir().join(format!("ocs_table_edit_{}.py", std::process::id()));
|
||||
std::fs::write(&script, format!(concat!(
|
||||
"doc = ocs.active_document\n",
|
||||
"t = doc.entities[{}]\n",
|
||||
"rows = t.rows\n",
|
||||
"cols = t.columns\n",
|
||||
"rows.append(dict(rows[0]))\n",
|
||||
"cols[0]['width'] = 30.0\n",
|
||||
"rows[1]['cells'][1]['contents'] = [{{'content_type': 'Value', 'scale': 1.0, 'text_height': 2.5,\n",
|
||||
" 'value': {{'value_type': 'String', 'raw_type_code': 4, 'text': 'Hello', 'formatted_value': 'Hello'}}}}]\n",
|
||||
"with doc.transaction('Edit table'):\n",
|
||||
" t.rows = rows\n",
|
||||
" t.columns = cols\n",
|
||||
" t.merged_ranges = [{{'top_row': 0, 'left_col': 0, 'bottom_row': 0, 'right_col': 2}}]\n",
|
||||
" t.break_spacing = 2.0\n",
|
||||
"doc.selection = [t]\n"
|
||||
), handle.value())).unwrap();
|
||||
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
|
||||
let _ = std::fs::remove_file(&script);
|
||||
let expected = host.document().get_entity(handle).unwrap().clone();
|
||||
let EntityType::Table(edited) = &expected else { unreachable!() };
|
||||
assert_eq!(edited.rows.len(), 4, "edit failed: {}", last_output(&host));
|
||||
assert_eq!(edited.columns[0].width, 30.0);
|
||||
assert_eq!(edited.rows[1].cells[1].contents[0].value.text, "Hello");
|
||||
assert_eq!(edited.merged_ranges.len(), 1);
|
||||
assert_eq!(edited.break_spacing, 2.0);
|
||||
assert_eq!(edited.common, created_table.common);
|
||||
assert_eq!(edited.insertion_point, created_table.insertion_point);
|
||||
assert_eq!(edited.normal, created_table.normal);
|
||||
assert_eq!(edited.columns[1], created_table.columns[1]);
|
||||
assert_eq!(edited.rows[2], created_table.rows[2]);
|
||||
assert_eq!(edited.table_style_handle, created_table.table_style_handle);
|
||||
assert_eq!(host.selection(), vec![handle]);
|
||||
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty());
|
||||
|
||||
for (patch, message) in [
|
||||
("'columns':[{'name':'A','width':0.0},{'width':1.0},{'width':1.0}]", "greater than zero"),
|
||||
("'columns':[{'width':5.0}]", "cells but the table has 1 columns"),
|
||||
("'columns':[]", "at least one row and one column"),
|
||||
("'merged_ranges':[{'top_row':0,'left_col':0,'bottom_row':9,'right_col':0}]", "outside the"),
|
||||
("'merged_ranges':[{'top_row':1,'left_col':0,'bottom_row':2,'right_col':1},{'top_row':2,'left_col':1,'bottom_row':3,'right_col':2}]", "overlaps"),
|
||||
("'table_style_handle':999999", "does not exist"),
|
||||
("'block_name':'x'", "read-only"),
|
||||
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
|
||||
("'break_spacing':-1.0", "non-negative"),
|
||||
("'insertion_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
|
||||
] {
|
||||
dispatch(&mut host, &format!(
|
||||
"PY_EVAL ocs.update_many('Reject table', [{{'handle':{}, {patch}}}])", handle.value()));
|
||||
assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}");
|
||||
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
|
||||
}
|
||||
dispatch(&mut host, &format!(concat!(
|
||||
"PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'break_spacing':7.0}},",
|
||||
"{{'handle':{},'break_spacing':-1.0}}])"
|
||||
), handle.value(), handle.value()));
|
||||
assert_eq!(host.document().get_entity(handle), Some(&expected));
|
||||
|
||||
let mut gaps = Vec::new();
|
||||
let dwg = crate::io::load_bytes("table.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
|
||||
let dxf = crate::io::load_bytes("table.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
|
||||
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
|
||||
let Some(EntityType::Table(value)) = document.get_entity(handle) else {
|
||||
gaps.push(format!("{format}: Table missing"));
|
||||
continue;
|
||||
};
|
||||
if value.rows.len() != 4 { gaps.push(format!("{format}: rows {}", value.rows.len())); }
|
||||
if value.columns.first().map(|c| c.width) != Some(30.0) { gaps.push(format!("{format}: column width {:?}", value.columns.first().map(|c| c.width))); }
|
||||
let text = value.rows.get(1).and_then(|r| r.cells.get(1)).and_then(|c| c.contents.first()).map(|c| c.value.text.clone());
|
||||
if text.as_deref() != Some("Hello") { gaps.push(format!("{format}: cell text {text:?}")); }
|
||||
// DWG stores the ranges; DXF stores the origin cell's merge
|
||||
// dimensions and its reader does not rebuild `merged_ranges`
|
||||
// (engine gap, recorded in the ledger). Scripted saves populate
|
||||
// both, so each format keeps the merge in its own form.
|
||||
let origin = &value.rows[0].cells[0];
|
||||
if format == "DXF" {
|
||||
if (origin.merge_width, origin.merge_height) != (3, 1) { gaps.push(format!("DXF: merge dims {}x{}", origin.merge_width, origin.merge_height)); }
|
||||
if !value.merged_ranges.is_empty() { gaps.push("DXF: reader now rebuilds merged_ranges; drop the blocker".into()); }
|
||||
} else if value.merged_ranges.len() != 1 {
|
||||
gaps.push(format!("DWG: merged_ranges {}", value.merged_ranges.len()));
|
||||
}
|
||||
if value.insertion_point != edited.insertion_point { gaps.push(format!("{format}: insertion {:?}", value.insertion_point)); }
|
||||
}
|
||||
assert!(gaps.is_empty(), "persistence gaps: {gaps:#?}");
|
||||
for (name, mut document) in [("table-reedit.dwg", dwg), ("table-reedit.dxf", dxf)] {
|
||||
let before = document.get_entity(handle).unwrap().clone();
|
||||
let mut after = before.clone();
|
||||
let EntityType::Table(value) = &mut after else { unreachable!() };
|
||||
value.columns[1].width = 12.0;
|
||||
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
|
||||
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap();
|
||||
*document.get_entity_mut(handle).unwrap() = after;
|
||||
let bytes = if name.ends_with("dwg") {
|
||||
acadrust::DwgWriter::write_to_vec(&document).unwrap()
|
||||
} else {
|
||||
acadrust::DxfWriter::new(&document).write_to_vec().unwrap()
|
||||
};
|
||||
assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle),
|
||||
Some(EntityType::Table(value)) if value.columns[1].width == 12.0), "{name}");
|
||||
}
|
||||
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value()));
|
||||
assert!(host.document().get_entity(handle).is_none());
|
||||
for (name, bytes) in [
|
||||
("table-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
|
||||
("table-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()),
|
||||
] {
|
||||
assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none());
|
||||
}
|
||||
drop(process);
|
||||
drop(host);
|
||||
app.finish_pending_history(0);
|
||||
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
|
||||
app.undo_steps(1);
|
||||
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected));
|
||||
app.undo_steps(1);
|
||||
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created));
|
||||
app.undo_steps(1);
|
||||
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
|
||||
app.redo_steps(3);
|
||||
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_python_point_pick_and_cancel_over_real_ipc() {
|
||||
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
|
||||
|
||||
@@ -3339,3 +3339,29 @@ impl Transformable for Table {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the per-cell merge dimensions (what DXF stores) in step with
|
||||
/// `merged_ranges` (what DWG stores) after a scripted create or edit. Cells a
|
||||
/// script did not touch keep their merge state when the ranges are unchanged.
|
||||
pub(crate) fn normalize_scripted_table(old: Option<&Table>, new: &mut Table) {
|
||||
if old.is_some_and(|old| old.merged_ranges == new.merged_ranges) {
|
||||
return;
|
||||
}
|
||||
for row in &mut new.rows {
|
||||
for cell in &mut row.cells {
|
||||
cell.merge_width = 1;
|
||||
cell.merge_height = 1;
|
||||
}
|
||||
}
|
||||
let ranges = new.merged_ranges.clone();
|
||||
for range in &ranges {
|
||||
if let Some(cell) = new
|
||||
.rows
|
||||
.get_mut(range.top_row)
|
||||
.and_then(|row| row.cells.get_mut(range.left_col))
|
||||
{
|
||||
cell.merge_width = range.col_count() as i32;
|
||||
cell.merge_height = range.row_count() as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user