493 lines
19 KiB
Python
Executable File
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)
|