diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index d04a7a2957..81f3e0186a 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -317,6 +317,7 @@ SET(PathPythonPostScripts_SRCS Path/Post/scripts/slic3r_pre.py Path/Post/scripts/smoothie_post.py Path/Post/scripts/snapmaker_post.py + Path/Post/scripts/svg_post.py Path/Post/scripts/uccnc_post.py Path/Post/scripts/wedm_post.py ) diff --git a/src/Mod/CAM/Path/Post/scripts/svg_post.py b/src/Mod/CAM/Path/Post/scripts/svg_post.py new file mode 100644 index 0000000000..8cb3378457 --- /dev/null +++ b/src/Mod/CAM/Path/Post/scripts/svg_post.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * 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"