forked from 1b-agent/opencadstudio-upstream
488 lines
19 KiB
Rust
488 lines
19 KiB
Rust
//! The op executor: 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 and an advanced revision.
|
|
/// 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 result = apply_op_inner(b, op);
|
|
if result.is_err() {
|
|
b.cancel_op();
|
|
}
|
|
result
|
|
}
|
|
|
|
fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt> {
|
|
let name = op.op_name();
|
|
crate::validation::operation(&op)?;
|
|
if let Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) = &op {
|
|
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
|
if ids.is_empty() || unique.len() != ids.len() {
|
|
return Err(ApiError::validation(
|
|
name,
|
|
"batch ids must be nonempty and unique",
|
|
));
|
|
}
|
|
}
|
|
if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op
|
|
{
|
|
crate::geom::validate_placement(placement)?;
|
|
}
|
|
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(b.profile_plane(*profile)?, &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(b.profile_plane(*profile)?, &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.
|
|
if *erase_sources {
|
|
if a == bid {
|
|
return Err(ApiError::validation(name, "boolean source ids must differ"));
|
|
}
|
|
b.can_modify(*a)?;
|
|
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.
|
|
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()));
|
|
}
|
|
if specs.is_empty() {
|
|
return Err(ApiError::validation(name, "batch must not be empty"));
|
|
}
|
|
for spec in specs {
|
|
if let EntitySpec::Curve(curve) = spec {
|
|
crate::validation::curve(curve)?;
|
|
}
|
|
}
|
|
let ids = b.create_many(specs)?;
|
|
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,
|
|
// the bulk contract): 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.transform_many(ids, placement)?;
|
|
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())
|
|
}
|
|
Operation::CreateInsert(spec) => {
|
|
// Validate the block name exists before committing (no rollback needed).
|
|
b.push_undo(name);
|
|
let id = b.add_insert(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateViewport(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_viewport(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateText(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_text(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateMText(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_mtext(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::SetTextContent { id, value } => {
|
|
b.can_modify(*id).map_err(|e| match e {
|
|
ApiError::UnknownId(_) => {
|
|
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
|
|
}
|
|
other => other,
|
|
})?;
|
|
b.push_undo(name);
|
|
b.set_text_content(*id, value)?;
|
|
b.finalize_op();
|
|
OpOutcome::Updated(*id)
|
|
}
|
|
Operation::CreateHatch(spec) => {
|
|
// Validate the boundary (>= 3 points) before committing.
|
|
if spec.boundary.len() < 3 {
|
|
return Err(ApiError::validation(
|
|
name,
|
|
"hatch boundary needs >= 3 points",
|
|
));
|
|
}
|
|
b.push_undo(name);
|
|
let id = b.add_hatch(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateDimensionLinear(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_dimension_linear(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::SetAttribute { id, tag, value } => {
|
|
b.can_modify(*id).map_err(|e| match e {
|
|
ApiError::UnknownId(_) => {
|
|
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
|
|
}
|
|
other => other,
|
|
})?;
|
|
b.push_undo(name);
|
|
b.set_attribute(*id, tag, value)?;
|
|
b.finalize_op();
|
|
OpOutcome::Updated(*id)
|
|
}
|
|
Operation::SetViewportView {
|
|
id,
|
|
view_target,
|
|
view_height,
|
|
} => {
|
|
b.can_modify(*id).map_err(|e| match e {
|
|
ApiError::UnknownId(_) => {
|
|
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
|
|
}
|
|
other => other,
|
|
})?;
|
|
b.push_undo(name);
|
|
b.set_viewport_view(*id, *view_target, *view_height)?;
|
|
b.finalize_op();
|
|
OpOutcome::Updated(*id)
|
|
}
|
|
Operation::CreateRasterImage(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_raster_image(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::Loft { profiles } => {
|
|
// Bound the section count like the bulk ops (one envelope can carry many
|
|
// ids; an uncapped loft would be an unbounded kernel call on the UI thread).
|
|
if profiles.len() > BULK_ITEM_CAP {
|
|
return Err(over_cap(name, profiles.len()));
|
|
}
|
|
// Resolve every profile to its curve set BEFORE push_undo (all-or-nothing).
|
|
if profiles.len() < 2 {
|
|
return Err(ApiError::validation(name, "loft needs >= 2 profiles"));
|
|
}
|
|
let mut sections = Vec::with_capacity(profiles.len());
|
|
for (i, p) in profiles.iter().enumerate() {
|
|
let curves = profile_curves(b, *p, name).map_err(|e| at_index(name, i, e))?;
|
|
sections.push((b.profile_plane(*p)?, curves));
|
|
}
|
|
b.push_undo(name);
|
|
let id = b.loft(§ions)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateDimensionRadius(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_dimension_radius(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateDimensionDiameter(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_dimension_diameter(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateDimensionAngular(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_dimension_angular(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateAttributeDefinition(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_attribute_definition(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateTable(spec) => {
|
|
// Validate a non-empty, rectangular grid before committing.
|
|
let cols = spec.data.first().map(|r| r.len()).unwrap_or(0);
|
|
if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) {
|
|
return Err(ApiError::validation(
|
|
name,
|
|
"table needs a non-empty rectangular grid",
|
|
));
|
|
}
|
|
b.push_undo(name);
|
|
let id = b.add_table(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
Operation::CreateDimensionAngular2Ln(spec) => {
|
|
b.push_undo(name);
|
|
let id = b.add_dimension_angular2ln(spec)?;
|
|
b.finalize_op();
|
|
OpOutcome::NewId(id)
|
|
}
|
|
};
|
|
Ok(Receipt {
|
|
outcome: Some(outcome),
|
|
query_results: Vec::new(),
|
|
new_revision: b.revision(),
|
|
})
|
|
}
|
|
|
|
/// 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 qname = q.query_name();
|
|
let r = match q {
|
|
Query::GetEntity { id } => {
|
|
QueryResult::Entity(b.get_entity(*id).map_err(|e| label(qname, e))?)
|
|
}
|
|
Query::GetBounds { id } => {
|
|
QueryResult::Bounds(b.bounds(*id).map_err(|e| label(qname, e))?)
|
|
}
|
|
Query::GetCentroid { id } => {
|
|
QueryResult::Centroid(b.centroid(*id).map_err(|e| label(qname, e))?)
|
|
}
|
|
Query::GetVolume { id } => {
|
|
QueryResult::Volume(b.volume(*id).map_err(|e| label(qname, e))?)
|
|
}
|
|
Query::GetIntersects { a, b: bid } => {
|
|
let ba = b.bounds(*a).map_err(|e| label(qname, e))?;
|
|
let bb = b.bounds(*bid).map_err(|e| label(qname, e))?;
|
|
QueryResult::Intersects(ba.overlaps(&bb))
|
|
}
|
|
Query::GetGeometryRevision => QueryResult::Revision(b.revision()),
|
|
Query::GetTextContent { id } => {
|
|
QueryResult::TextContent(b.text_content(*id).map_err(|e| label(qname, e))?)
|
|
}
|
|
Query::GetHatchBoundary { id } => {
|
|
QueryResult::HatchBoundary(b.hatch_boundary(*id).map_err(|e| label(qname, e))?)
|
|
}
|
|
Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(
|
|
b.dimension_measurement(*id).map_err(|e| label(qname, e))?,
|
|
),
|
|
Query::GetAttributes { id } => {
|
|
QueryResult::Attributes(b.attributes(*id).map_err(|e| label(qname, e))?)
|
|
}
|
|
Query::GetBlockEntities { block_name } => QueryResult::BlockEntities(
|
|
b.block_entities(block_name).map_err(|e| label(qname, e))?,
|
|
),
|
|
Query::GetViewportView { id } => {
|
|
let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?;
|
|
QueryResult::ViewportView { target, height }
|
|
}
|
|
};
|
|
results.push(r);
|
|
}
|
|
Ok(Receipt {
|
|
outcome: None,
|
|
query_results: results,
|
|
new_revision: b.revision(),
|
|
})
|
|
}
|
|
|
|
/// Attach the query name to an error for better diagnostics (uses `query_name`).
|
|
fn label(query: &'static str, e: ApiError) -> ApiError {
|
|
match e {
|
|
ApiError::Validation { reason, .. } => ApiError::validation(query, reason),
|
|
ApiError::UnknownId(id) => ApiError::validation(query, format!("unknown ObjectId {id:?}")),
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// Validate that an entity exists before performing an operation.
|
|
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,
|
|
}
|
|
}
|