From ba8e5ed0cd6c11728eb3e8fb5814606db9d3148b Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:24:11 +0200 Subject: [PATCH] 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. --- crates/ocs_doc_api/IMPLEMENTATION.md | 6 +- crates/ocs_doc_api/examples/common/mod.rs | 10 +++ crates/ocs_doc_api/spec/entities.toml | 21 +++++ crates/ocs_doc_api/src/backend.rs | 6 ++ crates/ocs_doc_api/src/executor.rs | 11 +++ crates/ocs_doc_api/src/facade.rs | 10 +++ crates/ocs_doc_api/src/gen/api_reference.md | 12 +++ .../ocs_doc_api/src/gen/binding_schema.json | 19 +++++ crates/ocs_doc_api/src/gen/ops_gen.rs | 4 +- crates/ocs_doc_api/src/gen/query_gen.rs | 2 + crates/ocs_doc_api/src/ops.rs | 11 +++ crates/ocs_doc_api/src/query.rs | 3 + crates/ocs_doc_api/tests/mock_backend.rs | 10 +++ .../tests/spec_wire_consistency.rs | 4 + src/app/doc_api.rs | 77 +++++++++++++++++++ src/app/doc_api_convert.rs | 20 +++++ 16 files changed, 223 insertions(+), 3 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 73aab0b02..3670bb15c 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -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). diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index e0ba05758..114589d77 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -23,6 +23,8 @@ pub struct MockBackend { curves: HashMap, /// Stored text content for Text/MText annotations. text_values: HashMap, + /// Stored hatch boundary loops. + hatch_boundaries: HashMap>>, } impl MockBackend { @@ -156,6 +158,14 @@ impl DocApiBackend for MockBackend { fn text_content(&self, id: ObjectId) -> ApiResult { self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id)) } + fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { + let id = self.alloc("Hatch"); + self.hatch_boundaries.insert(id, vec![spec.boundary.clone()]); + Ok(id) + } + fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { + 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(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 0740139c1..b978c72d0 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -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>" + # ============================================================================ # CONTAINER FAMILY (phase 3) — block references # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index d1a166854..f8c05aaac 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -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; + /// Add a `HATCH` (single closed polyline boundary); returns the fresh `ObjectId`. + fn add_hatch(&mut self, spec: &crate::ops::HatchSpec) -> ApiResult; + + /// A hatch's boundary loops (outer + islands) as closed polylines. + fn hatch_boundary(&self, id: ObjectId) -> ApiResult>>; + /// 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). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 75f2cbbae..ffc0ca882 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -211,6 +211,16 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + // 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: &mut B, queries: Vec) -> 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); } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 581d1f495..7bece8d08 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -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 { + 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 { let receipt = self.session.apply_op(Operation::CreateText(crate::ops::TextSpec { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 1813d743d..4ce34e0e6 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -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>` — query `GetHatchBoundary` + ## `Entity` (acadrust `Insert`, collection `entities`) Block reference. Place an existing BlockRecord by name; transform moves its insert point. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 0b8941def..36ec3db4f 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -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>" + } + ], + "name": "Hatch" + }, { "acadrust_variant": "Insert", "collection": "entities", diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 37e22ac7c..e6ebc2284 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -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), } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 743cae602..7c7f093fa 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -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 }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 0738122f5..2953f6f85 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -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", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index b971eb58c..5793f33c3 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -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>), } /// Convenience: the query name for diagnostics. @@ -59,6 +61,7 @@ impl crate::gen::Query { GetIntersects { .. } => "GetIntersects", GetGeometryRevision => "GetGeometryRevision", GetTextContent { .. } => "GetTextContent", + GetHatchBoundary { .. } => "GetHatchBoundary", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 72e907b6c..c98bc7acf 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -23,6 +23,8 @@ struct MockBackend { kinds: HashMap, /// Stored text content for Text/MText annotations. text_values: HashMap, + /// Stored hatch boundary loops. + hatch_boundaries: HashMap>>, } impl MockBackend { @@ -96,6 +98,14 @@ impl DocApiBackend for MockBackend { fn text_content(&self, id: ObjectId) -> ApiResult { self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id)) } + fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { + let id = self.alloc("Hatch"); + self.hatch_boundaries.insert(id, vec![spec.boundary.clone()]); + Ok(id) + } + fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { + 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(()) diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index e8915c915..c04814347 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -56,6 +56,7 @@ fn operation_variants() -> Vec { 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 { 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] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index d0977d269..a66be9384 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -197,6 +197,47 @@ impl DocApiBackend for HostSession<'_> { Ok(()) } + fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { + use acadrust::entities::{BoundaryEdge, BoundaryPath, Hatch, LineEdge}; + use acadrust::types::Vector2; + let mut hatch = if spec.solid { Hatch::solid() } else { Hatch::default() }; + // Build a BoundaryPath of Line edges around the closed polyline. + let n = spec.boundary.len(); + let mut path = BoundaryPath::new(); + for i in 0..n { + let start = spec.boundary[i]; + let end = spec.boundary[(i + 1) % n]; + path.edges.push(BoundaryEdge::Line(LineEdge { + start: Vector2::new(start[0], start[1]), + end: Vector2::new(end[0], end[1]), + })); + } + hatch.paths.push(path); + let handle = self.scene_mut().add_entity(EntityType::Hatch(hatch)); + Ok(handle_to_obj(handle)) + } + + fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { + let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; + let EntityType::Hatch(h) = entity else { + return Err(ApiError::Unsupported("GetHatchBoundary is only for Hatch".into())); + }; + // Reconstruct each boundary loop's vertices from its Line edges. + let mut loops = Vec::with_capacity(h.paths.len()); + for path in &h.paths { + let mut pts = Vec::with_capacity(path.edges.len()); + for edge in &path.edges { + match edge { + acadrust::entities::BoundaryEdge::Line(le) => pts.push([le.start.x, le.start.y]), + // Arc/ellipse boundary edges are a later refinement (no vertex list). + _ => return Err(ApiError::Unsupported("hatch boundary contains non-line edges".into())), + } + } + loops.push(pts); + } + Ok(loops) + } + 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 { @@ -864,6 +905,42 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Phase 2b-b: hatch ──────────────────────────────────────────────────── + + #[test] + fn phase2b_hatch_create_boundary_bounds_delete() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Solid hatch over a unit square boundary. + let sq = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + let hatch = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateHatch( + ocs_doc_api::ops::HatchSpec { boundary: sq.clone(), solid: true }))).unwrap()); + + // Boundary round-trips (one loop, 4 vertices). + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetHatchBoundary { id: hatch }, Query::GetBounds { id: hatch }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::HatchBoundary(loops) => { + assert_eq!(loops.len(), 1); + assert_eq!(loops[0].len(), 4); + assert!((loops[0][0][0] - 0.0).abs() < 1e-9 && (loops[0][2][0] - 10.0).abs() < 1e-9); + } + other => panic!("expected boundary, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => assert!((b.min[0] - 0.0).abs() < 1e-6 && (b.max[1] - 10.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + + // Boundary with < 3 points is a validation error. + assert!(dispatch(&mut host, DocApiEnvelope::op(Operation::CreateHatch( + ocs_doc_api::ops::HatchSpec { boundary: vec![[0.0, 0.0], [1.0, 1.0]], solid: true }))).is_err()); + + // Generic delete works. + dispatch(&mut host, DocApiEnvelope::op(Operation::Delete { id: hatch })).unwrap(); + assert!(host.document().get_entity(obj_to_handle(hatch)).is_none()); + } + // ── Phase 5: media & misc (read-mostly) ────────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 856050119..b39bfa9f3 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -412,6 +412,26 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + let mut any = false; + for path in &h.paths { + for edge in &path.edges { + if let acadrust::entities::BoundaryEdge::Line(le) = edge { + for (x, y) in [(le.start.x, le.start.y), (le.end.x, le.end.y)] { + min = [min[0].min(x), min[1].min(y), min[2].min(0.0)]; + max = [max[0].max(x), max[1].max(y), max[2].max(0.0)]; + any = true; + } + } + } + } + if !any { + return Err(ApiError::Unsupported("hatch has no line-edge boundary".into())); + } + Aabb { min, max } + } // Ray/XLine are unbounded by definition — no finite bounds. _ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())), };