""" svg2scad2cc.py Script to convert SVG paths into an OpenSCAD cookie cutter (with stamp). Original code from plugin svg2cookiecutter.py """ from __future__ import annotations import sys import argparse from typing import List, Tuple, Optional, Dict, Any import svgpath.parser as svgpath_parser # PRELIM template: use named placeholders to be filled from extension GUI. # OpenSCAD uses curly braces { }, so they are escaped as double braces {{ }} for .format(). PRELIM_TEMPLATE = """// OpenSCAD file automatically generated by svg2scad2cc.py // parameters tunable by user wallHeight = {wallHeight}; minWallThickness = {minWallThickness}; maxWallThickness = {maxWallThickness}; minInsideWallThickness = {minInsideWallThickness}; maxInsideWallThickness = {maxInsideWallThickness}; wallFlareWidth = {wallFlareWidth}; wallFlareThickness = {wallFlareThickness}; insideWallFlareWidth = {insideWallFlareWidth}; insideWallFlareThickness = {insideWallFlareThickness}; featureHeight = {featureHeight}; minFeatureThickness = {minFeatureThickness}; maxFeatureThickness = {maxFeatureThickness}; connectorThickness = {connectorThickness}; cuttingTaperHeight = {cuttingTaperHeight}; cuttingEdgeThickness = {cuttingEdgeThickness}; // set to non-zero value to generate a demoulding plate demouldingPlateHeight = {demouldingPlateHeight}; demouldingPlateSlack = {demouldingPlateSlack}; // sizing function function clamp(t,minimum,maximum) = min(maximum,max(t,minimum)); function featureThickness(t) = clamp(t,minFeatureThickness,maxFeatureThickness); function wallThickness(t) = clamp(t,minWallThickness,maxWallThickness); function insideWallThickness(t) = clamp(t,minInsideWallThickness,maxInsideWallThickness); size = $OVERALL_SIZE$; scale = size/$OVERALL_SIZE$; // helper modules: subshapes module ribbon(points, thickness=1) {{ union() {{ for (i=[1:len(points)-1]) {{ hull() {{ translate(points[i-1]) circle(d=thickness, $fn=8); translate(points[i]) circle(d=thickness, $fn=8); }} }} }} }} module wall(points,height,thickness) {{ module profile() {{ if (height>=cuttingTaperHeight && cuttingTaperHeight>0 && cuttingEdgeThickness str: """ Build the PRELIM section for the generated OpenSCAD file from parameters. Args: params: dict Dictionary mapping parameter names (strings) to numeric values. Expected keys: - wallHeight, minWallThickness, maxWallThickness, minInsideWallThickness, maxInsideWallThickness, wallFlareWidth, wallFlareThickness, insideWallFlareWidth, insideWallFlareThickness, featureHeight, minFeatureThickness, maxFeatureThickness, connectorThickness, cuttingTaperHeight, cuttingEdgeThickness, demouldingPlateHeight, demouldingPlateSlack Returns: A string containing the PRELIM OpenSCAD code with parameter values substituted. """ defaults: Dict[str, float] = { "wallHeight": 12.0, "minWallThickness": 1.6, "maxWallThickness": 1.6, "minInsideWallThickness": 1.6, "maxInsideWallThickness": 1.6, "wallFlareWidth": 8.0, "wallFlareThickness": 2.0, "insideWallFlareWidth": 5.0, "insideWallFlareThickness": 2.0, "featureHeight": 1.0, "minFeatureThickness": 1.0, "maxFeatureThickness": 3.0, "connectorThickness": 1.6, "cuttingTaperHeight": 3.0, "cuttingEdgeThickness": 0.8, "demouldingPlateHeight": 2.0, "demouldingPlateSlack": 0.5 } filled: Dict[str, float] = {**defaults} for k, v in params.items(): if v is None: continue try: filled[k] = float(v) except (ValueError, TypeError): # ignore invalid values, keep default continue # format numeric values to reasonable precision (OpenSCAD accepts floats) formatted: Dict[str, str] = {} for k, v in filled.items(): formatted[k] = ("%.3f" % v).rstrip('0').rstrip('.') if isinstance(v, float) else str(v) return PRELIM_TEMPLATE.format(**formatted) def isRed(rgb: Optional[Tuple[float, float, float]]) -> bool: """Return True if rgb tuple represents primarily red color.""" return rgb is not None and rgb[0] >= 0.4 and (rgb[1] + rgb[2]) < rgb[0] * 0.25 def isGreen(rgb: Optional[Tuple[float, float, float]]) -> bool: """Return True if rgb tuple represents primarily green color.""" return rgb is not None and rgb[1] >= 0.4 and (rgb[0] + rgb[2]) < rgb[1] * 0.25 def isBlack(rgb: Optional[Tuple[float, float, float]]) -> bool: """Return True if rgb tuple represents near-black color.""" return rgb is not None and (rgb[0] + rgb[1] + rgb[2]) < 0.2 def str2bool(v: object) -> bool: """ Convert a string-like value to boolean for argparse. Accepts: - boolean values (returned as-is), - common textual representations: 'true','false','1','0','yes','no','on','off', - None (treated as False). Raises: argparse.ArgumentTypeError for unrecognized values. """ import argparse as _argparse if isinstance(v, bool): return v if v is None: return False s = str(v).strip().lower() if s in ("true", "1", "yes", "y", "on"): return True if s in ("false", "0", "no", "n", "off"): return False raise _argparse.ArgumentTypeError(f"Boolean value expected (got '{v}'). Use true/false.") class Line: """ Base class representing a path line from the SVG and how it maps to OpenSCAD. """ def __init__(self, path_name: str, points: List[Tuple[float, float]], fill: bool, stroke: bool, stroke_width: Optional[float]) -> None: self.path_name: str = path_name self.points: List[Tuple[float, float]] = points self.fill: bool = fill self.stroke: bool = stroke self.stroke_width: Optional[float] = stroke_width # These will typically be overridden by subclasses self.height: str = "0" self.width: Optional[str] = None self.fillHeight: str = "0" self.hasOuterFlare: bool = False self.hasInnerFlare: bool = False def path_code(self) -> str: """ Generate OpenSCAD code that defines the polygon points for this path. Returns: A string like "name = scale * [[x,y],[x,y],...];" """ pts = ",".join(('[%.3f,%.3f]' % (p[0], p[1]) for p in self.points)) return f"{self.path_name} = scale * [{pts}];" def shapes_code(self) -> str: """ Generate the OpenSCAD code for the shapes (wall, outerFlare, innerFlare, fill). Returns: Combined OpenSCAD code fragment for this line. """ code_lines: List[str] = [] if self.stroke and self.width is not None: code_lines.append(f"wall({self.path_name},{self.height},{self.width});") if self.hasOuterFlare: code_lines.append(f" outerFlare({self.path_name});") elif self.hasInnerFlare: code_lines.append(f" innerFlare({self.path_name});") if self.fill: code_lines.append(f" fill({self.path_name},{self.fillHeight});") return "\n".join(code_lines) class OuterWall(Line): """Represents an outer wall (red) stroke in the SVG.""" def __init__(self, path_name: str, points: List[Tuple[float, float]], fill: bool, stroke: bool, stroke_width: Optional[float]) -> None: super().__init__(path_name, points, fill, stroke, stroke_width) self.height = "wallHeight" self.width = f"wallThickness({(stroke_width or 0):.3f})" if stroke_width is not None else "wallThickness(0)" self.fillHeight = "wallHeight" self.hasOuterFlare = True self.hasInnerFlare = False class InnerWall(Line): """Represents an inner wall (green) stroke in the SVG.""" def __init__(self, path_name: str, points: List[Tuple[float, float]], fill: bool, stroke: bool, stroke_width: Optional[float]) -> None: super().__init__(path_name, points, fill, stroke, stroke_width) self.height = "wallHeight" self.width = f"insideWallThickness({(stroke_width or 0):.3f})" if stroke_width is not None else "insideWallThickness(0)" self.fillHeight = "wallHeight" self.hasOuterFlare = False self.hasInnerFlare = True class Feature(Line): """Represents a feature (black) fill/stroke that is not cutting all the way.""" def __init__(self, path_name: str, points: List[Tuple[float, float]], fill: bool, stroke: bool, stroke_width: Optional[float]) -> None: super().__init__(path_name, points, fill, stroke, stroke_width) self.height = "featureHeight" self.width = f"featureThickness({(stroke_width or 0):.3f})" if stroke_width is not None else "featureThickness(0)" self.fillHeight = "featureHeight" self.hasOuterFlare = False self.hasInnerFlare = False class Connector(Line): """Represents a connector or polygon fill with no stroke-based wall.""" def __init__(self, path_name: str, points: List[Tuple[float, float]], fill: bool) -> None: super().__init__(path_name, points, fill, False, None) self.width = None self.fillHeight = "connectorThickness" self.hasOuterFlare = False self.hasInnerFlare = False def svgToCookieCutter(filename: str, tolerance: float = 0.1, strokeAll: bool = False, prelim: Optional[str] = None) -> str: """ Convert an SVG file to an OpenSCAD cookie cutter script. Args: filename: Path to input SVG file. tolerance: Approximation tolerance for path linearization. strokeAll: If True, treat all paths as stroked. prelim: Optional PRELIM OpenSCAD string. If None, uses default PRELIM_TEMPLATE defaults. Returns: The generated OpenSCAD script as a string. """ lines: List[Line] = [] feature_lines: List[Feature] = [] pathCount = 0 minXY: List[float] = [float("inf"), float("inf")] maxXY: List[float] = [float("-inf"), float("-inf")] # Parse SVG and build Line instances svg_paths = svgpath_parser.getPathsFromSVGFile(filename)[0] for superpath in svg_paths: for path in superpath.breakup(): pathName = '_' + str(pathCount) pathCount += 1 fill = path.svgState.fill is not None stroke = strokeAll or (path.svgState.stroke is not None) if not stroke and not fill: continue linearPath = path.linearApproximation(error=tolerance) # collect points (negating x as original script did) points: List[Tuple[float, float]] = [(-l.start.real, l.start.imag) for l in linearPath] points.append((-linearPath[-1].end.real, linearPath[-1].end.imag)) svg_fill = path.svgState.fill svg_stroke = path.svgState.stroke stroke_width = getattr(path.svgState, "strokeWidth", None) if isRed(svg_fill) or isRed(svg_stroke): line = OuterWall('outerWall' + pathName, points, fill, stroke, stroke_width) elif isGreen(svg_fill) or isGreen(svg_stroke): line = InnerWall('innerWall' + pathName, points, fill, stroke, stroke_width) elif isBlack(svg_fill) or isBlack(svg_stroke): feature_line = Feature('feature' + pathName, points, fill, stroke, stroke_width) line = feature_line feature_lines.append(feature_line) else: line = Connector('connector' + pathName, points, fill) # update bounding box for i in range(2): minXY[i] = min(minXY[i], min(p[i] for p in line.points)) maxXY[i] = max(maxXY[i], max(p[i] for p in line.points)) lines.append(line) bbox_width = maxXY[0] - minXY[0] size = max(bbox_width, maxXY[1] - minXY[1]) # Start building code prelim_text = prelim if prelim is not None else build_prelim_from_params({}) code: List[str] = [prelim_text] code.append('// data from svg file') code += [line.path_code() + '\n' for line in lines] code.append('// Main modules\nmodule cookieCutter() {') for line in lines: shapes = line.shapes_code() if not shapes: continue if isinstance(line, Feature): code.append(" if (demouldingPlateHeight <= 0) {") for shape_line in shapes.splitlines(): code.append(" " + shape_line) code.append(" }") else: for shape_line in shapes.splitlines(): code.append(" " + shape_line) code.append('}\n') # demoulding plate module positives = [line for line in lines if isinstance(line, OuterWall) and line.stroke and not line.fill] negatives_stroke = [line for line in lines if not isinstance(line, Feature)] negatives_fill = [line for line in lines if not isinstance(line, (OuterWall, Feature)) and line.fill] code.append("module demouldingPlate(){") code.append(" // A plate to help push on the cookie to turn it out.") code.append(" // If features exists, they forms stamp with plate.") code.append(" render(convexity=10) difference() {") code.append(" linear_extrude(height=demouldingPlateHeight) union() {") for line in positives: code.append(f" polygon(points={line.path_name});") code.append(" }") code.append(" translate([0,0,-0.01]) linear_extrude(height=demouldingPlateHeight+0.02) union() {") for line in negatives_stroke: if line.stroke and line.width: code.append(f" ribbon({line.path_name},thickness=demouldingPlateSlack+{line.width});") elif line.stroke: code.append(f" ribbon({line.path_name},thickness=demouldingPlateSlack);") code.append(" }") code.append(" }") if feature_lines: code.append(" if (demouldingPlateHeight>0) {") code.append(" translate([0,0,demouldingPlateHeight]) {") code.append(" // features transferred onto the demoulding plate") for feature in feature_lines: feature_shapes = feature.shapes_code() if not feature_shapes: continue for shape_line in feature_shapes.splitlines(): code.append(" " + shape_line) code.append(" }") code.append(" }") code.append("}\n") code.append('////////////////////////////////////////////////////////////////////////////////') code.append('// final call, use main modules') translate_x = -minXY[0] translate_y = -minXY[1] code.append(f"translate([{translate_x:.3f}*scale + wallFlareWidth/2, {translate_y:.3f}*scale + wallFlareWidth/2,0])") code.append(" cookieCutter();\n") code.append('// translate([-40,15,0]) cylinder(h=wallHeight+10,d=5,$fn=20); // handle') demould_offset_expr = f"{bbox_width:.3f}*scale + wallFlareWidth + demouldingPlateSlack" code.append('if (demouldingPlateHeight>0)') code.append(f" translate([{translate_x:.3f}*scale + wallFlareWidth/2 + {demould_offset_expr}, {translate_y:.3f}*scale + wallFlareWidth/2,0])") code.append(' demouldingPlate();') return '\n'.join(code).replace('$OVERALL_SIZE$', '%.3f' % size) def _collect_prelim_params_from_namespace(ns: argparse.Namespace) -> Dict[str, Any]: """ Helper to extract only PRELIM-related args from argparse Namespace. """ keys = [ "wallHeight", "minWallThickness", "maxWallThickness", "minInsideWallThickness", "maxInsideWallThickness", "wallFlareWidth", "wallFlareThickness", "insideWallFlareWidth", "insideWallFlareThickness", "featureHeight", "minFeatureThickness", "maxFeatureThickness", "connectorThickness", "cuttingTaperHeight", "cuttingEdgeThickness", "demouldingPlateHeight", "demouldingPlateSlack" ] params: Dict[str, Any] = {} for k in keys: if hasattr(ns, k): val = getattr(ns, k) if val is not None: params[k] = val return params if __name__ == '__main__': parser = argparse.ArgumentParser(description="Convert SVG to OpenSCAD cookie cutter") parser.add_argument('inputfile', help='Input SVG file') # Add PRELIM parameters as optional command-line args (can be passed from .inx) parser.add_argument('--wallHeight', type=float) parser.add_argument('--minWallThickness', type=float) parser.add_argument('--maxWallThickness', type=float) parser.add_argument('--minInsideWallThickness', type=float) parser.add_argument('--maxInsideWallThickness', type=float) parser.add_argument('--wallFlareWidth', type=float) parser.add_argument('--wallFlareThickness', type=float) parser.add_argument('--insideWallFlareWidth', type=float) parser.add_argument('--insideWallFlareThickness', type=float) parser.add_argument('--featureHeight', type=float) parser.add_argument('--minFeatureThickness', type=float) parser.add_argument('--maxFeatureThickness', type=float) parser.add_argument('--connectorThickness', type=float) parser.add_argument('--cuttingTaperHeight', type=float) parser.add_argument('--cuttingEdgeThickness', type=float) parser.add_argument('--demouldingPlateHeight', type=float) parser.add_argument('--demouldingPlateSlack', type=float) # Other optional flags parser.add_argument('--tolerance', type=float, default=0.1) parser.add_argument('--tab', type=str) # Accept boolean values passed as "true"/"false" from Inkscape .inx UI. # nargs='?' allows "--strokeAll" or "--strokeAll true" or "--strokeAll false" parser.add_argument('--strokeAll', type=str2bool, nargs='?', const=True, default=False, help='Treat all paths as stroked (force stroke behavior). Accepts true/false') args = parser.parse_args() prelim_params = _collect_prelim_params_from_namespace(args) prelim_text = build_prelim_from_params(prelim_params) if prelim_params else None # Print final OpenSCAD code to stdout out = svgToCookieCutter(args.inputfile, tolerance=args.tolerance, strokeAll=args.strokeAll, prelim=prelim_text) sys.stdout.write(out)