Files
OneCAD-Draft/src/app/doc_api.rs
T
Sebastian 6997dfe618 ocs_doc_api: accurate GetVolume/GetCentroid via fine tessellation
- mass_properties: query path now uses fine mesh_body(0.1, 1e-4) for near-analytic
  volume/centroid; the render-mesh metrics cache (coarse display LOD) no longer
  shadows query accuracy. Memoized per (handle, epoch); cold-tess budget kept.
- IMPLEMENTATION.md: accuracy item done.
- Test accurate_volume_centroid_sphere_and_cube (sphere within 0.5% of 4/3*pi*r^3,
  centroid at centre, cube exact) -> 617 host lib green.
2026-09-06 13:40:07 +02:00

1718 lines
90 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DocApi v2 backend: implement `ocs_doc_api::backend::DocApiBackend` for
// `HostSession` (plan §6/§8, decision #11 — thin host hook, executor logic in
// the versioned crate). Maps each backend method to already-present scene/app
// primitives; no new geometry code.
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 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 ocs_doc_api::{DocApiEnvelope, EnvelopeBody};
let envelope: DocApiEnvelope = bincode::deserialize(bytes)
.map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?;
let result: ApiResult<ocs_doc_api::Receipt> = 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}"))
}
fn obj_to_handle(id: ObjectId) -> Handle {
Handle::new(id.as_u64())
}
fn handle_to_obj(h: Handle) -> ObjectId {
ObjectId::from_u64(h.value())
}
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 store_solid(&mut self, body: &KernelBody) -> ApiResult<ObjectId> {
// Pre-flight the display prep BEFORE committing the entity: if the mesh
// cannot be produced, fail now — no entity, no rollback, no revision bump
// (plan review: store_solid rollback moved the epoch on failure).
let scene = self.scene_mut();
let Some(display) = scene.prepare_solid_model_display(Handle::NULL, body) else {
return Err(ApiError::geometry(GeometryErrorKind::Other, "prepare_solid_model_display failed"));
};
// Mirror `add_solid_model` (history-free v1): edge wires + SAT + entity.
let mut inner = Solid3D::new();
inner.common.plotstyle_flags = 2;
inner.wires = solid_model::edge_wires(body);
let document = acis_export::solid_to_sat(body)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Acis, "solid_to_sat failed"))?;
inner.set_sat_document(&document);
let handle = self
.commit_entity_handle(EntityType::Solid3D(inner))
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Other, "commit_entity_handle failed"))?;
// register_prepared_solid_model cannot fail once the display prep succeeded,
// so there is no post-commit rollback path. Register the live B-rep so
// booleans/transforms see it (decision #7).
self.scene_mut().register_prepared_solid_model(handle, body.clone(), display);
Ok(handle_to_obj(handle))
}
fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> {
let handle = obj_to_handle(id);
// Rebuild the Solid3D entity with fresh SAT + wires, preserving the handle.
let Some(existing) = self.document().get_entity(handle).cloned() else {
return Err(ApiError::UnknownId(id));
};
let EntityType::Solid3D(mut inner) = existing else {
return Err(ApiError::Unsupported("update_solid on non-solid entity".into()));
};
inner.wires = solid_model::edge_wires(body);
let document = acis_export::solid_to_sat(body)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Acis, "solid_to_sat failed"))?;
inner.set_sat_document(&document);
// Pre-flight the display prep before mutating (fail without touching state).
let Some(display) = self.scene_mut().prepare_solid_model_display(handle, body) else {
return Err(ApiError::geometry(GeometryErrorKind::Other, "prepare_solid_model_display failed"));
};
if !self.scene_mut().update_entity(EntityType::Solid3D(inner)) {
return Err(ApiError::UnknownId(id));
}
// Refresh the live cache (cannot fail after a successful pre-flight).
self.scene_mut().register_prepared_solid_model(handle, body.clone(), display);
Ok(())
}
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
let entity = crate::app::doc_api_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) => (crate::app::doc_api_convert::entity_kind_name(e).to_string(),
crate::app::doc_api_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};
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: &[Vec<cadkernel::geom2d::Curve>]) -> ApiResult<ObjectId> {
// brep::loft takes (Plane, Vec<Curve>) per section; all profiles are on XY.
let plane = cadkernel::space::Plane::orthonormal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0])
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Other, "failed to construct XY plane"))?;
let loft_sections: Vec<(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)> =
sections.iter().map(|s| (plane, s.clone())).collect();
let body = cadkernel::brep::loft(&loft_sections)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "brep::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 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 (plan review: erase path).
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: crate::app::doc_api_convert::entity_kind_name(entity).to_string(),
bounds: self.bounds(id).ok(),
})
}
fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> {
let handle = obj_to_handle(id);
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
// Solids go through the kernel transform (re-embed SAT). 2D entities are
// transformed via acadrust geometry and updated in place.
if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) {
let body = self.resolve_body(id)?;
let place = kernel_placement(placement);
let moved = cadkernel::brep::transform(&body, &place)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed"))?;
self.update_solid(id, &moved)
} else {
let entity = self
.document()
.get_entity(handle)
.cloned()
.ok_or(ApiError::UnknownId(id))?;
let moved = crate::app::doc_api_convert::transform_entity_geometry(&entity, placement)?;
if !self.scene_mut().update_entity(moved) {
return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer")));
}
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::app::doc_api_convert::entity_to_profile_curves(entity)
}
fn bounds(&mut self, id: ObjectId) -> ApiResult<Aabb> {
let handle = obj_to_handle(id);
// Lift-on-miss for solids (consistent with volume/centroid): resolve_body
// repopulates a cold solid_models cache from SAT. A solid whose cache entry
// was dropped (e.g. by a host-side update_entity) no longer misreports
// `Unsupported` (plan review).
if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) {
let body = self.resolve_body(id)?;
let bb = cadkernel::brep::body_bounds(&body)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?;
return Ok(Aabb { min: bb.min, max: bb.max });
}
crate::app::doc_api_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 finalize_op(&mut self) {
// Close the delta entry + bump geometry + republish the document view.
self.commit_doc_api_undo();
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
impl HostSession<'_> {
/// Volume + centroid for a solid, memoized per (handle, geometry_epoch) so
/// repeated queries on the SAME solid are O(1) (the common query-batch DoS).
/// Served from the render-mesh metrics cache when present; otherwise tessellates
/// the body ONCE per epoch and caches it. A per-session cold-tessellation
/// budget bounds pathological batches of many distinct cold solids.
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.mass_cache.get(&handle) {
if cached_epoch == epoch {
return Ok((v, c));
}
}
// Accuracy path: fine tessellation so the divergence volume/centroid
// converge to near-analytic (the render-mesh metrics cache is the coarse
// LOD for display, NOT for query accuracy). Bounded by the cold-tess budget;
// memoized per (handle, epoch) so repeated queries on the same solid are O(1).
const COLD_TESS_BUDGET: usize = 256;
if self.cold_tess_used >= COLD_TESS_BUDGET {
return Err(ApiError::Unsupported(
"volume/centroid cold-tessellation budget exceeded for this session; query fewer distinct solids".into(),
));
}
self.cold_tess_used += 1;
let body = self.resolve_body(id)?;
let mesh = cadkernel::brep::mesh_body(&body, 0.1, 1e-4);
let (v, c) = mesh_volume_centroid(&mesh);
self.mass_cache.insert(handle, (epoch, v, c));
Ok((v, c))
}
}
fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement {
cadkernel::brep::Placement {
x_axis: p.x_axis,
y_axis: p.y_axis,
z_axis: p.z_axis,
origin: p.origin,
}
}
/// Signed volume and centroid of a closed triangle mesh via the divergence
/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source
/// of truth for both (used by the cold-cache `volume`/`centroid` fallback).
fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
// Delegate to the crate's single source of truth (divergence theorem).
ocs_doc_api::geom::mesh_volume_centroid(mesh)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::plugin_host::HostSession;
use crate::app::OpenCADStudio;
use ocs_doc_api::ops::{BoolOp, SolidPrimitive};
use ocs_doc_api::{
DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt,
};
fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult<Receipt> {
let bytes = bincode::serialize(&env).unwrap();
let out = execute_doc_api(host, 0, &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] }));
// Loft may be geometry-sensitive; assert either a positive-volume solid or
// 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, "loft volume {vol}"),
other => panic!("expected volume, got {other:?}"),
}
}
Err(ApiError::Geometry { .. } | ApiError::Validation { .. } | ApiError::Unsupported(_)) => {}
Err(e) => panic!("unexpected loft error: {e}"),
}
}
#[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);
}
}
// ── 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:?}"),
}
}
// ── 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_unknown_id_surfaces_structured_error() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
let ghost = ObjectId::from_u64(0xDEAD);
let err = dispatch(&mut host, DocApiEnvelope::op(Operation::Delete { id: ghost })).unwrap_err();
assert!(matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), "{err:?}");
}
// ── Roundtrip tests: create -> read back via queries -> assert geometry ──
use ocs_doc_api::ops::{Curve2Spec, PlacementSpec};
/// Create an entity via DocApi, then read it back via GetEntity/GetBounds and
/// assert the geometry round-trips with the expected kind + bounds.
#[test]
fn roundtrip_2d_entities_line_circle_point_polyline() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
// Line [0,0,0]-[10,0,0]: kind + bounds round-trip.
let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
Curve2Spec::Line { start: [0.0, 0.0, 0.0], end: [10.0, 0.0, 0.0] }))).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![
Query::GetEntity { id: line }, Query::GetBounds { id: line }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Entity(v) => assert_eq!(v.kind, "Line"),
other => panic!("expected entity, got {other:?}"),
}
match &receipt.query_results[1] {
QueryResult::Bounds(b) => {
assert!((b.min[0] - 0.0).abs() < 1e-9 && (b.max[0] - 10.0).abs() < 1e-9, "{b:?}");
}
other => panic!("expected bounds, got {other:?}"),
}
// Circle centre (5,5,0) r=3: bounds = centre ± radius in XY.
let circle = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
Curve2Spec::Circle { centre: [5.0, 5.0, 0.0], radius: 3.0 }))).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: circle }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Bounds(b) => {
assert!((b.min[0] - 2.0).abs() < 1e-9 && (b.max[0] - 8.0).abs() < 1e-9, "{b:?}");
}
other => panic!("expected bounds, got {other:?}"),
}
// Point at (1,2,3): degenerate bounds.
let point = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
Curve2Spec::Point { position: [1.0, 2.0, 3.0] }))).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: point }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Bounds(b) => assert_eq!(b.min, [1.0, 2.0, 3.0]),
other => panic!("expected bounds, got {other:?}"),
}
// Closed polyline (unit square in XY): bounds = [0,1]^2 at z=0.
let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
Curve2Spec::Polyline {
points: vec![[0.0,0.0,0.0],[1.0,0.0,0.0],[1.0,1.0,0.0],[0.0,1.0,0.0]],
closed: true,
}))).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: poly }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Bounds(b) => {
assert!((b.min[0] - 0.0).abs() < 1e-9 && (b.max[1] - 1.0).abs() < 1e-9, "{b:?}");
}
other => panic!("expected bounds, got {other:?}"),
}
}
/// Geometric methods round-trip: transform moves a solid (bounds shift);
/// union/subtract produce the expected mass/volume relationships.
#[test]
fn roundtrip_geometric_methods_transform_and_booleans() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
// Two overlapping boxes [0,10]^3 and [5,15]^3.
let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }))).unwrap());
let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] }))).unwrap());
// Intersection FIRST: [0,10]^3 ∩ [5,15]^3 = [5,10]^3 = 125.
let intersect_op = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true };
let inter = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id: inter }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Volume(v) => assert!((*v - 125.0).abs() < 1.0, "intersection vol {v}"),
other => panic!("expected volume, got {other:?}"),
}
// THEN transform the result by +100 in X (a real, non-identity move): its
// bounds must shift by exactly +100 in X while Y/Z stay at [5,10].
let move_op = Operation::Transform { id: inter, placement: PlacementSpec::at([100.0, 0.0, 0.0]) };
dispatch(&mut host, DocApiEnvelope::op(move_op)).unwrap();
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: inter }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Bounds(bb) => {
assert!((bb.min[0] - 105.0).abs() < 1e-4 && (bb.max[0] - 110.0).abs() < 1e-4, "{bb:?}");
assert!((bb.min[1] - 5.0).abs() < 1e-4 && (bb.max[1] - 10.0).abs() < 1e-4, "{bb:?}");
}
other => panic!("expected bounds, got {other:?}"),
}
}
/// DWG roundtrip: create two intersected solids, write the document to a DWG
/// file, read it back, and assert the intersected solid survives with valid
/// ACIS data (the exact path `restore_solid_models` re-lifts on load).
#[test]
fn roundtrip_intersected_solids_through_dwg_file() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
// Two overlapping boxes -> intersect -> the result solid (125 vol).
let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }))).unwrap());
let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] }))).unwrap());
let intersect_op = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true };
let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap());
// Write the live document to a STABLE, inspectable DWG in the workspace
// target dir so it can be opened in a CAD viewer after the test. The file
// is kept (not deleted). If the stable path is momentarily locked (e.g. a
// concurrent test run or a viewer holding it), fall back to a process-
// unique path for the roundtrip so the test never flakes.
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target");
std::fs::create_dir_all(&dir).expect("create target dir");
let doc = host.document().clone();
let stable = dir.join("doc_api_roundtrip_intersected.dwg");
// Release any stale lock/handle on the stable file before rewriting.
let _ = std::fs::remove_file(&stable);
let path = match acadrust::io::dwg::DwgWriter::write_to_file(&stable, &doc) {
Ok(()) => stable,
Err(_) => {
// Locked by another live process (e.g. a viewer) — write a unique
// file for the roundtrip instead of failing.
let alt = dir.join(format!("doc_api_roundtrip_intersected_{}.dwg", std::process::id()));
acadrust::io::dwg::DwgWriter::write_to_file(&alt, &doc).expect("write DWG failed");
alt
}
};
assert!(path.exists(), "DWG was written");
// Read it back.
let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).expect("open DWG");
let reloaded = reader.read().expect("read DWG failed");
// The intersected solid must be present as a Solid3D with valid ACIS data,
// and re-lifting it must reproduce a body with the expected volume.
let handle = obj_to_handle(lens);
let Some(EntityType::Solid3D(solid)) = reloaded.get_entity(handle) else {
panic!("intersected solid {handle:?} not found as Solid3D in reloaded DWG");
};
assert!(solid.has_acis_data(), "reloaded solid carries ACIS data");
// Re-lift through the same SAT->kernel path used on load and check volume.
let body = crate::scene::convert::solid3d_tess::kernel_body(solid)
.expect("re-lift reloaded solid failed");
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
let vol = mesh_volume_centroid(&mesh).0;
assert!((vol - 125.0).abs() < 1.0, "reloaded intersection volume {vol}");
// The DWG is intentionally kept for inspection (see `path` above).
}
}