Map Leader in the Python host model; record acadrust W blockers

Adds Leader validation and annotation/dimension-style reference checks to
ocs_plugin_api, maps 15 writable and 5 read-only fields in the adapter, and
adds staged_python_leader_lifecycle_over_real_ipc covering C/R/E/D/U/I/V/P.
The W gate stays open: acadrust's DXF writer omits the annotation handle and
DXF reads drop annotation_offset; DWG R2010+ does not store text size or the
hookline flag. Each gap is pinned by a canary assertion.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 11:12:27 +02:00
1 parent a8dd3640b3
commit ec10bdbc48
10 files changed
+486 -20

No files matched your search

+17
View File
@@ -756,6 +756,23 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
| "paths" | "paths"
| "seed_points" | "seed_points"
) )
| (
"Leader",
"dimension_style"
| "arrow_enabled"
| "path_type"
| "creation_type"
| "hookline_direction"
| "hookline_enabled"
| "text_height"
| "text_width"
| "vertices"
| "annotation_handle"
| "normal"
| "horizontal_direction"
| "block_offset"
| "annotation_offset"
)
) { ) {
"transaction_geometry" "transaction_geometry"
} else { } else {
@@ -23,12 +23,14 @@
"Shape": ["layer", "insertion_point", "size", "shape_name", "shape_number", "rotation", "relative_x_scale", "oblique_angle", "normal", "thickness", "style_name"], "Shape": ["layer", "insertion_point", "size", "shape_name", "shape_number", "rotation", "relative_x_scale", "oblique_angle", "normal", "thickness", "style_name"],
"AttributeDefinition": ["layer", "tag", "prompt", "default_value", "insertion_point", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "text_style", "text_generation_flags", "horizontal_alignment", "vertical_alignment", "flags", "field_length", "normal", "mtext_flag", "is_multiline", "line_count", "lock_position"], "AttributeDefinition": ["layer", "tag", "prompt", "default_value", "insertion_point", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "text_style", "text_generation_flags", "horizontal_alignment", "vertical_alignment", "flags", "field_length", "normal", "mtext_flag", "is_multiline", "line_count", "lock_position"],
"AttributeEntity": ["layer", "tag", "value", "insertion_point", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "text_style", "text_generation_flags", "horizontal_alignment", "vertical_alignment", "flags", "field_length", "normal", "mtext_flag", "is_multiline", "line_count", "lock_position"], "AttributeEntity": ["layer", "tag", "value", "insertion_point", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "text_style", "text_generation_flags", "horizontal_alignment", "vertical_alignment", "flags", "field_length", "normal", "mtext_flag", "is_multiline", "line_count", "lock_position"],
"Hatch": ["layer", "elevation", "normal", "is_solid", "pattern", "pattern_angle", "pattern_scale", "pattern_type", "is_double", "style", "is_associative", "pixel_size", "paths", "seed_points"] "Hatch": ["layer", "elevation", "normal", "is_solid", "pattern", "pattern_angle", "pattern_scale", "pattern_type", "is_double", "style", "is_associative", "pixel_size", "paths", "seed_points"],
"Leader": ["layer", "dimension_style", "arrow_enabled", "path_type", "creation_type", "hookline_direction", "hookline_enabled", "text_height", "text_width", "vertices", "annotation_handle", "normal", "horizontal_direction", "block_offset", "annotation_offset"]
}, },
"readable": { "readable": {
"Insert": ["block_name"], "Insert": ["block_name"],
"Tolerance": ["dimension_style_handle"], "Tolerance": ["dimension_style_handle"],
"Shape": ["style_handle"], "Shape": ["style_handle"],
"Leader": ["origin", "dimension_gap", "arrowhead_type", "arrow_size", "byblock_color"],
"AttributeEntity": ["attdef_handle"] "AttributeEntity": ["attdef_handle"]
}, },
"aliases": { "aliases": {
@@ -231,6 +231,93 @@ fn validate_hatch_pattern(value: &crate::host::acadrust::entities::Hatch) -> Res
Ok(()) Ok(())
} }
/// Field checks shared by Leader creation and mutation. `old` limits the
/// checks to fields the edit touched so legacy DWG leaders stay editable.
#[cfg(feature = "host")]
fn validate_leader(
old: Option<&crate::host::acadrust::entities::Leader>,
new: &crate::host::acadrust::entities::Leader,
) -> Result<(), String> {
use crate::host::acadrust::entities::LeaderCreationType;
let vec_changed = |a: &crate::host::acadrust::types::Vector3,
b: &crate::host::acadrust::types::Vector3| {
old.is_none()
|| a.x.to_bits() != b.x.to_bits()
|| a.y.to_bits() != b.y.to_bits()
|| a.z.to_bits() != b.z.to_bits()
};
if old.is_none_or(|old| old.vertices != new.vertices) {
if new.vertices.len() < 2 {
return Err("Leader.vertices requires at least 2 points".into());
}
for (index, vertex) in new.vertices.iter().enumerate() {
finite_vector(&format!("Leader.vertices[{index}]"), vertex)?;
}
if new.vertices.windows(2).all(|pair| pair[0] == pair[1]) {
return Err("Leader.vertices must not all coincide".into());
}
}
if let Some(old_leader) = old {
if vec_changed(&old_leader.normal, &new.normal) {
solid_normal("Leader.normal", &new.normal)?;
}
} else {
solid_normal("Leader.normal", &new.normal)?;
}
if vec_changed(
old.map_or(&new.horizontal_direction, |o| &o.horizontal_direction),
&new.horizontal_direction,
) {
solid_normal("Leader.horizontal_direction", &new.horizontal_direction)?;
}
for (name, before, after) in [
(
"block_offset",
old.map_or(&new.block_offset, |o| &o.block_offset),
&new.block_offset,
),
(
"annotation_offset",
old.map_or(&new.annotation_offset, |o| &o.annotation_offset),
&new.annotation_offset,
),
] {
if vec_changed(before, after) {
finite_vector(&format!("Leader.{name}"), after)?;
}
}
for (name, before, after) in [
(
"text_height",
old.map_or(new.text_height, |o| o.text_height),
new.text_height,
),
(
"text_width",
old.map_or(new.text_width, |o| o.text_width),
new.text_width,
),
] {
if (old.is_none() || before.to_bits() != after.to_bits())
&& (!after.is_finite() || after < 0.0 || (name == "text_height" && after == 0.0))
{
return Err(format!(
"Leader.{name} must be finite and {}",
if name == "text_height" { "greater than zero" } else { "non-negative" }
));
}
}
if old.is_none_or(|o| o.dimension_style != new.dimension_style)
&& new.dimension_style.trim().is_empty()
{
return Err("Leader.dimension_style is empty".into());
}
if new.creation_type == LeaderCreationType::NoAnnotation && !new.annotation_handle.is_null() {
return Err("Leader with no annotation cannot carry an annotation handle".into());
}
Ok(())
}
/// Validate newly created geometry for the kinds whose mapped fields have /// 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. /// host checks. Called by the Python add path before an entity enters the document.
#[cfg(feature = "host")] #[cfg(feature = "host")]
@@ -322,6 +409,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<()
return Err("Shape.style_name is empty".into()); return Err("Shape.style_name is empty".into());
} }
} }
EntityType::Leader(value) => validate_leader(None, value)?,
EntityType::AttributeDefinition(value) => { EntityType::AttributeDefinition(value) => {
finite_vector( finite_vector(
"AttributeDefinition.insertion_point", "AttributeDefinition.insertion_point",
@@ -676,6 +764,7 @@ pub fn validate_entity_mutation(
return Err("Shape.style_handle is read-only".into()); return Err("Shape.style_handle is read-only".into());
} }
} }
(EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?,
(EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => {
changed3( changed3(
"AttributeDefinition.insertion_point", "AttributeDefinition.insertion_point",
@@ -937,6 +1026,48 @@ pub fn validate_canvas_entity_references(
return Err(format!("Shape text style {:?} has no SHX file", style.name)); return Err(format!("Shape text style {:?} has no SHX file", style.name));
} }
} }
if let EntityType::Leader(value) = entity {
use crate::host::acadrust::entities::LeaderCreationType;
if !document
.dim_styles
.iter()
.any(|style| style.name.eq_ignore_ascii_case(value.dimension_style.trim()))
{
return Err(format!(
"Leader dimension style {:?} does not exist",
value.dimension_style
));
}
if !value.annotation_handle.is_null() {
if value.creation_type == LeaderCreationType::NoAnnotation {
return Err("Leader with no annotation cannot carry an annotation handle".into());
}
let annotation = document
.get_entity(value.annotation_handle)
.filter(|_| value.annotation_handle != value.common.handle)
.ok_or_else(|| {
format!(
"Leader annotation handle {:?} does not exist",
value.annotation_handle
)
})?;
let expected = match value.creation_type {
LeaderCreationType::WithText => matches!(
annotation,
EntityType::Text(_) | EntityType::MText(_)
),
LeaderCreationType::WithTolerance => matches!(annotation, EntityType::Tolerance(_)),
LeaderCreationType::WithBlock => matches!(annotation, EntityType::Insert(_)),
LeaderCreationType::NoAnnotation => unreachable!(),
};
if !expected {
return Err(format!(
"Leader annotation {:?} does not match creation_type {:?}",
value.annotation_handle, value.creation_type
));
}
}
}
if let EntityType::AttributeDefinition(value) = entity { if let EntityType::AttributeDefinition(value) = entity {
let owner = entity.common().owner_handle; let owner = entity.common().owner_handle;
let block = document let block = document
@@ -1202,6 +1333,7 @@ mod tests {
| "AttributeDefinition" | "AttributeDefinition"
| "AttributeEntity" | "AttributeEntity"
| "Hatch" | "Hatch"
| "Leader"
) )
{ {
assert!( assert!(
@@ -1336,6 +1468,20 @@ mod tests {
"Hatch.pixel_size".to_owned(), "Hatch.pixel_size".to_owned(),
"Hatch.paths".to_owned(), "Hatch.paths".to_owned(),
"Hatch.seed_points".to_owned(), "Hatch.seed_points".to_owned(),
"Leader.dimension_style".to_owned(),
"Leader.arrow_enabled".to_owned(),
"Leader.path_type".to_owned(),
"Leader.creation_type".to_owned(),
"Leader.hookline_direction".to_owned(),
"Leader.hookline_enabled".to_owned(),
"Leader.text_height".to_owned(),
"Leader.text_width".to_owned(),
"Leader.vertices".to_owned(),
"Leader.annotation_handle".to_owned(),
"Leader.normal".to_owned(),
"Leader.horizontal_direction".to_owned(),
"Leader.block_offset".to_owned(),
"Leader.annotation_offset".to_owned(),
]) ])
); );
} }
@@ -1368,6 +1514,69 @@ mod tests {
.contains("does not exist")); .contains("does not exist"));
} }
#[cfg(feature = "host")]
#[test]
fn leader_geometry_and_annotation_references_are_validated() {
use crate::host::acadrust::{self, entities::LeaderCreationType, types::Vector3};
let mut document = acadrust::CadDocument::new();
let mut text = acadrust::entities::Text::new();
text.common.handle = document.allocate_handle();
let text_handle = text.common.handle;
document.add_entity(acadrust::EntityType::Text(text)).unwrap();
let mut leader = acadrust::entities::Leader::two_point(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 5.0, 0.0),
);
leader.annotation_handle = text_handle;
let entity = acadrust::EntityType::Leader(leader.clone());
validate_new_canvas_entity(&entity).unwrap();
validate_canvas_entity_references(&document, &entity).unwrap();
let mut wrong_kind = leader.clone();
wrong_kind.creation_type = LeaderCreationType::WithTolerance;
assert!(validate_canvas_entity_references(
&document,
&acadrust::EntityType::Leader(wrong_kind)
)
.unwrap_err()
.contains("does not match"));
let mut missing = leader.clone();
missing.annotation_handle = acadrust::Handle::new(0xdead);
assert!(validate_canvas_entity_references(
&document,
&acadrust::EntityType::Leader(missing)
)
.unwrap_err()
.contains("does not exist"));
let mut none_with_handle = leader.clone();
none_with_handle.creation_type = LeaderCreationType::NoAnnotation;
assert!(validate_new_canvas_entity(&acadrust::EntityType::Leader(none_with_handle))
.unwrap_err()
.contains("no annotation"));
let mut one_point = leader.clone();
one_point.vertices.truncate(1);
assert!(validate_new_canvas_entity(&acadrust::EntityType::Leader(one_point))
.unwrap_err()
.contains("at least 2"));
let mut bad_style = leader.clone();
bad_style.dimension_style = "Missing".into();
assert!(validate_canvas_entity_references(
&document,
&acadrust::EntityType::Leader(bad_style)
)
.unwrap_err()
.contains("dimension style"));
let mut edited = leader.clone();
edited.vertices[1] = Vector3::new(f64::NAN, 0.0, 0.0);
assert!(validate_entity_mutation(
&acadrust::EntityType::Leader(leader.clone()),
&acadrust::EntityType::Leader(edited)
)
.unwrap_err()
.contains("finite"));
}
#[cfg(feature = "host")] #[cfg(feature = "host")]
#[test] #[test]
fn shape_geometry_and_shape_file_style_are_validated_and_bound() { fn shape_geometry_and_shape_file_style_are_validated_and_bound() {
+13 -12
View File
@@ -5,15 +5,15 @@
Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work. Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work.
As of 20 September 2026, the working branch is `plugin/host-model-api` at As of 20 September 2026, the working branch is `plugin/host-model-api` at
`8648225a`, published as draft PR `a8dd3640` plus the Leader increment, published as draft PR
[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is
v7. The Python feature has explicit writes for **22 of 43** canvas kinds: 21 v7. The Python feature has explicit writes for **23 of 43** canvas kinds: 22
are creatable and `Insert` remains update-only. This is **not** 22 completed are creatable and `Insert` remains update-only. This is **not** 23 completed
kinds: Tolerance, Shape, AttributeEntity and Hatch pass every completion gate. kinds: Tolerance, Shape, AttributeEntity and Hatch pass every completion gate.
AttributeDefinition passes its host and real IPC lifecycle but remains short of AttributeDefinition passes its host and real IPC lifecycle but remains short of
`Complete` because the previously pinned CAD codec dropped optional ATTDEF `Complete` because the previously pinned CAD codec dropped optional ATTDEF
fields during DXF reads; revalidate that blocker against the current fields during DXF reads; revalidate that blocker against the current
`acadrust` revision `7ea4247`. The other 21 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 20 canvas kinds have only `layer`
writes through the document model. All 43 have raw typed snapshots. There are 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. three internal records and two opaque fallbacks outside the 43 canvas kinds.
@@ -123,14 +123,14 @@ The last five may require new engine capabilities rather than only Python conver
## Handoff start point ## Handoff start point
Continue with **Leader**. Build a valid vertex path and annotation-linked Continue with **MLine** (queue item 2). Leader is done except for its W
fixture, map geometry separately from annotation/object handles, and verify gate; when the engine writes DXF group 340, reads group 213 and the DWG
path rendering, selection, reference preservation, deletion, undo/redo and R2010+ text-size question is decided, flip the canary assertions in
both file formats through real IPC. AttributeDefinition remains mapped and `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`.
integration-tested but open at W. First rerun its DXF regression against AttributeDefinition remains mapped and integration-tested but open at W; its
current acadrust `7ea4247`; do not mark it complete until the full optional real IPC lifecycle now passes against acadrust `7ea4247`, so audit the full
property state passes DXF save/reopen. Keep the exact queue above and update optional-property DXF round trip and close or retain the blocker. Keep the
statuses with evidence after each increment. exact queue above and update statuses with evidence after each increment.
## Phase 8 progress log ## Phase 8 progress log
@@ -157,3 +157,4 @@ statuses with evidence after each increment.
- `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the actual v7 adapter. It creates a linked attribute from its block's ATTDEF, reads and edits value/placement/rotation, selects the parent Insert, renders text, rejects invalid tag/height/owner edits atomically, reopens edited and deleted state in DWG and DXF, and proves three-step undo/redo. AttributeEntity is **Complete**; the queue now continues with `Hatch`. - `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the actual v7 adapter. It creates a linked attribute from its block's ATTDEF, reads and edits value/placement/rotation, selects the parent Insert, renders text, rejects invalid tag/height/owner edits atomically, reopens edited and deleted state in DWG and DXF, and proves three-step undo/redo. AttributeEntity is **Complete**; the queue now continues with `Hatch`.
- Completed `Hatch` with full solid/pattern definitions and typed nested boundary paths. The adapter generator now handles single-payload tagged enums as `{kind, value}` dictionaries, which covers every Hatch edge variant and is reusable by later nested entity models. Unknown adapter properties are rejected instead of being silently ignored. - Completed `Hatch` with full solid/pattern definitions and typed nested boundary paths. The adapter generator now handles single-payload tagged enums as `{kind, value}` dictionaries, which covers every Hatch edge variant and is reusable by later nested entity models. Unknown adapter properties are rejected instead of being silently ignored.
- `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with closed line-loop fixtures: solid creation, conversion to a stored-line pattern, boundary replacement, selection and live render-cache checks, atomic invalid scale/association/open-loop/unmapped-payload rejection, edited/deleted DWG and DXF reopen, post-reopen edits, and three-step undo/redo. Hatch is **Complete**; the queue now continues with `Leader`. - `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with closed line-loop fixtures: solid creation, conversion to a stored-line pattern, boundary replacement, selection and live render-cache checks, atomic invalid scale/association/open-loop/unmapped-payload rejection, edited/deleted DWG and DXF reopen, post-reopen edits, and three-step undo/redo. Hatch is **Complete**; the queue now continues with `Leader`.
- 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`.
+2 -2
View File
@@ -1,6 +1,6 @@
# OCS canvas kind coverage ledger # 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: **22 of 43 canvas kinds have explicit geometry writes** (21 creatable, plus update-only `Insert`), **4 kinds are complete**, and 21 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: **23 of 43 canvas kinds have explicit geometry writes** (22 creatable, plus update-only `Insert`), **4 kinds are complete**, and 20 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. 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.
@@ -28,7 +28,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea
| AttributeDefinition | **Integration-tested / DXF blocker pending revalidation** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, rendered text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open:** acadrust `568a12c` dropped optional ATTDEF fields including width factor while reading DXF. Rerun the regression against the currently merged `7ea4247` revision and update this row with the result. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | | AttributeDefinition | **Integration-tested / DXF blocker pending revalidation** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, rendered text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open:** acadrust `568a12c` dropped optional ATTDEF fields including width factor while reading DXF. Rerun the regression against the currently merged `7ea4247` revision and update this row with the result. `embedded_mtext` mutation remains unsupported pending linked-layout validation. |
| AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | | AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. |
| Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | | Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. |
| Leader | Layer-only | Requires annotation-linked fixture. | | 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`. Unmapped but preserved on edit: `override_color` (multi-payload `Color` enum the generator cannot express) and 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). Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. |
| MLine | Layer-only | Requires valid style and vertex fixture. | | MLine | Layer-only | Requires valid style and vertex fixture. |
| Dimension | Layer-only | Track individual dimension subtypes. | | Dimension | Layer-only | Track individual dimension subtypes. |
| MultiLeader | Layer-only | Requires nested context and style fixture. | | MultiLeader | Layer-only | Requires nested context and style 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 catalog. All 43 canvas kinds also support nonempty `layer` changes through
the same undoable transaction; internal and opaque variants remain read-only the same undoable transaction; internal and opaque variants remain read-only
through the document model. The adapter accepts only `layer` patches for through the document model. The adapter accepts only `layer` patches for
canvas kinds outside its 22 geometry converters. Other writable properties canvas kinds outside its 23 geometry converters. Other writable properties
still report `type_conversion_only`; broader CAD range and cross-property still report `type_conversion_only`; broader CAD range and cross-property
checks remain outstanding. checks remain outstanding.
The host's `entity_snapshot` helper serializes any typed entity as a detached 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. token from another tab returns no result and does not consume it.
Python `doc.entities[handle]` returns a descriptor for every entity. For kinds Python `doc.entities[handle]` returns a descriptor for every entity. For kinds
outside the 22-kind generated schema it contains only handle, kind, and layer; outside the 23-kind generated schema it contains only handle, kind, and layer;
`layer` is editable on canvas kinds. `doc.coverage()` enumerates the `layer` is editable on canvas kinds. `doc.coverage()` enumerates the
catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For
covered kinds, the coverage object lists actual readable and editable 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 traced field, including fields marked `unmapped`. Query it with
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field, `doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
type, access, and current validation status. Geometry edits remain limited to type, access, and current validation status. Geometry edits remain limited to
the 22 mapped kinds. the 23 mapped kinds.
For any entity, `entity.snapshot` returns a detached dictionary of its raw For any entity, `entity.snapshot` returns a detached dictionary of its raw
CAD fields, including `snapshot["common"]` and kind-specific nested values. CAD fields, including `snapshot["common"]` and kind-specific nested values.
It can inspect an unmapped kind; editing the dictionary does It can inspect an unmapped kind; editing the dictionary does
+22 -1
View File
@@ -21,7 +21,8 @@
"Shape", "Shape",
"AttributeDefinition", "AttributeDefinition",
"AttributeEntity", "AttributeEntity",
"Hatch" "Hatch",
"Leader"
], ],
"base_fields": ["handle", "layer", "owner_handle"], "base_fields": ["handle", "layer", "owner_handle"],
"overrides": { "overrides": {
@@ -228,6 +229,26 @@
"mpolygon_x_direction": { "exclude": true }, "mpolygon_x_direction": { "exclude": true },
"mpolygon_boundary_handle_count": { "exclude": true } "mpolygon_boundary_handle_count": { "exclude": true }
} }
},
"Leader": {
"fields": {
"vertices": { "required": true },
"origin": { "read_only": true },
"dimension_gap": { "read_only": true },
"arrowhead_type": { "read_only": true },
"arrow_size": { "read_only": true },
"byblock_color": { "read_only": true },
"dwg_unknown_bit1": { "exclude": true },
"dwg_unknown_bit2": { "exclude": true },
"dwg_unknown_bit3": { "exclude": true },
"dwg_unknown_bit4": { "exclude": true },
"dwg_unknown_bit5": { "exclude": true },
"dwg_unknown_short1": { "exclude": true },
"override_color": {
"exclude": true,
"_comment": "Color is a multi-payload tagged enum the generator cannot express yet. It stays snapshot-readable and is preserved untouched by Python edits."
}
}
} }
} }
} }
+1 -1
View File
@@ -4663,7 +4663,7 @@ mod tests {
); );
} }
} }
assert_eq!(emitted.len(), 22); assert_eq!(emitted.len(), 23);
} }
#[cfg(feature = "experimental-host-model")] #[cfg(feature = "experimental-host-model")]
+216
View File
@@ -2152,6 +2152,222 @@ mod tests {
assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none()); assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none());
} }
#[test]
fn staged_python_leader_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
dispatch(&mut host, concat!(
"PY_EVAL ocs.active_document.create_entity('Text', text='NOTE', ",
"insertion={'x':20.0,'y':10.0,'z':0.0}, height=2.5).handle"
));
let text_handle = host.document().entities().find_map(|entity| match entity {
EntityType::Text(text) => Some(text.common.handle),
_ => None,
}).unwrap_or_else(|| panic!("Python did not create Text: {}", last_output(&host)));
let script = std::env::temp_dir().join(format!("ocs_leader_create_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"doc = ocs.active_document\n",
"doc.create_entity('Leader', vertices=[{{'x':0.0,'y':0.0,'z':0.0}},",
"{{'x':10.0,'y':10.0,'z':0.0}},{{'x':20.0,'y':10.0,'z':0.0}}], ",
"annotation_handle={}, hookline_enabled=True)\n"
), text_handle.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let leader_handle = host.document().entities().find_map(|entity| match entity {
EntityType::Leader(leader) => Some(leader.common.handle),
_ => None,
}).unwrap_or_else(|| panic!("Python did not create Leader: {}", last_output(&host)));
let created = host.document().get_entity(leader_handle).unwrap().clone();
let EntityType::Leader(created_leader) = &created else { unreachable!() };
assert_eq!(created_leader.annotation_handle, text_handle);
assert_eq!(created_leader.vertices.len(), 3);
let assert_geometry = |entity: &EntityType, document: &CadDocument| {
let EntityType::Leader(leader) = entity else { panic!("expected Leader") };
let render = leader.to_render(document).expect("leader render");
let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object else {
panic!("expected leader linework");
};
assert!(points.len() >= leader.vertices.len());
assert!(points.iter().flatten().all(|value| value.is_nan() || value.is_finite()));
};
assert_geometry(&created, host.document());
dispatch(&mut host, &format!(
"PY_EVAL ocs.active_document.entities[{}].annotation_handle", leader_handle.value()));
assert!(last_output(&host).contains(&text_handle.value().to_string()));
// GUI picking groups a leader with its annotation, while scripted
// selection stays exact.
host.app.tabs[0].scene.select_entities(&[leader_handle]);
let mut grouped = host.selection();
grouped.sort_by_key(|handle| handle.value());
assert_eq!(grouped, vec![text_handle, leader_handle]);
host.app.tabs[0].scene.replace_selection_exact(&[]);
let script = std::env::temp_dir().join(format!("ocs_leader_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"doc = ocs.active_document\n",
"leader = doc.entities[{}]\n",
"with doc.transaction('Edit leader'):\n",
" leader.vertices = [{{'x':1.0,'y':1.0,'z':0.0}},{{'x':11.0,'y':12.0,'z':0.0}},",
"{{'x':22.0,'y':12.0,'z':0.0}},{{'x':30.0,'y':12.0,'z':0.0}}]\n",
" leader.text_height = 4.0\n",
" leader.arrow_enabled = False\n",
" leader.annotation_offset = (1.0, 2.0, 0.0)\n",
"doc.selection = [leader]\n"
), leader_handle.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let expected = host.document().get_entity(leader_handle).unwrap().clone();
let EntityType::Leader(edited) = &expected else { unreachable!() };
assert_eq!(edited.vertices.len(), 4, "edit failed: {}", last_output(&host));
assert_eq!(edited.text_height, 4.0);
assert!(!edited.arrow_enabled);
assert_eq!(edited.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0));
assert_eq!(edited.annotation_handle, text_handle);
assert_eq!(edited.common, created_leader.common);
assert_eq!(edited.dimension_style, created_leader.dimension_style);
assert_eq!(edited.hookline_enabled, created_leader.hookline_enabled);
assert_eq!(edited.normal, created_leader.normal);
assert_eq!(edited.origin, created_leader.origin);
assert_eq!(edited.override_color, created_leader.override_color);
assert_eq!(host.selection(), vec![leader_handle]);
assert_geometry(&expected, host.document());
for (patch, message) in [
("'annotation_handle':999999", "does not exist"),
("'creation_type':'WithTolerance'", "does not match creation_type"),
("'creation_type':'NoAnnotation'", "no annotation"),
("'vertices':[{'x':0.0,'y':0.0,'z':0.0}]", "at least 2"),
("'text_height':0.0", "greater than zero"),
("'dimension_style':'Missing'", "dimension style"),
("'override_color':{}", "outside the editable schema"),
] {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject leader', [{{'handle':{}, {patch}}}])",
leader_handle.value()));
assert_eq!(host.document().get_entity(leader_handle), Some(&expected), "{patch}");
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
}
// A valid edit paired with an invalid one is atomic across entities.
dispatch(&mut host, &format!(concat!(
"PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'text_width':5.0}},",
"{{'handle':{},'text_height':0.0}}])"
), leader_handle.value(), text_handle.value()));
assert_eq!(host.document().get_entity(leader_handle), Some(&expected));
// Both formats keep the path, annotation link, arrow flag and offset.
// DWG R2010+ derives text_height/text_width/hookline_enabled instead of
// storing them (acadrust writes them for R13-R2007/R13-R14 only), so
// only DXF must round-trip those three.
let assert_saved = |document: &CadDocument, is_dxf: bool| {
let Some(EntityType::Leader(leader)) = document.get_entity(leader_handle) else {
panic!("Leader missing after reopen");
};
assert_eq!(leader.vertices.len(), 4);
if is_dxf {
// BLOCKER: acadrust's DXF writer never emits group 340, so a
// DXF save unlinks the annotation. Flip this to
// `assert_eq!(.., text_handle)` once the engine writes it.
assert!(leader.annotation_handle.is_null(), "acadrust now writes DXF 340; drop the blocker");
} else {
assert_eq!(leader.annotation_handle, text_handle);
}
assert!(!leader.arrow_enabled);
if is_dxf {
// BLOCKER: the DXF writer emits group 213 but the reader's
// coordinate mapping does not reassemble it.
assert_eq!(leader.annotation_offset, acadrust::types::Vector3::ZERO,
"acadrust now reads DXF 213; drop the blocker");
} else {
assert_eq!(leader.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0));
}
assert_eq!(leader.dimension_style, "Standard");
if is_dxf {
assert!((leader.text_height - 4.0).abs() < 1e-12);
assert!(leader.hookline_enabled);
} else {
assert_eq!(leader.text_height, 2.5, "DWG R2010+ does not store text_height");
assert!(!leader.hookline_enabled, "DWG R2010+ does not store hookline_enabled");
}
assert!(matches!(document.get_entity(text_handle), Some(EntityType::Text(_))));
};
let reopened_dwg = crate::io::load_bytes("leader.dwg",
acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let reopened_dxf = crate::io::load_bytes("leader.dxf",
acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
assert_saved(&reopened_dwg, false);
assert_saved(&reopened_dxf, true);
for (name, mut document) in [("leader-reedit.dwg", reopened_dwg),
("leader-reedit.dxf", reopened_dxf)] {
assert_geometry(document.get_entity(leader_handle).unwrap(), &document);
let before = document.get_entity(leader_handle).unwrap().clone();
let mut after = before.clone();
let EntityType::Leader(leader) = &mut after else { unreachable!() };
leader.vertices[3] = acadrust::types::Vector3::new(35.0, 12.0, 0.0);
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap();
*document.get_entity_mut(leader_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(leader_handle),
Some(EntityType::Leader(leader)) if (leader.vertices[3].x - 35.0).abs() < 1e-9));
}
// Erasing a leader also erases its annotation (scene leader grouping).
dispatch(&mut host, &format!(
"PY_EVAL ocs.active_document.delete_entity({})", leader_handle.value()));
assert!(host.document().get_entity(leader_handle).is_none());
assert!(host.document().get_entity(text_handle).is_none());
for (name, bytes) in [
("leader-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
("leader-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()),
] {
let document = crate::io::load_bytes(name, bytes).unwrap();
assert!(document.get_entity(leader_handle).is_none());
assert!(document.get_entity(text_handle).is_none());
}
drop(process);
drop(host);
app.finish_pending_history(0);
// Text create, Leader create, edit transaction, Leader delete.
assert_eq!(app.tabs[0].history.undo_stack.len(), 4);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&expected));
assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some());
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&created));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none());
assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some());
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none());
app.redo_steps(4);
assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none());
assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none());
}
#[test] #[test]
fn staged_python_point_pick_and_cancel_over_real_ipc() { fn staged_python_point_pick_and_cancel_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {