forked from 1b-agent/opencadstudio-upstream
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:
1 parent
9129c85f97
commit
e41086b058
44 files changed
+4837
-12
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user