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 <[email protected]>
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,
};
#[derive(serde::Deserialize)]
struct SyntheticProperty {
name: String,
type_id: String,
access: String,
}
#[derive(serde::Deserialize)]
struct EntityCoveragePolicy {
internal_kinds: Vec<String>,
@@ -28,6 +35,10 @@ struct EntityCoveragePolicy {
editable: BTreeMap<String, Vec<String>>,
readable: BTreeMap<String, Vec<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() {
@@ -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!("<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!(
unresolved.is_empty(),
"{kind} has coverage keys absent from registry: {unresolved:?}"
+518 -36
View File
@@ -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"
}
]
}
}
@@ -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(),
])
);
}
+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
`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 `<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
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. |
+2 -2
View File
@@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet
catalog. All 43 canvas kinds also support nonempty `layer` changes through
the same undoable transaction; internal and opaque variants remain read-only
through the document model. The adapter accepts only `layer` patches for
canvas kinds outside its 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
+1 -1
View File
@@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every
traced field, including fields marked `unmapped`. Query it with
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
type, access, and current validation status. Geometry edits remain limited to
the 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
+28
View File
@@ -25,6 +25,10 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)]
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
/// central, easy-to-widen filter for Phase 2 (see the plan doc).
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();
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);
+3 -1
View File
@@ -23,8 +23,10 @@
"AttributeEntity",
"Hatch",
"Leader",
"MLine"
"MLine",
"Dimension"
],
"manual_kinds": ["Dimension"],
"base_fields": ["handle", "layer", "owner_handle"],
"overrides": {
"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)? {
None => 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(
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")]
+206
View File
@@ -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 {
+8
View File
@@ -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,