diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 1155f5c73..31bf0bcda 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -61,9 +61,11 @@ variants append at enum end. are supported (Phase 0). ### Phase 3 — containers -- **Entities:** insert/block references, attribute definitions/entities. -- **Methods:** `create_insert`, placement, attribute get/set. -- **Read-only:** nested block content traversal (nested ≠ first-level). +- **Done:** `create_insert(block_name, point, scale, rotation)` → `Operation::CreateInsert` + (validates the BlockRecord exists; unknown block → `Validation`, no mutation). + Insert `transform` moves its `insert_point`. Covered by `phase3_*` test. +- **Remaining:** `AttributeDefinition`/`AttributeEntity` typed create + attribute + get/set; read-only nested block-content traversal. ### Phase 4 — paper-space & viewports - **Entities:** viewport entities. diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 8b05ffb1d..e9ec290ab 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -99,6 +99,12 @@ impl DocApiBackend for MockBackend { self.curves.insert(id, spec.clone()); Ok(id) } + 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")) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index f6f2e0306..e30563518 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -237,6 +237,21 @@ args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; op = "CreateCurve" fixed = { curve = "XLine" } +# ============================================================================ +# CONTAINER FAMILY (phase 3) — block references +# ============================================================================ +[[family]] +name = "Insert" +acadrust_variant = "Insert" +handle = "Entity" +collection = "entities" +description = "Block reference. Place an existing BlockRecord by name; transform moves its insert point." + +[[family.constructor]] +name = "create_insert" +args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }] +op = "CreateInsert" + # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 16bb91bc0..9be15e0ba 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -35,6 +35,10 @@ pub trait DocApiBackend { /// Add a 2D curve entity; returns the fresh `ObjectId`. fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult; + /// Add a block reference (`INSERT`). Validates `block_name` exists; returns + /// the fresh `ObjectId`. `Validation` if the block is unknown. + fn add_insert(&mut self, spec: &crate::ops::InsertSpec) -> ApiResult; + /// Remove any first-level entity by id. Returns `false` if absent; returns /// `Err` if the entity exists but cannot be removed (e.g. locked layer). fn remove_entity(&mut self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index f2736bf3c..4fe69917f 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -176,6 +176,13 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + // Validate the block name exists before committing (no rollback needed). + b.push_undo(name); + let id = b.add_insert(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 80c157be2..ea4f73d32 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -464,6 +464,21 @@ pub struct EntityCollection { } impl EntityCollection { + /// Place a block reference (`INSERT`) for `block_name` (must exist). + pub fn create_insert(&self, block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64) -> ApiResult { + let receipt = self.session.apply_op(Operation::CreateInsert(crate::ops::InsertSpec { + block_name: block_name.to_string(), + insert_point, + scale, + rotation, + }))?; + let id = receipt + .outcome + .and_then(|o| o.new_id()) + .ok_or_else(|| ApiError::Transport("create_insert returned no id".into()))?; + Ok(Entity::new(self.session.clone(), id)) + } + /// Generic lookup by id (any family) → `Entity` (downcast via `as_solid()`, …). pub fn get(&self, id: ObjectId) -> ApiResult { // Validate existence eagerly so `get` errors surface as `UnknownId`. diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 69a191ceb..201f9e5ac 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -87,6 +87,14 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_xline**(`origin: [f64; 3], direction: [f64; 3]`) -> `XLine` +## `Entity` (acadrust `Insert`, collection `entities`) + +Block reference. Place an existing BlockRecord by name; transform moves its insert point. + +### Constructors (`doc.entities()`) + +- **create_insert**(`block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64`) -> `Entity` + ## Generic methods (every handle) Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index b7e9c9ca2..611ca78ca 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -187,6 +187,13 @@ "handle": "XLine", "methods": [], "name": "XLine" + }, + { + "acadrust_variant": "Insert", + "collection": "entities", + "handle": "Entity", + "methods": [], + "name": "Insert" } ], "layouts": { diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 2070e6117..3e7883f3a 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, EntitySpec, PlacementSpec, SolidPrimitive}; +use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, PlacementSpec, SolidPrimitive}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -27,4 +27,6 @@ pub enum Operation { CreateMany(Vec), TransformMany { ids: Vec, placement: PlacementSpec }, DeleteMany(Vec), + // containers + CreateInsert(InsertSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 6ab66f47f..f6ca50ecd 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -56,6 +56,17 @@ pub enum EntitySpec { Solid(SolidPrimitive), } +/// 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). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InsertSpec { + pub block_name: String, + pub insert_point: [f64; 3], + pub scale: f64, + pub rotation: f64, +} + /// A rigid similarity (rotation + translation + optional uniform scale/reflection) /// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` /// (non-serde). Columns are the new basis axes; `origin` is the translation. @@ -103,6 +114,7 @@ impl crate::gen::Operation { CreateMany(_) => "CreateMany", TransformMany { .. } => "TransformMany", DeleteMany(_) => "DeleteMany", + CreateInsert(_) => "CreateInsert", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index eba7e75ce..5de13c856 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -59,6 +59,12 @@ impl DocApiBackend for MockBackend { }; Ok(self.alloc(kind)) } + 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")) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index b1c6abaa1..e6faf7db2 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -105,6 +105,26 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { + // Validate the referenced block exists before committing. + if self.document().block_records.get(&spec.block_name).is_none() { + return Err(ApiError::validation( + "CreateInsert", + format!("unknown block {:?}", spec.block_name), + )); + } + let mut ins = acadrust::entities::Insert::new( + spec.block_name.clone(), + acadrust::types::Vector3::new(spec.insert_point[0], spec.insert_point[1], spec.insert_point[2]), + ); + ins.rotation = spec.rotation; + ins.set_x_scale(spec.scale); + ins.set_y_scale(spec.scale); + ins.set_z_scale(spec.scale); + let handle = self.scene_mut().add_entity(EntityType::Insert(ins)); + Ok(handle_to_obj(handle)) + } + 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 { @@ -162,6 +182,7 @@ impl DocApiBackend for HostSession<'_> { | EntityType::Spline(_) | EntityType::Ray(_) | EntityType::XLine(_) + | EntityType::Insert(_) | EntityType::Point(_) | EntityType::LwPolyline(_) ); @@ -605,6 +626,37 @@ mod tests { assert!((r.base_point.z - 5.0).abs() < 1e-6); } + // ── Phase 3: containers (block references) ─────────────────────────────── + + #[test] + fn phase3_create_insert_and_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Register a block record so the insert can reference it. + host.document_mut().block_records.add(acadrust::tables::BlockRecord::new("MyBlock")).unwrap(); + + // Insert the block at (10,20,0) scale 2, rotation 0. + let ins = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateInsert( + ocs_doc_api::ops::InsertSpec { block_name: "MyBlock".into(), insert_point: [10.0, 20.0, 0.0], scale: 2.0, rotation: 0.0 }))).unwrap()); + let handle = obj_to_handle(ins); + let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else { panic!("insert not found") }; + assert_eq!(i.block_name, "MyBlock"); + assert!((i.insert_point.x - 10.0).abs() < 1e-9 && (i.x_scale() - 2.0).abs() < 1e-9); + + // Transform the insert by +5 in X. + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: ins, placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]) })).unwrap(); + let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else { panic!("insert not found") }; + assert!((i.insert_point.x - 15.0).abs() < 1e-6); + + // Inserting a non-existent block is a Validation error, no entity created. + let before = host.scene().geometry_epoch; + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::CreateInsert( + ocs_doc_api::ops::InsertSpec { block_name: "NoSuchBlock".into(), insert_point: [0.0; 3], scale: 1.0, rotation: 0.0 }))).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); + assert_eq!(host.scene().geometry_epoch, before, "no entity on unknown block"); + } + #[test] fn doc_api_unknown_id_surfaces_structured_error() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 83a212e8e..880507133 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -124,6 +124,11 @@ pub fn transform_entity_geometry( x.base_point = apply(x.base_point); EntityType::XLine(x) } + EntityType::Insert(ins) => { + let mut ins = ins.clone(); + ins.insert_point = apply(ins.insert_point); + EntityType::Insert(ins) + } EntityType::Point(pt) => { let mut pt = pt.clone(); pt.location = apply(pt.location); @@ -236,9 +241,9 @@ pub fn entity_kind_name(entity: &EntityType) -> &'static str { EntityType::Spline(_) => "Spline", EntityType::Ray(_) => "Ray", EntityType::XLine(_) => "XLine", + EntityType::Insert(_) => "Insert", EntityType::Text(_) => "Text", EntityType::MText(_) => "MText", - EntityType::Insert(_) => "Insert", _ => "Other", } }