Initial: move params to plugin UI, create a stamp from features, add typing

This commit is contained in:
miklo 2025-12-27 00:34:45 +01:00
parent c6b6b360c8
commit a91726e1e1
10 changed files with 2275 additions and 1 deletions

View File

@ -1,3 +1,30 @@
# svg2scad2cc
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 <https://github.com/arpruss/gcodeplot/blob/master/svg2cookiecutter.py>(svg2cookiecutter) (c) Alexander R. Pruss
* SVG path code (c) Lennart Regebro, Justin Gruenberg
## License
GPL v3.0

71
svg2scad2cc.inx Normal file
View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<_name>OpenSCAD cookie-cutter/stamp export </_name>
<id>eu.citizen4.forge.svg2scad2cc</id>
<dependency type="extension">org.inkscape.output.svg.inkscape</dependency>
<dependency type="executable" location="extensions">svg2scad2cc.py</dependency>
<output>
<extension>.scad</extension>
<mimetype>text/plain</mimetype>
<_filetypename>OpenSCAD cookie-cutter/stamp (*.scad)</_filetypename>
<_filetypetooltip>Export an OpenSCAD cookie-cutter/stamp</_filetypetooltip>
<dataloss>true</dataloss>
</output>
<script>
<command reldir="extensions" interpreter="python">svg2scad2cc.py</command>
</script>
<param name="tab" type="notebook">
<page name="controls" gui-text="Controls">
<param name="wallHeight" type="float" min="0" step="0.1" gui-text="Wall height (mm)">12.0</param>
<param name="minWallThickness" type="float" min="0" step="0.1" gui-text="Minimum outer wall thickness (mm)">1.6</param>
<param name="maxWallThickness" type="float" min="0" step="0.1" gui-text="Maximum outer wall thickness (mm)">1.6</param>
<param name="minInsideWallThickness" type="float" min="0" step="0.1" gui-text="Minimum inner wall thickness (mm)">1.6</param>
<param name="maxInsideWallThickness" type="float" min="0" step="0.1" gui-text="Maximum inner wall thickness (mm)">1.6</param>
<label gui-text="spacer"> </label>
<param name="wallFlareWidth" type="float" min="0" step="0.1" gui-text="Outer flare width (mm)">8.0</param>
<param name="wallFlareThickness" type="float" min="0" step="0.1" gui-text="Outer flare thickness (mm)">2.0</param>
<param name="insideWallFlareWidth" type="float" min="0" step="0.1" gui-text="Inner flare width (mm)">5.0</param>
<param name="insideWallFlareThickness" type="float" min="0" step="0.1" gui-text="Inner flare thickness (mm)">1.6</param>
<param name="featureHeight" type="float" min="0" step="0.1" gui-text="Feature (non-cut) height (mm)">1.0</param>
<param name="minFeatureThickness" type="float" min="0" step="0.1" gui-text="Minimum feature thickness (mm)">1.0</param>
<param name="maxFeatureThickness" type="float" min="0" step="0.1" gui-text="Maximum feature thickness (mm)">3.0</param>
<param name="connectorThickness" type="float" min="0" step="0.1" gui-text="Connector thickness (mm)">1.6</param>
<param name="cuttingTaperHeight" type="float" min="0" step="0.1" gui-text="Cutting taper height (mm)">3.0</param>
<param name="cuttingEdgeThickness" type="float" min="0" step="0.1" gui-text="Cutting edge thickness (mm)">0.8</param>
<param name="demouldingPlateHeight" type="float" min="0" step="0.1" gui-text="Demoulding plate height (mm)">2.0</param>
<param name="demouldingPlateSlack" type="float" min="0" step="0.1" gui-text="Demoulding plate slack / clearance (mm)">0.5</param>
<!-- Other exporter options -->
<param name="tolerance" type="float" min="0.0001" step="0.0001" gui-text="Approximation tolerance">0.1</param>
<param name="strokeAll" type="boolean" gui-text="Treat all paths as stroked (force stroke behavior)">false</param>
</page>
<page name="help" gui-text="Help">
<label xml:space="preserve">Svg -> OpenSCAD -> Cookie cutter
Generate OpenSCAD for 3D-printable cookie cutter (with plate and stamp):
RED - (no fill) outer outline (cut) wall.
GREEN - (no fill) inner (cut) walls.
BLACK - (no fill) inner features. If plate selected - stamp features.
ANY - (no stroke) filled-in polygons - connect inner parts to the outside outline. </label>
</page>
<page name="about" gui-text="About">
<label xml:space="preserve">
Svg2OpenSCAD2Cookiecutter
(c) 2025 Miklo GPL v3.0
Main code derived from gcodeplot (c) Alexander R. Pruss
SVG path code (c) Lennart Regebro, Justin Gruenberg
</label>
</page>
</param>
</inkscape-extension>

