ocs_doc_api: move doc_api_convert.rs into the crate (src/convert.rs)

Move the 503-line host-independent entity-conversion module (acadrust EntityType
<-> DTO conversions, transform_entity_geometry, entity_to_profile_curves,
bulge_arc_segment, entity_bounds, entity_kind_name, points_aabb) from
src/app/doc_api_convert.rs into crates/ocs_doc_api/src/convert.rs (host feature).

The host file is now a thin re-export (pub use ocs_doc_api::convert::*) so all
existing crate::app::doc_api_convert::* paths keep working unchanged. Zero
coupling risk (the module only touches acadrust + crate DTOs).

623 host lib + 8 crate + 3 consistency green.
This commit is contained in:
Sebastian committed 2026-09-06 17:00:04 +02:00
1 parent d1b8fc93f5
commit d746d635fb
3 files changed
+522 -503

No files matched your search

+505
View File
@@ -0,0 +1,505 @@
//! Entity conversions (plan §5.1): map the crate's plain-data DTOs to acadrust
//! `EntityType` and back. Host feature only (uses the real acadrust types). This
//! module is host-side-capable but host-independent — it operates purely on
//! acadrust entities and crate DTOs, so any host can reuse it.
use acadrust::entities::{Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine};
use acadrust::types::{Vector2, Vector3};
use acadrust::EntityType;
use crate::{Aabb, ApiError, ApiResult, Curve2Spec, GeometryErrorKind, 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<Vec<cadkernel::geom2d::Curve>> {
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"));
}
// 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 {
segs.push(bulge_arc_segment(start, end, a.bulge)?);
}
}
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: &crate::PlacementSpec,
) -> ApiResult<EntityType> {
// Placement = R·S·v + t where the axis COLUMNS carry rotation AND uniform
// scale (|x_axis| = s). A point maps v -> axes·v + origin (axes already embed
// s — do NOT pre-multiply by s or scale is applied twice). A direction maps
// v -> axes·v (no origin). Scalar fields (radius) scale by 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();
// Z-rotation of the placement in the XY plane (for arc angles / insert rotation).
let rot_z = p.x_axis[1].atan2(p.x_axis[0]);
let apply = |v: Vector3| -> Vector3 {
Vector3::new(
p.x_axis[0] * v.x + p.y_axis[0] * v.y + p.z_axis[0] * v.z + p.origin[0],
p.x_axis[1] * v.x + p.y_axis[1] * v.y + p.z_axis[1] * v.z + p.origin[1],
p.x_axis[2] * v.x + p.y_axis[2] * v.y + p.z_axis[2] * v.z + p.origin[2],
)
};
// Direction/vector transform: rotation+scale only, NO translation.
let apply_dir = |v: Vector3| -> Vector3 {
Vector3::new(
p.x_axis[0] * v.x + p.y_axis[0] * v.y + p.z_axis[0] * v.z,
p.x_axis[1] * v.x + p.y_axis[1] * v.y + p.z_axis[1] * v.z,
p.x_axis[2] * v.x + p.y_axis[2] * v.y + p.z_axis[2] * v.z,
)
};
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;
// A Z-rotation shifts both angles by the placement's XY rotation.
a.start_angle += rot_z;
a.end_angle += rot_z;
EntityType::Arc(a)
}
EntityType::Ellipse(e) => {
let mut e = e.clone();
e.center = apply(e.center);
// The major-axis is a direction+length vector: rotate+scale it (no origin).
e.major_axis = apply_dir(e.major_axis);
// A Z-rotation also shifts the partial-arc sweep (eccentric-anomaly params).
e.start_parameter += rot_z;
e.end_parameter += rot_z;
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);
r.direction = apply_dir(r.direction);
EntityType::Ray(r)
}
EntityType::XLine(x) => {
let mut x = x.clone();
x.base_point = apply(x.base_point);
x.direction = apply_dir(x.direction);
EntityType::XLine(x)
}
EntityType::Insert(ins) => {
let mut ins = ins.clone();
ins.insert_point = apply(ins.insert_point);
// Compose the block rotation with the placement's Z-rotation.
ins.rotation += rot_z;
EntityType::Insert(ins)
}
EntityType::Viewport(vp) => {
let mut vp = vp.clone();
vp.center = apply(vp.center);
EntityType::Viewport(vp)
}
EntityType::Text(t) => {
let mut t = t.clone();
t.insertion_point = apply(t.insertion_point);
EntityType::Text(t)
}
EntityType::MText(t) => {
let mut t = t.clone();
t.insertion_point = apply(t.insertion_point);
EntityType::MText(t)
}
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])
}
/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a
/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise.
pub 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. The apothem MAGNITUDE is
// (chord/2)/|tan(θ/2)| (always positive); the SIGN of the bulge alone picks the
// side (CCW=+90° perp, CW=-90° perp). Using signed tan(half) AND the sign flip
// would double-count and mirror CW arcs to the wrong side.
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().abs();
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 {
Curve2Spec::Line { start, end } => {
let mut e = Line::new();
e.start = v3(*start);
e.end = v3(*end);
EntityType::Line(e)
}
Curve2Spec::Circle { centre, radius } => {
let mut e = Circle::new();
e.center = v3(*centre);
e.radius = *radius;
EntityType::Circle(e)
}
Curve2Spec::Polyline { points, closed } => {
let mut e = LwPolyline::new();
e.vertices = points
.iter()
.map(|p| LwVertex {
location: Vector2::new(p[0], p[1]),
bulge: 0.0,
start_width: 0.0,
end_width: 0.0,
vertex_id: 0,
})
.collect();
e.is_closed = *closed;
EntityType::LwPolyline(e)
}
Curve2Spec::Point { position } => {
let mut e = Point::new();
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 } => {
// Validate the minor/major ratio so the (major-axis) bounds never
// under-bound: ratio must be in (0, 1].
if !(*ratio > 0.0 && *ratio <= 1.0) {
return Err(ApiError::validation(
"CreateCurve",
format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"),
));
}
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)
}
/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts).
pub fn entity_kind_name(entity: &EntityType) -> &'static str {
match entity {
EntityType::Solid3D(_) => "Solid3D",
EntityType::Line(_) => "Line",
EntityType::Circle(_) => "Circle",
EntityType::LwPolyline(_) => "LwPolyline",
EntityType::Polyline(_) => "Polyline",
EntityType::Point(_) => "Point",
EntityType::Arc(_) => "Arc",
EntityType::Ellipse(_) => "Ellipse",
EntityType::Spline(_) => "Spline",
EntityType::Ray(_) => "Ray",
EntityType::XLine(_) => "XLine",
EntityType::Insert(_) => "Insert",
EntityType::Viewport(_) => "Viewport",
EntityType::Text(_) => "Text",
EntityType::MText(_) => "MText",
EntityType::AttributeDefinition(_) => "AttributeDefinition",
EntityType::AttributeEntity(_) => "AttributeEntity",
// Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind.
EntityType::RasterImage(_) => "RasterImage",
EntityType::Table(_) => "Table",
EntityType::Leader(_) => "Leader",
EntityType::MultiLeader(_) => "MultiLeader",
EntityType::MLine(_) => "MLine",
EntityType::Mesh(_) => "Mesh",
EntityType::Helix(_) => "Helix",
EntityType::Region(_) => "Region",
EntityType::Body(_) => "Body",
EntityType::Surface(_) => "Surface",
EntityType::Face3D(_) => "Face3D",
EntityType::Dimension(_) => "Dimension",
EntityType::Hatch(_) => "Hatch",
_ => "Other",
}
}
/// Coarse bounds for a non-solid entity (solids go through the kernel cache).
/// For v1 typed curves we compute the obvious bounds; anything else errors
/// `Unsupported` (viewport/annotation bounds land in later phases).
pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult<Aabb> {
let entity = entity.ok_or(ApiError::UnknownId(id))?;
let bb = match entity {
EntityType::Line(l) => Aabb {
min: [
l.start.x.min(l.end.x),
l.start.y.min(l.end.y),
l.start.z.min(l.end.z),
],
max: [
l.start.x.max(l.end.x),
l.start.y.max(l.end.y),
l.start.z.max(l.end.z),
],
},
EntityType::Circle(c) => Aabb {
min: [c.center.x - c.radius, c.center.y - c.radius, c.center.z],
max: [c.center.x + c.radius, c.center.y + c.radius, c.center.z],
},
EntityType::Point(p) => Aabb {
min: [p.location.x, p.location.y, p.location.z],
max: [p.location.x, p.location.y, p.location.z],
},
EntityType::LwPolyline(pl) if !pl.vertices.is_empty() => {
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for vtx in &pl.vertices {
let (x, y, z) = (vtx.location.x, vtx.location.y, pl.elevation);
min = [min[0].min(x), min[1].min(y), min[2].min(z)];
max = [max[0].max(x), max[1].max(y), max[2].max(z)];
}
Aabb { min, max }
}
EntityType::Arc(a) => {
// 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) => points_aabb(s.control_points.iter().collect(), id)?,
EntityType::Viewport(vp) => Aabb {
min: [
vp.center.x - vp.width / 2.0,
vp.center.y - vp.height / 2.0,
vp.center.z,
],
max: [
vp.center.x + vp.width / 2.0,
vp.center.y + vp.height / 2.0,
vp.center.z,
],
},
EntityType::RasterImage(img) => {
// Bracket ALL FOUR image corners (origin, +u*W, +v*H, +u*W+v*H) per
// axis so rotated/flipped images are not under-bounded.
let (w, h) = (img.size.x, img.size.y);
let ux = img.u_vector.x * w;
let uy = img.u_vector.y * w;
let vx = img.v_vector.x * h;
let vy = img.v_vector.y * h;
let x0 = img.insertion_point.x;
let y0 = img.insertion_point.y;
let xs = [x0, x0 + ux, x0 + vx, x0 + ux + vx];
let ys = [y0, y0 + uy, y0 + vy, y0 + uy + vy];
let min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min);
let max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min_y = ys.iter().cloned().fold(f64::INFINITY, f64::min);
let max_y = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
Aabb {
min: [min_x, min_y, img.insertion_point.z],
max: [max_x, max_y, img.insertion_point.z],
}
}
EntityType::Text(t) => Aabb {
// Coarse: a square of side height*N around the insertion point.
min: [t.insertion_point.x, t.insertion_point.y - t.height, t.insertion_point.z],
max: [t.insertion_point.x + t.height * t.value.len() as f64, t.insertion_point.y, t.insertion_point.z],
},
EntityType::MText(t) => Aabb {
min: [t.insertion_point.x, t.insertion_point.y - t.height, t.insertion_point.z],
max: [t.insertion_point.x + t.height * t.value.len() as f64, t.insertion_point.y, t.insertion_point.z],
},
EntityType::Dimension(acadrust::entities::Dimension::Linear(d)) => points_aabb(
[&d.first_point, &d.second_point, &d.definition_point].into_iter().collect(),
id,
)?,
EntityType::Hatch(h) => {
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 }
}
// Read-mostly families with vertex/point data: bounds from their points.
EntityType::Leader(l) => points_aabb(l.vertices.iter().collect(), id)?,
EntityType::Mesh(m) => points_aabb(m.vertices.iter().collect(), id)?,
EntityType::Face3D(f) => points_aabb(
[&f.first_corner, &f.second_corner, &f.third_corner, &f.fourth_corner].into_iter().collect(),
id,
)?,
EntityType::MLine(m) => points_aabb(m.vertices.iter().map(|v| &v.position).collect(), id)?,
EntityType::Helix(h) => {
// Bound the evaluated spline's control polygon (conservative); the
// single start_point alone would be a degenerate zero-size box.
if h.spline.control_points.is_empty() {
points_aabb([&h.start_point].into_iter().collect(), id)?
} else {
points_aabb(h.spline.control_points.iter().collect(), id)?
}
}
// 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)
}
/// AABB over a set of 3D points. A geometry-Empty error if there are no points
/// to bound (the entity exists — do NOT misclassify as a stale/deleted id).
fn points_aabb(pts: Vec<&Vector3>, _id: ObjectId) -> ApiResult<Aabb> {
if pts.is_empty() {
return Err(ApiError::geometry(GeometryErrorKind::Empty, "entity has no points to bound"));
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for p in pts {
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)];
}
Ok(Aabb { min, max })
}
+8
View File
@@ -21,6 +21,8 @@ pub mod backend;
pub mod executor;
#[cfg(feature = "host")]
pub mod geom;
#[cfg(feature = "host")]
pub mod convert;
pub mod envelope;
pub mod facade;
@@ -52,6 +54,12 @@ pub use facade::{
};
pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive};
pub use query::{Aabb, EntityView, Query, QueryResult};
#[cfg(feature = "host")]
pub use convert::{
bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name,
entity_to_profile_curves, transform_entity_geometry,
};
pub use transport::Transport;
/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`).
+9 -503
View File
@@ -1,503 +1,9 @@
// DocApi v2 entity conversions (plan §5.1): map the crate's plain-data DTOs to
// acadrust `EntityType` and back. Lives in the host (uses the real acadrust
// types), keeping the crate's DTOs dependency-light.
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, GeometryErrorKind, 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<Vec<cadkernel::geom2d::Curve>> {
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"));
}
// 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 {
segs.push(bulge_arc_segment(start, end, a.bulge)?);
}
}
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<EntityType> {
// Placement = R·S·v + t where the axis COLUMNS carry rotation AND uniform
// scale (|x_axis| = s). A point maps v -> axes·v + origin (axes already embed
// s — do NOT pre-multiply by s or scale is applied twice). A direction maps
// v -> axes·v (no origin). Scalar fields (radius) scale by 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();
// Z-rotation of the placement in the XY plane (for arc angles / insert rotation).
let rot_z = p.x_axis[1].atan2(p.x_axis[0]);
let apply = |v: Vector3| -> Vector3 {
Vector3::new(
p.x_axis[0] * v.x + p.y_axis[0] * v.y + p.z_axis[0] * v.z + p.origin[0],
p.x_axis[1] * v.x + p.y_axis[1] * v.y + p.z_axis[1] * v.z + p.origin[1],
p.x_axis[2] * v.x + p.y_axis[2] * v.y + p.z_axis[2] * v.z + p.origin[2],
)
};
// Direction/vector transform: rotation+scale only, NO translation.
let apply_dir = |v: Vector3| -> Vector3 {
Vector3::new(
p.x_axis[0] * v.x + p.y_axis[0] * v.y + p.z_axis[0] * v.z,
p.x_axis[1] * v.x + p.y_axis[1] * v.y + p.z_axis[1] * v.z,
p.x_axis[2] * v.x + p.y_axis[2] * v.y + p.z_axis[2] * v.z,
)
};
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;
// A Z-rotation shifts both angles by the placement's XY rotation.
a.start_angle += rot_z;
a.end_angle += rot_z;
EntityType::Arc(a)
}
EntityType::Ellipse(e) => {
let mut e = e.clone();
e.center = apply(e.center);
// The major-axis is a direction+length vector: rotate+scale it (no origin).
e.major_axis = apply_dir(e.major_axis);
// A Z-rotation also shifts the partial-arc sweep (eccentric-anomaly params).
e.start_parameter += rot_z;
e.end_parameter += rot_z;
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);
r.direction = apply_dir(r.direction);
EntityType::Ray(r)
}
EntityType::XLine(x) => {
let mut x = x.clone();
x.base_point = apply(x.base_point);
x.direction = apply_dir(x.direction);
EntityType::XLine(x)
}
EntityType::Insert(ins) => {
let mut ins = ins.clone();
ins.insert_point = apply(ins.insert_point);
// Compose the block rotation with the placement's Z-rotation.
ins.rotation += rot_z;
EntityType::Insert(ins)
}
EntityType::Viewport(vp) => {
let mut vp = vp.clone();
vp.center = apply(vp.center);
EntityType::Viewport(vp)
}
EntityType::Text(t) => {
let mut t = t.clone();
t.insertion_point = apply(t.insertion_point);
EntityType::Text(t)
}
EntityType::MText(t) => {
let mut t = t.clone();
t.insertion_point = apply(t.insertion_point);
EntityType::MText(t)
}
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])
}
/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a
/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise.
pub(crate) 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. The apothem MAGNITUDE is
// (chord/2)/|tan(θ/2)| (always positive); the SIGN of the bulge alone picks the
// side (CCW=+90° perp, CW=-90° perp). Using signed tan(half) AND the sign flip
// would double-count and mirror CW arcs to the wrong side.
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().abs();
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 {
Curve2Spec::Line { start, end } => {
let mut e = Line::new();
e.start = v3(*start);
e.end = v3(*end);
EntityType::Line(e)
}
Curve2Spec::Circle { centre, radius } => {
let mut e = Circle::new();
e.center = v3(*centre);
e.radius = *radius;
EntityType::Circle(e)
}
Curve2Spec::Polyline { points, closed } => {
let mut e = LwPolyline::new();
e.vertices = points
.iter()
.map(|p| LwVertex {
location: Vector2::new(p[0], p[1]),
bulge: 0.0,
start_width: 0.0,
end_width: 0.0,
vertex_id: 0,
})
.collect();
e.is_closed = *closed;
EntityType::LwPolyline(e)
}
Curve2Spec::Point { position } => {
let mut e = Point::new();
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 } => {
// Validate the minor/major ratio so the (major-axis) bounds never
// under-bound: ratio must be in (0, 1].
if !(*ratio > 0.0 && *ratio <= 1.0) {
return Err(ApiError::validation(
"CreateCurve",
format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"),
));
}
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)
}
/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts).
pub fn entity_kind_name(entity: &EntityType) -> &'static str {
match entity {
EntityType::Solid3D(_) => "Solid3D",
EntityType::Line(_) => "Line",
EntityType::Circle(_) => "Circle",
EntityType::LwPolyline(_) => "LwPolyline",
EntityType::Polyline(_) => "Polyline",
EntityType::Point(_) => "Point",
EntityType::Arc(_) => "Arc",
EntityType::Ellipse(_) => "Ellipse",
EntityType::Spline(_) => "Spline",
EntityType::Ray(_) => "Ray",
EntityType::XLine(_) => "XLine",
EntityType::Insert(_) => "Insert",
EntityType::Viewport(_) => "Viewport",
EntityType::Text(_) => "Text",
EntityType::MText(_) => "MText",
EntityType::AttributeDefinition(_) => "AttributeDefinition",
EntityType::AttributeEntity(_) => "AttributeEntity",
// Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind.
EntityType::RasterImage(_) => "RasterImage",
EntityType::Table(_) => "Table",
EntityType::Leader(_) => "Leader",
EntityType::MultiLeader(_) => "MultiLeader",
EntityType::MLine(_) => "MLine",
EntityType::Mesh(_) => "Mesh",
EntityType::Helix(_) => "Helix",
EntityType::Region(_) => "Region",
EntityType::Body(_) => "Body",
EntityType::Surface(_) => "Surface",
EntityType::Face3D(_) => "Face3D",
EntityType::Dimension(_) => "Dimension",
EntityType::Hatch(_) => "Hatch",
_ => "Other",
}
}
/// Coarse bounds for a non-solid entity (solids go through the kernel cache).
/// For v1 typed curves we compute the obvious bounds; anything else errors
/// `Unsupported` (viewport/annotation bounds land in later phases).
pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult<Aabb> {
let entity = entity.ok_or(ApiError::UnknownId(id))?;
let bb = match entity {
EntityType::Line(l) => Aabb {
min: [
l.start.x.min(l.end.x),
l.start.y.min(l.end.y),
l.start.z.min(l.end.z),
],
max: [
l.start.x.max(l.end.x),
l.start.y.max(l.end.y),
l.start.z.max(l.end.z),
],
},
EntityType::Circle(c) => Aabb {
min: [c.center.x - c.radius, c.center.y - c.radius, c.center.z],
max: [c.center.x + c.radius, c.center.y + c.radius, c.center.z],
},
EntityType::Point(p) => Aabb {
min: [p.location.x, p.location.y, p.location.z],
max: [p.location.x, p.location.y, p.location.z],
},
EntityType::LwPolyline(pl) if !pl.vertices.is_empty() => {
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for vtx in &pl.vertices {
let (x, y, z) = (vtx.location.x, vtx.location.y, pl.elevation);
min = [min[0].min(x), min[1].min(y), min[2].min(z)];
max = [max[0].max(x), max[1].max(y), max[2].max(z)];
}
Aabb { min, max }
}
EntityType::Arc(a) => {
// 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) => points_aabb(s.control_points.iter().collect(), id)?,
EntityType::Viewport(vp) => Aabb {
min: [
vp.center.x - vp.width / 2.0,
vp.center.y - vp.height / 2.0,
vp.center.z,
],
max: [
vp.center.x + vp.width / 2.0,
vp.center.y + vp.height / 2.0,
vp.center.z,
],
},
EntityType::RasterImage(img) => {
// Bracket ALL FOUR image corners (origin, +u*W, +v*H, +u*W+v*H) per
// axis so rotated/flipped images are not under-bounded.
let (w, h) = (img.size.x, img.size.y);
let ux = img.u_vector.x * w;
let uy = img.u_vector.y * w;
let vx = img.v_vector.x * h;
let vy = img.v_vector.y * h;
let x0 = img.insertion_point.x;
let y0 = img.insertion_point.y;
let xs = [x0, x0 + ux, x0 + vx, x0 + ux + vx];
let ys = [y0, y0 + uy, y0 + vy, y0 + uy + vy];
let min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min);
let max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min_y = ys.iter().cloned().fold(f64::INFINITY, f64::min);
let max_y = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
Aabb {
min: [min_x, min_y, img.insertion_point.z],
max: [max_x, max_y, img.insertion_point.z],
}
}
EntityType::Text(t) => Aabb {
// Coarse: a square of side height*N around the insertion point.
min: [t.insertion_point.x, t.insertion_point.y - t.height, t.insertion_point.z],
max: [t.insertion_point.x + t.height * t.value.len() as f64, t.insertion_point.y, t.insertion_point.z],
},
EntityType::MText(t) => Aabb {
min: [t.insertion_point.x, t.insertion_point.y - t.height, t.insertion_point.z],
max: [t.insertion_point.x + t.height * t.value.len() as f64, t.insertion_point.y, t.insertion_point.z],
},
EntityType::Dimension(acadrust::entities::Dimension::Linear(d)) => points_aabb(
[&d.first_point, &d.second_point, &d.definition_point].into_iter().collect(),
id,
)?,
EntityType::Hatch(h) => {
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 }
}
// Read-mostly families with vertex/point data: bounds from their points.
EntityType::Leader(l) => points_aabb(l.vertices.iter().collect(), id)?,
EntityType::Mesh(m) => points_aabb(m.vertices.iter().collect(), id)?,
EntityType::Face3D(f) => points_aabb(
[&f.first_corner, &f.second_corner, &f.third_corner, &f.fourth_corner].into_iter().collect(),
id,
)?,
EntityType::MLine(m) => points_aabb(m.vertices.iter().map(|v| &v.position).collect(), id)?,
EntityType::Helix(h) => {
// Bound the evaluated spline's control polygon (conservative); the
// single start_point alone would be a degenerate zero-size box.
if h.spline.control_points.is_empty() {
points_aabb([&h.start_point].into_iter().collect(), id)?
} else {
points_aabb(h.spline.control_points.iter().collect(), id)?
}
}
// 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)
}
/// AABB over a set of 3D points. A geometry-Empty error if there are no points
/// to bound (the entity exists — do NOT misclassify as a stale/deleted id).
fn points_aabb(pts: Vec<&Vector3>, _id: ObjectId) -> ApiResult<Aabb> {
if pts.is_empty() {
return Err(ApiError::geometry(GeometryErrorKind::Empty, "entity has no points to bound"));
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for p in pts {
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)];
}
Ok(Aabb { min, max })
}
// DocApi v2 entity conversions now live in the crate (`ocs_doc_api::convert`),
// moved out of the host to minimize /src surface (the conversion logic is
// host-independent: acadrust + crate DTOs only). Re-exported here so existing
// `crate::app::doc_api_convert::*` paths keep working unchanged.
#[allow(unused_imports)] // re-export for tests + downstream; non-test build may not use all of it
pub use ocs_doc_api::convert::{
bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name,
entity_to_profile_curves, transform_entity_geometry,
};