vaseweld 1.3.0__py3-none-any.whl
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.
- vaseweld/__init__.py +5 -0
- vaseweld/bgcode.py +307 -0
- vaseweld/cli.py +286 -0
- vaseweld/compat.py +199 -0
- vaseweld/parser.py +475 -0
- vaseweld/preview.py +363 -0
- vaseweld/validate.py +245 -0
- vaseweld/weld.py +849 -0
- vaseweld-1.3.0.dist-info/METADATA +393 -0
- vaseweld-1.3.0.dist-info/RECORD +14 -0
- vaseweld-1.3.0.dist-info/WHEEL +5 -0
- vaseweld-1.3.0.dist-info/entry_points.txt +2 -0
- vaseweld-1.3.0.dist-info/licenses/LICENSE +21 -0
- vaseweld-1.3.0.dist-info/top_level.txt +1 -0
vaseweld/__init__.py
ADDED
vaseweld/bgcode.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Read PrusaSlicer's binary G-code back into the text form the rest of vaseweld expects.
|
|
2
|
+
|
|
3
|
+
Binarising a slice moves two things out of the G-code stream that vaseweld needs: the
|
|
4
|
+
config block, which becomes a Slicer Metadata block, and the filament and time totals,
|
|
5
|
+
which become a Print Metadata block. Without them the compatibility check has no fields
|
|
6
|
+
to compare and the totals cannot be recomputed, so this puts both back where PrusaSlicer
|
|
7
|
+
would have written them in a text file.
|
|
8
|
+
|
|
9
|
+
The trailing comment on every command (``G28 ; home all axes``) is not recoverable. The
|
|
10
|
+
binariser drops it, which is a good part of why the format is smaller.
|
|
11
|
+
|
|
12
|
+
Format: https://github.com/prusa3d/libbgcode/blob/main/doc/specifications.md
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import struct
|
|
18
|
+
import zlib
|
|
19
|
+
|
|
20
|
+
MAGIC = b"GCDE"
|
|
21
|
+
|
|
22
|
+
_FILE_METADATA = 0
|
|
23
|
+
_GCODE = 1
|
|
24
|
+
_SLICER_METADATA = 2
|
|
25
|
+
_PRINT_METADATA = 4
|
|
26
|
+
_THUMBNAIL = 5
|
|
27
|
+
|
|
28
|
+
_DEFLATE = 1
|
|
29
|
+
_HEATSHRINK_11_4 = 2
|
|
30
|
+
_HEATSHRINK_12_4 = 3
|
|
31
|
+
|
|
32
|
+
_MEATPACK = 1
|
|
33
|
+
_MEATPACK_COMMENTS = 2
|
|
34
|
+
|
|
35
|
+
# The 16 characters MeatPack can pack two-to-a-byte. 0xF means "not packed, the real
|
|
36
|
+
# byte follows"; 0xB is a space unless the no-spaces command swapped it for 'E'.
|
|
37
|
+
_PACKED = "0123456789. \nGX"
|
|
38
|
+
_SIGNAL = 0xFF
|
|
39
|
+
_ENABLE_PACKING = 251
|
|
40
|
+
_DISABLE_PACKING = 250
|
|
41
|
+
_RESET_ALL = 249
|
|
42
|
+
_ENABLE_NO_SPACES = 247
|
|
43
|
+
_DISABLE_NO_SPACES = 246
|
|
44
|
+
|
|
45
|
+
# G-line parameters, which the binariser is free to drop the space in front of.
|
|
46
|
+
_PARAMETERS = frozenset("XYZEFIJRSGPWHCA")
|
|
47
|
+
|
|
48
|
+
_CONFIG_MARKERS = {
|
|
49
|
+
"OrcaSlicer": ("; CONFIG_BLOCK_START", "; CONFIG_BLOCK_END"),
|
|
50
|
+
"BambuStudio": ("; CONFIG_BLOCK_START", "; CONFIG_BLOCK_END"),
|
|
51
|
+
"SuperSlicer": ("; SuperSlicer_config = begin", "; SuperSlicer_config = end"),
|
|
52
|
+
}
|
|
53
|
+
_DEFAULT_MARKERS = ("; prusaslicer_config = begin", "; prusaslicer_config = end")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BgcodeError(ValueError):
|
|
57
|
+
"""The file claims to be binary G-code but cannot be read as one."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_bgcode(raw: bytes) -> bool:
|
|
61
|
+
return raw[:4] == MAGIC
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def heatshrink(data: bytes, window_bits: int, lookahead_bits: int, size: int) -> bytes:
|
|
65
|
+
"""Decompress a heatshrink stream, stopping at ``size`` bytes.
|
|
66
|
+
|
|
67
|
+
Tokens are read most significant bit first: a 1 bit introduces an 8-bit literal, a 0
|
|
68
|
+
bit a backreference of ``window_bits`` index and ``lookahead_bits`` count, both stored
|
|
69
|
+
one less than their real value. The final byte is zero-padded, so the caller's
|
|
70
|
+
uncompressed size is what ends the stream rather than running out of bits.
|
|
71
|
+
"""
|
|
72
|
+
out = bytearray()
|
|
73
|
+
append = out.append
|
|
74
|
+
acc = held = pos = 0
|
|
75
|
+
end_of_data = len(data)
|
|
76
|
+
# The widest token is the tag plus a whole backreference; refilling to that once
|
|
77
|
+
# per token keeps the bit twiddling out of a helper, which this loop runs millions
|
|
78
|
+
# of times on a real file.
|
|
79
|
+
wanted = 1 + max(8, window_bits + lookahead_bits)
|
|
80
|
+
index_mask = (1 << window_bits) - 1
|
|
81
|
+
count_mask = (1 << lookahead_bits) - 1
|
|
82
|
+
|
|
83
|
+
while len(out) < size:
|
|
84
|
+
while held < wanted and pos < end_of_data:
|
|
85
|
+
acc = (acc << 8) | data[pos]
|
|
86
|
+
pos += 1
|
|
87
|
+
held += 8
|
|
88
|
+
if held < 1:
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
held -= 1
|
|
92
|
+
literal = (acc >> held) & 1
|
|
93
|
+
acc &= (1 << held) - 1
|
|
94
|
+
|
|
95
|
+
if literal:
|
|
96
|
+
if held < 8:
|
|
97
|
+
break
|
|
98
|
+
held -= 8
|
|
99
|
+
append((acc >> held) & 0xFF)
|
|
100
|
+
acc &= (1 << held) - 1
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
if held < window_bits + lookahead_bits:
|
|
104
|
+
break
|
|
105
|
+
held -= window_bits
|
|
106
|
+
index = (acc >> held) & index_mask
|
|
107
|
+
held -= lookahead_bits
|
|
108
|
+
count = ((acc >> held) & count_mask) + 1
|
|
109
|
+
acc &= (1 << held) - 1
|
|
110
|
+
|
|
111
|
+
start = len(out) - index - 1
|
|
112
|
+
if start < 0:
|
|
113
|
+
raise BgcodeError("backreference points before the start of the stream")
|
|
114
|
+
stop = start + count
|
|
115
|
+
if stop <= len(out):
|
|
116
|
+
out += out[start:stop]
|
|
117
|
+
else:
|
|
118
|
+
# The match runs into what it is producing, which is how a run of one
|
|
119
|
+
# repeated byte is stored. It has to be copied a byte at a time.
|
|
120
|
+
for i in range(start, stop):
|
|
121
|
+
append(out[i])
|
|
122
|
+
|
|
123
|
+
if len(out) < size:
|
|
124
|
+
raise BgcodeError(f"stream ended early, got {len(out)} of {size} bytes")
|
|
125
|
+
return bytes(out[:size])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def meatpack(data: bytes, respace: bool) -> str:
|
|
129
|
+
"""Unpack a MeatPack stream, optionally putting the G-line spaces back."""
|
|
130
|
+
chars: list[str] = []
|
|
131
|
+
packing = no_spaces = command = False
|
|
132
|
+
signals = pending = 0
|
|
133
|
+
held: str | None = None
|
|
134
|
+
|
|
135
|
+
def unpack(nibble: int) -> str:
|
|
136
|
+
return "E" if nibble == 0xB and no_spaces else _PACKED[nibble]
|
|
137
|
+
|
|
138
|
+
def receive(byte: int) -> None:
|
|
139
|
+
nonlocal pending, held
|
|
140
|
+
if not packing:
|
|
141
|
+
chars.append(chr(byte))
|
|
142
|
+
return
|
|
143
|
+
if pending:
|
|
144
|
+
# A full-width character owed from the previous byte. If its partner was
|
|
145
|
+
# packed it was decoded already and has been waiting for this one.
|
|
146
|
+
chars.append(chr(byte))
|
|
147
|
+
if held is not None:
|
|
148
|
+
chars.append(held)
|
|
149
|
+
held = None
|
|
150
|
+
pending -= 1
|
|
151
|
+
return
|
|
152
|
+
low, high = byte & 0xF, byte >> 4
|
|
153
|
+
if low == 0xF:
|
|
154
|
+
pending += 1
|
|
155
|
+
if high == 0xF:
|
|
156
|
+
pending += 1
|
|
157
|
+
else:
|
|
158
|
+
held = unpack(high)
|
|
159
|
+
return
|
|
160
|
+
first = unpack(low)
|
|
161
|
+
chars.append(first)
|
|
162
|
+
if first == "\n":
|
|
163
|
+
return
|
|
164
|
+
if high == 0xF:
|
|
165
|
+
pending += 1
|
|
166
|
+
else:
|
|
167
|
+
chars.append(unpack(high))
|
|
168
|
+
|
|
169
|
+
for byte in data:
|
|
170
|
+
if byte == _SIGNAL:
|
|
171
|
+
if signals:
|
|
172
|
+
command, signals = True, 0
|
|
173
|
+
else:
|
|
174
|
+
signals = 1
|
|
175
|
+
continue
|
|
176
|
+
if command:
|
|
177
|
+
if byte == _ENABLE_PACKING:
|
|
178
|
+
packing = True
|
|
179
|
+
elif byte in (_DISABLE_PACKING, _RESET_ALL):
|
|
180
|
+
packing = False
|
|
181
|
+
elif byte == _ENABLE_NO_SPACES:
|
|
182
|
+
no_spaces = True
|
|
183
|
+
elif byte == _DISABLE_NO_SPACES:
|
|
184
|
+
no_spaces = False
|
|
185
|
+
command = False
|
|
186
|
+
continue
|
|
187
|
+
if signals:
|
|
188
|
+
receive(_SIGNAL)
|
|
189
|
+
signals = 0
|
|
190
|
+
receive(byte)
|
|
191
|
+
|
|
192
|
+
return _respace(chars) if respace else "".join(chars)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _respace(chars: list[str]) -> str:
|
|
196
|
+
"""Re-insert the spaces between G-line parameters that the binariser dropped."""
|
|
197
|
+
out: list[str] = []
|
|
198
|
+
spacing = False
|
|
199
|
+
for char in chars:
|
|
200
|
+
starts_line = not out or out[-1] == "\n"
|
|
201
|
+
opened = False
|
|
202
|
+
if char == "G" and starts_line:
|
|
203
|
+
spacing = opened = True
|
|
204
|
+
elif char == "\n":
|
|
205
|
+
spacing = False
|
|
206
|
+
if not opened and spacing and char in _PARAMETERS and (not out or out[-1] != " "):
|
|
207
|
+
out.append(" ")
|
|
208
|
+
if char != "\n" or not out or out[-1] != "\n":
|
|
209
|
+
out.append(char)
|
|
210
|
+
return "".join(out)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _blocks(raw: bytes):
|
|
214
|
+
"""Yield ``(type, parameter, payload)`` for every block, decompressed."""
|
|
215
|
+
if len(raw) < 10:
|
|
216
|
+
raise BgcodeError("file is too short to hold a header")
|
|
217
|
+
magic, version, checksum = struct.unpack_from("<4sIH", raw, 0)
|
|
218
|
+
if magic != MAGIC:
|
|
219
|
+
raise BgcodeError("missing the GCDE magic number")
|
|
220
|
+
if version != 1:
|
|
221
|
+
raise BgcodeError(f"version {version} is not supported, only version 1 is")
|
|
222
|
+
if checksum > 1:
|
|
223
|
+
raise BgcodeError(f"unknown checksum type {checksum}")
|
|
224
|
+
|
|
225
|
+
at = 10
|
|
226
|
+
while at < len(raw):
|
|
227
|
+
try:
|
|
228
|
+
btype, compression = struct.unpack_from("<HH", raw, at)
|
|
229
|
+
at += 4
|
|
230
|
+
(size,) = struct.unpack_from("<I", raw, at)
|
|
231
|
+
at += 4
|
|
232
|
+
stored = size
|
|
233
|
+
if compression:
|
|
234
|
+
(stored,) = struct.unpack_from("<I", raw, at)
|
|
235
|
+
at += 4
|
|
236
|
+
if btype == _THUMBNAIL:
|
|
237
|
+
parameter = 0
|
|
238
|
+
at += 6
|
|
239
|
+
else:
|
|
240
|
+
(parameter,) = struct.unpack_from("<H", raw, at)
|
|
241
|
+
at += 2
|
|
242
|
+
except struct.error as exc:
|
|
243
|
+
raise BgcodeError("truncated block header") from exc
|
|
244
|
+
|
|
245
|
+
payload = raw[at : at + stored]
|
|
246
|
+
if len(payload) < stored:
|
|
247
|
+
raise BgcodeError("truncated block data")
|
|
248
|
+
at += stored + (4 if checksum else 0)
|
|
249
|
+
|
|
250
|
+
if btype == _THUMBNAIL:
|
|
251
|
+
continue
|
|
252
|
+
if compression == _DEFLATE:
|
|
253
|
+
try:
|
|
254
|
+
payload = zlib.decompress(payload)
|
|
255
|
+
except zlib.error as exc:
|
|
256
|
+
raise BgcodeError(f"deflate block would not decompress ({exc})") from exc
|
|
257
|
+
elif compression in (_HEATSHRINK_11_4, _HEATSHRINK_12_4):
|
|
258
|
+
window = 11 if compression == _HEATSHRINK_11_4 else 12
|
|
259
|
+
payload = heatshrink(payload, window, 4, size)
|
|
260
|
+
elif compression:
|
|
261
|
+
raise BgcodeError(f"unknown compression algorithm {compression}")
|
|
262
|
+
yield btype, parameter, payload
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def decode(raw: bytes) -> str:
|
|
266
|
+
"""Turn binary G-code into the text file the same slice would have produced."""
|
|
267
|
+
gcode: list[str] = []
|
|
268
|
+
producer = ""
|
|
269
|
+
totals: list[str] = []
|
|
270
|
+
config: list[str] = []
|
|
271
|
+
|
|
272
|
+
for btype, parameter, payload in _blocks(raw):
|
|
273
|
+
if btype == _GCODE:
|
|
274
|
+
if parameter in (_MEATPACK, _MEATPACK_COMMENTS):
|
|
275
|
+
gcode.append(meatpack(payload, parameter == _MEATPACK_COMMENTS))
|
|
276
|
+
else:
|
|
277
|
+
gcode.append(payload.decode("utf-8", errors="replace"))
|
|
278
|
+
continue
|
|
279
|
+
text = payload.decode("utf-8", errors="replace")
|
|
280
|
+
if btype == _FILE_METADATA:
|
|
281
|
+
producer = _entries(text).get("Producer", "")
|
|
282
|
+
elif btype == _PRINT_METADATA:
|
|
283
|
+
totals = [f"; {key} = {value}" for key, value in _entries(text).items()]
|
|
284
|
+
elif btype == _SLICER_METADATA:
|
|
285
|
+
config = [f"; {key} = {value}" for key, value in _entries(text).items()]
|
|
286
|
+
|
|
287
|
+
if not gcode:
|
|
288
|
+
raise BgcodeError("no G-code blocks in the file")
|
|
289
|
+
|
|
290
|
+
lines = "".join(gcode).splitlines()
|
|
291
|
+
lines += totals
|
|
292
|
+
if config:
|
|
293
|
+
begin, end = _DEFAULT_MARKERS
|
|
294
|
+
for name, markers in _CONFIG_MARKERS.items():
|
|
295
|
+
if producer.startswith(name):
|
|
296
|
+
begin, end = markers
|
|
297
|
+
lines += ["", begin, *config, end]
|
|
298
|
+
return "\n".join(lines) + "\n"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _entries(text: str) -> dict[str, str]:
|
|
302
|
+
entries: dict[str, str] = {}
|
|
303
|
+
for line in text.splitlines():
|
|
304
|
+
key, sep, value = line.partition("=")
|
|
305
|
+
if sep:
|
|
306
|
+
entries[key.strip()] = value.strip()
|
|
307
|
+
return entries
|
vaseweld/cli.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Command line entry point for vaseweld."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .compat import CompatError, check_compatible
|
|
12
|
+
from .parser import GcodeError, parse_file
|
|
13
|
+
from .preview import write as write_preview
|
|
14
|
+
from .validate import check as run_check
|
|
15
|
+
from .weld import WeldError, weld
|
|
16
|
+
|
|
17
|
+
EXIT_OK = 0
|
|
18
|
+
EXIT_PROBLEMS = 1
|
|
19
|
+
EXIT_USAGE = 2
|
|
20
|
+
|
|
21
|
+
_EPILOG = r"""Slice the same plate twice, once with Spiral Vase off and once on, then:
|
|
22
|
+
|
|
23
|
+
vaseweld layers body.gcode
|
|
24
|
+
vaseweld weld --normal base.gcode --vase body.gcode --at 12.4 -o out.gcode
|
|
25
|
+
vaseweld check out.gcode
|
|
26
|
+
vaseweld preview out.gcode
|
|
27
|
+
|
|
28
|
+
Repeat --at to alternate again, so a solid base, a vase body and a solid lid is:
|
|
29
|
+
|
|
30
|
+
vaseweld weld --normal base.gcode --vase body.gcode --at 12.4 --at 30 -o out.gcode
|
|
31
|
+
|
|
32
|
+
The output always uses relative extrusion (M83). Absolute-E inputs are converted.
|
|
33
|
+
|
|
34
|
+
As a slicer post-processing script, leave out the side the slicer is producing and
|
|
35
|
+
let it append the temporary file path:
|
|
36
|
+
|
|
37
|
+
vaseweld weld --normal C:\prints\base.gcode --at 12.4
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
parser = argparse.ArgumentParser(
|
|
43
|
+
prog="vaseweld",
|
|
44
|
+
description="Weld two G-code files sliced from the same plate at a chosen Z height.",
|
|
45
|
+
epilog=_EPILOG,
|
|
46
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument("--version", action="version", version=f"vaseweld {__version__}")
|
|
49
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
50
|
+
|
|
51
|
+
weld_cmd = sub.add_parser(
|
|
52
|
+
"weld",
|
|
53
|
+
help="splice a normal slice and a vase slice at a Z height",
|
|
54
|
+
description="Splice a normal slice and a spiral vase slice at a Z height.",
|
|
55
|
+
)
|
|
56
|
+
weld_cmd.add_argument("--normal", type=Path, help="the non-vase slice")
|
|
57
|
+
weld_cmd.add_argument("--vase", type=Path, help="the spiral vase slice")
|
|
58
|
+
weld_cmd.add_argument(
|
|
59
|
+
"--at",
|
|
60
|
+
required=True,
|
|
61
|
+
type=float,
|
|
62
|
+
metavar="Z",
|
|
63
|
+
action="append",
|
|
64
|
+
help="cut height in mm; repeat it to alternate again, so two cuts give a "
|
|
65
|
+
"solid base, a vase body and a solid lid",
|
|
66
|
+
)
|
|
67
|
+
weld_cmd.add_argument("-o", "--output", type=Path, help="file to write")
|
|
68
|
+
weld_cmd.add_argument(
|
|
69
|
+
"gcode",
|
|
70
|
+
nargs="?",
|
|
71
|
+
type=Path,
|
|
72
|
+
help="the file a slicer post-processing hook just produced; it takes whichever "
|
|
73
|
+
"of --normal or --vase you left out, and is rewritten in place unless -o is given",
|
|
74
|
+
)
|
|
75
|
+
weld_cmd.add_argument(
|
|
76
|
+
"--vase-first",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="start with the vase part below the first cut instead of the normal part",
|
|
79
|
+
)
|
|
80
|
+
weld_cmd.add_argument(
|
|
81
|
+
"--start-flow",
|
|
82
|
+
type=float,
|
|
83
|
+
metavar="RATIO",
|
|
84
|
+
help="flow ratio the vase transition layer ramps up from "
|
|
85
|
+
"(default: spiral_starting_flow_ratio from the vase file, else 0.8)",
|
|
86
|
+
)
|
|
87
|
+
weld_cmd.add_argument(
|
|
88
|
+
"--finish-flow",
|
|
89
|
+
type=float,
|
|
90
|
+
metavar="RATIO",
|
|
91
|
+
help="flow ratio the last vase layer ramps down to "
|
|
92
|
+
"(default: spiral_finishing_flow_ratio from the vase file, else 0.25)",
|
|
93
|
+
)
|
|
94
|
+
weld_cmd.add_argument(
|
|
95
|
+
"--no-seam-retract",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help="do not retract before the seam travel; the retraction state is still matched",
|
|
98
|
+
)
|
|
99
|
+
weld_cmd.add_argument(
|
|
100
|
+
"--dry-run",
|
|
101
|
+
action="store_true",
|
|
102
|
+
help="report the plan and write nothing",
|
|
103
|
+
)
|
|
104
|
+
weld_cmd.add_argument(
|
|
105
|
+
"--force",
|
|
106
|
+
action="store_true",
|
|
107
|
+
help="weld even if the two files disagree on printer or plate settings",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
preview_cmd = sub.add_parser(
|
|
111
|
+
"preview",
|
|
112
|
+
help="write a self-contained HTML page you can open and scrub through",
|
|
113
|
+
description="Draw the toolpath into one HTML file: no server, no dependencies.",
|
|
114
|
+
)
|
|
115
|
+
preview_cmd.add_argument("file", type=Path, help="G-code file to draw")
|
|
116
|
+
preview_cmd.add_argument("-o", "--output", type=Path, help="defaults to FILE.html")
|
|
117
|
+
|
|
118
|
+
layers_cmd = sub.add_parser(
|
|
119
|
+
"layers",
|
|
120
|
+
help="list the layer heights a file can be cut at",
|
|
121
|
+
description="Report the Z ladder, so you can pick a cut without guessing.",
|
|
122
|
+
)
|
|
123
|
+
layers_cmd.add_argument("file", type=Path, help="G-code file to inspect")
|
|
124
|
+
layers_cmd.add_argument(
|
|
125
|
+
"--all", action="store_true", help="print every layer, not just the summary"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
check_cmd = sub.add_parser(
|
|
129
|
+
"check",
|
|
130
|
+
help="verify a G-code file is coherent enough to print",
|
|
131
|
+
description="Verify Z, extrusion, retractions and the temperature timeline.",
|
|
132
|
+
)
|
|
133
|
+
check_cmd.add_argument("file", type=Path, help="G-code file to check")
|
|
134
|
+
return parser
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _validate_flow(name: str, value: float | None) -> None:
|
|
138
|
+
if value is not None and not 0.0 <= value <= 1.0:
|
|
139
|
+
raise WeldError(f"{name} must be between 0 and 1, got {value}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _resolve_inputs(args: argparse.Namespace) -> tuple[Path, Path, Path]:
|
|
143
|
+
"""Work out the two inputs and the destination, honouring a trailing hook file."""
|
|
144
|
+
normal, vase, output = args.normal, args.vase, args.output
|
|
145
|
+
if args.gcode is not None:
|
|
146
|
+
if normal is not None and vase is not None:
|
|
147
|
+
raise WeldError(
|
|
148
|
+
f"--normal and --vase are both set, so {args.gcode} has no role. "
|
|
149
|
+
"Drop one of them when a slicer passes the file it just wrote."
|
|
150
|
+
)
|
|
151
|
+
if normal is None:
|
|
152
|
+
normal = args.gcode
|
|
153
|
+
else:
|
|
154
|
+
vase = args.gcode
|
|
155
|
+
output = output or args.gcode
|
|
156
|
+
if normal is None or vase is None:
|
|
157
|
+
raise WeldError(
|
|
158
|
+
"need both slices: pass --normal and --vase, or pass one of them plus the "
|
|
159
|
+
"file a slicer post-processing hook hands over as the last argument"
|
|
160
|
+
)
|
|
161
|
+
if output is None:
|
|
162
|
+
raise WeldError("no destination: pass -o/--output")
|
|
163
|
+
if output.suffix.lower() == ".bgcode":
|
|
164
|
+
raise WeldError(
|
|
165
|
+
f"{output}: vaseweld reads binary G-code but cannot write it. "
|
|
166
|
+
"Give -o a .gcode name, or turn off 'Supports binary G-code' in "
|
|
167
|
+
"Print Settings > Output options on the profile that runs this hook. "
|
|
168
|
+
"Printers that take .bgcode take plain G-code too."
|
|
169
|
+
)
|
|
170
|
+
return normal, vase, output
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _run_weld(args: argparse.Namespace, out: "object") -> int:
|
|
174
|
+
_validate_flow("--start-flow", args.start_flow)
|
|
175
|
+
_validate_flow("--finish-flow", args.finish_flow)
|
|
176
|
+
normal_path, vase_path, output = _resolve_inputs(args)
|
|
177
|
+
|
|
178
|
+
normal = parse_file(normal_path)
|
|
179
|
+
vase = parse_file(vase_path)
|
|
180
|
+
try:
|
|
181
|
+
check_compatible(normal, vase)
|
|
182
|
+
except CompatError as exc:
|
|
183
|
+
if not args.force:
|
|
184
|
+
raise
|
|
185
|
+
print(f"warning: {exc}", file=sys.stderr)
|
|
186
|
+
print("warning: continuing because --force was given", file=sys.stderr)
|
|
187
|
+
|
|
188
|
+
result = weld(
|
|
189
|
+
normal,
|
|
190
|
+
vase,
|
|
191
|
+
args.at,
|
|
192
|
+
first_role="vase" if args.vase_first else "normal",
|
|
193
|
+
start_flow=args.start_flow,
|
|
194
|
+
finish_flow=args.finish_flow,
|
|
195
|
+
seam_retract=not args.no_seam_retract,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if not args.dry_run:
|
|
199
|
+
text = result.newline.join(result.lines) + result.newline
|
|
200
|
+
try:
|
|
201
|
+
output.write_text(text, encoding="utf-8", newline="")
|
|
202
|
+
except OSError as exc:
|
|
203
|
+
raise GcodeError(f"{output}: cannot write ({exc.strerror or exc})") from exc
|
|
204
|
+
|
|
205
|
+
for line in result.summary():
|
|
206
|
+
print(line, file=out)
|
|
207
|
+
verb = "would write" if args.dry_run else "wrote"
|
|
208
|
+
print(f"{verb} {output} ({len(result.lines)} lines)", file=out)
|
|
209
|
+
for warning in result.warnings:
|
|
210
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
211
|
+
if result.stats_removed:
|
|
212
|
+
print(
|
|
213
|
+
"note: removed "
|
|
214
|
+
+ " and ".join(result.stats_removed)
|
|
215
|
+
+ ", which cannot be recomputed from these two files",
|
|
216
|
+
file=sys.stderr,
|
|
217
|
+
)
|
|
218
|
+
destination = os.environ.get("SLIC3R_PP_OUTPUT_NAME")
|
|
219
|
+
if destination:
|
|
220
|
+
print(f"slicer will save it as {destination}", file=out)
|
|
221
|
+
return EXIT_OK
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _run_preview(args: argparse.Namespace, out: "object") -> int:
|
|
225
|
+
destination = args.output or args.file.with_suffix(".html")
|
|
226
|
+
try:
|
|
227
|
+
written = write_preview(destination, args.file)
|
|
228
|
+
except OSError as exc:
|
|
229
|
+
raise GcodeError(f"{destination}: cannot write ({exc.strerror or exc})") from exc
|
|
230
|
+
size = written.stat().st_size / 1024
|
|
231
|
+
print(f"wrote {written} ({size:.0f} KB), open it in any browser", file=out)
|
|
232
|
+
return EXIT_OK
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _run_layers(args: argparse.Namespace, out: "object") -> int:
|
|
236
|
+
gcode = parse_file(args.file)
|
|
237
|
+
zs = [layer.z for layer in gcode.layers]
|
|
238
|
+
steps = {round(b - a, 4) for a, b in zip(zs, zs[1:])}
|
|
239
|
+
print(f"{gcode.path.name}: {len(zs)} layers, Z {zs[0]:.3f} to {zs[-1]:.3f}", file=out)
|
|
240
|
+
if len(steps) == 1:
|
|
241
|
+
print(f"layer height: {next(iter(steps)):.3f} mm", file=out)
|
|
242
|
+
else:
|
|
243
|
+
print(
|
|
244
|
+
f"layer height: varies, {min(steps):.3f} to {max(steps):.3f} mm. "
|
|
245
|
+
"Slice both files at a fixed layer height before welding.",
|
|
246
|
+
file=out,
|
|
247
|
+
)
|
|
248
|
+
print(
|
|
249
|
+
f"weldable range: Z {zs[1]:.3f} to {zs[-1]:.3f} (layers 2 to {len(zs)})",
|
|
250
|
+
file=out,
|
|
251
|
+
)
|
|
252
|
+
if args.all:
|
|
253
|
+
for layer in gcode.layers:
|
|
254
|
+
print(f" layer {layer.index:4d} Z {layer.z:.3f}", file=out)
|
|
255
|
+
return EXIT_OK
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _run_check(args: argparse.Namespace, out: "object") -> int:
|
|
259
|
+
report = run_check(args.file)
|
|
260
|
+
print(report.summary(), file=out)
|
|
261
|
+
for problem in report.problems:
|
|
262
|
+
print(problem, file=out)
|
|
263
|
+
return EXIT_OK if report.ok else EXIT_PROBLEMS
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main(argv: list[str] | None = None, out: "object" = None) -> int:
|
|
267
|
+
out = out or sys.stdout
|
|
268
|
+
args = _build_parser().parse_args(argv)
|
|
269
|
+
try:
|
|
270
|
+
if args.command == "weld":
|
|
271
|
+
return _run_weld(args, out)
|
|
272
|
+
if args.command == "layers":
|
|
273
|
+
return _run_layers(args, out)
|
|
274
|
+
if args.command == "preview":
|
|
275
|
+
return _run_preview(args, out)
|
|
276
|
+
return _run_check(args, out)
|
|
277
|
+
except (GcodeError, CompatError, WeldError) as exc:
|
|
278
|
+
print(f"vaseweld: {exc}", file=sys.stderr)
|
|
279
|
+
return EXIT_USAGE
|
|
280
|
+
except KeyboardInterrupt:
|
|
281
|
+
print("vaseweld: interrupted", file=sys.stderr)
|
|
282
|
+
return EXIT_USAGE
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
if __name__ == "__main__":
|
|
286
|
+
sys.exit(main())
|