forked from 1b-agent/opencadstudio-upstream
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:
1 parent
22f9c20fe8
commit
b3f5388208
14 files changed
+523
-31
No files matched your search
@@ -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(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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}
|
||||
@@ -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`.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?; }"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user