layer-rescue 1.0.1__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.
@@ -0,0 +1,34 @@
1
+ """Layer Rescue: conservative G-code recovery for interrupted FDM prints."""
2
+
3
+ from ._version import __version__
4
+ from .core import (
5
+ Analysis,
6
+ LayerInfo,
7
+ ResumeError,
8
+ ResumeOptions,
9
+ ResumeReport,
10
+ ZReferenceMode,
11
+ analyze_gcode,
12
+ build_resume_gcode,
13
+ rewrite_gcode_file,
14
+ )
15
+ from .fileio import rewrite_with
16
+ from .insert import InsertOptions, InsertReport, build_insert_gcode, plan_insert
17
+
18
+ __all__ = [
19
+ "__version__",
20
+ "Analysis",
21
+ "LayerInfo",
22
+ "ResumeError",
23
+ "ResumeOptions",
24
+ "ResumeReport",
25
+ "ZReferenceMode",
26
+ "analyze_gcode",
27
+ "build_resume_gcode",
28
+ "rewrite_gcode_file",
29
+ "rewrite_with",
30
+ "InsertOptions",
31
+ "InsertReport",
32
+ "build_insert_gcode",
33
+ "plan_insert",
34
+ ]
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ __version__ = "1.0.1"
Binary file
Binary file
layer_rescue/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .core import ResumeError, ResumeOptions, ZReferenceMode, analyze_gcode, rewrite_gcode_file
10
+ from .fileio import rewrite_with
11
+
12
+
13
+ def _parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="layer-rescue",
16
+ description="Create conservative layer-resume G-code for interrupted Bambu Lab P1S prints.",
17
+ )
18
+ parser.add_argument("gcode", nargs="?", help="G-code path; Bambu Studio appends this automatically")
19
+ selection = parser.add_mutually_exclusive_group()
20
+ selection.add_argument("--start-layer", type=int, help="first layer to print")
21
+ selection.add_argument("--last-layer", type=int, help="last successfully printed layer")
22
+ selection.add_argument(
23
+ "--part-height",
24
+ type=float,
25
+ help="insert mode: measured height (mm) of a loose part to seat in a printed wall and print on top of",
26
+ )
27
+ selection.add_argument("--part-layer", type=int, help="insert mode: last source layer the loose part contains")
28
+ parser.add_argument("--gui", action="store_true", help="open the graphical layer selector")
29
+ parser.add_argument("--analyze", action="store_true", help="print source information without modifying it")
30
+ parser.add_argument(
31
+ "--z-mode",
32
+ choices=[mode.value for mode in ZReferenceMode],
33
+ help="Z reference: retained after uninterrupted power, or manual after a restart",
34
+ )
35
+ parser.add_argument(
36
+ "--confirm-manual-z-aligned",
37
+ action="store_true",
38
+ help="confirm that the nozzle was aligned to touch the last successful layer",
39
+ )
40
+ parser.add_argument(
41
+ "--assume-z-known",
42
+ action="store_true",
43
+ help="deprecated alias for --z-mode retained",
44
+ )
45
+ parser.add_argument(
46
+ "--allow-untested",
47
+ action="store_true",
48
+ help="accept the risks and continue with a printer or multi-filament job Layer Rescue has not been tested on",
49
+ )
50
+ parser.add_argument("--no-home-corexy", action="store_true", help="do not emit the P1S G28 X CoreXY home")
51
+ parser.add_argument("--no-backup", action="store_true", help="do not create a sibling .bak file")
52
+ parser.add_argument("--nozzle-temp", type=int, help="override detected nozzle temperature")
53
+ parser.add_argument("--bed-temp", type=int, help="override detected bed temperature")
54
+ parser.add_argument("--z-lift", type=float, default=2.0, help="relative safety lift before CoreXY motion (default: 2.0)")
55
+ insert = parser.add_argument_group("insert mode (a loose part seated in a printed wall)")
56
+ insert.add_argument("--wall-height", type=float, help="holding wall height in mm (default: recommended)")
57
+ insert.add_argument("--clearance", type=float, default=0.25, help="gap between part and wall in mm (default: 0.25)")
58
+ insert.add_argument("--wall-lines", type=int, default=4, help="wall thickness in lines (default: 4)")
59
+ insert.add_argument("--brim", type=float, default=5.0, help="brim width around the wall in mm (default: 5)")
60
+ insert.add_argument("--no-chamfer", action="store_true", help="no lead-in chamfer at the wall rim")
61
+ insert.add_argument("--z-fine", type=float, default=0.0, help="added to the part height; negative squishes more")
62
+ insert.add_argument("--standby-temp", type=int, default=140, help="nozzle °C while paused, 0 = keep (default: 140)")
63
+ insert.add_argument("--adhesion-layers", type=int, default=2, help="hotter/slower/no-fan layers on the part (default: 2)")
64
+ insert.add_argument(
65
+ "--confirm-attended",
66
+ action="store_true",
67
+ help="confirm you will seat the part when the printer pauses and watch the first layers",
68
+ )
69
+ insert.add_argument(
70
+ "--no-reprint-supports",
71
+ action="store_true",
72
+ help="do not print the supports below the part height before the pause",
73
+ )
74
+ insert.add_argument("--preview-svg", help="insert mode: also write a top-view SVG of the wall to this path")
75
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
76
+ return parser
77
+
78
+
79
+ def _analysis_json(path: Path) -> str:
80
+ from .machine_state import untested_setup
81
+
82
+ analysis = analyze_gcode(path.read_text(encoding="utf-8", errors="replace"))
83
+ return json.dumps(
84
+ {
85
+ "file": str(path),
86
+ "printer_model": analysis.printer_model,
87
+ "filament_type": analysis.filament_type,
88
+ "first_layer": analysis.first_layer,
89
+ "last_layer": analysis.last_layer,
90
+ "total_layers": analysis.total_layers,
91
+ "first_z": analysis.layers[0].z,
92
+ "last_z": analysis.layers[-1].z,
93
+ "sha256": analysis.source_sha256,
94
+ "warnings": analysis.warnings,
95
+ "untested_setup": untested_setup(analysis),
96
+ },
97
+ indent=2,
98
+ )
99
+
100
+
101
+ def _run_insert(args: argparse.Namespace, path: Path) -> int:
102
+ from .insert import InsertOptions, build_insert_gcode, plan_insert
103
+
104
+ if not args.confirm_attended:
105
+ raise ResumeError(
106
+ "Insert mode requires --confirm-attended: you must seat the part when the printer pauses "
107
+ "and watch the first layers."
108
+ )
109
+ part_height = args.part_height
110
+ if part_height is None:
111
+ analysis = analyze_gcode(path.read_text(encoding="utf-8", errors="replace"))
112
+ part_height = analysis.layer(args.part_layer).z
113
+ options = InsertOptions(
114
+ part_height_mm=part_height,
115
+ wall_height_mm=args.wall_height,
116
+ clearance_mm=args.clearance,
117
+ wall_lines=args.wall_lines,
118
+ brim_mm=args.brim,
119
+ chamfer=not args.no_chamfer,
120
+ z_fine_mm=args.z_fine,
121
+ standby_temperature=args.standby_temp,
122
+ adhesion_layers=args.adhesion_layers,
123
+ reprint_supports=not args.no_reprint_supports,
124
+ allow_untested=args.allow_untested,
125
+ z_lift_mm=args.z_lift,
126
+ nozzle_temperature=args.nozzle_temp,
127
+ bed_temperature=args.bed_temp,
128
+ )
129
+ if args.preview_svg:
130
+ from .preview import plan_svg
131
+
132
+ plan = plan_insert(path.read_text(encoding="utf-8", errors="replace"), options)
133
+ Path(args.preview_svg).write_text(plan_svg(plan), encoding="utf-8")
134
+ report, backup = rewrite_with(
135
+ path, lambda text: build_insert_gcode(text, options), create_backup=not args.no_backup
136
+ )
137
+ print(
138
+ json.dumps(
139
+ {
140
+ "status": "converted",
141
+ "mode": "insert",
142
+ "part_height_mm": report.part_height_mm,
143
+ "part_layer": report.part_layer,
144
+ "resume_layer": report.resume_layer,
145
+ "total_layers": report.total_layers,
146
+ "z_offset_mm": report.z_offset_mm,
147
+ "first_layer_thickness_mm": report.first_layer_thickness_mm,
148
+ "wall_layers": report.wall_layers,
149
+ "wall_top_z": report.wall_top_z,
150
+ "park_z": report.park_z,
151
+ "wall_filament_mm": report.wall_filament_mm,
152
+ "support_layers": report.support_layers,
153
+ "last_printed_layer": report.last_printed_layer,
154
+ "nozzle_temperature": report.nozzle_temperature,
155
+ "bed_temperature": report.bed_temperature,
156
+ "backup": str(backup) if backup else None,
157
+ "output_sha256": report.output_sha256,
158
+ "warnings": report.warnings,
159
+ },
160
+ indent=2,
161
+ )
162
+ )
163
+ return 0
164
+
165
+
166
+ def post_processing_command() -> str:
167
+ """The command to paste into Bambu Studio's Post-processing Scripts field."""
168
+ if getattr(sys, "frozen", False):
169
+ return f'"{sys.executable}"'
170
+ return f'"{sys.executable}" -m layer_rescue'
171
+
172
+
173
+ def main(argv: list[str] | None = None) -> int:
174
+ args = _parser().parse_args(argv)
175
+ if not args.gcode:
176
+ if getattr(sys, "frozen", False):
177
+ # Opened directly (double-click) instead of from Bambu Studio: explain the setup.
178
+ from .gui import show_setup_info
179
+
180
+ return show_setup_info(post_processing_command())
181
+ _parser().error("a G-code path is required")
182
+ path = Path(args.gcode).expanduser().resolve()
183
+
184
+ try:
185
+ if args.analyze:
186
+ print(_analysis_json(path))
187
+ return 0
188
+
189
+ if args.part_height is not None or args.part_layer is not None:
190
+ return _run_insert(args, path)
191
+
192
+ if args.gui or (args.start_layer is None and args.last_layer is None):
193
+ from .gui import launch
194
+
195
+ return launch(path)
196
+
197
+ if args.assume_z_known and args.z_mode not in {None, ZReferenceMode.RETAINED.value}:
198
+ raise ResumeError(
199
+ "--assume-z-known is an alias for --z-mode retained and cannot be combined with manual mode."
200
+ )
201
+ z_mode_value = args.z_mode or (ZReferenceMode.RETAINED.value if args.assume_z_known else None)
202
+ if z_mode_value is None:
203
+ raise ResumeError("Batch conversion requires --z-mode retained or --z-mode manual.")
204
+ z_mode = ZReferenceMode(z_mode_value)
205
+ if z_mode is ZReferenceMode.MANUAL and not args.confirm_manual_z_aligned:
206
+ raise ResumeError(
207
+ "Manual mode requires --confirm-manual-z-aligned after the nozzle touches the last successful layer."
208
+ )
209
+
210
+ start_layer = args.start_layer if args.start_layer is not None else args.last_layer + 1
211
+ report = rewrite_gcode_file(
212
+ path,
213
+ ResumeOptions(
214
+ start_layer=start_layer,
215
+ z_reference_mode=z_mode,
216
+ home_corexy=not args.no_home_corexy,
217
+ z_lift_mm=args.z_lift,
218
+ nozzle_temperature=args.nozzle_temp,
219
+ bed_temperature=args.bed_temp,
220
+ allow_untested=args.allow_untested,
221
+ ),
222
+ create_backup=not args.no_backup,
223
+ )
224
+ print(
225
+ json.dumps(
226
+ {
227
+ "status": "converted",
228
+ "start_layer": report.start_layer,
229
+ "total_layers": report.total_layers,
230
+ "start_z": report.start_z,
231
+ "nozzle_temperature": report.nozzle_temperature,
232
+ "bed_temperature": report.bed_temperature,
233
+ "z_reference_mode": report.z_reference_mode.value,
234
+ "reference_z": report.reference_z,
235
+ "backup": str(report.backup_path) if report.backup_path else None,
236
+ "output_sha256": report.output_sha256,
237
+ "warnings": report.warnings,
238
+ },
239
+ indent=2,
240
+ )
241
+ )
242
+ return 0
243
+ except (OSError, ResumeError) as exc:
244
+ print(f"layer-rescue: error: {exc}", file=sys.stderr)
245
+ return 2
246
+
247
+
248
+ if __name__ == "__main__":
249
+ raise SystemExit(main())
layer_rescue/core.py ADDED
@@ -0,0 +1,29 @@
1
+ """Backward-compatible facade; the implementation lives in the gcode, machine_state, resume and fileio modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .fileio import _available_backup_path, rewrite_gcode_file, rewrite_with
6
+ from .gcode import * # noqa: F401,F403
7
+ from .gcode import ( # noqa: F401
8
+ _code,
9
+ _command,
10
+ _find_layers,
11
+ _find_marker,
12
+ _first_number,
13
+ _format_number,
14
+ _next_extrusion_mode,
15
+ _parameter,
16
+ _parse_config,
17
+ )
18
+ from .machine_state import MachineState, _scan_machine_state, _validate_supported_source # noqa: F401
19
+ from .resume import * # noqa: F401,F403
20
+ from .resume import ( # noqa: F401
21
+ _first_layer_xy,
22
+ _format_z_delta,
23
+ _relative_z_move,
24
+ _relativize_z,
25
+ _resume_preamble,
26
+ _retraction_length,
27
+ _validate_output,
28
+ _z_reference_mode,
29
+ )
layer_rescue/fileio.py ADDED
@@ -0,0 +1,77 @@
1
+ """Atomic in-place rewriting of the G-code file Bambu Studio hands to the post-processor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from dataclasses import replace
9
+ from pathlib import Path
10
+ from typing import Callable, TypeVar
11
+
12
+ from .gcode import ResumeError
13
+ from .resume import ResumeOptions, ResumeReport, build_resume_gcode
14
+
15
+ R = TypeVar("R")
16
+
17
+
18
+ def _available_backup_path(path: Path) -> Path:
19
+ candidate = path.with_name(path.name + ".layer-rescue.bak")
20
+ counter = 1
21
+ while candidate.exists():
22
+ candidate = path.with_name(path.name + f".layer-rescue.{counter}.bak")
23
+ counter += 1
24
+ return candidate
25
+
26
+
27
+
28
+ def rewrite_with(
29
+ path: str | os.PathLike[str],
30
+ builder: Callable[[str], tuple[str, R]],
31
+ *,
32
+ create_backup: bool = True,
33
+ ) -> tuple[R, Path | None]:
34
+ """Run ``builder`` on the file's text and replace the file atomically, keeping a backup."""
35
+ source_path = Path(path).expanduser().resolve()
36
+ if not source_path.is_file():
37
+ raise ResumeError(f"G-code file does not exist: {source_path}")
38
+ if source_path.suffix.lower() not in {".gcode", ".gco"}:
39
+ raise ResumeError("Post-processing MVP accepts raw .gcode files only.")
40
+
41
+ text = source_path.read_text(encoding="utf-8", errors="replace")
42
+ output, report = builder(text)
43
+ backup_path: Path | None = None
44
+ if create_backup:
45
+ backup_path = _available_backup_path(source_path)
46
+ shutil.copy2(source_path, backup_path)
47
+
48
+ temp_name: str | None = None
49
+ try:
50
+ with tempfile.NamedTemporaryFile(
51
+ mode="w",
52
+ encoding="utf-8",
53
+ newline="",
54
+ dir=source_path.parent,
55
+ prefix=source_path.name + ".",
56
+ suffix=".tmp",
57
+ delete=False,
58
+ ) as handle:
59
+ temp_name = handle.name
60
+ handle.write(output)
61
+ handle.flush()
62
+ os.fsync(handle.fileno())
63
+ os.replace(temp_name, source_path)
64
+ finally:
65
+ if temp_name and Path(temp_name).exists():
66
+ Path(temp_name).unlink()
67
+ return report, backup_path
68
+
69
+
70
+ def rewrite_gcode_file(
71
+ path: str | os.PathLike[str],
72
+ options: ResumeOptions,
73
+ *,
74
+ create_backup: bool = True,
75
+ ) -> ResumeReport:
76
+ report, backup_path = rewrite_with(path, lambda text: build_resume_gcode(text, options), create_backup=create_backup)
77
+ return replace(report, backup_path=backup_path)
layer_rescue/flow.py ADDED
@@ -0,0 +1,108 @@
1
+ """Extrusion amounts and print speeds for toolpaths Layer Rescue generates itself."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Mapping
8
+
9
+ from .gcode import NUMBER_RE
10
+
11
+
12
+ def extrusion_per_mm(width: float, height: float, filament_diameter: float, flow_ratio: float = 1.0) -> float:
13
+ """Filament length per mm of path for a rounded-rectangle bead (the Slic3r/Bambu flow model)."""
14
+ if width <= 0 or height <= 0 or filament_diameter <= 0:
15
+ raise ValueError("width, height and filament diameter must be positive")
16
+ width = max(width, height)
17
+ bead_area = (width - height) * height + math.pi * (height / 2) ** 2
18
+ filament_area = math.pi * (filament_diameter / 2) ** 2
19
+ return bead_area / filament_area * flow_ratio
20
+
21
+
22
+ def _number(config: Mapping[str, str], key: str, *, relative_to: float | None = None) -> float | None:
23
+ value = config.get(key)
24
+ if not value:
25
+ return None
26
+ match = NUMBER_RE.search(value)
27
+ if not match:
28
+ return None
29
+ number = float(match.group(0))
30
+ if "%" in value.split(",")[0] and relative_to is not None:
31
+ return number / 100 * relative_to
32
+ return number
33
+
34
+
35
+ def _first(config: Mapping[str, str], *keys: str, default: float, relative_to: float | None = None) -> float:
36
+ for key in keys:
37
+ value = _number(config, key, relative_to=relative_to)
38
+ if value is not None and value > 0:
39
+ return value
40
+ return default
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class FlowSettings:
45
+ nozzle_diameter: float = 0.4
46
+ line_width: float = 0.42
47
+ first_layer_line_width: float = 0.5
48
+ filament_diameter: float = 1.75
49
+ flow_ratio: float = 0.98
50
+ wall_speed: float = 60.0 # mm/s
51
+ first_layer_speed: float = 30.0
52
+ travel_speed: float = 200.0
53
+ support_speed: float = 60.0
54
+ retraction_length: float = 0.8
55
+ retraction_speed: float = 30.0
56
+ z_hop: float = 0.4
57
+ min_layer_time: float = 8.0 # s
58
+ min_speed: float = 20.0
59
+
60
+ @property
61
+ def wall_feed(self) -> int:
62
+ return int(round(self.wall_speed * 60))
63
+
64
+ @property
65
+ def first_layer_feed(self) -> int:
66
+ return int(round(self.first_layer_speed * 60))
67
+
68
+ @property
69
+ def support_feed(self) -> int:
70
+ return int(round(self.support_speed * 60))
71
+
72
+ @property
73
+ def travel_feed(self) -> int:
74
+ return int(round(self.travel_speed * 60))
75
+
76
+ @property
77
+ def retraction_feed(self) -> int:
78
+ return int(round(self.retraction_speed * 60))
79
+
80
+
81
+ # The wall is a fixture, not a show piece: print it at moderate speeds whatever the profile says.
82
+ MAX_WALL_SPEED = 100.0
83
+ MAX_FIRST_LAYER_SPEED = 40.0
84
+ MAX_TRAVEL_SPEED = 300.0
85
+
86
+
87
+ def flow_settings_from_config(config: Mapping[str, str]) -> FlowSettings:
88
+ nozzle = _first(config, "nozzle_diameter", default=0.4)
89
+ return FlowSettings(
90
+ nozzle_diameter=nozzle,
91
+ line_width=_first(config, "outer_wall_line_width", "line_width", default=nozzle * 1.05, relative_to=nozzle),
92
+ first_layer_line_width=_first(
93
+ config, "initial_layer_line_width", "first_layer_extrusion_width", default=nozzle * 1.25, relative_to=nozzle
94
+ ),
95
+ filament_diameter=_first(config, "filament_diameter", default=1.75),
96
+ flow_ratio=min(max(_first(config, "filament_flow_ratio", "extrusion_multiplier", default=1.0), 0.5), 1.5),
97
+ wall_speed=min(_first(config, "outer_wall_speed", "external_perimeter_speed", default=60.0), MAX_WALL_SPEED),
98
+ first_layer_speed=min(
99
+ _first(config, "initial_layer_speed", "first_layer_speed", default=30.0), MAX_FIRST_LAYER_SPEED
100
+ ),
101
+ travel_speed=min(_first(config, "travel_speed", default=200.0), MAX_TRAVEL_SPEED),
102
+ support_speed=min(_first(config, "support_speed", default=60.0), MAX_WALL_SPEED),
103
+ retraction_length=min(_first(config, "retraction_length", default=0.8), 5.0),
104
+ retraction_speed=min(_first(config, "retraction_speed", default=30.0), 60.0),
105
+ z_hop=min(_first(config, "z_hop", "retract_lift_above", default=0.4), 2.0),
106
+ min_layer_time=_first(config, "slow_down_layer_time", "slowdown_below_layer_time", default=8.0),
107
+ min_speed=_first(config, "slow_down_min_speed", "min_print_speed", default=20.0),
108
+ )