From a91726e1e1bcf442c0323fa43cd92f48d9643806 Mon Sep 17 00:00:00 2001 From: miklo Date: Sat, 27 Dec 2025 00:34:45 +0100 Subject: [PATCH] Initial: move params to plugin UI, create a stamp from features, add typing --- README.md | 29 +- svg2scad2cc.inx | 71 +++++ svg2scad2cc.py | 492 +++++++++++++++++++++++++++++++ svgpath/__init__.py | 2 + svgpath/parser.py | 700 ++++++++++++++++++++++++++++++++++++++++++++ svgpath/path.py | 646 ++++++++++++++++++++++++++++++++++++++++ svgpath/shader.py | 136 +++++++++ test/test.png | Bin 0 -> 12546 bytes test/test.scad | 136 +++++++++ test/test.svg | 64 ++++ 10 files changed, 2275 insertions(+), 1 deletion(-) create mode 100644 svg2scad2cc.inx create mode 100755 svg2scad2cc.py create mode 100644 svgpath/__init__.py create mode 100644 svgpath/parser.py create mode 100644 svgpath/path.py create mode 100755 svgpath/shader.py create mode 100644 test/test.png create mode 100644 test/test.scad create mode 100644 test/test.svg diff --git a/README.md b/README.md index f3d8029..86134f2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ # svg2scad2cc -Inkscape plugin to convert SVG paths into an OpenSCAD cookie cutter (with stamp) \ No newline at end of file +Inkscape plugin to convert SVG paths into an OpenSCAD cookie cutter (with stamp) + +## Usage: + +1. Unpack contents of the repo to the Inkscape extensions directory. +2. Convert all objects to paths. + - red (no fill) - outer outline / through cut. + - green (no fill) - inner walls / through cut. + - black (no fill) - internal features. If a demoulding plate is selected, together they form a stamp. + - any color (fill without stroke) - connections between inner walls and outer outline. +3. Select File -> Save As... "OpenSCAD cookie-cutter/stamp (*.scad)". +4. Adjust parameters (wall thickness, generating demoulding plate/stamp). +5. Load into OpenSCAD, generate STL, 3D print. + +![svg test file](test/test.svg) + +![OpenSCAD output file](test/test.scad) + +![OpenSCAD preview screenshot](test/test.png) + +## Credits + + * Main code derived from (svg2cookiecutter) (c) Alexander R. Pruss + * SVG path code (c) Lennart Regebro, Justin Gruenberg + +## License + +GPL v3.0 diff --git a/svg2scad2cc.inx b/svg2scad2cc.inx new file mode 100644 index 0000000..1c12ff8 --- /dev/null +++ b/svg2scad2cc.inx @@ -0,0 +1,71 @@ + + + <_name>OpenSCAD cookie-cutter/stamp export + eu.citizen4.forge.svg2scad2cc + org.inkscape.output.svg.inkscape + svg2scad2cc.py + + + .scad + text/plain + <_filetypename>OpenSCAD cookie-cutter/stamp (*.scad) + <_filetypetooltip>Export an OpenSCAD cookie-cutter/stamp + true + + + + + + 12.0 + 1.6 + 1.6 + 1.6 + 1.6 + + + + 8.0 + 2.0 + 5.0 + 1.6 + + 1.0 + 1.0 + 3.0 + + 1.6 + 3.0 + 0.8 + + 2.0 + 0.5 + + + 0.1 + false + + + + + + + + + + + diff --git a/svg2scad2cc.py b/svg2scad2cc.py new file mode 100755 index 0000000..4811e23 --- /dev/null +++ b/svg2scad2cc.py @@ -0,0 +1,492 @@ +""" +svg2scad2cc.py + +Script to convert SVG paths into an OpenSCAD cookie cutter (with stamp). + +Original code from plugin svg2cookiecutter.py +""" + +from __future__ import annotations + +import sys +import argparse +from typing import List, Tuple, Optional, Dict, Any + +import svgpath.parser as svgpath_parser + + +# PRELIM template: use named placeholders to be filled from extension GUI. +# OpenSCAD uses curly braces { }, so they are escaped as double braces {{ }} for .format(). +PRELIM_TEMPLATE = """// OpenSCAD file automatically generated by svg2scad2cc.py +// parameters tunable by user +wallHeight = {wallHeight}; +minWallThickness = {minWallThickness}; +maxWallThickness = {maxWallThickness}; +minInsideWallThickness = {minInsideWallThickness}; +maxInsideWallThickness = {maxInsideWallThickness}; + +wallFlareWidth = {wallFlareWidth}; +wallFlareThickness = {wallFlareThickness}; +insideWallFlareWidth = {insideWallFlareWidth}; +insideWallFlareThickness = {insideWallFlareThickness}; + +featureHeight = {featureHeight}; +minFeatureThickness = {minFeatureThickness}; +maxFeatureThickness = {maxFeatureThickness}; + +connectorThickness = {connectorThickness}; +cuttingTaperHeight = {cuttingTaperHeight}; +cuttingEdgeThickness = {cuttingEdgeThickness}; +// set to non-zero value to generate a demoulding plate +demouldingPlateHeight = {demouldingPlateHeight}; +demouldingPlateSlack = {demouldingPlateSlack}; + +// sizing function +function clamp(t,minimum,maximum) = min(maximum,max(t,minimum)); +function featureThickness(t) = clamp(t,minFeatureThickness,maxFeatureThickness); +function wallThickness(t) = clamp(t,minWallThickness,maxWallThickness); +function insideWallThickness(t) = clamp(t,minInsideWallThickness,maxInsideWallThickness); + +size = $OVERALL_SIZE$; +scale = size/$OVERALL_SIZE$; + +// helper modules: subshapes +module ribbon(points, thickness=1) {{ + union() {{ + for (i=[1:len(points)-1]) {{ + hull() {{ + translate(points[i-1]) circle(d=thickness, $fn=8); + translate(points[i]) circle(d=thickness, $fn=8); + }} + }} + }} +}} + +module wall(points,height,thickness) {{ + module profile() {{ + if (height>=cuttingTaperHeight && cuttingTaperHeight>0 && cuttingEdgeThickness str: + """ + Build the PRELIM section for the generated OpenSCAD file from parameters. + + Args: + params: dict + Dictionary mapping parameter names (strings) to numeric values. + Expected keys: + - wallHeight, minWallThickness, maxWallThickness, + minInsideWallThickness, maxInsideWallThickness, + wallFlareWidth, wallFlareThickness, insideWallFlareWidth, + insideWallFlareThickness, featureHeight, minFeatureThickness, + maxFeatureThickness, connectorThickness, cuttingTaperHeight, + cuttingEdgeThickness, demouldingPlateHeight, demouldingPlateSlack + + Returns: + A string containing the PRELIM OpenSCAD code with parameter values substituted. + """ + defaults: Dict[str, float] = { + "wallHeight": 12.0, + "minWallThickness": 1.6, + "maxWallThickness": 1.6, + "minInsideWallThickness": 1.6, + "maxInsideWallThickness": 1.6, + "wallFlareWidth": 8.0, + "wallFlareThickness": 2.0, + "insideWallFlareWidth": 5.0, + "insideWallFlareThickness": 2.0, + "featureHeight": 1.0, + "minFeatureThickness": 1.0, + "maxFeatureThickness": 3.0, + "connectorThickness": 1.6, + "cuttingTaperHeight": 3.0, + "cuttingEdgeThickness": 0.8, + "demouldingPlateHeight": 2.0, + "demouldingPlateSlack": 0.5 + } + filled: Dict[str, float] = {**defaults} + for k, v in params.items(): + if v is None: + continue + try: + filled[k] = float(v) + except (ValueError, TypeError): + # ignore invalid values, keep default + continue + + # format numeric values to reasonable precision (OpenSCAD accepts floats) + formatted: Dict[str, str] = {} + for k, v in filled.items(): + formatted[k] = ("%.3f" % v).rstrip('0').rstrip('.') if isinstance(v, float) else str(v) + + return PRELIM_TEMPLATE.format(**formatted) + + +def isRed(rgb: Optional[Tuple[float, float, float]]) -> bool: + """Return True if rgb tuple represents primarily red color.""" + return rgb is not None and rgb[0] >= 0.4 and (rgb[1] + rgb[2]) < rgb[0] * 0.25 + + +def isGreen(rgb: Optional[Tuple[float, float, float]]) -> bool: + """Return True if rgb tuple represents primarily green color.""" + return rgb is not None and rgb[1] >= 0.4 and (rgb[0] + rgb[2]) < rgb[1] * 0.25 + + +def isBlack(rgb: Optional[Tuple[float, float, float]]) -> bool: + """Return True if rgb tuple represents near-black color.""" + return rgb is not None and (rgb[0] + rgb[1] + rgb[2]) < 0.2 + +def str2bool(v: object) -> bool: + """ + Convert a string-like value to boolean for argparse. + + Accepts: + - boolean values (returned as-is), + - common textual representations: 'true','false','1','0','yes','no','on','off', + - None (treated as False). + + Raises: + argparse.ArgumentTypeError for unrecognized values. + """ + import argparse as _argparse + + if isinstance(v, bool): + return v + if v is None: + return False + s = str(v).strip().lower() + if s in ("true", "1", "yes", "y", "on"): + return True + if s in ("false", "0", "no", "n", "off"): + return False + raise _argparse.ArgumentTypeError(f"Boolean value expected (got '{v}'). Use true/false.") + +class Line: + """ + Base class representing a path line from the SVG and how it maps to OpenSCAD. + """ + + def __init__(self, + path_name: str, + points: List[Tuple[float, float]], + fill: bool, + stroke: bool, + stroke_width: Optional[float]) -> None: + self.path_name: str = path_name + self.points: List[Tuple[float, float]] = points + self.fill: bool = fill + self.stroke: bool = stroke + self.stroke_width: Optional[float] = stroke_width + + # These will typically be overridden by subclasses + self.height: str = "0" + self.width: Optional[str] = None + self.fillHeight: str = "0" + self.hasOuterFlare: bool = False + self.hasInnerFlare: bool = False + + def path_code(self) -> str: + """ + Generate OpenSCAD code that defines the polygon points for this path. + + Returns: + A string like "name = scale * [[x,y],[x,y],...];" + """ + pts = ",".join(('[%.3f,%.3f]' % (p[0], p[1]) for p in self.points)) + return f"{self.path_name} = scale * [{pts}];" + + def shapes_code(self) -> str: + """ + Generate the OpenSCAD code for the shapes (wall, outerFlare, innerFlare, fill). + + Returns: + Combined OpenSCAD code fragment for this line. + """ + code_lines: List[str] = [] + if self.stroke and self.width is not None: + code_lines.append(f"wall({self.path_name},{self.height},{self.width});") + if self.hasOuterFlare: + code_lines.append(f" outerFlare({self.path_name});") + elif self.hasInnerFlare: + code_lines.append(f" innerFlare({self.path_name});") + if self.fill: + code_lines.append(f" fill({self.path_name},{self.fillHeight});") + return "\n".join(code_lines) + + +class OuterWall(Line): + """Represents an outer wall (red) stroke in the SVG.""" + + def __init__(self, path_name: str, points: List[Tuple[float, float]], fill: bool, stroke: bool, stroke_width: Optional[float]) -> None: + super().__init__(path_name, points, fill, stroke, stroke_width) + self.height = "wallHeight" + self.width = f"wallThickness({(stroke_width or 0):.3f})" if stroke_width is not None else "wallThickness(0)" + self.fillHeight = "wallHeight" + self.hasOuterFlare = True + self.hasInnerFlare = False + + +class InnerWall(Line): + """Represents an inner wall (green) stroke in the SVG.""" + + def __init__(self, path_name: str, points: List[Tuple[float, float]], fill: bool, stroke: bool, stroke_width: Optional[float]) -> None: + super().__init__(path_name, points, fill, stroke, stroke_width) + self.height = "wallHeight" + self.width = f"insideWallThickness({(stroke_width or 0):.3f})" if stroke_width is not None else "insideWallThickness(0)" + self.fillHeight = "wallHeight" + self.hasOuterFlare = False + self.hasInnerFlare = True + + +class Feature(Line): + """Represents a feature (black) fill/stroke that is not cutting all the way.""" + + def __init__(self, path_name: str, points: List[Tuple[float, float]], fill: bool, stroke: bool, stroke_width: Optional[float]) -> None: + super().__init__(path_name, points, fill, stroke, stroke_width) + self.height = "featureHeight" + self.width = f"featureThickness({(stroke_width or 0):.3f})" if stroke_width is not None else "featureThickness(0)" + self.fillHeight = "featureHeight" + self.hasOuterFlare = False + self.hasInnerFlare = False + + +class Connector(Line): + """Represents a connector or polygon fill with no stroke-based wall.""" + + def __init__(self, path_name: str, points: List[Tuple[float, float]], fill: bool) -> None: + super().__init__(path_name, points, fill, False, None) + self.width = None + self.fillHeight = "connectorThickness" + self.hasOuterFlare = False + self.hasInnerFlare = False + + +def svgToCookieCutter(filename: str, tolerance: float = 0.1, strokeAll: bool = False, prelim: Optional[str] = None) -> str: + """ + Convert an SVG file to an OpenSCAD cookie cutter script. + + Args: + filename: Path to input SVG file. + tolerance: Approximation tolerance for path linearization. + strokeAll: If True, treat all paths as stroked. + prelim: Optional PRELIM OpenSCAD string. If None, uses default PRELIM_TEMPLATE defaults. + + Returns: + The generated OpenSCAD script as a string. + """ + lines: List[Line] = [] + feature_lines: List[Feature] = [] + pathCount = 0 + minXY: List[float] = [float("inf"), float("inf")] + maxXY: List[float] = [float("-inf"), float("-inf")] + + # Parse SVG and build Line instances + svg_paths = svgpath_parser.getPathsFromSVGFile(filename)[0] + for superpath in svg_paths: + for path in superpath.breakup(): + pathName = '_' + str(pathCount) + pathCount += 1 + fill = path.svgState.fill is not None + stroke = strokeAll or (path.svgState.stroke is not None) + if not stroke and not fill: + continue + + linearPath = path.linearApproximation(error=tolerance) + # collect points (negating x as original script did) + points: List[Tuple[float, float]] = [(-l.start.real, l.start.imag) for l in linearPath] + points.append((-linearPath[-1].end.real, linearPath[-1].end.imag)) + + svg_fill = path.svgState.fill + svg_stroke = path.svgState.stroke + stroke_width = getattr(path.svgState, "strokeWidth", None) + + if isRed(svg_fill) or isRed(svg_stroke): + line = OuterWall('outerWall' + pathName, points, fill, stroke, stroke_width) + elif isGreen(svg_fill) or isGreen(svg_stroke): + line = InnerWall('innerWall' + pathName, points, fill, stroke, stroke_width) + elif isBlack(svg_fill) or isBlack(svg_stroke): + feature_line = Feature('feature' + pathName, points, fill, stroke, stroke_width) + line = feature_line + feature_lines.append(feature_line) + else: + line = Connector('connector' + pathName, points, fill) + + # update bounding box + for i in range(2): + minXY[i] = min(minXY[i], min(p[i] for p in line.points)) + maxXY[i] = max(maxXY[i], max(p[i] for p in line.points)) + lines.append(line) + + bbox_width = maxXY[0] - minXY[0] + size = max(bbox_width, maxXY[1] - minXY[1]) + + # Start building code + prelim_text = prelim if prelim is not None else build_prelim_from_params({}) + code: List[str] = [prelim_text] + code.append('// data from svg file') + code += [line.path_code() + '\n' for line in lines] + + code.append('// Main modules\nmodule cookieCutter() {') + for line in lines: + shapes = line.shapes_code() + if not shapes: + continue + if isinstance(line, Feature): + code.append(" if (demouldingPlateHeight <= 0) {") + for shape_line in shapes.splitlines(): + code.append(" " + shape_line) + code.append(" }") + else: + for shape_line in shapes.splitlines(): + code.append(" " + shape_line) + code.append('}\n') + + # demoulding plate module + positives = [line for line in lines if isinstance(line, OuterWall) and line.stroke and not line.fill] + negatives_stroke = [line for line in lines if not isinstance(line, Feature)] + negatives_fill = [line for line in lines if not isinstance(line, (OuterWall, Feature)) and line.fill] + + code.append("module demouldingPlate(){") + code.append(" // A plate to help push on the cookie to turn it out.") + code.append(" // If features exists, they forms stamp with plate.") + code.append(" render(convexity=10) difference() {") + code.append(" linear_extrude(height=demouldingPlateHeight) union() {") + for line in positives: + code.append(f" polygon(points={line.path_name});") + code.append(" }") + code.append(" translate([0,0,-0.01]) linear_extrude(height=demouldingPlateHeight+0.02) union() {") + for line in negatives_stroke: + if line.stroke and line.width: + code.append(f" ribbon({line.path_name},thickness=demouldingPlateSlack+{line.width});") + elif line.stroke: + code.append(f" ribbon({line.path_name},thickness=demouldingPlateSlack);") + code.append(" }") + code.append(" }") + if feature_lines: + code.append(" if (demouldingPlateHeight>0) {") + code.append(" translate([0,0,demouldingPlateHeight]) {") + code.append(" // features transferred onto the demoulding plate") + for feature in feature_lines: + feature_shapes = feature.shapes_code() + if not feature_shapes: + continue + for shape_line in feature_shapes.splitlines(): + code.append(" " + shape_line) + code.append(" }") + code.append(" }") + code.append("}\n") + + code.append('////////////////////////////////////////////////////////////////////////////////') + code.append('// final call, use main modules') + translate_x = -minXY[0] + translate_y = -minXY[1] + code.append(f"translate([{translate_x:.3f}*scale + wallFlareWidth/2, {translate_y:.3f}*scale + wallFlareWidth/2,0])") + code.append(" cookieCutter();\n") + + code.append('// translate([-40,15,0]) cylinder(h=wallHeight+10,d=5,$fn=20); // handle') + demould_offset_expr = f"{bbox_width:.3f}*scale + wallFlareWidth + demouldingPlateSlack" + code.append('if (demouldingPlateHeight>0)') + code.append(f" translate([{translate_x:.3f}*scale + wallFlareWidth/2 + {demould_offset_expr}, {translate_y:.3f}*scale + wallFlareWidth/2,0])") + code.append(' demouldingPlate();') + + return '\n'.join(code).replace('$OVERALL_SIZE$', '%.3f' % size) + + +def _collect_prelim_params_from_namespace(ns: argparse.Namespace) -> Dict[str, Any]: + """ + Helper to extract only PRELIM-related args from argparse Namespace. + """ + keys = [ + "wallHeight", "minWallThickness", "maxWallThickness", + "minInsideWallThickness", "maxInsideWallThickness", + "wallFlareWidth", "wallFlareThickness", "insideWallFlareWidth", + "insideWallFlareThickness", "featureHeight", "minFeatureThickness", + "maxFeatureThickness", "connectorThickness", "cuttingTaperHeight", + "cuttingEdgeThickness", "demouldingPlateHeight", "demouldingPlateSlack" + ] + params: Dict[str, Any] = {} + for k in keys: + if hasattr(ns, k): + val = getattr(ns, k) + if val is not None: + params[k] = val + return params + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description="Convert SVG to OpenSCAD cookie cutter") + parser.add_argument('inputfile', help='Input SVG file') + + # Add PRELIM parameters as optional command-line args (can be passed from .inx) + parser.add_argument('--wallHeight', type=float) + parser.add_argument('--minWallThickness', type=float) + parser.add_argument('--maxWallThickness', type=float) + parser.add_argument('--minInsideWallThickness', type=float) + parser.add_argument('--maxInsideWallThickness', type=float) + + parser.add_argument('--wallFlareWidth', type=float) + parser.add_argument('--wallFlareThickness', type=float) + parser.add_argument('--insideWallFlareWidth', type=float) + parser.add_argument('--insideWallFlareThickness', type=float) + + parser.add_argument('--featureHeight', type=float) + parser.add_argument('--minFeatureThickness', type=float) + parser.add_argument('--maxFeatureThickness', type=float) + + parser.add_argument('--connectorThickness', type=float) + parser.add_argument('--cuttingTaperHeight', type=float) + parser.add_argument('--cuttingEdgeThickness', type=float) + + parser.add_argument('--demouldingPlateHeight', type=float) + parser.add_argument('--demouldingPlateSlack', type=float) + + # Other optional flags + parser.add_argument('--tolerance', type=float, default=0.1) + + parser.add_argument('--tab', type=str) + + + # Accept boolean values passed as "true"/"false" from Inkscape .inx UI. + # nargs='?' allows "--strokeAll" or "--strokeAll true" or "--strokeAll false" + parser.add_argument('--strokeAll', type=str2bool, nargs='?', const=True, default=False, + help='Treat all paths as stroked (force stroke behavior). Accepts true/false') + + args = parser.parse_args() + + prelim_params = _collect_prelim_params_from_namespace(args) + prelim_text = build_prelim_from_params(prelim_params) if prelim_params else None + + # Print final OpenSCAD code to stdout + out = svgToCookieCutter(args.inputfile, tolerance=args.tolerance, strokeAll=args.strokeAll, prelim=prelim_text) + sys.stdout.write(out) diff --git a/svgpath/__init__.py b/svgpath/__init__.py new file mode 100644 index 0000000..ad78c9a --- /dev/null +++ b/svgpath/__init__.py @@ -0,0 +1,2 @@ +from .path import Path, Line, Arc, CubicBezier, QuadraticBezier +from .parser import parse_path diff --git a/svgpath/parser.py b/svgpath/parser.py new file mode 100644 index 0000000..6b320c0 --- /dev/null +++ b/svgpath/parser.py @@ -0,0 +1,700 @@ +# SVG Path specification parser + +import re +from . import path +import xml.etree.ElementTree as ET +import re +import math + +COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') +UPPERCASE = set('MZLHVCSQTA') + +COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])") +FLOAT_RE = re.compile("[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") + +SVG_COLORS = { +"aliceblue": (0.941176,0.972549,1), +"antiquewhite": (0.980392,0.921569,0.843137), +"aqua": (0,1,1), +"aquamarine": (0.498039,1,0.831373), +"azure": (0.941176,1,1), +"beige": (0.960784,0.960784,0.862745), +"bisque": (1,0.894118,0.768627), +"black": (0,0,0), +"blanchedalmond": (1,0.921569,0.803922), +"blue": (0,0,1), +"blueviolet": (0.541176,0.168627,0.886275), +"brown": (0.647059,0.164706,0.164706), +"burlywood": (0.870588,0.721569,0.529412), +"cadetblue": (0.372549,0.619608,0.627451), +"chartreuse": (0.498039,1,0), +"chocolate": (0.823529,0.411765,0.117647), +"coral": (1,0.498039,0.313725), +"cornflowerblue": (0.392157,0.584314,0.929412), +"cornsilk": (1,0.972549,0.862745), +"crimson": (0.862745,0.0784314,0.235294), +"cyan": (0,1,1), +"darkblue": (0,0,0.545098), +"darkcyan": (0,0.545098,0.545098), +"darkgoldenrod": (0.721569,0.52549,0.0431373), +"darkgray": (0.662745,0.662745,0.662745), +"darkgreen": (0,0.392157,0), +"darkgrey": (0.662745,0.662745,0.662745), +"darkkhaki": (0.741176,0.717647,0.419608), +"darkmagenta": (0.545098,0,0.545098), +"darkolivegreen": (0.333333,0.419608,0.184314), +"darkorange": (1,0.54902,0), +"darkorchid": (0.6,0.196078,0.8), +"darkred": (0.545098,0,0), +"darksalmon": (0.913725,0.588235,0.478431), +"darkseagreen": (0.560784,0.737255,0.560784), +"darkslateblue": (0.282353,0.239216,0.545098), +"darkslategray": (0.184314,0.309804,0.309804), +"darkslategrey": (0.184314,0.309804,0.309804), +"darkturquoise": (0,0.807843,0.819608), +"darkviolet": (0.580392,0,0.827451), +"deeppink": (1,0.0784314,0.576471), +"deepskyblue": (0,0.74902,1), +"dimgray": (0.411765,0.411765,0.411765), +"dimgrey": (0.411765,0.411765,0.411765), +"dodgerblue": (0.117647,0.564706,1), +"firebrick": (0.698039,0.133333,0.133333), +"floralwhite": (1,0.980392,0.941176), +"forestgreen": (0.133333,0.545098,0.133333), +"fuchsia": (1,0,1), +"gainsboro": (0.862745,0.862745,0.862745), +"ghostwhite": (0.972549,0.972549,1), +"gold": (1,0.843137,0), +"goldenrod": (0.854902,0.647059,0.12549), +"gray": (0.501961,0.501961,0.501961), +"grey": (0.501961,0.501961,0.501961), +"green": (0,0.501961,0), +"greenyellow": (0.678431,1,0.184314), +"honeydew": (0.941176,1,0.941176), +"hotpink": (1,0.411765,0.705882), +"indianred": (0.803922,0.360784,0.360784), +"indigo": (0.294118,0,0.509804), +"ivory": (1,1,0.941176), +"khaki": (0.941176,0.901961,0.54902), +"lavender": (0.901961,0.901961,0.980392), +"lavenderblush": (1,0.941176,0.960784), +"lawngreen": (0.486275,0.988235,0), +"lemonchiffon": (1,0.980392,0.803922), +"lightblue": (0.678431,0.847059,0.901961), +"lightcoral": (0.941176,0.501961,0.501961), +"lightcyan": (0.878431,1,1), +"lightgoldenrodyellow": (0.980392,0.980392,0.823529), +"lightgray": (0.827451,0.827451,0.827451), +"lightgreen": (0.564706,0.933333,0.564706), +"lightgrey": (0.827451,0.827451,0.827451), +"lightpink": (1,0.713725,0.756863), +"lightsalmon": (1,0.627451,0.478431), +"lightseagreen": (0.12549,0.698039,0.666667), +"lightskyblue": (0.529412,0.807843,0.980392), +"lightslategray": (0.466667,0.533333,0.6), +"lightslategrey": (0.466667,0.533333,0.6), +"lightsteelblue": (0.690196,0.768627,0.870588), +"lightyellow": (1,1,0.878431), +"lime": (0,1,0), +"limegreen": (0.196078,0.803922,0.196078), +"linen": (0.980392,0.941176,0.901961), +"magenta": (1,0,1), +"maroon": (0.501961,0,0), +"mediumaquamarine": (0.4,0.803922,0.666667), +"mediumblue": (0,0,0.803922), +"mediumorchid": (0.729412,0.333333,0.827451), +"mediumpurple": (0.576471,0.439216,0.858824), +"mediumseagreen": (0.235294,0.701961,0.443137), +"mediumslateblue": (0.482353,0.407843,0.933333), +"mediumspringgreen": (0,0.980392,0.603922), +"mediumturquoise": (0.282353,0.819608,0.8), +"mediumvioletred": (0.780392,0.0823529,0.521569), +"midnightblue": (0.0980392,0.0980392,0.439216), +"mintcream": (0.960784,1,0.980392), +"mistyrose": (1,0.894118,0.882353), +"moccasin": (1,0.894118,0.709804), +"navajowhite": (1,0.870588,0.678431), +"navy": (0,0,0.501961), +"oldlace": (0.992157,0.960784,0.901961), +"olive": (0.501961,0.501961,0), +"olivedrab": (0.419608,0.556863,0.137255), +"orange": (1,0.647059,0), +"orangered": (1,0.270588,0), +"orchid": (0.854902,0.439216,0.839216), +"palegoldenrod": (0.933333,0.909804,0.666667), +"palegreen": (0.596078,0.984314,0.596078), +"paleturquoise": (0.686275,0.933333,0.933333), +"palevioletred": (0.858824,0.439216,0.576471), +"papayawhip": (1,0.937255,0.835294), +"peachpuff": (1,0.854902,0.72549), +"peru": (0.803922,0.521569,0.247059), +"pink": (1,0.752941,0.796078), +"plum": (0.866667,0.627451,0.866667), +"powderblue": (0.690196,0.878431,0.901961), +"purple": (0.501961,0,0.501961), +"red": (1,0,0), +"rosybrown": (0.737255,0.560784,0.560784), +"royalblue": (0.254902,0.411765,0.882353), +"saddlebrown": (0.545098,0.270588,0.0745098), +"salmon": (0.980392,0.501961,0.447059), +"sandybrown": (0.956863,0.643137,0.376471), +"seagreen": (0.180392,0.545098,0.341176), +"seashell": (1,0.960784,0.933333), +"sienna": (0.627451,0.321569,0.176471), +"silver": (0.752941,0.752941,0.752941), +"skyblue": (0.529412,0.807843,0.921569), +"slateblue": (0.415686,0.352941,0.803922), +"slategray": (0.439216,0.501961,0.564706), +"slategrey": (0.439216,0.501961,0.564706), +"snow": (1,0.980392,0.980392), +"springgreen": (0,1,0.498039), +"steelblue": (0.27451,0.509804,0.705882), +"tan": (0.823529,0.705882,0.54902), +"teal": (0,0.501961,0.501961), +"thistle": (0.847059,0.74902,0.847059), +"tomato": (1,0.388235,0.278431), +"turquoise": (0.25098,0.878431,0.815686), +"violet": (0.933333,0.509804,0.933333), +"wheat": (0.960784,0.870588,0.701961), +"white": (1,1,1), +"whitesmoke": (0.960784,0.960784,0.960784), +"yellow": (1,1,0), +"yellowgreen": (0.603922,0.803922,0.196078), +} + +def _tokenize_path(pathdef): + for x in COMMAND_RE.split(pathdef): + if x in COMMANDS: + yield x + for token in FLOAT_RE.findall(x): + yield token + +def applyMatrix(matrix, z): + return complex(z.real * matrix[0] + z.imag * matrix[1] + matrix[2], + z.real * matrix[3] + z.imag * matrix[4] + matrix[5] ) + +def matrixMultiply(matrix1, matrix2): + if matrix1 is None: + return matrix2 + elif matrix2 is None: + return matrix1 + + m1 = [matrix1[0:3], matrix1[3:6] ] # don't need last row + m2 = [matrix2[0:3], matrix2[3:6], [0,0,1]] + + out = [] + + for i in range(2): + for j in range(3): + out.append( sum(m1[i][k]*m2[k][j] for k in range(3)) ) + + return out + +def parse_path(pathdef, current_pos=0j, matrix = None, svgState=None): + if matrix is None: + scaler=lambda z : z + else: + scaler=lambda z : applyMatrix(matrix, z) + if svgState is None: + svgState = path.SVGState() + + # In the SVG specs, initial movetos are absolute, even if + # specified as 'm'. This is the default behavior here as well. + # But if you pass in a current_pos variable, the initial moveto + # will be relative to that current_pos. This is useful. + elements = list(_tokenize_path(pathdef)) + # Reverse for easy use of .pop() + elements.reverse() + + segments = path.Path(svgState = svgState) + start_pos = None + command = None + + while elements: + + if elements[-1] in COMMANDS: + # New command. + last_command = command # Used by S and T + command = elements.pop() + absolute = command in UPPERCASE + command = command.upper() + else: + # If this element starts with numbers, it is an implicit command + # and we don't change the command. Check that it's allowed: + if command is None: + raise ValueError("Unallowed implicit command in %s, position %s" % ( + pathdef, len(pathdef.split()) - len(elements))) + last_command = command # Used by S and T + + if command == 'M': + # Moveto command. + x = elements.pop() + y = elements.pop() + pos = float(x) + float(y) * 1j + if absolute: + current_pos = pos + else: + current_pos += pos + + # when M is called, reset start_pos + # This behavior of Z is defined in svg spec: + # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand + start_pos = current_pos + + # Implicit moveto commands are treated as lineto commands. + # So we set command to lineto here, in case there are + # further implicit commands after this moveto. + command = 'L' + + elif command == 'Z': + # Close path + if current_pos != start_pos: + segments.append(path.Line(scaler(current_pos), scaler(start_pos))) + if len(segments): + segments.closed = True + current_pos = start_pos + start_pos = None + command = None # You can't have implicit commands after closing. + + elif command == 'L': + x = elements.pop() + y = elements.pop() + pos = float(x) + float(y) * 1j + if not absolute: + pos += current_pos + segments.append(path.Line(scaler(current_pos), scaler(pos))) + current_pos = pos + + elif command == 'H': + x = elements.pop() + pos = float(x) + current_pos.imag * 1j + if not absolute: + pos += current_pos.real + segments.append(path.Line(scaler(current_pos), scaler(pos))) + current_pos = pos + + elif command == 'V': + y = elements.pop() + pos = current_pos.real + float(y) * 1j + if not absolute: + pos += current_pos.imag * 1j + segments.append(path.Line(scaler(current_pos), scaler(pos))) + current_pos = pos + + elif command == 'C': + control1 = float(elements.pop()) + float(elements.pop()) * 1j + control2 = float(elements.pop()) + float(elements.pop()) * 1j + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + control1 += current_pos + control2 += current_pos + end += current_pos + + segments.append(path.CubicBezier(scaler(current_pos), scaler(control1), scaler(control2), scaler(end))) + current_pos = end + + elif command == 'S': + # Smooth curve. First control point is the "reflection" of + # the second control point in the previous path. + + if last_command not in 'CS': + # If there is no previous command or if the previous command + # was not an C, c, S or s, assume the first control point is + # coincident with the current point. + control1 = scaler(current_pos) + else: + # The first control point is assumed to be the reflection of + # the second control point on the previous command relative + # to the current point. + control1 = 2 * scaler(current_pos) - segments[-1].control2 + + control2 = float(elements.pop()) + float(elements.pop()) * 1j + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + control2 += current_pos + end += current_pos + + segments.append(path.CubicBezier(scaler(current_pos), control1, scaler(control2), scaler(end))) + current_pos = end + + elif command == 'Q': + control = float(elements.pop()) + float(elements.pop()) * 1j + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + control += current_pos + end += current_pos + + segments.append(path.QuadraticBezier(scaler(current_pos), scaler(control), scaler(end))) + current_pos = end + + elif command == 'T': + # Smooth curve. Control point is the "reflection" of + # the second control point in the previous path. + + if last_command not in 'QT': + # If there is no previous command or if the previous command + # was not an Q, q, T or t, assume the first control point is + # coincident with the current point. + control = scaler(current_pos) + else: + # The control point is assumed to be the reflection of + # the control point on the previous command relative + # to the current point. + control = 2 * scaler(current_pos) - segments[-1].control + + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + end += current_pos + + segments.append(path.QuadraticBezier(scaler(current_pos), control, scaler(end))) + current_pos = end + + elif command == 'A': + radius = float(elements.pop()) + float(elements.pop()) * 1j + rotation = float(elements.pop()) + arc = float(elements.pop()) + sweep = float(elements.pop()) + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + end += current_pos + + segments.append(path.Arc(current_pos, radius, rotation, arc, sweep, end, scaler)) + current_pos = end + + return segments + +def path_from_ellipse(x, y, rx, ry, matrix, state): + arc = "M %.9f %.9f " % (x-rx,y) + arc += "A %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, x+rx,y) + arc += "A %.9f %.9f 0 0 1 %.9f %.9f" % (rx, ry, x-rx,y) + return parse_path(arc, matrix=matrix, svgState=state) + +def path_from_rect(x,y,w,h,rx,ry, matrix,state): + if not rx and not ry: + rect = "M %.9f %.9f h %.9f v %.9f h %.9f Z" % (x,y,w,h,-w) + else: + if rx is None: + rx = ry + elif ry is None: + ry = rx + rect = "M %.9f %.9f h %.9f " % (x+rx,y,w-2*rx) + rect += "a %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, rx, ry) + rect += "v %.9f " % (h-2*ry) + rect += "a %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, -rx, ry) + rect += "h %.9f " % -(w-2*rx) + rect += "a %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, -rx, -ry) + rect += "v %.9f " % -(h-2*ry) + rect += "a %.9f %.9f 0 0 1 %.9f %.9f Z" % (rx, ry, rx, -ry) + return parse_path(rect, matrix=matrix, svgState=state) + +def sizeFromString(text): + """ + Returns size in mm, if possible. + """ + text = re.sub(r'\s',r'', text) + try: + return float(text)*25.4/96 # px + except: + if text[-1] == '%': + return float(text[:-1]) # NOT mm + units = text[-2:].lower() + x = float(text[:-2]) + convert = { 'mm':1, 'cm':10, 'in':25.4, 'px':25.4/96, 'pt':25.4/72, 'pc':12*25.4/72 } + try: + return x * convert[units] + except: + return x # NOT mm + +def rgbFromColor(colorName): + colorName = colorName.strip().lower() + if colorName == 'none': + return None + cmd = re.split(r'[\s(),]+', colorName) + if cmd[0] == 'rgb': + colors = cmd[1:4] + outColor = [] + for c in colors: + if c.endswith('%'): + outColor.append(float(c[:-1]) / 100.) + else: + outColor.append(float(c) / 255.) + return tuple(outColor) + elif colorName.startswith('#'): + if len(colorName) == 4: + return (int(colorName[1],16)/15., int(colorName[2],16)/15., int(colorName[3],16)/15.) + else: + return (int(colorName[1:3],16)/255., int(colorName[3:5],16)/255., int(colorName[5:7],16)/255.) + else: + return SVG_COLORS[colorName] + + +def getPathsFromSVG(svg): + def updateStateCommand(state,cmd,arg): + if cmd == 'fill': + state.fill = rgbFromColor(arg) + elif cmd == 'fill-opacity': + state.fillOpacity = float(arg) + elif cmd == 'fill-rule': + state.fillRule = arg +# if state.fill is None: +# state.fill = (0.,0.,0.) + elif cmd == 'stroke': + state.stroke = rgbFromColor(arg) + elif cmd == 'stroke-opacity': + state.strokeOpacity = rgbFromColor(arg) + elif cmd == 'stroke-width': + state.strokeWidth = float(arg) + elif cmd == 'vector-effect': + state.strokeWidthScaling = 'non-scaling-stroke' not in cmd + # todo better scaling for non-uniform cases? + + def updateState(tree,state,matrix): + state = state.clone() + try: + style = re.sub(r'\s',r'', tree.attrib['style']).lower() + for item in style.split(';'): + cmd,arg = item.split(':')[:2] + updateStateCommand(state,cmd,arg) + except: + pass + + for item in tree.attrib: + try: + updateStateCommand(state,item,tree.attrib[item]) + except: + pass + + if state.strokeWidth and state.strokeWidthScaling: + # this won't work great for non-uniform scaling + h = abs(applyMatrix(matrix, complex(0,state.strokeWidth)) - applyMatrix(matrix, 0j)) + w = abs(applyMatrix(matrix, complex(state.strokeWidth,0)) - applyMatrix(matrix, 0j)) + state.strokeWidth = (h+w)/2 + return state + + def reorder(a,b,c,d,e,f): + return [a,c,e, b,d,f] + + def updateMatrix(tree, matrix): + try: + transformList = re.split(r'\)[\s,]+', tree.attrib['transform'].strip().lower()) + except KeyError: + return matrix + + for transform in transformList: + cmd = re.split(r'[,()\s]+', transform) + + updateMatrix = None + + if cmd[0] == 'matrix': + updateMatrix = reorder(*list(map(float, cmd[1:7]))) + elif cmd[0] == 'translate': + x = float(cmd[1]) + if len(cmd) >= 3 and cmd[2] != '': + y = float(cmd[2]) + else: + y = 0 + updateMatrix = reorder(1,0,0,1,x,y) + elif cmd[0] == 'scale': + x = float(cmd[1]) + if len(cmd) >= 3 and cmd[2] != '': + y = float(cmd[2]) + else: + y = x + updateMatrix = reorder(x,0,0, y,0,0) + elif cmd[0] == 'rotate': + theta = float(cmd[1]) * math.pi / 180. + c = math.cos(theta) + s = math.sin(theta) + updateMatrix = [c, -s, 0, s, c, 0] + if len(cmd) >= 4 and cmd[2] != '': + x = float(cmd[2]) + y = float(cmd[3]) + updateMatrix = matrixMultiply(updateMatrix, [1,0,-x, 0,1,-y]) + updateMatrix = matrixMultiply([1,0,x, 0,1,y], updateMatrix) + elif cmd[0] == 'skewX': + theta = float(cmd[1]) * math.pi / 180. + updateMatrix = [1, math.tan(theta), 0, 0,1,0] + elif cmd[0] == 'skewY': + theta = float(cmd[1]) * math.pi / 180. + updateMatrix = [1,0,0, math.tan(theta),1,0] + + matrix = matrixMultiply(matrix, updateMatrix) + + return matrix + + def updateStateAndMatrix(tree,state,matrix): + matrix = updateMatrix(tree,matrix) + return updateState(tree,state,matrix),matrix + + def getPaths(paths, matrix, tree, state, savedElements): + def getFloat(attribute,default=0.): + try: + return float(tree.attrib[attribute].strip()) + except KeyError: + return default + + tag = re.sub(r'.*}', '', tree.tag).lower() + try: + savedElements[tree.attrib['id']] = tree + except KeyError: + pass + + state, matrix = updateStateAndMatrix(tree, state, matrix) + if tag == 'path': + path = parse_path(tree.attrib['d'], matrix=matrix, svgState=state) + if len(path): + paths.append(path) + elif tag == 'circle': + path = path_from_ellipse(getFloat('cx'), getFloat('cy'), getFloat('r'), getFloat('r'), matrix, state) + paths.append(path) + elif tag == 'ellipse': + path = path_from_ellipse(getFloat('cx'), getFloat('cy'), getFloat('rx'), getFloat('ry'), matrix, state) + paths.append(path) + elif tag == 'line': + x1 = getFloat('x1') + y1 = getFloat('y1') + x2 = getFloat('x2') + y2 = getFloat('y2') + p = 'M %.9f %.9f L %.9f %.9f' % (x1,y1,x2,y2) + path = parse_path(p, matrix=matrix, svgState=state) + paths.append(path) + elif tag == 'polygon': + points = re.split(r'[\s,]+', tree.attrib['points'].strip()) + p = ' '.join(['M', points[0], points[1], 'L'] + points[2:] + ['Z']) + path = parse_path(p, matrix=matrix, svgState=state) + paths.append(path) + elif tag == 'polyline': + points = re.split(r'[\s,]+', tree.attrib['points'].strip()) + p = ' '.join(['M', points[0], points[1], 'L'] + points[2:]) + path = parse_path(p, matrix=matrix, svgState=state) + paths.append(path) + elif tag == 'rect': + x = getFloat('x') + y = getFloat('y') + w = getFloat('width') + h = getFloat('height') + rx = getFloat('rx',default=None) + ry = getFloat('ry',default=None) + path = path_from_rect(x,y,w,h,rx,ry, matrix,state) + paths.append(path) + elif tag == 'g' or tag == 'svg': + for child in tree: + getPaths(paths, matrix, child, state, savedElements) + elif tag == 'use': + try: + link = None + for tag in tree.attrib: + if tag.strip().lower().endswith("}href"): + link = tree.attrib[tag] + break + if link is None or link[0] is not '#': + raise KeyError + source = savedElements[link[1:]] + x = 0 + y = 0 + try: + x = float(tree.attrib['x']) + except: + pass + try: + y = float(tree.attrib['y']) + except: + pass + # TODO: handle width and height? (Inkscape does not) + matrix = matrixMultiply(matrix, reorder(1,0,0,1,x,y)) + getPaths(paths, matrix, source, state, dict(savedElements)) + except KeyError: + pass + + def scale(width, height, viewBox, z): + x = (z.real - viewBox[0]) / (viewBox[2] - viewBox[0]) * width + y = (viewBox[3]-z.imag) / (viewBox[3] - viewBox[1]) * height + return complex(x,y) + + paths = [] + + try: + width = sizeFromString(svg.attrib['width'].strip()) + except KeyError: + width = None + try: + height = sizeFromString(svg.attrib['height'].strip()) + except KeyError: + height = None + + try: + viewBox = list(map(float, re.split(r'[\s,]+', svg.attrib['viewBox'].strip()))) + except KeyError: + if width is None or height is None: + raise KeyError + viewBox = [0, 0, width*96/25.4, height*96/25.4] + + if width is None: + width = viewBox[2] * 25.4/96 + + if height is None: + height = viewBox[3] * 25.4/96 + + viewBoxWidth = viewBox[2] + viewBoxHeight = viewBox[3] + + viewBox[2] += viewBox[0] + viewBox[3] += viewBox[1] + + try: + preserve = svg.attrib['preserveAspectRatio'].strip().lower().split() + if len(preserve[0]) != 8: + raise KeyError + if len(preserve)>=2 and preserve[1] == 'slice': + if viewBoxWidth/viewBoxHeight > width/height: + # viewbox is wider than viewport, so scale by height to ensure + # viewbox covers the viewport + rescale = height / viewBoxHeight + else: + rescale = width / viewBoxWidth + else: + if viewBoxWidth/viewBoxHeight > width/height: + # viewbox is wider than viewport, so scale by width to ensure + # viewport covers the viewbox + rescale = width / viewBoxWidth + else: + rescale = height / viewBoxHeight + matrix = [rescale, 0, 0, + 0, rescale, 0]; + + if preserve[0][0:4] == 'xmin': + # viewBox[0] to 0 + matrix[2] = -viewBox[0] * rescale + elif preserve[0][0:4] == 'xmid': + # viewBox[0] to width/2 + matrix[2] = -viewBox[0] * rescale + width/2 + else: # preserve[0][0:4] == 'xmax': + # viewBox[0] to width + matrix[2] = -viewBox[0] * rescale + width + + if preserve[0][4:8] == 'ymin': + # viewBox[1] to 0 + matrix[5] = -viewBox[1] * rescale + elif preserve[0][4:8] == 'ymid': + # viewBox[0] to width/2 + matrix[5] = -viewBox[1] * rescale + height/2 + else: # preserve[0][4:8] == 'xmax': + # viewBox[0] to width + matrix[5] = -viewBox[1] * rescale + height + except: + matrix = [ width/viewBoxWidth, 0, -viewBox[0]* width/viewBoxWidth, + 0, -height/viewBoxHeight, viewBox[3]*height/viewBoxHeight ] + + getPaths(paths, matrix, svg, path.SVGState(), {}) + + return ( paths, applyMatrix(matrix, complex(viewBox[0], viewBox[1])), + applyMatrix(matrix, complex(viewBox[2], viewBox[3])) ) + +def getPathsFromSVGFile(filename): + return getPathsFromSVG(ET.parse(filename).getroot()) + \ No newline at end of file diff --git a/svgpath/path.py b/svgpath/path.py new file mode 100644 index 0000000..51d4b6a --- /dev/null +++ b/svgpath/path.py @@ -0,0 +1,646 @@ +from __future__ import division +from math import sqrt, cos, sin, acos, degrees, radians, log +from collections import MutableSequence + +# This file contains classes for the different types of SVG path segments as +# well as a Path object that contains a sequence of path segments. + +MIN_DEPTH = 5 +ERROR = 1e-12 + +def segment_length(curve, start, end, start_point, end_point, error, min_depth, depth): + """Recursively approximates the length by straight lines""" + mid = (start + end) / 2 + mid_point = curve.point(mid) + length = abs(end_point - start_point) + first_half = abs(mid_point - start_point) + second_half = abs(end_point - mid_point) + + length2 = first_half + second_half + if (length2 - length > error) or (depth < min_depth): + # Calculate the length of each segment: + depth += 1 + return (segment_length(curve, start, mid, start_point, mid_point, + error, min_depth, depth) + + segment_length(curve, mid, end, mid_point, end_point, + error, min_depth, depth)) + # This is accurate enough. + return length2 + +def approximate(path, start, end, start_point, end_point, max_error, depth, max_depth): + if depth >= max_depth: + return [start_point, end_point] + actual_length = path.measure(start, end, error=max_error/4) + linear_length = abs(end_point - start_point) + # Worst case deviation given a fixed linear_length and actual_length would probably be + # a symmetric tent shape (I haven't proved it -- TODO). + deviationSquared = (actual_length/2)**2 - (linear_length/2)**2 + if deviationSquared <= max_error ** 2: + return [start_point, end_point] + else: + mid = (start+end)/2. + mid_point = path.point(mid) + return ( approximate(path, start, mid, start_point, mid_point, max_error, depth+1, max_depth)[:-1] + + approximate(path, mid, end, mid_point, end_point, max_error, depth+1, max_depth) ) + +def removeCollinear(points, error, pointsToKeep=set()): + out = [] + + lengths = [0] + + for i in range(1,len(points)): + lengths.append(lengths[-1] + abs(points[i]-points[i-1])) + + def length(a,b): + return lengths[b] - lengths[a] + + i = 0 + + while i < len(points): + j = len(points) - 1 + while i < j: + deviationSquared = (length(i, j)/2)**2 - (abs(points[j]-points[i])/2)**2 + if deviationSquared <= error ** 2 and set(range(i+1,j)).isdisjoint(pointsToKeep): + out.append(points[i]) + i = j + break + j -= 1 + out.append(points[j]) + i += 1 + + return out + +class Segment(object): + def __init__(self, start, end): + self.start = start + self.end = end + + def measure(self, start, end, error=ERROR, min_depth=MIN_DEPTH): + return Path(self).measure(start, end, error=error, min_depth=min_depth) + + def getApproximatePoints(self, error=0.001, max_depth=32): + points = approximate(self, 0., 1., self.point(0.), self.point(1.), error, 0, max_depth) + return points + +class Line(Segment): + def __init__(self, start, end): + super(Line, self).__init__(start,end) + + def __repr__(self): + return 'Line(start=%s, end=%s)' % (self.start, self.end) + + def __eq__(self, other): + if not isinstance(other, Line): + return NotImplemented + return self.start == other.start and self.end == other.end + + def __ne__(self, other): + if not isinstance(other, Line): + return NotImplemented + return not self == other + + def getApproximatePoints(self, error=0.001, max_depth=32): + return [self.start, self.end] + + def point(self, pos): + if pos == 0.: + return self.start + elif pos == 1.: + return self.end + distance = self.end - self.start + return self.start + distance * pos + + def length(self, error=None, min_depth=None): + distance = (self.end - self.start) + return sqrt(distance.real ** 2 + distance.imag ** 2) + + +class CubicBezier(Segment): + def __init__(self, start, control1, control2, end): + super(CubicBezier, self).__init__(start,end) + self.control1 = control1 + self.control2 = control2 + + def __repr__(self): + return 'CubicBezier(start=%s, control1=%s, control2=%s, end=%s)' % ( + self.start, self.control1, self.control2, self.end) + + def __eq__(self, other): + if not isinstance(other, CubicBezier): + return NotImplemented + return self.start == other.start and self.end == other.end and \ + self.control1 == other.control1 and self.control2 == other.control2 + + def __ne__(self, other): + if not isinstance(other, CubicBezier): + return NotImplemented + return not self == other + + def is_smooth_from(self, previous): + """Checks if this segment would be a smooth segment following the previous""" + if isinstance(previous, CubicBezier): + return (self.start == previous.end and + (self.control1 - self.start) == (previous.end - previous.control2)) + else: + return self.control1 == self.start + + def point(self, pos): + """Calculate the x,y position at a certain position of the path""" + if pos == 0.: + return self.start + elif pos == 1.: + return self.end + return ((1 - pos) ** 3 * self.start) + \ + (3 * (1 - pos) ** 2 * pos * self.control1) + \ + (3 * (1 - pos) * pos ** 2 * self.control2) + \ + (pos ** 3 * self.end) + + def length(self, error=ERROR, min_depth=MIN_DEPTH): + """Calculate the length of the path up to a certain position""" + start_point = self.point(0) + end_point = self.point(1) + return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) + + +class QuadraticBezier(Segment): + def __init__(self, start, control, end): + super(QuadraticBezier, self).__init__(start,end) + self.control = control + + def __repr__(self): + return 'QuadraticBezier(start=%s, control=%s, end=%s)' % ( + self.start, self.control, self.end) + + def __eq__(self, other): + if not isinstance(other, QuadraticBezier): + return NotImplemented + return self.start == other.start and self.end == other.end and \ + self.control == other.control + + def __ne__(self, other): + if not isinstance(other, QuadraticBezier): + return NotImplemented + return not self == other + + def is_smooth_from(self, previous): + """Checks if this segment would be a smooth segment following the previous""" + if isinstance(previous, QuadraticBezier): + return (self.start == previous.end and + (self.control - self.start) == (previous.end - previous.control)) + else: + return self.control == self.start + + def point(self, pos): + if pos == 0.: + return self.start + elif pos == 1.: + return self.end + return (1 - pos) ** 2 * self.start + 2 * (1 - pos) * pos * self.control + \ + pos ** 2 * self.end + + def length(self, error=None, min_depth=None): + a = self.start - 2*self.control + self.end + b = 2*(self.control - self.start) + a_dot_b = a.real*b.real + a.imag*b.imag + + if abs(a) < 1e-12: + s = abs(b) + elif abs(a_dot_b + abs(a)*abs(b)) < 1e-12: + k = abs(b)/abs(a) + if k >= 2: + s = abs(b) - abs(a) + else: + s = abs(a)*(k**2/2 - k + 1) + else: + # For an explanation of this case, see + # http://www.malczak.info/blog/quadratic-bezier-curve-length/ + A = 4 * (a.real ** 2 + a.imag ** 2) + B = 4 * (a.real * b.real + a.imag * b.imag) + C = b.real ** 2 + b.imag ** 2 + + Sabc = 2 * sqrt(A + B + C) + A2 = sqrt(A) + A32 = 2 * A * A2 + C2 = 2 * sqrt(C) + BA = B / A2 + + s = (A32 * Sabc + A2 * B * (Sabc - C2) + (4 * C * A - B ** 2) * + log((2 * A2 + BA + Sabc) / (BA + C2))) / (4 * A32) + return s + +class Arc(Segment): + def __init__(self, start, radius, rotation, arc, sweep, end, scaler=lambda z:z): + """radius is complex, rotation is in degrees, + large and sweep are 1 or 0 (True/False also work)""" + + super(Arc, self).__init__(scaler(start),scaler(end)) + self.start0 = start + self.end0 = end + self.radius = radius + self.rotation = rotation + self.arc = bool(arc) + self.sweep = bool(sweep) + self.scaler = scaler + + self._parameterize() + + def __repr__(self): + return 'Arc(start0=%s, radius=%s, rotation=%s, arc=%s, sweep=%s, end0=%s, scaler=%s)' % ( + self.start0, self.radius, self.rotation, self.arc, self.sweep, self.end0, self.scaler) + + def __eq__(self, other): + if not isinstance(other, Arc): + return NotImplemented + return self.start == other.start and self.end == other.end and \ + self.radius == other.radius and self.rotation == other.rotation and \ + self.arc == other.arc and self.sweep == other.sweep + + def __ne__(self, other): + if not isinstance(other, Arc): + return NotImplemented + return not self == other + + def _parameterize(self): + # Conversion from endpoint to center parameterization + # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes + + cosr = cos(radians(self.rotation)) + sinr = sin(radians(self.rotation)) + dx = (self.start0.real - self.end0.real) / 2 + dy = (self.start0.imag - self.end0.imag) / 2 + x1prim = cosr * dx + sinr * dy + x1prim_sq = x1prim * x1prim + y1prim = -sinr * dx + cosr * dy + y1prim_sq = y1prim * y1prim + + rx = self.radius.real + rx_sq = rx * rx + ry = self.radius.imag + ry_sq = ry * ry + + # Correct out of range radii + radius_check = (x1prim_sq / rx_sq) + (y1prim_sq / ry_sq) + if radius_check > 1: + rx *= sqrt(radius_check) + ry *= sqrt(radius_check) + rx_sq = rx * rx + ry_sq = ry * ry + + t1 = rx_sq * y1prim_sq + t2 = ry_sq * x1prim_sq + c = sqrt(abs((rx_sq * ry_sq - t1 - t2) / (t1 + t2))) + + if self.arc == self.sweep: + c = -c + cxprim = c * rx * y1prim / ry + cyprim = -c * ry * x1prim / rx + + self.center = complex((cosr * cxprim - sinr * cyprim) + + ((self.start0.real + self.end0.real) / 2), + (sinr * cxprim + cosr * cyprim) + + ((self.start0.imag + self.end0.imag) / 2)) + + ux = (x1prim - cxprim) / rx + uy = (y1prim - cyprim) / ry + vx = (-x1prim - cxprim) / rx + vy = (-y1prim - cyprim) / ry + n = sqrt(ux * ux + uy * uy) + p = ux + theta = degrees(acos(p / n)) + if uy < 0: + theta = -theta + self.theta = theta % 360 + + n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) + p = ux * vx + uy * vy + d = p/n + # In certain cases the above calculation can through inaccuracies + # become just slightly out of range, f ex -1.0000000000000002. + if d > 1.0: + d = 1.0 + elif d < -1.0: + d = -1.0 + delta = degrees(acos(d)) + if (ux * vy - uy * vx) < 0: + delta = -delta + self.delta = delta % 360 + if not self.sweep: + self.delta -= 360 + + def point(self, pos): + if pos == 0.: + return self.start + elif pos == 1.: + return self.end + angle = radians(self.theta + (self.delta * pos)) + cosr = cos(radians(self.rotation)) + sinr = sin(radians(self.rotation)) + + x = (cosr * cos(angle) * self.radius.real - sinr * sin(angle) * + self.radius.imag + self.center.real) + y = (sinr * cos(angle) * self.radius.real + cosr * sin(angle) * + self.radius.imag + self.center.imag) + return self.scaler(complex(x, y)) + + def length(self, error=ERROR, min_depth=MIN_DEPTH): + """The length of an elliptical arc segment requires numerical + integration, and in that case it's simpler to just do a geometric + approximation, as for cubic bezier curves. + """ + start_point = self.point(0) + end_point = self.point(1) + return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) + +class SVGState(object): + def __init__(self, fill=(0.,0.,0.), fillOpacity=None, fillRule='nonzero', stroke=None, strokeOpacity=None, strokeWidth=0.1, strokeWidthScaling=True): + self.fill = fill + self.fillOpacity = fillOpacity + self.fillRule = fillRule + self.stroke = stroke + self.strokeOpacity = strokeOpacity + self.strokeWidth = strokeWidth + self.strokeWidthScaling = strokeWidthScaling + + def clone(self): + return SVGState(fill=self.fill, fillOpacity=self.fillOpacity, fillRule=self.fillRule, stroke=self.stroke, strokeOpacity=self.strokeOpacity, + strokeWidth=self.strokeWidth, strokeWidthScaling=self.strokeWidthScaling) + +class Path(MutableSequence): + """A Path is a sequence of path segments""" + + # Put it here, so there is a default if unpickled. + _closed = False + + def __init__(self, *segments, **kw): + self._segments = list(segments) + self._length = None + self._lengths = None + if 'closed' in kw: + self.closed = kw['closed'] + if 'svgState' in kw: + self.svgState = kw['svgState'] + else: + self.svgState = SVGState() + + def __getitem__(self, index): + return self._segments[index] + + def __setitem__(self, index, value): + self._segments[index] = value + self._length = None + + def __delitem__(self, index): + del self._segments[index] + self._length = None + + def insert(self, index, value): + self._segments.insert(index, value) + self._length = None + + def reverse(self): + # Reversing the order of a path would require reversing each element + # as well. That's not implemented. + raise NotImplementedError + + def __len__(self): + return len(self._segments) + + def __repr__(self): + return 'Path(%s, closed=%s)' % ( + ', '.join(repr(x) for x in self._segments), self.closed) + + def __eq__(self, other): + if not isinstance(other, Path): + return NotImplemented + if len(self) != len(other): + return False + for s, o in zip(self._segments, other._segments): + if not s == o: + return False + return True + + def __ne__(self, other): + if not isinstance(other, Path): + return NotImplemented + return not self == other + + def _calc_lengths(self, error=ERROR, min_depth=MIN_DEPTH): + ## TODO: check if error has decreased since last calculation + if self._length is not None: + return + + lengths = [each.length(error=error, min_depth=min_depth) for each in self._segments] + self._length = sum(lengths) + self._lengths = [each / (1 if self._length==0. else self._length) for each in lengths] + + def point(self, pos, error=ERROR): + # Shortcuts + if pos == 0.0: + return self._segments[0].point(pos) + if pos == 1.0: + return self._segments[-1].point(pos) + + self._calc_lengths(error=error) + # Find which segment the point we search for is located on: + segment_start = 0 + for index, segment in enumerate(self._segments): + segment_end = segment_start + self._lengths[index] + if segment_end >= pos: + # This is the segment! How far in on the segment is the point? + segment_pos = (pos - segment_start) / (segment_end - segment_start) + break + segment_start = segment_end + + return segment.point(segment_pos) + + def length(self, error=ERROR, min_depth=MIN_DEPTH): + self._calc_lengths(error, min_depth) + return self._length + + def measure(self, start, end, error=ERROR, min_depth=MIN_DEPTH): + self._calc_lengths(error=error) + if start == 0.0 and end == 1.0: + return self.length() + length = 0 + segment_start = 0 + for index, segment in enumerate(self._segments): + if end <= segment_start: + break + segment_end = segment_start + self._lengths[index] + if start < segment_end: + # this segment intersects the part of the path we want + if start <= segment_start and segment_end <= end: + # whole segment is contained in the part of the path + length += self._lengths[index] * self._length + else: + if start <= segment_start: + start_in_segment = 0. + else: + start_in_segment = (start-segment_start)/(segment_end-segment_start) + if segment_end <= end: + end_in_segment = 1. + else: + end_in_segment = (end-segment_start)/(segment_end-segment_start) + segment = self._segments[index] + length += segment_length(segment, start_in_segment, end_in_segment, segment.point(start_in_segment), + segment.point(end_in_segment), error, MIN_DEPTH, 0) + segment_start = segment_end + return length + + def _is_closable(self): + """Returns true if the end is on the start of a segment""" + try: + end = self[-1].end + except: + return False + for segment in self: + if segment.start == end: + return True + return False + + def breakup(self): + paths = [] + prevEnd = None + segments = [] + for segment in self._segments: + if prevEnd is None or segment.point(0.) == prevEnd: + segments.append(segment) + else: + paths.append(Path(*segments, svgState=self.svgState)) + segments = [segment] + prevEnd = segment.point(1.) + + if len(segments) > 0: + paths.append(Path(*segments, svgState=self.svgState)) + + return paths + + def linearApproximation(self, error=0.001, max_depth=32): + closed = False + keepSegmentIndex = 0 + if self.closed: + end = self[-1].end + for i,segment in enumerate(self): + if segment.start == end: + keepSegmentIndex = i + closed = True + break + + keepSubpathIndex = 0 + keepPointIndex = 0 + + subpaths = [] + subpath = [] + prevEnd = None + for i,segment in enumerate(self._segments): + if prevEnd is None or segment.start == prevEnd: + if i == keepSegmentIndex: + keepSubpathIndex = len(subpaths) + keepPointIndex = len(subpath) + else: + subpaths.append(subpath) + subpath = [] + subpath += segment.getApproximatePoints(error=error/2., max_depth=max_depth) + prevEnd = segment.end + + if len(subpath) > 0: + subpaths.append(subpath) + + linearPath = Path(svgState=self.svgState) + + for i,subpath in enumerate(subpaths): + keep = set((keepPointIndex,)) if i == keepSubpathIndex else set() + special = None + if i == keepSubpathIndex: + special = subpath[keepPointIndex] + points = removeCollinear(subpath, error=error/2., pointsToKeep=keep) +# points = subpath + + for j in range(len(points)-1): + linearPath.append(Line(points[j], points[j+1])) + + linearPath.closed = self.closed and linearPath._is_closable() + linearPath.svgState = self.svgState + + return linearPath + + def getApproximateLines(self, error=0.001, max_depth=32): + lines = [] + for subpath in self.breakup(): + points = subpath.getApproximatePoints(error=error, max_depth=max_depth) + for i in range(len(points)-1): + lines.append(points[i],points[i+1]) + return lines + + @property + def closed(self): + """Checks that the path is closed""" + return self._closed and self._is_closable() + + @closed.setter + def closed(self, value): + value = bool(value) + if value and not self._is_closable(): + raise ValueError("End does not coincide with a segment start.") + self._closed = value + + def d(self): + if self.closed: + segments = self[:-1] + else: + segments = self[:] + + current_pos = None + parts = [] + previous_segment = None + end = self[-1].end + + for segment in segments: + start = segment.start + # If the start of this segment does not coincide with the end of + # the last segment or if this segment is actually the close point + # of a closed path, then we should start a new subpath here. + if current_pos != start or (self.closed and start == end): + parts.append('M {0:G},{1:G}'.format(start.real, start.imag)) + + if isinstance(segment, Line): + parts.append('L {0:G},{1:G}'.format( + segment.end.real, segment.end.imag) + ) + elif isinstance(segment, CubicBezier): + if segment.is_smooth_from(previous_segment): + parts.append('S {0:G},{1:G} {2:G},{3:G}'.format( + segment.control2.real, segment.control2.imag, + segment.end.real, segment.end.imag) + ) + else: + parts.append('C {0:G},{1:G} {2:G},{3:G} {4:G},{5:G}'.format( + segment.control1.real, segment.control1.imag, + segment.control2.real, segment.control2.imag, + segment.end.real, segment.end.imag) + ) + elif isinstance(segment, QuadraticBezier): + if segment.is_smooth_from(previous_segment): + parts.append('T {0:G},{1:G}'.format( + segment.end.real, segment.end.imag) + ) + else: + parts.append('Q {0:G},{1:G} {2:G},{3:G}'.format( + segment.control.real, segment.control.imag, + segment.end.real, segment.end.imag) + ) + + elif isinstance(segment, Arc): + parts.append('A {0:G},{1:G} {2:G} {3:d},{4:d} {5:G},{6:G}'.format( + segment.radius.real, segment.radius.imag, segment.rotation, + int(segment.arc), int(segment.sweep), + segment.end.real, segment.end.imag) + ) + current_pos = segment.end + previous_segment = segment + + if self.closed: + parts.append('Z') + + return ' '.join(parts) + diff --git a/svgpath/shader.py b/svgpath/shader.py new file mode 100755 index 0000000..a49a104 --- /dev/null +++ b/svgpath/shader.py @@ -0,0 +1,136 @@ +import math +from operator import itemgetter + +class Shader(object): + MODE_EVEN_ODD = 0 + MODE_NONZERO = 1 + + def __init__(self, unshadedThreshold=1., lightestSpacing=3., darkestSpacing=0.5, angle=45, crossHatch=False): + self.unshadedThreshold = unshadedThreshold + self.lightestSpacing = lightestSpacing + self.darkestSpacing = darkestSpacing + self.angle = angle + self.secondaryAngle = angle + 90 + self.crossHatch = False + + def isActive(self): + return self.unshadedThreshold > 0.000001 + + def setDrawingDirectionAngle(self, drawingDirectionAngle): + self.drawingDirectionAngle = drawingDirectionAngle + + if drawingDirectionAngle is None: + return + + if 90 < (self.angle - drawingDirectionAngle) % 360 < 270: + self.angle = (self.angle + 180) % 360 + if 90 < (self.secondaryAngle - drawingDirectionAngle) % 360 < 270: + self.secondaryAngle = (self.secondaryAngle + 180) % 360 + + def shade(self, polygon, grayscale, avoidOutline=True, mode=None): + if mode is None: + mode = Shader.MODE_EVEN_ODD + if grayscale >= self.unshadedThreshold: + return [] + intensity = (self.unshadedThreshold-grayscale) / float(self.unshadedThreshold) + spacing = self.lightestSpacing * (1-intensity) + self.darkestSpacing * intensity + lines = Shader.shadePolygon(polygon, self.angle, spacing, avoidOutline=avoidOutline, mode=mode, alternate=(self.drawingDirectionAngle is None)) + if self.crossHatch: + lines += Shader.shadePolygon(polygon, self.angle+90, spacing, avoidOutline=avoidOutline, mode=mode, alternate=(self.drawingDirectionAngle is None)) + return lines + + @staticmethod + def shadePolygon(polygon, angleDegrees, spacing, avoidOutline=True, mode=None, alternate=True): + if mode is None: + mode = Shader.MODE_EVEN_ODD + + rotate = complex(math.cos(angleDegrees * math.pi / 180.), math.sin(angleDegrees * math.pi / 180.)) + + polygon = [(line[0] / rotate,line[1] / rotate) for line in polygon] + + spacing = float(spacing) + + toAvoid = list(set(line[0].imag for line in polygon)|set(line[1].imag for line in polygon)) + + if len(toAvoid) <= 1: + deltaY = (toAvoid[0]-spacing/2.) % spacing + else: + # find largest interval + toAvoid.sort() + largestIndex = 0 + largestLen = 0 + for i in range(len(toAvoid)): + l = ( toAvoid[i] - toAvoid[i-1] ) % spacing + if l > largestLen: + largestIndex = i + largestLen = l + deltaY = (toAvoid[largestIndex-1] + largestLen / 2.) % spacing + + minY = min(min(line[0].imag,line[1].imag) for line in polygon) + maxY = max(max(line[0].imag,line[1].imag) for line in polygon) + + y = minY + ( - minY ) % spacing + deltaY + + if y > minY + spacing: + y -= spacing + + y += 0.01 + + odd = False + + all = [] + + while y < maxY: + intersections = [] + for line in polygon: + z = line[0] + z1 = line[1] + if z1.imag == y or z.imag == y: # roundoff generated corner case -- ignore -- TODO + break + if z1.imag < y < z.imag or z.imag < y < z1.imag: + if z1.real == z.real: + intersections.append(( complex(z.real, y), z.imag`S(6V~y;TrIMXu#F*@%k~JYqj6I@vgh94S zc9ONSMfUA~NALUlKTkb1-@BZ1&pqdJKKI@eZJ?(?Psc$Afk5cBG*yitkmJ@6$gvkR zRN#x#?bs*aA6hR>>>UV%;REIGu_wX|oDj%)h?c6dv0wV)m@QdwAb8^U+Vyjb0zS8H z_PY!2CaV^x=9FbuxfGx}YaiU0RJuky#*RTgFkpr)yoJ}f7#CX@%iL2@qYFfcPm>I; ze0#^(a`(C+QZiQAnBEkDt<&b~bH3$jg3~N(cdI#AxBt?&uqW4a(YZTwc{6Zaj5=Ur zTjpZZoXo|{?T_hC$3jBC=PdZP^(n>_Z+(V9!f5V;U-e_&i$hA15f})Baju|~F~dcZ z3>M?^kKv}G{#Nca;|L-n1fqK7@)oy_TzCTw1ahODyUH2TZUCc#go%)GptD!3mCH#- z1Oze|Wd%Aw=Eykdqvpl$cP2wZN%0QgX=UIKSS(xH@`I*4v5o8?Q3KA%00GZOen=1*%7RE!5K_K6_>I}rx)4>ud z|C3+9LXoc-`=5LcLyCG_|7&KTG?OF#YkqsAUeN!VX^xulf1A^#GuB-2lcue*4#>j?Y2t=tkH&2zAe1i+&6kcogoMIuCGQV>}%&PK;liq$+#~@1g zBnIFch)A;0kztTwzbRG)hC*_gQ8csvPc)L9A{rU}pJt%fd^{BQonI1sCH(hQ@@P4$ ze+S*ZDLT5W@5f@}L(wgZZU>kMBC8>q97$2+i;r>O$uFEQv;_Nssq)A;%f}D1<{oy2 zCnQjsja5Z;KQb?*@bY27FHI#;w6oCRx_?3#*f^B^s8;aqK5aCmpN1&Q17ToorF+x= z%^yaY01N>e5q4*q^b-l1!x;ZIx9vlJ5e;GE0Y4HBrgC>h&C;16kg}G(KM1aqiF9u4 zP9G4fyb#Fh;wvGq{;6RPDI1ePzaKm2w{|D?V){};rpy#sRym^x5D1OLopamYriQ}` ze}z^x{Sx$T?$J%M_he%)WIG07w0|XLwYess{bu!J!)@!=^;=akFYe@8u|2>uLBjBC z_aFM{nAl38)WK?U#EP3+e@UaE*7!zoJK$_IHb?_*~uZBsf#EW3GyqdUi*8j_|gigw$WG1^3uhKRj#n>8V;-EiBT zUqd`{2QD#u=>=dhZ!2qSN*#q%JcC&5?7W-f^=;}e?fQ@Sdv_|iY5-+fG;GbgHwN5x z4dxf+9kcJ;E)*l&xFRW<4dyrg^1ZZ%?vQ=@z46?3^^)0)-lGf4*ji(W%byb{YcgO8 zwnKb|yKS+z3&uX$bIjHmhO3LG!>>{V7gpBth%Da-u-SPX!Q6IL?u;xJ(;}R(a03Kb9U%D?Co%{9d)yRUpSalyr|`cz1`ECH7OT?%n$KU?CrK|cN9J8+v{!d)i36Z5qZ6edNk`$bG0h`7SSrbSaE?kBVjQ`9l z3qK>9aJUng5g>ztT66xturG#9^_TsM;AUcyk@DYEzj>VLV-Zkpk;Q<)l3=jPm$z5G zwVDYyRI5F~#Wsp_110%h+TT@Ghmc=I2kSqnKQ8)0nc=XDsp199(N4{nz=yb**B%2Y zOBmwTjS7!@V!6)up!$&^?c#z5HcB#uH+QmOzP5$|!qWG_e>#Hdy0sM+`O~6#QdD|{ zpExB?G_K~GI(xDa^4>yZNu)ZefNgDm)xtZSXuyqXMF5vG$JnNgZwNzk^AokLu=Dd! z`f_geRNzQ1qX{d!hjykdXg%y8&B?~ohTz+Wz+k&SEw}#Q_=b6c;MTCqJEUs%?wE7N z1PI#S^UAsVGhESL1va-2Y`ktO8k7~^hXXUeF3RALt zBc{uCzY&r zC)H(i?m^t%GgE@~-A?bbZk^%Ft2m5cmVM9y>1m&HMuT6;`@0g3S@u>{*^scryB_K| zodd4PpE8|MNT@p&PoQ1HT$Th(SVBS04R|%VF>F-a?W)YdM2N zalI1of3eHFH?N)qn}H0!_1(0+lO#(ktghy0mu|wED>zr8)`B!B$9vxepi2fwsIpKU za#j-A9?lvZw1VBOF&4 zwG7drHVX{RuTI6E);f8f?o=Z!p?ZDb(Cv*BN5yxd1)8}zVtz_KH{%EkG!tT;TbAbFTk-G3j+4r9*0Rklv6BF#3(g~B$>l8UkNnF6Piwut^=@VNcXtlD`1>AiIh|*(;yW`H zEe-)X&hyIn(tMd~v3E3T!I#fSsu*I;_h}!{C}U}qpgwlm7i!j@qFS%#KU6om4l^zP z=_s}mp|k~Zlffga+Tn$(&>2l5iE1k+BnmoZAOV{dgVt}3Y&btVu5@;qI=L?wuz%%s1!zt+5leX#XSjjNkOdf_iRbgo3Y|67w^%cp%=333Dp}N zn4c<;dkN6b@wTv67_9lYbI9*~`*14Osp7|GutbJqUofHsyF#>T98nwuVvRrBYDwxj z1P2w!pltZwZ%An8PTs`cnrHC(xw+AnH}AG_f^I$6F3$?9M+ePZwk|g3Vsfve&K6 z_5*IxGg0Fo2{}J#DC6d^;XbXVfgKE64Yd!|7|cbZJ#IR@L{r~*pJ3S?s^j0c#^-bQ zVy2gZB@&uy0DE;dktVf+1dHU4VGS|8d<)CA84h9Wx*wb^Xzb6M%Q*JYvlt@;?Gj!S zWI)qAvQS_!eAyG@KA{P`%>mDKU$Vo*{-*tFROQSIDaT2eJ2S!z#-RdyBUQbP?~!q~ z%$xL>gZTUFsmFQvj!WI1VjI@= zEnQP(^vZW;=ZXC=qQR4CGd{o@3u3Hf1uD=4n8b?X4?{mEk8Nf_Ime(fzJ+#qhJT_` zj%$j@yU+Ipr|NXm_MV@jy#XB=3D9qe!k#z_@}g+_vg0+ZU+*V(JsWBx1bn>J92->_ zrRcpBL$jMi?j>&=bMW`(v_q)_E<7n;y^aj(NUKQ~O~zS(L{bzFolwEL*nG>Zb>Mx8 zHhwIXY1*#w>AD0}1sSJ2_*EvyOJk7FWMKI2DP<0 zb7DzVRt;NVL6VxtRK1ODr{r)x3nfu`!fb1q1;r(^MYlUJ^J)$e?9-SzB$QKp%*{+6 z(DTut*McD@@|KlVE$)F@CiOLtvWRux+Sbwn6t+ehfWrOwXnSy8fR~=9H98Ha?4=t zt2=gaFDfs;)nng8LQQhfx-mz|9ai}qDg=FE^o-__EJGiT$heYgU-@#(YVf9D2`(pw zMnjrGX;xO1s)JMs?4{gN(A~Fi?cnS9jZW5I7P>iiErRRO{HLZf z`JuJgWSo+MF~PC8u~B>l!HX(Of|1>L3C-S7y2&_80~oIy?@dhC<11ijVb}Fmu15t* zSep43?2oEvow%zV7{stv#Dm{CZQh#J?d~ES7$pW&>xEl0m=25~hoa+0?6pR{?o5HXlj=7Oemt)$k8S1e8a7?AT$b)(5Vn@CmU+UQbqE| zEZlWQ*n!grGB9eQ~34{&5OBQ2JbbWK73H;ItSl&Gg!I zi+gjZg72hmiJal<4&_%}l)w>rG>2)Pmq6y6wd0Ca;m<--Cq5syaAaW)oK`bDJxg)O zjonQJJ=v3LPQ25ys&L?|5Az!4hhqs<43hM-q;pqXAEe~iZ4Eg*jTUEULr~7hocDf_ zkp2Fv6K7GatJE{3u_eEq_Wc8gelf5GvpoC5cP0*61tT+~Vrb&AQd}qh3@pHcKF1Gb7p%C<7R& z>_>E}PU^%?`$dg2SG~-ZwDK(+hl|;M*(CT#own(lv10v_5Mk7zOxlBi_(L7rjuuMlR~8i_b(w3q$+m{G|Qofvcdj@gVl! zALA#`YDM5XC^Wd$IU~4!k7&%(2nz_dvvMeYc@pQW1TpWp>BrV0-N=C z03qOCo#d?OGuJ;bmnqx-ES^jrPPj3u1!w>DN~Htyj;bvSaJ+0x=c#zBG|%?ed~>5y zdiWQrdbf9G-w@ILofttkd7rZ)p1&5OLm0HR8fNagHlY73|75XFi(8T1`ZHj=peVYM zQX!?;`H2{zlLFn`O&VLO?Wl@O#tm}^sb=I6Daas_zuhL9J~__wnp-zwKe#b6x_9Ob zE})ZC=+udEej~=^>TLOD?4QYY$to@}sT}lzg3H=C&&ddWaQ3;_(|A=oqQx9Fh!*KY z{k${jHPSJ2__~B6qnO20j@CkN?bQ8yqc-NR-mA_lW7$e)h`sD&+_^=MUtPVd^p>%3 zjtgSYns{G8rR7kq2lq|uIHTwgaHiO^b}d0pKjK_mv0p#E0C`~qagn2xbOaGL-M@pg zbnD;N;uF61dIlq_q{*!OS)ee$ZO3H<2 z->qtKDc9=Q3v;sLJL0Dpt&vcuzC`-8zp%h}UW_aH!tP0)Aeo#?aGSSV28V4RTRkjy zsl~@vz4K1SNor-(jDmDp;i=PsDbBkDzde-fGL(E%Ys`fLAjvpfKAYZZ!%WJLWN%id z#Ner#{2x!caTRff)`hvKZ}Z|5rQVUOw;?vM}b5yEoKDc$dcmlp=52lNg?}}OKO0efOF0x z)j5o!?o8Rt~M8BY3lb+sw&V`b@vtw)13 zl4mH0a=bE5`8RWIb+aLi?!01!dchNlaK?I~akdcL2sWHMgHZv1_@jpPv*IZFX5fkK z`dd0;wdt{C8d$biqL=n{3eZ-o>PT6s%?`(+9PPG~$a*WcEkm5>&8`9>FM#lMEL6KD z`VuX*{7>B2O#1FpXw$iUPP`hJ*EkEEthEcZxyrC(2#d@`r{@X4H;~XpcGJi+Aq%D9 zePt_b$?yI8nCq)$c(+3mh~liXAwG{ZoV=^H;#6;A>08i|lBFoiGWTLM|4~i%@)HbBltkcsK zp1zu>sxs~aXx!iJmgZGtG9|$AiTldHZ%gj?@CA}4j2zHOaB$pM+lxLCtNeRPL{PcY z)#gaRy=g&A+-MtuG9Vcj^5~72RYzzBzMSA&y)IHDdopjvFPn+7`;kfVp4@8D@kDW2 zoz6t7z1k#K8{G_+MHWT;T9?`F9ZCM7epWwvmho7AOIP#C|{0Bjb zKcYo+1lE_`uX3`3^~VebMQcJLG%y)Q7L?}*n71CeRb&K9QH)-oUanpPXP@0WJQ*RYKi)KLjVZ=9 zMXCZdD43KOO{N%vbuc*6IP&&c`sKYtGov7IjWc$N>yf59);ti#K;C?~+5hdcZD{%K zo8A0L7jSq3KqLOF_!lric}uGV5@GyiF_N-|8#1HL%4^6YyrQWo6dPTPeRSBee-^An zdr85}+NE})Y4_>%d|%4+;9P*bz61|))%m39sCfilG1uz6niSa&2Gp7j%}@8lB;z!B zN|B1 zsNWOP-fEcT3({`?vzkdQW{)b0m5SXua!W5V3PY2=*a zqJ{ZD9D%A@!j7+pK|K<_LsQK5?~-xQ@zTAYoC-6;2EPl|F?d_hr!`e7CqQ?N-B8D z{SRXCeUL!+dUdb`(Jy4~^-)00gBT~UL0@RtXD)pWV`kfQZ*|yDY5upyt;m+x#RpqH6<3jU1%X;V&}qh9TIk73WM#d}QjuidawR&u}#- zx>Ns4mM@R&0WgA#p{;PT#Cckt%QFJ+4gR>)ZUp2^E^Ow)j)aWF??LGSKj~eKG@kZy z;&A$B{?U|)M`8inqg4~aY9|H+Il%hJ_PEIFN9q3}HuW*)T&zq-tT7=+%3MNkz$Vnu z#qX9G;H2mKpPCFGxf(#G*B78l)(0btu^P%=HCGz_XGRy|_U1JT1v1!JrwneiARmW9 zWeJg)TicuO_S|q9lJ@u`%r&avG?hBpVhAf$#-{0SWK~RLBI%ezz}rhR8JXm~$XyjV zmb>EdW-$N&mY`yVD3k1wnaYJ@e4{_>ZbnVpIl2h2>@cxEdOg?jUgTSIX7JLPgMG!1 zS1;0h@f3a~nBdGlB21pR^(*)v6D#1L6fhL7NKlJC*`p0DWK;P%uAz9S(OsvZm>GJT zu}B+hJ`-lTKp|jPHT5W{1&ETp(zwg_iRz_Ite_}2SP!Ye#S=Qn0&I$Y_w7?_7BKxK zB=k|1 z?WDft;Y;I|`B+*{{qTs73;rn=^uY6a0i_lo5>OpLzTLrQ8z}1Ty=V&F(YI+d3>5w1 zUS@xHWT`~OCYf)0(e>^VT;eaT_BB}kcU)viON{PE2dnbc$jIdeul?*(LGHub8VcXi z16RLS6<730qFNIaB(?o=q#`Xup#bCfdn~!+ZW>q}Ofl-TXAsLyGqry!hdMhc>f3z8 zgLOG9zVzy3O0eWYt>CPf%VJrwmq! zUeFE_0-P31|AQi?((cAOXw=xEt316e``;O*EF}cc(0=X-o$-DUq`KM)r$FST=Q$cq z7Km+tYyG9`V~RENq0Uix3no{^0%jjHq_HM=lik2=421~+6EI*YL`?w1eQvmnbV=8Ju zaQ5I_eIGaP$u!jVUY7`&+6=ke{_?n?ggt5`X262uMW^+~G#w zME(k{0D>2x^{TZ$1unUVh8{|=PFegbF`V_LyW^xdT(q!p9kB0Vzifo#BuJH2E*-dB zTKYq|Jye3X2=7hSF7?*XJ$>8lKZ0$~cZaOlh-Ojav2ca(pdYK2eSC`@9#_dYKFb`t z5+*^XgNG*IrMxW3qvwlr6{$wE^lO|Pk14lznBEv;bDc!BE;sp1Ifu?x9tL_?VP&6q zRcY6+-;j|{+>k?B0U#2F{?e{^F66Z&D}ri$T)7?EwY!t=XQT3a8UU4Oc7h=;rM!- z;!5N0^3^VABY*n};2q$o(SKpkxj-1Iq6-J{%!g`4MVJvxIxsq$Xoo7)`8_88zjCyr z2W67koDcjXGC7X|KyVxJ@hjWL% zJQVucv~uP^{n-%JY7A$FE(M_tqDty|jZA+TpUIL(cu_RrMYUEqqZPkV3;4HYH5|0o zpOXI&BLYCKUwVd=_g2`y^mRtRk9obpYLE7H341pd_6fhWuYiWz1TW5R>j#&8MIY-k zwRSQ8c1#c(8VVjqeYonBUz@XDC%gH~b8qp=QC@-p(#H2N(|5K(bxu47fUrpJ%EQ;b zOpE?hJZt8ZT}v^YI^Z z(EKl>`2sTSSP_e1+$T$k%f|q_=@WCeRKiAaMZ!5!GT-Js?D|AxV@!gcc}1VZF3cJz z7Q0u(_M*Gu({u|Wb{Wy@;SQ1=xJ)XOcv^KCJ{)t2I|*t+cnA7~kq7lCH_PwT`^rA< zb{YL+8iGZi_{Aj~=aDi^Ep~`f*Jwe~IwJUm{TJ+nJ_FTtOy@}P1qkU_V?nthwJB7q z%{sZD8~_!yX}K{hrA>KO`43Kd2%5FK-~I_>C=uCfDM92ub$NqMTC!AWnv_Fuyd=mM zW+ZXju*RM+E*vo$m`P;ZNTW3x%}_5M0gU+iNO0@LZ+BB7ihCuK-h_U@cmuCG+pXPm zyX}!@q~Xr;kmU);#q6@19^M~oMc{fhFF&gye=_+O=y*b&@UUMnBV_oSKY=GLu%;X& zC*#T{Kh4y1E3Q5mzLUTP&`EhN;ZdqDZD^WgdW)j!B&5TH&_^ZQdhtHb{>Gd_&`M1? z_o?&+69o>(Z1XFD>{O1)AKhABvC1Ii61<~Z_uI@eLMW{KK9AT7)s3xHI8Sfwm-pnn zPS5@JPMNO<$5G|d&24+nC;uYpmkHHRkP8fve}c0MP`Ja(B-#>oa%KewtFLo(tC5SiC$Yz z?OP^Ms_IDMJNux6zyU=t%~I2*lw)?#sU>%MV~1sWp-FXuo{d8~2aY6iF7zjEsAr%n zhV&?gN~I75#IUl(5x!zYpi=_L%c$0s#+Caan~!pzitRD#q112W8agcOkmBoy1i=-l zv$y>p$@`OD$E{5L|Zk^a3;|Z9Z%rbr>&dOKDt!lm3v_+}abx-z&e||Pw*S#_He7}oI=E}5M z7M+isRH+!Xc6?Glzpe`?7D*TqW#;9Jr_J_)`XCG*?iC9-SZt%zMaVJl)u&0Iyiv(_ zxWZzLR?f3&K8jGq#EziL_R9V3H_bm10491ZUiXvRs=ybeep355`PL{LRM@;LPQh8V zusy~zT>}=ry~owS|6B)5o`DCGV-}4GI^-DfXscT80XUVqOOM;V{QhfE_&z4~Z1y^@ zD?W&@d&8#zADdRA(Lm}xw4%9j!Vr6n6j8v&oxmg+4^8qDgA0sO-w8(CctV*GycWfh zql{>9g$>W4pKrt3aba2~i54xia;5fM=f!wMeC$x5L@)ca^Cbjx4y8P*7|a{>3wn&& zRk}zk4F-scm_2))I0A@1?{MiUGp{5-&Jh}%Yu7X>MEG;J^ocMY?6uCyNapV#dzPP= zCRO^1-r!_PV5$4SSoUF>W6T4RN10fd2G_2Zp^+ibvUE{yDd2dPXi`x{C1ur=rPcxryUIv+=f)^4ga?zI;@WS4M5(XZi%gR!^_xMu zvm|@4m+xH-C4gRf5hPvk6~;s-lbF&Up&NxyXsG3`6Hl_Liyx!C0TEtR#}l7L{O!z! zu~PtRUo(%9Atv;@T_Xb!S|rD)Op_?3pR?sK_dXwr>WgaX7&}vqry-tKg!v&-m~Yt+ zokNBT*?53g=ZTpR2>Lb+)qgIyI_F7zsAYy26weUFcT&ZI3tw>OjN`q{VS0zji+d;5 z5OAt6dugIYiR)v5FM2KW2zg1-7pgFGC*hWL93-sSf&px-p03Sa-EPQemo--Nr{;wL z&;q%ryL7i1j{1&LKh!Ur3N3l9p`NtdV?n9+6#BCGjeYBT%Be<&j+oOb-FQkRDbGEu zi0T6z$9Ry$G)i6Y-T?c)#npo^P#wkR;*!sTLQPd|X&@^JzEzo`pK)N*lknxCPhMvx zo}5ZJ2H~xgublZNhTSn*Ix!@9IN2QVYC?M70nVOEb-}2;AHJGT1&O#=H=tQ%0Pq4) zL;Gj6k(1W@96O|!@#e8g%ak9Oyw_~bO>+D~Ur`+8Om&P9gUol_L1SMMtL#tjbx%fs z%TEtMdWx(P@u!%tqRE0eCHo00g?=I1ccsDWZ;lu>d_wIQe9^Z%RQRC%i!Zu7I){O6-Kvox6B+yJh`lsA;L80|Jp! z^MTA1-}R}lh0otGO5T&Ef+$rbUKz6gjJgPlh?>a1pw5{QvU^5mms3Kcaq{D!p8NPh zm3&4Jre*$Y>=mZf*YwWKrI&E!i0b(w zA)>V8xge(Eb=0*2cHC1k~l2=@#!= z$3=i0R3E&U^Vv#ty5tPl1yt{?OETw%^!-r7s4 zl%j9(qLSZa@azwP=*&}of3vz?7f&fjexKWNR9v0*EL6cK)HUm=hXxib;hRh*G~e6n z8B2Xn8f`2X_V{@Z?)vAwfnX=UUyFvm)6t+{dk&3`B&y}EylZ-Y2tm%t4y(1e8Tru3nyVU~20P0eE- zs!rtqc79i~S(S2I=+Iv@h+h(vd<$TEa60mnV%7FsS2yF@AYgUYs~H;7PaZq1eP7fW z5PMU20@&%6OO+UbF@9}hH)-jsJjGmD43tQMd`sW|O?IS&A!J_7HtnN0bVD#1e6P#` z(F6*z<#|90guDAle>o6P5XfaK00W>N|Hxpy0G+_f=cuttingTaperHeight && cuttingTaperHeight>0 && cuttingEdgeThickness0) { + translate([0,0,demouldingPlateHeight]) { + // features transferred onto the demoulding plate + wall(feature_1,featureHeight,featureThickness(0.794)); + wall(feature_2,featureHeight,featureThickness(0.794)); + } + } +} + +//////////////////////////////////////////////////////////////////////////////// +// final call, use main modules +translate([100.396*scale + wallFlareWidth/2, -0.397*scale + wallFlareWidth/2,0]) + cookieCutter(); + +// translate([-40,15,0]) cylinder(h=wallHeight+10,d=5,$fn=20); // handle +if (demouldingPlateHeight>0) + translate([100.396*scale + wallFlareWidth/2 + 100.000*scale + wallFlareWidth + demouldingPlateSlack, -0.397*scale + wallFlareWidth/2,0]) + demouldingPlate(); \ No newline at end of file diff --git a/test/test.svg b/test/test.svg new file mode 100644 index 0000000..095ce42 --- /dev/null +++ b/test/test.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + +