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 ADDED
@@ -0,0 +1,5 @@
1
+ """Weld two G-code files sliced from the same plate at a chosen Z height."""
2
+
3
+ __version__ = "1.3.0"
4
+
5
+ __all__ = ["__version__"]
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())