forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8dd3640b3
commit
ec10bdbc48
10 files changed
+486
-20
No files matched your search
@@ -756,6 +756,23 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
|
||||
| "paths"
|
||||
| "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"
|
||||
} else {
|
||||
|
||||
@@ -23,12 +23,14 @@
|
||||
"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"],
|
||||
"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": {
|
||||
"Insert": ["block_name"],
|
||||
"Tolerance": ["dimension_style_handle"],
|
||||
"Shape": ["style_handle"],
|
||||
"Leader": ["origin", "dimension_gap", "arrowhead_type", "arrow_size", "byblock_color"],
|
||||
"AttributeEntity": ["attdef_handle"]
|
||||
},
|
||||
"aliases": {
|
||||
|
||||
@@ -231,6 +231,93 @@ fn validate_hatch_pattern(value: &crate::host::acadrust::entities::Hatch) -> Res
|
||||
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
|
||||
/// host checks. Called by the Python add path before an entity enters the document.
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
EntityType::Leader(value) => validate_leader(None, value)?,
|
||||
EntityType::AttributeDefinition(value) => {
|
||||
finite_vector(
|
||||
"AttributeDefinition.insertion_point",
|
||||
@@ -676,6 +764,7 @@ pub fn validate_entity_mutation(
|
||||
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)) => {
|
||||
changed3(
|
||||
"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));
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let owner = entity.common().owner_handle;
|
||||
let block = document
|
||||
@@ -1202,6 +1333,7 @@ mod tests {
|
||||
| "AttributeDefinition"
|
||||
| "AttributeEntity"
|
||||
| "Hatch"
|
||||
| "Leader"
|
||||
)
|
||||
{
|
||||
assert!(
|
||||
@@ -1336,6 +1468,20 @@ mod tests {
|
||||
"Hatch.pixel_size".to_owned(),
|
||||
"Hatch.paths".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"));
|
||||
}
|
||||
|
||||
#[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")]
|
||||
#[test]
|
||||
fn shape_geometry_and_shape_file_style_are_validated_and_bound() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
v7. The Python feature has explicit writes for **22 of 43** canvas kinds: 21
|
||||
are creatable and `Insert` remains update-only. This is **not** 22 completed
|
||||
v7. The Python feature has explicit writes for **23 of 43** canvas kinds: 22
|
||||
are creatable and `Insert` remains update-only. This is **not** 23 completed
|
||||
kinds: Tolerance, Shape, AttributeEntity and Hatch 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`. 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
|
||||
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
|
||||
|
||||
Continue with **Leader**. Build a valid vertex path and annotation-linked
|
||||
fixture, map geometry separately from annotation/object handles, and verify
|
||||
path rendering, selection, reference preservation, deletion, undo/redo and
|
||||
both file formats through real IPC. AttributeDefinition remains mapped and
|
||||
integration-tested but open at W. First rerun its DXF regression against
|
||||
current acadrust `7ea4247`; do not mark it complete until the full optional
|
||||
property state passes DXF save/reopen. Keep the exact queue above and update
|
||||
statuses with evidence after each increment.
|
||||
Continue with **MLine** (queue item 2). 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`.
|
||||
AttributeDefinition remains mapped and integration-tested but open at W; its
|
||||
real IPC lifecycle now passes against acadrust `7ea4247`, so audit the full
|
||||
optional-property DXF round trip and close or retain the blocker. Keep the
|
||||
exact queue above and update statuses with evidence after each increment.
|
||||
|
||||
## 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`.
|
||||
- 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`.
|
||||
- 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`.
|
||||
@@ -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: **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.
|
||||
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Dimension | Layer-only | Track individual dimension subtypes. |
|
||||
| MultiLeader | Layer-only | Requires nested context and style 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 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
|
||||
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 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
|
||||
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 22 mapped kinds.
|
||||
the 23 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
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"Shape",
|
||||
"AttributeDefinition",
|
||||
"AttributeEntity",
|
||||
"Hatch"
|
||||
"Hatch",
|
||||
"Leader"
|
||||
],
|
||||
"base_fields": ["handle", "layer", "owner_handle"],
|
||||
"overrides": {
|
||||
@@ -228,6 +229,26 @@
|
||||
"mpolygon_x_direction": { "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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4663,7 +4663,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(emitted.len(), 22);
|
||||
assert_eq!(emitted.len(), 23);
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
|
||||
@@ -2152,6 +2152,222 @@ mod tests {
|
||||
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]
|
||||
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