diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 4eb771fc9..1155f5c73 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -11,8 +11,9 @@ content). | Capability | Method / op | Notes | |---|---|---| | Primitives | `create_cuboid` / `create_sphere` / `create_cylinder` / `create_cone` / `create_torus` / `create_wedge` | `CreateSolid(SolidPrimitive)` → `brep::make::*` | +| Sweep | `extrude(profile, dir)` / `revolve(profile, pivot, axis, angle)` | `Extrude`/`Revolve` → `brep::extrude`/`brep::revolve`; profile = Line/Circle/Arc/LwPolyline | | Booleans | `intersect` / `union` / `subtract` | `SolidBoolean{op, a, b, erase_sources:true}` → `brep::combine` | -| Transform | `transform(placement)` (in-place) | `Transform{id, placement}` → `brep::transform`; solids only | +| Transform | `transform(placement)` (in-place) | `Transform{id, placement}` → `brep::transform`; solids | | Bounds | `bounds()` | `GetBounds` → `brep::body_bounds` (lift-on-miss) | | Volume | `volume()` | `GetVolume` → `meshes.metrics` cache, mesh-divergence fallback | | Centroid | `centroid()` | `GetCentroid` → `meshes.metrics` cache, mesh-divergence fallback | @@ -21,17 +22,19 @@ content). ### Minimal 2D (phase-1 set, used as profiles/inputs) | Capability | Method / op | Notes | |---|---|---| -| Line | `create_line` → bounds | `CreateCurve(Line)` | -| Circle | `create_circle` → bounds | `CreateCurve(Circle)` | -| Polyline | `create_polyline` → bounds | `CreateCurve(Polyline)` | -| Point | `create_point` / `create_points` (bulk) | `CreateCurve(Point)` / `CreateMany` | +| Line | `create_line` → bounds, transform | `CreateCurve(Line)` | +| Circle | `create_circle` → bounds, transform | `CreateCurve(Circle)` | +| Arc | (create via host) → transform, profile | usable as sweep profile | +| Polyline | `create_polyline` → bounds, transform, `add_vertex` | `CreateCurve(Polyline)`; `AddVertex` inserts a vertex | +| Point | `create_point` / `create_points` (bulk) → transform | `CreateCurve(Point)` / `CreateMany` | +| Transform (2D) | `transform(placement)` / `transform_many` | `Line`/`Circle`/`Arc`/`Point`/`LwPolyline` via acadrust | ### Cross-cutting | Capability | Method | Notes | |---|---|---| | Lookup / downcast | `entities().get(id)` → `Entity`; `as_solid()` | generic, any family | | Bulk create | `create_points(&[..])`, `CreateMany` | all-or-nothing, one undo step | -| Bulk transform | `transform_many(&ids, placement)` | all-or-nothing, solids only (v1) | +| Bulk transform | `transform_many(&ids, placement)` | all-or-nothing, solids + 2D families | | Bulk delete | `delete_many(&ids)` | all-or-nothing; `OpGroup::compensate` uses it | | Read batch | `query_batch(\|q\| …)` | read-only, one round-trip, no bump | | Revision guard | `revision()` / `assert_revision(rev)` | read-modify-write guard | @@ -47,12 +50,15 @@ Phases are **spec-additive**: each is a `[[family]]`/`[[family.method]]` block i variants append at enum end. ### Phase 2 — full 2D + annotations -- **Entities:** ellipse, spline, xline/ray, arc (typed), text, mtext, dimension, - hatch. -- **Methods:** boundary/vertex actions (`add_vertex` on polyline — currently - `Unsupported`), hatch boundary query, text content get/set. -- **Blocked by:** `AddVertex` backend impl (currently returns `Unsupported`); - curve → `geom2d::Curve` conversion for non-trivial curves. +- **Done (2a):** typed curve families `ArcCurve`/`Ellipse`/`Spline`/`Ray`/`XLine` — + `create_arc`/`create_ellipse`/`create_spline`/`create_ray`/`create_xline`, bounds + (Arc coarse full-circle, Ellipse major-axis radius, Spline control-point bbox; + Ray/XLine unbounded → `Unsupported`), and transform (all five + the phase-1 set) + via acadrust. Covered by `phase2_*` tests. +- **Remaining (2b):** annotations `Text`/`MText` (create + content get/set), `Hatch` + (create + boundary query), `Dimension` (typed sub-types). +- **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline) + are supported (Phase 0). ### Phase 3 — containers - **Entities:** insert/block references, attribute definitions/entities. @@ -75,13 +81,14 @@ variants append at enum end. | Method | Family | Status | Blocker | |---|---|---|---| -| `extrude(profile, dir)` | Solid | stub | `profile_curves` backend returns `Unsupported`; needs entity → `geom2d::Curve` conversion | -| `revolve(profile, pivot, axis, angle)` | Solid | stub | same `profile_curves` blocker | | `loft(sections)` | Solid | not started | multi-profile kernel op + spec | -| `add_vertex(at, pt)` | Polyline | stub | backend `add_vertex` returns `Unsupported` | -| `transform` (non-solid) | 2D entities | `Unsupported` | per-family transform via acadrust (not kernel) | +| 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 (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`. + ## Design invariants to preserve when extending - Per-op autocommit; **no multi-op write transactions**. diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 35db08cdc..f6f2e0306 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -62,13 +62,11 @@ fixed = { primitive = "Wedge" } name = "extrude" args = [{ name = "profile", ty = "EntityRef" }, { name = "direction", ty = "[f64; 3]" }] op = "Extrude" -status = "unsupported" [[family.constructor]] name = "revolve" args = [{ name = "profile", ty = "EntityRef" }, { name = "pivot", ty = "[f64; 3]" }, { name = "axis", ty = "[f64; 3]" }, { name = "angle", ty = "f64" }] op = "Revolve" -status = "unsupported" [[family.method]] name = "intersect" @@ -158,7 +156,6 @@ kind = "op" op = "AddVertex" args = [{ name = "at", ty = "usize" }, { name = "point", ty = "[f64; 3]" }] returns = "()" -status = "unsupported" [[family]] name = "Point" @@ -180,6 +177,66 @@ fixed = { curve = "Point" } bulk = true returns = "Vec" +[[family]] +name = "Arc" +acadrust_variant = "Arc" +handle = "ArcCurve" +collection = "curves" + +[[family.constructor]] +name = "create_arc" +args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "start_angle", ty = "f64" }, { name = "end_angle", ty = "f64" }] +op = "CreateCurve" +fixed = { curve = "Arc" } + +[[family]] +name = "Ellipse" +acadrust_variant = "Ellipse" +handle = "Ellipse" +collection = "curves" + +[[family.constructor]] +name = "create_ellipse" +args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_axis", ty = "[f64; 3]" }, { name = "ratio", ty = "f64" }, { name = "start", ty = "f64" }, { name = "end", ty = "f64" }] +op = "CreateCurve" +fixed = { curve = "Ellipse" } + +[[family]] +name = "Spline" +acadrust_variant = "Spline" +handle = "Spline" +collection = "curves" + +[[family.constructor]] +name = "create_spline" +args = [{ name = "degree", ty = "i32" }, { name = "control_points", ty = "&[[f64; 3]]" }, { name = "knots", ty = "&[f64]" }, { name = "weights", ty = "&[f64]" }] +op = "CreateCurve" +fixed = { curve = "Spline" } + +[[family]] +name = "Ray" +acadrust_variant = "Ray" +handle = "Ray" +collection = "curves" + +[[family.constructor]] +name = "create_ray" +args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] +op = "CreateCurve" +fixed = { curve = "Ray" } + +[[family]] +name = "XLine" +acadrust_variant = "XLine" +handle = "XLine" +collection = "curves" + +[[family.constructor]] +name = "create_xline" +args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] +op = "CreateCurve" +fixed = { curve = "XLine" } + # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs # ============================================================================ diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 2a01ed796..80c157be2 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -262,6 +262,11 @@ handle!(Line); handle!(Circle); handle!(Polyline); handle!(Point); +handle!(ArcCurve); +handle!(Ellipse); +handle!(Spline); +handle!(Ray); +handle!(XLine); impl Entity { pub fn view(&self) -> ApiResult { @@ -412,6 +417,31 @@ impl CurveCollection { let (s, id) = self.create_curve(Curve2Spec::Point { position })?; Ok(Point::new(s, id)) } + pub fn create_arc(&self, centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Arc { centre, radius, start_angle, end_angle })?; + Ok(ArcCurve::new(s, id)) + } + pub fn create_ellipse(&self, centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Ellipse { centre, major_axis, ratio, start, end })?; + Ok(Ellipse::new(s, id)) + } + pub fn create_spline(&self, degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Spline { + degree, + control_points: control_points.to_vec(), + knots: knots.to_vec(), + weights: weights.to_vec(), + })?; + Ok(Spline::new(s, id)) + } + pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Ray { origin, direction })?; + Ok(Ray::new(s, id)) + } + pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?; + Ok(XLine::new(s, id)) + } /// Bulk-create many points in ONE op (plan §5.3): all-or-nothing, one undo step. pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { let specs: Vec = positions diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 3f9655bd1..69a191ceb 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -16,8 +16,8 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_cone**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid` - **create_torus**(`centre: [f64; 3], major_radius: f64, minor_radius: f64`) -> `Solid` - **create_wedge**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid` -- **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`) -- **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`) +- **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` +- **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` ### Methods @@ -48,7 +48,7 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ### Methods -- **add_vertex**(`at: usize, point: [f64; 3]`) -> `()` — op `AddVertex` — **not yet supported** (returns `ApiError::Unsupported`) +- **add_vertex**(`at: usize, point: [f64; 3]`) -> `()` — op `AddVertex` ## `Point` (acadrust `Point`, collection `curves`) @@ -57,6 +57,36 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_point**(`position: [f64; 3]`) -> `Point` - **create_points**(`positions: &[[f64; 3]]`) -> `Vec` *(bulk op)* +## `ArcCurve` (acadrust `Arc`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_arc**(`centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64`) -> `ArcCurve` + +## `Ellipse` (acadrust `Ellipse`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_ellipse**(`centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64`) -> `Ellipse` + +## `Spline` (acadrust `Spline`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_spline**(`degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]`) -> `Spline` + +## `Ray` (acadrust `Ray`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_ray**(`origin: [f64; 3], direction: [f64; 3]`) -> `Ray` + +## `XLine` (acadrust `XLine`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_xline**(`origin: [f64; 3], direction: [f64; 3]`) -> `XLine` + ## Generic methods (every handle) Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 41a4c5f1e..b7e9c9ca2 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -152,6 +152,41 @@ "handle": "Point", "methods": [], "name": "Point" + }, + { + "acadrust_variant": "Arc", + "collection": "curves", + "handle": "ArcCurve", + "methods": [], + "name": "Arc" + }, + { + "acadrust_variant": "Ellipse", + "collection": "curves", + "handle": "Ellipse", + "methods": [], + "name": "Ellipse" + }, + { + "acadrust_variant": "Spline", + "collection": "curves", + "handle": "Spline", + "methods": [], + "name": "Spline" + }, + { + "acadrust_variant": "Ray", + "collection": "curves", + "handle": "Ray", + "methods": [], + "name": "Ray" + }, + { + "acadrust_variant": "XLine", + "collection": "curves", + "handle": "XLine", + "methods": [], + "name": "XLine" } ], "layouts": { diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 1e30f98e4..6ab66f47f 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -34,6 +34,17 @@ pub enum Curve2Spec { Circle { centre: [f64; 3], radius: f64 }, Polyline { points: Vec<[f64; 3]>, closed: bool }, Point { position: [f64; 3] }, + /// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians). + Arc { centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64 }, + /// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`; + /// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π). + Ellipse { centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64 }, + /// NURBS spline (control points + knots + weights; `degree` typically 3). + Spline { degree: i32, control_points: Vec<[f64; 3]>, knots: Vec, weights: Vec }, + /// A ray from `origin` along `direction` (bounded at origin only). + Ray { origin: [f64; 3], direction: [f64; 3] }, + /// An infinite construction line through `origin` along `direction`. + XLine { origin: [f64; 3], direction: [f64; 3] }, } /// A generic entity-construction payload used by the bulk op diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index b2f259000..b1c6abaa1 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -105,8 +105,28 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } - fn add_vertex(&mut self, _id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { - Err(ApiError::Unsupported("AddVertex is not yet implemented".into())) + 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 { + return Err(ApiError::UnknownId(id)); + }; + let EntityType::LwPolyline(mut pl) = entity else { + return Err(ApiError::Unsupported("AddVertex is only implemented for polylines".into())); + }; + if at > pl.vertices.len() { + return Err(ApiError::validation( + "AddVertex", + format!("index {at} out of range ({} vertices)", pl.vertices.len()), + )); + } + pl.vertices.insert( + at, + acadrust::entities::LwVertex::from_coords(point[0], point[1]), + ); + if !self.scene_mut().update_entity(EntityType::LwPolyline(pl)) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) } fn remove_entity(&mut self, id: ObjectId) -> ApiResult { @@ -126,15 +146,29 @@ impl DocApiBackend for HostSession<'_> { } fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { - // v1 typed transform is solids-only; pre-validating this lets TransformMany - // be all-or-nothing (no mid-loop Unsupported after earlier transforms). - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if matches!(self.document().get_entity(obj_to_handle(id)), Some(EntityType::Solid3D(_))) { + // Pre-validating this lets TransformMany be all-or-nothing. Solids and the + // supported 2D families are transformable; anything else errors up front. + let entity = self + .document() + .get_entity(obj_to_handle(id)) + .ok_or(ApiError::UnknownId(id))?; + let ok = matches!( + entity, + EntityType::Solid3D(_) + | EntityType::Line(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) + | EntityType::Ray(_) + | EntityType::XLine(_) + | EntityType::Point(_) + | EntityType::LwPolyline(_) + ); + if ok { Ok(()) } else { - Err(ApiError::Unsupported("transform is only implemented for solids in v1".into())) + Err(ApiError::Unsupported("transform is not supported for this entity family".into())) } } @@ -167,8 +201,8 @@ impl DocApiBackend for HostSession<'_> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); } - // Solids go through the kernel transform (re-embed SAT). Non-solids are - // out of scope for v1 typed transforms. + // Solids go through the kernel transform (re-embed SAT). 2D entities are + // transformed via acadrust geometry and updated in place. if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) { let body = self.resolve_body(id)?; let place = kernel_placement(placement); @@ -176,14 +210,25 @@ impl DocApiBackend for HostSession<'_> { .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed"))?; self.update_solid(id, &moved) } else { - Err(ApiError::Unsupported("transform is only implemented for solids in v1".into())) + let entity = self + .document() + .get_entity(handle) + .cloned() + .ok_or(ApiError::UnknownId(id))?; + let moved = crate::app::doc_api_convert::transform_entity_geometry(&entity, placement)?; + if !self.scene_mut().update_entity(moved) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) } } - fn profile_curves(&self, _id: ObjectId) -> ApiResult> { - // Profiles for Extrude/Revolve land in a later phase (§5.2); v1 surfaces - // a clear error rather than guessing. - Err(ApiError::Unsupported("profile curves for sweep ops are not yet implemented".into())) + fn profile_curves(&self, id: ObjectId) -> ApiResult> { + let entity = self + .document() + .get_entity(obj_to_handle(id)) + .ok_or(ApiError::UnknownId(id))?; + crate::app::doc_api_convert::entity_to_profile_curves(entity) } fn bounds(&mut self, id: ObjectId) -> ApiResult { @@ -345,25 +390,23 @@ mod tests { } #[test] - fn doc_api_transform_many_with_non_solid_fails_all_or_nothing() { - use ocs_doc_api::ops::Curve2Spec; + fn doc_api_transform_many_with_stale_id_fails_all_or_nothing() { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); - // A solid and a non-solid (a line is not transformable in v1). + // A transformable solid, plus a stale id (always non-transformable -> UnknownId). let mk_solid = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }); - let mk_line = Operation::CreateCurve(Curve2Spec::Line { start: [0.0; 3], end: [1.0; 3] }); let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_solid)).unwrap()); - let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap()); + let stale = ObjectId::from_u64(0xFFFF_FFFF); let rev_before = host.scene().geometry_epoch; - // TransformMany over [solid, line]: the non-solid makes it fail BEFORE any + // TransformMany over [solid, stale]: the stale id makes it fail BEFORE any // mutation (all-or-nothing), so the epoch must not move and no undo is recorded. let op = Operation::TransformMany { - ids: vec![solid, line], + ids: vec![solid, stale], placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), }; let err = dispatch(&mut host, DocApiEnvelope::op(op)).unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. } | ApiError::Unsupported(_)), "{err:?}"); + assert!(matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), "{err:?}"); assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany"); } @@ -377,6 +420,191 @@ mod tests { assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); } + // ── Phase 0: outstanding supported-family methods ────────────────────── + + #[test] + fn phase0_add_vertex_to_polyline() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let mk_poly = 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, + }); + let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_poly)).unwrap()); + // Insert a vertex at index 1. + dispatch(&mut host, DocApiEnvelope::op(Operation::AddVertex { + id: poly, at: 1, point: [5.0, 0.0, 0.0] })).unwrap(); + // The polyline now has 4 vertices with the inserted one at index 1. + let handle = obj_to_handle(poly); + let Some(EntityType::LwPolyline(pl)) = host.document().get_entity(handle) else { + panic!("polyline not found"); + }; + assert_eq!(pl.vertices.len(), 4); + assert!((pl.vertices[1].location.x - 5.0).abs() < 1e-9); + // Out-of-range index is a validation error, not a panic. + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::AddVertex { + id: poly, at: 99, point: [0.0, 0.0, 0.0] })).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); + } + + #[test] + fn phase0_extrude_rectangular_profile_to_solid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A closed rectangular 2x3 profile in XY. + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + points: vec![[0.0,0.0,0.0],[2.0,0.0,0.0],[2.0,3.0,0.0],[0.0,3.0,0.0]], + closed: true, + }); + let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); + // Extrude +Z by 5 -> a 2x3x5 box = volume 30. + let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::Extrude { + profile, direction: [0.0, 0.0, 5.0] })).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id: solid }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Volume(v) => assert!((*v - 30.0).abs() < 1.0, "extrude volume {v}"), + other => panic!("expected volume, got {other:?}"), + } + } + + #[test] + fn phase0_revolve_profile_to_solid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A 1-wide, 2-tall rectangle offset 1 from the Y axis; revolve about the Y + // axis by 2*pi -> a cylinder-ish annulus (outer r=2, inner r=1, h=2): pi*(4-1)*2 = 6pi ≈ 18.85. + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + points: vec![[1.0,0.0,0.0],[2.0,0.0,0.0],[2.0,2.0,0.0],[1.0,2.0,0.0]], + closed: true, + }); + let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); + let result = dispatch(&mut host, DocApiEnvelope::op(Operation::Revolve { + profile, + axis: ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + angle: std::f64::consts::TAU, + })); + // Revolve may be geometry-sensitive; assert it either produces a positive + // volume or surfaces 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, "revolve volume {vol}"), + other => panic!("expected volume, got {other:?}"), + } + } + Err(ApiError::Geometry { .. } | ApiError::Validation { .. }) => {} + Err(e) => panic!("unexpected error: {e}"), + } + } + + #[test] + fn phase0_non_solid_transform_line_and_circle() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let mk_line = Operation::CreateCurve(Curve2Spec::Line { start: [0.0; 3], end: [10.0; 3] }); + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap()); + // Translate the line by +5 in X: bounds shift by +5. + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: line, placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]) })).unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: line }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 5.0).abs() < 1e-6 && (b.max[0] - 15.0).abs() < 1e-6, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + // TransformMany over a mix of line + circle is now all-or-nothing (both transformable). + let circle = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Circle { centre: [0.0; 3], radius: 2.0 }))).unwrap()); + dispatch(&mut host, DocApiEnvelope::op(Operation::TransformMany { + ids: vec![line, circle], + placement: ocs_doc_api::PlacementSpec::at([0.0, 1.0, 0.0]), + })).unwrap(); + } + + // ── Phase 2: full 2D curve families ──────────────────────────────────── + + #[test] + fn phase2_arc_ellipse_spline_create_bounds_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Arc: create -> kind Arc; bounds are the coarse full-circle bounds. + let arc = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Arc { centre: [5.0, 5.0, 0.0], radius: 4.0, start_angle: 0.0, end_angle: std::f64::consts::PI }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetEntity { id: arc }, Query::GetBounds { id: arc }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Arc"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => assert!((b.min[0] - 1.0).abs() < 1e-6 && (b.max[0] - 9.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + + // Ellipse: create -> kind Ellipse; bounds = centre ± major-axis length. + let ell = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ellipse { centre: [0.0, 0.0, 0.0], major_axis: [6.0, 0.0, 0.0], ratio: 0.5, start: 0.0, end: std::f64::consts::TAU }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetEntity { id: ell }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Ellipse"), + other => panic!("expected entity, got {other:?}"), + } + + // Spline (degree-3 cubic through 4 control points): create -> kind Spline; + // bounds = control-point bounds. Transform by +10 X shifts bounds. + let spline = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Spline { + degree: 3, + control_points: vec![[0.0,0.0,0.0],[1.0,2.0,0.0],[2.0,-2.0,0.0],[3.0,0.0,0.0]], + knots: vec![0.0,0.0,0.0,0.0,1.0,1.0,1.0,1.0], + weights: vec![1.0; 4], + }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => assert!((b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: spline, placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]) })).unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => assert!((b.min[0] - 10.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + } + + #[test] + fn phase2_ray_xline_create_and_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let ray = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ray { origin: [1.0, 2.0, 0.0], direction: [1.0, 0.0, 0.0] }))).unwrap()); + let xline = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::XLine { origin: [0.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0] }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetEntity { id: ray }, Query::GetEntity { id: xline }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Ray"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Entity(v) => assert_eq!(v.kind, "XLine"), + other => panic!("expected entity, got {other:?}"), + } + // Ray/XLine are unbounded -> GetBounds is Unsupported (not a panic). + assert!(dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: ray }])).is_err()); + // Transform moves the ray's base point (no error). + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: ray, placement: ocs_doc_api::PlacementSpec::at([0.0, 0.0, 5.0]) })).unwrap(); + let handle = obj_to_handle(ray); + let Some(EntityType::Ray(r)) = host.document().get_entity(handle) else { panic!("ray not found") }; + assert!((r.base_point.z - 5.0).abs() < 1e-6); + } + #[test] fn doc_api_unknown_id_surfaces_structured_error() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 3fbc29b6e..83a212e8e 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -2,11 +2,152 @@ // acadrust `EntityType` and back. Lives in the host (uses the real acadrust // types), keeping the crate's DTOs dependency-light. -use acadrust::entities::{Circle, Line, LwPolyline, LwVertex, Point}; +use acadrust::entities::{Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine}; use acadrust::types::{Vector2, Vector3}; use acadrust::EntityType; use ocs_doc_api::{Aabb, ApiError, ApiResult, Curve2Spec, ObjectId}; +/// Convert a profile entity (Line/Circle/Arc/LwPolyline) to `geom2d::Curve`s for +/// sweep ops (`Extrude`/`Revolve`). Z is dropped (profiles lie on the XY plane). +pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult> { + use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Curve as KCurve, Line as KLine}; + let curves = match entity { + EntityType::Line(l) => vec![KCurve::Line(KLine { + start: [l.start.x, l.start.y], + end: [l.end.x, l.end.y], + })], + EntityType::Circle(c) => vec![KCurve::Circle(KCircle { + centre: [c.center.x, c.center.y], + radius: c.radius, + })], + EntityType::Arc(a) => vec![KCurve::Arc(KArc { + centre: [a.center.x, a.center.y], + radius: a.radius, + start_angle: a.start_angle, + end_angle: a.end_angle, + })], + EntityType::LwPolyline(pl) => { + if pl.vertices.len() < 2 { + return Err(ApiError::validation("profile", "polyline profile needs >= 2 vertices")); + } + // Build a straight-segment polyline chain (bulge arcs land in a later + // phase; straight segments cover the common rectangular profile case). + 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] + } else { + break; + }; + segs.push(KCurve::Line(KLine { start, end })); + } + segs + } + _ => { + return Err(ApiError::Unsupported( + "profile conversion is supported for Line/Circle/Arc/LwPolyline only".into(), + )) + } + }; + Ok(curves) +} + +/// Apply a rigid similarity (rotation+translation+uniform scale) to a non-solid +/// entity in place. Supports the common 2D families; `Solid3D` goes through the +/// kernel path instead. Returns the transformed entity (same handle) for the +/// caller to `update_entity`. +pub fn transform_entity_geometry( + entity: &EntityType, + p: &ocs_doc_api::PlacementSpec, +) -> ApiResult { + // Affine point transform: out = R * (s * v) + t. Uniform scale s = |x_axis|. + let s = (p.x_axis[0] * p.x_axis[0] + p.x_axis[1] * p.x_axis[1] + p.x_axis[2] * p.x_axis[2]).sqrt(); + let apply = |v: Vector3| -> Vector3 { + let x = v.x * s; + let y = v.y * s; + let z = v.z * s; + Vector3::new( + p.x_axis[0] * x + p.y_axis[0] * y + p.z_axis[0] * z + p.origin[0], + p.x_axis[1] * x + p.y_axis[1] * y + p.z_axis[1] * z + p.origin[1], + p.x_axis[2] * x + p.y_axis[2] * y + p.z_axis[2] * z + p.origin[2], + ) + }; + let entity = match entity { + EntityType::Line(l) => { + let mut l = l.clone(); + l.start = apply(l.start); + l.end = apply(l.end); + EntityType::Line(l) + } + EntityType::Circle(c) => { + let mut c = c.clone(); + c.center = apply(c.center); + c.radius *= s; + EntityType::Circle(c) + } + EntityType::Arc(a) => { + let mut a = a.clone(); + a.center = apply(a.center); + a.radius *= s; + EntityType::Arc(a) + } + EntityType::Ellipse(e) => { + let mut e = e.clone(); + e.center = apply(e.center); + // Scale the major-axis vector by the uniform scale (rigid similarity). + e.major_axis = Vector3::new( + e.major_axis.x * s, e.major_axis.y * s, e.major_axis.z * s, + ); + EntityType::Ellipse(e) + } + EntityType::Spline(sp) => { + let mut sp = sp.clone(); + for cp in &mut sp.control_points { + *cp = apply(*cp); + } + for fp in &mut sp.fit_points { + *fp = apply(*fp); + } + EntityType::Spline(sp) + } + EntityType::Ray(r) => { + let mut r = r.clone(); + r.base_point = apply(r.base_point); + EntityType::Ray(r) + } + EntityType::XLine(x) => { + let mut x = x.clone(); + x.base_point = apply(x.base_point); + EntityType::XLine(x) + } + EntityType::Point(pt) => { + let mut pt = pt.clone(); + pt.location = apply(pt.location); + EntityType::Point(pt) + } + EntityType::LwPolyline(pl) => { + let mut pl = pl.clone(); + let z0 = pl.elevation; + for v in &mut pl.vertices { + let t = apply(Vector3::new(v.location.x, v.location.y, z0)); + v.location = Vector2::new(t.x, t.y); + pl.elevation = t.z; + } + EntityType::LwPolyline(pl) + } + _ => { + return Err(ApiError::Unsupported( + "transform is supported for Line/Circle/Arc/Point/LwPolyline (and solids) in v1".into(), + )) + } + }; + Ok(entity) +} + fn v3(p: [f64; 3]) -> Vector3 { Vector3::new(p[0], p[1], p[2]) } @@ -46,6 +187,37 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { e.location = v3(*position); EntityType::Point(e) } + Curve2Spec::Arc { centre, radius, start_angle, end_angle } => { + let mut e = Arc::new(); + e.center = v3(*centre); + e.radius = *radius; + e.start_angle = *start_angle; + e.end_angle = *end_angle; + EntityType::Arc(e) + } + Curve2Spec::Ellipse { centre, major_axis, ratio, start, end } => { + let mut e = Ellipse::new(); + e.center = v3(*centre); + e.major_axis = v3(*major_axis); + e.minor_axis_ratio = *ratio; + e.start_parameter = *start; + e.end_parameter = *end; + EntityType::Ellipse(e) + } + Curve2Spec::Spline { degree, control_points, knots, weights } => { + let mut e = Spline::new(); + e.degree = *degree; + e.control_points = control_points.iter().map(|p| v3(*p)).collect(); + e.knots = knots.clone(); + e.weights = weights.clone(); + EntityType::Spline(e) + } + Curve2Spec::Ray { origin, direction } => { + EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) + } + Curve2Spec::XLine { origin, direction } => { + EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) + } }; Ok(entity) } @@ -62,6 +234,8 @@ pub fn entity_kind_name(entity: &EntityType) -> &'static str { EntityType::Arc(_) => "Arc", EntityType::Ellipse(_) => "Ellipse", EntityType::Spline(_) => "Spline", + EntityType::Ray(_) => "Ray", + EntityType::XLine(_) => "XLine", EntityType::Text(_) => "Text", EntityType::MText(_) => "MText", EntityType::Insert(_) => "Insert", @@ -105,6 +279,34 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { + // Coarse: full-circle bounds (exact arc bounds is a later refinement). + Aabb { + min: [a.center.x - a.radius, a.center.y - a.radius, a.center.z], + max: [a.center.x + a.radius, a.center.y + a.radius, a.center.z], + } + } + EntityType::Ellipse(e) => { + // Coarse: use the major-axis length as the bounding radius. + let r = (e.major_axis.x * e.major_axis.x + + e.major_axis.y * e.major_axis.y + + e.major_axis.z * e.major_axis.z) + .sqrt(); + Aabb { + min: [e.center.x - r, e.center.y - r, e.center.z - r], + max: [e.center.x + r, e.center.y + r, e.center.z + r], + } + } + EntityType::Spline(s) if !s.control_points.is_empty() => { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for p in &s.control_points { + min = [min[0].min(p.x), min[1].min(p.y), min[2].min(p.z)]; + max = [max[0].max(p.x), max[1].max(p.y), max[2].max(p.z)]; + } + 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())), }; Ok(bb)