# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * Copyright (c) 2024 Ondsel * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * # * as published by the Free Software Foundation; either version 2 of * # * the License, or (at your option) any later version. * # * for detail see the LICENCE text file. * # * * # * This program is distributed in the hope that it will be useful, * # * but WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * # * GNU Library General Public License for more details. * # * * # * You should have received a copy of the GNU Library General Public * # * License along with this program; if not, write to the Free Software * # * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * # * USA * # * * # *************************************************************************** from Path.Post.Processor import PostProcessor import Path import FreeCAD import math Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) translate = FreeCAD.Qt.translate debug = False if debug: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) # Define colors for the layers LAYER_COLORS = {"CUT": "red", "ENGRAVE": "blue", "FILL": "green", "DEFAULT": "black"} class Svg(PostProcessor): def __init__(self, job): super().__init__( job, tooltip=translate("CAM", "SVG post processor"), tooltipargs=[], units="mm", ) Path.Log.debug("SVG post processor initialized") def export(self): Path.Log.debug("Exporting the job") use_layers = "--layers" in self._job.PostProcessorArgs postables = self._buildPostList() Path.Log.debug(f"postables count: {len(postables)}") svg_strings = [] for idx, section in enumerate(postables): svg_content = self.create_svg_section(section, idx, use_layers) svg_strings.append((idx, svg_content)) return svg_strings def create_svg_section(self, section, idx, use_layers): Path.Log.track() partname, sublist = section # Initialize bounding box xmin, ymin, xmax, ymax = self.calculate_bounding_box(sublist) if xmin is None or ymin is None or xmax is None or ymax is None: Path.Log.debug("No wires found, skipping section") return "" width = xmax - xmin height = ymax - ymin # Create the SVG header with a normalized viewBox svg_content = ( f'\n' ) # Convert each wire to an SVG path for obj_idx, obj in enumerate(sublist): strokestyle = self.get_stroke_style(obj) color = strokestyle["color"] width = strokestyle["width"] pathtype = strokestyle["label"] Path.Log.debug(pathtype) wires = Path.Geom.wiresForPath(obj.Path) if not wires: continue # Skip objects that do not produce wires if use_layers: layer_id = f"layer{idx}_{obj_idx}" svg_content += ( f'\n' ) for wire in wires: path_data = self.wire_to_svg_path(wire, width, height, xmin, ymin) if pathtype == "FILL": svg_content += f' \n' else: svg_content += f' \n' if use_layers: svg_content += "\n" # Close the SVG tag svg_content += "" return svg_content def wire_to_svg_path(self, wire, width, height, xmin, ymin): """Convert FreeCAD wire to SVG path data with y-axis inversion and limited precision""" path_data = "" is_first_point = True vertices_info = [] def format_coord(value): return f"{value:.4f}" for edge in wire.Edges: start_point = edge.Vertexes[0].Point end_point = edge.Vertexes[-1].Point Path.Log.debug(f"Edge Type: {edge.Curve.TypeId}") Path.Log.debug( f"Start Point: ({format_coord(start_point.x)}, {format_coord(start_point.y)})" ) Path.Log.debug(f"End Point: ({format_coord(end_point.x)}, {format_coord(end_point.y)})") # Check if the edge is vertical (should be skipped) if start_point.x == end_point.x and start_point.y == end_point.y: Path.Log.debug("Skipping vertical edge") continue if is_first_point: path_data += f"M {format_coord(start_point.x - xmin)} {format_coord(height - (start_point.y - ymin))} " vertices_info.append( f"M {format_coord(start_point.x - xmin)} {format_coord(height - (start_point.y - ymin))}" ) is_first_point = False if edge.Curve.TypeId in ["Part::GeomLineSegment", "Part::GeomLine"]: # Handle line segment without discretization path_data += f"L {format_coord(end_point.x - xmin)} {format_coord(height - (end_point.y - ymin))} " vertices_info.append( f"L {format_coord(end_point.x - xmin)} {format_coord(height - (end_point.y - ymin))}" ) Path.Log.debug( f"Line segment from ({format_coord(start_point.x)}, {format_coord(start_point.y)}) to ({format_coord(end_point.x)}, {format_coord(end_point.y)})" ) elif edge.Curve.TypeId in ["Part::GeomCircle", "Part::GeomArcOfCircle"]: # Handle circular arc using 'A' command radius = edge.Curve.Radius center = edge.Curve.Center start_angle = math.atan2(start_point.y - center.y, start_point.x - center.x) end_angle = math.atan2(end_point.y - center.y, end_point.x - center.x) # Calculate the angle difference and normalize to [-π, π] angle_diff = (end_angle - start_angle) % (2 * math.pi) if angle_diff > math.pi: angle_diff -= 2 * math.pi Path.Log.debug(f"Angle difference: {angle_diff:.2f} radians") # Determine the large_arc_flag and sweep_flag large_arc_flag = 1 if abs(angle_diff) > (math.pi) else 0 sweep_flag = 0 if angle_diff > 0 else 1 start_x = format_coord(start_point.x - xmin) start_y = format_coord(height - (start_point.y - ymin)) end_x = format_coord(end_point.x - xmin) end_y = format_coord(height - (end_point.y - ymin)) path_data += f"A {format_coord(radius)} {format_coord(radius)} 0 {large_arc_flag} {sweep_flag} {end_x} {end_y} " vertices_info.append( f"A {format_coord(radius)} {format_coord(radius)} 0 {large_arc_flag} {sweep_flag} {end_x} {end_y}" ) Path.Log.debug( f"Circular arc with radius {format_coord(radius)} from ({start_x}, {start_y}) to ({end_x}, {end_y}) (large_arc_flag: {large_arc_flag}, sweep_flag: {sweep_flag})" ) Path.Log.debug(path_data) else: # Discretize other types of edges into 100 segments vertices = edge.discretize(100) if len(vertices) < 2: continue if all(vertices[0].x == v.x and vertices[0].y == v.y for v in vertices[1:]): continue for vertex in vertices: path_data += f"L {format_coord(vertex.x - xmin)} {format_coord(height - (vertex.y - ymin))} " vertices_info.append( f"L {format_coord(vertex.x - xmin)} {format_coord(height - (vertex.y - ymin))}" ) Path.Log.debug(f"Discretized edge with {len(vertices)} points") path_data += "Z " # Close the path for filled edges vertices_info.append("Z") Path.Log.debug(f"SVG Path: {path_data.strip()}") Path.Log.debug(f"Vertices Info: {vertices_info}") return path_data.strip() def calculate_bounding_box(self, sublist): xmin, ymin, xmax, ymax = None, None, None, None for obj in sublist: wires = Path.Geom.wiresForPath(obj.Path) for wire in wires: for vertex in wire.Vertexes: x, y = vertex.X, vertex.Y if xmin is None or x < xmin: xmin = x if xmax is None or x > xmax: xmax = x if ymin is None or y < ymin: ymin = y if ymax is None or y > ymax: ymax = y return xmin, ymin, xmax, ymax def get_stroke_style(self, obj): strokewidth = 0.1 color = LAYER_COLORS["DEFAULT"] label = None if hasattr(obj, "ToolController"): tc = obj.ToolController if hasattr(tc, "Tool") and hasattr(tc.Tool, "Diameter"): strokewidth = tc.Tool.Diameter if hasattr(tc, "Label"): for key in LAYER_COLORS: if key in tc.Label: label = key color = LAYER_COLORS[key] break return {"width": strokewidth, "color": color, "label": label} @property def tooltip(self): tooltip = """ This is the Ondsel SVG CAM post processor. It will export a CAM job to an SVG file with colors and layers. Tool Controllers determine how the svg will be created and, thus, how the laser will behave. They must have a label containing a string from the list at the top of the post file. (e.g. CUT, FILL, ENGRAVE). SVG Paths will be color coded similarly Actual laser behavior will depend on how the laser controller is configured to process the colors / layers. Step-downs in operations will result in multiple passes. """ return tooltip @property def tooltipArgs(self): argtooltip = """ --layers: Output will be written to different layers. Layer names are taken from the operation label """ return argtooltip @property def preferredExtension(self): return "svg"