diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md index 7d4a34f73..8603f8a10 100644 --- a/crates/ocs_doc_api/ARCHITECTURE.md +++ b/crates/ocs_doc_api/ARCHITECTURE.md @@ -4,6 +4,12 @@ `Transport::apply` carries one operation or a read-only query batch to the 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 must prepare fallible geometry and serialization before changing the document. 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 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, add a native behavior test, and rebuild the generated reference/schema. New 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. diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 592effe98..f60f46a86 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -34,6 +34,8 @@ default = [] host = ["acadrust", "cadkernel"] # ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side). ipc = ["acadrust", "ocs_plugin_api"] +# doc_api_host: convenience helper that builds a DocApi from a HostApi (IPC-only). +doc_api_host = ["ipc"] [lib] name = "ocs_doc_api" diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md index bd1f0289a..cc38a56ca 100644 --- a/crates/ocs_doc_api/README.md +++ b/crates/ocs_doc_api/README.md @@ -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 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 | |---|---| | Default | DTOs, facade, transport trait and binding schema | | `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` | | `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host | +| `doc_api_host` | Convenience `doc_api_for_host(&dyn HostApi) -> Option` 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 { + 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, [architecture](ARCHITECTURE.md) for backend rules, and diff --git a/crates/ocs_doc_api/bindings/README.md b/crates/ocs_doc_api/bindings/README.md index 13f351c75..deb13c55d 100644 --- a/crates/ocs_doc_api/bindings/README.md +++ b/crates/ocs_doc_api/bindings/README.md @@ -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`. 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 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 -for an error response. Transport binding determines the tab; requesting another -tab or combining typed handles from different documents raises an error. +unwraps these into typed handles or values. Layer results use `{"Layers": [...]}` +and `{"EntityLayer": "WALLS"}`. Layer properties (`LayerInfo`, `LayerFlags`, +`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. diff --git a/crates/ocs_doc_api/bindings/python/generate.py b/crates/ocs_doc_api/bindings/python/generate.py index 53259fb15..d361493ba 100644 --- a/crates/ocs_doc_api/bindings/python/generate.py +++ b/crates/ocs_doc_api/bindings/python/generate.py @@ -36,6 +36,12 @@ class Entity: def delete(self): 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 = ''' @@ -79,6 +85,18 @@ class Document: def curves(self): return Curves(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 class DocApi: _transport: Any diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs index 74c522413..67fab317a 100644 --- a/crates/ocs_doc_api/build.rs +++ b/crates/ocs_doc_api/build.rs @@ -168,10 +168,32 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, - **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: solids only.** - **`delete()`** — remove the entity (one undo step). +- **`layer()` -> `String`** — the name of the layer the entity is on. +- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; + blocked if the entity or target layer is locked). `Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed downcast when `kind == "Solid3D"`). +## Layer table + +Layers are a named document table, not an entity family. `Document::layers()` +lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, +`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` +returns the layer name and `Entity::set_layer(layer)` moves the entity to an +existing layer. The host rejects duplicate names, deletion of layer "0" or the +current layer, and removing a layer that still has entities assigned. + +## Transports + +- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. +- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the + `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes + `DocApiRequest { tab_id, bytes }` to the same executor. +- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that + builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a + `PluginRequestSender` (out-of-process / worker-thread plugins). + ## Collections & cross-cutting - **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 363841d44..8b7b7454b 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -8,11 +8,12 @@ use std::collections::HashMap; use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, - ObjectId, PlacementSpec, + normalize_layer_name, Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, + GeometryRevision, LayerInfo, ObjectId, PlacementSpec, }; -#[derive(Default, Clone)] + +#[derive(Clone)] pub struct MockBackend { next_id: u64, revision: u64, @@ -31,6 +32,38 @@ pub struct MockBackend { attributes_store: HashMap>, /// Stored viewport views (target, height). viewport_views: HashMap, + /// 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, + /// Stored layer table. + layers: HashMap, + /// Layer name assigned to each entity. + entity_layers: HashMap, +} + +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 { @@ -52,7 +85,7 @@ impl MockBackend { /// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds). fn curve_bounds(spec: &Curve2Spec) -> Aabb { match spec { - Curve2Spec::Line { start, end } => Aabb { + Curve2Spec::Line { start, end, .. } => Aabb { min: [ start[0].min(end[0]), start[1].min(end[1]), @@ -64,11 +97,11 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { start[2].max(end[2]), ], }, - Curve2Spec::Circle { centre, radius } => Aabb { + Curve2Spec::Circle { centre, radius, .. } => Aabb { min: [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, max: *position, }, @@ -133,6 +166,15 @@ impl DocApiBackend for MockBackend { Ok(()) } fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { + // 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 ids = specs .iter() @@ -173,10 +215,29 @@ impl DocApiBackend for MockBackend { Curve2Spec::Ray { .. } => "Ray", Curve2Spec::XLine { .. } => "XLine", }; + let layer = spec + .layer() + .map(|name| { + self.lookup_layer(name).map(|info| info.normalized_name()) + }) + .transpose()?; let id = self.alloc(kind); self.curves.insert(id, spec.clone()); + self.entity_layers + .insert(id, layer.unwrap_or_else(|| "0".to_string())); Ok(id) } + + fn lookup_layer(&self, name: &str) -> ApiResult { + 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 { if spec.block_name.is_empty() { return Err(ApiError::validation("CreateInsert", "empty block name")); @@ -355,6 +416,144 @@ impl DocApiBackend for MockBackend { .copied() .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> { + 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 { + 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> { + 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> { + 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 { + self.entity_layers + .get(&id) + .cloned() + .ok_or(ApiError::UnknownId(id)) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) @@ -364,17 +563,85 @@ impl DocApiBackend for MockBackend { } fn remove_entity(&mut self, id: ObjectId) -> ApiResult { 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()) } + fn enumerate_entities( + &self, + kind: Option<&str>, + layer: Option<&str>, + include_bounds: bool, + ) -> ApiResult> { + 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 { 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| { cadkernel::brep::body_bounds(b).map(|bb| Aabb { min: bb.min, max: bb.max, }) }); - Ok(EntityView { id, kind, bounds }) + Ok(EntityView { + id, + kind, + layer, + bounds, + }) } fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { if !self.entity_exists(id) { @@ -423,6 +690,9 @@ impl DocApiBackend for MockBackend { fn entity_exists(&self, id: ObjectId) -> bool { self.kinds.contains_key(&id) } + fn object_exists(&self, id: ObjectId) -> bool { + self.xrecord_store.contains_key(&id) + } fn revision(&self) -> GeometryRevision { GeometryRevision(self.revision) } diff --git a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs index f86db5132..2cc82dbe5 100644 --- a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs +++ b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs @@ -54,10 +54,7 @@ fn main() -> ocs_doc_api::ApiResult<()> { } // A totally unknown id also surfaces a structured error. - assert!(matches!( - doc.entities().get(ObjectId::from_u64(0xDEAD)), - Err(_) - )); + assert!(doc.entities().get(ObjectId::from_u64(0xDEAD)).is_err()); println!("failure semantics verified"); Ok(()) } diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index b885693dd..e490f7d7b 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -457,6 +457,50 @@ name = "create_table" args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec]" }] 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" + +[[family.method]] +name = "set_xdata" +kind = "op" +op = "SetXData" +args = [{ name = "application_name", ty = "&str" }, { name = "record", ty = "Option" }] +returns = "()" + # ============================================================================ # 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. # - 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" + +[[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_map]] op = "CreateSolid.Cuboid" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 5a857db8a..51e4cfafe 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -5,7 +5,7 @@ use crate::error::{ApiError, ApiResult}; use crate::id::ObjectId; -use crate::ops::Curve2Spec; +use crate::ops::{Curve2Spec, LayerInfo}; use crate::query::{Aabb, EntityView}; use crate::revision::GeometryRevision; @@ -152,6 +152,85 @@ pub trait DocApiBackend { sections: &[(cadkernel::space::Plane, Vec)], ) -> ApiResult; + /// 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>; + + /// Create a standalone `XRECORD` object from a spec; returns the fresh `ObjectId`. + fn add_xrecord(&mut self, spec: &crate::ops::XRecordSpec) -> ApiResult; + + /// 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>; + + // ── 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 { + 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>; + + /// The layer name of an entity. + fn entity_layer(&self, id: ObjectId) -> ApiResult; + + /// 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>; + + /// The WCS location of a Point entity. + fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]>; + + /// The start and end WCS points of a Line entity. + fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])>; + /// Can `id` be modified in place right now (exists, is the expected family, /// not on a locked layer)? Read-only pre-check used before mutations. /// Default: existence + not-locked (backends narrow the family check). @@ -178,6 +257,10 @@ pub trait DocApiBackend { } } + /// Whether an object (named-object / XRecord / dictionary) exists. Distinct + /// from `entity_exists`, which is for first-class entities only. + fn object_exists(&self, id: ObjectId) -> bool; + /// A generic, untyped view of any entity (id + kind + coarse bounds). fn get_entity(&mut self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/convert.rs b/crates/ocs_doc_api/src/convert.rs index 4f91cb4c4..db1f0a22f 100644 --- a/crates/ocs_doc_api/src/convert.rs +++ b/crates/ocs_doc_api/src/convert.rs @@ -113,19 +113,19 @@ pub fn bulge_arc_segment( pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { crate::validation::curve(spec)?; let entity = match spec { - Curve2Spec::Line { start, end } => { + Curve2Spec::Line { start, end, .. } => { let mut e = Line::new(); e.start = v3(*start); e.end = v3(*end); EntityType::Line(e) } - Curve2Spec::Circle { centre, radius } => { + Curve2Spec::Circle { centre, radius, .. } => { let mut e = Circle::new(); e.center = v3(*centre); e.radius = *radius; EntityType::Circle(e) } - Curve2Spec::Polyline { points, closed } => { + Curve2Spec::Polyline { points, closed, .. } => { let mut e = LwPolyline::new(); e.vertices = points .iter() @@ -147,7 +147,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { e.is_closed = *closed; EntityType::LwPolyline(e) } - Curve2Spec::Point { position } => { + Curve2Spec::Point { position, .. } => { let mut e = Point::new(); e.location = v3(*position); EntityType::Point(e) @@ -157,6 +157,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { radius, start_angle, end_angle, + .. } => { let mut e = Arc::new(); e.center = v3(*centre); @@ -171,6 +172,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { ratio, start, end, + .. } => { // Validate the minor/major ratio so the (major-axis) bounds never // under-bound: ratio must be in (0, 1]. @@ -193,6 +195,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { control_points, knots, weights, + .. } => { let mut e = Spline::new(); e.degree = *degree; @@ -201,10 +204,10 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { e.weights = weights.clone(); EntityType::Spline(e) } - Curve2Spec::Ray { origin, direction } => { + Curve2Spec::Ray { origin, direction, .. } => { EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) } - Curve2Spec::XLine { origin, direction } => { + Curve2Spec::XLine { origin, direction, .. } => { EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) } }; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 5f8cbce0f..0d11ad799 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -25,14 +25,35 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult(b: &mut B, op: Operation) -> ApiResult { let name = op.op_name(); crate::validation::operation(&op)?; - if let Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) = &op { - let unique: std::collections::HashSet<_> = ids.iter().collect(); - if ids.is_empty() || unique.len() != ids.len() { - return Err(ApiError::validation( - name, - "batch ids must be nonempty and unique", - )); + match &op { + Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) => { + let unique: std::collections::HashSet<_> = ids.iter().collect(); + if ids.is_empty() || unique.len() != ids.len() { + return Err(ApiError::validation( + name, + "batch ids must be nonempty and unique", + )); + } } + Operation::SetXDataMany(records) => { + if records.is_empty() { + return Err(ApiError::validation( + name, + "SetXDataMany records must be nonempty", + )); + } + if records.len() > BULK_ITEM_CAP { + return Err(over_cap(name, records.len())); + } + let unique: std::collections::HashSet<_> = records.iter().map(|(id, _, _)| id).collect(); + if unique.len() != records.len() { + return Err(ApiError::validation( + name, + "SetXDataMany records must have unique entity ids", + )); + } + } + _ => {} } if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op { @@ -353,6 +374,70 @@ fn apply_op_inner(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + b.create_layer(info)?; + b.finalize_op(); + OpOutcome::Updated(ObjectId::NULL) + } + Operation::UpdateLayer { name, info } => { + b.push_undo(name); + b.update_layer(name, info)?; + b.finalize_op(); + OpOutcome::Updated(ObjectId::NULL) + } + Operation::DeleteLayer { name } => { + b.push_undo(name); + b.delete_layer(name)?; + b.finalize_op(); + OpOutcome::Updated(ObjectId::NULL) + } + Operation::SetEntityLayer { id, layer } => { + require_exists(b, *id, name)?; + b.push_undo(name); + b.set_entity_layer(*id, layer)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } + Operation::SetXData { + id, + application_name, + record, + } => { + require_exists(b, *id, name)?; + b.push_undo(name); + b.set_xdata(*id, application_name, record.as_ref())?; + b.finalize_op(); + OpOutcome::Updated(*id) + } + Operation::SetXDataMany(records) => { + // Pre-validate every entity id before any mutation. + for (id, app, _) in records { + require_exists(b, *id, name)?; + if app.is_empty() { + return Err(ApiError::validation(name, "empty application_name")); + } + } + b.push_undo(name); + for (id, application_name, record) in records { + b.set_xdata(*id, application_name, record.as_ref())?; + } + b.finalize_op(); + OpOutcome::Updated(ObjectId::NULL) + } + Operation::CreateXRecord(spec) => { + b.push_undo(name); + let id = b.add_xrecord(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::SetXRecord { id, spec } => { + require_object_exists(b, *id, name)?; + b.push_undo(name); + b.set_xrecord(*id, spec)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -417,6 +502,38 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?; QueryResult::ViewportView { target, height } } + Query::GetXData { + id, + application_name, + } => QueryResult::XData( + b.xdata(*id, application_name) + .map_err(|e| label(qname, e))? + .clone(), + ), + Query::GetXRecord { id } => { + QueryResult::XRecord(b.xrecord(*id).map_err(|e| label(qname, e))?.ok_or_else( + || ApiError::validation(qname, format!("ObjectId {id:?} is not an XRecord")), + )?) + } + Query::ListLayers => QueryResult::Layers(b.layers().map_err(|e| label(qname, e))?), + Query::GetEntityLayer { id } => { + QueryResult::EntityLayer(b.entity_layer(*id).map_err(|e| label(qname, e))?) + } + Query::EnumerateEntities { + kind, + layer, + include_bounds, + } => QueryResult::Entities( + b.enumerate_entities(kind.as_deref(), layer.as_deref(), *include_bounds) + .map_err(|e| label(qname, e))?, + ), + Query::GetPointPosition { id } => QueryResult::PointPosition( + b.point_position(*id).map_err(|e| label(qname, e))?, + ), + Query::GetLineGeometry { id } => { + let (start, end) = b.line_geometry(*id).map_err(|e| label(qname, e))?; + QueryResult::LineGeometry((start, end)) + } }; results.push(r); } @@ -459,6 +576,15 @@ fn require_exists(b: &B, id: ObjectId, op: &'static str) -> Ap } } +/// Validate that a named object exists before performing an operation. +fn require_object_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { + if b.object_exists(id) { + Ok(()) + } else { + Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) + } +} + fn over_cap(op: &'static str, n: usize) -> ApiError { ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}")) } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 97cc4d0d6..104b0a853 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -13,7 +13,7 @@ use crate::envelope::{DocApiEnvelope, Receipt}; use crate::error::{ApiError, ApiResult}; use crate::gen::{Operation, Query}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive}; +use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive, XDataRecord, XRecordSpec}; use crate::query::{Aabb, EntityView, QueryResult}; use crate::revision::GeometryRevision; use crate::transport::Transport; @@ -170,6 +170,74 @@ impl Document { } } + /// List all layers in the document. + pub fn layers(&self) -> ApiResult> { + 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(&self, items: I) -> ApiResult<()> + where + I: IntoIterator)>, + S: Into, + { + let updates: Vec<_> = items + .into_iter() + .map(|(id, app, record)| (id, app.into(), record)) + .collect(); + if updates.is_empty() { + return Ok(()); + } + self.session.apply_op(Operation::SetXDataMany(updates))?; + Ok(()) + } + /// Batch of read-only queries in ONE round-trip (safe: no mutation/undo). /// The closure records queries on a [`QueryBatch`]; the results are returned /// in the same order as a [`QueryResults`] view the caller destructures. @@ -336,6 +404,25 @@ macro_rules! handle { })?; Ok(()) } + /// Return the entity's current layer name (normalized uppercase). + pub fn layer(&self) -> ApiResult { + match self + .session + .one_query(Query::GetEntityLayer { id: self.id })? + { + QueryResult::EntityLayer(name) => Ok(name), + _ => Err(ApiError::Transport("unexpected entity layer result".into())), + } + } + /// Move the entity to a different layer. One undo step; fails if the + /// target layer does not exist or is locked. + pub fn set_layer(&self, layer: &str) -> ApiResult<()> { + self.session.apply_op(Operation::SetEntityLayer { + id: self.id, + layer: layer.to_string(), + })?; + Ok(()) + } } }; } @@ -354,6 +441,25 @@ handle!(XLine); handle!(Text); handle!(MText); handle!(Dimension); +handle!(XRecord); + +impl XRecord { + /// Read the XRECORD payload. + pub fn payload(&self) -> ApiResult { + match self.session.one_query(Query::GetXRecord { id: self.id })? { + QueryResult::XRecord(spec) => Ok(spec), + _ => Err(ApiError::Transport("unexpected xrecord result".into())), + } + } + /// Replace the XRECORD payload in place (one undo step). + pub fn set_payload(&self, spec: &XRecordSpec) -> ApiResult<()> { + self.session.apply_op(Operation::SetXRecord { + id: self.id, + spec: spec.clone(), + })?; + Ok(()) + } +} impl Dimension { /// The measured value of this dimension (distance for linear/radius, degrees @@ -416,6 +522,26 @@ impl Entity { })?; Ok(()) } + /// The XDATA record for `application_name` on this entity (`None` if absent). + pub fn xdata(&self, application_name: &str) -> ApiResult> { + 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) -> ApiResult<()> { + self.session.apply_op(Operation::SetXData { + id: self.id, + application_name: application_name.to_string(), + record, + })?; + Ok(()) + } pub fn as_solid(&self) -> Option { // Typed downcast is validated by the view's kind. matches!(self.view().ok()?.kind.as_str(), "Solid3D") @@ -621,22 +747,80 @@ impl CurveCollection { Ok((self.session.clone(), id)) } pub fn create_line(&self, start: [f64; 3], end: [f64; 3]) -> ApiResult { - 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 { + let (s, id) = self.create_curve(Curve2Spec::Line { + start, + end, + layer: Some(layer.to_string()), + })?; Ok(Line::new(s, id)) } pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult { - 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 { + let (s, id) = self.create_curve(Curve2Spec::Circle { + centre, + radius, + layer: Some(layer.to_string()), + })?; Ok(Circle::new(s, id)) } pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult { let (s, id) = self.create_curve(Curve2Spec::Polyline { points: points.to_vec(), closed, + layer: None, + })?; + Ok(Polyline::new(s, id)) + } + pub fn create_polyline_on_layer( + &self, + points: &[[f64; 3]], + closed: bool, + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Polyline { + points: points.to_vec(), + closed, + layer: Some(layer.to_string()), })?; Ok(Polyline::new(s, id)) } pub fn create_point(&self, position: [f64; 3]) -> ApiResult { - 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 { + let (s, id) = self.create_curve(Curve2Spec::Point { + position, + layer: Some(layer.to_string()), + })?; Ok(Point::new(s, id)) } pub fn create_arc( @@ -651,6 +835,24 @@ impl CurveCollection { radius, start_angle, end_angle, + layer: None, + })?; + Ok(ArcCurve::new(s, id)) + } + pub fn create_arc_on_layer( + &self, + centre: [f64; 3], + radius: f64, + start_angle: f64, + end_angle: f64, + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Arc { + centre, + radius, + start_angle, + end_angle, + layer: Some(layer.to_string()), })?; Ok(ArcCurve::new(s, id)) } @@ -668,6 +870,26 @@ impl CurveCollection { ratio, start, end, + layer: None, + })?; + Ok(Ellipse::new(s, id)) + } + pub fn create_ellipse_on_layer( + &self, + centre: [f64; 3], + major_axis: [f64; 3], + ratio: f64, + start: f64, + end: f64, + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Ellipse { + centre, + major_axis, + ratio, + start, + end, + layer: Some(layer.to_string()), })?; Ok(Ellipse::new(s, id)) } @@ -683,15 +905,67 @@ impl CurveCollection { control_points: control_points.to_vec(), knots: knots.to_vec(), weights: weights.to_vec(), + layer: None, + })?; + Ok(Spline::new(s, id)) + } + pub fn create_spline_on_layer( + &self, + degree: i32, + control_points: &[[f64; 3]], + knots: &[f64], + weights: &[f64], + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Spline { + degree, + control_points: control_points.to_vec(), + knots: knots.to_vec(), + weights: weights.to_vec(), + layer: Some(layer.to_string()), })?; Ok(Spline::new(s, id)) } pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - 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 { + let (s, id) = self.create_curve(Curve2Spec::Ray { + origin, + direction, + layer: Some(layer.to_string()), + })?; Ok(Ray::new(s, id)) } pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - 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 { + let (s, id) = self.create_curve(Curve2Spec::XLine { + origin, + direction, + layer: Some(layer.to_string()), + })?; Ok(XLine::new(s, id)) } /// A RASTER_IMAGE placed at `insertion_point` (host auto-registers the image definition). @@ -874,10 +1148,42 @@ impl CurveCollection { Ok(MText::new(self.session.clone(), id)) } /// Bulk-create many points in ONE op: all-or-nothing, one undo step. + /// Places points on the current layer (`layer: None`). pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { let specs: Vec = positions .iter() - .map(|&p| crate::ops::EntitySpec::Curve(Curve2Spec::Point { position: p })) + .map(|&p| { + crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { + position: p, + layer: None, + }) + }) + .collect(); + let receipt = self.session.apply_op(Operation::CreateMany(specs))?; + let outcome = receipt + .outcome + .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; + Ok(outcome + .new_ids() + .iter() + .map(|&id| Point::new(self.session.clone(), id)) + .collect()) + } + + /// Bulk-create many points on a specific layer in ONE op. + pub fn create_points_on_layer( + &self, + positions: &[[f64; 3]], + layer: &str, + ) -> ApiResult> { + let specs: Vec = positions + .iter() + .map(|&p| { + crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { + position: p, + layer: Some(layer.to_string()), + }) + }) .collect(); let receipt = self.session.apply_op(Operation::CreateMany(specs))?; let outcome = receipt @@ -1001,6 +1307,41 @@ impl EntityCollection { self.session.apply_op(Operation::Delete { id })?; Ok(()) } + + /// Create a standalone `XRECORD` object (returns an `Entity` handle). + pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult { + 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 { + // 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> { + let receipt = self.session.apply_op(Operation::CreateMany(specs.to_vec()))?; + let outcome = receipt + .outcome + .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; + Ok(outcome + .new_ids() + .iter() + .map(|&id| Entity::new(self.session.clone(), id)) + .collect()) + } + /// Bulk transform (one op, all-or-nothing). pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> { self.session.apply_op(Operation::TransformMany { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 20cb2a0d6..3430545b6 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -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]) -> `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` — query `GetXData` +- **set_xdata**(application_name: &str, record: Option) -> `()` — op `SetXData` + +## `Layer` (acadrust `-`, collection `layers`) + +Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers(). + +### Methods + +- **list**() -> `Vec` — query `ListLayers` +- **create**(info: LayerInfo) -> `()` — op `CreateLayer` +- **update**(name: String, info: LayerInfo) -> `()` — op `UpdateLayer` +- **delete**(name: String) -> `()` — op `DeleteLayer` + ## Generic methods (every handle) Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, @@ -204,10 +230,32 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, - **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: solids only.** - **`delete()`** — remove the entity (one undo step). +- **`layer()` -> `String`** — the name of the layer the entity is on. +- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; + blocked if the entity or target layer is locked). `Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed downcast when `kind == "Solid3D"`). +## Layer table + +Layers are a named document table, not an entity family. `Document::layers()` +lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, +`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` +returns the layer name and `Entity::set_layer(layer)` moves the entity to an +existing layer. The host rejects duplicate names, deletion of layer "0" or the +current layer, and removing a layer that still has entities assigned. + +## Transports + +- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. +- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the + `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes + `DocApiRequest { tab_id, bytes }` to the same executor. +- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that + builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a + `PluginRequestSender` (out-of-process / worker-thread plugins). + ## Collections & cross-cutting - **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. @@ -284,6 +332,14 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read- - `CreateAttributeDefinition(AttributeDefinitionSpec)` - `CreateTable(TableSpec)` - `CreateDimensionAngular2Ln(DimensionAngularSpec)` +- `SetXData { id: ObjectId, application_name: String, record: Option }` +- `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)` ### `Query` @@ -299,6 +355,10 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read- - `GetAttributes { id: ObjectId }` - `GetBlockEntities { block_name: String }` - `GetViewportView { id: ObjectId }` +- `GetXData { id: ObjectId, application_name: String }` +- `GetXRecord { id: ObjectId }` +- `ListLayers` +- `GetEntityLayer { id: ObjectId }` ## Errors (`ApiError`) diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 90f422758..3a7d454c6 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -1124,6 +1124,164 @@ "handle": "Entity", "methods": [], "name": "Table" + }, + { + "acadrust_variant": "XRecord", + "collection": "entities", + "constructors": [ + { + "args": [ + { + "name": "spec", + "ty": "XRecordSpec" + } + ], + "bulk": false, + "fixed": null, + "name": "create_xrecord", + "op": "CreateXRecord", + "returns": "Entity" + } + ], + "handle": "XRecord", + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "payload", + "op": null, + "query": "GetXRecord", + "read_only": true, + "returns": "XRecordSpec" + }, + { + "args": [ + { + "name": "spec", + "ty": "XRecordSpec" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_payload", + "op": "SetXRecord", + "query": null, + "read_only": false, + "returns": "()" + }, + { + "args": [ + { + "name": "application_name", + "ty": "&str" + } + ], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "xdata", + "op": null, + "query": "GetXData", + "read_only": true, + "returns": "Option" + }, + { + "args": [ + { + "name": "application_name", + "ty": "&str" + }, + { + "name": "record", + "ty": "Option" + } + ], + "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" + }, + { + "args": [ + { + "name": "info", + "ty": "LayerInfo" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "create", + "op": "CreateLayer", + "query": null, + "read_only": false, + "returns": "()" + }, + { + "args": [ + { + "name": "name", + "ty": "String" + }, + { + "name": "info", + "ty": "LayerInfo" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "update", + "op": "UpdateLayer", + "query": null, + "read_only": false, + "returns": "()" + }, + { + "args": [ + { + "name": "name", + "ty": "String" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "delete", + "op": "DeleteLayer", + "query": null, + "read_only": false, + "returns": "()" + } + ], + "name": "Layer" } ], "layouts": { @@ -1164,7 +1322,15 @@ "CreateDimensionAngular(DimensionAngularSpec)", "CreateAttributeDefinition(AttributeDefinitionSpec)", "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)" + "CreateDimensionAngular2Ln(DimensionAngularSpec)", + "SetXData { id: ObjectId, application_name: String, record: Option }", + "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)" ] }, "Query": { @@ -1180,7 +1346,11 @@ "GetDimensionMeasurement { id: ObjectId }", "GetAttributes { id: ObjectId }", "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }" + "GetViewportView { id: ObjectId }", + "GetXData { id: ObjectId, application_name: String }", + "GetXRecord { id: ObjectId }", + "ListLayers", + "GetEntityLayer { id: ObjectId }" ] } }, @@ -1205,7 +1375,9 @@ "XLine", "Text", "MText", - "Dimension" + "Dimension", + "XRecord", + "Layer" ], "root": "DocApi" }, diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json index c743ba25d..b43755336 100644 --- a/crates/ocs_doc_api/src/gen/layouts.json +++ b/crates/ocs_doc_api/src/gen/layouts.json @@ -29,7 +29,15 @@ "CreateDimensionAngular(DimensionAngularSpec)", "CreateAttributeDefinition(AttributeDefinitionSpec)", "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)" + "CreateDimensionAngular2Ln(DimensionAngularSpec)", + "SetXData { id: ObjectId, application_name: String, record: Option }", + "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)" ] }, "Query": { @@ -45,7 +53,11 @@ "GetDimensionMeasurement { id: ObjectId }", "GetAttributes { id: ObjectId }", "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }" + "GetViewportView { id: ObjectId }", + "GetXData { id: ObjectId, application_name: String }", + "GetXRecord { id: ObjectId }", + "ListLayers", + "GetEntityLayer { id: ObjectId }" ] } } diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 22ca691a3..7d8e91d22 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -10,7 +10,7 @@ use crate::id::ObjectId; use crate::ops::{ AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, - SolidPrimitive, TableSpec, TextSpec, ViewportSpec, + SolidPrimitive, TableSpec, TextSpec, ViewportSpec, XDataRecord, XRecordSpec, }; /// A typed write operation. Each variant is ONE atomic host call = one @@ -99,4 +99,29 @@ pub enum Operation { CreateTable(TableSpec), // dimension sub-types (2-line angular) CreateDimensionAngular2Ln(DimensionAngularSpec), + // xdata / xrecord roundtrip + SetXData { + id: ObjectId, + application_name: String, + record: Option, + }, + SetXDataMany(Vec<(ObjectId, String, Option)>), + 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, + }, } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index efefde74a..f58c6a0c8 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -55,4 +55,35 @@ pub enum Query { GetViewportView { id: ObjectId, }, + /// The XDATA record for `application_name` attached to an entity (`None` if absent). + GetXData { + id: ObjectId, + application_name: String, + }, + /// The XRECORD object payload by id. + GetXRecord { + id: ObjectId, + }, + /// List all layers in the document. + ListLayers, + /// The layer name of an entity. + GetEntityLayer { + id: ObjectId, + }, + /// Enumerate first-class entities, optionally filtered by kind and/or layer. + /// `include_bounds` populates `EntityView::bounds`; leave it false for a + /// lightweight list view. + EnumerateEntities { + kind: Option, + layer: Option, + 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, + }, } diff --git a/crates/ocs_doc_api/src/layer.rs b/crates/ocs_doc_api/src/layer.rs new file mode 100644 index 000000000..48987f24f --- /dev/null +++ b/crates/ocs_doc_api/src/layer.rs @@ -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(" ")); + } +} diff --git a/crates/ocs_doc_api/src/lib.rs b/crates/ocs_doc_api/src/lib.rs index 4949b82d6..649f4bc84 100644 --- a/crates/ocs_doc_api/src/lib.rs +++ b/crates/ocs_doc_api/src/lib.rs @@ -17,6 +17,7 @@ mod validation; pub mod envelope; pub mod facade; +pub mod layer; pub mod ops; pub mod query; pub mod transport; @@ -41,9 +42,14 @@ pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt}; pub use facade::{ ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity, EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray, - Solid, SolidCollection, Spline, Text, XLine, + Solid, SolidCollection, Spline, Text, XLine, XRecord, +}; +pub use layer::{is_valid_layer_name, normalize_layer_name}; +pub use ops::{ + BoolOp, Color, Curve2Spec, EntitySpec, LayerFlags, LayerInfo, LineWeight, Operation, + PlacementSpec, SolidPrimitive, XDataRecord, XDataValue, XRecordCloningFlags, XRecordEntry, + XRecordSpec, XRecordValue, }; -pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive}; pub use query::{Aabb, EntityView, Query, QueryResult}; #[cfg(feature = "host")] @@ -51,6 +57,9 @@ pub use convert::{ bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, entity_to_profile_curves, transform_entity_geometry, }; +#[cfg(feature = "doc_api_host")] +pub use transport::ipc::doc_api_for_host; + pub use transport::Transport; /// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`). diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index c41d7cb1c..7941b4112 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -8,6 +8,80 @@ use serde::{Deserialize, Serialize}; pub use crate::gen::Operation; +/// An XDATA record payload: one registered application name plus its typed +/// value list. This is a plain-data, serde-compatible mirror of +/// `acadrust::xdata::ExtendedDataRecord` so the wire DTO stays independent of +/// the host dependency. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct XDataRecord { + pub application_name: String, + pub values: Vec, +} + +/// 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), + 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, +} + +/// 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), +} + /// Boolean operation kind for [`Operation::SolidBoolean`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BoolOp { @@ -16,6 +90,72 @@ pub enum BoolOp { Difference, } +/// A plain-data color value. Mirrors `acadrust::types::Color` for wire +/// independence from the host dependency. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Color { + ByLayer, + None, + ByBlock, + Index(u8), + Rgb { r: u8, g: u8, b: u8 }, +} + +impl Color { + pub const WHITE: Color = Color::Index(7); +} + +/// A plain-data line weight. Mirrors `acadrust::types::LineWeight`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum LineWeight { + #[default] + ByLayer, + ByBlock, + Default, + /// Specific line weight in 1/100 mm. + Value(i16), +} + +/// Layer visibility/lock flags. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct LayerFlags { + pub frozen: bool, + pub locked: bool, + pub frozen_in_new_viewport: bool, + pub off: bool, +} + +/// A plain-data layer table entry. Mirrors `acadrust::tables::Layer`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayerInfo { + pub name: String, + pub flags: LayerFlags, + pub color: Color, + pub line_type: String, + pub line_weight: LineWeight, + pub plot_style: String, + pub is_plottable: bool, +} + +impl LayerInfo { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + flags: LayerFlags::default(), + color: Color::WHITE, + line_type: "Continuous".to_string(), + line_weight: LineWeight::Default, + plot_style: String::new(), + is_plottable: true, + } + } + + /// The normalized (trimmed + uppercased) lookup key for this layer name. + pub fn normalized_name(&self) -> String { + crate::layer::normalize_layer_name(&self.name) + } +} + /// Construction spec for a B-rep solid primitive (plain-data mirror of the /// non-serde `cadkernel::brep::make::*` arguments). #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -55,17 +195,25 @@ pub enum Curve2Spec { Line { start: [f64; 3], end: [f64; 3], + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, Circle { centre: [f64; 3], radius: f64, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, Polyline { points: Vec<[f64; 3]>, closed: bool, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, Point { position: [f64; 3], + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians). Arc { @@ -73,6 +221,8 @@ pub enum Curve2Spec { radius: f64, start_angle: f64, end_angle: f64, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`; /// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π). @@ -82,6 +232,8 @@ pub enum Curve2Spec { ratio: f64, start: f64, end: f64, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// NURBS spline (control points + knots + weights; `degree` typically 3). Spline { @@ -89,19 +241,42 @@ pub enum Curve2Spec { control_points: Vec<[f64; 3]>, knots: Vec, weights: Vec, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// A ray from `origin` along `direction` (bounded at origin only). Ray { origin: [f64; 3], direction: [f64; 3], + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// An infinite construction line through `origin` along `direction`. XLine { origin: [f64; 3], direction: [f64; 3], + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, } +impl Curve2Spec { + /// The target layer name, if any. + pub fn layer(&self) -> Option<&str> { + match self { + Curve2Spec::Line { layer, .. } + | Curve2Spec::Circle { layer, .. } + | Curve2Spec::Polyline { layer, .. } + | Curve2Spec::Point { layer, .. } + | Curve2Spec::Arc { layer, .. } + | Curve2Spec::Ellipse { layer, .. } + | Curve2Spec::Spline { layer, .. } + | Curve2Spec::Ray { layer, .. } + | Curve2Spec::XLine { layer, .. } => layer.as_deref(), + } + } +} + /// A generic entity-construction payload used by the bulk op /// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction /// specs so one bulk op can carry many homogeneous (or mixed) creations. @@ -111,6 +286,16 @@ pub enum EntitySpec { Solid(SolidPrimitive), } +impl EntitySpec { + /// The target layer name for this spec, if explicitly set. + pub fn layer(&self) -> Option<&str> { + match self { + EntitySpec::Curve(c) => c.layer(), + EntitySpec::Solid(_) => None, + } + } +} + /// Construction spec for a block reference (`INSERT`): place the block /// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform /// `scale` and `rotation` (radians, about Z). @@ -291,6 +476,14 @@ impl crate::gen::Operation { CreateAttributeDefinition(_) => "CreateAttributeDefinition", CreateTable(_) => "CreateTable", CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln", + SetXData { .. } => "SetXData", + SetXDataMany(_) => "SetXDataMany", + CreateXRecord(_) => "CreateXRecord", + SetXRecord { .. } => "SetXRecord", + CreateLayer(_) => "CreateLayer", + UpdateLayer { .. } => "UpdateLayer", + DeleteLayer { .. } => "DeleteLayer", + SetEntityLayer { .. } => "SetEntityLayer", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index 4ce1b1ba5..e53d2b650 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -41,6 +41,8 @@ pub struct EntityView { /// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line"). pub kind: String, pub bounds: Option, + /// The normalized (uppercase) layer name the entity is on. + pub layer: String, } /// The result of a single [`Query`] (one per query in a `Queries` batch). @@ -68,6 +70,20 @@ pub enum QueryResult { target: [f64; 3], height: f64, }, + /// An XDATA record for a single application (`None` if no such record). + XData(Option), + /// An XRECORD object payload. + XRecord(crate::ops::XRecordSpec), + /// All layers in the document. + Layers(Vec), + /// The layer name of an entity. + EntityLayer(String), + /// A filtered list of entity views. + Entities(Vec), + /// The WCS location of a Point entity. + PointPosition([f64; 3]), + /// The start and end WCS points of a Line entity. + LineGeometry(([f64; 3], [f64; 3])), } /// Convenience: the query name for diagnostics. @@ -87,6 +103,13 @@ impl crate::gen::Query { GetAttributes { .. } => "GetAttributes", GetBlockEntities { .. } => "GetBlockEntities", GetViewportView { .. } => "GetViewportView", + GetXData { .. } => "GetXData", + GetXRecord { .. } => "GetXRecord", + ListLayers => "ListLayers", + GetEntityLayer { .. } => "GetEntityLayer", + EnumerateEntities { .. } => "EnumerateEntities", + GetPointPosition { .. } => "GetPointPosition", + GetLineGeometry { .. } => "GetLineGeometry", } } } diff --git a/crates/ocs_doc_api/src/transport/ipc.rs b/crates/ocs_doc_api/src/transport/ipc.rs index 74bd4f06f..f9b4b1d3e 100644 --- a/crates/ocs_doc_api/src/transport/ipc.rs +++ b/crates/ocs_doc_api/src/transport/ipc.rs @@ -5,11 +5,12 @@ use std::sync::Arc; -use ocs_plugin_api::host::{PluginRequestError, PluginRequestSender}; +use ocs_plugin_api::host::{HostApi, PluginRequestError, PluginRequestSender}; use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; use crate::envelope::{DocApiEnvelope, Receipt}; use crate::error::{ApiError, ApiResult}; +use crate::facade::DocApi; use crate::transport::Transport; /// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`; @@ -29,6 +30,21 @@ impl OcsPluginApiIpc { } } +/// Build a typed [`DocApi`] from any host that can provide a +/// [`PluginRequestSender`] (out-of-process / V4 worker plugins). +/// +/// Returns `None` for hosts that do not expose a request sender (e.g. in-process +/// hosts). In-process code should use [`crate::DocApi::in_process`] with a +/// concrete [`crate::backend::DocApiBackend`]. +pub fn doc_api_for_host(host: &dyn HostApi) -> Option { + let sender = host.plugin_request_sender()?; + let tab_id = host.tab_id(); + Some(DocApi::connect( + Arc::new(OcsPluginApiIpc::new(sender.into(), tab_id)), + tab_id, + )) +} + impl Transport for OcsPluginApiIpc { fn apply(&self, req: DocApiEnvelope) -> ApiResult { 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>>, + } + + impl PluginRequestSender for MockSender { + fn request(&self, req: PluginRequest) -> Result { + 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, + } + + 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) {} + 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, + ) -> &mut (dyn Any + Send + Sync) { + unimplemented!() + } + fn document_reader(&self) -> Box { + unimplemented!() + } + fn plugin_request_sender(&self) -> Option> { + self.sender.as_ref().map(|s| Box::new(s.clone()) as Box) + } + } + + #[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()); + } +} diff --git a/crates/ocs_doc_api/src/validation.rs b/crates/ocs_doc_api/src/validation.rs index 8a96a3710..a6e93067a 100644 --- a/crates/ocs_doc_api/src/validation.rs +++ b/crates/ocs_doc_api/src/validation.rs @@ -1,5 +1,5 @@ //! Reject invalid numeric inputs before the backend starts an undo operation. -use crate::{ops::*, ApiError, ApiResult}; +use crate::{id::ObjectId, ops::*, ApiError, ApiResult}; fn finite(values: &[f64]) -> bool { values.iter().all(|v| v.is_finite()) @@ -24,16 +24,17 @@ fn check(valid: bool, op: &'static str) -> ApiResult<()> { pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { use Curve2Spec::*; let valid = match spec { - Line { start, end } => finite(start) && finite(end) && start != end, - Circle { centre, radius } => finite(centre) && positive(*radius), - Point { position } => finite(position), + Line { start, end, .. } => finite(start) && finite(end) && start != end, + Circle { centre, radius, .. } => finite(centre) && positive(*radius), + Point { position, .. } => finite(position), Arc { centre, radius, start_angle, end_angle, + .. } => finite(centre) && positive(*radius) && finite(&[*start_angle, *end_angle]), - Polyline { points, closed } => { + Polyline { points, closed, .. } => { points.len() >= if *closed { 3 } else { 2 } && points.len() <= BULK_ITEM_CAP && points.iter().all(|p| finite(p) && p[2] == points[0][2]) @@ -44,6 +45,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { ratio, start, end, + .. } => { finite(centre) && direction(major_axis) @@ -56,6 +58,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { control_points, knots, weights, + .. } => { *degree > 0 && control_points.len() <= BULK_ITEM_CAP @@ -76,10 +79,12 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { Ray { origin, direction: d, + .. } | XLine { origin, direction: d, + .. } => finite(origin) && direction(d), }; check(valid, "CreateCurve") @@ -111,6 +116,11 @@ pub(crate) fn operation(op: &Operation) -> ApiResult<()> { } CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(), CreateMText(s) => finite(&s.insertion_point) && positive(s.height), + SetXDataMany(records) => records.iter().all(|(id, app, record)| { + let app_ok = !app.is_empty(); + let record_ok = record.as_ref().map_or(true, |r| !r.application_name.is_empty() && !r.application_name.contains('\0')); + *id != ObjectId::NULL && app_ok && record_ok + }), CreateHatch(s) => { (3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p)) } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 7983d7ad8..112c93e8d 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -8,15 +8,16 @@ use std::sync::Arc; use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Curve2Spec, DocApi, EntityView, GeometryErrorKind, GeometryRevision, - HasId, ObjectId, PlacementSpec, + normalize_layer_name, Aabb, ApiError, ApiResult, Color, Curve2Spec, DocApi, EntityView, + GeometryErrorKind, GeometryRevision, HasId, LayerInfo, LineWeight, ObjectId, PlacementSpec, + XDataRecord, XDataValue, }; /// 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). fn curve_bounds(spec: &Curve2Spec) -> Aabb { match spec { - Curve2Spec::Line { start, end } => Aabb { + Curve2Spec::Line { start, end, .. } => Aabb { min: [ start[0].min(end[0]), start[1].min(end[1]), @@ -28,11 +29,12 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { 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]], max: [centre[0] + radius, centre[1] + radius, centre[2]], }, - Curve2Spec::Point { position } => Aabb { + Curve2Spec::Point { position, .. } => Aabb { min: *position, max: *position, }, @@ -72,13 +74,17 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { /// In-memory mock backend: HashMap + a tiny entity table. /// Tracks undo steps + revision bumps to assert per-op semantics. -#[derive(Default, Clone)] +#[derive(Clone)] struct MockBackend { next_id: u64, revision: u64, undo_steps: u32, bodies: HashMap, kinds: HashMap, + /// Layer name assigned to each entity. + entity_layers: HashMap, + /// Stored layer table. + layers: HashMap, /// Stored text content for Text/MText annotations. text_values: HashMap, /// Stored hatch boundary loops. @@ -92,6 +98,36 @@ struct MockBackend { curves: HashMap, /// Stored viewport views (target, height). viewport_views: HashMap, + /// 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, +} + +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 { @@ -104,6 +140,22 @@ impl MockBackend { fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) } + fn add_curve_only(&mut self, spec: &Curve2Spec) -> ApiResult { + 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 { @@ -113,6 +165,7 @@ impl DocApiBackend for MockBackend { fn store_solid(&mut self, body: &KernelBody) -> ApiResult { let id = self.alloc("Solid3D"); self.bodies.insert(id, body.clone()); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { @@ -122,6 +175,15 @@ impl DocApiBackend for MockBackend { Ok(()) } fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { + // 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 ids = specs .iter() @@ -151,26 +213,35 @@ impl DocApiBackend for MockBackend { Ok(()) } fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - 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()); + let layer = spec + .layer() + .map(|name| { + self.lookup_layer(name).map(|info| info.normalized_name()) + }) + .transpose()?; + let id = self.add_curve_only(spec)?; + self.entity_layers + .insert(id, layer.unwrap_or_else(|| "0".to_string())); Ok(id) } + + fn lookup_layer(&self, name: &str) -> ApiResult { + 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 { if spec.block_name.is_empty() { 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 { 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 { let id = self.alloc("Text"); self.text_values.insert(id, spec.value.clone()); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { let id = self.alloc("MText"); self.text_values.insert(id, spec.value.clone()); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { @@ -206,6 +279,7 @@ impl DocApiBackend for MockBackend { let id = self.alloc("Hatch"); self.hatch_boundaries .insert(id, vec![spec.boundary.clone()]); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { @@ -224,6 +298,7 @@ impl DocApiBackend for MockBackend { + (spec.first_point[2] - spec.second_point[2]).powi(2)) .sqrt(); self.dimension_measurements.insert(id, d); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn add_dimension_radius( @@ -236,13 +311,16 @@ impl DocApiBackend for MockBackend { + (spec.center[2] - spec.point[2]).powi(2)) .sqrt(); self.dimension_measurements.insert(id, d); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn add_dimension_diameter( &mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec, ) -> ApiResult { - 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( &mut self, @@ -250,6 +328,7 @@ impl DocApiBackend for MockBackend { ) -> ApiResult { let id = self.alloc("Dimension"); self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn dimension_measurement(&self, id: ObjectId) -> ApiResult { @@ -286,7 +365,9 @@ impl DocApiBackend for MockBackend { "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<()> { if !self.entity_exists(id) { @@ -313,7 +394,9 @@ impl DocApiBackend for MockBackend { &mut self, _spec: &ocs_doc_api::ops::RasterImageSpec, ) -> ApiResult { - Ok(self.alloc("RasterImage")) + let id = self.alloc("RasterImage"); + self.entity_layers.insert(id, "0".to_string()); + Ok(id) } fn loft( &mut self, @@ -345,6 +428,203 @@ impl DocApiBackend for MockBackend { .copied() .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> { + 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 { + 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> { + 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> { + 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 { + 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> { + 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<()> { if self.entity_exists(id) { Ok(()) @@ -354,6 +634,9 @@ impl DocApiBackend for MockBackend { } fn remove_entity(&mut self, id: ObjectId) -> ApiResult { 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()) } fn get_entity(&mut self, id: ObjectId) -> ApiResult { @@ -364,7 +647,17 @@ impl DocApiBackend for MockBackend { 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<()> { if !self.entity_exists(id) { @@ -412,6 +705,9 @@ impl DocApiBackend for MockBackend { fn entity_exists(&self, id: ObjectId) -> bool { self.kinds.contains_key(&id) } + fn object_exists(&self, id: ObjectId) -> bool { + self.xrecord_store.contains_key(&id) + } fn revision(&self) -> GeometryRevision { GeometryRevision(self.revision) } @@ -609,3 +905,145 @@ fn typed_handles_reject_foreign_sessions_and_tabs() { assert!(group.compensate(&doc).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 { .. })); +} diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index f65c3a63f..d7fc6c3dd 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -38,7 +38,8 @@ fn spec() -> Spec { /// The variant names of the `Operation` enum, in declaration order. fn operation_variants() -> Vec { 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; let _marker: Vec = vec![ @@ -46,7 +47,10 @@ fn operation_variants() -> Vec { centre: [0.0; 3], radius: 1.0, }), - Operation::CreateCurve(Curve2Spec::Point { position: [0.0; 3] }), + Operation::CreateCurve(Curve2Spec::Point { + position: [0.0; 3], + layer: None, + }), Operation::Extrude { profile: ObjectId::from_u64(0), direction: [0.0; 3], @@ -167,6 +171,37 @@ fn operation_variants() -> Vec { second_point: [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 .iter() @@ -178,7 +213,7 @@ fn operation_variants() -> Vec { index ); let dbg = format!("{:?}", op); - dbg.split(|c: char| c == '(' || c == ' ' || c == '{') + dbg.split(['(', ' ', '{']) .next() .unwrap_or("") .to_string() @@ -226,6 +261,17 @@ fn query_variants() -> Vec { Query::GetViewportView { 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 .iter() @@ -237,7 +283,7 @@ fn query_variants() -> Vec { index ); let dbg = format!("{:?}", q); - dbg.split(|c: char| c == '(' || c == ' ' || c == '{') + dbg.split(['(', ' ', '{']) .next() .unwrap_or("") .to_string() @@ -309,6 +355,14 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateAttributeDefinition", "CreateTable", "CreateDimensionAngular2Ln", + "SetXData", + "SetXDataMany", + "CreateXRecord", + "SetXRecord", + "CreateLayer", + "UpdateLayer", + "DeleteLayer", + "SetEntityLayer", ]; /// Recorded baseline of the `Query` variant order. @@ -325,6 +379,10 @@ const QUERY_BASELINE: &[&str] = &[ "GetAttributes", "GetBlockEntities", "GetViewportView", + "GetXData", + "GetXRecord", + "ListLayers", + "GetEntityLayer", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index d749e4a6c..fe23287ed 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -1,19 +1,53 @@ //! Native document API adapter over the existing scene, history and kernel paths. use acadrust::entities::Solid3D; +use acadrust::objects::{Dictionary, ObjectType}; use acadrust::{EntityType, Handle}; use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, - ObjectId, PlacementSpec, + Aabb, ApiError, ApiResult, Color, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, + LayerFlags, LayerInfo, LineWeight, ObjectId, PlacementSpec, }; +use crate::scene::annotative::root_named_dict_handle; use crate::scene::convert::acis_export; use crate::scene::model::solid_model; use ocs_doc_api::convert; use super::plugin_host::HostSession; +const XRECORD_DICT_NAME: &str = "OCS_XRECORD_DICT"; + +/// Return the stable named-object dictionary used to hold `XRecord` objects. +/// Created on first use under the root named-objects dictionary and reused +/// thereafter. +fn xrecord_dictionary_handle(doc: &mut acadrust::CadDocument) -> Handle { + let root_h = root_named_dict_handle(doc); + let existing = match doc.objects.get(&root_h) { + Some(ObjectType::Dictionary(root)) => root + .entries + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(XRECORD_DICT_NAME)) + .map(|(_, handle)| *handle), + _ => None, + }; + match existing.filter(|handle| matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_)))) { + Some(handle) => handle, + None => { + let handle = doc.allocate_handle(); + let mut dict = Dictionary::new(); + dict.handle = handle; + dict.owner = root_h; + doc.objects.insert(handle, ObjectType::Dictionary(dict)); + if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_h) { + root.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(XRECORD_DICT_NAME)); + root.add_entry(XRECORD_DICT_NAME, handle); + } + handle + } + } +} + /// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by // the in-process path. Deserializes the envelope, runs the crate executor, // serializes the `Receipt` (or `ApiError`) back to bytes. @@ -63,6 +97,10 @@ fn handle_to_obj(h: Handle) -> ObjectId { ObjectId::from_handle(h) } +fn v3_to_array(v: acadrust::types::Vector3) -> [f64; 3] { + [v.x, v.y, v.z] +} + impl DocApiBackend for HostSession<'_> { fn resolve_body(&mut self, id: ObjectId) -> ApiResult { let handle = obj_to_handle(id); @@ -104,16 +142,32 @@ impl DocApiBackend for HostSession<'_> { } fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { + // 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 prepared = Vec::with_capacity(specs.len()); for spec in specs { prepared.push(match spec { ocs_doc_api::EntitySpec::Solid(spec) => { self.prepare_doc_solid(ocs_doc_api::geom::make_solid(spec)?, None)? } - ocs_doc_api::EntitySpec::Curve(spec) => PreparedDocEntity { - entity: convert::curve_spec_to_entity(spec)?, - solid: None, - }, + ocs_doc_api::EntitySpec::Curve(spec) => { + let mut entity = convert::curve_spec_to_entity(spec)?; + if let Some(name) = spec.layer() { + entity.common_mut().layer = + acadrust::tables::normalize_name(name.trim()); + } + PreparedDocEntity { + entity, + solid: None, + } + } }); } DocApiBackend::push_undo(self, "CreateMany"); @@ -139,11 +193,38 @@ impl DocApiBackend for HostSession<'_> { } fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let entity = convert::curve_spec_to_entity(spec)?; + let layer_name = spec.layer(); + if let Some(name) = layer_name { + self.lookup_layer(name).map_err(|e| ApiError::validation( + "CreateCurve", + e.to_string(), + ))?; + } + let mut entity = convert::curve_spec_to_entity(spec)?; + if let Some(name) = layer_name { + entity.common_mut().layer = acadrust::tables::normalize_name(name.trim()); + } let handle = self.scene_mut().add_entity(entity); Ok(handle_to_obj(handle)) } + fn lookup_layer(&self, name: &str) -> ApiResult { + let norm = acadrust::tables::normalize_name(name.trim()); + if norm.is_empty() { + return Err(ApiError::validation("LookupLayer", "empty layer name")); + } + self.document() + .layers + .get(&norm) + .map(|l| layer_info_from_acadrust(l)) + .ok_or_else(|| { + ApiError::validation( + "LookupLayer", + format!("layer '{name}' does not exist"), + ) + }) + } + fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { // Validate the referenced block exists before committing. if self @@ -422,17 +503,19 @@ impl DocApiBackend for HostSession<'_> { ) -> ApiResult { use acadrust::entities::AttributeDefinition; use acadrust::types::Vector3; - let mut att = AttributeDefinition::default(); - att.tag = spec.tag.clone(); - att.prompt = spec.prompt.clone(); - att.default_value = spec.default_value.clone(); - att.insertion_point = Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ); - att.height = spec.height; - att.rotation = spec.rotation; + let att = AttributeDefinition { + tag: spec.tag.clone(), + prompt: spec.prompt.clone(), + default_value: spec.default_value.clone(), + insertion_point: Vector3::new( + spec.insertion_point[0], + spec.insertion_point[1], + spec.insertion_point[2], + ), + height: spec.height, + rotation: spec.rotation, + ..Default::default() + }; let handle = self .scene_mut() .add_entity(EntityType::AttributeDefinition(att)); @@ -493,10 +576,12 @@ impl DocApiBackend for HostSession<'_> { if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) { attr.value = value.to_string(); } else { - let mut attr = acadrust::entities::AttributeEntity::default(); - attr.tag = tag.to_string(); - attr.value = value.to_string(); - attr.insertion_point = ins.insert_point; + let attr = acadrust::entities::AttributeEntity { + tag: tag.to_string(), + value: value.to_string(), + insertion_point: ins.insert_point, + ..Default::default() + }; ins.attributes.push(attr); } if !self.scene_mut().update_entity(EntityType::Insert(ins)) { @@ -543,7 +628,16 @@ impl DocApiBackend for HostSession<'_> { ), None => ("Missing".to_string(), None), }; - out.push(EntityView { id, kind, bounds }); + let layer = match self.document().get_entity(*h) { + Some(e) => e.common().layer.clone(), + None => String::new(), + }; + out.push(EntityView { + id, + kind, + layer, + bounds, + }); } Ok(out) } @@ -602,16 +696,18 @@ impl DocApiBackend for HostSession<'_> { // non-empty, a known image extension, and not a UNC/network path or // parent-traversal (which could point the host at unintended resources). validate_image_path(&spec.file_path)?; - let mut img = RasterImage::default(); - img.file_path = spec.file_path.clone(); - img.insertion_point = Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ); - img.u_vector = Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]); - img.v_vector = Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]); - img.size = Vector2::new(spec.size[0], spec.size[1]); + let img = RasterImage { + file_path: spec.file_path.clone(), + insertion_point: Vector3::new( + spec.insertion_point[0], + spec.insertion_point[1], + spec.insertion_point[2], + ), + u_vector: Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]), + v_vector: Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]), + size: Vector2::new(spec.size[0], spec.size[1]), + ..Default::default() + }; // add_entity auto-registers the ImageDefinition (scene/entity.rs). let handle = self.scene_mut().add_entity(EntityType::RasterImage(img)); Ok(handle_to_obj(handle)) @@ -626,6 +722,338 @@ impl DocApiBackend for HostSession<'_> { self.store_solid(&body) } + fn set_xdata( + &mut self, + id: ObjectId, + application_name: &str, + record: Option<&ocs_doc_api::XDataRecord>, + ) -> ApiResult<()> { + self.can_modify(id)?; + let handle = obj_to_handle(id); + let values = record.map(|r| { + r.values + .iter() + .map(|v| xdata_value_to_acadrust(v, self.document())) + .collect::>() + }); + crate::scene::view::dispatch::set_entity_xdata( + self.document_mut(), + handle, + application_name, + values, + ); + Ok(()) + } + + fn xdata(&self, id: ObjectId, application_name: &str) -> ApiResult> { + let handle = obj_to_handle(id); + let entity = self + .document() + .get_entity(handle) + .ok_or(ApiError::UnknownId(id))?; + let Some(rec) = entity.common().extended_data.get_record(application_name) else { + return Ok(None); + }; + Ok(Some(ocs_doc_api::XDataRecord { + application_name: rec.application_name.clone(), + values: rec + .values + .iter() + .map(xdata_value_from_acadrust) + .collect(), + })) + } + + fn object_exists(&self, id: ObjectId) -> bool { + let handle = obj_to_handle(id); + self.document().objects.contains_key(&handle) + } + + fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { + use acadrust::objects::{ObjectType, XRecord}; + let mut record = XRecord::named(spec.name.clone()); + record.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); + record.entries = spec + .entries + .iter() + .map(xrecord_entry_to_acadrust) + .collect(); + record.synchronize_object_references(); + let record_handle = { + let doc = self.document_mut(); + let handle = doc.allocate_handle(); + record.handle = handle; + doc.objects + .insert(handle, ObjectType::XRecord(record)); + handle + }; + // Attach the new XRecord to the stable named-object dictionary so it is + // reachable as a standalone named object. + let dict_h = xrecord_dictionary_handle(self.document_mut()); + if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { + dict.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(&spec.name)); + dict.add_entry(spec.name.clone(), record_handle); + } + Ok(handle_to_obj(record_handle)) + } + + 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:?}"), + )); + } + let handle = obj_to_handle(id); + let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { + return Err(ApiError::Unsupported(format!("ObjectId {id:?} is not an XRecord"))); + }; + let old_name = record.name.clone(); + let mut updated = record.clone(); + updated.name = spec.name.clone(); + updated.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); + updated.entries = spec + .entries + .iter() + .map(xrecord_entry_to_acadrust) + .collect(); + updated.synchronize_object_references(); + self.document_mut() + .objects + .insert(handle, acadrust::objects::ObjectType::XRecord(updated)); + // If the name changed, rename the dictionary entry so the XRecord remains + // reachable under its new name. + if !old_name.eq_ignore_ascii_case(&spec.name) { + let dict_h = xrecord_dictionary_handle(self.document_mut()); + if let Some(ObjectType::Dictionary(dict)) = + self.document_mut().objects.get_mut(&dict_h) + { + dict.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(&old_name)); + dict.add_entry(spec.name.clone(), handle); + } + } + Ok(()) + } + + fn xrecord(&self, id: ObjectId) -> ApiResult> { + let handle = obj_to_handle(id); + let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { + return Ok(None); + }; + Ok(Some(ocs_doc_api::XRecordSpec { + name: record.name.clone(), + cloning_flags: cloning_flags_from_acadrust(record.cloning_flags), + entries: record + .entries + .iter() + .map(xrecord_entry_from_acadrust) + .collect(), + })) + } + + fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { + let name = acadrust::tables::normalize_name(info.name.trim()); + if name.is_empty() { + return Err(ApiError::validation("CreateLayer", "empty layer name")); + } + if self.document().layers.contains(&name) { + return Err(ApiError::validation( + "CreateLayer", + format!("layer '{name}' already exists"), + )); + } + let mut layer = acadrust::tables::Layer::new(&name); + layer.handle = self.document_mut().allocate_handle(); + apply_layer_info(&mut layer, info); + self.document_mut() + .layers + .add(layer) + .map_err(|e| ApiError::validation("CreateLayer", e))?; + Ok(()) + } + + fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { + let deps = { + let doc = self.document_mut(); + let name_upper = acadrust::tables::normalize_name(name.trim()); + let Some(existing) = doc.layers.get(&name_upper).cloned() else { + return Err(ApiError::validation( + "UpdateLayer", + format!("layer '{name}' does not exist"), + )); + }; + let new_name = acadrust::tables::normalize_name(info.name.trim()); + if new_name.is_empty() { + return Err(ApiError::validation("UpdateLayer", "empty layer name")); + } + // Rename if the key changed (preserves handle and table position). + if name_upper != new_name { + doc.layers + .rename(&name_upper, new_name.clone()) + .map_err(|e| ApiError::validation("UpdateLayer", e))?; + } + let Some(layer) = doc.layers.get_mut(&new_name) else { + return Err(ApiError::validation( + "UpdateLayer", + format!("layer '{name}' does not exist"), + )); + }; + apply_layer_info(layer, info); + [existing.name.clone(), layer.name.clone()] + }; + // By-layer appearance changed: rebuild derived geometry that resolved it. + self.scene_mut().invalidate_layer_dependencies(&deps); + Ok(()) + } + fn delete_layer(&mut self, name: &str) -> ApiResult<()> { + let name_upper = acadrust::tables::normalize_name(name); + if name_upper == "0" { + return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); + } + if !self.document().layers.contains(&name_upper) { + return Err(ApiError::validation( + "DeleteLayer", + format!("layer '{name}' does not exist"), + )); + } + if acadrust::tables::normalize_name(&self.document().header.current_layer_name) == name_upper + { + return Err(ApiError::validation( + "DeleteLayer", + "cannot delete the current layer", + )); + } + if self + .document() + .entities() + .any(|e| acadrust::tables::normalize_name(&e.common().layer) == name_upper) + { + return Err(ApiError::validation( + "DeleteLayer", + format!("layer '{name}' is still in use"), + )); + } + // Remove by the normalized name so a differently-cased request behaves + // consistently with the rest of the layer table API. + self.document_mut().layers.remove(&name_upper); + Ok(()) + } + + + fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { + self.can_modify(id)?; + let handle = obj_to_handle(id); + let layer_upper = acadrust::tables::normalize_name(layer.trim()); + let target = self.document().layers.get(&layer_upper).cloned(); + let Some(target) = target else { + return Err(ApiError::validation( + "SetEntityLayer", + format!("layer '{layer}' does not exist"), + )); + }; + if target.is_locked() { + return Err(ApiError::Unsupported(format!( + "entity {id:?} cannot be moved to locked layer '{layer}'" + ))); + } + let mut entity = self + .document() + .get_entity(handle) + .cloned() + .ok_or(ApiError::UnknownId(id))?; + entity.common_mut().layer = target.name.clone(); + if !self.scene_mut().update_entity(entity) { + return Err(ApiError::Unsupported(format!( + "entity {id:?} is on a locked layer" + ))); + } + Ok(()) + } + + fn layers(&self) -> ApiResult> { + let mut v: Vec<_> = self + .document() + .layers + .iter() + .map(layer_info_from_acadrust) + .collect(); + v.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(v) + } + + fn entity_layer(&self, id: ObjectId) -> ApiResult { + let handle = obj_to_handle(id); + let entity = self + .document() + .get_entity(handle) + .ok_or(ApiError::UnknownId(id))?; + Ok(entity.common().layer.clone()) + } + + fn enumerate_entities( + &self, + kind: Option<&str>, + layer: Option<&str>, + include_bounds: bool, + ) -> ApiResult> { + let doc = self.document(); + let mut out = Vec::new(); + for entity in doc.entities() { + let handle = entity.common().handle; + let id = handle_to_obj(handle); + let entity_kind = convert::entity_kind_name(entity); + if let Some(k) = kind { + if !entity_kind.eq_ignore_ascii_case(k) { + continue; + } + } + if let Some(l) = layer { + if !entity.common().layer.eq_ignore_ascii_case(l) { + continue; + } + } + out.push(EntityView { + id, + kind: entity_kind.to_string(), + layer: entity.common().layer.clone(), + bounds: if include_bounds { + convert::entity_bounds(Some(entity), id).ok() + } else { + None + }, + }); + } + Ok(out) + } + + fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { + let handle = obj_to_handle(id); + let entity = self + .document() + .get_entity(handle) + .ok_or(ApiError::UnknownId(id))?; + let EntityType::Point(pt) = entity else { + return Err(ApiError::Unsupported( + "GetPointPosition is only for Point entities".into(), + )); + }; + Ok(v3_to_array(pt.location)) + } + + fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { + let handle = obj_to_handle(id); + let entity = self + .document() + .get_entity(handle) + .ok_or(ApiError::UnknownId(id))?; + let EntityType::Line(line) = entity else { + return Err(ApiError::Unsupported( + "GetLineGeometry is only for Line entities".into(), + )); + }; + Ok((v3_to_array(line.start), v3_to_array(line.end))) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -746,6 +1174,7 @@ impl DocApiBackend for HostSession<'_> { Ok(EntityView { id, kind: convert::entity_kind_name(entity).to_string(), + layer: entity.common().layer.clone(), bounds: self.bounds(id).ok(), }) } @@ -831,16 +1260,15 @@ impl DocApiBackend for HostSession<'_> { // ── helpers ────────────────────────────────────────────────────────────────── +type SolidDisplay = ( + crate::scene::model::mesh_model::MeshLodSet, + Vec, + [f64; 3], +); + struct PreparedDocEntity { entity: EntityType, - solid: Option<( - KernelBody, - ( - crate::scene::model::mesh_model::MeshLodSet, - Vec, - [f64; 3], - ), - )>, + solid: Option<(KernelBody, SolidDisplay)>, } impl HostSession<'_> { @@ -999,12 +1427,228 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { } } +// Conversions between ocs_doc_api plain-data DTOs and acadrust types. +// Kept in this file because they are host-side implementation details. + +fn cloning_flags_to_acadrust( + flags: ocs_doc_api::XRecordCloningFlags, +) -> acadrust::objects::DictionaryCloningFlags { + use acadrust::objects::DictionaryCloningFlags as F; + match flags { + ocs_doc_api::XRecordCloningFlags::NotApplicable => F::NotApplicable, + ocs_doc_api::XRecordCloningFlags::KeepExisting => F::KeepExisting, + ocs_doc_api::XRecordCloningFlags::UseClone => F::UseClone, + ocs_doc_api::XRecordCloningFlags::XrefName => F::XrefName, + ocs_doc_api::XRecordCloningFlags::Name => F::Name, + ocs_doc_api::XRecordCloningFlags::UnmangleName => F::UnmangleName, + } +} + +fn cloning_flags_from_acadrust( + flags: acadrust::objects::DictionaryCloningFlags, +) -> ocs_doc_api::XRecordCloningFlags { + use acadrust::objects::DictionaryCloningFlags as F; + match flags { + F::NotApplicable => ocs_doc_api::XRecordCloningFlags::NotApplicable, + F::KeepExisting => ocs_doc_api::XRecordCloningFlags::KeepExisting, + F::UseClone => ocs_doc_api::XRecordCloningFlags::UseClone, + F::XrefName => ocs_doc_api::XRecordCloningFlags::XrefName, + F::Name => ocs_doc_api::XRecordCloningFlags::Name, + F::UnmangleName => ocs_doc_api::XRecordCloningFlags::UnmangleName, + } +} + +fn xdata_value_to_acadrust( + value: &ocs_doc_api::XDataValue, + _doc: &acadrust::CadDocument, +) -> acadrust::xdata::XDataValue { + use acadrust::types::Vector3; + use ocs_doc_api::XDataValue as V; + match value { + V::String(s) => acadrust::xdata::XDataValue::String(s.clone()), + V::ControlString(s) => acadrust::xdata::XDataValue::ControlString(s.clone()), + V::LayerName(s) => acadrust::xdata::XDataValue::LayerName(s.clone()), + V::BinaryData(b) => acadrust::xdata::XDataValue::BinaryData(b.clone()), + V::Handle(h) => acadrust::xdata::XDataValue::Handle(acadrust::Handle::new(*h)), + V::Point3D(p) => acadrust::xdata::XDataValue::Point3D(Vector3::new(p[0], p[1], p[2])), + V::Position3D(p) => acadrust::xdata::XDataValue::Position3D(Vector3::new(p[0], p[1], p[2])), + V::Displacement3D(p) => { + acadrust::xdata::XDataValue::Displacement3D(Vector3::new(p[0], p[1], p[2])) + } + V::Direction3D(p) => acadrust::xdata::XDataValue::Direction3D(Vector3::new(p[0], p[1], p[2])), + V::Real(r) => acadrust::xdata::XDataValue::Real(*r), + V::Distance(d) => acadrust::xdata::XDataValue::Distance(*d), + V::ScaleFactor(s) => acadrust::xdata::XDataValue::ScaleFactor(*s), + V::Integer16(i) => acadrust::xdata::XDataValue::Integer16(*i), + V::Integer32(i) => acadrust::xdata::XDataValue::Integer32(*i), + } +} + +fn xdata_value_from_acadrust(value: &acadrust::xdata::XDataValue) -> ocs_doc_api::XDataValue { + use acadrust::xdata::XDataValue as V; + match value { + V::String(s) => ocs_doc_api::XDataValue::String(s.clone()), + V::ControlString(s) => ocs_doc_api::XDataValue::ControlString(s.clone()), + V::LayerName(s) => ocs_doc_api::XDataValue::LayerName(s.clone()), + V::BinaryData(b) => ocs_doc_api::XDataValue::BinaryData(b.clone()), + V::Handle(h) => ocs_doc_api::XDataValue::Handle(h.value()), + V::Point3D(v) => ocs_doc_api::XDataValue::Point3D([v.x, v.y, v.z]), + V::Position3D(v) => ocs_doc_api::XDataValue::Position3D([v.x, v.y, v.z]), + V::Displacement3D(v) => ocs_doc_api::XDataValue::Displacement3D([v.x, v.y, v.z]), + V::Direction3D(v) => ocs_doc_api::XDataValue::Direction3D([v.x, v.y, v.z]), + V::Real(r) => ocs_doc_api::XDataValue::Real(*r), + V::Distance(d) => ocs_doc_api::XDataValue::Distance(*d), + V::ScaleFactor(s) => ocs_doc_api::XDataValue::ScaleFactor(*s), + V::Integer16(i) => ocs_doc_api::XDataValue::Integer16(*i), + V::Integer32(i) => ocs_doc_api::XDataValue::Integer32(*i), + } +} + +fn xrecord_value_to_acadrust(value: &ocs_doc_api::XRecordValue) -> acadrust::objects::XRecordValue { + use ocs_doc_api::XRecordValue as V; + match value { + V::String(s) => acadrust::objects::XRecordValue::String(s.clone()), + V::Double(d) => acadrust::objects::XRecordValue::Double(*d), + V::Int16(i) => acadrust::objects::XRecordValue::Int16(*i), + V::Int32(i) => acadrust::objects::XRecordValue::Int32(*i), + V::Int64(i) => acadrust::objects::XRecordValue::Int64(*i), + V::Byte(b) => acadrust::objects::XRecordValue::Byte(*b), + V::Bool(b) => acadrust::objects::XRecordValue::Bool(*b), + V::Handle(h) => acadrust::objects::XRecordValue::Handle(acadrust::Handle::new(*h)), + V::Point3D(p) => acadrust::objects::XRecordValue::Point3D(p[0], p[1], p[2]), + V::Chunk(c) => acadrust::objects::XRecordValue::Chunk(c.clone()), + } +} + +fn xrecord_value_from_acadrust( + value: &acadrust::objects::XRecordValue, +) -> ocs_doc_api::XRecordValue { + use acadrust::objects::XRecordValue as V; + match value { + V::String(s) => ocs_doc_api::XRecordValue::String(s.clone()), + V::Double(d) => ocs_doc_api::XRecordValue::Double(*d), + V::Int16(i) => ocs_doc_api::XRecordValue::Int16(*i), + V::Int32(i) => ocs_doc_api::XRecordValue::Int32(*i), + V::Int64(i) => ocs_doc_api::XRecordValue::Int64(*i), + V::Byte(b) => ocs_doc_api::XRecordValue::Byte(*b), + V::Bool(b) => ocs_doc_api::XRecordValue::Bool(*b), + V::Handle(h) => ocs_doc_api::XRecordValue::Handle(h.value()), + V::Point3D(x, y, z) => ocs_doc_api::XRecordValue::Point3D([*x, *y, *z]), + V::Chunk(c) => ocs_doc_api::XRecordValue::Chunk(c.clone()), + } +} + +fn xrecord_entry_to_acadrust( + entry: &ocs_doc_api::XRecordEntry, +) -> acadrust::objects::XRecordEntry { + acadrust::objects::XRecordEntry { + code: entry.code, + value: xrecord_value_to_acadrust(&entry.value), + } +} + +fn xrecord_entry_from_acadrust( + entry: &acadrust::objects::XRecordEntry, +) -> ocs_doc_api::XRecordEntry { + ocs_doc_api::XRecordEntry { + code: entry.code, + value: xrecord_value_from_acadrust(&entry.value), + } +} + +// ── layer conversions ───────────────────────────────────────────────────── + +fn color_to_acadrust(c: Color) -> acadrust::types::Color { + use acadrust::types::Color as A; + match c { + Color::ByLayer => A::ByLayer, + Color::None => A::None, + Color::ByBlock => A::ByBlock, + Color::Index(i) => A::Index(i), + Color::Rgb { r, g, b } => A::Rgb { r, g, b }, + } +} + +fn color_from_acadrust(c: acadrust::types::Color) -> Color { + use acadrust::types::Color as A; + match c { + A::ByLayer => Color::ByLayer, + A::None => Color::None, + A::ByBlock => Color::ByBlock, + A::Index(i) => Color::Index(i), + A::Rgb { r, g, b } => Color::Rgb { r, g, b }, + } +} + +fn line_weight_to_acadrust(lw: LineWeight) -> acadrust::types::LineWeight { + use acadrust::types::LineWeight as A; + match lw { + LineWeight::ByLayer => A::ByLayer, + LineWeight::ByBlock => A::ByBlock, + LineWeight::Default => A::Default, + LineWeight::Value(v) => A::Value(v), + } +} + +fn line_weight_from_acadrust(lw: acadrust::types::LineWeight) -> LineWeight { + use acadrust::types::LineWeight as A; + match lw { + A::ByLayer => LineWeight::ByLayer, + A::ByBlock => LineWeight::ByBlock, + A::Default => LineWeight::Default, + A::Value(v) => LineWeight::Value(v), + } +} + +fn layer_flags_to_acadrust(f: LayerFlags) -> acadrust::tables::LayerFlags { + acadrust::tables::LayerFlags { + frozen: f.frozen, + locked: f.locked, + frozen_in_new_viewport: f.frozen_in_new_viewport, + off: f.off, + xref_dependent: false, + } +} + +fn layer_flags_from_acadrust(f: acadrust::tables::LayerFlags) -> LayerFlags { + LayerFlags { + frozen: f.frozen, + locked: f.locked, + frozen_in_new_viewport: f.frozen_in_new_viewport, + off: f.off, + } +} + +fn apply_layer_info(layer: &mut acadrust::tables::Layer, info: &LayerInfo) { + layer.name = info.name.trim().to_string(); + layer.flags = layer_flags_to_acadrust(info.flags); + layer.color = color_to_acadrust(info.color); + layer.color_name = None; + layer.book_name = None; + layer.line_type = info.line_type.clone(); + layer.line_weight = line_weight_to_acadrust(info.line_weight); + layer.plot_style = info.plot_style.clone(); + layer.is_plottable = info.is_plottable; +} + +fn layer_info_from_acadrust(layer: &acadrust::tables::Layer) -> LayerInfo { + LayerInfo { + name: layer.name.clone(), + flags: layer_flags_from_acadrust(layer.flags), + color: color_from_acadrust(layer.color), + line_type: layer.line_type.clone(), + line_weight: line_weight_from_acadrust(layer.line_weight), + plot_style: layer.plot_style.clone(), + is_plottable: layer.is_plottable, + } +} + #[cfg(test)] mod tests { use super::*; use crate::app::plugin_host::HostSession; use crate::app::OpenCADStudio; - use ocs_doc_api::ops::{BoolOp, SolidPrimitive}; + use ocs_doc_api::ops::{BoolOp, Color, LayerFlags, LayerInfo, LineWeight, SolidPrimitive}; use ocs_doc_api::{DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt}; fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult { @@ -1139,7 +1783,7 @@ mod tests { fn phase0_add_vertex_to_polyline() { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); - let mk_poly = Operation::CreateCurve(Curve2Spec::Polyline { + let mk_poly = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], closed: false, }); @@ -1179,7 +1823,7 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); // A closed rectangular 2x3 profile in XY. - let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [0.0, 0.0, 0.0], [2.0, 0.0, 0.0], @@ -1217,7 +1861,7 @@ mod tests { let mut host = HostSession::new(&mut app, 0); // A 1-wide, 2-tall rectangle offset 1 from the Y axis; revolve about the Y // axis by 2*pi -> a cylinder-ish annulus (outer r=2, inner r=1, h=2): pi*(4-1)*2 = 6pi ≈ 18.85. - let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], @@ -1259,7 +1903,7 @@ mod tests { fn phase0_non_solid_transform_line_and_circle() { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); - let mk_line = Operation::CreateCurve(Curve2Spec::Line { + let mk_line = Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [10.0; 3], }); @@ -1291,7 +1935,7 @@ mod tests { let circle = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0; 3], radius: 2.0, })), @@ -1319,7 +1963,7 @@ mod tests { let arc = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, centre: [5.0, 5.0, 0.0], radius: 4.0, start_angle: 0.0, @@ -1352,7 +1996,7 @@ mod tests { let ell = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, centre: [0.0, 0.0, 0.0], major_axis: [6.0, 0.0, 0.0], ratio: 0.5, @@ -1377,7 +2021,7 @@ mod tests { let spline = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline { layer: None, degree: 3, control_points: vec![ [0.0, 0.0, 0.0], @@ -1429,7 +2073,7 @@ mod tests { let ray = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, origin: [1.0, 2.0, 0.0], direction: [1.0, 0.0, 0.0], })), @@ -1439,7 +2083,7 @@ mod tests { let xline = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine { layer: None, origin: [0.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0], })), @@ -1561,7 +2205,7 @@ mod tests { let circle = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0, 0.0, 0.0], radius: 1.0, })), @@ -1609,7 +2253,7 @@ mod tests { let ray = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, origin: [0.0, 0.0, 0.0], direction: [1.0, 0.0, 0.0], })), @@ -1637,7 +2281,7 @@ mod tests { let ell = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, @@ -1668,7 +2312,7 @@ mod tests { let arc = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, centre: [0.0; 3], radius: 2.0, start_angle: 0.0, @@ -1704,7 +2348,7 @@ mod tests { let a = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [0.0; 3], })), ) @@ -1713,7 +2357,7 @@ mod tests { let b = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [1.0; 3], })), ) @@ -1808,7 +2452,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [1.0; 3], })), @@ -1869,7 +2513,7 @@ mod tests { let c1 = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0, 0.0, 0.0], radius: 5.0, })), @@ -1879,7 +2523,7 @@ mod tests { let c2 = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0, 0.0, 10.0], radius: 2.0, })), @@ -1920,7 +2564,7 @@ mod tests { let poly = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], closed: false, })), @@ -1967,7 +2611,7 @@ mod tests { if let cadkernel::geom2d::Curve::Arc(arc) = &neg { // 90° arc, chord 1: radius = 0.5/sin(45°) ≈ 0.7071; center y must be negative. assert!( - (arc.radius - 0.7071).abs() < 0.01, + (arc.radius - std::f64::consts::FRAC_1_SQRT_2).abs() < 0.01, "cw arc radius {}", arc.radius ); @@ -2286,8 +2930,8 @@ mod tests { let rev_before = host.scene().geometry_epoch; // A valid point + an invalid ellipse (ratio > 1) in one CreateMany batch. let specs = vec![ - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }), - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { + ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [0.0; 3] }), + ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { layer: None, centre: [0.0; 3], major_axis: [1.0; 3], ratio: 2.0, @@ -2314,7 +2958,7 @@ mod tests { let ell = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, @@ -2392,24 +3036,28 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); // Leader with vertices at (0,0,0),(5,5,0),(10,0,0) -> bounds x[0,10] y[0,5]. - let mut leader = acadrust::entities::Leader::default(); - leader.vertices = vec![ - acadrust::types::Vector3::new(0.0, 0.0, 0.0), - acadrust::types::Vector3::new(5.0, 5.0, 0.0), - acadrust::types::Vector3::new(10.0, 0.0, 0.0), - ]; + let leader = acadrust::entities::Leader { + vertices: vec![ + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(5.0, 5.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ], + ..Default::default() + }; let leader_h = host .document_mut() .add_entity(EntityType::Leader(leader)) .unwrap(); // Mesh with vertices in a unit cube at (2..3, 2..3, 0). - let mut mesh = acadrust::entities::Mesh::default(); - mesh.vertices = vec![ - acadrust::types::Vector3::new(2.0, 2.0, 0.0), - acadrust::types::Vector3::new(3.0, 2.0, 0.0), - acadrust::types::Vector3::new(3.0, 3.0, 0.0), - acadrust::types::Vector3::new(2.0, 3.0, 0.0), - ]; + let mesh = acadrust::entities::Mesh { + vertices: vec![ + acadrust::types::Vector3::new(2.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 3.0, 0.0), + acadrust::types::Vector3::new(2.0, 3.0, 0.0), + ], + ..Default::default() + }; let mesh_h = host .document_mut() .add_entity(EntityType::Mesh(mesh)) @@ -2562,7 +3210,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [1.0; 3], })), @@ -2594,7 +3242,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [10.0; 3], })), @@ -2811,11 +3459,13 @@ mod tests { let img = { use acadrust::entities::RasterImage; use acadrust::types::{Vector2, Vector3}; - let mut img = RasterImage::default(); - img.insertion_point = Vector3::new(10.0, 20.0, 0.0); - img.u_vector = Vector3::new(0.5, 0.0, 0.0); // 0.5 world-units/pixel in X - img.v_vector = Vector3::new(0.0, 0.5, 0.0); - img.size = Vector2::new(100.0, 50.0); + let img = RasterImage { + insertion_point: Vector3::new(10.0, 20.0, 0.0), + u_vector: Vector3::new(0.5, 0.0, 0.0), // 0.5 world-units/pixel in X + v_vector: Vector3::new(0.0, 0.5, 0.0), + size: Vector2::new(100.0, 50.0), + ..Default::default() + }; host.document_mut() .add_entity(EntityType::RasterImage(img)) .unwrap() @@ -2927,7 +3577,7 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); // A request naming a DIFFERENT tab than the bound one is rejected. - let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [0.0; 3], })); let bytes = bincode::serialize(&env).unwrap(); @@ -2967,7 +3617,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0, 0.0, 0.0], end: [10.0, 0.0, 0.0], })), @@ -3000,7 +3650,7 @@ mod tests { let circle = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [5.0, 5.0, 0.0], radius: 3.0, })), @@ -3026,7 +3676,7 @@ mod tests { let point = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [1.0, 2.0, 3.0], })), ) @@ -3046,7 +3696,7 @@ mod tests { let poly = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], @@ -3247,17 +3897,17 @@ mod tests { let mut host = HostSession::new(&mut app, 0); let invalid_ops = vec![ Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }), + EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [0.0; 3] }), EntitySpec::Solid(SolidPrimitive::Sphere { centre: [0.0; 3], radius: f64::NAN, }), ]), - Operation::CreateCurve(Curve2Spec::Circle { + Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0; 3], radius: -1.0, }), - Operation::CreateCurve(Curve2Spec::Polyline { + Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![[0.0; 3], [1.0; 3]], closed: false, }), @@ -3290,7 +3940,7 @@ mod tests { let receipt = dispatch( &mut host, DocApiEnvelope::op(Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { position: [1.0; 3] }), + EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [1.0; 3] }), EntitySpec::Solid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [2.0; 3], @@ -3360,7 +4010,7 @@ mod tests { let profile = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [0.0, 0.0, 7.0], [2.0, 0.0, 7.0], @@ -3473,4 +4123,218 @@ mod tests { .is_some()); assert!(app.tabs[0].scene.document.objects.len() > objects); } + + #[test] + fn doc_api_layer_crud_roundtrip_and_entity_assignment() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Create a line to assign to a layer later. + let line = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, + start: [0.0; 3], + end: [1.0; 3], + })), + ) + .unwrap(), + ); + + // List default layers — must include "0". + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); + let QueryResult::Layers(list) = &receipt.query_results[0] else { + panic!("expected Layers result"); + }; + assert!(list.iter().any(|l| l.name == "0")); + + // Create a new layer. + let mut info = LayerInfo::new("Walls"); + info.color = Color::Index(1); + info.line_weight = LineWeight::Value(25); + info.flags = LayerFlags { + frozen: false, + locked: false, + frozen_in_new_viewport: false, + off: false, + }; + dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info.clone()))).unwrap(); + + // Update the layer color. + let mut updated = info.clone(); + updated.color = Color::Index(2); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::UpdateLayer { + name: "Walls".into(), + info: updated.clone(), + }), + ) + .unwrap(); + + // Assign the line to the new layer. + dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetEntityLayer { + id: line, + layer: "Walls".into(), + }), + ) + .unwrap(); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetEntityLayer { id: line }]), + ) + .unwrap(); + assert!(matches!(&receipt.query_results[0], QueryResult::EntityLayer(name) if name == "Walls")); + + // Deleting a layer still in use fails. + let err = dispatch( + &mut host, + DocApiEnvelope::op(Operation::DeleteLayer { + name: "Walls".into(), + }), + ) + .unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. })); + + // Move the line back to "0", then delete the layer. + dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetEntityLayer { + id: line, + layer: "0".into(), + }), + ) + .unwrap(); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::DeleteLayer { + name: "Walls".into(), + }), + ) + .unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); + let QueryResult::Layers(list) = &receipt.query_results[0] else { + panic!("expected Layers result"); + }; + assert!(!list.iter().any(|l| l.name == "Walls")); + } + + #[test] + fn doc_api_enumerate_entities_and_geometry_queries() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Create entities on different layers. + let line_id = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, + start: [1.0, 2.0, 3.0], + end: [4.0, 5.0, 6.0], + })), + ) + .unwrap(), + ); + let point_id = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Point { layer: None, + position: [7.0, 8.0, 9.0], + })), + ) + .unwrap(), + ); + + // Create a layer and move the point to it. + let mut info = LayerInfo::new("Markers"); + info.color = Color::Index(3); + dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info))).unwrap(); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetEntityLayer { + id: point_id, + layer: "Markers".into(), + }), + ) + .unwrap(); + + // Enumerate all entities. + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::EnumerateEntities { + kind: None, + layer: None, + include_bounds: true, + }]), + ) + .unwrap(); + let QueryResult::Entities(all) = &receipt.query_results[0] else { + panic!("expected Entities result"); + }; + assert!(all.iter().any(|e| e.id == line_id && e.kind == "Line" && e.layer == "0")); + assert!(all.iter().any(|e| e.id == point_id && e.kind == "Point" && e.layer == "Markers")); + + // Filter by kind. + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::EnumerateEntities { + kind: Some("Line".into()), + layer: None, + include_bounds: true, + }]), + ) + .unwrap(); + let QueryResult::Entities(lines) = &receipt.query_results[0] else { + panic!("expected Entities result"); + }; + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].id, line_id); + + // Filter by layer. + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::EnumerateEntities { + kind: None, + layer: Some("Markers".into()), + include_bounds: true, + }]), + ) + .unwrap(); + let QueryResult::Entities(markers) = &receipt.query_results[0] else { + panic!("expected Entities result"); + }; + assert_eq!(markers.len(), 1); + assert_eq!(markers[0].id, point_id); + + // Geometry queries. + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![ + Query::GetPointPosition { id: point_id }, + Query::GetLineGeometry { id: line_id }, + ]), + ) + .unwrap(); + let QueryResult::PointPosition(pos) = &receipt.query_results[0] else { + panic!("expected PointPosition result"); + }; + assert!((pos[0] - 7.0).abs() < 1e-9); + assert!((pos[1] - 8.0).abs() < 1e-9); + assert!((pos[2] - 9.0).abs() < 1e-9); + let QueryResult::LineGeometry((start, end)) = &receipt.query_results[1] else { + panic!("expected LineGeometry result"); + }; + assert!((start[0] - 1.0).abs() < 1e-9); + assert!((end[0] - 4.0).abs() < 1e-9); + + // Wrong entity family is rejected. + let err = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetPointPosition { id: line_id }]), + ) + .unwrap_err(); + assert!(matches!(err, ApiError::Unsupported { .. })); + } }