File size: 11,802 Bytes
985c397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# SPDX-License-Identifier: LGPL-2.1-or-later

# ***************************************************************************
# *   Copyright (c) 2024 Ondsel <development@ondsel.com>                    *
# *                                                                         *
# *   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'<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'
        )

        # 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'<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"

        # Close the SVG tag
        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)})")

            # 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"