svg2scad2cc/svg2scad2cc.py

493 lines
19 KiB
Python
Executable File

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