svg2gcode 3.2.5__tar.gz → 3.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/PKG-INFO +6 -2
  2. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/README.md +5 -1
  3. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/__init__.py +1 -1
  4. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/__main__.py +1 -1
  5. svg2gcode-3.2.7/svg2gcode/svg_to_gcode/compiler/_compiler_1.py +941 -0
  6. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/LICENSE +0 -0
  7. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/pyproject.toml +0 -0
  8. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/LICENSE +0 -0
  9. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/__init__.py +0 -0
  10. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/__init__.py +0 -0
  11. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/_compiler.py +0 -0
  12. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/interfaces/__init__.py +0 -0
  13. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/interfaces/_abstract_interface.py +0 -0
  14. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/interfaces/_fan_controlled_gcode.py +0 -0
  15. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/interfaces/_gcode.py +0 -0
  16. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/css_color.py +0 -0
  17. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/formulas.py +0 -0
  18. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/__init__.py +0 -0
  19. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_abstract_chain.py +0 -0
  20. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_abstract_curve.py +0 -0
  21. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_circular_arc.py +0 -0
  22. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_cubic_bazier.py +0 -0
  23. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_elliptical_arc.py +0 -0
  24. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_line.py +0 -0
  25. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_line_segment_chain.py +0 -0
  26. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_matrix.py +0 -0
  27. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_quadratic_bazier.py +0 -0
  28. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_raster_image.py +0 -0
  29. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_smooth_arc_chain.py +0 -0
  30. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_vector.py +0 -0
  31. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/svg_parser/__init__.py +0 -0
  32. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/svg_parser/_parser_methods.py +0 -0
  33. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/svg_parser/_path.py +0 -0
  34. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/svg_parser/_transformation.py +0 -0
  35. {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/svg_parser/debug_methods.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: svg2gcode
3
- Version: 3.2.5
3
+ Version: 3.2.7
4
4
  Summary: svg2gcode: convert an SVG (Scalable Vector Graphic) image to gcode.
5
5
  Keywords: scalable vector graphics,svg,image,laser cutter,laser engraving
6
6
  Author-email: Johannes Noordanus <mailjohannes.mailnoordanus@gmail.com>
@@ -127,7 +127,7 @@ options:
127
127
  --overscan <default:0>
128
128
  overscan image lines to avoid incorrect power levels for pixels at left and right borders, number in pixels, default off
129
129
  --showoverscan show overscan pixels (note that this is visible and part of the gcode emitted!)
130
- --constantburn use constant burn mode M3 (a bit more dangerous!), instead of dynamic burn mode M4
130
+ --constantburn set constant burn mode M3 (default set to dynamic burn mode M4); this enhances engraving quality
131
131
  --origin delta-x delta-y
132
132
  translate origin by vector (delta-x,delta-y) in mm (default not set, option --selfcenter cannot be used at the same time)
133
133
  --scale factor-x factor-y
@@ -290,6 +290,10 @@ One containing the drawings of the .svg, the other containing the images:
290
290
  > logo_images.gc # all images
291
291
  ```
292
292
 
293
+ ### Burn mode M3/M4:
294
+
295
+ Default *svg2gcode* uses dynamic burn mode *M4*. This can be changed by setting option *--constantburn* which selects burn mode *M3*. Mode *M4* is not suitable for engravings because it automatically compensates (laser)power for speed. This conflicts with the specific gcode settings given by *image2gcode* (called by *svg2gcode*) for each pixel. In fact some experiments show that *M4* causes loss of quality and image deterioration when speed is increased. On white oak images had too much black and grey which did not go away for substantially higher speed. When switched back to constant burn (*M3*) mode, the same high speed gave excellent images having a sepia (licht yellow brown) color tone.
296
+
293
297
  ### Notes:
294
298
  - drawing objects - within the composer - must be converted to a```path```to be translated to a gcode sequence
295
299
  - also, image objects should **not** be converted to a ```path```
@@ -113,7 +113,7 @@ options:
113
113
  --overscan <default:0>
114
114
  overscan image lines to avoid incorrect power levels for pixels at left and right borders, number in pixels, default off
115
115
  --showoverscan show overscan pixels (note that this is visible and part of the gcode emitted!)
116
- --constantburn use constant burn mode M3 (a bit more dangerous!), instead of dynamic burn mode M4
116
+ --constantburn set constant burn mode M3 (default set to dynamic burn mode M4); this enhances engraving quality
117
117
  --origin delta-x delta-y
118
118
  translate origin by vector (delta-x,delta-y) in mm (default not set, option --selfcenter cannot be used at the same time)
119
119
  --scale factor-x factor-y
@@ -276,6 +276,10 @@ One containing the drawings of the .svg, the other containing the images:
276
276
  > logo_images.gc # all images
277
277
  ```
278
278
 
279
+ ### Burn mode M3/M4:
280
+
281
+ Default *svg2gcode* uses dynamic burn mode *M4*. This can be changed by setting option *--constantburn* which selects burn mode *M3*. Mode *M4* is not suitable for engravings because it automatically compensates (laser)power for speed. This conflicts with the specific gcode settings given by *image2gcode* (called by *svg2gcode*) for each pixel. In fact some experiments show that *M4* causes loss of quality and image deterioration when speed is increased. On white oak images had too much black and grey which did not go away for substantially higher speed. When switched back to constant burn (*M3*) mode, the same high speed gave excellent images having a sepia (licht yellow brown) color tone.
282
+
279
283
  ### Notes:
280
284
  - drawing objects - within the composer - must be converted to a```path```to be translated to a gcode sequence
281
285
  - also, image objects should **not** be converted to a ```path```
@@ -1,4 +1,4 @@
1
1
  """
2
2
  svg2gcode: convert an SVG (Scalable Vector Graphic) image to gcode.
3
3
  """
4
- __version__ = "3.2.5"
4
+ __version__ = "3.2.7"
@@ -127,7 +127,7 @@ def main() -> int:
127
127
  parser.add_argument('--overscan', default=cfg["overscan_default"], metavar="<default:" +str(cfg["overscan_default"])+ ">",
128
128
  type=int, help="overscan image lines to avoid incorrect power levels for pixels at left and right borders, number in pixels, default off")
129
129
  parser.add_argument('--showoverscan', action='store_true', default=False, help='show overscan pixels (note that this is visible and part of the gcode emitted!)' )
130
- parser.add_argument('--constantburn', action='store_true', default=False, help='use constant burn mode M3 (a bit more dangerous!), instead of dynamic burn mode M4')
130
+ parser.add_argument('--constantburn', action='store_true', default=False, help='set constant burn mode M3 (default is dynamic burn mode M4); this enhances engraving quality')
131
131
  parser.add_argument('--origin', default=None, nargs=2, metavar=('delta-x', 'delta-y'),
132
132
  type=float, help="translate origin by vector (delta-x,delta-y) in mm (default not set, option --selfcenter cannot be used at the same time)")
133
133
  parser.add_argument('--scale', default=None, nargs=2, metavar=('factor-x', 'factor-y'),
@@ -0,0 +1,941 @@
1
+ import os
2
+ import re
3
+ import logging
4
+ import math
5
+ import copy
6
+
7
+ from io import BytesIO
8
+ from typing import Any
9
+
10
+ import base64
11
+ from datetime import datetime
12
+ from PIL import Image
13
+ import numpy as np
14
+
15
+ from svg2gcode.svg_to_gcode.compiler.interfaces import Interface
16
+ from svg2gcode.svg_to_gcode.geometry import Curve
17
+ from svg2gcode.svg_to_gcode.geometry import LineSegmentChain, Vector, RasterImage
18
+ from svg2gcode.svg_to_gcode import DEFAULT_SETTING
19
+ from svg2gcode.svg_to_gcode import TOLERANCES, SETTING, check_setting
20
+
21
+ from svg2gcode.svg_to_gcode import css_color
22
+
23
+ from svg2gcode import __version__
24
+
25
+ from image2gcode.boundingbox import Boundingbox
26
+ from image2gcode.image2gcode import Image2gcode
27
+
28
+ #logging.basicConfig(format="[%(levelname)s] %(message)s (%(name)s:%(lineno)s)")
29
+ logging.basicConfig(format="[%(levelname)s] %(message)s")
30
+ logger = logging.getLogger(__name__)
31
+ logger.setLevel(logging.INFO)
32
+
33
+ class Compiler:
34
+ """
35
+ The Compiler class handles the process of drawing geometric objects using interface commands and assembling the
36
+ resulting numerical control code.
37
+ """
38
+
39
+ def __init__(self, interface_class: Interface, custom_header: list[str] =None, custom_footer: list[str] =None,
40
+ params: dict[str, Any] = None):
41
+ """
42
+
43
+ :param interface_class: Specify which interface to use. The most common is the gcode interface.
44
+ :param custom_header: A list of commands to be executed before all generated commands.
45
+ :param custom_footer: A list of commands to be executed after all generated commands.
46
+ Default [laser_off, program_end]
47
+ :param settings: dictionary to specify "unit", "pass_depth", "dwell_time", "movement_speed", etc.
48
+ """
49
+ self.svg_file_name = None
50
+ self.boundingbox = Boundingbox()
51
+ self.interface = interface_class()
52
+
53
+ # Round outputs to the same number of significant figures as the operational tolerance.
54
+ self.precision = abs(round(math.log(TOLERANCES["operation"], 10)))
55
+
56
+ if params is None or not check_setting(params):
57
+ raise ValueError(f"Please set at least 'maximum_laser_power' and 'movement_speed' from {SETTING}")
58
+
59
+ # save params
60
+ self.params = params
61
+ # get default settings and update
62
+ self.settings = copy.deepcopy(DEFAULT_SETTING)
63
+ for key in params.keys():
64
+ self.settings[key] = params[key]
65
+
66
+ self.interface.set_machine_parameters(self.settings)
67
+
68
+ if custom_header is None:
69
+ custom_header = []
70
+
71
+ if custom_footer is None:
72
+ custom_footer = [self.interface.laser_off(), self.interface.program_end()]
73
+
74
+ self.header = [self.interface.code_initialize(),
75
+ self.interface.set_unit(self.settings["unit"]),
76
+ self.interface.set_distance_mode(self.settings["distance_mode"])] + custom_header
77
+ self.footer = custom_footer
78
+
79
+ # path object gcode
80
+ self.body: list[str] = []
81
+ # image gcode
82
+ self.gcode: list[str] = []
83
+
84
+ def gcode_file_header(self):
85
+
86
+ gcode = []
87
+
88
+ if not self.check_bounds():
89
+ if self.settings["distance_mode"] == "absolute" and self.check_axis_maximum_travel():
90
+ logger.warn("Cut is not within machine bounds.")
91
+ gcode += ["; WARNING: Cut is not within machine bounds of "
92
+ f"X[0,{self.settings['x_axis_maximum_travel']}], Y[0,{self.settings['y_axis_maximum_travel']}]\n",]
93
+ elif not self.check_axis_maximum_travel():
94
+ # logger.warn("Please define machine cutting area, set parameter: 'x_axis_maximum_travel' and 'y_axis_maximum_travel'")
95
+ gcode += ["; WARNING: Please define machine cutting area, set parameter: 'x_axis_maximum_travel' and 'y_axis_maximum_travel'\n",]
96
+ else:
97
+ gcode += [f"; WARNING: distance mode is not absolute: {self.settings['distance_mode']}\n",]
98
+
99
+ # add generator info and boundingbox for this code
100
+
101
+ # get program parameters
102
+ params = ''
103
+ for k, v in self.params.items():
104
+ if params != '':
105
+ params += ",\n"
106
+ if hasattr(v, 'name'):
107
+ params += f"; {k}: {os.path.basename(v.name)}"
108
+ else:
109
+ params += f"; {k}: {v}"
110
+
111
+ center = self.boundingbox.center()
112
+ gcode += [ f"; svg2gcode {__version__} ({str(datetime.now()).split('.')[0]})",
113
+ f"; arguments: \n{params}",
114
+ f"; {self.boundingbox}",
115
+ f"; boundingbox center: (X{center[0]:.{0 if center[0].is_integer() else self.precision}f},"
116
+ f"Y{center[1]:.{0 if center[1].is_integer() else self.precision}f})" ]
117
+
118
+ gcode += [ f"; GRBL 1.1, unit={self.settings['unit']}, {self.settings['distance_mode']} coordinates" ]
119
+
120
+ return '\n'.join(gcode) + '\n'
121
+
122
+ def compile(self, passes=1):
123
+
124
+ """
125
+ Assembles the code in the header, body and footer.
126
+
127
+ :param passes: the number of passes that should be made. Every pass the machine moves_down (z-axis) by
128
+ self.pass_depth and self.body is repeated.
129
+ :return returns the assembled code. self.header + [self.body, -self.pass_depth] * passes + self.footer
130
+ """
131
+ if len(self.body) == 0:
132
+ logger.debug("Compile with an empty body (no curves).")
133
+ return ''
134
+
135
+ gcode = []
136
+ for i in range(passes):
137
+ gcode += [f"; pass #{i+1}"]
138
+ gcode.extend(self.body)
139
+
140
+ if i < (passes - 1) and self.settings["pass_depth"] > 0:
141
+ # If it isn't the last pass, turn off the laser and move down
142
+ gcode.append(self.interface.laser_off())
143
+ gcode.append(self.interface.set_relative_coordinates())
144
+ gcode.append(self.interface.linear_move(z=-self.settings["pass_depth"]))
145
+ gcode.append(self.interface.set_distance_mode(self.settings["distance_mode"]))
146
+
147
+ gcode += [self.interface.laser_off()]
148
+
149
+ # remove all ""
150
+ gcode = filter(lambda command: len(command) > 0, gcode)
151
+
152
+ return '\n'.join(gcode)
153
+
154
+ def compile_images(self):
155
+
156
+ """
157
+ Assembles the code in the header, body and footer.
158
+ """
159
+
160
+ # laser off, fan on, M3 or M4 burn mode
161
+ header_gc = ["M5","M8", 'M3' if self.settings["laser_mode"] == "constant" else 'M4']
162
+ # laser off, fan off
163
+ footer_gc = ["M5","M9"]
164
+
165
+ return '\n'.join(header_gc + self.gcode + footer_gc)
166
+
167
+ def compile_to_file(self, file_name: str, svg_file_name: str, curves: list[Curve], passes=1):
168
+ """
169
+ A wrapper for the self.compile method. Assembles the code in the header, body and footer, saving it to a file.
170
+
171
+ :param file_name: the path to save the file.
172
+ :param svg_file_name: the path to the original svg image.
173
+ :param curves: SVG curves approximated by line segments.
174
+ :param passes: the number of passes that should be made. Every pass the machine moves_down (z-axis) by
175
+ self.pass_depth and self.body is repeated.
176
+ """
177
+ self.svg_file_name = svg_file_name
178
+
179
+ # generate gcode for 'path' and 'image' svg tags (calculate bbox)
180
+ self.append_curves(curves)
181
+
182
+ header = '\n'.join(self.header) + '\n'
183
+
184
+ if len(self.body) > 0:
185
+ # write path objects
186
+ with open(file_name, 'w') as file:
187
+ emit_program_end = self.interface.program_end() if (self.settings["splitfile"] or len(self.gcode) == 0) else ""
188
+ file.write(self.gcode_file_header() + header + self.compile(passes=passes) + '\n' + emit_program_end)
189
+ logger.info(f"Generated {file_name}")
190
+ else:
191
+ logger.warn(f'No path (curve) data found, skipping "{file_name}"')
192
+
193
+ image_file_name = file_name.rsplit('.',1)[0] + "_images." + file_name.rsplit('.',1)[1]
194
+ if len(self.gcode) == 0:
195
+ if self.settings["splitfile"]:
196
+ logger.warn(f"No image found, skipping '{image_file_name}'")
197
+ else:
198
+ logger.warn(f"No image found in file '{svg_file_name}'")
199
+ else:
200
+ if self.settings["splitfile"]:
201
+ # emit image objects to <filename>_images.<gcext>
202
+ with open(image_file_name, 'w') as file:
203
+ file.write(self.gcode_file_header() + header + self.compile_images() + '\n' + self.interface.program_end() + '\n')
204
+ logger.info(f"Generated {image_file_name}")
205
+ else:
206
+ # emit images objects in same file
207
+ open_mode = 'w' if len(self.body) == 0 else 'a+'
208
+ with open(file_name, open_mode) as file:
209
+ file.write((self.gcode_file_header() if len(self.body) == 0 else "") + '\n' + self.compile_images() + '\n' + self.interface.program_end() + '\n')
210
+ logger.info(f"Added image(s) to {file_name}")
211
+
212
+ def append_line_chain(self, line_chain: LineSegmentChain, step: float, color: int = None):
213
+ """
214
+ Draws a LineSegmentChain by calling interface.linear_move() for each segment. The resulting code is appended to
215
+ self.body
216
+ """
217
+
218
+ if line_chain.chain_size() == 0:
219
+ logger.warn("Attempted to parse empty LineChain")
220
+ return
221
+
222
+ code = [f"\n; delta: {step}"]
223
+ start = line_chain.get(0).start
224
+
225
+ # set laser power to color value when svg attribute 'stroke' (color) is set
226
+ laser_power = color if color is not None else self.settings["laser_power"]
227
+
228
+ # Move to the next line_chain when the next line segment doesn't connect to the end of the previous one.
229
+ if self.interface.position is None or abs(self.interface.position - start) > TOLERANCES["operation"]:
230
+ if self.interface.position is None or self.settings["rapid_move"]:
231
+ # move to the next line_chain: set laser off, rapid move to start of chain,
232
+ # set movement (cutting) speed, set laser mode and power on
233
+ code += [self.interface.laser_off(), self.interface.rapid_move(start.x, start.y),
234
+ self.interface.set_movement_speed(self.settings["movement_speed"]),
235
+ self.interface.set_laser_mode(self.settings["laser_mode"]), self.interface.set_laser_power_value(laser_power)]
236
+ else:
237
+ # move to the next line_chain: set laser mode, set laser power to 0 (cutting is off),
238
+ # set movement speed, (no rapid) move to start of chain, set laser to power
239
+ code += [self.interface.set_laser_mode(self.settings["laser_mode"]), self.interface.set_laser_power_value(0),
240
+ self.interface.set_movement_speed(self.settings["movement_speed"]), self.interface.linear_move(start.x, start.y),
241
+ self.interface.set_laser_power_value(laser_power)]
242
+
243
+ self.boundingbox.update(start)
244
+
245
+ if self.settings["dwell_time"] > 0:
246
+ code += [self.interface.dwell(self.settings["dwell_time"])] + code
247
+
248
+ for line in line_chain:
249
+ code.append(self.interface.linear_move(line.end.x, line.end.y))
250
+ self.boundingbox.update(line.end)
251
+
252
+ self.body.extend(code)
253
+
254
+ def isBase64(self, b64str):
255
+ try:
256
+ base64.b64decode(b64str)
257
+ return True
258
+ except Exception as e:
259
+ return False
260
+
261
+ def decode_base64(self, base64_string):
262
+ """
263
+ Get base64 image from either embedded data or file.
264
+ :param base64_string: xlink:href field from SVG document
265
+ """
266
+ img_file = ''
267
+
268
+ # note: file names (including path) do not have (re): '<>:;,*|\"' characters,
269
+ # base64 data has only '([A-Za-z0-9+/]{4})*' (multiples of 4 ended by '='
270
+ # character padding). So, char ':' which is part of either prefix, makes
271
+ # a match possible.
272
+
273
+ # strip either 'file:...' or 'data:...' prefix (MIME part) from field 'xlink:href'
274
+ is_link = not base64_string.startswith('data')
275
+ fileordata = re.sub("(^file://|^data:[a-z0-9;/]+,)","", base64_string, flags=re.I)
276
+
277
+ # if os.path.isfile(fileordata):
278
+ if is_link:
279
+ if fileordata.startswith(os.path.curdir):
280
+ img_file = os.path.join(os.path.dirname(self.svg_file_name), fileordata)
281
+ else:
282
+ img_file = fileordata
283
+ if not os.path.isfile(img_file):
284
+ logger.error("Unable to find image : %s", img_file)
285
+ return
286
+
287
+ elif self.isBase64(fileordata):
288
+ # convert to right form
289
+ imgdata = base64.b64decode(fileordata)
290
+
291
+ # open as binary file
292
+ img_file = BytesIO(imgdata)
293
+ else:
294
+ # Neither file nor data
295
+ logger.error("Unable to read image data: %s", base64_string[:30])
296
+ return
297
+
298
+ return Image.open(img_file)
299
+
300
+ # convert a MIME base64 image image string
301
+ def convert_image(self, image:str, img_attrib: dict[str, Any]):
302
+
303
+ # get svg image attributes info or default
304
+ pixelsize = img_attrib['gcode_pixelsize'] if 'gcode_pixelsize' in img_attrib else self.settings["pixel_size"]
305
+
306
+ # convert image from base64 string
307
+ img = self.decode_base64(image)
308
+
309
+ # convert image to new size
310
+ if img is not None:
311
+ # add alpha channel
312
+ img = img.convert("RGBA")
313
+
314
+ # create a white background and add it to the image
315
+ img_background = Image.new(mode = "RGBA", size = img.size, color = (255,255,255))
316
+ img = Image.alpha_composite(img_background, img)
317
+
318
+ # Note that the image resize action below is based on the following:
319
+ # - the image data (linked file or embedded) has a certain source resolution (number of pixels WidthxHeight)
320
+ # - image attributes 'width' and 'height' are in user units
321
+ # - user units are default (Inkscape, others?) set to be mm (1 user unit is 1 mm)
322
+ # - make sure this is the case (Inkscape: check the document settings and look at the 'scaling' parameter)
323
+ # - if this is the case, we have images attributes 'width' and 'height' in mm
324
+ # - so, for a given pixelsize, we need a 'width'/pixelsize x 'height'/pixelsize resolution to get to the correct
325
+ # width and height in mm after 'width' steps (same for height in the other direction)
326
+ # (note that the image conversion algorithm takes these steps)
327
+ # - note that the source resolution does not seem to be taken into account and (so) does not matter.
328
+ # generally that is the case when the result of the calculation above results in downsampling of the source image
329
+ # (make it lower resolution), if the calculation results in upsampling of the source image (make it higher resolution)
330
+ # it does make a difference because the resized image can be blocky
331
+
332
+ # convert image to black&white (without alpha) and new size)'
333
+ img = img.resize((int(float(img_attrib['width'])/float(pixelsize)),
334
+ int(float(img_attrib['height'])/float(pixelsize))), Image.Resampling.LANCZOS).convert("L")
335
+
336
+ if self.settings['showimage']:
337
+ img.show()
338
+
339
+ # convert to nparray for fast handling
340
+ return np.array(img)
341
+
342
+ return None
343
+
344
+ def image2gcode(self, img_attrib: dict[str, Any], img=None, transformation = None, power = None):
345
+
346
+ # create image conversion object
347
+ convert = Image2gcode(transformation = transformation.apply_affine_transformation if transformation is not None else None, power = power)
348
+
349
+ #
350
+ # set arguments
351
+
352
+ # get svg image attribute/object info (set in Inkscape for example) when available
353
+ # else set tool invocation values
354
+ arguments = {}
355
+ arguments["pixelsize"] = img_attrib['gcode_pixelsize'] if 'gcode_pixelsize' in img_attrib else self.settings["pixel_size"]
356
+ arguments["maxpower"] = img_attrib['gcode_maxpower'] if 'gcode_maxpower' in img_attrib else self.settings["maximum_image_laser_power"]
357
+ arguments["poweroffset"] = img_attrib['gcode_poweroffset'] if 'gcode_poweroffset' in img_attrib else self.settings["image_poweroffset"]
358
+ arguments["speed"] = img_attrib['gcode_speed'] if 'gcode_speed' in img_attrib else self.settings["image_movement_speed"]
359
+ arguments["noise"] = img_attrib['gcode_noise'] if 'gcode_noise' in img_attrib else self.settings["image_noise"]
360
+ arguments["speedmoves"] = img_attrib['gcode_speedmoves'] if 'gcode_speedmoves' in img_attrib else self.settings["rapid_move"]
361
+ arguments["overscan"] = img_attrib['gcode_overscan'] if 'gcode_overscan' in img_attrib else self.settings["image_overscan"]
362
+ arguments["showoverscan"] = img_attrib['gcode_showoverscan'] if 'gcode_showoverscan' in img_attrib else self.settings["image_showoverscan"]
363
+ arguments["offset"] = (float(img_attrib['x']), float(img_attrib['y']))
364
+ arguments["name"] = img_attrib['id']
365
+ arguments["invert"] = img_attrib['invert'] if 'invert' in img_attrib else False
366
+
367
+ # get image parameters
368
+ params = ''
369
+ for k, v in arguments.items():
370
+ if params != '':
371
+ params += ",\n"
372
+ params += f"; {k}: {v}"
373
+
374
+ self.gcode += [f"; image:\n{params}"]
375
+
376
+ # get gcode for image
377
+ self.gcode += [convert.image2gcode(img, arguments)]
378
+
379
+ # update bounding box info
380
+ bbox_image = convert.bbox.get()
381
+ self.boundingbox.update(bbox_image[0])
382
+ self.boundingbox.update(bbox_image[1])
383
+
384
+ def parse_style_attribute(self, curve: Curve) -> {}:
385
+ """
386
+ Parse style attribute.
387
+ for example "fill:#F4CF84;fill-rule:evenodd;stroke:#D07735;"
388
+ """
389
+
390
+ style = {'fill' : None, 'fill-rule': None, 'fill-opacity': None, 'stroke': None, 'stroke-width': None, 'stroke-opacity': None, 'pathcut': None}
391
+
392
+ # parse style attribute
393
+
394
+ if curve.path_attrib and 'style' in curve.path_attrib:
395
+ style_str = curve.path_attrib['style']
396
+
397
+ # parse fill
398
+ if 'fill' in style_str:
399
+ fill = re.search('fill:[^;]+;',style_str)
400
+ if fill:
401
+ style['fill'] = fill.group(0)[5:-1]
402
+ # parse fill-rule
403
+ if 'fill-rule' in style_str:
404
+ fill_rule = re.search('fill-rule:#(evenodd|nonzero)',style_str)
405
+ if fill_rule:
406
+ style['fill-rule'] = re.search('(evenodd|nonzero)', fill_rule.group(0)).group(0)
407
+ # parse fill-opacity
408
+ if 'fill-opacity' in style_str:
409
+ fill_opacity = re.search('fill-opacity:(\d*\.)?\d+',style_str)
410
+ if fill_opacity:
411
+ style['fill-opacity'] = re.search('(\d*\.)?\d+', fill_opacity.group(0)).group(0)
412
+ # parse stroke
413
+ if 'stroke' in style_str:
414
+ stroke = re.search('stroke:[^;]+;',style_str)
415
+ if stroke:
416
+ style['stroke'] = stroke.group(0)[7:-1]
417
+ # parse stroke-width
418
+ if 'stroke-width' in style_str:
419
+ stroke_width = re.search('stroke-width:(\d*\.)?\d+',style_str)
420
+ if stroke_width:
421
+ style['stroke-width'] = re.search('(\d*\.)?\d+', stroke_width.group(0)).group(0)
422
+ # parse stroke-opacity
423
+ if 'stroke-opacity' in style_str:
424
+ stroke_opacity = re.search('stroke-opacity:(\d*\.)?\d+',style_str)
425
+ if stroke_opacity:
426
+ style['stroke-opacity'] = re.search('(\d*\.)?\d+', stroke_opacity.group(0)).group(0)
427
+
428
+ # parse pathcut
429
+ if 'gcode-pathcut' in style_str:
430
+ pathcut = re.search('gcode-pathcut:(true|false)',style_str)
431
+ if pathcut:
432
+ style['pathcut'] = re.search('(true|false)', pathcut.group(0)).group(0)
433
+
434
+ # parse other attributes
435
+
436
+ # parse fill attribute
437
+ if 'fill' in curve.path_attrib:
438
+ style['fill'] = curve.path_attrib['fill']
439
+ if 'fill-rule' in curve.path_attrib:
440
+ style['fill-rule'] = curve.path_attrib['fill-rule']
441
+ # parse fill-opacity
442
+ if 'fill-opacity' in curve.path_attrib:
443
+ fill_opacity = re.search('fill-opacity:(\d*\.)?\d+',curve.path_attrib)
444
+ if fill_opacity:
445
+ style['fill-opacity'] = re.search('(\d*\.)?\d+', fill_opacity.group(0)).group(0)
446
+ # parse stroke attribute
447
+ if 'stroke' in curve.path_attrib:
448
+ style['stroke'] = curve.path_attrib['stroke']
449
+ # parse stroke-width attribute
450
+ if 'stroke-width' in curve.path_attrib:
451
+ style['stroke-width'] = curve.path_attrib['stroke-width']
452
+ # parse stroke-opacity attribute
453
+ if 'stroke-opacity' in curve.path_attrib:
454
+ style['stroke-opacity'] = curve.path_attrib['stroke-opacity']
455
+
456
+ # parse gcode_pathcut attribute
457
+ if 'gcode_pathcut' in curve.path_attrib:
458
+ style['pathcut'] = curve.path_attrib['gcode_pathcut']
459
+
460
+ return style
461
+
462
+ def append_curves(self, curves: list[Curve]):
463
+ """
464
+ Draws curves.
465
+ """
466
+
467
+ def line_slope(p1: (int,int), p2: (int,int)):
468
+ """Calculate the slope of the line p1p2"""
469
+ x1, y1 = p1[0], p1[1]
470
+ x2, y2 = p2[0], p2[1]
471
+
472
+ if x1 == x2:
473
+ return 1
474
+
475
+ return (y1 - y2) / (x1 - x2)
476
+
477
+ def line_offset(p1: (int,int), p2: (int,int)):
478
+ """Calculate the offset of the line p1p2 from the origin"""
479
+ x1, y1 = p1[0], p1[1]
480
+
481
+ return y1 - line_slope(p1, p2) * x1
482
+
483
+ def lline(x, slope, offset):
484
+ y = slope * x + offset
485
+ return round(y)
486
+
487
+ def draw(img, start: (int,int), end: (int,int), color):
488
+ """
489
+ Draws a line from - and including - start (x,y) to - and including - end (x,y).
490
+ """
491
+ slope = line_slope(start, end)
492
+ offset = line_offset(start, end)
493
+
494
+ if slope == 1 and ((start[0] - end[0]) != (start[1] - end[1])):
495
+ # vertical line (not a 45 degrees line)
496
+ diy = 1 if end[1] > start[1] else -1
497
+ for y2 in range(start[1], end[1] + diy, diy):
498
+ img[y2,start[0]] = color
499
+ else:
500
+ # non vertical line
501
+ dix = 1 if end[0] > start[0] else -1
502
+ prev = start
503
+ for x in range(start[0], end[0] + dix, dix):
504
+ y = lline(x,slope,offset)
505
+ if abs(y - prev[1]) > 1:
506
+ di = 1 if y > prev[1] else -1
507
+ for y1 in range(prev[1] + di, y, di):
508
+ img[y1,x] = color
509
+
510
+ img[y,x] = color
511
+ prev = (x,y)
512
+
513
+ def draw_line(img: np.array, p1: (int,int), p2: (int,int), gray: int):
514
+ """
515
+ Draws a line from - and including - p1 (x,y) to -and including p2 (x,y).
516
+ """
517
+ if not ((p1[0] < 0) or (p1[1] < 0) or (p2[0] < 0) or( p2[1] < 0)):
518
+ pixel = 1/self.settings["pixel_size"]
519
+ draw(img, (int(p1[0]*pixel),int(p1[1]*pixel)),(int(p2[0]*pixel),int(p2[1]*pixel)), gray)
520
+
521
+ path_curves = {}
522
+ pixel_size = float(self.settings["pixel_size"])
523
+
524
+ for curve in curves:
525
+ if isinstance(curve, RasterImage):
526
+ # curve is 'image', draw it
527
+
528
+ # convert image (scale and type)
529
+ img = self.convert_image(curve.image, curve.img_attrib)
530
+
531
+ if img is not None:
532
+ # Draw image by converting raster image scan lines to gcode, possibly applying tranformations on each pixel
533
+ self.image2gcode(curve.img_attrib, img, curve.transformation)
534
+ else:
535
+ # curve is a 'path', approximate it (when needed) as line segments.
536
+ # organize curves by 'name_id' to be able to apply fill/stroke color and
537
+ # line width later on.
538
+
539
+ curve_name_id = ""
540
+ # parse id
541
+ if curve.path_attrib and 'id' in curve.path_attrib:
542
+ curve_name_id = curve.path_attrib['id']
543
+
544
+ if curve_name_id not in path_curves:
545
+ path_curves[curve_name_id] = []
546
+
547
+ line_chain = LineSegmentChain()
548
+ # approximate curve
549
+ approximation = LineSegmentChain.line_segment_approximation(curve)
550
+ line_chain.extend(approximation)
551
+
552
+ # stitch chains when the next chain starts at the end of the previous chain
553
+ if len(path_curves[curve_name_id]) and path_curves[curve_name_id][-1].get(-1).end == line_chain.get(0).start:
554
+ path_curves[curve_name_id][-1].extend(line_chain)
555
+ else:
556
+ # add line segments to curves having this id
557
+ path_curves[curve_name_id].append(line_chain)
558
+
559
+ # default stroke width is one line (thickness)
560
+ stroke_width = pixel_size
561
+
562
+ # emit all paths (organized by name id)
563
+ for name_id in path_curves:
564
+ steps = []
565
+ fill_color = None
566
+ fill_rule = None
567
+ # set a boundingbox per 'name_id'
568
+ boundingbox = Boundingbox()
569
+ for line_chain in path_curves[name_id]:
570
+
571
+ # default stroke width is one line (thickness)
572
+ width = 0
573
+ stroke_color = ""
574
+ stroke_alpha = None
575
+
576
+ # get style info for this line chain (of object 'name_id')
577
+ first_line_of_chain = line_chain.get(0)
578
+ style = self.parse_style_attribute(first_line_of_chain)
579
+ if style:
580
+ if not (style['pathcut'] == 'true' or self.settings["pathcut"]) \
581
+ and style['stroke'] is not None and style['stroke'] != "none":
582
+ stroke_color = style['stroke']
583
+ if style['stroke-opacity'] is not None:
584
+ # fill opacity attribute overrides rgba property
585
+ stroke_alpha = float(style['stroke-opacity'])
586
+ if not (stroke_alpha >=0 and stroke_alpha <= 1):
587
+ logger.warn(f"Opacity value '{stroke_alpha}' should be in range [0.0..1.0]!")
588
+ if style['stroke-width'] is not None and style['stroke-width'] != "none":
589
+ stroke_width = float(style['stroke-width'])
590
+ width = math.ceil(round(stroke_width/pixel_size, self.precision)/2)
591
+ if style['fill'] is not None:
592
+ # Invert b&w value and apply alpha channel - when available - to the inverted b&w value
593
+ # (library function image2gcode - as it is now - cannot invert a color value and then apply
594
+ # the alpha channel) Note that step 6 sets option 'invert' to false to be able to use the value below directly.
595
+ fill_color = Image2gcode.linear_power(css_color.parse_css_color2bw8(style['fill']), self.settings["maximum_image_laser_power"])
596
+ if style['fill-rule'] is not None:
597
+ fill_rule = style['fill-rule']
598
+ if fill_rule == "nonzero":
599
+ logger.warn(f"fill-rule 'nonzero' of object '{name_id}' is currently unsupported!")
600
+ if style['fill-opacity'] is not None:
601
+ # fill opacity attribute overrides rgba property
602
+ fill_alpha = float(style['fill-opacity'])
603
+ if not (fill_alpha >=0 and fill_alpha <= 1):
604
+ logger.warn(f"Opacity value '{fill_alpha}' should be in range [0.0..1.0]!")
605
+ fill_alpha = 1
606
+ else:
607
+ rgba = css_color.parse_css_color(style['fill'])
608
+ fill_alpha = 1
609
+ if len(rgba) == 4:
610
+ fill_alpha = rgba[3]
611
+ fill_color = fill_color * fill_alpha
612
+
613
+ # Get steps (offsets) for the lines that make the border
614
+ steps = [0]
615
+ if len(stroke_color) and width:
616
+ for delta in range(width):
617
+ if delta:
618
+ steps.append(round(delta * pixel_size, self.precision))
619
+ if not (delta == width and width % 2 != 0):
620
+ steps.append(round(-delta * pixel_size, self.precision))
621
+
622
+ # Get stroke alpha channel (opacity)
623
+ if len(stroke_color):
624
+ # fill opacity attribute overrides rgba property
625
+ if stroke_alpha is None:
626
+ stroke_alpha = 1
627
+ rgba = css_color.parse_css_color(stroke_color)
628
+ if len(rgba) == 4:
629
+ stroke_alpha = rgba[3]
630
+
631
+ # Render svg stroke (of size 'width')
632
+ # (draw smooth border outlines in gcode)
633
+ # Note the update of the boundingbox.
634
+ self.body.extend([f"\n; border: '{name_id}'"])
635
+ for step in steps:
636
+ if step:
637
+ delta_chain = LineSegmentChain.delta_chain(line_chain, step)
638
+ # apply alpha channel - when available - to inverted b&w value
639
+ inverse_bw = Image2gcode.linear_power(css_color.parse_css_color2bw8(stroke_color), self.settings["maximum_image_laser_power"]) * stroke_alpha
640
+ self.append_line_chain(delta_chain, step, inverse_bw)
641
+ # update 'name_id' boundingbox
642
+ for line in delta_chain:
643
+ boundingbox.update(line.start)
644
+ boundingbox.update(line.end)
645
+ else:
646
+ # apply alpha channel - when available - to inverted b&w value
647
+ inverse_bw = Image2gcode.linear_power(css_color.parse_css_color2bw8(stroke_color), self.settings["maximum_image_laser_power"]) * stroke_alpha if len(stroke_color) else None
648
+ self.append_line_chain(line_chain, step, inverse_bw)
649
+ # update 'name_id' boundingbox
650
+ for line in line_chain:
651
+ boundingbox.update(line.start)
652
+ boundingbox.update(line.end)
653
+
654
+ if not self.settings["nofill"] and fill_color is not None:
655
+ # fill a path
656
+ # this is done in 6 steps:
657
+ # step 1: create two raster images matching the bbox
658
+ # step 2: add marker lines just inside the line chains of the path
659
+ # step 3: scan the marker image lines and apply 'evenodd' fill
660
+ # (fill rule 'nonzero' to be implemented later on see note below)
661
+ # step 4: draw white borders to erase fill overlap
662
+ # step 5: filter stray pixels (to remove noise from the action above)
663
+ # step 6: generate gcode from image_fill
664
+
665
+ # get bounding box info from the path border
666
+ lowerleft = boundingbox.get()[0]
667
+ upperright = boundingbox.get()[1]
668
+
669
+ # normalize origin to (0.0)
670
+ vdXY = Vector(-lowerleft.x, -lowerleft.y)
671
+ dXY = (-lowerleft.x, -lowerleft.y)
672
+
673
+ # get raster image dimensions
674
+ img_height = math.ceil((upperright.y - lowerleft.y)/pixel_size)
675
+ img_width = math.ceil((upperright.x - lowerleft.x)/pixel_size)
676
+
677
+ # default scan error (step 3 below)
678
+ scan_error = 4
679
+
680
+ # step 1: create two raster images matching the bbox
681
+ # init
682
+ image_mark = np.full([img_height + 2, img_width + scan_error], 255, dtype=np.uint8)
683
+ image_fill = np.full([img_height + 2, img_width + scan_error], 0, dtype=np.uint8)
684
+
685
+ # step 2: add marker lines just inside the line chains of the path
686
+ # - determine the inside of the line chain
687
+ # - draw marker lines
688
+ for line_chain in path_curves[name_id]:
689
+ # Note that a svg object with a specific name_id can have multiple line_chains that
690
+ # together, define one shape (circumference). When this the case the boundingbox
691
+ # inside/outside method below is not fullproof. This can be solved to stitch together
692
+ # the line chain parts (TODO)
693
+
694
+ # - determine the inside of the line chain
695
+ # Compare the bbox of the line chain with that of a delta line chain a
696
+ # fixed distance (offset) from the base line chain. Initially we do not know if the
697
+ # delta line chain is inside or outside the base line chain, but when we compare
698
+ # the bbox sizes we know.
699
+ # Note that this method can be used to determine if a line chain is drawn clockwise
700
+ # or anti-clockwise because a posive delta offset should be 'outside' the line chain
701
+ # in this case (depending on the definition/calculation of the delta function).
702
+ # we can use this to implement the other svg fill rule: 'nonzero'.
703
+ bbox = Boundingbox()
704
+ for line in line_chain:
705
+ bbox.update(line.start + vdXY)
706
+ bbox.update(line.end + vdXY)
707
+ bbox_size = bbox.size()
708
+
709
+ bbox = Boundingbox()
710
+ delta_chain = LineSegmentChain.delta_chain(line_chain, pixel_size * 2)
711
+ for line in delta_chain:
712
+ bbox.update(line.start + vdXY)
713
+ bbox.update(line.end + vdXY)
714
+ bbox_deltasize = bbox.size()
715
+
716
+ # compare bboxes and set inside offset
717
+ inside = 1
718
+ if bbox_deltasize > bbox_size:
719
+ inside = -1
720
+
721
+ # Note that some tuning is going on here.
722
+ # This can be remedied in several ways:
723
+ # - use draw lines that have a thickness?
724
+ # - use a higher resolution (pixel grid) to reduce the 'rounding' errors
725
+ offsets = [inside * .5 * pixel_size, inside * pixel_size, inside * 1.5 * pixel_size, inside * 2 * pixel_size]
726
+ # Note that the system sometimes returns line_chains having 1 point and thus having no size, this is 'solved'
727
+ # below, but should not happen (TODO). It is also assumed that line_chain parts that define one shape have
728
+ # similar sizes (TODO).
729
+ if bbox_size > 0 and bbox_size < 6:
730
+ # small area, less margin for error
731
+ scan_error = 1
732
+ del offsets[-2:]
733
+
734
+ ## direct gcode: update this, see note step 3
735
+ # draw border lines of a specific marker color within line chain
736
+ # Note that the marker values are just a choice and only have to be consistent
737
+ # with the algorithm used (step 3)
738
+ if bbox_size > 0.0:
739
+ for offset in offsets:
740
+ # make a line chain just one pixel inside the base (step 0) line chain
741
+ delta_chain = LineSegmentChain.delta_chain(line_chain, offset)
742
+ for line in delta_chain:
743
+ draw_line(image_mark, line.start + vdXY, line.end + vdXY, 128)
744
+
745
+ # draw the line chain border using another marker color
746
+ for line in line_chain:
747
+ draw_line(image_mark, line.start + vdXY, line.end + vdXY, 10)
748
+
749
+ # step 3: scan the marker image lines and and apply 'evenodd' fill
750
+ # (fill rule 'nonzero' to be implemented later on)
751
+ # for each line (y):
752
+ # for each point (x) on the line:
753
+ # scan from left to right or reverse:
754
+ # for markers (border,inside border) and apply rule
755
+ # 'evenodd' to fill when an even number of borders
756
+ # is crossed, untill odd
757
+ # Note that the image 'image_fill' is filled not 'image_mark' to make sure marks stay in place.
758
+
759
+ # Note that the fill algorithm can be adapted to support direct rendering of gcode (instead of rendering via 'image_fill' and
760
+ # function 'image2gcode', converting a raster image to gcode). This reduces the number of steps needed: steps 4, 5 and 6 can
761
+ # be left out. It does need a few adaptions in the previous steps, namely drawing the line chain border (color '10') in full width
762
+ # (instead of 1 pixel) and draw the 'inside' line (color 128) one pixel further. In addition to this uncomment the '## direct gcode'
763
+ # lines of step 3 to activate gcode rendering.
764
+
765
+ ## direct gcode
766
+ ## code = [f"\n; fill '{name_id}'"]
767
+ ## code += [self.interface.set_laser_power_value(Image2gcode.linear_power(fill_color, self.settings["maximum_image_laser_power"]))]
768
+
769
+ go_right = True
770
+ # start scanning to the right
771
+ for y in range(image_mark.shape[0]):
772
+ evenodd = 0
773
+
774
+ if go_right:
775
+ # scan to the right
776
+ x = 0
777
+ start = None
778
+ while x < image_mark.shape[1]:
779
+ if image_mark[y,x] == 10 :
780
+ # found a border
781
+ x_b = x
782
+ ## direct gcode
783
+ ## update line below, to be able to skip empty (value 255) pixels
784
+ # scan border, possibly having multiple pixels.
785
+ while x_b < image_mark.shape[1] and image_mark[y,x_b] == 10:
786
+ x_b += 1
787
+
788
+ xscan_b = x - 1
789
+ while xscan_b > 0 and xscan_b > (x - scan_error) and (image_mark[y,xscan_b] != 10 and image_mark[y,xscan_b] != 128):
790
+ xscan_b -= 1
791
+ xscan_a = x_b
792
+ while xscan_a < image_mark.shape[1] and xscan_a < (x_b + scan_error) and (image_mark[y,xscan_a] != 10 and image_mark[y,xscan_a] != 128):
793
+ xscan_a += 1
794
+
795
+ if xscan_b >= 0 and image_mark[y,xscan_b] == 128:
796
+ # found border
797
+ if evenodd % 2 == 0:
798
+ start = (x * pixel_size, y * pixel_size)
799
+ ## direct gcode
800
+ ## code += [self.interface.rapid_move(start[0] - dXY[0], start[1] - dXY[1])]
801
+ else:
802
+ draw_line(image_fill, start + dXY, (x * pixel_size, y * pixel_size) + dXY, fill_color)
803
+ ## direct gcode
804
+ ## code += [self.interface.linear_move(x * pixel_size - dXY[0], y * pixel_size - dXY[1])]
805
+ evenodd = evenodd + 1
806
+ if xscan_a < image_mark.shape[1] and image_mark[y,xscan_a] == 128:
807
+ # found border
808
+ if evenodd % 2 == 0:
809
+ start = (x * pixel_size, y * pixel_size)
810
+ ## direct gcode
811
+ ## code += [self.interface.rapid_move(start[0] - dXY[0], start[1] - dXY[1])]
812
+ else:
813
+ draw_line(image_fill, start + dXY, (x_b * pixel_size, y * pixel_size) + dXY, fill_color)
814
+ ## direct gcode
815
+ ## code += [self.interface.linear_move(x_b * pixel_size - dXY[0], y * pixel_size - dXY[1])]
816
+ evenodd = evenodd + 1
817
+ #if x_b > x:
818
+ x = x_b - 1
819
+ x += 1
820
+ else:
821
+ # scan to the left
822
+ start = None
823
+ x = image_mark.shape[1] - 1
824
+ while x >= 0:
825
+ if image_mark[y,x] == 10:
826
+ # found a border
827
+ ## direct gcode
828
+ ## update line below, to be able to skip empty (value 255) pixels
829
+ # scan border, possibly having multiple pixels.
830
+ x_b = x
831
+ while x_b >= 0 and image_mark[y,x_b] == 10:
832
+ x_b -= 1
833
+
834
+ xscan_b = x_b
835
+ while xscan_b > 0 and xscan_b > (x_b - scan_error) and (image_mark[y,xscan_b] != 10 and image_mark[y,xscan_b] != 128):
836
+ xscan_b -= 1
837
+ xscan_a = x + 1
838
+ while xscan_a < image_mark.shape[1] and xscan_a < (x + scan_error) and (image_mark[y,xscan_a] != 10 and image_mark[y,xscan_a] != 128):
839
+ xscan_a += 1
840
+
841
+ if xscan_a < image_mark.shape[1] and image_mark[y,xscan_a] == 128:
842
+ # found border
843
+ if evenodd % 2 == 0:
844
+ start = (x * pixel_size, y * pixel_size)
845
+ ## direct gcode
846
+ ## code += [self.interface.rapid_move(start[0] - dXY[0], start[1] - dXY[1])]
847
+ else:
848
+ draw_line(image_fill, start + dXY, (x * pixel_size, y * pixel_size) + dXY, fill_color)
849
+ ## direct gcode
850
+ ## code += [self.interface.linear_move(x * pixel_size - dXY[0], y * pixel_size - dXY[1])]
851
+ evenodd = evenodd + 1
852
+ if xscan_b >= 0 and image_mark[y,xscan_b] == 128:
853
+ # found border
854
+ if evenodd % 2 == 0:
855
+ start = (x * pixel_size, y * pixel_size)
856
+ ## direct gcode
857
+ ## code += [self.interface.rapid_move(start[0] - dXY[0], start[1] - dXY[1])]
858
+ else:
859
+ draw_line(image_fill, start + dXY, (x_b * pixel_size, y * pixel_size) + dXY, fill_color)
860
+ ## direct gcode
861
+ ## code += [self.interface.linear_move(x_b * pixel_size - dXY[0], y * pixel_size - dXY[1])]
862
+ evenodd = evenodd + 1
863
+ #if x_b < x:
864
+ x = x_b + 1
865
+ x -= 1
866
+
867
+ # switch scan direction
868
+ go_right = not go_right
869
+
870
+ ## direct gcode
871
+ ## self.body.extend(code)
872
+
873
+ ## direct gcode: ignore this step
874
+ # step 4: draw white borders to erase fill overlap
875
+
876
+ # 4a: make half steps to 'completely' erase the overlap
877
+ halfsteps = copy.deepcopy(steps)
878
+ for step in steps:
879
+ if step:
880
+ sign = -1 if step >= 0 else 1
881
+ halfsteps.append(round(step + sign * pixel_size/2, self.precision))
882
+
883
+ # 4b: erase
884
+ for line_chain in path_curves[name_id]:
885
+ for step in halfsteps:
886
+ if step:
887
+ delta_chain = LineSegmentChain.delta_chain(line_chain, step)
888
+ for line in delta_chain:
889
+ draw_line(image_fill, line.start + vdXY, line.end + vdXY, 0)
890
+ else:
891
+ for line in line_chain:
892
+ draw_line(image_fill, line.start + vdXY, line.end + vdXY, 0)
893
+
894
+ ## direct gcode: ignore this step
895
+ # step 5: filter stray pixels (to remove noise from the action above)
896
+ for y in range(image_fill.shape[0]):
897
+ for x in range(image_fill.shape[1]):
898
+ if image_fill[y,x] != 0:
899
+ if x > 1 and x < (image_fill.shape[1] - 1) and y > 1 and y < (image_fill.shape[0] - 1):
900
+ if image_fill[y,x+1] == 0 and image_fill[y,x-1] == 0 and image_fill[y+1,x] == 0 and image_fill[y-1,x] == 0:
901
+ image_fill[y,x] = 0
902
+
903
+ ## direct gcode: ignore this step
904
+ # step 6: generate gcode from image_fill
905
+ img_attrib = {}
906
+ img_attrib['id'] = name_id
907
+ # Set speedmove to a short distance to be able to view it correctly using a viewer like LaserWeb.
908
+ # (image2gcode converts movements without writing to 'G1 S0' gcodes which viewers show in a specific
909
+ # writing color, speedmoves uses gcode G0 to move without writing which viewers show in another color)
910
+ #img_attrib['gcode_speedmoves'] = 0.1
911
+ img_attrib['x'] = -dXY[0]
912
+ img_attrib['y'] = -dXY[1]
913
+ img_attrib['invert'] = False
914
+
915
+ ## direct gcode: ignore this step
916
+ # start gcode fill
917
+ self.body.extend([f"\n;fill '{name_id}'"])
918
+ self.image2gcode(img_attrib, image_fill)
919
+
920
+ def check_axis_maximum_travel(self):
921
+ return self.settings["x_axis_maximum_travel"] is not None and self.settings["y_axis_maximum_travel"] is not None
922
+ # logger.warn("Please define machine cutting area, set parameter: 'x_axis_maximum_travel' and 'y_axis_maximum_travel'")
923
+
924
+ def check_bounds(self):
925
+ """
926
+ Check if line segments are within the machine cutting area. Note that machine coordinate mode must
927
+ be absolute and machine parameters 'x_axis_maximum_travel' and 'y_axis_maximum_travel' are set, also
928
+ bounding box must be in the positive quadrant.
929
+ :return true when box is in machine area bounds, false otherwise
930
+ """
931
+
932
+ if self.settings["distance_mode"] == "absolute" and self.check_axis_maximum_travel():
933
+ machine_max = Vector(self.settings["x_axis_maximum_travel"],self.settings["y_axis_maximum_travel"])
934
+ bbox = self.boundingbox.get()
935
+ # bbox[0] == lowerleft, bbox[1] == uperright, bbox[0/1][0] == x, bbox[0/1][1] == y
936
+ # lower left x and y >= 0 and upperright x and y <= resp. machine max x and y
937
+ return (bbox[0][0] >= 0 and bbox[0][1] >=0
938
+ and bbox[1][0] * (25.4 if self.settings["unit"] == "inch" else 1) <= machine_max.x
939
+ and bbox[1][1] * (25.4 if self.settings["unit"] == "inch" else 1) <= machine_max.y)
940
+
941
+ return False
File without changes
File without changes