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

+92
View File
@@ -0,0 +1,92 @@
//! The thin host hook (plan §6, decision #11). The host implements this trait;
//! the crate's [`crate::executor`] drives it. Both the in-process transport and
//! the host's IPC executor call into it. Logic lives in the versioned crate; the
//! host only provides scene primitives.
use crate::error::{ApiError, ApiResult};
use crate::id::ObjectId;
use crate::ops::Curve2Spec;
use crate::query::{Aabb, EntityView};
use crate::revision::GeometryRevision;
/// A kernel B-rep solid body, opaque to the wire. On `host` this is
/// `cadkernel::brep::Body`; kernel keys never cross the API (decision #7).
pub type KernelBody = cadkernel::brep::Body;
/// The per-op host hook. Every method maps to an already-present scene primitive.
/// The executor calls `push_undo` BEFORE applying a write op and `finalize_op`
/// AFTER — recording exactly one undo step + one geometry bump per applied op.
pub trait DocApiBackend {
// ── identity / solid lookup ────────────────────────────────────────────
/// Resolve a live B-rep body for `id` (solid cache; re-lift from AcisData on
/// miss). `UnknownId` if `id` is not a live solid.
fn resolve_body(&mut self, id: ObjectId) -> ApiResult<KernelBody>;
/// Store a new solid body as a `Solid3D` entity; returns the fresh `ObjectId`.
/// Mirrors host `add_solid_model` (edge wires + `solid_to_sat` +
/// `set_sat_document` + `register_solid_model`).
fn store_solid(&mut self, body: &KernelBody) -> ApiResult<ObjectId>;
/// Replace the body of an existing solid in place (same `ObjectId`).
/// Re-embeds SAT and refreshes the solid cache.
fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()>;
// ── plain (2D) entities ────────────────────────────────────────────────
/// Add a 2D curve entity; returns the fresh `ObjectId`.
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId>;
/// Remove any first-level entity by id. Returns `false` if absent; returns
/// `Err` if the entity exists but cannot be removed (e.g. locked layer).
fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool>;
/// Can `id` be removed right now (exists and is not blocked, e.g. by a locked
/// layer)? Read-only; used by the boolean-erase path to validate BEFORE any
/// mutation so a mid-op failure cannot leave partial state. Default: existence-only.
fn can_remove(&self, id: ObjectId) -> ApiResult<()> {
if self.entity_exists(id) {
Ok(())
} else {
Err(ApiError::UnknownId(id))
}
}
/// A generic, untyped view of any entity (id + kind + coarse bounds).
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView>;
/// The 2D curve geometry of a profile entity (polyline/closed curve) for
/// sweep ops (`Extrude`/`Revolve`). `UnknownId`/`Unsupported` if not a profile.
fn profile_curves(&self, id: ObjectId) -> ApiResult<Vec<cadkernel::geom2d::Curve>>;
/// Apply a rigid similarity to any entity in place (same `ObjectId`).
fn transform_entity(&mut self, id: ObjectId, placement: &crate::ops::PlacementSpec) -> ApiResult<()>;
/// Validate that `id` exists and can be transformed, WITHOUT mutating.
/// Used by `TransformMany` to pre-validate all ids before `push_undo` so the
/// apply loop cannot fail part-way (all-or-nothing, plan §5.3). Default:
/// existence-only (backends that restrict transform to a family override).
fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> {
if self.entity_exists(id) {
Ok(())
} else {
Err(ApiError::UnknownId(id))
}
}
/// Insert a vertex into a polyline at index `at` (polyline family only).
fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()>;
// ── queries (no undo, no bump; `&mut` because solid resolution may
// populate the kernel body cache on miss) ─────────────────────────────
fn bounds(&mut self, id: ObjectId) -> ApiResult<Aabb>;
fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]>;
fn volume(&mut self, id: ObjectId) -> ApiResult<f64>;
fn entity_exists(&self, id: ObjectId) -> bool;
fn revision(&self) -> GeometryRevision;
// ── per-op boundary ────────────────────────────────────────────────────
/// Called BEFORE applying a write op (records the before-state undo snapshot).
fn push_undo(&mut self, label: &str);
/// Called AFTER a successful write op (one DeltaSnapshot + one geometry bump
/// + one publish). NOT called on failure (a failed op records nothing).
fn finalize_op(&mut self);
}
+77
View File
@@ -0,0 +1,77 @@
//! The wire envelope (plan §7). One request = ONE write op OR a query batch
//! (read-only batching is safe: no mutation, no undo, no rollback).
use serde::{Deserialize, Serialize};
use crate::gen::{Operation, Query};
use crate::id::ObjectId;
use crate::query::QueryResult;
use crate::revision::GeometryRevision;
use crate::ENVELOPE_VERSION;
/// The single request unit crossing the transport.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DocApiEnvelope {
/// Crate protocol version (`ENVELOPE_VERSION`). Bridges must check this.
pub version: u16,
pub body: EnvelopeBody,
}
impl DocApiEnvelope {
pub fn op(operation: Operation) -> Self {
Self { version: ENVELOPE_VERSION, body: EnvelopeBody::Op(operation) }
}
pub fn queries(queries: Vec<Query>) -> Self {
Self { version: ENVELOPE_VERSION, body: EnvelopeBody::Queries(queries) }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum EnvelopeBody {
/// Exactly ONE write op (write batching removed, plan §13). Atomic by itself.
Op(Operation),
/// One-or-more read-only queries answered together (safe: no mutation/undo).
Queries(Vec<Query>),
}
/// The outcome of an applied write op.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum OpOutcome {
/// A new entity was created; carries its fresh `ObjectId`.
NewId(ObjectId),
/// An existing entity was updated in place (same `ObjectId` preserved).
Updated(ObjectId),
/// Entities were deleted; carries the deleted ids (one for `Delete`, N for `DeleteMany`).
Deleted(Vec<ObjectId>),
/// Many entities were created (bulk `CreateMany`); carries all fresh ids in order.
NewIds(Vec<ObjectId>),
}
impl OpOutcome {
/// The single fresh id, for ops that create exactly one entity.
pub fn new_id(&self) -> Option<ObjectId> {
match self {
OpOutcome::NewId(id) => Some(*id),
_ => None,
}
}
/// All fresh ids, for bulk creates.
pub fn new_ids(&self) -> &[ObjectId] {
match self {
OpOutcome::NewIds(ids) => ids,
OpOutcome::NewId(id) => std::slice::from_ref(id),
_ => &[],
}
}
}
/// The per-request result (and the query-batch answer carrier).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Receipt {
/// Present for `EnvelopeBody::Op`; absent for pure queries.
pub outcome: Option<OpOutcome>,
/// One `QueryResult` per `Query` in a `Queries` batch (in order).
pub query_results: Vec<QueryResult>,
/// The geometry revision after this request (== before, for pure queries).
pub new_revision: GeometryRevision,
}
+59
View File
@@ -0,0 +1,59 @@
//! Structured error model (plan §2 decision #9). Serializable so IPC returns the
//! same error the in-process executor produced.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::id::ObjectId;
/// Which kind of kernel failure occurred (a mapped `cadkernel` `Snag`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GeometryErrorKind {
/// The operation produced an empty / degenerate body.
Empty,
/// Boolean refused (coincident geometry, unsupported cut, …).
BooleanFailed,
/// Invalid input to a kernel constructor (e.g. non-positive radius).
InvalidInput,
/// ACIS lift/parse/lower failed.
Acis,
/// Any other kernel error (message carries detail).
Other,
}
/// The single error type for the whole API. `commit`/ops return
/// `Result<_, ApiError>`; on a failed op nothing is applied and no undo step is
/// recorded (per-op atomicity).
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ApiError {
/// Input failed validation before any mutation (names the op + reason).
#[error("validation failed on {op}: {reason}")]
Validation { op: String, reason: String },
/// The geometry kernel reported a failure (a mapped `Snag`).
#[error("geometry kernel: {kind:?}: {msg}")]
Geometry { kind: GeometryErrorKind, msg: String },
/// An `ObjectId` does not resolve to a live entity (stale/deleted/never existed).
#[error("unknown ObjectId {0:?}")]
UnknownId(ObjectId),
/// The requested capability is not supported (e.g. over-cap bulk op, unimplemented family).
#[error("unsupported capability: {0}")]
Unsupported(String),
/// The transport (IPC / channel) failed — disconnected, oversized, timeout.
#[error("transport: {0}")]
Transport(String),
}
pub type ApiResult<T> = Result<T, ApiError>;
impl ApiError {
pub fn validation(op: &'static str, reason: impl Into<String>) -> Self {
ApiError::Validation { op: op.to_string(), reason: reason.into() }
}
pub fn geometry(kind: GeometryErrorKind, msg: impl Into<String>) -> Self {
ApiError::Geometry { kind, msg: msg.into() }
}
}
+268
View File
@@ -0,0 +1,268 @@
//! The op executor (plan §6, decision #11): crate-owned logic mapping each
//! `Operation`/`Query` to `DocApiBackend` calls. Both the in-process transport
//! and the host's IPC executor use this — one implementation, versioned in the
//! crate. Per-op atomicity: a write op validates its inputs and computes its
//! geometry BEFORE calling `push_undo`, mutates, then `finalize_op` once.
use crate::backend::DocApiBackend;
use crate::envelope::{OpOutcome, Receipt};
use crate::error::{ApiError, ApiResult};
use crate::gen::{Operation, Query};
use crate::id::ObjectId;
use crate::ops::{BoolOp, EntitySpec, BULK_ITEM_CAP};
use crate::query::QueryResult;
/// Apply ONE write op atomically. On success: one undo step + one revision bump.
/// On failure: nothing applied, no undo, no bump (the op validates before mutating).
pub fn apply_op<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt> {
let name = op.op_name();
let outcome = match &op {
Operation::CreateSolid(prim) => {
let body = crate::geom::make_solid(prim)?;
b.push_undo(name);
let id = b.store_solid(&body)?;
b.finalize_op();
OpOutcome::NewId(id)
}
Operation::CreateCurve(spec) => {
b.push_undo(name);
let id = b.add_curve(spec)?;
b.finalize_op();
OpOutcome::NewId(id)
}
Operation::Extrude { profile, direction } => {
let curves = profile_curves(b, *profile, name)?;
let body = crate::geom::extrude(&curves, *direction)?;
b.push_undo(name);
let id = b.store_solid(&body)?;
b.finalize_op();
OpOutcome::NewId(id)
}
Operation::Revolve { profile, axis, angle } => {
let curves = profile_curves(b, *profile, name)?;
let (pivot, axis_dir) = *axis;
let body = crate::geom::revolve(&curves, pivot, axis_dir, *angle)?;
b.push_undo(name);
let id = b.store_solid(&body)?;
b.finalize_op();
OpOutcome::NewId(id)
}
Operation::Transform { id, placement } => {
require_exists(b, *id, name)?;
b.push_undo(name);
b.transform_entity(*id, placement)?;
b.finalize_op();
OpOutcome::Updated(*id)
}
Operation::SolidBoolean { op: bop, a, b: bid, erase_sources } => {
// Resolve + combine BEFORE push_undo: pure computation, no mutation.
let ba = b.resolve_body(*a).map_err(|_| ApiError::UnknownId(*a))?;
let bb = b.resolve_body(*bid).map_err(|_| ApiError::UnknownId(*bid))?;
let result = crate::geom::boolean(&ba, &bb, *bop)?;
// Validate the erase BEFORE mutating: if `b` cannot be removed (e.g.
// locked layer), fail now rather than after `update_solid(a)` has
// already committed a partial mutation (plan review: erase path).
if *erase_sources {
b.can_remove(*bid)?;
}
b.push_undo(name);
// `erase_sources`: the result lives at `a` (updated in place), `b` erased.
// Otherwise store a fresh solid and leave both inputs untouched.
let out_id = if *erase_sources {
b.update_solid(*a, &result)?;
b.remove_entity(*bid)?;
*a
} else {
b.store_solid(&result)?
};
b.finalize_op();
OpOutcome::NewId(out_id)
}
Operation::AddVertex { id, at, point } => {
require_exists(b, *id, name)?;
b.push_undo(name);
b.add_vertex(*id, *at, *point)?;
b.finalize_op();
OpOutcome::Updated(*id)
}
Operation::Delete { id } => {
// can_remove validates existence AND removability (locked layer) before
// any mutation, so a locked entity errors cleanly instead of a no-op
// reported as Deleted (plan review: erase path).
b.can_remove(*id).map_err(|e| match e {
ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")),
other => other,
})?;
b.push_undo(name);
b.remove_entity(*id)?;
b.finalize_op();
OpOutcome::Deleted(vec![*id])
}
Operation::CreateMany(specs) => {
if specs.len() > BULK_ITEM_CAP {
return Err(over_cap(name, specs.len()));
}
// Upfront validation: compute/validate EVERY payload before any insert
// (all-or-nothing without rollback, plan §5.3).
let mut prepared = Vec::with_capacity(specs.len());
for (i, spec) in specs.iter().enumerate() {
let prep = match spec {
EntitySpec::Solid(p) => {
Prepared::Solid(crate::geom::make_solid(p).map_err(|e| at_index(name, i, e))?)
}
EntitySpec::Curve(c) => Prepared::Curve(c.clone()),
};
prepared.push(prep);
}
b.push_undo(name);
let mut ids = Vec::with_capacity(prepared.len());
for prep in prepared {
let id = match prep {
Prepared::Solid(body) => b.store_solid(&body)?,
Prepared::Curve(c) => b.add_curve(&c)?,
};
ids.push(id);
}
b.finalize_op();
OpOutcome::NewIds(ids)
}
Operation::TransformMany { ids, placement } => {
if ids.len() > BULK_ITEM_CAP {
return Err(over_cap(name, ids.len()));
}
// Pre-validate EVERYTHING fallible before push_undo (all-or-nothing,
// plan §5.3): every id must exist AND be transformable, so the apply
// loop below cannot fail part-way. `transform_entity` after this is
// infallible-by-construction for the validated ids.
for (i, id) in ids.iter().enumerate() {
b.ensure_transformable(*id).map_err(|e| match e {
ApiError::UnknownId(_) => stale_index(name, i, *id),
other => match other {
ApiError::Unsupported(msg) => ApiError::Validation {
op: name.to_string(),
reason: format!("index {i}: {msg}"),
},
o => o,
},
})?;
}
b.push_undo(name);
for id in ids {
b.transform_entity(*id, placement)?;
}
b.finalize_op();
OpOutcome::Updated(*ids.first().unwrap_or(&ObjectId::NULL))
}
Operation::DeleteMany(ids) => {
if ids.len() > BULK_ITEM_CAP {
return Err(over_cap(name, ids.len()));
}
// Pre-validate existence AND removability (locked layer) for every id
// before push_undo, so the apply loop cannot fail part-way.
for (i, id) in ids.iter().enumerate() {
b.can_remove(*id).map_err(|e| match e {
ApiError::UnknownId(_) => stale_index(name, i, *id),
ApiError::Unsupported(msg) => ApiError::Validation {
op: name.to_string(),
reason: format!("index {i}: {msg}"),
},
other => other,
})?;
}
b.push_undo(name);
for id in ids {
b.remove_entity(*id)?;
}
b.finalize_op();
OpOutcome::Deleted(ids.clone())
}
};
Ok(Receipt {
outcome: Some(outcome),
query_results: Vec::new(),
new_revision: b.revision(),
})
}
enum Prepared {
Solid(crate::backend::KernelBody),
Curve(crate::ops::Curve2Spec),
}
/// Apply a batch of read-only queries (no undo, no bump; `&mut` because solid
/// resolution may populate the kernel body cache on miss). Bounded like the bulk
/// ops: a batch over `BULK_ITEM_CAP` is rejected before any work (the 64 MiB frame
/// cap bounds message size, not count).
pub fn apply_queries<B: DocApiBackend>(b: &mut B, queries: Vec<Query>) -> ApiResult<Receipt> {
if queries.len() > BULK_ITEM_CAP {
return Err(ApiError::validation(
"Queries",
format!("query batch over cap: {} > {}", queries.len(), BULK_ITEM_CAP),
));
}
let mut results = Vec::with_capacity(queries.len());
for q in &queries {
let r = match *q {
Query::GetEntity { id } => QueryResult::Entity(b.get_entity(id)?),
Query::GetBounds { id } => QueryResult::Bounds(b.bounds(id)?),
Query::GetCentroid { id } => QueryResult::Centroid(b.centroid(id)?),
Query::GetVolume { id } => QueryResult::Volume(b.volume(id)?),
Query::GetIntersects { a, b: bid } => {
let ba = b.bounds(a)?;
let bb = b.bounds(bid)?;
QueryResult::Intersects(ba.overlaps(&bb))
}
Query::GetGeometryRevision => QueryResult::Revision(b.revision()),
};
results.push(r);
}
Ok(Receipt {
outcome: None,
query_results: results,
new_revision: b.revision(),
})
}
fn profile_curves<B: DocApiBackend>(
b: &B,
profile: ObjectId,
op: &'static str,
) -> ApiResult<Vec<cadkernel::geom2d::Curve>> {
if !b.entity_exists(profile) {
return Err(ApiError::validation(op, format!("unknown ObjectId {profile:?}")));
}
b.profile_curves(profile)
}
fn require_exists<B: DocApiBackend>(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> {
if b.entity_exists(id) {
Ok(())
} else {
Err(ApiError::validation(op, format!("unknown ObjectId {id:?}")))
}
}
fn over_cap(op: &'static str, n: usize) -> ApiError {
ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}"))
}
fn stale_index(op: &'static str, i: usize, id: ObjectId) -> ApiError {
ApiError::validation(op, format!("stale ObjectId at index {i}: {id:?}"))
}
fn at_index(op: &'static str, i: usize, e: ApiError) -> ApiError {
match e {
ApiError::Validation { reason, .. } => ApiError::validation(op, format!("index {i}: {reason}")),
other => other,
}
}
/// Boolean op kind → kernel `Operation` (host feature only).
#[cfg(feature = "host")]
pub(crate) fn kernel_bool_op(op: BoolOp) -> cadkernel::brep::Operation {
match op {
BoolOp::Union => cadkernel::brep::Operation::Union,
BoolOp::Intersection => cadkernel::brep::Operation::Intersection,
BoolOp::Difference => cadkernel::brep::Operation::Difference,
}
}
+490
View File
@@ -0,0 +1,490 @@
//! The object-centric facade (plan §8.1, decision #12).
//!
//! Hierarchy: [`DocApi`] (root: session + transport) → [`Document`] →
//! collections ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`])
//! → typed handles ([`Solid`], [`Line`], [`Circle`], [`Polyline`], [`Point`],
//! [`Entity`]) carrying methods. Every handle = `{ ObjectId, Arc<Session> }` —
//! `Clone`, `Send`, `Sync`; it never holds kernel geometry. Per-op autocommit:
//! each method is ONE atomic host call = one undo step (no transactions).
use std::sync::Arc;
use crate::envelope::{DocApiEnvelope, Receipt};
use crate::error::{ApiError, ApiResult};
use crate::gen::{Operation, Query};
use crate::id::ObjectId;
use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive};
use crate::query::{Aabb, EntityView, QueryResult};
use crate::revision::GeometryRevision;
use crate::transport::Transport;
/// Shared session: the transport. Held by every handle. The transport already
/// carries the tab binding (IPC) or the backend (in-process), so the session
/// stores no per-tab field.
#[derive(Clone)]
pub struct Session {
transport: Arc<dyn Transport>,
}
impl Session {
fn apply_op(&self, op: Operation) -> ApiResult<Receipt> {
self.transport.apply(DocApiEnvelope::op(op))
}
fn apply_queries(&self, queries: Vec<Query>) -> ApiResult<Receipt> {
self.transport.apply(DocApiEnvelope::queries(queries))
}
fn one_query(&self, q: Query) -> ApiResult<QueryResult> {
let mut r = self.apply_queries(vec![q])?;
let result = r.query_results.drain(..).next();
result.ok_or_else(|| ApiError::Transport("empty query result".into()))
}
}
/// The API root: owns the session (transport + active tab). Entry point.
#[derive(Clone)]
pub struct DocApi {
transport: Arc<dyn Transport>,
active_tab: u64,
}
impl DocApi {
/// Connect over an existing transport (IPC or in-process) bound to `active_tab`.
pub fn connect(transport: Arc<dyn Transport>, active_tab: u64) -> Self {
Self { transport, active_tab }
}
/// Connect in-process over a backend (host feature).
#[cfg(feature = "host")]
pub fn in_process<B: crate::backend::DocApiBackend + Send + 'static>(
backend: B,
active_tab: u64,
) -> Self {
Self::connect(Arc::new(crate::transport::InProcess::new(backend)), active_tab)
}
/// The active tab id this root is bound to.
pub fn active_tab(&self) -> u64 {
self.active_tab
}
/// Liveness probe of the host/transport.
pub fn alive(&self) -> bool {
self.transport.alive()
}
/// Bind a `Document` to `tab`. In v1 the transport is already bound to the
/// active tab (IPC carries `tab_id`; in-process carries the backend), so the
/// argument is accepted for API forward-compat but not stored.
pub fn document(&self, _tab: u64) -> Document {
Document {
session: Session { transport: Arc::clone(&self.transport) },
}
}
// Host console passthroughs (UX feedback; map to PushInfo/PushError on the host).
pub fn push_info(&self, msg: &str) {
let _ = msg; // host console passthrough is a host-side concern; facade is silent.
}
pub fn push_error(&self, msg: &str) {
let _ = msg;
}
}
/// One open document tab: factory + lookup + queries + read-guards.
#[derive(Clone)]
pub struct Document {
session: Session,
}
impl Document {
pub fn solids(&self) -> SolidCollection {
SolidCollection { session: self.session.clone() }
}
pub fn curves(&self) -> CurveCollection {
CurveCollection { session: self.session.clone() }
}
pub fn entities(&self) -> EntityCollection {
EntityCollection { session: self.session.clone() }
}
/// Current geometry revision (query, no bump).
pub fn revision(&self) -> ApiResult<GeometryRevision> {
match self.session.one_query(Query::GetGeometryRevision)? {
QueryResult::Revision(r) => Ok(r),
_ => Err(ApiError::Transport("unexpected revision result".into())),
}
}
/// Standalone read-guard: fail with `ApiError::Validation` if the document's
/// revision moved past `expected` (read-modify-write guard; NOT a batch pre-condition).
pub fn assert_revision(&self, expected: GeometryRevision) -> ApiResult<()> {
let now = self.revision()?;
if now == expected {
Ok(())
} else {
Err(ApiError::Validation {
op: "assert_revision".to_string(),
reason: format!("revision moved: expected {expected:?}, now {now:?}; re-read and retry"),
})
}
}
/// Batch of read-only queries in ONE round-trip (safe: no mutation/undo).
/// The closure records queries on a [`QueryBatch`]; the results are returned
/// in the same order as a [`QueryResults`] view the caller destructures.
pub fn query_batch<F: FnOnce(&mut QueryBatch)>(&self, f: F) -> ApiResult<QueryResults> {
let mut qb = QueryBatch { queries: Vec::new() };
f(&mut qb);
let receipt = self.session.apply_queries(qb.queries)?;
Ok(QueryResults { results: receipt.query_results })
}
}
/// Records queries for [`Document::query_batch`]. Each method appends one query.
pub struct QueryBatch {
queries: Vec<Query>,
}
impl QueryBatch {
pub fn bounds(&mut self, e: &impl HasId) {
self.queries.push(Query::GetBounds { id: e.id() });
}
pub fn volume(&mut self, e: &impl HasId) {
self.queries.push(Query::GetVolume { id: e.id() });
}
pub fn centroid(&mut self, e: &impl HasId) {
self.queries.push(Query::GetCentroid { id: e.id() });
}
pub fn intersects(&mut self, a: &impl HasId, b: &impl HasId) {
self.queries.push(Query::GetIntersects { a: a.id(), b: b.id() });
}
pub fn revision(&mut self) {
self.queries.push(Query::GetGeometryRevision);
}
}
/// Ordered results of a [`Document::query_batch`], one per recorded query.
pub struct QueryResults {
results: Vec<QueryResult>,
}
impl QueryResults {
fn at(&self, i: usize) -> ApiResult<&QueryResult> {
self.results.get(i).ok_or_else(|| ApiError::Transport("query result missing".into()))
}
pub fn bounds(&self, i: usize) -> ApiResult<Aabb> {
match self.at(i)? {
QueryResult::Bounds(b) => Ok(*b),
_ => Err(ApiError::Transport("result is not bounds".into())),
}
}
pub fn volume(&self, i: usize) -> ApiResult<f64> {
match self.at(i)? {
QueryResult::Volume(v) => Ok(*v),
_ => Err(ApiError::Transport("result is not volume".into())),
}
}
pub fn centroid(&self, i: usize) -> ApiResult<[f64; 3]> {
match self.at(i)? {
QueryResult::Centroid(c) => Ok(*c),
_ => Err(ApiError::Transport("result is not centroid".into())),
}
}
pub fn intersects(&self, i: usize) -> ApiResult<bool> {
match self.at(i)? {
QueryResult::Intersects(x) => Ok(*x),
_ => Err(ApiError::Transport("result is not intersects".into())),
}
}
pub fn revision(&self, i: usize) -> ApiResult<GeometryRevision> {
match self.at(i)? {
QueryResult::Revision(r) => Ok(*r),
_ => Err(ApiError::Transport("result is not revision".into())),
}
}
}
/// Something with an `ObjectId` (all handles + raw ids).
pub trait HasId {
fn id(&self) -> ObjectId;
}
impl HasId for ObjectId {
fn id(&self) -> ObjectId {
*self
}
}
// ── Typed handles ────────────────────────────────────────────────────────────
macro_rules! handle {
($name:ident) => {
#[derive(Clone)]
pub struct $name {
session: Session,
id: ObjectId,
}
impl std::fmt::Debug for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, concat!(stringify!($name), "({:?})"), self.id)
}
}
impl $name {
fn new(session: Session, id: ObjectId) -> Self {
Self { session, id }
}
}
impl HasId for $name {
fn id(&self) -> ObjectId {
self.id
}
}
impl $name {
pub fn bounds(&self) -> ApiResult<Aabb> {
match self.session.one_query(Query::GetBounds { id: self.id })? {
QueryResult::Bounds(b) => Ok(b),
_ => Err(ApiError::Transport("unexpected bounds result".into())),
}
}
pub fn delete(&self) -> ApiResult<()> {
self.session.apply_op(Operation::Delete { id: self.id })?;
Ok(())
}
pub fn transform(&self, placement: PlacementSpec) -> ApiResult<()> {
self.session.apply_op(Operation::Transform { id: self.id, placement })?;
Ok(())
}
}
};
}
handle!(Entity);
handle!(Solid);
handle!(Line);
handle!(Circle);
handle!(Polyline);
handle!(Point);
impl Entity {
pub fn view(&self) -> ApiResult<EntityView> {
match self.session.one_query(Query::GetEntity { id: self.id })? {
QueryResult::Entity(v) => Ok(v),
_ => Err(ApiError::Transport("unexpected entity result".into())),
}
}
pub fn as_solid(&self) -> Option<Solid> {
// Typed downcast is validated by the view's kind.
matches!(self.view().ok()?.kind.as_str(), "Solid3D").then(|| Solid::new(self.session.clone(), self.id))
}
}
impl Solid {
fn boolean(&self, op: BoolOp, other: &Solid) -> ApiResult<Solid> {
let receipt = self.session.apply_op(Operation::SolidBoolean {
op,
a: self.id,
b: other.id,
erase_sources: true,
})?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("boolean returned no id".into()))?;
Ok(Solid::new(self.session.clone(), id))
}
pub fn intersect(&self, other: &Solid) -> ApiResult<Solid> {
self.boolean(BoolOp::Intersection, other)
}
pub fn union(&self, other: &Solid) -> ApiResult<Solid> {
self.boolean(BoolOp::Union, other)
}
pub fn subtract(&self, other: &Solid) -> ApiResult<Solid> {
self.boolean(BoolOp::Difference, other)
}
pub fn volume(&self) -> ApiResult<f64> {
match self.session.one_query(Query::GetVolume { id: self.id })? {
QueryResult::Volume(v) => Ok(v),
_ => Err(ApiError::Transport("unexpected volume result".into())),
}
}
pub fn centroid(&self) -> ApiResult<[f64; 3]> {
match self.session.one_query(Query::GetCentroid { id: self.id })? {
QueryResult::Centroid(c) => Ok(c),
_ => Err(ApiError::Transport("unexpected centroid result".into())),
}
}
pub fn intersects(&self, other: &Solid) -> ApiResult<bool> {
match self.session.one_query(Query::GetIntersects { a: self.id, b: other.id })? {
QueryResult::Intersects(x) => Ok(x),
_ => Err(ApiError::Transport("unexpected intersects result".into())),
}
}
}
impl Polyline {
pub fn add_vertex(&self, at: usize, point: [f64; 3]) -> ApiResult<()> {
self.session.apply_op(Operation::AddVertex { id: self.id, at, point })?;
Ok(())
}
}
// ── Collections (construction / lookup) ─────────────────────────────────────
#[derive(Clone)]
pub struct SolidCollection {
session: Session,
}
impl SolidCollection {
fn create(&self, prim: SolidPrimitive) -> ApiResult<Solid> {
let receipt = self.session.apply_op(Operation::CreateSolid(prim))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_solid returned no id".into()))?;
Ok(Solid::new(self.session.clone(), id))
}
pub fn create_cuboid(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult<Solid> {
self.create(SolidPrimitive::Cuboid { origin, size })
}
pub fn create_sphere(&self, centre: [f64; 3], radius: f64) -> ApiResult<Solid> {
self.create(SolidPrimitive::Sphere { centre, radius })
}
pub fn create_cylinder(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult<Solid> {
self.create(SolidPrimitive::Cylinder { base, radius, height })
}
pub fn create_cone(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult<Solid> {
self.create(SolidPrimitive::Cone { base, radius, height })
}
pub fn create_torus(&self, centre: [f64; 3], major_radius: f64, minor_radius: f64) -> ApiResult<Solid> {
self.create(SolidPrimitive::Torus { centre, major_radius, minor_radius })
}
pub fn create_wedge(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult<Solid> {
self.create(SolidPrimitive::Wedge { origin, size })
}
pub fn extrude(&self, profile: &impl HasId, direction: [f64; 3]) -> ApiResult<Solid> {
let receipt = self.session.apply_op(Operation::Extrude { profile: profile.id(), direction })?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("extrude returned no id".into()))?;
Ok(Solid::new(self.session.clone(), id))
}
pub fn revolve(&self, profile: &impl HasId, pivot: [f64; 3], axis: [f64; 3], angle: f64) -> ApiResult<Solid> {
let receipt = self.session.apply_op(Operation::Revolve {
profile: profile.id(),
axis: (pivot, axis),
angle,
})?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("revolve returned no id".into()))?;
Ok(Solid::new(self.session.clone(), id))
}
}
#[derive(Clone)]
pub struct CurveCollection {
session: Session,
}
impl CurveCollection {
fn create_curve(&self, spec: Curve2Spec) -> ApiResult<(Session, ObjectId)> {
let receipt = self.session.apply_op(Operation::CreateCurve(spec))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_curve returned no id".into()))?;
Ok((self.session.clone(), id))
}
pub fn create_line(&self, start: [f64; 3], end: [f64; 3]) -> ApiResult<Line> {
let (s, id) = self.create_curve(Curve2Spec::Line { start, end })?;
Ok(Line::new(s, id))
}
pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult<Circle> {
let (s, id) = self.create_curve(Curve2Spec::Circle { centre, radius })?;
Ok(Circle::new(s, id))
}
pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult<Polyline> {
let (s, id) = self.create_curve(Curve2Spec::Polyline { points: points.to_vec(), closed })?;
Ok(Polyline::new(s, id))
}
pub fn create_point(&self, position: [f64; 3]) -> ApiResult<Point> {
let (s, id) = self.create_curve(Curve2Spec::Point { position })?;
Ok(Point::new(s, id))
}
/// Bulk-create many points in ONE op (plan §5.3): all-or-nothing, one undo step.
pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult<Vec<Point>> {
let specs: Vec<crate::ops::EntitySpec> = positions
.iter()
.map(|&p| crate::ops::EntitySpec::Curve(Curve2Spec::Point { position: p }))
.collect();
let receipt = self.session.apply_op(Operation::CreateMany(specs))?;
let outcome = receipt.outcome.ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?;
Ok(outcome
.new_ids()
.iter()
.map(|&id| Point::new(self.session.clone(), id))
.collect())
}
}
#[derive(Clone)]
pub struct EntityCollection {
session: Session,
}
impl EntityCollection {
/// Generic lookup by id (any family) → `Entity` (downcast via `as_solid()`, …).
pub fn get(&self, id: ObjectId) -> ApiResult<Entity> {
// Validate existence eagerly so `get` errors surface as `UnknownId`.
self.session.one_query(Query::GetEntity { id })?;
Ok(Entity::new(self.session.clone(), id))
}
pub fn delete(&self, id: ObjectId) -> ApiResult<()> {
self.session.apply_op(Operation::Delete { id })?;
Ok(())
}
/// Bulk transform (one op, all-or-nothing).
pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> {
self.session.apply_op(Operation::TransformMany { ids: ids.to_vec(), placement })?;
Ok(())
}
/// Bulk delete (one op, all-or-nothing). Used by `OpGroup::compensate`.
pub fn delete_many(&self, ids: &[ObjectId]) -> ApiResult<()> {
self.session.apply_op(Operation::DeleteMany(ids.to_vec()))?;
Ok(())
}
}
// ── OpGroup: client-side failure-cleanup (NOT a transaction) ────────────────
/// Tracks handles created during a logical operation so a later failure can be
/// cleaned up (best-effort, sequential delete ops — plan §8.1). No atomicity,
/// no host involvement, no rollback.
#[derive(Default)]
pub struct OpGroup {
created: Vec<ObjectId>,
}
impl OpGroup {
pub fn new() -> Self {
Self { created: Vec::new() }
}
/// Record a created handle (propagating any construction error); returns it
/// for fluent `grp.track(doc.solids().create_cuboid(..)?)?` use.
pub fn track<T: HasId>(&mut self, handle: ApiResult<T>) -> ApiResult<T> {
let handle = handle?;
self.created.push(handle.id());
Ok(handle)
}
/// Keep everything: drop the tracking without deleting.
pub fn commit(self) {}
/// Best-effort cleanup: delete every tracked entity (one `DeleteMany` op).
/// Does not resurrect already-consumed/deleted entities (plan §13 note).
pub fn compensate(self, doc: &Document) -> ApiResult<()> {
if self.created.is_empty() {
return Ok(());
}
doc.entities().delete_many(&self.created)
}
}
+115
View File
@@ -0,0 +1,115 @@
# ocs_doc_api — API reference (auto-generated)
Envelope version: `1`
Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`.
## `Solid` (acadrust `Solid3D`, collection `solids`)
B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT).
### Constructors (`doc.solids()`)
- **create_cuboid**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid`
- **create_sphere**(`centre: [f64; 3], radius: f64`) -> `Solid`
- **create_cylinder**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid`
- **create_cone**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid`
- **create_torus**(`centre: [f64; 3], major_radius: f64, minor_radius: f64`) -> `Solid`
- **create_wedge**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid`
- **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`)
- **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`)
### Methods
- **intersect**(`other: Solid`) -> `Solid` — op `SolidBoolean`
- **union**(`other: Solid`) -> `Solid` — op `SolidBoolean`
- **subtract**(`other: Solid`) -> `Solid` — op `SolidBoolean`
- **volume**(``) -> `f64` — query `GetVolume`
- **centroid**(``) -> `[f64; 3]` — query `GetCentroid`
- **intersects**(`other: Solid`) -> `bool` — query `GetIntersects`
## `Line` (acadrust `Line`, collection `curves`)
### Constructors (`doc.curves()`)
- **create_line**(`start: [f64; 3], end: [f64; 3]`) -> `Line`
## `Circle` (acadrust `Circle`, collection `curves`)
### Constructors (`doc.curves()`)
- **create_circle**(`centre: [f64; 3], radius: f64`) -> `Circle`
## `Polyline` (acadrust `LwPolyline`, collection `curves`)
### Constructors (`doc.curves()`)
- **create_polyline**(`points: &[[f64; 3]], closed: bool`) -> `Polyline`
### Methods
- **add_vertex**(`at: usize, point: [f64; 3]`) -> `()` — op `AddVertex` — **not yet supported** (returns `ApiError::Unsupported`)
## `Point` (acadrust `Point`, collection `curves`)
### Constructors (`doc.curves()`)
- **create_point**(`position: [f64; 3]`) -> `Point`
- **create_points**(`positions: &[[f64; 3]]`) -> `Vec<Point>` *(bulk op)*
## Generic methods (every handle)
Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a
`Copy`, `Send`, `Sync` token `{ ObjectId, session }` and supports:
- **`id()` -> `ObjectId`** — the stable entity identity.
- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities
computed; later families may be `Unsupported`).
- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1:
solids only.**
- **`delete()`** — remove the entity (one undo step).
`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed
downcast when `kind == "Solid3D"`).
## Collections & cross-cutting
- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`.
- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup,
`revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only
batch, one round-trip, **no revision bump**).
- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`,
`transform_many(&ids, placement)`, `delete_many(&ids)`.
- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` /
`compensate`). **Not** a transaction: no atomicity, no rollback.
## Bulk ops — single op, all-or-nothing, item-capped
`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo
step, one publish, and **all-or-nothing via upfront validation** (no rollback).
Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for
bulk data (N ≳ a few hundred); per-op autocommit is the right tool for
modeling-scale work.
## Semantics
- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host
call = one undo step. Multi-op write transactions were removed (security/
integrity review).
- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a
failed op applies nothing and records no undo step. `erase_sources` booleans run
only on success.
- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported,
Transport}`; bulk validation names the failing index; a stale handle returns
`UnknownId` (never a panic).
## Kernel mapping
| op | kernel call | returns |
|---|---|---|
| CreateSolid.Cuboid | `cadkernel::brep::make::cuboid` | Option<Body> |
| CreateSolid.Sphere | `cadkernel::brep::make::sphere` | Option<Body> |
| SolidBoolean | `cadkernel::brep::combine` | Result<Body, Snag> |
| Transform | `cadkernel::brep::transform` | Option<Body> |
| GetBounds | `cadkernel::brep::body_bounds` | Option<Aabb> |
| GetVolume | `cadkernel::brep::mesh_body + divergence` | computed |
@@ -0,0 +1,210 @@
{
"envelope_version": 1,
"families": [
{
"acadrust_variant": "Solid3D",
"collection": "solids",
"handle": "Solid",
"methods": [
{
"args": [
{
"name": "other",
"ty": "Solid"
}
],
"bulk": false,
"fixed": {
"erase_sources": true,
"op": "Intersection"
},
"kind": "op",
"name": "intersect",
"op": "SolidBoolean",
"query": null,
"read_only": false,
"returns": "Solid"
},
{
"args": [
{
"name": "other",
"ty": "Solid"
}
],
"bulk": false,
"fixed": {
"erase_sources": true,
"op": "Union"
},
"kind": "op",
"name": "union",
"op": "SolidBoolean",
"query": null,
"read_only": false,
"returns": "Solid"
},
{
"args": [
{
"name": "other",
"ty": "Solid"
}
],
"bulk": false,
"fixed": {
"erase_sources": true,
"op": "Difference"
},
"kind": "op",
"name": "subtract",
"op": "SolidBoolean",
"query": null,
"read_only": false,
"returns": "Solid"
},
{
"args": [],
"bulk": false,
"fixed": null,
"kind": "query",
"name": "volume",
"op": null,
"query": "GetVolume",
"read_only": true,
"returns": "f64"
},
{
"args": [],
"bulk": false,
"fixed": null,
"kind": "query",
"name": "centroid",
"op": null,
"query": "GetCentroid",
"read_only": true,
"returns": "[f64; 3]"
},
{
"args": [
{
"name": "other",
"ty": "Solid"
}
],
"bulk": false,
"fixed": null,
"kind": "query",
"name": "intersects",
"op": null,
"query": "GetIntersects",
"read_only": true,
"returns": "bool"
}
],
"name": "Solid"
},
{
"acadrust_variant": "Line",
"collection": "curves",
"handle": "Line",
"methods": [],
"name": "Line"
},
{
"acadrust_variant": "Circle",
"collection": "curves",
"handle": "Circle",
"methods": [],
"name": "Circle"
},
{
"acadrust_variant": "LwPolyline",
"collection": "curves",
"handle": "Polyline",
"methods": [
{
"args": [
{
"name": "at",
"ty": "usize"
},
{
"name": "point",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": null,
"kind": "op",
"name": "add_vertex",
"op": "AddVertex",
"query": null,
"read_only": false,
"returns": "()"
}
],
"name": "Polyline"
},
{
"acadrust_variant": "Point",
"collection": "curves",
"handle": "Point",
"methods": [],
"name": "Point"
}
],
"layouts": {
"ObjectId": {
"struct": [
{
"name": "0",
"type": "u64"
}
]
},
"Operation": {
"enum": [
"CreateSolid(SolidPrimitive)",
"CreateCurve(Curve2Spec)",
"Extrude { profile: ObjectId, direction: [f64; 3] }",
"Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }",
"Transform { id: ObjectId, placement: PlacementSpec }",
"SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }",
"AddVertex { id: ObjectId, at: usize, point: [f64; 3] }",
"Delete { id: ObjectId }",
"CreateMany(Vec<EntitySpec>)",
"TransformMany { ids: Vec<ObjectId>, placement: PlacementSpec }",
"DeleteMany(Vec<ObjectId>)"
]
},
"Query": {
"enum": [
"GetEntity { id: ObjectId }",
"GetBounds { id: ObjectId }",
"GetCentroid { id: ObjectId }",
"GetVolume { id: ObjectId }",
"GetIntersects { a: ObjectId, b: ObjectId }",
"GetGeometryRevision"
]
}
},
"object_model": {
"collections": [
"solids",
"curves",
"entities"
],
"document": "Document",
"handles": [
"Entity",
"Solid",
"Line",
"Circle",
"Polyline",
"Point"
],
"root": "DocApi"
},
"schema": "ocs_doc_api/binding_schema"
}
+28
View File
@@ -0,0 +1,28 @@
{
"ObjectId": { "struct": [ { "name": "0", "type": "u64" } ] },
"Operation": {
"enum": [
"CreateSolid(SolidPrimitive)",
"CreateCurve(Curve2Spec)",
"Extrude { profile: ObjectId, direction: [f64; 3] }",
"Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }",
"Transform { id: ObjectId, placement: PlacementSpec }",
"SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }",
"AddVertex { id: ObjectId, at: usize, point: [f64; 3] }",
"Delete { id: ObjectId }",
"CreateMany(Vec<EntitySpec>)",
"TransformMany { ids: Vec<ObjectId>, placement: PlacementSpec }",
"DeleteMany(Vec<ObjectId>)"
]
},
"Query": {
"enum": [
"GetEntity { id: ObjectId }",
"GetBounds { id: ObjectId }",
"GetCentroid { id: ObjectId }",
"GetVolume { id: ObjectId }",
"GetIntersects { a: ObjectId, b: ObjectId }",
"GetGeometryRevision"
]
}
}
+30
View File
@@ -0,0 +1,30 @@
// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand.
// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI
// fails on drift (`--check`). Append new variants at the END only (bincode
// discriminant stability, plan §7).
use serde::{Deserialize, Serialize};
use crate::id::ObjectId;
use crate::ops::{BoolOp, Curve2Spec, EntitySpec, PlacementSpec, SolidPrimitive};
/// A typed write operation (plan §5). Each variant is ONE atomic host call = one
/// undo step. Append new variants at the END only.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Operation {
// construction
CreateSolid(SolidPrimitive),
CreateCurve(Curve2Spec),
Extrude { profile: ObjectId, direction: [f64; 3] },
Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 },
// modification
Transform { id: ObjectId, placement: PlacementSpec },
SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool },
AddVertex { id: ObjectId, at: usize, point: [f64; 3] },
Delete { id: ObjectId },
// Bulk ops (single op, upfront-validation all-or-nothing — NOT a transaction, §5.3)
CreateMany(Vec<EntitySpec>),
TransformMany { ids: Vec<ObjectId>, placement: PlacementSpec },
DeleteMany(Vec<ObjectId>),
}
+22
View File
@@ -0,0 +1,22 @@
// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand.
// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI
// fails on drift (`--check`). Append new variants at the END only (bincode
// discriminant stability, plan §7).
use serde::{Deserialize, Serialize};
use crate::id::ObjectId;
/// A typed read query (plan §5). Read-only: no mutation, no undo, no revision
/// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Query {
GetEntity { id: ObjectId },
GetBounds { id: ObjectId },
GetCentroid { id: ObjectId },
GetVolume { id: ObjectId },
/// Whether two entities' bounds overlap (two-phase conditional modelling).
GetIntersects { a: ObjectId, b: ObjectId },
GetGeometryRevision,
}
+58
View File
@@ -0,0 +1,58 @@
//! Kernel geometry helpers (host feature, plan §10 code-mapping table). Thin
//! wrappers over `cadkernel::brep::*` mapping the crate's plain-data DTOs to the
//! non-serde kernel types. Kernel keys never cross the API (decision #7).
use cadkernel::geom2d::Curve as Curve2;
use cadkernel::space::Plane;
use crate::backend::KernelBody;
use crate::error::{ApiError, ApiResult, GeometryErrorKind};
use crate::ops::{BoolOp, SolidPrimitive};
fn geom_err(kind: GeometryErrorKind, msg: impl Into<String>) -> ApiError {
ApiError::geometry(kind, msg)
}
/// The XY work plane (origin at 0, X=(1,0,0), normal=(0,0,1)).
fn xy_plane() -> ApiResult<Plane> {
Plane::orthonormal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0])
.ok_or_else(|| geom_err(GeometryErrorKind::Other, "failed to construct XY plane"))
}
/// Construct a solid primitive (plan mapping table).
pub(crate) fn make_solid(p: &SolidPrimitive) -> ApiResult<KernelBody> {
use cadkernel::brep::make;
let body = match *p {
SolidPrimitive::Cuboid { origin, size } => make::cuboid(origin, size),
SolidPrimitive::Sphere { centre, radius } => make::sphere(centre, radius),
SolidPrimitive::Cylinder { base, radius, height } => make::cylinder(base, radius, height),
SolidPrimitive::Cone { base, radius, height } => make::cone(base, radius, height),
SolidPrimitive::Torus { centre, major_radius, minor_radius } => {
make::torus(centre, major_radius, minor_radius)
}
SolidPrimitive::Wedge { origin, size } => make::wedge(origin, size[0], size[1], size[2]),
};
body.ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "kernel make returned empty body"))
}
/// Boolean combine (pure): `brep::combine(a, b, op, operation_tolerance(&[&a,&b]))`.
/// Maps the kernel `Snag` to `ApiError::Geometry`. Does NOT mutate the inputs.
pub(crate) fn boolean(a: &KernelBody, b: &KernelBody, op: BoolOp) -> ApiResult<KernelBody> {
let kernel_op = crate::executor::kernel_bool_op(op);
let tol = cadkernel::brep::operation_tolerance(&[a, b]);
cadkernel::brep::combine(a.clone(), b.clone(), kernel_op, tol)
.map_err(|e| geom_err(GeometryErrorKind::BooleanFailed, format!("{e:?}")))
}
/// Extrude a closed 2D profile into a solid (`brep::extrude`).
pub(crate) fn extrude(profile: &[Curve2], direction: [f64; 3]) -> ApiResult<KernelBody> {
cadkernel::brep::extrude(xy_plane()?, profile, direction)
.ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "extrude failed"))
}
/// Revolve a closed 2D profile about an axis into a solid (`brep::revolve`).
pub(crate) fn revolve(profile: &[Curve2], pivot: [f64; 3], axis: [f64; 3], angle: f64) -> ApiResult<KernelBody> {
cadkernel::brep::revolve(xy_plane()?, profile, pivot, axis, angle)
.ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "revolve failed"))
}
+40
View File
@@ -0,0 +1,40 @@
//! Stable entity identity (plan §2 decision #7).
use serde::{Deserialize, Serialize};
/// A stable u64 handle to a first-level document entity, decoupled from any
/// internal engine/kernel pointer. Value-compatible with `acadrust::Handle` —
/// kernel keys never cross the API boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ObjectId(pub u64);
impl ObjectId {
/// The null / absent handle (`acadrust::Handle::NULL` value `0`).
pub const NULL: ObjectId = ObjectId(0);
pub const fn as_u64(self) -> u64 {
self.0
}
pub const fn is_null(self) -> bool {
self.0 == 0
}
/// Build from a raw u64 (e.g. an `acadrust::Handle`'s value).
pub const fn from_u64(v: u64) -> Self {
Self(v)
}
/// Build from an `acadrust::Handle` (host/ipc feature).
#[cfg(any(feature = "host", feature = "ipc", test))]
pub fn from_handle(h: acadrust::Handle) -> Self {
Self(h.value())
}
/// Convert to an `acadrust::Handle` (host/ipc feature).
#[cfg(any(feature = "host", feature = "ipc", test))]
pub fn to_handle(self) -> acadrust::Handle {
acadrust::Handle::new(self.0)
}
}
+66
View File
@@ -0,0 +1,66 @@
//! `ocs_doc_api` — Object-oriented, transport-agnostic CAD entity API for
//! OpenCADStudio plugins and hosts (v2).
//!
//! Core vocabulary: **Entity**, **ObjectId** (u64), **Operation** (typed write),
//! **Query** (typed read), **Transport** (the only plugin↔host boundary).
//! Every write op is one atomic host call = one undo step (no multi-op write
//! transactions — removed after the security/integrity review; see the plan §13).
//!
//! The facade is object-centric: [`DocApi`] (root) → [`Document`] → collections
//! ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`]) → typed
//! handles ([`Solid`], [`Line`], …) carrying geometric methods. The same typed
//! requests run in-process and over IPC; only the [`Transport`] differs.
pub mod error;
pub mod id;
pub mod revision;
#[cfg(feature = "host")]
pub mod backend;
#[cfg(feature = "host")]
pub mod executor;
#[cfg(feature = "host")]
pub mod geom;
pub mod envelope;
pub mod facade;
pub mod ops;
pub mod query;
pub mod transport;
// Generated (build.rs) — committed snapshots under src/gen/; do NOT edit by hand.
pub mod gen {
pub mod ops {
include!("gen/ops_gen.rs");
}
pub mod query {
include!("gen/query_gen.rs");
}
pub use ops::Operation;
pub use query::Query;
}
pub use error::{ApiError, ApiResult, GeometryErrorKind};
pub use id::ObjectId;
pub use revision::GeometryRevision;
pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt};
pub use facade::{
CurveCollection, DocApi, Document, Entity, EntityCollection, HasId, Line, OpGroup, Point,
Polyline, QueryBatch, QueryResults, Solid, SolidCollection,
};
pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive};
pub use query::{Aabb, EntityView, Query, QueryResult};
pub use transport::Transport;
/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`).
/// Bump when the envelope layout or `Operation`/`Query` discriminants change.
/// Bridges must check this before speaking (see `bindings/README.md`).
pub const ENVELOPE_VERSION: u16 = 1;
/// The generated binding handover schema (plan §10.2): object model + method
/// signatures + op/query mapping with traced wire layouts inlined. Single,
/// self-contained, versioned with [`ENVELOPE_VERSION`].
pub fn binding_schema_json() -> &'static str {
include_str!("gen/binding_schema.json")
}
+97
View File
@@ -0,0 +1,97 @@
//! Write operations — the typed, transport-agnostic vocabulary for changing the
//! document (plan §5). The `Operation` enum itself is GENERATED into
//! `crate::gen` by `build.rs` from `spec/entities.toml`; this module re-exports
//! it and holds the hand-written payload mirrors.
use serde::{Deserialize, Serialize};
pub use crate::gen::Operation;
/// Boolean operation kind for [`Operation::SolidBoolean`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BoolOp {
Union,
Intersection,
Difference,
}
/// Construction spec for a B-rep solid primitive (plain-data mirror of the
/// non-serde `cadkernel::brep::make::*` arguments).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum SolidPrimitive {
Cuboid { origin: [f64; 3], size: [f64; 3] },
Sphere { centre: [f64; 3], radius: f64 },
Cylinder { base: [f64; 3], radius: f64, height: f64 },
Cone { base: [f64; 3], radius: f64, height: f64 },
Torus { centre: [f64; 3], major_radius: f64, minor_radius: f64 },
Wedge { origin: [f64; 3], size: [f64; 3] },
}
/// Construction spec for a minimal 2D curve entity (phase-1 set; profiles/inputs).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Curve2Spec {
Line { start: [f64; 3], end: [f64; 3] },
Circle { centre: [f64; 3], radius: f64 },
Polyline { points: Vec<[f64; 3]>, closed: bool },
Point { position: [f64; 3] },
}
/// A generic entity-construction payload used by the bulk op
/// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction
/// specs so one bulk op can carry many homogeneous (or mixed) creations.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum EntitySpec {
Curve(Curve2Spec),
Solid(SolidPrimitive),
}
/// A rigid similarity (rotation + translation + optional uniform scale/reflection)
/// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement`
/// (non-serde). Columns are the new basis axes; `origin` is the translation.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PlacementSpec {
pub x_axis: [f64; 3],
pub y_axis: [f64; 3],
pub z_axis: [f64; 3],
pub origin: [f64; 3],
}
impl PlacementSpec {
/// Identity placement (no transform).
pub const IDENTITY: PlacementSpec = PlacementSpec {
x_axis: [1.0, 0.0, 0.0],
y_axis: [0.0, 1.0, 0.0],
z_axis: [0.0, 0.0, 1.0],
origin: [0.0, 0.0, 0.0],
};
/// Pure translation to `origin` (axes unchanged).
pub fn at(origin: [f64; 3]) -> Self {
Self { origin, ..Self::IDENTITY }
}
}
/// The item-count cap for a single bulk op envelope (plan §5.3): bounds
/// host-side execution time and payload. Over-cap → `ApiError::Validation`.
pub const BULK_ITEM_CAP: usize = 100_000;
/// Convenience accessors used by the facade/executor.
impl crate::gen::Operation {
/// The op name used in `ApiError::Validation { op, .. }`.
pub fn op_name(&self) -> &'static str {
use crate::gen::Operation::*;
match self {
CreateSolid(_) => "CreateSolid",
CreateCurve(_) => "CreateCurve",
Extrude { .. } => "Extrude",
Revolve { .. } => "Revolve",
Transform { .. } => "Transform",
SolidBoolean { .. } => "SolidBoolean",
AddVertex { .. } => "AddVertex",
Delete { .. } => "Delete",
CreateMany(_) => "CreateMany",
TransformMany { .. } => "TransformMany",
DeleteMany(_) => "DeleteMany",
}
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Read queries — typed, transport-agnostic reads of entity state (plan §5).
//! The `Query` enum is GENERATED into `crate::gen` by `build.rs`; this module
//! re-exports it and holds the hand-written result DTOs.
use serde::{Deserialize, Serialize};
use crate::id::ObjectId;
use crate::revision::GeometryRevision;
pub use crate::gen::Query;
/// Axis-aligned bounding box (plain-data mirror of the kernel's non-serde `Aabb`).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Aabb {
pub min: [f64; 3],
pub max: [f64; 3],
}
impl Aabb {
pub fn overlaps(&self, other: &Aabb) -> bool {
(0..3).all(|i| self.min[i] <= other.max[i] && other.min[i] <= self.max[i])
}
}
/// A generic, untyped view of an entity returned by [`Query::GetEntity`].
/// Family-specific data lives in the typed construction specs; this carries
/// identity + kind + a coarse bounding box so any first-level entity is readable.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EntityView {
pub id: ObjectId,
/// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line").
pub kind: String,
pub bounds: Option<Aabb>,
}
/// The result of a single [`Query`] (one per query in a `Queries` batch).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum QueryResult {
Entity(EntityView),
Bounds(Aabb),
Centroid([f64; 3]),
Volume(f64),
/// Whether two entities' bounds overlap (two-phase conditional modelling).
Intersects(bool),
Revision(GeometryRevision),
}
/// Convenience: the query name for diagnostics.
impl crate::gen::Query {
pub fn query_name(&self) -> &'static str {
use crate::gen::Query::*;
match self {
GetEntity { .. } => "GetEntity",
GetBounds { .. } => "GetBounds",
GetCentroid { .. } => "GetCentroid",
GetVolume { .. } => "GetVolume",
GetIntersects { .. } => "GetIntersects",
GetGeometryRevision => "GetGeometryRevision",
}
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Geometry revision counter (mirrors the host scene's `geometry_epoch`).
use serde::{Deserialize, Serialize};
/// Monotonic revision of a document's geometry. Bumped exactly once per applied
/// write op (`push_undo` + `finalize_op`). Pure queries return the current
/// revision without bumping it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct GeometryRevision(pub u64);
impl GeometryRevision {
pub const fn as_u64(self) -> u64 {
self.0
}
}
@@ -0,0 +1,42 @@
//! In-process transport (plan §7): drives a `DocApiBackend` directly, inline.
//! Used by in-tree/built-in plugins and host tests. Thread-safe via a mutex so
//! many handles across many threads share it (calls are serialized).
use std::sync::Mutex;
use crate::backend::DocApiBackend;
use crate::envelope::{DocApiEnvelope, EnvelopeBody, Receipt};
use crate::error::ApiResult;
use crate::executor;
use crate::transport::Transport;
/// In-process transport: holds the backend behind a mutex (reentrant `&self`).
pub struct InProcess<B: DocApiBackend> {
backend: Mutex<B>,
}
impl<B: DocApiBackend> InProcess<B> {
pub fn new(backend: B) -> Self {
Self { backend: Mutex::new(backend) }
}
/// Borrow the backend mutably (test/introspection helper).
pub fn backend_mut(&mut self) -> &mut B {
self.backend.get_mut().expect("in-process backend poisoned")
}
/// Borrow the backend immutably (test/introspection helper).
pub fn backend(&self) -> std::sync::MutexGuard<'_, B> {
self.backend.lock().expect("in-process backend poisoned")
}
}
impl<B: DocApiBackend + Send> Transport for InProcess<B> {
fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt> {
let mut backend = self.backend.lock().map_err(|_| {
crate::error::ApiError::Transport("in-process backend poisoned".into())
})?;
match req.body {
EnvelopeBody::Op(op) => executor::apply_op(&mut *backend, op),
EnvelopeBody::Queries(queries) => executor::apply_queries(&mut *backend, queries),
}
}
}
+55
View File
@@ -0,0 +1,55 @@
//! IPC transport (plan §7, decision #10): serializes `DocApiEnvelope` into the
//! append-only `PluginRequest::DocApiRequest { tab_id, bytes }` variant and ships
//! it through the plugin's `PluginRequestSender`. The host routes that variant to
//! the same crate executor (`ocs_doc_api::executor`) — one implementation.
use std::sync::Arc;
use ocs_plugin_api::host::{PluginRequestError, PluginRequestSender};
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
use crate::envelope::{DocApiEnvelope, Receipt};
use crate::error::{ApiError, ApiResult};
use crate::transport::Transport;
/// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`;
/// the underlying `PluginRequestSender` already serializes + correlates (V4).
pub struct OcsPluginApiIpc {
sender: Arc<dyn PluginRequestSender>,
tab_id: u64,
}
impl OcsPluginApiIpc {
pub fn new(sender: Arc<dyn PluginRequestSender>, tab_id: u64) -> Self {
Self { sender, tab_id }
}
fn transport_err(e: PluginRequestError) -> ApiError {
ApiError::Transport(e.0)
}
}
impl Transport for OcsPluginApiIpc {
fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt> {
let bytes = bincode::serialize(&req)
.map_err(|e| ApiError::Transport(format!("envelope serialize: {e}")))?;
let resp = self
.sender
.request(PluginRequest::DocApiRequest { tab_id: self.tab_id, bytes })
.map_err(Self::transport_err)?;
match resp {
PluginResponse::DocApiResponse { bytes } => {
// The host serializes `ApiResult<Receipt>` so op/query errors
// (validation, geometry, unknown id) surface as the SAME
// structured `ApiError` the in-process executor produced.
let result: ApiResult<Receipt> = bincode::deserialize(&bytes)
.map_err(|e| ApiError::Transport(format!("receipt deserialize: {e}")))?;
result
}
PluginResponse::Error(msg) => Err(ApiError::Transport(msg)),
other => Err(ApiError::Transport(format!(
"unexpected DocApi response variant: {other:?}"
))),
}
}
}
+36
View File
@@ -0,0 +1,36 @@
//! The transport boundary (plan §7) — the ONLY plugin↔host boundary.
//!
//! Portability contract (SPSC / flume / shared-memory safe): a transport impl
//! must (a) accept `&self` calls from many threads and serialize sends internally,
//! (b) demultiplex responses to the right caller by a correlation id, (c)
//! block-with-timeout, (d) expose liveness. This matches the existing V4 channel
//! shape, so swapping socket → shm changes only the impl, never the facade.
#[cfg(feature = "host")]
pub mod inproc;
#[cfg(feature = "ipc")]
pub mod ipc;
#[cfg(feature = "host")]
pub use inproc::InProcess;
#[cfg(feature = "ipc")]
pub use ipc::OcsPluginApiIpc;
use crate::envelope::{DocApiEnvelope, Receipt};
use crate::error::ApiResult;
/// The only plugin↔host boundary. Object-centric: one method per unit of work —
/// a single write op, or a query batch. `&self` + `Send + Sync` so many entity
/// handles across many threads share one transport concurrently.
pub trait Transport: Send + Sync {
/// Apply `req` (ONE write op, or a query batch) and return its `Receipt`.
/// A write request carries exactly one `Operation`; a read request carries
/// one-or-more `Query` items (read-only batching is safe). Blocks up to an
/// internal timeout. Reentrant: safe to call concurrently from many threads.
fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt>;
/// Cheap host-liveness probe (shm has no socket-disconnect to detect a dead peer).
fn alive(&self) -> bool {
true
}
}