feat: free-form cut-outs stay exact in sheet-metal flat patterns (#692)
Sheet-metal cut-outs and base flange outlines drawn with ellipses, splines or text stay exact curves in the flat pattern and the unfolded body; the folded body follows them with tangent arcs within 0.001 mm (#659).
This commit is contained in:
1 parent
eac4d66adc
commit
4ff8f3e14d
8 files changed
+894
-70
No files matched your search
@@ -0,0 +1,391 @@
|
||||
//! Arc fitting: free-form segments (conics, cubic and higher Béziers) as chains of circular
|
||||
//! arcs and lines within a tolerance, tangent-continuous inside each segment (biarcs).
|
||||
//!
|
||||
//! Code that works on lines and arcs alone (planar booleans, the folded sheet-metal solid)
|
||||
//! keeps smooth curves this way with far fewer pieces than a chord polyline of the same
|
||||
//! tolerance: a biarc's error falls with the cube of its length, a chord's with the square.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use crate::{Loop2, Region2, Seg2, Vec2};
|
||||
|
||||
/// Most pieces one segment is fitted with.
|
||||
pub const MAX_FIT_PIECES: usize = 2048;
|
||||
/// Deepest subdivision of one segment.
|
||||
const MAX_DEPTH: u32 = 30;
|
||||
/// Samples inside each candidate span that must lie within the tolerance.
|
||||
const SAMPLES: usize = 12;
|
||||
|
||||
impl Seg2 {
|
||||
/// A straight line or a circular arc (what [`Seg2::fit_arcs`] produces).
|
||||
pub fn is_line_or_arc(&self) -> bool {
|
||||
matches!(self, Seg2::Line { .. } | Seg2::Arc { .. })
|
||||
}
|
||||
|
||||
/// Lines and circular arcs that follow the segment within `tol` (mm), in order, the first
|
||||
/// starting at the segment's start and the last ending at its end; consecutive pieces share
|
||||
/// their end points and tangents. Lines and arcs come back unchanged. `None` when the
|
||||
/// segment is not finite or would need more than [`MAX_FIT_PIECES`] pieces.
|
||||
pub fn fit_arcs(&self, tol: f64) -> Option<Vec<Seg2>> {
|
||||
if self.is_line_or_arc() {
|
||||
return Some(vec![*self]);
|
||||
}
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let tol = tol.max(1e-9);
|
||||
let mut out: Vec<Seg2> = Vec::new();
|
||||
// Spans still to fit; the last pushed is fitted first, so pushing the right half before
|
||||
// the left one keeps the output in parameter order.
|
||||
let mut todo = vec![(0.0f64, 1.0f64, 0u32)];
|
||||
let mut steps = 0usize;
|
||||
while let Some((t0, t1, depth)) = todo.pop() {
|
||||
steps += 1;
|
||||
if steps > 4 * MAX_FIT_PIECES {
|
||||
return None;
|
||||
}
|
||||
let (p0, p1) = (self.point_at(t0), self.point_at(t1));
|
||||
if !(p0.is_finite() && p1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if p0.dist(p1) < 1e-12 && self.span_extent(t0, t1) < tol {
|
||||
continue;
|
||||
}
|
||||
let fit = match (self.tangent_out(t0, t1), self.tangent_in(t0, t1)) {
|
||||
(Some(d0), Some(d1)) => biarc(p0, d0, p1, d1).filter(|arcs| self.within(arcs, t0, t1, tol)),
|
||||
_ => None,
|
||||
};
|
||||
match fit {
|
||||
Some(arcs) => {
|
||||
out.extend(arcs);
|
||||
if out.len() > MAX_FIT_PIECES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None if depth < MAX_DEPTH => {
|
||||
let tm = 0.5 * (t0 + t1);
|
||||
todo.push((tm, t1, depth + 1));
|
||||
todo.push((t0, tm, depth + 1));
|
||||
}
|
||||
None => {
|
||||
// Too fine to split further (a cusp): the chord is within the tolerance
|
||||
// here or nothing is.
|
||||
let chord = [Seg2::Line { a: p0, b: p1 }];
|
||||
if !self.within(&chord, t0, t1, tol) {
|
||||
return None;
|
||||
}
|
||||
out.push(chord[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The pieces join where their spans meet; pin the outer ends to the segment's own.
|
||||
if let Some(first) = out.first_mut() {
|
||||
*first = with_start(*first, self.start());
|
||||
}
|
||||
if let Some(last) = out.last_mut() {
|
||||
*last = with_end(*last, self.end());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Unit direction of travel leaving `t` towards `t1`.
|
||||
fn tangent_out(&self, t: f64, t1: f64) -> Option<Vec2> {
|
||||
self.derivative(t).normalized().or_else(|| (self.point_at(t + (t1 - t) * 1e-4) - self.point_at(t)).normalized())
|
||||
}
|
||||
|
||||
/// Unit direction of travel arriving at `t1` from `t0`.
|
||||
fn tangent_in(&self, t0: f64, t1: f64) -> Option<Vec2> {
|
||||
self.derivative(t1).normalized().or_else(|| (self.point_at(t1) - self.point_at(t1 - (t1 - t0) * 1e-4)).normalized())
|
||||
}
|
||||
|
||||
/// Largest distance of the span's sample points from its start (a loop's size).
|
||||
fn span_extent(&self, t0: f64, t1: f64) -> f64 {
|
||||
let p0 = self.point_at(t0);
|
||||
(1..=SAMPLES).map(|i| self.point_at(t0 + (t1 - t0) * i as f64 / (SAMPLES + 1) as f64).dist(p0)).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Do `pieces` follow the span `t0..t1` within `tol` both ways?
|
||||
fn within(&self, pieces: &[Seg2], t0: f64, t1: f64, tol: f64) -> bool {
|
||||
if pieces.iter().any(|p| matches!(p, Seg2::Arc { sweep, .. } if sweep.abs() > PI)) {
|
||||
return false;
|
||||
}
|
||||
let samples: Vec<Vec2> = (0..=SAMPLES + 1).map(|i| self.point_at(t0 + (t1 - t0) * i as f64 / (SAMPLES + 1) as f64)).collect();
|
||||
// The curve near the pieces...
|
||||
if samples.iter().any(|p| pieces.iter().map(|s| dist_to(s, *p)).fold(f64::INFINITY, f64::min) > tol) {
|
||||
return false;
|
||||
}
|
||||
// ...and the pieces near the curve (their quarter points against the curve's closest
|
||||
// point on the span).
|
||||
pieces.iter().all(|s| {
|
||||
[0.25, 0.5, 0.75].iter().all(|f| {
|
||||
let q = s.point_at(*f);
|
||||
self.dist_on_span(q, t0, t1, &samples) <= tol
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Distance from `q` to the span `t0..t1` (closest sample, then Newton steps).
|
||||
fn dist_on_span(&self, q: Vec2, t0: f64, t1: f64, samples: &[Vec2]) -> f64 {
|
||||
let n = samples.len().saturating_sub(1).max(1);
|
||||
let (k, mut best) = samples.iter().enumerate().map(|(i, p)| (i, p.dist(q))).fold((0, f64::INFINITY), |a, b| if b.1 < a.1 { b } else { a });
|
||||
let mut t = t0 + (t1 - t0) * k as f64 / n as f64;
|
||||
for _ in 0..12 {
|
||||
let (p, d) = (self.point_at(t), self.derivative(t));
|
||||
let dd = d.dot(d);
|
||||
if !(dd > 1e-300) {
|
||||
break;
|
||||
}
|
||||
let nt = (t - (p - q).dot(d) / dd).clamp(t0, t1);
|
||||
best = best.min(self.point_at(nt).dist(q));
|
||||
if (nt - t).abs() < 1e-15 {
|
||||
break;
|
||||
}
|
||||
t = nt;
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
impl Loop2 {
|
||||
/// The loop with every free-form segment fitted by lines and arcs (see
|
||||
/// [`Seg2::fit_arcs`]). `None` when a segment cannot be fitted.
|
||||
pub fn fit_arcs(&self, tol: f64) -> Option<Loop2> {
|
||||
let mut segs = Vec::with_capacity(self.segs.len());
|
||||
for s in &self.segs {
|
||||
segs.extend(s.fit_arcs(tol)?);
|
||||
if segs.len() > MAX_FIT_PIECES * 8 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Loop2 { segs })
|
||||
}
|
||||
|
||||
/// Only lines and arcs?
|
||||
pub fn is_line_arc(&self) -> bool {
|
||||
self.segs.iter().all(Seg2::is_line_or_arc)
|
||||
}
|
||||
}
|
||||
|
||||
impl Region2 {
|
||||
/// Only lines and arcs?
|
||||
pub fn is_line_arc(&self) -> bool {
|
||||
self.outer.is_line_arc() && self.holes.iter().all(Loop2::is_line_arc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from `p` to a line or arc segment (other segments: to their closest point).
|
||||
fn dist_to(s: &Seg2, p: Vec2) -> f64 {
|
||||
match *s {
|
||||
Seg2::Line { a, b } => {
|
||||
let d = b - a;
|
||||
let l2 = d.len2();
|
||||
let t = if l2 > 1e-300 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(a + d * t).dist(p)
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
let ang = (p - center).angle();
|
||||
let delta = if sweep >= 0.0 { (ang - start).rem_euclid(TAU) } else { (start - ang).rem_euclid(TAU) };
|
||||
if delta <= sweep.abs() { (p.dist(center) - radius).abs() } else { p.dist(s.start()).min(p.dist(s.end())) }
|
||||
}
|
||||
_ => s.point_at(s.closest_param(p)).dist(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two arcs (or lines) from `p0` leaving along unit `d0` to `p1` arriving along unit `d1`,
|
||||
/// meeting tangentially: the tangent lines from both ends are cut at one length `d`, and the
|
||||
/// arcs join at the middle of the line between the two cut points (which is `2 d` long).
|
||||
fn biarc(p0: Vec2, d0: Vec2, p1: Vec2, d1: Vec2) -> Option<Vec<Seg2>> {
|
||||
let v = p1 - p0;
|
||||
let vv = v.dot(v);
|
||||
if vv < 1e-24 {
|
||||
return None;
|
||||
}
|
||||
let len = vv.sqrt();
|
||||
let chord = v / len;
|
||||
if d0.cross(chord).abs() < 1e-12 && d1.cross(chord).abs() < 1e-12 && d0.dot(chord) > 0.0 && d1.dot(chord) > 0.0 {
|
||||
return Some(vec![Seg2::Line { a: p0, b: p1 }]);
|
||||
}
|
||||
// |v − d (d0 + d1)| = 2 d, the positive root.
|
||||
let t = d0 + d1;
|
||||
let vt = v.dot(t);
|
||||
let a = 2.0 * (1.0 - d0.dot(d1));
|
||||
let d = if a < 1e-12 {
|
||||
if vt <= 1e-12 * len {
|
||||
return None;
|
||||
}
|
||||
vv / (2.0 * vt)
|
||||
} else {
|
||||
(-vt + (vt * vt + a * vv).max(0.0).sqrt()) / a
|
||||
};
|
||||
if !(d.is_finite() && d > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let j = ((p0 + d0 * d) + (p1 - d1 * d)) * 0.5;
|
||||
let mut out = Vec::with_capacity(2);
|
||||
if j.dist(p0) > 1e-12 {
|
||||
out.push(arc_from(p0, d0, j)?);
|
||||
}
|
||||
if j.dist(p1) > 1e-12 {
|
||||
out.push(arc_from(p1, -d1, j)?.reversed());
|
||||
}
|
||||
(!out.is_empty()).then_some(out)
|
||||
}
|
||||
|
||||
/// The arc leaving `p` along unit `d` that ends at `q` (a line when `q` lies straight ahead).
|
||||
fn arc_from(p: Vec2, d: Vec2, q: Vec2) -> Option<Seg2> {
|
||||
let c = q - p;
|
||||
let cc = c.dot(c);
|
||||
let n = d.perp();
|
||||
let h = n.dot(c);
|
||||
// Sagitta of the arc over the chord: a line when the bulge is negligible.
|
||||
if h.abs() * cc.sqrt() < 1e-12 * cc || (cc / (2.0 * h)).abs() > 1e7 {
|
||||
return (d.dot(c) > 0.0).then_some(Seg2::Line { a: p, b: q });
|
||||
}
|
||||
let r = cc / (2.0 * h);
|
||||
let center = p + n * r;
|
||||
let start = (p - center).angle();
|
||||
let mut sweep = (q - center).angle() - start;
|
||||
if r > 0.0 {
|
||||
while sweep <= 0.0 {
|
||||
sweep += TAU;
|
||||
}
|
||||
} else {
|
||||
while sweep >= 0.0 {
|
||||
sweep -= TAU;
|
||||
}
|
||||
}
|
||||
(sweep.is_finite() && sweep.abs() <= TAU).then_some(Seg2::Arc { center, radius: r.abs(), start, sweep })
|
||||
}
|
||||
|
||||
/// `s` moved to start exactly at `p` (an arc keeps its centre; `p` is within a rounding error).
|
||||
fn with_start(s: Seg2, p: Vec2) -> Seg2 {
|
||||
match s {
|
||||
Seg2::Line { b, .. } => Seg2::Line { a: p, b },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` moved to end exactly at `p`.
|
||||
fn with_end(s: Seg2, p: Vec2) -> Seg2 {
|
||||
match s {
|
||||
Seg2::Line { a, .. } => Seg2::Line { a, b: p },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Largest distance of 401 points of `seg` from `pieces`: 30 times as many as the fit
|
||||
/// itself looks at in a span.
|
||||
fn max_dev(seg: &Seg2, pieces: &[Seg2]) -> f64 {
|
||||
(0..=400)
|
||||
.map(|i| seg.point_at(i as f64 / 400.0))
|
||||
.map(|p| pieces.iter().map(|s| dist_to(s, p)).fold(f64::INFINITY, f64::min))
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
fn chained(seg: &Seg2, pieces: &[Seg2]) {
|
||||
assert!(pieces.first().unwrap().start().dist(seg.start()) < 1e-9);
|
||||
assert!(pieces.last().unwrap().end().dist(seg.end()) < 1e-9);
|
||||
for w in pieces.windows(2) {
|
||||
assert!(w[0].end().dist(w[1].start()) < 1e-9, "{w:?}");
|
||||
// Tangent-continuous inside the segment (biarc joints and span joints).
|
||||
assert!(w[0].end_tangent().dot(w[1].start_tangent()) > 1.0 - 1e-6, "{w:?}");
|
||||
}
|
||||
assert!(pieces.iter().all(Seg2::is_line_or_arc));
|
||||
}
|
||||
|
||||
fn ellipse(c: Vec2, a: f64, b: f64) -> Loop2 {
|
||||
let corners = [c + Vec2::new(a, 0.0), c + Vec2::new(0.0, b), c - Vec2::new(a, 0.0), c - Vec2::new(0.0, b)];
|
||||
Loop2 {
|
||||
segs: (0..4)
|
||||
.map(|i| Seg2::Conic { a: corners[i], apex: corners[i] + corners[(i + 1) % 4] - c, b: corners[(i + 1) % 4], w: FRAC_1_SQRT_2 })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circular_arc_given_as_a_conic_comes_back_as_arcs_of_its_circle() {
|
||||
// A quarter circle of radius 10 about (5, −3): the biarc through its ends and end
|
||||
// tangents lies on the circle itself.
|
||||
let c = Vec2::new(5.0, -3.0);
|
||||
let q = Seg2::Conic { a: c + Vec2::new(10.0, 0.0), apex: c + Vec2::new(10.0, 10.0), b: c + Vec2::new(0.0, 10.0), w: FRAC_1_SQRT_2 };
|
||||
let arcs = q.fit_arcs(1e-6).unwrap();
|
||||
assert_eq!(arcs.len(), 2, "{arcs:?}");
|
||||
let mut total = 0.0;
|
||||
for s in &arcs {
|
||||
let Seg2::Arc { center, radius, sweep, .. } = *s else { panic!("{s:?}") };
|
||||
assert!(center.dist(c) < 1e-9 && (radius - 10.0).abs() < 1e-9, "{s:?}");
|
||||
total += sweep;
|
||||
}
|
||||
assert!((total - FRAC_PI_2).abs() < 1e-9, "{total}");
|
||||
chained(&q, &arcs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_ellipse_fits_within_the_tolerance_with_few_arcs() {
|
||||
let q = ellipse(Vec2::new(200.0, 150.0), 30.0, 12.0).segs[0];
|
||||
for tol in [1e-2, 1e-3, 1e-4] {
|
||||
let arcs = q.fit_arcs(tol).unwrap();
|
||||
chained(&q, &arcs);
|
||||
assert!(max_dev(&q, &arcs) <= tol * 1.01, "tol {tol}: {}", max_dev(&q, &arcs));
|
||||
// Far fewer pieces than a chord polyline of the same tolerance.
|
||||
let chords = q.polyline(tol).len() - 1;
|
||||
assert!(arcs.len() * 2 < chords, "tol {tol}: {} arcs vs {chords} chords", arcs.len());
|
||||
}
|
||||
// The chords of 0.001 mm: √(2·√(30² + 12²) / 0.008) = 89.9, so 90.
|
||||
assert_eq!(q.polyline(1e-3).len() - 1, 90);
|
||||
assert!(q.fit_arcs(1e-3).unwrap().len() <= 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_like_quadratics_cubics_and_s_curves_fit() {
|
||||
let segs = [
|
||||
Seg2::Conic { a: Vec2::new(0.0, 0.0), apex: Vec2::new(1.0, 2.0), b: Vec2::new(3.0, 0.5), w: 1.0 },
|
||||
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(1.0, 3.0), p2: Vec2::new(2.0, -3.0), p3: Vec2::new(3.0, 0.0) },
|
||||
// A cusp-like cubic and one with a coincident control point.
|
||||
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(4.0, 2.0), p2: Vec2::new(-1.0, 2.0), p3: Vec2::new(3.0, 0.0) },
|
||||
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(0.0, 0.0), p2: Vec2::new(2.0, 1.0), p3: Vec2::new(3.0, 0.0) },
|
||||
Seg2::Conic { a: Vec2::new(0.0, 0.0), apex: Vec2::new(5.0, 5.0), b: Vec2::new(10.0, 0.0), w: 3.0 },
|
||||
crate::bezier(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 2.0), Vec2::new(2.0, -1.0), Vec2::new(3.0, 2.0), Vec2::new(4.0, 0.0)]).unwrap(),
|
||||
];
|
||||
for s in segs {
|
||||
let arcs = s.fit_arcs(1e-3).unwrap();
|
||||
assert!(max_dev(&s, &arcs) <= 1.01e-3, "{s:?}: {}", max_dev(&s, &arcs));
|
||||
assert!(arcs.first().unwrap().start().dist(s.start()) < 1e-9 && arcs.last().unwrap().end().dist(s.end()) < 1e-9);
|
||||
for w in arcs.windows(2) {
|
||||
assert!(w[0].end().dist(w[1].start()) < 1e-9);
|
||||
}
|
||||
}
|
||||
// A straight "curve" is one line; lines and arcs come back as they are.
|
||||
let flat = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(1.0, 0.0), p2: Vec2::new(2.0, 0.0), p3: Vec2::new(3.0, 0.0) };
|
||||
assert_eq!(flat.fit_arcs(1e-3).unwrap(), vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::new(3.0, 0.0) }]);
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1.0 };
|
||||
assert_eq!(arc.fit_arcs(1e-3).unwrap(), vec![arc]);
|
||||
// Hostile input.
|
||||
let bad = Seg2::Conic { a: Vec2::new(f64::NAN, 0.0), apex: Vec2::ZERO, b: Vec2::new(1.0, 0.0), w: 1.0 };
|
||||
assert!(bad.fit_arcs(1e-3).is_none());
|
||||
assert!(segs[0].fit_arcs(0.0).is_none() && segs[0].fit_arcs(f64::NAN).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitted_ellipse_loop_keeps_its_area() {
|
||||
let lp = ellipse(Vec2::ZERO, 15.0, 8.0);
|
||||
let fitted = lp.fit_arcs(1e-3).unwrap();
|
||||
assert!(fitted.is_closed(1e-9) && fitted.is_line_arc() && !lp.is_line_arc());
|
||||
// π·a·b, within the tolerance times the perimeter (74 mm).
|
||||
let exact = PI * 15.0 * 8.0;
|
||||
assert!((lp.signed_area() - exact).abs() < 1e-9, "{}", lp.signed_area());
|
||||
assert!((fitted.signed_area() - exact).abs() < 1e-3 * 74.0, "{} vs {exact}", fitted.signed_area());
|
||||
assert!(fitted.segs.len() * 2 < lp.polyline(1e-3).len(), "{} pieces", fitted.segs.len());
|
||||
// As a hole of a 40 × 20 plate.
|
||||
let plate = Loop2::polygon(&[Vec2::new(-20.0, -10.0), Vec2::new(20.0, -10.0), Vec2::new(20.0, 10.0), Vec2::new(-20.0, 10.0)]);
|
||||
let (free, arcs) = (Region2 { outer: plate.clone(), holes: vec![lp.reversed()] }, Region2 { outer: plate, holes: vec![fitted.reversed()] });
|
||||
assert!(arcs.is_line_arc() && !free.is_line_arc());
|
||||
assert!((free.area() - (800.0 - exact)).abs() < 1e-9 && (arcs.area() - (800.0 - exact)).abs() < 1e-3 * 74.0, "{}", arcs.area());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod arcfit;
|
||||
pub mod brep;
|
||||
pub mod bspline;
|
||||
pub mod env;
|
||||
@@ -18,6 +19,7 @@ mod profile;
|
||||
pub mod tspline;
|
||||
mod vec;
|
||||
|
||||
pub use arcfit::MAX_FIT_PIECES;
|
||||
pub use mesh::{Aabb3, InsideIndex, Mesh, MeshMeasure};
|
||||
pub use plane::Plane;
|
||||
pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier};
|
||||
|
||||
Reference in new issue
Block a user