///|
/// VertexSelection selects which vertices a 2D chamfer or fillet operation
/// applies to.
pub(all) enum VertexSelection {
/// Every non-collinear vertex.
All
/// Only vertices that turn toward the material (convex for the profile's
/// winding).
ConvexOnly
/// Only re-entrant vertices.
ConcaveOnly
/// Explicit indices into the input profile.
Indices(Array[Int32])
}
///|
/// ProfileOpError reports why a 2D profile chamfer or fillet was rejected.
pub(all) enum ProfileOpError {
TooFewVertices
NonPositiveSize
FeatureTooLarge(Int32)
VertexIndexOutOfRange(Int32)
SelfIntersection
}
///|
/// ProfileOpError::message returns a human-readable diagnostic.
pub fn ProfileOpError::message(self : ProfileOpError) -> String {
match self {
TooFewVertices => "a profile chamfer/fillet needs at least three vertices"
NonPositiveSize =>
"chamfer distance and fillet radius must be positive and finite"
FeatureTooLarge(i) =>
"the chamfer/fillet at vertex \{i} does not fit on its adjacent edges"
VertexIndexOutOfRange(i) => "vertex index \{i} is out of range"
SelfIntersection =>
"the chamfer/fillet would make the profile self-intersect"
}
}
///|
fn seg_orient(a : Vec2, b : Vec2, c : Vec2) -> Double {
(b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
}
///|
fn seg_crosses(a0 : Vec2, a1 : Vec2, b0 : Vec2, b1 : Vec2) -> Bool {
let o1 = seg_orient(a0, a1, b0)
let o2 = seg_orient(a0, a1, b1)
let o3 = seg_orient(b0, b1, a0)
let o4 = seg_orient(b0, b1, a1)
o1 * o2 < -1.0e-12 && o3 * o4 < -1.0e-12
}
///|
/// Profile2D::is_simple reports whether the profile is a simple closed polygon:
/// no zero-length edges and no proper edge crossings between non-adjacent
/// edges.
pub fn Profile2D::is_simple(pts : Array[Vec2]) -> Bool {
let n = pts.length()
if n < 3 {
return false
}
for i in 0.. Vec2 {
(a.0 - b.0, a.1 - b.1)
}
///|
fn v2_norm(a : Vec2) -> Vec2 {
let l = Double::sqrt(a.0 * a.0 + a.1 * a.1)
if l == 0.0 {
(1.0, 0.0)
} else {
(a.0 / l, a.1 / l)
}
}
///|
fn v2_dot(a : Vec2, b : Vec2) -> Double {
a.0 * b.0 + a.1 * b.1
}
///|
/// profile_arc_points samples the minor arc from `t1` to `t2` about `center`.
fn profile_arc_points(
center : Vec2,
t1 : Vec2,
t2 : Vec2,
segments : Int32,
) -> Array[Vec2] {
let a0 = @math.atan2(t1.1 - center.1, t1.0 - center.0)
let a1 = @math.atan2(t2.1 - center.1, t2.0 - center.0)
let mut delta = a1 - a0
let two_pi = 2.0 * @math.PI
while delta > @math.PI {
delta = delta - two_pi
}
while delta <= -@math.PI {
delta = delta + two_pi
}
let rho = Double::sqrt(
(t1.0 - center.0) * (t1.0 - center.0) +
(t1.1 - center.1) * (t1.1 - center.1),
)
let n = if segments < 1 { 1 } else { segments }
let out : Array[Vec2] = []
for i in 0..<=n {
let t = Double::from_int(i) / Double::from_int(n)
let ang = a0 + delta * t
out.push((center.0 + rho * @math.cos(ang), center.1 + rho * @math.sin(ang)))
}
out
}
///|
/// profile_vertex_cross returns the signed turn at `cur` for `prev -> cur ->
/// next`. For a counter-clockwise profile a positive value is convex.
fn profile_vertex_cross(prev : Vec2, cur : Vec2, next : Vec2) -> Double {
(cur.0 - prev.0) * (next.1 - cur.1) - (cur.1 - prev.1) * (next.0 - cur.0)
}
///|
/// profile_selected_mask resolves a `VertexSelection` into per-vertex flags.
fn profile_selected_mask(
pts : Array[Vec2],
which : VertexSelection,
) -> Result[Array[Bool], ProfileOpError] {
let n = pts.length()
let winding = if polygon_area_2d(pts) >= 0.0 { 1.0 } else { -1.0 }
let mask : Array[Bool] = []
for _ in 0..
for raw in idxs {
if raw < 0 || raw >= n {
return Err(VertexIndexOutOfRange(raw))
}
mask[raw] = true
}
_ =>
for i in 0.. 0.0
match which {
All => mask[i] = true
ConvexOnly => if convex { mask[i] = true }
ConcaveOnly => if !convex { mask[i] = true }
Indices(_) => ()
}
}
}
Ok(mask)
}
///|
/// profile_edge_lengths returns the length of every edge i -> i+1.
fn profile_edge_lengths(pts : Array[Vec2]) -> Array[Double] {
let n = pts.length()
let out : Array[Double] = []
for i in 0.. Double {
let c = v2_dot(u1, u2)
if c > 1.0 {
0.0
} else if c < -1.0 {
@math.PI
} else {
@math.acos(c)
}
}
///|
/// Profile2D::chamfer_vertices truncates selected vertices with a straight
/// bevel.
///
/// `distance` is the leg length measured along the edge toward the previous
/// vertex. When `angle_deg` is supplied it is the angle between the chamfer
/// line and that incoming edge (measured inside the material); the leg along
/// the outgoing edge then follows from the triangle. Without an angle the
/// chamfer has equal legs.
///
/// The input winding is preserved. Convex vertices are cut; re-entrant vertices
/// have their notch beveled. The result is a simple closed polygon suitable for
/// `ExtrudedProfile`.
pub fn Profile2D::chamfer_vertices(
profile : Array[Vec2],
distance : Double,
angle_deg? : Double,
which~ : VertexSelection = All,
) -> Result[Array[Vec2], ProfileOpError] {
let n = profile.length()
if n < 3 {
return Err(TooFewVertices)
}
if !(distance > 0.0) || distance > 1.0e300 {
return Err(NonPositiveSize)
}
let mask = match profile_selected_mask(profile, which) {
Ok(m) => m
Err(e) => return Err(e)
}
let t_in : Array[Double] = []
let t_out : Array[Double] = []
for _ in 0..= @math.PI - 1.0e-9 {
continue
}
match angle_deg {
None => {
t_in[i] = distance
t_out[i] = distance
}
Some(a_deg) => {
let a = a_deg * @math.PI / 180.0
let denom = @math.sin(theta + a)
if denom.abs() < 1.0e-9 {
return Err(FeatureTooLarge(i))
}
t_in[i] = distance
t_out[i] = distance * @math.sin(a) / denom
if !(t_out[i] > 0.0) || t_out[i] > 1.0e300 {
return Err(FeatureTooLarge(i))
}
}
}
}
profile_build_corners(profile, mask, t_in, t_out, None, 0)
}
///|
/// Profile2D::fillet_vertices rounds selected vertices with a circular arc of
/// `radius` tangent to both adjacent edges. `segments` controls the arc
/// subdivisions per corner.
///
/// The input winding is preserved. Convex vertices are rounded (material
/// removed); re-entrant vertices are rounded with the arc filling the notch.
pub fn Profile2D::fillet_vertices(
profile : Array[Vec2],
radius : Double,
segments~ : Int32 = 8,
which~ : VertexSelection = All,
) -> Result[Array[Vec2], ProfileOpError] {
let n = profile.length()
if n < 3 {
return Err(TooFewVertices)
}
if !(radius > 0.0) || radius > 1.0e300 {
return Err(NonPositiveSize)
}
let mask = match profile_selected_mask(profile, which) {
Ok(m) => m
Err(e) => return Err(e)
}
let t_in : Array[Double] = []
let t_out : Array[Double] = []
for _ in 0..= @math.PI - 1.0e-9 {
continue
}
let t = radius / @math.tan(theta / 2.0)
t_in[i] = t
t_out[i] = t
}
profile_build_corners(profile, mask, t_in, t_out, Some(radius), segments)
}
///|
/// profile_build_corners replaces selected vertices with a chamfer or fillet.
fn profile_build_corners(
profile : Array[Vec2],
mask : Array[Bool],
t_in : Array[Double],
t_out : Array[Double],
fillet_radius : Double?,
segments : Int32,
) -> Result[Array[Vec2], ProfileOpError] {
let n = profile.length()
let lengths = profile_edge_lengths(profile)
// Edge i -> i+1 consumes t_out[i] at its start and t_in[i+1] at its end.
for i in 0.. lengths[i] + 1.0e-9 {
return Err(FeatureTooLarge(i))
}
}
let out : Array[Vec2] = []
for i in 0.. {
out.push(t1)
out.push(t2)
}
Some(radius) => {
let theta = profile_corner_angle(u1, u2)
let bis = v2_norm((u1.0 + u2.0, u1.1 + u2.1))
let center_dist = radius / @math.sin(theta / 2.0)
let center = (cur.0 + bis.0 * center_dist, cur.1 + bis.1 * center_dist)
let arc = profile_arc_points(center, t1, t2, segments)
out.push_iter(arc.iter())
}
}
}
if !Profile2D::is_simple(out) {
return Err(SelfIntersection)
}
Ok(out)
}