From fc0e2c2c9db09563ec94ad002165703aecbdaf02 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:07:41 +0200 Subject: [PATCH] ocs_doc_api: Phase 2b-a (Text/MText annotations) - CreateText/CreateMText/SetTextContent ops + GetTextContent query (append-only). - backend add_text/add_mtext/set_text_content/text_content (Text.value/MText.value). - facade create_text/create_mtext + Text/MText handles with content()/set_content(). - Text/MText coarse bounds + transform (insertion point); ensure_transformable. - spec Text/MText families; api_reference + binding_schema regenerated; mocks. - can_modify backend hook (exists + not-locked) for SetTextContent validation. - consistency baseline extended with the appended variants. - Test phase2b_text_mtext_create_content_transform -> 605 host lib + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 8 +- crates/ocs_doc_api/examples/common/mod.rs | 23 +++++ crates/ocs_doc_api/spec/entities.toml | 53 ++++++++++ crates/ocs_doc_api/src/backend.rs | 23 +++++ crates/ocs_doc_api/src/executor.rs | 23 +++++ crates/ocs_doc_api/src/facade.rs | 44 +++++++++ crates/ocs_doc_api/src/gen/api_reference.md | 26 +++++ .../ocs_doc_api/src/gen/binding_schema.json | 74 +++++++++++++- crates/ocs_doc_api/src/gen/ops_gen.rs | 6 +- crates/ocs_doc_api/src/gen/query_gen.rs | 2 + crates/ocs_doc_api/src/ops.rs | 20 ++++ crates/ocs_doc_api/src/query.rs | 3 + crates/ocs_doc_api/tests/mock_backend.rs | 23 +++++ .../tests/spec_wire_consistency.rs | 8 ++ src/app/doc_api.rs | 98 +++++++++++++++++++ src/app/doc_api_convert.rs | 19 ++++ 16 files changed, 449 insertions(+), 4 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 00e35823d..73aab0b02 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -61,8 +61,12 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal (Arc coarse full-circle, Ellipse major-axis radius, Spline control-point bbox; Ray/XLine unbounded → `Unsupported`), and transform (all five + the phase-1 set) via acadrust. Covered by `phase2_*` tests. -- **Remaining (2b):** annotations `Text`/`MText` (create + content get/set), `Hatch` - (create + boundary query), `Dimension` (typed sub-types). +- **Done (2b-a):** annotations `Text`/`MText` — `create_text`/`create_mtext` + (`CreateText`/`CreateMText` wire ops), `content()`/`set_content()` (new + `GetTextContent` query + `SetTextContent` op), transform (insertion point), + coarse bounds. Covered by `phase2b_*` test. +- **Remaining (2b-b/c):** `Hatch` (create + boundary query), `Dimension` (typed + sub-types). - **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline) are supported (Phase 0). diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 7156bd178..e0ba05758 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -21,6 +21,8 @@ pub struct MockBackend { kinds: HashMap, /// Stored 2D curve specs so `bounds()` works on non-solid entities too. curves: HashMap, + /// Stored text content for Text/MText annotations. + text_values: HashMap, } impl MockBackend { @@ -133,6 +135,27 @@ impl DocApiBackend for MockBackend { fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { Ok(self.alloc("Viewport")) } + 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()); + 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()); + Ok(id) + } + fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { + if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { + e.insert(value.to_string()); + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + fn text_content(&self, id: ObjectId) -> ApiResult { + self.text_values.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(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 7eb4fc097..0740139c1 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -237,6 +237,59 @@ args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; op = "CreateCurve" fixed = { curve = "XLine" } +# ============================================================================ +# ANNOTATION FAMILY (phase 2b) — text annotations +# ============================================================================ +[[family]] +name = "Text" +acadrust_variant = "Text" +handle = "Text" +collection = "curves" +description = "Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." + +[[family.constructor]] +name = "create_text" +args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }] +op = "CreateText" + +[[family.method]] +name = "content" +kind = "query" +query = "GetTextContent" +returns = "String" + +[[family.method]] +name = "set_content" +kind = "op" +op = "SetTextContent" +args = [{ name = "value", ty = "&str" }] +returns = "()" + +[[family]] +name = "MText" +acadrust_variant = "MText" +handle = "MText" +collection = "curves" +description = "Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." + +[[family.constructor]] +name = "create_mtext" +args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }] +op = "CreateMText" + +[[family.method]] +name = "content" +kind = "query" +query = "GetTextContent" +returns = "String" + +[[family.method]] +name = "set_content" +kind = "op" +op = "SetTextContent" +args = [{ name = "value", ty = "&str" }] +returns = "()" + # ============================================================================ # CONTAINER FAMILY (phase 3) — block references # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 5fc03379e..d1a166854 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -42,6 +42,29 @@ pub trait DocApiBackend { /// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`. fn add_viewport(&mut self, spec: &crate::ops::ViewportSpec) -> ApiResult; + /// Add a single-line `TEXT` annotation; returns the fresh `ObjectId`. + fn add_text(&mut self, spec: &crate::ops::TextSpec) -> ApiResult; + + /// Add a multi-line `MTEXT` annotation; returns the fresh `ObjectId`. + fn add_mtext(&mut self, spec: &crate::ops::MTextSpec) -> ApiResult; + + /// Set the text content of a Text/MText annotation in place. + fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()>; + + /// The text content of a Text/MText annotation. `Unsupported` for other kinds. + fn text_content(&self, id: ObjectId) -> ApiResult; + + /// 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). + fn can_modify(&self, id: ObjectId) -> ApiResult<()> { + if self.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + /// 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 5267229b3..75f2cbbae 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -189,6 +189,28 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + let id = b.add_text(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::CreateMText(spec) => { + b.push_undo(name); + let id = b.add_mtext(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::SetTextContent { id, value } => { + b.can_modify(*id).map_err(|e| match e { + ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")), + other => other, + })?; + b.push_undo(name); + b.set_text_content(*id, value)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -226,6 +248,7 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes QueryResult::Intersects(ba.overlaps(&bb)) } Query::GetGeometryRevision => QueryResult::Revision(b.revision()), + Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(id)?), }; results.push(r); } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 02bb6bbf0..581d1f495 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -267,6 +267,8 @@ handle!(Ellipse); handle!(Spline); handle!(Ray); handle!(XLine); +handle!(Text); +handle!(MText); impl Entity { pub fn view(&self) -> ApiResult { @@ -331,6 +333,27 @@ impl Polyline { } } +macro_rules! text_handle { + ($name:ident) => { + impl $name { + /// The text content of this annotation. + pub fn content(&self) -> ApiResult { + match self.session.one_query(Query::GetTextContent { id: self.id })? { + QueryResult::TextContent(s) => Ok(s), + _ => Err(ApiError::Transport("unexpected content result".into())), + } + } + /// Replace the text content (one undo step). + pub fn set_content(&self, value: &str) -> ApiResult<()> { + self.session.apply_op(Operation::SetTextContent { id: self.id, value: value.to_string() })?; + Ok(()) + } + } + }; +} +text_handle!(Text); +text_handle!(MText); + // ── Collections (construction / lookup) ───────────────────────────────────── #[derive(Clone)] @@ -442,6 +465,27 @@ impl CurveCollection { let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?; Ok(XLine::new(s, id)) } + /// Single-line TEXT annotation. + pub fn create_text(&self, value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> ApiResult { + let receipt = self.session.apply_op(Operation::CreateText(crate::ops::TextSpec { + value: value.to_string(), + insertion_point, + height, + rotation, + }))?; + let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_text returned no id".into()))?; + Ok(Text::new(self.session.clone(), id)) + } + /// Multi-line MTEXT annotation. + pub fn create_mtext(&self, value: &str, insertion_point: [f64; 3], height: f64) -> ApiResult { + let receipt = self.session.apply_op(Operation::CreateMText(crate::ops::MTextSpec { + value: value.to_string(), + insertion_point, + height, + }))?; + let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_mtext returned no id".into()))?; + Ok(MText::new(self.session.clone(), id)) + } /// Bulk-create many points in ONE op (plan §5.3): all-or-nothing, one undo step. pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { let specs: Vec = positions diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 71f95f743..1813d743d 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -87,6 +87,32 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_xline**(`origin: [f64; 3], direction: [f64; 3]`) -> `XLine` +## `Text` (acadrust `Text`, collection `curves`) + +Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. + +### Constructors (`doc.curves()`) + +- **create_text**(`value: &str, insertion_point: [f64; 3], height: f64, rotation: f64`) -> `Text` + +### Methods + +- **content**(``) -> `String` — query `GetTextContent` +- **set_content**(`value: &str`) -> `()` — op `SetTextContent` + +## `MText` (acadrust `MText`, collection `curves`) + +Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. + +### Constructors (`doc.curves()`) + +- **create_mtext**(`value: &str, insertion_point: [f64; 3], height: f64`) -> `MText` + +### Methods + +- **content**(``) -> `String` — query `GetTextContent` +- **set_content**(`value: &str`) -> `()` — op `SetTextContent` + ## `Entity` (acadrust `Insert`, collection `entities`) Block reference. Place an existing BlockRecord by name; transform moves its insert point. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 27cc042c9..0b8941def 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -188,6 +188,76 @@ "methods": [], "name": "XLine" }, + { + "acadrust_variant": "Text", + "collection": "curves", + "handle": "Text", + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "content", + "op": null, + "query": "GetTextContent", + "read_only": true, + "returns": "String" + }, + { + "args": [ + { + "name": "value", + "ty": "&str" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_content", + "op": "SetTextContent", + "query": null, + "read_only": false, + "returns": "()" + } + ], + "name": "Text" + }, + { + "acadrust_variant": "MText", + "collection": "curves", + "handle": "MText", + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "content", + "op": null, + "query": "GetTextContent", + "read_only": true, + "returns": "String" + }, + { + "args": [ + { + "name": "value", + "ty": "&str" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_content", + "op": "SetTextContent", + "query": null, + "read_only": false, + "returns": "()" + } + ], + "name": "MText" + }, { "acadrust_variant": "Insert", "collection": "entities", @@ -270,7 +340,9 @@ "Ellipse", "Spline", "Ray", - "XLine" + "XLine", + "Text", + "MText" ], "root": "DocApi" }, diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 685195f86..37e22ac7c 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::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, PlacementSpec, SolidPrimitive, ViewportSpec}; +use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, MTextSpec, PlacementSpec, SolidPrimitive, 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. @@ -32,4 +32,8 @@ pub enum Operation { CreateInsert(InsertSpec), // paper-space CreateViewport(ViewportSpec), + // annotations + CreateText(TextSpec), + CreateMText(MTextSpec), + SetTextContent { id: ObjectId, value: String }, } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 6341590b0..743cae602 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -19,4 +19,6 @@ pub enum Query { /// Whether two entities' bounds overlap (two-phase conditional modelling). GetIntersects { a: ObjectId, b: ObjectId }, GetGeometryRevision, + /// The text content of a Text/MText annotation. + GetTextContent { id: ObjectId }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 8d3566059..0738122f5 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -79,6 +79,23 @@ pub struct ViewportSpec { pub view_height: f64, } +/// Construction spec for a single-line `TEXT` annotation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TextSpec { + pub value: String, + pub insertion_point: [f64; 3], + pub height: f64, + pub rotation: f64, +} + +/// Construction spec for a multi-line `MTEXT` annotation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MTextSpec { + pub value: String, + pub insertion_point: [f64; 3], + pub height: 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. @@ -128,6 +145,9 @@ impl crate::gen::Operation { DeleteMany(_) => "DeleteMany", CreateInsert(_) => "CreateInsert", CreateViewport(_) => "CreateViewport", + CreateText(_) => "CreateText", + CreateMText(_) => "CreateMText", + SetTextContent { .. } => "SetTextContent", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index 5c40942d8..b971eb58c 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -43,6 +43,8 @@ pub enum QueryResult { /// Whether two entities' bounds overlap (two-phase conditional modelling). Intersects(bool), Revision(GeometryRevision), + /// The text content of a Text/MText annotation. + TextContent(String), } /// Convenience: the query name for diagnostics. @@ -56,6 +58,7 @@ impl crate::gen::Query { GetVolume { .. } => "GetVolume", GetIntersects { .. } => "GetIntersects", GetGeometryRevision => "GetGeometryRevision", + GetTextContent { .. } => "GetTextContent", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 3946add9f..72e907b6c 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -21,6 +21,8 @@ struct MockBackend { undo_steps: u32, bodies: HashMap, kinds: HashMap, + /// Stored text content for Text/MText annotations. + text_values: HashMap, } impl MockBackend { @@ -73,6 +75,27 @@ impl DocApiBackend for MockBackend { fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { Ok(self.alloc("Viewport")) } + 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()); + 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()); + Ok(id) + } + fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { + if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { + e.insert(value.to_string()); + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + fn text_content(&self, id: ObjectId) -> ApiResult { + self.text_values.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(()) diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index aa72ef557..e8915c915 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -53,6 +53,9 @@ fn operation_variants() -> Vec { Operation::DeleteMany(vec![]), Operation::CreateInsert(InsertSpec { block_name: String::new(), insert_point: [0.0; 3], scale: 1.0, rotation: 0.0 }), Operation::CreateViewport(ViewportSpec { center: [0.0; 3], width: 0.0, height: 0.0, view_target: [0.0; 3], view_height: 0.0 }), + Operation::CreateText(ocs_doc_api::ops::TextSpec { value: String::new(), insertion_point: [0.0; 3], height: 0.0, rotation: 0.0 }), + Operation::CreateMText(ocs_doc_api::ops::MTextSpec { value: String::new(), insertion_point: [0.0; 3], height: 0.0 }), + Operation::SetTextContent { id: ObjectId::from_u64(0), value: String::new() }, ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -77,6 +80,7 @@ fn query_variants() -> Vec { Query::GetVolume { id: ObjectId::from_u64(0) }, Query::GetIntersects { a: ObjectId::from_u64(0), b: ObjectId::from_u64(0) }, Query::GetGeometryRevision, + Query::GetTextContent { id: ObjectId::from_u64(0) }, ]; marker .iter() @@ -127,6 +131,9 @@ const OPERATION_BASELINE: &[&str] = &[ "DeleteMany", "CreateInsert", "CreateViewport", + "CreateText", + "CreateMText", + "SetTextContent", ]; /// Recorded baseline of the `Query` variant order. @@ -137,6 +144,7 @@ const QUERY_BASELINE: &[&str] = &[ "GetVolume", "GetIntersects", "GetGeometryRevision", + "GetTextContent", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index bccae0bd6..d0977d269 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -139,6 +139,64 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { + use acadrust::entities::Text; + use acadrust::types::Vector3; + let mut t = Text::new(); + t.value = spec.value.clone(); + t.insertion_point = Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]); + t.height = spec.height; + t.rotation = spec.rotation; + let handle = self.scene_mut().add_entity(EntityType::Text(t)); + Ok(handle_to_obj(handle)) + } + + fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { + use acadrust::entities::MText; + use acadrust::types::Vector3; + let mut t = MText::with_value( + spec.value.clone(), + Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]), + ); + t.height = spec.height; + let handle = self.scene_mut().add_entity(EntityType::MText(t)); + Ok(handle_to_obj(handle)) + } + + fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { + let handle = obj_to_handle(id); + let entity = self.document().get_entity(handle).cloned().ok_or(ApiError::UnknownId(id))?; + let new_entity = match entity { + EntityType::Text(mut t) => { t.value = value.to_string(); EntityType::Text(t) } + EntityType::MText(mut t) => { t.value = value.to_string(); EntityType::MText(t) } + _ => return Err(ApiError::Unsupported("SetTextContent is only for Text/MText".into())), + }; + if !self.scene_mut().update_entity(new_entity) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) + } + + fn text_content(&self, id: ObjectId) -> ApiResult { + let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; + match entity { + EntityType::Text(t) => Ok(t.value.clone()), + EntityType::MText(t) => Ok(t.value.clone()), + _ => Err(ApiError::Unsupported("GetTextContent is only for Text/MText".into())), + } + } + + fn can_modify(&self, id: ObjectId) -> ApiResult<()> { + let handle = obj_to_handle(id); + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + if self.scene().is_layer_locked(handle) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) + } + 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 { @@ -201,6 +259,8 @@ impl DocApiBackend for HostSession<'_> { | EntityType::XLine(_) | EntityType::Insert(_) | EntityType::Viewport(_) + | EntityType::Text(_) + | EntityType::MText(_) | EntityType::Point(_) | EntityType::LwPolyline(_) ); @@ -766,6 +826,44 @@ mod tests { assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany"); } + // ── Phase 2b-a: annotations (Text/MText) ───────────────────────────────── + + #[test] + fn phase2b_text_mtext_create_content_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Create a TEXT, read its content, set it, verify. + let text = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateText( + ocs_doc_api::ops::TextSpec { value: "hello".into(), insertion_point: [1.0, 2.0, 0.0], height: 2.5, rotation: 0.0 }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetTextContent { id: text }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::TextContent(s) => assert_eq!(s, "hello"), + other => panic!("expected content, got {other:?}"), + } + dispatch(&mut host, DocApiEnvelope::op(Operation::SetTextContent { id: text, value: "world".into() })).unwrap(); + let Some(EntityType::Text(t)) = host.document().get_entity(obj_to_handle(text)) else { panic!("text not found") }; + assert_eq!(t.value, "world"); + + // SetTextContent on a non-text entity is Unsupported. + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Line { start: [0.0; 3], end: [1.0; 3] }))).unwrap()); + assert!(dispatch(&mut host, DocApiEnvelope::op(Operation::SetTextContent { id: line, value: "x".into() })).is_err()); + + // MTEXT create + content + transform (insertion point moves). + let mtext = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateMText( + ocs_doc_api::ops::MTextSpec { value: "multi\nline".into(), insertion_point: [5.0, 5.0, 0.0], height: 3.0 }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetTextContent { id: mtext }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::TextContent(s) => assert_eq!(s, "multi\nline"), + other => panic!("expected content, got {other:?}"), + } + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: mtext, placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]) })).unwrap(); + let Some(EntityType::MText(t)) = host.document().get_entity(obj_to_handle(mtext)) else { panic!("mtext not found") }; + assert!((t.insertion_point.x - 15.0).abs() < 1e-6); + } + // ── Phase 5: media & misc (read-mostly) ────────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index ac5f6a084..856050119 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -155,6 +155,16 @@ pub fn transform_entity_geometry( vp.center = apply(vp.center); EntityType::Viewport(vp) } + EntityType::Text(t) => { + let mut t = t.clone(); + t.insertion_point = apply(t.insertion_point); + EntityType::Text(t) + } + EntityType::MText(t) => { + let mut t = t.clone(); + t.insertion_point = apply(t.insertion_point); + EntityType::MText(t) + } EntityType::Point(pt) => { let mut pt = pt.clone(); pt.location = apply(pt.location); @@ -393,6 +403,15 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult Aabb { + // Coarse: a square of side height*N around the insertion point. + min: [t.insertion_point.x, t.insertion_point.y - t.height, t.insertion_point.z], + max: [t.insertion_point.x + t.height * t.value.len() as f64, t.insertion_point.y, t.insertion_point.z], + }, + EntityType::MText(t) => Aabb { + min: [t.insertion_point.x, t.insertion_point.y - t.height, t.insertion_point.z], + max: [t.insertion_point.x + t.height * t.value.len() as f64, t.insertion_point.y, t.insertion_point.z], + }, // Ray/XLine are unbounded by definition — no finite bounds. _ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())), };