From 6b4c86fd7a4bee3683d16c70abd2b0537c1587aa Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:15:03 +0200 Subject: [PATCH] Complete Dimension host model coverage for all nine subtypes Adds a hand-written flat Dimension model to the adapter (manifest manual_kinds hook) and a synthetic-property section to the host coverage policy. The host derives actual_measurement and the base definition point on scripted create/edit. staged_python_dimension_lifecycle_over_real_ipc passes the full gate for every subtype including DWG and DXF reopen. Python ints are now accepted for coordinates and angles. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 24 + .../entity_coverage_policy.json | 554 ++++++++++++++++-- crates/ocs_plugin_api/src/entity_coverage.rs | 131 +++++ docs/plugin-host-model-completion-plan.md | 11 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/build/generate.rs | 28 + plugins/opencad-python/entity_manifest.json | 4 +- .../opencad-python/src/dimension_model.rs.inc | 327 +++++++++++ plugins/opencad-python/src/ocs_module.rs | 27 +- src/app/plugin_host.rs | 206 +++++++ src/entities/dimension.rs | 8 + 13 files changed, 1281 insertions(+), 49 deletions(-) create mode 100644 plugins/opencad-python/src/dimension_model.rs.inc diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 277d79ea6..77623fd0e 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -21,6 +21,13 @@ use entity_coverage_schema::{ EntityCoverageCatalog, EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage, }; +#[derive(serde::Deserialize)] +struct SyntheticProperty { + name: String, + type_id: String, + access: String, +} + #[derive(serde::Deserialize)] struct EntityCoveragePolicy { internal_kinds: Vec, @@ -28,6 +35,10 @@ struct EntityCoveragePolicy { editable: BTreeMap>, readable: BTreeMap>, aliases: BTreeMap>, + /// Properties for kinds whose payload is a nested enum (Dimension), where + /// the traced variant has no single field list to map. + #[serde(default)] + synthetic: BTreeMap>, } fn main() { @@ -802,6 +813,19 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { model_access: access, }); } + for synthetic in policy.synthetic.get(kind).into_iter().flatten() { + let writable = synthetic.access == "read_write"; + properties.push(PropertyCoverage { + name: synthetic.name.clone(), + source_path: format!(".{}", synthetic.name), + type_id: synthetic.type_id.clone(), + optional: false, + is_sequence: false, + snapshot_readable: true, + validation: if writable { "transaction_geometry" } else { "none" }.into(), + model_access: if writable { ModelAccess::ReadWrite } else { ModelAccess::ReadOnly }, + }); + } assert!( unresolved.is_empty(), "{kind} has coverage keys absent from registry: {unresolved:?}" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 4230aeee2..b60db1e29 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -1,44 +1,526 @@ { - "internal_kinds": ["Block", "BlockEnd", "Seqend"], - "opaque_kinds": ["Extended", "Unknown"], + "internal_kinds": [ + "Block", + "BlockEnd", + "Seqend" + ], + "opaque_kinds": [ + "Extended", + "Unknown" + ], "editable": { - "Point": ["layer", "location", "thickness", "normal", "x_axis_angle"], - "Line": ["layer", "start", "end", "thickness", "normal"], - "Circle": ["layer", "center", "radius", "thickness", "normal"], - "Arc": ["layer", "center", "radius", "start_angle", "end_angle", "thickness", "normal"], - "Ellipse": ["layer", "center", "major_axis", "minor_axis_ratio", "start_parameter", "end_parameter", "normal"], - "Polyline": ["layer", "closed", "vertices"], - "Polyline2D": ["layer", "closed", "smooth_surface", "start_width", "end_width", "thickness", "elevation", "normal", "vertices"], - "Polyline3D": ["layer", "flags", "default_start_width", "default_end_width", "mesh_m_count", "mesh_n_count", "smooth_m_density", "smooth_n_density", "elevation", "normal", "vertices"], - "LwPolyline": ["layer", "vertices", "is_closed", "plinegen", "constant_width", "elevation", "thickness", "normal"], - "Spline": ["layer", "degree", "flags", "knots", "control_points", "weights", "fit_points", "normal", "knot_tolerance", "control_tolerance", "fit_tolerance", "begin_tangent", "end_tangent", "knot_parameterization", "cv_frame_visible", "dwg_flags1", "dxf_flags"], - "Text": ["layer", "text", "insertion", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "style", "horizontal_alignment", "vertical_alignment", "normal", "thickness", "generation_flags"], - "MText": ["layer", "text", "insertion", "height", "rectangle_width", "rectangle_height", "rotation", "dwg_x_direction", "style", "attachment_point", "drawing_direction", "line_spacing_factor", "line_spacing_style", "normal", "background_fill_flags", "background_scale", "background_transparency", "is_annotative", "column_data", "extents_width", "extents_height"], - "Ray": ["layer", "base_point", "direction"], - "XLine": ["layer", "base_point", "direction"], - "Solid": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "normal", "thickness", "is_trace"], - "Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"], - "Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"], - "Tolerance": ["layer", "insertion_point", "direction", "normal", "text", "dimension_style_name", "text_height", "dimension_gap"], - "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"], - "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"], - "MLine": ["layer", "flags", "justification", "normal", "scale_factor", "style_name", "vertices"] + "Point": [ + "layer", + "location", + "thickness", + "normal", + "x_axis_angle" + ], + "Line": [ + "layer", + "start", + "end", + "thickness", + "normal" + ], + "Circle": [ + "layer", + "center", + "radius", + "thickness", + "normal" + ], + "Arc": [ + "layer", + "center", + "radius", + "start_angle", + "end_angle", + "thickness", + "normal" + ], + "Ellipse": [ + "layer", + "center", + "major_axis", + "minor_axis_ratio", + "start_parameter", + "end_parameter", + "normal" + ], + "Polyline": [ + "layer", + "closed", + "vertices" + ], + "Polyline2D": [ + "layer", + "closed", + "smooth_surface", + "start_width", + "end_width", + "thickness", + "elevation", + "normal", + "vertices" + ], + "Polyline3D": [ + "layer", + "flags", + "default_start_width", + "default_end_width", + "mesh_m_count", + "mesh_n_count", + "smooth_m_density", + "smooth_n_density", + "elevation", + "normal", + "vertices" + ], + "LwPolyline": [ + "layer", + "vertices", + "is_closed", + "plinegen", + "constant_width", + "elevation", + "thickness", + "normal" + ], + "Spline": [ + "layer", + "degree", + "flags", + "knots", + "control_points", + "weights", + "fit_points", + "normal", + "knot_tolerance", + "control_tolerance", + "fit_tolerance", + "begin_tangent", + "end_tangent", + "knot_parameterization", + "cv_frame_visible", + "dwg_flags1", + "dxf_flags" + ], + "Text": [ + "layer", + "text", + "insertion", + "alignment_point", + "height", + "rotation", + "width_factor", + "oblique_angle", + "style", + "horizontal_alignment", + "vertical_alignment", + "normal", + "thickness", + "generation_flags" + ], + "MText": [ + "layer", + "text", + "insertion", + "height", + "rectangle_width", + "rectangle_height", + "rotation", + "dwg_x_direction", + "style", + "attachment_point", + "drawing_direction", + "line_spacing_factor", + "line_spacing_style", + "normal", + "background_fill_flags", + "background_scale", + "background_transparency", + "is_annotative", + "column_data", + "extents_width", + "extents_height" + ], + "Ray": [ + "layer", + "base_point", + "direction" + ], + "XLine": [ + "layer", + "base_point", + "direction" + ], + "Solid": [ + "layer", + "first_corner", + "second_corner", + "third_corner", + "fourth_corner", + "normal", + "thickness", + "is_trace" + ], + "Face3D": [ + "layer", + "first_corner", + "second_corner", + "third_corner", + "fourth_corner", + "invisible_edges" + ], + "Insert": [ + "layer", + "insert_point", + "x_scale", + "y_scale", + "z_scale", + "rotation", + "normal", + "column_count", + "row_count", + "column_spacing", + "row_spacing" + ], + "Tolerance": [ + "layer", + "insertion_point", + "direction", + "normal", + "text", + "dimension_style_name", + "text_height", + "dimension_gap" + ], + "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" + ], + "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" + ], + "MLine": [ + "layer", + "flags", + "justification", + "normal", + "scale_factor", + "style_name", + "vertices" + ] }, "readable": { - "Insert": ["block_name"], - "Tolerance": ["dimension_style_handle"], - "Shape": ["style_handle"], - "MLine": ["start_point", "style_handle", "style_element_count"], - "Leader": ["origin", "dimension_gap", "arrowhead_type", "arrow_size", "byblock_color"], - "AttributeEntity": ["attdef_handle"] + "Insert": [ + "block_name" + ], + "Tolerance": [ + "dimension_style_handle" + ], + "Shape": [ + "style_handle" + ], + "MLine": [ + "start_point", + "style_handle", + "style_element_count" + ], + "Leader": [ + "origin", + "dimension_gap", + "arrowhead_type", + "arrow_size", + "byblock_color" + ], + "AttributeEntity": [ + "attdef_handle" + ] }, "aliases": { - "Polyline": {"closed": "flags"}, - "Polyline2D": {"closed": "flags"}, - "Text": {"text": "value", "insertion": "insertion_point"}, - "MText": {"text": "value", "insertion": "insertion_point"} + "Polyline": { + "closed": "flags" + }, + "Polyline2D": { + "closed": "flags" + }, + "Text": { + "text": "value", + "insertion": "insertion_point" + }, + "MText": { + "text": "value", + "insertion": "insertion_point" + } + }, + "synthetic": { + "Dimension": [ + { + "name": "subtype", + "type_id": "String", + "access": "read_only" + }, + { + "name": "actual_measurement", + "type_id": "f64", + "access": "read_only" + }, + { + "name": "text", + "type_id": "String", + "access": "read_write" + }, + { + "name": "style_name", + "type_id": "String", + "access": "read_write" + }, + { + "name": "normal", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "text_middle_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "attachment_point", + "type_id": "AttachmentPointType", + "access": "read_write" + }, + { + "name": "text_rotation", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "horizontal_direction", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "flip_arrow1", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "flip_arrow2", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "text_user_positioned", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "definition_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "first_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "second_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "angle_vertex", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "dimension_arc", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "feature_location", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "leader_endpoint", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "first_extension_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "second_extension_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "center_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "first_leader_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "second_leader_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "chord_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "override_center", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "jog_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "rotation", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "ext_line_rotation", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "leader_length", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "arc_start_parameter", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "arc_end_parameter", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "jog_angle", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "is_ordinate_type_x", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "is_partial", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "has_leader", + "type_id": "bool", + "access": "read_write" + } + ] } } diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 37ef77f55..51c268545 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -377,6 +377,94 @@ fn validate_mline( Ok(()) } +/// Field checks for every Dimension subtype. Derived fields (base definition +/// point, `actual_measurement`) are recomputed by the host, so they are not +/// validated as inputs; the measurement the geometry implies must be finite. +#[cfg(feature = "host")] +fn validate_dimension( + old: Option<&crate::host::acadrust::entities::Dimension>, + new: &crate::host::acadrust::entities::Dimension, +) -> Result<(), String> { + use crate::host::acadrust::entities::Dimension as D; + if old == Some(new) { + return Ok(()); + } + let base = new.base(); + solid_normal("Dimension.normal", &base.normal)?; + finite_vector("Dimension.text_middle_point", &base.text_middle_point)?; + if !base.text_rotation.is_finite() || !base.horizontal_direction.is_finite() { + return Err("Dimension.text_rotation and horizontal_direction must be finite".into()); + } + if base.style_name.trim().is_empty() { + return Err("Dimension.style_name is empty".into()); + } + let (points, scalars, distinct): (Vec<(&str, &crate::host::acadrust::types::Vector3)>, Vec<(&str, f64)>, Vec<(&str, &str)>) = match new { + D::Aligned(d) => ( + vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point)], + vec![("ext_line_rotation", d.ext_line_rotation)], + vec![("first_point", "second_point")], + ), + D::Linear(d) => ( + vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point)], + vec![("rotation", d.rotation), ("ext_line_rotation", d.ext_line_rotation)], + vec![("first_point", "second_point")], + ), + D::Radius(d) => ( + vec![("definition_point", &d.definition_point), ("angle_vertex", &d.angle_vertex)], + vec![("leader_length", d.leader_length)], + vec![("definition_point", "angle_vertex")], + ), + D::Diameter(d) => ( + vec![("definition_point", &d.definition_point), ("angle_vertex", &d.angle_vertex)], + vec![("leader_length", d.leader_length)], + vec![("definition_point", "angle_vertex")], + ), + D::Angular2Ln(d) => ( + vec![("definition_point", &d.definition_point), ("dimension_arc", &d.dimension_arc), ("first_point", &d.first_point), ("second_point", &d.second_point), ("angle_vertex", &d.angle_vertex)], + vec![], + vec![], + ), + D::Angular3Pt(d) => ( + vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point), ("angle_vertex", &d.angle_vertex)], + vec![], + vec![("first_point", "angle_vertex"), ("second_point", "angle_vertex")], + ), + D::Ordinate(d) => ( + vec![("definition_point", &d.definition_point), ("feature_location", &d.feature_location), ("leader_endpoint", &d.leader_endpoint)], + vec![], + vec![], + ), + D::Arc(d) => ( + vec![("definition_point", &d.definition_point), ("first_extension_point", &d.first_extension_point), ("second_extension_point", &d.second_extension_point), ("center_point", &d.center_point), ("first_leader_point", &d.first_leader_point), ("second_leader_point", &d.second_leader_point)], + vec![("arc_start_parameter", d.arc_start_parameter), ("arc_end_parameter", d.arc_end_parameter)], + vec![("first_extension_point", "second_extension_point")], + ), + D::LargeRadial(d) => ( + vec![("definition_point", &d.definition_point), ("chord_point", &d.chord_point), ("override_center", &d.override_center), ("jog_point", &d.jog_point)], + vec![("jog_angle", d.jog_angle)], + vec![("definition_point", "chord_point")], + ), + }; + for (name, point) in &points { + finite_vector(&format!("Dimension.{name}"), point)?; + } + for (name, value) in &scalars { + if !value.is_finite() { + return Err(format!("Dimension.{name} must be finite")); + } + } + let find = |name: &str| points.iter().find(|(n, _)| *n == name).map(|(_, p)| **p); + for (a, b) in distinct { + if find(a) == find(b) { + return Err(format!("Dimension.{a} and {b} must not coincide")); + } + } + if !new.measurement().is_finite() { + return Err("Dimension geometry does not define a finite measurement".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")] @@ -470,6 +558,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } EntityType::Leader(value) => validate_leader(None, value)?, EntityType::MLine(value) => validate_mline(None, value)?, + EntityType::Dimension(value) => validate_dimension(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -824,6 +913,7 @@ pub fn validate_entity_mutation( return Err("Shape.style_handle is read-only".into()); } } + (EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(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)) => { @@ -1087,6 +1177,12 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::Dimension(value) = entity { + let name = value.base().style_name.trim(); + if !document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(name)) { + return Err(format!("Dimension style {:?} does not exist", value.base().style_name)); + } + } if let EntityType::MLine(value) = entity { let style = resolve_mline_style(document, value).ok_or_else(|| { format!("MLine style {:?} does not exist", value.style_name) @@ -1443,6 +1539,7 @@ mod tests { | "Hatch" | "Leader" | "MLine" + | "Dimension" ) { assert!( @@ -1597,6 +1694,40 @@ mod tests { "MLine.scale_factor".to_owned(), "MLine.style_name".to_owned(), "MLine.vertices".to_owned(), + "Dimension.text".to_owned(), + "Dimension.style_name".to_owned(), + "Dimension.normal".to_owned(), + "Dimension.text_middle_point".to_owned(), + "Dimension.attachment_point".to_owned(), + "Dimension.text_rotation".to_owned(), + "Dimension.horizontal_direction".to_owned(), + "Dimension.flip_arrow1".to_owned(), + "Dimension.flip_arrow2".to_owned(), + "Dimension.text_user_positioned".to_owned(), + "Dimension.definition_point".to_owned(), + "Dimension.first_point".to_owned(), + "Dimension.second_point".to_owned(), + "Dimension.angle_vertex".to_owned(), + "Dimension.dimension_arc".to_owned(), + "Dimension.feature_location".to_owned(), + "Dimension.leader_endpoint".to_owned(), + "Dimension.first_extension_point".to_owned(), + "Dimension.second_extension_point".to_owned(), + "Dimension.center_point".to_owned(), + "Dimension.first_leader_point".to_owned(), + "Dimension.second_leader_point".to_owned(), + "Dimension.chord_point".to_owned(), + "Dimension.override_center".to_owned(), + "Dimension.jog_point".to_owned(), + "Dimension.rotation".to_owned(), + "Dimension.ext_line_rotation".to_owned(), + "Dimension.leader_length".to_owned(), + "Dimension.arc_start_parameter".to_owned(), + "Dimension.arc_end_parameter".to_owned(), + "Dimension.jog_angle".to_owned(), + "Dimension.is_ordinate_type_x".to_owned(), + "Dimension.is_partial".to_owned(), + "Dimension.has_leader".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 950f6fa49..049a4db14 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -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 **24 of 43** canvas kinds: 23 -are creatable and `Insert` remains update-only. This is **not** 24 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch and MLine pass every completion gate. +v7. The Python feature has explicit writes for **25 of 43** canvas kinds: 24 +are creatable and `Insert` remains update-only. This is **not** 25 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine and Dimension 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 19 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 18 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 **Dimension** (queue item 3; track each subtype separately). Leader is done except for its W +Continue with **MultiLeader** (queue item 4). 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`. @@ -159,3 +159,4 @@ exact queue above and update statuses with evidence after each increment. - `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`. - Completed `MLine`. Host validation and style binding live in `ocs_plugin_api`; derived vertex geometry and element parameters are recomputed by the host (`normalize_scripted_mline`) on scripted create/edit, so scripts supply positions only. The adapter maps `flags` as an integer through a manifest override because the generator cannot classify acadrust's serde-newtype `MLineFlags`. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen. The queue now continues with `Dimension`. +- Completed `Dimension` for all nine subtypes. Because the payload is an enum, the adapter generator gained a `manual_kinds` hook (hand-written `_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`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 93d8b2189..0118f6573 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -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: **24 of 43 canvas kinds have explicit geometry writes** (23 creatable, plus update-only `Insert`), **5 kinds are complete**, and 19 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: **25 of 43 canvas kinds have explicit geometry writes** (24 creatable, plus update-only `Insert`), **6 kinds are complete**, and 18 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. @@ -30,7 +30,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | 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 | **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 | **Complete** | Writable: `vertices` (raw vertex dicts; a script may pass only `position`), `flags` (integer, unknown bits rejected), `justification`, `normal`, `scale_factor`, `style_name`. Read-only: `start_point`, `style_handle`, `style_element_count`. Host validation requires ≥2 finite non-coincident vertices, ≥3 for a closed multiline, a finite nonzero scale and normal, an existing non-empty `MLineStyle`, and per-vertex segments that are either empty or match the style element count. Vertex `direction`/`miter`, segment layout and per-element miter offsets (justification and scale included) are host-derived by `normalize_scripted_mline` on every scripted create or edit, reusing the drawing command's `rebuild_mline_geometry`/`sync_mline_element_parameters`; dash and fill cuts survive an edit unless a vertex moves. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: create against the Standard style, a three-vertex/scale/justification transaction edit with untouched-field preservation, corner miter widening and element offsets checked, real `mline_lines` linework before and after DWG and DXF reopen, nine single-edit rejections plus a cross-entity atomic rollback, re-edit after reopen in both formats, deletion, and three-step undo/redo. Known limit: switching to a style with a different element count is rejected until the script also clears the vertices' `segments`; only the rejection is covered by the integration test. | -| Dimension | Layer-only | Track individual dimension subtypes. | +| 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 | Layer-only | Requires nested context and style fixture. | | Table | Layer-only | Requires table style and cell fixture. | | PolygonMesh | Layer-only | Requires consistent grid/count fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 3dad4fbe7..76adad3b3 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -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 24 geometry converters. Other writable properties +canvas kinds outside its 25 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 24-kind generated schema it contains only handle, kind, and layer; +outside the 25-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 diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index edd6e405b..6bdb400c6 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -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 24 mapped kinds. +the 25 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 diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index 9867780a2..22f77db3f 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -25,6 +25,10 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct Manifest { + /// Kinds whose payload is a nested enum: converted by hand-written + /// `_to_dict`/`_from_dict`/`_apply` functions. + #[serde(default)] + pub manual_kinds: Vec, /// Entity kinds exposed to `ocs.add`/`ocs.update`/`entity_to_dict`. The /// central, easy-to-widen filter for Phase 2 (see the plan doc). pub type_filter: Vec, @@ -164,6 +168,9 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec { let mut queue: Vec = Vec::new(); for kind in &manifest.type_filter { + if manifest.manual_kinds.contains(kind) { + continue; + } let Some(info) = get(registry, kind) else { panic!("entity_manifest.json: unknown entity kind {kind}"); }; @@ -683,6 +690,13 @@ fn resolve_entity_fields( fn gen_entity_to_dict(manifest: &Manifest, registry: &TypeRegistry) -> String { let mut arms = String::new(); for kind in &manifest.type_filter { + if manifest.manual_kinds.contains(kind) { + arms.push_str(&format!( + " acadrust::EntityType::{kind}(value) => {}_to_dict(vm, value),\n", + snake_case(kind) + )); + continue; + } let info = get(registry, kind).unwrap(); let var = "value"; let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); @@ -734,6 +748,13 @@ fn gen_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { if manifest.overrides.get(kind).is_some_and(|override_def| override_def.update_only) { continue; } + if manifest.manual_kinds.contains(kind) { + arms.push_str(&format!( + " \"{kind}\" => acadrust::EntityType::{kind}({}_from_dict(dict, vm)?),\n", + snake_case(kind) + )); + continue; + } let info = get(registry, kind).unwrap(); let var = "value"; let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); @@ -792,6 +813,13 @@ pub(crate) fn dict_to_entity(dict: &rustpython_vm::builtins::PyDictRef, vm: &Vir fn gen_apply_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { let mut arms = String::new(); for kind in &manifest.type_filter { + if manifest.manual_kinds.contains(kind) { + arms.push_str(&format!( + " acadrust::EntityType::{kind}(existing_value) => acadrust::EntityType::{kind}({}_apply(existing_value, dict, vm)?),\n", + snake_case(kind) + )); + continue; + } let info = get(registry, kind).unwrap(); let var = "value"; let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Update); diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 93da7a608..b6e9450f3 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -23,8 +23,10 @@ "AttributeEntity", "Hatch", "Leader", - "MLine" + "MLine", + "Dimension" ], + "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], "overrides": { "Point": { diff --git a/plugins/opencad-python/src/dimension_model.rs.inc b/plugins/opencad-python/src/dimension_model.rs.inc new file mode 100644 index 000000000..bc3f29923 --- /dev/null +++ b/plugins/opencad-python/src/dimension_model.rs.inc @@ -0,0 +1,327 @@ +// Hand-written Dimension model, `include!`d next to the generated CRUD code. +// +// `EntityType::Dimension` wraps a nine-variant enum, so the generator cannot +// map it. Python sees one flat dict: `subtype` (fixed at creation), the +// shared base fields, and the fields of that subtype only. Fields that belong +// to another subtype are rejected rather than ignored. `actual_measurement` +// and the base `definition_point` are host-derived, never scripted. + +enum DimField<'a> { + V(&'a mut Vector3), + F(&'a mut f64), + B(&'a mut bool), +} + +const DIM_SUBTYPES: &[&str] = &[ + "Aligned", "Linear", "Radius", "Diameter", "Angular2Ln", "Angular3Pt", "Ordinate", "Arc", + "LargeRadial", +]; +const DIM_BASE_KEYS: &[&str] = &[ + "text", "style_name", "normal", "text_middle_point", "attachment_point", "text_rotation", + "horizontal_direction", "flip_arrow1", "flip_arrow2", "text_user_positioned", +]; +const DIM_READ_ONLY_KEYS: &[&str] = &["subtype", "actual_measurement"]; + +fn dimension_subtype_name(dim: &acadrust::entities::Dimension) -> &'static str { + use acadrust::entities::Dimension as D; + match dim { + D::Aligned(_) => "Aligned", + D::Linear(_) => "Linear", + D::Radius(_) => "Radius", + D::Diameter(_) => "Diameter", + D::Angular2Ln(_) => "Angular2Ln", + D::Angular3Pt(_) => "Angular3Pt", + D::Ordinate(_) => "Ordinate", + D::Arc(_) => "Arc", + D::LargeRadial(_) => "LargeRadial", + } +} + +/// Subtype-specific fields in stable order. +fn dimension_fields(dim: &mut acadrust::entities::Dimension) -> Vec<(&'static str, DimField<'_>)> { + use acadrust::entities::Dimension as D; + use DimField::{B, F, V}; + match dim { + D::Aligned(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("ext_line_rotation", F(&mut d.ext_line_rotation)), + ], + D::Linear(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("rotation", F(&mut d.rotation)), + ("ext_line_rotation", F(&mut d.ext_line_rotation)), + ], + D::Radius(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ("leader_length", F(&mut d.leader_length)), + ], + D::Diameter(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ("leader_length", F(&mut d.leader_length)), + ], + D::Angular2Ln(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("dimension_arc", V(&mut d.dimension_arc)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ], + D::Angular3Pt(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ], + D::Ordinate(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("feature_location", V(&mut d.feature_location)), + ("leader_endpoint", V(&mut d.leader_endpoint)), + ("is_ordinate_type_x", B(&mut d.is_ordinate_type_x)), + ], + D::Arc(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_extension_point", V(&mut d.first_extension_point)), + ("second_extension_point", V(&mut d.second_extension_point)), + ("center_point", V(&mut d.center_point)), + ("is_partial", B(&mut d.is_partial)), + ("arc_start_parameter", F(&mut d.arc_start_parameter)), + ("arc_end_parameter", F(&mut d.arc_end_parameter)), + ("has_leader", B(&mut d.has_leader)), + ("first_leader_point", V(&mut d.first_leader_point)), + ("second_leader_point", V(&mut d.second_leader_point)), + ], + D::LargeRadial(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("chord_point", V(&mut d.chord_point)), + ("jog_angle", F(&mut d.jog_angle)), + ("override_center", V(&mut d.override_center)), + ("jog_point", V(&mut d.jog_point)), + ], + } +} + +/// Fields `ocs.add()` must receive (the geometry that defines the dimension). +fn dimension_required(subtype: &str) -> &'static [&'static str] { + match subtype { + "Aligned" | "Linear" => &["definition_point", "first_point", "second_point"], + "Radius" | "Diameter" => &["definition_point", "angle_vertex"], + "Angular2Ln" => &["definition_point", "dimension_arc", "first_point", "second_point", "angle_vertex"], + "Angular3Pt" => &["definition_point", "first_point", "second_point", "angle_vertex"], + "Ordinate" => &["feature_location", "leader_endpoint"], + "Arc" => &["definition_point", "first_extension_point", "second_extension_point", "center_point"], + "LargeRadial" => &["definition_point", "chord_point"], + _ => &[], + } +} + +fn dimension_base_mut(dim: &mut acadrust::entities::Dimension) -> &mut acadrust::entities::DimensionBase { + dim.base_mut() +} + +fn dim_attachment_point_name(value: acadrust::entities::AttachmentPointType) -> &'static str { + use acadrust::entities::AttachmentPointType as A; + match value { + A::TopLeft => "TopLeft", + A::TopCenter => "TopCenter", + A::TopRight => "TopRight", + A::MiddleLeft => "MiddleLeft", + A::MiddleCenter => "MiddleCenter", + A::MiddleRight => "MiddleRight", + A::BottomLeft => "BottomLeft", + A::BottomCenter => "BottomCenter", + A::BottomRight => "BottomRight", + } +} + +fn parse_dim_attachment_point( + value: &str, + vm: &VirtualMachine, +) -> PyResult { + use acadrust::entities::AttachmentPointType as A; + Ok(match value { + "TopLeft" => A::TopLeft, + "TopCenter" => A::TopCenter, + "TopRight" => A::TopRight, + "MiddleLeft" => A::MiddleLeft, + "MiddleCenter" => A::MiddleCenter, + "MiddleRight" => A::MiddleRight, + "BottomLeft" => A::BottomLeft, + "BottomCenter" => A::BottomCenter, + "BottomRight" => A::BottomRight, + other => { + return Err(vm.new_value_error(format!("ocs: unsupported AttachmentPointType value: {other}"))) + } + }) +} + +fn dimension_to_dict( + vm: &VirtualMachine, + dimension: &acadrust::entities::Dimension, +) -> PyResult { + let mut dim = dimension.clone(); + let dict = vm.ctx.new_dict(); + let base = dimension.base(); + dict.set_item("kind", vm.new_pyobj("Dimension"), vm)?; + dict.set_item("handle", vm.new_pyobj(base.common.handle.value()), vm)?; + dict.set_item("layer", vm.new_pyobj(base.common.layer.clone()), vm)?; + dict.set_item("owner_handle", vm.new_pyobj(base.common.owner_handle.value()), vm)?; + dict.set_item("subtype", vm.new_pyobj(dimension_subtype_name(dimension)), vm)?; + dict.set_item("actual_measurement", vm.new_pyobj(base.actual_measurement), vm)?; + dict.set_item("text", vm.new_pyobj(base.text.clone()), vm)?; + dict.set_item("style_name", vm.new_pyobj(base.style_name.clone()), vm)?; + dict.set_item("normal", vector3_to_py_dict(vm, &base.normal)?, vm)?; + dict.set_item("text_middle_point", vector3_to_py_dict(vm, &base.text_middle_point)?, vm)?; + dict.set_item("attachment_point", vm.new_pyobj(dim_attachment_point_name(base.attachment_point)), vm)?; + dict.set_item("text_rotation", vm.new_pyobj(base.text_rotation), vm)?; + dict.set_item("horizontal_direction", vm.new_pyobj(base.horizontal_direction), vm)?; + dict.set_item("flip_arrow1", vm.new_pyobj(base.flip_arrow1), vm)?; + dict.set_item("flip_arrow2", vm.new_pyobj(base.flip_arrow2), vm)?; + dict.set_item("text_user_positioned", vm.new_pyobj(base.text_user_positioned), vm)?; + for (name, field) in dimension_fields(&mut dim) { + let value: PyObjectRef = match field { + DimField::V(v) => vector3_to_py_dict(vm, v)?, + DimField::F(v) => vm.new_pyobj(*v), + DimField::B(v) => vm.new_pyobj(*v), + }; + dict.set_item(name, value, vm)?; + } + Ok(dict.into()) +} + +/// Merge `dict` onto `dim`. With `creating`, geometry the subtype needs must +/// be present so a script cannot silently default a point to the origin. +fn dimension_merge( + dim: &mut acadrust::entities::Dimension, + dict: &rustpython_vm::builtins::PyDictRef, + creating: bool, + vm: &VirtualMachine, +) -> PyResult<()> { + let subtype = dimension_subtype_name(dim); + let applicable: Vec<&'static str> = + dimension_fields(&mut dim.clone()).into_iter().map(|(name, _)| name).collect(); + for key in dict.keys_vec() { + let key = key.try_into_value::(vm)?; + let key = key.as_str(); + if matches!(key, "kind" | "handle" | "layer" | "owner_handle") || DIM_BASE_KEYS.contains(&key) { + continue; + } + if key == "subtype" { + let value = dict.get_item("subtype", vm)?.try_into_value::(vm)?; + if value != subtype { + return Err(vm.new_value_error("Dimension.subtype is read-only".to_owned())); + } + continue; + } + if key == "actual_measurement" { + return Err(vm.new_value_error("Dimension.actual_measurement is read-only".to_owned())); + } + if !applicable.contains(&key) { + let known = [ + "definition_point", "first_point", "second_point", "angle_vertex", "dimension_arc", + "feature_location", "leader_endpoint", "first_extension_point", "second_extension_point", + "center_point", "first_leader_point", "second_leader_point", "chord_point", + "override_center", "jog_point", "rotation", "ext_line_rotation", "leader_length", + "arc_start_parameter", "arc_end_parameter", "jog_angle", "is_ordinate_type_x", + "is_partial", "has_leader", + ]; + return Err(vm.new_value_error(if known.contains(&key) { + format!("Dimension.{key} does not apply to the {subtype} subtype") + } else { + format!("ocs: Dimension property {key:?} is outside the editable schema") + })); + } + } + if creating { + for name in dimension_required(subtype) { + if dict.get_item_opt(*name, vm)?.is_none() { + return Err(vm.new_value_error(format!("ocs.add: Dimension {subtype} requires {name}"))); + } + } + } + for (name, field) in dimension_fields(dim) { + let Some(value) = dict.get_item_opt(name, vm)? else { continue }; + match field { + DimField::V(target) => *target = py_to_vector3_dict(value, vm)?, + DimField::F(target) => *target = py_number_to_f64(value, vm)?, + DimField::B(target) => *target = value.try_into_value::(vm)?, + } + } + let base = dimension_base_mut(dim); + if let Some(v) = dict.get_item_opt("text", vm)? { base.text = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("style_name", vm)? { base.style_name = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("normal", vm)? { base.normal = py_to_vector3_dict(v, vm)?; } + if let Some(v) = dict.get_item_opt("text_middle_point", vm)? { base.text_middle_point = py_to_vector3_dict(v, vm)?; } + if let Some(v) = dict.get_item_opt("attachment_point", vm)? { + base.attachment_point = parse_dim_attachment_point(&v.try_into_value::(vm)?, vm)?; + } + if let Some(v) = dict.get_item_opt("text_rotation", vm)? { base.text_rotation = py_number_to_f64(v, vm)?; } + if let Some(v) = dict.get_item_opt("horizontal_direction", vm)? { base.horizontal_direction = py_number_to_f64(v, vm)?; } + if let Some(v) = dict.get_item_opt("flip_arrow1", vm)? { base.flip_arrow1 = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("flip_arrow2", vm)? { base.flip_arrow2 = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("text_user_positioned", vm)? { base.text_user_positioned = v.try_into_value::(vm)?; } + Ok(()) +} + +fn dimension_from_dict( + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, +) -> PyResult { + use acadrust::entities::*; + let subtype = match dict.get_item_opt("subtype", vm)? { + Some(v) => v.try_into_value::(vm)?, + None => { + return Err(vm.new_value_error(format!( + "ocs.add: Dimension requires subtype, one of {DIM_SUBTYPES:?}" + ))) + } + }; + let mut dim = match subtype.as_str() { + "Aligned" => Dimension::Aligned(DimensionAligned::default()), + "Linear" => Dimension::Linear(DimensionLinear::default()), + "Radius" => Dimension::Radius(DimensionRadius::default()), + "Diameter" => Dimension::Diameter(DimensionDiameter::default()), + "Angular2Ln" => Dimension::Angular2Ln(DimensionAngular2Ln::default()), + "Angular3Pt" => Dimension::Angular3Pt(DimensionAngular3Pt::default()), + "Ordinate" => Dimension::Ordinate(DimensionOrdinate::default()), + "Arc" => Dimension::Arc(DimensionArc::default()), + "LargeRadial" => Dimension::LargeRadial(DimensionLargeRadial::default()), + other => { + return Err(vm.new_value_error(format!( + "ocs.add: unsupported Dimension subtype {other:?}, expected one of {DIM_SUBTYPES:?}" + ))) + } + }; + dimension_merge(&mut dim, dict, true, vm)?; + let base = dimension_base_mut(&mut dim); + base.common.handle = acadrust::Handle::new(get_opt_u64(dict, "handle", vm)?); + base.common.layer = get_opt_string(dict, "layer", "0", vm)?; + base.common.owner_handle = acadrust::Handle::new(get_opt_u64(dict, "owner_handle", vm)?); + Ok(dim) +} + +fn dimension_apply( + existing: &acadrust::entities::Dimension, + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, +) -> PyResult { + let mut dim = existing.clone(); + dimension_merge(&mut dim, dict, false, vm)?; + let base = dimension_base_mut(&mut dim); + if let Some(v) = dict.get_item_opt("layer", vm)? { + if !vm.is_none(&v) { + base.common.layer = v.try_into_value::(vm)?; + } + } + if let Some(owner) = dict.get_item_opt("owner_handle", vm)? { + if owner.try_into_value::(vm)? != base.common.owner_handle.value() { + return Err(vm.new_value_error("owner_handle is read-only after creation".to_owned())); + } + } + Ok(dim) +} diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index a1e3c1448..290c8da6e 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -3127,10 +3127,15 @@ mod ocs { match dict.get_item_opt(key, vm)? { None => Ok(0.0), Some(v) if vm.is_none(&v) => Ok(0.0), - Some(v) => v.try_into_value::(vm), + Some(v) => py_number_to_f64(v, vm), } } + /// Accept a Python int or float; `try_into_value::` rejects ints. + fn py_number_to_f64(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + Ok(value.try_float(vm)?.to_f64()) + } + fn get_opt_bool( dict: &rustpython_vm::builtins::PyDictRef, key: &str, @@ -3186,6 +3191,7 @@ mod ocs { } include!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); + include!("dimension_model.rs.inc"); // ════════════════════════════════════════════════════════════════════ // Phase 1 — wiring the generic conversion functions above into `ocs`. @@ -4640,6 +4646,23 @@ mod tests { current = None; } } + // Hand-written kinds: keys come from the include file's dict writes + // and per-subtype field table. + let manual = include_str!("dimension_model.rs.inc"); + let dimension_keys = emitted.entry("Dimension".to_owned()).or_default(); + for line in manual.lines() { + let line = line.trim(); + let key = line + .strip_prefix("dict.set_item(\"") + .or_else(|| { + (line.contains("\", V(") || line.contains("\", F(") || line.contains("\", B(")) + .then(|| line.strip_prefix("(\"")) + .flatten() + }); + if let Some(key) = key { + dimension_keys.insert(key.split('"').next().unwrap().to_owned()); + } + } for entry in &catalog.entity_kinds { let expected: BTreeSet<_> = entry .properties @@ -4663,7 +4686,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 24); + assert_eq!(emitted.len(), 25); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 67c7fedce..0c96af428 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -155,6 +155,9 @@ impl<'a> HostSession<'a> { old: Option<&EntityType>, entity: &mut EntityType, ) -> Result<(), String> { + if let EntityType::Dimension(dimension) = entity { + crate::entities::dimension::normalize_scripted_dimension(dimension); + } if let EntityType::MLine(new) = entity { let old = match old { Some(EntityType::MLine(old)) => Some(old), @@ -2572,6 +2575,209 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + #[test] + fn staged_python_dimension_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(); + + // (subtype, python fields, expected measurement) + let fixtures: [(&str, &str, f64); 9] = [ + ("Linear", "definition_point=P(10,5), first_point=P(0,0), second_point=P(10,0)", 10.0), + ("Aligned", "definition_point=P(1,5.5), first_point=P(0,0), second_point=P(3,4)", 5.0), + ("Radius", "definition_point=P(5,0), angle_vertex=P(0,0)", 5.0), + ("Diameter", "definition_point=P(10,0), angle_vertex=P(0,0)", 10.0), + ("Angular3Pt", "definition_point=P(3,3), first_point=P(5,0), second_point=P(0,5), angle_vertex=P(0,0)", 90.0), + ("Angular2Ln", "definition_point=P(0,5), dimension_arc=P(3,3), first_point=P(0,0), second_point=P(5,0), angle_vertex=P(0,0)", 90.0), + ("Ordinate", "feature_location=P(5,3), leader_endpoint=P(5,8), is_ordinate_type_x=True", 5.0), + ("Arc", "definition_point=P(3.5,3.5), first_extension_point=P(5,0), second_extension_point=P(0,5), center_point=P(0,0), arc_start_parameter=0.0, arc_end_parameter=1.5707963267948966", 5.0 * std::f64::consts::FRAC_PI_2), + ("LargeRadial", "definition_point=P(10,0), chord_point=P(4,0)", 6.0), + ]; + let mut script = String::from("def P(x, y): return {'x': x, 'y': y, 'z': 0.0}\ndoc = ocs.active_document\n"); + for (subtype, fields, _) in &fixtures { + script.push_str(&format!("doc.create_entity('Dimension', subtype='{subtype}', {fields})\n")); + } + let path = std::env::temp_dir().join(format!("ocs_dimension_create_{}.py", std::process::id())); + std::fs::write(&path, script).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", path.display())); + let _ = std::fs::remove_file(&path); + let subtype_of = |dimension: &acadrust::entities::Dimension| -> &'static str { + use acadrust::entities::Dimension as D; + match dimension { + D::Aligned(_) => "Aligned", D::Linear(_) => "Linear", D::Radius(_) => "Radius", + D::Diameter(_) => "Diameter", D::Angular2Ln(_) => "Angular2Ln", + D::Angular3Pt(_) => "Angular3Pt", D::Ordinate(_) => "Ordinate", + D::Arc(_) => "Arc", D::LargeRadial(_) => "LargeRadial", + } + }; + let handles_by_subtype = |document: &CadDocument| { + let mut map = std::collections::BTreeMap::new(); + for entity in document.entities() { + if let EntityType::Dimension(dimension) = entity { + map.insert(subtype_of(dimension), dimension.base().common.handle); + } + } + map + }; + let handles = handles_by_subtype(host.document()); + assert_eq!(handles.len(), 9, "created: {handles:?}; {}", last_output(&host)); + let measured = |document: &CadDocument, handle: Handle| -> f64 { + let Some(EntityType::Dimension(dimension)) = document.get_entity(handle) else { + panic!("Dimension missing"); + }; + dimension.base().actual_measurement + }; + for (subtype, _, expected) in &fixtures { + let value = measured(host.document(), handles[subtype]); + assert!((value - expected).abs() < 1e-6, "{subtype}: measured {value}, expected {expected}"); + let Some(EntityType::Dimension(dimension)) = host.document().get_entity(handles[subtype]) else { unreachable!() }; + assert_eq!(dimension.measurement(), value, "{subtype}: stored measurement is derived"); + let wires = host.app.tabs[0].scene.wire_models_for(&[handles[subtype]]); + assert!(!wires.is_empty(), "{subtype}: dimension produced no canvas geometry"); + } + let linear = handles["Linear"]; + let created_linear = host.document().get_entity(linear).unwrap().clone(); + + // Edit: move a defining point (the measurement follows), change text + // and style-independent placement; untouched fields survive. + let script = std::env::temp_dir().join(format!("ocs_dimension_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "linear = doc.entities[{}]\n", + "radius = doc.entities[{}]\n", + "with doc.transaction('Edit dimensions'):\n", + " linear.second_point = (20.0, 0.0, 0.0)\n", + " linear.text = '<> mm'\n", + " linear.attachment_point = 'TopCenter'\n", + " radius.definition_point = (8.0, 0.0, 0.0)\n", + "doc.selection = [linear]\n" + ), linear.value(), handles["Radius"].value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected_linear = host.document().get_entity(linear).unwrap().clone(); + let EntityType::Dimension(edited) = &expected_linear else { unreachable!() }; + let EntityType::Dimension(before) = &created_linear else { unreachable!() }; + assert!((edited.base().actual_measurement - 20.0).abs() < 1e-9, "edit failed: {}", last_output(&host)); + assert!((measured(host.document(), handles["Radius"]) - 8.0).abs() < 1e-9); + assert_eq!(edited.base().text, "<> mm"); + assert_eq!(edited.base().attachment_point, acadrust::entities::AttachmentPointType::TopCenter); + assert_eq!(edited.base().common, before.base().common); + assert_eq!(edited.base().style_name, before.base().style_name); + assert_eq!(edited.base().normal, before.base().normal); + assert_eq!(host.selection(), vec![linear]); + let expected_radius = host.document().get_entity(handles["Radius"]).unwrap().clone(); + assert!(!host.app.tabs[0].scene.wire_models_for(&[linear]).is_empty()); + + let reject_cases: [(&str, &str, &str); 9] = [ + ("Linear", "'leader_length':2.0", "does not apply to the Linear subtype"), + ("Linear", "'subtype':'Radius'", "read-only"), + ("Linear", "'actual_measurement':5.0", "read-only"), + ("Linear", "'style_name':'Missing'", "does not exist"), + ("Linear", "'first_point':{'x':20.0,'y':0.0,'z':0.0}", "must not coincide"), + ("Linear", "'first_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("Linear", "'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("Radius", "'angle_vertex':{'x':8.0,'y':0.0,'z':0.0}", "must not coincide"), + ("Linear", "'attachment_point':'Nowhere'", "unsupported AttachmentPointType"), + ]; + for (subtype, patch, message) in reject_cases { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject dimension', [{{'handle':{}, {patch}}}])", + handles[subtype].value())); + assert_eq!(host.document().get_entity(linear), Some(&expected_linear), "{patch}"); + assert_eq!(host.document().get_entity(handles["Radius"]), Some(&expected_radius), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'text':'changed'}},", + "{{'handle':{},'style_name':'Missing'}}])" + ), linear.value(), handles["Radius"].value())); + assert_eq!(host.document().get_entity(linear), Some(&expected_linear)); + for (patch, message) in [ + ("ocs.active_document.create_entity('Dimension', definition_point=P(0,0))", "requires subtype"), + ("ocs.active_document.create_entity('Dimension', subtype='Cone')", "unsupported Dimension subtype"), + ("ocs.active_document.create_entity('Dimension', subtype='Linear', first_point=P(0,0), second_point=P(1,0))", "requires definition_point"), + ] { + dispatch(&mut host, &format!("PY_EVAL (lambda P: {patch})(lambda x, y: {{'x': x, 'y': y, 'z': 0.0}})")); + assert_eq!(handles_by_subtype(host.document()).len(), 9, "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + + // DWG and DXF: every subtype is recorded separately. + let mut failures = Vec::new(); + let dwg = crate::io::load_bytes("dimension.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("dimension.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let reopened = handles_by_subtype(document); + for (subtype, _, expected) in &fixtures { + let expected = match *subtype { "Linear" => 20.0, "Radius" => 8.0, _ => *expected }; + match reopened.get(subtype) { + None => failures.push(format!("{format} {subtype}: missing")), + Some(handle) => { + let value = measured(document, *handle); + if (value - expected).abs() > 1e-6 { + failures.push(format!("{format} {subtype}: measurement {value} != {expected}")); + } + } + } + } + let Some(EntityType::Dimension(dimension)) = document.get_entity(linear) else { + failures.push(format!("{format} Linear handle lost")); + continue; + }; + if dimension.base().text != "<> mm" { failures.push(format!("{format} Linear text {:?}", dimension.base().text)); } + } + assert!(failures.is_empty(), "persistence gaps: {failures:#?}"); + for (name, mut document) in [("dimension-reedit.dwg", dwg), ("dimension-reedit.dxf", dxf)] { + let before = document.get_entity(linear).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Dimension(dimension) = &mut after else { unreachable!() }; + if let acadrust::entities::Dimension::Linear(value) = dimension { value.second_point.x = 30.0; } + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + let EntityType::Dimension(dimension) = &mut after else { unreachable!() }; + crate::entities::dimension::normalize_scripted_dimension(dimension); + *document.get_entity_mut(linear).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!((measured(&reopened, linear) - 30.0).abs() < 1e-6, "{name}"); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", linear.value())); + assert!(host.document().get_entity(linear).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(linear), Some(&expected_linear)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(linear), Some(&created_linear)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(linear).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(linear).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 { diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 6cf37e0a5..638a21179 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -25,6 +25,14 @@ pub(crate) fn set_dimension_text_override(base: &mut DimensionBase, text: Option base.user_text = text; } +/// Recompute the fields a script cannot set: the base definition point and +/// the stored measurement, exactly as grip edits do. +pub(crate) fn normalize_scripted_dimension(dim: &mut Dimension) { + let definition_point = dimension_definition_point(dim); + dim.base_mut().definition_point = definition_point; + dim.base_mut().actual_measurement = dim.measurement(); +} + fn dimension_definition_point(dim: &Dimension) -> acadrust::types::Vector3 { match dim { Dimension::Aligned(d) => d.definition_point,