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"
| "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() {
+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.
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`.
+2 -2
View File
@@ -1,6 +1,6 @@
# OCS canvas kind coverage ledger
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **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. |
+2 -2
View File
@@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet
catalog. All 43 canvas kinds also support nonempty `layer` changes through
the same undoable transaction; internal and opaque variants remain read-only
through the document model. The adapter accepts only `layer` patches for
canvas kinds outside its 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
+1 -1
View File
@@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every
traced field, including fields marked `unmapped`. Query it with
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
type, access, and current validation status. Geometry edits remain limited to
the 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
+22 -1
View File
@@ -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."
}
}
}
}
}
+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")]
+216
View File
@@ -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 {