Files
OneCAD-Draft/src/app/plugin_host.rs
T
Felix RiestraandClaude Sonnet 5 7c959c8395 Register scripted image definitions in ACAD_IMAGE_DICT and reuse per file
A scripted RasterImage now reuses one ImageDefinition per file and registers
it in ACAD_IMAGE_DICT, which owns it, as for an AutoCAD-attached image. The
reactor is deliberately not written because its owner convention cannot be
verified. An ignored spike records that OCS's own IMAGE command creates bare
images that lose their file path on save.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 19:00:16 +02:00

6895 lines
344 KiB
Rust

// HostSession — plugin-facing API implemented inside `app` (private field access).
use std::any::Any;
use acadrust::tables::AppId;
use acadrust::xdata::ExtendedDataRecord;
use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi, HostSettingValue};
use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData};
use super::OpenCADStudio;
#[cfg(not(target_arch = "wasm32"))]
use crate::plugin::v4_support;
/// Session adapter: one active document tab, command line, undo.
pub(crate) struct HostSession<'a> {
app: &'a mut OpenCADStudio,
tab: usize,
doc_store: Option<DocumentSnapshotStore<DocumentViewData>>,
}
impl<'a> HostSession<'a> {
pub(crate) fn new(app: &'a mut OpenCADStudio, tab: usize) -> Self {
Self {
app,
tab,
doc_store: None,
}
}
pub fn tab_index(&self) -> usize {
self.tab
}
pub fn tab_id(&self) -> u64 {
self.app.tabs[self.tab].id
}
pub fn document_path(&self, tab_id: u64) -> Option<std::path::PathBuf> {
self.app
.tabs
.iter()
.find(|t| t.id == tab_id)
.and_then(|t| t.current_path.clone())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn document_view_v4(
&mut self,
tab_id: u64,
) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
if tab_id != self.tab_id() {
return None;
}
v4_support::open_document_view_v4(tab_id, self.document())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn close_document_view_v4(&mut self, tab_id: u64) {
if tab_id == self.tab_id() {
v4_support::close_document_view_v4(tab_id);
}
}
pub fn document(&self) -> &CadDocument {
&self.app.tabs[self.tab].scene.document
}
pub fn document_mut(&mut self) -> &mut CadDocument {
&mut self.app.tabs[self.tab].scene.document
}
fn nested_attribute(&self, handle: Handle) -> Option<EntityType> {
self.document().entities().find_map(|entity| {
let EntityType::Insert(insert) = entity else {
return None;
};
insert
.attributes
.iter()
.find(|attribute| attribute.common.handle == handle)
.cloned()
.map(EntityType::AttributeEntity)
})
}
fn host_model_entity(&self, handle: Handle) -> Option<EntityType> {
self.document()
.get_entity(handle)
.cloned()
.or_else(|| self.nested_attribute(handle))
}
fn replace_nested_attribute(&mut self, attribute: acadrust::entities::AttributeEntity) -> bool {
let owner = attribute.common.owner_handle;
let handle = attribute.common.handle;
let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned()
else {
return false;
};
let Some(slot) = insert
.attributes
.iter_mut()
.find(|candidate| candidate.common.handle == handle)
else {
return false;
};
*slot = attribute;
self.app.tabs[self.tab]
.scene
.update_entity(EntityType::Insert(insert))
}
pub fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
if self.doc_store.is_none() {
let mut store =
DocumentSnapshotStore::<DocumentViewData>::new(self.tab as u64, 8 * 1024 * 1024)
.ok()?;
store.publish(&self.document().into()).ok()?;
self.doc_store = Some(store);
}
let store = self.doc_store.as_ref()?;
Some(ocs_plugin_api::shm::DocumentViewInfo {
path: store.path().to_string_lossy().to_string(),
version: store.version(),
})
}
fn publish_document_view(&mut self) {
let doc = &self.app.tabs[self.tab].scene.document;
// Only publish views that have been opened by a consumer. V3 is lazily
// created by document_view(); V4 is tracked per-tab by the V4 manager.
if let Some(store) = self.doc_store.as_mut() {
if let Err(e) = store.publish(&doc.into()) {
eprintln!(
"[host] failed to publish document view for tab {}: {e}",
self.tab
);
}
}
#[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));
}
}
/// Recompute host-derived state for entities a script created or edited.
fn normalize_scripted_entity(
&self,
old: Option<&EntityType>,
entity: &mut EntityType,
) -> Result<(), String> {
if let EntityType::Dimension(dimension) = entity {
crate::entities::dimension::normalize_scripted_dimension(dimension);
}
if let EntityType::Viewport(viewport) = entity {
// A viewport needs an id unique in its layout and at least 2 (id 1
// is the never-drawn sheet viewport, and a lone id 0 can be taken for
// it), and its scale follows its paper height and model view height;
// both are what the MVIEW command sets.
if old.is_none() {
let owner = viewport.common.owner_handle;
let highest = self
.document()
.entities()
.filter_map(|e| match e {
EntityType::Viewport(other) if other.common.owner_handle == owner => Some(other.id),
_ => None,
})
.max()
.unwrap_or(1);
viewport.id = (highest + 1).max(2);
}
viewport.custom_scale = viewport.height / viewport.view_height;
}
if let EntityType::SectionSymbol(symbol) = entity {
// `points` is the canonical geometry: the point counts follow it
// (they equal the point count in every verified export) and the
// end, tick and label projections are refreshed from it. A symbol
// whose points did not change is left exactly as it was read.
let unchanged = matches!(old, Some(EntityType::SectionSymbol(previous)) if previous.points == symbol.points);
if !unchanged {
let count = i32::try_from(symbol.points.len()).unwrap_or(i32::MAX);
symbol.raw_point_count_90 = count;
symbol.raw_point_record_count = count;
symbol.sync_display_fields();
}
}
if let EntityType::Spline(spline) = entity {
// The DXF writer emits weights only for a rational spline, so the
// flag must follow the weights a script sets.
spline.flags.rational = !spline.weights.is_empty();
}
if let EntityType::Helix(new) = entity {
let old = match old {
Some(EntityType::Helix(old)) => Some(old),
_ => None,
};
crate::entities::helix::normalize_scripted_helix(old, new)?;
}
if let EntityType::Table(new) = entity {
let old = match old {
Some(EntityType::Table(old)) => Some(old),
_ => None,
};
crate::entities::table::normalize_scripted_table(old, new);
}
if let EntityType::MLine(new) = entity {
let old = match old {
Some(EntityType::MLine(old)) => Some(old),
_ => None,
};
crate::entities::mline::normalize_scripted_mline(old, new, self.document())?;
}
Ok(())
}
/// The `ACAD_IMAGE_DICT` dictionary under the named-objects root, created
/// when the drawing has none. `None` when the drawing has no root dictionary.
fn ensure_image_dictionary(&mut self) -> Option<Handle> {
use acadrust::objects::{Dictionary, ObjectType};
let root = self.document().header.named_objects_dict_handle;
let Some(ObjectType::Dictionary(root_dictionary)) = self.document().objects.get(&root) else {
return None;
};
if let Some(existing) = root_dictionary.get("ACAD_IMAGE_DICT") {
if matches!(self.document().objects.get(&existing), Some(ObjectType::Dictionary(_))) {
return Some(existing);
}
}
let handle = self.document_mut().allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = handle;
dictionary.owner = root;
self.document_mut().objects.insert(handle, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(root_dictionary)) = self.document_mut().objects.get_mut(&root) {
root_dictionary.add_entry("ACAD_IMAGE_DICT", handle);
}
Some(handle)
}
/// A scripted RasterImage names a file; the host creates the image
/// definition object it needs (pixel size read from the file) and links it.
fn prepare_scripted_raster_image(&mut self, entity: &mut EntityType) -> Result<(), String> {
let EntityType::RasterImage(image) = entity else {
return Ok(());
};
if image.definition_handle.is_some_and(|handle| !handle.is_null()) {
return Ok(());
}
let (width, height) = image::image_dimensions(&image.file_path)
.map_err(|error| format!("cannot read image {:?}: {error}", image.file_path))?;
// One definition per file, as AutoCAD keeps it: reuse an existing one.
let existing = self.document().objects.iter().find_map(|(handle, object)| match object {
acadrust::objects::ObjectType::ImageDefinition(definition)
if definition.file_name == image.file_path => Some(*handle),
_ => None,
});
let handle = match existing {
Some(handle) => handle,
None => {
let handle = self.document_mut().allocate_handle();
let mut definition = acadrust::objects::ImageDefinition::with_dimensions(
image.file_path.clone(),
width,
height,
);
definition.handle = handle;
definition.is_loaded = true;
// Register it in ACAD_IMAGE_DICT under the file's name, and let
// that dictionary own it, like an image AutoCAD attached.
let stem = std::path::Path::new(&image.file_path)
.file_stem()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or("IMAGE")
.to_owned();
if let Some(dictionary) = self.ensure_image_dictionary() {
definition.owner = dictionary;
if let Some(acadrust::objects::ObjectType::Dictionary(entries)) =
self.document_mut().objects.get_mut(&dictionary)
{
let mut key = stem.clone();
let mut suffix = 1;
while entries.get(&key).is_some() {
suffix += 1;
key = format!("{stem}_{suffix}");
}
entries.add_entry(key, handle);
}
}
self.document_mut()
.objects
.insert(handle, acadrust::objects::ObjectType::ImageDefinition(definition));
handle
}
};
image.definition_handle = Some(handle);
// `size` is the image's pixel size, and an untouched clip boundary
// covers the whole image, so both follow the file.
let untouched_clip = image.clip_boundary
== acadrust::entities::ClipBoundary::full_image(image.size.x, image.size.y);
image.size = acadrust::types::Vector2::new(f64::from(width), f64::from(height));
if untouched_clip {
image.clip_boundary = acadrust::entities::ClipBoundary::full_image(
f64::from(width),
f64::from(height),
);
}
Ok(())
}
pub fn add_entity(&mut self, mut entity: EntityType) -> Handle {
if self.prepare_scripted_raster_image(&mut entity).is_err() {
return Handle::NULL;
}
if self.normalize_scripted_entity(None, &mut entity).is_err() {
return Handle::NULL;
}
if let EntityType::AttributeEntity(mut attribute) = entity {
let owner = attribute.common.owner_handle;
let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned()
else {
return Handle::NULL;
};
if attribute.common.handle.is_null() {
attribute.common.handle = self.document_mut().allocate_handle();
}
let handle = attribute.common.handle;
if insert
.attributes
.iter()
.any(|existing| existing.common.handle == handle)
{
return Handle::NULL;
}
insert.attributes.push(attribute);
if !self.app.tabs[self.tab]
.scene
.update_entity(EntityType::Insert(insert))
{
return Handle::NULL;
}
self.publish_document_view();
return handle;
}
let handle = self.app.tabs[self.tab].scene.add_entity(entity);
self.publish_document_view();
handle
}
pub fn add_entities(&mut self, mut entities: Vec<EntityType>) -> Vec<Handle> {
for entity in &mut entities {
let _ = self.prepare_scripted_raster_image(entity);
// Creation input was validated by the caller; a degenerate MLine
// simply keeps its supplied (unnormalized) vertices.
let _ = self.normalize_scripted_entity(None, entity);
}
let handles = self.app.tabs[self.tab].scene.add_entities(entities);
self.publish_document_view();
handles
}
pub fn bump_geometry(&mut self) {
self.app.tabs[self.tab].scene.bump_geometry();
}
/// Replace the entity carrying `entity`'s handle in place, refreshing the
/// scene's derived caches. Returns `false` when no entity has that handle.
pub fn update_entity(&mut self, mut entity: EntityType) -> bool {
let old = self.document().get_entity(entity.common().handle).cloned();
if self.normalize_scripted_entity(old.as_ref(), &mut entity).is_err() {
return false;
}
let ok = match entity {
EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute),
entity => self.app.tabs[self.tab].scene.update_entity(entity),
};
if ok {
self.publish_document_view();
}
ok
}
pub fn update_entities_transaction(
&mut self,
label: &str,
mut 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 &mut 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.host_model_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::bind_canvas_entity_references(self.document(), entity)
.map_err(|error| format!("entity {handle:?}: {error}"))?;
ocs_plugin_api::entity_coverage::validate_entity_mutation(&existing, entity)
.map_err(|error| format!("entity {handle:?}: {error}"))?;
self.normalize_scripted_entity(Some(&existing), entity)
.map_err(|error| format!("entity {handle:?}: {error}"))?;
let lock_handle = if matches!(entity, EntityType::AttributeEntity(_)) {
entity.common().owner_handle
} else {
handle
};
if self.app.tabs[self.tab].scene.is_layer_locked(lock_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.
let updated = match entity {
EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute),
entity => self.app.tabs[self.tab].scene.update_entity(entity),
};
assert!(updated);
}
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).
pub fn remove_entity(&mut self, handle: Handle) -> bool {
if self.document().get_entity(handle).is_none() {
let Some(EntityType::AttributeEntity(attribute)) = self.nested_attribute(handle) else {
return false;
};
let owner = attribute.common.owner_handle;
let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned()
else {
return false;
};
insert
.attributes
.retain(|candidate| candidate.common.handle != handle);
let removed = self.app.tabs[self.tab]
.scene
.update_entity(EntityType::Insert(insert));
if removed {
self.publish_document_view();
}
return removed;
}
self.app.tabs[self.tab].scene.erase_entities(&[handle]);
let removed = self.document().get_entity(handle).is_none();
if removed {
self.publish_document_view();
}
removed
}
// ── XDATA convenience ──────────────────────────────────────────────────
// Plugins persist domain data as XDATA on plain entities so it round-trips
// through DWG/DXF. These wrap the `acadrust::xdata` API keyed by entity
// handle and keep the APPID table in sync.
/// Read the XDATA record for `app_name` attached to entity `handle`, if any.
pub fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> {
self.document()
.get_entity(handle)?
.common()
.extended_data
.get_record(app_name)
}
/// Attach `record` to entity `handle`, replacing any existing record for the
/// same application. Registers the application in the APPID table when
/// missing so the file stays valid for other CAD apps. Returns `false` when
/// the entity does not exist.
pub fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool {
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return false;
}
let app = record.application_name.clone();
self.ensure_app_id(&app);
let app_handle = self.document().app_ids.get(&app).map(|a| a.handle.value());
let Some(entity) = self.document_mut().get_entity_mut(handle) else {
return false;
};
let xd = &mut entity.common_mut().extended_data;
// Drop any existing record for this app, then append the new one.
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != app)
.cloned()
.collect();
xd.clear();
for r in kept {
xd.add_record(r);
}
xd.add_record(record);
// Drop stale verbatim EED for this app so the fresh record — not the
// pre-edit bytes captured on a prior read — wins on the next save.
// Otherwise a plugin's edit made after a save/reopen (which registered
// the app in `raw_dwg_eed`) would not persist.
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
}
/// Remove the XDATA record for `app_name` from entity `handle`. Returns
/// `true` when a record was actually removed.
pub fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool {
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return false;
}
let app_handle = self
.document()
.app_ids
.get(app_name)
.map(|a| a.handle.value());
let Some(entity) = self.document_mut().get_entity_mut(handle) else {
return false;
};
let xd = &mut entity.common_mut().extended_data;
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != app_name)
.cloned()
.collect();
let removed_record = kept.len() != xd.records().len();
// Also drop verbatim EED for this app so the removal persists across a
// save (a record read back from DWG lives in `raw_dwg_eed`).
let removed_raw = app_handle
.map(|ah| xd.raw_dwg_eed.iter().any(|(a, _)| *a == ah))
.unwrap_or(false);
if !removed_record && !removed_raw {
return false;
}
xd.clear();
for r in kept {
xd.add_record(r);
}
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
}
/// Register `name` in the APPID table if it is not already present, so XDATA
/// written under it survives a DWG/DXF round-trip. The entry is given a real
/// handle — a null-handle APPID is written as handle 0, which the DWG EED
/// reference then can't resolve, so the XDATA would be dropped on reopen.
fn ensure_app_id(&mut self, name: &str) {
let doc = self.document_mut();
if !doc.app_ids.contains(name) {
let mut app = AppId::new(name);
app.handle = doc.allocate_handle();
let _ = doc.app_ids.add(app);
}
}
/// Kernel-backed solid create or transform. Every result is verified by
/// `solid_to_sat` (the exported payload must lift back with no loss) and an
/// existing payload is only transformed when it lifts losslessly, so a
/// failure never changes the document.
pub fn solid_operation(
&mut self,
operation: ocs_plugin_api::host::SolidOperation,
) -> Result<Handle, String> {
use crate::scene::model::solid_model as model;
use ocs_plugin_api::host::{SolidOperation, SolidPrimitive};
let positive = |name: &str, value: f64| {
if value.is_finite() && value > 0.0 {
Ok(())
} else {
Err(format!("{name} must be finite and greater than zero"))
}
};
let finite3 = |name: &str, value: [f64; 3]| {
if value.iter().all(|v| v.is_finite()) {
Ok(())
} else {
Err(format!("{name} must be finite"))
}
};
match operation {
SolidOperation::Create { primitive, layer } => {
let body = match primitive {
SolidPrimitive::Box { center, size } => {
finite3("box center", center)?;
for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] {
positive(&format!("box {name}"), v)?;
}
model::box_solid(center, size[0], size[1], size[2])
}
SolidPrimitive::Wedge { origin, size } => {
finite3("wedge origin", origin)?;
for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] {
positive(&format!("wedge {name}"), v)?;
}
model::wedge_solid(origin, size[0], size[1], size[2])
}
SolidPrimitive::Cylinder { center, radius, height } => {
finite3("cylinder center", center)?;
positive("cylinder radius", radius)?;
positive("cylinder height", height)?;
model::cylinder_solid(center, radius, height)
}
SolidPrimitive::Sphere { center, radius } => {
finite3("sphere center", center)?;
positive("sphere radius", radius)?;
model::sphere_solid(center, radius)
}
SolidPrimitive::Torus { center, major, minor } => {
finite3("torus center", center)?;
positive("torus major radius", major)?;
positive("torus minor radius", minor)?;
if minor >= major {
return Err("torus minor radius must be smaller than the major radius".into());
}
model::torus_solid(center, major, minor)
}
SolidPrimitive::Pyramid { center, radius, height, sides } => {
finite3("pyramid center", center)?;
positive("pyramid radius", radius)?;
positive("pyramid height", height)?;
if !(3..=1024).contains(&sides) {
return Err("pyramid sides must be between 3 and 1024".into());
}
model::pyramid_solid(center, radius, height, sides as usize)
}
}
.ok_or("the geometry kernel refused these dimensions")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
.ok_or("the solid could not be exported losslessly")?;
let mut solid = acadrust::entities::Solid3D::new();
solid.wires = model::edge_wires(&body);
solid.set_sat_document(&sat);
if let Some(layer) = layer {
if layer.trim().is_empty() {
return Err("layer name is empty".into());
}
solid.common.layer = layer;
}
self.push_undo("Create solid");
let handle = self.add_entity(EntityType::Solid3D(solid));
if handle.is_null() {
return Err("the solid could not be added".into());
}
Ok(handle)
}
SolidOperation::RegionFromProfile { source, layer, delete_source } => {
let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?;
if matches!(entity, EntityType::Region(_)) {
return Err("the source is already a region".into());
}
if !closed {
return Err("the profile must be closed".into());
}
let body = cadkernel::brep::planar_region(plane, &loops)
.ok_or("the geometry kernel could not build a region from this profile")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
.ok_or("the region could not be exported losslessly")?;
let mut region = acadrust::entities::Region::new();
region.point_of_reference =
acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]);
region.wires = model::edge_wires(&body);
region.set_sat_document(&sat);
region.common.layer = layer;
self.commit_profile_result(EntityType::Region(region), source, delete_source, "Create region")
}
SolidOperation::SurfaceFromProfile { source, layer, delete_source } => {
let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?;
if matches!(entity, EntityType::Region(_)) {
return Err("the source is already a region".into());
}
if !closed {
return Err("the profile must be closed".into());
}
let body = cadkernel::brep::planar_region(plane, &loops)
.ok_or("the geometry kernel could not build a surface from this profile")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
.ok_or("the surface could not be exported losslessly")?;
let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane);
surface.point_of_reference =
acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]);
surface.wires = model::edge_wires(&body);
surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string());
surface.common.layer = layer;
self.commit_profile_result(EntityType::Surface(surface), source, delete_source, "Create surface")
}
SolidOperation::Extrude { source, direction, layer, delete_source } => {
if direction.iter().any(|v| !v.is_finite()) {
return Err("extrusion direction must be finite".into());
}
if direction.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-9 {
return Err("extrusion direction must be nonzero".into());
}
let (entity, _plane, _loops, closed, layer) = self.profile_source(source, layer, delete_source)?;
if matches!(entity, EntityType::Region(_)) {
return Err("extrude a curve profile; a region cannot be extruded here".into());
}
let body = crate::scene::model::presspull_model::extrusion_body(&entity, direction)
.ok_or("the geometry kernel could not extrude this profile in that direction")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
.ok_or("the extrusion could not be exported losslessly")?;
let result = if closed {
let mut solid = acadrust::entities::Solid3D::new();
solid.wires = model::edge_wires(&body);
solid.set_sat_document(&sat);
solid.common.layer = layer;
EntityType::Solid3D(solid)
} else {
// An open profile sweeps to a surface. The generic kind
// carries only the payload, as OCS does after a boolean.
let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Generic);
surface.wires = model::edge_wires(&body);
surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string());
surface.common.layer = layer;
EntityType::Surface(surface)
};
self.commit_profile_result(result, source, delete_source, "Extrude profile")
}
SolidOperation::EmbedPicture { path, origin, width, layer } => {
finite3("picture origin", origin)?;
positive("picture width", width)?;
if layer.as_ref().is_some_and(|name| name.trim().is_empty()) {
return Err("layer name is empty".into());
}
let picture = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path))
.map_err(|error| format!("cannot read the picture {path:?}: {error}"))?;
let (upper_left, lower_right) = crate::io::ole_embed::corners_from_placement(
acadrust::types::Vector3::new(origin[0], origin[1], origin[2]),
width,
picture.aspect(),
);
let mut frame = crate::io::ole_embed::build_embedded_ole(&picture, upper_left, lower_right);
if let Some(layer) = layer {
frame.common.layer = layer;
}
self.push_undo("Embed picture");
let handle = self.add_entity(EntityType::Ole2Frame(frame));
if handle.is_null() {
return Err("the picture frame could not be added".into());
}
Ok(handle)
}
SolidOperation::Boolean { first, second, operation, layer, keep_operands } => {
use ocs_plugin_api::host::SolidBoolean;
if first == second {
return Err("a solid cannot be combined with itself".into());
}
if layer.as_ref().is_some_and(|name| name.trim().is_empty()) {
return Err("layer name is empty".into());
}
let operand = |host: &Self, handle: Handle, which: &str| -> Result<acadrust::entities::Solid3D, String> {
match host.document().get_entity(handle) {
Some(EntityType::Solid3D(solid)) => Ok(solid.clone()),
Some(_) => Err(format!("the {which} operand must be a Solid3D")),
None => Err(format!("entity {handle:?} does not exist")),
}
};
let a = operand(self, first, "first")?;
let b = operand(self, second, "second")?;
if !keep_operands {
for handle in [first, second] {
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return Err(format!("entity {handle:?} is on a locked layer"));
}
}
}
let lift = |solid: &acadrust::entities::Solid3D, which: &str| {
crate::scene::convert::solid3d_tess::kernel_acis_body(&solid.acis_data).ok_or_else(|| {
format!("the {which} solid's payload cannot be lifted losslessly, so nothing was changed")
})
};
let (body_a, body_b) = (lift(&a, "first")?, lift(&b, "second")?);
let kind = match operation {
SolidBoolean::Union => model::Bool::Union,
SolidBoolean::Subtract => model::Bool::Subtract,
SolidBoolean::Intersect => model::Bool::Intersect,
};
let combined = model::boolean_result(kind, &body_a, &body_b).map_err(|snag| {
let reason = match snag {
cadkernel::brep::Snag::Coincident => "two faces lie on the same surface, which the kernel cannot resolve",
cadkernel::brep::Snag::CutRefused => "a face could not be cut along the intersection curve",
cadkernel::brep::Snag::NoClosedForm => "the surfaces meet along a curve the kernel has no closed form for",
};
format!("the geometry kernel refused this {operation:?} ({snag:?}): {reason}; nothing was changed")
})?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&combined)
.ok_or("the result could not be exported losslessly, so nothing was changed")?;
let mut solid = acadrust::entities::Solid3D::new();
solid.common = a.common.clone();
solid.common.handle = Handle::NULL;
solid.common.owner_handle = Handle::NULL;
solid.wires = model::edge_wires(&combined);
solid.set_sat_document(&sat);
if let Some(layer) = layer {
solid.common.layer = layer;
}
self.push_undo("Boolean solids");
let handle = self.add_entity(EntityType::Solid3D(solid));
if handle.is_null() {
return Err("the result could not be added".into());
}
if !keep_operands {
self.app.tabs[self.tab].scene.erase_entities(&[first, second]);
self.publish_document_view();
}
Ok(handle)
}
SolidOperation::Transform { handle, matrix } => {
if matrix.iter().any(|v| !v.is_finite()) {
return Err("transform matrix must be finite".into());
}
let column = |i: usize| [matrix[i], matrix[i + 1], matrix[i + 2]];
let (x, y, z) = (column(0), column(4), column(8));
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let rigid = [x, y, z].iter().all(|c| (dot(*c, *c) - 1.0).abs() < 1e-6)
&& dot(x, y).abs() < 1e-6
&& dot(x, z).abs() < 1e-6
&& dot(y, z).abs() < 1e-6
&& matrix[3].abs() < 1e-9
&& matrix[7].abs() < 1e-9
&& matrix[11].abs() < 1e-9
&& (matrix[15] - 1.0).abs() < 1e-9;
if !rigid {
return Err("only rigid moves and mirrors are supported; scaling and shear are not".into());
}
let mut entity = self
.document()
.get_entity(handle)
.cloned()
.ok_or_else(|| format!("entity {handle:?} does not exist"))?;
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return Err(format!("entity {handle:?} is on a locked layer"));
}
macro_rules! retarget {
($value:expr) => {{
let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&$value.acis_data)
.ok_or("this body's payload cannot be lifted losslessly, so it is left untouched")?;
let moved = model::by_matrix(&body, matrix)
.ok_or("the geometry kernel refused this transform")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&moved)
.ok_or("the moved body could not be exported losslessly")?;
$value.wires = model::edge_wires(&moved);
$value.silhouettes.clear();
$value.history_handle = None;
$value.set_sat_document(&sat);
}};
}
match &mut entity {
EntityType::Solid3D(value) => retarget!(value),
EntityType::Body(value) => retarget!(value),
EntityType::Region(value) => retarget!(value),
EntityType::Surface(value) => {
let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&value.acis_data)
.ok_or("this surface's payload cannot be lifted losslessly, so it is left untouched")?;
let moved = model::by_matrix(&body, matrix)
.ok_or("the geometry kernel refused this transform")?;
let sat = crate::scene::convert::acis_export::solid_to_sat(&moved)
.ok_or("the moved surface could not be exported losslessly")?;
value.wires = model::edge_wires(&moved);
value.silhouettes.clear();
value.history_handle = None;
value.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string());
// A moved plane is still a plane; a swept surface no longer
// matches its stored sweep parameters, so it becomes generic.
if value.kind != acadrust::entities::SurfaceKind::Plane {
value.kind = acadrust::entities::SurfaceKind::Generic;
value.surface_data = acadrust::entities::SurfaceData::Generic;
}
}
_ => return Err("transform applies to Solid3D, Body, Region and Surface entities".into()),
}
self.push_undo("Transform solid");
if !self.update_entity(entity) {
return Err(format!("entity {handle:?} could not be updated"));
}
Ok(handle)
}
}
}
/// Look up a profile entity and its exact planar loops for the kernel.
/// Returns the entity, its plane, loops and closed flag, and the layer the
/// result should use (`layer`, or the profile's own).
#[allow(clippy::type_complexity)]
fn profile_source(
&self,
source: Handle,
layer: Option<String>,
delete_source: bool,
) -> Result<(EntityType, cadkernel::space::Plane, Vec<Vec<cadkernel::geom2d::Curve>>, bool, String), String> {
if layer.as_ref().is_some_and(|name| name.trim().is_empty()) {
return Err("layer name is empty".into());
}
let entity = self
.document()
.get_entity(source)
.cloned()
.ok_or_else(|| format!("entity {source:?} does not exist"))?;
if delete_source && self.app.tabs[self.tab].scene.is_layer_locked(source) {
return Err(format!("entity {source:?} is on a locked layer"));
}
if matches!(entity, EntityType::Solid3D(_) | EntityType::Body(_) | EntityType::Surface(_)) {
return Err("the source must be a curve profile, not an existing solid or surface".into());
}
let (plane, loops, closed) = crate::scene::model::presspull_model::profile_geometry(&entity)
.ok_or("the source is not a planar profile")?;
let layer = layer.unwrap_or_else(|| entity.common().layer.clone());
Ok((entity, plane, loops, closed, layer))
}
/// Record one undo step, add `result` and optionally erase the profile.
fn commit_profile_result(
&mut self,
result: EntityType,
source: Handle,
delete_source: bool,
label: &str,
) -> Result<Handle, String> {
self.push_undo(label);
let handle = self.add_entity(result);
if handle.is_null() {
return Err("the result could not be added".into());
}
if delete_source {
self.app.tabs[self.tab].scene.erase_entities(&[source]);
self.publish_document_view();
}
Ok(handle)
}
pub fn push_undo(&mut self, label: &str) {
self.app.push_undo_snapshot(self.tab, label);
}
pub fn set_dirty(&mut self) {
self.app.tabs[self.tab].dirty = true;
}
pub fn system_variable(&self, name: &str) -> Option<HostSettingValue> {
match name.to_ascii_uppercase().as_str() {
"CLAYER" => Some(HostSettingValue::Text(
self.document().header.current_layer_name.clone(),
)),
"SNAPANG" => Some(HostSettingValue::Number(self.app.snap_angle_deg as f64)),
_ => None,
}
}
pub fn set_system_variable(
&mut self,
name: &str,
value: HostSettingValue,
) -> Result<HostSettingValue, String> {
match (name.to_ascii_uppercase().as_str(), value) {
("CLAYER", HostSettingValue::Text(layer)) => {
self.app.set_current_layer_name(self.tab, &layer)?;
Ok(HostSettingValue::Text(layer))
}
("SNAPANG", HostSettingValue::Number(angle)) if (angle as f32).is_finite() => {
self.app.snap_angle_deg = (angle as f32).rem_euclid(360.0);
Ok(HostSettingValue::Number(self.app.snap_angle_deg as f64))
}
("SNAPANG", HostSettingValue::Number(_)) => {
Err("SNAPANG: finite number required".to_owned())
}
("CLAYER", _) | ("SNAPANG", _) => {
Err(format!("{}: wrong value type", name.to_ascii_uppercase()))
}
_ => Err(format!("unsupported system variable {name:?}")),
}
}
pub fn push_info(&mut self, msg: &str) {
self.app.command_line.push_info(msg);
}
pub fn push_output(&mut self, msg: &str) {
self.app.command_line.push_output(msg);
}
pub fn push_error(&mut self, msg: &str) {
self.app.command_line.push_error(msg);
}
}
/// The stable contract a plugin's `dispatch` sees. Each method forwards to the
/// inherent `HostSession` method of the same name (inherent methods take
/// resolution priority, so this is plain delegation, not recursion). The
/// per-tab plugin-state accessors expose the raw `Any` box; the typed
/// `ocs_plugin_api::host::plugin_state*` helpers wrap them.
impl HostApi for HostSession<'_> {
fn tab_index(&self) -> usize {
self.tab_index()
}
fn document(&self) -> &CadDocument {
self.document()
}
fn document_mut(&mut self) -> &mut CadDocument {
self.document_mut()
}
fn add_entity(&mut self, entity: EntityType) -> Handle {
self.add_entity(entity)
}
fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
self.add_entities(entities)
}
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 solid_operation(
&mut self,
operation: ocs_plugin_api::host::SolidOperation,
) -> Result<Handle, String> {
self.solid_operation(operation)
}
fn remove_entity(&mut self, handle: Handle) -> bool {
self.remove_entity(handle)
}
fn bump_geometry(&mut self) {
self.bump_geometry()
}
fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> {
self.read_record(handle, app_name)
}
fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool {
self.write_record(handle, record)
}
fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool {
self.remove_record(handle, app_name)
}
fn push_undo(&mut self, label: &str) {
self.push_undo(label)
}
fn set_dirty(&mut self) {
self.set_dirty()
}
fn push_info(&mut self, msg: &str) {
self.push_info(msg)
}
fn push_output(&mut self, msg: &str) {
self.push_output(msg)
}
fn push_error(&mut self, msg: &str) {
self.push_error(msg)
}
fn start_interactive(&mut self, command: Box<dyn ocs_plugin_api::host::InteractiveCommand>) {
self.app.tabs[self.tab].active_cmd =
Some(Box::new(PluginInteractiveAdapter { inner: command }));
}
fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> {
self.app.tabs[self.tab]
.plugin_state
.get(plugin_id)
.map(|b| b.as_ref())
}
fn plugin_state_any_mut(&mut self, plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> {
self.app.tabs[self.tab]
.plugin_state
.get_mut(plugin_id)
.map(|b| b.as_mut())
}
fn ensure_plugin_state_any(
&mut self,
plugin_id: &'static str,
init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
) -> &mut (dyn Any + Send + Sync) {
self.app.tabs[self.tab]
.plugin_state
.entry(plugin_id)
.or_insert_with(|| init())
.as_mut()
}
fn document_reader(&self) -> Box<dyn ocs_plugin_api::host::DocumentReader + '_> {
Box::new(ocs_plugin_api::host::CadDocumentReader(self.document()))
}
fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
self.document_view()
}
fn tab_id(&self) -> u64 {
self.tab_id()
}
fn document_path(&self, tab_id: u64) -> Option<std::path::PathBuf> {
self.document_path(tab_id)
}
fn system_variable(&self, name: &str) -> Option<HostSettingValue> {
self.system_variable(name)
}
fn set_system_variable(
&mut self,
name: &str,
value: HostSettingValue,
) -> Result<HostSettingValue, String> {
self.set_system_variable(name, value)
}
#[cfg(not(target_arch = "wasm32"))]
fn document_view_v4(&mut self, tab_id: u64) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
self.document_view_v4(tab_id)
}
#[cfg(not(target_arch = "wasm32"))]
fn close_document_view_v4(&mut self, tab_id: u64) {
self.close_document_view_v4(tab_id)
}
}
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
/// to the host's internal `CadCommand`, so a plugin tool drives the host's
/// point-collection flow (viewport clicks or `--serve` coordinates) just like a
/// built-in tool.
struct PluginInteractiveAdapter {
inner: Box<dyn ocs_plugin_api::host::InteractiveCommand>,
}
impl crate::command::CadCommand for PluginInteractiveAdapter {
fn name(&self) -> &'static str {
"PLUGIN"
}
// Every call into the plugin runs under a panic guard (#145): a buggy plugin
// that panics mid-command leaves the host running — the command just ends.
fn prompt(&self) -> String {
crate::plugin::guard("InteractiveCommand::prompt", || self.inner.prompt())
.unwrap_or_default()
}
fn on_point(&mut self, pt: glam::DVec3) -> crate::command::CmdResult {
crate::plugin::guard("InteractiveCommand::on_point", || {
self.inner.on_point([pt.x as f64, pt.y as f64, pt.z as f64])
})
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel)
}
fn on_enter(&mut self) -> crate::command::CmdResult {
crate::plugin::guard("InteractiveCommand::on_enter", || self.inner.on_enter())
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel)
}
fn needs_entity_pick(&self) -> bool {
crate::plugin::guard("InteractiveCommand::needs_object_pick", || {
self.inner.needs_object_pick()
})
.unwrap_or(false)
}
fn on_entity_pick(&mut self, handle: Handle, pt: glam::DVec3) -> crate::command::CmdResult {
crate::plugin::guard("InteractiveCommand::on_object_pick", || {
self.inner
.on_object_pick(handle, [pt.x as f64, pt.y as f64, pt.z as f64])
})
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel)
}
}
/// Bridges an out-of-process plugin's interactive command to the host's
/// `CadCommand`. Events are sent over IPC and the returned `CommandStep` is
/// translated into a `CmdResult`. Prompt and object-pick mode are cached and
/// refreshed after each event.
pub(crate) struct PluginProcessInteractiveAdapter {
pub process: std::sync::Arc<ocs_plugin_api::process::PluginProcess>,
pub command_id: u64,
prompt: Option<String>,
needs_entity_pick: Option<bool>,
}
impl PluginProcessInteractiveAdapter {
pub(crate) fn new(
process: std::sync::Arc<ocs_plugin_api::process::PluginProcess>,
command_id: u64,
) -> Self {
let prompt = process.get_prompt(command_id).ok();
let needs_entity_pick = process.needs_entity_pick(command_id).ok();
Self {
process,
command_id,
prompt,
needs_entity_pick,
}
}
fn refresh(&mut self) {
self.prompt = self.process.get_prompt(self.command_id).ok();
self.needs_entity_pick = self.process.needs_entity_pick(self.command_id).ok();
}
}
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"
}
fn prompt(&self) -> String {
self.prompt.clone().unwrap_or_default()
}
fn on_point(&mut self, pt: glam::DVec3) -> crate::command::CmdResult {
use ocs_plugin_api::ipc::protocol::InteractiveEvent;
let result = self
.process
.interactive_event(self.command_id, InteractiveEvent::Point([pt.x, pt.y, pt.z]))
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel);
self.refresh();
result
}
fn on_enter(&mut self) -> crate::command::CmdResult {
use ocs_plugin_api::ipc::protocol::InteractiveEvent;
let result = self
.process
.interactive_event(self.command_id, InteractiveEvent::Enter)
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel);
self.refresh();
result
}
fn needs_entity_pick(&self) -> bool {
self.needs_entity_pick.unwrap_or(false)
}
fn on_entity_pick(&mut self, handle: Handle, pt: glam::DVec3) -> crate::command::CmdResult {
use ocs_plugin_api::ipc::protocol::InteractiveEvent;
let result = self
.process
.interactive_event(
self.command_id,
InteractiveEvent::ObjectPick {
handle,
pt: [pt.x, pt.y, pt.z],
},
)
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel);
self.refresh();
result
}
}
fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::command::CmdResult {
use crate::command::CmdResult;
use ocs_plugin_api::host::CommandStep;
match step {
CommandStep::NeedPoint => CmdResult::NeedPoint,
CommandStep::Commit(e) => CmdResult::CommitEntity(e),
CommandStep::CommitAndEnd(e) => CmdResult::CommitAndExit(e),
CommandStep::Done | CommandStep::Cancel => CmdResult::Cancel,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::OpenCADStudio;
use crate::entities::traits::RenderConvertible;
use acadrust::entities::{Line, Point};
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 nested_insert_attribute_crud_uses_parent_storage_and_undo() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let (insert_handle, attribute_handle);
{
let mut host = HostSession::new(&mut app, 0);
let next = host.document().next_handle();
let record_handle = Handle::new(next);
let block_handle = Handle::new(next + 1);
let block_end_handle = Handle::new(next + 2);
let mut record = acadrust::tables::BlockRecord::new("TAGBLOCK");
record.handle = record_handle;
record.block_entity_handle = block_handle;
record.block_end_handle = block_end_handle;
host.document_mut().block_records.add(record).unwrap();
let mut block =
acadrust::entities::Block::new("TAGBLOCK", acadrust::types::Vector3::ZERO);
block.common.handle = block_handle;
block.common.owner_handle = record_handle;
host.document_mut()
.add_entity(EntityType::Block(block))
.unwrap();
let mut end = acadrust::entities::BlockEnd::new();
end.common.handle = block_end_handle;
end.common.owner_handle = record_handle;
host.document_mut()
.add_entity(EntityType::BlockEnd(end))
.unwrap();
let mut definition = acadrust::entities::AttributeDefinition::new(
"PART_NO".into(),
"Part number".into(),
"PN-001".into(),
);
definition.common.owner_handle = record_handle;
let definition_handle = host
.document_mut()
.add_entity(EntityType::AttributeDefinition(definition))
.unwrap();
insert_handle = host
.document_mut()
.add_entity(EntityType::Insert(acadrust::entities::Insert::new(
"TAGBLOCK",
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
)))
.unwrap();
let mut attribute =
acadrust::entities::AttributeEntity::new("PART_NO".into(), "PN-101".into());
attribute.common.owner_handle = insert_handle;
attribute.attdef_handle = definition_handle;
attribute.insertion_point = acadrust::types::Vector3::new(10.0, 2.0, 0.0);
host.push_undo("Create attribute");
attribute_handle = host.add_entity(EntityType::AttributeEntity(attribute));
assert!(!attribute_handle.is_null());
assert!(
host.document().get_entity(attribute_handle).is_none(),
"nested attributes must not become orphan flat entities"
);
assert!(matches!(host.host_model_entity(attribute_handle),
Some(EntityType::AttributeEntity(value)) if value.value == "PN-101"));
let view = ocs_plugin_api::shm::DocumentViewDataV4::from(host.document());
assert!(view
.entities
.iter()
.any(|entity| entity.handle == attribute_handle.value()));
let mut changed = host.host_model_entity(attribute_handle).unwrap();
let EntityType::AttributeEntity(value) = &mut changed else {
unreachable!()
};
value.value = "PN-102".into();
value.insertion_point.x = 12.0;
host.update_entities_transaction("Edit attribute", vec![changed])
.unwrap();
assert!(matches!(host.host_model_entity(attribute_handle),
Some(EntityType::AttributeEntity(value))
if value.value == "PN-102" && value.insertion_point.x == 12.0));
host.push_undo("Delete attribute");
assert!(host.remove_entity(attribute_handle));
assert!(host.host_model_entity(attribute_handle).is_none());
}
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(insert_handle),
Some(EntityType::Insert(insert)) if insert.attributes.len() == 1
&& insert.attributes[0].value == "PN-102")
);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(insert_handle),
Some(EntityType::Insert(insert)) if insert.attributes[0].value == "PN-101")
);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(insert_handle),
Some(EntityType::Insert(insert)) if insert.attributes.is_empty())
);
app.redo_steps(3);
assert!(
matches!(app.tabs[0].scene.document.get_entity(insert_handle),
Some(EntityType::Insert(insert)) if insert.attributes.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)
);
app.redo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0)
);
assert!(
matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 20.0)
);
}
/// Opt-in end-to-end check using the staged Python cdylib and the actual
/// OCS executable as its out-of-process runner. Run with OCS_TEST_PYTHON_PLUGIN
/// and OCS_PLUGIN_RUNNER_EXE set; unlike the local-socket protocol test,
/// this executes Python and calls the app's HostSession over real IPC.
#[test]
fn staged_python_plugin_line_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let plugin_path = std::path::PathBuf::from(plugin_path);
assert!(
plugin_path.is_file(),
"missing staged Python plugin: {}",
plugin_path.display()
);
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
&plugin_path,
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.expect("spawn staged Python plugin");
assert_eq!(process.id(), "opencad.python");
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process
.dispatch(host, command, &mut |_| {})
.expect("Python dispatch"));
};
dispatch(
&mut host,
concat!(
"PY_EVAL ocs.active_document.create_entity('Line',",
"start={'x':0.0,'y':0.0,'z':0.0},",
"end={'x':1.0,'y':0.0,'z':0.0}).handle"
),
);
let handles: Vec<_> = host
.document()
.entities()
.map(|entity| entity.common().handle)
.collect();
assert_eq!(handles.len(), 1, "Python add did not reach the host");
let handle = handles[0];
assert!(
matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0)
);
let before_edit = host.document().get_entity(handle).unwrap().clone();
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Move line', [{{'handle':{},'end':{{'x':5.0,'y':0.0,'z':0.0}}}}])",
handle.value()
));
let mut expected_after_edit = before_edit;
let EntityType::Line(expected_line) = &mut expected_after_edit else {
unreachable!()
};
expected_line.end.x = 5.0;
assert_eq!(
host.document().get_entity(handle),
Some(&expected_after_edit),
"partial Python edit must preserve every unmentioned common and geometry field"
);
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject NaN', [{{'handle':{},'start':{{'x':float('nan'),'y':0.0,'z':0.0}}}}])",
handle.value()
));
assert_eq!(
host.document().get_entity(handle),
Some(&expected_after_edit),
"invalid geometry changed the line"
);
assert!(host
.app
.command_line
.history
.last()
.unwrap()
.text
.contains("finite coordinates"));
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject duplicate', [{{'handle':{},'end':{{'x':8.0,'y':0.0,'z':0.0}}}},{{'handle':{},'end':{{'x':9.0,'y':0.0,'z':0.0}}}}])",
handle.value(), handle.value()
));
assert!(
matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0),
"rejected batch changed the line"
);
let dwg_bytes =
acadrust::DwgWriter::write_to_vec(host.document()).expect("write edited DWG");
let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes))
.read()
.expect("reopen edited DWG");
assert!(
matches!(dwg_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)
);
let dxf_bytes = acadrust::DxfWriter::new(host.document())
.write_to_vec()
.expect("write edited DXF");
let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes))
.expect("open edited DXF")
.read()
.expect("reopen edited DXF");
assert!(
matches!(dxf_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)
);
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.active_document.delete_entity({})",
handle.value()
),
);
assert!(
host.document().get_entity(handle).is_none(),
"Python removal did not reach the host"
);
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(
app.tabs[0].history.undo_stack.len(),
3,
"create, edit, and delete should each form one undo entry"
);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)
);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0)
);
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_tolerance_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.expect("spawn staged Python plugin");
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process
.dispatch(host, command, &mut |_| {})
.expect("Python dispatch"));
};
dispatch(
&mut host,
concat!(
"PY_EVAL ocs.active_document.create_entity('Tolerance',",
"insertion_point={'x':1.0,'y':2.0,'z':0.0},",
"text='POSITION%%v0.1').handle"
),
);
let handle = host
.document()
.entities()
.next()
.expect("Python created a Tolerance")
.common()
.handle;
let Some(EntityType::Tolerance(created)) = host.document().get_entity(handle) else {
panic!("expected Tolerance");
};
assert_eq!(
created.insertion_point,
acadrust::types::Vector3::new(1.0, 2.0, 0.0)
);
assert_eq!(created.dimension_style_name, "Standard");
let created = created.clone();
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.active_document.entities[{}].text",
handle.value()
),
);
assert!(host
.app
.command_line
.history
.last()
.unwrap()
.text
.contains("POSITION%%v0.1"));
let script_path = std::env::temp_dir().join(format!(
"ocs_tolerance_document_model_{}.py",
std::process::id()
));
std::fs::write(
&script_path,
format!(
concat!(
"doc = ocs.active_document\n",
"tolerance = doc.entities[{}]\n",
"with doc.transaction('Edit tolerance'):\n",
" tolerance.insertion_point = (4.0, 5.0, 0.0)\n",
" tolerance.direction = (0.0, 1.0, 0.0)\n",
" tolerance.text = 'POSITION%%v0.2'\n",
"doc.selection = [tolerance]\n"
),
handle.value()
),
)
.unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script_path.display()));
let _ = std::fs::remove_file(&script_path);
let expected = host.document().get_entity(handle).unwrap().clone();
let EntityType::Tolerance(edited) = &expected else {
unreachable!()
};
assert_eq!(
edited.insertion_point,
acadrust::types::Vector3::new(4.0, 5.0, 0.0)
);
assert_eq!(edited.direction, acadrust::types::Vector3::UNIT_Y);
assert_eq!(edited.text, "POSITION%%v0.2");
assert_eq!(edited.common, created.common);
assert_eq!(edited.normal, created.normal);
assert_eq!(edited.dimension_style_name, created.dimension_style_name);
assert_eq!(
edited.dimension_style_handle,
created.dimension_style_handle
);
assert_eq!(edited.text_height, created.text_height);
assert_eq!(edited.dimension_gap, created.dimension_gap);
assert_eq!(edited.dwg_unknown_short, created.dwg_unknown_short);
assert_eq!(
host.selection(),
vec![handle],
"Python selection did not reach the canvas"
);
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject direction', [{{'handle':{},'direction':{{'x':2.0,'y':0.0,'z':0.0}}}}])",
handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected));
assert!(host
.app
.command_line
.history
.last()
.unwrap()
.text
.contains("unit vector"));
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'dimension_style_name':'Missing'}}])",
handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected));
assert!(host
.app
.command_line
.history
.last()
.unwrap()
.text
.contains("does not exist"));
let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap();
let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes))
.read()
.unwrap();
assert!(
matches!(dwg_doc.get_entity(handle), Some(EntityType::Tolerance(value))
if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2")
);
let dxf_bytes = acadrust::DxfWriter::new(host.document())
.write_to_vec()
.unwrap();
let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes))
.unwrap()
.read()
.unwrap();
assert!(
matches!(dxf_doc.get_entity(handle), Some(EntityType::Tolerance(value))
if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2")
);
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.active_document.delete_entity({})",
handle.value()
),
);
assert!(host.document().get_entity(handle).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(
app.tabs[0].scene.document.get_entity(handle),
Some(&expected)
);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Tolerance(value))
if value.insertion_point.x == 1.0 && value.text == "POSITION%%v0.1")
);
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_shape_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let shx_path =
std::env::temp_dir().join(format!("ocs_host_shape_{}.shx", std::process::id()));
let mut shx = b"AutoCAD-86 shapes 1.0\r\n\x1A".to_vec();
for value in [1_u16, 1, 1, 1, 9] {
shx.extend_from_slice(&value.to_le_bytes());
}
shx.extend_from_slice(b"ARROW\0\x10\x14\0");
std::fs::write(&shx_path, shx).unwrap();
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let style_handle = host.document_mut().allocate_handle();
let mut style = acadrust::tables::TextStyle::new("TestShapes");
style.handle = style_handle;
style.is_shape_file = true;
style.font_file = shx_path.to_string_lossy().into_owned();
host.document_mut().text_styles.add(style).unwrap();
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.expect("spawn staged Python plugin");
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process
.dispatch(host, command, &mut |_| {})
.expect("Python dispatch"));
};
dispatch(
&mut host,
concat!(
"PY_EVAL ocs.active_document.create_entity('Shape',",
"insertion_point={'x':1.0,'y':2.0,'z':0.0},size=2.0,",
"shape_name='ARROW',shape_number=1,style_name='TestShapes').handle"
),
);
let handle = host
.document()
.entities()
.next()
.expect("Python created a Shape")
.common()
.handle;
let Some(EntityType::Shape(created)) = host.document().get_entity(handle) else {
panic!("expected Shape");
};
assert_eq!(created.style_handle, Some(style_handle));
let created = created.clone();
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.active_document.entities[{}].shape_name",
handle.value()
),
);
assert!(host
.app
.command_line
.history
.last()
.unwrap()
.text
.contains("ARROW"));
let script_path = std::env::temp_dir().join(format!(
"ocs_shape_document_model_{}.py",
std::process::id()
));
std::fs::write(
&script_path,
format!(
concat!(
"doc = ocs.active_document\n",
"shape = doc.entities[{}]\n",
"with doc.transaction('Edit shape'):\n",
" shape.insertion_point = (4.0, 5.0, 0.0)\n",
" shape.size = 3.0\n",
" shape.rotation = 0.5\n",
" shape.relative_x_scale = 1.5\n",
"doc.selection = [shape]\n"
),
handle.value()
),
)
.unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script_path.display()));
let _ = std::fs::remove_file(&script_path);
let expected = host.document().get_entity(handle).unwrap().clone();
let EntityType::Shape(edited) = &expected else {
unreachable!()
};
assert_eq!(
edited.insertion_point,
acadrust::types::Vector3::new(4.0, 5.0, 0.0)
);
assert_eq!(edited.size, 3.0);
assert_eq!(edited.rotation, 0.5);
assert_eq!(edited.relative_x_scale, 1.5);
assert_eq!(edited.common, created.common);
assert_eq!(edited.shape_name, created.shape_name);
assert_eq!(edited.shape_number, created.shape_number);
assert_eq!(edited.style_name, created.style_name);
assert_eq!(edited.style_handle, created.style_handle);
assert_eq!(edited.normal, created.normal);
assert_eq!(edited.thickness, created.thickness);
assert_eq!(host.selection(), vec![handle]);
let assert_real_glyph = |entity: &EntityType, document: &CadDocument| {
let EntityType::Shape(shape) = entity else {
panic!("expected Shape");
};
let render = shape.to_render(document).expect("shape render");
let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object
else {
panic!("expected shape linework");
};
assert_eq!(
points.len(),
3,
"expected SHX glyph rather than diamond placeholder"
);
assert!(points.iter().flatten().all(|value| value.is_finite()));
};
assert_real_glyph(&expected, host.document());
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.update_many('Reject size', [{{'handle':{},'size':0.0}}])",
handle.value()
),
);
assert_eq!(host.document().get_entity(handle), Some(&expected));
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'style_name':'Missing'}}])",
handle.value()
),
);
assert_eq!(host.document().get_entity(handle), Some(&expected));
assert!(host
.app
.command_line
.history
.last()
.unwrap()
.text
.contains("does not exist"));
let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap();
let dwg_doc = crate::io::load_bytes("shape.dwg", dwg_bytes).unwrap();
let dwg_entity = dwg_doc.get_entity(handle).expect("Shape survives DWG");
assert!(matches!(dwg_entity, EntityType::Shape(value)
if value.shape_number == 1 && value.size == 3.0 && value.rotation == 0.5));
assert_real_glyph(dwg_entity, &dwg_doc);
let dxf_bytes = acadrust::DxfWriter::new(host.document())
.write_to_vec()
.unwrap();
let dxf_doc = crate::io::load_bytes("shape.dxf", dxf_bytes).unwrap();
let dxf_entity = dxf_doc.get_entity(handle).expect("Shape survives DXF");
assert!(matches!(dxf_entity, EntityType::Shape(value)
if value.shape_name == "ARROW" && value.size == 3.0 && (value.rotation - 0.5).abs() < 1e-12));
assert_real_glyph(dxf_entity, &dxf_doc);
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.active_document.delete_entity({})",
handle.value()
),
);
assert!(host.document().get_entity(handle).is_none());
drop(process);
drop(host);
let _ = std::fs::remove_file(&shx_path);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(
app.tabs[0].scene.document.get_entity(handle),
Some(&expected)
);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Shape(value))
if value.insertion_point.x == 1.0 && value.size == 2.0)
);
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_attribute_definition_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let next = host.document().next_handle();
let block_record_handle = Handle::new(next);
let block_handle = Handle::new(next + 1);
let block_end_handle = Handle::new(next + 2);
let mut block_record = acadrust::tables::BlockRecord::new("TAGBLOCK");
block_record.handle = block_record_handle;
block_record.block_entity_handle = block_handle;
block_record.block_end_handle = block_end_handle;
host.document_mut().block_records.add(block_record).unwrap();
let mut block = acadrust::entities::Block::new("TAGBLOCK", acadrust::types::Vector3::ZERO);
block.common.handle = block_handle;
block.common.owner_handle = block_record_handle;
host.document_mut()
.add_entity(EntityType::Block(block))
.unwrap();
let mut block_end = acadrust::entities::BlockEnd::new();
block_end.common.handle = block_end_handle;
block_end.common.owner_handle = block_record_handle;
host.document_mut()
.add_entity(EntityType::BlockEnd(block_end))
.unwrap();
let insert = acadrust::entities::Insert::new(
"TAGBLOCK",
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
);
let insert_handle = host
.document_mut()
.add_entity(EntityType::Insert(insert))
.unwrap();
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.expect("spawn staged Python plugin");
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process
.dispatch(host, command, &mut |_| {})
.expect("Python dispatch"));
};
dispatch(
&mut host,
&format!(
concat!(
"PY_EVAL ocs.active_document.create_entity('AttributeDefinition',",
"owner_handle={},tag='PART_NO',prompt='Part number',default_value='PN-001',",
"insertion_point={{'x':1.0,'y':2.0,'z':0.0}},height=2.5).handle"
),
block_record_handle.value()
),
);
let handle = host
.document()
.entities()
.find(|entity| matches!(entity, EntityType::AttributeDefinition(_)))
.expect("Python created an AttributeDefinition")
.common()
.handle;
let Some(EntityType::AttributeDefinition(created)) = host.document().get_entity(handle)
else {
panic!("expected AttributeDefinition");
};
assert_eq!(created.common.owner_handle, block_record_handle);
assert_eq!(created.tag, "PART_NO");
assert_eq!(created.text_style, "Standard");
assert!(host
.document()
.block_records
.get("TAGBLOCK")
.unwrap()
.entity_handles
.contains(&handle));
let created = created.clone();
dispatch(&mut host, &format!(
"PY_EVAL (ocs.active_document.entities[{}].tag, ocs.active_document.entities[{}].owner_handle)",
handle.value(), handle.value()));
let readback = &host.app.command_line.history.last().unwrap().text;
assert!(
readback.contains("PART_NO")
&& readback.contains(&block_record_handle.value().to_string())
);
let script_path = std::env::temp_dir().join(format!(
"ocs_attribute_definition_document_model_{}.py",
std::process::id()
));
std::fs::write(
&script_path,
format!(
concat!(
"doc = ocs.active_document\n",
"definition = doc.entities[{}]\n",
"with doc.transaction('Edit attribute definition'):\n",
" definition.insertion_point = (4.0, 5.0, 0.0)\n",
" definition.default_value = 'PN-002'\n",
" definition.rotation = 0.25\n",
" definition.width_factor = 1.25\n",
"doc.selection = [definition]\n"
),
handle.value()
),
)
.unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script_path.display()));
let _ = std::fs::remove_file(&script_path);
let expected = host.document().get_entity(handle).unwrap().clone();
let EntityType::AttributeDefinition(edited) = &expected else {
unreachable!()
};
assert_eq!(
edited.insertion_point,
acadrust::types::Vector3::new(4.0, 5.0, 0.0)
);
assert_eq!(edited.default_value, "PN-002");
assert_eq!(edited.rotation, 0.25);
assert_eq!(edited.width_factor, 1.25);
assert_eq!(edited.common, created.common);
assert_eq!(edited.tag, created.tag);
assert_eq!(edited.prompt, created.prompt);
assert_eq!(edited.text_style, created.text_style);
assert_eq!(edited.flags, created.flags);
assert_eq!(edited.embedded_mtext, created.embedded_mtext);
assert_eq!(host.selection(), vec![handle]);
assert!(
matches!(host.document().get_entity(insert_handle), Some(EntityType::Insert(value))
if value.block_name == "TAGBLOCK")
);
let render = edited
.to_render(host.document())
.expect("attribute definition render");
let crate::scene::convert::acad_to_render::RenderObject::Text(strokes) = render.object
else {
panic!("expected attribute definition text");
};
assert!(!strokes.is_empty());
assert!(strokes.iter().any(|stroke| stroke
.run
.as_ref()
.is_some_and(|run| run.text.contains("PN-002"))));
for (label, patch, message) in [
("Reject tag", "'tag':'BAD TAG'", "whitespace"),
("Reject height", "'height':0.0", "greater than zero"),
("Reject style", "'text_style':'Missing'", "does not exist"),
("Reject owner", "'owner_handle':999999", "read-only"),
] {
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.update_many({label:?}, [{{'handle':{}, {patch}}}])",
handle.value()
),
);
assert_eq!(host.document().get_entity(handle), Some(&expected));
assert!(host
.app
.command_line
.history
.last()
.unwrap()
.text
.contains(message));
}
let assert_persisted = |label: &str, document: &CadDocument, full_text_state: bool| {
let actual = document.get_entity(handle);
assert!(
matches!(actual, Some(EntityType::AttributeDefinition(value))
if value.common.owner_handle == block_record_handle
&& value.tag == "PART_NO" && value.default_value == "PN-002"
&& value.insertion_point == acadrust::types::Vector3::new(4.0, 5.0, 0.0)
&& (value.rotation - 0.25).abs() < 1e-12),
"{label}: {actual:?}"
);
if full_text_state {
assert!(
matches!(actual, Some(EntityType::AttributeDefinition(value))
if value.width_factor == 1.25 && value.text_style == "Standard"
&& value.prompt == "Part number"),
"{label}: {actual:?}"
);
}
assert!(document
.block_records
.get("TAGBLOCK")
.unwrap()
.entity_handles
.contains(&handle));
assert!(
matches!(document.get_entity(insert_handle), Some(EntityType::Insert(value))
if value.block_name == "TAGBLOCK")
);
};
let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap();
let dwg_doc = crate::io::load_bytes("attribute-definition.dwg", dwg_bytes).unwrap();
assert_persisted("DWG", &dwg_doc, true);
let dxf_bytes = acadrust::DxfWriter::new(host.document())
.write_to_vec()
.unwrap();
let dxf_doc = crate::io::load_bytes("attribute-definition.dxf", dxf_bytes).unwrap();
// The pinned cadcodec DXF ATTDEF reader retains identity, placement,
// height, value, prompt and rotation, but currently drops several
// optional AcDbText fields such as width factor. Keep that external
// codec gap explicit in the coverage ledger rather than claiming a
// complete DXF property round trip here.
assert_persisted("DXF", &dxf_doc, false);
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.active_document.delete_entity({})",
handle.value()
),
);
assert!(host.document().get_entity(handle).is_none());
// CadDocument intentionally leaves the block membership slot in place
// so delta undo can restore the shared entity without rewriting the
// record. Writers must still omit the absent entity.
let deleted_dwg = acadrust::DwgWriter::write_to_vec(host.document()).unwrap();
assert!(
crate::io::load_bytes("attribute-definition-deleted.dwg", deleted_dwg)
.unwrap()
.get_entity(handle)
.is_none()
);
let deleted_dxf = acadrust::DxfWriter::new(host.document())
.write_to_vec()
.unwrap();
assert!(
crate::io::load_bytes("attribute-definition-deleted.dxf", deleted_dxf)
.unwrap()
.get_entity(handle)
.is_none()
);
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(
app.tabs[0].scene.document.get_entity(handle),
Some(&expected)
);
app.undo_steps(1);
assert!(matches!(app.tabs[0].scene.document.get_entity(handle),
Some(EntityType::AttributeDefinition(value))
if value.default_value == "PN-001" && value.insertion_point.x == 1.0));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_attribute_entity_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let next = host.document().next_handle();
let record_handle = Handle::new(next);
let block_handle = Handle::new(next + 1);
let end_handle = Handle::new(next + 2);
let mut record = acadrust::tables::BlockRecord::new("TAGBLOCK");
record.handle = record_handle;
record.block_entity_handle = block_handle;
record.block_end_handle = end_handle;
host.document_mut().block_records.add(record).unwrap();
let mut block = acadrust::entities::Block::new("TAGBLOCK", acadrust::types::Vector3::ZERO);
block.common.handle = block_handle;
block.common.owner_handle = record_handle;
host.document_mut()
.add_entity(EntityType::Block(block))
.unwrap();
let mut end = acadrust::entities::BlockEnd::new();
end.common.handle = end_handle;
end.common.owner_handle = record_handle;
host.document_mut()
.add_entity(EntityType::BlockEnd(end))
.unwrap();
let mut definition = acadrust::entities::AttributeDefinition::new(
"PART_NO".into(),
"Part number".into(),
"PN-001".into(),
);
definition.common.owner_handle = record_handle;
definition.insertion_point = acadrust::types::Vector3::new(0.0, 2.0, 0.0);
let definition_handle = host
.document_mut()
.add_entity(EntityType::AttributeDefinition(definition))
.unwrap();
let insert_handle = host
.document_mut()
.add_entity(EntityType::Insert(acadrust::entities::Insert::new(
"TAGBLOCK",
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
)))
.unwrap();
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process
.dispatch(host, command, &mut |_| {})
.expect("Python dispatch"));
};
dispatch(
&mut host,
&format!(
concat!(
"PY_EVAL ocs.active_document.create_entity('AttributeEntity',owner_handle={},",
"tag='PART_NO',value='PN-101',",
"insertion_point={{'x':10.0,'y':2.0,'z':0.0}},height=2.5).handle"
),
insert_handle.value()
),
);
let created_insert = match host.document().get_entity(insert_handle).unwrap() {
EntityType::Insert(insert) => insert,
_ => unreachable!(),
};
assert_eq!(created_insert.attributes.len(), 1);
let attribute_handle = created_insert.attributes[0].common.handle;
assert_eq!(
created_insert.attributes[0].attdef_handle,
definition_handle
);
let created = created_insert.attributes[0].clone();
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.active_document.entities[{}].value",
attribute_handle.value()
),
);
let readback = &host.app.command_line.history.last().unwrap().text;
assert!(readback.contains("PN-101"), "{readback}");
let script =
std::env::temp_dir().join(format!("ocs_attribute_entity_{}.py", std::process::id()));
std::fs::write(
&script,
format!(
concat!(
"doc = ocs.active_document\n",
"attribute = doc.entities[{}]\n",
"with doc.transaction('Edit attribute'):\n",
" attribute.value = 'PN-102'\n",
" attribute.insertion_point = (12.0, 3.0, 0.0)\n",
" attribute.rotation = 0.25\n",
"doc.selection = [doc.entities[{}]]\n"
),
attribute_handle.value(),
insert_handle.value()
),
)
.unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let expected = host.nested_attribute(attribute_handle).unwrap();
let EntityType::AttributeEntity(edited) = &expected else {
unreachable!()
};
assert_eq!(edited.value, "PN-102");
assert_eq!(
edited.insertion_point,
acadrust::types::Vector3::new(12.0, 3.0, 0.0)
);
assert_eq!(edited.common, created.common);
assert_eq!(edited.attdef_handle, definition_handle);
assert_eq!(host.selection(), vec![insert_handle]);
let render = edited.to_render(host.document()).unwrap();
assert!(matches!(render.object,
crate::scene::convert::acad_to_render::RenderObject::Text(ref strokes)
if !strokes.is_empty()));
for (patch, message) in [
("'tag':'MISSING'", "no definition"),
("'height':0.0", "greater than zero"),
("'owner_handle':999999", "read-only"),
] {
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.update_many('Reject attribute', [{{'handle':{}, {patch}}}])",
attribute_handle.value()
),
);
assert_eq!(
host.nested_attribute(attribute_handle),
Some(expected.clone())
);
assert!(host
.app
.command_line
.history
.last()
.unwrap()
.text
.contains(message));
}
let assert_saved = |document: &CadDocument| {
assert!(
matches!(document.get_entity(insert_handle), Some(EntityType::Insert(insert))
if insert.attributes.len() == 1
&& insert.attributes[0].common.handle == attribute_handle
&& insert.attributes[0].value == "PN-102"
&& (insert.attributes[0].rotation - 0.25).abs() < 1e-12)
);
};
assert_saved(
&crate::io::load_bytes(
"attribute.dwg",
acadrust::DwgWriter::write_to_vec(host.document()).unwrap(),
)
.unwrap(),
);
assert_saved(
&crate::io::load_bytes(
"attribute.dxf",
acadrust::DxfWriter::new(host.document())
.write_to_vec()
.unwrap(),
)
.unwrap(),
);
dispatch(
&mut host,
&format!(
"PY_EVAL ocs.active_document.delete_entity({})",
attribute_handle.value()
),
);
assert!(host.nested_attribute(attribute_handle).is_none());
let assert_deleted = |document: &CadDocument| {
assert!(
matches!(document.get_entity(insert_handle), Some(EntityType::Insert(insert))
if insert.attributes.is_empty())
);
};
assert_deleted(
&crate::io::load_bytes(
"attribute-deleted.dwg",
acadrust::DwgWriter::write_to_vec(host.document()).unwrap(),
)
.unwrap(),
);
assert_deleted(
&crate::io::load_bytes(
"attribute-deleted.dxf",
acadrust::DxfWriter::new(host.document())
.write_to_vec()
.unwrap(),
)
.unwrap(),
);
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(insert_handle),
Some(EntityType::Insert(insert)) if insert.attributes.len() == 1
&& insert.attributes[0].value == "PN-102")
);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(insert_handle),
Some(EntityType::Insert(insert)) if insert.attributes[0].value == "PN-101")
);
app.undo_steps(1);
assert!(
matches!(app.tabs[0].scene.document.get_entity(insert_handle),
Some(EntityType::Insert(insert)) if insert.attributes.is_empty())
);
app.redo_steps(3);
assert!(
matches!(app.tabs[0].scene.document.get_entity(insert_handle),
Some(EntityType::Insert(insert)) if insert.attributes.is_empty())
);
}
#[test]
fn staged_python_hatch_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let script = std::env::temp_dir().join(format!("ocs_hatch_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"def p(x, y): return {'x': x, 'y': y}\n",
"def boundary(size):\n",
" pairs = [(0.0,0.0,size,0.0),(size,0.0,size,size),(size,size,0.0,size),(0.0,size,0.0,0.0)]\n",
" edges = [{'kind':'Line','value':{'start':p(a,b),'end':p(c,d)}} for a,b,c,d in pairs]\n",
" return {'flags':1,'edges':edges,'boundary_handles':[]}\n",
"doc = ocs.active_document\n",
"hatch = doc.create_entity('Hatch', paths=[boundary(10.0)])\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let hatch_handle = host.document().entities().find_map(|entity| match entity {
EntityType::Hatch(hatch) => Some(hatch.common.handle),
_ => None,
}).unwrap_or_else(|| panic!("Python did not create Hatch: {:?}",
host.app.command_line.history.last().map(|entry| &entry.text)));
let created = host.document().get_entity(hatch_handle).unwrap().clone();
assert!(host.app.tabs[0].scene.hatches.get(&hatch_handle)
.is_some_and(|model| model.boundary.len() >= 4 && model.name == "SOLID"));
dispatch(&mut host, &format!(
"PY_EVAL ocs.active_document.entities[{}].pattern['name']", hatch_handle.value()));
assert!(host.app.command_line.history.last().unwrap().text.contains("SOLID"));
let script = std::env::temp_dir().join(format!("ocs_hatch_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"def p(x, y): return {{'x': x, 'y': y}}\n",
"def boundary(size):\n",
" pairs = [(0.0,0.0,size,0.0),(size,0.0,size,size),(size,size,0.0,size),(0.0,size,0.0,0.0)]\n",
" edges = [{{'kind':'Line','value':{{'start':p(a,b),'end':p(c,d)}}}} for a,b,c,d in pairs]\n",
" return {{'flags':1,'edges':edges,'boundary_handles':[]}}\n",
"doc = ocs.active_document\n",
"hatch = doc.entities[{}]\n",
"with doc.transaction('Edit hatch'):\n",
" hatch.paths = [boundary(12.0)]\n",
" hatch.is_solid = False\n",
" hatch.pattern = {{'name':'TEST','description':'test pattern','lines':[",
"{{'angle':0.0,'base_point':p(0.0,0.0),'offset':p(0.0,2.0),'dash_lengths':[1.0,-1.0]}}]}}\n",
" hatch.pattern_scale = 2.0\n",
" hatch.pattern_angle = 0.25\n",
"doc.selection = [hatch]\n"
), hatch_handle.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let expected = host.document().get_entity(hatch_handle).unwrap().clone();
let EntityType::Hatch(edited) = &expected else { unreachable!() };
assert!(!edited.is_solid);
assert_eq!(edited.pattern.name, "TEST");
assert_eq!(edited.pattern.lines.len(), 1);
assert_eq!(edited.paths[0].edges.len(), 4);
assert!((edited.pattern_scale - 2.0).abs() < 1e-12);
assert_eq!(edited.gradient_color, match &created { EntityType::Hatch(value) => value.gradient_color.clone(), _ => unreachable!() });
assert_eq!(host.selection(), vec![hatch_handle]);
assert!(host.app.tabs[0].scene.hatches.get(&hatch_handle).is_some_and(|model|
model.boundary.len() >= 4 && model.name == "TEST"
&& matches!(&model.pattern,
crate::scene::model::hatch_model::HatchPattern::Pattern(lines)
if !lines.is_empty())));
for (patch, message) in [
("'pattern_scale':0.0", "greater than zero"),
("'is_associative':True", "requires boundary handles"),
("'gradient_color':{}", "outside the editable schema"),
("'paths':[{'flags':1,'edges':[{'kind':'Line','value':{'start':{'x':0.0,'y':0.0},'end':{'x':1.0,'y':0.0}}}],'boundary_handles':[]}]", "not closed"),
] {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject hatch', [{{'handle':{}, {patch}}}])",
hatch_handle.value()));
assert_eq!(host.document().get_entity(hatch_handle), Some(&expected));
assert!(host.app.command_line.history.last().unwrap().text.contains(message));
}
let assert_saved = |document: &CadDocument| {
assert!(matches!(document.get_entity(hatch_handle), Some(EntityType::Hatch(hatch))
if !hatch.is_solid && hatch.pattern.name == "TEST"
&& hatch.pattern.lines.len() == 1 && hatch.paths[0].edges.len() == 4
&& (hatch.pattern_scale - 2.0).abs() < 1e-12));
};
let reopened_dwg = crate::io::load_bytes("hatch.dwg",
acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let reopened_dxf = crate::io::load_bytes("hatch.dxf",
acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
assert_saved(&reopened_dwg);
assert_saved(&reopened_dxf);
for (name, mut document) in [("hatch-reedit.dwg", reopened_dwg),
("hatch-reedit.dxf", reopened_dxf)] {
let before = document.get_entity(hatch_handle).unwrap().clone();
let mut after = before.clone();
let EntityType::Hatch(hatch) = &mut after else { unreachable!() };
hatch.elevation = 3.0;
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
*document.get_entity_mut(hatch_handle).unwrap() = after;
let bytes = if name.ends_with("dwg") {
acadrust::DwgWriter::write_to_vec(&document).unwrap()
} else {
acadrust::DxfWriter::new(&document).write_to_vec().unwrap()
};
assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(hatch_handle),
Some(EntityType::Hatch(hatch)) if (hatch.elevation - 3.0).abs() < 1e-12));
}
dispatch(&mut host, &format!(
"PY_EVAL ocs.active_document.delete_entity({})", hatch_handle.value()));
assert!(host.document().get_entity(hatch_handle).is_none());
for (name, bytes) in [
("hatch-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
("hatch-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()),
] {
assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(hatch_handle).is_none());
}
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(hatch_handle), Some(&expected));
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(hatch_handle), Some(&created));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none());
}
#[test]
fn staged_python_leader_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
dispatch(&mut host, concat!(
"PY_EVAL ocs.active_document.create_entity('Text', text='NOTE', ",
"insertion={'x':20.0,'y':10.0,'z':0.0}, height=2.5).handle"
));
let text_handle = host.document().entities().find_map(|entity| match entity {
EntityType::Text(text) => Some(text.common.handle),
_ => None,
}).unwrap_or_else(|| panic!("Python did not create Text: {}", last_output(&host)));
let script = std::env::temp_dir().join(format!("ocs_leader_create_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"doc = ocs.active_document\n",
"doc.create_entity('Leader', vertices=[{{'x':0.0,'y':0.0,'z':0.0}},",
"{{'x':10.0,'y':10.0,'z':0.0}},{{'x':20.0,'y':10.0,'z':0.0}}], ",
"annotation_handle={}, hookline_enabled=True)\n"
), text_handle.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let leader_handle = host.document().entities().find_map(|entity| match entity {
EntityType::Leader(leader) => Some(leader.common.handle),
_ => None,
}).unwrap_or_else(|| panic!("Python did not create Leader: {}", last_output(&host)));
let created = host.document().get_entity(leader_handle).unwrap().clone();
let EntityType::Leader(created_leader) = &created else { unreachable!() };
assert_eq!(created_leader.annotation_handle, text_handle);
assert_eq!(created_leader.vertices.len(), 3);
let assert_geometry = |entity: &EntityType, document: &CadDocument| {
let EntityType::Leader(leader) = entity else { panic!("expected Leader") };
let render = leader.to_render(document).expect("leader render");
let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object else {
panic!("expected leader linework");
};
assert!(points.len() >= leader.vertices.len());
assert!(points.iter().flatten().all(|value| value.is_nan() || value.is_finite()));
};
assert_geometry(&created, host.document());
dispatch(&mut host, &format!(
"PY_EVAL ocs.active_document.entities[{}].annotation_handle", leader_handle.value()));
assert!(last_output(&host).contains(&text_handle.value().to_string()));
// GUI picking groups a leader with its annotation, while scripted
// selection stays exact.
host.app.tabs[0].scene.select_entities(&[leader_handle]);
let mut grouped = host.selection();
grouped.sort_by_key(|handle| handle.value());
assert_eq!(grouped, vec![text_handle, leader_handle]);
host.app.tabs[0].scene.replace_selection_exact(&[]);
let script = std::env::temp_dir().join(format!("ocs_leader_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"doc = ocs.active_document\n",
"leader = doc.entities[{}]\n",
"with doc.transaction('Edit leader'):\n",
" leader.vertices = [{{'x':1.0,'y':1.0,'z':0.0}},{{'x':11.0,'y':12.0,'z':0.0}},",
"{{'x':22.0,'y':12.0,'z':0.0}},{{'x':30.0,'y':12.0,'z':0.0}}]\n",
" leader.text_height = 4.0\n",
" leader.arrow_enabled = False\n",
" leader.annotation_offset = (1.0, 2.0, 0.0)\n",
" leader.override_color = {{'kind':'Rgb','value':{{'r':255,'g':0,'b':0}}}}\n",
"doc.selection = [leader]\n"
), leader_handle.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let expected = host.document().get_entity(leader_handle).unwrap().clone();
let EntityType::Leader(edited) = &expected else { unreachable!() };
assert_eq!(edited.vertices.len(), 4, "edit failed: {}", last_output(&host));
assert_eq!(edited.text_height, 4.0);
assert!(!edited.arrow_enabled);
assert_eq!(edited.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0));
assert_eq!(edited.annotation_handle, text_handle);
assert_eq!(edited.common, created_leader.common);
assert_eq!(edited.dimension_style, created_leader.dimension_style);
assert_eq!(edited.hookline_enabled, created_leader.hookline_enabled);
assert_eq!(edited.normal, created_leader.normal);
assert_eq!(edited.origin, created_leader.origin);
assert_eq!(edited.override_color, acadrust::types::Color::Rgb { r: 255, g: 0, b: 0 });
assert_eq!(host.selection(), vec![leader_handle]);
assert_geometry(&expected, host.document());
for (patch, message) in [
("'annotation_handle':999999", "does not exist"),
("'creation_type':'WithTolerance'", "does not match creation_type"),
("'creation_type':'NoAnnotation'", "no annotation"),
("'vertices':[{'x':0.0,'y':0.0,'z':0.0}]", "at least 2"),
("'text_height':0.0", "greater than zero"),
("'dimension_style':'Missing'", "dimension style"),
("'override_color':{'kind':'Index','value':300}", "Color"),
] {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject leader', [{{'handle':{}, {patch}}}])",
leader_handle.value()));
assert_eq!(host.document().get_entity(leader_handle), Some(&expected), "{patch}");
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
}
// A valid edit paired with an invalid one is atomic across entities.
dispatch(&mut host, &format!(concat!(
"PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'text_width':5.0}},",
"{{'handle':{},'text_height':0.0}}])"
), leader_handle.value(), text_handle.value()));
assert_eq!(host.document().get_entity(leader_handle), Some(&expected));
// Both formats keep the path, annotation link, arrow flag and offset.
// DWG R2010+ derives text_height/text_width/hookline_enabled instead of
// storing them (acadrust writes them for R13-R2007/R13-R14 only), so
// only DXF must round-trip those three.
let assert_saved = |document: &CadDocument, is_dxf: bool| {
let Some(EntityType::Leader(leader)) = document.get_entity(leader_handle) else {
panic!("Leader missing after reopen");
};
assert_eq!(leader.vertices.len(), 4);
if is_dxf {
// BLOCKER: acadrust's DXF writer never emits group 340, so a
// DXF save unlinks the annotation. Flip this to
// `assert_eq!(.., text_handle)` once the engine writes it.
assert!(leader.annotation_handle.is_null(), "acadrust now writes DXF 340; drop the blocker");
} else {
assert_eq!(leader.annotation_handle, text_handle);
}
assert!(!leader.arrow_enabled);
// BLOCKER: neither writer stores `override_color`, so it reopens
// as ByLayer in both formats.
assert_eq!(leader.override_color, acadrust::types::Color::ByLayer,
"acadrust now persists Leader.override_color; drop the blocker");
if is_dxf {
// BLOCKER: the DXF writer emits group 213 but the reader's
// coordinate mapping does not reassemble it.
assert_eq!(leader.annotation_offset, acadrust::types::Vector3::ZERO,
"acadrust now reads DXF 213; drop the blocker");
} else {
assert_eq!(leader.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0));
}
assert_eq!(leader.dimension_style, "Standard");
if is_dxf {
assert!((leader.text_height - 4.0).abs() < 1e-12);
assert!(leader.hookline_enabled);
} else {
assert_eq!(leader.text_height, 2.5, "DWG R2010+ does not store text_height");
assert!(!leader.hookline_enabled, "DWG R2010+ does not store hookline_enabled");
}
assert!(matches!(document.get_entity(text_handle), Some(EntityType::Text(_))));
};
let reopened_dwg = crate::io::load_bytes("leader.dwg",
acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let reopened_dxf = crate::io::load_bytes("leader.dxf",
acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
assert_saved(&reopened_dwg, false);
assert_saved(&reopened_dxf, true);
for (name, mut document) in [("leader-reedit.dwg", reopened_dwg),
("leader-reedit.dxf", reopened_dxf)] {
assert_geometry(document.get_entity(leader_handle).unwrap(), &document);
let before = document.get_entity(leader_handle).unwrap().clone();
let mut after = before.clone();
let EntityType::Leader(leader) = &mut after else { unreachable!() };
leader.vertices[3] = acadrust::types::Vector3::new(35.0, 12.0, 0.0);
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap();
*document.get_entity_mut(leader_handle).unwrap() = after;
let bytes = if name.ends_with("dwg") {
acadrust::DwgWriter::write_to_vec(&document).unwrap()
} else {
acadrust::DxfWriter::new(&document).write_to_vec().unwrap()
};
assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(leader_handle),
Some(EntityType::Leader(leader)) if (leader.vertices[3].x - 35.0).abs() < 1e-9));
}
// Erasing a leader also erases its annotation (scene leader grouping).
dispatch(&mut host, &format!(
"PY_EVAL ocs.active_document.delete_entity({})", leader_handle.value()));
assert!(host.document().get_entity(leader_handle).is_none());
assert!(host.document().get_entity(text_handle).is_none());
for (name, bytes) in [
("leader-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
("leader-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()),
] {
let document = crate::io::load_bytes(name, bytes).unwrap();
assert!(document.get_entity(leader_handle).is_none());
assert!(document.get_entity(text_handle).is_none());
}
drop(process);
drop(host);
app.finish_pending_history(0);
// Text create, Leader create, edit transaction, Leader delete.
assert_eq!(app.tabs[0].history.undo_stack.len(), 4);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&expected));
assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some());
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&created));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none());
assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some());
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none());
app.redo_steps(4);
assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none());
assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none());
}
#[test]
fn staged_python_mline_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let style_handle = host.document().objects.iter().find_map(|(handle, object)| match object {
acadrust::objects::ObjectType::MLineStyle(style) if style.name == "Standard" => Some(*handle),
_ => None,
}).expect("document carries the Standard MLineStyle");
let script = std::env::temp_dir().join(format!("ocs_mline_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"def p(x, y): return {'position': {'x': x, 'y': y, 'z': 0.0}}\n",
"doc = ocs.active_document\n",
"doc.create_entity('MLine', vertices=[p(0.0, 0.0), p(10.0, 0.0)])\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let handle = host.document().entities().find_map(|entity| match entity {
EntityType::MLine(mline) => Some(mline.common.handle),
_ => None,
}).unwrap_or_else(|| panic!("Python did not create MLine: {}", last_output(&host)));
let created = host.document().get_entity(handle).unwrap().clone();
let EntityType::MLine(created_mline) = &created else { unreachable!() };
assert_eq!(created_mline.style_handle, Some(style_handle));
assert_eq!(created_mline.style_element_count, 2);
assert_eq!(created_mline.start_point, acadrust::types::Vector3::ZERO);
let first_parameters = |mline: &acadrust::entities::MLine, vertex: usize| -> Vec<f64> {
mline.vertices[vertex].segments.iter().map(|segment| segment.parameters[0]).collect()
};
assert_eq!(first_parameters(created_mline, 0), vec![0.5, -0.5]);
assert!(created_mline.vertices.iter().all(|vertex| {
(vertex.direction.x - 1.0).abs() < 1e-9 && (vertex.miter.y.abs() - 1.0).abs() < 1e-9
}));
let assert_lines = |entity: &EntityType, document: &CadDocument, expected_vertices: usize| {
let EntityType::MLine(mline) = entity else { panic!("expected MLine") };
assert_eq!(mline.vertices.len(), expected_vertices);
let lines = crate::entities::mline::mline_lines(mline, document);
assert!(lines.len() >= 2, "expected styled element lines");
assert!(lines.iter().flat_map(|line| &line.points).flatten().all(|v| v.is_finite()));
};
assert_lines(&created, host.document(), 2);
let script = std::env::temp_dir().join(format!("ocs_mline_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"def p(x, y): return {{'position': {{'x': x, 'y': y, 'z': 0.0}}}}\n",
"doc = ocs.active_document\n",
"mline = doc.entities[{}]\n",
"with doc.transaction('Edit mline'):\n",
" mline.vertices = [p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0)]\n",
" mline.scale_factor = 2.0\n",
" mline.justification = 'Top'\n",
"doc.selection = [mline]\n"
), handle.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let expected = host.document().get_entity(handle).unwrap().clone();
let EntityType::MLine(edited) = &expected else { unreachable!() };
assert_eq!(edited.vertices.len(), 3, "edit failed: {}", last_output(&host));
assert_eq!(edited.scale_factor, 2.0);
assert_eq!(edited.justification, acadrust::entities::MLineJustification::Top);
assert_eq!(edited.style_handle, created_mline.style_handle);
assert_eq!(edited.common, created_mline.common);
assert_eq!(edited.normal, created_mline.normal);
assert_eq!(edited.flags.bits() & !1, 0);
// Top justification with scale 2: first element sits on the path,
// the second 2.0 to the right of travel; the corner miter widens it.
let end = first_parameters(edited, 0);
assert!(end[0].abs() < 1e-9 && (end[1] + 2.0).abs() < 1e-9, "{end:?}");
let corner = first_parameters(edited, 1);
assert!(corner[1].abs() > 2.0 && corner[1].is_finite(), "miter must widen at the corner: {corner:?}");
assert!((edited.vertices[1].direction.y - edited.vertices[1].direction.x).abs() > 1e-6);
assert_lines(&expected, host.document(), 3);
assert_eq!(host.selection(), vec![handle]);
for (patch, message) in [
("'style_name':'Missing'", "does not exist"),
("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"),
("'scale_factor':0.0", "nonzero"),
("'flags':64", "unknown bits"),
("'flags':3,'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "requires at least 3"),
("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "coincide"),
("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':float('nan'),'z':0.0}}]", "finite"),
("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0},'segments':[{}]},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "segments but style"),
("'start_point':{'x':5.0,'y':5.0,'z':0.0}", "read-only"),
] {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject mline', [{{'handle':{}, {patch}}}])", handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}");
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
}
dispatch(&mut host, &format!(concat!(
"PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'scale_factor':3.0}},",
"{{'handle':{},'scale_factor':0.0}}])"
), handle.value(), handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected));
let assert_saved = |document: &CadDocument| {
let Some(EntityType::MLine(mline)) = document.get_entity(handle) else {
panic!("MLine missing after reopen");
};
assert_eq!(mline.vertices.len(), 3);
assert_eq!(mline.scale_factor, 2.0);
assert_eq!(mline.justification, acadrust::entities::MLineJustification::Top);
assert_eq!(mline.style_name, "Standard");
assert_eq!(mline.vertices[2].position, acadrust::types::Vector3::new(10.0, 10.0, 0.0));
let (a, b) = (first_parameters(mline, 1), first_parameters(edited, 1));
assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9), "{a:?} vs {b:?}");
assert_lines(&document.get_entity(handle).unwrap().clone(), document, 3);
};
let reopened_dwg = crate::io::load_bytes("mline.dwg",
acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let reopened_dxf = crate::io::load_bytes("mline.dxf",
acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
assert_saved(&reopened_dwg);
assert_saved(&reopened_dxf);
for (name, mut document) in [("mline-reedit.dwg", reopened_dwg), ("mline-reedit.dxf", reopened_dxf)] {
let before = document.get_entity(handle).unwrap().clone();
let mut after = before.clone();
let EntityType::MLine(mline) = &mut after else { unreachable!() };
mline.vertices[2].position = acadrust::types::Vector3::new(10.0, 12.0, 0.0);
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap();
let EntityType::MLine(mline) = &mut after else { unreachable!() };
let old = match &before { EntityType::MLine(old) => old, _ => unreachable!() };
crate::entities::mline::normalize_scripted_mline(Some(old), mline, &document).unwrap();
*document.get_entity_mut(handle).unwrap() = after;
let bytes = if name.ends_with("dwg") {
acadrust::DwgWriter::write_to_vec(&document).unwrap()
} else {
acadrust::DxfWriter::new(&document).write_to_vec().unwrap()
};
assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle),
Some(EntityType::MLine(mline)) if (mline.vertices[2].position.y - 12.0).abs() < 1e-9));
}
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value()));
assert!(host.document().get_entity(handle).is_none());
for (name, bytes) in [
("mline-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
("mline-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()),
] {
assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none());
}
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected));
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_dimension_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
// (subtype, python fields, expected measurement)
let fixtures: [(&str, &str, f64); 9] = [
("Linear", "definition_point=P(10,5), first_point=P(0,0), second_point=P(10,0)", 10.0),
("Aligned", "definition_point=P(1,5.5), first_point=P(0,0), second_point=P(3,4)", 5.0),
("Radius", "definition_point=P(5,0), angle_vertex=P(0,0)", 5.0),
("Diameter", "definition_point=P(10,0), angle_vertex=P(0,0)", 10.0),
("Angular3Pt", "definition_point=P(3,3), first_point=P(5,0), second_point=P(0,5), angle_vertex=P(0,0)", 90.0),
("Angular2Ln", "definition_point=P(0,5), dimension_arc=P(3,3), first_point=P(0,0), second_point=P(5,0), angle_vertex=P(0,0)", 90.0),
("Ordinate", "feature_location=P(5,3), leader_endpoint=P(5,8), is_ordinate_type_x=True", 5.0),
("Arc", "definition_point=P(3.5,3.5), first_extension_point=P(5,0), second_extension_point=P(0,5), center_point=P(0,0), arc_start_parameter=0.0, arc_end_parameter=1.5707963267948966", 5.0 * std::f64::consts::FRAC_PI_2),
("LargeRadial", "definition_point=P(10,0), chord_point=P(4,0)", 6.0),
];
let mut script = String::from("def P(x, y): return {'x': x, 'y': y, 'z': 0.0}\ndoc = ocs.active_document\n");
for (subtype, fields, _) in &fixtures {
script.push_str(&format!("doc.create_entity('Dimension', subtype='{subtype}', {fields})\n"));
}
let path = std::env::temp_dir().join(format!("ocs_dimension_create_{}.py", std::process::id()));
std::fs::write(&path, script).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", path.display()));
let _ = std::fs::remove_file(&path);
let subtype_of = |dimension: &acadrust::entities::Dimension| -> &'static str {
use acadrust::entities::Dimension as D;
match dimension {
D::Aligned(_) => "Aligned", D::Linear(_) => "Linear", D::Radius(_) => "Radius",
D::Diameter(_) => "Diameter", D::Angular2Ln(_) => "Angular2Ln",
D::Angular3Pt(_) => "Angular3Pt", D::Ordinate(_) => "Ordinate",
D::Arc(_) => "Arc", D::LargeRadial(_) => "LargeRadial",
}
};
let handles_by_subtype = |document: &CadDocument| {
let mut map = std::collections::BTreeMap::new();
for entity in document.entities() {
if let EntityType::Dimension(dimension) = entity {
map.insert(subtype_of(dimension), dimension.base().common.handle);
}
}
map
};
let handles = handles_by_subtype(host.document());
assert_eq!(handles.len(), 9, "created: {handles:?}; {}", last_output(&host));
let measured = |document: &CadDocument, handle: Handle| -> f64 {
let Some(EntityType::Dimension(dimension)) = document.get_entity(handle) else {
panic!("Dimension missing");
};
dimension.base().actual_measurement
};
for (subtype, _, expected) in &fixtures {
let value = measured(host.document(), handles[subtype]);
assert!((value - expected).abs() < 1e-6, "{subtype}: measured {value}, expected {expected}");
let Some(EntityType::Dimension(dimension)) = host.document().get_entity(handles[subtype]) else { unreachable!() };
assert_eq!(dimension.measurement(), value, "{subtype}: stored measurement is derived");
let wires = host.app.tabs[0].scene.wire_models_for(&[handles[subtype]]);
assert!(!wires.is_empty(), "{subtype}: dimension produced no canvas geometry");
}
let linear = handles["Linear"];
let created_linear = host.document().get_entity(linear).unwrap().clone();
// Edit: move a defining point (the measurement follows), change text
// and style-independent placement; untouched fields survive.
let script = std::env::temp_dir().join(format!("ocs_dimension_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"doc = ocs.active_document\n",
"linear = doc.entities[{}]\n",
"radius = doc.entities[{}]\n",
"with doc.transaction('Edit dimensions'):\n",
" linear.second_point = (20.0, 0.0, 0.0)\n",
" linear.text = '<> mm'\n",
" linear.attachment_point = 'TopCenter'\n",
" radius.definition_point = (8.0, 0.0, 0.0)\n",
"doc.selection = [linear]\n"
), linear.value(), handles["Radius"].value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let expected_linear = host.document().get_entity(linear).unwrap().clone();
let EntityType::Dimension(edited) = &expected_linear else { unreachable!() };
let EntityType::Dimension(before) = &created_linear else { unreachable!() };
assert!((edited.base().actual_measurement - 20.0).abs() < 1e-9, "edit failed: {}", last_output(&host));
assert!((measured(host.document(), handles["Radius"]) - 8.0).abs() < 1e-9);
assert_eq!(edited.base().text, "<> mm");
assert_eq!(edited.base().attachment_point, acadrust::entities::AttachmentPointType::TopCenter);
assert_eq!(edited.base().common, before.base().common);
assert_eq!(edited.base().style_name, before.base().style_name);
assert_eq!(edited.base().normal, before.base().normal);
assert_eq!(host.selection(), vec![linear]);
let expected_radius = host.document().get_entity(handles["Radius"]).unwrap().clone();
assert!(!host.app.tabs[0].scene.wire_models_for(&[linear]).is_empty());
let reject_cases: [(&str, &str, &str); 9] = [
("Linear", "'leader_length':2.0", "does not apply to the Linear subtype"),
("Linear", "'subtype':'Radius'", "read-only"),
("Linear", "'actual_measurement':5.0", "read-only"),
("Linear", "'style_name':'Missing'", "does not exist"),
("Linear", "'first_point':{'x':20.0,'y':0.0,'z':0.0}", "must not coincide"),
("Linear", "'first_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
("Linear", "'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
("Radius", "'angle_vertex':{'x':8.0,'y':0.0,'z':0.0}", "must not coincide"),
("Linear", "'attachment_point':'Nowhere'", "unsupported AttachmentPointType"),
];
for (subtype, patch, message) in reject_cases {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject dimension', [{{'handle':{}, {patch}}}])",
handles[subtype].value()));
assert_eq!(host.document().get_entity(linear), Some(&expected_linear), "{patch}");
assert_eq!(host.document().get_entity(handles["Radius"]), Some(&expected_radius), "{patch}");
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
}
dispatch(&mut host, &format!(concat!(
"PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'text':'changed'}},",
"{{'handle':{},'style_name':'Missing'}}])"
), linear.value(), handles["Radius"].value()));
assert_eq!(host.document().get_entity(linear), Some(&expected_linear));
for (patch, message) in [
("ocs.active_document.create_entity('Dimension', definition_point=P(0,0))", "requires subtype"),
("ocs.active_document.create_entity('Dimension', subtype='Cone')", "unsupported Dimension subtype"),
("ocs.active_document.create_entity('Dimension', subtype='Linear', first_point=P(0,0), second_point=P(1,0))", "requires definition_point"),
] {
dispatch(&mut host, &format!("PY_EVAL (lambda P: {patch})(lambda x, y: {{'x': x, 'y': y, 'z': 0.0}})"));
assert_eq!(handles_by_subtype(host.document()).len(), 9, "{patch}");
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
}
// DWG and DXF: every subtype is recorded separately.
let mut failures = Vec::new();
let dwg = crate::io::load_bytes("dimension.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("dimension.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
let reopened = handles_by_subtype(document);
for (subtype, _, expected) in &fixtures {
let expected = match *subtype { "Linear" => 20.0, "Radius" => 8.0, _ => *expected };
match reopened.get(subtype) {
None => failures.push(format!("{format} {subtype}: missing")),
Some(handle) => {
let value = measured(document, *handle);
if (value - expected).abs() > 1e-6 {
failures.push(format!("{format} {subtype}: measurement {value} != {expected}"));
}
}
}
}
let Some(EntityType::Dimension(dimension)) = document.get_entity(linear) else {
failures.push(format!("{format} Linear handle lost"));
continue;
};
if dimension.base().text != "<> mm" { failures.push(format!("{format} Linear text {:?}", dimension.base().text)); }
}
assert!(failures.is_empty(), "persistence gaps: {failures:#?}");
for (name, mut document) in [("dimension-reedit.dwg", dwg), ("dimension-reedit.dxf", dxf)] {
let before = document.get_entity(linear).unwrap().clone();
let mut after = before.clone();
let EntityType::Dimension(dimension) = &mut after else { unreachable!() };
if let acadrust::entities::Dimension::Linear(value) = dimension { value.second_point.x = 30.0; }
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap();
let EntityType::Dimension(dimension) = &mut after else { unreachable!() };
crate::entities::dimension::normalize_scripted_dimension(dimension);
*document.get_entity_mut(linear).unwrap() = after;
let bytes = if name.ends_with("dwg") {
acadrust::DwgWriter::write_to_vec(&document).unwrap()
} else {
acadrust::DxfWriter::new(&document).write_to_vec().unwrap()
};
let reopened = crate::io::load_bytes(name, bytes).unwrap();
assert!((measured(&reopened, linear) - 30.0).abs() < 1e-6, "{name}");
}
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", linear.value()));
assert!(host.document().get_entity(linear).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(linear), Some(&expected_linear));
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(linear), Some(&created_linear));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(linear).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(linear).is_none());
}
#[test]
fn staged_python_multileader_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let script = std::env::temp_dir().join(format!("ocs_mleader_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"def P(x, y): return {'x': x, 'y': y, 'z': 0.0}\n",
"doc = ocs.active_document\n",
"doc.create_entity('MultiLeader', content_type='MText', context={\n",
" 'has_text_contents': True, 'text_string': 'NOTE', 'text_location': P(20, 10),\n",
" 'text_height': 2.5,\n",
" 'leader_roots': [{'connection_point': P(15, 10), 'direction': P(1, 0), 'landing_distance': 2.0,\n",
" 'lines': [{'points': [P(0, 0), P(10, 10), P(15, 10)]}]}]})\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let handle = host.document().entities().find_map(|entity| match entity {
EntityType::MultiLeader(value) => Some(value.common.handle),
_ => None,
}).unwrap_or_else(|| panic!("Python did not create MultiLeader: {}", last_output(&host)));
let created = host.document().get_entity(handle).unwrap().clone();
let EntityType::MultiLeader(created_ml) = &created else { unreachable!() };
assert_eq!(created_ml.context.text_string, "NOTE");
assert_eq!(created_ml.context.leader_roots[0].lines[0].points.len(), 3);
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry");
let script = std::env::temp_dir().join(format!("ocs_mleader_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"def P(x, y): return {{'x': x, 'y': y, 'z': 0.0}}\n",
"doc = ocs.active_document\n",
"ml = doc.entities[{}]\n",
"roots = ml.context['leader_roots']\n",
"roots[0]['lines'][0]['points'] = [P(0, 0), P(12, 12), P(18, 12), P(22, 12)]\n",
"with doc.transaction('Edit multileader'):\n",
" ml.context = {{'leader_roots': roots, 'text_string': 'EDITED', 'text_location': P(26, 12), 'text_height': 3.0}}\n",
" ml.dogleg_length = 3.0\n",
" ml.line_color = {{'kind': 'Rgb', 'value': {{'r': 0, 'g': 128, 'b': 255}}}}\n",
"doc.selection = [ml]\n"
), handle.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let expected = host.document().get_entity(handle).unwrap().clone();
let EntityType::MultiLeader(edited) = &expected else { unreachable!() };
assert_eq!(edited.context.text_string, "EDITED", "edit failed: {}", last_output(&host));
assert_eq!(edited.context.leader_roots[0].lines[0].points.len(), 4);
assert_eq!(edited.dogleg_length, 3.0);
assert_eq!(edited.context.text_height, 3.0);
assert_eq!(edited.text_height, created_ml.text_height);
assert_eq!(edited.line_color, acadrust::types::Color::Rgb { r: 0, g: 128, b: 255 });
// Untouched context fields and the entity identity survive a partial patch.
assert_eq!(edited.common, created_ml.common);
assert_eq!(edited.context.scale_factor, created_ml.context.scale_factor);
assert_eq!(edited.content_type, created_ml.content_type);
assert_eq!(edited.context.leader_roots[0].connection_point, created_ml.context.leader_roots[0].connection_point);
assert_eq!(host.selection(), vec![handle]);
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty());
for (patch, message) in [
("'text_height':0.0", "read-only"),
("'scale_factor':-1.0", "greater than zero"),
("'context':{'text_height':float('inf')}", "finite"),
("'style_handle':999999", "does not exist"),
("'text_style_handle':999999", "does not exist"),
("'content_type':'None'", "cannot carry"),
("'context':{'nonsense':1}", "outside the editable schema"),
("'context':{'leader_roots':[{'lines':[{'points':[]}]}]}", "has no points"),
("'context':{'text_location':{'x':float('nan'),'y':0.0,'z':0.0}}", "finite"),
("'dwg_version':5", "read-only"),
("'line_color':{'kind':'Index','value':300}", "Color"),
] {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject multileader', [{{'handle':{}, {patch}}}])", handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}");
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
}
dispatch(&mut host, &format!(concat!(
"PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'dogleg_length':9.0}},",
"{{'handle':{},'scale_factor':-1.0}}])"
), handle.value(), handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected));
let mut gaps = Vec::new();
let dwg = crate::io::load_bytes("mleader.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("mleader.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
let Some(EntityType::MultiLeader(value)) = document.get_entity(handle) else {
gaps.push(format!("{format}: MultiLeader missing"));
continue;
};
if value.context.text_string != "EDITED" { gaps.push(format!("{format}: text {:?}", value.context.text_string)); }
let points = value.context.leader_roots.first().and_then(|r| r.lines.first()).map_or(0, |l| l.points.len());
if points != 4 { gaps.push(format!("{format}: leader points {points}")); }
if value.dogleg_length != 3.0 { gaps.push(format!("{format}: dogleg {}", value.dogleg_length)); }
if value.context.text_height != edited.context.text_height { gaps.push(format!("{format}: context.text_height {}", value.context.text_height)); }
if value.line_color != edited.line_color { gaps.push(format!("{format}: line_color {:?}", value.line_color)); }
}
assert!(gaps.is_empty(), "persistence gaps: {gaps:#?}");
for (name, mut document) in [("mleader-reedit.dwg", dwg), ("mleader-reedit.dxf", dxf)] {
let before = document.get_entity(handle).unwrap().clone();
let mut after = before.clone();
let EntityType::MultiLeader(value) = &mut after else { unreachable!() };
value.dogleg_length = 4.5;
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap();
*document.get_entity_mut(handle).unwrap() = after;
let bytes = if name.ends_with("dwg") {
acadrust::DwgWriter::write_to_vec(&document).unwrap()
} else {
acadrust::DxfWriter::new(&document).write_to_vec().unwrap()
};
assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle),
Some(EntityType::MultiLeader(value)) if value.dogleg_length == 4.5), "{name}");
}
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value()));
assert!(host.document().get_entity(handle).is_none());
for (name, bytes) in [
("mleader-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
("mleader-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()),
] {
assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none());
}
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected));
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_table_lifecycle_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Table', insertion_point={'x':5.0,'y':5.0,'z':0.0}).handle");
let handle = host.document().entities().find_map(|entity| match entity {
EntityType::Table(value) => Some(value.common.handle),
_ => None,
}).unwrap_or_else(|| panic!("Python did not create Table: {}", last_output(&host)));
let created = host.document().get_entity(handle).unwrap().clone();
let EntityType::Table(created_table) = &created else { unreachable!() };
assert_eq!((created_table.rows.len(), created_table.columns.len()), (3, 3));
assert_eq!(created_table.insertion_point, acadrust::types::Vector3::new(5.0, 5.0, 0.0));
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry");
let script = std::env::temp_dir().join(format!("ocs_table_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"doc = ocs.active_document\n",
"t = doc.entities[{}]\n",
"rows = t.rows\n",
"cols = t.columns\n",
"rows.append(dict(rows[0]))\n",
"cols[0]['width'] = 30.0\n",
"rows[1]['cells'][1]['contents'] = [{{'content_type': 'Value', 'scale': 1.0, 'text_height': 2.5,\n",
" 'value': {{'value_type': 'String', 'raw_type_code': 4, 'text': 'Hello', 'formatted_value': 'Hello'}}}}]\n",
"with doc.transaction('Edit table'):\n",
" t.rows = rows\n",
" t.columns = cols\n",
" t.merged_ranges = [{{'top_row': 0, 'left_col': 0, 'bottom_row': 0, 'right_col': 2}}]\n",
" t.break_spacing = 2.0\n",
"doc.selection = [t]\n"
), handle.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let expected = host.document().get_entity(handle).unwrap().clone();
let EntityType::Table(edited) = &expected else { unreachable!() };
assert_eq!(edited.rows.len(), 4, "edit failed: {}", last_output(&host));
assert_eq!(edited.columns[0].width, 30.0);
assert_eq!(edited.rows[1].cells[1].contents[0].value.text, "Hello");
assert_eq!(edited.merged_ranges.len(), 1);
assert_eq!(edited.break_spacing, 2.0);
assert_eq!(edited.common, created_table.common);
assert_eq!(edited.insertion_point, created_table.insertion_point);
assert_eq!(edited.normal, created_table.normal);
assert_eq!(edited.columns[1], created_table.columns[1]);
assert_eq!(edited.rows[2], created_table.rows[2]);
assert_eq!(edited.table_style_handle, created_table.table_style_handle);
assert_eq!(host.selection(), vec![handle]);
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty());
for (patch, message) in [
("'columns':[{'name':'A','width':0.0},{'width':1.0},{'width':1.0}]", "greater than zero"),
("'columns':[{'width':5.0}]", "cells but the table has 1 columns"),
("'columns':[]", "at least one row and one column"),
("'merged_ranges':[{'top_row':0,'left_col':0,'bottom_row':9,'right_col':0}]", "outside the"),
("'merged_ranges':[{'top_row':1,'left_col':0,'bottom_row':2,'right_col':1},{'top_row':2,'left_col':1,'bottom_row':3,'right_col':2}]", "overlaps"),
("'table_style_handle':999999", "does not exist"),
("'block_name':'x'", "read-only"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
("'break_spacing':-1.0", "non-negative"),
("'insertion_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
] {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject table', [{{'handle':{}, {patch}}}])", handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}");
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
}
dispatch(&mut host, &format!(concat!(
"PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'break_spacing':7.0}},",
"{{'handle':{},'break_spacing':-1.0}}])"
), handle.value(), handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected));
let mut gaps = Vec::new();
let dwg = crate::io::load_bytes("table.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("table.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
let Some(EntityType::Table(value)) = document.get_entity(handle) else {
gaps.push(format!("{format}: Table missing"));
continue;
};
if value.rows.len() != 4 { gaps.push(format!("{format}: rows {}", value.rows.len())); }
if value.columns.first().map(|c| c.width) != Some(30.0) { gaps.push(format!("{format}: column width {:?}", value.columns.first().map(|c| c.width))); }
let text = value.rows.get(1).and_then(|r| r.cells.get(1)).and_then(|c| c.contents.first()).map(|c| c.value.text.clone());
if text.as_deref() != Some("Hello") { gaps.push(format!("{format}: cell text {text:?}")); }
// DWG stores the ranges; DXF stores the origin cell's merge
// dimensions and its reader does not rebuild `merged_ranges`
// (engine gap, recorded in the ledger). Scripted saves populate
// both, so each format keeps the merge in its own form.
let origin = &value.rows[0].cells[0];
if format == "DXF" {
if (origin.merge_width, origin.merge_height) != (3, 1) { gaps.push(format!("DXF: merge dims {}x{}", origin.merge_width, origin.merge_height)); }
if !value.merged_ranges.is_empty() { gaps.push("DXF: reader now rebuilds merged_ranges; drop the blocker".into()); }
} else if value.merged_ranges.len() != 1 {
gaps.push(format!("DWG: merged_ranges {}", value.merged_ranges.len()));
}
if value.insertion_point != edited.insertion_point { gaps.push(format!("{format}: insertion {:?}", value.insertion_point)); }
}
assert!(gaps.is_empty(), "persistence gaps: {gaps:#?}");
for (name, mut document) in [("table-reedit.dwg", dwg), ("table-reedit.dxf", dxf)] {
let before = document.get_entity(handle).unwrap().clone();
let mut after = before.clone();
let EntityType::Table(value) = &mut after else { unreachable!() };
value.columns[1].width = 12.0;
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap();
*document.get_entity_mut(handle).unwrap() = after;
let bytes = if name.ends_with("dwg") {
acadrust::DwgWriter::write_to_vec(&document).unwrap()
} else {
acadrust::DxfWriter::new(&document).write_to_vec().unwrap()
};
assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle),
Some(EntityType::Table(value)) if value.columns[1].width == 12.0), "{name}");
}
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value()));
assert!(host.document().get_entity(handle).is_none());
for (name, bytes) in [
("table-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
("table-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()),
] {
assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none());
}
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected));
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created));
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
app.redo_steps(3);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
/// One scripted lifecycle shared by the mesh kinds: create, edit,
/// reject, save/reopen both formats, re-edit, delete, undo/redo.
struct MeshCase {
create: &'static str,
edit: &'static str,
rejects: &'static [(&'static str, &'static str)],
is_kind: fn(&EntityType) -> bool,
digest: fn(&EntityType) -> String,
reedit: fn(&mut EntityType),
expect_created: &'static str,
expect_edited: &'static str,
expect_reedited: &'static str,
/// Digest a DXF reopen yields when the engine loses a field (empty:
/// same as DWG). A canary that fails once the engine round-trips it.
expect_edited_dxf: &'static str,
expect_reedited_dxf: &'static str,
/// Same idea for DWG (empty: same as the in-session digest).
expect_edited_dwg: &'static str,
expect_reedited_dwg: &'static str,
}
fn run_mesh_lifecycle(case: &MeshCase) {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let run_script = |host: &mut HostSession<'_>, tag: &str, body: &str| {
let path = std::env::temp_dir().join(format!("ocs_mesh_{tag}_{}.py", std::process::id()));
std::fs::write(&path, format!("def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\ndoc = ocs.active_document\n{body}")).unwrap();
dispatch(host, &format!("PY_RUN {}", path.display()));
let _ = std::fs::remove_file(&path);
};
let tmp = std::env::temp_dir().to_string_lossy().into_owned();
let mut create_script = case.create.replace("TMPDIR", &tmp);
// Insert is update-only: the host makes a block and one insert, and the
// "create" script only has to exist.
let make_polyline = create_script.contains("MAKEPOLYLINE");
if make_polyline {
// The legacy Polyline is update-only: the host makes one to edit.
let points = [(0.0, 0.0, 0.0), (10.0, 0.0, 2.0), (10.0, 5.0, 4.0)]
.map(|(x, y, z)| acadrust::types::Vector3::new(x, y, z));
host.add_entity(EntityType::Polyline(acadrust::entities::Polyline::from_points(points.to_vec())));
}
let make_ole = create_script.contains("MAKEOLE");
if make_ole {
// An Ole2Frame is update-only: the host embeds a real picture.
let mut png = std::io::Cursor::new(Vec::new());
image::RgbaImage::from_pixel(4, 3, image::Rgba([10, 120, 200, 255]))
.write_to(&mut png, image::ImageFormat::Png)
.unwrap();
let picture = crate::io::ole_embed::EmbeddedImage {
bytes: png.into_inner(),
pixel_width: 4,
pixel_height: 3,
name: "audit.png".into(),
};
crate::io::ole_embed::add_embedded_image(
host.document_mut(),
&picture,
acadrust::types::Vector3::new(10.0, 10.0, 0.0),
20.0,
)
.unwrap();
}
let update_only = create_script.contains("MAKEINSERT") || make_polyline || make_ole;
if update_only || create_script.contains("MAKEBLOCK") {
let next = host.document().next_handle();
let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2));
let mut record = acadrust::tables::BlockRecord::new("AUDITBLK");
record.handle = record_handle;
record.block_entity_handle = block_handle;
record.block_end_handle = end_handle;
host.document_mut().block_records.add(record).unwrap();
let mut block = acadrust::entities::Block::new("AUDITBLK", acadrust::types::Vector3::ZERO);
block.common.handle = block_handle;
block.common.owner_handle = record_handle;
host.document_mut().add_entity(EntityType::Block(block)).unwrap();
let mut end = acadrust::entities::BlockEnd::new();
end.common.handle = end_handle;
end.common.owner_handle = record_handle;
host.document_mut().add_entity(EntityType::BlockEnd(end)).unwrap();
if update_only {
let insert = acadrust::entities::Insert::new("AUDITBLK", acadrust::types::Vector3::new(1.0, 2.0, 0.0));
host.add_entity(EntityType::Insert(insert));
}
create_script = create_script.replace("BLOCKRECORD", &record_handle.value().to_string());
}
let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle.value());
let layer0 = host.document().layers.iter().find(|l| l.name == "0").map(|l| l.handle.value());
if create_script.contains("VIEWPORTHANDLE") || create_script.contains("SCALEHANDLE") {
// A view border ties to an existing viewport and scale.
let owner = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle).unwrap();
let mut viewport = acadrust::entities::Viewport::new();
viewport.common.owner_handle = owner;
let viewport_handle = host.add_entity(EntityType::Viewport(viewport));
let scale_handle = host.document_mut().allocate_handle();
let mut scale = acadrust::objects::Scale::new("1:1", 1.0, 1.0);
scale.handle = scale_handle;
host.document_mut().objects.insert(scale_handle, acadrust::objects::ObjectType::Scale(scale));
create_script = create_script
.replace("VIEWPORTHANDLE", &viewport_handle.value().to_string())
.replace("SCALEHANDLE", &scale_handle.value().to_string());
}
create_script = create_script
.replace("PAPERSPACE", &paper.unwrap_or_default().to_string())
.replace("LAYER0", &layer0.unwrap_or_default().to_string());
if create_script.contains("DEFHANDLE") {
// Scripts reference an underlay definition that the drawing owns.
let handle = host.document_mut().allocate_handle();
let mut definition = acadrust::objects::UnderlayDefinition::pdf("plan.pdf", "1");
definition.handle = handle;
host.document_mut().objects.insert(handle, acadrust::objects::ObjectType::UnderlayDefinition(definition));
create_script = create_script.replace("DEFHANDLE", &handle.value().to_string());
}
run_script(&mut host, "create", &create_script);
let handle = host.document().entities().find(|entity| (case.is_kind)(entity))
.map(|entity| entity.common().handle)
.unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host)));
let created = host.document().get_entity(handle).unwrap().clone();
assert_eq!((case.digest)(&created), case.expect_created);
// A paper-space viewport draws only on its layout tab, so the canvas
// oracle first activates the layout that owns it.
if matches!(created, EntityType::Viewport(_)) {
let owner = created.common().owner_handle;
let layout = host.document().objects.values().find_map(|object| match object {
acadrust::objects::ObjectType::Layout(l) if l.block_record == owner => Some(l.name.clone()),
_ => None,
}).expect("the viewport's owner is a layout block");
host.app.tabs[0].scene.set_current_layout(layout);
}
let has_wires = true;
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry");
run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string()).replace("TMPDIR", &tmp).replace("LAYER0", &layer0.unwrap_or_default().to_string()));
let expected = host.document().get_entity(handle).unwrap().clone();
assert_eq!((case.digest)(&expected), case.expect_edited, "edit failed: {}", last_output(&host));
assert_eq!(expected.common(), created.common());
assert_eq!(host.selection(), vec![handle]);
if has_wires {
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty());
}
for (patch, message) in case.rejects {
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Reject mesh', [{{'handle':{}, {patch}}}])", handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}");
assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host));
}
let (first_patch, _) = case.rejects[0];
dispatch(&mut host, &format!(
"PY_EVAL ocs.update_many('Atomic', [{{'handle':{h}, 'layer':'Other'}}, {{'handle':{h}, {first_patch}}}])",
h = handle.value()));
assert_eq!(host.document().get_entity(handle), Some(&expected));
let dwg = crate::io::load_bytes("mesh.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("mesh.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
let mut gaps = Vec::new();
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
let expected_digest = if format == "DXF" && !case.expect_edited_dxf.is_empty() {
case.expect_edited_dxf
} else if format == "DWG" && !case.expect_edited_dwg.is_empty() {
case.expect_edited_dwg
} else {
case.expect_edited
};
match document.get_entity(handle) {
Some(entity) if (case.digest)(entity) == expected_digest => {}
Some(entity) => gaps.push(format!("{format}: {}", (case.digest)(entity))),
None => gaps.push(format!("{format}: entity missing")),
}
}
assert!(gaps.is_empty(), "persistence gaps (expected {} / DXF {:?}): {gaps:#?}", case.expect_edited, case.expect_edited_dxf);
for (name, mut document) in [("mesh-reedit.dwg", dwg), ("mesh-reedit.dxf", dxf)] {
let before = document.get_entity(handle).unwrap().clone();
let mut after = before.clone();
(case.reedit)(&mut after);
ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap();
ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap();
*document.get_entity_mut(handle).unwrap() = after;
let bytes = if name.ends_with("dwg") {
acadrust::DwgWriter::write_to_vec(&document).unwrap()
} else {
acadrust::DxfWriter::new(&document).write_to_vec().unwrap()
};
let reopened = crate::io::load_bytes(name, bytes).unwrap();
let expected_digest = if name.ends_with("dxf") && !case.expect_reedited_dxf.is_empty() {
case.expect_reedited_dxf
} else if name.ends_with("dwg") && !case.expect_reedited_dwg.is_empty() {
case.expect_reedited_dwg
} else {
case.expect_reedited
};
assert_eq!((case.digest)(reopened.get_entity(handle).unwrap()), expected_digest, "{name}");
}
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value()));
assert!(host.document().get_entity(handle).is_none());
for (name, bytes) in [
("mesh-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()),
("mesh-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()),
] {
assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none());
}
drop(process);
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), if update_only { 2 } else { 3 });
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected));
app.undo_steps(1);
assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created));
if !update_only {
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
app.redo_steps(if update_only { 2 } else { 3 });
assert!(app.tabs[0].scene.document.get_entity(handle).is_none());
}
#[test]
fn staged_python_polygon_mesh_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"verts = [{'location': P(i, j, 0.0)} for i in range(3) for j in range(3)]\n",
"doc.create_entity('PolygonMesh', m_vertex_count=3, n_vertex_count=3, vertices=verts)\n"),
edit: concat!(
"mesh = doc.entities[HANDLE]\n",
"verts = mesh.vertices\n",
"verts[4]['location'] = P(1.0, 1.0, 2.5)\n",
"with doc.transaction('Edit mesh'):\n",
" mesh.vertices = verts\n",
" mesh.m_smooth_density = 6\n",
" mesh.n_smooth_density = 6\n",
" mesh.smooth_type = 'Cubic'\n",
"doc.selection = [mesh]\n"),
rejects: &[
("'m_vertex_count':4", "requires 12"),
("'n_vertex_count':1", "at least 2"),
("'m_smooth_density':-1", "non-negative"),
("'elevation':float('nan')", "finite"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}}]*9", "finite"),
("'smooth_type':'Spline'", "unsupported"),
],
is_kind: |entity| matches!(entity, EntityType::PolygonMesh(_)),
digest: |entity| match entity {
EntityType::PolygonMesh(m) => format!("{}x{} v{} z{} d{}/{} {:?}", m.m_vertex_count, m.n_vertex_count,
m.vertices.len(), m.vertices[4].location.z, m.m_smooth_density, m.n_smooth_density, m.smooth_type),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::PolygonMesh(m) = entity { m.vertices[0].location.z = 1.0; },
expect_created: "3x3 v9 z0 d0/0 NoSmooth",
expect_edited: "3x3 v9 z2.5 d6/6 Cubic",
expect_reedited: "3x3 v9 z2.5 d6/6 Cubic",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn staged_python_polyface_mesh_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"verts = [{'location': P(0.0, 0.0, 0.0)}, {'location': P(4.0, 0.0, 0.0)},\n",
" {'location': P(4.0, 4.0, 0.0)}, {'location': P(0.0, 4.0, 0.0)}]\n",
"faces = [{'index1': 1, 'index2': 2, 'index3': 3}, {'index1': 1, 'index2': 3, 'index3': 4}]\n",
"doc.create_entity('PolyfaceMesh', vertices=verts, faces=faces)\n"),
edit: concat!(
"mesh = doc.entities[HANDLE]\n",
"verts = mesh.vertices\n",
"verts[2]['location'] = P(4.0, 4.0, 3.0)\n",
"verts.append({'location': P(2.0, 6.0, 0.0)})\n",
"faces = mesh.faces\n",
"faces.append({'index1': 3, 'index2': -4, 'index3': 5})\n",
"with doc.transaction('Edit mesh'):\n",
" mesh.vertices = verts\n",
" mesh.faces = faces\n",
"doc.selection = [mesh]\n"),
rejects: &[
("'faces':[{'index1':1,'index2':2,'index3':9}]", "references vertex 9"),
("'faces':[{'index1':1,'index2':2}]", "at least 3 vertex indices"),
("'faces':[]", "at least 1 face"),
("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 3"),
("'thickness':float('inf')", "finite"),
("'seqend_handle':5", "read-only"),
],
is_kind: |entity| matches!(entity, EntityType::PolyfaceMesh(_)),
digest: |entity| match entity {
EntityType::PolyfaceMesh(m) => format!("v{} f{} z{} last {:?}", m.vertices.len(), m.faces.len(),
m.vertices[2].location.z,
m.faces.last().map(|f| (f.index1, f.index2, f.index3))),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::PolyfaceMesh(m) = entity { m.vertices[0].location.z = 1.0; },
expect_created: "v4 f2 z0 last Some((1, 3, 4))",
expect_edited: "v5 f3 z3 last Some((3, -4, 5))",
expect_reedited: "v5 f3 z3 last Some((3, -4, 5))",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn staged_python_mesh_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"verts = [P(0.0, 0.0, 0.0), P(4.0, 0.0, 0.0), P(4.0, 4.0, 0.0), P(0.0, 4.0, 0.0)]\n",
"doc.create_entity('Mesh', vertices=verts, faces=[{'vertices': [0, 1, 2, 3]}],\n",
" edges=[{'start': 0, 'end': 1, 'crease': 1.5}])\n"),
edit: concat!(
"mesh = doc.entities[HANDLE]\n",
"verts = mesh.vertices\n",
"verts.append(P(2.0, 6.0, 0.0))\n",
"faces = mesh.faces\n",
"faces.append({'vertices': [3, 2, 4]})\n",
"with doc.transaction('Edit mesh'):\n",
" mesh.vertices = verts\n",
" mesh.faces = faces\n",
" mesh.subdivision_level = 1\n",
"doc.selection = [mesh]\n"),
rejects: &[
("'faces':[{'vertices':[0,1,9]}]", "references vertex 9"),
("'faces':[{'vertices':[0,1]}]", "at least 3 vertices"),
("'edges':[{'start':0,'end':9}]", "out of range"),
("'edges':[{'start':2,'end':2}]", "identical endpoints"),
("'edges':[{'start':0,'end':1,'crease':-1.0}]", "non-negative"),
("'subdivision_level':-1", "non-negative"),
("'version':3", "read-only"),
],
is_kind: |entity| matches!(entity, EntityType::Mesh(_)),
digest: |entity| match entity {
EntityType::Mesh(m) => format!("v{} f{} e{} sub{} crease {:?}", m.vertices.len(), m.faces.len(),
m.edges.len(), m.subdivision_level, m.edges.first().and_then(|e| e.crease)),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Mesh(m) = entity { m.vertices[0].z = 1.0; },
expect_created: "v4 f1 e1 sub0 crease Some(1.5)",
expect_edited: "v5 f2 e1 sub1 crease Some(1.5)",
expect_reedited: "v5 f2 e1 sub1 crease Some(1.5)",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn staged_python_helix_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"doc.create_entity('Helix', axis_base_point=P(0.0, 0.0, 0.0), axis_vector=P(0.0, 0.0, 1.0),\n",
" start_point=P(5.0, 0.0, 0.0), radius=5.0, turns=3.0, turn_height=2.0)\n"),
edit: concat!(
"helix = doc.entities[HANDLE]\n",
"with doc.transaction('Edit helix'):\n",
" helix.turns = 5.0\n",
" helix.handedness = False\n",
"doc.selection = [helix]\n"),
rejects: &[
("'radius':0.0", "greater than zero"),
("'turns':-1.0", "greater than zero"),
("'turn_height':float('nan')", "greater than zero"),
("'axis_vector':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
("'start_point':{'x':0.0,'y':0.0,'z':3.0}", "on the axis"),
("'spline':{}", "read-only"),
("'major_version':1", "read-only"),
],
is_kind: |entity| matches!(entity, EntityType::Helix(_)),
digest: |entity| match entity {
EntityType::Helix(h) => format!("r{} t{} h{} endz{:.1} ccw{} cp{}", h.radius, h.turns, h.turn_height,
h.spline.control_points.last().map_or(f64::NAN, |p| p.z), h.handedness,
!h.spline.control_points.is_empty()),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Helix(h) = entity { h.turns = 6.0; },
expect_created: "r5 t3 h2 endz6.0 ccwtrue cptrue",
expect_edited: "r5 t5 h2 endz10.0 ccwfalse cptrue",
expect_reedited: "r5 t6 h2 endz10.0 ccwfalse cptrue",
// BLOCKER: the DXF reader parses boolean group 290 as an i16 and
// never applies it, so handedness always reopens counter-clockwise.
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "r5 t5 h2 endz10.0 ccwtrue cptrue",
expect_reedited_dxf: "r5 t6 h2 endz10.0 ccwtrue cptrue",
});
}
#[test]
fn staged_python_raster_image_lifecycle_over_real_ipc() {
image::RgbaImage::from_pixel(8, 4, image::Rgba([200, 30, 30, 255]))
.save(std::env::temp_dir().join("ocs_raster_test.png"))
.unwrap();
run_mesh_lifecycle(&MeshCase {
create: concat!(
"doc.create_entity('RasterImage', file_path='TMPDIR/ocs_raster_test.png',\n",
" insertion_point=P(10.0, 20.0, 0.0), u_vector=P(0.5, 0.0, 0.0), v_vector=P(0.0, 0.5, 0.0))\n"),
edit: concat!(
"img = doc.entities[HANDLE]\n",
"cb = img.clip_boundary\n",
"cb['vertices'] = [{'x': 1.0, 'y': 1.0}, {'x': 7.0, 'y': 3.0}]\n",
"with doc.transaction('Edit image'):\n",
" img.insertion_point = (12.0, 22.0, 0.0)\n",
" img.u_vector = (1.0, 0.0, 0.0)\n",
" img.brightness = 70\n",
" img.contrast = 40\n",
" img.fade = 10\n",
" img.clip_boundary = cb\n",
" img.clipping_enabled = True\n",
"doc.selection = [img]\n"),
rejects: &[
("'file_path':'other.png'", "fixed at creation"),
("'brightness':101", "within 0..=100"),
("'u_vector':{'x':0.0,'y':0.0,'z':0.0}", "not parallel"),
("'v_vector':{'x':2.0,'y':0.0,'z':0.0}", "not parallel"),
("'size':{'x':5.0,'y':5.0}", "read-only"),
("'definition_handle':5", "read-only"),
("'flags':64", "unknown or out-of-range bits"),
("'clip_boundary':{'vertices':[]}", "at least 2"),
],
is_kind: |entity| matches!(entity, EntityType::RasterImage(_)),
digest: |entity| match entity {
EntityType::RasterImage(i) => format!("{} {:.1},{:.1} u{:.1} b{} c{} f{} clip{} n{} {}x{} def{}",
std::path::Path::new(&i.file_path).file_name().unwrap().to_string_lossy(),
i.insertion_point.x, i.insertion_point.y, i.u_vector.x, i.brightness, i.contrast, i.fade,
i.clipping_enabled, i.clip_boundary.vertices.len(), i.size.x, i.size.y, i.definition_handle.is_some()),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::RasterImage(i) = entity { i.brightness = 80; },
expect_created: "ocs_raster_test.png 10.0,20.0 u0.5 b50 c50 f0 clipfalse n2 8x4 deftrue",
expect_edited: "ocs_raster_test.png 12.0,22.0 u1.0 b70 c40 f10 cliptrue n2 8x4 deftrue",
expect_reedited: "ocs_raster_test.png 12.0,22.0 u1.0 b80 c40 f10 cliptrue n2 8x4 deftrue",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn staged_python_wipeout_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"doc.create_entity('Wipeout', insertion_point=P(0.0, 0.0, 0.0),\n",
" u_vector=P(10.0, 0.0, 0.0), v_vector=P(0.0, 6.0, 0.0))\n"),
edit: concat!(
"w = doc.entities[HANDLE]\n",
"with doc.transaction('Edit wipeout'):\n",
" w.clip_type = 'Polygonal'\n",
" w.clip_boundary_vertices = [{'x': -0.5, 'y': -0.5}, {'x': 0.5, 'y': -0.5}, {'x': 0.0, 'y': 0.5}]\n",
" w.insertion_point = (2.0, 3.0, 0.0)\n",
" w.clip_mode = 'Inside'\n",
"doc.selection = [w]\n"),
rejects: &[
("'clip_boundary_vertices':[{'x':0.0,'y':0.0}]", "at least 3"),
("'clip_type':'Rectangular'", "exactly 2"),
("'u_vector':{'x':0.0,'y':0.0,'z':0.0}", "not parallel"),
("'brightness':200", "within 0..=100"),
("'size':{'x':0.0,'y':1.0}", "greater than zero"),
("'insertion_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
("'definition_handle':5", "read-only"),
("'clip_type':'Circle'", "unsupported"),
],
is_kind: |entity| matches!(entity, EntityType::Wipeout(_)),
digest: |entity| match entity {
EntityType::Wipeout(w) => format!("{:?} {:?} n{} at{:.1},{:.1} u{:.1} v{:.1}", w.clip_type, w.clip_mode,
w.clip_boundary_vertices.len(), w.insertion_point.x, w.insertion_point.y, w.u_vector.x, w.v_vector.y),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Wipeout(w) = entity { w.u_vector.x = 12.0; },
expect_created: "Rectangular Outside n2 at0.0,0.0 u10.0 v6.0",
expect_edited: "Polygonal Inside n3 at2.0,3.0 u10.0 v6.0",
expect_reedited: "Polygonal Inside n3 at2.0,3.0 u12.0 v6.0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn staged_python_underlay_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"doc.create_entity('Underlay', underlay_type='Pdf', definition_handle=DEFHANDLE,\n",
" insertion_point=P(5.0, 5.0, 0.0), x_scale=2.0, y_scale=2.0)\n"),
edit: concat!(
"u = doc.entities[HANDLE]\n",
"with doc.transaction('Edit underlay'):\n",
" u.insertion_point = (8.0, 9.0, 0.0)\n",
" u.x_scale = 3.0\n",
" u.y_scale = 3.0\n",
" u.rotation = 0.5\n",
" u.contrast = 70\n",
" u.fade = 20\n",
" u.clip_boundary_vertices = [{'x': 0.0, 'y': 0.0}, {'x': 4.0, 'y': 3.0}]\n",
"doc.selection = [u]\n"),
rejects: &[
("'definition_handle':999999", "does not exist"),
("'underlay_type':'Dwf'", "does not match its definition"),
("'x_scale':0.0", "nonzero"),
("'rotation':float('nan')", "finite"),
("'contrast':101", "within 0..=100"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
("'clip_boundary_vertices':[{'x':1.0,'y':1.0}]", "at least 2"),
("'flags':64", "unknown or out-of-range bits"),
],
is_kind: |entity| matches!(entity, EntityType::Underlay(_)),
digest: |entity| match entity {
EntityType::Underlay(u) => format!("{:?} at{:.1},{:.1} s{:.1} r{:.1} c{} f{} clip{}", u.underlay_type,
u.insertion_point.x, u.insertion_point.y, u.x_scale, u.rotation, u.contrast, u.fade,
u.clip_boundary_vertices.len()),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Underlay(u) = entity { u.x_scale = 4.0; },
expect_created: "Pdf at5.0,5.0 s2.0 r0.0 c100 f0 clip0",
expect_edited: "Pdf at8.0,9.0 s3.0 r0.5 c70 f20 clip2",
expect_reedited: "Pdf at8.0,9.0 s4.0 r0.5 c70 f20 clip2",
// BLOCKER: DXF stores the rotation in degrees and the reader returns
// it unconverted, so 0.5 rad reopens as 28.6 and a second save-reopen compounds it to 1641.4.
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "Pdf at8.0,9.0 s3.0 r28.6 c70 f20 clip2",
expect_reedited_dxf: "Pdf at8.0,9.0 s4.0 r1641.4 c70 f20 clip2",
});
}
#[test]
fn staged_python_viewport_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"doc.create_entity('Viewport', owner_handle=PAPERSPACE, center=P(100.0, 100.0, 0.0),\n",
" width=80.0, height=60.0, view_center=P(0.0, 0.0, 0.0), view_height=50.0)\n"),
edit: concat!(
"vp = doc.entities[HANDLE]\n",
"with doc.transaction('Edit viewport'):\n",
" vp.center = (120.0, 110.0, 0.0)\n",
" vp.width = 90.0\n",
" vp.view_height = 75.0\n",
" vp.twist_angle = 0.25\n",
" vp.frozen_layers = [LAYER0]\n",
"doc.selection = [vp]\n"),
rejects: &[
("'width':0.0", "greater than zero"),
("'view_height':float('nan')", "greater than zero"),
("'view_direction':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
("'frozen_layers':[999999]", "does not exist"),
("'circle_sides':2", "at least 3"),
("'id':7", "read-only"),
("'clip_boundary_handle':5", "read-only"),
],
is_kind: |entity| matches!(entity, EntityType::Viewport(_)),
digest: |entity| match entity {
EntityType::Viewport(v) => format!("at{:.1},{:.1} {:.1}x{:.1} vh{:.1} tw{:.2} frozen{}", v.center.x, v.center.y,
v.width, v.height, v.view_height, v.twist_angle, v.frozen_layers.len()),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Viewport(v) = entity { v.width = 95.0; },
expect_created: "at100.0,100.0 80.0x60.0 vh50.0 tw0.00 frozen0",
expect_edited: "at120.0,110.0 90.0x60.0 vh75.0 tw0.25 frozen1",
expect_reedited: "at120.0,110.0 95.0x60.0 vh75.0 tw0.25 frozen1",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn staged_python_view_border_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"doc.create_entity('ViewBorder', min=[0.0, 0.0], max=[40.0, 30.0], center=[20.0, 15.0], scale=1.0,\n",
" active_viewport=VIEWPORTHANDLE, scale_handle=SCALEHANDLE)\n"),
edit: concat!(
"vb = doc.entities[HANDLE]\n",
"with doc.transaction('Edit view border'):\n",
" vb.max = [60.0, 45.0]\n",
" vb.center = [30.0, 22.5]\n",
" vb.scale = 2.0\n",
" vb.rotation_angle = 0.3\n",
"doc.selection = [vb]\n"),
rejects: &[
("'max':[-1.0, 5.0]", "below max"),
("'scale':0.0", "greater than zero"),
("'rotation_angle':float('nan')", "finite"),
("'center':[float('inf'), 0.0]", "finite"),
("'active_viewport':999999", "does not exist"),
("'scale_handle':999999", "does not exist"),
("'version':3", "read-only"),
],
is_kind: |entity| matches!(entity, EntityType::ViewBorder(_)),
digest: |entity| match entity {
EntityType::ViewBorder(v) => format!("max{:.1},{:.1} c{:.1},{:.1} s{:.1} r{:.1}", v.max[0], v.max[1],
v.center[0], v.center[1], v.scale, v.rotation_angle),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::ViewBorder(v) = entity { v.scale = 3.0; },
expect_created: "max40.0,30.0 c20.0,15.0 s1.0 r0.0",
expect_edited: "max60.0,45.0 c30.0,22.5 s2.0 r0.3",
expect_reedited: "max60.0,45.0 c30.0,22.5 s3.0 r0.3",
// BLOCKER: a DXF DRAWINGVIEW reopens as a different (opaque) entity
// kind, not as a ViewBorder; only DWG restores the typed record.
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "wrong kind",
expect_reedited_dxf: "wrong kind",
});
}
#[test]
fn staged_python_light_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"doc.create_entity('Light', name='Key', light_type=3, position=P(10.0, 10.0, 10.0),\n",
" target=P(0.0, 0.0, 0.0), intensity=1.5, hotspot_angle=0.4, falloff_angle=0.8,\n",
" light_color={'kind': 'Rgb', 'value': {'r': 255, 'g': 240, 'b': 200}})\n"),
edit: concat!(
"lt = doc.entities[HANDLE]\n",
"with doc.transaction('Edit light'):\n",
" lt.intensity = 3.0\n",
" lt.target = (5.0, 5.0, 0.0)\n",
" lt.position = (12.0, 8.0, 10.0)\n",
" lt.cast_shadows = True\n",
"doc.selection = [lt]\n"),
rejects: &[
("'light_type':9", "must be 1 (distant)"),
("'intensity':-1.0", "non-negative"),
("'target':{'x':12.0,'y':8.0,'z':10.0}", "different from its position"),
("'hotspot_angle':2.0", "must not exceed falloff_angle"),
("'position':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
("'name':''", "empty"),
("'class_version':2", "read-only"),
("'light_color':{'kind':'Index','value':300}", "Color"),
],
is_kind: |entity| matches!(entity, EntityType::Light(_)),
digest: |entity| match entity {
EntityType::Light(l) => format!("{} t{} i{:.1} pos{:.0},{:.0},{:.0} tgt{:.0},{:.0} sh{} {:?}", l.name, l.light_type,
l.intensity, l.position.x, l.position.y, l.position.z, l.target.x, l.target.y, l.cast_shadows, l.light_color),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Light(l) = entity { l.intensity = 4.0; },
expect_created: "Key t3 i1.5 pos10,10,10 tgt0,0 shfalse Rgb { r: 255, g: 240, b: 200 }",
expect_edited: "Key t3 i3.0 pos12,8,10 tgt5,5 shtrue Rgb { r: 255, g: 240, b: 200 }",
expect_reedited: "Key t3 i4.0 pos12,8,10 tgt5,5 shtrue Rgb { r: 255, g: 240, b: 200 }",
// BLOCKER: DXF does not restore `cast_shadows` (it reopens false),
// like the other boolean groups the reader parses as integers.
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "Key t3 i3.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }",
expect_reedited_dxf: "Key t3 i4.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }",
});
}
#[test]
fn audit_python_point_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('Point', location=P(1.0, 2.0, 3.0))",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.location = (4.0, 5.0, 6.0)\n", " e.thickness = 2.0\n", "doc.selection = [e]\n"),
rejects: &[
("'location':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
("'thickness':float('inf')", "finite"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
],
is_kind: |entity| matches!(entity, EntityType::Point(_)),
digest: |entity| match entity {
EntityType::Point(v) => format!("{:.1},{:.1},{:.1} th{:.1}", v.location.x, v.location.y, v.location.z, v.thickness),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Point(v) = entity { v.location.z = 9.0; },
expect_created: "1.0,2.0,3.0 th0.0",
expect_edited: "4.0,5.0,6.0 th2.0",
expect_reedited: "4.0,5.0,9.0 th2.0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_line_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('Line', start=P(0.0, 0.0, 0.0), end=P(10.0, 0.0, 0.0))",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.end = (10.0, 5.0, 0.0)\n", " e.thickness = 1.5\n", "doc.selection = [e]\n"),
rejects: &[
("'start':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
("'thickness':float('inf')", "finite"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
],
is_kind: |entity| matches!(entity, EntityType::Line(_)),
digest: |entity| match entity {
EntityType::Line(v) => format!("{:.1},{:.1} th{:.1}", v.end.x, v.end.y, v.thickness),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Line(v) = entity { v.start.x = 1.0; },
expect_created: "10.0,0.0 th0.0",
expect_edited: "10.0,5.0 th1.5",
expect_reedited: "10.0,5.0 th1.5",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_circle_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('Circle', center=P(0.0, 0.0, 0.0), radius=5.0)",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.radius = 7.0\n", " e.center = (1.0, 1.0, 0.0)\n", "doc.selection = [e]\n"),
rejects: &[
("'radius':0.0", "radius"),
("'radius':-3.0", "radius"),
("'center':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
],
is_kind: |entity| matches!(entity, EntityType::Circle(_)),
digest: |entity| match entity {
EntityType::Circle(v) => format!("{:.1},{:.1} r{:.1}", v.center.x, v.center.y, v.radius),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Circle(v) = entity { v.radius = 8.0; },
expect_created: "0.0,0.0 r5.0",
expect_edited: "1.0,1.0 r7.0",
expect_reedited: "1.0,1.0 r8.0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_arc_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('Arc', center=P(0.0, 0.0, 0.0), radius=5.0, start_angle=0.0, end_angle=1.5)",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.end_angle = 2.0\n", " e.radius = 6.0\n", "doc.selection = [e]\n"),
rejects: &[
("'radius':0.0", "radius"),
("'end_angle':float('nan')", "finite"),
("'center':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
],
is_kind: |entity| matches!(entity, EntityType::Arc(_)),
digest: |entity| match entity {
EntityType::Arc(v) => format!("r{:.1} {:.1}..{:.1}", v.radius, v.start_angle, v.end_angle),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Arc(v) = entity { v.start_angle = 0.5; },
expect_created: "r5.0 0.0..1.5",
expect_edited: "r6.0 0.0..2.0",
expect_reedited: "r6.0 0.5..2.0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_ellipse_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('Ellipse', center=P(0.0, 0.0, 0.0), major_axis=P(10.0, 0.0, 0.0), minor_axis_ratio=0.5)",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.minor_axis_ratio = 0.25\n", " e.end_parameter = 3.0\n", "doc.selection = [e]\n"),
rejects: &[
("'minor_axis_ratio':0.0", "ratio"),
("'minor_axis_ratio':2.0", "ratio"),
("'major_axis':{'x':0.0,'y':0.0,'z':0.0}", "axis"),
],
is_kind: |entity| matches!(entity, EntityType::Ellipse(_)),
digest: |entity| match entity {
EntityType::Ellipse(v) => format!("q{:.2} ..{:.1}", v.minor_axis_ratio, v.end_parameter),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Ellipse(v) = entity { v.minor_axis_ratio = 0.75; },
expect_created: "q0.50 ..6.3",
expect_edited: "q0.25 ..3.0",
expect_reedited: "q0.75 ..3.0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_ray_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('Ray', base_point=P(0.0, 0.0, 0.0), direction=P(1.0, 0.0, 0.0))",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.base_point = (2.0, 2.0, 0.0)\n", " e.direction = (0.0, 1.0, 0.0)\n", "doc.selection = [e]\n"),
rejects: &[
("'direction':{'x':2.0,'y':0.0,'z':0.0}", "unit"),
("'base_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
],
is_kind: |entity| matches!(entity, EntityType::Ray(_)),
digest: |entity| match entity {
EntityType::Ray(v) => format!("{:.1},{:.1} d{:.1},{:.1}", v.base_point.x, v.base_point.y, v.direction.x, v.direction.y),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Ray(v) = entity { v.base_point.x = 3.0; },
expect_created: "0.0,0.0 d1.0,0.0",
expect_edited: "2.0,2.0 d0.0,1.0",
expect_reedited: "3.0,2.0 d0.0,1.0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_xline_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('XLine', base_point=P(0.0, 0.0, 0.0), direction=P(1.0, 0.0, 0.0))",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.base_point = (2.0, 2.0, 0.0)\n", " e.direction = (0.0, 1.0, 0.0)\n", "doc.selection = [e]\n"),
rejects: &[
("'direction':{'x':2.0,'y':0.0,'z':0.0}", "unit"),
("'base_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
],
is_kind: |entity| matches!(entity, EntityType::XLine(_)),
digest: |entity| match entity {
EntityType::XLine(v) => format!("{:.1},{:.1} d{:.1},{:.1}", v.base_point.x, v.base_point.y, v.direction.x, v.direction.y),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::XLine(v) = entity { v.base_point.x = 3.0; },
expect_created: "0.0,0.0 d1.0,0.0",
expect_edited: "2.0,2.0 d0.0,1.0",
expect_reedited: "3.0,2.0 d0.0,1.0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_solid_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('Solid', first_corner=P(0.0, 0.0, 0.0), second_corner=P(4.0, 0.0, 0.0), third_corner=P(0.0, 4.0, 0.0), fourth_corner=P(4.0, 4.0, 0.0))",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.fourth_corner = (5.0, 5.0, 0.0)\n", " e.thickness = 2.0\n", "doc.selection = [e]\n"),
rejects: &[
("'first_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
("'thickness':float('inf')", "finite"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
],
is_kind: |entity| matches!(entity, EntityType::Solid(_)),
digest: |entity| match entity {
EntityType::Solid(v) => format!("{:.1},{:.1} th{:.1}", v.fourth_corner.x, v.fourth_corner.y, v.thickness),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Solid(v) = entity { v.first_corner.x = 1.0; },
expect_created: "4.0,4.0 th0.0",
expect_edited: "5.0,5.0 th2.0",
expect_reedited: "5.0,5.0 th2.0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_face3d_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('Face3D', first_corner=P(0.0, 0.0, 0.0), second_corner=P(4.0, 0.0, 0.0), third_corner=P(4.0, 4.0, 1.0), fourth_corner=P(0.0, 4.0, 1.0))",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.third_corner = (4.0, 4.0, 2.0)\n", " e.invisible_edges = 3\n", "doc.selection = [e]\n"),
rejects: &[
("'first_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
("'invisible_edges':64", "bits"),
],
is_kind: |entity| matches!(entity, EntityType::Face3D(_)),
digest: |entity| match entity {
EntityType::Face3D(v) => format!("z{:.1} inv{}", v.third_corner.z, v.invisible_edges.bits()),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Face3D(v) = entity { v.first_corner.x = 1.0; },
expect_created: "z1.0 inv0",
expect_edited: "z2.0 inv3",
expect_reedited: "z2.0 inv3",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_text_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('Text', text='Hello', insertion=P(1.0, 2.0, 0.0), height=2.5)",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.text = 'World'\n", " e.rotation = 0.5\n", " e.height = 3.0\n", "doc.selection = [e]\n"),
rejects: &[
("'height':0.0", "height"),
("'height':-1.0", "height"),
("'insertion':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
],
is_kind: |entity| matches!(entity, EntityType::Text(_)),
digest: |entity| match entity {
EntityType::Text(v) => format!("{} h{:.1} r{:.1}", v.value, v.height, v.rotation),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Text(v) = entity { v.height = 4.0; },
expect_created: "Hello h2.5 r0.0",
expect_edited: "World h3.0 r0.5",
expect_reedited: "World h4.0 r0.5",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_mtext_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "doc.create_entity('MText', text='Line one', insertion=P(1.0, 2.0, 0.0), height=2.5, rectangle_width=30.0)",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.text = 'Line two'\n", " e.rectangle_width = 40.0\n", " e.rotation = 0.5\n", "doc.selection = [e]\n"),
rejects: &[
("'height':0.0", "height"),
("'rectangle_width':-1.0", "width"),
("'insertion':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
],
is_kind: |entity| matches!(entity, EntityType::MText(_)),
digest: |entity| match entity {
EntityType::MText(v) => format!("{} w{:.1} r{:.1}", v.value, v.rectangle_width, v.rotation),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::MText(v) = entity { v.rectangle_width = 50.0; },
expect_created: "Line one w30.0 r0.0",
expect_edited: "Line two w40.0 r0.5",
expect_reedited: "Line two w50.0 r0.5",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_lwpolyline_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!("doc.create_entity('LwPolyline', vertices=[{'location': {'x': 0.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 5.0}}])\n"),
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 8.0}, 'bulge': 0.5}, {'location': {'x': 0.0, 'y': 8.0}}]\n", " e.is_closed = True\n", " e.constant_width = 0.5\n", "doc.selection = [e]\n"),
rejects: &[
("'vertices':[{'location':{'x':0.0,'y':0.0}}]", "at least 2"),
("'constant_width':-1.0", "non-negative"),
("'vertices':[{'location':{'x':float('nan'),'y':0.0}},{'location':{'x':1.0,'y':0.0}}]", "finite"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
],
is_kind: |entity| matches!(entity, EntityType::LwPolyline(_)),
digest: |entity| match entity {
EntityType::LwPolyline(v) => format!("n{} closed{} w{:.1} bulge{:.1}", v.vertices.len(), v.is_closed, v.constant_width, v.vertices.get(2).map_or(0.0, |x| x.bulge)),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::LwPolyline(v) = entity { v.constant_width = 1.0; },
expect_created: "n3 closedfalse w0.0 bulge0.0",
expect_edited: "n4 closedtrue w0.5 bulge0.5",
expect_reedited: "n4 closedtrue w1.0 bulge0.5",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_polyline2d_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!("doc.create_entity('Polyline2D', vertices=[{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 5.0, 'z': 0.0}}])\n"),
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 0.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 0.0}}]\n", " e.closed = True\n", " e.thickness = 1.0\n", "doc.selection = [e]\n"),
rejects: &[
("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"),
("'thickness':float('inf')", "finite"),
("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}},{'location':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
],
is_kind: |entity| matches!(entity, EntityType::Polyline2D(_)),
digest: |entity| match entity {
EntityType::Polyline2D(v) => format!("n{} closed{} th{:.1}", v.vertices.len(), v.flags.is_closed(), v.thickness),
other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()),
},
reedit: |entity| if let EntityType::Polyline2D(v) = entity { v.thickness = 2.0; },
expect_created: "n3 closedfalse th0.0",
expect_edited: "n4 closedtrue th1.0",
expect_reedited: "n4 closedtrue th2.0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "wrong kind LwPolyline",
expect_reedited_dxf: "wrong kind LwPolyline",
});
}
#[test]
fn audit_python_polyline_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: "# MAKEPOLYLINE
",
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.closed = True\n", "doc.selection = [e]\n"),
rejects: &[
("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"),
("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}},{'location':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"),
],
is_kind: |entity| matches!(entity, EntityType::Polyline(_)),
digest: |entity| match entity {
EntityType::Polyline(v) => format!("n{} closed{} lastz{:.1}", v.vertices.len(), v.flags.is_closed(), v.vertices.last().map_or(0.0, |x| x.location.z)),
other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()),
},
reedit: |entity| if let EntityType::Polyline(v) = entity { v.vertices[0].location.z = 1.0; },
expect_created: "n3 closedfalse lastz4.0",
expect_edited: "n4 closedtrue lastz6.0",
expect_reedited: "n4 closedtrue lastz6.0",
expect_edited_dwg: "wrong kind Polyline3D",
expect_reedited_dwg: "wrong kind Polyline3D",
expect_edited_dxf: "wrong kind Polyline3D",
expect_reedited_dxf: "wrong kind Polyline3D",
});
}
#[test]
fn audit_python_polyline3d_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!("doc.create_entity('Polyline3D', vertices=[{'position': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'position': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'position': {'x': 10.0, 'y': 5.0, 'z': 4.0}}])\n"),
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'position': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'position': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'position': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'position': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.elevation = 1.5\n", "doc.selection = [e]\n"),
rejects: &[
("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"),
("'elevation':float('inf')", "finite"),
("'vertices':[{'position':{'x':float('nan'),'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
],
is_kind: |entity| matches!(entity, EntityType::Polyline3D(_)),
digest: |entity| match entity {
EntityType::Polyline3D(v) => format!("n{} el{:.1} lastz{:.1}", v.vertices.len(), v.elevation, v.vertices.last().map_or(0.0, |x| x.position.z)),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Polyline3D(v) = entity { v.elevation = 2.5; },
expect_created: "n3 el0.0 lastz4.0",
expect_edited: "n4 el1.5 lastz6.0",
expect_reedited: "n4 el2.5 lastz6.0",
expect_edited_dwg: "n4 el0.0 lastz6.0",
expect_reedited_dwg: "n4 el0.0 lastz6.0",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_spline_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!("doc.create_entity('Spline', degree=2, knots=[0.0, 0.0, 0.0, 1.0, 1.0, 1.0], control_points=[{'x': 0.0, 'y': 0.0, 'z': 0.0}, {'x': 5.0, 'y': 5.0, 'z': 0.0}, {'x': 10.0, 'y': 0.0, 'z': 0.0}])\n"),
edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.control_points = [{'x': 0.0, 'y': 0.0, 'z': 0.0}, {'x': 5.0, 'y': 8.0, 'z': 0.0}, {'x': 10.0, 'y': 0.0, 'z': 0.0}]\n", " e.weights = [1.0, 2.0, 1.0]\n", "doc.selection = [e]\n"),
rejects: &[
("'degree':0", "at least 1"),
("'knots':[0.0,1.0]", "must hold"),
("'weights':[1.0,0.0,1.0]", "greater than zero"),
("'weights':[1.0]", "match"),
("'control_points':[{'x':float('nan'),'y':0.0,'z':0.0},{'x':1.0,'y':0.0,'z':0.0},{'x':2.0,'y':0.0,'z':0.0}]", "finite"),
("'knots':[0.0,0.0,1.0,0.5,1.0,1.0]", "nondecreasing"),
],
is_kind: |entity| matches!(entity, EntityType::Spline(_)),
digest: |entity| match entity {
EntityType::Spline(v) => format!("deg{} cp{} y{:.1} w{:?}", v.degree, v.control_points.len(), v.control_points[1].y, v.weights),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Spline(v) = entity { v.control_points[1].y = 9.0; },
expect_created: "deg2 cp3 y5.0 w[]",
expect_edited: "deg2 cp3 y8.0 w[1.0, 2.0, 1.0]",
expect_reedited: "deg2 cp3 y9.0 w[1.0, 2.0, 1.0]",
expect_edited_dwg: "",
expect_reedited_dwg: "",
expect_edited_dxf: "",
expect_reedited_dxf: "",
});
}
#[test]
fn audit_python_insert_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!("# MAKEBLOCK\n", "doc.create_entity('Insert', block_name='AUDITBLK', insert_point=P(1.0, 2.0, 0.0))\n"),
edit: concat!(
"e = doc.entities[HANDLE]\n",
"with doc.transaction('Edit'):\n",
" e.insert_point = (4.0, 5.0, 0.0)\n",
" e.x_scale = 2.0\n",
" e.y_scale = 3.0\n",
" e.rotation = 0.5\n",
" e.column_count = 2\n",
" e.column_spacing = 10.0\n",
"doc.selection = [e]\n"),
rejects: &[
("'x_scale':0.0", "nonzero"),
("'rotation':float('nan')", "finite"),
("'column_count':0", "greater than zero"),
("'insert_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
("'block_name':'OTHER'", "does not exist"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
],
is_kind: |entity| matches!(entity, EntityType::Insert(_)),
digest: |entity| match entity {
EntityType::Insert(v) => format!("{} at{:.1},{:.1} s{:.1},{:.1} r{:.1} cols{}x{:.1}", v.block_name, v.insert_point.x,
v.insert_point.y, v.x_scale(), v.y_scale(), v.rotation, v.column_count, v.column_spacing),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Insert(v) = entity { v.rotation = 1.0; },
expect_created: "AUDITBLK at1.0,2.0 s1.0,1.0 r0.0 cols1x0.0",
expect_edited: "AUDITBLK at4.0,5.0 s2.0,3.0 r0.5 cols2x10.0",
expect_reedited: "AUDITBLK at4.0,5.0 s2.0,3.0 r1.0 cols2x10.0",
expect_edited_dxf: "",
expect_reedited_dxf: "",
expect_edited_dwg: "",
expect_reedited_dwg: "",
});
}
#[test]
fn audit_python_attribute_definition_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"# MAKEBLOCK\n",
"doc.create_entity('AttributeDefinition', owner_handle=BLOCKRECORD, tag='PART_NO', prompt='Part number',\n",
" default_value='PN-001', insertion_point=P(1.0, 2.0, 0.0), height=2.5, width_factor=1.25, oblique_angle=0.1,\n",
" rotation=0.25, horizontal_alignment='Center', vertical_alignment='Top', alignment_point=P(3.0, 4.0, 0.0),\n",
" flags={'invisible': True, 'constant': False, 'verify': True, 'preset': False, 'locked_position': False, 'annotative': False},\n",
" field_length=12, text_generation_flags=2, lock_position=True)\n"),
edit: concat!(
"e = doc.entities[HANDLE]\n",
"with doc.transaction('Edit'):\n",
" e.insertion_point = (4.0, 5.0, 0.0)\n",
" e.default_value = 'PN-002'\n",
" e.prompt = 'Serial'\n",
" e.height = 3.0\n",
" e.width_factor = 0.8\n",
" e.rotation = 0.5\n",
" e.oblique_angle = 0.2\n",
" e.horizontal_alignment = 'Right'\n",
" e.vertical_alignment = 'Middle'\n",
" e.flags = {'invisible': False, 'constant': True, 'verify': False, 'preset': True, 'locked_position': False, 'annotative': False}\n",
" e.field_length = 20\n",
"doc.selection = [e]\n"),
rejects: &[
("'tag':'BAD TAG'", "whitespace"),
("'height':0.0", "greater than zero"),
("'width_factor':0.0", "width_factor"),
("'text_style':'Missing'", "does not exist"),
("'owner_handle':999999", "read-only"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
("'rotation':float('nan')", "finite"),
("'horizontal_alignment':'Nowhere'", "unsupported"),
],
is_kind: |entity| matches!(entity, EntityType::AttributeDefinition(_)),
digest: |entity| match entity {
EntityType::AttributeDefinition(v) => format!(
"{}|{}|{} ins{:.1},{:.1} al{:.1},{:.1} h{:.1} r{:.2} wf{:.2} ob{:.2} {:?}/{:?} f{}{}{}{} fl{} tg{} lock{}",
v.tag, v.prompt, v.default_value, v.insertion_point.x, v.insertion_point.y,
v.alignment_point.x, v.alignment_point.y, v.height, v.rotation, v.width_factor, v.oblique_angle,
v.horizontal_alignment, v.vertical_alignment,
u8::from(v.flags.invisible), u8::from(v.flags.constant), u8::from(v.flags.verify), u8::from(v.flags.preset),
v.field_length, v.text_generation_flags, u8::from(v.lock_position)),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::AttributeDefinition(v) = entity { v.default_value = "PN-003".into(); },
expect_created: "PART_NO|Part number|PN-001 ins1.0,2.0 al3.0,4.0 h2.5 r0.25 wf1.25 ob0.10 Center/Top f1010 fl12 tg2 lock1",
expect_edited: "PART_NO|Serial|PN-002 ins4.0,5.0 al3.0,4.0 h3.0 r0.50 wf0.80 ob0.20 Right/Middle f0101 fl20 tg2 lock1",
expect_reedited: "PART_NO|Serial|PN-003 ins4.0,5.0 al3.0,4.0 h3.0 r0.50 wf0.80 ob0.20 Right/Middle f0101 fl20 tg2 lock1",
// BLOCKER: acadrust's DXF ATTDEF reader handles only groups 1, 2, 3,
// 10, 40, 50, 280 and 101, so the alignment point and alignments,
// width factor, oblique angle, attribute flags, field length,
// generation flags, lock and text style (groups 7, 11, 41, 51, 70-74,
// 210, 280 lock) reopen as defaults. The writer emits all of them.
expect_edited_dxf: "PART_NO|Serial|PN-002 ins4.0,5.0 al0.0,0.0 h3.0 r0.50 wf1.00 ob0.00 Left/Baseline f0000 fl0 tg0 lock0",
expect_reedited_dxf: "PART_NO|Serial|PN-003 ins4.0,5.0 al0.0,0.0 h3.0 r0.50 wf1.00 ob0.00 Left/Baseline f0000 fl0 tg0 lock0",
expect_edited_dwg: "",
expect_reedited_dwg: "",
});
}
#[test]
fn audit_python_insert_creation_rules_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let mut records = std::collections::BTreeMap::new();
for name in ["OUTER", "INNER"] {
let next = host.document().next_handle();
let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2));
let mut record = acadrust::tables::BlockRecord::new(name);
record.handle = record_handle;
record.block_entity_handle = block_handle;
record.block_end_handle = end_handle;
host.document_mut().block_records.add(record).unwrap();
let mut block = acadrust::entities::Block::new(name, acadrust::types::Vector3::ZERO);
block.common.handle = block_handle;
block.common.owner_handle = record_handle;
host.document_mut().add_entity(EntityType::Block(block)).unwrap();
let mut end = acadrust::entities::BlockEnd::new();
end.common.handle = end_handle;
end.common.owner_handle = record_handle;
host.document_mut().add_entity(EntityType::BlockEnd(end)).unwrap();
records.insert(name, record_handle);
}
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let count = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::Insert(_))).count();
let create = |name: &str, extra: &str| format!(
"PY_EVAL ocs.active_document.create_entity('Insert', block_name='{name}', insert_point={{'x':0.0,'y':0.0,'z':0.0}}{extra})");
// A valid insert of INNER into the drawing works.
dispatch(&mut host, &create("INNER", ""));
assert_eq!(count(&host), 1, "{}", last(&host));
// Refused: unknown block, model space, bad scale, zero array count.
for (command, message) in [
(create("MISSING", ""), "does not exist"),
(create("*Model_Space", ""), "model-space or paper-space"),
(create("INNER", ", x_scale=0.0"), "nonzero"),
(create("INNER", ", column_count=0"), "greater than zero"),
(create("", ""), "empty"),
] {
dispatch(&mut host, &command);
assert_eq!(count(&host), 1, "{command}");
assert!(last(&host).contains(message), "{command}: {}", last(&host));
}
// A block cannot contain itself, directly or through another block.
let owner = |name: &str| format!(", owner_handle={}", records[name].value());
dispatch(&mut host, &create("OUTER", &owner("OUTER")));
assert_eq!(count(&host), 1);
assert!(last(&host).contains("contain itself"), "{}", last(&host));
dispatch(&mut host, &create("INNER", &owner("OUTER")));
assert_eq!(count(&host), 2, "{}", last(&host));
dispatch(&mut host, &create("OUTER", &owner("INNER")));
assert_eq!(count(&host), 2);
assert!(last(&host).contains("contain itself"), "{}", last(&host));
// The legacy Polyline cannot be created and says what to use instead.
dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Polyline', vertices=[])");
assert!(last(&host).contains("Polyline2D or Polyline3D"), "{}", last(&host));
}
#[test]
fn audit_python_ole2frame_lifecycle_over_real_ipc() {
image::RgbaImage::from_pixel(4, 3, image::Rgba([10, 120, 200, 255]))
.save(std::env::temp_dir().join("ocs_ole_audit.png"))
.unwrap();
run_mesh_lifecycle(&MeshCase {
create: "doc.embed_picture('TMPDIR/ocs_ole_audit.png', origin=(10, 10, 0), width=20)\n",
edit: concat!(
"e = doc.entities[HANDLE]\n",
"with doc.transaction('Edit'):\n",
" e.upper_left_corner = (12.0, 40.0, 0.0)\n",
" e.lower_right_corner = (42.0, 10.0, 0.0)\n",
" e.lock_aspect = 1\n",
"doc.selection = [e]\n"),
rejects: &[
("'upper_left_corner':{'x':42.0,'y':40.0,'z':0.0}", "nonzero width and height"),
("'lock_aspect':2", "0 or 1"),
("'lower_right_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"),
("'version':3", "read-only"),
("'storage':{}", "outside the editable schema"),
],
is_kind: |entity| matches!(entity, EntityType::Ole2Frame(_)),
digest: |entity| match entity {
EntityType::Ole2Frame(v) => {
let picture = match acadrust::entities::extract_presentation(&v.encoded_payload()) {
Some(acadrust::entities::OlePresentation::Raster(bytes)) => bytes.len(),
_ => 0,
};
format!("ul{:.1},{:.1} lr{:.1},{:.1} lock{} picture{}", v.upper_left_corner.x, v.upper_left_corner.y,
v.lower_right_corner.x, v.lower_right_corner.y, v.lock_aspect, picture)
}
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Ole2Frame(v) = entity { v.lower_right_corner.x = 50.0; },
expect_created: "ul10.0,25.0 lr30.0,10.0 lock0 picture119",
expect_edited: "ul12.0,40.0 lr42.0,10.0 lock1 picture119",
expect_reedited: "ul12.0,40.0 lr50.0,10.0 lock1 picture119",
expect_edited_dxf: "",
expect_reedited_dxf: "",
expect_edited_dwg: "",
expect_reedited_dwg: "",
});
}
/// Evidence for docs/cadkernel-body-path.md: primitive, transform and
/// boolean bodies become Solid3D/Body payloads that lift back losslessly
/// and survive DWG and DXF. Slow in a debug build (booleans take ~23 s).
#[test]
#[ignore = "kernel spike; slow in debug builds"]
fn spike_kernel_body_round_trip() {
use crate::scene::model::solid_model as sm;
use crate::scene::convert::solid3d_tess::{kernel_acis_body, kernel_body};
let make = |body: &cadkernel::brep::Body| -> acadrust::entities::Solid3D {
let sat = crate::scene::convert::acis_export::solid_to_sat(body).expect("solid_to_sat");
let mut solid = acadrust::entities::Solid3D::new();
solid.wires = sm::edge_wires(body);
solid.set_sat_document(&sat);
solid
};
let report = |label: &str, solid: &acadrust::entities::Solid3D| {
let body = kernel_body(solid);
eprintln!("SPIKE {label}: lift={} volume={:?} wires={} sat_bytes={} binary={}",
body.is_some(), body.as_ref().map(sm::volume), solid.wires.len(),
solid.acis_data.sat_data.len(), solid.acis_data.is_binary);
body
};
let boxed = sm::box_solid([0.0, 0.0, 0.0], 10.0, 6.0, 4.0).unwrap();
let mut doc = acadrust::CadDocument::new();
let solid = make(&boxed);
report("created box", &solid);
let handle = doc.add_entity(EntityType::Solid3D(solid.clone())).unwrap();
// Round trip both formats and compare payloads.
let dwg = crate::io::load_bytes("s.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap();
let dxf = crate::io::load_bytes("s.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap();
for (label, reopened) in [("DWG", &dwg), ("DXF", &dxf)] {
let Some(EntityType::Solid3D(back)) = reopened.get_entity(handle) else { panic!("{label} lost the solid") };
report(&format!("{label} reopen"), back);
eprintln!("SPIKE {label}: acis_data equal={} wires equal={} silhouettes {}", back.acis_data == solid.acis_data,
back.wires.len() == solid.wires.len(), back.silhouettes.len());
}
// Transforms: translate, rotate, mirror, matrix.
let moved = sm::by_matrix(&boxed, [1.,0.,0.,0., 0.,1.,0.,0., 0.,0.,1.,0., 5.,5.,5.,1.]).unwrap();
let turned = sm::turned(&boxed, 2, std::f64::consts::FRAC_PI_4, [0.0, 0.0, 0.0]).unwrap();
let mirrored = sm::mirrored(&boxed, 0, [0.0, 0.0, 0.0]).unwrap();
for (label, body) in [("moved", &moved), ("turned", &turned), ("mirrored", &mirrored)] {
let solid = make(body);
let extent = sm::extent(body);
eprintln!("SPIKE {label}: volume={:.3} extent={:?} lifts_back={}", sm::volume(body), extent, kernel_body(&solid).is_some());
}
// Booleans and primitives.
let cyl = sm::cylinder_solid([0.0, 0.0, -3.0], 2.0, 10.0).unwrap();
for (label, op) in [("union", sm::Bool::Union), ("subtract", sm::Bool::Subtract), ("intersect", sm::Bool::Intersect)] {
match sm::boolean_result(op, &boxed, &cyl) {
Ok(result) => { let solid = make(&result); eprintln!("SPIKE box {label} cylinder: volume={:.3} faces_wires={} lifts_back={}", sm::volume(&result), solid.wires.len(), kernel_body(&solid).is_some()); }
Err(snag) => eprintln!("SPIKE box {label} cylinder: refused {snag:?}"),
}
}
for (label, body) in [
("sphere", sm::sphere_solid([0.0; 3], 3.0)), ("torus", sm::torus_solid([0.0; 3], 5.0, 1.0)),
("wedge", sm::wedge_solid([0.0; 3], 4.0, 3.0, 2.0)), ("pyramid", sm::pyramid_solid([0.0; 3], 3.0, 4.0, 5)),
] {
match body {
Some(body) => { let solid = make(&body); eprintln!("SPIKE {label}: volume={:.3} lifts_back={}", sm::volume(&body), kernel_body(&solid).is_some()); }
None => eprintln!("SPIKE {label}: kernel returned None"),
}
}
// The same payload inside a Body entity and a Region-like container.
let mut body_entity = acadrust::entities::Body::new();
body_entity.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap());
let lifted = kernel_acis_body(&body_entity.acis_data);
eprintln!("SPIKE Body entity: lift={} volume={:?}", lifted.is_some(), lifted.as_ref().map(sm::volume));
let bh = doc.add_entity(EntityType::Body(body_entity)).unwrap();
let dwg = crate::io::load_bytes("b.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap();
let dxf = crate::io::load_bytes("b.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap();
for (label, reopened) in [("DWG", &dwg), ("DXF", &dxf)] {
match reopened.get_entity(bh) {
Some(EntityType::Body(back)) => eprintln!("SPIKE Body {label}: lift={}", kernel_acis_body(&back.acis_data).is_some()),
other => eprintln!("SPIKE Body {label}: came back as {:?}", other.map(|e| format!("{e:?}").chars().take(30).collect::<String>())),
}
}
}
/// Per-operation kernel timings for docs/cadkernel-body-path.md.
#[test]
#[ignore = "kernel spike; slow in debug builds"]
fn spike_kernel_timings() {
use crate::scene::model::solid_model as sm;
use std::time::Instant;
let time = |label: &str, f: &mut dyn FnMut() -> String| {
let start = Instant::now();
let note = f();
eprintln!("TIMING {label}: {:.2}s {note}", start.elapsed().as_secs_f64());
};
let boxed = sm::box_solid([0.0; 3], 10.0, 6.0, 4.0).unwrap();
let cyl = sm::cylinder_solid([0.0, 0.0, -3.0], 2.0, 10.0).unwrap();
time("box primitive", &mut || format!("{}", sm::box_solid([0.0; 3], 10.0, 6.0, 4.0).is_some()));
time("sphere primitive", &mut || format!("{}", sm::sphere_solid([0.0; 3], 3.0).is_some()));
time("solid_to_sat(box)", &mut || format!("{}", crate::scene::convert::acis_export::solid_to_sat(&boxed).is_some()));
time("edge_wires(box)", &mut || format!("{}", sm::edge_wires(&boxed).len()));
time("transform (matrix)", &mut || format!("{}", sm::by_matrix(&boxed, [1.,0.,0.,0., 0.,1.,0.,0., 0.,0.,1.,0., 5.,5.,5.,1.]).is_some()));
time("union", &mut || format!("{}", sm::boolean_result(sm::Bool::Union, &boxed, &cyl).is_ok()));
time("subtract", &mut || format!("{}", sm::boolean_result(sm::Bool::Subtract, &boxed, &cyl).is_ok()));
time("intersect", &mut || format!("{}", sm::boolean_result(sm::Bool::Intersect, &boxed, &cyl).is_ok()));
let result = sm::boolean_result(sm::Bool::Union, &boxed, &cyl).unwrap();
time("solid_to_sat(union)", &mut || format!("{}", crate::scene::convert::acis_export::solid_to_sat(&result).is_some()));
time("edge_wires(union)", &mut || format!("{}", sm::edge_wires(&result).len()));
time("volume(union)", &mut || format!("{:.1}", sm::volume(&result)));
}
#[test]
fn audit_python_solid3d_lifecycle_over_real_ipc() {
use crate::scene::convert::solid3d_tess::kernel_body;
use crate::scene::model::solid_model as sm;
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let solids = |host: &HostSession<'_>| -> Vec<Handle> {
let mut list: Vec<_> = host.document().entities().filter_map(|e| match e {
EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect();
list.sort_by_key(|h| h.value());
list
};
let body_of = |host: &HostSession<'_>, handle: Handle| {
let Some(EntityType::Solid3D(solid)) = host.document().get_entity(handle) else { panic!("no solid {handle:?}") };
kernel_body(solid).expect("payload lifts losslessly")
};
let volume = |host: &HostSession<'_>, handle: Handle| sm::volume(&body_of(host, handle));
let extent = |host: &HostSession<'_>, handle: Handle| sm::extent(&body_of(host, handle)).unwrap();
let close = |a: f64, b: f64, tolerance: f64| (a - b).abs() <= tolerance * b.abs().max(1.0);
// C: every primitive, checked against its analytic volume.
let script = std::env::temp_dir().join(format!("ocs_solid_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"s = ocs.active_document.solids\n",
"s.box(center=(0, 0, 0), size=(10, 6, 4))\n",
"s.cylinder(center=(20, 0, 0), radius=2, height=5)\n",
"s.sphere(center=(40, 0, 0), radius=3)\n",
"s.torus(center=(60, 0, 0), major=5, minor=1)\n",
"s.wedge(origin=(80, 0, 0), size=(4, 3, 2))\n",
"s.pyramid(center=(100, 0, 0), radius=3, height=4, sides=5)\n",
"s.box(center=(0, 20, 0), size=(2, 2, 2), layer='SOLIDS')\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let list = solids(&host);
assert_eq!(list.len(), 7, "{}", last(&host));
let pi = std::f64::consts::PI;
let five_gon = 0.5 * 5.0 * 3.0f64.powi(2) * (2.0 * pi / 5.0).sin();
for (index, expected, tolerance) in [
(0, 240.0, 1e-9), (1, pi * 4.0 * 5.0, 0.02), (2, 4.0 / 3.0 * pi * 27.0, 0.02),
(3, 2.0 * pi * pi * 5.0 * 1.0, 0.02), (4, 12.0, 1e-9), (5, five_gon * 4.0 / 3.0, 0.02), (6, 8.0, 1e-9),
] {
let actual = volume(&host, list[index]);
assert!(close(actual, expected, tolerance), "solid {index}: volume {actual}, expected {expected}");
}
let Some(EntityType::Solid3D(first)) = host.document().get_entity(list[0]) else { unreachable!() };
assert_eq!((first.wires.len(), first.common.layer.as_str()), (12, "0"));
assert!(matches!(host.document().get_entity(list[6]), Some(EntityType::Solid3D(s)) if s.common.layer == "SOLIDS"));
assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry");
let read = |host: &mut HostSession<'_>, expression: &str| {
dispatch(host, &format!("PY_EVAL {expression}"));
last(host)
};
assert!(read(&mut host, &format!("ocs.active_document.entities[{}].kind", list[0].value())).contains("Solid3D"));
// E: rigid moves keep the volume and move the extent exactly.
let box_handle = list[0];
let before = host.document().get_entity(box_handle).unwrap().clone();
let undo_before = host.app.tabs[0].history.undo_stack.len();
let (low, high) = extent(&host, box_handle);
assert!(close(low[0], -5.0, 1e-6) && close(high[2], 2.0, 1e-6), "{low:?} {high:?}");
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (5, 5, 5)).handle", box_handle.value()));
let (low, high) = extent(&host, box_handle);
assert!(close(low[0], 0.0, 1e-6) && close(high[1], 8.0, 1e-6) && close(high[2], 7.0, 1e-6), "{low:?} {high:?}");
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.rotate({}, 'z', 0.7853981633974483, (5, 5, 5)).handle", box_handle.value()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.mirror({}, 'x', (0, 0, 0)).handle", box_handle.value()));
assert!(close(volume(&host, box_handle), 240.0, 1e-9), "moves preserve volume");
let Some(EntityType::Solid3D(moved)) = host.document().get_entity(box_handle) else { unreachable!() };
assert_eq!((moved.wires.len(), moved.history_handle, moved.common.handle), (12, None, box_handle));
assert_eq!(moved.common.layer, "0");
assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 3, "one undo step per move");
// V: refused operations change nothing and leave no undo step.
let snapshot = host.document().get_entity(box_handle).unwrap().clone();
let count_before = solids(&host).len();
let undo_now = host.app.tabs[0].history.undo_stack.len();
let empty_payload = acadrust::entities::Solid3D::new();
let unliftable = host.add_entity(EntityType::Solid3D(empty_payload.clone()));
let unliftable_before = host.document().get_entity(unliftable).unwrap().clone();
for (command, message) in [
("ocs.active_document.solids.box(size=(0, 1, 1))".to_string(), "greater than zero"),
("ocs.active_document.solids.box(center=(float('nan'), 0, 0))".to_string(), "must be finite"),
("ocs.active_document.solids.torus(major=1, minor=2)".to_string(), "smaller than the major"),
("ocs.active_document.solids.pyramid(sides=2)".to_string(), "between 3 and 1024"),
("ocs.active_document.solids.sphere(radius=-1)".to_string(), "greater than zero"),
("ocs.active_document.solids.box(layer='')".to_string(), "layer name is empty"),
("ocs.solid_create('blob', [1.0], None)".to_string(), "unknown primitive"),
("ocs.solid_create('box', [1.0], None)".to_string(), "takes 6 numbers"),
(format!("ocs.active_document.solids.transform({}, [2,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])", box_handle.value()), "rigid moves"),
(format!("ocs.active_document.solids.transform({}, [1]*16)", box_handle.value()), "rigid moves"),
(format!("ocs.active_document.solids.translate({}, (float('nan'), 0, 0))", box_handle.value()), "must be finite"),
(format!("ocs.active_document.solids.translate({}, (1, 0, 0))", unliftable.value()), "cannot be lifted losslessly"),
("ocs.active_document.solids.translate(999999, (1, 0, 0))".to_string(), "does not exist"),
] {
dispatch(&mut host, &format!("PY_EVAL {command}"));
assert!(last(&host).contains(message), "{command}: {}", last(&host));
}
assert_eq!(host.document().get_entity(box_handle), Some(&snapshot));
assert_eq!(host.document().get_entity(unliftable), Some(&unliftable_before));
assert_eq!(solids(&host).len(), count_before + 1, "only the fixture was added");
assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step");
// Transforming a non-solid is refused.
let line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 0.0, 0.0))));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (1, 0, 0))", line.value()));
assert!(last(&host).contains("outside the Python schema") || last(&host).contains("applies to Solid3D"), "{}", last(&host));
// W: both formats reopen the moved solid losslessly; the reopened
// payload can be moved again.
let moved_volume = volume(&host, box_handle);
let moved_extent = extent(&host, box_handle);
let dwg = crate::io::load_bytes("solid.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("solid.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
let Some(EntityType::Solid3D(back)) = document.get_entity(box_handle) else { panic!("{format} lost the solid") };
let body = kernel_body(back).unwrap_or_else(|| panic!("{format} payload does not lift"));
assert!(close(sm::volume(&body), moved_volume, 1e-6), "{format}");
let reopened_extent = sm::extent(&body).unwrap();
for axis in 0..3 {
assert!(close(reopened_extent.0[axis], moved_extent.0[axis], 1e-6) && close(reopened_extent.1[axis], moved_extent.1[axis], 1e-6), "{format} extent");
}
let again = host.add_entity(EntityType::Solid3D(back.clone()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value()));
let (low, _) = extent(&host, again);
assert!(close(low[0], reopened_extent.0[0] + 100.0, 1e-6), "{format} re-edit");
}
// D and U: delete, then undo and redo restore the moved state.
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", box_handle.value()));
assert!(host.document().get_entity(box_handle).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
// Steps after creation: 3 moves, 2 re-edits of reopened copies, 1 delete.
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(box_handle).is_some(), "undo restores the deleted solid");
app.undo_steps(5);
assert_eq!(app.tabs[0].scene.document.get_entity(box_handle), Some(&before), "undoing every move restores the original solid exactly");
app.redo_steps(6);
assert!(app.tabs[0].scene.document.get_entity(box_handle).is_none(), "redo replays the moves and the delete");
}
#[test]
fn audit_python_body_transform_over_real_ipc() {
use crate::scene::model::solid_model as sm;
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
// A Body entity carrying a kernel box; scripts cannot create one.
let boxed = sm::box_solid([0.0; 3], 4.0, 4.0, 4.0).unwrap();
let mut body = acadrust::entities::Body::new();
body.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap());
body.wires = sm::edge_wires(&boxed);
let handle = host.add_entity(EntityType::Body(body));
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let extent = |host: &HostSession<'_>| {
let Some(EntityType::Body(b)) = host.document().get_entity(handle) else { panic!("no body") };
sm::extent(&crate::scene::convert::solid3d_tess::kernel_acis_body(&b.acis_data).unwrap()).unwrap()
};
assert!((extent(&host).0[0] + 2.0).abs() < 1e-6);
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", handle.value()));
assert!(host.app.command_line.history.last().unwrap().text.contains("Body"));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 0, 0)).handle", handle.value()));
assert!((extent(&host).0[0] - 8.0).abs() < 1e-6, "{:?}", extent(&host));
let bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap();
let reopened = crate::io::load_bytes("body.dwg", bytes).unwrap();
let Some(EntityType::Body(back)) = reopened.get_entity(handle) else { panic!("DWG lost the body") };
let lifted = crate::scene::convert::solid3d_tess::kernel_acis_body(&back.acis_data).expect("DWG body lifts");
assert!((sm::volume(&lifted) - 64.0).abs() < 1e-6);
// Scripts cannot create a Body and get the explanatory message.
dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Body')");
assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text);
}
#[test]
fn audit_python_region_lifecycle_over_real_ipc() {
use crate::scene::convert::solid3d_tess::kernel_acis_body;
use crate::scene::model::solid_model as sm;
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let regions = |host: &HostSession<'_>| -> Vec<Handle> {
let mut list: Vec<_> = host.document().entities().filter_map(|e| match e {
EntityType::Region(r) => Some(r.common.handle), _ => None }).collect();
list.sort_by_key(|h| h.value());
list
};
let extent_in = |document: &CadDocument, handle: Handle| {
let Some(EntityType::Region(region)) = document.get_entity(handle) else { panic!("no region {handle:?}") };
let body = kernel_acis_body(&region.acis_data).expect("region payload lifts losslessly");
sm::extent(&body).unwrap()
};
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
// C: regions from a circle (source kept), a closed rectangle (source
// deleted) and a second circle on its own layer.
let script = std::env::temp_dir().join(format!("ocs_region_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"doc = ocs.active_document\n",
"circle = doc.create_entity('Circle', center=(0, 0, 0), radius=5)\n",
"rect = doc.create_entity('LwPolyline', is_closed=True, vertices=[{'location': {'x': 20.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 6.0}}, {'location': {'x': 20.0, 'y': 6.0}}])\n",
"doc.solids.region(circle)\n",
"doc.solids.region(rect, delete_source=True)\n",
"other = doc.create_entity('Circle', center=(50, 0, 0), radius=2, layer='PROFILES')\n",
"doc.solids.region(other, layer='REGIONS')\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let list = regions(&host);
assert_eq!(list.len(), 3, "{}", last(&host));
let (low, high) = extent_in(host.document(), list[0]);
assert!(close(low[0], -5.0) && close(high[0], 5.0) && close(low[1], -5.0) && close(high[1], 5.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}");
let (low, high) = extent_in(host.document(), list[1]);
assert!(close(low[0], 20.0) && close(high[0], 30.0) && close(high[1], 6.0), "{low:?} {high:?}");
let layer_of = |host: &HostSession<'_>, handle: Handle| host.document().get_entity(handle).unwrap().common().layer.clone();
assert_eq!(layer_of(&host, list[0]), "0", "defaults to the profile's layer");
assert_eq!(layer_of(&host, list[2]), "REGIONS");
let circles = host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count();
let polylines = host.document().entities().filter(|e| matches!(e, EntityType::LwPolyline(_))).count();
assert_eq!((circles, polylines), (2, 0), "delete_source removed only the rectangle");
let Some(EntityType::Region(first)) = host.document().get_entity(list[0]) else { unreachable!() };
assert_eq!((first.wires.is_empty(), first.point_of_reference.z), (false, 0.0));
assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry");
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", list[0].value()));
assert!(last(&host).contains("Region"));
// V: refusals change nothing and leave no undo step.
let open_line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(5.0, 0.0, 0.0))));
let mut open_poly = acadrust::entities::LwPolyline::new();
open_poly.vertices = vec![
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(0.0, 0.0)),
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 0.0)),
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 4.0)),
];
let open_poly = host.add_entity(EntityType::LwPolyline(open_poly));
let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default()));
let entity_count = host.document().entities().count();
let undo_now = host.app.tabs[0].history.undo_stack.len();
for (command, message) in [
(format!("ocs.active_document.solids.region({})", open_line.value()), "must be closed"),
(format!("ocs.active_document.solids.region({})", open_poly.value()), "must be closed"),
(format!("ocs.active_document.solids.region({})", ray.value()), "planar profile"),
(format!("ocs.active_document.solids.region({})", list[0].value()), "already a region"),
("ocs.active_document.solids.region(999999)".to_string(), "does not exist"),
(format!("ocs.active_document.solids.region({}, layer='')", open_line.value()), "layer name is empty"),
] {
dispatch(&mut host, &format!("PY_EVAL {command}"));
assert!(last(&host).contains(message), "{command}: {}", last(&host));
}
assert_eq!(host.document().entities().count(), entity_count);
assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step");
// E: a region moves through the same kernel channel.
let before = host.document().get_entity(list[0]).unwrap().clone();
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", list[0].value()));
let (low, high) = extent_in(host.document(), list[0]);
assert!(close(low[0], 5.0) && close(high[0], 15.0) && close(low[1], 0.0) && close(high[1], 10.0), "{low:?} {high:?}");
// W: both formats reopen a region that lifts with the same extent, and
// a reopened region moves again.
let dwg = crate::io::load_bytes("region.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("region.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
let moved = extent_in(host.document(), list[0]);
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
assert_eq!(regions_in(document).len(), 3, "{format}");
let reopened = extent_in(document, list[0]);
for axis in 0..3 {
assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent {reopened:?} vs {moved:?}");
}
let Some(EntityType::Region(back)) = document.get_entity(list[0]) else { unreachable!() };
let again = host.add_entity(EntityType::Region(back.clone()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value()));
assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit");
}
fn regions_in(document: &CadDocument) -> Vec<Handle> {
document.entities().filter_map(|e| match e { EntityType::Region(r) => Some(r.common.handle), _ => None }).collect()
}
// D and U: delete, undo, and undo the creation steps exactly.
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", list[2].value()));
assert!(host.document().get_entity(list[2]).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(list[2]).is_some(), "undo restores the deleted region");
// Two re-edits of reopened copies, then the move of the first region.
app.undo_steps(3);
assert_eq!(app.tabs[0].scene.document.get_entity(list[0]), Some(&before), "undoing the move restores the region exactly");
}
#[test]
fn audit_python_surface_lifecycle_over_real_ipc() {
use crate::scene::convert::solid3d_tess::kernel_acis_body;
use crate::scene::model::solid_model as sm;
use acadrust::entities::SurfaceKind;
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let sorted = |mut list: Vec<Handle>| { list.sort_by_key(|h| h.value()); list };
let surfaces_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e {
EntityType::Surface(s) => Some(s.common.handle), _ => None }).collect());
let solids_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e {
EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect());
let body_of = |document: &CadDocument, handle: Handle| match document.get_entity(handle) {
Some(EntityType::Surface(s)) => kernel_acis_body(&s.acis_data).expect("surface payload lifts losslessly"),
Some(EntityType::Solid3D(s)) => kernel_acis_body(&s.acis_data).expect("solid payload lifts losslessly"),
other => panic!("no surface or solid {handle:?}: {other:?}"),
};
let extent_in = |document: &CadDocument, handle: Handle| sm::extent(&body_of(document, handle)).unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
let kind_in = |document: &CadDocument, handle: Handle| match document.get_entity(handle) {
Some(EntityType::Surface(s)) => s.kind, other => panic!("no surface {handle:?}: {other:?}") };
// C: plane surfaces and extrusions from real profile entities.
let script = std::env::temp_dir().join(format!("ocs_surface_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"doc = ocs.active_document\n",
"s = doc.solids\n",
"circle = doc.create_entity('Circle', center=(0, 0, 0), radius=3)\n",
"rect = doc.create_entity('LwPolyline', is_closed=True, vertices=[{'location': {'x': 20.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 6.0}}, {'location': {'x': 20.0, 'y': 6.0}}])\n",
"open_path = doc.create_entity('LwPolyline', vertices=[{'location': {'x': 50.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 4.0}}])\n",
"temp = doc.create_entity('Circle', center=(70, 0, 0), radius=2)\n",
"s.surface(circle)\n",
"s.surface(rect, layer='SURFACES')\n",
"s.extrude(rect, (0, 0, 4))\n",
"s.extrude(circle, (0, 0, 5))\n",
"s.extrude(open_path, (0, 0, 3))\n",
"s.extrude(temp, (0, 0, 2), delete_source=True)\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let surfaces = surfaces_in(host.document());
let solids = solids_in(host.document());
assert_eq!((surfaces.len(), solids.len()), (3, 3), "{}", last(&host));
// surfaces[0] plane of the circle, [1] plane of the rectangle, [2] extruded open path.
assert_eq!((kind_in(host.document(), surfaces[0]), kind_in(host.document(), surfaces[1]), kind_in(host.document(), surfaces[2])),
(SurfaceKind::Plane, SurfaceKind::Plane, SurfaceKind::Generic));
let (low, high) = extent_in(host.document(), surfaces[0]);
assert!(close(low[0], -3.0) && close(high[1], 3.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}");
let (low, high) = extent_in(host.document(), surfaces[2]);
assert!(close(low[0], 50.0) && close(high[0], 54.0) && close(low[2], 0.0) && close(high[2], 3.0), "open path extrusion {low:?} {high:?}");
assert_eq!(host.document().get_entity(surfaces[1]).unwrap().common().layer, "SURFACES");
let pi = std::f64::consts::PI;
for (index, expected, tolerance) in [(0, 240.0, 1e-9), (1, pi * 9.0 * 5.0, 0.02), (2, pi * 4.0 * 2.0, 0.02)] {
let volume = sm::volume(&body_of(host.document(), solids[index]));
assert!((volume - expected).abs() <= tolerance * expected, "solid {index}: {volume} vs {expected}");
}
assert_eq!(host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count(), 1,
"delete_source removed only the extruded temporary circle");
assert!(!host.app.tabs[0].scene.wire_models_for(&[surfaces[0]]).is_empty(), "no canvas geometry");
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", surfaces[0].value()));
assert!(last(&host).contains("Surface"));
// V: refusals change nothing and leave no undo step.
let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default()));
let circle = host.document().entities().find_map(|e| match e { EntityType::Circle(c) => Some(c.common.handle), _ => None }).unwrap();
let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len());
for (command, message) in [
(format!("ocs.active_document.solids.extrude({}, (0, 0, 0))", circle.value()), "nonzero"),
(format!("ocs.active_document.solids.extrude({}, (float('nan'), 0, 1))", circle.value()), "must be finite"),
(format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", ray.value()), "planar profile"),
(format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", solids[0].value()), "curve profile"),
(format!("ocs.active_document.solids.surface({})", surfaces[0].value()), "curve profile"),
(format!("ocs.active_document.solids.surface({})", ray.value()), "planar profile"),
(format!("ocs.active_document.solids.surface({}, layer='')", circle.value()), "layer name is empty"),
("ocs.active_document.solids.surface(999999)".to_string(), "does not exist"),
("ocs.solid_extrude(1, [1.0], None, False)".to_string(), "3 numbers"),
] {
dispatch(&mut host, &format!("PY_EVAL {command}"));
assert!(last(&host).contains(message), "{command}: {}", last(&host));
}
assert_eq!((host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()), counts,
"refusals change nothing and record no undo step");
// E: isoline density is scriptable and validated; kind and payload are not.
let plane = surfaces[0];
let plane_before = host.document().get_entity(plane).unwrap().clone();
let script = std::env::temp_dir().join(format!("ocs_surface_edit_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"doc = ocs.active_document\n",
"e = doc.entities[{}]\n",
"with doc.transaction('Edit surface'):\n",
" e.u_isolines = 8\n",
" e.v_isolines = 6\n",
), plane.value())).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let Some(EntityType::Surface(edited)) = host.document().get_entity(plane) else { unreachable!() };
assert_eq!((edited.u_isolines, edited.v_isolines, edited.kind), (8, 6, SurfaceKind::Plane), "{}", last(&host));
let edited_state = host.document().get_entity(plane).unwrap().clone();
for (patch, message) in [("'u_isolines':500", "between 0 and 200"), ("'surface_kind':'Extruded'", "read-only"),
("'surface_data':{}", "outside the editable schema"), ("'v_isolines':-1", "between 0 and 200")] {
dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Reject surface', [{{'handle':{}, {patch}}}])", plane.value()));
assert_eq!(host.document().get_entity(plane), Some(&edited_state), "{patch}");
assert!(last(&host).contains(message), "{patch}: {}", last(&host));
}
// Moves keep a plane a plane and turn a swept surface generic.
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", plane.value()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (0, 0, 7)).handle", surfaces[2].value()));
assert_eq!(kind_in(host.document(), plane), SurfaceKind::Plane);
let (low, high) = extent_in(host.document(), plane);
assert!(close(low[0], 7.0) && close(high[0], 13.0) && close(low[1], 2.0) && close(high[1], 8.0), "{low:?} {high:?}");
let (low, _) = extent_in(host.document(), surfaces[2]);
assert!(close(low[2], 7.0), "{low:?}");
// W: both formats reopen surfaces that lift with the same extents, keep
// the isolines, and a reopened surface moves again.
let dwg = crate::io::load_bytes("surface.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("surface.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
let moved = extent_in(host.document(), plane);
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
assert_eq!((surfaces_in(document).len(), solids_in(document).len()), (3, 3), "{format}");
let reopened = extent_in(document, plane);
for axis in 0..3 {
assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent");
}
let Some(EntityType::Surface(back)) = document.get_entity(plane) else { unreachable!() };
assert_eq!((back.u_isolines, back.v_isolines), (8, 6), "{format} isolines");
assert_eq!(back.kind, SurfaceKind::Plane, "{format} keeps the plane kind");
let again = host.add_entity(EntityType::Surface(back.clone()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value()));
assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit");
}
// D and U.
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", surfaces[1].value()));
assert!(host.document().get_entity(surfaces[1]).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(surfaces[1]).is_some(), "undo restores the deleted surface");
// Two re-edits of reopened copies, two moves, and the isoline edit come before.
app.undo_steps(2 + 2 + 1);
assert_eq!(app.tabs[0].scene.document.get_entity(plane), Some(&plane_before), "undoing the edits restores the surface exactly");
}
#[test]
fn audit_python_boolean_lifecycle_over_real_ipc() {
use crate::scene::convert::solid3d_tess::kernel_body;
use crate::scene::model::solid_model as sm;
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let solids_in = |document: &CadDocument| {
let mut list: Vec<Handle> = document.entities().filter_map(|e| match e {
EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect();
list.sort_by_key(|h| h.value());
list
};
let body_in = |document: &CadDocument, handle: Handle| {
let Some(EntityType::Solid3D(solid)) = document.get_entity(handle) else { panic!("no solid {handle:?}") };
kernel_body(solid).expect("payload lifts losslessly")
};
let volume_in = |document: &CadDocument, handle: Handle| sm::volume(&body_in(document, handle));
let extent_in = |document: &CadDocument, handle: Handle| sm::extent(&body_in(document, handle)).unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-6 * b.abs().max(1.0);
// C: all three operations on two overlapping boxes, keeping the
// operands. A: x 0..10, B: x 5..15, both 6 by 4; the overlap is 120.
let script = std::env::temp_dir().join(format!("ocs_boolean_create_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"s = ocs.active_document.solids\n",
"a = s.box(center=(5, 3, 2), size=(10, 6, 4))\n",
"b = s.box(center=(10, 3, 2), size=(10, 6, 4))\n",
"s.union(a, b, keep_operands=True)\n",
"s.subtract(a, b, keep_operands=True)\n",
"s.intersect(a, b, keep_operands=True)\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let list = solids_in(host.document());
assert_eq!(list.len(), 5, "operands kept and three results: {}", last(&host));
let (a, b) = (list[0], list[1]);
for (index, expected) in [(2, 360.0), (3, 120.0), (4, 120.0)] {
let actual = volume_in(host.document(), list[index]);
assert!(close(actual, expected), "result {index}: volume {actual}, expected {expected}");
}
let (low, high) = extent_in(host.document(), list[3]);
assert!(close(low[0], 0.0) && close(high[0], 5.0), "subtract keeps x 0..5: {low:?} {high:?}");
let (low, high) = extent_in(host.document(), list[4]);
assert!(close(low[0], 5.0) && close(high[0], 10.0), "intersect keeps x 5..10: {low:?} {high:?}");
let Some(EntityType::Solid3D(union)) = host.document().get_entity(list[2]) else { unreachable!() };
assert_eq!((union.wires.is_empty(), union.history_handle), (false, None));
assert!(!host.app.tabs[0].scene.wire_models_for(&[list[2]]).is_empty(), "no canvas geometry");
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", list[2].value()));
assert!(last(&host).contains("Solid3D"));
// Consuming operands: A minus B leaves only the result, on a new layer.
let before_consume = (host.document().get_entity(a).unwrap().clone(), host.document().get_entity(b).unwrap().clone());
let undo_before = host.app.tabs[0].history.undo_stack.len();
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.subtract({}, {}, layer='CUT').handle", a.value(), b.value()));
assert!(host.document().get_entity(a).is_none() && host.document().get_entity(b).is_none(), "operands are consumed");
let cut = *solids_in(host.document()).last().unwrap();
assert!(close(volume_in(host.document(), cut), 120.0));
assert_eq!(host.document().get_entity(cut).unwrap().common().layer, "CUT");
assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 1, "one undo step per boolean");
// A second planar operation chains on the result: drill a through-hole.
dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(2.5, 3, 2), size=(2, 2, 6)).handle");
let hole = *solids_in(host.document()).last().unwrap();
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.subtract({}, {}).handle", cut.value(), hole.value()));
let drilled = *solids_in(host.document()).last().unwrap();
assert!(close(volume_in(host.document(), drilled), 104.0), "120 minus a 2x2x4 hole is 104");
// V: refusals change nothing and leave no undo step.
let line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 0.0, 0.0))));
let unliftable = host.add_entity(EntityType::Solid3D(acadrust::entities::Solid3D::new()));
let mut plane = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane);
plane.acis_data = acadrust::entities::AcisData::new();
let surface = host.add_entity(EntityType::Surface(plane));
let far = { dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(500, 500, 500), size=(1, 1, 1)).handle");
*solids_in(host.document()).last().unwrap() };
let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len());
let z = drilled.value();
for (command, message) in [
(format!("ocs.active_document.solids.union({z}, {z})"), "cannot be combined with itself"),
(format!("ocs.active_document.solids.union({z}, {})", line.value()), "must be a Solid3D"),
(format!("ocs.active_document.solids.union({}, {z})", surface.value()), "must be a Solid3D"),
(format!("ocs.active_document.solids.union({z}, 999999)"), "does not exist"),
(format!("ocs.active_document.solids.union({z}, {})", unliftable.value()), "cannot be lifted losslessly"),
(format!("ocs.active_document.solids.union({z}, {}, layer='')", far.value()), "layer name is empty"),
(format!("ocs.solid_boolean({z}, {}, 'xor', None, False)", far.value()), "unknown operation"),
(format!("ocs.active_document.solids.intersect({z}, {})", far.value()), "changed"),
] {
dispatch(&mut host, &format!("PY_EVAL {command}"));
assert!(last(&host).contains(message), "{command}: {}", last(&host));
}
assert_eq!((host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()), counts,
"refusals change nothing and record no undo step");
// W: both formats reopen the drilled result losslessly; it moves again.
let expected_volume = volume_in(host.document(), drilled);
let expected_extent = extent_in(host.document(), drilled);
let dwg = crate::io::load_bytes("boolean.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("boolean.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
assert!(close(volume_in(document, drilled), expected_volume), "{format} volume");
let reopened = extent_in(document, drilled);
for axis in 0..3 {
assert!(close(reopened.0[axis], expected_extent.0[axis]) && close(reopened.1[axis], expected_extent.1[axis]), "{format} extent");
}
let Some(EntityType::Solid3D(back)) = document.get_entity(drilled) else { unreachable!() };
let again = host.add_entity(EntityType::Solid3D(back.clone()));
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value()));
assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit");
}
// D and U: delete, then undo restores it, and undoing the consuming
// boolean brings both operands back exactly.
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", drilled.value()));
assert!(host.document().get_entity(drilled).is_none());
drop(process);
drop(host);
app.finish_pending_history(0);
app.undo_steps(1);
assert!(app.tabs[0].scene.document.get_entity(drilled).is_some(), "undo restores the deleted result");
// Two re-edits, the far box, the drilling subtract and the hole box, then the consuming subtract.
app.undo_steps(2 + 1 + 1 + 1 + 1);
assert_eq!(app.tabs[0].scene.document.get_entity(a), Some(&before_consume.0), "undo restores operand A exactly");
assert_eq!(app.tabs[0].scene.document.get_entity(b), Some(&before_consume.1), "undo restores operand B exactly");
assert!(app.tabs[0].scene.document.get_entity(cut).is_none(), "undo removes the boolean result");
}
/// Release-only evidence for docs/cadkernel-body-path.md: the kernel's
/// refusals reach the script as messages. Curved booleans take about
/// 23 s in a debug build, so this is ignored by default.
#[test]
#[ignore = "curved boolean; slow in debug builds"]
fn audit_python_boolean_kernel_refusal_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let script = std::env::temp_dir().join(format!("ocs_boolean_refusal_{}.py", std::process::id()));
std::fs::write(&script, concat!(
"s = ocs.active_document.solids\n",
"box = s.box(center=(0, 0, 0), size=(10, 6, 4))\n",
"sphere = s.sphere(center=(0, 0, 0), radius=3)\n",
"s.subtract(box, sphere)\n",
)).unwrap();
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
let _ = std::fs::remove_file(&script);
let message = host.app.command_line.history.last().unwrap().text.clone();
assert!(message.contains("Coincident") && message.contains("nothing was changed"), "{message}");
}
#[test]
fn audit_python_section_symbol_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"doc.create_entity('SectionSymbol', symbol_scale=1.0, points=[\n",
" {'point': P(0.0, 0.0, 0.0), 'label': 'A', 'label_offset': P(0.0, 2.0, 0.0)},\n",
" {'point': P(40.0, 0.0, 0.0), 'label': 'A', 'label_offset': P(0.0, 2.0, 0.0)}])\n"),
edit: concat!(
"e = doc.entities[HANDLE]\n",
"with doc.transaction('Edit'):\n",
" e.points = [{'point': P(0.0, 0.0, 0.0), 'label': 'B', 'label_offset': P(0.0, 3.0, 0.0)},\n",
" {'point': P(20.0, 10.0, 0.0), 'bulge': 0.0}, {'point': P(40.0, 0.0, 0.0), 'label': 'B', 'label_offset': P(0.0, -3.0, 0.0)}]\n",
" e.symbol_scale = 2.0\n",
"doc.selection = [e]\n"),
rejects: &[
("'points':[{'point':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2 points"),
("'points':[{'point':{'x':float('nan'),'y':0.0,'z':0.0}},{'point':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"),
("'symbol_scale':0.0", "greater than zero"),
("'style_handle':999999", "not an existing SectionViewStyle"),
("'view_rep_handle':999999", "not an existing ViewRep"),
("'raw_point_count_90':9", "read-only"),
("'end_a':[1.0, 1.0]", "read-only"),
],
is_kind: |entity| matches!(entity, EntityType::SectionSymbol(_)),
digest: |entity| match entity {
EntityType::SectionSymbol(v) => format!("n{} ends {:.1},{:.1}->{:.1},{:.1} label{} tick{:.1}/{:.1} scale{:.1} counts{}/{}",
v.points.len(), v.end_a[0], v.end_a[1], v.end_b[0], v.end_b[1], v.label, v.tick_a, v.tick_b,
v.symbol_scale, v.raw_point_count_90, v.raw_point_record_count),
other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()),
},
reedit: |entity| if let EntityType::SectionSymbol(v) = entity { v.symbol_scale = 3.0; },
expect_created: "n2 ends 0.0,0.0->40.0,0.0 labelA tick2.0/2.0 scale1.0 counts2/2",
expect_edited: "n3 ends 0.0,0.0->40.0,0.0 labelB tick3.0/-3.0 scale2.0 counts3/3",
expect_reedited: "n3 ends 0.0,0.0->40.0,0.0 labelB tick3.0/-3.0 scale3.0 counts3/3",
// BLOCKER (cadcodec, see docs/cadcodec-reader-gaps.md issue 5): the
// DXF reader dispatches SECTIONLINE only inside blocks, so a symbol
// in the entity list reopens as an unknown entity.
expect_edited_dxf: "wrong kind Unknown",
expect_reedited_dxf: "wrong kind Unknown",
expect_edited_dwg: "",
expect_reedited_dwg: "",
});
}
#[test]
fn audit_python_section_symbol_references_over_real_ipc() {
use acadrust::objects::{ClassObject, ClassObjectData, ObjectType};
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let mut object = |host: &mut HostSession<'_>, data: ClassObjectData| {
let handle = host.document_mut().allocate_handle();
let mut class_object = ClassObject::new(data);
class_object.handle = handle;
host.document_mut().objects.insert(handle, ObjectType::ClassObject(class_object));
handle
};
let style = object(&mut host, ClassObjectData::SectionViewStyle(Default::default()));
let view_rep = object(&mut host, ClassObjectData::ViewRep(Default::default()));
let scale = host.document_mut().allocate_handle();
let mut scale_object = acadrust::objects::Scale::new("1:1", 1.0, 1.0);
scale_object.handle = scale;
host.document_mut().objects.insert(scale, ObjectType::Scale(scale_object));
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let count = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::SectionSymbol(_))).count();
let create = |extra: &str| format!(
"PY_EVAL ocs.active_document.create_entity('SectionSymbol', points=[{{'point': {{'x': 0.0, 'y': 0.0, 'z': 0.0}}}}, {{'point': {{'x': 10.0, 'y': 0.0, 'z': 0.0}}}}]{extra}).handle");
// A symbol may reference the drawing's own style and view representation.
dispatch(&mut host, &create(&format!(", style_handle={}, view_rep_handle={}", style.value(), view_rep.value())));
assert_eq!(count(&host), 1, "{}", last(&host));
let Some(EntityType::SectionSymbol(symbol)) = host.document().entities().find(|e| matches!(e, EntityType::SectionSymbol(_))) else { unreachable!() };
assert_eq!((symbol.style_handle, symbol.view_rep_handle), (style, view_rep));
// A standalone symbol needs neither.
dispatch(&mut host, &create(""));
assert_eq!(count(&host), 2, "{}", last(&host));
// Wrong object kinds are refused: a style slot cannot hold a view rep, a
// scale object is neither, and a swapped pair fails.
for (extra, message) in [
(format!(", style_handle={}", view_rep.value()), "not an existing SectionViewStyle"),
(format!(", style_handle={}", scale.value()), "not an existing SectionViewStyle"),
(format!(", view_rep_handle={}", style.value()), "not an existing ViewRep"),
(", view_rep_handle=424242".to_string(), "not an existing ViewRep"),
] {
dispatch(&mut host, &create(&extra));
assert_eq!(count(&host), 2, "{extra}");
assert!(last(&host).contains(message), "{extra}: {}", last(&host));
}
}
#[test]
fn audit_python_ole2frame_creation_rules_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let dir = std::env::temp_dir();
let png = dir.join("ocs_ole_rules.png");
let jpeg = dir.join("ocs_ole_rules.jpg");
let tiff = dir.join("ocs_ole_rules.tif");
let text = dir.join("ocs_ole_rules.txt");
let picture = image::RgbImage::from_pixel(8, 4, image::Rgb([200, 40, 40]));
picture.save(&png).unwrap();
picture.save(&jpeg).unwrap();
picture.save(&tiff).unwrap();
std::fs::write(&text, "not a picture").unwrap();
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let frames = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::Ole2Frame(_))).count();
let picture_len = |host: &HostSession<'_>, index: usize| {
let mut all: Vec<_> = host.document().entities().filter_map(|e| match e {
EntityType::Ole2Frame(f) => Some(f.clone()), _ => None }).collect();
all.sort_by_key(|f| f.common.handle.value());
match acadrust::entities::extract_presentation(&all[index].encoded_payload()) {
Some(acadrust::entities::OlePresentation::Raster(bytes)) => bytes,
other => panic!("no embedded raster: {other:?}"),
}
};
let embed = |file: &std::path::Path, extra: &str| format!(
"PY_EVAL ocs.active_document.embed_picture({:?}, origin=(1, 2, 3), width=16{extra}).handle", file.display().to_string());
// C: PNG and JPEG are stored as read, a TIFF is re-encoded as PNG, and
// the frame keeps the picture's 2:1 aspect and the requested layer.
dispatch(&mut host, &embed(&png, ", layer='PICTURES'"));
dispatch(&mut host, &embed(&jpeg, ""));
dispatch(&mut host, &embed(&tiff, ""));
assert_eq!(frames(&host), 3, "{}", last(&host));
assert_eq!(picture_len(&host, 0), std::fs::read(&png).unwrap(), "PNG is stored as read");
assert_eq!(picture_len(&host, 1), std::fs::read(&jpeg).unwrap(), "JPEG is stored as read");
assert_eq!(&picture_len(&host, 2)[..4], b"\x89PNG", "TIFF is re-encoded as PNG");
let mut all: Vec<_> = host.document().entities().filter_map(|e| match e {
EntityType::Ole2Frame(f) => Some(f.clone()), _ => None }).collect();
all.sort_by_key(|f| f.common.handle.value());
assert_eq!((all[0].common.layer.as_str(), all[1].common.layer.as_str()), ("PICTURES", "0"));
let frame = &all[0];
assert!((frame.lower_right_corner.x - frame.upper_left_corner.x - 16.0).abs() < 1e-9, "width 16");
assert!((frame.upper_left_corner.y - frame.lower_right_corner.y - 8.0).abs() < 1e-9, "height 8 keeps the 2:1 aspect");
assert!((frame.lower_right_corner.y - 2.0).abs() < 1e-9 && (frame.upper_left_corner.x - 1.0).abs() < 1e-9, "origin is the bottom-left corner");
// V: refusals change nothing and record no undo step.
let undo = host.app.tabs[0].history.undo_stack.len();
let missing = dir.join("ocs_ole_rules_missing.png");
for (command, message) in [
(embed(&missing, ""), "cannot read the picture"),
(embed(&text, ""), "cannot read the picture"),
(embed(&png, ", layer=''").replace("width=16", "width=16"), "layer name is empty"),
(embed(&png, "").replace("width=16", "width=0"), "must be finite and greater than zero"),
(embed(&png, "").replace("origin=(1, 2, 3)", "origin=(float('nan'), 0, 0)"), "must be finite"),
("ocs.embed_picture('x.png', [1.0], 5.0, None)".to_string(), "origin needs 3 numbers"),
("ocs.active_document.create_entity('Ole2Frame', upper_left_corner=(0, 0, 0), lower_right_corner=(1, 1, 0))".to_string(), "doc.embed_picture"),
] {
dispatch(&mut host, &format!("PY_EVAL {}", command.trim_start_matches("PY_EVAL ")));
assert!(last(&host).contains(message), "{command}: {}", last(&host));
}
assert_eq!(frames(&host), 3);
assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo, "refusals record no undo step");
for file in [&png, &jpeg, &tiff, &text] {
let _ = std::fs::remove_file(file);
}
}
#[test]
fn audit_python_viewport_ids_and_scale_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle).unwrap();
let layout = host.document().objects.values().find_map(|o| match o {
acadrust::objects::ObjectType::Layout(l) if l.block_record == paper => Some(l.name.clone()), _ => None }).unwrap();
host.app.tabs[0].scene.set_current_layout(layout);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dispatch = |host: &mut HostSession<'_>, command: &str| {
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
};
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
let viewports = |host: &HostSession<'_>| {
let mut list: Vec<_> = host.document().entities().filter_map(|e| match e {
EntityType::Viewport(v) => Some((v.common.handle, v.id)), _ => None }).collect();
list.sort_by_key(|(h, _)| h.value());
list
};
let create = |width: f64, height: f64, view_height: f64| format!(
"PY_EVAL ocs.active_document.create_entity('Viewport', owner_handle={}, center=(50, 50, 0), width={width}, height={height}, view_height={view_height}).handle", paper.value());
dispatch(&mut host, &create(80.0, 60.0, 50.0));
dispatch(&mut host, &create(40.0, 30.0, 10.0));
dispatch(&mut host, &create(20.0, 20.0, 20.0));
let list = viewports(&host);
assert_eq!(list.len(), 3, "{}", last(&host));
let ids: Vec<i16> = list.iter().map(|(_, id)| *id).collect();
assert_eq!(ids, vec![2, 3, 4], "scripted viewports get unique ids of at least 2: {ids:?}");
for (handle, expected) in [(list[0].0, 60.0 / 50.0), (list[1].0, 30.0 / 10.0), (list[2].0, 1.0)] {
let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { unreachable!() };
assert!((v.custom_scale - expected).abs() < 1e-12, "custom_scale {} vs {expected}", v.custom_scale);
assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "viewport {handle:?} draws a frame");
}
// The scale follows the geometry; it cannot be set directly.
let second = list[0].0;
dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Zoom', [{{'handle':{}, 'view_height':25.0}}])", second.value()));
let Some(EntityType::Viewport(zoomed)) = host.document().get_entity(second) else { unreachable!() };
assert!((zoomed.custom_scale - 60.0 / 25.0).abs() < 1e-12, "{}", last(&host));
assert_eq!(zoomed.id, 2, "an edit keeps the id");
dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Resize', [{{'handle':{}, 'height':90.0}}])", second.value()));
let Some(EntityType::Viewport(resized)) = host.document().get_entity(second) else { unreachable!() };
assert!((resized.custom_scale - 90.0 / 25.0).abs() < 1e-12);
let before = host.document().get_entity(second).unwrap().clone();
dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Scale', [{{'handle':{}, 'custom_scale':3.0}}])", second.value()));
assert!(last(&host).contains("read-only"), "{}", last(&host));
assert_eq!(host.document().get_entity(second), Some(&before));
// Control: the engine's default id 0 is not invisible here (there is no
// sheet viewport to confuse it with), so the derived id matters for
// uniqueness, not for visibility.
let mut bare = acadrust::entities::Viewport::new();
bare.common.owner_handle = paper;
bare.width = 40.0;
bare.height = 30.0;
let bare = host.document_mut().add_entity(EntityType::Viewport(bare)).unwrap();
assert_eq!(host.document().get_entity(bare).map(|e| match e { EntityType::Viewport(v) => v.id, _ => -1 }), Some(0));
assert!(!host.app.tabs[0].scene.wire_models_for(&[bare]).is_empty());
}
/// Evidence for the RasterImage ledger row: what a save does with a bare
/// image (OCS's native IMAGE command) and with a definition-linked one.
#[test]
#[ignore = "exploratory evidence"]
fn spike_raster_image_definition_persistence() {
let dir = std::env::temp_dir();
let path = dir.join("ocs_spike_image.png").to_string_lossy().into_owned();
image::RgbaImage::from_pixel(8, 4, image::Rgba([1, 2, 3, 255])).save(&path).unwrap();
for linked in [false, true] {
let mut doc = acadrust::CadDocument::new();
let mut img = acadrust::entities::RasterImage::with_size(&path, acadrust::types::Vector3::ZERO, 8.0, 4.0, 16.0, 8.0);
if linked {
let h = doc.allocate_handle();
let mut def = acadrust::objects::ImageDefinition::with_dimensions(path.clone(), 8, 4);
def.handle = h;
def.is_loaded = true;
doc.objects.insert(h, acadrust::objects::ObjectType::ImageDefinition(def));
img.definition_handle = Some(h);
}
let handle = doc.add_entity(EntityType::RasterImage(img)).unwrap();
for (label, document) in [
("DWG", crate::io::load_bytes("i.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap()),
("DXF", crate::io::load_bytes("i.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap()),
] {
let entity = document.get_entity(handle);
let defs = document.objects.values().filter(|o| matches!(o, acadrust::objects::ObjectType::ImageDefinition(_))).count();
let reactors = document.objects.values().filter(|o| matches!(o, acadrust::objects::ObjectType::ImageDefinitionReactor(_))).count();
let dicts = document.objects.values().filter(|o| matches!(o, acadrust::objects::ObjectType::Dictionary(d) if d.entries.iter().any(|(k, _)| k.contains("IMAGE")))).count();
match entity {
Some(EntityType::RasterImage(i)) => eprintln!("SPIKE linked={linked} {label}: file_path_ok={} definition={:?} reactor={:?} defs={defs} reactors={reactors} image_dicts={dicts}",
i.file_path == path, i.definition_handle.map(|h| h.value()), i.definition_reactor_handle.map(|h| h.value())),
other => eprintln!("SPIKE linked={linked} {label}: came back as {:?}", other.map(|e| format!("{e:?}").chars().take(24).collect::<String>())),
}
}
}
}
#[test]
fn audit_python_raster_image_definition_linkage_over_real_ipc() {
use acadrust::objects::ObjectType;
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let dir = std::env::temp_dir();
let (a, b) = (dir.join("ocs_link_a.png"), dir.join("ocs_link_b.png"));
image::RgbaImage::from_pixel(8, 4, image::Rgba([9, 9, 9, 255])).save(&a).unwrap();
image::RgbaImage::from_pixel(6, 6, image::Rgba([90, 9, 9, 255])).save(&b).unwrap();
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let script = dir.join(format!("ocs_link_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"doc = ocs.active_document\n",
"doc.create_entity('RasterImage', file_path={a:?}, insertion_point=(0, 0, 0))\n",
"doc.create_entity('RasterImage', file_path={a:?}, insertion_point=(20, 0, 0))\n",
"doc.create_entity('RasterImage', file_path={b:?}, insertion_point=(40, 0, 0))\n",
), a = a.display().to_string(), b = b.display().to_string())).unwrap();
assert!(process.dispatch(&mut host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap());
let _ = std::fs::remove_file(&script);
// The structure: one definition per file, registered and owned by ACAD_IMAGE_DICT.
let structure = |document: &CadDocument| {
let root = document.header.named_objects_dict_handle;
let Some(ObjectType::Dictionary(root_dict)) = document.objects.get(&root) else { panic!("no root dictionary") };
let dict_handle = root_dict.get("ACAD_IMAGE_DICT");
let entries = dict_handle.and_then(|h| match document.objects.get(&h) {
Some(ObjectType::Dictionary(d)) => Some(d.entries.clone()), _ => None }).unwrap_or_default();
let defs: Vec<_> = document.objects.iter().filter_map(|(h, o)| match o {
ObjectType::ImageDefinition(d) => Some((*h, d.owner, d.file_name.clone())), _ => None }).collect();
let mut images: Vec<_> = document.entities().filter_map(|e| match e {
EntityType::RasterImage(i) => Some((i.common.handle, i.definition_handle, i.file_path.clone())), _ => None }).collect();
images.sort_by_key(|(h, _, _)| h.value());
(dict_handle, entries, defs, images)
};
let check = |label: &str, document: &CadDocument| {
let (dict, entries, defs, images) = structure(document);
let dict = dict.unwrap_or_else(|| panic!("{label}: no ACAD_IMAGE_DICT"));
assert_eq!(defs.len(), 2, "{label}: one definition per file");
let mut keys: Vec<_> = entries.iter().map(|(k, _)| k.as_str()).collect();
keys.sort();
assert_eq!(keys, vec!["ocs_link_a", "ocs_link_b"], "{label}");
for (key, handle) in &entries {
let (_, owner, file) = defs.iter().find(|(h, _, _)| h == handle).unwrap_or_else(|| panic!("{label}: dangling entry {key}"));
assert_eq!(*owner, dict, "{label}: {key} is owned by ACAD_IMAGE_DICT");
assert!(file.contains(key.as_str()), "{label}: {key} -> {file}");
}
assert_eq!(images.len(), 3, "{label}");
assert_eq!(images[0].1, images[1].1, "{label}: images of one file share a definition");
assert_ne!(images[0].1, images[2].1, "{label}");
for (_, definition, path) in &images {
assert!(defs.iter().any(|(h, _, _)| Some(*h) == *definition), "{label}: definition resolves");
assert!(path.contains("ocs_link_"), "{label}: file path kept: {path}");
}
};
check("live", host.document());
let dwg = crate::io::load_bytes("link.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("link.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
check("DWG", &dwg);
check("DXF", &dxf);
// U: undoing the last image leaves no dangling dictionary entry.
drop(process);
drop(host);
app.finish_pending_history(0);
app.undo_steps(1);
let (_, entries, defs, images) = structure(&app.tabs[0].scene.document);
assert!(images.len() < 3);
for (key, handle) in &entries {
assert!(defs.iter().any(|(h, _, _)| h == handle), "undo left a dangling entry {key}");
}
for file in [&a, &b] {
let _ = std::fs::remove_file(file);
}
}
#[test]
fn staged_python_point_pick_and_cancel_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let process = {
let mut host = HostSession::new(&mut app, 0);
std::sync::Arc::new(
ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.expect("spawn staged Python plugin"),
)
};
let request = |app: &mut OpenCADStudio, prompt: &str, entity: bool| {
let mut started = None;
{
let mut host = HostSession::new(app, 0);
let method = if entity {
"request_entity"
} else {
"request_point"
};
let cmd = format!("PY_EVAL ocs.active_document.{method}('{prompt}')");
assert!(process
.dispatch(&mut host, &cmd, &mut |id| started = Some(id))
.unwrap());
}
let output = app.command_line.history.last().unwrap().text.clone();
let token = output
.split_whitespace()
.find_map(|part| part.parse::<u64>().ok())
.unwrap_or_else(|| panic!("Python pick token missing from {output:?}"));
let id = started.expect("Python request started an interactive command");
app.set_active_command(
0,
Box::new(PluginProcessInteractiveAdapter::new(
std::sync::Arc::clone(&process),
id,
)),
);
token
};
let picked_token = request(&mut app, "Pick a point", false);
let result = app.tabs[0]
.active_cmd
.as_mut()
.unwrap()
.on_point(glam::DVec3::new(3.0, 4.0, 0.0));
let _ = app.apply_cmd_result(result);
assert!(app.tabs[0].active_cmd.is_none());
{
let mut host = HostSession::new(&mut app, 0);
assert!(process
.dispatch(
&mut host,
&format!("PY_EVAL ocs.active_document.poll_input({picked_token})"),
&mut |_| {}
)
.unwrap());
}
assert!(app
.command_line
.history
.last()
.unwrap()
.text
.contains("point"));
let target = {
let mut host = HostSession::new(&mut app, 0);
host.add_entity(EntityType::Line(Line::from_points(
acadrust::types::Vector3::new(6.0, 7.0, 0.0),
acadrust::types::Vector3::new(9.0, 7.0, 0.0),
)))
};
let entity_token = request(&mut app, "Pick an entity", true);
assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick());
let result = app.tabs[0]
.active_cmd
.as_mut()
.unwrap()
.on_entity_pick(target, glam::DVec3::new(6.0, 7.0, 0.0));
let _ = app.apply_cmd_result(result);
{
let mut host = HostSession::new(&mut app, 0);
assert!(process
.dispatch(
&mut host,
&format!("PY_EVAL ocs.active_document.poll_input({entity_token})"),
&mut |_| {}
)
.unwrap());
}
assert!(app
.command_line
.history
.last()
.unwrap()
.text
.contains("entity"));
assert!(app
.command_line
.history
.last()
.unwrap()
.text
.contains(&target.value().to_string()));
let cancelled_token = request(&mut app, "Cancel a point", false);
let result = app.tabs[0].active_cmd.as_mut().unwrap().on_enter();
let _ = app.apply_cmd_result(result);
{
let mut host = HostSession::new(&mut app, 0);
assert!(process
.dispatch(
&mut host,
&format!("PY_EVAL ocs.active_document.poll_input({cancelled_token})"),
&mut |_| {}
)
.unwrap());
}
assert!(app
.command_line
.history
.last()
.unwrap()
.text
.contains("cancelled"));
}
#[test]
fn staged_python_tabs_isolate_tokens_and_notifications() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable");
assert!(std::path::Path::new(&runner_path).is_file());
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
app.tabs
.push(crate::app::document::DocumentTab::new_drawing(2));
app.tabs[1].is_start = false;
let tab0 = app.tabs[0].id;
let tab1 = app.tabs[1].id;
assert_ne!(tab0, tab1);
let process = {
let mut host = HostSession::new(&mut app, 0);
std::sync::Arc::new(
ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path),
&mut host,
crate::plugin::v4_support::notification_handler(),
)
.expect("spawn staged Python plugin"),
)
};
let mut started = None;
{
let mut host = HostSession::new(&mut app, 0);
assert!(process
.dispatch(
&mut host,
"PY_EVAL ocs.active_document.request_point('Tab zero')",
&mut |id| started = Some(id)
)
.unwrap());
}
let token = app
.command_line
.history
.last()
.unwrap()
.text
.split_whitespace()
.find_map(|part| part.parse::<u64>().ok())
.expect("pick token");
app.set_active_command(
0,
Box::new(PluginProcessInteractiveAdapter::new(
std::sync::Arc::clone(&process),
started.unwrap(),
)),
);
{
let mut host = HostSession::new(&mut app, 1);
assert!(process
.dispatch(
&mut host,
&format!("PY_EVAL ocs.active_document.poll_input({token})"),
&mut |_| {}
)
.unwrap());
}
assert!(
app.command_line
.history
.last()
.unwrap()
.text
.contains("None"),
"other tab must not consume the pending token"
);
let result = app.tabs[0]
.active_cmd
.as_mut()
.unwrap()
.on_point(glam::DVec3::new(2.0, 3.0, 0.0));
let _ = app.apply_cmd_result(result);
{
let mut host = HostSession::new(&mut app, 1);
assert!(process
.dispatch(
&mut host,
&format!("PY_EVAL ocs.active_document.poll_input({token})"),
&mut |_| {}
)
.unwrap());
}
assert!(app
.command_line
.history
.last()
.unwrap()
.text
.contains("None"));
{
let mut host = HostSession::new(&mut app, 0);
assert!(process
.dispatch(
&mut host,
&format!("PY_EVAL ocs.active_document.poll_input({token})"),
&mut |_| {}
)
.unwrap());
}
assert!(app
.command_line
.history
.last()
.unwrap()
.text
.contains("point"));
use ocs_plugin_api::host::HostNotification;
process
.notify_plugin(
None,
HostNotification::DrawingChanged {
tab_id: tab0,
epoch: 101,
},
)
.unwrap();
process
.notify_plugin(
None,
HostNotification::DrawingChanged {
tab_id: tab1,
epoch: 202,
},
)
.unwrap();
// Notifications are best-effort and consumed by the runner's reader
// thread before its next Dispatch. Give that thread a bounded handoff.
std::thread::sleep(std::time::Duration::from_millis(50));
let poll_events_until = |app: &mut OpenCADStudio, tab: usize, epoch: &str| {
let mut output = String::new();
for _ in 0..10 {
{
let mut host = HostSession::new(app, tab);
assert!(process
.dispatch(
&mut host,
"PY_EVAL ocs.active_document.poll_events()",
&mut |_| {}
)
.unwrap());
}
output = app.command_line.history.last().unwrap().text.clone();
if output.contains(epoch) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
output
};
let other_events = poll_events_until(&mut app, 1, "202");
assert!(
other_events.contains("202") && !other_events.contains("101"),
"{other_events}"
);
let first_events = poll_events_until(&mut app, 0, "101");
assert!(
first_events.contains("101") && !first_events.contains("202"),
"{first_events}"
);
for epoch in 0..257 {
process
.notify_plugin(
None,
HostNotification::DrawingChanged {
tab_id: tab0,
epoch,
},
)
.unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(100));
{
let mut host = HostSession::new(&mut app, 0);
assert!(process
.dispatch(
&mut host,
"PY_EVAL ocs.active_document.poll_events()",
&mut |_| {}
)
.unwrap());
}
let overflow = &app.command_line.history.last().unwrap().text;
assert!(
overflow.contains("overflow") && overflow.contains("dropped"),
"{overflow}"
);
process
.notify_plugin(
None,
HostNotification::DrawingChanged {
tab_id: tab0,
epoch: 303,
},
)
.unwrap();
process
.notify_plugin(None, HostNotification::DocumentTabClosed { tab_id: tab0 })
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
{
let mut host = HostSession::new(&mut app, 0);
assert!(process
.dispatch(
&mut host,
"PY_EVAL ocs.active_document.poll_events()",
&mut |_| {}
)
.unwrap());
}
assert!(
app.command_line.history.last().unwrap().text.contains("[]"),
"closing a tab must discard its queued events"
);
}
#[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::MLine(acadrust::entities::MLine::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 insert_transform_transaction_undoes_without_changing_block_identity() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let handle;
{
let mut host = HostSession::new(&mut app, 0);
let insert = acadrust::entities::Insert::new(
"DOOR",
acadrust::types::Vector3::new(1.0, 2.0, 0.0),
);
handle = host.add_entity(EntityType::Insert(insert));
let mut changed = host.document().get_entity(handle).unwrap().clone();
if let EntityType::Insert(insert) = &mut changed {
insert.insert_point.x = 4.0;
insert.set_x_scale(2.0);
}
host.update_entities_transaction("Move block", vec![changed])
.unwrap();
assert!(
matches!(host.document().get_entity(handle), Some(EntityType::Insert(insert))
if insert.insert_point.x == 4.0 && insert.x_scale() == 2.0 && insert.block_name == "DOOR")
);
}
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(handle), Some(EntityType::Insert(insert))
if insert.insert_point.x == 1.0 && insert.x_scale() == 1.0 && insert.block_name == "DOOR")
);
}
#[test]
fn system_variables_change_without_command_reentry() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
assert_eq!(
host.system_variable("clayer"),
Some(HostSettingValue::Text("0".into()))
);
assert_eq!(
host.system_variable("SNAPANG"),
Some(HostSettingValue::Number(0.0))
);
assert!(host
.set_system_variable("CLAYER", HostSettingValue::Text("Missing".into()))
.is_err());
assert_eq!(
host.system_variable("CLAYER"),
Some(HostSettingValue::Text("0".into()))
);
let mut point = Point::new();
point.common.layer = "Annotations".into();
host.add_entity(EntityType::Point(point));
assert_eq!(
host.set_system_variable("clayer", HostSettingValue::Text("Annotations".into())),
Ok(HostSettingValue::Text("Annotations".into()))
);
assert_eq!(
host.system_variable("CLAYER"),
Some(HostSettingValue::Text("Annotations".into()))
);
assert_eq!(host.app.tabs[0].active_layer, "Annotations");
assert_eq!(host.app.tabs[0].layers.current_layer, "Annotations");
assert_eq!(host.app.ribbon.active_layer, "Annotations");
assert!(host.app.tabs[0].dirty);
assert_eq!(
host.set_system_variable("snapang", HostSettingValue::Number(450.0)),
Ok(HostSettingValue::Number(90.0))
);
assert_eq!(
host.system_variable("SNAPANG"),
Some(HostSettingValue::Number(90.0))
);
assert!(host
.set_system_variable("SNAPANG", HostSettingValue::Number(f64::INFINITY))
.is_err());
assert!(host
.set_system_variable("SNAPANG", HostSettingValue::Number(f64::MAX))
.is_err());
assert_eq!(
host.system_variable("SNAPANG"),
Some(HostSettingValue::Number(90.0))
);
}
#[test]
fn xdata_record_round_trips_and_registers_appid() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(EntityType::Point(Point::new()));
let mut rec = ExtendedDataRecord::new("DEMO_SURVEY");
rec.add_value(XDataValue::String("PNT-1".to_string()));
rec.add_value(XDataValue::Integer32(42));
assert!(host.write_record(h, rec));
let got = host.read_record(h, "DEMO_SURVEY").expect("record missing");
assert_eq!(got.values.len(), 2);
// APPID registered so the XDATA survives a DWG/DXF round-trip.
assert!(host.document().app_ids.contains("DEMO_SURVEY"));
// A second write replaces rather than duplicates the record.
let mut rec2 = ExtendedDataRecord::new("DEMO_SURVEY");
rec2.add_value(XDataValue::String("PNT-2".to_string()));
assert!(host.write_record(h, rec2));
let got = host.read_record(h, "DEMO_SURVEY").unwrap();
assert_eq!(got.values.len(), 1);
// Removal reports whether anything was dropped.
assert!(host.remove_record(h, "DEMO_SURVEY"));
assert!(host.read_record(h, "DEMO_SURVEY").is_none());
assert!(!host.remove_record(h, "DEMO_SURVEY"));
}
#[test]
fn plugin_state_round_trips_through_hostapi_trait() {
use ocs_plugin_api::host::{self, HostApi};
let mut app = OpenCADStudio::new_for_test();
let mut session = HostSession::new(&mut app, 0);
let host: &mut dyn HostApi = &mut session;
// Absent before first use.
assert!(host::plugin_state::<u32>(&*host, "opencad.demo").is_none());
// Insert via ensure, then mutate.
*host::ensure_plugin_state(host, "opencad.demo", || 7u32) += 1;
assert_eq!(
*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(),
8
);
*host::plugin_state_mut::<u32>(host, "opencad.demo").unwrap() = 100;
assert_eq!(
*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(),
100
);
}
#[test]
fn add_entities_batch_assigns_handles_and_publishes_once() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let pts: Vec<EntityType> = (0..5)
.map(|i| {
EntityType::Point(Point::at(acadrust::types::Vector3::new(
i as f64, i as f64, 0.0,
)))
})
.collect();
let handles = host.add_entities(pts);
assert_eq!(handles.len(), 5);
assert!(handles.iter().all(|h| !h.is_null()));
// Each handle is unique.
let mut set = std::collections::HashSet::new();
for h in &handles {
assert!(set.insert(h.value()));
}
// All entities are in the document.
for h in &handles {
assert!(host.document().get_entity(*h).is_some());
}
}
#[test]
fn update_entity_replaces_in_place_preserving_handle() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
1.0, 1.0, 0.0,
))));
let epoch_before = host.app.tabs[0].scene.geometry_epoch;
// Edit a snapshot copy (as a plugin would) and commit it.
let mut edited = host.document().get_entity(h).unwrap().clone();
edited.common_mut().layer = "PLUGIN_EDIT".to_string();
assert!(host.update_entity(edited));
// Same handle, edit applied, geometry re-tessellated.
let got = host
.document()
.get_entity(h)
.expect("entity kept its handle");
assert_eq!(got.common().layer, "PLUGIN_EDIT");
assert_ne!(
host.app.tabs[0].scene.geometry_epoch, epoch_before,
"update should bump geometry"
);
// Updating an unknown handle fails and changes nothing.
let mut ghost = Point::new();
ghost.common.handle = Handle::new(999_999);
assert!(!host.update_entity(EntityType::Point(ghost)));
}
#[test]
fn add_and_update_auto_register_novel_layers_with_real_handles() {
// A plugin adds/edits an entity naming a layer no LAYER command ever
// created. The layer must gain a real table entry (non-null handle) so
// it survives a DWG save instead of collapsing to layer 0 (#252, #67).
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
assert!(!host.document().layers.contains("PLUGIN-LAYER"));
let mut pt = Point::at(acadrust::types::Vector3::new(3.0, 3.0, 0.0));
pt.common.layer = "PLUGIN-LAYER".to_string();
host.add_entity(EntityType::Point(pt));
let layer = host
.document()
.layers
.get("PLUGIN-LAYER")
.expect("novel layer auto-registered on add_entity");
assert!(
!layer.handle.is_null(),
"auto-registered layer must carry a real handle (#67)"
);
// Adding again on the same (now-existing) layer must not duplicate or
// error — the always-present default "0" is likewise never re-created.
let count_before = host.document().layers.len();
host.add_entity(EntityType::Point(Point::new())); // default layer "0"
let mut pt2 = Point::new();
pt2.common.layer = "PLUGIN-LAYER".to_string();
host.add_entity(EntityType::Point(pt2));
assert_eq!(host.document().layers.len(), count_before);
// Retargeting an entity to a novel layer via update_entity registers it.
let h = host.add_entity(EntityType::Point(Point::new()));
let mut edited = host.document().get_entity(h).unwrap().clone();
edited.common_mut().layer = "EDIT-LAYER".to_string();
assert!(host.update_entity(edited));
assert!(host.document().layers.contains("EDIT-LAYER"));
}
#[test]
fn remove_entity_deletes_and_clears_caches() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
2.0, 2.0, 0.0,
))));
assert!(host.document().get_entity(h).is_some());
assert!(host.remove_entity(h));
assert!(host.document().get_entity(h).is_none());
assert!(!host.app.tabs[0].scene.hatches.contains_key(&h));
assert!(!host.app.tabs[0].scene.meshes.contains_key(&h));
// Removing an already-gone handle reports false.
assert!(!host.remove_entity(h));
}
/// A plugin command: second point commits a Point and ends.
struct PlacePoint {
got_first: bool,
}
impl ocs_plugin_api::host::InteractiveCommand for PlacePoint {
fn prompt(&self) -> String {
crate::t!("Pick a point").into_owned()
}
fn on_point(&mut self, pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep {
use ocs_plugin_api::host::CommandStep;
if self.got_first {
let p = acadrust::entities::Point::at(acadrust::types::Vector3::new(
pt[0], pt[1], pt[2],
));
CommandStep::CommitAndEnd(acadrust::EntityType::Point(p))
} else {
self.got_first = true;
CommandStep::NeedPoint
}
}
}
#[test]
fn plugin_interactive_command_drives_host_flow() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
{
let mut host = HostSession::new(&mut app, 0);
host.start_interactive(Box::new(PlacePoint { got_first: false }));
}
assert!(app.tabs[0].active_cmd.is_some());
for pt in [
glam::DVec3::new(0.0, 0.0, 0.0),
glam::DVec3::new(5.0, 5.0, 0.0),
] {
let r = app.tabs[0].active_cmd.as_mut().unwrap().on_point(pt);
let _ = app.apply_cmd_result(r);
}
assert_eq!(app.tabs[0].scene.document.entities().count(), 1);
assert!(
app.tabs[0].active_cmd.is_none(),
"command should have ended"
);
}
/// A plugin command that picks an existing object, then marks it.
struct PickThenMark;
impl ocs_plugin_api::host::InteractiveCommand for PickThenMark {
fn prompt(&self) -> String {
crate::t!("Pick an object").into_owned()
}
fn on_point(&mut self, _pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep {
ocs_plugin_api::host::CommandStep::Cancel
}
fn needs_object_pick(&self) -> bool {
true
}
fn on_object_pick(
&mut self,
_handle: acadrust::Handle,
pt: [f64; 3],
) -> ocs_plugin_api::host::CommandStep {
let p =
acadrust::entities::Point::at(acadrust::types::Vector3::new(pt[0], pt[1], pt[2]));
ocs_plugin_api::host::CommandStep::CommitAndEnd(acadrust::EntityType::Point(p))
}
}
#[test]
fn plugin_object_pick_routes_to_command() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let target = {
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at(
acadrust::types::Vector3::new(3.0, 4.0, 0.0),
)));
host.start_interactive(Box::new(PickThenMark));
h
};
// The command requested an entity pick, not a free point.
assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick());
let r = app.tabs[0]
.active_cmd
.as_mut()
.unwrap()
.on_entity_pick(target, glam::DVec3::new(3.0, 4.0, 0.0));
let _ = app.apply_cmd_result(r);
// Original point + the mark the command committed.
assert_eq!(app.tabs[0].scene.document.entities().count(), 2);
}
#[test]
fn host_document_reader_sees_entities() {
use ocs_plugin_api::host::ReaderEntityKind;
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at(
acadrust::types::Vector3::new(7.0, 8.0, 0.0),
)));
let reader = host.document_reader();
assert_eq!(reader.entity_count(), 1);
let mut kinds = Vec::new();
reader.for_each_entity(&mut |e| kinds.push(e.kind));
assert_eq!(kinds, vec![ReaderEntityKind::Point]);
}
#[test]
fn host_document_view_publish_and_read_shared() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let info = host.document_view().unwrap();
let reader = ocs_plugin_api::shm::SharedDocumentReader::<
ocs_plugin_api::shm::DocumentViewData,
>::open(std::path::Path::new(&info.path))
.unwrap();
assert_eq!(reader.entity_count(), 0);
host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at(
acadrust::types::Vector3::new(1.0, 2.0, 0.0),
)));
assert_eq!(reader.entity_count(), 1);
}
/// Read an entity handle from the live document, write XDATA for that
/// handle, read it back, and remove it.
#[test]
fn document_reader_to_xdata_roundtrip() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
7.0, 8.0, 0.0,
))));
{
let reader = host.document_reader();
assert_eq!(reader.entity_count(), 1);
let mut handles = Vec::new();
reader.for_each_entity(&mut |e| handles.push(e.handle));
assert_eq!(handles, vec![h]);
}
let mut rec = ExtendedDataRecord::new("ROUNDTRIP");
rec.add_value(XDataValue::String("from-reader".to_string()));
assert!(host.write_record(h, rec));
let got = host.read_record(h, "ROUNDTRIP").expect("record missing");
assert_eq!(got.values.len(), 1);
assert!(matches!(got.values[0], XDataValue::String(ref s) if s == "from-reader"));
assert!(host.remove_record(h, "ROUNDTRIP"));
assert!(host.read_record(h, "ROUNDTRIP").is_none());
}
/// Publish a shared document view, read the entity handle from shared
/// memory, then write and read-back XDATA through the normal HostApi RPCs.
#[test]
fn shared_document_view_read_then_write_xdata_roundtrip() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let info = host.document_view().unwrap();
let reader = ocs_plugin_api::shm::SharedDocumentReader::<
ocs_plugin_api::shm::DocumentViewData,
>::open(std::path::Path::new(&info.path))
.unwrap();
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
1.0, 2.0, 0.0,
))));
assert_eq!(reader.entity_count(), 1);
let mut handles = Vec::new();
reader.for_each_entity(&mut |e| handles.push(e.handle));
assert_eq!(handles, vec![h]);
let mut rec = ExtendedDataRecord::new("SHM_ROUNDTRIP");
rec.add_value(XDataValue::Integer32(123));
assert!(host.write_record(h, rec));
let got = host
.read_record(h, "SHM_ROUNDTRIP")
.expect("record missing");
assert_eq!(got.values.len(), 1);
assert!(matches!(got.values[0], XDataValue::Integer32(123)));
}
}