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, Literal

from svg_path_editor.geometry import Point, Vec2, 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 corners between straight segments in closed subpaths. The input must be one or more closed subpaths ``M … Z``. Each corner between two straight segments (``L``/``H``/``V``/``Z``) at point :math:`B` between :math:`AB` and :math:`BC` is replaced by: * a shortened segment from :math:`A` to :math:`P` on :math:`AB`, * a circular arc from :math:`P` to :math:`Q` with radius ``radius``, * a shortened segment from :math:`Q` to :math:`C` on :math:`BC`. Only corners with straight incoming and outgoing segments are modified. The input path is not modified. :param path: :class:`SvgPath` with one or more closed subpaths ``M … Z``. :param radius: Corner radius; must be positive. :param selector: Optional ``selector(a, b, c) -> bool``; if it returns ``False`` for corner :math:`B` between :math:`AB` and :math:`BC`, that corner is left unchanged. :return: New :class:`SvgPath` with chosen line-line corners rounded by 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 cloned, fully absolute path 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] = [] # Per-item adjusted points after shortening/offsetting segments off_pts: dict[tuple[int, Literal["prv", "tgt"]], Vec2] = {} for i, curr in enumerate(items): post_idx = (i + 1) % n post = items[post_idx] if isinstance(curr, MoveTo): # For the first item of a subpath, "ante" is the closing segment j = i + 1 while not isinstance(items[j], ClosePath): assert not isinstance(items[j], MoveTo) j += 1 ante_idx = j else: ante_idx = i ante = items[ante_idx] if not (is_line_like(ante) and is_line_like(post)): result.append(curr) continue # Corner 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 # Use previously adjusted endpoints if this segment was already shortened a = off_pts.get((ante_idx, "prv"), a.vec2) b = off_pts.get((i, "tgt"), b.vec2) c = off_pts.get((post_idx, "tgt"), c.vec2) # Vectors from corner b along incoming and outgoing segments v1, v2 = a - b, c - b l1, l2 = v1.length, v2.length if l1 == 0 or l2 == 0: result.append(curr) continue # Unit directions u1, u2 = v1 / l1, v2 / l2 if u1 == u2: result.append(curr) continue # Angle between segments udot = dot(u1, u2) # Clamp for acos udot = max(min(udot, sp.S.One), -sp.S.One) theta = sp.acos(udot) # Distance from corner to tangent points on each segment d = rr * sp.cot(theta / 2) # Skip if the fillet would be longer than a segment if d > l1 or d > l2: result.append(curr) continue # New endpoints: tail of incoming, head of outgoing tail1, head2 = (b + u1 * d).simplify(), (b + u2 * d).simplify() head2x, head2y = head2.point # Shorten current segment to end at tail1 curr.set_target_location(tail1.point) off_pts[i, "tgt"] = tail1 result.append(curr) # Determine sweep direction: rotate v2 into v1’s frame and check sign 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, rounding the external corner arc = A( rx=r, ry=r, angle=0, large_arc_flag=False, sweep_flag=sweep, x=head2x, y=head2y, ) result.append(arc) # Make following segment start at head2 post.previous_point = SvgPoint(head2x, head2y) off_pts[post_idx, "prv"] = head2 new_path.path = result new_path.refresh_absolute_positions() return new_path