Add ocs_doc_api: object-oriented CAD entity API (v2)

New crate crates/ocs_doc_api: object-centric facade (DocApi -> Document ->
collections -> typed handles), transport-agnostic wire layer (DocApiEnvelope =
single op | read-only query batch), per-op autocommit (no write transactions),
bulk ops with upfront-validation all-or-nothing, structured ApiError, and
build-time codegen (spec/entities.toml -> ops/query enums, api_reference.md,
binding_schema.json).

ocs_plugin_api: append-only PluginRequest::DocApiRequest / PluginResponse::
DocApiResponse (serde_bytes payload) + defaulted HostApi::doc_api_dispatch.

Host: DocApiBackend impl for HostSession (src/app/doc_api.rs) mapping to scene
primitives (solid_models lift-on-miss, register_prepared_solid_model, meshes
metrics), entity conversions (doc_api_convert.rs), HostSession accessors +
doc_api_dispatch override.

Tests: 592 host lib (incl. 7 DocApi integration tests: create/boolean/query,
unknown-id, transform-many all-or-nothing, query-batch cap, 2D/geometric/DWG
roundtrips), 8 crate unit tests. Examples + README/ARCHITECTURE/IMPLEMENTATION docs.
This commit is contained in:
Sebastian committed 2026-09-06 00:15:27 +02:00
1 parent 9129c85f97
commit e41086b058
44 files changed
+4837 -12

No files matched your search

