forked from 1b-agent/opencadstudio-upstream
Add versioned scripting host model and entity coverage
This commit is contained in:
1 parent
ec2271fc9c
commit
11d5614991
22 files changed
+1015
-4
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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(®istry).unwrap();
|
||||
fs::write(out_dir.join("type_registry.json"), json).unwrap();
|
||||
generate_entity_coverage(out_dir, ®istry);
|
||||
}
|
||||
|
||||
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 = ®istry.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 = ®istry.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 = ®istry.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 = ®istry.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>,
|
||||
}
|
||||
@@ -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`].
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(¬ification).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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user