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.
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/PKG-INFO +6 -2
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/README.md +5 -1
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/__init__.py +1 -1
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/__main__.py +1 -1
- svg2gcode-3.2.7/svg2gcode/svg_to_gcode/compiler/_compiler_1.py +941 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/LICENSE +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/pyproject.toml +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/LICENSE +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/__init__.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/__init__.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/_compiler.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/interfaces/__init__.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/interfaces/_abstract_interface.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/interfaces/_fan_controlled_gcode.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/compiler/interfaces/_gcode.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/css_color.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/formulas.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/__init__.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_abstract_chain.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_abstract_curve.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_circular_arc.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_cubic_bazier.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_elliptical_arc.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_line.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_line_segment_chain.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_matrix.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_quadratic_bazier.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_raster_image.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_smooth_arc_chain.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/geometry/_vector.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/svg_parser/__init__.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/svg_parser/_parser_methods.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/svg_parser/_path.py +0 -0
- {svg2gcode-3.2.5 → svg2gcode-3.2.7}/svg2gcode/svg_to_gcode/svg_parser/_transformation.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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```
|
|
@@ -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='
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|