Source code for svg_path_editor.path_round_corners

# This file is part of https://github.com/KurtBoehm/svg-path-editor.
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

from decimal import Decimal
from typing import Callable

from svg_path_editor.geometry import Point, dot, rotation_matrix
from svg_path_editor.math import Number, as_bool, dec_to_rat
from svg_path_editor.svg import (
    A,
    ClosePath,
    HorizontalLineTo,
    LineTo,
    MoveTo,
    SvgItem,
    SvgPath,
    SvgPoint,
    VerticalLineTo,
)


[docs] def round_corners( path: SvgPath, radius: Number, *, selector: Callable[[Point, Point, Point], bool] = lambda a, b, c: True, ) -> SvgPath: """ Round the corners between straight-line segments in closed subpaths. The input must be a sequence of closed subpaths ``M … Z``. Every corner between two straight line segments (``L``/``H``/``V``/``Z``) is replaced by: * a shortened segment from :math:`A` to :math:`P` (on :math:`AB`), * a circular arc (``A`` command) from :math:`P` to :math:`Q` of radius ``radius``, * a shortened segment from :math:`Q` to :math:`C` (on :math:`BC`), where the corner is at point :math:`B` between segments :math:`AB` and :math:`BC`. Only corners with both adjacent segments line-like are processed. The original path is not modified. :param path: :class:`SvgPath` containing one or more closed subpaths ``M … Z``. :param radius: Corner rounding radius. Must be positive. :param selector: Optional callback ``selector(a, b, c) -> bool`` that decides whether to round the corner at ``b`` between the segments ``ab`` and ``bc``. If it returns ``False`` the corner is left unchanged. :return: A new :class:`SvgPath` where the selected corners between line-like segments are rounded with circular arcs. :raises ValueError: If ``radius`` is not positive. """ import sympy as sp r = Decimal(radius) if r <= 0: raise ValueError("The radius must be positive!") rr = dec_to_rat(r) # Work on a clone and convert everything to absolute to simplify logic new_path = path.clone() new_path.relative = False def is_line_like(it: SvgItem) -> bool: return isinstance(it, (LineTo, HorizontalLineTo, VerticalLineTo, ClosePath)) items = new_path.path n = len(items) if n < 3: return new_path result: list[SvgItem] = [] for i, curr in enumerate(items): post = items[(i + 1) % n] if isinstance(curr, MoveTo): j = i + 1 while not isinstance(items[j], ClosePath): assert not isinstance(items[j], MoveTo) j += 1 ante = items[j] else: ante = curr if not (is_line_like(ante) and is_line_like(post)): result.append(curr) continue # Geometry: a → b → c, corner at b a = ante.previous_point b = curr.target_location c = post.target_location if not selector(a, b, c): result.append(curr) continue a, b, c = a.vec2, b.vec2, c.vec2 # Vectors from the corner v1, v2 = a - b, c - b l1, l2 = v1.length, v2.length if l1 == 0 or l2 == 0: result.append(curr) continue # Unit vectors u1, u2 = v1 / l1, v2 / l2 if u1 == u2: result.append(curr) continue # Angle between segments udot = dot(u1, u2) # Clamp to valid range for acos udot = max(min(udot, sp.S.One), -sp.S.One) theta = sp.acos(udot) # Distance from corner along each segment to tangent points d = rr * sp.cot(theta / 2) # Ensure we don’t overshoot the segment length if d >= l1 or d >= l2: # Corner too sharp or segments too short: skip rounding here result.append(curr) continue # New tail/head points on each segment tail1, head2 = (b + u1 * d).point, (b + u2 * d).point # Shorten `curr` so it ends at tail1 and add it to the path curr.set_target_location(tail1) result.append(curr) # Compute the angle of v1, rotate v2 by that angle, and compute the angle # to determine the sweep v1deg = sp.deg(sp.atan2(v1.y, v1.x)) v2rot = rotation_matrix(-v1deg) @ v2 sweep = as_bool(sp.atan2(v2rot.y, v2rot.x) < 0) # Arc from tail1 to head2 with radius r # Sweep chosen so it rounds the outside of the corner arc = A( rx=r, ry=r, angle=0, large_arc_flag=False, sweep_flag=sweep, x=head2.x, y=head2.y, ) result.append(arc) # Update `post` start so it will start at head2. post.previous_point = SvgPoint(head2.x, head2.y) new_path.path = result new_path.refresh_absolute_positions() return new_path