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 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 14:15:03 +02:00
1 parent a86d5bc727
commit 6b4c86fd7a
13 files changed
+1281 -49

No files matched your search

+24
View File
@@ -21,6 +21,13 @@ use entity_coverage_schema::{
EntityCoverageCatalog, EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage, EntityCoverageCatalog, EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage,
}; };
#[derive(serde::Deserialize)]
struct SyntheticProperty {
name: String,
type_id: String,
access: String,
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct EntityCoveragePolicy { struct EntityCoveragePolicy {
internal_kinds: Vec<String>, internal_kinds: Vec<String>,
@@ -28,6 +35,10 @@ struct EntityCoveragePolicy {
editable: BTreeMap<String, Vec<String>>, editable: BTreeMap<String, Vec<String>>,
readable: BTreeMap<String, Vec<String>>, readable: BTreeMap<String, Vec<String>>,
aliases: BTreeMap<String, BTreeMap<String, String>>, aliases: BTreeMap<String, BTreeMap<String, String>>,
/// 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<String, Vec<SyntheticProperty>>,
} }
fn main() { fn main() {
@@ -802,6 +813,19 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
model_access: access, 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!("<subtype>.{}", 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!( assert!(
unresolved.is_empty(), unresolved.is_empty(),
"{kind} has coverage keys absent from registry: {unresolved:?}" "{kind} has coverage keys absent from registry: {unresolved:?}"
+518 -36
View File
@@ -1,44 +1,526 @@
{ {
"internal_kinds": ["Block", "BlockEnd", "Seqend"], "internal_kinds": [
"opaque_kinds": ["Extended", "Unknown"], "Block",
"BlockEnd",
"Seqend"
],
"opaque_kinds": [
"Extended",
"Unknown"
],
"editable": { "editable": {
"Point": ["layer", "location", "thickness", "normal", "x_axis_angle"], "Point": [
"Line": ["layer", "start", "end", "thickness", "normal"], "layer",
"Circle": ["layer", "center", "radius", "thickness", "normal"], "location",
"Arc": ["layer", "center", "radius", "start_angle", "end_angle", "thickness", "normal"], "thickness",
"Ellipse": ["layer", "center", "major_axis", "minor_axis_ratio", "start_parameter", "end_parameter", "normal"], "normal",
"Polyline": ["layer", "closed", "vertices"], "x_axis_angle"
"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"], "Line": [
"LwPolyline": ["layer", "vertices", "is_closed", "plinegen", "constant_width", "elevation", "thickness", "normal"], "layer",
"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"], "start",
"Text": ["layer", "text", "insertion", "alignment_point", "height", "rotation", "width_factor", "oblique_angle", "style", "horizontal_alignment", "vertical_alignment", "normal", "thickness", "generation_flags"], "end",
"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"], "thickness",
"Ray": ["layer", "base_point", "direction"], "normal"
"XLine": ["layer", "base_point", "direction"], ],
"Solid": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "normal", "thickness", "is_trace"], "Circle": [
"Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"], "layer",
"Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"], "center",
"Tolerance": ["layer", "insertion_point", "direction", "normal", "text", "dimension_style_name", "text_height", "dimension_gap"], "radius",
"Shape": ["layer", "insertion_point", "size", "shape_name", "shape_number", "rotation", "relative_x_scale", "oblique_angle", "normal", "thickness", "style_name"], "thickness",
"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"], "normal"
"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"], "Arc": [
"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"], "layer",
"MLine": ["layer", "flags", "justification", "normal", "scale_factor", "style_name", "vertices"] "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": { "readable": {
"Insert": ["block_name"], "Insert": [
"Tolerance": ["dimension_style_handle"], "block_name"
"Shape": ["style_handle"], ],
"MLine": ["start_point", "style_handle", "style_element_count"], "Tolerance": [
"Leader": ["origin", "dimension_gap", "arrowhead_type", "arrow_size", "byblock_color"], "dimension_style_handle"
"AttributeEntity": ["attdef_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": { "aliases": {
"Polyline": {"closed": "flags"}, "Polyline": {
"Polyline2D": {"closed": "flags"}, "closed": "flags"
"Text": {"text": "value", "insertion": "insertion_point"}, },
"MText": {"text": "value", "insertion": "insertion_point"} "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"
}
]
} }
} }
@@ -377,6 +377,94 @@ fn validate_mline(
Ok(()) 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 /// Validate newly created geometry for the kinds whose mapped fields have
/// host checks. Called by the Python add path before an entity enters the document. /// host checks. Called by the Python add path before an entity enters the document.
#[cfg(feature = "host")] #[cfg(feature = "host")]
@@ -470,6 +558,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<()
} }
EntityType::Leader(value) => validate_leader(None, value)?, EntityType::Leader(value) => validate_leader(None, value)?,
EntityType::MLine(value) => validate_mline(None, value)?, EntityType::MLine(value) => validate_mline(None, value)?,
EntityType::Dimension(value) => validate_dimension(None, value)?,
EntityType::AttributeDefinition(value) => { EntityType::AttributeDefinition(value) => {
finite_vector( finite_vector(
"AttributeDefinition.insertion_point", "AttributeDefinition.insertion_point",
@@ -824,6 +913,7 @@ pub fn validate_entity_mutation(
return Err("Shape.style_handle is read-only".into()); 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::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?,
(EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?,
(EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => {
@@ -1087,6 +1177,12 @@ pub fn validate_canvas_entity_references(
return Err(format!("Shape text style {:?} has no SHX file", style.name)); 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 { if let EntityType::MLine(value) = entity {
let style = resolve_mline_style(document, value).ok_or_else(|| { let style = resolve_mline_style(document, value).ok_or_else(|| {
format!("MLine style {:?} does not exist", value.style_name) format!("MLine style {:?} does not exist", value.style_name)
@@ -1443,6 +1539,7 @@ mod tests {
| "Hatch" | "Hatch"
| "Leader" | "Leader"
| "MLine" | "MLine"
| "Dimension"
) )
{ {
assert!( assert!(
@@ -1597,6 +1694,40 @@ mod tests {
"MLine.scale_factor".to_owned(), "MLine.scale_factor".to_owned(),
"MLine.style_name".to_owned(), "MLine.style_name".to_owned(),
"MLine.vertices".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(),
]) ])
); );
} }
+6 -5
View File
@@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas
As of 20 September 2026, the working branch is `plugin/host-model-api` at As of 20 September 2026, the working branch is `plugin/host-model-api` at
`a8dd3640` plus the Leader increment, published as draft PR `a8dd3640` plus the Leader increment, published as draft PR
[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is
v7. The Python feature has explicit writes for **24 of 43** canvas kinds: 23 v7. The Python feature has explicit writes for **25 of 43** canvas kinds: 24
are creatable and `Insert` remains update-only. This is **not** 24 completed are creatable and `Insert` remains update-only. This is **not** 25 completed
kinds: Tolerance, Shape, AttributeEntity, Hatch and MLine pass every completion gate. kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine and Dimension pass every completion gate.
AttributeDefinition passes its host and real IPC lifecycle but remains short of AttributeDefinition passes its host and real IPC lifecycle but remains short of
`Complete` because the previously pinned CAD codec dropped optional ATTDEF `Complete` because the previously pinned CAD codec dropped optional ATTDEF
fields during DXF reads; revalidate that blocker against the current fields during DXF reads; revalidate that blocker against the current
`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 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 writes through the document model. All 43 have raw typed snapshots. There are
three internal records and two opaque fallbacks outside the 43 canvas kinds. three internal records and two opaque fallbacks outside the 43 canvas kinds.
@@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver
## Handoff start point ## Handoff start point
Continue with **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 gate; when the engine writes DXF group 340, reads group 213 and the DWG
R2010+ text-size question is decided, flip the canary assertions in R2010+ text-size question is decided, flip the canary assertions in
`staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`.
@@ -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`. - `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`. - 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 `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 `<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`.
+2 -2
View File
@@ -1,6 +1,6 @@
# OCS canvas kind coverage ledger # OCS canvas kind coverage ledger
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **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. 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. | | 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. | | 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. | | 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. | | MultiLeader | Layer-only | Requires nested context and style fixture. |
| Table | Layer-only | Requires table style and cell fixture. | | Table | Layer-only | Requires table style and cell fixture. |
| PolygonMesh | Layer-only | Requires consistent grid/count fixture. | | PolygonMesh | Layer-only | Requires consistent grid/count fixture. |
+2 -2
View File
@@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet
catalog. All 43 canvas kinds also support nonempty `layer` changes through catalog. All 43 canvas kinds also support nonempty `layer` changes through
the same undoable transaction; internal and opaque variants remain read-only the same undoable transaction; internal and opaque variants remain read-only
through the document model. The adapter accepts only `layer` patches for through the document model. The adapter accepts only `layer` patches for
canvas kinds outside its 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 still report `type_conversion_only`; broader CAD range and cross-property
checks remain outstanding. checks remain outstanding.
The host's `entity_snapshot` helper serializes any typed entity as a detached The host's `entity_snapshot` helper serializes any typed entity as a detached
@@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a
token from another tab returns no result and does not consume it. token from another tab returns no result and does not consume it.
Python `doc.entities[handle]` returns a descriptor for every entity. For kinds Python `doc.entities[handle]` returns a descriptor for every entity. For kinds
outside the 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 `layer` is editable on canvas kinds. `doc.coverage()` enumerates the
catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For
covered kinds, the coverage object lists actual readable and editable covered kinds, the coverage object lists actual readable and editable
+1 -1
View File
@@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every
traced field, including fields marked `unmapped`. Query it with traced field, including fields marked `unmapped`. Query it with
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field, `doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
type, access, and current validation status. Geometry edits remain limited to type, access, and current validation status. Geometry edits remain limited to
the 24 mapped kinds. the 25 mapped kinds.
For any entity, `entity.snapshot` returns a detached dictionary of its raw For any entity, `entity.snapshot` returns a detached dictionary of its raw
CAD fields, including `snapshot["common"]` and kind-specific nested values. CAD fields, including `snapshot["common"]` and kind-specific nested values.
It can inspect an unmapped kind; editing the dictionary does It can inspect an unmapped kind; editing the dictionary does
+28
View File
@@ -25,6 +25,10 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Manifest { pub struct Manifest {
/// Kinds whose payload is a nested enum: converted by hand-written
/// `<snake>_to_dict`/`<snake>_from_dict`/`<snake>_apply` functions.
#[serde(default)]
pub manual_kinds: Vec<String>,
/// Entity kinds exposed to `ocs.add`/`ocs.update`/`entity_to_dict`. The /// Entity kinds exposed to `ocs.add`/`ocs.update`/`entity_to_dict`. The
/// central, easy-to-widen filter for Phase 2 (see the plan doc). /// central, easy-to-widen filter for Phase 2 (see the plan doc).
pub type_filter: Vec<String>, pub type_filter: Vec<String>,
@@ -164,6 +168,9 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec<String> {
let mut queue: Vec<String> = Vec::new(); let mut queue: Vec<String> = Vec::new();
for kind in &manifest.type_filter { for kind in &manifest.type_filter {
if manifest.manual_kinds.contains(kind) {
continue;
}
let Some(info) = get(registry, kind) else { let Some(info) = get(registry, kind) else {
panic!("entity_manifest.json: unknown entity kind {kind}"); 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 { fn gen_entity_to_dict(manifest: &Manifest, registry: &TypeRegistry) -> String {
let mut arms = String::new(); let mut arms = String::new();
for kind in &manifest.type_filter { 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 info = get(registry, kind).unwrap();
let var = "value"; let var = "value";
let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); 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) { if manifest.overrides.get(kind).is_some_and(|override_def| override_def.update_only) {
continue; 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 info = get(registry, kind).unwrap();
let var = "value"; let var = "value";
let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); 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 { fn gen_apply_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String {
let mut arms = String::new(); let mut arms = String::new();
for kind in &manifest.type_filter { 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 info = get(registry, kind).unwrap();
let var = "value"; let var = "value";
let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Update); let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Update);
+3 -1
View File
@@ -23,8 +23,10 @@
"AttributeEntity", "AttributeEntity",
"Hatch", "Hatch",
"Leader", "Leader",
"MLine" "MLine",
"Dimension"
], ],
"manual_kinds": ["Dimension"],
"base_fields": ["handle", "layer", "owner_handle"], "base_fields": ["handle", "layer", "owner_handle"],
"overrides": { "overrides": {
"Point": { "Point": {
@@ -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<acadrust::entities::AttachmentPointType> {
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<PyObjectRef> {
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::<String>(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::<String>(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::<bool>(vm)?,
}
}
let base = dimension_base_mut(dim);
if let Some(v) = dict.get_item_opt("text", vm)? { base.text = v.try_into_value::<String>(vm)?; }
if let Some(v) = dict.get_item_opt("style_name", vm)? { base.style_name = v.try_into_value::<String>(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::<String>(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::<bool>(vm)?; }
if let Some(v) = dict.get_item_opt("flip_arrow2", vm)? { base.flip_arrow2 = v.try_into_value::<bool>(vm)?; }
if let Some(v) = dict.get_item_opt("text_user_positioned", vm)? { base.text_user_positioned = v.try_into_value::<bool>(vm)?; }
Ok(())
}
fn dimension_from_dict(
dict: &rustpython_vm::builtins::PyDictRef,
vm: &VirtualMachine,
) -> PyResult<acadrust::entities::Dimension> {
use acadrust::entities::*;
let subtype = match dict.get_item_opt("subtype", vm)? {
Some(v) => v.try_into_value::<String>(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<acadrust::entities::Dimension> {
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::<String>(vm)?;
}
}
if let Some(owner) = dict.get_item_opt("owner_handle", vm)? {
if owner.try_into_value::<u64>(vm)? != base.common.owner_handle.value() {
return Err(vm.new_value_error("owner_handle is read-only after creation".to_owned()));
}
}
Ok(dim)
}
+25 -2
View File
@@ -3127,10 +3127,15 @@ mod ocs {
match dict.get_item_opt(key, vm)? { match dict.get_item_opt(key, vm)? {
None => Ok(0.0), None => Ok(0.0),
Some(v) if vm.is_none(&v) => Ok(0.0), Some(v) if vm.is_none(&v) => Ok(0.0),
Some(v) => v.try_into_value::<f64>(vm), Some(v) => py_number_to_f64(v, vm),
} }
} }
/// Accept a Python int or float; `try_into_value::<f64>` rejects ints.
fn py_number_to_f64(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<f64> {
Ok(value.try_float(vm)?.to_f64())
}
fn get_opt_bool( fn get_opt_bool(
dict: &rustpython_vm::builtins::PyDictRef, dict: &rustpython_vm::builtins::PyDictRef,
key: &str, key: &str,
@@ -3186,6 +3191,7 @@ mod ocs {
} }
include!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); include!(concat!(env!("OUT_DIR"), "/entity_crud.rs"));
include!("dimension_model.rs.inc");
// ════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════
// Phase 1 — wiring the generic conversion functions above into `ocs`. // Phase 1 — wiring the generic conversion functions above into `ocs`.
@@ -4640,6 +4646,23 @@ mod tests {
current = None; 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 { for entry in &catalog.entity_kinds {
let expected: BTreeSet<_> = entry let expected: BTreeSet<_> = entry
.properties .properties
@@ -4663,7 +4686,7 @@ mod tests {
); );
} }
} }
assert_eq!(emitted.len(), 24); assert_eq!(emitted.len(), 25);
} }
#[cfg(feature = "experimental-host-model")] #[cfg(feature = "experimental-host-model")]
+206
View File
@@ -155,6 +155,9 @@ impl<'a> HostSession<'a> {
old: Option<&EntityType>, old: Option<&EntityType>,
entity: &mut EntityType, entity: &mut EntityType,
) -> Result<(), String> { ) -> Result<(), String> {
if let EntityType::Dimension(dimension) = entity {
crate::entities::dimension::normalize_scripted_dimension(dimension);
}
if let EntityType::MLine(new) = entity { if let EntityType::MLine(new) = entity {
let old = match old { let old = match old {
Some(EntityType::MLine(old)) => Some(old), Some(EntityType::MLine(old)) => Some(old),
@@ -2572,6 +2575,209 @@ mod tests {
assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); 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] #[test]
fn staged_python_point_pick_and_cancel_over_real_ipc() { fn staged_python_point_pick_and_cancel_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
+8
View File
@@ -25,6 +25,14 @@ pub(crate) fn set_dimension_text_override(base: &mut DimensionBase, text: Option
base.user_text = text; 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 { fn dimension_definition_point(dim: &Dimension) -> acadrust::types::Vector3 {
match dim { match dim {
Dimension::Aligned(d) => d.definition_point, Dimension::Aligned(d) => d.definition_point,