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

+18 -8
View File
@@ -4,6 +4,12 @@
`Transport::apply` carries one operation or a read-only query batch to the `Transport::apply` carries one operation or a read-only query batch to the
executor. The native adapter binds each request to its existing `HostSession`. executor. The native adapter binds each request to its existing `HostSession`.
For out-of-process / worker-thread plugins, `doc_api_for_host(host)` constructs a
`DocApi` backed by `OcsPluginApiIpc`, which sends `PluginRequest::DocApiRequest`
through the host's `PluginRequestSender` and deserializes
`PluginResponse::DocApiResponse`. This keeps plugin code free of manual envelope
serialization while reusing the same executor that the in-process transport uses.
The executor validates inputs and dispatches through `DocApiBackend`. Backends The executor validates inputs and dispatches through `DocApiBackend`. Backends
must prepare fallible geometry and serialization before changing the document. must prepare fallible geometry and serialization before changing the document.
Bulk writes prepare all entities, begin one undo record, apply the prepared Bulk writes prepare all entities, begin one undo record, apply the prepared
@@ -22,14 +28,18 @@ do not reorder them. The consistency tests verify their bincode discriminants.
generates the API reference and binding schema. The layout snapshot is curated generates the API reference and binding schema. The layout snapshot is curated
vocabulary, not a reflected binary codec schema. vocabulary, not a reflected binary codec schema.
Layer CRUD is a document-table family, not an entity family. The API carries
layer properties in the plain-data `LayerInfo` DTO and the host translates them
to/from `acadrust::tables::Layer`. Layer names are case-insensitive. The host
rejects duplicate names, deletion of layer "0" or the current layer, and removal
of a layer that still has entities assigned.
XDATA and XRECORD are serialization roundtrip families: the API carries their
payloads as plain-data DTOs and the host stores/retrieves them without semantic
interpretation. XDATA records are keyed by registered application name on any
entity; XRECORD objects are standalone named dictionaries of group-code/value
entries. Both preserve exact bytes/values across save and reload.
To extend the API, update the DTO, backend, executor, facade and spec together, To extend the API, update the DTO, backend, executor, facade and spec together,
add a native behavior test, and rebuild the generated reference/schema. New add a native behavior test, and rebuild the generated reference/schema. New
backend methods must reject unsupported behavior explicitly. backend methods must reject unsupported behavior explicitly.
Extension workflow:
1. Add the operation/query DTO and its name mapping, then update `spec/entities.toml`.
2. Implement validation, backend preparation, executor dispatch and facade methods.
3. Preserve each profile's plane and use cadkernel for geometry.
4. Test native behavior, failed-operation atomicity and the binding transport payload.
5. Run `cargo test -p ocs_doc_api --all-features` and rebuild the generated snapshots.
+2
View File
@@ -34,6 +34,8 @@ default = []
host = ["acadrust", "cadkernel"] host = ["acadrust", "cadkernel"]
# ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side). # ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side).
ipc = ["acadrust", "ocs_plugin_api"] ipc = ["acadrust", "ocs_plugin_api"]
# doc_api_host: convenience helper that builds a DocApi from a HostApi (IPC-only).
doc_api_host = ["ipc"]
[lib] [lib]
name = "ocs_doc_api" name = "ocs_doc_api"
+98
View File
@@ -30,11 +30,109 @@ Transports bind to one document tab. Typed handles from another transport and
requests for another tab are rejected. Raw `ObjectId` values are relative to the requests for another tab are rejected. Raw `ObjectId` values are relative to the
receiving document. Errors are serialized as `ApiError` variants over IPC. receiving document. Errors are serialized as `ApiError` variants over IPC.
Layer CRUD is exposed through `Document::layers()`, `create_layer(info)`,
`update_layer(name, info)`, `delete_layer(name)` and through the `layer()` /
`set_layer(...)` methods on every entity handle (`Line`, `Circle`, `Solid`,
`Entity`, etc.). Layer properties are carried as the plain-data `LayerInfo` DTO
with `LayerFlags`, `Color` and `LineWeight`. The host enforces CAD-style rules:
layer "0" and the current layer cannot be deleted, duplicate names are rejected,
and a layer still referenced by entities cannot be removed.
XDATA and XRECORD payloads are carried as plain-data, serde-compatible DTOs
(`XDataRecord`, `XRecordSpec`, and their value/entry types) and roundtripped
verbatim by the host. Every `Entity` handle can read/write one XDATA record per
registered application name via `xdata(application_name)` and `set_xdata(...)`,
and standalone XRECORD objects can be created with `doc.entities().create_xrecord(...)`
and updated through the `XRecord` handle.
| Feature | Use | | Feature | Use |
|---|---| |---|---|
| Default | DTOs, facade, transport trait and binding schema | | Default | DTOs, facade, transport trait and binding schema |
| `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` | | `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` |
| `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host | | `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host |
| `doc_api_host` | Convenience `doc_api_for_host(&dyn HostApi) -> Option<DocApi>` for out-of-process / worker-thread plugins |
### Connecting from a plugin
For out-of-process or worker-thread plugins that receive `&dyn HostApi` (or any
`ocs_plugin_api::host::HostApi` implementor), enable the `doc_api_host` feature
and use the helper instead of manually serializing `DocApiEnvelope`s:
```rust
use ocs_doc_api::doc_api_for_host;
use ocs_plugin_api::host::HostApi;
fn on_dispatch(host: &mut dyn HostApi) -> Option<()> {
let doc_api = doc_api_for_host(host)?;
let doc = doc_api.document(host.tab_id());
let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?;
Some(())
}
```
`doc_api_for_host` returns `None` for hosts that do not expose a
`PluginRequestSender` (for example the in-process `HostSession`). In-process code
should use `DocApi::in_process(backend, tab_id)` with a concrete
`DocApiBackend`.
### Plugin example: create, read, update and delete a line
```rust
use ocs_doc_api::{doc_api_for_host, ApiResult, Color, LayerInfo, LineWeight};
use ocs_plugin_api::host::HostApi;
fn draw_and_modify_line(host: &mut dyn HostApi) -> ApiResult<()> {
// Build a DocApi from the host's plugin request channel.
let api = doc_api_for_host(host).ok_or_else(|| {
ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into())
})?;
let doc = api.document(api.active_tab());
// Ensure a target layer exists.
let mut info = LayerInfo::new("MyLayer");
info.color = Color::Index(1);
info.line_weight = LineWeight::Value(25);
let _ = doc.create_layer(&info); // ok if it already exists
// Create a line on that layer.
let line = doc.curves().create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?;
line.set_layer("MyLayer")?;
// Read it back.
assert_eq!(line.layer()?, "MYLAYER");
// Move the endpoint in place (same ObjectId).
line.transform(ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]))?;
// Delete it; the document is left unchanged if this failed.
line.delete()?;
Ok(())
}
```
### Plugin example: create a box and read its volume
```rust
use ocs_doc_api::{doc_api_for_host, ApiResult};
use ocs_plugin_api::host::HostApi;
fn create_box_and_measure(host: &mut dyn HostApi) -> ApiResult<f64> {
let api = doc_api_for_host(host).ok_or_else(|| {
ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into())
})?;
let doc = api.document(api.active_tab());
let box_ = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?;
let volume = box_.volume()?;
// Boolean example: intersect with a second box.
let other = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?;
let intersection = box_.intersect(&other)?;
let _intersection_volume = intersection.volume()?;
Ok(volume)
}
```
See the [API reference](src/gen/api_reference.md) for constructors and methods, See the [API reference](src/gen/api_reference.md) for constructors and methods,
[architecture](ARCHITECTURE.md) for backend rules, and [architecture](ARCHITECTURE.md) for backend rules, and
+34 -3
View File
@@ -21,8 +21,39 @@ The bridge converts these objects to the Rust DTOs and bincode 1.3 wire format,
sends `DocApiRequest { tab_id, bytes }`, and decodes `Result<Receipt, ApiError>`. sends `DocApiRequest { tab_id, bytes }`, and decodes `Result<Receipt, ApiError>`.
Host plugin API version 6 and document envelope version 1 are required. Host plugin API version 6 and document envelope version 1 are required.
For Rust plugins compiled against `ocs_doc_api` with the `doc_api_host` feature,
use the `doc_api_for_host` helper to build a typed `DocApi` from any
`ocs_plugin_api::host::HostApi` that exposes a `PluginRequestSender`
(out-of-process / worker-thread hosts):
```rust
use ocs_doc_api::doc_api_for_host;
use ocs_plugin_api::host::HostApi;
fn on_dispatch(host: &mut dyn HostApi) -> Option<()> {
let doc_api = doc_api_for_host(host)?;
let doc = doc_api.document(host.tab_id());
let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?;
// ...
Some(())
}
```
In-process plugins should use `DocApi::in_process(backend, tab_id)` with a
concrete `DocApiBackend`; `doc_api_for_host` returns `None` for hosts that do not
expose a `PluginRequestSender`.
Decode receipt outcomes as `{"NewId": 42}` or `{"NewIds": [42, 43]}` and query Decode receipt outcomes as `{"NewId": 42}` or `{"NewIds": [42, 43]}` and query
results as externally tagged dictionaries such as `{"Volume": 12.5}`. The facade results as externally tagged dictionaries such as `{"Volume": 12.5}`. The facade
unwraps these into typed handles or values. The bridge must raise an exception unwraps these into typed handles or values. Layer results use `{"Layers": [...]}`
for an error response. Transport binding determines the tab; requesting another and `{"EntityLayer": "WALLS"}`. Layer properties (`LayerInfo`, `LayerFlags`,
tab or combining typed handles from different documents raises an error. `Color`, `LineWeight`) appear as plain dictionaries in operation payloads, e.g.
`{"CreateLayer": {"name": "Walls", "flags": {"frozen": false, "locked": false,
"frozen_in_new_viewport": false, "off": false}, "color": {"Index": 7},
"line_type": "Continuous", "line_weight": {"Default": null},
"plot_style": "", "is_plottable": true}}`.
XDATA and XRECORD payloads appear as nested externally tagged dictionaries
(e.g. `{"XData": {"application_name": ..., "values": [...]}}` or
`{"XRecord": {"name": ..., "entries": [...]}}`). The bridge must raise an
exception for an error response. Transport binding determines the tab; requesting
another tab or combining typed handles from different documents raises an error.
@@ -36,6 +36,12 @@ class Entity:
def delete(self): def delete(self):
self._doc._apply_op({"Delete": {"id": self.id}}) self._doc._apply_op({"Delete": {"id": self.id}})
def layer(self):
return self._doc._apply_query({"GetEntityLayer": {"id": self.id}})
def set_layer(self, layer):
self._doc._apply_op({"SetEntityLayer": {"id": self.id, "layer": layer}})
''' '''
DOCUMENT = ''' DOCUMENT = '''
@@ -79,6 +85,18 @@ class Document:
def curves(self): return Curves(self) def curves(self): return Curves(self)
def entities(self): return Entities(self) def entities(self): return Entities(self)
def layers(self):
return self._apply_query({"ListLayers": {}})
def create_layer(self, info):
self._apply_op({"CreateLayer": info})
def update_layer(self, name, info):
self._apply_op({"UpdateLayer": {"name": name, "info": info}})
def delete_layer(self, name):
self._apply_op({"DeleteLayer": {"name": name}})
@dataclass @dataclass
class DocApi: class DocApi:
_transport: Any _transport: Any
+22
View File
@@ -168,10 +168,32 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`,
- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: - **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1:
solids only.** solids only.**
- **`delete()`** — remove the entity (one undo step). - **`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 `Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed
downcast when `kind == "Solid3D"`). 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 ## Collections & cross-cutting
- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. - **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`.
+277 -7
View File
@@ -8,11 +8,12 @@ use std::collections::HashMap;
use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::backend::{DocApiBackend, KernelBody};
use ocs_doc_api::{ use ocs_doc_api::{
Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, normalize_layer_name, Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind,
ObjectId, PlacementSpec, GeometryRevision, LayerInfo, ObjectId, PlacementSpec,
}; };
#[derive(Default, Clone)]
#[derive(Clone)]
pub struct MockBackend { pub struct MockBackend {
next_id: u64, next_id: u64,
revision: u64, revision: u64,
@@ -31,6 +32,38 @@ pub struct MockBackend {
attributes_store: HashMap<ObjectId, Vec<(String, String)>>, attributes_store: HashMap<ObjectId, Vec<(String, String)>>,
/// Stored viewport views (target, height). /// Stored viewport views (target, height).
viewport_views: HashMap<ObjectId, ([f64; 3], f64)>, viewport_views: HashMap<ObjectId, ([f64; 3], f64)>,
/// Stored XDATA records keyed by (entity id, application name).
xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>,
/// Stored XRECORD payloads keyed by entity id.
xrecord_store: HashMap<ObjectId, ocs_doc_api::XRecordSpec>,
/// Stored layer table.
layers: HashMap<String, LayerInfo>,
/// Layer name assigned to each entity.
entity_layers: HashMap<ObjectId, String>,
}
impl Default for MockBackend {
fn default() -> Self {
let mut backend = Self {
next_id: 0,
revision: 0,
undo_steps: 0,
bodies: HashMap::new(),
kinds: HashMap::new(),
curves: HashMap::new(),
text_values: HashMap::new(),
hatch_boundaries: HashMap::new(),
dimension_measurements: HashMap::new(),
attributes_store: HashMap::new(),
viewport_views: HashMap::new(),
xdata_store: HashMap::new(),
xrecord_store: HashMap::new(),
layers: HashMap::new(),
entity_layers: HashMap::new(),
};
backend.layers.insert("0".to_string(), LayerInfo::new("0"));
backend
}
} }
impl MockBackend { impl MockBackend {
@@ -52,7 +85,7 @@ impl MockBackend {
/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds). /// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds).
fn curve_bounds(spec: &Curve2Spec) -> Aabb { fn curve_bounds(spec: &Curve2Spec) -> Aabb {
match spec { match spec {
Curve2Spec::Line { start, end } => Aabb { Curve2Spec::Line { start, end, .. } => Aabb {
min: [ min: [
start[0].min(end[0]), start[0].min(end[0]),
start[1].min(end[1]), start[1].min(end[1]),
@@ -64,11 +97,11 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb {
start[2].max(end[2]), start[2].max(end[2]),
], ],
}, },
Curve2Spec::Circle { centre, radius } => Aabb { Curve2Spec::Circle { centre, radius, .. } => Aabb {
min: [centre[0] - radius, centre[1] - radius, centre[2]], min: [centre[0] - radius, centre[1] - radius, centre[2]],
max: [centre[0] + radius, centre[1] + radius, centre[2]], max: [centre[0] + radius, centre[1] + radius, centre[2]],
}, },
Curve2Spec::Point { position } => Aabb { Curve2Spec::Point { position, .. } => Aabb {
min: *position, min: *position,
max: *position, max: *position,
}, },
@@ -133,6 +166,15 @@ impl DocApiBackend for MockBackend {
Ok(()) Ok(())
} }
fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult<Vec<ObjectId>> { fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult<Vec<ObjectId>> {
// Validate per-spec layers up front so the op is atomic.
for spec in specs {
if let Some(name) = spec.layer() {
self.lookup_layer(name).map_err(|e| ApiError::validation(
"CreateMany",
e.to_string(),
))?;
}
}
let mut next = self.clone(); let mut next = self.clone();
let ids = specs let ids = specs
.iter() .iter()
@@ -173,10 +215,29 @@ impl DocApiBackend for MockBackend {
Curve2Spec::Ray { .. } => "Ray", Curve2Spec::Ray { .. } => "Ray",
Curve2Spec::XLine { .. } => "XLine", Curve2Spec::XLine { .. } => "XLine",
}; };
let layer = spec
.layer()
.map(|name| {
self.lookup_layer(name).map(|info| info.normalized_name())
})
.transpose()?;
let id = self.alloc(kind); let id = self.alloc(kind);
self.curves.insert(id, spec.clone()); self.curves.insert(id, spec.clone());
self.entity_layers
.insert(id, layer.unwrap_or_else(|| "0".to_string()));
Ok(id) Ok(id)
} }
fn lookup_layer(&self, name: &str) -> ApiResult<LayerInfo> {
let norm = normalize_layer_name(name);
if norm.is_empty() {
return Err(ApiError::validation("LookupLayer", "empty layer name"));
}
self.layers
.get(&norm)
.cloned()
.ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist")))
}
fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult<ObjectId> { fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult<ObjectId> {
if spec.block_name.is_empty() { if spec.block_name.is_empty() {
return Err(ApiError::validation("CreateInsert", "empty block name")); return Err(ApiError::validation("CreateInsert", "empty block name"));
@@ -355,6 +416,144 @@ impl DocApiBackend for MockBackend {
.copied() .copied()
.ok_or(ApiError::UnknownId(id)) .ok_or(ApiError::UnknownId(id))
} }
fn set_xdata(
&mut self,
id: ObjectId,
application_name: &str,
record: Option<&ocs_doc_api::XDataRecord>,
) -> ApiResult<()> {
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
let key = (id, application_name.to_string());
match record {
Some(r) => {
self.xdata_store.insert(key, r.clone());
}
None => {
self.xdata_store.remove(&key);
}
}
Ok(())
}
fn xdata(
&self,
id: ObjectId,
application_name: &str,
) -> ApiResult<Option<ocs_doc_api::XDataRecord>> {
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
Ok(self
.xdata_store
.get(&(id, application_name.to_string()))
.cloned())
}
fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<ObjectId> {
let id = self.alloc("XRecord");
self.xrecord_store.insert(id, spec.clone());
Ok(id)
}
fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> {
if !self.object_exists(id) {
return Err(ApiError::validation(
"SetXRecord",
format!("unknown ObjectId {id:?}"),
));
}
self.xrecord_store.insert(id, spec.clone());
Ok(())
}
fn xrecord(&self, id: ObjectId) -> ApiResult<Option<ocs_doc_api::XRecordSpec>> {
if !self.object_exists(id) {
return Err(ApiError::validation(
"GetXRecord",
format!("unknown ObjectId {id:?}"),
));
}
Ok(self.xrecord_store.get(&id).cloned())
}
fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> {
let name = normalize_layer_name(&info.name);
if name.is_empty() {
return Err(ApiError::validation("CreateLayer", "empty layer name"));
}
if self.layers.contains_key(&name) {
return Err(ApiError::validation(
"CreateLayer",
format!("layer '{name}' already exists"),
));
}
let mut stored = info.clone();
stored.name = name.clone();
self.layers.insert(name, stored);
Ok(())
}
fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> {
let name_upper = normalize_layer_name(name);
if !self.layers.contains_key(&name_upper) {
return Err(ApiError::validation(
"UpdateLayer",
format!("layer '{name}' does not exist"),
));
}
let mut stored = info.clone();
stored.name = name_upper.clone();
self.layers.insert(name_upper, stored);
Ok(())
}
fn delete_layer(&mut self, name: &str) -> ApiResult<()> {
let name_upper = normalize_layer_name(name);
if name_upper == "0" {
return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0"));
}
if self.entity_layers.values().any(|l| normalize_layer_name(l) == name_upper) {
return Err(ApiError::validation(
"DeleteLayer",
format!("layer '{name_upper}' still in use"),
));
}
if self.layers.remove(&name_upper).is_none() {
return Err(ApiError::validation(
"DeleteLayer",
format!("layer '{name}' does not exist"),
));
}
Ok(())
}
fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> {
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
let layer_upper = normalize_layer_name(layer);
let target = self
.layers
.get(&layer_upper)
.ok_or_else(|| {
ApiError::validation(
"SetEntityLayer",
format!("layer '{layer_upper}' does not exist"),
)
})?;
if target.flags.locked {
return Err(ApiError::Unsupported(format!(
"entity {id:?} cannot be moved to locked layer '{layer_upper}'"
)));
}
self.entity_layers.insert(id, layer_upper);
Ok(())
}
fn layers(&self) -> ApiResult<Vec<LayerInfo>> {
let mut v: Vec<_> = self.layers.values().cloned().collect();
v.sort_by(|a, b| a.name.cmp(&b.name));
Ok(v)
}
fn entity_layer(&self, id: ObjectId) -> ApiResult<String> {
self.entity_layers
.get(&id)
.cloned()
.ok_or(ApiError::UnknownId(id))
}
fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> {
if self.entity_exists(id) { if self.entity_exists(id) {
Ok(()) Ok(())
@@ -364,17 +563,85 @@ impl DocApiBackend for MockBackend {
} }
fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool> { fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool> {
self.bodies.remove(&id); self.bodies.remove(&id);
self.xdata_store.retain(|(k, _), _| *k != id);
self.xrecord_store.remove(&id);
self.entity_layers.remove(&id);
Ok(self.kinds.remove(&id).is_some()) Ok(self.kinds.remove(&id).is_some())
} }
fn enumerate_entities(
&self,
kind: Option<&str>,
layer: Option<&str>,
include_bounds: bool,
) -> ApiResult<Vec<EntityView>> {
let mut out = Vec::new();
for (id, kind_name) in &self.kinds {
if let Some(k) = kind {
if !k.eq_ignore_ascii_case(kind_name) {
continue;
}
}
let entity_layer = self
.entity_layers
.get(id)
.cloned()
.unwrap_or_else(|| "0".to_string());
if let Some(l) = layer {
if !l.eq_ignore_ascii_case(&entity_layer) {
continue;
}
}
let bounds = if include_bounds {
self.curves.get(id).map(curve_bounds)
} else {
None
};
out.push(EntityView {
id: *id,
kind: kind_name.clone(),
layer: entity_layer,
bounds,
});
}
Ok(out)
}
fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> {
let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?;
match spec {
Curve2Spec::Point { position, .. } => Ok(*position),
_ => Err(ApiError::Unsupported(
"GetPointPosition is only for Point entities".into(),
)),
}
}
fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> {
let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?;
match spec {
Curve2Spec::Line { start, end, .. } => Ok((*start, *end)),
_ => Err(ApiError::Unsupported(
"GetLineGeometry is only for Line entities".into(),
)),
}
}
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> { fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> {
let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone();
let layer = self
.entity_layers
.get(&id)
.cloned()
.unwrap_or_else(|| "0".to_string());
let bounds = self.bodies.get(&id).and_then(|b| { let bounds = self.bodies.get(&id).and_then(|b| {
cadkernel::brep::body_bounds(b).map(|bb| Aabb { cadkernel::brep::body_bounds(b).map(|bb| Aabb {
min: bb.min, min: bb.min,
max: bb.max, max: bb.max,
}) })
}); });
Ok(EntityView { id, kind, bounds }) Ok(EntityView {
id,
kind,
layer,
bounds,
})
} }
fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> {
if !self.entity_exists(id) { if !self.entity_exists(id) {
@@ -423,6 +690,9 @@ impl DocApiBackend for MockBackend {
fn entity_exists(&self, id: ObjectId) -> bool { fn entity_exists(&self, id: ObjectId) -> bool {
self.kinds.contains_key(&id) self.kinds.contains_key(&id)
} }
fn object_exists(&self, id: ObjectId) -> bool {
self.xrecord_store.contains_key(&id)
}
fn revision(&self) -> GeometryRevision { fn revision(&self) -> GeometryRevision {
GeometryRevision(self.revision) GeometryRevision(self.revision)
} }
@@ -54,10 +54,7 @@ fn main() -> ocs_doc_api::ApiResult<()> {
} }
// A totally unknown id also surfaces a structured error. // A totally unknown id also surfaces a structured error.
assert!(matches!( assert!(doc.entities().get(ObjectId::from_u64(0xDEAD)).is_err());
doc.entities().get(ObjectId::from_u64(0xDEAD)),
Err(_)
));
println!("failure semantics verified"); println!("failure semantics verified");
Ok(()) Ok(())
} }
+80
View File
@@ -457,6 +457,50 @@ name = "create_table"
args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec<String>]" }] args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec<String>]" }]
op = "CreateTable" op = "CreateTable"
# ============================================================================
# XDATA / XRECORD FAMILY — application-specific extended data roundtrip
# ============================================================================
[[family]]
name = "XRecord"
acadrust_variant = "XRecord"
handle = "XRecord"
collection = "entities"
description = "Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload."
[[family.constructor]]
name = "create_xrecord"
args = [{ name = "spec", ty = "XRecordSpec" }]
op = "CreateXRecord"
returns = "Entity"
[[family.method]]
name = "payload"
kind = "query"
query = "GetXRecord"
returns = "XRecordSpec"
[[family.method]]
name = "set_payload"
kind = "op"
op = "SetXRecord"
args = [{ name = "spec", ty = "XRecordSpec" }]
returns = "()"
# Cross-cutting: every Entity handle can read/write its own XDATA.
[[family.method]]
name = "xdata"
kind = "query"
query = "GetXData"
args = [{ name = "application_name", ty = "&str" }]
returns = "Option<XDataRecord>"
[[family.method]]
name = "set_xdata"
kind = "op"
op = "SetXData"
args = [{ name = "application_name", ty = "&str" }, { name = "record", ty = "Option<XDataRecord>" }]
returns = "()"
# ============================================================================ # ============================================================================
# Cross-cutting ops (generic handles + bulk) — documented in build.rs # Cross-cutting ops (generic handles + bulk) — documented in build.rs
# ============================================================================ # ============================================================================
@@ -469,6 +513,42 @@ op = "CreateTable"
# - Read-only batching: query_batch(|q| ...) — one round-trip, no revision bump. # - Read-only batching: query_batch(|q| ...) — one round-trip, no revision bump.
# - OpGroup: best-effort client-side failure cleanup (NOT a transaction). # - OpGroup: best-effort client-side failure cleanup (NOT a transaction).
# ============================================================================
# LAYER family (named drawing layers)
# ============================================================================
[[family]]
name = "Layer"
handle = "Layer"
collection = "layers"
description = "Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers()."
[[family.method]]
name = "list"
kind = "query"
query = "ListLayers"
returns = "Vec<LayerInfo>"
[[family.method]]
name = "create"
kind = "op"
op = "CreateLayer"
args = [{ name = "info", ty = "LayerInfo" }]
returns = "()"
[[family.method]]
name = "update"
kind = "op"
op = "UpdateLayer"
args = [{ name = "name", ty = "String" }, { name = "info", ty = "LayerInfo" }]
returns = "()"
[[family.method]]
name = "delete"
kind = "op"
op = "DeleteLayer"
args = [{ name = "name", ty = "String" }]
returns = "()"
# ---- Kernel/op mapping (reference for the executor + docs) ---- # ---- Kernel/op mapping (reference for the executor + docs) ----
[[kernel_map]] [[kernel_map]]
op = "CreateSolid.Cuboid" op = "CreateSolid.Cuboid"
+84 -1
View File
@@ -5,7 +5,7 @@
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::id::ObjectId; use crate::id::ObjectId;
use crate::ops::Curve2Spec; use crate::ops::{Curve2Spec, LayerInfo};
use crate::query::{Aabb, EntityView}; use crate::query::{Aabb, EntityView};
use crate::revision::GeometryRevision; use crate::revision::GeometryRevision;
@@ -152,6 +152,85 @@ pub trait DocApiBackend {
sections: &[(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)], sections: &[(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)],
) -> ApiResult<ObjectId>; ) -> 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, /// 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. /// not on a locked layer)? Read-only pre-check used before mutations.
/// Default: existence + not-locked (backends narrow the family check). /// 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). /// A generic, untyped view of any entity (id + kind + coarse bounds).
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView>; 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> { pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
crate::validation::curve(spec)?; crate::validation::curve(spec)?;
let entity = match spec { let entity = match spec {
Curve2Spec::Line { start, end } => { Curve2Spec::Line { start, end, .. } => {
let mut e = Line::new(); let mut e = Line::new();
e.start = v3(*start); e.start = v3(*start);
e.end = v3(*end); e.end = v3(*end);
EntityType::Line(e) EntityType::Line(e)
} }
Curve2Spec::Circle { centre, radius } => { Curve2Spec::Circle { centre, radius, .. } => {
let mut e = Circle::new(); let mut e = Circle::new();
e.center = v3(*centre); e.center = v3(*centre);
e.radius = *radius; e.radius = *radius;
EntityType::Circle(e) EntityType::Circle(e)
} }
Curve2Spec::Polyline { points, closed } => { Curve2Spec::Polyline { points, closed, .. } => {
let mut e = LwPolyline::new(); let mut e = LwPolyline::new();
e.vertices = points e.vertices = points
.iter() .iter()
@@ -147,7 +147,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
e.is_closed = *closed; e.is_closed = *closed;
EntityType::LwPolyline(e) EntityType::LwPolyline(e)
} }
Curve2Spec::Point { position } => { Curve2Spec::Point { position, .. } => {
let mut e = Point::new(); let mut e = Point::new();
e.location = v3(*position); e.location = v3(*position);
EntityType::Point(e) EntityType::Point(e)
@@ -157,6 +157,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
radius, radius,
start_angle, start_angle,
end_angle, end_angle,
..
} => { } => {
let mut e = Arc::new(); let mut e = Arc::new();
e.center = v3(*centre); e.center = v3(*centre);
@@ -171,6 +172,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
ratio, ratio,
start, start,
end, end,
..
} => { } => {
// Validate the minor/major ratio so the (major-axis) bounds never // Validate the minor/major ratio so the (major-axis) bounds never
// under-bound: ratio must be in (0, 1]. // under-bound: ratio must be in (0, 1].
@@ -193,6 +195,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
control_points, control_points,
knots, knots,
weights, weights,
..
} => { } => {
let mut e = Spline::new(); let mut e = Spline::new();
e.degree = *degree; e.degree = *degree;
@@ -201,10 +204,10 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
e.weights = weights.clone(); e.weights = weights.clone();
EntityType::Spline(e) EntityType::Spline(e)
} }
Curve2Spec::Ray { origin, direction } => { Curve2Spec::Ray { origin, direction, .. } => {
EntityType::Ray(Ray::new(v3(*origin), v3(*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))) 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> { fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt> {
let name = op.op_name(); let name = op.op_name();
crate::validation::operation(&op)?; crate::validation::operation(&op)?;
if let Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) = &op { match &op {
let unique: std::collections::HashSet<_> = ids.iter().collect(); Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) => {
if ids.is_empty() || unique.len() != ids.len() { let unique: std::collections::HashSet<_> = ids.iter().collect();
return Err(ApiError::validation( if ids.is_empty() || unique.len() != ids.len() {
name, return Err(ApiError::validation(
"batch ids must be nonempty and unique", 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 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(); b.finalize_op();
OpOutcome::NewId(id) 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 { Ok(Receipt {
outcome: Some(outcome), 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))?; let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?;
QueryResult::ViewportView { target, height } 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); 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 { fn over_cap(op: &'static str, n: usize) -> ApiError {
ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}")) 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::error::{ApiError, ApiResult};
use crate::gen::{Operation, Query}; use crate::gen::{Operation, Query};
use crate::id::ObjectId; 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::query::{Aabb, EntityView, QueryResult};
use crate::revision::GeometryRevision; use crate::revision::GeometryRevision;
use crate::transport::Transport; 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). /// Batch of read-only queries in ONE round-trip (safe: no mutation/undo).
/// The closure records queries on a [`QueryBatch`]; the results are returned /// The closure records queries on a [`QueryBatch`]; the results are returned
/// in the same order as a [`QueryResults`] view the caller destructures. /// in the same order as a [`QueryResults`] view the caller destructures.
@@ -336,6 +404,25 @@ macro_rules! handle {
})?; })?;
Ok(()) 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!(Text);
handle!(MText); handle!(MText);
handle!(Dimension); 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 { impl Dimension {
/// The measured value of this dimension (distance for linear/radius, degrees /// The measured value of this dimension (distance for linear/radius, degrees
@@ -416,6 +522,26 @@ impl Entity {
})?; })?;
Ok(()) 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> { pub fn as_solid(&self) -> Option<Solid> {
// Typed downcast is validated by the view's kind. // Typed downcast is validated by the view's kind.
matches!(self.view().ok()?.kind.as_str(), "Solid3D") matches!(self.view().ok()?.kind.as_str(), "Solid3D")
@@ -621,22 +747,80 @@ impl CurveCollection {
Ok((self.session.clone(), id)) Ok((self.session.clone(), id))
} }
pub fn create_line(&self, start: [f64; 3], end: [f64; 3]) -> ApiResult<Line> { 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)) Ok(Line::new(s, id))
} }
pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult<Circle> { 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)) Ok(Circle::new(s, id))
} }
pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult<Polyline> { pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult<Polyline> {
let (s, id) = self.create_curve(Curve2Spec::Polyline { let (s, id) = self.create_curve(Curve2Spec::Polyline {
points: points.to_vec(), points: points.to_vec(),
closed, 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)) Ok(Polyline::new(s, id))
} }
pub fn create_point(&self, position: [f64; 3]) -> ApiResult<Point> { 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)) Ok(Point::new(s, id))
} }
pub fn create_arc( pub fn create_arc(
@@ -651,6 +835,24 @@ impl CurveCollection {
radius, radius,
start_angle, start_angle,
end_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)) Ok(ArcCurve::new(s, id))
} }
@@ -668,6 +870,26 @@ impl CurveCollection {
ratio, ratio,
start, start,
end, 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)) Ok(Ellipse::new(s, id))
} }
@@ -683,15 +905,67 @@ impl CurveCollection {
control_points: control_points.to_vec(), control_points: control_points.to_vec(),
knots: knots.to_vec(), knots: knots.to_vec(),
weights: weights.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)) Ok(Spline::new(s, id))
} }
pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult<Ray> { 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)) Ok(Ray::new(s, id))
} }
pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult<XLine> { 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)) Ok(XLine::new(s, id))
} }
/// A RASTER_IMAGE placed at `insertion_point` (host auto-registers the image definition). /// 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)) Ok(MText::new(self.session.clone(), id))
} }
/// Bulk-create many points in ONE op: all-or-nothing, one undo step. /// 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>> { pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult<Vec<Point>> {
let specs: Vec<crate::ops::EntitySpec> = positions let specs: Vec<crate::ops::EntitySpec> = positions
.iter() .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(); .collect();
let receipt = self.session.apply_op(Operation::CreateMany(specs))?; let receipt = self.session.apply_op(Operation::CreateMany(specs))?;
let outcome = receipt let outcome = receipt
@@ -1001,6 +1307,41 @@ impl EntityCollection {
self.session.apply_op(Operation::Delete { id })?; self.session.apply_op(Operation::Delete { id })?;
Ok(()) 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). /// Bulk transform (one op, all-or-nothing).
pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> { pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> {
self.session.apply_op(Operation::TransformMany { 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` - **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) ## Generic methods (every handle)
Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, 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: - **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1:
solids only.** solids only.**
- **`delete()`** — remove the entity (one undo step). - **`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 `Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed
downcast when `kind == "Solid3D"`). 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 ## Collections & cross-cutting
- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. - **`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)` - `CreateAttributeDefinition(AttributeDefinitionSpec)`
- `CreateTable(TableSpec)` - `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` ### `Query`
@@ -299,6 +355,10 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read-
- `GetAttributes { id: ObjectId }` - `GetAttributes { id: ObjectId }`
- `GetBlockEntities { block_name: String }` - `GetBlockEntities { block_name: String }`
- `GetViewportView { id: ObjectId }` - `GetViewportView { id: ObjectId }`
- `GetXData { id: ObjectId, application_name: String }`
- `GetXRecord { id: ObjectId }`
- `ListLayers`
- `GetEntityLayer { id: ObjectId }`
## Errors (`ApiError`) ## Errors (`ApiError`)
+175 -3
View File
@@ -1124,6 +1124,164 @@
"handle": "Entity", "handle": "Entity",
"methods": [], "methods": [],
"name": "Table" "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": { "layouts": {
@@ -1164,7 +1322,15 @@
"CreateDimensionAngular(DimensionAngularSpec)", "CreateDimensionAngular(DimensionAngularSpec)",
"CreateAttributeDefinition(AttributeDefinitionSpec)", "CreateAttributeDefinition(AttributeDefinitionSpec)",
"CreateTable(TableSpec)", "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": { "Query": {
@@ -1180,7 +1346,11 @@
"GetDimensionMeasurement { id: ObjectId }", "GetDimensionMeasurement { id: ObjectId }",
"GetAttributes { id: ObjectId }", "GetAttributes { id: ObjectId }",
"GetBlockEntities { block_name: String }", "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", "XLine",
"Text", "Text",
"MText", "MText",
"Dimension" "Dimension",
"XRecord",
"Layer"
], ],
"root": "DocApi" "root": "DocApi"
}, },
+14 -2
View File
@@ -29,7 +29,15 @@
"CreateDimensionAngular(DimensionAngularSpec)", "CreateDimensionAngular(DimensionAngularSpec)",
"CreateAttributeDefinition(AttributeDefinitionSpec)", "CreateAttributeDefinition(AttributeDefinitionSpec)",
"CreateTable(TableSpec)", "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": { "Query": {
@@ -45,7 +53,11 @@
"GetDimensionMeasurement { id: ObjectId }", "GetDimensionMeasurement { id: ObjectId }",
"GetAttributes { id: ObjectId }", "GetAttributes { id: ObjectId }",
"GetBlockEntities { block_name: String }", "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::{ use crate::ops::{
AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec,
DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, 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 /// A typed write operation. Each variant is ONE atomic host call = one
@@ -99,4 +99,29 @@ pub enum Operation {
CreateTable(TableSpec), CreateTable(TableSpec),
// dimension sub-types (2-line angular) // dimension sub-types (2-line angular)
CreateDimensionAngular2Ln(DimensionAngularSpec), 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 { GetViewportView {
id: ObjectId, 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 envelope;
pub mod facade; pub mod facade;
pub mod layer;
pub mod ops; pub mod ops;
pub mod query; pub mod query;
pub mod transport; pub mod transport;
@@ -41,9 +42,14 @@ pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt};
pub use facade::{ pub use facade::{
ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity, ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity,
EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray, 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}; pub use query::{Aabb, EntityView, Query, QueryResult};
#[cfg(feature = "host")] #[cfg(feature = "host")]
@@ -51,6 +57,9 @@ pub use convert::{
bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name,
entity_to_profile_curves, transform_entity_geometry, entity_to_profile_curves, transform_entity_geometry,
}; };
#[cfg(feature = "doc_api_host")]
pub use transport::ipc::doc_api_for_host;
pub use transport::Transport; pub use transport::Transport;
/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`). /// 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; 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`]. /// Boolean operation kind for [`Operation::SolidBoolean`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BoolOp { pub enum BoolOp {
@@ -16,6 +90,72 @@ pub enum BoolOp {
Difference, 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 /// Construction spec for a B-rep solid primitive (plain-data mirror of the
/// non-serde `cadkernel::brep::make::*` arguments). /// non-serde `cadkernel::brep::make::*` arguments).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -55,17 +195,25 @@ pub enum Curve2Spec {
Line { Line {
start: [f64; 3], start: [f64; 3],
end: [f64; 3], end: [f64; 3],
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
}, },
Circle { Circle {
centre: [f64; 3], centre: [f64; 3],
radius: f64, radius: f64,
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
}, },
Polyline { Polyline {
points: Vec<[f64; 3]>, points: Vec<[f64; 3]>,
closed: bool, closed: bool,
/// Target layer name. `None` places the entity on the current layer.
layer: Option<String>,
}, },
Point { Point {
position: [f64; 3], 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). /// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians).
Arc { Arc {
@@ -73,6 +221,8 @@ pub enum Curve2Spec {
radius: f64, radius: f64,
start_angle: f64, start_angle: f64,
end_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`; /// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`;
/// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π). /// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π).
@@ -82,6 +232,8 @@ pub enum Curve2Spec {
ratio: f64, ratio: f64,
start: f64, start: f64,
end: 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). /// NURBS spline (control points + knots + weights; `degree` typically 3).
Spline { Spline {
@@ -89,19 +241,42 @@ pub enum Curve2Spec {
control_points: Vec<[f64; 3]>, control_points: Vec<[f64; 3]>,
knots: Vec<f64>, knots: Vec<f64>,
weights: 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). /// A ray from `origin` along `direction` (bounded at origin only).
Ray { Ray {
origin: [f64; 3], origin: [f64; 3],
direction: [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`. /// An infinite construction line through `origin` along `direction`.
XLine { XLine {
origin: [f64; 3], origin: [f64; 3],
direction: [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 /// A generic entity-construction payload used by the bulk op
/// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction /// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction
/// specs so one bulk op can carry many homogeneous (or mixed) creations. /// specs so one bulk op can carry many homogeneous (or mixed) creations.
@@ -111,6 +286,16 @@ pub enum EntitySpec {
Solid(SolidPrimitive), 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 /// Construction spec for a block reference (`INSERT`): place the block
/// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform /// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform
/// `scale` and `rotation` (radians, about Z). /// `scale` and `rotation` (radians, about Z).
@@ -291,6 +476,14 @@ impl crate::gen::Operation {
CreateAttributeDefinition(_) => "CreateAttributeDefinition", CreateAttributeDefinition(_) => "CreateAttributeDefinition",
CreateTable(_) => "CreateTable", CreateTable(_) => "CreateTable",
CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln", 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"). /// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line").
pub kind: String, pub kind: String,
pub bounds: Option<Aabb>, 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). /// The result of a single [`Query`] (one per query in a `Queries` batch).
@@ -68,6 +70,20 @@ pub enum QueryResult {
target: [f64; 3], target: [f64; 3],
height: f64, 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. /// Convenience: the query name for diagnostics.
@@ -87,6 +103,13 @@ impl crate::gen::Query {
GetAttributes { .. } => "GetAttributes", GetAttributes { .. } => "GetAttributes",
GetBlockEntities { .. } => "GetBlockEntities", GetBlockEntities { .. } => "GetBlockEntities",
GetViewportView { .. } => "GetViewportView", 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 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 ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
use crate::envelope::{DocApiEnvelope, Receipt}; use crate::envelope::{DocApiEnvelope, Receipt};
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::facade::DocApi;
use crate::transport::Transport; use crate::transport::Transport;
/// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`; /// 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 { impl Transport for OcsPluginApiIpc {
fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt> { fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt> {
let bytes = bincode::serialize(&req) 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. //! 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 { fn finite(values: &[f64]) -> bool {
values.iter().all(|v| v.is_finite()) 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<()> { pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
use Curve2Spec::*; use Curve2Spec::*;
let valid = match spec { let valid = match spec {
Line { start, end } => finite(start) && finite(end) && start != end, Line { start, end, .. } => finite(start) && finite(end) && start != end,
Circle { centre, radius } => finite(centre) && positive(*radius), Circle { centre, radius, .. } => finite(centre) && positive(*radius),
Point { position } => finite(position), Point { position, .. } => finite(position),
Arc { Arc {
centre, centre,
radius, radius,
start_angle, start_angle,
end_angle, end_angle,
..
} => finite(centre) && positive(*radius) && finite(&[*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() >= if *closed { 3 } else { 2 }
&& points.len() <= BULK_ITEM_CAP && points.len() <= BULK_ITEM_CAP
&& points.iter().all(|p| finite(p) && p[2] == points[0][2]) && points.iter().all(|p| finite(p) && p[2] == points[0][2])
@@ -44,6 +45,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
ratio, ratio,
start, start,
end, end,
..
} => { } => {
finite(centre) finite(centre)
&& direction(major_axis) && direction(major_axis)
@@ -56,6 +58,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
control_points, control_points,
knots, knots,
weights, weights,
..
} => { } => {
*degree > 0 *degree > 0
&& control_points.len() <= BULK_ITEM_CAP && control_points.len() <= BULK_ITEM_CAP
@@ -76,10 +79,12 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
Ray { Ray {
origin, origin,
direction: d, direction: d,
..
} }
| XLine { | XLine {
origin, origin,
direction: d, direction: d,
..
} => finite(origin) && direction(d), } => finite(origin) && direction(d),
}; };
check(valid, "CreateCurve") 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(), CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(),
CreateMText(s) => finite(&s.insertion_point) && positive(s.height), 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) => { CreateHatch(s) => {
(3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p)) (3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p))
} }
+462 -24
View File
@@ -8,15 +8,16 @@ use std::sync::Arc;
use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::backend::{DocApiBackend, KernelBody};
use ocs_doc_api::{ use ocs_doc_api::{
Aabb, ApiError, ApiResult, Curve2Spec, DocApi, EntityView, GeometryErrorKind, GeometryRevision, normalize_layer_name, Aabb, ApiError, ApiResult, Color, Curve2Spec, DocApi, EntityView,
HasId, ObjectId, PlacementSpec, GeometryErrorKind, GeometryRevision, HasId, LayerInfo, LineWeight, ObjectId, PlacementSpec,
XDataRecord, XDataValue,
}; };
/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds and /// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds and
/// the example mock — all three must answer curve bounds identically). /// the example mock — all three must answer curve bounds identically).
fn curve_bounds(spec: &Curve2Spec) -> Aabb { fn curve_bounds(spec: &Curve2Spec) -> Aabb {
match spec { match spec {
Curve2Spec::Line { start, end } => Aabb { Curve2Spec::Line { start, end, .. } => Aabb {
min: [ min: [
start[0].min(end[0]), start[0].min(end[0]),
start[1].min(end[1]), start[1].min(end[1]),
@@ -28,11 +29,12 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb {
start[2].max(end[2]), start[2].max(end[2]),
], ],
}, },
Curve2Spec::Circle { centre, radius } | Curve2Spec::Arc { centre, radius, .. } => Aabb { Curve2Spec::Circle { centre, radius, .. }
| Curve2Spec::Arc { centre, radius, .. } => Aabb {
min: [centre[0] - radius, centre[1] - radius, centre[2]], min: [centre[0] - radius, centre[1] - radius, centre[2]],
max: [centre[0] + radius, centre[1] + radius, centre[2]], max: [centre[0] + radius, centre[1] + radius, centre[2]],
}, },
Curve2Spec::Point { position } => Aabb { Curve2Spec::Point { position, .. } => Aabb {
min: *position, min: *position,
max: *position, max: *position,
}, },
@@ -72,13 +74,17 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb {
/// In-memory mock backend: HashMap<ObjectId, Body> + a tiny entity table. /// In-memory mock backend: HashMap<ObjectId, Body> + a tiny entity table.
/// Tracks undo steps + revision bumps to assert per-op semantics. /// Tracks undo steps + revision bumps to assert per-op semantics.
#[derive(Default, Clone)] #[derive(Clone)]
struct MockBackend { struct MockBackend {
next_id: u64, next_id: u64,
revision: u64, revision: u64,
undo_steps: u32, undo_steps: u32,
bodies: HashMap<ObjectId, KernelBody>, bodies: HashMap<ObjectId, KernelBody>,
kinds: HashMap<ObjectId, String>, kinds: HashMap<ObjectId, String>,
/// Layer name assigned to each entity.
entity_layers: HashMap<ObjectId, String>,
/// Stored layer table.
layers: HashMap<String, ocs_doc_api::LayerInfo>,
/// Stored text content for Text/MText annotations. /// Stored text content for Text/MText annotations.
text_values: HashMap<ObjectId, String>, text_values: HashMap<ObjectId, String>,
/// Stored hatch boundary loops. /// Stored hatch boundary loops.
@@ -92,6 +98,36 @@ struct MockBackend {
curves: HashMap<ObjectId, Curve2Spec>, curves: HashMap<ObjectId, Curve2Spec>,
/// Stored viewport views (target, height). /// Stored viewport views (target, height).
viewport_views: HashMap<ObjectId, ([f64; 3], f64)>, viewport_views: HashMap<ObjectId, ([f64; 3], f64)>,
/// Stored XDATA records keyed by (entity id, application name).
xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>,
/// Stored XRECORD payloads keyed by entity id.
xrecord_store: HashMap<ObjectId, ocs_doc_api::XRecordSpec>,
}
impl Default for MockBackend {
fn default() -> Self {
let mut backend = Self {
next_id: 0,
revision: 0,
undo_steps: 0,
bodies: HashMap::new(),
kinds: HashMap::new(),
entity_layers: HashMap::new(),
layers: HashMap::new(),
text_values: HashMap::new(),
hatch_boundaries: HashMap::new(),
dimension_measurements: HashMap::new(),
attributes_store: HashMap::new(),
curves: HashMap::new(),
viewport_views: HashMap::new(),
xdata_store: HashMap::new(),
xrecord_store: HashMap::new(),
};
backend
.layers
.insert("0".to_string(), ocs_doc_api::LayerInfo::new("0"));
backend
}
} }
impl MockBackend { impl MockBackend {
@@ -104,6 +140,22 @@ impl MockBackend {
fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> {
self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) self.bodies.get(&id).ok_or(ApiError::UnknownId(id))
} }
fn add_curve_only(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
let kind = match spec {
Curve2Spec::Line { .. } => "Line",
Curve2Spec::Circle { .. } => "Circle",
Curve2Spec::Polyline { .. } => "LwPolyline",
Curve2Spec::Point { .. } => "Point",
Curve2Spec::Arc { .. } => "Arc",
Curve2Spec::Ellipse { .. } => "Ellipse",
Curve2Spec::Spline { .. } => "Spline",
Curve2Spec::Ray { .. } => "Ray",
Curve2Spec::XLine { .. } => "XLine",
};
let id = self.alloc(kind);
self.curves.insert(id, spec.clone());
Ok(id)
}
} }
impl DocApiBackend for MockBackend { impl DocApiBackend for MockBackend {
@@ -113,6 +165,7 @@ impl DocApiBackend for MockBackend {
fn store_solid(&mut self, body: &KernelBody) -> ApiResult<ObjectId> { fn store_solid(&mut self, body: &KernelBody) -> ApiResult<ObjectId> {
let id = self.alloc("Solid3D"); let id = self.alloc("Solid3D");
self.bodies.insert(id, body.clone()); self.bodies.insert(id, body.clone());
self.entity_layers.insert(id, "0".to_string());
Ok(id) Ok(id)
} }
fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> {
@@ -122,6 +175,15 @@ impl DocApiBackend for MockBackend {
Ok(()) Ok(())
} }
fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult<Vec<ObjectId>> { fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult<Vec<ObjectId>> {
// Validate per-spec layers up front so the op is atomic.
for spec in specs {
if let Some(name) = spec.layer() {
self.lookup_layer(name).map_err(|e| ApiError::validation(
"CreateMany",
e.to_string(),
))?;
}
}
let mut next = self.clone(); let mut next = self.clone();
let ids = specs let ids = specs
.iter() .iter()
@@ -151,26 +213,35 @@ impl DocApiBackend for MockBackend {
Ok(()) Ok(())
} }
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> { fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
let kind = match spec { let layer = spec
Curve2Spec::Line { .. } => "Line", .layer()
Curve2Spec::Circle { .. } => "Circle", .map(|name| {
Curve2Spec::Polyline { .. } => "LwPolyline", self.lookup_layer(name).map(|info| info.normalized_name())
Curve2Spec::Point { .. } => "Point", })
Curve2Spec::Arc { .. } => "Arc", .transpose()?;
Curve2Spec::Ellipse { .. } => "Ellipse", let id = self.add_curve_only(spec)?;
Curve2Spec::Spline { .. } => "Spline", self.entity_layers
Curve2Spec::Ray { .. } => "Ray", .insert(id, layer.unwrap_or_else(|| "0".to_string()));
Curve2Spec::XLine { .. } => "XLine",
};
let id = self.alloc(kind);
self.curves.insert(id, spec.clone());
Ok(id) Ok(id)
} }
fn lookup_layer(&self, name: &str) -> ApiResult<ocs_doc_api::LayerInfo> {
let norm = normalize_layer_name(name);
if norm.is_empty() {
return Err(ApiError::validation("LookupLayer", "empty layer name"));
}
self.layers
.get(&norm)
.cloned()
.ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist")))
}
fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult<ObjectId> { fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult<ObjectId> {
if spec.block_name.is_empty() { if spec.block_name.is_empty() {
return Err(ApiError::validation("CreateInsert", "empty block name")); return Err(ApiError::validation("CreateInsert", "empty block name"));
} }
Ok(self.alloc("Insert")) let id = self.alloc("Insert");
self.entity_layers.insert(id, "0".to_string());
Ok(id)
} }
fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> { fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Viewport"); let id = self.alloc("Viewport");
@@ -181,11 +252,13 @@ impl DocApiBackend for MockBackend {
fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult<ObjectId> { fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Text"); let id = self.alloc("Text");
self.text_values.insert(id, spec.value.clone()); self.text_values.insert(id, spec.value.clone());
self.entity_layers.insert(id, "0".to_string());
Ok(id) Ok(id)
} }
fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult<ObjectId> { fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult<ObjectId> {
let id = self.alloc("MText"); let id = self.alloc("MText");
self.text_values.insert(id, spec.value.clone()); self.text_values.insert(id, spec.value.clone());
self.entity_layers.insert(id, "0".to_string());
Ok(id) Ok(id)
} }
fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> {
@@ -206,6 +279,7 @@ impl DocApiBackend for MockBackend {
let id = self.alloc("Hatch"); let id = self.alloc("Hatch");
self.hatch_boundaries self.hatch_boundaries
.insert(id, vec![spec.boundary.clone()]); .insert(id, vec![spec.boundary.clone()]);
self.entity_layers.insert(id, "0".to_string());
Ok(id) Ok(id)
} }
fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>> { fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>> {
@@ -224,6 +298,7 @@ impl DocApiBackend for MockBackend {
+ (spec.first_point[2] - spec.second_point[2]).powi(2)) + (spec.first_point[2] - spec.second_point[2]).powi(2))
.sqrt(); .sqrt();
self.dimension_measurements.insert(id, d); self.dimension_measurements.insert(id, d);
self.entity_layers.insert(id, "0".to_string());
Ok(id) Ok(id)
} }
fn add_dimension_radius( fn add_dimension_radius(
@@ -236,13 +311,16 @@ impl DocApiBackend for MockBackend {
+ (spec.center[2] - spec.point[2]).powi(2)) + (spec.center[2] - spec.point[2]).powi(2))
.sqrt(); .sqrt();
self.dimension_measurements.insert(id, d); self.dimension_measurements.insert(id, d);
self.entity_layers.insert(id, "0".to_string());
Ok(id) Ok(id)
} }
fn add_dimension_diameter( fn add_dimension_diameter(
&mut self, &mut self,
spec: &ocs_doc_api::ops::DimensionRadialSpec, spec: &ocs_doc_api::ops::DimensionRadialSpec,
) -> ApiResult<ObjectId> { ) -> ApiResult<ObjectId> {
self.add_dimension_radius(spec) let id = self.add_dimension_radius(spec)?;
self.entity_layers.insert(id, "0".to_string());
Ok(id)
} }
fn add_dimension_angular( fn add_dimension_angular(
&mut self, &mut self,
@@ -250,6 +328,7 @@ impl DocApiBackend for MockBackend {
) -> ApiResult<ObjectId> { ) -> ApiResult<ObjectId> {
let id = self.alloc("Dimension"); let id = self.alloc("Dimension");
self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees
self.entity_layers.insert(id, "0".to_string());
Ok(id) Ok(id)
} }
fn dimension_measurement(&self, id: ObjectId) -> ApiResult<f64> { fn dimension_measurement(&self, id: ObjectId) -> ApiResult<f64> {
@@ -286,7 +365,9 @@ impl DocApiBackend for MockBackend {
"table needs a non-empty rectangular grid", "table needs a non-empty rectangular grid",
)); ));
} }
Ok(self.alloc("Table")) let id = self.alloc("Table");
self.entity_layers.insert(id, "0".to_string());
Ok(id)
} }
fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> {
if !self.entity_exists(id) { if !self.entity_exists(id) {
@@ -313,7 +394,9 @@ impl DocApiBackend for MockBackend {
&mut self, &mut self,
_spec: &ocs_doc_api::ops::RasterImageSpec, _spec: &ocs_doc_api::ops::RasterImageSpec,
) -> ApiResult<ObjectId> { ) -> ApiResult<ObjectId> {
Ok(self.alloc("RasterImage")) let id = self.alloc("RasterImage");
self.entity_layers.insert(id, "0".to_string());
Ok(id)
} }
fn loft( fn loft(
&mut self, &mut self,
@@ -345,6 +428,203 @@ impl DocApiBackend for MockBackend {
.copied() .copied()
.ok_or(ApiError::UnknownId(id)) .ok_or(ApiError::UnknownId(id))
} }
fn set_xdata(
&mut self,
id: ObjectId,
application_name: &str,
record: Option<&ocs_doc_api::XDataRecord>,
) -> ApiResult<()> {
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
let key = (id, application_name.to_string());
match record {
Some(r) => {
self.xdata_store.insert(key, r.clone());
}
None => {
self.xdata_store.remove(&key);
}
}
Ok(())
}
fn xdata(
&self,
id: ObjectId,
application_name: &str,
) -> ApiResult<Option<ocs_doc_api::XDataRecord>> {
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
Ok(self
.xdata_store
.get(&(id, application_name.to_string()))
.cloned())
}
fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<ObjectId> {
let id = self.alloc("XRecord");
self.xrecord_store.insert(id, spec.clone());
Ok(id)
}
fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> {
if !self.object_exists(id) {
return Err(ApiError::validation(
"SetXRecord",
format!("unknown ObjectId {id:?}"),
));
}
self.xrecord_store.insert(id, spec.clone());
Ok(())
}
fn xrecord(&self, id: ObjectId) -> ApiResult<Option<ocs_doc_api::XRecordSpec>> {
if !self.object_exists(id) {
return Err(ApiError::validation(
"GetXRecord",
format!("unknown ObjectId {id:?}"),
));
}
Ok(self.xrecord_store.get(&id).cloned())
}
fn create_layer(&mut self, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> {
let name = normalize_layer_name(&info.name);
if name.is_empty() {
return Err(ApiError::validation("CreateLayer", "empty layer name"));
}
if self.layers.contains_key(&name) {
return Err(ApiError::validation(
"CreateLayer",
format!("layer '{name}' already exists"),
));
}
let mut stored = info.clone();
stored.name = name.clone();
self.layers.insert(name, stored);
Ok(())
}
fn update_layer(&mut self, name: &str, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> {
let name_upper = normalize_layer_name(name);
if !self.layers.contains_key(&name_upper) {
return Err(ApiError::validation(
"UpdateLayer",
format!("layer '{name}' does not exist"),
));
}
let mut stored = info.clone();
stored.name = name_upper.clone();
self.layers.insert(name_upper, stored);
Ok(())
}
fn delete_layer(&mut self, name: &str) -> ApiResult<()> {
let name_upper = normalize_layer_name(name);
if name_upper == "0" {
return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0"));
}
if self
.entity_layers
.values()
.any(|l| normalize_layer_name(l) == name_upper)
{
return Err(ApiError::validation(
"DeleteLayer",
format!("layer '{name_upper}' still in use"),
));
}
if self.layers.remove(&name_upper).is_none() {
return Err(ApiError::validation(
"DeleteLayer",
format!("layer '{name}' does not exist"),
));
}
Ok(())
}
fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> {
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
let layer_upper = normalize_layer_name(layer);
let target = self
.layers
.get(&layer_upper)
.ok_or_else(|| {
ApiError::validation(
"SetEntityLayer",
format!("layer '{layer_upper}' does not exist"),
)
})?;
if target.flags.locked {
return Err(ApiError::Unsupported(format!(
"entity {id:?} cannot be moved to locked layer '{layer_upper}'"
)));
}
self.entity_layers.insert(id, layer_upper);
Ok(())
}
fn layers(&self) -> ApiResult<Vec<ocs_doc_api::LayerInfo>> {
let mut v: Vec<_> = self.layers.values().cloned().collect();
v.sort_by(|a, b| a.name.cmp(&b.name));
Ok(v)
}
fn entity_layer(&self, id: ObjectId) -> ApiResult<String> {
self.entity_layers
.get(&id)
.cloned()
.ok_or(ApiError::UnknownId(id))
}
fn enumerate_entities(
&self,
kind: Option<&str>,
layer: Option<&str>,
include_bounds: bool,
) -> ApiResult<Vec<EntityView>> {
let mut out = Vec::new();
for (id, kind_name) in &self.kinds {
if let Some(k) = kind {
if !k.eq_ignore_ascii_case(kind_name) {
continue;
}
}
let entity_layer = self
.entity_layers
.get(id)
.cloned()
.unwrap_or_else(|| "0".to_string());
if let Some(l) = layer {
if !l.eq_ignore_ascii_case(&entity_layer) {
continue;
}
}
let bounds = if include_bounds {
self.curves.get(id).map(curve_bounds)
} else {
None
};
out.push(EntityView {
id: *id,
kind: kind_name.clone(),
layer: entity_layer,
bounds,
});
}
Ok(out)
}
fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> {
let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?;
match spec {
Curve2Spec::Point { position, .. } => Ok(*position),
_ => Err(ApiError::Unsupported(
"GetPointPosition is only for Point entities".into(),
)),
}
}
fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> {
let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?;
match spec {
Curve2Spec::Line { start, end, .. } => Ok((*start, *end)),
_ => Err(ApiError::Unsupported(
"GetLineGeometry is only for Line entities".into(),
)),
}
}
fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> {
if self.entity_exists(id) { if self.entity_exists(id) {
Ok(()) Ok(())
@@ -354,6 +634,9 @@ impl DocApiBackend for MockBackend {
} }
fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool> { fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool> {
self.bodies.remove(&id); self.bodies.remove(&id);
self.xdata_store.retain(|(k, _), _| *k != id);
self.xrecord_store.remove(&id);
self.entity_layers.remove(&id);
Ok(self.kinds.remove(&id).is_some()) Ok(self.kinds.remove(&id).is_some())
} }
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> { fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> {
@@ -364,7 +647,17 @@ impl DocApiBackend for MockBackend {
max: bb.max, max: bb.max,
}) })
}); });
Ok(EntityView { id, kind, bounds }) let layer = self
.entity_layers
.get(&id)
.cloned()
.unwrap_or_else(|| "0".to_string());
Ok(EntityView {
id,
kind,
layer,
bounds,
})
} }
fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> {
if !self.entity_exists(id) { if !self.entity_exists(id) {
@@ -412,6 +705,9 @@ impl DocApiBackend for MockBackend {
fn entity_exists(&self, id: ObjectId) -> bool { fn entity_exists(&self, id: ObjectId) -> bool {
self.kinds.contains_key(&id) self.kinds.contains_key(&id)
} }
fn object_exists(&self, id: ObjectId) -> bool {
self.xrecord_store.contains_key(&id)
}
fn revision(&self) -> GeometryRevision { fn revision(&self) -> GeometryRevision {
GeometryRevision(self.revision) GeometryRevision(self.revision)
} }
@@ -609,3 +905,145 @@ fn typed_handles_reject_foreign_sessions_and_tabs() {
assert!(group.compensate(&doc).is_err()); assert!(group.compensate(&doc).is_err());
assert!(first.document(11).curves().create_point([0.0; 3]).is_err()); assert!(first.document(11).curves().create_point([0.0; 3]).is_err());
} }
#[test]
fn doc_api_layer_crud_via_facade_and_entity_assignment() {
let (api, _tp) = api();
let doc = api.document(api.active_tab());
let mut info = LayerInfo::new("Walls");
info.color = Color::Index(1);
info.line_weight = LineWeight::Value(25);
doc.create_layer(&info).unwrap();
let layers = doc.layers().unwrap();
assert!(layers.iter().any(|l| l.name == "WALLS"));
let mut updated = info.clone();
updated.color = Color::Index(2);
doc.update_layer("walls", &updated).unwrap();
let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap();
line.set_layer("Walls").unwrap();
assert_eq!(line.layer().unwrap(), "WALLS");
let err = doc.delete_layer("Walls").unwrap_err();
assert!(matches!(err, ApiError::Validation { .. }));
let other = LayerInfo::new("Other");
doc.create_layer(&other).unwrap();
line.set_layer("Other").unwrap();
doc.delete_layer("Walls").unwrap();
assert!(!doc.layers().unwrap().iter().any(|l| l.name == "WALLS"));
}
#[test]
fn set_xdata_many_is_atomic_and_assigns_records() {
let (api, _tp) = api();
let doc = api.document(api.active_tab());
let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap();
let circle = doc.curves().create_circle([0.0; 3], 1.0).unwrap();
let record1 = XDataRecord {
application_name: "APP1".to_string(),
values: vec![XDataValue::Integer32(42)],
};
let record2 = XDataRecord {
application_name: "APP2".to_string(),
values: vec![XDataValue::Real(3.14)],
};
doc.set_xdata_many([
(line.id(), "APP1", Some(record1.clone())),
(circle.id(), "APP2", Some(record2.clone())),
])
.unwrap();
let r1 = doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().unwrap();
assert_eq!(r1.values, vec![XDataValue::Integer32(42)]);
let r2 = doc.entities().get(circle.id()).unwrap().xdata("APP2").unwrap().unwrap();
assert_eq!(r2.values, vec![XDataValue::Real(3.14)]);
}
#[test]
fn set_xdata_many_with_unknown_id_fails_atomically() {
let (api, _tp) = api();
let doc = api.document(api.active_tab());
let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap();
let stale = ObjectId::from_u64(999_999);
let record = XDataRecord {
application_name: "APP1".to_string(),
values: vec![XDataValue::Integer32(42)],
};
let err = doc
.set_xdata_many([
(line.id(), "APP1", Some(record.clone())),
(stale, "APP2", Some(record)),
])
.unwrap_err();
assert!(matches!(err, ApiError::UnknownId(..) | ApiError::Validation { .. }));
assert!(doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().is_none());
}
#[test]
fn create_many_applies_per_curve_layer() {
let (api, _tp) = api();
let doc = api.document(api.active_tab());
doc.create_layer(&LayerInfo::new("Walls")).unwrap();
let ids = doc
.entities()
.create_many(&[
ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line {
start: [0.0; 3],
end: [1.0; 3],
layer: Some("Walls".to_string()),
}),
ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point {
position: [2.0; 3],
layer: None,
}),
])
.unwrap();
assert_eq!(ids[0].layer().unwrap(), "WALLS");
assert_eq!(ids[1].layer().unwrap(), "0");
}
#[test]
fn create_many_rejects_missing_layer_before_mutating() {
let (api, tp) = api();
let doc = api.document(api.active_tab());
let rev0 = doc.revision().unwrap().as_u64();
let err = doc
.entities()
.create_many(&[
ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line {
start: [0.0; 3],
end: [1.0; 3],
layer: Some("Missing".to_string()),
}),
ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point {
position: [2.0; 3],
layer: None,
}),
])
.unwrap_err();
assert!(matches!(err, ApiError::Validation { .. }));
let backend = tp.backend();
assert_eq!(backend.revision, rev0);
assert_eq!(backend.next_id, 0);
}
#[test]
fn create_line_on_layer_requires_existing_layer() {
let (api, _tp) = api();
let doc = api.document(api.active_tab());
let err = doc
.curves()
.create_line_on_layer([0.0; 3], [1.0; 3], "Missing")
.unwrap_err();
assert!(matches!(err, ApiError::Validation { .. }));
}
@@ -38,7 +38,8 @@ fn spec() -> Spec {
/// The variant names of the `Operation` enum, in declaration order. /// The variant names of the `Operation` enum, in declaration order.
fn operation_variants() -> Vec<String> { fn operation_variants() -> Vec<String> {
use ocs_doc_api::ops::{ use ocs_doc_api::ops::{
BoolOp, Curve2Spec, InsertSpec, Operation, PlacementSpec, SolidPrimitive, ViewportSpec, BoolOp, Curve2Spec, InsertSpec, LayerInfo, Operation, PlacementSpec, SolidPrimitive,
ViewportSpec,
}; };
use ocs_doc_api::ObjectId; use ocs_doc_api::ObjectId;
let _marker: Vec<Operation> = vec![ let _marker: Vec<Operation> = vec![
@@ -46,7 +47,10 @@ fn operation_variants() -> Vec<String> {
centre: [0.0; 3], centre: [0.0; 3],
radius: 1.0, radius: 1.0,
}), }),
Operation::CreateCurve(Curve2Spec::Point { position: [0.0; 3] }), Operation::CreateCurve(Curve2Spec::Point {
position: [0.0; 3],
layer: None,
}),
Operation::Extrude { Operation::Extrude {
profile: ObjectId::from_u64(0), profile: ObjectId::from_u64(0),
direction: [0.0; 3], direction: [0.0; 3],
@@ -167,6 +171,37 @@ fn operation_variants() -> Vec<String> {
second_point: [0.0; 3], second_point: [0.0; 3],
arc_location: [0.0; 3], arc_location: [0.0; 3],
}), }),
Operation::SetXData {
id: ObjectId::from_u64(0),
application_name: String::new(),
record: None,
},
Operation::SetXDataMany(vec![]),
Operation::CreateXRecord(ocs_doc_api::ops::XRecordSpec {
name: String::new(),
cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable,
entries: vec![],
}),
Operation::SetXRecord {
id: ObjectId::from_u64(0),
spec: ocs_doc_api::ops::XRecordSpec {
name: String::new(),
cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable,
entries: vec![],
},
},
Operation::CreateLayer(LayerInfo::new("LAYER")),
Operation::UpdateLayer {
name: String::new(),
info: LayerInfo::new("LAYER"),
},
Operation::DeleteLayer {
name: String::new(),
},
Operation::SetEntityLayer {
id: ObjectId::from_u64(0),
layer: String::new(),
},
]; ];
_marker _marker
.iter() .iter()
@@ -178,7 +213,7 @@ fn operation_variants() -> Vec<String> {
index index
); );
let dbg = format!("{:?}", op); let dbg = format!("{:?}", op);
dbg.split(|c: char| c == '(' || c == ' ' || c == '{') dbg.split(['(', ' ', '{'])
.next() .next()
.unwrap_or("") .unwrap_or("")
.to_string() .to_string()
@@ -226,6 +261,17 @@ fn query_variants() -> Vec<String> {
Query::GetViewportView { Query::GetViewportView {
id: ObjectId::from_u64(0), id: ObjectId::from_u64(0),
}, },
Query::GetXData {
id: ObjectId::from_u64(0),
application_name: String::new(),
},
Query::GetXRecord {
id: ObjectId::from_u64(0),
},
Query::ListLayers,
Query::GetEntityLayer {
id: ObjectId::from_u64(0),
},
]; ];
marker marker
.iter() .iter()
@@ -237,7 +283,7 @@ fn query_variants() -> Vec<String> {
index index
); );
let dbg = format!("{:?}", q); let dbg = format!("{:?}", q);
dbg.split(|c: char| c == '(' || c == ' ' || c == '{') dbg.split(['(', ' ', '{'])
.next() .next()
.unwrap_or("") .unwrap_or("")
.to_string() .to_string()
@@ -309,6 +355,14 @@ const OPERATION_BASELINE: &[&str] = &[
"CreateAttributeDefinition", "CreateAttributeDefinition",
"CreateTable", "CreateTable",
"CreateDimensionAngular2Ln", "CreateDimensionAngular2Ln",
"SetXData",
"SetXDataMany",
"CreateXRecord",
"SetXRecord",
"CreateLayer",
"UpdateLayer",
"DeleteLayer",
"SetEntityLayer",
]; ];
/// Recorded baseline of the `Query` variant order. /// Recorded baseline of the `Query` variant order.
@@ -325,6 +379,10 @@ const QUERY_BASELINE: &[&str] = &[
"GetAttributes", "GetAttributes",
"GetBlockEntities", "GetBlockEntities",
"GetViewportView", "GetViewportView",
"GetXData",
"GetXRecord",
"ListLayers",
"GetEntityLayer",
]; ];
#[test] #[test]
+960 -96
View File
File diff suppressed because it is too large. Load diff