Skip to content

Commit

Permalink
Add import_svg_as_forced_outline function to make importing unclean o…
Browse files Browse the repository at this point in the history
…utlines easier
  • Loading branch information
bluedrink9 committed Sep 29, 2024
1 parent 55f6e8f commit 4b368fb
Showing 1 changed file with 260 additions and 3 deletions.
263 changes: 260 additions & 3 deletions src/build123d/importers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@
# pylint has trouble with the OCP imports
# pylint: disable=no-name-in-module, import-error

import copy
import os
from math import degrees
from math import degrees, sqrt
from pathlib import Path
from typing import TextIO, Union, Optional

Expand Down Expand Up @@ -66,10 +67,14 @@
XCAFDoc_DocumentTool,
)
from ocpsvg import ColorAndLabel, import_svg_document
from svgpathtools import svg2paths
import svgpathtools as svg
import unicodedata

from build123d.build_enums import CenterOf, Mode, AngularDirection
from build123d.build_line import BuildLine
from build123d.geometry import Color, Location
from build123d.objects_curve import Line, EllipticalCenterArc
from build123d.operations_generic import add, offset, mirror
from build123d.topology import (
Compound,
Edge,
Expand Down Expand Up @@ -282,7 +287,7 @@ def import_svg_as_buildline_code(file_name: str) -> tuple[str, str]:
"sweep",
],
}
paths_info = svg2paths(file_name)
paths_info = svg.svg2paths(file_name)
paths, _path_attributes = paths_info[0], paths_info[1]
builder_name = os.path.basename(file_name).split(".")[0]
builder_name = builder_name if builder_name.isidentifier() else "builder"
Expand Down Expand Up @@ -381,3 +386,255 @@ def import_svg(
shapes.append(shape)

return ShapeList(shapes)


def import_svg_as_forced_outline(
svg_file: Union[str, Path, TextIO],
reorient: bool = True,
tolerance: float = 0.01,
extra_cleaning=False,
) -> Wire:
"""Import an SVG and apply cleaning operations to return a closed wire outline, if possible. Useful for SVG outlines that are actually made of thin shapes or slightly disconnected paths. May fail on more complex shapes.
* Removes duplicate lines, including ones that are reverses of the other, within a tolerance level (useful for 'outlines' that are actually very thin shapes)
* Sorts paths such that they are end to start in order of distance, flipping them if needed to line up start to end
* Goes through each path and creates the next one such that it starts at the end of the last one
* Ensures the last and first paths are connected
Args:
svg_file (Union[str, Path, TextIO]): svg file
reorient (bool, optional): Center result on origin by bounding box, and
flip objects to compensate for svg orientation (so the resulting wire
is the same way up as it looks when opened in an SVG viewer). Defaults
to True.
tolerance (float, optional): Amount of tolerance to use for comparing paths. Defaults to 0.01.
extra_clean (bool, optional): Do some extra cleaning, mainly skipping tiny paths. Defaults to False.
Raises:
ValueError: If an unknown path type is encountered.
FileNotFoundError: the input file cannot be found.
Returns:
Wire: Forcefully connected SVG paths as a wire.
"""

def point(path_point):
return (path_point.real, path_point.imag)

paths = svg.svg2paths(svg_file)[0]
curves = []
for p in paths:
curves.extend(p)
curves = _remove_duplicate_paths(curves, tolerance=tolerance)
curves = _sort_curves(curves)
first_line = curves[0]
with BuildLine() as bd_l:
line_start = point(first_line.start)
for i, p in enumerate(curves):
if extra_cleaning and p.length() < tolerance:
# Filter out tiny edges that may cause issues with OCCT ops
continue
line_end = point(p.end)
if i == len(curves) - 1:
# Forcefully reconnect the end to the start.
# Note: This won't quite work if the last path is an arc,
# but make_face should still sort it out. Once
# EllipticalStartArc is released in build123d, this can be
# fixed.
line_end = point(first_line.start)
else:
if (
extra_cleaning
and Vertex(line_end).distance(Vertex(line_start)) < tolerance
):
# Skip this path if it's really short, just go straight
# to the next one.
continue
if isinstance(p, svg.Line):
edge = Line(line_start, line_end)
elif isinstance(p, svg.Arc):
start, end = sorted(
[
p.theta,
p.theta + p.delta,
]
)
if p.delta < 0.0:
dir_ = AngularDirection.CLOCKWISE
else:
dir_ = AngularDirection.COUNTER_CLOCKWISE
edge = EllipticalCenterArc(
center=point(p.center),
x_radius=p.radius.real,
y_radius=p.radius.imag,
start_angle=start,
end_angle=end,
rotation=degrees(p.phi),
angular_direction=dir_,
mode=Mode.PRIVATE,
)
add(edge.move(Location(line_start - edge @ 0)))

else:
print("Unknown path type for ", p)
raise ValueError
line_start = edge @ 1

wire = bd_l.wire()
if reorient:
wire = wire.move(Location(-wire.center(center_of=CenterOf.BOUNDING_BOX)))
wire = mirror(wire)

return wire


def _sort_curves(curves):
"""Return list of paths sorted and flipped so that they are connected end to end as the list iterates."""
if not curves:
return []

def euclidean_distance(p1, p2):
return sqrt((p1.real - p2.real) ** 2 + (p1.imag - p2.imag) ** 2)

# Start with the first curve
sorted_curves = [curves.pop(0)]

while curves:
last_curve = sorted_curves[-1]
last_end = last_curve.end

# Find the closest curve to the previous end point.
closest_curve, closest_distance, flip = None, float("inf"), False
for curve in curves:
dist_start = euclidean_distance(last_end, curve.start)
dist_end = euclidean_distance(last_end, curve.end)
# If end is closer than start, flip the curve right way around.
if dist_start < closest_distance:
closest_curve, closest_distance, flip = curve, dist_start, False
if dist_end < closest_distance:
closest_curve, closest_distance, flip = curve, dist_end, True

# Flip the curve if necessary
if flip:
flipped = _reverse_svg_curve(closest_curve)
sorted_curves.append(flipped)
else:
sorted_curves.append(closest_curve)
curves.remove(closest_curve)

return sorted_curves


def _remove_duplicate_paths(paths, tolerance=0.01):
"""Remove paths that are identical to within the given positional and
parameter tolerance limit, including similar but reversed paths."""
cleaned_paths = []

for path in paths:
# Check if a similar path already exists in the cleaned list (either
# forward or reversed)
flipped = _reverse_svg_curve(path)
if any(
_are_paths_similar(path, cleaned_path, tolerance)
or _are_paths_similar(flipped, cleaned_path, tolerance)
for cleaned_path in cleaned_paths
):
# Skip this path if a similar one is already in the list
continue
cleaned_paths.append(path)

return cleaned_paths


def _are_paths_similar(path1, path2, tolerance=0.01):
"""Compares two SVG paths, based on type, start/end points, length, and Arc attributes."""

if type(path1) != type(path2):
return False

def lengths_are_close(p1, p2):
return (
abs(p1.length() - p2.length()) / max(p1.length(), p2.length()) < tolerance
)

if not lengths_are_close(path1, path2):
return False

def points_are_close(p1, p2):
return abs(p1.real - p2.real) < tolerance and abs(p1.imag - p2.imag) < tolerance

# Handle reversed paths by checking both normal and reversed orientation
if not points_are_close(path1.start, path2.start) and points_are_close(
path1.end, path2.end
):
return False

# Additional checks for arcs (to handle radius, rotation, etc.)
if isinstance(path1, svg.Arc) and isinstance(path2, svg.Arc):
arc_attributes = [
"radius",
"phi",
"theta",
"delta",
"rotation",
"center",
"large_arc",
"sweep",
]

for attr in arc_attributes:
try:
if abs(vars(path1)[attr] - vars(path2)[attr]) > tolerance:
return False
except KeyError:
continue

return True


def _reverse_svg_curve(c):
c = copy.deepcopy(c)
t = c.start
c.start = c.end
c.end = t
if isinstance(c, svg.Arc):
# Flipping ElipticalArcs is a bit more complicated.
# Calculate the new theta as the original end angle.
new_theta = c.theta + c.delta
# Reverse the delta.
c.delta = -c.delta
# Set theta to the new start angle.
c.theta = new_theta
return c


if "__file__" in globals():
script_dir = Path(__file__).parent
else:
script_dir = Path(os.getcwd())


# For debugging/viewing in cq-editor or vscode's ocp_vscode plugin.
if __name__ not in ["__cq_main__", "temp"]:
show_object = lambda *_, **__: None
log = lambda x: print(x)
# show_object = lambda *_, **__: None

if __name__ == "__main__":
import ocp_vscode as ocp
from ocp_vscode import show

ocp.set_port(3939)
ocp.set_defaults(reset_camera=ocp.Camera.KEEP)
show_object = lambda *args, **__: ocp.show(args)

p = Path(
"~/src/keyboard_design/maizeless/pcb/build/maizeless-Edge_Cuts gerber.svg"
).expanduser()
# p = script_dir / "build/outline.svg"
# p = Path("~/src/keeb_snakeskin/manual_outlines/ferris-base-0.1.svg").expanduser()

import build123d as bd
base_face = bd.make_face(import_svg_as_forced_outline(p))
show_object(base_face, name="base_face")

0 comments on commit 4b368fb

Please sign in to comment.