424 lines
14 KiB
Python
424 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# This file is part of Precut.
|
|
#
|
|
# Precut is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# Precut is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with Precut. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
# please, stick to pep8 formatting for this file
|
|
|
|
# seems to be lost in year 2016 https://wiki.inkscape.org/wiki/index.php?title=Inkscape_Extensions&oldid=99881
|
|
|
|
"""
|
|
Migrator: Mario Voigt / FabLab Chemnitz
|
|
Mail: mario.voigt@stadtfabrikanten.org
|
|
Date: 13.08.2020
|
|
|
|
This plugin - initially called "Precut" - was found deeply on web and was nearly lost in translation. Ported to Python 3.0 for InkScape 1.0. This tool finds path intersections within the complete SVG document. Intersections are going to be marked with little squares.
|
|
"""
|
|
|
|
"""
|
|
What do we want to check?
|
|
=========================
|
|
|
|
* any text objects that are not converted to a path?
|
|
* can be implemented as tag blacklist
|
|
* any outlines? they need to be converted to paths
|
|
* check for crossing paths
|
|
* this is the hardest
|
|
* for lines, this is easy, and can, for example, be done with shapely:
|
|
|
|
>>> from shapely.geometry import LineString
|
|
>>> l1 = LineString([(0, 0), (1, 1)])
|
|
>>> l2 = LineString([(0, 1), (1, 0)])
|
|
>>> l1.intersects(l2)
|
|
True
|
|
>>> p = l1.intersection(l2)
|
|
>>> p.x
|
|
0.5
|
|
>>> p.y
|
|
0.5
|
|
* check for self-intersection, too (=> line.is_simple)
|
|
* need to split each complex subpath into its segments
|
|
* then, when doing intersections, remove the `boundary`
|
|
from the intersection set, because two adjacent
|
|
segments from a subpath always intersect in their boundary
|
|
* handle the commands M, Z, L, C, Q, A (parsed via simplepath)
|
|
* M: moveto
|
|
* Z: closepath (straight closing line)
|
|
* L: lineto
|
|
* C: curveto (cubic bezier)
|
|
* Q: curveto (quadratic bezier)
|
|
* A: elliptical arc (circles, ellipsis)
|
|
* paths need to have a minimum distance to other paths
|
|
* if two paths are connected ("T-junction"), this junction needs to be
|
|
exempt from the distance check.
|
|
"""
|
|
|
|
from lxml import etree
|
|
import inkex
|
|
from inkex import bezier
|
|
from inkex.paths import Path
|
|
from inkex import Color
|
|
import sys
|
|
import logging
|
|
|
|
from shapely.geometry import LineString, MultiLineString, Point, MultiPoint, GeometryCollection
|
|
from shapely import speedups
|
|
|
|
if speedups.available:
|
|
speedups.enable()
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def take_N(seq, n):
|
|
"""
|
|
split ``seq`` into slices of length ``n``. the total
|
|
length of ``seq` must be a multiple of ``n``.
|
|
"""
|
|
if len(seq) % n != 0:
|
|
raise ValueError("len=%d, n=%d, (%s)" % (len(seq), n, seq))
|
|
sub = []
|
|
for elem in seq:
|
|
sub.append(elem)
|
|
if len(sub) == n:
|
|
yield sub
|
|
sub = []
|
|
|
|
|
|
def linear_interp(a, b, t):
|
|
"""
|
|
linearly interpolate between ``a`` and ``b``. ``t`` must be a
|
|
a float between 0 and 1.
|
|
"""
|
|
return (1 - t) * a + t * b
|
|
|
|
|
|
def sample(start, stop, num):
|
|
"""
|
|
interpolate between start and stop, and yield ``num`` samples
|
|
"""
|
|
if num == 0:
|
|
return
|
|
delta = 1.0 / num
|
|
t = 0
|
|
for i in range(num):
|
|
yield linear_interp(start, stop, t)
|
|
t += delta
|
|
|
|
|
|
class CheckerResult(object):
|
|
|
|
def __init__(self, msg, elem, extra=None, max_len=50):
|
|
self.msg = msg
|
|
self.elem = elem
|
|
self.extra = extra
|
|
self.max_len = max_len
|
|
|
|
def fmt(self, s):
|
|
s = ", ".join(["%s: %s" % (k, v) for k, v in s.items()])
|
|
if len(s) > self.max_len:
|
|
return s[:50] + u"…"
|
|
return s
|
|
|
|
def __unicode__(self):
|
|
msg, elem, extra = self.msg, self.elem, self.extra
|
|
if extra:
|
|
return "%s: %s (%s)" % (msg, elem.get("id"), self.fmt(extra))
|
|
else:
|
|
return "%s: %s" % (msg, elem.get("id"))
|
|
|
|
def __repr__(self):
|
|
return "<CheckerResult: %s>" % self.msg
|
|
|
|
|
|
class Checker(object):
|
|
def __call__(self, elem):
|
|
"""
|
|
run a check on ``elem`` and yield (elem, message) tuples
|
|
for each failed check
|
|
"""
|
|
raise NotImplementedError("please implement __call__")
|
|
|
|
def collect(self):
|
|
"""
|
|
run a second stage check on aggregated data
|
|
"""
|
|
return []
|
|
|
|
|
|
class StyleChecker(Checker):
|
|
def __call__(self, elem):
|
|
style = elem.get("style")
|
|
if style is None:
|
|
return
|
|
parsed = dict(inkex.Style.parse_str(style))
|
|
if "stroke" in parsed and parsed["stroke"] != "none":
|
|
yield CheckerResult("element with stroke found", elem)
|
|
|
|
|
|
class ElemBlacklistChecker(Checker):
|
|
blacklist = ["text"]
|
|
|
|
def __call__(self, elem):
|
|
_, tag = elem.tag.rsplit("}", 1)
|
|
if tag in self.blacklist:
|
|
yield CheckerResult("'%s' element found in document" % tag, elem)
|
|
|
|
|
|
class Subpath(object):
|
|
def __init__(self):
|
|
self.points = []
|
|
self.cursor = None
|
|
|
|
def __len__(self):
|
|
return len(self.points)
|
|
|
|
@property
|
|
def last_point(self):
|
|
if self.points:
|
|
return self.points[-1]
|
|
|
|
@property
|
|
def first_point(self):
|
|
if self.points:
|
|
return self.points[0]
|
|
|
|
def moveto(self, point):
|
|
assert len(self) == 0, "moveto may only be called at the beginning of a subpath"
|
|
self.points.append(point)
|
|
self.cursor = point
|
|
|
|
def lineto(self, point):
|
|
self.points.append(point)
|
|
self.cursor = point
|
|
|
|
def curveto(self, points):
|
|
for p in self.approx_curve([self.cursor] + points):
|
|
self.lineto(p)
|
|
|
|
def closepath(self):
|
|
self.lineto(self.first_point)
|
|
|
|
def add_points(self, points):
|
|
self.points.extend(points)
|
|
self.cursor = points[-1]
|
|
|
|
def as_linestring(self):
|
|
return LineString(self.points)
|
|
|
|
def approx_curve(self, points):
|
|
for four in take_N(points, 4):
|
|
# TODO: automatically set number of samples depending on length
|
|
for t in sample(0, 1, 50):
|
|
yield bezier.bezierpointatt(four, t)
|
|
|
|
|
|
class IntersectionChecker(Checker):
|
|
def __init__(self):
|
|
self.paths = []
|
|
|
|
def __call__(self, elem):
|
|
# logger.debug(elem.attrib)
|
|
path = elem.get("d")
|
|
if path is None:
|
|
return []
|
|
parsed = Path(path).to_arrays()
|
|
self.paths.append((parsed, elem))
|
|
# logger.debug(parsed)
|
|
return []
|
|
|
|
def fixVHbehaviour(self, elem):
|
|
raw = Path(elem.get("d")).to_arrays()
|
|
subpaths, prev = [], 0
|
|
for i in range(len(raw)): # Breaks compound paths into simple paths
|
|
if raw[i][0] == 'M' and i != 0:
|
|
subpaths.append(raw[prev:i])
|
|
prev = i
|
|
subpaths.append(raw[prev:])
|
|
seg = []
|
|
for simpath in subpaths:
|
|
if simpath[-1][0] == 'Z':
|
|
simpath[-1][0] = 'L'
|
|
if simpath[-2][0] == 'L': simpath[-1][1] = simpath[0][1]
|
|
else: simpath.pop()
|
|
for i in range(len(simpath)):
|
|
if simpath[i][0] == 'V': # vertical and horizontal lines only have one point in args, but 2 are required
|
|
#inkex.utils.debug(simpath[i][0])
|
|
simpath[i][0]='L' #overwrite V with regular L command
|
|
add=simpath[i-1][1][0] #read the X value from previous segment
|
|
simpath[i][1].append(simpath[i][1][0]) #add the second (missing) argument by taking argument from previous segment
|
|
simpath[i][1][0]=add #replace with recent X after Y was appended
|
|
if simpath[i][0] == 'H': # vertical and horizontal lines only have one point in args, but 2 are required
|
|
#inkex.utils.debug(simpath[i][0])
|
|
simpath[i][0]='L' #overwrite H with regular L command
|
|
simpath[i][1].append(simpath[i-1][1][1]) #add the second (missing) argument by taking argument from previous segment
|
|
#inkex.utils.debug(simpath[i])
|
|
seg.append(simpath[i])
|
|
elem.set("d", Path(seg))
|
|
return seg
|
|
|
|
def get_line_strings(self):
|
|
# logger.debug("paths: %s", self.paths)
|
|
for path, elem in self.paths:
|
|
path = self.fixVHbehaviour(elem)
|
|
logger.debug("new path, %s", elem.get("id"))
|
|
current_subpath = Subpath()
|
|
for cmd, coords in path:
|
|
logger.debug(" new command: %s", cmd)
|
|
if cmd != "A":
|
|
points = list(take_N(coords, n=2))
|
|
else:
|
|
points = list(take_N(coords, n=7))
|
|
logger.debug(" points: %s", points)
|
|
if cmd == "M":
|
|
# M starts a new subpath
|
|
if len(current_subpath) > 1:
|
|
yield current_subpath, elem
|
|
current_subpath = Subpath()
|
|
current_subpath.moveto(points[0])
|
|
# more than one point means the rest of the points are to
|
|
# be treated as if cmd was L:
|
|
# http://www.w3.org/TR/SVG/paths.html#PathDataMovetoCommands
|
|
if len(points) > 1:
|
|
points = points[1:]
|
|
cmd = "L"
|
|
if cmd == "L":
|
|
current_subpath.add_points(points)
|
|
if cmd == "Z":
|
|
current_subpath.closepath()
|
|
if cmd == "C":
|
|
current_subpath.curveto(points)
|
|
if cmd == "Q":
|
|
logger.warning("quadratic beziers are not supported yet")
|
|
# current_subpath.moveto(points[-1])
|
|
if cmd == "A":
|
|
logger.warning("elliptic arcs are not supported yet")
|
|
if len(current_subpath) > 1:
|
|
yield current_subpath, elem
|
|
current_subpath = Subpath()
|
|
|
|
def collect(self):
|
|
return self.check_intersections()
|
|
|
|
def check_intersections(self):
|
|
checks_done = MultiLineString()
|
|
for subpath, elem in self.get_line_strings():
|
|
line = subpath.as_linestring()
|
|
if not line.is_simple:
|
|
# TODO: find location of self-intersection and introduce some
|
|
# tolerance
|
|
# checks_done = checks_done.union(line)
|
|
yield CheckerResult("self-intersection found", elem)
|
|
# continue
|
|
if checks_done.intersects(line):
|
|
intersection = checks_done.intersection(line)
|
|
yield CheckerResult("intersection found", elem, extra={"intersection": intersection})
|
|
checks_done = checks_done.union(line)
|
|
|
|
|
|
class ErrorVisualization(object):
|
|
def __init__(self, svg, effect, color, group_id="precut_errors"):
|
|
self.svg = svg
|
|
self.color = color
|
|
|
|
g = svg.find(".//%s[@id='%s']" % (inkex.addNS("g", "svg"), group_id))
|
|
if g is not None:
|
|
self.g = g
|
|
else:
|
|
parent = svg
|
|
attrs = {"id": group_id, "style": "opacity:.5", inkex.addNS("label", "inkscape"): "Precut Errors"}
|
|
self.g = etree.SubElement(parent, inkex.addNS("g", "svg"), attrs)
|
|
|
|
def fmt_point(self, point):
|
|
return "%s %s" % point
|
|
|
|
def convert(self, geom):
|
|
"""
|
|
convert a shapely geometry to SVG
|
|
"""
|
|
|
|
def vis_line_string(geom):
|
|
path = []
|
|
point_iter = iter(geom.coords)
|
|
head = next(point_iter)
|
|
tail = list(point_iter)
|
|
path.append("M%s" % self.fmt_point(head))
|
|
for point in tail:
|
|
path.append("L%s" % self.fmt_point(point))
|
|
attrs = {"d": " ".join(path), "style": "stroke:%s;stroke-width:5px;" % self.color}
|
|
etree.SubElement(self.g, inkex.addNS("path", "svg"), attrs)
|
|
|
|
def vis_point(geom):
|
|
x, y = geom.x, geom.y
|
|
x1, y1 = x - 5, y - 5
|
|
x2, y2 = x - 5, y + 5
|
|
x3, y3 = x + 5, y + 5
|
|
x4, y4 = x + 5, y - 5
|
|
vis_line_string(LineString([(x1, y1), (x2, y2), (x3, y3), (x4, y4), (x1, y1)]))
|
|
|
|
def vis_geom_collection(geom):
|
|
for g in geom.geoms:
|
|
self.convert(g)
|
|
|
|
converters = {
|
|
LineString: vis_line_string,
|
|
Point: vis_point,
|
|
MultiLineString: vis_geom_collection,
|
|
MultiPoint: vis_geom_collection,
|
|
GeometryCollection: vis_geom_collection,
|
|
}
|
|
converters[geom.__class__](geom)
|
|
|
|
def add_error(self, geom):
|
|
self.convert(geom)
|
|
|
|
|
|
class PathIntersections(inkex.Effect):
|
|
def __init__(self, *args, **kwargs):
|
|
self.check_result = []
|
|
self.checkers = [ElemBlacklistChecker(), StyleChecker(), IntersectionChecker()]
|
|
inkex.Effect.__init__(self, *args, **kwargs)
|
|
self.arg_parser.add_argument("--color", type=Color, default='4012452351', help="Error highlight color")
|
|
|
|
def walk(self, elem):
|
|
if elem.get("id") == "precut_errors":
|
|
return
|
|
for child in elem.iterchildren():
|
|
self.visit(child)
|
|
self.walk(child)
|
|
|
|
def visit(self, elem):
|
|
logger.debug("visiting %s", elem)
|
|
for checker in self.checkers:
|
|
self.check_result.extend(checker(elem))
|
|
|
|
def effect(self):
|
|
svg = self.document.getroot()
|
|
self.walk(svg)
|
|
vis = ErrorVisualization(svg, self, color=self.options.color)
|
|
# additional "collect" pass for "global" analysis
|
|
for checker in self.checkers:
|
|
self.check_result.extend(checker.collect())
|
|
for res in self.check_result:
|
|
#print >>sys.stderr, unicode(res).encode("utf8")
|
|
#print(sys.stderr, str(res.encode("utf8")))
|
|
if res.extra and "intersection" in res.extra:
|
|
# TODO: add visualization for other kinds of errors
|
|
vis.add_error(res.extra["intersection"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(stream=sys.stderr, level=logging.WARNING, format="%(levelname)s %(message)s")
|
|
PathIntersections().run()
|