ocs_doc_api: Phase 2b-b (Hatch)

- CreateHatch(HatchSpec) op + GetHatchBoundary query (append-only).
- HatchSpec{boundary: Vec<[f64;2]>, solid}; executor validates >= 3 boundary points.
- backend add_hatch (Line-edge BoundaryPath, solid/pattern) + hatch_boundary
  (reconstruct loops from Line edges; non-line edges -> Unsupported).
- Hatch bounds from boundary edges; facade create_hatch + Entity handle.
- spec Hatch family; api_reference + binding_schema regenerated; mocks + baseline.
- Test phase2b_hatch_create_boundary_bounds_delete -> 606 host lib + 8 crate + 3 consistency green.
This commit is contained in:
Sebastian committed 2026-09-06 09:24:11 +02:00
1 parent fc0e2c2c9d
commit ba8e5ed0cd
16 files changed
+223 -3

No files matched your search

+4 -2
View File
@@ -65,8 +65,10 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal
(`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).
- **Done (2b-b):** `Hatch` — `create_hatch(boundary, solid)` (`CreateHatch`,
validates ≥3 points), `boundary()` → loops (`GetHatchBoundary`), bounds from
boundary edges, delete. Covered by `phase2b_hatch_*` test.
- **Remaining (2b-c):** `Dimension` (typed sub-types).
- **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline)
are supported (Phase 0).
+10
View File
@@ -23,6 +23,8 @@ pub struct MockBackend {
curves: HashMap<ObjectId, Curve2Spec>,
/// Stored text content for Text/MText annotations.
text_values: HashMap<ObjectId, String>,
/// Stored hatch boundary loops.
hatch_boundaries: HashMap<ObjectId, Vec<Vec<[f64; 2]>>>,
}
impl MockBackend {
@@ -156,6 +158,14 @@ impl DocApiBackend for MockBackend {
fn text_content(&self, id: ObjectId) -> ApiResult<String> {
self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id))
}
fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Hatch");
self.hatch_boundaries.insert(id, vec![spec.boundary.clone()]);
Ok(id)
}
fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>> {
self.hatch_boundaries.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(())
+21
View File
@@ -290,6 +290,27 @@ op = "SetTextContent"
args = [{ name = "value", ty = "&str" }]
returns = "()"
# ============================================================================
# HATCH FAMILY (phase 2b) — filled regions
# ============================================================================
[[family]]
name = "Hatch"
acadrust_variant = "Hatch"
handle = "Entity"
collection = "curves"
description = "Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete."
[[family.constructor]]
name = "create_hatch"
args = [{ name = "boundary", ty = "&[[f64; 2]]" }, { name = "solid", ty = "bool" }]
op = "CreateHatch"
[[family.method]]
name = "boundary"
kind = "query"
query = "GetHatchBoundary"
returns = "Vec<Vec<[f64; 2]>>"
# ============================================================================
# CONTAINER FAMILY (phase 3) — block references
# ============================================================================
+6
View File
@@ -54,6 +54,12 @@ pub trait DocApiBackend {
/// The text content of a Text/MText annotation. `Unsupported` for other kinds.
fn text_content(&self, id: ObjectId) -> ApiResult<String>;
/// Add a `HATCH` (single closed polyline boundary); returns the fresh `ObjectId`.
fn add_hatch(&mut self, spec: &crate::ops::HatchSpec) -> ApiResult<ObjectId>;
/// A hatch's boundary loops (outer + islands) as closed polylines.
fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>>;
/// 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).
+11
View File
@@ -211,6 +211,16 @@ pub fn apply_op<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt
b.finalize_op();
OpOutcome::Updated(*id)
}
Operation::CreateHatch(spec) => {
// Validate the boundary (>= 3 points) before committing.
if spec.boundary.len() < 3 {
return Err(ApiError::validation(name, "hatch boundary needs >= 3 points"));
}
b.push_undo(name);
let id = b.add_hatch(spec)?;
b.finalize_op();
OpOutcome::NewId(id)
}
};
Ok(Receipt {
outcome: Some(outcome),
@@ -249,6 +259,7 @@ pub fn apply_queries<B: DocApiBackend>(b: &mut B, queries: Vec<Query>) -> ApiRes
}
Query::GetGeometryRevision => QueryResult::Revision(b.revision()),
Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(id)?),
Query::GetHatchBoundary { id } => QueryResult::HatchBoundary(b.hatch_boundary(id)?),
};
results.push(r);
}
+10
View File
@@ -465,6 +465,16 @@ impl CurveCollection {
let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?;
Ok(XLine::new(s, id))
}
/// A solid (or pattern) HATCH over a single closed polyline boundary.
pub fn create_hatch(&self, boundary: &[[f64; 2]], solid: bool) -> ApiResult<Entity> {
let receipt = self.session.apply_op(Operation::CreateHatch(crate::ops::HatchSpec {
boundary: boundary.to_vec(),
solid,
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_hatch returned no id".into()))?;
Ok(Entity::new(self.session.clone(), 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 {
@@ -113,6 +113,18 @@ Multi-line text annotation. content()/set_content() read+replace the value; tran
- **content**(``) -> `String` — query `GetTextContent`
- **set_content**(`value: &str`) -> `()` — op `SetTextContent`
## `Entity` (acadrust `Hatch`, collection `curves`)
Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete.
### Constructors (`doc.curves()`)
- **create_hatch**(`boundary: &[[f64; 2]], solid: bool`) -> `Entity`
### Methods
- **boundary**(``) -> `Vec<Vec<[f64; 2]>>` — query `GetHatchBoundary`
## `Entity` (acadrust `Insert`, collection `entities`)
Block reference. Place an existing BlockRecord by name; transform moves its insert point.
@@ -258,6 +258,25 @@
],
"name": "MText"
},
{
"acadrust_variant": "Hatch",
"collection": "curves",
"handle": "Entity",
"methods": [
{
"args": [],
"bulk": false,
"fixed": null,
"kind": "query",
"name": "boundary",
"op": null,
"query": "GetHatchBoundary",
"read_only": true,
"returns": "Vec<Vec<[f64; 2]>>"
}
],
"name": "Hatch"
},
{
"acadrust_variant": "Insert",
"collection": "entities",
+3 -1
View File
@@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize};
use crate::id::ObjectId;
use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, MTextSpec, PlacementSpec, SolidPrimitive, TextSpec, ViewportSpec};
use crate::ops::{BoolOp, Curve2Spec, EntitySpec, HatchSpec, 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.
@@ -36,4 +36,6 @@ pub enum Operation {
CreateText(TextSpec),
CreateMText(MTextSpec),
SetTextContent { id: ObjectId, value: String },
// hatch
CreateHatch(HatchSpec),
}
+2
View File
@@ -21,4 +21,6 @@ pub enum Query {
GetGeometryRevision,
/// The text content of a Text/MText annotation.
GetTextContent { id: ObjectId },
/// The boundary loops of a Hatch (outer + islands) as polylines.
GetHatchBoundary { id: ObjectId },
}
+11
View File
@@ -96,6 +96,16 @@ pub struct MTextSpec {
pub height: f64,
}
/// Construction spec for a `HATCH` with a single closed polyline boundary
/// (straight segments). `solid` selects solid fill vs a pattern (pattern naming
/// is a later refinement).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HatchSpec {
/// Closed boundary as an ordered polyline (first==last implied; closed).
pub boundary: Vec<[f64; 2]>,
pub solid: bool,
}
/// 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.
@@ -148,6 +158,7 @@ impl crate::gen::Operation {
CreateText(_) => "CreateText",
CreateMText(_) => "CreateMText",
SetTextContent { .. } => "SetTextContent",
CreateHatch(_) => "CreateHatch",
}
}
}
+3
View File
@@ -45,6 +45,8 @@ pub enum QueryResult {
Revision(GeometryRevision),
/// The text content of a Text/MText annotation.
TextContent(String),
/// A hatch's boundary loops (outer + islands), each a closed polyline.
HatchBoundary(Vec<Vec<[f64; 2]>>),
}
/// Convenience: the query name for diagnostics.
@@ -59,6 +61,7 @@ impl crate::gen::Query {
GetIntersects { .. } => "GetIntersects",
GetGeometryRevision => "GetGeometryRevision",
GetTextContent { .. } => "GetTextContent",
GetHatchBoundary { .. } => "GetHatchBoundary",
}
}
}
+10
View File
@@ -23,6 +23,8 @@ struct MockBackend {
kinds: HashMap<ObjectId, String>,
/// Stored text content for Text/MText annotations.
text_values: HashMap<ObjectId, String>,
/// Stored hatch boundary loops.
hatch_boundaries: HashMap<ObjectId, Vec<Vec<[f64; 2]>>>,
}
impl MockBackend {
@@ -96,6 +98,14 @@ impl DocApiBackend for MockBackend {
fn text_content(&self, id: ObjectId) -> ApiResult<String> {
self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id))
}
fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Hatch");
self.hatch_boundaries.insert(id, vec![spec.boundary.clone()]);
Ok(id)
}
fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>> {
self.hatch_boundaries.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(())
@@ -56,6 +56,7 @@ fn operation_variants() -> Vec<String> {
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() },
Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { boundary: vec![], solid: true }),
];
// Derive the variant names by serializing a probe of each and reading the
// bincode variant index -> name map is not available; instead match by
@@ -81,6 +82,7 @@ fn query_variants() -> Vec<String> {
Query::GetIntersects { a: ObjectId::from_u64(0), b: ObjectId::from_u64(0) },
Query::GetGeometryRevision,
Query::GetTextContent { id: ObjectId::from_u64(0) },
Query::GetHatchBoundary { id: ObjectId::from_u64(0) },
];
marker
.iter()
@@ -134,6 +136,7 @@ const OPERATION_BASELINE: &[&str] = &[
"CreateText",
"CreateMText",
"SetTextContent",
"CreateHatch",
];
/// Recorded baseline of the `Query` variant order.
@@ -145,6 +148,7 @@ const QUERY_BASELINE: &[&str] = &[
"GetIntersects",
"GetGeometryRevision",
"GetTextContent",
"GetHatchBoundary",
];
#[test]