Complete Surface: plane surfaces, extrusions, isolines and moves

Adds SurfaceFromProfile and Extrude kernel operations (doc.solids.surface and
doc.solids.extrude), accepts surfaces in Transform, and maps Surface with
editable isolines. Fixes a real bug found by the test: the surface kind field
overwrote the entity-type kind key in Python dicts, so it is exposed as
surface_kind, with a catalog test guarding against name collisions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 17:47:22 +02:00
1 parent 918351f264
commit 9cd6653a58
14 files changed
+441 -34

No files matched your search

+1
View File
@@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
| ("Helix", _)
| ("RasterImage", _)
| ("Wipeout", _)
| ("Surface", "u_isolines" | "v_isolines")
| ("Ole2Frame", _)
| ("Underlay", _)
| ("Viewport", _)
@@ -502,6 +502,11 @@
],
"Region": [
"layer"
],
"Surface": [
"layer",
"u_isolines",
"v_isolines"
]
},
"readable": {
@@ -627,6 +632,12 @@
"uid",
"point_of_reference",
"history_handle"
],
"Surface": [
"surface_kind",
"modeler_format_version",
"point_of_reference",
"history_handle"
]
},
"aliases": {
@@ -643,6 +654,9 @@
"MText": {
"text": "value",
"insertion": "insertion_point"
},
"Surface": {
"surface_kind": "kind"
}
},
"synthetic": {
@@ -1245,6 +1245,24 @@ fn validate_ole_frame(
Ok(())
}
/// Surface: only the display isoline density is scriptable; the geometry lives
/// in the ACIS payload that the geometry kernel owns.
#[cfg(feature = "host")]
fn validate_surface(
old: Option<&crate::host::acadrust::entities::Surface>,
new: &crate::host::acadrust::entities::Surface,
) -> Result<(), String> {
if old.is_some_and(|old| old.u_isolines == new.u_isolines && old.v_isolines == new.v_isolines) {
return Ok(());
}
for (name, value) in [("u_isolines", new.u_isolines), ("v_isolines", new.v_isolines)] {
if !(0..=200).contains(&value) {
return Err(format!("Surface.{name} must be between 0 and 200"));
}
}
Ok(())
}
/// Validate newly created geometry for the kinds whose mapped fields have
/// host checks. Called by the Python add path before an entity enters the document.
#[cfg(feature = "host")]
@@ -1371,6 +1389,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<()
}
}
EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?,
EntityType::Surface(value) => validate_surface(None, value)?,
EntityType::AttributeDefinition(value) => {
finite_vector(
"AttributeDefinition.insertion_point",
@@ -1745,6 +1764,7 @@ pub fn validate_entity_mutation(
return Err("Ole2Frame embedded storage cannot change".into());
}
}
(EntityType::Surface(old), EntityType::Surface(new)) => validate_surface(Some(old), new)?,
(EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?,
(EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?,
(EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => {
@@ -2486,6 +2506,20 @@ mod tests {
}
}
#[test]
fn exposed_property_names_are_unique_per_kind() {
// A field named like a base key (`kind`, `handle`, `layer`, ...) would
// overwrite it in every Python dict; such fields must be renamed.
let catalog: EntityCoverageCatalog =
serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
for entry in &catalog.entity_kinds {
let mut seen = std::collections::HashSet::new();
for property in entry.properties.iter().filter(|p| p.model_access != ModelAccess::Unmapped) {
assert!(seen.insert(property.name.clone()), "{}: duplicate exposed name {}", entry.kind, property.name);
}
}
}
#[test]
fn unsupported_kinds_have_no_editable_properties() {
let catalog: EntityCoverageCatalog =
@@ -2535,6 +2569,7 @@ mod tests {
| "Solid3D"
| "Body"
| "Region"
| "Surface"
)
{
assert!(
@@ -2824,6 +2859,8 @@ mod tests {
"Ole2Frame.lower_right_corner".to_owned(),
"Ole2Frame.lock_aspect".to_owned(),
"Ole2Frame.is_paper_space".to_owned(),
"Surface.u_isolines".to_owned(),
"Surface.v_isolines".to_owned(),
"Underlay.underlay_type".to_owned(),
"Underlay.definition_handle".to_owned(),
"Underlay.insertion_point".to_owned(),
+5
View File
@@ -90,6 +90,11 @@ pub enum SolidOperation {
/// `layer`, or on the source's layer when `None`; `delete_source` erases
/// the profile afterwards, as the REGION command does.
RegionFromProfile { source: Handle, layer: Option<String>, delete_source: bool },
/// Build a plane surface from one closed planar profile entity.
SurfaceFromProfile { source: Handle, layer: Option<String>, delete_source: bool },
/// Extrude a planar profile along `direction`: a solid when the profile is
/// closed, a surface when it is open.
Extrude { source: Handle, direction: [f64; 3], layer: Option<String>, delete_source: bool },
}
/// Value of a host-managed drafting or document setting exposed to plugins.
@@ -115,6 +115,8 @@ mod tests {
},
SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] },
SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true },
SolidOperation::SurfaceFromProfile { source: acadrust::Handle::new(4), layer: Some("S".into()), delete_source: false },
SolidOperation::Extrude { source: acadrust::Handle::new(5), direction: [0.0, 0.0, 2.0], layer: None, delete_source: true },
] {
let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap();
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),