492
svg2scad2cc.py Executable file
View File

@ -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<thickness) {{
cylinder(h=height-cuttingTaperHeight+0.001,d=thickness,$fn=8);
translate([0,0,height-cuttingTaperHeight]) cylinder(h=cuttingTaperHeight,d1=thickness,d2=cuttingEdgeThickness);
}} else {{
cylinder(h=height,d=thickness,$fn=8);
}}
}}
for (i=[1:len(points)-1]) {{
hull() {{
translate(points[i-1]) profile();
translate(points[i]) profile();
}}
}}
}}
module outerFlare(path) {{
difference() {{
render(convexity=10) linear_extrude(height=wallFlareThickness) ribbon(path,thickness=wallFlareWidth);
translate([0,0,-0.01]) linear_extrude(height=wallFlareThickness+0.02) polygon(points=path);
}}
}}
module innerFlare(path) {{
intersection() {{
render(convexity=10) linear_extrude(height=insideWallFlareThickness) ribbon(path,thickness=insideWallFlareWidth);
translate([0,0,-0.01]) linear_extrude(height=insideWallFlareThickness+0.02) polygon(points=path);
}}
}}
module fill(path,height) {{
render(convexity=10) linear_extrude(height=height) polygon(points=path);
}}
"""
def build_prelim_from_params(params: Dict[str, Any]) -> 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)

2
svgpath/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from .path import Path, Line, Arc, CubicBezier, QuadraticBezier
from .parser import parse_path

700
svgpath/parser.py Normal file
View File

@ -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())

646
svgpath/path.py Normal file
View File

@ -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)

136
svgpath/shader.py Executable file
View File

@ -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<y, line))
else:
m = (z1.imag-z.imag)/(z1.real-z.real)
# m * (x - z.real) = y - z.imag
# so: x = (y - z.imag) / m + z.real
intersections.append( (complex((y-z.imag)/m + z.real, y), z.imag<y, line) )
intersections.sort(key=lambda datum: datum[0].real)
thisLine = []
if mode == Shader.MODE_EVEN_ODD:
for i in range(0,len(intersections)-1,2):
thisLine.append((intersections[i], intersections[i+1]))
elif mode == Shader.MODE_NONZERO:
count = 0
for i in range(0,len(intersections)-1):
if intersections[i][1]:
count += 1
else:
count -= 1
if count != 0:
thisLine.append((intersections[i], intersections[i+1]))
else:
raise ValueError()
if odd and alternate:
thisLine = list(reversed([(l[1],l[0]) for l in thisLine]))
if not avoidOutline and len(thisLine) and len(all) and all[-1][1][2] == thisLine[0][0][2]:
# follow along outline to avoid an extra pen bob
all.append( (all[-1][1], thisLine[0][0]) )
all += thisLine
odd = not odd
y += spacing
return [(line[0][0]*rotate, line[1][0]*rotate) for line in all]
if __name__ == '__main__':
polygon=(0+0j, 10+10j, 10+0j, 0+0j)
print(shadePolygon(polygon,90,1))

BIN
test/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

136
test/test.scad Normal file
View File

@ -0,0 +1,136 @@
// OpenSCAD file automatically generated by svg2scad2cc.py
// parameters tunable by user
wallHeight = 10;
minWallThickness = 1.6;
maxWallThickness = 1.6;
minInsideWallThickness = 1.6;
maxInsideWallThickness = 1.6;
wallFlareWidth = 8;
wallFlareThickness = 2;
insideWallFlareWidth = 5;
insideWallFlareThickness = 1.6;
featureHeight = 1;
minFeatureThickness = 1;
maxFeatureThickness = 3;
connectorThickness = 1.6;
cuttingTaperHeight = 3;
cuttingEdgeThickness = 0.8;
// set to non-zero value to generate a demoulding plate
demouldingPlateHeight = 2;
demouldingPlateSlack = 0.5;
// 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 = 100.198;
scale = size/100.198;
// 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<thickness) {
cylinder(h=height-cuttingTaperHeight+0.001,d=thickness,$fn=8);
translate([0,0,height-cuttingTaperHeight]) cylinder(h=cuttingTaperHeight,d1=thickness,d2=cuttingEdgeThickness);
} else {
cylinder(h=height,d=thickness,$fn=8);
}
}
for (i=[1:len(points)-1]) {
hull() {
translate(points[i-1]) profile();
translate(points[i]) profile();
}
}
}
module outerFlare(path) {
difference() {
render(convexity=10) linear_extrude(height=wallFlareThickness) ribbon(path,thickness=wallFlareWidth);
translate([0,0,-0.01]) linear_extrude(height=wallFlareThickness+0.02) polygon(points=path);
}
}
module innerFlare(path) {
intersection() {
render(convexity=10) linear_extrude(height=insideWallFlareThickness) ribbon(path,thickness=insideWallFlareWidth);
translate([0,0,-0.01]) linear_extrude(height=insideWallFlareThickness+0.02) polygon(points=path);
}
}
module fill(path,height) {
render(convexity=10) linear_extrude(height=height) polygon(points=path);
}
// data from svg file
outerWall_0 = scale * [[-0.397,100.396],[-0.397,0.397],[-100.396,0.397],[-100.396,78.534],[-81.412,78.534],[-81.412,100.396],[-0.397,100.396]];
feature_1 = scale * [[-74.069,67.833],[-88.752,67.833],[-88.752,17.555],[-74.069,17.555],[-74.069,67.833]];
feature_2 = scale * [[-12.522,85.476],[-31.750,85.476],[-31.750,15.995],[-12.522,15.995],[-12.522,85.476]];
innerWall_3 = scale * [[-41.656,80.465],[-41.656,19.228],[-64.933,49.709],[-41.656,80.465]];
connector_4 = scale * [[-45.418,100.595],[-59.237,100.595],[-59.237,0.482],[-45.418,0.482],[-45.418,100.595]];
// Main modules
module cookieCutter() {
wall(outerWall_0,wallHeight,wallThickness(0.794));
outerFlare(outerWall_0);
if (demouldingPlateHeight <= 0) {
wall(feature_1,featureHeight,featureThickness(0.794));
}
if (demouldingPlateHeight <= 0) {
wall(feature_2,featureHeight,featureThickness(0.794));
}
wall(innerWall_3,wallHeight,insideWallThickness(0.794));
innerFlare(innerWall_3);
fill(connector_4,connectorThickness);
}
module demouldingPlate(){
// A plate to help push on the cookie to turn it out.
// If features exists, they forms stamp with plate.
render(convexity=10) difference() {
linear_extrude(height=demouldingPlateHeight) union() {
polygon(points=outerWall_0);
}
translate([0,0,-0.01]) linear_extrude(height=demouldingPlateHeight+0.02) union() {
ribbon(outerWall_0,thickness=demouldingPlateSlack+wallThickness(0.794));
ribbon(innerWall_3,thickness=demouldingPlateSlack+insideWallThickness(0.794));
}
}
if (demouldingPlateHeight>0) {
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();

64
test/test.svg Normal file
View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100.79333mm"
height="100.79333mm"
viewBox="0 0 100.79333 100.79333"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="test.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.8409652"
inkscape:cx="490.50781"
inkscape:cy="197.39223"
inkscape:window-width="2560"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="36"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-26.128121,-27.734224)">
<path
id="rect234"
style="fill:none;stroke:#ff0000;stroke-width:0.79375;stroke-miterlimit:4.2;stroke-dashoffset:0.569953"
d="M 26.524996,28.131099 V 128.13068 H 126.52458 V 49.993331 H 107.54021 V 28.131099 Z"
inkscape:label="outer-red" />
<path
id="rect420"
style="fill:none;stroke:#000000;stroke-width:0.79375;stroke-miterlimit:4.2;stroke-dashoffset:0.569953"
d="m 100.19727,60.69487 h 14.68295 v 50.27799 H 100.19727 Z M 38.649815,43.051849 H 57.878283 V 112.53231 H 38.649815 Z" />
<path
style="fill:none;stroke:#00ff00;stroke-width:0.79375;stroke-miterlimit:4.2;stroke-dashoffset:0.569953"
d="M 67.783955,48.062319 V 109.2999 L 91.061589,78.818882 Z"
id="path1235" />
<rect
style="fill:#b3b3b3;stroke:none;stroke-width:0.79375;stroke-miterlimit:4.2;stroke-dashoffset:0.569953"
id="rect1289"
width="13.819408"
height="100.11295"
x="71.545761"
y="27.932762" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB