forked from 1b-agent/opencadstudio-upstream
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:
1 parent
46a5922520
commit
e216a7bf94
13 files changed
+176
-21
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(§ions)?;
|
||||
b.finalize_op();
|
||||
OpOutcome::NewId(id)
|
||||
}
|
||||
};
|
||||
Ok(Receipt {
|
||||
outcome: Some(outcome),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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> },
|
||||
}
|
||||
@@ -185,6 +185,7 @@ impl crate::gen::Operation {
|
||||
SetAttribute { .. } => "SetAttribute",
|
||||
SetViewportView { .. } => "SetViewportView",
|
||||
CreateRasterImage(_) => "CreateRasterImage",
|
||||
Loft { .. } => "Loft",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -347,6 +347,17 @@ impl DocApiBackend for HostSession<'_> {
|
||||
Ok(handle_to_obj(handle))
|
||||
}
|
||||
|
||||
fn loft(&mut self, sections: &[Vec<cadkernel::geom2d::Curve>]) -> ApiResult<ObjectId> {
|
||||
// brep::loft takes (Plane, Vec<Curve>) 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<cadkernel::geom2d::Curve>)> =
|
||||
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]
|
||||
|
||||
+48
-19
@@ -30,26 +30,22 @@ pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult<Vec<cadkernel:
|
||||
if pl.vertices.len() < 2 {
|
||||
return Err(ApiError::validation("profile", "polyline profile needs >= 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<cadkernel::geom2d::Curve> {
|
||||
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<EntityType> {
|
||||
let entity = match spec {
|
||||
|
||||
Reference in new issue
Block a user