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

+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())),
};