forked from 1b-agent/opencadstudio-upstream
Add the typed document facade and plugin IPC adapter. Prepare bulk edits before mutation, preserve undo and dirty state, keep profile elevations, validate numeric inputs and session bindings, and generate Python constructors with typed results. Reuse existing entity transforms and profile conversion. Compute mass properties and closed-surface bounds in cadkernel; pin its main revision 6a5e6fc2822f6af6b2611cd605b44437b35158f0. Keep plugin API metadata at version 6. Validation: 646 native application tests, 597 kernel tests, 12 document API tests, 91 plugin API tests, 3 Python tests, default/IPC feature builds and wasm32 check passed.
3485 lines
127 KiB
Rust
3485 lines
127 KiB
Rust
//! Native document API adapter over the existing scene, history and kernel paths.
|
||
|
||
use acadrust::entities::Solid3D;
|
||
use acadrust::{EntityType, Handle};
|
||
use ocs_doc_api::backend::{DocApiBackend, KernelBody};
|
||
use ocs_doc_api::{
|
||
Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision,
|
||
ObjectId, PlacementSpec,
|
||
};
|
||
|
||
use crate::scene::convert::acis_export;
|
||
use crate::scene::model::solid_model;
|
||
use ocs_doc_api::convert;
|
||
|
||
use super::plugin_host::HostSession;
|
||
|
||
/// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by
|
||
// the in-process path. Deserializes the envelope, runs the crate executor,
|
||
// serializes the `Receipt` (or `ApiError`) back to bytes.
|
||
pub fn execute_doc_api(
|
||
host: &mut HostSession<'_>,
|
||
tab_id: u64,
|
||
bytes: &[u8],
|
||
) -> Result<Vec<u8>, String> {
|
||
use bincode::Options;
|
||
use ocs_doc_api::{DocApiEnvelope, EnvelopeBody};
|
||
// Authorization: the request must name the SAME tab the HostSession is bound to.
|
||
// The HostSession is built per-tab by the dispatch pump; a plugin naming a
|
||
// different tab_id would otherwise reach a tab it isn't bound to (confused deputy).
|
||
if tab_id != host.tab_id() {
|
||
return Err(format!(
|
||
"DocApi tab mismatch: request names tab {tab_id} but the bound tab is {}",
|
||
host.tab_id()
|
||
));
|
||
}
|
||
host.scene_mut().doc_api_cold_tess_used = 0;
|
||
const FRAME_LIMIT: u64 = 64 * 1024 * 1024;
|
||
if bytes.len() as u64 > FRAME_LIMIT {
|
||
return Err("DocApi frame exceeds 64 MiB".into());
|
||
}
|
||
let envelope: DocApiEnvelope = bincode::DefaultOptions::new()
|
||
.with_fixint_encoding()
|
||
.with_limit(FRAME_LIMIT)
|
||
.reject_trailing_bytes()
|
||
.deserialize(bytes)
|
||
.map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?;
|
||
let result: ApiResult<ocs_doc_api::Receipt> =
|
||
envelope
|
||
.validate_version()
|
||
.and_then(|_| match envelope.body {
|
||
EnvelopeBody::Op(op) => ocs_doc_api::executor::apply_op(host, op),
|
||
EnvelopeBody::Queries(qs) => ocs_doc_api::executor::apply_queries(host, qs),
|
||
});
|
||
bincode::serialize(&result).map_err(|e| format!("Receipt serialize: {e}"))
|
||
}
|
||
|
||
// ObjectId <-> acadrust::Handle converters delegate to the crate's helpers
|
||
// (single source of truth; no local duplicates).
|
||
fn obj_to_handle(id: ObjectId) -> Handle {
|
||
id.to_handle()
|
||
}
|
||
fn handle_to_obj(h: Handle) -> ObjectId {
|
||
ObjectId::from_handle(h)
|
||
}
|
||
|
||
impl DocApiBackend for HostSession<'_> {
|
||
fn resolve_body(&mut self, id: ObjectId) -> ApiResult<KernelBody> {
|
||
let handle = obj_to_handle(id);
|
||
// Lift-on-miss: populate the solid_models cache from AcisData if absent.
|
||
self.scene_mut().restore_solid_models(&[handle]);
|
||
self.scene()
|
||
.solid_models
|
||
.get(&handle)
|
||
.cloned()
|
||
.ok_or(ApiError::UnknownId(id))
|
||
}
|
||
|
||
fn with_body<R>(
|
||
&mut self,
|
||
id: ObjectId,
|
||
f: &mut dyn FnMut(&KernelBody) -> ApiResult<R>,
|
||
) -> ApiResult<R> {
|
||
let handle = obj_to_handle(id);
|
||
// Lift-on-miss, then borrow the cache entry in place — O(1), no deep clone.
|
||
self.scene_mut().restore_solid_models(&[handle]);
|
||
let body = self
|
||
.scene()
|
||
.solid_models
|
||
.get(&handle)
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
f(body)
|
||
}
|
||
|
||
fn store_solid(&mut self, body: &KernelBody) -> ApiResult<ObjectId> {
|
||
let prepared = self.prepare_doc_solid(body.clone(), None)?;
|
||
Ok(self.apply_doc_entity(prepared, None))
|
||
}
|
||
|
||
fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> {
|
||
self.can_modify(id)?;
|
||
let prepared = self.prepare_doc_solid(body.clone(), Some(id))?;
|
||
self.apply_doc_entity(prepared, Some(id));
|
||
Ok(())
|
||
}
|
||
|
||
fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult<Vec<ObjectId>> {
|
||
let mut prepared = Vec::with_capacity(specs.len());
|
||
for spec in specs {
|
||
prepared.push(match spec {
|
||
ocs_doc_api::EntitySpec::Solid(spec) => {
|
||
self.prepare_doc_solid(ocs_doc_api::geom::make_solid(spec)?, None)?
|
||
}
|
||
ocs_doc_api::EntitySpec::Curve(spec) => PreparedDocEntity {
|
||
entity: convert::curve_spec_to_entity(spec)?,
|
||
solid: None,
|
||
},
|
||
});
|
||
}
|
||
DocApiBackend::push_undo(self, "CreateMany");
|
||
let ids = prepared
|
||
.into_iter()
|
||
.map(|entity| self.apply_doc_entity(entity, None))
|
||
.collect();
|
||
self.finalize_op();
|
||
Ok(ids)
|
||
}
|
||
|
||
fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> {
|
||
let prepared = ids
|
||
.iter()
|
||
.map(|&id| self.prepare_doc_transform(id, placement))
|
||
.collect::<ApiResult<Vec<_>>>()?;
|
||
DocApiBackend::push_undo(self, "TransformMany");
|
||
for (&id, entity) in ids.iter().zip(prepared) {
|
||
self.apply_doc_entity(entity, Some(id));
|
||
}
|
||
self.finalize_op();
|
||
Ok(())
|
||
}
|
||
|
||
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
|
||
let entity = convert::curve_spec_to_entity(spec)?;
|
||
let handle = self.scene_mut().add_entity(entity);
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult<ObjectId> {
|
||
// Validate the referenced block exists before committing.
|
||
if self
|
||
.document()
|
||
.block_records
|
||
.get(&spec.block_name)
|
||
.is_none()
|
||
{
|
||
return Err(ApiError::validation(
|
||
"CreateInsert",
|
||
format!("unknown block {:?}", spec.block_name),
|
||
));
|
||
}
|
||
let mut ins = acadrust::entities::Insert::new(
|
||
spec.block_name.clone(),
|
||
acadrust::types::Vector3::new(
|
||
spec.insert_point[0],
|
||
spec.insert_point[1],
|
||
spec.insert_point[2],
|
||
),
|
||
);
|
||
ins.rotation = spec.rotation;
|
||
ins.set_x_scale(spec.scale);
|
||
ins.set_y_scale(spec.scale);
|
||
ins.set_z_scale(spec.scale);
|
||
let handle = self.scene_mut().add_entity(EntityType::Insert(ins));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::Viewport;
|
||
use acadrust::types::Vector3;
|
||
let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]);
|
||
let mut vp = Viewport::new();
|
||
vp.center = v3(spec.center);
|
||
vp.width = spec.width;
|
||
vp.height = spec.height;
|
||
vp.view_target = v3(spec.view_target);
|
||
vp.view_height = spec.view_height;
|
||
let handle = self.scene_mut().add_entity(EntityType::Viewport(vp));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::Text;
|
||
use acadrust::types::Vector3;
|
||
let mut t = Text::new();
|
||
t.value = spec.value.clone();
|
||
t.insertion_point = Vector3::new(
|
||
spec.insertion_point[0],
|
||
spec.insertion_point[1],
|
||
spec.insertion_point[2],
|
||
);
|
||
t.height = spec.height;
|
||
t.rotation = spec.rotation;
|
||
let handle = self.scene_mut().add_entity(EntityType::Text(t));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::MText;
|
||
use acadrust::types::Vector3;
|
||
let mut t = MText::with_value(
|
||
spec.value.clone(),
|
||
Vector3::new(
|
||
spec.insertion_point[0],
|
||
spec.insertion_point[1],
|
||
spec.insertion_point[2],
|
||
),
|
||
);
|
||
t.height = spec.height;
|
||
let handle = self.scene_mut().add_entity(EntityType::MText(t));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> {
|
||
let handle = obj_to_handle(id);
|
||
let entity = self
|
||
.document()
|
||
.get_entity(handle)
|
||
.cloned()
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
let new_entity = match entity {
|
||
EntityType::Text(mut t) => {
|
||
t.value = value.to_string();
|
||
EntityType::Text(t)
|
||
}
|
||
EntityType::MText(mut t) => {
|
||
t.value = value.to_string();
|
||
EntityType::MText(t)
|
||
}
|
||
_ => {
|
||
return Err(ApiError::Unsupported(
|
||
"SetTextContent is only for Text/MText".into(),
|
||
))
|
||
}
|
||
};
|
||
if !self.scene_mut().update_entity(new_entity) {
|
||
return Err(ApiError::Unsupported(format!(
|
||
"entity {id:?} is on a locked layer"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn text_content(&self, id: ObjectId) -> ApiResult<String> {
|
||
let entity = self
|
||
.document()
|
||
.get_entity(obj_to_handle(id))
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
match entity {
|
||
EntityType::Text(t) => Ok(t.value.clone()),
|
||
EntityType::MText(t) => Ok(t.value.clone()),
|
||
_ => Err(ApiError::Unsupported(
|
||
"GetTextContent is only for Text/MText".into(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn can_modify(&self, id: ObjectId) -> ApiResult<()> {
|
||
let handle = obj_to_handle(id);
|
||
if !self.entity_exists(id) {
|
||
return Err(ApiError::UnknownId(id));
|
||
}
|
||
if self.scene().is_layer_locked(handle) {
|
||
return Err(ApiError::Unsupported(format!(
|
||
"entity {id:?} is on a locked layer"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::{BoundaryEdge, BoundaryPath, Hatch, LineEdge};
|
||
use acadrust::types::Vector2;
|
||
let mut hatch = if spec.solid {
|
||
Hatch::solid()
|
||
} else {
|
||
Hatch::default()
|
||
};
|
||
// Build a BoundaryPath of Line edges around the closed polyline.
|
||
let n = spec.boundary.len();
|
||
let mut path = BoundaryPath::new();
|
||
for i in 0..n {
|
||
let start = spec.boundary[i];
|
||
let end = spec.boundary[(i + 1) % n];
|
||
path.edges.push(BoundaryEdge::Line(LineEdge {
|
||
start: Vector2::new(start[0], start[1]),
|
||
end: Vector2::new(end[0], end[1]),
|
||
}));
|
||
}
|
||
hatch.paths.push(path);
|
||
let handle = self.scene_mut().add_entity(EntityType::Hatch(hatch));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>> {
|
||
let entity = self
|
||
.document()
|
||
.get_entity(obj_to_handle(id))
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
let EntityType::Hatch(h) = entity else {
|
||
return Err(ApiError::Unsupported(
|
||
"GetHatchBoundary is only for Hatch".into(),
|
||
));
|
||
};
|
||
// Reconstruct each boundary loop's vertices from its Line edges.
|
||
let mut loops = Vec::with_capacity(h.paths.len());
|
||
for path in &h.paths {
|
||
let mut pts = Vec::with_capacity(path.edges.len());
|
||
for edge in &path.edges {
|
||
match edge {
|
||
acadrust::entities::BoundaryEdge::Line(le) => {
|
||
pts.push([le.start.x, le.start.y])
|
||
}
|
||
// Arc/ellipse boundary edges are a later refinement (no vertex list).
|
||
_ => {
|
||
return Err(ApiError::Unsupported(
|
||
"hatch boundary contains non-line edges".into(),
|
||
))
|
||
}
|
||
}
|
||
}
|
||
loops.push(pts);
|
||
}
|
||
Ok(loops)
|
||
}
|
||
|
||
fn add_dimension_linear(
|
||
&mut self,
|
||
spec: &ocs_doc_api::ops::DimensionSpec,
|
||
) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::{Dimension, DimensionLinear};
|
||
use acadrust::types::Vector3;
|
||
let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]);
|
||
let mut dim = DimensionLinear::new(v3(spec.first_point), v3(spec.second_point));
|
||
dim.definition_point = v3(spec.definition_point);
|
||
let handle = self
|
||
.scene_mut()
|
||
.add_entity(EntityType::Dimension(Dimension::Linear(dim)));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn add_dimension_radius(
|
||
&mut self,
|
||
spec: &ocs_doc_api::ops::DimensionRadialSpec,
|
||
) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::{Dimension, DimensionRadius};
|
||
use acadrust::types::Vector3;
|
||
let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]);
|
||
let dim = DimensionRadius::new(v3(spec.center), v3(spec.point));
|
||
let handle = self
|
||
.scene_mut()
|
||
.add_entity(EntityType::Dimension(Dimension::Radius(dim)));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn add_dimension_diameter(
|
||
&mut self,
|
||
spec: &ocs_doc_api::ops::DimensionRadialSpec,
|
||
) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::{Dimension, DimensionDiameter};
|
||
use acadrust::types::Vector3;
|
||
let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]);
|
||
let dim = DimensionDiameter::new(v3(spec.center), v3(spec.point));
|
||
let handle = self
|
||
.scene_mut()
|
||
.add_entity(EntityType::Dimension(Dimension::Diameter(dim)));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn add_dimension_angular(
|
||
&mut self,
|
||
spec: &ocs_doc_api::ops::DimensionAngularSpec,
|
||
) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::{Dimension, DimensionAngular3Pt};
|
||
use acadrust::types::Vector3;
|
||
let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]);
|
||
let mut dim =
|
||
DimensionAngular3Pt::new(v3(spec.vertex), v3(spec.first_point), v3(spec.second_point));
|
||
dim.definition_point = v3(spec.arc_location);
|
||
let handle = self
|
||
.scene_mut()
|
||
.add_entity(EntityType::Dimension(Dimension::Angular3Pt(dim)));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn dimension_measurement(&self, id: ObjectId) -> ApiResult<f64> {
|
||
let entity = self
|
||
.document()
|
||
.get_entity(obj_to_handle(id))
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
let EntityType::Dimension(d) = entity else {
|
||
return Err(ApiError::Unsupported(
|
||
"GetDimensionMeasurement is only for Dimension".into(),
|
||
));
|
||
};
|
||
// Distance for linear/radius/diameter; degrees for angular.
|
||
Ok(match d {
|
||
acadrust::entities::Dimension::Linear(x) => x.measurement(),
|
||
acadrust::entities::Dimension::Radius(x) => x.measurement(),
|
||
acadrust::entities::Dimension::Diameter(x) => x.measurement(),
|
||
acadrust::entities::Dimension::Angular3Pt(x) => x.measurement_degrees(),
|
||
acadrust::entities::Dimension::Angular2Ln(x) => x.measurement_degrees(),
|
||
_ => {
|
||
return Err(ApiError::Unsupported(
|
||
"dimension measurement for this sub-type".into(),
|
||
))
|
||
}
|
||
})
|
||
}
|
||
|
||
fn add_attribute_definition(
|
||
&mut self,
|
||
spec: &ocs_doc_api::ops::AttributeDefinitionSpec,
|
||
) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::AttributeDefinition;
|
||
use acadrust::types::Vector3;
|
||
let mut att = AttributeDefinition::default();
|
||
att.tag = spec.tag.clone();
|
||
att.prompt = spec.prompt.clone();
|
||
att.default_value = spec.default_value.clone();
|
||
att.insertion_point = Vector3::new(
|
||
spec.insertion_point[0],
|
||
spec.insertion_point[1],
|
||
spec.insertion_point[2],
|
||
);
|
||
att.height = spec.height;
|
||
att.rotation = spec.rotation;
|
||
let handle = self
|
||
.scene_mut()
|
||
.add_entity(EntityType::AttributeDefinition(att));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn add_dimension_angular2ln(
|
||
&mut self,
|
||
spec: &ocs_doc_api::ops::DimensionAngularSpec,
|
||
) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::{Dimension, DimensionAngular2Ln};
|
||
use acadrust::types::Vector3;
|
||
let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]);
|
||
let mut dim =
|
||
DimensionAngular2Ln::new(v3(spec.vertex), v3(spec.first_point), v3(spec.second_point));
|
||
dim.dimension_arc = v3(spec.arc_location);
|
||
let handle = self
|
||
.scene_mut()
|
||
.add_entity(EntityType::Dimension(Dimension::Angular2Ln(dim)));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::Table;
|
||
use acadrust::types::Vector3;
|
||
let cols = spec.data.first().map(|r| r.len()).unwrap_or(0);
|
||
let mut table = Table::new(
|
||
Vector3::new(
|
||
spec.insertion_point[0],
|
||
spec.insertion_point[1],
|
||
spec.insertion_point[2],
|
||
),
|
||
spec.data.len(),
|
||
cols,
|
||
);
|
||
// Fill cells from the grid (each cell = text).
|
||
for (r, row) in spec.data.iter().enumerate() {
|
||
for (c, text) in row.iter().enumerate() {
|
||
table.set_cell_text(r, c, text);
|
||
}
|
||
}
|
||
let handle = self.scene_mut().add_entity(EntityType::Table(table));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> {
|
||
let handle = obj_to_handle(id);
|
||
let entity = self
|
||
.document()
|
||
.get_entity(handle)
|
||
.cloned()
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
let EntityType::Insert(mut ins) = entity else {
|
||
return Err(ApiError::Unsupported(
|
||
"SetAttribute is only for Insert entities".into(),
|
||
));
|
||
};
|
||
if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) {
|
||
attr.value = value.to_string();
|
||
} else {
|
||
let mut attr = acadrust::entities::AttributeEntity::default();
|
||
attr.tag = tag.to_string();
|
||
attr.value = value.to_string();
|
||
attr.insertion_point = ins.insert_point;
|
||
ins.attributes.push(attr);
|
||
}
|
||
if !self.scene_mut().update_entity(EntityType::Insert(ins)) {
|
||
return Err(ApiError::Unsupported(format!(
|
||
"entity {id:?} is on a locked layer"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn attributes(&self, id: ObjectId) -> ApiResult<Vec<(String, String)>> {
|
||
let entity = self
|
||
.document()
|
||
.get_entity(obj_to_handle(id))
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
let EntityType::Insert(ins) = entity else {
|
||
return Err(ApiError::Unsupported(
|
||
"GetAttributes is only for Insert entities".into(),
|
||
));
|
||
};
|
||
Ok(ins
|
||
.attributes
|
||
.iter()
|
||
.map(|a| (a.tag.clone(), a.value.clone()))
|
||
.collect())
|
||
}
|
||
|
||
fn block_entities(&self, block_name: &str) -> ApiResult<Vec<EntityView>> {
|
||
let br = self
|
||
.document()
|
||
.block_records
|
||
.get(block_name)
|
||
.ok_or_else(|| {
|
||
ApiError::validation("GetBlockEntities", format!("unknown block {block_name:?}"))
|
||
})?;
|
||
// Read-only traversal of the block definition's entities (handle lookups).
|
||
let mut out = Vec::with_capacity(br.entity_handles.len());
|
||
for h in &br.entity_handles {
|
||
let id = handle_to_obj(*h);
|
||
let (kind, bounds) = match self.document().get_entity(*h) {
|
||
Some(e) => (
|
||
convert::entity_kind_name(e).to_string(),
|
||
convert::entity_bounds(Some(e), id).ok(),
|
||
),
|
||
None => ("Missing".to_string(), None),
|
||
};
|
||
out.push(EntityView { id, kind, bounds });
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn set_viewport_view(
|
||
&mut self,
|
||
id: ObjectId,
|
||
view_target: [f64; 3],
|
||
view_height: f64,
|
||
) -> ApiResult<()> {
|
||
let handle = obj_to_handle(id);
|
||
let entity = self
|
||
.document()
|
||
.get_entity(handle)
|
||
.cloned()
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
let EntityType::Viewport(mut vp) = entity else {
|
||
return Err(ApiError::Unsupported(
|
||
"SetViewportView is only for Viewport".into(),
|
||
));
|
||
};
|
||
vp.view_target =
|
||
acadrust::types::Vector3::new(view_target[0], view_target[1], view_target[2]);
|
||
vp.view_height = view_height;
|
||
if !self.scene_mut().update_entity(EntityType::Viewport(vp)) {
|
||
return Err(ApiError::Unsupported(format!(
|
||
"entity {id:?} is on a locked layer"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> {
|
||
let entity = self
|
||
.document()
|
||
.get_entity(obj_to_handle(id))
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
let EntityType::Viewport(vp) = entity else {
|
||
return Err(ApiError::Unsupported(
|
||
"GetViewportView is only for Viewport".into(),
|
||
));
|
||
};
|
||
Ok((
|
||
[vp.view_target.x, vp.view_target.y, vp.view_target.z],
|
||
vp.view_height,
|
||
))
|
||
}
|
||
|
||
fn add_raster_image(
|
||
&mut self,
|
||
spec: &ocs_doc_api::ops::RasterImageSpec,
|
||
) -> ApiResult<ObjectId> {
|
||
use acadrust::entities::RasterImage;
|
||
use acadrust::types::{Vector2, Vector3};
|
||
// Validate the plugin-supplied path before embedding it in the document:
|
||
// non-empty, a known image extension, and not a UNC/network path or
|
||
// parent-traversal (which could point the host at unintended resources).
|
||
validate_image_path(&spec.file_path)?;
|
||
let mut img = RasterImage::default();
|
||
img.file_path = spec.file_path.clone();
|
||
img.insertion_point = Vector3::new(
|
||
spec.insertion_point[0],
|
||
spec.insertion_point[1],
|
||
spec.insertion_point[2],
|
||
);
|
||
img.u_vector = Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]);
|
||
img.v_vector = Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]);
|
||
img.size = Vector2::new(spec.size[0], spec.size[1]);
|
||
// add_entity auto-registers the ImageDefinition (scene/entity.rs).
|
||
let handle = self.scene_mut().add_entity(EntityType::RasterImage(img));
|
||
Ok(handle_to_obj(handle))
|
||
}
|
||
|
||
fn loft(
|
||
&mut self,
|
||
sections: &[(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)],
|
||
) -> ApiResult<ObjectId> {
|
||
let body = cadkernel::brep::loft(sections)
|
||
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "loft failed"))?;
|
||
self.store_solid(&body)
|
||
}
|
||
|
||
fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> {
|
||
let handle = obj_to_handle(id);
|
||
let Some(entity) = self.document().get_entity(handle).cloned() else {
|
||
return Err(ApiError::UnknownId(id));
|
||
};
|
||
let EntityType::LwPolyline(mut pl) = entity else {
|
||
return Err(ApiError::Unsupported(
|
||
"AddVertex is only implemented for polylines".into(),
|
||
));
|
||
};
|
||
if point[2] != pl.elevation || pl.vertices.len() >= ocs_doc_api::ops::BULK_ITEM_CAP {
|
||
return Err(ApiError::validation(
|
||
"AddVertex",
|
||
"vertex must share the polyline elevation and fit the item cap",
|
||
));
|
||
}
|
||
if at > pl.vertices.len() {
|
||
return Err(ApiError::validation(
|
||
"AddVertex",
|
||
format!("index {at} out of range ({} vertices)", pl.vertices.len()),
|
||
));
|
||
}
|
||
pl.vertices.insert(
|
||
at,
|
||
acadrust::entities::LwVertex::from_coords(point[0], point[1]),
|
||
);
|
||
if !self.scene_mut().update_entity(EntityType::LwPolyline(pl)) {
|
||
return Err(ApiError::Unsupported(format!(
|
||
"entity {id:?} is on a locked layer"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool> {
|
||
let handle = obj_to_handle(id);
|
||
if !self.entity_exists(id) {
|
||
return Ok(false);
|
||
}
|
||
// Locked layers refuse deletion; surface that as an error so the executor
|
||
// does not treat a no-op as success.
|
||
if self.scene().is_layer_locked(handle) {
|
||
return Err(ApiError::Unsupported(format!(
|
||
"entity {id:?} is on a locked layer"
|
||
)));
|
||
}
|
||
// erase_entities clears the entity + solid_models/meshes/hatches and
|
||
// records the undo delta; the single publish happens at finalize_op.
|
||
self.scene_mut().erase_entities(&[handle]);
|
||
Ok(self.document().get_entity(handle).is_none())
|
||
}
|
||
|
||
fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> {
|
||
// Pre-validating everything the apply loop can actually fail on is what makes
|
||
// TransformMany all-or-nothing (no mid-loop failure after earlier mutations).
|
||
// This must check more than the entity TYPE: layer-lock (update_entity returns
|
||
// false) and, for solids, that the body resolves.
|
||
let handle = obj_to_handle(id);
|
||
let entity = self
|
||
.document()
|
||
.get_entity(handle)
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
let ok = matches!(
|
||
entity,
|
||
EntityType::Solid3D(_)
|
||
| EntityType::Line(_)
|
||
| EntityType::Circle(_)
|
||
| EntityType::Arc(_)
|
||
| EntityType::Ellipse(_)
|
||
| EntityType::Spline(_)
|
||
| EntityType::Ray(_)
|
||
| EntityType::XLine(_)
|
||
| EntityType::Insert(_)
|
||
| EntityType::Viewport(_)
|
||
| EntityType::Text(_)
|
||
| EntityType::MText(_)
|
||
| EntityType::Point(_)
|
||
| EntityType::LwPolyline(_)
|
||
);
|
||
if !ok {
|
||
return Err(ApiError::Unsupported(
|
||
"transform is not supported for this entity family".into(),
|
||
));
|
||
}
|
||
// Locked layer -> update_entity would return false mid-loop.
|
||
if self.scene().is_layer_locked(handle) {
|
||
return Err(ApiError::Unsupported(format!(
|
||
"entity {id:?} is on a locked layer"
|
||
)));
|
||
}
|
||
// Solid: the body must resolve (kernel transform/display-prep can still fail
|
||
// at apply time, but resolution is the checkable precondition).
|
||
if matches!(entity, EntityType::Solid3D(_)) {
|
||
self.resolve_body(id)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn can_remove(&self, id: ObjectId) -> ApiResult<()> {
|
||
let handle = obj_to_handle(id);
|
||
if !self.entity_exists(id) {
|
||
return Err(ApiError::UnknownId(id));
|
||
}
|
||
if self.scene().is_layer_locked(handle) {
|
||
return Err(ApiError::Unsupported(format!(
|
||
"entity {id:?} is on a locked layer"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> {
|
||
let handle = obj_to_handle(id);
|
||
let entity = self
|
||
.document()
|
||
.get_entity(handle)
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
Ok(EntityView {
|
||
id,
|
||
kind: convert::entity_kind_name(entity).to_string(),
|
||
bounds: self.bounds(id).ok(),
|
||
})
|
||
}
|
||
|
||
fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> {
|
||
let prepared = self.prepare_doc_transform(id, placement)?;
|
||
self.apply_doc_entity(prepared, Some(id));
|
||
Ok(())
|
||
}
|
||
|
||
fn profile_curves(&self, id: ObjectId) -> ApiResult<Vec<cadkernel::geom2d::Curve>> {
|
||
let entity = self
|
||
.document()
|
||
.get_entity(obj_to_handle(id))
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
crate::scene::model::sweep_model::extrusion_profile_of(entity)
|
||
.map(|(profile, _)| profile.pieces)
|
||
.ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into()))
|
||
}
|
||
|
||
fn profile_plane(&self, id: ObjectId) -> ApiResult<cadkernel::space::Plane> {
|
||
let entity = self
|
||
.document()
|
||
.get_entity(obj_to_handle(id))
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
crate::scene::model::sweep_model::extrusion_profile_of(entity)
|
||
.map(|(profile, _)| profile.plane)
|
||
.ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into()))
|
||
}
|
||
|
||
fn bounds(&mut self, id: ObjectId) -> ApiResult<Aabb> {
|
||
let handle = obj_to_handle(id);
|
||
// Lift-on-miss for solids (consistent with volume/centroid); borrow the cache
|
||
// read-only via with_body (O(1), no B-rep deep clone).
|
||
if matches!(
|
||
self.document().get_entity(handle),
|
||
Some(EntityType::Solid3D(_))
|
||
) {
|
||
let mut f = |body: &KernelBody| -> ApiResult<Aabb> {
|
||
let bb = cadkernel::brep::body_bounds(body)
|
||
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?;
|
||
Ok(Aabb {
|
||
min: bb.min,
|
||
max: bb.max,
|
||
})
|
||
};
|
||
return self.with_body(id, &mut f);
|
||
}
|
||
convert::entity_bounds(self.document().get_entity(handle), id)
|
||
}
|
||
|
||
fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> {
|
||
Ok(self.mass_properties(id)?.1)
|
||
}
|
||
|
||
fn volume(&mut self, id: ObjectId) -> ApiResult<f64> {
|
||
Ok(self.mass_properties(id)?.0)
|
||
}
|
||
|
||
fn entity_exists(&self, id: ObjectId) -> bool {
|
||
self.document().get_entity(obj_to_handle(id)).is_some()
|
||
}
|
||
|
||
fn revision(&self) -> GeometryRevision {
|
||
GeometryRevision(self.scene().geometry_epoch)
|
||
}
|
||
|
||
fn push_undo(&mut self, label: &str) {
|
||
// Begin undo capture for an entity-only delta (delta_safe = true: solids
|
||
// and curves are entity-store changes).
|
||
self.begin_doc_api_undo(label);
|
||
}
|
||
|
||
fn cancel_op(&mut self) {
|
||
self.cancel_doc_api_undo();
|
||
}
|
||
|
||
fn finalize_op(&mut self) {
|
||
// Close the delta entry + bump geometry + republish the document view.
|
||
self.commit_doc_api_undo();
|
||
}
|
||
}
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
struct PreparedDocEntity {
|
||
entity: EntityType,
|
||
solid: Option<(
|
||
KernelBody,
|
||
(
|
||
crate::scene::model::mesh_model::MeshLodSet,
|
||
Vec<acadrust::entities::Wire>,
|
||
[f64; 3],
|
||
),
|
||
)>,
|
||
}
|
||
|
||
impl HostSession<'_> {
|
||
fn prepare_doc_solid(
|
||
&self,
|
||
body: KernelBody,
|
||
id: Option<ObjectId>,
|
||
) -> ApiResult<PreparedDocEntity> {
|
||
let mut inner = match id {
|
||
Some(id) => match self.document().get_entity(obj_to_handle(id)) {
|
||
Some(EntityType::Solid3D(solid)) => solid.clone(),
|
||
_ => return Err(ApiError::UnknownId(id)),
|
||
},
|
||
None => Solid3D::new(),
|
||
};
|
||
let handle = id.map_or(Handle::NULL, obj_to_handle);
|
||
let display = self
|
||
.scene()
|
||
.prepare_solid_model_display(handle, &body)
|
||
.ok_or_else(|| {
|
||
ApiError::geometry(GeometryErrorKind::Other, "solid display preparation failed")
|
||
})?;
|
||
let sat = acis_export::solid_to_sat(&body).ok_or_else(|| {
|
||
ApiError::geometry(GeometryErrorKind::Acis, "solid serialization failed")
|
||
})?;
|
||
inner.wires = solid_model::edge_wires(&body);
|
||
inner.set_sat_document(&sat);
|
||
Ok(PreparedDocEntity {
|
||
entity: EntityType::Solid3D(inner),
|
||
solid: Some((body, display)),
|
||
})
|
||
}
|
||
|
||
fn prepare_doc_transform(
|
||
&mut self,
|
||
id: ObjectId,
|
||
placement: &PlacementSpec,
|
||
) -> ApiResult<PreparedDocEntity> {
|
||
self.ensure_transformable(id)?;
|
||
let entity = self
|
||
.document()
|
||
.get_entity(obj_to_handle(id))
|
||
.ok_or(ApiError::UnknownId(id))?;
|
||
if matches!(entity, EntityType::Solid3D(_)) {
|
||
let body = self.resolve_body(id)?;
|
||
let moved = cadkernel::brep::transform(&body, &kernel_placement(placement))
|
||
.ok_or_else(|| {
|
||
ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed")
|
||
})?;
|
||
self.prepare_doc_solid(moved, Some(id))
|
||
} else {
|
||
Ok(PreparedDocEntity {
|
||
entity: convert::transform_entity_geometry(entity, placement)?,
|
||
solid: None,
|
||
})
|
||
}
|
||
}
|
||
|
||
fn apply_doc_entity(&mut self, prepared: PreparedDocEntity, id: Option<ObjectId>) -> ObjectId {
|
||
let handle = match id {
|
||
Some(id) => {
|
||
assert!(
|
||
self.scene_mut().update_entity(prepared.entity),
|
||
"prevalidated entity update"
|
||
);
|
||
obj_to_handle(id)
|
||
}
|
||
None => self.scene_mut().add_entity(prepared.entity),
|
||
};
|
||
if let Some((body, display)) = prepared.solid {
|
||
self.scene_mut()
|
||
.register_prepared_solid_model(handle, body, display);
|
||
}
|
||
handle_to_obj(handle)
|
||
}
|
||
|
||
/// Kernel mesh mass properties, cached per entity and geometry epoch.
|
||
fn mass_properties(&mut self, id: ObjectId) -> ApiResult<(f64, [f64; 3])> {
|
||
let handle = obj_to_handle(id);
|
||
let epoch = self.scene().geometry_epoch;
|
||
if let Some(&(cached_epoch, v, c)) = self.scene().doc_api_mass_cache.get(&handle) {
|
||
if cached_epoch == epoch {
|
||
return Ok((v, c));
|
||
}
|
||
}
|
||
if self.scene().doc_api_cold_tess_used >= 32 {
|
||
return Err(ApiError::validation(
|
||
"mass properties",
|
||
"request exceeds 32 uncached solid queries",
|
||
));
|
||
}
|
||
let body = self.resolve_body(id)?;
|
||
self.scene_mut().doc_api_cold_tess_used += 1;
|
||
let mesh = cadkernel::brep::mesh_body(&body, 0.1, 1e-4);
|
||
let (v, c) = mesh.mass_properties().ok_or_else(|| {
|
||
ApiError::geometry(GeometryErrorKind::Empty, "solid has no measurable volume")
|
||
})?;
|
||
if self.scene().doc_api_mass_cache.len() >= ocs_doc_api::ops::BULK_ITEM_CAP {
|
||
self.scene_mut().doc_api_mass_cache.clear();
|
||
}
|
||
self.scene_mut()
|
||
.doc_api_mass_cache
|
||
.insert(handle, (epoch, v, c));
|
||
Ok((v, c))
|
||
}
|
||
}
|
||
|
||
/// Validate a raster-image file path before embedding it in a document.
|
||
/// Rejects empty paths, UNC/network paths, parent-traversal, and non-image extensions.
|
||
fn validate_image_path(path: &str) -> ApiResult<()> {
|
||
let p = path.trim();
|
||
if p.is_empty() {
|
||
return Err(ApiError::validation(
|
||
"CreateRasterImage",
|
||
"empty image file path",
|
||
));
|
||
}
|
||
// UNC / network / device paths.
|
||
if p.starts_with("\\\\") || p.starts_with("//") {
|
||
return Err(ApiError::Unsupported(
|
||
"network/UNC image paths are not allowed".into(),
|
||
));
|
||
}
|
||
// Parent-traversal anywhere in the path.
|
||
if p.split(['/', '\\']).any(|seg| seg == "..") {
|
||
return Err(ApiError::Unsupported(
|
||
"parent-traversal ('..') in image path is not allowed".into(),
|
||
));
|
||
}
|
||
// Known image extension (last component).
|
||
let ext_ok = p
|
||
.rsplit('.')
|
||
.next()
|
||
.map(|ext| {
|
||
matches!(
|
||
ext.to_ascii_lowercase().as_str(),
|
||
"png" | "jpg" | "jpeg" | "bmp" | "gif" | "tif" | "tiff" | "webp" | "tga"
|
||
)
|
||
})
|
||
.unwrap_or(false);
|
||
if !ext_ok {
|
||
return Err(ApiError::validation(
|
||
"CreateRasterImage",
|
||
format!("unsupported image file extension in {path:?}"),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement {
|
||
cadkernel::brep::Placement {
|
||
x_axis: p.x_axis,
|
||
y_axis: p.y_axis,
|
||
z_axis: p.z_axis,
|
||
origin: p.origin,
|
||
}
|
||
}
|
||
|
||
/// Signed volume and centroid of a closed triangle mesh via the divergence
|
||
/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source
|
||
/// of truth for both (used by the cold-cache `volume`/`centroid` fallback).
|
||
fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
|
||
// Delegate to the crate's single source of truth (divergence theorem).
|
||
ocs_doc_api::geom::mesh_volume_centroid(mesh)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::app::plugin_host::HostSession;
|
||
use crate::app::OpenCADStudio;
|
||
use ocs_doc_api::ops::{BoolOp, SolidPrimitive};
|
||
use ocs_doc_api::{DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt};
|
||
|
||
fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult<Receipt> {
|
||
let bytes = bincode::serialize(&env).unwrap();
|
||
let tab_id = host.tab_id();
|
||
let out = execute_doc_api(host, tab_id, &bytes).expect("dispatch failed");
|
||
bincode::deserialize(&out).expect("receipt deserialize")
|
||
}
|
||
|
||
fn new_id(receipt: &Receipt) -> ObjectId {
|
||
receipt
|
||
.outcome
|
||
.as_ref()
|
||
.and_then(|o| o.new_id())
|
||
.expect("no new id")
|
||
}
|
||
|
||
#[test]
|
||
fn doc_api_create_boolean_query_end_to_end() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let rev0 = host.scene().geometry_epoch;
|
||
|
||
// op 1 + 2: create two overlapping boxes (one undo step + one bump each).
|
||
let cuboid_a = Operation::CreateSolid(SolidPrimitive::Cuboid {
|
||
origin: [0.0; 3],
|
||
size: [10.0; 3],
|
||
});
|
||
let cuboid_b = Operation::CreateSolid(SolidPrimitive::Cuboid {
|
||
origin: [5.0; 3],
|
||
size: [10.0; 3],
|
||
});
|
||
let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_a)).unwrap());
|
||
let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_b)).unwrap());
|
||
// Each write op advanced the geometry epoch (at least one bump per op).
|
||
assert!(
|
||
host.scene().geometry_epoch > rev0,
|
||
"epoch advanced by creates"
|
||
);
|
||
|
||
// op 3: intersect; erase_sources keeps the result at `a`, erases `b`.
|
||
let intersect = Operation::SolidBoolean {
|
||
op: BoolOp::Intersection,
|
||
a,
|
||
b,
|
||
erase_sources: true,
|
||
};
|
||
let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect)).unwrap());
|
||
assert_eq!(lens, a);
|
||
assert!(
|
||
host.document().get_entity(obj_to_handle(b)).is_none(),
|
||
"b erased"
|
||
);
|
||
assert!(
|
||
host.scene().solid_models.contains_key(&obj_to_handle(a)),
|
||
"result in cache"
|
||
);
|
||
|
||
// query batch: bounds + volume (read-only, no bump).
|
||
let rev_before_query = host.scene().geometry_epoch;
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: a }, Query::GetVolume { id: a }]),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
host.scene().geometry_epoch,
|
||
rev_before_query,
|
||
"queries must not bump"
|
||
);
|
||
let bb = match &receipt.query_results[0] {
|
||
QueryResult::Bounds(bb) => *bb,
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
};
|
||
// [0,10]^3 intersect [5,15]^3 = [5,10]^3.
|
||
assert!(
|
||
(bb.min[0] - 5.0).abs() < 1e-4 && (bb.max[0] - 10.0).abs() < 1e-4,
|
||
"bounds {bb:?}"
|
||
);
|
||
let vol = match &receipt.query_results[1] {
|
||
QueryResult::Volume(v) => *v,
|
||
other => panic!("expected volume, got {other:?}"),
|
||
};
|
||
assert!((vol - 125.0).abs() < 1.0, "volume {vol}");
|
||
let _ = HasId::id(&lens);
|
||
}
|
||
|
||
#[test]
|
||
fn doc_api_transform_many_with_stale_id_fails_all_or_nothing() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// A transformable solid, plus a stale id (always non-transformable -> UnknownId).
|
||
let mk_solid = Operation::CreateSolid(SolidPrimitive::Cuboid {
|
||
origin: [0.0; 3],
|
||
size: [10.0; 3],
|
||
});
|
||
let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_solid)).unwrap());
|
||
let stale = ObjectId::from_u64(0xFFFF_FFFF);
|
||
let rev_before = host.scene().geometry_epoch;
|
||
|
||
// TransformMany over [solid, stale]: the stale id makes it fail BEFORE any
|
||
// mutation (all-or-nothing), so the epoch must not move and no undo is recorded.
|
||
let op = Operation::TransformMany {
|
||
ids: vec![solid, stale],
|
||
placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]),
|
||
};
|
||
let err = dispatch(&mut host, DocApiEnvelope::op(op)).unwrap_err();
|
||
assert!(
|
||
matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)),
|
||
"{err:?}"
|
||
);
|
||
assert_eq!(
|
||
host.scene().geometry_epoch,
|
||
rev_before,
|
||
"no mutation on rejected TransformMany"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn doc_api_query_batch_over_cap_is_rejected() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1;
|
||
let queries: Vec<Query> = (0..over).map(|_| Query::GetGeometryRevision).collect();
|
||
let err = dispatch(&mut host, DocApiEnvelope::queries(queries)).unwrap_err();
|
||
assert!(matches!(err, ApiError::Validation { .. }), "{err:?}");
|
||
}
|
||
|
||
// ── Phase 0: outstanding supported-family methods ──────────────────────
|
||
|
||
#[test]
|
||
fn phase0_add_vertex_to_polyline() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let mk_poly = Operation::CreateCurve(Curve2Spec::Polyline {
|
||
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]],
|
||
closed: false,
|
||
});
|
||
let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_poly)).unwrap());
|
||
// Insert a vertex at index 1.
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::AddVertex {
|
||
id: poly,
|
||
at: 1,
|
||
point: [5.0, 0.0, 0.0],
|
||
}),
|
||
)
|
||
.unwrap();
|
||
// The polyline now has 4 vertices with the inserted one at index 1.
|
||
let handle = obj_to_handle(poly);
|
||
let Some(EntityType::LwPolyline(pl)) = host.document().get_entity(handle) else {
|
||
panic!("polyline not found");
|
||
};
|
||
assert_eq!(pl.vertices.len(), 4);
|
||
assert!((pl.vertices[1].location.x - 5.0).abs() < 1e-9);
|
||
// Out-of-range index is a validation error, not a panic.
|
||
let err = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::AddVertex {
|
||
id: poly,
|
||
at: 99,
|
||
point: [0.0, 0.0, 0.0],
|
||
}),
|
||
)
|
||
.unwrap_err();
|
||
assert!(matches!(err, ApiError::Validation { .. }), "{err:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn phase0_extrude_rectangular_profile_to_solid() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// A closed rectangular 2x3 profile in XY.
|
||
let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline {
|
||
points: vec![
|
||
[0.0, 0.0, 0.0],
|
||
[2.0, 0.0, 0.0],
|
||
[2.0, 3.0, 0.0],
|
||
[0.0, 3.0, 0.0],
|
||
],
|
||
closed: true,
|
||
});
|
||
let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap());
|
||
// Extrude +Z by 5 -> a 2x3x5 box = volume 30.
|
||
let solid = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Extrude {
|
||
profile,
|
||
direction: [0.0, 0.0, 5.0],
|
||
}),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetVolume { id: solid }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Volume(v) => assert!((*v - 30.0).abs() < 1.0, "extrude volume {v}"),
|
||
other => panic!("expected volume, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn phase0_revolve_profile_to_solid() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// A 1-wide, 2-tall rectangle offset 1 from the Y axis; revolve about the Y
|
||
// axis by 2*pi -> a cylinder-ish annulus (outer r=2, inner r=1, h=2): pi*(4-1)*2 = 6pi ≈ 18.85.
|
||
let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline {
|
||
points: vec![
|
||
[1.0, 0.0, 0.0],
|
||
[2.0, 0.0, 0.0],
|
||
[2.0, 2.0, 0.0],
|
||
[1.0, 2.0, 0.0],
|
||
],
|
||
closed: true,
|
||
});
|
||
let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap());
|
||
let result = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Revolve {
|
||
profile,
|
||
axis: ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
|
||
angle: std::f64::consts::TAU,
|
||
}),
|
||
);
|
||
// Revolve may be geometry-sensitive; assert it either produces a positive
|
||
// volume or surfaces a structured geometry error (no panic).
|
||
match result {
|
||
Ok(receipt) => {
|
||
let id = receipt.outcome.and_then(|o| o.new_id()).unwrap();
|
||
let v = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetVolume { id }]),
|
||
)
|
||
.unwrap();
|
||
match &v.query_results[0] {
|
||
QueryResult::Volume(vol) => assert!(*vol > 0.0, "revolve volume {vol}"),
|
||
other => panic!("expected volume, got {other:?}"),
|
||
}
|
||
}
|
||
Err(ApiError::Geometry { .. } | ApiError::Validation { .. }) => {}
|
||
Err(e) => panic!("unexpected error: {e}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn phase0_non_solid_transform_line_and_circle() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let mk_line = Operation::CreateCurve(Curve2Spec::Line {
|
||
start: [0.0; 3],
|
||
end: [10.0; 3],
|
||
});
|
||
let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap());
|
||
// Translate the line by +5 in X: bounds shift by +5.
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: line,
|
||
placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: line }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Bounds(b) => {
|
||
assert!(
|
||
(b.min[0] - 5.0).abs() < 1e-6 && (b.max[0] - 15.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
// TransformMany over a mix of line + circle is now all-or-nothing (both transformable).
|
||
let circle = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle {
|
||
centre: [0.0; 3],
|
||
radius: 2.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::TransformMany {
|
||
ids: vec![line, circle],
|
||
placement: ocs_doc_api::PlacementSpec::at([0.0, 1.0, 0.0]),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
// ── Phase 2: full 2D curve families ────────────────────────────────────
|
||
|
||
#[test]
|
||
fn phase2_arc_ellipse_spline_create_bounds_transform() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
|
||
// Arc: create -> kind Arc; bounds are the coarse full-circle bounds.
|
||
let arc = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc {
|
||
centre: [5.0, 5.0, 0.0],
|
||
radius: 4.0,
|
||
start_angle: 0.0,
|
||
end_angle: std::f64::consts::PI,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetEntity { id: arc },
|
||
Query::GetBounds { id: arc },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Entity(v) => assert_eq!(v.kind, "Arc"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Bounds(b) => assert!(
|
||
(b.min[0] - 1.0).abs() < 1e-6 && (b.max[0] - 9.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
),
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
|
||
// Ellipse: create -> kind Ellipse; bounds = centre ± major-axis length.
|
||
let ell = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse {
|
||
centre: [0.0, 0.0, 0.0],
|
||
major_axis: [6.0, 0.0, 0.0],
|
||
ratio: 0.5,
|
||
start: 0.0,
|
||
end: std::f64::consts::TAU,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetEntity { id: ell }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Entity(v) => assert_eq!(v.kind, "Ellipse"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
|
||
// Spline (degree-3 cubic through 4 control points): create -> kind Spline;
|
||
// bounds = control-point bounds. Transform by +10 X shifts bounds.
|
||
let spline = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline {
|
||
degree: 3,
|
||
control_points: vec![
|
||
[0.0, 0.0, 0.0],
|
||
[1.0, 2.0, 0.0],
|
||
[2.0, -2.0, 0.0],
|
||
[3.0, 0.0, 0.0],
|
||
],
|
||
knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0],
|
||
weights: vec![1.0; 4],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Bounds(b) => assert!(
|
||
(b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
),
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: spline,
|
||
placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Bounds(b) => assert!((b.min[0] - 10.0).abs() < 1e-6, "{b:?}"),
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn phase2_ray_xline_create_and_transform() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let ray = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray {
|
||
origin: [1.0, 2.0, 0.0],
|
||
direction: [1.0, 0.0, 0.0],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let xline = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine {
|
||
origin: [0.0, 0.0, 0.0],
|
||
direction: [0.0, 1.0, 0.0],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetEntity { id: ray },
|
||
Query::GetEntity { id: xline },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Entity(v) => assert_eq!(v.kind, "Ray"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Entity(v) => assert_eq!(v.kind, "XLine"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
// Ray/XLine are unbounded -> GetBounds is Unsupported (not a panic).
|
||
assert!(dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: ray }])
|
||
)
|
||
.is_err());
|
||
// Transform moves the ray's base point (no error).
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: ray,
|
||
placement: ocs_doc_api::PlacementSpec::at([0.0, 0.0, 5.0]),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let handle = obj_to_handle(ray);
|
||
let Some(EntityType::Ray(r)) = host.document().get_entity(handle) else {
|
||
panic!("ray not found")
|
||
};
|
||
assert!((r.base_point.z - 5.0).abs() < 1e-6);
|
||
}
|
||
|
||
// ── Phase 4: paper-space viewports ───────────────────────────────────────
|
||
|
||
#[test]
|
||
fn phase4_create_viewport_bounds_transform() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// A 40x30 paper-space viewport at (50,50,0) looking at model origin.
|
||
let vp = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateViewport(ocs_doc_api::ops::ViewportSpec {
|
||
center: [50.0, 50.0, 0.0],
|
||
width: 40.0,
|
||
height: 30.0,
|
||
view_target: [0.0, 0.0, 0.0],
|
||
view_height: 100.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let handle = obj_to_handle(vp);
|
||
let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else {
|
||
panic!("viewport not found")
|
||
};
|
||
assert!((v.width - 40.0).abs() < 1e-9 && (v.view_height - 100.0).abs() < 1e-9);
|
||
|
||
// Bounds = center ± width/2, height/2.
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetEntity { id: vp },
|
||
Query::GetBounds { id: vp },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Entity(e) => assert_eq!(e.kind, "Viewport"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Bounds(b) => {
|
||
assert!(
|
||
(b.min[0] - 30.0).abs() < 1e-6 && (b.max[0] - 70.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
assert!(
|
||
(b.min[1] - 35.0).abs() < 1e-6 && (b.max[1] - 65.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
|
||
// Transform moves the viewport's paper-space center.
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: vp,
|
||
placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else {
|
||
panic!("viewport not found")
|
||
};
|
||
assert!((v.center.x - 60.0).abs() < 1e-6);
|
||
}
|
||
|
||
// ── Regression tests for review fixes (s^2 scale, rotation, all-or-nothing) ──
|
||
|
||
#[test]
|
||
fn fix_uniform_scale_applied_once_not_squared() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let circle = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle {
|
||
centre: [0.0, 0.0, 0.0],
|
||
radius: 1.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
// Uniform scale by 2 (x_axis=(2,0,0) etc). Radius must become 2 (s), not 4 (s^2).
|
||
let placement = ocs_doc_api::PlacementSpec {
|
||
x_axis: [2.0, 0.0, 0.0],
|
||
y_axis: [0.0, 2.0, 0.0],
|
||
z_axis: [0.0, 0.0, 2.0],
|
||
origin: [0.0; 3],
|
||
};
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: circle,
|
||
placement,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let handle = obj_to_handle(circle);
|
||
let Some(EntityType::Circle(c)) = host.document().get_entity(handle) else {
|
||
panic!("circle not found")
|
||
};
|
||
assert!(
|
||
(c.radius - 2.0).abs() < 1e-9,
|
||
"radius {} (must be 2.0, not 4.0)",
|
||
c.radius
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fix_rotation_rotates_ellipse_ray_and_insert() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// 90-degree Z-rotation: x_axis=(0,1,0), y_axis=(-1,0,0).
|
||
let rot90 = ocs_doc_api::PlacementSpec {
|
||
x_axis: [0.0, 1.0, 0.0],
|
||
y_axis: [-1.0, 0.0, 0.0],
|
||
z_axis: [0.0, 0.0, 1.0],
|
||
origin: [0.0; 3],
|
||
};
|
||
// Ray along +X must become along +Y after a 90° rotation.
|
||
let ray = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray {
|
||
origin: [0.0, 0.0, 0.0],
|
||
direction: [1.0, 0.0, 0.0],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: ray,
|
||
placement: rot90,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let Some(EntityType::Ray(r)) = host.document().get_entity(obj_to_handle(ray)) else {
|
||
panic!("ray not found")
|
||
};
|
||
assert!(
|
||
(r.direction.y - 1.0).abs() < 1e-6 && r.direction.x.abs() < 1e-6,
|
||
"ray dir {:?}",
|
||
r.direction
|
||
);
|
||
|
||
// Ellipse major_axis along +X must rotate to +Y (length preserved).
|
||
let ell = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse {
|
||
centre: [0.0; 3],
|
||
major_axis: [4.0, 0.0, 0.0],
|
||
ratio: 0.5,
|
||
start: 0.0,
|
||
end: std::f64::consts::TAU,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: ell,
|
||
placement: rot90,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else {
|
||
panic!("ellipse not found")
|
||
};
|
||
assert!(
|
||
(e.major_axis.y - 4.0).abs() < 1e-6 && e.major_axis.x.abs() < 1e-6,
|
||
"ellipse axis {:?}",
|
||
e.major_axis
|
||
);
|
||
|
||
// Arc angles offset by +90° (π/2).
|
||
let arc = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc {
|
||
centre: [0.0; 3],
|
||
radius: 2.0,
|
||
start_angle: 0.0,
|
||
end_angle: 1.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: arc,
|
||
placement: rot90,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let Some(EntityType::Arc(a)) = host.document().get_entity(obj_to_handle(arc)) else {
|
||
panic!("arc not found")
|
||
};
|
||
let half_pi = std::f64::consts::FRAC_PI_2;
|
||
assert!(
|
||
(a.start_angle - half_pi).abs() < 1e-6,
|
||
"arc start {} (expected {})",
|
||
a.start_angle,
|
||
half_pi
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fix_transform_many_locked_layer_fails_all_or_nothing() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let a = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point {
|
||
position: [0.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let b = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point {
|
||
position: [1.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
// Lock the layer holding `b`.
|
||
let bh = obj_to_handle(b);
|
||
let layer = host
|
||
.document()
|
||
.get_entity(bh)
|
||
.unwrap()
|
||
.common()
|
||
.layer
|
||
.clone();
|
||
host.document_mut().layers.get_mut(&layer).unwrap().lock();
|
||
let rev_before = host.scene().geometry_epoch;
|
||
|
||
// TransformMany [a, b] must fail all-or-nothing (b is locked) BEFORE mutating a.
|
||
let err = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::TransformMany {
|
||
ids: vec![a, b],
|
||
placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]),
|
||
}),
|
||
)
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(
|
||
err,
|
||
ApiError::Validation { .. } | ApiError::Unsupported(_) | ApiError::UnknownId(_)
|
||
),
|
||
"{err:?}"
|
||
);
|
||
// `a` was NOT transformed (all-or-nothing): its location is unchanged.
|
||
let Some(EntityType::Point(pa)) = host.document().get_entity(obj_to_handle(a)) else {
|
||
panic!("point a not found")
|
||
};
|
||
assert!(
|
||
(pa.location.x - 0.0).abs() < 1e-9,
|
||
"a moved despite locked batch member"
|
||
);
|
||
assert_eq!(
|
||
host.scene().geometry_epoch,
|
||
rev_before,
|
||
"no mutation on rejected TransformMany"
|
||
);
|
||
}
|
||
|
||
// ── Phase 2b-a: annotations (Text/MText) ─────────────────────────────────
|
||
|
||
#[test]
|
||
fn phase2b_text_mtext_create_content_transform() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
|
||
// Create a TEXT, read its content, set it, verify.
|
||
let text = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateText(ocs_doc_api::ops::TextSpec {
|
||
value: "hello".into(),
|
||
insertion_point: [1.0, 2.0, 0.0],
|
||
height: 2.5,
|
||
rotation: 0.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetTextContent { id: text }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::TextContent(s) => assert_eq!(s, "hello"),
|
||
other => panic!("expected content, got {other:?}"),
|
||
}
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::SetTextContent {
|
||
id: text,
|
||
value: "world".into(),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let Some(EntityType::Text(t)) = host.document().get_entity(obj_to_handle(text)) else {
|
||
panic!("text not found")
|
||
};
|
||
assert_eq!(t.value, "world");
|
||
|
||
// SetTextContent on a non-text entity is Unsupported.
|
||
let line = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line {
|
||
start: [0.0; 3],
|
||
end: [1.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert!(dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::SetTextContent {
|
||
id: line,
|
||
value: "x".into()
|
||
})
|
||
)
|
||
.is_err());
|
||
|
||
// MTEXT create + content + transform (insertion point moves).
|
||
let mtext = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateMText(ocs_doc_api::ops::MTextSpec {
|
||
value: "multi\nline".into(),
|
||
insertion_point: [5.0, 5.0, 0.0],
|
||
height: 3.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetTextContent { id: mtext }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::TextContent(s) => assert_eq!(s, "multi\nline"),
|
||
other => panic!("expected content, got {other:?}"),
|
||
}
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: mtext,
|
||
placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let Some(EntityType::MText(t)) = host.document().get_entity(obj_to_handle(mtext)) else {
|
||
panic!("mtext not found")
|
||
};
|
||
assert!((t.insertion_point.x - 15.0).abs() < 1e-6);
|
||
}
|
||
|
||
// ── Outstanding methods: loft + bulge-arc profiles ───────────────────────
|
||
|
||
#[test]
|
||
fn loft_two_profiles_produces_solid() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Two circles at different Z (profiles for loft).
|
||
let c1 = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle {
|
||
centre: [0.0, 0.0, 0.0],
|
||
radius: 5.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let c2 = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle {
|
||
centre: [0.0, 0.0, 10.0],
|
||
radius: 2.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let result = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Loft {
|
||
profiles: vec![c1, c2],
|
||
}),
|
||
);
|
||
let id = new_id(&result.expect("loft must preserve the sections' elevations"));
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetVolume { id }, Query::GetBounds { id }]),
|
||
)
|
||
.unwrap();
|
||
let QueryResult::Volume(volume) = receipt.query_results[0] else {
|
||
panic!("volume")
|
||
};
|
||
let expected = std::f64::consts::PI * 10.0 * 39.0 / 3.0;
|
||
assert!(
|
||
(volume - expected).abs() < expected * 0.05,
|
||
"volume {volume}"
|
||
);
|
||
let QueryResult::Bounds(bounds) = receipt.query_results[1] else {
|
||
panic!("bounds")
|
||
};
|
||
assert!((bounds.min[2]).abs() < 1e-4 && (bounds.max[2] - 10.0).abs() < 1e-4);
|
||
}
|
||
|
||
#[test]
|
||
fn bulge_arc_polyline_profile_converts_to_arc() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// A polyline with a bulge (arc) segment: square with one curved side.
|
||
let poly = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline {
|
||
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]],
|
||
closed: false,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
// Add a bulge to the first segment by editing the vertex.
|
||
let handle = obj_to_handle(poly);
|
||
let Some(EntityType::LwPolyline(mut pl)) = host.document().get_entity(handle).cloned()
|
||
else {
|
||
panic!("polyline not found")
|
||
};
|
||
pl.vertices[0].bulge = 1.0; // 90-degree arc (tan(π/2/4) = 1)
|
||
host.scene_mut().update_entity(EntityType::LwPolyline(pl));
|
||
|
||
// profile_curves now converts the bulge to a Curve::Arc (not Unsupported).
|
||
let curves = host
|
||
.profile_curves(poly)
|
||
.expect("bulge profile must convert");
|
||
// Open polyline, 3 vertices -> 2 segments: arc (bulged first), line.
|
||
assert_eq!(curves.len(), 2, "2 segments (arc + line)");
|
||
assert!(
|
||
matches!(curves[0], cadkernel::geom2d::Curve::Arc(_)),
|
||
"first segment is an arc"
|
||
);
|
||
assert!(
|
||
matches!(curves[1], cadkernel::geom2d::Curve::Line(_)),
|
||
"second is a line"
|
||
);
|
||
// bulge=1 -> included angle θ = 4·atan(1) = π (a semicircle); radius = chord/2 = 5.
|
||
if let cadkernel::geom2d::Curve::Arc(arc) = &curves[0] {
|
||
assert!(
|
||
(arc.radius - 5.0).abs() < 0.01,
|
||
"arc radius {} (semicircle)",
|
||
arc.radius
|
||
);
|
||
}
|
||
|
||
// Negative (CW) bulge: the center must be on the CW side (mirror of CCW).
|
||
// A single 90° bulge segment start=(0,0), end=(1,0), bulge=-0.4142 (~90° CW):
|
||
// correct center is BELOW the chord; the buggy double-sign put it above.
|
||
let neg = convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], -0.4142)
|
||
.expect("negative bulge arc");
|
||
if let cadkernel::geom2d::Curve::Arc(arc) = &neg {
|
||
// 90° arc, chord 1: radius = 0.5/sin(45°) ≈ 0.7071; center y must be negative.
|
||
assert!(
|
||
(arc.radius - 0.7071).abs() < 0.01,
|
||
"cw arc radius {}",
|
||
arc.radius
|
||
);
|
||
assert!(
|
||
arc.centre[1] < 0.0,
|
||
"CW arc center y must be below the chord, got {:?}",
|
||
arc.centre
|
||
);
|
||
} else {
|
||
panic!("expected an arc for negative bulge");
|
||
}
|
||
// And the CCW mirror: center above.
|
||
let pos =
|
||
convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], 0.4142).expect("positive bulge arc");
|
||
if let cadkernel::geom2d::Curve::Arc(arc) = &pos {
|
||
assert!(
|
||
arc.centre[1] > 0.0,
|
||
"CCW arc center y must be above the chord, got {:?}",
|
||
arc.centre
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Phase 2c-ii: dimension sub-types (radius/diameter/angular) ──────────
|
||
|
||
#[test]
|
||
fn phase2cii_dimension_radius_diameter_angular() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Radius: center (0,0,0), point on circle (4,0,0) -> radius 4.
|
||
let rad = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateDimensionRadius(
|
||
ocs_doc_api::ops::DimensionRadialSpec {
|
||
center: [0.0; 3],
|
||
point: [4.0, 0.0, 0.0],
|
||
},
|
||
)),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
// Diameter: chord points (0,0,0) and (6,0,0) -> diameter 6.
|
||
let dia = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateDimensionDiameter(
|
||
ocs_doc_api::ops::DimensionRadialSpec {
|
||
center: [0.0; 3],
|
||
point: [6.0, 0.0, 0.0],
|
||
},
|
||
)),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
// Angular: vertex (0,0,0), leg points (10,0,0) and (0,10,0) -> 90 degrees.
|
||
let ang = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateDimensionAngular(
|
||
ocs_doc_api::ops::DimensionAngularSpec {
|
||
vertex: [0.0; 3],
|
||
first_point: [10.0, 0.0, 0.0],
|
||
second_point: [0.0, 10.0, 0.0],
|
||
arc_location: [5.0, 5.0, 0.0],
|
||
},
|
||
)),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetDimensionMeasurement { id: rad },
|
||
Query::GetDimensionMeasurement { id: dia },
|
||
Query::GetDimensionMeasurement { id: ang },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::DimensionMeasurement(v) => assert!((*v - 4.0).abs() < 1e-4, "radius {v}"),
|
||
other => panic!("expected measurement, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::DimensionMeasurement(v) => {
|
||
assert!((*v - 6.0).abs() < 1e-4, "diameter {v}")
|
||
}
|
||
other => panic!("expected measurement, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[2] {
|
||
QueryResult::DimensionMeasurement(v) => assert!((*v - 90.0).abs() < 0.1, "angle {v}"),
|
||
other => panic!("expected measurement, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ── Phase 3-ii: typed AttributeDefinition create ──────────────────────────
|
||
|
||
#[test]
|
||
fn phase3ii_create_attribute_definition() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let attdef = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateAttributeDefinition(
|
||
ocs_doc_api::ops::AttributeDefinitionSpec {
|
||
tag: "DOOR_NO".into(),
|
||
prompt: "Door number?".into(),
|
||
default_value: "D-1".into(),
|
||
insertion_point: [5.0, 5.0, 0.0],
|
||
height: 2.5,
|
||
rotation: 0.0,
|
||
},
|
||
)),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let handle = obj_to_handle(attdef);
|
||
let Some(EntityType::AttributeDefinition(a)) = host.document().get_entity(handle) else {
|
||
panic!("attdef not found")
|
||
};
|
||
assert_eq!(a.tag, "DOOR_NO");
|
||
assert_eq!(a.default_value, "D-1");
|
||
assert!((a.height - 2.5).abs() < 1e-9);
|
||
// GetEntity reports the AttributeDefinition kind.
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetEntity { id: attdef }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Entity(e) => assert_eq!(e.kind, "AttributeDefinition"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ── Phase 5-ii: typed Table create ───────────────────────────────────────
|
||
|
||
#[test]
|
||
fn phase5ii_create_table_from_grid() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let data = vec![
|
||
vec!["Name".to_string(), "Value".to_string()],
|
||
vec!["Length".to_string(), "100.0".to_string()],
|
||
vec!["Width".to_string(), "50.0".to_string()],
|
||
];
|
||
let table = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateTable(ocs_doc_api::ops::TableSpec {
|
||
insertion_point: [10.0, 10.0, 0.0],
|
||
data: data.clone(),
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let handle = obj_to_handle(table);
|
||
let Some(EntityType::Table(t)) = host.document().get_entity(handle) else {
|
||
panic!("table not found")
|
||
};
|
||
assert_eq!(t.rows.len(), 3);
|
||
assert_eq!(t.columns.len(), 2);
|
||
// Non-rectangular grid is a validation error.
|
||
let bad = vec![
|
||
vec!["a".to_string()],
|
||
vec!["b".to_string(), "c".to_string()],
|
||
];
|
||
assert!(dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateTable(ocs_doc_api::ops::TableSpec {
|
||
insertion_point: [0.0; 3],
|
||
data: bad
|
||
}))
|
||
)
|
||
.is_err());
|
||
// GetEntity reports kind Table.
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetEntity { id: table }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Entity(e) => assert_eq!(e.kind, "Table"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ── Outstanding method: accurate volume/centroid (fine tessellation) ─────
|
||
|
||
#[test]
|
||
fn accurate_volume_centroid_sphere_and_cube() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Sphere r=5: analytic volume = 4/3 * pi * 125 ≈ 523.599; centroid at centre.
|
||
let ball = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Sphere {
|
||
centre: [10.0, 20.0, 30.0],
|
||
radius: 5.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetVolume { id: ball },
|
||
Query::GetCentroid { id: ball },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
let analytic = 4.0 / 3.0 * std::f64::consts::PI * 125.0;
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Volume(v) => {
|
||
let err = ((*v - analytic) / analytic).abs();
|
||
assert!(
|
||
err < 0.005,
|
||
"sphere volume {v} vs analytic {analytic} (err {err:.4})"
|
||
);
|
||
}
|
||
other => panic!("expected volume, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Centroid(c) => {
|
||
assert!(
|
||
(c[0] - 10.0).abs() < 0.05
|
||
&& (c[1] - 20.0).abs() < 0.05
|
||
&& (c[2] - 30.0).abs() < 0.05,
|
||
"sphere centroid {c:?}"
|
||
);
|
||
}
|
||
other => panic!("expected centroid, got {other:?}"),
|
||
}
|
||
// Cube 10^3 is exact (planar faces: divergence is exact regardless of LOD).
|
||
let cube = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid {
|
||
origin: [0.0; 3],
|
||
size: [10.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetVolume { id: cube }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Volume(v) => assert!((*v - 1000.0).abs() < 1.0, "cube volume {v}"),
|
||
other => panic!("expected volume, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ── Phase 2c-iii: 2-line angular dimension ────────────────────────────────
|
||
|
||
#[test]
|
||
fn phase2ciii_dimension_angular2ln() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Angle between line (0,0,0)->(10,0,0) and (0,0,0)->(0,10,0) = 90 degrees.
|
||
let ang = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateDimensionAngular2Ln(
|
||
ocs_doc_api::ops::DimensionAngularSpec {
|
||
vertex: [0.0; 3],
|
||
first_point: [10.0, 0.0, 0.0],
|
||
second_point: [0.0, 10.0, 0.0],
|
||
arc_location: [5.0, 5.0, 0.0],
|
||
},
|
||
)),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetDimensionMeasurement { id: ang },
|
||
Query::GetEntity { id: ang },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::DimensionMeasurement(v) => assert!((*v - 90.0).abs() < 0.1, "angle {v}"),
|
||
other => panic!("expected measurement, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Entity(e) => assert_eq!(e.kind, "Dimension"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ── Review-fix regression tests (Loft cap, CreateMany validation, Ellipse
|
||
// rotation, raster path validation) ─────────────────────────────────────
|
||
|
||
#[test]
|
||
fn fix_loft_over_cap_is_rejected() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1;
|
||
let profiles: Vec<ObjectId> = (0..over).map(|i| ObjectId::from_u64(i as u64)).collect();
|
||
let err =
|
||
dispatch(&mut host, DocApiEnvelope::op(Operation::Loft { profiles })).unwrap_err();
|
||
assert!(matches!(err, ApiError::Validation { .. }), "{err:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn fix_create_many_invalid_curve_is_all_or_nothing() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let rev_before = host.scene().geometry_epoch;
|
||
// A valid point + an invalid ellipse (ratio > 1) in one CreateMany batch.
|
||
let specs = vec![
|
||
ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }),
|
||
ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse {
|
||
centre: [0.0; 3],
|
||
major_axis: [1.0; 3],
|
||
ratio: 2.0,
|
||
start: 0.0,
|
||
end: std::f64::consts::TAU,
|
||
}),
|
||
];
|
||
let err =
|
||
dispatch(&mut host, DocApiEnvelope::op(Operation::CreateMany(specs))).unwrap_err();
|
||
assert!(matches!(err, ApiError::Validation { .. }), "{err:?}");
|
||
// All-or-nothing: NO entity was created (the point would have been added first).
|
||
assert_eq!(
|
||
host.scene().geometry_epoch,
|
||
rev_before,
|
||
"no mutation on rejected CreateMany"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn partial_ellipse_rotation_preserves_parameters() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Rotating the major axis already rotates the ellipse; its parameters stay fixed.
|
||
let ell = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse {
|
||
centre: [0.0; 3],
|
||
major_axis: [4.0, 0.0, 0.0],
|
||
ratio: 0.5,
|
||
start: 0.0,
|
||
end: std::f64::consts::FRAC_PI_2,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let rot90 = ocs_doc_api::PlacementSpec {
|
||
x_axis: [0.0, 1.0, 0.0],
|
||
y_axis: [-1.0, 0.0, 0.0],
|
||
z_axis: [0.0, 0.0, 1.0],
|
||
origin: [0.0; 3],
|
||
};
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: ell,
|
||
placement: rot90,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else {
|
||
panic!("ellipse not found")
|
||
};
|
||
let half_pi = std::f64::consts::FRAC_PI_2;
|
||
assert!(e.start_parameter.abs() < 1e-6);
|
||
assert!((e.end_parameter - half_pi).abs() < 1e-6);
|
||
// major_axis rotated from +X to +Y.
|
||
assert!(
|
||
(e.major_axis.y - 4.0).abs() < 1e-6,
|
||
"major_axis {:?}",
|
||
e.major_axis
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fix_raster_image_path_validation() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let mk = |path: &str| {
|
||
Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec {
|
||
file_path: path.into(),
|
||
insertion_point: [0.0; 3],
|
||
u_vector: [0.5, 0.0, 0.0],
|
||
v_vector: [0.0, 0.5, 0.0],
|
||
size: [10.0, 10.0],
|
||
})
|
||
};
|
||
// Valid path succeeds.
|
||
assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/photo.png"))).is_ok());
|
||
// UNC path rejected.
|
||
assert!(dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(mk("\\\\server\\share\\x.png"))
|
||
)
|
||
.is_err());
|
||
// Parent-traversal rejected.
|
||
assert!(dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(mk("C:/img/../../etc/passwd.png"))
|
||
)
|
||
.is_err());
|
||
// Non-image extension rejected.
|
||
assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/evil.exe"))).is_err());
|
||
// Empty path rejected.
|
||
assert!(dispatch(&mut host, DocApiEnvelope::op(mk(""))).is_err());
|
||
}
|
||
|
||
// ── Read-mostly family bounds (Leader/Mesh/Face3D/MLine/Helix) ──────────
|
||
|
||
#[test]
|
||
fn read_mostly_family_bounds_leader_and_mesh() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Leader with vertices at (0,0,0),(5,5,0),(10,0,0) -> bounds x[0,10] y[0,5].
|
||
let mut leader = acadrust::entities::Leader::default();
|
||
leader.vertices = vec![
|
||
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
|
||
acadrust::types::Vector3::new(5.0, 5.0, 0.0),
|
||
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
|
||
];
|
||
let leader_h = host
|
||
.document_mut()
|
||
.add_entity(EntityType::Leader(leader))
|
||
.unwrap();
|
||
// Mesh with vertices in a unit cube at (2..3, 2..3, 0).
|
||
let mut mesh = acadrust::entities::Mesh::default();
|
||
mesh.vertices = vec![
|
||
acadrust::types::Vector3::new(2.0, 2.0, 0.0),
|
||
acadrust::types::Vector3::new(3.0, 2.0, 0.0),
|
||
acadrust::types::Vector3::new(3.0, 3.0, 0.0),
|
||
acadrust::types::Vector3::new(2.0, 3.0, 0.0),
|
||
];
|
||
let mesh_h = host
|
||
.document_mut()
|
||
.add_entity(EntityType::Mesh(mesh))
|
||
.unwrap();
|
||
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetBounds {
|
||
id: handle_to_obj(leader_h),
|
||
},
|
||
Query::GetBounds {
|
||
id: handle_to_obj(mesh_h),
|
||
},
|
||
Query::GetEntity {
|
||
id: handle_to_obj(leader_h),
|
||
},
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Bounds(b) => {
|
||
assert!(
|
||
(b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 10.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
assert!(
|
||
(b.min[1] - 0.0).abs() < 1e-6 && (b.max[1] - 5.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Bounds(b) => {
|
||
assert!(
|
||
(b.min[0] - 2.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[2] {
|
||
QueryResult::Entity(e) => assert_eq!(e.kind, "Leader"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ── Phase 5 remaining: typed raster image create ─────────────────────────
|
||
|
||
#[test]
|
||
fn phase5_create_raster_image_registers_definition() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let img = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateRasterImage(
|
||
ocs_doc_api::ops::RasterImageSpec {
|
||
file_path: "C:/img/photo.png".into(),
|
||
insertion_point: [10.0, 20.0, 0.0],
|
||
u_vector: [0.5, 0.0, 0.0],
|
||
v_vector: [0.0, 0.5, 0.0],
|
||
size: [100.0, 50.0],
|
||
},
|
||
)),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let handle = obj_to_handle(img);
|
||
let Some(EntityType::RasterImage(i)) = host.document().get_entity(handle) else {
|
||
panic!("image not found")
|
||
};
|
||
assert_eq!(i.file_path, "C:/img/photo.png");
|
||
// The host auto-registered an ImageDefinition (definition_handle set).
|
||
assert!(
|
||
i.definition_handle.is_some(),
|
||
"ImageDefinition was auto-registered"
|
||
);
|
||
// Bounds = insertion + u*100 + v*50 = (60, 45) (4-corner bracket).
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: img }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Bounds(b) => {
|
||
assert!(
|
||
(b.min[0] - 10.0).abs() < 1e-6 && (b.max[0] - 60.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
assert!(
|
||
(b.min[1] - 20.0).abs() < 1e-6 && (b.max[1] - 45.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ── Phase 4 remaining: set_view + viewport-view query ────────────────────
|
||
|
||
#[test]
|
||
fn phase4_set_view_and_view_query() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let vp = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateViewport(ocs_doc_api::ops::ViewportSpec {
|
||
center: [50.0, 50.0, 0.0],
|
||
width: 40.0,
|
||
height: 30.0,
|
||
view_target: [0.0, 0.0, 0.0],
|
||
view_height: 100.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
|
||
// set_view retargets + re-zooms in place.
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::SetViewportView {
|
||
id: vp,
|
||
view_target: [20.0, 30.0, 0.0],
|
||
view_height: 50.0,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let Some(EntityType::Viewport(v)) = host.document().get_entity(obj_to_handle(vp)) else {
|
||
panic!("viewport not found")
|
||
};
|
||
assert!((v.view_target.x - 20.0).abs() < 1e-6 && (v.view_height - 50.0).abs() < 1e-6);
|
||
|
||
// viewport_view query reads it back.
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetViewportView { id: vp }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::ViewportView { target, height } => {
|
||
assert!((target[0] - 20.0).abs() < 1e-6 && (*height - 50.0).abs() < 1e-6);
|
||
}
|
||
other => panic!("expected viewport view, got {other:?}"),
|
||
}
|
||
|
||
// set_view on a non-viewport is Unsupported.
|
||
let line = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line {
|
||
start: [0.0; 3],
|
||
end: [1.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert!(dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::SetViewportView {
|
||
id: line,
|
||
view_target: [0.0; 3],
|
||
view_height: 1.0
|
||
})
|
||
)
|
||
.is_err());
|
||
}
|
||
|
||
// ── Phase 3 remaining: attributes + nested block traversal ──────────────
|
||
|
||
#[test]
|
||
fn phase3_attributes_and_block_traversal() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// A block "Door" containing a line; insert it, then set/read attributes.
|
||
host.document_mut()
|
||
.block_records
|
||
.add(acadrust::tables::BlockRecord::new("Door"))
|
||
.unwrap();
|
||
let line = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line {
|
||
start: [0.0; 3],
|
||
end: [10.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
host.document_mut()
|
||
.block_records
|
||
.get_mut("Door")
|
||
.unwrap()
|
||
.entity_handles
|
||
.push(obj_to_handle(line));
|
||
|
||
let ins = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateInsert(ocs_doc_api::ops::InsertSpec {
|
||
block_name: "Door".into(),
|
||
insert_point: [0.0; 3],
|
||
scale: 1.0,
|
||
rotation: 0.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
|
||
// set_attribute adds a new attribute; get_attributes reads it back.
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::SetAttribute {
|
||
id: ins,
|
||
tag: "HANDLE".into(),
|
||
value: "A-1".into(),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Attributes(attrs) => {
|
||
assert_eq!(attrs.len(), 1);
|
||
assert_eq!(attrs[0], ("HANDLE".to_string(), "A-1".to_string()));
|
||
}
|
||
other => panic!("expected attributes, got {other:?}"),
|
||
}
|
||
// set_attribute on an existing tag updates it.
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::SetAttribute {
|
||
id: ins,
|
||
tag: "HANDLE".into(),
|
||
value: "A-2".into(),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Attributes(attrs) => assert_eq!(attrs[0].1, "A-2"),
|
||
other => panic!("expected attributes, got {other:?}"),
|
||
}
|
||
|
||
// Nested traversal: GetBlockEntities("Door") returns the line.
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBlockEntities {
|
||
block_name: "Door".into(),
|
||
}]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::BlockEntities(v) => {
|
||
assert_eq!(v.len(), 1);
|
||
assert_eq!(v[0].kind, "Line");
|
||
}
|
||
other => panic!("expected block entities, got {other:?}"),
|
||
}
|
||
// Unknown block -> validation error.
|
||
assert!(dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBlockEntities {
|
||
block_name: "Nope".into()
|
||
}])
|
||
)
|
||
.is_err());
|
||
}
|
||
|
||
// ── Phase 2b-c: dimension ─────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn phase2c_dimension_linear_create_measurement_bounds() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Linear dimension from (0,0,0) to (30,0,0) with the line at (0,5,0).
|
||
let dim = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateDimensionLinear(
|
||
ocs_doc_api::ops::DimensionSpec {
|
||
first_point: [0.0, 0.0, 0.0],
|
||
second_point: [30.0, 0.0, 0.0],
|
||
definition_point: [0.0, 5.0, 0.0],
|
||
},
|
||
)),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetDimensionMeasurement { id: dim },
|
||
Query::GetEntity { id: dim },
|
||
Query::GetBounds { id: dim },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::DimensionMeasurement(v) => {
|
||
assert!((*v - 30.0).abs() < 1e-6, "measurement {v}")
|
||
}
|
||
other => panic!("expected measurement, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Entity(e) => assert_eq!(e.kind, "Dimension"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[2] {
|
||
QueryResult::Bounds(b) => assert!(
|
||
(b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 30.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
),
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ── Phase 2b-b: hatch ────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn phase2b_hatch_create_boundary_bounds_delete() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Solid hatch over a unit square boundary.
|
||
let sq = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
|
||
let hatch = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateHatch(ocs_doc_api::ops::HatchSpec {
|
||
boundary: sq.clone(),
|
||
solid: true,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
|
||
// Boundary round-trips (one loop, 4 vertices).
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetHatchBoundary { id: hatch },
|
||
Query::GetBounds { id: hatch },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::HatchBoundary(loops) => {
|
||
assert_eq!(loops.len(), 1);
|
||
assert_eq!(loops[0].len(), 4);
|
||
assert!(
|
||
(loops[0][0][0] - 0.0).abs() < 1e-9 && (loops[0][2][0] - 10.0).abs() < 1e-9
|
||
);
|
||
}
|
||
other => panic!("expected boundary, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Bounds(b) => assert!(
|
||
(b.min[0] - 0.0).abs() < 1e-6 && (b.max[1] - 10.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
),
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
|
||
// Boundary with < 3 points is a validation error.
|
||
assert!(dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateHatch(ocs_doc_api::ops::HatchSpec {
|
||
boundary: vec![[0.0, 0.0], [1.0, 1.0]],
|
||
solid: true
|
||
}))
|
||
)
|
||
.is_err());
|
||
|
||
// Generic delete works.
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Delete { id: hatch }),
|
||
)
|
||
.unwrap();
|
||
assert!(host.document().get_entity(obj_to_handle(hatch)).is_none());
|
||
}
|
||
|
||
// ── Phase 5: media & misc (read-mostly) ──────────────────────────────────
|
||
|
||
#[test]
|
||
fn phase5_media_entities_read_kind_and_bounds() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Insert a RasterImage directly (read-mostly: DocApi reads kind + bounds).
|
||
let img = {
|
||
use acadrust::entities::RasterImage;
|
||
use acadrust::types::{Vector2, Vector3};
|
||
let mut img = RasterImage::default();
|
||
img.insertion_point = Vector3::new(10.0, 20.0, 0.0);
|
||
img.u_vector = Vector3::new(0.5, 0.0, 0.0); // 0.5 world-units/pixel in X
|
||
img.v_vector = Vector3::new(0.0, 0.5, 0.0);
|
||
img.size = Vector2::new(100.0, 50.0);
|
||
host.document_mut()
|
||
.add_entity(EntityType::RasterImage(img))
|
||
.unwrap()
|
||
};
|
||
let img_id = handle_to_obj(img);
|
||
// GetEntity reports kind "RasterImage"; bounds = insertion + u*100 + v*50 = (60, 45).
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetEntity { id: img_id },
|
||
Query::GetBounds { id: img_id },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Entity(e) => assert_eq!(e.kind, "RasterImage"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Bounds(b) => {
|
||
assert!(
|
||
(b.min[0] - 10.0).abs() < 1e-6 && (b.max[0] - 60.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
assert!(
|
||
(b.min[1] - 20.0).abs() < 1e-6 && (b.max[1] - 45.0).abs() < 1e-6,
|
||
"{b:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
// Generic delete works on media entities (read-mostly, but deletable).
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Delete { id: img_id }),
|
||
)
|
||
.unwrap();
|
||
assert!(host.document().get_entity(img).is_none());
|
||
}
|
||
|
||
// ── Phase 3: containers (block references) ───────────────────────────────
|
||
|
||
#[test]
|
||
fn phase3_create_insert_and_transform() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// Register a block record so the insert can reference it.
|
||
host.document_mut()
|
||
.block_records
|
||
.add(acadrust::tables::BlockRecord::new("MyBlock"))
|
||
.unwrap();
|
||
|
||
// Insert the block at (10,20,0) scale 2, rotation 0.
|
||
let ins = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateInsert(ocs_doc_api::ops::InsertSpec {
|
||
block_name: "MyBlock".into(),
|
||
insert_point: [10.0, 20.0, 0.0],
|
||
scale: 2.0,
|
||
rotation: 0.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let handle = obj_to_handle(ins);
|
||
let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else {
|
||
panic!("insert not found")
|
||
};
|
||
assert_eq!(i.block_name, "MyBlock");
|
||
assert!((i.insert_point.x - 10.0).abs() < 1e-9 && (i.x_scale() - 2.0).abs() < 1e-9);
|
||
|
||
// Transform the insert by +5 in X.
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: ins,
|
||
placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]),
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else {
|
||
panic!("insert not found")
|
||
};
|
||
assert!((i.insert_point.x - 15.0).abs() < 1e-6);
|
||
|
||
// Inserting a non-existent block is a Validation error, no entity created.
|
||
let before = host.scene().geometry_epoch;
|
||
let err = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateInsert(ocs_doc_api::ops::InsertSpec {
|
||
block_name: "NoSuchBlock".into(),
|
||
insert_point: [0.0; 3],
|
||
scale: 1.0,
|
||
rotation: 0.0,
|
||
})),
|
||
)
|
||
.unwrap_err();
|
||
assert!(matches!(err, ApiError::Validation { .. }), "{err:?}");
|
||
assert_eq!(
|
||
host.scene().geometry_epoch,
|
||
before,
|
||
"no entity on unknown block"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn doc_api_tab_mismatch_is_rejected() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
// A request naming a DIFFERENT tab than the bound one is rejected.
|
||
let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point {
|
||
position: [0.0; 3],
|
||
}));
|
||
let bytes = bincode::serialize(&env).unwrap();
|
||
let wrong_tab = host.tab_id().wrapping_add(999);
|
||
let err = execute_doc_api(&mut host, wrong_tab, &bytes).unwrap_err();
|
||
assert!(err.contains("tab mismatch"), "{err}");
|
||
}
|
||
|
||
#[test]
|
||
fn doc_api_unknown_id_surfaces_structured_error() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let ghost = ObjectId::from_u64(0xDEAD);
|
||
let err = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Delete { id: ghost }),
|
||
)
|
||
.unwrap_err();
|
||
assert!(
|
||
matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)),
|
||
"{err:?}"
|
||
);
|
||
}
|
||
|
||
// ── Roundtrip tests: create -> read back via queries -> assert geometry ──
|
||
|
||
use ocs_doc_api::ops::{Curve2Spec, PlacementSpec};
|
||
|
||
/// Create an entity via DocApi, then read it back via GetEntity/GetBounds and
|
||
/// assert the geometry round-trips with the expected kind + bounds.
|
||
#[test]
|
||
fn roundtrip_2d_entities_line_circle_point_polyline() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
|
||
// Line [0,0,0]-[10,0,0]: kind + bounds round-trip.
|
||
let line = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line {
|
||
start: [0.0, 0.0, 0.0],
|
||
end: [10.0, 0.0, 0.0],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![
|
||
Query::GetEntity { id: line },
|
||
Query::GetBounds { id: line },
|
||
]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Entity(v) => assert_eq!(v.kind, "Line"),
|
||
other => panic!("expected entity, got {other:?}"),
|
||
}
|
||
match &receipt.query_results[1] {
|
||
QueryResult::Bounds(b) => {
|
||
assert!(
|
||
(b.min[0] - 0.0).abs() < 1e-9 && (b.max[0] - 10.0).abs() < 1e-9,
|
||
"{b:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
|
||
// Circle centre (5,5,0) r=3: bounds = centre ± radius in XY.
|
||
let circle = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle {
|
||
centre: [5.0, 5.0, 0.0],
|
||
radius: 3.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: circle }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Bounds(b) => {
|
||
assert!(
|
||
(b.min[0] - 2.0).abs() < 1e-9 && (b.max[0] - 8.0).abs() < 1e-9,
|
||
"{b:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
|
||
// Point at (1,2,3): degenerate bounds.
|
||
let point = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point {
|
||
position: [1.0, 2.0, 3.0],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: point }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Bounds(b) => assert_eq!(b.min, [1.0, 2.0, 3.0]),
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
|
||
// Closed polyline (unit square in XY): bounds = [0,1]^2 at z=0.
|
||
let poly = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline {
|
||
points: vec![
|
||
[0.0, 0.0, 0.0],
|
||
[1.0, 0.0, 0.0],
|
||
[1.0, 1.0, 0.0],
|
||
[0.0, 1.0, 0.0],
|
||
],
|
||
closed: true,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: poly }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Bounds(b) => {
|
||
assert!(
|
||
(b.min[0] - 0.0).abs() < 1e-9 && (b.max[1] - 1.0).abs() < 1e-9,
|
||
"{b:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Geometric methods round-trip: transform moves a solid (bounds shift);
|
||
/// union/subtract produce the expected mass/volume relationships.
|
||
#[test]
|
||
fn roundtrip_geometric_methods_transform_and_booleans() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
|
||
// Two overlapping boxes [0,10]^3 and [5,15]^3.
|
||
let a = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid {
|
||
origin: [0.0; 3],
|
||
size: [10.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let b = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid {
|
||
origin: [5.0; 3],
|
||
size: [10.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
|
||
// Intersection FIRST: [0,10]^3 ∩ [5,15]^3 = [5,10]^3 = 125.
|
||
let intersect_op = Operation::SolidBoolean {
|
||
op: BoolOp::Intersection,
|
||
a,
|
||
b,
|
||
erase_sources: true,
|
||
};
|
||
let inter = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap());
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetVolume { id: inter }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Volume(v) => assert!((*v - 125.0).abs() < 1.0, "intersection vol {v}"),
|
||
other => panic!("expected volume, got {other:?}"),
|
||
}
|
||
|
||
// THEN transform the result by +100 in X (a real, non-identity move): its
|
||
// bounds must shift by exactly +100 in X while Y/Z stay at [5,10].
|
||
let move_op = Operation::Transform {
|
||
id: inter,
|
||
placement: PlacementSpec::at([100.0, 0.0, 0.0]),
|
||
};
|
||
dispatch(&mut host, DocApiEnvelope::op(move_op)).unwrap();
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::queries(vec![Query::GetBounds { id: inter }]),
|
||
)
|
||
.unwrap();
|
||
match &receipt.query_results[0] {
|
||
QueryResult::Bounds(bb) => {
|
||
assert!(
|
||
(bb.min[0] - 105.0).abs() < 1e-4 && (bb.max[0] - 110.0).abs() < 1e-4,
|
||
"{bb:?}"
|
||
);
|
||
assert!(
|
||
(bb.min[1] - 5.0).abs() < 1e-4 && (bb.max[1] - 10.0).abs() < 1e-4,
|
||
"{bb:?}"
|
||
);
|
||
}
|
||
other => panic!("expected bounds, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// DWG roundtrip: create two intersected solids, write the document to a DWG
|
||
/// file, read it back, and assert the intersected solid survives with valid
|
||
/// ACIS data (the exact path `restore_solid_models` re-lifts on load).
|
||
#[test]
|
||
fn roundtrip_intersected_solids_through_dwg_file() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
|
||
// Two overlapping boxes -> intersect -> the result solid (125 vol).
|
||
let a = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid {
|
||
origin: [0.0; 3],
|
||
size: [10.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let b = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid {
|
||
origin: [5.0; 3],
|
||
size: [10.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let intersect_op = Operation::SolidBoolean {
|
||
op: BoolOp::Intersection,
|
||
a,
|
||
b,
|
||
erase_sources: true,
|
||
};
|
||
let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap());
|
||
|
||
// Write the live document to a STABLE, inspectable DWG in the workspace
|
||
// target dir so it can be opened in a CAD viewer after the test. The file
|
||
// is kept (not deleted). If the stable path is momentarily locked (e.g. a
|
||
// concurrent test run or a viewer holding it), fall back to a process-
|
||
// unique path for the roundtrip so the test never flakes.
|
||
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target");
|
||
std::fs::create_dir_all(&dir).expect("create target dir");
|
||
let doc = host.document().clone();
|
||
let stable = dir.join("doc_api_roundtrip_intersected.dwg");
|
||
// Release any stale lock/handle on the stable file before rewriting.
|
||
let _ = std::fs::remove_file(&stable);
|
||
let path = match acadrust::io::dwg::DwgWriter::write_to_file(&stable, &doc) {
|
||
Ok(()) => stable,
|
||
Err(_) => {
|
||
// Locked by another live process (e.g. a viewer) — write a unique
|
||
// file for the roundtrip instead of failing.
|
||
let alt = dir.join(format!(
|
||
"doc_api_roundtrip_intersected_{}.dwg",
|
||
std::process::id()
|
||
));
|
||
acadrust::io::dwg::DwgWriter::write_to_file(&alt, &doc).expect("write DWG failed");
|
||
alt
|
||
}
|
||
};
|
||
assert!(path.exists(), "DWG was written");
|
||
|
||
// Read it back.
|
||
let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).expect("open DWG");
|
||
let reloaded = reader.read().expect("read DWG failed");
|
||
|
||
// The intersected solid must be present as a Solid3D with valid ACIS data,
|
||
// and re-lifting it must reproduce a body with the expected volume.
|
||
let handle = obj_to_handle(lens);
|
||
let Some(EntityType::Solid3D(solid)) = reloaded.get_entity(handle) else {
|
||
panic!("intersected solid {handle:?} not found as Solid3D in reloaded DWG");
|
||
};
|
||
assert!(solid.has_acis_data(), "reloaded solid carries ACIS data");
|
||
|
||
// Re-lift through the same SAT->kernel path used on load and check volume.
|
||
let body = crate::scene::convert::solid3d_tess::kernel_body(solid)
|
||
.expect("re-lift reloaded solid failed");
|
||
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
|
||
let vol = mesh_volume_centroid(&mesh).0;
|
||
assert!(
|
||
(vol - 125.0).abs() < 1.0,
|
||
"reloaded intersection volume {vol}"
|
||
);
|
||
|
||
// The DWG is intentionally kept for inspection (see `path` above).
|
||
}
|
||
#[test]
|
||
fn doc_api_failed_inputs_leave_document_and_history_unchanged() {
|
||
use ocs_doc_api::EntitySpec;
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let count = app.tabs[0].scene.document.entity_count();
|
||
let dirty = app.tabs[0].dirty;
|
||
let undo = app.tabs[0].history.undo_stack.len();
|
||
let epoch = app.tabs[0].scene.geometry_epoch;
|
||
{
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let invalid_ops = vec![
|
||
Operation::CreateMany(vec![
|
||
EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }),
|
||
EntitySpec::Solid(SolidPrimitive::Sphere {
|
||
centre: [0.0; 3],
|
||
radius: f64::NAN,
|
||
}),
|
||
]),
|
||
Operation::CreateCurve(Curve2Spec::Circle {
|
||
centre: [0.0; 3],
|
||
radius: -1.0,
|
||
}),
|
||
Operation::CreateCurve(Curve2Spec::Polyline {
|
||
points: vec![[0.0; 3], [1.0; 3]],
|
||
closed: false,
|
||
}),
|
||
Operation::CreateText(ocs_doc_api::ops::TextSpec {
|
||
value: "test".into(),
|
||
insertion_point: [0.0; 3],
|
||
height: f64::INFINITY,
|
||
rotation: 0.0,
|
||
}),
|
||
];
|
||
for op in invalid_ops {
|
||
assert!(dispatch(&mut host, DocApiEnvelope::op(op)).is_err());
|
||
assert!(!host.scene().is_recording_undo());
|
||
}
|
||
}
|
||
assert_eq!(app.tabs[0].scene.document.entity_count(), count);
|
||
assert_eq!(app.tabs[0].scene.geometry_epoch, epoch);
|
||
assert_eq!(app.tabs[0].history.undo_stack.len(), undo);
|
||
assert_eq!(app.tabs[0].dirty, dirty);
|
||
}
|
||
|
||
#[test]
|
||
fn doc_api_bulk_creation_is_dirty_and_undoes_in_one_step() {
|
||
use ocs_doc_api::EntitySpec;
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let undo = app.tabs[0].history.undo_stack.len();
|
||
let count = app.tabs[0].scene.document.entity_count();
|
||
let ids = {
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let receipt = dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateMany(vec![
|
||
EntitySpec::Curve(Curve2Spec::Point { position: [1.0; 3] }),
|
||
EntitySpec::Solid(SolidPrimitive::Cuboid {
|
||
origin: [0.0; 3],
|
||
size: [2.0; 3],
|
||
}),
|
||
])),
|
||
)
|
||
.unwrap();
|
||
receipt.outcome.unwrap().new_ids().to_vec()
|
||
};
|
||
assert!(app.tabs[0].dirty);
|
||
assert_eq!(app.tabs[0].history.undo_stack.len(), undo + 1);
|
||
assert_eq!(app.tabs[0].scene.document.entity_count(), count + 2);
|
||
app.undo_active_tab();
|
||
assert_eq!(app.tabs[0].scene.document.entity_count(), count);
|
||
for id in &ids {
|
||
assert!(!app.tabs[0]
|
||
.scene
|
||
.solid_models
|
||
.contains_key(&obj_to_handle(*id)));
|
||
}
|
||
app.redo_active_tab();
|
||
for id in ids {
|
||
assert!(app.tabs[0]
|
||
.scene
|
||
.document
|
||
.get_entity(obj_to_handle(id))
|
||
.is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn doc_api_text_scale_and_elevated_extrusion() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let text = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateText(ocs_doc_api::ops::TextSpec {
|
||
value: "label".into(),
|
||
insertion_point: [1.0, 2.0, 0.0],
|
||
height: 2.0,
|
||
rotation: 0.0,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let placement = PlacementSpec {
|
||
origin: [0.0, 0.0, 7.0],
|
||
x_axis: [2.0, 0.0, 0.0],
|
||
y_axis: [0.0, 2.0, 0.0],
|
||
z_axis: [0.0, 0.0, 2.0],
|
||
};
|
||
dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Transform {
|
||
id: text,
|
||
placement,
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let EntityType::Text(text) = host.document().get_entity(obj_to_handle(text)).unwrap()
|
||
else {
|
||
panic!("text")
|
||
};
|
||
assert!((text.height - 4.0).abs() < 1e-9);
|
||
assert!((text.insertion_point.z - 7.0).abs() < 1e-9);
|
||
let profile = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline {
|
||
points: vec![
|
||
[0.0, 0.0, 7.0],
|
||
[2.0, 0.0, 7.0],
|
||
[2.0, 3.0, 7.0],
|
||
[0.0, 3.0, 7.0],
|
||
],
|
||
closed: true,
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let solid = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::Extrude {
|
||
profile,
|
||
direction: [0.0, 0.0, 5.0],
|
||
}),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let bounds = host.bounds(solid).unwrap();
|
||
assert!(
|
||
(bounds.min[2] - 7.0).abs() < 1e-6 && (bounds.max[2] - 12.0).abs() < 1e-6,
|
||
"{bounds:?}"
|
||
);
|
||
assert!((host.volume(solid).unwrap() - 30.0).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn doc_api_envelope_guards_and_query_budget_reset() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let id = new_id(
|
||
&dispatch(
|
||
&mut host,
|
||
DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid {
|
||
origin: [0.0; 3],
|
||
size: [2.0; 3],
|
||
})),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let mut env = DocApiEnvelope::queries(vec![Query::GetVolume { id }]);
|
||
env.version += 1;
|
||
assert!(matches!(
|
||
dispatch(&mut host, env),
|
||
Err(ApiError::Unsupported(_))
|
||
));
|
||
let valid = DocApiEnvelope::queries(vec![Query::GetVolume { id }]);
|
||
let mut bytes = bincode::serialize(&valid).unwrap();
|
||
bytes.push(0);
|
||
let tab = host.tab_id();
|
||
assert!(execute_doc_api(&mut host, tab, &bytes).is_err());
|
||
let bytes = bincode::serialize(&valid).unwrap();
|
||
assert!(execute_doc_api(&mut host, tab + 1, &bytes).is_err());
|
||
host.scene_mut().doc_api_cold_tess_used = 32;
|
||
let receipt = dispatch(&mut host, valid).unwrap();
|
||
assert!(
|
||
matches!(receipt.query_results[0], QueryResult::Volume(v) if (v - 8.0).abs() < 1e-9)
|
||
);
|
||
assert_eq!(host.scene().doc_api_cold_tess_used, 1);
|
||
}
|
||
#[test]
|
||
fn doc_api_plugin_wire_dispatch_and_image_undo() {
|
||
use ocs_plugin_api::ipc::{
|
||
protocol::{PluginRequest, PluginResponse},
|
||
server::handle_plugin_request,
|
||
};
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let objects = app.tabs[0].scene.document.objects.len();
|
||
let id = {
|
||
let mut host = HostSession::new(&mut app, 0);
|
||
let env = DocApiEnvelope::op(Operation::CreateRasterImage(
|
||
ocs_doc_api::ops::RasterImageSpec {
|
||
file_path: "image.png".into(),
|
||
insertion_point: [0.0; 3],
|
||
u_vector: [1.0, 0.0, 0.0],
|
||
v_vector: [0.0, 1.0, 0.0],
|
||
size: [2.0; 2],
|
||
},
|
||
));
|
||
let request = PluginRequest::DocApiRequest {
|
||
tab_id: host.tab_id(),
|
||
bytes: bincode::serialize(&env).unwrap(),
|
||
};
|
||
let request = bincode::deserialize(&bincode::serialize(&request).unwrap()).unwrap();
|
||
let response = handle_plugin_request(&mut host, request, &mut |_| {});
|
||
let response: PluginResponse =
|
||
bincode::deserialize(&bincode::serialize(&response).unwrap()).unwrap();
|
||
let PluginResponse::DocApiResponse { bytes } = response else {
|
||
panic!("unexpected response")
|
||
};
|
||
let result: ApiResult<Receipt> = bincode::deserialize(&bytes).unwrap();
|
||
new_id(&result.unwrap())
|
||
};
|
||
assert!(app.tabs[0].scene.document.objects.len() > objects);
|
||
app.undo_active_tab();
|
||
assert!(app.tabs[0]
|
||
.scene
|
||
.document
|
||
.get_entity(obj_to_handle(id))
|
||
.is_none());
|
||
assert_eq!(app.tabs[0].scene.document.objects.len(), objects);
|
||
app.redo_active_tab();
|
||
assert!(app.tabs[0]
|
||
.scene
|
||
.document
|
||
.get_entity(obj_to_handle(id))
|
||
.is_some());
|
||
assert!(app.tabs[0].scene.document.objects.len() > objects);
|
||
}
|
||
}
|