| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | 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()) |
| |
|
| | |
| | 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 |
| |
|
| | |
| | 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 |
| |
|
| | |
| | svg_content = ( |
| | f'<svg xmlns="http://www.w3.org/2000/svg" ' |
| | f'xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" ' |
| | f'width="{width}mm" height="{height}mm" viewBox="0 0 {width} {height}">\n' |
| | ) |
| |
|
| | |
| | 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 |
| |
|
| | if use_layers: |
| | layer_id = f"layer{idx}_{obj_idx}" |
| | svg_content += ( |
| | f'<g id="{layer_id}" inkscape:label="{obj.Label}" inkscape:groupmode="layer">\n' |
| | ) |
| |
|
| | for wire in wires: |
| | path_data = self.wire_to_svg_path(wire, width, height, xmin, ymin) |
| | if pathtype == "FILL": |
| | svg_content += f' <path d="{path_data}" stroke="none" fill="{color}" />\n' |
| | else: |
| | svg_content += f' <path d="{path_data}" stroke="{color}" stroke-width="{width}" fill="none" />\n' |
| |
|
| | if use_layers: |
| | svg_content += "</g>\n" |
| |
|
| | |
| | svg_content += "</svg>" |
| |
|
| | 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)})") |
| |
|
| | |
| | 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"]: |
| | |
| | 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"]: |
| | |
| | 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) |
| |
|
| | |
| | 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") |
| |
|
| | |
| | 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: |
| | |
| | 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 " |
| | 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" |
| |
|