Complete PolygonMesh, PolyfaceMesh and Mesh host model coverage

Maps all three mesh kinds with host validation of grid counts, face indices
and edge topology, and skips sub-record common data in the generator.
A shared run_mesh_lifecycle driver passes the full gate for each kind,
including DWG and DXF reopen with re-edit.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 14:42:42 +02:00
1 parent 0206c7c30b
commit b97d74efa4
11 files changed
+479 -15

No files matched your search

+3
View File
@@ -787,6 +787,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
) )
| ("MultiLeader", _) | ("MultiLeader", _)
| ("Table", _) | ("Table", _)
| ("PolygonMesh", _)
| ("PolyfaceMesh", _)
| ("Mesh", _)
| ( | (
"MLine", "MLine",
"flags" "flags"
@@ -344,6 +344,39 @@
"break_spacing", "break_spacing",
"break_flow_direction", "break_flow_direction",
"break_options" "break_options"
],
"PolygonMesh": [
"layer",
"flags",
"m_vertex_count",
"n_vertex_count",
"m_smooth_density",
"n_smooth_density",
"smooth_type",
"elevation",
"normal",
"vertices"
],
"PolyfaceMesh": [
"layer",
"elevation",
"flags",
"normal",
"start_width",
"end_width",
"smooth_surface",
"thickness",
"vertices",
"faces"
],
"Mesh": [
"layer",
"blend_crease",
"subdivision_level",
"vertices",
"faces",
"edges",
"override_option"
] ]
}, },
"readable": { "readable": {
@@ -393,6 +426,12 @@
"base_style", "base_style",
"break_data", "break_data",
"break_ranges" "break_ranges"
],
"PolyfaceMesh": [
"seqend_handle"
],
"Mesh": [
"version"
] ]
}, },
"aliases": { "aliases": {
@@ -622,6 +622,127 @@ fn validate_table(
Ok(()) Ok(())
} }
/// PolygonMesh: an M x N grid; the vertex list must match exactly.
#[cfg(feature = "host")]
fn validate_polygon_mesh(
old: Option<&crate::host::acadrust::entities::PolygonMeshEntity>,
new: &crate::host::acadrust::entities::PolygonMeshEntity,
) -> Result<(), String> {
if old == Some(new) {
return Ok(());
}
if new.m_vertex_count < 2 || new.n_vertex_count < 2 {
return Err("PolygonMesh.m_vertex_count and n_vertex_count must be at least 2".into());
}
let expected = new.m_vertex_count as usize * new.n_vertex_count as usize;
if new.vertices.len() != expected {
return Err(format!(
"PolygonMesh has {} vertices but {}x{} requires {expected}",
new.vertices.len(),
new.m_vertex_count,
new.n_vertex_count
));
}
if new.m_smooth_density < 0 || new.n_smooth_density < 0 {
return Err("PolygonMesh smooth densities must be non-negative".into());
}
if !new.elevation.is_finite() {
return Err("PolygonMesh.elevation must be finite".into());
}
solid_normal("PolygonMesh.normal", &new.normal)?;
for (index, vertex) in new.vertices.iter().enumerate() {
finite_vector(&format!("PolygonMesh.vertices[{index}].location"), &vertex.location)?;
}
Ok(())
}
/// PolyfaceMesh: faces index the vertex list (1-based; a negative index hides
/// that edge; 0 marks an unused corner).
#[cfg(feature = "host")]
fn validate_polyface_mesh(
old: Option<&crate::host::acadrust::entities::PolyfaceMesh>,
new: &crate::host::acadrust::entities::PolyfaceMesh,
) -> Result<(), String> {
if old == Some(new) {
return Ok(());
}
if !new.elevation.is_finite() || !new.thickness.is_finite() || !new.start_width.is_finite() || !new.end_width.is_finite() {
return Err("PolyfaceMesh elevation, thickness and widths must be finite".into());
}
solid_normal("PolyfaceMesh.normal", &new.normal)?;
if new.vertices.len() < 3 {
return Err("PolyfaceMesh.vertices requires at least 3 vertices".into());
}
for (index, vertex) in new.vertices.iter().enumerate() {
finite_vector(&format!("PolyfaceMesh.vertices[{index}].location"), &vertex.location)?;
if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() || !vertex.curve_tangent.is_finite() {
return Err(format!("PolyfaceMesh.vertices[{index}] has non-finite bulge, width or tangent"));
}
}
if new.faces.is_empty() {
return Err("PolyfaceMesh.faces requires at least 1 face".into());
}
let count = new.vertices.len() as i64;
for (index, face) in new.faces.iter().enumerate() {
let indices = [face.index1, face.index2, face.index3, face.index4];
for value in indices {
if i64::from(value).abs() > count {
return Err(format!(
"PolyfaceMesh.faces[{index}] references vertex {} but only {count} exist",
value.abs()
));
}
}
if indices.iter().filter(|value| **value != 0).count() < 3 {
return Err(format!("PolyfaceMesh.faces[{index}] needs at least 3 vertex indices"));
}
}
Ok(())
}
/// Mesh (subdivision): faces and edges index the vertex list (0-based).
#[cfg(feature = "host")]
fn validate_mesh(
old: Option<&crate::host::acadrust::entities::Mesh>,
new: &crate::host::acadrust::entities::Mesh,
) -> Result<(), String> {
if old == Some(new) {
return Ok(());
}
if new.subdivision_level < 0 {
return Err("Mesh.subdivision_level must be non-negative".into());
}
if new.vertices.len() < 3 {
return Err("Mesh.vertices requires at least 3 vertices".into());
}
for (index, vertex) in new.vertices.iter().enumerate() {
finite_vector(&format!("Mesh.vertices[{index}]"), vertex)?;
}
for (index, face) in new.faces.iter().enumerate() {
if face.vertices.len() < 3 {
return Err(format!("Mesh.faces[{index}] needs at least 3 vertices"));
}
if let Some(bad) = face.vertices.iter().find(|vertex| **vertex >= new.vertices.len()) {
return Err(format!(
"Mesh.faces[{index}] references vertex {bad} but only {} exist",
new.vertices.len()
));
}
}
for (index, edge) in new.edges.iter().enumerate() {
if edge.start >= new.vertices.len() || edge.end >= new.vertices.len() {
return Err(format!("Mesh.edges[{index}] references a vertex out of range"));
}
if edge.start == edge.end {
return Err(format!("Mesh.edges[{index}] has identical endpoints"));
}
if edge.crease.is_some_and(|crease| !crease.is_finite() || crease < 0.0) {
return Err(format!("Mesh.edges[{index}].crease must be finite and non-negative"));
}
}
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")]
@@ -718,6 +839,9 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<()
EntityType::Dimension(value) => validate_dimension(None, value)?, EntityType::Dimension(value) => validate_dimension(None, value)?,
EntityType::MultiLeader(value) => validate_multileader(None, value)?, EntityType::MultiLeader(value) => validate_multileader(None, value)?,
EntityType::Table(value) => validate_table(None, value)?, EntityType::Table(value) => validate_table(None, value)?,
EntityType::PolygonMesh(value) => validate_polygon_mesh(None, value)?,
EntityType::PolyfaceMesh(value) => validate_polyface_mesh(None, value)?,
EntityType::Mesh(value) => validate_mesh(None, value)?,
EntityType::AttributeDefinition(value) => { EntityType::AttributeDefinition(value) => {
finite_vector( finite_vector(
"AttributeDefinition.insertion_point", "AttributeDefinition.insertion_point",
@@ -1075,6 +1199,9 @@ pub fn validate_entity_mutation(
(EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?, (EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?,
(EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => validate_multileader(Some(old), new)?, (EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => validate_multileader(Some(old), new)?,
(EntityType::Table(old), EntityType::Table(new)) => validate_table(Some(old), new)?, (EntityType::Table(old), EntityType::Table(new)) => validate_table(Some(old), new)?,
(EntityType::PolygonMesh(old), EntityType::PolygonMesh(new)) => validate_polygon_mesh(Some(old), new)?,
(EntityType::PolyfaceMesh(old), EntityType::PolyfaceMesh(new)) => validate_polyface_mesh(Some(old), new)?,
(EntityType::Mesh(old), EntityType::Mesh(new)) => validate_mesh(Some(old), new)?,
(EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?,
(EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?,
(EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => {
@@ -1749,6 +1876,9 @@ mod tests {
| "Dimension" | "Dimension"
| "MultiLeader" | "MultiLeader"
| "Table" | "Table"
| "PolygonMesh"
| "PolyfaceMesh"
| "Mesh"
) )
{ {
assert!( assert!(
@@ -1980,6 +2110,30 @@ mod tests {
"Table.break_spacing".to_owned(), "Table.break_spacing".to_owned(),
"Table.break_flow_direction".to_owned(), "Table.break_flow_direction".to_owned(),
"Table.break_options".to_owned(), "Table.break_options".to_owned(),
"PolygonMesh.flags".to_owned(),
"PolygonMesh.m_vertex_count".to_owned(),
"PolygonMesh.n_vertex_count".to_owned(),
"PolygonMesh.m_smooth_density".to_owned(),
"PolygonMesh.n_smooth_density".to_owned(),
"PolygonMesh.smooth_type".to_owned(),
"PolygonMesh.elevation".to_owned(),
"PolygonMesh.normal".to_owned(),
"PolygonMesh.vertices".to_owned(),
"PolyfaceMesh.elevation".to_owned(),
"PolyfaceMesh.flags".to_owned(),
"PolyfaceMesh.normal".to_owned(),
"PolyfaceMesh.start_width".to_owned(),
"PolyfaceMesh.end_width".to_owned(),
"PolyfaceMesh.smooth_surface".to_owned(),
"PolyfaceMesh.thickness".to_owned(),
"PolyfaceMesh.vertices".to_owned(),
"PolyfaceMesh.faces".to_owned(),
"Mesh.blend_crease".to_owned(),
"Mesh.subdivision_level".to_owned(),
"Mesh.vertices".to_owned(),
"Mesh.faces".to_owned(),
"Mesh.edges".to_owned(),
"Mesh.override_option".to_owned(),
]) ])
); );
} }
+6 -5
View File
@@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas
As of 20 September 2026, the working branch is `plugin/host-model-api` at As of 20 September 2026, the working branch is `plugin/host-model-api` at
`a8dd3640` plus the Leader increment, 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 **27 of 43** canvas kinds: 26 v7. The Python feature has explicit writes for **30 of 43** canvas kinds: 29
are creatable and `Insert` remains update-only. This is **not** 27 completed are creatable and `Insert` remains update-only. This is **not** 30 completed
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader and Table pass every completion gate. kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh and Mesh 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`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 16 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 13 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,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver
## Handoff start point ## Handoff start point
Continue with **PolygonMesh** (queue item 6). Leader is done except for its W Continue with **Helix** (queue item 9). Leader is done except for its W
gate; when the engine writes DXF group 340, reads group 213 and the DWG 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 R2010+ text-size question is decided, flip the canary assertions in
`staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`.
@@ -162,3 +162,4 @@ exact queue above and update statuses with evidence after each increment.
- Completed `Dimension` for all nine subtypes. Because the payload is an enum, the adapter generator gained a `manual_kinds` hook (hand-written `<kind>_to_dict/_from_dict/_apply`) and the host catalog a `synthetic` policy section. The host derives `actual_measurement` and the base definition point on every scripted create/edit. Python ints are now accepted wherever a coordinate or angle is expected. `staged_python_dimension_lifecycle_over_real_ipc` records each subtype separately and all nine pass C/R/E/D/U/I/W/V/P. The queue now continues with `MultiLeader`. - Completed `Dimension` for all nine subtypes. Because the payload is an enum, the adapter generator gained a `manual_kinds` hook (hand-written `<kind>_to_dict/_from_dict/_apply`) and the host catalog a `synthetic` policy section. The host derives `actual_measurement` and the base definition point on every scripted create/edit. Python ints are now accepted wherever a coordinate or angle is expected. `staged_python_dimension_lifecycle_over_real_ipc` records each subtype separately and all nine pass C/R/E/D/U/I/W/V/P. The queue now continues with `MultiLeader`.
- Completed `MultiLeader`. The adapter generator now converts `Color`, `LineWeight`, mixed unit/payload enums, fixed float arrays and bitflags-2 newtypes, and a `keep_types` override lets a hand-written setter reuse generated converters (used for the merge-style `context` patch). `Leader.override_color` became writable through the same `Color` form; neither file format stores it. `staged_python_multileader_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `Table`. - Completed `MultiLeader`. The adapter generator now converts `Color`, `LineWeight`, mixed unit/payload enums, fixed float arrays and bitflags-2 newtypes, and a `keep_types` override lets a hand-written setter reuse generated converters (used for the merge-style `context` patch). `Leader.override_color` became writable through the same `Color` form; neither file format stores it. `staged_python_multileader_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `Table`.
- Completed `Table`. The generator now handles tuples and `usize`; the host keeps DXF cell merge dimensions in step with `merged_ranges`. `staged_python_table_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `PolygonMesh`. - Completed `Table`. The generator now handles tuples and `usize`; the host keeps DXF cell merge dimensions in step with `merged_ranges`. `staged_python_table_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `PolygonMesh`.
- Completed `PolygonMesh`, `PolyfaceMesh` and `Mesh` (queue items 6-8) with one shared `run_mesh_lifecycle` test driver and per-kind host validation. The generator now omits a sub-record's own `EntityCommon` from the scripted form. All three pass the full gate including DWG and DXF reopen with re-edit. The queue now continues with `Helix`.
+4 -4
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: **27 of 43 canvas kinds have explicit geometry writes** (26 creatable, plus update-only `Insert`), **8 kinds are complete**, and 16 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: **30 of 43 canvas kinds have explicit geometry writes** (29 creatable, plus update-only `Insert`), **11 kinds are complete**, and 13 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.
@@ -33,9 +33,9 @@ Completion gate codes: **C** = create through Python document model; **R** = rea
| Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the acadrust convenience constructors. | | Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the acadrust convenience constructors. |
| MultiLeader | **Complete** | Mapped through the generic generator, which gained support for `Color`, `LineWeight`, mixed unit/payload enums, fixed `[f64; N]` arrays, bitflags-2 newtypes and per-type module paths for this kind. Writable: `content_type`, `path_type`, `line_color`, `line_weight`, `enable_landing`, `enable_dogleg`, `dogleg_length`, `arrowhead_size`, `text_color`, `text_frame`, the four text attachments, attachment direction/point, `text_alignment`, `text_angle_type`, `text_direction_negative`, `scale_factor`, `enable_annotation_scale`, `extend_leader_to_text`, block content fields, the reference handles (`style_handle`, `text_style_handle`, `arrowhead_handle`, `line_type_handle`, `block_content_handle`) and `context`, which merges as a patch so a script can change `text_string` without resetting leader roots. Read-only: `dwg_version`, `property_override_flags`, `text_align_in_ipe` and the entity-level `text_height` (neither writer stores it; set `context.text_height`). Excluded but preserved: `arrowhead_overrides`, `block_attributes`. Host validation checks finite/positive scale, height, dogleg and arrow sizes, finite context geometry and transform, non-empty leader lines, content-type/context consistency, and that every referenced handle exists as the right object kind. `staged_python_multileader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: MText creation with a three-point leader root, a partial-context edit that replaces the path (four points), text, text location, dogleg and colour while preserving scale/content/common fields, canvas wires, eleven rejection cases plus a duplicate-handle atomic rollback, DWG and DXF reopen (text, path, dogleg, `context.text_height`, colour) with post-reopen re-edit, and three-step undo/redo. Not covered: block-content MultiLeaders (no block fixture yet), tolerance content, and style-defined defaults. | | MultiLeader | **Complete** | Mapped through the generic generator, which gained support for `Color`, `LineWeight`, mixed unit/payload enums, fixed `[f64; N]` arrays, bitflags-2 newtypes and per-type module paths for this kind. Writable: `content_type`, `path_type`, `line_color`, `line_weight`, `enable_landing`, `enable_dogleg`, `dogleg_length`, `arrowhead_size`, `text_color`, `text_frame`, the four text attachments, attachment direction/point, `text_alignment`, `text_angle_type`, `text_direction_negative`, `scale_factor`, `enable_annotation_scale`, `extend_leader_to_text`, block content fields, the reference handles (`style_handle`, `text_style_handle`, `arrowhead_handle`, `line_type_handle`, `block_content_handle`) and `context`, which merges as a patch so a script can change `text_string` without resetting leader roots. Read-only: `dwg_version`, `property_override_flags`, `text_align_in_ipe` and the entity-level `text_height` (neither writer stores it; set `context.text_height`). Excluded but preserved: `arrowhead_overrides`, `block_attributes`. Host validation checks finite/positive scale, height, dogleg and arrow sizes, finite context geometry and transform, non-empty leader lines, content-type/context consistency, and that every referenced handle exists as the right object kind. `staged_python_multileader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: MText creation with a three-point leader root, a partial-context edit that replaces the path (four points), text, text location, dogleg and colour while preserving scale/content/common fields, canvas wires, eleven rejection cases plus a duplicate-handle atomic rollback, DWG and DXF reopen (text, path, dogleg, `context.text_height`, colour) with post-reopen re-edit, and three-step undo/redo. Not covered: block-content MultiLeaders (no block fixture yet), tolerance content, and style-defined defaults. |
| Table | **Complete** | Mapped through the generic generator (now also handling tuples and `usize`, with the generic `LegacyBorderOverrides<T>` fields excluded and preserved). Writable: `insertion_point`, `horizontal_direction`, `normal`, `table_style_handle`, `rows`, `columns`, `merged_ranges`, `break_spacing`, `break_flow_direction`, `break_options`. Read-only: `block_name`, `block_record_handle`, `data_version`, `value_flags`, the override flags, `name`, `description`, `field_handles`, `base_style`, `break_data`, `break_ranges`, `legacy_style_override`. Creation yields the engine default 3x3 grid; scripts read `rows`/`columns`, modify or extend them and assign them back (rows and columns replace wholesale, so copy an existing row dict when adding one). Host validation requires a non-empty grid, positive finite row heights and column widths, every row to have exactly one cell per column, finite cell and content geometry, merged ranges inside the grid and non-overlapping, and existing table-style, cell text-style and cell block handles. `normalize_scripted_table` mirrors `merged_ranges` onto the origin cell's `merge_width/height` on every scripted change to the ranges. `staged_python_table_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: default-grid creation, a row append, column-width edit, a text cell, a three-column merge and break spacing in one transaction with untouched rows/columns/style/common fields preserved, canvas wires, ten rejection cases plus an atomic rollback, DWG and DXF reopen (row count, width, cell text, insertion) with re-edit, and three-step undo/redo. Format notes: DWG restores `merged_ranges` but not per-cell merge dimensions; DXF restores the cell dimensions but its reader does not rebuild `merged_ranges` (recorded by a canary assertion); both keep the merge. Not covered: the anonymous table block is not regenerated, cell styles and formats beyond text, linked data and field content. | | Table | **Complete** | Mapped through the generic generator (now also handling tuples and `usize`, with the generic `LegacyBorderOverrides<T>` fields excluded and preserved). Writable: `insertion_point`, `horizontal_direction`, `normal`, `table_style_handle`, `rows`, `columns`, `merged_ranges`, `break_spacing`, `break_flow_direction`, `break_options`. Read-only: `block_name`, `block_record_handle`, `data_version`, `value_flags`, the override flags, `name`, `description`, `field_handles`, `base_style`, `break_data`, `break_ranges`, `legacy_style_override`. Creation yields the engine default 3x3 grid; scripts read `rows`/`columns`, modify or extend them and assign them back (rows and columns replace wholesale, so copy an existing row dict when adding one). Host validation requires a non-empty grid, positive finite row heights and column widths, every row to have exactly one cell per column, finite cell and content geometry, merged ranges inside the grid and non-overlapping, and existing table-style, cell text-style and cell block handles. `normalize_scripted_table` mirrors `merged_ranges` onto the origin cell's `merge_width/height` on every scripted change to the ranges. `staged_python_table_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: default-grid creation, a row append, column-width edit, a text cell, a three-column merge and break spacing in one transaction with untouched rows/columns/style/common fields preserved, canvas wires, ten rejection cases plus an atomic rollback, DWG and DXF reopen (row count, width, cell text, insertion) with re-edit, and three-step undo/redo. Format notes: DWG restores `merged_ranges` but not per-cell merge dimensions; DXF restores the cell dimensions but its reader does not rebuild `merged_ranges` (recorded by a canary assertion); both keep the merge. Not covered: the anonymous table block is not regenerated, cell styles and formats beyond text, linked data and field content. |
| PolygonMesh | Layer-only | Requires consistent grid/count fixture. | | PolygonMesh | **Complete** | Writable: `flags`, `m_vertex_count`, `n_vertex_count`, `m_smooth_density`, `n_smooth_density`, `smooth_type`, `elevation`, `normal`, `vertices` (each `{location, flags}`; a vertex's own common data is not scripted and defaults). Host validation requires M and N of at least 2, exactly M×N vertices, non-negative densities, finite elevation and locations, and a nonzero normal, so inconsistent counts are refused before any mutation. `staged_python_polygon_mesh_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: a 3×3 grid, a vertex move plus density and cubic smoothing in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. |
| PolyfaceMesh | Layer-only | Requires valid face-index fixture. | | PolyfaceMesh | **Complete** | Writable: `elevation`, `flags`, `normal`, `start_width`, `end_width`, `smooth_surface`, `thickness`, `vertices` (location, flags, bulge, widths, tangent, id) and `faces` (four indices, flags, optional colour). `seqend_handle` is read-only and preserved. Host validation requires at least 3 finite vertices and 1 face, every face index within the vertex count (a negative index hides that edge, 0 marks an unused corner), at least 3 indices per face, and finite widths, thickness and elevation. `staged_python_polyface_mesh_lifecycle_over_real_ipc` passes the full gate: two triangles, a vertex move plus a new vertex and a hidden-edge face in one transaction, canvas wires, six rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. |
| Mesh | Layer-only | Requires valid topology fixture. | | Mesh | **Complete** | Writable: `blend_crease`, `subdivision_level`, `vertices`, `faces`, `edges` (start, end, optional crease), `override_option`; `version` is read-only. Host validation requires at least 3 finite vertices, faces of at least 3 in-range indices, edges with distinct in-range endpoints and a finite non-negative crease, and a non-negative subdivision level, so out-of-range topology is rejected before mutation. `staged_python_mesh_lifecycle_over_real_ipc` passes the full gate: a quad with a creased edge, an added vertex and triangle plus subdivision level 1 in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. |
| Helix | Layer-only | Requires valid derived-spline fixture. | | Helix | Layer-only | Requires valid derived-spline fixture. |
| RasterImage | Layer-only | Requires image definition and file fixture. | | RasterImage | Layer-only | Requires image definition and file fixture. |
| Wipeout | Layer-only | Requires clip and image-definition fixture. | | Wipeout | Layer-only | Requires clip and image-definition 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 27 geometry converters. Other writable properties canvas kinds outside its 30 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 27-kind generated schema it contains only handle, kind, and layer; outside the 30-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 27 mapped kinds. the 30 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
+11 -1
View File
@@ -218,7 +218,9 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec<String> {
panic!("entity_manifest.json: type {name} (reached transitively) is not in the type registry"); panic!("entity_manifest.json: type {name} (reached transitively) is not in the type registry");
}; };
for f in &info.fields { for f in &info.fields {
queue.push(f.type_id.as_str().to_string()); if f.type_id.as_str() != "EntityCommon" {
queue.push(f.type_id.as_str().to_string());
}
} }
for v in &info.variants { for v in &info.variants {
for f in &v.fields { for f in &v.fields {
@@ -666,6 +668,14 @@ fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String {
let mut from_dict_fields = String::new(); let mut from_dict_fields = String::new();
let mut default_fields = String::new(); let mut default_fields = String::new();
for f in &info.fields { for f in &info.fields {
if f.type_id.as_str() == "EntityCommon" {
// A sub-record's own common data (layer, XDATA, handles) is not part
// of the scripted model; it defaults on the way in.
let default = "acadrust::entities::EntityCommon::default()";
from_dict_fields.push_str(&format!(" {}: {default},\n", f.name));
default_fields.push_str(&format!(" {}: {default},\n", f.name));
continue;
}
let getter = getter_for_field( let getter = getter_for_field(
registry, registry,
f.type_id.as_str(), f.type_id.as_str(),
+22 -1
View File
@@ -26,7 +26,10 @@
"MLine", "MLine",
"Dimension", "Dimension",
"MultiLeader", "MultiLeader",
"Table" "Table",
"PolygonMesh",
"PolyfaceMesh",
"Mesh"
], ],
"manual_kinds": ["Dimension"], "manual_kinds": ["Dimension"],
"base_fields": ["handle", "layer", "owner_handle"], "base_fields": ["handle", "layer", "owner_handle"],
@@ -310,6 +313,24 @@
"legacy_border_line_weights": { "exclude": true }, "legacy_border_line_weights": { "exclude": true },
"legacy_border_visibility": { "exclude": true } "legacy_border_visibility": { "exclude": true }
} }
},
"PolygonMesh": {
"rust_constructor": "acadrust::entities::PolygonMeshEntity::default()",
"fields": { "vertices": { "required": true } }
},
"PolyfaceMesh": {
"fields": {
"vertices": { "required": true },
"faces": { "required": true },
"seqend_handle": { "read_only": true }
}
},
"Mesh": {
"fields": {
"vertices": { "required": true },
"faces": { "required": true },
"version": { "read_only": true }
}
} }
} }
} }
+1 -1
View File
@@ -4756,7 +4756,7 @@ mod tests {
); );
} }
} }
assert_eq!(emitted.len(), 27); assert_eq!(emitted.len(), 30);
} }
#[cfg(feature = "experimental-host-model")] #[cfg(feature = "experimental-host-model")]
+236
View File
@@ -3100,6 +3100,242 @@ mod tests {
assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
} }
/// One scripted lifecycle shared by the mesh kinds: create, edit,
/// reject, save/reopen both formats, re-edit, delete, undo/redo.
struct MeshCase {
create: &'static str,
edit: &'static str,
rejects: &'static [(&'static str, &'static str)],
is_kind: fn(&EntityType) -> bool,
digest: fn(&EntityType) -> String,
reedit: fn(&mut EntityType),
expect_created: &'static str,
expect_edited: &'static str,
expect_reedited: &'static str,
}
fn run_mesh_lifecycle(case: &MeshCase) {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let run_script = |host: &mut HostSession<'_>, tag: &str, body: &str| {
let path = std::env::temp_dir().join(format!("ocs_mesh_{tag}_{}.py", std::process::id()));
std::fs::write(&path, format!("def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\ndoc = ocs.active_document\n{body}")).unwrap();
dispatch(host, &format!("PY_RUN {}", path.display()));
let _ = std::fs::remove_file(&path);
};
run_script(&mut host, "create", case.create);
let handle = host.document().entities().find(|entity| (case.is_kind)(entity))
.map(|entity| entity.common().handle)
.unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host)));
let created = host.document().get_entity(handle).unwrap().clone();
assert_eq!((case.digest)(&created), case.expect_created);
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry");
run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string()));
let expected = host.document().get_entity(handle).unwrap().clone();
assert_eq!((case.digest)(&expected), case.expect_edited, "edit failed: {}", last_output(&host));
assert_eq!(expected.common(), created.common());
assert_eq!(host.selection(), vec![handle]);
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty());
for (patch, message) in case.rejects {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject mesh', [{{'handle':{}, {patch}}}])", handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}");
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
}
let (first_patch, _) = case.rejects[0];
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Atomic', [{{'handle':{h}, 'layer':'Other'}}, {{'handle':{h}, {first_patch}}}])",
h = handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected));
let dwg = crate::io::load_bytes("mesh.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("mesh.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
let mut gaps = Vec::new();
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
match document.get_entity(handle) {
Some(entity) if (case.digest)(entity) == case.expect_edited => {}
Some(entity) => gaps.push(format!("{format}: {}", (case.digest)(entity))),
None => gaps.push(format!("{format}: entity missing")),
}
}
assert!(gaps.is_empty(), "persistence gaps (expected {}): {gaps:#?}", case.expect_edited);
for (name, mut document) in [("mesh-reedit.dwg", dwg), ("mesh-reedit.dxf", dxf)] {
let before = document.get_entity(handle).unwrap().clone();
let mut after = before.clone();
(case.reedit)(&mut after);
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap();
*document.get_entity_mut(handle).unwrap() = after;
let bytes = if name.ends_with("dwg") {
acadrust::DwgWriter::write_to_vec(&document).unwrap()
} else {
acadrust::DxfWriter::new(&document).write_to_vec().unwrap()
};
let reopened = crate::io::load_bytes(name, bytes).unwrap();
assert_eq!((case.digest)(reopened.get_entity(handle).unwrap()), case.expect_reedited, "{name}");
}
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value()));
assert!(host.document().get_entity(handle).is_none());
for (name, bytes) in [
("mesh-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
("mesh-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()),
] {
assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none());
}
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected));
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_polygon_mesh_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"verts = [{'location': P(i, j, 0.0)} for i in range(3) for j in range(3)]\n",
"doc.create_entity('PolygonMesh', m_vertex_count=3, n_vertex_count=3, vertices=verts)\n"),
edit: concat!(
"mesh = doc.entities[HANDLE]\n",
"verts = mesh.vertices\n",
"verts[4]['location'] = P(1.0, 1.0, 2.5)\n",
"with doc.transaction('Edit mesh'):\n",
" mesh.vertices = verts\n",
" mesh.m_smooth_density = 6\n",
" mesh.n_smooth_density = 6\n",
" mesh.smooth_type = 'Cubic'\n",
"doc.selection = [mesh]\n"),
rejects: &[
("'m_vertex_count':4", "requires 12"),
("'n_vertex_count':1", "at least 2"),
("'m_smooth_density':-1", "non-negative"),
("'elevation':float('nan')", "finite"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}}]*9", "finite"),
("'smooth_type':'Spline'", "unsupported"),
],
is_kind: |entity| matches!(entity, EntityType::PolygonMesh(_)),
digest: |entity| match entity {
EntityType::PolygonMesh(m) => format!("{}x{} v{} z{} d{}/{} {:?}", m.m_vertex_count, m.n_vertex_count,
m.vertices.len(), m.vertices[4].location.z, m.m_smooth_density, m.n_smooth_density, m.smooth_type),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::PolygonMesh(m) = entity { m.vertices[0].location.z = 1.0; },
expect_created: "3x3 v9 z0 d0/0 NoSmooth",
expect_edited: "3x3 v9 z2.5 d6/6 Cubic",
expect_reedited: "3x3 v9 z2.5 d6/6 Cubic",
});
}
#[test]
fn staged_python_polyface_mesh_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"verts = [{'location': P(0.0, 0.0, 0.0)}, {'location': P(4.0, 0.0, 0.0)},\n",
" {'location': P(4.0, 4.0, 0.0)}, {'location': P(0.0, 4.0, 0.0)}]\n",
"faces = [{'index1': 1, 'index2': 2, 'index3': 3}, {'index1': 1, 'index2': 3, 'index3': 4}]\n",
"doc.create_entity('PolyfaceMesh', vertices=verts, faces=faces)\n"),
edit: concat!(
"mesh = doc.entities[HANDLE]\n",
"verts = mesh.vertices\n",
"verts[2]['location'] = P(4.0, 4.0, 3.0)\n",
"verts.append({'location': P(2.0, 6.0, 0.0)})\n",
"faces = mesh.faces\n",
"faces.append({'index1': 3, 'index2': -4, 'index3': 5})\n",
"with doc.transaction('Edit mesh'):\n",
" mesh.vertices = verts\n",
" mesh.faces = faces\n",
"doc.selection = [mesh]\n"),
rejects: &[
("'faces':[{'index1':1,'index2':2,'index3':9}]", "references vertex 9"),
("'faces':[{'index1':1,'index2':2}]", "at least 3 vertex indices"),
("'faces':[]", "at least 1 face"),
("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 3"),
("'thickness':float('inf')", "finite"),
("'seqend_handle':5", "read-only"),
],
is_kind: |entity| matches!(entity, EntityType::PolyfaceMesh(_)),
digest: |entity| match entity {
EntityType::PolyfaceMesh(m) => format!("v{} f{} z{} last {:?}", m.vertices.len(), m.faces.len(),
m.vertices[2].location.z,
m.faces.last().map(|f| (f.index1, f.index2, f.index3))),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::PolyfaceMesh(m) = entity { m.vertices[0].location.z = 1.0; },
expect_created: "v4 f2 z0 last Some((1, 3, 4))",
expect_edited: "v5 f3 z3 last Some((3, -4, 5))",
expect_reedited: "v5 f3 z3 last Some((3, -4, 5))",
});
}
#[test]
fn staged_python_mesh_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"verts = [P(0.0, 0.0, 0.0), P(4.0, 0.0, 0.0), P(4.0, 4.0, 0.0), P(0.0, 4.0, 0.0)]\n",
"doc.create_entity('Mesh', vertices=verts, faces=[{'vertices': [0, 1, 2, 3]}],\n",
" edges=[{'start': 0, 'end': 1, 'crease': 1.5}])\n"),
edit: concat!(
"mesh = doc.entities[HANDLE]\n",
"verts = mesh.vertices\n",
"verts.append(P(2.0, 6.0, 0.0))\n",
"faces = mesh.faces\n",
"faces.append({'vertices': [3, 2, 4]})\n",
"with doc.transaction('Edit mesh'):\n",
" mesh.vertices = verts\n",
" mesh.faces = faces\n",
" mesh.subdivision_level = 1\n",
"doc.selection = [mesh]\n"),
rejects: &[
("'faces':[{'vertices':[0,1,9]}]", "references vertex 9"),
("'faces':[{'vertices':[0,1]}]", "at least 3 vertices"),
("'edges':[{'start':0,'end':9}]", "out of range"),
("'edges':[{'start':2,'end':2}]", "identical endpoints"),
("'edges':[{'start':0,'end':1,'crease':-1.0}]", "non-negative"),
("'subdivision_level':-1", "non-negative"),
("'version':3", "read-only"),
],
is_kind: |entity| matches!(entity, EntityType::Mesh(_)),
digest: |entity| match entity {
EntityType::Mesh(m) => format!("v{} f{} e{} sub{} crease {:?}", m.vertices.len(), m.faces.len(),
m.edges.len(), m.subdivision_level, m.edges.first().and_then(|e| e.crease)),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Mesh(m) = entity { m.vertices[0].z = 1.0; },
expect_created: "v4 f1 e1 sub0 crease Some(1.5)",
expect_edited: "v5 f2 e1 sub1 crease Some(1.5)",
expect_reedited: "v5 f2 e1 sub1 crease Some(1.5)",
});
}
#[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 {