+547
View File
@@ -0,0 +1,547 @@
// DocApi v2 backend: implement `ocs_doc_api::backend::DocApiBackend` for
// `HostSession` (plan §6/§8, decision #11 — thin host hook, executor logic in
// the versioned crate). Maps each backend method to already-present scene/app
// primitives; no new geometry code.
use acadrust::entities::Solid3D;
use acadrust::{EntityType, Handle};
use ocs_doc_api::backend::{DocApiBackend, KernelBody};
use ocs_doc_api::{
Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, ObjectId,
PlacementSpec,
};
use crate::scene::convert::acis_export;
use crate::scene::model::solid_model;
use super::plugin_host::HostSession;
/// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by
// the in-process path. Deserializes the envelope, runs the crate executor,
// serializes the `Receipt` (or `ApiError`) back to bytes.
pub fn execute_doc_api(host: &mut HostSession<'_>, _tab_id: u64, bytes: &[u8]) -> Result<Vec<u8>, String> {
use ocs_doc_api::{DocApiEnvelope, EnvelopeBody};
let envelope: DocApiEnvelope = bincode::deserialize(bytes)
.map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?;
let result: ApiResult<ocs_doc_api::Receipt> = match envelope.body {
EnvelopeBody::Op(op) => ocs_doc_api::executor::apply_op(host, op),
EnvelopeBody::Queries(qs) => ocs_doc_api::executor::apply_queries(host, qs),
};
bincode::serialize(&result).map_err(|e| format!("Receipt serialize: {e}"))
}
fn obj_to_handle(id: ObjectId) -> Handle {
Handle::new(id.as_u64())
}
fn handle_to_obj(h: Handle) -> ObjectId {
ObjectId::from_u64(h.value())
}
impl DocApiBackend for HostSession<'_> {
fn resolve_body(&mut self, id: ObjectId) -> ApiResult<KernelBody> {
let handle = obj_to_handle(id);
// Lift-on-miss: populate the solid_models cache from AcisData if absent.
self.scene_mut().restore_solid_models(&[handle]);
self.scene()
.solid_models
.get(&handle)
.cloned()
.ok_or(ApiError::UnknownId(id))
}
fn store_solid(&mut self, body: &KernelBody) -> ApiResult<ObjectId> {
// Pre-flight the display prep BEFORE committing the entity: if the mesh
// cannot be produced, fail now — no entity, no rollback, no revision bump
// (plan review: store_solid rollback moved the epoch on failure).
let scene = self.scene_mut();
let Some(display) = scene.prepare_solid_model_display(Handle::NULL, body) else {
return Err(ApiError::geometry(GeometryErrorKind::Other, "prepare_solid_model_display failed"));
};
// Mirror `add_solid_model` (history-free v1): edge wires + SAT + entity.
let mut inner = Solid3D::new();
inner.common.plotstyle_flags = 2;
inner.wires = solid_model::edge_wires(body);
let document = acis_export::solid_to_sat(body)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Acis, "solid_to_sat failed"))?;
inner.set_sat_document(&document);
let handle = self
.commit_entity_handle(EntityType::Solid3D(inner))
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Other, "commit_entity_handle failed"))?;
// register_prepared_solid_model cannot fail once the display prep succeeded,
// so there is no post-commit rollback path. Register the live B-rep so
// booleans/transforms see it (decision #7).
self.scene_mut().register_prepared_solid_model(handle, body.clone(), display);
Ok(handle_to_obj(handle))
}
fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> {
let handle = obj_to_handle(id);
// Rebuild the Solid3D entity with fresh SAT + wires, preserving the handle.
let Some(existing) = self.document().get_entity(handle).cloned() else {
return Err(ApiError::UnknownId(id));
};
let EntityType::Solid3D(mut inner) = existing else {
return Err(ApiError::Unsupported("update_solid on non-solid entity".into()));
};
inner.wires = solid_model::edge_wires(body);
let document = acis_export::solid_to_sat(body)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Acis, "solid_to_sat failed"))?;
inner.set_sat_document(&document);
// Pre-flight the display prep before mutating (fail without touching state).
let Some(display) = self.scene_mut().prepare_solid_model_display(handle, body) else {
return Err(ApiError::geometry(GeometryErrorKind::Other, "prepare_solid_model_display failed"));
};
if !self.scene_mut().update_entity(EntityType::Solid3D(inner)) {
return Err(ApiError::UnknownId(id));
}
// Refresh the live cache (cannot fail after a successful pre-flight).
self.scene_mut().register_prepared_solid_model(handle, body.clone(), display);
Ok(())
}
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
let entity = crate::app::doc_api_convert::curve_spec_to_entity(spec)?;
let handle = self.scene_mut().add_entity(entity);
Ok(handle_to_obj(handle))
}
fn add_vertex(&mut self, _id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> {
Err(ApiError::Unsupported("AddVertex is not yet implemented".into()))
}
fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool> {
let handle = obj_to_handle(id);
if !self.entity_exists(id) {
return Ok(false);
}
// Locked layers refuse deletion; surface that as an error so the executor
// does not treat a no-op as success (plan review: erase path).
if self.scene().is_layer_locked(handle) {
return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer")));
}
// erase_entities clears the entity + solid_models/meshes/hatches and
// records the undo delta; the single publish happens at finalize_op.
self.scene_mut().erase_entities(&[handle]);
Ok(self.document().get_entity(handle).is_none())
}
fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> {
// v1 typed transform is solids-only; pre-validating this lets TransformMany
// be all-or-nothing (no mid-loop Unsupported after earlier transforms).
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
if matches!(self.document().get_entity(obj_to_handle(id)), Some(EntityType::Solid3D(_))) {
Ok(())
} else {
Err(ApiError::Unsupported("transform is only implemented for solids in v1".into()))
}
}
fn can_remove(&self, id: ObjectId) -> ApiResult<()> {
let handle = obj_to_handle(id);
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
if self.scene().is_layer_locked(handle) {
return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer")));
}
Ok(())
}
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> {
let handle = obj_to_handle(id);
let entity = self
.document()
.get_entity(handle)
.ok_or(ApiError::UnknownId(id))?;
Ok(EntityView {
id,
kind: crate::app::doc_api_convert::entity_kind_name(entity).to_string(),
bounds: self.bounds(id).ok(),
})
}
fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> {
let handle = obj_to_handle(id);
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
// Solids go through the kernel transform (re-embed SAT). Non-solids are
// out of scope for v1 typed transforms.
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 {
Err(ApiError::Unsupported("transform is only implemented for solids in v1".into()))
}
}
fn profile_curves(&self, _id: ObjectId) -> ApiResult<Vec<cadkernel::geom2d::Curve>> {
// Profiles for Extrude/Revolve land in a later phase (§5.2); v1 surfaces
// a clear error rather than guessing.
Err(ApiError::Unsupported("profile curves for sweep ops are not yet implemented".into()))
}
fn bounds(&mut self, id: ObjectId) -> ApiResult<Aabb> {
let handle = obj_to_handle(id);
// Lift-on-miss for solids (consistent with volume/centroid): resolve_body
// repopulates a cold solid_models cache from SAT. A solid whose cache entry
// was dropped (e.g. by a host-side update_entity) no longer misreports
// `Unsupported` (plan review).
if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) {
let body = self.resolve_body(id)?;
let bb = cadkernel::brep::body_bounds(&body)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?;
return Ok(Aabb { min: bb.min, max: bb.max });
}
crate::app::doc_api_convert::entity_bounds(self.document().get_entity(handle), id)
}
fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> {
let handle = obj_to_handle(id);
// Serve from the scene's render-mesh metrics cache when present (avoids a
// full body tessellation + B-rep clone per query, plan review); fall back
// to the kernel mesh when the cache is cold.
if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) {
if metrics.volume.abs() > 0.0 {
return Ok(metrics.centroid);
}
}
let body = self.resolve_body(id)?;
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
Ok(mesh_volume_centroid(&mesh).1)
}
fn volume(&mut self, id: ObjectId) -> ApiResult<f64> {
let handle = obj_to_handle(id);
if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) {
if metrics.volume.abs() > 0.0 {
return Ok(metrics.volume);
}
}
let body = self.resolve_body(id)?;
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
Ok(mesh_volume_centroid(&mesh).0)
}
fn entity_exists(&self, id: ObjectId) -> bool {
self.document().get_entity(obj_to_handle(id)).is_some()
}
fn revision(&self) -> GeometryRevision {
GeometryRevision(self.scene().geometry_epoch)
}
fn push_undo(&mut self, label: &str) {
// Begin undo capture for an entity-only delta (delta_safe = true: solids
// and curves are entity-store changes).
self.begin_doc_api_undo(label);
}
fn finalize_op(&mut self) {
// Close the delta entry + bump geometry + republish the document view.
self.commit_doc_api_undo();
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement {
cadkernel::brep::Placement {
x_axis: p.x_axis,
y_axis: p.y_axis,
z_axis: p.z_axis,
origin: p.origin,
}
}
/// Signed volume and centroid of a closed triangle mesh via the divergence
/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source
/// of truth for both (used by the cold-cache `volume`/`centroid` fallback).
fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
let mut vol = 0.0;
let mut c = [0.0; 3];
for t in &mesh.triangles {
let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]);
let cross = [
v1[1] * v2[2] - v1[2] * v2[1],
v1[2] * v2[0] - v1[0] * v2[2],
v1[0] * v2[1] - v1[1] * v2[0],
];
let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0;
vol += tet;
for i in 0..3 {
c[i] += (v0[i] + v1[i] + v2[i]) * tet;
}
}
if vol.abs() < 1e-12 {
return (0.0, [0.0; 3]);
}
(vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::plugin_host::HostSession;
use crate::app::OpenCADStudio;
use ocs_doc_api::ops::{BoolOp, SolidPrimitive};
use ocs_doc_api::{
DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt,
};
fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult<Receipt> {
let bytes = bincode::serialize(&env).unwrap();
let out = execute_doc_api(host, 0, &bytes).expect("dispatch failed");
bincode::deserialize(&out).expect("receipt deserialize")
}
fn new_id(receipt: &Receipt) -> ObjectId {
receipt.outcome.as_ref().and_then(|o| o.new_id()).expect("no new id")
}
#[test]
fn doc_api_create_boolean_query_end_to_end() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
let rev0 = host.scene().geometry_epoch;
// op 1 + 2: create two overlapping boxes (one undo step + one bump each).
let cuboid_a = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] });
let cuboid_b = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] });
let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_a)).unwrap());
let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_b)).unwrap());
// Each write op advanced the geometry epoch (at least one bump per op).
assert!(host.scene().geometry_epoch > rev0, "epoch advanced by creates");
// op 3: intersect; erase_sources keeps the result at `a`, erases `b`.
let intersect = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true };
let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect)).unwrap());
assert_eq!(lens, a);
assert!(host.document().get_entity(obj_to_handle(b)).is_none(), "b erased");
assert!(host.scene().solid_models.contains_key(&obj_to_handle(a)), "result in cache");
// query batch: bounds + volume (read-only, no bump).
let rev_before_query = host.scene().geometry_epoch;
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![
Query::GetBounds { id: a }, Query::GetVolume { id: a }])).unwrap();
assert_eq!(host.scene().geometry_epoch, rev_before_query, "queries must not bump");
let bb = match &receipt.query_results[0] {
QueryResult::Bounds(bb) => *bb,
other => panic!("expected bounds, got {other:?}"),
};
// [0,10]^3 intersect [5,15]^3 = [5,10]^3.
assert!((bb.min[0] - 5.0).abs() < 1e-4 && (bb.max[0] - 10.0).abs() < 1e-4, "bounds {bb:?}");
let vol = match &receipt.query_results[1] {
QueryResult::Volume(v) => *v,
other => panic!("expected volume, got {other:?}"),
};
assert!((vol - 125.0).abs() < 1.0, "volume {vol}");
let _ = HasId::id(&lens);
}
#[test]
fn doc_api_transform_many_with_non_solid_fails_all_or_nothing() {
use ocs_doc_api::ops::Curve2Spec;
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
// A solid and a non-solid (a line is not transformable in v1).
let mk_solid = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] });
let mk_line = Operation::CreateCurve(Curve2Spec::Line { start: [0.0; 3], end: [1.0; 3] });
let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_solid)).unwrap());
let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap());
let rev_before = host.scene().geometry_epoch;
// TransformMany over [solid, line]: the non-solid makes it fail BEFORE any
// mutation (all-or-nothing), so the epoch must not move and no undo is recorded.
let op = Operation::TransformMany {
ids: vec![solid, line],
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::Unsupported(_)), "{err:?}");
assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany");
}
#[test]
fn doc_api_query_batch_over_cap_is_rejected() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1;
let queries: Vec<Query> = (0..over).map(|_| Query::GetGeometryRevision).collect();
let err = dispatch(&mut host, DocApiEnvelope::queries(queries)).unwrap_err();
assert!(matches!(err, ApiError::Validation { .. }), "{err:?}");
}
#[test]
fn doc_api_unknown_id_surfaces_structured_error() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
let ghost = ObjectId::from_u64(0xDEAD);
let err = dispatch(&mut host, DocApiEnvelope::op(Operation::Delete { id: ghost })).unwrap_err();
assert!(matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), "{err:?}");
}
// ── Roundtrip tests: create -> read back via queries -> assert geometry ──
use ocs_doc_api::ops::{Curve2Spec, PlacementSpec};
/// Create an entity via DocApi, then read it back via GetEntity/GetBounds and
/// assert the geometry round-trips with the expected kind + bounds.
#[test]
fn roundtrip_2d_entities_line_circle_point_polyline() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
// Line [0,0,0]-[10,0,0]: kind + bounds round-trip.
let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
Curve2Spec::Line { start: [0.0, 0.0, 0.0], end: [10.0, 0.0, 0.0] }))).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![
Query::GetEntity { id: line }, Query::GetBounds { id: line }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Entity(v) => assert_eq!(v.kind, "Line"),
other => panic!("expected entity, got {other:?}"),
}
match &receipt.query_results[1] {
QueryResult::Bounds(b) => {
assert!((b.min[0] - 0.0).abs() < 1e-9 && (b.max[0] - 10.0).abs() < 1e-9, "{b:?}");
}
other => panic!("expected bounds, got {other:?}"),
}
// Circle centre (5,5,0) r=3: bounds = centre ± radius in XY.
let circle = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
Curve2Spec::Circle { centre: [5.0, 5.0, 0.0], radius: 3.0 }))).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: circle }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Bounds(b) => {
assert!((b.min[0] - 2.0).abs() < 1e-9 && (b.max[0] - 8.0).abs() < 1e-9, "{b:?}");
}
other => panic!("expected bounds, got {other:?}"),
}
// Point at (1,2,3): degenerate bounds.
let point = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
Curve2Spec::Point { position: [1.0, 2.0, 3.0] }))).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: point }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Bounds(b) => assert_eq!(b.min, [1.0, 2.0, 3.0]),
other => panic!("expected bounds, got {other:?}"),
}
// Closed polyline (unit square in XY): bounds = [0,1]^2 at z=0.
let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
Curve2Spec::Polyline {
points: vec![[0.0,0.0,0.0],[1.0,0.0,0.0],[1.0,1.0,0.0],[0.0,1.0,0.0]],
closed: true,
}))).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: poly }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Bounds(b) => {
assert!((b.min[0] - 0.0).abs() < 1e-9 && (b.max[1] - 1.0).abs() < 1e-9, "{b:?}");
}
other => panic!("expected bounds, got {other:?}"),
}
}
/// Geometric methods round-trip: transform moves a solid (bounds shift);
/// union/subtract produce the expected mass/volume relationships.
#[test]
fn roundtrip_geometric_methods_transform_and_booleans() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
// Two overlapping boxes [0,10]^3 and [5,15]^3.
let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }))).unwrap());
let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] }))).unwrap());
// Intersection FIRST: [0,10]^3 ∩ [5,15]^3 = [5,10]^3 = 125.
let intersect_op = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true };
let inter = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap());
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id: inter }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Volume(v) => assert!((*v - 125.0).abs() < 1.0, "intersection vol {v}"),
other => panic!("expected volume, got {other:?}"),
}
// THEN transform the result by +100 in X (a real, non-identity move): its
// bounds must shift by exactly +100 in X while Y/Z stay at [5,10].
let move_op = Operation::Transform { id: inter, placement: PlacementSpec::at([100.0, 0.0, 0.0]) };
dispatch(&mut host, DocApiEnvelope::op(move_op)).unwrap();
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: inter }])).unwrap();
match &receipt.query_results[0] {
QueryResult::Bounds(bb) => {
assert!((bb.min[0] - 105.0).abs() < 1e-4 && (bb.max[0] - 110.0).abs() < 1e-4, "{bb:?}");
assert!((bb.min[1] - 5.0).abs() < 1e-4 && (bb.max[1] - 10.0).abs() < 1e-4, "{bb:?}");
}
other => panic!("expected bounds, got {other:?}"),
}
}
/// DWG roundtrip: create two intersected solids, write the document to a DWG
/// file, read it back, and assert the intersected solid survives with valid
/// ACIS data (the exact path `restore_solid_models` re-lifts on load).
#[test]
fn roundtrip_intersected_solids_through_dwg_file() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
// Two overlapping boxes -> intersect -> the result solid (125 vol).
let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }))).unwrap());
let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] }))).unwrap());
let intersect_op = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true };
let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap());
// Write the live document to a STABLE, inspectable DWG in the workspace
// target dir so it can be opened in a CAD viewer after the test. The file
// is kept (not deleted). If the stable path is momentarily locked (e.g. a
// concurrent test run or a viewer holding it), fall back to a process-
// unique path for the roundtrip so the test never flakes.
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target");
std::fs::create_dir_all(&dir).expect("create target dir");
let doc = host.document().clone();
let stable = dir.join("doc_api_roundtrip_intersected.dwg");
// Release any stale lock/handle on the stable file before rewriting.
let _ = std::fs::remove_file(&stable);
let path = match acadrust::io::dwg::DwgWriter::write_to_file(&stable, &doc) {
Ok(()) => stable,
Err(_) => {
// Locked by another live process (e.g. a viewer) — write a unique
// file for the roundtrip instead of failing.
let alt = dir.join(format!("doc_api_roundtrip_intersected_{}.dwg", std::process::id()));
acadrust::io::dwg::DwgWriter::write_to_file(&alt, &doc).expect("write DWG failed");
alt
}
};
assert!(path.exists(), "DWG was written");
// Read it back.
let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).expect("open DWG");
let reloaded = reader.read().expect("read DWG failed");
// The intersected solid must be present as a Solid3D with valid ACIS data,
// and re-lifting it must reproduce a body with the expected volume.
let handle = obj_to_handle(lens);
let Some(EntityType::Solid3D(solid)) = reloaded.get_entity(handle) else {
panic!("intersected solid {handle:?} not found as Solid3D in reloaded DWG");
};
assert!(solid.has_acis_data(), "reloaded solid carries ACIS data");
// Re-lift through the same SAT->kernel path used on load and check volume.
let body = crate::scene::convert::solid3d_tess::kernel_body(solid)
.expect("re-lift reloaded solid failed");
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
let vol = mesh_volume_centroid(&mesh).0;
assert!((vol - 125.0).abs() < 1.0, "reloaded intersection volume {vol}");
// The DWG is intentionally kept for inspection (see `path` above).
}
}
+111
View File
@@ -0,0 +1,111 @@
// DocApi v2 entity conversions (plan §5.1): map the crate's plain-data DTOs to
// acadrust `EntityType` and back. Lives in the host (uses the real acadrust
// types), keeping the crate's DTOs dependency-light.
use acadrust::entities::{Circle, Line, LwPolyline, LwVertex, Point};
use acadrust::types::{Vector2, Vector3};
use acadrust::EntityType;
use ocs_doc_api::{Aabb, ApiError, ApiResult, Curve2Spec, ObjectId};
fn v3(p: [f64; 3]) -> Vector3 {
Vector3::new(p[0], p[1], p[2])
}
/// Build an acadrust entity for a 2D curve construction spec.
pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
let entity = match spec {
Curve2Spec::Line { start, end } => {
let mut e = Line::new();
e.start = v3(*start);
e.end = v3(*end);
EntityType::Line(e)
}
Curve2Spec::Circle { centre, radius } => {
let mut e = Circle::new();
e.center = v3(*centre);
e.radius = *radius;
EntityType::Circle(e)
}
Curve2Spec::Polyline { points, closed } => {
let mut e = LwPolyline::new();
e.vertices = points
.iter()
.map(|p| LwVertex {
location: Vector2::new(p[0], p[1]),
bulge: 0.0,
start_width: 0.0,
end_width: 0.0,
vertex_id: 0,
})
.collect();
e.is_closed = *closed;
EntityType::LwPolyline(e)
}
Curve2Spec::Point { position } => {
let mut e = Point::new();
e.location = v3(*position);
EntityType::Point(e)
}
};
Ok(entity)
}
/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts).
pub fn entity_kind_name(entity: &EntityType) -> &'static str {
match entity {
EntityType::Solid3D(_) => "Solid3D",
EntityType::Line(_) => "Line",
EntityType::Circle(_) => "Circle",
EntityType::LwPolyline(_) => "LwPolyline",
EntityType::Polyline(_) => "Polyline",
EntityType::Point(_) => "Point",
EntityType::Arc(_) => "Arc",
EntityType::Ellipse(_) => "Ellipse",
EntityType::Spline(_) => "Spline",
EntityType::Text(_) => "Text",
EntityType::MText(_) => "MText",
EntityType::Insert(_) => "Insert",
_ => "Other",
}
}
/// Coarse bounds for a non-solid entity (solids go through the kernel cache).
/// For v1 typed curves we compute the obvious bounds; anything else errors
/// `Unsupported` (viewport/annotation bounds land in later phases).
pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult<Aabb> {
let entity = entity.ok_or(ApiError::UnknownId(id))?;
let bb = match entity {
EntityType::Line(l) => Aabb {
min: [
l.start.x.min(l.end.x),
l.start.y.min(l.end.y),
l.start.z.min(l.end.z),
],
max: [
l.start.x.max(l.end.x),
l.start.y.max(l.end.y),
l.start.z.max(l.end.z),
],
},
EntityType::Circle(c) => Aabb {
min: [c.center.x - c.radius, c.center.y - c.radius, c.center.z],
max: [c.center.x + c.radius, c.center.y + c.radius, c.center.z],
},
EntityType::Point(p) => Aabb {
min: [p.location.x, p.location.y, p.location.z],
max: [p.location.x, p.location.y, p.location.z],
},
EntityType::LwPolyline(pl) if !pl.vertices.is_empty() => {
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for vtx in &pl.vertices {
let (x, y, z) = (vtx.location.x, vtx.location.y, pl.elevation);
min = [min[0].min(x), min[1].min(y), min[2].min(z)];
max = [max[0].max(x), max[1].max(y), max[2].max(z)];
}
Aabb { min, max }
}
_ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())),
};
Ok(bb)
}
+4
View File
@@ -11,6 +11,10 @@ pub(crate) mod expr_eval;
mod find_replace;
pub(crate) mod helpers;
mod history;
#[cfg(not(target_arch = "wasm32"))]
mod doc_api;
#[cfg(not(target_arch = "wasm32"))]
mod doc_api_convert;
mod layers;
mod model_ops;
mod mtext_editor;
+38
View File
@@ -16,6 +16,8 @@ pub(crate) struct HostSession<'a> {
app: &'a mut OpenCADStudio,
tab: usize,
doc_store: Option<DocumentSnapshotStore<DocumentViewData>>,
/// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`).
doc_api_pending: Option<super::history::PendingDelta>,
}
impl<'a> HostSession<'a> {
@@ -24,6 +26,7 @@ impl<'a> HostSession<'a> {
app,
tab,
doc_store: None,
doc_api_pending: None,
}
}
@@ -242,6 +245,37 @@ impl<'a> HostSession<'a> {
self.app.push_undo_snapshot(self.tab, label);
}
// ── DocApi v2 accessors (used by `app::doc_api` backend) ───────────────
pub(crate) fn scene(&self) -> &crate::scene::Scene {
&self.app.tabs[self.tab].scene
}
pub(crate) fn scene_mut(&mut self) -> &mut crate::scene::Scene {
&mut self.app.tabs[self.tab].scene
}
/// Commit a freshly-built entity into the document, returning its handle.
pub(crate) fn commit_entity_handle(&mut self, entity: EntityType) -> Option<Handle> {
self.app.commit_entity_handle(entity)
}
/// DocApi `push_undo`: begin an entity-only delta capture for one op.
pub(crate) fn begin_doc_api_undo(&mut self, label: &str) {
self.doc_api_pending = self.app.begin_undo(self.tab, label, 1, true);
}
/// DocApi `finalize_op`: close the delta + republish the document view.
/// The underlying entity op (add/update/remove/register_solid_model) already
/// bumped `geometry_epoch`, so we do NOT bump again here — exactly one undo
/// step is recorded, and the epoch has advanced for this op.
pub(crate) fn commit_doc_api_undo(&mut self) {
if let Some(pending) = self.doc_api_pending.take() {
self.app.commit_undo_delta(self.tab, pending);
}
self.publish_document_view();
}
pub fn set_dirty(&mut self) {
self.app.tabs[self.tab].dirty = true;
}
@@ -360,6 +394,10 @@ impl HostApi for HostSession<'_> {
fn close_document_view_v4(&mut self, tab_id: u64) {
self.close_document_view_v4(tab_id)
}
#[cfg(not(target_arch = "wasm32"))]
fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result<Vec<u8>, String> {
super::doc_api::execute_doc_api(self, tab_id, bytes)
}
}
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)