Add versioned scripting host model and entity coverage

This commit is contained in:
Felix Riestra committed 2026-09-19 01:13:24 +02:00
1 parent ec2271fc9c
commit 11d5614991
22 files changed
+1015 -4

No files matched your search

+2 -1
View File
@@ -10,6 +10,7 @@ license = "GPL-3.0-only"
# registry types (`type_registry_types.rs`). The host feature no longer needs
# to enable it explicitly.
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine
@@ -30,7 +31,7 @@ getrandom = { version = "0.2", optional = true }
# Enables the runtime host surface (`HostApi` trait) and the out-of-process
# plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers
# leave it off.
host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"]
host = ["dep:acadrust", "dep:serde_json", "dep:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"]
[build-dependencies]
serde-reflection = "0.6"
+112 -1
View File
@@ -14,6 +14,18 @@ use serde_reflection::{
// Include the stable schema types so the same definitions are used at build
// time and at runtime. The file is self-contained and only depends on serde.
include!("src/type_registry_types.rs");
mod entity_coverage_schema {
include!("src/entity_coverage_types.rs");
}
use entity_coverage_schema::{EntityCoverageCatalog, EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage};
#[derive(serde::Deserialize)]
struct EntityCoveragePolicy {
internal_kinds: Vec<String>,
opaque_kinds: Vec<String>,
editable: BTreeMap<String, Vec<String>>,
aliases: BTreeMap<String, BTreeMap<String, String>>,
}
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
@@ -23,6 +35,8 @@ fn main() {
"cargo:rerun-if-changed={}",
workspace_cargo_lock_path().display()
);
println!("cargo:rerun-if-changed=entity_coverage_policy.json");
println!("cargo:rerun-if-changed=src/entity_coverage_types.rs");
}
// ════════════════════════════════════════════════════════════════════════════
@@ -486,6 +500,103 @@ fn generate_type_registry(out_dir: &Path) {
);
let json = serde_json::to_string_pretty(&registry).unwrap();
fs::write(out_dir.join("type_registry.json"), json).unwrap();
generate_entity_coverage(out_dir, &registry);
}
fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
use std::collections::HashSet;
let policy: EntityCoveragePolicy = serde_json::from_str(include_str!("entity_coverage_policy.json"))
.expect("valid entity coverage policy");
let variants = &registry.types[&TypeId::new("EntityType")].variants;
let variant_names: HashSet<&str> = variants.iter().map(|v| v.name.as_str()).collect();
for name in policy.internal_kinds.iter().chain(&policy.opaque_kinds)
.chain(policy.editable.keys()) {
assert!(variant_names.contains(name.as_str()), "coverage policy names unknown kind: {name}");
}
for name in &policy.internal_kinds {
assert!(!policy.opaque_kinds.contains(name) && !policy.editable.contains_key(name),
"internal kind has conflicting coverage: {name}");
}
for name in &policy.opaque_kinds {
assert!(!policy.editable.contains_key(name), "opaque kind is editable: {name}");
}
for name in policy.aliases.keys() {
assert!(policy.editable.contains_key(name), "aliases for non-editable kind: {name}");
}
let common = &registry.types[&TypeId::new("EntityCommon")];
let mut entity_kinds = Vec::with_capacity(variants.len());
for variant in variants {
let kind = &variant.name;
let scope = if policy.internal_kinds.contains(kind) { EntityScope::Internal }
else if policy.opaque_kinds.contains(kind) { EntityScope::Opaque }
else { EntityScope::Canvas };
let editable = policy.editable.get(kind).cloned().unwrap_or_default();
let mut unresolved: HashSet<String> = editable.iter().cloned().collect();
assert_eq!(unresolved.len(), editable.len(), "duplicate editable property in {kind}");
let aliases = policy.aliases.get(kind);
let mut properties = vec![PropertyCoverage {
name: "kind".into(), source_path: "<variant>".into(), type_id: "String".into(),
optional: false, is_sequence: false, snapshot_readable: true,
model_access: ModelAccess::ReadOnly, validation: "none".into(),
}];
for field in &common.fields {
let (name, access) = match field.name.as_str() {
"handle" => ("handle".to_owned(), ModelAccess::ReadOnly),
"layer" if scope == EntityScope::Canvas => {
unresolved.remove("layer");
("layer".to_owned(), ModelAccess::ReadWrite)
},
"layer" => ("layer".to_owned(), ModelAccess::ReadOnly),
other => (format!("common.{other}"), ModelAccess::Unmapped),
};
properties.push(PropertyCoverage {
name, source_path: format!("common.{}", field.name), type_id: field.type_id.as_str().to_owned(),
optional: field.optional, is_sequence: field.is_sequence, snapshot_readable: true,
validation: if access == ModelAccess::ReadWrite && field.name == "layer" {
"transaction_nonempty"
} else if access == ModelAccess::ReadWrite { "type_conversion_only" } else { "none" }.into(),
model_access: access,
});
}
let shape_type = variant.fields.first().expect("entity variant payload").type_id.clone();
let shape = &registry.types[&shape_type];
for field in &shape.fields {
if field.name == "common" { continue; }
let exposed = editable.iter().find(|name| {
aliases.and_then(|map| map.get(*name)).map_or(name.as_str(), String::as_str) == field.name
});
let (name, access) = match exposed {
Some(name) => {
unresolved.remove(name);
((*name).clone(), ModelAccess::ReadWrite)
}
None => (field.name.clone(), ModelAccess::Unmapped),
};
let validation = if access != ModelAccess::ReadWrite { "none" }
else if matches!((kind.as_str(), name.as_str()),
("Point", "location") | ("Line", "start" | "end") |
("Circle" | "Arc", "center" | "radius") |
("Ray" | "XLine", "base_point" | "direction") |
("Solid", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "normal" | "thickness") |
("Face3D", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "invisible_edges")) {
"transaction_geometry"
} else { "type_conversion_only" };
properties.push(PropertyCoverage {
name, source_path: field.name.clone(),
type_id: if exposed.is_some_and(|name| name == "closed") { "bool".into() }
else { field.type_id.as_str().to_owned() },
optional: field.optional, is_sequence: field.is_sequence, snapshot_readable: true,
validation: validation.into(),
model_access: access,
});
}
assert!(unresolved.is_empty(), "{kind} has coverage keys absent from registry: {unresolved:?}");
entity_kinds.push(EntityKindCoverage { kind: kind.clone(), scope, properties });
}
let catalog = EntityCoverageCatalog { schema_version: 1, entity_kinds };
fs::write(out_dir.join("entity_coverage.json"), serde_json::to_string_pretty(&catalog).unwrap())
.expect("write entity coverage");
}
fn trace<T>(tracer: &mut Tracer, samples: &Samples)
@@ -908,7 +1019,7 @@ fn generate_version_info(out_dir: &Path) {
"acadrust_version": acadrust.version.to_string(),
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
"rustc_version": rustc_version,
"api_version": 6,
"api_version": 7,
"api_version_min_supported": 2,
"build_timestamp": build_timestamp,
});
@@ -0,0 +1,28 @@
{
"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"]
},
"aliases": {
"Polyline": {"closed": "flags"},
"Polyline2D": {"closed": "flags"},
"Text": {"text": "value", "insertion": "insertion_point"},
"MText": {"text": "value", "insertion": "insertion_point"}
}
}
@@ -0,0 +1,342 @@
//! Host-owned coverage generated from the traced entity registry and an
//! explicit policy. `unmapped` fields remain accessible through the typed
//! `CadDocument` snapshot but are not promised by the Python document model.
pub use crate::entity_coverage_types::*;
pub const EMBEDDED_ENTITY_COVERAGE_JSON: &str =
include_str!(concat!(env!("OUT_DIR"), "/entity_coverage.json"));
pub fn get_embedded_entity_coverage_json() -> &'static str {
EMBEDDED_ENTITY_COVERAGE_JSON
}
/// Read-only JSON serialization of one typed entity. The outer object
/// has one key: the `EntityType` variant name used by the coverage catalog.
/// JSON does not preserve non-finite floating-point values, so this is an
/// inspection format and must remain separate from the typed entity write path.
#[cfg(feature = "host")]
pub fn entity_snapshot(entity: &crate::host::EntityType) -> Result<serde_json::Value, serde_json::Error> {
serde_json::to_value(entity)
}
#[cfg(feature = "host")]
fn finite_vector(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> {
if value.x.is_finite() && value.y.is_finite() && value.z.is_finite() { Ok(()) }
else { Err(format!("{name} must contain finite coordinates")) }
}
#[cfg(feature = "host")]
fn unit_direction(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> {
finite_vector(name, value)?;
let length = value.x.hypot(value.y).hypot(value.z);
if (length - 1.0).abs() <= 1e-6 { Ok(()) }
else { Err(format!("{name} must be a unit vector")) }
}
#[cfg(feature = "host")]
fn solid_normal(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> {
finite_vector(name, value)?;
if value.x.hypot(value.y).hypot(value.z) > 0.0 { Ok(()) }
else { Err(format!("{name} must be nonzero")) }
}
/// 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")]
pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<(), String> {
use crate::host::EntityType;
if entity.common().layer.trim().is_empty() { return Err("layer name is empty".into()); }
match entity {
EntityType::Ray(value) => {
finite_vector("Ray.base_point", &value.base_point)?;
unit_direction("Ray.direction", &value.direction)?;
}
EntityType::XLine(value) => {
finite_vector("XLine.base_point", &value.base_point)?;
unit_direction("XLine.direction", &value.direction)?;
}
EntityType::Solid(value) => {
for (name, corner) in [
("first_corner", &value.first_corner), ("second_corner", &value.second_corner),
("third_corner", &value.third_corner), ("fourth_corner", &value.fourth_corner),
] { finite_vector(&format!("Solid.{name}"), corner)?; }
solid_normal("Solid.normal", &value.normal)?;
if !value.thickness.is_finite() { return Err("Solid.thickness must be finite".into()); }
}
EntityType::Face3D(value) => {
for (name, corner) in [
("first_corner", &value.first_corner), ("second_corner", &value.second_corner),
("third_corner", &value.third_corner), ("fourth_corner", &value.fourth_corner),
] { finite_vector(&format!("Face3D.{name}"), corner)?; }
if value.invisible_edges.bits() & !0x0f != 0 {
return Err("Face3D.invisible_edges has unknown bits".into());
}
}
_ => {}
}
Ok(())
}
/// Validate geometry changed by a typed replacement before an undoable
/// transaction commits. Legacy invalid values in untouched fields remain
/// untouched, so editing a layer does not unexpectedly reject an old drawing.
#[cfg(feature = "host")]
pub fn validate_entity_mutation(
before: &crate::host::EntityType,
after: &crate::host::EntityType,
) -> Result<(), String> {
use crate::host::EntityType;
if before.common().layer != after.common().layer {
if after.common().layer.trim().is_empty() { return Err("layer name is empty".into()); }
if matches!(after, EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::Seqend(_)
| EntityType::Extended(_) | EntityType::Unknown(_)) {
return Err("entity kind does not support canvas layer edits".into());
}
}
let finite3 = |name: &str, x: f64, y: f64, z: f64| {
if x.is_finite() && y.is_finite() && z.is_finite() { Ok(()) }
else { Err(format!("{name} must contain finite coordinates")) }
};
let same3 = |a: &crate::host::acadrust::types::Vector3,
b: &crate::host::acadrust::types::Vector3| {
a.x.to_bits() == b.x.to_bits() && a.y.to_bits() == b.y.to_bits() && a.z.to_bits() == b.z.to_bits()
};
let radius = |name: &str, value: f64| {
if value.is_finite() && value > 0.0 { Ok(()) }
else { Err(format!("{name} must be finite and greater than zero")) }
};
let changed3 = |name: &str, old: &crate::host::acadrust::types::Vector3,
new: &crate::host::acadrust::types::Vector3| {
if !same3(old, new) { finite_vector(name, new) } else { Ok(()) }
};
match (before, after) {
(EntityType::Point(old), EntityType::Point(new)) => {
if !same3(&old.location, &new.location) {
finite3("Point.location", new.location.x, new.location.y, new.location.z)?;
}
}
(EntityType::Line(old), EntityType::Line(new)) => {
if !same3(&old.start, &new.start) {
finite3("Line.start", new.start.x, new.start.y, new.start.z)?;
}
if !same3(&old.end, &new.end) {
finite3("Line.end", new.end.x, new.end.y, new.end.z)?;
}
}
(EntityType::Circle(old), EntityType::Circle(new)) => {
if !same3(&old.center, &new.center) {
finite3("Circle.center", new.center.x, new.center.y, new.center.z)?;
}
if old.radius.to_bits() != new.radius.to_bits() { radius("Circle.radius", new.radius)?; }
}
(EntityType::Arc(old), EntityType::Arc(new)) => {
if !same3(&old.center, &new.center) {
finite3("Arc.center", new.center.x, new.center.y, new.center.z)?;
}
if old.radius.to_bits() != new.radius.to_bits() { radius("Arc.radius", new.radius)?; }
}
(EntityType::Ray(old), EntityType::Ray(new)) => {
changed3("Ray.base_point", &old.base_point, &new.base_point)?;
if !same3(&old.direction, &new.direction) { unit_direction("Ray.direction", &new.direction)?; }
}
(EntityType::XLine(old), EntityType::XLine(new)) => {
changed3("XLine.base_point", &old.base_point, &new.base_point)?;
if !same3(&old.direction, &new.direction) { unit_direction("XLine.direction", &new.direction)?; }
}
(EntityType::Solid(old), EntityType::Solid(new)) => {
for (name, old_corner, new_corner) in [
("first_corner", &old.first_corner, &new.first_corner),
("second_corner", &old.second_corner, &new.second_corner),
("third_corner", &old.third_corner, &new.third_corner),
("fourth_corner", &old.fourth_corner, &new.fourth_corner),
] { changed3(&format!("Solid.{name}"), old_corner, new_corner)?; }
if !same3(&old.normal, &new.normal) { solid_normal("Solid.normal", &new.normal)?; }
if old.thickness.to_bits() != new.thickness.to_bits() && !new.thickness.is_finite() {
return Err("Solid.thickness must be finite".into());
}
}
(EntityType::Face3D(old), EntityType::Face3D(new)) => {
for (name, old_corner, new_corner) in [
("first_corner", &old.first_corner, &new.first_corner),
("second_corner", &old.second_corner, &new.second_corner),
("third_corner", &old.third_corner, &new.third_corner),
("fourth_corner", &old.fourth_corner, &new.fourth_corner),
] { changed3(&format!("Face3D.{name}"), old_corner, new_corner)?; }
if old.invisible_edges != new.invisible_edges && new.invisible_edges.bits() & !0x0f != 0 {
return Err("Face3D.invisible_edges has unknown bits".into());
}
}
_ => {}
}
Ok(())
}
/// Clone a canvas entity with a new layer, preserving its other snapshot
/// fields. Internal records and opaque fallbacks are excluded.
#[cfg(feature = "host")]
pub fn patch_canvas_layer(entity: &crate::host::EntityType, layer: &str) -> Result<crate::host::EntityType, String> {
use crate::host::EntityType;
if layer.trim().is_empty() { return Err("layer name is empty".into()); }
if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::Seqend(_)
| EntityType::Extended(_) | EntityType::Unknown(_)) {
return Err("entity kind does not support canvas layer edits".into());
}
let mut changed = entity.clone();
changed.common_mut().layer = layer.to_owned();
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{get_embedded_type_registry_json, TypeId, TypeRegistry};
#[test]
fn every_entity_variant_has_exactly_one_classification() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
let registry: TypeRegistry = serde_json::from_str(get_embedded_type_registry_json()).unwrap();
let variants = &registry.types[&TypeId::new("EntityType")].variants;
assert_eq!(catalog.entity_kinds.len(), variants.len());
let mut names = std::collections::HashSet::new();
for entry in &catalog.entity_kinds {
assert!(names.insert(&entry.kind), "duplicate kind {}", entry.kind);
assert!(variants.iter().any(|v| v.name == entry.kind));
assert!(entry.properties.iter().any(|p| p.name == "handle" && p.model_access == ModelAccess::ReadOnly));
assert!(entry.properties.iter().any(|p| p.name == "kind" && p.model_access == ModelAccess::ReadOnly));
let mut source_paths = std::collections::HashSet::new();
for property in &entry.properties {
assert!(source_paths.insert(&property.source_path), "duplicate source path in {}: {}", entry.kind, property.source_path);
assert!(property.snapshot_readable, "unreadable traced property in {}: {}", entry.kind, property.name);
if property.model_access == ModelAccess::ReadWrite {
assert!(matches!(property.validation.as_str(), "type_conversion_only" | "transaction_geometry" | "transaction_nonempty"));
}
}
}
}
#[test]
fn unsupported_kinds_have_no_editable_properties() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
for entry in &catalog.entity_kinds {
if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(),
"Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" |
"Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" |
"Ray" | "XLine" | "Solid" | "Face3D") {
assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite
|| (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind);
}
}
}
#[test]
fn catalog_names_the_fields_with_transaction_geometry_checks() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
let checked: std::collections::HashSet<_> = catalog.entity_kinds.iter()
.flat_map(|entry| entry.properties.iter()
.filter(|property| property.validation == "transaction_geometry")
.map(|property| format!("{}.{}", entry.kind, property.name)))
.collect();
assert_eq!(checked, std::collections::HashSet::from([
"Point.location".to_owned(), "Line.start".to_owned(), "Line.end".to_owned(),
"Circle.center".to_owned(), "Circle.radius".to_owned(),
"Arc.center".to_owned(), "Arc.radius".to_owned(),
"Ray.base_point".to_owned(), "Ray.direction".to_owned(),
"XLine.base_point".to_owned(), "XLine.direction".to_owned(),
"Solid.first_corner".to_owned(), "Solid.second_corner".to_owned(),
"Solid.third_corner".to_owned(), "Solid.fourth_corner".to_owned(),
"Solid.normal".to_owned(), "Solid.thickness".to_owned(),
"Face3D.first_corner".to_owned(), "Face3D.second_corner".to_owned(),
"Face3D.third_corner".to_owned(), "Face3D.fourth_corner".to_owned(),
"Face3D.invisible_edges".to_owned(),
]));
}
#[test]
fn layer_write_coverage_matches_canvas_scope() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
assert_eq!(catalog.entity_kinds.iter().filter(|entry| entry.scope == EntityScope::Canvas).count(), 43);
for entry in &catalog.entity_kinds {
let layer = entry.properties.iter().find(|property| property.name == "layer").unwrap();
assert_eq!(layer.model_access == ModelAccess::ReadWrite, entry.scope == EntityScope::Canvas);
if entry.scope == EntityScope::Canvas {
assert_eq!(layer.validation, "transaction_nonempty");
}
}
}
#[cfg(feature = "host")]
#[test]
fn unsupported_canvas_kind_has_typed_snapshot_fields() {
use crate::host::acadrust;
let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default());
let snapshot = entity_snapshot(&hatch).unwrap();
let fields = snapshot.get("Hatch").and_then(|value| value.as_object()).unwrap();
assert!(fields.contains_key("common"));
assert!(fields.len() > 1);
}
#[cfg(feature = "host")]
#[test]
fn changed_geometry_is_validated_without_rejecting_untouched_legacy_values() {
use crate::host::acadrust;
let mut before = acadrust::entities::Circle::default();
before.radius = f64::NAN;
let mut layer_only = before.clone();
layer_only.common.layer = "OTHER".into();
assert!(validate_entity_mutation(
&acadrust::EntityType::Circle(before.clone()),
&acadrust::EntityType::Circle(layer_only),
).is_ok());
let mut bad_radius = before.clone();
bad_radius.radius = -1.0;
assert!(validate_entity_mutation(
&acadrust::EntityType::Circle(before),
&acadrust::EntityType::Circle(bad_radius),
).unwrap_err().contains("Circle.radius"));
}
#[cfg(feature = "host")]
#[test]
fn new_phase_seven_shapes_reject_invalid_geometry_and_keep_legacy_fields() {
use crate::host::acadrust::{self, types::Vector3};
let mut ray = acadrust::entities::Ray::default();
ray.direction = Vector3::new(2.0, 0.0, 0.0);
assert!(validate_new_canvas_entity(&acadrust::EntityType::Ray(ray.clone())).unwrap_err().contains("Ray.direction"));
let before = acadrust::EntityType::Ray(ray.clone());
ray.common.layer = "CONSTRUCTION".into();
assert!(validate_entity_mutation(&before, &acadrust::EntityType::Ray(ray.clone())).is_ok());
ray.direction = Vector3::new(0.0, 1.0, 0.0);
assert!(validate_entity_mutation(&before, &acadrust::EntityType::Ray(ray)).is_ok());
let mut xline = acadrust::entities::XLine::default();
xline.base_point.x = f64::NAN;
assert!(validate_new_canvas_entity(&acadrust::EntityType::XLine(xline)).unwrap_err().contains("XLine.base_point"));
let solid = acadrust::entities::Solid::new(Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, Vector3::ZERO);
let mut changed = solid.clone();
changed.thickness = f64::INFINITY;
assert!(validate_entity_mutation(&acadrust::EntityType::Solid(solid), &acadrust::EntityType::Solid(changed))
.unwrap_err().contains("Solid.thickness"));
let face = acadrust::entities::Face3D::new(Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, Vector3::ZERO);
let mut changed = face.clone();
changed.invisible_edges = acadrust::entities::InvisibleEdgeFlags::from_bits(0x10);
assert!(validate_entity_mutation(&acadrust::EntityType::Face3D(face), &acadrust::EntityType::Face3D(changed))
.unwrap_err().contains("Face3D.invisible_edges"));
}
#[cfg(feature = "host")]
#[test]
fn canvas_layer_patch_preserves_unmapped_hatch_fields() {
use crate::host::acadrust;
let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default());
let patched = patch_canvas_layer(&hatch, "HATCHES").unwrap();
assert_eq!(patched.common().layer, "HATCHES");
assert_eq!(hatch.common().layer, "0");
let mut expected = hatch.clone();
expected.common_mut().layer = "HATCHES".into();
assert_eq!(patched, expected);
assert!(patch_canvas_layer(&hatch, " ").is_err());
}
}
@@ -0,0 +1,52 @@
/// Stable, language-neutral entity capability schema.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntityScope {
Canvas,
Internal,
Opaque,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelAccess {
ReadWrite,
ReadOnly,
Unmapped,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropertyCoverage {
/// Name shown in the document model, or source name when unmapped.
pub name: String,
/// Field in the acadrust snapshot; `kind` is synthesized from the variant.
pub source_path: String,
pub type_id: String,
pub optional: bool,
pub is_sequence: bool,
/// Whether the serialized host snapshot carries this property.
pub snapshot_readable: bool,
/// `unmapped` means no normalized Python property or setter; the raw
/// snapshot can still expose its serialized value.
pub model_access: ModelAccess,
/// Write validation applied by the v7 transaction path. Currently
/// `transaction_geometry` for selected primitive geometry, otherwise
/// `type_conversion_only` for writable properties or `none`.
pub validation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityKindCoverage {
pub kind: String,
pub scope: EntityScope,
pub properties: Vec<PropertyCoverage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityCoverageCatalog {
pub schema_version: u32,
pub entity_kinds: Vec<EntityKindCoverage>,
}
+45
View File
@@ -83,6 +83,10 @@ pub enum HostNotification {
DocumentTabClosed { tab_id: u64 },
/// V4 selection changed for a specific tab. Discriminant 7.
SelectionChangedV4 { tab_id: u64, handles: Vec<Handle> },
/// V7 active interactive-command state for a tab. Discriminant 8.
CommandStateChanged { tab_id: u64, command: Option<String> },
/// V7 drawing epoch, broadcast even without an open shared view. Discriminant 9.
DrawingChanged { tab_id: u64, epoch: u64 },
/// Fallback for notification variants added in future minor revisions.
/// Carries the raw bincode payload so an older peer can ignore it without
/// failing deserialization.
@@ -133,6 +137,20 @@ impl Serialize for HostNotification {
bincode::serialize_into(&mut bytes, handles)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::CommandStateChanged { tab_id, command } => {
bytes.push(8);
bincode::serialize_into(&mut bytes, tab_id)
.map_err(serde::ser::Error::custom)?;
bincode::serialize_into(&mut bytes, command)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::DrawingChanged { tab_id, epoch } => {
bytes.push(9);
bincode::serialize_into(&mut bytes, tab_id)
.map_err(serde::ser::Error::custom)?;
bincode::serialize_into(&mut bytes, epoch)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::Unknown(raw) => bytes.extend_from_slice(raw),
}
bytes.serialize(serializer)
@@ -170,6 +188,12 @@ impl<'de> Deserialize<'de> for HostNotification {
7 => bincode::deserialize(rest)
.map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles })
.map_err(serde::de::Error::custom),
8 => bincode::deserialize(rest)
.map(|(tab_id, command)| HostNotification::CommandStateChanged { tab_id, command })
.map_err(serde::de::Error::custom),
9 => bincode::deserialize(rest)
.map(|(tab_id, epoch)| HostNotification::DrawingChanged { tab_id, epoch })
.map_err(serde::de::Error::custom),
_ => Ok(HostNotification::Unknown(bytes)),
}
}
@@ -575,6 +599,27 @@ pub trait HostApi {
) -> Result<HostSettingValue, String> {
Err("system variable is not supported by this host".to_owned())
}
/// Validate and replace existing entities as one undo step (API v7).
/// Callers clone entities from `document()`, change only supported fields,
/// and submit all replacements together. Identity and kind must be kept.
/// An error leaves the drawing and undo history unchanged.
fn update_entities_transaction(
&mut self,
_label: &str,
_entities: Vec<EntityType>,
) -> Result<(), String> {
Err("entity transactions are not supported by this host".to_owned())
}
/// Current ordered selection in this session's tab (API v7).
fn selection(&self) -> Vec<Handle> { Vec::new() }
/// Replace this tab's selection with exactly these handles, in order.
/// Missing or duplicate handles reject the request (API v7).
fn set_selection(&mut self, _handles: &[Handle]) -> Result<(), String> {
Err("selection writes are not supported by this host".to_owned())
}
}
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
+34
View File
@@ -444,6 +444,40 @@ impl HostApi for PluginHostApi {
Err(error) => Err(error.to_string()),
}
}
fn update_entities_transaction(
&mut self,
label: &str,
entities: Vec<EntityType>,
) -> Result<(), String> {
match self.client.request(PluginRequest::UpdateEntitiesTransaction {
label: label.to_owned(), entities,
}) {
Ok(PluginResponse::EntityTransactionResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected entity transaction response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn selection(&self) -> Vec<Handle> {
match self.client.request(PluginRequest::GetSelection) {
Ok(PluginResponse::Selection(handles)) => handles,
_ => Vec::new(),
}
}
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
Ok(PluginResponse::SelectionResult(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected selection response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
}
/// Sentinel reader used when the shared-memory view could not be initialized.
+10
View File
@@ -76,6 +76,8 @@ pub enum HostRequest {
code: String,
tab_index: usize,
},
/// V7: release an interactive command after completion or cancellation.
DropInteractive { command_id: u64 },
}
/// Responses the plugin runner sends back for `HostRequest`.
@@ -142,6 +144,11 @@ pub enum PluginRequest {
GetSystemVariable { name: String },
/// Change a host-managed setting without nested command dispatch.
SetSystemVariable { name: String, value: HostSettingValue },
/// V7: validate and replace existing entities in a single undo step.
UpdateEntitiesTransaction { label: String, entities: Vec<EntityType> },
/// V7: synchronous selection read/write for the dispatch tab.
GetSelection,
SetSelection { handles: Vec<Handle> },
}
/// Responses the host sends back for `PluginRequest`.
@@ -170,6 +177,9 @@ pub enum PluginResponse {
DocumentPath(Option<std::ffi::OsString>),
SystemVariable(Option<HostSettingValue>),
SystemVariableResult(Result<HostSettingValue, String>),
EntityTransactionResult(Result<(), String>),
Selection(Vec<Handle>),
SelectionResult(Result<(), String>),
}
/// Messages sent from the host to the plugin runner.
+5
View File
@@ -83,5 +83,10 @@ pub fn handle_plugin_request(
SetSystemVariable { name, value } => {
PluginResponse::SystemVariableResult(host.set_system_variable(&name, value))
}
UpdateEntitiesTransaction { label, entities } => PluginResponse::EntityTransactionResult(
host.update_entities_transaction(&label, entities),
),
GetSelection => PluginResponse::Selection(host.selection()),
SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)),
}
}
@@ -758,6 +758,40 @@ impl HostApi for V4PluginHostApi {
Err(error) => Err(error.to_string()),
}
}
fn update_entities_transaction(
&mut self,
label: &str,
entities: Vec<EntityType>,
) -> Result<(), String> {
match self.request(PluginRequest::UpdateEntitiesTransaction {
label: label.to_owned(), entities,
}) {
Ok(PluginResponse::EntityTransactionResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected entity transaction response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn selection(&self) -> Vec<Handle> {
match self.request(PluginRequest::GetSelection) {
Ok(PluginResponse::Selection(handles)) => handles,
_ => Vec::new(),
}
}
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
Ok(PluginResponse::SelectionResult(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected selection response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
}
#[cfg(test)]
@@ -86,6 +86,37 @@ mod tests {
}
}
#[test]
fn command_state_notification_roundtrips() {
let notification = HostNotification::CommandStateChanged {
tab_id: 42,
command: Some("LINE".to_owned()),
};
let bytes = bincode::serialize(&notification).unwrap();
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
assert_eq!(decoded, notification);
let stopped = HostNotification::CommandStateChanged { tab_id: 42, command: None };
let bytes = bincode::serialize(&stopped).unwrap();
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
assert_eq!(decoded, stopped);
let changed = HostNotification::DrawingChanged { tab_id: 42, epoch: 7 };
let bytes = bincode::serialize(&changed).unwrap();
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
assert_eq!(decoded, changed);
}
#[test]
fn selection_request_and_response_roundtrip() {
let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] };
let bytes = bincode::serialize(&request).unwrap();
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::SetSelection { handles } if handles == vec![acadrust::Handle::new(9)]));
let response = PluginResponse::Selection(vec![acadrust::Handle::new(9)]);
let bytes = bincode::serialize(&response).unwrap();
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::Selection(handles) if handles == vec![acadrust::Handle::new(9)]));
}
#[test]
fn plugin_notification_roundtrip() {
let frame = PluginToHostV4::Notification(NotificationEnvelope {
+4
View File
@@ -36,6 +36,8 @@ pub mod manifest;
pub mod ribbon;
pub mod type_registry;
pub mod type_registry_types;
pub mod entity_coverage;
pub mod entity_coverage_types;
pub mod version_info;
/// Runtime host surface — only built with the `host` feature (pulls `acadrust`).
@@ -73,6 +75,8 @@ pub use type_registry::{
get_embedded_type_registry_json, EnumVariantInfo, FieldInfo, MethodInfo, ParameterInfo,
TypeId, TypeInfo, TypeKind, TypeRegistry,
};
pub use entity_coverage::{get_embedded_entity_coverage_json, EntityCoverageCatalog,
EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage};
pub use version_info::get_embedded_version_info_json;
#[cfg(test)]
+3 -2
View File
@@ -20,8 +20,9 @@
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5
/// adds the `BuiltinPlugin::on_load` lifecycle callback and
/// `HostApi::document_path`. v6 appends generic host system-variable access
/// (`HostApi::system_variable` / `set_system_variable`).
pub const API_VERSION: u32 = 6;
/// (`HostApi::system_variable` / `set_system_variable`). v7 appends atomic,
/// undoable replacement of a set of existing entities.
pub const API_VERSION: u32 = 7;
/// Oldest plugin API major the current host still loads. This keeps previously
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
+10
View File
@@ -237,6 +237,7 @@ fn request_kind(req: &HostRequest) -> &'static str {
HostRequest::GetPrompt { .. } => "GetPrompt",
HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick",
HostRequest::ExecuteCode { .. } => "ExecuteCode",
HostRequest::DropInteractive { .. } => "DropInteractive",
HostRequest::Shutdown => "Shutdown",
}
}
@@ -599,6 +600,15 @@ impl PluginProcess {
}
}
/// Release a completed or abandoned V4 interactive command in the runner.
pub fn drop_interactive(&self, command_id: u64) -> Result<(), PluginError> {
let Some(v4) = &self.v4 else { return Ok(()); };
match v4.call(&mut NullHost, HostRequest::DropInteractive { command_id }, &mut |_| {})? {
HostResponse::Bool(true) => Ok(()),
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
}
}
/// Ask the plugin process for the current prompt of an interactive command.
pub fn get_prompt(&self, command_id: u64) -> Result<String, PluginError> {
if let Some(v4) = &self.v4 {
+1
View File
@@ -71,6 +71,7 @@ fn request_kind(req: &HostRequest) -> &'static str {
HostRequest::GetPrompt { .. } => "GetPrompt",
HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick",
HostRequest::ExecuteCode { .. } => "ExecuteCode",
HostRequest::DropInteractive { .. } => "DropInteractive",
HostRequest::Shutdown => "Shutdown",
}
}
+8
View File
@@ -191,6 +191,10 @@ fn handle_host_request(
Err(_) => HostResponse::Error("interactive command panicked".to_string()),
}
}
HostRequest::DropInteractive { command_id } => {
interactive.borrow_mut().remove(&command_id);
HostResponse::Bool(true)
}
HostRequest::GetPrompt { command_id } => {
let result = {
let registry = interactive.borrow();
@@ -290,6 +294,10 @@ fn handle_host_request_v4(
Err(_) => Some(HostResponse::Error("interactive command panicked".to_string())),
}
}
HostRequest::DropInteractive { command_id } => {
interactive.borrow_mut().remove(&command_id);
Some(HostResponse::Bool(true))
}
HostRequest::GetPrompt { command_id } => {
let result = {
let registry = interactive.borrow();
+88
View File
@@ -0,0 +1,88 @@
# Scripting host model (API v7)
The host owns the drawing. Plugins can read the full `CadDocument` snapshot and
commit cloned `EntityType` values through IPC. `update_entities_transaction`
validates every replacement before changing the drawing, records one undo
entry, refreshes scene caches and the shared document view, and marks the
document dirty. The request preserves handles, owners, and entity kinds. A
missing or duplicate handle, a kind/owner change, or a locked source layer
rejects the whole batch. An empty batch does nothing.
This is a full-entity replacement API, not a promise that every CAD property
is editable through Python. A client should clone the current value and patch
only fields it understands; unknown fields remain intact. The host can read
all `acadrust::EntityType` variants through `document()`. Its generic write
path accepts existing variants subject to the validation above. Entity
creation and deletion still use `add_entity` and `remove_entity` and are not
part of this transaction request.
The host embeds a versioned coverage catalog generated from its traced
`EntityType` registry and `crates/ocs_plugin_api/entity_coverage_policy.json`.
It classifies all 48 variants: 43 canvas kinds, three internal records
(`Block`, `BlockEnd`, `Seqend`), and two opaque fallbacks (`Extended`,
`Unknown`). Each property records its source field, type, optional/sequence
shape, snapshot readability, Python access (`read_write`, `read_only`, or
`unmapped`), and validation status. `unmapped` means the typed snapshot carries
the field but the Python document model has no getter or setter for it.
The v7 transaction path checks changed Point locations, Line endpoints,
Circle/Arc centers, Ray/XLine base points, and Solid/Face3D corners for finite
coordinates. Circle/Arc radii must be finite and positive; Ray/XLine directions
must be unit vectors; Solid normals must be nonzero, its thickness finite,
and Face3D invisible-edge flags limited to four known bits. Those fields report `transaction_geometry` in the
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 16 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
JSON value keyed by its catalog variant name. This read path carries every
serializable field, including nested and unmapped values, without granting a
write path for them. The Python adapter exposes its variant payload through
`entity.snapshot`; changing that dictionary does not change the drawing.
It is an inspection view: JSON converts non-finite floating-point values to
null and cannot be used as a lossless replacement entity.
The separate PandoraBox adapter currently maps these entity kinds to Python
dictionaries: Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D,
Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, and Face3D. The authoritative per-field mapping
is `plugins/opencad-python/entity_manifest.json` in PandoraBox. The generated
mapping excludes fields it cannot represent, including common color, line
weight, transparency, Polyline3D smooth type, and MText background color.
Absent geometry fields are not editable through the Python document model.
A feature test compares the generated Python keys with the
host catalog. The adapter's `experimental-host-model` feature requires API
v7; its portable default remains pinned to H7's v5 contract.
API v7 also exposes a synchronous, ordered selection query and an atomic
selection replacement scoped to the session tab. All handles must exist before
replacement begins, duplicate handles are rejected, and the scripted
replacement keeps exactly the requested handles and order. UI picks continue
to group linked leaders and annotations. The V4 notification channel still broadcasts best-effort
document and selection changes. A new `DrawingChanged` notification carries
the scene epoch even when no shared document view is open. A new `CommandStateChanged` notification
reports transitions of the active interactive command at app message
boundaries, with the tab id and command name (or `None` when it ends). A
noninteractive command that starts and finishes within one message does not
produce an active-state transition.
The host's existing `InteractiveCommand` machinery collects either a point or
an entity pick. PandoraBox's v7 adapter now exposes a token-based request and
poll API because `PY_RUN` uses a fresh Python interpreter per invocation; a
script cannot suspend in place while the user clicks. Enter and command
cancellation report a cancelled result. The host releases the runner's pick
command when it ends, including Escape cancellation. Drawing, selection, and command
notifications are retained in a bounded 256-event queue per tab between runs
and can be drained with `doc.poll_events()`. If a tab exceeds that bound, the
next poll begins with `{"type": "overflow", "dropped": n}`; scripts should
refresh drawing and selection state when they receive it. Closing a tab clears
its pending events, and a script can poll only its active tab.
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 16-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
dictionary keys, plus the unmapped snapshot fields and their types.
+167
View File
@@ -93,6 +93,10 @@ impl<'a> HostSession<'a> {
}
#[cfg(not(target_arch = "wasm32"))]
{
let key = (self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch);
if self.app.last_plugin_document != Some(key) {
v4_support::publish_drawing_changed(key.0, key.1);
}
v4_support::publish_document_view_v4(self.tab_id(), doc);
self.app.last_plugin_document =
Some((self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch));
@@ -125,6 +129,73 @@ impl<'a> HostSession<'a> {
ok
}
pub fn update_entities_transaction(
&mut self,
label: &str,
entities: Vec<EntityType>,
) -> Result<(), String> {
use std::collections::HashSet;
if label.trim().is_empty() { return Err("transaction label is empty".into()); }
if entities.is_empty() { return Ok(()); }
let mut seen = HashSet::new();
for entity in &entities {
let handle = entity.common().handle;
if handle.is_null() || !seen.insert(handle) {
return Err(format!("null or duplicate entity handle: {handle:?}"));
}
let Some(existing) = self.document().get_entity(handle) else {
return Err(format!("entity {handle:?} does not exist"));
};
if std::mem::discriminant(existing) != std::mem::discriminant(entity) {
return Err(format!("entity {handle:?} changes kind"));
}
if existing.common().owner_handle != entity.common().owner_handle {
return Err(format!("entity {handle:?} changes owner"));
}
ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity)
.map_err(|error| format!("entity {handle:?}: {error}"))?;
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return Err(format!("entity {handle:?} is on a locked layer"));
}
}
self.push_undo(label);
for entity in entities {
// Validation above makes this infallible while the session owns
// the document exclusively.
assert!(self.app.tabs[self.tab].scene.update_entity(entity));
}
self.set_dirty();
self.publish_document_view();
Ok(())
}
pub fn selection(&self) -> Vec<Handle> {
self.app.tabs[self.tab].scene.selected_handles_in_order()
}
pub fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
let mut seen = std::collections::HashSet::new();
for handle in handles {
if !seen.insert(*handle) {
return Err(format!("duplicate selected entity: {handle:?}"));
}
if self.document().get_entity(*handle).is_none() {
return Err(format!("entity {handle:?} does not exist"));
}
}
if self.selection() == handles { return Ok(()); }
let scene = &mut self.app.tabs[self.tab].scene;
scene.replace_selection_exact(handles);
#[cfg(not(target_arch = "wasm32"))]
{
if self.tab == self.app.active_tab {
self.app.last_plugin_selection = Some((self.tab_id(), self.app.tabs[self.tab].scene.selection_fingerprint()));
}
crate::plugin::v4_support::publish_selection_changed_v4(self.tab_id(), self.selection());
}
Ok(())
}
/// Delete the entity with `handle`, keeping the scene's render caches in
/// sync. Returns `false` when the entity is absent or on a locked layer
/// (which `erase_entities` refuses to remove).
@@ -188,6 +259,8 @@ impl<'a> HostSession<'a> {
if let Some(ah) = app_handle {
xd.raw_dwg_eed.retain(|(a, _)| *a != ah);
}
self.bump_geometry();
self.set_dirty();
self.publish_document_view();
true
}
@@ -225,6 +298,8 @@ impl<'a> HostSession<'a> {
if let Some(ah) = app_handle {
xd.raw_dwg_eed.retain(|(a, _)| *a != ah);
}
self.bump_geometry();
self.set_dirty();
self.publish_document_view();
true
}
@@ -322,6 +397,11 @@ impl HostApi for HostSession<'_> {
fn update_entity(&mut self, entity: EntityType) -> bool {
self.update_entity(entity)
}
fn update_entities_transaction(&mut self, label: &str, entities: Vec<EntityType>) -> Result<(), String> {
self.update_entities_transaction(label, entities)
}
fn selection(&self) -> Vec<Handle> { self.selection() }
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { self.set_selection(handles) }
fn remove_entity(&mut self, handle: Handle) -> bool {
self.remove_entity(handle)
}
@@ -489,6 +569,12 @@ impl PluginProcessInteractiveAdapter {
}
}
impl Drop for PluginProcessInteractiveAdapter {
fn drop(&mut self) {
let _ = self.process.drop_interactive(self.command_id);
}
}
impl crate::command::CadCommand for PluginProcessInteractiveAdapter {
fn name(&self) -> &'static str {
"PLUGIN"
@@ -559,6 +645,87 @@ mod tests {
use acadrust::xdata::XDataValue;
use ocs_plugin_api::host::DocumentReader;
#[test]
fn selection_query_is_live_and_replacement_is_validated() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let a = host.add_entity(EntityType::Point(Point::new()));
let b = host.add_entity(EntityType::Point(Point::new()));
assert!(host.selection().is_empty());
host.set_selection(&[b, a]).unwrap();
assert_eq!(host.selection(), vec![b, a]);
assert!(host.set_selection(&[Handle::new(99999)]).is_err());
assert!(host.set_selection(&[a, a]).is_err());
assert_eq!(host.selection(), vec![b, a]);
host.set_selection(&[]).unwrap();
assert!(host.selection().is_empty());
}
#[test]
fn scripted_selection_does_not_expand_linked_leader_annotation() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let annotation = host.add_entity(EntityType::Text(acadrust::entities::Text::default()));
let mut leader = acadrust::entities::Leader::default();
leader.annotation_handle = annotation;
let leader_handle = host.add_entity(EntityType::Leader(leader));
host.set_selection(&[leader_handle]).unwrap();
assert_eq!(host.selection(), vec![leader_handle]);
}
#[test]
fn entity_transaction_validates_before_mutation_and_undoes_as_one_step() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let (first, second);
{
let mut host = HostSession::new(&mut app, 0);
first = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(1.0, 0.0, 0.0))));
second = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(2.0, 0.0, 0.0))));
let mut a = host.document().get_entity(first).unwrap().clone();
let mut b = host.document().get_entity(second).unwrap().clone();
if let EntityType::Point(p) = &mut a { p.location.x = 10.0; }
if let EntityType::Point(p) = &mut b { p.location.x = 20.0; }
let mut invalid = b.clone();
invalid.common_mut().owner_handle = Handle::new(99999);
assert!(host.update_entities_transaction("Move points", vec![a.clone(), invalid]).is_err());
let mut invalid_geometry = b.clone();
if let EntityType::Point(p) = &mut invalid_geometry { p.location.x = f64::NAN; }
assert!(host.update_entities_transaction("Move points", vec![a.clone(), invalid_geometry])
.unwrap_err().contains("Point.location"));
assert_eq!(host.app.tabs[0].history.undo_stack.len(), 0);
assert!(matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0));
host.update_entities_transaction("Move points", vec![a, b]).unwrap();
assert!(matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0));
}
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 1);
app.undo_steps(1);
assert!(matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0));
assert!(matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 2.0));
}
#[test]
fn unmapped_canvas_kind_layer_change_undoes() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let handle;
{
let mut host = HostSession::new(&mut app, 0);
handle = host.add_entity(EntityType::Hatch(acadrust::entities::Hatch::default()));
let original = host.document().get_entity(handle).unwrap();
let changed = ocs_plugin_api::entity_coverage::patch_canvas_layer(original, "HATCHES").unwrap();
host.update_entities_transaction("Move hatch layer", vec![changed]).unwrap();
assert_eq!(host.document().get_entity(handle).unwrap().common().layer, "HATCHES");
}
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 1);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle).unwrap().common().layer, "0");
}
#[test]
fn system_variables_change_without_command_reentry() {
let mut app = OpenCADStudio::new_for_test();
+16
View File
@@ -276,6 +276,7 @@ impl OpenCADStudio {
return;
}
self.last_plugin_document = Some(key);
crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch);
crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document);
}
@@ -305,7 +306,22 @@ impl OpenCADStudio {
return Task::none();
}
}
#[cfg(not(target_arch = "wasm32"))]
let command_before = self.tabs.get(self.active_tab).map(|tab| {
(tab.id, tab.active_cmd.as_ref().map(|command| command.name().to_owned()))
});
let task = self.update_inner(msg);
#[cfg(not(target_arch = "wasm32"))]
{
let command_after = self.tabs.get(self.active_tab).map(|tab| {
(tab.id, tab.active_cmd.as_ref().map(|command| command.name().to_owned()))
});
if command_before != command_after {
if let Some((tab_id, command)) = command_after {
crate::plugin::v4_support::publish_command_state_changed(tab_id, command);
}
}
}
self.refresh_gpu_status();
self.show_next_startup_modal();
self.sync_open_command_history();
+10
View File
@@ -51,6 +51,16 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec<acadrust::Handle>)
broadcast(HostNotification::SelectionChangedV4 { tab_id, handles });
}
/// Broadcast a change in a tab's active interactive command.
pub fn publish_command_state_changed(tab_id: u64, command: Option<String>) {
broadcast(HostNotification::CommandStateChanged { tab_id, command });
}
/// Broadcast drawing changes independently of shared-view subscriptions.
pub fn publish_drawing_changed(tab_id: u64, epoch: u64) {
broadcast(HostNotification::DrawingChanged { tab_id, epoch });
}
/// V4 notification handler installed on the plugin manager.
///
/// Forwards REPL status messages to the log; other notifications are handled
+3
View File
@@ -525,6 +525,9 @@ impl Scene {
let Some(existing) = self.document.get_entity(handle) else {
return false;
};
// IPC snapshots omit storage-only payloads (notably Unknown/Extended
// entities). Preserve them when replacing a clone from a plugin.
entity.preserve_storage_data_from(existing);
// The caller edited a snapshot copy; keep the live entity in its block.
entity.common_mut().owner_handle = existing.common().owner_handle;
+10
View File
@@ -144,6 +144,16 @@ impl Scene {
self.bump_selection_set();
}
/// Script-facing replacement: preserve exactly the validated input order.
/// UI picks still use `select_entity` and its leader/annotation expansion.
pub(crate) fn replace_selection_exact(&mut self, handles: &[Handle]) {
if self.selected_handles_in_order() == handles { return; }
self.selected_constraint = None;
self.selected = handles.iter().copied().collect();
self.selected_order = handles.to_vec();
self.bump_selection_set();
}
pub fn select_all_visible(&mut self) -> usize {
let block = self.interaction_block_handle();
let frozen: Option<HashSet<Handle>> = self