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.
This commit is contained in:
Sebastian committed 2026-09-06 09:07:41 +02:00
1 parent 1925a83a25
commit fc0e2c2c9d
16 files changed
+449 -4

No files matched your search

+6 -2
View File
@@ -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).
+23
View File
@@ -21,6 +21,8 @@ pub struct MockBackend {
kinds: HashMap<ObjectId, String>,
/// Stored 2D curve specs so `bounds()` works on non-solid entities too.
curves: HashMap<ObjectId, Curve2Spec>,
/// Stored text content for Text/MText annotations.
text_values: HashMap<ObjectId, String>,
}
impl MockBackend {
@@ -133,6 +135,27 @@ impl DocApiBackend for MockBackend {
fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
Ok(self.alloc("Viewport"))
}
fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult<ObjectId> {
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<ObjectId> {
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<String> {
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(())
+53
View File
@@ -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
# ============================================================================
+23
View File
@@ -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<ObjectId>;
/// Add a single-line `TEXT` annotation; returns the fresh `ObjectId`.
fn add_text(&mut self, spec: &crate::ops::TextSpec) -> ApiResult<ObjectId>;
/// Add a multi-line `MTEXT` annotation; returns the fresh `ObjectId`.
fn add_mtext(&mut self, spec: &crate::ops::MTextSpec) -> ApiResult<ObjectId>;
/// 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<String>;
/// 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<bool>;
+23
View File
@@ -189,6 +189,28 @@ pub fn apply_op<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt
b.finalize_op();
OpOutcome::NewId(id)
}
Operation::CreateText(spec) => {
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: DocApiBackend>(b: &mut B, queries: Vec<Query>) -> ApiRes
QueryResult::Intersects(ba.overlaps(&bb))
}
Query::GetGeometryRevision => QueryResult::Revision(b.revision()),
Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(id)?),
};
results.push(r);
}
+44
View File
@@ -267,6 +267,8 @@ handle!(Ellipse);
handle!(Spline);
handle!(Ray);
handle!(XLine);
handle!(Text);
handle!(MText);
impl Entity {
pub fn view(&self) -> ApiResult<EntityView> {
@@ -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<String> {
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<Text> {
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<MText> {
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<Vec<Point>> {
let specs: Vec<crate::ops::EntitySpec> = positions
@@ -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.
+73 -1
View File
@@ -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"
},
+5 -1
View File
@@ -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 },
}
+2
View File
@@ -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 },
}
+20
View File
@@ -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",
}
}
}
+3
View File
@@ -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",
}
}
}
+23
View File
@@ -21,6 +21,8 @@ struct MockBackend {
undo_steps: u32,
bodies: HashMap<ObjectId, KernelBody>,
kinds: HashMap<ObjectId, String>,
/// Stored text content for Text/MText annotations.
text_values: HashMap<ObjectId, String>,
}
impl MockBackend {
@@ -73,6 +75,27 @@ impl DocApiBackend for MockBackend {
fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
Ok(self.alloc("Viewport"))
}
fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult<ObjectId> {
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<ObjectId> {
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<String> {
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(())
@@ -53,6 +53,9 @@ fn operation_variants() -> Vec<String> {
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<String> {
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]
+98
View File
@@ -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<ObjectId> {
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<ObjectId> {
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<String> {
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]
+19
View File
@@ -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<Aab
max: [max_x, max_y, img.insertion_point.z],
}
}
EntityType::Text(t) => 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())),
};