forked from 1b-agent/opencadstudio-upstream
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:
1 parent
1925a83a25
commit
fc0e2c2c9d
16 files changed
+449
-4
No files matched your search
@@ -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).
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user