Skip to main content

clipper2_rust/
offset.rs

1/*******************************************************************************
2* Author    :  Angus Johnson (original C++), Rust port                        *
3* Date      :  2025                                                           *
4* Website   :  https://www.angusj.com                                         *
5* Copyright :  Angus Johnson 2010-2025                                        *
6* Purpose   :  Path Offset (Inflate/Shrink)                                   *
7* License   :  https://www.boost.org/LICENSE_1_0.txt                          *
8*******************************************************************************/
9
10//! Path offset (inflate/shrink) module.
11//!
12//! Direct port from clipper.offset.h / clipper.offset.cpp.
13//! Provides `ClipperOffset` for inflating/shrinking paths by a specified delta.
14
15use crate::core::{
16    area, constants, cross_product_two_vectors, dot_product_two_vectors, ellipse_point64,
17    get_segment_intersect_pt_d, reflect_point, strip_duplicates_path, translate_point, Path64,
18    PathD, Paths64, Point64, PointD, Rect64,
19};
20use crate::engine::ClipType;
21use crate::engine_public::{Clipper64, PolyTree64};
22use crate::FillRule;
23
24// ---------------------------------------------------------------------------
25// Constants
26// ---------------------------------------------------------------------------
27
28const FLOATING_POINT_TOLERANCE: f64 = 1e-12;
29
30/// Default arc approximation constant (1/500).
31/// When arc_tolerance is 0, the calculated default arc tolerance
32/// (offset_radius * arc_const) generally produces good (smooth) arc
33/// approximations without producing excessively small segment lengths.
34const ARC_CONST: f64 = 0.002;
35
36// ---------------------------------------------------------------------------
37// Enums
38// ---------------------------------------------------------------------------
39
40/// Join type for path offsetting.
41/// Direct port from clipper.offset.h line 19.
42///
43/// - `Square`: Joins are 'squared' at exactly the offset distance (more complex code)
44/// - `Bevel`: Similar to Square, but offset distance varies with angle (simple & faster)
45/// - `Round`: Joins are rounded (arc approximation)
46/// - `Miter`: Joins extend to a point, limited by miter_limit
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum JoinType {
49    Square,
50    Bevel,
51    Round,
52    Miter,
53}
54
55/// End type for open path offsetting.
56/// Direct port from clipper.offset.h line 23.
57///
58/// - `Polygon`: Offsets only one side of a closed path
59/// - `Joined`: Offsets both sides of a path, with joined ends
60/// - `Butt`: Offsets both sides of a path, with square blunt ends
61/// - `Square`: Offsets both sides of a path, with square extended ends
62/// - `Round`: Offsets both sides of a path, with round extended ends
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum EndType {
65    Polygon,
66    Joined,
67    Butt,
68    Square,
69    Round,
70}
71
72/// Delta callback type for variable offset.
73/// Called for each vertex to determine the offset delta at that point.
74/// Parameters: (path, path_normals, curr_idx, prev_idx) -> delta
75pub type DeltaCallback64 = Box<dyn Fn(&Path64, &PathD, usize, usize) -> f64>;
76
77// ---------------------------------------------------------------------------
78// Helper functions (module-level, matching C++ file-scope functions)
79// ---------------------------------------------------------------------------
80
81/// Find the lowest (highest y) closed path and determine its orientation.
82/// Direct port from clipper.offset.cpp GetLowestClosedPathInfo.
83fn get_lowest_closed_path_info(paths: &Paths64) -> (Option<usize>, bool) {
84    let mut idx: Option<usize> = None;
85    let mut bot_pt = Point64::new(i64::MAX, i64::MIN);
86    let mut is_neg_area = false;
87
88    for (i, path_i) in paths.iter().enumerate() {
89        let mut a: f64 = f64::MAX;
90        for pt in path_i {
91            if (pt.y < bot_pt.y) || (pt.y == bot_pt.y && pt.x >= bot_pt.x) {
92                continue;
93            }
94            if a == f64::MAX {
95                a = area(path_i);
96                if a == 0.0 {
97                    break; // invalid closed path
98                }
99                is_neg_area = a < 0.0;
100            }
101            idx = Some(i);
102            bot_pt.x = pt.x;
103            bot_pt.y = pt.y;
104        }
105    }
106    (idx, is_neg_area)
107}
108
109/// Hypotenuse calculation.
110/// Direct port from clipper.offset.cpp Hypot.
111#[inline]
112fn hypot_xy(x: f64, y: f64) -> f64 {
113    (x * x + y * y).sqrt()
114}
115
116/// Get unit normal vector between two points.
117/// Direct port from clipper.offset.cpp GetUnitNormal.
118#[inline]
119fn get_unit_normal(pt1: &Point64, pt2: &Point64) -> PointD {
120    if pt1 == pt2 {
121        return PointD::new(0.0, 0.0);
122    }
123    let dx = (pt2.x - pt1.x) as f64;
124    let dy = (pt2.y - pt1.y) as f64;
125    let inverse_hypot = 1.0 / hypot_xy(dx, dy);
126    PointD::new(dy * inverse_hypot, -dx * inverse_hypot)
127}
128
129/// Check if a floating-point value is approximately zero.
130/// Direct port from clipper.offset.cpp AlmostZero.
131#[inline]
132fn almost_zero(value: f64, epsilon: f64) -> bool {
133    value.abs() < epsilon
134}
135
136/// Normalize a 2D vector to unit length.
137/// Direct port from clipper.offset.cpp NormalizeVector.
138#[inline]
139fn normalize_vector(vec: &PointD) -> PointD {
140    let h = hypot_xy(vec.x, vec.y);
141    if almost_zero(h, 0.001) {
142        return PointD::new(0.0, 0.0);
143    }
144    let inverse_hypot = 1.0 / h;
145    PointD::new(vec.x * inverse_hypot, vec.y * inverse_hypot)
146}
147
148/// Get the average of two unit vectors, normalized.
149/// Direct port from clipper.offset.cpp GetAvgUnitVector.
150#[inline]
151fn get_avg_unit_vector(vec1: &PointD, vec2: &PointD) -> PointD {
152    normalize_vector(&PointD::new(vec1.x + vec2.x, vec1.y + vec2.y))
153}
154
155/// Check if the end type represents a closed path.
156/// Direct port from clipper.offset.cpp IsClosedPath.
157#[inline]
158#[allow(dead_code)]
159fn is_closed_path(et: EndType) -> bool {
160    et == EndType::Polygon || et == EndType::Joined
161}
162
163/// Get perpendicular offset point (returns Point64, rounds coordinates).
164/// Direct port from clipper.offset.cpp GetPerpendic.
165#[inline]
166fn get_perpendic(pt: &Point64, norm: &PointD, delta: f64) -> Point64 {
167    Point64::new(
168        (pt.x as f64 + norm.x * delta).round() as i64,
169        (pt.y as f64 + norm.y * delta).round() as i64,
170    )
171}
172
173/// Get perpendicular offset point (returns PointD, no rounding).
174/// Direct port from clipper.offset.cpp GetPerpendicD.
175#[inline]
176fn get_perpendic_d(pt: &Point64, norm: &PointD, delta: f64) -> PointD {
177    PointD::new(pt.x as f64 + norm.x * delta, pt.y as f64 + norm.y * delta)
178}
179
180/// Negate all coordinates in a PathD.
181/// Direct port from clipper.offset.cpp NegatePath.
182#[inline]
183fn negate_path(path: &mut PathD) {
184    for pt in path.iter_mut() {
185        pt.x = -pt.x;
186        pt.y = -pt.y;
187    }
188}
189
190/// Convert PointD to Point64 by rounding.
191/// Helper for C++ implicit conversions where path_out.emplace_back(PointD) rounds.
192#[inline]
193fn point64_from_f(x: f64, y: f64) -> Point64 {
194    Point64::new(x.round() as i64, y.round() as i64)
195}
196
197// ---------------------------------------------------------------------------
198// Group struct (internal)
199// ---------------------------------------------------------------------------
200
201/// Internal grouping of paths with shared join/end types.
202/// Direct port from ClipperOffset::Group (clipper.offset.h line 35-43).
203struct Group {
204    paths_in: Paths64,
205    lowest_path_idx: Option<usize>,
206    is_reversed: bool,
207    join_type: JoinType,
208    end_type: EndType,
209}
210
211impl Group {
212    /// Construct a new Group, stripping duplicates and determining orientation.
213    /// Direct port from ClipperOffset::Group constructor (clipper.offset.cpp line 139-162).
214    fn new(paths: &Paths64, join_type: JoinType, end_type: EndType) -> Self {
215        let mut paths_in = paths.clone();
216        let is_joined = end_type == EndType::Polygon || end_type == EndType::Joined;
217
218        for p in paths_in.iter_mut() {
219            strip_duplicates_path(p, is_joined);
220        }
221
222        let (lowest_path_idx, is_reversed) = if end_type == EndType::Polygon {
223            let (idx, is_neg_area) = get_lowest_closed_path_info(&paths_in);
224            // The lowermost path must be an outer path, so if its orientation is
225            // negative, then flag the whole group as 'reversed' (will negate delta etc.)
226            // as this is much more efficient than reversing every path.
227            let is_reversed = idx.is_some() && is_neg_area;
228            (idx, is_reversed)
229        } else {
230            (None, false)
231        };
232
233        Group {
234            paths_in,
235            lowest_path_idx,
236            is_reversed,
237            join_type,
238            end_type,
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// ClipperOffset
245// ---------------------------------------------------------------------------
246
247/// Path offset (inflate/shrink) engine.
248/// Direct port from ClipperOffset class (clipper.offset.h / clipper.offset.cpp).
249///
250/// Inflates (or shrinks) both open and closed paths using the specified join type
251/// and end type. After building the raw offset, a Clipper64 union is used to
252/// clean up self-intersections.
253pub struct ClipperOffset {
254    // Internal state
255    error_code: i32,
256    delta: f64,
257    group_delta: f64,
258    temp_lim: f64,
259    steps_per_rad: f64,
260    step_sin: f64,
261    step_cos: f64,
262    norms: PathD,
263    path_out: Path64,
264    solution: Paths64,
265    groups: Vec<Group>,
266    join_type: JoinType,
267    end_type: EndType,
268
269    // User-configurable parameters
270    miter_limit: f64,
271    arc_tolerance: f64,
272    preserve_collinear: bool,
273    reverse_solution: bool,
274
275    // Callbacks
276    delta_callback: Option<DeltaCallback64>,
277}
278
279impl ClipperOffset {
280    // ------------------------------------------------------------------
281    // Construction / configuration
282    // ------------------------------------------------------------------
283
284    /// Create a new ClipperOffset with the given parameters.
285    /// Direct port from ClipperOffset constructor (clipper.offset.h line 85-91).
286    pub fn new(
287        miter_limit: f64,
288        arc_tolerance: f64,
289        preserve_collinear: bool,
290        reverse_solution: bool,
291    ) -> Self {
292        ClipperOffset {
293            error_code: 0,
294            delta: 0.0,
295            group_delta: 0.0,
296            temp_lim: 0.0,
297            steps_per_rad: 0.0,
298            step_sin: 0.0,
299            step_cos: 0.0,
300            norms: PathD::new(),
301            path_out: Path64::new(),
302            solution: Paths64::new(),
303            groups: Vec::new(),
304            join_type: JoinType::Bevel,
305            end_type: EndType::Polygon,
306            miter_limit,
307            arc_tolerance,
308            preserve_collinear,
309            reverse_solution,
310            delta_callback: None,
311        }
312    }
313
314    /// Create a new ClipperOffset with default parameters.
315    /// miter_limit = 2.0, arc_tolerance = 0.0,
316    /// preserve_collinear = false, reverse_solution = false.
317    pub fn new_default() -> Self {
318        Self::new(2.0, 0.0, false, false)
319    }
320
321    /// Get the error code from the last operation.
322    pub fn error_code(&self) -> i32 {
323        self.error_code
324    }
325
326    /// Get the miter limit.
327    pub fn miter_limit(&self) -> f64 {
328        self.miter_limit
329    }
330
331    /// Set the miter limit.
332    pub fn set_miter_limit(&mut self, miter_limit: f64) {
333        self.miter_limit = miter_limit;
334    }
335
336    /// Get the arc tolerance.
337    pub fn arc_tolerance(&self) -> f64 {
338        self.arc_tolerance
339    }
340
341    /// Set the arc tolerance.
342    /// Needed for rounded offsets. See offset_trigonometry2.svg.
343    pub fn set_arc_tolerance(&mut self, arc_tolerance: f64) {
344        self.arc_tolerance = arc_tolerance;
345    }
346
347    /// Get the preserve_collinear flag.
348    pub fn preserve_collinear(&self) -> bool {
349        self.preserve_collinear
350    }
351
352    /// Set the preserve_collinear flag.
353    pub fn set_preserve_collinear(&mut self, preserve_collinear: bool) {
354        self.preserve_collinear = preserve_collinear;
355    }
356
357    /// Get the reverse_solution flag.
358    pub fn reverse_solution(&self) -> bool {
359        self.reverse_solution
360    }
361
362    /// Set the reverse_solution flag.
363    pub fn set_reverse_solution(&mut self, reverse_solution: bool) {
364        self.reverse_solution = reverse_solution;
365    }
366
367    /// Set the delta callback for variable offset.
368    /// Direct port from ClipperOffset::SetDeltaCallback.
369    pub fn set_delta_callback(&mut self, cb: Option<DeltaCallback64>) {
370        self.delta_callback = cb;
371    }
372
373    // ------------------------------------------------------------------
374    // Path input
375    // ------------------------------------------------------------------
376
377    /// Add a single path with the given join type and end type.
378    /// Direct port from ClipperOffset::AddPath (clipper.offset.cpp line 168-171).
379    pub fn add_path(&mut self, path: &Path64, jt: JoinType, et: EndType) {
380        self.groups.push(Group::new(&vec![path.clone()], jt, et));
381    }
382
383    /// Add multiple paths with the given join type and end type.
384    /// Direct port from ClipperOffset::AddPaths (clipper.offset.cpp line 173-177).
385    pub fn add_paths(&mut self, paths: &Paths64, jt: JoinType, et: EndType) {
386        if paths.is_empty() {
387            return;
388        }
389        self.groups.push(Group::new(paths, jt, et));
390    }
391
392    /// Clear all groups and normals.
393    /// Direct port from ClipperOffset::Clear (clipper.offset.h line 98).
394    pub fn clear(&mut self) {
395        self.groups.clear();
396        self.norms.clear();
397    }
398
399    // ------------------------------------------------------------------
400    // Execution
401    // ------------------------------------------------------------------
402
403    /// Execute the offset operation, storing results in `paths`.
404    /// Direct port from ClipperOffset::Execute(double, Paths64&)
405    /// (clipper.offset.cpp line 636-642).
406    pub fn execute(&mut self, delta: f64, paths: &mut Paths64) {
407        paths.clear();
408        self.solution.clear();
409        self.execute_internal(delta, None);
410        std::mem::swap(&mut self.solution, paths);
411    }
412
413    /// Execute the offset operation, storing results in a `PolyTree64`.
414    /// Direct port from ClipperOffset::Execute(double, PolyTree64&)
415    /// (clipper.offset.cpp line 645-653).
416    pub fn execute_tree(&mut self, delta: f64, polytree: &mut PolyTree64) {
417        polytree.clear();
418        self.solution.clear();
419        self.execute_internal(delta, Some(polytree));
420        self.solution.clear();
421    }
422
423    /// Execute using a delta callback for variable offset.
424    /// Direct port from ClipperOffset::Execute(DeltaCallback64, Paths64&)
425    /// (clipper.offset.cpp line 655-659).
426    pub fn execute_with_callback(&mut self, delta_cb: DeltaCallback64, paths: &mut Paths64) {
427        self.delta_callback = Some(delta_cb);
428        self.execute(1.0, paths);
429    }
430
431    // ------------------------------------------------------------------
432    // Internal execution
433    // ------------------------------------------------------------------
434
435    /// Calculate the expected solution capacity.
436    /// Direct port from ClipperOffset::CalcSolutionCapacity (clipper.offset.cpp line 553-559).
437    fn calc_solution_capacity(&self) -> usize {
438        let mut result = 0;
439        for g in &self.groups {
440            result += if g.end_type == EndType::Joined {
441                g.paths_in.len() * 2
442            } else {
443                g.paths_in.len()
444            };
445        }
446        result
447    }
448
449    /// Check if the groups have reversed orientation.
450    /// Direct port from ClipperOffset::CheckReverseOrientation (clipper.offset.cpp line 561-572).
451    fn check_reverse_orientation(&self) -> bool {
452        // nb: this assumes there's consistency in orientation between groups
453        for g in &self.groups {
454            if g.end_type == EndType::Polygon {
455                return g.is_reversed;
456            }
457        }
458        false
459    }
460
461    /// Core internal execution logic.
462    /// Direct port from ClipperOffset::ExecuteInternal (clipper.offset.cpp line 574-634).
463    fn execute_internal(&mut self, delta: f64, polytree: Option<&mut PolyTree64>) {
464        self.error_code = 0;
465        if self.groups.is_empty() {
466            return;
467        }
468        self.solution.reserve(self.calc_solution_capacity());
469
470        if delta.abs() < 0.5 {
471            // offset is insignificant - just copy paths
472            let mut sol_size = 0;
473            for group in &self.groups {
474                sol_size += group.paths_in.len();
475            }
476            self.solution.reserve(sol_size);
477            for group in &self.groups {
478                self.solution.extend(group.paths_in.iter().cloned());
479            }
480        } else {
481            self.temp_lim = if self.miter_limit <= 1.0 {
482                2.0
483            } else {
484                2.0 / (self.miter_limit * self.miter_limit)
485            };
486
487            self.delta = delta;
488            // Process each group - we need indices because do_group_offset
489            // borrows self mutably
490            for i in 0..self.groups.len() {
491                self.do_group_offset(i);
492                if self.error_code != 0 {
493                    self.solution.clear();
494                }
495            }
496        }
497
498        if self.solution.is_empty() {
499            return;
500        }
501
502        let paths_reversed = self.check_reverse_orientation();
503        // Clean up self-intersections using Clipper64 union
504        let mut c = Clipper64::new();
505        c.set_preserve_collinear(self.preserve_collinear);
506        // The solution should retain the orientation of the input
507        c.set_reverse_solution(self.reverse_solution != paths_reversed);
508        c.add_subject(&self.solution);
509
510        let fill_rule = if paths_reversed {
511            FillRule::Negative
512        } else {
513            FillRule::Positive
514        };
515
516        if let Some(tree) = polytree {
517            let mut open_paths = Paths64::new();
518            c.execute_tree(ClipType::Union, fill_rule, tree, &mut open_paths);
519        } else {
520            c.execute(ClipType::Union, fill_rule, &mut self.solution, None);
521        }
522    }
523
524    // ------------------------------------------------------------------
525    // Build normals
526    // ------------------------------------------------------------------
527
528    /// Build unit normals for each edge of a path.
529    /// Direct port from ClipperOffset::BuildNormals (clipper.offset.cpp line 179-188).
530    fn build_normals(&mut self, path: &Path64) {
531        self.norms.clear();
532        if path.is_empty() {
533            return;
534        }
535        self.norms.reserve(path.len());
536        for i in 0..path.len() - 1 {
537            self.norms.push(get_unit_normal(&path[i], &path[i + 1]));
538        }
539        self.norms
540            .push(get_unit_normal(path.last().unwrap(), &path[0]));
541    }
542
543    // ------------------------------------------------------------------
544    // Join type implementations
545    // ------------------------------------------------------------------
546
547    /// Bevel join implementation.
548    /// Direct port from ClipperOffset::DoBevel (clipper.offset.cpp line 190-216).
549    fn do_bevel(&mut self, path: &Path64, j: usize, k: usize) {
550        let pt1: PointD;
551        let pt2: PointD;
552        if j == k {
553            let abs_delta = self.group_delta.abs();
554            pt1 = PointD::new(
555                path[j].x as f64 - abs_delta * self.norms[j].x,
556                path[j].y as f64 - abs_delta * self.norms[j].y,
557            );
558            pt2 = PointD::new(
559                path[j].x as f64 + abs_delta * self.norms[j].x,
560                path[j].y as f64 + abs_delta * self.norms[j].y,
561            );
562        } else {
563            pt1 = PointD::new(
564                path[j].x as f64 + self.group_delta * self.norms[k].x,
565                path[j].y as f64 + self.group_delta * self.norms[k].y,
566            );
567            pt2 = PointD::new(
568                path[j].x as f64 + self.group_delta * self.norms[j].x,
569                path[j].y as f64 + self.group_delta * self.norms[j].y,
570            );
571        }
572        self.path_out.push(point64_from_f(pt1.x, pt1.y));
573        self.path_out.push(point64_from_f(pt2.x, pt2.y));
574    }
575
576    /// Square join implementation.
577    /// Direct port from ClipperOffset::DoSquare (clipper.offset.cpp line 218-256).
578    fn do_square(&mut self, path: &Path64, j: usize, k: usize) {
579        let vec: PointD = if j == k {
580            PointD::new(self.norms[j].y, -self.norms[j].x)
581        } else {
582            get_avg_unit_vector(
583                &PointD::new(-self.norms[k].y, self.norms[k].x),
584                &PointD::new(self.norms[j].y, -self.norms[j].x),
585            )
586        };
587
588        let abs_delta = self.group_delta.abs();
589
590        // Now offset the original vertex delta units along unit vector
591        let pt_q = PointD::new(path[j].x as f64, path[j].y as f64);
592        let pt_q = translate_point(&pt_q, abs_delta * vec.x, abs_delta * vec.y);
593        // Get perpendicular vertices
594        let pt1 = translate_point(&pt_q, self.group_delta * vec.y, self.group_delta * -vec.x);
595        let pt2 = translate_point(&pt_q, self.group_delta * -vec.y, self.group_delta * vec.x);
596        // Get 2 vertices along one edge offset
597        let pt3 = get_perpendic_d(&path[k], &self.norms[k], self.group_delta);
598
599        if j == k {
600            let pt4 = PointD::new(
601                pt3.x + vec.x * self.group_delta,
602                pt3.y + vec.y * self.group_delta,
603            );
604            let mut pt = pt_q;
605            get_segment_intersect_pt_d(pt1, pt2, pt3, pt4, &mut pt);
606            // Get the second intersect point through reflection
607            let reflected = reflect_point(&pt, &pt_q);
608            self.path_out.push(point64_from_f(reflected.x, reflected.y));
609            self.path_out.push(point64_from_f(pt.x, pt.y));
610        } else {
611            let pt4 = get_perpendic_d(&path[j], &self.norms[k], self.group_delta);
612            let mut pt = pt_q;
613            get_segment_intersect_pt_d(pt1, pt2, pt3, pt4, &mut pt);
614            self.path_out.push(point64_from_f(pt.x, pt.y));
615            // Get the second intersect point through reflection
616            let reflected = reflect_point(&pt, &pt_q);
617            self.path_out.push(point64_from_f(reflected.x, reflected.y));
618        }
619    }
620
621    /// Miter join implementation.
622    /// Direct port from ClipperOffset::DoMiter (clipper.offset.cpp line 258-271).
623    fn do_miter(&mut self, path: &Path64, j: usize, k: usize, cos_a: f64) {
624        let q = self.group_delta / (cos_a + 1.0);
625        self.path_out.push(point64_from_f(
626            path[j].x as f64 + (self.norms[k].x + self.norms[j].x) * q,
627            path[j].y as f64 + (self.norms[k].y + self.norms[j].y) * q,
628        ));
629    }
630
631    /// Round join implementation.
632    /// Direct port from ClipperOffset::DoRound (clipper.offset.cpp line 273-309).
633    fn do_round(&mut self, path: &Path64, j: usize, k: usize, angle: f64) {
634        if self.delta_callback.is_some() {
635            // When delta_callback is assigned, group_delta won't be constant,
636            // so we need to do these calculations for *every* vertex.
637            let abs_delta = self.group_delta.abs();
638            let arc_tol = if self.arc_tolerance > FLOATING_POINT_TOLERANCE {
639                abs_delta.min(self.arc_tolerance)
640            } else {
641                abs_delta * ARC_CONST
642            };
643            let steps_per_360 =
644                (constants::PI / (1.0 - arc_tol / abs_delta).acos()).min(abs_delta * constants::PI);
645            self.step_sin = (2.0 * constants::PI / steps_per_360).sin();
646            self.step_cos = (2.0 * constants::PI / steps_per_360).cos();
647            if self.group_delta < 0.0 {
648                self.step_sin = -self.step_sin;
649            }
650            self.steps_per_rad = steps_per_360 / (2.0 * constants::PI);
651        }
652
653        let pt = path[j];
654        let mut offset_vec = PointD::new(
655            self.norms[k].x * self.group_delta,
656            self.norms[k].y * self.group_delta,
657        );
658
659        if j == k {
660            offset_vec = offset_vec.negate();
661        }
662        self.path_out.push(point64_from_f(
663            pt.x as f64 + offset_vec.x,
664            pt.y as f64 + offset_vec.y,
665        ));
666
667        let steps = (self.steps_per_rad * angle.abs()).ceil() as i32; // #448, #456
668        for _ in 1..steps {
669            // ie 1 less than steps
670            offset_vec = PointD::new(
671                offset_vec.x * self.step_cos - self.step_sin * offset_vec.y,
672                offset_vec.x * self.step_sin + offset_vec.y * self.step_cos,
673            );
674            self.path_out.push(point64_from_f(
675                pt.x as f64 + offset_vec.x,
676                pt.y as f64 + offset_vec.y,
677            ));
678        }
679        self.path_out
680            .push(get_perpendic(&path[j], &self.norms[j], self.group_delta));
681    }
682
683    // ------------------------------------------------------------------
684    // Offset operations
685    // ------------------------------------------------------------------
686
687    /// Offset a single vertex.
688    /// Direct port from ClipperOffset::OffsetPoint (clipper.offset.cpp line 311-370).
689    fn offset_point(&mut self, group_idx: usize, path: &Path64, j: usize, k: usize) {
690        // Let A = change in angle where edges join
691        // A == 0: ie no change in angle (flat join)
692        // A == PI: edges 'spike'
693        // sin(A) < 0: right turning
694        // cos(A) < 0: change in angle is more than 90 degree
695
696        if path[j] == path[k] {
697            return;
698        }
699
700        let sin_a = cross_product_two_vectors(self.norms[j], self.norms[k]);
701        let cos_a = dot_product_two_vectors(self.norms[j], self.norms[k]);
702        let sin_a = sin_a.clamp(-1.0, 1.0);
703
704        if let Some(ref cb) = self.delta_callback {
705            self.group_delta = cb(path, &self.norms, j, k);
706            if self.groups[group_idx].is_reversed {
707                self.group_delta = -self.group_delta;
708            }
709        }
710        if self.group_delta.abs() <= FLOATING_POINT_TOLERANCE {
711            self.path_out.push(path[j]);
712            return;
713        }
714
715        if cos_a > -0.999 && (sin_a * self.group_delta < 0.0) {
716            // test for concavity first (#593)
717            // Is concave.
718            // By far the simplest way to construct concave joins, especially those
719            // joining very short segments, is to insert 3 points that produce negative
720            // regions. These regions will be removed later by the finishing union
721            // operation. This is also the best way to ensure that path reversals
722            // (ie over-shrunk paths) are removed.
723            self.path_out
724                .push(get_perpendic(&path[j], &self.norms[k], self.group_delta));
725            self.path_out.push(path[j]); // (#405, #873, #916)
726            self.path_out
727                .push(get_perpendic(&path[j], &self.norms[j], self.group_delta));
728        } else if cos_a > 0.999 && self.join_type != JoinType::Round {
729            // Almost straight - less than 2.5 degree (#424, #482, #526 & #724)
730            self.do_miter(path, j, k, cos_a);
731        } else if self.join_type == JoinType::Miter {
732            // Miter unless the angle is sufficiently acute to exceed ML
733            if cos_a > self.temp_lim - 1.0 {
734                self.do_miter(path, j, k, cos_a);
735            } else {
736                self.do_square(path, j, k);
737            }
738        } else if self.join_type == JoinType::Round {
739            self.do_round(path, j, k, sin_a.atan2(cos_a));
740        } else if self.join_type == JoinType::Bevel {
741            self.do_bevel(path, j, k);
742        } else {
743            self.do_square(path, j, k);
744        }
745    }
746
747    /// Offset a closed polygon.
748    /// Direct port from ClipperOffset::OffsetPolygon (clipper.offset.cpp line 372-378).
749    fn offset_polygon(&mut self, group_idx: usize, path: &Path64) {
750        self.path_out.clear();
751        let len = path.len();
752        if len == 0 {
753            return;
754        }
755        let mut k = len - 1;
756        for j in 0..len {
757            self.offset_point(group_idx, path, j, k);
758            k = j;
759        }
760        let path_out = std::mem::take(&mut self.path_out);
761        self.solution.push(path_out);
762    }
763
764    /// Offset an open path with joined ends.
765    /// Direct port from ClipperOffset::OffsetOpenJoined (clipper.offset.cpp line 380-393).
766    fn offset_open_joined(&mut self, group_idx: usize, path: &Path64) {
767        self.offset_polygon(group_idx, path);
768        let mut reverse_path = path.clone();
769        reverse_path.reverse();
770
771        // Rebuild normals
772        self.norms.reverse();
773        self.norms.push(self.norms[0]);
774        self.norms.remove(0);
775        negate_path(&mut self.norms);
776
777        self.offset_polygon(group_idx, &reverse_path);
778    }
779
780    /// Offset an open path.
781    /// Direct port from ClipperOffset::OffsetOpenPath (clipper.offset.cpp line 395-453).
782    fn offset_open_path(&mut self, group_idx: usize, path: &Path64) {
783        self.path_out.clear();
784
785        // Do the line start cap
786        if let Some(ref cb) = self.delta_callback {
787            self.group_delta = cb(path, &self.norms, 0, 0);
788        }
789
790        if self.group_delta.abs() <= FLOATING_POINT_TOLERANCE {
791            self.path_out.push(path[0]);
792        } else {
793            match self.end_type {
794                EndType::Butt => self.do_bevel(path, 0, 0),
795                EndType::Round => self.do_round(path, 0, 0, constants::PI),
796                _ => self.do_square(path, 0, 0),
797            }
798        }
799
800        let high_i = path.len() - 1;
801        // Offset the left side going forward
802        let mut k = 0;
803        for j in 1..high_i {
804            self.offset_point(group_idx, path, j, k);
805            k = j;
806        }
807
808        // Reverse normals
809        for i in (1..=high_i).rev() {
810            self.norms[i] = PointD::new(-self.norms[i - 1].x, -self.norms[i - 1].y);
811        }
812        self.norms[0] = self.norms[high_i];
813
814        // Do the line end cap
815        if let Some(ref cb) = self.delta_callback {
816            self.group_delta = cb(path, &self.norms, high_i, high_i);
817        }
818
819        if self.group_delta.abs() <= FLOATING_POINT_TOLERANCE {
820            self.path_out.push(path[high_i]);
821        } else {
822            match self.end_type {
823                EndType::Butt => self.do_bevel(path, high_i, high_i),
824                EndType::Round => self.do_round(path, high_i, high_i, constants::PI),
825                _ => self.do_square(path, high_i, high_i),
826            }
827        }
828
829        // Offset the right side going backward
830        let mut k = high_i;
831        for j in (1..high_i).rev() {
832            self.offset_point(group_idx, path, j, k);
833            k = j;
834        }
835        let path_out = std::mem::take(&mut self.path_out);
836        self.solution.push(path_out);
837    }
838
839    // ------------------------------------------------------------------
840    // Group offset
841    // ------------------------------------------------------------------
842
843    /// Process a single group of paths.
844    /// Direct port from ClipperOffset::DoGroupOffset (clipper.offset.cpp line 455-539).
845    fn do_group_offset(&mut self, group_idx: usize) {
846        let group_end_type = self.groups[group_idx].end_type;
847        let group_join_type = self.groups[group_idx].join_type;
848        let group_is_reversed = self.groups[group_idx].is_reversed;
849        let group_lowest = self.groups[group_idx].lowest_path_idx;
850
851        if group_end_type == EndType::Polygon {
852            // A straight path (2 points) can now also be 'polygon' offset
853            // where the ends will be treated as (180 deg.) joins
854            if group_lowest.is_none() {
855                self.delta = self.delta.abs();
856            }
857            self.group_delta = if group_is_reversed {
858                -self.delta
859            } else {
860                self.delta
861            };
862        } else {
863            self.group_delta = self.delta.abs();
864        }
865
866        let abs_delta = self.group_delta.abs();
867        self.join_type = group_join_type;
868        self.end_type = group_end_type;
869
870        if group_join_type == JoinType::Round || group_end_type == EndType::Round {
871            // Calculate the number of steps required to approximate a circle
872            let arc_tol = if self.arc_tolerance > FLOATING_POINT_TOLERANCE {
873                abs_delta.min(self.arc_tolerance)
874            } else {
875                abs_delta * ARC_CONST
876            };
877
878            let steps_per_360 =
879                (constants::PI / (1.0 - arc_tol / abs_delta).acos()).min(abs_delta * constants::PI);
880            self.step_sin = (2.0 * constants::PI / steps_per_360).sin();
881            self.step_cos = (2.0 * constants::PI / steps_per_360).cos();
882            if self.group_delta < 0.0 {
883                self.step_sin = -self.step_sin;
884            }
885            self.steps_per_rad = steps_per_360 / (2.0 * constants::PI);
886        }
887
888        // Iterate over paths in the group
889        let paths_count = self.groups[group_idx].paths_in.len();
890        for path_idx in 0..paths_count {
891            let path = self.groups[group_idx].paths_in[path_idx].clone();
892            let path_len = path.len();
893            self.path_out.clear();
894
895            if path_len == 1 {
896                // Single point
897                if self.delta_callback.is_some() {
898                    let cb_result = if let Some(ref cb) = self.delta_callback {
899                        cb(&path, &self.norms, 0, 0)
900                    } else {
901                        0.0
902                    };
903                    self.group_delta = cb_result;
904                    if group_is_reversed {
905                        self.group_delta = -self.group_delta;
906                    }
907                }
908
909                if self.group_delta < 1.0 {
910                    continue;
911                }
912                let pt = path[0];
913                let abs_delta_local = self.group_delta.abs();
914
915                // Single vertex: build a circle or square
916                if group_join_type == JoinType::Round {
917                    let radius = abs_delta_local;
918                    let steps = if self.steps_per_rad > 0.0 {
919                        (self.steps_per_rad * 2.0 * constants::PI).ceil() as usize
920                    } else {
921                        0
922                    };
923                    self.path_out = ellipse_point64(pt, radius, radius, steps);
924                } else {
925                    let d = abs_delta_local.ceil() as i64;
926                    let r = Rect64::new(pt.x - d, pt.y - d, pt.x + d, pt.y + d);
927                    self.path_out = r.as_path();
928                }
929
930                let path_out = std::mem::take(&mut self.path_out);
931                self.solution.push(path_out);
932                continue;
933            } // end of offsetting a single point
934
935            if path_len == 2 && group_end_type == EndType::Joined {
936                self.end_type = if group_join_type == JoinType::Round {
937                    EndType::Round
938                } else {
939                    EndType::Square
940                };
941            }
942
943            self.build_normals(&path);
944            if self.end_type == EndType::Polygon {
945                self.offset_polygon(group_idx, &path);
946            } else if self.end_type == EndType::Joined {
947                self.offset_open_joined(group_idx, &path);
948            } else {
949                self.offset_open_path(group_idx, &path);
950            }
951        }
952    }
953}
954
955// ---------------------------------------------------------------------------
956// Tests
957// ---------------------------------------------------------------------------
958
959#[cfg(test)]
960#[path = "offset_tests.rs"]
961mod tests;