Complete MultiLeader host model coverage

Extends the adapter generator (Color, LineWeight, mixed unit/payload enums,
fixed float arrays, bitflags-2 newtypes, keep_types override) and maps
MultiLeader with a merge-style context patch, host validation and
reference checks. staged_python_multileader_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:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 14:27:43 +02:00
1 parent 22f9c20fe8
commit b3f5388208
14 files changed
+523 -31

No files matched your search

+1
View File
@@ -785,6 +785,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
| "block_offset"
| "annotation_offset"
)
| ("MultiLeader", _)
| (
"MLine",
"flags"
@@ -296,6 +296,41 @@
"scale_factor",
"style_name",
"vertices"
],
"MultiLeader": [
"layer",
"content_type",
"path_type",
"line_color",
"line_weight",
"enable_landing",
"enable_dogleg",
"dogleg_length",
"arrowhead_size",
"text_color",
"text_frame",
"text_left_attachment",
"text_right_attachment",
"text_top_attachment",
"text_bottom_attachment",
"text_attachment_direction",
"text_attachment_point",
"text_alignment",
"text_angle_type",
"text_direction_negative",
"scale_factor",
"enable_annotation_scale",
"extend_leader_to_text",
"block_content_color",
"block_connection_type",
"block_rotation",
"block_scale",
"context",
"style_handle",
"text_style_handle",
"arrowhead_handle",
"line_type_handle",
"block_content_handle"
]
},
"readable": {
@@ -322,6 +357,12 @@
],
"AttributeEntity": [
"attdef_handle"
],
"MultiLeader": [
"dwg_version",
"property_override_flags",
"text_align_in_ipe",
"text_height"
]
},
"aliases": {
@@ -465,6 +465,89 @@ fn validate_dimension(
Ok(())
}
/// Field checks shared by MultiLeader creation and mutation.
#[cfg(feature = "host")]
fn validate_multileader(
old: Option<&crate::host::acadrust::entities::MultiLeader>,
new: &crate::host::acadrust::entities::MultiLeader,
) -> Result<(), String> {
use crate::host::acadrust::entities::LeaderContentType;
if old == Some(new) {
return Ok(());
}
for (name, value, allow_zero) in [
("dogleg_length", new.dogleg_length, true),
("arrowhead_size", new.arrowhead_size, true),
("text_height", new.text_height, false),
("scale_factor", new.scale_factor, false),
] {
if !value.is_finite() || value < 0.0 || (!allow_zero && value == 0.0) {
return Err(format!(
"MultiLeader.{name} must be finite and {}",
if allow_zero { "non-negative" } else { "greater than zero" }
));
}
}
if !new.block_rotation.is_finite() {
return Err("MultiLeader.block_rotation must be finite".into());
}
finite_vector("MultiLeader.block_scale", &new.block_scale)?;
let context = &new.context;
if !context.scale_factor.is_finite() || context.scale_factor <= 0.0 {
return Err("MultiLeader.context.scale_factor must be finite and greater than zero".into());
}
for (name, value) in [
("text_height", context.text_height),
("text_width", context.text_width),
("text_rotation", context.text_rotation),
("landing_gap", context.landing_gap),
("arrowhead_size", context.arrowhead_size),
] {
if !value.is_finite() {
return Err(format!("MultiLeader.context.{name} must be finite"));
}
}
for (name, point) in [
("content_base_point", &context.content_base_point),
("text_location", &context.text_location),
("block_content_location", &context.block_content_location),
("base_point", &context.base_point),
] {
finite_vector(&format!("MultiLeader.context.{name}"), point)?;
}
if context.transform_matrix.iter().any(|value| !value.is_finite()) {
return Err("MultiLeader.context.transform_matrix must be finite".into());
}
for (ri, root) in context.leader_roots.iter().enumerate() {
finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].connection_point"), &root.connection_point)?;
finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].direction"), &root.direction)?;
if !root.landing_distance.is_finite() {
return Err(format!("MultiLeader.context.leader_roots[{ri}].landing_distance must be finite"));
}
for (li, line) in root.lines.iter().enumerate() {
if line.points.is_empty() {
return Err(format!("MultiLeader.context.leader_roots[{ri}].lines[{li}] has no points"));
}
for (pi, point) in line.points.iter().enumerate() {
finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].lines[{li}].points[{pi}]"), point)?;
}
}
}
match new.content_type {
LeaderContentType::MText if !context.has_text_contents => {
return Err("MText MultiLeader requires context.has_text_contents".into());
}
LeaderContentType::Block if !context.has_block_contents => {
return Err("Block MultiLeader requires context.has_block_contents".into());
}
LeaderContentType::None if context.has_text_contents || context.has_block_contents => {
return Err("MultiLeader without content cannot carry text or block contents".into());
}
_ => {}
}
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")]
@@ -559,6 +642,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<()
EntityType::Leader(value) => validate_leader(None, value)?,
EntityType::MLine(value) => validate_mline(None, value)?,
EntityType::Dimension(value) => validate_dimension(None, value)?,
EntityType::MultiLeader(value) => validate_multileader(None, value)?,
EntityType::AttributeDefinition(value) => {
finite_vector(
"AttributeDefinition.insertion_point",
@@ -914,6 +998,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::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)) => {
@@ -1177,6 +1262,26 @@ pub fn validate_canvas_entity_references(
return Err(format!("Shape text style {:?} has no SHX file", style.name));
}
}
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| {
match handle.filter(|handle| !handle.is_null()) {
Some(handle) if !ok(handle) => Err(format!("MultiLeader {what} handle {handle:?} does not exist")),
_ => Ok(()),
}
};
let is_block = |h| document.block_records.iter().any(|record| record.handle == h);
let is_text_style = |h| document.text_styles.iter().any(|style| style.handle == h);
let is_linetype = |h| document.line_types.iter().any(|ltype| ltype.handle == h);
let is_mleader_style = |h| matches!(document.objects.get(&h), Some(ObjectType::MultiLeaderStyle(_)));
handle_exists(value.style_handle, "style", &is_mleader_style)?;
handle_exists(value.text_style_handle, "text style", &is_text_style)?;
handle_exists(value.context.text_style_handle, "context text style", &is_text_style)?;
handle_exists(value.line_type_handle, "line type", &is_linetype)?;
handle_exists(value.arrowhead_handle, "arrowhead block", &is_block)?;
handle_exists(value.block_content_handle, "block content", &is_block)?;
handle_exists(value.context.block_content_handle, "context block content", &is_block)?;
}
if let EntityType::Dimension(value) = entity {
let name = value.base().style_name.trim();
if !document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(name)) {
@@ -1540,6 +1645,7 @@ mod tests {
| "Leader"
| "MLine"
| "Dimension"
| "MultiLeader"
)
{
assert!(
@@ -1729,6 +1835,38 @@ mod tests {
"Dimension.is_ordinate_type_x".to_owned(),
"Dimension.is_partial".to_owned(),
"Dimension.has_leader".to_owned(),
"MultiLeader.content_type".to_owned(),
"MultiLeader.path_type".to_owned(),
"MultiLeader.line_color".to_owned(),
"MultiLeader.line_weight".to_owned(),
"MultiLeader.enable_landing".to_owned(),
"MultiLeader.enable_dogleg".to_owned(),
"MultiLeader.dogleg_length".to_owned(),
"MultiLeader.arrowhead_size".to_owned(),
"MultiLeader.text_color".to_owned(),
"MultiLeader.text_frame".to_owned(),
"MultiLeader.text_left_attachment".to_owned(),
"MultiLeader.text_right_attachment".to_owned(),
"MultiLeader.text_top_attachment".to_owned(),
"MultiLeader.text_bottom_attachment".to_owned(),
"MultiLeader.text_attachment_direction".to_owned(),
"MultiLeader.text_attachment_point".to_owned(),
"MultiLeader.text_alignment".to_owned(),
"MultiLeader.text_angle_type".to_owned(),
"MultiLeader.text_direction_negative".to_owned(),
"MultiLeader.scale_factor".to_owned(),
"MultiLeader.enable_annotation_scale".to_owned(),
"MultiLeader.extend_leader_to_text".to_owned(),
"MultiLeader.block_content_color".to_owned(),
"MultiLeader.block_connection_type".to_owned(),
"MultiLeader.block_rotation".to_owned(),
"MultiLeader.block_scale".to_owned(),
"MultiLeader.context".to_owned(),
"MultiLeader.style_handle".to_owned(),
"MultiLeader.text_style_handle".to_owned(),
"MultiLeader.arrowhead_handle".to_owned(),
"MultiLeader.line_type_handle".to_owned(),
"MultiLeader.block_content_handle".to_owned(),
])
);
}
BIN
View File
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
{\rtf1\ansi\ansicpg1252\cocoartf2907
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 SFProText-Regular;}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
\paperw11900\paperh16840\margl1440\margr1440\vieww28720\viewh12800\viewkind0
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
\f0\fs36 \cf0 Geometric \'97 select entities, then run\
Constraint Command AutoCAD-native? DWG class\
Horizontal HCONSTRAINT \uc0\u9989 Native ACHORIZONTALCONSTRAINT\
Vertical VCONSTRAINT \uc0\u9989 Native ACVERTICALCONSTRAINT\
Fixed FXCONSTRAINT \uc0\u9989 Native ACFIXEDCONSTRAINT\
Parallel PCONSTRAINT \uc0\u9989 Native ACPARALLELCONSTRAINT\
Perpendicular QCONSTRAINT \uc0\u9989 Native ACPERPENDICULARCONSTRAINT\
Equal ECONSTRAINT \uc0\u9989 Native ACEQUALLENGTHCONSTRAINT (lines) / ACEQUALRADIUSCONSTRAINT (circles/arcs)\
Tangent TCONSTRAINT \uc0\u9989 Native ACTANGENTCONSTRAINT\
Colinear LCONSTRAINT \uc0\u9989 Native ACCOLINEARCONSTRAINT\
Normal NRCONSTRAINT \uc0\u9989 Native ACNORMALCONSTRAINT\
Concentric NCONSTRAINT \uc0\u9989 Native ACCONCENTRICCONSTRAINT\
Symmetric SYCONSTRAINT \uc0\u9989 Native ACSYMMETRICCONSTRAINT\
\
Geometric \'97 pick points directly\
Constraint Command AutoCAD-native? DWG class\
Coincident CCONSTRAINT \uc0\u9989 Native ACPOINTCOINCIDENCECONSTRAINT\
Equal Distance EDCONSTRAINT \uc0\u9989 Native ACEQUALDISTANCECONSTRAINT (AutoCAD's 4th Equal sub-kind)\
\
Geometric \'97 select target, then pick a point\
Constraint Command AutoCAD-native? DWG class\
Center Point CPCONSTRAINT \uc0\u9989 Native ACCENTERPOINTCONSTRAINT\
Midpoint MPCONSTRAINT \uc0\u9989 Native ACMIDPOINTCONSTRAINT\
Point on Curve OCCONSTRAINT \uc0\u9989 Native ACPOINTCURVECONSTRAINT\
\
Dimensional \'97 select target, type a value/parameter\
Constraint Command AutoCAD-native? DWG class\
Distance DCONSTRAINT \uc0\u9989 Native ACDISTANCECONSTRAINT\
DistanceX DCONSTRAINT \uc0\u8594 X \u9989 Native ACDISTANCECONSTRAINT (fixed X direction byte)\
DistanceY DCONSTRAINT \uc0\u8594 Y \u9989 Native ACDISTANCECONSTRAINT (fixed Y direction byte)\
Radius DCONSTRAINT \uc0\u9989 Native ACRADIUSDIAMETERCONSTRAINT\
Diameter DCONSTRAINT \uc0\u8594 D \u9989 Native ACRADIUSDIAMETERCONSTRAINT (mode byte)\
Angle ACONSTRAINT \uc0\u9989 Native ACANGLECONSTRAINT\
Arc Length DCONSTRAINT \uc0\u8594 A \u10060 OCS-only none \'97 confirmed against AcExplicitConstr.h; no ArcLength-shaped class exists in ObjectARX at all}
+6 -5
View File
@@ -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 **25 of 43** canvas kinds: 24
are creatable and `Insert` remains update-only. This is **not** 25 completed
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine and Dimension pass every completion gate.
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.
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 18 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 17 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 **MultiLeader** (queue item 4). Leader is done except for its W
Continue with **Table** (queue item 5). 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`.
@@ -160,3 +160,4 @@ exact queue above and update statuses with evidence after each increment.
- Mapped `Leader` (host validation and reference binding in `entity_coverage.rs`, policy/build.rs/manifest entries, adapter count 23). `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P through the real runner. W is blocked by acadrust: the DXF writer omits the annotation handle (340), DXF reads lose `annotation_offset` (213), and DWG R2010+ does not store text size or hookline flag. Each gap is pinned by a canary assertion so it fails loudly once fixed. The queue now continues with `MLine`.
- 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`.
+2 -2
View File
@@ -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: **25 of 43 canvas kinds have explicit geometry writes** (24 creatable, plus update-only `Insert`), **6 kinds are complete**, and 18 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: **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`.
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.
@@ -31,7 +31,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea
| Leader | **Integration-tested / blocked at W by acadrust** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. `override_color` is writable through the new generator `Color` form (`{'kind': 'Rgb', 'value': {'r','g','b'}}`, `Index`, `ByLayer`, `ByBlock`, `None`). Unmapped but preserved on edit: the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14); (4) neither writer stores `override_color`, so it reopens as `ByLayer` in DWG and DXF. Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. |
| 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 | Layer-only | Requires nested context and style fixture. |
| 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. |
| PolygonMesh | Layer-only | Requires consistent grid/count fixture. |
| PolyfaceMesh | Layer-only | Requires valid face-index fixture. |
+2 -2
View File
@@ -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 25 geometry converters. Other writable properties
canvas kinds outside its 26 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 25-kind generated schema it contains only handle, kind, and layer;
outside the 26-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
+1 -1
View File
@@ -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 25 mapped kinds.
the 26 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
+83 -19
View File
@@ -89,6 +89,11 @@ pub struct FieldOverride {
/// already-valid entity never needs it re-specified).
#[serde(default)]
pub required: bool,
/// Still generate this field type's converters although a
/// `rust_getter`/`rust_setter` override replaces the generic path (the
/// override's hand-written code calls them).
#[serde(default)]
pub keep_types: bool,
}
impl Manifest {
@@ -141,7 +146,7 @@ fn classify<'a>(name: &str, info: &'a TypeInfo) -> Classify {
}
TypeKind::Enum => {
if info.variants.iter().any(|v| !v.fields.is_empty()) {
if info.variants.iter().all(|v| v.fields.len() == 1) {
if info.variants.iter().all(|v| v.fields.len() <= 1) {
Classify::TaggedEnum
} else {
panic!("entity_manifest.json: {name} has a tagged variant without exactly one payload");
@@ -150,6 +155,9 @@ fn classify<'a>(name: &str, info: &'a TypeInfo) -> Classify {
Classify::UnitEnum
}
}
// acadrust `bitflags!` types serialize as a newtype over their integer
// bits; they convert exactly like the hand-written `bits` structs.
TypeKind::Newtype => Classify::BitFlags,
other => panic!("entity_manifest.json: {name} has unsupported registry kind {other:?}"),
}
}
@@ -181,7 +189,7 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec<String> {
}
if override_def
.and_then(|o| o.fields.get(&f.name))
.map(|o| o.exclude || o.rust_getter.is_some())
.map(|o| o.exclude || (o.rust_getter.is_some() && !o.keep_types))
.unwrap_or(false)
{
// Excluded, or fully hand-supplied by the override — its
@@ -193,7 +201,10 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec<String> {
}
while let Some(name) = queue.pop() {
if BUILTIN.contains(&name.as_str()) || SPECIAL.contains(&name.as_str()) {
if BUILTIN.contains(&name.as_str())
|| SPECIAL.contains(&name.as_str())
|| f64_array_len(&name).is_some()
{
continue;
}
if !needed.insert(name.clone()) {
@@ -276,11 +287,32 @@ fn native_rust_type(type_id: &str) -> &'static str {
}
}
/// Rust path of a traced type; most live in `acadrust::entities`.
fn type_path(name: &str) -> String {
match name {
"LineWeight" => "acadrust::types::LineWeight".to_owned(),
"LeaderLineBreakInfo" => "acadrust::entities::multileader::LeaderLineBreakInfo".to_owned(),
_ => format!("acadrust::entities::{name}"),
}
}
fn is_native(type_id: &str) -> bool {
BUILTIN.contains(&type_id)
}
/// `[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
.strip_prefix("[f64; ")?
.strip_suffix(']')?
.parse()
.ok()
}
fn default_expr(type_id: &str) -> String {
if let Some(n) = f64_array_len(type_id) {
return format!("[0.0f64; {n}]");
}
match type_id {
"f64" | "f32" => "0.0".to_string(),
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => "0".to_string(),
@@ -302,6 +334,11 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String
// call or cast directly on `item_expr` (`item_expr.method()`,
// `item_expr as T`) does, since `&` binds looser than both.
let parenthesized = format!("({item_expr})");
if f64_array_len(type_id).is_some() {
return format!(
"Ok(vm.ctx.new_list(({item_expr}).iter().map(|item| vm.new_pyobj(*item)).collect()).into())"
);
}
match type_id {
"f64" | "f32" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "bool" => {
format!("Ok(vm.new_pyobj(*{parenthesized}))")
@@ -331,6 +368,9 @@ fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> Str
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)");
}
match type_id {
"Vector3" => format!("py_to_vector3_dict({value_expr}, vm)"),
"Vector2" => format!("py_to_vector2({value_expr}, vm)"),
@@ -346,11 +386,18 @@ fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> Str
),
Classify::TaggedEnum => format!("dict_to_{}({value_expr}, vm)", snake_case(other)),
Classify::BitFlags => {
let path = format!("acadrust::entities::{other}");
let path = type_path(other);
let bits_ty = bits_field_type(info);
format!(
"{value_expr}.try_into_value::<i64>(vm).and_then(|n| <{bits_ty}>::try_from(n).map({path}::from_bits).map_err(|_| vm.new_value_error(\"{other} bits out of range\".to_owned())))"
)
if matches!(info.kind, TypeKind::Newtype) {
// bitflags 2: `from_bits` is fallible and rejects unknown bits.
format!(
"{value_expr}.try_into_value::<i64>(vm).and_then(|n| <{bits_ty}>::try_from(n).ok().and_then({path}::from_bits).ok_or_else(|| vm.new_value_error(\"{other} has unknown or out-of-range bits\".to_owned())))"
)
} else {
format!(
"{value_expr}.try_into_value::<i64>(vm).and_then(|n| <{bits_ty}>::try_from(n).map({path}::from_bits).map_err(|_| vm.new_value_error(\"{other} bits out of range\".to_owned())))"
)
}
}
Classify::Struct => format!("dict_to_{}({value_expr}, vm)", snake_case(other)),
}
@@ -371,6 +418,8 @@ fn getter_for_field(
is_sequence: bool,
field_place: &str,
) -> String {
// The registry reports a fixed array as a sequence of itself.
let is_sequence = is_sequence && f64_array_len(type_id).is_none();
if is_sequence {
let inner = leaf_to_py(registry, type_id, "item");
format!(
@@ -398,6 +447,7 @@ fn setter_for_field(
py_name: &str,
keep_expr: &str,
) -> String {
let is_sequence = is_sequence && f64_array_len(type_id).is_none();
if is_sequence {
let inner = leaf_from_py(registry, type_id, "item");
format!(
@@ -417,7 +467,7 @@ fn setter_for_field(
}
fn field_default_expr(f: &FieldInfo) -> String {
if f.is_sequence {
if f.is_sequence && f64_array_len(f.type_id.as_str()).is_none() {
"Vec::new()".to_string()
} else if f.optional {
"None".to_string()
@@ -451,7 +501,7 @@ fn wrap_required_nonempty(setter_expr: &str, entity_kind: &str, py_name: &str) -
// ════════════════════════════════════════════════════════════════════════════
fn gen_unit_enum(name: &str, info: &TypeInfo) -> String {
let path = format!("acadrust::entities::{name}");
let path = type_path(name);
let snake = snake_case(name);
let mut name_arms = String::new();
let mut parse_arms = String::new();
@@ -486,13 +536,14 @@ fn default_{snake}() -> {path} {{
)
}
fn gen_bitflags(name: &str) -> String {
let path = format!("acadrust::entities::{name}");
fn gen_bitflags(name: &str, info: &TypeInfo) -> String {
let path = type_path(name);
let snake = snake_case(name);
let ctor = if matches!(info.kind, TypeKind::Newtype) { "empty" } else { "new" };
format!(
r#"#[allow(dead_code)]
fn default_{snake}() -> {path} {{
{path}::new()
{path}::{ctor}()
}}
"#
@@ -500,11 +551,22 @@ fn default_{snake}() -> {path} {{
}
fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String {
let path = format!("acadrust::entities::{name}");
let path = type_path(name);
let snake = snake_case(name);
let mut to_arms = String::new();
let mut from_arms = String::new();
for variant in &info.variants {
if variant.fields.is_empty() {
to_arms.push_str(&format!(
" {path}::{variant} => {{ let dict = vm.ctx.new_dict(); dict.set_item(\"kind\", vm.new_pyobj(\"{variant}\"), vm)?; Ok(dict.into()) }}\n",
variant = variant.name,
));
from_arms.push_str(&format!(
" \"{variant}\" => Ok({path}::{variant}),\n",
variant = variant.name,
));
continue;
}
let payload = &variant.fields[0];
let getter = leaf_to_py(registry, payload.type_id.as_str(), "value");
let setter = leaf_from_py(registry, payload.type_id.as_str(), "payload");
@@ -518,7 +580,10 @@ fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> Stri
));
}
let first = &info.variants[0];
let default_payload = default_expr(first.fields[0].type_id.as_str());
let default_value = match first.fields.first() {
None => format!("{path}::{}", first.name),
Some(payload) => format!("{path}::{}({})", first.name, default_expr(payload.type_id.as_str())),
};
format!(
r#"fn {snake}_to_dict(vm: &VirtualMachine, value: &{path}) -> PyResult<PyObjectRef> {{
match value {{
@@ -535,16 +600,15 @@ fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}>
#[allow(dead_code)]
fn default_{snake}() -> {path} {{
{path}::{first_variant}({default_payload})
{default_value}
}}
"#,
first_variant = first.name,
"#
)
}
fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String {
let path = format!("acadrust::entities::{name}");
let path = type_path(name);
let snake = snake_case(name);
let mut to_dict_body = String::new();
let mut from_dict_fields = String::new();
@@ -910,7 +974,7 @@ pub fn generate_entity_crud(manifest: &Manifest, registry: &TypeRegistry) -> Str
match classify(&name, info) {
Classify::UnitEnum => out.push_str(&gen_unit_enum(&name, info)),
Classify::TaggedEnum => out.push_str(&gen_tagged_enum(&name, info, registry)),
Classify::BitFlags => out.push_str(&gen_bitflags(&name)),
Classify::BitFlags => out.push_str(&gen_bitflags(&name, info)),
Classify::Struct => out.push_str(&gen_struct(&name, info, registry)),
}
}
+17 -1
View File
@@ -24,7 +24,8 @@
"Hatch",
"Leader",
"MLine",
"Dimension"
"Dimension",
"MultiLeader"
],
"manual_kinds": ["Dimension"],
"base_fields": ["handle", "layer", "owner_handle"],
@@ -264,6 +265,21 @@
"dwg_unknown_bit5": { "exclude": true },
"dwg_unknown_short1": { "exclude": true }
}
},
"MultiLeader": {
"fields": {
"arrowhead_overrides": { "exclude": true, "_comment": "Per-arrow override records carry linked block handles; preserved untouched until dedicated validation exists." },
"block_attributes": { "exclude": true, "_comment": "Block attribute records carry attribute-definition handles; preserved untouched." },
"dwg_version": { "read_only": true },
"text_height": { "read_only": true, "_comment": "Neither writer stores the entity-level text height; scripts set the persisted context.text_height." },
"property_override_flags": { "read_only": true },
"text_align_in_ipe": { "read_only": true },
"context": {
"keep_types": true,
"rust_getter": "multi_leader_annot_context_to_dict(vm, &VAR.context)?",
"rust_setter": "if let Some(v) = dict.get_item_opt(\"context\", vm)? { VAR.context = merge_multileader_context(&VAR.context, v, vm)?; }"
}
}
}
}
}
+14 -1
View File
@@ -3188,6 +3188,18 @@ mod ocs {
})
}
fn py_to_f64_array<const N: usize>(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<[f64; N]> {
let items: Vec<PyObjectRef> = value.try_into_value(vm)?;
if items.len() != N {
return Err(vm.new_value_error(format!("expected {N} numbers, got {}", items.len())));
}
let mut out = [0.0; N];
for (slot, item) in out.iter_mut().zip(items) {
*slot = py_number_to_f64(item, vm)?;
}
Ok(out)
}
/// Accept a Python int or float; `try_into_value::<f64>` rejects ints.
fn py_number_to_f64(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<f64> {
Ok(value.try_float(vm)?.to_f64())
@@ -3249,6 +3261,7 @@ mod ocs {
include!(concat!(env!("OUT_DIR"), "/entity_crud.rs"));
include!("dimension_model.rs.inc");
include!("patch_helpers.rs.inc");
// ════════════════════════════════════════════════════════════════════
// Phase 1 — wiring the generic conversion functions above into `ocs`.
@@ -4743,7 +4756,7 @@ mod tests {
);
}
}
assert_eq!(emitted.len(), 25);
assert_eq!(emitted.len(), 26);
}
#[cfg(feature = "experimental-host-model")]
@@ -0,0 +1,23 @@
// Merge helpers for nested structs that scripts edit as a partial patch.
/// Apply `patch` (a dict of context fields) onto `existing`, so a script can
/// change one context field without resetting the rest. Unknown keys error.
fn merge_multileader_context(
existing: &acadrust::entities::MultiLeaderAnnotContext,
patch: PyObjectRef,
vm: &VirtualMachine,
) -> PyResult<acadrust::entities::MultiLeaderAnnotContext> {
let patch = patch.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
let merged = multi_leader_annot_context_to_dict(vm, existing)?
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
for key in patch.keys_vec() {
let name = key.clone().try_into_value::<String>(vm)?;
if merged.get_item_opt(name.as_str(), vm)?.is_none() {
return Err(vm.new_value_error(format!(
"ocs: MultiLeader.context property {name:?} is outside the editable schema"
)));
}
merged.set_item(name.as_str(), patch.get_item(name.as_str(), vm)?, vm)?;
}
dict_to_multi_leader_annot_context(merged.into(), vm)
}
+154
View File
@@ -2783,6 +2783,160 @@ mod tests {
assert!(app.tabs[0].scene.document.get_entity(linear).is_none());
}
#[test]
fn staged_python_multileader_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();
let script = std::env::temp_dir().join(format!("ocs_mleader_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"def P(x, y): return {'x': x, 'y': y, 'z': 0.0}\n",
"doc = ocs.active_document\n",
"doc.create_entity('MultiLeader', content_type='MText', context={\n",
" 'has_text_contents': True, 'text_string': 'NOTE', 'text_location': P(20, 10),\n",
" 'text_height': 2.5,\n",
" 'leader_roots': [{'connection_point': P(15, 10), 'direction': P(1, 0), 'landing_distance': 2.0,\n",
" 'lines': [{'points': [P(0, 0), P(10, 10), P(15, 10)]}]}]})\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let handle = host.document().entities().find_map(|entity| match entity {
EntityType::MultiLeader(value) => Some(value.common.handle),
_ => None,
}).unwrap_or_else(|| panic!("Python did not create MultiLeader: {}", last_output(&host)));
let created = host.document().get_entity(handle).unwrap().clone();
let EntityType::MultiLeader(created_ml) = &created else { unreachable!() };
assert_eq!(created_ml.context.text_string, "NOTE");
assert_eq!(created_ml.context.leader_roots[0].lines[0].points.len(), 3);
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry");
let script = std::env::temp_dir().join(format!("ocs_mleader_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"def P(x, y): return {{'x': x, 'y': y, 'z': 0.0}}\n",
"doc = ocs.active_document\n",
"ml = doc.entities[{}]\n",
"roots = ml.context['leader_roots']\n",
"roots[0]['lines'][0]['points'] = [P(0, 0), P(12, 12), P(18, 12), P(22, 12)]\n",
"with doc.transaction('Edit multileader'):\n",
" ml.context = {{'leader_roots': roots, 'text_string': 'EDITED', 'text_location': P(26, 12), 'text_height': 3.0}}\n",
" ml.dogleg_length = 3.0\n",
" ml.line_color = {{'kind': 'Rgb', 'value': {{'r': 0, 'g': 128, 'b': 255}}}}\n",
"doc.selection = [ml]\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::MultiLeader(edited) = &expected else { unreachable!() };
assert_eq!(edited.context.text_string, "EDITED", "edit failed: {}", last_output(&host));
assert_eq!(edited.context.leader_roots[0].lines[0].points.len(), 4);
assert_eq!(edited.dogleg_length, 3.0);
assert_eq!(edited.context.text_height, 3.0);
assert_eq!(edited.text_height, created_ml.text_height);
assert_eq!(edited.line_color, acadrust::types::Color::Rgb { r: 0, g: 128, b: 255 });
// Untouched context fields and the entity identity survive a partial patch.
assert_eq!(edited.common, created_ml.common);
assert_eq!(edited.context.scale_factor, created_ml.context.scale_factor);
assert_eq!(edited.content_type, created_ml.content_type);
assert_eq!(edited.context.leader_roots[0].connection_point, created_ml.context.leader_roots[0].connection_point);
assert_eq!(host.selection(), vec![handle]);
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty());
for (patch, message) in [
("'text_height':0.0", "read-only"),
("'scale_factor':-1.0", "greater than zero"),
("'context':{'text_height':float('inf')}", "finite"),
("'style_handle':999999", "does not exist"),
("'text_style_handle':999999", "does not exist"),
("'content_type':'None'", "cannot carry"),
("'context':{'nonsense':1}", "outside the editable schema"),
("'context':{'leader_roots':[{'lines':[{'points':[]}]}]}", "has no points"),
("'context':{'text_location':{'x':float('nan'),'y':0.0,'z':0.0}}", "finite"),
("'dwg_version':5", "read-only"),
("'line_color':{'kind':'Index','value':300}", "Color"),
] {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject multileader', [{{'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':{},'dogleg_length':9.0}},",
"{{'handle':{},'scale_factor':-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("mleader.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("mleader.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
let Some(EntityType::MultiLeader(value)) = document.get_entity(handle) else {
gaps.push(format!("{format}: MultiLeader missing"));
continue;
};
if value.context.text_string != "EDITED" { gaps.push(format!("{format}: text {:?}", value.context.text_string)); }
let points = value.context.leader_roots.first().and_then(|r| r.lines.first()).map_or(0, |l| l.points.len());
if points != 4 { gaps.push(format!("{format}: leader points {points}")); }
if value.dogleg_length != 3.0 { gaps.push(format!("{format}: dogleg {}", value.dogleg_length)); }
if value.context.text_height != edited.context.text_height { gaps.push(format!("{format}: context.text_height {}", value.context.text_height)); }
if value.line_color != edited.line_color { gaps.push(format!("{format}: line_color {:?}", value.line_color)); }
}
assert!(gaps.is_empty(), "persistence gaps: {gaps:#?}");
for (name, mut document) in [("mleader-reedit.dwg", dwg), ("mleader-reedit.dxf", dxf)] {
let before = document.get_entity(handle).unwrap().clone();
let mut after = before.clone();
let EntityType::MultiLeader(value) = &mut after else { unreachable!() };
value.dogleg_length = 4.5;
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::MultiLeader(value)) if value.dogleg_length == 4.5), "{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 [
("mleader-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
("mleader-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 {