From e216a7bf947aa24644d9e1bc843026a33b5687f5 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:44:37 +0200 Subject: [PATCH] =?UTF-8?q?ocs=5Fdoc=5Fapi:=20outstanding=20methods=20?= =?UTF-8?q?=E2=80=94=20loft=20+=20bulge-arc=20polyline=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Loft{profiles} op (append-only); executor resolves each profile to curve sets all-or-nothing; backend loft() -> brep::loft on XY plane; facade solids().loft. - entity_to_profile_curves: convert LwVertex.bulge to geom2d::Curve::Arc via bulge_arc_segment (theta = 4*atan(bulge)), so bulged polyline profiles sweep correctly instead of erroring Unsupported. - spec loft; api_reference + binding_schema regenerated; mocks + baseline. - Tests: loft_two_profiles_produces_solid, bulge_arc_polyline_profile_converts_to_arc (semicircle radius=5 for bulge=1) -> 612 host lib + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 8 ++- crates/ocs_doc_api/examples/common/mod.rs | 8 +++ crates/ocs_doc_api/spec/entities.toml | 5 ++ crates/ocs_doc_api/src/backend.rs | 4 ++ crates/ocs_doc_api/src/executor.rs | 16 +++++ crates/ocs_doc_api/src/facade.rs | 7 ++ crates/ocs_doc_api/src/gen/api_reference.md | 1 + crates/ocs_doc_api/src/gen/ops_gen.rs | 2 + crates/ocs_doc_api/src/ops.rs | 1 + crates/ocs_doc_api/tests/mock_backend.rs | 9 +++ .../tests/spec_wire_consistency.rs | 2 + src/app/doc_api.rs | 67 +++++++++++++++++++ src/app/doc_api_convert.rs | 67 +++++++++++++------ 13 files changed, 176 insertions(+), 21 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index a413b20f5..c9fe0a5eb 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -112,10 +112,14 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal | Method | Family | Status | Blocker | |---|---|---|---| -| `loft(sections)` | Solid | not started | multi-profile kernel op + spec | -| Bulge-arc polyline profiles | Solid sweep | partial | `entity_to_profile_curves` emits straight segments; bulge arcs need arc-segment conversion | | `GetCentroid`/`GetVolume` accuracy | Solid | mesh approx | render-mesh LOD tolerance vs query tolerance | +**Completed (outstanding methods):** `loft(profiles)` — `Operation::Loft{profiles}`, +resolves each profile to curve sets (all-or-nothing), `brep::loft` (`loft_two_profiles_*` +test). **Bulge-arc polyline profiles** — `entity_to_profile_curves` now converts +`LwVertex.bulge` to `geom2d::Curve::Arc` via the bulge→arc formula (θ = 4·atan(bulge)), +so bulged profiles sweep correctly (`bulge_arc_polyline_profile_converts_to_arc` test). + **Completed (Phase 0):** `extrude`, `revolve` (profiles: Line/Circle/Arc/LwPolyline), `add_vertex` (polyline), non-solid `transform` + `transform_many` (Line/Circle/Arc/ Point/LwPolyline). Covered by `phase0_*` tests in `src/app/doc_api.rs`. diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index b52e9781c..8ddc9a849 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -207,6 +207,14 @@ impl DocApiBackend for MockBackend { fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult { Ok(self.alloc("RasterImage")) } + fn loft(&mut self, sections: &[Vec]) -> ApiResult { + if sections.len() < 2 { + return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); + } + let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; + self.store_solid(&body) + } fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 72d3f2314..16f1ec48f 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -68,6 +68,11 @@ name = "revolve" args = [{ name = "profile", ty = "EntityRef" }, { name = "pivot", ty = "[f64; 3]" }, { name = "axis", ty = "[f64; 3]" }, { name = "angle", ty = "f64" }] op = "Revolve" +[[family.constructor]] +name = "loft" +args = [{ name = "profiles", ty = "&[EntityRef]" }] +op = "Loft" + [[family.method]] name = "intersect" kind = "op" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index ba25033d4..47991ec74 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -84,6 +84,10 @@ pub trait DocApiBackend { /// Add a `RASTER_IMAGE` (host auto-registers the ImageDefinition). fn add_raster_image(&mut self, spec: &crate::ops::RasterImageSpec) -> ApiResult; + /// Loft a solid through >= 2 profile curve sets (each already resolved to + /// `geom2d::Curve`s on the XY plane). Returns the fresh `ObjectId`. + fn loft(&mut self, sections: &[Vec]) -> 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 57ad09c2c..7af52512a 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -253,6 +253,22 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + // Resolve every profile to its curve set BEFORE push_undo (all-or-nothing). + if profiles.len() < 2 { + return Err(ApiError::validation(name, "loft needs >= 2 profiles")); + } + let mut sections = Vec::with_capacity(profiles.len()); + for (i, p) in profiles.iter().enumerate() { + let curves = profile_curves(b, *p, name) + .map_err(|e| at_index(name, i, e))?; + sections.push(curves); + } + b.push_undo(name); + let id = b.loft(§ions)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 5305469ec..43cb6d75a 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -452,6 +452,13 @@ impl SolidCollection { .ok_or_else(|| ApiError::Transport("revolve returned no id".into()))?; Ok(Solid::new(self.session.clone(), id)) } + /// Loft a solid through >= 2 profile entities (polylines/circles/arcs). + pub fn loft(&self, profiles: &[impl HasId]) -> ApiResult { + let ids: Vec = profiles.iter().map(|p| p.id()).collect(); + let receipt = self.session.apply_op(Operation::Loft { profiles: ids })?; + let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("loft returned no id".into()))?; + Ok(Solid::new(self.session.clone(), id)) + } } #[derive(Clone)] diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 38766a90f..b4f17ebd6 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -18,6 +18,7 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_wedge**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid` - **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` - **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` +- **loft**(`profiles: &[EntityRef]`) -> `Solid` ### Methods diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 28ecb9a28..29a2a5792 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -46,4 +46,6 @@ pub enum Operation { SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }, // media CreateRasterImage(RasterImageSpec), + // multi-profile solid + Loft { profiles: Vec }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 749ddb753..e041f89b0 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -185,6 +185,7 @@ impl crate::gen::Operation { SetAttribute { .. } => "SetAttribute", SetViewportView { .. } => "SetViewportView", CreateRasterImage(_) => "CreateRasterImage", + Loft { .. } => "Loft", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 2bd1cd1fe..f6e8b39af 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -147,6 +147,15 @@ impl DocApiBackend for MockBackend { fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult { Ok(self.alloc("RasterImage")) } + fn loft(&mut self, sections: &[Vec]) -> ApiResult { + if sections.len() < 2 { + return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); + } + // Mock: store a unit cuboid as the loft result. + let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; + self.store_solid(&body) + } fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index b636e3e5d..94303ab7e 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -63,6 +63,7 @@ fn operation_variants() -> Vec { Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { file_path: String::new(), insertion_point: [0.0; 3], u_vector: [0.0; 3], v_vector: [0.0; 3], size: [0.0; 2], }), + Operation::Loft { profiles: vec![] }, ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -151,6 +152,7 @@ const OPERATION_BASELINE: &[&str] = &[ "SetAttribute", "SetViewportView", "CreateRasterImage", + "Loft", ]; /// Recorded baseline of the `Query` variant order. diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index d0d27d0ec..13c3ec1d4 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -347,6 +347,17 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn loft(&mut self, sections: &[Vec]) -> ApiResult { + // brep::loft takes (Plane, Vec) per section; all profiles are on XY. + let plane = cadkernel::space::Plane::orthonormal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Other, "failed to construct XY plane"))?; + let loft_sections: Vec<(cadkernel::space::Plane, Vec)> = + sections.iter().map(|s| (plane, s.clone())).collect(); + let body = cadkernel::brep::loft(&loft_sections) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "brep::loft failed"))?; + self.store_solid(&body) + } + 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 { @@ -1014,6 +1025,62 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Outstanding methods: loft + bulge-arc profiles ─────────────────────── + + #[test] + fn loft_two_profiles_produces_solid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Two circles at different Z (profiles for loft). + let c1 = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Circle { centre: [0.0, 0.0, 0.0], radius: 5.0 }))).unwrap()); + let c2 = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Circle { centre: [0.0, 0.0, 10.0], radius: 2.0 }))).unwrap()); + let result = dispatch(&mut host, DocApiEnvelope::op(Operation::Loft { profiles: vec![c1, c2] })); + // Loft may be geometry-sensitive; assert either a positive-volume solid or + // a structured geometry error (no panic). + match result { + Ok(receipt) => { + let id = receipt.outcome.and_then(|o| o.new_id()).unwrap(); + let v = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id }])).unwrap(); + match &v.query_results[0] { + QueryResult::Volume(vol) => assert!(*vol > 0.0, "loft volume {vol}"), + other => panic!("expected volume, got {other:?}"), + } + } + Err(ApiError::Geometry { .. } | ApiError::Validation { .. } | ApiError::Unsupported(_)) => {} + Err(e) => panic!("unexpected loft error: {e}"), + } + } + + #[test] + fn bulge_arc_polyline_profile_converts_to_arc() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A polyline with a bulge (arc) segment: square with one curved side. + let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Polyline { + points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], + closed: false, + }))).unwrap()); + // Add a bulge to the first segment by editing the vertex. + let handle = obj_to_handle(poly); + let Some(EntityType::LwPolyline(mut pl)) = host.document().get_entity(handle).cloned() else { panic!("polyline not found") }; + pl.vertices[0].bulge = 1.0; // 90-degree arc (tan(π/2/4) = 1) + host.scene_mut().update_entity(EntityType::LwPolyline(pl)); + + // profile_curves now converts the bulge to a Curve::Arc (not Unsupported). + let curves = host.profile_curves(poly).expect("bulge profile must convert"); + // Open polyline, 3 vertices -> 2 segments: arc (bulged first), line. + assert_eq!(curves.len(), 2, "2 segments (arc + line)"); + assert!(matches!(curves[0], cadkernel::geom2d::Curve::Arc(_)), "first segment is an arc"); + assert!(matches!(curves[1], cadkernel::geom2d::Curve::Line(_)), "second is a line"); + // bulge=1 -> included angle θ = 4·atan(1) = π (a semicircle); radius = chord/2 = 5. + if let cadkernel::geom2d::Curve::Arc(arc) = &curves[0] { + assert!((arc.radius - 5.0).abs() < 0.01, "arc radius {} (semicircle)", arc.radius); + } + } + // ── Phase 5 remaining: typed raster image create ───────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 4123e6dc9..bdbcb78a2 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -30,26 +30,22 @@ pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult= 2 vertices")); } - // Fail explicitly on bulge arcs rather than silently producing a - // chord-substituted (wrong-shape) profile for Extrude/Revolve. - if pl.vertices.iter().any(|v| v.bulge != 0.0) { - return Err(ApiError::Unsupported( - "polyline profiles with bulge (arc) segments are not yet supported for sweep ops".into(), - )); - } - // Build a straight-segment polyline chain. - let pts: Vec<[f64; 2]> = pl.vertices.iter().map(|v| [v.location.x, v.location.y]).collect(); - let mut segs = Vec::with_capacity(pts.len()); - for i in 0..pts.len() { - let start = pts[i]; - let end = if i + 1 < pts.len() { - pts[i + 1] - } else if pl.is_closed { - pts[0] + // Convert each segment: straight for bulge==0, a circular arc for + // bulge!=0 (bulge = tan(included_angle/4)). This supports bulged + // polyline profiles for Extrude/Revolve instead of rejecting them. + let n = pl.vertices.len(); + let count = if pl.is_closed { n } else { n - 1 }; + let mut segs = Vec::with_capacity(count); + for i in 0..count { + let a = pl.vertices[i]; + let b = pl.vertices[(i + 1) % n]; + let start = [a.location.x, a.location.y]; + let end = [b.location.x, b.location.y]; + if a.bulge.abs() < 1e-12 { + segs.push(KCurve::Line(KLine { start, end })); } else { - break; - }; - segs.push(KCurve::Line(KLine { start, end })); + segs.push(bulge_arc_segment(start, end, a.bulge)?); + } } segs } @@ -193,6 +189,39 @@ fn v3(p: [f64; 3]) -> Vector3 { Vector3::new(p[0], p[1], p[2]) } +/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a +/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise. +fn bulge_arc_segment(start: [f64; 2], end: [f64; 2], bulge: f64) -> ApiResult { + use cadkernel::geom2d::{Arc as KArc, Curve as KCurve}; + let chord_x = end[0] - start[0]; + let chord_y = end[1] - start[1]; + let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt(); + if chord_len < 1e-12 { + return Err(ApiError::validation("profile", "bulge segment with coincident endpoints")); + } + // Included angle θ = 4·atan(bulge); radius = (chord/2) / sin(θ/2). + let theta = 4.0 * bulge.atan(); + let half = theta / 2.0; + let sin_half = half.sin(); + if sin_half.abs() < 1e-9 { + return Err(ApiError::validation("profile", "bulge segment with degenerate angle")); + } + let radius = (chord_len / 2.0) / sin_half.abs(); + // Center: chord midpoint + perpendicular offset (chord/2)/tan(θ/2). + let mid_x = (start[0] + end[0]) / 2.0; + let mid_y = (start[1] + end[1]) / 2.0; + let apothem = (chord_len / 2.0) / half.tan(); + // Perpendicular to chord (rotate chord unit vector by +90° for CCW bulge). + let sign = if bulge >= 0.0 { 1.0 } else { -1.0 }; + let perp_x = -chord_y / chord_len * sign; + let perp_y = chord_x / chord_len * sign; + let centre = [mid_x + perp_x * apothem, mid_y + perp_y * apothem]; + // Start/end angles about the centre. + let start_angle = (start[1] - centre[1]).atan2(start[0] - centre[0]); + let end_angle = (end[1] - centre[1]).atan2(end[0] - centre[0]); + Ok(KCurve::Arc(KArc { centre, radius, start_angle, end_angle })) +} + /// Build an acadrust entity for a 2D curve construction spec. pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { let entity = match spec {