Initial: move params to plugin UI, create a stamp from features, add typing
This commit is contained in:
parent
c6b6b360c8
commit
a91726e1e1
29
README.md
29
README.md
|
|
@ -1,3 +1,30 @@
|
||||||
# svg2scad2cc
|
# svg2scad2cc
|
||||||
|
|
||||||
Inkscape plugin to convert SVG paths into an OpenSCAD cookie cutter (with stamp)
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
from .path import Path, Line, Arc, CubicBezier, QuadraticBezier
|
||||||
|
from .parser import parse_path
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -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();
|
||||||
|
|
@ -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 |
Loading…
Reference in New Issue