Files
OneCAD-Draft/src/app/doc_api.rs
T
Karim Jerbi 0425f979f9 feat(render): GPU analytical rendering for circles, arcs, and ellipses
- Render planar circles, circular arcs, ellipses, and elliptical arcs analytically via GPU-instanced screen-space quads and WGSL shaders.
- Implement exact arc sweep clamping with round end caps matching the CAD wire shader.
- Implement robust Newton-Raphson closest-point solver for ellipses with isotropic screen-space derivative anti-aliasing.
- Integrate into wire pipeline and PersistentWireArena upload paths.
- Remove unused mesh_volume_centroid function in doc_api.
2026-09-07 13:50:12 +01:00

3477 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,
}
}
#[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 = ocs_doc_api::geom::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);
}
}