diff --git a/Cargo.lock b/Cargo.lock
index 6fa08c29a..93d22681c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -895,7 +895,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
-source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dfd0de176a878c20a1aeb8d23b0b8265197b5f5c#dfd0de176a878c20a1aeb8d23b0b8265197b5f5c"
+source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0507b720fef47bd7da0d4f777f97f1743a4f4038#0507b720fef47bd7da0d4f777f97f1743a4f4038"
dependencies = [
"acadrust",
"cavalier_contours",
diff --git a/Cargo.toml b/Cargo.toml
index 85dfe881b..34cc635e3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -34,7 +34,7 @@ rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] }
-cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"] }
+cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
diff --git a/assets/icons/boundary.svg b/assets/icons/boundary.svg
new file mode 100644
index 000000000..61848abfd
--- /dev/null
+++ b/assets/icons/boundary.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg
index 813f60544..f770b6cf6 100644
--- a/assets/icons/copy.svg
+++ b/assets/icons/copy.svg
@@ -1,4 +1,60 @@
-
diff --git a/assets/icons/xline.svg b/assets/icons/xline.svg
new file mode 100644
index 000000000..7b02665ef
--- /dev/null
+++ b/assets/icons/xline.svg
@@ -0,0 +1,3 @@
+
+
+
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..744d3559e 100644
--- a/crates/ocs_doc_api/Cargo.toml
+++ b/crates/ocs_doc_api/Cargo.toml
@@ -15,7 +15,7 @@ thiserror = "1"
# Host adapters convert ObjectId to acadrust handles; kernel handles stay local.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true }
-cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"], optional = true }
+cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"], optional = true }
ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] }
[build-dependencies]
@@ -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..c951f0d66 100644
--- a/crates/ocs_doc_api/build.rs
+++ b/crates/ocs_doc_api/build.rs
@@ -168,9 +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"`).
+`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed
+downcast when `kind == "Solid3D"`), and XDATA access through
+`xdata(application_name)` / `set_xdata(application_name, record)`.
+
+## 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
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