diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 511b269c8..a0630584d 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -107,10 +107,11 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal `RasterImage` has coarse bounds (insertion + u·width + v·height); generic `delete` works on all. Covered by `phase5_*` test. - **Done (typed create):** `create_raster_image(file_path, insertion, u, v, size)` - → `Operation::CreateRasterImage`; the host auto-registers the `ImageDefinition` - (asserted by `phase5_create_raster_image_registers_definition`). Covered by that - test. `Table`/light stay read-only `EntityView` DTOs (typed ops only where the - spec declares an action mapping). + → `Operation::CreateRasterImage` (host auto-registers the ImageDefinition), and + `create_table(insertion_point, data[row][col])` → `Operation::CreateTable` + (non-rectangular grid → `Validation`; cells built as text). Covered by + `phase5_create_raster_image_*` and `phase5ii_create_table_*` tests. `light` stays + read-only (no meaningful geometric action). ## 🔧 Outstanding methods on supported families diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 97a8c51e8..5e49cb300 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -206,6 +206,13 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("AttributeDefinition")) } + fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { + let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); + if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { + return Err(ApiError::validation("CreateTable", "table needs a non-empty rectangular grid")); + } + Ok(self.alloc("Table")) + } fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index ba7ad316c..e8442a563 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -445,7 +445,12 @@ name = "Table" acadrust_variant = "Table" handle = "Entity" collection = "entities" -description = "Table (read-only): GetEntity kind; generic delete. No typed create/bounds in v1." +description = "Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete." + +[[family.constructor]] +name = "create_table" +args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec]" }] +op = "CreateTable" # ============================================================================ # 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 6432ed2da..3bed14d8e 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -75,6 +75,9 @@ pub trait DocApiBackend { /// Add an `ATTDEF` (in-block attribute definition); returns the fresh `ObjectId`. fn add_attribute_definition(&mut self, spec: &crate::ops::AttributeDefinitionSpec) -> ApiResult; + /// Add a `TABLE` (rows × columns grid); returns the fresh `ObjectId`. + fn add_table(&mut self, spec: &crate::ops::TableSpec) -> ApiResult; + /// A dimension's measured value (distance for linear/radius, degrees for angular). fn dimension_measurement(&self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index c2c3b7f08..735ed8bc3 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -293,6 +293,17 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + // Validate a non-empty, rectangular grid before committing. + let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); + if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { + return Err(ApiError::validation(name, "table needs a non-empty rectangular grid")); + } + b.push_undo(name); + let id = b.add_table(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 4cb8e40d9..d7ce04372 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -631,6 +631,16 @@ impl EntityCollection { Ok(Entity::new(self.session.clone(), id)) } + /// Create a TABLE from a grid of cell strings (`data[row][column]`), at a point. + pub fn create_table(&self, insertion_point: [f64; 3], data: &[Vec]) -> ApiResult { + let receipt = self.session.apply_op(Operation::CreateTable(crate::ops::TableSpec { + insertion_point, + data: data.to_vec(), + }))?; + let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_table returned no id".into()))?; + Ok(Entity::new(self.session.clone(), id)) + } + /// Create an ATTDEF (in-block attribute definition): tag/prompt/default at a point. pub fn create_attribute_definition(&self, tag: &str, prompt: &str, default_value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> ApiResult { let receipt = self.session.apply_op(Operation::CreateAttributeDefinition(crate::ops::AttributeDefinitionSpec { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 57fadbf00..ed7c2b9ac 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -185,7 +185,11 @@ Raster image: create_raster_image(file_path, insertion, u, v, size) places an im ## `Entity` (acadrust `Table`, collection `entities`) -Table (read-only): GetEntity kind; generic delete. No typed create/bounds in v1. +Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete. + +### Constructors (`doc.entities()`) + +- **create_table**(insertion_point: [f64; 3], data: &[Vec]) -> `Entity` — `CreateTable` ## Generic methods (every handle) diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index d88a3cf3f..c4c1f6266 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, SolidPrimitive, TextSpec, ViewportSpec}; +use crate::ops::{AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, SolidPrimitive, TableSpec, TextSpec, ViewportSpec}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -54,4 +54,6 @@ pub enum Operation { CreateDimensionAngular(DimensionAngularSpec), // attribute definitions CreateAttributeDefinition(AttributeDefinitionSpec), + // tables + CreateTable(TableSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 0fe193af2..32e02250c 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -148,6 +148,15 @@ pub struct AttributeDefinitionSpec { pub rotation: f64, } +/// Construction spec for a `TABLE`: an insertion point plus a grid of cell +/// strings (`data[row][column]`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TableSpec { + pub insertion_point: [f64; 3], + /// Grid of cell text values, rows × columns. + pub data: Vec>, +} + /// Construction spec for a `RASTER_IMAGE`: an image file placed at /// `insertion_point` with per-pixel world-unit vectors `u_vector`/`v_vector` and /// pixel `size` (width, height). The host auto-registers the ImageDefinition. @@ -223,6 +232,7 @@ impl crate::gen::Operation { CreateDimensionDiameter(_) => "CreateDimensionDiameter", CreateDimensionAngular(_) => "CreateDimensionAngular", CreateAttributeDefinition(_) => "CreateAttributeDefinition", + CreateTable(_) => "CreateTable", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 1e8c46688..2488f29df 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -146,6 +146,13 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("AttributeDefinition")) } + fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { + let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); + if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { + return Err(ApiError::validation("CreateTable", "table needs a non-empty rectangular grid")); + } + Ok(self.alloc("Table")) + } fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index 0486d3ada..c646aabbe 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -70,6 +70,7 @@ fn operation_variants() -> Vec { Operation::CreateAttributeDefinition(ocs_doc_api::ops::AttributeDefinitionSpec { tag: String::new(), prompt: String::new(), default_value: String::new(), insertion_point: [0.0; 3], height: 0.0, rotation: 0.0, }), + Operation::CreateTable(ocs_doc_api::ops::TableSpec { insertion_point: [0.0; 3], data: vec![] }), ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -163,6 +164,7 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateDimensionDiameter", "CreateDimensionAngular", "CreateAttributeDefinition", + "CreateTable", ]; /// Recorded baseline of the `Query` variant order. diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index e771bc2eb..1a2bb9b21 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -306,6 +306,25 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { + use acadrust::entities::Table; + use acadrust::types::Vector3; + let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); + let mut table = Table::new( + Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]), + spec.data.len(), + cols, + ); + // Fill cells from the grid (each cell = text). + for (r, row) in spec.data.iter().enumerate() { + for (c, text) in row.iter().enumerate() { + table.set_cell_text(r, c, text); + } + } + let handle = self.scene_mut().add_entity(EntityType::Table(table)); + Ok(handle_to_obj(handle)) + } + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { let handle = obj_to_handle(id); let entity = self.document().get_entity(handle).cloned().ok_or(ApiError::UnknownId(id))?; @@ -1186,6 +1205,35 @@ mod tests { } } + // ── Phase 5-ii: typed Table create ─────────────────────────────────────── + + #[test] + fn phase5ii_create_table_from_grid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let data = vec![ + vec!["Name".to_string(), "Value".to_string()], + vec!["Length".to_string(), "100.0".to_string()], + vec!["Width".to_string(), "50.0".to_string()], + ]; + let table = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateTable( + ocs_doc_api::ops::TableSpec { insertion_point: [10.0, 10.0, 0.0], data: data.clone() }))).unwrap()); + let handle = obj_to_handle(table); + let Some(EntityType::Table(t)) = host.document().get_entity(handle) else { panic!("table not found") }; + assert_eq!(t.rows.len(), 3); + assert_eq!(t.columns.len(), 2); + // Non-rectangular grid is a validation error. + let bad = vec![vec!["a".to_string()], vec!["b".to_string(), "c".to_string()]]; + assert!(dispatch(&mut host, DocApiEnvelope::op(Operation::CreateTable( + ocs_doc_api::ops::TableSpec { insertion_point: [0.0; 3], data: bad }))).is_err()); + // GetEntity reports kind Table. + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetEntity { id: table }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(e) => assert_eq!(e.kind, "Table"), + other => panic!("expected entity, got {other:?}"), + } + } + // ── Phase 5 remaining: typed raster image create ───────────────────────── #[test]