From ecb8ca93420ffbdfb6af5bffbd8b66b29c078ae5 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:11:12 +0200 Subject: [PATCH] Swap doc_api.rs for commit reversability --- src/app/doc_api.rs | 2649 +++++++++----------------------------------- 1 file changed, 553 insertions(+), 2096 deletions(-) diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index d749e4a6c..38154767c 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -1,11 +1,14 @@ -//! Native document API adapter over the existing scene, history and kernel paths. +// 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, + Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, ObjectId, + PlacementSpec, }; use crate::scene::convert::acis_export; @@ -17,12 +20,7 @@ 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 bincode::Options; +pub fn execute_doc_api(host: &mut HostSession<'_>, tab_id: u64, bytes: &[u8]) -> Result, String> { use ocs_doc_api::{DocApiEnvelope, EnvelopeBody}; // Authorization: the request must name the SAME tab the HostSession is bound to. // The HostSession is built per-tab by the dispatch pump; a plugin naming a @@ -33,24 +31,12 @@ pub fn execute_doc_api( host.tab_id() )); } - host.scene_mut().doc_api_cold_tess_used = 0; - const FRAME_LIMIT: u64 = 64 * 1024 * 1024; - if bytes.len() as u64 > FRAME_LIMIT { - return Err("DocApi frame exceeds 64 MiB".into()); - } - let envelope: DocApiEnvelope = bincode::DefaultOptions::new() - .with_fixint_encoding() - .with_limit(FRAME_LIMIT) - .reject_trailing_bytes() - .deserialize(bytes) + let envelope: DocApiEnvelope = bincode::deserialize(bytes) .map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?; - let result: ApiResult = - envelope - .validate_version() - .and_then(|_| match envelope.body { - EnvelopeBody::Op(op) => ocs_doc_api::executor::apply_op(host, op), - EnvelopeBody::Queries(qs) => ocs_doc_api::executor::apply_queries(host, qs), - }); + 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}")) } @@ -83,58 +69,57 @@ impl DocApiBackend for HostSession<'_> { let handle = obj_to_handle(id); // Lift-on-miss, then borrow the cache entry in place — O(1), no deep clone. self.scene_mut().restore_solid_models(&[handle]); - let body = self - .scene() - .solid_models - .get(&handle) - .ok_or(ApiError::UnknownId(id))?; + let body = self.scene().solid_models.get(&handle).ok_or(ApiError::UnknownId(id))?; f(body) } fn store_solid(&mut self, body: &KernelBody) -> ApiResult { - let prepared = self.prepare_doc_solid(body.clone(), None)?; - Ok(self.apply_doc_entity(prepared, None)) + // 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<()> { - self.can_modify(id)?; - let prepared = self.prepare_doc_solid(body.clone(), Some(id))?; - self.apply_doc_entity(prepared, Some(id)); - Ok(()) - } - - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - let mut prepared = Vec::with_capacity(specs.len()); - for spec in specs { - prepared.push(match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - self.prepare_doc_solid(ocs_doc_api::geom::make_solid(spec)?, None)? - } - ocs_doc_api::EntitySpec::Curve(spec) => PreparedDocEntity { - entity: convert::curve_spec_to_entity(spec)?, - solid: None, - }, - }); + 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)); } - DocApiBackend::push_undo(self, "CreateMany"); - let ids = prepared - .into_iter() - .map(|entity| self.apply_doc_entity(entity, None)) - .collect(); - self.finalize_op(); - Ok(ids) - } - - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let prepared = ids - .iter() - .map(|&id| self.prepare_doc_transform(id, placement)) - .collect::>>()?; - DocApiBackend::push_undo(self, "TransformMany"); - for (&id, entity) in ids.iter().zip(prepared) { - self.apply_doc_entity(entity, Some(id)); - } - self.finalize_op(); + // Refresh the live cache (cannot fail after a successful pre-flight). + self.scene_mut().register_prepared_solid_model(handle, body.clone(), display); Ok(()) } @@ -146,12 +131,7 @@ impl DocApiBackend for HostSession<'_> { 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() - { + if self.document().block_records.get(&spec.block_name).is_none() { return Err(ApiError::validation( "CreateInsert", format!("unknown block {:?}", spec.block_name), @@ -159,11 +139,7 @@ impl DocApiBackend for HostSession<'_> { } 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], - ), + 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); @@ -192,11 +168,7 @@ impl DocApiBackend for HostSession<'_> { 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.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)); @@ -208,11 +180,7 @@ impl DocApiBackend for HostSession<'_> { 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], - ), + 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)); @@ -221,45 +189,24 @@ impl DocApiBackend for HostSession<'_> { 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 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(), - )) - } + 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" - ))); + 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))?; + 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(), - )), + _ => Err(ApiError::Unsupported("GetTextContent is only for Text/MText".into())), } } @@ -269,9 +216,7 @@ impl DocApiBackend for HostSession<'_> { return Err(ApiError::UnknownId(id)); } if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); } Ok(()) } @@ -279,11 +224,7 @@ impl DocApiBackend for HostSession<'_> { 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() - }; + 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(); @@ -301,14 +242,9 @@ impl DocApiBackend for HostSession<'_> { } fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; + 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(), - )); + 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()); @@ -316,15 +252,9 @@ impl DocApiBackend for HostSession<'_> { 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]) - } + 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(), - )) - } + _ => return Err(ApiError::Unsupported("hatch boundary contains non-line edges".into())), } } loops.push(pts); @@ -332,74 +262,48 @@ impl DocApiBackend for HostSession<'_> { Ok(loops) } - fn add_dimension_linear( - &mut self, - spec: &ocs_doc_api::ops::DimensionSpec, - ) -> ApiResult { + 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))); + 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 { + 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))); + 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 { + 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))); + 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 { + 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)); + 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))); + 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 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(), - )); + return Err(ApiError::Unsupported("GetDimensionMeasurement is only for Dimension".into())); }; // Distance for linear/radius/diameter; degrees for angular. Ok(match d { @@ -408,50 +312,31 @@ impl DocApiBackend for HostSession<'_> { 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(), - )) - } + _ => return Err(ApiError::Unsupported("dimension measurement for this sub-type".into())), }) } - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { + 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.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)); + 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 { + 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)); + 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))); + let handle = self.scene_mut().add_entity(EntityType::Dimension(Dimension::Angular2Ln(dim))); Ok(handle_to_obj(handle)) } @@ -460,11 +345,7 @@ impl DocApiBackend for HostSession<'_> { 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], - ), + Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]), spec.data.len(), cols, ); @@ -480,15 +361,9 @@ impl DocApiBackend for HostSession<'_> { 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 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(), - )); + 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(); @@ -500,28 +375,17 @@ impl DocApiBackend for HostSession<'_> { 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" - ))); + 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 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(), - )); + return Err(ApiError::Unsupported("GetAttributes is only for Insert entities".into())); }; - Ok(ins - .attributes - .iter() - .map(|a| (a.tag.clone(), a.value.clone())) - .collect()) + Ok(ins.attributes.iter().map(|a| (a.tag.clone(), a.value.clone())).collect()) } fn block_entities(&self, block_name: &str) -> ApiResult> { @@ -529,18 +393,14 @@ impl DocApiBackend for HostSession<'_> { .document() .block_records .get(block_name) - .ok_or_else(|| { - ApiError::validation("GetBlockEntities", format!("unknown block {block_name:?}")) - })?; + .ok_or_else(|| ApiError::validation("GetBlockEntities", format!("unknown block {block_name:?}")))?; // Read-only traversal of the block definition's entities (handle lookups). let mut out = Vec::with_capacity(br.entity_handles.len()); for h in &br.entity_handles { let id = handle_to_obj(*h); let (kind, bounds) = match self.document().get_entity(*h) { - Some(e) => ( - convert::entity_kind_name(e).to_string(), - convert::entity_bounds(Some(e), id).ok(), - ), + Some(e) => (convert::entity_kind_name(e).to_string(), + convert::entity_bounds(Some(e), id).ok()), None => ("Missing".to_string(), None), }; out.push(EntityView { id, kind, bounds }); @@ -548,54 +408,29 @@ impl DocApiBackend for HostSession<'_> { Ok(out) } - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()> { + 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 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(), - )); + 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_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" - ))); + 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 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(), - )); + 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, - )) + 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 { + fn add_raster_image(&mut self, spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult { use acadrust::entities::RasterImage; use acadrust::types::{Vector2, Vector3}; // Validate the plugin-supplied path before embedding it in the document: @@ -604,11 +439,7 @@ impl DocApiBackend for HostSession<'_> { validate_image_path(&spec.file_path)?; let mut img = RasterImage::default(); img.file_path = spec.file_path.clone(); - img.insertion_point = Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ); + img.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]); @@ -617,12 +448,14 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - let body = cadkernel::brep::loft(sections) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "loft failed"))?; + 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) } @@ -632,16 +465,8 @@ impl DocApiBackend for HostSession<'_> { return Err(ApiError::UnknownId(id)); }; let EntityType::LwPolyline(mut pl) = entity else { - return Err(ApiError::Unsupported( - "AddVertex is only implemented for polylines".into(), - )); + return Err(ApiError::Unsupported("AddVertex is only implemented for polylines".into())); }; - if point[2] != pl.elevation || pl.vertices.len() >= ocs_doc_api::ops::BULK_ITEM_CAP { - return Err(ApiError::validation( - "AddVertex", - "vertex must share the polyline elevation and fit the item cap", - )); - } if at > pl.vertices.len() { return Err(ApiError::validation( "AddVertex", @@ -653,9 +478,7 @@ impl DocApiBackend for HostSession<'_> { 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" - ))); + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); } Ok(()) } @@ -666,11 +489,9 @@ impl DocApiBackend for HostSession<'_> { return Ok(false); } // Locked layers refuse deletion; surface that as an error so the executor - // does not treat a no-op as success. + // 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" - ))); + 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. @@ -706,15 +527,11 @@ impl DocApiBackend for HostSession<'_> { | EntityType::LwPolyline(_) ); if !ok { - return Err(ApiError::Unsupported( - "transform is not supported for this entity family".into(), - )); + 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" - ))); + 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). @@ -730,9 +547,7 @@ impl DocApiBackend for HostSession<'_> { return Err(ApiError::UnknownId(id)); } if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); } Ok(()) } @@ -751,9 +566,30 @@ impl DocApiBackend for HostSession<'_> { } fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - let prepared = self.prepare_doc_transform(id, placement)?; - self.apply_doc_entity(prepared, Some(id)); - Ok(()) + 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 = 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> { @@ -761,36 +597,18 @@ impl DocApiBackend for HostSession<'_> { .document() .get_entity(obj_to_handle(id)) .ok_or(ApiError::UnknownId(id))?; - crate::scene::model::sweep_model::extrusion_profile_of(entity) - .map(|(profile, _)| profile.pieces) - .ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into())) - } - - fn profile_plane(&self, id: ObjectId) -> ApiResult { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - crate::scene::model::sweep_model::extrusion_profile_of(entity) - .map(|(profile, _)| profile.plane) - .ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into())) + 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); borrow the cache // read-only via with_body (O(1), no B-rep deep clone). - if matches!( - self.document().get_entity(handle), - Some(EntityType::Solid3D(_)) - ) { + if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) { let mut f = |body: &KernelBody| -> ApiResult { let bb = cadkernel::brep::body_bounds(body) .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - Ok(Aabb { - min: bb.min, - max: bb.max, - }) + Ok(Aabb { min: bb.min, max: bb.max }) }; return self.with_body(id, &mut f); } @@ -819,10 +637,6 @@ impl DocApiBackend for HostSession<'_> { self.begin_doc_api_undo(label); } - fn cancel_op(&mut self) { - self.cancel_doc_api_undo(); - } - fn finalize_op(&mut self) { // Close the delta entry + bump geometry + republish the document view. self.commit_doc_api_undo(); @@ -831,93 +645,12 @@ impl DocApiBackend for HostSession<'_> { // ── helpers ────────────────────────────────────────────────────────────────── -struct PreparedDocEntity { - entity: EntityType, - solid: Option<( - KernelBody, - ( - crate::scene::model::mesh_model::MeshLodSet, - Vec, - [f64; 3], - ), - )>, -} - impl HostSession<'_> { - fn prepare_doc_solid( - &self, - body: KernelBody, - id: Option, - ) -> ApiResult { - let mut inner = match id { - Some(id) => match self.document().get_entity(obj_to_handle(id)) { - Some(EntityType::Solid3D(solid)) => solid.clone(), - _ => return Err(ApiError::UnknownId(id)), - }, - None => Solid3D::new(), - }; - let handle = id.map_or(Handle::NULL, obj_to_handle); - let display = self - .scene() - .prepare_solid_model_display(handle, &body) - .ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Other, "solid display preparation failed") - })?; - let sat = acis_export::solid_to_sat(&body).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Acis, "solid serialization failed") - })?; - inner.wires = solid_model::edge_wires(&body); - inner.set_sat_document(&sat); - Ok(PreparedDocEntity { - entity: EntityType::Solid3D(inner), - solid: Some((body, display)), - }) - } - - fn prepare_doc_transform( - &mut self, - id: ObjectId, - placement: &PlacementSpec, - ) -> ApiResult { - self.ensure_transformable(id)?; - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - if matches!(entity, EntityType::Solid3D(_)) { - let body = self.resolve_body(id)?; - let moved = cadkernel::brep::transform(&body, &kernel_placement(placement)) - .ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.prepare_doc_solid(moved, Some(id)) - } else { - Ok(PreparedDocEntity { - entity: convert::transform_entity_geometry(entity, placement)?, - solid: None, - }) - } - } - - fn apply_doc_entity(&mut self, prepared: PreparedDocEntity, id: Option) -> ObjectId { - let handle = match id { - Some(id) => { - assert!( - self.scene_mut().update_entity(prepared.entity), - "prevalidated entity update" - ); - obj_to_handle(id) - } - None => self.scene_mut().add_entity(prepared.entity), - }; - if let Some((body, display)) = prepared.solid { - self.scene_mut() - .register_prepared_solid_model(handle, body, display); - } - handle_to_obj(handle) - } - - /// Kernel mesh mass properties, cached per entity and geometry epoch. + /// 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; @@ -926,24 +659,31 @@ impl HostSession<'_> { return Ok((v, c)); } } - if self.scene().doc_api_cold_tess_used >= 32 { - return Err(ApiError::validation( - "mass properties", - "request exceeds 32 uncached solid queries", + // Accuracy path: fine tessellation so the divergence volume/centroid + // converge to near-analytic (the render-mesh metrics cache is the coarse + // display LOD, NOT for query accuracy). Bounded by the cold-tess budget; + // memoized per (handle, epoch) so repeated queries on the same solid are O(1). + // The budget + cache live on the per-tab Scene so they persist across + // DocApi dispatches (not reset per request). + const COLD_TESS_BUDGET: usize = 256; + if self.scene().doc_api_cold_tess_used >= COLD_TESS_BUDGET { + // Budget exhausted: serve a documented-approximate value from the render + // mesh metrics cache rather than failing (large distinct-solid batches + // degrade gracefully instead of erroring). Accuracy-first up to the budget. + if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { + if metrics.volume.abs() > 0.0 { + return Ok((metrics.volume, metrics.centroid)); + } + } + return Err(ApiError::Unsupported( + "volume/centroid cold-tessellation budget exceeded for this tab; query fewer distinct solids".into(), )); } - let body = self.resolve_body(id)?; self.scene_mut().doc_api_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.mass_properties().ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Empty, "solid has no measurable volume") - })?; - if self.scene().doc_api_mass_cache.len() >= ocs_doc_api::ops::BULK_ITEM_CAP { - self.scene_mut().doc_api_mass_cache.clear(); - } - self.scene_mut() - .doc_api_mass_cache - .insert(handle, (epoch, v, c)); + let (v, c) = mesh_volume_centroid(&mesh); + self.scene_mut().doc_api_mass_cache.insert(handle, (epoch, v, c)); Ok((v, c)) } } @@ -953,34 +693,21 @@ impl HostSession<'_> { fn validate_image_path(path: &str) -> ApiResult<()> { let p = path.trim(); if p.is_empty() { - return Err(ApiError::validation( - "CreateRasterImage", - "empty image file path", - )); + return Err(ApiError::validation("CreateRasterImage", "empty image file path")); } // UNC / network / device paths. if p.starts_with("\\\\") || p.starts_with("//") { - return Err(ApiError::Unsupported( - "network/UNC image paths are not allowed".into(), - )); + return Err(ApiError::Unsupported("network/UNC image paths are not allowed".into())); } // Parent-traversal anywhere in the path. if p.split(['/', '\\']).any(|seg| seg == "..") { - return Err(ApiError::Unsupported( - "parent-traversal ('..') in image path is not allowed".into(), - )); + return Err(ApiError::Unsupported("parent-traversal ('..') in image path is not allowed".into())); } // Known image extension (last component). - let ext_ok = p - .rsplit('.') - .next() - .map(|ext| { - matches!( - ext.to_ascii_lowercase().as_str(), - "png" | "jpg" | "jpeg" | "bmp" | "gif" | "tif" | "tiff" | "webp" | "tga" - ) - }) - .unwrap_or(false); + let ext_ok = p.rsplit('.').next().map(|ext| { + matches!(ext.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "bmp" | "gif" | "tif" | "tiff" | "webp" | "tga") + }).unwrap_or(false); if !ext_ok { return Err(ApiError::validation( "CreateRasterImage", @@ -999,13 +726,23 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { } } +/// 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}; + 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(); @@ -1015,11 +752,7 @@ mod tests { } fn new_id(receipt: &Receipt) -> ObjectId { - receipt - .outcome - .as_ref() - .and_then(|o| o.new_id()) - .expect("no new id") + receipt.outcome.as_ref().and_then(|o| o.new_id()).expect("no new id") } #[test] @@ -1029,61 +762,31 @@ mod tests { 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 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" - ); + 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 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" - ); + 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 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:?}" - ); + 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:?}"), @@ -1097,10 +800,7 @@ mod tests { 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 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; @@ -1112,15 +812,8 @@ mod tests { 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" - ); + assert!(matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), "{err:?}"); + assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany"); } #[test] @@ -1145,15 +838,8 @@ mod tests { }); 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(); + 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 { @@ -1162,15 +848,8 @@ mod tests { 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(); + 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:?}"); } @@ -1180,31 +859,14 @@ mod tests { 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], - ], + 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(); + 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:?}"), @@ -1218,33 +880,21 @@ mod tests { // 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], - ], + 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, - }), - ); + 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(); + 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:?}"), @@ -1259,53 +909,25 @@ mod tests { 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 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(); + 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:?}" - ); + 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(); + 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 ──────────────────────────────────── @@ -1316,57 +938,23 @@ mod tests { 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(); + 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:?}" - ), + 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(); + 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:?}"), @@ -1374,48 +962,21 @@ mod tests { // 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(); + 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:?}" - ), + 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(); + 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:?}"), @@ -1426,34 +987,12 @@ mod tests { 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(); + 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:?}"), @@ -1463,24 +1002,12 @@ mod tests { 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()); + 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(); + 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") - }; + 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); } @@ -1491,64 +1018,34 @@ mod tests { 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 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") - }; + 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(); + 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:?}" - ); + 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") - }; + 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); } @@ -1558,40 +1055,16 @@ mod tests { 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(), - ); + 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], + 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(); + 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 - ); + 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] @@ -1600,166 +1073,55 @@ mod tests { 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], + 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 - ); + 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 - ); + 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 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 - ); + 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(), - ); + 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(); + 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:?}" - ); + 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" - ); + 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) ───────────────────────────────── @@ -1770,92 +1132,33 @@ mod tests { 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(); + 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") - }; + 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()); + 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(); + 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") - }; + 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); } @@ -1866,50 +1169,25 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); // Two circles at different Z (profiles for loft). - let c1 = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { - centre: [0.0, 0.0, 0.0], - radius: 5.0, - })), - ) - .unwrap(), - ); - let c2 = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { - centre: [0.0, 0.0, 10.0], - radius: 2.0, - })), - ) - .unwrap(), - ); - let result = dispatch( - &mut host, - DocApiEnvelope::op(Operation::Loft { - profiles: vec![c1, c2], - }), - ); - let id = new_id(&result.expect("loft must preserve the sections' elevations")); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id }, Query::GetBounds { id }]), - ) - .unwrap(); - let QueryResult::Volume(volume) = receipt.query_results[0] else { - panic!("volume") - }; - let expected = std::f64::consts::PI * 10.0 * 39.0 / 3.0; - assert!( - (volume - expected).abs() < expected * 0.05, - "volume {volume}" - ); - let QueryResult::Bounds(bounds) = receipt.query_results[1] else { - panic!("bounds") - }; - assert!((bounds.min[2]).abs() < 1e-4 && (bounds.max[2] - 10.0).abs() < 1e-4); + 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] @@ -1917,46 +1195,26 @@ mod tests { 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(), - ); + 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") - }; + 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"); + 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" - ); + 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 - ); + assert!((arc.radius - 5.0).abs() < 0.01, "arc radius {} (semicircle)", arc.radius); } // Negative (CW) bulge: the center must be on the CW side (mirror of CCW). @@ -1966,28 +1224,16 @@ mod tests { .expect("negative bulge arc"); if let cadkernel::geom2d::Curve::Arc(arc) = &neg { // 90° arc, chord 1: radius = 0.5/sin(45°) ≈ 0.7071; center y must be negative. - assert!( - (arc.radius - 0.7071).abs() < 0.01, - "cw arc radius {}", - arc.radius - ); - assert!( - arc.centre[1] < 0.0, - "CW arc center y must be below the chord, got {:?}", - arc.centre - ); + assert!((arc.radius - 0.7071).abs() < 0.01, "cw arc radius {}", arc.radius); + assert!(arc.centre[1] < 0.0, "CW arc center y must be below the chord, got {:?}", arc.centre); } else { panic!("expected an arc for negative bulge"); } // And the CCW mirror: center above. - let pos = - convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], 0.4142).expect("positive bulge arc"); + let pos = convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], 0.4142) + .expect("positive bulge arc"); if let cadkernel::geom2d::Curve::Arc(arc) = &pos { - assert!( - arc.centre[1] > 0.0, - "CCW arc center y must be above the chord, got {:?}", - arc.centre - ); + assert!(arc.centre[1] > 0.0, "CCW arc center y must be above the chord, got {:?}", arc.centre); } } @@ -1998,64 +1244,28 @@ mod tests { 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(), - ); + 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(), - ); + 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 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(); + 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}") - } + QueryResult::DimensionMeasurement(v) => assert!((*v - 6.0).abs() < 1e-4, "diameter {v}"), other => panic!("expected measurement, got {other:?}"), } match &receipt.query_results[2] { @@ -2070,35 +1280,18 @@ mod tests { 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 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") - }; + 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(); + 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:?}"), @@ -2116,41 +1309,18 @@ mod tests { 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 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") - }; + 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()); + 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(); + 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:?}"), @@ -2164,62 +1334,29 @@ mod tests { 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 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})" - ); + 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:?}" - ); + 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(); + 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:?}"), @@ -2233,28 +1370,14 @@ mod tests { 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(); + 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:?}"), @@ -2274,8 +1397,7 @@ mod tests { let mut host = HostSession::new(&mut app, 0); let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1; let profiles: Vec = (0..over).map(|i| ObjectId::from_u64(i as u64)).collect(); - let err = - dispatch(&mut host, DocApiEnvelope::op(Operation::Loft { profiles })).unwrap_err(); + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::Loft { profiles })).unwrap_err(); assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); } @@ -2288,97 +1410,47 @@ mod tests { let specs = vec![ ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }), ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { - centre: [0.0; 3], - major_axis: [1.0; 3], - ratio: 2.0, - start: 0.0, - end: std::f64::consts::TAU, + centre: [0.0; 3], major_axis: [1.0; 3], ratio: 2.0, start: 0.0, end: std::f64::consts::TAU, }), ]; - let err = - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateMany(specs))).unwrap_err(); + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::CreateMany(specs))).unwrap_err(); assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); // All-or-nothing: NO entity was created (the point would have been added first). - assert_eq!( - host.scene().geometry_epoch, - rev_before, - "no mutation on rejected CreateMany" - ); + assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected CreateMany"); } #[test] - fn partial_ellipse_rotation_preserves_parameters() { + fn fix_partial_ellipse_rotates_sweep() { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); - // Rotating the major axis already rotates the ellipse; its parameters stay fixed. - let ell = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { - centre: [0.0; 3], - major_axis: [4.0, 0.0, 0.0], - ratio: 0.5, - start: 0.0, - end: std::f64::consts::FRAC_PI_2, - })), - ) - .unwrap(), - ); + // Partial ellipse arc start=0,end=pi/2; rotate by +pi/2 -> sweep becomes pi/2..pi. + let ell = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ellipse { centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, start: 0.0, end: std::f64::consts::FRAC_PI_2 }))).unwrap()); let rot90 = ocs_doc_api::PlacementSpec { - x_axis: [0.0, 1.0, 0.0], - y_axis: [-1.0, 0.0, 0.0], - z_axis: [0.0, 0.0, 1.0], - origin: [0.0; 3], - }; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ell, - placement: rot90, - }), - ) - .unwrap(); - let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { - panic!("ellipse not found") + x_axis: [0.0, 1.0, 0.0], y_axis: [-1.0, 0.0, 0.0], z_axis: [0.0, 0.0, 1.0], origin: [0.0; 3], }; + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: ell, placement: rot90 })).unwrap(); + let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { panic!("ellipse not found") }; let half_pi = std::f64::consts::FRAC_PI_2; - assert!(e.start_parameter.abs() < 1e-6); - assert!((e.end_parameter - half_pi).abs() < 1e-6); + assert!((e.start_parameter - half_pi).abs() < 1e-6, "start {} (expected {})", e.start_parameter, half_pi); + assert!((e.end_parameter - std::f64::consts::PI).abs() < 1e-6, "end {} (expected pi)", e.end_parameter); // major_axis rotated from +X to +Y. - assert!( - (e.major_axis.y - 4.0).abs() < 1e-6, - "major_axis {:?}", - e.major_axis - ); + assert!((e.major_axis.y - 4.0).abs() < 1e-6, "major_axis {:?}", e.major_axis); } #[test] fn fix_raster_image_path_validation() { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); - let mk = |path: &str| { - Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { - file_path: path.into(), - insertion_point: [0.0; 3], - u_vector: [0.5, 0.0, 0.0], - v_vector: [0.0, 0.5, 0.0], - size: [10.0, 10.0], - }) - }; + let mk = |path: &str| Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { + file_path: path.into(), insertion_point: [0.0; 3], u_vector: [0.5, 0.0, 0.0], v_vector: [0.0, 0.5, 0.0], size: [10.0, 10.0], + }); // Valid path succeeds. assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/photo.png"))).is_ok()); // UNC path rejected. - assert!(dispatch( - &mut host, - DocApiEnvelope::op(mk("\\\\server\\share\\x.png")) - ) - .is_err()); + assert!(dispatch(&mut host, DocApiEnvelope::op(mk("\\\\server\\share\\x.png"))).is_err()); // Parent-traversal rejected. - assert!(dispatch( - &mut host, - DocApiEnvelope::op(mk("C:/img/../../etc/passwd.png")) - ) - .is_err()); + assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/../../etc/passwd.png"))).is_err()); // Non-image extension rejected. assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/evil.exe"))).is_err()); // Empty path rejected. @@ -2398,10 +1470,7 @@ mod tests { acadrust::types::Vector3::new(5.0, 5.0, 0.0), acadrust::types::Vector3::new(10.0, 0.0, 0.0), ]; - let leader_h = host - .document_mut() - .add_entity(EntityType::Leader(leader)) - .unwrap(); + let leader_h = host.document_mut().add_entity(EntityType::Leader(leader)).unwrap(); // Mesh with vertices in a unit cube at (2..3, 2..3, 0). let mut mesh = acadrust::entities::Mesh::default(); mesh.vertices = vec![ @@ -2410,45 +1479,23 @@ mod tests { acadrust::types::Vector3::new(3.0, 3.0, 0.0), acadrust::types::Vector3::new(2.0, 3.0, 0.0), ]; - let mesh_h = host - .document_mut() - .add_entity(EntityType::Mesh(mesh)) - .unwrap(); + let mesh_h = host.document_mut().add_entity(EntityType::Mesh(mesh)).unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetBounds { - id: handle_to_obj(leader_h), - }, - Query::GetBounds { - id: handle_to_obj(mesh_h), - }, - Query::GetEntity { - id: handle_to_obj(leader_h), - }, - ]), - ) - .unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetBounds { id: handle_to_obj(leader_h) }, + Query::GetBounds { id: handle_to_obj(mesh_h) }, + Query::GetEntity { id: handle_to_obj(leader_h) }, + ])).unwrap(); match &receipt.query_results[0] { QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 10.0).abs() < 1e-6, - "{b:?}" - ); - assert!( - (b.min[1] - 0.0).abs() < 1e-6 && (b.max[1] - 5.0).abs() < 1e-6, - "{b:?}" - ); + assert!((b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 10.0).abs() < 1e-6, "{b:?}"); + assert!((b.min[1] - 0.0).abs() < 1e-6 && (b.max[1] - 5.0).abs() < 1e-6, "{b:?}"); } other => panic!("expected bounds, got {other:?}"), } match &receipt.query_results[1] { QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 2.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, - "{b:?}" - ); + assert!((b.min[0] - 2.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, "{b:?}"); } other => panic!("expected bounds, got {other:?}"), } @@ -2464,47 +1511,25 @@ mod tests { 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 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") - }; + 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" - ); + 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(); + 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:?}" - ); + 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:?}"), } @@ -2516,41 +1541,20 @@ mod tests { 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(), - ); + 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") - }; + 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(); + 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); @@ -2559,25 +1563,10 @@ mod tests { } // 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()); + 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 ────────────── @@ -2587,55 +1576,17 @@ mod tests { 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)); + 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(), - ); + 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(); + 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); @@ -2644,33 +1595,15 @@ mod tests { 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(); + 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(); + 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); @@ -2679,13 +1612,7 @@ mod tests { 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()); + assert!(dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBlockEntities { block_name: "Nope".into() }])).is_err()); } // ── Phase 2b-c: dimension ───────────────────────────────────────────────── @@ -2695,32 +1622,19 @@ mod tests { 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(); + 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}") - } + QueryResult::DimensionMeasurement(v) => assert!((*v - 30.0).abs() < 1e-6, "measurement {v}"), other => panic!("expected measurement, got {other:?}"), } match &receipt.query_results[1] { @@ -2728,10 +1642,7 @@ mod tests { 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:?}" - ), + 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:?}"), } } @@ -2744,60 +1655,31 @@ mod tests { 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(), - ); + 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(); + 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 - ); + 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:?}" - ), + 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()); + 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(); + dispatch(&mut host, DocApiEnvelope::op(Operation::Delete { id: hatch })).unwrap(); assert!(host.document().get_entity(obj_to_handle(hatch)).is_none()); } @@ -2816,43 +1698,25 @@ mod tests { 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() + 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(); + 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:?}" - ); + 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(); + dispatch(&mut host, DocApiEnvelope::op(Operation::Delete { id: img_id })).unwrap(); assert!(host.document().get_entity(img).is_none()); } @@ -2863,63 +1727,28 @@ mod tests { 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(); + 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 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") - }; + 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") - }; + 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(); + 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" - ); + assert_eq!(host.scene().geometry_epoch, before, "no entity on unknown block"); } #[test] @@ -2927,9 +1756,7 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); // A request naming a DIFFERENT tab than the bound one is rejected. - let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { - position: [0.0; 3], - })); + let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { position: [0.0; 3] })); let bytes = bincode::serialize(&env).unwrap(); let wrong_tab = host.tab_id().wrapping_add(999); let err = execute_doc_api(&mut host, wrong_tab, &bytes).unwrap_err(); @@ -2941,15 +1768,8 @@ mod tests { 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:?}" - ); + 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 ── @@ -2964,111 +1784,51 @@ mod tests { 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(); + 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:?}" - ); + 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(); + 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:?}" - ); + 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(); + 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(); + 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:?}" - ); + assert!((b.min[0] - 0.0).abs() < 1e-9 && (b.max[1] - 1.0).abs() < 1e-9, "{b:?}"); } other => panic!("expected bounds, got {other:?}"), } @@ -3082,40 +1842,15 @@ mod tests { 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(), - ); + 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 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(); + 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:?}"), @@ -3123,26 +1858,13 @@ mod tests { // 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]), - }; + 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(); + 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:?}" - ); + 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:?}"), } @@ -3157,32 +1879,11 @@ mod tests { 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 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 @@ -3201,10 +1902,7 @@ mod tests { 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() - )); + 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 } @@ -3227,250 +1925,9 @@ mod tests { let body = crate::scene::convert::solid3d_tess::kernel_body(solid) .expect("re-lift reloaded solid failed"); let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); - let vol = ocs_doc_api::geom::mesh_volume_centroid(&mesh).0; - assert!( - (vol - 125.0).abs() < 1.0, - "reloaded intersection volume {vol}" - ); + let vol = mesh_volume_centroid(&mesh).0; + assert!((vol - 125.0).abs() < 1.0, "reloaded intersection volume {vol}"); // The DWG is intentionally kept for inspection (see `path` above). } - #[test] - fn doc_api_failed_inputs_leave_document_and_history_unchanged() { - use ocs_doc_api::EntitySpec; - let mut app = OpenCADStudio::new_for_test(); - let count = app.tabs[0].scene.document.entity_count(); - let dirty = app.tabs[0].dirty; - let undo = app.tabs[0].history.undo_stack.len(); - let epoch = app.tabs[0].scene.geometry_epoch; - { - let mut host = HostSession::new(&mut app, 0); - let invalid_ops = vec![ - Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }), - EntitySpec::Solid(SolidPrimitive::Sphere { - centre: [0.0; 3], - radius: f64::NAN, - }), - ]), - Operation::CreateCurve(Curve2Spec::Circle { - centre: [0.0; 3], - radius: -1.0, - }), - Operation::CreateCurve(Curve2Spec::Polyline { - points: vec![[0.0; 3], [1.0; 3]], - closed: false, - }), - Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "test".into(), - insertion_point: [0.0; 3], - height: f64::INFINITY, - rotation: 0.0, - }), - ]; - for op in invalid_ops { - assert!(dispatch(&mut host, DocApiEnvelope::op(op)).is_err()); - assert!(!host.scene().is_recording_undo()); - } - } - assert_eq!(app.tabs[0].scene.document.entity_count(), count); - assert_eq!(app.tabs[0].scene.geometry_epoch, epoch); - assert_eq!(app.tabs[0].history.undo_stack.len(), undo); - assert_eq!(app.tabs[0].dirty, dirty); - } - - #[test] - fn doc_api_bulk_creation_is_dirty_and_undoes_in_one_step() { - use ocs_doc_api::EntitySpec; - let mut app = OpenCADStudio::new_for_test(); - let undo = app.tabs[0].history.undo_stack.len(); - let count = app.tabs[0].scene.document.entity_count(); - let ids = { - let mut host = HostSession::new(&mut app, 0); - let receipt = dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { position: [1.0; 3] }), - EntitySpec::Solid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [2.0; 3], - }), - ])), - ) - .unwrap(); - receipt.outcome.unwrap().new_ids().to_vec() - }; - assert!(app.tabs[0].dirty); - assert_eq!(app.tabs[0].history.undo_stack.len(), undo + 1); - assert_eq!(app.tabs[0].scene.document.entity_count(), count + 2); - app.undo_active_tab(); - assert_eq!(app.tabs[0].scene.document.entity_count(), count); - for id in &ids { - assert!(!app.tabs[0] - .scene - .solid_models - .contains_key(&obj_to_handle(*id))); - } - app.redo_active_tab(); - for id in ids { - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_some()); - } - } - - #[test] - fn doc_api_text_scale_and_elevated_extrusion() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let text = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "label".into(), - insertion_point: [1.0, 2.0, 0.0], - height: 2.0, - rotation: 0.0, - })), - ) - .unwrap(), - ); - let placement = PlacementSpec { - origin: [0.0, 0.0, 7.0], - x_axis: [2.0, 0.0, 0.0], - y_axis: [0.0, 2.0, 0.0], - z_axis: [0.0, 0.0, 2.0], - }; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: text, - placement, - }), - ) - .unwrap(); - let EntityType::Text(text) = host.document().get_entity(obj_to_handle(text)).unwrap() - else { - panic!("text") - }; - assert!((text.height - 4.0).abs() < 1e-9); - assert!((text.insertion_point.z - 7.0).abs() < 1e-9); - let profile = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { - points: vec![ - [0.0, 0.0, 7.0], - [2.0, 0.0, 7.0], - [2.0, 3.0, 7.0], - [0.0, 3.0, 7.0], - ], - closed: true, - })), - ) - .unwrap(), - ); - let solid = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::Extrude { - profile, - direction: [0.0, 0.0, 5.0], - }), - ) - .unwrap(), - ); - let bounds = host.bounds(solid).unwrap(); - assert!( - (bounds.min[2] - 7.0).abs() < 1e-6 && (bounds.max[2] - 12.0).abs() < 1e-6, - "{bounds:?}" - ); - assert!((host.volume(solid).unwrap() - 30.0).abs() < 1e-6); - } - - #[test] - fn doc_api_envelope_guards_and_query_budget_reset() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [2.0; 3], - })), - ) - .unwrap(), - ); - let mut env = DocApiEnvelope::queries(vec![Query::GetVolume { id }]); - env.version += 1; - assert!(matches!( - dispatch(&mut host, env), - Err(ApiError::Unsupported(_)) - )); - let valid = DocApiEnvelope::queries(vec![Query::GetVolume { id }]); - let mut bytes = bincode::serialize(&valid).unwrap(); - bytes.push(0); - let tab = host.tab_id(); - assert!(execute_doc_api(&mut host, tab, &bytes).is_err()); - let bytes = bincode::serialize(&valid).unwrap(); - assert!(execute_doc_api(&mut host, tab + 1, &bytes).is_err()); - host.scene_mut().doc_api_cold_tess_used = 32; - let receipt = dispatch(&mut host, valid).unwrap(); - assert!( - matches!(receipt.query_results[0], QueryResult::Volume(v) if (v - 8.0).abs() < 1e-9) - ); - assert_eq!(host.scene().doc_api_cold_tess_used, 1); - } - #[test] - fn doc_api_plugin_wire_dispatch_and_image_undo() { - use ocs_plugin_api::ipc::{ - protocol::{PluginRequest, PluginResponse}, - server::handle_plugin_request, - }; - let mut app = OpenCADStudio::new_for_test(); - let objects = app.tabs[0].scene.document.objects.len(); - let id = { - let mut host = HostSession::new(&mut app, 0); - let env = DocApiEnvelope::op(Operation::CreateRasterImage( - ocs_doc_api::ops::RasterImageSpec { - file_path: "image.png".into(), - insertion_point: [0.0; 3], - u_vector: [1.0, 0.0, 0.0], - v_vector: [0.0, 1.0, 0.0], - size: [2.0; 2], - }, - )); - let request = PluginRequest::DocApiRequest { - tab_id: host.tab_id(), - bytes: bincode::serialize(&env).unwrap(), - }; - let request = bincode::deserialize(&bincode::serialize(&request).unwrap()).unwrap(); - let response = handle_plugin_request(&mut host, request, &mut |_| {}); - let response: PluginResponse = - bincode::deserialize(&bincode::serialize(&response).unwrap()).unwrap(); - let PluginResponse::DocApiResponse { bytes } = response else { - panic!("unexpected response") - }; - let result: ApiResult = bincode::deserialize(&bytes).unwrap(); - new_id(&result.unwrap()) - }; - assert!(app.tabs[0].scene.document.objects.len() > objects); - app.undo_active_tab(); - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_none()); - assert_eq!(app.tabs[0].scene.document.objects.len(), objects); - app.redo_active_tab(); - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_some()); - assert!(app.tabs[0].scene.document.objects.len() > objects); - } }