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;
|
(Arc coarse full-circle, Ellipse major-axis radius, Spline control-point bbox;
|
||||||
Ray/XLine unbounded → `Unsupported`), and transform (all five + the phase-1 set)
|
Ray/XLine unbounded → `Unsupported`), and transform (all five + the phase-1 set)
|
||||||
via acadrust. Covered by `phase2_*` tests.
|
via acadrust. Covered by `phase2_*` tests.
|
||||||
- **Remaining (2b):** annotations `Text`/`MText` (create + content get/set), `Hatch`
|
- **Done (2b-a):** annotations `Text`/`MText` — `create_text`/`create_mtext`
|
||||||
(create + boundary query), `Dimension` (typed sub-types).
|
(`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)
|
- **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline)
|
||||||
are supported (Phase 0).
|
are supported (Phase 0).
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub struct MockBackend {
|
|||||||
kinds: HashMap<ObjectId, String>,
|
kinds: HashMap<ObjectId, String>,
|
||||||
/// Stored 2D curve specs so `bounds()` works on non-solid entities too.
|
/// Stored 2D curve specs so `bounds()` works on non-solid entities too.
|
||||||
curves: HashMap<ObjectId, Curve2Spec>,
|
curves: HashMap<ObjectId, Curve2Spec>,
|
||||||
|
/// Stored text content for Text/MText annotations.
|
||||||
|
text_values: HashMap<ObjectId, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockBackend {
|
impl MockBackend {
|
||||||
@@ -133,6 +135,27 @@ impl DocApiBackend for MockBackend {
|
|||||||
fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
|
fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
|
||||||
Ok(self.alloc("Viewport"))
|
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<()> {
|
fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> {
|
||||||
if self.entity_exists(id) {
|
if self.entity_exists(id) {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -237,6 +237,59 @@ args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64;
|
|||||||
op = "CreateCurve"
|
op = "CreateCurve"
|
||||||
fixed = { curve = "XLine" }
|
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
|
# CONTAINER FAMILY (phase 3) — block references
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -42,6 +42,29 @@ pub trait DocApiBackend {
|
|||||||
/// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`.
|
/// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`.
|
||||||
fn add_viewport(&mut self, spec: &crate::ops::ViewportSpec) -> ApiResult<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
|
/// 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).
|
/// `Err` if the entity exists but cannot be removed (e.g. locked layer).
|
||||||
fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool>;
|
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();
|
b.finalize_op();
|
||||||
OpOutcome::NewId(id)
|
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 {
|
Ok(Receipt {
|
||||||
outcome: Some(outcome),
|
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))
|
QueryResult::Intersects(ba.overlaps(&bb))
|
||||||
}
|
}
|
||||||
Query::GetGeometryRevision => QueryResult::Revision(b.revision()),
|
Query::GetGeometryRevision => QueryResult::Revision(b.revision()),
|
||||||
|
Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(id)?),
|
||||||
};
|
};
|
||||||
results.push(r);
|
results.push(r);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ handle!(Ellipse);
|
|||||||
handle!(Spline);
|
handle!(Spline);
|
||||||
handle!(Ray);
|
handle!(Ray);
|
||||||
handle!(XLine);
|
handle!(XLine);
|
||||||
|
handle!(Text);
|
||||||
|
handle!(MText);
|
||||||
|
|
||||||
impl Entity {
|
impl Entity {
|
||||||
pub fn view(&self) -> ApiResult<EntityView> {
|
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) ─────────────────────────────────────
|
// ── Collections (construction / lookup) ─────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -442,6 +465,27 @@ impl CurveCollection {
|
|||||||
let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?;
|
let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?;
|
||||||
Ok(XLine::new(s, id))
|
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.
|
/// 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>> {
|
pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult<Vec<Point>> {
|
||||||
let specs: Vec<crate::ops::EntitySpec> = positions
|
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`
|
- **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`)
|
## `Entity` (acadrust `Insert`, collection `entities`)
|
||||||
|
|
||||||
Block reference. Place an existing BlockRecord by name; transform moves its insert point.
|
Block reference. Place an existing BlockRecord by name; transform moves its insert point.
|
||||||
|
|||||||
@@ -188,6 +188,76 @@
|
|||||||
"methods": [],
|
"methods": [],
|
||||||
"name": "XLine"
|
"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",
|
"acadrust_variant": "Insert",
|
||||||
"collection": "entities",
|
"collection": "entities",
|
||||||
@@ -270,7 +340,9 @@
|
|||||||
"Ellipse",
|
"Ellipse",
|
||||||
"Spline",
|
"Spline",
|
||||||
"Ray",
|
"Ray",
|
||||||
"XLine"
|
"XLine",
|
||||||
|
"Text",
|
||||||
|
"MText"
|
||||||
],
|
],
|
||||||
"root": "DocApi"
|
"root": "DocApi"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::id::ObjectId;
|
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
|
/// A typed write operation (plan §5). Each variant is ONE atomic host call = one
|
||||||
/// undo step. Append new variants at the END only.
|
/// undo step. Append new variants at the END only.
|
||||||
@@ -32,4 +32,8 @@ pub enum Operation {
|
|||||||
CreateInsert(InsertSpec),
|
CreateInsert(InsertSpec),
|
||||||
// paper-space
|
// paper-space
|
||||||
CreateViewport(ViewportSpec),
|
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).
|
/// Whether two entities' bounds overlap (two-phase conditional modelling).
|
||||||
GetIntersects { a: ObjectId, b: ObjectId },
|
GetIntersects { a: ObjectId, b: ObjectId },
|
||||||
GetGeometryRevision,
|
GetGeometryRevision,
|
||||||
|
/// The text content of a Text/MText annotation.
|
||||||
|
GetTextContent { id: ObjectId },
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,23 @@ pub struct ViewportSpec {
|
|||||||
pub view_height: f64,
|
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)
|
/// A rigid similarity (rotation + translation + optional uniform scale/reflection)
|
||||||
/// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement`
|
/// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement`
|
||||||
/// (non-serde). Columns are the new basis axes; `origin` is the translation.
|
/// (non-serde). Columns are the new basis axes; `origin` is the translation.
|
||||||
@@ -128,6 +145,9 @@ impl crate::gen::Operation {
|
|||||||
DeleteMany(_) => "DeleteMany",
|
DeleteMany(_) => "DeleteMany",
|
||||||
CreateInsert(_) => "CreateInsert",
|
CreateInsert(_) => "CreateInsert",
|
||||||
CreateViewport(_) => "CreateViewport",
|
CreateViewport(_) => "CreateViewport",
|
||||||
|
CreateText(_) => "CreateText",
|
||||||
|
CreateMText(_) => "CreateMText",
|
||||||
|
SetTextContent { .. } => "SetTextContent",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,8 @@ pub enum QueryResult {
|
|||||||
/// Whether two entities' bounds overlap (two-phase conditional modelling).
|
/// Whether two entities' bounds overlap (two-phase conditional modelling).
|
||||||
Intersects(bool),
|
Intersects(bool),
|
||||||
Revision(GeometryRevision),
|
Revision(GeometryRevision),
|
||||||
|
/// The text content of a Text/MText annotation.
|
||||||
|
TextContent(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience: the query name for diagnostics.
|
/// Convenience: the query name for diagnostics.
|
||||||
@@ -56,6 +58,7 @@ impl crate::gen::Query {
|
|||||||
GetVolume { .. } => "GetVolume",
|
GetVolume { .. } => "GetVolume",
|
||||||
GetIntersects { .. } => "GetIntersects",
|
GetIntersects { .. } => "GetIntersects",
|
||||||
GetGeometryRevision => "GetGeometryRevision",
|
GetGeometryRevision => "GetGeometryRevision",
|
||||||
|
GetTextContent { .. } => "GetTextContent",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,8 @@ struct MockBackend {
|
|||||||
undo_steps: u32,
|
undo_steps: u32,
|
||||||
bodies: HashMap<ObjectId, KernelBody>,
|
bodies: HashMap<ObjectId, KernelBody>,
|
||||||
kinds: HashMap<ObjectId, String>,
|
kinds: HashMap<ObjectId, String>,
|
||||||
|
/// Stored text content for Text/MText annotations.
|
||||||
|
text_values: HashMap<ObjectId, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockBackend {
|
impl MockBackend {
|
||||||
@@ -73,6 +75,27 @@ impl DocApiBackend for MockBackend {
|
|||||||
fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
|
fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
|
||||||
Ok(self.alloc("Viewport"))
|
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<()> {
|
fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> {
|
||||||
if self.entity_exists(id) {
|
if self.entity_exists(id) {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ fn operation_variants() -> Vec<String> {
|
|||||||
Operation::DeleteMany(vec![]),
|
Operation::DeleteMany(vec![]),
|
||||||
Operation::CreateInsert(InsertSpec { block_name: String::new(), insert_point: [0.0; 3], scale: 1.0, rotation: 0.0 }),
|
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::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
|
// Derive the variant names by serializing a probe of each and reading the
|
||||||
// bincode variant index -> name map is not available; instead match by
|
// 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::GetVolume { id: ObjectId::from_u64(0) },
|
||||||
Query::GetIntersects { a: ObjectId::from_u64(0), b: ObjectId::from_u64(0) },
|
Query::GetIntersects { a: ObjectId::from_u64(0), b: ObjectId::from_u64(0) },
|
||||||
Query::GetGeometryRevision,
|
Query::GetGeometryRevision,
|
||||||
|
Query::GetTextContent { id: ObjectId::from_u64(0) },
|
||||||
];
|
];
|
||||||
marker
|
marker
|
||||||
.iter()
|
.iter()
|
||||||
@@ -127,6 +131,9 @@ const OPERATION_BASELINE: &[&str] = &[
|
|||||||
"DeleteMany",
|
"DeleteMany",
|
||||||
"CreateInsert",
|
"CreateInsert",
|
||||||
"CreateViewport",
|
"CreateViewport",
|
||||||
|
"CreateText",
|
||||||
|
"CreateMText",
|
||||||
|
"SetTextContent",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Recorded baseline of the `Query` variant order.
|
/// Recorded baseline of the `Query` variant order.
|
||||||
@@ -137,6 +144,7 @@ const QUERY_BASELINE: &[&str] = &[
|
|||||||
"GetVolume",
|
"GetVolume",
|
||||||
"GetIntersects",
|
"GetIntersects",
|
||||||
"GetGeometryRevision",
|
"GetGeometryRevision",
|
||||||
|
"GetTextContent",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -139,6 +139,64 @@ impl DocApiBackend for HostSession<'_> {
|
|||||||
Ok(handle_to_obj(handle))
|
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<()> {
|
fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> {
|
||||||
let handle = obj_to_handle(id);
|
let handle = obj_to_handle(id);
|
||||||
let Some(entity) = self.document().get_entity(handle).cloned() else {
|
let Some(entity) = self.document().get_entity(handle).cloned() else {
|
||||||
@@ -201,6 +259,8 @@ impl DocApiBackend for HostSession<'_> {
|
|||||||
| EntityType::XLine(_)
|
| EntityType::XLine(_)
|
||||||
| EntityType::Insert(_)
|
| EntityType::Insert(_)
|
||||||
| EntityType::Viewport(_)
|
| EntityType::Viewport(_)
|
||||||
|
| EntityType::Text(_)
|
||||||
|
| EntityType::MText(_)
|
||||||
| EntityType::Point(_)
|
| EntityType::Point(_)
|
||||||
| EntityType::LwPolyline(_)
|
| EntityType::LwPolyline(_)
|
||||||
);
|
);
|
||||||
@@ -766,6 +826,44 @@ mod tests {
|
|||||||
assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany");
|
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) ──────────────────────────────────
|
// ── Phase 5: media & misc (read-mostly) ──────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -155,6 +155,16 @@ pub fn transform_entity_geometry(
|
|||||||
vp.center = apply(vp.center);
|
vp.center = apply(vp.center);
|
||||||
EntityType::Viewport(vp)
|
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) => {
|
EntityType::Point(pt) => {
|
||||||
let mut pt = pt.clone();
|
let mut pt = pt.clone();
|
||||||
pt.location = apply(pt.location);
|
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],
|
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.
|
// Ray/XLine are unbounded by definition — no finite bounds.
|
||||||
_ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())),
|
_ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user