ocs_doc_api: outstanding methods — loft + bulge-arc polyline profiles

- 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.
This commit is contained in:
Sebastian committed 2026-09-06 10:44:37 +02:00
1 parent 46a5922520
commit e216a7bf94
13 files changed
+176 -21

No files matched your search

+6 -2
View File
@@ -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`.
@@ -207,6 +207,14 @@ impl DocApiBackend for MockBackend {
fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult<ObjectId> {
Ok(self.alloc("RasterImage"))
}
fn loft(&mut self, sections: &[Vec<cadkernel::geom2d::Curve>]) -> ApiResult<ObjectId> {
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));
+5
View File
@@ -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"
+4
View File
@@ -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<ObjectId>;
/// 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<cadkernel::geom2d::Curve>]) -> ApiResult<ObjectId>;
/// 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).
+16
View File
@@ -253,6 +253,22 @@ pub fn apply_op<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt
b.finalize_op();
OpOutcome::NewId(id)
}
Operation::Loft { profiles } => {
// 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(&sections)?;
b.finalize_op();
OpOutcome::NewId(id)
}
};
Ok(Receipt {
outcome: Some(outcome),
+7
View File
@@ -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<Solid> {
let ids: Vec<ObjectId> = 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)]
@@ -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
+2
View File
@@ -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<ObjectId> },
}
+1
View File
@@ -185,6 +185,7 @@ impl crate::gen::Operation {
SetAttribute { .. } => "SetAttribute",
SetViewportView { .. } => "SetViewportView",
CreateRasterImage(_) => "CreateRasterImage",
Loft { .. } => "Loft",
}
}
}
+9
View File
@@ -147,6 +147,15 @@ impl DocApiBackend for MockBackend {
fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult<ObjectId> {
Ok(self.alloc("RasterImage"))
}
fn loft(&mut self, sections: &[Vec<cadkernel::geom2d::Curve>]) -> ApiResult<ObjectId> {
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));
@@ -63,6 +63,7 @@ fn operation_variants() -> Vec<String> {
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.