// 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, String> { use ocs_doc_api::{DocApiEnvelope, EnvelopeBody}; let envelope: DocApiEnvelope = bincode::deserialize(bytes) .map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?; let result: ApiResult = 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 { 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 { // 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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>> { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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> { 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> { 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 { 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]) -> ApiResult { // brep::loft takes (Plane, Vec) 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)> = 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 { 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 { 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> { 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 { 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 { 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 { 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 = (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). } }