ocs_doc_api: layer CRUD, per-curve layer assignment, XDATA/XRECORD support, and bulk SetXDataMany

This commit is contained in:
Sebastian committed 2026-09-10 10:43:15 +02:00
1 parent b19992cbcb
commit 8bf5eac3fc
27 files changed
+3340 -181

No files matched your search

+84 -1
View File
@@ -5,7 +5,7 @@
use crate::error::{ApiError, ApiResult};
use crate::id::ObjectId;
use crate::ops::Curve2Spec;
use crate::ops::{Curve2Spec, LayerInfo};
use crate::query::{Aabb, EntityView};
use crate::revision::GeometryRevision;
@@ -152,6 +152,85 @@ pub trait DocApiBackend {
sections: &[(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)],
) -> ApiResult<ObjectId>;
/// Attach or replace an XDATA record for `application_name` on `id`.
/// `None` removes any existing record for that application.
fn set_xdata(
&mut self,
id: ObjectId,
application_name: &str,
record: Option<&crate::ops::XDataRecord>,
) -> ApiResult<()>;
/// Read the XDATA record for `application_name` on `id` (`None` if absent).
fn xdata(
&self,
id: ObjectId,
application_name: &str,
) -> ApiResult<Option<crate::ops::XDataRecord>>;
/// Create a standalone `XRECORD` object from a spec; returns the fresh `ObjectId`.
fn add_xrecord(&mut self, spec: &crate::ops::XRecordSpec) -> ApiResult<ObjectId>;
/// Replace an existing `XRECORD` object's payload in place.
fn set_xrecord(&mut self, id: ObjectId, spec: &crate::ops::XRecordSpec) -> ApiResult<()>;
/// Read an existing `XRECORD` object's payload (`None` if `id` is not an XRecord).
fn xrecord(&self, id: ObjectId) -> ApiResult<Option<crate::ops::XRecordSpec>>;
// ── layer CRUD ─────────────────────────────────────────────────────────
/// Create a new layer. Fails with `Validation` if the name already exists or is reserved.
fn create_layer(&mut self, info: &crate::ops::LayerInfo) -> ApiResult<()>;
/// Update an existing layer's properties by name. Fails with `Validation` if absent.
fn update_layer(&mut self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()>;
/// Delete a layer by name. Fails with `Validation` if the layer is "0",
/// the current layer, does not exist, or has entities.
fn delete_layer(&mut self, name: &str) -> ApiResult<()>;
/// Look up a layer by raw name, normalizing it. Default implementation scans
/// `layers()`; backends with O(1) lookup should override.
fn lookup_layer(&self, name: &str) -> ApiResult<LayerInfo> {
let norm = crate::layer::normalize_layer_name(name);
if norm.is_empty() {
return Err(ApiError::validation("LookupLayer", "empty layer name"));
}
self.layers()?
.into_iter()
.find(|l| l.normalized_name() == norm)
.ok_or_else(|| {
ApiError::validation(
"LookupLayer",
format!("layer '{name}' does not exist"),
)
})
}
/// Move an entity to a different layer. The target layer must exist.
fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()>;
/// List all layers in the document.
fn layers(&self) -> ApiResult<Vec<crate::ops::LayerInfo>>;
/// The layer name of an entity.
fn entity_layer(&self, id: ObjectId) -> ApiResult<String>;
/// Enumerate first-class entities, optionally filtered by kind and/or layer.
/// `include_bounds` controls whether `EntityView::bounds` is populated; skip it
/// for lightweight list views.
fn enumerate_entities(
&self,
kind: Option<&str>,
layer: Option<&str>,
include_bounds: bool,
) -> ApiResult<Vec<EntityView>>;
/// The WCS location of a Point entity.
fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]>;
/// The start and end WCS points of a Line entity.
fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])>;
/// Can `id` be modified in place right now (exists, is the expected family,
/// not on a locked layer)? Read-only pre-check used before mutations.
/// Default: existence + not-locked (backends narrow the family check).
@@ -178,6 +257,10 @@ pub trait DocApiBackend {
}
}
/// Whether an object (named-object / XRecord / dictionary) exists. Distinct
/// from `entity_exists`, which is for first-class entities only.
fn object_exists(&self, id: ObjectId) -> bool;
/// A generic, untyped view of any entity (id + kind + coarse bounds).
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView>;
+9 -6
View File
@@ -113,19 +113,19 @@ pub fn bulge_arc_segment(
pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
crate::validation::curve(spec)?;
let entity = match spec {
Curve2Spec::Line { start, end } => {
Curve2Spec::Line { start, end, .. } => {
let mut e = Line::new();
e.start = v3(*start);
e.end = v3(*end);
EntityType::Line(e)
}
Curve2Spec::Circle { centre, radius } => {
Curve2Spec::Circle { centre, radius, .. } => {
let mut e = Circle::new();
e.center = v3(*centre);
e.radius = *radius;
EntityType::Circle(e)
}
Curve2Spec::Polyline { points, closed } => {
Curve2Spec::Polyline { points, closed, .. } => {
let mut e = LwPolyline::new();
e.vertices = points
.iter()
@@ -147,7 +147,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
e.is_closed = *closed;
EntityType::LwPolyline(e)
}
Curve2Spec::Point { position } => {
Curve2Spec::Point { position, .. } => {
let mut e = Point::new();
e.location = v3(*position);
EntityType::Point(e)
@@ -157,6 +157,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
radius,
start_angle,
end_angle,
..
} => {
let mut e = Arc::new();
e.center = v3(*centre);
@@ -171,6 +172,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
ratio,
start,
end,
..
} => {
// Validate the minor/major ratio so the (major-axis) bounds never
// under-bound: ratio must be in (0, 1].
@@ -193,6 +195,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
control_points,
knots,
weights,
..
} => {
let mut e = Spline::new();
e.degree = *degree;
@@ -201,10 +204,10 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
e.weights = weights.clone();
EntityType::Spline(e)
}
Curve2Spec::Ray { origin, direction } => {
Curve2Spec::Ray { origin, direction, .. } => {
EntityType::Ray(Ray::new(v3(*origin), v3(*direction)))
}
Curve2Spec::XLine { origin, direction } => {
Curve2Spec::XLine { origin, direction, .. } => {
EntityType::XLine(XLine::new(v3(*origin), v3(*direction)))
}
};
+133 -7
View File
@@ -25,14 +25,35 @@ pub fn apply_op<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt
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",
));
match &op {
Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) => {
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",
));
}
}
Operation::SetXDataMany(records) => {
if records.is_empty() {
return Err(ApiError::validation(
name,
"SetXDataMany records must be nonempty",
));
}
if records.len() > BULK_ITEM_CAP {
return Err(over_cap(name, records.len()));
}
let unique: std::collections::HashSet<_> = records.iter().map(|(id, _, _)| id).collect();
if unique.len() != records.len() {
return Err(ApiError::validation(
name,
"SetXDataMany records must have unique entity ids",
));
}
}
_ => {}
}
if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op
{
@@ -353,6 +374,70 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
b.finalize_op();
OpOutcome::NewId(id)
}
Operation::CreateLayer(info) => {
b.push_undo(name);
b.create_layer(info)?;
b.finalize_op();
OpOutcome::Updated(ObjectId::NULL)
}
Operation::UpdateLayer { name, info } => {
b.push_undo(name);
b.update_layer(name, info)?;
b.finalize_op();
OpOutcome::Updated(ObjectId::NULL)
}
Operation::DeleteLayer { name } => {
b.push_undo(name);
b.delete_layer(name)?;
b.finalize_op();
OpOutcome::Updated(ObjectId::NULL)
}
Operation::SetEntityLayer { id, layer } => {
require_exists(b, *id, name)?;
b.push_undo(name);
b.set_entity_layer(*id, layer)?;
b.finalize_op();
OpOutcome::Updated(*id)
}
Operation::SetXData {
id,
application_name,
record,
} => {
require_exists(b, *id, name)?;
b.push_undo(name);
b.set_xdata(*id, application_name, record.as_ref())?;
b.finalize_op();
OpOutcome::Updated(*id)
}
Operation::SetXDataMany(records) => {
// Pre-validate every entity id before any mutation.
for (id, app, _) in records {
require_exists(b, *id, name)?;
if app.is_empty() {
return Err(ApiError::validation(name, "empty application_name"));
}
}
b.push_undo(name);
for (id, application_name, record) in records {
b.set_xdata(*id, application_name, record.as_ref())?;
}
b.finalize_op();
OpOutcome::Updated(ObjectId::NULL)
}
Operation::CreateXRecord(spec) => {
b.push_undo(name);
let id = b.add_xrecord(spec)?;
b.finalize_op();
OpOutcome::NewId(id)
}
Operation::SetXRecord { id, spec } => {
require_object_exists(b, *id, name)?;
b.push_undo(name);
b.set_xrecord(*id, spec)?;
b.finalize_op();
OpOutcome::Updated(*id)
}
};
Ok(Receipt {
outcome: Some(outcome),
@@ -417,6 +502,38 @@ pub fn apply_queries<B: DocApiBackend>(b: &mut B, queries: Vec<Query>) -> ApiRes
let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?;
QueryResult::ViewportView { target, height }
}
Query::GetXData {
id,
application_name,
} => QueryResult::XData(
b.xdata(*id, application_name)
.map_err(|e| label(qname, e))?
.clone(),
),
Query::GetXRecord { id } => {
QueryResult::XRecord(b.xrecord(*id).map_err(|e| label(qname, e))?.ok_or_else(
|| ApiError::validation(qname, format!("ObjectId {id:?} is not an XRecord")),
)?)
}
Query::ListLayers => QueryResult::Layers(b.layers().map_err(|e| label(qname, e))?),
Query::GetEntityLayer { id } => {
QueryResult::EntityLayer(b.entity_layer(*id).map_err(|e| label(qname, e))?)
}
Query::EnumerateEntities {
kind,
layer,
include_bounds,
} => QueryResult::Entities(
b.enumerate_entities(kind.as_deref(), layer.as_deref(), *include_bounds)
.map_err(|e| label(qname, e))?,
),
Query::GetPointPosition { id } => QueryResult::PointPosition(
b.point_position(*id).map_err(|e| label(qname, e))?,
),
Query::GetLineGeometry { id } => {
let (start, end) = b.line_geometry(*id).map_err(|e| label(qname, e))?;
QueryResult::LineGeometry((start, end))
}
};
results.push(r);
}
@@ -459,6 +576,15 @@ fn require_exists<B: DocApiBackend>(b: &B, id: ObjectId, op: &'static str) -> Ap
}
}
/// Validate that a named object exists before performing an operation.
fn require_object_exists<B: DocApiBackend>(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> {
if b.object_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}"))
}
+348 -7
View File
@@ -13,7 +13,7 @@ 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::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive, XDataRecord, XRecordSpec};
use crate::query::{Aabb, EntityView, QueryResult};
use crate::revision::GeometryRevision;
use crate::transport::Transport;
@@ -170,6 +170,74 @@ impl Document {
}
}
/// List all layers in the document.
pub fn layers(&self) -> ApiResult<Vec<crate::ops::LayerInfo>> {
match self.session.one_query(Query::ListLayers)? {
QueryResult::Layers(v) => Ok(v),
_ => Err(ApiError::Transport("unexpected layers result".into())),
}
}
/// Create a new layer. One undo step; fails if the normalized name already
/// exists or is empty.
pub fn create_layer(&self, info: &crate::ops::LayerInfo) -> ApiResult<()> {
if !crate::layer::is_valid_layer_name(&info.name) {
return Err(ApiError::validation(
"CreateLayer",
"empty layer name",
));
}
self.session
.apply_op(Operation::CreateLayer(info.clone()))?;
Ok(())
}
/// Update an existing layer's properties by name. One undo step; `info.name`
/// becomes the new display name (case-insensitive key unchanged).
pub fn update_layer(&self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()> {
if !crate::layer::is_valid_layer_name(&info.name) {
return Err(ApiError::validation(
"UpdateLayer",
"empty layer name",
));
}
self.session.apply_op(Operation::UpdateLayer {
name: name.to_string(),
info: info.clone(),
})?;
Ok(())
}
/// Delete a layer by name. One undo step; fails for layer "0", the current
/// layer, or any layer still referenced by entities.
pub fn delete_layer(&self, name: &str) -> ApiResult<()> {
self.session.apply_op(Operation::DeleteLayer {
name: name.to_string(),
})?;
Ok(())
}
/// Set XDATA on many entities in a single atomic operation.
///
/// Each tuple is `(entity_id, application_name, record)`. Pass `None` for
/// `record` to delete the application's data. Fails up-front if any id is
/// invalid; the whole batch is one undo step.
pub fn set_xdata_many<I, S>(&self, items: I) -> ApiResult<()>
where
I: IntoIterator<Item = (ObjectId, S, Option<XDataRecord>)>,
S: Into<String>,
{
let updates: Vec<_> = items
.into_iter()
.map(|(id, app, record)| (id, app.into(), record))
.collect();
if updates.is_empty() {
return Ok(());
}
self.session.apply_op(Operation::SetXDataMany(updates))?;
Ok(())
}
/// 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.
@@ -336,6 +404,25 @@ macro_rules! handle {
})?;
Ok(())
}
/// Return the entity's current layer name (normalized uppercase).
pub fn layer(&self) -> ApiResult<String> {
match self
.session
.one_query(Query::GetEntityLayer { id: self.id })?
{
QueryResult::EntityLayer(name) => Ok(name),
_ => Err(ApiError::Transport("unexpected entity layer result".into())),
}
}
/// Move the entity to a different layer. One undo step; fails if the
/// target layer does not exist or is locked.
pub fn set_layer(&self, layer: &str) -> ApiResult<()> {
self.session.apply_op(Operation::SetEntityLayer {
id: self.id,
layer: layer.to_string(),
})?;
Ok(())
}
}
};
}
@@ -354,6 +441,25 @@ handle!(XLine);
handle!(Text);
handle!(MText);
handle!(Dimension);
handle!(XRecord);
impl XRecord {
/// Read the XRECORD payload.
pub fn payload(&self) -> ApiResult<XRecordSpec> {
match self.session.one_query(Query::GetXRecord { id: self.id })? {
QueryResult::XRecord(spec) => Ok(spec),
_ => Err(ApiError::Transport("unexpected xrecord result".into())),
}
}
/// Replace the XRECORD payload in place (one undo step).
pub fn set_payload(&self, spec: &XRecordSpec) -> ApiResult<()> {
self.session.apply_op(Operation::SetXRecord {
id: self.id,
spec: spec.clone(),
})?;
Ok(())
}
}
impl Dimension {
/// The measured value of this dimension (distance for linear/radius, degrees
@@ -416,6 +522,26 @@ impl Entity {
})?;
Ok(())
}
/// The XDATA record for `application_name` on this entity (`None` if absent).
pub fn xdata(&self, application_name: &str) -> ApiResult<Option<XDataRecord>> {
match self.session.one_query(Query::GetXData {
id: self.id,
application_name: application_name.to_string(),
})? {
QueryResult::XData(v) => Ok(v),
_ => Err(ApiError::Transport("unexpected xdata result".into())),
}
}
/// Attach or replace an XDATA record for `application_name` on this entity.
/// Pass `None` to remove the record (one undo step).
pub fn set_xdata(&self, application_name: &str, record: Option<XDataRecord>) -> ApiResult<()> {
self.session.apply_op(Operation::SetXData {
id: self.id,
application_name: application_name.to_string(),
record,
})?;
Ok(())
}
pub fn as_solid(&self) -> Option<Solid> {
// Typed downcast is validated by the view's kind.
matches!(self.view().ok()?.kind.as_str(), "Solid3D")
@@ -621,22 +747,80 @@ impl CurveCollection {
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 })?;
let (s, id) = self.create_curve(Curve2Spec::Line {
start,
end,
layer: None,
})?;
Ok(Line::new(s, id))
}
pub fn create_line_on_layer(
&self,
start: [f64; 3],
end: [f64; 3],
layer: &str,
) -> ApiResult<Line> {
let (s, id) = self.create_curve(Curve2Spec::Line {
start,
end,
layer: Some(layer.to_string()),
})?;
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 })?;
let (s, id) = self.create_curve(Curve2Spec::Circle {
centre,
radius,
layer: None,
})?;
Ok(Circle::new(s, id))
}
pub fn create_circle_on_layer(
&self,
centre: [f64; 3],
radius: f64,
layer: &str,
) -> ApiResult<Circle> {
let (s, id) = self.create_curve(Curve2Spec::Circle {
centre,
radius,
layer: Some(layer.to_string()),
})?;
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,
layer: None,
})?;
Ok(Polyline::new(s, id))
}
pub fn create_polyline_on_layer(
&self,
points: &[[f64; 3]],
closed: bool,
layer: &str,
) -> ApiResult<Polyline> {
let (s, id) = self.create_curve(Curve2Spec::Polyline {
points: points.to_vec(),
closed,
layer: Some(layer.to_string()),
})?;
Ok(Polyline::new(s, id))
}
pub fn create_point(&self, position: [f64; 3]) -> ApiResult<Point> {
let (s, id) = self.create_curve(Curve2Spec::Point { position })?;
let (s, id) = self.create_curve(Curve2Spec::Point {
position,
layer: None,
})?;
Ok(Point::new(s, id))
}
pub fn create_point_on_layer(&self, position: [f64; 3], layer: &str) -> ApiResult<Point> {
let (s, id) = self.create_curve(Curve2Spec::Point {
position,
layer: Some(layer.to_string()),
})?;
Ok(Point::new(s, id))
}
pub fn create_arc(
@@ -651,6 +835,24 @@ impl CurveCollection {
radius,
start_angle,
end_angle,
layer: None,
})?;
Ok(ArcCurve::new(s, id))
}
pub fn create_arc_on_layer(
&self,
centre: [f64; 3],
radius: f64,
start_angle: f64,
end_angle: f64,
layer: &str,
) -> ApiResult<ArcCurve> {
let (s, id) = self.create_curve(Curve2Spec::Arc {
centre,
radius,
start_angle,
end_angle,
layer: Some(layer.to_string()),
})?;
Ok(ArcCurve::new(s, id))
}
@@ -668,6 +870,26 @@ impl CurveCollection {
ratio,
start,
end,
layer: None,
})?;
Ok(Ellipse::new(s, id))
}
pub fn create_ellipse_on_layer(
&self,
centre: [f64; 3],
major_axis: [f64; 3],
ratio: f64,
start: f64,
end: f64,
layer: &str,
) -> ApiResult<Ellipse> {
let (s, id) = self.create_curve(Curve2Spec::Ellipse {
centre,
major_axis,
ratio,
start,
end,
layer: Some(layer.to_string()),
})?;
Ok(Ellipse::new(s, id))
}
@@ -683,15 +905,67 @@ impl CurveCollection {
control_points: control_points.to_vec(),
knots: knots.to_vec(),
weights: weights.to_vec(),
layer: None,
})?;
Ok(Spline::new(s, id))
}
pub fn create_spline_on_layer(
&self,
degree: i32,
control_points: &[[f64; 3]],
knots: &[f64],
weights: &[f64],
layer: &str,
) -> ApiResult<Spline> {
let (s, id) = self.create_curve(Curve2Spec::Spline {
degree,
control_points: control_points.to_vec(),
knots: knots.to_vec(),
weights: weights.to_vec(),
layer: Some(layer.to_string()),
})?;
Ok(Spline::new(s, id))
}
pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult<Ray> {
let (s, id) = self.create_curve(Curve2Spec::Ray { origin, direction })?;
let (s, id) = self.create_curve(Curve2Spec::Ray {
origin,
direction,
layer: None,
})?;
Ok(Ray::new(s, id))
}
pub fn create_ray_on_layer(
&self,
origin: [f64; 3],
direction: [f64; 3],
layer: &str,
) -> ApiResult<Ray> {
let (s, id) = self.create_curve(Curve2Spec::Ray {
origin,
direction,
layer: Some(layer.to_string()),
})?;
Ok(Ray::new(s, id))
}
pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult<XLine> {
let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?;
let (s, id) = self.create_curve(Curve2Spec::XLine {
origin,
direction,
layer: None,
})?;
Ok(XLine::new(s, id))
}
pub fn create_xline_on_layer(
&self,
origin: [f64; 3],
direction: [f64; 3],
layer: &str,
) -> ApiResult<XLine> {
let (s, id) = self.create_curve(Curve2Spec::XLine {
origin,
direction,
layer: Some(layer.to_string()),
})?;
Ok(XLine::new(s, id))
}
/// A RASTER_IMAGE placed at `insertion_point` (host auto-registers the image definition).
@@ -874,10 +1148,42 @@ impl CurveCollection {
Ok(MText::new(self.session.clone(), id))
}
/// Bulk-create many points in ONE op: all-or-nothing, one undo step.
/// Places points on the current layer (`layer: None`).
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 }))
.map(|&p| {
crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point {
position: p,
layer: None,
})
})
.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())
}
/// Bulk-create many points on a specific layer in ONE op.
pub fn create_points_on_layer(
&self,
positions: &[[f64; 3]],
layer: &str,
) -> ApiResult<Vec<Point>> {
let specs: Vec<crate::ops::EntitySpec> = positions
.iter()
.map(|&p| {
crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point {
position: p,
layer: Some(layer.to_string()),
})
})
.collect();
let receipt = self.session.apply_op(Operation::CreateMany(specs))?;
let outcome = receipt
@@ -1001,6 +1307,41 @@ impl EntityCollection {
self.session.apply_op(Operation::Delete { id })?;
Ok(())
}
/// Create a standalone `XRECORD` object (returns an `Entity` handle).
pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult<Entity> {
let receipt = self
.session
.apply_op(Operation::CreateXRecord(spec.clone()))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_xrecord returned no id".into()))?;
Ok(Entity::new(self.session.clone(), id))
}
/// Lookup an `XRECORD` object by id and return a typed accessor handle.
pub fn get_xrecord(&self, id: ObjectId) -> ApiResult<XRecord> {
// Validate existence + that the payload is readable as an XRecord.
match self.session.one_query(Query::GetXRecord { id })? {
QueryResult::XRecord(_) => Ok(XRecord::new(self.session.clone(), id)),
_ => Err(ApiError::Transport("unexpected xrecord result".into())),
}
}
/// Bulk-create many entities in ONE op (all-or-nothing, one undo step).
pub fn create_many(&self, specs: &[crate::ops::EntitySpec]) -> ApiResult<Vec<Entity>> {
let receipt = self.session.apply_op(Operation::CreateMany(specs.to_vec()))?;
let outcome = receipt
.outcome
.ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?;
Ok(outcome
.new_ids()
.iter()
.map(|&id| Entity::new(self.session.clone(), id))
.collect())
}
/// Bulk transform (one op, all-or-nothing).
pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> {
self.session.apply_op(Operation::TransformMany {
@@ -192,6 +192,32 @@ Table: create_table(insertion_point, data[row][col]) builds rows x columns of te
- **create_table**(insertion_point: [f64; 3], data: &[Vec<String>]) -> `Entity` — `CreateTable`
## `XRecord` (acadrust `XRecord`, collection `entities`)
Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload.
### Constructors (`doc.entities()`)
- **create_xrecord**(spec: XRecordSpec) -> `Entity` — `CreateXRecord`
### Methods
- **payload**() -> `XRecordSpec` — query `GetXRecord`
- **set_payload**(spec: XRecordSpec) -> `()` — op `SetXRecord`
- **xdata**(application_name: &str) -> `Option<XDataRecord>` — query `GetXData`
- **set_xdata**(application_name: &str, record: Option<XDataRecord>) -> `()` — op `SetXData`
## `Layer` (acadrust `-`, collection `layers`)
Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers().
### Methods
- **list**() -> `Vec<LayerInfo>` — query `ListLayers`
- **create**(info: LayerInfo) -> `()` — op `CreateLayer`
- **update**(name: String, info: LayerInfo) -> `()` — op `UpdateLayer`
- **delete**(name: String) -> `()` — op `DeleteLayer`
## Generic methods (every handle)
Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`,
@@ -204,10 +230,32 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`,
- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1:
solids only.**
- **`delete()`** — remove the entity (one undo step).
- **`layer()` -> `String`** — the name of the layer the entity is on.
- **`set_layer(layer)`** — move the entity to an existing layer (one undo step;
blocked if the entity or target layer is locked).
`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed
downcast when `kind == "Solid3D"`).
## Layer table
Layers are a named document table, not an entity family. `Document::layers()`
lists all layers as `Vec<LayerInfo>`. Table-level ops are `CreateLayer(info)`,
`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()`
returns the layer name and `Entity::set_layer(layer)` moves the entity to an
existing layer. The host rejects duplicate names, deletion of layer "0" or the
current layer, and removing a layer that still has entities assigned.
## Transports
- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host.
- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the
`ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes
`DocApiRequest { tab_id, bytes }` to the same executor.
- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that
builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a
`PluginRequestSender` (out-of-process / worker-thread plugins).
## Collections & cross-cutting
- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`.
@@ -284,6 +332,14 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read-
- `CreateAttributeDefinition(AttributeDefinitionSpec)`
- `CreateTable(TableSpec)`
- `CreateDimensionAngular2Ln(DimensionAngularSpec)`
- `SetXData { id: ObjectId, application_name: String, record: Option<XDataRecord> }`
- `CreateXRecord(XRecordSpec)`
- `SetXRecord { id: ObjectId, spec: XRecordSpec }`
- `CreateLayer(LayerInfo)`
- `UpdateLayer { name: String, info: LayerInfo }`
- `DeleteLayer { name: String }`
- `SetEntityLayer { id: ObjectId, layer: String }`
- `SetXDataMany(Vec<(ObjectId, String, Option<XDataRecord)>)`
### `Query`
@@ -299,6 +355,10 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read-
- `GetAttributes { id: ObjectId }`
- `GetBlockEntities { block_name: String }`
- `GetViewportView { id: ObjectId }`
- `GetXData { id: ObjectId, application_name: String }`
- `GetXRecord { id: ObjectId }`
- `ListLayers`
- `GetEntityLayer { id: ObjectId }`
## Errors (`ApiError`)
+175 -3
View File
@@ -1124,6 +1124,164 @@
"handle": "Entity",
"methods": [],
"name": "Table"
},
{
"acadrust_variant": "XRecord",
"collection": "entities",
"constructors": [
{
"args": [
{
"name": "spec",
"ty": "XRecordSpec"
}
],
"bulk": false,
"fixed": null,
"name": "create_xrecord",
"op": "CreateXRecord",
"returns": "Entity"
}
],
"handle": "XRecord",
"methods": [
{
"args": [],
"bulk": false,
"fixed": null,
"kind": "query",
"name": "payload",
"op": null,
"query": "GetXRecord",
"read_only": true,
"returns": "XRecordSpec"
},
{
"args": [
{
"name": "spec",
"ty": "XRecordSpec"
}
],
"bulk": false,
"fixed": null,
"kind": "op",
"name": "set_payload",
"op": "SetXRecord",
"query": null,
"read_only": false,
"returns": "()"
},
{
"args": [
{
"name": "application_name",
"ty": "&str"
}
],
"bulk": false,
"fixed": null,
"kind": "query",
"name": "xdata",
"op": null,
"query": "GetXData",
"read_only": true,
"returns": "Option<XDataRecord>"
},
{
"args": [
{
"name": "application_name",
"ty": "&str"
},
{
"name": "record",
"ty": "Option<XDataRecord>"
}
],
"bulk": false,
"fixed": null,
"kind": "op",
"name": "set_xdata",
"op": "SetXData",
"query": null,
"read_only": false,
"returns": "()"
}
],
"name": "XRecord"
},
{
"acadrust_variant": null,
"collection": "layers",
"constructors": [],
"handle": "Layer",
"methods": [
{
"args": [],
"bulk": false,
"fixed": null,
"kind": "query",
"name": "list",
"op": null,
"query": "ListLayers",
"read_only": true,
"returns": "Vec<LayerInfo>"
},
{
"args": [
{
"name": "info",
"ty": "LayerInfo"
}
],
"bulk": false,
"fixed": null,
"kind": "op",
"name": "create",
"op": "CreateLayer",
"query": null,
"read_only": false,
"returns": "()"
},
{
"args": [
{
"name": "name",
"ty": "String"
},
{
"name": "info",
"ty": "LayerInfo"
}
],
"bulk": false,
"fixed": null,
"kind": "op",
"name": "update",
"op": "UpdateLayer",
"query": null,
"read_only": false,
"returns": "()"
},
{
"args": [
{
"name": "name",
"ty": "String"
}
],
"bulk": false,
"fixed": null,
"kind": "op",
"name": "delete",
"op": "DeleteLayer",
"query": null,
"read_only": false,
"returns": "()"
}
],
"name": "Layer"
}
],
"layouts": {
@@ -1164,7 +1322,15 @@
"CreateDimensionAngular(DimensionAngularSpec)",
"CreateAttributeDefinition(AttributeDefinitionSpec)",
"CreateTable(TableSpec)",
"CreateDimensionAngular2Ln(DimensionAngularSpec)"
"CreateDimensionAngular2Ln(DimensionAngularSpec)",
"SetXData { id: ObjectId, application_name: String, record: Option<XDataRecord> }",
"CreateXRecord(XRecordSpec)",
"SetXRecord { id: ObjectId, spec: XRecordSpec }",
"CreateLayer(LayerInfo)",
"UpdateLayer { name: String, info: LayerInfo }",
"DeleteLayer { name: String }",
"SetEntityLayer { id: ObjectId, layer: String }",
"SetXDataMany(Vec<(ObjectId, String, Option<XDataRecord)>)"
]
},
"Query": {
@@ -1180,7 +1346,11 @@
"GetDimensionMeasurement { id: ObjectId }",
"GetAttributes { id: ObjectId }",
"GetBlockEntities { block_name: String }",
"GetViewportView { id: ObjectId }"
"GetViewportView { id: ObjectId }",
"GetXData { id: ObjectId, application_name: String }",
"GetXRecord { id: ObjectId }",
"ListLayers",
"GetEntityLayer { id: ObjectId }"
]
}
},
@@ -1205,7 +1375,9 @@
"XLine",
"Text",
"MText",
"Dimension"
"Dimension",
"XRecord",
"Layer"
],
"root": "DocApi"
},
+14 -2
View File
@@ -29,7 +29,15 @@
"CreateDimensionAngular(DimensionAngularSpec)",
"CreateAttributeDefinition(AttributeDefinitionSpec)",
"CreateTable(TableSpec)",
"CreateDimensionAngular2Ln(DimensionAngularSpec)"
"CreateDimensionAngular2Ln(DimensionAngularSpec)",
"SetXData { id: ObjectId, application_name: String, record: Option<XDataRecord> }",
"CreateXRecord(XRecordSpec)",
"SetXRecord { id: ObjectId, spec: XRecordSpec }",
"CreateLayer(LayerInfo)",
"UpdateLayer { name: String, info: LayerInfo }",
"DeleteLayer { name: String }",
"SetEntityLayer { id: ObjectId, layer: String }",
"SetXDataMany(Vec<(ObjectId, String, Option<XDataRecord)>)"
]
},
"Query": {
@@ -45,7 +53,11 @@
"GetDimensionMeasurement { id: ObjectId }",
"GetAttributes { id: ObjectId }",
"GetBlockEntities { block_name: String }",
"GetViewportView { id: ObjectId }"
"GetViewportView { id: ObjectId }",
"GetXData { id: ObjectId, application_name: String }",
"GetXRecord { id: ObjectId }",
"ListLayers",
"GetEntityLayer { id: ObjectId }"
]
}
}
+26 -1
View File
@@ -10,7 +10,7 @@ use crate::id::ObjectId;
use crate::ops::{
AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec,
DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec,
SolidPrimitive, TableSpec, TextSpec, ViewportSpec,
SolidPrimitive, TableSpec, TextSpec, ViewportSpec, XDataRecord, XRecordSpec,
};
/// A typed write operation. Each variant is ONE atomic host call = one
@@ -99,4 +99,29 @@ pub enum Operation {
CreateTable(TableSpec),
// dimension sub-types (2-line angular)
CreateDimensionAngular2Ln(DimensionAngularSpec),
// xdata / xrecord roundtrip
SetXData {
id: ObjectId,
application_name: String,
record: Option<XDataRecord>,
},
SetXDataMany(Vec<(ObjectId, String, Option<XDataRecord>)>),
CreateXRecord(XRecordSpec),
SetXRecord {
id: ObjectId,
spec: XRecordSpec,
},
// layer CRUD
CreateLayer(crate::ops::LayerInfo),
UpdateLayer {
name: String,
info: crate::ops::LayerInfo,
},
DeleteLayer {
name: String,
},
SetEntityLayer {
id: ObjectId,
layer: String,
},
}
+31
View File
@@ -55,4 +55,35 @@ pub enum Query {
GetViewportView {
id: ObjectId,
},
/// The XDATA record for `application_name` attached to an entity (`None` if absent).
GetXData {
id: ObjectId,
application_name: String,
},
/// The XRECORD object payload by id.
GetXRecord {
id: ObjectId,
},
/// List all layers in the document.
ListLayers,
/// The layer name of an entity.
GetEntityLayer {
id: ObjectId,
},
/// Enumerate first-class entities, optionally filtered by kind and/or layer.
/// `include_bounds` populates `EntityView::bounds`; leave it false for a
/// lightweight list view.
EnumerateEntities {
kind: Option<String>,
layer: Option<String>,
include_bounds: bool,
},
/// The WCS location of a Point entity.
GetPointPosition {
id: ObjectId,
},
/// The start and end WCS points of a Line entity.
GetLineGeometry {
id: ObjectId,
},
}
+42
View File
@@ -0,0 +1,42 @@
//! Shared layer-name normalization rules.
//!
//! Layer names in OpenCADStudio/acadrust are case-insensitive, leading/trailing
//! whitespace is ignored, and empty names are invalid. The host (`normalize_name`
//! in `acadrust::tables`) implements the canonical rule set; this module re-exports
//! a compatible, dependency-free function for the wire side and for backends that
//! do not link against `acadrust` directly.
/// Normalize a raw layer name into its case-insensitive lookup key.
///
/// This mirrors `acadrust::tables::normalize_name` used by the host:
/// - trims leading and trailing ASCII whitespace
/// - converts the result to uppercase
/// - returns `""` if the name is empty after trimming
pub fn normalize_layer_name(name: &str) -> String {
name.trim().to_ascii_uppercase()
}
/// A normalized layer name is non-empty and matches the host lookup key.
pub fn is_valid_layer_name(name: &str) -> bool {
!normalize_layer_name(name).is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalization_trims_and_uppercases() {
assert_eq!(normalize_layer_name(" Walls "), "WALLS");
assert_eq!(normalize_layer_name("walls"), "WALLS");
assert_eq!(normalize_layer_name("WALLS"), "WALLS");
}
#[test]
fn empty_and_whitespace_names_are_invalid() {
assert_eq!(normalize_layer_name(""), "");
assert_eq!(normalize_layer_name(" "), "");
assert!(!is_valid_layer_name(""));
assert!(!is_valid_layer_name(" "));
}
}
+11 -2
View File
@@ -17,6 +17,7 @@ mod validation;
pub mod envelope;
pub mod facade;
pub mod layer;
pub mod ops;
pub mod query;
pub mod transport;
@@ -41,9 +42,14 @@ pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt};
pub use facade::{
ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity,
EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray,
Solid, SolidCollection, Spline, Text, XLine,
Solid, SolidCollection, Spline, Text, XLine, XRecord,
};
pub use layer::{is_valid_layer_name, normalize_layer_name};
pub use ops::{
BoolOp, Color, Curve2Spec, EntitySpec, LayerFlags, LayerInfo, LineWeight, Operation,
PlacementSpec, SolidPrimitive, XDataRecord, XDataValue, XRecordCloningFlags, XRecordEntry,
XRecordSpec, XRecordValue,
};
pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive};
pub use query::{Aabb, EntityView, Query, QueryResult};
#[cfg(feature = "host")]
@@ -51,6 +57,9 @@ pub use convert::{
bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name,
entity_to_profile_curves, transform_entity_geometry,
};
#[cfg(feature = "doc_api_host")]
pub use transport::ipc::doc_api_for_host;
pub use transport::Transport;
/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`).
+193
View File
@@ -8,6 +8,80 @@ use serde::{Deserialize, Serialize};
pub use crate::gen::Operation;
/// An XDATA record payload: one registered application name plus its typed
/// value list. This is a plain-data, serde-compatible mirror of
/// `acadrust::xdata::ExtendedDataRecord` so the wire DTO stays independent of
/// the host dependency.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct XDataRecord {
pub application_name: String,
pub values: Vec<XDataValue>,
}
/// A single XDATA value. Mirrors `acadrust::xdata::XDataValue`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum XDataValue {
String(String),
ControlString(String),
LayerName(String),
BinaryData(Vec<u8>),
Handle(u64),
Point3D([f64; 3]),
Position3D([f64; 3]),
Displacement3D([f64; 3]),
Direction3D([f64; 3]),
Real(f64),
Distance(f64),
ScaleFactor(f64),
Integer16(i16),
Integer32(i32),
}
/// Construction spec for an `XRECORD` object. Mirrors the acadrust
/// `XRecord` payload (name + cloning flags + group-code entries) in a
/// serde-compatible, host-independent form.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct XRecordSpec {
pub name: String,
pub cloning_flags: XRecordCloningFlags,
pub entries: Vec<XRecordEntry>,
}
/// XRecord cloning behavior flags. Mirror of
/// `acadrust::objects::DictionaryCloningFlags`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum XRecordCloningFlags {
#[default]
NotApplicable,
KeepExisting,
UseClone,
XrefName,
Name,
UnmangleName,
}
/// One XRECORD group-code entry.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct XRecordEntry {
pub code: i32,
pub value: XRecordValue,
}
/// A single XRECORD value. Mirrors `acadrust::objects::XRecordValue`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum XRecordValue {
String(String),
Double(f64),
Int16(i16),
Int32(i32),
Int64(i64),
Byte(u8),
Bool(bool),
Handle(u64),
Point3D([f64; 3]),
Chunk(Vec<u8>),
}
/// Boolean operation kind for [`Operation::SolidBoolean`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BoolOp {
@@ -16,6 +90,72 @@ pub enum BoolOp {
Difference,
}
/// A plain-data color value. Mirrors `acadrust::types::Color` for wire
/// independence from the host dependency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Color {
ByLayer,
None,
ByBlock,
Index(u8),
Rgb { r: u8, g: u8, b: u8 },
}
impl Color {
pub const WHITE: Color = Color::Index(7);
}
/// A plain-data line weight. Mirrors `acadrust::types::LineWeight`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum LineWeight {
#[default]
ByLayer,
ByBlock,
Default,
/// Specific line weight in 1/100 mm.
Value(i16),
}
/// Layer visibility/lock flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct LayerFlags {
pub frozen: bool,
pub locked: bool,
pub frozen_in_new_viewport: bool,
pub off: bool,
}
/// A plain-data layer table entry. Mirrors `acadrust::tables::Layer`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayerInfo {
pub name: String,
pub flags: LayerFlags,
pub color: Color,
pub line_type: String,
pub line_weight: LineWeight,
pub plot_style: String,
pub is_plottable: bool,
}
impl LayerInfo {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
flags: LayerFlags::default(),
color: Color::WHITE,
line_type: "Continuous".to_string(),
line_weight: LineWeight::Default,
plot_style: String::new(),
is_plottable: true,
}
}
/// The normalized (trimmed + uppercased) lookup key for this layer name.
pub fn normalized_name(&self) -> String {
crate::layer::normalize_layer_name(&self.name)
}
}
/// 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)]
@@ -55,17 +195,25 @@ pub enum Curve2Spec {
Line {
start: [f64; 3],
end: [f64; 3],
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
},
Circle {
centre: [f64; 3],
radius: f64,
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
},
Polyline {
points: Vec<[f64; 3]>,
closed: bool,
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
},
Point {
position: [f64; 3],
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
},
/// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians).
Arc {
@@ -73,6 +221,8 @@ pub enum Curve2Spec {
radius: f64,
start_angle: f64,
end_angle: f64,
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
},
/// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`;
/// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π).
@@ -82,6 +232,8 @@ pub enum Curve2Spec {
ratio: f64,
start: f64,
end: f64,
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
},
/// NURBS spline (control points + knots + weights; `degree` typically 3).
Spline {
@@ -89,19 +241,42 @@ pub enum Curve2Spec {
control_points: Vec<[f64; 3]>,
knots: Vec<f64>,
weights: Vec<f64>,
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
},
/// A ray from `origin` along `direction` (bounded at origin only).
Ray {
origin: [f64; 3],
direction: [f64; 3],
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
},
/// An infinite construction line through `origin` along `direction`.
XLine {
origin: [f64; 3],
direction: [f64; 3],
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
},
}
impl Curve2Spec {
/// The target layer name, if any.
pub fn layer(&self) -> Option<&str> {
match self {
Curve2Spec::Line { layer, .. }
| Curve2Spec::Circle { layer, .. }
| Curve2Spec::Polyline { layer, .. }
| Curve2Spec::Point { layer, .. }
| Curve2Spec::Arc { layer, .. }
| Curve2Spec::Ellipse { layer, .. }
| Curve2Spec::Spline { layer, .. }
| Curve2Spec::Ray { layer, .. }
| Curve2Spec::XLine { layer, .. } => layer.as_deref(),
}
}
}
/// 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.
@@ -111,6 +286,16 @@ pub enum EntitySpec {
Solid(SolidPrimitive),
}
impl EntitySpec {
/// The target layer name for this spec, if explicitly set.
pub fn layer(&self) -> Option<&str> {
match self {
EntitySpec::Curve(c) => c.layer(),
EntitySpec::Solid(_) => None,
}
}
}
/// Construction spec for a block reference (`INSERT`): place the block
/// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform
/// `scale` and `rotation` (radians, about Z).
@@ -291,6 +476,14 @@ impl crate::gen::Operation {
CreateAttributeDefinition(_) => "CreateAttributeDefinition",
CreateTable(_) => "CreateTable",
CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln",
SetXData { .. } => "SetXData",
SetXDataMany(_) => "SetXDataMany",
CreateXRecord(_) => "CreateXRecord",
SetXRecord { .. } => "SetXRecord",
CreateLayer(_) => "CreateLayer",
UpdateLayer { .. } => "UpdateLayer",
DeleteLayer { .. } => "DeleteLayer",
SetEntityLayer { .. } => "SetEntityLayer",
}
}
}
+23
View File
@@ -41,6 +41,8 @@ pub struct EntityView {
/// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line").
pub kind: String,
pub bounds: Option<Aabb>,
/// The normalized (uppercase) layer name the entity is on.
pub layer: String,
}
/// The result of a single [`Query`] (one per query in a `Queries` batch).
@@ -68,6 +70,20 @@ pub enum QueryResult {
target: [f64; 3],
height: f64,
},
/// An XDATA record for a single application (`None` if no such record).
XData(Option<crate::ops::XDataRecord>),
/// An XRECORD object payload.
XRecord(crate::ops::XRecordSpec),
/// All layers in the document.
Layers(Vec<crate::ops::LayerInfo>),
/// The layer name of an entity.
EntityLayer(String),
/// A filtered list of entity views.
Entities(Vec<EntityView>),
/// The WCS location of a Point entity.
PointPosition([f64; 3]),
/// The start and end WCS points of a Line entity.
LineGeometry(([f64; 3], [f64; 3])),
}
/// Convenience: the query name for diagnostics.
@@ -87,6 +103,13 @@ impl crate::gen::Query {
GetAttributes { .. } => "GetAttributes",
GetBlockEntities { .. } => "GetBlockEntities",
GetViewportView { .. } => "GetViewportView",
GetXData { .. } => "GetXData",
GetXRecord { .. } => "GetXRecord",
ListLayers => "ListLayers",
GetEntityLayer { .. } => "GetEntityLayer",
EnumerateEntities { .. } => "EnumerateEntities",
GetPointPosition { .. } => "GetPointPosition",
GetLineGeometry { .. } => "GetLineGeometry",
}
}
}
+142 -1
View File
@@ -5,11 +5,12 @@
use std::sync::Arc;
use ocs_plugin_api::host::{PluginRequestError, PluginRequestSender};
use ocs_plugin_api::host::{HostApi, PluginRequestError, PluginRequestSender};
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
use crate::envelope::{DocApiEnvelope, Receipt};
use crate::error::{ApiError, ApiResult};
use crate::facade::DocApi;
use crate::transport::Transport;
/// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`;
@@ -29,6 +30,21 @@ impl OcsPluginApiIpc {
}
}
/// Build a typed [`DocApi`] from any host that can provide a
/// [`PluginRequestSender`] (out-of-process / V4 worker plugins).
///
/// Returns `None` for hosts that do not expose a request sender (e.g. in-process
/// hosts). In-process code should use [`crate::DocApi::in_process`] with a
/// concrete [`crate::backend::DocApiBackend`].
pub fn doc_api_for_host(host: &dyn HostApi) -> Option<DocApi> {
let sender = host.plugin_request_sender()?;
let tab_id = host.tab_id();
Some(DocApi::connect(
Arc::new(OcsPluginApiIpc::new(sender.into(), tab_id)),
tab_id,
))
}
impl Transport for OcsPluginApiIpc {
fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt> {
let bytes = bincode::serialize(&req)
@@ -56,3 +72,128 @@ impl Transport for OcsPluginApiIpc {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::facade::HasId;
use crate::ObjectId;
use ocs_plugin_api::host::{
CadDocument, EntityType, ExtendedDataRecord, Handle, HostApi,
};
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
use std::any::Any;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct MockSender {
captured: Arc<Mutex<Vec<(u64, PluginRequest)>>>,
}
impl PluginRequestSender for MockSender {
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError> {
let tab_id = match &req {
PluginRequest::DocApiRequest { tab_id, .. } => *tab_id,
_ => 0,
};
self.captured.lock().unwrap().push((tab_id, req));
Ok(PluginResponse::DocApiResponse {
bytes: bincode::serialize(&crate::ApiResult::Ok(Receipt {
outcome: Some(crate::envelope::OpOutcome::NewId(ObjectId::from_u64(7))),
query_results: vec![],
new_revision: crate::revision::GeometryRevision(1),
}))
.unwrap(),
})
}
}
struct MockHost {
sender: Option<MockSender>,
}
impl HostApi for MockHost {
fn tab_index(&self) -> usize {
0
}
fn document(&self) -> &CadDocument {
unimplemented!()
}
fn document_mut(&mut self) -> &mut CadDocument {
unimplemented!()
}
fn add_entity(&mut self, _entity: EntityType) -> Handle {
unimplemented!()
}
fn bump_geometry(&mut self) {}
fn push_undo(&mut self, _label: &str) {}
fn set_dirty(&mut self) {}
fn push_info(&mut self, _msg: &str) {}
fn push_output(&mut self, _msg: &str) {}
fn push_error(&mut self, _msg: &str) {}
fn start_interactive(&mut self, _command: Box<dyn ocs_plugin_api::host::InteractiveCommand>) {}
fn read_record(&self, _handle: Handle, _app_name: &str) -> Option<&ExtendedDataRecord> {
None
}
fn write_record(&mut self, _handle: Handle, _record: ExtendedDataRecord) -> bool {
false
}
fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool {
false
}
fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> {
None
}
fn plugin_state_any_mut(&mut self, _plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> {
None
}
fn ensure_plugin_state_any(
&mut self,
_plugin_id: &'static str,
_init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
) -> &mut (dyn Any + Send + Sync) {
unimplemented!()
}
fn document_reader(&self) -> Box<dyn ocs_plugin_api::host::DocumentReader + '_> {
unimplemented!()
}
fn plugin_request_sender(&self) -> Option<Box<dyn PluginRequestSender>> {
self.sender.as_ref().map(|s| Box::new(s.clone()) as Box<dyn PluginRequestSender>)
}
}
#[test]
fn doc_api_for_host_builds_doc_api_and_routes_doc_api_request() {
let captured = Arc::new(Mutex::new(Vec::new()));
let host = MockHost {
sender: Some(MockSender {
captured: Arc::clone(&captured),
}),
};
let doc_api = doc_api_for_host(&host).expect("host exposes a sender");
let doc = doc_api.document(host.tab_id());
let id = doc
.solids()
.create_cuboid([0.0; 3], [1.0; 3])
.expect("mock returns a NewId receipt");
assert_eq!(id.id(), ObjectId::from_u64(7));
let calls = captured.lock().unwrap();
assert_eq!(calls.len(), 1);
let (tab_id, ref req) = &calls[0];
assert_eq!(*tab_id, host.tab_id());
match req {
PluginRequest::DocApiRequest { tab_id, bytes } => {
assert_eq!(*tab_id, host.tab_id());
assert!(!bytes.is_empty());
}
other => panic!("expected DocApiRequest, got {other:?}"),
}
}
#[test]
fn doc_api_for_host_returns_none_without_sender() {
let host = MockHost { sender: None };
assert!(doc_api_for_host(&host).is_none());
}
}
+15 -5
View File
@@ -1,5 +1,5 @@
//! Reject invalid numeric inputs before the backend starts an undo operation.
use crate::{ops::*, ApiError, ApiResult};
use crate::{id::ObjectId, ops::*, ApiError, ApiResult};
fn finite(values: &[f64]) -> bool {
values.iter().all(|v| v.is_finite())
@@ -24,16 +24,17 @@ fn check(valid: bool, op: &'static str) -> ApiResult<()> {
pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
use Curve2Spec::*;
let valid = match spec {
Line { start, end } => finite(start) && finite(end) && start != end,
Circle { centre, radius } => finite(centre) && positive(*radius),
Point { position } => finite(position),
Line { start, end, .. } => finite(start) && finite(end) && start != end,
Circle { centre, radius, .. } => finite(centre) && positive(*radius),
Point { position, .. } => finite(position),
Arc {
centre,
radius,
start_angle,
end_angle,
..
} => finite(centre) && positive(*radius) && finite(&[*start_angle, *end_angle]),
Polyline { points, closed } => {
Polyline { points, closed, .. } => {
points.len() >= if *closed { 3 } else { 2 }
&& points.len() <= BULK_ITEM_CAP
&& points.iter().all(|p| finite(p) && p[2] == points[0][2])
@@ -44,6 +45,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
ratio,
start,
end,
..
} => {
finite(centre)
&& direction(major_axis)
@@ -56,6 +58,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
control_points,
knots,
weights,
..
} => {
*degree > 0
&& control_points.len() <= BULK_ITEM_CAP
@@ -76,10 +79,12 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
Ray {
origin,
direction: d,
..
}
| XLine {
origin,
direction: d,
..
} => finite(origin) && direction(d),
};
check(valid, "CreateCurve")
@@ -111,6 +116,11 @@ pub(crate) fn operation(op: &Operation) -> ApiResult<()> {
}
CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(),
CreateMText(s) => finite(&s.insertion_point) && positive(s.height),
SetXDataMany(records) => records.iter().all(|(id, app, record)| {
let app_ok = !app.is_empty();
let record_ok = record.as_ref().map_or(true, |r| !r.application_name.is_empty() && !r.application_name.contains('\0'));
*id != ObjectId::NULL && app_ok && record_ok
}),
CreateHatch(s) => {
(3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p))
}