#!/usr/bin/env python3 ''' ---DESTRUCTIVE Clip--- An Inkscape Extension which works like Object|Clip|Set except that the paths clipped are actually *modified* Thus the clipping is included when exported, for example as a DXF file. Select two or more *paths* then choose Extensions|Modify path|Destructive clip. The topmost path will be used to clip the others. Notes:- * Curves in paths are not supported (use Flatten Beziers). * Non-path objects in the selection will be ignored. Use Object|Ungroup. * Paths entirely outside the clipping path will remain untouched (rather than modifying them to an empty path) * Complex paths may take a while (there seems to be no way too show progress) * Yes, using MBR's to do gross clipping might make it faster * No, Python is not my first language (C/C++ is) Mark Wilson Feb 2016 ---- Edits by Windell H. Oskay, www.evilmadscientit.com, August 2020 Update calls to Inkscape 1.0 extension API to avoid deprecation warnings Minimal standardization of python whitespace Handle some errors more gracefully ''' import inkex import sys from inkex.paths import Path class DestructiveClip(inkex.EffectExtension): def __init__(self): self.tolerance = 0.0001 # arbitrary fudge factor inkex.Effect.__init__(self) self.error_messages = [] self.curve_error = 'Unable to parse path.\nConsider removing curves with Extensions > Modify Path > Flatten Beziers...' def approxEqual(self, a, b): # compare with tiny tolerance return abs(a-b) <= self.tolerance def midPoint(self, line): # midPoint of line return [(line[0][0] + line[1][0])/2, (line[0][1] + line[1][1])/2] def maxX(self, lineSegments): # return max X coord of lineSegments maxx = 0.0 for line in lineSegments: maxx = max(maxx, line[0][0]) maxx = max(maxx, line[1][0]) return maxx def simplepathToLineSegments(self, path): # takes a simplepath and converts to line *segments*, for simplicity. # Thus [MoveTo P0, LineTo P1, LineTo P2] becomes [[P0-P1],[P1,P2]] # only handles, Move, Line and Close. # The simplepath library has already simplified things, normalized relative commands, etc lineSegments = first = prev = this = [] errors = set([]) # Similar errors will be stored only once for cmd in path: this = cmd[1] if cmd[0] == 'M': # moveto if first == []: first = this elif cmd[0] == 'L': # lineto lineSegments.append([prev, this]) elif cmd[0] == 'Z': # close lineSegments.append([prev, first]) first = [] elif cmd[0] == 'C': # https://developer.mozilla.org/en/docs/Web/SVG/Tutorial/Paths lineSegments.append([prev, [this[4], this[5]]]) errors.add("Curve node detected (svg type C), this node will be handled as a regular node") else: errors.add("Invalid node type detected: {}. This script only handle type M, L, Z".format(cmd[0])) prev = this return (lineSegments, errors) def linesgmentsToSimplePath(self, lineSegments): # reverses simplepathToLines - converts line segments to Move/Line-to's path = [] end = None for line in lineSegments: start = line[0] if end is None: path.append(['M', start]) # start with a move elif not (self.approxEqual(end[0], start[0]) and self.approxEqual(end[1], start[1])): path.append(['M', start]) # only move if previous end not within tolerance of this start end = line[1] path.append(['L', end]) return path def lineIntersection(self, L1From, L1To, L2From, L2To): # returns as [x, y] the intersection of the line L1From-L1To and L2From-L2To, or None # http://stackoverflow.com/questions/563198/how-do-you-detect-where-two-line-segments-intersect try: dL1 = [L1To[0] - L1From[0], L1To[1] - L1From[1]] dL2 = [L2To[0] - L2From[0], L2To[1] - L2From[1]] except IndexError: inkex.errormsg(self.curve_error) sys.exit() denominator = -dL2[0]*dL1[1] + dL1[0]*dL2[1] if not self.approxEqual(denominator, 0.0): s = (-dL1[1]*(L1From[0] - L2From[0]) + dL1[0]*(L1From[1] - L2From[1]))/denominator t = (+dL2[0]*(L1From[1] - L2From[1]) - dL2[1]*(L1From[0] - L2From[0]))/denominator if s >= 0.0 and s <= 1.0 and t >= 0.0 and t <= 1.0: return [L1From[0] + (t * dL1[0]), L1From[1] + (t * dL1[1])] else: return None def insideRegion(self, point, lineSegments, lineSegmentsMaxX): # returns true if point is inside the region defined by lineSegments. lineSegmentsMaxX is the maximum X extent ray = [point, [lineSegmentsMaxX*2.0, point[1]]] # hz line to right of point, extending well outside MBR crossings = 0 for line in lineSegments: if not self.lineIntersection(line[0], line[1], ray[0], ray[1]) is None: crossings += 1 return (crossings % 2) == 1 # odd number of crossings means inside def cullSegmentedLine(self, segmentedLine, lineSegments, lineSegmentsMaxX): # returns just the segments in segmentedLine which are inside lineSegments culled = [] for segment in segmentedLine: if self.insideRegion(self.midPoint(segment), lineSegments, lineSegmentsMaxX): culled.append(segment) return culled def clipLine(self, line, lineSegments): # returns line split where-ever lines in lineSegments cross it linesWrite = [line] for segment in lineSegments: linesRead = linesWrite linesWrite = [] for line in linesRead: intersect = self.lineIntersection(line[0], line[1], segment[0], segment[1]) if intersect is None: linesWrite.append(line) else: # split linesWrite.append([line[0], intersect]) linesWrite.append([intersect, line[1]]) return linesWrite def clipLineSegments(self, lineSegmentsToClip, clippingLineSegments): # return the lines in lineSegmentsToClip clipped by the lines in clippingLineSegments clippedLines = [] for lineToClip in lineSegmentsToClip: clippedLines.extend(self.cullSegmentedLine(self.clipLine(lineToClip, clippingLineSegments), clippingLineSegments, self.maxX(clippingLineSegments))) return clippedLines #you can also run the extension Modify Path > To Absolute Coordinates to convert VH to L 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 effect(self): clippingLineSegments = None pathTag = inkex.addNS('path', 'svg') groupTag = inkex.addNS('g', 'svg') self.error_messages = [] for id in self.options.ids: # the selection, top-down node = self.svg.selected[id] if node.tag == pathTag: path = self.fixVHbehaviour(node) if clippingLineSegments is None: # first path is the clipper #(clippingLineSegments, errors) = self.simplepathToLineSegments(node.path.to_arrays()) (clippingLineSegments, errors) = self.simplepathToLineSegments(path) self.error_messages.extend(['{}: {}'.format(id, err) for err in errors]) else: # do all the work! #segmentsToClip, errors = self.simplepathToLineSegments(node.path.to_arrays()) segmentsToClip, errors = self.simplepathToLineSegments(path) self.error_messages.extend(['{}: {}'.format(id, err) for err in errors]) clippedSegments = self.clipLineSegments(segmentsToClip, clippingLineSegments) if len(clippedSegments) != 0: path = str(inkex.Path(self.linesgmentsToSimplePath(clippedSegments))) node.set('d', path) else: # don't put back an empty path(?) could perhaps put move, move? inkex.errormsg('Object {} clipped to nothing, will not be updated.'.format(node.get('id'))) elif node.tag == groupTag: # we don't look inside groups for paths inkex.errormsg('Group object {} will be ignored. Please ungroup before running the script.'.format(id)) else: # something else inkex.errormsg('Object {} is not of type path ({}), and will be ignored. Current type "{}".'.format(id, pathTag, node.tag)) for error in self.error_messages: inkex.errormsg(error) if __name__ == '__main__': DestructiveClip().run()