forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0206c7c30b
commit
b97d74efa4
11 files changed
+479
-15
No files matched your search
@@ -787,6 +787,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
|
||||
)
|
||||
| ("MultiLeader", _)
|
||||
| ("Table", _)
|
||||
| ("PolygonMesh", _)
|
||||
| ("PolyfaceMesh", _)
|
||||
| ("Mesh", _)
|
||||
| (
|
||||
"MLine",
|
||||
"flags"
|
||||
|
||||
@@ -344,6 +344,39 @@
|
||||
"break_spacing",
|
||||
"break_flow_direction",
|
||||
"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": {
|
||||
@@ -393,6 +426,12 @@
|
||||
"base_style",
|
||||
"break_data",
|
||||
"break_ranges"
|
||||
],
|
||||
"PolyfaceMesh": [
|
||||
"seqend_handle"
|
||||
],
|
||||
"Mesh": [
|
||||
"version"
|
||||
]
|
||||
},
|
||||
"aliases": {
|
||||
|
||||
@@ -622,6 +622,127 @@ fn validate_table(
|
||||
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
|
||||
/// host checks. Called by the Python add path before an entity enters the document.
|
||||
#[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::MultiLeader(value) => validate_multileader(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) => {
|
||||
finite_vector(
|
||||
"AttributeDefinition.insertion_point",
|
||||
@@ -1075,6 +1199,9 @@ pub fn validate_entity_mutation(
|
||||
(EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?,
|
||||
(EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => validate_multileader(Some(old), new)?,
|
||||
(EntityType::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::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?,
|
||||
(EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => {
|
||||
@@ -1749,6 +1876,9 @@ mod tests {
|
||||
| "Dimension"
|
||||
| "MultiLeader"
|
||||
| "Table"
|
||||
| "PolygonMesh"
|
||||
| "PolyfaceMesh"
|
||||
| "Mesh"
|
||||
)
|
||||
{
|
||||
assert!(
|
||||
@@ -1980,6 +2110,30 @@ mod tests {
|
||||
"Table.break_spacing".to_owned(),
|
||||
"Table.break_flow_direction".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(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas
|
||||
As of 20 September 2026, the working branch is `plugin/host-model-api` at
|
||||
`a8dd3640` plus the Leader increment, published as draft PR
|
||||
[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is
|
||||
v7. The Python feature has explicit writes for **27 of 43** canvas kinds: 26
|
||||
are creatable and `Insert` remains update-only. This is **not** 27 completed
|
||||
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader and Table pass every completion gate.
|
||||
v7. The Python feature has explicit writes for **30 of 43** canvas kinds: 29
|
||||
are creatable and `Insert` remains update-only. This is **not** 30 completed
|
||||
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
|
||||
`Complete` because the previously pinned CAD codec dropped optional ATTDEF
|
||||
fields during DXF reads; revalidate that blocker against the current
|
||||
`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 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
|
||||
three internal records and two opaque fallbacks outside the 43 canvas kinds.
|
||||
|
||||
@@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver
|
||||
|
||||
## Handoff start point
|
||||
|
||||
Continue with **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
|
||||
R2010+ text-size question is decided, flip the canary assertions in
|
||||
`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 `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 `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`.
|
||||
@@ -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: **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.
|
||||
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| PolygonMesh | Layer-only | Requires consistent grid/count fixture. |
|
||||
| PolyfaceMesh | Layer-only | Requires valid face-index fixture. |
|
||||
| Mesh | Layer-only | Requires valid topology 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 | **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 | **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. |
|
||||
| RasterImage | Layer-only | Requires image definition and file fixture. |
|
||||
| Wipeout | Layer-only | Requires clip and image-definition 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 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
|
||||
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 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
|
||||
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 27 mapped kinds.
|
||||
the 30 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
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
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 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 default_fields = String::new();
|
||||
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(
|
||||
registry,
|
||||
f.type_id.as_str(),
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
"MLine",
|
||||
"Dimension",
|
||||
"MultiLeader",
|
||||
"Table"
|
||||
"Table",
|
||||
"PolygonMesh",
|
||||
"PolyfaceMesh",
|
||||
"Mesh"
|
||||
],
|
||||
"manual_kinds": ["Dimension"],
|
||||
"base_fields": ["handle", "layer", "owner_handle"],
|
||||
@@ -310,6 +313,24 @@
|
||||
"legacy_border_line_weights": { "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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4756,7 +4756,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(emitted.len(), 27);
|
||||
assert_eq!(emitted.len(), 30);
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
|
||||
@@ -3100,6 +3100,242 @@ mod tests {
|
||||
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]
|
||||
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